Tối ưu hóa cấp thấp và Zig
(alloc.dev)- Tối ưu hóa cấp thấp là công việc giúp trình biên dịch hiểu rõ hơn ý định và ràng buộc của mã, và Zig rất phù hợp với mục đích này vì dễ biểu đạt rõ ràng thông tin về kiểu, căn chỉnh, alias và thời điểm biên dịch
- Ngay cả các trình biên dịch tối ưu hóa như LLVM cũng không phải lúc nào cũng tạo ra mã tốt nhất, nên ở các đoạn nghẽn cổ chai vẫn cần kiểm tra mã được sinh ra và điều chỉnh mã nguồn
- Zig có thể truyền
noalias,align, kích thước mảng cố định và kiểu phần tử tại thời điểm biên dịch, từ đó tạo ra mã vector hóa nhỏ hơn so với ví dụ JavaScript comptimecho phép chạy mã Zig thông thường tại thời điểm biên dịch, qua đó hỗ trợ metaprogramming như tạo hằng số, triển khai generic, phản chiếu kiểu và tối ưu hóa so sánh chuỗi- Điểm mạnh của Zig nằm ở việc thực thi tại thời điểm biên dịch được tích hợp vào ngôn ngữ, hơn là macro trực tiếp thay đổi AST; một số giá trị runtime cũng có thể được dispatch sang các hàm chuyên biệt hóa tại thời điểm biên dịch
Vì sao nên tin trình biên dịch nhưng vẫn phải kiểm chứng
- Tối ưu hóa không chỉ là kỹ thuật tạo ra chương trình chạy nhanh, mà còn gắn với giảm chi phí, cải thiện khả năng mở rộng và giữ cho hệ thống đơn giản
- Các trình biên dịch hiện đại tạo ra kết quả ấn tượng bằng backend như LLVM, nhưng trong một số tình huống vẫn sinh ra mã chưa tối ưu
- Lý do ngôn ngữ cấp thấp nhanh không chỉ là vì ít overhead từ garbage collection hay trình thông dịch, mà còn vì chúng có thể biểu đạt nhiều thông tin về ý định hơn để trình biên dịch hiểu được
- Trình biên dịch không thể tự thay đổi thuật toán hay paradigm lập trình, và nhìn chung thực hiện tối ưu hóa trong phạm vi hạn chế như vòng lặp
Ví dụ tìm giá trị lớn nhất trong mảng bằng JavaScript và Zig
- Ví dụ JavaScript lưu giá trị lớn nhất theo từng phần tử của hai mảng vào
xdưới dạngx[i] = y[i] > x[i] ? y[i] : x[i] - Với con người, đây là mã rõ ràng, nhưng bytecode do V8 sinh ra lại phình to
- Ví dụ Zig chỉ rõ hơn trong tham số hàm các thông tin cần thiết cho tối ưu hóa
noalias x:xkhông phải là alias của con trỏ khác*align(64): căn chỉnh 64 byte[65536]f64: kích thước mảng và kiểu phần tửconst: tham số chỉ đọc
- Nhờ thông tin này, trình biên dịch có thể tạo mã tốt hơn; trong ví dụ, assembly đã được vector hóa được sinh ra
- Mã Rust tương đương cũng sinh ra assembly gần như giống hệt
Điểm Zig thuận lợi cho tối ưu hóa và các giới hạn
- Zig cho phép cách biểu đạt dài dòng để truyền nhiều thông tin mã cho LLVM
- Các yếu tố chính Zig cung cấp liên quan đến tối ưu hóa gồm:
- Mô hình bộ nhớ của Rust cho phép trình biên dịch luôn giả định rằng tham số hàm không tạo alias, còn trong Zig phải chỉ định trực tiếp điều này
- Nếu trình biên dịch không thể biết tham số hàm Zig có alias hay không, một hàm Zig không có chú thích có thể chậm hơn hàm Rust
- Ngay cả nếu chỉ xét LLVM IR được chú thích tốt, Zig vẫn cho kết quả tốt, nhưng thế mạnh lớn hơn nằm ở thực thi tại thời điểm biên dịch
Vai trò của comptime
comptimecủa Zig là tính năng dành cho sinh mã tại thời điểm biên dịch- Những việc có thể làm tại thời điểm biên dịch gồm:
- Tạo hằng số và đưa vào binary
- Tránh phải viết lặp lại cùng một cấu trúc hashmap cho nhiều kiểu dữ liệu
- Dẫn dắt tối ưu hóa để loại bỏ mã không cần thiết dựa trên dữ liệu đã biết tại thời điểm biên dịch
- Kiểm tra, phản chiếu và tạo kiểu để triển khai generic
- Mã
comptimelà mã Zig thông thường được thực thi tại thời điểm biên dịch và không thể có tác dụng phụ như network IO - Máy mô phỏng tại thời điểm biên dịch khớp với mục tiêu biên dịch
- Gần như mọi mã Zig đều có thể được chạy tại thời điểm biên dịch bằng
comptime, và mọi kiểu đều có thể được kiểm tra, phản chiếu và tạo ra tại thời điểm biên dịch
Khác gì với macro
- Mục đích của
comptimetương tự macro, nhưng cách hoạt động thì khác - Một số macro thay thế văn bản thô, một số trực tiếp sửa AST của chương trình
comptimecủa Zig không trực tiếp thay đổi AST, và cũng không có tính năng như macro nối token- Vì Zig hướng tới một ngôn ngữ dễ đọc, nó không phù hợp với kiểu macro tạo hoặc sửa biến trong các scope không liên quan
- Những việc macro làm được nhưng Zig
comptimekhông trực tiếp làm được gồm:- Định nghĩa macro khác
- Thay đổi AST
- Trực tiếp triển khai mini-language hoặc DSL
- Tuy vậy, vẫn có thể tạo DSL trong Zig; hàm
printcủa Zig phân tích chuỗi định dạng bằngcomptimeđể xây dựng đồ thị hàm dùng cho tuần tự hóa dữ liệu - Ví dụ gồm TigerBeetle account testing DSL, comath, zilliam
Tối ưu hóa so sánh chuỗi bằng comptime
- So sánh chuỗi thông thường trả về
falsenếu độ dài khác nhau; nếu độ dài bằng nhau thì so sánh lần lượt từng byte - Cách này phải đọc byte từ cả hai chuỗi để so sánh
- Vì một chuỗi thường đã được biết tại thời điểm biên dịch, trong Zig có thể yêu cầu một tham số là
comptimefn staticEql(comptime a: []const u8, b: []const u8) bool
- Khi so sánh với chuỗi tĩnh như
"Hello!\n", trình biên dịch sinh mã gồm so sánh độ dài và so sánh từng byte với hằng số - Mục đích của phần này không chỉ là cho thấy một tối ưu hóa mà trình biên dịch có thể tự làm, mà còn cho thấy
comptimecó thể ép buộc chuyển đổi để mở ra các cơ hội trình biên dịch không nhìn thấy
So sánh theo đơn vị lớn hơn và tận dụng SIMD
- So sánh chuỗi
comptimeđơn giản vẫn thực hiện so sánh theo từng byte - Phiên bản cải tiến dùng
std.simd.suggestVectorLength(u8)hoặc@sizeOf(usize)để xác định kích thước khối so sánh - Sau khi kiểm tra độ dài chuỗi trước, nó tính số khối lớn có thể so sánh và số byte còn lại
- Mỗi khối được
@bitCastsang kiểu số nguyên tạo bằngstd.meta.Int(.unsigned, block_len * 8)rồi đem so sánh - Các byte còn lại cũng được so sánh bằng một kiểu số nguyên riêng
- Assembly được sinh ra trong ví dụ
"Hello, World!\n"sử dụng thanh ghi lớn hơn và giảm số nhánh điều kiện - Với so sánh chuỗi dài hơn, assembly được sinh ra sẽ dùng thanh ghi SIMD lớn hơn
Kết hợp giá trị runtime với chuyên biệt hóa tại thời điểm biên dịch
comptimecủa Zig không chỉ giới hạn ở dữ liệu đã biết tại thời điểm biên dịch- Trong các trường hợp đơn giản, có thể tạo sẵn nhiều thủ tục tại thời điểm biên dịch rồi dynamic dispatch đến thủ tục phù hợp theo giá trị runtime
- Mã ví dụ xử lý các giá trị trong phạm vi
inline 0...100ởswitch (runtime_val)bằngstaticFn(comptime_val), còn các giá trị còn lại bằngruntimeFn(runtime_val) - Nếu không muốn tăng kích thước binary, có thể fallback về triển khai runtime hoàn toàn
Kết luận
comptimecủa Zig đóng vai trò thay thế template, macro, generic và sinh mã thủ công- Có thể làm việc tương tự bằng các ngôn ngữ khác, nhưng trong Zig,
comptimeđược tích hợp vào ngôn ngữ một cách tự nhiên hơn - Zig giúp viết mã hiệu năng cao dễ hơn trong những tình huống thực sự hữu ích, đối lập với Turing tar-pit, nơi mọi thứ đều có thể nhưng các việc thú vị lại khó làm
- Về các cuộc chiến ngôn ngữ, vẫn đồng thời tồn tại góc nhìn rộng rằng chỉ cần Turing-complete là đủ, và quan điểm rằng con người có thể có ngôn ngữ mình ưa thích
- Những câu xem bản thân ngôn ngữ là đối tượng benchmark, như “C nhanh hơn Python”, có thể sai; đối tượng benchmark thực sự không phải là ngôn ngữ mà là mã và triển khai cụ thể
1 bình luận
Ý kiến trên Hacker News
Điều hấp dẫn nhất ở Zig là việc nó hướng tới hệ thống build dễ dùng, biên dịch chéo và tốc độ lặp nhanh
Là nhà phát triển game nên tôi có yêu cầu về hiệu năng, nhưng tôi cho rằng hầu hết ngôn ngữ đều cho hiệu năng cần thiết đủ tốt, nên đó không phải ưu tiên số một khi chọn ngôn ngữ
Có thể viết mã mạnh mẽ bằng bất kỳ ngôn ngữ nào, nhưng điều cốt lõi là chọn một framework có khả năng thích ứng tương lai cao để có thể duy trì mã theo kiểu mô-đun trong hàng chục năm
C/C++ từng là câu trả lời mặc định vì được hỗ trợ ở khắp nơi, và tôi có cảm giác Zig cũng có thể đạt tới mức đó
Zig không thân thiện với đóng gói, và không thể đặt thành viên của struct là riêng tư: https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
Trích dẫn cốt lõi là quan điểm rằng “field riêng tư và getter/setter là một anti-pattern do Java phổ biến hóa; field là dữ liệu đang tồn tại, nên hãy đặt tên cẩn thận và ghi tài liệu như một phần của API công khai”
Nếu không thể ẩn biểu diễn bên trong, sẽ khó tạo đúng hợp đồng API, vốn là nền tảng của tính mô-đun trong phần mềm; cần có khả năng thay đổi biểu diễn bên trong mà không làm hỏng mã của người dùng
Lập trường của Zig dường như là không nên có cái gọi là biểu diễn bên trong riêng biệt, mà chính biểu diễn đó phải được công khai, ghi tài liệu và bảo đảm; tôi hy vọng một ngày nào đó họ sẽ đảo ngược quyết định này và hỗ trợ field riêng tư
Rất nhiều thứ chạy được ngay, và tôi còn có thể dùng GDB cũ để debug cả những lỗi kỳ quặc
Tôi cũng đã bị Zig thuyết phục, và có viết về việc đó ở đây: https://news.ycombinator.com/item?id=44211041
Tôi không hiểu lắm vì sao người ta ghét nó đến vậy
Generic khó nhìn thì C# hay TypeScript cũng có, còn borrow checker là một khái niệm có thể hiểu được nếu từng làm việc ở mức thấp
Trong các công cụ được xây trên Zig, thứ khiến tôi thật sự ấn tượng là bun; sau khi dùng bun, cuộc sống của tôi đơn giản hơn rất nhiều
Có thể nói điều tương tự về uv được viết bằng Rust
Thông thường console không thích những thứ không phải C/C++, nhưng vì Zig có thể chuyển thành C nên có lẽ nó sẽ không bị loại trừ hoàn toàn
Về tuyên bố “ngay cả compiler hiện đại cũng phá vỡ đặc tả ngôn ngữ (Clang giả định rằng mọi vòng lặp không có side effect đều kết thúc)”, tôi không nghi ngờ việc compiler đôi khi phá vỡ đặc tả, nhưng trong trường hợp đó Clang đúng, ít nhất là theo chuẩn từ C11 trở đi
C11 nói rằng với các vòng lặp có biểu thức điều khiển không phải là biểu thức hằng, và không thực hiện nhập/xuất, truy cập volatile, đồng bộ hóa hay thao tác nguyên tử, implementation có thể giả định rằng chúng sẽ kết thúc
Vì vậy vòng lặp vô hạn đơn giản trong C như
for (;;);phải thực sự được biên dịch thành vòng lặp vô hạn, vàloop {}ít mập mờ hơn của Rust cũng phải như vậyNhưng LLVM được tạo bởi những người đôi khi quên rằng họ không phải lúc nào cũng đang làm compiler C++, nên đã có lúc khi Rust yêu cầu “cho tôi vòng lặp vô hạn”, LLVM lại nói “theo C++ thì thứ đó không tồn tại, nên tôi sẽ tối ưu hóa”, và đó là áp dụng sai cho ngôn ngữ khác
Không nhất thiết phải có comptime để inline hóa và bung phép so sánh chuỗi
Trong C cũng làm được: https://godbolt.org/z/6edWbqnfT
Tôi đã sửa lỗi chính tả
Ví dụ tốt hơn là https://github.com/RetroDev256/comptime_suffix_automaton
Tuy nhiên, đoạn code godbolt được liên kết thực ra cũng đang cho thấy một trong hai ví dụ không hay lắm
Tôi cho rằng so sánh ví dụ JavaScript với ví dụ Zig/Rust không phải là một phép so sánh hay
Với trình biên dịch Zig và Rust thì lại bảo chọn CPU đích rất hiện đại, còn V8 có vẻ không ở cùng điều kiện
JIT tối ưu hóa cũng biết vector hóa nếu điều kiện phù hợp
Nhân tiện, hầu hết ngôn ngữ hiện đại đều thực hiện tối ưu hóa tương tự với chuỗi; ví dụ C++ ở đây: https://godbolt.org/z/TM5qdbTqh
Ví dụ Zig dùng mảng kích thước cố định với kiểu đã biết, còn mã JS thì “generic” ở runtime nên
xvàycó thể là bất kỳ object nàoĐúng là trong JS phải trả chi phí đó, nhưng trớ trêu là ở ví dụ cụ thể này có thể truyền thông tin kiểu tốt hơn cho JIT
Nếu khiến hàm này luôn được gọi với Float64Array cùng kích thước, JIT sẽ biết điều đó và tạo vòng lặp nhanh hơn. Dù không vector hóa thì cũng tốt hơn nhiều
Tuy nhiên typed array có chi phí khởi tạo lớn, nên thực tế không hay dùng trừ khi cấp phát một typed array lớn một lần rồi tái sử dụng nhiều lần
Ngoài ra bài viết nói bytecode JS khá phình ra; có lẽ phần lớn là do JIT không thể đảm bảo
65536bằng độ dài của cả hai mảng nên phải chèn guardDù vậy trên thực tế không ai viết vòng lặp
forkiểu đó mà sẽ viếti < x.length, và trong trường hợp này JIT ít nhất sẽ loại bỏ được một phép kiểm tra mảngtargetsang CPU cũ hơnXin lỗi vì đã không nghĩ tới giới hạn của mục tiêu JS
Ví dụ C++ được liên kết là một minh họa tốt cho những gì Clang có thể làm với C++, nhưng dù xét việc Zig biên dịch nhắm tới CPU cụ thể, assembly được tạo ra trông vẫn hơi đáng tiếc
Sẽ rất thú vị nếu xem bản port C++ của https://github.com/RetroDev256/comptime_suffix_automaton
Đây là cách tận dụng comptime mà trình biên dịch C++ không thể suy luận gọn gàng
Tôi không chắc câu “ngôn ngữ cấp cao thiếu lượng ý định phong phú mà ngôn ngữ cấp thấp có” có thật sự đúng không
Biểu đạt ý định dường như không phải là yếu tố nằm trên phổ cao/thấp; trái lại, càng có nhiều cách biểu đạt ý định chi tiết thì càng nên gần với cấp cao hơn
Đó là ý định về muốn khiến máy làm gì, hơn là muốn đạt được điều gì
Mã như
purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?;đầy ý định, nhưng không thể biết cuối cùng machine code nào sẽ được sinh raCú pháp vòng lặp
forđó thật kinh khủngÝ là có hai list đặt cạnh nhau, và vị trí phần tử trong một list tương ứng với vị trí phần tử trong list kia sao?
Chỉ nhìn thôi đã đau mắt
Có vẻ các ngôn ngữ hiện đại đã đi sai đường khi bắt đầu thêm đủ thứ “ma thuật” vào parser và rải các ký hiệu nhỏ khắp nơi trong mã
Không phải dạng tôi muốn nhìn chằm chằm hàng giờ
Vì thế việc Zig cung cấp cú pháp giúp làm đúng việc đó một cách tiện lợi mà vẫn cho thấy rõ điều gì đang xảy ra là tự nhiên
Cá nhân tôi thấy làm khá tốt, nên tò mò vì sao lại đau mắt
Tôi thật sự thích mô hình allocator của Zig
Giá mà trong Go cũng có thể dùng thứ như allocator theo từng request thay cho garbage collection
Vì bản thân ngôn ngữ không có cách biểu đạt và cưỡng chế quy tắc ownership, cuối cùng chẳng khác gì viết C với cú pháp hơi khác rồi hy vọng mọi thứ ổn
Nếu không có garbage collection thì ngay cả C++ cũng an toàn hơn Go rất nhiều
Memory model của Rust cho phép trình biên dịch luôn giả định rằng đối số hàm tuyệt đối không tham chiếu alias, nhưng trong Zig phải chỉ định điều này thủ công
Tôi đang tránh các chỉ định alias kiểu này
Vì ít người hiểu, và nếu dùng sai có thể tạo ra bug khó hiểu trong mã
Về lập luận rằng sự linh hoạt comptime của Zig đã mang lại cải tiến tốt cho các ngôn ngữ khác: thực thi hàm tại thời gian biên dịch và hàm nhận đối số hằng đã được D giới thiệu từ năm 2007 và khiến nhiều ngôn ngữ áp dụng thứ tương tự
https://dlang.org/spec/function.html#interpretation
Tôi thích Zig, nhưng câu “tôi thích sự dài dòng của Zig” nghe có vẻ kỳ lạ
Đúng là C quá lỏng lẻo ở nhiều ngóc ngách, nhưng theo tiêu chuẩn hiện nay, Zig đôi khi lại vung hơi quá sang phía ngược lại, khiến có quá nhiều nhiễu ký hiệu kiểu dữ liệu như chú thích
Điều này đặc biệt đúng với việc ép kiểu số nguyên tường minh trong biểu thức; liên quan đến việc này, tôi có viết một chút ở đây: https://floooh.github.io/2024/08/24/zig-and-emulators.html
Về mặt hiệu năng, nếu mã Zig nhanh hơn mã C tương tự thì thường là do thiết lập tối ưu hóa LLVM của Zig mạnh tay hơn
Ví dụ, Zig mặc định dùng
-march=nativevà tối ưu hóa toàn chương trình, đồng thời biên dịch toàn bộ mã Zig của dự án thành một đơn vị biên dịch duy nhấtHầu như mọi “mẹo” kiểu dùng
unreachablelàm gợi ý tối ưu hóa cũng có thể làm trong C, chỉ là đôi khi cần phần mở rộng ngôn ngữ không chuẩnTrình biên dịch C, đặc biệt là Clang, cũng gấp hằng rất mạnh tay, và có thể thu gọn những vùng mã lớn có thể gấp thành hằng ngay cả khi có ngăn xếp lời gọi sâu
Vì vậy nếu chỉ nhìn vào sinh mã, nhiều khi không khác comptime của Zig là mấy
Ưu điểm của comptime là nó không âm thầm quay lại thành mã runtime, còn mã không phải comptime cũng là đối tượng của cùng kiểu tối ưu hóa gấp hằng như C
Chẳng hạn, nếu một hàm không-comptime “thuần” được gọi với đối số hằng, trình biên dịch vẫn sẽ thay lời gọi hàm bằng kết quả của nó
Tóm lại, nếu mã C chậm hơn mã Zig thì nên kiểm tra thiết lập trình biên dịch C. Rốt cuộc, mọi phần việc nặng của tối ưu hóa đều diễn ra bên dưới LLVM
fn signExtendCast(comptime T: type, x: anytype) T { const ST = std.meta.Int(.signed, @bitSizeOf(T)); const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x))); return @bitCast(@as(ST, @as(SX, @bitCast(x)))); }export fn addi8(addr: u16, offset: u8) u16 { return addr +% signExtendCast(u16, offset); }Nó biên dịch ra cùng assembly, có thể tái sử dụng, và ý định cũng rõ ràng hơn
Tôi đồng ý với điều đó
Virgil đã có thể dùng toàn bộ ngôn ngữ ở thời điểm biên dịch từ năm 2006, và cũng hỗ trợ biên dịch toàn chương trình
Tuy nhiên, vì Virgil không nhắm tới LLVM, so sánh tốc độ rốt cuộc trở thành so sánh giữa hai backend của trình biên dịch
Virgil phụ thuộc nhiều vào phân tích khả đạt và các tối ưu hóa chuyên biệt hóa mà mô hình biên dịch này cho phép
Ví dụ, nó khử ảo hóa các lời gọi phương thức một cách mạnh tay, loại bỏ các trường và đối tượng không thể đạt tới, nâng hằng qua các trường và đối tượng heap, và đơn hình hóa hoàn toàn mã đa hình
Chỉ vì điều đó khiến AI dễ xử lý hơn
Việc dùng AI để lập trình có phải là ý tưởng hay hay không, và hay đến mức nào, là chuyện khác; nhưng nhiều lập trình viên tin như vậy, và các ngôn ngữ sẽ cố gắng đáp ứng họ