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のパフォーマンスに対する回答だ.