- 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 が生む非効率な比較を、zerocopy の as_bytes() ベースの比較に置き換え、さらに約 0.5秒 を削減した
- 2つのPRにより新たな
unsafe なしで合計2.3%の改善が得られたが、測定は macOS M3チップ、単一スレッド、特定のベンチマーク入力に限られており、dav1d との差は依然として約4.2秒残っている
基準性能と測定環境
rav1d は dav1d のRustポート
c2rust で dav1d を変換
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%遅い
clang と rustc のLLVMバージョンの違いはパッチバージョンのみだった
プロファイリングのアプローチ
- プロファイリングには
samply を使用
- デフォルトのサンプリング速度は1000Hz
- 特定の関数で500サンプルの差は、おおよそ0.5秒の実行時間差に相当する
- 2つのバイナリは似ていて決定的に動作するため、ビデオデコーダ全体を新たに理解するよりも、関数ごとのサンプル差 を比較する方法が有効だった
- 共通で使用している最適化アセンブリ呼び出しをアンカーとして利用
dav1d は cdef_filter_8x8_neon, cdef_filter_4x4_neon を呼び出し、それぞれが関連するアセンブリ関数をディスパッチする
rav1d は cdef_filter_neon_erased がすべてのアセンブリ関数ディスパッチを処理する
cdef_filter8_pri_sec_edged_8bpc_neon のサンプル数は2つのスナップショットでほぼ同じで、比較の方向性が正しいことを確認できた
cdef_filter_neon_erased と rav1d_cdef_brow の差は、合計で rav1d 全体の実行時間の約 1% に相当した
dav1d の cdef_filter_{8x8,4x4}_neon Selfサンプル合計は約400
rav1d の cdef_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.memset で 400バイト を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 に減少
dav1d の cdef_filter_{8x8,4x4}_neon Selfサンプル合計よりやや低くなった
改善1の延長: ループ内の初期化を減らす
- 大きな
Align16 バッファをさらに探す過程で、rav1d_cdef_brow 内の lr_bak 初期化が見つかった
- 従来のコードでは、ループ内で
lr_bak を毎回0初期化していた
- 対応する
dav1d のコードではこのバッファを初期化していない
- ここでは
MaybeUninit への移行がより難しかったため、lr_bak の生成をループの外に移動
- 毎反復で初期化せず、1回だけ行うようにした
- 削減幅は小さいが、同種の不要な処理を減らせる
- この変更まで含めた全体ベンチマークで、
rav1d は 72.644秒 を記録
- 従来の73.914秒から1.2秒改善
- 全体ランタイム基準で約1.5%改善
dav1d の67.912秒との差はなお残る
改善2: 小さな構造体の等価比較を最適化
- inverted stack表示で再びプロファイリングすると、
add_temporal_candidate に目立つ差が現れた
- Rust版とC版の差は約400サンプルで、約0.5秒に相当
- 関数自体は約50行の
if、for、短いユーティリティ呼び出しで構成される
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
dav1d の mv は union として定義されている
struct { int16_t y, x; }
uint32_t n
- 比較時は
mvstack[n].mv.n == mvp.n のように32ビット値として比較する
- Rustで
union を使うとフィールドアクセスが unsafe になり、Mv の利用箇所全体に影響する可能性がある
- そこで代わりに
zerocopy の AsBytes を使ってバイト表現を比較
impl PartialEq for Mv で self.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初期化を回避した
- 2つ目のPRは、小さな数値構造体に対するデフォルトの
PartialEq 実装をバイトベース比較に変更した
- 2つの変更を合わせると数十行規模で、コードベースに新たな
unsafe を導入していない
- 最終的な
rav1d の実行時間は 72.182秒 で、出発点より2.3%高速化した
dav1d の67.912秒とは約4.2秒の差
- 当初観測された性能差の約30%を縮めた
- 両実装の間には依然として約 6%の差 が残っており、
dav1d と rav1d のプロファイラスナップショット比較は、今後の追加最適化探索にも引き続き活用できる
1件のコメント
Hacker Newsのコメント
u16を2つ比較することに関する問題が興味深い
https://github.com/rust-lang/rust/issues/140167
-O3のコード生成は奇妙だが、-O2の出力は妥当である。ある構造体がちょうど計算された直後の状態にあるなら、これを単一の32ビットロードで読もうとした際にストアフォワーディングの失敗が起き、ロード結合の利点が失われる可能性がある。インライン化されず、PGOもない状況では、コンパイラにはその最適化が適切かどうかを判断するための情報が不足しているWeb開発者としては GitHub issue がかなりひどいことは多い
Cコンパイラでも一般的なケースでこの問題をよりうまく扱えると確信するのは難しい
こういう話があるからこそ、ffmpegのTwitterアカウントがRustに反対する立場を出しているのだと思う
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
ログインしていないので元ツイートしか見えないが、返信で説明されている点があるのか気になる
まともな代替がないのは残念で、開発者たちはかなり攻撃的に見える。パイプライン全体を制御できるなら最高性能は魅力的だが、不特定ユーザーから信頼できないデータを受け取るなら、ffmpegには毎年少なくとも5〜6件のリモート悪用可能なCVEが出ている。サンドボックスを強固にしておくのがよい
https://ffmpeg.org/security.html
それぞれが自分の立場に固執するよりも、皆が 安全で高速な解法 に向かう中間地点があるはずだ
オリンピック記録の測定基準を見直して、Boltの100m記録を9.63秒ではなく9.64秒に遡って修正しても誰も気にしない。しかし実際に100mを9秒で走れば注目される。もちろん人間ならの話で、ダチョウなら印象的ではないが、たいていダチョウはオリンピックの100mに出場しない
バッファを0で初期化しないことで得られる性能向上についての記事が、この記事の2日後に現れたのは興味深い
https://news.ycombinator.com/item?id=44032680
タイトルは記事を過小評価している
実際には 良い最適化が2つ によって2.3%高速化された
今後の展開の大半がArmとx86だと考えるなら、半分程度として見るほうがより妥当だろう
良い記事で、16ビット整数ペアの比較 で非効率なコードが見つかった部分が興味深かった
Rustはメモリが初期化済みかどうかについて、はるかに正確な情報を持てるはずだ
条件が同じなら、コーデックはRustより WUFFS で書かれるべきだと思う
ただしdav1dのように複雑なものをWUFFSで新規実装するのは、c2rust変換結果を整理するよりはるかに大仕事かもしれない。1000倍難しいと言われても信じられる。それでも文明全体としては挑戦する価値があると思う
WUFFSまたは同等の専用言語のことを言っていて、WUFFSはすでに存在する
動的メモリ割り当てがなければ動的データを扱いにくい。ビデオコーデックは単にファイルをパースしてデータを取り出すだけではなく、かなり動的な状態を多く管理しなければならない
面白いミームで始まる記事は良い記事だとわかる
最近の議論とも関係していそうだ: $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 diffはあるが、異なるシンボル名を対応付けられず、使っている人も多くなさそうだ異なる背景を持つ人が「振り返れば明らか」な抜け穴を見つけるのはよくある
これは本当に面白い
rustcにその transmuteトリック を実行できなくしている何かがあるのか気になったが、コメントする前に次の段落を読んでいればこのissueに気づいていただろう
https://github.com/rust-lang/rust/issues/140167