Rust、パフォーマンス考えるとダリィの解決策

に公開

Rustを選ぶ人の目的はその安全性とパフォーマンスを目的にしているはず...

でもRust。書いてみると所有権や参照制約で頭がががが。

ワーキングメモリを食い果たし、安全性はまだしも、パフォーマンスについて考える際は脳死状態になります。

「この実装って速度的に大丈夫かなぁ、メモリ消費はどうかなぁ、わかんないなぁ。AIに相談...」

とね。

で、ここでは脳死を解決するため、Rustのパフォーマンスについての考えを抽象化指定校思う。


Rust大事な基本

Rustがメモリ安全なのは?
→ 所有権、参照システムでメモリ操作へゴリゴリに制約をかけるからーー!!
Rustが高速なのは?
→ ゼロコスト抽象化などで高速化するからー!!


基本を振り返って

Rustが高速なのは構文、所有権、参照などのシステムが素晴らしいのもあるが、一番の理由はコンパイラの最適化である。

これを考えると、プログラマが高レイヤーな Rust のコードについてパフォーマンスを考えるのは、設計やアルゴリズムなどでは有効かもしれないが些細なこと(ちょっとした処理やデータの管理方法)について考えるのは無駄なのかもしれない。


RustCompiler

ggr>Rustが早い理由
こちらが見つかりました。

  • ゼロコスト抽象化

ゼロコスト抽象化とは

辞書定義: 最適化のためにパフォーマンスを犠牲にしない設計

つまり fn などで抽象化しまくっても生成されたバイナリは、抽象化せずに書いた素晴らしいコードとパフォーマンスに差がないことを目標にしているということ。

コンパイラがしてること

  1. step1 動的ディスパッチ以外の型をすべて特定
  2. step2 MIR (Mid‑Level IR) へ変換しライフタイムを解析
  3. step3 LLVM IR へ変換 (トランスコンパイル)
  4. 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 OptimizationOption<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 が有利なケースが多い
  • 隣接データを連続配置してキャッシュヒット率を上げる

まとめ

  1. LLVM の最適化を信頼し、標準ライブラリと所有権モデルを素直に使う。
  2. clone はコストと可読性のトレードオフ。必要なら恐れず使い、無理に共有参照を引き回さない。
  3. 改善ポイントは データ構造選択・アロケーション抑制・キャッシュ効率 の 3 本柱。
  4. 計測 (cargo bench, flamegraph) → ボトルネック特定 → 局所最適化、が王道。

Discussion