概要

kago-utilsというPython製の麻雀OSSを作っている。

将来的にはこいつを拡張して強化学習を行うことが目標だ。

強化学習をするとなると速度は命だ。

そこでpytest-benchmarkを使って逐一ベンチマークを行い、その結果をGitHub Pagesにデプロイし、パフォーマンスを改悪するようなコミットをしてしまっていないかどうかを可視化することにした。

最終的には以下のようなGitHub Pagesができる。

Benchmarks

基本的にはgithub-action-benchmark(GitHub)に書いてある通りにやればよいのだが、意外と行間を補完する必要があって手間取ったので、この記事にまとめておく。

サンプルプロジェクト

というわけで、kago-utilsにpytest-benchmarkを導入して上手くいったのだが、色々とコミットがごちゃごちゃしてしまった。

そこで今回は新しくこの記事用のリポジトリを作成したので、説明ではこちらを使う。

リポジトリは以下。

pytest-benchmark-with-github-actions-example

GitHub Pages用のブランチを作成

とりあえずコードを書く前にGitHub Pages用のブランチを作成しておく。

今回はベンチマークの結果を保存するだけなので空のブランチを作成する。

空のブランチを作成する方法が以下。

git switch --orphan gh-pages
git commit --allow-empty -m "Initial empty commit for gh-pages branch"
git push origin gh-pages
git switch main

git switchgit checkoutで新しいブランチを作成する際に--orphanオプションをつけることで空のブランチを作成できる。

通常のコミットは何らかの変更点が必要だが、--allow-emptyオプションをつけることで、変更点なしの空のコミットを作成できる。これは今回四苦八苦する中で初めて知った。

あとはプッシュして終了。

これ以降は手動でgh-pagesブランチに変更を加えることはないので、うっかりgh-pagesブランチで作業をしてしまわないようにgit switch mainmainブランチに戻っておく。

GitHubの管理画面でGitHub Pages用の設定を行う

Settings > PagesのBranchにgh-pagesを選択する。

自分の場合は自動でgh-pagesが選択されていたが念の為。

GitHub Pagesの設定

mainブランチでの作業

ディレクトリ構成

├── Pipfile
├── Pipfile.lock
├── README.md
├── src
│   ├── __init__.py
│   └── prime.py
└── tests
    ├── __init__.py
    └── test_prime.py

Pipfileの作成

Pipfile
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]

[dev-packages]
pytest = "*"
pytest-benchmark = "*"

[requires]
python_version = "3.12"

[scripts]
test = "python -m pytest --benchmark-skip"
benchmark = "python -m pytest --benchmark-only --benchmark-json output.json"

自分はPipenvに慣れているのでPipenvを使っているが、pipやpoetryやuvなど各々の環境に併せて適宜読み替えてほしい。

今回はテストツールとしてdev-packagesにpytestとpytest-benchmarkだけを追加している。

ベンチマークは意図的に負荷のかかる処理を書くこともあるので、通常のテストでは実行したくない。

そこでpipenv run testではベンチマークを除くテストを、pipenv run benchmarkではベンチマークを含むテストを実行するように棲み分けている。

ベンチマーク時には--benchmark-onlyオプションをつけてoutput.jsonを出力しているが、このファイルはGitHub Pagesにデプロイする際に利用される。

src/prime.pyの作成

src/prime.py
def is_prime(n):
    factors = []
    for i in range(1, n + 1):
        if n % i == 0:
            factors.append(i)
    return len(factors) == 2

素数判定の素朴な関数を実装している。

遅い。

tests/test_prime.pyの作成

tests/test_prime.py
from src.prime import is_prime

def test_is_prime():
    assert is_prime(1) is False
    assert is_prime(2) is True
    assert is_prime(3) is True
    assert is_prime(4) is False
    assert is_prime(5) is True
    assert is_prime(6) is False
    assert is_prime(7) is True
    assert is_prime(8) is False
    assert is_prime(9) is False
    assert is_prime(10) is False
    assert is_prime(1009) is True
    assert is_prime(1011) is False
    assert is_prime(1013) is True
    assert is_prime(1015) is False

def test_is_prime_benchmark(benchmark):
    def benchmark_is_prime():
        is_prime(10000019)

    benchmark(benchmark_is_prime)

test_is_primeでは通常のテストを、test_is_prime_benchmarkではベンチマークを行っている。

ファイルサイズが大きくなりそうだったら通常のテストとベンチマークとでファイルを分けるのもありかもしてない。今回は面倒だったので一緒にしている。

ベンチマークしたいメソッドは引数にbenchmarkを指定すればpytest-benchmarkがよしなにベンチマークしてくれるのだが、benchmarkという文言がimportなども無く突然湧いて出てくるのが正直トリッキーだなと思ってしまう。

workflows/benchmark.ymlの作成

workflows/benchmark.yml
name: Benchmark

on:
  push:
    branches:
      - main

permissions:
  deployments: write
  contents: write
  checks: write
  pull-requests: write

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install pipenv
        run: |
          python -m pip install --upgrade pip
          python -m pip install pipenv

      - name: Install dependencies
        run: python -m pipenv install --dev

      - name: Run benchmark
        run: python -m pipenv run benchmark

      - name: Store benchmark result
        uses: benchmark-action/github-action-benchmark@v1
        with:
          tool: 'pytest'
          output-file-path: output.json
          github-token: ${{ secrets.GITHUB_TOKEN }}
          auto-push: true

このyamlファイルでGitHub Actionsの制御を行う。

on:
  push:
    branches:
      - main

と書くことで、mainブランチへのプッシュをトリガーとしてGitHub Actionsが実行されるようになる。

permissions:
  deployments: write
  contents: write
  checks: write
  pull-requests: write

と書くことで、GitHub Pagesにデプロイするための権限の設定をしている。

- name: Store benchmark result
  uses: benchmark-action/github-action-benchmark@v1
  with:
    tool: 'pytest'
    output-file-path: output.json
    github-token: ${{ secrets.GITHUB_TOKEN }}
    auto-push: true

と書くことで、前段のpipenv run benchmarkで出力したoutput.jsonをHTMLファイルに変換して、GitHub Pagesにデプロイしてくれる。

変換やデプロイなどの処理はgithub-action-benchmarkというactionsが行ってくれる。

デプロイにはGITHUB_TOKENが必要だが、${{ secrets.GITHUB_TOKEN }}と書くことでGitHub Actionsが自動で値を代入してくれる。

基本的にシークレットはGitHubの管理画面で環境変数のような感じで自分で設定するものだが、secrets.GITHUB_TOKENはデフォルトで使用できるので管理画面での設定は必要ない。

コードをpushする

git push origin main

プッシュしたらGitHubのActionsタブを開いてGitHub Actionsの様子を見守る。

GitHub Pagesの確認

GitHub Actionsが成功したら、GitHub PagesのURLを開いてみる。

まず、画像の赤丸で囲まれた部分を押す。

GitHub PagesのURL1

つぎに、画像の赤丸で囲まれた部分を押す。

GitHub PagesのURL2

そして出てきたページは404エラーになっているはずなので、URLの末尾に/dev/benchを追加する。

これでベンチマークを確認できる。

ベンチマーク1

1秒で1.9イテレーションくらい回せているらしい。

src/prime.pyの修正

ChatGPTにいい感じに速度を改善してもらった。

src/prime.py
def is_prime(n):
    if n < 2:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False
    for i in range(3, int(n**0.5) + 1, 2):
        if n % i == 0:
            return False
    return True

これをプッシュしてみる。

ベンチマーク2

12000倍くらい速くなった。やったぜ。