Tiến trình tái viết trình biên dịch Roc từ Rust sang Zig
(rtfeldman.com)- Để giải quyết các khiếm khuyết mang tính cấu trúc của bản triển khai hiện có, nhóm đã viết lại 300.000 dòng Rust sang Zig và đạt mức tương đương tính năng sau 487 ngày, hướng tới bản phát hành đánh số chính thức đầu tiên 0.1.0 vào cuối năm nay
- Trình biên dịch mới hỗ trợ hot code loading và cross compile có thể tái lập, nội suy chuỗi trong pattern matching, loại bỏ cấp phát heap trong định tuyến HTTP, đồng thời giảm kích thước wasm của
Rocci Birdxuống chỉ còn 31KB, tức chưa đến một nửa - Lý do cốt lõi để chọn Zig là thời gian build, khả năng kiểm soát allocator và bố trí dữ liệu ở mức chi tiết, hệ sinh thái phù hợp cho phát triển trình biên dịch, cùng khả năng kiểm tra mã không an toàn bộ nhớ; incremental build của Zig 0.17.0 có thể build lại khoảng 460.000 dòng chỉ trong 35ms
- Trong phân loại lỗi thực tế, trình biên dịch Rust có 21 vụ hỏng bộ nhớ, còn trình biên dịch Zig có 10 vụ, nhưng đa số là do sinh mã sai; lỗi an toàn bộ nhớ của chính trình biên dịch Zig chỉ có 2 trường hợp use-after-free làm hỏng tên tệp
- Zig rất phù hợp với cấu trúc dữ liệu không dùng con trỏ, giải tuần tự hóa không cần parsing, và việc tái sử dụng bộ tuần tự hóa LLVM bitcode, nhưng về giải phóng bộ nhớ tự động trong test, tính đa hình, trường struct riêng tư, phát hiện mã chết và tính tương thích giữa các bản phát hành thì trải nghiệm phát triển với Rust tốt hơn
Bản viết lại đã đạt tương đương tính năng
- Nhóm trình biên dịch Roc đã dành khoảng 1 năm rưỡi để viết lại 300.000 dòng Rust sang Zig và đạt mức tương đương tính năng với trình biên dịch cũ
- Tương đương tính năng là một cột mốc quan trọng, nhưng chưa phải bản phát hành chính thức; 0.1.0 của trình biên dịch mới được nhắm tới vào cuối năm nay
- Giờ đây đã có thể build game WASM-4 năm 2024 Rocci Bird bằng trình biên dịch mới
- Toàn bộ game có chưa đến 1.000 dòng mã Roc
- Mã nguồn đã cập nhật ngắn gọn hơn bản gốc
- Tệp nhị phân wasm do
roc build --opt=sizetạo ra có kích thước 31KB, nhỏ hơn một nửa so với đầu ra của trình biên dịch cũ
- Trên trang chủ, có thể viết và chạy chương trình Roc cơ bản ngay trong trình duyệt bằng trình biên dịch WebAssembly 2,5MB
- Việc viết lại mất 487 ngày, dài hơn 476 ngày so với 11 ngày Bun chuyển khoảng 500.000 dòng Zig sang Rust
- Bun là chuyển thẳng, còn Roc gần như là một cuộc tái kiến trúc với nhiều thay đổi lớn về cấu trúc và tính năng của trình biên dịch
- Vì vậy, khó có thể so sánh trực tiếp quy mô hay thời gian của hai codebase chỉ dựa trên Rust và Zig
Trải nghiệm phát triển mà trình biên dịch mới mang lại
-
Hot code loading và cross compile
- Chạy server bằng
roc server.roc, sau đó sửa mã thì từ request tiếp theo mã mới sẽ tự động được áp dụng - Điều này phổ biến ở các ngôn ngữ thông dịch như Python, nhưng không thường thấy ở các ngôn ngữ biên dịch hiệu năng cao như Roc
- Cơ chế tương tự cũng hoạt động không chỉ với web server mà cả trong các game 2D đơn giản
- Khi triển khai,
roc build server.rocsẽ tạo tệp thực thi độc lập có áp dụng tối ưu hóa LLVM - Có thể cross compile tệp nhị phân tĩnh cho Alpine Linux bằng
roc build --target=x64musl - Nếu đầu vào là cùng một byte mã nguồn, thì trên bất kỳ hệ thống build nào, kể cả Mac, cũng sẽ tạo ra cùng một byte đầu ra
- Chạy server bằng
-
Nội suy chuỗi trong pattern matching
- Hỗ trợ nội suy chuỗi như
"/users/${id}"ngay bên trong pattern matching - Trình biên dịch xử lý trực tiếp thay vì parse chuỗi mẫu lúc chạy như kiểu định tuyến của Express
- Có thể match đồng thời HTTP method và đường dẫn, đồng thời phân nhánh bằng pattern cho cả đường dẫn lồng nhau hoặc giá trị mặc định
- Toàn bộ mã xử lý request HTTP trong ví dụ không thực hiện bất kỳ cấp phát heap nào, đồng thời vẫn kiểm tra tính an toàn kiểu khi biên dịch
- Định tuyến request HTTP được triển khai không cấp phát nhờ thực thi hàm thuần ở thời điểm biên dịch, và có thể thử cú pháp này trong trình biên dịch WebAssembly trên trang chủ
- Hỗ trợ nội suy chuỗi như
Lý do viết lại từ đầu
- Khác với Rust, C và Zig, Roc không phải ngôn ngữ hệ thống mà dùng quản lý bộ nhớ tự động dựa trên đếm tham chiếu
- Tránh các lần tạm dừng của garbage collector dạng tracing
- Tận dụng tối ưu hóa Perceus và cơ chế đột biến cơ hội kiểu Koka
- Không cấp phát heap cho mỗi lần closure capture như các ngôn ngữ không phải hệ thống thông thường, mà dùng defunctionalization đa hình thông qua chuyên biệt hóa tập lambda
- Defunctionalization cho phép nhiều tối ưu hóa tiếp theo giống như inline trong ngôn ngữ hàm, nhưng việc làm cho bản triển khai cũ trở nên chính xác là cực kỳ khó
- Thông qua prototype OCaml của Ayaz Hafiz, nhóm xác nhận rằng vấn đề là khiếm khuyết mang tính cấu trúc trải dài qua nhiều giai đoạn của trình biên dịch, và để giải quyết thì phải viết lại phần lớn trình biên dịch
- Do các cộng tác viên khác cũng đã lên kế hoạch viết lại nhiều phần vì những lý do khác nhau, nên nhóm bắt đầu xem xét viết lại toàn bộ thay vì thay thế dần gần như toàn bộ theo kiểu con tàu Theseus
- Trong các dự án trình biên dịch thành công, việc viết lại từ đầu, chẳng hạn để self-hosting, là chuyện phổ biến; cũng có trường hợp TypeScript viết lại sang Go chứ không phải ngôn ngữ của chính nó
- Dù Roc có chủ trương không self-hosting, lần này nhóm đánh giá lợi ích đạt được từ việc viết lại lớn hơn các chi phí đã biết
Tiêu chí chọn Zig thay vì lại dùng Rust
- Nhóm vốn đã dùng Zig cho nhiều chức năng nền tảng trong standard library, và chỉ thật sự thông thạo Rust và Zig trong các ngôn ngữ hệ thống, nên chỉ nghiêm túc xem xét hai ngôn ngữ này
- Mỗi dự án phù hợp với một ngôn ngữ khác nhau; việc tiếp tục dùng Rust cho công việc khác và chọn Zig cho Roc không hề mâu thuẫn
-
Thời gian build
- Các bản build
cargocủa Rust, kể cả incremental build, đều mất nhiều thời gian, và khi codebase lớn dần thì đây trở thành bất tiện chính - Nhóm kỳ vọng chuyển sang Zig sẽ giúp build nhanh hơn đáng kể
- Các bản build
-
Kiểm soát bộ nhớ và bố trí dữ liệu
- Ở mỗi giai đoạn biên dịch, nhóm dùng nhiều allocator khác nhau, đặc biệt là arena allocator, và tận dụng rộng rãi cách bố trí struct-of-arrays (SoA)
- Hệ sinh thái Rust nhìn chung giả định một global allocator, trong khi hệ sinh thái Zig mặc định truyền allocator một cách chi tiết và cũng hỗ trợ SoA theo tiêu chuẩn
-
Phạm vi hệ sinh thái cần thiết
- Toàn bộ hệ sinh thái Rust lớn hơn Zig, nhưng các package liên quan đến nhu cầu đặc thù của Roc thì ở cả hai bên đều không nhiều
- Với những chức năng ngách cần thiết, như sinh bitcode nhanh thay vì bọc thư viện LLVM C++, phía Zig có nhiều lựa chọn hơn
-
Hỗ trợ mã không an toàn bộ nhớ
- Trình biên dịch Rust cũ với 300.000 dòng có khoảng 1.200 lần dùng
unsafe - Để so sánh, chính trình biên dịch Rust dùng khoảng 40.000
unsafetrên 3,5 triệu dòng - Với trình biên dịch sinh mã máy, các thao tác không an toàn bộ nhớ là một phần quan trọng của công việc
- Rust theo mô hình cô lập các khối
unsafehiếm gặp rồi kiểm tra bằng Miri hoặc Valgrind, nhưng ở Roc thìunsafekhông hề hiếm - Zig cung cấp nhiều tính năng hơn để làm cho mã không an toàn bộ nhớ hoạt động đúng, và đây chính là lĩnh vực mà nhóm muốn được hỗ trợ nhiều nhất
- Trình biên dịch Rust cũ với 300.000 dòng có khoảng 1.200 lần dùng
Độ an toàn bộ nhớ đạt được mà không cần borrow checker
-
Phạm vi kiểm tra của Rust và Zig
- Theo tài liệu năm 2019 của Microsoft, khoảng 70% lỗ hổng được xử lý qua các bản cập nhật bảo mật hằng năm là vấn đề an toàn bộ nhớ
- Nếu chia phân loại năm 2018 trong tài liệu đó theo góc nhìn của Rust và Zig thì như sau
- 83,6% là đọc/ghi ngoài phạm vi, cast không an toàn, đọc dữ liệu chưa khởi tạo, tràn ngăn xếp và các vấn đề không liên quan đến an toàn bộ nhớ; đây là nhóm không bị ảnh hưởng bởi việc chọn Rust hay Zig
- 16,4% là use-after-free, thuộc nhóm có thể được phát hiện bởi borrow checker của Rust,
ReleaseSafecủa Zig và cơ chế kiểm tra kiểu Fil-C ReleaseSafecủa Zig sẽ gây panic khi chạy nếu sử dụng bộ nhớ đã được giải phóng- Phạm vi kiểm tra kém toàn diện hơn tập con an toàn của Rust
- Có chi phí khi chạy và chương trình có thể panic
ReleaseFastbỏ qua các kiểm tra trong môi trường production, nhưng vẫn giữ chúng trong bản build debug và test- Nếu kiểm thử được mọi đường thực thi thực tế thì có thể đạt mức an toàn tương đương
ReleaseSafe, nhưng mức bao phủ kiểm thử như vậy thường không thực tế
-
Các dự án Zig khác và
unsafecủa Rust- TigerBeetle dùng
ReleaseSafe, và qua kiểm chứng Jepsen rất kỹ đã phát hiện 2 lỗi an toàn, nhưng cả hai đều không liên quan đến an toàn bộ nhớ - Ghostty và trình biên dịch Zig dùng
ReleaseFastcũng không có CVE nào bắt nguồn từ mất an toàn bộ nhớ trong mã Zig - Chương trình Rust cũng có thể xuất hiện khoảng trống về an toàn bộ nhớ thông qua
unsafebên trong dependency - Unsafe Rust có rủi ro tương tự Zig
ReleaseFast, nhưng không có tính năng tương đương với các kiểm tra khi chạy của Zig để bắt lỗi trong quá trình phát triển - Miri và Valgrind có thể giúp ích, nhưng không nhiều dự án Rust sử dụng chúng
- Thay vào đó, việc chỉ dùng
unsafemột cách hiếm hoi và văn hóa rà soát nghiêm ngặt hơn đã tạo nên danh tiếng mạnh về an toàn bộ nhớ của Rust trong thực tế - Đã có những trường hợp lỗ hổng liên quan đến
unsafexuất hiện cả trong các dự án viết bằng Rust- Deno từng có CVE đọc ngoài phạm vi và use-after-free
- Rocket từng có CVE use-after-free
- Actix từng phát sinh nhiều CVE mất an toàn bộ nhớ trong giai đoạn lượng dùng
unsafecao bất thường
- Roc đánh giá use-after-free không phải rủi ro lớn vì phần lớn cấp phát diễn ra trong arena có vòng đời đơn giản, và cho rằng các kiểm tra bổ sung của Zig hữu ích hơn với phần mã vốn dĩ không an toàn
- TigerBeetle dùng
Các lỗi hỏng bộ nhớ được xác nhận sau khi tái viết
- Kết quả phân loại issue tracker của Roc bằng Claude Opus 4.8 như sau
| Trình biên dịch | Có hỏng bộ nhớ | Không hỏng bộ nhớ | Tổng |
|---|---|---|---|
| Rust | 21 | 2.575 | 2.596 |
| Zig | 10 | 421 | 431 |
- 21 trường hợp hỏng bộ nhớ của trình biên dịch Rust không phải là hỏng logic bên trong chính trình biên dịch
- borrow checker đã thực hiện đúng vai trò mong đợi
- Chúng là các lỗi miscompile khiến mã máy được tạo sai làm hỏng bộ nhớ trong chương trình đã biên dịch
- Trong 10 trường hợp hỏng bộ nhớ của trình biên dịch Zig, 8 trường hợp cũng là miscompile
- 2 trường hợp còn lại là use-after-free trong mã báo lỗi
- Tên tệp trong thông báo lỗi của
roc checkvàroc bundlebị vỡ thành ký tự thay thế U+FFFD - Nếu là borrow checker của Rust thì cả hai lỗi này đều đã được phát hiện
- Tên tệp trong thông báo lỗi của
- Ảnh hưởng thực tế đến người dùng nếu chọn công cụ khác sẽ như sau
| Lựa chọn công cụ | Ảnh hưởng thực tế đến an toàn bộ nhớ |
|---|---|
Zig ReleaseFast |
2 lỗi khiến tên tệp không hiển thị trong một số thông báo lỗi |
Zig ReleaseSafe |
2 lỗi khiến một số lỗi bị panic và không thể hiển thị tên tệp |
| Rust borrow checker | Ngăn cả 2 lỗi |
- Xét trong 18 tháng, hàng trăm nghìn dòng mã và hàng trăm báo cáo lỗi, khác biệt giữa ba lựa chọn này về thực chất không lớn đối với dự án
- Bun xử lý đồng thời các giá trị JavaScript do garbage collector theo dõi và các giá trị được quản lý thủ công, nên use-after-free, double-free và quên giải phóng chiếm tỷ trọng lớn
- Trình biên dịch Roc không tích hợp với JavaScript hay bộ gom rác có theo dõi nào khác, nên không gặp cùng kiểu vấn đề quản lý vòng đời
- Roc cần công cụ để tìm lỗi bộ nhớ trong mã đầu ra được sinh ra hơn là lỗi bộ nhớ bên trong chính trình biên dịch, và nhóm sau nằm ngoài phạm vi của borrow checker
Kết quả thực tế về thời gian build
zig build --watch -fincrementalhiện build lại thay đổi trong khoảng 35ms trên khoảng 450 nghìn đến 460 nghìn dòng mã Zig- Zig 0.16.0 bản ổn định có một lỗi làm hỏng
-fincrementaltrong codebase của Roc- Bản sửa đã có, nhưng để dùng thì phải chuyển sang bản prebuild Zig 0.17.0 có thay đổi không tương thích ngược
- Vì cũng phải vendor các dependency liên quan và nâng cấp chúng lên 0.17.0, nên dự án quyết định chờ bản stable tiếp theo
- Kết quả đo trên máy desktop Intel và Ubuntu 26 như sau
| Trình biên dịch Roc | Quy mô mã | Build nguội | Build tăng dần |
|---|---|---|---|
| Bản gốc dựa trên Rust 1.85.0 | 354K | 32,4 giây | 10,0 giây |
| Bản gốc dựa trên Rust 1.97.0 | 354K | 25,4 giây | 3,4 giây |
| Bản Zig 0.16.0 tại thời điểm tương đương tính năng | 320K | 39,6 giây | 8,6 giây |
| Bản tái viết hiện tại dùng Zig 0.17.0 | 464K | 32,1 giây | 0,035 giây |
- Ở thời điểm tương đương tính năng, các đầu ra gần như không đổi cũng bị build lại mỗi lần, còn hiện nay chỉ được tạo lại khi cần
- Thay đổi này khiến mã tăng khoảng 50%, nhưng build nguội lại nhanh hơn
- Khi chuyển từ Rust 1.85.0 sang 1.97.0, build tăng dần giảm từ 10 giây xuống 3,4 giây, tức cải thiện khoảng hai phần ba trong 18 tháng
- Mức 35ms của Zig bằng khoảng 1/100 của 3,4 giây, và đạt được trên codebase lớn hơn khoảng 50% so với phần mã được đo bên phía Rust
- Hiện tại
-fincrementalchỉ hoạt động trên x86-64, trong khi nhiều người đóng góp dùng Mac nền tảng ARM, nên lợi thế thời gian build vẫn chưa được tận dụng trọn vẹn
Cấu trúc không con trỏ và giải tuần tự hóa không cần phân tích
- Bộ nhớ đệm trên đĩa mới sử dụng trực tiếp cách bố trí bộ nhớ tối ưu cho thực thi làm định dạng trên đĩa, giống như cách được dùng trong trình biên dịch Zig và phát triển game
- Mọi cấu trúc dữ liệu của trình biên dịch đều được biểu diễn bằng mảng chỉ mục 32 bit thay vì con trỏ, và ở nhiều nơi sử dụng dạng mảng struct
- Giảm mức sử dụng bộ nhớ và tăng tốc độ thực thi
- Có thể ghi trực tiếp cấu trúc dữ liệu ra đĩa mà không cần chuyển sang định dạng tuần tự hóa riêng
- Khi giải tuần tự hóa, không phân tích các byte trên đĩa
- Đọc các byte vào bộ nhớ
- Sắp xếp lại một phần để các cấu trúc dữ liệu hiện có trỏ tới các mảng mới
- Sau đó có thể dùng ngay lập tức
- Tốc độ trên thực tế gần như bị giới hạn bởi tốc độ I/O khi đọc byte từ đĩa vào bộ nhớ
- Nếu dữ liệu đã có trong bộ nhớ đệm đĩa của hệ điều hành, kết quả build trước đó được nạp lại với tốc độ xấp xỉ
memcpy
- Nếu dữ liệu đã có trong bộ nhớ đệm đĩa của hệ điều hành, kết quả build trước đó được nạp lại với tốc độ xấp xỉ
roc checklưu các kết quả như phân tích cú pháp và kiểm tra kiểu xuống đĩa ở lần chạy đầu tiên- Nếu mã nguồn đầu vào không thay đổi, ở lần chạy thứ hai dữ liệu cấu trúc sẽ được chuyển thẳng từ đĩa vào bộ nhớ
roc testcũng lưu bộ nhớ đệm kết quả kiểm thử của các hàm thuần xác định- Bộ nhớ đệm hoạt động theo từng tệp, nên khi thay đổi một tệp thì chỉ tệp đó và các tệp phụ thuộc mới được xử lý lại
- Cách này khả thi vì toàn bộ trình biên dịch dùng chỉ mục thay cho con trỏ; với cấu trúc phổ biến dựa trên con trỏ, giải tuần tự hóa không cần phân tích là không thể
-
Giới hạn an toàn của cấu trúc dựa trên chỉ mục
- Cũng như con trỏ có thể trỏ sai địa chỉ, chỉ mục cũng có thể truy vấn sai mảng và đọc các byte tùy ý
- Rust borrow checker xử lý vòng đời con trỏ, nhưng không kiểm tra chỉ mục nào thuộc về mảng nào
- Nếu biết trước số lượng mảng cần thiết,
compact_arenacủa Rust có thể tạo type tag bằng macro để ngăn truy vấn sai mảng - Với Roc, số lượng mảng thay đổi theo số module nên không thể áp dụng kỹ thuật này, vì vậy
compact_arenacũng đánh dấuSmallArena::newlàunsafe - Việc tạo một arena rỗng tự nó không tạo ra rủi ro, nhưng rủi ro thực sự nằm ở thao tác lập chỉ mục mảng được thực hiện rất thường xuyên
- Safe Rust hiệu quả khi giả định
unsafelà nhỏ và được cô lập, nhưng với Roc, nơiunsafexuất hiện rộng khắp, giả định này không còn đúng
Hệ sinh thái Zig phù hợp với Roc
- Với Bun,
Dropcủa Rust, vốn chỉ chạy mã dọn dẹp một lần, rất hữu ích vì phải tương tác giữa JavaScript và bộ nhớ quản lý thủ công - Ngược lại, Roc muốn dùng arena riêng biệt cho từng module và từng giai đoạn biên dịch, nên các package Rust giả định allocator toàn cục và
Dropngầm trở nên bất tiện - Trong hệ sinh thái Zig, API truyền allocator một cách tường minh là phổ biến, nên rất hợp với cách quản lý bộ nhớ của Roc
- Hệ sinh thái Rust phù hợp hơn với cấu trúc Bun muốn, còn hệ sinh thái Zig phù hợp hơn với cấu trúc Roc muốn
-
Tái sử dụng bộ tuần tự hóa LLVM bitcode
- LLVM là phụ thuộc cốt lõi của trình tối ưu hóa Roc và thực hiện tối ưu hóa bổ sung sau khi Roc tự tối ưu hóa
- LLVM thường xuyên phá vỡ tương thích API quan trọng, nên việc nâng cấp phiên bản tốn khá nhiều thời gian và chi phí
- Định dạng bitcode được tuần tự hóa của LLVM thì ổn định và tương thích ngược, nên nếu dùng bộ tuần tự hóa riêng có thể tránh được các thay đổi của API C++
- Để làm vậy, cần một bộ tuần tự hóa bitcode viết thủ công, tách rời khỏi thư viện LLVM C++
- Triển khai đã được biết đến trước đó nằm trong trình biên dịch Zig, và trình biên dịch mới của Roc tái sử dụng phần mã Zig đó
- Nguồn phụ thuộc lớn nhất mà Roc nhận từ hệ sinh thái Zig không phải là các package thông thường mà là chính trình biên dịch Zig
Những tính năng Rust còn nhớ khi ở Zig
- Trong việc hiện thực trình biên dịch cần kiểm soát cấp phát tường minh, nhưng trong kiểm thử thì cấp phát/giải phóng tự động của Rust lại tiện hơn
- Allocator dùng cho kiểm thử của Zig có thể phát hiện rò rỉ bộ nhớ, và cũng phát hiện được rò rỉ trong mã Roc đã biên dịch
- Đổi lại, ở mỗi bài kiểm thử phải viết chính xác
initvàdefer deinit; chỉ cần sai một chỗ là kiểm thử sẽ thất bại vì rò rỉ
comptimecủa Zig có phần chồng lấp với đa hình tham số và đa hình ad-hoc, nhưng vẫn thấy nhớ cả hai dạng đa hình đó- Trait
Allocatorcủa Rust có thể nhậnself - Các triển khai như
ArenaAllocatorcủa Zig phải nhận con trỏanyopaquerồi ép kiểu về đúng kiểu của chính nó
- Trait
- Không có trường struct riêng tư, nên không thể dùng lỗi biên dịch để chặn truy cập trực tiếp vào các trường lẽ ra không nên được truy cập
- Khi xem xét diff mã, chỉ thấy truy cập trường mà không thấy tài liệu của struct gốc, nên lần nào cũng phải kiểm tra riêng
- Đôi khi vẫn nhớ tính nhất quán của Rust khi hàm, biến và hằng số đều dùng
snake_case unsafevà borrow checker có cái giá phải trả, nhưng cũng mang lại cảm giác yên tâm vì chỉ cần lo một số vấn đề nhất định bên trong các khốiunsafe- Điều này không có nghĩa là cần thêm tính năng tương tự vào Zig
- Trong Zig, việc phát hiện mã chết muộn hơn so với Rust xảy ra khá nhiều
- Công cụ tích hợp của Zig và
tidy.zigcủa TigerBeetle cũng không bắt được một phần mã chết - Mã chết không được sinh vào binary nên không ảnh hưởng tới người dùng, nhưng lại bất lợi cho việc quản lý codebase
- Công cụ tích hợp của Zig và
- Trong Rust, việc nâng cấp phiên bản nhỏ và thay đổi edition phần lớn đều diễn ra suôn sẻ
- Hiện tại Zig không hướng tới tương thích ngược, và đây là điều đã được dự đoán nên không phải vấn đề lớn, nhưng trải nghiệm nâng cấp đơn giản của Rust vẫn tốt hơn
Những điểm hài lòng ở Zig
- Zig có sức hút của một thiết kế theo hướng cắt giảm, giảm bớt các công cụ quen thuộc kiểu lập trình hàm để đổi lấy những thuộc tính khác
- Không có macro, và nhiều vấn đề, bao gồm đa hình tham số, có thể được giải quyết bằng
comptimehoặc hàm thông thường - Có thể kiểm soát bố trí dữ liệu rất chi tiết
- Dùng được các kiểu số nguyên không phải lũy thừa của 2 như
u7,u5mà không cần xử lý bit riêng - Hỗ trợ
packed structmặc định - Có thể inline hàm ở vị trí gọi thay vì vị trí khai báo
- Có thể dùng các tính năng mà trong Rust cần crate dựa trên macro, nhưng ở đây không cần thêm phụ thuộc
- Dùng được các kiểu số nguyên không phải lũy thừa của 2 như
- Chuỗi công cụ build của Zig rất phù hợp để tạo binary độc lập cho Alpine Linux và WebAssembly
- Nó cũng xử lý được kiểu build đặc biệt là biên dịch các builtins tương ứng với thư viện chuẩn của Roc thành blob nhị phân opaque rồi nhúng vào file thực thi cuối cùng
- Uber cũng dùng chuỗi công cụ Zig trong hạ tầng build dù không dùng ngôn ngữ Zig
- Trong cơ chế xử lý lỗi của Zig, cả việc cấp phát heap thất bại cũng được xem là lỗi không gian người dùng thông thường
- Roc cũng dùng một chiến lược tương tự, tận dụng kiểu hợp ẩn danh và payload để lỗi tích lũy một cách tự nhiên
- Tác giả thích cách làm của Zig và Roc hơn so với
anyhow,thiserrorvà cách xử lý dựa trênResultmặc định của Rust - Về cú pháp lan truyền lỗi, tác giả thích toán tử hậu tố
?của Rust hơntrycủa Zig, nên Roc cũng chọn toán tử hậu tố? - Xét toàn bộ dự án, bao gồm API allocator chi tiết và mã tái sử dụng cho trình biên dịch hiệu năng cao, tác giả rất hài lòng với lựa chọn Zig
Các bước tiếp theo của Roc
- Dự kiến phát hành 0.1.0 của trình biên dịch mới vào nửa sau năm nay, đây sẽ là bản phát hành được đánh số đầu tiên của Roc
- Ngay cả trước khi phát hành, vẫn có thể thử bản dựng Nightly, nhưng hiện vẫn còn nhiều lỗi, tính năng chưa hoàn thiện và tài liệu chưa đầy đủ
- Roc Programming Language Foundation là tổ chức phi lợi nhuận 501(c)(3) tại Mỹ, và tiền quyên góp chủ yếu được dùng để trả thù lao cho những người đóng góp
- Có thể theo dõi tiến độ phát triển trong tương lai và đặt câu hỏi tại Roc Zulip
2 bình luận
Các ý kiến trên Lobste.rs
Nhìn trên số liệu, 35ms của Zig nhanh hơn gần 100 lần so với 3,4 giây của Rust và mã cũng nhiều hơn khoảng 50%, nhưng trong phát triển thực tế có vẻ sự khác biệt đó bị phóng đại
Nếu không build lại trình biên dịch mỗi phút mà build mỗi 10 phút, thời gian tiết kiệm được chỉ khoảng 3 giây, và 3,4 giây cũng đã đủ nhanh
Khi codebase lớn lên Rust có thể chậm hơn, nhưng thời gian cold build lại tệ đi; cộng thêm việc thay laptop mỗi 2 năm và các cải tiến của chính trình biên dịch, khó có thể khẳng định thời gian build của Rust sẽ tiếp tục tăng trong dài hạn
Chế độ
checkvà LSP của Rust nhanh hơn biên dịch lại toàn bộ, nhưng không đạt cùng mứcDùng tổ hợp
--watch -fincrementalcó thể biên dịch lại mỗi khi lưu, đem lại phản hồi rất nhanh và thường xuyênTôi đã chuyển dự án cá nhân từ Rust sang Zig và cũng đã dùng Rust nhiều năm trong công việc; tốc độ biên dịch của Zig thật sự là một thay đổi giúp dễ thở hơn
35ms thì nhanh đến đáng kinh ngạc
Chỉ relink thôi tưởng cũng lâu hơn thế, nên tôi tò mò trình biên dịch thực sự đang làm gì
Nếu thay đổi phần triển khai của một hàm thì chỉ cần chèn assembly mới vào binary hiện có, nhưng inlining, thay đổi chữ ký hàm, dependency, hay tái bố trí khi thiếu chỗ đều cần phân tích thêm
Khi sửa hoặc thêm hàm, nó tìm một vùng trống đủ lớn trong section
.textcủa file thực thi đầu ra rồi ghi mã máy mới vào đóNếu không đủ chỗ, có thể phải mở rộng
.textvà di chuyển dữ liệu khác, nhưng họ mở rộng section theo cấp số nhân để phân bổ chi phíKhi cần, symbol table và relocation entry cũng được thêm vào; vì cũng chừa sẵn vùng trống cho những phần này, nên sau khi thao tác xong chỉ cần đóng file là kết thúc
Ước tính dựa trên kết quả Tracy tôi thấy gần đây, trong tổng 35ms thì linker chỉ dùng khoảng 1ms
Con số dùng
unsafekhoảng 1.200 lần trong 300 nghìn dòng Rust nhiều hơn tôi dự đoánVí dụ Inko chỉ có 162 biểu thức
unsafe { ... }, và 87 hàmunsafeđược lộ ra cho mã sinh thông qua C ABI, trong khi tổng mã Rust khoảng 88 nghìn dòngTuy nhiên, do cách Inkwell hoạt động, toàn bộ backend LLVM về cơ bản là một vùng
unsafekhổng lồ, nên khó rút kết luận chỉ từ so sánh đơn giảnTôi chỉ đánh dấu
unsafecho những hàm thực sự gây hại đến an toàn bộ nhớ, như thao tác trực tiếp với con trỏ, nhưng lập trình viên khác cũng có thể đánh dấu như một gợi ý rằng cần làm một số việc trước để tránh panicCon số
unsafexuất hiện 40 nghìn lần trong thư viện chuẩn và trình biên dịch Rust là không chính xácĐó là kết quả tính cả các từ trong test và chú thích; phần lớn nằm trong thư viện chuẩn, còn trong chính trình biên dịch thì kể cả chú thích và test cũng dưới 2 nghìn lần
Trong thời gian đóng góp cho rustc, tỷ lệ PR của tôi có chứa
unsafecòn chưa tới 1%, cho thấy mã không an toàn bên trong trình biên dịch khá hiếmViệc thư viện chuẩn, nơi triển khai runtime nền tảng mà toàn bộ Rust phụ thuộc vào, có nhiều
unsafelà điều tự nhiên; loại mã này trong ngôn ngữ nào cũng không an toàn, dù được thể hiện tường minh hay ngầm địnhTrình biên dịch đó nhỏ hơn rustc khoảng 10 lần nhưng lượng dùng
unsafelại tương tự, nên khó nói là tràn lan, song chắc chắn sẽ gặp thường xuyên; tôi tò mò vì sao nó cần nhiều hơn rustc đến vậyChúng tôi chưa phân tích riêng phần
unsafeliên quan đến thư viện chuẩn trong hai codebase, nhưng nếu triển khai trình biên dịch mới bằng Rust, tôi dự đoán sẽ có nhiềuunsafehơn rustc do cấu trúc dùng caching và chỉ mục thay cho con trỏChúng tôi không có ý hạ thấp công việc của nhóm Rust; mục đích là trình bày lựa chọn và tiến độ của mình trong khi vẫn tôn trọng thành quả của các dự án khác
Đây là bài viết đánh giá cân bằng, không che giấu ưu nhược điểm của cả Rust lẫn Zig; đặc biệt thành thật ở kết luận rằng ngay cả sau 18 tháng viết hàng trăm nghìn dòng mã và xử lý hàng trăm lỗi, nếu chọn một ngôn ngữ khác trong số các phương án thì kết quả dự án có lẽ cũng không khác biệt đáng kể
Tuy nhiên, phần giải thích rằng trong các trình biên dịch sinh mã máy như Roc hay rustc, các thao tác không an toàn về bộ nhớ chiếm một phần lớn công việc thì khá khó hiểu
Cũng có nhiều trình biên dịch được viết bằng OCaml hoặc Haskell, và bản thân việc sinh mã máy chỉ là sắp xếp các byte vào một vector rồi ghi ra tệp, nên không có lý do gì phải không an toàn
Nếu là thông dịch hoặc biên dịch JIT thì có thể hiểu được, nhưng tôi tò mò vì sao điều đó cũng cần thiết trong biên dịch thông thường
Rủi ro phát sinh khi thực thi mã máy đã được sinh ra, và vì trình biên dịch thực tế thường vừa sinh mã máy vừa thực thi nó, nên tôi đã diễn đạt là một phần lớn công việc
Ngoài thông dịch và JIT, các trường hợp này còn bao gồm việc đánh giá mã người dùng tại thời điểm biên dịch, như
const fncủa Rust hoặc các biểu thức có thể được kéo lên cấp cao nhất trong Roc, hoặc chạy kiểm thử rồi kiểm tra đầu ra để quyết định nội dung sẽ hiển thị cho người dùngDù bộ nhớ bị hỏng nằm trong tiến trình trình biên dịch hay trong chương trình được sinh ra, tiêu chí là nguyên nhân khiến bộ xử lý hoạt động sai là các lệnh do trình biên dịch tạo ra, và nơi cần sửa cũng là mã của trình biên dịch
Các thành phần như mã liên kết bên ngoài và bộ thu gom rác cũng cần rất nhiều
unsafetrong Rust, làm triệt tiêu đáng kể lợi thế của Rust an toànCó thể chỉ viết trình biên dịch bằng một ngôn ngữ an toàn, nhưng hiệu năng là vấn đề; Zig và trình biên dịch Roc sử dụng rộng rãi cấu trúc mảng (SoA) và chỉ số mảng thay cho con trỏ
Nếu triển khai điều này bằng Rust thì sẽ phải vòng qua borrow checker, làm mất các lợi ích an toàn liên quan
Quan hệ vòng đời trong trình biên dịch hóa ra đơn giản đến bất ngờ: ở một giai đoạn, có thể cấp phát, tạo và sửa dữ liệu trong arena; chuyển sang giai đoạn tiếp theo dưới dạng chỉ đọc; rồi hủy toàn bộ arena
Phần phức tạp hơn là biên dịch tăng dần và liên kết, trong đó đọc trạng thái từ đĩa và sửa binary tại chỗ; quá trình này có thể gây hỏng trạng thái, lỗi, biên dịch sai và vấn đề bộ nhớ, nhưng tách biệt với an toàn bộ nhớ của chính tiến trình trình biên dịch
An toàn và tính đúng đắn là khái niệm rộng hơn rất nhiều so với an toàn bộ nhớ bên trong chương trình, đặc biệt khi cần thực hiện một cách an toàn và đúng đắn những tác vụ khó biểu đạt bằng Rust
Thật vui khi được giới thiệu là tác giả của
compact_arena, nhưng cách bài viết mô tả việc thư viện này dùngunsafelà không đúngMục đích của
compact_arenalà bảo đảm không thể lạm dụngnewtrong Rust an toàn, từ đó làm cho việc lập chỉ mục trở nên an toànMacro
mk_arenacó thể được gọi từ mã Rust an toàn ngay cả bên trong vòng lặp khi không biết trước số lượng arena cần thiếtnewđược dùng an toàn, nhưng trong mục an toàn của https://docs.rs/compact_arena/0.5.0/… có ghi rằng tag truyền vào constructor là nền tảng của kỹ thuật lập chỉ mục, và nếu dùng giá trị này với arena khác thì chỉ số của hai arena sẽ bị trộn lẫn, dẫn đến truy cập ngoài phạm vi và hành vi không xác địnhÝ của tôi không phải là bản thân lời gọi
new(), mà là rủi ro phát sinh khi lập chỉ mục trên giá trị được tạo bởinew(); và tôi cho rằng nơi cần audit và đánh dấuunsafecũng là chính chỗ gọi lập chỉ mục thực tếTuy nhiên, cũng có khả năng tôi đã hiểu sai thiết kế
Theo tôi biết, với Python, để phản ánh mã mới thì phải khởi động lại chương trình, còn hot reloading cần các phần mở rộng không chuẩn và phải viết mã cẩn thận
Hot reloading phổ biến hơn trong các môi trường phát triển dựa trên image như Lisp hoặc Smalltalk, hoặc trong Erlang
importlib.reload()thì Python cũng có thể hot reload, nhưng tôi không rõ nó được dùng rộng rãi đến mức nàoTôi tò mò liệu có thể giảm thời gian build nếu không dùng
cargo buildhay khôngKhông chắc việc cấu hình Bazel lớn hơn hay nhỏ hơn so với chuyển sang Zig, và tất nhiên thời gian build không phải là lý do duy nhất cho việc đổi ngôn ngữ
Ý kiến trên Hacker News
Nhìn chung bài viết ổn, nhưng khó đồng ý với cách nói rằng trong các trình biên dịch tạo mã máy như Roc hay rustc, các thao tác không an toàn về bộ nhớ chiếm phần lớn công việc
Việc vá nhị phân khi đang chạy hoặc nạp lại mã có thể cần mã không an toàn, nhưng trong quy trình tạo tệp thực thi thông thường thì bản thân việc sinh mã máy không có lý do gì phải là không an toàn
Ngược lại, khả năng gặp mã không an toàn trong runtime của ngôn ngữ còn cao hơn
Tuy vậy trên thực tế nhiều trình biên dịch vừa tạo mã máy vừa thực thi trực tiếp, nên mới diễn đạt là “chiếm phần lớn công việc”, nhưng không có nghĩa trình biên dịch bắt buộc phải làm cả hai việc
Ý ở đây còn bao gồm đánh giá mã người dùng tại thời điểm biên dịch, như
const fncủa Rust hay các biểu thức có thể được kéo lên top-level trong Roc, cũng như chạy test rồi kiểm tra đầu ra để quyết định nội dung hiển thịLập luận này cũng giống như nói rằng vì dù thắt dây an toàn vẫn có thể đập đầu, nên dây an toàn chỉ phiền phức và không nên dùng
Với mã hiệu năng cực hạn, từ cấu trúc dữ liệu, thuật toán cho tới chiến lược cấp phát bộ nhớ đều sẽ khác đi, và TigerBeetle nổi tiếng với cách cấp phát toàn bộ bộ nhớ ngay từ lúc khởi động
Trình biên dịch Roc cũng muốn chọn những đánh đổi tương tự Zig, nên việc thấy nhiều mẫu hình chung là điều tự nhiên
Không thấy có cơ sở cho nhận định rằng
ReleaseSafedùng kiểm tra runtime để bắt use-after-freeTôi đã xem tài liệu Zig về các kiểm tra an toàn bộ nhớ runtime nhưng không tìm được nội dung liên quan khi tìm
use-after-free,UaF, haysafety-checked; ngay cả khi dùngDebugAllocatortrong bản build phát hành thì cũng không phát hiện việc này một cách đáng tin cậyBài viết liên quan được tổng hợp tại https://landaire.net/memory-safety-by-default-is-non-negotia...
Đây là đánh giá tôi có được từ việc tự tay viết code trước thời AI, dù có thể khi kết hợp Zig với LLM thì có gì đó khác đi, nhưng càng đọc bài lại càng thấy thêm những nhận định diễn đạt kỳ lạ nên khó tin tưởng
Bài này nghe giống một bài viết nhằm hợp thức hóa một cuộc tranh cãi có sẵn hơn là một đánh giá kỹ thuật chân thành, nhưng vì tôi thích những bài viết và ngôn ngữ khác thường, đồng thời cũng có phần phản cảm với làn sóng AI thái quá, nên tạm thời vẫn muốn nhìn nó theo hướng thiện chí
ReleaseSafechủ yếu thêm kiểm tra biên và gây panic ở những đoạn mã không thể tới đượcCó vẻ Zig không cung cấp temporal memory safety
Việc một OCaml đã trưởng thành, linh hoạt và đủ giàu sức biểu đạt được dùng để kiểm chứng nguyên mẫu nhưng không được chọn làm ngôn ngữ triển khai cuối cùng là điều khá thú vị
Cũng nghi ngờ liệu incremental build của Zig có thực sự nhanh hơn dune một cách đáng kể hay không, và dù cross-compilation là một ưu điểm, phần “vì sao là Zig” lại không nhắc tới điều đó
Tôi cũng tò mò liệu khả năng kiểm soát bộ nhớ chi tiết có thực sự quan trọng với trình biên dịch đến vậy không, và giống như Rust hay WASM từng khởi đầu với OCaml, các maintainer thường quyết định chuyển sang ngôn ngữ khác ở cột mốc nào
Incremental build của Zig rõ ràng là một tính năng mang tính quyết định, và tôi hiểu lựa chọn đổi ngôn ngữ để có được nó trong ngắn hạn
Nhưng ở trung hạn, tôi kỳ vọng Rust cũng sẽ sớm có tính năng tương tự trong tương lai gần
Tôi muốn tốc độ, nhưng không muốn đi nhanh rồi tự chuốc họa vào thân, và hiện đang tự làm một ngôn ngữ kết hợp sự an toàn của Rust, các tính năng của Zig, và runtime Go không có garbage collection
Hướng này có vẻ khả thi hơn, thậm chí có thể triển khai ở user space
Nếu đã thực hiện một so sánh mang tính khoa học thực sự trong quá trình chọn ngôn ngữ để viết compiler thì có lẽ sẽ thuyết phục hơn nhiều
Có vẻ họ xuất phát từ giả định chưa được kiểm chứng rằng compiler hiệu năng cao cần một ngôn ngữ hệ thống cấp thấp (https://www.roc-lang.org/faq#self-hosted-compiler), rồi đi đến kết luận rằng ngoài Rust thì lựa chọn duy nhất là Zig
Hiệu năng compiler bị chi phối bởi thuật toán; ngay cả các ngôn ngữ managed nhanh nếu dùng cùng thuật toán thì nhìn chung thời gian chạy thường cũng chỉ trong khoảng gấp đôi, trong khi chênh lệch hiệu năng do khác biệt thuật toán thì gần như không có giới hạn
Bản thân Zig là một phản ví dụ cho lý thuyết rằng viết compiler bằng ngôn ngữ cấp thấp sẽ khiến nó nhanh hơn, và mức khoảng 15 nghìn dòng mỗi giây của Roc không phải là nhanh. Còn có tài liệu cho thấy ngay từ năm 1998, compiler ML đã xử lý được 3 nghìn dòng mỗi giây (https://flint.cs.yale.edu/cs421/case-for-ml.html)
Có thể sẽ hữu ích hơn cho tương lai nếu tạm dừng công việc compiler hiện tại và tạo một compiler tự host cho một tập con Roc thật nhỏ có thể làm trong dưới 10 nghìn dòng
Khi đó, thay vì một implementation 300 nghìn dòng, có thể thử nhiều implementation khác nhau ở quy mô 10 nghìn dòng và kiểm chứng liệu ngôn ngữ cấp thấp có thực sự cần thiết cho mục tiêu hiệu năng hay không
Trong quá trình tự host, các tính năng Roc thực sự quan trọng sẽ bộc lộ rõ hơn, cũng sẽ viết được nhiều mã Roc hơn, và nếu cải thiện các tính năng đa dụng cần cho compiler thì các ứng dụng tầng dưới cũng sẽ cùng được hưởng lợi
Chỉ riêng việc ML đã từng được compile nhanh vào thập niên 1990 thì khó có thể dùng để đánh giá tốc độ compile của Roc ngày nay. Thiết kế ngôn ngữ tạo ra những ràng buộc rất mạnh lên các thuật toán cần dùng, và phần cứng hiện đại cũng phức tạp hơn nhiều
Roc có một mức độ overloading nhất định và dường như cũng có những thuật toán tinh vi để tránh cấp phát closure trên heap; các yêu cầu như vậy có thể tạo ra độ phức tạp thuật toán không thể loại bỏ
Khi đã chạm tới giới hạn tối ưu thuật toán thì thứ còn lại là giảm hệ số hằng số; và đặc biệt với các ngôn ngữ cấp cao có quản lý bộ nhớ, điều đó rõ ràng tạo ra một mức đáy cho việc có thể hạ thấp hệ số này đến đâu
Tôi đã thấy những trường hợp trong code thực tế mà việc trực tiếp kiểm soát bố cục bộ nhớ giúp cải thiện hiệu năng hơn 10 lần, và trong ngành game thì kiểu công việc này đôi khi chiếm phần lớn sự nghiệp. Một môi trường nơi chỉ cần một thuật toán khéo léo là mọi vấn đề hiệu năng đều biến mất thì khá xa rời thực tế
Build Rust là một trong những nguyên nhân gây lãng phí lớn dung lượng lưu trữ trên hầu hết mọi máy tính; chỉ cần build vài thư viện là hàng chục GB có thể tích lại rất nhanh
Có thể cấu hình thư mục build toàn cục để tái sử dụng dependency giữa các project, nhưng dù là giải pháp nào thì tốt hơn vẫn nên được cung cấp như hành vi mặc định
Tuy vậy vẫn cần dọn rác cache, và hiện đang gần hoàn tất một thay đổi mới về cách bố trí các output build trung gian để việc này trở nên dễ dàng hơn
node_modulesluôn bị chỉ trích trong hệ sinh thái JavaScript thì đây là một sự đối lập khá thú vịNgay cả trong một project Tauri mà code backend còn nhỏ hơn nhiều so với frontend, output build Rust vẫn là 9GB, còn
node_moduleschỉ có 550MBTôi đang hiểu Roc như một ngôn ngữ scripting được nhúng vào C ABI, và tôi tò mò về lĩnh vực ứng dụng thực tế của nó
Không rõ họ có định cạnh tranh với WASM trong môi trường plugin cung cấp một nền tảng Roc lớn hay không, và tại sao nhà phát triển ứng dụng lại có lý do để lộ ra một lớp Roc thay vì dùng WASM, nơi người viết plugin có thể dùng bất kỳ ngôn ngữ nào
Nếu đó là một ngôn ngữ ở cấp độ ứng dụng dùng một nền tảng Roc nhỏ, thì tôi cũng muốn biết liệu họ có định cạnh tranh với Gleam cho mã HTTP phía server và với Elm cho mã phía client hay không
Thời gian compile là một yếu tố bị đánh giá thấp nghiêm trọng. Việc phải chờ build C++ 10 phút mỗi lần là điều tôi khó chịu nhất, và nó phá vỡ hoàn toàn dòng tập trung khi phát triển
Chỉ cần chuyển từ file
.rssang file.tslà đã có cảm giác như vừa thay hẳn sang một chiếc máy tính khácTôi không trực tiếp dùng Zig, nhưng vẫn rất kỳ vọng vào một vài khả năng của nó
Đặc biệt là các game mới được viết bằng Zig dễ dùng hơn C, phần mềm phân tán mà TigerBeetle đã cho thấy tiềm năng, và lĩnh vực robotics mà cá nhân tôi quan tâm rất nhiều