Calling convention ของ Rust ที่เราควรได้รับ
(mcyoung.xyz)- calling convention ของ
extern "Rust"ใน Rust ปัจจุบันอาศัยเส้นทาง calling convention แบบ C ของ LLVM และเมื่อส่งค่าที่ซับซ้อน การใช้ register ยังระมัดระวังเกินไป จึงพลาดโอกาสสร้างโค้ดที่ดีกว่า - แก่นของแนวคิดคือแยกวิธีปัจจุบันอย่าง
legacyกับวิธีใหม่ที่เน้น register อย่างfastด้วยแฟล็กระดับ crate-Zcallconvและใช้ ABI ที่ก้าวร้าวขึ้นใน optimized build - แม้ไม่เพิ่ม calling convention ใหม่เข้าไปใน LLVM โดยตรง ก็สามารถควบคุมการจัดวาง argument ได้ด้วย LLVM function signature แบบคงที่และค่า
poisonเพื่อเว้น argument ใน register ที่ไม่ใช้ไว้โดยไม่มีต้นทุน - type ของ Rust เช่น struct, enum, union,
bool,Resultสามารถส่งผ่านได้แน่นขึ้นด้วย ขนาดที่มีผลจริง ที่ไม่รวม padding, การ flatten, bit packing และ heuristic สำหรับแบ่ง stack/register - หากนำข้อมูล function body, borrow checker และ profile มาใช้ตัดสิน ABI จะทำ optimization ได้แรงขึ้น แต่ความซับซ้อนของการสร้างโค้ด ABI ใน rustc และการขาดความเชี่ยวชาญ LLVM ยังเป็นอุปสรรคในทางปฏิบัติ
การ optimize calling convention ที่ Rust ยังพลาดอยู่ในปัจจุบัน
- calling convention เป็นส่วนหนึ่งของ ABI ที่กำหนดว่าจะส่ง argument และ return value อย่างไร ใช้ register ใด และจัดการ prologue/epilogue กับ unwinding อย่างไร
- Rust กำหนด calling convention แบบ unspecified ของตัวเอง แต่ในทางปฏิบัติถูก lower ลงเป็น calling convention แบบ C ที่ LLVM มีอยู่ และพึ่งพาการสร้างโค้ด prologue/epilogue ของ LLVM
- rustc ทำงานแบบระมัดระวัง โดยพยายามสร้าง LLVM function signature ที่ Clang น่าจะสร้าง
- ช่วยลดโอกาสที่ debugger จะพัง
- ลดโอกาสไปกระทบ bug ของ LLVM ผ่านเส้นทางสร้างโค้ด ABI ที่ Clang ไม่ค่อยใช้
- บนระบบฐาน ELF นั้น DWARF ไม่ได้ฝัง Linux C ABI ไว้ ดังนั้นในขอบเขตของบทความนี้จึงไม่ถือว่าความสามารถในการ debug เป็นประเด็นหลัก
- ตัวอย่างง่าย ๆ คือ
fn extract(arr: [i32; 3]) -> i32ซึ่ง array ขนาด 12 ไบต์ถูกส่งผ่าน pointer แทนที่จะส่งผ่าน register- ถ้าใส่
extern "C"ให้[i32; 3]เดียวกัน จะถูก pack แล้วส่งผ่านrdi,rsi - นี่เป็นกรณีที่เส้นทาง default ของ Rust ยังระมัดระวังกว่า Linux C ABI เสียอีก
- ถ้าใส่
-Zcallconv: วิธีแยก legacy กับ fast
- คง calling convention ปัจจุบันของ
extern "Rust"ไว้ แต่เลือก calling convention ที่จะใช้ผ่านแฟล็ก compile ระดับ crate-Zcallconv-Zcallconv=legacy: วิธีปัจจุบัน-Zcallconv=fast: วิธีใหม่ที่ออกแบบโดยเน้น register-Oอาจตั้ง-Zcallconv=fastให้อัตโนมัติได้
- calling convention แบบ
fastไม่ได้จัดวาง argument ตามลำดับของ C ABI ดังนั้นคนที่คาดหวังลำดับ register ตามธรรมเนียมของ x86 อาจสับสนได้ - บน target อย่าง WASM ที่ไม่มีแนวคิดเรื่อง register และ spilling อาจไม่รองรับ
-Zcallconv=fast - ใน debug build ที่ปิด optimization นั้น
fastอาจสร้างโค้ดที่แย่กว่า จึงอาจไม่เหมาะที่จะเปิดใช้ - function pointer และบล็อก
extern "Rust" {}ต้องมีข้อจำกัดแยกต่างหาก- แฟล็กเป็นระดับ crate แต่ function pointer แสดงได้ยากว่ากำลังใช้
extern "Rust"เวอร์ชันใด - การเรียกผ่าน function pointer อาจถูกมองว่าเป็นเส้นทางที่ช้าและพบไม่บ่อย แล้วบังคับใช้
-Zcallconv=legacy - หากจำเป็นให้สร้าง shim สำหรับแปลง calling convention
- เนื่องจากมีเส้นทางที่สามารถเรียก unmangled symbol ได้ จึงอาจให้ symbol ที่เป็น
#[no_mangle]ใช้ calling convention แบบ legacy ด้วย
- แฟล็กเป็นระดับ crate แต่ function pointer แสดงได้ยากว่ากำลังใช้
วิธีควบคุม LLVM ทางอ้อม
- ในอุดมคติ เราอยากกำหนด calling convention โดยตรงกับ LLVM เช่น “argument นี้ใช้ register นี้ return value นี้ใช้ register นั้น” แต่การเพิ่ม calling convention ให้ LLVM ต้องเขียนโค้ด C++ จำนวนมาก
- แทนที่จะทำเช่นนั้น สามารถใช้ขั้นตอนต่อไปนี้เพื่อให้ได้ผลใกล้เคียง calling convention ของตัวเอง
- กำหนดจำนวนค่าสูงสุดที่ส่งผ่าน register ได้ในแต่ละ target triple
- ตัดสินว่า return value ใส่ใน output register ได้หรือไม่ หรือควร return แบบ by-reference ผ่าน argument
ptrเพิ่มเติมที่ติด attributesret - ลด by-value argument ที่ใหญ่เกินไปให้เป็น by-reference
- ตัดสินว่าจะส่ง argument ใดผ่าน register เพื่อเพิ่ม อัตราการใช้พื้นที่ register ให้สูงสุด
- argument ที่เหลือวางไว้บน stack
- LLVM IR function signature ประกอบด้วย non-aggregate argument เช่น
i64,ptr,double,<2 x i64> - ใน function prologue ให้ decode register input กลับเป็น argument ระดับ Rust
- ในบล็อกจบฟังก์ชัน ให้ encode return value เป็นรูปแบบ output ที่ต้องการแล้ว
ret - สำหรับ non-polymorphic, non-inline function ที่อาจถูกนำ address ไปใช้ ให้สร้าง legacy shim เพื่อรักษา identity ของ function pointer
- ปัญหาการตัดสินว่าจะใส่ค่าใดใน register เหมือนกับ ปัญหา knapsack ซึ่งเป็น NP-hard และการ implement จริงต้องใช้ heuristic
- ไม่ควรคำนวณข้อมูลนี้ช้าเกินไป และสามารถใส่ไว้ใน
rmetaเพื่อหลีกเลี่ยงการคำนวณซ้ำ - Rust มี ABI ที่แตกในแต่ละ release อยู่แล้ว ดังนั้นเงื่อนไขที่ว่าต้องไม่ให้ link โค้ดที่สร้างจาก compiler Rust หลายตัวต่างเวอร์ชันเข้าด้วยกันนั้นก็สอดคล้องกับสถานการณ์ปัจจุบันอยู่แล้ว
ขีดจำกัดการส่งผ่าน register ที่ LLVM อนุญาต
- เมื่อส่ง aggregate by-value argument เข้า function LLVM จะพยายาม “explode” เป็น register ให้มากที่สุดเท่าที่เป็นไปได้
- บน x86 input ที่ LLVM ส่งผ่าน register ได้มีประมาณนี้
- integer 6 ตัว
- SSE vector 8 ตัว
- return ได้ครึ่งหนึ่ง คือ integer 3 ตัวและ vector 4 ตัว
- บน
aarch64-unknown-linuxทั้ง input และ output ใช้ได้ทั้ง integer 8 ตัว และ vector 8 ตัว - สามารถออกแบบให้ทุกฟังก์ชัน
-Zcallconv=fastบน x86 มีจำนวน by-register argument เท่ากันได้- argument สำหรับ integer register 6 ตัว
- vector argument 8 ตัวตั้งแต่
xmm0ถึงxmm7 - เมื่อส่ง pointer จริง ให้เปลี่ยน
i64ที่เกี่ยวข้องเป็นptr - เมื่อส่ง
doubleให้ใช้แทนตำแหน่ง<2 x i64>
- แม้ฟังก์ชันส่วนใหญ่จะไม่ได้ส่งข้อมูล 176 ไบต์ แต่ถ้าส่ง LLVM
poisonให้ argument ที่ไม่ได้ใช้ ก็หลีกเลี่ยงต้นทุนเพิ่มเติมได้- LLVM สามารถมอง
poisonเป็นค่าที่สะดวกที่สุดในขณะนั้นได้ - เมื่อส่ง
poisonเป็น register argument ก็สามารถจัดการเหมือน “ค่าที่อยู่ใน register นั้นอยู่แล้ว” จึงไม่จำเป็นต้องแตะ register - ในตัวอย่าง
load_rcx()รับ pointer ผ่านrcxและโค้ดที่ใส่poisonลงใน register อีก 13 ตัวที่เหลือจะไม่สร้างโค้ดใด ๆ หลัง optimization
- LLVM สามารถมอง
- วิธีนี้ทำให้ควบคุมการส่ง argument ได้เกือบทั้งหมด แต่สถานการณ์อุดมคติที่ใช้ register เดียวกันทั้ง input และ output ต่างกันไปตามสถาปัตยกรรม
- ARM และ RISC-V ใกล้เคียงกับโครงสร้างที่ใช้ register เดียวกันสำหรับ input และ output
- x86 ไม่ใช่แบบนั้น แต่สามารถลดการย้าย register ที่ไม่จำเป็นได้โดยสมมติลำดับการจัดสรร register ให้ต่างออกไป
จัด type ของ Rust ให้เข้ากับ register ได้ดีขึ้น
- เมื่อจัดการ struct และ union ของ Rust ให้ถือว่า rustc ได้ประมวลผล user type เป็น aggregate และ union พื้นฐานแล้ว จากนั้นจึงตัดสินว่าส่วนใดควรอยู่ใน register
- สำหรับ return value สิ่งที่สำคัญกว่าขนาดรวมของ struct คือ ขนาดที่มีผลจริงซึ่งไม่รวม padding
[(u64, u32); 2]มีขนาดรวม 32 ไบต์ แต่ 8 ไบต์เป็น padding- หาก flatten เป็น
(u64, u32, u64, u32)แล้วเรียงตามขนาดเป็น(u64, u64, u32, u32)จะเหลือ 24 ไบต์ - สามารถใส่ใน integer return register 3 ตัวของ x86 ได้
- ขนาดที่มีผลจริงนิยามจากจำนวนบิตที่ไม่ใช่
undef[(u64, u32); 2]คือ 192 บิตboolคือ 1 บิต- ในทางเทคนิค
charคือ 21 บิต แต่เพื่อความง่ายให้ปฏิบัติเหมือน alias ของu32
- struct ที่มี
boolจำนวนมากสามารถ return โดย bit packingboolหลายตัวลงใน register เดียวได้ - ฝั่ง argument ยากกว่า และสามารถใช้ heuristic ต่อไปนี้
- argument ที่มีขนาดที่มีผลจริงใหญ่กว่าพื้นที่ input แบบ by-register ทั้งหมด ให้ลดเป็น by-reference
- บน x86 พื้นที่ input ทั้งหมดคือ 176 ไบต์ หรือ 1408 บิต
- enum ถูกแปลงเป็นคู่ discriminant กับ union
Option<i32>มองภายในได้เหมือน(union { i32, () }, i1)Option<Option<i32>>มองได้เหมือน(union { i32, (), () }, i2)
- union มักส่งผ่านเหมือน array ของ
u8เพราะอาจแตะบิตที่ยังไม่ initialize ได้ตามอำเภอใจ - union ที่มี non-empty variant เพียงตัวเดียว ให้แทนด้วย variant นั้น
- argument ที่แปลงแล้วถูก flatten เป็น primitive เช่น pointer, integer, float, bool
- field ที่ใหญ่กว่า argument register ขนาดเล็ก เช่น
u128,f64สามารถแบ่งย่อยได้ - เรียงรายการ primitive ตามขนาดที่มีผลจริง แล้วเลือก prefix ที่ใหญ่ที่สุดที่ใส่ register ได้
- ส่วนที่เหลือวางบน stack
- ถ้าส่วนที่ไป stack ใหญ่กว่าตัวคูณเล็ก ๆ ของขนาด pointer ให้ลดเป็น pointer-on-the-stack เพื่อลด memory traffic
- ค่าที่ส่งผ่าน register ให้วางจากค่าที่ใหญ่ก่อน และ
boolให้ bit packing ได้สูงสุด 64 ตัวต่อ register
ตัวอย่างฟังก์ชัน Rust ที่ซับซ้อนและข้อจำกัดของ rustc ปัจจุบัน
- ในตัวอย่าง
do_thingที่รับOption<usize>,&dyn Context,&str,[char; 6], structOptionsหลัง flatten และจัดเรียงแล้ว raw LLVM argument ทั้งหมดสามารถใส่ใน register ได้ - raw argument LLVM type ในตัวอย่างจะมีรูปแบบดังนี้
gprs: i64, ptr, ptr, ptr, i64, i32, i32xmm0: i32, i32, i32, i32xmm1: i32, i1, i1, i1, i1
- function prologue จะดึง primitive ออกมาแล้วประกอบกลับเป็นค่าระดับ Rust
Option<usize>คือ{ i64, i1 }- trait object คือ
{ ptr, ptr } &strคือ{ ptr, i64 }[char; 6]คือ[6 x i32]Optionsคือ{ i32, i1, i1, i1 }
- หากใส่ metadata
!dbgให้ instruction ที่ materialize ค่า argument จริง gdb จะให้ผลลัพธ์ที่ดีขึ้นเมื่อพิมพ์ค่า argument - ปัจจุบัน rustc ส่ง pointer-sized parameter 8 ตัวให้ LLVM สำหรับฟังก์ชันเดียวกัน ส่งผลให้ใช้ integer register ครบ 6 ตัว และอีก 2 ค่าไปอยู่บน stack
โอกาส optimize return value และ Result
- การออกแบบนี้ไม่ได้ครอบคลุม optimization ของ calling convention ทั้งหมดที่เป็นไปได้
- ในบางกรณีสามารถใช้ register เพิ่มเติม เช่น AVX register ของ x86
- อาจพิจารณาวิธีส่ง struct โดยแบ่งระหว่าง register กับ stack ได้ด้วย
- การ return
Resultยังมีช่องทาง optimize แยกต่างหาก- เมื่อผ่าน
?ข้ามหลายชั้นของฟังก์ชัน อาจเกิดการย้าย register ซ้ำซ้อนจำนวนมาก - ถ้า
Resultใหญ่จนใส่ใน register ไม่ได้ ทุกการเรียก?ต้อง load ok bit จาก memory บน stack เพื่อตรวจสอบ - ทางเลือกคือให้ error เป็น out-parameter pointer และ return payload ของ ok variant กับ is-ok bit เป็น
Option<T> - รายละเอียดที่
?มาพร้อมการเรียกIntoนั้นจัดการยาก แต่ implement ได้
- เมื่อผ่าน
ABI ที่ขึ้นกับ optimization
- ต่างจาก C, ใน
-Zcallconv=fastRust สามารถดู function body ได้เมื่อสร้าง ABI ที่ caller จะเห็น - crate สามารถประกาศ ABI ที่แม่นยำในมุมมองการส่งผ่าน register แยกตามฟังก์ชันได้
- optimization ที่ง่ายที่สุดคือทิ้ง argument ที่ไม่ใช้จาก ABI
- ถ้าฟังก์ชันไม่ใช้ parameter ใดเลย ก็ไม่ใช้ register กับ argument นั้น
- ถ้า argument
&Tไม่ถูกเก็บไว้ ไม่ถูกแปลงเป็น raw pointer และTมีขนาดเล็กพร้อมT: Freezeก็สามารถส่งตัว pointee เองแบบ by-value แทน reference ได้ - API อย่าง
HashMap::get()เป็นผู้สมัคร- ถ้า key เป็น type อย่าง
i32ปัจจุบันต้อง spill integer ลง stack แล้วส่ง pointer ไปให้ - memory traffic นี้สามารถหลีกเลี่ยงได้
- ถ้า key เป็น type อย่าง
- ABI ที่อิง profile เป็นรูปแบบที่ก้าวร้าวกว่า
- สามารถให้ argument ที่ hot กว่าได้ลำดับความสำคัญในลำดับการจัดสรร register
- แม้รับ struct ขนาดใหญ่เป็น reference ก็ให้ caller load field
i64ที่ hot 3 ตัวล่วงหน้า แล้วส่งทั้ง pointer และ register ได้ - callee ต้อง load ค่าเหล่านั้นอยู่แล้ว จึงไม่เสียต้นทุนเพิ่ม
- instrumentation profile อาจให้เหตุผลพอสำหรับการ clone ฟังก์ชันที่ต่างกันเฉพาะ ABI
ทำไมยังไม่ได้ทำ
- Rust มีข้อจำกัด ABI น้อยกว่า C++ จึงสามารถสร้างโค้ดที่ดีกว่าได้ และแนวคิดนี้สอดคล้องกับวิธีที่ Go register ABI ใช้งานจริง
- อุปสรรคแรกคือ ความซับซ้อนของการสร้างโค้ด ABI
- LLVM แทบไม่มี knob ควบคุมที่เป็นประโยชน์ให้ใช้
- ภายใน rustc เองก็ไม่ใช่พื้นที่ที่เป็นมิตรนัก
- หาก implement ผิด อาจส่งผลเสียต่อ usability ได้
- อุปสรรคอีกอย่างคือการขาดความเชี่ยวชาญ
- ในหมู่ผู้ร่วมพัฒนา rustc มีเพียงไม่กี่คนที่เข้าใจ semantics ของ LLVM และลักษณะการสร้างโค้ดดีพอที่จะสร้างโค้ดที่ดีและไม่ทำให้ LLVM crash
- compile time ก็อาจเป็นภาระได้
- ยิ่ง function signature ซับซ้อนขึ้น LLVM ก็ยิ่งต้องประมวลผลโค้ด prologue/epilogue มากขึ้น
- อย่างไรก็ตาม
-Zcallconvตั้งใจให้ใช้เฉพาะเมื่อเปิด optimization ดังนั้นจึงไม่ถือเป็นข้อเสียชี้ขาด
- โค้ด ABI ของ Rust เป็นพื้นที่ที่มี bus factor ต่ำ และความรู้ LLVM สามารถนำมาใช้ได้โดยตรงเพื่อช่วยทีม compiler ของ Rust สร้างโค้ดที่ optimize ดีขึ้น
1 ความคิดเห็น
ความคิดเห็นใน Hacker News
หัวใจสำคัญในการปรับแต่ง calling convention ไม่ใช่การคิดในหัวว่ารูปแบบไหนดูดี แต่คือการ วัดประสิทธิภาพ
โค้ดที่ดีคือโค้ดที่ต้องเร็ว ไม่ใช่โค้ดที่ดูเหมือนจะเร็ว
สิ่งที่ผู้เขียนเรียกว่าโค้ดแย่ บางครั้งกลับเร็วที่สุดด้วยเหตุผลที่ไม่เป็นไปตามสัญชาตญาณเลย และเรื่องนั้นจะรู้ได้ก็ต่อเมื่อนำไปวัดกับ benchmark ขนาดใหญ่
เหตุผลหนึ่งที่ calling convention ที่ดูแย่ทำงานได้ดี คือมันช่วยประหยัด รีจิสเตอร์สำหรับอาร์กิวเมนต์ ทำให้ register allocator ทำงานง่ายขึ้นเล็กน้อย
อีกอย่าง CPU ในปัจจุบันถูกปรับแต่งมาให้เหมาะกับกระแสคำสั่งที่คอมไพเลอร์ C สร้างขึ้น ดังนั้นถ้าสร้างโค้ดแบบคอมไพเลอร์ C ที่ส่งผ่านสแต็กบ่อยเกินคาด โดยเฉพาะแบบ MSVC ก็อาจตรงกับจุดเหมาะสมของ CPU ได้
เพราะ inlining ทำได้ดีมาก การเรียกฟังก์ชันบน hot path จึงกลายเป็นขอบเขตที่พบไม่บ่อย และแม้ขอบเขตนั้นจะรกไปบ้าง แต่ถ้ามันทำให้สิ่งอื่น ๆ เรียบง่ายขึ้นก็ไม่เป็นไร
ไม่ได้หมายความว่าการเปลี่ยนแปลงตรงนี้แย่ แต่การถกเถียงโดยดูแค่โค้ดที่ดูแปลกโดยไม่วัดผลนั้นเป็นเรื่องแปลก
ผมเคยทำงานปรับแต่ง calling convention ใน JavaScriptCore เป็นอาชีพ และในโค้ดขนาดใหญ่จริง ๆ โค้ดส่งผ่านสแต็กที่ดูแย่กลับชนะบ่อยจนน่าประหลาดใจ
อย่างไรก็ตาม ผมไม่คิดว่าผลการวัดประสิทธิภาพควรเป็นเกณฑ์เดียวเท่านั้น
ในคำว่า CPU “ปัจจุบัน” ถูกปรับแต่งมา คำสำคัญคือปัจจุบัน และ CPU ก็เปลี่ยนไปเรื่อย ๆ ดังนั้น calling convention ควรเป็น การออกแบบระยะยาว
เพราะฉะนั้นถึงจะน่าเสียดาย แต่การไม่ออกห่างจากวิธีที่ C++ ทำมากเกินไปน่าจะได้เปรียบกว่า เพราะการปรับแต่งโปรเซสเซอร์ในอนาคตก็น่าจะเล็งไปทางนั้น
ขณะเดียวกันก็ควรพิจารณาหลักการทั่วไปที่ไม่เปลี่ยนง่าย เช่น การประหยัดรีจิสเตอร์สำหรับอาร์กิวเมนต์ เพื่อทำให้ calling convention แข็งแรงและมองไปข้างหน้า
พูดแบบนี้แล้วรู้สึกแปลกอยู่บ้าง เพราะช่วงไม่กี่ปีที่ผ่านมา Rust ดูเหมือนจะอนุรักษนิยมเกินไปในแง่เพดานความแปลก (https://steveklabnik.com/writing/the-language-strangeness-bu...) ท้ายที่สุด ถ้าไม่แตกต่างก็ไม่มีทางดีขึ้นได้
ถ้าฟังก์ชันเริ่มมาก็เอาที่อยู่ของพารามิเตอร์แล้วส่งต่อให้ฟังก์ชันที่ไม่รู้จักอยู่ดี สุดท้ายก็ต้อง spill ลงสแต็กอยู่แล้ว
น่าจะน่าสนใจถ้าได้เห็นการปรับแต่ง calling convention ตามตัวฟังก์ชัน สำหรับ static function ใน C ก็น่าจะปลอดภัย ตราบใดที่ไม่ได้ถูกนำ address ไปใช้
JIT ได้เปรียบในประเด็นนี้ เพราะก่อนจะสร้างแอสเซมบลีแม้แต่บรรทัดเดียว มันก็เก็บข้อมูลเกี่ยวกับ CPU ที่กำลังรันจริงไว้มากแล้ว
ในโค้ดที่คอมไพล์แบบ static ล้วน ๆ เราไม่สามารถรู้ชุดความสามารถของสถาปัตยกรรมตอนรันไทม์ได้ จึงมักเจอ กำแพง inlining บ่อยในโค้ดที่อยากปรับแต่งมากที่สุดเสียเอง
ตอนนี้ Rust ดูอ่อนในจุดนี้บนแพลตฟอร์มขนาดเล็ก และ calling convention อาจช่วยได้ในส่วนที่เกี่ยวข้องกับการคืนค่า
Resultถึงอย่างนั้นก็ยังสงสัยว่าข้อได้เปรียบเชิงประสบการณ์ของการส่งผ่านทางสแต็กจะยังใช้ได้ต่อไปหรือไม่เมื่อย้ายไปยัง CPU ARMV8 ที่มีรีจิสเตอร์จำนวนมาก หรือ RISC-V
เป็นร่างที่สมเหตุสมผล แต่ยังขาดการแยกระหว่าง caller-saved/callee-saved และมีข้อผิดพลาดที่พบบ่อยคือการจัดสรรรีจิสเตอร์ขาเข้าบางส่วนให้เป็นขาออก
ความคาดหวังว่า debugger จะเข้าใจ calling convention ที่ต่างจาก C ก็ดูมองโลกในแง่ดีเกินไปด้วย ไม่ว่า DWARF จะ encode อะไรได้ ในทางปฏิบัติมีโอกาสสูงที่จะล้มเหลวอย่างย่อยยับ
ถ้าเปลี่ยน ABI ตามการตั้งค่า optimization จะมีปฏิสัมพันธ์ที่แย่มากกับ การคอมไพล์แยกส่วน
วิธีจัดเรียงอาร์กิวเมนต์ใหม่เหมือน bin packing น่าจะทำงานได้ แต่จะเพิ่มความซับซ้อนของ compiler อย่างมาก และไม่แน่ใจว่าคุ้มค่าเมื่อเทียบกับการวางแบบ first-fit จากซ้ายไปขวาหรือไม่ นักพัฒนาก็จะคาดเดาได้ยากขึ้นว่าอาร์กิวเมนต์จะไปอยู่ตรงไหน
แนวทางใหญ่ที่ให้ฟังก์ชันที่ address หลุดออกไปกับฟังก์ชันที่ไม่เป็นเช่นนั้นใช้ calling convention ต่างกันนั้นสมเหตุสมผล วิธีแยก prologue ที่ทำ impedance matching ออกมาก็น่าจะทำงานได้ดี
Rust ควรเต็มใจมี calling convention ที่ต่างจาก C แต่ไม่แน่ใจว่าควรเป็น convention เดียวแบบ hard-coded ที่ทุกฟังก์ชันใช้หรือไม่ การใส่ไว้ใน type system ดูเป็นธรรมชาติกว่า และถ้าให้นักพัฒนาควบคุม calling convention ได้ ข้อได้เปรียบด้านประสิทธิภาพอย่างหนึ่งของ assembly ก็จะหายไป
จากมุมมองของ caller ยังไงก็ต้องเคลียร์รีจิสเตอร์ขาออกระหว่างการเรียกฟังก์ชันสองครั้งอยู่แล้ว และใน system call convention ก็ใช้กันค่อนข้างแพร่หลาย
เดาว่าอาจเป็นเพราะอยากให้ callee เตรียมค่าขาออกได้ง่ายโดยยังคงค่าขาเข้าไว้ ถ้าเป็นอย่างนั้น การวางรีจิสเตอร์ขาออกไว้ท้ายลำดับขาเข้าเพื่อหลีกเลี่ยงการทับซ้อนก็พอเข้าใจได้ แต่ยังไม่ค่อยเห็นเหตุผลว่าทำไมต้องห้ามการทับซ้อนใด ๆ โดยสิ้นเชิง
Function AเรียกFunction B,Function C,Function Dไปพร้อมกันสงสัยว่า semantics ที่ทั้งรักษา optimization แบบนั้นไว้และยังอนุญาตให้ควบคุมได้จะเป็นอย่างไร หรือจริง ๆ แล้วมันเป็นแค่ภาพลวงตา
ในความเป็นจริง assembly ส่วนใหญ่ไม่ใช่เป้าหมายของการ optimize ของ compiler จึงมีข้อเสียด้านประสิทธิภาพอยู่ หลายครั้งไม่ได้รับ optimization แบบ “ตรวจดูพฤติกรรมแล้วตัดสินว่าเป็นของซ้ำทั้งหมด จากนั้นลบทิ้งทั้งก้อน” ด้วย และตอนนี้ก็ไม่ใช่ยุค 1990 แล้ว
อย่างไรก็ตาม ถ้าเป็นกรณีที่ไม่สามารถพิจารณา optimization แบบนั้นได้เลย ผมมองว่าสิ่งที่ inline assembly จะเสียเปรียบชัดเจนก็คงมีแค่ profile-guided optimization เท่านั้น เพราะนักพัฒนาแอปพลิเคชันรู้พฤติกรรมของโค้ดอย่างสมบูรณ์ แต่ผู้พัฒนา compiler ไม่รู้
overhead จากการเรียกสามารถกำจัดได้ด้วยการใช้ assembly เพิ่มจนกว่าจะครอบคลุม boundary ร้อน ๆ ที่เกี่ยวข้อง
boolอาจสร้าง dependency chain ได้บน x64 ดูเหมือนไม่มีวิธีที่ดีกว่าการนำ
boolใส่รีจิสเตอร์ก่อน แล้ว shift จากนั้น OR เข้ากับผลลัพธ์วิธีแบบง่ายจะสร้าง dependency chain ความยาว 64 และอาจมี penalty 64 cycle แต่ถ้าทำดี ๆ อาจลดเหลือ 6 cycle หรือในทางปฏิบัติราว 12 cycle ได้
แต่ปัญหาคือ
boolทั้ง 64 ตัวนั้นมาจากไหนด้วย รีจิสเตอร์ไม่ได้มีมากขนาดนั้น สุดท้ายก็ต้องอ่านกลับจาก stack อยู่ดีถ้า Rust ABI pack
boolใน struct ให้แน่นแบบนี้อยู่แล้ว ก็เป็นงานที่ต้องทำไม่ว่าอย่างไร แต่ไม่แน่ใจนักแล้ว caller ก็ต้องคลี่ทั้งหมดออกอีกครั้ง
การสอน compiler ให้ปล่อยค่าไหลลงไปยังพื้นที่ผลลัพธ์บน stack น่าจะง่ายกว่า และมีโอกาสให้ประสิทธิภาพดีกว่าด้วย
ถ้าอย่างนั้นก็สงสัยว่าการใส่ค่าไว้ในรีจิสเตอร์ช่วยได้จริงมากแค่ไหน
C calling convention ไม่ค่อยดีนัก
เป็นความจริงที่เราเปลี่ยน C calling convention ไม่ได้ แต่นั่นก็ไม่ได้ทำให้เสียดายน้อยลง
ควรใช้ caller-saved registers ทั้งหมดที่มีอยู่สำหรับอาร์กิวเมนต์และค่าส่งคืน แต่ใน SysV ABI แบบดั้งเดิมใช้รีจิสเตอร์สำหรับค่าส่งคืนแค่หนึ่งตัว บางครั้งสองตัวเท่านั้น
ถ้าส่งคืน
struct Point3D { long x, y, z }ทั้งที่สามารถใส่Point3Dลงในrax,rdi,rsiได้ ก็ยัง spill ลง stackระบบอื่นก็มีเทคนิคอื่น ๆ ด้วย ถ้าจำไม่ผิด ใน SBCL เวลาฟังก์ชันคืนค่าหลายค่า จะตั้ง carry flag ตอนจบ ตัวอย่างเช่น ผมคิดว่าน่าจะดีถ้าใช้ carry flag เพื่อบอกว่า
Resultมี error อยู่หรือไม่C calling convention โดยพื้นฐานรองรับสิ่งที่ C รองรับ นั่นคือการคืนค่าอาร์กิวเมนต์เดียว แม้แต่การคืนค่า struct ก็ยังไม่ค่อยดีนัก
ใน C จะออกแนว “ก็รู้อยู่แล้วไม่ใช่เหรอ” ส่วนฝั่ง C++ ก็จะเป็นทำนอง “ก็ inline ไปสิ”
ในทางกลับกัน memory spill เกิดขึ้นจริง ตัวอย่างเช่น พื้นที่รีจิสเตอร์และ window ที่กว้างขวางของ SPARC ทำให้ฟังก์ชันง่าย ๆ เหลือรีจิสเตอร์ที่ไม่ได้ใช้จำนวนมาก และเมื่อ spill register ring ก็ทำให้ใช้ stack ปริมาณมากจนทำลาย cache
บน x86 แม้จะมี
movจำนวนมากที่ย้ายข้อมูลไปยัง “ที่ที่ต้องใช้” แต่ผลลัพธ์มักเร็วกว่าถ้าดูแค่โค้ดของ callee ก็อยากพูดว่า “ถ้าอาร์กิวเมนต์นี้อยู่ตรงนี้ ค่าส่งคืนนั้นอยู่ตรงนั้น ยังไงก็ต้องเร็วกว่า” แต่เราไม่รู้ caller
รับประกันไม่ได้ว่าการเตรียมอาร์กิวเมนต์จะผ่านไปได้ตามเดิม หรือค่าส่งคืนจะถูกใช้ทันทีในเส้นทางร้อนหรือไม่ ตัวอย่างเช่น ถ้าใช้
struct Point { x: i32, y: i32, z: i32 }เป็นอาร์กิวเมนต์/ค่าส่งคืน และ caller ทำสิ่งอย่างmystruct.deepinside.point[i] = func(mystruct.deepinside.point[i])ในลูป การใส่เข้าและเอาออกจากรีจิสเตอร์อาจกลายเป็น overhead หรืออาจขัดขวาง vectorization ได้callee ไม่มีทางรู้เรื่องนี้ และจะเป็นข้อยกเว้นก็ต่อเมื่อ compiler มองเห็นทั้งสองฝั่งและ inline ได้เท่านั้น
ผลไม้ที่ห้อยต่ำที่สุดเกี่ยวกับการเรียกฟังก์ชันน่าจะเป็นการเอาสมมติฐานที่ฝังอยู่ใน C ABI แทบทั้งหมดว่า ฟังก์ชันคืนค่า primitive หนึ่งค่า ออกไป ส่วนที่เหลือต้องอาศัย benchmark และสถิติการสร้างโค้ดจำนวนมาก
ใน Rust ยังมีรายละเอียดที่น่าเสียดายอีกอย่างที่ทำให้ struct ใหญ่กว่าที่เราต้องการ
ลองคิดถึง struct
Fooที่มีฟิลด์Option8 ฟิลด์ ซึ่งเป็นNoneหรือSome(u8)ในภาษา C สามารถแทนได้ด้วยboolขนาด 1 บิต 8 ตัวกับuint8_t8 ตัว รวมเป็น 9 ไบต์แต่ใน Rust จะกลายเป็นตัวแยกแยะขนาด 1 ไบต์กับ
uint8_tซ้ำ 8 ครั้ง รวมเป็น 16 ไบต์เหตุผลคือ struct ต้องสามารถให้ยืมฟิลด์ได้ เมื่อมี
&Fooคอมไพเลอร์ต้องสามารถสร้าง&Foo::some_fieldหรือก็คือ&Optionได้ และ&Optionนี้ต้องมีรูปแบบเดียวกับ&Optionอื่นทั้งหมดในโปรแกรมดังนั้น
Optionภายในจึงต้องมี layout แบบเดียวกับOptionอื่นในโปรแกรม คือมีบิตแยกแยะของตัวเองที่ถูกปัดขึ้นเป็นไบต์ และมีu8ต่อให้ไม่ได้สร้าง&Foo::some_fieldจริง ๆ struct ก็ยังต้องจ่ายต้นทุนนี้ถ้าคิดถึง
Optionของชนิดที่ใหญ่กว่านี้ สถานการณ์ยิ่งแย่ลง ใน struct ที่มีฟิลด์Option8 ฟิลด์ ตัวแยกแยะแต่ละตัวจะถูกปัดขึ้นเป็น 2 ไบต์ รวมเป็น 32 ไบต์ และหนึ่งในสี่ หรือถ้านับบิตที่ไม่ได้ใช้ของตัวแยกแยะด้วยก็เกือบครึ่งหนึ่ง จะสูญเปล่าไปกับ padding ระหว่างกลาง โครงสร้างเทียบเท่าใน C ใช้แค่ 18 ไบต์ก็พอเมื่อใช้
Optionstruct ของ Rust อาจเป็น 128 ไบต์ ขณะที่ struct ของ C เป็น 72 ไบต์แน่นอนว่าเราสามารถทำ representation แบบเดียวกับ C ได้ โดยมี
u8หนึ่งตัวสำหรับตัวแยกแยะที่ pack แล้ว และมีMaybeUninit8 ตัว จากนั้นเขียนฟังก์ชันเองเพื่อ map จาก&Fooเป็นOption<&T>และจาก&mut Fooเป็นOption<&mut T>เพียงแต่จะทำเป็น&Optionหรือ&mut Optionไม่ได้https://play.rust-lang.org/?version=stable&mode=debug&editio...
โดยพื้นฐานแล้วนี่คือการอธิบาย ชนิดที่ผู้ใช้กำหนดเอง ซึ่งบรรจุ
Option8 ตัว และเมื่อเริ่มใส่ใจเรื่อง performance ก็ต้องจัดการOptionภายในเองการที่ Rust มีฟีเจอร์สะดวก ๆ ให้เลือกใช้เมื่อเข้ากับเป้าหมาย จึงยากจะเรียกว่าเป็นข้อเสีย
use case ที่อธิบายมาค่อนข้างพบไม่บ่อย และถ้ามันเป็นคอขวดด้าน performance จริง การใช้เวลาเพิ่มอีกเล็กน้อยเพื่อ implement ใน Rust ก็ไม่ใช่ปัญหาใหญ่
ในการใช้งานทั่วไป ข้อดีของชนิด
Option<_>มีมากมาก ดังนั้นจึงยากจะมองว่านี่เป็น “รายละเอียดที่น่าเสียดาย” ของ Rustมีประเด็นว่า ถ้าที่อยู่ของฟังก์ชันแบบไม่เป็น polymorphic และไม่ inline สามารถถูกนำไปใช้เป็น function pointer ได้ ก็จะสร้าง shim ที่ใช้
-Zcallconv=legacyแล้ว tail-call ไปยัง implementation จริงทันที เข้าใจเจตนาที่ต้องการรักษาความเท่ากันของ function pointerแต่ถ้า legacy shim tail-call ไปยังฟังก์ชันที่ใช้ calling convention ของ Rust มันแก้ ความต่างของค่าที่คืนกลับ ใน calling convention ไม่ได้ไม่ใช่หรือ?
เป็นเรื่องต่างออกไปเล็กน้อย แต่สงสัยว่าตอนนี้ Go กับ Rust interop ทำได้ไหม
จำได้ว่าเคยเห็นกรณีที่ทำได้โดยมี Zig คั่นกลาง แต่หาไม่เจอแล้ว มีโค้ด Rust legacy อยู่และอยากค่อย ๆ ย้ายไป Go
extern "C"FFI ผ่าน CGO เพื่อเรียกฟังก์ชัน Rust ได้เคยพูดเรื่องวิธีใช้ GitHub code search ในงาน RustConf 2023 (https://www.youtube.com/watch?v=KYdlqhb267c) และหลังจากนั้นก็ได้ยินว่าที่อย่าง 1Password ก็ทำคล้าย ๆ กัน
การย้าย type ข้ามขอบเขต C interop นั้นยุ่งยากเลยไม่ค่อยสนุก แต่ทำได้และ reuse โค้ดได้
extern "C"แล้วเรียกจาก Go เหมือนเรียก Cส่วนทิศทางกลับกันไม่ค่อยแน่ใจ
โค้ด managed ต้องสามารถเป็นเจ้าของหน่วยความจำที่จะ free หรือ move ได้ ส่วนโค้ด unmanaged ต้องอนุมานให้ได้ว่าหน่วยความจำจะถูก free หรือ move เมื่อไร
สิ่งอย่าง
cgoช่วยให้ผสมการเรียก FFI จากโค้ด managed ของ Go ไปยังหน่วยความจำ unmanaged ได้ แต่ก็ต้องแลกด้วยต้นทุนใน implementation ที่ภาษาซึ่งเรียกกันไปมาไม่ได้ใช้ garbage collector ร่วมกัน ปัญหานี้จะเกิดขึ้นเสมอ
การผสมโค้ด managed/unmanaged เป็นไอเดียเก่าแก่ แต่ก็ยังเป็นหัวข้อที่มีการวิจัยอย่างคึกคัก
เว้นแต่ runtime ในตัวจะถูกออกแบบมาเพื่อเรื่องนี้ การเรียกจากโค้ด unmanaged ไปยังโค้ด managed แทบจะเป็นความคิดที่แย่เสมอ และมักจะมีชั้น serialization คั่นกลาง
ถ้านี่เป็นงานหลักก็คงคิดว่าไม่ดีนัก แต่เหนื่อยกับการกลับมาดูโค้ดทุก ๆ สองสามสัปดาห์แล้วจำไม่ได้ว่าต้องทำอะไรอย่างไร
ส่ง Rust closure ที่มี state ให้โค้ด Go เป็น callback เพื่อนำไปใส่ในฟังก์ชันของ standard library ของ Go และรวมไปถึงการ unwind ของ panic ภายใน Rust closure ด้วย
https://github.com/Voultapher/sort-research-rs/commit/df6c91...
ผมนั่งดู Inspect Element อยู่นานเพื่อหาว่าหัวข้อแต่ละเซกชันถูกทำให้เอียงได้อย่างไร แต่ถ้าดูจากเครื่องมือของ Safari แล้วหาทางต่อไม่ได้เลย สรุปทำได้ยังไงกัน?
.post-title:transform: skewY(-2deg) translate(-1rem, -0.4rem);element()(https://developer.mozilla.org/en-US/docs/Web/CSS/element) แต่จริง ๆ แล้วเป็นสำเนาของเนื้อหาบทความที่ย่อให้เล็กมาก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กับh1, h2, h3, h4, h5, h6อีกด้านหนึ่ง มีบทความปี 2019 ชื่อ “How Swift Achieved Dynamic Linking Where Rust Couldn't”
https://faultlore.com/blah/swift-abi/
น่าเสียดายที่ Rust ยังไม่มี calling convention สำหรับ semantics ระดับ Rust แต่ในขณะเดียวกัน บทความนั้นก็แสดงให้เห็นว่างานที่ต้องทำเพื่อไปให้ถึงจุดนั้นมีปริมาณมหาศาล
Apple มีแรงจูงใจอย่างลึกซึ้งที่จะทำให้ Swift เป็นภาษา systems ที่ใช้งานได้จริงและแอปพลิเคชันสามารถพึ่งพาได้ แต่ Rust ไม่มีผู้สนับสนุนแบบนั้น
การอภิปรายใน HN: https://news.ycombinator.com/item?id=21488415
ถ้า Rust มีตัวเลือกสนับสนุนสำหรับ trade-off แบบนี้มากขึ้นก็คงดี และไม่จำเป็นต้องจำกัดอยู่แค่สิ่งอย่าง https://github.com/rust-lang/rfcs/pull/3470
ถ้าคอมไพเลอร์ Rust ปัจจุบันทำ inline อย่าง aggressive แล้วค่อย optimize ก็สงสัยว่าสิ่งนี้คุ้มค่ากับความพยายามหรือไม่
ถ้าฟังก์ชันที่ถูกเรียกมีขนาดเล็ก มันก็น่าจะถูก inline และถ้ามีขนาดใหญ่ ก็คงใช้เวลาอยู่ในฟังก์ชันพอสมควร ทำให้ overhead ของการเรียกมีน้อย
dyn Traitไม่สามารถถูก inline ได้ ดังนั้นการเปลี่ยนแปลงแบบนี้จึงช่วยได้ถ้าทำให้การเรียกมีต้นทุนต่ำได้ ก็อาจไม่ต้อง inline อย่าง aggressive ขนาดนั้น ซึ่งช่วยเรื่อง ขนาดโค้ด และเวลา compile ได้ด้วย
ฟังก์ชันที่ซับซ้อนซึ่งไม่เหมาะกับการ inline มีแนวโน้มสูงที่จะเข้าถึง memory หลายครั้ง และการเข้าถึงเหล่านั้นก็น่าจะเป็นคอขวด
การส่งผ่านทาง stack จะยิ่งบีบคอขวดนั้นให้แน่นขึ้น เพราะเพิ่มแรงกดดันต่อ cache และเพิ่มการ load/store
หาก Rust สามารถส่งอาร์กิวเมนต์ได้อย่างเหมาะสมที่สุดในสัดส่วนการเรียกฟังก์ชันที่มากพอ ก็ไม่เพียงหลีกเลี่ยงรอบเวลาไม่กี่ cycle ของการเข้าถึง L1 แต่ยังอาจทำให้ CPU ไปถึงคอขวด memory ที่แท้จริงได้เร็วขึ้นด้วย
อาจได้ประโยชน์สักไม่กี่เปอร์เซ็นต์ก็ได้ แต่ตอนนี้ผมกำลังดื่มไวน์อยู่และไม่ได้คำนวณ
ช่วยอธิบายได้ไหมว่า mnemonic “Diana’s silk dress cost $89” ที่อยู่ในเอกสารอ้างอิง x86 คืออะไร?