Calling convention Rust mà chúng ta lẽ ra phải có
(mcyoung.xyz)Bài viết này giải thích chi tiết cách cải thiện Calling Convention của ngôn ngữ Rust.
Vấn đề của Calling Convention hiện tại trong Rust
- Hiện tại Rust chưa có Calling Convention được định nghĩa rõ ràng
- Trên thực tế, nó đang dùng Calling Convention C mặc định của LLVM
- Hiện nay Rust đang cố gắng tạo ra chữ ký hàm LLVM theo hướng bảo thủ, tương tự như những gì Clang có thể tạo ra
- Để tương thích với debugger
- Để tránh các lỗi của LLVM
- Nhưng cách này quá bảo thủ nên ngay cả với các hàm đơn giản cũng sinh ra mã kém
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
- Đoạn mã trên lẽ ra phải được truyền qua thanh ghi nhưng lại được truyền bằng con trỏ
- Rust còn bảo thủ hơn cả C ABI. Nếu chỉ định
extern "C"thì nó sẽ được truyền qua thanh ghi.
Đề xuất Calling Convention mới
- Giữ nguyên Calling Convention hiện có cho các hàm
extern "Rust" - Thêm cờ
-Zcallconvđể thiết lập Calling Convention cho các hàmextern "Rust"-Zcallconv=legacylà cách hiện tại-Zcallconv=fastlà cách mới sẽ được thiết kế
- Vì sao phải giữ Calling Convention hiện có?
- Vì tính dễ dàng khi debug, nó không được sắp xếp theo thứ tự C ABI
- Một số target như WASM có thể không hỗ trợ
- Trong bản build debug, điều này có thể không có ý nghĩa
- Những điểm cần lưu ý liên quan đến con trỏ hàm và khối
extern "Rust" {}- Vì đây là cờ ở cấp crate nên không thể áp dụng cho con trỏ hàm
- Lời gọi qua con trỏ hàm chậm và hiếm nên dùng
-Zcallconv=legacy - Khi cần có thể sinh Shim để chuyển đổi Calling Convention
- Khi gọi trực tiếp như
extern "Rust" { fn my_func() -> i32; }- Chỉ có thể gọi các symbol không bị mangling
- Các hàm
#[no_mangle]dùng Calling Convention cũ
Cách tận dụng LLVM
- Lý tưởng nhất là có thể chỉ định trực tiếp Calling Convention cho LLVM, nhưng trên thực tế điều đó rất khó
- Có thể lách bằng quy trình sau
- Xác định số lượng giá trị tối đa có thể truyền qua thanh ghi cho target đã cho
- Quyết định cách truyền giá trị trả về. Nếu vừa thanh ghi thì giữ nguyên, nếu lớn thì truyền bằng tham chiếu
- Trong các đối số truyền theo giá trị, chọn ra những thứ phải truyền bằng tham chiếu
- Những thứ lớn hơn không gian có thể truyền qua thanh ghi
- Trên x86 là khoảng 176 byte
- Quyết định đối số nào sẽ truyền qua thanh ghi để tận dụng tối đa không gian thanh ghi
- Đây là bài toán NP-hard nên cần heuristic
- Phần còn lại truyền qua stack
- Tạo chữ ký hàm bằng LLVM IR
- Các đối số truyền qua thanh ghi được biểu diễn bằng các kiểu không phải aggregate như i64, ptr, double, <2 x i64>
- Các đối số truyền qua stack sẽ tuân theo "đầu vào thanh ghi"
- Tạo phần prologue của hàm
- Giải mã các đối số ở mức Rust từ đầu vào thanh ghi để tạo ra các giá trị %ssa giống hệt như khi dùng
-Zcallconv=legacy - Phần thân hàm có thể sinh cùng một loại mã bất kể Calling Convention
- Mã giải mã không cần thiết sẽ bị DCE loại bỏ
- Giải mã các đối số ở mức Rust từ đầu vào thanh ghi để tạo ra các giá trị %ssa giống hệt như khi dùng
- Tạo khối trả về của hàm
- Bao gồm các lệnh phi cho kiểu trả về giống như khi dùng
-Zcallconv=legacy - Mã hóa sang định dạng đầu ra cần thiết và trả về bằng ret
- Cần nhảy nhánh tới khối này thay vì ret trực tiếp
- Bao gồm các lệnh phi cho kiểu trả về giống như khi dùng
- Nếu có hàm không đa hình, không inline có thể được dùng làm con trỏ hàm
- Nếu bị lộ ra ngoài crate hoặc được truyền dưới dạng con trỏ hàm
- Sinh một Shim dùng
-Zcallconv=legacyrồi Tail Call tới phần cài đặt thực - Điều này cần thiết để giữ tính tương đương của con trỏ hàm
Cách kiểm tra giới hạn truyền qua thanh ghi của LLVM
- Một chương trình LLVM để kiểm tra số lượng truyền qua thanh ghi tối đa mà LLVM cho phép
- Trên x86, có thể nhận đầu vào là 6 số nguyên và 8 vector SSE, đồng thời xuất ra 3 số nguyên và 4 vector SSE
- Trên aarch64, đầu vào và đầu ra đều giống nhau: 8 số nguyên và 8 vector
- Nếu vượt quá giới hạn này thì sẽ được truyền qua stack
Xử lý struct và enum của Rust
- Giả định rằng rustc đã xử lý chúng thành các aggregate cơ bản và union
- Xử lý giá trị trả về
- Điều quan trọng không phải là kích thước struct mà là kích thước dữ liệu thực sau khi loại trừ padding
- [(u64, u32); 2] có kích thước 32 byte nhưng nếu bỏ 8 byte padding thì còn 24 byte
- Định nghĩa kích thước hiệu dụng (Effective Size) của một kiểu
- Là số bit được xác định, không tính padding
- [(u64, u32); 2] là 192 bit
- bool là 1 bit
- Nếu kích thước hiệu dụng nhỏ hơn không gian thanh ghi đầu ra thì trả về theo giá trị
- Trên x86, 3 số nguyên + 4 SSE = 88 byte = 704 bit
- Xử lý thanh ghi cho đối số
- Đây là bài toán Knapsack nên là NP-hard
- Một heuristic đơn giản
- Nếu kích thước hiệu dụng lớn hơn toàn bộ không gian thanh ghi đầu vào thì truyền bằng tham chiếu
- Enum được thay bằng cặp discriminator-union
- Union có thể đụng vào các bit chưa khởi tạo nên được truyền bằng mảng u8 hoặc một biến thể đơn không rỗng
- Làm phẳng thành các phần tử cơ bản nhất như con trỏ, số nguyên, số thực, boolean
- Sắp xếp tăng dần theo kích thước hiệu dụng
- Gán một tiền tố lớn nhất có thể vào thanh ghi, phần còn lại đưa lên stack
- Nếu một phần của đầu vào đi qua stack lớn hơn một bội số nhỏ của kích thước con trỏ thì truyền bằng con trỏ trên stack
- Phần còn lại được truyền trực tiếp trên stack theo thứ tự trước khi sắp xếp
- Những gì truyền qua thanh ghi được gán theo thứ tự giảm dần kích thước
- Boolean được bit-pack theo từng nhóm 64 giá trị
Ý kiến của GN+
- Cá nhân tôi thấy Calling Convention hiện tại của Rust rất đáng tiếc. Nó có thể đạt hiệu năng tốt hơn C++ rất nhiều nhưng hiện vẫn chưa làm được
- Đây là cách mà ngôn ngữ Go đã triển khai từ lâu
- Lý do Rust chưa áp dụng được
- Việc sinh mã ABI rất phức tạp và LLVM không hỗ trợ được bao nhiêu
- Trong nhóm compiler không có nhiều người thực sự giỏi LLVM
- Có lo ngại về thời gian biên dịch, nhưng nếu chỉ dùng trong bản build tối ưu thì không phải vấn đề lớn
- Tác giả không có thời gian để tự sửa, nhưng sẵn sàng hỗ trợ nhóm compiler Rust dựa trên chuyên môn về LLVM của mình
- Hoặc đơn giản là chuyển sang
extern "C"hayextern "fastcall"cũng có thể là một phương án thay thế
1 bình luận
Ý kiến trên Hacker News
Tóm tắt:
Option<u8>sẽ là 16 byte trong Rust, còn trong C là 9 byte.&Option<T>hoặc&mut Option<T>.repr.