Go言語のベンチマークでパフォーマンス測定

はじめに

Goのtestingパッケージにはコードをベンチマークするための仕組みが備わっています。
ベンチマークは実装が容易なのと、実行も go test コマンドで行うことができるため簡単に使用できます。

今回はそのベンチマークの測定方法を書きます。

ベンチマーク

ベンチマークといっても対象となる実装がないと測定できないので、今回は以前書いた「Go言語 (Tour of Go) で数値解析 〜ニュートン法〜」から平方根を求める関数を対象にします。

func Sqrt(x float64) float64 {
	z := 1.0
	for i := 0; i < 10; i++ {
		z = z - (z*z-x)/(2*z)
	}
	return z
}

このSqrt関数と、mathパッケージの math.Sqrt関数の性能を比較しようと思います。

他に、メモリについて(無理やり)性能比較するためにFibonacci関数も用意しています。


go-benchmark/math: https://github.com/eure/go-benchmark/tree/master/math
go-benchmark/fibonacci https://github.com/eure/go-benchmark/tree/master/fibonacci

書き方 – BenchmarkXxx

テストコードを書くときと同様に、ファイル名を「foo_test.go」の様にして作成します。
ベンチマークのテストケースとして認識させるには BenchmarkXxx(*testing.B) のようにBenchmarkプレフィックスを付けて、引数に*testing.Bを渡す関数を実装します。

今回のベンチマークでは「2の平方根を求める」ベンチを行うため、下記の実装をしました。

// ユーザ定義のSqrt関数
func BenchmarkSqrt(b *testing.B) {
	for n := 0; n < b.N; n++ {
		Sqrt(2)
	}
}

// mathパッケージのSqrt関数
func BenchmarkMathSqrt(b *testing.B) {
	for n := 0; n < b.N; n++ {
		math.Sqrt(2)
	}
}

テストコードの書き方とほとんど似ています。
コード中にしれっと使用している b.N ですが、これは対象となるコードのベンチマークが信頼得る分まで増量して測定を行ってくれます。

実行方法 – go test -bench

実行は go test コマンドに -bench フラグを与えて実行するだけです。

$ go test -bench .
PASS
BenchmarkSqrt           30000000                51.8 ns/op
BenchmarkMathSqrt       2000000000               0.36 ns/op
ok      github.com/eure/go-benchmark/math       2.373s

他にも様々なオプションを指定して実行することもできます。

-benchmem

このオプションはベンチマークの際にメモリアロケーションに関する情報も出力してくれます。速度以外のパフォーマンス・チューニングを行う際は必ず付けてベンチ実行したほうがいいでしょう。

-benchtime

実行する時間です。デフォルトは1秒なので、ベンチ実行時間を10秒に変更したい場合は -benchtime 10s を与えて実行してください。


他のオプションは go help testflag で確認することができます。

速度比較

比較方法は実行した後の出力結果から簡単に判断することができます。(他にもプロファイリング等あります。)

BenchmarkSqrt           30000000                51.8 ns/op
BenchmarkMathSqrt       2000000000               0.36 ns/op
  • BenchmarkSqrt(ユーザ定義のSqrt関数のベンチ)
    • 「30,000,000回」のループを実施
    • 一回のループに平均「51.8ns」の実行時間
  • BenchmarkMathSqrt(math.Sqrt関数のベンチ)
    • 「2,000,000,000回」のループを実施
    • 一回のループに平均「0.36ns」の実行時間

ループの回数はb.Nが決定(testingパッケージの方で)しているので、1回のループの平均時間でパフォーマンスを比較します。
今回だとmathパッケージのSqrt実装の方がかなり高速で処理を完了することができていることがわかります。さすがです。

メモリ効率比較

平方根を求める関数ではメモリ確保は特にしていなので、goroutineで処理を平行化したFibonacci関数でベンチマークを測定したいと思います。

$ go test -bench . -benchmem
PASS
BenchmarkFibonacciLoop                          100000000               12.2 ns/op             0 B/op          0 allocs/op
BenchmarkFibonacciRecursive                      3000000               436 ns/op               0 B/op          0 allocs/op
BenchmarkFibonacciRecursiveGoRoutine             1000000              1815 ns/op             192 B/op          2 allocs/op
BenchmarkFibonacciRecursiveContinuousGoRoutine           10000            130769 ns/op           16896 B/op        176 allocs/op
ok      github.com/eure/go-benchmark/fibonacci  6.255s

※ メモリに関する出力が見えない場合は上の出力をスクロールしてください。

  • BenchmarkFibonacciLoop
    • For文でフィボナッチ数の計算をベンチ
  • BenchmarkFibonacciRecursive
    • 再帰関数でフィボナッチ数の計算をベンチ
  • BenchmarkFibonacciRecursiveGoRoutine
    • 再帰関数をgoroutineで分けてフィボナッチ数の計算をベンチ
  • BenchmarkFibonacciRecursiveContinuousGoRoutine
    • 再帰関数を全てgoroutineで分けてフィボナッチ数の計算をベンチ

※ 実装はこちらへ

今回は下2つのベンチマーク用の関数がgoroutineを使っており、その関数は下記のようにchannelを作成しています。

func FibonacciRecursiveContinuousGoRoutine(n int) int {
	// ...
	chf1, chf0 := make(chan int), make(chan int)
	go func(f chan int) { f <- FibonacciRecursiveContinuousGoRoutine(n - 1) }(chf1)
	go func(f chan int) { f <- FibonacciRecursiveContinuousGoRoutine(n - 2) }(chf0)
	// ...
	return
}

上2つは除いたベンチ結果;

BenchmarkFibonacciRecursiveGoRoutine             1000000              1815 ns/op             192 B/op          2 allocs/op
BenchmarkFibonacciRecursiveContinuousGoRoutine           10000            130769 ns/op           16896 B/op        176 allocs/op

※ メモリに関する出力が見えない場合は上の出力をスクロールしてください。

-benchmem オプションを付けて実行すると確保したメモリ領域とアロケーションの回数が出力されます。それを基に上の結果から比較すると

  • BenchmarkFibonacciRecursiveGoRoutine
    • 一回のループに平均「192B」のメモリ領域確保
    • 一回のループに平均「2回」のメモリアロケーション
  • BenchmarkFibonacciRecursiveContinuousGoRoutine
    • 一回のループに平均「16896B」のメモリ領域確保
    • 一回のループに平均「176回」のメモリアロケーション

今回の2つの関数は無理やりメモリ確保しているので、使用する価値が正直無いのですが、使うなら「FibonacciRecursiveGoRoutine」の方が有効です。

goroutineもただ愚直に使うのではなく、適した場所に使用することを心がけましょう。

余談:再帰は綺麗だけどパフォーマンスは落ちる

組み込み系だと再帰式の使用は大方禁止されています。再帰は関数がスタックに積まれるのと関数オーバーヘッドによるコストが高いからですね。

Forループのフィボナッチ数
// FibonacciLoop returns a fibonacci value.
// The function is using loop to calculate.
func FibonacciLoop(n int) int {
	if n < 2 {
		return n
	}
	f1, f0 := 1, 0
	fn := f1 + f0
	for i := n; i >= 2; i-- {
		fn = f1 + f0
		f1, f0 = fn, f1
	}
	return fn
}
  • BenchmarkFibonacciLoop
    • 「100,000,000回」のループ
    • 「12.2 ns」の実行平均時間
再帰関数のフィボナッチ数
// FibonacciRecursive returns a fibonacci value
// The function is recursively defined to calculate.
func FibonacciRecursive(n int) int {
	if n < 2 {
		return n
	}
	return FibonacciRecursive(n-1) + FibonacciRecursive(n-2)
}
  • BenchmarkFibonacciRecursive
    • 「3,000,000回」のループ
    • 「436 ns」の実行平均時間

CPU、メモリが潤沢に使えるなら保守性を考慮して再帰関数を使うのは構わないと思いますが、こういった面をパフォーマンスチェックするのは面白いです

おわりに

テストコード書くノリと同じでベンチマークを記述することができるので、試したことがない方は自分が書いた関数の性能比較をすると意外とパフォーマンスが悪いことがわかったりします。
プロダクションコードにtimeパッケージを使って時間を測定することもできますが、標準としてベンチマークができるので積極的に使い、効率の良いコードにしていきましょう。

参考

  • このエントリーをはてなブックマークに追加

Recommend

RaspberryPi×Go言語で電子工作

pairs開発責任者が考える「プロダクト・マネジメント」に必要な5つの資質