1 คะแนน โดย GN⁺ 2025-05-23 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ตัวถอดรหัส 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 และย้ายตำแหน่งการ initialize lr_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: commit a654c1e82adb2d9a33ae50d2a82a7a747102cbb6
    • rustc 1.88.0-nightly, LLVM 20.1.2
    • dav1d: 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

การปรับปรุง 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 ใช้ค่านั้นต่อโดยตรง
  • ใน 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 เล็กน้อย

ส่วนต่อขยายของการปรับปรุง 1: ลดการ initialize ใน loop

  • ระหว่างค้นหา buffer Align16 ขนาดใหญ่เพิ่มเติม พบการ initialize lr_bak ใน rav1d_cdef_brow
    • โค้ดเดิม initialize lr_bak เป็น 0 ทุกครั้งใน loop
    • โค้ด dav1d ที่สอดคล้องกันไม่ initialize buffer นี้
  • จุดนี้การเปลี่ยนเป็น 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 นิยามเป็น union
    • struct { 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 แต่ยังไม่ initialize x ได้
    • ถ้าการเปรียบเทียบคือ 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%
  • ระหว่าง implementation ทั้งสองยังเหลือช่องว่างประมาณ 6% และยังสามารถใช้การเปรียบเทียบ profiler snapshots ของ dav1d กับ rav1d เพื่อค้นหา optimization เพิ่มเติมต่อไปได้

1 ความคิดเห็น

 
GN⁺ 2025-05-23
ความคิดเห็นบน Hacker News
  • ประเด็นเกี่ยวกับการ เปรียบเทียบ u16 สองตัว น่าสนใจ
    https://github.com/rust-lang/rust/issues/140167

    • แปลกใจที่ในการถกเถียงนั้นไม่มีการพูดถึง store forwarding
      การสร้างโค้ดด้วย -O3 ดูแปลก แต่เอาต์พุตของ -O2 สมเหตุสมผล ถ้าโครงสร้างเพิ่งถูกคำนวณเสร็จ การพยายามอ่านมันด้วยโหลดแบบ 32 บิตครั้งเดียวอาจทำให้ store forwarding ล้มเหลว และทำให้ข้อดีของการรวมโหลดหายไป ในกรณีที่ไม่ได้ inline และไม่มี PGO คอมไพเลอร์ก็มีข้อมูลไม่พอจะตัดสินว่าการ optimize นั้นเหมาะสมหรือไม่
    • ดีที่การถกเถียงนี้ไม่ใช่คอมเมนต์ 14 หน้าแบบ “ฉันก็เจอปัญหานี้เหมือนกัน” หรือ “เมื่อไหร่จะแก้?”
      ในฐานะนักพัฒนาเว็บ GitHub issue มักจะค่อนข้างแย่อยู่บ่อยครั้ง
    • กรณีนี้ดูเหมือนจะแสดงให้เห็นถึง ความซับซ้อนของการเขียนคอมไพเลอร์
      ก็ยากจะมั่นใจว่า C compiler จะจัดการปัญหานี้ได้ดีกว่าในกรณีทั่วไป
  • เนื้อหาแบบนี้คงเป็นเหตุผลที่บัญชี Twitter ของ ffmpeg ออกมามีท่าทีต่อต้าน Rust
    https://x.com/ffmpeg/status/1924137645988356437?s=46

    • ปกติแล้ว benchmark ของ rbultje เชื่อถือได้ แต่ใน issue สำหรับติดตาม rav1d มีตัวเลขแบบ multi-thread จากหลายแพลตฟอร์ม และไม่ได้เห็นความต่างใหญ่ขนาดนั้น
      https://github.com/memorysafety/rav1d/issues/1294
      เพราะไม่ได้ล็อกอินเลยเห็นแค่ทวีตต้นทาง เลยสงสัยว่ามีส่วนที่อธิบายใน replies หรือเปล่า
    • แค่อ่านบัญชี Twitter ของ ffmpeg ก็ทำให้ไม่อยากใช้ ffmpeg แล้ว
      น่าเสียดายที่ไม่มีทางเลือกที่เหมาะสมจริง ๆ และนักพัฒนาดูค่อนข้างก้าวร้าว ถ้าควบคุมทั้ง pipeline ได้หมด ประสิทธิภาพสูงสุดก็คงดี แต่ถ้าต้องรับข้อมูลที่ไม่น่าเชื่อถือจากผู้ใช้ไม่ระบุราย ffmpeg มี CVE ที่ exploit จากระยะไกลได้อย่างน้อยปีละห้าหกตัว ควรทำ sandbox ให้แข็งแรงไว้ดีกว่า
      https://ffmpeg.org/security.html
      น่าจะมีจุดกึ่งกลางที่ทุกคนกำลังมุ่งไปสู่ ทางออกที่ทั้งปลอดภัยและเร็ว มากกว่าจุดยืนที่แต่ละฝ่ายยึดอยู่ตรงนี้
    • การตอบสนองที่ดีต่อสุขภาพกว่าน่าจะเป็นการ ทำให้ dav1d เร็วขึ้น
      ต่อให้ปรับเกณฑ์การคำนวณสถิติโอลิมปิก แล้วย้อนแก้สถิติ 100 เมตรของ Bolt จาก 9.63 วินาทีเป็น 9.64 วินาที ก็ไม่มีใครสนใจ แต่ถ้าวิ่ง 100 เมตรได้จริงใน 9 วินาที คนจะสนใจแน่ ทั้งนี้เฉพาะกรณีที่เป็นมนุษย์ ถ้าเป็นนกกระจอกเทศก็คงไม่น่าประทับใจ แต่โดยทั่วไปนกกระจอกเทศไม่ได้ลงแข่ง 100 เมตรในโอลิมปิกอยู่แล้ว
  • น่าสนใจที่บทความเกี่ยวกับข้อได้เปรียบด้านประสิทธิภาพจากการไม่ initialize buffer เป็นศูนย์ โผล่มาหลังบทความนี้สองวัน
    https://news.ycombinator.com/item?id=44032680

  • ชื่อเรื่องประเมินบทความต่ำไป
    จริง ๆ แล้วเร็วขึ้น 2.3% จาก optimization ดี ๆ สองอย่าง

    • optimization 1.5% นั้นเป็น เฉพาะ aarch64 เลยดูไม่ค่อยยุติธรรมถ้าจะอ้างตัวเลขรวมตามนั้น
      ถ้ามองว่า Arm กับ x86 จะเป็นส่วนใหญ่ของการ deploy ในอนาคต น่าจะนับเป็นประมาณครึ่งหนึ่งมากกว่า
  • เป็นบทความที่ดี และจุดที่พบโค้ดไม่มีประสิทธิภาพในการ เปรียบเทียบคู่จำนวนเต็ม 16 บิต ก็น่าสนใจ

    • สงสัยว่าฝั่ง Rust/LLVM จะปรับปรุงคอมไพเลอร์ให้ทำ optimization นี้ได้ทุกครั้งที่เป็นไปได้หรือไม่
      Rust น่าจะมีข้อมูลเกี่ยวกับสถานะการ initialize หน่วยความจำที่แม่นยำกว่ามาก
  • ถ้าเงื่อนไขเหมือนกัน ผมคิดว่า codec ควรถูกเขียนด้วย WUFFS มากกว่า Rust
    อย่างไรก็ตาม การเขียนสิ่งที่ซับซ้อนอย่าง dav1d ใหม่ด้วย WUFFS อาจเป็นงานใหญ่กว่าการเก็บกวาดผลลัพธ์จาก c2rust มาก เชื่อได้เลยถ้าจะบอกว่ายากกว่าพันเท่า แต่ในระดับอารยธรรมโดยรวมก็ยังคิดว่าคุ้มค่าที่จะลอง
    ที่พูดถึงคือ WUFFS หรือภาษาเฉพาะทางที่เทียบเท่า และ WUFFS ก็มีอยู่แล้ว

    • WUFFS น่าจะยอดเยี่ยมสำหรับ การ parse ไฟล์ container อย่าง Matroska, webm, mp4 แต่ดูไม่เหมาะกับ video decoder เลย
      ถ้าไม่มี 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 เฉย ๆ แต่ใกล้เคียงกับการทำ differential profiling ระหว่างเวอร์ชัน C กับ Rust แล้วจับคู่ด้วยมือมากกว่า
      perf diff ก็มีอยู่ แต่ไม่สามารถ match ชื่อ symbol ที่ต่างกันได้ และก็ดูเหมือนมีคนใช้ไม่มาก
    • น่าจะเป็นไปได้เพราะเข้าหาจาก มุมมองของ aarch64 บนอุปกรณ์ Apple
      มักเห็นบ่อยว่าคนที่มาจากพื้นหลังต่างกันพบช่องโหว่ที่ “พอมองย้อนกลับไปก็ชัดเจน”
  • อันนี้สนุกจริง ๆ
    ตอนแรกสงสัยว่ามีอะไรขวางไม่ให้ rustc ทำ ทริก transmute นั้นหรือเปล่า แต่ถ้าได้อ่านย่อหน้าถัดไปก็คงรู้เรื่อง issue นี้ก่อนจะคอมเมนต์แล้ว
    https://github.com/rust-lang/rust/issues/140167