HN 日本語サマリー

← 一覧へ戻る
プログラミング

Benchfmt.rs: GoのベンチマークとbenchstatワークフローをRustにもたらす

Benchfmt.rs: Bringing Go's benchmarking and benchstat workflow to Rust (bwplotka.dev)

16 pointsby witcher2 コメント

要約

この記事では、GoのbenchstatワークフローをRustにもたらすためのツール「benchfmt.rs」が紹介されています。GoとRustで同じコードを実装し、パフォーマンスを比較することで、Rustのベンチマークエコシステムを強化し、Goとの比較を容易にする方法が示されています。特に、メモリ割り当ての追跡やpprofプロファイリングの統合といった機能が強調されています。

全文翻訳

benchfmt.rs: GoのベンチマークとbenchstatワークフローをRustにもたらす

TL;DR: 私はbenchfmt.rsを開発しました。これは、Goのbenchstat向けの依存関係ゼロのRustベンチマークランナーであり、Criterionコンバーターです。/key=valueプロジェクション、割り当てられたバイト数(B/op)とオブジェクト数(allocs/op)のヒープ追跡、およびpprofプロファイリングを追加します。

この投稿では、benchstatライクなワークフローの利点をRustで示すために、varintとPrometheus Remote Write 2.0のデコードをGoとRustでベンチマークします。また、途中でいくつかの興味深い発見(例:Rustは確かに速い)も共有します。

最近、Google内外でいくつかのRustプロジェクト(オープンソースを含む)に取り組んでいます。私は、Prometheus、Thanosでパフォーマンスが重視されるGoを長年書き、「Efficient Go」という書籍でGoを教えてきた後、Rustに移行しました。私にとって、Rustのパフォーマンスに関する話の多くは非常に良いのですが… A/Bマイクロベンチマークを実行したいと思ったときです。

おそらく、私はGoのベンチマークエコシステムに甘やかされていたのでしょう。中心には go tool -bench と benchstatがありました。私にとって、Rustの標準的なベンチマークツールには以下の点が欠けていました。

  • ケース比較プロジェクションのための標準フォーマット。
  • 標準的なヒープ割り当て測定。
  • 標準的なpprofサポート。
  • 理解しやすい信頼性スコアリング。

免責事項: ここでの「標準」とは、多数の開発者に馴染みのあるものを意味し、私は確かにGoの経験に偏っています!

その結果、私は自問しました。「Rustのマイクロベンチマークでbenchstat互換の出力を得られるだろうか? それはA/Bテストを容易にし、興味深いGo対Rustの比較を可能にするのではないか?」

ついに実現しました! 私はbenchfmt.rsを開発しました。これは、スレッドローカルなヒープ割り当てカウンター、pprofプロファイラー統合、そしてgo test -benchと同じCLIフラグを持つ、依存関係ゼロのRustベンチマークランナーです。新しいベンチマークランタイムを信頼しない人(最初から信頼すべきではありません!)のために、Goのbenchfmtフォーマットを直接出力するCriterionおよびlibtestコンバーターも同梱しています。

このブログ記事では、GoとRustで同じコード片を実装し、さまざまなRustフレームワークでベンチマークし、結果を比較します。その過程で何か新しいことを学ぶかもしれません(私はこのツールを開発中に学びました!)。お楽しみください!

すべてのベンチマークコードと生出力は、bwplotka/benchmarks/benchmarks/rust-go-decodeにあります。

2つのワークロードのベンチマーク

GoとRustの両方で実装してベンチマークする2つのワークロードを選びましょう。私は特にprotobufデコードパフォーマンスに興味があります。これは、面白いことに、最近の分散システム実行における主なコストの1つであることがよくあります。そこで、Prometheus Remote Write 2.0プロトコルをデコードしてみましょう。protobufコードジェネレーターは言語間で成熟度やトレードオフが異なるため、それに加えて、より単純なもの、つまりスタンドアロンのvarintデコーダーも測定しましょう。

ワークロードの詳細:
  1. DecodeVarints。36行の符号なしLEB128 varintストリームデコーダー。VarintsはProtocol Buffersの基盤となる整数エンコーディングです。私たちのフィクスチャvarints_2000.binは4,798バイトで、1バイトのシンボルインデックス、2バイトのバケットオフセット、4バイトのデルタ、6バイトのミリ秒Unixタイムスタンプを混在させた2,000個のuint64値をパックしています。この関数は、ヒープ割り当てゼロで、事前に割り当てられた2,000要素のスライスに書き込みます。
  2. DecodeRemoteWrite2。Snappy圧縮されたRemote Write 2.0 (io.prometheus.write.v2.Request)ペイロードの完全なHTTPリクエストボディデコーダー。200および2,000シリーズのバッチをテストします。2,000シリーズはPrometheusのデフォルトmax_samples_per_sendに一致します。2,000シリーズのフィクスチャは、カウンター、ゲージ、クラシックおよびネイティブヒストグラム、1,000個のトレースエグゼンプラー、およびメトリックメタデータを混在させます。Remote Write 2.0は、すべての文字列をトップレベルのsymbolsテーブルにインターンし、シリーズごとにパックされたvarint labels_refsを格納します。これにより、2,000シリーズのバッチは、非圧縮で244.9 KiB、Snappy圧縮で57.0 KiBに縮小されます。

Goでの実装とベンチマーク

まず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が本番環境で実行する方法に合わせてGoの実装を調整します。x86-64アセンブリをlinux/amd64で使用するgithub.com/golang/snappyと、生成された最適化されたUnmarshalVTデコーダーを持つvtprotobufをペアにします。

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
}

さて、Goコードの効率を測定しましょう!

Goの/key=valueサブベンチマーク名(samples=2000/lang=go)に注意してください。これにより、Goの公式benchstatツールが後で/langまたは/samplesでピボットできるようになります。また、b.SetBytes、b.ReportAllocs、for b.Loop()も使用します。

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)

	// ディメンションを/key=valueとしてエンコードし、benchstatが/samplesまたは/langでピボットできるようにします。
	b.Run("samples=2000/lang=go", func(b *testing.B) {
		b.SetBytes(int64(len(raw)))
		b.ReportAllocs()
		// 注意: b.Loop()は最初の呼び出しでタイマーをリセットし、終了時に停止し、
		// パッケージレベルのシンク変数なしで呼び出し引数と結果を生存させます。
		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)
			}
		}
	})
}

func BenchmarkDecodeRemoteWrite2(b *testing.B) {
	ensureFixtures(b)

	for _, samples := range []int{200, 2000} {
		file := fmt.Sprintf("rw2_%d.snappy", samples)
		compressed, err := os.ReadFile(filepath.Join("testdata", file))
		if err != nil {
			b.Fatalf("read %s: %v", file, err)
		}

		b.Run(fmt.Sprintf("samples=%d/lang=go", samples), func(b *testing.B) {
			b.SetBytes(int64(len(compressed)))
			b.ReportAllocs()
			for b.Loop() {
				req, err := DecodeRemoteWrite2(compressed)
				if err != nil || len(req.Timeseries) != samples {
					b.Fatalf("unexpected v2 decode: err=%v", err)
				}
			}
		})
	}
}

最後に、24コアのIntel Xeon 2.20GHz Linuxワークステーション(Go 1.26.8、Rust 1.99.1)で、-cpu 2で2sのベンチマーク実行を6回(エポック)実行します。

go test -run '^$' -bench '^BenchmarkDecode' \
    -benchtime 2s -count 6 -cpu 2 -timeout 999m -benchmem \
| tee results/go.txt

1エポックは次のようになります。

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での実装とベンチマーク

次に、両方のデコーダーとベンチマークをRustに移植しましょう。まず、decode_varintsはGoのループを一行一行ミラーリングします。

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)
}

次に、decode_remote_write2はsnapクレートとprostのコンパイル時生成Message構造体をペアにします。

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、つまり安定版Rustマイクロベンチマークのデフォルト選択肢を使用して、両方のRustベンチマークを記述しましょう。同じ/key=valueベンチマークIDを維持し、Throughput::BytesDecimalを設定してGoの10進数MB/sに合わせます。

fn bench_varints(c: &mut Criterion) {
    // コンパイル時に焼き付けられたクレートディレクトリからtestdata/を解決します。
    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata");
    let raw = fs::read(root.join("varints_2000.bin")).unwrap();
    // 出力バッファをタイムドループの外で事前割り当てし、各操作が0ヒープ割り当てを行うようにします。
    let mut dst = vec![0u64; 2000];

    // グループ+関数IDを結合して "DecodeVarints/samples=2000/lang=rust" にし、Goのb.Runに合わせます。
    let mut group = c.benchmark_group("DecodeVarints");
    // Goのb.SetBytesに合わせるために10進数MB/s(1e6 B/s)を報告します。Throughput::BytesはMiB/sを表示します。
    group.throughput(Throughput::BytesDecimal(raw.len() as u64));
    group.bench_function("samples=2000/lang=rust", |b| {
        // b.iterは、Criterionのウォームアップと100の線形ランプサンプル全体でクロージャを実行します。
        b.iter(|| {
            // std::hint::black_boxはLLVM最適化バリアです:Goのb.Loop()とは異なり、Rustは
            // black_boxヒントなしでは既知の入力を定数折り返ししたり、未使用の作業を削除したりします。
            let n = decode_varints(black_box(&raw), black_box(&mut dst)).unwrap();
            assert_eq!(n, 2000);
            // LLVMがdstへのストアを最適化して削除できないように、書き込まれたバッファを観測します。
            black_box(dst[n - 1]);
        });
    });
    group.finish();
}

fn bench_remote_write2(c: &mut Criterion) {
    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata");
    let mut group = c.benchmark_group("DecodeRemoteWrite2");

    for samples in [200usize, 2000usize] {
        let file = format!("rw2_{samples}.snappy");
        let compressed = fs::read(root.join(file)).unwrap();

        group.throughput(Throughput::BytesDecimal(compressed.len() as u64));
        let id = format!("samples={samples}/lang=rust");
        group.bench_function(&id, |b| {
            b.iter(|| {
                let req = decode_remote_write2(black_box(&compressed)).unwrap();
                assert_eq!(req.timeseries.len(), samples);
                // イテレーション終了時にドロップされる前にreqを具体化させます。
                black_box(req);
            });
        });
    }
    group.finish();
}

// Cargo.tomlのharness = falseで必要とされるベンチマークグループランナーとカスタムmain()を生成します。
criterion_group!(benches, bench_varints, bench_remote_write2);
criterion_main!(benches);

最後に、Criterionベンチマークスイートを実行します。

cargo bench --bench criterion_decode
DecodeVarints/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の欠点

Criterionは、外部ツールなしで単一のcargo benchコマンドから信頼区間とHTMLプロットを取得したい場合に便利です。しかし、go testとbenchstatと比較すると、4つの設計上の選択が妨げとなります。

  1. コンパイル重量。 criterionをdefault-features = falseでプルインしても、clap、regex-automata、ciborium、oorandom、itertools、tinytemplate、anesがコンパイルされます。これは42個の推移的なクレートと、コールドなcargo benchビルドに約19秒を追加します。

  2. 線形ランプサンプリング対定常状態エポック。

    • Goのtesting.Bは、-benchtime 2sの固定イテレーション数Nを調整し、-count 6の等しい定常状態エポックを実行します。これはbenchstatが外れ値に強い中央値とMann-Whitney U検定で要約します。
    • CriterionはデフォルトでLinearサンプリングを使用します:3秒のウォームアップ後、[1d, 2d, ..., 100d]イテレーションの100バッチを収集し、原点を通る最小二乗線y = m * xにフィットさせます。線はゼロを通るように強制されるため、固定のタイマーオーバーヘッド切片を減算しません。代わりに、バッチ100がバッチ1の10,000倍の重みを持つk^2重み付け平均を計算し、遅延したOSスパイクが傾きを上に引き上げます。

    これらの2つのモデルを直接比較すると、2つの測定可能な歪みが生じます。

    • 第一に、Criterionの100個のランプサンプルを6つのbenchstatエポックにグループ化すると、エポック1はエポック6よりも11.5倍少ないイテレーションになります(DecodeVarintsで24,888対286,212)。短いバーストと長い実行を混在させると、定常状態エポックの± 1-2%と比較して、分散が± 5%に膨らみます。
    • 第二に、100個の個別のクロージャバッチをタイミングすると、マイクロ秒ループで+8.6%のオーバーヘッド(5.36 µs対4.90 µs、p=0.002)が発生し、コールドアロケータキャッシュを持つ早期の割り当てサンプルが歪みます(DecodeRemoteWrite2で+2.3%)。
  3. ヒープ割り当て追跡なし。 Criterionは壁時計時間とスループットのみを報告します。DecodeVarintsで0 allocs/opの場合は問題ありません。DecodeRemoteWrite2では、prostが500個のヒープ割り当てを行ったのか、50万個行ったのか、あるいはリファクタリングでヒープのチャーンが倍増したのかを知ることはできません。

  4. ペアワイズベースラインフォルダー対多次元テーブル。 Criterionはtarget/criterion/<id>/baseとnewを比較します。単一の実行ファイルを、 -col /samples または -col /lang でピボットできるテーブルにフィードすることはできません。

benchfmt CLIによるCriterion出力の変換

RustコードベースがすでにCriterionを使用している場合、benchstatテーブルを取得するためだけにベンチマークを書き直すのは理想的ではありません。そのため、benchfmt.rsにはスタンドアロンのbenchfmt CLIバイナリが含まれています。これは、target/criterionから直接benchmark.jsonとsample.jsonを読み込み、Throughput::BytesDecimal(またはThroughput::Bytes)を10進数MB/sとして抽出し、Criterionの100サンプルを--epochs 6を介して6つの連続したエポックにグループ化します。

cargo install --git https://github.com/bwplotka/benchfmt.rs

benchfmt criterion target/criterion \
    --pkg github.com/bwplotka/benchmarks/benchmarks/rust-go-decode \
    --cpu 2 \
> results/rust_criterion.txt

benchstat -col /lang results/go.txt results/rust_criterion.txt

ベンチマーク実装に手を加えることなく、benchstatはGo対Rustのサイドバイサイドテーブルを提供します。

goos: linux
goarch: amd64
pkg: github.com/bwplotka/benchmarks/benchmarks/rust-go-decode
cpu: Intel(R) Xeon(R) CPU @ 2.20GHz
                                  │     go      │                rust                │
                                  │   sec/op    │   sec/op     vs base               │
DecodeVarints/samples=2000-2        8.536µ ± 1%   5.364µ ± 5%  -37.16% (p=0.002 n=6)
DecodeRemoteWrite2/samples=200-2    271.4µ ± 3%   235.1µ ± 4%  -13.37% (p=0.002 n=6)
DecodeRemoteWrite2/samples=2000-2   2.470m ± 5%   2.082m ± 1%  -15.72% (p=0.002 n=6)
geomean                             178.9µ        138.0µ       -22.87%

                                  │      go      │                rust                 │
                                  │     B/s      │     B/s       vs base               │
DecodeVarints/samples=2000-2        536.1Mi ± 1%   853.2Mi ± 5%  +59.15% (p=0.002 n=6)
DecodeRemoteWrite2/samples=200-2    29.19Mi ± 3%   33.70Mi ± 4%  +15.44% (p=0.002 n=6)
DecodeRemoteWrite2/samples=2000-2   22.53Mi ± 5%   26.74Mi ± 1%  +18.67% (p=0.002 n=6)
geomean                             70.65Mi        91.60Mi       +29.67%

                                  │       go       │
                                  │      B/op      │
DecodeVarints/samples=2000-2          0.000 ± 0%
DecodeRemoteWrite2/samples=200-2    190.7Ki ± 0%
DecodeRemoteWrite2/samples=2000-2   1.568Mi ± 0%
geomean                                         ¹
¹ summaries must be >0 to compute geomean

                                  │      go       │
                                  │   allocs/op   │
DecodeVarints/samples=2000-2         0.000 ± 0%
DecodeRemoteWrite2/samples=200-2    2.189k ± 0%
DecodeRemoteWrite2/samples=2000-2   19.30k ± 0%
geomean                                         ¹
¹ summaries must be >0 to compute geomean

これで、Go開発者が好む、読みやすく標準的なbenchstat出力が得られました。例示された関数におけるGo対Rustの効率が明確に示されています。RustはDecodeVarintsで37.16%(5.36 µs対8.54 µs)、DecodeRemoteWrite2で13.37%から15.72%高速です。

残念ながら、いくつかのギャップがあります。

  1. B/opとallocs/opテーブルにはgo列しかありません。これはCriterionが割り当てを記録しなかったためです。
  2. Criterionの線形ランプエポックは、DecodeVarintsで± 5%の分散を残します。

ネイティブbenchfmt実行とbenchfmt::SuiteおよびCountingAlloc

新しいRustベンチマークでは、Criterionをスキップしてbenchfmt::Suiteをharness = falseでCargo.tomlで使用できます。CountingAllocを#[global_allocator]として登録すると、ゼロアトミック競合とゼロ外部依存関係を持つthread_local! Cell<(u64, u64)>で、すべてのalloc、alloc_zeroed、reallocを追跡します。

// ベンチマークスレッド上のすべてのヒープ割り当て/再割り当てが
// スレッドローカル(allocs、bytes)カウンターをインクリメントするように、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();
    let rw2_200 = fs::read(root.join("rw2_200.snappy")).unwrap();
    let rw2_2000 = fs::read(root.join("rw2_2000.snappy")).unwrap();
    let mut dst = vec![0u64; 2000];

    // std::env::args()からgo-test互換のCLIフラグ(-bench、-benchtime、-count、-cpu、-benchmem、
    // -cpuprofile、-memprofile)を解析し、デフォルトの "pkg:" ヘッダーを設定します。
    // Goのgo.modと同じpkgパスを使用すると、`benchstat -col /lang`がGoとRustの行を結合できます。
    Suite::from_env("github.com/bwplotka/benchmarks/benchmarks/rust-go-decode")
        // 各ベンチマークがエポックごとにスレッドローカル割り当てデルタをスナップショットできるように、ALLOCをアタッチします。
        .with_alloc(&ALLOC)
        // Suiteはbenchfmt出力に "Benchmark" を追加し、GoのBenchmarkDecodeVarintsに一致させます。
        .bench("DecodeVarints/samples=2000/lang=rust", |b| {
            b.set_bytes(raw_varints.len() as u64);
            b.report_allocs();
            // Goのb.Loop()相当:最初の呼び出しでタイミング、allocスナップショット、およびpprofキャプチャを開始し、
            // キャリブレーションされたN個の定常状態イテレーションを実行し、すべてを停止します。
            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]);
            }
        })
        .bench("DecodeRemoteWrite2/samples=200/lang=rust", |b| {
            b.set_bytes(rw2_200.len() as u64);
            b.report_allocs();
            while b.keep_running() {
                let req = decode_remote_write2(black_box(&rw2_200)).unwrap();
                assert_eq!(req.timeseries.len(), 200);
                black_box(req);
            }
        })
        .bench("DecodeRemoteWrite2/samples=2000/lang=rust", |b| {
            b.set_bytes(rw2_2000.len() as u64);
            b.report_allocs();
            while b.keep_running() {
                let req = decode_remote_write2(black_box(&rw2_2000)).unwrap();
                assert_eq!(req.timeseries.len(), 2000);
                black_box(req);
            }
        })
        .run();
}

CountingAllocはTokioやカスタムアロケータで動作しますか? ほとんどの場合、はい。CountingAlloc::with_allocator(Jemalloc)を介してjemallocやmimallocのようなカスタムアロケータをラップできます。CountingAllocはベンチマークスレッドでスレッドローカルカウンターを使用するため、シングルスレッドのTokio current_threadはそのまま動作しますが、tokio::spawnやrayonのようなマルチスレッドプールは、Suite::with_allocに渡されるプロセス全体のアトミックなAllocTrackerを必要とします。

Suite::from_envはstd::env::args()を読み取り、goos、goarch、cpuを自動検出します。渡された文字列をpkg:メタデータ行として書き込みます(benchstatがGoとRustの行を1つのテーブルに結合できるように、Goのモジュールパスに一致させます)。--の後のフラグはgo testをミラーリングします。

cargo bench --bench suite_decode -- \
    -bench '^BenchmarkDecode' -benchtime 2s -count 6 -cpu 2 -benchmem \
| tee results/rust_suite.txt

まず、benchfmt::Suiteがタイミングに関してCriterionと一致するかどうかを確認します。CountingAllocがすべての割り当てでアクティブであってもです。

benchstat criterion=results/rust_criterion.txt suite=results/rust_suite.txt
                                            │  criterion  │               suite               │
                                            │   sec/op    │   sec/op     vs base              │
DecodeRemoteWrite2/samples=2000/lang=rust-2   2.082m ± 1%   2.033m ± 2%  -2.33% (p=0.015 n=6)
DecodeRemoteWrite2/samples=200/lang=rust-2    235.1µ ± 4%   231.5µ ± 1%  -1.55% (p=0.004 n=6)
DecodeVarints/samples=2000/lang=rust-2        5.364µ ± 5%   4.902µ ± 2%  -8.60% (p=0.002 n=6)
geomean                                       138.0µ        132.1µ       -4.21%

DecodeRemoteWrite2では、Criterionとbenchfmt::Suiteは1.6%から2.3%の範囲で一致しますが、Suiteの定常状態2sエポックは、最悪ケースの分散を± 4-5%から± 1-2%に引き締めます。DecodeVarintsでは、while b.keep_running()は100個の短いランプバッチの代わりに、1つのタイトなループで490,000回の連続したイテレーションを実行し、5.36 µs対4.90 µsを記録します。

Go対Rustの比較

次に、results/rust_suite.txtを使用して、完全なGo対Rustの比較を実行しましょう。

benchstat -col /lang results/go.txt results/rust_suite.txt
goos: linux
goarch: amd64
pkg: github.com/bwplotka/benchmarks/benchmarks/rust-go-decode
cpu: Intel(R) Xeon(R) CPU @ 2.20GHz
                                  │     go      │                rust                │
                                  │   sec/op    │   sec/op     vs base               │
DecodeVarints/samples=2000-2        8.536µ ± 1%   4.902µ ± 2%  -42.56% (p=0.002 n=6)
DecodeRemoteWrite2/samples=200-2    271.4µ ± 3%   231.5µ ± 1%  -14.71% (p=0.002 n=6)
DecodeRemoteWrite2/samples=2000-2   2.470m ± 5%   2.033m ± 2%  -17.68% (p=0.002 n=6)
geomean                             178.9µ        132.1µ       -26.12%

                                  │      go      │                rust                 │
                                  │     B/s      │     B/s       vs base               │
DecodeVarints/samples=2000-2        536.1Mi ± 1%   933.3Mi ± 2%  +74.09% (p=0.002 n=6)
DecodeRemoteWrite2/samples=200-2    29.19Mi ± 3%   34.22Mi ± 1%  +17.23% (p=0.002 n=6)
DecodeRemoteWrite2/samples=2000-2   22.53Mi ± 5%   27.37Mi ± 2%  +21.48% (p=0.002 n=6)
geomean                             70.65Mi        95.62Mi       +35.35%

                                  │       go       │                 rust                  │
                                  │      B/op      │     B/op      vs base                 │
DecodeVarints/samples=2000-2          0.000 ± 0%       0.000 ± 0%        ~ (p=1.000 n=6) ¹
DecodeRemoteWrite2/samples=200-2    190.7Ki ± 0%     271.9Ki ± 0%  +42.59% (p=0.002 n=6)
DecodeRemoteWrite2/samples=2000-2   1.568Mi ± 0%     2.053Mi ± 0%  +30.98% (p=0.002 n=6)
geomean                                          ²                 +23.15%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                  │      go       │                 rust                 │
                                  │   allocs/op   │  allocs/op   vs base                 │
DecodeVarints/samples=2000-2         0.000 ± 0%      0.000 ± 0%        ~ (p=1.000 n=6) ¹
DecodeRemoteWrite2/samples=200-2    2.189k ± 0%     1.905k ± 0%  -12.97% (p=0.002 n=6)
DecodeRemoteWrite2/samples=2000-2   19.30k ± 0%     16.49k ± 0%  -14.55% (p=0.002 n=6)
geomean                                         ²                 -9.40%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

B/opとallocs/opテーブルをよく見てください。これらは、割り当てカウンターなしでは見逃してしまうような驚きです。

  • Rustは、2,000シリーズで14.55%少ないヒープ割り当て(16,490対Goの19,297 allocs/op)を行います。Goのprotoc-gen-goは、[]*TimeSeriesや[]*Sampleのようなポインタスライスを生成し、各ネストされた構造体をヒープに個別に割り当てます。Rustのprostは、Vec<TimeSeries>とVec<Sample>を値としてインラインで格納します。
  • それでもRustは、2,000シリーズで30.98%多いヒープバイト(2.05 MiB対Goの1.57 MiB)、200シリーズで42.59%多い(271.9 KiB対190.7 KiB)を要求します!

ボーナス: GoとRustでオーバーヘッドが存在する場所を特定するためのgo tool pprofプロファイリング

benchstatは何が変更されたかを教えてくれますが、pprofはなぜ変更されたかを教えてくれます。Cargo.tomlでfeatures = ["pprof"]と[profile.bench] debug = 1を使用すると、benchfmt::Suiteはgo testと同じ-cpuprofile、-memprofile、-memprofilerateフラグをサポートします。各.pb.gzファイルは、go tool pprof -http=:8080またはpprof.meで直接開くことができます。

DecodeVarintsのCPUプロファイリング

DecodeVarintsは両言語でヒープ割り当てをゼロにしますが、Rustは4.90 µsで完了するのに対し、Goは同じループで8.54 µsかかります。両方のランナーからシングルスレッドCPUプロファイルをキャプチャしましょう。

go test -run '^$' -bench '^BenchmarkDecodeVarints$' -benchtime 5s -count 1 -cpu 1 \
    -cpuprofile /tmp/go_varints_cpu.pb.gz
cargo bench --bench suite_decode -- \
    -bench '^BenchmarkDecodeVarints/' -benchtime 3s -count 1 -cpu 1 \
    -cpuprofile /tmp/rust_varints_cpu.pb.gz

pprofでソースビューを開くと、各コンパイラがサイクルを費やしている場所がわかります。

[画像: /2026/rust-microbenchmarks-benchstat/pprof_go_varints_cpu.png]

Go DecodeVarints CPUソースプロファイル。pprof.meでインタラクティブなプロファイルを表示します。

[画像: /2026/rust-microbenchmarks-benchstat/pprof_rust_varints_cpu.png]

Rust decode_varints CPUソースプロファイル。pprof.meでインタラクティブなプロファイルを表示します。

両言語ともCPU時間の99%以上をvarintループ内で費やしています。しかし、Goはx |= uint64(b&0x7f) << shiftだけで1.74s(32.3%)を消費し、外側のループチェックで1.24s(23.0%)を消費しています。go tool objdumpとobjdump -dを調べると、42.56%のギャップの背後にある2つのコード生成の違いが見えてきます。

  1. ループ展開と64ビットシフトマスク。 shiftは7ずつ最大64(最大9バイト)までステップするため、LLVMはRustの内部継続ループを展開します。各<< shiftは、shiftカウンターやshift >= 64ブランチなしの定数シフト(shl $0x7、shl $0xe、shl $0x15、…)になります。Goは内部ループを展開したままにし、shift >= 64の場合はshiftを0としてx << shiftを定義しますが、x86のSHLQ CLは64でマスクします。これにより、Goは各継続バイトでSHLQ CL、CMPQ $64、SBBQ、ANDQを発行せざるを得なくなります。
  2. 符号付きint対符号なしusizeインデックス。 Goのiとnは符号付きintであるため、i < len(src)とn >= len(dst)はループバックエッジ全体で非負性を証明しません。Goはsrc[i]とdst[n]の前に重複する符号なしCMPQ/JBEスライス境界チェックを発行しますが、LLVMはRustの符号なしusizeインデックスの内部チェックを削除し、decode_varintsをwhile b.keep_running()にインライン化します。
DecodeRemoteWrite2のCPUとヒーププロファイリング

次に、2,000シリーズのRemote Write 2.0ベンチマークで-cpuprofileと正確な-memprofileキャプチャを収集しましょう。

# CPUプロファイル: -cpu 1で3秒。
go test -run '^$' -bench '^BenchmarkDecodeRemoteWrite2/samples=2000/' -benchtime 3s -count 1 -cpu 1 \
    -cpuprofile /tmp/go_rw2_cpu.pb.gz
cargo bench --bench suite_decode -- \
    -bench 'DecodeRemoteWrite2/samples=2000' -benchtime 3s -count 1 -cpu 1 \
    -cpuprofile /tmp/rust_rw2_cpu.pb.gz

# 正確なヒーププロファイル: 各割り当てをサンプリングして50イテレーション。
go test -run '^$' -bench '^BenchmarkDecodeRemoteWrite2/samples=2000/' -benchtime 50x -count 1 -cpu 1 \
    -memprofile /tmp/go_rw2_mem.pb.gz -memprofilerate 1
cargo bench --bench suite_decode -- \
    -bench 'DecodeRemoteWrite2/samples=2000' -benchtime 50x -count 1 -cpu 1 \
    -memprofile /tmp/rust_rw2_mem.pb.gz -memprofilerate 1

2つのCPUフレイムグラフは、非常に異なるボトルネックを示しています。

[画像: /2026/rust-microbenchmarks-benchstat/pprof_go_rw2_cpu.png]

Go DecodeRemoteWrite2 CPUフレイムグラフ(samples=2000)。pprof.meでインタラクティブなプロファイルを表示します。

[画像: /2026/rust-microbenchmarks-benchstat/pprof_rust_rw2_cpu.png]

Rust DecodeRemoteWrite2 CPUフレイムグラフ(samples=2000)。pprof.meでインタラクティブなプロファイルを表示します。

Goのx86アセンブリsnappy.Decodeは~0.12 ms/opで、Rustの純粋なsnap(~0.18 ms/op)よりも実際に優れており、vtprotobuf自体のワイヤパーシングは~0.95 ms/opしかかかりません。しかし、Goは全体的に劣っています。なぜなら、runtime.mallocgcとGCアシストがCPU時間の54.1%、つまり~1.34 ms/opを消費し、19,297個のヒープオブジェクトを割り当ててスキャンしているからです。

Rustでは、CPU時間の71.1%がprostフィールドデコードに、8.9%がsnap解凍に、18.7%がVec::pushとVec::reserve下のRawVecInner::grow_amortizedに費やされています。alloc_spaceヒーププロファイルは、RawVecInner::grow_amortizedがなぜそれほど忙しく、RustがGoよりも30.98%多いB/opを割り当てる理由を示しています。

[画像: /2026/rust-microbenchmarks-benchstat/pprof_go_rw2_mem.png]

Go DecodeRemoteWrite2 ヒープ alloc_space フレイムグラフ(50x)。pprof.meでインタラクティブなプロファイルを表示します。

[画像: /2026/rust-microbenchmarks-benchstat/pprof_rust_rw2_mem.png]

Rust DecodeRemoteWrite2 ヒープ alloc_space フレイムグラフ(50x)。pprof.meでインタラクティブなプロファイルを表示します。

GoのUnmarshalVTは、最終的なサイズに近いスライスと構造体を事前割り当てし、合計で1.57 MiB/opになります。Rustは2.05 MiB/opを割り当て、そのバイト数の88.4%はprost内のRawVec容量の倍増に由来します。

  • prostは、ワイヤバイト長から事前サイズ設定する代わりに、パックされたrepeated uint32 labels_refsをu32ごとにVec::pushでデコードします。各シリーズは、容量4 -> 8 -> 16 -> 32を通じてVec<u32>を成長させ、割り当てられたバイト数の23.0%を占めます。
  • Vec<v2::TimeSeries>を0から2,048まで成長させると、各2のべき乗で112バイトの構造体がインラインコピーされ、割り当てられたバイト数の21.3%になります。Goは8バイトの[]*TimeSeriesポインタスライスしか成長させません。
  • 空のVec<Sample>に1つの24バイトSampleをプッシュすると、RawVec::MIN_NON_ZERO_CAPにより直接容量4にジャンプし、1,900シリーズ全体でさらに8.5%の割り当てバイト数が増加します。

しかし、この余分なVecチャーンがあっても、Rustのglibcスレッドキャッシュ再割り当ては、GoのmallocgcとGCアシストよりもCPUコストが~3.5倍少なく、-memprofileはVec::with_capacityの修正に直接つながります。

スタンドアロンバイナリと/usr/bin/time -vによるマイクロベンチマークの確認

さて、これらのマイクロベンチマークは素晴らしいですが、効率の数値を実際のプログラムと比較して検証しましょう。

マイクロベンチマークは、ホットな命令キャッシュ、ウォームなアロケータ、suite_decodeでのカスタム#[global_allocator]ラッパーを備えた人工ループ内で実行されます。これらのns/opとB/opの数値は、変更されていないリリースバイナリでも有効でしょうか?

確認するために、2つのスタンドアロンCLIバイナリを作成しました。

  1. 1つは、ベンチマークライブラリなしでRustのデフォルトSystemアロケータを使用しています。
  2. 2つ目はGoで作成されています。

各バイナリはNイテレーションを実行し、チェックサムを累積し、オプションで--retain Kを使用してK個のデコード済みメッセージを保持して、取り込みキューをシミュレートします。GNU /usr/bin/time -vでGOMAXPROCS=2(Goの-cpu 2に合わせる)を使用して両方を実行しました。

cargo build --release --bin verify_decode
/usr/bin/time -v ./target/release/verify_decode --case rw2_2000 --iters 1000 --retain 0
/usr/bin/time -v ./target/release/verify_decode --case rw2_2000 --iters 1000 --retain 1000

/usr/bin/time -vがすべての実行で報告した内容は次のとおりです。

RunItersMicrobenchBinary wallTotal CPUCPU %Max RSS
Rust varints500,0004.90 µs5.74 µs2.89s (5.78 µs/op)99%2.44 MiB
Go varints500,0008.54 µs8.64 µs4.34s (8.68 µs/op)100%6.11 MiB
Rust rw2_20001,0002.03 ms2.22 ms2.22s (2.22 ms/op)99%4.21 MiB
Go rw2_20001,0002.47 ms2.97 ms4.28s (4.28 ms/op)144%15.98 MiB
Rust rw2_2000 --retain 10001,0002.03 ms2.75 ms3.18s (3.18 ms/op)99%1,292.4 MiB

プロセスレベルの数値は、いくつかの重要な詳細を示しています。

  • CountingAllocはレイテンシを歪めませんでした。 スタンドアロンRustバイナリは、benchfmt::Suiteの2.03 ms/opに近い2.22 ms/opでrw2_2000を1,000回のコールドスタートイテレーションで実行しましたが、イテレーションごとに16,490個の割り当てをカウントしていました。
  • B/opはライブメモリではなく、アロケータのチャーンを測定します。 rw2_2000の1,000イテレーション全体で、Rustはアロケータから2.00 GiBを要求しましたが、retain=0でのピークRSSは4.21 MiBに留まりました。ループの終わりに各Requestをドロップすると、glibcは次のイテレーションでスレッドキャッシュチャンクを再利用でき、Linuxに新しいページを要求することなく処理できます。--retain 1000で1,000バッチすべてを保持すると、RSSは1,292.4 MiB(一時的なSnappyおよびVec二重化バッファが解放された後の、保持されたメッセージあたり1.29 MiB)に増加し、CPUはページフォールトのために2.12sユーザー時間 + 1.06sカーネルsys時間に分割されました。
  • 壁時計ns/opはGoのバックグラウンドGCコストを隠します。 benchstatでは、Rustはrw2_2000でGoより17.68%高速でした。/usr/bin/time -vでGOMAXPROCS=2を使用すると、Rustはコアの99%と4.21 MiBのRSSを使用しましたが、GoはCPU時間の144%、オペレーションあたり4.28 ms、RSS15.98 MiBに達しました。24コアすべてでGOMAXPROCSを無制限にすると、GoはCPU時間の220%(オペレーションあたり7.13 ms)とRSS18.53 MiBに増加しました。プロセス全体で、Rustはリクエストあたり1.9倍から3.2倍少ないCPUと3.8倍から4.4倍少ないRAMを使用しました。

まとめ

一般的に、RustはこのAI時代に真の可能性を秘めていると思います。

  • 極端なコンパイル時検証(厳格な所有権とメモリ安全性チェックを含む)を備えており、AIがコードを生成する際に理想的です。
  • 手で書くには時間がかかり、煩わしいこともありますが(すみません!)、AIは今日多くのコードを書いています。コードはレビューのために読みやすい必要がありますが、Rustはその点で十分です。
  • arrow-rsのようなクレートを中心とした強力な列指向ストレージエコシステムを持っています。
  • ガベージコレクタがなく、メモリ割り当ての制御がより多く、明らかに高速です!

ベンチマークに関しては、Rustに両方のbenchfmtツールがあることで、ジョブに合わせてワークフローを選択できます。

  1. コミット間または言語間でbenchstat A/Bテーブルが必要で、ベンチマークコードを編集したくない場合は、既存のCriterionスイートでbenchfmt criterion target/criterionを実行します。
  2. 新しいRustマイクロベンチマークには、benchfmt::SuiteとCountingAllocをfeatures = ["pprof"]で使用して、高速なstd-のみのコンパイル、定常状態エポック、B/opとallocs/opの追跡、およびpprofプロファイルを取得します。

benchfmt.rsを試してみて、GitHubで何が壊れているか、または何が欠けているか教えてください!