Rust nightly, 병렬 프론트엔드로 컴파일 속도 개선
(blog.rust-lang.org)- Rust 컴파일러의 병렬화는 그동안 Cargo와 LLVM 백엔드에 주로 의존했지만, 이제 프론트엔드 병렬 실행까지 더해져 빌드 후반 병목을 줄일 수 있음
- nightly에서는
-Z threads=8로 실험 기능을 켤 수 있으며, 기본값은 여전히 단일 스레드 모드라서 명시적 설정 없이는 속도 개선이 없음 - 새 프론트엔드는 Rayon 기반으로 세밀한 컴파일 작업을 나누며, 예제 측정에서 프론트엔드 시간이 10.2초에서 5.9초로 줄어듦
- 실제 코드 측정에서는 컴파일 시간이 최대 50% 감소했지만, 코드 특성·빌드 설정에 따라 편차가 크고 메모리 사용량은 최대 35% 늘 수 있음
- 기능은 아직 실험 단계이며,
-Z threads안정화와 stable에서 다중 스레드 기본 실행을 2024년 목표로 추진 중임
Rust 컴파일 병렬화의 새 축
- Rust 컴파일러 프론트엔드는 이제 병렬 실행으로 컴파일 시간을 줄일 수 있음
- nightly 컴파일러에서
-Z threads=8옵션을 주면 다중 스레드 프론트엔드를 시험할 수 있음 - 이 기능은 아직 실험적이며, stable 컴파일러 포함 목표 시점은 2024년임
기존 최적화와 남은 병목
- Compiler Performance Working Group은 여러 해 동안 Rust 컴파일러 성능을 개선해 왔음
- 컴파일러가 이미 많이 최적화된 상태라, 남은 큰 개선 여지는 병렬성 확대에 가까움
Cargo와 백엔드 병렬화의 한계
- Cargo는 Rust 프로그램을 빌드할 때 여러
rustc프로세스를 실행해 crate를 병렬로 컴파일함 -j1플래그로 이 병렬화를 끄면 큰 Rust 프로그램의 컴파일 시간이 크게 늘어남- Cargo의
--timings플래그는 crate 컴파일 타임라인 차트를 생성함 - ripgrep을 28개 가상 코어 머신에서 빌드한 예시에는 60개의 프로세스 라인이 나타남
- 대부분은
rustc이고 일부는 build script임 - 초기 20개 프로세스는 crate 간 의존성이 없어 동시에 시작 가능함
- 빌드 후반으로 갈수록 crate 의존성이 늘어 병렬성이 줄어듦
- 대부분은
- pipelined compilation으로 의존 crate 컴파일을 어느 정도 겹칠 수 있지만, 큰 Rust 프로그램에서는 빌드 후반의 병렬 실행이 훨씬 적어짐
프론트엔드와 백엔드가 맡는 일
- Rust 컴파일러는 크게 프론트엔드와 백엔드로 나뉨
- 프론트엔드는 파싱, 타입 검사, borrow checking 등을 수행함
- 기존 프론트엔드는 병렬 실행을 사용할 수 없었음
- 백엔드는 코드 생성을 담당하며, “codegen units” 단위로 코드를 생성한 뒤 LLVM이 이를 병렬 처리함
- 릴리스 빌드의 기본 codegen unit 수는 16개라서, 예시 프로파일에는 16개의 LLVM 스레드가 나타남
기존 백엔드 프로파일에서 드러난 병목
- Samply로 Cargo의 마지막 crate를 릴리스 빌드할 때 측정한 예시에서 프론트엔드는 10.2초 걸림
- 백엔드는 6.2초 걸렸고, LLVM 스레드는 그중 5.9초 동안 실행됨
- LLVM 기반 병렬 코드 생성은 효과적이지만, 28코어 머신에서도 16개 LLVM 스레드가 모두 동시에 실행되지는 않았음
- main thread가 MIR를 LLVM IR로 변환하는 작업을 직렬로 수행해 codegen thread 시작 부분에 계단형 형태가 나타남
- 완전히 직렬로 동작하던 프론트엔드가 가장 큰 개선 지점으로 남아 있었음
새 병렬 프론트엔드의 구현 방식
- 새 프론트엔드는 Rayon을 사용해 세밀한 병렬 컴파일 작업을 수행함
- 여러 자료구조는 mutex와 read-write lock으로 동기화하고, 필요한 곳에는 atomic 타입을 사용함
- 많은 프론트엔드 작업이 병렬화됐지만, 변경은 비교적 적은 핵심 지점에 집중됨
- 프론트엔드 코드 대부분은 바꿀 필요가 없었음
8개 스레드 설정의 측정 결과
- 병렬 프론트엔드를 활성화하고 8개 스레드를 사용한 같은 예제에서 프론트엔드 실행 시간은 10.2초에서 5.9초로 줄어듦
- 백엔드 실행 시간은 6.2초에서 5.3초로, LLVM 스레드 실행 시간은 5.9초에서 4.9초로 감소함
- 프론트엔드에는
rustc로 표시된 추가 스레드 7개가 동작함 - 스레드 활용은 고르지 않고 8개 스레드 모두 비활성 구간이 있어 추가 개선 여지가 남아 있음
- 8개의 LLVM 스레드가 동시에 시작되는 이유는 8개의
rustc스레드가 8개 codegen unit의 LLVM IR을 병렬 생성하기 때문임 - 프론트엔드 스레드 수를 16개로 바꾸면 계단형 형태는 완전히 사라지지만, 해당 사례의 최종 실행 시간은 거의 변하지 않음
프로세스 간 병렬화와 프로세스 내 병렬화의 조합
- Rust 컴파일은 오래전부터 Cargo의 프로세스 간 병렬화와 백엔드의 프로세스 내 병렬화에서 이점을 얻어 왔음
- 이제 프론트엔드도 프로세스 내 병렬화 이점을 활용할 수 있음
- 여러
rustc프로세스가 동시에 실행되고 각 프로세스가 여러 스레드를 만들 때는 jobserver protocol이 스레드 수를 제한함 - 프로세스 간 병렬성이 높으면 프로세스 내 병렬성은 그에 맞춰 줄어들며, 전체 스레드 수는 코어 수를 넘지 않음
사용 방법
- nightly 컴파일러에는 병렬 프론트엔드가 포함되어 있음
- 기본값은 단일 스레드 모드라서 그대로는 컴파일 시간이 줄지 않음
- 다중 스레드 모드는
-Z threads옵션으로 명시적으로 켜야 함
RUSTFLAGS="-Z threads=8" cargo build --release
- 하나 이상의 프로젝트에서
config.toml로 설정하려면 다음을 추가함
[build]
rustflags = ["-Z", "threads=8"]
- 단일 스레드 모드가 기본인 이유는 신중한 배포를 위해서임
- 병렬 프론트엔드는 새 코드가 많음
- 단일 스레드 모드는 새 코드 대부분을 실행하지만 deadlock 같은 스레딩 버그 가능성은 제외함
- 병렬 프로그램은 Rust에서도 직렬 프로그램보다 올바르게 작성하기 어려움
- 이 때문에 병렬 프론트엔드는 당분간 beta나 stable 릴리스에 포함되지 않음
성능과 메모리 영향
- 단일 스레드 모드에서 병렬 프론트엔드는 기존 직렬 프론트엔드보다 일반적으로 0%~2% 느림
-Z threads=8다중 스레드 모드에서는 실제 코드 측정에서 컴파일 시간이 최대 50% 줄어들 수 있음- 성능 효과는 코드 특성과 빌드 설정에 따라 크게 달라짐
- 개발 빌드는 릴리스 빌드보다 더 큰 개선을 볼 가능성이 있음
- 릴리스 빌드는 보통 백엔드 최적화에 더 많은 시간을 쓰기 때문임
- 이미 빠르게 컴파일되는 작은 프로그램 일부는 다중 스레드 모드가 단일 스레드 모드보다 느릴 수 있음
- 권장값은 8개 스레드임
- 가장 많이 테스트된 설정이고 좋은 결과를 내는 것으로 알려져 있음
- 8보다 낮은 값은 이점이 작지만, 코어가 8개보다 적은 하드웨어에는 적절함
- 8보다 높은 값은 수익 체감이 있고 성능이 더 나빠질 수도 있음
- 1개에서 8개 스레드로 늘려도 개선이 50% 수준인 이유는 프론트엔드가 전체 컴파일 시간의 일부이고 백엔드는 이미 병렬화되어 있기 때문임
- 다중 스레드 모드에서는 메모리 사용량이 크게 늘 수 있으며, 최대 35% 증가가 관찰됨
정확성과 피드백
- 단일 스레드 모드의 신뢰성은 높을 것으로 예상됨
- 다중 스레드 모드에는 deadlock을 포함한 알려진 버그가 있음
- 컴파일이 멈추면 알려진 버그 중 하나를 만났을 가능성이 있음
- 어떤 프론트엔드를 사용하든 컴파일러가 생성하는 바이너리는 같아야 하며, 차이가 있으면 버그로 취급됨
- 문제가 있으면
WG-compiler-parallel라벨 이슈를 먼저 확인하고, 일치하는 기존 이슈가 없을 때 새 이슈를 열 수 있음 - 일반 피드백은 wg-parallel-rustc Zulip channel에서 받을 수 있으며, 실제 코드에서의 성능 효과에 특히 관심이 있음
2024년 stable 목표
- 병렬 프론트엔드의 성능 개선 작업이 진행 중임
- 프로파일에서 보인 것처럼 프론트엔드 스레드 활용률에는 아직 개선 여지가 있음
- 다중 스레드 모드의 남은 버그도 정리 중임
-Z threads옵션 안정화와 stable 릴리스에서 병렬 프론트엔드를 다중 스레드 기본값으로 제공하는 목표 시점은 2024년임
댓글과 토론
Hacker News 의견들
-
아직 초기 단계인 건 알지만, Rust의 단점은 컴파일 속도라고 봄
Rust 모노레포에서 일해봤을 때 가장 큰 불만이 컴파일 속도였고, CI/CD 비용을 키웠으며 캐시를 지워야 할 때 개발 시간도 크게 느려졌음
그 원인은 Cargo가 아니라 Docker 버그였지만, 그래도 이런 진전은 반가움- 극적으로 좋아질 가능성은 낮아 보임
이미 많이 최적화됐고, 현재 Rust 컴파일러는 거의 모든 주류 컴파일러보다 병렬성이 높음
Rust의 언어 설계 자체가 빠른 컴파일을 목표로 만든 Go 같은 언어보다 컴파일을 더 어렵게 만듦 - rust-analyzer는 rustc 자체보다도 리소스를 더 많이 먹음
이 작업이 얼마나 직접 적용될지는 모르겠지만, 거기서도 큰 개선이 있으면 좋겠음
최신 IDE 지원에는 분명히 필요한 부분임 - 여기엔 꽤 동의하기 어려움
중간 규모 오픈소스 Rust 프로젝트 [1]의 메인테이너인데, 로컬에서 Rust 컴파일 시간이 늘 놀라울 정도로 빠르다고 느낌
MacBook Pro에서 디버그 빌드는 몇 초 수준이고, 릴리스 빌드와 CI/CD는 느리지만 2년 전 Rust를 시작한 뒤로 Rust 컴파일은 아주 빠르게 느껴짐
균형 있게 말하자면, 본업은 Java/Kotlin과 Gradle인데 여기서는 정말 빙하 같은 컴파일 시간을 말할 수 있음
내 오픈소스 Rust 프로젝트에서는 의존성을 최소화하고,derive[Debug, Clone]같은 것 외에는 매크로를 쓰지 않으며, 제네릭 사용도 매우 절제함
cargo build로 이 프로젝트를 빌드해보고 컴파일 시간 피드백을 주면 좋겠음
[1]: https://github.com/Orange-OpenSource/hurl - Rust로 작은 프로젝트만 해본 입장에서는 코드 줄 수가 어느 정도였는지 궁금함
그리고 적절한 지점에서 프로젝트를 여러 크레이트로 나눴는지도 궁금함 - 실제로 얼마나 느린 건지 궁금함
모노레포 빌드에 몇 분이 걸렸는지?
최대한 솔직하게 말해도 괜찮음. 주로 쓰는 컴파일러가 GHC라 웬만해서는 놀라지 않음
- 극적으로 좋아질 가능성은 낮아 보임
-
어리석은 질문일 수 있는데, 백엔드는 프런트엔드가 빌림 검사를 끝낼 때까지 기다려야 하나? 그렇다면 왜 그런가?
뭔가 잘못됐다는 뜻은 아니고, 빌림 검사가 단순한 정합성 이상으로 백엔드가 의존하는 불변식을 세우는지 궁금함
예를 들어 빌림 검사 오류가 나면 버릴 수 있는 투기적 백엔드 작업을 할 수 없는 이유가 있는지 궁금함- mrustc[0]라는 빌림 검사기가 없는 Rust 컴파일러가 있으니, 적어도 일부 Rust 버전은 빌림 검사기 없이도 컴파일 가능함
다만 가장 최적화된 코드를 만들지는 못할 수 있음
알기로는 악명 높은noalias최적화처럼 빌림 검사 중 확립된 정보를 쓰는 최적화가 있음. 이 최적화는 켜지기까지 여러 번 시도가 필요했음[1]
또 NLL(비어휘적 수명)과의 관계도 확실치 않은데, 백엔드가 관심 가질 정보를 세우려면 최소한 원시적인 빌림 검사기가 필요할 것 같음
그런데 mrustc는 NLL 기능이 있는 Rust 버전도 빌림 검사기 없이 컴파일하므로, 필수라기보다는 최적화 쪽에 더 가까운 듯함
[0]: https://github.com/thepowersgang/mrustc
[1]: https://stackoverflow.com/a/57259339 - 엄밀히 말하면 꼭 그래야 하진 않는다고 봄
- mrustc[0]라는 빌림 검사기가 없는 Rust 컴파일러가 있으니, 적어도 일부 Rust 버전은 빌림 검사기 없이도 컴파일 가능함
-
서로 다른 머신에서 쓰는 설정 파일에 고정값을 박는 대신, CPU 코어 수를 쓰게 할 방법이 있나?
- 아직 실험 단계이고 nightly에만 제한돼 있으니 일반 사용용으로 설정된 건 아니라는 점을 감안해야 함
안정화된 기본값은 코어 수가 될 거라고 예상함
이 작업이 지금 어디까지 갔는지는 모르지만, 한때는 Cargo의 rustc 호출들 사이에서 jobserver로 조율하려 했고, 그렇게 되면 기본값이 코어 수인 Cargo의 작업 수를 쓰게 됨
Cargo는 음수 값으로 코어 수에서 차감하는 방식도 지원함 RUSTFLAGS환경 변수를 쓰면 되고, 글에도 나와 있음:$ RUSTFLAGS="-Z threads=8" cargo build --release
- jobserver 프로토콜을 쓰고 Cargo가 기본적으로 코어 수로 초기화하므로, 새 플래그를 10000 같은 비현실적으로 큰 값으로 설정하면 남는 코어 수로 사용량이 제한될 것 같음
- 아직 실험 단계이고 nightly에만 제한돼 있으니 일반 사용용으로 설정된 건 아니라는 점을 감안해야 함
-
좋다! 아주 오래전에 Rust를 썼을 때는 장난감 예제조차 컴파일이 꽤 느렸는데, 최근 다시 돌아와 보니 Rust가 정말 좋아졌고 컴파일 시간을 거의 의식하지 않고 가능한 곳마다 쓰고 있음
다만 조금 커진 프로젝트 하나에서는 간단한 변경도 컴파일에 5초 이상 걸리기 시작하면서 예전 기억이 되살아남
노트북이 항공기 엔진처럼 돌기 전에 다른 걸 정리할 때까지 분석기가 돌지 않게 저장을 미루고 싶다는 생각까지 하게 됨
개인적으로 가장 큰 고통 지점이라 어떤 진전이든 매우 반가움 -
좋음! 라이브러리 크레이트 생태계와 달리, 내 바이너리 크레이트들은 기본적으로 크고 단일체가 되는 경향이 있었음
지금은 여러 라이브러리 크레이트로 나누고 있음
이 말은 컴파일 후반부에 병렬화가 안 될 뿐 아니라, 가장 큰 크레이트들이 순차적으로 처리된다는 뜻이어서 이번 변화가 아주 반가움 -
몇 년간 Rust를 반쯤 멀리하고 Python이나 TypeScript 같은 환경에서 일하다가, 최근 한 프로젝트에 다시 써봤는데 컴파일 속도가 거의 즉시 끝나는 수준이었음
더 좋아지는 건 언제나 좋지만, 이미 꽤 훌륭한 상태임
요즘은 ChatGPT라는 치트키로 몇 년 전이라면 막혔을 법한 어려운 Rust 문제들도 거의 헤쳐 나갈 수 있어서, Rust 쪽 전망이 꽤 좋아 보임- 내 컴파일 시간은 대체로 괜찮지만, GitHub Actions에서 교차 아키텍처 Docker 이미지를 만들 때만 예외임
그때는 Docker 이미지 빌드가 60~90분 걸리고, 프로젝트 전체 의존성이 얼마나 많은지 절감하게 됨 - 컴파일 시간은 의존성의 양·크기·복잡도에 매우 크게 좌우됨
- 내 컴파일 시간은 대체로 괜찮지만, GitHub Actions에서 교차 아키텍처 Docker 이미지를 만들 때만 예외임
-
컴파일러를 다시 빌드하지 않고 병렬 컴파일러 옵션을 끌 방법이 있나?
쓸 필요가 없고, 어차피 codegen units도 1로 설정해뒀으며, 디버깅하고 싶지 않은 ICE를 유발하는 것 같음
기본값이 1스레드인 건 알지만, 완전히 꺼버리고 싶음 -
“멀티스레드 모드에는 교착 상태를 포함해 알려진 버그가 있다. 컴파일이 멈추면 그중 하나를 밟았을 가능성이 높다.”
그렇다면-Z threads를 쓰기 전까지 조금 더 기다려야겠음 ;)