스크랩

Bun의 Zig 빌드는 왜 24분이나 걸렸을까? 빌드 시각화 도구 buildprof로 추적한 LTO 병목

Bun의 Zig 빌드는 왜 24분이나 걸렸을까? 빌드 시각화 도구 buildprof로 추적한 LTO 병목

들어가며

빌드가 느리면 보통 코드가 많아서 그렇겠거니 하고 넘기기 쉽습니다. 하지만 실제로 들여다보면 병렬화가 안 되는 구간, 반복 작업, 의존성 다운로드, 유난히 오래 걸리는 컴파일러·링커 호출처럼 고칠 수 있는 원인이 숨어 있는 경우가 많습니다.

이번에 소개할 글은 Bun이 Rust로 전환한 뒤 빌드 속도가 크게 빨라졌다는 소식에서 출발합니다. 작성자는 그 이유를 직접 확인하려고 오픈소스 빌드 추적 도구 buildprof를 만들었습니다. 이 도구로 알아낸 내용을 차례대로 정리해 보겠습니다.

핵심 요약

  • buildprof는 Linux에서 빌드 명령이 실행하는 모든 하위 프로세스를 하나의 타임라인으로 보여주는 오픈소스 도구입니다.
  • Bun의 Zig 빌드를 추적해 보니 전체 24분 24초 가운데 최종 링크가 16분 35초를 차지했고, 그 시간 대부분이 Full LTO 작업에 쓰였습니다.
  • Bun 본체뿐 아니라 미리 빌드해 내려받던 WebKit과 ICU까지 ThinLTO로 다시 빌드하자 전체 빌드가 15분 11초로 줄었습니다. 다만 대조군 실험을 하지 않았기 때문에 줄어든 시간 전부를 LTO 설정 덕분이라고 볼 수는 없습니다.
  • 남은 차이는 컴파일 구조에 있었습니다. Rust 빌드는 90개가 넘는 크레이트로 나뉘어 있지만, Zig 빌드는 단일 모듈에 몰려 있어 같은 방식으로 병렬화할 수 없었습니다.

buildprof는 어떤 도구인가요?

사용법은 간단합니다. 기존 빌드 명령 앞에 buildprof --만 붙이면 됩니다.

buildprof -- make
buildprof -- cargo build

Make, Cargo, Ninja, just는 물론 직접 만든 스크립트에도 쓸 수 있습니다. 결과 타임라인은 이렇게 읽으면 됩니다.

  • 가로축: 시간
  • 막대 너비: 프로세스 실행 시간
  • 아래쪽 배치: 부모와 자식 프로세스 관계

왜 프로세스 트리를 기준으로 볼까요?

Cargo는 크레이트, Ninja는 빌드 간선, CMake는 다른 빌드 시스템에 넘길 실행 지침을 다룹니다. 이렇게 저마다 개념은 다르지만, 운영체제 입장에서는 모두 프로세스가 또 다른 프로세스를 실행하는 일일 뿐입니다. 예를 들어 Rust 빌드에서는 이런 실행 흐름이 생깁니다.

cargo → rustc → cc → collect2 → ld.lld

이 계층에서 기록하면 빌드 시스템마다 따로 연동하지 않아도 여러 도구를 한 화면에서 볼 수 있습니다. 빌드 시스템 바깥에서 도는 저장소 설정·의존성 다운로드 스크립트나, 빌드 안쪽의 코드 생성기까지 모두 잡힙니다.

여기에 더해 프로세스별 파일 읽기/쓰기도 기록합니다. 한 프로세스가 쓴 파일을 다른 프로세스가 읽으면 둘을 연결하기 때문에, 서로 다른 빌드 시스템 사이에서도 누가 결과물을 만들고 누가 쓰는지 따라갈 수 있습니다.

다만 프로세스 하나가 내부에서 여러 스레드로 일하는 모습은 타임라인만으로는 보이지 않습니다. 이 부분은 Clang, LLD처럼 프로그램이 자체적으로 제공하는 실행 추적을 가져와 타임라인에 합칩니다(--compiler-traces). 현재 Clang, LLD, nightly Rust를 지원합니다.

조사의 출발점: Rust 빌드가 5배 빠르다?

Bun의 수석 아키텍트 Jarred Sumner는 새 Rust 빌드가 기존 Zig 빌드보다 Linux에서 5배 넘게 빠르다고 밝혔습니다. 비슷한 규모의 프로젝트에서는 Zig가 Rust보다 훨씬 빨리 컴파일되던 작성자의 경험과는 반대되는 결과였죠.

그런데 두 빌드는 언어 말고도 중요한 차이가 하나 더 있었습니다.

| 구분 | Zig 빌드 | Rust 빌드 | |---|---|---| | LTO 방식 | Full LTO | ThinLTO | | 컴파일 단위 | 사실상 단일 모듈 | 90개 이상의 크레이트 |

잠깐, LTO가 뭐였죠?

**링크 시간 최적화(LTO, Link Time Optimization)**는 컴파일 단위의 경계를 넘어 프로그램 전체를 최적화하는 기법입니다.

  • Full LTO: 여러 단위를 하나의 거대한 최적화 작업으로 합칩니다.
  • ThinLTO: 단위 사이의 분리를 더 많이 유지해서 상당 부분을 병렬로 처리할 수 있습니다.

이 차이 하나만으로도 빌드 시간이 크게 달라질 수 있습니다.

컴파일 단위도 언어마다 다릅니다. C/C++는 보통 소스 파일과 포함된 헤더가 한 단위이고, Rust는 크레이트를 여러 코드 생성 단위로 나눌 수 있으며, Zig는 프로그램의 Zig 소스 전체를 보통 하나의 단위로 처리합니다. Bun이 쓰는 Zig 컴파일러 포크는 여러 LLVM 모듈로 나눌 수 있었지만, CI에서는 LTO를 켤 때 모듈 하나를 쓰도록 명시해 두었습니다.

CI 빌드를 재현해 보기

작성자는 Bun 1.3.14(Zig)와 1.4.0(Rust)의 Linux x64 CI 빌드를 6코어 12스레드 VM 한 대에서 스크립트로 재현했습니다.

  • Bun이 공개한 CI 중앙값: Zig 30분 06초 / Rust 5분 37초
  • 단일 머신 재현값: Zig 24분 24초 / Rust 5분 40초

격차의 규모는 비슷하게 나왔습니다. 다만 원래 CI는 C++와 Zig 컴파일을 서로 다른 Buildkite 머신에서 돌렸고, 재현 스크립트는 둘을 한 머신에서 동시에 돌렸기 때문에 하드웨어와 자원 경합 조건이 다릅니다. 또 재현값은 한 번 실행한 결과이고 Bun의 수치는 중앙값입니다. 무엇보다 언어 말고도 바뀐 요소가 많았기 때문에, 시간이 정확히 어디서 쓰였는지 보여줄 실행 추적이 필요했습니다.

발견 1: 병목은 최종 링크였다

Zig 빌드 타임라인에서 ld.lld가 마지막에 홀로 16분 넘게 실행되고 있었습니다. 전체 시간의 약 3분의 2에 해당합니다. 자동으로 수집된 명령행에서도 Full LTO를 쓰고 있다는 게 확인됐습니다.

--compiler-traces를 켜고 링크를 다시 기록해 보니, 링커 내부 시간도 대부분 LTO에 쓰이고 있었습니다. 링커가 이미 컴파일된 파일을 합치는 데 그치지 않고, 프로그램 전체에 컴파일러 패스를 돌리고 있었던 것입니다.

  • 기계어 생성 패스를 포함한 OptModule 이벤트 하나에만 10분 넘게 걸렸습니다.
  • 참고로 OptModule은 LLD가 코드 생성에 쓰는 LLVM 레거시 패스 관리자의 이벤트입니다. 인라이닝 같은 IR 최적화가 그 앞에 따로 나타날 수 있어서, 전체 모듈 최적화 시간과 같은 값은 아닙니다.

반면 Rust 빌드의 링크 시간은 2분 24초였고, 링커 명령에는 예상대로 -plugin-opt=thinlto가 들어 있었습니다.

발견 2: Bun만 ThinLTO로 바꿔서는 부족했다

Zig 버전 Bun에 ThinLTO 빌드 플래그 패치를 적용해 다시 클린 빌드를 해 봤습니다. 링크가 3분 40초 빨라졌지만 여전히 13분 가까이 걸렸습니다.

컴파일러 추적을 보니 JavaScriptCore의 JSC 심볼을 처리하는 작업이 잔뜩 보였습니다. Bun이 쓰는 JavaScript 엔진까지 링커가 다시 컴파일하고 있었던 것이죠.

  • 이전 Full LTO 링크에서 JSC 심볼이 붙은 OptFunction 이벤트는 26,825개였고, 시간을 모두 더하면 약 209초였습니다. 이는 이벤트 시간의 합계일 뿐 JavaScriptCore가 링크 시간에 기여한 전체 몫을 잰 값은 아닙니다.
  • 예를 들어 JSC::JITThunks::initialize(JSC::VM&) 하나에 2.94초가 걸렸습니다.

링크 입력을 거꾸로 따라가 보니, libJavaScriptCore.a 같은 WebKit 라이브러리는 Bun이 직접 컴파일하지 않고 별도 빌드에서 내려받는 아카이브였습니다. 그리고 그 WebKit은 -flto=full로 빌드되어 있었습니다. Rust 빌드는 ThinLTO로 설정된 더 최신 WebKit 리비전을 쓰고 있었고요.

결국 Bun 설정만 바꿔서는 소용이 없었습니다. 내려받은 라이브러리에 Full LTO 입력이 그대로 남아 있으니, 링커는 여전히 그 코드를 최적화하고 기계어로 바꿔야 했습니다.

발견 3: WebKit과 ICU까지 ThinLTO로 다시 빌드하면?

작성자는 과거 WebKit 리비전과 ICU 의존성을 호환되는 ThinLTO 설정으로 다시 빌드해서 내려받던 라이브러리를 바꿔 끼웠습니다. 결과는 다음과 같습니다.

| 구성 | 전체 빌드 | 최종 링크 | |---|---|---| | 기존 Full LTO | 24분 24초 | 16분 35초 | | Bun만 ThinLTO | 20분 20초 | 12분 55초 | | Bun + WebKit + ICU ThinLTO | 15분 11초 | 7분 22초 |

몇 가지 짚고 넘어갈 점이 있습니다.

  • 이 수치는 라이브러리를 미리 준비해 둔 상태에서 잰 Bun 빌드 시간이며, WebKit과 ICU를 다시 빌드한 시간은 들어 있지 않습니다.
  • 같은 조건의 Full LTO WebKit을 대조군으로 다시 빌드하지 않았기 때문에, 줄어든 시간 전부를 LTO 설정 효과로 볼 수는 없습니다.

링크 전 8분 대기, 원인은 파일 의존성

링크가 시작되기 전에도 거의 8분이 걸렸습니다. buildprof의 Show on timeline 기능을 켜면 파일 생산자와 소비자가 화살표로 이어지는데, 이걸로 원인을 찾을 수 있었습니다.

  • 링커는 C++ 컴파일 결과물인 libbun-profile.a와 Zig 컴파일 결과물인 bun-zig.o를 받습니다.
  • C++ 쪽은 먼저 끝났지만 bun-zig.o가 아직 준비되지 않아, Zig 쪽이 끝날 때까지 링크를 시작할 수 없었습니다.

끝내 남은 구조적 차이

마지막으로 남은 큰 차이는 Rust의 90개 넘는 크레이트 대 Zig의 단일 모듈이었습니다. 이 구조에서는 Zig 빌드를 Rust처럼 병렬화할 수 없습니다. 거대한 ThinLTO 비트코드 모듈 하나를 최적화해야 하는 구조가 느린 링크까지 설명할 수 있을 거라는 추측도 있었지만, 이는 입증하지 못했습니다. 확인하려면 Zig 모듈을 직접 쪼개야 했는데, 이미 쓰지 않는 코드라서 조사는 여기서 멈췄습니다.

타임라인에서 덤으로 발견한 것들

  • CI 중간의 잡다한 작업: 공인 IP 조회, 실행 중인 Docker 컨테이너 검사, 최신 Git 커밋 메시지 읽기 등이 있었지만 다 합쳐도 1초에 한참 못 미쳐 최적화할 대상은 아니었습니다.
  • WebKit 다운로드: 다운로드와 압축 해제에 약 20초가 걸렸습니다. 처음 12초는 Node 실행만 보이다가 그 뒤에 tar와 gzip이 시작돼서, 압축 해제 시간을 따로 확인할 수 있었습니다.
  • 늦게 끝나는 C++ 파일 분석: ZigGeneratedClasses.cpp의 Ninja 명령을 --compiler-traces로 다시 실행하자, Clang의 -ftime-trace 결과가 타임라인에 합쳐졌습니다. 약 12초가 프런트엔드와 백엔드에 거의 절반씩 쓰였고, 백엔드의 ModuleInlinerWrapperPass가 4초 넘게 차지했습니다.

어떻게 기록하고, 오버헤드는 얼마나 될까?

buildprof는 디버거가 쓰는 Linux 인터페이스인 **ptrace**로 빌드를 실행하고 자식 프로세스를 직접 추적합니다.

  • fork, exec, 종료는 ptrace 내장 이벤트로 감지합니다.
  • 파일 시스템 활동은 seccomp 필터로 필요한 시스템 호출만 골라서 가로챕니다.
  • eBPF와 ftrace도 검토했지만 제외했습니다. eBPF는 CAP_BPF, CAP_PERFMON 같은 권한이 필요하고 불안정할 수 있는 추적 지점이나 커널 함수에 연결해야 합니다. ftrace는 다른 사용자와 간섭하지 않도록 막고, 하위 프로세스를 필터링하고, tracefs 접근을 설정하는 부담이 있었습니다.
  • 다만 ptrace도 호스트 보안 설정의 영향을 받습니다. 컨테이너에서는 자식 프로세스 추적을 허용하는 추가 권한이 필요할 수 있습니다.

기록 비용은 거의 파일을 여는 횟수에 달려 있습니다. 같은 VM에서 빌드 작업 6개로 모드별 클린 빌드를 5번씩 돌려 중앙값을 쟀더니, ripgrep(Cargo) 빌드의 결과는 이랬습니다.

  • 추적 안 함: 12.27초
  • 프로세스만 추적: 12.30초
  • 프로세스 + 파일 추적: 12.43초

오버헤드가 거의 없는 수준입니다. 다만 파일을 아주 많이 여는 빌드라면 기록 비용이 더 커질 수 있다는 점은 기억해 두세요.

에디터의 한마디

이 글에서 가장 인상적인 부분은 Rust가 Zig보다 빠르다는 단순한 결론으로 가지 않았다는 점입니다. 실제로 드러난 원인은 언어가 아니라 이런 것들이었습니다.

  1. 링커가 떠안은 Full LTO 작업
  2. 내려받은 라이브러리(WebKit, ICU)에 숨어 있던 LTO 설정
  3. 단일 모듈 구조 때문에 막힌 병렬화

그리고 작성자는 대조군이 없다는 점, 추측을 입증하지 못했다는 점을 분명히 밝혀 두었습니다. 빌드가 느리다고 느껴진다면 추측하기 전에 먼저 측정해 보세요. buildprof처럼 프로세스 트리 전체를 보여주는 도구는 빌드 시스템 경계에 가려져 있던 병목을 찾는 데 큰 도움이 됩니다.


원문: https://news.hada.io/topic?id=33611