Quy ước gọi hàm Rust mà lẽ ra chúng ta nên có
(mcyoung.xyz)- Quy ước gọi hàm
extern "Rust"hiện tại của Rust dựa vào đường đi quy ước gọi C của LLVM, nên việc tận dụng thanh ghi khi truyền các giá trị phức tạp còn khá bảo thủ và bỏ lỡ cơ hội sinh mã tốt hơn - Ý tưởng cốt lõi là giữ cờ cấp crate
-Zcallconvđể tách giữa cách hiện tại làlegacyvà cách mới ưu tiên thanh ghi làfast, đồng thời dùng ABI quyết liệt hơn trong các bản build tối ưu hóa - Ngay cả khi không thêm trực tiếp một quy ước gọi hàm mới vào LLVM, vẫn có thể điều khiển cách bố trí đối số bằng chữ ký hàm LLVM cố định và giá trị
poisonđể bỏ trống các đối số thanh ghi không dùng mà không tốn chi phí - Các kiểu Rust như struct, enum, union,
bool,Resultcó thể được truyền dày đặc hơn nhờ kích thước hữu hiệu bỏ qua padding, làm phẳng, đóng gói bit và các heuristic chia giữa stack/thanh ghi - Nếu phản ánh thân hàm, thông tin borrow checker và thông tin profile vào quyết định ABI, có thể đạt tối ưu hóa mạnh hơn, nhưng độ phức tạp của phần sinh mã ABI trong rustc và thiếu chuyên môn về LLVM vẫn là rào cản thực tế
Tối ưu hóa quy ước gọi hàm mà Rust hiện đang bỏ lỡ
- Quy ước gọi hàm (calling convention) là một phần của ABI, quyết định cách truyền đối số và giá trị trả về, dùng thanh ghi nào, và cách xử lý prologue/epilogue cũng như unwinding
- Rust tự định nghĩa một quy ước gọi hàm không được chỉ định cụ thể, nhưng trên thực tế nó được hạ xuống quy ước gọi C tích hợp sẵn của LLVM và dựa vào LLVM để sinh mã prologue/epilogue
- rustc vận hành theo hướng bảo thủ để tạo ra chữ ký hàm LLVM giống như những gì Clang có thể tạo
- Có thể giúp giảm khả năng debugger bị hỏng
- Có thể làm giảm nguy cơ chạm vào các bug của LLVM qua những nhánh sinh mã ABI mà Clang ít khi dùng
- Trên các hệ thống dựa trên ELF, DWARF không đóng cứng Linux C ABI, nên trong phạm vi bài viết này khả năng debug không được xem là vấn đề cốt lõi
- Ví dụ đơn giản với
fn extract(arr: [i32; 3]) -> i32, mảng 12 byte được truyền bằng con trỏ thay vì thanh ghi- Nếu gắn
extern "C", cùng một[i32; 3]sẽ được đóng gói và truyền quardi,rsi - Đây là ví dụ cho thấy đường mặc định của Rust còn bảo thủ hơn cả Linux C ABI
- Nếu gắn
-Zcallconv: tách giữa legacy và fast
- Giữ nguyên quy ước gọi hàm hiện tại của
extern "Rust", nhưng chọn quy ước gọi sẽ dùng bằng cờ biên dịch cấp crate-Zcallconv-Zcallconv=legacy: cách hiện tại-Zcallconv=fast: cách mới được thiết kế theo hướng ưu tiên thanh ghi-Ocũng có thể tự động bật-Zcallconv=fast
- Quy ước gọi
fastkhông sắp xếp đối số theo thứ tự ABI của C, nên có thể gây khó hiểu với những ai quen nhìn thứ tự thanh ghi mang tính quy ước trên x86 - Trên các target như WASM, nơi không có khái niệm thanh ghi và spilling,
-Zcallconv=fastcó thể không được hỗ trợ - Ở các bản build debug tắt tối ưu hóa,
fastcó thể sinh mã tệ hơn nên có thể không phù hợp để bật - Con trỏ hàm và các khối
extern "Rust" {}cần có ràng buộc riêng- Cờ là ở cấp crate, nhưng con trỏ hàm khó biểu diễn được đang dùng phiên bản
extern "Rust"nào - Có thể ép các lời gọi qua con trỏ hàm dùng
-Zcallconv=legacyvì đây là đường đi chậm và ít gặp - Khi cần, sinh ra shim để chuyển đổi giữa các quy ước gọi
- Do có đường đi có thể gọi tới symbol không bị mangle, các symbol
#[no_mangle]cũng có thể dùng quy ước gọi legacy
- Cờ là ở cấp crate, nhưng con trỏ hàm khó biểu diễn được đang dùng phiên bản
Điều khiển LLVM theo cách gián tiếp
- Lý tưởng nhất là có thể chỉ định trực tiếp với LLVM kiểu như “đối số này vào thanh ghi này, giá trị trả về kia vào thanh ghi kia”, nhưng để thêm quy ước gọi hàm vào LLVM thì phải viết khá nhiều mã C++
- Thay vào đó, có thể đạt hiệu ứng gần với quy ước gọi riêng bằng quy trình sau
- Xác định số lượng tối đa các giá trị có thể truyền bằng thanh ghi theo từng target triple
- Quyết định xem giá trị trả về có đi qua các thanh ghi đầu ra hay phải trả về by-reference bằng một đối số
ptrbổ sung có thuộc tínhsret - Hạ các đối số by-value quá lớn thành by-reference
- Xác định đối số nào sẽ đi qua thanh ghi để tối đa hóa mức sử dụng không gian thanh ghi
- Đặt các đối số còn lại lên stack
- Chữ ký hàm LLVM IR được cấu thành từ các đối số non-aggregate như
i64,ptr,double,<2 x i64> - Trong prologue của hàm, giải mã đầu vào từ thanh ghi thành các đối số ở mức Rust
- Ở block kết thúc hàm, mã hóa giá trị trả về thành định dạng đầu ra cần thiết rồi
ret - Với các hàm non-polymorphic, non-inline có thể lấy địa chỉ, tạo legacy shim để giữ tính đồng nhất của con trỏ hàm
- Bài toán quyết định giá trị nào nên vào thanh ghi giống như bài toán ba lô (knapsack problem) nên là NP-hard, và khi hiện thực cần heuristic
- Thông tin này có thể được đưa vào
rmetađể tránh phải tính lại quá muộn - ABI của Rust đã thay đổi giữa các bản phát hành, nên việc không cho phép link mã do nhiều trình biên dịch Rust khác nhau sinh ra vốn cũng phù hợp với tình trạng hiện tại
Giới hạn truyền bằng thanh ghi mà LLVM cho phép
- Khi truyền đối số aggregate by-value vào hàm, LLVM cố gắng “explode” chúng ra để đưa được càng nhiều phần vào thanh ghi càng tốt
- Trên x86, đầu vào mà LLVM có thể truyền bằng thanh ghi đại khái là
- 6 số nguyên
- 8 vector SSE
- Giá trị trả về chỉ bằng một nửa: 3 số nguyên và 4 vector
- Trên
aarch64-unknown-linux, cả đầu vào lẫn đầu ra đều có thể dùng 8 thanh ghi số nguyên và 8 thanh ghi vector - Có thể thiết kế sao cho mọi hàm
-Zcallconv=fasttrên x86 đều có cùng số lượng đối số by-register- 6 đối số cho thanh ghi số nguyên
- 8 đối số vector từ
xmm0đếnxmm7 - Khi thực sự truyền con trỏ, đổi
i64tương ứng thànhptr - Khi truyền
double, thay thế một vị trí<2 x i64>
- Dù phần lớn hàm không truyền tới 176 byte, vẫn có thể tránh chi phí bổ sung bằng cách truyền LLVM
poisoncho các đối số không dùng- LLVM có thể xem
poisonlà bất kỳ giá trị thuận tiện nào ở thời điểm hiện tại - Nếu
poisonđược truyền qua đối số thanh ghi, nó có thể được xử lý như “giá trị vốn đã nằm sẵn trong thanh ghi đó”, nên không cần đụng vào thanh ghi - Trong ví dụ,
load_rcx()nhận con trỏ quarcx, còn mã chènpoisonvào 13 thanh ghi còn lại sẽ không sinh ra lệnh nào sau tối ưu hóa
- LLVM có thể xem
- Cách này cho phép kiểm soát gần như hoàn toàn việc truyền đối số, nhưng tình huống lý tưởng khi đầu vào và đầu ra dùng cùng thanh ghi còn tùy từng kiến trúc
- ARM và RISC-V gần với cấu trúc dùng chung thanh ghi cho đầu vào và đầu ra hơn
- x86 thì không, nhưng vẫn có thể giảm các lệnh di chuyển thanh ghi không cần thiết bằng cách giả định thứ tự cấp phát thanh ghi khác đi
Làm cho kiểu Rust phù hợp với thanh ghi hơn
- Khi xử lý struct và union của Rust, giả định rằng rustc đã coi các kiểu người dùng là aggregate và union cơ bản, rồi từ đó quyết định phần nào nên đặt vào thanh ghi
- Với giá trị trả về, kích thước hữu hiệu bỏ qua padding quan trọng hơn tổng kích thước của struct
[(u64, u32); 2]có tổng kích thước 32 byte nhưng 8 byte là padding- Nếu làm phẳng thành
(u64, u32, u64, u32)rồi sắp theo kích thước thành(u64, u64, u32, u32), tổng chỉ còn 24 byte - Nó có thể nằm trong 3 thanh ghi trả về số nguyên của x86
- Kích thước hữu hiệu được định nghĩa bằng số bit non-
undef[(u64, u32); 2]là 192 bitboollà 1 bitcharvề mặt kỹ thuật là 21 bit, nhưng để đơn giản thì được xem như bí danh củau32
- Các struct có nhiều
boolcó thể trả về bằng cách đóng gói bit nhiềuboolvào cùng một thanh ghi - Phía đối số khó hơn, và có thể áp dụng các heuristic như sau
- Các đối số có kích thước hữu hiệu lớn hơn toàn bộ không gian đầu vào by-register sẽ bị hạ thành by-reference
- Với x86, toàn bộ không gian đầu vào là 176 byte, tức 1408 bit
- enum được chuyển thành cặp discriminant và union
Option<i32>có thể được xem nội bộ như(union { i32, () }, i1)Option<Option<i32>>có thể được xem như(union { i32, (), () }, i2)
- union có thể đụng tới các bit chưa khởi tạo theo cách tùy ý nên thường được truyền như mảng
u8 - union chỉ có một variant không rỗng sẽ được thay bằng chính variant đó
- Các đối số đã biến đổi được làm phẳng thành các primitive như con trỏ, số nguyên, float, bool
- Các field lớn hơn thanh ghi đối số nhỏ nhất, như
u128,f64, có thể bị tách nhỏ - Sắp xếp danh sách primitive theo kích thước hữu hiệu và chọn prefix lớn nhất có thể nhét vào thanh ghi
- Phần còn lại đặt lên stack
- Nếu phần đi stack lớn hơn một bội số nhỏ của kích thước con trỏ, có thể hạ thành pointer-on-the-stack để giảm traffic bộ nhớ
- Các giá trị truyền qua thanh ghi được bố trí từ lớn đến nhỏ, còn
boolcó thể được đóng gói bit tối đa 64 giá trị trên mỗi thanh ghi
Ví dụ hàm Rust phức tạp và giới hạn hiện tại của rustc
- Trong ví dụ
do_thingnhậnOption<usize>,&dyn Context,&str,[char; 6], và structOptions, sau khi làm phẳng và sắp xếp thì toàn bộ đối số LLVM thô đều có thể nằm trong thanh ghi - Kiểu đối số LLVM raw trong ví dụ có dạng
gprs: i64, ptr, ptr, ptr, i64, i32, i32xmm0: i32, i32, i32, i32xmm1: i32, i1, i1, i1, i1
- Prologue của hàm sẽ lấy các primitive ra rồi ghép lại thành các giá trị ở mức Rust
Option<usize>là{ i64, i1 }- trait object là
{ ptr, ptr } &strlà{ ptr, i64 }[char; 6]là[6 x i32]Optionslà{ i32, i1, i1, i1 }
- Nếu gắn metadata
!dbgvào các lệnh materialize giá trị đối số, gdb có thể cho kết quả tốt hơn khi in giá trị đối số - Hiện tại, với cùng hàm này rustc truyền cho LLVM 8 tham số cỡ pointer, kết quả là dùng hết 6 thanh ghi số nguyên và phải đẩy 2 giá trị xuống stack
Giá trị trả về và dư địa tối ưu cho Result
- Thiết kế này không bao trùm toàn bộ mọi tối ưu hóa quy ước gọi hàm có thể có
- Trong một số trường hợp, có thể dùng thêm thanh ghi như các thanh ghi AVX trên x86
- Cũng có thể cân nhắc cách truyền struct bằng cách chia giữa thanh ghi và stack
- Việc trả về
Resultcòn có dư địa tối ưu riêng- Khi đi qua nhiều tầng hàm bằng
?, có thể phát sinh nhiều lần di chuyển thanh ghi trùng lặp - Nếu
Resultđủ lớn để không nằm vừa trong thanh ghi, mỗi tầng gọi?phải load ok bit từ bộ nhớ để kiểm tra - Một phương án khác là đặt error vào out-parameter pointer, còn payload của variant ok cùng bit is-ok được trả về dưới dạng
Option<T> - Chi tiết
?đi kèm lời gọiIntokhá rắc rối nhưng vẫn khả thi để hiện thực
- Khi đi qua nhiều tầng hàm bằng
ABI phụ thuộc tối ưu hóa
- Không giống C, với
-Zcallconv=fast, Rust có thể nhìn thấy thân hàm khi tạo ABI mà caller sẽ thấy - Mỗi crate có thể quảng bá ABI chính xác theo góc nhìn truyền bằng thanh ghi cho từng hàm
- Tối ưu đơn giản nhất là bỏ các đối số không được dùng khỏi ABI
- Nếu hàm không dùng tham số nào, sẽ không cấp thanh ghi cho đối số đó
- Nếu đối số
&Tkhông bị giữ lại, không bị chuyển thành raw pointer, vàTnhỏ đồng thờiT: Freeze, có thể truyền trực tiếp giá trị pointee by-value thay vì truyền tham chiếu - Các API như
HashMap::get()là ứng viên phù hợp- Nếu key là kiểu như
i32, hiện nay thường phải spill số nguyên xuống stack rồi truyền con trỏ của nó - Có thể tránh được traffic bộ nhớ này
- Nếu key là kiểu như
- ABI dựa trên profile là một hướng còn quyết liệt hơn
- Có thể ưu tiên các đối số “hot” hơn trong thứ tự cấp phát thanh ghi
- Ngay cả khi nhận một struct lớn bằng tham chiếu, caller vẫn có thể load trước 3 field
i64hot rồi truyền cả con trỏ lẫn các thanh ghi đó - callee vốn dĩ cũng phải thực hiện các lệnh load ấy nên không phát sinh thêm chi phí
- Profile dạng instrumentation thậm chí có thể biện minh cho việc nhân bản hàm chỉ khác ABI
Vì sao vẫn chưa làm được
- So với C++, Rust có ít ràng buộc ABI hơn nên có thể sinh mã tốt hơn, và ý tưởng này có điểm giao với cách Go register ABI được dùng trong thực tế
- Trở ngại đầu tiên là độ phức tạp của phần sinh mã ABI
- LLVM gần như không cung cấp nhiều núm điều khiển hữu ích
- Ngay trong rustc đây cũng không phải khu vực thân thiện
- Nếu hiện thực sai, kết quả có thể ảnh hưởng xấu tới trải nghiệm sử dụng
- Trở ngại khác là thiếu chuyên môn
- Trong số những người đóng góp cho rustc, chỉ có số ít người hiểu đủ sâu ngữ nghĩa LLVM và đặc tính sinh mã để vừa tạo được mã tốt vừa không làm LLVM crash
- Thời gian biên dịch cũng có thể là gánh nặng
- Chữ ký hàm càng phức tạp thì LLVM càng phải xử lý nhiều mã prologue/epilogue hơn
- Tuy vậy,
-Zcallconvvốn được định hướng chỉ dùng khi bật tối ưu hóa, nên đây không bị xem là nhược điểm mang tính quyết định
- Phần mã ABI của Rust là một khu vực có bus factor thấp, và hiểu biết về LLVM có thể được tận dụng trực tiếp để giúp đội ngũ trình biên dịch Rust tạo ra mã tối ưu hơn
1 bình luận
Các ý kiến trên Hacker News
Khi tối ưu hóa calling convention, điểm cốt lõi không phải là suy nghĩ trong đầu xem hình thức nào có vẻ tốt, mà là đo hiệu năng
Mã tốt là mã phải nhanh, chứ không phải trông có vẻ nhanh
Có những trường hợp thứ mà tác giả gọi là mã xấu lại nhanh nhất vì những lý do hoàn toàn không trực quan, và chỉ có thể biết được khi đo trên benchmark lớn
Một trong những lý do một calling convention trông có vẻ tệ lại hoạt động tốt là vì nó tiết kiệm thanh ghi đối số, giúp bộ cấp phát thanh ghi dễ thở hơn một chút
Ngoài ra, CPU ngày nay được tối ưu cho luồng lệnh do trình biên dịch C tạo ra, nên nếu tạo ra kiểu mã của trình biên dịch C, đặc biệt như MSVC vốn chuyển qua stack thường xuyên một cách bất ngờ, thì có thể khớp với điểm tối ưu của CPU
Vì inlining đã làm quá tốt, các lời gọi trên hot path trở thành những ranh giới hiếm gặp; nếu ranh giới đó hơi lộn xộn nhưng làm cho những thứ khác đơn giản hơn thì vẫn ổn
Điều này không có nghĩa thay đổi ở đây là xấu, nhưng chỉ nhìn vào mã trông kỳ lạ rồi bàn luận mà không đo đạc thì thật kỳ lạ
Tôi từng làm công việc tối ưu calling convention ở JavaScriptCore, và trong mã lớn thực tế, chuyện mã chuyển qua stack trông có vẻ tệ lại thắng xảy ra thường xuyên đến đáng ngạc nhiên
Tuy nhiên, tôi cho rằng kết quả đo hiệu năng không nên là tiêu chí duy nhất
Trong cách diễn đạt CPU “ngày nay” được tối ưu, từ quan trọng là ngày nay; CPU sẽ tiếp tục thay đổi, nên calling convention phải là một thiết kế dài hạn
Vì vậy, đáng tiếc là tốt hơn nên không đi quá xa khỏi cách C++ đang làm. Bởi các tối ưu hóa bộ xử lý trong tương lai cũng nhiều khả năng sẽ nhắm vào hướng đó
Đồng thời, nên cân nhắc các nguyên tắc chung khó thay đổi, như tiết kiệm thanh ghi đối số, để làm cho calling convention vững chắc và hướng tới tương lai
Nói vậy hơi lạ, vì tôi có cảm giác trong vài năm gần đây Rust đã trở nên quá bảo thủ về mức cho phép sự kỳ lạ (https://steveklabnik.com/writing/the-language-strangeness-bu...). Rốt cuộc, nếu không khác biệt thì không thể tốt hơn
Nếu ngay khi hàm bắt đầu đã lấy địa chỉ của tham số và truyền cho một hàm không xác định, thì dù sao cũng phải đổ nó xuống stack
Sẽ rất thú vị nếu thấy tối ưu calling convention dựa trên thân hàm. Với hàm static trong C, miễn là không lấy địa chỉ của nó thì có vẻ an toàn
JIT có lợi thế trong vấn đề này vì trước cả khi tạo ra một dòng assembly, nó đã thu thập nhiều thông tin về CPU đang thực sự chạy
Với mã biên dịch tĩnh thuần túy, không thể biết tập tính năng kiến trúc tại runtime, nên trong chính phần mã muốn tối ưu nhất lại thường gặp rào cản inlining
Hiện tại Rust có vẻ yếu ở điểm này trên các nền tảng nhỏ, và calling convention có thể giúp ích liên quan đến việc trả về
ResultDù vậy, tôi vẫn tò mò liệu lợi thế thực nghiệm của việc truyền qua stack có tiếp tục áp dụng khi chuyển sang CPU ARMV8 có nhiều thanh ghi hoặc RISC-V hay không
Đây là một bản nháp hợp lý, nhưng còn thiếu sự phân biệt caller-saved/callee-saved, và mắc lỗi thường gặp là gán một phần các thanh ghi đầu vào cho đầu ra
Kỳ vọng debugger sẽ hiểu một calling convention khác với C cũng khá lạc quan. Bất kể DWARF có thể mã hóa được gì, trên thực tế khả năng cao là sẽ thất bại thảm hại
Nếu thay đổi ABI tùy theo thiết lập tối ưu hóa thì sẽ tương tác rất tệ với biên dịch tách rời
Cách sắp xếp lại đối số như bin packing có thể hoạt động, nhưng làm tăng đáng kể độ phức tạp của trình biên dịch, và tôi không rõ nó có đáng so với cách bố trí “first fit” từ trái sang phải hay không. Nhà phát triển cũng khó dự đoán đối số sẽ đi đâu hơn
Hướng lớn là dùng calling convention khác nhau cho các hàm có địa chỉ bị thoát ra ngoài và các hàm không như vậy là hợp lý. Cách tách phần prologue làm nhiệm vụ khớp trở kháng cũng hoạt động tốt
Rust nên sẵn sàng có calling convention khác C, nhưng tôi không chắc đó có nên là một quy ước duy nhất được hard-code cho mọi hàm hay không. Đưa vào hệ thống kiểu có vẻ tự nhiên hơn, và nếu cho nhà phát triển kiểm soát calling convention thì một trong các lợi thế hiệu năng của assembly sẽ biến mất
Ở phía caller, dù sao thì giữa hai lần gọi hàm cũng phải làm trống các thanh ghi đầu ra, và cách này cũng được dùng khá rộng rãi trong các calling convention của system call
Có lẽ mục đích là giúp callee dễ chuẩn bị giá trị đầu ra trong khi vẫn giữ nguyên giá trị đầu vào. Nếu vậy thì tôi hiểu việc đặt thanh ghi đầu ra ở cuối thứ tự đầu vào để tránh chồng lấn, nhưng không thấy rõ lý do phải cấm hoàn toàn mọi sự chồng lấn
Function AgọiFunction B,Function C,Function D, việc tối ưu bằng cách đổi đối số của các hàm trung gian sang quy ước khác để giảm overhead cũng sẽ bị chặn đồng thờiTôi tự hỏi ngữ nghĩa nào vừa cho phép quyền kiểm soát vừa giữ được tối ưu hóa như vậy, hay rốt cuộc đó gần như chỉ là ảo tưởng
Trên thực tế, assembly không phải là đối tượng của hầu hết các tối ưu hóa trình biên dịch, nên có bất lợi về hiệu năng. Nhiều khi cũng không được hưởng các tối ưu kiểu “quan sát hành vi, xác định là hoàn toàn trùng lặp rồi loại bỏ toàn bộ”, và giờ không còn là thập niên 1990 nữa
Tuy nhiên, nếu là trường hợp thậm chí không thể xét đến các tối ưu hóa như vậy, thì tôi nghĩ inline assembly chắc chỉ thua rõ ở mức tối ưu hóa dựa trên hồ sơ chạy. Vì nhà phát triển ứng dụng biết hoàn toàn hành vi của mã, còn nhà phát triển trình biên dịch thì không
Overhead gọi hàm có thể được loại bỏ bằng cách dùng thêm assembly cho đến khi bao phủ được các ranh giới nóng liên quan
boolthì có thể tạo ra chuỗi phụ thuộcTrên x64, với các
bool, có vẻ không có cách nào tốt hơn nhiều ngoài việc trước hết đưa chúng vào thanh ghi, shift, rồi OR vào kết quảCách đơn giản sẽ tạo chuỗi phụ thuộc dài 64 và có thể bị phạt 64 chu kỳ, nhưng nếu làm khéo có thể giảm xuống 6 chu kỳ, còn thực tế có lẽ khoảng 12 chu kỳ
Nhưng 64 giá trị
boolđó đến từ đâu cũng là vấn đề. Không có nhiều thanh ghi đến vậy, nên cuối cùng vẫn phải đọc lại từ stackNếu Rust ABI đã pack chặt
booltrong struct theo cách này thì dù sao cũng phải làm, nhưng tôi không rõ lắmVà caller lại phải unpack toàn bộ
Dạy trình biên dịch để spill giá trị vào vùng kết quả trên stack có lẽ dễ hơn, và cũng có khả năng cho hiệu năng tốt hơn
Vì vậy tôi tự hỏi việc đưa giá trị vào thanh ghi thực sự giúp được bao nhiêu
Calling convention của C hơi tệ
Đúng là không thể thay đổi calling convention của C, nhưng điều đó không làm nó bớt đáng tiếc hơn
Đáng lẽ phải dùng tất cả các thanh ghi caller-saved khả dụng cho đối số và giá trị trả về, nhưng trong ABI SysV truyền thống, giá trị trả về chỉ dùng một, đôi khi hai thanh ghi
Khi trả về
struct Point3D { long x, y, z }, dù có thể đặtPoint3Dvàorax,rdi,rsi, nó vẫn bị spill ra stackCác hệ thống khác cũng có những mẹo khác. Nếu tôi nhớ không nhầm, trong SBCL, khi một hàm trả về nhiều giá trị, nó sẽ bật cờ carry khi kết thúc. Tôi nghĩ có thể dùng cờ carry để chỉ ra chẳng hạn
Resultcó chứa lỗi hay không thì hayCalling convention của C về cơ bản hỗ trợ thứ C hỗ trợ, tức là trả về một đối số. Ngay cả trả về struct cũng không hẳn được hỗ trợ đúng nghĩa
Với C thì gần như là “chẳng phải bạn đã biết sẽ vậy sao”, còn với C++ thì thành kiểu “cứ inline là xong mà”
Ngược lại, spill ra bộ nhớ thực sự xảy ra. Ví dụ, không gian thanh ghi và register window rộng rãi của SPARC để lại nhiều thanh ghi không dùng trong các hàm đơn giản, và khi spill register ring thì dẫn đến mức dùng stack lớn làm hỏng cache
Trên x86, dù có nhiều lệnh
movđể sắp xếp lại dữ liệu tới “nơi cần thiết”, kết quả thường vẫn nhanh hơnNếu chỉ nhìn mã callee, người ta dễ muốn nói “đối số này ở đây, giá trị trả về kia ở kia thì chắc chắn sẽ nhanh”, nhưng ta không biết caller
Không thể bảo đảm việc chuẩn bị đối số sẽ được truyền qua nguyên vẹn, hay giá trị trả về sẽ được tiêu thụ nóng. Ví dụ, nếu dùng
struct Point { x: i32, y: i32, z: i32 }làm đối số/giá trị trả về và caller trong một vòng lặp làm việc kiểumystruct.deepinside.point[i] = func(mystruct.deepinside.point[i]), thì việc đưa vào/rút ra khỏi thanh ghi có thể thành overhead hoặc cản vector hóaCallee không thể biết điều này, ngoại lệ chỉ xảy ra khi trình biên dịch có thể nhìn cả hai phía và inline
“Quả thấp dễ hái” nhất liên quan đến gọi hàm có lẽ là loại bỏ giả định đã ăn sâu trong hầu hết ABI C rằng hàm trả về một giá trị nguyên thủy duy nhất. Phần còn lại cần rất nhiều benchmark và thống kê sinh mã
Rust có một chi tiết đáng tiếc khác khiến struct trở nên lớn hơn mong muốn
Hãy xét struct
Foochứa 8 fieldOption, mỗi field làNonehoặcSome(u8). Trong C, có thể biểu diễn bằng 8bool1 bit và 8uint8_t, tổng cộng 9 byteTrong Rust, nó trở thành 16 byte do lặp lại 8 lần một discriminator 1 byte và một
uint8_tLý do là struct phải có khả năng cung cấp borrow của các field. Nếu có
&Foo, compiler phải có thể tạo&Foo::some_field, tức&Option, và&Optionnày phải có cùng hình dạng với mọi&Optionkhác trong chương trìnhVì vậy
Optionbên trong phải có cùng layout với cácOptionkhác trong chương trình, tức có bit discriminator của chính nó được làm tròn lên thành byte và cóu8. Struct phải trả chi phí này ngay cả khi thực tế không tạo&Foo::some_fieldNếu xét
Optioncủa các kiểu lớn hơn thì còn tệ hơn. Với một struct có 8 fieldOption, mỗi discriminator được làm tròn lên thành 2 byte, tổng cộng thành 32 byte; một phần tư, và nếu tính cả các bit không dùng của discriminator thì gần như một nửa, bị lãng phí cho padding ở giữa. Struct tương đương trong C chỉ cần 18 byteKhi dùng
Option, struct Rust có thể là 128 byte, còn struct C là 72 byteTất nhiên, có thể tự triển khai cách biểu diễn giống C bằng cách có một
u8dành cho các discriminator được đóng gói và 8MaybeUninit, rồi tự viết các hàm ánh xạ từ&FoosangOption<&T>và từ&mut FoosangOption<&mut T>. Tuy nhiên không thể đi qua&Optionhay&mut Optionhttps://play.rust-lang.org/?version=stable&mode=debug&editio...
Thực chất đây là đang mô tả một kiểu do người dùng định nghĩa chứa 8
Option, và khi bắt đầu quan tâm đến hiệu năng thì phải tự xử lý cácOptionbên trongViệc Rust cung cấp một tính năng tiện lợi để dùng khi phù hợp với mục tiêu khó có thể xem là nhược điểm
Use case được mô tả tương đối hiếm, và nếu đó thực sự là nút thắt hiệu năng thì việc dành thêm chút thời gian để triển khai trong Rust không phải vấn đề lớn
Vì trong cách dùng thông thường, lợi ích mà kiểu
Option<_>mang lại là rất lớn, nên khó xem đây là một “chi tiết đáng tiếc” của RustCó đoạn nói rằng nếu địa chỉ của một hàm không đa hình, không inline có thể được lấy làm con trỏ hàm, thì sẽ tạo một shim dùng
-Zcallconv=legacyrồi tail-call ngay vào triển khai thật; tôi hiểu ý định là để bảo toàn tính tương đương của con trỏ hàmNhưng nếu shim legacy tail-call một hàm theo quy ước gọi của Rust, chẳng phải nó không thể sửa được khác biệt ở giá trị trả về của quy ước gọi sao?
Hơi lệch chủ đề một chút, nhưng tôi tò mò liệu hiện nay có thể tương tác giữa Go và Rust không
Tôi nhớ trước đây từng thấy một trường hợp làm được bằng cách đặt Zig ở giữa, nhưng không tìm lại được. Tôi có code Rust legacy và muốn chuyển dần sang Go
extern "C"qua CGO để gọi hàm RustTôi đã trình bày ở RustConf 2023 về cách chúng tôi dùng việc này trong GitHub code search (https://www.youtube.com/watch?v=KYdlqhb267c), và sau đó nghe nói những nơi như 1Password cũng đang làm tương tự
Việc chuyển kiểu dữ liệu qua ranh giới tương tác với C khá phiền nên không thú vị lắm, nhưng làm được và cũng tái sử dụng code được
extern "C", rồi gọi từ Go như gọi CChiều ngược lại thì tôi không rõ
Code được quản lý cần có khả năng sở hữu bộ nhớ mà nó sẽ giải phóng hoặc di chuyển, còn code không được quản lý phải suy luận khi nào bộ nhớ bị giải phóng hoặc di chuyển
Những thứ như
cgocho phép trộn lời gọi FFI từ code được quản lý của Go sang bộ nhớ không được quản lý, nhưng phải trả giáTrong các triển khai mà các ngôn ngữ gọi lẫn nhau không chia sẻ garbage collector, vấn đề này luôn xuất hiện
Trộn code được quản lý/không được quản lý là một ý tưởng cũ nhưng vẫn là chủ đề được nghiên cứu tích cực
Trừ khi runtime tích hợp được thiết kế cho việc này, gọi từ code không được quản lý sang code được quản lý gần như luôn là ý tưởng tệ, và thường sẽ có một lớp tuần tự hóa ở giữa
Nếu đây là công việc chính thì có thể tôi sẽ thấy cách này không ổn, nhưng tôi đã chán cảnh cứ vài tuần quay lại code lại không nhớ mình đang làm gì và làm thế nào
Tôi truyền một closure Rust có trạng thái vào code Go làm callback để đưa vào hàm thư viện chuẩn của Go, thậm chí còn bao gồm cả việc unwind panic bên trong closure Rust
https://github.com/Voultapher/sort-research-rs/commit/df6c91...
Tôi đã xem Inspect Element khá lâu để tìm xem tiêu đề các mục được đặt nghiêng như thế nào, nhưng với công cụ của Safari thì chịu. Rốt cuộc họ làm bằng cách nào vậy?
.post-title:transform: skewY(-2deg) translate(-1rem, -0.4rem);element()(https://developer.mozilla.org/en-US/docs/Web/CSS/element), nhưng thực ra đó là một bản sao của nội dung bài viết được thu nhỏ lại rất nhỏh1, h2, h3, h4, h5, h6được áp dụngtransform:skewY(-2deg) translate(-1rem,0rem);,transform-origin:top;,font-style:italic;,text-decoration-line:underline;,text-decoration-color:goldenrod;,text-underline-offset:4%;,text-decoration-thickness:.25exNgược lại, có bài viết năm 2019 “How Swift Achieved Dynamic Linking Where Rust Couldn't”
https://faultlore.com/blah/swift-abi/
Việc Rust vẫn chưa có calling convention cho ngữ nghĩa ở cấp độ Rust là điều đáng tiếc, nhưng đồng thời bài viết đó cho thấy khối lượng công việc cần thiết để đi tới đó là rất lớn
Apple có động lực sâu sắc để biến Swift thành một ngôn ngữ hệ thống thực dụng mà ứng dụng có thể phụ thuộc vào, nhưng Rust thì không có sự hậu thuẫn như vậy
Thảo luận trên HN: https://news.ycombinator.com/item?id=21488415
Sẽ tốt hơn nếu Rust cũng có nhiều lựa chọn hỗ trợ hơn cho sự đánh đổi này, và không nhất thiết chỉ giới hạn ở những thứ như https://github.com/rust-lang/rfcs/pull/3470
Nếu trình biên dịch Rust hiện nay inline mạnh tay rồi tối ưu hóa, tôi tự hỏi liệu việc này có đáng công không
Nếu hàm được gọi nhỏ thì nó sẽ được inline, còn nếu lớn thì thời gian sẽ chủ yếu nằm trong hàm, nên overhead của lời gọi sẽ nhỏ
dyn Trait, không thể được inline, nên thay đổi như thế này sẽ có íchNếu có thể làm cho lời gọi rẻ hơn, ta cũng không cần inline mạnh tay đến vậy, điều này có thể giúp cả kích thước mã lẫn thời gian biên dịch
Các hàm phức tạp không phù hợp để inline có khả năng sẽ truy cập bộ nhớ vài lần, và những truy cập đó có khả năng là nút thắt
Truyền qua stack sẽ siết nút thắt đó chặt hơn, vì nó làm tăng áp lực cache và số lần load/store
Nếu Rust có thể truyền tham số tối ưu trong một tỷ lệ đáng kể các lời gọi hàm, không chỉ tránh được vài chu kỳ truy cập L1, mà còn có thể giúp CPU chạm tới nút thắt bộ nhớ thực sự nhanh hơn
Có thể sẽ được lợi vài phần trăm, nhưng giờ tôi đang uống rượu vang và không tính toán gì cả
Có ai giải thích được mẹo ghi nhớ “Diana’s silk dress cost $89” trong tài liệu tham khảo x86 là gì không?