🦀

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) を使用します:

  1. トレイトオブジェクトは、データポインタとvtableポインタの2つのポインタを持ちます
  2. vtableには、各メソッドの実装へのポインタが格納されています
  3. メソッド呼び出し時は、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]
                   ↓         ↓         ↓         ↓
               直接呼び出し  直接呼び出し  直接呼び出し  直接呼び出し

パフォーマンスへの影響:

  1. キャッシュ効率: 静的ディスパッチでは連続したメモリアクセス
  2. TLB効率: 静的ディスパッチではページフォルトが少ない
  3. 分岐予測: 静的ディスパッチでは予測可能な実行パス

静的ディスパッチの仕組み

静的ディスパッチでは、コンパイラが各型に対して専用のコードを生成します:

  1. コンパイル時に型が決定されるため、直接的な関数呼び出しが可能
  2. コンパイラの最適化(インライン化など)が適用される
  3. 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

パフォーマンス差の詳細分析

動的ディスパッチのオーバーヘッド:

  1. メモリアクセス: ldp x0, x8, [x19], #16 - トレイトオブジェクトからポインタを取得
  2. 間接参照: ldr x8, [x8, #24] - vtableから関数ポインタを取得
  3. 間接呼び出し: blr x8 - 関数ポインタを介した呼び出し
  4. キャッシュミス: vtableへのアクセスがキャッシュラインを跨ぐ可能性

静的ディスパッチの最適化:

  1. 直接呼び出し: 関数が直接インライン化される
  2. レジスタ最適化: 引数がレジスタに直接配置される
  3. キャッシュ効率: コードが連続したメモリ領域に配置される
  4. 分岐予測: 条件分岐が少なく、CPUの分岐予測が効率的に動作

2. コンパイラ最適化

静的ディスパッチでは、コンパイラが以下の最適化を適用できます:

  • インライン化: 関数呼び出しを直接コードに埋め込み
  • 定数畳み込み: コンパイル時に計算を実行
  • ループ最適化: より効率的なループ構造への変換

3. キャッシュ効率

静的ディスパッチでは、コードが連続したメモリ領域に配置されるため、CPUキャッシュの効率が良くなります。

使い分けの指針

静的ディスパッチを選ぶべき場合

  1. パフォーマンスが重要な箇所

    • ホットパス(頻繁に実行される部分)
    • 数値計算やアルゴリズムの核心部分
  2. コンパイル時に型が決定できる場合

    • 設定ファイルから読み込む型
    • 定数として定義される型
  3. コードサイズが問題にならない場合

    • 静的ディスパッチでは各型に対して専用コードが生成される

動的ディスパッチを選ぶべき場合

  1. 実行時に型が決定される必要がある場合

    • ユーザー入力に基づく型の選択
    • プラグインシステム
  2. メモリ使用量を最小限にしたい場合

    • トレイトオブジェクトのサイズは一定
    • 静的ディスパッチでは各型分のコードが生成される
  3. 異なる型を同じコレクションで管理したい場合

    • 異なる実装を同じベクターに格納

実際のプロジェクトでの応用例

例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)
    })
}

まとめ

重要なポイント

  1. 静的ディスパッチは動的ディスパッチより2-3倍高速

    • 間接呼び出しのオーバーヘッドがない
    • コンパイラ最適化が適用される
    • キャッシュ効率が良い
  2. 使い分けが重要

    • パフォーマンス重視 → 静的ディスパッチ
    • 柔軟性重視 → 動的ディスパッチ
  3. 両方を組み合わせる

    • ホットパスは静的ディスパッチ
    • 設定可能な部分は動的ディスパッチ

実践的なアドバイス

  1. プロファイリングを活用

    • 実際のパフォーマンスを測定してから最適化を決定
  2. 段階的な最適化

    • まずは正しく動作するコードを書く
    • ボトルネックを特定してから最適化
  3. 可読性とのバランス

    • 過度な最適化は避ける
    • チームの理解度を考慮

Rustの強力な型システムとコンパイラの最適化を活用して、適切なディスパッチ方法を選択することで、高性能で保守性の高いコードを書くことができます。


参考資料

コラボスタイル Developers

Discussion