ปรับปรุงประสิทธิภาพวิดีโอดีโคเดอร์ rav1d
(ohadravid.github.io)- พบว่า AV1 ดีโคเดอร์ rav1d ที่เขียนด้วย Rust ช้ากว่า dav1d ที่พัฒนาด้วย C อยู่ราว 9%
- ยืนยันผลการเพิ่มความเร็วได้จากการปรับปรุง การ initialize บัฟเฟอร์ และการปรับปรุงลอจิกการเปรียบเทียบ struct โดยได้ผลดีขึ้นแยกกันที่ 1.5% และ 0.7%
- ใช้เครื่องมือโปรไฟล์ samply เพื่อระบุสาเหตุของความต่างด้านประสิทธิภาพระหว่างทั้งสองเวอร์ชันอย่างชัดเจน
- เพิ่มประสิทธิภาพใน Rust ด้วยการเปรียบเทียบแบบ byte-level แทนการใช้ implementation เริ่มต้นของ PartialEq
- การปรับแต่งครั้งนี้ช่วยลดช่องว่างด้านประสิทธิภาพรวมได้ราว 30% แต่ยังมีพื้นที่ให้ปรับปรุงต่ออีก
พื้นหลังและแนวทาง
- rav1d เป็นโปรเจ็กต์ที่พอร์ต dav1d AV1 ดีโคเดอร์มาเป็น Rust ด้วย c2rust พร้อมนำฟังก์ชัน asm ที่ปรับแต่งไว้และการปรับปรุงด้านความปลอดภัยตามแบบของภาษา Rust เข้ามาใช้
- มีการกำหนดเกณฑ์ประสิทธิภาพพื้นฐานแบบเปิดเผยไว้แล้ว และในสถานะปัจจุบัน rav1d ที่พัฒนาด้วย Rust ช้ากว่า dav1d ที่พัฒนาด้วย C อยู่ราว 5%
- การวิเคราะห์นี้โฟกัสที่ความต่างของ runtime ของไบนารีเมื่อใช้ input เดียวกัน แทนที่จะมองโครงสร้างโดยรวมของวิดีโอดีโคเดอร์ที่มีความซับซ้อน
- เปรียบเทียบอย่างเป็นระบบด้วย เครื่องมือวัดประสิทธิภาพ (hyperfine) และ profiler (samply)
- สภาพแวดล้อมที่ใช้ทดสอบคือ macOS ชิป M3 และลดความซับซ้อนด้วยการรันแบบเธรดเดียว
การวัดประสิทธิภาพ: เปรียบเทียบค่าพื้นฐาน
- ทำการ build และ benchmark ด้วยไฟล์ทดสอบเดียวกัน (Chimera-AV1-8bit-1920x1080-6736kbps.ivf)
- rav1d ใช้เวลาราว 73.9 วินาที ส่วน dav1d ใช้ราว 67.9 วินาที พบความต่างของเวลาในการรันประมาณ 6 วินาที (9%)
- คอมไพเลอร์แต่ละตัว (Clang, Rustc) ใช้ LLVM เวอร์ชันแทบจะเหมือนกัน
การวิเคราะห์ด้วยโปรไฟล์
- ใช้ profiler อย่าง samply เพื่อเปรียบเทียบจำนวน sample ระดับฟังก์ชันของแต่ละ executable
- ตรวจดูเส้นทางการเรียกใช้และการกระจายของ sample ของฟังก์ชันแอสเซมบลีที่อิงกับ NEON (ARM SIMD) อย่างเจาะจง
- dav1d แยกเป็นฟังก์ชัน filter ต่างหากเพื่อแตกแขนงไปเรียกฟังก์ชัน asm ขณะที่ rav1d จัดการทั้งหมดผ่านฟังก์ชัน dispatch เดียว
- พบว่า cdef_filter_neon_erased มีจำนวน Self sample มากกว่าผลรวมของสองฟังก์ชันใน dav1d อยู่ราว 270 sample (คิดเป็นประมาณ 1% ของทั้งหมด)
- จากการวิเคราะห์พบช่วงที่มีการ initialize บัฟเฟอร์ชั่วคราว (zero-initialized buffer) ขนาดใหญ่โดยไม่จำเป็น
การปรับแต่งเพื่อตัดการ initialize บัฟเฟอร์
- Rust จะทำ zeroing ให้อัตโนมัติเพื่อความปลอดภัยด้วยรูปแบบอย่าง [0u16; LEN]
- แต่ C (dav1d) ไม่ได้ทำ zeroing ให้บัฟเฟอร์แบบชัดเจน และจะเขียนค่าเฉพาะช่วงที่ใช้งานจริงเท่านั้น
- ใน Rust จึงใช้ std::mem::MaybeUninit เพื่อตัดค่าใช้จ่ายจากการ initialize ที่ไม่จำเป็น
- ทำให้จำนวน Self sample ของฟังก์ชัน cdef_filter_neon_erased ลดลงมากจาก 670 เหลือ 274
- นอกจากนี้ยังย้ายการ initialize ของบัฟเฟอร์ Align16 ขนาดใหญ่อีกตัวออกไปไว้นอกลูป (hoist) เพื่อลดต้นทุนการ initialize ให้เหลือเพียง 1 ครั้ง
- หลังปรับแต่งแล้ว benchmark อยู่ที่ราว 72.6 วินาที ดีขึ้น 1.2 วินาที (1.5%)
การปรับแต่งการเปรียบเทียบ struct
- จากการวิเคราะห์ inverted stack ในโปรไฟล์ พบว่าฟังก์ชัน add_temporal_candidate ทำงานได้ไม่มีประสิทธิภาพเกินคาด
- การเปรียบเทียบฟิลด์ของ struct Mv ภายในฟังก์ชันนี้ (จากการ derive PartialEq อัตโนมัติ) สร้างโค้ดที่ช้ากว่าที่ควรโดยไม่จำเป็น
- ฝั่ง C ใช้ union เพื่อเปรียบเทียบอย่างมีประสิทธิภาพในระดับ uint32_t
- ฝั่ง Rust หลีกเลี่ยง unsafe โดยใช้ trait zerocopy::AsBytes เพื่อทำการเปรียบเทียบในระดับ byte slice
- การปรับแต่งนี้เพิ่มประสิทธิภาพได้อีก 0.5 วินาที (ประมาณ 0.7%)
ผลลัพธ์และสรุป
- การปรับแต่งง่าย ๆ สองจุด (ตัดการ initialize บัฟเฟอร์, เปรียบเทียบ struct แบบไบต์) ช่วยลด runtime ได้รวมกว่า 2%
- ยังเหลือช่องว่างด้านประสิทธิภาพอีกราว 6% และยังมีโอกาสปรับแต่งเพิ่มเติมได้มาก
- ยืนยันได้ว่าวิธีเปรียบเทียบ snapshot จาก profiler มีประสิทธิภาพ
- มีความเป็นไปได้สูงที่จะทำ optimization เพิ่มเติมบน rav1d และ dav1d จากการวิเคราะห์ snapshot
- ด้วย feedback และความร่วมมืออย่างแข็งขันจากผู้ดูแลโปรเจ็กต์ จึงสามารถปรับปรุงได้โดยไม่กระทบความปลอดภัย
สรุปย่อ
- ใช้เครื่องมือ profiler (samply) และ benchmark (hyperfine) วิเคราะห์ความต่างของ runtime 6 วินาที (9%) ระหว่าง rav1d กับ dav1d อย่างละเอียด
- การปรับแต่งหลักมี 2 อย่าง:
- ตัดการ zeroing บัฟเฟอร์ที่ไม่จำเป็นในโค้ดเฉพาะทาง ARM (1.2 วินาที, -1.6%)
- เปลี่ยน implementation ของ PartialEq สำหรับ struct ตัวเลขขนาดเล็กให้เป็นการเปรียบเทียบไบต์ที่เร็วกว่า (0.5 วินาที, -0.7%)
- แต่ละการปรับแต่งกระชับ ใช้โค้ดเพียงไม่กี่สิบบรรทัด และไม่ต้องเพิ่ม unsafe ใหม่
- บรรลุทั้งความน่าเชื่อถือและคุณภาพที่ดีขึ้นผ่านความร่วมมือกับผู้ดูแลโปรเจ็กต์และการรีวิว PR
- ยังเหลือช่องว่างด้านประสิทธิภาพอีกราว 6% จึงมีพื้นที่มากพอสำหรับการศึกษา optimization เพิ่มเติมด้วยการเปรียบเทียบแบบอิง profiler
ลองนำแนวทางนี้ไปใช้ดูได้เลย! บางที rav1d อาจเร็วกว่า dav1d ได้ในที่สุด 👀🦀
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
u16สองค่าน่าสนใจ พร้อมแนบลิงก์อีชชูที่เกี่ยวข้อง https://github.com/rust-lang/rust/issues/140167-O3ดูเกินไปหน่อย แต่ที่-O2ถือว่าสมเหตุสมผล พร้อมอธิบายอย่างเจาะจงว่าถ้า struct ตัวใดตัวหนึ่งเพิ่งผ่านการคำนวณมา การพยายามโหลด 32 บิตอาจทำให้ store forwarding ล้มเหลวและทำให้การเพิ่มประสิทธิภาพไร้ความหมาย อีกทั้งในกรณีที่ไม่ inline/ไม่ใช้ PGO คอมไพเลอร์ก็ขาดข้อมูลที่จำเป็นต่อการตัดสินใจว่าเหมาะจะ optimize หรือไม่dav1dไปเป็น WUFFS นั้นยากกว่าการแปลโค้ด C เดิมเสียอีก ถึงอย่างนั้นก็ยังเห็นว่าความพยายามแบบนี้มีคุณค่าและคุ้มแก่การลงทุนในระดับอารยธรรมNominative determinismอย่างชัดเจนperfเก่งๆ เลยค่อนข้างแปลกใจ ส่วนประเด็น zeroing นึกว่าได้คุยกันไปแล้วในโพสต์แรก optimization อย่างที่สองซับซ้อนและน่าสนใจกว่า แต่ก็ยังเป็นทิศทางที่perfชี้นำอยู่ดี จึงเตือนว่าอย่าประเมินประโยชน์ของเครื่องมือperfต่ำเกินไปperfอย่างเดียว แต่ยังอาศัยการทำ differential profiling ระหว่างเวอร์ชัน C และเวอร์ชัน Rust รวมถึงการจับคู่ด้วยตนเอง จึงระบุปัญหาได้ถูกต้อง แม้perfจะมีฟังก์ชัน diff แต่ก็มีข้อจำกัดเพราะชื่อ symbol ต่างกันทำให้จับคู่อัตโนมัติได้ยาก