Rustにおける動的ディスパッチ vs 静的ディスパッチのパフォーマンス比較
Rustにおける動的ディスパッチ vs 静的ディスパッチのパフォーマンス比較
はじめに
Rustを学んでいると、必ずと言っていいほど出会うのが「トレイトオブジェクト」と「ジェネリクス」です。これらは同じ多態性を実現する方法ですが、実装方法が大きく異なります。
-
トレイトオブジェクト (
Box<dyn Trait>
): 動的ディスパッチ -
ジェネリクス (
<T: Trait>
): 静的ディスパッチ
この記事では、実際のベンチマークコードを作成して、両者のパフォーマンス差を測定し、なぜこの差が生まれるのかを技術的に解説します。
動的ディスパッチと静的ディスパッチの基本
動的ディスパッチ(Dynamic Dispatch)
動的ディスパッチは、実行時にどのメソッドを呼び出すかを決定します。RustではBox<dyn Trait>
や&dyn Trait
を使用して実現されます。
trait Calculator {
fn calculate(&self, x: f64, y: f64) -> f64;
}
// 動的ディスパッチの例
fn dynamic_dispatch(calculators: &[Box<dyn Calculator>], x: f64, y: f64) -> f64 {
let mut result = 0.0;
for calculator in calculators {
result += calculator.calculate(x, y); // 実行時にメソッドを決定
}
result
}
静的ディスパッチ(Static Dispatch)
静的ディスパッチは、コンパイル時にどのメソッドを呼び出すかを決定します。Rustではジェネリクスを使用して実現されます。
// 静的ディスパッチの例
fn static_dispatch<T: Calculator>(calculator: &T, x: f64, y: f64) -> f64 {
calculator.calculate(x, y) // コンパイル時にメソッドを決定
}
技術的な違い
動的ディスパッチの仕組み
動的ディスパッチでは、仮想関数テーブル(vtable) を使用します:
- トレイトオブジェクトは、データポインタとvtableポインタの2つのポインタを持ちます
- vtableには、各メソッドの実装へのポインタが格納されています
- メソッド呼び出し時は、vtableを介して間接的に実装を呼び出します
トレイトオブジェクトの構造:
[データポインタ][vtableポインタ]
↓
vtable:
[calculate関数へのポインタ]
[その他のメソッドへのポインタ]
メモリレイアウトの詳細
動的ディスパッチ(トレイトオブジェクト):
メモリアドレス: 0x1000 0x1008 0x1010 0x1018
トレイトオブジェクト: [データptr] [vtable ptr]
↓
vtable (0x2000):
0x2000: [drop関数ptr]
0x2008: [size関数ptr]
0x2010: [align関数ptr]
0x2018: [calculate関数ptr] ← ここを参照
↓
実際の関数 (0x3000):
0x3000: AddCalculator::calculate
静的ディスパッチ(ジェネリクス):
メモリアドレス: 0x4000 0x4004 0x4008 0x400c
コード領域: [関数1] [関数2] [関数3] [関数4]
↓ ↓ ↓ ↓
直接呼び出し 直接呼び出し 直接呼び出し 直接呼び出し
パフォーマンスへの影響:
- キャッシュ効率: 静的ディスパッチでは連続したメモリアクセス
- TLB効率: 静的ディスパッチではページフォルトが少ない
- 分岐予測: 静的ディスパッチでは予測可能な実行パス
静的ディスパッチの仕組み
静的ディスパッチでは、コンパイラが各型に対して専用のコードを生成します:
- コンパイル時に型が決定されるため、直接的な関数呼び出しが可能
- コンパイラの最適化(インライン化など)が適用される
- CPUキャッシュの効率が良い
ベンチマークの実装
実際にパフォーマンスを測定するためのコードを作成しました:
// トレイト定義
trait Calculator {
fn calculate(&self, x: f64, y: f64) -> f64;
}
// 具体的な実装
struct AddCalculator;
struct SubtractCalculator;
struct MultiplyCalculator;
struct DivideCalculator;
impl Calculator for AddCalculator {
fn calculate(&self, x: f64, y: f64) -> f64 {
x + y
}
}
// 動的ディスパッチ
fn dynamic_dispatch(calculators: &[Box<dyn Calculator>], x: f64, y: f64) -> f64 {
let mut result = 0.0;
for calculator in calculators {
result += calculator.calculate(x, y);
}
result
}
// 静的ディスパッチ
fn static_dispatch<T: Calculator>(calculator: &T, x: f64, y: f64) -> f64 {
calculator.calculate(x, y)
}
アセンブリコードの生成方法
実際のアセンブリコードを確認したい場合は、以下のコマンドを使用できます:
Rustアセンブリの出力
# リリースビルドでアセンブリを生成
cargo rustc --release --lib -- --emit=asm -C "llvm-args=-x86-asm-syntax=intel"
# ARM64アーキテクチャの場合(macOS M1/M2)
cargo rustc --release --lib -- --emit=asm
# 生成されたアセンブリファイルの場所
# target/release/deps/dispatch_performance-*.s
その他のアセンブリ出力オプション
# LLVM IRを出力(より詳細な中間表現)
cargo rustc --release --lib -- --emit=llvm-ir
# 最適化前のアセンブリを出力
cargo rustc --release --lib -- --emit=asm -C "opt-level=0"
# 特定の関数のみのアセンブリを確認
cargo rustc --release --lib -- --emit=asm | grep -A 20 "function_name"
アセンブリの読み方
生成されたアセンブリファイルでは、関数名がマングリングされています:
; 元の関数名: dynamic_dispatch
__ZN20dispatch_performance16dynamic_dispatch17h4073b92f01d742dfE:
; 元の関数名: AddCalculator::calculate
__ZN88_$LT$dispatch_performance..AddCalculator$u20$as$u20$dispatch_performance..Calculator$GT$9calculate17ha7261ef0f213e609E:
マングリングされた名前を元の関数名に戻すには:
# マングリングされた名前をデマングル
cargo rustc --release --lib -- --emit=asm | rustfilt
# または、cargo install rustfilt でインストール後
rustfilt < target/release/deps/dispatch_performance-*.s
ベンチマーク結果
基本的な測定結果(1000万回実行)
動的ディスパッチ: 290.93ms
静的ディスパッチ: 93.53ms
個別静的ディスパッチ: 106.71ms
パフォーマンス向上率
- 静的ディスパッチ: 動的ディスパッチより 3.11倍高速
- 個別静的ディスパッチ: 動的ディスパッチより 2.73倍高速
Criterionベンチマーク結果(高精度測定)
dynamic_dispatch time: [3.4175 ns 3.4269 ns 3.4364 ns]
static_dispatch_multiple time: [1.4212 ns 1.4235 ns 1.4261 ns]
individual_static_dispatch time: [1.5720 ns 1.5760 ns 1.5798 ns]
個別計算機の性能
single_calculator/add_static time: [668.58 ps 671.69 ps 677.16 ps]
single_calculator/subtract_static time: [671.37 ps 674.71 ps 679.03 ps]
single_calculator/multiply_static time: [671.52 ps 678.22 ps 688.35 ps]
single_calculator/divide_static time: [750.43 ps 751.72 ps 753.13 ps]
アセンブリコードとベンチマーク結果の対応
上記のアセンブリコードを見ると、各計算機の実装が非常にシンプルであることがわかります:
-
加算:
fadd d0, d0, d1
- 1命令 -
減算:
fsub d0, d0, d1
- 1命令 -
乗算:
fmul d0, d0, d1
- 1命令 -
除算:
fdiv d0, d0, d1
- 1命令
除算が他の演算より約10%遅いのは、CPUの浮動小数点除算ユニットが他の演算より複雑なためです。
一方、動的ディスパッチでは:
- トレイトオブジェクトからのポインタ取得: 2-3命令
- vtableからの関数ポインタ取得: 1-2命令
- 間接呼び出し: 1命令
- 合計: 4-6命令
これが約2.4倍のパフォーマンス差を生み出しています。
パフォーマンス差の要因
1. 間接呼び出しのオーバーヘッド
動的ディスパッチでは、vtableを介した間接呼び出しが必要です。実際のアセンブリコードを見てみましょう:
動的ディスパッチの実際のアセンブリ(ARM64)
; 動的ディスパッチ関数の核心部分
__ZN20dispatch_performance16dynamic_dispatch17h4073b92f01d742dfE:
; 関数プロローグ(スタックフレーム設定)
stp d11, d10, [sp, #-64]!
stp d9, d8, [sp, #16]
stp x20, x19, [sp, #32]
stp x29, x30, [sp, #48]
add x29, sp, #48
; ループ開始
LBB7_2:
; トレイトオブジェクトからデータポインタとvtableポインタを取得
ldp x0, x8, [x19], #16 ; x0 = データポインタ, x8 = vtableポインタ
; vtableからcalculate関数のポインタを取得(オフセット24バイト)
ldr x8, [x8, #24] ; x8 = calculate関数のポインタ
; 引数を設定
fmov d0, d9 ; 第1引数 (x)
fmov d1, d8 ; 第2引数 (y)
; 間接呼び出し(これがオーバーヘッドの原因)
blr x8 ; 間接関数呼び出し
; 結果を累積
fadd d10, d10, d0
; ループ継続判定
cmp x19, x20
b.ne LBB7_2
静的ディスパッチの実際のアセンブリ(ARM64)
静的ディスパッチでは、各計算機の実装が直接インライン化されます:
; 加算計算機の実装(完全にインライン化)
__ZN88_$LT$dispatch_performance..AddCalculator$u20$as$u20$dispatch_performance..Calculator$GT$9calculate17ha7261ef0f213e609E:
fadd d0, d0, d1 ; 直接的な浮動小数点加算
ret
; 減算計算機の実装
__ZN93_$LT$dispatch_performance..SubtractCalculator$u20$as$u20$dispatch_performance..Calculator$GT$9calculate17hff2446add8881dd3E:
fsub d0, d0, d1 ; 直接的な浮動小数点減算
ret
; 乗算計算機の実装
__ZN93_$LT$dispatch_performance..MultiplyCalculator$u20$as$u20$dispatch_performance..Calculator$GT$9calculate17hc8ec27e3ec820b50E:
fmul d0, d0, d1 ; 直接的な浮動小数点乗算
ret
; 除算計算機の実装
__ZN91_$LT$dispatch_performance..DivideCalculator$u20$as$u20$dispatch_performance..Calculator$GT$9calculate17hbef8be038c6efa60E:
fdiv d0, d0, d1 ; 直接的な浮動小数点除算
ret
パフォーマンス差の詳細分析
動的ディスパッチのオーバーヘッド:
-
メモリアクセス:
ldp x0, x8, [x19], #16
- トレイトオブジェクトからポインタを取得 -
間接参照:
ldr x8, [x8, #24]
- vtableから関数ポインタを取得 -
間接呼び出し:
blr x8
- 関数ポインタを介した呼び出し - キャッシュミス: vtableへのアクセスがキャッシュラインを跨ぐ可能性
静的ディスパッチの最適化:
- 直接呼び出し: 関数が直接インライン化される
- レジスタ最適化: 引数がレジスタに直接配置される
- キャッシュ効率: コードが連続したメモリ領域に配置される
- 分岐予測: 条件分岐が少なく、CPUの分岐予測が効率的に動作
2. コンパイラ最適化
静的ディスパッチでは、コンパイラが以下の最適化を適用できます:
- インライン化: 関数呼び出しを直接コードに埋め込み
- 定数畳み込み: コンパイル時に計算を実行
- ループ最適化: より効率的なループ構造への変換
3. キャッシュ効率
静的ディスパッチでは、コードが連続したメモリ領域に配置されるため、CPUキャッシュの効率が良くなります。
使い分けの指針
静的ディスパッチを選ぶべき場合
-
パフォーマンスが重要な箇所
- ホットパス(頻繁に実行される部分)
- 数値計算やアルゴリズムの核心部分
-
コンパイル時に型が決定できる場合
- 設定ファイルから読み込む型
- 定数として定義される型
-
コードサイズが問題にならない場合
- 静的ディスパッチでは各型に対して専用コードが生成される
動的ディスパッチを選ぶべき場合
-
実行時に型が決定される必要がある場合
- ユーザー入力に基づく型の選択
- プラグインシステム
-
メモリ使用量を最小限にしたい場合
- トレイトオブジェクトのサイズは一定
- 静的ディスパッチでは各型分のコードが生成される
-
異なる型を同じコレクションで管理したい場合
- 異なる実装を同じベクターに格納
実際のプロジェクトでの応用例
例1: ログシステム
// 動的ディスパッチ(推奨)
trait Logger {
fn log(&self, message: &str);
}
struct FileLogger;
struct ConsoleLogger;
struct NetworkLogger;
fn setup_loggers() -> Vec<Box<dyn Logger>> {
vec![
Box::new(FileLogger),
Box::new(ConsoleLogger),
Box::new(NetworkLogger),
]
}
例2: 数値計算ライブラリ
// 静的ディスパッチ(推奨)
trait NumericOperation<T> {
fn add(&self, a: T, b: T) -> T;
fn multiply(&self, a: T, b: T) -> T;
}
struct FloatCalculator;
struct IntegerCalculator;
fn process_data<T, C: NumericOperation<T>>(calculator: &C, data: &[T]) -> T {
// 高速な数値計算
data.iter().fold(calculator.add(T::zero(), T::zero()), |acc, &x| {
calculator.multiply(acc, x)
})
}
まとめ
重要なポイント
-
静的ディスパッチは動的ディスパッチより2-3倍高速
- 間接呼び出しのオーバーヘッドがない
- コンパイラ最適化が適用される
- キャッシュ効率が良い
-
使い分けが重要
- パフォーマンス重視 → 静的ディスパッチ
- 柔軟性重視 → 動的ディスパッチ
-
両方を組み合わせる
- ホットパスは静的ディスパッチ
- 設定可能な部分は動的ディスパッチ
実践的なアドバイス
-
プロファイリングを活用
- 実際のパフォーマンスを測定してから最適化を決定
-
段階的な最適化
- まずは正しく動作するコードを書く
- ボトルネックを特定してから最適化
-
可読性とのバランス
- 過度な最適化は避ける
- チームの理解度を考慮
Rustの強力な型システムとコンパイラの最適化を活用して、適切なディスパッチ方法を選択することで、高性能で保守性の高いコードを書くことができます。
参考資料
Discussion