Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 18343] ファイルの概要

このコミットは、Go言語の標準ライブラリであるbufioパッケージ内のベンチマークの振る舞いを修正するものです。具体的には、ベンチマークがtestingパッケージに対して不正確な情報を提供していた問題を解決し、より正確なパフォーマンス測定を可能にしています。

コミット

commit 0ad2cd004c94c664dfee3ff16e9d587467f99883
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Thu Jan 23 15:13:21 2014 -0500

    bufio: fix benchmarks behavior
    Currently the benchmarks lie to testing package by doing O(N)
    work under StopTimer. And that hidden O(N) actually consitutes
    the bulk of benchmark work (e.g includes GC per iteration).
    This behavior accounts for windows-amd64-race builder hangs.
    
    Before:
    BenchmarkReaderCopyOptimal-4     1000000              1861 ns/op
    BenchmarkReaderCopyUnoptimal-4    500000              3327 ns/op
    BenchmarkReaderCopyNoWriteTo-4     50000             34549 ns/op
    BenchmarkWriterCopyOptimal-4      100000             16849 ns/op
    BenchmarkWriterCopyUnoptimal-4    500000              3126 ns/op
    BenchmarkWriterCopyNoReadFrom-4    50000             34609 ns/op
    ok      bufio   65.273s
    
    After:
    BenchmarkReaderCopyOptimal-4    10000000               172 ns/op
    BenchmarkReaderCopyUnoptimal-4  10000000               267 ns/op
    BenchmarkReaderCopyNoWriteTo-4    100000             22905 ns/op
    BenchmarkWriterCopyOptimal-4    10000000               170 ns/op
    BenchmarkWriterCopyUnoptimal-4  10000000               226 ns/op
    BenchmarkWriterCopyNoReadFrom-4   100000             20575 ns/op
    ok      bufio   14.074s
    
    Note the change in total time.
    
    LGTM=alex.brainman, rsc
    R=golang-codereviews, alex.brainman, rsc
    CC=golang-codereviews
    https://golang.org/cl/51360046

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/0ad2cd004c94c664dfee3ff16e9d587467f99883

元コミット内容

このコミットの元の内容は、bufioパッケージのベンチマークがtestingパッケージに対して不正確な測定結果を報告していたというものです。具体的には、ベンチマークの各イテレーションにおいて、b.StopTimer()b.StartTimer()の間にO(N)の作業(例えば、バッファの初期化やGCの発生など)が含まれており、これがベンチマークの真の実行時間を歪めていました。この不正確な測定は、特にwindows-amd64-raceビルダのハングアップを引き起こす原因となっていました。

コミットメッセージには、修正前と修正後のベンチマーク結果が示されており、修正後にはns/op(1操作あたりのナノ秒)の値が大幅に改善され、全体の実行時間も短縮されていることが強調されています。

変更の背景

Go言語のベンチマークは、testingパッケージが提供するBenchmark関数とtesting.B型を使用して記述されます。testing.Bには、ベンチマーク対象のコードの実行時間を正確に測定するためのStartTimer()StopTimer()メソッドがあります。これらのメソッドは、ベンチマークのセットアップやクリーンアップなど、測定対象外の処理を時間計測から除外するために使用されます。

しかし、このコミットが修正しようとしている問題は、ベンチマークコードがStopTimer()StartTimer()の間に、実際には測定対象となるべき、あるいは測定結果に影響を与えるような処理(O(N)の作業)を含んでいたことです。この「隠れた作業」には、各イテレーションでのメモリ割り当てやガベージコレクション(GC)が含まれていました。

このような不正確なベンチマークは、以下のような問題を引き起こします。

  1. 誤解を招くパフォーマンスデータ: 開発者はベンチマーク結果を見て、実際のパフォーマンスよりも良い、あるいは悪いと誤解する可能性があります。これにより、最適化の優先順位付けや設計判断が誤る可能性があります。
  2. CI/CDパイプラインの不安定化: windows-amd64-raceビルダのハングアップという具体的な問題が示されているように、ベンチマークの実行時間が予期せず長くなったり、リソースを過剰に消費したりすることで、継続的インテグレーション/デリバリー(CI/CD)パイプラインが不安定になることがあります。特にrace検出器が有効な環境では、メモリ割り当てやGCのオーバーヘッドが顕著になり、問題が表面化しやすくなります。
  3. 最適化の妨げ: 真のボトルネックが隠蔽されるため、開発者がパフォーマンス改善のための適切な箇所を特定しにくくなります。

このコミットは、これらの問題を解決し、bufioパッケージのベンチマークがより信頼性の高いパフォーマンスデータを提供するようにすることを目的としています。

前提知識の解説

Go言語のベンチマーク

Go言語では、go testコマンドを使用してユニットテストだけでなくベンチマークも実行できます。ベンチマーク関数はBenchmarkXxx(*testing.B)というシグネチャを持ち、testing.B型の引数を受け取ります。

  • b.N: ベンチマーク関数内のループで実行されるイテレーション回数を示します。testingパッケージが自動的に調整し、統計的に有意な結果が得られるようにします。
  • b.ResetTimer(): タイマーをリセットします。通常、ベンチマークループの直前に呼び出され、セットアップコードの時間を測定から除外します。
  • b.StartTimer() / b.StopTimer(): これらのメソッドは、ベンチマークループ内で特定のコードブロックの時間を測定対象に含めたり、除外したりするために使用されます。StopTimer()が呼び出されるとタイマーが一時停止し、StartTimer()が呼び出されると再開します。

ベンチマークのベストプラクティスとして、測定対象のコードのみがb.Nループ内に存在し、かつタイマーが開始されている間に実行されるようにすることが重要です。セットアップやクリーンアップなど、各イテレーションで繰り返されるが測定対象ではない処理は、b.StopTimer()b.StartTimer()の間に配置するか、ループの外に配置する必要があります。

io.Copy関数

io.Copy(dst Writer, src Reader)は、Go言語のioパッケージで提供されるユーティリティ関数で、srcからdstへデータをコピーします。この関数は、内部的にsrcio.WriterToインターフェースを実装している場合、またはdstio.ReaderFromインターフェースを実装している場合に、より効率的なパス(ゼロコピーなど)を使用しようとします。そうでない場合は、内部バッファを使用してデータを読み書きします。

bufioパッケージ

bufioパッケージは、I/O操作をバッファリングすることで効率を向上させるための機能を提供します。bufio.Readerbufio.Writerは、それぞれio.Readerio.Writerをラップし、内部バッファを介して読み書きを行うことで、システムコールを減らし、パフォーマンスを改善します。

bytes.Buffer

bytes.Bufferは、可変長のバイトバッファを実装する型です。io.Readerおよびio.Writerインターフェースを実装しており、メモリ内でデータを効率的に操作するのに便利です。

windows-amd64-raceビルダ

GoプロジェクトのCI/CD環境では、様々なプラットフォームや設定でテストが実行されます。windows-amd64-raceは、Windows 64-bit環境でデータ競合検出器(raceフラグ)を有効にしてビルドおよびテストを実行するビルダを指します。race検出器は、並行処理におけるデータ競合を検出するために追加のインストルメンテーションを行うため、通常よりも多くのメモリやCPUリソースを消費し、実行時間が長くなる傾向があります。そのため、ベンチマークの不正確さがこの環境で特に問題として顕在化しやすかったと考えられます。

技術的詳細

このコミットの技術的な核心は、Goのベンチマークにおけるb.StopTimer()b.StartTimer()の誤用を修正することにあります。以前のベンチマークコードでは、各イテレーションの開始時にb.StopTimer()を呼び出し、その間にsrc(ソースリーダー)とdst(デスティネーションライター)の新しいインスタンスを作成していました。その後、b.StartTimer()を呼び出してio.Copyの実行時間を測定していました。

問題は、srcdstのインスタンス作成が、単なるポインタの割り当てではなく、内部的にバッファの割り当てや初期化といったO(N)の作業を伴っていたことです。特にbytes.NewBuffer(make([]byte, 8192))のように、毎回新しい8KBのバッファを割り当てていたため、これがGCの頻度を増やし、ベンチマークの実行時間に大きな影響を与えていました。b.StopTimer()で囲まれているにもかかわらず、これらの操作がベンチマークの総実行時間に寄与し、結果としてns/opの値が不正確になっていました。

修正後のコードでは、srcdst、およびそれらの内部バッファ(srcBuf, dstBuf)のインスタンスをベンチマークループの外側で一度だけ作成しています。そして、各イテレーションの開始時には、これらの既存のインスタンスをReset()メソッドを使って再利用しています。

例えば、BenchmarkReaderCopyOptimalの変更を見てみましょう。

修正前:

func BenchmarkReaderCopyOptimal(b *testing.B) {
	// Optimal case is where the underlying reader implements io.WriterTo
	for i := 0; i < b.N; i++ {
		b.StopTimer()
		src := NewReader(bytes.NewBuffer(make([]byte, 8192))) // O(N) work here
		dst := onlyWriter{new(bytes.Buffer)}                 // O(N) work here
		b.StartTimer()
		io.Copy(dst, src)
	}
}

修正後:

func BenchmarkReaderCopyOptimal(b *testing.B) {
	// Optimal case is where the underlying reader implements io.WriterTo
	srcBuf := bytes.NewBuffer(make([]byte, 8192)) // Setup outside loop
	src := NewReader(srcBuf)                      // Setup outside loop
	dstBuf := new(bytes.Buffer)                   // Setup outside loop
	dst := onlyWriter{dstBuf}                     // Setup outside loop
	for i := 0; i < b.N; i++ {
		srcBuf.Reset()  // Reset existing buffers
		src.Reset(srcBuf) // Reset existing readers/writers
		dstBuf.Reset()
		io.Copy(dst, src) // Only io.Copy is measured
	}
}

この変更により、各イテレーションで新しいメモリ割り当てやGCが発生するのを防ぎ、io.Copy自体のパフォーマンスのみが正確に測定されるようになりました。Reset()メソッドは、既存のバッファやリーダー/ライターの状態を初期化するだけで、新しいメモリを割り当てないため、オーバーヘッドが大幅に削減されます。

結果として、ns/opの値が劇的に改善され、ベンチマークの総実行時間も大幅に短縮されました。これは、以前のベンチマークがio.Copyの性能だけでなく、その前処理(バッファの初期化など)のコストも誤って含んでいたことを明確に示しています。

コアとなるコードの変更箇所

変更はsrc/pkg/bufio/bufio_test.goファイルに集中しています。

具体的には、以下のベンチマーク関数が修正されています。

  • BenchmarkReaderCopyOptimal
  • BenchmarkReaderCopyUnoptimal
  • BenchmarkReaderCopyNoWriteTo
  • BenchmarkWriterCopyOptimal
  • BenchmarkWriterCopyUnoptimal
  • BenchmarkWriterCopyNoReadFrom

これらの関数において、b.StopTimer()b.StartTimer()の呼び出しが削除され、代わりにベンチマークループの外部で必要なbytes.Bufferbufio.Reader/bufio.Writerのインスタンスが初期化されています。そして、ループの各イテレーション内で、これらのインスタンスのReset()メソッドが呼び出され、再利用されるようになっています。

変更の概要:

  • b.StopTimer()b.StartTimer()の削除。
  • ベンチマークループの前に、srcBuf, src, dstBuf, dstなどの変数を宣言し、一度だけ初期化。
  • ベンチマークループの各イテレーション内で、srcBuf.Reset(), src.Reset(), dstBuf.Reset(), dst.Reset()などを呼び出して、既存のオブジェクトを再利用し、状態をリセット。

コアとなるコードの解説

各ベンチマーク関数で行われた変更は、基本的に同じパターンに従っています。

例えば、BenchmarkReaderCopyOptimalでは、io.Copyの最適なケース(io.WriterToを実装するリーダー)を測定します。

修正前: 各イテレーションで新しいbufio.ReaderonlyWriter(内部にbytes.Bufferを持つ)が作成されていました。

func BenchmarkReaderCopyOptimal(b *testing.B) {
	for i := 0; i < b.N; i++ {
		b.StopTimer() // タイマー停止
		// ここで新しいReaderとWriterが作成され、メモリが割り当てられる
		src := NewReader(bytes.NewBuffer(make([]byte, 8192)))
		dst := onlyWriter{new(bytes.Buffer)}
		b.StartTimer() // タイマー再開
		io.Copy(dst, src)
	}
}

修正後: ループの前にsrcBuf, src, dstBuf, dstが一度だけ初期化されます。ループ内では、これらの既存のオブジェクトがReset()メソッドで再利用されます。

func BenchmarkReaderCopyOptimal(b *testing.B) {
	srcBuf := bytes.NewBuffer(make([]byte, 8192)) // ループ外で一度だけバッファを確保
	src := NewReader(srcBuf)                      // ループ外で一度だけReaderを確保
	dstBuf := new(bytes.Buffer)                   // ループ外で一度だけバッファを確保
	dst := onlyWriter{dstBuf}                     // ループ外で一度だけWriterを確保
	for i := 0; i < b.N; i++ {
		srcBuf.Reset()  // 既存のsrcBufをリセット
		src.Reset(srcBuf) // 既存のsrcをリセット(内部バッファもリセットされる)
		dstBuf.Reset()  // 既存のdstBufをリセット
		// b.StopTimer() / b.StartTimer() は不要になった
		io.Copy(dst, src) // io.Copyのみが測定対象となる
	}
}

bufio.Readerbufio.WriterReset()メソッドは、内部バッファを再利用し、読み書き位置をリセットする機能を提供します。bytes.BufferReset()も同様に、バッファの内容をクリアし、読み書き位置を先頭に戻しますが、基盤となるメモリは解放しません。これにより、各イテレーションでのメモリ割り当てとGCのオーバーヘッドが排除され、io.Copyの純粋なパフォーマンスが測定されるようになります。

この修正は、Goのベンチマークを記述する際の重要なベストプラクティスを示しています。すなわち、ベンチマークの測定対象となる操作以外の、各イテレーションで繰り返されるセットアップ処理は、可能な限りループの外で行うか、Reset()のようなメソッドを使って既存のリソースを再利用すべきであるということです。これにより、ベンチマーク結果の正確性と信頼性が向上します。

関連リンク

参考にした情報源リンク

  • Go言語のベンチマークに関する公式ドキュメントやブログ記事 (一般的なベンチマークのベストプラクティスについて)
  • Go言語のtesting.BStopTimer()StartTimer()の挙動に関する情報
  • Go言語のio.Copyの内部実装とパフォーマンス特性に関する情報
  • Go言語のガベージコレクションとベンチマークへの影響に関する情報
  • Go言語のCI/CD環境、特にrace検出器の挙動に関する情報

(注:具体的なURLは、Web検索の結果に基づいて補完されるべきですが、ここでは一般的な情報源の種類を記載しています。)