앞의 네 편은 무엇으로 빌드할 것인가를 다뤘다. 이번에는 도구를 그대로 두고 같은 빌드를 더 빠르게 만드는 방법이다.

인터넷의 조언은 대개 목록이다 — 병렬 빌드, 캐시, 유니티 빌드, PCH, 빠른 링커. 문제는 어느 것이 내 프로젝트에서 얼마나 먹히는지는 목록이 말해 주지 않는다는 점이다. 그래서 순서를 뒤집는다. 먼저 재고, 숫자를 보고 고른다.

측정 대상

시리즈 공통 예제 textstat은 파일이 여덟 개라 빌드 시간 차이가 보이지 않는다. 그래서 같은 구조를 C++ 소스 152개로 부풀렸다. 각 소스는 표준 라이브러리 템플릿을 여럿 끌어오는 공용 헤더(heavy.h)를 포함하고 std::map·std::sort를 인스턴스화한다. 현실의 C++ 프로젝트가 시간을 쓰는 곳을 흉내 낸 것이다.

$ cmake -S . -B b -G Ninja -DCMAKE_BUILD_TYPE=Release && ninja -C b
기준 풀 빌드: 8~9초 (32코어, -j32)

이 숫자를 기준으로 하나씩 바꿔 가며 다시 잰다.

1단계 — 어디가 느린지부터 잰다

고치기 전에 컴파일러에게 물어본다. 쓰는 컴파일러에 따라 도구가 다르다.

clang — -ftime-trace

TU 하나의 시간을 단계별로 쪼개 JSON으로 뱉는다. Chrome의 chrome://tracing이나 Perfetto에서 열어 보면 시간축 그래프가 된다.

$ clang++ -std=c++17 -O2 -Iinclude -ftime-trace -c tu_heavy.cpp
Total ExecuteCompiler               313.6 ms
Total Frontend                      254.4 ms
Total Source                        222.0 ms
Total ParseClass                     90.2 ms
Total ParseFunctionDefinition        60.4 ms

313ms 중 254ms가 프론트엔드, 그중 222ms가 소스(=헤더 포함)를 읽고 파싱하는 데 쓰였다.

gcc — -ftime-trace는 없고 -ftime-report가 있다

먼저 확인해 둘 것. gcc에는 -ftime-trace가 없다.

$ g++ -ftime-trace -c src/mod1.cpp
g++: error: unrecognized command-line option '-ftime-trace'
 
$ g++ --help=common | grep ftime
  -ftime-report          Report the time taken by each compiler pass.
  -ftime-report-details  Record times taken by sub-phases separately.

대신 -ftime-report가 패스별 시간을 표로 준다. 같은 프로젝트의 TU 하나를 잰 결과다(마지막 백분율 열이 전체 대비 비중).

$ g++ -std=c++17 -O2 -Iinclude -ftime-report -c src/mod1.cpp -o /dev/null
 phase parsing              :   0.25 ( 45%)   0.20 ( 69%)   0.51 ( 55%)    76M ( 66%)
 phase opt and generate     :   0.26 ( 46%)   0.05 ( 17%)   0.31 ( 34%)    21M ( 19%)
 template instantiation     :   0.10 ( 18%)   0.06 ( 21%)   0.20 ( 22%)    34M ( 30%)
 preprocessing              :   0.04 (  7%)   0.07 ( 24%)   0.15 ( 16%)  2061k (  2%)
 TOTAL                      :   0.57          0.22          0.79          115M

파싱이 55%, 최적화·코드 생성이 34%. clang의 -ftime-trace와 같은 결론이다 — 형태만 다르고 답은 같다. 시간축 그래프가 필요하면 clang으로 한 번 재 보고, 일상적인 확인은 -ftime-report로 충분하다.

MSVC — /d1reportTime/Bt+

MSVC에도 -ftime-trace는 없다.

> cl /std:c++17 /c /timeTrace tu_heavy.cpp
cl : 명령줄 warning D9002 : 알 수 없는 '/timeTrace' 옵션을 무시합니다.

대신 /d1reportTime 이 시간을 세 갈래로 갈라 준다. 문서화되지 않은 내부 플래그(/d1)라 버전에 따라 사라질 수 있지만, 지금(MSVC 14.51) 잘 동작한다.

> cl /std:c++17 /O2 /EHsc /c /d1reportTime tu_heavy.cpp
Include Headers:
        Count: 136
        Total: 0.214679s
Class Definitions:
        Count: 1495
        Total: 0.068040s
Function Definitions:
        Count: 2047
        Total: 0.089874s

여기서 MSVC가 clang보다 직접적인 부분이 있다 — 헤더를 포함 트리로 펼치면서 파일별 시간을 찍는다.

includelgorithm:  0.115537s
include\sstream:    0.059537s
includeunctional: 0.028043s
include\map:        0.004235s

clang의 -ftime-trace는 “Source 222ms”처럼 뭉쳐 말하는데, 이쪽은 “<algorithm>이 0.116초”라고 짚어 준다. 무엇을 끊어야 하는지 바로 나온다.

곁들여 쓸 만한 것 셋이 더 있다. 같은 TU를 잰 값이다.

플래그알려 주는 것실측
/Bt+프론트엔드(c1xx)와 백엔드(c2) 시간0.300초 대 0.102초 — 프론트엔드가 3배
/d1templateStats템플릿 인스턴스화 통계전체 743개(클래스 템플릿 471, 특수화 433)
/d2cgsummary코드 생성 요약함수 273개, 코드 생성 0.095초 / 전체 0.287초

TU 하나가 아니라 빌드 전체를 보려면 툴체인에 함께 깔리는 vcperf.exe(Build Insights)를 쓴다 — ETW로 추적해 타임라인으로 본다.

세 컴파일러의 답이 같다는 점이 중요하다. clang은 “프론트엔드 254/313ms”, gcc는 “파싱 55%”, MSVC는 “0.300초 대 0.102초”라고 말한다. 표현만 다르고 결론은 하나다 — 헤더 파싱이 지배적이다.

컴파일러와 무관한 방법 셋

-ftime-report가 “무엇이 느린가”를 말해 준다면, 아래는 “왜 무거운가”“어느 파일이 느린가” 를 말해 준다.

-H — 헤더가 몇 개나 딸려 오는가. 포함 트리를 점 개수(깊이)와 함께 찍는다.

$ g++ -std=c++17 -Iinclude -H -fsyntax-only src/mod1.cpp
. include/heavy.h
.. /usr/include/c++/11/algorithm
... /usr/include/c++/11/utility
.... /usr/include/x86_64-linux-gnu/c++/11/bits/c++config.h
...
$ g++ ... -H -fsyntax-only src/mod1.cpp 2>&1 | grep -c '^\.'
258

소스 두 줄짜리 파일이 헤더 258개를 끌어온다. clang도 같은 옵션을 지원한다.

-E — 컴파일러가 실제로 읽는 분량. 전처리 결과의 줄 수가 곧 파서가 씹어야 할 양이다.

TU전처리 결과포함 헤더
heavy.h를 포함64,533 줄258개
lean.h를 포함22,869 줄175개

같은 두 줄짜리 코드인데 파서가 보는 분량이 2.8배 차이 난다. 뒤의 “헤더 위생” 절에서 이 차이가 시간으로 어떻게 나타나는지 다시 나온다.

.ninja_log — 빌드 전체에서 어느 파일이 느린가. Ninja가 타깃마다 시작·종료 시각을 남기므로, 컴파일러가 무엇이든 상관없이 병목 파일을 뽑을 수 있다.

$ awk 'NR>1 {printf "%6d ms  %s
", $2-$1, $4}' build/.ninja_log | sort -rn | head -5
  2436 ms  CMakeFiles/app.dir/src/mod48.cpp.o
  2387 ms  CMakeFiles/app.dir/src/mod34.cpp.o
  2355 ms  CMakeFiles/app.dir/src/mod43.cpp.o

TU 하나가 2.4초씩 걸린다는 사실을 알면, 그다음 질문(“왜 이 파일만?”)으로 넘어갈 수 있다.

여기서 예측이 나온다. 파싱이 지배적이니, 헤더 파싱 횟수를 줄이는 기법이 잘 들을 것이다. 아래 결과가 정확히 그렇다.

2단계 — 코어를 다 쓴다

가장 먼저 할 일이고, 가장 착각하기 쉬운 항목이다. 32코어 기계에서 -j만 바꿔 가며 풀 빌드를 쟀다.

병렬도시간배속
-j176,938 ms1.0×
-j238,802 ms2.0×
-j423,734 ms3.2×
-j813,753 ms5.6×
-j1611,567 ms6.7×
-j328,577 ms9.0×

코어 32개를 줘도 9배다. -j8까지는 거의 선형(5.6배)인데 그 뒤로 급격히 꺾인다. 이유는 셋이다 — 링크는 병렬화되지 않는 직렬 구간이고, 파일 수가 152개라 끝물에 놀고 있는 코어가 생기며, 32개가 동시에 헤더를 파싱하면 메모리 대역폭이 병목이 된다.

실무적 결론은 “코어 수만큼 주면 된다”가 아니라 “재 보고 꺾이는 지점을 찾으라” 이다. 여기서는 -j16이면 -j32의 대부분을 얻는다.

3단계 — 같은 걸 두 번 컴파일하지 않는다 (ccache)

ccache는 전처리 결과와 컴파일 옵션을 해시해 두고, 같으면 컴파일을 건너뛰고 저장해 둔 오브젝트를 꺼낸다. CMake는 컴파일러 앞에 끼워 넣기만 하면 된다.

cmake -S . -B b -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
상황시간
1회차 (캐시 미스)9,299 ms
2회차 (빌드 디렉터리를 지우고 다시)498 ms

18배다. 컴파일이 통째로 사라졌기 때문이다(Hits: 152 / Misses: 152 → 다음 회차 전부 히트).

이 숫자가 의미 있는 곳은 로컬보다 CI다. 브랜치를 옮겨 다니거나 깨끗한 작업 디렉터리에서 매번 새로 빌드하는 환경에서, 바뀐 파일만 실제로 컴파일된다. 캐시를 팀이 공유하면 4편에서 본 Bazel의 원격 캐시와 같은 효과를 CMake 프로젝트에서도 얻는다.

4단계 — 헤더를 여러 번 파싱하지 않는다

1단계에서 “헤더 파싱이 지배적”이라고 나왔으니, 여기가 본진이다.

유니티 빌드 — 소스를 묶어서 한 번에

CMake 3.16부터 옵션 하나로 켜진다.

cmake -S . -B b -DCMAKE_UNITY_BUILD=ON -DCMAKE_UNITY_BUILD_BATCH_SIZE=8

CMake가 소스를 묶은 파일을 만들어 대신 컴파일한다. 실제로 생성된 것을 열어 보면 이렇다.

/* generated by CMake */
#include "/home/ewanshin/textstat-big/src/count.cpp"
#include "/home/ewanshin/textstat-big/src/main.cpp"
...

152개 소스가 19개 묶음이 됐다. 결과는 2,059 ms — 기준 대비 4.4배다. 여덟 개 소스가 공용 헤더를 여덟 번 파싱하던 것을 한 번으로 줄였으니 당연한 결과다.

공짜는 아니다. 파일들이 한 TU에 합쳐지므로 익명 네임스페이스나 static 이름이 충돌할 수 있고, 한 파일에서 정의한 매크로가 다음 파일로 샌다. 그래서 처음 켜면 컴파일 오류가 여럿 나오는 게 정상이고, 그걸 고치는 것이 곧 이름 위생을 정리하는 일이 된다.

미리 컴파일된 헤더(PCH)

target_precompile_headers(app PRIVATE include/heavy.h)

결과는 5,527 ms — 1.65배. 유니티 빌드보다 덜 효과적이었다.

5단계 — 증분 빌드에서 대가를 확인한다

여기가 목록형 조언이 잘 빠뜨리는 부분이다. 풀 빌드가 빨라져도 매일 겪는 증분 빌드가 느려지면 손해다. 소스 하나를 고친 경우와 공용 헤더를 고친 경우를 각각 쟀다.

구성소스 1개 수정공용 헤더 수정
기본1,033 ms7,920 ms
유니티 빌드821 ms2,110 ms
PCH1,068 ms10,788 ms

읽을 것이 둘 있다.

  • 유니티 빌드는 여기서 양쪽 다 이겼다. 흔히 “유니티는 증분 빌드를 망친다”고 하는데(파일 하나만 고쳐도 묶음 전체를 다시 컴파일하니까), 이 프로젝트에서는 묶음을 다시 컴파일하는 비용보다 헤더를 여덟 번 안 읽어서 버는 이득이 컸다. 묶음 크기(8)와 헤더 무게에 달린 문제이므로, 자기 프로젝트에서 재 봐야 한다.
  • PCH는 공용 헤더를 고칠 때 오히려 느려졌다(7.9초 → 10.8초). PCH 자체를 다시 만든 뒤 전부 다시 컴파일하기 때문이다. 자주 바뀌는 헤더를 PCH에 넣으면 안 된다는 규칙이 숫자로 나온 것이다. PCH에는 표준 라이브러리나 서드파티처럼 잘 안 바뀌는 것만 넣는다.

6단계 — 근본 처방은 헤더를 가볍게 하는 것

위 기법들은 “무거운 헤더를 덜 읽는” 방법이다. 헤더 자체를 가볍게 하면 어디에나 효과가 있다. 같은 코드를 무거운 헤더와 최소 헤더로 각각 컴파일해 봤다.

포함한 헤더TU 3회 컴파일
heavy.h (표준 컨테이너·알고리즘 다수)806 ms
lean.h (전방 선언과 <string>만)240 ms

TU 하나당 269ms 대 80ms, 3.4배다. 헤더에서 #include를 하나 줄이는 일이 그 헤더를 포함하는 모든 파일에 곱해진다. 전방 선언, PIMPL, 구현 세부를 .cpp로 밀어내기 — 오래된 조언이지만 효과의 크기는 이 표대로다.

7단계 — 링커는 프로젝트에 따라 다르다

gold로 바꿔 재링크만 쟀다.

링커재링크
기본(bfd)518 ms
gold480 ms

7% 차이로, 이 프로젝트에서는 의미가 없다. 오브젝트 152개짜리라 링크가 애초에 짧기 때문이다. 링커 교체가 효과를 내는 건 오브젝트가 수천 개이고 디버그 정보가 무거운 프로젝트다. 그런 경우라면 moldlld까지 검토할 값이 있다. 순서상으로는 위의 다른 항목을 먼저 하는 게 맞다.

재지 못한 것

정직하게 적어 둔다.

  • 분산 빌드(icecc, distcc) — 여러 대의 기계가 필요해 이 환경에서 확인하지 못했다. 원리는 로컬 병렬화의 확장이므로, 위 -j 표에서 본 꺾이는 지점을 옆 기계로 밀어내는 셈이 된다
  • 하드웨어 — SSD·코어·RAM은 재 볼 대상이라기보다 예산 문제다

정리 — 어떤 순서로 손댈 것인가

순서무엇이 프로젝트에서대가
0먼저 측정 (-ftime-trace)프론트엔드가 313ms 중 254ms없음
1병렬도 조정9.0배 (단, -j16이면 대부분 얻음)없음
2ccache캐시 히트 시 18배디스크, CI 설정
3유니티 빌드4.4배, 증분도 개선이름 충돌·매크로 누수 정리
4PCH1.65배헤더 바뀌면 오히려 손해
5헤더 위생TU당 3.4배리팩터링 시간
6링커 교체7% (링크가 짧아서)거의 없음

핵심은 순서다. 1단계에서 “프론트엔드가 지배적”이라는 답이 나왔기 때문에 유니티 빌드와 헤더 위생이 크게 먹혔다. 만약 최적화나 코드 생성이 지배적인 프로젝트였다면 같은 기법이 훨씬 덜 들었을 것이고, 그때는 병렬화와 캐시 쪽에 시간을 써야 한다. 목록을 위에서부터 다 적용하는 대신, 재고 나서 고른다.

여기까지가 내 소스를 빌드하는 이야기다. 실제 프로젝트가 링크하는 남의 라이브러리를 어떻게 찾아내는지의존 라이브러리를 어떻게 찾는가에서 이어 간다.

이 글의 모든 수치는 WSL2 우분투 22.04(32코어, gcc 11.4.0, clang 18.1.8, CMake 3.22.1, Ninja 1.10.1, ccache 4.5.1)에서 같은 프로젝트를 반복 빌드해 얻었다. 소스 152개 규모의 합성 프로젝트이므로 배수는 프로젝트마다 다르다 — 방법은 그대로 쓰되 숫자는 각자 재는 편이 낫다.

참고

  • Faster C++ builds (Marcus Geelnard) — 병렬 빌드·컴파일러 캐시·링커·분산 빌드·유니티 빌드·PCH 등 손댈 지점의 목록을 잡는 데 참고했다. 이 글의 측정 대상과 수치는 별도로 구성해 실행한 것이다.
  • Another cool MSVC flag: /d1reportTime (Aras Pranckevičius) — MSVC에 /d1reportTime이 있다는 사실을 여기서 알았다. 위 출력과 수치는 이 환경(MSVC 14.51)에서 직접 실행해 얻은 것이다.
  • CMake — UNITY_BUILD — 묶음 파일 생성 방식과 배치 크기 옵션을 확인했다.
  • CMake — target_precompile_headers — PCH를 타깃 단위로 붙이는 방법을 확인했다.
  • Clang — -ftime-trace — 컴파일 시간을 단계별 JSON으로 뽑는 옵션을 확인했다.
  • ccache 문서 — 컴파일러 앞단 실행기로 붙이는 방식과 통계 확인 방법을 참고했다.