buildprof: Công cụ trực quan hóa quá trình build giúp lý giải vì sao Bun chuyển từ Zig sang Rust lại nhanh hơn 5 lần
Một kỹ sư đã tự phát triển buildprof, công cụ mã nguồn mở dùng ptrace trên Linux để ghi lại toàn bộ cây tiến trình khi biên dịch phần mềm. Anh dùng nó để giải mã bí ẩn đằng sau việc bản build Rust của Bun nhanh hơn bản Zig hơn 5 lần, và phát hiện thủ phạm chính là Full LTO cùng cách tổ chức mã nguồn.

buildprof: Công cụ trực quan hóa quá trình build giúp lý giải vì sao Bun chuyển từ Zig sang Rust lại nhanh hơn 5 lần
Một kỹ sư đã tự phát triển buildprof, công cụ mã nguồn mở dùng để ghi lại và trực quan hóa toàn bộ quá trình build trên Linux, cho thấy thời gian thực sự chảy về đâu. Anh dùng nó để truy tìm nguyên nhân khiến bản build Rust của Bun nhanh hơn bản Zig hơn 5 lần, và phát hiện thủ phạm chính nằm ở chế độ Full LTO cùng cách tổ chức mã nguồn thành một khối duy nhất của Zig.
Tổng quan về buildprof
Vì sao build chậm, và vì sao khó nhìn ra
Khi bạn gõ cargo build hay zig build, cảm giác như đang chạy một chương trình duy nhất. Thực tế, hệ thống build chỉ tìm ra cái gì cần biên dịch lại, thứ tự phụ thuộc và phần nào chạy song song được — còn phần lớn công việc lại do các chương trình con đảm nhiệm: trình biên dịch, sinh mã, archiver, linker và vô số script tùy ý. Những chương trình này lại gọi tiếp các chương trình khác, tạo thành một cây tiến trình sâu hun hút.
Các hệ thống build mô tả công việc theo những cách khác nhau: Cargo nhìn theo crate, Ninja nhìn theo build edge, CMake lại sinh lệnh cho hệ thống build khác. Nhưng từ góc nhìn của hệ điều hành, chúng gần như đều là tiến trình gọi tiến trình.
Điểm mấu chốt mà buildprof khai thác: nếu ghi lại thời điểm bắt đầu và kết thúc của từng tiến trình con, ta có thể trải chúng lên một dòng thời gian. Trục thời gian chạy từ trái sang phải, độ rộng thanh thể hiện thời lượng, và tiến trình con nằm ngay dưới tiến trình đã sinh ra nó.
Cây tiến trình trong một lần build Rust
Cách tiếp cận ở tầng tiến trình mang lại vài lợi thế rõ rệt:
- Không phụ thuộc hệ thống build: Cargo, Ninja, Zig, Make đều gọi tiến trình con, nên không cần viết tích hợp riêng cho từng cái.
- Bao trùm cả script tùy chỉnh: Từ script thiết lập kho mã, tải phụ thuộc, cho tới script sinh mã và xử lý tài nguyên.
- Theo dõi được dòng chảy tệp tin: Ghi lại tệp mà mỗi tiến trình đọc và ghi giúp thấy bước nào tạo ra đầu vào cho bước nào — thậm chí xuyên qua nhiều hệ thống build.
Cách dùng cực kỳ đơn giản, chỉ cần đặt buildprof -- trước lệnh build quen thuộc:
buildprof -- make -j16
buildprof -- cargo build
buildprof -- ninja -C out/target
Bí ẩn từ một dòng tweet của kiến trúc sư trưởng Bun
Tác giả kể rằng anh bị ám ảnh bởi một tweet của Jarred Sumner, kiến trúc sư trưởng của runtime JavaScript Bun. Trong tweet đó, Jarred tuyên bố bản build Rust mới của Bun nhanh hơn hơn 5 lần so với bản build Zig cũ trên Linux.
Điều khiến tác giả khó chịu là trong kinh nghiệm của anh, các dự án Zig thường biên dịch nhanh hơn nhiều so với dự án Rust có độ phức tạp tương đương. Một chi tiết quan trọng nhưng dễ bị bỏ qua trong tweet: bản Zig dùng Full LTO, còn bản Rust chỉ dùng ThinLTO.
Compiler thường tối ưu từng đơn vị biên dịch gần như độc lập. Link-time optimization (LTO) cho phép tối ưu xuyên qua ranh giới đó. Full LTO gộp tất cả lại thành một khối tối ưu lớn, còn ThinLTO giữ mức tách biệt cao hơn để phần lớn công việc chạy song song. Khác biệt này, theo kinh nghiệm, có thể ảnh hưởng khổng lồ tới thời gian build.
Thời gian biên dịch Zig và Rust của Bun
Tái hiện con số và bắt đầu nghi ngờ
Tác giả tải Bun 1.3.14 và Bun 1.4.0, viết script chạy lại các bản build CI Linux x64 trên một máy ảo Linux 6 nhân 12 luồng. Kết quả nằm cùng tầm với con số của Jarred:
- Thời gian CI trung vị do Bun công bố: Zig 30m06s — Rust 5m37s
- Tái hiện trên một máy: Zig 24m24s — Rust 5m40s
Khoảng cách đúng là có thật. Nhưng giữa hai phép đo có quá nhiều thứ thay đổi ngoài ngôn ngữ. Vậy đâu mới là nguyên nhân thật sự: trình biên dịch Zig, khâu link Full LTO, hay một thứ gì khác chưa ai để ý?
Nhìn thẳng vào bản build Zig: 16 phút chỉ để link
Khi ghi lại bản build CI thời Zig bằng buildprof, vấn đề lớn hiện ra ngay: lệnh gọi linker ld.lld chiếm gần như toàn bộ cuối quá trình build, chạy một mình suốt hơn 16 phút, tức khoảng hai phần ba tổng thời gian.
Bật --compiler-traces, buildprof hiển thị cả các sự kiện đo thời gian nội bộ do LLD ghi lại. Kết quả: gần như toàn bộ thời gian nằm ở LTO. Linker đang chạy các lượt biên dịch trên toàn bộ chương trình chứ không đơn thuần ghép các tệp đã biên dịch. Riêng thanh OptModule đã ngốn hơn 10 phút.
Bản build Rust thì sao? Chỉ 2m24s cho khâu link, và lệnh linker có tham số -plugin-opt=thinlto. Cả hai bản đều làm LTO, nhưng cấu hình khác nhau dẫn tới chênh lệch khổng lồ.
Thử đổi sang ThinLTO: chưa đủ
Tác giả đổi cờ build của Bun Zig sang ThinLTO và ghi lại một bản build sạch. Kết quả: link nhanh hơn 3m40s, nhưng vẫn mất gần 13 phút. Vì sao vẫn đắt đỏ đến vậy?
Nhìn lại vết compiler, rất nhiều công việc nằm ở các hàm mang tên JSC — tức JavaScriptCore, engine mà Bun dùng để chạy JavaScript. Linker đang tốn thời gian biên dịch luôn cả engine JavaScript.
Truy ngược đầu vào của linker, tác giả thấy các thư viện WebKit, trong đó có libJavaScriptCore.a. Bun không tự biên dịch chúng mà tải về từ một bản build WebKit riêng. Và khi kiểm tra cờ build của bản này, lại thấy -flto=full. Bản Rust dùng một revision WebKit mới hơn với công thức build chọn ThinLTO.
Nói cách khác, dù đã đổi cách Bun biên dịch mã của chính nó, các thư viện tải về vẫn chứa đầu vào Full LTO, buộc linker phải tối ưu và sinh mã cho chúng. Muốn thay đổi, phải build lại cả WebKit.
Build lại WebKit: từ 24 phút xuống 15 phút
Tác giả tải revision WebKit lịch sử, build lại nó cùng các phụ thuộc ICU với cấu hình ThinLTO tương thích, rồi thay thế các thư viện đã tải. Kết quả:
| Phiên bản build | Tổng thời gian | Thời gian linker |
|---|---|---|
| Full LTO gốc | 24m24s | 16m35s |
| Bun ThinLTO, WebKit gốc | 20m20s | 12m55s |
| Bun ThinLTO, WebKit và ICU build lại | 15m11s | 7m22s |
Link giảm còn 7m22s. Vẫn chậm hơn bản Rust, nhưng đủ để nhìn xa hơn khỏi linker.
Nguyên nhân thật sự nằm ở cấu trúc mã nguồn
Build vẫn mất 15 phút, và gần 8 phút trôi qua trước cả khi linker bắt đầu. Vì sao? buildprof cũng ghi lại tệp mà mỗi tiến trình đọc/ghi, và tự động nối các bước có quan hệ sản xuất — tiêu thụ. Khi bật "Show on timeline", các mối liên hệ này hiện thành mũi tên.
Kết quả cho thấy linker chờ bun-zig.o từ nhánh Zig, nên không thể bắt đầu cho tới khi nhánh đó hoàn tất. Và đây là khác biệt then chốt: Bun đã được chia thành hơn 90 crate khi chuyển sang Rust, trong khi bản Zig gom tất cả vào một module Zig duy nhất.
Điều này khiến bản Zig không thể song song hóa theo cách Rust làm. Tác giả cũng nghi ngờ — dù chưa chứng minh — rằng đây chính là lý do linker chậm: nó phải tối ưu một module bitcode ThinLTO khổng lồ thay vì chia đều công việc trên nhiều crate.
Tóm lại, các phát hiện chính:
- Điểm bất thường lớn nhất trong bản build Zig là khâu linker chạy đơn độc ở cuối, chiếm phần lớn thời gian.
- Chỉ đổi LTO cho Bun là chưa đủ, vì WebKit — phần đáng kể của build — vẫn dùng Full LTO.
- Sau khi sửa cả hai, bản Zig giảm từ 24 phút xuống 15 phút.
- Khác biệt lớn nhất còn lại mang tính cấu trúc: Rust trải việc biên dịch trên hơn 90 crate, còn Zig dồn tất cả qua một module.
Vài thứ thú vị khác lộ ra trong lúc ghi vết
Trong bản build CI của Bun, tác giả bắt gặp những lệnh hỏi internet công khai địa chỉ IP của máy, kiểm tra các container Docker đang chạy, và đọc thông điệp Git commit mới nhất. Tổng cộng chưa tới một giây — không đáng tối ưu, nhưng đúng là thứ khó ngờ trong một vết build.
Ở một lần ghi khác, quá trình tải và giải nén WebKit mất khoảng 20 giây. Mười hai giây đầu chỉ thấy Node chạy, sau đó nó gọi tar và gzip, và bước giải nén hiện ra tách bạch.
buildprof hoạt động thế nào
Phần ghi của buildprof dùng ptrace — đúng giao diện Linux mà các trình gỡ lỗi dùng. Tác giả cân nhắc cả eBPF lẫn ftrace, nhưng ptrace hợp hoàn hảo với bài toán này: eBPF đòi quyền CAP_BPF và CAP_PERFMON cùng việc móc vào các tracepoint/hàm kernel có thể không ổn định; còn ftrace thì phải xoay xở nhiều phiên ghi để không ảnh hưởng người dùng khác, và việc lọc chính xác chỉ cho tiến trình build cùng hậu duệ khá phiền phức.
Với ptrace, buildprof khởi chạy build và bám theo các tiến trình con. Các sự kiện có sẵn của nó cho biết khi nào tiến trình fork, exec chương trình mới, hay thoát. Với hoạt động hệ thống tệp, buildprof dùng bộ lọc seccomp để chỉ chặn đúng những lệnh gọi cần thiết.
Chi phí của buildprof gần như phụ thuộc hoàn toàn vào số tệp mà build mở:
- ripgrep / Cargo: không ghi vết 12,27s — chỉ tiến trình 12,30s — cả tệp 12,43s
- Redis / Make: không ghi vết 26,78s — chỉ tiến trình 27,04s — cả tệp 31,89s
Nếu phần chi phí này cản trở, có thể tắt theo dõi hệ thống tệp bằng --no-file-events và giữ lại dòng thời gian tiến trình.
Về giao diện, tác giả vốn làm việc trên Perfetto nên chọn nó làm điểm khởi đầu. Phần UI của buildprof là một bản fork nhẹ của Perfetto UI, tận dụng hạ tầng plugin mà nhóm đã phát triển nhiều năm qua. Perfetto lo phần khó — phân tích vết, truy vấn sự kiện, kết xuất dòng thời gian, quản lý workspace — còn tác giả tập trung vào những gì khiến chúng hữu ích cho việc build.
Không phải cứ làm công cụ mới là hay, nhưng lần này là cần
Tác giả thừa nhận ngày nay việc tạo công cụ mới rất dễ, nhưng anh đã tìm kỹ trước khi viết buildprof. Một số công cụ hiện có:
- ninjatracing: chuyển log của Ninja thành dòng thời gian, nhưng Ninja chỉ thấy một phần build của Bun — thiếu các script gọi nó, và các lệnh nó chạy chỉ hiện thành khối đơn dù bên trong là cả cây tiến trình.
- Cargo timings: tốt cho build do Cargo quản lý, nhưng không phân rã được công việc tùy ý trong
build.rshay các script bao quanh. Báo cáo tác giả thu được chỉ phủ 1m51s trong tổng 5m40s của bản build CI. -ftime-tracecủa Clang: cho chi tiết bên trong một lần gọi compiler, nhưng không thấy phần còn lại của build.stracevàtracexec: bám theo tiến trình tùy ý qua fork và exec, nhưng hiển thị sự kiện tiến trình chung chứ không theo hướng build.- What the Fork: gần nhất với nhu cầu — theo tiến trình xuyên hệ thống build và trình bày dạng chuyên cho build — nhưng dường như vẫn ở bản beta kín và chưa có kế hoạch mở mã nguồn.
Tiếp theo là gì
buildprof hiện đã làm được điều tác giả mong muốn. Một số hướng cải thiện trong tương lai gồm giảm chi phí ghi vết (đặc biệt với build mở nhiều tệp), hỗ trợ macOS và có thể cả Windows, thử thêm các hệ thống build như npm, Gradle, Bazel, và tự động tính đường găng (critical path) để chỉ ra chuỗi công việc đang kìm hãm build.
Tác giả kết luận rằng anh đã thỏa mãn trí tò mò của mình, dù tốn nhiều thời gian hơn dự kiến. Trên đường đi, anh tạo ra một công cụ mà mình sẽ muốn có sẵn mỗi khi build quá chậm.
Đối với cộng đồng lập trình viên Việt Nam — những người thường xuyên làm việc với các pipeline CI/CD cồng kềnh hoặc các dự án build đa ngôn ngữ — đây là minh chứng cho giá trị của việc quan sát hệ thống ở đúng tầng trừu tượng. Trực quan hóa cây tiến trình không chỉ giúp tối ưu thời gian chờ, mà còn phơi bày những quyết định kiến trúc sâu xa — như việc chia nhỏ module — vốn có sức ảnh hưởng lớn hơn nhiều so với việc tinh chỉnh cờ compiler đơn thuần.
Các ghi chú về lệnh Full LTO trong build Zig

