rav1d वीडियो डिकोडर के परफ़ॉर्मेंस में सुधार
(ohadravid.github.io)- Rust-आधारित AV1 डिकोडर rav1d एक ही इनपुट पर C-आधारित dav1d की तुलना में लगभग 6 सेकंड, यानी 9% धीमा था, और दो छोटे optimization से रनटाइम 73.914 सेकंड से घटकर 72.182 सेकंड हो गया
- विश्लेषण में
samplyसे दोनों बाइनरी की तुलना एक ही शर्तों में की गई, और साझा Arm assembly functions को anchor मानकर Rust wrapper और function implementation के अंतर को ट्रैक किया गया - पहला सुधार Arm path के temporary buffer की 0 initialization को
MaybeUninitसे टालने औरlr_bakinitialization की जगह बदलने से जुड़ा था, जिससे कुल runtime लगभग 1.6% घटा - दूसरा सुधार छोटे numeric
structके defaultPartialEqसे बनी inefficient comparison कोzerocopyकेas_bytes()-आधारित comparison से बदलकर लगभग 0.5 सेकंड अतिरिक्त बचत लाया - इन दो PR ने नया
unsafeजोड़े बिना कुल 2.3% सुधार दिया, लेकिन माप macOS M3 chip, single-thread, और एक खास benchmark input तक सीमित था, और dav1d से अब भी लगभग 4.2 सेकंड का अंतर बचा है
आधारभूत परफ़ॉर्मेंस और मापन वातावरण
rav1d,dav1dका Rust पोर्ट हैc2rustसेdav1dको रूपांतरित किया गयाdav1dके assembly-optimized functions को एकीकृत किया गया- कोड को और अधिक Rust-सुलभ और सुरक्षित बनाने का काम शामिल है
- memorysafety.org ने
rav1dperformance improvement contest आयोजित किया था, और आधार स्थिति में Rust-आधारितrav1d, C-आधारितdav1dसे लगभग 5% धीमा था - लोकल मापन MacBook Air M3, 8-core वातावरण में किया गया
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, आउटपुट/dev/null
- शुरुआती
hyperfineपरिणाम rav1d 73.914 सेकंड, dav1d 67.912 सेकंड थे- एक ही sample file पर
rav1dलगभग 6 सेकंड, यानी 9% धीमा था clangऔरrustcके LLVM version में केवल patch version का अंतर था
- एक ही sample file पर
प्रोफ़ाइलिंग का तरीका
- प्रोफ़ाइलिंग के लिए
samplyका उपयोग किया गया- डिफ़ॉल्ट sampling rate 1000Hz थी
- किसी खास function में 500 sample का अंतर लगभग 0.5 सेकंड runtime अंतर के बराबर था
- क्योंकि दोनों बाइनरी समान और deterministically चलती हैं, पूरे video decoder को फिर से समझने के बजाय function-wise sample difference की तुलना प्रभावी रही
- साझा optimization assembly calls को anchor की तरह इस्तेमाल किया गया
dav1dcdef_filter_8x8_neon,cdef_filter_4x4_neonको कॉल करता है और उनसे जुड़े assembly function dispatch करता हैrav1dमेंcdef_filter_neon_erasedसभी assembly function dispatch संभालता है
cdef_filter8_pri_sec_edged_8bpc_neonके sample count दोनों snapshots में लगभग समान थे, जिससे पुष्टि हुई कि तुलना की दिशा सही हैcdef_filter_neon_erasedऔरrav1d_cdef_browका अंतर मिलाकरrav1dके कुल runtime का लगभग 1% थाdav1dकेcdef_filter_{8x8,4x4}_neonSelf samples का योग लगभग 400rav1dकेcdef_filter_neon_erasedSelf samples लगभग 670dav1d_cdef_brow_8bpcमें 1790 sample,rav1d_cdef_browमें 2350 sample
सुधार 1: temporary buffer की 0 initialization हटाना
cdef_filter_neon_erasedtemporary buffer कोAlign16([0u16; TMP_LEN])के रूप में बनाता हैTMP_LENworst case में12 * 16 + 8 = 200है- नतीजतन
[u16; 200]के बराबर temporary buffer को 0 से भरा जाता है
- इसके अनुरूप
dav1dC codeuint16_t tmp_buf[200] __attribute__((aligned(16)))के रूप में stack buffer बनाता है, लेकिन initialize नहीं करता- यह buffer
paddingassembly function के write target के रूप में काम करता है - बाद में
filterassembly function उसी value का उपयोग करता है
- यह buffer
rav1dके LLVM IR मेंllvm.memsetसे 400 bytes को 0 से भरने वाला code दिखता है- Rust compiler यह नहीं जान सका कि इस initialization को हटाना सुरक्षित है
MaybeUninitका उपयोग करके temporary buffer की 0 initialization को टाला गयाAlign16([0u16; TMP_LEN])कोAlign16([MaybeUninit::<u16>::uninit(); TMP_LEN])में बदला गया- internal function signature को
tmp: *mut MaybeUninit<u16>,tmp: &[MaybeUninit<u16>]के रूप में समायोजित किया गया - यह पहले से
unsafecode path के भीतर था, इसलिए कोई नयाunsafeblock नहीं जोड़ा गया
- बदलाव के बाद
cdef_filter_neon_erasedके Self samples 670 से 274 हो गए- यह
dav1dकेcdef_filter_{8x8,4x4}_neonSelf samples के योग से थोड़ा कम हो गया
- यह
सुधार 1 का विस्तार: loop के अंदर initialization कम करना
- बड़े
Align16buffer खोजते समयrav1d_cdef_browके भीतरlr_bakinitialization मिला- पुराना code loop के अंदर हर बार
lr_bakको 0 से initialize करता था - इसके अनुरूप
dav1dcode इस buffer को initialize नहीं करता
- पुराना code loop के अंदर हर बार
- यहां
MaybeUninitमें बदलना अधिक कठिन था, इसलिएlr_bakनिर्माण को loop के बाहर ले जाया गया- हर iteration पर initialization करने के बजाय सिर्फ एक बार किया गया
- बचत छोटी थी, लेकिन इसी तरह के अनावश्यक काम को कम किया गया
- इस बदलाव सहित पूरे benchmark में
rav1dने 72.644 सेकंड दर्ज किए- पहले के 73.914 सेकंड से 1.2 सेकंड सुधार
- कुल runtime के हिसाब से लगभग 1.5% सुधार
dav1dके 67.912 सेकंड से अभी भी अंतर बाकी था
सुधार 2: छोटे struct की equality comparison का optimization
- inverted stack view से दोबारा profiling करने पर
add_temporal_candidateमें स्पष्ट अंतर दिखा- Rust और C version का अंतर लगभग 400 sample, यानी लगभग 0.5 सेकंड था
- function खुद करीब 50 lines के
if,for, और छोटे utility calls से बना था
release-with-debugprofile से दोबारा build kar line-level sample distribution देखा गयाif cand.mv.mv[0] == mv {if cand.mv == mvp {- ये दोनों lines मिलाकर लगभग 600 sample ले रही थीं
- Rust का
Mv,#[derive(PartialEq)]इस्तेमाल करने वाला एक छोटा struct है#[repr(C)]y: i16,x: i16
dav1dकाmvएकunionके रूप में परिभाषित हैstruct { int16_t y, x; }uint32_t n- तुलना के समय
mvstack[n].mv.n == mvp.nकी तरह 32-bit value के रूप में compare किया जाता है
- Rust में
unionइस्तेमाल करने पर field accessunsafeहो जाता, जिससेMvके पूरे usage पर असर पड़ सकता था- इसके बजाय
zerocopyकेAsBytesका उपयोग करके byte representation compare की गई impl PartialEq for Mvमेंself.as_bytes() == other.as_bytes()इस्तेमाल किया गया- Godbolt पर जांच से पता चला कि यह
transmute-आधारित तरीके जैसी ही optimized assembly बनाता है
- इसके बजाय
RefMvs{Mv,Ref}Pairपर भी समान optimization लागू किया गया- benchmark result 72.182 सेकंड रहा
- पिछले 72.644 सेकंड की तुलना में लगभग 0.5 सेकंड सुधार
- शुरुआती 73.914 सेकंड की तुलना में 2.3% सुधार
Rust के default PartialEq और code generation की सीमाएँ
- छोटे struct के default
PartialEqसे inefficient code generation होने का कारण Rust issue#140167से जुड़ा है - C में
struct { int16_t y, x; }ऐसी स्थिति में हो सकता है जहां सिर्फyinitialize हो औरxuninitialized रहे- अगर comparison
this.y == other.y && this.x == other.xहै और सभीyअलग हों, तोxपढ़ने की ज़रूरत नहीं पड़ती - ऐसे मामलों को देखते हुए single memory load में optimize करना तभी मान्य है जब यह गारंटी हो कि सभी fields हमेशा initialized हैं
- अगर comparison
- संबंधित चर्चा में यह बात आई कि LLVM के पास “इस pointer के ज़रिए load हमेशा initialized bytes पढ़ेगा” जैसी property व्यक्त करने का तरीका नहीं है
zerocopystatic रूप से यह verify कर सकता है कि struct को byte slice के रूप में व्यक्त करना सुरक्षित है, इसलिए नयाunsafeजोड़े बिना optimized comparison लागू की जा सकी
अंतिम परिणाम और बचा हुआ performance gap
- पहला PR, Arm-only hot path में महंगी 0 initialization से बचता है
- PR #1397
- runtime में 1.2 सेकंड सुधार
- लगभग -1.6%
- दूसरा PR छोटे numeric struct के default
PartialEqimplementation को byte-based comparison में बदलता है- PR #1400
- runtime में 0.5 सेकंड सुधार
- लगभग -0.7%
- दोनों बदलाव मिलाकर कुछ दर्जन lines के हैं और codebase में कोई नया
unsafeनहीं लाते - अंतिम
rav1druntime 72.182 सेकंड रहा, जो शुरुआती बिंदु से 2.3% तेज़ हैdav1dके 67.912 सेकंड से अब भी लगभग 4.2 सेकंड पीछे- शुरुआत में देखे गए performance gap का लगभग 30% कम हुआ
- दोनों implementations के बीच अब भी लगभग 6% का अंतर बचा है, और
dav1dतथाrav1dके profiler snapshots की तुलना आगे के optimization खोजने में काम आती रह सकती है
1 टिप्पणियां
Hacker News राय
दो u16 की तुलना से जुड़ा issue दिलचस्प है
https://github.com/rust-lang/rust/issues/140167
-O3code generation अजीब है, लेकिन-O2output तर्कसंगत है। अगर कोई struct अभी-अभी calculate हुआ है, तो उसे एक single 32-bit load के रूप में पढ़ने की कोशिश करने पर store forwarding failure हो सकता है, जिससे load merging का फायदा खत्म हो सकता है। inline न होने और PGO न होने की स्थिति में compiler के पास यह तय करने के लिए पर्याप्त जानकारी नहीं होती कि वह optimization सही है या नहींweb developer के तौर पर, GitHub issues अक्सर काफी खराब लगते हैं
यह यकीन से कहना मुश्किल है कि C compiler भी सामान्य case में इस समस्या को बेहतर संभाल पाएगा
ऐसी बातों की वजह से लगता है कि ffmpeg Twitter account Rust के खिलाफ रुख लेता है
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
login न होने की वजह से मुझे सिर्फ original tweet दिख रहा है, इसलिए जानना चाहता हूं कि replies में इस पर कुछ समझाया गया है या नहीं
अफसोस है कि कोई ढंग का alternative नहीं है, और developers काफी aggressive लगते हैं। अगर आप पूरी pipeline control करते हैं, तो peak performance अच्छी होगी, लेकिन अगर आप अनजान users से untrusted data लेते हैं, तो ffmpeg में हर साल remote-exploitable CVE कम-से-कम पांच-छह तो आ ही जाते हैं। sandbox को मजबूत रखना बेहतर है
https://ffmpeg.org/security.html
लगता है कि यहां हर पक्ष की अड़ी हुई position से अलग, कोई बीच का रास्ता होगा जहां सभी safe और fast solution की ओर बढ़ रहे हों
Olympic record के criteria को refine करके Bolt के 100m record को retrospectively 9.63 seconds की जगह 9.64 seconds कर देने से किसी को फर्क नहीं पड़ेगा। लेकिन अगर कोई सच में 9 seconds में 100m दौड़े, तो ध्यान मिलेगा। हालांकि यह इंसान होने पर लागू है; अगर वह शुतुरमुर्ग हो तो उतना impressive नहीं, लेकिन आम तौर पर शुतुरमुर्ग Olympic 100m में हिस्सा नहीं लेते
यह दिलचस्प है कि buffers को zero-initialize न करने से मिलने वाले performance benefit पर एक post, इस post के दो दिन बाद दिखती है
https://news.ycombinator.com/item?id=44032680
title article को कम करके आंकता है
असल में दो अच्छी optimizations से यह 2.3% तेज हुआ
अगर Arm और x86 आगे distribution का ज्यादातर हिस्सा होंगे, तो इसे करीब आधा मानना ज्यादा सही होगा
अच्छा article है, और 16-bit integer pair comparison में inefficient code मिलने वाला हिस्सा दिलचस्प लगा
Rust के पास memory initialization state के बारे में कहीं ज्यादा accurate information हो सकती है
बाकी चीजें समान हों तो मेरा मानना है कि codecs Rust की बजाय WUFFS में लिखे जाने चाहिए
हालांकि dav1d जैसी complex चीज को WUFFS में नए सिरे से लिखना c2rust conversion result को साफ करने से कहीं बड़ा काम हो सकता है। अगर कोई कहे कि यह हजार गुना मुश्किल है, तो भी मान लूंगा। फिर भी पूरी civilization के स्तर पर यह करने लायक लगता है
मेरा मतलब WUFFS या किसी equivalent special-purpose language से है, और WUFFS पहले से मौजूद है
dynamic memory allocation न हो तो dynamic data संभालना मुश्किल है। video codec सिर्फ file parse करके data निकालने भर की चीज नहीं है; इसमें काफी dynamic state manage करनी पड़ती है
अगर कोई article मजेदार meme से शुरू होता है, तो समझ जाता हूं कि article अच्छा होगा
हाल की discussion से भी related लगता है: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238
सच कहूं तो थोड़ा आश्चर्य हुआ कि पहली optimization perf इस्तेमाल करने भर से काफी साफ दिख रही थी
लगता है पहले article में buffer zero-initialization issue पर पहले ही discussion हुई थी, और दूसरी optimization निश्चित रूप से ज्यादा complex और interesting थी, लेकिन फिर भी perf ने उसे point कर दिया। इस tool को underestimate नहीं करना चाहिए
perf diffमौजूद है, लेकिन वह अलग-अलग symbol names match नहीं कर पाता, और इसे इस्तेमाल करने वाले भी बहुत नहीं दिखतेअलग background से आए लोग अक्सर “पीछे मुड़कर देखें तो obvious” gaps खोज लेते हैं
यह सच में मजेदार है
मैं सोच रहा था कि rustc को वह transmute trick करने से क्या रोकता है, लेकिन अगर मैंने अगला paragraph पढ़ लिया होता तो comment करने से पहले ही इस issue के बारे में जान जाता
https://github.com/rust-lang/rust/issues/140167