Claude CodeやCodexなどのAIコーディングエージェントを使って開発していると、実装や調査を並行して進められる。以前なら一つずつ片付けていたタスクに、同じ日にいくつも手を付けられるようになった。一方で、PRを出した後のCIは、こちらの作業速度に合わせて速くなるわけではない。
自作ブラウザのOmoikaneはRustのコードが大きく、内部で使うBoaも別のCargo workspaceにある。テストはLinuxとmacOS、異なるアーキテクチャ、WPTなどに広がっている。Rustは依存関係やfeature、targetに応じてコンパイルするので、この構成では特にビルドの待ち時間が目立つ。エージェントが次の変更を作れても、前の変更の検証が終わらないまま積み上がっていく。
そこでIssue #1104で、どこに時間がかかっているかを測り、CIの実行条件を見直した。目標はテストを雑に減らすことではない。変更に必要な検証は維持したまま、不要な実行と再準備を減らすことだ。
最初に待ち時間を測った
調査時点で成功していた通常CIは14分13秒と16分40秒、Release / Gate 5は27分16秒と27分03秒だった。これらのworkflowは並行して走るので、数字を足してPRの待ち時間にしてはいけない。最後に終わるcheckが、次の判断を待たせる。
特に分かりやすかったのが、AGENTS.mdだけを変更したPR #1103だった。コードの動作は変わらないのに、通常CI、ブラウザの挙動確認、JIT、Release、CodeQLが起動し、全checkの完了まで30分43秒かかった。大きなコード変更と同じ規模のゲート待ちが、文書修正にも付いていた。
もう一つはキャッシュだった。Gate 5のmacOS ARM64では、rust-cacheがfull matchを報告した成功runでも242クレートを再コンパイルし、Cargoビルドに約12分かかっていた。「キャッシュに命中した」という表示だけでは、コンパイル済み成果物まで使えているとは言えない。
この時点では、CIを丸ごと一つ速くする方法を探すより、待ち時間を生んでいる理由を分けて調べることにした。文書の変更判定、Rustの成果物、WPTの準備、古いrunの重複は、それぞれ別の問題である。
文書だけなら、重い検証を起動しない
最も大きな差が出たのは、最初から不要なjobを実行しないことだった。
変更ファイルを分類し、ルートのAGENTS.mdとREADME.md、docs/配下のMarkdownだけなら軽量経路を使う。コード、fixture、WPTの入力、workflow、スクリプトなどが一つでも混ざれば通常の検証へ進む。変更範囲を確定できなかったときも、検証を省かない。分類スクリプトはPRの変更ファイルを取得し、許可した文書だけかを判定する。
ここでworkflow自体をpathsで除外しなかった。branch protectionでrequired checkにしているjobが生成されず、PR側ではPendingのまま残る可能性があるからだ。変更判定用のcheckは常に動かし、重いjobをjob単位でskipする。分類に失敗したら全検証へ戻す。意図したskipと、必要な検証が失敗・未実行になった状態を混同しないための設計である。
変更後の文書のみのPR #1114は、全checkが3分53秒で完了した。変更前のPR #1103の30分43秒から26分50秒、約87%短い。成功jobの実行時間を全部足した値も271分06秒から33秒になった。ただし、この「job実行時間合計」は並列jobの時間を足したもので、PRの待ち時間でも課金時間でもない。比較した二つのPRは別revisionで、runnerの待ち時間も異なる。厳密な同一条件のA/B試験とは言わない。
それでも、文書変更に対して何を実行するかを変えた効果は明確だった。重い検証はskipされ、required checkは完了し、PRをマージできた。
Rustのキャッシュは「ヒット」だけ見ても分からない
コード変更ではビルドを避けられない。そこで次に見たのが、Gate 5のキャッシュである。
Omoikaneのルートworkspaceとengine/boa/は別workspaceになっている。ルート側から見ればBoaはpath依存だが、Swatinem/rust-cacheのcache-workspace-crates: trueだけでは、そのBoaの成果物が期待どおり残らない構成だった。LinuxではOmoikaneとBoaのpath依存群を含む10クレートが再コンパイルされ、macOSではレジストリ依存を含む242クレートが再コンパイルされていた。macOSの大量再コンパイルを、Boaだけのせいと決めることもできなかった。
Gate 5のworkflowでは、Rust全般のキャッシュと、Boaのsuite用成果物を保持する別キャッシュを使うようにした。BoaのRustソースやmanifest、lockfileが変わればBoa側のキーを切り替える。逆にOmoikane側のソースだけが変わったなら、Boaの成果物は再利用する。実際にBoaだけを変えたrunではキャッシュミスと必要な再コンパイルを、ホスト側だけを変えたrunでは3ターゲットでのBoaキャッシュ復元を確認した。
同じhead a34b618f のGate 5を初回と再実行で比べた結果は次のとおりだった。
| suite target | 初回 | warm再実行 | Compilingの件数 |
|---|---|---|---|
| Linux ARM64 | 22分53秒 | 18分49秒 | 242 → 10 |
| Linux x86_64 | 27分45秒 | 23分35秒 | 245 → 10 |
| macOS ARM64 | 25分06秒 | 20分24秒 | 242 → 10 |
Gate 5全体では31分04秒から25分09秒へ、5分55秒短くなった。同一revision・同一feature・同一targetでの比較だが、runnerの負荷による変動も含む。初回には新しいキャッシュの保存時間も入っている。先に挙げた変更前の「約27分」のrunとはrevisionが違うので、その差を改善効果と呼ぶことはできない。
macOSで残った10件については、復元したCargo fingerprintより、checkoutされたソースの更新時刻が新しいことをログで確認した。キャッシュがあるのに再ビルドされる理由は、表示上のhitだけではなくfingerprintまで見ないと分からない。
さらに後の確認では、mainから復元した古いRustキャッシュがfull hitでも約100 MBの小さい状態に留まり、Linuxで約235件が再コンパイルされる例が見つかった。なぜそのキャッシュが作られたのかは確定していない。PR #1127ではキーをv3へ更新し、失敗したjobの不完全な成果物をキャッシュへ保存しないようにした。main上で改めてキャッシュを作り、3ターゲットのsuiteとpackage、集約判定が成功するところまで確認している。
ただし「再コンパイル件数が減ったから必ず速い」わけでもない。v3の同一headでLinux x86_64のsuiteを比べると、コンパイル件数は245件から10件へ減ったのに、二つのrunの実行時間は1085.9秒から1317.9秒へ増えた。runner負荷など他の変動を含むため、この一例だけでキャッシュが逆効果とも言えない。CIではコンパイル件数とwall-clockの時間を両方記録する必要がある。
WPTはキャッシュの中身を使う前にも時間がかかっていた
WPTはWeb Platform Testsのテストデータで、Omoikaneでは必要な部分だけを取得している。以前のscripts/fetch-wpt.shはsparse-checkout set/addを55回実行し、その後で固定revisionと一致しているかを確かめていた。WPTのキャッシュに命中したrunでも、Gate 5で49〜72秒、専用WPT jobで73〜86秒の準備時間があった。
修正後のスクリプトでは、必要なパターンをまとめて一度のsparse-checkout setに渡す。既に固定revisionで、必要なパターンの集合も一致していれば、その場で終了する。revisionだけの一致で終えてしまうと、後から必要になったファイルを取りこぼすため、パターン側も確認する。
変更後に計測した準備時間はGate 5で3.3〜5.5秒、専用jobで5秒だった。旧新で239パターンが一致し、WPTの検証も成功した。以前の準備時間のすべてを55回の呼び出しだけに帰属させることはできないが、キャッシュ命中時に毎回行っていた余分な準備は減らせた。
連続pushで古い通常CIを走らせ続けない
最後は一回のrunの速度ではなく、重複実行の話である。同じPRへ続けてpushすると、古いrevisionのCIがまだ走っていることがある。新しいrevisionを検証したいのに、古い方がrunnerを使い続けるのはもったいない。
通常CIにPR番号・ref単位のconcurrency設定を入れ、同じPRの古いrunを中止するようにした。異なるPRやmainへのpushまで同じgroupにして中止しないよう、eventの種類も分けている。
実際の連続pushでは古いrunがcancelledになり、最新runはsuccessになった。この例では古いrunはrunner割当前に止まったので、観測されたrunner時間の削減は0秒である。「無駄な実行を防ぐ設定が動いた」ことと、「今回何分のrunner時間を節約した」ことは分けておきたい。
検証を省く条件は狭く、コード変更のゲートは維持する
CIを短くした結果、必要な検証まで消しては意味がない。今回軽量経路にしたのは、実行結果へ影響しないことを明示できる文書だけである。コード変更を含むPR #1110では、通常CIの--include-ignored、Gate 4、WPT、Browser、Native JIT、CodeQL、Gate 5の3ターゲットのsuiteとpackage、集約判定まで成功した。後続のキャッシュ修正後もmainの手動Release runで確認している。
文書だけの変更なら3分53秒でcheckが終わった。一方、コード変更のGate 5はwarmでも約25分かかる測定がある。問題を全部解消したわけではないし、Rust製の大きなプログラムを複数targetで検証する以上、コンパイルやテストそのものの時間は残る。
それでも、AIで開発できるタスク数が増えたとき、CIの遅さを単なる待ち時間として放置しない方がよいと感じた。速くコードを書けても、どの変更が正しいかを確かめるまでが開発である。変更が検証へ流れる経路を測り、意味のない実行を省き、必要な検証は落とさない。その整理だけで、少なくとも文書修正の30分待ちはなくせた。
計測値と条件の詳細はCI所要時間の記録に残している。