rav1d video çözücüsünde performans iyileştirmeleri
(ohadravid.github.io)- Rust tabanlı AV1 çözücü rav1d, aynı girdide C tabanlı dav1d'den yaklaşık 6 saniye, %9 daha yavaştı; iki küçük optimizasyonla çalışma süresi 73.914 saniyeden 72.182 saniyeye düştü
- Analiz, iki ikiliyi aynı koşullarda
samplyile karşılaştırdı ve ortak Arm assembly fonksiyonlarını çapa alarak Rust sarmalayıcısı ile fonksiyon uygulaması arasındaki farkları izledi - İlk iyileştirme, Arm yolundaki geçici tamponun 0 ile başlatılmasını
MaybeUninitile önleyiplr_bakbaşlatma konumunu taşıyarak toplam çalışma süresini yaklaşık %1,6 azalttı - İkinci iyileştirme, küçük sayısal
structiçin varsayılanPartialEq'in ürettiği verimsiz karşılaştırmayızerocopy'ninas_bytes()tabanlı karşılaştırmasıyla değiştirerek ek olarak yaklaşık 0,5 saniye kazandırdı - İki PR, yeni
unsafeeklemeden toplamda %2,3 iyileşme sağladı; ancak ölçümler macOS M3 çip, tek iş parçacığı ve belirli bir benchmark girdisiyle sınırlıydı, ayrıca dav1d ile hâlâ yaklaşık 4,2 saniyelik fark kaldı
Referans performans ve ölçüm ortamı
rav1d,dav1d'nin Rust portudurc2rustiledav1ddönüştürüldüdav1d'nin assembly ile optimize edilmiş fonksiyonları entegre edildi- Kodun daha Rust tarzı ve güvenli hâle getirilmesi de buna dahil
- memorysafety.org,
rav1dperformans iyileştirme yarışması düzenledi ve başlangıç durumunda Rust tabanlırav1d, C tabanlıdav1d'den yaklaşık %5 daha yavaştı - Yerel ölçümler MacBook Air M3, 8 çekirdekli ortamda yapıldı
rav1d: commita654c1e82adb2d9a33ae50d2a82a7a747102cbb6rustc 1.88.0-nightly, LLVM20.1.2dav1d:1.5.1- Homebrew clang
20.1.4 - Girdi dosyası:
Chimera-AV1-8bit-1920x1080-6736kbps.ivf - Çalıştırma seçenekleri:
--threads 1, çıktı/dev/null
- İlk
hyperfinesonucu rav1d 73.914 saniye, dav1d 67.912 saniye oldu- Aynı örnek dosyada
rav1dyaklaşık 6 saniye, %9 daha yavaştı clangilerustc'nin LLVM sürümleri yalnızca patch sürümünde farklıydı
- Aynı örnek dosyada
Profil çıkarma yaklaşımı
- Profil çıkarma için
samplykullanıldı- Varsayılan örnekleme hızı 1000Hz
- Belirli bir fonksiyondaki 500 örnek farkı kabaca 0,5 saniyelik çalışma süresi farkına karşılık geliyor
- İki ikili benzer ve deterministik davrandığı için, tüm video çözücüyü baştan anlamak yerine fonksiyon bazında örnek farklarını karşılaştırmak etkili oldu
- Ortak kullanılan optimize assembly çağrıları çapa olarak alındı
dav1d,cdef_filter_8x8_neonvecdef_filter_4x4_neonçağırıyor, bunlar da ilgili assembly fonksiyonlarına dispatch ediyorrav1dise tüm assembly fonksiyonu dispatch işinicdef_filter_neon_erasedile yapıyor
cdef_filter8_pri_sec_edged_8bpc_neoniçin örnek sayısı iki anlık görüntüde de neredeyse aynıydı; bu da karşılaştırma yönünün doğru olduğunu gösterdicdef_filter_neon_erasedilerav1d_cdef_browfarkı birlikte,rav1d'nin toplam çalışma süresinin yaklaşık %1'ine karşılık geldidav1d'decdef_filter_{8x8,4x4}_neoniçin Self örneklerinin toplamı yaklaşık 400rav1d'decdef_filter_neon_erasediçin Self örnekleri yaklaşık 670dav1d_cdef_brow_8bpc1790 örnek alırkenrav1d_cdef_brow2350 örnek aldı
İyileştirme 1: geçici tamponun 0 ile başlatılmasını kaldırmak
cdef_filter_neon_erased, geçici tamponuAlign16([0u16; TMP_LEN])ile oluşturuyorTMP_LENen kötü durumda12 * 16 + 8 = 200- Sonuç olarak
[u16; 200]karşılığı geçici tampon 0 ile dolduruluyor
- Buna karşılık gelen
dav1dC kodu,uint16_t tmp_buf[200] __attribute__((aligned(16)))biçiminde bir stack tamponu oluşturuyor ama başlatmıyor- Bu tampon,
paddingassembly fonksiyonunun yazma hedefi oluyor - Ardından
filterassembly fonksiyonu bu değerleri doğrudan kullanıyor
- Bu tampon,
rav1d'nin LLVM IR çıktısında 400 baytı 0 ile dolduranllvm.memsetkodu görünüyor- Rust derleyicisi bu başlatmanın kaldırılmasının güvenli olduğunu bilemiyordu
- Geçici tamponun 0 ile başlatılmasını önlemek için
MaybeUninitkullanıldıAlign16([0u16; TMP_LEN]),Align16([MaybeUninit::<u16>::uninit(); TMP_LEN])olarak değiştirildi- İç fonksiyon imzaları
tmp: *mut MaybeUninit<u16>,tmp: &[MaybeUninit<u16>]biçimine uyarlandı - Zaten
unsafeolan kod yolunda işlendiği için yeni birunsafebloğu eklenmedi
- Değişiklikten sonra
cdef_filter_neon_erasediçin Self örnekleri 670'ten 274'e düştü- Bu değer,
dav1d'dekicdef_filter_{8x8,4x4}_neonSelf örnekleri toplamının biraz altına indi
- Bu değer,
İyileştirme 1'in uzantısı: döngü içi başlatmayı azaltmak
- Daha büyük
Align16tamponları aranırkenrav1d_cdef_browiçindekilr_bakbaşlatması bulundu- Eski kod, döngü içinde
lr_bak'ı her seferinde 0 ile başlatıyordu - Karşılık gelen
dav1dkodu bu tamponu başlatmıyor
- Eski kod, döngü içinde
- Burada
MaybeUninitdönüşümü daha zordu, bu yüzdenlr_bakoluşturma işlemi döngü dışına taşındı- Böylece her yinelemede başlatmak yerine yalnızca bir kez başlatıldı
- Kazanç küçük olsa da aynı tür gereksiz işi azaltmış oldu
- Bu değişiklik de dahil edildiğinde tam benchmark'ta
rav1d72.644 saniye ölçüldü- Önceki 73.914 saniyeye göre 1,2 saniye iyileşme
- Toplam çalışma süresine göre yaklaşık %1,5 iyileşme
dav1d'nin 67.912 saniyelik sonucuyla arasında hâlâ fark var
İyileştirme 2: küçük struct eşitlik karşılaştırmasını optimize etmek
- Ters çevrilmiş stack görünümünde yeniden profil çıkarılınca
add_temporal_candidateiçinde belirgin bir fark görüldü- Rust ve C sürümleri arasındaki fark yaklaşık 400 örnekti; bu da yaklaşık 0,5 saniyeye denk geliyor
- Fonksiyonun kendisi yaklaşık 50 satırlık
if,forve kısa yardımcı çağrılardan oluşuyor
- Satır bazında örnek dağılımını görmek için yeniden
release-with-debugprofiliyle derlendiif cand.mv.mv[0] == mv {if cand.mv == mvp {- Bu iki satır birlikte yaklaşık 600 örnek tuttu
- Rust'taki
Mv,#[derive(PartialEq)]kullanan küçük bir struct#[repr(C)]y: i16,x: i16
dav1d'dekimviseunionolarak tanımlanmışstruct { int16_t y, x; }uint32_t n- Karşılaştırma
mvstack[n].mv.n == mvp.nşeklinde 32 bitlik değer üzerinden yapılıyor
- Rust'ta
unionkullanılırsa alan erişimiunsafeoluyor ve bu durumMvkullanımının tamamını etkileyebiliyor- Bunun yerine
zerocopy'ninAsBytesözelliğiyle bayt gösterimi karşılaştırıldı impl PartialEq for Mviçindeself.as_bytes() == other.as_bytes()kullanıldı- Godbolt kontrolü, bunun
transmutetabanlı yaklaşımla aynı optimize assembly çıktısını ürettiğini gösterdi
- Bunun yerine
- Benzer optimizasyon
RefMvs{Mv,Ref}Pairiçin de uygulandı- Benchmark sonucu 72.182 saniye oldu
- Önceki 72.644 saniyeye göre yaklaşık 0,5 saniye iyileşme
- İlk referans olan 73.914 saniyeye göre %2,3 iyileşme
Rust varsayılan PartialEq ve kod üretim sınırları
- Küçük struct'lar için varsayılan
PartialEq'in verimsiz kod üretmesine yol açmasının nedeni Rust sorunu#140167ile bağlantılı - C'de
struct { int16_t y, x; }, yalnızcaybaşlatılmışkenxbaşlatılmamış durumda olabilir- Karşılaştırma
this.y == other.y && this.x == other.xise ve tümydeğerleri farklıysa,x'i okumaya gerek kalmaz - Bu tür durumlar düşünüldüğünde, tek bir bellek yüklemesiyle optimize etmek ancak tüm alanların her zaman başlatıldığı garanti edildiğinde geçerlidir
- Karşılaştırma
- İlgili tartışmalarda, LLVM'nin “bu işaretçi üzerinden yapılan yükleme her zaman başlatılmış baytları okur” özelliğini ifade etme yoluna sahip olmaması ele alınıyor
zerocopy, struct'ların bayt dilimi olarak temsil edilmesinin güvenlik koşullarını statik olarak doğrulayabildiği için, yeniunsafeolmadan optimize edilmiş karşılaştırma uygulanabildi
Nihai sonuçlar ve kalan performans farkı
- İlk PR, Arm'a özel sıcak yoldaki pahalı 0 ile başlatmayı ortadan kaldırdı
- PR #1397
- Çalışma süresinde 1,2 saniye iyileşme
- Yaklaşık -%1,6
- İkinci PR, küçük sayısal struct'lar için varsayılan
PartialEquygulamasını bayt tabanlı karşılaştırmayla değiştirdi- PR #1400
- Çalışma süresinde 0,5 saniye iyileşme
- Yaklaşık -%0,7
- Bu iki değişiklik birlikte birkaç düzine satır ölçeğinde kaldı ve kod tabanına yeni
unsafeeklemedi - Nihai
rav1dçalışma süresi 72.182 saniye oldu; bu da başlangıca göre %2,3 daha hızlıdav1d'nin 67.912 saniyelik sonucuna göre yaklaşık 4,2 saniye fark var- Başlangıçta gözlenen performans farkının yaklaşık %30'u kapatıldı
- İki uygulama arasında hâlâ yaklaşık %6 fark bulunuyor ve
dav1dilerav1dprofiler anlık görüntülerinin karşılaştırılması, ek optimizasyonları aramak için kullanılmaya devam edilebilir
1 yorum
Hacker News yorumları
İki u16’yı karşılaştırma ile ilgili mesele ilginç
https://github.com/rust-lang/rust/issues/140167
-O3kod üretimi tuhaf, ama-O2çıktısı makul. Bir struct az önce hesaplanmış durumdaysa, onu tek bir 32 bit load ile okumaya çalışmak store forwarding başarısızlığına yol açıp load birleştirmenin faydasını ortadan kaldırabilir. Inline edilmediği ve PGO olmadığı durumda derleyicinin bu optimizasyonun uygun olup olmadığını değerlendirecek yeterli bilgisi yokBir web geliştiricisi olarak GitHub issue’ları çoğu zaman epey kötü buluyorum
C derleyicilerinin de genel durumda bu sorunu daha iyi ele alabileceğinden emin olmak zor
Bu tür şeyler yüzünden ffmpeg Twitter hesabı Rust’a karşı bir tutum sergiliyor gibi
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
Giriş yapmadığım için yalnızca asıl tweet’i görebiliyorum; yanıtlarında açıklanan bir kısım var mı merak ediyorum
Düzgün bir alternatif olmaması üzücü ve geliştiriciler epey saldırgan görünüyor. Tüm pipeline’ı kontrol ediyorsanız en iyi performans iyi olabilir, ama belirsiz kullanıcılardan güvenilmeyen veri alıyorsanız ffmpeg’de her yıl uzaktan istismar edilebilir en az beş altı CVE çıkıyor. Sandbox’ı sağlam tutmakta fayda var
https://ffmpeg.org/security.html
Burada herkesin tutunduğu konumlardan ziyade, herkesin güvenli ve hızlı bir çözüme doğru gittiği bir orta nokta var gibi
Olimpiyat rekoru hesaplama kriterlerini ince ayarlayıp Bolt’un 100 m rekorunu 9,63 saniye değil 9,64 saniye olarak geriye dönük düzeltsek kimsenin umurunda olmaz. Ama gerçekten 100 metreyi 9 saniyede koşarsanız ilgi görürsünüz. Tabii insan olduğunuzda; devekuşuysanız etkileyici olmaz, ama genelde devekuşları Olimpiyat 100 m yarışına katılmaz
Buffer’ı sıfırla başlatmayarak elde edilen performans avantajıyla ilgili bir yazının, bu yazı çıktıktan iki gün sonra görülmesi ilginç
https://news.ycombinator.com/item?id=44032680
Başlık yazının değerini düşük gösteriyor
Aslında iki iyi optimizasyon sayesinde %2,3 daha hızlı hale geliyor
Arm ve x86’ın gelecekte dağıtımların çoğunu oluşturacağını düşünürsek bunu yarı yarıya saymak daha doğru olur gibi
Güzel bir yazı; 16 bit tamsayı çiftlerini karşılaştırma sırasında verimsiz kod bulunması ilginçti
Rust, belleğin ilklendirilip ilklendirilmediği konusunda çok daha doğru bilgiye sahip olabilir
Koşullar eşitse codec’lerin Rust yerine WUFFS ile yazılması gerektiğini düşünüyorum
Yine de dav1d kadar karmaşık bir şeyi WUFFS ile baştan yazmak, c2rust dönüşüm sonucunu temizlemekten çok daha büyük bir iş olabilir. Bin kat daha zor deseler inanırım. Yine de tüm medeniyet ölçeğinde denemeye değer olduğunu düşünüyorum
WUFFS’ten ya da eşdeğer özel amaçlı bir dilden bahsediyorum; WUFFS zaten var
Dinamik bellek ayırma yoksa dinamik verilerle uğraşmak zor. Video codec’leri yalnızca bir dosyayı parse edip veri elde etmekten ibaret değil; oldukça dinamik pek çok durumu yönetmeleri gerekiyor
Komik bir meme ile başlayan bir yazıysa iyi yazı olduğunu anlarsınız
Yakın zamandaki tartışmayla da ilgili görünüyor: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238
Açıkçası ilk optimizasyonun sadece perf kullanılsa bile epey net görülebilen bir şey olması beni biraz şaşırttı
İlk yazıda buffer’ı sıfırla başlatma sorununu zaten tartışmışlardı sanırım; ikinci optimizasyon kesinlikle daha karmaşık ve ilginçti ama yine de perf ona işaret etti. Bu aracı küçümsememek gerek
perf diffvar, ama farklı sembol adlarını eşleştiremiyor ve kullanan da pek fazla görünmüyorFarklı arka planlardan gelen insanların “geriye dönüp bakınca bariz” olan açıkları bulduğunu sık sık görüyorum
Bu gerçekten çok eğlenceli
rustc’nin o transmute hilesini yapmasını engelleyen bir şey var mı diye merak etmiştim; bir sonraki paragrafı okusaydım yorum yazmadan önce bu issue’dan haberdar olurdum
https://github.com/rust-lang/rust/issues/140167