レッスン0 — Reth のためのパフォーマンスエンジニアリング
問い
Reth フォークを本番投入。ベンチではブロック取り込み 12ms だったのに、本番では 80ms。残り 68ms はどこに消えた? — 答えられない。誰もプロファイルを取っていなかったから。これこそが「見えない遅さ」— 気づかぬ間に積み重なり、バリデータが 200 ブロック遅れるまで誰も気づかない。測定なしの最適化はどう間違うか?
原理(最小モデル)
- 測ってから直す. flamegraph(「全体としてどこで時間を使っているか?」)と Criterion(「特定の変更で関数 X が速くなったか?」)の 2 ツールで両側面をカバー。
- マイクロベンチはシステム効果を測れない. Criterion で 20% 改善 → ノード全体が 20% 改善する保証はない。ノード全体は schelk(ブロックデバイススナップショット)でディスク状態をロールバックして実測する。
- キャッシュラインで考える. RAM 読み出しはレジスタ演算の ~100 倍遅い。Struct of Arrays > Array of Structs、ホット / コールド分離、64 バイトキャッシュライン単位。
- jemalloc は p99 を救う. スループットではなくテイルレイテンシ。1 行で 10-30% 改善するワークロードが珍しくない。
- 3 つのリリースプロファイル. release(日常)/ maxperf(バリデータ・ベンチ)/ maxperf-symbols(本番プロファイリング)。
具体例
flamegraph を 30 秒で:
cargo install flamegraph
cargo flamegraph --bin reth -- node --chain mainnet
# flamegraph.svg をブラウザで開く
Criterion でマイクロベンチ:
// Cargo.toml
// [dev-dependencies]
// criterion = "0.5"
// benches/my_bench.rs
use criterion::{criterion_group, criterion_main, Criterion};
fn bench_my_thing(c: &mut Criterion) {
c.bench_function("hash 1KB", |b| {
let data = vec![0u8; 1024];
b.iter(|| keccak256(&data))
});
}
criterion_group!(benches, bench_my_thing);
criterion_main!(benches);
ホット / コールド分離:
// 悪い例:1イテレーションごとに200バイト触る
struct Row {
id: u64,
big_blob: [u8; 192],
}
// 改善:ホットとコールドを分離
struct Hot { id: u64, version: u32 }
struct Cold { big_blob: [u8; 192] }
jemalloc 1 行で:
# Cargo.toml
[dependencies]
tikv-jemallocator = "0.5"
// main.rs
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
Reth の実本番プロファイル (Cargo.toml):
[profile.release]
opt-level = 3
lto = "thin"
debug = "none"
strip = "symbols"
panic = "unwind"
codegen-units = 16
[profile.maxperf]
inherits = "release"
lto = "fat"
codegen-units = 1
[profile.maxperf-symbols]
inherits = "maxperf"
debug = "full"
strip = "none"
起動:
cargo build --profile maxperf --bin reth
# またはネイティブCPU命令で(AVX2など):
RUSTFLAGS="-C target-cpu=native" cargo build --profile maxperf --bin reth
失敗例(誤解)
「コードを短くすると速くなる」— 間違い。現代 CPU では行数ではなく メモリレイアウト が支配的。短い行でも200バイト触れば 1 キャッシュラインでは収まらない。
「マイクロベンチで関数 X が 20% 速くなった → ノード全体が 20% 速くなる」— 間違い。マイクロベンチはシステム効果(cache miss、I/O 直列化、aliasing)を測らない。1 関数が速くても他の遅さに飲まれる。
「maxperf を日常開発でも使う」— 間違い。codegen-units = 1 + lto = "fat" は全モジュール跨ぎインライン化でコンパイル時間が桁違いに長くなる。日常開発は release、出荷時だけ maxperf。
🛑 予測。 ジュニアエンジニアが「ノードが遅い気がする、HashMap を BTreeMap に置き換えてみよう」と言う。アプローチの問題点を 3 つ挙げよ。(答え: ① 測定なしに原因を仮定している(実際の遅さの原因は HashMap ではないかもしれない)、② BTreeMap も遅い可能性がある(cache 局所性は劣る場合あり)、③ 変更後の再測定計画がない(最適化が打ち消し合う場合あり)。測定なしの最適化は逆効果になりうる。)
ステップで組み立てる
Step 1: flamegraph を 1 回取れる
cargo flamegraph --bin reth → svg をブラウザで → 上部の横に広いバーがホットパス。
Step 2: Criterion ベンチを 1 つ書ける
benches/ 配下に 1 つ。性能改善の主張にはベンチ結果をコミットに添える規律。
Step 3: schelk でディスク状態をロールバック
500GB Reth DB を毎ラン再構築するのは数時間 → 不可。tempoxyz/schelk の dm-era でブロックデバイススナップショット、ロールバックは数秒。ロールバック規律のないベンチは再現不可能。
Step 4: jemalloc + maxperf プロファイル
features = ["jemalloc", "asm-keccak"] + cargo build --profile maxperf。バリデータ向けの「1 サイクルでも惜しい」ビルド。
Step 5: 3 ルール
- 何かを変える前に必ず測る(「速くなった気がする」はデータではない)
- プロファイラが示すパスだけを最適化
- 変更後にもう一度測る(手動最適化がコンパイラ最適化と打ち消す場合あり)
答え合わせ
- Criterion 20% → ノード全体 20% は保証されない理由: マイクロベンチはシステム効果(cache、I/O 直列化、aliasing)を測らない。同じ関数が単独で 20% 速くても、ホットパス全体の中では他の遅さに飲まれて改善が薄まる。
- 100 万要素
Vec<Row>でrow.idだけ読むとき CPU がロードする量: 200 MB(200バイト × 100万)。big_blobを読まなくても 1 ロウあたり 200 バイト全部キャッシュラインに乗る = キャッシュラインを 200 バイト触ったのと同じ。SoA に分離すれば 12 バイト × 100 万 = 12 MB に縮む(~16 倍速)。 - jemalloc の 10-30% はテイル(p99): 平均レイテンシではなく、断片化が進んだ状態での p99 が改善する。glibc malloc は arena ロック競合 + 断片化でテイルが暴れる。
合格基準
- 2 つのツール(flamegraph + Criterion)の役割を即答できる。
- 3 つのリリースプロファイル(release / maxperf / maxperf-symbols)の使い分けを言える。
- 「マイクロベンチがノード全体について嘘をつく理由」を 2 つ言える。
- jemalloc が救うのは p50 ではなく p99 と説明できる。
まとめ(3行)
- 測ってから直す = flamegraph(全体)+ Criterion(関数別)。マイクロベンチはシステム効果を測れないので、ノード全体は schelk でロールバックして実測する。
- メモリレイアウト > 行数。SoA + ホット / コールド分離 + jemalloc がテイルレイテンシを救う。
- 3 プロファイル(release / maxperf / maxperf-symbols)を使い分け、性能改善の主張にはベンチ結果を必ず添える。