- Docker 배포용 Rust 웹사이트 빌드에서 의존성을 캐시해도 최종 크레이트만 약 175초 걸리며, 병목이
rustc내부와 LLVM 최적화 단계로 좁혀짐 cargo-chef,cargo --timings,-Zself-profile,measureme를 차례로 적용한 결과, 단순 의존성 문제가 아니라 LTO와 LLVM 코드 생성 비용이 빌드 시간을 지배함- 오래된
Cargo.toml설정의lto = "thin"과debug = "full"이 큰 영향을 줬고, 둘을 끄자 최종 바이너리 빌드는 172.2초에서 약 50초 수준으로 줄어듦 - LLVM 추적에서는
OptFunction,InlinerPass,core::ptr::drop_in_place, 큰 async 함수와 제네릭 단형화가 주요 비용으로 나타났고, 인라이닝 축소·함수 분리·Pin<Box<dyn Future>>·제네릭 제거가 추가 개선을 만듦 - 마지막으로
-Zshare-generics와 Debian 기반 빌드 전환을 적용하자 컴파일 시간이 29.1초에서 9.1초까지 떨어져, 코드 구조뿐 아니라 allocator와 musl 타깃 여부도 빌드 시간에 크게 작용함
Docker 빌드에서 드러난 병목
- 웹사이트는 주로 단일 Rust 바이너리로 제공되며, 기존에는 정적 링크 바이너리를 빌드해 서버로 복사한 뒤 서비스를 재시작했음
- 컨테이너 기반 배포로 옮기려 하자 Docker에서 빠른 Rust 빌드를 구성하는 일이 예상보다 까다로웠음
- 기본 Dockerfile은 소스가 바뀔 때마다 모든 것을 다시 빌드함
rust:1.87-alpine3.22를 builder로 쓰고x86_64-unknown-linux-musl타깃으로 빌드- 최종 이미지는 Alpine에 바이너리만 복사
- 이 방식의 클린 빌드는 crates 다운로드 10초를 포함해 3분 51초 걸림
cargo-chef로 의존성 캐시를 분리했지만 부족했음
- cargo-chef는 워크스페이스에서 단순화된 recipe 파일을 만들고, 이를 기반으로 의존성을 별도 Docker 캐시 레이어에 미리 빌드함
- 웹사이트가 수백 개 의존성을 사용하므로 캐시 효과가 클 것으로 기대했음
- 실제 측정에서는 의존성 빌드가 1분 7초, 캐시된 의존성을 사용한 최종 바이너리 빌드가 2분 50초였음
- 전체 시간의 약 25%만 의존성에 쓰였고, 나머지 대부분은
web-http-server최종 크레이트의 단일rustc호출에서 소비됨
cargo --timings와 rustc self-profile
cargo build --release --timings는 크레이트별 컴파일 시간을 보여주며, 최종 크레이트 시간은 174.1초로cargo build출력의 2분 54초와 대략 일치함- 병목이 최종 크레이트 하나에 집중되어 있어
cargo --timings만으로는 세부 원인을 파악하기 어려웠음 rustc의 self-profile 기능을 쓰기 위해-Zself-profile을 사용함- 안정 컴파일러에서 불안정
-Z플래그를 쓰기 위해RUSTC_BOOTSTRAP=1을 사용 cargo-chef캐시 무효화를 피하려고cargo rustc -- -Z self-profile대신RUSTFLAGS='-Zself-profile'을 사용
- 안정 컴파일러에서 불안정
- measureme의
summarize,flamegraph,crox도구로 self-profile 데이터를 분석함 summarize상위 항목은 LLVM 관련 작업에 몰려 있었음LLVM_lto_optimize: 851.95초, 전체의 33.389%LLVM_module_codegen_emit_obj: 674.94초, 26.452%LLVM_thin_lto_import: 317.75초, 12.453%LLVM_module_optimize: 189.00초, 7.407%
- flamegraph에서는
codegen_module_perform_lto가 전체 시간의 약 80% 를 차지함
LTO와 디버그 심볼 설정의 영향
- Rust 컴파일러는 크레이트를 codegen unit으로 나누고 LLVM에 별도 모듈로 넘김
- LTO는 링크 시점에 codegen unit 또는 크레이트 사이에서 인라이닝과 최적화를 수행하는 옵션임
- Cargo와
rustc의 LTO 선택지는 다음과 같음- LTO 끔
"thin"LTO"fat"LTO- 명시하지 않으면 단일 크레이트 안에서만 제한되는 “thin local LTO”
- 기존
Cargo.toml에는 몇 년 전 설정한 값이 남아 있었음lto = "thin"debug = "full"
debug = "full"은 release 프로파일에서 기본적으로 제외되는 전체 디버그 심볼을 활성화함- 다양한
lto와debug조합을 측정한 결과는 차이가 컸음- LTO 끔,
debug=none: 50.0초 / 21.0MiB - Thin local LTO,
debug=full: 88.2초 / 256.8MiB "thin"LTO,debug=full: 172.2초 / 197.5MiB"fat"LTO,debug=full: 287.1초 / 155.9MiB
- LTO 끔,
- 전체 디버그 심볼은 컴파일 시간을 30~50% 늘렸고, fat LTO는 LTO를 완전히 끈 경우보다 약 4배 오래 걸림
- LTO와 디버그 심볼을 꺼도 최종 바이너리 하나를 컴파일하는 데 여전히 약 50초가 남음
증분 컴파일 대신 Docker 캐시를 유지한 이유
- 로컬 개발에서는
/target디렉터리를 Dockerfile의 cache mount로 연결하고 빌드 사이에 유지하면 증분 컴파일을 사용할 수 있음 - 다만
docker build가 매번 깨끗한 환경을 가질 수 있다는 점을 유지하고, Docker 자체 캐시 시스템을 활용하기 위해cargo-chef를 계속 사용함
LTO 이후에도 남은 LLVM 최적화 비용
- LTO와 디버그 심볼을 끈 뒤에도 최종 바이너리 컴파일은 약 50초 걸렸음
- self-profile을 다시 보면 시간의 약 70% 가
LLVM_module_optimize에 쓰였고, 이는 LLVM이 코드를 최적화하는 단계임 - release 프로파일의 기본
opt-level = 3을 낮춰 최종 바이너리만 덜 최적화하는 설정을 실험함- 의존성은 캐시되므로
profile.release.package."*"에서opt-level = 3유지 - 최종 크레이트만
opt-level을 낮춤
- 의존성은 캐시되므로
- 측정 결과는 최적화 유무에 따라 크게 갈림
- 최종
opt-level=0: 약 15초 - 최종
opt-level=1: 약 48초 - 최종
opt-level=2또는3: 약 50~55초 - 최종
opt-level="z": 약 42초
- 최종
- 최종 바이너리에 어떤 최적화든 켜면 약 50초 기준선이 생기고, 최적화를 완전히 끄면 약 15초로 빨라짐
LLVM 추적 데이터 수집의 어려움
rustc에는 LLVM 정보를 보기 위한 플래그가 있음-Z time-llvm-passes: LLVM 프로파일 정보를 일반 텍스트로 출력-Z llvm-time-trace: Chrome tracing format으로 LLVM 프로파일 출력
-Z time-llvm-passes는 Docker BuildKit의 기본 로그 제한에 걸렸음BUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- 이 환경 변수는
docker build호출이 아니라 Docker daemon에 설정해야 하며, Linux에서는systemddrop-in으로docker.service에 설정할 수 있음 - 제한을 풀면 약 20만 줄의 텍스트가 출력되어 직접 다루기 어려웠음
-Z llvm-time-trace는*.llvm_timings.json파일을 생성했지만, 최종 바이너리 추적 파일은 1.4GiB의 단일 줄 JSON이었음- Firefox Profiler, Perfetto UI, Chromium의
chrome://tracing모두 이 파일을 다루는 데 문제가 있었음 - JSON을 JSONL로 바꿔 일반 도구로 처리함
- 단일 JSON 객체의
traceEvents배열을 이벤트별 한 줄로 분리 - 변환 뒤 이벤트 수는 7,301,865줄이었음
- 단일 JSON 객체의
LLVM 이벤트에서 보인 병목
- LLVM 추적 이벤트는 주로
"ph":"X"인 complete event였고,dur필드가 마이크로초 단위 duration을 나타냄 "ph":"M"은 metadata event였으며, 이 분석에서는 유용한 정보가 많지 않았음- aggregate 이벤트에서 시간이 많이 쓰인 항목은 다음과 같음
Total ModuleInlinerWrapperPass: 665.37초Total ModuleToPostOrderCGSCCPassAdaptor: 656.47초Total DevirtSCCRepeatedPass: 632.44초Total OptFunction: 189.62초Total InlinerPass: 182.25초
- 이 실행은 16코어 머신에서 약 110초 걸렸기 때문에 일부 pass 시간은 중복 집계됨
- 큰 축은 함수 최적화인
OptFunction과 인라이닝인InlinerPass였음
인라이닝 임계값 조정
- LLVM 인라이닝 옵션은
rustc의-C llvm-args로 전달할 수 있음 - 2025년 6월 기준
rustc -C llvm-args='--help-list-hidden'에는 인라이닝 관련 옵션이 약 100개 있음 - 실험에 사용한 옵션은 세 가지였음
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- threshold는 대략 비용이 그 값보다 낮은 함수의 인라이닝을 허용하므로, 값을 낮추면 인라이닝이 줄어듦
- 세 임계값을 50으로 낮추자 시간이 48.8초에서 42.2초로 감소함
- 부하가 거의 없는 개인 웹사이트라는 사용 사례에서는 threshold 10도 유망한 선택지로 봄
OptFunction과 제네릭 단형화
OptFunction이벤트의args.detail에는 최적화 중인 함수의 mangled symbol이 들어 있음- rustfilt로 demangle하면 원래 Rust 심볼을 볼 수 있음
__rustc::__rust_allocserde_json::value::to_value
- 같은
serde_json::value::to_value가 여러 해시로 나타난 이유는 제네릭 함수가 서로 다른 타입 파라미터로 단형화되기 때문임 - 다른 크레이트의 함수도 최종 크레이트에서 최적화됨. 함수가 특정 타입으로 단형화되는 위치가 호출하는 크레이트의 문맥이기 때문임
- 최적화 시간이 많이 든 함수 예시는 다음과 같음
web_http_server::photos::PhotosState::new안의 closureweb_http_server::run안의 closuretokio_postgres::connect_rawpulldown_cmark의 500줄 규모 제네릭 함수core::ptr::drop_in_place의 여러 구체 타입
- 바깥쪽 크레이트 이름으로 대략 집계하면
core가 61.53초로 가장 컸고, 그중 84%는core::ptr::drop_in_place의 파라미터화였음
v0 심볼 맹글링으로 async 함수 위치를 더 명확히 봄
- 기본 legacy symbol mangling은 closure를 구분하기 어려웠음
-C symbol-mangling-version=v0를 추가하면 closure 번호와 제네릭 타입 정보가 더 잘 드러남- 예를 들어
serde_json::value::to_value가 어떤web_http_server타입으로 단형화되었는지 전체 제네릭 인자를 볼 수 있었음 - v0 출력에서 비싼 항목은 다음과 같았음
<web_http_server::photos::PhotosState>::new::{closure#0}: 1.99초web_http_server::run::{closure#0}: 1.56초core::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1.22초
- 겉보기에는 작은 closure였지만, LLVM IR을 덤프해 보니 async 함수와 async block이 내부적으로 nested closure로 표현되어 있었음
- Rust에는 이미 async function/block의 맹글링 관련 open issue가 있었음
큰 async 함수와 Pin<Box<dyn Future>>
- 비싼 항목은 closure 자체라기보다 큰 async 함수 본문이었음
PhotosState::new관련 최적화 시간은 처음에 총 5.3초였음- 단순히 함수를 쪼갠 첫 시도는 4.66초로 조금만 줄었음
- 인접한
.await를 묶어.await수를 10개에서 3개로 줄인 시도는 오히려 6.24초로 늘어남 - async 함수는 내부적으로 복잡한 state machine으로 낮아지므로, caller에서 구현 세부를 감추기 위해
Future를 trait object로 지우는 방식을 시도함 - 사용한 함수는
impl Future<Output = T>를Pin<Box<dyn Send + Future<Output = T>>>로 감싸는 형태였음 .await지점마다erase(get_img_candidates()).await?처럼 적용한 결과:PhotosState::new관련 시간은 2.14초로 감소- 전체 빌드 시간은 프로파일링 없이 48.8초에서 46.8초로 감소
#[inline(never)]와 poll 함수 인라이닝 비활성화도 시도했지만, boxing만큼 좋지는 않았음
여러 변경을 합친 결과
- 적용한 접근은 세 가지였음
- LLVM args로 인라이닝 축소
- 메인 크레이트의 비싼 함수 분리와 async Future boxing
- 의존성 API의 제네릭을 줄여 최종 크레이트에서 다시 컴파일되는 부분 감소
- 최종 Dockerfile에는 세 인라이닝 threshold를 10으로 낮추는
RUSTFLAGS를cargo chef cook과cargo build양쪽에 적용함 - 메인 크레이트에는 10개 파일에 걸쳐 898줄 추가, 657줄 삭제가 생김
- 의존성 쪽 변경도 함께 들어감
pulldown-cmark의 제네릭 함수 비제네릭화 PRlol_html,deadpool_postgres에서 사용하는 API의 비제네릭 버전을 노출하는 로컬 크레이트
- 이 조합으로 최종 컴파일 시간은 32.3초가 됨
2025-06-27 업데이트: -Zshare-generics와 Alpine 제거
- Bluesky와 Lobsters에서 받은 제안으로 두 가지를 추가 실험함
-Zshare-generics활성화- Alpine에서 벗어나기
-Zshare-generics는 크레이트 의존성의 제네릭 인스턴스를 재사용하는 플래그임- release 빌드에서는 기본 활성화되지 않음
- stable toolchain의 dev build에서는 활성화되어 있음
- 이 플래그는 nightly에서만 사용 가능함
-Zshare-generics를 켠 결과 전체 컴파일 시간은 32.3초에서 29.1초로 줄었음drop_in_place인스턴스는 여전히 많이 컴파일되었지만, 해당 최적화 시간은 21.7초에서 17.4초로 감소함- Alpine에서 Debian으로 바꾸고
--target=x86_64-unknown-linux-musl을 제거하자 전체 컴파일 시간은 29.1초에서 9.1초로 크게 줄었음 - 제안의 배경에는 기본 allocator가 빌드 시간에 큰 영향을 줄 수 있다는 점이 있었음
최종 수치와 남은 과제
- 최종 변화는 다음과 같음
- 시작점: 약 175초
- LTO와 디버그 심볼 비활성화: 51초, -71%
- 최종 크레이트
opt-level = 1: 48.8초, -4% -C llvm-args로 인라이닝 축소: 40.7초, -16%- 로컬 코드 변경: 37.7초, -7%
- 의존성 변경: 32.3초, -14%
-Zshare-generics: 29.1초, -10%- Alpine 제거: 9.1초, -69%
- 분석 과정에서 도구와 문서는 실제 개선을 만들 만큼 잘 동작했음
- 다만 몇 가지 복잡한 문제는 남아 있음
- 깊은 async 함수 호출 그래프의 컴파일 시간은 더 개선될 필요가 있음
core::ptr::drop_in_place<T>를T를 정의한 크레이트에서 컴파일하도록 특수 처리하는 방안은 일부 경우에 도움이 될 수 있으나, 제네릭 타입에는 적용하기 어렵고 사용되지 않는 drop glue까지 컴파일할 위험이 있음-Zshare-generics는 도움이 되지만 완전한 해결책은 아님- 코드베이스의 어느 부분이 컴파일 시간을 많이 쓰는지 격리하고 완화책을 제안하는 도구가 더 필요할 수 있음
- 실용적으로는 최종 크레이트에
opt-level = 0을 설정하는 선택도 충분할 수 있음