1 ポイント 投稿者 GN⁺ 2025-05-23 | 1件のコメント | WhatsAppで共有
  • RustベースのAV1デコーダ rav1d は、同じ入力でCベースの dav1d より約6秒、9%遅かったが、2つの小さな最適化によって実行時間が73.914秒から72.182秒に短縮された
  • 分析では samply を使って2つのバイナリを同条件で比較し、共通のArmアセンブリ関数を アンカー として、Rustラッパーと関数実装の差を追跡した
  • 1つ目の改善では、Arm経路の一時バッファの0初期化を MaybeUninit で回避し、lr_bak の初期化位置を移動して、全体のランタイムを約 1.6% 削減した
  • 2つ目の改善では、小さな数値 struct に対するデフォルトの PartialEq が生む非効率な比較を、zerocopyas_bytes() ベースの比較に置き換え、さらに約 0.5秒 を削減した
  • 2つのPRにより新たな unsafe なしで合計2.3%の改善が得られたが、測定は macOS M3チップ、単一スレッド、特定のベンチマーク入力に限られており、dav1d との差は依然として約4.2秒残っている

基準性能と測定環境

  • rav1ddav1d のRustポート
    • c2rustdav1d を変換
    • dav1d のアセンブリ最適化関数を統合
    • コードをよりRustらしく安全にしていく作業を含む
  • memorysafety.org は rav1d 性能改善コンテストを開催しており、基準状態ではRustベースの rav1d はCベースの dav1d より約 5%遅かった
  • ローカル測定は 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, 出力先は /dev/null
  • 初期の hyperfine 結果は rav1d 73.914秒dav1d 67.912秒
    • 同じサンプルファイルで rav1d は約6秒、9%遅い
    • clangrustc のLLVMバージョンの違いはパッチバージョンのみだった

プロファイリングのアプローチ

  • プロファイリングには samply を使用
    • デフォルトのサンプリング速度は1000Hz
    • 特定の関数で500サンプルの差は、おおよそ0.5秒の実行時間差に相当する
  • 2つのバイナリは似ていて決定的に動作するため、ビデオデコーダ全体を新たに理解するよりも、関数ごとのサンプル差 を比較する方法が有効だった
  • 共通で使用している最適化アセンブリ呼び出しをアンカーとして利用
    • dav1dcdef_filter_8x8_neon, cdef_filter_4x4_neon を呼び出し、それぞれが関連するアセンブリ関数をディスパッチする
    • rav1dcdef_filter_neon_erased がすべてのアセンブリ関数ディスパッチを処理する
  • cdef_filter8_pri_sec_edged_8bpc_neon のサンプル数は2つのスナップショットでほぼ同じで、比較の方向性が正しいことを確認できた
  • cdef_filter_neon_erasedrav1d_cdef_brow の差は、合計で rav1d 全体の実行時間の約 1% に相当した
    • dav1dcdef_filter_{8x8,4x4}_neon Selfサンプル合計は約400
    • rav1dcdef_filter_neon_erased Selfサンプルは約670
    • dav1d_cdef_brow_8bpc は1790サンプル、rav1d_cdef_brow は2350サンプル

改善1: 一時バッファの0初期化を削除

  • cdef_filter_neon_erased は一時バッファを Align16([0u16; TMP_LEN]) で生成する
    • TMP_LEN は最悪ケースで 12 * 16 + 8 = 200
    • 結果として [u16; 200] に相当する一時バッファを0で埋めることになる
  • 対応する dav1d のCコードでは、uint16_t tmp_buf[200] __attribute__((aligned(16))) という形のスタックバッファを作るが初期化しない
    • このバッファは padding アセンブリ関数の書き込み先になる
    • その後 filter アセンブリ関数がその値をそのまま使う
  • rav1d のLLVM IRには、llvm.memset400バイト を0で埋めるコードが現れる
    • Rustコンパイラはこの初期化を消してよいことを認識できなかった
  • MaybeUninit を使って一時バッファの0初期化を回避
    • Align16([0u16; TMP_LEN])Align16([MaybeUninit::<u16>::uninit(); TMP_LEN]) に変更
    • 内部関数シグネチャは tmp: *mut MaybeUninit<u16>, tmp: &[MaybeUninit<u16>] の形に調整
    • すでに unsafe なコード経路内で処理されていたため、新しい unsafe ブロックは追加していない
  • 変更後、cdef_filter_neon_erased のSelfサンプルは 670から274 に減少
    • dav1dcdef_filter_{8x8,4x4}_neon Selfサンプル合計よりやや低くなった

改善1の延長: ループ内の初期化を減らす

  • 大きな Align16 バッファをさらに探す過程で、rav1d_cdef_brow 内の lr_bak 初期化が見つかった
    • 従来のコードでは、ループ内で lr_bak を毎回0初期化していた
    • 対応する dav1d のコードではこのバッファを初期化していない
  • ここでは MaybeUninit への移行がより難しかったため、lr_bak の生成をループの外に移動
    • 毎反復で初期化せず、1回だけ行うようにした
    • 削減幅は小さいが、同種の不要な処理を減らせる
  • この変更まで含めた全体ベンチマークで、rav1d72.644秒 を記録
    • 従来の73.914秒から1.2秒改善
    • 全体ランタイム基準で約1.5%改善
    • dav1d の67.912秒との差はなお残る

改善2: 小さな構造体の等価比較を最適化

  • inverted stack表示で再びプロファイリングすると、add_temporal_candidate に目立つ差が現れた
    • Rust版とC版の差は約400サンプルで、約0.5秒に相当
    • 関数自体は約50行の iffor、短いユーティリティ呼び出しで構成される
  • release-with-debug プロファイルで再ビルドし、行単位のサンプル分布を確認
    • if cand.mv.mv[0] == mv {
    • if cand.mv == mvp {
    • この2行で合計約600サンプルを占めていた
  • Rustの Mv#[derive(PartialEq)] を使う小さな構造体
    • #[repr(C)]
    • y: i16, x: i16
  • dav1dmvunion として定義されている
    • struct { int16_t y, x; }
    • uint32_t n
    • 比較時は mvstack[n].mv.n == mvp.n のように32ビット値として比較する
  • Rustで union を使うとフィールドアクセスが unsafe になり、Mv の利用箇所全体に影響する可能性がある
    • そこで代わりに zerocopyAsBytes を使ってバイト表現を比較
    • impl PartialEq for Mvself.as_bytes() == other.as_bytes() を使用
    • Godboltで確認したところ、transmute ベースの方法と同じ最適化済みアセンブリが生成された
  • RefMvs{Mv,Ref}Pair にも同様の最適化を適用
    • ベンチマーク結果は 72.182秒
    • 前回の72.644秒から約0.5秒改善
    • 最初の基準73.914秒比で2.3%改善

Rustのデフォルト PartialEq とコード生成の限界

  • 小さな構造体に対するデフォルトの PartialEq が非効率なコード生成をもたらす理由は、Rustのissue #140167 と関連している
  • Cでは struct { int16_t y, x; } において、y だけ初期化され x は未初期化のままという状態があり得る
    • 比較が this.y == other.y && this.x == other.x で、すべての y が異なれば、x を読む必要はない
    • このケースを考慮すると、単一のメモリロードへ最適化できるのは、すべてのフィールドが常に初期化済みだと保証される場合に限られる
  • 関連する議論では、LLVMに「このポインタ経由のロードは常に初期化済みバイトを読む」という性質を表現する方法がない点が扱われている
  • zerocopy は、構造体をバイトスライスとして表現してよい安全条件を静的に検証できるため、新たな unsafe なしで最適化された比較を実装できた

最終結果と残る性能差

  • 1つ目のPRは、Arm専用のホットパスで高コストな0初期化を回避した
    • PR #1397
    • 実行時間を1.2秒改善
    • 約 -1.6%
  • 2つ目のPRは、小さな数値構造体に対するデフォルトの PartialEq 実装をバイトベース比較に変更した
    • PR #1400
    • 実行時間を0.5秒改善
    • 約 -0.7%
  • 2つの変更を合わせると数十行規模で、コードベースに新たな unsafe を導入していない
  • 最終的な rav1d の実行時間は 72.182秒 で、出発点より2.3%高速化した
    • dav1d の67.912秒とは約4.2秒の差
    • 当初観測された性能差の約30%を縮めた
  • 両実装の間には依然として約 6%の差 が残っており、dav1drav1d のプロファイラスナップショット比較は、今後の追加最適化探索にも引き続き活用できる

1件のコメント

 
GN⁺ 2025-05-23
Hacker Newsのコメント
  • u16を2つ比較することに関する問題が興味深い
    https://github.com/rust-lang/rust/issues/140167

    • その議論で ストアフォワーディング(store forwarding) に触れられていないのは意外だった
      -O3 のコード生成は奇妙だが、-O2 の出力は妥当である。ある構造体がちょうど計算された直後の状態にあるなら、これを単一の32ビットロードで読もうとした際にストアフォワーディングの失敗が起き、ロード結合の利点が失われる可能性がある。インライン化されず、PGOもない状況では、コンパイラにはその最適化が適切かどうかを判断するための情報が不足している
    • この議論が「自分にもこの問題がある」「いつ直るのか?」のようなコメント14ページ分ではないのが良い
      Web開発者としては GitHub issue がかなりひどいことは多い
    • この事例は コンパイラ実装の複雑さ を示しているように思える
      Cコンパイラでも一般的なケースでこの問題をよりうまく扱えると確信するのは難しい
  • こういう話があるからこそ、ffmpegのTwitterアカウントがRustに反対する立場を出しているのだと思う
    https://x.com/ffmpeg/status/1924137645988356437?s=46

    • 普段はrbultjeのベンチマークを信頼しているが、rav1dの追跡issue には複数プラットフォームでのマルチスレッド数値があり、そこまで大きな差は見られない
      https://github.com/memorysafety/rav1d/issues/1294
      ログインしていないので元ツイートしか見えないが、返信で説明されている点があるのか気になる
    • ffmpegのTwitterアカウントを読むだけでもffmpegを使いたくなくなるほどだ
      まともな代替がないのは残念で、開発者たちはかなり攻撃的に見える。パイプライン全体を制御できるなら最高性能は魅力的だが、不特定ユーザーから信頼できないデータを受け取るなら、ffmpegには毎年少なくとも5〜6件のリモート悪用可能なCVEが出ている。サンドボックスを強固にしておくのがよい
      https://ffmpeg.org/security.html
      それぞれが自分の立場に固執するよりも、皆が 安全で高速な解法 に向かう中間地点があるはずだ
    • より健全な対応は dav1dをさらに高速化すること だったはずだ
      オリンピック記録の測定基準を見直して、Boltの100m記録を9.63秒ではなく9.64秒に遡って修正しても誰も気にしない。しかし実際に100mを9秒で走れば注目される。もちろん人間ならの話で、ダチョウなら印象的ではないが、たいていダチョウはオリンピックの100mに出場しない
  • バッファを0で初期化しないことで得られる性能向上についての記事が、この記事の2日後に現れたのは興味深い
    https://news.ycombinator.com/item?id=44032680

  • タイトルは記事を過小評価している
    実際には 良い最適化が2つ によって2.3%高速化された

    • 1.5%の最適化は aarch64専用 なので、全体の数値としてそのまま主張するのは少し不公平に見える
      今後の展開の大半がArmとx86だと考えるなら、半分程度として見るほうがより妥当だろう
  • 良い記事で、16ビット整数ペアの比較 で非効率なコードが見つかった部分が興味深かった

    • Rust/LLVM側で、可能なときは常にこの最適化を適用するようコンパイラを改善できるのか気になる
      Rustはメモリが初期化済みかどうかについて、はるかに正確な情報を持てるはずだ
  • 条件が同じなら、コーデックはRustより WUFFS で書かれるべきだと思う
    ただしdav1dのように複雑なものをWUFFSで新規実装するのは、c2rust変換結果を整理するよりはるかに大仕事かもしれない。1000倍難しいと言われても信じられる。それでも文明全体としては挑戦する価値があると思う
    WUFFSまたは同等の専用言語のことを言っていて、WUFFSはすでに存在する

    • WUFFSはMatroska、webm、mp4のような コンテナファイルのパース には素晴らしいだろうが、ビデオデコーダにはまったく向いていないように見える
      動的メモリ割り当てがなければ動的データを扱いにくい。ビデオコーデックは単にファイルをパースしてデータを取り出すだけではなく、かなり動的な状態を多く管理しなければならない
  • 面白いミームで始まる記事は良い記事だとわかる
    最近の議論とも関係していそうだ: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238

  • 正直、最初の最適化が perf だけでもかなり明確に見えるものだったのは少し驚きだ
    最初の記事でバッファの0初期化問題はすでに議論されていた気がするが、2つ目の最適化は確かにより複雑で興味深かった。それでもperfが示していた。このツールを過小評価してはいけない

    • 見たところ、単にperfを使ったというより、C版とRust版の差分プロファイリング を行い、それを手作業で対応付けたのに近い
      perf diff はあるが、異なるシンボル名を対応付けられず、使っている人も多くなさそうだ
    • Appleデバイスで aarch64の観点 から見たからこそ可能だったようだ
      異なる背景を持つ人が「振り返れば明らか」な抜け穴を見つけるのはよくある
  • これは本当に面白い
    rustcにその transmuteトリック を実行できなくしている何かがあるのか気になったが、コメントする前に次の段落を読んでいればこのissueに気づいていただろう
    https://github.com/rust-lang/rust/issues/140167