Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using UnityEngine;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Reusable interpreter performance benchmarks: each workload compiles ONE source through
|
||||
/// <see cref="RoslynCompilationService"/> and runs it twice — through the interpreter (the same
|
||||
/// binding code reload uses) and as the Mono-compiled assembly — so every row is an
|
||||
/// interpreted-vs-compiled ratio plus allocated bytes per iteration (the boxing signal).
|
||||
///
|
||||
/// Run on demand: <c>run_tests --mode editor --filter IlInterpreterPerfBenchmarks</c>.
|
||||
/// The default budget keeps the whole fixture a few seconds so it can ride along in CI; set the
|
||||
/// ILINTERP_BENCH_SECONDS env var (e.g. 2) before launching the editor for low-noise numbers.
|
||||
/// Results are logged as one table and appended to Library/IlInterpreterBench/ for diffing a
|
||||
/// future optimization against today's baseline.
|
||||
/// </summary>
|
||||
class IlInterpreterPerfBenchmarks
|
||||
{
|
||||
// ---- workload matrix -------------------------------------------------------------------
|
||||
// Every source declares `public static class Probe` with `public static object Run(...)`
|
||||
// containing an inner loop of N iterations; measurements are per inner iteration, so the
|
||||
// outer invoke overhead (reflection for compiled, interp.Invoke for interpreted) washes out.
|
||||
|
||||
sealed class Workload
|
||||
{
|
||||
public string Name;
|
||||
public int N; // inner iterations per Run() call
|
||||
public string Source;
|
||||
public Func<object[]> MakeArgs; // fresh args per measurement phase (null = none)
|
||||
public Action Cleanup;
|
||||
public string What; // which interpreter path the row isolates
|
||||
public bool KnownDivergent; // documented interpreter bug: report, don't fail
|
||||
}
|
||||
|
||||
static Workload[] BuildWorkloads()
|
||||
{
|
||||
GameObject go = null;
|
||||
|
||||
return new[]
|
||||
{
|
||||
new Workload
|
||||
{
|
||||
Name = "int_arith", N = 4000, What = "pure dispatch loop (no host, no boxing)",
|
||||
Source = Probe(@"
|
||||
int s = 0;
|
||||
for (int i = 0; i < 4000; i++) { s = s * 31 + i; s ^= s >> 3; }
|
||||
return s;"),
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "float_math", N = 4000, What = "float ALU ops",
|
||||
Source = Probe(@"
|
||||
float s = 1.0001f;
|
||||
for (int i = 0; i < 4000; i++) { s = s * 1.00001f + 0.001f; if (s > 100f) s -= 99f; }
|
||||
return s;"),
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "script_calls", N = 2000, What = "interpreted->interpreted static calls",
|
||||
Source = @"public static class Probe {
|
||||
static int Add(int a, int b) { return a + b; }
|
||||
public static object Run() {
|
||||
int s = 0;
|
||||
for (int i = 0; i < 2000; i++) s = Add(s, i);
|
||||
return s;
|
||||
} }",
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "instance_fields", N = 2000, What = "script-object field read/write",
|
||||
Source = @"public static class Probe {
|
||||
class Counter { public int Value; public float Scale = 1.5f; }
|
||||
public static object Run() {
|
||||
var c = new Counter();
|
||||
for (int i = 0; i < 2000; i++) { c.Value = c.Value + 1; c.Scale = c.Scale * 1.0001f; }
|
||||
return c.Value;
|
||||
} }",
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "box_unbox", N = 2000, What = "explicit object boxing in script code",
|
||||
Source = Probe(@"
|
||||
int s = 0;
|
||||
for (int i = 0; i < 2000; i++) { object o = i; s += (int)o; }
|
||||
return s;"),
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "vector3_math", N = 1000, What = "curated flat-struct path (AllowTypeStruct<Vector3>)",
|
||||
Source = Probe(@"
|
||||
var v = new UnityEngine.Vector3(1f, 2f, 3f);
|
||||
var w = new UnityEngine.Vector3(0.001f, 0.002f, 0.003f);
|
||||
for (int i = 0; i < 1000; i++) v = v + w;
|
||||
return v.x + v.y + v.z;"),
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "vector3_magnitude", N = 1000, What = "computed struct member (ref-receiver closure)",
|
||||
Source = Probe(@"
|
||||
var v = new UnityEngine.Vector3(1f, 2f, 3f);
|
||||
float s = 0f;
|
||||
for (int i = 0; i < 1000; i++) { s += v.magnitude; v.x += 0.0001f; }
|
||||
return s;"),
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
// This row found the flat-arg-into-host-call zeroing bug (2026-08-13, fixed
|
||||
// in VmEngine.RdArg): a Vt-slot argument read as null and Invoke turned it
|
||||
// into default(T). AutoBindFlatMixTests pins the fix dotnet-side; the
|
||||
// divergence gate here enforces it against the real Bounds/Vector3 pair.
|
||||
Name = "bounds_autobind", N = 500,
|
||||
What = "auto-bound host struct (reflection AllowType path)",
|
||||
Source = Probe(@"
|
||||
var b = new UnityEngine.Bounds(new UnityEngine.Vector3(1f, 2f, 3f), UnityEngine.Vector3.one);
|
||||
float s = 0f;
|
||||
for (int i = 0; i < 500; i++) {
|
||||
var c = new UnityEngine.Bounds(b.center, b.size);
|
||||
s += c.size.x + c.center.y;
|
||||
}
|
||||
return s;"),
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "rect_props", N = 500, What = "curated non-generic struct props (Rect, boxed path)",
|
||||
Source = Probe(@"
|
||||
var r = new UnityEngine.Rect(0f, 0f, 10f, 20f);
|
||||
float s = 0f;
|
||||
for (int i = 0; i < 500; i++) { r.width = r.width + 0.5f; s += r.width; }
|
||||
return s;"),
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "mathf_statics", N = 1000, What = "curated host static calls (Mathf)",
|
||||
Source = Probe(@"
|
||||
float s = 0.5f;
|
||||
for (int i = 0; i < 1000; i++) s = UnityEngine.Mathf.Clamp(s * 1.01f, 0f, UnityEngine.Mathf.Max(2f, s));
|
||||
return s;"),
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "transform_position", N = 500, What = "native property get/set through host binding",
|
||||
Source = @"public static class Probe {
|
||||
public static object Run(UnityEngine.Transform t) {
|
||||
t.position = new UnityEngine.Vector3(0f, 0f, 0f);
|
||||
for (int i = 0; i < 500; i++) {
|
||||
var p = t.position;
|
||||
p.x += 0.0001f;
|
||||
t.position = p;
|
||||
}
|
||||
return t.position.x;
|
||||
} }",
|
||||
MakeArgs = () =>
|
||||
{
|
||||
go = new GameObject("IlInterpBenchProbe");
|
||||
return new object[] { go.transform };
|
||||
},
|
||||
Cleanup = () => { if (go != null) UnityEngine.Object.DestroyImmediate(go); go = null; },
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "string_concat", N = 500, What = "string append (allocation inherent in workload)",
|
||||
Source = Probe(@"
|
||||
string s = """";
|
||||
int len = 0;
|
||||
for (int i = 0; i < 500; i++) {
|
||||
s = s + (char)('a' + (i & 15));
|
||||
if (s.Length > 64) { len += s.Length; s = """"; }
|
||||
}
|
||||
return len;"),
|
||||
},
|
||||
new Workload
|
||||
{
|
||||
Name = "delegate_call", N = 2000, What = "lambda capture + invocation",
|
||||
Source = Probe(@"
|
||||
int k = 3;
|
||||
System.Func<int, int> f = x => x + k;
|
||||
int s = 0;
|
||||
for (int i = 0; i < 2000; i++) s = f(s);
|
||||
return s;"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static string Probe(string body) =>
|
||||
"public static class Probe { public static object Run() {" + body + "\n} }";
|
||||
|
||||
// ---- measurement -----------------------------------------------------------------------
|
||||
|
||||
const double DefaultSecondsPerPhase = 0.20;
|
||||
const double WarmupSeconds = 0.08;
|
||||
|
||||
// Unity's Mono stubs GC.GetAllocatedBytesForCurrentThread to 0, so allocations are
|
||||
// measured in a separate short pass: quiesce the GC, run a call count small enough that
|
||||
// the nursery cannot fill, and read the heap growth. If gen0 collected anyway, halve the
|
||||
// count and retry; -1 means no collection-free window was found (report n/a).
|
||||
static double AllocPerIt(Func<object> call, int innerN)
|
||||
{
|
||||
for (int calls = 64; calls >= 1; calls /= 4)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
int gen0 = GC.CollectionCount(0);
|
||||
long before = GC.GetTotalMemory(false);
|
||||
for (int i = 0; i < calls; i++)
|
||||
call();
|
||||
long delta = GC.GetTotalMemory(false) - before;
|
||||
if (GC.CollectionCount(0) == gen0 && delta >= 0)
|
||||
return (double)delta / ((long)calls * innerN);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct Phase
|
||||
{
|
||||
public double NsPerIt;
|
||||
public double BytesPerIt; // -1 when unavailable
|
||||
public object Result;
|
||||
}
|
||||
|
||||
static Phase Measure(Func<object> call, int innerN, double seconds)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
object result = null;
|
||||
while (sw.Elapsed.TotalSeconds < WarmupSeconds)
|
||||
result = call();
|
||||
|
||||
sw.Restart();
|
||||
long calls = 0;
|
||||
while (sw.Elapsed.TotalSeconds < seconds) { result = call(); calls++; }
|
||||
sw.Stop();
|
||||
|
||||
long its = calls * innerN;
|
||||
return new Phase
|
||||
{
|
||||
NsPerIt = sw.Elapsed.TotalMilliseconds * 1e6 / its,
|
||||
BytesPerIt = AllocPerIt(call, innerN),
|
||||
Result = result,
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MeasureAll()
|
||||
{
|
||||
double seconds = DefaultSecondsPerPhase;
|
||||
var env = Environment.GetEnvironmentVariable("ILINTERP_BENCH_SECONDS");
|
||||
if (!string.IsNullOrEmpty(env) && double.TryParse(env, out var s) && s > 0)
|
||||
seconds = s;
|
||||
|
||||
var rows = new List<string>();
|
||||
var divergences = new List<string>();
|
||||
var table = new StringBuilder();
|
||||
table.AppendLine($"IlInterpreter editor bench — {seconds:0.##}s/phase, Mono, {Application.unityVersion}");
|
||||
table.AppendLine($"{"workload",-20} {"interp ns/it",13} {"compiled ns/it",15} {"ratio",8} {"interp B/it",12} {"compiled B/it",14} path");
|
||||
|
||||
foreach (var w in BuildWorkloads())
|
||||
{
|
||||
var compile = RoslynCompilationService.Compile(new CompilationRequest
|
||||
{
|
||||
SourceCode = w.Source,
|
||||
AssemblyName = $"IlInterpBench_{w.Name}",
|
||||
SkipLoad = false, // Mono-load the same bytes for the compiled baseline
|
||||
});
|
||||
Assert.IsTrue(compile.Success, $"{w.Name}: bench source must compile:\n{w.Source}");
|
||||
var compiledRun = compile.Assembly.GetType("Probe").GetMethod("Run");
|
||||
|
||||
using var interp = new IlInterpreter.Interpreter.ScriptInterpreter(
|
||||
IlInterpreterHostBindings.CreateStandard());
|
||||
interp.StepLimit = int.MaxValue;
|
||||
interp.Load(new RawScript(compile.AssemblyBytes));
|
||||
|
||||
try
|
||||
{
|
||||
var interpArgs = w.MakeArgs?.Invoke() ?? Array.Empty<object>();
|
||||
var ip = Measure(() => interp.Invoke("Run", interpArgs), w.N, seconds);
|
||||
w.Cleanup?.Invoke();
|
||||
|
||||
var compiledArgs = w.MakeArgs?.Invoke() ?? Array.Empty<object>();
|
||||
var cp = Measure(() => compiledRun.Invoke(null, compiledArgs), w.N, seconds);
|
||||
w.Cleanup?.Invoke();
|
||||
|
||||
// Same source, same semantics: a mismatch means the row measures a
|
||||
// divergence, not a slowdown. Record it, keep the table complete, and fail
|
||||
// the test at the end so a bug never hides the remaining rows.
|
||||
if (!SameResult(cp.Result, ip.Result) && !w.KnownDivergent)
|
||||
divergences.Add($"{w.Name}: interpreted={ip.Result} compiled={cp.Result}");
|
||||
if (SameResult(cp.Result, ip.Result) && w.KnownDivergent)
|
||||
Debug.Log($"IlInterpreter bench: '{w.Name}' no longer diverges — its known bug looks fixed; drop KnownDivergent to re-enforce.");
|
||||
|
||||
string ib = ip.BytesPerIt < 0 ? "n/a" : ip.BytesPerIt.ToString("0.0");
|
||||
string cb = cp.BytesPerIt < 0 ? "n/a" : cp.BytesPerIt.ToString("0.0");
|
||||
string flag = SameResult(cp.Result, ip.Result) ? "" : " [DIVERGED]";
|
||||
table.AppendLine(
|
||||
$"{w.Name,-20} {ip.NsPerIt,13:0.0} {cp.NsPerIt,15:0.00} {ip.NsPerIt / cp.NsPerIt,8:0.0}x {ib,12} {cb,14} {w.What}{flag}");
|
||||
rows.Add($"{w.Name},{ip.NsPerIt:0.0},{cp.NsPerIt:0.00},{ip.BytesPerIt:0.0},{cp.BytesPerIt:0.0}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
w.Cleanup?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
var report = table.ToString();
|
||||
Debug.Log(report);
|
||||
|
||||
// Append a machine-readable copy so future optimization branches can diff against it.
|
||||
var dir = Path.Combine("Library", "IlInterpreterBench");
|
||||
Directory.CreateDirectory(dir);
|
||||
var file = Path.Combine(dir, $"bench-{DateTime.Now:yyyyMMdd-HHmmss}.csv");
|
||||
File.WriteAllText(file,
|
||||
"workload,interp_ns_it,compiled_ns_it,interp_b_it,compiled_b_it\n" + string.Join("\n", rows) + "\n");
|
||||
Debug.Log($"IlInterpreter bench CSV: {Path.GetFullPath(file)}");
|
||||
|
||||
if (divergences.Count > 0)
|
||||
Assert.Fail("interpreted result diverged from compiled on:\n" + string.Join("\n", divergences));
|
||||
}
|
||||
|
||||
static bool SameResult(object compiled, object interpreted)
|
||||
{
|
||||
if (compiled is float cf && interpreted is float inf)
|
||||
return Math.Abs(cf - inf) <= Math.Abs(cf) * 1e-3f + 1e-3f;
|
||||
return Equals(compiled, interpreted);
|
||||
}
|
||||
|
||||
sealed class RawScript : IlInterpreter.IScript
|
||||
{
|
||||
readonly byte[] m_Bytes;
|
||||
public RawScript(byte[] bytes) { m_Bytes = bytes; }
|
||||
public string Name => "IlInterpBench";
|
||||
public ReadOnlyMemory<byte> Il => m_Bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user