Calling Convention ของ Rust ที่เราควรได้รับอย่างแท้จริง
(mcyoung.xyz)บทความนี้อธิบายอย่างละเอียดถึงวิธีปรับปรุง Calling Convention ของภาษา Rust
ปัญหาของ Calling Convention ปัจจุบันของ Rust
- ปัจจุบัน Rust ยังไม่ได้กำหนด Calling Convention ไว้อย่างชัดเจน
- ในทางปฏิบัติ Rust ใช้ค่าเริ่มต้นของ C calling convention จาก LLVM
- ตอนนี้ Rust พยายามสร้าง LLVM function signature แบบอนุรักษ์นิยมให้ใกล้เคียงกับที่ Clang น่าจะสร้าง
- เพื่อให้เข้ากันได้กับดีบักเกอร์
- เพื่อหลีกเลี่ยงบั๊กของ LLVM
- แต่ความอนุรักษ์นิยมนี้มากเกินไป จึงทำให้แม้แต่ฟังก์ชันง่าย ๆ ก็ยังสร้างโค้ดได้ไม่ดี
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
- โค้ดข้างต้นควรถูกส่งผ่านรีจิสเตอร์ แต่กลับถูกส่งผ่านพอยน์เตอร์
- Rust อนุรักษ์นิยมกว่า C ABI เสียอีก หากระบุเป็น
extern "C"จะส่งผ่านรีจิสเตอร์
ข้อเสนอ Calling Convention ใหม่
- สำหรับฟังก์ชัน
extern "Rust"ให้คง Calling Convention เดิมไว้ - เพิ่มแฟล็ก
-Zcallconvเพื่อกำหนด Calling Convention ของฟังก์ชันextern "Rust"-Zcallconv=legacyคือวิธีปัจจุบัน-Zcallconv=fastคือวิธีใหม่ที่จะออกแบบ
- ทำไมต้องคง Calling Convention เดิมไว้?
- เพื่อความสะดวกในการดีบัก จึงไม่จัดวางตามลำดับแบบ C ABI
- บางเป้าหมายอย่าง WASM อาจไม่รองรับ
- ใน debug build อาจไม่มีความหมาย
- ข้อควรระวังเกี่ยวกับ function pointer และบล็อก
extern "Rust" {}- เป็นแฟล็กระดับ crate จึงใช้กับ function pointer ไม่ได้
- การเรียกผ่าน function pointer ทั้งช้าและพบไม่บ่อย จึงใช้
-Zcallconv=legacy - หากจำเป็นให้สร้าง shim เพื่อแปลง Calling Convention
- กรณีเรียกโดยตรงแบบ
extern "Rust" { fn my_func() -> i32; }- เรียกได้เฉพาะสัญลักษณ์ที่ไม่ถูก mangling
- ฟังก์ชัน
#[no_mangle]ใช้ Calling Convention เดิม
แนวทางการใช้ LLVM
- ในอุดมคติ ถ้าสามารถกำหนด Calling Convention ให้ LLVM ได้โดยตรงก็คงดี แต่ในความเป็นจริงทำได้ยาก
- สามารถอ้อมด้วยขั้นตอนต่อไปนี้
- ตรวจสอบจำนวนค่าสูงสุดที่ส่งผ่านรีจิสเตอร์ได้สำหรับเป้าหมายที่กำหนด
- ตัดสินใจว่าจะส่งค่าที่คืนกลับอย่างไร ถ้าพอดีกับรีจิสเตอร์ก็ส่งตรง ๆ ถ้าใหญ่เกินไปก็ส่งเป็นการอ้างอิง
- คัดเลือกอาร์กิวเมนต์ที่ส่งแบบ by-value แต่ควรส่งแบบ by-reference
- ตัวที่มีขนาดใหญ่กว่าพื้นที่ที่ส่งผ่านรีจิสเตอร์ได้
- บน x86 อยู่ที่ประมาณ 176 ไบต์
- ตัดสินใจว่าอาร์กิวเมนต์ใดควรถูกส่งผ่านรีจิสเตอร์เพื่อใช้พื้นที่รีจิสเตอร์ให้คุ้มที่สุด
- เป็นปัญหา NP-hard จึงต้องใช้ heuristic
- ที่เหลือส่งผ่านสแตก
- สร้าง function signature ใน LLVM IR
- อาร์กิวเมนต์ที่ส่งผ่านรีจิสเตอร์จะแทนด้วยชนิดที่ไม่ใช่ aggregate เช่น i64, ptr, double, <2 x i64>
- อาร์กิวเมนต์ที่ส่งผ่านสแตกจะตาม "register inputs"
- สร้าง function prologue
- ถอดรหัสอาร์กิวเมนต์ระดับ Rust จาก register inputs เพื่อสร้างค่า %ssa แบบเดียวกับตอน
-Zcallconv=legacy - ตัวฟังก์ชันสามารถสร้างโค้ดชุดเดียวกันได้โดยไม่ขึ้นกับ Calling Convention
- โค้ดถอดรหัสที่ไม่จำเป็นจะถูกลบด้วย DCE
- ถอดรหัสอาร์กิวเมนต์ระดับ Rust จาก register inputs เพื่อสร้างค่า %ssa แบบเดียวกับตอน
- สร้างบล็อกคืนค่าของฟังก์ชัน
- มีคำสั่ง phi สำหรับชนิดคืนค่าแบบเดียวกับตอน
-Zcallconv=legacy - เข้ารหัสเป็นรูปแบบผลลัพธ์ที่ต้องการแล้วคืนด้วย ret
- ต้อง branch มายังบล็อกนี้แทนการใช้ ret โดยตรง
- มีคำสั่ง phi สำหรับชนิดคืนค่าแบบเดียวกับตอน
- หากมีฟังก์ชันที่ไม่เป็น polymorphic และไม่ inline ซึ่งอาจถูกใช้เป็น function pointer
- กรณีถูกเปิดเผยออกนอก crate หรือถูกส่งผ่านเป็น function pointer
- ให้สร้าง shim ที่ใช้
-Zcallconv=legacyแล้ว tail call ไปยัง implementation จริง - จำเป็นเพื่อรักษาความเท่าเทียมกันของ function pointer
วิธีตรวจสอบข้อจำกัดของการส่งผ่านรีจิสเตอร์ใน LLVM
- มีโปรแกรม LLVM สำหรับตรวจสอบจำนวนสูงสุดของการส่งผ่านผ่านรีจิสเตอร์ที่ LLVM อนุญาต
- บน x86 รับอินพุตเป็นจำนวนเต็มได้ 6 ตัว, เวกเตอร์ SSE 8 ตัว และส่งออกเป็นจำนวนเต็ม 3 ตัว, เวกเตอร์ SSE 4 ตัว
- บน aarch64 อินพุตและเอาต์พุตเหมือนกัน คือจำนวนเต็ม 8 ตัวและเวกเตอร์ 8 ตัว
- เกินจากนี้จะถูกส่งผ่านสแตก
การจัดการ struct และ enum ของ Rust
- สมมติว่า rustc ได้จัดการเป็น aggregate พื้นฐานและ union เรียบร้อยแล้ว
- การจัดการค่าคืนกลับ
- สิ่งสำคัญไม่ใช่ขนาดของ struct แต่เป็นขนาดข้อมูลจริงโดยไม่นับ padding
- [(u64, u32); 2] มีขนาด 32 ไบต์ แต่เมื่อตัด padding 8 ไบต์ออกจะเหลือ 24 ไบต์
- นิยาม Effective Size ของชนิดข้อมูล
- คือจำนวนบิตที่ไม่ถูกกำหนดโดยไม่นับ padding
- [(u64, u32); 2] คือ 192 บิต
- bool คือ 1 บิต
- หาก Effective Size เล็กกว่าพื้นที่รีจิสเตอร์สำหรับเอาต์พุต ก็ให้คืนค่าแบบ by-value
- บน x86 จำนวนเต็ม 3 ตัว + SSE 4 ตัว = 88 ไบต์ = 704 บิต
- การจัดการรีจิสเตอร์ของอาร์กิวเมนต์
- เป็นปัญหา Knapsack จึงเป็น NP-hard
- heuristic แบบง่าย
- หาก Effective Size ใหญ่กว่าพื้นที่รีจิสเตอร์อินพุตทั้งหมด ให้ส่งแบบ by-reference
- enum ให้แทนด้วยคู่ discriminator-union
- union อาจแตะบิตที่ยังไม่ได้กำหนดค่าได้ จึงส่งเป็นอาร์เรย์
u8หรือเป็นตัวแปรทางเลือกหนึ่งตัวที่ไม่ว่าง - flatten ให้เหลือองค์ประกอบพื้นฐานที่สุด เช่น พอยน์เตอร์ จำนวนเต็ม จำนวนจริง บูลีน เป็นต้น
- เรียงลำดับตาม Effective Size จากน้อยไปมาก
- จัดสรร prefix ที่ใหญ่ที่สุดเท่าที่ทำได้ลงรีจิสเตอร์ ส่วนที่เหลือไปสแตก
- หากส่วนหนึ่งของอินพุตที่จะไปสแตกมีขนาดมากกว่าพหุคูณเล็ก ๆ ของขนาดพอยน์เตอร์ ให้ส่งเป็นพอยน์เตอร์บนสแตก
- ที่เหลือให้ส่งตรงบนสแตกตามลำดับก่อนการจัดเรียง
- สิ่งที่จะส่งผ่านรีจิสเตอร์ให้จัดสรรโดยเรียงขนาดจากมากไปน้อย
- บูลีนให้ bit-pack ครั้งละ 64 ค่า
ความเห็นของ GN+
- โดยส่วนตัวรู้สึกเสียดายมากกับ Calling Convention ปัจจุบันของ Rust เพราะจริง ๆ แล้วน่าจะให้ประสิทธิภาพได้ดีกว่า C++ มาก แต่ยังทำไม่ได้
- วิธีนี้เป็นสิ่งที่ภาษา Go ทำมาตั้งนานแล้ว
- เหตุผลที่ Rust ยังทำไม่ได้
- การสร้างโค้ด ABI ซับซ้อน และ LLVM ก็ช่วยอะไรได้ไม่มาก
- ในทีมคอมไพเลอร์มีคนที่เข้าใจ LLVM อย่างลึกซึ้งไม่มาก
- มีความกังวลเรื่องเวลาในการคอมไพล์ แต่ถ้าใช้เฉพาะใน optimized build ก็ไม่น่าเป็นปัญหาใหญ่
- ผู้เขียนไม่มีเวลาพอจะลงมือแก้เอง แต่ด้วยความเชี่ยวชาญด้าน LLVM ก็พร้อมช่วยทีมคอมไพเลอร์ Rust
- หรืออีกทางหนึ่ง การเปลี่ยนไปใช้
extern "C"หรือextern "fastcall"เลยก็อาจเป็นทางเลือกได้
1 ความคิดเห็น
ความเห็นจาก Hacker News
สรุป:
Option<u8>8 ฟิลด์จะมีขนาด 16 ไบต์ใน Rust และ 9 ไบต์ใน C&Option<T>หรือ&mut Option<T>ได้repr.