sync.Poolのすすめ
Goの高速化テクニックの一つにsync.Pool
を使うというものがあります. bufferとして使う領域を毎回allocせずに使いまわすことで, allocとGCにかかる時間を省くのが狙いです.
poolが効果を発揮するのは可変長データをgo routineで並列処理する場合です.
予め適切なbuffer sizeがわからない場合でも, 足りなくなったら都度交換することで, 十分な時間が経てばpool内は大きなbufferだけが残る事になり, 償却的にzero allocにできます.
// pool初期化
pool := sync.Pool {
New: func() interface{} {
return &Buffer{}
},
}
//...
// 使うとき
buf := pool.Get().(*Buffer)
buf.Reset()
if !buf.EnoughSize() {
buf = NewLargerBuffer() //bufferが足りないので大きいbufferに取り替える
}
pool.Put(buf)
ここで古い小さなbufferをpoolに戻さないところがポイントです.
例えばhttp handlerでzero allocを達成できれば, webサーバーの高速化にもかなり貢献するはずです.
sliceをpoolする
bufferとしてsliceを使うユースケースは多いと思いますが, sliceをpoolに突っ込んだ場合, zero allocを達成するまでにいくつか罠を踏んだので書いておきます.
slice実体をpoolする(罠1)
sliceをpoolしようとして, まず私はこう書きました. poolは[]intを貯めています.
func BenchmarkSlicePool(b *testing.B) {
pool := sync.Pool{
New: func() interface{} {
return make([]int, 0, 0) // 適切なsizeがわからない場合を想定してあえてcap = 0とした.
},
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
arr := pool.Get().([]int)[0:0] // キャストしてlenリセット
arr = append(arr, 42)
pool.Put(arr)
}
})
}
slice = ポインタという感覚からすると正しいように思えますが, しかしこれはzero allocにはなりません.
bench name | cores | parallelism | ns / op | alloc bytes / op | alloc times / op |
---|---|---|---|---|---|
BenchmarkSlicePool | 12 | 100 | 28.2 ns/op | 32 B/op | 1 allocs/op |
このallocは pool.Put(arr)
から来ています.
Putの引数はinterface{}型なので, sliceをinterface{}にキャストしますがそのときこの中でslice headerをheapにコピーする必要があります.
実際にキャストの内部を見てみると...
380 func convTslice(val []byte) (x unsafe.Pointer) {
381 // Note: this must work for any element type, not just byte.
382 if (*slice)(unsafe.Pointer(&val)).array == nil {
383 x = unsafe.Pointer(&zeroVal[0])
384 } else {
385 x = mallocgc(unsafe.Sizeof(val), sliceType, true)
386 *(*[]byte)(x) = val
387 }
388 }
なお, slice headerはsliceの内部表現で, 下記の通りptr, len, capを持つstructです.
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
sliceのポインタをpoolする(罠2)
OK. じゃあポインタにすっかという事で, return &make([]int)
がsyntax errorになる事に若干イライラしつつ, こうします. 今度は*[]intを貯めています.
func BenchmarkSlicePtrPool_1(b *testing.B) {
pool := sync.Pool{
New: func() interface{} {
s := make([]int, 0, 0)
return &s
},
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
arr := (*pool.Get().(*[]int))[0:0] // キャストしてderefしてlenリセット
arr = append(arr, 42)
pool.Put(&arr)
}
})
}
しかしこれもzero allocならず.
bench name | cores | parallelism | ns / op | alloc bytes / op | alloc times / op |
---|---|---|---|---|---|
BenchmarkSlicePtrPool_1 | 12 | 100 | 27.0 ns/op | 32 B/op | 1 allocs/op |
escape解析をしてみると, Getの行でarrがheapに行ってしまっていることがわかります.
$ go test . -gcflags="-m"
...
moved to heap: arr
Putでheapに行くことがわかっているので, arrをstackに確保しなかったということですね.
sliceのポインタをpoolし, ポインタを残す(罠3)
arrをstackに確保するべく, 次の手は
func BenchmarkSlicePtrPool_2(b *testing.B) {
pool := sync.Pool{
New: func() interface{} {
s := make([]int, 0, 0)
return &s
},
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
ptr := pool.Get().(*[]int) // キャスト
arr := *ptr // deref
arr = arr[0:0] // lenリセット
arr = append(arr, 42)
pool.Put(ptr)
}
})
}
これもzero allocならず.
bench name | cores | parallelism | ns / op | alloc bytes / op | alloc times / op |
---|---|---|---|---|---|
BenchmarkSlicePtrPool_2 | 12 | 100 | 12.4 ns/op | 8 B/op | 1 allocs/op |
今度は, appendでせっかくgrowしたarrがPutできていません. Putの時点でptr != &arr
であり, ptrの指すsliceは依然としてcap == 0
のため, 毎回growしてしまいます.
sliceのポインタをpoolし, ポインタを残し, Put前にheaderをコピーする (多分これが一番速いと思います)
Putする前に, ptrが指すheap上のslice headerにstack上のslice header (arr)をコピーします.
func BenchmarkSlicePtrPool_3(b *testing.B) {
pool := sync.Pool{
New: func() interface{} {
s := make([]int, 0, 0)
return &s
},
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
ptr := pool.Get().(*[]int)
arr := *ptr
arr = arr[0:0]
arr = append(arr, 42)
*ptr = arr //slice headerの書き戻し
pool.Put(ptr)
}
})
}
これでようやく zero alloc達成できました.
bench name | cores | parallelism | ns / op | alloc bytes / op | alloc times / op |
---|---|---|---|---|---|
BenchmarkSlicePtrPool_3 | 12 | 100 | 3.39 ns/op | 0 B/op | 0 allocs/op |
BenchmarkSlicePool (再掲) | 12 | 100 | 28.2 ns/op | 32 B/op | 1 allocs/op |
slice実体のpoolから比べると8.3x速くなっています. 配列の領域自体は使い回しできていても, 高々32Bのallocでも馬鹿にできないことがわかります.
検証環境
go version go1.15.1 darwin/amd64
Mac Pro (Late 2013)
3.5 GHz 6-Core Intel Xeon E5