Goで並列実行のベンチマークを取るためのライブラリ parallel-benchmark を書いた

以前 Perl で、forkして並列実行するベンチマークを取るためのライブラリ、Parallel::Benchmark というのを書きました。

これを使うと、単に Perl コードのベンチマークだけではなく、並列に外部にアクセスして計測を行うような (たとえばApacheBenchのような) ベンチマークツールが簡単に作れるので重宝しています。(仕事では、ソーシャルゲームのサーバアプリケーションに対する負荷テストを行うために使ったりもしています)

で、思い立って Go 版を書きました。

使用例

フィボナッチ数を求めるコードを並列実行するベンチマーク
  • fib(30) を1回計算するごとにスコア1とする
  • 10個の goroutine で並列実行
  • 3秒間計測
package main

import (
	"github.com/kayac/parallel-benchmark/benchmark"
	"log"
	"time"
)

func main() {
	result := benchmark.RunFunc(
		func() (subscore int) {
			fib(30)
			return 1
		},
		time.Duration(3)*time.Second,
		10,
	)
	log.Printf("%#v", result)
}

func fib(n int) int {
	if n == 0 {
		return 0
	}
	if n == 1 {
		return 1
	}
	return (fib(n-1) + fib(n-2))
}

実行結果はこんな感じです。

$ go run fib.go
2014/07/18 14:24:52 starting benchmark: concurrency: 10, time: 3s, GOMAXPROCS: 1
2014/07/18 14:24:55 done benchmark: score 330, elapsed 3.303671587s = 99.888863 / sec
2014/07/18 14:24:55 &benchmark.Result{Score:330, Elapsed:3303671587}

使い方は簡単で、benchmark.RunFunc() に計測したい処理の func() int を渡すだけです。
渡した func が返した int 値が1回実行ごとのスコアになるので、処理内容によって違うスコアを返すこともできます。(失敗したら 0 とか)

GOMAXPROCS は適宜環境変数で渡すなどしてください。

$ GOMAXPROCS=4 go run fib.go
2014/07/18 14:28:56 starting benchmark: concurrency: 10, time: 3s, GOMAXPROCS: 4
2014/07/18 14:28:59 done benchmark: score 974, elapsed 3.097321734s = 314.465233 / sec
2014/07/18 14:28:59 &benchmark.Result{Score:974, Elapsed:3097321734}
ApacheBench のような HTTP GET を行うベンチマーク

benchmark.Worker interface を実装した、自前の Worker オブジェクトをスライスで渡すことで、状態を持ったオブジェクトを使ったベンチマークを作ることもできます。

package main

import (
	"flag"
	"github.com/kayac/parallel-benchmark/benchmark"
	"io/ioutil"
	"log"
	"net/http"
	"time"
)

type myWorker struct {
	URL    string
	client *http.Client
}

func (w *myWorker) Setup() {
	w.client = &http.Client{}
}

func (w *myWorker) Teardown() {
}

func (w *myWorker) Process() (subscore int) {
	resp, err := w.client.Get(w.URL)
	if err == nil {
		defer resp.Body.Close()
		_, _ = ioutil.ReadAll(resp.Body)
		if resp.StatusCode == 200 {
			return 1
		}
	} else {
		log.Printf("err: %v, resp: %#v", err, resp)
	}
	return 0
}

func main() {
	var (
		conn     int
		duration int
	)
	flag.IntVar(&conn, "c", 1, "connections to keep open")
	flag.IntVar(&duration, "d", 1, "duration of benchmark")
	flag.Parse()
	url := flag.Args()[0]
	// benchmark.Worker interface をもった worker を作成してスライスに入れる
	workers := make([]benchmark.Worker, conn)
	for i, _ := range workers {
		workers[i] = &myWorker{URL: url}
	}
	benchmark.Run(workers, time.Duration(duration)*time.Second)
}
  • Setup() : goroutine が作成された後、各workerで呼ばれます。初期化を行うのに利用してください
  • Process() int: 全ての worker の Setup() が終了後、各 worker の Process() が指定時間に達するまでループで呼び出されます。返す int 値が1回実行ごとのスコアになります
  • Teardown(): 指定時間が経過後、各 worker で呼ばれます。後処理が必要であればここで行ってください

実装上のポイントとか

Perl版でも同様なのですが、ベンチマークを取るときにちょっと嬉しい小ネタが入っています。

  • すべての worker の初期化が終わるのを待ってから計測開始するので、重い初期化処理があっても開始が揃う
  • 途中でシグナル (INT, TERM, QUIT, HUP) を受けた場合は、そこで計測を終了してその時点での結果を返す
    • つい計測時間10分でベンチ始めたけど待つの辛いので5分にしたい、けど中断したらそれまでの計測が無駄に…というようなケースでも躊躇なく停止できます

Perl版では上記の挙動を実装するために fork した子プロセスが初期化完了するのを Parallel::Scoreborad で待ったり、子プロセスを制御するのにシグナルを送ったりしていてなかなか複雑な実装になっていたのですが、Goでは channel が使えるので大変楽に書けてすばらしいですね。