【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回" }

計測したbefore/after

+= は連結のたびに新しい string を作って捨てる(=ゴミを撒く)ので、1366ms かかって GC を 12 回も誘発。StringBuilder8ms・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 での最適化の面白さでした。次回はシーン全体の一括監査をやります。

ではでは!