Bun 빌드가 5배 빨라진 진짜 이유를 추적한 오픈소스 프로파일러 buildprof

Lobsters28일 전조회 4

Lalit Maganti가 Linux에서 소프트웨어를 컴파일할 때 시간이 어디로 흘러가는지를 보여주는 오픈소스 트레이싱 도구 buildprof를 공개했다. 빌드 시스템 종류와 무관하게 동작하는 것이 특징으로, Cargo·Ninja·Zig·Make처럼 프로세스를 생성해 작업을 처리하는 빌드라면 별도 플러그인 없이 추적할 수 있다.

사용법은 단순하다. 기존에 쓰던 빌드 명령 앞에 buildprof --를 붙이면 된다. 예를 들어 buildprof -- ninja -C out/target이나 buildprof -- ./dev/custom-build-script.sh 형태다. 도구는 빌드 명령이 실행한 모든 프로세스와 그 하위 프로세스, 다시 그 하위 프로세스까지 기록해 하나의 타임라인 위에 배치한다. 시간은 왼쪽에서 오른쪽으로 흐르고, 막대의 너비가 실행 시간을 나타내며, 자식 프로세스는 자신을 실행한 부모 아래에 그려진다. 각 프로세스가 읽고 쓴 파일까지 기록되기 때문에 어떤 단계가 다른 단계의 입력을 만들어내는지도 따라갈 수 있다.

이 도구가 나온 계기는 Bun 런타임의 수석 아키텍트 Jarred Sumner의 발표였다. Bun이 기존 Zig 빌드에서 Rust 빌드로 옮기면서 Linux 기준 빌드 시간이 5배 이상 빨라졌다는 주장이었는데, 저자는 Zig 프로젝트가 비슷한 복잡도의 Rust 프로젝트보다 대체로 더 빨리 컴파일된다는 경험을 갖고 있었기에 이 수치가 걸렸다. 여기에 결정적인 단서가 하나 더 있었다. Zig 빌드는 Full LTO를, Rust 빌드는 ThinLTO를 사용했다는 점이다. 링크 타임 최적화(LTO)는 컴파일 단위를 넘어서 최적화를 수행하는 기법인데, Full LTO는 모든 단위를 하나로 합쳐 거대한 최적화 작업을 만들고 ThinLTO는 분리를 더 많이 유지한다. 이 차이가 빌드 시간에 큰 영향을 줄 수 있다는 것이 저자의 문제의식이었다.

먼저 수치를 재현했다. 6코어 12스레드 Linux VM에서 각 시대의 CI 빌드 스크립트를 단일 머신에서 재생했고, 결과는 같은 범위에 들어왔다. Bun이 보고한 CI 중간값은 Zig 시대 30분 6초, Rust 시대 5분 37초였고, 저자의 재생 결과는 24분 24초와 5분 40초였다. 격차는 분명히 존재했지만 언어 외에도 많은 것이 바뀐 상태였기 때문에, 원인을 특정하려면 추적이 필요했다.

Zig 시대 빌드를 기록해 보니 문제가 바로 드러났다. ld.lld 링커 호출이 빌드 막바지에 홀로 16분 넘게 실행되며 전체 시간의 약 3분의 2를 차지하고 있었다. 프로세스 트리만으로는 그 시간이 LTO 때문인지 알 수 없었지만, LLD가 자체적으로 남기는 내부 타이밍 이벤트를 --compiler-traces 옵션으로 함께 수집하자 답이 나왔다. 링커가 이미 컴파일된 파일을 합치는 수준을 넘어 프로그램 전체에 컴파일러 패스를 돌리고 있었고, 기계어 코드를 생성하는 패스를 포함한 OptModule 구간만 10분을 넘겼다.

Rust 시대 빌드의 링크 시간은 2분 24초였고, 링커 명령에는 예상대로 ThinLTO가 들어 있었다. 그렇다면 Zig 코드는 그대로 두고 Full LTO만 ThinLTO로 바꾸면 어떻게 될까. 빌드 플래그를 바꿔 다시 측정한 결과 링크 시간이 3분 40초 줄었지만 여전히 13분에 가까웠다. 남은 시간은 JSC라는 이름이 붙은 함수들에서 소모되고 있었다. JavaScriptCore, 즉 Bun이 자바스크립트 실행에 사용하는 엔진이다.

링커 입력 목록을 따라가 보니 Bun은 이 라이브러리들을 직접 컴파일하지 않고 별도의 WebKit 빌드에서 내려받고 있었고, 그 아카이브에는 -flto=full이 붙어 있었다. Rust 빌드는 ThinLTO를 선택하는 더 새로운 WebKit 리비전을 사용했지만, Zig 시대 빌드는 그렇지 않았던 것이다. 결국 Bun 자신의 코드만 ThinLTO로 바꿔서는 부족했고, 저자는 해당 시점의 WebKit 리비전과 ICU 의존성을 호환되는 ThinLTO 설정으로 다시 빌드해 내려받은 라이브러리를 교체하는 실험까지 진행했다.

측정 결과는 원본 Full LTO 구성이 전체 24분 24초, 최종 링커 16분 35초였고, Bun을 ThinLTO로 바꾸되 기존 WebKit 아카이브를 그대로 쓴 구성은 전체 20분 20초, 최종 링커 12분 55초였다. 빌드 언어를 바꾼 것이 전부가 아니라, 링크 단계에 들어오는 외부 정적 라이브러리의 LTO 설정까지 함께 맞춰야 한다는 것이 이 분석의 핵심이다.

실무에서 얻을 교훈은 명확하다. 빌드가 느릴 때 컴파일러나 언어를 의심하기 전에, 프로세스 단위 타임라인으로 링커가 얼마나 오래 홀로 돌고 있는지, 그 입력으로 들어오는 아카이브가 어떤 LTO 플래그로 만들어졌는지 확인할 가치가 있다. 다만 이번 측정은 단일 머신에서 CI 프로파일을 재생한 결과이며 특정 시점의 WebKit 리비전을 전제로 하므로, 실제 CI 환경이나 최신 리비전에서는 수치가 달라질 수 있다.