การปรับปรุงประสิทธิภาพของตัวถอดรหัสวิดีโอ rav1d
(ohadravid.github.io)- ตัวถอดรหัส AV1 ที่ใช้ Rust อย่าง rav1d ช้ากว่า dav1d ที่ใช้ C ประมาณ 6 วินาที หรือ 9% บนอินพุตเดียวกัน และการปรับแต่งเล็ก ๆ 2 จุดช่วยลดเวลารันจาก 73.914 วินาทีเหลือ 72.182 วินาที
- การวิเคราะห์ใช้
samplyเปรียบเทียบไบนารีทั้งสองภายใต้เงื่อนไขเดียวกัน และใช้ฟังก์ชัน Arm assembly ร่วมกันเป็น anchor เพื่อติดตามความต่างระหว่าง Rust wrapper กับการ implement ฟังก์ชัน - การปรับปรุงแรกคือหลีกเลี่ยงการ initialize เป็น 0 ของ temporary buffer ในเส้นทาง Arm ด้วย
MaybeUninitและย้ายตำแหน่งการ initializelr_bakทำให้ runtime รวมลดลงประมาณ 1.6% - การปรับปรุงที่สองคือเปลี่ยนการเปรียบเทียบที่ไม่มีประสิทธิภาพซึ่งเกิดจาก
PartialEqค่าเริ่มต้นของstructตัวเลขขนาดเล็ก มาเป็นการเปรียบเทียบผ่านas_bytes()ของzerocopyช่วยลดได้อีกประมาณ 0.5 วินาที - PR ทั้งสองทำให้ดีขึ้นรวม 2.3% โดยไม่เพิ่ม
unsafeใหม่ แต่การวัดจำกัดอยู่ที่ macOS ชิป M3, single thread และอินพุต benchmark เฉพาะ โดยยังเหลือช่องว่างจาก dav1d ประมาณ 4.2 วินาที
ประสิทธิภาพอ้างอิงและสภาพแวดล้อมการวัด
rav1dเป็นพอร์ตภาษา Rust ของdav1d- แปลง
dav1dด้วยc2rust - รวมฟังก์ชันที่ optimize ด้วย assembly ของ
dav1d - รวมงานปรับโค้ดให้เป็น Rust มากขึ้นและปลอดภัยขึ้น
- แปลง
- memorysafety.org จัดการแข่งขันปรับปรุงประสิทธิภาพ
rav1dและในสถานะอ้างอิงrav1dที่ใช้ Rust ช้ากว่าdav1dที่ใช้ C ประมาณ 5% - การวัดแบบ local ทำบน MacBook Air M3 สภาพแวดล้อม 8 คอร์
rav1d: commita654c1e82adb2d9a33ae50d2a82a7a747102cbb6rustc 1.88.0-nightly, LLVM20.1.2dav1d:1.5.1- Homebrew clang
20.1.4 - ไฟล์อินพุต:
Chimera-AV1-8bit-1920x1080-6736kbps.ivf - ตัวเลือกการรัน:
--threads 1, output เป็น/dev/null
- ผล
hyperfineเริ่มต้นคือ rav1d 73.914 วินาที, dav1d 67.912 วินาที- บนไฟล์ตัวอย่างเดียวกัน
rav1dช้ากว่าประมาณ 6 วินาที หรือ 9% - เวอร์ชัน LLVM ของ
clangและrustcต่างกันเฉพาะ patch version
- บนไฟล์ตัวอย่างเดียวกัน
แนวทางการ profiling
- การ profiling ใช้
samply- sampling rate ค่าเริ่มต้นคือ 1000Hz
- ความต่าง 500 samples ในฟังก์ชันหนึ่ง ๆ เทียบได้คร่าว ๆ กับเวลารันต่างกัน 0.5 วินาที
- เพราะไบนารีทั้งสองคล้ายกันและทำงานแบบ deterministic จึงใช้วิธีเทียบ ความต่างของ samples รายฟังก์ชัน ได้ผล แทนที่จะต้องทำความเข้าใจตัวถอดรหัสวิดีโอทั้งหมดใหม่
- ใช้การเรียก optimized assembly ที่ใช้ร่วมกันเป็น anchor
dav1dเรียกcdef_filter_8x8_neon,cdef_filter_4x4_neonและแต่ละตัว dispatch ไปยังฟังก์ชัน assembly ที่เกี่ยวข้องrav1dใช้cdef_filter_neon_erasedจัดการ dispatch ฟังก์ชัน assembly ทั้งหมด
- จำนวน samples ของ
cdef_filter8_pri_sec_edged_8bpc_neonแทบเท่ากันใน snapshot ทั้งสอง จึงยืนยันได้ว่าแนวทางเปรียบเทียบถูกต้อง - ความต่างของ
cdef_filter_neon_erasedและrav1d_cdef_browรวมกันคิดเป็นประมาณ 1% ของเวลารันรวมของrav1d- ผลรวม Self samples ของ
cdef_filter_{8x8,4x4}_neonในdav1dอยู่ราว 400 - Self samples ของ
cdef_filter_neon_erasedในrav1dอยู่ราว 670 dav1d_cdef_brow_8bpcมี 1790 samples,rav1d_cdef_browมี 2350 samples
- ผลรวม Self samples ของ
การปรับปรุง 1: ตัดการ initialize temporary buffer เป็น 0
cdef_filter_neon_erasedสร้าง temporary buffer เป็นAlign16([0u16; TMP_LEN])TMP_LENในกรณีเลวร้ายที่สุดคือ12 * 16 + 8 = 200- ส่งผลให้ temporary buffer เทียบเท่า
[u16; 200]ถูกเติมด้วย 0
- โค้ด C ของ
dav1dที่สอดคล้องกันสร้าง stack buffer รูปแบบuint16_t tmp_buf[200] __attribute__((aligned(16)))แต่ไม่ initialize- buffer นี้กลายเป็นเป้าหมายการเขียนของฟังก์ชัน assembly
padding - จากนั้นฟังก์ชัน assembly
filterใช้ค่านั้นต่อโดยตรง
- buffer นี้กลายเป็นเป้าหมายการเขียนของฟังก์ชัน assembly
- ใน LLVM IR ของ
rav1dมีโค้ดllvm.memsetสำหรับเติม 400 ไบต์ เป็น 0- Rust compiler ไม่สามารถรู้ได้ว่าการ initialize นี้สามารถลบออกได้
- ใช้
MaybeUninitเพื่อเลี่ยงการ initialize temporary buffer เป็น 0- เปลี่ยน
Align16([0u16; TMP_LEN])เป็นAlign16([MaybeUninit::<u16>::uninit(); TMP_LEN]) - ปรับ signature ของฟังก์ชันภายในเป็นรูปแบบ
tmp: *mut MaybeUninit<u16>,tmp: &[MaybeUninit<u16>] - จัดการภายในเส้นทางโค้ดที่เป็น
unsafeอยู่แล้ว จึงไม่เพิ่มบล็อกunsafeใหม่
- เปลี่ยน
- หลังเปลี่ยน Self samples ของ
cdef_filter_neon_erasedลดจาก 670 เหลือ 274- ต่ำกว่าผลรวม Self samples ของ
cdef_filter_{8x8,4x4}_neonในdav1dเล็กน้อย
- ต่ำกว่าผลรวม Self samples ของ
ส่วนต่อขยายของการปรับปรุง 1: ลดการ initialize ใน loop
- ระหว่างค้นหา buffer
Align16ขนาดใหญ่เพิ่มเติม พบการ initializelr_bakในrav1d_cdef_brow- โค้ดเดิม initialize
lr_bakเป็น 0 ทุกครั้งใน loop - โค้ด
dav1dที่สอดคล้องกันไม่ initialize buffer นี้
- โค้ดเดิม initialize
- จุดนี้การเปลี่ยนเป็น
MaybeUninitทำได้ยากกว่า จึงย้ายการสร้างlr_bakออกไปนอก loop- ไม่ initialize ทุก iteration แต่ทำเพียงครั้งเดียว
- ผลประหยัดไม่มาก แต่ลดงานไม่จำเป็นชนิดเดียวกัน
- benchmark รวมที่รวมการเปลี่ยนนี้แล้ว
rav1dทำได้ 72.644 วินาที- ดีขึ้น 1.2 วินาทีจาก 73.914 วินาทีเดิม
- ดีขึ้นประมาณ 1.5% เมื่อเทียบกับ runtime รวม
- ยังเหลือช่องว่างจาก 67.912 วินาทีของ
dav1d
การปรับปรุง 2: optimize การเปรียบเทียบความเท่ากันของ struct ขนาดเล็ก
- เมื่อ profiling อีกครั้งด้วยมุมมอง inverted stack พบความต่างเด่นชัดใน
add_temporal_candidate- ความต่างระหว่างเวอร์ชัน Rust กับ C อยู่ราว 400 samples หรือประมาณ 0.5 วินาที
- ตัวฟังก์ชันประกอบด้วย
if,forและการเรียก utility สั้น ๆ รวมประมาณ 50 บรรทัด
- build ใหม่ด้วย profile
release-with-debugเพื่อตรวจดูการกระจาย samples รายบรรทัดif cand.mv.mv[0] == mv {if cand.mv == mvp {- สองบรรทัดนี้รวมกันกินประมาณ 600 samples
Mvของ Rust เป็น struct ขนาดเล็กที่ใช้#[derive(PartialEq)]#[repr(C)]y: i16,x: i16
mvของdav1dนิยามเป็นunionstruct { int16_t y, x; }uint32_t n- ตอนเปรียบเทียบใช้ค่า 32 บิต เช่น
mvstack[n].mv.n == mvp.n
- ถ้าใช้
unionใน Rust การเข้าถึง field จะกลายเป็นunsafeและอาจกระทบทุกจุดที่ใช้Mv- จึงใช้
AsBytesของzerocopyแทน เพื่อเปรียบเทียบ representation แบบ byte - ใน
impl PartialEq for Mvใช้self.as_bytes() == other.as_bytes() - ผลตรวจด้วย Godbolt พบว่าสร้าง assembly ที่ optimize แล้วเหมือนวิธีที่ใช้
transmute
- จึงใช้
- ใช้ optimization คล้ายกันกับ
RefMvs{Mv,Ref}Pairด้วย- ผล benchmark คือ 72.182 วินาที
- ดีขึ้นประมาณ 0.5 วินาทีจากผลก่อนหน้า 72.644 วินาที
- ดีขึ้น 2.3% เมื่อเทียบกับฐานแรก 73.914 วินาที
PartialEq ค่าเริ่มต้นของ Rust และข้อจำกัดของ code generation
- เหตุผลที่
PartialEqค่าเริ่มต้นของ struct ขนาดเล็กนำไปสู่ code generation ที่ไม่มีประสิทธิภาพ เชื่อมโยงกับ issue ของ Rust#140167 - ใน C
struct { int16_t y, x; }สามารถอยู่ในสถานะที่ initialize เฉพาะyแต่ยังไม่ initializexได้- ถ้าการเปรียบเทียบคือ
this.y == other.y && this.x == other.xและค่าyทั้งหมดต่างกัน ก็ไม่จำเป็นต้องอ่านx - เมื่อพิจารณากรณีแบบนี้ การ optimize เป็น memory load เดียวจะถูกต้องก็ต่อเมื่อมีการรับประกันว่าทุก field ถูก initialize เสมอ
- ถ้าการเปรียบเทียบคือ
- การสนทนาที่เกี่ยวข้องพูดถึงว่า LLVM ไม่มีวิธีแสดงคุณสมบัติว่า “การ load ผ่าน pointer นี้จะอ่าน bytes ที่ initialize แล้วเสมอ”
zerocopyสามารถตรวจเงื่อนไขความปลอดภัยแบบ static ว่า struct สามารถแสดงเป็น byte slice ได้ จึงทำให้ implement การเปรียบเทียบที่ optimize แล้วได้โดยไม่เพิ่มunsafeใหม่
ผลลัพธ์สุดท้ายและช่องว่างประสิทธิภาพที่ยังเหลือ
- PR แรกหลีกเลี่ยงการ initialize เป็น 0 ราคาแพงใน hot path เฉพาะ Arm
- PR #1397
- เวลารันดีขึ้น 1.2 วินาที
- ประมาณ -1.6%
- PR ที่สองเปลี่ยน implementation
PartialEqค่าเริ่มต้นของ struct ตัวเลขขนาดเล็กให้เป็นการเปรียบเทียบแบบอิง bytes- PR #1400
- เวลารันดีขึ้น 0.5 วินาที
- ประมาณ -0.7%
- การเปลี่ยนทั้งสองรวมกันมีขนาดระดับไม่กี่สิบบรรทัด และไม่เพิ่ม
unsafeใหม่ใน codebase - เวลารันสุดท้ายของ
rav1dคือ 72.182 วินาที เร็วกว่าจุดเริ่มต้น 2.3%- ยังต่างจาก 67.912 วินาทีของ
dav1dประมาณ 4.2 วินาที - ลดช่องว่างประสิทธิภาพที่สังเกตได้ตอนเริ่มต้นไปประมาณ 30%
- ยังต่างจาก 67.912 วินาทีของ
- ระหว่าง implementation ทั้งสองยังเหลือช่องว่างประมาณ 6% และยังสามารถใช้การเปรียบเทียบ profiler snapshots ของ
dav1dกับrav1dเพื่อค้นหา optimization เพิ่มเติมต่อไปได้
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
ประเด็นเกี่ยวกับการ เปรียบเทียบ u16 สองตัว น่าสนใจ
https://github.com/rust-lang/rust/issues/140167
การสร้างโค้ดด้วย
-O3ดูแปลก แต่เอาต์พุตของ-O2สมเหตุสมผล ถ้าโครงสร้างเพิ่งถูกคำนวณเสร็จ การพยายามอ่านมันด้วยโหลดแบบ 32 บิตครั้งเดียวอาจทำให้ store forwarding ล้มเหลว และทำให้ข้อดีของการรวมโหลดหายไป ในกรณีที่ไม่ได้ inline และไม่มี PGO คอมไพเลอร์ก็มีข้อมูลไม่พอจะตัดสินว่าการ optimize นั้นเหมาะสมหรือไม่ในฐานะนักพัฒนาเว็บ GitHub issue มักจะค่อนข้างแย่อยู่บ่อยครั้ง
ก็ยากจะมั่นใจว่า C compiler จะจัดการปัญหานี้ได้ดีกว่าในกรณีทั่วไป
เนื้อหาแบบนี้คงเป็นเหตุผลที่บัญชี Twitter ของ ffmpeg ออกมามีท่าทีต่อต้าน Rust
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
เพราะไม่ได้ล็อกอินเลยเห็นแค่ทวีตต้นทาง เลยสงสัยว่ามีส่วนที่อธิบายใน replies หรือเปล่า
น่าเสียดายที่ไม่มีทางเลือกที่เหมาะสมจริง ๆ และนักพัฒนาดูค่อนข้างก้าวร้าว ถ้าควบคุมทั้ง pipeline ได้หมด ประสิทธิภาพสูงสุดก็คงดี แต่ถ้าต้องรับข้อมูลที่ไม่น่าเชื่อถือจากผู้ใช้ไม่ระบุราย ffmpeg มี CVE ที่ exploit จากระยะไกลได้อย่างน้อยปีละห้าหกตัว ควรทำ sandbox ให้แข็งแรงไว้ดีกว่า
https://ffmpeg.org/security.html
น่าจะมีจุดกึ่งกลางที่ทุกคนกำลังมุ่งไปสู่ ทางออกที่ทั้งปลอดภัยและเร็ว มากกว่าจุดยืนที่แต่ละฝ่ายยึดอยู่ตรงนี้
ต่อให้ปรับเกณฑ์การคำนวณสถิติโอลิมปิก แล้วย้อนแก้สถิติ 100 เมตรของ Bolt จาก 9.63 วินาทีเป็น 9.64 วินาที ก็ไม่มีใครสนใจ แต่ถ้าวิ่ง 100 เมตรได้จริงใน 9 วินาที คนจะสนใจแน่ ทั้งนี้เฉพาะกรณีที่เป็นมนุษย์ ถ้าเป็นนกกระจอกเทศก็คงไม่น่าประทับใจ แต่โดยทั่วไปนกกระจอกเทศไม่ได้ลงแข่ง 100 เมตรในโอลิมปิกอยู่แล้ว
น่าสนใจที่บทความเกี่ยวกับข้อได้เปรียบด้านประสิทธิภาพจากการไม่ initialize buffer เป็นศูนย์ โผล่มาหลังบทความนี้สองวัน
https://news.ycombinator.com/item?id=44032680
ชื่อเรื่องประเมินบทความต่ำไป
จริง ๆ แล้วเร็วขึ้น 2.3% จาก optimization ดี ๆ สองอย่าง
ถ้ามองว่า Arm กับ x86 จะเป็นส่วนใหญ่ของการ deploy ในอนาคต น่าจะนับเป็นประมาณครึ่งหนึ่งมากกว่า
เป็นบทความที่ดี และจุดที่พบโค้ดไม่มีประสิทธิภาพในการ เปรียบเทียบคู่จำนวนเต็ม 16 บิต ก็น่าสนใจ
Rust น่าจะมีข้อมูลเกี่ยวกับสถานะการ initialize หน่วยความจำที่แม่นยำกว่ามาก
ถ้าเงื่อนไขเหมือนกัน ผมคิดว่า codec ควรถูกเขียนด้วย WUFFS มากกว่า Rust
อย่างไรก็ตาม การเขียนสิ่งที่ซับซ้อนอย่าง dav1d ใหม่ด้วย WUFFS อาจเป็นงานใหญ่กว่าการเก็บกวาดผลลัพธ์จาก c2rust มาก เชื่อได้เลยถ้าจะบอกว่ายากกว่าพันเท่า แต่ในระดับอารยธรรมโดยรวมก็ยังคิดว่าคุ้มค่าที่จะลอง
ที่พูดถึงคือ WUFFS หรือภาษาเฉพาะทางที่เทียบเท่า และ WUFFS ก็มีอยู่แล้ว
ถ้าไม่มี dynamic memory allocation การจัดการข้อมูลแบบไดนามิกจะทำได้ยาก video codec ไม่ใช่แค่ parse ไฟล์เพื่อดึงข้อมูล แต่ต้องจัดการ state ที่ค่อนข้างไดนามิกจำนวนมาก
ถ้าบทความเริ่มด้วยมีมตลก ๆ ก็รู้ได้เลยว่าเป็นบทความดี
ดูเกี่ยวข้องกับการถกเถียงล่าสุดด้วย: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238
พูดตรง ๆ ว่าค่อนข้างแปลกใจที่ optimization แรกเป็นสิ่งที่เห็นได้ค่อนข้างชัดแม้ใช้แค่ perf
เหมือนในบทความแรกจะพูดถึงปัญหา zero-initialization ของ buffer ไปแล้ว ส่วน optimization ที่สองแน่นอนว่าซับซ้อนและน่าสนใจกว่า แต่ perf ก็ยังชี้ให้เห็นอยู่ดี เครื่องมือนี้ไม่ควรถูกประเมินต่ำไป
perf diffก็มีอยู่ แต่ไม่สามารถ match ชื่อ symbol ที่ต่างกันได้ และก็ดูเหมือนมีคนใช้ไม่มากมักเห็นบ่อยว่าคนที่มาจากพื้นหลังต่างกันพบช่องโหว่ที่ “พอมองย้อนกลับไปก็ชัดเจน”
อันนี้สนุกจริง ๆ
ตอนแรกสงสัยว่ามีอะไรขวางไม่ให้ rustc ทำ ทริก transmute นั้นหรือเปล่า แต่ถ้าได้อ่านย่อหน้าถัดไปก็คงรู้เรื่อง issue นี้ก่อนจะคอมเมนต์แล้ว
https://github.com/rust-lang/rust/issues/140167