- Khi lặp đi lặp lại việc build website viết bằng Rust bằng Docker, tác giả gặp vấn đề về thời gian build
- Với cấu hình Docker mặc định, toàn bộ dependency bị build lại mỗi lần, mất hơn 4 phút
- Ngay cả khi dùng
cargo-chef và các công cụ cache, quá trình build binary cuối cùng vẫn tốn rất nhiều thời gian
- Kết quả profiling cho thấy phần lớn thời gian bị tiêu tốn vào LTO (tối ưu hóa ở thời điểm liên kết) và tối ưu hóa module LLVM
- Có thể cải thiện phần nào bằng cách điều chỉnh tùy chọn tối ưu hóa, thông tin debug và thiết lập LTO, nhưng vẫn xác nhận hiện tượng biên dịch binary cuối cùng mất tối thiểu 50 giây
Nêu vấn đề và bối cảnh
- Mỗi khi sửa website cá nhân viết bằng Rust, tác giả lại phải lặp lại quy trình phiền toái: build binary liên kết tĩnh, chép lên server rồi khởi động lại
- Tác giả muốn chuyển sang triển khai dựa trên container như Docker hoặc Kubernetes, nhưng tốc độ build Rust trong Docker lại trở thành vấn đề lớn
- Ngay cả với thay đổi nhỏ trong mã nguồn bên trong Docker, toàn bộ dự án vẫn phải build lại từ đầu, gây ra sự kém hiệu quả
Build Rust trong Docker – cách tiếp cận cơ bản
- Cách làm Dockerfile phổ biến là sao chép toàn bộ dependency và mã nguồn rồi chạy cargo build
- Trong trường hợp này gần như không có lợi ích từ cache, nên việc build lại toàn bộ cứ lặp đi lặp lại
- Với website của tác giả, một lần build đầy đủ mất khoảng 4 phút — chưa kể thêm thời gian tải dependency
Cải thiện cache khi build Docker – cargo-chef
- Công cụ
cargo-chef cho phép cache trước riêng phần dependency ở các layer khác nhau
- Nhờ đó khi mã nguồn thay đổi, phần build dependency có thể được tái sử dụng, giúp cải thiện tốc độ build
- Tuy nhiên trong thực tế, chỉ khoảng 25% tổng thời gian nằm ở phần build dependency, còn binary dịch vụ web cuối cùng vẫn chiếm lượng thời gian đáng kể (2 phút 50 giây ~ 3 phút)
- Dù chỉ gồm các dependency chính (axum, reqwest, tokio-postgres, v.v.) và khoảng 7.000 dòng mã tự viết, một lần chạy đơn của rustc vẫn mất tới 3 phút
Phân tích thời gian build của rustc: cargo --timings
- Có thể dùng
cargo --timings để xem thời gian build theo từng crate (đơn vị biên dịch)
- Kết quả cho thấy phần build binary cuối cùng chiếm phần lớn tổng thời gian
- Dù hữu ích cho việc phân tích chi tiết hơn, công cụ này vẫn chưa cho thấy cụ thể hoạt động nội bộ của compiler
Dùng profiling nội bộ của rustc (-Zself-profile)
- Kích hoạt tính năng tự profiling của rustc bằng cờ
-Zself-profile để đo thời gian chi tiết của từng bước
- Việc này được bật thông qua biến môi trường
- Khi phân tích bằng công cụ summarize, tác giả phát hiện LLVM LTO (tối ưu hóa ở thời điểm liên kết) và sinh mã ở mức module LLVM chiếm hơn 60% tổng thời gian
- Hình ảnh flamegraph cũng cho thấy riêng bước codegen_module_perform_lto đã ngốn tới 80% thời gian tổng thể
LTO (tối ưu hóa ở thời điểm liên kết) và các tùy chọn tối ưu hóa build
- Quá trình build Rust mặc định được chia theo từng codegen unit, sau đó LTO sẽ áp dụng tối ưu hóa toàn cục ở giai đoạn khá muộn
- LTO có nhiều tùy chọn như off, thin, fat: mỗi tùy chọn ảnh hưởng khác nhau đến hiệu năng và kết quả đầu ra cuối cùng
- Dự án của tác giả trong
Cargo.toml đang đặt LTO là thin, còn symbol debug là full
- Khi thử nhiều tổ hợp LTO/symbol debug khác nhau, tác giả thấy:
- symbol debug full làm tăng thời gian build, còn fat LTO khiến thời gian build chậm hơn khoảng 4 lần
- ngay cả khi bỏ LTO và thông tin debug, thời gian build vẫn cần ít nhất 50 giây
Tối ưu thêm và một vài suy nghĩ
- Mức 50 giây không phải vấn đề lớn với website cá nhân gần như không có nhiều tải thực tế, nhưng vì tò mò về mặt kỹ thuật nên tác giả tiếp tục phân tích
- Nếu biết tận dụng tốt incremental compilation trong Docker, có thể còn build nhanh hơn nữa, dù điều này cần kết hợp giữa sự sạch sẽ của môi trường build và cache của Docker
Profiling chi tiết giai đoạn LLVM
- Ngay cả sau khi bỏ LTO và symbol debug, bước LLVM_module_optimize vẫn chiếm gần 70% thời gian
- Tác giả nhận ra trong profile release, giá trị mặc định của opt-level (3) gây chi phí tối ưu hóa lớn, nên đã thử cách hạ opt-level chỉ cho binary
- Kết quả thử nghiệm với nhiều tổ hợp tối ưu hóa cho thấy khi không áp dụng tối ưu hóa (
opt-level=0) thì chỉ mất khoảng 15 giây, còn khi có tối ưu hóa (1~3) thì mất khoảng 50 giây
Phân tích sâu các sự kiện trace của LLVM
- Có thể dùng thêm các cờ của rustc (
-Z time-llvm-passes, -Z llvm-time-trace) để theo dõi chi tiết thời gian thực thi theo từng giai đoạn LLVM
-Z time-llvm-passes tạo ra lượng đầu ra rất lớn, thường vượt giới hạn log của Docker nên cần điều chỉnh cấu hình log
- Nếu lưu log ra file để phân tích, có thể xem riêng thời gian chạy của từng LLVM optimization pass
- Tùy chọn
-Z llvm-time-trace sinh ra lượng JSON rất lớn theo định dạng chrome tracing, file quá to nên khó dùng các công cụ chỉnh sửa/phân tích văn bản thông thường
- Có thể tách nó theo từng dòng (jsonl) để phân tích trong môi trường CLI/script
Insight chính và kết luận
- Khi build các dự án Rust phức tạp bằng Docker, nút thắt tốc độ build chủ yếu nằm ở binary cuối cùng và các bước tối ưu hóa LLVM liên quan
- Khi điều chỉnh LTO, symbol debug và opt-level, sự đánh đổi giữa thời gian build và kích thước binary là rất rõ ràng
- Nếu chủ động điều chỉnh các tùy chọn tối ưu hóa, có thể rút ngắn mạnh thời gian build, nhưng việc không tối ưu hóa cũng có thể làm giảm hiệu năng
- Với các dự án phụ thuộc vào nhiều crate lớn và môi trường production coi trọng hiệu quả build, việc tích cực dùng profiling để xác định chính xác nút thắt chi tiết là chiến lược tốt
- Khi thiết kế pipeline build Rust, cần có sự kết hợp tinh chỉnh giữa LTO, opt-level, symbol debug và chiến lược cache
1 bình luận
Ý kiến Hacker News
Dự án Rust đôi khi trông có vẻ nhỏ từ bên ngoài nên thấy khá thú vị. Thứ nhất, dependency không liên hệ trực tiếp với kích thước thực của codebase. Trong C++, các dependency của dự án lớn thường được vendoring hoặc thậm chí không dùng, nên nếu 400 nghìn dòng code mà build chậm thì có thể nghĩ là "nhiều code nên chậm cũng phải". Thứ hai, vấn đề lớn hơn nhiều là macro. Những macro mở rộng lặp đi lặp lại 10 dòng, 100 dòng có thể nhanh chóng biến một dự án 10 nghìn dòng thành cả triệu dòng. Thứ ba là generic. Mỗi lần generic instantiation đều tiêu tốn tài nguyên CPU. Dù vậy cũng phải bào chữa một chút: chính nhờ các tính năng này mà thứ cần 100 nghìn dòng ở C, 25 nghìn dòng ở C++ thì trong Rust có thể rút xuống còn vài nghìn dòng. Nhưng cũng đúng là khi các tính năng này bị lạm dụng, toàn bộ hệ sinh thái trông sẽ chậm đi. Ví dụ công ty tôi dùng async-graphql, bản thân thư viện rất tốt nhưng phụ thuộc cực nặng vào procedural macro. Các issue về hiệu năng đã mở suốt nhiều năm, và mỗi lần thêm data type mới đều cảm nhận rất rõ compiler chậm đi
Ryan Fleury đã làm Epic RAD Debugger bằng C với 278 nghìn dòng theo kiểu unity build (toàn bộ code là một file, một compile unit duy nhất), và clean compile trên Windows chỉ mất 1,5 giây. Chỉ riêng ví dụ này cũng cho thấy compile có thể nhanh đến mức nào, nên tôi thắc mắc vì sao Rust hay Swift không thể làm tương tự
foo(int)vàofoo(char*)Tôi thật sự mừng vì Go ưu tiên tốc độ compile hơn tối ưu hóa. Với server, networking và glue code, compile thật nhanh quan trọng hơn bất cứ thứ gì. Tôi cũng muốn có mức độ type safety vừa phải, nhưng không đến mức cản trở việc prototype lỏng tay. Có GC cũng tiện. Tôi nghĩ sau kinh nghiệm mở rộng phát triển ở quy mô lớn tại Google, họ đi đến kết luận rằng type đơn giản, GC và compile cực nhanh quan trọng hơn nhiều so với tốc độ chạy hay sự hoàn hảo về ngữ nghĩa. Chỉ cần nhìn vào các phần mềm networking và hạ tầng quy mô lớn viết bằng Go là thấy lựa chọn đó hoàn toàn trúng đích. Tất nhiên ở những môi trường không chấp nhận GC hoặc nơi độ chính xác tuyệt đối quan trọng hơn, người ta có thể không dùng Go, nhưng với môi trường làm việc của tôi thì các lựa chọn của Go là tối ưu
Tôi không hiểu luận điểm cho rằng cài đặt một static binary duy nhất lại đơn giản hơn quản lý container
Trên laptop của tôi (Mac M4 Pro), compile toàn bộ Deno (một dự án Rust lớn) mất 2 phút. Xét theo lệnh thì debug khoảng 1 phút 54 giây, release khoảng 8 phút 17 giây. Đây là số đo không dùng incremental compile. Thực ra build để deploy chạy trên hệ thống CI/CD nên tôi cũng không phải tự ngồi chờ
Phần nói về Cranelift ở đâu nhỉ? Theo tôi, khi làm game bằng Rust tôi suýt nữa từ bỏ chỉ vì compile time quá dài. Tìm hiểu thì thấy LLVM chậm bất kể optimization level. Đây là điều các nhà phát triển ngôn ngữ Jai vẫn luôn chỉ ra. Tôi cũng từng trải nghiệm build time giảm từ 16 giây xuống 4 giây với Cranelift. Quá khâm phục đội Cranelift!
Tôi không nghĩ Rust là chậm. So với các ngôn ngữ cùng hạng thì nó đủ nhanh, và nếu so với những lần compile C++/Scala mất 15 phút thì Rust còn nhanh hơn nhiều
Từ góc nhìn của một cựu lập trình viên C++, tôi rất khó hiểu nhận định rằng Rust build chậm
Incremental compile thực sự rất mạnh. Sau build đầu tiên, có thể cố định snapshot của incremental cache, rồi nếu không có thay đổi thì tận dụng nó để build/deploy rất nhanh. Nó cũng hợp với docker. Trừ khi đổi phiên bản compiler hoặc cập nhật lớn cho website, còn không thì không đụng đến các layer build image. Nếu chỉ có code thay đổi thì cấu hình để layer đó không bị rebuild lại sẽ rất hiệu quả
Build time trang chủ của tôi là 73ms. Static site generator recompile chỉ mất 17ms. Việc chạy generator thực tế chỉ tốn 56ms. Tôi đính kèm log build của zig
cargo watch. Nếu dùng thêm incremental linking và hotpatch như subsecond[0] thì còn nhanh hơn nữa. Không bằng Zig nhưng cũng khá sát. Nếu con số 331ms nói ở trên là clean build (không cache) thì nhanh hơn hẳn clean build 12 giây của website tôi. [0]: https://news.ycombinator.com/item?id=44369642