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;
}
}
}