Goでアロケーションに気をつけたコードを書く方法
GoはPythonのようなLLと比べると実行速度は速いのですが、GCは特別速いわけではないので、相対的にGCがパフォーマンスに与える影響は大きくなります。
また、Java に比べると、一時オブジェクトなどのために頻繁にヒープアロケーションを行うとGCの停止時間が長くなりがちですが、一方でヒープアロケーションを避けたプログラミングがしやすい言語でもあります。
MySQL ドライバのような低レイヤーのライブラリを作る場合、アプリケーション側の性能要件を勝手に決めることができないので、現実的な範囲でアロケーションを減らす努力をするべきです。
ということで、前回の記事 で紹介したプレースホルダ置換を実装するにあたって経験した、アロケーションに気を使ったプログラミングについて、チューニングする手順やコード上のテクニックを紹介したいと思います。
1. まずは正しく動くものを作る
go-sql-driver/mysql のメンテナに受け入れられるかどうか分からない機能だったので、最初はなるべく簡単に実装して、この機能を取り込むかどうか、APIや設計はこれでいいかという話から始めます。
2. ベンチマークプログラムを書く
チューニングを始める前に、効果確認用のベンチマークプログラムを書きます。 テストと同じく _test.go という名前で終わるファイルに、 BenchmarkXxxx(b *testing.B) という関数を定義します。その中で b.ReportAllocs() を呼んでおくとアロケート回数を計測することができます。
今回作成したベンチマークプログラムは次のようなものです。 database/sql がユーザーが渡した値を正規化してくれるので、ドライバ側に渡される値は int64, float64, bool, time.Time, []byte, string の6つの型だけになります。そこで6つの方の値を1つずつ用意して、プレースホルダ置換を実行しています。
func BenchmarkInterpolation(b *testing.B) {
mc := &mysqlConn{
cfg: &config{
interpolateParams: true,
loc: time.UTC,
},
maxPacketAllowed: maxPacketSize,
maxWriteSize: maxPacketSize - 1,
}
args := []driver.Value{
int64(42424242),
float64(math.Pi),
false,
time.Unix(1423411542, 807015000),
[]byte("bytes containing special chars ' \" \a \x00"),
"string containing special chars ' \" \a \x00",
}
q := "SELECT ?, ?, ?, ?, ?, ?"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := mc.interpolateParams(q, args)
if err != nil {
b.Fatal(err)
}
}
}
実行するには次のようなコマンドを実行します。
$ go test -bench=BenchmarkInterpolation PASS BenchmarkInterpolation 300000 3887 ns/op 1144 B/op 15 allocs/op ok github.com/go-sql-driver/mysql 2.386s
3. アロケーションが起こっている場所を調べる
GODEBUG=allocfreetrace=1 という環境変数を設定すると、アロケートが起こった場所のスタックトレースを見ることができます。 (ドキュメント)
そこで、ベンチマークプログラムのループの中身だけを実行する小さなプログラムを用意したいのですが、今回用意しているのは小文字から始まる名前の内部処理なので、 main パッケージから直接呼び出すことはできません。 mysql パッケージにコミットに含めないソースファイルを追加してそこから呼び出せば良いのですが、今回はベンチマークプログラムを直接利用することにしました。
GODEBUG を設定したまま go test を実行するとノイズが多すぎるので、先に go test -c を使って mysql.test という実行ファイルを作り、それを実行します。その際、オプションは go test に渡すオプションに test. という prefix をつけることになります。
$ go test -c $ GODEBUG=allocfreetrace=1 ./mysql.test -test.run=none -test.bench=BenchmarkInter -test.benchtime=10ms 2>trace.log PASS BenchmarkInterpolation 5000 4095 ns/op 1144 B/op 15 allocs/op
この例では、 -test.run にどのテストにもマッチしない適当な名前 none を指定してベンチマーク以外のテストが実行されることを防ぎ、 -test.bench で目的以外のベンチマーク関数の実行を抑止し、 -test.benchtime=10ms でデフォルト1秒のベンチマーク時間を短くしています。標準エラー出力にアロケートのスタックトレースが出力されているので、それを trace.log に出力しています。
目的の関数である interpolateParams を探し、それより手前を削除します。すると次のようなスタックトレースが得られます。
tracealloc(0xc2080100e0, 0x70, string) goroutine 5 [running]: runtime.mallocgc(0x70, 0x22e4e0, 0x0, 0x2) /usr/local/Cellar/go/1.4.1/libexec/src/runtime/malloc.go:327 +0x32a fp=0xc20802ea60 sp=0xc20802e9b0 runtime.newarray(0x22e4e0, 0x7, 0x15c5e) /usr/local/Cellar/go/1.4.1/libexec/src/runtime/malloc.go:365 +0xc1 fp=0xc20802ea98 sp=0xc20802ea60 runtime.makeslice(0x2229c0, 0x7, 0x7, 0x0, 0x0, 0x0) /usr/local/Cellar/go/1.4.1/libexec/src/runtime/slice.go:32 +0x15c fp=0xc20802eae0 sp=0xc20802ea98 strings.genSplit(0x30c190, 0x17, 0x2e1f10, 0x1, 0x0, 0x7, 0x0, 0x0, 0x0) /usr/local/Cellar/go/1.4.1/libexec/src/strings/strings.go:287 +0x14d fp=0xc20802eb60 sp=0xc20802eae0 strings.Split(0x30c190, 0x17, 0x2e1f10, 0x1, 0x0, 0x0, 0x0) /usr/local/Cellar/go/1.4.1/libexec/src/strings/strings.go:325 +0x76 fp=0xc20802ebb0 sp=0xc20802eb60 github.com/go-sql-driver/mysql.(*mysqlConn).interpolateParams(0xc20802eed0, 0x30c190, 0x17, 0xc20802ee70, 0x6, 0x6, 0x0, 0x0, 0x0, 0x0) /Users/inada-n/go1.4/src/github.com/go-sql-driver/mysql/connection.go:180 +0x86 fp=0xc20802ed38 sp=0xc20802ebb0 github.com/go-sql-driver/mysql.BenchmarkInterpolation(0xc20806a400) /Users/inada-n/go1.4/src/github.com/go-sql-driver/mysql/benchmark_test.go:240 +0x437 fp=0xc20802ef58 sp=0xc20802ed38 testing.(*B).runN(0xc20806a400, 0x1) /usr/local/Cellar/go/1.4.1/libexec/src/testing/benchmark.go:124 +0x95 fp=0xc20802ef68 sp=0xc20802ef58 testing.(*B).launch(0xc20806a400) /usr/local/Cellar/go/1.4.1/libexec/src/testing/benchmark.go:199 +0x78 fp=0xc20802efd8 sp=0xc20802ef68 runtime.goexit() /usr/local/Cellar/go/1.4.1/libexec/src/runtime/asm_amd64.s:2232 +0x1 fp=0xc20802efe0 sp=0xc20802efd8 created by testing.(*B).run /usr/local/Cellar/go/1.4.1/libexec/src/testing/benchmark.go:179 +0x3e ...(続く)
このスタックトレースで interpolateParams() から strings.Split() を呼び出している部分でアロケートが発生していることが分かります。
ソースコードのファイル名と行数も書かれているので、簡単に該当部分を特定できます。
func (mc *mysqlConn) interpolateParams(query string, args []driver.Value) (string, error) {
chunks := strings.Split(query, "?")
if len(chunks) != len(args)+1 {
return "", driver.ErrSkip
}
余談ですが、スタックトレースのような「たくさんの英数字」を見た時に、反射的に無視してエラーメッセージだけをコピペして質問する人をよく見かけます。確かに読むのに気力がいるかもしれませんが、スタックトレースはその言語やアプリケーションを深く知る重要な手がかりなので、デバッグ時やチューニングするときなどはしっかりと向きあいましょう。
4. チューニング
これで準備は万端です。あとは1つ1つ改善しながらで効果を確認していくだけです。とりあえず初期状態のベンチマーク結果を残しておきましょう。
$ go test -bench=BenchmarkInterpolation | tee old.txt PASS BenchmarkInterpolation 500000 3942 ns/op 1144 B/op 15 allocs/op ok github.com/go-sql-driver/mysql 3.219s
4.1. 文字列連結から []byte への append へ
最初の実装は、クエリ文字列を ? で分割して、 parts []string に分割された各クエリ文字列と、パラメータを文字列化したものを交互に格納し ("SELECT SLEEP(0)", 42 なら []string{"SELECT SLEEP(", "42", ")"})、最後に strings.Join(parts, "") で結合するという、LLで書くようなコードでした。
Go の文字列は immutable なので、小さい文字列をたくさんつくるこの方式ではアロケートが増えるのを避けられません。そこで、最初に []byte のバッファを確保してそこに追記していくようにしました。
バッファの容量は先にクエリ文字列と各パラメータから概算して確保することで、リアロケーションを避けます。整数や実数は strconv.AppendInt() という、 []byte に追記しつつ文字列化する関数が標準ライブラリに用意されているのでそれを使います。これで次のようなコードになりました。
buf := make([]byte, 0, estimated)
argPos := 0
// Go 1.5 will optimize range([]byte(string)) to skip allocation.
for _, c := range []byte(query) {
if c != '?' {
buf = append(buf, c)
continue
}
arg := args[argPos]
argPos++
if arg == nil {
buf = append(buf, []byte("NULL")...)
continue
}
switch v := arg.(type) {
case int64:
buf = strconv.AppendInt(buf, v, 10)
4.2. benchcmp
これで一旦効果を確認します。その際、 benchcmp というツールを使って比較し、それをコミットログに含めると Go のコミッタみたいでクールです。
$ go get -u golang.org/x/tools/cmd/benchcmp $ go test -bench=BenchmarkInterpolation > new.txt $ benchcmp old.txt new.txt benchmark old ns/op new ns/op delta BenchmarkInterpolation 3942 2573 -34.73% benchmark old allocs new allocs delta BenchmarkInterpolation 15 6 -60.00% benchmark old bytes new bytes delta BenchmarkInterpolation 1144 560 -51.05%
アロケート回数が 15 から 6 に減っているのが分かります。実行速度もメモリ消費も大幅に削減できていますね。
4.3. Time.Format() を避ける
int64 と float64 は strconv.AppendInt() と strconv.AppendFloat() でそれぞれ []byte に追記することが出来ましたが、残念ながら time.Time にはそのようなメソッドがありません。
そこで、ベッタベタなコードで文字列表現を作ります。
before:
case time.Time:
if v.IsZero() {
buf = append(buf, []byte("'0000-00-00'")...)
} else {
fmt := "'2006-01-02 15:04:05.999999'"
if v.Nanosecond() == 0 {
fmt = "'2006-01-02 15:04:05'"
}
s := v.In(mc.cfg.loc).Format(fmt)
buf = append(buf, []byte(s)...)
}
after:
case time.Time:
if v.IsZero() {
buf = append(buf, "'0000-00-00'"...)
} else {
v := v.In(mc.cfg.loc)
v = v.Add(time.Nanosecond * 500) // To round under microsecond
year := v.Year()
year100 := year / 100
year1 := year % 100
month := v.Month()
day := v.Day()
hour := v.Hour()
minute := v.Minute()
second := v.Second()
micro := v.Nanosecond() / 1000
buf = append(buf, []byte{
'\'',
digits10[year100], digits01[year100],
digits10[year1], digits01[year1],
'-',
digits10[month], digits01[month],
'-',
digits10[day], digits01[day],
' ',
digits10[hour], digits01[hour],
':',
digits10[minute], digits01[minute],
':',
digits10[second], digits01[second],
}...)
if micro != 0 {
micro10000 := micro / 10000
micro100 := micro / 100 % 100
micro1 := micro % 100
buf = append(buf, []byte{
'.',
digits10[micro10000], digits01[micro10000],
digits10[micro100], digits01[micro100],
digits10[micro1], digits01[micro1],
}...)
}
buf = append(buf, '\'')
}
after は最終盤のコードで、チューニング以外にマイクロ秒部分が0なら省略するなどの改善も入っています。
digits10, digits01 は除算を減らすために 0~99 の10の位と1の位の文字を並べたものです。これは今回用意したものではなくて既に mysql ドライバの他の箇所で利用されていた最適化を適用しました。
これでアロケートを2つ減らせました。
benchmark old allocs new allocs delta
BenchmarkInterpolation 6 4 -33.33%
4.4. 文字列に対して range を使わない
Go でループを使うときは良く range 構文を使うのですが、 string 型の s に対して for i, c := range s { とすると、バイト単位ではなく unicode の code point 単位でループしてしまいます。
for i, c := range([]byte(s)) { のように []byte にキャストしてループしていたのですが、ここで string から []byte への変換でアロケートとコピーが実行されてしまいます。 (Go 1.5 では最適化されます)
そこで、これを C 言語風の for 文に書き直します。
@@ -210,8 +210,8 @@ func (mc *mysqlConn) interpolateParams(query string, args []driver.Value) (strin
buf := make([]byte, 0, estimated)
argPos := 0
- // Go 1.5 will optimize range([]byte(string)) to skip allocation.
- for _, c := range []byte(query) {
+ for i := 0; i < len(query); i++ {
+ c := query[i]
if c != '?' {
これでもう1つアロケートを減らすことができました。
benchmark old allocs new allocs delta
BenchmarkInterpolation 4 3 -25.00%
4.5. []byte に string を直接 append する
Go の append には、 []T 型のスライス ts に T 型の要素 t を1つ追加する ts = append(ts, t) と、別の []T 型のスライス ts2 を結合する ts = append(ts, ts2...) の2つの使い方があります。
ただし []byte に string を追記する場合については、 string が []byte 型であるかのように直接 append を利用することができます。 buff = append(buff, s...)
アロケートを避けるために 1 バイトずつ append していた部分を改良したり、文字列リテラルをキャストしながら append していた部分(最適化されて余計なアロケートとコピーは発生しない)を簡潔にする事ができました。
@@ -210,17 +210,19 @@ func (mc *mysqlConn) interpolateParams(query string, args []driver.Value) (strin
argPos := 0
for i := 0; i < len(query); i++ {
- c := query[i]
- if c != '?' {
- buf = append(buf, c)
- continue
+ q := strings.IndexByte(query[i:], '?')
+ if q == -1 {
+ buf = append(buf, query[i:]...)
+ break
}
+ buf = append(buf, query[i:i+q]...)
+ i += q
arg := args[argPos]
argPos++
if arg == nil {
- buf = append(buf, []byte("NULL")...)
+ buf = append(buf, "NULL"...)
continue
}
今回はこれでアロケートを避けられる部分は無かったですが、 []byte に append していくコードを書くときに覚えておきたい Tips でした。
4.6. []bytes 用の関数と string 用の関数を別に用意する
[]byte 型の引数と string 型の引数は両方同じようにクォート、エスケープします。
しかし、残念ながら Go は []byte と string の間の変換でアロケート&コピーが発生してしまいます。
最適化で除去されるのは buf = append(buf, []byte("NULL")...) のようなシンプルなケースだけで、 string を引数に取る関数に []byte を string(bs) でキャストして渡す場合やその逆のケースでは現在の Go では最適化されません。
そこで、全く同じエスケープ処理を行う関数を、 []byte を引数に取るバージョンと string を引数に取るバージョンの2つ用意します。
せめてループ内の switch だけでも共通化できないかと思いましたが、インライン展開を行うしきい値となる大きさを超えてしまうようで性能が落ちてしまったので、殆どコピペな2つの関数を用意することになりました。
これは実行するべきか迷ったのですが、クエリ文字列に比べて、パラメータに渡される文字列/バイト列は BLOB や TEXT 型の大きなデータである可能性があるので、速度・アロケート共に妥協しないことにしました。
コピペコードはもちろんメンテナンス性に悪影響を及ぼすので、これは最後の手段です。将来の Go のバージョンで何らかの回避手段が用意されることを期待します。
これで、アロケート回数が2つにまで減りました。
benchmark old ns/op new ns/op delta
BenchmarkInterpolation 2463 2118 -14.01%
benchmark old allocs new allocs delta
BenchmarkInterpolation 3 2 -33.33%
benchmark old bytes new bytes delta
BenchmarkInterpolation 496 448 -9.68%
4.7. 送受信バッファの再利用
(この改良はプルリクエストがマージされた後に気づいたので、現在別のプルリクエストとして送っていて、まだ master には取り込まれていません)
最後に残った2つのアロケートは、最初にバッファを用意する部分と、最後に MySQL にコマンドを投げる関数に渡す文字列を作るためにバッファを string にキャストする2箇所です。
MySQL にコマンドを送信するときに利用している送信用のバッファに、パケットヘッダの部分を空けて直接クエリを生成することで最後の2つのアロケートも消すことができます。
しかし、既存のコマンドを送る部分の設計を汚してしまうのと、テストがしにくくなるので、直接パケットを作って送るのは自重しておいて、送信用のバッファをinterpolateに使えだけにしました。送信するパケットはクエリに4バイトのヘッダを付けたものなので、サイズ的にも再利用するのにぴったりです。
benchmark old ns/op new ns/op delta
BenchmarkInterpolation 1900 1403 -26.16%
benchmark old allocs new allocs delta
BenchmarkInterpolation 2 1 -50.00%
benchmark old bytes new bytes delta
BenchmarkInterpolation 448 160 -64.29%
まとめ
アロケート箇所を特定する方法や、主に文字列処理をチューニングする際に覚えておきたいコーディング上の Tips を紹介しました。
@methane