Wonderfull Rust

パフォーマンス

Rust のパフォーマンスの源泉

Rust は「安全性とパフォーマンスの両立」を目指して設計された言語だ.この章では,Rust がなぜ高いパフォーマンスを達成できるのかを,その技術的な根拠とともに解説する.

ゼロコスト抽象化

ゼロコスト抽象化 (Zero-Cost Abstractions) は Rust の設計原則の中核を成す概念であり,C++ の設計者 Bjarne Stroustrup が提唱した原則に由来する:

What you don't use, you don't pay for.(使わないものに対してコストを払わない) And further: What you do use, you couldn't hand code any better.(使うものについては,手書きでこれ以上うまく書くことはできない)

Rust はこの原則を徹底している.高レベルな抽象化(イテレータ,トレイト,ジェネリクスなど)を使っても,手書きの低レベルコードと同等のマシンコードが生成される.

イテレータの例

// 高レベルな書き方
let sum: i32 = (0..1000)
    .filter(|x| x % 2 == 0)
    .map(|x| x * x)
    .sum();

// 手書きの低レベルな書き方
let mut sum: i32 = 0;
let mut i = 0;
while i < 1000 {
    if i % 2 == 0 {
        sum += i * i;
    }
    i += 1;
}

この 2 つは,最適化後に 同一のマシンコードを生成する.イテレータチェーンによる抽象化のオーバーヘッドは,文字通りゼロだ.

これが実現できる理由は後述する 単相化 (Monomorphization) にある.

GC が存在しないという選択

多くのモダンな言語(Java, Go, Python, JavaScript, C#, ...)はガベージコレクション (GC) を採用している.GC はメモリ管理を自動化し,プログラマの負担を軽減するが,パフォーマンスに対して避けられないコストを伴う:

  • Stop-the-world (STW) ポーズ: GC がメモリを回収する際に,アプリケーションの実行が一時停止する.世代別 GC や並行 GC により緩和されるが,完全に排除することはできない
  • メモリオーバーヘッド: GC は効率的に動作するために,実際に必要な量以上のメモリを確保する必要がある.一般的に,GC 付き言語は GC なしの言語と比較して 2〜5 倍のメモリを消費する
  • 予測困難なレイテンシ: GC の動作タイミングは非決定的であり,レイテンシに敏感なアプリケーション(リアルタイムシステム,ゲームエンジン,HFT など)では問題になりうる

Rust は GC を持たない.代わりに 所有権システム によってメモリの解放タイミングをコンパイル時に決定する.値がスコープを抜けるとき,その値のメモリが即座に解放される (RAII).これにより:

  • STW ポーズが存在しない
  • メモリ使用量が予測可能
  • メモリの解放タイミングが決定的

GC を排除しつつもメモリ安全性を保証する — これは Rust の所有権システムの最大の功績だ.

LLVM バックエンド

Rust のコンパイラ (rustc) は,コード生成に LLVM を使用している.LLVM は世界で最も成熟したコンパイラ基盤の一つであり,Clang (C/C++),Swift,Julia など多くの言語がバックエンドとして採用している.

LLVM が行う最適化の例:

  • 関数のインライン化: 関数呼び出しのオーバーヘッドを排除
  • ループの展開 (Loop Unrolling): ループのイテレーション回数が少ない場合,ループを展開して分岐を排除
  • 定数伝播 (Constant Propagation): コンパイル時に決定できる値を事前に計算
  • デッドコード除去 (Dead Code Elimination): 実行されないコードを削除
  • 自動ベクトル化 (Auto-Vectorization): SIMD 命令を使って並列処理を行うコードを自動生成
  • エイリアス解析 (Alias Analysis): ポインタの指す先を解析し,最適化の機会を発見

Rust はコンパイル時に豊富な情報(所有権,ライフタイム,エイリアシングルール)を LLVM に提供できるため,C/C++ と同等もしくはそれ以上の最適化が可能になる場面もある.

特に重要なのは &mut の排他性保証 だ.Rust では,ある値への可変参照 (&mut T) が存在する間,その値への他のいかなる参照も存在しないことがコンパイラによって保証される.これは C の restrict ポインタ修飾子と同等のセマンティクスを提供するが,C では restrict はプログラマの自己申告にすぎず,実際に守られている保証はない.Rust では借用チェッカーがこれを静的に検証する.

この保証により,LLVM はより積極的なエイリアス解析と最適化を行うことができる.

単相化 (Monomorphization)

Rust のジェネリクスは 単相化 というメカニズムで実装されている.これは,ジェネリックな関数や型が実際に使われる具体的な型ごとに,コンパイル時に個別のコードが生成されることを意味する.

fn max<T: PartialOrd>(a: T, b: T) -> T {
    if a >= b { a } else { b }
}

fn main() {
    max(1_i32, 2_i32);       // max::<i32> が生成される
    max(1.0_f64, 2.0_f64);   // max::<f64> が生成される
    max("a", "b");            // max::<&str> が生成される
}

コンパイラは max::<i32>, max::<f64>, max::<&str> という 3 つの独立した関数を生成する.各関数は具体的な型に対して完全に最適化されるため,ジェネリクスを使用することによる実行時のオーバーヘッドはない.

これは Java のジェネリクス(型消去によるランタイムキャスト)や Go のジェネリクス(GC Shape Stenciling による部分的な単相化)とは根本的に異なるアプローチだ.

単相化のトレードオフとして,コンパイル時間とバイナリサイズが増加する.しかし,実行時パフォーマンスに対するコストはゼロだ.

静的ディスパッチ vs 動的ディスパッチ

Rust のトレイトは,静的ディスパッチと動的ディスパッチの両方をサポートする:

trait Drawable {
    fn draw(&self);
}

// 静的ディスパッチ: コンパイル時に具体的な型が決まる
fn draw_static(item: &impl Drawable) {
    item.draw();
}

// 動的ディスパッチ: 実行時に vtable を通じてメソッドを呼び出す
fn draw_dynamic(item: &dyn Drawable) {
    item.draw();
}

impl Trait による静的ディスパッチでは,単相化によりコンパイル時に具体的な関数呼び出しが確定するため,仮想関数テーブル (vtable) のオーバーヘッドがない.dyn Trait による動的ディスパッチでは,vtable を通じた間接呼び出しが行われるが,これは C++ の仮想関数呼び出しと同じコストだ.

重要なのは,プログラマがどちらを使うかを明示的に選択できる 点だ.Java や C# では仮想メソッド呼び出しがデフォルトであり,JIT コンパイラによる脱仮想化 (devirtualization) に最適化を委ねるしかない.Rust では,パフォーマンスが重要な場面で静的ディスパッチを選択し,柔軟性が必要な場面で動的ディスパッチを選択するという判断をプログラマの手に委ねている.

メモリレイアウトの制御

Rust では,データのメモリレイアウトをプログラマが制御できる:

// フィールドの並び順はコンパイラが最適化する(パディング最小化)
struct Optimized {
    a: u8,
    b: u64,
    c: u8,
}

// C 互換のレイアウトを強制する
#[repr(C)]
struct CCompatible {
    a: u8,
    b: u64,
    c: u8,
}

// 特定のアライメントを指定する
#[repr(align(64))]
struct CacheAligned {
    data: [u8; 64],
}

デフォルトでは,Rust コンパイラはフィールドの並びを自由に最適化してパディングを最小化できる.#[repr(C)] を指定すると C 言語互換のレイアウトが強制され,FFI で安全に使用できる.#[repr(align(N))] でアライメントを制御すれば,キャッシュライン境界に合わせた配置も可能だ.

コンパイル時計算

Rust の const fn は,関数をコンパイル時に評価可能にする:

const fn factorial(n: u64) -> u64 {
    match n {
        0 | 1 => 1,
        _ => n * factorial(n - 1),
    }
}

// コンパイル時に計算される
const FACT_10: u64 = factorial(10);

fn main() {
    // FACT_10 は実行時にはただの定数
    println!("{}", FACT_10); // 3628800
}

const fn で定義された関数は,const 文脈(定数定義,配列長の指定など)で呼び出された場合にコンパイル時に評価される.実行時コストがゼロだ.

Rust の const fn はバージョンを重ねるごとに使える機能が拡張されており,ループ,条件分岐,参照の取得など,多くの操作がコンパイル時に実行可能になっている.

まとめ

Rust のパフォーマンスは,単一の技術的要因によるものではなく,複数の設計選択の積み重ねによって実現されている:

要因 効果
GC がない 予測可能なレイテンシ,低メモリ使用量
所有権による RAII 決定的なリソース解放
単相化 ジェネリクスのゼロコスト
LLVM バックエンド 最先端の最適化
&mut の排他性 積極的なエイリアス最適化
静的/動的ディスパッチの選択 プログラマによるコスト制御
メモリレイアウト制御 キャッシュ効率の最適化
const fn コンパイル時計算

これらが組み合わさることで,Rust は高レベルな抽象化を使いながらも C/C++ と同等の実行性能を達成する.安全性のために速度を犠牲にする必要はない — これが Rust のパフォーマンスに対する回答だ.

関連コンテンツ

本に戻る