はじめに
Rust の売り文句の一つ、ゼロコスト抽象化。
「高レベルの抽象化を使っても、手書きのコードと同じ性能が出る」
...本当?
検証してみました。
目次
ゼロコスト抽象化とは
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以上) -
None→usize::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 Trait や Rc にコストがあるのは、それ自体が必要な機能だから。
- 動的ディスパッチが必要なら、vtable は避けられない
- 共有所有権が必要なら、参照カウントは避けられない
ベストプラクティス
- 迷ったらジェネリクス - 静的ディスパッチ
- イテレータを恐れない - インライン化される
- Option/Result を使う - ペナルティなし
- dyn は必要なときだけ - コストを意識する
- 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 のゼロコスト抽象化、ほとんど本当でした。安心して抽象化を使いましょう!
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!