프로파일 기반 최적화
프로파일 기반 최적화 (PGO)
Go 1.20부터 Go 컴파일러는 **프로파일 기반 최적화(Profile-guided optimization, PGO)**를 지원해서 빌드를 더 최적화할 수 있게 해 줍니다. 이 글은 PGO가 무엇인지, 프로파일을 어떻게 모으고 어떤 주의점이 있는지 정리합니다. 피드백 기반 최적화(FDO)라고도 불리는 PGO는, 애플리케이션의 대표적인 실행에서 얻은 정보(프로파일)를 다음 빌드의 컴파일러에 되먹여 더 현명한 최적화 결정을 내리게 하는 컴파일러 기법이에요. 예를 들어 컴파일러는 프로파일이 자주 호출된다고 알려 준 함수를 더 적극적으로 인라인할 수 있습니다. Go에서 컴파일러는 입력 프로파일로 runtime/pprof나 net/http/pprof 같은 CPU pprof 프로파일을 사용합니다. Go 1.22 기준, 대표적인 Go 프로그램 집합의 벤치마크에서 PGO로 빌드하면 성능이 약 2~14% 향상된다고 해요.
출처: Go 공식 문서
프로파일 모으기 (Collecting profiles)
Go 컴파일러는 PGO의 입력으로 CPU pprof 프로파일을 기대합니다. Go 런타임(runtime/pprof, net/http/pprof)이 만든 프로파일은 컴파일러 입력으로 바로 쓸 수 있어요. 다른 프로파일링 시스템의 프로파일도 변환해서 쓸 수 있을 수 있습니다(부록 참고).
좋은 결과를 얻으려면 프로파일이 애플리케이션의 실제 운영 환경 동작을 대표해야 하는 게 중요합니다. 대표적이지 않은 프로파일을 쓰면 운영에서 개선 효과가 거의 또는 전혀 없는 바이너리가 나올 가능성이 커요. 그래서 운영 환경에서 직접 프로파일을 모으는 걸 권장하며, 그게 Go PGO가 설계된 주된 방법입니다. 전형적인 워크플로는 이렇습니다.
- 초기 바이너리(PGO 없이)를 빌드해 릴리스합니다.
- 운영 환경에서 프로파일을 모읍니다.
- 업데이트된 바이너리를 릴리스할 때가 되면 최신 소스로 빌드하면서 운영 프로파일을 제공합니다.
- 2번으로 돌아갑니다.
Go PGO는 프로파일을 뜬 애플리케이션 버전과 프로파일로 빌드하는 버전 사이의 어긋남(skew)에 일반적으로 견고하며, 이미 최적화된 바이너리에서 모은 프로파일로 빌드하는 것에도 견고합니다. 이 덕분에 이런 반복적 라이프사이클이 가능해져요. 이 워크플로에 대한 자세한 내용은 AutoFDO 절을 참고하세요.
운영 환경에서 모으기 어렵거나 불가능한 경우(예: 최종 사용자에게 배포되는 커맨드라인 도구)에는 대표적인 벤치마크에서 모을 수도 있습니다. 다만 대표적인 벤치마크를 만드는 건 자주 어렵습니다(애플리케이션이 진화함에 따라 대표성을 유지하는 것도 마찬가지죠). 특히 마이크로벤치마크는 보통 PGO 프로파일링에 나쁜 후보예요. 애플리케이션의 작은 일부만 다루기 때문에, 전체 프로그램에 적용하면 얻는 이득이 작습니다.
PGO로 빌드하기 (Building with PGO)
빌드의 표준 방식은 pprof CPU 프로파일을 default.pgo라는 이름으로, 프로파일을 뜬 바이너리의 메인 패키지 디렉터리에 저장하는 것입니다. 기본적으로 go build는 default.pgo 파일을 자동으로 감지해서 PGO를 켭니다. 프로파일은 재현 가능한(그리고 성능 좋은!) 빌드에 중요한 입력이므로, 프로파일을 소스 저장소에 직접 커밋하는 걸 권장해요. 소스 옆에 저장하면 소스를 가져오는 것 외에 프로파일을 얻기 위한 추가 단계가 없어져 빌드 경험이 단순해집니다.
더 복잡한 시나리오에서는 go build -pgo 플래그가 PGO 프로파일 선택을 제어합니다. 이 플래그의 기본값은 위에서 설명한 default.pgo 동작을 위한 -pgo=auto입니다. 플래그를 -pgo=off로 설정하면 PGO 최적화를 완전히 끕니다. default.pgo를 쓸 수 없다면(예: 바이너리 하나에 시나리오별로 다른 프로파일을 쓰거나, 프로파일을 소스와 함께 저장할 수 없는 경우) 사용할 프로파일의 경로를 직접 넘길 수도 있어요(예: go build -pgo=/tmp/foo.pprof).
참고: -pgo에 넘긴 경로는 모든 메인 패키지에 적용됩니다. 예를 들어 go build -pgo=/tmp/foo.pprof ./cmd/foo ./cmd/bar는 foo.pprof를 foo와 bar 두 바이너리 모두에 적용하는데, 이는 종종 원하는 바가 아니에요. 보통 서로 다른 바이너리는 서로 다른 프로파일을 가져야 하며, 그럴 땐 go build를 각각 분리해서 호출해야 합니다.
참고: Go 1.21 이전에는 기본값이 -pgo=off라서 PGO를 명시적으로 켜야 했습니다.
운영에서 대표적인 프로파일 모으기
운영 환경이 내 애플리케이션에 가장 대표적인 프로파일의 원천입니다(프로파일 모으기 절 참고). 가장 간단한 시작 방법은 net/http/pprof를 애플리케이션에 추가한 뒤, 서비스의 아무 인스턴스에서 /debug/pprof/profile?seconds=30을 가져오는 것이에요. 시작하기엔 좋지만, 이렇게 하면 비대표적일 수 있는 경우가 있습니다.
- 프로파일을 뜨는 순간 그 인스턴스가 보통은 바쁘지만, 그때 아무 일도 안 하고 있을 수 있습니다.
- 하루 종일 트래픽 패턴이 바뀌어 동작도 하루 종일 달라질 수 있습니다.
- 인스턴스가 오래 걸리는 작업을 수행할 수 있습니다(예: 5분 동안 작업 A, 그다음 5분 동안 작업 B). 30초 프로파일은 아마 한 종류의 작업만 덮을 거예요.
- 인스턴스들이 요청을 공정하게 배분받지 못할 수 있습니다(어떤 인스턴스는 한 종류의 요청을 다른 인스턴스보다 더 많이 받죠).
더 견고한 전략은 다른 시점에 다른 인스턴스에서 여러 프로파일을 모아 개별 인스턴스 프로파일 사이의 차이 영향을 줄이는 것입니다. 여러 프로파일은 PGO에 쓰기 위해 하나의 프로파일로 병합하면 돼요. 많은 조직이 이런 방식의 fleet 차원 샘플링 프로파일링을 자동으로 수행하는 "연속 프로파일링" 서비스를 운영하며, 그걸 PGO의 프로파일 원천으로 쓰기도 합니다.
프로파일 병합하기
pprof 도구로 여러 프로파일을 이렇게 병합할 수 있습니다.
$ go tool pprof -proto a.pprof b.pprof > merged.pprof
이 병합은 프로파일의 벽시계 시간과 무관하게, 입력의 샘플을 사실상 단순 합산한 것입니다. 그래서 애플리케이션의 작은 시간 조각을 프로파일링할 때(예: 무기한 실행되는 서버) 모든 프로파일이 같은 벽시계 시간(즉 모두 30초 동안 수집)을 갖도록 하는 게 좋아요. 그렇지 않으면 벽시계 시간이 더 긴 프로파일이 병합 결과에서 과대 대표될 수 있습니다.
AutoFDO
Go PGO는 "AutoFDO" 스타일 워크플로를 지원하도록 설계되었습니다. 프로파일 모으기 절에서 설명한 워크플로를 자세히 보죠.
- 초기 바이너리(PGO 없이)를 빌드해 릴리스합니다.
- 운영 환경에서 프로파일을 모읍니다.
- 업데이트된 바이너리를 릴리스할 때가 되면 최신 소스로 빌드하면서 운영 프로파일을 제공합니다.
- 2번으로 돌아갑니다.
겉보기엔 단순해 보이지만, 여기엔 몇 가지 중요한 속성이 있습니다.
- 개발은 항상 진행 중이므로, 프로파일을 뜬 바이너리 버전(2단계)의 소스가 빌드하는 최신 소스(3단계)와 조금 다를 가능성이 높습니다. Go PGO는 이에 견고하도록 설계되었으며, 이를 소스 안정성(source stability) 이라고 합니다.
- 이건 닫힌 루프입니다. 즉 첫 반복 이후에는 프로파일을 뜬 바이너리가 이미 이전 반복의 프로파일로 PGO 최적화되어 있어요. Go PGO는 이에도 견고하도록 설계되었으며, 이를 반복 안정성(iterative stability) 이라고 합니다.
소스 안정성은 프로파일의 샘플을 컴파일하는 소스와 대응시키는 휴리스틱으로 달성됩니다. 그래서 새 함수를 추가하는 것 같은 소스의 많은 변화는 기존 코드를 대응시키는 데 아무 영향이 없어요. 컴파일러가 바뀐 코드를 대응시키지 못하면 일부 최적화가 사라지는데, 이는 점진적인 저하(graceful degradation)라는 점을 기억하세요. 함수 하나가 대응에 실패하면 최적화 기회를 잃을 수 있지만, PGO의 이득은 보통 많은 함수에 걸쳐 퍼져 있으니까요. 대응과 저하에 대한 자세한 내용은 소스 안정성 절을 참고하세요.
반복 안정성은 연속된 PGO 빌드에서 성능이 들쭉날쭉해지는 주기(#1 빌드가 빠르고 #2가 느리고 #3가 빠르고...)를 막는 것입니다. CPU 프로파일로 최적화 대상이 될 핫 함수를 식별합니다. 이론상, 핫 함수가 PGO로 너무 빨라져서 다음 프로파일에서 더 이상 핫으로 보이지 않고 최적화되지 않아 다시 느려질 수 있어요. Go 컴파일러는 PGO 최적화에 보수적인 접근을 취하며, 이는 큰 변동을 막아 준다고 봅니다. 이런 불안정성을 관찰하면 go.dev/issue/new에 이슈를 올려 주세요.
소스 안정성과 반복 안정성이 함께 작동하면, 최고 성능이 반드시 필요한 경우가 아니라면 2단계 빌드(먼저 최적화하지 않은 빌드를 캐너리로 프로파일링한 뒤, 운영용으로 PGO로 다시 빌드)가 필요 없어집니다.
소스 안정성과 리팩터링
앞에서 설명했듯 Go의 PGO는 오래된 프로파일의 샘플을 현재 소스 코드에 계속 대응시키도록 최선을 다합니다. 구체적으로 Go는 함수 내부의 줄 오프셋(예: 함수 foo의 5번째 줄에서의 호출)을 사용합니다. 흔한 변경들은 대응을 깨뜨리지 않습니다.
- 핫 함수 바깥의 파일 변경(함수 위나 아래에 코드를 추가·변경).
- 같은 패키지 안에서 함수를 다른 파일로 옮기는 것(컴파일러가 소스 파일 이름을 아예 무시하므로).
반면 대응을 깨뜨릴 수 있는 변경들도 있어요.
- 핫 함수 안의 변경(줄 오프셋에 영향을 줄 수 있음).
- 함수(메서드의 경우 타입도) 이름 바꾸기(심볼 이름이 바뀌므로).
- 함수를 다른 패키지로 옮기기(심볼 이름이 바뀌므로).
프로파일이 비교적 최근이라면, 차이는 아마 핫 함수 몇 개에만 영향을 줘서 대응 실패한 함수에서 놓친 최적화의 영향이 제한됩니다. 그래도 코드는 예전 형태로 리팩터링되는 일이 드물기 때문에 시간이 지나며 저하는 천천히 누적되니, 운영과의 소스 어긋남을 제한하려면 새 프로파일을 정기적으로 모으는 게 중요합니다.
프로파일 대응이 크게 저하되는 상황 하나는, 함수 이름을 많이 바꾸거나 패키지 사이로 옮기는 대규모 리팩터링입니다. 이 경우 새 프로파일이 새 구조를 보여 주기 전까지 단기적인 성능 하락을 감수해야 할 수 있어요. 단순한 이름 변경(rote rename)의 경우, 기존 프로파일을 이론적으로 다시 써서 옛 심볼 이름을 새 이름으로 바꿀 수 있습니다. github.com/google/pprof/profile에는 이런 방식으로 pprof 프로파일을 다시 쓰는 데 필요한 기본 요소가 들어 있지만, 글을 쓰는 시점에 바로 쓸 수 있는 도구는 아직 없습니다.
새 코드의 성능
새 코드를 추가하거나 플래그 전환으로 새 코드 경로를 켜면, 그 코드는 첫 빌드의 프로파일에는 없어서 새 코드를 반영한 새 프로파일을 모으기 전까지는 PGO 최적화를 받지 못합니다. 새 코드 롤아웃을 평가할 때, 초기 릴리스는 그 안정 상태 성능을 나타내지 않는다는 점을 기억하세요.
자주 묻는 질문 (FAQ)
PGO로 Go 표준 라이브러리 패키지를 최적화할 수 있나요? 네. Go의 PGO는 전체 프로그램에 적용됩니다. 표준 라이브러리 패키지를 포함해 모든 패키지가 프로파일 기반 최적화 가능성을 고려해 다시 빌드됩니다.
의존 모듈의 패키지도 PGO로 최적화할 수 있나요? 네. Go의 PGO는 전체 프로그램에 적용됩니다. 의존성 안의 패키지를 포함해 모든 패키지가 다시 빌드됩니다. 즉 애플리케이션이 의존성을 사용하는 독특한 방식이 그 의존성에 적용되는 최적화에 영향을 줍니다.
대표적이지 않은 프로파일로 PGO를 쓰면 PGO를 안 쓰는 것보다 프로그램이 느려지나요? 그래선 안 됩니다. 운영 동작을 대표하지 않는 프로파일은 애플리케이션의 차가운 부분에 최적화를 만들겠지만, 핫한 부분을 더 느리게 만들지는 않아야 합니다. PGO가 PGO를 끈 것보다 성능이 나빠지는 프로그램을 만나면 go.dev/issue/new에 이슈를 올려 주세요.
다른 GOOS/GOARCH 빌드에 같은 프로파일을 쓸 수 있나요?
네. 프로파일의 형식은 OS와 아키텍처 구성에서 동일하므로, 다른 구성에서 쓸 수 있습니다. 예를 들어 linux/arm64 바이너리에서 수집한 프로파일을 windows/amd64 빌드에 쓸 수 있어요. 다만 위에서 논의한 소스 안정성 주의점이 여기에도 적용됩니다. 구성별로 다른 소스 코드는 최적화되지 않을 거예요. 대부분의 애플리케이션에서 코드의 대부분은 플랫폼 독립적이므로 이런 저하는 제한적입니다. 구체적인 예로, os 패키지의 파일 처리 내부는 Linux와 Windows에서 다릅니다. Linux 프로파일에서 이 함수들이 핫하다면, Windows 대응 함수는 프로파일과 대응되지 않으므로 PGO 최적화를 받지 못합니다. 서로 다른 GOOS/GOARCH 빌드의 프로파일을 병합할 수도 있습니다(병합 시의 트레이드오프는 다음 질문 참고).
여러 워크로드 유형에 쓰이는 단일 바이너리는 어떻게 처리해야 하나요? 명확한 정답은 없습니다. 한 바이너리가 서로 다른 유형의 워크로드에 쓰일 때(예: 한 서비스에선 읽기 위주로, 다른 서비스에선 쓰기 위주로 쓰이는 데이터베이스) 핫 컴포넌트가 달라서 서로 다른 최적화가 유리할 수 있어요. 세 가지 선택지가 있습니다.
- 워크로드별로 바이너리 버전을 다르게 빌드: 각 워크로드의 프로파일을 써서 workload 특화 빌드를 여러 개 만듭니다. 각 워크로드에 최고의 성능을 주지만, 여러 바이너리와 프로파일 원천을 다뤄야 해 운영 복잡도가 늘 수 있습니다.
- "가장 중요한" 워크로드의 프로파일만 써서 단일 바이너리 빌드: 가장 중요한 워크로드(영향이 가장 크고 성능에 가장 민감한 것)를 골라, 그 워크로드의 프로파일만으로 빌드합니다. 선택한 워크로드에 최고의 성능을 주고, 워크로드 간 공유되는 공통 코드 최적화 덕에 다른 워크로드에도 여전히 완만한 개선을 줄 가능성이 높습니다.
- 워크로드 간 프로파일 병합: 각 워크로드의 프로파일을(총 영향으로 가중치를 매겨) 하나의 "fleet 차원" 프로파일로 병합해, 그 하나의 공통 프로파일로 빌드합니다. 모든 워크로드에 완만한 성능 개선을 주는 편일 겁니다.
PGO는 빌드 시간에 어떤 영향을 주나요? PGO를 켜면 패키지 빌드 시간이 눈에 띄게 늘어날 가능성이 높습니다. 가장 두드러진 부분은 PGO 프로파일이 바이너리의 모든 패키지에 적용된다는 점인데, 프로파일을 처음 쓸 때는 의존성 그래프의 모든 패키지를 다시 빌드해야 합니다. 이 빌드들은 다른 빌드와 마찬가지로 캐시되므로, 같은 프로파일을 쓰는 이후의 증분 빌드는 전체 재빌드가 필요 없어요. 빌드 시간이 극단적으로 늘어난다면 go.dev/issue/new에 이슈를 올려 주세요.
PGO는 바이너리 크기에 어떤 영향을 주나요? PGO는 함수가 추가로 인라인되면서 바이너리가 약간 커질 수 있습니다.
부록: 대체 프로파일 원천
Go 런타임(runtime/pprof 등)이 만든 CPU 프로파일은 PGO 입력으로 직접 쓰기에 이미 올바른 형식입니다. 하지만 조직은 대체 선호 도구(예: Linux perf)나 기존 fleet 차원 연속 프로파일링 시스템을 Go PGO에 쓰고 싶을 수 있어요. 대체 원천의 프로파일은 pprof 형식으로 변환된다면, 다음 일반 요구 사항을 따르는 한 Go PGO에 쓸 수 있습니다.
- 샘플 인덱스 중 하나가 type/unit이 "samples"/"count"이거나 "cpu"/"nanoseconds"여야 합니다.
- 샘플이 샘플 위치에서의 CPU 시간 샘플을 나타내야 합니다.
- 프로파일이 심볼화되어 있어야 합니다(
Function.name이 설정되어야 함). - 샘플이 인라인 함수에 대한 스택 프레임을 포함해야 합니다. 인라인 함수가 빠지면 Go는 반복 안정성을 유지하지 못합니다.
Function.start_line이 설정되어야 합니다. 이는 함수가 시작하는 줄 번호, 즉func키워드가 있는 줄입니다. Go 컴파일러는 이 필드로 샘플의 줄 오프셋(Location.Line.line - Function.start_line)을 계산합니다. 많은 기존 pprof 변환기가 이 필드를 빠뜨린다는 점을 참고하세요.
참고: Go 1.21 이전에는 DWARF 메타데이터가 함수 시작 줄(DW_AT_decl_line)을 생략해서, 도구가 시작 줄을 알아내기 어려울 수 있습니다. 특정 서드파티 도구의 PGO 호환성에 대한 추가 정보는 Go 위키의 PGO Tools 페이지를 참고하세요.
더 알아보기 (Learn more)
- PGO에 대한 최신 변경 사항은 Go 릴리스 노트를 확인하세요.
- 서드파티 프로파일링 도구와의 PGO 호환성은 Go 위키의 PGO Tools 페이지를 보세요.
- CPU 프로파일을 직접 수집하는 법은
runtime/pprof와net/http/pprof문서를 참고하세요.