[번역] 고성능 Go Workshop

2019-09-03

Note

원글은 High Performance Go Workshop입니다. 학습 차원에서 허락없이 번역하였습니다. 저작권 등의 문제가 있을 경우, 이메일로 연락주시길 바랍니다.

개요

이 워크샵의 목표는 Go 애플리케이션에서 성능 문제를 진단하고 해결하는 데 필요한 도구를 제공하는 것입니다.

하루 동안 작은 것부터 작업할 것입니다. 벤치마크를 작성하는 방법을 배우고, 작은 코드 조각을 프로파일링하는 등 그런 다음 한걸음 나와 엑스큐션 트레이서, 가비지 콜렉터, 실행 중인 애플리케이션 트레이싱에 대해 얘기하고자 합니다. 남는 시간에 질문을 하며 자신의 코드로 실험해 볼 수 있을 것입니다.

이 프리젠테이션의 최신 버전은 다음 사이트에서 제공됩니다. http://bit.ly/dotgo2019

일정

다음은 오늘의 개략적인 일정입니다.

시작 설명
09:00 환영 인사 그리고 소개
09:30 벤치마킹
10:45 휴식 (15분)
11:00 성능 측정 그리고 프로파일링
12:00 점심 (90분)
13:30 컴파일러 최적화
14:30 엑스큐션 트레이서
15:30 휴식 (15분)
15:45 메모리 그리고 가비지 콜렉터
16:15 팁과 트릭
16:30 연습
16:45 최종 질문 및 결론
17:00 맺음말

환영인사

안녕하세요, 환영합니다 🎉

이 워크샵은 Go 애플리케이션에서 성능 문제를 진단하고 해결하는 데 필요한 도구를 제공하려는 것이 목적입니다.

하루 동안 작은 것부터 작업할 것입니다. 벤치마크를 작성하는 방법을 배우고, 작은 코드 조각을 프로파일링하는 등 그런 다음 한걸음 나와 엑스큐션 트레이서, 가비지 콜렉터, 실행 중인 애플리케이션 트레이싱에 대해 얘기하고자 합니다. 남는 시간에 질문을 하며 자신의 코드로 실험해 볼 수 있을 것입니다.

강사

  • Dave Cheney dave@cheney.net

라이센스 및 내용물

이 워크숍은 데이브 체니(Dave Cheney)프란체스코 캄포이(Francesc Campoy)의 합작품입니다.

이 프레젠테이션은 Creative Commons At Attion-ShareAlike 4.0 International 라이센스에 따라 라이센스가 부여됩니다.

전제 조건

오늘 다운로드해야 할 몇 가지 소프트웨어들이 있습니다.

워크샵 저장소

https://github.com/davecheney/high-performance-go-workshop에서 이 문서에 대한 소스와 및 코드 샘플을 다운로드하세요.

노트북, 전원 공급 장치 등

워크샵 자료는 Go 1.12를 대상으로 합니다.

Go 1.12 다운로드

Go 1.13으로 이미 업그레이드했다면 좋습니다. Go 마이너 릴리즈마다 항상 최적화 선택에 따라 약간의 변경 사항이 있는데, 진행하면서 언급하겠습니다.

Graphviz

pprof 섹션에서 그래피즈 도구 모음과 함께 제공되는 dot 프로그램이 필요합니다.

  • Linux: [sudo] apt-get install graphviz
  • OSX:
    • MacPorts: sudo port install graphviz
    • Homebrew: brew install graphviz
  • Windows (테스트 안됨)

구글 크롬

엑스큐선 트레이서 (execution tracer) 섹션에서 구글 크롬이 필요합니다. Safari, Edge, Firefox 또는 IE 4.01에서는 작동하지 않습니다. 배터리에게 미안하다고 말해주세요. (역자주, 미국식 농담??)

구글 크롬 다운로드

프로파일링과 최적화를 위한 자신의 코드

오늘 남는 시간에는 배운 도구로 실험을 해보는 공개 세션이 될 것입니다.

한 가지 더

이건 강의가 아니라 대화입니다. 질문을 할 수 있는 많은 휴식 시간이 있을 겁니다.

만약 여러분이 무언가를 이해하지 못하거나, 여러분이 듣고 있는 것이 정확하지 않다고 생각된다면, 질문하세요.

1. 마이크로 프로세서의 과거, 현재, 그리고 미래

고성능 코드를 작성하는 것에 대한 워크샵입니다. 다른 워크샵에서는 디커플된 설계와 유지보수성에 대해 이야기하지만, 오늘은 성능에 대해 이야기하려고 합니다.

저는 오늘 컴퓨터의 진화의 역사에 대해 어떻게 생각하는지 그리고 왜 고성능 소프트웨어를 작성하는 것이 중요하다고 생각하는지에 대한 짧은 강의로 시작하고 싶습니다.

실제로 소프트웨어는 하드웨어에서 실행되므로 고성능 코드 작성에 대해 이야기하려면 먼저 코드를 실행하는 하드웨어에 대해 이야기해야 합니다.

1.1 기계적 공감

요즘 대중적으로 사용되는 용어가 있는데, 마틴 톰슨(Martin Thompson)이나 빌 케네디(Bill Kennedy) 같은 사람들이 “기계적 공감”에 대해 이야기하는 것을 들을 수 있습니다.

“기계적 공감”이라는 이름은 세계 3대 포뮬라1 챔피언인 위대한 경주 자동차 드라이버인 재키 스튜어트(Jackie Stewart)로부터 유래했습니다. 그는 최고의 운전자는 기계가 어떻게 작동하는지 충분히 이해하고 있어서 기계와 조화를 이룰 수 있다고 믿었습니다.

훌륭한 경주용 자동차 드라이버가 되기 위해서는 훌륭한 정비사가 될 필요는 없지만, 자동차 작동 방식에 대한 호기심 이상의 이해가 필요합니다.

소프트웨어 엔지니어들도 마찬가지라고 생각합니다. 저는 이 자리에 계신 여러분 중 누구도 전문적인 CPU 디자이너가 되지는 않을 것이라고 생각합니다. 하지만 그렇다고 해서 CPU 디자이너들이 직면하고 있는 문제들을 무시할 수는 없습니다.

1.2 10의 6승

다음과 같은 일반적인 인터넷 밈이 있습니다.

물론 말도 안 되는 소리이지만, 이는 컴퓨터 업계에서 얼마나 많은 변화가 일어났는지를 잘 보여주고 있습니다.

이 자리에 모인 소프트웨어 저자들은 모두 무어의 법칙의 혜택을 받아왔기 때문에, 40년 동안 18개월마다 칩에서 사용할 수 있는 트랜지스터의 수가 두 배로 증가했습니다. 평생동안 툴이 10의 6승 만큼 개선된 산업 분야는 없습니다.

하지만 이 모든 것이 변하고 있습니다.

1.3 컴퓨터는 여전히 더 빨라지고 있나요?

그래서 근본적인 질문은, 위의 그림에서와 같은 통계와 마주치게 됩니다. 컴퓨터가 여전히 더 빨라지고 있는지 물어봐야 할까요?

컴퓨터가 여전히 빨라지고 있다면, 코드 성능에 신경 쓸 필요가 없습니다. 조금만 기다리면 하드웨어 제조업체가 성능 문제를 해결할테니깐요.

1.3.1 데이터를 살펴봅시다.

다음은 컴퓨터 아키텍처, 존 해네시(John L. Hennessy)의 정량적 접근(A Quantitative Approach, David A. Patterson)과 같은 교과서에서 볼 수 있는 고전적인 데이터입니다. 이 그래프는 5판에서 가져왔습니다.

5판에서 헤네시(Hennessey)와 패터슨(Patterson)은 세 가지 컴퓨팅 성능 시대가 있다고 주장합니다.

  • 첫 번째는 1970년대와 80년대 초였으며, 형성기였습니다. 오늘날 우리가 알고 있는 마이크로프로세서는 실제로 존재하지 않았습니다. 컴퓨터는 개별 트랜지스터나 소형 집적회로에서 제작되었습니다. 재료 과학의 비용, 크기, 그리고 이해의 한계가 제한 요인이었습니다.

  • 80년대 중반부터 2004년까지 추세선은 명확합니다. 컴퓨터 정수 성능이 매년 평균 52% 향상되었습니다. 컴퓨터 전력은 2년마다 두 배씩 증가했기 때문에 사람들은 트랜지스터 다이의 수를 두 배로 늘려가며 무어의 법칙과 컴퓨터 성능을 혼용하였습니다.

  • 그리고 나서 우리는 컴퓨터 성능의 세 번째 시대에 도달했습니다. 느려집니다. 총 변화율은 연간 22%입니다.

이전 그래프는 2012년까지만 올라갔지만, 다행히도 2012년 제프 프레싱(Jeff Preshing)Spec 웹 사이트를 스크래치하고 자신만의 그래프를 만드는 도구를 작성했습니다.

이것은 1995년부터 2017년까지 Spec 데이터를 사용한 것과 동일한 그래프입니다.

2012년 데이터에서 살펴본 단계 변화보다는 단일 코어 성능이 한계에 근접하고 있다고 말씀드리고 싶습니다. 부동 소수점보다 숫자가 더 좋지만, 이 자리에 있는 비즈니스 애플리케이션 라인의 업무를 수행하는 저희에게 있어, 그다지 관련이 없을 것입니다.

1.3.2 네, 컴퓨터는 여전히 더 빨라지고 있어요, 천천히

무어의 법칙 끝에서 가장 먼저 기억해야 할 것은 고든 무어(Gordon Moore)가 내게 얘기한 것입니다. 그는 “모든 지수가 끝나고 있다”고 말했습니다. — 존 헤네시

이것은 헤네시(Hennessy)가 Google Next 18에서 그의 Turing Award 강연에서 인용한 것입니다. 그의 주장은 네, CPU 성능은 여전히 향상되고 있습니다. 그러나 단일 쓰레드 정수(Integer) 성능은 여진히 연간 약 2~3% 개선되고 있습니다. 이런 속도라면 정수의 성능을 두 배로 끌어올리기 위해 20년 간의 복합 성장(Compound Growth)을 해야할 것입니다. 이를 2년마다 성능이 2배씩 증가한 90년대의 잘 나가던(Go-Go) 시대와 비교해야 합니다.

왜 이런 일이 일어나는 걸까요?

1.4 클럭 속도

2015년도의 그래프가 이를 잘 보여줍니다. 상단 라인은 트랜지스터 다이의 수를 나타냅니다. 이것은 1970년대 이후로 거의 선형적인 추세선상에서 지속되어 왔습니다. 로그/린 그래프이기 때문에 이 선형 계열은 지수 성장을 나타냅니다.

하지만, 중간 라인을 보면, 10년 동안 클럭 속도가 증가하지 않았으며 2004년경에 CPU 속도가 정체된 것을 알 수 있습니다.

하단 그래프는 열 방출 전력(thermal dissipation power)을 보여 줍니다. 즉, 열로 변환되는 전력은 동일한 패턴을 따릅니다. 클럭 속도와 CPU 열 방출은 서로 관련이 있습니다.

1.5 열

CPU가 열을 발생시키는 이유는 무엇일까요? 고체 상태 장치(Solid State Device)이고 움직이는 구성 요소가 없으므로 마찰과 같은 효과는 (직접적으로) 관련이 없습니다.

이 다이어그램은 TI에서 제작한 훌륭한 데이터 시트에서 가져온 것입니다. 이 모델에서 N타입 장치의 스위치는 양극 전압으로 끌어 당겨집니다. P타입 디바이스는 양극 전압에서 쫓겨납니다.

이 방, 여러분의 책상, 그리고 여러분의 주머니에 있는 모든 트랜지스터에 사용되는 CMOS 기기의 전력 소비량은 세 가지 요인의 조합입니다.

  • 정적 전력(static power). 트랜지스터가 정적인 경우 즉, 상태를 변경하지 않으면 트랜지스터를 통해 접지로 누설되는 소량의 전류가 있습니다. 트랜지스터가 작을수록 누수가 많아집니다. 온도가 높아지면 누수가 증가합니다. 수 십억 개의 트랜지스터가 있으면 1분이라도 누수가 발생합니다!

  • 동적 전력(dynamic power). 트랜지스터가 한 상태에서 다른 상태로 전이될 때, 트랜지스터는 게이트에 연결된 다양한 커패시턴스를 충전하거나 방전해야 합니다. 트랜지스터당 동적 전력은 제곱한 전압 곱하기 커패시턴스와 변화 빈도입니다. 전압을 낮추면 트랜지스터가 소비하는 전력이 감소하지만 전압이 낮으면 트랜지스터가 느리게 전환됩니다.

  • 크라우바 또는 단락 전류(crowbar, or short circuit current) 우리는 트랜지스터를 원자 상태에서 하나의 상태 또는 다른 상태를 차지하는 디지털 장치로 생각합니다. 실제로 트랜지스터는 아날로그 장치입니다. 스위치로서 트랜지스터는 대부분 오프(off) 상태에서 시작하여 온(on) 상태로 전이 또는 전환됩니다. 이러한 전이 또는 전환 시간은 매우 빠릅니다. 현대 프로세서에서는 피코 초의 단위이지만, Vcc에서 접지로의 저항 경로가 있을 때 여전히 어느 정도의 시간이 걸립니다. 트랜지스터의 전환 속도가 빠를수록 더 많은 열이 방출됩니다.

1.6 Dennard 스케일링의 종말

다음에 무슨 일이 일어났는지 이해하려면 1974년에 로버트 H. 데너드(Robert H. Dennard)가 공동 저술한 논문을 살펴봐야 합니다. 데너드의 스케일링 법칙에 따르면 트랜지스터가 작아질수록 전력 밀도는 일정하게 유지됩니다. 트랜지스터가 작아질수록 전압도 낮아지고, 게이트 커패시턴스도 낮아지며, 스위치 속도도 빨라져 동적 전력량을 줄일 수 있습니다.

그래서 어떻게 되었을까요?

결과는 별로 좋지 않습니다. 트랜지스터의 게이트 길이가 몇 개의 실리콘 원자의 너비에 접근함에 따라 트랜지스터 크기, 전압 및 중요한 누수 사이의 관계가 끊어졌습니다.

1999년 Micro-32 컨퍼런스에서 클럭 속도를 높이고 트랜지스터 크기를 줄이는 추세를 따랐다면, 프로세서 시대의 트랜지스터 접합부가 원자로 코어 온도에 근접할 것이라고 가정했습니다. 분명히 바보 같은 짓이었죠. 펜티엄 4는 단일 코어, 고주파, 소비자 CPU 라인의 종지부를 찍었습니다.

다시 그래프로 돌아와서, 클럭 속도가 멈춘 이유는 CPU의 속도가 CPU를 냉각시키는 능력을 초과했기 때문입니다. 2006년까지 트랜지스터 크기의 감소로 전력 효율은 더 이상 개선되지 않았습니다.

이제 CPU 피처 크기 감소는 주로 전력 소비를 줄이는 데 목적이 있다는 것을 알게 되었습니다. 전력 소비량을 줄인다고 해서 단순히 지구를 살리는 재활용과 같은 “친환경”을 의미하는 것이 아닙니다. 주된 목표는 전력 소비를 유지하고, 그러한 열 손실로, CPU를 손상시키는 수준을 낮추는 것입니다.

하지만, 그래프의 한 부분에서 보듯이, 트랜지스터 다이 수는 계속 증가하고 있습니다. 주어진 동일한 영역에 더 많은 트랜지스터를 집적하는 CPU 피처 크기의 행진은 긍정적인 효과와 부정적인 효과 모두를 지닙니다.

또한, 다음 인서트에서도 볼 수 있듯이 트랜지스터당 비용은 5년 전까지 계속 하락했습니다. 그 이후 트랜지스터당 비용이 다시 상승하기 시작했습니다.

더 작은 트랜지스터를 만드는 데, 더 많은 비용이 들 뿐만 아니라 더 어려워지고 있습니다. 2016년의 이 보고서는 칩 제조사들이 2013년 격게 될 일의 예측했었습니다. 2년 후 그들의 모든 예측이 빗나갔습니다. 이 보고서의 최신 버전은 없지만, 이러한 추세를 되돌릴 수 있을 것 같은 징후는 보이지 않습니다.

인텔, TSMC, AMD, 그리고 삼성은 새로운 공장을 짓고 새로운 프로세스 툴링을 구매해야 하기 때문에 수 십억 달러의 비용이 듭니다. 그래서 다이당 트랜지스터 수가 계속 상승하며, 단가가 상승하기 시작했습니다.

Note

나노미터 단위로 측정했던 게이트 길이(gate length)라는 용어도 모호해졌습니다. 다양한 제조사들은 트랜지스터 크기를 다양한 방법으로 측정하여, 납품없이 경쟁사보다 적은 수의 트랜지스터를 시연할 수 있습니다. 이는 CPU 제조사의 비일반회계(Non-GAAP) 수익 보고 모델입니다.

1.7 더 많은 코어

열 및 주파수 한계에 도달하면 단일 코어 속도를 2배 빠르게 실행할 수 없습니다. 그러나 다른 코어를 추가하면 소프트웨어에서 지원할 수 있는 경우 처리 용량을 두 배로 제공할 수 있습니다.

실제로 CPU의 코어 수는 열 방출에 의해 좌우됩니다. 데나드 스케일링의 종말은 CPU 클럭 속도가 얼마나 뜨거운 지에 따라 1~4Ghz 사이의 임의의 숫자라는 것을 의미합니다. 벤치마킹에 대해 이야기할 때 이것을 잠깐 보게 될 것입니다.

1.8 암달의 법칙

CPU는 점점 더 빨라지지는 않지만, 하이퍼 스레딩과 다중 코어로 점점 더 확장되고 있습니다. 모바일 부품에 듀얼 코어, 데스크톱 부품에 쿼드 코어, 서버 부품에 수십 개의 코어. 이것이 컴퓨터 성능의 미래일까요? 불행히도 그렇지 않습니다.

IBM/360의 설계자인 진 암달(Gene Amdahl)의 이름을 딴 암달의 법칙은 리소스가 개선된 시스템에서 기대할 수 있는 고정 워크로드에서 작업 실행의 대기 시간을 이론적으로 가속화하는 공식입니다.

암달의 법칙에 따르면 프로그램의 최대 속도는 프로그램의 순차적인 부분에 의해 제한됩니다. 실행의 95%를 병렬로 실행할 수 있는 프로그램을 작성하는 경우, 수천 개의 프로세서를 사용하더라도 프로그램 실행의 최대 속도는 20배로 제한됩니다.

우리가 매일 작업하는 프로그램에 대해 생각해봅시다. 얼마나 많이 병렬로 실행할 수 있을까요?

1.9 동적 최적화

클럭 속도가 지연되고, 추가 코어를 투척해도 소득이 없는 문제점이 있을 때, 속도 향상은 어디서 오는 것일까요? 속도는 칩 자체의 아키텍처를 개선한 결과입니다. 이들은 Nehalem, Sandy Bridge, 및 Skylake와 같은 이름의 5~7년 짜리의 큰 프로젝트입니다.

지난 20년 동안의 성능 향샹은 아키텍처 개선에서 비롯되었습니다.

1.9.1 비순차적 명령어 처리 (Out of order execution)

슈퍼 스칼라라고 하는 비순차적 실행은 CPU가 실행 중인 코드에서 소위 명령어 수준 병렬 처리를 추출하는 방법입니다. 최신 CPU는 하드웨어 수준에서 SSA를 효과적으로 수행하여 작업 간의 데이터 종속성을 식별하고 가능한 경우 독립적인 명령을 병렬로 실행합니다.

그러나 모든 코드에 내재된 병렬 처리량에는 제한이 있습니다. 엄청나게 힘도 듭니다. 대부분의 최신 CPU는 파이프라인의 각 단계에서 각 실행 장치를 다른 모든 장치에 연결하는데 n제곱의 비용이 발생하기 때문에 코어당 6개의 실행 장치로 정착했습니다.

1.9.2 추측 실행 (Speculative execution)

가장 작은 마이크로 컨트롤러들을 모으면, 모든 CPU는 fetch/decode/execute/commit 명령 사이클의 일부와 겹치는 명령 파이프라인(instruction pipeline)을 활용합니다.

명령어 파이프라인의 문제는 분기 명령어이며 평균 5-8 명령어마다 발생합니다. CPU가 분기에 도달하면 분기를 넘어 추가적인 명령을 실행할 수 없으며 프로그램 카운터도 분기할 위치를 알 때까지 파이프 라인 채우기를 시작할 수 없습니다. 추측 실행은 CPU가 분기 명령이 여전히 처리되고 있는 중에도 어느 경로로 분기해야 할지 “추측”할 수 있게 합니다!

CPU가 분기를 올바르게 예측하면 명령 파이프라인을 가득 채울 수 있습니다. CPU가 올바른 분기를 예측하지 못하고 오류로 인식하면 아키텍처 상태에 대한 변경 사항을 롤백해야 합니다. 우리 모두가 Spectre 스타일의 취약점을 배우고 있기 때문에, 때때로 이 롤백은 기대만큼 원활하지 않은 경우가 있습니다.

분기 예측률이 낮을 때, 추측 실행이 매우 어려울 수 있습니다. 분기가 잘못 예측된 경우, CPU 역추적은 잘못 예측한 지점까지 역추적해야 할 뿐만 아니라 잘못된 분기에서 소비된 에너지가 낭비됩니다.

엄청난 수의 트랜지스터와 전력을 희생하며 이러한 모든 최적화로 단일 쓰레드 성능이 개선되었습니다.

Note

클리프 클릭(Cliff Click)은 비순차적 및 추론적 실행은 캐시 미스를 조기에 시작하여 관찰된 캐시 대기 시간을 줄이는데 가장 유용하다고 논증하는 훌륭한 프리젠테이션을 제공합니다.

1.10 현대 CPU는 벌크 동작에 대해 최적화되었습니다.

현대의 프로세서는 니트로 연료가 함유된 재미있는 자동차와 비슷하며 단거리 경주에서 뛰어납니다. 불행히도 현대의 프로그래밍 언어는 Monte Carlo와 비슷하지만 트위스트와 턴으로 가득합니다. — 데이비드 언가(David Ungar)

이는 영향력 있는 컴퓨터 과학자이자 SELF 프로그래밍 언어의 개발자인 데이비드 언가의 인용문으로 온라인에서 찾은 아주 오래된 프리젠테이션에서 언급되었습니다.

따라서 최신 CPU는 대량 전송 및 대량 작업에 최적화되어 있습니다. 모든 레벨에서 작업 설정 비용은 대량 작업을 권장합니다. 예를 들어, 다음과 같습니다.

  • 메모리는 바이트당 로드되지 않고 여러 캐시 라인마다 로드되므로 정렬이 이전 컴퓨터에서만큼 문제가 되지않는 이유입니다.
  • MMX 및 SSE와 같은 벡터 명령을 사용하면 프로그램이 해당 형식으로 표현될 수 있는 동시에 다중 데이터 항목에 대해 단일 명령을 실행할 수 있습니다.

1.11 현대 프로세서는 메모리 용량이 아닌 메모리 지연 시간으로 제한됩니다.

CPU라는 토지 상황이 그다지 나쁘지 않았다면, 메모리라는 집 쪽에서 들려오는 뉴스는 크게 나아지지 않습니다.

서버에 연결된 물리적 메모리가 기하학적으로 증가했습니다. 1980년대에 저의 첫 컴퓨터는 킬로바이트의 메모리를 가지고 있었습니다. 고등학교를 다닐 때 저는 386에 1.8메가바이트의 램을 가지고 모든 에세이를 썼습니다. 이제 수십 기가바이트 또는 수백 기가바이트의 램을 가진 서버를 찾는 것이 일반적이며, 클라우드 공급업체들은 테라바이트의 램을 도입하고 있습니다.

그러나 프로세서 속도와 메모리 액세스 시간 사이의 간극은 계속 증가하고 있습니다.

하지만, 메모리 대기 중에 손실된 프로세서 싸이클의 경우, 메모리가 CPU 속도의 증가에 보조를 맞추지 못했기 때문에 물리적 메모리는 그 어느때보다 여전히 멀리 떨어져 있습니다.

따라서 대부분의 최신 프로세서는 커패시티가 아닌 메모리 레이턴시로 제한됩니다.

1.12 캐시가 주변의 모든 것을 지배합니다.

수십 년 동안 프로세서/메모리 캡에 대한 솔루션은 캐시를 추가하는 것이 었습니다. 캐시는 CPU에 더 가까이, 이제는 직접 통합된 작은 고속 메모리입니다.

그러나;

  • L1은 수십 년 동안 코어 당 32kb로 고정되었습니다.

  • L2는 가장 큰 인텔 부품에서 최대 512kb까지 천천히 상승했습니다.

  • L3는 지금 4-32mb 범위로 측정되지만, 액세스 시간은 가변적입니다.

캐시는 CPU 다이에서 물리적으로 크기 때문에 크기가 제한되어 많은 전력을 소비합니다. 캐시 미스률을 절반으로 줄이려면 캐시 크기를 4배로 늘려야합니다.

1.13 공짜 점심은 끝났다.

2005년 C++위원회 리더인 허브 서터(Herb Sutter)는 공짜 점심은 끝났다라는 제목의 기사를 썼습니다. 그의 글에서 서터는 제가 다룰 모든 점에 대해 논의했으며 미래 프로그래머는 더 이상 느린 프로그램 또는 느린 프로그래밍 언어를 수정하기 위해 더 빠른 하드웨어에 의존할 수 없을 것이라고 주장했습니다.

10년이 지난 지금, 허브 서터가 옳았다는 것에는 의심의 여지가 없습니다. 메모리가 느리고 캐시가 너무 작고, CPU 클럭 속도가 거꾸로 가고 있으며 단일 쓰레드 CPU의 단순한 세계는 오래 전에 사라졌습니다.

무어의 법칙은 여전히 유효하지만, 이 자리에 있는 우리 모두에게 공짜 점심은 끝이 났습니다.

1.14 결론

제가 인용하려는 숫자들은 2010년까지 될 것입니다. 30GHz, 100억 개의 트랜지스터, 초당 1테라 인스트럭션이 그 숫자들이죠. — 팻 겔싱어(Pat Gelsinger), 인텔의 전설적인 CTO, 2002년 4월

재료 과학의 획기적인 발전없이는 CPU 성능의 연간 증가율 52%의 시대로 돌아올 가능성이 거의 없다는 것이 분명합니다. 일반적인 합의는 재료 과학 자체가 아니라 트랜지스터 사용 방법에 결함이 있다는 것입니다. 실리콘으로 표현된 순차적 명령흐름의 논리적 모델은 값비싼 최종 게임으로 귀결됩니다.

온라인에 많은 프리젠테이션이 이 점을 반복하고 있습니다. 미래에는 컴퓨터가 오늘날처럼 프로그래밍되지 않을 것입니다. 일부는 수백 개의 매우 멍청하고 일관성이 없는 프로세서를 갖춘 그래픽 카드처럼 보일 것이라고 주장합니다. 다른 사람들은 VLIW(Very Long Instruction Word) 컴퓨터가 우세할 것이라고 주장합니다. 현재 순차 프로그래밍 언어는 이러한 종류의 프로세서와 호환되지 않을 것이라는 데 모두 동의합니다.

제 생각에는 이러한 예측이 정확하다는 것입니다. 이 시점에서 우리를 구해 온 하드웨어 제조업체에 대한 전망은 어둡습니다. 그러나 오늘날 우리가 쓰는 하드웨어에 작성하는 프로그램을 최적화할 수 있는 광범위한 방법이 있습니다. 릭 허드슨(Rick Hudson)은 GopherCon 2015에서 오늘날의 하드웨어와 무관하지 않게 동작하는 소프트웨어의 “유연한 순환”에 다시 참여하는 것에 대해 말했습니다.

앞서 보여드린 그래프를 보면, 2015년부터 2018년까지 정수의 성능은 기껏해야 5~8% 향상되고 메모리 대기 시간은 이보다 적은 것으로 나타났는데, Go팀은 가비지 콜렉션 일시 중지 시간을 2자리 수의 크기로 줄였습니다. Go 1.11 프로그램은 Go 1.6을 사용하는 동일한 하드웨어의 동일한 프로그램보다 GC 대기 시간이 훨씬 뛰어납니다. 이 중 어느 것도 하드웨어에서 비롯된 것이 아닙니다.

따라서, 오늘날의 오늘날 하드웨어에서 최상의 성능을 얻으려면 다음과 같은 프로그래밍 언어가 필요합니다.

  • 해석된 프로그래밍 언어가 CPU 분기 예측 변수 및 추론 실행과 제대로 상호 작용하지 않기 때문에 해석되지 않고 컴파일됩니다.

  • 효율적인 코드를 작성할 수 있는 언어가 필요하며, 모든 숫자가 이상적인 부동 소수인 척 하기보다는 비트와 바이트, 정수의 길이에 대해 효과적으로 얘기할 수 있어야 합니다.

  • 프로그래머가 메모리에 대해 효과적으로 이야기하고 구조체와 자바 객체를 생각할 수 있는 언어가 필요합니다. 왜냐하면 포인터 추적이 CPU 캐시에 압력을 가하고 캐시 미스가 수백 번의 사이클을 태우기 때문입니다.

  • 애플리케이션 성능에 따라 여러 코어로 확장되는 프로그래밍 언어는 캐시를 얼마나 효율적으로 사용하고 여러 코어에서 작업을 얼마나 효율적으로 병렬화할 수 있는지에 따라 결정됩니다.

분명히 우리는 Go에 대해 이야기하러 여기에 왔습니다. 그리고 저는 Go가 방금 설명한 많은 특징들을 물려받았다고 믿습니다.

1.14.1 우리와 무슨 상관이 있나요?

최적화는 세 가지 뿐입니다. 적게 하세요. 덜 자주하세요. 더 빨리 하세요. 가장 큰 이득은 첫 번째에서 비롯되지만, 우리는 모든 시간을 세 번째에서 소비합니다. — Michael Fromberger

이 강의의 요점은 프로그램이나 시스템의 성능에 대해 이야기 할 때 전적으로 소프트웨어에 있음을 설명하는 것입니다. 더 빠른 하드웨어가 하루를 구하기를 기다리는 것은 어리석은 일입니다.

그러나 좋은 소식이 있습니다. 소프트웨어에서 할 수 있는 많은 개선 사항들이 있습니다. 그리고 그것이 오늘 이야기하고자 하는 것입니다.

1.14.2 더 읽을 거리

2. 벤치마킹

두 번 측정하고 한 번 잘라라 — 고대 속담

코드의 성능을 향상시키기 전에 먼저 현재 성능을 알아야 합니다.

이 섹션에서는 Go 테스트 프레임워크를 사용하여 유용한 벤치마크를 구성하는 방법에 중점을 두고 위험요소을 피하기 위한 실용적인 팁을 제공합니다.

2.1 벤치마킹 기본 규칙

벤치마킹하기 전에 반복 가능한 결과를 얻으려면 안정적인 환경이 있어야 합니다.

  • 컴퓨터는 유휴 상태이어야 합니다. 공용 하드웨어에서 프로파일링하지 말고 긴 벤치마크가 실행되기를 기다리는 동안 웹을 탐색하지 마세요.

  • 절전 및 열 스케일링에 주의하세요. 이들은 현대 노트북에서는 거의 피할 수 없습니다.

  • 가상머신 및 공유 클라우드 호스팅을 피하세요. 일관된 측정을 하기에는 노이즈가 꽤 있을 수 있습니다.

여유가 있다면 전용 성능 테스트 하드웨어를 구입하세요. 랙을 세팅하고, 모든 전원 관리 및 열 스케일링을 비활성화한 다음, 시스템의 소프트웨어를 업데이트하지 마세요. 마지막 사항은 시스템 관리 관점에서는 좋지 않은 조언이지만 소프트웨어 업데이트로 커널 또는 라이브러리의 성능을 변경하는 경우, (Spectre 패치를 생각해 보시면), 이전의 모든 벤치마킹 결과가 무효화되어 버립니다.

우리 모두를 위해, 사전 및 사후 샘플을 가지고 일관된 결과를 얻으려면 여러 번 실행하시길 바랍니다.

2.2. 벤치마킹에 테스트 패키지 사용

테스트 패키지는 벤치마크 작성을 지원합니다. 다음과 같은 간단한 기능이 있다면,

func Fib(n int) int {
	switch n {
	case 0:
		return 0
	case 1:
		return 1
	default:
		return Fib(n-1) + Fib(n-2)
	}
}

테스트 패키지를 사용하여 다음 형태로 함수의 벤치마크를 작성할 수 있습니다.

func BenchmarkFib20(b *testing.B) {
	for n := 0; n < b.N; n++ {
		Fib(20) // run the Fib function b.N times
	}
}

Tip

벤치마크 함수는 _test.go 파일에 테스트 함수와 나란히 있습니다.

벤치마크는 테스트와 유사하지만 유일한 차이점은 *testing.T 대신 * testing.B를 사용한다는 것입니다. 이 두 가지 유형 모두 Errorf(), Fatalf()FailNow()와 같은 대중들이 좋아하는 testing.TB 인터페이스를 구현합니다.

2.2.1 패키지의 벤치마크 실행

벤치마크에서 테스트 패키지를 사용할 때, go test 하위 명령을 통해 실행됩니다. 그러나 기본적으로 go test만 호출하면 벤치마크는 제외됩니다.

패키지에서 벤치마크를 명시적으로 실행하려면 -bench 플래그를 사용하세요. -bench는 실행하려는 벤치마크 이름과 일치하는 정규식을 사용하므로 패키지에서 모든 벤치마크를 호출하는 가장 일반적인 방법은 -bench=.입니다. 다음은 예제입니다.

% go test -bench=. ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib20-8           30000             40865 ns/op
PASS
ok      _/Users/dfc/devel/high-performance-go-workshop/examples/fib     1.671s

Tip

go test는 벤치마크와 일치하기 전에 패키지의 모든 테스트를 실행합니다. 따라서, 패키지에 많은 테스트가 있거나 실행하는 데 시간이 오래 걸리면 go test-run 플래그에 아무것도 일치하지 않는 정규식을 줘서 테스트에서 제외시킬 수 있습니다.

go test -run=^$	

2.2.2 벤치마크 동작 방식

각 벤치마크 함수는 b.N에 다른 값으로 호출됩니다. 이는 벤치마크가 실행해야 하는 반복 횟수입니다.

b.N은 1에서 시작합니다. 벤치마크 함수가 1초 미만으로 완료되는 경우(기본값), 그 다음 b.N이 증가되고 벤치마크 함수가 다시 실행됩니다.

b.N은 대략적으로 1, 2, 3, 5, 10, 20, 30, 50, 100 등의 순서로 증가합니다. 벤치마크 프레임워크는 꾀를 쓰려고 합니다. 작은 b.N 값이 비교적 빠르게 완료되는 경우에는 반복 횟수가 더 빠르게 증가하죠.

위의 예를 살펴보면 BenchmarkFib20-8은 약 30,000회의 루프 반복이 1초 이상 걸린다는 것을 발견했습니다. 여기에서 벤치마크 프레임워크는 작업당 평균 시간이 40865ns임을 계산했습니다.

Tip

-8 접미사는 이 테스트를 실행하는 데 사용된 GOMAXPROCS 값과 관련이 있습니다. 이 숫자는 GOMAXPROCS와 마찬가지로, 시작시 Go 프로세스에서 보이는 CPU 수로 기본 설정됩니다. 벤치마크를 실행할 값 목록을 가져오는 -cpu 플래그를 사용하여 이 값을 변경할 수 있습니다.

	% go test -bench=. -cpu=1,2,4 ./examples/fib/
	goos: darwin
	goarch: amd64
	BenchmarkFib20             30000             39115 ns/op
	BenchmarkFib20-2           30000             39468 ns/op
	BenchmarkFib20-4           50000             40728 ns/op
	PASS
	ok      _/Users/dfc/devel/high-performance-go-workshop/examples/fib     5.531s

여기에서는 1, 2, 4개의 코어로 벤치마크를 실행하고 있습니다. 이 경우 이 벤치마크는 완전히 순차적이므로 플래그가 결과에 거의 영향을 미치지 않습니다.

2.2.3 벤치마크 정확도 개선

fib 함수는 약간 억지스런 예제입니다. TechPower 웹서버 벤치마크를 작성하지 않는 한, 피보나치 시퀀스에서 20번째 숫자를 얼마나 빨리 계산할 수 있는지에 대한 비즈니스가 결정되지는 않을 것입니다. 그러나 이 벤치마크는 올바른 벤치마크의 신뢰할만한 예제를 보여줍니다.

특히, 벤치마크를 수만 번 반복하여 실행하면 작업당 좋은 평균값을 얻을 수 있습니다. 벤치마크가 100회 또는 10회 반복으로 실행되는 경우, 해당 실행의 평균은 표준편차가 높을 수 있습니다. 벤치마크가 수백만 또는 수십억 번 반복되는 경우 평균은 매우 정확할 수 있지만, 코드 레이아웃 및 정렬에 차이가 있을 수 있습니다.

반복 횟수를 늘리려면, -benchtime 플래그로 벤치마크 시간을 늘릴 수 있습니다. 예를 들면,

% go test -bench=. -benchtime=10s ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib20-8          300000             39318 ns/op
PASS
ok      _/Users/dfc/devel/high-performance-go-workshop/examples/fib     20.066s

반환되는 데 10초 이상 걸리는 b.N 값에 도달할 때까지 동일한 벤치마크를 실행합니다. 10배 더 길어질수록 총 반복 횟수는 10배 더 큽니다. 결과가 크게 달라지지 않았는데, 이는 우리가 예상한 것입니다.

총 시간이 10이 아닌 20초인 이유는 무엇일까요?

마이크로 또는 나노초 범위에서 작업당 시간을 발생시키는 수백만 또는 수십억 회 반복 벤치마크가 있는 경우 열 스케일링, 메모리 로컬성, 백그라운드 처리, gc 활동 등으로 인해 벤치마크 수가 불안정할 수 있습니다.

작업당 시간이 두 자리 또는 한 자리 수의 나노초 단위로 측정되는 경우, 명령어 순서 변경 및 코드 정렬의 상대적인 효과가 벤치마크 시간에 영향을 미칩니다.

이를 해결하려면 -count 플래그로 벤치마크를 여러 번 실행합니다.

% go test -bench=Fib1 -count=10 ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         1000000000               1.95 ns/op
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         2000000000               1.97 ns/op
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         2000000000               1.96 ns/op
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         2000000000               2.01 ns/op
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         1000000000               2.00 ns/op

Fib (1)의 벤치마크는 +/- 2% 차이로 약 2 나노초가 걸립니다.

Go 1.12의 새로운 기능인 -benchtime 플래그는 반복 횟수를 취합니다. 예를 들면, -benchtime=20x은 코드를 정확하게 benchtime 번 실행합니다.

10x, 20x, 50x, 100x 및 300x의 -benchtime으로 위의 fib 벤치를 실행해보세요. 어떤가요?

Tip

go test가 적용하는 기본값을 특정 패키지에 맞게 조정해야 하는 경우, 벤치마크를 실행하려는 모든 사람이 동일한 설정으로 수행할 수 있도록 Makefile에서 해당 설정을 코드화하는 것이 좋습니다.

2.3 benchstat과 벤치마크 비교

이전 섹션에서는 더 많은 데이터를 평균화하기 위해 벤치마크를 두 번 이상 실행할 것을 제안했습니다. 이는 챕터의 시작 부분에서 언급한 전원 관리, 백그라운드 프로세스 및 발열 관리의 영향으로 인한 모든 벤치마크에 유용합니다.

러스 콕스(Russ Cox)가 benchstat라고 부르는 도구를 소개하겠습니다.

% go get golang.org/x/perf/cmd/benchstat

benchstat는 일련의 벤치마크 실행을 수행하고 얼마나 안정적인지 알려줍니다. 다음은 배터리 전원에 대한 Fib(20)의 예제입니다.

% go test -bench=Fib20 -count=10 ./examples/fib/ | tee old.txt
goos: darwin
goarch: amd64
BenchmarkFib20-8           50000             38479 ns/op
BenchmarkFib20-8           50000             38303 ns/op
BenchmarkFib20-8           50000             38130 ns/op
BenchmarkFib20-8           50000             38636 ns/op
BenchmarkFib20-8           50000             38784 ns/op
BenchmarkFib20-8           50000             38310 ns/op
BenchmarkFib20-8           50000             38156 ns/op
BenchmarkFib20-8           50000             38291 ns/op
BenchmarkFib20-8           50000             38075 ns/op
BenchmarkFib20-8           50000             38705 ns/op
PASS
ok      _/Users/dfc/devel/high-performance-go-workshop/examples/fib     23.125s
% benchstat old.txt
name     time/op
Fib20-8  38.4µs ± 1%

benchstat에 따르면 평균은 샘플 전체에서 +/- 1% 오차로 38.8 마이크로 초입니다. 이는 배터리 전원에 아주 좋습니다.

  • 첫 번째 실행이 운영 체제에서 전원을 절약하기 위해 CPU를 클럭 다운시켰기 때문에 가장 느립니다.

  • 다음 두 번의 실행이 가장 빠릅니다. 왜냐하면 운영 체제가 일시적인 작업 스파이크가 아니라고 판단함으로써, 다시 슬립 상태로 돌아갈 수 있기를 바라며 가능한 한 빨리 작업을 수행할 수 있도록 클럭 속도를 높였기 때문입니다.

  • 나머지 실행은 열 생산을 위한 운영 체제와 바이오스 트레이드 전력 소비량입니다.

2.3.1 Fib 개선

두 벤치마크 세트 간의 성능 델타를 결정하는 것은 지루하고 오류가 발생하기 쉽습니다. Benchstat가 이를 도와줄 수 있습니다.

Tip

벤치마크 실행에서 출력을 저장하는 것이 유용하지만, 이를 생성한 바이너리를 저장할 수도 있습니다. 이를 통해 이전 반복 벤치마크를 다시 실행할 수 있습니다. 이렇게 하려면, -c 플래그를 사용하여 테스트 바이너리를 저장합니다. 종종 이 이진데이터의 이름을 .test에서 .golden로 바꿉니다.

% go test -c
% mv fib.test fib.golden

이전 Fib 함수는 피보나치 시리즈의 0번째와 1번째 숫자의 값을 하드코딩했습니다. 그 후 코드는 재귀적으로 호출됩니다. 이후에 재귀 비용에 대해 이야기할테지만, 현재로서는, 특히 알고리즘이 기하급수적인 시간을 사용하기 때문에 비용이 든다고 가정하겠습니다.

이를 간단하게 해결할 수 있는 방법은 피보나치 시리즈에서 또다른 수를 하드코딩하여 각 재귀 호출의 깊이를 하나씩 줄이는 것입니다.

func Fib(n int) int {
	switch n {
	case 0:
		return 0
	case 1:
		return 1
	case 2:
		return 1
	default:
		return Fib(n-1) + Fib(n-2)
	}
}

새 버전을 비교하기 위해, 새로운 테스트 바이너리를 컴파일하고 둘 다 벤치마킹하고 benchstat를 사용하여 출력을 비교합니다.

% go test -c
% ./fib.golden -test.bench=. -test.count=10 > old.txt
% ./fib.test -test.bench=. -test.count=10 > new.txt
% benchstat old.txt new.txt
name     old time/op  new time/op  delta
Fib20-8  44.3µs ± 6%  25.6µs ± 2%  -42.31%  (p=0.000 n=10+10)

벤치마크를 비교할 때, 확인해야 할 세 가지가 있습니다.

  • 이전 샘플과 새 샘플의 ±오차. 1-2% 변화가 양호하고, 3-5%는 괜찮으며, 5% 보다 크면 일부 샘플은 신뢰할 수 없는 것으로 간주됩니다. 한 쪽의 변화가 큰 벤치마크를 비교할 때, 개선이 이루지지 않는 것을 볼 수 있으므로 주의해야 합니다.
  • p값. 0.05보다 낮은 p값은 양호하고 0.05보다 크면 벤치마크가 통계적으로 유의하지 않을 수 있음을 의미합니다.
  • 누락된 샘플. benchstat는 이전 및 새 샘플 중 유효하다고 생각되는 샘플의 개수를 보고합니다. 때로는 -count = 10을 수행하더라도 9개만 보고될 수 있습니다. 10% 이하의 거부율(rejection rate)은 괜찮지만, 10%보다 높으면 설정이 불안정하고 너무 적은 수의 샘플을 비교하고 있는 것일 수 있습니다.

2.4 벤치마킹 시작 비용 피하기

때로는 벤치마크를 실행할마다 셋업 비용이 한번씩 발생할 수 있습니다. b.ResetTimer()은 셋업에서 발생한 시간을 무시할 때 사용합니다.

func BenchmarkExpensive(b *testing.B) {
        boringAndExpensiveSetup() // (1)
        b.ResetTimer() 
        for n := 0; n < b.N; n++ {
                // function under test
        }
}

(1) - 벤치마크 타이머를 리셋합니다.

루프가 반복될 때마다 값비싼 셋업 로직이 있는 경우, 벤치마크 타이머를 일시 중지하려면 b.StopTimer()b.StartTimer()를 사용하세요.

func BenchmarkComplicated(b *testing.B) {
        for n := 0; n < b.N; n++ {
                b.StopTimer() // (1)
                complicatedSetup()
                b.StartTimer() // (2)
                // function under test
        }
}

(1) - 벤치마크 타이머를 중지합니다.
(2) - 타이머를 재개합니다.

2.5 벤치마킹 할당

할당 횟수와 크기는 벤치마크 시간과 밀접한 상관 관계가 있습니다. 테스트중인 코드에서 진행된 할당수를 기록하도록 testing 프레임워크에 지시할 수 있습니다.

func BenchmarkRead(b *testing.B) {
        b.ReportAllocs()
        for n := 0; n < b.N; n++ {
                // function under test
        }
}

다음은 bufio 패키지의 벤치마크를 사용한 예입니다.

% go test -run=^$ -bench=. bufio
goos: darwin
goarch: amd64
pkg: bufio
BenchmarkReaderCopyOptimal-8            20000000               103 ns/op
BenchmarkReaderCopyUnoptimal-8          10000000               159 ns/op
BenchmarkReaderCopyNoWriteTo-8            500000              3644 ns/op
BenchmarkReaderWriteToOptimal-8          5000000               344 ns/op
BenchmarkWriterCopyOptimal-8            20000000                98.6 ns/op
BenchmarkWriterCopyUnoptimal-8          10000000               131 ns/op
BenchmarkWriterCopyNoReadFrom-8           300000              3955 ns/op
BenchmarkReaderEmpty-8                   2000000               789 ns/op            4224 B/op          3 allocs/op
BenchmarkWriterEmpty-8                   2000000               683 ns/op            4096 B/op          1 allocs/op
BenchmarkWriterFlush-8                  100000000               17.0 ns/op             0 B/op          0 allocs/op

Tip

go test -benchmem 플래그를 사용해서 테스트 프레임워크가 모든 벤치마크 실행에 대한 할당 통계를 보고하도록 할 수도 있습니다.

	% go test -run=^$ -bench=. -benchmem bufio
	goos: darwin
	goarch: amd64
	pkg: bufio
	BenchmarkReaderCopyOptimal-8            20000000                93.5 ns/op            16 B/op          1 allocs/op
	BenchmarkReaderCopyUnoptimal-8          10000000               155 ns/op              32 B/op          2 allocs/op
	BenchmarkReaderCopyNoWriteTo-8            500000              3238 ns/op           32800 B/op          3 allocs/op
	BenchmarkReaderWriteToOptimal-8          5000000               335 ns/op              16 B/op          1 allocs/op
	BenchmarkWriterCopyOptimal-8            20000000                96.7 ns/op            16 B/op          1 allocs/op
	BenchmarkWriterCopyUnoptimal-8          10000000               124 ns/op              32 B/op          2 allocs/op
	BenchmarkWriterCopyNoReadFrom-8           500000              3219 ns/op           32800 B/op          3 allocs/op
	BenchmarkReaderEmpty-8                   2000000               748 ns/op            4224 B/op          3 allocs/op
	BenchmarkWriterEmpty-8                   2000000               662 ns/op            4096 B/op          1 allocs/op
	BenchmarkWriterFlush-8                  100000000               16.9 ns/op             0 B/op          0 allocs/op
	PASS
	ok      bufio   20.366s

2.6 컴파일러 최적화 주의

이 예제는 이슈 14813에서 가져온 것입니다.

const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101

func popcnt(x uint64) uint64 {
	x -= (x >> 1) & m1
	x = (x & m2) + ((x >> 2) & m2)
	x = (x + (x >> 4)) & m4
	return (x * h01) >> 56
}

func BenchmarkPopcnt(b *testing.B) {
	for i := 0; i < b.N; i++ {
		popcnt(uint64(i))
	}
}

이 함수가 얼마나 빨리 벤치마킹할 것이라고 생각하시나요? 알아봅시다.

% go test -bench=. ./examples/popcnt/
goos: darwin
goarch: amd64
BenchmarkPopcnt-8       2000000000               0.30 ns/op
PASS

0.3 나노초입니다. 이는 기본적으로 한 클럭 사이클입니다. CPU에 클럭 틱당 몇 개의 인플라이트 (in flight) 명령어가 있다고 가정하더라도 이 숫자는 터무니없이 낮은 것으로 보입니다. 어떻게 된 걸까요?

무슨 일이 일어났는지 이해하기 위해서, 벤치마크 내의 popcnt 함수를 살펴봐야 합니다. popcnt는 리프 함수 (다른 함수를 호출하지 않음) 이므로 컴파일러는 이를 인라인화할 수 있습니다.

함수가 인라인화되어 있으므로 이제 컴파일러에서 사이드 이펙트가 없음을 알 수 있습니다. popcnt는 전역 변수의 상태에 영향을 주지 않습니다. 따라서 호출을 없앱니다. 컴파일러가 보는 것은 다음과 같습니다.

func BenchmarkPopcnt(b *testing.B) {
	for i := 0; i < b.N; i++ {
		// 최적화되어 사라짐
	}
}

테스트해 본 모든 버전의 Go 컴파일러에서 루프가 계속 생성됩니다. 그러나 Intel CPU는 루프, 특히 빈 루프를 최적화하는 데 정말 좋습니다.

2.6.1 연습, 어셈블리 보기

계속하기 전에, 우리가 본 것을 확인하기 위해 어셈블리를 살펴봅시다.

% go test -gcflags=-S

gcflags = -l -S를 사용해서 인라인을 비활성화합니다. 어셈블리 출력에 어떻게 영향을 미칠까요?

Note

최적화는 좋은 것입니다 실제 코드를 빠르게 만드는 똑같은 최적화를 제거해야 합니다. 이는 불필요한 계산을 제거함으로써, 관찰할 수 없는 사이드 이펙트가 있는 벤치마크를 제거하는 것과 동일합니다.

이는 Go 컴파일러가 개선됨에 따라 더 흔해지겠죠.

2.6.2 벤치마크 고치기

벤치마크 동작을 위해 인라인을 비활성화하는 것은 비현실적입니다. 우리는 최적화를 바탕으로 코드를 작성하려고합니다.

이 벤치마크를 수정하려면 컴파일러에서 BenchmarkPopcnt의 바디가 전역 상태를 변경하지 않음을 증명할 수 없도록 해야합니다.

var Result uint64

func BenchmarkPopcnt(b *testing.B) {
	var r uint64
	for i := 0; i < b.N; i++ {
		r = popcnt(uint64(i))
	}
	Result = r
}

이처럼 컴파일러가 루프 바디를 최적화하지 않도록 권장합니다.

첫째, popcnt를 호출한 결과는 r에 저장하는데 사용합니다. 둘째, 벤치마크가 끝나면 rBenchmarkPopcnt 범위 내에서 로컬로 선언되었으므로, r의 결과는 프로그램의 다른 부분에 보여지지 않으며, 최종 조치로 r의 값을 패키지 퍼블릭 변수 Result에 할당합니다.

Result는 퍼블릭이기 때문에 컴파일러는 이 패키지를 임포트하는 다른 패키지가 시간이 지남에 따라 변경되는 Result 값을 볼 수 없다는 것을 증명할 수 없으므로, 할당으로 이어지는 모든 작업을 최적화 할 수 없습니다.

Result에 직접 할당하면 어떻게 될까요? 이것이 벤치마크 시간에 영향을 줄까요? popcnt의 결과를 _에 할당하면 어떨까요?

Warning

이전 Fib 벤치마크에서 이러한 예방 조치를 취하지 않았습니다. 그렇게 했어야 했을까요?

2.7 벤치마크 실수들

for 루프는 벤치마크 동작에 중요합니다.

다음은 두 가지 잘못된 벤치마크입니다. 무엇이 잘못되었는지 설명할 수 있을까요?

func BenchmarkFibWrong(b *testing.B) {
	Fib(b.N)
}
func BenchmarkFibWrong2(b *testing.B) {
	for n := 0; n < b.N; n++ {
		Fib(n)
	}
}

이 벤치마크를 실행해보세요, 어떤가요?

2.8 프로파일링 벤치마크

testing 패키지는 CPU, 메모리 및 블록 프로파일 생성을 지원합니다.

  • -cpuprofile=$FILE은 CPU 프로파일을 $FILE에 씁니다.

  • -memprofile=$FILE, 메모리 프로파일을 $FILE에 쓰고, -memprofilerate=N은 프로파일 속도를 1 / N으로 조정합니다.

  • -blockprofile=$FILE, $FILE에 블록 프로파일을 씁니다.

이 플래그 중 하나를 사용하면 바이너리도 유지됩니다.

% go test -run=XXX -bench=. -cpuprofile=c.p bytes
% go tool pprof c.p

2.9 토론

질문 있으세요?

휴식 시간이겠군요.

3. 성능 측정 및 프로파일링

이전 섹션에서는 병목 현상이 발생한 위치를 미리 알고 있을 때, 유용한 개별 함수를 벤치마킹하는 방법에 대해 살펴 보았습니다. 하지만, 종종 여러분은 질문하는 입장이 될 것입니다.

이 프로그램은 왜 이렇게 오래 걸리는 거죠?

이처럼 고수준의 질문에 답을 하려면 전체 프로그램을 프로파일링하는 것이 유용합니다. 이번 섹션에서는 Go에 내장된 프로파일링 도구를 사용하여 프로그램내부 동작을 조사할 것입니다.

3.1 pprof

오늘 우리가 다룰 첫 번째 도구는 pprof입니다. pprofGoogle Perf Tools 도구 모음에서 유래했으며, 최초 공개 버전 이후로 Go 런타임에 통합되었습니다.

pprof는 두 부분으로 구성됩니다.

  • runtime/pprof 패키지는 모든 Go 프로그램에 내장되어 있습니다.
  • go tool pprof는 프로파일을 조사합니다.

3.2 프로파일링 타입

pprof는 여러 타입의 프로파일링을 지원합니다. 오늘은 이들 중 세 가지에 대해 설명하겠습니다.

  • CPU 프로파일링.
  • 메모리 프로파일링.
  • 블락 (또는 블락킹) 프로파일링.
  • 뮤텍스 경합 프로파일링.

3.2.1 CPU 프로파일링

CPU 프로파일링은 가장 일반적인 유형의 프로파일이며 가장 명확합니다.

CPU 프로파일링이 활성화되면, 런타임은 10ms마다 자체적으로 중단되고 현재 실행중인 고루틴의 스택 트래이싱을 기록합니다.

프로파일이 완료되면 가장 핫한 코드 경로를 결정하기 위해 프로파일을 분석할 수 있습니다.

함수가 프로파일에 더 많이 나타날수록, 코드 경로가 총 런타임의 백분율로 더 많은 시간이 소요됩니다.

3.2.2 메모리 프로파일링

메모리 프로파일링은 힙 할당이 이루어질 때 스택 트레이싱을 기록합니다.

스택 할당은 사용 가능(free)한 것으로 가정되며 메모리 프로파일에서 추적되지 않습니다.

CPU 프로파일링과 같은 메모리 프로파일링은 기본적으로 1000개 할당마다 메모리 프로파일링 샘플 1을 기본으로 합니다. 이 비율은 변경할 수 있습니다.

메모리 프로파일링은 샘플 기반이며 사용하지 않는 할당을 추적하기 때문에 메모리 프로파일링을 사용하여 애플리케이션의 전체 메모리 사용량을 결정하는 것은 어렵습니다. 개인적인 의견: 메모리 누수를 찾는 데 유용한 메모리 프로파일링을 찾지 못했습니다. 애플리케이션에서 사용중인 메모리 양을 확인하는 더 좋은 방법이 있습니다. 나중 프레젠테이션에서 이에 대해 논의할 것입니다.

3.2.3 블록 프로파일링

블록 프로파일링은 Go만의 고유한 특징입니다.

블록 프로파일은 CPU 프로파일과 유사하지만, 고루틴이 공유 자원을 기다리는 데 소요된 시간을 기록합니다.

이는 애플리케이션의 동시성 병목 현상을 파악하는 데 유용할 수 있습니다.

블록 프로파일링은 많은 수의 고루틴이 진행할 수 있지만, 블록된 때를 보여줄 수 있습니다. 블럭킹에는 다음이 포함됩니다.

  • 언버퍼드 채널에서 전송 또는 수신합니다.
  • 풀(full) 채널로 전송하고 빈(empty) 채널에서 수신합니다.
  • 다른 고루틴이 잠군 sync.MutexLock하려고 합니다.

블록 프로파일링은 매우 전문화된 도구이므로, 모든 CPU 및 메모리 사용 병목 현상을 제거했다고 확신하기 전에는 사용해서는 안 됩니다.

3.2.4 뮤텍스 프로파일링

뮤텍스 프로파일링은 블록 프로파일링과 유사하지만 뮤텍스 경합으로 인한 지연을 유발하는 작업에만 집중합니다.

이 타입의 프로필에 대한 경험이 많지 않지만, 이를 보여주는 예제를 작성했습니다. 곧 그 예제를 살펴 보겠습니다.

3.3 한 번에 한 프로파일

프로파일링은 공짜가 아닙니다.

프로파일링은 프로그램 성능에 괜찮지만, 프로그램에 측정 가능한 영향을 미칩니다. 특히, 메모리 프로파일 샘플 속도를 증가시키는 경우에 그러합니다.

대부분의 도구는 한 번에 여러 프로파일링을 활성화하지 못하게 합니다.

Warning

한 번에 둘 이상의 프로파일을 활성화하지 마세요.

여러 프로파일을 동시에 활성화하면, 서로의 상호 작용을 보게 되고, 그 결과를 버리게 됩니다.

3.4 프로파일 수집

Go 런타임의 프로파일링 인터페이스는 runtime/pprof 패키지에 있습니다. runtime/pprof는 매우 저수준의 도구이며, 역사적 이유로 여러 유형의 프로파일링에 대한 인터페이스가 통일되어 있지 않습니다.

이전 섹션에서 보았듯이, pprof 프로파일링은 testing 패키지에 내장되어 있지만, testing.B 벤치마크의 컨텍스트 내에 프로파일링하려는 코드를 배치하는 것이 불편하거나 어려운 경우가 있습니다. 그래서 runtime/pprof API를 직접 사용해야 합니다.

몇 년 전에 저는 기존 애플리케이션을 보다 쉽게 프로파일링할 수 있도록 [작은 패키지][0]를 작성했습니다.

import "github.com/pkg/profile"

func main() {
	defer profile.Start().Stop()
	// ...
}

이 섹션에서는 프로파일링 패키지를 사용합니다. 나중에 runtime/pprof 인터페이스를 직접 사용하는 것에 대해 이야기할 것입니다.

3.5 pprof 프로파일 분석

pprof가 측정할 수 있는 것과 프로파일을 생성하는 방법에 대해 말씀드렸으니 pprof를 사용하여 프로파일을 분석하는 방법에 대해 살펴 보겠습니다.

분석은 go pprof 하위 명령으로 수행합니다.

go tool pprof /path/to/your/profile

이 도구는 텍스트, 그래픽, 심지어 프레임 그래프 등 프로파일링 데이터를 여러가지 다르게 표현할 수 있습니다.

Tip

Go를 한동안 사용했다면 pprof에 두 가지 아규먼트가 있다고 들었을 것입니다. Go 1.9부터 프로파일 파일에는 프로파일을 렌더링하는 데 필요한 모든 정보가 포함되어 있습니다. 더 이상 프로파일을 생성한 바이너리가 필요하지 않습니다. 🎉

3.5.1 더 읽을거리

3.5.2 CPU 프로파일링 (연습문제)

단어 개수를 세는 프로그램을 작성해봅시다.

package main

import (
	"fmt"
	"io"
	"log"
	"os"
	"unicode"

	"github.com/pkg/profile"
)

func readbyte(r io.Reader) (rune, error) {
	var buf [1]byte
	_, err := r.Read(buf[:])
	return rune(buf[0]), err
}

func main() {
	defer profile.Start().Stop()

	f, err := os.Open(os.Args[1])
	if err != nil {
		log.Fatalf("could not open file %q: %v", os.Args[1], err)
	}

	words := 0
	inword := false
	for {
		r, err := readbyte(f)
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Fatalf("could not read file %q: %v", os.Args[1], err)
		}
		if unicode.IsSpace(r) && inword {
			words++
			inword = false
		}
		inword = unicode.IsLetter(r)
	}
	fmt.Printf("%q: %d words\n", os.Args[1], words)
}

허먼 멜빌(Herman Melville)의 고전 모비딕(구텐베르크 프로젝트에서 발췌)에 몇 개의 단어가 있는지 살펴보겠습니다.

% go build && time ./words moby.txt
"moby.txt": 181275 words

real    0m2.110s
user    0m1.264s
sys     0m0.944s

wc -w의 유닉스 명령과 비교합니다.

% time wc -w moby.txt
215829 moby.txt

real    0m0.012s
user    0m0.009s
sys     0m0.002s

그러니까 숫자가 같지 않네요. wc는 나의 간단한 프로그램과 하는 것처럼 단어를 다르게 생각하기 때문에 약 19% 더 높습니다. 이는 중요하지 않습니다. 두 프로그램 모두 전체 파일을 입력으로 사용하고 단일 패스 카운트에서 한 단어에서 비단어로의 전환 횟수를 계산합니다.

pprof를 사용하여 이러한 프로그램의 실행 시간이 다른 이유를 살펴 보겠습니다.

3.5.3 CPU 프로파일링 추가

먼저, main.go 파일을 수정하여 프로파일링을 활성화 합니다.

import (
        "github.com/pkg/profile"
)

func main() {
        defer profile.Start().Stop()
        // ...

이제 프로그램을 실행하면 cpu.pprof 파일이 생성됩니다.

% go run main.go moby.txt
2018/08/25 14:09:01 profile: cpu profiling enabled, /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile239941020/cpu.pprof
"moby.txt": 181275 words
2018/08/25 14:09:03 profile: cpu profiling disabled, /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile239941020/cpu.pprof

이제 go tool pprof를 사용하여 분석할 수 있는 프로파일이 있죠.

% go tool pprof /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile239941020/cpu.pprof
Type: cpu
Time: Aug 25, 2018 at 2:09pm (AEST)
Duration: 2.05s, Total samples = 1.36s (66.29%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 1.42s, 100% of 1.42s total
      flat  flat%   sum%        cum   cum%
     1.41s 99.30% 99.30%      1.41s 99.30%  syscall.Syscall
     0.01s   0.7%   100%      1.42s   100%  main.readbyte
         0     0%   100%      1.41s 99.30%  internal/poll.(*FD).Read
         0     0%   100%      1.42s   100%  main.main
         0     0%   100%      1.41s 99.30%  os.(*File).Read
         0     0%   100%      1.41s 99.30%  os.(*File).read
         0     0%   100%      1.42s   100%  runtime.main
         0     0%   100%      1.41s 99.30%  syscall.Read
         0     0%   100%      1.41s 99.30%  syscall.read

top 커맨드는 가장 많이 사용하는 커맨드입니다. 이 프로그램은 99%의 시간을 syscall.Syscall에, 그리고 미미한 부분을 main.readbyte에 쓰고 있음을 알 수 있습니다.

web 명령으로 이 호출을 시각화할 수도 있습니다. 프로파일 데이터에서 방향 그래프를 생성합니다. 내부적으로는 Graphviz의 dot명령을 사용합니다.

그러나 Go 1.10 (아마도 1.11)에서 Go는 네이티브하게 http 서버를 지원하는 pprof 버전을 제공합니다.

% go tool pprof -http=:8080 /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile239941020/cpu.pprof

웹 브라우저를 엽니다.

  • 그래프 모드
  • 플레임 그래프 (Flame Graph) 모드

그래프에서는 가장 많은 CPU 시간을 소비하는 박스가 가장 큽니다. sys call.SysCall은 프로그램이 소요한 총 시간의 99.3%입니다. syscall.Syscall로 이어지는 일련의 박스는 순간적인 호출자를 나타냅니다. 여러 개의 코드 경로가 동일한 함수에 수렴하는 경우 둘 이상이 있을 수 있습니다. 화살표의 크기는 박스의 자식이 소요한 시간을 나타냅니다. main.readByte 이후 부터는 이 그래프의 암에 사용된 1.41초 중 거의 0을 차지한다는 것을 알 수 있습니다.

질문: 왜 우리 버전이 wc보다 훨씬 느린지 짐작할 수 있을까요?

3.5.4 현재 버전 개선하기

프로그램이 느린 이유는 Go의 syscall.Syscall이 느리기 때문이 아닙니다. 일반적으로 syscall은 비싼 작업이기 때문입니다. (그리고 Spectre 계열의 취약점이 더 많이 발견될수록 비용이 더 많이 듭니다).

readbyte를 호출할 때마다 버퍼 크기가 1인 syscall.Read가 발생합니다. 따라서 프로그램에서 실행되는 syscall 수는 입력의 크기와 같습니다. pprof 그래프에서 입력을 읽는 것이 다른 모든 것을 지배한다는 것을 알 수 있습니다.

func main() {
	defer profile.Start(profile.MemProfile, profile.MemProfileRate(1)).Stop()
	// defer profile.Start(profile.MemProfile).Stop()

	f, err := os.Open(os.Args[1])
	if err != nil {
		log.Fatalf("could not open file %q: %v", os.Args[1], err)
	}

	b := bufio.NewReader(f)
	words := 0
	inword := false
	for {
		r, err := readbyte(b)
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Fatalf("could not read file %q: %v", os.Args[1], err)
		}
		if unicode.IsSpace(r) && inword {
			words++
			inword = false
		}
		inword = unicode.IsLetter(r)
	}
	fmt.Printf("%q: %d words\n", os.Args[1], words)
}

입력 파일과 readbyte 사이에 bufio.Reader를 삽입하고

이 수정된 프로그램의 시간을 wc와 비교해보세요. 얼마나 가까워졌나요? 프로필을 보고 뭐가 또 있는지 살펴보세요.

3.5.5 메모리 프로파일링

새로운 words 프로파일은 readbyte 함수 안에 무언가가 할당되어 있음을 나타냅니다. pprof를 사용하여 조사할 수 있습니다.

defer profile.Start(profile.MemProfile).Stop()

다음 평소와 같이 프로그램을 수행하면,

% go run main2.go moby.txt
2018/08/25 14:41:15 profile: memory profiling enabled (rate 4096), /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile312088211/mem.pprof
"moby.txt": 181275 words
2018/08/25 14:41:15 profile: memory profiling disabled, /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile312088211/mem.pprof
unnamed cluster_L Type: inuse_space Type: inuse_space Time: Mar 23, 2019 at 6:14pm (CET) Showing nodes accounting for 368.72kB, 100% of 368.72kB total N1 main readbyte 368.72kB (100%) NN1_0 16B N1->NN1_0 368.72kB N2 runtime main 0 of 368.72kB (100%) N3 main main 0 of 368.72kB (100%) N2->N3 368.72kB N3->N1 368.72kB

할당이 readbyte에서 비롯된 것으로 의심했던 것과 같네요. 이는 그렇게 복잡하지 않았죠, readbyte는 3줄짜리라서요.

pprof를 사용해서 할당 위치를 판별해보세요.

func readbyte(r io.Reader) (rune, error) {
        var buf [1]byte // (1)
        _, err := r.Read(buf[:])
        return rune(buf[0]), err
}

(1) 여기서 할당이 일어납니다.

다음 섹션에서 왜 이런 일이 발생하는지 자세히 설명하겠지만, 현재는 보여지는 것은 readbyte에 대한 모든 호출이 새 1바이트 길이의 배열을 할당하고 해당 배열이 힙에 할당되고 있다는 것입니다.

이것을 피할 수 있는 방법은 무엇일까요? 그 방법들을 CPU와 메모리 프로파일링을 사용해서 입증해봅시다.

alloc 오브젝트와 inuse 오브젝트 메모리 프로파일은 두 가지로 구분되며, go tool pprof 플래그의 이름을 따라 명명되었습니다.

  • -alloc_objects는 각 할당이 이루어진 호출 사이트를 리포팅합니다.
  • -inuse_objects는 프로파일 끝까지 도달하게 되는 경우, 할당이 이루어진 호출 사이트를 리포팅합니다.

다음은 이를 증명해보기 위해, 다수의 메모리를 제어된 방식으로 할당하도록 고안된 프로그램입니다.

const count = 100000

var y []byte

func main() {
	defer profile.Start(profile.MemProfile, profile.MemProfileRate(1)).Stop()
	y = allocate()
	runtime.GC()
}

// allocate allocates count byte slices and returns the first slice allocated.
func allocate() []byte {
	var x [][]byte
	for i := 0; i < count; i++ {
		x = append(x, makeByteSlice())
	}
	return x[0]
}

// makeByteSlice returns a byte slice of a random length in the range [0, 16384).
func makeByteSlice() []byte {
	return make([]byte, rand.Intn(2^14))
}

프로그램은 profile 패키지의 어노테이션을 달고, 메모리 프로파일 속도를 1로 설정합니다. 즉, 모든 할당에 대해 스택 트레이싱을 기록합니다. 이로 인해 프로그램 속도가 많이 느려지지만, 잠시 후 이유를 알게 됩니다.

% go run main.go
2018/08/25 15:22:05 profile: memory profiling enabled (rate 1), /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile730812803/mem.pprof
2018/08/25 15:22:05 profile: memory profiling disabled, /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile730812803/mem.pprof

할당된 객체의 그래프를 살펴 보겠습니다. 이 그래프는 기본값이며 프로파일 동안 모든 객체의 할당으로 이어지는 호출 그래프를 보여줍니다.

% go tool pprof -http=:8080 /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile891268605/mem.pprof
unnamed cluster_L Type: alloc_objects Type: alloc_objects Time: Mar 23, 2019 at 1:08pm (GMT) Showing nodes accounting for 43837, 99.83% of 43910 total Dropped 66 nodes (cum <= 219) N1 main makeByteSlice 43806 (99.76%) NN1_0 16B N1->NN1_0 43806 N2 runtime main 0 of 43856 (99.88%) N4 main main 0 of 43856 (99.88%) N2->N4 43856 N3 main allocate 31 (0.071%) of 43837 (99.83%) N3->N1 43806 N4->N3 43837

당연하게 할당량의 99% 이상이 makeByteSlice 내부에 있습니다. 이제 -inuse_objects를 사용하여 동일한 프로파일을 살펴보겠습니다.

% go tool pprof -http=:8080 /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile891268605/mem.pprof
unnamed cluster_L Type: inuse_objects Type: inuse_objects Time: Mar 23, 2019 at 1:08pm (GMT) Showing nodes accounting for 60, 100% of 60 total N1 runtime malg 24 (40.00%) NN1_0 384B N1->NN1_0 24 N2 runtime allocm 7 (11.67%) of 21 (35.00%) N2->N1 7 NN2_0 1kB N2->NN2_0 7 N34 runtime mcommoninit 0 of 7 (11.67%) N2->N34 7 N3 runtime mstart 0 of 17 (28.33%) N8 runtime systemstack 3 (5.00%) of 14 (23.33%) N3->N8 14 N36 runtime mstart1 0 of 3 (5.00%) N3->N36 3 N4 runtime gcBgMarkWorker 8 (13.33%) NN4_0 16B N4->NN4_0 8 N5 runtime schedule 0 of 18 (30.00%) N39 runtime resetspinning 0 of 15 (25.00%) N5->N39 15 N42 runtime stoplockedm 0 of 3 (5.00%) N5->N42 3 N6 profile Start func8 2 (3.33%) of 9 (15.00%) NN6_0 16B N6->NN6_0 1 NN6_1 96B N6->NN6_1 1 N12 signal Notify 3 (5.00%) of 7 (11.67%) N6->N12 7 N7 runtime mcall 0 of 15 (25.00%) N38 runtime park_m 0 of 15 (25.00%) N7->N38 15 NN8_0 64B N8->NN8_0 2 NN8_1 48B N8->NN8_1 1 N37 runtime newproc func1 0 of 11 (18.33%) N8->N37 11 N9 runtime newm 0 of 21 (35.00%) N9->N2 21 N10 runtime ensureSigM func1 0 of 5 (8.33%) N30 runtime LockOSThread 0 of 3 (5.00%) N10->N30 3 N32 runtime chansend1 0 of 1 (1.67%) N10->N32 1 N40 runtime selectgo 0 of 1 (1.67%) N10->N40 1 N11 runtime startm 0 of 18 (30.00%) N11->N9 18 NN12_0 144B N12->NN12_0 1 NN12_1 16B N12->NN12_1 1 NN12_2 48B N12->NN12_2 1 N28 signal Notify func1 0 of 4 (6.67%) N12->N28 4 N13 signal signal_enable 4 (6.67%) NN13_0 96B N13->NN13_0 4 N14 runtime acquireSudog 2 (3.33%) NN14_0 96B N14->NN14_0 2 N15 runtime main 0 of 6 (10.00%) N20 main main 0 of 6 (10.00%) N15->N20 6 N16 profile Start 1 (1.67%) of 5 (8.33%) NN16_0 48B N16->NN16_0 1 N24 profile Start func2 0 of 4 (6.67%) N16->N24 4 N17 log (*Logger) Output 1 (1.67%) of 4 (6.67%) NN17_0 144B N17->NN17_0 1 N25 log (*Logger) formatHeader 0 of 3 (5.00%) N17->N25 3 N18 runtime newproc1 0 of 11 (18.33%) N18->N1 10 N22 runtime allgadd 1 (1.67%) N18->N22 1 N19 time LoadLocationFromTZData 2 (3.33%) of 3 (5.00%) NN19_0 224B N19->NN19_0 1 NN19_1 4kB N19->NN19_1 1 N23 time byteString 1 (1.67%) N19->N23 1 N20->N16 5 N27 main allocate 0 of 1 (1.67%) N20->N27 1 N21 main makeByteSlice 1 (1.67%) NN21_0 16B N21->NN21_0 1 NN22_0 128B N22->NN22_0 1 NN23_0 16B N23->NN23_0 1 N26 log Printf 0 of 4 (6.67%) N24->N26 4 N46 time Time Date 0 of 3 (5.00%) N25->N46 3 N26->N17 4 N27->N21 1 N29 signal enableSignal 0 of 4 (6.67%) N28->N29 4 N29->N13 4 N41 runtime startTemplateThread 0 of 3 (5.00%) N30->N41 3 N31 runtime chansend 0 of 1 (1.67%) N31->N14 1 N32->N31 1 N33 runtime handoffp 0 of 3 (5.00%) N33->N11 3 N35 runtime mpreinit 0 of 7 (11.67%) N34->N35 7 N35->N1 7 N36->N5 3 N37->N18 11 N38->N5 15 N43 runtime wakep 0 of 15 (25.00%) N39->N43 15 N40->N14 1 N41->N9 3 N42->N33 3 N43->N11 15 N44 sync (*Once) Do 0 of 3 (5.00%) N49 time initLocal 0 of 3 (5.00%) N44->N49 3 N45 time (*Location) get 0 of 3 (5.00%) N45->N44 3 N48 Time date 0 of 3 (5.00%) N46->N48 3 N47 Time abs 0 of 3 (5.00%) N47->N45 3 N48->N47 3 N50 time loadLocation 0 of 3 (5.00%) N49->N50 3 N50->N19 3

3.5.6 블록 프로파일링

마지막으로 볼 프로파일 유형은 블록 프로파일 링입니다. net/http 패키지의 ClientServer 벤치 마크를 사용합니다

% go test -run=XXX -bench=ClientServer$ -blockprofile=/tmp/block.p net/http
% go tool pprof -http=:8080 /tmp/block.p
unnamed cluster_L Type: delay Type: delay Time: Mar 23, 2019 at 6:05pm (CET) Showing nodes accounting for 7.82s, 100% of 7.82s total Dropped 39 nodes (cum <= 0.04s) N1 runtime selectgo 4.55s (58.18%) N2 testing (*B) runN 0 of 5.06s (64.63%) N7 http_test BenchmarkClientServer 0 of 1.94s (24.83%) N2->N7 1.94s N36 testing runBenchmarks func1 0 of 3.11s (39.80%) N2->N36 3.11s N3 runtime chanrecv1 3.23s (41.25%) N4 runtime main 0 of 3.11s (39.80%) N16 main main 0 of 3.11s (39.80%) N4->N16 3.11s N5 http (*persistConn) writeLoop 0 of 2.54s (32.44%) N5->N1 2.54s N6 testing (*B) launch 0 of 1.94s (24.82%) N6->N2 1.94s N14 ioutil ReadAll 0 of 0.11s (1.46%) N7->N14 0.11s N28 http Get 0 of 1.83s (23.37%) N7->N28 1.83s N8 http (*persistConn) readLoop 0 of 0.18s (2.36%) N8->N1 0.18s N9 sync (*Cond) Wait 0.04s (0.57%) N10 http (*conn) serve 0 of 0.04s (0.57%) N27 http (*response) finishRequest 0 of 0.04s (0.57%) N10->N27 0.04s N11 testing (*B) Run 0 of 3.11s (39.80%) N32 testing (*B) run 0 of 3.11s (39.77%) N11->N32 3.11s N12 http (*Transport) roundTrip 0 of 1.83s (23.37%) N26 http (*persistConn) roundTrip 0 of 1.83s (23.36%) N12->N26 1.83s N13 bytes (*Buffer) ReadFrom 0 of 0.11s (1.46%) N22 http (*bodyEOFSignal) Read 0 of 0.11s (1.46%) N13->N22 0.11s N15 ioutil readAll 0 of 0.11s (1.46%) N14->N15 0.11s N15->N13 0.11s N30 http_test TestMain 0 of 3.11s (39.80%) N16->N30 3.11s N17 http (*Client) Do 0 of 1.83s (23.37%) N19 http (*Client) do 0 of 1.83s (23.37%) N17->N19 1.83s N18 http (*Client) Get 0 of 1.83s (23.37%) N18->N17 1.83s N20 http (*Client) send 0 of 1.83s (23.37%) N19->N20 1.83s N29 http send 0 of 1.83s (23.37%) N20->N29 1.83s N21 http (*Transport) RoundTrip 0 of 1.83s (23.37%) N21->N12 1.83s N23 http (*bodyEOFSignal) condfn 0 of 0.11s (1.46%) N22->N23 0.11s N25 http (*persistConn) readLoop func4 0 of 0.11s (1.46%) N23->N25 0.11s N24 http (*connReader) abortPendingRead 0 of 0.04s (0.57%) N24->N9 0.04s N25->N3 0.11s N26->N1 1.83s N27->N24 0.04s N28->N18 1.83s N29->N21 1.83s N33 testing (*M) Run 0 of 3.11s (39.80%) N30->N33 3.11s N31 testing (*B) doBench 0 of 3.11s (39.77%) N31->N3 3.11s N34 testing (*benchContext) processBench 0 of 3.11s (39.77%) N32->N34 3.11s N35 testing runBenchmarks 0 of 3.11s (39.80%) N33->N35 3.11s N34->N31 3.11s N35->N2 3.11s N36->N11 3.11s

3.5.7 쓰레드 생성 프로파일링

Go 1.11(?)은 운영체제 쓰레드 작성 프로파일링에 대한 지원이 추가되었습니다.

쓰레드 작성 프로파일 링을 godoc에 추가하고 프로파일링 결과인 godoc -http=:8080 -index를 살펴보세요.

3.5.8 프레임 포인터

Go 1.7이 출시되었으며 amd64용 새 컴파일러와 함께 컴파일러는 기본적으로 프레임 포인터를 활성화합니다.

프레임 포인터는 항상 현재 스택 프레임의 상단을 가리키는 레지스터입니다.

프레임 포인터를 사용하면 gdb(1)perf(1)와 같은 도구가 Go 호출 스택을 이해할 수 있습니다.

이 워크샵에서는 이러한 도구를 다루지 않지만 Go 프로그램을 프로파일 링하는 7가지 방법에 대해 발표한 프레젠테이션을 읽고 볼 수 있습니다.

3.5.9 연습문제

잘 알고 있는 코드 조각에서 프로파일을 생성합니다. 코드 샘플이 없으면 godoc을 프로파일링해 봅니다.

% go get golang.org/x/tools/cmd/godoc
% cd $GOPATH/src/golang.org/x/tools/cmd/godoc
% vim main.go

한 머신에서 프로필을 생성하고 다른 머신에서 검사하려면 어떻게 해야할까요?

4. 컴파일러 최적화

이 섹션에서는 Go 컴파일러가 수행하는 최적화 중 일부를 다룹니다.

예를 들면 다음과 같습니다.

  • 이스케이프 분석
  • 인라인
  • 데드 코드 제거

코드는 여전히 AST 형식이지만, 컴파일러의 프론트 엔드에서 모두 처리됩니다. 그런 다음 코드는 SSA 컴파일러로 전달되어 더욱 최적화됩니다.

4.1 Go 컴파일러의 역사

Go 컴파일러는 2007년경 Plan9 컴파일러 툴 체인의 포크로 시작했습니다. 당시 컴파일러는 아호(Aho)나 울만(Ullman)의 드래곤북(Principles of Compiler Design)과 매우 닯아 있습니다.

2015년 당시 Go 1.5 컴파일러는 기계적으로 C에서 Go로 변환되었습니다.

1 년 후, Go 1.7은 SSA 기술을 기반으로 한 새로운 컴파일러 백엔드를 도입하여 이전의 Plan 9 스타일 코드 생성을 대체했습니다. 이 새로운 백엔드는 일반 및 아키텍처별 최적화에 대한 많은 기회를 제공했습니다.

4.2 이스케이프 분석 (Escape analysis)

우리가 논의하고자 하는 첫 번째 최적화는 이스케이프 분석입니다.

이스케이프 분석이 무엇을 하는 것인지 보여 주려면, Go 스펙에 힙이나 스택이 언급되어 있지 않음을 상기하셔야 합니다. 도입부에서 언어가 가비지 콜렉트된다는 것을 언급할 뿐, 이것이 어떻게 이루어지는지에 대한 힌트는 주지 않습니다.

Go 스펙을 준수하는 Go 구현은 모든 할당을 힙에 저장할 수 있습니다. 그렇게 되면 가비지 컬렉터에 큰 부담이 되겠지만, 잘못된 것은 아닙니다. 몇 년 동안, gccgo는 이스케이프 분석에 대한 지원이 매우 제한적이었으므로 힙 모드에서 효과적으로 작동하는 것으로 간주될 수 있습니다.

그러나 고루틴의 스택은 지역 변수를 저장하는데 값싼 장소입니다. 왜냐하면 스택에 대해서 가비지 콜렉션할 필요가 없기 때문입니다. 따라서 스택에 저장하는 것이 안전한 경우, 스택에 할당하는 것이 더 효율적입니다.

일부 언어, 예를 들면 C 및 C++에서, 스택 또는 힙 할당을 선택하는 것은 프로그래머의 수작업이었습니다. 힙 할당은 mallocfree로 이루어지고 무료이며 스택 할당은 alloca를 통해 이루어집니다. 이러한 메커니즘을 사용해서 발생하는 실수가 메모리 손상 버그의 흔한 원인입니다.

Go에서는, 컴파일러가 함수 호출의 라이프 사이클보다 오래 존재하는 값은 힙에 자동적으로 이동시킵니다. 이를 값이 힙으로 빠져나갔다(이스케이프되었다)고 말합니다.

type Foo struct {
	a, b, c, d int
}

func NewFoo() *Foo {
	return &Foo{a: 3, b: 1, c: 4, d: 7}
}

이 예제에서 NewFoo에 할당된 Foo는 힙으로 이동하여 NewFoo가 반환된 후에도 내용이 유효하게 유지됩니다.

이것은 Go의 초기 시절부터 존재했습니다. 자동 수정 기능만큼의 최적화는 아닙니다. Go에서 실수로 스택에 할당된 변수의 주소를 반환할 수 없습니다.

그러나 컴파일러는 그 반대로 할 수도 있습니다. 힙에 할당된 것으로 추정되는 것을 찾아 스택으로 옮길 수 있습니다.

예제를 살펴보겠습니다.

func Sum() int {
	const count = 100
	numbers := make([]int, count)
	for i := range numbers {
		numbers[i] = i + 1
	}

	var sum int
	for _, i := range numbers {
		sum += i
	}
	return sum
}

func main() {
	answer := Sum()
	fmt.Println(answer)
}

Sum은 1과 100 사이에 int를 더하고 결과를 반환합니다.

숫자 slice는 Sum 내에서만 참조되므로, 컴파일러는 해당 슬라이스에 대한 100개의 정수를 힙이 아닌 스택에 저장합니다. 가비지 콜렉트할 numbers가 필요 없습니다. Sum이 반환될 때 자동으로 해제됩니다.

4.2.1 증명하기

컴파일러 이스케이프 분석 디시즌을 출력하려면, -m 플래그를 사용합니다.

% go build -gcflags=-m examples/esc/sum.go
# command-line-arguments
examples/esc/sum.go:22:13: inlining call to fmt.Println
examples/esc/sum.go:8:17: Sum make([]int, count) does not escape
examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13: io.Writer(os.Stdout) escapes to heap
examples/esc/sum.go:22:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape

8행은 컴파일러가 make ([] int, 100)의 결과가 힙으로 이스케이프되지 않음을 올바르게 추론했음을 보여줍니다.

22번 째 줄은 answer가 힙으로 이스케이프되었다고 보고하는데 fmt.Println은 가변적(variadic) 함수입니다. 가변적 함수에 대한 매개 변수는 Slice로 박싱됩니다, 이 경우에는 []interface {}입니다. 그래서 answer는 인터페이스 값으로 배치됩니다. 왜냐하면 fmt.Println 호출에 의해 참조되기 때문입니다. Go 1.6부터 가비지 콜렉터는 인터페이스를 통해 전달된 모든 값이 포인터가 되어야하므로 컴파일러가 보는 관점은 대략 다음과 같습니다.

var answer = Sum()
fmt.Println([]interface{&answer}...)

이는 다음과 같이= -gcflags="-m -m" 플래그를 사용해서 확인할 수 있습니다.

% go build -gcflags='-m -m' examples/esc/sum.go 2>&1 | grep sum.go:22
examples/esc/sum.go:22:13: inlining call to fmt.Println func(...interface {}) (int, error) { return fmt.Fprintln(io.Writer(os.Stdout), fmt.a...) }
examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13:      from ~arg0 (assign-pair) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: io.Writer(os.Stdout) escapes to heap
examples/esc/sum.go:22:13:      from io.Writer(os.Stdout) (passed to call[argument escapes]) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: main []interface {} literal does not escape

요약하자면, 22번 째 줄은 걱정하지 않아도 됩니다. 이번 논의에서 중요하지 않습니다.

4.2.2 연습문제

  • 이 최적화가 모든 count 값에 참일까요?
  • count가 상수가 아닌 변수인 경우, 이 최적화가 참일까요?
  • countSum의 매개변수인 경우, 이 최적화가 참일까요?

4.2.3 이스케이프 분석 (계속)

이 예제는 약간 인위적인 예제입니다. 실제 코드가 아니라 예제일 뿐입니다.

type Point struct{ X, Y int }

const Width = 640
const Height = 480

func Center(p *Point) {
	p.X = Width / 2
	p.Y = Height / 2
}

func NewPoint() {
	p := new(Point)
	Center(p)
	fmt.Println(p.X, p.Y)
}

NewPoint는 새 *Pointp를 생성합니다. p를 스크린 중앙의 좌표로 이동시키는 Center 함수로 전달합니다. 마지막으로 p.Xp.Y를 출력합니다.

% go build -gcflags=-m examples/esc/center.go
# command-line-arguments
examples/esc/center.go:11:6: can inline Center
examples/esc/center.go:18:8: inlining call to Center
examples/esc/center.go:19:13: inlining call to fmt.Println
examples/esc/center.go:11:13: Center p does not escape
examples/esc/center.go:19:15: p.X escapes to heap
examples/esc/center.go:19:20: p.Y escapes to heap
examples/esc/center.go:19:13: io.Writer(os.Stdout) escapes to heap
examples/esc/center.go:17:10: NewPoint new(Point) does not escape
examples/esc/center.go:19:13: NewPoint []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape

pnew 함수로 할당 되었더라도, 참조가 없는 pCenter 함수를 이스케이프하지 않기 때문에, p가 힙에 저장되지 않습니다.

질문: 19행은 어떤가요? p가 이스케이프되지 않으면, 힙으로 이스케이프되는 건 무엇일까요?

Sum이 할당되지 않도록 벤치마크를 작성해보세요

4.3 인라이닝

Go 함수에서 호출은 고정된 오버헤드를 가지는데, 스택 및 선점(preemption)을 체크합니다.

이 중 일부는 하드웨어 분기 예측 변수에 의해 개선되지만, 함수 크기와 클럭 주기에 있어서는 여전히 비용이 듭니다.

인라이닝은 이러한 비용을 피하는 고전적인 최적화입니다.

Go 1.11 인라이닝이 오직 리프 함수에만 작용하기 전까지는, 함수는 다른 함수를 호출하지 않습니다. 이에 대한 정당성은 다음과 같습니다.

만약 함수가 많은 일을 한다면, 프리앰블 오버헤드는 무시할 수 있습니다. 이는 함수가 특정 크기를 초과하는 이유입니다. (현재 일부 명령어 카운트와 추가로 인라이닝을 방지하는 몇 가지 작업을 합쳐서입니다. (예: Go 1.7 이전 스위치))

반면에 작은 함수는 상대적으로 적은 양의 유용한 작업을 수행하기 위해 고정된 오버헤드를 지닙니다. 가장 많은 혜택을 받기 때문에 이들이 인라이닝 타겟이 되는 함수들입니다.

또 다른 이유는 인라인이 많으면, 스택 트레이싱을 따라가기가 어렵습니다.

4.3.1 인라이닝 (예제)

func Max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func F() {
	const a, b = 100, 20
	if Max(a, b) == b {
		panic(b)
	}
}

다시 -gcflags = -m 플래그를 사용하여 컴파일러 최적화 디시즌을 살펴봅시다.

% go build -gcflags=-m examples/inl/max.go
# command-line-arguments
examples/inl/max.go:4:6: can inline Max
examples/inl/max.go:11:6: can inline F
examples/inl/max.go:13:8: inlining call to Max
examples/inl/max.go:20:6: can inline main
examples/inl/max.go:21:3: inlining call to F
examples/inl/max.go:21:3: inlining call to Max

컴파일러는 두 줄을 출력했습니다.

  • 첫 번째는 3행에서, Max 선언은, 인라인될 수 있음을 알려줍니다.
  • 두 번째는 Max의 본문이 12행에서 호출자에게 인라인되었다고 보고합니다.

//go:noinline 주석을 사용하지 않고, Max를 다시 작성하여 여전히 올바른 답을 리턴하지만, 더 이상 컴파일러가 인라인될 수 있다고 고려하지 않도록 해봅시다.

4.3.2 인라인은 어떻게 생겼나요?

max.go를 컴파일하고 F()의 최적화된 버전을 봅시다.

% go build -gcflags=-S examples/inl/max.go 2>&1 | grep -A5 '"".F STEXT'
"".F STEXT nosplit size=2 args=0x0 locals=0x0
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     TEXT    "".F(SB), NOSPLIT|ABIInternal, $0-0
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:13)     PCDATA  $2, $0

이는 Max가 인라인되었을 때 F의 바디입니다. 이 함수에는 아무 일도 일어나지 않습니다. 쓸데없이 화면에 텍스트가 많이 나오는 건 알고 있지만, 제 말을 믿으세요. 한가지 일어나는 일은 RET뿐입니다. 실제로 F는 다음과 같이 되었습니다.

func F() {
        return
}

Note

FUNCDATA와 PCDATA가 무엇인가요? -S의 출력은 바이너리로 들어가는 최종 기계코드가 아닙니다. 링커는 최종 링크단계에서 일부를 처리합니다. FUNCDATAPCDATA와 같은 줄은 연결시 다른 곳으로 이동하는 가비지 콜렉터의 메타 데이터입니다. -S의 출력을 읽을 때 FUNCDATAPCDATA 행은 그냥 무시하세요. 그들은 최종 바이너리의 일부가 아니니깐요.

4.3.3 논의

F()에서 왜 ab를 상수로 선언했을까요?

ab가 변수로 선언되면 어떻게 될까요? a와 b가 매개변수로 F()에 전달되면 어떻게 될까요?

Note

-gcflags = -S는 작업 디렉토리에 최종 바이너리가 빌드되는 것을 막지 않습니다. 이후의 go build.. 실행으로 출력이 생성되지 않으면 작업 디렉토리에서 ./max 바이너리를 삭제하세요.

4.3.4 인라이닝 레벨 조정

인라인 레벨은 -gcflags = -l 플래그를 사용하여 조정됩니다. 다소 혼란스럽지만, 하나의 -l을 전달하면 인라인이 비활성화되고 더 공격적인 설정으로 둘 이상을 전달하면 인라인이 활성화됩니다.

  • -gcflags = -l, 인라인 비활성화
  • 아무것도 전달하지 않음, 일반적인 인라이닝
  • -gcflags = "-l -l" 인라인 레벨 2, 더 공격적이며, 더 빨라질 수 있으며, 더 큰 바이너리를 만듭니다.
  • -gcflags = "-l -l -l" 인라인 레벨 3, 좀 더 공격적이며, 확실히 더 큰 바이너리를 만들고, 좀 더 빠를 수 있지만 버그가 있을 수 있습니다.
  • - Go 1.11의 -gcflags = -l = 4 (4 개의 -l)는 실험적인 중간 스택 인라인 최적화를 가능하게 합니다.

4.3.5 중간 스택 인라이닝

Go 1.12부터 중간 스택(mid stack)이라고 부르는 인라이닝이 활성화되었습니다 (이전에 Go 1.11 미리보기에서 -gcflags='-l -l -l' 플래그로 사용할 수 있었습니다.)

이전 예에서 중간 스택의 인라이닝 예제를 볼 수 있습니다. Go 1.11 및 이전 F는 리프 함수가 될 수 없었습니다. max를 호출했었죠. 그러나 개선된 F 인라이닝으로 인해 F는 이제 호출자에게 인라이닝됩니다. 이것은 두 가지 이유입니다. maxF에 인라인딩되면, F는 다른 함수 호출을 포함하지 않으므로, 복잡성 예산(complexity budget)을 초과하지 않았다고 가정하며, 잠재적 리프 함수가 됩니다. 왜냐하면 F는 단순한 함수이기 때문에 max 호출에 관계없이 중간 스택 인라이닝에 적합합니다. 인라이닝과 데드 코드 제거로 복잡성 예산(complexity budget)의 많은 부분이 제거되었기 때문이죠.

Tip

중간 스택 인라이닝을 사용하면 함수의 고속 경로를 인라인화하여, 고속 경로에서 함수 호출 오버헤드를 제거할 수 있습니다. Go 1.13에 들어온 최근의 CLsync.RWMutex.Unlock() 적용된 이 기술을 보여줍니다.

더 읽을거리

4.4 데드 코드 제거

ab가 상수라는 것이 왜 중요할까요?

어떤 일이 발생했는지 이해하기 위해 컴파일러에서 MaxF로 인라인으로 설정한 후 무엇을 발생하는지 보겠습니다. 컴파일러로부터 이를 쉽게 얻을 수는 없지만, 손으로 직접하는 것이 좋습니다.

이전:

func Max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func F() {
	const a, b = 100, 20
	if Max(a, b) == b {
		panic(b)
	}
}

이후:

func F() {
	const a, b = 100, 20
	var result int
	if a > b {
		result = a
	} else {
		result = b
	}
	if result == b {
		panic(b)
	}
}

ab는 상수이므로 컴파일시에 분기문이 절대 false가 아님을 입증합니다. 100은 항상 20보다 크니깐요. 따라서 컴파일러는 F를 더욱 최적화할 수 있습니다.

func F() {
	const a, b = 100, 20
	var result int
	if true {
		result = a
	} else {
		result = b
	}
	if result == b {
		panic(b)
	}
}

이제 분기문 결과를 알게 되어 결과의 내용도 알게 됩니다. 이것을 분기문 제거(branch elimination)라고 부릅니다.

func F() {
        const a, b = 100, 20
        const result = a
        if result == b {
                panic(b)
        }
}

이제 분기문이 제거되어 결과가 항상 a와 같다는 것을 알 수 있으며 a가 상수이기 때문에 결과가 상수라는 것을 알 수 있습니다. 컴파일러는 이 증명을 두 번째 분기문에 적용합니다

func F() {
        const a, b = 100, 20
        const result = a
        if false {
                panic(b)
        }
}

그리고 다시 분기문 제거를 사용하여 최종 형태 F로 줄입니다.

func F() {
        const a, b = 100, 20
        const result = a
}

마지막으로 그냥 저렇게 됩니다.

func F() {
}

4.4.1 데드 코드 제거

분기 제거는 데드 코드 제거로 알려진 최적화 방법 중 하나입니다. 실제로 정적 증거(static proofs)를 사용하여 코드 조각에 도달할 수 없거나 죽은 것으로 알려져 있으므로 최종 바이너리에서 컴파일, 최적화 또는 생성할 필요가 없습니다.

데드 코드 제거가 인라인과 함께 작동하여 도달할 수 없는 것으로 입증된 루프와 분기문을 제거하여 생성된 코드의 양을 줄이는 방법을 보았습니다.

이를 활용하여 고가의 디버깅을 구현하고 숨길 수 있습니다

const debug = false

빌드 태그와 함께 사용하면 매우 유용해질 수 있습니다.

4.4.2 더 읽을거리

4.5 컴파일러 플래그 연습문제

컴파일러 플래그는 다음과 같이 주어집니다.

go build -gcflags=$FLAGS

아래 컴파일러 기능들의 동작을 살펴봅시다.

  • -S는 컴파일할 패키지의 (Go Flavoured) 어셈블리를 출력합니다.
  • -l은 인라이너의 동작을 제어합니다. -l은 인라이닝을 비활성화하고 -l -l은 인라이닝 코드에 대한 컴파일러의 식욕을 자극합니다. 컴파일 시간, 프로그램 크기 및 실행 시간의 차이를 실험할 수 있습니다.
  • -m은 인라이닝, 이스케이프 분석과 같은 디시즌을 제어합니다. -m-m은 컴파일러가 생각하고 있는 것들을 자세히 출력합니다.
  • -l -N은 모든 최적화를 비활성화합니다.

Note

go build... 실행 결과로 출력이 생성되지 않으면, 작업 디렉토리에서 ./max 바이너리를 삭제하세요.

4.5.1 더 읽을거리

4.6 바운드 체크 제거

Go는 바운드 체크 언어입니다. 이는 배열 및 슬라이스 구독 연산이 해당 타입의 바운드 내에 있는지 체크한다는 것을 뜻합니다.

배열의 경우, 컴파일 타임에 수행합니다. 슬라이스의 경우, 런타임에만 수행됩니다.

var v = make([]int, 9)

var A, B, C, D, E, F, G, H, I int

func BenchmarkBoundsCheckInOrder(b *testing.B) {
	for n := 0; n < b.N; n++ {
		A = v[0]
		B = v[1]
		C = v[2]
		D = v[3]
		E = v[4]
		F = v[5]
		G = v[6]
		H = v[7]
		I = v[8]
	}
}

-gcflags = -S를 사용해서 BenchmarkBoundsCheckInOrder를 디셈블보세요. 루프당 몇 개의 바운드 체크 작업이 수행되나요?

func BenchmarkBoundsCheckOutOfOrder(b *testing.B) {
	for n := 0; n < b.N; n++ {
		I = v[8]
		A = v[0]
		B = v[1]
		C = v[2]
		D = v[3]
		E = v[4]
		F = v[5]
		G = v[6]
		H = v[7]
	}
}

A부터 I까지의 순서를 재정렬하면 어셈블리에 영향을 주어야 합니다. BenchmarkBoundsCheckOutOfOrder를 디셈블하고 찾아보세요.

4.6.1 연습문제

  • 구독 연산의 순서를 재정렬하면 함수 크기에 영향을 주나요? 함수 속도에 영향을 주나요?
  • vBenchmark 함수 내부로 이동하면 어떻게 될까요?
  • vvar v[9] int? 배열로 선언되면 어떻게 될까요?

5. 엑스큐션 트레이서

엑스큐션 트레이서는 Go 1.5때 Dmitry Vyukov에 의해 개발되었으며, 몇 년 동안 문서화 및 활용률이 낮았습니다.

샘플 기반 프로파일링과 달리 엑스큐션 트레이서는 Go 런타임에 통합되므로 특정 시점에서 Go 프로그램이 무엇을 하고 있는지 알 수 있을 뿐 아니라 그 이유도 알 수 있습니다.

5.1 엑스큐션 트레이서란 무엇인가, 왜 필요한가

저는 엑스큐션 트레이서가 무엇을 하는지 그리고 왜 중요한지를 설명하는 가장 쉬운 방법은 pprof, 즉 go tool pprof가 잘 수행되지 않는 코드 조각을 살펴보는 것이라고 생각합니다.

examples/mandelbrot 디렉토리에는 간단한 만델브로트 생성기가 들어 있습니다. 이 코드는 프란체스 캄포이 저장소에서 따왔습니다.

cd examples/mandelbrot
go build && ./mandelbrot

5.1.1 얼마나 오래 걸리는가

자, 1024 x 1024 픽셀의 이미지를 생성하는데 얼마나 오래 걸릴까요? 가장 간단한 방법은 time(1)과 같은 것들을 사용하는 것입니다.

% time ./mandelbrot
real    0m1.654s
user    0m1.630s
sys     0m0.015s

Note

time go run man을 사용하지 마세요. 그렇지 않으면 프로그램을 컴파일하고 실행하는데 걸리는 시간이 길어집니다.

5.1.2 프로그램은 무엇을 하고 있을까요

따라서, 이 예제에서 프로그램은 만델브로트를 생성하고 png파일에 쓰는 데 1.6초가 걸렸습니다.

괜찮나요? 더 빨리 만들 수 있을까요?

질문에 대한 한 가지 답변은 Go 빌트인 pprof 지원 기능을 사용하여 프로그램을 프로파일링하는 것이 될 수 있습니다.

한번 해봅시다.

5.2 프로파일링 생성

프로파일을 생성하려면 다음 중 하나를 선택해야 합니다.

  • runtime/pprof 패키지를 직접 사용합니다.
  • github.com/pkg/profile과 같은 래퍼를 사용하여 자동화합니다.

5.3 runtime/pprof 사용한 프로파일 생성

마술이 없다는 것을 보여주기 위해 os.Stdout에 CPU 프로파일링을 쓰도록 프로그램을 수정해 봅시다.

import "runtime/pprof"

func main() {
	pprof.StartCPUProfile(os.Stdout)
	defer pprof.StopCPUProfile()

이 코드를 main 함수의 맨 위에 추가하면 프로그램은 os.Stdout에 프로파일을 작성합니다.

cd examples/mandelbrot-runtime-pprof
go run mandelbrot.go > cpu.pprof

Note

CPU 프로파일에는 컴파일이 아닌 mandelbrot.go의 실행만 포함되므로 이 경우 go run을 사용할 수 있습니다.

5.3.1 github.com/pkg/profile 사용한 프로파일 생성

이전 슬라이드는 프로파일을 생성하는 매우 값싼 방법을 보여 주었지만, 몇 가지 문제가 있습니다.

  • 출력을 파일로 리디렉션하는 것을 잊어버린 경우 해당 터미널 세션을 날리게 됩니다. 😞 (힌트 : reset (1)은 여러분의 친구입니다)
  • os.Stdout에 다른 것을 쓰게 되면(예 : fmt.Println), 트레이싱에 오류가 생깁니다.

runtime/pprof을 사용하는 권장 방법은 트레이싱을 파일에 쓰는 것입니다. 하지만, 누군가가 ^C으로 프로그램을 중단시켰다면, 트레이싱이 멈춰 있는지, 그리고 프로그램이 중단되기 전에 파일이 닫혀 있는지 확인해야 합니다.

그래서, 몇 년 전에 저는 이를 관리하기 위해 패키지를 작성했습니다.

import "github.com/pkg/profile"

func main() {
	defer profile.Start(profile.CPUProfile, profile.ProfilePath(".")).Stop()

이 버전을 실행하면, 현재 작업 디렉토리에 프로파일이 작성됩니다.

% go run mandelbrot.go
2017/09/17 12:22:06 profile: cpu profiling enabled, cpu.pprof
2017/09/17 12:22:08 profile: cpu profiling disabled, cpu.pprof

Note

pkg/profile을 사용하는 것이 필수는 아니지만, 트레이싱 수집 및 기록과 관련된 많은 보일러플레이트가 필요하므로 이 워크샵의 나머지 부분에서 사용합니다.

5.3.2 프로파일 분석하기

이제 프로파일이 있으니, go tool pprof를 사용해서 분석할 수 있습니다.

% go tool pprof -http=:8080 cpu.pprof

이 실행에서 프로그램이 1.81초 동안 실행되었음을 알 수 있습니다 (프로파일링은 약간의 오버헤드를 추가시킴). pprof는 샘플 기반이므로 운영체제의 SIGPROF 타이머에 의존하여 pprof가 1.53초 동안만 데이터를 캡처했음을 알 수 있습니다.

Tip

Go 1.9 이후 pprof 트레이싱에는 트레이싱을 분석하는데 필요한 모든 정보가 포함됩니다. 더 이상 트레이싱을 생성하는 일치하는 바이너리가 없어도 됩니다. 🎉

top pprof 명령어를 사용하여 트레이싱이 기록한 함수를 정렬할 수 있습니다.

% go tool pprof cpu.pprof
Type: cpu
Time: Mar 24, 2019 at 5:18pm (CET)
Duration: 2.16s, Total samples = 1.91s (88.51%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 1.90s, 99.48% of 1.91s total
Showing top 10 nodes out of 35
      flat  flat%   sum%        cum   cum%
     0.82s 42.93% 42.93%      1.63s 85.34%  main.fillPixel
     0.81s 42.41% 85.34%      0.81s 42.41%  main.paint
     0.11s  5.76% 91.10%      0.12s  6.28%  runtime.mallocgc
     0.04s  2.09% 93.19%      0.04s  2.09%  runtime.memmove
     0.04s  2.09% 95.29%      0.04s  2.09%  runtime.nanotime
     0.03s  1.57% 96.86%      0.03s  1.57%  runtime.pthread_cond_signal
     0.02s  1.05% 97.91%      0.04s  2.09%  compress/flate.(*compressor).deflate
     0.01s  0.52% 98.43%      0.01s  0.52%  compress/flate.(*compressor).findMatch
     0.01s  0.52% 98.95%      0.01s  0.52%  compress/flate.hash4
     0.01s  0.52% 99.48%      0.01s  0.52%  image/png.filter

pprof가 스택을 캡처했을 때 CPU에는 main.fillPixel 함수가 가장 많았습니다.

스택에서 main.paint를 찾는 것은 놀라운 일이 아닙니다. 픽셀을 그리는 것은 이 프로그램이 하는 일입니다. 그러나 페인트가 너무 많은 시간을 소비하는 원인은 무엇일까요? top에 대해 누적 플래그(cummulative flag)를 사용해서 확인할 수 있습니다.

(pprof) top --cum
Showing nodes accounting for 1630ms, 85.34% of 1910ms total
Showing top 10 nodes out of 35
      flat  flat%   sum%        cum   cum%
         0     0%     0%     1840ms 96.34%  main.main
         0     0%     0%     1840ms 96.34%  runtime.main
     820ms 42.93% 42.93%     1630ms 85.34%  main.fillPixel
         0     0% 42.93%     1630ms 85.34%  main.seqFillImg
     810ms 42.41% 85.34%      810ms 42.41%  main.paint
         0     0% 85.34%      210ms 10.99%  image/png.(*Encoder).Encode
         0     0% 85.34%      210ms 10.99%  image/png.Encode
         0     0% 85.34%      160ms  8.38%  main.(*img).At
         0     0% 85.34%      160ms  8.38%  runtime.convT2Inoptr
         0     0% 85.34%      150ms  7.85%  image/png.(*encoder).writeIDATs

정렬 결과는 main.fillPixed가 대부분의 작업을 처리하고 있다고 제안하고 있습니다.

Note

web 커맨드를 사용하여 프로파일을 시각화할 수도 있습니다.

unnamed cluster_L Type: cpu Type: cpu Time: Sep 17, 2017 at 12:22pm (AEST) Duration: 1.81s, Total samples = 1.53s (84.33%) Showing nodes accounting for 1.53s, 100% of 1.53s total N1 main paint mandelbrot.go 1s (65.36%) N2 runtime main proc.go 0 of 1.53s (100%) N4 main main mandelbrot.go 0 of 1.53s (100%) N2->N4 1.53s N3 main fillPixel mandelbrot.go 0.27s (17.65%) of 1.27s (83.01%) N3->N1 1s (inline) N35 image/png Encode writer.go 0 of 0.26s (16.99%) N4->N35 0.26s N38 main seqFillImg mandelbrot.go 0 of 1.27s (83.01%) N4->N38 1.27s N5 runtime mallocgc malloc.go 0.13s (8.50%) of 0.16s (10.46%) N41 runtime (*mcache) nextFree malloc.go 0 of 0.03s (1.96%) N5->N41 0.03s N6 image/png (*encoder) writeImage writer.go 0 of 0.19s (12.42%) N8 main (*img) At mandelbrot.go 0 of 0.18s (11.76%) N6->N8 0.11s N18 image/png filter writer.go 0.01s (0.65%) N6->N18 0.01s N31 compress/zlib (*Writer) Write writer.go 0 of 0.07s (4.58%) N6->N31 0.07s N7 image/png (*Encoder) Encode writer.go 0 of 0.26s (16.99%) N34 image/png (*encoder) writeIDATs writer.go 0 of 0.19s (12.42%) N7->N34 0.19s N36 image/png opaque writer.go 0 of 0.07s (4.58%) N7->N36 0.07s N10 runtime convT2Inoptr iface.go 0 of 0.18s (11.76%) N8->N10 0.18s N9 syscall Syscall asm_darwin_amd64.s 0.05s (3.27%) N10->N5 0.16s N13 runtime memmove memmove_amd64.s 0.02s (1.31%) N10->N13 0.02s N11 compress/flate (*compressor) deflate deflate.go 0.01s (0.65%) of 0.07s (4.58%) N24 compress/flate (*compressor) findMatch deflate.go 0 of 0.01s (0.65%) N11->N24 0.01s N26 compress/flate (*compressor) writeBlock deflate.go 0 of 0.05s (3.27%) N11->N26 0.05s N12 runtime mmap sys_darwin_amd64.s 0.02s (1.31%) N14 compress/flate (*huffmanBitWriter) write huffman_bit_writer.go 0 of 0.05s (3.27%) N27 compress/flate (*dictWriter) Write deflate.go 0 of 0.05s (3.27%) N14->N27 0.05s N15 compress/flate (*huffmanBitWriter) writeTokens huffman_bit_writer.go 0 of 0.05s (3.27%) N28 compress/flate (*huffmanBitWriter) writeBits huffman_bit_writer.go 0 of 0.01s (0.65%) N15->N28 0.01s N30 compress/flate (*huffmanBitWriter) writeCode huffman_bit_writer.go 0 of 0.04s (2.61%) N15->N30 0.04s N16 runtime systemstack asm_amd64.s 0 of 0.03s (1.96%) N42 runtime (*mcache) nextFree func1 malloc.go 0 of 0.02s (1.31%) N16->N42 0.02s N46 runtime (*mheap) alloc func1 mheap.go 0 of 0.01s (0.65%) N16->N46 0.01s N17 compress/flate matchLen deflate.go 0.01s (0.65%) N19 runtime (*mcentral) grow mcentral.go 0 of 0.02s (1.31%) N45 runtime (*mheap) alloc mheap.go 0 of 0.01s (0.65%) N19->N45 0.01s N51 runtime heapBits initSpan mbitmap.go 0 of 0.01s (0.65%) N19->N51 0.01s N20 runtime memclrNoHeapPointers memclr_amd64.s 0.01s (0.65%) N21 bufio (*Writer) Flush bufio.go 0 of 0.05s (3.27%) N32 image/png (*encoder) Write writer.go 0 of 0.05s (3.27%) N21->N32 0.05s N22 bufio (*Writer) Write bufio.go 0 of 0.05s (3.27%) N22->N21 0.05s N23 compress/flate (*Writer) Write deflate.go 0 of 0.07s (4.58%) N25 compress/flate (*compressor) write deflate.go 0 of 0.07s (4.58%) N23->N25 0.07s N24->N17 0.01s N25->N11 0.07s N29 compress/flate (*huffmanBitWriter) writeBlock huffman_bit_writer.go 0 of 0.05s (3.27%) N26->N29 0.05s N27->N22 0.05s N28->N14 0.01s N29->N15 0.05s N30->N14 0.04s N31->N23 0.07s N33 image/png (*encoder) writeChunk writer.go 0 of 0.05s (3.27%) N32->N33 0.05s N39 os (*File) Write file.go 0 of 0.05s (3.27%) N33->N39 0.05s N34->N6 0.19s N35->N7 0.26s N36->N8 0.07s N37 internal/poll (*FD) Write fd_unix.go 0 of 0.05s (3.27%) N56 syscall Write syscall_unix.go 0 of 0.05s (3.27%) N37->N56 0.05s N38->N3 1.27s N40 os (*File) write file_unix.go 0 of 0.05s (3.27%) N39->N40 0.05s N40->N37 0.05s N41->N16 0.03s N43 runtime (*mcache) refill mcache.go 0 of 0.02s (1.31%) N42->N43 0.02s N44 runtime (*mcentral) cacheSpan mcentral.go 0 of 0.02s (1.31%) N43->N44 0.02s N44->N19 0.02s N45->N20 0.01s N48 runtime (*mheap) alloc_m mheap.go 0 of 0.01s (0.65%) N46->N48 0.01s N47 runtime (*mheap) allocSpanLocked mheap.go 0 of 0.01s (0.65%) N49 runtime (*mheap) grow mheap.go 0 of 0.01s (0.65%) N47->N49 0.01s N48->N47 0.01s N50 runtime (*mheap) sysAlloc malloc.go 0 of 0.01s (0.65%) N49->N50 0.01s N55 runtime sysMap mem_darwin.go 0 of 0.01s (0.65%) N50->N55 0.01s N53 runtime newMarkBits mheap.go 0 of 0.01s (0.65%) N51->N53 0.01s N52 runtime newArenaMayUnlock mheap.go 0 of 0.01s (0.65%) N54 runtime sysAlloc mem_darwin.go 0 of 0.01s (0.65%) N52->N54 0.01s N53->N52 0.01s N54->N12 0.01s N55->N12 0.01s N57 syscall write zsyscall_darwin_amd64.go 0 of 0.05s (3.27%) N56->N57 0.05s N57->N9 0.05s

5.4 트레이싱과 프로파일링

이 예제가 프로파일링의 한계를 보여주길 바랍니다. 프로파일링은 프로파일러가 본 내용을 알려줍니다. fillPixel은 모든 작업을 수행했습니다. 프로파일링이 그에 대해 할 수 있는 일이 많지 않은 듯 보입니다.

이제 동일한 프로그램에 대한 다른 관점을 제공하는 엑스큐션 트레이서 프로그램을 소개하는 것이 좋을 듯합니다.

5.4.1 엑스큐션 트레이서 사용하기

트레이서를 사용하는 것은 을 호출하는 것이 전부입니다. 바뀌는 게 없습니다.

import "github.com/pkg/profile"

func main() {
	defer profile.Start(profile.TraceProfile, profile.ProfilePath(".")).Stop()

프로그램을 실행하면 현재 작업 디렉토리에 trace.out 파일이 생성됩니다.

% go build mandelbrot.go
% % time ./mandelbrot
2017/09/17 13:19:10 profile: trace enabled, trace.out
2017/09/17 13:19:12 profile: trace disabled, trace.out

real    0m1.740s
user    0m1.707s
sys     0m0.020s

pprof와 마찬가지로 go 명령에는 트레이싱을 분석하는 도구가 있습니다.

% go tool trace trace.out
2017/09/17 12:41:39 Parsing trace...
2017/09/17 12:41:40 Serializing trace...
2017/09/17 12:41:40 Splitting trace...
2017/09/17 12:41:40 Opening browser. Trace viewer s listening on http://127.0.0.1:57842

이 도구는 go tool pprof와 약간 다릅니다. 엑스큐션 트레이서 프로그램은 크롬에 내장된 많은 프로파일 시각화 인프라를 재사용하므로 go tool trace은 서버로 작동하여 원시 엑스큐션 트레이싱을 크롬이 기본적으로 표시 할 수 있는 데이터로 변환합니다.

5.4.2 트레이싱 분석하기

트레이싱에서 프로그램이 하나의 cpu만 사용하고 있다는 것을 알 수 있습니다.

func seqFillImg(m *img) {
	for i, row := range m.m {
		for j := range row {
			fillPixel(m, i, j)
		}
	}
}

기본적으로 mandelbrot.go는 각 행의 각 픽셀에 대해 fillPixel을 순서대로 호출합니다.

이미지가 페인트되면 엑스큐션 스위치가 .png 파일 쓰기로 전환되는 것을 참고하세요. 이는 힙에 가비지를 생성하여 해당 시점에서 트레이싱가 변경되므로 가비지 콜렉터 힙의 고전적인 톱니 패턴을 볼 수 있습니다.

트레이싱 프로파일은 마이크로초 수준까지 타이밍 레졸루션을 제공합니다. 이는 외부 프로파일링으로는 얻을 수 없는 것입니다.

Note

go tool trace

계속하기 전에 추적 도구의 사용법에 대해 이야기해야 할 것이 있습니다.

  • 이 도구는 크롬에 내장된 자바 스크립트 디버깅 지원을 사용합니다. 트레이싱 프로필은 크롬에서만 볼 수 있으며 Firefox, Safari, IE Edge에서는 작동하지 않습니다. 쏘리.
  • 이 제품은 Google 제품이므로 키보드 단축키를 지원합니다. WASD를 사용하여 탐색하고, ? 사용해서 목록을 얻을 수 있습니다.
  • 트레이싱를 보려면 많은 메모리가 필요할 수 있습니다. 실제로 4Gb는 부족합니다. 8Gb가 아마 최소 수준일 것입니다.
  • Fedora와 같은 OS 배포판에서 Go를 설치한 경우 트레이싱 뷰어의 지원 파일이 기본 golang deb/rpm의 일부가 아닌 어떤 -extra 패키지에 있을 수 있습니다.

5.5 하나 이상의 CPU 사용하기

이전 트레이싱에서 프로그램이 순차적으로 실행되고 있으며, 이 시스템의 다른 CPU를 활용하지 않는 것으로 확인했습니다.

만델브로트 생성은 embarassingly_parallel로 알려져 있습니다. 각 픽셀은 다른 픽셀과 독립적이며 모두 병렬로 계산할 수 있습니다. 자, 그럼 한번 해보죠.

% go build mandelbrot.go
% time ./mandelbrot -mode px
2017/09/17 13:19:48 profile: trace enabled, trace.out
2017/09/17 13:19:50 profile: trace disabled, trace.out

real    0m1.764s
user    0m4.031s
sys     0m0.865s

실행 시간은 기본적으로 동일했습니다. 더 많은 user 시간이 있었는데, 말이 됩니다. 모든 CPU를 사용하고 있었는데도, real (wall clock, 벽시계) 시간은 거의 똑같습니다.

트레이싱를 한번 볼까요.

보시다시피, 이 추적은 훨씬 더 많은 데이터를 생성했습니다.

  • 많은 작업이 완료된 것 같지만 확대하면 약간의 차이가 있습니다. 이것은 스케줄러로 여겨집니다.
  • 4개의 코어를 모두 사용하는 동안 각 fillPixel은 상대적으로 적은 양의 작업이므로 오버헤드를 예약하는 데 많은 시간을 소비하고 있습니다.

5.6 작업 배치하기

픽셀 당 하나의 고루틴을 사용하는 것은 매우 세분화된 작업입니다. 고루틴 비용을 정당화할 수 있는 작업이 충분하지 않았습니다.

대신에, 고루틴 한 개당 한 행씩 처리해 봅시다.

% go build mandelbrot.go
% time ./mandelbrot -mode row
2017/09/17 13:41:55 profile: trace enabled, trace.out
2017/09/17 13:41:55 profile: trace disabled, trace.out

real    0m0.764s
user    0m1.907s
sys     0m0.025s

좋게 개선되었습니다. 프로그램의 실행 시간을 거의 절반으로 줄였습니다. 트레이싱를 봅시다.

보시다시피 이제 트레이싱는 더 작고 작업하기가 더 쉽습니다. 우리는 전체 트레이싱를 볼 수 있는데, 이는 멋진 보너스입니다.

  • 프로그램이 시작될 때 고루틴의 수가 최대 약 1,000 개로 증가하는 것을 볼 수 있습니다. 이것은 이전 트레이싱에서 보았던 1 << 20에 비해 개선된 것입니다.
  • 확대하면 onePerRowFillImg이 더 오래 실행되는 것을 볼 수 있으며 고루틴 생성 작업이 조기에 완료되므로 스케줄러는 나머지 실행 가능한 고루틴을 효율적으로 처리합니다.

5.7 워커 사용하기

mandelbrot.go는 다른 모드를 지원하는데요, 해봅시다.

% go build mandelbrot.go
% time ./mandelbrot -mode workers
2017/09/17 13:49:46 profile: trace enabled, trace.out
2017/09/17 13:49:50 profile: trace disabled, trace.out

real    0m4.207s
user    0m4.459s
sys     0m1.284s

자, 실행시간은 이전보다 훨씬 나쁩니다. 트레이싱를 보고 무슨 일이 있었는지 봅시다.

트레이싱를 살펴보면 하나뿐인 워커 프로세스가, 프로듀서 하나와 컨슈머 하나가 있으므로, 프로듀서와 컨슈머를 교대하는 경향이 있다는 것을 알 수 있습니다. 워커 수를 늘려 봅시다.

% go build mandelbrot.go
% time ./mandelbrot -mode workers -workers 4
2017/09/17 13:52:51 profile: trace enabled, trace.out
2017/09/17 13:52:57 profile: trace disabled, trace.out

real    0m5.528s
user    0m7.307s
sys     0m4.311s

자, 더 나빠졌습니다! real 시간이 늘어났고, CPU 시간도 늘어났습니다. 무슨 일이 있었는지 트레이싱를 살펴 봅시다.

트레이싱가 엉망입니다. 더 많은 워커들이 있지만, 그 일을 하려고 싸우는 데 모든 시간을 보내는 것처럼 보입니다.

이는 채널이 언버퍼드(unbuffered)이기 때문입니다. 버퍼링되지 않은 채널은 수신할 준비가 된 사람이 있을 때까지 보낼 수 없습니다.

  • 프로듀셔는 수신할 준비가 된 워커가 있을 때까지 작업을 보낼 수 없습니다.
  • 워커들은 누군가 보낼 준비가 될 때까지 작업을 받을 수 없기 때문에 기다릴 때 서로 경쟁합니다.
  • 송신자는 권한이 없으므로 이미 실행 중인 워커보다 우선 순위를 가질 수 없습니다.

여기서 볼 수 있는 것은 버퍼되지 않은 채널에 의해 도입되는 많은 지연 시간입니다. 스케줄러 내부에는 많은 중지 및 시작이 있으며 작업을 기다리는 동안 잠재적으로 잠금 및 뮤텍스가 발생할 수 있으며, 이는 sys 시간이 더 높은 이유입니다.

5.8 버퍼드 채널 사용하기

import "github.com/pkg/profile"

func main() {
	defer profile.Start(profile.TraceProfile, profile.ProfilePath(".")).Stop()
% go build mandelbrot.go
% time ./mandelbrot -mode workers -workers 4
2017/09/17 14:23:56 profile: trace enabled, trace.out
2017/09/17 14:23:57 profile: trace disabled, trace.out

real    0m0.905s
user    0m2.150s
sys     0m0.121s

위의 row당 모드와 매우 유사합니다.

버퍼드(buffered) 채널을 사용한 트레이싱 결과는 다음과 같습니다.

  • 프로듀서는 워커가 도착할 때까지 기다릴 필요가 없고, 빠르게 채널을 채울 수 있습니다.
  • 프로듀서는 작업을 기다리지 않고 채널에서 다음 항목을 빠르게 가져올 수 있습니다.

이 방법을 사용하면 이전에 row마다 고루틴을 스케쥴링했던 것보다 픽셀마다 작업을 전달하기 위해 채널을 사용하는 속도와 거의 같은 속도를 얻게 됩니다.

행마다 동작하도록 nWorkersFillImg를 수정하세요. 결과 시간을 측정하고 트레이싱을 분석해보세요.

5.9 만델브로트 마이크로 서비스

2019년입니다. 만델브로트를 서버리스 마이크로서비스로 인터넷에 배포하지 않고 생성하는 것은 큰 의미가 없죠. 따라서, 만델웹을 선물로 드릴게요.

% go run examples/mandelweb/mandelweb.go
2017/09/17 15:29:21 listening on http://127.0.0.1:8080/

http://127.0.0.1:8080/mandelbrot

5.9.1 실행 중인 어플리케이션 트레이싱

이전 예제에서는 전체 프로그램에 대해 트레이싱을 실행했습니다.

보시다시피, 소량이라도 트레이싱이 매우 클 수 있으므로 트레이싱 데이터를 지속적으로 수집하면 너무 많은 데이터가 생성됩니다. 또한 트레이싱은 프로그램 속도에 영향을 줄 수 있습니다. 특히 활동이 많은 경우에 그렇습니다.

우리가 원하는 것은 실행중인 프로그램에서 짧은 트레이싱을 수집하는 방법입니다.

다행히도 net/http/pprof 패키지에는 이러한 기능이 있습니다.

5.9.2 http를 통한 트레이싱 수집

모두가 net/http/pprof 패키지에 대해 알고 있을 거라고 생각합니다.

import _ "net/http/pprof"

Warning

net/http/pprof를 임포트하면 http.DefaultServeMux에 트레이싱 및 프로파일링 경로를 등록합니다. Go 1.5부터 트레이싱 프로파일러가 포함됩니다.

curl (또는 wget)을 사용해서 mandelweb에서 5초 트레이싱을 확보할 수 있습니다.

% curl -o trace.out http://127.0.0.1:8080/debug/pprof/trace?seconds=5

5.9.3 부하 생성하기

이전 예제는 흥미롭지만 유휴 상태인 웹 서버에는, 정의상, 성능 문제가 없습니다. 약간의 부하를 발생시켜야합니다. 이를 위해 JBD가 개발하신 hey를 사용할게요.

% go get -u github.com/rakyll/hey

초당 1개의 요청으로 시작합니다.

% hey -c 1 -n 1000 -q 1 http://127.0.0.1:8080/mandelbrot

실행시키며, 다른 창에서 트레이싱을 수집합니다.

% curl -o trace.out http://127.0.0.1:8080/debug/pprof/trace?seconds=5
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 66169    0 66169    0     0  13233      0 --:--:--  0:00:05 --:--:-- 17390
% go tool trace trace.out
2017/09/17 16:09:30 Parsing trace...
2017/09/17 16:09:30 Serializing trace...
2017/09/17 16:09:30 Splitting trace...
2017/09/17 16:09:30 Opening browser.
Trace viewer is listening on http://127.0.0.1:60301

5.9.4 과부하 생성하기

초당 5개의 요청으로 속도를 높이겠습니다.

% hey -c 5 -n 1000 -q 5 http://127.0.0.1:8080/mandelbrot

실행시키며, 다른 창에서 트레이싱을 수집합니다.

% curl -o trace.out http://127.0.0.1:8080/debug/pprof/trace?seconds=5
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                Dload  Upload   Total   Spent    Left  Speed
100 66169    0 66169    0     0  13233      0 --:--:--  0:00:05 --:--:-- 17390
% go tool trace trace.out
2017/09/17 16:09:30 Parsing trace...
2017/09/17 16:09:30 Serializing trace...
2017/09/17 16:09:30 Splitting trace...
2017/09/17 16:09:30 Opening browser. Trace viewer is listening on http://127.0.0.1:60301

5.9.5 추가 작품, 에라토스테네스의 체

동시 프라임 체 (concurrent prime sieve)는 처음 작성된 Go 프로그램 중 하나입니다.

이반 다닐루크 (Ivan Daniluk)는 이를 시각화하는 멋진 포스트를 작성했습니다.

엑스큐션 트레이서를 사용한 동작 상황을 살펴보겠습니다.

5.9.6 더 많은 자료들

6.메모리 및 가비지 콜렉터

Go는 가비지 콜렉트되는 언어입니다. 이는 디자인 원칙이며 변치 않을 겁니다.

가비지 콜렉트 언어인 Go 프로그램의 성능은 가비지 콜렉터와의 상호 작용에 의해 결정됩니다.

선택한 알고리즘 다음으로는, 메모리 소비가 애플리케이션의 성능과 확장성을 결정하는 가장 중요한 요소입니다.

이 섹션에서는 가비지 콜렉터의 작동, 프로그램의 메모리 사용량을 측정하는 방법 및 가비지 콜렉터 성능이 병목 지점일 경우 메모리 사용량을 낮추기 위한 전략에 대해 설명합니다.

6.1 가비지 콜렉터 월드뷰

가비지 콜렉터의 목적은 프로그램에서 사용할 수 있는 무한한 양의 메모리가 있다는 환상을 보여주는 것입니다.

이 설명에 동의하지 않을 수도 있지만, 가비지 콜렉터 디자이너의 작동 방식에 대한 기본 가정이 이렇습니다.

스탑-더-월드 (STW, stop the world)인 마크 스윕 GC는 전체 실행 시간 측면에서 가장 효율적이며 일괄 처리, 시뮬레이션 등에 적합합니다. 그러나 시간이 지남에 따라 Go GC는 순수 스탑-더-월드 콜렉터에서 동시성 비압축 컬렉터(concurrent, non compacting, collector)로 이동했습니다. 이는 Go GC가 레이턴시가 짧은 서버 및 대화형 애플리케이션을 위해 설계되었기 때문입니다.

Go GC의 설계는 최대 처리량보다 짧은 레이턴시를 선호합니다. 즉, 이후의 정리 비용을 줄이기 위해 할당 비용의 일부를 mutator로 옮깁니다.

6.2 가비지 콜렉터 디자인

Go GC의 설계는 수년에 걸쳐 변경되었습니다.

  • Go 1.0, tcmalloc에 크게 의존하는 스탑-더-월드 마크 스윕 콜렉터
  • Go 1.3, 힙의 큰 숫자를 포인터로 오인하지 않아, 메모리 누수가 발생하는, 완전 정밀 콜렉터
  • Go 1.5, 처리량보다 레이턴시에 중점을 둔 새로운 GC 설계
  • Go 1.6, 레이턴시가 짧은 더 큰 힙을 처리하는 GC 개선
  • Go 1.7, 리팩토링 위주의 작은 GC 개선
  • Go 1.8, STW 시간을 줄이기 위한 추가 작업, 이제 100 마이크로초 범위로 내려감.
  • Go 1.10+, 전체 GC 사이클을 트리거할 때 레이턴시를 낮추기 위한 순수 협업 (pure cooperative) 고루틴에서 벗어남.

6.3 가비지 콜렉터 모니터링

가비지 콜렉터가 얼마나 열심히 작동하는지 간단하게 알 수 있는 방법은 GC 로깅 출력을 활성화하는 것입니다.

이 통계는 항상 수집되지만 일반적으로 억제되므로 GODEBUG 환경 변수를 설정하여 해당 통계를 표시할 수 있습니다.

% env GODEBUG=gctrace=1 godoc -http=:8080
gc 1 @0.012s 2%: 0.026+0.39+0.10 ms clock, 0.21+0.88/0.52/0+0.84 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
gc 2 @0.016s 3%: 0.038+0.41+0.042 ms clock, 0.30+1.2/0.59/0+0.33 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 3 @0.020s 4%: 0.054+0.56+0.054 ms clock, 0.43+1.0/0.59/0+0.43 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 4 @0.025s 4%: 0.043+0.52+0.058 ms clock, 0.34+1.3/0.64/0+0.46 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 5 @0.029s 5%: 0.058+0.64+0.053 ms clock, 0.46+1.3/0.89/0+0.42 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 6 @0.034s 5%: 0.062+0.42+0.050 ms clock, 0.50+1.2/0.63/0+0.40 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 7 @0.038s 6%: 0.057+0.47+0.046 ms clock, 0.46+1.2/0.67/0+0.37 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 8 @0.041s 6%: 0.049+0.42+0.057 ms clock, 0.39+1.1/0.57/0+0.46 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 9 @0.045s 6%: 0.047+0.38+0.042 ms clock, 0.37+0.94/0.61/0+0.33 ms cpu, 4->4->1 MB, 5 MB goal, 8 P

트레이스 출력은 GC 활동의 일반적인 측정값을 제공합니다. gctrace = 1의 출력 형식은 런타임 패키지 설명서에 설명되어 있습니다.

DEMO: GODEBUG=gctrace=1을 활성화하여 godoc을 표시합니다.

Tip

프러덕션 환경에서 이 환경 변수를 사용하세요. 성능에 영향을 미치지 않습니다.

GODEBUG=gctrace=1은 문제가 있는 것을 알고 있을 때 사용하면 좋습니다. 그러나 Go 애플리케이션의 일반적인 원격 측정의 경우는 net/http/pprof 인터페이스를 사용하는 것을 추천합니다.

import _ "net/http/pprof"

net/http/pprof 패키지를 임포트하면 /debug/pprof에 핸들러를 다음과 같은 다양한 런타임 메트릭으로 등록합니다.

  • 실행 중인 모든 고루틴의 목록, /debug/pprof/heap?debug=1.
  • 메모리 할당 통계 리포트, /debug/pprof/heap?debug=1.

Warning

net/http/pprof는 기본 http.ServeMux에 등록됩니다. http.ListenAndServe(address, nil)을 사용하면 이것이 표시되므로 주의하세요.

DEMO: godoc -http=:8080, /debug/pprof를 보여줍니다.

6.3.1 가비지 콜렉터 튜닝

Go 런타임은 GC, GOGC를 조정하기 위한 환경 변수를 제공합니다.

GOGC의 공식은 다음과 같습니다.

g o a l = r e a c h a b l e ( 1 + G O G C 100 )

예를 들어, 현재 256MB 힙이 있고 GOGC = 100 (기본값) 인 경우 힙이 가득 차면 다음과 같이 증가합니다.

512 M B = 256 M B ( 1 + 100 100 )
  • GOGC 값이 100보다 크면 힙이 더 빨리 커져서 GC에 대한 압력이 감소합니다.

  • GOGC 값이 100보다 작으면 힙이 느리게 커져 GC에 대한 압력이 증가합니다.

기본값 100은 just_a_guide입니다. 프로덕션 부하로 애플리케이션을 프로파일링한 후 고유한 값을 선택해야 합니다.

6.5 string과 []bytes

Go에서, string 값은 변경할 수 없으며 []byte는 변경할 수 있습니다.

대부분의 프로그램은 string 작업을 선호하지만 대부분의 IO는 []byte로 수행됩니다.

가능하다면 []byte에서 string 변환을 피하세요. 이는 일반적으로 값으로 string 또는 []byte 중 한가지 표현을 선택함을 의미합니다. 네트워크나 디스크에서 데이터를 읽는 경우 종종 []byte가 됩니다.

bytes 패키지에는 strings 패키지와 동일한 작업 (Split, Compare, HasPrefix, Trim 등)이 많이 포함되어 있습니다.

내부적으로, stringsbytes 패키지와 동일한 어셈블리 프리미티브를 사용합니다.

6.6 맵 키로 []byte 사용

string을 맵 키로 사용하는 것이 매우 일반적이지만, 종종 []byte를 사용합니다.

컴파일러는 이 경우에 특별한 최적화를 구현합니다.

var m map[string]string
v, ok := m[string(bytes)]

이렇게 하면 맵 룩업 시에 바이트 슬라이스를 문자열로 변환하지 않아도 됩니다. 다음과 같은 코딩할 경우 작동하지 않는데, 아주 특이하죠.

key := string(bytes)
val, ok := m[key]

이게 아직도 사실인지 확인해 봅시다. []byte를 string 맵 키로 사용하는 이 두 가지 방법을 비교하는 벤치마크를 작성해보세요.

6.7 문자열 연결 피하기

Go 문자열은 불변입니다. 두 개의 문자열을 결합하면 세 번째 문자열이 생성됩니다. 다음 중 가장 빠른 것은 무엇일까요?

		s := request.ID
		s += " " + client.Addr().String()
		s += " " + time.Now().String()
		r = s
		var b bytes.Buffer
		fmt.Fprintf(&b, "%s %v %v", request.ID, client.Addr(), time.Now())
		r = b.String()
		r = fmt.Sprintf("%s %v %v", request.ID, client.Addr(), time.Now())
		b := make([]byte, 0, 40)
		b = append(b, request.ID...)
		b = append(b, ' ')
		b = append(b, client.Addr().String()...)
		b = append(b, ' ')
		b = time.Now().AppendFormat(b, "2006-01-02 15:04:05.999999999 -0700 MST")
		r = string(b)
		var b strings.Builder
		b.WriteString(request.ID)
		b.WriteString(" ")
		b.WriteString(client.Addr().String())
		b.WriteString(" ")
		b.WriteString(time.Now().String())
		r = b.String()

DEMO: go test -bench=. ./examples/concat

6.8 길이가 알려진 경우, 슬라이스를 미리 할당

Append는 편리하지만, 낭비입니다.

슬라이스는 최대 1024개의 요소를 두 배로 늘린 후 그 후 약 25% 증가합니다. 요소를 하나 더 추가한 후에 b의 용량은 얼마일까요?

func main() {
	b := make([]int, 1024)
	b = append(b, 99)
	fmt.Println("len:", len(b), "cap:", cap(b))
}

Append 패턴을 사용하면 많은 데이터를 복사하며 많은 가비지를 만들 수 있습니다.

슬라이스의 길이를 미리 알고 있으면, 복사를 피하고, 대상이 정확히 맞는 크기인지 확인하기 위해 대상을 미리 할당합니다.

이전

var s []string
for _, v := range fn() {
        s = append(s, v)
}
return s

이후

vals := fn()
s := make([]string, len(vals))
for i, v := range vals {
        s[i] = v
}
return s

6.9 sync.Pool 사용

동기화 패키지는 공통 객체를 재사용하는데 사용되는 sync.Pool 타입과 함께 제공됩니다.

Warning

sync.Pool에는 고정 크기 또는 최대 용량이 없습니다. GC가 발생할 때까지 추가하고 가져와 무조건 비웁니다. 이는 설계를 따른 것입니다.

Tip

가비지 콜렉션이 너무 빠르거나 가비지 콜렉션이 너무 늦으면 Pool을 비우기에 적절한 시간은 가비지 콜렉션 중이어야 합니다. 즉, Pool 타입의 세만틱스는 각 가비지 콜렉션에서 비어져야 한다는 것입니다. — 러스 콕스

var pool = sync.Pool{New: func() interface{} { return make([]byte, 4096) }}

func fn() {
	buf := pool.Get().([]byte) // takes from pool or calls New
	// do work
	pool.Put(buf) // returns buf to the pool
}

6.10 연습문제

  • godoc(또는 다른 프로그램)을 사용해서 GODEBUG=gctrace=1을 사용한 GOGC 변경 내용을 관찰하세요.

  • byte의 string(byte) 맵 키를 벤치마크해보세요.

  • 서로 다른 연결(concat) 방법의 메모리 할당을 벤치마크해보세요.

7. 팁과 트립

팁과 제안을 무작위로 살펴보겠습니다.

이 마지막 섹션에는 Go 코드를 마이크로 최적화하기 위한 여러가지 팁이 포함되어 있습니다.

7.1 고루틴

현대 하드웨어에 잘 어울리는 Go의 주요 기능은 고루틴입니다.

고루틴은 사용하기 쉽고 만들기에도 저렴합니다. 고루틴을 거의 공짜라고 생각할 수 있습니다.

Go 런타임은 일반적으로 수만 개의 고루틴이 있는 프로그램을 위해 작성되었으나 수십만 고루틴을 예상치 못한 것은 아닙니다.

그러나 각 고루틴은 현재 최소 2k인 고루틴 스택에 대해 최소한의 메모리를 소비합니다.

2048 * 백만 고루틴 == 2GB의 메모리이고, 아직 시작도 안했죠.

어쩌면 이것이 많을 수도 있고, 어쩌면 애플리케이션의 다른 용도로 사용되지 않을 수도 있습니다.

7.1.1 고루팁을 언제 멈추어야 하는지 알기

고루틴은 시작 비용이 저렴하고 실행 비용이 저렴합니다. 하지만 메모리 사용 공간 측면에서 비용이 한정되어 있습니다. 무한히 많은 고루틴을 만들 수는 없습니다.

여러분은 고루틴을 런칭시키기 위해 프로그램에서 Go 키워드를 사용할 때마다 고루틴이 어떻게 언제 종료되는지 알아야 합니다.

여러분의 설계에서, 몇몇 고루틴은 프로그램이 끝날 때까지 실행될 수 있습니다. 이러한 고루틴은 예외적인 규칙이 아닐 정도로 희귀합니다.

답을 모르신다면, 고루틴이 스택에서 접근할 수 있는 모든 힙 할당 변수뿐만 아니라 스택의 메모리를 힙에 고정하기 때문에 메모리 누수가 발생할 수 있습니다.

Tip

고루틴을 어떻게 멈출 지 모른채로 시작하지 마세요.

7.1.2 더 읽을거리

7.2 Go는 일부 요청에 대해 효율적인 네트워크 폴링을 사용합니다.

Go 런타임은 효율적인 운영 체제 폴링 메커니즘 (kqueue, epoll, windows IOCP 등)을 사용하여 네트워크 IO를 처리합니다. 대기 중인 많은 고루틴은 단일 운영 체제 쓰레드로 서비스됩니다.

그러나 로컬 파일 IO의 경우 Go는 IO 폴링을 구현하지 않습니다. * os.File의 각 작업은 진행 중인 동안 운영 체제 쓰레드를 하나 사용합니다.

로컬 파일 IO를 많이 사용하면 프로그램이 수백 또는 수천 개의 쓰레드를 생성할 수 있습니다. 운영 체제가 허용하는 것보다 더 많을 수도 있습니다.

디스크 서브 시스템은 수백 또는 수천 개의 동시 IO 요청을 처리할 수 없을 것으로 예상됩니다.

Tip

동시 블럭킹 IO의 양을 제한하려면 작업자 고루틴 풀 또는 버퍼링된 채널을 세마포어로 사용하세요.

	var semaphore = make(chan struct{}, 10)
	
	func processRequest(work *Work) {
		semaphore <- struct{}{} // acquire semaphore
		// process request
		<-semaphore // release semaphore
	}

7.3 애플리케이션에서 IO 멀티 플라이어를 조심하세요

서버 프로세스를 작성하는 경우 해당 서버의 주 작업은 네트워크를 통해 연결된 클라이언트와 애플리케이션에 저장된 데이터를 멀리플렉싱하는 것입니다.

대부분의 서버 프로그램은 요청을 받고, 처리를 한 다음에, 결과를 반환합니다. 간단하게 들리지만 결과에 따라 클라이언트가 서버에서 많은 양의 리소소를 사용할 수 있습니다. 다음은 주의해야 할 몇 가지 사항입니다.

  • 들어오는 요청당 IO 요청량, 즉 단일 클라이언트 요청이 얼마나 많은 IO 이벤트를 생성할까요? 캐시에서 많은 요청이 서비스되는 경우 평균적으로 1 또는 1 미만일 수 있습니다.

  • 쿼리를 처리하는 데 필요한 읽기량, 이는 고정적이며, N + 1 또는 선형입니다 (전체 테이블 읽어 마지막 결과 페이지를 생성).

메모리가 느리면, 상대적으로, IO가 너무 느리기 때문에 어떠한 비용도 들이지 않도록 해야합니다. 요청과 관련하여 IO를 수행하지 않는 것이 가장 중요합니다. 즉, 사용자가 디스크 서브 시스템이 디스크에 쓰기나 읽기 조차도 기다리게 하지 않도록 해야 합니다.

7.4 스트리밍 IO 인터페이스를 사용하세요

가능하다면 []byte로 데이터를 읽고 전달하는 것을 피하세요.

요청에 따라 메가 바이트 (또는 그 이상)의 데이터를 메모리로 읽을 수 있습니다. 이로 인해 GC에 큰 압박이 가해져 애플리케이션의 평균 레이턴시가 증가합니다.

대신 io.Readerio.Writer를 사용해서 프로세싱 파이프라인을 구성하고 요청당 사용 중인 메모리 양을 제한하세요.

효율성을 높이기 위해, io.Copy를 많이 사용하는 경우 io.ReaderFrom/io.WriterTo를 구현하는 것이 좋습니다. 이러한 인터페이스는 보다 효율적이며 메모리를 임시 버퍼에 복사하지 않습니다.

7.5 타임아웃, 타임아웃, 타임아웃

최대 소요 시간을 알지 못한 채로 IO를 시작해서는 안 됩니다.

SetDeadline, SetReadDeadline, SetWriteDeadline을 사용하여 수행하는 모든 네트워크 요청에 대해 타임아웃을 설정해야 합니다.

7.6 Defer는 비쌉니다.

defer는 비용이 많이 듭니다. 왜냐하면 defer 인자에 대한 클로저(closure)를 기록해야 하기 때문입니다.

defer mu.Unlock()

는 아래와 동일합니다.

defer func() {
        mu.Unlock()
}()

수행되는 작업이 작으면 defer는 비싸지만, 전형적인 예는 구조체 변수 또는 맵 조회 주위에 뮤텍스 잠금 해제를 defering하는 것입니다. 그러한 상황에서 defer를 피하도록 선택할 수 있습니다.

성능 향상을 위해 가독성과 유지 관리가 희생되는 경우입니다.

이러한 결정을 항상 다시 방문하십시오.

7.7 Finaler를 피하세요

파이널리제이션(Finalization)는 가지비 콜렉션을 앞둔 객체를 동작(behaviour)를 부착하는 기술입니다.

따라서, 파이널리제이션은 결정적이지 않습니다.

파이널라이저를 실행하려면 어떤 것도 객체에 접근할 수 없어야 합니다. 맵에 있는 객체에 대한 참조를 실수로 보관하면 파이널리제이션이 수행되지 않습니다.

파이널라이저는 gc 사이클의 일부로 실행되며, 이는 언제 실행할지 예측할 수 없으며 gc 작동을 줄인다는 목표에 상충합니다.

힙이 크고, 가비지를 최소화하도록 애플리케이션을 조정한 경우, 파이널라이저가 오랫동안 실행되지 않을 수 있습니다.

7.8 cgo를 최소화하세요

cgo를 사용하면 Go 프로그램이 C 라이브러리를 호출할 수 있습니다.

C 코드와 Go 코드는 서로 다른 두 우주에 살고 있으며, cgo는 두 우주 사이의 경계를 통과합니다.

이 전환은 무료가 아니며 코드의 위치에 따라 비용이 많이 들 수 있습니다.

cgo 호출은 IO 차단과 유사하며 작동 중에 쓰레드를 소비합니다.

빡빡한 루프 중간에 C 코드를 호출하면 안됩니다.

7.8.1 사실, cgo는 피하세요

cgo는 오버헤드가 높습니다.

최상의 성능을 위해서는 애플리케이션에서 cgo를 피하는 것이 좋습니다.

C코드에 시간이 오래 걸리면, cgo 오버헤드가 중요하지 않습니다.

cgo를 사용해서 오버헤드가 가장 눈에 띄는 매우 짧은 C함수를 호출하는 경우, Go로 해당 코드를 다시 작성하세요. 원래 짧으니깐요.

타이트한 루프(tight loop)내에 호출되는 비싼 C코드를 많이 사용하는 경우, 왜 Go를 사용하시나요?

비싼 C코드를 자주 호출하기 위해, cgo를 사용하는 분이 있으신가요?

더 읽을 거리

7.9 항상 최신 릴리즈된 버전의 Go를 사용하세요

이전 버전의 Go는 결코 나아지지 않을 것입니다. 버그 수정이나 최적화가 절대 이뤄지지 않을 겁니다.

  • Go 1.4는 사용해서는 안 됩니다.
  • Go 1.5와 1.6은 컴파일러 속도가 느렸지만, 코드도 더 빠르고, GC는 더 빠릅니다.
  • Go 1.7은 1.6보다 컴파일 속도가 약 30% 향상 되었으며, 링크 속도가 2배 향상되었습니다 (이전 모든 버전의 Go보다 좋음)
  • Go 1.8은 이 시점에서 컴파일 속도가 약간 향상되지만, 인텔 이외의 아키텍처에서는 코드 품질이 크게 향상됩니다.
  • Go 1.9-1.12는 지속적으로 생성된 코드의 성능을 개선하고, 버그를 수정하며, 인라인을 개선하고 디버깅을 개선합니다.

Tip

이전 버전의 Go에는 업데이트가 없습니다. 사용해서는 안됩니다. 최신버전을 사용하면 최상의 성능을 얻을 수 있습니다.

7.9.1 더 읽을거리

7.9.2 핫 필드를 구조체의 상단으로 이동

(내용없음)

7.10

질문 있나요?

마지막 질문과 결론

읽기 쉽다는 것은 신뢰할 수 있다는 뜻입니다. — Rob Pike

가장 간단한 코드부터 시작하세요.

측정하세요. 병목 현상을 확인하기 위해 코드를 프로파일링하세요.

성능이 좋으면 멈추세요. 모든 것을 최적화할 필요는 없습니다. 코드에서 가장 중요한 부분만 다루세요.

애플리케이션이 성장하거나 트래픽 패턴이 진화함에 따라 성능 핫 스팟이 변경될 것입니다.

성능에 중요하지 않은 복잡한 코드를 그대로 두지 말고, 병목이 다른 곳으로 이동하면 더 간단한 동작으로 다시 작성하세요.

항상 가장 간단한 코드를 작성하세요. 컴파일러는 일반코드에 맞게 최적화되어 있습니다.

더 짧은 코드는 더 빠른 코드입니다. Go는 C++이 아닙니다. 컴파일러에서 복잡한 추상화를 풀 것으로 기대하지 마세요.

더 짧은 코드는 더 작은 코드입니다. CPU 캐시에 중요합니다.

할당에 매우 주의하세요. 가능한 경우 불필요한 할당을 피하세요.

정확하지 않아도 된다면 전 아주 빠르게 만들 수 있습니다. — Russ Cox

성능과 신뢰성은 똑같이 중요합니다.

저는 패닉, 교착 상태 또는 OOM의 기반 위에서 매우 빠른 서버를 만드는 것은 별로 가치가 없다고 봅니다.

성능을 신뢰성과 바꾸지 마세요.

Youngpil Yoon

Powered by Hugo & Kiss.