| name | perf-loop |
| description | Rust の性能改善を「計測 → 仮説 → 変更 → 再計測」のループで地道に回すためのスキル。プロファイラの選び方、Cargo の最適化設定、データ構造・アロケーション・ハッシュ・分岐などの定石をフェーズ順に適用する。1 回 1 変更 1 計測の規律を厳守する。rsvelte 固有の既知ボトルネック(`serde_json::Value` 駆逐、`bumpalo` 導入、`Atom<'a>`、codegen 直書き)と OXC 対応表、NAPI 検証手順も §7 に収録。「Rust の性能改善」「ボトルネック調査」「プロファイル取って最適化」などの依頼で使用。 |
| argument-hint | [focus area, e.g. parser | hot function name | 'continue'] |
| allowed-tools | Read, Grep, Glob, Bash, Edit, Write, Agent, WebSearch, WebFetch |
| effort | max |
Rust Performance Loop — Measure → Hypothesize → Change → Measure
0. 大原則(このスキルの背骨)
性能改善は 推測ではなく観測 で進める。以下のルールを毎ループ守る。
- 計測なき最適化は禁止。 着手前に必ずベースラインを取る。「速くなったはず」を許さない。
- 1 変更 = 1 計測。 複数の最適化を同時にコミットしない(効いたかどうかが分からなくなる)。
- 効かなかったら revert。 「ちょっと速くなったかも」を残さない。ノイズと改善を混同しない。
- ホットでない場所は触らない。 プロファイルで上位 5 関数に入らないコードは原則対象外。
- 正しさは速さに優先する。 各イテレーションの後に
cargo test --release を必ず通す。
- シンプルに保つ。 複雑性を上塗りせず、データ構造・アルゴリズム自体をシンプルにする方向で攻める。最適化後のコードが前より読みにくくなったらアプローチを疑う。高速なプログラムは、シンプルなデータ構造に対してシンプルなコードが書かれている。
1. 計測レイヤーの選び方
「どのツールで測るか」を間違えると、何時間も無駄になる。目的に応じて使い分ける。
| 目的 | 推奨ツール | 用途 |
|---|
| エンドツーエンドの実時間 | hyperfine | バイナリ全体の比較。ノイズに対し統計処理してくれる |
| 関数単位の統計的マイクロベンチ | criterion(または divan) | 「この関数だけ X% 速くなった」を信頼区間つきで判定 |
| CPU サンプリングプロファイル | samply(推奨)/cargo flamegraph/perf(Linux)/Instruments(macOS) | ホットな関数・行を炎グラフで把握 |
| アロケーション量 | dhat(dhat-rs) | どこで何回 alloc しているか、ピークメモリ |
| キャッシュ・分岐ミス | perf stat/cachegrind | LLC ミス・branch miss 率の計測 |
| 生成コード(最後の手段) | cargo asm/rustc --emit=llvm-ir | LLVM が本当に最適化したかの確認 |
デフォルトの一手目は samply。perf よりセットアップが軽く、Firefox Profiler の UI で読みやすく、macOS / Linux 両対応。
cargo install samply hyperfine cargo-flamegraph dhat
計測精度を下げる地雷
- debug ビルドで測る → 桁違いに遅い。常に
--release。
- ウォームアップなしの初回実行 → I/O・JIT・ページキャッシュで歪む。
hyperfine --warmup 3 を基本にする。
- 電源管理(省電力モード、サーマルスロットリング) → ノートで長時間測ると周波数が落ちる。電源接続・温度確認。
- 他のプロセスのノイズ → ブラウザや Slack を閉じる。CI 上で比較するなら同じランナーで連続実行。
- テストデータが小さい → ボトルネックが現れない。本番相当のサイズで測る。
- シングルランの差分で判断 → ±5〜10% は常にノイズ。
hyperfine で 10 回以上回して有意差を見る。
2. リリースビルドの土台を作る
まず、プロファイルが読めるリリースビルドにする。これを忘れるとフレームグラフが空っぽになる。
Cargo.toml:
[profile.release]
debug = "line-tables-only"
[profile.profiling]
inherits = "release"
debug = "full"
strip = false
シンボル可読化とフレームポインタ:
RUSTFLAGS="-C force-frame-pointers=yes -C symbol-mangling-version=v0" \
cargo build --profile profiling
ベンチ専用プロファイル(ベースラインを正確に取るため):
[profile.bench]
inherits = "release"
debug = "line-tables-only"
3. The Loop(毎周こう回す)
┌──────────────────────────────────────────────────────────────┐
│ 1. Baseline 計測(hyperfine / criterion で数値を記録) │
│ 2. Profile(samply 等でホットスポット特定) │
│ 3. Hypothesize(「ここをこう変えれば N% 効くはず」を言語化) │
│ 4. Change(1 つだけ変える) │
│ 5. Test(cargo test --release で回帰がないか) │
│ 6. Re-measure(同条件で再計測) │
│ 7. Decide: │
│ 改善あり → コミット → 次のホットスポットへ │
│ 改善なし/劣化 → revert、仮説を記録、別の手を試す │
└──────────────────────────────────────────────────────────────┘
各ループの所要時間は 30 分〜2 時間 が目安。1 日 1 ループしか回せないなら、計測コストが高すぎる(自動化を検討する)。
ループの記録(毎周残すと判断が速くなる)
短い perf log を書き残すと、後で「もう試したか」「なぜ効かなかったか」を辿れる。1 周 1 行で十分:
2026-05-13 parse baseline 142ms → hot: lex_identifier 31%
2026-05-13 parse try: FxHashMap for keyword lookup +0.3% (noise) revert
2026-05-13 parse try: byte-level whitespace skip -8% keep
2026-05-13 parse try: SIMD memchr for `<` -3% keep (small but consistent)
4. 最適化プレイブック(効く順)
上から順に試す。 下に行くほどリターン逓減、複雑性増加。
A. アルゴリズム・データ構造(桁が変わる)
- O(n²) → O(n log n) の置き換えはマイクロ最適化 100 個に勝る。まずビッグオーを疑う。
- 不要な中間表現を削除する。「AST → IR → 文字列」を「AST → 文字列」にできないか。
- そもそも そのコードは要るか? 削除できる計算は最強の最適化。lazy 化・キャッシュも検討。
- ホットケース最適化: 「99% は空 / 1 要素 / 短い文字列」なら fast path を分岐させる。
B. アロケーション削減(典型 2〜10x)
ヒープアロケーションは現代 CPU では非常に高価。プロファイルで malloc/__rust_alloc/drop_in_place が上位に来ていたら本セクション。
- Arena allocator (
bumpalo): AST など寿命を揃えられるノード群に。OXC で約 +20%。
- Borrow over own:
String → &str/Vec<T> → &[T]/引数は &str、戻り値は Cow<'a, str>。
SmallVec/ArrayVec: 「ほぼ常に小さい」コレクションをスタックに乗せる。
compact_str/smartstring: 短い文字列のヒープ確保を回避。
Box<[T]> over Vec<T>: 伸長しない場合は容量を持つ必要がない。
.clone() の駆除: visitor を & で受ける。Rc<T>/Arc<T> で共有。
- 書き込みバッファの事前確保:
String::with_capacity(estimate) で再アロックを防ぐ。
format!() を write!() に: ホットパスでは format! が temporary を作るので避ける。
let s = format!("{}-{}", a, b);
out.push_str(&s);
use std::fmt::Write;
write!(out, "{}-{}", a, b).unwrap();
C. メモリレイアウト(典型 1.1〜1.5x、塵も積もる)
D. ハッシュ(典型 1.2〜2x、ホットなマップで顕著)
- 標準
HashMap は SipHash(暗号強度) で遅い。非暗号用途は FxHashMap(rustc-hash)または ahash。
- キーが小さく数が少ない場合は 線形探索 (
Vec<(K,V)>) のほうが速い こともある。要計測。
- 完全ハッシュ(キーワードテーブル等)は
phf クレートでビルド時生成。
E. ホットループのミクロ最適化(典型 1.05〜1.3x)
プロファイルで本当にホットな関数だけに適用。読みやすさを犠牲にする価値があるか毎回問う。
F. ビルド設定(リターン中、コスト極小)
Cargo.toml:
[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"
debug = "line-tables-only"
CPU 固有命令(配布バイナリ以外):
RUSTFLAGS="-C target-cpu=native" cargo build --release
アロケータ差し替え(malloc が profile 上位なら効く):
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
リンカ (mold/lld) はビルド時間短縮であり実行速度には効かない。ただしループの回転速度が上がる=間接的に効く。
最後の数 % が欲しいなら PGO (Profile-Guided Optimization) と BOLT。cargo-pgo で自動化、典型 +5〜15%。
G. 並列化(シングルスレッドを絞ってから)
- 1 スレッドのプロファイルで「もう絞れない」と判断してから
rayon 等を入れる。
- 並列化は アロケータ競合・false sharing・ロック争奪 を生むため、シングルスレッド最適化を済ませる前にやると測定がぐちゃぐちゃになる。
- グローバル
Mutex は並列化を殺す(OXC が string-cache を削除して +30% 出した教訓)。スレッドローカル → 最後にマージ、を基本に。
5. アンチパターン(過去のハマりどころ)
- LLVM が既にやっていることを手でやる: 数日かけて 1% — 引き合わない。
cargo asm で確認してから。
- マイクロベンチで勝ってマクロで負ける: criterion で +20% でも、ホットでなければ全体は無変化。エンドツーエンドも必ず測る。
- キャッシュやワークアラウンドで複雑性を盛る: 根本のデータ構造を直すほうが速いし読みやすい。
- ノイズを改善と誤認: ±5% は計測ノイズ。
hyperfine の信頼区間で有意差を確認。
- 「速くなったはず」のコミット: 数値なしで残さない。後で誰も判断できない。
- コールド領域の最適化: 起動時 1 回しか走らないコードを 10x しても誰も気付かない。
- ベンチが本番と乖離: 小さい入力・人工データでは現実のボトルネックが出ない。
- 回帰テスト省略: 速くて壊れているコードはバグ。
cargo test --release を毎周。
6. 止めどき
以下のいずれかに該当したら、そのループは閉じて別ホットスポットへ移る:
- プロファイル上位 5 関数を全て触ったが、目標数値の 80% 以上を達成
- 残るホット関数が
<5% の比重しかない(伸びしろが少ない)
- 次の改善案が「コードを著しく読みにくくする」かつ期待 < 5%
- ループを 3 周回しても 1% も動かない(仮説の質を疑う、別フェーズへ)
7. rsvelte 固有のロードマップ
7.1 計測コマンド(即実行)
./scripts/bench/bench.sh --quick
./scripts/bench/bench.sh --profile
./scripts/bench/bench.sh --criterion
cargo build --profile profiling --bin profiler
samply record ./target/profiling/profiler --file path/to/large.svelte --iterations 100
instruments -t "Time Profiler" ./target/profiling/profiler -- --file path/to/large.svelte --iterations 100
cargo test --release
7.2 既知の大物ボトルネック(rsvelte 固有)
perf-loop の §4 プレイブックを rsvelte の実コードに当てると、以下が「まだ手付かずの大物」になる。プロファイルで該当領域が上位に出たらここに戻る。
A. serde_json::Value の駆逐 → typed AST
B. bumpalo アリーナの導入
- 現状:
bumpalo は Cargo.toml に入っているが未使用。AST ノードは個別に Box<T> / Vec<T> でヒープ確保されている
- OXC の流儀:
oxc_allocator::Box<'a, T> / Vec<'a, T> で単一の Bump からまとめて確保 → ポインタ加算で alloc、アリーナ一括解放
- 手順:
- AST に lifetime を導入:
Root<'a>, Fragment<'a>, TemplateNode<'a>, ...
Box<T> → bumpalo::boxed::Box<'a, T>、Vec<T> → bumpalo::collections::Vec<'a, T>
- パーサに allocator を貫通:
Parser<'a> { alloc: &'a Bump }
- 大規模リファクタなので段階的に。まずパーサ層から
- 参考:
~/.cargo/registry/src/*/oxc_allocator-*/src/
struct Element {
name: String,
children: Vec<TemplateNode>,
}
struct Element<'a> {
name: &'a str,
children: Vec<'a, TemplateNode<'a>>,
}
C. Atom<'a> での文字列インターン
- 現状:
CompactString(短い文字列はインラインだが長いものはヒープ)。重複文字列はその都度コピー
- OXC の流儀:
Atom<'a> でソース or アリーナから借用、頻出文字列("div", "class" 等)はポインタ比較
- 手順:
- ソース直結の識別子・タグ名は
&'a str(元ソースへの参照)に変える
- 生成側の文字列はアリーナから確保
- 頻出文字列は簡易インターナを検討
- 参考:
oxc_span::Atom
D. codegen の直接書き出し化
- 現状: 中間
JsNode ツリーを組み、それを文字列にシリアライズする 2 段構え
- OXC の流儀: AST →
String バッファに直接 write!()、インデントはカウンタで管理。中間表現なし
- 手順:
- transform フェーズの出力を
String バッファに直書き
String::with_capacity(estimate) で再 alloc 抑制
- ホットループでは
format! を避け write!() に
- 参考:
oxc_codegen
struct CodeWriter {
buf: String,
indent: u32,
}
impl CodeWriter {
fn write_expression(&mut self, expr: &Expression) {
}
}
E. .clone() 駆除
F. パーサ層
- 方針: 既に JS は OXC 任せ。Svelte テンプレートパーサだけが自前
- バイトレベル走査(
&[u8])/UTF-8 再検証回避(from_utf8_unchecked は要根拠コメント)/文字種テーブルの事前計算/正規表現禁止(OXC も使っていない)
- ソースからの借用を徹底し、パース中の
String alloc を最小化
7.3 OXC クレート → rsvelte の対応
| OXC クレート | 役割 | rsvelte で参照すべき場面 |
|---|
oxc_allocator | アリーナ確保 | §7.2 B の実装時 |
oxc_ast | typed AST | §7.2 A・B の AST 設計 |
oxc_parser | JS/TS パーサ | §7.2 F のパーサ最適化 |
oxc_codegen | コード生成 | §7.2 D の直書き codegen |
oxc_span | Atom<'a>・Span | §7.2 C の文字列インターン |
oxc_syntax | 演算子テーブル等 | キーワード/演算子の高速判定 |
ローカルキャッシュからソースを読む:
ls ~/.cargo/registry/src/*/oxc_allocator-*/src/
ls ~/.cargo/registry/src/*/oxc_parser-*/src/
ls ~/.cargo/registry/src/*/oxc_codegen-*/src/
ls ~/.cargo/registry/src/*/oxc_ast-*/src/
7.4 NAPI 経由のエンドツーエンド検証
cargo build --release --features napi --lib
cp target/release/librsvelte_core.dylib svelte/rsvelte.darwin-arm64.node
cd svelte && USE_RSVELTE=true npx vitest run \
packages/svelte/tests/runtime-runes/test.ts \
packages/svelte/tests/runtime-legacy/test.ts
性能改善で「rsvelte 単体は速いが Vite から呼ぶと遅い」「テストは通るが NAPI 経由で壊れる」を防ぐため、最終確認は NAPI 経由で行う。
8. ワークフロー($ARGUMENTS 指定時の挙動)
ユーザーが /perf-loop $ARGUMENTS を呼んだら:
$ARGUMENTS が continue → 直近の perf log を読み、次のループを開始
$ARGUMENTS が関数名・モジュール名 → そこに焦点を絞ってループ
$ARGUMENTS が空 → 全体を --profile で計測し、上位 5 ホットスポットを提示
- 必ず順序を守る: baseline → profile → 仮説提示(ユーザー確認)→ 変更 → test → 再計測 → keep/revert 判定
- 各イテレーション後に 数値と判定をユーザーに報告。1 イテレーション 1 メッセージを目安に
- 3 周ごとに、達成した数値と次の候補ホットスポットを要約する
9. References