GitHub Actions上で安定したベンチマーク環境を得る(ただし、相対比較専用)

現在、実験的にですが少しパフォーマンスを気にしたいパッケージを書いています。 ちなみに、その1つはリバースプロキシです。

github.com

「気にしたいパフォーマンス」というのは以下の2つです。

  1. 現在のGoの実装がNGINX(のリバースプロキシ機能)と比べてリクエストあたりの処理時間が小さいか
  2. 現在の実装が以前の実装(Pull Requestであればmainブランチ)と比べてリクエストあたりの処理時間が極端に大きくなっていないか

GitHubリポジトリがあるのでCI環境としてはGitHub Actionsがあります。

そこで、上記2点をGitHub Actions上で継続的に計測したいと考えました。

まず、1つ目の「NGINXとの比較」ですが、これはただNGINXを使ったベンチマークと自分の実装を使ったベンチマークをそれぞれ実行することにしました。

NGINXを使ったベンチマークの結果(ns/op)と、同じようにGoの実装を使ったベンチマークの値(ns/op)を比較するだけです。

それぞれのベンチマークgo test -bench で計測しoctocovのカスタムメトリクス機能を使って結果を表示します。

k1low.hatenablog.com

GitHub Actionsで提供されるRunnerのスペックはいつも同じとは限らない

2つ目の「以前の実装との比較」です。

当初はoctocovの比較機能を使い、GitHub Artifactsに保存してある以前のベンチマーク計測結果と比較するようにしたのですが、ベンチマーク結果が安定しませんでした。

原因はGitHub Actionsで提供されるRunnerのスペックがいつも同じとは限らないことでした。

このRunnerのスペックの違いを、CPU命令数とCPUキャッシュのヒット率を使って乗り越える方法を実現した方がいます。

www.mizdra.net

私も手元ではうまくいったように見えたので、Cachegrindの結果をoctocovのカスタムメトリクスで取得できる仕組みまで作ったのですが、残念ながら期待した結果にはなりませんでした。おそらく、ベンチマーク対象のコードにCPU以外に大きく影響をうけるような箇所があるんだろうなと推測しています。

github.com

同じRunnerで実装前後のベンチマークを比較する

ようは、実装前後の2値の比較だけができればいいわけです。

ということで愚直に同一Job上で実装前後のベンチマークを取得し比較するようにしました。そうすれば少なくともRunnerのスペックの違いに悩まされることはなくなります。

actions/checkoutk1LoW/octocov-action の機能を駆使します。

作成したGitHub ActionsのWorkflowは次のとおりです。

# .github/workflows/benchmark.yml
name: benchmark

on:
  pull_request:

jobs:
  benchmark:
    name: Benchmark
    runs-on: ubuntu-latest
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - name: Check out source code
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version-file: go.mod

      - name: Set up octocov-go-test-bench
        run: go install github.com/k1LoW/octocov-go-test-bench/cmd/octocov-go-test-bench@latest

      - name: Check out source code (main)
        uses: actions/checkout@v4
        with:
          ref: main
          path: main

      - name: Run benchmark (main)
        run: go test -bench . -benchmem -benchtime 10000x | octocov-go-test-bench --tee > custom_metrics_benchmark.json
        working-directory: main

      - name: Run octocov (main)
        uses: k1LoW/octocov-action@v0
        with:
          config: .octocov.benchmark.main.yml
        env:
          OCTOCOV_GITHUB_REF: refs/heads/main
          OCTOCOV_GITHUB_SHA: none
          OCTOCOV_CUSTOM_METRICS_BENCHMARK: main/custom_metrics_benchmark.json

      - name: Run benchmark
        run: go test -bench . -benchmem -benchtime 10000x | octocov-go-test-bench --tee > custom_metrics_benchmark.json

      - name: Run octocov
        uses: k1LoW/octocov-action@v0
        with:
          config: .octocov.benchmark.yml
        env:
          OCTOCOV_CUSTOM_METRICS_BENCHMARK: custom_metrics_benchmark.json

順を追って説明します。

まず、Check out source code Set up Go Set up octocov-go-test-bench ステップまでは通常通りですが、Check out source code (main) でmainブランチのソースコードを mainディレクトリにチェックアウトします。

      - name: Check out source code (main)
        uses: actions/checkout@v4
        with:
          ref: main
          path: main

次にmainブランチのソースコードベンチマークを実行します。ベンチマーク結果を octocov-go-test-bench でパースしてoctocovのカスタムメトリクスJSONとして、main/custom_metrics_benchmark.json に保存します。

      - name: Run benchmark (main)
        run: go test -bench . -benchmem -benchtime 10000x | octocov-go-test-bench --tee > custom_metrics_benchmark.json
        working-directory: main

mainブランチのoctocovカスタムメトリクスJSONを使ってoctocovでレポートを作成します。

      - name: Run octocov (main)
        uses: k1LoW/octocov-action@v0
        with:
          config: .octocov.benchmark.main.yml
        env:
          OCTOCOV_GITHUB_REF: refs/heads/main
          OCTOCOV_GITHUB_SHA: none
          OCTOCOV_CUSTOM_METRICS_BENCHMARK: main/custom_metrics_benchmark.json

mainブランチのカバレッジレポートであることを記録するために環境変数 GITHUB_REFGITHUB_SHA を上書きします。GitHub Actions上では直接 GITHUB_REFGITHUB_SHA を上書きすることはできないので OCTOCOV_GITHUB_REFOCTOCOV_GITHUB_SHA で上書きします*1

使用する設定ファイル .octocov.benchmark.main.yml は次のように指定します。

# .octocov.benchmark.main.yml
repository: ${GITHUB_REPOSITORY}/benchmark
coverage:
  if: false
codeToTestRatio:
  if: false
testExecutionTime:
  if: false
report:
  datastores:
    - local://main # レポートをローカルの main/ ディレクトリ以下に保存する

コードメトリクスの取得は全てOFFにし、レポートファイルをローカルディレクトリに保存するようにしています。

最後はPull Requestの実装のベンチマークを取得してoctocovにカスタムメトリクスとして読み込ませます。

      - name: Run benchmark
        run: go test -bench . -benchmem -benchtime 10000x | octocov-go-test-bench --tee > custom_metrics_benchmark.json

      - name: Run octocov
        uses: k1LoW/octocov-action@v0
        with:
          config: .octocov.benchmark.yml
        env:
          OCTOCOV_CUSTOM_METRICS_BENCHMARK: custom_metrics_benchmark.json

ここで比較対象として、先ほどローカルの保存したmainブランチのoctocovレポートを指定します。

.octocov.benchmark.yml は次のようになります。

# .octocov.benchmark.yaml
repository: ${GITHUB_REPOSITORY}/benchmark
coverage:
  if: false
codeToTestRatio:
  if: false
testExecutionTime:
  if: false
diff:
  datastores:
    - local://main # 比較対象としてローカルの main/ ディレクトリ以下に保存されているレポートを使用する
comment:
  if: is_pull_request
summary:
  if: true

実際のレポート結果

  1. Goの実装とNGINX(のリバースプロキシ機能)との比較
  2. 現在の実装と以前の実装(Pull Requestであればmainブランチ)との比較

なのでちょうど次のようなマトリクスになります。

以前の実装(main) Pull Requestの実装
NGINX
Goの実装

そして、今回説明したWorkflowをPull Requestで実行すると以下のようになります。

Metadataを展開するとちゃんと実行環境のCPUが揃っていることがわかります。

まとめ

今回、2つの要件を満たすためのGitHub Actions上での安定したベンチマーク環境を得る方法を紹介しました。

ただ、残念ながら継続的なベンチマーク環境とはいかず、あくまで相対比較専用となっています。

それでも、Pull Requestごとに数値で確認できるのは大きなメリットです。

個人的には、Cachegrindの手法(もしくはそれに準じる手法)が「GitHub Actions上での安定した継続的ベンチマーク環境」を得る方法だと思っているので、折を見て再挑戦したいと思っています。