【Unity×AI】uloop で処理を計測して最適化する — GC とメモリをコマンドで測る
こんにちは、saba です。
前回は uloop CLI の概要を紹介しました。今回はもっと実戦的に、uloop で処理を計測して最適化する話です。
最適化のいちばんの敵は「なんとなく速くした気になる」こと。数値で before / after を取るのが鉄則です。そして uloop の execute-dynamic-code を使えば、Unity エディタ上でその場で計測用コードを走らせて、実数値を受け取れます。この記事の数値はすべて手元の Unity 6 で実測したものです。
メモリの現状をコマンドで読む
まずは足元。今のエディタがどれだけメモリを使っているかを UnityEngine.Profiling.Profiler から取ります。
uloop execute-dynamic-code --code 'using UnityEngine.Profiling; \
long total = Profiler.GetTotalAllocatedMemoryLong(); \
long reserved = Profiler.GetTotalReservedMemoryLong(); \
long mono = Profiler.GetMonoUsedSizeLong(); \
return $"Allocated: {total/1024/1024}MB, Reserved: {reserved/1024/1024}MB, Mono: {mono/1024/1024}MB";'
返ってきた実物がこちら。
{ "Success": true, "Result": "Allocated: 855MB, Reserved: 1166MB, Mono: 960MB" }
Allocated は実際に使用中、Reserved は Unity が OS から確保済みの総量、Mono は C# 側(マネージド)ヒープです。Mono が膨らみ続けるならマネージドリークの疑い、というふうに当たりをつけられます。
処理の「重さ」を GC 回数で測る
C# の重さは、実行時間だけでなく GC(ガベージコレクション)をどれだけ誘発するかが効きます。毎フレーム大量のゴミを出すコードは、時々カクッと来る「GC スパイク」の原因になります。
execute-dynamic-code の中で GC.CollectionCount(0) を前後で比較すれば、その処理が何回 GC を起こしたかがわかります。
例①: 文字列連結 — += vs StringBuilder
ありがちなアンチパターン、ループ内の += 連結を計測してみます。
uloop execute-dynamic-code --code 'using System; using System.Text; using System.Diagnostics; \
int g0 = GC.CollectionCount(0); var sw = Stopwatch.StartNew(); \
string s = ""; for (int i=0;i<20000;i++){ s += i.ToString(); } \
sw.Stop(); int g1 = GC.CollectionCount(0); \
var sw2 = Stopwatch.StartNew(); var sb = new StringBuilder(); \
for (int i=0;i<20000;i++){ sb.Append(i); } string s2 = sb.ToString(); \
sw2.Stop(); int g2 = GC.CollectionCount(0); \
return $"[+=] {sw.Elapsed.TotalMilliseconds:F0}ms / GC {g1-g0}回 || [SB] {sw2.Elapsed.TotalMilliseconds:F1}ms / GC {g2-g1}回";'
実測結果:
{ "Result": "[+=] 1366ms / GC 12回 || [SB] 8.0ms / GC 0回" }
+= は連結のたびに新しい string を作って捨てる(=ゴミを撒く)ので、1366ms かかって GC を 12 回も誘発。StringBuilder は 8ms・GC 0 回。約 170 倍速く、ゴミもゼロです。数字で見ると差が暴力的ですね。
例②: Camera.main のキャッシュ
Camera.main は「毎回シーンを検索している」ことで有名です。ループ内で叩くとどうなるか。
uloop execute-dynamic-code --code 'using System.Diagnostics; using UnityEngine; \
var sw=Stopwatch.StartNew(); Camera c=null; \
for(int i=0;i<100000;i++){ c=Camera.main; } sw.Stop(); \
var cached=Camera.main; var sw2=Stopwatch.StartNew(); Camera c2=null; \
for(int i=0;i<100000;i++){ c2=cached; } sw2.Stop(); \
return $"[毎回] {sw.Elapsed.TotalMilliseconds:F0}ms || [キャッシュ] {sw2.Elapsed.TotalMilliseconds:F2}ms";'
{ "Result": "[毎回 Camera.main] 30ms || [キャッシュ] 0.37ms" }
10 万回で 30ms vs 0.37ms、約 80 倍。Camera.main は 1 回取って変数に持っておく——基本ですが、数値で見ると徹底する気になります。
AI と組み合わせると何が嬉しいか
ここまでは手でコマンドを打ちましたが、真価は Claude Code などに任せたときです。
「この関数、GC 起こしてないか計測して。起こしてたら直して、直った後の数値も見せて」
こう頼むと、AI が 計測コードを execute-dynamic-code で走らせ → 数値を読み → 修正 → もう一度計測まで自走します。前回紹介した「自律ループ」が、そのまま最適化のループになるわけです。人間は最終的な数値と diff を見て「OK」と言うだけ。
execute-dynamic-code 内の Stopwatch 計測は、エディタの他の処理の影響を受けるので絶対値は毎回ブレます(同じ += でも実行状況で数百 ms 変動します)。重要なのは 同じ条件での before / after の比。桁が変わる差だけを信じるのが安全です。
まとめ
Profiler.GetTotalAllocatedMemoryLongなどでメモリの現状をコマンドで読めるGC.CollectionCount(0)の前後差で、処理が撒くゴミの量を数値化できる+=連結やCamera.main毎回参照は、実測すると桁で差がつく- AI に「計測 → 修正 → 再計測」を任せれば、最適化そのものが自律ループになる
「推測するな、計測せよ」を、AI と一緒に高速で回す。それが uloop での最適化の面白さでした。次回はシーン全体の一括監査をやります。
ではでは!
