GitHub Actions でテストを並列に実行して高速化する (parallelism)
テスト A に 5 分、テスト B に 10 分、テスト C に 3 分かかるとします。
直列で実行すると全部で 5 + 10 + 3 で 18 分かかります。
これを、テスト A と C だけを実行する job と、テスト B だけを実行する job の 2 つにわけると、A + C の 8 分と B の 10 分になるため、全体では長い方の 10 分待つだけで済みます。
拙作のツール、split-test を使うことで、JUnit Format XML に記録された実行時間を元にしたグループ分割が簡単にできます。
split-test --junit-xml-report-dir <xml があるディレクトリ> --node-index {{ matrix.node_index }} --node-total 2 --tests-glob 'spec/**/*_spec.rb'
のように実行すると、node_index 0 では
spec/a_spec.rb
spec/c_spec.rb
node_index 1 では
spec/b_spec.rb
のように、グループ分けされた結果を標準出力に返します。
この結果をテストコマンド (例: rspec) に渡すことで、分割実行しようというものです。
例:
bin/rspec --format progress --format RspecJunitFormatter --out report/rspec-${{ matrix.node_index }}.xml $(./split-test --junit-xml-report-dir report-tmp --node-index ${{ matrix.node_index }} --node-total 2 --tests-glob 'spec/**/*_spec.rb' --debug)
--debug オプションをつけることで、どのように分割しようとしているか、実行時間データの検出漏れがないかを確認することができます。これは標準エラーに出力されます。
例 (https://github.com/mtsmfm/split-test-example/runs/1667231492)
[2021-01-08T07:12:09Z WARN split_test] Timing data not found: /home/runner/work/split-test-example/split-test-example/spec/1_spec.rb
...
[2021-01-08T07:12:09Z DEBUG split_test] node 0: recorded_total_time: 16.439781
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/87_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/62_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/53_spec.rb
...
[2021-01-08T07:12:09Z DEBUG split_test] node 1: recorded_total_time: 16.440659
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/97_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/59_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/50_spec.rb
...
[2021-01-08T07:12:09Z DEBUG split_test] node 2: recorded_total_time: 16.450345999999996
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/80_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/64_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/56_spec.rb
...
GitHub Actions に直接依存しているわけではないため、実行結果を保存、展開するところさえ用意すればどの CI サービスでも利用可能です。
GitHub Actions における流れ
設定例は次の通りです。
on: push
jobs:
# Download test-report and save as test-report-tmp to use the exactly same test report across parallel jobs.
download-test-report:
runs-on: ubuntu-latest
steps:
# Use dawidd6/action-download-artifact to download JUnit Format XML test report from another branch
# https://github.com/actions/download-artifact/issues/3
- uses: dawidd6/action-download-artifact@v2
with:
branch: main
name: test-report
workflow: ci.yml
path: report
# Use continue-on-error to run tests even if test-report is not uploaded
continue-on-error: true
- uses: actions/upload-artifact@v2
with:
name: test-report-tmp
path: report
test:
needs: download-test-report
runs-on: ubuntu-latest
strategy:
matrix:
node_index: [0, 1, 2]
steps:
- uses: actions/checkout@v2
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
ruby-version: 3.0.0
- uses: actions/download-artifact@v2
with:
name: test-report-tmp
path: report-tmp
# Use continue-on-error to run tests even if test-report is not uploaded
continue-on-error: true
- run: |
curl -L --output split-test https://github.com/mtsmfm/split-test/releases/download/v0.3.0/split-test-x86_64-unknown-linux-gnu
chmod +x split-test
- run: bin/rspec --format progress --format RspecJunitFormatter --out report/rspec-${{ matrix.node_index }}.xml $(./split-test --junit-xml-report-dir report-tmp --node-index ${{ matrix.node_index }} --node-total 3 --tests-glob 'spec/**/*_spec.rb' --debug)
- uses: actions/upload-artifact@v2
with:
name: test-report
path: report
if-no-files-found: error
# Upload test-report on main branch only to avoid conflicting test report
if: github.ref == 'refs/heads/main'
- main ブランチ (デフォルトブランチ) 上でテストの実行結果を記録し、artifacts として保存する。
- main ブランチのテスト記録を復元する。このとき、公式アクションだと他のブランチから取得できないため、dawidd6/action-download-artifact@v2 を使用する。 https://github.com/actions/download-artifact/issues/3
- main ブランチのテスト記録は、matrix 設定された各 job が全く同時に動くとは限らず、タイミングによっては main 側の workflow が動いて更新される可能性があるため、一旦 workflow 用にスナップショットを tmp として保存する。
- tmp 側を matrix 設定された各 job で復元する。
- 記録された情報を元に split-test コマンドを使って分割する。
- main ブランチのときだけ実行記録を保存しなおす。
分割手法
内部では貪欲法で分割しています。
具体的には、ファイルごとに実行時間を足し合わせ、その実行時間が多い順に並べ、グループごと合計実行時間が最も少ないものに詰める、を繰り返しています。
厳密には最適な分割ではない可能性がありますが、多くの場合 queue の時間などで数秒~数十秒はずれてくるため、ここが最適でなくても誤差の範疇になるはずだと考えています。
実際に利用した際においてひどいケースを見つけたら issue で報告 お願いします。
余談: JUnit Format XML の方言たち
JUnit Format XML といいつつもいくつか方言が存在するようです。
そして、split-test にとって最も重要な、テストファイル名の情報は実は方言のようで、どこが公式な情報かはわかりませんが、ググって上の方にでてきた IBM Knowledge Center の定義にはありませんでした。
仕事で使うのがほぼ Ruby なので RSpec や Minitest の結果を食わせて試していましたが、この2つだけでも次のような違いがありました。
rspec_junit_formatter:
<testsuite>
<testcase file="foo_spec.rb">
<testcase>
</testsuite>
minitest-reporters:
<testsuites>
<testsuite filepath="foo_spec.rb">
<testcase>
<testcase>
</testsuite>
</testsuites>
Coverage 情報なども考慮した、扱いやすい大統一フォーマットが求められているのかもしれません。
Discussion