9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

Rust の売り文句の一つ、ゼロコスト抽象化

「高レベルの抽象化を使っても、手書きのコードと同じ性能が出る」

...本当?

検証してみました。

目次

  1. ゼロコスト抽象化とは
  2. イテレータ
  3. トレイトオブジェクト vs ジェネリクス
  4. Option と Result
  5. クロージャ
  6. スマートポインタ
  7. まとめ

ゼロコスト抽象化とは

C++ の設計原則から来た考え方:

You don't pay for what you don't use.
What you do use, you couldn't hand code any better.

使わない機能にはコストがかからない。
使う機能も、手書きより良いコードにはならない。

つまり、抽象化を使ってもパフォーマンスのペナルティがない

イテレータ

for ループ vs iter()

// 手書きのループ
fn sum_manual(v: &[i32]) -> i32 {
    let mut sum = 0;
    for i in 0..v.len() {
        sum += v[i];
    }
    sum
}

// イテレータ
fn sum_iterator(v: &[i32]) -> i32 {
    v.iter().sum()
}

// イテレータ + fold
fn sum_fold(v: &[i32]) -> i32 {
    v.iter().fold(0, |acc, &x| acc + x)
}

ベンチマーク

use std::time::Instant;

fn benchmark<F: Fn(&[i32]) -> i32>(name: &str, f: F, data: &[i32]) {
    let start = Instant::now();
    for _ in 0..1000 {
        std::hint::black_box(f(data));
    }
    let elapsed = start.elapsed();
    println!("{}: {:?}", name, elapsed);
}

fn main() {
    let data: Vec<i32> = (0..100000).collect();
    
    benchmark("manual", sum_manual, &data);
    benchmark("iterator", sum_iterator, &data);
    benchmark("fold", sum_fold, &data);
}

結果(Release ビルド)

manual:    23.4ms
iterator:  23.2ms
fold:      23.3ms

ほぼ同じ! イテレータはゼロコスト。

なぜ同じになるのか

コンパイラがインライン展開ループ最適化を行うから。

cargo rustc --release -- --emit asm で生成されるアセンブリを見ると、3つとも同じ SIMD 命令を使っている。

map + filter + collect

// 手書き
fn manual_transform(v: &[i32]) -> Vec<i32> {
    let mut result = Vec::new();
    for &x in v {
        if x % 2 == 0 {
            result.push(x * 2);
        }
    }
    result
}

// イテレータチェーン
fn iterator_transform(v: &[i32]) -> Vec<i32> {
    v.iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| x * 2)
        .collect()
}

結果

manual:    15.2ms
iterator:  15.1ms

ゼロコスト!

トレイトオブジェクト vs ジェネリクス

静的ディスパッチ(ジェネリクス)

trait Animal {
    fn speak(&self) -> &'static str;
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn speak(&self) -> &'static str { "woof" }
}

impl Animal for Cat {
    fn speak(&self) -> &'static str { "meow" }
}

// 静的ディスパッチ - コンパイル時に解決
fn static_dispatch<T: Animal>(animal: &T) -> &'static str {
    animal.speak()
}

動的ディスパッチ(トレイトオブジェクト)

// 動的ディスパッチ - 実行時に解決
fn dynamic_dispatch(animal: &dyn Animal) -> &'static str {
    animal.speak()
}

ベンチマーク

fn main() {
    let dog = Dog;
    let cat = Cat;
    
    let start = Instant::now();
    for _ in 0..10_000_000 {
        std::hint::black_box(static_dispatch(&dog));
        std::hint::black_box(static_dispatch(&cat));
    }
    println!("static: {:?}", start.elapsed());
    
    let animals: Vec<&dyn Animal> = vec![&dog, &cat];
    let start = Instant::now();
    for _ in 0..10_000_000 {
        for animal in &animals {
            std::hint::black_box(dynamic_dispatch(*animal));
        }
    }
    println!("dynamic: {:?}", start.elapsed());
}

結果

static:  12ms  (ほぼ消える - インライン化)
dynamic: 89ms  (vtable 経由)

動的ディスパッチはコストがある! これはゼロコストではない。

使い分け

方式 コスト 用途
ジェネリクス ゼロ 型が静的に決まる場合
トレイトオブジェクト あり 型が動的に変わる場合

Option と Result

Option

// Option を使う
fn find_with_option(v: &[i32], target: i32) -> Option<usize> {
    for (i, &x) in v.iter().enumerate() {
        if x == target {
            return Some(i);
        }
    }
    None
}

// -1 を返す(C スタイル)
fn find_with_sentinel(v: &[i32], target: i32) -> isize {
    for (i, &x) in v.iter().enumerate() {
        if x == target {
            return i as isize;
        }
    }
    -1
}

アセンブリを比較

どちらも同じアセンブリを生成する。Option<usize> は内部的に:

  • Some(n)n(0以上)
  • Noneusize::MAX として表現される(ニッチ最適化)

ゼロコスト!

Option の内部表現

use std::mem::size_of;

fn main() {
    // 通常の Option
    println!("Option<i32>: {}", size_of::<Option<i32>>());        // 8 bytes
    println!("i32: {}", size_of::<i32>());                         // 4 bytes
    
    // 参照の Option(ニッチ最適化)
    println!("Option<&i32>: {}", size_of::<Option<&i32>>());      // 8 bytes
    println!("&i32: {}", size_of::<&i32>());                       // 8 bytes
    
    // NonZero の Option(ニッチ最適化)
    println!("Option<NonZeroU64>: {}", size_of::<Option<std::num::NonZeroU64>>());  // 8 bytes
}

参照は null にならないので、Option<&T>&T同じサイズ

クロージャ

キャプチャなし

// 関数
fn add_one_fn(x: i32) -> i32 {
    x + 1
}

// クロージャ
let add_one_closure = |x: i32| x + 1;

結果

完全に同じアセンブリ。ゼロコスト!

キャプチャあり

let offset = 10;
let add_offset = |x: i32| x + offset;

offset をキャプチャするので、クロージャは構造体になる:

// コンパイラが生成するもの(イメージ)
struct AddOffset {
    offset: i32,
}

impl Fn<(i32,)> for AddOffset {
    fn call(&self, (x,): (i32,)) -> i32 {
        x + self.offset
    }
}

これもインライン展開されればゼロコスト

dyn Fn vs impl Fn

// 静的ディスパッチ
fn call_static(f: impl Fn(i32) -> i32, x: i32) -> i32 {
    f(x)
}

// 動的ディスパッチ
fn call_dynamic(f: &dyn Fn(i32) -> i32, x: i32) -> i32 {
    f(x)
}

結果

static:  インライン化でほぼ消える
dynamic: vtable 経由で少しオーバーヘッド

スマートポインタ

Box

fn use_box() {
    let boxed = Box::new(42);
    println!("{}", *boxed);
}

fn use_stack() {
    let value = 42;
    println!("{}", value);
}

Boxヒープアロケーションのコストがある。これは避けられない。

でも、アクセス自体は:

let boxed = Box::new(42);
let x = *boxed;  // 1回のメモリ読み取り

ポインタのデリファレンスと同じ。追加のオーバーヘッドなし。

Rc/Arc

let rc = Rc::new(42);
let rc2 = Rc::clone(&rc);  // 参照カウントをインクリメント

参照カウントの操作はゼロコストではない

  • Rc::clone: カウント +1
  • drop: カウント -1、0 なら解放

でも、これは「使った分だけ」のコスト。使わなければコストなし。

まとめ

検証結果

抽象化 ゼロコスト? 備考
イテレータ ✅ Yes インライン展開される
ジェネリクス ✅ Yes モノモーフィゼーション
トレイトオブジェクト ❌ No vtable 経由
Option/Result ✅ Yes ニッチ最適化
クロージャ (FnOnce/Fn) ✅ Yes インライン展開
dyn Fn ❌ No vtable 経由
Box ⚠️ 部分的 アロケーションコストのみ
Rc/Arc ⚠️ 部分的 参照カウントコスト

本当の意味

ゼロコスト抽象化 = 「同じことを手書きしても、これ以上速くならない」

dyn TraitRc にコストがあるのは、それ自体が必要な機能だから

  • 動的ディスパッチが必要なら、vtable は避けられない
  • 共有所有権が必要なら、参照カウントは避けられない

ベストプラクティス

  1. 迷ったらジェネリクス - 静的ディスパッチ
  2. イテレータを恐れない - インライン化される
  3. Option/Result を使う - ペナルティなし
  4. dyn は必要なときだけ - コストを意識する
  5. Release ビルドで測定 - Debug は最適化されない

確認方法

# アセンブリを見る
cargo rustc --release -- --emit asm

# LLVM IR を見る
cargo rustc --release -- --emit llvm-ir

# 最適化レポート
RUSTFLAGS="-C opt-level=3 -C target-cpu=native" cargo build --release

Rust のゼロコスト抽象化、ほとんど本当でした。安心して抽象化を使いましょう!

この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!

9
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?