実機のプロファイルは、誰かが必要だと思ったときにしか取られないことがほとんどです。リリース前の追い込みで誰かがProfilerをつないで、スパイクやメモリの増加を見つけて、原因のコミットを探す。この流れでは、いつから悪くなったかを探す範囲が数週間分の差分になりがちです。本章では、デイリービルドのたびに実機で計測を自動で回すための構成を扱います。
構成は3つの部品に分かれます。実機を貸し出すデバイスファーム、アプリに組み込んで外部から操作を受け付けるリモート操作サーバー、計測データをサーバーへ送る計測SDKです。デバイスファームから遠隔で実機を借り、リモート操作でゲームの画面を順に回りながら、計測SDKでメモリスナップショットとProfilerキャプチャを送ります。これをデイリービルドの後続ジョブとしてCIに載せると、ビルドが出るたびにコミット単位の計測データが溜まります。溜まったデータはコマンドラインから取り出せるので、AIエージェントに差分を読ませて修正案まで進める入口にもなります。
本章の内容は、社内プロジェクトで運用しているデイリー巡回のワークフローと、そこに至るまで工夫した点について解説します。執筆時点で対象にしているのはAndroid端末だけで、iOSへの対応は今後予定しています。
パイプラインは図2.1の順に進みます。端末の確保、アプリの操作、計測データの送信は、それぞれ別のツールが担当します。どれかを別のツールに置き換えても、この分担はそのまま使えます。
図2.1: デイリー計測パイプラインの段階
デイリービルド自体には手を入れません。デイリービルドに別途巡回用のシナリオ組み込みまたは予め設定した計測コードは不要で、こういった計測を念頭においたコードを置かないことにより、内部構成が激しく変化する開発段階でも巡回が壊れることなく、安定性と柔軟性を維持できました。その手法については下で詳しく説明します。
デイリービルドが完了すると、ビルドの完了トリガーを別のワークフローが購読し、成功したときだけ巡回を起動します。このとき後続ワークフローへ渡すのは、APKをビルドしたrunのIDです。
起動したワークフローは、runのIDからビルドのアーティファクトを特定し、ダウンロードします。そのあと、巡回を担うLLMエージェントを立ち上げます。執筆時点では、モデルにClaude Opus 5.5を使い、推論の深さを決めるeffortはmediumに設定しています。
本章で自動化するのは、Unity側の計測です。どのアセットがメモリを占めているか、どのProfilerマーカーが重いかといったUnity内部の内訳を、毎日同じ形で取って比べることを目的にしています。
端末の外側から使える計測手段にも、もちろん有用なものは多くあります。iOSならInstrumentsのTime Profilerでコールスタック単位のボトルネックを追えます。Androidでも、simpleperfやそれを使うAndroid StudioのCPU Profilerで、IL2CPPが生成したネイティブ関数の単位までサンプリングできます。こうした道具を自動化に活用することも十分に考えられますが、今回の範囲では扱いません。
Unityの内訳は、普段はUnityエディタのProfilerやMemory Profilerを実機につないで取ります。ただ、CIの巡回にエディタを挟むと、考えることが一気に増えます。ランナーでプロジェクトを開くたびにLibraryフォルダのインポートを待つことになり、ランナーごとにUnityのライセンスも必要です。さらに、エディタと実機の接続に加えて、エージェントからエディタを操作するUnity MCPのような経路も用意しなければならず、構成が複雑になります。
そのため、CI上ではeditorは使わず、ランタイムのAPIのみを使用し、アプリケーション内で計測を完結させることにしました。
デバイスファームに使うのはブラウザから実機を操作できるSTFという基盤です*1。ただし、本章で使うのは画面を通したデバイスファームの操作ではなく、APIを通じた端末の確保のみで、端末を予約するとリモートのadbエンドポイントが返り、CIのランナーからはUSB接続と同じように扱えます。予約からAPKのインストールまでをスクリプト1本にまとめ、CIからは1ステップで呼びます。
巡回だけが目的ならAndroid 10以上の空いている端末が1台あれば十分ですが、計測を日ごとに比較したいなら、端末が毎日変わると比較になりません。計測用には機種を固定し、空きがなければその日はスキップする運用にしたほうが、比較できないデータを溜めずに済みます。
アプリの操作には、アプリに組み込んだリモート操作サーバーを使います。アプリが起動すると端末の中だけで命令を待ち受け、CIのランナーからはadb経由でつないで命令を送ります。アプリが動いているかの確認、シーン上のオブジェクト構成の取得、スクリーンショットの撮影、ログの取得といった決まった命令のほかに、JavaScriptで書いた任意の処理をアプリの中で実行できます。
このJavaScriptはJint*2で解釈され、C#の型に直接触れます。UIのボタンを名前で見つけて押すことも、ナビゲーションの現在ノードを読むことも、計測SDKを開始することも、すべて同じ経路で行えます。実機の上でC#のコードをその場で書いて試せる環境に近いもので、計測SDKのAPIもビルドし直さずに外から呼べます。閾値や対象画面を変えるたびにAPKを作り直す必要がありません。
計測を毎日比較するには、毎日同じ画面を同じ順序で通る必要があります。固定座標のタップは端末の解像度やレイアウトが変わると押す位置がずれるので、ランタイムのUIオブジェクトを名前で取得し、UIフレームワークのイベントを直接発火させます。現在どの画面にいるかは、ナビゲーションの状態を読んで判定します。プロジェクト側に画面遷移グラフの定義があるなら、それをそのまま巡回計画の入力にできます。
計測データの送信には、筆者の所属組織で開発している計測基盤を使います。アプリに組み込んだSDKが計測データをサーバーへ送り、サーバー側で蓄積して、ダッシュボードで日ごとの比較や悪化したときのアラートを出す仕組みです。
SDKが送れるのは、UnityのメモリスナップショットやProfilerキャプチャだけではありません。CPU時間の内訳やドローコール数などの描画負荷、コード中の任意の区間の処理時間、アプリの起動時間、一定間隔で取ったメモリ使用量の推移なども送れるので、巡回に組み込めば同じように定点観測の対象にできます。本章では、そのうち表2.1の2種類に絞って説明します。
表2.1: デイリー巡回で送る計測データ
| 種類 | 取るタイミング | 前提 | 送る量の考え方 |
|---|---|---|---|
| メモリスナップショット | 各画面に到達して安定したあと | Development Build | 画面ごとに1枚、すべて送る |
| Profilerキャプチャ | 巡回の開始から終了まで1本 | Profilerが有効なビルド | 閾値を超えたフレームだけ送る |
メモリスナップショットはUnityのMemory Profilerと同じ形式で、Unityの仕様上Development Buildでしか取れません*3。デイリービルドの巡回用APKはDevelopment Buildにしておく必要があり、リリース設定のビルドを計測に流用することはできません。
スナップショットは、巡回が画面に到達しロードが落ち着いたところで1枚取ります。名前には画面名とコミットハッシュを入れ、あとで同じ画面の別の日のデータを並べられるようにします。
Profilerキャプチャは、Unityが書き出す生のProfilerログをSDKが端末上で読み進め、条件に合うフレームだけを別ファイルへ書き出してアップロードするものです。全フレームをそのまま残さないのは、Profilerが長時間の記録に向いていないからです。エディタのProfilerウィンドウが保持できるのは最大2,000フレームで、60fpsなら30秒あまりです。古いフレームから捨てられるので、数十分の巡回をエディタにつないで記録しても、残るのは最後の30秒あまりです。都度ファイルとして書き出せばフレーム数の上限はなくなりますが、今度はファイルが巡回1回分で数十GBに膨らみます。CIのアーティファクトとして残すにも、毎日溜めるにも大きすぎます。
キャプチャは巡回の最初に開始し、最後に停止とアップロードをまとめて行います。開始時に決めるのは、どのフレームを残すかの閾値で、フレーム時間とGC割り当て量の2つで指定します。停止は、生ログの読み残しを消化してから完了します。絞り込みは計測中も並行して進んでいるので、Deep Profilingのように書き出しが速い場合でも読み残しはほとんど溜まりません。それでも、停止が終わる前に端末を返却しないよう、停止は巡回の締め切りから余裕をもって開始します。
生ログは、スレッドごとのバッファをブロック単位で順に書き足したものです。どのブロックがどのフレームのものかを知るには、フレームの区切りを見つける必要があります。手がかりになるのはProfilerの書き出し方です。Profilerはフレームの区切りを書く直前と直後にスレッドのバッファを切り替えるので、区切りは十数バイトだけの単独のブロックとして届きます。そこで絞り込みのスレッドは、この長さ以下のブロックだけを開いて区切りかどうかを確かめます。それより長いブロックは中身を読まず、そのスレッドの現在のフレームに属するものとして扱います。メインスレッドで次のフレームの区切りが届くとフレームの長さが決まるので、そこで残すかどうかを判定し、残すフレームのブロックだけを元の順序のまま書き出します。サンプラー名やスレッド名のように、どのフレームにも属さない定義のブロックはすべて書き出すので、出力はUnityのProfilerウィンドウでそのまま開けます。
GC割り当て量は、生ログからは取れません。生ログのフレーム統計にもGCの欄はありますが、Unity 2022.3から6000.5まで、確認したどのキャプチャでも0でした。そこで計測中は、メインスレッドで毎フレーム、ProfilerRecorderでGC Allocated In Frameカウンタを読みます。ProfilerRecorderの値には時刻が付かないので、そのフレームの中でProfilerUnsafeUtility.Timestampを読んで値に添えておきます。この時刻は、生ログのフレームの区切りに記録される時刻と同じ時計です。絞り込みのスレッドは、あるフレームの開始から次のフレームの開始までの間に打たれた値を、そのフレームのGC割り当て量として突き合わせます。あるフレームの値が手に入るのは次のフレームの中なので、値が届くまで数フレーム分は判定を待ちます。
端末の上で絞り込みを行うと、それ自体が計測を重くしないかが気になるかもしれません。絞り込みは専用のスレッドで行い、メインスレッドで行うのはデータの増え方の確認とGCカウンタの読み取りだけです。前述のとおり、ほとんどのブロックは中身を読まないので、絞り込みにかかる処理はごく一部のデータに限られます。絞り込みのスレッドと、Profilerのデータを書き出すDispatcherスレッドは、スレッド名で見分けて出力から除いています。また、絞り込みの処理はDeep Profilingの対象から外しています。対象に含めると、絞り込みのスレッドのメソッド呼び出しの一つひとつがサンプルになり、ちょうど読んでいる生ログに書き込まれてしまうからです。ただし、GCカウンタはプロセス全体の値なので、絞り込みのスレッドが割り当てる1フレームあたり数KBも含まれます。後述する閾値と比べれば十分に小さい量ですが、Profilerを有効にすること自体の負荷も含めて、計測による上乗せがまったく無いとは言えません。
筆者の巡回では、現在300ms・2MBを閾値にしています。30fpsのタイトルなら通常のおよそ10倍のフレーム時間で、プレイ中の細かな引っかかりではなく、画面遷移やロードで起きる大きな停止が昨日より増えていないかを見るための設定です。この設定なら、巡回全体のうち残るのはごく一部のフレームだけで、毎日送り続けられる量に収まっています。閾値を下げれば細かな引っかかりも拾えますが、残るフレームが増えてファイルが大きくなり、後述する出力の上限にも早く届きます。対象タイトルのフレームレートや、計測で解決したい問題に合わせて、データ量を見ながら閾値を調整して運用してください。
巡回が長くなると、生ログの置き場所も問題になります。SDKは生ログの書き出し先を一定の大きさごとに切り替えて連番のファイルに分け、絞り込みのスレッドは読み終えたファイルから削除します。これで、端末のストレージ使用量は数ファイル分で頭打ちになります。絞り込みが遅れて未処理のファイルが溜まりすぎたときは、記録を止めます。ファイルを切り替えると、Unityは新しいファイルの先頭に、それまでに登場したサンプラー名やオブジェクト名の定義をすべて書き直します。出力は1本のファイルにつなぐので、2つ目以降の定義はほとんどが重複です。そこで、直前に書いたものとバイト単位で同じ定義は出力から省いています。出力ファイルには1.5GBの上限を設け、次に残すフレームで上限を超えるところで記録を止めます。アップロードできるのは2GBまでなので、その手前で止めて、上限までに残したフレームは確実に送れるようにしています。
キャプチャには、各フレームのスクリーンショットも埋め込んでいます。Profilerの数値だけでは、スパイクが起きたときにどの画面で何が表示されていたかがわかりにくいためです。画面は非同期のGPUリードバックで取るので、画像が手に入るのは数フレーム後になります。そこで、最後に手に入った画像を毎フレームの終わりに埋め込み直し、どのフレームを残しても画像が付くようにしています。Unity 6.6からは、Profilerの標準機能でもスクリーンショットを連続して記録できるようになり、既定でスクリーンショットを撮る機能が有効になっています。ただし撮影は15フレームに1回固定で、筆者のAndroid端末で記録された画像は256×112ピクセルしかなく、UIの文字が読めませんでした。また、AsyncGPUReadbackが有効ではない端末では選択肢がなく、静かにスクリーンショットが無効になりました。300ms・2MBで絞り込んだある日のキャプチャでは、残った71フレームのうち、標準のスクリーンショットが付いていたのは6フレームだけで、残すフレームが飛び飛びになる絞り込みとは相性が悪いので、SDK側の撮影を使い続けています。
キャプチャのアップロード時に添えるメタデータのほかに、SDKには全計測データに共通の情報を付ける仕組みがあります。ビルド時に埋め込んだコミットハッシュ、ビルドのrun ID、端末モデルを初期化直後に登録しておけば、スナップショットにもキャプチャにも同じ情報が付き、操作側から毎回渡す必要も無くなります。計測結果をコミット順に並べられるのは、この登録があるからです。
巡回は毎日ゲームの主な画面をひととおり通るので、シェーダーのウォームアップに使うデータを集める場にもなります。GraphicsStateCollectionは、実行中に使われたシェーダーバリアントと描画状態の組み合わせを記録しておき、次回以降に事前にウォームアップできるようにするアセットです。
収集には計測SDKは使わず、リモート操作からUnityのAPIを直接呼びます。巡回の開始時に記録を始め、いつもどおり画面を回り、終了時に記録を止めて端末上のアプリの保存領域にファイルとして書き出します。書き出したファイルはadbでCIのランナーへ取り出します。エディタやPlayer接続は使わないので、計測と同じくCIの中だけで完結します。ただし、IL2CPPのビルドではコードストリッピングによって使われていない型のメンバーが削られ、リモート操作から呼べなくなることがあります。巡回用のビルドでは、link.xmlでGraphicsStateCollectionの型を残しておく必要があります。
取り出したファイルを、そのままプロジェクトに入れることはしません。巡回がエラーで止まらずに終わり、ファイルの保存まで済んだ日だけ、同じプラットフォームと品質設定の既存アセットに足し合わせ、ウォームアップ時に見つからないと警告されたバリアントを取り除いたうえで、アセット更新のPRを自動で作り、そのまま自動でマージします。毎日の巡回で通った画面の記録が、こうして少しずつ積み上がっていきます。
とはいえ、この方法だけで必要なバリアントをすべて網羅できるわけではありません。記録されるのは巡回が実際に通った画面と、そのとき描画されたものだけです。巡回で通らない画面や、特定の条件でしか出ないエフェクトなどは記録から漏れます。こうした取りこぼしや、ウォームアップしたのにスタッターが残るといった落とし穴については、第1部6章を参照してください。本書を頭から順に読んでここまで来られた方は、すでに目を通されているかもしれません。
毎日データが溜まっても、ダッシュボードを毎朝開く人がいなければ溜まっただけで終わります。この計測基盤にはコマンドラインからデータを取得するCLIもあり、本章のパイプラインでは、悪化の確認から修正PRの作成までを、このCLIを使うエージェントに任せています。人が毎回依頼を書くのではなく、計測の後ろに続く工程として毎日回します。
巡回の計測が終わると、巡回とは別の比較用のエージェントが立ち上がります。このエージェントはCLIで今日と前日の計測データを取得し、同じ画面のスナップショットやキャプチャに残ったスパイクを比べて、悪化した箇所が無いかを確認します。悪化が見つかった場合は、どの画面のどの値がどれだけ変わったか、比較に使ったデータ、その間に入ったコミットの範囲をまとめて、バックログにイシューとして登録します。悪化が無ければ何も登録しません。
登録されたイシューは、さらに別の、修正を担当するエージェントが拾います。イシューの内容を確認し、コミット履歴から原因を絞り込んだうえで修正を加え、PRを作ります。人が関わるのは、そのPRのレビューからです。
修正の確認は、デイリーの巡回とは分けて行います。デイリーの巡回は決まった画面を決まった順に回るもので、イシューごとに確かめたい画面や条件に合わせるには向いていないためです。そこで、バックログ上でイシューが修正完了(確認待ち)の状態になったことをきっかけに、修正確認用のエージェントが動きます。このエージェントは、デバイスファームの端末、アプリ内リモート操作、計測SDKというデイリーの巡回と同じ仕組みを使って、修正を取り込んだビルドを実機で動かし、イシューに残した比較元のデータと突き合わせます。悪化が解消したことを実機で確認できて初めて、チケットをQAに渡します。修正が効いたかを確かめるために、開発者が実機を触る必要はありません。
この確認の仕組みは、パフォーマンスの悪化に限らず、日頃の不具合修正にも使えます。前述のとおり、リモート操作では、実質リフレクション同様で直接触れる処理はその場で実行できるので、エージェントはゲームの中で起きることをほぼすべて操作し、読み取れます。イシューに再現手順と期待する結果が書かれていれば、その手順を実機上でたどり、ゲーム内部の状態まで含めて修正できているかを確かめられます。
本章の巡回は、コーディングエージェントに画面遷移グラフとリモート操作の使い方を渡して巡回させています。到達できない画面を回避し、新しい画面が増えたら計画を組み直せる柔軟さがある反面、1回の巡回に90ターン前後、費用にして5ドル前後が掛かります。計測の開始と停止のように毎回同じでなければならない部分は、エージェントの判断に委ねず、再利用可能な手順として固定し、エージェントにはそれを所定のタイミングで実行させるだけにしています。巡回の学習内容も、次回の入力としてリポジトリにPRで残しています。
巡回の結果はレポート、スクリーンショット、ログ、機械可読な結果ファイルとして残し、CIが結果ファイルを読んでSlackへ通知します。計測そのものの結果はダッシュボードにあるので、通知にはアップロードの成否とダッシュボードへのリンクがあれば足ります。比較で悪化が見つかった日は、登録したイシューへのリンクを付けて同じチャンネルに通知します。逆に、アップロードが無い日は、計測されていないことも通知に含めます。計測が知らないうちに止まっている状態が最も見つけにくいためです。
スナップショットとキャプチャを同じ巡回の中で取っているため、2つのデータが互いに混ざります。スナップショットの取得中は数秒間フレームが止まるので、同時に走っているキャプチャにはスパイクとして記録されます。逆に、キャプチャ中はProfilerのバッファのメモリがスナップショットに含まれるため、データを読むときには注意が必要です。データを正確に見たい場合は、スナップショット用とキャプチャ用で巡回を分けることも選択肢ですが、端末を借りる時間がおよそ倍になるため、頻度を落とすなどの折り合いが必要です。
Profilerキャプチャの粒度にも課題があります。デイリー計測ではDeep Profilingを切っているので、ゲーム側のスクリプトはMonoBehaviourのUpdateのような単位でしか見えません。それより細かく見たい箇所には、ProfilerMarkerを手で追加するか、Deep Profilingを有効にすることが必要になります。そのため、指定したアセンブリのメソッドに、ビルド時の後処理でProfilerMarkerを自動で挿入する方法などを模索しています。対象を絞った軽いDeep Profilingのように使えるので、データ量と負荷を抑えたまま、内訳を細かく取れるようになると考えています。
デイリービルドの後ろに、デバイスファームで端末を借り、アプリ内リモート操作でゲームを動かし、計測SDKへメモリスナップショットとフィルタ済みのProfilerキャプチャを送るジョブをつなぎました。コミットハッシュをメタデータに添えることで計測結果はコミット単位の履歴になり、CLIで取り出せば、悪化の検出からイシューの登録、修正PRの作成までをAIエージェントに任せられます。
本章の構成は大がかりに見えるかもしれませんが、すべてを一度にそろえる必要はありません。これから計測の自動化を計画されているなら、まずはビルドマシンに実機を1台USBでつなぎ、adb越しにリフレクションで動くインタプリタからメモリスナップショットを撮るだけでも、立派なメモリの定点観測になるかもしれません。本章が、皆さんの計測自動化の第一歩の助けになれば幸いです。
[*1] DeviceFarmer/stf(OpenSTFの後継リポジトリ) https://github.com/DeviceFarmer/stf
[*2] sebastienros/jint, JavaScript Interpreter for .NET https://github.com/sebastienros/jint
[*3] Unity Manual, Memory Profiler package https://docs.unity3d.com/Packages/com.unity.memoryprofiler@1.1/manual/index.html