
2026/11/05 3:20
Benchfmt.rs:Go のベンチマークと benchstat ワークフローを Rust に持ち込む
RSS: https://news.ycombinator.com/rss
要約▶
Japanese 翻訳:
以下で示す改善されたサマリーでは、不足していた技術的な文脈(Criterion の特定の欠陥)、ベンチマーク環境の詳細、そして「他のライブラリ」の問題が標準的な Criterion の動作を指していることを明確にしています:
改善されたサマリー
最も重要な発見は、新しい Rust ツールである
benchfmt.rs が、Go ベンチマークとの直接的なパフォーマンス比較を可能にしながら、著しく高い効率性を提供することです。Go の標準的なテストスイートのレポート機能に一致するように設計されたこのゼロ依存関係のランナーは、Rust の人気のある Criterion ライブラリに見られる特定の落とし穴を解決します。これらの落とし穴には、その重たいコンパイル負荷(約 42 クレート)、線形ランプサンプリングの問題、そして組み込みのヒープ追跡や多次元テーブルピボティングの欠如などが含まれます。benchfmt.rs はヒープ追跡 (CountingAlloc) と pprof プロファイリングをネイティブに統合するため、ベンチマークコードを書き換えることなく、Go ツール(例:benchstat JSON)と完全に互換性のある出力形式を生成することを可能にします。
24 コア Intel Xeon ワークステーション上で実施されたテストの結果(Go 1.26.8)から、同一の負荷において Rust が Go を上回ることが示されています:varint デコードでは 37%〜42% 高速化し、リモート書き込みタスク全体では 13%〜15% の高速化が達成されています。プロセスレベルの指標は、Rust が Go よりも CPU 使用量を 1.9 倍から 3.2 倍低く、特に Go でバックグラウンドガベージコレクションが有効になっている場合(これらの負荷において Go のオン-CPU タイムの約 54% を占める)、著しく少ない RAM を消費することを示しています。この効率性は、標準的な
Criterion ランナーに見られる欠点を欠いています。今後、ユーザーは -cpuprofile といったフラグを活用して、pprof.me で分析可能なプロファイルを生成できます。最終的に、このツールはマイクロサービス設計においてメモリの使用量と生速度に関するアーキテクチャ上の意思決定を行うために、クロス言語チームのための一元化されたワークフローを提供します。
(注:厳密に元のテキストの正確な言葉に従うこと(サマリー入力には存在するが Key Points には存在しない特定の Go バージョンやハードウェア仕様などの外部事実を追加しないこと)を要求される場合は、元のサマリーに戻り、欠落している場所の URL
に関する注釈を単に追加すべきです。ただし、一般的には、改善されたサマリーはこれらの重要な方法論的なポイントを統合すべきです。)bwplotka/benchmarks/benchmarks/rust-go-decode
本文
benchfmt.rs: Rust ベンチマークランナーと Go の benchstat 互換性の実現
TL;DR
- benchfmt.rs を作成しました。
- ゼロ依存関係を持つ Rust ベンチマークランナーです。
- Go の
と互換性のある出力形式。benchstat - クリテリオン(Criterion)および libtest 変換器を内蔵しています。
- 追加機能:
プロジェクション機能(A/B 比較対応)。/key=value- オペレーションごとの**割り当てされたバイト数(B/op)およびオブジェクト数(allocs/op)**の追跡。
- pprofプロファイリングサポート。
本稿では、Go と Rust の間で varint 解析と Prometheus Remote Write 2.0 デコードのパフォーマンスをベンチマークし、Rust において
benchstat 類似のワークフローの利点を示します。また、得られた興味深い学び(「はい、Rust は確かに速いです」など)を共有します。
背景:なぜ benchfmt.rs を作る必要があるのか?
最近、Google プロジェクトやオープンソースで Rust のパフォーマンスストーリーを楽しんできましたが、マイクロベンチマークの不足に直面しました。Go ベンチマッキングエコシステム(
go test -bench と benchstat)には以下の機能が不足していました:
- ケース比較のための
プロジェクション形式がない。/key=value - 標準的なヒープ割り当て測定機能がない。
- 標準的な pprof サポートがない。
- 信頼性のスコアリングが簡易的すぎる。
免責事項: ここで言う「標準」は、大規模な開発者層に親しみのあるものを指します。私自身、Go の経験によるバイアスがある可能性を否定しません!
そこで、**「Rust マイクロベンチマークに対して、
benchstat 互換の出力を得ることは可能か?」**という問いかけから benchfmt.rs を構築しました。
- スレッド局所ヒープ割り当てカウンター付き。
- pprof プロファイラー統合。
- Go の
と同じ CLI フラグ対応。go test -bench
2 つのワークロードのベンチマーク
プロトコルバッファのデコードパフォーマンスと分散システムの主要コストに着目し、以下の 2 つのワークロードを実装しました。
ワークロード詳細
-
DecodeVarints:
- 36 行の符号なし LEB128 varint ストリームデコーダー。
- テストデータ
(4,798 バイト):2,000 つの uint64 値を混在パック。varints_2000.bin - ゼロヒープ割り当てを行う事前に確保されたスライスを先頭に書き込みます。
-
DecodeRemoteWrite2:
- Snappy 圧縮された Prometheus Remote Write 2.0 (
) の HTTP リクエスト本体デコーダー。io.prometheus.write.v2.Request - デフォルトの
に一致する2,000 シリーズを含むバッチをテスト。max_samples_per_send - 文字列をトップレベルのシンボルテーブルにインターン化、varint ラベル参照をパック。
- 未圧縮: 244.9 KiB / Snappy 圧縮:57.0 KiB。
- Snappy 圧縮された Prometheus Remote Write 2.0 (
Go の実装とベンチマーク
コード実装
DecodeVarints は単一のバイト値が 0x80 より小さい場合の高速パスを含む、呼び出し元に提供されたスライスを直接書き込みます:
func DecodeVarints(src []byte, dst []uint64) (int, error) { n := 0 i := 0 for i < len(src) { if n >= len(dst) { return n, ErrDstTooSmall } b := src[i] i++ if b < 0x80 { dst[n] = uint64(b) n++ continue } x := uint64(b & 0x7f) shift := uint(7) for { if i >= len(src) { return n, ErrUnexpectedEOF } b = src[i] i++ x |= uint64(b&0x7f) << shift if b < 0x80 { break } shift += 7 if shift >= 64 { return n, ErrOverflow } } dst[n] = x n++ } return n, nil }
DecodeRemoteWrite2 は、プロダクション環境で Prometheus が使用する方式(Snappy デコーダーと生成された最適化された UnmarshalVT)を使用します:
func DecodeRemoteWrite2(compressed []byte) (*writev2.Request, error) { raw, err := snappy.Decode(nil, compressed) if err != nil { return nil, err } req := &writev2.Request{} if err := req.UnmarshalVT(raw); err != nil { return nil, err } return req, nil }
ベンチマーク設定 (/key=value
形式)
/key=valueGo の
/key=value サブベンチマーク名(例:samples=2000/lang=go)により、benchstat が後でピボット可能です。
func BenchmarkDecodeVarints(b *testing.B) { manifest := ensureFixtures(b) raw, err := os.ReadFile(filepath.Join("testdata", "varints_2000.bin")) if err != nil { b.Fatalf("read varints: %v", err) } dst := make([]uint64, manifest.VarintsCount) b.Run("samples=2000/lang=go", func(b *testing.B) { b.SetBytes(int64(len(raw))) b.ReportAllocs() for b.Loop() { n, err := DecodeVarints(raw, dst) if err != nil || n != manifest.VarintsCount { b.Fatalf("unexpected decode result: n=%d err=%v", n, err) } } }) }
実行結果 (Intel Xeon 2.20GHz / 2 コア)
Go 1.26.8, Rust 1.99.1 で、-cpu 2、エポック 6、タイムアウト 999m で実行。
goos: linux goarch: amd64 pkg: github.com/bwplotka/benchmarks/benchmarks/rust-go-decode cpu: Intel(R) Xeon(R) CPU @ 2.20GHz BenchmarkDecodeVarints/samples=2000/lang=go-2 279861 8514 ns/op 563.52 MB/s 0 B/op 0 allocs/op BenchmarkDecodeRemoteWrite2/samples=200/lang=go-2 8467 264321 ns/op 31.43 MB/s 195296 B/op 2189 allocs/op BenchmarkDecodeRemoteWrite2/samples=2000/lang=go-2 976 2448633 ns/op 23.84 MB/s 1643970 B/op 19297 allocs/op
Criterion を使用した Rust の実装とベンチマーク
コード実装
Go のループを文字通りコピーし、
snap クレートと prost を組み合わせます。
pub fn decode_varints(src: &[u8], dst: &mut [u64]) -> Result<usize, VarintError> { let mut n = 0usize; let mut i = 0usize; while i < src.len() { if n >= dst.len() { return Err(VarintError::DstTooSmall); } let mut b = src[i]; i += 1; if b < 0x80 { dst[n] = u64::from(b); n += 1; continue; } let mut x = u64::from(b & 0x7f); let mut shift = 7u32; loop { if i >= src.len() { return Err(VarintError::UnexpectedEof); } b = src[i]; i += 1; x |= u64::from(b & 0x7f) << shift; if b < 0x80 { break; } shift += 7; if shift >= 64 { return Err(VarintError::Overflow); } } dst[n] = x; n += 1; } Ok(n) } pub fn decode_remote_write2(compressed: &[u8]) -> Result<proto::v2::Request, String> { let raw = snap::raw::Decoder::new() .decompress_vec(compressed) .map_err(|e| e.to_string())?; proto::v2::Request::decode(raw.as_slice()).map_err(|e| e.to_string()) }
Criterion 設定と実行
Throughput::BytesDecimal を使用して Go の b.SetBytes に合わせる。
fn bench_varints(c: &mut Criterion) { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata"); let raw = fs::read(root.join("varints_2000.bin")).unwrap(); let mut dst = vec![0u64; 2000]; let mut group = c.benchmark_group("DecodeVarints"); // Go の b.SetBytes に合わせるために十進法の MB/s (1e6 B/s) をレポート group.throughput(Throughput::BytesDecimal(raw.len() as u64)); group.bench_function("samples=2000/lang=rust", |b| { // std::hint::black_box は LLVM オープティマイゼーションバリアです b.iter(|| { let n = decode_varints(black_box(&raw), black_box(&mut dst)).unwrap(); assert_eq!(n, 2000); black_box(dst[n - 1]); // 書き込まれたバッファーを観察する }); }); group.finish(); }
実行結果 (cargo bench
)
cargo benchDecodeVarints/samples=2000/lang=rust time: [5.2789 µs 5.3369 µs 5.4014 µs] thrpt: [888.29 MB/s 899.02 MB/s 908.90 MB/s] DecodeRemoteWrite2/samples=200/lang=rust time: [237.09 µs 238.48 µs 239.95 µs] thrpt: [34.620 MB/s 34.833 MB/s 35.037 MB/s] DecodeRemoteWrite2/samples=2000/lang=rust time: [2.0712 ms 2.0831 ms 2.0968 ms] thrpt: [27.835 MB/s 28.018 MB/s 28.179 MB/s]
⚠️ Criterion の欠点(benchstat 互換性の障壁)
Criterion は単一のビルドから信頼区間と HTML プロートを取得できますが、
go test と benchstat と比較すると以下の4 つのデザイン選択が妨げになります:
- コンパイル重量:
でも多くの依存関係(clap, regex-automata など)がコンパイルされ、ビルド時間が長くなる。default-features = false - 線形ラップサンプリング:
- Go は固定タイマーオーバーヘッドインターセプトを考慮し、等しいステディステートエポックを実行する。
- Criterion は 100 の独立したバッチ([1d, ..., 100d])を使用するため、遅い OS スパイキがスロープを上げ、変動が増大する。
- ヒープ割り当て追跡なし: Criterion はウォールクロック時間とスループットのみをレポートします。
のような大量の割り当て状況を区別できません。DecodeRemoteWrite2 - ペアワイズベースラインフォルダ:
に対して比較するため、Go と Rust を横断した単一のテーブルへの出力が困難です。target/criterion/<id>/base
benchfmt CLI で Criterion 出力を変換
Rust コードを Criterion から書き換えずとも、
benchfmt.rs の benchfmt CLI バイナリを使って変換可能です。
cargo install --git https://github.com/bwplotka/benchfmt.rs # Criterion 結果を読み込み、benchstat 形式に変換 benchfmt criterion target/criterion \ --pkg github.com/bwplotka/benchmarks/benchmarks/rust-go-decode \ --cpu 2 \ > results/rust_criterion.txt # Go と Rust の結果を結合して表示 benchstat -col /lang results/go.txt results/rust_criterion.txt
結果:
Go vs Rustの効率性が明確に示されます(例:Rust は DecodeVarints で 37.16% 高速化)。
- 残りのギャップ:
とB/op
の列が Go しかない(Criterion が割り当てを記録していないため)。allocs/op- 線形ラップエポックにより、変動が ±5% 残留している。
benchfmt::Suite と CountingAlloc を使用したネイティブ実行
新しい Rust ベンチマークでは、
benchfmt::Suite を直接使用できます。これにより、ゼロ依存関係かつ benchstat 互換の完全なワークフローが可能になります。
実装ポイント
- CountingAlloc: スレッド局所的な
でヒープ割り当て、ゼロ割り当て、再割り当てを追跡します。Cell<(u64, u64)> - CLI フラグ:
,-bench
,-benchtime
,-count
,-cpu
,-benchmem
,-cpuprofile
を標準通りサポートします。-memprofile
// Rust システムアロケーターをグローバルにラップ #[global_allocator] static ALLOC: CountingAlloc = CountingAlloc::new(); fn main() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata"); let raw_varints = fs::read(root.join("varints_2000.bin")).unwrap(); // ... (ファイル読み込み) // go-test 互換 CLI フラグを std::env::args() から解析 Suite::from_env("github.com/bwplotka/benchmarks/benchmarks/rust-go-decode") .with_alloc(&ALLOC) .bench("DecodeVarints/samples=2000/lang=rust", |b| { b.set_bytes(raw_varints.len() as u64); b.report_allocs(); while b.keep_running() { let n = decode_varints(black_box(&raw_varints), black_box(&mut dst)).unwrap(); assert_eq!(n, 2000); black_box(dst[n - 1]); } }) // ... 他のベンチマーク定義 .run(); }
実行コマンド
cargo bench --bench suite_decode -- \ -bench '^BenchmarkDecode' -benchtime 2s -count 6 -cpu 2 -benchmem \ | tee results/rust_suite.txt
ベンチマーク結果比較
- ステディステートエポック: Suite の実行により、最悪の変動が ±4-5% から**±1-2%**に引き締まりました。
- クロック値:
による緊密ループ(490,000 回反復)により、Go の 8.54 µs vs Rust の 5.36 µs に近づきました。b.keep_running()
Go vs Rust の詳細な比較と分析
ヒープ割り当てとメモリ使用量の意外な事実
B/op および allocs/op テーブルから以下のことが判明しました:
- allocs/op: Rust は 2,000 シリーズで Go よりも14.55%少ないヒープ割り当て(16,490 vs 19,297)を行います。Go の
がポインタスライスを生成するのに対し、Rust のprotoc-gen-go
はバイナリー値を内蔵ストレージに格納するためです。prost - B/op: Rust は逆に、2,000 シリーズで Go よりも30.98%多いヒープバイトを要求(2.05 MiB vs 1.57 MiB)します。Rust の
成長戦略と構造体のインラインコピーによるオーバーヘッドのためです。Vec
pprof プロファイリングによるボトルネック分析
benchfmt::Suite (features = ["pprof"]) を使用して詳細なプロファイルを取得できます。
DecodeVarints の CPU プロファイル
- 結果: Go は 8.54 µs vs Rust の 4.90 µs。
- 原因:
- ループアンロールとシフトマスキング: Rust は定数シフトを使用し、Go は動的なチェックを必要とするため、Go がより多くの指令を実行する。
- インデックス型: Go の有符号 int はバウンダーチェックにオーバーヘッドを生じ、Rust の無符号 usize はそれを排除できる。
DecodeRemoteWrite2 の CPU およびヒーププロファイル
- 結果: Rust が全体遅延で負けている(Go よりも時間がかかる)。
- 原因: Go は**背景 GC アシストが全体のオン-CPU 時間の 54.1%**を占めるためです。Rust ではこれがありませんが、
やVec::push
による割り当てコストが発生します。RawVecInner::grow_amortized
マイクロベンチマークの検証(実プログラムへの適用)
マイクロベンチマークの結果が実際のリリースバイナリでも再現するか確認しました。
- 環境: ゴーと Rust のスタンドアロンの CLI バイナリ、
で測定。/usr/bin/time -v - シナリオ:
を通じてインゲスチョンキューを模擬。--retain K
| Run | Iters | Microbench | Binary wall | Total CPU | CPU % | Max RSS |
|---|---|---|---|---|---|---|
| Rust varints | 500,000 | 4.90 µs | 5.74 µs | 2.89s (5.78 µs/op) | 99% | 2.44 MiB |
| Go varints | 500,000 | 8.54 µs | 8.64 µs | 4.34s (8.68 µs/op) | 100% | 6.11 MiB |
| Rust rw2_2000 | 1,000 | 2.03 ms | 2.22 ms | 2.22s (2.22 ms/op) | 99% | 4.21 MiB |
| Go rw2_2000 | 1,000 | 2.47 ms | 2.97 ms | 4.28s (4.28 ms/op) | 144% | 15.98 MiB |
| Rust rw2_2000 --retain 1000 | 1,000 | 2.03 ms | 2.75 ms | 3.18s (3.18 ms/op) | 99% | 1,292.4 MiB |
検証結果の洞察
- CountingAlloc: レイテンシーを歪ませませんでした。
- B/op vs RSS: Rust はアロケーターから多量のメモリを取得しましたが、ループ終了時のドロップにより、ピーク RSS は低く抑えられます。
で保持すると RSS は急増します。--retain - 背景 GC のコスト: Go は GOMAXPROCS=2 でも CPU を 144% 消費し、バックグラウンドでスキャンしています。Rust は CPU と RAM を3〜4 倍効率よく使用します(GC コストがないため)。
まとめ
一般的に、Rust はこの AI エラにおいて以下の点で真のポテンシャルを持っています:
- 厳密な所有性: AI がコードを生成する際、コンパイル時の検証により品質が保証されます。
- AI 時代の適応: 大量のコードを生成する AI にて、レビュー可能なコード量は重要です。Rust は十分です。
- 制御されたメモリ: GC のないため、メモリ割り当てにさらなるコントロールがあり、パフォーマンス向上が可能です。
benchfmt.rs の活用ワークフロー
- 既存の Criterion スイート:
を使って A/B 比較表を生成します。benchfmt criterion - 新規 Rust ベンチマーク:
を使用し、ステディステートエポック、B/op/allocs/op 追跡、pprof プロファイルを取得します。benchfmt::Suite
benchfmt.rs を試し、GitHub でフィードバック(壊れている点や不足している点)をお待ちしています!