Go college day4に参加してまいりました。今回の内容はパフォーマンスチューニングや単体テスト周りについて勉強しました。
私はgo test で単体テストを書くことはあっても、ベンチマークは今までちゃんと触ったことはあまりありませんでした。
ただ、パフォーマンスを見たいときに毎回 time.Now() で雑に測るだけだと、比較としてかなり弱いです。
そこで今回は、前回実際に書いたRaw SQL vs GOR の比較コードを使いながら、Goのベンチマークは何ができて、どう読むと勉強になるのかを整理してみました。
今回やりたいのは、アルゴリズムの理論比較というより、実装の違いがどの程度パフォーマンスに影響するのかを見ることです。
そのために見る指標は以下にしました。
-
スループット
-
1回の処理あたりの時間
-
メモリアロケーション量
今回使うコードはこちらです。
5つのケースを計測する構成ですね。
そもそもGoのベンチマークとは何か
Goでは、*_test.go に BenchmarkXxx(*testing.B) という関数を書くと、go test -bench でベンチマークを実行できます。
testing パッケージのベンチマークは、対象処理を複数回実行して、ns/op のような形で結果を出してくれます。公式ドキュメントでも、ベンチマークは go test で実行する前提になっています。
たとえば基本形はこうです。
func BenchmarkSomething(b *testing.B) {
for i := 0; i < b.N; i++ {
// 計測したい処理
}
}
この b.N は自分で決める回数ではなく、十分に安定した測定になるまで testing パッケージ側が調整してくれる回数です。
つまり1回だけ測るのではなくて、信頼しやすい数字になるまで何度も回してくれます。
今回のコードはどういう構成か
今回の benchmark_test.go では、最初のセットアップで以下をやっています。
1.MySQL に接続
2.テーブル作成
3.初回のみシード投入
4.sql.DB と gorm.DB を両方準備
5.ランダムに使う user_id / post_id のプールを作る
つまり、ただのマイクロベンチマークではなく、ある程度実運用に近いDBアクセスを比較するための土台を作っています。
対象ユースケースはこの5つです。
-
GetUserByID
-
ListPostsByUser
-
ListPostsWithCommentsCount
-
CreatePostWith2CommentsTx
-
UpdatePostStatus
この切り方が勉強になる理由は、単発のRead、複数件Read、Join集計、トランザクション付きWrite、単純Updateと、支配要因が変わるケースをちゃんと分けているからです。
今回のコードで勉強になるポイント1
- b.Run でケースを切ると比較しやすい
たとえばBenchmarkGetUserByID では、1つのベンチマーク関数の中でraw_sqlとgormの2つを b.Run で分けています。
この形の良いところは、同じ前提条件・同じIDプールで2実装を比較できることです。
b.Run("raw_sql", func(b *testing.B) {
...
})
b.Run("gorm", func(b *testing.B) {
...
})
これをやらずに別のベンチ関数に分けることもできますが、今回みたいなA/B比較ではサブベンチにした方が見やすいです。
単純に「比較の土俵をそろえやすい」という意味で、かなり実務的だと思いました。
今回のコードで勉強になるポイント2
- ReportAllocs を必ず見る
今回のリポジトリでは、各ケースで b.ReportAllocs() を呼んでいます。
これはメモリアロケーション統計を出すためのもので、-benchmem と同じ方向の情報を出す設定です。公式ドキュメントでも、ReportAllocs は malloc 統計を有効化するものと説明されています。
ベンチマーク結果を見ていると、初心者のうちは ns/op だけを見がちです。
でも実際には、B/op、allocs/opもかなり重要です。
特にORM比較みたいなケースだと、速度差がそこまで大きくなくても、allocs/op はかなり差が出ることがあります。
この差は、後で高頻度アクセスやGC負荷に効いてきます。だから 速いか遅いか だけで終わると、かなり浅い見方になります。
今回のコードで勉強になるポイント3
独自指標を載せられる
このコードでは reportOpsPerSec という関数を用意して、b.Elapsed() と b.N から ops/s を計算して出しています。
Goの testing.B には ReportMetric があり、独自メトリクスをベンチ結果に載せられます。
ベンチマークを書くときの注意点
- ベンチ対象以外をなるべく混ぜない
ベンチマークで見たいのがSQL実行なのに、毎回ランダム生成や巨大な前処理を入れると、何を測っているのか分からなくなります。
今回のコードでは、初期セットアップやIDプール作成を先にやっていて、ベンチループの中にはなるべく比較対象の処理を置いています。これはかなり大事です。
- 1ケースだけで一般化しない
GetUserByID だけ見て「やっぱりORMは全部遅い」と言うのは雑です。
逆に JOIN + GROUP BY だけ見て「差はない」と言うのも雑です。
今回のコードが良いのは、Read/Write/集計/トランザクションを分けているところです。
ベンチマークは、都合のいい1ケースで自説を補強する道具にすると一気に価値が下がると思います。
まとめ
今回ベンチマークを触ってみて思ったのは、Goのベンチマークは単に「何秒でした」を出すものではなくて、
-
実装差を比較する
-
アロケーション差を見る
-
ユースケースごとに支配要因が変わることを確認する
-
独自メトリクスまで含めて解釈しやすくする
ための道具だということでした。
特に今回の Raw SQL vs GORM のコードは、
b.Run でA/B比較している
ReportAllocs を見ている
ops/s を追加している
単発Readからトランザクションまでケースを分けている
ので、Goのベンチマークを勉強する題材としてかなり良いと思いました。