Tips for Faster Rust Compile Times

Matthias Endler

더 빠른 Rust 컴파일을 위한 팁

Rust 빌드가 느리신가요?

컴파일 시간을 단축할 수 있는 몇 가지 팁을 소개합니다. 이 목록은 원래 제 개인 블로그에 공개했던 것이지만, 업데이트하여 이곳으로 옮겼습니다.

모든 팁은 대략 효과 순으로 정렬되어 있으니 위에서부터 차례대로 적용해 보세요.

일반 팁

Rust 컴파일러와 툴체인 업데이트하기

최신 Rust 버전을 사용하고 있는지 확인하세요:

rustup update

Rust 컴파일러를 더 빠르게 만드는 작업은 지금도 계속 진행 중입니다. 그분들의 노력 덕분에 컴파일러 속도는 올해 들어 전반적으로 30~40% 향상되었으며, 일부 프로젝트에서는 45% 이상 개선되기도 했습니다. 툴체인을 최신 상태로 유지하는 것만으로도 충분히 보상이 됩니다.

cargo build 대신 cargo check 사용하기

# Slow 🐢
cargo build

# Fast 🐇 (2x-3x speedup)
cargo check

대부분의 경우 프로젝트를 아예 컴파일할 필요조차 없습니다. 그저 어딘가 잘못된 곳이 없는지만 확인하고 싶을 뿐이죠. 가능하다면 컴파일 자체를 건너뛰세요. 대신 필요한 것은 빠르고 정확한 코드 린트, 타입 및 빌림 검사입니다.

가능하다면 항상 cargo build 대신 cargo check를 사용하세요. 이 명령은 오류 여부만 검사하고 실행 파일을 생성하지 않습니다.

왼쪽의 cargo check와 가운데의 cargo debug 사이의 명령어 수 차이를 살펴보세요. (서로 다른 스케일에 주목하세요.)

속도 향상 계수: check 1, debug 5, opt 20

제가 자주 쓰는 유용한 팁은 cargo watch로 백그라운드에서 실행하는 것입니다. 이렇게 하면 파일을 변경할 때마다 자동으로 cargo check가 실행됩니다.

보너스: cargo watch -c를 사용하면 매 실행 전에 화면을 지울 수 있습니다.

사용하지 않는 의존성 제거하기

# Install cargo-machete 🔪️
cargo install cargo-machete && cargo machete

# Install cargo-shear ✂️🐑
cargo install cargo-shear

# Install cargo-udeps 🧼🧹️
cargo install cargo-udeps --locked

리팩터링 후에는 의존성이 더 이상 필요 없게 되는 경우가 있습니다. 가끔 사용하지 않는 의존성을 제거할 수 있는지 확인해 보면 도움이 됩니다.

위 도구들은 프로젝트에서 사용하지 않는 의존성을 모두 나열해 줍니다. 각 도구마다 한계가 있어 오탐과 미탐이 발생할 수 있으므로, 세 도구를 함께 사용하면 가장 좋은 결과를 얻을 수 있습니다.

Analyzing dependencies of crates in this directory...
cargo-machete found the following unused dependencies in <project>:
crate1 -- <project>/Cargo.toml:
        clap
crate2 -- <project>/crate2/Cargo.toml:
        anyhow
        async-once-cell
        dirs
        log
        tracing
        url

자세한 내용은 cargo-machete, cargo-shearcargo-udeps 프로젝트 페이지에서 확인할 수 있습니다.

cargo-shearcargo-udeps를 알려주신 독자 Nicholas Nethercote님께 감사드립니다. 이분은 Rust Performance Book과 유명한 How to speed up the Rust compiler 시리즈의 저자입니다.

의존성 업데이트하기

  1. cargo update를 실행해 호환되는 최신 semver 버전으로 업데이트하세요.
  2. cargo outdated -wR를 실행해 더 새롭고 호환되지 않을 수도 있는 의존성을 찾으세요. 해당 의존성을 업데이트하고 필요에 따라 코드를 수정하세요.
  3. cargo tree --duplicate를 실행해 여러 버전으로 포함된 의존성을 찾으세요. 오래된 버전에 의존하는 패키지를 업데이트해 단일 버전으로 통합하는 것을 목표로 하세요. (/u/dbdr 님께서 알려주신 내용에 감사드립니다.)

(Reddit의 /u/oherrala 님이 알려주신 방법입니다.)

여기에 더해 cargo audit를 사용하면 조치가 필요한 취약점이나 교체가 필요한 크레이트에 대한 알림을 받을 수 있습니다.

코드베이스에서 느린 크레이트 찾기

cargo build --timings

이 명령은 각 크레이트의 컴파일에 걸리는 시간에 대한 정보를 제공합니다.

cargo build --timings 다이어그램

다이어그램에서 빨간 선은 현재 컴파일을 기다리고 있는(다른 크레이트 때문에 차단된) 유닛(크레이트) 수를 나타냅니다. 단일 크레이트 때문에 많은 크레이트가 병목되고 있다면, 병렬성을 높이기 위해 해당 크레이트 개선에 집중하세요.

색상의 의미는 다음과 같습니다:

  • Waiting (빨강) — CPU 슬롯이 비기를 기다리는 크레이트.
  • Inactive (파랑) — 의존성이 완료되기를 기다리는 크레이트.
  • Active (초록) — 현재 컴파일 중인 크레이트.

자세한 내용은 문서에서 확인할 수 있습니다.

컴파일 시간 프로파일링하기

cargo --timings보다 더 깊이 파고들고 싶다면 cargo rustc -- -Zself-profile로 Rust 컴파일을 프로파일링할 수 있습니다. 생성된 트레이스 파일은 플레임그래프나 Chromium 프로파일러로 시각화할 수 있습니다:

모든 크레이트가 표시된 Chrome 프로파일러 이미지

또 하나 유용한 도구는 cargo-llvm-lines입니다. 이 도구는 최종 바이너리에서 생성된 줄 수와 각 제네릭 함수의 복사본 수를 보여줍니다. 이를 통해 컴파일 비용이 가장 높은 함수를 파악할 수 있습니다.

$ cargo llvm-lines | head -20

  Lines        Copies         Function name
  -----        ------         -------------
  30737 (100%)   1107 (100%)  (TOTAL)
   1395 (4.5%)     83 (7.5%)  core::ptr::drop_in_place
    760 (2.5%)      2 (0.2%)  alloc::slice::merge_sort
    734 (2.4%)      2 (0.2%)  alloc::raw_vec::RawVec<T,A>::reserve_internal
    666 (2.2%)      1 (0.1%)  cargo_llvm_lines::count_lines
    490 (1.6%)      1 (0.1%)  <std::process::Command as cargo_llvm_lines::PipeTo>::pipe_to
    476 (1.5%)      6 (0.5%)  core::result::Result<T,E>::map
    440 (1.4%)      1 (0.1%)  cargo_llvm_lines::read_llvm_ir
    422 (1.4%)      2 (0.2%)  alloc::slice::merge
    399 (1.3%)      4 (0.4%)  alloc::vec::Vec<T>::extend_desugared
    388 (1.3%)      2 (0.2%)  alloc::slice::insert_head
    366 (1.2%)      5 (0.5%)  core::option::Option<T>::map
    304 (1.0%)      6 (0.5%)  alloc::alloc::box_free
    296 (1.0%)      4 (0.4%)  core::result::Result<T,E>::map_err
    295 (1.0%)      1 (0.1%)  cargo_llvm_lines::wrap_args
    291 (0.9%)      1 (0.1%)  core::char::methods::<impl char>::encode_utf8
    286 (0.9%)      1 (0.1%)  cargo_llvm_lines::run_cargo_rustc
    284 (0.9%)      4 (0.4%)  core::option::Option<T>::ok_or_else

느린 증분 빌드 문제 해결하기

증분 빌드가 예상보다 느리다면 rustc 백엔드에서 특정 크레이트 때문에 병목이 발생하고 있을 수 있습니다.

이를 진단하려면 samply-Zhuman_readable_cgu_names=yes 플래그를 함께 사용해 컴파일러를 프로파일링하고, 컴파일에 가장 오래 걸리는 코드젠 유닛을 찾아낼 수 있습니다:

samply record cargo build

이를 통해 특정 크레이트가 빌드의 가장 큰 병목인지 확인할 수 있습니다. 병목을 찾았다면 해당 크레이트를 리팩터링하거나 분할해 컴파일 시간을 개선할 수 있습니다.

Rust 컴파일러의 백엔드 병렬성에 대한 자세한 내용은 Nicholas Nethercote의 블로그 글을 참고하세요.

또 다른 유용한 플래그는 -Zprint-mono-items=yes로, 컴파일 중에 모든 단형화(monomorphized) 아이템을 출력합니다. 이를 통해 각 CGU에서 무엇이 생성되는지 이해하는 데 도움이 됩니다.

RUSTFLAGS="-Zprint-mono-items=yes" cargo +nightly build                                               ⏎

이 팁을 알려주신 Caspar(Reddit의 asparck)님께 감사드립니다!

무거운 의존성 교체하기

가끔은 인기 있는 크레이트의 더 가벼운 대안을 찾아보는 것이 도움이 됩니다.

여기서도 cargo tree가 유용합니다. 의존성 중 어떤 것이 꽤 무거운지 파악하는 데 도움이 됩니다. 다른 크레이트를 많이 필요로 하거나 과도한 네트워크 I/O를 유발해 빌드를 느리게 하는 의존성을 찾아 더 가벼운 대안을 검색해 보세요.

또한 cargo-bloat--time 플래그를 사용하면 크레이트별 빌드 시간을 확인할 수 있어 매우 유용합니다!

몇 가지 예시는 다음과 같습니다:

크레이트대안
serdeminiserde, nanoserde
reqwestureq
claplexopt

크레이트를 교체해 컴파일 시간을 2분 22초에서 26초로 단축한 사례도 있습니다.

워크스페이스를 사용해 큰 크레이트를 더 작은 크레이트로 나누기

Cargo에는 워크스페이스라는 유용한 기능이 있어 하나의 큰 크레이트를 여러 개의 작은 크레이트로 나눌 수 있습니다. 이렇게 코드를 분할하면 변경된 크레이트만 다시 컴파일하면 되므로 반복 컴파일을 피하는 데 효과적입니다. servovector 같은 큰 프로젝트에서는 컴파일 시간을 줄이기 위해 워크스페이스를 적극 활용합니다.

크레이트 의존성의 사용하지 않는 기능 비활성화하기

cargo-features-manager는 의존성의 사용하지 않는 기능을 비활성화하는 데 도움을 주는 비교적 새로운 도구입니다.

cargo install cargo-features-manager
cargo features prune

가끔 의존성의 기능 플래그를 점검해 보세요. 많은 라이브러리 유지보수자들이 필요에 따라 켜고 끌 수 있도록 크레이트를 별도의 기능으로 나눠 놓았습니다. 모든 크레이트의 기본 기능을 다 사용할 필요는 없을지도 모릅니다.

예를 들어 tokio에는 수많은 기능이 있으며 필요하지 않다면 비활성화할 수 있습니다.

또 다른 예로 bindgen은 바이너리 사용을 위해 기본적으로 clap 지원을 활성화합니다. 이는 일반적인 사용 사례인 라이브러리 사용에는 필요하지 않습니다. 이 기능을 비활성화하자 rust-rocksdb의 컴파일 시간이 디버그 빌드와 릴리스 빌드에서 각각 약 13초, 약 9초 단축되었습니다. 이 점을 알려주신 독자 Lilian Anatolie Moraru님께 감사드립니다.

주의

기능을 끈다고 해서 항상 컴파일 시간이 개선되는 것은 아닌 것 같습니다. (tikv의 경험은 여기를 참고하세요.) 그럼에도 코드의 공격 표면을 줄여 보안을 개선하는 데는 여전히 좋은 방법일 수 있습니다. 또한 기능을 비활성화하면 의존성 트리를 가볍게 만드는 데 도움이 됩니다.

cargo add로 크레이트를 설치할 때 해당 크레이트의 기능 목록을 확인할 수 있습니다.

크레이트의 기능 플래그를 찾아보려면 docs.rs에 나와 있습니다. 예를 들어 tokio의 기능 플래그를 확인해 보세요.

사용하지 않는 기능을 제거한 뒤에는 Cargo.lock 파일의 diff를 확인해 불필요한 의존성이 얼마나 정리되었는지 살펴보세요.

비용이 큰 코드를 위한 기능 추가하기

[features]
# Basic feature for default functionality
default = []

# Optional feature for JSON support
json = ["serde_json"]

# Another optional feature for more expensive or complex code
complex_feature = ["some-expensive-crate"]

프로젝트의 모든 코드가 컴파일 비용이 동일하지는 않습니다. Cargo 기능을 사용하면 크레이트보다 더 세밀한 수준에서 코드를 작은 조각으로 나눌 수 있습니다. 이렇게 하면 필요한 기능만 컴파일할 수 있습니다.

이는 라이브러리에서 흔히 사용하는 방식입니다. 예를 들어 serde에는 직렬화와 역직렬화를 위한 코드 생성을 활성화하는 derive라는 기능이 있는데, 항상 필요한 것은 아니므로 기본적으로 비활성화되어 있습니다. 마찬가지로 Tokioreqwest에도 활성화하거나 비활성화할 수 있는 기능이 많습니다.

여러분의 코드에서도 동일하게 할 수 있습니다. 위 예시에서 Cargo.tomljson 기능은 JSON 지원을 활성화하고, complex_feature 기능은 또 다른 비용이 큰 코드 경로를 활성화합니다.

재빌드의 근본 원인 찾기

때로는 코드가 변경되지 않았는데도 많은 크레이트가 재빌드되곤 합니다. 이는 종종 빌드 프로세스 간(예: Makefile 빌드 vs rust-analyzer vs CI 빌드) 환경 변수 차이 때문입니다.

cargo의 핑거프린트 로깅을 사용해 재빌드가 발생한 정확한 이유를 파악하세요:

export CARGO_LOG="cargo::core::compiler::fingerprint=info"
export RUST_LOG=trace
cargo build -vv

재빌드를 유발한 원인을 보여주는 줄을 찾아보세요. 예를 들면 다음과 같습니다:

INFO prepare_target: cargo::core::compiler::fingerprint: dirty: EnvVarChanged { name: "VIRTUAL_ENV", old_value: None, new_value: Some("/path/to/.venv") }

Dirty pyo3-build-config v0.24.1: the env variable VIRTUAL_ENV changed

저 “Dirty” 줄이 보이시나요? 무엇 때문에 재빌드가 발생했는지 정확히 알려줍니다. 해당 줄을 grep으로 검색하면 모든 재빌드 원인을 찾을 수 있습니다.

흔한 원인으로는 다음과 같은 것들이 있습니다:

  • 환경 변수(CC, CXX, VIRTUAL_ENV, PATH 변경)
  • 서로 다른 빌드 도구 간의 기능 플래그 불일치
  • 서로 다른 cargo 프로필 사용
  • 생성된 파일의 타임스탬프 차이

원인을 파악했다면 모든 빌드 프로세스에서 일관성을 유지하세요. VS Code의 rust-analyzer에서는 .vscode/settings.json에서 일치하는 환경 변수를 설정할 수 있습니다:

{
  "rust-analyzer.check.extraEnv": {
    "CC": "clang",
    "CXX": "clang++", 
    "VIRTUAL_ENV": "/path/to/your/.venv"
  },
  "rust-analyzer.cargo.features": "all"
}

이 디버깅 접근 방식을 사용하면 서로 다른 도구가 일관되지 않은 환경을 사용할 때 발생하는 불필요한 재빌드를 크게 줄일 수 있습니다.

출처: 이 기법은 Ishan Bhanuka 님의 이 글에서 언급되었습니다.

sccache로 의존성 캐시하기

또 다른 유용한 프로젝트는 Mozilla의 sccache로, 컴파일된 크레이트를 캐시해 반복 컴파일을 피하게 해줍니다.

제 노트북에서 한동안 실행해 봤는데 솔직히 체감되는 이점은 크지 않았습니다. 같은 버전의 의존성을 공유하는 여러 독립 프로젝트에서 작업할 때 가장 효과적입니다. 공유 빌드 서버가 대표적인 사용 사례입니다.

Cranelift: 대체 Rust 컴파일러

Rust 프로젝트가 모든 CI 빌드마다 rustc와 병렬로 실행하는 대체 컴파일러가 있다는 사실을 알고 계셨나요?

rustc_codegen_cranelift(일명 CG_CLIF)는 Cranelift 컴파일러 프레임워크를 기반으로 한 Rust 컴파일러의 실험적 백엔드입니다.

다음은 몇몇 인기 크레이트에 대한 rustc와 Cranelift의 비교입니다(파란색이 더 좋음을 의미합니다):

rustc와 cranelift 간의 LLVM 컴파일 시간 비교, cranelift가 더 빠름

이 컴파일러는 완전히 동작하는 실행 파일을 생성합니다. 최적화 수준은 다소 낮지만 로컬 개발에는 아주 유용합니다.

더 자세한 내용은 Jason Williams의 페이지에 정리되어 있으며, 프로젝트 코드는 Github에서 확인할 수 있습니다.

더 빠른 링커로 교체하기

링커란 무엇인가요?

링커는 여러 오브젝트 파일을 하나의 실행 파일로 합치는 도구입니다.
컴파일 과정의 마지막 단계입니다.

다음 명령을 실행해 링커가 병목인지 확인할 수 있습니다:

cargo clean
cargo +nightly rustc --bin <your_binary_name> -- -Z time-passes

각 단계의 소요 시간이 출력되며, 링크 시간도 포함됩니다:

...
time:   0.000   llvm_dump_timing_file
time:   0.001   serialize_work_products
time:   0.002   incr_comp_finalize_session_directory
time:   0.004   link_binary_check_files_are_writeable
time:   0.614   run_linker
time:   0.000   link_binary_remove_temps
time:   0.620   link_binary
time:   0.622   link_crate
time:   0.757   link
time:   3.836   total
    Finished dev [unoptimized + debuginfo] target(s) in 42.75s

link 단계가 느리다면 더 빠른 대안으로 교체를 고려해 보세요:

링커플랫폼프로덕션 사용 가능설명
lldLinux/macOS시스템 링커를 대체하는 드롭인 교체
moldLinuxLinux에 최적화됨
zldmacOS아니요(지원 중단됨)Apple의 ld 링커를 대체하는 드롭인 교체

macOS 전용: 더 빠른 증분 디버그 빌드

Rust 1.51에서는 macOS에서 더 빠른 증분 디버그 빌드를 위한 플래그가 추가되었습니다. 사용 사례에 따라 디버그 빌드를 수 초 더 빠르게 만들 수 있습니다. 일부 엔지니어는 이 플래그만으로 macOS에서 컴파일 시간이 70% 단축되었다고 보고합니다.

다음 내용을 Cargo.toml에 추가하세요:

[profile.dev]
split-debuginfo = "unpacked"

이 플래그는 곧 macOS의 표준이 될 수도 있습니다. 이미 nightly에서는 기본값입니다.

macOS 전용: Gatekeeper 검사에서 Rust 컴파일 제외하기

Gatekeeper는 macOS에서 바이너리에 대해 보안 검사를 수행하는 시스템입니다. 이로 인해 Rust 빌드가 반복마다 몇 초씩 느려질 수 있습니다. 해결책은 터미널을 개발자 도구에 추가해 해당 터미널에서 실행되는 프로세스가 Gatekeeper 검사에서 제외되도록 하는 것입니다.

  1. 터미널에서 sudo spctl developer-mode enable-terminal을 실행하세요.
  2. 시스템 설정으로 이동한 뒤 보안 및 개인정보 보호를 여세요.
  3. 개인정보 보호 탭에서 Developer Tools로 이동하세요.
  4. 터미널이 목록에 있고 활성화되어 있는지 확인하세요. iTerm이나 Ghostty 같은 서드파티 터미널을 사용 중이라면 함께 추가하세요.
  5. 터미널을 재시작하세요.

macOS 개발자 도구에서 Gatekeeper 검사에서 터미널 제외하기

nextestZed 개발자분들께 감사드립니다.

Windows 전용: Rust를 위한 Dev Drive 설정하기

Windows 11에는 개발에 최적화된 파일 시스템인 Dev Drive가 포함되어 있습니다. Microsoft에 따르면 Dev Drive를 사용하면 약 20~30% 정도의 속도 향상을 기대할 수 있습니다:

Dev Drive 성능 차트

Rust 컴파일 속도를 높이려면 다음 항목들을 Dev Drive로 옮기세요:

  • Rust 툴체인 폴더(CARGO_HOME)
  • 프로젝트 코드
  • Cargo의 target 디렉터리

한 걸음 더 나아가 위의 폴더들을 백신 예외 목록에도 추가하면 추가로 속도가 향상될 수 있습니다. 예외 설정은 Windows 보안의 바이러스 및 위협 방지 설정에서 찾을 수 있습니다.

Windows 백신 예외 설정

nextest 팀에 감사드립니다.

코드젠 옵션과 컴파일러 플래그 조정하기

Rust에는 코드 생성을 위한 방대한 설정이 있습니다. 목록을 살펴보고 프로젝트에 맞게 매개변수를 조정하면 도움이 될 수 있습니다.

전체 코드젠 옵션 목록에는 숨은 보석이 정말 많습니다. 참고용으로 더 빠른 컴파일을 위한 bevy의 설정을 소개합니다.

절차적 매크로 크레이트 사용 자제하기

프로젝트에서 절차적 매크로를 많이 사용한다면(예: serde를 사용하는 경우) Cargo.toml에서 opt-level을 조정해 보는 것이 도움이 될 수 있습니다.

[profile.dev.build-override]
opt-level = 3

독자 jfmontanaro 님이 Github에서 언급하신 내용입니다:

빌드 시간을 단축하는 이유는 이 설정이 빌드 스크립트와 절차적 매크로에만 적용되기 때문인 것 같습니다. 빌드 스크립트와 절차적 매크로는 일반적인 빌드 과정에서 컴파일될 뿐만 아니라 실행도 되고(절차적 매크로의 경우 반복적으로 실행될 수 있습니다), 프로젝트에서 절차적 매크로를 많이 사용한다면 매크로 자체를 최적화하는 것만으로도 이론적으로 많은 시간을 절약할 수 있습니다.

또 다른 접근법은 매크로가 컴파일 시간에 미치는 영향을 watt로 우회하는 것입니다. 이 도구는 매크로 컴파일을 WebAssembly로 오프로드합니다.

문서에서 발췌:

매크로를 미리 Wasm으로 컴파일함으로써, 매크로를 사용하는 모든 다운스트림 사용자가 매크로 로직이나 그 의존성을 직접 컴파일해야 하는 부담을 덜 수 있습니다.

대신 그들이 컴파일하는 것은 모든 매크로가 공유하는 작은 독립형 Wasm 런타임(약 3초)과 각 매크로 크레이트마다 Wasm 바이트코드를 Watt 런타임으로 전달하는 아주 작은 절차적 매크로 심(약 0.3초)뿐입니다. 이는 복잡한 절차적 매크로와 그 의존성을 컴파일하는 데 20초 이상 걸릴 수 있는 것과 비교하면 훨씬 적은 시간입니다.

이 크레이트는 아직 실험 단계라는 점에 유의하세요.

비용이 큰 절차적 매크로 찾기

RUSTFLAGS="-Zmacro-stats" cargo +nightly build

일부 매크로는 컴파일 시간 비용이 큽니다. 하지만 정확히 얼마나 클까요? 비용을 정량화하면 해당 매크로를 최적화하거나 제거할 가치가 있는지 판단하는 데 도움이 됩니다. 그들이 생성하는 코드가 얼마나 많은지 파악하는 것이 한 가지 방법입니다.

한 가지 방법은 cargo expand로 생성된 코드를 보는 것이지만, 이는 큰 코드베이스에서는 확장성이 떨어지고 출력 결과를 정량화하기 어렵습니다.

대안은 -Zmacro-stats 플래그를 사용해 많은 코드를 생성하는 절차적 매크로를 식별하는 것입니다. 이 도구는 이미 BevyArbitrary 같은 프로젝트에서 성공적인 최적화로 이어졌습니다.

자세한 내용은 Nicholas Nethercote의 블로그 글을 참고하세요.

절차적 매크로를 위한 조건부 컴파일

절차적 매크로는 Rust 코드를 파싱해야 하므로 비교적 복잡한 작업입니다. 절차적 매크로에 의존하는 크레이트는 해당 매크로가 컴파일될 때까지 기다려야 합니다. 예를 들어 serde는 컴파일 시간의 병목이 되어 CPU 활용률을 제한할 수 있습니다.

Rust 컴파일 시간을 개선하려면 특히 공유 크레이트 구조를 가진 프로젝트에서 Serde를 이용한 직렬화를 전략적으로 다루는 것을 고려해 보세요. 공유 크레이트에 Serde를 직접 넣는 대신 Cargo 기능을 통해 선택적 의존성으로 만들 수 있습니다.

공유 크레이트에서 Serde 사용과 derive를 기능 게이트 뒤에 두기 위해 cfgcfg_attr 속성을 사용하세요. 이렇게 하면 실제로 직렬화/역직렬화를 수행하는 리프 크레이트에서만 활성화되는 선택적 의존성이 됩니다.

이 접근 방식은 Serde가 공유 크레이트의 필수 직접 의존성인 경우처럼 전체 프로젝트가 Serde 의존성 컴파일을 기다리는 상황을 방지합니다.

간단한 예시로 설명해 보겠습니다. 공유 라이브러리 크레이트와 이에 의존하는 몇 개의 다른 크레이트가 있는 Rust 프로젝트가 있다고 가정해 보세요. 프로젝트의 일부를 빌드할 때 Serde를 불필요하게 컴파일하고 싶지 않습니다.

Cargo의 선택적 기능을 사용하는 프로젝트 구조는 다음과 같습니다:

공유 크레이트의 Cargo.toml에서 serde를 선택적 의존성으로 선언하세요:

[package]
name = "shared"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", optional = true }

이 크레이트에서는 기능이 활성화된 경우에만 serde를 포함하도록 조건부 컴파일을 사용하세요:

#[cfg(feature = "serde")]
use serde::{Serialize, Deserialize};

#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MySharedStruct {
    // Your struct fields
}

다른 크레이트에서는 필요한 경우 공유 크레이트에 대해 serde 기능을 활성화하세요:

[package]
name = "other"
version = "0.1.0"
edition = "2021"

[dependencies]
shared = { path = "../shared", features = ["serde"] }

이제 MySharedStruct를 Serde 기능이 활성화된 상태로 사용할 수 있으며, 필요하지 않은 크레이트의 컴파일을 무겁게 만들지 않습니다.

제네릭: 내부 비제네릭 함수 사용하기

제네릭 함수가 있다면 사용하는 모든 타입에 대해 컴파일됩니다. 서로 다른 타입이 많다면 문제가 될 수 있습니다.

흔한 해결책은 내부에 비제네릭 함수를 사용하는 것입니다. 이렇게 하면 컴파일러가 내부 함수를 한 번만 컴파일합니다.

이는 표준 라이브러리에서 자주 사용되는 기법입니다. 예를 들어 다음은 read_to_string의 구현입니다:

pub fn read_to_string<P: AsRef<Path>>(path: P) -> io::Result<String> {
    fn inner(path: &Path) -> io::Result<String> {
        let mut file = File::open(path)?;
        let size = file.metadata().map(|m| m.len() as usize).ok();
        let mut string = String::with_capacity(size.unwrap_or(0));
        io::default_read_to_string(&mut file, &mut string, size)?;
        Ok(string)
    }
    inner(path.as_ref())
}

여러분의 코드에서도 동일하게 할 수 있습니다. 바깥 함수는 제네릭이지만 실제 작업을 수행하는 안쪽의 비제네릭 함수를 호출하는 방식입니다.

cargo-hakari로 워크스페이스 빌드 시간 개선하기

다음과 같은 의존성을 가진 큰 Rust 워크스페이스가 있으신가요?

  1. 여러 크레이트에서 사용됨
  2. 해당 크레이트마다 서로 다른 기능 집합을 가짐

이 경우 빌드 중인 크레이트에 따라 cargo가 각 의존성을 서로 다른 기능으로 여러 번 빌드하므로 빌드 시간이 길어질 수 있습니다. 이때 cargo-hakari가 도움이 됩니다. 이 도구는 “workspace-hack” 크레이트를 자동으로 관리하도록 설계되었습니다.

일부 시나리오에서는 이를 통해 연속 빌드 시간을 최대 50% 이상 단축할 수 있습니다. 자세한 사용법과 벤치마크는 공식 cargo-hakari 문서에서 확인하세요.

dylib로 증분 Rust 컴파일 속도 높이기

# Install the tool
cargo install cargo-add-dynamic

# Add a dynamic library to your project
cargo add-dynamic polars --features csv-file,lazy,list,describe,rows,fmt,strings,temporal

이 명령은 polars를 감싸는 래퍼 크레이트를 만들어 동적 라이브러리(Linux에서는 .so, macOS에서는 .dylib, Windows에서는 .dll)로 컴파일합니다.

본질적으로 의존성을 다음과 같이 패치합니다:

[lib]
crate-type = ["dylib"]

이 트릭을 사용하면 자신의 코드만 변경했을 때 의존성의 링크 시간을 절약할 수 있습니다. 의존성 자체는 기능이나 버전을 변경할 때만 재컴파일됩니다. 물론 이는 polars뿐만 아니라 모든 크레이트에 적용됩니다.

자세한 내용은 Robert Krahn의 블로그 글도구 홈페이지에서 확인하세요.

새로운 병렬 컴파일러 프론트엔드로 전환하기

nightly에서 새로운 병렬 컴파일러 프론트엔드를 활성화할 수 있습니다. 사용해 보려면 nightly 컴파일러를 -Z threads=8 옵션과 함께 실행하세요:

RUSTFLAGS="-Z threads=8" cargo +nightly build

잘 동작한다고 판단되면 ~/.cargo/config.toml 파일에 -Z threads=8을 추가해 기본값으로 만들 수 있습니다:

[build]
rustflags = ["-Z", "threads=8"]

또는 셸 설정 파일(예: ~/.bashrc~/.zshrc)에 cargo에 대한 별칭을 설정할 수도 있습니다:

alias cargo="RUSTFLAGS='-Z threads=8' cargo +nightly"

프론트엔드를 -Z threads=8을 사용해 멀티스레드 환경에서 실행하면, 실제 코드에 대한 벤치마크에서 컴파일 시간이 최대 50%까지 단축될 수 있습니다. 다만 개선 효과는 컴파일하는 코드에 따라 달라집니다. 한 번 시도해 볼 만합니다.

다음은 병렬 컴파일러 프론트엔드가 동작하는 모습을 시각화한 것입니다:

병렬 컴파일러 실행 결과

자세한 내용은 Rust 블로그의 공식 발표에서 확인하세요.

더 빠른 빌드를 위해 스크래치 디스크 사용하기

파일 시스템이 병목일 수도 있습니다. 빌드 디렉터리를 위해 인메모리 파일 시스템을 사용하는 것을 고려해 보세요.

tmpfs 같은 전통적인 임시 파일 시스템은 RAM과 스왑 공간의 합으로 제한되며 큰 중간 아티팩트를 생성하는 빌드에서는 문제가 될 수 있습니다.

대신 Linux에서는 다음 옵션으로 ext4 볼륨을 마운트하세요:

-o noauto_da_alloc,data=writeback,lazytime,journal_async_commit,commit=999,nobarrier

이렇게 하면 RAM이 충분할 경우 파일을 페이지 캐시에 저장하고 나중에 쓰기 지연이 발생합니다. 충돌이나 정전 후에는 데이터가 손실되거나 손상될 수 있으므로 임시 파일 시스템처럼 취급하세요.

이 팁은 Reddit의 /u/The_8472 님께 감사드립니다.

더 좋은 하드웨어에 투자하기

여기까지 왔다면 컴파일 시간을 더 개선하는 가장 쉬운 방법은 최상급 하드웨어에 투자하는 것일지도 모릅니다.

노트북으로는 Apple의 새로운 MacBook M 시리즈가 Rust 컴파일에서 정말 뛰어난 성능을 보여줍니다.

트위터의 Rik Arends

M1 Max를 탑재한 MacBook Pro의 벤치마크는 이미 빠른 M1과 비교해도 정말 터무니없이 빠릅니다:

프로젝트M1 MaxM1 Air
Deno6m11s11m15s
MeiliSearch1m28s3m36s
bat43s1m23s
hyperfine23s42s
ripgrep16s37s

확실히 2배의 성능 향상입니다.

하지만 Linux를 고수하고 싶다면 AMD Ryzen Threadripper와 32GB RAM 같은 멀티코어 CPU로도 큰 성공을 거둔 사례가 많습니다.

휴대용 기기에서는 컴파일 시 배터리가 빨리 닳고 속도도 느립니다. 이를 피하기 위해 저는 집에 있는 6코어 AMD FX 6300, 12GB RAM 사양의 머신을 빌드 머신으로 사용하고 Visual Studio Code Remote Development와 함께 활용하고 있습니다.

클라우드에서 컴파일하기

전용 머신이 따로 없다면 컴파일 과정을 클라우드로 오프로드할 수도 있습니다. GitHub Codespaces는 GitHub 안에서 바로 사용할 수 있는 완전한 클라우드 개발 환경을 제공합니다. Code 버튼을 클릭하고 Open with Codespaces를 선택하면 어떤 저장소에서도 codespace를 시작할 수 있습니다. 머신 크기는 2코어부터 최대 32코어까지 다양하며, 모든 GitHub 계정에는 무료 월간 할당량(현재 2코어 머신 기준 60시간)이 포함됩니다.

Codespaces는 .devcontainer/devcontainer.json 파일을 통한 dev container 설정을 지원하므로, Rust 툴체인과 기타 의존성을 미리 설치해 환경이 시작되는 즉시 사용할 수 있습니다. 이는 풀 리퀘스트를 검토할 때 특히 유용합니다. PR을 codespace에서 바로 열면 모든 것이 이미 설정된 상태로 준비됩니다.

빌드 환경을 더 세밀하게 제어하고 싶다면 Northflank도 또 다른 선택지입니다. 전용 클라우드 인프라에서 임의의 빌드 작업을 실행할 수 있으며, 플랜은 공유 0.1 vCPU 컨테이너부터 32개의 전용 vCPU까지 다양합니다. 무료 샌드박스 티어가 있으며, 유료 플랜은 초 단위로 청구되므로 실제 빌드 시간에 대해서만 비용을 지불합니다.

모든 크레이트를 로컬에 캐시하기

인터넷 연결이 느리다면 초기 빌드 과정의 상당 부분은 crates.io에서 수많은 크레이트를 가져오는 데 소요됩니다. 이를 완화하기 위해 모든 크레이트를 미리 다운로드해 로컬에 캐시할 수 있습니다. criner가 바로 그 역할을 합니다:

git clone https://github.com/the-lean-crate/criner
cd criner
cargo run --release -- mine

아카이브 크기는 의외로 부담스럽지 않으며, 현재 기준으로 약 50GB의 디스크 공간이 필요합니다.

테스트 실행

cargo test 대신 cargo nextest 사용하기

cargo install cargo-nextest
cargo nextest run

cargo에 자체 테스트 러너가 포함되어 있는 것은 좋지만, 특히 여러 테스트 바이너리를 빌드해야 하는 경우 cargo nextest는 병렬 실행 모델 덕분에 cargo test보다 최대 60% 더 빠를 수 있습니다. 간단한 벤치마크는 다음과 같습니다:

프로젝트리비전테스트 수cargo test (초)nextest (초)개선 효과
cruciblecb228c2b4835.141.523.38×
guppy2cc51b412716.422.802.29×
mdBook0079184c1993.851.662.31×
meilisearchbfb1f92772157.0428.991.96×
omicrone7949cd1619444.08202.502.19×
penumbra4ecd94cc144125.3890.961.37×
reqwest3459b8941135.572.262.48×
ring450ada2817913.129.401.39×
tokio1f50c571113824.2711.602.09×

모든 통합 테스트를 단일 바이너리로 합치기

통합 테스트(tests 폴더에 있는 테스트)가 있으신가요? Rust 컴파일러가 각각에 대해 바이너리를 하나씩 생성한다는 사실을 알고 계셨나요? 그리고 각 바이너리는 개별적으로 링크되어야 합니다. 링크는 매우 느리기 때문에 🐢 빌드 시간의 대부분을 차지할 수 있습니다. 그 이유는 많은 시스템 링커(예: ld)가 싱글 스레드이기 때문입니다.

링커의 작업을 조금 더 쉽게 하려면 모든 테스트를 하나의 크레이트에 넣을 수 있습니다. (기본적으로 테스트 폴더에 main.rs를 만들고 테스트 파일들을 mod로 추가하면 됩니다.)

그러면 링커는 단일 바이너리만 빌드합니다. 좋아 보이지만 주의하세요. 내부 타입과 함수를 외부에 노출해야 하는 트레이드오프가 있습니다(즉, pub으로 만들어야 합니다).

통합 테스트가 많다면 최대 50%의 속도 향상을 기대할 수 있습니다.

이 팁은 Luca Palmieri, Lucio Franco, Azriel Hoh 님께서 제공해 주셨습니다. 감사합니다!

느린 테스트는 환경 변수 뒤에 두기

#[test]
fn completion_works_with_real_standard_library() {
  if std::env::var("RUN_SLOW_TESTS").is_err() {
    return;
  }
  ...
}

느린 테스트가 있다면 환경 변수 뒤에 두어 기본적으로 비활성화할 수 있습니다. 이렇게 하면 로컬에서는 건너뛰고 CI에서만 실행할 수 있습니다.

(matklad(Alex Kladov)의 글에서 배운 유용한 팁입니다.)

CI 빌드

CI 빌드를 위한 팁

이 글의 많은 기법은 CI 빌드에도 적용됩니다. CI 전용 최적화와 모범 사례는 더 빠른 CI 빌드를 위한 팁이라는 별도의 가이드에서 다루고 있으니, 캐싱 전략, 워크플로 최적화, GitHub Actions 관련 개선 사항을 확인해 보세요.

의존성에 캐시 사용하기

특히 GitHub Actions에서는 Swatinem/rust-cache를 사용할 수도 있습니다.

워크플로에 단 하나의 단계만 추가하면 될 정도로 간단합니다:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --all

이렇게 하면 빌드 간에 의존성이 캐시되어 상당한 속도 향상을 기대할 수 있습니다.

컴파일 단계와 테스트 단계 분리하기

- name: Compile
  run: cargo test --no-run --locked

- name: Test
  run: cargo test -- --nocapture --quiet

이렇게 하면 컴파일에 얼마나 많은 시간이 소요되는지, 테스트 실행에 얼마나 걸리는지 파악하기가 더 쉬워집니다.

CI에서 증분 컴파일 비활성화하기

env:
  CARGO_INCREMENTAL: 0

CI 빌드는 처음부터 빌드하는 방식에 더 가깝기 때문에, 증분 컴파일은 불필요한 의존성 추적과 I/O 오버헤드를 추가해 캐시 효율을 떨어뜨립니다. 비활성화하는 방법은 여기에서 확인하세요.

디버그 정보 끄기

[profile.dev]
debug = 0
strip = "debuginfo"

빌드 프로세스 속도를 높이기 위해 디버그 정보 링크를 피하세요. 특히 실제 디버거를 거의 사용하지 않는다면 더욱 그렇습니다. 디버그 정보 링크를 피하는 방법은 두 가지입니다. debug=0으로 설정해 컴파일 자체를 건너뛰거나, strip="debuginfo"로 설정해 링크를 건너뛰는 것입니다. 안타깝게도 이러한 옵션을 변경하면 Cargo에서 전체 재빌드가 발생할 수 있습니다.

  • Linux에서는 두 옵션을 모두 설정하면 빌드 시간이 개선됩니다.
  • Mac에서는 rustc가 외부 strip 명령을 사용하므로 debug=0을 사용하세요.
  • Windows에서는 두 설정을 모두 테스트해 어느 쪽이 더 빠른지 확인해 보세요.

디버그 정보가 없으면 백트레이스에는 줄 번호가 아닌 함수 이름만 표시됩니다. 필요하다면 절충안으로 split-debuginfo="unpacked"를 사용하세요.

부수적인 장점으로 ./target의 크기도 줄어들어 캐시 효율도 높아집니다.

설정을 적용하는 방법은 샘플 설정을 참고하세요.

환경 변수를 통해 경고를 오류로 처리하기

코드에 #![deny(warnings)]를 반복적으로 선언하지 마세요. 또한 로컬 개발 중에는 경고가 발생해도 괜찮습니다.

대신 -D warningsRUSTFLAGS에 추가해 CI에서 모든 크레이트의 경고를 전역적으로 오류로 처리하세요.

env:
  RUSTFLAGS: -D warnings

더 빠른 GitHub Actions 러너로 교체하기

- runs-on: ubuntu-latest
+ runs-on: ubicloud

Ubicloud, BuildJet, RunsOn 같은 서비스는 GitHub Actions 빌드를 위한 더 빠른 워커를 제공합니다. 특히 Rust 파이프라인에서는 코어 수가 컴파일 시간에 상당한 영향을 미칠 수 있으므로 시도해 볼 만합니다.

다음은 Facebook Folly 프로젝트가 Ubicloud를 사용한 예시입니다. C++ 프로젝트이긴 하지만 더 빠른 러너의 잠재력을 보여줍니다:

facebook/folly 빌드 시간

서비스에 가입한 뒤에는 GitHub Actions 워크플로 파일에서 러너만 변경하면 됩니다.

더 빠른 Docker 빌드

cargo-chef로 Docker 빌드 속도 높이기

여러분의 Rust 코드로 Docker 이미지를 빌드하시나요? cargo가 아직 프로젝트의 의존성만 빌드하는 기능을 지원하지 않기 때문에, 신경 쓰지 않으면 매 빌드마다 Docker 캐시가 무효화되어 속도가 매우 느려질 수 있습니다. cargo-chef가 해결해 줍니다! ⚡

cargo-chef를 사용하면 Docker 레이어 캐시를 완전히 활용할 수 있어 Rust 프로젝트의 Docker 빌드 속도를 대폭 높일 수 있습니다. 저희 상용 코드베이스(약 1만 4천 줄의 코드, 약 500개의 의존성)에서 5배의 속도 향상을 측정했습니다. Docker 빌드 시간을 약 10분에서 약 2분으로 단축했습니다.

관심 있으시다면 예시 Dockerfile은 다음과 같습니다:

# Step 1: Compute a recipe file
FROM rust as planner
WORKDIR app
RUN cargo install cargo-chef
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# Step 2: Cache project dependencies
FROM rust as cacher
WORKDIR app
RUN cargo install cargo-chef
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json

# Step 3: Build the binary
FROM rust as builder
WORKDIR app
COPY . .
# Copy over the cached dependencies from above
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo
RUN cargo build --release --bin app

# Step 4:
# Create a tiny output image.
# It only contains our final binary.
FROM rust as runtime
WORKDIR app
COPY --from=builder /app/target/release/app /usr/local/bin
ENTRYPOINT ["/usr/local/bin/app"]

cargo-chef는 GitHub Actions를 이용한 지속적 통합이나 Google Cloud로의 배포 속도를 높이는 데도 도움이 될 수 있습니다.

더 나은 빌드 캐싱을 위해 Earthly 고려하기

Earthly는 Makefile, Dockerfile 및 기타 빌드 도구를 대체하기 위해 설계된 비교적 새로운 빌드 도구입니다. CI에서 빠르고 증분적인 Rust 빌드를 제공합니다.

Earthly는 Cargo의 캐싱과 Rust의 증분 컴파일을 효과적으로 구현해 CI에서 Rust 빌드 속도를 높입니다. 이 접근 방식은 CI에서 불필요한 재빌드를 크게 줄여 로컬 Rust 빌드의 효율성을 그대로 재현합니다.

출처: Earthly for Rust

이들은 Satellites라는 시스템을 사용하는데, 이는 캐시 데이터를 로컬에 유지하는 영구 원격 빌드 러너입니다. 이를 통해 캐시 업로드와 다운로드를 없애 CI 빌드 시간을 대폭 단축할 수 있습니다. 캐시 데이터를 컴퓨팅으로 가져오는 대신 캐시 데이터와 컴퓨팅을 같은 곳에 배치해 캐시 전송 자체를 없앱니다. I/O가 줄어들면 빌드도 빨라집니다.

Earthly는 캐시 설정을 완전히 추상화하는 lib/rust 라이브러리도 제공합니다. CI에서 Rust가 올바르게 캐시되고 증분 빌드되도록 보장합니다. Earthfile에서는 다음과 같이 사용할 수 있습니다:

IMPORT github.com/earthly/lib/rust

궁금하시다면 Rust를 위한 Earthly 가이드에서 캐싱 및 컴파일 단계가 최적화된 간단한 Rust 예시를 자세히 설명합니다.

IDE별 최적화

개발 환경에서 빌드 시간이 느리다고 느껴진다면 추가로 시도해 볼 수 있는 몇 가지 팁이 있습니다.

Visual Studio Code에서 디버그 세션이 느릴 때

Visual Studio Code를 사용 중이고 디버그 세션이 느리다면 중단점이 너무 많이 설정되어 있지 않은지 확인하세요. 중단점 하나하나가 디버그 세션을 느리게 만들 수 있습니다.

관련 없는 프로젝트 닫기

Visual Studio Code에서 여러 프로젝트를 열었다면 각 인스턴스마다 자체 rust-analyzer 복사본이 실행됩니다. 이로 인해 머신이 느려질 수 있습니다. 필요하지 않은 관련 없는 프로젝트는 닫아 두세요.

Rust Analyzer 캐시 무효화 문제 해결하기

VS Code에서 rust-analyzer를 사용 중인데 변경 사항을 저장할 때 빌드 시간이 느려진다면 캐시가 무효화되고 있기 때문일 수 있습니다. 이로 인해 serde 같은 의존성이 자주 재빌드됩니다.

VS Code 설정(가능하면 사용자 설정)에 다음 내용을 추가해 rust-analyzer용으로 별도의 타깃 디렉터리를 설정하면 해결할 수 있습니다:

{
    "rust-analyzer.cargo.targetDir": true
}

이렇게 하면 rust-analyzer가 기본 target/ 디렉터리 대신 target/rust-analyzer 안에서 빌드되어 일반적인 cargo run 빌드와의 간섭을 방지합니다.

일부 사용자들은 이 방법으로 상당한 속도 향상을 보고했습니다:

before: 34.98s user 2.02s system 122% cpu 30.176 total
after:   2.62s user 0.60s system 84% cpu 3.803 total

이는 rust analyzer가 디버그 빌드를 차단하는 문제를 해결하는 데도 도움이 될 수 있습니다.

출처: 이 팁은 Reddit의 asparck 님이 공유해 주셨습니다.

요약

이 글에서는 많은 내용을 다루었습니다. 더 좋은 하드웨어를 사용하고, 코드를 최적화하며, 더 나은 도구를 활용해 Rust 빌드 속도를 높이는 방법을 살펴보았습니다.

CI 전용 최적화에 대해서는 더 빠른 CI 빌드를 위한 팁도 잊지 말고 확인해 보세요. 여기에서 다룬 기법들을 보완해 줍니다.

이 팁들 중 일부를 활용해 Rust 빌드 속도를 높일 수 있었기를 바랍니다. Rust 빌드 속도를 높이는 다른 방법을 찾으셨거나 질문이나 피드백이 있으시다면ぜひ 들려주세요.

추가 자료

원문은 Matthias Endler님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.