ことの発端
新卒2か月目にして、早速開発者テストで考慮漏れを発生させた私。
「顧客が組んだ計算式の動作を担保するUT(ユニットテスト)を書く」という防止策の対応が急務でした。
無限に増えていくテストケースとの格闘、PICTやテスト手法の学習等、色々あった結果、全ての網羅は出来ないという結論にたどり着きました。
最低限担保する部分を決めてテストケースを出してみると46,000件になりました。
46,000件のテストケースを実行しなければならない
今回のテストケースとしては、実行する式、取りうる引数の値、期待する計算結果の3つを保持しておく必要がありました。
保持する方法としては、以下の2つを考えた結果
- protobufを使用する方法
- golangコードを生成する方法
binaryファイルにすると型情報が欠落してしまい上手くテストケースを表せなかったことから、golangコードを自動生成する方法に決定しました。
golangコードで46,000件テストケースを生成する手法は今回割愛させていただきますが、無事テストケースを生成する事はできました。
今回のテストケースを表す定義を以下に示しておきます。これで定義されたテストケースを逐一実行する必要があります。
var (
testCases := []struct{
name string // "算術演算子"
expr string // "$1 + 100"
args map[string]interface{} // {"$1":100}
result interface{} // 200
}{
{46,000件続く}
}
)
しかし、いざテストを実行をしようとするとエラーになるのでした。
NewBulkでCompile Errorになる
テストを実行をしようとすると、以下のようにデータが大きすぎるという理由でコンパイルエラーになりました。
$ go test -v -cpuprofile cpu.prof -memprofile mem.prof -bench . -benchmem >> log.txt
internal compiler error: NewBulk too big:
nbit=37794 count=1377187 nword=1182 size=1627835034
グローバル変数で巨大なtestCasesを定義していることでビルドファイルが大きくなるのが原因だと考えたため、testCaseを分割してGetter内に移動させることにしました。
type testCase struct {
name string // "算術演算子"
expr string // "$1 + 100"
args map[string]interface{} // {"$1":100}
result interface{} // 200
}
func GetTestCasesForHoge() []testCace {
var (
testCasesForHoge := []testCace{
{10000件ずつくらい}
}
)
return testCasesForHoge
}
Swap領域で50GB、pprofでもmemory30GB
関数内に定義を移動させると、無事にコンパイルは通るようになりました。
しかし安心したのもつかの間、パソコンがフリーズしてしまいます。
どうやらビルド時にmemory使用量が大きすぎて、メモリを食い尽くしてSwap領域もめちゃくちゃになっていたようです。
ビルドに時間がかかることから案の定アロケートも酷いことになっていました。
BenchmarkHoge 532 2246227 ns/op 5521797 B/op 32444 allocs/op
BenchmarkFuga 519 2340436 ns/op 5574756 B/op 32845 allocs/op
ビルド時間は減ったが、memory使用量が減らない
ここからは、コードの書き方によるパフォーマンス改善に着手していくことにしました。
色々調べましたところ、以下の観点に着目しました。
- ポインタのスライスと普通のスライスでは普通のスライスの方が良い
- 二次元マップは遅いので、一次元のスライスにする
- string、time.Timeが多いと遅くなるので、stringはconcatする。time.Timeはunixtimeのintで持つ
- mapはkey、valueに分けてスライスで持つ
- ポインタ渡しで値コピーを減らす
- ポインタをたくさん保持するmapはやめる
- 固定長の配列ではなくスライスにする
-
for i, v := range
ではなく、i
のみでスライスにアクセスする
ビルド時の高速化と、allocateを減らすことと、実行時間の高速化が混ざっていますが、ご了承ください。
上記の観点からmapがallocateを増やす原因だと考え、sliceでkeys、valuesを保持することにしました。
func GetTestExprsForHoge() *[]testCase {
var (
testExprsForHoge := []strings{
"100+$1",...,
}
)
return &testExprsForHoge
}
BenchmarkHoge 969 1330810 ns/op 1699080 B/op 22646 allocs/op
BenchmarkFuga 841 1212646 ns/op 2227347 B/op 29278 allocs/op
その結果、実行時間が格段と早くはなったものの、依然としてallocateとmemory使用量は高いままでした。
allocate=0、memory140MBになった
もうどうしようもないのかと諦めそうになったのですが、ふと最初の行動を振り返ってあることに気が付きました。
コンパイルエラーを回避するために関数内にテストケースを移したが、
テストケースを関数内に書くと、呼び出しごとに再生成が走ってしまう
mapを無くすことでビルド時の高速化に繋がったため、以前のようにNewBulkエラーは起こらないだろうと考え、関数内の定義を外に出すことにしました。
var (
testExprsForHoge := []strings{
"100+$1",...,
}
testArgsForHoge := [][]strings{
[]string{"$1"},...,
}
testArgValuesForHoge := [][]interface{}{
[]interface{}{100},...,
}
testResultsForHoge := []interface{}{
200,...,
}
)
func GetTestExprsForHoge() *[]testCace {
return &testExprsForHoge
}
その結果、allocが0になりmemoryの使用量が140MBに減りました。
めでたく46,000件のテストケースをgo testで扱うことが出来るようになりました。
まとめ
goでテストを実行するというタスクの中で、allocate、ビルド時のメモリ、実行時間等様々なことを学べました。
反省として、もっとcpuやmemoryの使用量の詳しい見方やallocateの対応を知識を持って臨めたら良かったです。
以後の目標としつつ、以下に今回学んだまとめを書いておさらばとします。
-
map[]
のスライス,[]struct{map[]}
等の大量のmapを見たら、アロケートを疑う。- keys、valuesのスライスに分けて持つ。
-
for i, v := range map[] or slice
ではなく、for i= range map[] or slice
にする-
v
にはコピーが走るので、大きなデータになるほど遅くなる -
i
を使って、list[i]
、map[i]
でアクセスする
-
- 関数の受け渡しにはポインタを使用する
- ポインタではないと、値コピーが走るのでデータが大きいときつい
- 大きな定義のデータは関数内ではなく、関数外に定義する
- 関数内だと毎回生成されてしまうので、アロケートが多くなってしまう
- ビルドが遅いのか、実行が遅いのかを分けて考える
- データの概算を出してみて、実行時のデータ量を考える
- 実質データ量とmemory使用量が異様に異なれば、アロケート等を疑う
- ビルド、実行が遅いファイルを
go test
、go build
から外すときは、ビルドタグを使用する-
//go:build hoge
とファイル冒頭に書く。 -
-tags=hoge
と指定すれば含めて、指定しなければビルドとテストから省かれる
-
参考