Peningkatan performa decoder video rav1d
(ohadravid.github.io)- Decoder AV1 berbasis Rust rav1d sekitar 6 detik, atau 9%, lebih lambat daripada dav1d berbasis C pada input yang sama, dan dua optimisasi kecil memangkas waktu eksekusi dari 73,914 detik menjadi 72,182 detik
- Analisis membandingkan kedua binary dalam kondisi yang sama dengan
samply, lalu memakai fungsi assembly Arm bersama sebagai anchor untuk menelusuri perbedaan antara wrapper Rust dan implementasi fungsi - Perbaikan pertama menghindari inisialisasi nol pada buffer sementara di jalur Arm dengan
MaybeUninitdan memindahkan lokasi inisialisasilr_bak, sehingga mengurangi total runtime sekitar 1,6% - Perbaikan kedua mengganti perbandingan tidak efisien yang dihasilkan
PartialEqdefault untukstructnumerik kecil dengan perbandingan berbasisas_bytes()darizerocopy, sehingga menghemat tambahan sekitar 0,5 detik - Dua PR tersebut menghasilkan peningkatan total 2,3% tanpa
unsafebaru, tetapi pengukuran terbatas pada macOS dengan chip M3, single-thread, dan input benchmark tertentu; masih tersisa selisih sekitar 4,2 detik dibanding dav1d
Performa acuan dan lingkungan pengukuran
rav1dadalah port Rust daridav1d- Mengonversi
dav1ddenganc2rust - Mengintegrasikan fungsi optimisasi assembly milik
dav1d - Mencakup pekerjaan untuk membuat kode lebih bergaya Rust dan lebih aman
- Mengonversi
- memorysafety.org mengadakan kontes peningkatan performa
rav1d, dan pada kondisi acuanrav1dberbasis Rust sekitar 5% lebih lambat daripadadav1dberbasis C - Pengukuran lokal dilakukan pada MacBook Air M3, lingkungan 8 core
rav1d: commita654c1e82adb2d9a33ae50d2a82a7a747102cbb6rustc 1.88.0-nightly, LLVM20.1.2dav1d:1.5.1- Homebrew clang
20.1.4 - File input:
Chimera-AV1-8bit-1920x1080-6736kbps.ivf - Opsi eksekusi:
--threads 1, output ke/dev/null
- Hasil awal
hyperfineadalah rav1d 73,914 detik, dav1d 67,912 detik- Pada file sampel yang sama,
rav1dsekitar 6 detik, 9% lebih lambat - Versi LLVM pada
clangdanrustchanya berbeda di versi patch
- Pada file sampel yang sama,
Pendekatan profiling
- Profiling menggunakan
samply- Kecepatan sampling default adalah 1000Hz
- Selisih 500 sampel pada fungsi tertentu kira-kira setara dengan selisih waktu eksekusi 0,5 detik
- Karena kedua binary serupa dan berperilaku deterministik, pendekatan membandingkan selisih sampel per fungsi efektif, alih-alih memahami ulang seluruh decoder video
- Panggilan assembly teroptimisasi yang digunakan bersama dijadikan anchor
dav1dmemanggilcdef_filter_8x8_neon,cdef_filter_4x4_neon, lalu masing-masing mendispatch fungsi assembly terkait- Di
rav1d,cdef_filter_neon_erasedmenangani dispatch semua fungsi assembly
- Jumlah sampel
cdef_filter8_pri_sec_edged_8bpc_neonhampir sama pada kedua snapshot, sehingga mengonfirmasi arah perbandingannya tepat - Selisih pada
cdef_filter_neon_eraseddanrav1d_cdef_browjika digabung setara dengan sekitar 1% dari total waktu eksekusirav1d- Total sampel Self
cdef_filter_{8x8,4x4}_neonpadadav1dsekitar 400 - Sampel Self
cdef_filter_neon_erasedpadarav1dsekitar 670 dav1d_cdef_brow_8bpcmemiliki 1790 sampel, sedangkanrav1d_cdef_browmemiliki 2350 sampel
- Total sampel Self
Perbaikan 1: Menghapus inisialisasi nol pada buffer sementara
cdef_filter_neon_erasedmembuat buffer sementara sebagaiAlign16([0u16; TMP_LEN])TMP_LENadalah12 * 16 + 8 = 200pada kasus terburuk- Akibatnya, buffer sementara yang setara dengan
[u16; 200]diisi dengan nol
- Kode C
dav1dyang bersesuaian membuat buffer stack berbentukuint16_t tmp_buf[200] __attribute__((aligned(16))), tetapi tidak menginisialisasinya- Buffer ini menjadi target penulisan fungsi assembly
padding - Setelah itu, fungsi assembly
filtermenggunakan nilai tersebut apa adanya
- Buffer ini menjadi target penulisan fungsi assembly
- Pada LLVM IR
rav1d, terlihat kode yang mengisi 400 byte dengan nol menggunakanllvm.memset- Compiler Rust tidak bisa mengetahui bahwa inisialisasi ini boleh dihapus
MaybeUninitdigunakan untuk menghindari inisialisasi nol pada buffer sementaraAlign16([0u16; TMP_LEN])diubah menjadiAlign16([MaybeUninit::<u16>::uninit(); TMP_LEN])- Signature fungsi internal disesuaikan menjadi bentuk
tmp: *mut MaybeUninit<u16>dantmp: &[MaybeUninit<u16>] - Karena diproses di dalam jalur kode yang sudah
unsafe, tidak ada blokunsafebaru yang ditambahkan
- Setelah perubahan, sampel Self
cdef_filter_neon_erasedturun dari 670 menjadi 274- Menjadi sedikit lebih rendah daripada total sampel Self
cdef_filter_{8x8,4x4}_neonpadadav1d
- Menjadi sedikit lebih rendah daripada total sampel Self
Kelanjutan perbaikan 1: Mengurangi inisialisasi di dalam loop
- Saat mencari buffer
Align16besar lainnya, ditemukan inisialisasilr_bakdi dalamrav1d_cdef_brow- Kode lama menginisialisasi nol
lr_baksetiap kali di dalam loop - Kode
dav1dyang bersesuaian tidak menginisialisasi buffer ini
- Kode lama menginisialisasi nol
- Di sini konversi ke
MaybeUninitlebih sulit, sehingga pembuatanlr_bakdipindahkan ke luar loop- Inisialisasi tidak lagi dilakukan pada setiap iterasi, melainkan hanya sekali
- Penghematannya kecil, tetapi mengurangi pekerjaan tidak perlu dari jenis yang sama
- Pada benchmark keseluruhan yang mencakup perubahan ini,
rav1dmencatat 72,644 detik- Peningkatan 1,2 detik dari 73,914 detik sebelumnya
- Peningkatan sekitar 1,5% terhadap total runtime
- Masih ada selisih dibanding 67,912 detik milik
dav1d
Perbaikan 2: Mengoptimalkan perbandingan kesetaraan struktur kecil
- Saat melakukan profiling ulang dengan tampilan inverted stack, terlihat selisih mencolok pada
add_temporal_candidate- Selisih antara versi Rust dan C sekitar 400 sampel, setara sekitar 0,5 detik
- Fungsi itu sendiri terdiri dari sekitar 50 baris
if,for, dan panggilan utilitas singkat
- Build ulang dilakukan dengan profil
release-with-debuguntuk memeriksa distribusi sampel per barisif cand.mv.mv[0] == mv {if cand.mv == mvp {- Kedua baris tersebut bersama-sama memakan sekitar 600 sampel
Mvdi Rust adalah struktur kecil yang menggunakan#[derive(PartialEq)]#[repr(C)]y: i16,x: i16
mvpadadav1ddidefinisikan sebagaiunionstruct { int16_t y, x; }uint32_t n- Saat membandingkan, ia dibandingkan sebagai nilai 32-bit seperti
mvstack[n].mv.n == mvp.n
- Jika menggunakan
uniondi Rust, akses field menjadiunsafedan dapat memengaruhi seluruh lokasi penggunaanMv- Sebagai gantinya, digunakan
AsBytesdarizerocopyuntuk membandingkan representasi byte - Di
impl PartialEq for Mv, digunakanself.as_bytes() == other.as_bytes() - Pemeriksaan dengan Godbolt menunjukkan bahwa cara ini menghasilkan assembly teroptimisasi yang sama dengan pendekatan berbasis
transmute
- Sebagai gantinya, digunakan
- Optimisasi serupa juga diterapkan pada
RefMvs{Mv,Ref}Pair- Hasil benchmark adalah 72,182 detik
- Peningkatan sekitar 0,5 detik dibanding hasil sebelumnya, 72,644 detik
- Peningkatan 2,3% dibanding acuan awal 73,914 detik
PartialEq default Rust dan batasan code generation
- Alasan
PartialEqdefault untuk struktur kecil menghasilkan code generation yang tidak efisien terkait dengan issue Rust#140167 - Di C,
struct { int16_t y, x; }bisa berada dalam keadaan ketika hanyayyang diinisialisasi danxbelum diinisialisasi- Jika perbandingannya adalah
this.y == other.y && this.x == other.xdan semuayberbeda,xtidak perlu dibaca - Dengan mempertimbangkan kasus seperti ini, optimisasi menjadi satu load memori hanya valid jika ada jaminan bahwa semua field selalu terinisialisasi
- Jika perbandingannya adalah
- Diskusi terkait membahas bahwa LLVM tidak memiliki cara untuk mengekspresikan properti “load melalui pointer ini selalu membaca byte yang sudah terinisialisasi”
zerocopydapat memeriksa secara statis kondisi aman yang membolehkan struktur direpresentasikan sebagai slice byte, sehingga perbandingan teroptimisasi bisa diimplementasikan tanpaunsafebaru
Hasil akhir dan sisa selisih performa
- PR pertama menghindari inisialisasi nol yang mahal pada hot path khusus Arm
- PR #1397
- Peningkatan waktu eksekusi 1,2 detik
- Sekitar -1,6%
- PR kedua mengganti implementasi
PartialEqdefault pada struktur numerik kecil dengan perbandingan berbasis byte- PR #1400
- Peningkatan waktu eksekusi 0,5 detik
- Sekitar -0,7%
- Kedua perubahan jika digabung hanya berukuran puluhan baris dan tidak memperkenalkan
unsafebaru ke codebase - Waktu eksekusi akhir
rav1dadalah 72,182 detik, 2,3% lebih cepat daripada titik awal- Masih ada selisih sekitar 4,2 detik dibanding 67,912 detik milik
dav1d - Mengurangi sekitar 30% dari selisih performa yang diamati di awal
- Masih ada selisih sekitar 4,2 detik dibanding 67,912 detik milik
- Masih tersisa selisih sekitar 6% antara kedua implementasi, dan perbandingan snapshot profiler
dav1ddanrav1dmasih dapat terus dimanfaatkan untuk mencari optimisasi tambahan
1 komentar
Opini Hacker News
Isu terkait membandingkan dua u16 ini menarik
https://github.com/rust-lang/rust/issues/140167
Pembuatan kode
-O3memang aneh, tetapi output-O2masuk akal. Jika sebuah struct baru saja dihitung, mencoba membacanya sebagai satu load 32-bit bisa menyebabkan kegagalan store forwarding, sehingga manfaat penggabungan load bisa hilang. Dalam situasi tanpa inlining dan tanpa PGO, compiler kekurangan informasi untuk menilai apakah optimisasi itu tepatSebagai web developer, issue GitHub sering kali terasa cukup buruk
Sulit juga untuk yakin bahwa compiler C bisa menangani masalah ini dengan lebih baik pada kasus umum
Hal-hal seperti ini tampaknya membuat akun Twitter ffmpeg mengambil sikap anti-Rust
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
Karena tidak login, saya hanya bisa melihat tweet aslinya; saya penasaran apakah ada penjelasan di balasannya
Sayang tidak ada alternatif yang layak, dan para developernya terlihat cukup agresif. Jika kita mengendalikan seluruh pipeline, performa terbaik tentu bagus, tetapi jika menerima data tak tepercaya dari pengguna yang tidak dikenal, ffmpeg setiap tahun memiliki setidaknya lima atau enam CVE yang bisa dieksploitasi dari jarak jauh. Sebaiknya sandbox-nya dibuat kuat
https://ffmpeg.org/security.html
Alih-alih posisi yang masing-masing pihak pertahankan di sini, rasanya ada titik tengah tempat semua orang bergerak menuju solusi yang aman sekaligus cepat
Tidak ada yang peduli jika kriteria penghitungan rekor Olimpiade disempurnakan lalu rekor 100m Bolt direvisi mundur dari 9,63 detik menjadi 9,64 detik. Tetapi jika seseorang benar-benar berlari 100m dalam 9 detik, orang akan memperhatikan. Tentu saja itu kalau ia manusia; kalau burung unta, tidak terlalu mengesankan, tetapi pada umumnya burung unta tidak ikut nomor 100m Olimpiade
Menarik bahwa tulisan tentang keuntungan performa dari tidak menginisialisasi buffer dengan nol muncul dua hari setelah tulisan ini
https://news.ycombinator.com/item?id=44032680
Judulnya meremehkan isi tulisan
Sebenarnya, dengan dua optimisasi bagus, performanya menjadi 2,3% lebih cepat
Jika Arm dan x86 akan menjadi mayoritas distribusi ke depan, mungkin lebih tepat menganggapnya sekitar separuh
Tulisan yang bagus, dan bagian ditemukannya kode yang tidak efisien pada perbandingan pasangan integer 16-bit terasa menarik
Rust bisa memiliki informasi yang jauh lebih akurat tentang apakah memori sudah diinisialisasi atau belum
Jika kondisinya sama, menurut saya codec seharusnya ditulis dengan WUFFS, bukan Rust
Namun, menulis ulang sesuatu serumit dav1d dengan WUFFS bisa jauh lebih besar pekerjaannya daripada merapikan hasil konversi c2rust. Saya bisa percaya kalau itu seribu kali lebih sulit. Tetap saja, untuk peradaban secara keseluruhan, menurut saya itu layak dicoba
Yang saya maksud adalah WUFFS atau bahasa khusus setara, dan WUFFS sudah ada
Tanpa alokasi memori dinamis, data dinamis sulit ditangani. Codec video bukan sekadar mem-parsing file untuk mendapatkan data; ia harus mengelola banyak state yang cukup dinamis
Kalau sebuah tulisan diawali meme lucu, kita tahu itu tulisan bagus
Tampaknya juga terkait dengan diskusi terbaru: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238
Jujur, saya agak terkejut optimisasi pertama adalah sesuatu yang cukup jelas terlihat hanya dengan perf
Sepertinya pada tulisan pertama masalah inisialisasi buffer ke nol sudah dibahas, sedangkan optimisasi kedua jelas lebih rumit dan menarik, tetapi tetap saja perf menunjukkannya. Alat ini tidak boleh diremehkan
Memang ada
perf diff, tetapi ia tidak bisa mencocokkan nama simbol yang berbeda, dan tampaknya tidak banyak orang yang memakainyaSaya sering melihat orang dari latar belakang berbeda menemukan celah yang “jelas jika dilihat kembali”
Ini benar-benar menarik
Saya sempat bertanya-tanya apakah ada yang mencegah rustc melakukan trik transmute itu, tetapi kalau saja saya membaca paragraf berikutnya, saya akan tahu issue ini sebelum berkomentar
https://github.com/rust-lang/rust/issues/140167