Vì sao trình biên dịch Rust lại chậm đến vậy?
(sharnoff.io)- Trong bản build website Rust để triển khai bằng Docker, dù đã cache dependency, chỉ riêng crate cuối cùng vẫn mất khoảng 175 giây; nút thắt được thu hẹp vào bên trong
rustcvà giai đoạn tối ưu hóa LLVM - Sau khi lần lượt áp dụng
cargo-chef,cargo --timings,-Zself-profile,measureme, kết quả cho thấy đây không chỉ là vấn đề dependency đơn giản, mà chi phí LTO và sinh mã LLVM mới là yếu tố chi phối thời gian build - Các thiết lập cũ trong
Cargo.tomllàlto = "thin"vàdebug = "full"có ảnh hưởng lớn; khi tắt cả hai, thời gian build binary cuối cùng giảm từ 172,2 giây xuống còn khoảng 50 giây - Trace LLVM cho thấy
OptFunction,InlinerPass,core::ptr::drop_in_place, các hàm async lớn và đơn hình hóa generic là những phần tốn kém chính; giảm inline, tách hàm, dùngPin<Box<dyn Future>>và loại bỏ generic tạo thêm cải thiện - Cuối cùng, sau khi áp dụng
-Zshare-genericsvà chuyển sang build trên nền Debian, thời gian biên dịch giảm từ 29,1 giây xuống 9,1 giây, cho thấy không chỉ cấu trúc mã mà cả allocator và việc có dùng target musl hay không cũng ảnh hưởng lớn đến thời gian build
Nút thắt lộ ra trong Docker build
- Website chủ yếu được cung cấp dưới dạng một binary Rust duy nhất; trước đây, quy trình là build binary liên kết tĩnh, copy lên server rồi khởi động lại service
- Khi chuyển sang triển khai dựa trên container, việc cấu hình build Rust nhanh trong Docker khó hơn dự kiến
- Dockerfile cơ bản sẽ build lại mọi thứ mỗi khi source thay đổi
- Dùng
rust:1.87-alpine3.22làm builder và build cho targetx86_64-unknown-linux-musl - Image cuối cùng chỉ copy binary vào Alpine
- Với cách này, một clean build mất 3 phút 51 giây, bao gồm 10 giây tải crates
- Dùng
Tách cache dependency bằng cargo-chef nhưng vẫn chưa đủ
- cargo-chef tạo một file recipe đơn giản hóa từ workspace, rồi dựa vào đó build trước dependency trong một layer Docker cache riêng
- Vì website dùng hàng trăm dependency, kỳ vọng hiệu quả cache sẽ lớn
- Nhưng đo thực tế cho thấy build dependency mất 1 phút 7 giây, còn build binary cuối cùng với dependency đã cache mất 2 phút 50 giây
- Chỉ khoảng 25% tổng thời gian dành cho dependency; phần lớn còn lại bị tiêu tốn trong một lần gọi
rustcduy nhất cho crate cuối cùngweb-http-server
cargo --timings và self-profile của rustc
cargo build --release --timingscho biết thời gian biên dịch theo từng crate; thời gian của crate cuối cùng là 174,1 giây, gần khớp với 2 phút 54 giây trong output củacargo build- Vì nút thắt tập trung vào một crate cuối cùng, chỉ dùng
cargo --timingsthì khó xác định nguyên nhân chi tiết - Để dùng tính năng self-profile của
rustc, tác giả dùng-Zself-profile- Dùng
RUSTC_BOOTSTRAP=1để sử dụng flag-Zbất ổn định trên compiler stable - Để tránh làm vô hiệu cache của
cargo-chef, dùngRUSTFLAGS='-Zself-profile'thay vìcargo rustc -- -Z self-profile
- Dùng
- Phân tích dữ liệu self-profile bằng các công cụ
summarize,flamegraph,croxcủa measureme - Các mục đứng đầu trong
summarizetập trung vào tác vụ liên quan đến LLVMLLVM_lto_optimize: 851,95 giây, 33,389% tổng thời gianLLVM_module_codegen_emit_obj: 674,94 giây, 26,452%LLVM_thin_lto_import: 317,75 giây, 12,453%LLVM_module_optimize: 189,00 giây, 7,407%
- Trên flamegraph,
codegen_module_perform_ltochiếm khoảng 80% tổng thời gian
Ảnh hưởng của LTO và thiết lập debug symbol
- Trình biên dịch Rust chia crate thành các codegen unit rồi chuyển cho LLVM dưới dạng các module riêng
- LTO là tùy chọn thực hiện inline và tối ưu hóa giữa các codegen unit hoặc giữa các crate tại thời điểm liên kết
- Các lựa chọn LTO của Cargo và
rustcgồm:- Tắt LTO
- LTO
"thin" - LTO
"fat" - Nếu không chỉ định, dùng “thin local LTO” chỉ giới hạn trong một crate duy nhất
Cargo.tomlhiện có vẫn còn các giá trị đã thiết lập từ vài năm trướclto = "thin"debug = "full"
debug = "full"bật toàn bộ debug symbol, vốn mặc định bị loại khỏi profile release- Kết quả đo nhiều tổ hợp
ltovàdebugcho thấy khác biệt rất lớn- Tắt LTO,
debug=none: 50,0 giây / 21,0MiB - Thin local LTO,
debug=full: 88,2 giây / 256,8MiB - LTO
"thin",debug=full: 172,2 giây / 197,5MiB - LTO
"fat",debug=full: 287,1 giây / 155,9MiB
- Tắt LTO,
- Debug symbol đầy đủ làm tăng thời gian biên dịch 30~50%, còn fat LTO mất lâu hơn khoảng 4 lần so với tắt hẳn LTO
- Ngay cả khi tắt LTO và debug symbol, việc biên dịch một binary cuối cùng vẫn còn mất khoảng 50 giây
Vì sao vẫn giữ Docker cache thay vì incremental compilation
- Khi phát triển local, có thể dùng incremental compilation bằng cách gắn thư mục
/targetlàm cache mount trong Dockerfile và giữ nó giữa các lần build - Tuy nhiên, để duy trì đặc tính
docker buildcó thể chạy trong môi trường sạch mỗi lần và tận dụng hệ thống cache riêng của Docker, tác giả tiếp tục dùngcargo-chef
Chi phí tối ưu hóa LLVM còn lại sau LTO
- Sau khi tắt LTO và debug symbol, biên dịch binary cuối cùng vẫn mất khoảng 50 giây
- Xem lại self-profile, khoảng 70% thời gian được dùng cho
LLVM_module_optimize, tức giai đoạn LLVM tối ưu hóa mã - Tác giả thử hạ
opt-level = 3mặc định của profile release để chỉ tối ưu hóa ít hơn cho binary cuối cùng- Vì dependency đã được cache, giữ
opt-level = 3trongprofile.release.package."*" - Chỉ hạ
opt-levelcho crate cuối cùng
- Vì dependency đã được cache, giữ
- Kết quả đo phân tách rõ theo việc có bật tối ưu hóa hay không
- Crate cuối cùng
opt-level=0: khoảng 15 giây - Crate cuối cùng
opt-level=1: khoảng 48 giây - Crate cuối cùng
opt-level=2hoặc3: khoảng 50~55 giây - Crate cuối cùng
opt-level="z": khoảng 42 giây
- Crate cuối cùng
- Chỉ cần bật bất kỳ tối ưu hóa nào cho binary cuối cùng là có mức nền khoảng 50 giây; tắt hoàn toàn tối ưu hóa thì nhanh lên khoảng 15 giây
Khó khăn khi thu thập dữ liệu trace LLVM
rustccó các flag để xem thông tin LLVM-Z time-llvm-passes: xuất thông tin profile LLVM dưới dạng văn bản thường-Z llvm-time-trace: xuất profile LLVM theo Chrome tracing format
-Z time-llvm-passesvướng giới hạn log mặc định của Docker BuildKitBUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- Các biến môi trường này phải được thiết lập cho Docker daemon, không phải trong lệnh gọi
docker build; trên Linux có thể cấu hình chodocker.servicebằng systemd drop-in - Khi gỡ giới hạn, output có khoảng 200 nghìn dòng văn bản, khó xử lý trực tiếp
-Z llvm-time-tracetạo các file*.llvm_timings.json, nhưng file trace của binary cuối cùng là JSON một dòng duy nhất dung lượng 1,4GiB- Firefox Profiler, Perfetto UI và
chrome://tracingcủa Chromium đều gặp vấn đề khi xử lý file này - Tác giả chuyển JSON sang JSONL để xử lý bằng công cụ thông thường
- Tách mảng
traceEventstrong một JSON object đơn thành từng dòng theo event - Sau khi chuyển đổi, số event là 7.301.865 dòng
- Tách mảng
Nút thắt thấy được trong các event LLVM
- Event trace LLVM chủ yếu là complete event có
"ph":"X", trong đó trườngdurbiểu thị duration theo micro giây "ph":"M"là metadata event và không có nhiều thông tin hữu ích trong phân tích này- Các mục tốn nhiều thời gian trong event aggregate gồm:
Total ModuleInlinerWrapperPass: 665,37 giâyTotal ModuleToPostOrderCGSCCPassAdaptor: 656,47 giâyTotal DevirtSCCRepeatedPass: 632,44 giâyTotal OptFunction: 189,62 giâyTotal InlinerPass: 182,25 giây
- Lần chạy này mất khoảng 110 giây trên máy 16 core, nên thời gian của một số pass được cộng dồn chồng lấp
- Hai trục lớn là
OptFunction, tức tối ưu hóa hàm, vàInlinerPass, tức inline
Điều chỉnh ngưỡng inline
- Có thể truyền tùy chọn inline của LLVM qua
-C llvm-argscủarustc - Tính đến tháng 6/2025,
rustc -C llvm-args='--help-list-hidden'có khoảng 100 tùy chọn liên quan đến inline - Ba tùy chọn được dùng trong thử nghiệm là:
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- Threshold về đại khái cho phép inline những hàm có chi phí thấp hơn giá trị đó, nên hạ giá trị sẽ giảm inline
- Khi hạ cả ba ngưỡng xuống 50, thời gian giảm từ 48,8 giây xuống 42,2 giây
- Với trường hợp sử dụng là website cá nhân gần như không có tải, threshold 10 cũng được xem là lựa chọn hứa hẹn
OptFunction và đơn hình hóa generic
args.detailcủa eventOptFunctionchứa mangled symbol của hàm đang được tối ưu hóa- Dùng rustfilt để demangle sẽ thấy symbol Rust gốc
__rustc::__rust_allocserde_json::value::to_value
- Lý do cùng một
serde_json::value::to_valuexuất hiện với nhiều hash là vì hàm generic được đơn hình hóa với các type parameter khác nhau - Hàm từ crate khác cũng được tối ưu hóa trong crate cuối cùng, vì vị trí một hàm được đơn hình hóa cho một type cụ thể nằm trong ngữ cảnh của crate gọi nó
- Ví dụ các hàm tốn nhiều thời gian tối ưu hóa:
- closure trong
web_http_server::photos::PhotosState::new - closure trong
web_http_server::run tokio_postgres::connect_raw- hàm generic khoảng 500 dòng của
pulldown_cmark - nhiều kiểu cụ thể của
core::ptr::drop_in_place
- closure trong
- Khi gộp xấp xỉ theo tên crate ngoài cùng,
corelớn nhất với 61,53 giây, trong đó 84% là các bản tham số hóa củacore::ptr::drop_in_place
Dùng symbol mangling v0 để thấy rõ hơn vị trí hàm async
- Legacy symbol mangling mặc định khiến việc phân biệt closure khá khó
- Thêm
-C symbol-mangling-version=v0giúp hiện rõ hơn số thứ tự closure và thông tin kiểu generic - Ví dụ, có thể thấy toàn bộ generic argument cho biết
serde_json::value::to_valueđược đơn hình hóa với kiểuweb_http_servernào - Trong output v0, các mục đắt đỏ gồm:
<web_http_server::photos::PhotosState>::new::{closure#0}: 1,99 giâyweb_http_server::run::{closure#0}: 1,56 giâycore::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1,22 giây
- Bề ngoài chúng là closure nhỏ, nhưng khi dump LLVM IR thì thấy async function và async block được biểu diễn nội bộ dưới dạng nested closure
- Rust đã có open issue liên quan đến mangling của async function/block
Hàm async lớn và Pin<Box<dyn Future>>
- Mục đắt đỏ không hẳn là bản thân closure, mà là thân hàm async lớn
- Thời gian tối ưu hóa liên quan đến
PhotosState::newban đầu tổng cộng là 5,3 giây - Thử nghiệm đầu tiên chỉ đơn giản tách hàm, giảm nhẹ xuống 4,66 giây
- Thử nghiệm gom các
.awaitliền kề để giảm số điểm.awaittừ 10 xuống 3 lại tăng lên 6,24 giây - Vì hàm async được hạ xuống thành state machine phức tạp bên trong, tác giả thử xóa chi tiết triển khai khỏi caller bằng cách biến
Futurethành trait object - Hàm được dùng có dạng bọc
impl Future<Output = T>thànhPin<Box<dyn Send + Future<Output = T>>> - Sau khi áp dụng ở từng điểm
.awaitnhưerase(get_img_candidates()).await?, kết quả là:- Thời gian liên quan đến
PhotosState::newgiảm xuống 2,14 giây - Thời gian build tổng thể khi không bật profiling giảm từ 48,8 giây xuống 46,8 giây
- Thời gian liên quan đến
- Cũng thử
#[inline(never)]và tắt inline hàm poll, nhưng không tốt bằng boxing
Kết quả khi kết hợp nhiều thay đổi
- Ba hướng tiếp cận đã được áp dụng:
- Giảm inline bằng LLVM args
- Tách các hàm đắt đỏ trong crate chính và boxing async Future
- Giảm generic trong API dependency để bớt phần phải biên dịch lại trong crate cuối cùng
- Dockerfile cuối cùng áp dụng
RUSTFLAGShạ ba inline threshold xuống 10 cho cảcargo chef cooklẫncargo build - Crate chính có thay đổi trải trên 10 file với 898 dòng thêm, 657 dòng xóa
- Các thay đổi phía dependency cũng được đưa vào
- PR biến hàm generic của
pulldown-cmarkthành không generic - Crate local expose phiên bản không generic của API dùng trong
lol_html,deadpool_postgres
- PR biến hàm generic của
- Với tổ hợp này, thời gian biên dịch cuối cùng còn 32,3 giây
Cập nhật 2025-06-27: -Zshare-generics và loại bỏ Alpine
- Từ các gợi ý nhận được trên Bluesky và Lobsters, tác giả thử thêm hai hướng:
- Bật
-Zshare-generics - Rời khỏi Alpine
- Bật
-Zshare-genericslà flag tái sử dụng các instance generic của dependency crate- Không được bật mặc định trong release build
- Được bật trong dev build của stable toolchain
- Flag này chỉ dùng được trên nightly
- Khi bật
-Zshare-generics, tổng thời gian biên dịch giảm từ 32,3 giây xuống 29,1 giây - Các instance
drop_in_placevẫn được biên dịch nhiều, nhưng thời gian tối ưu hóa tương ứng giảm từ 21,7 giây xuống 17,4 giây - Khi đổi từ Alpine sang Debian và bỏ
--target=x86_64-unknown-linux-musl, tổng thời gian biên dịch giảm mạnh từ 29,1 giây xuống 9,1 giây - Bối cảnh của gợi ý này là allocator mặc định có thể ảnh hưởng lớn đến thời gian build
Số liệu cuối cùng và việc còn lại
- Các thay đổi cuối cùng như sau:
- Điểm bắt đầu: khoảng 175 giây
- Tắt LTO và debug symbol: 51 giây, -71%
- Crate cuối cùng
opt-level = 1: 48,8 giây, -4% - Giảm inline bằng
-C llvm-args: 40,7 giây, -16% - Thay đổi mã local: 37,7 giây, -7%
- Thay đổi dependency: 32,3 giây, -14%
-Zshare-generics: 29,1 giây, -10%- Loại bỏ Alpine: 9,1 giây, -69%
- Trong quá trình phân tích, công cụ và tài liệu hoạt động đủ tốt để tạo ra cải thiện thực tế
- Tuy vậy, vẫn còn một số vấn đề phức tạp:
- Thời gian biên dịch của call graph async sâu cần được cải thiện thêm
- Việc xử lý đặc biệt để biên dịch
core::ptr::drop_in_place<T>trong crate định nghĩaTcó thể hữu ích trong một số trường hợp, nhưng khó áp dụng cho kiểu generic và có nguy cơ biên dịch cả drop glue không được dùng -Zshare-genericshữu ích nhưng không phải giải pháp hoàn chỉnh- Có thể cần thêm công cụ để cô lập phần nào trong codebase tiêu tốn nhiều thời gian biên dịch và đề xuất cách giảm nhẹ
- Về thực tế, đặt
opt-level = 0cho crate cuối cùng cũng có thể là lựa chọn đủ tốt
Chưa có bình luận nào.