Rust、パフォーマンス考えるとダリィの解決策
Rustを選ぶ人の目的はその安全性とパフォーマンスを目的にしているはず...
でもRust。書いてみると所有権や参照制約で頭がががが。
ワーキングメモリを食い果たし、安全性はまだしも、パフォーマンスについて考える際は脳死状態になります。
「この実装って速度的に大丈夫かなぁ、メモリ消費はどうかなぁ、わかんないなぁ。AIに相談...」
とね。
で、ここでは脳死を解決するため、Rustのパフォーマンスについての考えを抽象化指定校思う。
Rust大事な基本
Rustがメモリ安全なのは?
→ 所有権、参照システムでメモリ操作へゴリゴリに制約をかけるからーー!!
Rustが高速なのは?
→ ゼロコスト抽象化などで高速化するからー!!
基本を振り返って
Rustが高速なのは構文、所有権、参照などのシステムが素晴らしいのもあるが、一番の理由はコンパイラの最適化である。
これを考えると、プログラマが高レイヤーな Rust のコードについてパフォーマンスを考えるのは、設計やアルゴリズムなどでは有効かもしれないが些細なこと(ちょっとした処理やデータの管理方法)について考えるのは無駄なのかもしれない。
RustCompiler
ggr>Rustが早い理由
こちらが見つかりました。
- ゼロコスト抽象化
ゼロコスト抽象化とは
辞書定義: 最適化のためにパフォーマンスを犠牲にしない設計
つまり fn
などで抽象化しまくっても生成されたバイナリは、抽象化せずに書いた素晴らしいコードとパフォーマンスに差がないことを目標にしているということ。
コンパイラがしてること
- step1 動的ディスパッチ以外の型をすべて特定
- step2 MIR (Mid‑Level IR) へ変換しライフタイムを解析
- step3 LLVM IR へ変換 (トランスコンパイル)
- step4 LLVM が最適化 & バイナリ生成
LLVM が行う主な最適化
- Monomorphization – ジェネリクスを型ごとに静的展開
- Function Inlining – 関数呼び出しをその場に展開
- Dead Code Elimination – 未使用コードを除去
- Loop Unrolling / Fusion – ループ展開・統合
- Constant Propagation – 定数の事前評価
- Branch Elimination – 決定済み分岐の削除
- Scalar Replacement of Aggregates – 構造体をスカラー化
- Alias / Escape Analysis – ポインタ別名解析
- Stack Promotion / Heap Elision – ヒープ割当をスタックへ昇格
- Dead Function Elimination – 使われない関数を除去
- Trait Object Optimization – 不要な vtable の削除
-
Niche Optimization –
Option<T>
などの省メモリ化 - TBAA – 型情報に基づくキャッシュ効率向上
などがあります。
以下例です。
Monomorphization
fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T { a + b }
let _ = add::<u32>(1, 2); // u32 用に展開
let _ = add::<f64>(1.0, 2.0); // f64 用に展開
Function Inlining
fn square(x: i32) -> i32 { x * x }
let y = square(8); // 呼び出しコストが消える
Constant Propagation / Folding
const N: usize = 4 * 4; // 16 がバイナリに直接埋め込まれる 普通の計算などでももちろん
let arr = [0u8; N];
Dead Code Elimination
fn unused() { println!("never"); } // 呼ばれないので消える
Loop Unrolling
for i in 0..4 { sum += a[i]; }
// → コンパイル後は手展開された 4 回の加算だけになる
Stack Promotion (Heap Elision)
let boxed = Box::new([0u8; 16]); // 実際にはスタック上に置かれることも
Niche Optimization
let opt: Option<&u8> = None; // 参照型の 0 値を再利用し追加メモリ 0
#あんまりかんがえなくていい😀
LLVM の最適化は凄まじいです。
下手に効率的なコードを自ら考えると遅くなること、結構あります。
私達がすべきことは 標準的で読みやすいコード。つまり最適化しやすいコードを書くことではないか?
無駄な抵抗(いくつか)
例1: Vec や String の代わりに自前の動的配列を定義する
「標準ライブラリの Vec ってなんか重そうだから、もっと軽量な配列構造を自作しよう!」
→ 絶対やめとけ。
標準の Vec や String は LLVM との相性まで考えてチューニングされている。
自作構造は安全性も最適化も劣ることが多い。
// 標準 Vec を使う
let mut v: Vec<u8> = Vec::with_capacity(1024);
for i in 0..1024 {
v.push(i as u8);
}
例2: 無理に共有参照を引き回すよりシンプルに所有権を渡す
「clone
が発生するのが嫌だから、全部 &
で共有しちゃえ!」
→ clone
は“常に悪”ではない。
- **安価な型(
usize
,&str
, 小さなCopy
型)**ならclone
は実質ゼロコスト。 - 大きな
String
などでも、「あとでライフタイムで苦しむくらいなら一度 clone してしまう」方が保守も最適化も楽になる場合がある。 - もしくはそういうことをするためのArc<T>を使いましょう。
共有参照を無理に保持する例(ライフタイムが絡む)
fn collect_refs(src: &[String]) -> Vec<&str> {
src.iter().map(String::as_str).collect()
}
所有権をムーブする or 必要に応じて clone する例
// src をもう使わないならムーブ
fn append_move(mut src: Vec<String>, dst: &mut Vec<String>) {
dst.append(&mut src); // 再アロケーションも起きにくい
}
// src を後で使うなら clone(コスト許容)
fn append_clone(src: &[String], dst: &mut Vec<String>) {
dst.extend(src.iter().cloned());
}
例3: 行数を減らせば速くなると思っている
「短いコード = 高速だよね!よし、全部ワンライナーにしよう!」
→ 行数とパフォーマンスは無関係。
むしろ可読性が落ちて最適化しにくくなることがある。
意味のある抵抗(本当に効く改善策)
1. データ構造の選定
-
順序付きアクセス:
Vec
,VecDeque
-
高速キー検索 (大量):
HashMap
,HashSet
-
キー検索 (少量):
Vec<(K, V)>
-
範囲検索 / ソート:
BTreeMap
,BTreeSet
2. アロケーションの抑制
// 事前に容量を確保して再アロケーションを防ぐ
let mut v = Vec::with_capacity(10_000);
for i in 0..10_000 {
v.push(i);
}
-
.with_capacity()
で容量確保 -
Vec::clear()
+ 再利用 -
SmallVec
,ArrayVec
などスタックバッファの活用
3. キャッシュ効率を意識したデータ配置
// Struct of Arrays (SoA)
struct Particles {
positions: Vec<[f32; 3]>,
velocities: Vec<[f32; 3]>,
}
- AoS より SoA が有利なケースが多い
- 隣接データを連続配置してキャッシュヒット率を上げる
まとめ
- LLVM の最適化を信頼し、標準ライブラリと所有権モデルを素直に使う。
-
clone
はコストと可読性のトレードオフ。必要なら恐れず使い、無理に共有参照を引き回さない。 - 改善ポイントは データ構造選択・アロケーション抑制・キャッシュ効率 の 3 本柱。
- 計測 (
cargo bench
,flamegraph
) → ボトルネック特定 → 局所最適化、が王道。
Discussion