![### なぜ Rust からアセンブリを呼び出すと、C からの呼び出しよりも遅くなることがあるか
手書きで作成したアセンブリルーチンを **C** から呼び出す場合、コンパイラは次のように最適化できます。
1. **プレーンで保護されていない呼び出しを生成** – 関数には Rust のランタイムオーバーヘッドがありません。
2. **呼び出し元のレジスタを保持** – 呼び出し規約(例:`__cdecl` や `__stdcall`)に従い、保護すべきレジスタと破壊されるレジスタを区別します。
3. **安全性チェックを行わない** – C はデフォルトで「unsafe」とみなすため、追加の検証が発生しません。
一方で同じアセンブリルーチンを **Rust** から呼び出すと、以下のような微妙な差異がオーバーヘッドを招くことがあります。
| 観点 | Rust 呼び出し | C 呼び出し |
|------|----------------|------------|
| **呼び出し規約** | デフォルトでは `extern "C"` がシステム ABI と一致しますが、`#[no_mangle]` を明示せずに使用すると、コンパイラが名前マングリングやシンボル解決を処理する小さなラッパーを追加することがあります。 | 直接 ABI を使用し、ラッパーは不要です。 |
| **ABI の不一致** | Rust の関数シグネチャがアセンブリの期待するパラメータ(呼び出し規約・スタック整列・レジスタ使用)と合わない場合、コンパイラはスタック/整列を調整したり、引数を別順序で渡すコードを挿入します。 | C コンパイラは呼び出しのレイアウトを正確に知っています。 |
| **安全性ラッパー** | Rust は `extern "C"` 境界を跨ぐ際に追加の安全チェック(例:NULL ポインタの検査、関数ポインタが NULL でないことの確認)を生成する場合があります。これらは微小ですがマイクロベンチマークでは影響します。 | C にはそのようなチェックはありません。 |
| **インライン化と最適化** | アセンブリルーチン自体はインライン化できないため、Rust は通常の呼び出し命令を発行します。周囲の Rust コードが高度に最適化(例:ヘルパー関数の積極的なインライン化)されていると、呼び出しオーバーヘッドが目立ちます。 | C コンパイラは小さなラッパーをインライン化したり、一部チェックを削除してオーバーヘッドを減らすことがあります。 |
| **リンカ / シンボル解決** | Rust のリンカーは、`#[link(name = "...")]` で正しく宣言されていない `extern` シンボルに対し追加のリロケーションエントリを生成する場合があり、一部プラットフォームでは実行時に微小な検索コストが発生します。 | C リンカーは通常、余分な間接参照なしでシンボルを直接解決します。 |
#### 測定可能な遅延の典型的原因
1. **呼び出し規約の不一致** – 例:Rust が `extern "C"` を使用しているが、アセンブリは `__stdcall`(またはその逆)を期待している。
2. **スタック整列のズレ** – アセンブリルーチンが 16 バイト整列を前提としているのに、Rust の呼び出し側で 8 バイトのまま残っていると、コンパイラはスタック調整コードを挿入します。
3. **名前マングリング / シンボル検索** – `#[no_mangle]` を忘れると追加の名前解決ステップが発生し、別関数が呼び出される可能性があります。
4. **引数渡しの違い** – Rust がレジスタで引数を渡す一方、アセンブリはスタックを期待している(または逆)と、余分な移動命令が生成されます。
#### それらを比較可能にする方法
```rust
#[no_mangle]
pub extern "C" fn my_asm_fn(arg1: i32, arg2: i64) -> i32 {
// C の呼び出し規約に従ったインラインアセンブリ。
unsafe { asm!("...", in("rdi") arg1, in("rsi") arg2, out("eax") ret); }
}
```
- **`extern "C"` を明示的に宣言** して、Rust が C コードと同じ ABI を使用するようにします。
- **`#[no_mangle]` を使う** ことで名前マングリングを防ぎます。
- **引数の順序・型を正確に合わせる** ことでアセンブリ側の期待と一致させます。
- **スタック整列を保証する**(必要なら `#[inline(always)]` を付けたり、手動で整列処理を入れたりします)。
これらが揃えば、Rust の呼び出しオーバーヘッドはほぼ C と同等になり、ベンチマーク上で差異が見えなくなるはずです。もしまだ違いが残る場合は、生成されたアセンブリをプロファイルして、余分な前処理/後処理コードが出力されていないか確認してください。](/_next/image?url=%2Fscreenshots%2F2025-12-30%2F1767047840260.webp&w=3840&q=75)
2025/12/27 23:13
### なぜ Rust からアセンブリを呼び出すと、C からの呼び出しよりも遅くなることがあるか 手書きで作成したアセンブリルーチンを **C** から呼び出す場合、コンパイラは次のように最適化できます。 1. **プレーンで保護されていない呼び出しを生成** – 関数には Rust のランタイムオーバーヘッドがありません。 2. **呼び出し元のレジスタを保持** – 呼び出し規約(例:`__cdecl` や `__stdcall`)に従い、保護すべきレジスタと破壊されるレジスタを区別します。 3. **安全性チェックを行わない** – C はデフォルトで「unsafe」とみなすため、追加の検証が発生しません。 一方で同じアセンブリルーチンを **Rust** から呼び出すと、以下のような微妙な差異がオーバーヘッドを招くことがあります。 | 観点 | Rust 呼び出し | C 呼び出し | |------|----------------|------------| | **呼び出し規約** | デフォルトでは `extern "C"` がシステム ABI と一致しますが、`#[no_mangle]` を明示せずに使用すると、コンパイラが名前マングリングやシンボル解決を処理する小さなラッパーを追加することがあります。 | 直接 ABI を使用し、ラッパーは不要です。 | | **ABI の不一致** | Rust の関数シグネチャがアセンブリの期待するパラメータ(呼び出し規約・スタック整列・レジスタ使用)と合わない場合、コンパイラはスタック/整列を調整したり、引数を別順序で渡すコードを挿入します。 | C コンパイラは呼び出しのレイアウトを正確に知っています。 | | **安全性ラッパー** | Rust は `extern "C"` 境界を跨ぐ際に追加の安全チェック(例:NULL ポインタの検査、関数ポインタが NULL でないことの確認)を生成する場合があります。これらは微小ですがマイクロベンチマークでは影響します。 | C にはそのようなチェックはありません。 | | **インライン化と最適化** | アセンブリルーチン自体はインライン化できないため、Rust は通常の呼び出し命令を発行します。周囲の Rust コードが高度に最適化(例:ヘルパー関数の積極的なインライン化)されていると、呼び出しオーバーヘッドが目立ちます。 | C コンパイラは小さなラッパーをインライン化したり、一部チェックを削除してオーバーヘッドを減らすことがあります。 | | **リンカ / シンボル解決** | Rust のリンカーは、`#[link(name = "...")]` で正しく宣言されていない `extern` シンボルに対し追加のリロケーションエントリを生成する場合があり、一部プラットフォームでは実行時に微小な検索コストが発生します。 | C リンカーは通常、余分な間接参照なしでシンボルを直接解決します。 | #### 測定可能な遅延の典型的原因 1. **呼び出し規約の不一致** – 例:Rust が `extern "C"` を使用しているが、アセンブリは `__stdcall`(またはその逆)を期待している。 2. **スタック整列のズレ** – アセンブリルーチンが 16 バイト整列を前提としているのに、Rust の呼び出し側で 8 バイトのまま残っていると、コンパイラはスタック調整コードを挿入します。 3. **名前マングリング / シンボル検索** – `#[no_mangle]` を忘れると追加の名前解決ステップが発生し、別関数が呼び出される可能性があります。 4. **引数渡しの違い** – Rust がレジスタで引数を渡す一方、アセンブリはスタックを期待している(または逆)と、余分な移動命令が生成されます。 #### それらを比較可能にする方法 ```rust #[no_mangle] pub extern "C" fn my_asm_fn(arg1: i32, arg2: i64) -> i32 { // C の呼び出し規約に従ったインラインアセンブリ。 unsafe { asm!("...", in("rdi") arg1, in("rsi") arg2, out("eax") ret); } } ``` - **`extern "C"` を明示的に宣言** して、Rust が C コードと同じ ABI を使用するようにします。 - **`#[no_mangle]` を使う** ことで名前マングリングを防ぎます。 - **引数の順序・型を正確に合わせる** ことでアセンブリ側の期待と一致させます。 - **スタック整列を保証する**(必要なら `#[inline(always)]` を付けたり、手動で整列処理を入れたりします)。 これらが揃えば、Rust の呼び出しオーバーヘッドはほぼ C と同等になり、ベンチマーク上で差異が見えなくなるはずです。もしまだ違いが残る場合は、生成されたアセンブリをプロファイルして、余分な前処理/後処理コードが出力されていないか確認してください。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
著者らは、rav1d の CDEF フィルタが dav1d の同等機能より約 30 % 遅く、総デコード時間の約 0.5 % を占めていることを発見しました。プロファイリングによりボトルネックは単一の
ld1 命令にあり、期待される 10 サンプルではなく 441 サンプルがロードされていたことが判明しました。この問題は、生のポインタと入れ子になった FFISafe<WithOffset> ラッパーをアセンブリ関数へ渡すことで発生し、コンパイラがスタックレイアウトを最適化できず、必要以上に約 144 バイト多くのスタック割り当てが行われました。
未使用の
FFISafe 引数( _dst、 _top、 _bottom)を削除し、cdef_filter_block_c_erased をスタブ化することで、その関数のサンプルカウントは 1,562 から約 1,260 に減少しました。 WithOffset<T> に #[repr(C)] を追加し、シグネチャを入れ子ラッパーではなく WithOffset<*const FFISafe<...>> を使用するよう再構成すると、コンパイラは冗長なスタックスロットを排除できました。 cargo‑asm の比較で rav1d_cdef_brow における割り当てが減少し、プロファイルでは dav1d と比べた場合の性能差が約 5 % に留まることが報告されました。
これらの変更後も純粋な Rust のフォールバックは正しく機能し続け、リファクタリングにより ABI‑安全ラッパーの不一致が低レベル Rust コードでスタックレイアウトの非効率性とキャッシュストールを引き起こすことが示されました。
本文
1 %高速化を目指した rav1d のフォローアップ
前回は、Rust 実装の rav1d と C ベースラインの dav1d を比較し、Rust 側が遅いと判明した関数を特定しました。
本日はその結果に対する「小さな返済」を行います。両プロジェクトは手書きアセンブリ関数を共有しているため、その関数を基準に実装差分を追跡します――理想的には完全一致です。実際、ほぼ全てが一致しました。
概要
本日は「なぜ」三つの疑問に答えます。
| なぜ | 説明 |
|---|---|
| 1. 特定アセンブリ関数が Rust 側で遅い | Rust でデータをロードする手順が遅く、 の asm ビューで確認できます。 |
| 2. ロードが遅い理由 | LLVM IR を調べると、Rust はスタック上に余分なデータを保存しています。 |
| 3. なぜ余分なスタック? | コンパイラは関数ポインタを通じた特定の Rust 抽象化を除去できません! |
対策としてコンパイラフレンドリーな実装へ切り替え(PR)しました。
備考
すべてのベンチマークは MacBook 上で行っています。ツールが限定されるため推測も入ります。詳細を知っている方はコメントしてください――macOS のプロファイリング記事を書いていただければ幸いです 🍎💨。
ベンチマークの再実行
# rav1d を特定コミットに戻してビルド ./rav1d $ git checkout cfd3f59 && cargo build --release # dav1d のプロファイルを取得 ./rav1d $ sudo samply record ./target/release/dav1d -q -i Chimera-AV1-8bit-1920x1080-6736kbps.ivf -o /dev/null --threads 1
samply の「逆呼び出しスタック」ビューに切り替え、cdef_ 関数をフィルタリングして neon サフィックスを比較します。左側が dav1d(C)、右側が rav1d(Rust)です。ほとんどの関数は約10 %以内で一致していますが、cdef_filter4_pri_edged_8bpc_neon は30 %遅くなっています(350サンプル ≈ 0.35 s、総実行時間の約0.5 %)。
命令を調べる
samply の asm ビューで差異は単一命令に集約されます。
ld1 {v0.s}[2], [x13] ; 10サンプル (C) vs 441サンプル (Rust)
ld1 は32ビット要素を SIMD レジスタ v0 のレーン 2 にロードします。周辺コードは以下の通りです。
add x12, x2, #0x8 add x13, x2, #0x10 add x14, x2, #0x18 ld1 {v0.s}[0], [x2] ld1 {v0.s}[1], [x12] ld1 {v0.s}[2], [x13] ; ← 遅い ld1 {v0.s}[3], [x14]
x2 は関数に渡されたポインタ tmp を保持しています。
unsafe extern "C" fn filter( dst: *mut DynPixel, // x0 dst_stride: ptrdiff_t, // x1 tmp: *const MaybeUninit<u16>,// x2 … ) -> ()
このアセンブリ関数は
cdef_filter_neon_erased から呼び出され、未初期化の u16 バッファ tmp_buf をスタックに確保し、asm パディングルーチンで埋めます。なぜ連続したバッファからロードする方が遅いのでしょうか?
「未使用引数を削除してみた」修正
関数シグネチャには三つの未使用引数があります。
unsafe extern "C" fn cdef_filter_neon_erased<BD: BitDepth, ..>( dst: *mut DynPixel, stride: ptrdiff_t, left: *const [LeftPixelRow2px<DynPixel>; 8], top: *const DynPixel, bottom: *const DynPixel, …, _dst: *const FFISafe<Rav1dPictureDataComponentOffset>, _top: *const FFISafe<CdefTop>, _bottom: *const FFISafe<CdefBottom>, )
これらを削除(純粋 Rust のフォールバックはスタブで置き換え)すると、
cdef_filter4_pri_edged_8bpc_neon は 1 562 から 1 268 サンプルに減少します(dav1d の 1 199 に5 %以内)。さらに ld1 命令の実行時間も dav1d と同等になります。
背後で何が起きたか?
cargo asm を使ってスタック確保を確認すると、ベースライン版は高速化版より約144バイト多く確保しています。
baseline: alloca [16 x i8] ×4 alloca [24 x i8] ×2 … faster : alloca [16 x i8] alloca [24 x i8]
余分な割り当ては、削除した
dst、top、bottom の複数インスタンスに対応します。
なぜ未使用引数を削除すると改善するのか?
WithOffset
の役割
WithOffset#[derive(Clone, Copy)] pub struct WithOffset<T> { pub data: T, pub offset: usize, }
64bit 系ではサイズは
size_of::<T>() + 8 です。FFI に安全でないため、Rust は
FFISafe でラップします。
let top_ptr: *mut DynPixel = top.as_ptr::<BD>().cast(); ... let top = FFISafe::new(&top);
コンパイラはラップされた値への生ポインタしか見えず、最適化が制限されます。未使用引数を削除したことでスタックレイアウトが変わり、問題の
ld1 が停止しなくなりました。
WithOffset
を FFI‑safe にする
WithOffset#[repr(C)] を付けるとメモリレイアウトが予測可能になります。
#[derive(Clone, Copy)] #[repr(C)] pub struct WithOffset<T> { pub data: T, pub offset: usize, }
これにより、FFI 境界で「分解済み」バージョンを渡せます。
top: WithOffset<&'a u8>, // *const FFISafe<WithOffset<&'a u8>> の代わり
関数内ではラッパーを再構築します。
let top = WithOffset { data: FFISafe::new(&top.data), offset: top.offset, };
これにより余計な間接参照がなくなり、コンパイラは各引数が一度だけ使用されることを把握でき、スタック負荷が軽減します。
最終結果
repr(C) の変更と未使用引数の削除後:
サンプル数: 1 260(dav1d から5 %以内)cdef_filter4_pri_edged_8bpc_neon- 全ての
ロード命令が dav1d のパフォーマンスと一致ld1 - 純 Rust フォールバックも正しく動作
テイクアウェイ
- 未使用パラメータはスタック使用量を増やし、性能に影響します。
のようなラッパーは間接参照を導入し、最適化を妨げます。FFISafe- 構造体を
にして FFI 境界で直接渡すと、コンパイラの最適化が再び働き、実行速度が向上します。#[repr(C)]
ぜひ変更点を試してみてください。macOS のプロファイリングに関する記事を書いていただけると嬉しいです!