1 पॉइंट द्वारा GN⁺ 2025-05-23 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • 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_bak initialization की जगह बदलने से जुड़ा था, जिससे कुल runtime लगभग 1.6% घटा
  • दूसरा सुधार छोटे numeric struct के default PartialEq से बनी 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 ने rav1d performance improvement contest आयोजित किया था, और आधार स्थिति में Rust-आधारित rav1d, C-आधारित dav1d से लगभग 5% धीमा था
  • लोकल मापन MacBook Air M3, 8-core वातावरण में किया गया
    • 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, आउटपुट /dev/null
  • शुरुआती hyperfine परिणाम rav1d 73.914 सेकंड, dav1d 67.912 सेकंड थे
    • एक ही sample file पर rav1d लगभग 6 सेकंड, यानी 9% धीमा था
    • clang और rustc के LLVM version में केवल patch version का अंतर था

प्रोफ़ाइलिंग का तरीका

  • प्रोफ़ाइलिंग के लिए samply का उपयोग किया गया
    • डिफ़ॉल्ट sampling rate 1000Hz थी
    • किसी खास function में 500 sample का अंतर लगभग 0.5 सेकंड runtime अंतर के बराबर था
  • क्योंकि दोनों बाइनरी समान और deterministically चलती हैं, पूरे video decoder को फिर से समझने के बजाय function-wise sample difference की तुलना प्रभावी रही
  • साझा optimization assembly calls को anchor की तरह इस्तेमाल किया गया
    • dav1d cdef_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}_neon Self samples का योग लगभग 400
    • rav1d के cdef_filter_neon_erased Self samples लगभग 670
    • dav1d_cdef_brow_8bpc में 1790 sample, rav1d_cdef_brow में 2350 sample

सुधार 1: temporary buffer की 0 initialization हटाना

  • cdef_filter_neon_erased temporary buffer को Align16([0u16; TMP_LEN]) के रूप में बनाता है
    • TMP_LEN worst case में 12 * 16 + 8 = 200 है
    • नतीजतन [u16; 200] के बराबर temporary buffer को 0 से भरा जाता है
  • इसके अनुरूप dav1d C code uint16_t tmp_buf[200] __attribute__((aligned(16))) के रूप में stack buffer बनाता है, लेकिन initialize नहीं करता
    • यह buffer padding assembly function के write target के रूप में काम करता है
    • बाद में filter assembly function उसी value का उपयोग करता है
  • 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>] के रूप में समायोजित किया गया
    • यह पहले से unsafe code path के भीतर था, इसलिए कोई नया unsafe block नहीं जोड़ा गया
  • बदलाव के बाद cdef_filter_neon_erased के Self samples 670 से 274 हो गए
    • यह dav1d के cdef_filter_{8x8,4x4}_neon Self samples के योग से थोड़ा कम हो गया

सुधार 1 का विस्तार: loop के अंदर initialization कम करना

  • बड़े Align16 buffer खोजते समय rav1d_cdef_brow के भीतर lr_bak initialization मिला
    • पुराना code loop के अंदर हर बार lr_bak को 0 से initialize करता था
    • इसके अनुरूप dav1d code इस buffer को initialize नहीं करता
  • यहां 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-debug profile से दोबारा 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 access unsafe हो जाता, जिससे 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; } ऐसी स्थिति में हो सकता है जहां सिर्फ y initialize हो और x uninitialized रहे
    • अगर comparison this.y == other.y && this.x == other.x है और सभी y अलग हों, तो x पढ़ने की ज़रूरत नहीं पड़ती
    • ऐसे मामलों को देखते हुए single memory load में optimize करना तभी मान्य है जब यह गारंटी हो कि सभी fields हमेशा initialized हैं
  • संबंधित चर्चा में यह बात आई कि LLVM के पास “इस pointer के ज़रिए load हमेशा initialized bytes पढ़ेगा” जैसी property व्यक्त करने का तरीका नहीं है
  • zerocopy static रूप से यह 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 PartialEq implementation को byte-based comparison में बदलता है
    • PR #1400
    • runtime में 0.5 सेकंड सुधार
    • लगभग -0.7%
  • दोनों बदलाव मिलाकर कुछ दर्जन lines के हैं और codebase में कोई नया unsafe नहीं लाते
  • अंतिम rav1d runtime 72.182 सेकंड रहा, जो शुरुआती बिंदु से 2.3% तेज़ है
    • dav1d के 67.912 सेकंड से अब भी लगभग 4.2 सेकंड पीछे
    • शुरुआत में देखे गए performance gap का लगभग 30% कम हुआ
  • दोनों implementations के बीच अब भी लगभग 6% का अंतर बचा है, और dav1d तथा rav1d के profiler snapshots की तुलना आगे के optimization खोजने में काम आती रह सकती है

1 टिप्पणियां

 
GN⁺ 2025-05-23
Hacker News राय
  • दो u16 की तुलना से जुड़ा issue दिलचस्प है
    https://github.com/rust-lang/rust/issues/140167

    • हैरानी है कि उस चर्चा में store forwarding का ज़िक्र नहीं हुआ
      -O3 code generation अजीब है, लेकिन -O2 output तर्कसंगत है। अगर कोई struct अभी-अभी calculate हुआ है, तो उसे एक single 32-bit load के रूप में पढ़ने की कोशिश करने पर store forwarding failure हो सकता है, जिससे load merging का फायदा खत्म हो सकता है। inline न होने और PGO न होने की स्थिति में compiler के पास यह तय करने के लिए पर्याप्त जानकारी नहीं होती कि वह optimization सही है या नहीं
    • अच्छा है कि यह चर्चा “मेरे साथ भी यही समस्या है”, “कब ठीक होगा?” जैसे comments के 14 pages नहीं है
      web developer के तौर पर, GitHub issues अक्सर काफी खराब लगते हैं
    • यह case compiler writing की जटिलता दिखाता लगता है
      यह यकीन से कहना मुश्किल है कि C compiler भी सामान्य case में इस समस्या को बेहतर संभाल पाएगा
  • ऐसी बातों की वजह से लगता है कि ffmpeg Twitter account Rust के खिलाफ रुख लेता है
    https://x.com/ffmpeg/status/1924137645988356437?s=46

    • आम तौर पर rbultje के benchmarks पर भरोसा करता हूं, लेकिन rav1d tracking issue में कई platforms के multi-threaded numbers हैं और वे इतना बड़ा अंतर नहीं दिखाते
      https://github.com/memorysafety/rav1d/issues/1294
      login न होने की वजह से मुझे सिर्फ original tweet दिख रहा है, इसलिए जानना चाहता हूं कि replies में इस पर कुछ समझाया गया है या नहीं
    • सिर्फ ffmpeg Twitter account पढ़कर ही ffmpeg इस्तेमाल करने का मन नहीं करता
      अफसोस है कि कोई ढंग का alternative नहीं है, और developers काफी aggressive लगते हैं। अगर आप पूरी pipeline control करते हैं, तो peak performance अच्छी होगी, लेकिन अगर आप अनजान users से untrusted data लेते हैं, तो ffmpeg में हर साल remote-exploitable CVE कम-से-कम पांच-छह तो आ ही जाते हैं। sandbox को मजबूत रखना बेहतर है
      https://ffmpeg.org/security.html
      लगता है कि यहां हर पक्ष की अड़ी हुई position से अलग, कोई बीच का रास्ता होगा जहां सभी safe और fast solution की ओर बढ़ रहे हों
    • ज्यादा स्वस्थ प्रतिक्रिया शायद dav1d को और तेज बनाने पर काम करना होती
      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% तेज हुआ

    • 1.5% वाली optimization aarch64-specific है, इसलिए पूरे आंकड़े को जस का तस claim करना थोड़ा unfair लगता है
      अगर Arm और x86 आगे distribution का ज्यादातर हिस्सा होंगे, तो इसे करीब आधा मानना ज्यादा सही होगा
  • अच्छा article है, और 16-bit integer pair comparison में inefficient code मिलने वाला हिस्सा दिलचस्प लगा

    • सोच रहा हूं कि Rust/LLVM side पर compiler को improve किया जा सकता है या नहीं, ताकि जहां भी संभव हो यह optimization apply हो
      Rust के पास memory initialization state के बारे में कहीं ज्यादा accurate information हो सकती है
  • बाकी चीजें समान हों तो मेरा मानना है कि codecs Rust की बजाय WUFFS में लिखे जाने चाहिए
    हालांकि dav1d जैसी complex चीज को WUFFS में नए सिरे से लिखना c2rust conversion result को साफ करने से कहीं बड़ा काम हो सकता है। अगर कोई कहे कि यह हजार गुना मुश्किल है, तो भी मान लूंगा। फिर भी पूरी civilization के स्तर पर यह करने लायक लगता है
    मेरा मतलब WUFFS या किसी equivalent special-purpose language से है, और WUFFS पहले से मौजूद है

    • WUFFS Matroska, webm, mp4 जैसी container file parsing के लिए शानदार होगा, लेकिन video decoder के लिए बिल्कुल suited नहीं लगता
      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 इस्तेमाल करना नहीं था, बल्कि C version और Rust version की differential profiling करके manually correspondence बैठाने जैसा था
      perf diff मौजूद है, लेकिन वह अलग-अलग symbol names match नहीं कर पाता, और इसे इस्तेमाल करने वाले भी बहुत नहीं दिखते
    • शायद यह इसलिए संभव हुआ क्योंकि Apple devices पर aarch64 perspective से approach किया गया
      अलग background से आए लोग अक्सर “पीछे मुड़कर देखें तो obvious” gaps खोज लेते हैं
  • यह सच में मजेदार है
    मैं सोच रहा था कि rustc को वह transmute trick करने से क्या रोकता है, लेकिन अगर मैंने अगला paragraph पढ़ लिया होता तो comment करने से पहले ही इस issue के बारे में जान जाता
    https://github.com/rust-lang/rust/issues/140167