2 điểm bởi GN⁺ 2024-04-20 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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à legacy và 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, Result có 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 qua rdi, 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

-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
    • -O cũng có thể tự động bật -Zcallconv=fast
  • Quy ước gọi fast khô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=fast có thể không được hỗ trợ
  • Ở các bản build debug tắt tối ưu hóa, fast có 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=legacy vì đâ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

Đ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ố ptr bổ sung có thuộc tính sret
    • 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=fast trê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 đến xmm7
    • Khi thực sự truyền con trỏ, đổi i64 tương ứng thành ptr
    • 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 poison cho các đối số không dùng
    • LLVM có thể xem poison là 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ỏ qua rcx, còn mã chèn poison vào 13 thanh ghi còn lại sẽ không sinh ra lệnh nào sau tối ưu hóa
  • 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 bit
    • bool là 1 bit
    • char về mặt kỹ thuật là 21 bit, nhưng để đơn giản thì được xem như bí danh của u32
  • Các struct có nhiều bool có thể trả về bằng cách đóng gói bit nhiều bool và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 bool có 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_thing nhận Option<usize>, &dyn Context, &str, [char; 6], và struct Options, 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, i32
    • xmm0: i32, i32, i32, i32
    • xmm1: 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>{ i64, i1 }
    • trait object là { ptr, ptr }
    • &str{ ptr, i64 }
    • [char; 6][6 x i32]
    • Options{ i32, i1, i1, i1 }
  • Nếu gắn metadata !dbg và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ề Result cò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ọi Into khá rắc rối nhưng vẫn khả thi để hiện thực

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ố &T không bị giữ lại, không bị chuyển thành raw pointer, và T nhỏ đồng thời T: 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
  • 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 i64 hot 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, -Zcallconv vố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

 
GN⁺ 2024-04-20
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

    • Tôi hoàn toàn đồng ý rằng mã trông có vẻ nhanh không phải lúc nào cũng thực sự nhanh
      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
    • Việc truyền qua thanh ghi có nhanh hơn hay không cũng phụ thuộc vào thân hàm
      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
    • Kinh nghiệm đó không thể chuyển giao hoàn 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ệu năng có thể bao gồm không chỉ tốc độ thực thi mà cả kích thước binary
      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ề Result
    • Bài gốc chủ yếu nói về x86, và Intel đã dành nhiều thập kỷ làm kỹ thuật đáng kinh ngạc để khiến mã x86 xấu xí chạy nhanh trên silicon của họ mà mọi người mua
      Dù 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

    • Tôi thắc mắc vì sao việc dùng một phần thanh ghi đầu vào làm thanh ghi đầu ra lại là vấn đề lớn đến vậy
      Ở 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
    • Nếu để nhà phát triển kiểm soát calling convention, thì trong một chuỗi kiểu Function A gọi Function 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ời
      Tô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
    • DWARF hiện hoàn toàn không mã hóa calling convention tùy chỉnh
    • Bin packing có thể còn làm chậm hơn, đặc biệt với bool thì có thể tạo ra chuỗi phụ thuộc
      Trê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ừ stack
      Nếu Rust ABI đã pack chặt bool trong struct theo cách này thì dù sao cũng phải làm, nhưng tôi không rõ lắm
      Và 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
    • Phần lớn bộ xử lý hiện đại dễ dàng forward thao tác đọc ngay sau khi ghi, và cũng có nhiều mẹo để theo dõi trạng thái stack
      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ể đặt Point3D vào rax, rdi, rsi, nó vẫn bị spill ra stack
    Cá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 Result có chứa lỗi hay không thì hay

    • “Tệ” là cách nói mạnh, nhưng về giá trị trả về thì đúng
      Calling 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ơn
      Nế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ểu mystruct.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óa
      Callee 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 Foo chứa 8 field Option, mỗi field là None hoặc Some(u8). Trong C, có thể biểu diễn bằng 8 bool 1 bit và 8 uint8_t, tổng cộng 9 byte
    Trong Rust, nó trở thành 16 byte do lặp lại 8 lần một discriminator 1 byte và một uint8_t
    Lý 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à &Option này phải có cùng hình dạng với mọi &Option khác trong chương trình
    Vì vậy Option bên trong phải có cùng layout với các Option khá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_field
    Nếu xét Option của các kiểu lớn hơn thì còn tệ hơn. Với một struct có 8 field Option, 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 byte
    Khi dùng Option, struct Rust có thể là 128 byte, còn struct C là 72 byte
    Tấ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 u8 dành cho các discriminator được đóng gói và 8 MaybeUninit, rồi tự viết các hàm ánh xạ từ &Foo sang Option<&T> và từ &mut Foo sang Option<&mut T>. Tuy nhiên không thể đi qua &Option hay &mut Option
    https://play.rust-lang.org/?version=stable&mode=debug&editio...

    • Phiên bản C cũng phải tự triển khai, nên việc Rust cũng phải làm thủ công tương tự không hẳn là lạ
      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ác Option bên trong
    • Phiên bản tương đương trong C cũng phải tự triển khai
      Việ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 Rust
  • Có đ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=legacy rồ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àm
    Như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?

    • Đúng vậy. Mọi người thường có xu hướng quên nửa phần trả về của quy ước gọi, nên có vẻ là một lỗi đánh máy có thể hiểu được
  • 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

    • Có thể. Có thể dùng FFI extern "C" qua CGO để gọi hàm Rust
      Tô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
    • Để gọi Rust từ Go, chỉ cần khai báo hàm Rust là extern "C", rồi gọi từ Go như gọi C
      Chiều ngược lại thì tôi không rõ
    • Trộn bộ nhớ được quản lý và không được quản lý thường không phải là ý hay
      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ư cgo cho 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
    • Vì phải dùng khá nhiều Rust và Swift, cuối cùng tôi chọn cách trao đổi mảng byte của protobuf đã tuần tự hóa qua các lời gọi hàm kiểu boilerplate
      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
    • Một ví dụ khá bị nguyền rủa: gần đây tôi từng gọi code Go từ Rust thông qua C ở giữa
      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?

    • Kiểu nằm trên phần tử .post-title: transform: skewY(-2deg) translate(-1rem, -0.4rem);
    • Liên quan đến chuyện này, tôi tưởng minimap dùng hàm CSS 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ụng transform: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:.25ex
  • Ngượ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

    • Cũng cần công bằng chỉ ra rằng cách làm của Swift có chi phí runtime
      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ỏ

    • Các hàm runtime, ví dụ dyn Trait, không thể được inline, nên thay đổi như thế này sẽ có ích
      Nế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ó lẽ là đáng
      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?