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 { /// /// Reusable interpreter performance benchmarks: each workload compiles ONE source through /// 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: run_tests --mode editor --filter IlInterpreterPerfBenchmarks. /// 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. /// 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 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)", 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 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 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 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(); var divergences = new List(); 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(); var ip = Measure(() => interp.Invoke("Run", interpArgs), w.N, seconds); w.Cleanup?.Invoke(); var compiledArgs = w.MakeArgs?.Invoke() ?? Array.Empty(); 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 Il => m_Bytes; } } }