- Việc song song hóa của trình biên dịch Rust trước đây chủ yếu dựa vào Cargo và backend LLVM, nhưng giờ đã bổ sung cả thực thi frontend song song để giảm nút thắt ở giai đoạn cuối của quá trình build
- Trên nightly, có thể bật tính năng thử nghiệm bằng
-Z threads=8; giá trị mặc định vẫn là chế độ đơn luồng, nên nếu không cấu hình rõ ràng thì sẽ không có cải thiện tốc độ
- Frontend mới dựa trên Rayon để chia nhỏ công việc biên dịch ở mức chi tiết, và trong phép đo ví dụ, thời gian frontend giảm từ 10.2 giây xuống 5.9 giây
- Trong phép đo trên mã thực tế, thời gian biên dịch giảm tối đa 50%, nhưng mức chênh lệch khá lớn tùy đặc tính mã và cấu hình build, còn lượng bộ nhớ sử dụng có thể tăng tối đa 35%
- Tính năng này vẫn đang ở giai đoạn thử nghiệm, và mục tiêu là ổn định hóa
-Z threads cùng việc đưa chạy đa luồng làm mặc định trên stable trong năm 2024
Một trục mới cho song song hóa biên dịch Rust
- Frontend của trình biên dịch Rust giờ đây có thể chạy song song để rút ngắn thời gian biên dịch
- Trên trình biên dịch nightly, có thể thử frontend đa luồng bằng tùy chọn
-Z threads=8
- Tính năng này vẫn còn mang tính thử nghiệm, và mốc mục tiêu để đưa vào trình biên dịch stable là năm 2024
Các tối ưu hóa hiện có và nút thắt còn lại
- Compiler Performance Working Group đã cải thiện hiệu năng trình biên dịch Rust trong nhiều năm
- Trong 10 tháng đầu năm 2023, thời gian biên dịch trung bình theo công cụ đo hiệu năng đã giảm 13%
- Mức sử dụng bộ nhớ tối đa giảm 15%
- Kích thước nhị phân giảm 7%
- Vì trình biên dịch đã được tối ưu hóa khá nhiều, dư địa cải thiện lớn còn lại chủ yếu nằm ở mở rộng tính song song
Giới hạn của song song hóa bằng Cargo và backend
- Khi build chương trình Rust, Cargo chạy nhiều tiến trình
rustc để biên dịch các crate song song
- Nếu tắt cơ chế song song này bằng cờ
-j1, thời gian biên dịch của các chương trình Rust lớn sẽ tăng mạnh
- Cờ
--timings của Cargo tạo biểu đồ timeline thời gian biên dịch crate
- Trong ví dụ build ripgrep trên máy có 28 lõi ảo, có 60 dòng tiến trình xuất hiện
- Phần lớn là
rustc, một số là build script
- 20 tiến trình đầu có thể khởi động đồng thời vì không có phụ thuộc giữa các crate
- Càng về cuối quá trình build, phụ thuộc giữa các crate càng tăng nên mức độ song song càng giảm
- Dù pipelined compilation có thể chồng lấp việc biên dịch crate phụ thuộc ở mức nào đó, với các chương trình Rust lớn thì khả năng chạy song song ở giai đoạn cuối build vẫn giảm đi đáng kể
Frontend và backend đảm nhiệm những gì
- Trình biên dịch Rust được chia thành frontend và backend
- Frontend thực hiện phân tích cú pháp, kiểm tra kiểu, borrow checking, v.v.
- Frontend trước đây không thể tận dụng thực thi song song
- Backend phụ trách sinh mã, tạo mã theo các “codegen units”, sau đó LLVM xử lý chúng song song
- Số codegen unit mặc định của build release là 16, nên trong profile ví dụ có 16 luồng LLVM xuất hiện
Nút thắt thể hiện trong profile backend trước đây
- Trong ví dụ đo bằng Samply khi build crate cuối cùng của Cargo ở chế độ release, frontend mất 10.2 giây
- Backend mất 6.2 giây, trong đó các luồng LLVM chạy trong 5.9 giây
- Việc sinh mã song song dựa trên LLVM khá hiệu quả, nhưng ngay cả trên máy 28 lõi thì 16 luồng LLVM cũng không phải lúc nào cũng chạy đồng thời
- Main thread tuần tự thực hiện việc chuyển MIR sang LLVM IR, tạo ra dạng bậc thang ở phần đầu của các luồng codegen
- Frontend vốn hoạt động hoàn toàn tuần tự vẫn là điểm còn nhiều dư địa cải thiện nhất
Cách triển khai frontend song song mới
- Frontend mới dùng Rayon để thực hiện các tác vụ biên dịch song song ở mức chi tiết
- Nhiều cấu trúc dữ liệu được đồng bộ bằng mutex và read-write lock, còn atomic type được dùng ở nơi cần thiết
- Dù nhiều tác vụ frontend đã được song song hóa, các thay đổi chủ yếu tập trung vào một số điểm lõi tương đối nhỏ
- Phần lớn mã frontend không cần phải chỉnh sửa
Kết quả đo với cấu hình 8 luồng
- Trong cùng ví dụ khi bật frontend song song và dùng 8 luồng, thời gian chạy của frontend giảm từ 10.2 giây xuống 5.9 giây
- Thời gian chạy backend giảm từ 6.2 giây xuống 5.3 giây, còn thời gian chạy của các luồng LLVM giảm từ 5.9 giây xuống 4.9 giây
- Có thêm 7 luồng hoạt động trong frontend, được hiển thị là
rustc
- Mức sử dụng luồng chưa đồng đều, và cả 8 luồng đều có khoảng thời gian nhàn rỗi nên vẫn còn chỗ để tối ưu thêm
- Lý do 8 luồng LLVM khởi động cùng lúc là vì 8 luồng
rustc tạo LLVM IR cho 8 codegen unit theo cách song song
- Nếu tăng số luồng frontend lên 16 thì dạng bậc thang biến mất hoàn toàn, nhưng thời gian chạy cuối cùng của trường hợp này hầu như không đổi
Kết hợp song song hóa giữa tiến trình và trong tiến trình
- Biên dịch Rust từ lâu đã hưởng lợi từ song song hóa giữa các tiến trình của Cargo và song song hóa trong tiến trình ở backend
- Giờ đây frontend cũng có thể tận dụng lợi ích của song song hóa trong tiến trình
- Khi nhiều tiến trình
rustc cùng chạy và mỗi tiến trình tạo nhiều luồng, jobserver protocol sẽ giới hạn số lượng luồng
- Nếu mức song song giữa các tiến trình cao, mức song song trong tiến trình sẽ giảm tương ứng, và tổng số luồng sẽ không vượt quá số lõi
Cách sử dụng
- Trình biên dịch nightly đã bao gồm frontend song song
- Mặc định vẫn là chế độ đơn luồng, nên nếu để nguyên thì thời gian biên dịch sẽ không giảm
- Cần bật rõ ràng chế độ đa luồng bằng tùy chọn
-Z threads
RUSTFLAGS="-Z threads=8" cargo build --release
- Nếu muốn cấu hình bằng
config.toml cho một hoặc nhiều dự án, hãy thêm như sau
[build]
rustflags = ["-Z", "threads=8"]
- Lý do chế độ đơn luồng vẫn là mặc định là để triển khai một cách thận trọng
- Frontend song song có nhiều mã mới
- Chế độ đơn luồng chạy phần lớn mã mới nhưng loại trừ khả năng gặp lỗi luồng như deadlock
- Các chương trình song song, ngay cả trong Rust, vẫn khó viết đúng hơn chương trình tuần tự
- Vì vậy frontend song song sẽ chưa được đưa vào các bản phát hành beta hoặc stable trong một thời gian
Tác động tới hiệu năng và bộ nhớ
- Ở chế độ đơn luồng, frontend song song thường chậm hơn 0%~2% so với frontend tuần tự cũ
- Ở chế độ đa luồng
-Z threads=8, phép đo trên mã thực tế cho thấy thời gian biên dịch có thể giảm tới 50%
- Hiệu quả hiệu năng thay đổi rất nhiều tùy đặc tính mã và cấu hình build
- Build phát triển có thể thấy cải thiện lớn hơn build release
- Vì build release thường dành nhiều thời gian hơn cho tối ưu hóa backend
- Một số chương trình nhỏ vốn đã biên dịch nhanh có thể chạy chậm hơn ở chế độ đa luồng so với đơn luồng
- Giá trị khuyến nghị là 8 luồng
- Đây là cấu hình được thử nghiệm nhiều nhất và được biết là cho kết quả tốt
- Giá trị thấp hơn 8 mang lại ít lợi ích hơn, nhưng phù hợp với phần cứng có ít hơn 8 lõi
- Giá trị cao hơn 8 có lợi ích giảm dần và thậm chí có thể làm hiệu năng kém đi
- Lý do việc tăng từ 1 lên 8 luồng vẫn chỉ cải thiện khoảng 50% là vì frontend chỉ chiếm một phần trong tổng thời gian biên dịch, còn backend đã được song song hóa sẵn
- Ở chế độ đa luồng, lượng bộ nhớ sử dụng có thể tăng đáng kể, và đã quan sát thấy mức tăng tối đa 35%
Tính đúng đắn và phản hồi
- Độ tin cậy của chế độ đơn luồng được kỳ vọng là cao
- Chế độ đa luồng hiện có các lỗi đã biết, bao gồm cả deadlock
- Nếu quá trình biên dịch bị treo, có thể bạn đã gặp một trong các lỗi đã biết
- Dù dùng frontend nào, tệp nhị phân mà trình biên dịch tạo ra phải giống nhau; nếu có khác biệt thì được xem là lỗi
- Nếu gặp vấn đề, hãy kiểm tra trước các issue có nhãn
WG-compiler-parallel, và nếu không có issue phù hợp thì có thể mở issue mới
- Phản hồi chung có thể gửi qua wg-parallel-rustc Zulip channel, và nhóm đặc biệt quan tâm tới hiệu quả hiệu năng trên mã thực tế
Mục tiêu stable trong năm 2024
- Công việc cải thiện hiệu năng của frontend song song vẫn đang tiếp diễn
- Như đã thấy trong profile, mức tận dụng các luồng frontend vẫn còn chỗ để tối ưu
- Các lỗi còn lại của chế độ đa luồng cũng đang được xử lý
- Mục tiêu là ổn định hóa tùy chọn
-Z threads và cung cấp frontend song song với mặc định đa luồng trong bản phát hành stable vào năm 2024
1 bình luận
Ý kiến trên Hacker News
Tôi biết là vẫn còn ở giai đoạn đầu, nhưng tôi cho rằng nhược điểm của Rust là tốc độ biên dịch
Khi từng làm việc trong một monorepo Rust, phàn nàn lớn nhất của tôi là tốc độ biên dịch; nó làm tăng chi phí CI/CD và khi phải xóa cache thì thời gian phát triển cũng chậm đi đáng kể
Nguyên nhân là do bug của Docker chứ không phải Cargo, nhưng dù sao tiến triển như thế này vẫn rất đáng mừng
Nó đã được tối ưu hóa rất nhiều, và hiện tại trình biên dịch Rust có mức song song hóa cao hơn gần như mọi trình biên dịch phổ biến
Thiết kế ngôn ngữ của Rust tự nó khiến việc biên dịch khó hơn so với các ngôn ngữ như Go, vốn được tạo ra với mục tiêu biên dịch nhanh
Tôi không biết công việc này sẽ áp dụng trực tiếp đến đâu, nhưng nếu ở đó cũng có cải thiện lớn thì tốt
Đây rõ ràng là phần cần thiết cho hỗ trợ IDE hiện đại
Tôi là maintainer của một dự án Rust mã nguồn mở cỡ trung [1], và khi build Rust cục bộ, tôi luôn cảm thấy thời gian biên dịch nhanh đến đáng ngạc nhiên
Trên MacBook Pro, debug build chỉ mất vài giây; release build và CI/CD thì chậm, nhưng kể từ khi bắt đầu dùng Rust 2 năm trước, tôi thấy biên dịch Rust rất nhanh
Nói cho cân bằng thì công việc chính của tôi là Java/Kotlin và Gradle, và ở đó thì đúng là có thể nói về thời gian biên dịch chậm như băng trôi
Trong dự án Rust mã nguồn mở của tôi, tôi giảm thiểu dependency, không dùng macro ngoài những thứ như
derive[Debug, Clone], và cũng dùng generic rất tiết chếSẽ rất tốt nếu bạn thử build dự án này bằng
cargo buildrồi phản hồi về thời gian biên dịch[1]: https://github.com/Orange-OpenSource/hurl
Và cũng muốn biết liệu dự án có được chia thành nhiều crate ở những điểm phù hợp hay không
Build monorepo mất bao nhiêu phút?
Cứ nói thật nhất có thể cũng được. Trình biên dịch tôi dùng chủ yếu là GHC, nên bình thường khó làm tôi ngạc nhiên lắm
Có thể là câu hỏi ngớ ngẩn, nhưng backend có phải đợi frontend hoàn tất borrow checking không? Nếu có thì tại sao?
Tôi không có ý nói có gì đó sai, chỉ tò mò liệu borrow checking có thiết lập các invariant mà backend phụ thuộc vào, hơn cả việc kiểm tra tính đúng đắn đơn thuần hay không
Ví dụ, tôi thắc mắc có lý do nào khiến không thể thực hiện công việc backend mang tính đầu cơ rồi bỏ đi nếu gặp lỗi borrow checking hay không
Tuy nhiên có thể nó không tạo ra code tối ưu nhất
Theo tôi biết, có những tối ưu hóa dùng thông tin được xác lập trong quá trình borrow checking, như tối ưu hóa
noaliaskhét tiếng. Tối ưu hóa này đã cần nhiều lần thử mới được bật[1]Ngoài ra tôi cũng không chắc quan hệ của nó với NLL (non-lexical lifetimes), nhưng để thiết lập thông tin mà backend quan tâm thì có lẽ ít nhất cần một borrow checker thô sơ
Tuy vậy mrustc cũng biên dịch được các phiên bản Rust có tính năng NLL mà không cần borrow checker, nên có vẻ nó nghiêng về tối ưu hóa hơn là bắt buộc
[0]: https://github.com/thepowersgang/mrustc
[1]: https://stackoverflow.com/a/57259339
Có cách nào để dùng số lõi CPU thay vì hard-code một giá trị cố định vào file cấu hình dùng trên các máy khác nhau không?
Tôi đoán giá trị mặc định khi ổn định sẽ là số lõi
Tôi không biết hiện công việc này đã đi đến đâu, nhưng có thời điểm họ từng định điều phối giữa các lần Cargo gọi rustc bằng jobserver; nếu vậy thì nó sẽ dùng số job của Cargo, vốn mặc định là số lõi
Cargo cũng hỗ trợ giá trị âm để trừ bớt từ số lõi
RUSTFLAGS, bài viết cũng có nêu:Hay đấy! Từ rất lâu trước khi tôi dùng Rust, ngay cả ví dụ đồ chơi cũng biên dịch khá chậm; gần đây quay lại thì thấy Rust đã thực sự tốt hơn, và tôi đang dùng nó ở bất cứ đâu có thể mà hầu như không để ý đến thời gian biên dịch
Tuy nhiên, trong một dự án lớn hơn một chút, ngay cả thay đổi đơn giản cũng bắt đầu mất hơn 5 giây để biên dịch, khiến ký ức cũ quay lại
Tôi thậm chí còn muốn trì hoãn việc lưu file để analyzer không chạy cho đến khi dọn dẹp xong những thứ khác, trước khi laptop quay như động cơ máy bay
Với cá nhân tôi, đây là điểm đau lớn nhất, nên bất kỳ tiến triển nào cũng đều rất đáng mừng
Tốt! Khác với hệ sinh thái library crate, các binary crate của tôi về cơ bản có xu hướng lớn và nguyên khối
Hiện tôi đang chia chúng thành nhiều library crate
Điều đó có nghĩa là không chỉ giai đoạn cuối của biên dịch không được song song hóa, mà các crate lớn nhất còn được xử lý tuần tự, nên thay đổi lần này rất đáng mừng
Sau vài năm gần như giữ khoảng cách với Rust và làm việc trong các môi trường như Python hay TypeScript, gần đây tôi thử dùng lại cho một dự án thì tốc độ biên dịch gần như tức thì
Tốt hơn nữa thì lúc nào cũng tốt, nhưng hiện trạng đã khá tuyệt rồi
Dạo này với “mã gian lận” mang tên ChatGPT, tôi gần như có thể vượt qua cả những vấn đề Rust khó mà vài năm trước có lẽ đã bị kẹt, nên triển vọng phía Rust trông khá sáng
Khi đó build Docker image mất 60–90 phút, và tôi mới cảm nhận rõ toàn bộ dự án có nhiều dependency đến mức nào
Có cách nào tắt tùy chọn trình biên dịch song song mà không phải build lại compiler không?
Tôi không cần dùng nó, dù sao cũng đã đặt codegen units là 1, và nó có vẻ gây ra ICE mà tôi không muốn debug
Tôi biết mặc định là 1 thread, nhưng tôi muốn tắt hẳn
“Chế độ đa luồng có các bug đã biết, bao gồm deadlock. Nếu quá trình biên dịch bị treo, rất có thể bạn đã gặp một trong số đó.”
Vậy thì chắc tôi sẽ đợi thêm một chút trước khi dùng
-Z threads;)