Pull Requestを出すとGoのベンチマーク結果の比較をしてくれるCI Job

f:id:castaneai:20210817161445p:plain

Goには標準でベンチマークを取る機能 がある。 次のように go test のオプションとして実行できる。

go test -bench .

また、標準には入ってないが複数のベンチ結果を比較してくれる benchstat というツールもある。 go installですぐに導入でき、2つのベンチ結果のファイルを引数で渡すだけなので直感的に使えてとてもいい。

$ go install golang.org/x/perf/cmd/benchstat@latest
$ go test -bench . -count 10 > old.txt
$ go test -bench . -count 10 > new.txt
$ benchstat old.txt new.txt

name    old time/op  new time/op  delta
Test-8  11.0ms ± 2%  10.9ms ± 2%   ~     (p=0.436 n=10+10)

Pull Request提出時に自動的にベンチを取りたい

新しい実装を入れるたびに手元で古い実装とのベンチマーク結果を比較するのは面倒。 そこで、Pull Request提出時に自動的にベンチマーク結果を比較してくれると便利そうだと考えて、試してみた。

CIでベンチマーク結果の比較をする

CI上で次のような流れで benchstat を実行し、結果をPull Requestのコメントに書くとよさそう。

  • Pull RequestのベースとHEADブランチでそれぞれベンチマークを実行
  • 2つのベンチ結果をbenchstatで比較
  • 比較結果をPull Requestのコメントに書き込む

これを最低限実現するGitHub ActionsのYAMLは次のようになる。 (この例ではGitHub Actionsだけど、他のCIでも似たようなことは実現できるはず)。

on:
  pull_request:
    paths:
      - '**.go'

jobs:
  benchstat:
    runs-on: ubuntu-20.04
    permissions:
      pull-requests: write
      contents: read
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v2
        with:
          go-version: '^1.16'
      - name: Run benchstat
        id: benchstat
        env:
          BENCH_CMD: go test -bench . -count 10
        run: |
          go install golang.org/x/perf/cmd/benchstat@latest
          git checkout origin/${GITHUB_BASE_REF}
          ${{ env.BENCH_CMD }} > old.txt
          git checkout origin/${GITHUB_HEAD_REF}
          ${{ env.BENCH_CMD }} > new.txt
          benchstat old.txt new.txt > benchstat.txt
      - uses: actions/github-script@v4
        with:
          script: |
            const fs = require('fs').promises;
            const result = await fs.readFile("benchstat.txt");
            github.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: "```\n" + result  + "```",
            })

基本的に流れをそのままYAMLにしただけだが、いくつか注意点があった。

Goのファイルが変更されたときのみ実行する

たとえばドキュメントの変更のみなのにGoのベンチマークを取っても意味がないので、paths: フィルターでGoのファイルに変更があった場合のみにする。

actions/checkout のときは fetch-depth: 0 を指定する

デフォルトのactions/checkout の設定だと、PRを出したブランチしか取得してくれないので、fetch-depth: 0 を指定して他のブランチも取得しておく。 これでPRのベースブランチとの比較ができるようになる。

PRにコメントをつけるには actions/github-script が便利

PRにコメントを付ける実装はactions/github-scriptを使った。 これを使うとnode.jsでGitHub APIを直接叩くようなコードが記述できるので、curlなどでコマンドを組み立てるよりも読みやすくて便利だった。 Node.jsのコードが書けるのでベンチ結果のファイルを読み込むのも fs パッケージを使ってできたりする。

同じPRでpushする度にベンチマークが実行される

タイトル通り、一度Pull Requestを出した後に何度も追加でpushするとその度に結果がコメントされるので 場合によってはうるさいかもしれない…。

PR上で /bench など特定のコメントを送信したときだけベンチマークを実行するとか、に改造したらもっと良い体験になるかも。