1 điểm bởi GN⁺ 2025-06-28 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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

 
GN⁺ 2025-06-28
Ý 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

    • Tôi thắc mắc vì sao người ta hay rewrite sang Rust ở những chỗ mà mã gốc vốn đã đơn giản, như các tiện ích C nhỏ. So với các ca port chương trình C lớn 100 nghìn dòng, tôi thấy mã quy mô rất nhỏ xuất hiện thường xuyên hơn nhiều. Tôi muốn biết tốc độ compile của Rust và C so với nhau thế nào với các chương trình nhỏ như vậy. Tôi đang hỏi về tốc độ compile chứ không phải kích thước chương trình. Tham khảo thêm, theo đo đạc gần đây thì kích thước toolchain compiler Rust lớn gần gấp đôi GCC tôi đang dùng. 1. Các chương trình nhỏ cỡ này, dù ở ngôn ngữ nào, cũng ít khả năng ẩn bug memory safety hơn, và vì quy mô nhỏ nên audit cũng dễ. Tình huống rất khác với chương trình C 100 nghìn dòng
    • Mỗi lần định nghĩa type mới là có thể cảm nhận compiler chậm đi bằng da thịt. Tôi nhớ là hiệu năng compiler chậm theo cấp số nhân tùy theo “độ sâu” của type. Với GraphQL thì vì có nhiều type lồng nhau nên vấn đề này đặc biệt nghiêm trọng
    • Để xử lý vấn đề codebase có thể phình ra theo cấp số nhân khi macro mở rộng thành hàng chục hay hàng trăm dòng, gần đây đã có thêm hỗ trợ công cụ phân tích. Tài liệu liên quan: https://nnethercote.github.io/2025/06/26/how-much-code-does-that-proc-macro-generate.html
  • 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ự

    • Càng nhiều việc compiler phải làm ở build time thì build time càng dài. Go có thể đạt build time dưới 1 giây ngay cả với codebase lớn. Nó có module system và type system đơn giản được tối giản để chỉ làm đúng những gì cần lúc build, còn phần lớn tính năng được giao cho runtime GC xử lý. Ngược lại, nếu yêu cầu macro, type system phức tạp và mức độ chắc chắn cao thì build time tất yếu sẽ dài hơn
    • Rust cũng có đơn vị build là toàn bộ crate, và compiler sẽ tự chia ra thành các phần LLVM IR có kích thước phù hợp. Nó cũng tự cân bằng giữa công việc trùng lặp và incremental build. Xét theo số dòng source code thì Rust thường build nhanh hơn C++. Tuy nhiên Rust project có đặc tính là compile luôn cả dependency
    • Lý do Rust và Swift compile chậm hơn compiler C là vì bản thân ngôn ngữ đòi hỏi nhiều bước phân tích hơn rất nhiều. Ví dụ borrow checker của Rust không phải thứ miễn phí. Chỉ riêng các kiểm tra ở compile time thôi cũng tiêu tốn đáng kể tài nguyên. C nhanh vì gần như không kiểm tra gì vượt quá cú pháp cơ bản. Thậm chí C còn không kiểm tra cả những tổ hợp kỳ quặc như gọi foo(int) vào foo(char*)
    • Hồi những năm 2000 tôi từng compile một dự án C++ vài chục nghìn dòng và build xong trong dưới 1 giây ngay cả trên máy cũ. Trong khi một HELLO WORLD chỉ dùng Boost thôi cũng mất vài giây. Cuối cùng tốc độ build không chỉ phụ thuộc vào ngôn ngữ hay compiler mà còn thay đổi rất lớn theo cấu trúc code và các tính năng được dùng. Có thể làm DOOM bằng macro C, nhưng chắc hẳn sẽ không nhanh. Ngược lại, Rust cũng có thể được tổ chức để build nhanh
    • Việc các ngôn ngữ như C và Go, vốn hướng đến compile nhanh, lại compile nhanh thì không có gì lạ. Điều thực sự khó là compile nhanh ngữ nghĩa của Rust. Vấn đề này cũng có trong FAQ chính thức của Rust
  • 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 cũng thích Go, nhưng không nghĩ ngôn ngữ này là sản phẩm của trí tuệ tập thể kỳ vĩ kiểu tổ chức Google. Nếu thực sự phản ánh kinh nghiệm Google thì chẳng hạn đã phải thêm những tính năng như loại bỏ tĩnh null pointer exception. Có vẻ nó giống kết quả của việc một vài lập trình viên Google tạo ra ngôn ngữ họ muốn hơn
    • Go có các ưu điểm như compile nhanh, type system vừa phải, GC, nhưng trong không gian thiết kế thì Java đã chiếm một vị trí khá tương tự từ trước. Có vẻ động lực tạo ra Go chủ yếu đơn giản là ham muốn sáng tạo, và rốt cuộc nó lại hấp thụ người dùng từ lớp ngôn ngữ script (Python/Ruby/JS) nhiều hơn mục tiêu ban đầu là C/C++/Java phía server. Người dùng script chỉ muốn một type system dễ và nhanh, còn Java thì quá cũ và không vui. Thực ra Java vốn cũng không còn khoảng trống trong mảng server/conference/library nữa
    • Cũng có chuyện kể rằng các kỹ sư Google đã thiết kế Go trong lúc chờ compile các dự án C++
    • Tôi muốn hỏi “obnoxious type” là gì. Type hoặc biểu diễn dữ liệu đúng hoặc không, vậy thôi, và thực tế thì ở ngôn ngữ nào cũng có thể ép type checker im lặng bằng vũ lực
    • Go là ngôn ngữ rất khớp với mục tiêu thiết kế lẫn cách sử dụng thực tế của nó. Rủi ro lớn nhất là parallelism và trạng thái mutable được chia sẻ qua channel; ở phần này có thể xuất hiện các bug tinh vi hoặc mong manh. Thường thì đa số người dùng không dùng các pattern như vậy. Còn tôi dùng Rust vì công việc của tôi là phải vắt kiệt các thuật toán vốn đã chậm để chạy trên phần cứng chậm. Vì thế bài toán của tôi là kiểu rất khó parallelize ở quy mô lớn
  • 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

    • Có vẻ tác giả chưa hiểu rõ docker thực sự làm gì. Ví dụ có câu "nếu deploy bằng docker image thì mỗi lần đều phải build lại toàn bộ", nhưng trong môi trường build/deploy nội bộ thì chưa chắc có vấn đề đó. Với mục đích cá nhân, bạn hoàn toàn có thể giữ nguyên sự tiện lợi khi phát triển và chỉ đưa file build ở local vào container. Chỉ cần chú ý đến các đường dẫn mang dấu vết môi trường build. Trong CI/CD hay dự án nhóm, trọng tâm là đảm bảo có thể tạo build từ số 0 ở bất cứ đâu, nhưng với công việc cá nhân thì không nhất thiết phải vậy
    • Trong bài gốc, mục tiêu không phải là đơn giản hóa mà là hiện đại hóa. Ý là "10 năm gần đây phần lớn phần mềm đều lấy container deployment làm chuẩn, nên website của tôi cũng sẽ deploy bằng container như docker, kubernetes". Container có nhiều lợi ích như cô lập tiến trình, bảo mật, logging chuẩn hóa, khả năng scale ngang, v.v.
  • 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ờ

    • bài liên quan nói rằng trên M1 Max mất khoảng 6 phút, còn M1 Air mất khoảng 11 phút
  • 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!

    • Gần đây tại Bevy game jam, tôi dùng công cụ tên là 'subsecond' từ cộng đồng Dioxus, đúng như tên gọi, nó cho phép system hot reload dưới 1 giây nên cực kỳ hữu ích cho việc prototype UI. https://github.com/TheBevyFlock/bevy_simple_subsecond_system
    • Tôi biết là đội zig cũng đang thử làm compiler riêng (backend riêng) không dùng LLVM để đẩy build time lên rất nhanh
    • Tôi nhớ trước đây Cranelift không hỗ trợ macOS aarch64, nhưng gần đây mới biết là giờ đã hỗ trợ
    • Chỉ vì build time 16 giây mà suýt bỏ Rust thì có hơi quá không?
  • 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ôi cũng đồng ý. Tôi chưa từng thấy Rust build gây khó chịu gì đặc biệt. Có lẽ ấn tượng xấu từ thời kỳ đầu vẫn kéo dài nên mới có đánh giá như vậy
    • Mức dùng bộ nhớ lúc compile lớn hơn C/C++ rất nhiều. Để compile dự án Rust lớn trên VM tôi dùng để demo YouTube thì tôi cần hơn 8GB. Với C/C++ thì tôi không phải lo chuyện này
    • Vì template C++ là Turing-complete, nên nếu không xét đến phong cách viết code thực tế mà chỉ so build time thì cũng không có nhiều ý nghĩa
  • 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

    • Vì thế người ta mới nói Rust nhắm đến các lập trình viên C++. Người có nhiều kinh nghiệm C++ vốn đã có Stockholm syndrome và chịu đựng sự bất tiện của tool quá quen rồi
    • Dù nhanh hơn C++, nó vẫn có thể chậm xét theo giá trị tuyệt đối. Ai cũng biết tiếng xấu build C++ là cực kỳ khủng khiếp. Rust không gánh các vấn đề cấu trúc ngôn ngữ như vậy nên có lẽ kỳ vọng dành cho nó cao hơn
    • Tôi nghĩ đây là ví dụ kinh điển của việc cứ thêm tính năng mới liên tục nhưng lại không lắng nghe người dùng thực tế để giải quyết vấn đề
    • C có ít bước compile và đơn giản nên nhanh, còn C++ thì do dùng template mà cảm giác như phá hỏng phần lớn công sức encapsulation. Chỉ cần sửa một template header thôi là có cảm giác 98% toàn bộ project đều bị ảnh hưởng
  • 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ả

    • Artifact incremental của dự án tôi đã vượt 150GB. Khi dùng docker image to đến cỡ đó thì thực sự phát sinh những vấn đề rất lớn
  • 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

    • Có cảm giác với C/C++ thì luôn có bình luận bảo Rust tốt hơn, còn với Rust thì luôn có bình luận bảo Zig tốt hơn. (Hóa ra người viết bình luận này là nhà phát triển chính của zig.) Tôi nghĩ kiểu truyền đạo ngôn ngữ như vậy có hại cho cộng đồng; trên thực tế nó chỉ tạo phản cảm chứ không kéo thêm người dùng mới. Nếu thực sự yêu ngôn ngữ của mình thì tốt hơn nên kìm lại văn hóa truyền đạo này
    • Giá như thay vì chỉ đưa ra một con số compile time đơn lẻ thì có thêm phân tích hay diễn giải trực tiếp liên quan đến chủ đề bài gốc sẽ hay hơn
    • Website Rust của tôi (bao gồm framework kiểu react và cả web server thực thụ) cũng chỉ mất khoảng 1,25 giây với incremental build bằng 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
    • Tôi rất muốn hỏi @AndyKelley rằng theo anh, đâu là lý do mang tính quyết định khiến zig compile cực nhanh còn Rust và Swift thì luôn chậm
    • Zig không đảm bảo memory safety, đúng không?