2 คะแนน โดย GN⁺ 2024-04-20 | 1 ความคิดเห็น | แชร์ทาง WhatsApp

บทความนี้อธิบายอย่างละเอียดถึงวิธีปรับปรุง 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
    • สร้างบล็อกคืนค่าของฟังก์ชัน
      • มีคำสั่ง phi สำหรับชนิดคืนค่าแบบเดียวกับตอน -Zcallconv=legacy
      • เข้ารหัสเป็นรูปแบบผลลัพธ์ที่ต้องการแล้วคืนด้วย ret
      • ต้อง branch มายังบล็อกนี้แทนการใช้ ret โดยตรง
    • หากมีฟังก์ชันที่ไม่เป็น 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 ความคิดเห็น

 
GN⁺ 2024-04-20
ความเห็นจาก Hacker News

สรุป:

  • เมื่อต้องสร้าง calling convention ที่ปรับแต่งมาอย่างเหมาะสม สิ่งสำคัญคือการวัดประสิทธิภาพโดยตรง โค้ดที่ดูแปลกอาจเป็นโค้ดที่เร็วที่สุดจริง ๆ ก็ได้
  • CPU ในปัจจุบันปรับแต่งการทำงานตาม instruction trace ที่สร้างโดยคอมไพเลอร์ C ดังนั้นการส่งค่าผ่านสแตกบ่อย ๆ แบบคอมไพเลอร์ C อาจช่วยได้
  • การ inline มักสำเร็จจนจุดเรียกใช้กลายเป็นขอบเขตที่เกิดขึ้นไม่บ่อย จึงอาจยอมให้มีความไม่สม่ำเสมอเล็กน้อยที่ขอบเขตนั้นเพื่อทำให้ส่วนอื่นเรียบง่ายขึ้นได้
  • struct ของ Rust ต้องสามารถให้ reference ไปยังฟิลด์ได้ จึงอาจมีขนาดใหญ่กว่า C struct ที่มีฟิลด์ Option<u8> 8 ฟิลด์จะมีขนาด 16 ไบต์ใน Rust และ 9 ไบต์ใน C
  • ใน Rust สามารถเขียน implementation ที่เทียบเท่ากับ C แบบ manual ได้ แต่ไม่สามารถแมปกับ &Option<T> หรือ &mut Option<T> ได้
  • Rust ยังไม่มี calling convention สำหรับ semantic ระดับ Rust โดยตรง Apple มีแรงจูงใจในการสร้างสิ่งนี้ แต่ Rust ยังไม่มีการรองรับแบบนั้น
  • การทำงานร่วมกันระหว่าง Go และ Rust ในตอนนี้สามารถทำได้โดยใช้ Zig เป็นตัวกลาง
  • คอมไพเลอร์ Rust ปัจจุบันทำการ inline และ optimization อย่างหนักอยู่แล้ว จึงน่าสงสัยว่าปัญหานี้คุ้มค่าที่จะแก้หรือไม่
  • สำหรับการดีบัก สามารถใช้แฟลกใน Cargo.toml เพื่อหลีกเลี่ยงความกังวลได้ การจัดเรียงฟิลด์ตามขนาดเป็น optimization ที่ทำได้ง่าย และปิดได้ด้วย repr.