今川館

都内勤務の地味OLです

Goのベンチマークテストを試した

BenchmarkTestの読み方

Goではモジュールの性能試験を行う仕組みが最初から用意されていて、testing.Bを使えば良いらしい。

[Goのベンチマークテストのルール]

  • テストの名前はBenchmarkチョメチョメとする ("Benchmark"で名前を始める)
  • 引数は *testing.B 、つまりBのポインタ型を受け付ける
  • 実行するときは go test に "-bench" オプションをつける

決まりを守ってプログラムを書くと以下のような出力が得られる。

PASS
BenchmarkTest1	    5000	   1573757 ns/op
BenchmarkTest2	     500	   2457098 ns/op
ok  	_/home/echizen/tokyo/app/bench	11.021s

出力結果の読み方

まず、出力結果の読み方がわからなかったので調べたら大変親切にまとめている記事が既にあって助かった。

Golangでベンチマークを取ってみた - Qiita

// 関数の実行回数、有用な結果が得られるまで実行される
// 多ければ多いほど良い
2000000

// 1回の実行にかかった時間
// 少ないほど良い
815 ns/op

つまり、上記の出力結果だと、BenchmarkTest1は5000回試行して有意な結果が出て、1回あたり1,573,757ナノ秒かかるという意味。
対して、BenchmarkTest2は500回で有意な結果が出て、1回あたり2,457,098ナノ秒かかる。

ナノ秒って何秒?

まず、わたしには「ナノ秒」という単位が見慣れない。
ナノは10の-9乗らしいので、これをかけて秒数に直すと、

  • BenchmarkTest1は (10 ** -9) * 1573757 = 0.001573757秒
  • BenchmarkTest2は (10 ** -9) * 2457098 = 0.002457098秒

ということでBenchmark2の方が1より5割ほど遅いという意味らしい。

実際に試した

まず、整数のスライスを2つ受け取って、第2引数のスライスに出現する整数が第1引数の中にいくつ含まれるか数える関数を作る。
実装はふたつ用意することとし、一方をスライスを前から順に調べていく方法で、他方をmapを使う方法で作ったものが以下である。

[main.go]

package bench

type Pair [2]int

// itemsの中からtargetに該当するものを数える
func CountUp1(items []int, targets ...int) []Pair {
	result := make([]Pair, 0, len(targets))

	var acc int
	for _, x := range targets {
		acc = 0
		for _, y := range items {
			if x == y {
				acc++
			}
		}
		result = append(result, Pair{x, acc})
	}
	return result
}

func CountUp2(items []int, targets ...int) []Pair {
	xs := make(map[int]int, len(items))
	for _, x := range items {
		xs[x] += 1
	}

	result := make([]Pair, 0, len(targets))
	for _, y := range targets {
		result = append(result, Pair{y, xs[y]})
	}
	return result
}

次に、テストの条件(itemsの個数, targetsの個数)を3種類に分けることにした。

  1. targetsの個数が少ない (50000, 50)
  2. items, targetsともに多い (50000, 50000)
  3. itemsの個数が少ない (50, 50000)

テストコードは以下。

[main_test.go]

package bench

import (
	"math/rand"
	"testing"
)

func makeParams(P int, Q int) (x, y []int) {
	var items []int
	var targets []int
	items = make([]int, P)
	for i := 0; i < len(items); i++ {
		items[i] = rand.Intn(Q)
	}

	targets = make([]int, Q)
	for j := 0; j < len(targets); j++ {
		targets[j] = j
	}
	return items, targets
}

const ITEMS1 = 50000
const TARGETS1 = 50

const ITEMS2 = 50000
const TARGETS2 = 50000

const ITEMS3 = 50
const TARGETS3 = 50000

func BenchmarkTest1Using1(b *testing.B) {
	items, targets := makeParams(ITEMS1, TARGETS1)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		CountUp1(items, targets...)
	}
}

func BenchmarkTest1Using2(b *testing.B) {
	items, targets := makeParams(ITEMS1, TARGETS1)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		CountUp2(items, targets...)
	}
}

func BenchmarkTest2Using1(b *testing.B) {
	items, targets := makeParams(ITEMS2, TARGETS2)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		CountUp1(items, targets...)
	}
}

func BenchmarkTest2Using2(b *testing.B) {
	items, targets := makeParams(ITEMS2, TARGETS2)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		CountUp2(items, targets...)
	}
}

func BenchmarkTest3Using1(b *testing.B) {
	items, targets := makeParams(ITEMS3, TARGETS3)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		CountUp1(items, targets...)
	}
}

func BenchmarkTest3Using2(b *testing.B) {
	items, targets := makeParams(ITEMS3, TARGETS3)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		CountUp2(items, targets...)
	}
}

実行結果は

(_go)echizen@Seigaku:~/tokyo/app/bench$ go test -bench .
testing: warning: no tests to run
PASS
BenchmarkTest1Using1	     500	   3207881 ns/op
BenchmarkTest1Using2	     500	   2507589 ns/op
BenchmarkTest2Using1	       1	2415230671 ns/op
BenchmarkTest2Using2	     100	  11777956 ns/op
BenchmarkTest3Using1	     500	   3034820 ns/op
BenchmarkTest3Using2	    1000	   1892243 ns/op
ok  	_/home/echizen/tokyo/app/bench	15.183s

上記から、このような判断ができる。

  • 類型1(50000, 50)だと実装1, 2は互いに有意な結果が得られるまで500回かかったが、実装2の方が3割くらい速い。
  • 類型2(50000, 50000)だと、実装1はたった1回やれば十分と判断され、実装2に比べて240倍くらい遅い。
  • 類型3(50, 50000)だと、実装2は実装1の2倍の1000回で測定打ち切りになり、実装1より4割くらい速い。

testing.Bから取れるNとは?

上記サンプルコードでtesting.B型の引数bからNという値を取ってループを回しているが、最初これも何に使うのかわかっていなかった。

これについては『プログラミング言語Go』に書いてあった説明が非常にわかりやすいので引用しておく。

ベンチマークの実行系は操作に要する時間を最初は分かっていませんので、Nの小さな値を使って最初の測定を行い、それから安定した時間の計測ができるだけの十分な大きさの値を推定します。

何と裏側で打診的にテスト対象を実行した後、本格的に性能を計測しているそうだ。

Pythonでやる場合は?

組み込みモジュールにtimeitというやつがあるので、これを使うのがお手軽なんだと思う*1

[bench.py]

# -*- coding:utf-8 -*-
import random


def countup1(items, targets):
    L = []
    for target in targets:
        count = 0
        for x in items:
            if x == target:
                count += 1
        L.append((target, count))
    return L


def countup2(items, targets):
    L = []
    tmp = {x: 0 for x in targets}

    for x in items:
        tmp[x] += 1
    return [(x, tmp[x]) for x in targets]


def make_params(x, y):
    return (
        [random.randint(1, y) for i in range(x)],
        list(range(1, y + 1)),
    )


P1A, P1B = make_params(50000, 50)
P2A, P2B = make_params(50000, 50000)
P3A, P3B = make_params(50, 50000)


if __name__ == '__main__':
    import timeit

    items = (
        ('P1A', 'P1B', (500, 500)),
        ('P2A', 'P2B', (1, 100)),
        ('P3A', 'P3B', (500, 1000)),
    )

    G = globals()

    for x, y, ns in items:
        one, two = ns
        result1 = timeit.timeit(
            'countup1({}, {})'.format(x, y),
            setup='from __main__ import countup1, P1A, P1B, P2A, P2B, P3A, P3B',
            number=one,
        )
        print('1({}, {})>\t{}\t{}s'.format(len(G[x]), len(G[y]), one, result1))

        result2 = timeit.timeit(
            'countup2({}, {})'.format(x, y),
            setup='from __main__ import countup2, P1A, P1B, P2A, P2B, P3A, P3B',
            number=two,
        )
        print('2({}, {})>\t{}\t{}s'.format(len(G[x]), len(G[y]), two, result2))

[結果]

(py3k)echizen@Seigaku:~/py3k$python bench.py
1(50000, 50)>	500	46.40978667300078s
2(50000, 50)>	500	2.749026679999588s
1(50000, 50000)>	1	94.42981744899953s
2(50000, 50000)>	100	2.782400592999693s
1(50, 50000)>	500	51.12394202299947s
2(50, 50000)>	1000	13.445461807001266s

Pythonのtimeitは試行回数をnumberという名前の引数に指定して動かす。
何回実行してどの程度の速度の差が出るのか等、自分で判断してテストする必要がある。

こうしてみるとGoのtesting.Bの方が手軽に性能試験を試せて良いものだと思った。

*1:試したらすごく遅かったんだけど何かミスってますかね・・