// A0 result (2026-06-07): heavy 18.2 MiB -> 2.4 MiB/script; lean 1.65 MiB; ~7.6x drop // (within noise of theoretical 11x). Proves globalsType-closure isolation: ScriptGlobals // now resolves from Core.Scripting.Abstractions (Roslyn-free); heavy ≈ lean as expected. // Pre-A0 baseline: heavy ~18 MiB (Core.Scripting + Core.VirtualTags both -> Roslyn). // Post-A0 (this run): heavy 2.40/2.53 MiB, lean 1.64/1.65 MiB — both well under the 3 MiB gate. using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; // Memory measurement probe for Roslyn C# scripting per dotnet/roslyn#22219. // Compiles + RETAINS N distinct scripts (like the prod compiled-delegate cache) and // measures the per-script working-set cost. The ONLY thing that varies between "heavy" // and "lean" is the globalsType's assembly closure: // heavy = VirtualTagContext (closure pulls in Roslyn via Core.Scripting) // lean = LeanCtx (closure = {LeanContext, Core.Abstractions} only) static long Rss() => System.Diagnostics.Process.GetCurrentProcess().WorkingSet64; static void Settle() { for (int i = 0; i < 3; i++) { GC.Collect(2, GCCollectionMode.Forced, true, true); GC.WaitForPendingFinalizers(); } } var mode = args.Length > 0 ? args[0] : "heavy"; int N = args.Length > 1 && int.TryParse(args[1], out var n) ? n : 50; // The globalsType is what Roslyn's reference manager loads the transitive closure of // (dotnet/roslyn#22219). We mirror production's choice: prod uses the WRAPPER // ScriptGlobals (which exposes the named `ctx` property), NOT the raw context. // heavy = ScriptGlobals -> AFTER A0: both wrapper + generic arg now // live in Core.Scripting.Abstractions (Roslyn-free); closure = lean. // lean = LeanGlobals -> closure {LeanContext, Core.Abstractions}, // NO Roslyn. This is the A0 "globals type in a lean assembly" treatment. var globalsType = mode == "lean" ? typeof(LeanContext.LeanGlobals) : typeof(ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptGlobals); // The script reads ctx.GetTag("x").Value. We must reference: the globalsType's own // assembly, the assemblies of its generic type arguments (so `ctx`'s property type // resolves), and Core.Abstractions (DataValueSnapshot, the return type of GetTag). // References are minimal + identical in spirit for both modes; the real difference is // the transitive UNMANAGED closure of the globalsType's assembly that Roslyn's reference // manager loads per compilation (the #22219 effect). var snapshotAssembly = typeof(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DataValueSnapshot).Assembly; var refAssemblies = new System.Collections.Generic.HashSet { globalsType.Assembly, snapshotAssembly, }; foreach (var ga in globalsType.GetGenericArguments()) refAssemblies.Add(ga.Assembly); var opts = ScriptOptions.Default .WithReferences(refAssemblies) .WithImports(); // Warm up Roslyn once (compile 1 throwaway) so the baseline excludes one-time Roslyn init. _ = CSharpScript.Create("return 0;", opts, globalsType).GetCompilation().GetDiagnostics(); Settle(); long baseRss = Rss(); long baseGc = GC.GetTotalMemory(true); var held = new System.Collections.Generic.List(N); for (int i = 0; i < N; i++) { var src = $"return ctx.GetTag(\"ref_{i}\").Value;"; var script = CSharpScript.Create(src, opts, globalsType); script.Compile(); // force compilation / emit held.Add(script.CreateDelegate()); // retain the compiled delegate (like the prod cache) } Settle(); long afterRss = Rss(); long afterGc = GC.GetTotalMemory(true); GC.KeepAlive(held); Console.WriteLine($"MODE={mode} N={N}"); Console.WriteLine($" baseline RSS={baseRss / 1048576.0:F1} MiB managed={baseGc / 1048576.0:F1} MiB"); Console.WriteLine($" afterN RSS={afterRss / 1048576.0:F1} MiB managed={afterGc / 1048576.0:F1} MiB"); Console.WriteLine($" PER-SCRIPT: RSS={(afterRss - baseRss) / 1048576.0 / N:F2} MiB/script managed={(afterGc - baseGc) / 1048576.0 / N:F2} MiB/script");