テストを減らさず、CIの完了待ちを短くする。 そんな改善を、プログラミング言語NagiのGitHub Actionsで試した記録があります。
PR #157でWindowsの検証を整理したところ、変更前は単一のpackageジョブに42分16秒、変更後は分離したpackageとcontractsの両方が終わるまで32分10秒という実行結果になりました。約10分の差です。
| 観測した対象 | 変更前 | 変更後 |
|---|---|---|
| Windowsの対象検証が終わるまで | 42分16秒(旧package 1回) | 32分10秒(新package+contracts 1回) |
| Nagi checks全体のReady完了まで | 46分58秒(旧3回の中央値) | 32分53秒(新1回) |
この差の理由は、単なるテストの高速化ではありません。分割後のジョブ実行時間の合計はほぼ変わらなくても、Readyまでの待ち時間が短くなるという、依存関係の見直しにあります。
1. 独立して動かせるテストを、コードを変えずに別ジョブへ移す。
2. 分離後も失敗を見逃さないよう、Readyと公開判定を強化する。
3. 既存キャッシュを新ジョブと共有し、保存先をむやみに増やさない。
この3点を、実際のYAMLとPythonのコードを見ながら紹介します。比較値はPRの公開記録とActionsの対象runに基づきます。
1. 先に、何を待っているか調べる
GitHub Actionsでは、同じジョブに置かれたステップは基本的に順番に実行されます。ひとつのステップの結果を次のステップが使うなら、それは必要な待ち時間です。一方、成果物や中間結果を共有しない検証まで同じジョブに置くと、順番待ちが増えます。
Nagiでは、Linuxの検証ジョブにあった cargo test --locked と、Windowsの nagi-package に含まれていた6つの契約テストを見直しました。
| 変更前にいた場所 | 切り出した検証 | 新しいジョブ |
|---|---|---|
Linuxの linux
|
Rustワークスペース全体の cargo test --locked
|
linux-workspace |
Windowsの nagi-package
|
native send、Session期限、SF01の各種契約、HTTP Host/entrypointなど6ステップ | windows-contracts |
Windows側の6ステップは、アーカイブやインストーラーの生成結果を必要としない検証でした。そこで、配布物に依存する処理はpackageに残し、独立した検証だけを移しています。
図はジョブの切り出しとReady判定を示したものです。実際のワークフローにはほかにも必須ジョブや実行条件があります。
Linuxは、テストコマンドそのものを移した
変更後の ci.ymlでは、linux-workspace という独立ジョブに cargo test --locked が置かれています。
# ci.ymlから主要部分を抜粋
linux-workspace:
name: Workspace regression (Linux)
needs: [changes, ci-plan]
if: needs.changes.result == 'success' && needs.ci-plan.result == 'success' && needs.ci-plan.outputs.comprehensive_checks == 'true'
runs-on: ubuntu-latest
steps:
# checkout・Rust toolchain・キャッシュ復元などの準備は省略
- run: cargo test --locked
ポイントは、テストコマンドを別のものに置き換えていないこと。移動前後で検証内容が変わらないようにしています。
Linuxの既存ジョブには、ほかのビルドや診断、失敗時の補助処理を残しました。並列化する対象の依存関係を先に調べることが、この改善の出発点です。
Windowsも、6つの検証を独立ジョブへ
Windows側の新しいジョブは、実際には次の条件で起動します。
# ci.ymlからジョブ設定を抜粋
windows-contracts:
name: Compiler and runtime contracts (Windows)
needs: [changes, release-plan, ci-plan]
if: >-
always() && !cancelled() &&
needs.changes.result == 'success' &&
needs.release-plan.result == 'success' &&
needs.ci-plan.result == 'success' &&
needs.ci-plan.outputs.package_checks == 'true'
runs-on: windows-latest
# stepsには移動した6つの契約テストと準備処理
移した6ステップの中には、次のような実際の検証があります。
- native送信期限とKeep-Aliveの境界を調べるテスト
- private Sessionの期限診断
- SF01 observer・ポリシー・lease/permitの契約テスト
- Host authorityとHTTP entrypointのテスト
これらは単なる「重いテスト」ではありません。packageが作る配布物を待たず、別のWindows runnerで進められる検証だったことが重要です。
PR #157の差分を開くと、元の位置から消えた処理と、windows-contracts に移された処理を追えます。
2. ジョブを増やしたら、Readyの判定も増やす
ここが、今回の実装で特に参考になる部分です。
テストを別ジョブへ移しただけでは、既存の「成功」の判定から外れてしまうことがあります。そこでNagiでは、linux-workspace と windows-contracts を ready と publish-release の依存関係に追加しました。
# 実際のreadyジョブの設定から抜粋
ready:
name: Ready to merge
if: always() && !cancelled()
needs: [changes, ci-plan, critical, linux, linux-workspace, rust-1-88, jetbrains, release-plan, sf08-browser, vscode-package, nagi-package, nagi-regression, windows-contracts]
runs-on: ubuntu-latest
さらに、依存関係に名前を追加するだけでなく、scripts/ci/gate.pyで実行計画とジョブ結果を照合しています。
# gate.pyの実コードから抜粋
expected = "success" if comprehensive == "true" else "skipped"
for job in ("linux", "linux-workspace", "rust-1-88"):
actual = needs.get(job, {}).get("result", "missing")
if actual != expected:
failures.append(f"{job}: {actual}, expected {expected}")
実行する予定のジョブならsuccess、実行対象外ならskippedを要求する。 これなら、ジョブが存在しない・失敗した・キャンセルされた状態を「対象外の正常なスキップ」と取り違えません。Windowsの windows-contracts にも、package側の計画と照合する同様の判定があります。
リリース側でもpublication_gate.pyが全体の判定を再確認し、対象コミットと公開条件をチェックします。
ここから応用できるのは、検証を別ジョブにするときは、必須チェックの定義も同時に見直すこと。実行時間だけでなく、安心してマージできる状態を保てます。
3. キャッシュは共有して、保存するジョブを増やさない
ジョブを分けると、runnerの起動や環境構築がそれぞれ必要になります。
NagiではRustの依存関係やビルド成果物のキャッシュを使っています。Windowsの新しい windows-contracts は、既存の nagi-package と同じキー・対象パスのキャッシュを復元するようにしました。
# windows-contractsのキャッシュ復元設定から抜粋
- name: Cache Windows contract builds
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830
id: nagi_rust_cache
with:
path: |
~/.cargo/registry
~/.cargo/git
target
native-target
key: nagi-rust-v1-package-windows-x86_64-${{ steps.rust.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('Cargo.toml', 'compiler/**', 'runtime/**') }}
ここで使っているのは、GitHub Actionsの actions/cache/restore です。新しい2ジョブは復元専用にして、保存処理は既存ジョブ側のmain成功時のみに残しています。
実際の変更前Windows packageと変更後のpackage/contractsのログには、同じキャッシュキーと2,419,740,569バイトのキャッシュ復元が記録されています。
キャッシュは便利ですが、復元にも時間がかかります。これらのログでは、およそ2.4GBの復元に1分前後を使っています。runnerを増やすだけでなく、各runnerが何を準備するかまで見ると、改善の余地を見つけやすくなります。
キャッシュ方針もテスト対象にしているのが興味深いところです。test_parallel_cache.pyでは、新ジョブが既存のキーを復元し、保存ステップを持たないことを検査しています。
4. 「速くなった」の中身を、実行履歴で見比べる
ここまでの3つの変更を適用した結果が、次の観測値です。
| 指標 | 変更前 | 変更後 |
|---|---|---|
| Windowsの対象検証完了まで | 42分16秒(旧package、1回) | 32分10秒(新package+contracts、1回) |
| Windows対象ジョブの実行時間の合計 | 42分16秒 | 41分53秒 |
| Nagi checksの最初のjob開始からReady完了まで | 46分58秒(旧成功3回の中央値) | 32分53秒(新1回) |
| Linuxの対象検証完了まで | 42分48秒(旧3回の中央値) | 24分45秒(新1回) |
Windowsでは、packageが32分09秒、contractsが9分44秒。合計すると41分53秒ですが、同時に実行されているため、両方が終わるまでの経過は32分10秒でした。
この違いは、CIを見直すときの大事な視点です。
- 完了待ちの時間:開発者がReadyになるまで待つ時間
- ジョブ実行時間の和:対象runnerが動いた時間の合計(CPU時間や請求額とは別)
並列化は前者を短くするための方法です。今回のWindowsの観測では、後者はほぼ同程度でした。
キャッシュが見つからなかった実行も参考になる
初回構築のrun 40051073531 attempt 1では、Windowsのキャッシュが見つからず、対象検証の完了まで52分22秒、対象ジョブ時間の和は72分26秒でした。
この結果から、CIの性能を比べるときは、キャッシュの有無と初回実行のコストをセットで記録する、という次の改善ポイントも見えます。
また、検証を移したことでテストのウォームアップ位置も変わります。PRには、あるstalled-send検証の時間が約1秒から約92秒になった例も残っています。ステップ単位の時間を機械的に引き算するより、ジョブ全体の完了時間と環境条件を一緒に見る方が実態に近づきます。
これらはWindowsで新旧それぞれ1回などの観測で、runner負荷まで完全に揃えた比較実験ではありません。どの環境でも必ず同じだけ短くなるという数字ではなく、同じ手法を自分のCIで試すための実例として見てください。
GitHub Actionsの記録から再計算する
今回の比較は、Actions APIの各ジョブの started_at と completed_at から行いました。
curl -fsSL -H 'Accept: application/vnd.github+json' \
'https://api.github.com/repos/disnana/Nagi/actions/runs/40058215709/attempts/1/jobs?per_page=100' \
| jq -r '.jobs[] | select(.name == "Compiler and runtime contracts (Windows)" or (.name | startswith("nagi-package (windows-"))) | [.name,.started_at,.completed_at] | @tsv'
取得した各ジョブの開始・終了時刻を使えば、対象ジョブの合計時間(各ジョブの終了−開始の和)と、完了待ちの時間(最初の開始から最後の終了まで)を自分のワークフローでも計算できます。対象runはPR #157にも記録されています。認証なしのAPIにはレート制限があり、再実行されたattemptを混ぜないように注意してください。
5. 検証を減らさず移動できたことを、コードで確かめる
最後に、PR #157のもうひとつの見どころです。並列化は構成変更なので、元のテストがすべて残っているかをコードでも確かめています。
Nagiでは、ジョブ分割を元に戻すためのlinux_workspace_projection.pyとwindows_contract_projection.pyを用意しています。
逆変換で元のワークフローを再構成し、既知のハッシュや元の内容との一致をテストします。また、移したジョブの欠落・重複、検証条件の弱体化、キャッシュ保存の再導入などを、失敗すべきケースとして確認しています。
公開PRによれば、Windows向けの実効検証は元ジョブ63ステップ+新ジョブ6ステップの計69ステップ。ほかの3プラットフォームでは各65ステップを維持しました。検証対象を減らして時間を短くしたのではなく、仕事の分担を見直したことを確認しています。
すべてのCIで逆変換テストが必要というわけではありません。ただ、重要なチェックが多いリポジトリほど、「移動したステップが消えていないか」「判定条件が変わっていないか」もテストにするという考え方は役立ちます。
まとめ:速くする前に、待っている理由を分解する
NagiのCI改善でやったのは、単にジョブ数を増やすことではありませんでした。
- 配布物に依存しない検証を探し、別runnerで動かす
- Readyと公開判定へ新しいジョブを組み込み、品質ゲートを保つ
- 既存キャッシュを復元専用で共有し、準備のコストを管理する
- 変更前後の検証内容を照合し、時間の比較条件も記録する
これらを組み合わせたことで、Windowsの対象検証完了待ちは、観測した実行で42分16秒から32分10秒になりました。
CIの高速化を考えているなら、最初に見るべきはCPUの速さやキャッシュの量だけではなく、「どの検証が何を待っているのか」かもしれません。
実際の実装・変更履歴・計測条件はPR #157から確認できます。自分のワークフローでも、独立した検証をひとつ見つけるところから試せます。