diff --git a/ChibiRuby.slnx b/ChibiRuby.slnx index 69362f64..83f5d06e 100644 --- a/ChibiRuby.slnx +++ b/ChibiRuby.slnx @@ -12,6 +12,7 @@ + diff --git a/sandbox/ChibiRuby.Benchmark/ChibiRuby.Benchmark.csproj b/sandbox/ChibiRuby.Benchmark/ChibiRuby.Benchmark.csproj index 58c2278a..951b631d 100644 --- a/sandbox/ChibiRuby.Benchmark/ChibiRuby.Benchmark.csproj +++ b/sandbox/ChibiRuby.Benchmark/ChibiRuby.Benchmark.csproj @@ -11,6 +11,7 @@ + @@ -27,6 +28,7 @@ + diff --git a/sandbox/ChibiRuby.Benchmark/OptcarrotAotCompiler.cs b/sandbox/ChibiRuby.Benchmark/OptcarrotAotCompiler.cs new file mode 100644 index 00000000..11b9ffe2 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/OptcarrotAotCompiler.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using ChibiRuby; +using ChibiRuby.JetPack; +using ChibiRuby.JetPack.Mrb2Cs; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ChibiRuby.Benchmark; + +// Runtime host for mrb2cs: generate the C# for an irep tree (Mrb2CsCompiler.Compile), Roslyn-compile it +// into an assembly, and register each body by fingerprint so a (re)parse of the same bytecode +// binds it. The source generation itself lives in ChibiRuby.JetPack (Mrb2Cs); this is just the +// "load it into the running process" half (the build-time path would csc + reference instead). +static class OptcarrotAotCompiler +{ + public static int CompileAndRegister(MRubyState state, Irep root) + { + var result = Mrb2CsCompiler.Compile(state, root, "OptcarrotAotGenerated"); + if (result.Methods.Count == 0) + { + return 0; + } + + if (Environment.GetEnvironmentVariable("AOT_DUMP") is { Length: > 0 } dumpPath) + { + File.WriteAllText(dumpPath, result.Source); + } + + var asm = Compile(result.Source); + var type = asm.GetType("OptcarrotAotGenerated")!; + foreach (var (name, fingerprint) in result.Methods) + { + var method = type.GetMethod(name, BindingFlags.Public | BindingFlags.Static)!; + var body = (CompiledRubyMethodBody)method.CreateDelegate(typeof(CompiledRubyMethodBody)); + state.RegisterCompiledMethod(fingerprint, body); + } + + return result.Methods.Count; + } + + static Assembly Compile(string source) + { + var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Where(p => p.Length > 0) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .ToList(); + refs.Add(MetadataReference.CreateFromFile(typeof(MRubyState).Assembly.Location)); + + var compilation = CSharpCompilation.Create( + "OptcarrotAotGenerated", + new[] { tree }, + refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release, allowUnsafe: true)); + + using var ms = new MemoryStream(); + var emit = compilation.Emit(ms); + if (!emit.Success) + { + File.WriteAllText("/tmp/aot_fail.cs", source); + var errors = string.Join("\n", emit.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Take(20) + .Select(d => d.ToString())); + throw new InvalidOperationException("mrb2cs generated code failed to compile:\n" + errors); + } + + ms.Position = 0; + return Assembly.Load(ms.ToArray()); + } +} diff --git a/sandbox/ChibiRuby.Benchmark/Program.cs b/sandbox/ChibiRuby.Benchmark/Program.cs index dd2b711d..8094254f 100644 --- a/sandbox/ChibiRuby.Benchmark/Program.cs +++ b/sandbox/ChibiRuby.Benchmark/Program.cs @@ -95,6 +95,29 @@ return; } +if (args is ["--quick-optcarrot-aot", ..]) +{ + var frames = args.Length >= 2 && int.TryParse(args[1], out var parsedAotFrames) + ? parsedAotFrames + : 180; + var warmupRuns = args.Length >= 3 && int.TryParse(args[2], out var parsedAotWarmup) + ? parsedAotWarmup + : 0; + using var loader = new RubyScriptLoader(); + var compiled = loader.PreloadOptcarrotBenchmarkAot(frames, printResult: warmupRuns == 0); + Console.Error.WriteLine($"AOT-compiled methods: {compiled}"); + for (var i = 0; i < warmupRuns; i++) + { + loader.RunChibiRuby(); + } + if (warmupRuns > 0) + { + loader.PreloadOptcarrotRun(frames); + } + loader.RunChibiRuby(); + return; +} + if (args is ["--quick-optcarrot-mruby", ..]) { var frames = args.Length >= 2 && int.TryParse(args[1], out var parsedFrames) @@ -104,6 +127,33 @@ return; } +if (args is [ "--quick-variable-table-allocation", .. ]) +{ + var objectCount = args.Length >= 2 && int.TryParse(args[1], out var parsed) + ? parsed + : 100_000; + VariableTableAllocationCounter.Run(objectCount); + return; +} + +if (args is [ "--quick-script", var scriptName, .. ]) +{ + var iterations = args.Length >= 3 && int.TryParse(args[2], out var parsedIterations) + ? parsedIterations + : 1; + RubyScriptLoader.RunQuickScript(scriptName, iterations); + return; +} + +if (args is [ "--quick-script-aot", var aotScriptName, .. ]) +{ + var iterations = args.Length >= 3 && int.TryParse(args[2], out var parsedAotIterations) + ? parsedAotIterations + : 1; + RubyScriptLoader.RunQuickScript(aotScriptName, iterations, aot: true); + return; +} + BenchmarkSwitcher.FromAssembly(Assembly.GetEntryAssembly()!).Run(args); [Config(typeof(BenchmarkConfig))] diff --git a/sandbox/ChibiRuby.Benchmark/RubyScriptLoader.cs b/sandbox/ChibiRuby.Benchmark/RubyScriptLoader.cs index 35b5111b..0083f410 100644 --- a/sandbox/ChibiRuby.Benchmark/RubyScriptLoader.cs +++ b/sandbox/ChibiRuby.Benchmark/RubyScriptLoader.cs @@ -1,9 +1,12 @@ using System; +using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using ChibiRuby.Compiler; +using ChibiRuby.JetPack; namespace ChibiRuby.Benchmark; @@ -50,27 +53,100 @@ public RubyScriptLoader() mrbStateNative = NativeMethods.MrbOpen(); } - static void RegisterMathModule(MRubyState state) + public static void RunQuickScript(string scriptName, int iterations) { - state.DefineModule(state.Intern("Math"u8), mod => + RunQuickScript(scriptName, iterations, aot: false); + } + + public static void RunQuickScript(string scriptName, int iterations, bool aot) + { + if (iterations < 1) { - mod.DefineClassMethod(state.Intern("sqrt"u8), new MRubyMethod((s, self) => - { - var value = s.GetArgumentAsFloatAt(0); - return System.Math.Sqrt(value); - })); + iterations = 1; + } - mod.DefineClassMethod(state.Intern("cos"u8), new MRubyMethod((s, self) => - { - var value = s.GetArgumentAsFloatAt(0); - return System.Math.Cos(value); - })); + using var loader = new RubyScriptLoader(); + if (aot) + { + var compiled = loader.PreloadScriptFromFileAot(scriptName); + Console.Error.WriteLine($"AOT-compiled methods: {compiled}"); + } + else + { + loader.PreloadScriptFromFile(scriptName); + } - mod.DefineClassMethod(state.Intern("sin"u8), new MRubyMethod((s, self) => - { - var value = s.GetArgumentAsFloatAt(0); - return System.Math.Sin(value); - })); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var gc0 = GC.CollectionCount(0); + var gc1 = GC.CollectionCount(1); + var gc2 = GC.CollectionCount(2); + var pauseBefore = GC.GetTotalPauseDuration(); + var stopwatch = Stopwatch.StartNew(); + var result = MRubyValue.Nil; + for (var i = 0; i < iterations; i++) + { + result = loader.RunChibiRuby(); + } + stopwatch.Stop(); + var allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + var pauseMs = (GC.GetTotalPauseDuration() - pauseBefore).TotalMilliseconds; + var wallMs = stopwatch.Elapsed.TotalMilliseconds; + + Console.WriteLine( + $"ChibiRuby quick script={scriptName} aot={aot} iterations={iterations} elapsedMs={wallMs:F3} allocatedBytes={allocatedBytes} allocatedBytesPerIteration={(double)allocatedBytes / iterations:F0} result={result}"); + Console.WriteLine( + $"[gc] server={System.Runtime.GCSettings.IsServerGC} gen0={GC.CollectionCount(0) - gc0} gen1={GC.CollectionCount(1) - gc1} gen2={GC.CollectionCount(2) - gc2} pauseMs={pauseMs:F1} pausePct={(wallMs > 0 ? pauseMs / wallMs * 100 : 0):F1}%"); + } + + // AOT variant of PreloadScriptFromFile: compile the script, execute it once so the + // class hierarchy exists (lets the codegen resolve self-sends for devirtualize+inline), + // then AOT-compile every statically-compilable method to C#, register the bodies by + // fingerprint and bind them to the irep tree. Subsequent RunChibiRuby() calls hit the + // compiled bodies. Returns the number of methods compiled. + public int PreloadScriptFromFileAot(string fileName) + { + var source = ReadBytes(fileName); + currentChibiRubyIrep = CompileChibiRubySource(Encoding.UTF8.GetString(source)); + mrubyCSState.Execute(currentChibiRubyIrep); + var compiled = OptcarrotAotCompiler.CompileAndRegister(mrubyCSState, currentChibiRubyIrep); + mrubyCSState.BindCompiledMethods(currentChibiRubyIrep); + return compiled; + } + + static void RegisterMathModule(MRubyState state) + { + state.DefineModule(state.Intern("Math"u8), mod => + { + mod.DefineClassMethod(state.Intern("sqrt"u8), new MRubyMethod( + (s, self) => + { + var value = s.GetArgumentAsFloatAt(0); + return System.Math.Sqrt(value); + }, + (s, self, argument) => System.Math.Sqrt(s.AsFloat(argument)), + (_, _, argument) => System.Math.Sqrt(argument))); + + mod.DefineClassMethod(state.Intern("cos"u8), new MRubyMethod( + (s, self) => + { + var value = s.GetArgumentAsFloatAt(0); + return System.Math.Cos(value); + }, + (s, self, argument) => System.Math.Cos(s.AsFloat(argument)), + (_, _, argument) => System.Math.Cos(argument))); + + mod.DefineClassMethod(state.Intern("sin"u8), new MRubyMethod( + (s, self) => + { + var value = s.GetArgumentAsFloatAt(0); + return System.Math.Sin(value); + }, + (s, self, argument) => System.Math.Sin(s.AsFloat(argument)), + (_, _, argument) => System.Math.Sin(argument))); }); } @@ -124,6 +200,22 @@ public void PreloadOptcarrotRun(int frames = 180, bool printResult = true) currentChibiRubyIrep = CompileChibiRubySource(BuildOptcarrotRunSource(frames, printResult)); } + // Like PreloadOptcarrotBenchmark but AOT-compiles every statically-compilable + // optcarrot method to C# first, registering bodies by fingerprint and binding them + // to the definitions tree before it executes. Returns the number of methods compiled. + public int PreloadOptcarrotBenchmarkAot(int frames = 180, bool printResult = true) + { + var definitions = CompileChibiRubySource(BuildOptcarrotDefinitionsSource()); + // Define the classes first so the AOT compiler can resolve self-sends against + // the real class hierarchy (for devirtualize+inline), then compile + bind. + mrubyCSState.Execute(definitions); + var compiled = OptcarrotAotCompiler.CompileAndRegister(mrubyCSState, definitions); + mrubyCSState.BindCompiledMethods(definitions); + + PreloadOptcarrotRun(frames, printResult); + return compiled; + } + public MRubyValue RunChibiRuby() { return mrubyCSState.Execute(currentChibiRubyIrep!); diff --git a/sandbox/ChibiRuby.Benchmark/VariableTableAllocationBenchmark.cs b/sandbox/ChibiRuby.Benchmark/VariableTableAllocationBenchmark.cs new file mode 100644 index 00000000..cb2c053f --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/VariableTableAllocationBenchmark.cs @@ -0,0 +1,56 @@ +using BenchmarkDotNet.Attributes; + +namespace ChibiRuby.Benchmark; + +[Config(typeof(BenchmarkConfig))] +public class VariableTableAllocationBenchmark +{ + [Params(10_000)] + public int ObjectCount { get; set; } + + [Benchmark(Baseline = true)] + public long FourIvarsInline() => VariableTableAllocationWorkload.FourIvarsInline(ObjectCount); + + [Benchmark] + public long FiveIvarsPromoted() => VariableTableAllocationWorkload.FiveIvarsPromoted(ObjectCount); +} + +static class VariableTableAllocationWorkload +{ + static readonly Symbol X = new(10_001); + static readonly Symbol Y = new(10_002); + static readonly Symbol Z = new(10_003); + static readonly Symbol W = new(10_004); + static readonly Symbol Extra = new(10_005); + + public static long FourIvarsInline(int objectCount) + { + long sum = 0; + for (var i = 0; i < objectCount; i++) + { + var table = new VariableTable(); + table.Set(X, new MRubyValue(i)); + table.Set(Y, new MRubyValue(i + 1)); + table.Set(Z, new MRubyValue(i + 2)); + table.Set(W, new MRubyValue(i + 3)); + sum += table.Get(W).IntegerValue; + } + return sum; + } + + public static long FiveIvarsPromoted(int objectCount) + { + long sum = 0; + for (var i = 0; i < objectCount; i++) + { + var table = new VariableTable(); + table.Set(X, new MRubyValue(i)); + table.Set(Y, new MRubyValue(i + 1)); + table.Set(Z, new MRubyValue(i + 2)); + table.Set(W, new MRubyValue(i + 3)); + table.Set(Extra, new MRubyValue(i + 4)); + sum += table.Get(Extra).IntegerValue; + } + return sum; + } +} diff --git a/sandbox/ChibiRuby.Benchmark/VariableTableAllocationCounter.cs b/sandbox/ChibiRuby.Benchmark/VariableTableAllocationCounter.cs new file mode 100644 index 00000000..d407ead9 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/VariableTableAllocationCounter.cs @@ -0,0 +1,52 @@ +using System; +using System.Diagnostics; + +namespace ChibiRuby.Benchmark; + +static class VariableTableAllocationCounter +{ + public static void Run(int objectCount) + { + objectCount = objectCount <= 0 ? 100_000 : objectCount; + + VariableTableAllocationWorkload.FourIvarsInline(1_000); + VariableTableAllocationWorkload.FiveIvarsPromoted(1_000); + + var inline = Measure(() => VariableTableAllocationWorkload.FourIvarsInline(objectCount)); + var promoted = Measure(() => VariableTableAllocationWorkload.FiveIvarsPromoted(objectCount)); + + Console.WriteLine($"VariableTable allocation counter, objects={objectCount}"); + Print("FourIvarsInline", inline, objectCount); + Print("FiveIvarsPromoted", promoted, objectCount); + Console.WriteLine( + $"Delta: {promoted.AllocatedBytes - inline.AllocatedBytes:N0} B total, " + + $"{(double)(promoted.AllocatedBytes - inline.AllocatedBytes) / objectCount:N2} B/object"); + } + + static (long Result, long AllocatedBytes, long ElapsedTicks) Measure(Func action) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + var timestamp = Stopwatch.GetTimestamp(); + var result = action(); + var elapsedTicks = Stopwatch.GetTimestamp() - timestamp; + var allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - before; + GC.KeepAlive(result); + return (result, allocatedBytes, elapsedTicks); + } + + static void Print( + string name, + (long Result, long AllocatedBytes, long ElapsedTicks) measurement, + int objectCount) + { + var elapsed = TimeSpan.FromSeconds((double)measurement.ElapsedTicks / Stopwatch.Frequency); + Console.WriteLine( + $"{name}: allocated={measurement.AllocatedBytes:N0} B " + + $"({(double)measurement.AllocatedBytes / objectCount:N2} B/object), " + + $"elapsed={elapsed.TotalMilliseconds:N2} ms, checksum={measurement.Result}"); + } +} diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render.rb index 23b3c547..5dc56656 100644 --- a/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render.rb +++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render.rb @@ -255,6 +255,7 @@ def ambient_occlusion(isect) def render(w, h, nsubsamples) nsf = nsubsamples.to_f nsfs = nsf * nsf + sum = 0 h.times do |y| w.times do |x| rad = Vec.new(0.0, 0.0, 0.0) @@ -293,11 +294,13 @@ def render(w, h, nsubsamples) r = rad.x / nsfs g = rad.y / nsfs b = rad.z / nsfs + sum = sum + clamp(r) + clamp(g) + clamp(b) # printf("%c", clamp(r)) # printf("%c", clamp(g)) # printf("%c", clamp(b)) end end + sum end end diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render_checksum.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render_checksum.rb new file mode 100644 index 00000000..cf933dd8 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_ao_render_checksum.rb @@ -0,0 +1,296 @@ +# Checksummed AO render: same as bm_ao_render but accumulates clamp(r,g,b) into a global +# so interpreted vs AOT output can be compared exactly (the plain bench discards pixels). +IMAGE_WIDTH = 32 +IMAGE_HEIGHT = IMAGE_WIDTH +NSUBSAMPLES = 2 +NAO_SAMPLES = 8 + +$checksum = 0 + +module Rand + @x = 123456789 + @y = 362436069 + @z = 521288629 + @w = 88675123 + BNUM = 1 << 29 + BNUMF = BNUM.to_f + def self.rand + x = @x + t = x ^ ((x & 0xfffff) << 11) + w = @w + @x, @y, @z = @y, @z, w + w = @w = (w ^ (w >> 19) ^ (t ^ (t >> 8))) + (w % BNUM) / BNUMF + end +end + +class Vec + def initialize(x, y, z) + @x = x + @y = y + @z = z + end + + def x=(v); @x = v; end + def y=(v); @y = v; end + def z=(v); @z = v; end + def x; @x; end + def y; @y; end + def z; @z; end + + def vadd(b) + Vec.new(@x + b.x, @y + b.y, @z + b.z) + end + + def vsub(b) + Vec.new(@x - b.x, @y - b.y, @z - b.z) + end + + def vcross(b) + Vec.new(@y * b.z - @z * b.y, + @z * b.x - @x * b.z, + @x * b.y - @y * b.x) + end + + def vdot(b) + r = @x * b.x + @y * b.y + @z * b.z + r + end + + def vlength + Math.sqrt(@x * @x + @y * @y + @z * @z) + end + + def vnormalize + len = vlength + v = Vec.new(@x, @y, @z) + if len > 1.0e-17 + v.x = v.x / len + v.y = v.y / len + v.z = v.z / len + end + v + end +end + +class Sphere + def initialize(center, radius) + @center = center + @radius = radius + end + + def center; @center; end + def radius; @radius; end + + def intersect(ray, isect) + rs = ray.org.vsub(@center) + b = rs.vdot(ray.dir) + c = rs.vdot(rs) - (@radius * @radius) + d = b * b - c + if d > 0.0 + t = - b - Math.sqrt(d) + + if t > 0.0 and t < isect.t + isect.t = t + isect.hit = true + isect.pl = Vec.new(ray.org.x + ray.dir.x * t, + ray.org.y + ray.dir.y * t, + ray.org.z + ray.dir.z * t) + n = isect.pl.vsub(@center) + isect.n = n.vnormalize + end + end + end +end + +class Plane + def initialize(p, n) + @p = p + @n = n + end + + def intersect(ray, isect) + d = -@p.vdot(@n) + v = ray.dir.vdot(@n) + v0 = v + if v < 0.0 + v0 = -v + end + if v0 < 1.0e-17 + return + end + + t = -(ray.org.vdot(@n) + d) / v + + if t > 0.0 and t < isect.t + isect.hit = true + isect.t = t + isect.n = @n + isect.pl = Vec.new(ray.org.x + t * ray.dir.x, + ray.org.y + t * ray.dir.y, + ray.org.z + t * ray.dir.z) + end + end +end + +class Ray + def initialize(org, dir) + @org = org + @dir = dir + end + + def org; @org; end + def org=(v); @org = v; end + def dir; @dir; end + def dir=(v); @dir = v; end +end + +class Isect + def initialize + @t = 10000000.0 + @hit = false + @pl = Vec.new(0.0, 0.0, 0.0) + @n = Vec.new(0.0, 0.0, 0.0) + end + + def t; @t; end + def t=(v); @t = v; end + def hit; @hit; end + def hit=(v); @hit = v; end + def pl; @pl; end + def pl=(v); @pl = v; end + def n; @n; end + def n=(v); @n = v; end +end + +def clamp(f) + i = f * 255.5 + if i > 255.0 + i = 255.0 + end + if i < 0.0 + i = 0.0 + end + i.to_i +end + +def orthoBasis(basis, n) + basis[2] = Vec.new(n.x, n.y, n.z) + basis[1] = Vec.new(0.0, 0.0, 0.0) + + if n.x < 0.6 and n.x > -0.6 + basis[1].x = 1.0 + elsif n.y < 0.6 and n.y > -0.6 + basis[1].y = 1.0 + elsif n.z < 0.6 and n.z > -0.6 + basis[1].z = 1.0 + else + basis[1].x = 1.0 + end + + basis[0] = basis[1].vcross(basis[2]) + basis[0] = basis[0].vnormalize + + basis[1] = basis[2].vcross(basis[0]) + basis[1] = basis[1].vnormalize +end + +class Scene + def initialize + @spheres = Array.new + @spheres[0] = Sphere.new(Vec.new(-2.0, 0.0, -3.5), 0.5) + @spheres[1] = Sphere.new(Vec.new(-0.5, 0.0, -3.0), 0.5) + @spheres[2] = Sphere.new(Vec.new(1.0, 0.0, -2.2), 0.5) + @plane = Plane.new(Vec.new(0.0, -0.5, 0.0), Vec.new(0.0, 1.0, 0.0)) + end + + def ambient_occlusion(isect) + basis = Array.new(3) + orthoBasis(basis, isect.n) + + ntheta = NAO_SAMPLES + nphi = NAO_SAMPLES + eps = 0.0001 + occlusion = 0.0 + + p0 = Vec.new(isect.pl.x + eps * isect.n.x, + isect.pl.y + eps * isect.n.y, + isect.pl.z + eps * isect.n.z) + nphi.times do + ntheta.times do + r = Rand::rand + phi = 2.0 * 3.14159265 * Rand::rand + x = Math.cos(phi) * Math.sqrt(1.0 - r) + y = Math.sin(phi) * Math.sqrt(1.0 - r) + z = Math.sqrt(r) + + rx = x * basis[0].x + y * basis[1].x + z * basis[2].x + ry = x * basis[0].y + y * basis[1].y + z * basis[2].y + rz = x * basis[0].z + y * basis[1].z + z * basis[2].z + + raydir = Vec.new(rx, ry, rz) + ray = Ray.new(p0, raydir) + + occisect = Isect.new + @spheres[0].intersect(ray, occisect) + @spheres[1].intersect(ray, occisect) + @spheres[2].intersect(ray, occisect) + @plane.intersect(ray, occisect) + if occisect.hit + occlusion = occlusion + 1.0 + end + end + end + + occlusion = (ntheta.to_f * nphi.to_f - occlusion) / (ntheta.to_f * nphi.to_f) + Vec.new(occlusion, occlusion, occlusion) + end + + def render(w, h, nsubsamples) + nsf = nsubsamples.to_f + nsfs = nsf * nsf + h.times do |y| + w.times do |x| + rad = Vec.new(0.0, 0.0, 0.0) + + nsubsamples.times do |v| + nsubsamples.times do |u| + wf = w.to_f + hf = h.to_f + xf = x.to_f + yf = y.to_f + uf = u.to_f + vf = v.to_f + + px = (xf + (uf / nsf) - (wf / 2.0)) / (wf / 2.0) + py = -(yf + (vf / nsf) - (hf / 2.0)) / (hf / 2.0) + + eye = Vec.new(px, py, -1.0).vnormalize + + ray = Ray.new(Vec.new(0.0, 0.0, 0.0), eye) + + isect = Isect.new + @spheres[0].intersect(ray, isect) + @spheres[1].intersect(ray, isect) + @spheres[2].intersect(ray, isect) + @plane.intersect(ray, isect) + if isect.hit + col = ambient_occlusion(isect) + rad.x = rad.x + col.x + rad.y = rad.y + col.y + rad.z = rad.z + col.z + end + end + end + + r = rad.x / nsfs + g = rad.y / nsfs + b = rad.z / nsfs + $checksum = $checksum + clamp(r) + clamp(g) + clamp(b) + end + end + end +end + +Scene.new.render(IMAGE_WIDTH, IMAGE_HEIGHT, NSUBSAMPLES) +$checksum diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_constret_test.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_constret_test.rb new file mode 100644 index 00000000..48adfb54 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_constret_test.rb @@ -0,0 +1,19 @@ +# Constant-returning-method devirt test: 0-arg methods that return an immediate constant, +# called CROSS-OBJECT (cfg.w), including multi-level delegation (total -> w, deep -> total -> w). +class Config + def w; 256; end # single-level fixnum constant + def h; 240; end + def scale; 2.5; end # float constant + def total; w; end # multi-level: total -> w -> 256 + def deep; total; end # 3-level: deep -> total -> w -> 256 + def enabled; true; end # bool constant +end + +class Runner + def run(cfg) + cfg.w + cfg.h + cfg.total + cfg.deep + (cfg.scale * 4.0) + (cfg.enabled ? 100 : 0) + end +end + +# 256 + 240 + 256 + 256 + 10.0 + 100 = 1118.0 +Runner.new.run(Config.new) diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_loop_adv.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_loop_adv.rb new file mode 100644 index 00000000..b76472bd --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_loop_adv.rb @@ -0,0 +1,42 @@ +class Adv + # arg actually Float -> entry IsFixnum guard must deopt, result still correct + def divloop(s) + x = 0; sum = 0.0 + while x < 5 + sum = sum + (2.0 * x / s) + x += 1 + end + sum + end + # polymorphic loop var (sometimes Float, sometimes Fixnum) -> must stay boxed/correct + def poly(n) + i = 0; acc = 0.0 + while i < n + v = (i % 2 == 0) ? 1.5 : 3 + acc = acc + v + i += 1 + end + acc + end + # int accumulator + float reads mixed + def mix(n) + i = 0; s = 0.0 + while i <= n + s = s + i * 0.5 - 1 + i += 1 + end + s + end + # non-numeric touched in loop (string build) alongside numeric + def strnum(n) + i = 0; t = 0 + while i < n + str = "x" * 1 + t = t + str.length + i + i += 1 + end + t + end +end +a = Adv.new +[a.divloop(3), a.divloop(2.0), a.poly(6), a.mix(10), a.strnum(20)] diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_so_mandelbrot.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_so_mandelbrot.rb index 4915e418..5bd66cc5 100644 --- a/sandbox/ChibiRuby.Benchmark/ruby/bm_so_mandelbrot.rb +++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_so_mandelbrot.rb @@ -4,62 +4,72 @@ # contributed by Karl von Laudermann # modified by Jeremy Echols # optimized: while loops instead of for..in to avoid closure overhead +# +# Wrapped in a method (returning a checksum of the bytes that would be printed) +# so the while-loop body is AOT-compiled and invoked via method dispatch — the +# AOT path only fires for methods, not the top-level script body. The checksum +# also keeps the loop from being dead-code-eliminated. -size = 600 # ARGV[0].to_i +class Mandelbrot + def render(size) + # Cache constants in local variables to avoid repeated constant lookup + iter = 49 + limit_squared = 4.0 -# puts "P4\n#{size} #{size}" + byte_acc = 0 + bit_num = 0 -# Cache constants in local variables to avoid repeated constant lookup -iter = 49 -limit_squared = 4.0 + count_size = size - 1 + sum = 0 -byte_acc = 0 -bit_num = 0 + # Use while loops instead of for..in to avoid closure/upvalue overhead + y = 0 + while y <= count_size + x = 0 + while x <= count_size + zr = 0.0 + zi = 0.0 + cr = (2.0 * x / size) - 1.5 + ci = (2.0 * y / size) - 1.0 + escape = false -count_size = size - 1 + # Use while instead of for..in to avoid closure overhead + i = 0 + while i <= iter + tr = zr * zr - zi * zi + cr + ti = 2 * zr * zi + ci + zr = tr + zi = ti -# Use while loops instead of for..in to avoid closure/upvalue overhead -y = 0 -while y <= count_size - x = 0 - while x <= count_size - zr = 0.0 - zi = 0.0 - cr = (2.0*x/size)-1.5 - ci = (2.0*y/size)-1.0 - escape = false + if (zr * zr + zi * zi) > limit_squared + escape = true + break + end + i += 1 + end - # Use while instead of for..in to avoid closure overhead - i = 0 - while i <= iter - tr = zr*zr - zi*zi + cr - ti = 2*zr*zi + ci - zr = tr - zi = ti + byte_acc = (byte_acc << 1) | (escape ? 0b0 : 0b1) + bit_num += 1 - if (zr*zr+zi*zi) > limit_squared - escape = true - break + # Code is very similar for these cases, but using separate blocks + # ensures we skip the shifting when it's unnecessary, which is most cases. + if bit_num == 8 + sum = (sum + byte_acc) & 0x3fffffff + byte_acc = 0 + bit_num = 0 + elsif x == count_size + byte_acc <<= (8 - bit_num) + sum = (sum + byte_acc) & 0x3fffffff + byte_acc = 0 + bit_num = 0 + end + x += 1 end - i += 1 + y += 1 end - byte_acc = (byte_acc << 1) | (escape ? 0b0 : 0b1) - bit_num += 1 - - # Code is very similar for these cases, but using separate blocks - # ensures we skip the shifting when it's unnecessary, which is most cases. - if (bit_num == 8) - # print byte_acc.chr - byte_acc = 0 - bit_num = 0 - elsif (x == count_size) - byte_acc <<= (8 - bit_num) - # print byte_acc.chr - byte_acc = 0 - bit_num = 0 - end - x += 1 + sum end - y += 1 -end \ No newline at end of file +end + +Mandelbrot.new.render(600) diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_stackobj_test.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_stackobj_test.rb new file mode 100644 index 00000000..ad8061c1 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_stackobj_test.rb @@ -0,0 +1,139 @@ +# Stack-allocation test: `p` is created in a compiled method's while-loop and passed to a +# non-inlinable method that only READS it (non-retaining) -> p should be stack-allocated. +class Pt + def initialize(x, y) + @x = x + @y = y + end + def x; @x; end + def y; @y; end +end + +# By-REF mutated arg (A): Box is passed to a non-inlinable method that MUTATES it via setters. +# It stays stack-allocated (passed by ref); reads after the call see the mutations. +class Box + def initialize + @v = 1.0 + end + def v; @v; end + def v=(x); @v = x; end +end + +# Struct-RECEIVER variant: a stack object is the receiver of a non-accessor method that reads +# self's ivars directly (`@x`) and doesn't retain/mutate self. Big body so it is NOT inlined. +class V3 + def initialize(x, y, z) + @x = x + @y = y + @z = z + end + def blend + a = @x * @x + @y * @y + @z * @z + b = @x - @y + @z * @x - @y * 0.5 + @z * 0.5 + c = a + b - a * b + a * 0.25 + b * 0.75 + d = c * c - c + c * a - c * b + a * b + e = d + d - d * 0.5 + a - b + c + f = a * b + c * d + e * a - b * c + d * e + a + b + c + d + e + f + end +end + +# Nested struct field: @inner is a V3 built from literals inside initialize. Reading h.inner +# yields the inner V3 as a stack value that cascades into a struct-receiver `blend` call. +class Holder + def initialize + @inner = V3.new(2.0, 3.0, 6.0) + end + def inner; @inner; end +end + +# Literal-initialized fields (no ctor args) — exercises the stack-layout literal path. +class Lit + def initialize + @a = 10.0 + @b = 20.0 + end + def a; @a; end + def b; @b; end +end + +class Calc + # Big body (> 48 IR instr) so it is NOT inlined; reads q.a/q.b only (does not retain q). + def litsum(q) + a = q.a * q.a + q.b * q.b + b = q.a - q.b + q.a * q.b - q.a * 0.5 + q.b * 0.5 + c = a + b - a * b + a * 0.25 + b * 0.75 + d = c * c - c + c * a - c * b + a * b + e = d + d - d * 0.5 + a - b + c + f = a * b + c * d + e * a - b * c + d * e + a + b + c + d + e + f + end + + # Big body (> 48 IR instr) so it is NOT inlined; MUTATES b via setters (b passed by ref). + def bump(b) + b.v = b.v + 1.0 + b.v = b.v * 2.0 + b.v = b.v - 0.5 + b.v = b.v * b.v + b.v = b.v + 3.0 + b.v = b.v - 1.25 + b.v = b.v * 0.5 + b.v = b.v + 7.0 + b.v = b.v - 2.5 + b.v = b.v * 1.5 + b.v + end + + # Big body (> 48 IR instr) so it is NOT inlined; reads h.inner (a nested stack V3) and calls + # blend on it (struct-receiver via the nested-read cascade). Does not retain h. + def innerwork(h) + a = h.inner.blend + b = a * 0.5 + a - a * 0.25 + a * 2.0 - a * 3.0 + c = b + a - b * 0.5 + a * 0.75 - b * 1.5 + d = c * c - c + c * a - c * b + a * b + e = d + d - d * 0.5 + a - b + c + a + b + c + d + e + end + + # Big body (> 48 IR instr) so it is NOT inlined; reads p.x/p.y only (does not retain p). + def dist2(p) + a = p.x * p.x + p.y * p.y + b = p.x - p.y + p.x * p.y - p.x * 0.5 + p.y * 0.5 + c = a + b - a * b + a * 0.25 + b * 0.75 + d = c * c - c + c * a - c * b + a * b + e = d + d - d * 0.5 + a - b + c + f = a * b + c * d + e * a - b * c + d * e + g = f * 0.5 + a - b * c + d - e * f + h = g * g - f * 0.25 + a * b - c * d + e + i = h + g - f + e - d + c - b + a * h + j = i * 0.5 + h * 0.25 + g * 0.125 + f - e + k = j * j - i + h * g - f * e + d * c + l = k + j - i * 0.5 + h - g * 0.75 + f + a + b + c + d + e + f + g + h + i + j + k + l + end +end + +class Runner + # Loop-free (the AOT compiler bails on backward branches): create objects and pass them to a + # non-inlinable reader. Exercises the stack-allocation struct/variant/reify path. + def run + calc = Calc.new + p1 = Pt.new(1.0, 2.0) + p2 = Pt.new(3.0, 4.0) + p3 = Pt.new(5.0, 6.0) + l1 = Lit.new + l2 = Lit.new + v1 = V3.new(1.0, 2.0, 3.0) + v2 = V3.new(4.0, 5.0, 6.0) + h1 = Holder.new + h2 = Holder.new + b1 = Box.new + b2 = Box.new + r1 = calc.bump(b1) + r2 = calc.bump(b2) + calc.dist2(p1) + calc.dist2(p2) + calc.dist2(p3) + calc.litsum(l1) + calc.litsum(l2) + + v1.blend + v2.blend + calc.innerwork(h1) + calc.innerwork(h2) + r1 + r2 + b1.v + b2.v + end +end + +Runner.new.run diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_while_test.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_while_test.rb new file mode 100644 index 00000000..aefe0d63 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_while_test.rb @@ -0,0 +1,54 @@ +# Synthetic while-loop AOT test. Exercises backward branches (while), nested loops, +# break out of a loop, and mixed int arithmetic in a *method* (so it is AOT-compiled and +# invoked via dispatch). Returns a checksum the harness compares between interp / AOT-on / AOT-off. +class WhileBench + # Single counter loop with an accumulator. + def sum_to(n) + i = 0 + acc = 0 + while i < n + acc = (acc + i * 3 - 1) & 0x3fffffff + i += 1 + end + acc + end + + # Nested loops with an early break in the inner loop. + def grid(n) + y = 0 + total = 0 + while y < n + x = 0 + while x < n + v = x * y + y - x + if v > 500 + break + end + total = (total + v) & 0x3fffffff + x += 1 + end + y += 1 + end + total + end + + # until-style loop (post-decrement) to cover the other backward-branch shape. + def countdown(n) + acc = 0 + until n <= 0 + acc = (acc * 31 + n) & 0x3fffffff + n -= 1 + end + acc + end + + def run + s = 0 + s = (s + sum_to(1000)) & 0x3fffffff + s = (s + grid(60)) & 0x3fffffff + s = (s + countdown(2000)) & 0x3fffffff + s + end +end + +WhileBench.new.run diff --git a/sandbox/ChibiRuby.Benchmark/ruby/dbg_rand.rb b/sandbox/ChibiRuby.Benchmark/ruby/dbg_rand.rb new file mode 100644 index 00000000..3d499039 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/dbg_rand.rb @@ -0,0 +1,24 @@ +module Rand + @x = 123456789 + @y = 362436069 + @z = 521288629 + @w = 88675123 + BNUM = 1 << 29 + BNUMF = BNUM.to_f + def self.rand + x = @x + t = x ^ ((x & 0xfffff) << 11) + w = @w + @x, @y, @z = @y, @z, w + w = @w = (w ^ (w >> 19) ^ (t ^ (t >> 8))) + (w % BNUM) / BNUMF + end +end + +acc = 0 +i = 0 +while i < 1000 + acc = acc + (Rand::rand * 1e9).to_i + i = i + 1 +end +acc diff --git a/sandbox/ChibiRuby.Benchmark/ruby/dbg_vec.rb b/sandbox/ChibiRuby.Benchmark/ruby/dbg_vec.rb new file mode 100644 index 00000000..8834e03a --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/dbg_vec.rb @@ -0,0 +1,20 @@ +class Vec + def initialize(x, y, z); @x = x; @y = y; @z = z; end + def x; @x; end + def y; @y; end + def z; @z; end + def vdot(b); @x * b.x + @y * b.y + @z * b.z; end + def vlength; Math.sqrt(@x * @x + @y * @y + @z * @z); end + def vcross(b) + Vec.new(@y * b.z - @z * b.y, @z * b.x - @x * b.z, @x * b.y - @y * b.x) + end +end + +a = Vec.new(0.3, -0.7, 1.1) +b = Vec.new(2.0, 3.5, -1.2) +acc = 0 +acc = acc + (a.vdot(b) * 1e12).to_i +acc = acc + (a.vlength * 1e12).to_i +c = a.vcross(b) +acc = acc + (c.x * 1e12).to_i + (c.y * 1e12).to_i + (c.z * 1e12).to_i +acc diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_arith.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_arith.rb new file mode 100644 index 00000000..cc23c54b --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_arith.rb @@ -0,0 +1,14 @@ +class M + def calc(a, b, c) + a * b + c * a - b * c + a * a + b * b + a * c + end +end + +m = M.new +acc = 0.0 +i = 0 +while i < 4000000 + acc = m.calc(1.5, 2.5, 3.5) + i = i + 1 +end +acc diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_block.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_block.rb new file mode 100644 index 00000000..ba58af7b --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_block.rb @@ -0,0 +1,15 @@ +# Single-level times-block inline micro: sum_to news no objects but captures `s` (written by +# the block via an upvar). Interp and AOT must agree, and AOT should run the block as a C# loop. +def sum_to(n) + s = 0 + n.times { |i| s = s + i } + s +end + +acc = 0 +i = 0 +while i < 200000 + acc = sum_to(100) + i = i + 1 +end +acc diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_block_nested.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_block_nested.rb new file mode 100644 index 00000000..19a13ce6 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_block_nested.rb @@ -0,0 +1,19 @@ +# Nested times-block inline: the inner block writes `s` (a method local, depth-1 upvar) and +# reads `j` (the outer block's param, depth-0). Exercises C2 cell pass-through. +def grid(n) + s = 0 + n.times do |i| + n.times do |j| + s = s + i * j + end + end + s +end + +acc = 0 +k = 0 +while k < 100000 + acc = grid(20) + k = k + 1 +end +acc diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_branch.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_branch.rb new file mode 100644 index 00000000..7f06d73f --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_branch.rb @@ -0,0 +1,38 @@ +class Vec + def initialize(x, y, z) + @x = x + @y = y + @z = z + end + def x; @x; end + def y; @y; end + def z; @z; end + def vsub(b) + Vec.new(@x - b.x, @y - b.y, @z - b.z) + end + def vdot(b) + @x * b.x + @y * b.y + @z * b.z + end +end + +class Driver + def run(a, b, flag) + rs = a.vsub(b) # rs virtual, created before the branch + x = rs.vdot(b) # use rs before branch + if flag > 0.0 # branch does NOT touch rs + x = x + 1.0 + end + x + rs.vdot(rs) # use rs after the branch (rs lives across it) + end +end + +a = Vec.new(1.5, 2.5, 3.5) +b = Vec.new(4.5, 5.5, 6.5) +d = Driver.new +acc = 0.0 +i = 0 +while i < 2000000 + acc = d.run(a, b, -1.0) + i = i + 1 +end +acc diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_obj.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_obj.rb new file mode 100644 index 00000000..3d772324 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_obj.rb @@ -0,0 +1,34 @@ +class Vec + def initialize(x, y, z) + @x = x + @y = y + @z = z + end + def x; @x; end + def y; @y; end + def z; @z; end + def vsub(b) + Vec.new(@x - b.x, @y - b.y, @z - b.z) + end + def vdot(b) + @x * b.x + @y * b.y + @z * b.z + end +end + +class Driver + def run(a, b) + rs = a.vsub(b) + rs.vdot(b) + rs.vdot(rs) + end +end + +a = Vec.new(1.5, 2.5, 3.5) +b = Vec.new(4.5, 5.5, 6.5) +d = Driver.new +acc = 0.0 +i = 0 +while i < 4000000 + acc = d.run(a, b) + i = i + 1 +end +acc diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_prefix_loop.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_prefix_loop.rb new file mode 100644 index 00000000..0b3c90f6 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_prefix_loop.rb @@ -0,0 +1,11 @@ +def prefix_loop(n) + i = 0 + acc = 0 + while i < n + acc += i + i += 1 + end + acc +end + +prefix_loop(200000) diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_scalar.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_scalar.rb new file mode 100644 index 00000000..3fc19a4b --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_scalar.rb @@ -0,0 +1,27 @@ +# Scalar-replacement micro-benchmark. +# d2 news a Vec2 temporary that never escapes -> the AOT codegen must scalar-replace it +# (zero allocation, accessor sends become field-local reads). Interpreted allocates one +# Vec2 per call; AOT should allocate ~nothing. The hot while-loop runs interpreted and +# just dispatches into the compiled d2. + +class Vec2 + def initialize(x, y); @x = x; @y = y; end + def x; @x; end + def y; @y; end +end + +class Calc + def d2(px, py) + v = Vec2.new(px - 0.5, py - 0.25) + v.x * v.x + v.y * v.y + end +end + +c = Calc.new +sum = 0.0 +i = 0 +while i < 3000000 + sum = sum + c.d2(1.5, 2.0) + i = i + 1 +end +sum diff --git a/sandbox/ChibiRuby.Benchmark/ruby/micro_vself.rb b/sandbox/ChibiRuby.Benchmark/ruby/micro_vself.rb new file mode 100644 index 00000000..9427c336 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/micro_vself.rb @@ -0,0 +1,40 @@ +class Vec + def initialize(x, y, z) + @x = x + @y = y + @z = z + end + def x; @x; end + def y; @y; end + def z; @z; end + def vsub(b) + Vec.new(@x - b.x, @y - b.y, @z - b.z) + end + # Multi-statement with locals -> not an inline expression, so a call on a + # virtual receiver takes the virtual-self frame path instead of inlining. + def weighted(b) + s = @x * b.x + if s > 0.0 + s = s + @y * b.y + end + s + @z * b.z + end +end + +class Driver + def run(a, b) + rs = a.vsub(b) + rs.weighted(b) + rs.weighted(rs) + end +end + +a = Vec.new(1.5, 2.5, 3.5) +b = Vec.new(4.5, 5.5, 6.5) +d = Driver.new +acc = 0.0 +i = 0 +while i < 4000000 + acc = d.run(a, b) + i = i + 1 +end +acc diff --git a/sandbox/SampleConsoleApp/ruby/test.rb b/sandbox/SampleConsoleApp/ruby/test.rb index 656f5b4b..00ca7ef4 100644 --- a/sandbox/SampleConsoleApp/ruby/test.rb +++ b/sandbox/SampleConsoleApp/ruby/test.rb @@ -1,3 +1,8 @@ -puts $& -puts $` -puts $1 +x = 100 + +case x +in 0 + puts "0" +in 100.. + puts "100" +end \ No newline at end of file diff --git a/src/ChibiRuby.Cli/ChibiRuby.Cli.csproj b/src/ChibiRuby.Cli/ChibiRuby.Cli.csproj index 25687bd6..2549c4bd 100644 --- a/src/ChibiRuby.Cli/ChibiRuby.Cli.csproj +++ b/src/ChibiRuby.Cli/ChibiRuby.Cli.csproj @@ -21,6 +21,7 @@ + diff --git a/src/ChibiRuby.Cli/Program.cs b/src/ChibiRuby.Cli/Program.cs index 9187ab52..3f93f740 100644 --- a/src/ChibiRuby.Cli/Program.cs +++ b/src/ChibiRuby.Cli/Program.cs @@ -150,6 +150,121 @@ public static class {{className ?? "MRubyBytecodeEmbedded"}} writer.Write(sb.ToString()); writer.Flush(); } + + /// + /// mrb2cs: ahead-of-time compile Ruby (.rb) or bytecode (.mrb) to C# source. Accepts a single + /// file or a glob (e.g. "lib/**/*.rb"; ** = recurse). One .cs is emitted per input, with the + /// class named after the file, and the source subdirectory structure recreated under the output + /// directory. A .rb input is compiled to bytecode first, then converted. + /// + /// Input .rb / .mrb file, or a glob like "lib/**/*.rb". + /// -o, Output directory; source subdirectories are recreated under it (default: the glob's base directory). + /// C# namespace to wrap each generated class in (default: none). + [Command("mrb2cs")] + public void Mrb2CsCommand( + [Argument] string input, + string? outputDir = null, + string? csharpNamespace = null) + { + var (baseDir, files) = ExpandGlob(input); + if (files.Count == 0) + { + Console.Error.WriteLine($"mrb2cs: no .rb/.mrb input matched '{input}'"); + Environment.Exit(1); + } + outputDir ??= baseDir; + + var totalMethods = 0; + foreach (var file in files) + { + try + { + // Fresh state per file so one file's class definitions don't leak into another's. + var state = MRubyState.Create(); + var inputBytes = File.ReadAllBytes(file); + Irep irep; + if (IsBytecode(file, inputBytes)) + { + irep = state.ParseBytecode(inputBytes); + } + else + { + // .rb: compile to mruby bytecode first, then convert to C#. + var compiler = MRubyCompiler.Create(state); + using var compilation = compiler.Compile(inputBytes); + irep = state.ParseBytecode(compilation.AsBytecode().ToArray()); + } + + // True AOT: statically register the program's classes/methods WITHOUT running it. + ChibiRuby.JetPack.Mrb2Cs.DefinitionLoader.Load(state, irep); + var className = ToClassName(Path.GetFileNameWithoutExtension(file)); + var result = ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.Compile(state, irep, className, csharpNamespace); + + // Recreate the source's subdirectory (relative to the glob base) under outputDir. + // Auto-named output uses the `.g.cs` (generated) extension. + var relative = Path.GetRelativePath(baseDir, file); + var outPath = Path.Combine(outputDir, Path.ChangeExtension(relative, ".g.cs")); + var outFolder = Path.GetDirectoryName(outPath); + if (!string.IsNullOrEmpty(outFolder)) Directory.CreateDirectory(outFolder); + File.WriteAllText(outPath, result.Source); + totalMethods += result.Methods.Count; + Console.Error.WriteLine($"mrb2cs: {file} -> {outPath} (class {className}, {result.Methods.Count} methods)"); + } + catch (MRubyCompileException ex) + { + Console.Error.WriteLine($"{file}: {ex.Message}"); + Environment.Exit(1); + } + } + Console.Error.WriteLine($"mrb2cs: {files.Count} file(s), {totalMethods} methods total"); + } + + // Expand an input path/glob to a (baseDir, files) pair. baseDir is the leading wildcard-free + // prefix; output paths are made relative to it so the source subdirectory layout is preserved. + // Supports `*` (single segment) and `**` (recursive). Only .rb / .mrb files are returned. + static (string baseDir, List files) ExpandGlob(string pattern) + { + static bool IsSource(string f) => + f.EndsWith(".rb", StringComparison.OrdinalIgnoreCase) || + f.EndsWith(".mrb", StringComparison.OrdinalIgnoreCase); + + if (!pattern.Contains('*')) + { + var dir = Path.GetDirectoryName(pattern); + return (string.IsNullOrEmpty(dir) ? "." : dir, File.Exists(pattern) ? new List { pattern } : new()); + } + + var segments = pattern.Replace('\\', '/').Split('/'); + var baseParts = new List(); + foreach (var seg in segments) + { + if (seg.Contains('*')) break; + baseParts.Add(seg); + } + var baseDir = baseParts.Count > 0 ? string.Join("/", baseParts) : "."; + if (!Directory.Exists(baseDir)) return (baseDir, new()); + + var fileGlob = segments[^1].Contains('*') ? segments[^1] : "*"; + var option = pattern.Contains("**") ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var files = Directory.EnumerateFiles(baseDir, fileGlob, option) + .Where(IsSource) + .OrderBy(f => f, StringComparer.Ordinal) + .ToList(); + return (baseDir, files); + } + + // A valid C# identifier from a file name (non-identifier chars -> '_', digit-leading -> '_'-prefixed). + static string ToClassName(string fileName) + { + var sb = new StringBuilder(); + foreach (var c in fileName) + { + sb.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_'); + } + var name = sb.ToString(); + return name.Length == 0 || char.IsDigit(name[0]) ? "_" + name : name; + } + } enum OutputFormat diff --git a/src/ChibiRuby.JetPack/ChibiRuby.JetPack.csproj b/src/ChibiRuby.JetPack/ChibiRuby.JetPack.csproj new file mode 100644 index 00000000..ac5a9a9c --- /dev/null +++ b/src/ChibiRuby.JetPack/ChibiRuby.JetPack.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net9.0;net10.0;netstandard2.1 + enable + 13 + true + ChibiRuby.JetPack + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/Analyzer.cs b/src/ChibiRuby.JetPack/Mrb2Cs/Analyzer.cs new file mode 100644 index 00000000..4d227f29 --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/Analyzer.cs @@ -0,0 +1,2079 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using ChibiRuby; +namespace ChibiRuby.JetPack.Mrb2Cs; + +// Ruby/IR analysis: program-wide devirtualization registries + the trivial-accessor / +// constant-return recognizers they are built from. Pure over the IR (produces facts; emits nothing). +public static class Analyzer +{ + // Trivial getter (`def f; @f; end`) / setter (`def f=(v); @f = v; end`) recognizer over a + // method irep. Returns (field, isSetter, fingerprint) or null. Used both by scalar + // replacement (field access on a scalar object) and by cross-object accessor devirt. + public static (Symbol Field, bool IsSetter, ulong Fingerprint)? TryRecognizeTrivialAccessor(MRubyState state, Irep irep) + { + if (!Mrb2CsCompiler.TryReadMandatoryArgCount(irep, out var argc) || argc > 1) return null; + RubyIRMethod? exe; + try { exe = RubyIRBuilder.Build(irep, 0, out _); } + catch { return null; } + if (exe is null) return null; + + var self = new HashSet { 0 }; + foreach (var ii in exe.Instructions) + { + if (ii.OpCode == RubyIROpCode.LoadSelf) self.Add(ii.Dst); + } + + Symbol field = default; + var found = false; + var isSetter = argc == 1; + foreach (var ii in exe.Instructions) + { + switch (ii.OpCode) + { + case RubyIROpCode.CheckArity: + case RubyIROpCode.LoadSelf: + case RubyIROpCode.Return: + case RubyIROpCode.ReturnValue: + case RubyIROpCode.ReturnSelf: + break; + case RubyIROpCode.GetInstanceVariable: + case RubyIROpCode.VirtualGetField: + if (isSetter || found || !self.Contains(ii.Src0)) return null; + field = exe.GetSymbol(ii.Aux); found = true; + break; + case RubyIROpCode.SetInstanceVariable: + case RubyIROpCode.VirtualSetField: + if (!isSetter || found || !self.Contains(ii.Src0) || ii.Src1 != 1) return null; + field = exe.GetSymbol(ii.Aux); found = true; + break; + default: + return null; + } + } + return found ? (field, isSetter, state.ComputeIrepFingerprint(irep)) : null; + } + + // A 0-arg method whose body is a PURE return of an immediate constant — directly (`def n; 8; end`) + // or by delegating to another such method (`def n; m; end` where `m` returns a constant — multi- + // level, resolved on `klass`). Returns (the constant, THIS method's fingerprint for the call-site + // guard) or null. Strict: the body may contain only the prologue, the single value producer + // (a LoadValue immediate or one 0-arg self-send), and the return — anything else (a real + // computation / side effect / non-immediate literal like a string) disqualifies it. + public static (MRubyValue Value, ulong Fingerprint)? TryRecognizeConstantReturn(MRubyState state, RClass klass, Irep irep, int depth = 0) + { + if (depth > 8) return null; // delegation-cycle backstop + if (!Mrb2CsCompiler.TryReadMandatoryArgCount(irep, out var argc) || argc != 0) return null; + RubyIRMethod? exe; + try { exe = RubyIRBuilder.Build(irep, 0, out _); } catch { return null; } + if (exe is null) return null; + var ins = exe.Instructions; + var defIndex = new int[exe.ValueCount]; + for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1; + for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; } + var selfIds = new HashSet { 0 }; + foreach (var u in ins) if (u.OpCode == RubyIROpCode.LoadSelf) selfIds.Add(u.Dst); + + var retId = -1; + foreach (var u in ins) + if (u.OpCode is RubyIROpCode.Return or RubyIROpCode.ReturnValue) retId = u.Src0; + else if (u.OpCode == RubyIROpCode.ReturnSelf) return null; + if (retId < 0 || (uint)retId >= (uint)defIndex.Length || defIndex[retId] < 0) return null; + var prodIdx = defIndex[retId]; + + // Purity: every op is the prologue (CheckArity/LoadSelf), the value producer, or the return. + for (var i = 0; i < ins.Length; i++) + { + if (i == prodIdx) continue; + if (ins[i].OpCode is not (RubyIROpCode.CheckArity or RubyIROpCode.LoadSelf + or RubyIROpCode.Return or RubyIROpCode.ReturnValue)) return null; + } + + var fp = state.ComputeIrepFingerprint(irep); + var prod = ins[prodIdx]; + if (prod.OpCode == RubyIROpCode.LoadValue) + { + var lit = exe.GetLiteral(prod.Aux); + return Emitter.TryEmitLiteral(lit, out _) ? (lit, fp) : null; // immediates only (a string literal is a fresh object) + } + if (prod.OpCode is RubyIROpCode.Send or RubyIROpCode.SendSelf && + (prod.OpCode == RubyIROpCode.SendSelf || selfIds.Contains(prod.Src0)) && + exe.GetCallSiteArgumentCount(prod.Aux) == 0) + { + var sel2 = exe.GetCallSiteSymbol(prod.Aux); + if (state.TryFindMethod(klass, sel2, out var m2, out _) && m2.Proc is { } p2 && + TryRecognizeConstantReturn(state, klass, p2.Irep, depth + 1) is { } inner) + { + return (inner.Value, fp); // inner's constant, THIS method's fp for the guard + } + } + return null; + } + + + // Program-wide map: method selector -> the trivial-accessor it denotes, for cross-object + // devirtualization. Built by walking every method on the class tree. A selector whose + // trivial-accessor reading is ambiguous (two classes resolve it to different field/body) + // is dropped — a guarded devirt would just deopt for one of them anyway. Selectors that + // are sometimes non-accessor methods stay registered; the per-site fingerprint guard + // sends those receivers down the slow path, so it remains correct. + public static Dictionary BuildAccessorRegistry(MRubyState state) + { + var map = new Dictionary(); + var ambiguous = new HashSet(); + state.EnumerateAotMethods((_, methodId, irep) => + { + if (ambiguous.Contains(methodId)) return; + var acc = TryRecognizeTrivialAccessor(state, irep); + if (acc is null) return; + var target = new AccessorTarget(acc.Value.Field, acc.Value.Fingerprint, acc.Value.IsSetter); + if (map.TryGetValue(methodId, out var prev)) + { + if (!prev.Equals(target)) + { + map.Remove(methodId); + ambiguous.Add(methodId); + } + } + else + { + map[methodId] = target; + } + }); + return map; + } + + // Program-wide map: selector -> the immediate constant a 0-arg method of that name returns + // (directly or via delegation). Ambiguity (two classes' same-named methods return different + // constants) drops the selector; the per-site fingerprint guard sends non-matching receivers + // to the slow path, so a selector that is a constant-returner for one class and a real method + // for another stays correct. + public static Dictionary BuildConstReturnRegistry(MRubyState state) + { + var map = new Dictionary(); + var ambiguous = new HashSet(); + state.EnumerateAotMethods((cls, methodId, irep) => + { + if (ambiguous.Contains(methodId)) return; + if (TryRecognizeConstantReturn(state, cls, irep) is not { } c) return; + var target = new ConstReturnTarget(c.Value, c.Fingerprint); + if (map.TryGetValue(methodId, out var prev)) + { + if (!prev.Equals(target)) { map.Remove(methodId); ambiguous.Add(methodId); } + } + else + { + map[methodId] = target; + } + }); + return map; + } + + public static Dictionary BuildInlineSelectorRegistry( + MRubyState state, + IReadOnlyDictionary inlineRegistry) + { + var map = new Dictionary(); + var ambiguous = new HashSet(); + state.EnumerateAotMethods((definingClass, methodId, irep) => + { + if (ambiguous.Contains(methodId)) return; + var fp = state.ComputeIrepFingerprint(irep); + if (!inlineRegistry.TryGetValue(fp, out var argc)) return; + if (TryRecognizeTrivialAccessor(state, irep) is not null) return; + if (!TryReadInlineSelectorShape(irep, out var returnsNew)) return; + + var target = new InlineSelectorTarget(irep, argc, fp, definingClass, returnsNew); + if (map.TryGetValue(methodId, out var prev)) + { + if (prev.Fingerprint != fp || prev.ArgCount != argc) + { + map.Remove(methodId); + ambiguous.Add(methodId); + } + } + else + { + map[methodId] = target; + } + }); + return map; + } + + // Build the struct layout for a stack-allocatable class, or null if its initialize isn't a + // straight-line sequence of `@f = ` / `@f = ` stores to self (no method + // calls, branches, or reads of other state). Every field starts Boxed/not-Mutated; ②/① fill + // FieldKinds/FieldNested/Mutated. The initialize fingerprint names the struct type. + // + // Dedicated to stack allocation (NOT ScalarContext.AnalyzeInitialize, which only models `@f=arg` + // and feeds scalar replacement's EmitScalarNew); broadening that would change scalar behavior. + static StackLayout? GetStackLayout(MRubyState state, RClass klass, Symbol constName) + { + if (!state.TryFindMethod(klass, state.Intern("initialize"u8), out var method, out _) || + method.Proc is not { } proc || + !Mrb2CsCompiler.TryReadMandatoryArgCount(proc.Irep, out var iargc)) + { + return null; + } + RubyIRMethod? exe; + try { exe = RubyIRBuilder.Build(proc.Irep, 0, out _); } + catch { return null; } + if (exe is null) return null; + var ins = exe.Instructions; + var defIndex = new int[exe.ValueCount]; + for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1; + for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; } + var selfVids = new HashSet { 0 }; + foreach (var li in ins) if (li.OpCode == RubyIROpCode.LoadSelf) selfVids.Add(li.Dst); + + var fields = new List(); + var fieldArg = new List(); + var fieldLiteral = new List(); + var fieldKinds = new List(); + var fieldNested = new List(); + foreach (var ii in ins) + { + switch (ii.OpCode) + { + case RubyIROpCode.CheckArity: + case RubyIROpCode.LoadSelf: + case RubyIROpCode.LoadValue: // a literal we may store below + case RubyIROpCode.GetConstant: // a class for a nested new we may store below + case RubyIROpCode.VirtualNew: // a nested object we may store below + case RubyIROpCode.Return: + case RubyIROpCode.ReturnValue: + case RubyIROpCode.ReturnSelf: + break; + case RubyIROpCode.SetInstanceVariable: + case RubyIROpCode.VirtualSetField: + if (!selfVids.Contains(ii.Src0)) return null; + var v = ii.Src1; + if (v >= 1 && v <= iargc) // @f = ctor arg + { + fields.Add(exe.GetSymbol(ii.Aux)); + fieldArg.Add(v - 1); fieldLiteral.Add(default); + fieldKinds.Add(StackFieldKind.Boxed); fieldNested.Add(null); + } + else if ((uint)v < (uint)defIndex.Length && defIndex[v] >= 0 && + ins[defIndex[v]].OpCode == RubyIROpCode.LoadValue) // @f = literal + { + fields.Add(exe.GetSymbol(ii.Aux)); + fieldArg.Add(-1); fieldLiteral.Add(exe.GetLiteral(ins[defIndex[v]].Aux)); + fieldKinds.Add(StackFieldKind.Boxed); fieldNested.Add(null); + } + else if (TryBuildNestedFill(state, exe, defIndex, v) is { } nested) // @f = Vec.new(literals) + { + fields.Add(exe.GetSymbol(ii.Aux)); + fieldArg.Add(-1); fieldLiteral.Add(default); + fieldKinds.Add(StackFieldKind.Nested); fieldNested.Add(nested); + } + else + { + return null; // @f = computed value / non-literal nested new -> not yet + } + break; + default: + return null; + } + } + if (fields.Count == 0) return null; + var fp = state.ComputeIrepFingerprint(proc.Irep); + return new StackLayout + { + Cls = klass, + ClassFp = fp, + ConstName = constName, + NameSuffix = Emitter.NameSuffixFor(Encoding.UTF8.GetString(state.NameOf(constName))), + InitFingerprint = fp, + Fields = fields, + FieldArg = fieldArg, + FieldLiteral = fieldLiteral, + FieldKinds = fieldKinds, + FieldNested = fieldNested, + }; + } + + // A field initialized by `@f = Klass.new()`: returns a flattened inner layout whose + // fields are all literal-filled (so it nests as a value with no heap allocation), or null if + // the value isn't a nested `new` with literal args (computed args = ① ctor-arg nesting, later). + static StackLayout? TryBuildNestedFill(MRubyState state, RubyIRMethod exe, int[] defIndex, int valueId) + { + var ins = exe.Instructions; + if ((uint)valueId >= (uint)defIndex.Length || defIndex[valueId] < 0) return null; + var def = ins[defIndex[valueId]]; + if (def.OpCode != RubyIROpCode.VirtualNew) return null; + // Resolve the constructed class from the .new receiver (a GetConstant), tracing Moves. + var classVid = def.Src0; Symbol cn = default; RClass? kc = null; + for (var hops = 0; hops < ins.Length; hops++) + { + if ((uint)classVid >= (uint)defIndex.Length || defIndex[classVid] < 0) break; + var d = ins[defIndex[classVid]]; + if (d.OpCode == RubyIROpCode.Move) { classVid = d.Src0; continue; } + if (d.OpCode != RubyIROpCode.GetConstant) break; + cn = exe.GetSymbol(d.Aux); + if (state.TryGetConst(cn, out var cv) && cv.Object is RClass k) kc = k; + break; + } + if (kc is null) return null; + var inner = GetStackLayout(state, kc, cn); + if (inner is null) return null; + var argc = exe.GetCallSiteArgumentCount(def.Aux); + var litFill = new List(); + for (var i = 0; i < inner.Fields.Count; i++) + { + if (inner.FieldKinds[i] != StackFieldKind.Boxed) return null; // nested-nested: defer + if (inner.FieldArg[i] >= 0) // inner field <- nested new arg (must be literal) + { + if (inner.FieldArg[i] >= argc) return null; + var argVid = exe.GetCallSiteArgumentValueId(def.Aux, inner.FieldArg[i]); + if ((uint)argVid >= (uint)defIndex.Length || defIndex[argVid] < 0 || + ins[defIndex[argVid]].OpCode != RubyIROpCode.LoadValue) return null; + litFill.Add(exe.GetLiteral(ins[defIndex[argVid]].Aux)); + } + else + { + litFill.Add(inner.FieldLiteral[i]); // inner field is itself a literal + } + } + // Same struct TYPE as the generic inner layout (Stk_); only the fill is literal. + return new StackLayout + { + Cls = inner.Cls, + ClassFp = inner.ClassFp, + ConstName = inner.ConstName, + NameSuffix = inner.NameSuffix, // same struct TYPE (Stk_...) -> same suffix + InitFingerprint = inner.InitFingerprint, + Fields = inner.Fields, + FieldArg = inner.Fields.ConvertAll(_ => -1), + FieldLiteral = litFill, + FieldKinds = inner.Fields.ConvertAll(_ => StackFieldKind.Boxed), + FieldNested = inner.Fields.ConvertAll(_ => (StackLayout?)null), + }; + } + + // Find VirtualNew sites whose object can be built on the stack (as a struct) instead of + // heap-allocated: it never escapes EXCEPT by being passed as a non-retained argument to a + // callee (per the interprocedural escape summary). Field reads/writes on it and being the + // receiver of its own accessor sends are fine. Any other use (returned, stored to the heap, + // put in an array, passed as a ctor arg, receiver of a non-accessor send, passed to a callee + // that retains the arg) disqualifies it. This is ScalarContext's escape analysis relaxed to allow + // the proven-non-retaining cross-call argument pass. + // Conservative, single-method check: does `sel` on `klass` use self ONLY for reads — direct + // ivar gets and trivial getter sends on self — never retaining it (return self / store it / + // pass it as an arg / receiver of a non-getter send) or mutating it (ivar set / setter)? Such a + // callee is safe to compile as a read-only struct-RECEIVER variant (self passed by `in`). The + // escape summary only tracks params 1..argc (not self), so this is a focused, sound check + // (anything uncertain -> false). The variant still has a reify+Send fallback on guard miss. + static bool CalleeSelfReadOnly(MRubyState state, RClass klass, Symbol sel) + { + if (!state.TryFindMethod(klass, sel, out var m, out _) || m.Proc is not { } proc) return false; + RubyIRMethod? exe; + try { exe = RubyIRBuilder.Build(proc.Irep, 0, out _); } catch { return false; } + if (exe is null) return false; + if (Mrb2CsCompiler.SsaEnabled && Mrb2CsCompiler.TryReadMandatoryArgCount(proc.Irep, out var ac)) exe = RubyIRSsaRenumber.Run(exe, ac); + var ins = exe.Instructions; + var selfIds = new HashSet { 0 }; + foreach (var u in ins) if (u.OpCode == RubyIROpCode.LoadSelf) selfIds.Add(u.Dst); + var grew = true; + while (grew) + { + grew = false; + foreach (var u in ins) if (u.OpCode == RubyIROpCode.Move && selfIds.Contains(u.Src0) && selfIds.Add(u.Dst)) grew = true; + } + // self IS value-id 0, but unused IR operand slots also default to 0 — so the id-0 + // ambiguity must be kept out of VALUE-slot checks. A genuine self-as-value always flows + // through a LoadSelf temp (id > 0; mruby loads self into a register before using it as a + // value), so excluding 0 from value checks stays sound. Receiver (Src0) and Return operands + // are always meaningful, so they use the full set (incl 0). + bool IsSelfValue(int id) => id != 0 && selfIds.Contains(id); + foreach (var u in ins) + { + var op = u.OpCode; + if (op is RubyIROpCode.ReturnSelf) return false; // returns self + if (op == RubyIROpCode.Return && selfIds.Contains(u.Src0)) return false; // returns self + if (IsSelfValue(u.Src1) || IsSelfValue(u.Src2)) return false; // self stored as a value + // Self as receiver: only the genuinely dangerous ops disqualify (mutate self / a call + // that may retain it). Reads (GetInstanceVariable) and ops that don't use Src0 as a + // receiver (CheckArity, LoadSelf, ... — Src0 spuriously 0, self's own id) are fine; the + // escape paths are covered by the value/arg/return checks above and below. + if (selfIds.Contains(u.Src0)) + { + if (op is RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField or RubyIROpCode.SendSelf) + { + return false; // mutates self / self-context call that may retain self + } + if (op == RubyIROpCode.Send) + { + var s = exe.GetCallSiteSymbol(u.Aux); + if (!(state.TryFindMethod(klass, s, out var am, out _) && am.Proc is { } ap && + TryRecognizeTrivialAccessor(state, ap.Irep) is { IsSetter: false })) + { + return false; // non-getter send on self (could retain/mutate) + } + } + } + if (RubyIROpInfo.IsSendOp(op) || op is RubyIROpCode.VirtualNew or RubyIROpCode.PureUnarySend) + { + var argc = exe.GetCallSiteArgumentCount(u.Aux); + for (var a = 0; a < argc; a++) + if (IsSelfValue(exe.GetCallSiteArgumentValueId(u.Aux, a))) return false; // self passed as arg + } + } + return true; + } + + // `start` plus the value-ids that are (transitively) Move-copies of it — its alias set. + internal static HashSet MoveClosure(RubyIRMethod exe, int start) + { + var set = new HashSet { start }; + var grew = true; + while (grew) + { + grew = false; + foreach (var u in exe.Instructions) + if (u.OpCode == RubyIROpCode.Move && set.Contains(u.Src0) && set.Add(u.Dst)) + grew = true; + } + return set; + } + + internal static Dictionary FindStackEligible(MRubyState state, RubyIRMethod exe, RubyIREscapeSummary.Summary summary) + { + var result = new Dictionary(); + var ins = exe.Instructions; + var defIndex = new int[exe.ValueCount]; + for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1; + for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; } + + for (var i = 0; i < ins.Length; i++) + { + if (ins[i].OpCode != RubyIROpCode.VirtualNew) continue; + var objId = ins[i].Dst; + // Resolve the constructed class from the .new receiver (a GetConstant), tracing Moves. + var classVid = ins[i].Src0; + Symbol constName = default; + RClass? klass = null; + for (var hops = 0; hops < ins.Length; hops++) + { + if ((uint)classVid >= (uint)defIndex.Length || defIndex[classVid] < 0) break; + var def = ins[defIndex[classVid]]; + if (def.OpCode == RubyIROpCode.Move) { classVid = def.Src0; continue; } + if (def.OpCode != RubyIROpCode.GetConstant) break; + constName = exe.GetSymbol(def.Aux); + if (state.TryGetConst(constName, out var cv) && cv.Object is RClass kc) klass = kc; + break; + } + if (klass is null) + { + if (Environment.GetEnvironmentVariable("AOT_ESCAPE_DEBUG") == "1") + System.Console.Error.WriteLine($"[stackobj] v{objId}: class unresolved"); + continue; + } + + if (IsStackEligible(state, exe, defIndex, i, objId, klass, summary, out var aliases, out var mutated) && + GetStackLayout(state, klass, constName) is { } layout) + { + layout.Mutated = mutated; // mutated by a callee -> passed by ref (A) + // Map every alias (the VirtualNew dst + its Move copies) to the layout so each + // becomes a struct local; Moves between them are struct value-copies (sound for a + // read-only object; a ref/Mutated object's aliases share the same struct local too). + foreach (var a in aliases) result[a] = layout; + } + else if (Environment.GetEnvironmentVariable("AOT_ESCAPE_DEBUG") == "1") + { + System.Console.Error.WriteLine($"[stackobj] v{objId} = new {state.NameOf(constName)}: REJECTED ({StackRejectReason})"); + } + } + + PropagateNestedReads(state, exe, defIndex, result, summary); + DropUndispatchableMultiStructSends(state, exe, result); + return result; + } + + // Two lowerings dispatch a stack object at a Send: the RECEIVER (struct `self`, no struct args) + // via TryEmitStackReceiverSend, OR one-or-more struct ARGS (receiver not a struct) via + // TryEmitStackArgSend. A Send that is BOTH (struct receiver AND struct args) has no lowering, so + // drop its struct args, keeping the receiver. Fixpoint (dropping an object can relieve another + // Send). Multiple struct args with a non-struct receiver are fine (handled). + static void DropUndispatchableMultiStructSends(MRubyState state, RubyIRMethod exe, Dictionary result) + { + var ins = exe.Instructions; + var changed = true; + while (changed) + { + changed = false; + for (var j = 0; j < ins.Length; j++) + { + if (ins[j].OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) continue; + if (!result.ContainsKey(ins[j].Src0)) continue; // receiver not a struct -> arg path handles N args + var argc = exe.GetCallSiteArgumentCount(ins[j].Aux); + var victims = new List(); + for (var a = 0; a < argc; a++) + if (result.TryGetValue(exe.GetCallSiteArgumentValueId(ins[j].Aux, a), out var alay)) victims.Add(alay); + if (victims.Count == 0) continue; // struct receiver, no struct args -> receiver path handles it + foreach (var victim in victims) + foreach (var id in new List(result.Keys)) + if (ReferenceEquals(result[id], victim)) result.Remove(id); + changed = true; + break; // result mutated; restart the scan + } + } + } + + // Cascade: reading a Nested field of a stack object yields the inner struct as a value + // (`ray.dir` -> a Stk_Vec). Track that result value-id as a stack object too (using the inner + // layout) when it is itself used stack-safely, so e.g. `ray.dir.vdot(@n)` lowers to a struct- + // receiver variant instead of forcing a reify. Fixpoint (a nested read can feed another). The + // read itself is the dst's "def" (skipped via newIndex in the use check). Run BOTH after + // FindStackEligible (method/block-local objects) AND after a variant's struct param is + // registered (the param's own nested fields), so a `param.inner.m()` chain cascades too. + internal static void PropagateNestedReads(MRubyState state, RubyIRMethod exe, int[] defIndex, Dictionary result, RubyIREscapeSummary.Summary summary) + { + var ins = exe.Instructions; + var grew = true; + while (grew) + { + grew = false; + for (var j = 0; j < ins.Length; j++) + { + if (ins[j].OpCode != RubyIROpCode.Send) continue; + if (!result.TryGetValue(ins[j].Src0, out var olay)) continue; // recv is a stack object + if (result.ContainsKey(ins[j].Dst)) continue; // already tracked + var fi = NestedFieldGetterIndex(state, olay, exe.GetCallSiteSymbol(ins[j].Aux)); + if (fi < 0) continue; // not a nested-field getter + var innerLay = olay.FieldNested[fi]!; + // A nested-field read yields a COPY (`so90 = so30.f0`); mutating it would not write + // back to the parent, so only cascade read-only (non-mutated) inner values. + if (IsStackEligible(state, exe, defIndex, j, ins[j].Dst, innerLay.Cls, summary, out var caliases, out var cmut) && !cmut) + { + foreach (var a in caliases) result[a] = innerLay; + grew = true; + } + } + } + } + + // The field index whose trivial getter is `sel` on `lay.Cls` AND which is a Nested field, or -1. + static int NestedFieldGetterIndex(MRubyState state, StackLayout lay, Symbol sel) + { + if (!state.TryFindMethod(lay.Cls, sel, out var m, out _) || m.Proc is not { } proc) return -1; + if (TryRecognizeTrivialAccessor(state, proc.Irep) is not { IsSetter: false } acc) return -1; + for (var i = 0; i < lay.Fields.Count; i++) + if (lay.FieldKinds[i] == StackFieldKind.Nested && lay.Fields[i] == acc.Field) return i; + return -1; + } + + static string StackRejectReason = ""; + + static bool IsStackEligible(MRubyState state, RubyIRMethod exe, int[] defIndex, int newIndex, int objId, RClass klass, RubyIREscapeSummary.Summary summary, out HashSet aliases, out bool mutated) + { + bool Reject(string r) { StackRejectReason = r; return false; } + mutated = false; + var ins = exe.Instructions; + // Alias set via Move copies, assigned up front so it is valid on every return path. The + // caller maps EVERY alias id to the struct layout, so a `b = a` copy of a (read-only, + // Stage 1) stack object becomes a struct value-copy that shares the same fields. + aliases = new HashSet { objId }; + foreach (var captured in exe.ClosureCapturedValueIds) + { + if (captured == objId) return Reject("captured"); + } + var grew = true; + while (grew) + { + grew = false; + for (var j = 0; j < ins.Length; j++) + { + if (ins[j].OpCode == RubyIROpCode.Move && aliases.Contains(ins[j].Src0)) + { + foreach (var captured in exe.ClosureCapturedValueIds) if (captured == ins[j].Dst) return false; + if (aliases.Add(ins[j].Dst)) grew = true; + } + } + } + + for (var j = 0; j < ins.Length; j++) + { + if (j == newIndex) continue; + var u = ins[j]; + var op = u.OpCode; + if (op == RubyIROpCode.Move) continue; // pure alias copy; dest already in `aliases` + var recvIsObj = aliases.Contains(u.Src0); + // Receiver of its own accessor send (getter/setter on klass): a field access, ok. + if (op == RubyIROpCode.Send && recvIsObj) + { + var sel = exe.GetCallSiteSymbol(u.Aux); + var acc = state.TryFindMethod(klass, sel, out var mm, out _) && mm.Proc is { } pr + ? TryRecognizeTrivialAccessor(state, pr.Irep) : null; + // A trivial accessor on the object lowers to a field access (a SETTER mutates the + // struct -> by ref); a non-accessor method that uses self read-only (per + // CalleeSelfReadOnly) lowers to a struct-RECEIVER variant call — both keep it stack. + if (acc is { IsSetter: true }) mutated = true; + if (acc is null && !CalleeSelfReadOnly(state, klass, sel)) + { + return Reject("recv-nonaccessor-send:" + state.NameOf(sel)); + } + // Accessor arg (a setter's value) must not BE the object (would store it into itself + // — that's a cycle we don't model). Checked by the arg scan below. + } + // Direct ivar op on the object: ok (a SET mutates the struct -> by ref). + else if (recvIsObj && op is RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField or + RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField) + { + if (op is RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField) mutated = true; + // SetInstanceVariable storing the object as a VALUE (Src1) is handled below. + } + else if (op == RubyIROpCode.GuardInlineClass && recvIsObj) + { + // no-op guard, ok + } + else if (recvIsObj) + { + // Receiver in any other op (non-accessor send, etc.) -> escape. + return Reject("recv-other-op:" + op); + } + + // The object must not appear as Src1/Src2 (stored as a value / index / etc.). + if (op != RubyIROpCode.GuardInlineClass && (aliases.Contains(u.Src1) || aliases.Contains(u.Src2))) + { + return Reject("appears-src1/2:" + op); + } + // The object must not be put in a new array. + if (op is RubyIROpCode.NewArray or RubyIROpCode.NewArray2 or RubyIROpCode.NewHash) + { + var c = exe.GetOperandListCount(u.Aux); + for (var a = 0; a < c; a++) if (aliases.Contains(exe.GetOperandListValueId(u.Aux, a))) return Reject("into-array"); + } + // Returned -> escape. + if (op == RubyIROpCode.Return && aliases.Contains(u.Src0)) return Reject("returned"); + // Passed as a call/ctor argument: a ctor arg always retains; a send arg is ok only if + // the (polymorphic) callee provably does not retain that position for `klass`. + if (RubyIROpInfo.IsSendOp(op) || op is RubyIROpCode.PureUnarySend or RubyIROpCode.VirtualNew) + { + var sel = op == RubyIROpCode.VirtualNew ? default : exe.GetCallSiteSymbol(u.Aux); + var argc = exe.GetCallSiteArgumentCount(u.Aux); + for (var a = 0; a < argc; a++) + { + if (!aliases.Contains(exe.GetCallSiteArgumentValueId(u.Aux, a))) continue; + // Only a real Send/SendSelf can be proven non-retaining via the summary; a ctor + // arg or a pure-unary-send arg is assumed to retain. + if (op is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) return Reject("ctor/unary-arg:" + op); + if (summary.SelectorRetains(sel, a + 1, klass)) return Reject("callee-retains:" + state.NameOf(sel) + "#" + (a + 1)); + // A callee that mutates the arg is fine — it is passed by `ref` and the layout + // is marked Mutated (the reify fallback snapshots + copies back). (A) + if (summary.SelectorMutates(sel, a + 1, klass)) mutated = true; + } + } + } + return true; + } + + // Ivars the defining class's `initialize` sets to a fixnum literal (@x = 3) — statically int, + // so float speculation must skip them (else a guard misses every call and the method + // constant-deopts). Cheap one-shot scan of the lowered initialize body. + internal static HashSet CollectKnownFixnumIvars(MRubyState state, RClass? definingClass) + { + var result = new HashSet(); + if (definingClass is null) return result; + if (!state.TryFindMethod(definingClass, state.Intern("initialize"u8), out var m, out _) || + m.Proc is not { } proc) + { + return result; + } + RubyIRMethod? exe; + try { exe = RubyIRBuilder.Build(proc.Irep, 0, out _); } + catch { return result; } + if (exe is null) return result; + var ins = exe.Instructions; + var defIndex = new int[exe.ValueCount]; + for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1; + for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; } + for (var i = 0; i < ins.Length; i++) + { + if (ins[i].OpCode is not (RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField)) continue; + var v = ins[i].Src1; // value operand (Src0 is self) + if ((uint)v < (uint)defIndex.Length && defIndex[v] >= 0 && + ins[defIndex[v]].OpCode == RubyIROpCode.LoadValue && + exe.GetLiteral(ins[defIndex[v]].Aux).IsFixnum) + { + result.Add(exe.GetSymbol(ins[i].Aux)); + } + } + return result; + } + + + + internal static bool[] ComputeUnboxing(MRubyState state, RubyIRMethod exe, int argCount, ScalarContext? sc, out bool[] floatTaintOut, out bool[] isDoubleOut, out bool[] provesDoubleOut, out bool[] soundProvenOut, IReadOnlyDictionary? accessorRegistry = null, HashSet? knownFixnumIvars = null, bool classUsesFloat = true, RClass? definingClass = null) + { + var n = exe.ValueCount; + var isLong = new bool[n]; + var hasDef = new bool[n]; + var nonArithDef = new bool[n]; + var boxedUse = new bool[n]; + var floatTaint = new bool[n]; + floatTaintOut = floatTaint; + var ins = exe.Instructions; + void MarkBoxed(int id) + { + if ((uint)id < (uint)n) boxedUse[id] = true; + } + void Taint(int id) + { + if ((uint)id < (uint)n) floatTaint[id] = true; + } + for (var i = 0; i < ins.Length; i++) + { + var op = ins[i].OpCode; + var d = ins[i].Dst; + if ((uint)d < (uint)n) + { + hasDef[d] = true; + if (!RubyIROpInfo.IsFixnumArith(op)) nonArithDef[d] = true; + } + // Float-taint seeds: a float literal, a float-valued constant, or a send whose + // result is always a float. + if (op == RubyIROpCode.LoadValue && exe.GetLiteral(ins[i].Aux).IsFloat) + { + Taint(d); + } + else if (op == RubyIROpCode.GetConstant && IsFloatConstantName(state, exe.GetSymbol(ins[i].Aux))) + { + Taint(d); + } + else if ((op is RubyIROpCode.Send or RubyIROpCode.SendSelf) && + IsFloatReturningMethod(state, exe.GetCallSiteSymbol(ins[i].Aux))) + { + Taint(d); + } + // Arith/compare read their operands as fixnums -> not a boxed use. + if (RubyIROpInfo.IsFixnumArith(op) || RubyIROpInfo.IsFixnumCompare(op)) + { + continue; + } + // Return reads its operand at a re-box boundary (BoxReadFull handles any unboxed kind), + // so it does NOT force the value boxed — letting a returned double accumulator stay a + // raw double across the method and re-box only here. + if (op is RubyIROpCode.Return or RubyIROpCode.ReturnValue) + { + continue; + } + // Any other instruction consumes its value operands boxed. Over-marking a + // non-operand source field is harmless (only loses an unboxing opportunity). + MarkBoxed(ins[i].Src0); + MarkBoxed(ins[i].Src1); + MarkBoxed(ins[i].Src2); + if (RubyIROpInfo.IsSendOp(op) || op == RubyIROpCode.PureUnarySend || op == RubyIROpCode.VirtualNew) + { + var argc = exe.GetCallSiteArgumentCount(ins[i].Aux); + for (var a = 0; a < argc; a++) MarkBoxed(exe.GetCallSiteArgumentValueId(ins[i].Aux, a)); + } + else if (op is RubyIROpCode.NewArray or RubyIROpCode.NewArray2 or RubyIROpCode.NewHash) + { + var c = exe.GetOperandListCount(ins[i].Aux); + for (var a = 0; a < c; a++) MarkBoxed(exe.GetOperandListValueId(ins[i].Aux, a)); + } + } + // Propagate float taint forward through Move + arith to a fixpoint. The IR has + // forward branches (loops via goto), so a single pass can miss back-edge feeders; + // iterate until stable (bounded by value count, in practice 2-3 passes). Scalar- + // replaced object fields are folded in: a field is float-typed if any value written + // to it (ctor arg / setter arg) is tainted, and a getter read of a float field taints + // its result — so arith over `o.x` is not wrongly unboxed to long and deopting on float. + bool Tainted(int id) => (uint)id < (uint)n && floatTaint[id]; + var floatFields = new HashSet<(int Obj, int Field)>(); + bool changed = true; + while (changed) + { + changed = false; + for (var i = 0; i < ins.Length; i++) + { + var op = ins[i].OpCode; + var d = ins[i].Dst; + + if (sc is not null && op == RubyIROpCode.VirtualNew && sc.IsScalar(d)) + { + var o = sc.GetScalarObject(d); + for (var f = 0; f < o.Fields.Count; f++) + { + var argVid = exe.GetCallSiteArgumentValueId(ins[i].Aux, o.FieldArg[f]); + if (Tainted(argVid) && floatFields.Add((d, f))) changed = true; + } + continue; + } + if (sc is not null && op == RubyIROpCode.Send && + sc.TryGetAccessorSend(exe, ins[i], out var objId, out var fieldIdx, out var isSetter)) + { + if (isSetter) + { + var valVid = exe.GetCallSiteArgumentValueId(ins[i].Aux, 0); + if (Tainted(valVid)) + { + if (floatFields.Add((objId, fieldIdx))) changed = true; + if ((uint)d < (uint)n && !floatTaint[d]) { floatTaint[d] = true; changed = true; } + } + } + else if (floatFields.Contains((objId, fieldIdx)) && (uint)d < (uint)n && !floatTaint[d]) + { + floatTaint[d] = true; changed = true; + } + continue; + } + if (sc is not null && + sc.TryGetScalarFieldAccess(exe, ins[i], out var fieldObjId, out var scalarFieldIdx, out var isFieldSetter)) + { + if (isFieldSetter) + { + if (Tainted(ins[i].Src1) && floatFields.Add((fieldObjId, scalarFieldIdx))) + { + changed = true; + } + } + else if (floatFields.Contains((fieldObjId, scalarFieldIdx)) && + (uint)d < (uint)n && + !floatTaint[d]) + { + floatTaint[d] = true; + changed = true; + } + continue; + } + + if ((uint)d >= (uint)n || floatTaint[d]) + { + continue; + } + bool propagate = op switch + { + RubyIROpCode.Move => Tainted(ins[i].Src0), + _ when RubyIROpInfo.IsFixnumArith(op) => Tainted(ins[i].Src0) || Tainted(ins[i].Src1) || Tainted(ins[i].Src2), + _ => false, + }; + if (propagate) + { + floatTaint[d] = true; + changed = true; + } + } + } + // ---- double-unboxing (the float twin of long-unboxing) ---- + // provesDouble[id] = id is PROVABLY a Float at runtime (a MUST analysis): seeded by float + // literals / float constants / float-returning sends, then propagated through float arith + // ONLY when every operand is itself provably double (intersection, vs taint's union). + var provesDouble = new bool[n]; + provesDoubleOut = provesDouble; + // soundProven[id] = provesDouble[id] was established by an UNGUARDED whole-program proof + // (a float literal/const, a proven-Float send, or — new in Stage 1 — a class-wide-proven + // Float ivar read), as opposed to the speculation block below which guesses + deopts. The + // float-speculation guard (EmitFloatSpeculationGuard) is suppressed for sound dsts. + var soundProven = new bool[n]; + soundProvenOut = soundProven; + bool PD(int id) => (uint)id < (uint)n && provesDouble[id]; + for (var i = 0; i < ins.Length; i++) + { + var op = ins[i].OpCode; + var d = ins[i].Dst; + if ((uint)d >= (uint)n) continue; + if ((op == RubyIROpCode.LoadValue && exe.GetLiteral(ins[i].Aux).IsFloat) || + (op == RubyIROpCode.GetConstant && IsFloatConstantName(state, exe.GetSymbol(ins[i].Aux))) || + ((op is RubyIROpCode.Send or RubyIROpCode.SendSelf) && IsFloatReturningMethod(state, exe.GetCallSiteSymbol(ins[i].Aux)))) + { + provesDouble[d] = true; + soundProven[d] = true; // a proven/builtin Float source needs no runtime guard + } + } + + // Stage 1 — SOUND Float unboxing from whole-program inference (env AOT_NOIVKIND, default ON). + // An ivar read whose (definingClass, @name) is proven Float across the WHOLE program is a + // Float at runtime WITHOUT a guard (vs the speculation block below, which guards + deopts). + // Seeding provesDouble here makes downstream float arith read it via .FloatValue / d{} with + // no `if(!v.IsFloat) return false`. Self ivar reads use definingClass (exactly right); + // sends are already covered by the proven-Float seed above (selectorReturn is receiver-class + // independent, so a per-class ivar lookup for accessor sends would be unsound — not done). + var rt = Mrb2CsCompiler.CurrentReturnTypes; + if (rt is not null && definingClass is not null && Environment.GetEnvironmentVariable("AOT_NOIVKIND") != "1") + { + for (var i = 0; i < ins.Length; i++) + { + var op = ins[i].OpCode; + var d = ins[i].Dst; + if ((uint)d >= (uint)n || provesDouble[d]) continue; + if (op is RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField && + rt.IvarReturnsFloat(definingClass, exe.GetSymbol(ins[i].Aux))) + { + provesDouble[d] = true; + soundProven[d] = true; + } + } + } + + // Q1.2 speculative float ivars (AOT_SPECFLOAT): ao's hot float arith is over UNTYPED ivars + // (@x*b.x+...), which carry no static float proof. Speculate that an ivar/accessor read + // feeding only float-capable arith is a Float — seed it provesDouble (a runtime IsFloat + // guard at the read; a miss deopts). A deopt re-runs the bytecode from the method's top, + // so it is safe ONLY if no observable side effect was committed before the read. + // + // Pre-side-effect window: instead of requiring the WHOLE method side-effect-free, speculate + // only reads BEFORE the method's first COMMITTED side effect. The IR is loop-free (forward + // branches only), so instruction-index order is execution order — a read at index i with no + // committed side effect at index < i is deopt-safe. Transparent ops (writes to a freshly- + // allocated scalar object, fresh allocations, pure float-Math sends, getters) don't commit + // and don't close the window; a write to self/a pre-existing object or an arbitrary call + // does. This reaches side-effecting hot methods (e.g. ao's intersect: the discriminant is + // all computed before the @t/@hit setters) once their float producers (vdot) are inlined. + if (knownFixnumIvars is not null && classUsesFloat && Environment.GetEnvironmentVariable("AOT_NOSPECFLOAT") != "1") + { + // The pre-side-effect WINDOW (SSA path) speculates prefixes of side-effecting methods, + // which pays once float producers are inlined. With AOT_NOSSA the legacy whole-method + // gate applies instead (any committed side effect disables speculation). Either way the + // outer class-float-evidence guard already kept this off all-integer classes. + int firstSideEffect; + if (Mrb2CsCompiler.SsaEnabled) + { + firstSideEffect = ins.Length; + for (var i = 0; i < ins.Length; i++) + { + if (IsCommittedSideEffect(state, exe, sc, accessorRegistry, ins[i])) { firstSideEffect = i; break; } + } + } + else + { + var sideEffectFree = true; + for (var i = 0; i < ins.Length && sideEffectFree; i++) + { + var op = ins[i].OpCode; + if (op is RubyIROpCode.Send or RubyIROpCode.SendSelf) + { + var sel = exe.GetCallSiteSymbol(ins[i].Aux); + if (!((accessorRegistry?.ContainsKey(sel) ?? false) || IsFloatReturningMethod(state, sel))) + sideEffectFree = false; + } + else if (!RubyIROpInfo.IsPureSpeculationOp(op)) + { + sideEffectFree = false; + } + } + firstSideEffect = sideEffectFree ? ins.Length : 0; + } + + // A read is speculatable only if every use is a float-capable arith/compare operand + // (not a fixnum-typed/bitwise/index op, not a boxed use) — so integer ivars used in + // `&`/`<<`/`[]` are never mis-speculated. + var nonFloatCapableUse = new bool[n]; + void NFC(int id) { if ((uint)id < (uint)n) nonFloatCapableUse[id] = true; } + for (var i = 0; i < ins.Length; i++) + { + var op = ins[i].OpCode; + if (RubyIROpInfo.IsDoubleArith(op) || RubyIROpInfo.IsDoubleCompare(op)) continue; + NFC(ins[i].Src0); NFC(ins[i].Src1); NFC(ins[i].Src2); + if (RubyIROpInfo.IsSendOp(op) || op == RubyIROpCode.PureUnarySend || op == RubyIROpCode.VirtualNew) + { + var argc = exe.GetCallSiteArgumentCount(ins[i].Aux); + for (var a = 0; a < argc; a++) NFC(exe.GetCallSiteArgumentValueId(ins[i].Aux, a)); + } + } + for (var i = 0; i < firstSideEffect; i++) + { + var op = ins[i].OpCode; + var d = ins[i].Dst; + if ((uint)d >= (uint)n || provesDouble[d] || boxedUse[d] || nonFloatCapableUse[d]) continue; + var isRead = op is RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField; + var isAccessor = (op is RubyIROpCode.Send or RubyIROpCode.SendSelf) && + (accessorRegistry?.ContainsKey(exe.GetCallSiteSymbol(ins[i].Aux)) ?? false); + if (!isRead && !isAccessor) continue; + // Don't speculate an ivar the defining class's initialize sets to a fixnum + // literal (e.g. Point#@x = 3, or optcarrot counters @x = 0): those are int, so + // a float guard would miss every call and the method would constant-deopt. + var ivarName = isRead + ? exe.GetSymbol(ins[i].Aux) + : accessorRegistry![exe.GetCallSiteSymbol(ins[i].Aux)].Field; + if (knownFixnumIvars?.Contains(ivarName) ?? false) continue; + provesDouble[d] = true; // speculate Float; guard emitted at the read site + } + } + + // Def counts (all Dst occurrences; branches/stores write Dst 0 == self, irrelevant). A + // value-id with exactly one def is single-assignment, so a Move into it is a pure copy. + var defCount = new int[n]; + for (var i = 0; i < ins.Length; i++) + { + var d = ins[i].Dst; + if ((uint)d < (uint)n) defCount[d]++; + } + + changed = true; + while (changed) + { + changed = false; + for (var i = 0; i < ins.Length; i++) + { + var op = ins[i].OpCode; + var d = ins[i].Dst; + if ((uint)d >= (uint)n || provesDouble[d]) continue; + bool prove; + if (RubyIROpInfo.IsDoubleArith(op)) + { + prove = PD(ins[i].Src0) && PD(ins[i].Src1) && (!RubyIROpInfo.IsDoubleFused(op) || PD(ins[i].Src2)); + } + else if (op == RubyIROpCode.Move && defCount[d] == 1) + { + // Single-def copy: provably double iff its source is (MUST holds — one def). + // Carries the proof across the copy chains SSA renumbering introduces, so an + // inlined float producer's result (e.g. ao's `b = rs.vdot(dir)`) stays proven + // double through the merge into `b` instead of relapsing to a fixnum isLong. + prove = PD(ins[i].Src0); + } + else + { + continue; + } + if (prove) + { + provesDouble[d] = true; + changed = true; + } + } + } + + // isLong is computed AFTER provesDouble (incl. speculation) and excludes it: a provably/ + // speculated Float value must never be treated as a fixnum long (else its arith deopts on + // the first float, or — under speculation — its accumulator is read as an uninitialized l{}). + for (var id = argCount + 1; id < n; id++) + { + isLong[id] = hasDef[id] && !nonArithDef[id] && !boxedUse[id] && !floatTaint[id] && !provesDouble[id]; + } + // Registers captured by a descendant block are passed to the inlined block by ref, so + // they must be boxed MRubyValue locals (not unboxed longs). + foreach (var captured in exe.ClosureCapturedValueIds) + { + if ((uint)captured < (uint)n) isLong[captured] = false; + } + + // A value is held as a raw `double` iff it is provably double, DEFINED by a double-arith op + // (seeds stay boxed and are read via .FloatValue), and used ONLY as an operand of a + // pure-double op (all operands provably double). Any other use (Move, immediate, fixnum- + // typed arith, Send arg, ivar store, return, ...) is "impure" and keeps the value boxed — + // mirroring long-unboxing's boxedUse rule, so non-pure emission never sees a `d{}` local. + var doubleDef = new bool[n]; + var impureUse = new bool[n]; + void Impure(int id) { if ((uint)id < (uint)n) impureUse[id] = true; } + for (var i = 0; i < ins.Length; i++) + { + var op = ins[i].OpCode; + var d = ins[i].Dst; + if (RubyIROpInfo.IsDoubleArith(op) && (uint)d < (uint)n && provesDouble[d]) doubleDef[d] = true; + if (!RubyIROpInfo.IsFixnumArith(op) && !RubyIROpInfo.IsFixnumCompare(op)) continue; // non-arith operands already boxedUse-marked + var fused = RubyIROpInfo.IsDoubleFused(op); + var pure = (RubyIROpInfo.IsDoubleArith(op) || RubyIROpInfo.IsDoubleCompare(op)) && + PD(ins[i].Src0) && PD(ins[i].Src1) && (!fused || PD(ins[i].Src2)); + if (!pure) { Impure(ins[i].Src0); Impure(ins[i].Src1); if (fused) Impure(ins[i].Src2); } + } + var isDouble = new bool[n]; + isDoubleOut = isDouble; + for (var id = argCount + 1; id < n; id++) + { + isDouble[id] = provesDouble[id] && doubleDef[id] && !boxedUse[id] && !impureUse[id] && !isLong[id]; + } + foreach (var captured in exe.ClosureCapturedValueIds) + { + if ((uint)captured < (uint)n) isDouble[captured] = false; + } + return isLong; + } + + // --- Phase 2: sound numeric type inference for LOOPING methods (cyclic dataflow) --- + // The merge-slot loop IR gives each register ONE value-id with MULTIPLE defs (pre-loop init + + // back-edge update), so the acyclic ComputeUnboxing above is unsound here (it seeds provesDouble + // from a single def). This computes a per-id MUST type as a meet (over ALL defs) to a fixpoint, + // so a value is proven Float/Fixnum only if EVERY def produces that type. Mixed int/float arith + // is typed by Ruby semantics (Float op Numeric -> Float). Numerically-used method ARGS are + // speculated Fixnum (guarded at entry, before any side effect -> deopt-safe; see argGuardsOut). + // + // Lattice (meet = "could be either def's type", monotone toward UNK so it terminates): + // TOP(unseen) -> {FIX, FLT} -> NUM(fix|flt) -> UNK(boxed/non-numeric). + const byte TyTop = 0, TyFix = 1, TyFlt = 2, TyNum = 3, TyUnk = 4; + + static byte MeetTy(byte a, byte b) + { + if (a == TyTop) return b; + if (b == TyTop) return a; + if (a == b) return a; + if (a == TyUnk || b == TyUnk) return TyUnk; + // a,b in {FIX,FLT,NUM}, a!=b -> all numeric, so NUM + return TyNum; + } + + // Result type of `a OP b` (+,-,*,/) under Ruby coercion. Div is the same shape (Fixnum/Fixnum is + // integer division -> Fixnum; any Float operand -> Float). + static byte ArithTy(byte a, byte b) + { + if (a == TyTop || b == TyTop) return TyTop; // defer until operands known + if (a == TyUnk || b == TyUnk) return TyUnk; // a non-numeric operand poisons + if (a == TyFlt || b == TyFlt) return TyFlt; // float op numeric -> float + if (a == TyFix && b == TyFix) return TyFix; // fixnum op fixnum -> fixnum + return TyNum; // involves a NUM, no float forcing + } + + // Sound MUST numeric typing for a looping method. Outputs provesDouble (always Float) and + // provesFixnum (always Fixnum, modulo fixnum-overflow->Bignum, matching the existing isLong + // stance). floatTaint/soundProven are set consistently (no speculation guard for proven floats). + // argGuardsOut: arg value-ids to guard `IsFixnum` at method entry (their typing assumed Fixnum). + internal static void ComputeLoopUnboxing( + MRubyState state, RubyIRMethod exe, int argCount, RClass? definingClass, + out bool[] provesDouble, out bool[] provesFixnum, out bool[] floatTaint, + out bool[] soundProven, out List argGuardsOut, bool speculateArgs = true) + { + var n = exe.ValueCount; + var ins = exe.Instructions; + var ty = new byte[n]; // TyTop initially (0) + var rt = Mrb2CsCompiler.CurrentReturnTypes; + + // Args whose value REACHES a numeric-arith operand (directly, or through Move copies — the + // merge-slot IR loads operands into temps before arith) are speculated Fixnum (entry-guarded). + // feedsArith is seeded at arith operands and propagated BACKWARD through `dst = Move(src)`. + var feedsArith = new bool[n]; + void Feed(int id) { if ((uint)id < (uint)n) feedsArith[id] = true; } + for (var i = 0; i < ins.Length; i++) + { + var op = ins[i].OpCode; + if (RubyIROpInfo.IsFixnumArith(op) || RubyIROpInfo.IsFixnumCompare(op)) + { + Feed(ins[i].Src0); + Feed(ins[i].Src1); + if (RubyIROpInfo.IsDoubleFused(op)) Feed(ins[i].Src2); + } + } + bool feedChanged = true; + var feedGuard = 0; + while (feedChanged && feedGuard++ <= n + 4) + { + feedChanged = false; + for (var i = 0; i < ins.Length; i++) + { + if (ins[i].OpCode != RubyIROpCode.Move) continue; + var d = ins[i].Dst; + var s = ins[i].Src0; + if ((uint)d < (uint)n && feedsArith[d] && (uint)s < (uint)n && !feedsArith[s]) + { + feedsArith[s] = true; + feedChanged = true; + } + } + } + var argGuards = new List(); + for (var v = 1; v <= argCount && v < n; v++) + { + // Block bodies (speculateArgs=false) can't guard args at entry — a block deopt re-runs + // the PARENT method, double-applying prior loop iterations' side effects. So their args + // stay boxed (Unknown) rather than speculated Fixnum. + if (speculateArgs && feedsArith[v]) { ty[v] = TyFix; argGuards.Add(v); } + else ty[v] = TyUnk; + } + ty[0] = TyUnk; // self + var isArgOrSelf = new bool[n]; + for (var v = 0; v <= argCount && v < n; v++) isArgOrSelf[v] = true; + + // DefType: the type a single instruction's def produces (reads current operand types). + byte DefTy(in RubyIRInstruction ix) + { + var op = ix.OpCode; + switch (op) + { + case RubyIROpCode.LoadValue: + { + var lit = exe.GetLiteral(ix.Aux); + return lit.IsFixnum ? TyFix : lit.IsFloat ? TyFlt : TyUnk; + } + case RubyIROpCode.GetConstant: + return IsFloatConstantName(state, exe.GetSymbol(ix.Aux)) ? TyFlt : TyUnk; + case RubyIROpCode.Move: + return (uint)ix.Src0 < (uint)n ? ty[ix.Src0] : TyUnk; + case RubyIROpCode.Send: + if (IsFloatReturningMethod(state, exe.GetCallSiteSymbol(ix.Aux))) return TyFlt; + // Fixnum bitwise sends `& | ^ >>` produce a Fixnum when both operands are Fixnum. + // `<<` is EXCLUDED here — it can overflow to Bignum, so its result isn't a proven + // Fixnum (it still gets the boxed C# fast path, just not raw-long typing). + if (exe.GetCallSiteArgumentCount(ix.Aux) == 1 && + Emitter.TryFixnumBitwiseOp(state, exe.GetCallSiteSymbol(ix.Aux), out var bop, out _) && bop != "<<") + { + var rcv = ix.Src0; + var arg = exe.GetCallSiteArgumentValueId(ix.Aux, 0); + var rt2 = (uint)rcv < (uint)n ? ty[rcv] : TyUnk; + var at2 = (uint)arg < (uint)n ? ty[arg] : TyUnk; + if (rt2 == TyTop || at2 == TyTop) return TyTop; // defer until operands known + var rfix = rt2 == TyFix || Mrb2CsCompiler.ConstFix(rcv, out _); + var afix = at2 == TyFix || Mrb2CsCompiler.ConstFix(arg, out _); + return rfix && afix ? TyFix : TyUnk; + } + return TyUnk; + case RubyIROpCode.SendSelf: + return IsFloatReturningMethod(state, exe.GetCallSiteSymbol(ix.Aux)) ? TyFlt : TyUnk; + case RubyIROpCode.GetInstanceVariable: + case RubyIROpCode.VirtualGetField: + return rt is not null && definingClass is not null && + rt.IvarReturnsFloat(definingClass, exe.GetSymbol(ix.Aux)) ? TyFlt : TyUnk; + } + if (RubyIROpInfo.IsFixnumArith(op)) + { + var a = (uint)ix.Src0 < (uint)n ? ty[ix.Src0] : TyUnk; + // Immediate ops carry their constant in Aux (a fixnum for AddImmediate; the float + // variants are explicitly float); a plain binary reads Src1. + byte b; + if (op is RubyIROpCode.AddImmediate or RubyIROpCode.SubImmediate + or RubyIROpCode.AddImmediateFixnum or RubyIROpCode.SubImmediateFixnum) + b = TyFix; + else if (op is RubyIROpCode.AddImmediateFloat or RubyIROpCode.SubImmediateFloat) + b = TyFlt; + else + b = (uint)ix.Src1 < (uint)n ? ty[ix.Src1] : TyUnk; + var r = ArithTy(a, b); + if (RubyIROpInfo.IsDoubleFused(op)) + { + var c = (uint)ix.Src2 < (uint)n ? ty[ix.Src2] : TyUnk; + r = ArithTy(r, c); + } + return r; + } + // Comparisons produce a boolean (non-numeric); everything else is opaque. + return TyUnk; + } + + // Fixpoint: ty[id] = meet over all defs of DefTy(def). Args/self are fixed (not re-meet). + bool changed = true; + var iterGuard = 0; + while (changed && iterGuard++ <= n + 4) + { + changed = false; + var acc = new byte[n]; // TyTop + var seen = new bool[n]; + for (var i = 0; i < ins.Length; i++) + { + var d = ins[i].Dst; + if ((uint)d >= (uint)n || isArgOrSelf[d]) continue; + if (!Definecheck(ins[i].OpCode)) continue; + var dt = DefTy(ins[i]); + acc[d] = seen[d] ? MeetTy(acc[d], dt) : dt; + seen[d] = true; + } + for (var v = 0; v < n; v++) + { + if (isArgOrSelf[v]) continue; + var nt = seen[v] ? acc[v] : TyUnk; // no def -> boxed + if (nt != ty[v]) { ty[v] = nt; changed = true; } + } + } + + provesDouble = new bool[n]; + provesFixnum = new bool[n]; + floatTaint = new bool[n]; + soundProven = new bool[n]; + for (var v = 0; v < n; v++) + { + if (ty[v] == TyFlt) { provesDouble[v] = true; floatTaint[v] = true; soundProven[v] = true; } + else if (ty[v] == TyFix) { provesFixnum[v] = true; } + } + argGuardsOut = argGuards; + } + + // Phase 3: pick which proven Float/Fixnum loop value-ids can live in a RAW `double`/`long` local + // (FP/int register) instead of a boxed-but-guard-free MRubyValue. A value is raw-eligible iff it + // is never used in a boxed position (Send arg / ivar store / branch cond / index / non-numeric + // op), never used by a DUAL-path numeric op (whose float branch reads `v.FloatValue` and so needs + // the boxed form), and every def writes a raw form (fully-typed arith / immediate / Move / + // numeric LoadValue). Move and Return convert at the boundary, so they don't force boxing. + internal static void ComputeLoopRawLocals( + MRubyState state, RubyIRMethod exe, int argCount, bool[] provesDouble, bool[] provesFixnum, + out bool[] isLong, out bool[] isDouble) + { + var ins = exe.Instructions; + var nI = ins.Length; + var n = exe.ValueCount; + isLong = new bool[n]; + isDouble = new bool[n]; + var boxedUse = new bool[n]; + var impureUse = new bool[n]; // operand of a dual-path numeric op -> needs boxed storage + var rawDefBad = new bool[n]; // a def writes a boxed v{} (so the id can't be a raw local) + var hasDef = new bool[n]; + + bool ProvenNum(int id) + { + if ((uint)id < (uint)n && (provesDouble[id] || provesFixnum[id])) return true; + return Mrb2CsCompiler.ConstFix(id, out _) || Mrb2CsCompiler.ConstFloat(id, out _); + } + + var tmp = new ulong[(n + 63) >> 6]; + for (var i = 0; i < nI; i++) + { + var op = ins[i].OpCode; + var d = ins[i].Dst; + var defines = Definecheck(op) && (uint)d < (uint)n; + if (defines) hasDef[d] = true; + + if (RubyIROpInfo.IsFixnumArith(op) || RubyIROpInfo.IsFixnumCompare(op)) + { + int o0 = ins[i].Src0, o1 = -1, o2 = -1; + var imm = op is RubyIROpCode.AddImmediate or RubyIROpCode.SubImmediate + or RubyIROpCode.AddImmediateFixnum or RubyIROpCode.SubImmediateFixnum; + if (!imm) o1 = ins[i].Src1; + if (RubyIROpInfo.IsDoubleFused(op)) o2 = ins[i].Src2; + var fullyTyped = ProvenNum(o0) && (o1 < 0 || ProvenNum(o1)) && (o2 < 0 || ProvenNum(o2)); + if (!fullyTyped) + { + if ((uint)o0 < (uint)n) impureUse[o0] = true; + if (o1 >= 0 && (uint)o1 < (uint)n) impureUse[o1] = true; + if (o2 >= 0 && (uint)o2 < (uint)n) impureUse[o2] = true; + if (defines) rawDefBad[d] = true; // dual path writes a boxed v{d} + } + // A float-receiver immediate (`f + 1` -> Float) is emitted boxed: EmitFixnumImmediate + // reads its receiver via FloatRead (= v{}.FloatValue, NOT rep-aware) and writes a + // boxed v{dst}. So both the float operand and the dst must stay boxed (the fixnum + // immediate path IS rep-aware via FixRead/AssignFix, so isLong operands are fine). + else if (imm && (uint)o0 < (uint)n && provesDouble[o0]) + { + impureUse[o0] = true; + if (defines) rawDefBad[d] = true; + } + continue; + } + if (op == RubyIROpCode.Move) continue; // rep-aware Move converts at the assignment + if (op is RubyIROpCode.Return or RubyIROpCode.ReturnValue or RubyIROpCode.ReturnSelf) continue; + if (op == RubyIROpCode.LoadValue) + { + var lit = exe.GetLiteral(ins[i].Aux); + if (!lit.IsFixnum && !lit.IsFloat && defines) rawDefBad[d] = true; // non-numeric -> boxed + continue; // no value operands + } + // Fixnum bitwise send (& | ^ >>): its operands are read via FixRead (rep-aware, under a + // guard) so they never force boxing; the dst is a raw long iff both operands are proven + // Fixnum (then the codegen emits l{} = ...), else it stays boxed. + if (op == RubyIROpCode.Send && exe.GetCallSiteArgumentCount(ins[i].Aux) == 1 && + Emitter.TryFixnumBitwiseOp(state, exe.GetCallSiteSymbol(ins[i].Aux), out var bwOp, out _)) + { + var rcv = ins[i].Src0; + var arg = exe.GetCallSiteArgumentValueId(ins[i].Aux, 0); + var rfix = ((uint)rcv < (uint)n && provesFixnum[rcv]) || Mrb2CsCompiler.ConstFix(rcv, out _); + var afix = ((uint)arg < (uint)n && provesFixnum[arg]) || Mrb2CsCompiler.ConstFix(arg, out _); + // A non-fixnum operand (Unknown / proven Float) must stay BOXED: FixRead reads it as + // v{}.FixnumValue (under a guard), which has no raw l{}/d{} form. Only proven-fixnum + // operands stay raw long. + if (!rfix && (uint)rcv < (uint)n) impureUse[rcv] = true; + if (!afix && (uint)arg < (uint)n) impureUse[arg] = true; + // `<<` result can be Bignum -> dst must stay boxed; `& | ^ >>` give a raw-long dst iff + // both operands are proven fixnum. + if ((bwOp == "<<" || !(rfix && afix)) && defines) rawDefBad[d] = true; + continue; + } + // Any other op consumes its value operands boxed and (if it defines) writes a boxed v{d}. + Array.Clear(tmp, 0, tmp.Length); + CollectUses(exe, ins[i], tmp, n); + for (var w = 0; w < tmp.Length; w++) + { + var b = tmp[w]; + while (b != 0) + { + var bit = b & (ulong)(-(long)b); + var id = (w << 6) + Log2Floor(bit); + b ^= bit; + if ((uint)id < (uint)n) boxedUse[id] = true; + } + } + if (defines) rawDefBad[d] = true; + } + + for (var id = argCount + 1; id < n; id++) + { + if (!hasDef[id] || boxedUse[id] || impureUse[id] || rawDefBad[id]) continue; + if (provesDouble[id]) isDouble[id] = true; + else if (provesFixnum[id]) isLong[id] = true; + } + // Captured registers are passed to inlined blocks by ref as boxed MRubyValue -> never raw. + foreach (var c in exe.ClosureCapturedValueIds) + if ((uint)c < (uint)n) { isLong[c] = false; isDouble[c] = false; } + } + + // An opcode that defines a value (mirrors the producing cases the lattice models). A non-defining + // op (branch/store/return) contributes no def to the meet. + static bool Definecheck(RubyIROpCode op) => op switch + { + RubyIROpCode.Jump or RubyIROpCode.JumpIfTruthy or RubyIROpCode.JumpIfFalsy or + RubyIROpCode.JumpIfNil or RubyIROpCode.GuardInlineClass or RubyIROpCode.Return or + RubyIROpCode.ReturnSelf or RubyIROpCode.ReturnValue or RubyIROpCode.CheckArity or + RubyIROpCode.SetUpVar or RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField or + RubyIROpCode.ArraySet => false, + _ => true, + }; + + // --- generated-local coalescing (source-size only; the C# JIT register-allocates anyway) --- + // SSA renumbering gives every definition a fresh value-id, so a method can declare dozens of + // short-lived single-use temps. This maps each value-id to a representative "local id", reusing + // one C# local for value-ids that never live at the same time AND share a representation + // (boxed / long / double). Params (v0..vArg) and captured ids keep their own id (they are method + // parameters / by-ref cells). Returns id->repId, or null to leave naming unchanged. + internal static int[]? CoalesceLocals(RubyIRMethod ir, int argCount, bool[] isLong, bool[] isDouble, + Dictionary? scalarArrays = null, + Dictionary Keys)>? scalarHashes = null) + { + var ins = ir.Instructions; + var n = ins.Length; + var v = ir.ValueCount; + if (n == 0 || v == 0) return null; + + var kept = new bool[v]; + for (var i = 0; i <= argCount && i < v; i++) kept[i] = true; + foreach (var c in ir.ClosureCapturedValueIds) if (c < v) kept[c] = true; + // Scalar-replaced array value-ids never become a v{} local (they're element locals), so keep + // them out of the coalescing pool — they must never be chosen as a shared representative. + if (scalarArrays is not null) foreach (var id in scalarArrays.Keys) if ((uint)id < (uint)v) kept[id] = true; + if (scalarHashes is not null) foreach (var id in scalarHashes.Keys) if ((uint)id < (uint)v) kept[id] = true; + + // Successor CFG (branch targets are instruction indices; index n is the trailing end label). + var succ = new List[n]; + for (var i = 0; i < n; i++) succ[i] = new List(); + for (var i = 0; i < n; i++) + { + var op = ins[i].OpCode; + switch (op) + { + case RubyIROpCode.Return: + case RubyIROpCode.ReturnSelf: + case RubyIROpCode.ReturnValue: + break; + case RubyIROpCode.Jump: + if (ins[i].Aux < n) succ[i].Add(ins[i].Aux); + break; + case RubyIROpCode.JumpIfTruthy: + case RubyIROpCode.JumpIfFalsy: + case RubyIROpCode.JumpIfNil: + case RubyIROpCode.GuardInlineClass: + if (ins[i].Aux < n) succ[i].Add(ins[i].Aux); + if (i + 1 < n) succ[i].Add(i + 1); + break; + default: + if (i + 1 < n) succ[i].Add(i + 1); + break; + } + } + + var words = (v + 63) >> 6; + var use = new ulong[n][]; + var defId = new int[n]; + for (var i = 0; i < n; i++) + { + use[i] = new ulong[words]; + defId[i] = Definecheck(ins[i].OpCode) && ins[i].Dst < v ? ins[i].Dst : -1; + CollectUses(ir, ins[i], use[i], v); + } + + // Backward liveness to a fixpoint (reverse-order passes; loops converge in a few rounds). + var liveOut = new ulong[n][]; + for (var i = 0; i < n; i++) liveOut[i] = new ulong[words]; + var liveIn = new ulong[n][]; + for (var i = 0; i < n; i++) liveIn[i] = new ulong[words]; + var changed = true; + var guard = 0; + while (changed && guard++ <= n + 8) + { + changed = false; + for (var i = n - 1; i >= 0; i--) + { + var outI = liveOut[i]; + foreach (var s in succ[i]) + for (var w = 0; w < words; w++) outI[w] |= liveIn[s][w]; + // liveIn = (liveOut \ def) | use + var inI = liveIn[i]; + var d = defId[i]; + for (var w = 0; w < words; w++) + { + var nv = outI[w]; + if (d >= 0 && w == d >> 6) nv &= ~(1UL << (d & 63)); + nv |= use[i][w]; + if (nv != inI[w]) { inI[w] = nv; changed = true; } + } + } + } + + // Interference: a def interferes with everything live just after it (its live-out). + var adj = new ulong[v][]; + for (var i = 0; i < v; i++) adj[i] = new ulong[words]; + for (var i = 0; i < n; i++) + { + var d = defId[i]; + if (d < 0) continue; + var outI = liveOut[i]; + for (var w = 0; w < words; w++) + { + var bits = outI[w]; + while (bits != 0) + { + var bit = bits & (ulong)(-(long)bits); // lowest set bit + var t = (w << 6) + Log2Floor(bit); + bits ^= bit; + if (t != d) { adj[d][t >> 6] |= 1UL << (t & 63); adj[t][d >> 6] |= 1UL << (d & 63); } + } + } + } + + // Representation class: a long/double local can't hold a boxed value and vice-versa. + byte Repr(int id) => isDouble[id] ? (byte)2 : isLong[id] ? (byte)1 : (byte)0; + + // Greedy coloring within each representation: assign each colorable id the first existing + // representative whose members don't interfere with it, else open a new representative (=id). + var slot = new int[v]; + for (var i = 0; i < v; i++) slot[i] = i; + var reps = new List<(int Rep, byte R, ulong[] Members)>(); + var coalesced = false; + for (var id = argCount + 1; id < v; id++) + { + if (kept[id]) continue; + var r = Repr(id); + var placed = false; + foreach (var slotEntry in reps) + { + if (slotEntry.R != r) continue; + var conflict = false; + for (var w = 0; w < words; w++) + if ((adj[id][w] & slotEntry.Members[w]) != 0) { conflict = true; break; } + if (conflict) continue; + slot[id] = slotEntry.Rep; + slotEntry.Members[id >> 6] |= 1UL << (id & 63); + placed = true; + if (slotEntry.Rep != id) coalesced = true; + break; + } + if (!placed) + { + var members = new ulong[words]; + members[id >> 6] |= 1UL << (id & 63); + reps.Add((id, r, members)); + } + } + return coalesced ? slot : null; + } + + // Value-id uses of an instruction, set into `bits` (mirrors RubyIRMethod.CountValueUses / + // RubyIRSsaRenumber.EnumerateUses: Src0 always; Src1/Src2 unless an index field; call-site args; + // array operands; captured ids for LoadBlock / block-descriptor sends). + internal static void CollectUses(RubyIRMethod ir, in RubyIRInstruction ix, ulong[] bits, int v) + { + void Add(int id) { if ((uint)id < (uint)v) bits[id >> 6] |= 1UL << (id & 63); } + var op = ix.OpCode; + Add(ix.Src0); + if (op is not (RubyIROpCode.GuardInlineClass or RubyIROpCode.SendBlockDescriptor or RubyIROpCode.SendSelfBlockDescriptor)) + { + Add(ix.Src1); + Add(ix.Src2); + } + switch (op) + { + case RubyIROpCode.LoadBlock: + foreach (var c in ir.ClosureCapturedValueIds) Add(c); + break; + case RubyIROpCode.SendBlockDescriptor: + case RubyIROpCode.SendSelfBlockDescriptor: + foreach (var c in ir.ClosureCapturedValueIds) Add(c); + goto case RubyIROpCode.Send; + case RubyIROpCode.Send: + case RubyIROpCode.SendSelf: + case RubyIROpCode.SendBlock: + case RubyIROpCode.SendSelfBlock: + case RubyIROpCode.PureUnarySend: + case RubyIROpCode.VirtualNew: + { + var argc = ir.GetCallSiteArgumentCount(ix.Aux); + for (var a = 0; a < argc; a++) Add(ir.GetCallSiteArgumentValueId(ix.Aux, a)); + break; + } + case RubyIROpCode.NewArray: + case RubyIROpCode.NewArray2: + case RubyIROpCode.NewHash: + { + var c = ir.GetOperandListCount(ix.Aux); + for (var a = 0; a < c; a++) Add(ir.GetOperandListValueId(ix.Aux, a)); + break; + } + } + } + + // Bit index of a power-of-two ulong (netstandard2.1 has no BitOperations). Used to iterate set + // bits of a live-set word. De Bruijn sequence lookup. + static readonly int[] DeBruijn64 = + { + 0, 1, 48, 2, 57, 49, 28, 3, 61, 58, 50, 42, 38, 29, 17, 4, + 62, 55, 59, 36, 53, 51, 43, 22, 45, 39, 33, 30, 24, 18, 12, 5, + 63, 47, 56, 27, 60, 41, 37, 16, 54, 35, 52, 21, 44, 32, 23, 11, + 46, 26, 40, 15, 34, 20, 31, 10, 25, 14, 19, 9, 13, 8, 7, 6, + }; + static int Log2Floor(ulong powerOfTwo) => DeBruijn64[(powerOfTwo * 0x03f79d71b4cb0a89UL) >> 58]; + + internal static bool IsInlineCandidate(Irep calleeIrep) + { + try + { + var exe = RubyIRBuilder.Build(calleeIrep, 0, out _); + return exe is not null && exe.Instructions.Length <= 48; + } + catch + { + return false; + } + } + + internal static bool TryReadInlineSelectorShape(Irep irep, out bool returnsNew) + { + returnsNew = false; + try + { + var exe = RubyIRBuilder.Build(irep, 0, out _); + if (exe is null) return false; + foreach (var ins in exe.Instructions) + { + if (ins.OpCode is RubyIROpCode.SendSelf or RubyIROpCode.SendSelfBlock or RubyIROpCode.SendSelfBlockDescriptor) + { + return false; + } + if (ins.OpCode == RubyIROpCode.VirtualNew) + { + returnsNew = true; + } + } + return true; + } + catch + { + return false; + } + } + + internal static HashSet? FindSpliceCandidatePcs( + RubyIRMethod exe, + IReadOnlyDictionary? inlineSelectorRegistry, + IReadOnlyDictionary? accessorRegistry) + { + var instructions = exe.Instructions; + var defIndex = new int[exe.ValueCount]; + for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1; + for (var i = 0; i < instructions.Length; i++) + { + var d = instructions[i].Dst; + if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; + } + + HashSet? result = null; + for (var i = 0; i < instructions.Length; i++) + { + var ins = instructions[i]; + if (ins.OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) continue; + var argc = exe.GetCallSiteArgumentCount(ins.Aux); + var pc = exe.SourceBytecodePc(i); + if (pc < 0) continue; + + if (ins.OpCode == RubyIROpCode.SendSelf) + { + for (var a = 0; a < argc; a++) + { + if (ProducerReturnsNew(instructions, defIndex, exe, inlineSelectorRegistry, exe.GetCallSiteArgumentValueId(ins.Aux, a))) + { + result ??= []; + result.Add(pc); + break; + } + } + } + else if (ins.OpCode == RubyIROpCode.Send && + inlineSelectorRegistry is not null && + inlineSelectorRegistry.TryGetValue(exe.GetCallSiteSymbol(ins.Aux), out var target) && + target.ArgCount == argc) + { + var candidate = + (target.ReturnsNew && + HasVirtualProducerConsumer(instructions, exe, inlineSelectorRegistry, accessorRegistry, ins.Dst)) || + ProducerReturnsNew(instructions, defIndex, exe, inlineSelectorRegistry, ins.Src0); + for (var a = 0; !candidate && a < argc; a++) + { + candidate = ProducerReturnsNew(instructions, defIndex, exe, inlineSelectorRegistry, exe.GetCallSiteArgumentValueId(ins.Aux, a)); + } + if (candidate) + { + result ??= []; + result.Add(pc); + } + } + } + + return result; + } + + static bool HasVirtualProducerConsumer( + ReadOnlySpan instructions, + RubyIRMethod exe, + IReadOnlyDictionary? inlineSelectorRegistry, + IReadOnlyDictionary? accessorRegistry, + int valueId) + { + if (IsClosureCaptured(exe, valueId)) return false; + + var aliases = new HashSet { valueId }; + var changed = true; + while (changed) + { + changed = false; + foreach (var u in instructions) + { + if (u.OpCode == RubyIROpCode.Move && aliases.Contains(u.Src0)) + { + if (IsClosureCaptured(exe, u.Dst)) return false; + if (aliases.Add(u.Dst)) changed = true; + } + } + } + + foreach (var u in instructions) + { + if (u.OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) continue; + var sym = exe.GetCallSiteSymbol(u.Aux); + var inlineConsumer = inlineSelectorRegistry is not null && inlineSelectorRegistry.ContainsKey(sym); + var accessorConsumer = accessorRegistry is not null && accessorRegistry.ContainsKey(sym); + if (aliases.Contains(u.Src0) && (inlineConsumer || accessorConsumer)) + { + return true; + } + + if (inlineConsumer) + { + var argc = exe.GetCallSiteArgumentCount(u.Aux); + for (var a = 0; a < argc; a++) + { + if (aliases.Contains(exe.GetCallSiteArgumentValueId(u.Aux, a))) return true; + } + } + } + + return false; + } + + static bool ProducerReturnsNew( + ReadOnlySpan instructions, + int[] defIndex, + RubyIRMethod exe, + IReadOnlyDictionary? inlineSelectorRegistry, + int valueId) + { + if (IsClosureCaptured(exe, valueId)) return false; + if ((uint)valueId >= (uint)defIndex.Length) return false; + var d = defIndex[valueId]; + if (d < 0) return false; + var def = instructions[d]; + // Trace through copy chains: SSA-renumbered detection turns a reused merge id into a + // unique id reached via Move copies (`rs` = vsub result -> Move -> Move -> receiver), so + // the producer is behind one or more Moves. Bounded by instruction count (acyclic). + for (var hops = 0; def.OpCode == RubyIROpCode.Move && hops < instructions.Length; hops++) + { + var src = def.Src0; + if (IsClosureCaptured(exe, src) || (uint)src >= (uint)defIndex.Length) return false; + var sd = defIndex[src]; + if (sd < 0) return false; + def = instructions[sd]; + } + if (def.OpCode == RubyIROpCode.VirtualNew) return true; + return inlineSelectorRegistry is not null && + def.OpCode is RubyIROpCode.Send or RubyIROpCode.SendSelf && + inlineSelectorRegistry.TryGetValue(exe.GetCallSiteSymbol(def.Aux), out var target) && + target.ReturnsNew; + } + + static bool IsClosureCaptured(RubyIRMethod exe, int valueId) + { + foreach (var captured in exe.ClosureCapturedValueIds) + { + if (captured == valueId) return true; + } + return false; + } + + // Ops that commit no side effect and cannot re-enter arbitrary code, so a speculation guard's + // deopt (which re-runs the whole method in the interpreter) is harmless. Sends are judged + // separately (only trivial accessors / Math float calls are pure). Conservative whitelist: an + // op not listed here marks the method non-speculatable. + // Does this instruction COMMIT an observable side effect — i.e. one that a deopt (which + // re-runs the bytecode from the method's top) would wrongly re-apply? Used to bound the + // pre-side-effect float-speculation window. NOT side effects (a deopt safely re-does them): + // fresh allocations (VirtualNew/MaterializeObject — re-created), writes to a freshly-allocated + // scalar-replaced object (a method-local — discarded on deopt), pure float-Math sends, and + // accessor getters / pure ops. ARE side effects: writes to self or a pre-existing object, + // index/upvar/array writes, and any arbitrary (non-accessor, non-float-Math) call. + internal static bool IsCommittedSideEffect( + MRubyState state, + RubyIRMethod exe, + ScalarContext? sc, + IReadOnlyDictionary? accessorRegistry, + in RubyIRInstruction ins) + { + var op = ins.OpCode; + switch (op) + { + case RubyIROpCode.VirtualNew: + case RubyIROpCode.MaterializeObject: + return false; + case RubyIROpCode.SetInstanceVariable: + case RubyIROpCode.VirtualSetField: + return !(sc?.IsScalar(ins.Src0) ?? false); // write to a fresh scalar object is transparent + case RubyIROpCode.Send: + case RubyIROpCode.SendSelf: + { + var sel = exe.GetCallSiteSymbol(ins.Aux); + if (IsFloatReturningMethod(state, sel)) return false; + if (accessorRegistry is not null && accessorRegistry.TryGetValue(sel, out var acc)) + return acc.IsSetter && !(sc?.IsScalar(ins.Src0) ?? false); // getter pure; setter on fresh scalar transparent + return true; // arbitrary call + } + default: + return !RubyIROpInfo.IsPureSpeculationOp(op); // pure reads/arith/branches transparent; SetIndex/ArraySet/SetUpVar/... commit + } + } + + // Float-valued constant NAMES across the whole program, cached per state (codegen visits + // each method's GetConstant ops and needs to know if a constant is a Float to seed taint). + // Without this, `someInt / FLOAT_CONST` reads as same-taint and would deopt mid-method, + // corrupting any side effect already committed. Names can collide across scopes; a name + // that is float anywhere is treated as float-seeding, which is conservative (at worst it + // forces a Send slow path that's always correct). + [ThreadStatic] static MRubyState? floatConstState; + [ThreadStatic] static HashSet? floatConstNames; + + internal static bool IsFloatConstantName(MRubyState state, Symbol name) + { + if (!ReferenceEquals(floatConstState, state)) + { + var names = new HashSet(); + state.EnumerateConstants((sym, value) => { if (value.IsFloat) names.Add(sym); }); + floatConstNames = names; + floatConstState = state; + } + return floatConstNames!.Contains(name); + } + + // Cap on element count: `Array.new(1000)` shouldn't explode into 1000 locals. + const int ScalarArrayMaxSize = 32; + + internal static Dictionary? FindScalarArrays(MRubyState state, RubyIRMethod exe) + { + var ins = exe.Instructions; + var n = exe.ValueCount; + Dictionary? result = null; + var tmp = new ulong[(n + 63) >> 6]; + var defIndex = new int[n]; + for (var k = 0; k < n; k++) defIndex[k] = -1; + for (var k = 0; k < ins.Length; k++) { var d = ins[k].Dst; if ((uint)d < (uint)n) defIndex[d] = k; } + + for (var i = 0; i < ins.Length; i++) + { + int arr, size; + if (ins[i].OpCode == RubyIROpCode.NewArray) // [a, b, c] literal (NewArray2 splat not modeled) + { + arr = ins[i].Dst; + size = exe.GetOperandListCount(ins[i].Aux); + } + else if (ins[i].OpCode == RubyIROpCode.VirtualNew && + exe.GetCallSiteArgumentCount(ins[i].Aux) == 1 && + IsArrayClassReceiver(state, exe, ins, defIndex, ins[i].Src0) && + Mrb2CsCompiler.ConstFix(exe.GetCallSiteArgumentValueId(ins[i].Aux, 0), out var anSize) && + anSize > 0 && anSize <= ScalarArrayMaxSize) // Array.new(const) -> nil-filled + { + arr = ins[i].Dst; + size = (int)anSize; + } + else continue; + if ((uint)arr >= (uint)n || IsClosureCaptured(exe, arr)) continue; + if (size is 0 or > ScalarArrayMaxSize) continue; + // The array's value flows through Move copies (register reuse); follow them so accesses + // via any alias still count and the whole closure is replaced together. + var aliases = Analyzer.MoveClosure(exe, arr); + if (aliases.Contains(0)) continue; // would alias self/arg (shouldn't happen) + + var eligible = true; + for (var u = 0; u < ins.Length && eligible; u++) + { + var op = ins[u].OpCode; + if (op == RubyIROpCode.NewArray && ins[u].Dst == arr) + { + // The defining literal: elements must not reference the array itself. + var c = exe.GetOperandListCount(ins[u].Aux); + for (var a = 0; a < c; a++) if (InAliases(exe.GetOperandListValueId(ins[u].Aux, a))) { eligible = false; break; } + continue; + } + if (op == RubyIROpCode.Move && InAliases(ins[u].Src0)) continue; // intra-alias copy + if (op == RubyIROpCode.GetIndex0 && InAliases(ins[u].Src0)) continue; + if (op == RubyIROpCode.GetIndex && InAliases(ins[u].Src0) && + !InAliases(ins[u].Src1) && Mrb2CsCompiler.ConstFix(ins[u].Src1, out var gi) && gi >= 0 && gi < size) continue; + if (op == RubyIROpCode.SetIndex && InAliases(ins[u].Src0) && + !InAliases(ins[u].Src1) && !InAliases(ins[u].Src2) && + Mrb2CsCompiler.ConstFix(ins[u].Src1, out var si) && si >= 0 && si < size) continue; + // Any other appearance of an alias (non-const/oob index, Send arg, store, return, + // used as an index/element) -> escapes or dynamic -> not replaceable. + Array.Clear(tmp, 0, tmp.Length); + Analyzer.CollectUses(exe, ins[u], tmp, n); + foreach (var al in aliases) + if ((tmp[al >> 6] & (1UL << (al & 63))) != 0) { eligible = false; break; } + } + if (eligible) + { + result ??= new Dictionary(); + foreach (var al in aliases) result[al] = (arr, size); + } + + continue; + + bool InAliases(int id) => aliases.Contains(id); + } + return result; + } + + // True iff `recvId`'s definition is `GetConstant :Array` — i.e. the receiver of a `.new` is the + // core Array class, so `Array.new(n)` builds a plain n-element nil array we can scalar-replace. + static bool IsArrayClassReceiver(MRubyState state, RubyIRMethod exe, ReadOnlySpan ins, int[] defIndex, int recvId) + { + if ((uint)recvId >= (uint)defIndex.Length) return false; + var di = defIndex[recvId]; + if (di < 0 || ins[di].OpCode != RubyIROpCode.GetConstant) return false; + return state.NameOf(exe.GetSymbol(ins[di].Aux)).AsSpan().SequenceEqual("Array"u8); + } + + // Constant key -> identifier-safe, collision-free tag (symbol intern id / fixnum value). + static bool TryConstKeyTag(RubyIRMethod exe, int[] defIndex, int keyId, out string tag) + { + tag = ""; + if ((uint)keyId >= (uint)defIndex.Length) return false; + var di = defIndex[keyId]; + if (di < 0 || exe.Instructions[di].OpCode != RubyIROpCode.LoadValue) return false; + var lit = exe.GetLiteral(exe.Instructions[di].Aux); + if (lit.IsFixnum) { tag = "i" + lit.FixnumValue; return true; } + if (lit.IsSymbol) { tag = "s" + lit.SymbolValue.Value; return true; } + return false; // string/float/other keys not modeled + } + + internal static Dictionary)>? FindScalarHashes(RubyIRMethod exe) + { + var ins = exe.Instructions; + var n = exe.ValueCount; + Dictionary)>? result = null; + var tmp = new ulong[(n + 63) >> 6]; + var defIndex = new int[n]; + for (var k = 0; k < n; k++) defIndex[k] = -1; + for (var k = 0; k < ins.Length; k++) { var d = ins[k].Dst; if ((uint)d < (uint)n) defIndex[d] = k; } + + for (var i = 0; i < ins.Length; i++) + { + if (ins[i].OpCode != RubyIROpCode.NewHash) continue; + var hash = ins[i].Dst; + if ((uint)hash >= (uint)n || IsClosureCaptured(exe, hash)) continue; + var pairs = exe.GetOperandListCount(ins[i].Aux) / 2; + if (pairs == 0 || pairs > ScalarArrayMaxSize) continue; + + var aliases = Analyzer.MoveClosure(exe, hash); + if (aliases.Contains(0)) continue; + bool InAliases(int id) => aliases.Contains(id); + + var keys = new HashSet(); // keys that HOLD a value (literal + set) + var keyTags = new Dictionary(); // const-key value-id -> tag + var eligible = true; + + // Literal keys must all be constant. + for (var p = 0; p < pairs && eligible; p++) + { + var keyId = exe.GetOperandListValueId(ins[i].Aux, 2 * p); + if (!TryConstKeyTag(exe, defIndex, keyId, out var tag)) { eligible = false; break; } + keys.Add(tag); + keyTags[keyId] = tag; + } + + for (var u = 0; u < ins.Length && eligible; u++) + { + var op = ins[u].OpCode; + if (op == RubyIROpCode.NewHash && ins[u].Dst == hash) continue; // the literal itself + if (op == RubyIROpCode.Move && InAliases(ins[u].Src0)) continue; + if (op == RubyIROpCode.GetIndex0 && InAliases(ins[u].Src0)) continue; // h[0] -> tag i0 + if (op == RubyIROpCode.GetIndex && InAliases(ins[u].Src0) && !InAliases(ins[u].Src1) && + TryConstKeyTag(exe, defIndex, ins[u].Src1, out var gtag)) + { + keyTags[ins[u].Src1] = gtag; // a get of an absent key reads nil (no local needed) + continue; + } + if (op == RubyIROpCode.SetIndex && InAliases(ins[u].Src0) && !InAliases(ins[u].Src1) && + !InAliases(ins[u].Src2) && TryConstKeyTag(exe, defIndex, ins[u].Src1, out var stag)) + { + keys.Add(stag); // a set adds/holds the key + keyTags[ins[u].Src1] = stag; + continue; + } + // Any other appearance (dynamic key, iteration .each/.keys/.size, escape) -> bail. + Array.Clear(tmp, 0, tmp.Length); + Analyzer.CollectUses(exe, ins[u], tmp, n); + foreach (var al in aliases) + if ((tmp[al >> 6] & (1UL << (al & 63))) != 0) { eligible = false; break; } + } + if (eligible) + { + result ??= new Dictionary)>(); + foreach (var al in aliases) result[al] = (hash, keys); + Mrb2CsCompiler.CurrentHashKeyTags ??= new Dictionary(); + foreach (var (kid, t) in keyTags) Mrb2CsCompiler.CurrentHashKeyTags[kid] = t; + } + } + return result; + } + + internal static Dictionary? BuildConstLit(RubyIRMethod exe) + { + // Only a SINGLE-def LoadValue id is a stable constant — a register-reused id (non-SSA block + // IR) could be reassigned, so it must be excluded for soundness. + var defCount = new int[exe.ValueCount]; + foreach (var u in exe.Instructions) if ((uint)u.Dst < (uint)defCount.Length) defCount[u.Dst]++; + Dictionary? map = null; + foreach (var u in exe.Instructions) + if (u.OpCode == RubyIROpCode.LoadValue && (uint)u.Dst < (uint)defCount.Length && defCount[u.Dst] == 1) + { + var lit = exe.GetLiteral(u.Aux); + if (lit.IsFixnum || lit.IsFloat) (map ??= new())[u.Dst] = lit; + } + return map; + } + + // A send whose result is always a Float -> seeds float taint / provesDouble. True for builtin + // Float-returning methods (Math.* / to_f, which aren't in the AOT method set) OR a user method + // whose body was INFERRED to always return Float (RubyIRReturnTypes; sound, no name guessing). + internal static bool IsFloatReturningMethod(MRubyState state, Symbol sym) => + IsBuiltinFloatMethod(state, sym) || (Mrb2CsCompiler.CurrentReturnTypes?.ReturnsFloat(sym) ?? false); + + // Float-returning BUILTINS (not in the AOT method set, so they must be seeded): Math.* and + // to_f. Everything else is inferred from method bodies, not matched by name. + // Byte-exact symbol-name compare, the building block of the name-classification predicates below. + internal static bool Matches(ReadOnlySpan a, ReadOnlySpan b) => a.SequenceEqual(b); + + internal static bool IsBuiltinFloatMethod(MRubyState state, Symbol sym) + { + var name = state.NameOf(sym).AsSpan(); + return Matches(name, "to_f"u8) || + Matches(name, "sqrt"u8) || Matches(name, "cbrt"u8) || + Matches(name, "sin"u8) || Matches(name, "cos"u8) || Matches(name, "tan"u8) || + Matches(name, "asin"u8) || Matches(name, "acos"u8) || Matches(name, "atan"u8) || + Matches(name, "atan2"u8) || Matches(name, "hypot"u8) || + Matches(name, "exp"u8) || Matches(name, "log"u8) || + Matches(name, "log2"u8) || Matches(name, "log10"u8) || + Matches(name, "sinh"u8) || Matches(name, "cosh"u8) || Matches(name, "tanh"u8) || + Matches(name, "pow"u8); + } + + internal static bool IsToFMethod(MRubyState state, Symbol sym) => + Matches(state.NameOf(sym).AsSpan(), "to_f"u8); + + // Methods that switch fibers/threads — unsafe to call from a compiled C# frame. + internal static bool IsContextSwitchingMethod(MRubyState state, Symbol sym) + { + var name = state.NameOf(sym).AsSpan(); + return Matches(name, "yield"u8) || Matches(name, "resume"u8) || + Matches(name, "transfer"u8) || Matches(name, "sleep"u8) || + Matches(name, "pass"u8); + } +} diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/CompiledMethod.cs b/src/ChibiRuby.JetPack/Mrb2Cs/CompiledMethod.cs new file mode 100644 index 00000000..c463d43a --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/CompiledMethod.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace ChibiRuby.JetPack.Mrb2Cs; + +// One Ruby method compiled to C#: the emitted method source plus the metadata the driver +// (Compile) needs to assemble and bind it. Produced by Mrb2CsCompiler.TryCompileMethod. +public sealed class CompiledMethod(string methodName, string source, int argCount, int instructionCount, bool isLeaf, List? auxiliaryMethods = null) +{ + public string MethodName { get; } = methodName; + public string Source { get; } = source; + public int ArgCount { get; } = argCount; + public int InstructionCount { get; } = instructionCount; + // No outbound Ruby calls (Send/SendSelf/block sends) -> safe to inline without + // growing the C# call stack per Ruby call depth (bounds depth to caller+1). + public bool IsLeaf { get; } = isLeaf; + // Extra C# methods this body needs emitted alongside it (inlined block bodies as + // `__blk` methods). The driver adds them to the generated class. + public IReadOnlyList AuxiliaryMethods { get; } = (IReadOnlyList?)auxiliaryMethods ?? []; +} diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/DefinitionLoader.cs b/src/ChibiRuby.JetPack/Mrb2Cs/DefinitionLoader.cs new file mode 100644 index 00000000..30ce695d --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/DefinitionLoader.cs @@ -0,0 +1,201 @@ +using ChibiRuby; + +namespace ChibiRuby.JetPack.Mrb2Cs; + +// True-AOT front-end for mrb2cs: register a program's classes/modules/methods into the state +// WITHOUT executing it. mruby's class/def are runtime ops, so the normal way to discover +// definitions is to run the program (which also runs main — not AOT). This instead statically +// walks the def-structural ops (CLASS/MODULE/EXEC/TDEF/DEF/METHOD/TCLASS/OCLASS, + the GETCONST/ +// LOADNIL/MOVE feeding outer/super) and drives the same DefineClass/DefineMethod APIs the VM +// uses — never running method bodies or top-level main. +// +// Best-effort and CONSERVATIVE: it tracks only registers it can resolve to a class or a method +// irep; any op it doesn't model clears its destination register, so an unresolved class/super/ +// method is simply SKIPPED (those methods stay interpreted — correct, just not AOT-compiled). It +// never registers a method on a wrong class. EXT-widened operands abort the walk for that irep. +public static class DefinitionLoader +{ + public static void Load(MRubyState state, Irep root) + { + Walk(state, root, state.ObjectClass); + } + + static void Walk(MRubyState state, Irep irep, RClass currentClass) + { + var seq = irep.Sequence; + var n = irep.RegisterVariableCount < 1 ? 256 : irep.RegisterVariableCount + 1; + var regClass = new RClass?[n]; // register -> the class object it holds, if known + var regProc = new Irep?[n]; // register -> the method-body irep it holds (from OP_METHOD) + + void Clear(int r) { if ((uint)r < (uint)n) { regClass[r] = null; regProc[r] = null; } } + + var pc = 0; + while (pc < seq.Length) + { + var op = (OpCode)seq[pc]; + // EXT prefixes widen the next op's operands; we don't model widened operands, so stop + // (the rest of this irep's defs are skipped — safe). + if (op is OpCode.EXT1 or OpCode.EXT2 or OpCode.EXT3) return; + + var a = pc + 1 < seq.Length ? seq[pc + 1] : 0; + var b = pc + 2 < seq.Length ? seq[pc + 2] : 0; + + switch (op) + { + case OpCode.Move: + if ((uint)a < (uint)n && (uint)b < (uint)n) { regClass[a] = regClass[b]; regProc[a] = regProc[b]; } + break; + case OpCode.LoadNil: + Clear(a); + break; + case OpCode.OClass: + if ((uint)a < (uint)n) { regClass[a] = state.ObjectClass; regProc[a] = null; } + break; + case OpCode.TClass: + if ((uint)a < (uint)n) { regClass[a] = currentClass; regProc[a] = null; } + break; + case OpCode.LoadSelf: + // At a class/module body scope self IS the class being defined. + if ((uint)a < (uint)n) { regClass[a] = currentClass; regProc[a] = null; } + break; + case OpCode.GetConst: + case OpCode.GetMCnst: + if ((uint)a < (uint)n) + { + regProc[a] = null; + regClass[a] = (uint)b < (uint)irep.Symbols.Length && + state.TryGetConst(irep.Symbols[b], out var cv) && cv.Object is RClass kc + ? kc : null; + } + break; + case OpCode.Method: + if ((uint)a < (uint)n) { regProc[a] = (uint)b < (uint)irep.Children.Length ? irep.Children[b] : null; regClass[a] = null; } + break; + case OpCode.Class: + DefineClassAt(state, irep, currentClass, regClass, a, b, n); + break; + case OpCode.Module: + DefineModuleAt(state, irep, regClass, a, b, n); + break; + case OpCode.Exec: + // Run the class/module body irep statically (NOT via the VM) under its class. + if ((uint)a < (uint)n && regClass[a] is { } bodyClass && (uint)b < (uint)irep.Children.Length) + { + Walk(state, irep.Children[b], bodyClass); + } + break; + case OpCode.TDef: + // The normal `def name; ...; end` inside a class/module body (and at top level). + // Mirrors the VM's OP_TDEF (MRubyState.Vm.cs): BBB, target = the lexical scope's + // class (here `currentClass`), method symbol = B, proc body = child irep C. + DefineTDef(state, irep, currentClass, b, pc + 3 < seq.Length ? seq[pc + 3] : 0); + break; + case OpCode.Def: + // The `define_method`/proc-in-register form: target class in reg[A], proc in reg[A+1]. + if ((uint)a < (uint)n && (uint)(a + 1) < (uint)n && + regClass[a] is { } defClass && regProc[a + 1] is { } methodIrep && + (uint)b < (uint)irep.Symbols.Length) + { + var proc = state.NewProc(methodIrep, defClass); + // Mirror the VM's OP_METHOD: a method proc is strict + scoped. Trivial + // accessor/setter detection requires these flags, so without them the + // accessor registry stays empty and no devirtualization happens. + proc.SetFlag(MRubyObjectFlags.ProcStrict | MRubyObjectFlags.ProcScope); + state.DefineMethod(defClass, irep.Symbols[b], MRubyMethod.CreateFromProc(proc)); + } + break; + default: + // Any other op (the program's actual logic): not modeled — its destination + // register (operand A, for the ops that have one) no longer holds a tracked value. + Clear(a); + break; + } + + pc += 1 + OperandBytes(op); + } + } + + static void DefineClassAt(MRubyState state, Irep irep, RClass currentClass, RClass?[] regClass, int a, int b, int n) + { + if ((uint)a >= (uint)n || (uint)b >= (uint)irep.Symbols.Length) return; + var name = irep.Symbols[b]; + var outer = regClass[a] ?? currentClass; // nil outer -> lexical scope + var super = (uint)(a + 1) < (uint)n ? regClass[a + 1] : null; + RClass cls; + if (state.TryGetConst(name, outer, out var existing) && existing.Object is RClass reopened) + { + cls = reopened; // reopening an existing class/module + } + else + { + cls = state.DefineClass(name, super ?? state.ObjectClass, outer: outer); + // DefineClass only links the class path; bind the constant in `outer` so the class + // is reachable from EnumerateAotMethods (which walks outer.InstanceVariables). + state.DefineConst(outer, name, new MRubyValue(cls)); + } + regClass[a] = cls; + } + + static void DefineTDef(MRubyState state, Irep irep, RClass target, int symbolIdx, int childIdx) + { + if ((uint)symbolIdx >= (uint)irep.Symbols.Length || (uint)childIdx >= (uint)irep.Children.Length) return; + var proc = state.NewProc(irep.Children[childIdx], target); + proc.SetFlag(MRubyObjectFlags.ProcStrict | MRubyObjectFlags.ProcScope); + state.DefineMethod(target, irep.Symbols[symbolIdx], MRubyMethod.CreateFromProc(proc)); + } + + static void DefineModuleAt(MRubyState state, Irep irep, RClass?[] regClass, int a, int b, int n) + { + if ((uint)a >= (uint)n || (uint)b >= (uint)irep.Symbols.Length) return; + var name = irep.Symbols[b]; + var outer = regClass[a] ?? state.ObjectClass; + if (state.TryGetConst(name, outer, out var existing) && existing.Object is RClass reopened) + { + regClass[a] = reopened; + } + else + { + var mod = state.DefineModule(name, outer); + state.DefineConst(outer, name, new MRubyValue(mod)); + regClass[a] = mod; + } + } + + // Operand bytes AFTER the 1-byte opcode (mruby 4.0). Derived from the disassembler's per-op + // operand reads (MRubyState.Dump.cs): Z=0, B=1, S/BB=2, BS/BBB/W=3, BSS=5. Add/Sub are B + // (they `goto case EQ`). Anything not listed is treated as 0 (safe: a too-short advance just + // ends the walk early / mis-decodes into the conservative default path, never mis-registers). + static int OperandBytes(OpCode op) => op switch + { + OpCode.Nop or OpCode.Stop or OpCode.Call or OpCode.KeyEnd or + OpCode.EXT1 or OpCode.EXT2 or OpCode.EXT3 => 0, + + OpCode.Mul or OpCode.Div or OpCode.EQ or OpCode.LT or OpCode.LE or OpCode.GT or OpCode.GE or + OpCode.Add or OpCode.Sub or OpCode.AryCat or OpCode.ArySplat or OpCode.Break or OpCode.Err or + OpCode.Except or OpCode.GetIdx or OpCode.HashCat or OpCode.Intern or OpCode.LoadF or + OpCode.LoadI_0 or OpCode.LoadI_1 or OpCode.LoadI_2 or OpCode.LoadI_3 or OpCode.LoadI_4 or + OpCode.LoadI_5 or OpCode.LoadI_6 or OpCode.LoadI_7 or OpCode.LoadI__1 or OpCode.LoadNil or + OpCode.LoadSelf or OpCode.LoadT or OpCode.MatchErr or OpCode.OClass or OpCode.RaiseIf or + OpCode.RangeExc or OpCode.RangeInc or OpCode.Return or OpCode.ReturnBlk or OpCode.SClass or + OpCode.SetIdx or OpCode.StrCat or OpCode.TClass or OpCode.Undef => 1, + + OpCode.Jmp or OpCode.JmpUw or + OpCode.AddI or OpCode.SubI or OpCode.Alias or OpCode.Array or OpCode.AryPush or OpCode.BlkCall or + OpCode.Block or OpCode.Class or OpCode.Def or OpCode.GetCV or OpCode.GetConst or OpCode.GetGV or + OpCode.GetIV or OpCode.GetIdx0 or OpCode.GetMCnst or OpCode.GetSV or OpCode.Hash or OpCode.HashAdd or + OpCode.KArg or OpCode.KeyP or OpCode.Lambda or OpCode.LoadI8 or OpCode.LoadL or OpCode.LoadSym or + OpCode.Method or OpCode.Module or OpCode.Move or OpCode.Rescue or OpCode.SSend0 or OpCode.Send0 or + OpCode.SetCV or OpCode.SetConst or OpCode.SetGV or OpCode.SetIV or OpCode.SetMCnst or OpCode.SetSV or + OpCode.String or OpCode.Super or OpCode.Symbol or OpCode.Exec => 2, + + OpCode.Enter or + OpCode.APost or OpCode.ARef or OpCode.ASet or OpCode.AddILV or OpCode.Array2 or OpCode.Debug or + OpCode.GetUpVar or OpCode.SDef or OpCode.SSend or OpCode.SSendB or OpCode.Send or OpCode.SendB or + OpCode.SetUpVar or OpCode.SubILV or OpCode.TDef or + OpCode.ArgAry or OpCode.BlkPush or OpCode.JmpIf or OpCode.JmpNil or OpCode.JmpNot or OpCode.LoadI16 => 3, + + OpCode.LoadI32 => 5, + + _ => 0, + }; +} diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/DevirtTargets.cs b/src/ChibiRuby.JetPack/Mrb2Cs/DevirtTargets.cs new file mode 100644 index 00000000..de987c57 --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/DevirtTargets.cs @@ -0,0 +1,12 @@ +using ChibiRuby; + +namespace ChibiRuby.JetPack.Mrb2Cs; + +public readonly record struct AccessorTarget(Symbol Field, ulong Fingerprint, bool IsSetter); +public readonly record struct ConstReturnTarget(MRubyValue Value, ulong Fingerprint); +public readonly record struct InlineSelectorTarget( + Irep Irep, + int ArgCount, + ulong Fingerprint, + RClass DefiningClass, + bool ReturnsNew); diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/Emitter.cs b/src/ChibiRuby.JetPack/Mrb2Cs/Emitter.cs new file mode 100644 index 00000000..fcaa66a3 --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/Emitter.cs @@ -0,0 +1,2619 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Linq; +using static ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler; +using static ChibiRuby.JetPack.Mrb2Cs.Analyzer; + +using static ChibiRuby.JetPack.RubyIROpInfo; +namespace ChibiRuby.JetPack.Mrb2Cs; + +// C# emission: walks the analyzed RubyIR and emits the C# method body. Reads the per-method +// analysis facts (unboxing, scalar/stack replacement, const literals) that Mrb2CsCompiler's +// orchestration computes and stores in its (internal) per-method state, plus the shared pure +// helpers/predicates. Holds the emit-only caches (RObject-cast CSE, ivar-read CSE, block-emit +// state, struct-canonical aliasing). Mutual `using static` with Mrb2CsCompiler. +public static class Emitter +{ + [ThreadStatic] + internal static BlockEmitState? BlockEmit; + + // CSE for `(RObject)v.Object` casts: maps a value-id to the C# local that already holds its + // RObject cast, so repeated ivar ops on the same (unconditionally-RObject) receiver — self, or + // a freshly-`new`ed object — cast once. Invalidated when the value-id is reassigned or at a + // control-flow join (label), so the cached local always refers to the live value. + [ThreadStatic] + static Dictionary? AsObjCache; + + [ThreadStatic] + static int AsObjCounter; + + // Return the name of an RObject local holding `valueId`'s cast, emitting the one-time + // `RObject __roN = vK.As();` on first use. Only call for values known to be RObject. + internal static string AsRObject(int valueId, string boxedRead, StringBuilder body) + { + AsObjCache ??= new Dictionary(); + if (AsObjCache.TryGetValue(valueId, out var name)) return name; + name = "__ro" + AsObjCounter++; + Line(body, $"global::ChibiRuby.RObject {name} = {boxedRead}.As();"); + AsObjCache[valueId] = name; + return name; + } + + internal static void InvalidateAsObj(int valueId) => AsObjCache?.Remove(valueId); + internal static void ClearAsObjCache() => AsObjCache?.Clear(); + + // CSE of self instance-variable reads: `(receiver value-id, field) -> a dedicated `__ivN` local + // holding the result of the first `.InstanceVariables.Get(field)`. A re-read of the same field on + // the same receiver (with no intervening write/call/branch) reuses that local instead of doing a + // second hash-table lookup — e.g. `@x * @x` reads @x once. The temp is a dedicated local (like + // asObjCache's `__roN`), never coalesced, so reusing it is sound regardless of value-id liveness. + [ThreadStatic] internal static Dictionary<(int Recv, Symbol Field), string>? ivarGetCache; + [ThreadStatic] internal static int ivCounter; + // Pre-pass result: read dsts that have a later valid re-read, so are worth caching into a `__ivN` + // temp. A read NOT in this set is emitted inline (no temp) to avoid bloating single-read fields. + [ThreadStatic] internal static HashSet? reusedIvarReads; + + internal static void ClearIvarGetCache() => ivarGetCache?.Clear(); + + // Drop cached reads invalidated by instruction `ins`: any Ruby call or ivar write may change a + // field's value (conservatively clear all), and reassigning a receiver value-id stales its reads. + internal static void InvalidateIvarGet(in RubyIRInstruction ins) + { + if (ivarGetCache is not { Count: > 0 } cache) return; + switch (ins.OpCode) + { + case RubyIROpCode.Send: + case RubyIROpCode.SendSelf: + case RubyIROpCode.SendBlock: + case RubyIROpCode.SendSelfBlock: + case RubyIROpCode.SendBlockDescriptor: + case RubyIROpCode.SendSelfBlockDescriptor: + case RubyIROpCode.PureUnarySend: + case RubyIROpCode.VirtualNew: + case RubyIROpCode.SetInstanceVariable: + case RubyIROpCode.VirtualSetField: + cache.Clear(); + return; + } + var d = ins.Dst; + if (d >= 0) + { + List<(int, Symbol)>? drop = null; + foreach (var k in cache.Keys) if (k.Recv == d) (drop ??= new()).Add(k); + if (drop is not null) foreach (var k in drop) cache.Remove(k); + } + } + + // Pre-pass: which self-ivar read dsts are re-read later (same receiver+field, no intervening + // write/call/branch) — those are cached into a `__ivN` temp; one-shot reads stay inline. + internal static HashSet ComputeReusedIvarReads(RubyIRMethod ir, HashSet targets) + { + var reused = new HashSet(); + if (Environment.GetEnvironmentVariable("AOT_NOIVARCSE") == "1") return reused; // diagnostic off-switch + var seen = new Dictionary<(int, Symbol), int>(); + var ins = ir.Instructions; + for (var i = 0; i < ins.Length; i++) + { + if (targets.Contains(i)) seen.Clear(); // control-flow join: reads above may not reach here + ref readonly var x = ref ins[i]; + if (x.OpCode is RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField) + { + var key = (x.Src0, ir.GetSymbol(x.Aux)); + if (seen.TryGetValue(key, out var canon)) reused.Add(canon); + else seen[key] = x.Dst; + continue; + } + // Mirror InvalidateIvarGet's invalidation so the pre-pass agrees with emission. + switch (x.OpCode) + { + case RubyIROpCode.Send: + case RubyIROpCode.SendSelf: + case RubyIROpCode.SendBlock: + case RubyIROpCode.SendSelfBlock: + case RubyIROpCode.SendBlockDescriptor: + case RubyIROpCode.SendSelfBlockDescriptor: + case RubyIROpCode.PureUnarySend: + case RubyIROpCode.VirtualNew: + case RubyIROpCode.SetInstanceVariable: + case RubyIROpCode.VirtualSetField: + seen.Clear(); + break; + default: + if (x.Dst >= 0) + { + List<(int, Symbol)>? drop = null; + foreach (var k in seen.Keys) if (k.Item1 == x.Dst) (drop ??= new()).Add(k); + if (drop is not null) foreach (var k in drop) seen.Remove(k); + } + break; + } + } + return reused; + } + + // Assign a long-valued expression to dst in its representation (raw long or re-boxed). + static void AssignFix(bool[] isLong, StringBuilder body, int dst, string longExpr) + { + Line(body, isLong[dst] + ? $"l{Slot(dst)} = {longExpr};" + : $"v{Slot(dst)} = new global::ChibiRuby.MRubyValue({longExpr});"); + } + + // Emit `if (!(g0 && g1 && ...)) deopt;` over the non-null guard parts (none -> nothing). + static void EmitGuard(StringBuilder body, params string?[] guards) + { + var live = new List(); + foreach (var g in guards) + { + if (g is not null) live.Add(g); + } + if (live.Count > 0) + { + Line(body, $"if (!({string.Join(" && ", live)})) {{ result = default; return false; }}"); + } + } + + internal static bool EmitInstruction(MRubyState state, RubyIRMethod exe, in RubyIRInstruction ins, SymbolCache sym, StringBuilder body, InlineContext? ic, ScalarContext? sc, bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble) + { + switch (ins.OpCode) + { + case RubyIROpCode.CheckArity: + return true; + + // `Const.new(...)` proven non-escaping -> no allocation; initialize is inlined as + // field-local assignments. Any other VirtualNew still bails (interpreter allocates). + case RubyIROpCode.VirtualNew: + { + // Array.new(const) scalar-replaced: nil-fill the element locals, no allocation. + if (TryScalarArray(ins.Dst, out var vnCanon, out var vnSize)) + { + for (var k = 0; k < vnSize; k++) + Line(body, $"{ArrElem(vnCanon, k)} = global::ChibiRuby.MRubyValue.Nil;"); + return true; + } + // Stack-allocated object: build the struct in place (no heap alloc). + if (CurrentStackObjects is { } cso && cso.TryGetValue(ins.Dst, out var stackLay)) + { + EmitStackConstruct(exe, ins, stackLay, body); + return true; + } + if (sc is not null && sc.IsScalar(ins.Dst)) + { + EmitScalarNew(sc, exe, ins, body); + return true; + } + // Escaping but inline-constructible: allocate + set ivars directly, skipping the + // :new + :initialize double dispatch. + if (sc is not null && sc.IsFastNew(ins.Dst)) + { + EmitFastNew(sc, exe, ins, body); + return true; + } + if (Environment.GetEnvironmentVariable("AOT_NONEW") == "1") { LastBail = "vnew:gated"; return false; } + // Escaping allocation (returned / stored into another object): can't scalarize, + // so allocate for real via `Class.new(...)`. Emitting it as a Send keeps the + // rest of the method compiled (the whole body no longer bails to the + // interpreter just because it constructs an object). Same dispatch the + // interpreter would do; everything around it now runs as C#. + var newSym = exe.GetCallSiteSymbol(ins.Aux); + if (!TrySymbolStringLiteral(state, newSym, out var newName)) + { + LastBail = "vnew:sym"; + return false; + } + var newArgc = exe.GetCallSiteArgumentCount(ins.Aux); + if (newArgc > 4) + { + LastBail = "vnew:argc>4"; + return false; + } + var newCall = new StringBuilder(); + newCall.Append(Val(ins.Dst)).Append(" = state.Send(").Append(Val(ins.Src0)) + .Append(", ").Append(sym.Reference(newName)); + for (var i = 0; i < newArgc; i++) + { + newCall.Append(", ").Append(Val(exe.GetCallSiteArgumentValueId(ins.Aux, i))); + } + newCall.Append(");"); + Line(body, newCall.ToString()); + return true; + } + + case RubyIROpCode.Move: + // A copy of a scalar-replaced array/hash alias is a no-op (src and dst share the + // element/key locals). + if (TryScalarArray(ins.Src0, out _, out _) || TryScalarHash(ins.Src0, out _, out _)) return true; + if (sc is not null && sc.TryEmitScalarMove(ins)) + { + return true; + } + // Looping methods: representation-aware copy (the back-edge `zr = tr` and pre-loop + // `zr = 0.0` write the raw d{}/l{} local for raw loop-carried values). Reads convert + // from src's representation; a boxed dst re-boxes a raw src. + if (CurrentProvesFixnum is not null) + { + var md = ins.Dst; + var ms = ins.Src0; + var lhs = isDouble[md] ? "d" + Slot(md) : isLong[md] ? "l" + Slot(md) : Val(md); + var rhs = isDouble[md] ? DoubleRead(isLong, isDouble, ms) + : isLong[md] ? FixRead(isLong, ms) + : BoxReadFull(isLong, isDouble, ms); + if (lhs != rhs) Line(body, $"{lhs} = {rhs};"); + return true; + } + // Canonicalized mutated-struct aliases render to the same local -> a `soX = soX` + // self-copy; skip it (and it must be skipped so the snapshot/copy ordering is right). + if (Val(ins.Dst) != Val(ins.Src0)) Line(body, $"{Val(ins.Dst)} = {Val(ins.Src0)};"); + return true; + + case RubyIROpCode.LoadValue: + { + // Looping methods: a raw loop local is initialized directly (e.g. `d{} = 0D` for a + // pre-loop `zr = 0.0`), no MRubyValue box. + if (CurrentProvesFixnum is not null) + { + if (isDouble[ins.Dst]) { Line(body, $"d{Slot(ins.Dst)} = {DoubleLitText(exe.GetLiteral(ins.Aux).FloatValue)};"); return true; } + if (isLong[ins.Dst]) { Line(body, $"l{Slot(ins.Dst)} = {exe.GetLiteral(ins.Aux).FixnumValue}L;"); return true; } + } + // Symbol literal (`:a`) — used as a hash key / symbol value. Intern once via the + // method's SymbolCache. + var lvLit = exe.GetLiteral(ins.Aux); + if (lvLit.IsSymbol && TrySymbolStringLiteral(state, lvLit.SymbolValue, out var lvSymName)) + { + Line(body, $"{Val(ins.Dst)} = new global::ChibiRuby.MRubyValue({sym.Reference(lvSymName)});"); + return true; + } + if (!TryEmitLiteral(lvLit, out var expr)) + { + return false; + } + Line(body, $"{Val(ins.Dst)} = {expr};"); + return true; + } + + case RubyIROpCode.LoadSelf: + Line(body, $"{Val(ins.Dst)} = {Val(0)};"); + return true; + + case RubyIROpCode.GetConstant: + { + if (!TrySymbolStringLiteral(state, exe.GetSymbol(ins.Aux), out var cname)) + { + return false; + } + // GetConstantUnsafe shares the interpreter's lexical resolution; the cached form + // skips the scope-chain walk while no constant has been (re)assigned. Both paths + // resolve identically on a miss/first call. AOT_NOCONSTCACHE forces the uncached form. + if (ic is not null && Environment.GetEnvironmentVariable("AOT_NOCONSTCACHE") != "1") + { + EmitGuardedConstantRead(ic, ins, cname, body); + } + else + { + Line(body, $"{Val(ins.Dst)} = GetConstantUnsafe(state, {sym.Reference(cname)});"); + } + return true; + } + + case RubyIROpCode.GetModuleConstant: + { + if (!TrySymbolStringLiteral(state, exe.GetSymbol(ins.Aux), out var cname)) + { + return false; + } + // `Mod::Name` — resolve the constant in the module value (Src0), exactly + // as the interpreter's OP_GetMCnst does. GetConst(Symbol, RClass) is public. + Line(body, $"{Val(ins.Dst)} = state.GetConst({sym.Reference(cname)}, {Val(ins.Src0)}.As());"); + return true; + } + + case RubyIROpCode.GuardInlineClass: + { + if (sc is not null && TryEmitScalarInlineGuard(sc, exe, ins, body)) + { + return true; + } + if (ic is null || + !exe.TryGetGuardInline(ins.Src1, out var fp) || + fp == 0) + { + LastBail = "guardinline:metadata"; + return false; + } + if (!TrySymbolStringLiteral(state, exe.GetCallSiteSymbol(ins.Src1), out var mname)) + { + return false; + } + EmitGuardInlineClass(ic, ins, mname, fp, body); + return true; + } + + case RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField: + { + if (sc is not null && TryEmitScalarFieldAccess(sc, exe, ins, body)) + { + return true; + } + // Self is a stack struct (struct-receiver variant): read the field directly. + if (CurrentStackObjects is { } gcso && gcso.TryGetValue(ins.Src0, out var glay) && + glay.FieldIndexOf(exe.GetSymbol(ins.Aux)) is var gfi && gfi >= 0) + { + var gso = Val(ins.Src0); + var gread = glay.FieldKinds[gfi] switch + { + StackFieldKind.Double or StackFieldKind.Long => $"new global::ChibiRuby.MRubyValue({gso}.f{gfi})", + _ => $"{gso}.f{gfi}", + }; + Line(body, $"{Val(ins.Dst)} = {gread};"); + EmitFloatSpeculationGuard(body, provesDouble, ins.Dst); + return true; + } + if (!TrySymbolStringLiteral(state, exe.GetSymbol(ins.Aux), out var name)) + { + return false; + } + // Direct ivar ops are always on self (cross-object goes via accessor sends), so the + // receiver is unconditionally an RObject — cast it once and reuse via the cache. + var ivKey = (ins.Src0, exe.GetSymbol(ins.Aux)); + if (ivarGetCache is { } ivc && ivc.TryGetValue(ivKey, out var ivCached)) + { + // Re-read of the same field on the same receiver -> reuse the cached value. + Line(body, $"{Val(ins.Dst)} = {ivCached};"); + } + else if (reusedIvarReads is { } rir && rir.Contains(ins.Dst)) + { + // First of several reads: stash into a dedicated temp the later reads can reuse. + var t = "__iv" + ivCounter++; + Line(body, $"global::ChibiRuby.MRubyValue {t} = {IvarGet(AsRObject(ins.Src0, Val(ins.Src0), body), sym.Reference(name))};"); + Line(body, $"{Val(ins.Dst)} = {t};"); + ivarGetCache![ivKey] = t; + } + else + { + // One-shot read: emit inline, no temp. + Line(body, $"{Val(ins.Dst)} = {IvarGet(AsRObject(ins.Src0, Val(ins.Src0), body), sym.Reference(name))};"); + } + EmitFloatSpeculationGuard(body, provesDouble, ins.Dst); + return true; + } + + case RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField: + { + if (sc is not null && TryEmitScalarFieldAccess(sc, exe, ins, body)) + { + return true; + } + // Self is a stack struct (a by-ref struct-receiver variant): write the field + // directly. Only reachable for a `ref` (Mutated) layout; read-only `in` self never + // hits a SetInstanceVariable because CalleeSelfReadOnly rejects mutating callees. + if (CurrentStackObjects is { } scso && scso.TryGetValue(ins.Src0, out var slay) && + slay.FieldIndexOf(exe.GetSymbol(ins.Aux)) is var sfi && sfi >= 0) + { + var sso = Val(ins.Src0); + var sval = slay.FieldKinds[sfi] switch + { + StackFieldKind.Double => Val(ins.Src1) + ".FloatValue", + StackFieldKind.Long => Val(ins.Src1) + ".IntegerValue", + _ => Val(ins.Src1), + }; + Line(body, $"{sso}.f{sfi} = {sval};"); + return true; + } + if (!TrySymbolStringLiteral(state, exe.GetSymbol(ins.Aux), out var name)) + { + return false; + } + Line(body, $"{IvarSet(AsRObject(ins.Src0, Val(ins.Src0), body), sym.Reference(name), Val(ins.Src1))};"); + return true; + } + + // Generic (untyped) arith. The AOT lowering path never produces the typed + // Float opcodes, so int and float both arrive here; emit a runtime dual path + // (both-fixnum -> long, both-float -> double, else deopt -> interpreter handles + // mixed/coercion). Long-dst values are float-untainted by construction, so their + // path stays fixnum-only. + case RubyIROpCode.Add or RubyIROpCode.AddFixnum: + return EmitNumericBinary(sym, body, ins, "+", isLong, floatTaint, isDouble, provesDouble); + case RubyIROpCode.Sub or RubyIROpCode.SubFixnum: + return EmitNumericBinary(sym, body, ins, "-", isLong, floatTaint, isDouble, provesDouble); + + // `reg +/- ` (AddI/SubI). Immediate is a fixnum in the + // literal pool; guard the receiver is fixnum and fold the constant in. + case RubyIROpCode.AddImmediate or RubyIROpCode.AddImmediateFixnum: + return EmitFixnumImmediate(sym, body, exe, ins, "+", isLong); + case RubyIROpCode.SubImmediate or RubyIROpCode.SubImmediateFixnum: + return EmitFixnumImmediate(sym, body, exe, ins, "-", isLong); + case RubyIROpCode.Mul or RubyIROpCode.MulFixnum: + return EmitNumericBinary(sym, body, ins, "*", isLong, floatTaint, isDouble, provesDouble); + case RubyIROpCode.Div or RubyIROpCode.DivFixnum: + // Fixnum division matches the interpreter (C# truncating /); a zero divisor + // takes the slow path (interpreter raises ZeroDivisionError). Float division + // follows IEEE (x/0.0 -> Infinity, as Ruby Float#/), no zero guard. + return EmitNumericBinary(sym, body, ins, "/", isLong, floatTaint, isDouble, provesDouble, isDiv: true); + + // Fused multiply-add/sub from the arithmetic-fusion pass (generic int or float). + // Operands: src0*src1 combined with src2; dual fixnum/float path over all three. + case RubyIROpCode.MulAdd: + return EmitNumericFused(sym, body, ins, isLong, floatTaint, isDouble, provesDouble, "+", false); + case RubyIROpCode.MulSub: + return EmitNumericFused(sym, body, ins, isLong, floatTaint, isDouble, provesDouble, "-", false); + case RubyIROpCode.SubMul: + return EmitNumericFused(sym, body, ins, isLong, floatTaint, isDouble, provesDouble, "-", true); + + case RubyIROpCode.Lt or RubyIROpCode.LtFixnum: + return EmitNumericCompare(sym, body, ins, "<", isLong, floatTaint, isDouble, provesDouble); + case RubyIROpCode.Le or RubyIROpCode.LeFixnum: + return EmitNumericCompare(sym, body, ins, "<=", isLong, floatTaint, isDouble, provesDouble); + case RubyIROpCode.Gt or RubyIROpCode.GtFixnum: + return EmitNumericCompare(sym, body, ins, ">", isLong, floatTaint, isDouble, provesDouble); + case RubyIROpCode.Ge or RubyIROpCode.GeFixnum: + return EmitNumericCompare(sym, body, ins, ">=", isLong, floatTaint, isDouble, provesDouble); + case RubyIROpCode.Eq: + return EmitNumericCompare(sym, body, ins, "==", isLong, floatTaint, isDouble, provesDouble); + + // Nested method call -> public state.Send (full dispatch; if the callee is + // also AOT-compiled it uses its compiled body too). No-block sends only; + // explicit Send overloads cover 0..4 args. + case RubyIROpCode.Send or RubyIROpCode.SendSelf: + { + // In a stack-struct VARIANT: a trivial-accessor getter on the struct param lowers + // to a struct-field read (no devirt, no boxing for typed fields). + if (ins.OpCode == RubyIROpCode.Send && CurrentStackObjects is { Count: > 0 } && + TryEmitStackAccessor(state, exe, ins, sym, body)) + { + return true; + } + // Caller side: a non-accessor Send whose RECEIVER is a stack object -> call the + // callee's struct-`self` variant (class statically known), reify on miss/deopt. + if (CurrentStackObjects is { Count: > 0 } && TryEmitStackReceiverSend(ic, ins, body)) + { + return true; + } + // Caller side: a Send whose argument is a stack object -> dispatch to the callee's + // specialized struct variant (guarded per receiver class), reify on miss/deopt. + if (CurrentStackObjects is { Count: > 0 } && TryEmitStackArgSend(ic, ins, body)) + { + return true; + } + // Trivial accessor on a scalar-replaced object -> direct field-local access. + if (sc is not null && TryEmitAccessorSend(sc, exe, ins, body)) + { + return true; + } + var methodSym = exe.GetCallSiteSymbol(ins.Aux); + // Context-switching sends (Fiber.yield/resume/transfer, sleep, Thread.pass) + // cannot run from a compiled C# frame — the fiber can't be suspended/ + // resumed mid-C#-method ("resuming dead fiber"). Bail so they interpret. + if (IsContextSwitchingMethod(state, methodSym)) + { + LastBail = "send:ctxswitch"; + return false; + } + if (!TrySymbolStringLiteral(state, methodSym, out var mname)) + { + LastBail = "send:sym"; + return false; + } + var argc = exe.GetCallSiteArgumentCount(ins.Aux); + if (argc > 4) + { + LastBail = "send:argc>4"; + return false; + } + // Monomorphic self-send -> guarded inline call to the callee's __inline form. + if (ins.OpCode == RubyIROpCode.SendSelf && ic is not null && + TryInlineSelfSend(ic, ins, methodSym, mname, argc, body)) + { + return true; + } + // Cross-object 0-arg send to a constant-returning method -> guarded constant. + if (ic is not null && TryEmitConstantDevirt(ic, ins, methodSym, mname, argc, body)) + { + return true; + } + // Cross-object accessor (recv.getter / recv.setter=) -> guarded field access. + if (ic is not null && TryEmitAccessorDevirt(ic, ins, methodSym, mname, argc, body)) + { + // A speculated float accessor read (provesDouble) gets its IsFloat guard after + // both the devirt-hit and Send-fallback branches have written the result. + EmitFloatSpeculationGuard(body, provesDouble, ins.Dst); + return true; + } + // `to_f` is hot in numeric loops (e.g. ao-bench render). Inline the common + // immediate Integer/Float cases and fall back for String/custom receivers. + if (argc == 0 && + Environment.GetEnvironmentVariable("AOT_NOTOF") != "1" && + IsToFMethod(state, methodSym)) + { + var r = Val(ins.Src0); + var d = Val(ins.Dst); + Line(body, $"if ({r}.IsFixnum) {{ {d} = new global::ChibiRuby.MRubyValue((double){r}.FixnumValue); }} else if ({r}.IsFloat) {{ {d} = {r}; }} else {{ {d} = state.Send({r}, {sym.Reference(mname)}); }}"); + return true; + } + // One-argument pure C# methods avoid building a Ruby call frame. Guard the + // argument to numeric immediates so non-numeric error paths keep Send's frame. + if (argc == 1 && + Environment.GetEnvironmentVariable("AOT_NOPUREUNARY") != "1" && + IsFloatReturningMethod(state, methodSym) && + ic is not null) + { + return TryEmitPureUnarySend(ic, ins, mname, body); + } + // Fixnum bitwise-operator sends (&, |, ^, >>) have no mruby opcode, so they + // arrive as a full-dispatch Send — hot in integer code (NES masking/shifts). + // Inline the fixnum case (guarded) with a Send fallback. &|^ can't overflow; + // >> is guarded to a 0..63 shift (C# long >> is arithmetic, matching Ruby). + // << / % stay as Send (overflow / floor-semantics differ from C#). + if (argc == 1 && TryFixnumBitwiseOp(state, methodSym, out var binOp, out var isShift)) + { + var recvId = ins.Src0; + var argId = exe.GetCallSiteArgumentValueId(ins.Aux, 0); + // Drop the IsFixnum guard / use the C# literal for a constant operand (e.g. + // `@x & 0xFFFFF` -> `v1.FixnumValue & 1048575L`, only v1 guarded). + var condParts = new List(); + if (FixGuard(isLong, recvId) is { } gr) condParts.Add(gr); + if (FixGuard(isLong, argId) is { } gar) condParts.Add(gar); + if (isShift) condParts.Add($"(ulong){FixRead(isLong, argId)} <= 63UL"); + var expr = isShift + ? $"{FixRead(isLong, recvId)} {binOp} (int){FixRead(isLong, argId)}" + : $"{FixRead(isLong, recvId)} {binOp} {FixRead(isLong, argId)}"; + // & | ^ >> of fixnums always yield a fixnum, so a proven-fixnum dst lives in a raw + // long local (the else-Send is dead when operands are proven, but stays correct via + // .FixnumValue since the result is always a fixnum). Otherwise the dst stays boxed. + // The Send fallback needs boxed operands; BoxRead re-boxes a raw `long` operand. + var slowSend = $"state.Send({BoxRead(isLong, recvId)}, {sym.Reference(mname)}, {BoxRead(isLong, argId)})"; + if (isLong[ins.Dst]) + { + Line(body, condParts.Count == 0 + ? $"l{Slot(ins.Dst)} = {expr};" + : $"if ({Cond(condParts)}) {{ l{Slot(ins.Dst)} = {expr}; }} else {{ l{Slot(ins.Dst)} = {slowSend}.FixnumValue; }}"); + return true; + } + var d = Val(ins.Dst); + Line(body, $"if ({Cond(condParts)}) {{ {d} = new global::ChibiRuby.MRubyValue({expr}); }} else {{ {d} = {slowSend}; }}"); + return true; + } + var call = new StringBuilder(); + call.Append(Val(ins.Dst)).Append(" = state.Send(").Append(Val(ins.Src0)) + .Append(", ").Append(sym.Reference(mname)); + for (var i = 0; i < argc; i++) + { + call.Append(", ").Append(Val(exe.GetCallSiteArgumentValueId(ins.Aux, i))); + } + call.Append(");"); + Line(body, call.ToString()); + return true; + } + + // Indexed access. GetIndexUnsafe/SetIndexUnsafe inline the interpreter's + // Array-fast-path (RArray + fixnum -> direct element) and fall back to + // :[] / :[]= for everything else, so hot array code avoids full dispatch. + case RubyIROpCode.GetIndex: + if (TryScalarArray(ins.Src0, out var gCanon, out _) && ConstFix(ins.Src1, out var gidx)) + { + Line(body, $"{Val(ins.Dst)} = {ArrElem(gCanon, (int)gidx)};"); + return true; + } + if (TryScalarHash(ins.Src0, out var ghCanon, out var ghKeys) && KeyTag(ins.Src1) is { } ghTag) + { + Line(body, $"{Val(ins.Dst)} = {(ghKeys.Contains(ghTag) ? HashElem(ghCanon, ghTag) : "global::ChibiRuby.MRubyValue.Nil")};"); + return true; + } + Line(body, $"{Val(ins.Dst)} = GetIndexUnsafe(state, {Val(ins.Src0)}, {Val(ins.Src1)});"); + return true; + + case RubyIROpCode.GetIndex0: + if (TryScalarArray(ins.Src0, out var g0Canon, out _)) + { + Line(body, $"{Val(ins.Dst)} = {ArrElem(g0Canon, 0)};"); + return true; + } + if (TryScalarHash(ins.Src0, out var gh0Canon, out var gh0Keys)) + { + Line(body, $"{Val(ins.Dst)} = {(gh0Keys.Contains("i0") ? HashElem(gh0Canon, "i0") : "global::ChibiRuby.MRubyValue.Nil")};"); + return true; + } + Line(body, $"{Val(ins.Dst)} = GetIndexZeroUnsafe(state, {Val(ins.Src0)});"); + return true; + + // SetIdx writes the assigned value back to the receiver register (= Dst). + case RubyIROpCode.SetIndex: + if (TryScalarArray(ins.Src0, out var sCanon, out _) && ConstFix(ins.Src1, out var sidx)) + { + // a[i] = x -> element local = x; (the op also yields x) + Line(body, $"{ArrElem(sCanon, (int)sidx)} = {Val(ins.Src2)};"); + if (Val(ins.Dst) != Val(ins.Src2)) Line(body, $"{Val(ins.Dst)} = {Val(ins.Src2)};"); + return true; + } + if (TryScalarHash(ins.Src0, out var shCanon, out _) && KeyTag(ins.Src1) is { } shTag) + { + Line(body, $"{HashElem(shCanon, shTag)} = {Val(ins.Src2)};"); + if (Val(ins.Dst) != Val(ins.Src2)) Line(body, $"{Val(ins.Dst)} = {Val(ins.Src2)};"); + return true; + } + Line(body, $"{Val(ins.Dst)} = SetIndexUnsafe(state, {Val(ins.Src0)}, {Val(ins.Src1)}, {Val(ins.Src2)});"); + return true; + + // Array literal [a, b, c]. Elements are value ids in the operand list at Aux; + // state.NewArray(ReadOnlySpan) is public and its RArray converts to MRubyValue. + case RubyIROpCode.NewArray: + { + var n = exe.GetOperandListCount(ins.Aux); + // Scalar-replaced literal: initialize the per-element locals, no RArray allocation. + if (TryScalarArray(ins.Dst, out var nCanon, out _)) + { + for (var i = 0; i < n; i++) + Line(body, $"{ArrElem(nCanon, i)} = {Val(exe.GetOperandListValueId(ins.Aux, i))};"); + return true; + } + var call = new StringBuilder(); + call.Append(Val(ins.Dst)).Append(" = state.NewArray("); + if (n == 0) + { + call.Append("global::System.ReadOnlySpan.Empty"); + } + else + { + // MRubyValue is a managed struct, so it can't be stackalloc'd; a heap + // temp is fine (NewArray copies the elements into the RArray anyway). + call.Append("new global::ChibiRuby.MRubyValue[] { "); + for (var i = 0; i < n; i++) + { + if (i > 0) call.Append(", "); + call.Append(Val(exe.GetOperandListValueId(ins.Aux, i))); + } + call.Append(" }"); + } + call.Append(");"); + Line(body, call.ToString()); + return true; + } + + // Hash literal {k0 => v0, ...} / scalar-replaced (constant-key) form. + case RubyIROpCode.NewHash: + { + var nh = exe.GetOperandListCount(ins.Aux); // 2 * pairs + var pairs = nh / 2; + if (TryScalarHash(ins.Dst, out var hCanon, out _)) + { + for (var p = 0; p < pairs; p++) + Line(body, $"{HashElem(hCanon, KeyTag(exe.GetOperandListValueId(ins.Aux, 2 * p))!)} = {Val(exe.GetOperandListValueId(ins.Aux, 2 * p + 1))};"); + return true; + } + var hb = new StringBuilder(); + hb.Append("{ var _h = state.NewHash(").Append(pairs).Append(");"); + for (var p = 0; p < pairs; p++) + hb.Append(" _h.Add(").Append(Val(exe.GetOperandListValueId(ins.Aux, 2 * p))) + .Append(", ").Append(Val(exe.GetOperandListValueId(ins.Aux, 2 * p + 1))).Append(");"); + hb.Append(' ').Append(Val(ins.Dst)).Append(" = new global::ChibiRuby.MRubyValue(_h); }"); + Line(body, hb.ToString()); + return true; + } + + case RubyIROpCode.Jump: + Line(body, $"goto L{ins.Aux};"); + return true; + case RubyIROpCode.JumpIfTruthy: + Line(body, $"if ({Val(ins.Src0)}.Truthy) goto L{ins.Aux};"); + return true; + case RubyIROpCode.JumpIfFalsy: + Line(body, $"if ({Val(ins.Src0)}.Falsy) goto L{ins.Aux};"); + return true; + case RubyIROpCode.JumpIfNil: + Line(body, $"if ({Val(ins.Src0)}.IsNil) goto L{ins.Aux};"); + return true; + + case RubyIROpCode.Return or RubyIROpCode.ReturnValue: + Line(body, $"result = {BoxReadFull(isLong, isDouble, ins.Src0)}; return true;"); + return true; + case RubyIROpCode.ReturnSelf: + Line(body, $"result = {Val(0)}; return true;"); + return true; + + // Closure variable access, only valid while emitting an inlined block body where + // the captured registers are ref-param cells. aux packs (register << 8) | depth. + case RubyIROpCode.GetUpVar: + { + if (!TryResolveUpvarCell(ins.Aux, out var cell)) { LastBail = "upvar:unresolved"; return false; } + Line(body, $"{Val(ins.Dst)} = {cell};"); + return true; + } + case RubyIROpCode.SetUpVar: + { + if (!TryResolveUpvarCell(ins.Aux, out var cell)) { LastBail = "upvar:unresolved"; return false; } + Line(body, $"{cell} = {Val(ins.Src0)};"); + return true; + } + + // `count.times do |i| ... end` -> a C# for loop calling the block body's __blk + // method, with the block's captured method locals passed by ref. + case RubyIROpCode.SendBlockDescriptor or RubyIROpCode.SendSelfBlockDescriptor: + return TryEmitTimesLoop(state, exe, ins, sym, body); + + default: + return false; + } + } + + // Resolve an upvar (register = aux >> 8, lv = aux & 0xff) to its ref-param cell. In a block + // at level L, lv references the scope L-lv-1; the cell exists iff this block received it. + static bool TryResolveUpvarCell(int aux, out string cell) + { + cell = ""; + if (BlockEmit is not { Cells: { } cells } || BlockEmit.CurrentLevel == 0) return false; + var register = aux >> 8; + var scope = BlockEmit.CurrentLevel - (aux & 0xff) - 1; + if (scope < 0 || !cells.Contains((scope, register))) return false; + cell = CellName(scope, register); + return true; + } + + static string CellName(int scope, int register) => $"cell_{scope}_{register}"; + + static bool IsTimesSelector(MRubyState state, Symbol sym) => + state.NameOf(sym).AsSpan().SequenceEqual("times"u8); + + // `count.times do |i| ... end` -> a C# for loop. The block body becomes a separate __blk + // method (so its value-ids never collide with the caller's). Variables it (or a nested + // block) reads/writes from an enclosing scope are passed by ref as `cell__`. + // The receiver is guarded fixnum (Integer#times); the loop returns the receiver. Nesting is + // recursive: the child __blk's body re-enters this for its own nested times. + static bool TryEmitTimesLoop(MRubyState state, RubyIRMethod exe, in RubyIRInstruction ins, SymbolCache sym, StringBuilder body) + { + if (BlockEmit is null) { LastBail = "block:noctx"; return false; } + if (!IsTimesSelector(state, exe.GetCallSiteSymbol(ins.Aux))) { LastBail = "block:notTimes"; return false; } + if (exe.GetCallSiteArgumentCount(ins.Aux) != 0) { LastBail = "block:iterArgs"; return false; } + + var level = BlockEmit.CurrentLevel; // scope emitting the times + var childLevel = level + 1; + var child = exe.GetChildIrep(ins.Src1); + if (!TryReadMandatoryArgCount(child, out var blockArgc) || blockArgc > 1) { LastBail = "block:argc"; return false; } + + // Coordinates the child (and its descendants) reference above the child's own scope. + var needed = CollectBlockCells(state, child, childLevel); + if (needed is null) return false; // LastBail set inside + + var n = BlockEmit.BlockCounter++; + var blockName = BlockEmit.OwnerName + "__blk" + n; + var src = TryGenerateBlockBody(state, child, blockName, needed, blockArgc, childLevel); + if (src is null) return false; // LastBail set inside + BlockEmit.AuxMethods.Add(src); + + var recv = ins.OpCode == RubyIROpCode.SendSelfBlockDescriptor ? Val(0) : Val(ins.Src0); + var idx = "_bi" + n; + // Pass each needed coordinate by ref: a coordinate in the CURRENT scope is one of this + // body's own locals (v); a coordinate above it is one of the cells THIS body + // itself received (cell__). + var refs = new StringBuilder(); + foreach (var (scope, register) in needed) + { + refs.Append(", ref ").Append(scope == level ? Val(register) : CellName(scope, register)); + } + var argPass = blockArgc >= 1 ? $", new global::ChibiRuby.MRubyValue({idx})" : ""; + // The receiver-fixnum guard runs before the loop (no iteration has committed a side + // effect yet), so deopting here is safe. A block body itself never deopts under + // ForceSend except via a nested receiver guard, which is likewise pre-loop; propagate + // it up so a (never-in-practice, Integer#times) miss can't silently skip iterations. + Line(body, $"if (!{recv}.IsFixnum) {{ result = default; return false; }}"); + Line(body, $"for (long {idx} = 0; {idx} < {recv}.FixnumValue; {idx}++) {{ if (!{blockName}(state, {Val(0)}{argPass}{refs}, out var _bt{n})) {{ result = default; return false; }} }}"); + Line(body, $"{Val(ins.Dst)} = {recv};"); + return true; + } + + // Collect the absolute coordinates (scope, register) that a block at `blockLevel` (and its + // nested blocks) reads/writes from a scope ABOVE its own — these become the block's ref-param + // cells. Returns null (and sets LastBail) if anything uninlinable appears (a non-times block, + // a block passed as a proc, an unlowerable body). + static SortedSet<(int Scope, int Register)>? CollectBlockCells(MRubyState state, Irep blockIrep, int blockLevel) + { + var result = new SortedSet<(int, int)>(); + return Walk(blockIrep, blockLevel) ? result : null; + + bool Walk(Irep irep, int level) + { + RubyIRMethod? exe; + try { exe = RubyIRBuilder.Build(irep, 0, out _); } + catch { exe = null; } + if (exe is null) { LastBail = "block:childLower"; return false; } + foreach (var bi in exe.Instructions) + { + switch (bi.OpCode) + { + case RubyIROpCode.GetUpVar or RubyIROpCode.SetUpVar: + var scope = level - (bi.Aux & 0xff) - 1; + if (scope < 0) { LastBail = "block:badUpvar"; return false; } + if (scope < blockLevel) result.Add((scope, bi.Aux >> 8)); + break; + case RubyIROpCode.SendBlockDescriptor or RubyIROpCode.SendSelfBlockDescriptor: + if (!IsTimesSelector(state, exe.GetCallSiteSymbol(bi.Aux)) || + exe.GetCallSiteArgumentCount(bi.Aux) != 0) + { + LastBail = "block:nestedNonTimes"; + return false; + } + if (!Walk(exe.GetChildIrep(bi.Src1), level + 1)) return false; + break; + case RubyIROpCode.LoadBlock or RubyIROpCode.SendBlock or RubyIROpCode.SendSelfBlock: + LastBail = "block:escapingBlock"; + return false; + } + } + return true; + } + } + + // Emit the block body as a standalone method: (state, self, [block param], ref cell_s_r..., + // out result). Runs with ForceSend (no deopt — the body executes in a loop and must never + // re-execute a partial iteration) and no unboxing/scalar replacement. Recurses for nested + // times. Returns the full source (static sym fields + the method) or null if uncompilable. + static string? TryGenerateBlockBody(MRubyState state, Irep blockIrep, string blockName, SortedSet<(int Scope, int Register)> cells, int blockArgc, int level) + { + RubyIRMethod? exe; + try { exe = RubyIRBuilder.Build(blockIrep, 0, out _); } + catch { exe = null; } + if (exe is null) { LastBail = "block:childLower"; return null; } + + // Stack-allocate non-escaping objects constructed in THIS block body (ao's loop-local Ray + // lives here, not in the enclosing method). SSA-renumber first so each value-id is single- + // def (a clean struct-local mapping); Run keeps params (v0..vBlockArgc) and captured cell + // ids stable, preserving the block param/cell-ref convention. CurrentStackObjects is thread- + // static and shared with the enclosing method's emission (which continues after this block + // returns), and block value-ids are a different numbering space than the parent's, so it is + // saved here and restored before every return — never inherited into the block. + var savedStackObjects = CurrentStackObjects; + var savedCanonical = StructCanonical; + var savedConstLit = CurrentConstLit; + // Array scalar replacement is not wired into the block-body declaration path yet; disable it + // here (the parent's map must not leak into the block's distinct value-id space). + var savedScalarArrays = CurrentScalarArrays; + var savedScalarHashes = CurrentScalarHashes; + var savedHashKeyTags = CurrentHashKeyTags; + var savedBlkProvesFixnum = CurrentProvesFixnum; + var savedBlkSound = CurrentSoundProven; + var savedBlkSlot = CurrentLocalSlot; + CurrentScalarArrays = null; + CurrentScalarHashes = null; + CurrentLocalSlot = null; // no coalescing in block bodies (parent's slot map must not leak) + if (StackObjEnabled && SsaEnabled && CurrentEscapeSummary is { } blockEsc) + { + exe = RubyIRSsaRenumber.Run(exe, blockArgc); + CurrentStackObjects = FindStackEligible(state, exe, blockEsc); + if (Environment.GetEnvironmentVariable("AOT_ESCAPE_DEBUG") == "1" && CurrentStackObjects is { Count: > 0 } dbg) + foreach (var (objId, lay) in dbg) + System.Console.Error.WriteLine($"[stackobj] {blockName} v{objId} = new {state.NameOf(lay.ConstName)} -> {lay.StructType}"); + } + else + { + CurrentStackObjects = null; + } + RebuildStructCanonical(); + CurrentConstLit = BuildConstLit(exe); // for the (possibly SSA-renumbered) block exe + + var instructions = exe.Instructions; + var targets = new HashSet(); + foreach (var ins in instructions) + { + if (ins.OpCode is RubyIROpCode.Jump or RubyIROpCode.JumpIfTruthy + or RubyIROpCode.JumpIfFalsy or RubyIROpCode.JumpIfNil) + { + targets.Add(ins.Aux); + } + } + + // Sound, deopt-free unboxing for the block body. It is forward-only and runs under ForceSend + // (numeric slow paths Send, never deopt); no speculation and no arg guards (a block deopt + // re-runs the parent). Math-returning sends + float literals become raw double — this is + // where ao's ambient_occlusion trig chain unboxes. Captured cells stay boxed (ref params). + ComputeLoopUnboxing(state, exe, blockArgc, null, + out var provesDouble, out var provesFixnum, out var floatTaint, out var soundProvenBlk, out _, speculateArgs: false); + CurrentProvesFixnum = provesFixnum; + CurrentSoundProven = soundProvenBlk; + ComputeLoopRawLocals(state, exe, blockArgc, provesDouble, provesFixnum, out var isLong, out var isDouble); + var bsym = new SymbolCache(blockName); + + var savedCells = BlockEmit!.Cells; + var savedLevel = BlockEmit.CurrentLevel; + var savedForce = BlockEmit.ForceSend; + BlockEmit.Cells = new HashSet<(int, int)>(cells); + BlockEmit.CurrentLevel = level; + BlockEmit.ForceSend = true; + // Block bodies never self-inline other Ruby methods (fiber-unsafe), but they can still + // use guarded accessor devirt and pure-unary C# method caches. + var bic = new InlineContext(state, null, null, BlockEmit.AccessorRegistry, CurrentConstReturns, blockName, bsym, exe); + // The cast cache is per C# method; the block body is a separate method, so save the + // parent's and give the block a fresh one (restored after). + var savedAsObj = AsObjCache; + var savedAsCounter = AsObjCounter; + AsObjCache = new Dictionary(); + AsObjCounter = 0; + // Same for the ivar-read CSE cache: the block is a separate C# method. + var savedIvarCache = ivarGetCache; + var savedIvCounter = ivCounter; + var savedReused = reusedIvarReads; + ivarGetCache = new Dictionary<(int, Symbol), string>(); + ivCounter = 0; + reusedIvarReads = ComputeReusedIvarReads(exe, targets); + var body = new StringBuilder(); + var ok = true; + for (var i = 0; i < instructions.Length; i++) + { + if (targets.Contains(i)) { body.Append(" L").Append(i).Append(": ;\n"); ClearAsObjCache(); ClearIvarGetCache(); } + if (!EmitInstruction(state, exe, instructions[i], bsym, body, ic: bic, sc: null, isLong, floatTaint, isDouble, provesDouble)) + { + LastBail ??= "block-op:" + instructions[i].OpCode; + ok = false; + break; + } + InvalidateAsObj(instructions[i].Dst); + InvalidateIvarGet(instructions[i]); + } + AsObjCache = savedAsObj; + AsObjCounter = savedAsCounter; + ivarGetCache = savedIvarCache; + ivCounter = savedIvCounter; + reusedIvarReads = savedReused; + BlockEmit.Cells = savedCells; + BlockEmit.CurrentLevel = savedLevel; + BlockEmit.ForceSend = savedForce; + if (!ok) { CurrentStackObjects = savedStackObjects; StructCanonical = savedCanonical; CurrentConstLit = savedConstLit; CurrentScalarArrays = savedScalarArrays; CurrentScalarHashes = savedScalarHashes; CurrentHashKeyTags = savedHashKeyTags; CurrentProvesFixnum = savedBlkProvesFixnum; CurrentSoundProven = savedBlkSound; CurrentLocalSlot = savedBlkSlot; return null; } + + var sb = new StringBuilder(); + EmitSymbolFields(bsym, sb); + EmitInlineFields(bic, sb); + sb.Append("public static bool ").Append(blockName).Append("(global::ChibiRuby.MRubyState state, global::ChibiRuby.MRubyValue ").Append(Val(0)); + if (blockArgc >= 1) sb.Append(", global::ChibiRuby.MRubyValue ").Append(Val(1)); + foreach (var (scope, register) in cells) sb.Append(", ref global::ChibiRuby.MRubyValue ").Append(CellName(scope, register)); + sb.Append(", out global::ChibiRuby.MRubyValue result)\n{\n"); + + // Locals for the block's own non-param value-ids (self is v0, params are v1..vBlockArgc). + // Captured variables are separate cell_s_r ref params, not value-ids. Stack objects are + // struct locals (declared separately below). Proven-numeric temps are raw long/double. + var blkBoxed = new List(); + var blkLong = new List(); + var blkDouble = new List(); + for (var v = blockArgc + 1; v < exe.ValueCount; v++) + { + if (CurrentStackObjects is { } cso0 && cso0.ContainsKey(v)) continue; + (isDouble[v] ? blkDouble : isLong[v] ? blkLong : blkBoxed).Add(v); + } + if (blkBoxed.Count > 0) + { + sb.Append(" global::ChibiRuby.MRubyValue "); + for (var i = 0; i < blkBoxed.Count; i++) { if (i > 0) sb.Append(", "); sb.Append(Val(blkBoxed[i])).Append(" = default"); } + sb.Append(";\n"); + } + if (blkLong.Count > 0) + { + sb.Append(" long "); + for (var i = 0; i < blkLong.Count; i++) { if (i > 0) sb.Append(", "); sb.Append('l').Append(blkLong[i]).Append(" = 0"); } + sb.Append(";\n"); + } + if (blkDouble.Count > 0) + { + sb.Append(" double "); + for (var i = 0; i < blkDouble.Count; i++) { if (i > 0) sb.Append(", "); sb.Append('d').Append(blkDouble[i]).Append(" = 0"); } + sb.Append(";\n"); + } + if (CurrentStackObjects is { } cso1) + { + var declaredStructs = new HashSet(); // canonical mutated aliases share one local + foreach (var (v, lay) in cso1) + if (v > blockArgc && declaredStructs.Add(Val(v))) + sb.Append(" ").Append(lay.StructType).Append(' ').Append(Val(v)).Append(" = default;\n"); + } + + EmitSymbolInit(bsym, sb); + sb.Append(body); + if (targets.Contains(instructions.Length)) sb.Append(" L").Append(instructions.Length).Append(": ;\n"); + sb.Append(" result = default; return false;\n}\n"); + CurrentStackObjects = savedStackObjects; + StructCanonical = savedCanonical; + CurrentConstLit = savedConstLit; + CurrentScalarArrays = savedScalarArrays; + CurrentScalarHashes = savedScalarHashes; + CurrentHashKeyTags = savedHashKeyTags; + CurrentProvesFixnum = savedBlkProvesFixnum; + CurrentSoundProven = savedBlkSound; + CurrentLocalSlot = savedBlkSlot; + return sb.ToString(); + } + + // Read value-id `id` as a double in a float context: the constant literal if known, else the + // boxed value's .FloatValue. (A Long-unboxed value never reaches a float path.) + static string FloatRead(int id) => + ConstFloat(id, out var cv) ? DoubleLitText(cv) : "v" + Slot(id) + ".FloatValue"; + + // Guard asserting `id` is a float, or null for a known float constant (a non-float constant + // keeps `v.IsFloat`, which folds to false, so the float branch stays correctly unreachable). + static string? FloatGuard(int id) => ConstFloat(id, out _) ? null : "v" + Slot(id) + ".IsFloat"; + static string FloatCond(params int[] ids) + { + var parts = new List(); + foreach (var id in ids) if (FloatGuard(id) is { } g) parts.Add(g); + return Cond(parts); + } + + // C# text for a double constant: a plain `ldc.r8` literal via round-trippable formatting, or the + // bit-exact reconstruction for NaN/Inf/non-roundtrippable values. (Shared with TryEmitLiteral.) + internal static string DoubleLitText(double d) + { + if (double.IsFinite(d)) + { + var s = d.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + if (double.TryParse(s, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var rt) && + BitConverter.DoubleToInt64Bits(rt) == BitConverter.DoubleToInt64Bits(d)) + { + return s + "D"; + } + } + var bits = BitConverter.DoubleToInt64Bits(d); + return $"global::System.BitConverter.Int64BitsToDouble(unchecked((long)0x{(ulong)bits:x16}UL))"; + } + + // Read value-id `id` as a double in a PURE-double op (all operands provably Float). A raw + // `double` local reads directly; an unboxed long is a proven fixnum promoted to double; any + // other (provably-float but boxed, e.g. a literal/ivar seed) reads .FloatValue without a guard. + static string DoubleRead(bool[] isLong, bool[] isDouble, int id) => + isDouble[id] ? "d" + Slot(id) : isLong[id] ? "(double)l" + Slot(id) : "v" + Slot(id) + ".FloatValue"; + + // Read value-id `id` as a boxed MRubyValue, re-boxing any unboxed (long/double) kind — for the + // return boundary, where a value of any representation crosses back into the boxed world. + static string BoxReadFull(bool[] isLong, bool[] isDouble, int id) => + isDouble[id] ? Box("d" + Slot(id)) : isLong[id] ? Box("l" + Slot(id)) : Val(id); + + // Q1.2: after a speculated float ivar/accessor read, assert it really is a Float; a miss deopts + // (safe — only seeded in side-effect-free methods, so re-running in the interpreter is harmless). + static void EmitFloatSpeculationGuard(StringBuilder body, bool[] provesDouble, int dst) + { + // Sound proofs (a proven-Float ivar read / send / literal) need no guard — only an actual + // SPECULATION (the pre-side-effect-window guess) does. + var sound = CurrentSoundProven; + if ((uint)dst < (uint)provesDouble.Length && provesDouble[dst] && + !(sound is not null && (uint)dst < (uint)sound.Length && sound[dst])) + { + Line(body, $"if (!{Val(dst)}.IsFloat) {{ result = default; return false; }}"); + } + } + + static bool ProvesFixnum(int id) => + CurrentProvesFixnum is { } pf && (uint)id < (uint)pf.Length && pf[id]; + + // Read id as a `double` in a guard-free mixed-numeric float expression: a proven Float via + // .FloatValue (or its float constant / raw d-local / (double)l-local), or a proven Fixnum via + // (double)v.FixnumValue (or (double) of its fixnum constant / l-local). + static string DoubleReadNumeric(bool[] isLong, bool[] isDouble, bool[] provesDouble, int id) + { + if (provesDouble[id]) return DoubleRead(isLong, isDouble, id); + if (ConstFix(id, out var cv)) return "(double)" + cv + "L"; + if (isLong[id]) return "(double)l" + Slot(id); + return "(double)v" + Slot(id) + ".FixnumValue"; + } + + // True iff every operand is provably numeric (Float or Fixnum, in any representation) and at + // least one is provably Float — so `a OP b` is guard-free `double` arith under Ruby coercion. + static bool MixedNumericFloat(bool[] isLong, bool[] provesDouble, params int[] ids) + { + var anyFloat = false; + foreach (var id in ids) + { + var isFloat = provesDouble[id] || ConstFloat(id, out _); + var isFix = ProvesFixnum(id) || isLong[id] || ConstFix(id, out _); + if (!isFloat && !isFix) return false; + anyFloat |= isFloat; + } + return anyFloat; + } + + static string True => "global::ChibiRuby.MRubyValue.True"; + static string False => "global::ChibiRuby.MRubyValue.False"; + static string Box(string expr) => $"new global::ChibiRuby.MRubyValue({expr})"; + + // Direct internal-runtime expressions the generated code emits (it has InternalsVisibleTo). + // Emit calls to the AotGeneratedMethods base-class helpers (inherited by the generated class), + // not raw internal access — so RObject.InstanceVariables can stay internal. The helpers are + // aggressively inlined, so this is free at runtime. + // recv must be an RObject-typed expression (a cached __ro local, a fresh `new RObject`, or + // `x.As()`). InstanceVariables is public, so emit the table access directly. + internal static string IvarGet(string recv, string symRef) => + $"{recv}.InstanceVariables.Get({symRef})"; + internal static string IvarSet(string recv, string symRef, string value) => + $"{recv}.InstanceVariables.Set({symRef}, {value})"; + + // Read value-id `id` as a boxed MRubyValue (re-box an unboxed long) — for the slow-path + // Send, whose arguments must be boxed. + static string BoxRead(bool[] isLong, int id) => isLong[id] ? Box("l" + Slot(id)) : Val(id); + + // C# string literal for an operator selector (+, -, *, /, <, ==, ...). All chars are + // printable ASCII with no quote/backslash, so no escaping is needed. + static string OpLit(string op) => $"\"{op}\""; + + // Slow path for a numeric binary whose two fast branches (both-fixnum, both-float) missed. + // Two strategies, chosen by whether the op is genuinely mixed-type: + // - Send fallback (mixedType): a real Send to the operator. Keeps the method RUNNING — a + // deopt re-runs the whole method in the interpreter, double-applying any side effect + // already committed (e.g. an RNG that advanced its ivars before a `fixnum / float`). + // Used only when the operands have mismatched float-taint, i.e. the slow path actually + // fires every call. A Send is a call site, so emitting it on the HOT (always-fast) + // monomorphic ops would force the JIT to spill around a never-taken branch (~2x slower + // on optcarrot), hence: + // - deopt (return false) otherwise: the operands are same-typed (both fixnum, or both + // float), so this branch is cold/unreached in monomorphic code and re-execution is + // harmless. This is the original, fast behavior for integer/float-homogeneous methods. + static string NumericSlow(SymbolCache sym, bool[] isLong, bool mixedType, int dst, string op, int a, int b) => + mixedType || (BlockEmit?.ForceSend ?? false) + ? $"{Val(dst)} = state.Send({BoxRead(isLong, a)}, {sym.Reference(OpLit(op))}, {BoxRead(isLong, b)});" + : "result = default; return false;"; + + // True when the op's operands carry mismatched float-taint — one is statically float, the + // other isn't — so the dual fast path provably misses every call (genuine fixnum/float mix). + static bool MixedTaint(bool[] floatTaint, int a, int b) => floatTaint[a] != floatTaint[b]; + static bool MixedTaint(bool[] floatTaint, int a, int b, int c) => + !(floatTaint[a] == floatTaint[b] && floatTaint[b] == floatTaint[c]); + + // Generic numeric binary: runtime dual path. A Long-typed dst is float-untainted by + // construction, so it keeps the fixnum-only path. For a boxed dst, emit both-fixnum and + // both-float branches; the miss (mixed / non-numeric / fixnum div-by-zero) takes the slow + // path (Send for genuinely-mixed ops, deopt otherwise). The float branch is suppressed when + // any operand is an unboxed long (provably fixnum, so it can never pair with a float here). + static bool EmitNumericBinary(SymbolCache sym, StringBuilder body, in RubyIRInstruction ins, string op, bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble, bool isDiv = false) + { + int a = ins.Src0, b = ins.Src1, d = ins.Dst; + // Pure-double: both operands provably Float -> guard-free raw-double arith. dst is a raw + // `double` if it too is double-unboxed, else re-boxed at this boundary. + if (provesDouble[a] && provesDouble[b]) + { + var dexpr = $"{DoubleRead(isLong, isDouble, a)} {op} {DoubleRead(isLong, isDouble, b)}"; + Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};"); + return true; + } + // Looping-method mixed numeric: one operand proven Float, the other proven Fixnum -> Ruby + // coerces to Float, so emit guard-free double arith (reading the fixnum operand as (double)). + if (CurrentProvesFixnum is not null && !isDiv && MixedNumericFloat(isLong, provesDouble, a, b)) + { + var dexpr = $"{DoubleReadNumeric(isLong, isDouble, provesDouble, a)} {op} {DoubleReadNumeric(isLong, isDouble, provesDouble, b)}"; + Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};"); + return true; + } + // Mixed numeric division: Float result iff a Float operand is present (Ruby), so the same + // guard-free double path applies — but only when NOT both-fixnum (integer division differs). + if (CurrentProvesFixnum is not null && isDiv && MixedNumericFloat(isLong, provesDouble, a, b)) + { + var dexpr = $"{DoubleReadNumeric(isLong, isDouble, provesDouble, a)} / {DoubleReadNumeric(isLong, isDouble, provesDouble, b)}"; + Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};"); + return true; + } + var fixExpr = $"{FixRead(isLong, a)} {op} {FixRead(isLong, b)}"; + if (isLong[d]) + { + EmitGuard(body, FixGuard(isLong, a), FixGuard(isLong, b), + isDiv ? $"{FixRead(isLong, b)} != 0" : null); + AssignFix(isLong, body, d, fixExpr); + return true; + } + var fixParts = new List(); + if (FixGuard(isLong, a) is { } ga) fixParts.Add(ga); + if (FixGuard(isLong, b) is { } gb) fixParts.Add(gb); + if (isDiv) fixParts.Add($"{FixRead(isLong, b)} != 0"); + var line = new StringBuilder( + $"if ({Cond(fixParts)}) {{ {Val(d)} = {Box(fixExpr)}; }}"); + if (!isLong[a] && !isLong[b]) + { + line.Append($" else if ({FloatCond(a, b)}) {{ {Val(d)} = {Box($"{FloatRead(a)} {op} {FloatRead(b)}")}; }}"); + } + line.Append($" else {{ {NumericSlow(sym, isLong, MixedTaint(floatTaint, a, b), d, op, a, b)} }}"); + Line(body, line.ToString()); + return true; + } + + // SubMul (reverse=true) is src2 - src0*src1; MulAdd/MulSub are src0*src1 +/- src2. + static bool EmitNumericFused(SymbolCache sym, StringBuilder body, in RubyIRInstruction ins, bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble, string op, bool reverse) + { + int a = ins.Src0, b = ins.Src1, c = ins.Src2, d = ins.Dst; + // Pure-double fused multiply-add/sub: all three operands provably Float -> raw double. + if (provesDouble[a] && provesDouble[b] && provesDouble[c]) + { + var prod = $"{DoubleRead(isLong, isDouble, a)} * {DoubleRead(isLong, isDouble, b)}"; + var dexpr = reverse ? $"{DoubleRead(isLong, isDouble, c)} - {prod}" : $"{prod} {op} {DoubleRead(isLong, isDouble, c)}"; + Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};"); + return true; + } + // Looping-method mixed numeric fused (>=1 Float, rest Fixnum) -> guard-free double, reading + // each fixnum operand as (double). `a*b OP c` / `c - a*b` is Float when any operand is Float. + if (CurrentProvesFixnum is not null && MixedNumericFloat(isLong, provesDouble, a, b, c)) + { + var prod = $"{DoubleReadNumeric(isLong, isDouble, provesDouble, a)} * {DoubleReadNumeric(isLong, isDouble, provesDouble, b)}"; + var cExpr = DoubleReadNumeric(isLong, isDouble, provesDouble, c); + var dexpr = reverse ? $"{cExpr} - {prod}" : $"{prod} {op} {cExpr}"; + Line(body, isDouble[d] ? $"d{Slot(d)} = {dexpr};" : $"{Val(d)} = {Box(dexpr)};"); + return true; + } + string Fixed() + { + var product = $"{FixRead(isLong, a)} * {FixRead(isLong, b)}"; + return reverse ? $"{FixRead(isLong, c)} - {product}" : $"{product} {op} {FixRead(isLong, c)}"; + } + if (isLong[d]) + { + EmitGuard(body, FixGuard(isLong, a), FixGuard(isLong, b), FixGuard(isLong, c)); + AssignFix(isLong, body, d, Fixed()); + return true; + } + var fixParts = new List(); + foreach (var id in new[] { a, b, c }) + { + if (FixGuard(isLong, id) is { } g) fixParts.Add(g); + } + var line = new StringBuilder($"if ({Cond(fixParts)}) {{ {Val(d)} = {Box(Fixed())}; }}"); + if (!isLong[a] && !isLong[b] && !isLong[c]) + { + var fp = $"{FloatRead(a)} * {FloatRead(b)}"; + var fexpr = reverse ? $"{FloatRead(c)} - {fp}" : $"{fp} {op} {FloatRead(c)}"; + line.Append($" else if ({FloatCond(a, b, c)}) {{ {Val(d)} = {Box(fexpr)}; }}"); + } + if (MixedTaint(floatTaint, a, b, c) || (BlockEmit?.ForceSend ?? false)) + { + // Slow path: a*b then op c, both via Send (matches the unfused bytecode order). + var prod = $"state.Send({BoxRead(isLong, a)}, {sym.Reference(OpLit("*"))}, {BoxRead(isLong, b)})"; + var slow = reverse + ? $"{Val(d)} = state.Send({BoxRead(isLong, c)}, {sym.Reference(OpLit(op))}, {prod});" + : $"{Val(d)} = state.Send({prod}, {sym.Reference(OpLit(op))}, {BoxRead(isLong, c)});"; + line.Append($" else {{ {slow} }}"); + } + else + { + line.Append(" else { result = default; return false; }"); + } + Line(body, line.ToString()); + return true; + } + + static bool EmitFixnumImmediate(SymbolCache sym, StringBuilder body, RubyIRMethod exe, in RubyIRInstruction ins, string op, bool[] isLong) + { + var imm = exe.GetLiteral(ins.Aux); + if (!imm.IsFixnum) + { + // Float immediate (rare for AddI/SubI) -> let it bail so the method interprets. + return false; + } + int a = ins.Src0, d = ins.Dst; + var fixExpr = $"{FixRead(isLong, a)} {op} {imm.FixnumValue}L"; + if (isLong[d]) + { + EmitGuard(body, FixGuard(isLong, a)); + AssignFix(isLong, body, d, fixExpr); + return true; + } + var fixParts = new List(); + if (FixGuard(isLong, a) is { } ga) fixParts.Add(ga); + var line = new StringBuilder($"if ({Cond(fixParts)}) {{ {Val(d)} = {Box(fixExpr)}; }}"); + if (!isLong[a]) + { + // Float receiver + fixnum immediate -> Ruby coerces to float (e.g. 2.5 + 3 == 5.5). + line.Append($" else if ({FloatCond(a)}) {{ {Val(d)} = {Box($"{FloatRead(a)} {op} {imm.FixnumValue}d")}; }}"); + } + // Both fixnum and float receivers are covered above, so the miss is non-numeric (rare). + // Under ForceSend (loop/block bodies) the miss must Send, not deopt: a deopt re-runs the + // whole method from the start, double-applying any side effect already committed earlier in + // the iteration. Otherwise deopt is fine (re-execution only matters when the miss fires). + if (BlockEmit?.ForceSend ?? false) + { + line.Append($" else {{ {Val(d)} = state.Send({BoxRead(isLong, a)}, {sym.Reference(OpLit(op))}, {Box($"{imm.FixnumValue}L")}); }}"); + } + else + { + line.Append(" else { result = default; return false; }"); + } + Line(body, line.ToString()); + return true; + } + + static bool EmitNumericCompare(SymbolCache sym, StringBuilder body, in RubyIRInstruction ins, string op, bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble) + { + int a = ins.Src0, b = ins.Src1, d = ins.Dst; // dst is a bool result, never long + // Pure-double compare: both operands provably Float -> guard-free raw-double compare. + if (provesDouble[a] && provesDouble[b]) + { + Line(body, $"{Val(d)} = ({DoubleRead(isLong, isDouble, a)} {op} {DoubleRead(isLong, isDouble, b)}) ? {True} : {False};"); + return true; + } + // Looping-method mixed numeric compare (one Float, one Fixnum) -> guard-free double compare. + if (CurrentProvesFixnum is not null && MixedNumericFloat(isLong, provesDouble, a, b)) + { + Line(body, $"{Val(d)} = ({DoubleReadNumeric(isLong, isDouble, provesDouble, a)} {op} {DoubleReadNumeric(isLong, isDouble, provesDouble, b)}) ? {True} : {False};"); + return true; + } + var fixParts = new List(); + if (FixGuard(isLong, a) is { } ga) fixParts.Add(ga); + if (FixGuard(isLong, b) is { } gb) fixParts.Add(gb); + var fixExpr = $"({FixRead(isLong, a)} {op} {FixRead(isLong, b)})"; + var line = new StringBuilder( + $"if ({Cond(fixParts)}) {{ {Val(d)} = {fixExpr} ? {True} : {False}; }}"); + if (!isLong[a] && !isLong[b]) + { + line.Append($" else if ({FloatCond(a, b)}) {{ {Val(d)} = ({FloatRead(a)} {op} {FloatRead(b)}) ? {True} : {False}; }}"); + } + line.Append($" else {{ {NumericSlow(sym, isLong, MixedTaint(floatTaint, a, b), d, op, a, b)} }}"); + Line(body, line.ToString()); + return true; + } + + // Join guard parts with && for a branch condition; "true" when there are none (both + // operands unboxed long -> the fixnum branch is unconditional). + static string Cond(List parts) => parts.Count > 0 ? string.Join(" && ", parts) : "true"; + + // For a MUTATED (by-ref) stack object, its Move-copy aliases must share ONE struct local, or a + // mutation through one alias (`ref so90`) wouldn't be visible at a read through another (`so89`) + // — value copies don't alias. Map every mutated-object alias id to a canonical id (the min of + // the group, grouped by shared layout INSTANCE = object identity). Read-only objects keep + // separate value-copy aliases (cheaper, and correct since they're never mutated). Rebuilt + // whenever CurrentStackObjects is finalized. + [ThreadStatic] internal static Dictionary? StructCanonical; + internal static void RebuildStructCanonical() + { + StructCanonical = null; + if (CurrentStackObjects is not { Count: > 0 } cso) return; + var mut = new List(); + foreach (var (id, lay) in cso) if (lay.Mutated) mut.Add(id); + if (mut.Count == 0) return; + var map = new Dictionary(); + foreach (var id in mut) + { + var lay = cso[id]; + var canon = id; + foreach (var other in mut) if (ReferenceEquals(cso[other], lay) && other < canon) canon = other; + map[id] = canon; + } + StructCanonical = map; + } + + // Build a stack struct in place: each field <- its ctor arg, per FieldKind (Stage 1: all Boxed). + static void EmitStackConstruct(RubyIRMethod exe, in RubyIRInstruction ins, StackLayout lay, StringBuilder body) + { + // Any struct we BUILD needs its type declared, even if it is consumed by an inlined/spliced + // reader (no variant dispatch) rather than passed as an arg — TryEmitStackArgSend would + // otherwise be the only registrar and miss this object. + NeededStructs ??= new(); + NeededStructs[lay.ClassFp] = lay; + var so = Val(ins.Dst); + for (var f = 0; f < lay.Fields.Count; f++) + { + // Nested field: build the inner struct in place (literal-filled, no heap alloc). + if (lay.FieldKinds[f] == StackFieldKind.Nested) + { + EmitNestedFill($"{so}.f{f}", lay.FieldNested[f]!, body); + continue; + } + // A literal-initialized field (FieldArg == -1) builds from the constant; otherwise + // from ctor arg FieldArg[f]. Boxed fields take the value as-is; ② unboxes. + var src = lay.FieldArg[f] < 0 + ? (TryEmitLiteral(lay.FieldLiteral[f], out var lit) ? lit : "global::ChibiRuby.MRubyValue.Nil") + : Val(exe.GetCallSiteArgumentValueId(ins.Aux, lay.FieldArg[f])); + var rhs = lay.FieldKinds[f] switch + { + StackFieldKind.Double => src + ".FloatValue", // ② + StackFieldKind.Long => src + ".IntegerValue", // ② + _ => src, + }; + Line(body, $"{so}.f{f} = {rhs};"); + } + } + + // Fill a (literal-only) nested struct field in place: `target.fj = ` for each inner + // field, recursing into deeper Nested fields. `lay` is a TryBuildNestedFill clone (all fields + // literal or Nested). + static void EmitNestedFill(string target, StackLayout lay, StringBuilder body) + { + for (var j = 0; j < lay.Fields.Count; j++) + { + if (lay.FieldKinds[j] == StackFieldKind.Nested) + { + EmitNestedFill($"{target}.f{j}", lay.FieldNested[j]!, body); + continue; + } + var lit = TryEmitLiteral(lay.FieldLiteral[j], out var l) ? l : "global::ChibiRuby.MRubyValue.Nil"; + Line(body, lay.FieldKinds[j] switch + { + StackFieldKind.Double => $" {target}.f{j} = {lit}.FloatValue;", + StackFieldKind.Long => $" {target}.f{j} = {lit}.IntegerValue;", + _ => $" {target}.f{j} = {lit};", + }); + } + } + + // Materialize a stack struct into a real heap RObject (reify) — for escape/deopt fallback. + // Recursive: Nested fields reify inner structs, Double/Long re-box. Returns a C# MRubyValue + // expression; `rootLocal` is the top-level RObject local (for copy-back after a mutating Send). + internal static string EmitReify(MRubyState state, StackLayout lay, string structExpr, SymbolCache sym, StringBuilder body, ref int tmpCounter, out string rootLocal) + { + var tmp = "__reify" + tmpCounter++; + rootLocal = tmp; + Line(body, $"var {tmp} = new global::ChibiRuby.RObject(GetConstantUnsafe(state, {sym.Reference(StringLit(state, lay.ConstName))}).As());"); + for (var f = 0; f < lay.Fields.Count; f++) + { + if (!TrySymbolStringLiteral(state, lay.Fields[f], out var fieldLit)) continue; + var fieldExpr = lay.FieldKinds[f] switch + { + StackFieldKind.Double => $"new global::ChibiRuby.MRubyValue({structExpr}.f{f})", + StackFieldKind.Long => $"new global::ChibiRuby.MRubyValue({structExpr}.f{f})", + StackFieldKind.Nested => EmitReify(state, lay.FieldNested[f]!, $"{structExpr}.f{f}", sym, body, ref tmpCounter, out _), + _ => $"{structExpr}.f{f}", + }; + Line(body, $"{tmp}.InstanceVariables.Set({sym.Reference(fieldLit)}, {fieldExpr});"); + } + return $"new global::ChibiRuby.MRubyValue({tmp})"; + } + + // Copy a (possibly mutated) heap RObject's fields back into a stack struct — the deopt/reify + // fallback for a by-`ref` mutated arg (A), so post-call reads of the struct see the Send's + // mutations. Mirror of EmitReify (heap -> struct). Nested fields recurse through the heap + // object's nested ivar. `roLocal` is an RObject-typed local. + internal static void EmitCopyBack(MRubyState state, StackLayout lay, string structExpr, string roLocal, SymbolCache sym, StringBuilder body, ref int tmpCounter) + { + for (var f = 0; f < lay.Fields.Count; f++) + { + if (!TrySymbolStringLiteral(state, lay.Fields[f], out _)) continue; + var get = $"{roLocal}.InstanceVariables.Get({sym.Reference(StringLit(state, lay.Fields[f]))})"; + if (lay.FieldKinds[f] == StackFieldKind.Nested) + { + var inner = "__cb" + tmpCounter++; + Line(body, $"var {inner} = {get}.As();"); + EmitCopyBack(state, lay.FieldNested[f]!, $"{structExpr}.f{f}", inner, sym, body, ref tmpCounter); + continue; + } + Line(body, lay.FieldKinds[f] switch + { + StackFieldKind.Double => $" {structExpr}.f{f} = {get}.FloatValue;", + StackFieldKind.Long => $" {structExpr}.f{f} = {get}.IntegerValue;", + _ => $" {structExpr}.f{f} = {get};", + }); + } + } + + static string StringLit(MRubyState state, Symbol s) => TrySymbolStringLiteral(state, s, out var lit) ? lit : "\"\""; + + // In a variant: a trivial-accessor send on the struct param lowers to a struct-field read/write. + // A trivial accessor send whose RECEIVER is a stack object (a variant's struct param / its + // alias, or a block/method stack-allocated object) -> direct struct field read/write. + static bool TryEmitStackAccessor(MRubyState state, RubyIRMethod exe, in RubyIRInstruction ins, SymbolCache sym, StringBuilder body) + { + if (CurrentStackObjects is not { } cso || !cso.TryGetValue(ins.Src0, out var lay)) return false; + var sel = exe.GetCallSiteSymbol(ins.Aux); + if (!state.TryFindMethod(lay.Cls, sel, out var m, out _) || m.Proc is not { } pr) return false; + if (TryRecognizeTrivialAccessor(state, pr.Irep) is not { } acc) return false; + var fi = lay.FieldIndexOf(acc.Field); + if (fi < 0) return false; + var so = Val(ins.Src0); // the receiver's struct local + if (acc.IsSetter) + { + if (!lay.Mutated) return false; // read-only `in` param can't be written (Stage 1) + var arg = exe.GetCallSiteArgumentValueId(ins.Aux, 0); + if (lay.FieldKinds[fi] == StackFieldKind.Nested) + { + if (cso.ContainsKey(arg)) + { + Line(body, $"{so}.f{fi} = {Val(arg)};"); // arg is itself a stack struct -> value copy + } + else + { + // arg is a heap object (e.g. a computed Vec) -> copy its fields into the nested + // struct field. Block-scoped temps so repeated setters don't collide. + Line(body, "{"); + Line(body, $" var __ci = {Val(arg)}.As();"); + var t = 0; + EmitCopyBack(state, lay.FieldNested[fi]!, $"{so}.f{fi}", "__ci", sym, body, ref t); + Line(body, "}"); + } + Line(body, $"{Val(ins.Dst)} = {Val(arg)};"); + return true; + } + var rhs = lay.FieldKinds[fi] switch + { + StackFieldKind.Double => Val(arg) + ".FloatValue", + StackFieldKind.Long => Val(arg) + ".IntegerValue", + _ => Val(arg), + }; + Line(body, $"{so}.f{fi} = {rhs};"); + Line(body, $"{Val(ins.Dst)} = {Val(arg)};"); + return true; + } + if (lay.FieldKinds[fi] == StackFieldKind.Nested) + { + if (cso.ContainsKey(ins.Dst)) + { + Line(body, $"{Val(ins.Dst)} = {so}.f{fi};"); // dst tracked as a stack struct -> value copy + } + else + { + // dst is boxed -> reify the nested struct into a heap object. Block-scoped temps. + Line(body, "{"); + var t = 0; + var reified = EmitReify(state, lay.FieldNested[fi]!, $"{so}.f{fi}", sym, body, ref t, out _); + Line(body, $" {Val(ins.Dst)} = {reified};"); + Line(body, "}"); + } + return true; + } + var read = lay.FieldKinds[fi] switch + { + StackFieldKind.Double => $"new global::ChibiRuby.MRubyValue({so}.f{fi})", + StackFieldKind.Long => $"new global::ChibiRuby.MRubyValue({so}.f{fi})", + _ => $"{so}.f{fi}", + }; + Line(body, $"{Val(ins.Dst)} = {read};"); + return true; + } + + + // Assemble the full C# method source for one analyzed Ruby method: init the emit-only caches, + // walk the instructions (EmitInstruction) into the body, then wrap it in the entry/inline-form + // signature with arg marshalling + local declarations. Returns null when an op bails (LastBail + // set); isLeaf reports whether the body makes no outbound Ruby call. Reads the per-method + // analysis facts off Mrb2CsCompiler's state (CurrentScalarArrays/Hashes/StackObjects). + internal static string? EmitMethod( + MRubyState state, RubyIRMethod ir, Irep irep, string methodName, + IReadOnlyDictionary? inlineRegistry, + IReadOnlyDictionary? accessorRegistry, + SymbolCache sym, InlineContext ic, ScalarContext? sc, + Dictionary? structParams, + bool[] isLong, bool[] floatTaint, bool[] isDouble, bool[] provesDouble, + HashSet targets, List? loopArgGuards, bool looping, int argCount, + out bool isLeaf) + { + var instructions = ir.Instructions; + BlockEmit = new BlockEmitState { OwnerName = methodName, AccessorRegistry = accessorRegistry, ForceSend = looping }; + AsObjCache = new Dictionary(); + AsObjCounter = 0; + ivarGetCache = new Dictionary<(int, Symbol), string>(); + ivCounter = 0; + reusedIvarReads = ComputeReusedIvarReads(ir, targets); + var body = new StringBuilder(); + // Loop arg-type guards: assert each numerically-used arg is a Fixnum at method entry, before + // any instruction runs (no side effect committed yet -> a miss deopts safely to the + // interpreter). This lets the lattice type those args Fixnum, unboxing mixed int/float arith. + if (loopArgGuards is { Count: > 0 }) + { + foreach (var argId in loopArgGuards) + body.Append(" if (!").Append(Val(argId)).Append(".IsFixnum) { result = default; return false; }\n"); + } + isLeaf = true; + for (var i = 0; i < instructions.Length; i++) + { + // Accessor sends on a scalar-replaced object lower to field reads, not real calls, + // so they don't make the method non-leaf. + if (IsSendOp(instructions[i].OpCode) && + !(sc?.IsAccessorSendOnScalar(instructions[i]) ?? false)) + { + isLeaf = false; + } + if (targets.Contains(i)) + { + body.Append(" L").Append(i).Append(": ;\n"); + ClearAsObjCache(); // control-flow join: a cached cast may not reach here + ClearIvarGetCache(); // ...nor a cached ivar read + } + if (!EmitInstruction(state, ir, instructions[i], sym, body, ic, sc, isLong, floatTaint, isDouble, provesDouble)) + { + LastBail ??= "op:" + instructions[i].OpCode; + return null; + } + InvalidateAsObj(instructions[i].Dst); // the value-id was just reassigned + InvalidateIvarGet(instructions[i]); // a write/call stales cached ivar reads + } + + var sb = new StringBuilder(); + EmitSymbolFields(sym, sb); + EmitInlineFields(ic, sb); + if (sc is not null) EmitScalarFields(sc, sb); + + // Inline form: self (v0) + mandatory args (v1..vN) are parameters, so an inlining + // caller can call it frameless with values (no stack/CallInfo). Remaining value-ids + // are locals (default-init for goto / SSA-merge). The arity check lives in the wrapper. + // A stack-struct VARIANT (structParams set) is named directly (no __inline/wrapper) and + // takes its struct parameters as `in/ref ` — called C#->C# from the caller. + var isVariant = structParams is not null; + // The frameless `__inline` form (self+args as C# params, callable without a frame) is emitted + // only when some self-send actually inlines this method — i.e. its fingerprint is in the inline + // registry AND it isn't a trivial accessor (those devirtualize to direct field access at every + // call site, self or cross-object, so they're never `__inline`-called). Otherwise the irep + // wrapper IS the body (args marshalled into locals), with no redundant `__inline` + forward. + // `__inline` is a misnomer for "frameless direct-call form": the C# JIT, not this codegen, is + // what actually inlines it into a caller; emitting it for non-targets was pure dead weight. + var selfFp = isVariant ? 0UL : state.ComputeIrepFingerprint(irep); + var needsInlineForm = !isVariant && inlineRegistry is not null && inlineRegistry.ContainsKey(selfFp) + && !(AccessorFingerprints?.Contains(selfFp) ?? false); + var merged = !isVariant && !needsInlineForm; + var entryName = needsInlineForm ? methodName + "__inline" : methodName; + sb.Append("public static bool ").Append(entryName).Append("(global::ChibiRuby.MRubyState state"); + if (merged) + { + // Single irep-bound method: check arity, marshal self/args off the frame into locals, run body. + sb.Append(", int sp, out global::ChibiRuby.MRubyValue result)\n{\n"); + sb.Append(" if (ArgumentCountUnsafe(state) != ").Append(argCount).Append(") { result = default; return false; }\n"); + for (var v = 0; v <= argCount; v++) + { + sb.Append(" global::ChibiRuby.MRubyValue ").Append(Val(v)) + .Append(" = RegisterUnsafe(state, sp, ").Append(v).Append(");\n"); + } + } + else + { + for (var v = 0; v <= argCount; v++) + { + if (isVariant && structParams!.TryGetValue(v, out var play)) + { + sb.Append(play.Mutated ? ", ref " : ", in ") + .Append(play.StructType).Append(' ').Append(Val(v)); + } + else + { + sb.Append(", global::ChibiRuby.MRubyValue ").Append(Val(v)); + } + } + sb.Append(", out global::ChibiRuby.MRubyValue result)\n{\n"); + } + + // Non-arg value-ids: boxed ones as MRubyValue locals, unboxed ones as raw long. + // Scalar-replaced object value-ids are never materialized (their fields are locals). + // Stack objects are struct locals (declared separately below). + var boxedLocals = new List(); + var longLocals = new List(); + var doubleLocals = new List(); + var stackLocals = new List(); + for (var v = argCount + 1; v < ir.ValueCount; v++) + { + if (sc is not null && sc.IsScalar(v)) continue; + if (CurrentScalarArrays is { } sa && sa.ContainsKey(v)) continue; // replaced by element locals + if (CurrentScalarHashes is { } sh && sh.ContainsKey(v)) continue; // replaced by key locals + if (CurrentStackObjects is { } cso && cso.ContainsKey(v)) { stackLocals.Add(v); continue; } + if (Slot(v) != v) continue; // coalesced into another local's storage + (isDouble[v] ? doubleLocals : isLong[v] ? longLocals : boxedLocals).Add(v); + } + // Per-element locals for scalar-replaced array literals (boxed; array elements are dynamic). + // One declaration per CANONICAL array (aliases share its element locals). + if (CurrentScalarArrays is { Count: > 0 } scalarArrays) + { + var declaredArrays = new HashSet(); + foreach (var (_, info) in scalarArrays) + { + if (!declaredArrays.Add(info.Canon)) continue; + sb.Append(" global::ChibiRuby.MRubyValue "); + for (var k = 0; k < info.Size; k++) + { + if (k > 0) sb.Append(", "); + sb.Append(ArrElem(info.Canon, k)).Append(" = default"); + } + sb.Append(";\n"); + } + } + // Per-key locals for scalar-replaced hashes (one per distinct key, init nil = Hash default). + if (CurrentScalarHashes is { Count: > 0 } scalarHashes) + { + var declaredHashes = new HashSet(); + foreach (var (_, info) in scalarHashes) + { + if (!declaredHashes.Add(info.Canon) || info.Keys.Count == 0) continue; + sb.Append(" global::ChibiRuby.MRubyValue "); + var first = true; + foreach (var tag in info.Keys) + { + if (!first) sb.Append(", "); + first = false; + sb.Append(HashElem(info.Canon, tag)).Append(" = global::ChibiRuby.MRubyValue.Nil"); + } + sb.Append(";\n"); + } + } + if (boxedLocals.Count > 0) + { + sb.Append(" global::ChibiRuby.MRubyValue "); + for (var i = 0; i < boxedLocals.Count; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(Val(boxedLocals[i])).Append(" = default"); + } + sb.Append(";\n"); + } + if (longLocals.Count > 0) + { + sb.Append(" long "); + for (var i = 0; i < longLocals.Count; i++) + { + if (i > 0) sb.Append(", "); + sb.Append("l").Append(longLocals[i]).Append(" = 0"); + } + sb.Append(";\n"); + } + if (doubleLocals.Count > 0) + { + sb.Append(" double "); + for (var i = 0; i < doubleLocals.Count; i++) + { + if (i > 0) sb.Append(", "); + sb.Append("d").Append(doubleLocals[i]).Append(" = 0"); + } + sb.Append(";\n"); + } + + if (sc is not null) EmitScalarFieldLocals(sc, sb); + + var declaredStructs = new HashSet(); // canonical mutated aliases share one local + // A struct param is declared in the signature; a local that canonicalizes to it (a mutated + // param's alias) must NOT be redeclared. + if (structParams is not null) + foreach (var pidx in structParams.Keys) declaredStructs.Add(Val(pidx)); + foreach (var v in stackLocals) + { + var name = Val(v); + if (!declaredStructs.Add(name)) continue; + sb.Append(" ").Append(CurrentStackObjects![v].StructType).Append(' ').Append(name).Append(" = default;\n"); + } + + EmitSymbolInit(sym, sb); + sb.Append(body); + + if (targets.Contains(instructions.Length)) + { + sb.Append(" L").Append(instructions.Length).Append(": ;\n"); + } + sb.Append(" result = default; return false;\n"); + sb.Append("}\n"); + + // Only the split `__inline` form needs a separate wrapper. Variants are called directly + // C#->C# (never irep-keyed); merged methods already are the irep-bound entry. + if (needsInlineForm) + { + // Stack wrapper: bound to the irep by fingerprint (Irep.CompiledBody). Checks arity, + // marshals self/args off the frame, and calls the inline form. + sb.Append("public static bool ").Append(methodName) + .Append("(global::ChibiRuby.MRubyState state, int sp, out global::ChibiRuby.MRubyValue result)\n{\n"); + sb.Append(" if (ArgumentCountUnsafe(state) != ").Append(argCount) + .Append(") { result = default; return false; }\n"); + sb.Append(" return ").Append(methodName).Append("__inline(state"); + for (var v = 0; v <= argCount; v++) + { + sb.Append(", RegisterUnsafe(state, sp, ").Append(v).Append(")"); + } + sb.Append(", out result);\n}\n"); + } + + return sb.ToString(); + } + + // Assemble the generated source file: the auto-generated header, an optional block-scoped + // namespace, the AotGeneratedMethods-derived class, the stack struct declarations the methods + // use, then the method sources. `structs` is the transitive set the driver gathered from + // NeededStructs (nested field types included); `sources` are the per-method bodies + aux methods. + internal static string EmitProgram(string className, string? namespaceName, + IEnumerable structs, IReadOnlyList sources) + { + var sb = new StringBuilder(); + // Roslyn/csc honor `// ` to suppress analyzers & StyleCop on the file. + sb.Append("// \n"); + sb.Append("// Generated by ChibiRuby mrb2cs (ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.Compile). Do not edit.\n"); + // Block-scoped namespace (not file-scoped `namespace X;`, which is C# 10) so the generated + // source compiles under C# 9 — required for Unity / IL2CPP. + var hasNamespace = namespaceName is { Length: > 0 }; + if (hasNamespace) sb.Append("namespace ").Append(namespaceName).Append("\n{\n"); + sb.Append("public sealed class ").Append(className).Append(" : global::ChibiRuby.AotGeneratedMethods\n{\n"); + // Stack struct types declared before the methods that use them. + foreach (var lay in structs) + { + sb.Append("public struct ").Append(lay.StructType).Append(" { "); + for (var f = 0; f < lay.Fields.Count; f++) sb.Append("public ").Append(lay.CsFieldType(f)).Append(" f").Append(f).Append("; "); + sb.Append("}\n"); + } + foreach (var s in sources) sb.Append(s).Append('\n'); + sb.Append("}\n"); + if (hasNamespace) sb.Append("}\n"); + return sb.ToString(); + } + + // Emit a Symbol's name as a C# string literal. Covers ivar names + // (@x) and method names incl. operators ([], +, !=, %). Bails on empty or any + // non-printable / non-ASCII byte. + internal static bool TrySymbolStringLiteral(MRubyState state, Symbol sym, out string stringLiteral) + { + var name = state.NameOf(sym).AsSpan(); + if (name.Length == 0) + { + stringLiteral = ""; + return false; + } + var sb = new StringBuilder("\""); + foreach (var b in name) + { + if (b < 0x20 || b > 0x7E) + { + stringLiteral = ""; + return false; + } + if (b == (byte)'"' || b == (byte)'\\') + { + sb.Append('\\'); + } + sb.Append((char)b); + } + sb.Append('"'); + stringLiteral = sb.ToString(); + return true; + } + + // C# string literal for a class's registered name (used to re-resolve the class at + // runtime via a top-level constant in ResolveGuardClassUnsafe). Fails on non-printable bytes + // or a `::`-qualified path (the runtime resolver only walks top-level constants — a + // nested candidate simply isn't class-switched and falls through to reify+Send). + internal static bool TryClassNameLiteral(MRubyState state, RClass c, out string stringLiteral) + { + var name = state.NameOf(c).AsSpan(); + if (name.Length == 0) + { + stringLiteral = ""; + return false; + } + var sb = new StringBuilder("\""); + foreach (var b in name) + { + if (b < 0x20 || b > 0x7E || b == (byte)':') + { + stringLiteral = ""; + return false; + } + if (b == (byte)'"' || b == (byte)'\\') + { + sb.Append('\\'); + } + sb.Append((char)b); + } + sb.Append('"'); + stringLiteral = sb.ToString(); + return true; + } + + internal static void Line(StringBuilder body, string text) => body.Append(" ").Append(text).Append('\n'); + + // ---- Name mangling: Ruby names/fingerprints -> C# identifiers (emitted into the source) ---- + // Maps an arbitrary Ruby name (a symbol, method, or class name) to a snake_case C# identifier + // fragment: alphanumeric runs are kept as-is, and each character that isn't legal in a C# + // identifier is spelled out as a lowercase word, with segments joined by `_` + // (e.g. `@x` -> `at_x`, `empty?` -> `empty_q`, `<=>` -> `lt_eq_gt`). Returns the body only — no + // leading separator — so the caller attaches it (symbol slots / method / class names prefix `_`). + internal static string SanitizeToIdentifier(string raw) + { + var segments = new List(); + var run = new StringBuilder(); + foreach (var c in raw) + { + if (c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_') + { + run.Append(c); + continue; + } + FlushRun(); + var word = c switch + { + '@' => "at", + '?' => "q", + '!' => "bang", + '=' => "eq", + '<' => "lt", + '>' => "gt", + '+' => "plus", + '-' => "minus", + '*' => "star", + '/' => "slash", + '%' => "percent", + '[' => "lbracket", + ']' => "rbracket", + '&' => "amp", + '|' => "pipe", + '^' => "caret", + '~' => "tilde", + ' ' => "", + _ => "u" + ((int)c).ToString("x2"), + }; + if (word.Length > 0) segments.Add(word); + } + FlushRun(); + return string.Join("_", segments); + + void FlushRun() + { + if (run.Length > 0) + { + segments.Add(run.ToString()); run.Clear(); + + } + } + } + + // A `_name` identifier suffix for a Ruby name, or "" when the name sanitizes to nothing + // (so an unnamed/anonymous entity just keeps its fingerprint-only base name). + internal static string NameSuffixFor(string raw) + { + var body = SanitizeToIdentifier(raw); + return body.Length == 0 ? "" : "_" + body; + } + + // The C# name for a compiled method body, derived from its irep fingerprint plus (when known) + // the original Ruby method name. Used both where the body is DEFINED and at every inline/variant + // call site, so they always agree. + internal static string MethodCsName(ulong fp) + { + var name = "M_" + fp.ToString("x16"); + if (MethodNameSuffixes is { } m && m.TryGetValue(fp, out var suffix)) name += suffix; + return name; + } + + // Variant name suffix for a set of struct params: sorted `__s[r]_` per param. + internal static string StructParamSuffix(Dictionary structParams) + { + var sb = new StringBuilder(); + foreach (var idx in new List(structParams.Keys).OrderBy(x => x)) + sb.Append("__s").Append(idx).Append(structParams[idx].Mutated ? "r" : "") + .Append('_').Append(structParams[idx].ClassFp.ToString("x16")); + return sb.ToString(); + } + + + // ---- SymbolCache emission: per-method interned-symbol static fields + the intern prologue. + // SymbolCache owns the data (slot -> field name / literal); these turn it into C# source. + + // Class-scope: per-symbol UTF-8 name bytes + state cache field + one Symbol field per slot. + internal static void EmitSymbolFields(SymbolCache sym, StringBuilder sb) + { + if (sym.Count == 0) return; + // UTF-8 name bytes, statically initialized. We avoid both a UTF-16 string literal in + // the generated assembly and the per-state UTF-16->UTF-8 reencode `Intern(string)` does. + // (`"name"u8` would be ideal but doesn't work under Unity/IL2CPP, so emit an explicit + // `new byte[] { ... }`.) + for (var i = 0; i < sym.Count; i++) + { + sb.Append("static readonly byte[] ").Append(Utf8Field(sym.FieldNames[i])).Append(" = ") + .Append(Utf8ArrayLiteral(sym.Literals[i])).Append("; // ").Append(CommentText(RawName(sym.Literals[i]))).Append('\n'); + } + sb.Append("static global::ChibiRuby.MRubyState ").Append(sym.MethodName).Append("__symState;\n"); + sb.Append("static global::ChibiRuby.Symbol "); + for (var i = 0; i < sym.Count; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(sym.FieldNames[i]); + } + sb.Append(";\n"); + } + + // Method-prologue: (re)intern all symbols when the state changed (once per state). Interns + // from the static UTF-8 bytes (ReadOnlySpan overload), not a UTF-16 string. + internal static void EmitSymbolInit(SymbolCache sym, StringBuilder sb) + { + if (sym.Count == 0) return; + sb.Append(" if (!object.ReferenceEquals(").Append(sym.MethodName).Append("__symState, state)) { "); + for (var i = 0; i < sym.Count; i++) + { + sb.Append(sym.FieldNames[i]).Append(" = state.Intern(").Append(Utf8Field(sym.FieldNames[i])).Append("); "); + } + sb.Append(sym.MethodName).Append("__symState = state; }\n"); + } + + static string Utf8Field(string fieldName) => fieldName + "_u8"; + + // `new byte[] { .. }` of the symbol's UTF-8 bytes, for `state.Intern(ReadOnlySpan)`. + static string Utf8ArrayLiteral(string stringLiteral) + { + var bytes = Encoding.UTF8.GetBytes(RawName(stringLiteral)); + var sb = new StringBuilder("new byte[] { "); + for (var i = 0; i < bytes.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(bytes[i]); + } + return sb.Append(" }").ToString(); + } + + // The raw symbol name from a C# string literal: strip the outer quotes and undo the + // `\"`/`\\` escapes that TrySymbolStringLiteral introduced. + internal static string RawName(string stringLiteral) + { + var inner = stringLiteral is ['"', _, ..] && stringLiteral[^1] == '"' + ? stringLiteral.Substring(1, stringLiteral.Length - 2) + : stringLiteral; + var sb = new StringBuilder(); + for (var i = 0; i < inner.Length; i++) + { + var c = inner[i]; + if (c == '\\' && i + 1 < inner.Length) c = inner[++i]; // unescape \" and \\ + sb.Append(c); + } + return sb.ToString(); + } + + // The original symbol text, made safe for a `//` line comment (no embedded newlines). + static string CommentText(string raw) => raw.Replace("\r", " ").Replace("\n", " "); + + // ==== InlineContext emission (moved out of InlineContext; bodies unchanged, ic.* aliased) ==== + + // A constant read (`OP_GetConst`) cached per call site: resolve once, then re-resolve only + // when ConstCacheVersion changes. dst <- the constant's value. + internal static void EmitGuardedConstantRead(InlineContext ic, in RubyIRInstruction ins, string cname, StringBuilder body) + { + var state = ic.state; + var methodName = ic.methodName; + var sym = ic.sym; + ref var constReadCount = ref ic.constReadCount; + var n = constReadCount++; + var valField = methodName + "__gc" + n + "val"; + var verField = methodName + "__gc" + n + "ver"; + var stateField = methodName + "__gc" + n + "state"; + Line(body, $"{Val(ins.Dst)} = GetConstantCachedUnsafe(state, {sym.Reference(cname)}, ref {valField}, ref {verField}, ref {stateField});"); + } + + // Caller side: a Send whose argument is a stack-allocated object. Dispatch to the callee's + // struct variant (guarded per receiver class) and reify on miss/deopt. Stage 1 handles a + // single struct arg (ao's ray into intersect); other shapes return false (normal path). + internal static bool TryEmitStackArgSend(InlineContext ic, in RubyIRInstruction ins, StringBuilder body) + { + var state = ic.state; + var methodName = ic.methodName; + var sym = ic.sym; + var exe = ic.exe; + ref var candGroupCount = ref ic.candGroupCount; + var candGroupSizes = ic.candGroupSizes; + if (CurrentStackObjects is not { } cso || CurrentEscapeSummary is not { } summary) return false; + if (ins.OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) return false; + // The receiver must not itself be a stack object (that is the struct-receiver path). + if (cso.ContainsKey(ins.Src0)) return false; + var argc = exe.GetCallSiteArgumentCount(ins.Aux); + // Gather ALL struct args (each lowers to a struct param of the variant; in/ref by Mutated). + var sargs = new List<(int Pos, int ObjId, StackLayout Lay)>(); + for (var a = 0; a < argc; a++) + { + var aid = exe.GetCallSiteArgumentValueId(ins.Aux, a); + if (cso.TryGetValue(aid, out var alay)) sargs.Add((a, aid, alay)); + } + if (sargs.Count == 0) return false; + var sel = exe.GetCallSiteSymbol(ins.Aux); + if (!TrySymbolStringLiteral(state, sel, out var selName)) return false; + var defs = summary.DefiningClasses(sel); + if (defs.Count == 0) return false; + + // The variant's struct params: paramIndex (pos+1, v0 is self) -> layout. + var structParamsMap = new Dictionary(); + foreach (var sa in sargs) structParamsMap[sa.Pos + 1] = sa.Lay; + var suffix = StructParamSuffix(structParamsMap); + + var cands = new List<(RClass Cls, string NameLit, ulong Fp, string VariantName)>(); + foreach (var c in defs) + { + if (!state.TryFindMethod(c, sel, out var m, out _) || m.Proc is not { } proc) continue; + if (!TryClassNameLiteral(state, c, out var nameLit)) continue; + var fp = state.ComputeIrepFingerprint(proc.Irep); + cands.Add((c, nameLit, fp, MethodCsName(fp) + suffix)); + } + if (cands.Count == 0) return false; + + NeededStructs ??= new Dictionary(); NeededVariants ??= new(); + foreach (var sa in sargs) NeededStructs[sa.Lay.ClassFp] = sa.Lay; + foreach (var cand in cands) + NeededVariants[cand.VariantName] = (cand.Cls, sel, structParamsMap); + + var recv = Val(ins.Src0); + var dst = Val(ins.Dst); + var symRef = sym.Reference(selName); + var pos2sa = new Dictionary(); + foreach (var sa in sargs) pos2sa[sa.Pos] = sa; + var aux = ins.Aux; // can't capture the `in` param in the local function below + // Build the arg list: struct args use `structAt(sa)`, the rest are plain value reads. + string Args(System.Func<(int Pos, int ObjId, StackLayout Lay), string> structAt) + { + var b = new StringBuilder(); + for (var a = 0; a < argc; a++) + b.Append(", ").Append(pos2sa.TryGetValue(a, out var sa) ? structAt(sa) : Val(exe.GetCallSiteArgumentValueId(aux, a))); + return b.ToString(); + } + + // Class-switch dispatch: resolve candidate classes once per method-cache version into + // per-site static fields, then pointer-compare the receiver's class. + var grp = candGroupCount++; + candGroupSizes.Add(cands.Count); + var verField = methodName + "__cand" + grp + "ver"; + string ClsField(int i) => methodName + "__cand" + grp + "_" + i + "cls"; + + Line(body, $"if ({verField} != state.MethodCacheVersion) {{"); + for (var i = 0; i < cands.Count; i++) + Line(body, $" {ClsField(i)} = ResolveGuardClassUnsafe(state, {sym.Reference(cands[i].NameLit)}, {symRef}, 0x{cands[i].Fp:x16}UL);"); + Line(body, $" {verField} = state.MethodCacheVersion;"); + Line(body, "}"); + + var done = "__sdone" + grp; + var rc = "__src" + grp; + // Snapshot each mutated (ref) struct arg, so the reify fallback can restore the pre-call + // state (a variant may partially mutate it, then deopt). (A) + foreach (var sa in sargs) if (sa.Lay.Mutated) Line(body, $"var __snap{grp}_{sa.ObjId} = {Val(sa.ObjId)};"); + Line(body, $"bool {done} = false;"); + Line(body, $"var {rc} = {recv}.Object?.Class;"); + for (var i = 0; i < cands.Count; i++) + { + var kw = i == 0 ? "if" : "else if"; + var callArgs = Args(sa => (sa.Lay.Mutated ? "ref " : "in ") + Val(sa.ObjId)); + Line(body, $"{kw} ({rc} != null && {rc} == {ClsField(i)}) {{ if ({cands[i].VariantName}(state, {recv}{callArgs}, out {dst})) {done} = true; }}"); + } + // Reify + normal Send on unknown class / variant deopt: restore ref args, reify each + // struct arg, Send, then copy back the mutated heap objects into their structs. + Line(body, $"if (!{done}) {{"); + foreach (var sa in sargs) if (sa.Lay.Mutated) Line(body, $"{Val(sa.ObjId)} = __snap{grp}_{sa.ObjId};"); + var tmp = 0; + var reifyAt = new Dictionary(); + var refRos = new List<(StackLayout Lay, int ObjId, string Ro)>(); + foreach (var sa in sargs) + { + reifyAt[sa.Pos] = EmitReify(state, sa.Lay, Val(sa.ObjId), sym, body, ref tmp, out var ro); + if (sa.Lay.Mutated) refRos.Add((sa.Lay, sa.ObjId, ro)); + } + Line(body, $"{dst} = state.Send({recv}, {symRef}{Args(sa => reifyAt[sa.Pos])});"); + foreach (var (rlay, roid, ro) in refRos) EmitCopyBack(state, rlay, Val(roid), ro, sym, body, ref tmp); + Line(body, "}"); + return true; + } + + // Caller side: a non-accessor Send whose RECEIVER is a stack object (`ray.dir.vdot(@n)`). + // The receiver class is statically the object's layout class, so there is no class-switch — + // guard that the class/method are unchanged, then call the callee's struct-`self` variant + // (structParamIndex 0); reify + normal Send on a guard/variant miss. Stage C-step1 keeps it + // read-only `in` and requires plain (non-struct) args; nested/ref args come later. + internal static bool TryEmitStackReceiverSend(InlineContext ic, in RubyIRInstruction ins, StringBuilder body) + { + var state = ic.state; + var methodName = ic.methodName; + var sym = ic.sym; + var exe = ic.exe; + ref var icCount = ref ic.icCount; + if (CurrentStackObjects is not { } cso) return false; + if (ins.OpCode is not RubyIROpCode.Send) return false; + if (!cso.TryGetValue(ins.Src0, out var lay)) return false; + var sel = exe.GetCallSiteSymbol(ins.Aux); + if (!state.TryFindMethod(lay.Cls, sel, out var m, out _) || m.Proc is not { } proc) return false; + if (TryRecognizeTrivialAccessor(state, proc.Irep) is not null) return false; // accessor -> field path + var argc = exe.GetCallSiteArgumentCount(ins.Aux); + for (var a = 0; a < argc; a++) + if (cso.ContainsKey(exe.GetCallSiteArgumentValueId(ins.Aux, a))) return false; // struct arg: beyond step1 + if (!TrySymbolStringLiteral(state, sel, out var selName)) return false; + if (!TrySymbolStringLiteral(state, lay.ConstName, out var constLit)) return false; + + var fp = state.ComputeIrepFingerprint(proc.Irep); + var structParamsMap = new Dictionary { [0] = lay }; // self is the struct (paramIndex 0) + var variantName = MethodCsName(fp) + StructParamSuffix(structParamsMap); + NeededStructs ??= new(); NeededVariants ??= new(); + NeededStructs[lay.ClassFp] = lay; + NeededVariants[variantName] = (lay.Cls, sel, structParamsMap); + + var recv = Val(ins.Src0); + var dst = Val(ins.Dst); + var symRef = sym.Reference(selName); + var selfKw = lay.Mutated ? "ref " : "in "; // method is read-only (CalleeSelfReadOnly), but the + var args = new StringBuilder(); // param decl is keyed on Mutated, so match it; no copy-back needed. + for (var a = 0; a < argc; a++) args.Append(", ").Append(Val(exe.GetCallSiteArgumentValueId(ins.Aux, a))); + var n = icCount++; + var icCls = methodName + "__ic" + n + "cls"; + var icVer = methodName + "__ic" + n + "ver"; + var done = "__rdone" + n; + Line(body, $"bool {done} = false;"); + Line(body, + $"if (ClassMethodGuardUnsafe(state, {sym.Reference(constLit)}, {symRef}, 0x{fp:x16}UL, ref {icCls}, ref {icVer})) {{ if ({variantName}(state, {selfKw}{recv}{args}, out {dst})) {done} = true; }}"); + Line(body, $"if (!{done}) {{"); + var tmp = 0; + var reified = EmitReify(state, lay, recv, sym, body, ref tmp, out _); + Line(body, $"{dst} = state.Send({reified}, {symRef}{args});"); + Line(body, "}"); + return true; + } + + internal static bool TryInlineSelfSend(InlineContext ic, in RubyIRInstruction ins, Symbol methodSym, string mname, int argc, StringBuilder body) + { + var state = ic.state; + var definingClass = ic.definingClass; + var registry = ic.registry; + var accessors = ic.accessors; + if (definingClass is null || registry is null) return false; + // A self-send to a trivial accessor devirtualizes to direct field access (handled by + // TryEmitAccessorDevirt, which now also accepts SendSelf) — never an `__inline` call. + if (accessors is not null && accessors.ContainsKey(methodSym)) return false; + // Resolve the target against the defining class (self's class). Only inline if + // it lands on an RProc method that the first pass marked safe (compiled + leaf + + // small) and whose arity matches the call site. + if (!state.TryFindMethod(definingClass, methodSym, out var method, out _) || + method.Proc is not { } proc) + { + return false; + } + var fp = state.ComputeIrepFingerprint(proc.Irep); + if (!registry.TryGetValue(fp, out var calleeArgc) || calleeArgc != argc) + { + return false; + } + + EmitGuardedInlineCall(ic, ins, mname, fp, argc, body); + return true; + } + + // if (guard(recv class) && callee__inline(state, recv, args, out dst)) {} else dst = Send(...) + internal static void EmitGuardedInlineCall(InlineContext ic, in RubyIRInstruction ins, string mname, ulong fp, int argc, StringBuilder body) + { + var methodName = ic.methodName; + var sym = ic.sym; + var exe = ic.exe; + ref var icCount = ref ic.icCount; + var n = icCount++; + var icCls = methodName + "__ic" + n + "cls"; + var icVer = methodName + "__ic" + n + "ver"; + var calleeInline = MethodCsName(fp) + "__inline"; + var recv = Val(ins.Src0); + var dst = Val(ins.Dst); + var symRef = sym.Reference(mname); + var args = new StringBuilder(); + for (var i = 0; i < argc; i++) + { + args.Append(", ").Append(Val(exe.GetCallSiteArgumentValueId(ins.Aux, i))); + } + // Capture the receiver in a temp: register reuse can make dst == recv (e.g. + // `v = v.org; v = v.vsub(..)`). The callee's __inline writes `out dst` = nil before + // returning false on a guard miss, which would clobber the receiver the Send + // fallback then reads. The temp holds the original receiver across both paths. + var tmp = "_r" + n; + // Guard hit + inline body succeeds -> dst holds the inline result. Guard miss + // (polymorphic / overriding subclass / redefinition) or inline deopt -> Send. + Line(body, + $"{{ var {tmp} = {recv}; if (InlineGuardUnsafe(state, {tmp}, {symRef}, 0x{fp:x16}UL, ref {icCls}, ref {icVer}) && {calleeInline}(state, {tmp}{args}, out {dst})) {{ }} else {{ {dst} = state.Send({tmp}, {symRef}{args}); }} }}"); + } + + // `recv.getter` / `recv.setter=` -> guarded direct field access, for both cross-object (Send) + // and self (SendSelf) receivers. The guard (InlineGuardUnsafe) confirms recv's class still + // resolves the selector to the exact trivial-accessor body we devirtualized against; a miss + // falls back to a real Send (so a subclass override of the accessor still runs). We speculate + // the receiver is that class — a wrong receiver simply takes the Send path. + internal static bool TryEmitAccessorDevirt(InlineContext ic, in RubyIRInstruction ins, Symbol methodSym, string mname, int argc, StringBuilder body) + { + var state = ic.state; + var methodName = ic.methodName; + var sym = ic.sym; + var exe = ic.exe; + var accessors = ic.accessors; + ref var icCount = ref ic.icCount; + if (accessors is null || ins.OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) return false; + if (Environment.GetEnvironmentVariable("AOT_NODEVIRT") == "1") return false; + if (!accessors.TryGetValue(methodSym, out var target)) return false; + if (target.IsSetter ? argc != 1 : argc != 0) return false; + if (!TrySymbolStringLiteral(state, target.Field, out var fieldLit)) return false; + + var n = icCount++; + var icCls = methodName + "__ic" + n + "cls"; + var icVer = methodName + "__ic" + n + "ver"; + var recv = Val(ins.Src0); + // The guard confirms recv's class, so inside the hit branch recv is an RObject. + var recvObj = $"{recv}.As()"; + var dst = Val(ins.Dst); + var symM = sym.Reference(mname); + var symF = sym.Reference(fieldLit); + var guard = $"InlineGuardUnsafe(state, {recv}, {symM}, 0x{target.Fingerprint:x16}UL, ref {icCls}, ref {icVer})"; + if (target.IsSetter) + { + var arg = Val(exe.GetCallSiteArgumentValueId(ins.Aux, 0)); + Line(body, + $"if ({guard}) {{ {IvarSet(recvObj, symF, arg)}; {dst} = {arg}; }} else {{ {dst} = state.Send({recv}, {symM}, {arg}); }}"); + } + else + { + Line(body, + $"if ({guard}) {{ {dst} = {IvarGet(recvObj, symF)}; }} else {{ {dst} = state.Send({recv}, {symM}); }}"); + } + return true; + } + + // A cross-object 0-arg send to a method that returns an immediate constant (multi-level via + // delegation) -> emit the constant directly, guarded by the callee's fingerprint; a guard + // miss (different class / redefinition) falls back to a normal Send. + internal static bool TryEmitConstantDevirt(InlineContext ic, in RubyIRInstruction ins, Symbol methodSym, string mname, int argc, StringBuilder body) + { + var state = ic.state; + var methodName = ic.methodName; + var sym = ic.sym; + var constReturns = ic.constReturns; + ref var icCount = ref ic.icCount; + if (constReturns is null || ins.OpCode is not RubyIROpCode.Send || argc != 0) return false; + if (Environment.GetEnvironmentVariable("AOT_NODEVIRT") == "1") return false; + if (!constReturns.TryGetValue(methodSym, out var target)) return false; + if (!TryEmitLiteral(target.Value, out var constExpr)) return false; + var n = icCount++; + var icCls = methodName + "__ic" + n + "cls"; + var icVer = methodName + "__ic" + n + "ver"; + var recv = Val(ins.Src0); + var dst = Val(ins.Dst); + var symM = sym.Reference(mname); + Line(body, + $"if (InlineGuardUnsafe(state, {recv}, {symM}, 0x{target.Fingerprint:x16}UL, ref {icCls}, ref {icVer})) {{ {dst} = {constExpr}; }} else {{ {dst} = state.Send({recv}, {symM}); }}"); + return true; + } + + internal static bool TryEmitPureUnarySend(InlineContext ic, in RubyIRInstruction ins, string mname, StringBuilder body) + { + var methodName = ic.methodName; + var sym = ic.sym; + var exe = ic.exe; + ref var pureUnaryCount = ref ic.pureUnaryCount; + var n = pureUnaryCount++; + var icCls = methodName + "__pu" + n + "cls"; + var icVer = methodName + "__pu" + n + "ver"; + var icMethod = methodName + "__pu" + n + "method"; + var recv = Val(ins.Src0); + var arg = Val(exe.GetCallSiteArgumentValueId(ins.Aux, 0)); + var dst = Val(ins.Dst); + var symRef = sym.Reference(mname); + Line(body, $"if ({arg}.IsFixnum || {arg}.IsFloat) {{ {dst} = PureUnarySendUnsafe(state, {recv}, {symRef}, {arg}, ref {icCls}, ref {icVer}, ref {icMethod}); }} else {{ {dst} = state.Send({recv}, {symRef}, {arg}); }}"); + return true; + } + + internal static void EmitGuardInlineClass(InlineContext ic, in RubyIRInstruction ins, string mname, ulong fp, StringBuilder body) + { + var methodName = ic.methodName; + var sym = ic.sym; + ref var icCount = ref ic.icCount; + var n = icCount++; + var icCls = methodName + "__ic" + n + "cls"; + var icVer = methodName + "__ic" + n + "ver"; + var symRef = sym.Reference(mname); + Line(body, + $"if (InlineGuardUnsafe(state, {Val(ins.Src0)}, {symRef}, 0x{fp:x16}UL, ref {icCls}, ref {icVer})) goto L{ins.Aux}; else {{ result = default; return false; }}"); + } + + internal static void EmitInlineFields(InlineContext ic, StringBuilder sb) + { + var methodName = ic.methodName; + var icCount = ic.icCount; + var pureUnaryCount = ic.pureUnaryCount; + var constReadCount = ic.constReadCount; + var candGroupSizes = ic.candGroupSizes; + for (var i = 0; i < icCount; i++) + { + sb.Append("static global::ChibiRuby.RClass ").Append(methodName).Append("__ic").Append(i).Append("cls;\n"); + sb.Append("static int ").Append(methodName).Append("__ic").Append(i).Append("ver;\n"); + } + for (var i = 0; i < pureUnaryCount; i++) + { + sb.Append("static global::ChibiRuby.RClass ").Append(methodName).Append("__pu").Append(i).Append("cls;\n"); + sb.Append("static int ").Append(methodName).Append("__pu").Append(i).Append("ver;\n"); + sb.Append("static global::ChibiRuby.MRubyMethod ").Append(methodName).Append("__pu").Append(i).Append("method;\n"); + } + for (var g = 0; g < candGroupSizes.Count; g++) + { + // -1 sentinel: MethodCacheVersion is >= 0, so the first call always resolves + // (a 0 default would collide with the initial version and never resolve). + sb.Append("static int ").Append(methodName).Append("__cand").Append(g).Append("ver = -1;\n"); + for (var i = 0; i < candGroupSizes[g]; i++) + { + sb.Append("static global::ChibiRuby.RClass ").Append(methodName).Append("__cand").Append(g).Append('_').Append(i).Append("cls;\n"); + } + } + for (var i = 0; i < constReadCount; i++) + { + sb.Append("static global::ChibiRuby.MRubyValue ").Append(methodName).Append("__gc").Append(i).Append("val;\n"); + // -1 sentinel as above: never equals a real ConstCacheVersion (>= 0). + sb.Append("static int ").Append(methodName).Append("__gc").Append(i).Append("ver = -1;\n"); + sb.Append("static global::ChibiRuby.MRubyState ").Append(methodName).Append("__gc").Append(i).Append("state;\n"); + } + } + + // ==== ScalarContext emission (moved out of ScalarContext; bodies unchanged, sc.* aliased) ==== + + internal static void EmitScalarFieldLocals(ScalarContext sc, StringBuilder sb) + { + var objects = sc.objects; + var seen = new HashSet(); + foreach (var o in objects.Values) + { + if (!seen.Add(o.ValueId)) continue; + for (var f = 0; f < o.Fields.Count; f++) + { + sb.Append(" global::ChibiRuby.MRubyValue ").Append(ScalarContext.FieldLocal(o.ValueId, f)).Append(" = default;\n"); + } + } + } + + // VirtualNew of a scalar object: guard validity, then set each field local from the + // matching ctor arg (initialize inlined). No allocation. + internal static void EmitScalarNew(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body) + { + var state = sc.state; + var objects = sc.objects; + var o = objects[ins.Dst]; + // Guard: constant still resolves to this class and initialize + every accessor we + // inline still have the fingerprints we compiled against. Miss -> deopt. + EmitScalarGuardCall(sc, body, o, state.Intern("initialize"u8), o.InitFingerprint); + foreach (var acc in o.Accessors) + { + EmitScalarGuardCall(sc, body, o, acc.Key, acc.Value.Fingerprint); + } + for (var f = 0; f < o.Fields.Count; f++) + { + var argVid = exe.GetCallSiteArgumentValueId(ins.Aux, o.FieldArg[f]); + Line(body, $"{ScalarContext.FieldLocal(o.ValueId, f)} = {Val(argVid)};"); + } + } + + // Escaping `Const.new(args)` -> inline construction: allocate the object and store its + // fields directly, skipping the `:new` + `:initialize` double dispatch. Guarded so a + // redefined `:new`/`:initialize` deopts to the real dispatch (InlineNewGuardUnsafe). + internal static void EmitFastNew(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body) + { + var state = sc.state; + var methodName = sc.methodName; + var sym = sc.sym; + var fastNew = sc.fastNew; + ref var guardCount = ref sc.guardCount; + var o = fastNew[ins.Dst]; + if (!TrySymbolStringLiteral(state, state.Intern("new"u8), out var newLit) || + !TrySymbolStringLiteral(state, state.Intern("initialize"u8), out var initLit)) + { + Line(body, $"{Val(ins.Dst)} = state.Send({Val(ins.Src0)}, {sym.Reference("\"new\"")});"); + return; + } + var slot = guardCount++; + var cls = methodName + "__sc" + slot + "cls"; + var ver = methodName + "__sc" + slot + "ver"; + var classVal = Val(ins.Src0); + Line(body, + $"if (!InlineNewGuardUnsafe(state, {classVal}, {sym.Reference(newLit)}, {sym.Reference(initLit)}, 0x{o.InitFingerprint:x16}UL, ref {cls}, ref {ver})) {{ result = default; return false; }}"); + Line(body, $"{Val(ins.Dst)} = new global::ChibiRuby.MRubyValue(new global::ChibiRuby.RObject({classVal}.As()));"); + // The dst is the freshly-allocated RObject; cast it once for all field stores. + var newObj = o.Fields.Count > 0 ? AsRObject(ins.Dst, Val(ins.Dst), body) : Val(ins.Dst); + for (var f = 0; f < o.Fields.Count; f++) + { + if (!TrySymbolStringLiteral(state, o.Fields[f], out var fieldLit)) continue; + var argVid = exe.GetCallSiteArgumentValueId(ins.Aux, o.FieldArg[f]); + Line(body, $"{IvarSet(newObj, sym.Reference(fieldLit), Val(argVid))};"); + } + } + + internal static void EmitScalarGuardCall(ScalarContext sc, StringBuilder body, ScalarObject o, Symbol methodSym, ulong fp) + { + var state = sc.state; + var methodName = sc.methodName; + var sym = sc.sym; + var guardSlot = sc.guardSlot; + ref var guardCount = ref sc.guardCount; + if (!guardSlot.TryGetValue((o.ValueId, methodSym), out var slot)) + { + slot = guardCount++; + guardSlot[(o.ValueId, methodSym)] = slot; + } + if (!TrySymbolStringLiteral(state, o.ConstName, out var constLit) || + !TrySymbolStringLiteral(state, methodSym, out var methLit)) + { + // Should not happen for resolved class/method names; be safe and never elide. + Line(body, "{ result = default; return false; }"); + return; + } + var cls = methodName + "__sc" + slot + "cls"; + var ver = methodName + "__sc" + slot + "ver"; + Line(body, + $"if (!ClassMethodGuardUnsafe(state, {sym.Reference(constLit)}, {sym.Reference(methLit)}, 0x{fp:x16}UL, ref {cls}, ref {ver})) {{ result = default; return false; }}"); + } + + // Accessor send on a scalar object -> direct field-local read/write. Returns false if + // `ins` is not such a send (caller falls back to normal Send emission). + internal static bool TryEmitAccessorSend(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body) + { + var objects = sc.objects; + if (ins.OpCode is not RubyIROpCode.Send) return false; + if (!objects.TryGetValue(ins.Src0, out var o)) return false; + var msym = exe.GetCallSiteSymbol(ins.Aux); + if (!o.Accessors.TryGetValue(msym, out var acc)) return false; + var local = ScalarContext.FieldLocal(o.ValueId, acc.FieldIndex); + if (acc.IsSetter) + { + var arg = exe.GetCallSiteArgumentValueId(ins.Aux, 0); + Line(body, $"{local} = {Val(arg)};"); + Line(body, $"{Val(ins.Dst)} = {Val(arg)};"); // o.x = v evaluates to v + } + else + { + Line(body, $"{Val(ins.Dst)} = {local};"); + } + return true; + } + + internal static bool TryEmitScalarFieldAccess(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body) + { + if (!sc.TryGetScalarFieldAccess(exe, ins, out var objId, out var fieldIndex, out var isSetter)) + { + return false; + } + + var local = ScalarContext.FieldLocal(objId, fieldIndex); + if (isSetter) + { + Line(body, $"{local} = {Val(ins.Src1)};"); + } + else + { + Line(body, $"{Val(ins.Dst)} = {local};"); + } + return true; + } + + internal static bool TryEmitScalarInlineGuard(ScalarContext sc, RubyIRMethod exe, in RubyIRInstruction ins, StringBuilder body) + { + var objects = sc.objects; + if (ins.OpCode != RubyIROpCode.GuardInlineClass || + !objects.TryGetValue(ins.Src0, out var o) || + !exe.TryGetGuardInline(ins.Src1, out var fp) || + fp == 0) + { + return false; + } + + EmitScalarGuardCall(sc, body, o, exe.GetCallSiteSymbol(ins.Src1), fp); + Line(body, $"goto L{ins.Aux};"); + return true; + } + + internal static void EmitScalarFields(ScalarContext sc, StringBuilder sb) + { + var methodName = sc.methodName; + var guardCount = sc.guardCount; + for (var i = 0; i < guardCount; i++) + { + sb.Append("static global::ChibiRuby.RClass ").Append(methodName).Append("__sc").Append(i).Append("cls;\n"); + sb.Append("static int ").Append(methodName).Append("__sc").Append(i).Append("ver;\n"); + } + } + + internal static bool TryEmitLiteral(MRubyValue v, out string expr) + { + if (v.IsFixnum) + { + expr = $"new global::ChibiRuby.MRubyValue({v.FixnumValue}L)"; + return true; + } + if (v.IsFloat) + { + // Plain `ldc.r8` literal for finite values (folds with no JIT budget, unlike + // Int64BitsToDouble(const)); bit-exact reconstruction for NaN/Inf/non-roundtrippable. + expr = $"new global::ChibiRuby.MRubyValue({Emitter.DoubleLitText(v.FloatValue)})"; + return true; + } + // Exact VType match only — symbols/strings are truthy immediates/objects + // too, so a loose check would mis-emit them as `true`. Bail on those. + switch (v.VType) + { + case MRubyVType.Nil: + expr = "global::ChibiRuby.MRubyValue.Nil"; + return true; + case MRubyVType.True: + expr = "global::ChibiRuby.MRubyValue.True"; + return true; + case MRubyVType.False: + expr = "global::ChibiRuby.MRubyValue.False"; + return true; + default: + expr = ""; + return false; + } + } + + // Recognizes fixnum bitwise-operator method names with a safe C# equivalent. + internal static bool TryFixnumBitwiseOp(MRubyState state, Symbol sym, out string op, out bool isShift) + { + op = ""; + isShift = false; + var name = state.NameOf(sym).AsSpan(); + if (Matches(name, "&"u8)) { op = "&"; return true; } + if (Matches(name, "|"u8)) { op = "|"; return true; } + if (Matches(name, "^"u8)) { op = "^"; return true; } + if (Matches(name, ">>"u8)) { op = ">>"; isShift = true; return true; } + // Left shift: C# `long <<` matches Ruby for a 0..63 shift, modulo the fixnum-overflow->Bignum + // promotion the AOT already ignores for `*`. The 0..63 guard routes negative/large shifts + // (which Ruby treats as right-shift / Bignum) to the Send fallback. + if (Matches(name, "<<"u8)) { op = "<<"; isShift = true; return true; } + return false; + } +} diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/InlineContext.cs b/src/ChibiRuby.JetPack/Mrb2Cs/InlineContext.cs new file mode 100644 index 00000000..4ccbd831 --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/InlineContext.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using ChibiRuby; +namespace ChibiRuby.JetPack.Mrb2Cs; + +// Per-method emission context for block inlining, kept in a thread-static so it doesn't +// have to thread through every EmitInstruction/arith helper. Inside an inlined block body: +// ForceSend makes numeric slow paths Send (never deopt — a block body must not re-execute +// a partially-applied loop iteration); UpvarCells maps a depth-0 upvar register to the C# +// ref-param cell it reads/writes; AuxMethods collects the generated __blk method sources. +sealed class BlockEmitState +{ + public bool ForceSend; + // Scope level of the body currently being emitted: 0 = the method, 1 = a block directly + // in the method, 2 = a block in a block, ... A variable is identified by an absolute + // coordinate (scopeLevel, register) — a register number alone is ambiguous across levels. + public int CurrentLevel; + // Coordinates the current block received as `cell__` ref params (the + // captured variables it or its descendants read/write above its own scope). + public HashSet<(int Scope, int Register)>? Cells; + public readonly List AuxMethods = []; + public int BlockCounter; + public string OwnerName = ""; // prefix for unique __blk names + // Program-wide accessor map so inlined block bodies can devirtualize their (very common) + // cross-object getter/setter sends to guarded field access. Only accessor devirt is + // enabled in blocks — never self-send / cross-object __inline calls, which are direct C# + // calls that would break fiber switching from inside a loop body. + public IReadOnlyDictionary? AccessorRegistry; +} + +// Per-method inline-emission data. Holds the resolution inputs (definingClass/registry drive +// self-send inlining; accessors drives cross-object getter/setter devirtualization; either may +// be absent → that capability is off) plus the per-site counters the emitter increments and +// EmitInlineFields reads back. Pure data — all C# emission lives in Emitter. +sealed class InlineContext +{ + internal readonly MRubyState state; + internal readonly RClass? definingClass; + internal readonly IReadOnlyDictionary? registry; + internal readonly IReadOnlyDictionary? accessors; + internal readonly IReadOnlyDictionary? constReturns; + internal readonly string methodName; + internal readonly SymbolCache sym; + internal readonly RubyIRMethod exe; + + internal int icCount; + internal int pureUnaryCount; + internal int candGroupCount; + internal int constReadCount; + internal readonly List candGroupSizes = []; // per class-switch site: number of candidate classes + + public InlineContext( + MRubyState state, + RClass? definingClass, + IReadOnlyDictionary? registry, + IReadOnlyDictionary? accessors, + IReadOnlyDictionary? constReturns, + string methodName, + SymbolCache sym, + RubyIRMethod exe) + { + this.state = state; + this.definingClass = definingClass; + this.registry = registry; + this.accessors = accessors; + this.constReturns = constReturns; + this.methodName = methodName; + this.sym = sym; + this.exe = exe; + } +} diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/Mrb2CsCompiler.cs b/src/ChibiRuby.JetPack/Mrb2Cs/Mrb2CsCompiler.cs new file mode 100644 index 00000000..c0ee5a71 --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/Mrb2CsCompiler.cs @@ -0,0 +1,617 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Diagnostics.CodeAnalysis; + +namespace ChibiRuby.JetPack.Mrb2Cs; + +// Build-time Ruby -> C# codegen. Builds a method's Irep into RubyIR (via RubyIRBuilder.Build, +// which also runs escape-analysis op rewriting + arithmetic fusion), runs analyses over that IR +// (SSA renumbering, escape/scalar replacement, type inference / unboxing), then walks the IR +// and emits a C# method matching CompiledRubyMethodBody: bool (MRubyState, int sp, out MRubyValue). +// +// The generated code targets only PUBLIC ChibiRuby APIs (it lives in the user's +// assembly, no InternalsVisibleTo). Symbols (ivar names, send method ids) are interned +// ONCE per state into per-method static fields (state-keyed so it stays correct across +// multiple states) instead of per call. Control flow is labels + gotos mirroring the +// IR's forward branches; all value-id locals are pre-declared (default-init) so gotos +// never trip C# definite-assignment. Arithmetic/comparison is speculatively typed +// (fixnum fast path + guard->deopt). Unhandled ops -> TryCompileMethod returns false. +public static class Mrb2CsCompiler +{ + // Diagnostic: set to the reason TryCompileMethod last returned false (for coverage analysis). + [ThreadStatic] + internal static string? LastBail; + + // SSA live-range splitting (+ its SSA-driven splice detection and pre-side-effect float + // window) is ON by default; set AOT_NOSSA=1 to fall back to the register-reused path. + internal static bool SsaEnabled => Environment.GetEnvironmentVariable("AOT_NOSSA") != "1"; + + // Diagnostic: devirtualize+inline surface. Counted across a compile run when a + // defining class is supplied. SelfSendSites = all SendSelf in compiled methods; + // ResolvedSelfSends = those resolving to an RProc method in the defining class; + // InlinableSelfSends = resolved + callee is a small leaf that itself AOT-lowers. + internal static int UnboxedLocals; + + static void ResetInlineSurface() + { + UnboxedLocals = 0; + } + + + // Per-method irep fingerprint -> readable `_rubyname` suffix, so the C# method name carries the + // original Ruby method name (set by Compile; null for standalone TryCompileMethod). Read via MethodCsName. + [ThreadStatic] + internal static Dictionary? MethodNameSuffixes; + + // Fingerprints of methods whose selector is a (deduped) trivial accessor: every call to them — + // self or cross-object — devirtualizes to direct field access, so they are never `__inline`-called + // and don't need the frameless `__inline` form. Set by Compile; null for standalone TryCompileMethod. + [ThreadStatic] + internal static HashSet? AccessorFingerprints; + + // mrb2cs: compile every statically-compilable method in an irep tree to one C# class. + // Walks the tree, generates each method (dedup by fingerprint), and emits a single + // `class : ChibiRuby.AotGeneratedMethods` source. The caller compiles that + // source (Roslyn at runtime, or csc at build time) and binds Methods by fingerprint. + // `state` must already have executed the program so its classes/methods are defined. + public static ProgramResult Compile(MRubyState state, Irep root, string className = "Mrb2CsGenerated", string? namespaceName = null) + { + // Each method irep -> its defining class, so the codegen can resolve self-sends; and its + // fingerprint -> a readable `_rubyname` suffix, so the emitted C# method name carries the + // original Ruby method name (MethodCsName reads the latter via the thread-static below). + var classOf = new Dictionary(); + var suffixes = new Dictionary(); + state.EnumerateAotMethods((definingClass, methodId, irep) => + { + classOf[irep] = definingClass; + suffixes[state.ComputeIrepFingerprint(irep)] = Emitter.NameSuffixFor(Encoding.UTF8.GetString(state.NameOf(methodId))); + }); + MethodNameSuffixes = suffixes; + + var accessorRegistry = Analyzer.BuildAccessorRegistry(state); + // Fingerprints of the methods those accessor selectors denote — every send to them devirts to + // field access, so they skip the frameless `__inline` form (read in TryCompileMethod's emit step). + var accFps = new HashSet(); + state.EnumerateAotMethods((_, methodId, irep) => + { + if (accessorRegistry.ContainsKey(methodId)) accFps.Add(state.ComputeIrepFingerprint(irep)); + }); + AccessorFingerprints = accFps; + // Constant-returning-method devirt registry (set thread-static; sound regardless of staleness + // because every emitted call site is fingerprint-guarded). Disable with AOT_NOCONSTRET=1. + CurrentConstReturns = Environment.GetEnvironmentVariable("AOT_NOCONSTRET") == "1" ? null : Analyzer.BuildConstReturnRegistry(state); + + // Pass 1: which methods are small enough to inline, and their arg counts. + const int inlineMaxInstructions = 48; + var inlineRegistry = new Dictionary(); + var seen1 = new HashSet(); + Collect(root); + + var inlineSelectorRegistry = Analyzer.BuildInlineSelectorRegistry(state, inlineRegistry); + var returnTypes = RubyIRReturnTypes.Build(state); + // Whole-program retention summary for stack allocation (set once; read per-method via the + // thread-static below). Off switch mirrors the other AOT_NO* gates. + CurrentEscapeSummary = Environment.GetEnvironmentVariable("AOT_NOSTACKOBJ") == "1" + ? null + : RubyIREscapeSummary.Build(state); + NeededStructs = new Dictionary(); + NeededVariants = new Dictionary StructParams)>(); + + // Pass 2: emit final sources, inlining monomorphic self / selected cross-object sends. + var seen = new HashSet(); + var sources = new List(); + var methods = new List<(string, ulong)>(); + var total = 0; + var bails = new Dictionary(); + ResetInlineSurface(); + Walk(root); + + // Pass 3: generate the specialized struct-by-ref callee variants requested during Pass 2. + // (Worklist: a variant could request more; Stage 1's intersect doesn't, but loop safely.) + var emittedVariants = new HashSet(); + while (NeededVariants.Count > emittedVariants.Count) + { + foreach (var (variantName, spec) in new List StructParams)>>(NeededVariants)) + { + if (!emittedVariants.Add(variantName)) continue; + if (!state.TryFindMethod(spec.Callee, spec.Selector, out var m, out _) || m.Proc is not { } proc) continue; + CurrentStructParams = spec.StructParams; + try + { + if (TryCompileMethod(state, proc.Irep, variantName, spec.Callee, inlineRegistry, accessorRegistry, inlineSelectorRegistry, returnTypes, out var v)) + { + sources.Add(v.Source); + sources.AddRange(v.AuxiliaryMethods); + } + } + finally { CurrentStructParams = null; } + } + } + + if (Environment.GetEnvironmentVariable("AOT_COVERAGE") == "1") + { + Console.WriteLine($"mrb2cs coverage: {methods.Count}/{total} compiled, {total - methods.Count} bailed; {UnboxedLocals} unboxed long locals; {NeededStructs.Count} stack structs, {emittedVariants.Count} variants"); + foreach (var kv in bails.OrderByDescending(x => x.Value)) + { + Console.WriteLine($" bail {kv.Key}: {kv.Value}"); + } + } + + // Gather the stack struct types the methods use, transitively (a Stk_Ray with a Stk_Vec + // field needs Stk_Vec declared too); the emitter writes their declarations before the methods. + var allStructs = new Dictionary(); + foreach (var lay in NeededStructs.Values) CollectStruct(lay); + return new ProgramResult(Emitter.EmitProgram(className, namespaceName, allStructs.Values, sources), methods); + + void Walk(Irep irep) + { + var fp = state.ComputeIrepFingerprint(irep); + if (seen.Add(fp)) + { + total++; + if (TryCompileMethod(state, irep, Emitter.MethodCsName(fp), classOf.GetValueOrDefault(irep), inlineRegistry, accessorRegistry, inlineSelectorRegistry, returnTypes, out var gen)) + { + sources.Add(gen.Source); + methods.Add((Emitter.MethodCsName(fp), fp)); + foreach (var aux in gen.AuxiliaryMethods) sources.Add(aux); // __blk bodies, called by name + } + else + { + var reason = LastBail ?? "?"; + bails[reason] = bails.GetValueOrDefault(reason) + 1; + } + } + foreach (var child in irep.Children) Walk(child); + } + + void CollectStruct(StackLayout l) + { + if (!allStructs.TryAdd(l.ClassFp, l)) return; + foreach (var n in l.FieldNested.OfType()) CollectStruct(n); + } + + void Collect(Irep irep) + { + var fp = state.ComputeIrepFingerprint(irep); + if (seen1.Add(fp)) + { + if (TryCompileMethod(state, irep, Emitter.MethodCsName(fp), classOf.GetValueOrDefault(irep), null, accessorRegistry, out var gen) + && gen.InstructionCount <= inlineMaxInstructions) inlineRegistry[fp] = gen.ArgCount; + } + foreach (var child in irep.Children) Collect(child); + } + } + + // Compile one Ruby method's Irep to a C# method body. Returns false (and method = null) when the + // method can't be AOT-compiled (an unsupported op / shape bails — see LastBail); true with the + // emitted CompiledMethod otherwise. + public static bool TryCompileMethod(MRubyState state, Irep irep, string methodName, + [MaybeNullWhen(false)] out CompiledMethod method) => + TryCompileMethod(state, irep, methodName, null, null, out method); + + public static bool TryCompileMethod(MRubyState state, Irep irep, string methodName, RClass? definingClass, + IReadOnlyDictionary? inlineRegistry, + [MaybeNullWhen(false)] out CompiledMethod method) => + TryCompileMethod(state, irep, methodName, definingClass, inlineRegistry, null, out method); + + // inlineRegistry: fp -> argCount of methods safe to inline (compiled + leaf + small). + // When set (and definingClass known), monomorphic self-sends to those callees are + // emitted as a guarded direct call to the callee's __inline form (frameless), with a + // Send fallback. Built in a first pass; this is the second pass. accessorRegistry: selector + // -> trivial accessor, for guarded cross-object getter/setter devirtualization. + public static bool TryCompileMethod(MRubyState state, Irep irep, string methodName, RClass? definingClass, + IReadOnlyDictionary? inlineRegistry, + IReadOnlyDictionary? accessorRegistry, + [MaybeNullWhen(false)] out CompiledMethod method) => + TryCompileMethod(state, irep, methodName, definingClass, inlineRegistry, accessorRegistry, null, out method); + + public static bool TryCompileMethod(MRubyState state, Irep irep, string methodName, RClass? definingClass, + IReadOnlyDictionary? inlineRegistry, + IReadOnlyDictionary? accessorRegistry, + IReadOnlyDictionary? inlineSelectorRegistry, + [MaybeNullWhen(false)] out CompiledMethod method) => + TryCompileMethod(state, irep, methodName, definingClass, inlineRegistry, accessorRegistry, inlineSelectorRegistry, null, out method); + + // returnTypes: whole-program inferred numeric return kinds (RubyIRReturnTypes.Build), used to + // recognize Float/Integer-returning user methods instead of matching names. Null -> only + // builtin float methods are recognized. + static bool TryCompileMethod( + MRubyState state, + Irep irep, + string methodName, + RClass? definingClass, + IReadOnlyDictionary? inlineRegistry, + IReadOnlyDictionary? accessorRegistry, + IReadOnlyDictionary? inlineSelectorRegistry, + RubyIRReturnTypes.Registry? returnTypes, + [MaybeNullWhen(false)] out CompiledMethod method) + { + method = null; + // The RubyIR lowerer is not robust on every shape (it can throw rather than + // bail). Treat any lowering failure/exception as "not compilable". + CurrentReturnTypes = returnTypes; + LastBail = null; + RubyIRMethod? ir; + RubyIRBuildFailure failure; + try + { + ir = RubyIRBuilder.Build(irep, 0, out failure); + // Looping methods (a backward branch) skip the speculative splice/inline machinery: + // they compile fully boxed and deopt-free below (a partial loop iteration must never + // re-execute, so a guarded inline body's deopt-on-miss is unsafe inside a loop). + if (ir is not null && !ir.HasBackwardBranch && definingClass is not null && inlineRegistry is not null) + { + var effectiveInlineSelectorRegistry = + Environment.GetEnvironmentVariable("AOT_NOCROSSSPLICE") == "1" + ? null + : inlineSelectorRegistry; + // Splice detection traces each send's receiver/args to their producer via + // defIndex (the value's last def). On the register-reused IR a workhorse id + // (e.g. ao's `rs`, conflated into v10) points defIndex at a later redefinition, + // hiding that the receiver is a freshly-`new`ed object — so a consumer like + // `rs.vdot(dir)` is missed. SSA-renumbering first gives `rs` a unique id whose + // single def IS the producer, exposing the candidate. Detection is 1:1 over + // instructions, so the returned bytecode pcs still key the plan correctly. + var detectIr = ir; + if (SsaEnabled && + TryReadMandatoryArgCount(irep, out var detectArgCount)) + { + detectIr = RubyIRSsaRenumber.Run(ir, detectArgCount); + } + var candidatePcs = Analyzer.FindSpliceCandidatePcs(detectIr, effectiveInlineSelectorRegistry, accessorRegistry); + var splicePlan = candidatePcs is { Count: > 0 } + ? RubyIRBuilder.TryBuildSelfInlinePlan( + state, + irep, + definingClass, + inlineRegistry, + effectiveInlineSelectorRegistry, + candidatePcs) + : null; + if (splicePlan is { Count: > 0 }) + { + var spliced = RubyIRBuilder.Build(irep, 0, splicePlan, out var splicedFailure); + if (spliced is not null) + { + ir = spliced; + failure = splicedFailure; + } + } + } + } + catch + { + LastBail = "lower-throw"; + return false; + } + if (ir is null) + { + // Surface the lowerer's own reason (opcode it choked on / why) so coverage + // analysis can tell e.g. "backward branch" (loops) from an unsupported op. + LastBail = failure.OpCode is { } op + ? "lower:" + op + (failure.Reason.Length > 0 ? "(" + failure.Reason + ")" : "") + : "lower:" + (failure.Reason.Length > 0 ? failure.Reason : "null"); + return false; + } + + if (!TryReadMandatoryArgCount(irep, out var argCount)) + { + LastBail = "argspec"; + return false; + } + + // Looping methods (a `while`/`until` back-edge) compile in a fully-boxed, deopt-free mode: + // SSA renumbering, unboxing, scalar replacement and stack allocation are all OFF, and numeric + // slow paths Send instead of deopting (ForceSend) — exactly the block-body contract, because + // a loop body must never re-execute a partial iteration after a mid-loop deopt. SSA + the + // cyclic-dataflow type inference needed to unbox loop-carried values is Phase 2. + var looping = ir.HasBackwardBranch; + + // SSA-grade live-range splitting (ON by default; disable with AOT_NOSSA=1): split reused + // merge-slot value-ids into join-precise ids so per-range type inference / unboxing can + // fire. Renumbering only; emission + ComputeUnboxing are unchanged. Falls back internally + // to the original ir on any anomaly. Float-heavy code wins (ao ~-16%/-35% alloc) because + // the split float ranges unbox; integer code is neutral now that float speculation is gated + // on class-level float evidence (so it never mis-speculates int ivars). See jetpack-ssa-plan.md. + // SSA now handles back-edges (loop-aware reaching-defs fixpoint), so it runs for looping + // methods too: it splits the merge-slot temp workhorses into per-range ids (so an arith + // intermediate isn't conflated with a boolean compare result), which is what lets the sound + // loop lattice prove a clean Float/Fixnum type. Loop-carried values keep one id (their init + // and back-edge defs both reach the header use -> unioned). + if (SsaEnabled) + { + ir = RubyIRSsaRenumber.Run(ir, argCount); + } + + var instructions = ir.Instructions; + + var targets = new HashSet(); + foreach (var ins in instructions) + { + if (ins.OpCode is RubyIROpCode.Jump or RubyIROpCode.JumpIfTruthy + or RubyIROpCode.JumpIfFalsy or RubyIROpCode.JumpIfNil + or RubyIROpCode.GuardInlineClass) + { + targets.Add(ins.Aux); + } + } + + var sym = new SymbolCache(methodName); + CurrentConstLit = Analyzer.BuildConstLit(ir); // fixnum/float literals -> guard-free constant operands + // Scalar replacement / stack allocation deopt on a guard miss, which is unsafe inside a loop + // (a partial iteration would re-execute). Off for looping methods. + var sc = looping ? null : ScalarContext.TryBuild(state, ir, sym, methodName); + // Stack objects in THIS method (caller side): VirtualNew sites we build on the stack and + // pass by-ref to a specialized callee. Only when emission is enabled (opt-in for now). + var structParams = CurrentStructParams; // set by the variant generator (Pass 3) + CurrentStackObjects = !looping && StackObjEnabled && CurrentEscapeSummary is { } esc0 + ? Analyzer.FindStackEligible(state, ir, esc0) + : null; + // Defer to scalar replacement: a fully-local object ScalarContext already eliminates is strictly + // better as a scalar (no struct). Stack allocation is only for objects that escape via a + // non-retaining call (which ScalarContext rejects). Drop any overlap. + if (CurrentStackObjects is { Count: > 0 } && sc is not null) + { + // Only scalar-replaced (fully-eliminated) objects are excluded. fastNew objects still + // heap-allocate, so stack allocation SHOULD take them over (the VirtualNew case checks + // CurrentStackObjects before fastNew). + foreach (var k in new List(CurrentStackObjects.Keys)) + { + if (sc.IsScalar(k)) CurrentStackObjects.Remove(k); + } + } + // Variant body: the struct parameter and its Move-copies are struct locals too, so reads + // of `ray` aliased into another value-id (`v3 = ray`) stay struct (a struct copy) and + // their `ray.org`-style accessor sends lower to field reads. Register the whole alias + // closure (the param + copies) under the param layout in the same CurrentStackObjects map. + if (structParams is not null) + { + CurrentStackObjects ??= new Dictionary(); + foreach (var (pidx, play) in structParams) + { + foreach (var a in Analyzer.MoveClosure(ir, pidx)) + { + CurrentStackObjects[a] = play; + } + } + // The param is registered AFTER FindStackEligible ran, so cascade its own nested-field + // reads now (`param.inner.m()` -> struct-receiver on a stack copy). + if (CurrentEscapeSummary is { } pesc) + { + var pdef = new int[ir.ValueCount]; + for (var i = 0; i < pdef.Length; i++) pdef[i] = -1; + for (var i = 0; i < instructions.Length; i++) { var d = instructions[i].Dst; if ((uint)d < (uint)pdef.Length) pdef[d] = i; } + Analyzer.PropagateNestedReads(state, ir, pdef, CurrentStackObjects, pesc); + } + } + Emitter.RebuildStructCanonical(); // mutated-object aliases share one struct local + if (Environment.GetEnvironmentVariable("AOT_ESCAPE_DEBUG") == "1" && CurrentStackObjects is { Count: > 0 } dbgso) + { + foreach (var (objId, lay) in dbgso) + { + Console.Error.WriteLine($"[stackobj] {methodName} v{objId} = new {state.NameOf(lay.ConstName)} -> {lay.StructType}"); + } + } + // Float speculation needs class context (to know which ivars are statically int); without + // a defining class, pass null so ComputeUnboxing skips speculation entirely. + var knownFixnumIvars = definingClass is null ? null : Analyzer.CollectKnownFixnumIvars(state, definingClass); + // Float speculation only fires in classes that demonstrably use floats (per whole-program + // inference) — keeps it off all-integer classes (e.g. optcarrot) where it would mis-speculate + // int ivars and constant-deopt. No registry (unit tests) -> permit, preserving prior behavior. + var classUsesFloat = CurrentReturnTypes is null || CurrentReturnTypes.ClassUsesFloat(definingClass); + bool[] isLong, floatTaint, isDouble, provesDouble; + List? loopArgGuards = null; + CurrentProvesFixnum = null; + if (looping) + { + // Phase 2: sound MUST numeric typing over the cyclic loop IR. Storage stays boxed (loop- + // carried values are defined by the back-edge Move, which never writes a raw l/d local), + // but proven Float/Fixnum operands enable guard-free double arith (no per-op type + // dispatch). isLong/isDouble stay false (no raw locals); the win is provesDouble/ + // provesFixnum + the mixed-numeric arith path. Speculation is OFF (deopt-in-loop unsafe); + // numerically-used args are Fixnum-guarded at entry (pre-side-effect, deopt-safe). + Analyzer.ComputeLoopUnboxing(state, ir, argCount, definingClass, + out provesDouble, out var provesFixnum, out floatTaint, out var soundProvenLoop, out loopArgGuards); + CurrentProvesFixnum = provesFixnum; + CurrentSoundProven = soundProvenLoop; + // Phase 3: promote purely-numeric loop values to raw double/long locals (FP/int + // registers) instead of boxed-but-guard-free MRubyValue. Move/LoadValue become + // representation-aware so the back-edge update and the pre-loop init write the raw local. + Analyzer.ComputeLoopRawLocals(state, ir, argCount, provesDouble, provesFixnum, out isLong, out isDouble); + } + else + { + isLong = Analyzer.ComputeUnboxing(state, ir, argCount, sc, out floatTaint, out isDouble, out provesDouble, out var soundProven, accessorRegistry, knownFixnumIvars, classUsesFloat, definingClass); + CurrentSoundProven = soundProven; + } + // Coalesce the many SSA temps into a minimal set of C# locals (source-size only; the JIT + // register-allocates regardless). Looping methods are where SSA produces the most temps; + // gated to them to keep non-looping output byte-identical. Scalar/stack-object emission has + // its own value->local naming, so skip coalescing when those are active. + // Array-literal scalar replacement: `[a,b,c]` that never escapes and is read/written only by + // constant index becomes per-element locals (no RArray alloc). Independent of object + // scalar/stack replacement (those are VirtualNew-only), so it composes with every path. + // Computed BEFORE coalescing so its value-ids (which become element locals, never a v{}) are + // excluded from the coalescing pool. + CurrentScalarArrays = ScalarArrayEnabled ? Analyzer.FindScalarArrays(state, ir) : null; + CurrentHashKeyTags = null; + CurrentScalarHashes = ScalarArrayEnabled ? Analyzer.FindScalarHashes(ir) : null; + CurrentLocalSlot = looping && sc is null && CurrentStackObjects is null && structParams is null + ? Analyzer.CoalesceLocals(ir, argCount, isLong, isDouble, CurrentScalarArrays, CurrentScalarHashes) + : null; + var ic = new InlineContext(state, definingClass, inlineRegistry, accessorRegistry, CurrentConstReturns, methodName, sym, ir); + var source = Emitter.EmitMethod(state, ir, irep, methodName, inlineRegistry, accessorRegistry, + sym, ic, sc, structParams, isLong, floatTaint, isDouble, provesDouble, + targets, loopArgGuards, looping, argCount, out var isLeaf); + if (source is null) return false; // an op bailed; LastBail is set + if (definingClass is not null) MeasureInlineSurface(state, ir, definingClass); + method = new CompiledMethod(methodName, source, argCount, instructions.Length, isLeaf, Emitter.BlockEmit.AuxMethods); + return true; + } + + // Diagnostic only (no effect on emitted code): for each self-send in this compiled + // method, count whether it resolves to an RProc method on the defining class and + // whether that callee is a small splice candidate. Drives the inline build-out. + static void MeasureInlineSurface(MRubyState state, RubyIRMethod exe, RClass definingClass) + { + var instructions = exe.Instructions; + for (var i = 0; i < instructions.Length; i++) + { + if (instructions[i].OpCode != RubyIROpCode.SendSelf) + { + continue; + } + + var methodSym = exe.GetCallSiteSymbol(instructions[i].Aux); + if (!state.TryFindMethod(definingClass, methodSym, out var method, out _) || + method.Proc is not { } proc) + { + continue; + } + + if (Analyzer.IsInlineCandidate(proc.Irep)) + { + } + } + } + + // Decide which value-ids can live as unboxed `long`. A value is Long iff it is (a) not + // self/an argument, (b) defined only by fixnum-arith ops (so it's provably fixnum given + // the deopt guards), (c) used ONLY as a fixnum operand of arith/comparison ops, and (d) + // NOT float-tainted. (c) guarantees a Long value never reaches a boxed context, so non- + // arith op emission is untouched and never needs to box it. (d) keeps known-float arith + // chains boxed so the dual fixnum/float emission handles them at runtime instead of the + // long path speculating fixnum and deopting on the first float. Conservative throughout. + // + // Float taint is a forward dataflow: seeds are float literals and float-returning sends + // (to_f / Math.sqrt etc.); it flows through Move and arith ops to their dst. Integer-only + // code (e.g. optcarrot) has no seeds, so nothing is tainted and long-unboxing is unchanged. + // Collect a coalesced local's representative id (set per method while emitting; null = identity). + [ThreadStatic] internal static int[]? CurrentLocalSlot; + internal static int Slot(int id) => + CurrentLocalSlot is { } s && (uint)id < (uint)s.Length ? s[id] : id; + + // --- array-literal scalar replacement (alloc elimination) --- + // A `[a, b, c]` (NewArray) value-id whose ONLY uses are constant-index `[]`/`[]=` in range and + // which never escapes (no Send arg/receiver, return, ivar/array store, Move, non-const index) is + // replaced by one boxed MRubyValue local PER ELEMENT (`av{id}_{k}`) — the RArray + its backing + // MRubyValue[] allocation are eliminated. Maps array value-id -> element count. + // alias value-id -> (canonical array id, element count). Every Move-alias of a scalar-replaced + // array maps to the same canonical id, whose element locals are `av{canon}_{k}`. + [ThreadStatic] internal static Dictionary? CurrentScalarArrays; + static bool ScalarArrayEnabled => Environment.GetEnvironmentVariable("AOT_NOSCALARARRAY") != "1"; + internal static string ArrElem(int arrId, int index) => "av" + arrId + "_" + index; + internal static bool TryScalarArray(int id, out int canon, out int size) + { + if (CurrentScalarArrays is { } m && m.TryGetValue(id, out var info)) { canon = info.Canon; size = info.Size; return true; } + canon = 0; size = 0; return false; + } + + // --- hash-literal scalar replacement (constant-key {k=>v}) --- + // A `{k0 => v0, ...}` whose keys are all CONSTANT (symbol/fixnum), accessed only by constant key, + // never escaping/iterated, is replaced by one boxed local per distinct key (`hv{canon}_{tag}`) — + // no RHash alloc, no [] / []= dispatch. A lookup of a key never present reads nil (Hash default). + [ThreadStatic] internal static Dictionary Keys)>? CurrentScalarHashes; + [ThreadStatic] internal static Dictionary? CurrentHashKeyTags; // const-key value-id -> tag + internal static string HashElem(int canon, string tag) => "hv" + canon + "_" + tag; + internal static bool TryScalarHash(int id, out int canon, out HashSet keys) + { + if (CurrentScalarHashes is { } m && m.TryGetValue(id, out var info)) { canon = info.Canon; keys = info.Keys; return true; } + canon = 0; keys = null!; return false; + } + // Emission-time tag of a constant key value-id (computed during detection). + internal static string? KeyTag(int keyId) => + CurrentHashKeyTags is { } m && m.TryGetValue(keyId, out var t) ? t : null; + + // Whole-program inferred return types (RubyIRReturnTypes), set per compile run. Null when no + // registry was supplied (e.g. unit tests) -> only the builtin float methods are recognized. + [ThreadStatic] internal static RubyIRReturnTypes.Registry? CurrentReturnTypes; + // Selector -> the immediate constant its 0-arg method returns (set per Compile run). Fingerprint- + // guarded at each call site, so it is sound even if stale across states. + [ThreadStatic] internal static IReadOnlyDictionary? CurrentConstReturns; + + // Whole-program retention summary for AOT stack allocation (set per Compile run). Null when + // disabled (AOT_NOSTACKOBJ=1) or unavailable -> no stack-object classification. + [ThreadStatic] internal static RubyIREscapeSummary.Summary? CurrentEscapeSummary; + + // Stack-object EMISSION (struct + specialized callee). Stage 1 verified end-to-end + // (ao -18% alloc / ~10% faster, checksum-identical on/off, 173/173 both ways), so it is ON by + // default now — gated by AOT_NOSTACKOBJ like CurrentEscapeSummary and the other AOT passes. + internal static bool StackObjEnabled => Environment.GetEnvironmentVariable("AOT_NOSTACKOBJ") != "1"; + + // Caller side: stack-allocated objects in the method being compiled (objId -> struct layout). + [ThreadStatic] internal static Dictionary? CurrentStackObjects; + // Variant side: when generating a specialized callee, the parameters passed as stack structs + // (paramIndex -> layout; in/ref decided by layout.Mutated). A send may pass several. + [ThreadStatic] static Dictionary? CurrentStructParams; + // Whole-program collectors (set per Compile run): struct types to declare + variants to emit. + [ThreadStatic] internal static Dictionary? NeededStructs; // ClassFp -> layout + [ThreadStatic] internal static Dictionary StructParams)>? NeededVariants; // variant name -> spec + + // (Registry.ReturnsInteger is the symmetric "always returns Integer" proof — the foundation + // for unboxing a Send result to a long with no speculation/deopt — not wired into isLong yet.) + + // value-id -> its LoadValue fixnum/float constant (set per emitted exe). A constant operand + // needs no `IsFixnum`/`IsFloat` guard and reads as a C# literal, not `.FixnumValue`/`.FloatValue` + // — so a `@x & 0xFFFFF` lowers to `v1.FixnumValue & 1048575L` with only v1 guarded. Sound: the + // emitted IR is single-def (SSA), so the id's only definition is that LoadValue. + [ThreadStatic] internal static Dictionary? CurrentConstLit; + internal static bool ConstFix(int id, out long v) + { + if (CurrentConstLit is { } m && m.TryGetValue(id, out var lit) && lit.IsFixnum) { v = lit.FixnumValue; return true; } + v = 0; return false; + } + internal static bool ConstFloat(int id, out double v) + { + if (CurrentConstLit is { } m && m.TryGetValue(id, out var lit) && lit.IsFloat) { v = lit.FloatValue; return true; } + v = 0; return false; + } + + // Read value-id `id` as a long in a fixnum context: the constant literal if known, else l{id} + // if it lives unboxed, else the boxed value's FixnumValue (guarded separately by FixGuard). + internal static string FixRead(bool[] isLong, int id) => + ConstFix(id, out var cv) ? cv + "L" : isLong[id] ? "l" + Slot(id) : "v" + Slot(id) + ".FixnumValue"; + + // Guard asserting `id` is a fixnum, or null when it needs none (a known constant, or unboxed long). + internal static string? FixGuard(bool[] isLong, int id) => + ConstFix(id, out _) || isLong[id] ? null : "v" + Slot(id) + ".IsFixnum"; + + + // Per-compile set of dsts whose provesDouble is a sound proof (no guard), set in TryCompileMethod + // alongside CurrentReturnTypes. Null in contexts that don't compute it (preserves the guard). + [ThreadStatic] internal static bool[]? CurrentSoundProven; + + // Phase 2 (looping methods): per-id "always Fixnum" proof from the sound loop type lattice. + // Lets mixed Float/Fixnum arith read a boxed-but-proven-fixnum operand as `(double)v.FixnumValue` + // with no runtime type guard. Null for non-looping methods (no mixed-numeric fast path). + [ThreadStatic] internal static bool[]? CurrentProvesFixnum; + + internal static bool TryReadMandatoryArgCount(Irep irep, out int argCount) + { + argCount = 0; + var seq = irep.Sequence; + if (seq.Length < 4 || (OpCode)seq[0] != OpCode.Enter) + { + return false; + } + var aspec = new ArgumentSpec(((uint)seq[1] << 16) | ((uint)seq[2] << 8) | seq[3]); + if (aspec.OptionalArgumentsCount != 0 || aspec.TakeRestArguments || + aspec.MandatoryArguments2Count != 0 || aspec.KeywordArgumentsCount != 0 || + aspec.TakeKeywordDict || aspec.TakeBlock) + { + return false; + } + argCount = aspec.MandatoryArguments1Count; + return true; + } + + // A stack-allocated object's value-id refers to its struct local `so`; everything else is + // the boxed/unboxed local `v`. (An accidental boxed use of a stack obj -> `so` where an + // MRubyValue is expected -> a C# compile error, i.e. a loud failure, not silent miscompile.) + internal static string Val(int valueId) => + Emitter.StructCanonical is { } sc && sc.TryGetValue(valueId, out var c) ? "so" + c : + CurrentStackObjects is { } so && so.ContainsKey(valueId) ? "so" + valueId : "v" + Slot(valueId); + +} diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/ProgramResult.cs b/src/ChibiRuby.JetPack/Mrb2Cs/ProgramResult.cs new file mode 100644 index 00000000..e95bceb4 --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/ProgramResult.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace ChibiRuby.JetPack.Mrb2Cs; + +// The C# source for a whole compiled program, plus the (generated method name, irep +// fingerprint) pairs a host binds to make a re-parse of the same bytecode hit the compiled body. +public sealed class ProgramResult(string source, IReadOnlyList<(string Name, ulong Fingerprint)> methods) +{ + public string Source { get; } = source; + public IReadOnlyList<(string Name, ulong Fingerprint)> Methods { get; } = methods; +} diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/ScalarReplacement.cs b/src/ChibiRuby.JetPack/Mrb2Cs/ScalarReplacement.cs new file mode 100644 index 00000000..4f97c2f4 --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/ScalarReplacement.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.Text; +namespace ChibiRuby.JetPack.Mrb2Cs; + +// A scalar-replaced allocation: a `Const.new(...)` whose object never escapes the +// method, so it is not allocated — each of its fields becomes a C# local. initialize is +// inlined (field local <- ctor arg) and trivial-accessor sends on the object (`o.x`, +// `o.x=`) become direct field-local reads/writes. Validity (constant still the same +// class, initialize + accessors not redefined) is guarded once at the new site. +sealed class ScalarObject(int valueId, RClass klass, Symbol constName, ulong initFingerprint, int ctorArgCount) +{ + public int ValueId { get; } = valueId; + public RClass Klass { get; } = klass; + public Symbol ConstName { get; } = constName; + public ulong InitFingerprint { get; } = initFingerprint; + public int CtorArgCount { get; } = ctorArgCount; + public HashSet Aliases { get; } = [valueId]; + // Field symbols in initialize order; FieldArg[i] = which ctor arg sets field i. + public List Fields { get; } = []; + public List FieldArg { get; } = []; + // method symbol -> (field index into Fields, isSetter, callee fingerprint). + public Dictionary Accessors { get; } = new(); + + public int FieldIndexOf(Symbol field) + { + for (var i = 0; i < Fields.Count; i++) + { + if (Fields[i] == field) return i; + } + return -1; + } +} + +// Static escape analysis + scalar-replacement planning over one method's IR. Built +// before emission; supplies field-local declarations and per-op emission for the +// VirtualNew + accessor sends of every non-escaping, statically-typed allocation. +sealed class ScalarContext +{ + internal readonly MRubyState state; + internal readonly string methodName; + internal readonly SymbolCache sym; + internal readonly Dictionary objects; + // Escaping `Const.new(args)` that can't be scalar-replaced but whose construction can be + // inlined (plain-Object class, simple `@f=arg` initialize): valueId -> initialize template. + internal readonly Dictionary fastNew; + internal int guardCount; + // Per (object,inlined-method) inline-cache fields for the validity guard. + internal readonly Dictionary<(int, Symbol), int> guardSlot = new(); + + ScalarContext(MRubyState state, string methodName, SymbolCache sym, Dictionary objects, Dictionary fastNew) + { + this.state = state; + this.methodName = methodName; + this.sym = sym; + this.objects = objects; + this.fastNew = fastNew; + } + + public bool IsScalar(int valueId) => objects.ContainsKey(valueId); + public bool IsFastNew(int valueId) => fastNew.ContainsKey(valueId); + + public ScalarObject GetScalarObject(int valueId) => objects[valueId]; + + // For a Send, report whether it is an accessor on a scalar object and which field. + public bool TryGetAccessorSend(RubyIRMethod exe, in RubyIRInstruction ins, out int objId, out int fieldIndex, out bool isSetter) + { + objId = -1; fieldIndex = -1; isSetter = false; + if (ins.OpCode is not RubyIROpCode.Send) return false; + if (!objects.TryGetValue(ins.Src0, out var o)) return false; + if (!o.Accessors.TryGetValue(exe.GetCallSiteSymbol(ins.Aux), out var acc)) return false; + objId = o.ValueId; fieldIndex = acc.FieldIndex; isSetter = acc.IsSetter; + return true; + } + + // True when `ins` is an accessor send whose receiver is a scalar object (so it lowers + // to a field access, not a real call) — lets the caller keep the method leaf. + public bool IsAccessorSendOnScalar(in RubyIRInstruction ins) => + (ins.OpCode is RubyIROpCode.Send) && + objects.TryGetValue(ins.Src0, out var o) && + o.Accessors.ContainsKey(SymbolOfSend(ins)); + + Symbol SymbolOfSend(in RubyIRInstruction ins) => sendSymbols![ins.Aux]; + Symbol[]? sendSymbols; + + public static ScalarContext? TryBuild(MRubyState state, RubyIRMethod exe, SymbolCache sym, string methodName) + { + if (Environment.GetEnvironmentVariable("AOT_NOSCALAR") == "1") return null; + var instructions = exe.Instructions; + // SSA single-definition map: value-id -> defining instruction index (-1 = arg/self). + var defIndex = new int[exe.ValueCount]; + for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1; + for (var i = 0; i < instructions.Length; i++) + { + var d = instructions[i].Dst; + if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; + } + + var initCache = new Dictionary(); + var objects = new Dictionary(); + var fastNew = new Dictionary(); + for (var i = 0; i < instructions.Length; i++) + { + if (instructions[i].OpCode != RubyIROpCode.VirtualNew) continue; + var obj = TryAnalyze(state, exe, instructions, defIndex, i, initCache); + if (obj is not null) + { + foreach (var alias in obj.Aliases) + { + objects[alias] = obj; + } + continue; + } + // Couldn't scalar-replace (the object escapes). If it's still a plain-Object + // class with a simple `@f=arg` initialize, inline the construction (alloc + ivar + // stores) to skip the `:new` + `:initialize` double dispatch. + var template = TryAnalyzeFastNew(state, exe, instructions, defIndex, i, initCache); + if (template is not null && !objects.ContainsKey(instructions[i].Dst)) + { + fastNew[instructions[i].Dst] = template; + } + } + if (objects.Count == 0 && fastNew.Count == 0) return null; + + var ctx = new ScalarContext(state, methodName, sym, objects, fastNew) + { + // Cache the per-callsite send symbols once for IsAccessorSendOnScalar. + sendSymbols = new Symbol[CallSiteUpperBound(instructions)] + }; + foreach (var i in instructions) + { + if (RubyIROpInfo.IsSendOp(i.OpCode) || i.OpCode == RubyIROpCode.PureUnarySend) + { + ctx.sendSymbols[i.Aux] = exe.GetCallSiteSymbol(i.Aux); + } + } + return ctx; + } + + static int CallSiteUpperBound(ReadOnlySpan ins) + { + var max = 0; + foreach (var i in ins) + { + if (RubyIROpInfo.IsSendOp(i.OpCode) || i.OpCode == RubyIROpCode.PureUnarySend) + { + if (i.Aux + 1 > max) max = i.Aux + 1; + } + } + return max; + } + + static ScalarObject? TryAnalyze( + MRubyState state, RubyIRMethod exe, ReadOnlySpan ins, + int[] defIndex, int newIndex, Dictionary initCache) + { + var newIns = ins[newIndex]; + var objId = newIns.Dst; + foreach (var captured in exe.ClosureCapturedValueIds) + { + if (captured == objId) return null; + } + // Receiver of `.new` must trace to a GetConstant we can resolve to a class now. + var classVid = newIns.Src0; + if ((uint)classVid >= (uint)defIndex.Length || defIndex[classVid] < 0) return null; + var classDef = ins[defIndex[classVid]]; + if (classDef.OpCode != RubyIROpCode.GetConstant) return null; + var constSym = exe.GetSymbol(classDef.Aux); + if (!state.TryGetConst(constSym, out var classValue) || + classValue.Object is not RClass klass) + { + return null; + } + + // initialize must be a simple `@field_i = param_i` setter (cached per class). + var template = AnalyzeInitialize(state, klass, constSym, initCache); + if (template is null) return null; + + var newArgc = exe.GetCallSiteArgumentCount(newIns.Aux); + if (newArgc != template.CtorArgCount) return null; + + var obj = new ScalarObject(objId, klass, constSym, template.InitFingerprint, template.CtorArgCount); + obj.Fields.AddRange(template.Fields); + obj.FieldArg.AddRange(template.FieldArg); + + // Escape analysis: every use of objId (and aliases created by SSA moves, such as + // an inlined callee's return slot) must be a trivial accessor/direct field access. + // Anything else escapes. + var changed = true; + while (changed) + { + changed = false; + for (var j = 0; j < ins.Length; j++) + { + if (j == newIndex) continue; + var u = ins[j]; + var allowSrc0 = false; + switch (u.OpCode) + { + case RubyIROpCode.Move when obj.Aliases.Contains(u.Src0): + { + if ((uint)u.Dst >= (uint)defIndex.Length) return null; + foreach (var captured in exe.ClosureCapturedValueIds) + { + if (captured == u.Dst) return null; + } + if (obj.Aliases.Add(u.Dst)) changed = true; + allowSrc0 = true; + break; + } + case RubyIROpCode.Send when obj.Aliases.Contains(u.Src0): + { + var msym = exe.GetCallSiteSymbol(u.Aux); + var acc = RecognizeAccessor(state, klass, msym); + if (acc is null) return null; + var fieldIdx = obj.FieldIndexOf(acc.Value.Field); + if (fieldIdx < 0) return null; + var argc = exe.GetCallSiteArgumentCount(u.Aux); + if (acc.Value.IsSetter ? argc != 1 : argc != 0) return null; + obj.Accessors[msym] = (fieldIdx, acc.Value.IsSetter, acc.Value.Fingerprint); + allowSrc0 = true; + break; + } + default: + { + if (IsFieldAccessOnScalar(exe, u, obj) || u.OpCode == RubyIROpCode.GuardInlineClass && obj.Aliases.Contains(u.Src0)) + { + allowSrc0 = true; + } + + break; + } + } + + if (ObjectAppears(exe, u, obj.Aliases, allowSrc0)) return null; + } + } + + return obj; + } + + static bool IsFieldAccessOnScalar(RubyIRMethod exe, in RubyIRInstruction ins, ScalarObject obj) + { + if (!obj.Aliases.Contains(ins.Src0) || + ins.OpCode is not ( + RubyIROpCode.GetInstanceVariable or + RubyIROpCode.VirtualGetField or + RubyIROpCode.SetInstanceVariable or + RubyIROpCode.VirtualSetField)) + { + return false; + } + + return obj.FieldIndexOf(exe.GetSymbol(ins.Aux)) >= 0; + } + + // True if objId appears in any operand slot of `u` other than (optionally) Src0. + static bool ObjectAppears(RubyIRMethod exe, in RubyIRInstruction u, HashSet aliases, bool allowSrc0) + { + if (!allowSrc0 && aliases.Contains(u.Src0)) return true; + if (u.OpCode != RubyIROpCode.GuardInlineClass && + (aliases.Contains(u.Src1) || aliases.Contains(u.Src2))) return true; + if (RubyIROpInfo.IsSendOp(u.OpCode) || u.OpCode is RubyIROpCode.PureUnarySend or RubyIROpCode.VirtualNew) + { + // VirtualNew carries ctor args in a callsite too: an object passed as a + // constructor argument escapes (the new object may retain it). + var argc = exe.GetCallSiteArgumentCount(u.Aux); + for (var a = 0; a < argc; a++) + { + if (aliases.Contains(exe.GetCallSiteArgumentValueId(u.Aux, a))) return true; + } + } + else if (u.OpCode is RubyIROpCode.NewArray or RubyIROpCode.NewArray2 or RubyIROpCode.NewHash) + { + var c = exe.GetOperandListCount(u.Aux); + for (var a = 0; a < c; a++) + { + if (aliases.Contains(exe.GetOperandListValueId(u.Aux, a))) return true; + } + } + return false; + } + + // A `Const.new(args)` whose construction can be inlined even though the object escapes: + // the constant resolves to a plain-Object class with a simple `@f=arg` initialize and the + // arity matches. Returns the initialize template (shared/cached) or null. + static ScalarObject? TryAnalyzeFastNew( + MRubyState state, RubyIRMethod exe, ReadOnlySpan ins, + int[] defIndex, int newIndex, Dictionary initCache) + { + var newIns = ins[newIndex]; + var classVid = newIns.Src0; + if ((uint)classVid >= (uint)defIndex.Length || defIndex[classVid] < 0) return null; + var classDef = ins[defIndex[classVid]]; + if (classDef.OpCode != RubyIROpCode.GetConstant) return null; + var constSym = exe.GetSymbol(classDef.Aux); + if (!state.TryGetConst(constSym, out var classValue) || + classValue.Object is not RClass klass || + klass.InstanceVType != MRubyVType.Object) + { + return null; + } + var template = AnalyzeInitialize(state, klass, constSym, initCache); + if (template is null || exe.GetCallSiteArgumentCount(newIns.Aux) != template.CtorArgCount) return null; + return template; + } + + // Recognize `def initialize(a, b, ...); @f1 = a; @f2 = b; ...; end` — every mandatory + // param stored to one field on self, nothing else. Returns a template (field order + + // arg mapping + fingerprint) or null. Cached per class. + // The struct layout for a stack-allocatable class: field symbols (initialize order), per + // field which ctor arg sets it, the initialize fingerprint, and arg count. Null if the + // class has no simple `@f=arg` initialize. Reuses AnalyzeInitialize. + internal static (System.Collections.Generic.List Fields, System.Collections.Generic.List FieldArg, ulong InitFingerprint, int CtorArgCount)? + GetStackLayout(MRubyState state, RClass klass, Symbol constName) + { + var o = AnalyzeInitialize(state, klass, constName, new Dictionary()); + return o is null ? null : (o.Fields, o.FieldArg, o.InitFingerprint, o.CtorArgCount); + } + + static ScalarObject? AnalyzeInitialize(MRubyState state, RClass klass, Symbol constName, Dictionary cache) + { + if (cache.TryGetValue(klass, out var cached)) return cached; + cache[klass] = null; // guard against initialize that re-news the same class (recursion) + + ScalarObject? result = null; + if (state.TryFindMethod(klass, state.Intern("initialize"u8), out var method, out _) && + method.Proc is { } proc && + Mrb2CsCompiler.TryReadMandatoryArgCount(proc.Irep, out var iargc) && + iargc > 0) + { + RubyIRMethod? exeI; + try { exeI = RubyIRBuilder.Build(proc.Irep, 0, out _); } + catch { exeI = null; } + if (exeI is not null) + { + var obj = new ScalarObject(0, klass, constName, state.ComputeIrepFingerprint(proc.Irep), iargc); + var selfVids = new HashSet { 0 }; + foreach (var li in exeI.Instructions) + { + if (li.OpCode == RubyIROpCode.LoadSelf) selfVids.Add(li.Dst); + } + var ok = true; + foreach (var ii in exeI.Instructions) + { + switch (ii.OpCode) + { + case RubyIROpCode.CheckArity: + case RubyIROpCode.LoadSelf: + case RubyIROpCode.Return: + case RubyIROpCode.ReturnValue: + case RubyIROpCode.ReturnSelf: + break; + case RubyIROpCode.SetInstanceVariable: + case RubyIROpCode.VirtualSetField: + if (!selfVids.Contains(ii.Src0)) { ok = false; break; } + var valueId = ii.Src1; + if (valueId < 1 || valueId > iargc) { ok = false; break; } + obj.Fields.Add(exeI.GetSymbol(ii.Aux)); + obj.FieldArg.Add(valueId - 1); + break; + default: + ok = false; + break; + } + if (!ok) break; + } + if (ok && obj.Fields.Count > 0) result = obj; + } + } + cache[klass] = result; + return result; + } + + // Recognize a trivial getter/setter method `msym` on `klass`. Returns (field, isSetter, + // fingerprint) or null. Delegates to the irep-based recognizer. + static (Symbol Field, bool IsSetter, ulong Fingerprint)? RecognizeAccessor(MRubyState state, RClass klass, Symbol msym) + { + if (!state.TryFindMethod(klass, msym, out var method, out _) || method.Proc is not { } proc) return null; + return Analyzer.TryRecognizeTrivialAccessor(state, proc.Irep); + } + + // Field-local name for field `fieldIndex` of scalar object `objId`. + internal static string FieldLocal(int objId, int fieldIndex) => "so" + objId + "_" + fieldIndex; + + public bool TryEmitScalarMove(in RubyIRInstruction ins) + { + return ins.OpCode == RubyIROpCode.Move && + objects.TryGetValue(ins.Src0, out var src) && + objects.TryGetValue(ins.Dst, out var dst) && + ReferenceEquals(src, dst); + } + + public bool TryGetScalarFieldAccess(RubyIRMethod exe, in RubyIRInstruction ins, out int objId, out int fieldIndex, out bool isSetter) + { + objId = -1; fieldIndex = -1; isSetter = false; + if (ins.OpCode is not ( + RubyIROpCode.GetInstanceVariable or + RubyIROpCode.VirtualGetField or + RubyIROpCode.SetInstanceVariable or + RubyIROpCode.VirtualSetField)) + { + return false; + } + if (!objects.TryGetValue(ins.Src0, out var o)) return false; + var idx = o.FieldIndexOf(exe.GetSymbol(ins.Aux)); + if (idx < 0) return false; + objId = o.ValueId; + fieldIndex = idx; + isSetter = ins.OpCode is RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField; + return true; + } + +} diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/StackLayout.cs b/src/ChibiRuby.JetPack/Mrb2Cs/StackLayout.cs new file mode 100644 index 00000000..bd8c35e8 --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/StackLayout.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using ChibiRuby; + +namespace ChibiRuby.JetPack.Mrb2Cs; + +// Per-field representation of a stack struct. Stage 1 emits all Boxed; ② fills Double/Long +// (unboxed FP across calls), ① fills Nested (Vec-in-Ray etc.). +internal enum StackFieldKind { Boxed, Double, Long, Nested } + +// The C# struct layout for a stack-allocatable class. Forward-compatible with ①(nested)/②(typed). +internal sealed class StackLayout +{ + public required RClass Cls; + public required ulong ClassFp; // identity for the struct type name + variant name + public required Symbol ConstName; + public required ulong InitFingerprint; + public required List Fields; // ivar symbols, initialize order + public required List FieldArg; // field i <- ctor arg FieldArg[i], or -1 if literal + public required List FieldLiteral; // field i <- this literal when FieldArg[i] == -1 + public required List FieldKinds; + public required List FieldNested; // non-null when FieldKinds[i]==Nested (②/①) + public bool Mutated; // a setter is called on it -> pass by ref (①) + public string NameSuffix = ""; // `_RubyClassName`, so the struct type reads back to source + public string StructType => "Stk_" + ClassFp.ToString("x16") + NameSuffix; + public int FieldIndexOf(Symbol f) { for (var i = 0; i < Fields.Count; i++) if (Fields[i] == f) return i; return -1; } + public string CsFieldType(int i) => FieldKinds[i] switch + { + StackFieldKind.Double => "double", + StackFieldKind.Long => "long", + StackFieldKind.Nested => FieldNested[i]!.StructType, + _ => "global::ChibiRuby.MRubyValue", + }; +} diff --git a/src/ChibiRuby.JetPack/Mrb2Cs/SymbolCache.cs b/src/ChibiRuby.JetPack/Mrb2Cs/SymbolCache.cs new file mode 100644 index 00000000..b607e9ac --- /dev/null +++ b/src/ChibiRuby.JetPack/Mrb2Cs/SymbolCache.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +namespace ChibiRuby.JetPack.Mrb2Cs; + +// Interns each distinct symbol literal once into a per-method static field. Pure data + slot +// allocation: assigns each distinct literal a stable slot and a readable C# field name, and hands +// that data back to callers. Turning the slots into actual C# field declarations / the intern +// prologue is the Emitter's job (Emitter.EmitSymbolFields / EmitSymbolInit). +sealed class SymbolCache(string methodName) +{ + readonly Dictionary slots = new(); + readonly List literals = []; + readonly List fieldNames = []; // slot -> static field name (slot + readable name) + + // Returns the static field expression for `stringLiteral`, allocating a slot. + public string Reference(string stringLiteral) + { + if (!slots.TryGetValue(stringLiteral, out var slot)) + { + slot = literals.Count; + slots[stringLiteral] = slot; + literals.Add(stringLiteral); + // The slot index keeps the name unique (two symbols that sanitize to the same + // token still get distinct slots); the suffix just makes it readable. + fieldNames.Add(methodName + "__sym" + slot + SanitizedSuffix(stringLiteral)); + } + return fieldNames[slot]; + } + + public int Count => literals.Count; + + // Data exposed for the Emitter to turn into field declarations / the intern prologue. + public string MethodName => methodName; + public IReadOnlyList FieldNames => fieldNames; + public IReadOnlyList Literals => literals; + + // Build a readable `_name` suffix from a C# string literal of a Ruby symbol, mapping + // characters that aren't legal in a C# identifier to lowercase words (e.g. `@x` -> `_at_x`, + // `empty?` -> `_empty_q`, `<=>` -> `_lt_eq_gt`). The slot index already guarantees uniqueness; + // an all-illegal name (e.g. `+`) still gets a readable token, never an empty suffix. + static string SanitizedSuffix(string stringLiteral) => "_" + Emitter.SanitizeToIdentifier(Emitter.RawName(stringLiteral)); +} diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRAuxData.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRAuxData.cs new file mode 100644 index 00000000..6e28ea0c --- /dev/null +++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRAuxData.cs @@ -0,0 +1,39 @@ +namespace ChibiRuby.JetPack; + +struct RubyIRCallSite(int symbolIndex, int argumentStart, int argumentCount) +{ + public readonly int SymbolIndex = symbolIndex; + public readonly int ArgumentStart = argumentStart; + public readonly int ArgumentCount = argumentCount; + RClass? guardInlineReceiverClass; + int guardInlineMethodCacheVersion; + ulong guardInlineCalleeFingerprint; + + // Guard metadata for an SSA-spliced inline body. The GuardInlineClass + // instruction reads this off the cold-path Send's call site to decide whether + // the receiver still matches the class/method shape the body was specialized + // for; on a miss it deopts to that same Send. + public void SetGuardInline(RClass receiverClass, int currentMethodCacheVersion, ulong calleeFingerprint = 0) + { + guardInlineReceiverClass = receiverClass; + guardInlineMethodCacheVersion = currentMethodCacheVersion; + guardInlineCalleeFingerprint = calleeFingerprint; + } + + public bool TryGetGuardInline(out ulong calleeFingerprint) + { + if (guardInlineReceiverClass is { } guardedClass) + { + calleeFingerprint = guardInlineCalleeFingerprint; + return true; + } + calleeFingerprint = 0; + return false; + } +} + +readonly struct RubyIROperandList(int operandStart, int operandCount) +{ + public readonly int OperandStart = operandStart; + public readonly int OperandCount = operandCount; +} diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRBuildFailure.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRBuildFailure.cs new file mode 100644 index 00000000..95e4abe0 --- /dev/null +++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRBuildFailure.cs @@ -0,0 +1,11 @@ +using ChibiRuby; +namespace ChibiRuby.JetPack; + +readonly struct RubyIRBuildFailure(OpCode? opCode, int programCounter, string reason) +{ + public readonly OpCode? OpCode = opCode; + public readonly int ProgramCounter = programCounter; + public readonly string Reason = reason; + + public static RubyIRBuildFailure None => new(null, -1, string.Empty); +} diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRBuilder.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRBuilder.cs new file mode 100644 index 00000000..0637b3a9 --- /dev/null +++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRBuilder.cs @@ -0,0 +1,2621 @@ +using System; +using System.Collections.Generic; +using ChibiRuby.Internals; + +using ChibiRuby; +using ChibiRuby.JetPack.Mrb2Cs; +namespace ChibiRuby.JetPack; + +// One planned inline at a caller Send site. `Callee` is the body to splice in. +// When `GuardClass` is null the splice is unguarded (the receiver class is fixed +// by construction — used by isolation tests). When it is set, the splice is +// emitted behind a GuardInlineClass that deopts to the original Send on a class / +// method-cache-version miss, so the inline is safe in profile-driven production. +readonly struct RubyIRInlineSite( + Irep callee, + RClass? guardClass = null, + int guardMethodCacheVersion = 0, + ulong guardMethodFingerprint = 0, + bool guardMissDeopts = false) +{ + public readonly Irep Callee = callee; + public readonly RClass? GuardClass = guardClass; + public readonly int GuardMethodCacheVersion = guardMethodCacheVersion; + public readonly ulong GuardMethodFingerprint = guardMethodFingerprint; + public readonly bool GuardMissDeopts = guardMissDeopts; +} + +// Builds RubyIR (a high-level, type-inference-oriented IR) from mruby bytecode. `Build` is the +// entry point for the AOT codegen / analyses; it is NOT a high->low "lowering" and the result is +// NOT for execution — it is an analyzable IR. The bytecode walk emits the analysis-friendly op +// shapes directly (VirtualNew / VirtualGetField / VirtualSetField); the only post-build step is a +// multiply+add peephole fusion. +static class RubyIRBuilder +{ + // Build RubyIR from bytecode. Returns null (with `failure`) if the bytecode shape is + // unsupported. The bytecode walk already emits the analysis-friendly shapes directly + // (VirtualNew for `.new`, VirtualGetField/VirtualSetField for ivars); the only post-build + // step is the multiply+add peephole fusion. + public static RubyIRMethod? Build(Irep irep, int programCounter, out RubyIRBuildFailure failure) + { + var ir = TryCompile(irep, programCounter, out failure); + return ir is null ? null : FuseMultiplyAdd(ir); + } + + // Build RubyIR with an inline plan (splice the named callees at their caller Send sites). + public static RubyIRMethod? Build( + Irep irep, + int programCounter, + Dictionary? inlinePlan, + out RubyIRBuildFailure failure) + { + var ir = TryCompile(irep, programCounter, inlinePlan, out failure); + return ir is null ? null : FuseMultiplyAdd(ir); + } + + // Peephole: fuse `Mul` immediately followed by an `Add`/`Sub` that consumes it (single-use, + // not a branch target) into MulAdd / MulSub / SubMul. A pure optimization (fewer ops, and the + // double-unbox path emits it as one a*b±c expression); the codegen handles the unfused form too. + static RubyIRMethod FuseMultiplyAdd(RubyIRMethod ir) + { + var rewritten = FuseMultiplyAddInstructions(ir.Instructions.ToArray(), ir, out var oldToNew); + if (oldToNew is null) + { + return ir; + } + + return ir.CreateVariant( + rewritten, + loweredSourceIrep: ir.SourceIrep, + loweredSourceBytecodePcs: RemapSourceBytecodePcs(ir, oldToNew)); + } + + static RubyIRInstruction[] FuseMultiplyAddInstructions( + RubyIRInstruction[] source, + RubyIRMethod ir, + out int[]? oldToNewInstructionIndexes) + { + oldToNewInstructionIndexes = null; + var useCounts = ir.CountValueUses(source); + var branchTargets = ComputeBranchTargetInstructionIndexes(source); + var changed = false; + var removed = new bool[source.Length]; + + for (var i = 0; i + 1 < source.Length; i++) + { + var multiply = source[i]; + var consumer = source[i + 1]; + if (multiply.OpCode != RubyIROpCode.Mul || + branchTargets[i + 1] || + useCounts[multiply.Dst] != 1 || + consumer.OpCode != RubyIROpCode.Add && + consumer.OpCode != RubyIROpCode.Sub || + consumer.Src0 != multiply.Dst && + consumer.Src1 != multiply.Dst) + { + continue; + } + + var addend = consumer.Src0 == multiply.Dst + ? consumer.Src1 + : consumer.Src0; + var fusedOpCode = consumer.OpCode == RubyIROpCode.Add + ? RubyIROpCode.MulAdd + : consumer.Src0 == multiply.Dst + ? RubyIROpCode.MulSub + : RubyIROpCode.SubMul; + source[i + 1] = new RubyIRInstruction( + fusedOpCode, + consumer.Dst, + multiply.Src0, + multiply.Src1, + addend, + consumer.Aux); + removed[i] = true; + changed = true; + } + + if (!changed) + { + return source; + } + + oldToNewInstructionIndexes = new int[source.Length + 1]; + var newLength = 0; + for (var i = 0; i < source.Length; i++) + { + oldToNewInstructionIndexes[i] = newLength; + if (!removed[i]) + { + newLength++; + } + } + oldToNewInstructionIndexes[source.Length] = newLength; + + var rewritten = new RubyIRInstruction[newLength]; + var rewrittenIndex = 0; + for (var i = 0; i < source.Length; i++) + { + if (removed[i]) + { + continue; + } + + rewritten[rewrittenIndex++] = RemapBranchTarget(source[i], oldToNewInstructionIndexes); + } + + return rewritten; + } + + static int[]? RemapSourceBytecodePcs(RubyIRMethod ir, int[] oldToNewInstructionIndexes) + { + if (ir.SourceIrep is null) + { + return null; + } + + var remapped = new int[oldToNewInstructionIndexes[^1]]; + for (var oldIndex = 0; oldIndex + 1 < oldToNewInstructionIndexes.Length; oldIndex++) + { + var newIndex = oldToNewInstructionIndexes[oldIndex]; + if (oldToNewInstructionIndexes[oldIndex + 1] == newIndex) + { + continue; + } + + remapped[newIndex] = ir.SourceBytecodePc(oldIndex); + } + + return remapped; + } + + static bool[] ComputeBranchTargetInstructionIndexes(ReadOnlySpan instructions) + { + var branchTargets = new bool[instructions.Length]; + for (var i = 0; i < instructions.Length; i++) + { + var instruction = instructions[i]; + if (instruction.OpCode is ( + RubyIROpCode.Jump or + RubyIROpCode.JumpIfTruthy or + RubyIROpCode.JumpIfFalsy or + RubyIROpCode.JumpIfNil or + RubyIROpCode.GuardInlineClass) && + (uint)instruction.Aux < (uint)branchTargets.Length) + { + branchTargets[instruction.Aux] = true; + } + } + + return branchTargets; + } + + static RubyIRInstruction RemapBranchTarget( + RubyIRInstruction instruction, + int[] oldToNewInstructionIndexes) + { + if (instruction.OpCode is not ( + RubyIROpCode.Jump or + RubyIROpCode.JumpIfTruthy or + RubyIROpCode.JumpIfFalsy or + RubyIROpCode.JumpIfNil or + RubyIROpCode.GuardInlineClass) || + (uint)instruction.Aux >= (uint)oldToNewInstructionIndexes.Length) + { + return instruction; + } + + var target = oldToNewInstructionIndexes[instruction.Aux]; + return target == instruction.Aux + ? instruction + : new RubyIRInstruction( + instruction.OpCode, + instruction.Dst, + instruction.Src0, + instruction.Src1, + instruction.Src2, + target); + } + + public static RubyIRMethod? TryCompile(Irep irep, int entryPoint) => + TryCompile(irep, entryPoint, out _); + + public static bool ContainsSelfRecursiveSend(Irep irep, Symbol methodId) + { + if (methodId.Value == 0) + { + return false; + } + + var sequence = irep.Sequence; + var symbols = irep.Symbols; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + switch (opCode) + { + case OpCode.SSend: + if (sequence[pc + 2] < symbols.Length && symbols[sequence[pc + 2]] == methodId) + { + return true; + } + break; + case OpCode.SSend0: + if (sequence[pc + 2] < symbols.Length && symbols[sequence[pc + 2]] == methodId) + { + return true; + } + break; + } + + if (!TryGetInstructionLength(sequence, pc, opCode, out var length)) + { + return false; + } + pc += length; + } + + return false; + } + + static bool TryAnalyzeClosureCaptures( + Irep irep, + out bool[] closureCapturedRegisters, + out RubyIRBuildFailure failure) + { + closureCapturedRegisters = new bool[irep.RegisterVariableCount]; + failure = RubyIRBuildFailure.None; + + var sequence = irep.Sequence; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if (!TryGetInstructionLength(sequence, pc, opCode, out var length)) + { + failure = new RubyIRBuildFailure(opCode, pc, "unsupported opcode"); + return false; + } + + if (opCode is OpCode.Block) + { + var childIndex = sequence[pc + 2]; + if (childIndex >= irep.Children.Length) + { + failure = new RubyIRBuildFailure(opCode, pc, "child irep out of range"); + return false; + } + + MarkDescendantUpVars(irep.Children[childIndex], 0, closureCapturedRegisters); + } + pc += length; + } + + return true; + } + + static void MarkDescendantUpVars(Irep irep, int depthToAncestor, bool[] ancestorRegisters) + { + var sequence = irep.Sequence; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length)) + { + return; + } + + if (opCode is OpCode.GetUpVar or OpCode.SetUpVar && + sequence[pc + 3] == depthToAncestor) + { + var register = sequence[pc + 2]; + if (register < ancestorRegisters.Length) + { + ancestorRegisters[register] = true; + } + } + else if (opCode is OpCode.Block) + { + var childIndex = sequence[pc + 2]; + if (childIndex < irep.Children.Length) + { + MarkDescendantUpVars( + irep.Children[childIndex], + depthToAncestor + 1, + ancestorRegisters); + } + } + + pc += length; + } + } + + internal static bool BlockChildNeedsBytecodeBoundary(Irep irep, out string reason) + { + reason = string.Empty; + var sequence = irep.Sequence; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if (opCode is OpCode.Break or OpCode.ReturnBlk or OpCode.JmpUw) + { + reason = "non-local block control flow"; + return true; + } + + if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length)) + { + reason = "unsupported block child opcode"; + return true; + } + pc += length; + } + + foreach (var child in irep.Children) + { + if (BlockChildNeedsBytecodeBoundary(child, out reason)) + { + return true; + } + } + + return false; + } + + internal static bool BlockDescendantsContainNewObject(Irep[] children) + { + for (var i = 0; i < children.Length; i++) + { + if (IrepContainsNewObjectSend(children[i])) + { + return true; + } + } + + return false; + } + + static bool IrepContainsNewObjectSend(Irep irep) + { + var sequence = irep.Sequence; + var symbols = irep.Symbols; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if (opCode is + OpCode.Send or + OpCode.SSend or + OpCode.SendB or + OpCode.SSendB or + OpCode.Send0 or + OpCode.SSend0) + { + var symbolIndex = sequence[pc + 2]; + if ((uint)symbolIndex < (uint)symbols.Length && + symbols[symbolIndex] == Names.New) + { + return true; + } + } + else if (opCode is OpCode.Block) + { + var childIndex = sequence[pc + 2]; + if ((uint)childIndex < (uint)irep.Children.Length && + IrepContainsNewObjectSend(irep.Children[childIndex])) + { + return true; + } + } + + if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length)) + { + return true; + } + pc += length; + } + + return false; + } + + public static RubyIRMethod? TryCompile(Irep irep, int entryPoint, out RubyIRBuildFailure failure) => + TryCompile(irep, entryPoint, (Dictionary?)null, out failure); + + public static RubyIRMethod? TryCompile( + Irep irep, + int entryPoint, + Dictionary? inlinePlan, + out RubyIRBuildFailure failure) + { + Dictionary? sites = null; + if (inlinePlan is not null) + { + sites = new Dictionary(inlinePlan.Count); + foreach (var (pc, callee) in inlinePlan) + { + sites[pc] = new RubyIRInlineSite(callee); + } + } + + return TryCompile(irep, entryPoint, sites, out failure); + } + + public static RubyIRMethod? TryCompile( + Irep irep, + int entryPoint, + Dictionary? inlinePlan, + out RubyIRBuildFailure failure) + { + failure = RubyIRBuildFailure.None; + + if (entryPoint != 0) + { + failure = new RubyIRBuildFailure(null, entryPoint, "non-zero entry point"); + return null; + } + + if (irep.CatchHandlers.Length != 0) + { + failure = new RubyIRBuildFailure(null, entryPoint, "catch handler"); + return null; + } + + if (!AnalyzeForwardBranches(irep, out var mergeSlotRegisters, out var hasBackwardBranch, out failure)) + { + return null; + } + if (!TryAnalyzeClosureCaptures(irep, out var closureCapturedRegisters, out failure)) + { + return null; + } + for (var i = 0; i < closureCapturedRegisters.Length; i++) + { + if (closureCapturedRegisters[i]) + { + mergeSlotRegisters[i] = true; + } + } + + var builder = new Builder(irep, mergeSlotRegisters, closureCapturedRegisters, hasBackwardBranch); + if (!TryLowerSequence(builder, irep, inlinePlan, out failure)) + { + return null; + } + + if (builder.InstructionCount == 0) + { + failure = new RubyIRBuildFailure(null, entryPoint, "empty method"); + return null; + } + + return builder.TryBuild(out var ir, out failure) ? ir : null; + } + + // Lowers an irep's bytecode body into `builder`. Extracted from TryCompile so + // the same translation can re-lower a callee's body into a caller's builder + // when inlining (the builder applies register/pc offsets transparently). + static bool TryLowerSequence( + Builder builder, + Irep irep, + Dictionary? inlinePlan, + out RubyIRBuildFailure failure) + { + failure = RubyIRBuildFailure.None; + var sequence = irep.Sequence; + var symbols = irep.Symbols; + var pc = 0; + + return LowerLoop(builder, irep, inlinePlan, sequence, symbols, ref pc, out failure); + } + + // Re-lower `site.Callee` into `builder` as an inlined body bound to the + // caller's `receiverRegister`/args. Returns false (without emitting anything) + // when the callee is not inline-safe. v1 conservatively requires the callee + // to compile standalone and to contain no upvars/blocks/catch handlers, so + // re-lowering it inline cannot fail partway through. + // + // When `site.GuardClass` is set, the body is fenced by a GuardInlineClass that + // jumps to the spliced body on a class/method-version match and otherwise + // falls through to a cold copy of the original `methodId` Send. Both paths + // write the shared inline-result slot and merge at the continuation, so the + // inline is speculative-safe: a receiver of a different class deopts cleanly. + static bool TryInlineCallee( + Builder builder, + RubyIRInlineSite site, + int destinationRegister, + ushort receiver, + int firstArgumentRegister, + int argc, + Symbol methodId) + { + var calleeIrep = site.Callee; + if (calleeIrep.CatchHandlers.Length != 0 || + calleeIrep.RegisterVariableCount <= argc || + CalleeHasInlineUnsafeOpcodes(calleeIrep) || + !TryReadCalleeArity(calleeIrep, out var mandatoryArgs, out var bodyPc) || + mandatoryArgs != argc || + TryCompile(calleeIrep, 0, out _) is null || + !AnalyzeForwardBranches(calleeIrep, out var calleeMergeSlots, out var calleeHasBackwardBranch, out _) || + calleeHasBackwardBranch) + { + return false; + } + + Span args = argc == 0 ? default : stackalloc ushort[argc]; + for (var i = 0; i < argc; i++) + { + args[i] = builder.Read(firstArgumentRegister + i); + } + + var guarded = site.GuardClass is not null; + var resultValueId = builder.BeginInline( + calleeIrep.RegisterVariableCount, + calleeMergeSlots, + receiver, + args, + calleeIrep.Sequence.Length, + guarded); + + if (guarded) + { + if (site.GuardMissDeopts) + { + // AOT scalar replacement can then ignore the miss path entirely: + // the generated method returns false and the VM interprets from + // the original bytecode, where all objects are materialized. + builder.EmitInlineGuardDeopt( + receiver, + methodId, + site.GuardClass!, + site.GuardMethodCacheVersion, + site.GuardMethodFingerprint); + } + else + { + // Emit guard + cold Send before the body so the guard, on a match, + // jumps over the cold path into the spliced body (callee pc 0). + builder.EmitInlineGuardAndColdSend( + receiver, + args, + methodId, + site.GuardClass!, + site.GuardMethodCacheVersion, + site.GuardMethodFingerprint, + resultValueId); + } + } + + // Validated above, so this cannot fail; ignore failure. + TryLowerSequence(builder, calleeIrep, null, out _); + builder.EndInline(destinationRegister, resultValueId); + return true; + } + + // Bytecode pc of the first plain (non-self) Send/Send0 to `methodId`, or -1. + // Used to key inline plans by call-site pc. + internal static int FindFirstSendPc(Irep irep, Symbol methodId) + { + var sequence = irep.Sequence; + var symbols = irep.Symbols; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if (opCode is OpCode.Send or OpCode.Send0 && + sequence[pc + 2] < symbols.Length && + symbols[sequence[pc + 2]] == methodId) + { + return pc; + } + + if (!TryGetInstructionLength(sequence, pc, opCode, out var length)) + { + return -1; + } + pc += length; + } + + return -1; + } + + internal static Dictionary? TryBuildSelfInlinePlan( + MRubyState state, + Irep callerIrep, + RClass definingClass, + IReadOnlyDictionary inlineRegistry, + IReadOnlyDictionary? inlineSelectorRegistry = null, + HashSet? candidatePcs = null) + { + if (Environment.GetEnvironmentVariable("AOT_NOSPLICE") == "1") + { + return null; + } + + var noSelfSplice = Environment.GetEnvironmentVariable("AOT_NOSELFSPLICE") == "1"; + var noCrossSplice = Environment.GetEnvironmentVariable("AOT_NOCROSSSPLICE") == "1"; + Dictionary? plan = null; + var sequence = callerIrep.Sequence; + var symbols = callerIrep.Symbols; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if ((candidatePcs is null || candidatePcs.Contains(pc)) && + opCode is OpCode.Send or OpCode.SSend or OpCode.Send0 or OpCode.SSend0 && + sequence[pc + 2] < symbols.Length) + { + var hasArgs = opCode is OpCode.Send or OpCode.SSend; + var argc = hasArgs ? sequence[pc + 3] & 0xf : 0; + var kargc = hasArgs ? (sequence[pc + 3] >> 4) & 0xf : 0; + var methodId = symbols[sequence[pc + 2]]; + if (!noSelfSplice && kargc == 0 && (opCode is OpCode.SSend or OpCode.SSend0) && + state.TryFindMethod(definingClass, methodId, out var selfMethod, out _) && + selfMethod.Proc is { } selfProc) + { + var fp = state.ComputeIrepFingerprint(selfProc.Irep); + if (inlineRegistry.TryGetValue(fp, out var calleeArgc) && calleeArgc == argc) + { + plan ??= new Dictionary(); + plan[pc] = new RubyIRInlineSite( + selfProc.Irep, + definingClass, + guardMethodCacheVersion: 0, + guardMethodFingerprint: fp, + guardMissDeopts: true); + } + } + else if (!noCrossSplice && kargc == 0 && (opCode is OpCode.Send or OpCode.Send0) && + inlineSelectorRegistry is not null && + inlineSelectorRegistry.TryGetValue(methodId, out var target) && + target.ArgCount == argc) + { + plan ??= new Dictionary(); + plan[pc] = new RubyIRInlineSite( + target.Irep, + target.DefiningClass, + guardMethodCacheVersion: 0, + guardMethodFingerprint: target.Fingerprint, + guardMissDeopts: true); + } + } + + if (!TryGetInstructionLength(sequence, pc, opCode, out var length)) + { + return plan; + } + pc += length; + } + + return plan; + } + + static bool TryReadCalleeArity(Irep calleeIrep, out int mandatoryArgs, out int bodyPc) + { + mandatoryArgs = 0; + bodyPc = 0; + var sequence = calleeIrep.Sequence; + if (sequence.Length == 0 || (OpCode)sequence[0] != OpCode.Enter) + { + return false; + } + + var aspec = new ArgumentSpec(ReadUInt24(sequence, 1)); + if (aspec.OptionalArgumentsCount != 0 || + aspec.TakeRestArguments || + aspec.MandatoryArguments2Count != 0 || + aspec.KeywordArgumentsCount != 0 || + aspec.TakeKeywordDict || + aspec.TakeBlock) + { + return false; + } + + mandatoryArgs = aspec.MandatoryArguments1Count; + bodyPc = 4; + return true; + } + + // Inlining re-binds the callee's self/args to caller values, so a callee that + // reads an enclosing scope (upvars) or creates closures cannot be inlined as + // a flat body without breaking those scope references. + // True if the callee body contains a (forward) branch. SSA inline splicing is + // restricted to such callees: a straight-line callee is already served by the + // trivial-getter / expression-tree InlineBody path or a guarded direct call, + // so splicing only adds the capability those paths lack — inlining branches. + internal static bool CalleeHasControlFlow(Irep irep) + { + var sequence = irep.Sequence; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if (opCode is OpCode.Jmp or OpCode.JmpIf or OpCode.JmpNot or OpCode.JmpNil) + { + return true; + } + + if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length)) + { + return false; + } + pc += length; + } + + return false; + } + + static bool CalleeHasInlineUnsafeOpcodes(Irep irep) + { + var sequence = irep.Sequence; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if (opCode is OpCode.GetUpVar or OpCode.SetUpVar or OpCode.Block or + OpCode.SendB or OpCode.SSendB) + { + return true; + } + + if (!TryGetInstructionLengthForControlScan(sequence, pc, opCode, out var length)) + { + return true; + } + pc += length; + } + + return false; + } + + static bool LowerLoop( + Builder builder, + Irep irep, + Dictionary? inlinePlan, + byte[] sequence, + Symbol[] symbols, + ref int pc, + out RubyIRBuildFailure failure) + { + failure = RubyIRBuildFailure.None; + + while (pc < sequence.Length) + { + builder.MarkBytecodePc(pc); + var opCode = (OpCode)sequence[pc]; + switch (opCode) + { + case OpCode.Nop: + pc += 1; + break; + case OpCode.Enter: + { + var bits = ReadUInt24(sequence, pc + 1); + var aspec = new ArgumentSpec(bits); + if (aspec.OptionalArgumentsCount != 0 || + aspec.TakeRestArguments || + aspec.MandatoryArguments2Count != 0 || + aspec.KeywordArgumentsCount != 0 || + aspec.TakeKeywordDict || + aspec.TakeBlock || + aspec.MandatoryArguments1Count >= MRubyCallInfo.CallMaxArgs) + { + failure = new RubyIRBuildFailure(opCode, pc, "complex argument spec"); + return false; + } + // When inlining, arguments are already bound to the caller's + // values, so the callee's arity check is unnecessary. + if (!builder.Inlining) + { + builder.Add(new RubyIRInstruction( + RubyIROpCode.CheckArity, + aux: aspec.MandatoryArguments1Count)); + } + pc += 4; + break; + } + case OpCode.Move: + builder.Move(sequence[pc + 1], sequence[pc + 2]); + pc += 3; + break; + case OpCode.LoadL: + builder.DefineValue(sequence[pc + 1], irep.PoolValues[sequence[pc + 2]]); + pc += 3; + break; + case OpCode.LoadI8: + builder.DefineValue(sequence[pc + 1], new MRubyValue(sequence[pc + 2])); + pc += 3; + break; + case OpCode.LoadINeg: + builder.DefineValue(sequence[pc + 1], new MRubyValue(-sequence[pc + 2])); + pc += 3; + break; + case OpCode.LoadI__1: + case OpCode.LoadI_0: + case OpCode.LoadI_1: + case OpCode.LoadI_2: + case OpCode.LoadI_3: + case OpCode.LoadI_4: + case OpCode.LoadI_5: + case OpCode.LoadI_6: + case OpCode.LoadI_7: + builder.DefineValue( + sequence[pc + 1], + new MRubyValue((int)opCode - (int)OpCode.LoadI_0)); + pc += 2; + break; + case OpCode.LoadI16: + builder.DefineValue( + sequence[pc + 1], + new MRubyValue(unchecked((short)ReadUInt16(sequence, pc + 2)))); + pc += 4; + break; + case OpCode.LoadI32: + builder.DefineValue( + sequence[pc + 1], + new MRubyValue(unchecked((int)ReadUInt32FromTwoUInt16(sequence, pc + 2)))); + pc += 6; + break; + case OpCode.LoadSym: + builder.DefineValue(sequence[pc + 1], new MRubyValue(symbols[sequence[pc + 2]])); + pc += 3; + break; + case OpCode.LoadNil: + builder.DefineValue(sequence[pc + 1], MRubyValue.Nil); + pc += 2; + break; + case OpCode.LoadSelf: + builder.DefineSelf(sequence[pc + 1]); + pc += 2; + break; + case OpCode.GetUpVar: + builder.DefineUpVar( + sequence[pc + 1], + sequence[pc + 2], + sequence[pc + 3]); + pc += 4; + break; + case OpCode.SetUpVar: + builder.SetUpVar( + sequence[pc + 1], + sequence[pc + 2], + sequence[pc + 3]); + pc += 4; + break; + case OpCode.GetConst: + builder.DefineSymbolOp( + RubyIROpCode.GetConstant, + sequence[pc + 1], + symbols[sequence[pc + 2]]); + pc += 3; + break; + case OpCode.GetMCnst: + builder.DefineSymbolOp( + RubyIROpCode.GetModuleConstant, + sequence[pc + 1], + symbols[sequence[pc + 2]], + builder.Read(sequence[pc + 1])); + pc += 3; + break; + case OpCode.LoadT: + builder.DefineValue(sequence[pc + 1], MRubyValue.True); + pc += 2; + break; + case OpCode.LoadF: + builder.DefineValue(sequence[pc + 1], MRubyValue.False); + pc += 2; + break; + case OpCode.GetIV: + builder.DefineSymbolOp( + RubyIROpCode.GetInstanceVariable, + sequence[pc + 1], + symbols[sequence[pc + 2]], + builder.Self); + pc += 3; + break; + case OpCode.SetIV: + builder.Add(new RubyIRInstruction( + RubyIROpCode.SetInstanceVariable, + src0: builder.Self, + src1: builder.Read(sequence[pc + 1]), + aux: builder.Symbol(symbols[sequence[pc + 2]]))); + pc += 3; + break; + case OpCode.GetIdx: + builder.DefineBinaryOp( + RubyIROpCode.GetIndex, + sequence[pc + 1], + sequence[pc + 1], + sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.GetIdx0: + builder.DefineOp( + RubyIROpCode.GetIndex0, + sequence[pc + 1], + builder.Read(sequence[pc + 2])); + pc += 3; + break; + case OpCode.SetIdx: + builder.DefineTernaryOp( + RubyIROpCode.SetIndex, + sequence[pc + 1], + sequence[pc + 1], + sequence[pc + 1] + 1, + sequence[pc + 1] + 2); + pc += 2; + break; + case OpCode.Jmp: + // JmpUw (`break` out of a while loop) is a plain unconditional jump in a + // catch-handler-free method (TryCompile requires no catch handlers, so the VM's + // ensure-unwind branch never fires) — lower it identically to Jmp. + case OpCode.JmpUw: + { + // Backward targets (loops) are accepted: the codegen lowers them to a C# `goto` + // and AnalyzeForwardBranches has already forced every register to a merge slot + // (in-place boxed value-id) so loop-carried values round-trip correctly. + var targetPc = pc + 3 + unchecked((short)ReadUInt16(sequence, pc + 1)); + builder.AddBranch(RubyIROpCode.Jump, targetPc); + pc += 3; + break; + } + case OpCode.JmpIf: + { + var targetPc = ResolveShortCircuitBranchTarget( + sequence, + opCode, + sequence[pc + 1], + pc + 4 + unchecked((short)ReadUInt16(sequence, pc + 2))); + builder.AddBranch(RubyIROpCode.JumpIfTruthy, targetPc, builder.Read(sequence[pc + 1])); + pc += 4; + break; + } + case OpCode.JmpNot: + { + var targetPc = ResolveShortCircuitBranchTarget( + sequence, + opCode, + sequence[pc + 1], + pc + 4 + unchecked((short)ReadUInt16(sequence, pc + 2))); + builder.AddBranch(RubyIROpCode.JumpIfFalsy, targetPc, builder.Read(sequence[pc + 1])); + pc += 4; + break; + } + case OpCode.JmpNil: + { + var targetPc = ResolveShortCircuitBranchTarget( + sequence, + opCode, + sequence[pc + 1], + pc + 4 + unchecked((short)ReadUInt16(sequence, pc + 2))); + builder.AddBranch(RubyIROpCode.JumpIfNil, targetPc, builder.Read(sequence[pc + 1])); + pc += 4; + break; + } + case OpCode.Send: + case OpCode.SSend: + if (opCode == OpCode.Send && + inlinePlan is not null && + inlinePlan.TryGetValue(pc, out var sendSite) && + TryInlineCallee( + builder, + sendSite, + sequence[pc + 1], + builder.Read(sequence[pc + 1]), + sequence[pc + 1] + 1, + sequence[pc + 3] & 0xf, + symbols[sequence[pc + 2]])) + { + pc += 4; + break; + } + if (opCode == OpCode.SSend && + inlinePlan is not null && + inlinePlan.TryGetValue(pc, out var selfSendSite) && + TryInlineCallee( + builder, + selfSendSite, + sequence[pc + 1], + builder.Self, + sequence[pc + 1] + 1, + sequence[pc + 3] & 0xf, + symbols[sequence[pc + 2]])) + { + pc += 4; + break; + } + builder.DefineSend( + opCode == OpCode.Send ? RubyIROpCode.Send : RubyIROpCode.SendSelf, + sequence[pc + 1], + sequence[pc + 3] & 0xf, + symbols[sequence[pc + 2]]); + pc += 4; + break; + case OpCode.SendB: + case OpCode.SSendB: + { + var register = sequence[pc + 1]; + var callFlags = sequence[pc + 3]; + var argc = callFlags & 0xf; + var kargc = (callFlags >> 4) & 0xf; + if (argc >= MRubyCallInfo.CallMaxArgs || kargc != 0) + { + failure = new RubyIRBuildFailure(opCode, pc, "complex block send"); + return false; + } + + var blockRegister = register + MRubyCallInfo.CalculateBlockArgumentOffset(argc, kargc); + builder.DefineSendBlock( + opCode == OpCode.SendB ? RubyIROpCode.SendBlock : RubyIROpCode.SendSelfBlock, + register, + argc, + blockRegister, + symbols[sequence[pc + 2]]); + pc += 4; + break; + } + case OpCode.Send0: + case OpCode.SSend0: + if (opCode == OpCode.Send0 && + inlinePlan is not null && + inlinePlan.TryGetValue(pc, out var send0Site) && + TryInlineCallee( + builder, + send0Site, + sequence[pc + 1], + builder.Read(sequence[pc + 1]), + sequence[pc + 1] + 1, + 0, + symbols[sequence[pc + 2]])) + { + pc += 3; + break; + } + if (opCode == OpCode.SSend0 && + inlinePlan is not null && + inlinePlan.TryGetValue(pc, out var selfSend0Site) && + TryInlineCallee( + builder, + selfSend0Site, + sequence[pc + 1], + builder.Self, + sequence[pc + 1] + 1, + 0, + symbols[sequence[pc + 2]])) + { + pc += 3; + break; + } + builder.DefineSend( + opCode == OpCode.Send0 ? RubyIROpCode.Send : RubyIROpCode.SendSelf, + sequence[pc + 1], + 0, + symbols[sequence[pc + 2]]); + pc += 3; + break; + case OpCode.Add: + builder.DefineBinaryOp(RubyIROpCode.Add, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.AddI: + builder.DefineImmediateOp( + RubyIROpCode.AddImmediate, + sequence[pc + 1], + sequence[pc + 1], + new MRubyValue(sequence[pc + 2])); + pc += 3; + break; + case OpCode.AddILV: + builder.DefineImmediateOp( + RubyIROpCode.AddImmediate, + sequence[pc + 1], + sequence[pc + 1], + new MRubyValue(sequence[pc + 3])); + pc += 4; + break; + case OpCode.Sub: + builder.DefineBinaryOp(RubyIROpCode.Sub, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.SubI: + builder.DefineImmediateOp( + RubyIROpCode.SubImmediate, + sequence[pc + 1], + sequence[pc + 1], + new MRubyValue(sequence[pc + 2])); + pc += 3; + break; + case OpCode.SubILV: + builder.DefineImmediateOp( + RubyIROpCode.SubImmediate, + sequence[pc + 1], + sequence[pc + 1], + new MRubyValue(sequence[pc + 3])); + pc += 4; + break; + case OpCode.Mul: + builder.DefineBinaryOp(RubyIROpCode.Mul, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.Div: + builder.DefineBinaryOp(RubyIROpCode.Div, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.EQ: + builder.DefineBinaryOp(RubyIROpCode.Eq, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.LT: + builder.DefineBinaryOp(RubyIROpCode.Lt, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.LE: + builder.DefineBinaryOp(RubyIROpCode.Le, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.GT: + builder.DefineBinaryOp(RubyIROpCode.Gt, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.GE: + builder.DefineBinaryOp(RubyIROpCode.Ge, sequence[pc + 1], sequence[pc + 1], sequence[pc + 1] + 1); + pc += 2; + break; + case OpCode.Array: + builder.DefineArray(sequence[pc + 1], sequence[pc + 1], sequence[pc + 2]); + pc += 3; + break; + case OpCode.Array2: + builder.DefineArray(sequence[pc + 1], sequence[pc + 2], sequence[pc + 3]); + pc += 4; + break; + case OpCode.Hash: + // OP_HASH R B: build a hash from B key/value pairs at R..R+2B-1, result in R. + builder.DefineHash(sequence[pc + 1], sequence[pc + 2]); + pc += 3; + break; + case OpCode.ARef: + builder.DefineOp( + RubyIROpCode.ArrayRef, + sequence[pc + 1], + builder.Read(sequence[pc + 2]), + aux: sequence[pc + 3]); + pc += 4; + break; + case OpCode.ASet: + builder.Add(new RubyIRInstruction( + RubyIROpCode.ArraySet, + src0: builder.Read(sequence[pc + 2]), + src1: builder.Read(sequence[pc + 1]), + aux: sequence[pc + 3])); + pc += 4; + break; + case OpCode.Block: + if (sequence[pc + 2] >= irep.Children.Length) + { + failure = new RubyIRBuildFailure(opCode, pc, "child irep out of range"); + return false; + } + + var child = irep.Children[sequence[pc + 2]]; + if (BlockChildNeedsBytecodeBoundary(child, out var boundaryReason)) + { + failure = new RubyIRBuildFailure(opCode, pc, boundaryReason); + return false; + } + + var nextPc = pc + 3; + if ((uint)(nextPc + 3) < (uint)sequence.Length && + (OpCode)sequence[nextPc] is OpCode.SendB or OpCode.SSendB) + { + var sendOpCode = (OpCode)sequence[nextPc]; + var sendRegister = sequence[nextPc + 1]; + var sendCallFlags = sequence[nextPc + 3]; + var sendArgc = sendCallFlags & 0xf; + var sendKargc = (sendCallFlags >> 4) & 0xf; + if (sendArgc < MRubyCallInfo.CallMaxArgs && sendKargc == 0) + { + var blockRegister = sendRegister + MRubyCallInfo.CalculateBlockArgumentOffset(sendArgc, sendKargc); + if (blockRegister == sequence[pc + 1]) + { + builder.DefineSendBlockDescriptor( + sendOpCode == OpCode.SendB + ? RubyIROpCode.SendBlockDescriptor + : RubyIROpCode.SendSelfBlockDescriptor, + sendRegister, + sendArgc, + child, + symbols[sequence[nextPc + 2]]); + pc += 7; + break; + } + } + } + + builder.DefineBlock(sequence[pc + 1], child); + pc += 3; + break; + case OpCode.Return: + builder.EmitReturn(sequence[pc + 1]); + pc += 2; + break; + case OpCode.RetSelf: + builder.EmitReturnSelf(); + pc += 1; + break; + case OpCode.RetNil: + builder.EmitReturnLiteral(MRubyValue.Nil); + pc += 1; + break; + case OpCode.RetTrue: + builder.EmitReturnLiteral(MRubyValue.True); + pc += 1; + break; + case OpCode.RetFalse: + builder.EmitReturnLiteral(MRubyValue.False); + pc += 1; + break; + default: + failure = new RubyIRBuildFailure(opCode, pc, "unsupported opcode"); + return false; + } + } + + builder.MarkBytecodePc(sequence.Length); + return true; + } + + static uint ReadUInt16(byte[] sequence, int offset) => + (uint)((sequence[offset] << 8) | sequence[offset + 1]); + + static uint ReadUInt24(byte[] sequence, int offset) => + ((uint)sequence[offset] << 16) | ((uint)sequence[offset + 1] << 8) | sequence[offset + 2]; + + static uint ReadUInt32FromTwoUInt16(byte[] sequence, int offset) => + (ReadUInt16(sequence, offset) << 16) | ReadUInt16(sequence, offset + 2); + + static bool AnalyzeForwardBranches( + Irep irep, + out bool[] mergeSlotRegisters, + out bool hasBackwardBranch, + out RubyIRBuildFailure failure) + { + failure = RubyIRBuildFailure.None; + hasBackwardBranch = false; + + var sequence = irep.Sequence; + var registerCount = irep.RegisterVariableCount; + mergeSlotRegisters = new bool[registerCount]; + var pc = 0; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if (!TryGetInstructionLength(sequence, pc, opCode, out var length)) + { + failure = new RubyIRBuildFailure(opCode, pc, "unsupported opcode"); + return false; + } + + var nextPc = pc + length; + switch (opCode) + { + case OpCode.Jmp: + case OpCode.JmpUw: + { + var targetPc = nextPc + unchecked((short)ReadUInt16(sequence, pc + 1)); + // A backward target is a loop back-edge. The forward-range merge-slot analysis + // only models conditionally-skipped FORWARD ranges, so it can't run here; instead + // we flag the method as looping and (below) force every register to a merge slot, + // which gives each one a single in-place boxed value-id — exactly what a + // loop-carried register needs (header reads it, body rewrites it). + if (targetPc < nextPc) + { + hasBackwardBranch = true; + break; + } + if (!AnalyzeForwardBranchRange( + sequence, + registerCount, + mergeSlotRegisters, + nextPc, + targetPc, + opCode, + pc, + out failure)) + { + return false; + } + break; + } + case OpCode.JmpIf: + case OpCode.JmpNot: + case OpCode.JmpNil: + { + var targetPc = ResolveShortCircuitBranchTarget( + sequence, + opCode, + sequence[pc + 1], + nextPc + unchecked((short)ReadUInt16(sequence, pc + 2))); + if (targetPc < nextPc) + { + hasBackwardBranch = true; + break; + } + if (!AnalyzeForwardBranchRange( + sequence, + registerCount, + mergeSlotRegisters, + nextPc, + targetPc, + opCode, + pc, + out failure)) + { + return false; + } + break; + } + } + + pc = nextPc; + } + + if (hasBackwardBranch) + { + // Non-SSA, fully-boxed loop model: every register becomes a merge slot so the builder + // emits one value-id per register, reassigned in place across the back-edge. + for (var r = 0; r < mergeSlotRegisters.Length; r++) + { + mergeSlotRegisters[r] = true; + } + } + + return true; + } + + static int ResolveShortCircuitBranchTarget( + byte[] sequence, + OpCode opCode, + int conditionRegister, + int targetPc) + { + while (targetPc >= 0 && + targetPc + 4 <= sequence.Length && + (OpCode)sequence[targetPc] == opCode && + sequence[targetPc + 1] == conditionRegister) + { + var nextTargetPc = targetPc + 4 + unchecked((short)ReadUInt16(sequence, targetPc + 2)); + if (nextTargetPc <= targetPc) + { + break; + } + + targetPc = nextTargetPc; + } + + return targetPc; + } + + static bool AnalyzeForwardBranchRange( + byte[] sequence, + int registerCount, + bool[] mergeSlotRegisters, + int startPc, + int targetPc, + OpCode branchOpCode, + int branchPc, + out RubyIRBuildFailure failure) + { + failure = RubyIRBuildFailure.None; + if (targetPc < startPc) + { + failure = new RubyIRBuildFailure(branchOpCode, branchPc, "backward branch"); + return false; + } + + if (targetPc > sequence.Length) + { + failure = new RubyIRBuildFailure(branchOpCode, branchPc, "branch target out of range"); + return false; + } + + if (targetPc == startPc) + { + return true; + } + + var definedInSkippedRange = new bool[registerCount]; + var pc = startPc; + while (pc < targetPc) + { + var opCode = (OpCode)sequence[pc]; + if (!TryGetInstructionLength(sequence, pc, opCode, out var length) || + pc + length > targetPc || + !TryGetRegisterAccesses(sequence, pc, opCode, out _, out var write0, out var write1)) + { + failure = new RubyIRBuildFailure(opCode, pc, "unsupported branch body"); + return false; + } + + MarkDefined(definedInSkippedRange, write0); + MarkDefined(definedInSkippedRange, write1); + pc += length; + } + + for (var register = 0; register < definedInSkippedRange.Length; register++) + { + if (definedInSkippedRange[register] && + IsRegisterReadBeforeWrite(sequence, targetPc, register, out _, out _)) + { + mergeSlotRegisters[register] = true; + } + } + + return true; + } + + static bool IsRegisterReadBeforeWrite( + byte[] sequence, + int startPc, + int register, + out int readPc, + out OpCode readOpCode) + { + var pc = startPc; + while (pc < sequence.Length) + { + var opCode = (OpCode)sequence[pc]; + if (!TryGetInstructionLength(sequence, pc, opCode, out var length) || + !TryGetRegisterAccesses(sequence, pc, opCode, out var reads, out var write0, out var write1)) + { + break; + } + + if (ReadsRegister(reads, register)) + { + readPc = pc; + readOpCode = opCode; + return true; + } + + if (write0 == register || write1 == register) + { + break; + } + + pc += length; + } + + readPc = -1; + readOpCode = default; + return false; + } + + static void MarkDefined(bool[] registers, int register) + { + if ((uint)register < (uint)registers.Length) + { + registers[register] = true; + } + } + + static bool ReadsRegister(RegisterReads reads, int register) + { + for (var i = 0; i < reads.Count; i++) + { + if (reads[i] == register) + { + return true; + } + } + + return false; + } + + static bool TryGetInstructionLength(byte[] sequence, int pc, OpCode opCode, out int length) + { + length = opCode switch + { + OpCode.Nop or + OpCode.Call or + OpCode.RetSelf or + OpCode.RetNil or + OpCode.RetTrue or + OpCode.RetFalse => 1, + OpCode.LoadI__1 or + OpCode.LoadI_0 or + OpCode.LoadI_1 or + OpCode.LoadI_2 or + OpCode.LoadI_3 or + OpCode.LoadI_4 or + OpCode.LoadI_5 or + OpCode.LoadI_6 or + OpCode.LoadI_7 or + OpCode.LoadNil or + OpCode.LoadSelf or + OpCode.LoadT or + OpCode.LoadF or + OpCode.GetIdx or + OpCode.SetIdx or + OpCode.Add or + OpCode.Sub or + OpCode.Mul or + OpCode.Div or + OpCode.EQ or + OpCode.LT or + OpCode.LE or + OpCode.GT or + OpCode.GE or + OpCode.Return => 2, + OpCode.Move or + OpCode.LoadL or + OpCode.LoadI8 or + OpCode.LoadINeg or + OpCode.LoadSym or + OpCode.GetConst or + OpCode.GetMCnst or + OpCode.GetIV or + OpCode.SetIV or + OpCode.GetIdx0 or + OpCode.AddI or + OpCode.SubI or + OpCode.Array or + OpCode.Block or + OpCode.Send0 or + OpCode.SSend0 or + OpCode.Hash or + OpCode.Jmp or + OpCode.JmpUw => 3, + OpCode.Enter or + OpCode.LoadI16 or + OpCode.GetUpVar or + OpCode.SetUpVar or + OpCode.Send or + OpCode.SSend or + OpCode.SendB or + OpCode.SSendB or + OpCode.AddILV or + OpCode.SubILV or + OpCode.Array2 or + OpCode.ARef or + OpCode.ASet or + OpCode.JmpIf or + OpCode.JmpNot or + OpCode.JmpNil => 4, + OpCode.LoadI32 => 6, + _ => 0 + }; + + return length != 0 && pc + length <= sequence.Length; + } + + static bool TryGetInstructionLengthForControlScan(byte[] sequence, int pc, OpCode opCode, out int length) + { + length = opCode switch + { + OpCode.Nop or + OpCode.Call or + OpCode.KeyEnd or + OpCode.RetSelf or + OpCode.RetNil or + OpCode.RetTrue or + OpCode.RetFalse or + OpCode.Stop or + OpCode.EXT1 or + OpCode.EXT2 or + OpCode.EXT3 => 1, + OpCode.LoadI__1 or + OpCode.LoadI_0 or + OpCode.LoadI_1 or + OpCode.LoadI_2 or + OpCode.LoadI_3 or + OpCode.LoadI_4 or + OpCode.LoadI_5 or + OpCode.LoadI_6 or + OpCode.LoadI_7 or + OpCode.LoadNil or + OpCode.LoadSelf or + OpCode.LoadT or + OpCode.LoadF or + OpCode.GetIdx or + OpCode.SetIdx or + OpCode.Add or + OpCode.Sub or + OpCode.Mul or + OpCode.Div or + OpCode.EQ or + OpCode.LT or + OpCode.LE or + OpCode.GT or + OpCode.GE or + OpCode.Return or + OpCode.ReturnBlk or + OpCode.Break or + OpCode.AryCat or + OpCode.ArySplat or + OpCode.Intern or + OpCode.StrCat or + OpCode.HashCat or + OpCode.RangeInc or + OpCode.RangeExc or + OpCode.OClass or + OpCode.SClass or + OpCode.TClass or + OpCode.Err or + OpCode.Except or + OpCode.RaiseIf or + OpCode.MatchErr or + OpCode.Undef => 2, + OpCode.Move or + OpCode.LoadL or + OpCode.LoadI8 or + OpCode.LoadINeg or + OpCode.LoadSym or + OpCode.GetGV or + OpCode.SetGV or + OpCode.GetSV or + OpCode.SetSV or + OpCode.GetIV or + OpCode.SetIV or + OpCode.GetCV or + OpCode.SetCV or + OpCode.GetConst or + OpCode.SetConst or + OpCode.GetMCnst or + OpCode.SetMCnst or + OpCode.GetIdx0 or + OpCode.AddI or + OpCode.SubI or + OpCode.Jmp or + OpCode.JmpUw or + OpCode.SSend0 or + OpCode.Send0 or + OpCode.BlkCall or + OpCode.Super or + OpCode.KeyP or + OpCode.KArg or + OpCode.BlkPush or + OpCode.Array or + OpCode.AryPush or + OpCode.Symbol or + OpCode.String or + OpCode.Hash or + OpCode.HashAdd or + OpCode.Lambda or + OpCode.Block or + OpCode.Method or + OpCode.Class or + OpCode.Module or + OpCode.Exec or + OpCode.Def or + OpCode.Alias => 3, + OpCode.Enter or + OpCode.LoadI16 or + OpCode.GetUpVar or + OpCode.SetUpVar or + OpCode.JmpIf or + OpCode.JmpNot or + OpCode.JmpNil or + OpCode.SSend or + OpCode.SSendB or + OpCode.Send or + OpCode.SendB or + OpCode.ArgAry or + OpCode.AddILV or + OpCode.SubILV or + OpCode.Array2 or + OpCode.ARef or + OpCode.ASet or + OpCode.APost or + OpCode.Rescue or + OpCode.TDef or + OpCode.SDef or + OpCode.Debug => 4, + OpCode.LoadI32 => 6, + _ => 0 + }; + + return length != 0 && pc + length <= sequence.Length; + } + + static bool TryGetRegisterAccesses( + byte[] sequence, + int pc, + OpCode opCode, + out RegisterReads reads, + out int write0, + out int write1) + { + reads = default; + write0 = -1; + write1 = -1; + + switch (opCode) + { + case OpCode.Nop: + case OpCode.Enter: + case OpCode.Jmp: + // JmpUw (jump-with-unwind, emitted for `break` out of a while loop) is an + // unconditional jump with no register effects. In a catch-handler-free method + // (TryCompile requires that) it degenerates to a plain jump. + case OpCode.JmpUw: + case OpCode.RetNil: + case OpCode.RetTrue: + case OpCode.RetFalse: + return true; + case OpCode.Move: + reads.Add(sequence[pc + 2]); + write0 = sequence[pc + 1]; + return true; + case OpCode.LoadL: + case OpCode.LoadI8: + case OpCode.LoadINeg: + case OpCode.LoadSym: + case OpCode.GetConst: + case OpCode.LoadI16: + case OpCode.LoadI32: + case OpCode.LoadI__1: + case OpCode.LoadI_0: + case OpCode.LoadI_1: + case OpCode.LoadI_2: + case OpCode.LoadI_3: + case OpCode.LoadI_4: + case OpCode.LoadI_5: + case OpCode.LoadI_6: + case OpCode.LoadI_7: + case OpCode.LoadNil: + case OpCode.LoadSelf: + case OpCode.LoadT: + case OpCode.LoadF: + write0 = sequence[pc + 1]; + return true; + case OpCode.GetMCnst: + reads.Add(sequence[pc + 1]); + write0 = sequence[pc + 1]; + return true; + case OpCode.GetIV: + reads.Add(0); + write0 = sequence[pc + 1]; + return true; + case OpCode.GetUpVar: + write0 = sequence[pc + 1]; + return true; + case OpCode.SetIV: + reads.Add(0); + reads.Add(sequence[pc + 1]); + return true; + case OpCode.SetUpVar: + reads.Add(sequence[pc + 1]); + return true; + case OpCode.GetIdx: + reads.Add(sequence[pc + 1]); + reads.Add(sequence[pc + 1] + 1); + write0 = sequence[pc + 1]; + return true; + case OpCode.GetIdx0: + reads.Add(sequence[pc + 2]); + write0 = sequence[pc + 1]; + return true; + case OpCode.SetIdx: + reads.Add(sequence[pc + 1]); + reads.Add(sequence[pc + 1] + 1); + reads.Add(sequence[pc + 1] + 2); + write0 = sequence[pc + 1]; + return true; + case OpCode.Send: + case OpCode.SSend: + { + var register = sequence[pc + 1]; + var argumentCount = sequence[pc + 3] & 0xf; + reads.Add(opCode == OpCode.SSend ? 0 : register); + for (var i = 0; i < argumentCount; i++) + { + reads.Add(register + 1 + i); + } + write0 = register; + return true; + } + case OpCode.SendB: + case OpCode.SSendB: + { + var register = sequence[pc + 1]; + var argumentCount = sequence[pc + 3] & 0xf; + var keywordArgumentCount = (sequence[pc + 3] >> 4) & 0xf; + if (argumentCount >= MRubyCallInfo.CallMaxArgs || keywordArgumentCount != 0) + { + return false; + } + + reads.Add(opCode == OpCode.SSendB ? 0 : register); + for (var i = 0; i < argumentCount; i++) + { + reads.Add(register + 1 + i); + } + reads.Add(register + MRubyCallInfo.CalculateBlockArgumentOffset(argumentCount, keywordArgumentCount)); + write0 = register; + return true; + } + case OpCode.Send0: + case OpCode.SSend0: + { + var register = sequence[pc + 1]; + reads.Add(opCode == OpCode.SSend0 ? 0 : register); + write0 = register; + return true; + } + case OpCode.Add: + case OpCode.Sub: + case OpCode.Mul: + case OpCode.Div: + case OpCode.EQ: + case OpCode.LT: + case OpCode.LE: + case OpCode.GT: + case OpCode.GE: + reads.Add(sequence[pc + 1]); + reads.Add(sequence[pc + 1] + 1); + write0 = sequence[pc + 1]; + return true; + case OpCode.AddI: + case OpCode.SubI: + reads.Add(sequence[pc + 1]); + write0 = sequence[pc + 1]; + return true; + case OpCode.AddILV: + case OpCode.SubILV: + reads.Add(sequence[pc + 1]); + write0 = sequence[pc + 1]; + return true; + case OpCode.Array: + { + var start = sequence[pc + 1]; + var count = sequence[pc + 2]; + for (var i = 0; i < count; i++) + { + reads.Add(start + i); + } + write0 = start; + return true; + } + case OpCode.Array2: + { + var start = sequence[pc + 2]; + var count = sequence[pc + 3]; + for (var i = 0; i < count; i++) + { + reads.Add(start + i); + } + write0 = sequence[pc + 1]; + return true; + } + case OpCode.ARef: + reads.Add(sequence[pc + 2]); + write0 = sequence[pc + 1]; + return true; + case OpCode.ASet: + reads.Add(sequence[pc + 2]); + reads.Add(sequence[pc + 1]); + return true; + case OpCode.Block: + write0 = sequence[pc + 1]; + return true; + case OpCode.Hash: + { + var start = sequence[pc + 1]; + var pairs = sequence[pc + 2]; + for (var i = 0; i < pairs * 2; i++) reads.Add(start + i); + write0 = start; + return true; + } + case OpCode.Return: + case OpCode.JmpIf: + case OpCode.JmpNot: + case OpCode.JmpNil: + reads.Add(sequence[pc + 1]); + return true; + case OpCode.RetSelf: + reads.Add(0); + return true; + default: + return false; + } + } + + struct RegisterReads + { + int count; + int value0; + int value1; + int value2; + int value3; + List? overflow; + + public int Count => count; + + public int this[int index] + { + get + { + return index switch + { + 0 => value0, + 1 => value1, + 2 => value2, + 3 => value3, + _ => overflow![index - 4], + }; + } + } + + public void Add(int register) + { + switch (count) + { + case 0: + value0 = register; + break; + case 1: + value1 = register; + break; + case 2: + value2 = register; + break; + case 3: + value3 = register; + break; + default: + overflow ??= new List(); + overflow.Add(register); + break; + } + + count++; + } + } + + sealed class Builder + { + readonly List instructions; + readonly List literals = new(); + readonly List children = new(); + readonly List symbols = new(); + readonly List operands = new(); + readonly List callSites = new(); + readonly List operandLists = new(); + readonly Dictionary bytecodePcToInstructionIndex = new(); + readonly List branchFixups = new(); + readonly Irep sourceIrep; + bool[] mergeSlotRegisters; + readonly ushort[] closureCapturedValueIds; + ushort[] currentValues; + readonly int registerCount; + readonly bool hasBackwardBranch; + bool hasMergeSlots; + ushort nextValue; + int maxScratchCount; + // Register / bytecode-pc offsets applied transparently by Read/Define/ + // MarkBytecodePc/AddBranch. Zero for the top-level method; set to a + // callee's reserved region while re-lowering it for inlining so the + // callee's registers and branch targets live in a disjoint namespace. + int registerBase; + int pcBase; + // Inline-return state: while re-lowering a callee, its Return is emitted + // as a Move of the result into inlineResultValueId followed by a Jump to + // inlineContinuationPc (callee-relative), instead of a Return opcode. + bool inlining; + ushort inlineResultValueId; + int inlineContinuationPc; + int nextInlinePcBase = 1_000_000; + + public bool Inlining => inlining; + + public Builder(Irep irep, bool[] mergeSlotRegisters, bool[] closureCapturedRegisters, bool hasBackwardBranch = false) + { + sourceIrep = irep; + registerCount = irep.RegisterVariableCount; + instructions = new List(irep.Sequence.Length / 2); + this.mergeSlotRegisters = mergeSlotRegisters; + this.hasBackwardBranch = hasBackwardBranch; + closureCapturedValueIds = GetClosureCapturedValueIds(closureCapturedRegisters); + for (var i = 0; i < mergeSlotRegisters.Length; i++) + { + if (mergeSlotRegisters[i]) + { + hasMergeSlots = true; + break; + } + } + currentValues = new ushort[registerCount]; + for (var i = 0; i < currentValues.Length; i++) + { + currentValues[i] = (ushort)i; + } + nextValue = (ushort)registerCount; + } + + public int InstructionCount => instructions.Count; + public ushort Self => Read(0); + + public void MarkBytecodePc(int pc) + { + bytecodePcToInstructionIndex[pcBase + pc] = instructions.Count; + } + + public void Add(RubyIRInstruction instruction) + { + instructions.Add(instruction); + } + + public void AddBranch(RubyIROpCode opCode, int targetPc, ushort condition = 0) + { + var instructionIndex = instructions.Count; + instructions.Add(new RubyIRInstruction(opCode, src0: condition)); + branchFixups.Add(new BranchFixup(instructionIndex, pcBase + targetPc)); + } + + public ushort Read(int register) + { + var index = registerBase + register; + return IsMergeSlotIndex(index) ? (ushort)index : currentValues[index]; + } + + public void Move(int destinationRegister, int sourceRegister) + { + var source = Read(sourceRegister); + if (IsMergeSlotIndex(registerBase + destinationRegister) || IsMergeSlotValue(source)) + { + Add(new RubyIRInstruction(RubyIROpCode.Move, dst: Define(destinationRegister), src0: source)); + return; + } + + currentValues[registerBase + destinationRegister] = source; + } + + // Emit a method-body terminator. At top level this is a real Return; while + // inlining a callee it moves the result into the shared result slot and + // jumps to the post-inline continuation. + public void EmitReturn(int valueRegister) + { + if (inlining) + { + Add(new RubyIRInstruction(RubyIROpCode.Move, dst: inlineResultValueId, src0: Read(valueRegister))); + AddBranch(RubyIROpCode.Jump, inlineContinuationPc); + return; + } + Add(new RubyIRInstruction(RubyIROpCode.Return, src0: Read(valueRegister))); + } + + public void EmitReturnSelf() + { + if (inlining) + { + Add(new RubyIRInstruction(RubyIROpCode.Move, dst: inlineResultValueId, src0: Self)); + AddBranch(RubyIROpCode.Jump, inlineContinuationPc); + return; + } + Add(new RubyIRInstruction(RubyIROpCode.ReturnSelf, src0: Self)); + } + + public void EmitReturnLiteral(MRubyValue value) + { + if (inlining) + { + Add(new RubyIRInstruction(RubyIROpCode.LoadValue, dst: inlineResultValueId, aux: Literal(value))); + AddBranch(RubyIROpCode.Jump, inlineContinuationPc); + return; + } + Add(new RubyIRInstruction(RubyIROpCode.ReturnValue, aux: Literal(value))); + } + + // Reserve a disjoint register/value region for a callee and wire its self + // and arguments to the caller's value ids, then enter inline mode. Returns + // the result value id the callee's returns write to. Caller must call + // EndInline afterwards. + public ushort BeginInline( + int calleeRegisterCount, + bool[] calleeMergeSlots, + ushort receiverValueId, + ReadOnlySpan argValueIds, + int calleeSequenceLength, + bool guarded = false) + { + var baseIndex = nextValue; + // A guarded inline writes the result slot from two paths (the spliced + // body and the cold Send), so it must be a real merge slot with backing + // storage rather than a single-writer SSA value. + var newSize = baseIndex + calleeRegisterCount + (guarded ? 1 : 0); + Array.Resize(ref currentValues, newSize); + Array.Resize(ref mergeSlotRegisters, newSize); + for (var r = 0; r < calleeRegisterCount; r++) + { + var isMerge = r < calleeMergeSlots.Length && calleeMergeSlots[r]; + mergeSlotRegisters[baseIndex + r] = isMerge; + hasMergeSlots |= isMerge; + } + nextValue = (ushort)(baseIndex + calleeRegisterCount); + + currentValues[baseIndex] = receiverValueId; + for (var i = 0; i < argValueIds.Length; i++) + { + currentValues[baseIndex + 1 + i] = argValueIds[i]; + } + + var resultValueId = nextValue++; + if (guarded) + { + mergeSlotRegisters[resultValueId] = true; + hasMergeSlots = true; + } + registerBase = baseIndex; + pcBase = nextInlinePcBase; + nextInlinePcBase += 1_000_000; + inlineResultValueId = resultValueId; + inlineContinuationPc = calleeSequenceLength; + inlining = true; + return resultValueId; + } + + // Emit the speculative fence for a guarded inline, in callee pc space + // (call after BeginInline, before lowering the callee body): + // + // GuardInlineClass receiver -> body(callee pc 0) ; match jumps to body + // Send(original) -> resultSlot ; cold path on miss + // Jump continuation ; rejoin after the body + // + // The cold Send carries the guard's expected class / method-cache version + // on its call site, so the same site both validates the guard and serves + // as the deopt target. + public void EmitInlineGuardAndColdSend( + ushort receiverValueId, + ReadOnlySpan argValueIds, + Symbol methodId, + RClass guardClass, + int guardMethodCacheVersion, + ulong guardMethodFingerprint, + ushort resultValueId) + { + var argumentStart = operands.Count; + for (var i = 0; i < argValueIds.Length; i++) + { + operands.Add(argValueIds[i]); + } + if (argValueIds.Length > maxScratchCount) + { + maxScratchCount = argValueIds.Length; + } + + var coldCallSite = callSites.Count; + var callSite = new RubyIRCallSite(Symbol(methodId), argumentStart, argValueIds.Length); + callSite.SetGuardInline(guardClass, guardMethodCacheVersion, guardMethodFingerprint); + callSites.Add(callSite); + + // Guard: on a match jump to the body (callee pc 0); on a miss fall + // through to the cold Send. The branch target is resolved by the same + // fixup machinery as ordinary jumps. + var guardIndex = instructions.Count; + instructions.Add(new RubyIRInstruction( + RubyIROpCode.GuardInlineClass, + src0: receiverValueId, + src1: (ushort)coldCallSite)); + branchFixups.Add(new BranchFixup(guardIndex, pcBase + 0)); + + instructions.Add(new RubyIRInstruction( + RubyIROpCode.Send, + dst: resultValueId, + src0: receiverValueId, + aux: coldCallSite)); + AddBranch(RubyIROpCode.Jump, inlineContinuationPc); + } + + public void EmitInlineGuardDeopt( + ushort receiverValueId, + Symbol methodId, + RClass guardClass, + int guardMethodCacheVersion, + ulong guardMethodFingerprint) + { + var guardCallSite = callSites.Count; + var callSite = new RubyIRCallSite(Symbol(methodId), operands.Count, 0); + callSite.SetGuardInline(guardClass, guardMethodCacheVersion, guardMethodFingerprint); + callSites.Add(callSite); + + // On a match, jump into the spliced body. On a miss, generated AOT code + // returns false so the VM reruns the original bytecode from the start. + var guardIndex = instructions.Count; + instructions.Add(new RubyIRInstruction( + RubyIROpCode.GuardInlineClass, + src0: receiverValueId, + src1: (ushort)guardCallSite)); + branchFixups.Add(new BranchFixup(guardIndex, pcBase + 0)); + } + + public void EndInline(int callerDestinationRegister, ushort resultValueId) + { + registerBase = 0; + pcBase = 0; + inlining = false; + inlineResultValueId = 0; + inlineContinuationPc = 0; + + if (IsMergeSlotIndex(callerDestinationRegister)) + { + currentValues[callerDestinationRegister] = (ushort)callerDestinationRegister; + Add(new RubyIRInstruction( + RubyIROpCode.Move, + dst: (ushort)callerDestinationRegister, + src0: resultValueId)); + } + else + { + currentValues[callerDestinationRegister] = resultValueId; + } + } + + public void DefineValue(int register, MRubyValue value) + { + Add(new RubyIRInstruction( + RubyIROpCode.LoadValue, + dst: Define(register), + aux: Literal(value))); + } + + public void DefineSelf(int register) + { + Add(new RubyIRInstruction(RubyIROpCode.LoadSelf, dst: Define(register))); + } + + public void DefineUpVar(int register, int upvarIndex, int upvarDepth) + { + Add(new RubyIRInstruction( + RubyIROpCode.GetUpVar, + dst: Define(register), + aux: PackUpVar(upvarIndex, upvarDepth))); + } + + public void SetUpVar(int register, int upvarIndex, int upvarDepth) + { + Add(new RubyIRInstruction( + RubyIROpCode.SetUpVar, + src0: Read(register), + aux: PackUpVar(upvarIndex, upvarDepth))); + } + + public void DefineSymbolOp(RubyIROpCode opCode, int register, Symbol symbol, ushort src0 = 0) + { + Add(new RubyIRInstruction( + opCode, + dst: Define(register), + src0: src0, + aux: Symbol(symbol))); + } + + public void DefineOp(RubyIROpCode opCode, int destinationRegister, ushort src0, int aux = 0) + { + Add(new RubyIRInstruction(opCode, dst: Define(destinationRegister), src0: src0, aux: aux)); + } + + public void DefineBinaryOp( + RubyIROpCode opCode, + int destinationRegister, + int leftRegister, + int rightRegister) + { + var left = Read(leftRegister); + var right = Read(rightRegister); + Add(new RubyIRInstruction(opCode, dst: Define(destinationRegister), src0: left, src1: right)); + } + + public void DefineTernaryOp( + RubyIROpCode opCode, + int destinationRegister, + int firstRegister, + int secondRegister, + int thirdRegister) + { + var first = Read(firstRegister); + var second = Read(secondRegister); + var third = Read(thirdRegister); + Add(new RubyIRInstruction( + opCode, + dst: Define(destinationRegister), + src0: first, + src1: second, + src2: third)); + } + + public void DefineImmediateOp( + RubyIROpCode opCode, + int destinationRegister, + int receiverRegister, + MRubyValue value) + { + var receiver = Read(receiverRegister); + Add(new RubyIRInstruction( + opCode, + dst: Define(destinationRegister), + src0: receiver, + aux: Literal(value))); + } + + public void DefineSend(RubyIROpCode opCode, int register, int argc, Symbol methodId) + { + var receiver = opCode == RubyIROpCode.SendSelf ? Self : Read(register); + var argumentStart = operands.Count; + for (var i = 0; i < argc; i++) + { + operands.Add(Read(register + 1 + i)); + } + if (argc > maxScratchCount) + { + maxScratchCount = argc; + } + + var callSite = callSites.Count; + callSites.Add(new RubyIRCallSite(Symbol(methodId), argumentStart, argc)); + // `.new` is emitted as VirtualNew so escape analysis / scalar replacement can recognize + // the allocation (a plain Send always heap-allocates via state.Send). VirtualNew that + // doesn't end up scalar-replaced still emits a real allocation, so this is always safe. + Add(new RubyIRInstruction( + methodId == Names.New ? RubyIROpCode.VirtualNew : opCode, + dst: Define(register), + src0: receiver, + aux: callSite)); + } + + public void DefineSendBlock( + RubyIROpCode opCode, + int register, + int argc, + int blockRegister, + Symbol methodId) + { + var receiver = opCode == RubyIROpCode.SendSelfBlock ? Self : Read(register); + var block = Read(blockRegister); + var argumentStart = operands.Count; + for (var i = 0; i < argc; i++) + { + operands.Add(Read(register + 1 + i)); + } + if (argc > maxScratchCount) + { + maxScratchCount = argc; + } + + var callSite = callSites.Count; + callSites.Add(new RubyIRCallSite(Symbol(methodId), argumentStart, argc)); + Add(new RubyIRInstruction( + opCode, + dst: Define(register), + src0: receiver, + src1: block, + aux: callSite)); + } + + public void DefineSendBlockDescriptor( + RubyIROpCode opCode, + int register, + int argc, + Irep child, + Symbol methodId) + { + var receiver = opCode == RubyIROpCode.SendSelfBlockDescriptor ? Self : Read(register); + var argumentStart = operands.Count; + for (var i = 0; i < argc; i++) + { + operands.Add(Read(register + 1 + i)); + } + if (argc > maxScratchCount) + { + maxScratchCount = argc; + } + + var callSite = callSites.Count; + callSites.Add(new RubyIRCallSite(Symbol(methodId), argumentStart, argc)); + Add(new RubyIRInstruction( + opCode, + dst: Define(register), + src0: receiver, + src1: (ushort)Child(child), + aux: callSite)); + } + + public void DefineBlock(int register, Irep child) + { + Add(new RubyIRInstruction( + RubyIROpCode.LoadBlock, + dst: Define(register), + aux: Child(child))); + } + + public void DefineArray(int destinationRegister, int firstRegister, int count) + { + var operandStart = operands.Count; + for (var i = 0; i < count; i++) + { + operands.Add(Read(firstRegister + i)); + } + if (count > maxScratchCount) + { + maxScratchCount = count; + } + + var operandList = operandLists.Count; + operandLists.Add(new RubyIROperandList(operandStart, count)); + Add(new RubyIRInstruction( + RubyIROpCode.NewArray, + dst: Define(destinationRegister), + aux: operandList)); + } + + // Hash literal `{k0 => v0, ...}`: pairCount pairs, key/value value-ids interleaved in the + // operand list (k0,v0,k1,v1,...) read from destinationRegister..+2*pairCount-1. + public void DefineHash(int destinationRegister, int pairCount) + { + var count = pairCount * 2; + var operandStart = operands.Count; + for (var i = 0; i < count; i++) + { + operands.Add(Read(destinationRegister + i)); + } + if (count > maxScratchCount) + { + maxScratchCount = count; + } + + var operandList = operandLists.Count; + operandLists.Add(new RubyIROperandList(operandStart, count)); + Add(new RubyIRInstruction( + RubyIROpCode.NewHash, + dst: Define(destinationRegister), + aux: operandList)); + } + + public bool TryBuild(out RubyIRMethod ir, out RubyIRBuildFailure failure) + { + failure = RubyIRBuildFailure.None; + ir = null!; + foreach (var fixup in branchFixups) + { + if (!bytecodePcToInstructionIndex.TryGetValue(fixup.TargetPc, out var targetInstructionIndex)) + { + failure = new RubyIRBuildFailure(null, fixup.TargetPc, "branch target"); + return false; + } + + var instruction = instructions[fixup.InstructionIndex]; + instructions[fixup.InstructionIndex] = new RubyIRInstruction( + instruction.OpCode, + instruction.Dst, + instruction.Src0, + instruction.Src1, + instruction.Src2, + targetInstructionIndex); + } + + var valueCount = nextValue; + if (maxScratchCount > 0 && valueCount == registerCount) + { + valueCount++; + } + + ir = new RubyIRMethod( + instructions.ToArray(), + valueCount, + literals.ToArray(), + children.ToArray(), + symbols.ToArray(), + operands.ToArray(), + callSites.ToArray(), + operandLists.ToArray(), + closureCapturedValueIds, + hasBackwardBranch, + sourceIrep: sourceIrep, + sourceBytecodePcs: BuildSourceBytecodePcs(instructions.Count)); + return true; + } + + // Per-instruction source bytecode pc, derived by inverting the + // pc->first-instruction map: every instruction is attributed to the + // bytecode op whose mark most recently preceded it. Lets the specializer + // recover a send's bytecode pc from the instruction index the profile + // observed it at, to key an inline plan. + int[] BuildSourceBytecodePcs(int instructionCount) + { + var sourcePcs = new int[instructionCount]; + Array.Fill(sourcePcs, -1); + + var starts = new (int Index, int Pc)[bytecodePcToInstructionIndex.Count]; + var n = 0; + foreach (var entry in bytecodePcToInstructionIndex) + { + starts[n++] = (entry.Value, entry.Key); + } + Array.Sort(starts, static (a, b) => a.Index.CompareTo(b.Index)); + + for (var e = 0; e < starts.Length; e++) + { + var start = starts[e].Index; + if (start < 0) + { + continue; + } + + var end = e + 1 < starts.Length ? starts[e + 1].Index : instructionCount; + for (var k = start; k < end && k < instructionCount; k++) + { + sourcePcs[k] = starts[e].Pc; + } + } + + return sourcePcs; + } + + public int Literal(MRubyValue value) + { + literals.Add(value); + return literals.Count - 1; + } + + public int Symbol(Symbol symbol) + { + symbols.Add(symbol); + return symbols.Count - 1; + } + + public int Child(Irep child) + { + children.Add(child); + return children.Count - 1; + } + + static int PackUpVar(int upvarIndex, int upvarDepth) => + (upvarIndex << 8) | upvarDepth; + + static ushort[] GetClosureCapturedValueIds(bool[] closureCapturedRegisters) + { + var count = 0; + for (var i = 0; i < closureCapturedRegisters.Length; i++) + { + if (closureCapturedRegisters[i]) + { + count++; + } + } + + var valueIds = new ushort[count]; + var next = 0; + for (var i = 0; i < closureCapturedRegisters.Length; i++) + { + if (closureCapturedRegisters[i]) + { + valueIds[next++] = (ushort)i; + } + } + return valueIds; + } + + ushort Define(int register) + { + var index = registerBase + register; + if (IsMergeSlotIndex(index)) + { + currentValues[index] = (ushort)index; + return (ushort)index; + } + + var value = nextValue++; + currentValues[index] = value; + return value; + } + + bool IsMergeSlotIndex(int index) => + (uint)index < (uint)mergeSlotRegisters.Length && mergeSlotRegisters[index]; + + bool IsMergeSlotValue(ushort valueId) => + valueId < mergeSlotRegisters.Length && mergeSlotRegisters[valueId]; + + readonly record struct BranchFixup(int InstructionIndex, int TargetPc); + } +} diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIREscapeSummary.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIREscapeSummary.cs new file mode 100644 index 00000000..99346cec --- /dev/null +++ b/src/ChibiRuby.JetPack/RubyIR/RubyIREscapeSummary.cs @@ -0,0 +1,352 @@ +using System.Collections.Generic; +using ChibiRuby; + +using ChibiRuby.JetPack.Mrb2Cs; +namespace ChibiRuby.JetPack; + +// Whole-program interprocedural escape/retention summary — the foundation for AOT object stack +// allocation. For each (class, selector, parameter) it answers: does this method RETAIN that +// parameter beyond the call (store it to the heap / return it / pass it to a callee that retains +// it / capture it / use it in an unmodeled way)? A parameter that is only FIELD-read/written and +// otherwise dropped does NOT escape — the caller may then build it on the stack (as a struct) and +// pass it by ref, instead of `new RObject`. +// +// Soundness is the whole point: claiming NonEscaping for a param that actually escapes would let +// stack state leak. So the lattice DEFAULTS TO Escaping and only proves NonEscaping; any unresolved +// callee / polymorphic receiver / unmodeled op forces Escaping. The monotone fixpoint flips a param +// false->true (NonEscaping->Escaping) and never back, mirroring RubyIRReturnTypes' discipline. +public static class RubyIREscapeSummary +{ + // Per (class, selector, param): the class-independent "hard" escape verdict plus the set of + // selectors invoked on the param AS RECEIVER. A field read/write through the param's own + // accessor never retains it, but whether a given selector IS an accessor depends on the param's + // runtime class — which only the CALLER knows. So the query takes the arg's class and checks + // those selectors against it. + internal readonly struct ParamInfo(bool hardEscape, HashSet receiverSelectors) + { + public bool HardEscape { get; } = hardEscape; + public HashSet ReceiverSelectors { get; } = receiverSelectors; + } + + public sealed class Summary + { + readonly MRubyState state; + readonly Dictionary<(RClass, Symbol, int), ParamInfo> infos; + // Selectors defined by more than one class with differing hard-escape => treat as Escaping. + readonly HashSet<(Symbol, int)> ambiguous; + readonly Dictionary> selectorClasses; + internal Summary(MRubyState state, Dictionary<(RClass, Symbol, int), ParamInfo> infos, HashSet<(Symbol, int)> ambiguous, Dictionary> selectorClasses) + { + this.state = state; + this.infos = infos; + this.ambiguous = ambiguous; + this.selectorClasses = selectorClasses; + } + + // Polymorphic-safe query for a call site whose receiver class isn't statically pinned: does + // ANY class defining `selector` retain its arg at paramIndex when the arg is argClass? An + // unknown selector (not in the AOT set) => true. Used at a `recv.sel(arg)` site to decide + // whether `arg` (an argClass instance) can be passed without escaping, regardless of which + // override runs. + public bool SelectorRetains(Symbol selector, int paramIndex, RClass? argClass) + { + if (argClass is null) return true; + if (!selectorClasses.TryGetValue(selector, out var classes) || classes.Count == 0) return true; + foreach (var c in classes) + { + if (ParamEscapes(c, selector, paramIndex, argClass)) return true; + } + return false; + } + + // The classes defining `selector` (for emitting a guarded variant dispatch per receiver class). + public IReadOnlyList DefiningClasses(Symbol selector) => + selectorClasses.TryGetValue(selector, out var c) ? c : System.Array.Empty(); + + // Does ANY class defining `selector` invoke a setter on the param at paramIndex (given the + // arg is argClass)? Such a param is MUTATED by the callee -> needs by-`ref` (Stage 2), so + // Stage 1 (read-only `in`) must not stack-allocate it. Unknown => true (safe: exclude). + public bool SelectorMutates(Symbol selector, int paramIndex, RClass? argClass) + { + if (argClass is null) return true; + if (!selectorClasses.TryGetValue(selector, out var classes) || classes.Count == 0) return true; + foreach (var c in classes) + { + if (!infos.TryGetValue((c, selector, paramIndex), out var info)) return true; + foreach (var sel in info.ReceiverSelectors) + { + if (state.TryFindMethod(argClass, sel, out var m, out _) && m.Proc is { } pr && + Analyzer.TryRecognizeTrivialAccessor(state, pr.Irep) is { IsSetter: true }) + { + return true; + } + } + } + return false; + } + + // Does (calleeClass, selector) retain its argument at position paramIndex (1-based), GIVEN + // the argument is an instance of argClass? Unknown / ambiguous / non-accessor send on the + // param => true (safe). The caller passes argClass (known at the allocation site) so the + // param's own accessor sends can be resolved soundly. + public bool ParamEscapes(RClass? calleeClass, Symbol selector, int paramIndex, RClass? argClass) + { + if (calleeClass is null || argClass is null) return true; + if (ambiguous.Contains((selector, paramIndex))) return true; + if (!infos.TryGetValue((calleeClass, selector, paramIndex), out var info)) return true; + if (info.HardEscape) return true; + // Every send invoked on the param must be a trivial accessor (getter/setter) on argClass + // — those provably don't retain the receiver. Any other selector might store self. + foreach (var sel in info.ReceiverSelectors) + { + if (!state.TryFindMethod(argClass, sel, out var method, out _) || + method.Proc is not { } proc || + Analyzer.TryRecognizeTrivialAccessor(state, proc.Irep) is null) + { + return true; + } + } + return false; + } + } + + sealed class Method + { + public RClass Cls = null!; + public Symbol Selector; + public RubyIRMethod Ir = null!; + public int ArgCount; + public int[] DefIndex = null!; + } + + public static Summary Build(MRubyState state) + { + var methods = new List(); + // selector -> set of (class) defining it, to detect polymorphic/ambiguous escape later. + var selectorDefs = new Dictionary>(); + state.EnumerateAotMethods((cls, methodId, irep) => + { + if (!TryReadArgCount(irep, out var argCount) || argCount == 0) return; + RubyIRMethod? ir; + try + { + ir = RubyIRBuilder.Build(irep, 0, out _); + if (ir is not null) ir = RubyIRSsaRenumber.Run(ir, argCount); + } + catch { ir = null; } + if (ir is null) return; + var ins = ir.Instructions; + var defIndex = new int[ir.ValueCount]; + for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1; + for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; } + var m = new Method { Cls = cls, Selector = methodId, Ir = ir, ArgCount = argCount, DefIndex = defIndex }; + methods.Add(m); + if (!selectorDefs.TryGetValue(methodId, out var list)) selectorDefs[methodId] = list = []; + list.Add(m); + }); + + // Receiver-selector sets are class-independent (just which selectors are invoked on the + // param), so compute them once. HardEscape is the class-independent retention verdict, + // refined by a monotone fixpoint (false -> true only). + var infos = new Dictionary<(RClass, Symbol, int), ParamInfo>(); + foreach (var m in methods) + { + for (var p = 1; p <= m.ArgCount; p++) + { + infos[(m.Cls, m.Selector, p)] = new ParamInfo(false, CollectReceiverSelectors(m, p)); + } + } + + // Transitive (class-independent, conservative): a param passed onward as a callee arg + // retains it unless the callee provably never retains that arg WITHOUT needing the class + // (i.e. hard=false AND no receiver-selectors to validate). Unknown selector => retains. + bool SelectorParamRetainsConservatively(Symbol sel, int paramIndex) + { + if (!selectorDefs.TryGetValue(sel, out var defs)) return true; + foreach (var d in defs) + { + if (paramIndex > d.ArgCount) return true; + var info = infos.GetValueOrDefault((d.Cls, d.Selector, paramIndex), new ParamInfo(true, [])); + if (info.HardEscape || info.ReceiverSelectors.Count > 0) return true; + } + return false; + } + + var changed = true; + var pass = 0; + while (changed && pass++ < 64) + { + changed = false; + foreach (var m in methods) + { + for (var p = 1; p <= m.ArgCount; p++) + { + var info = infos[(m.Cls, m.Selector, p)]; + if (info.HardEscape) continue; + if (ParamHardRetained(m, p, SelectorParamRetainsConservatively)) + { + infos[(m.Cls, m.Selector, p)] = new ParamInfo(true, info.ReceiverSelectors); + changed = true; + } + } + } + } + + // Ambiguous: a selector defined by multiple classes whose hard-escape differs for a param + // -> caller (which only knows the selector) must assume escape. + var ambiguous = new HashSet<(Symbol, int)>(); + foreach (var (sel, defs) in selectorDefs) + { + if (defs.Count < 2) continue; + var maxArg = 0; + foreach (var d in defs) if (d.ArgCount > maxArg) maxArg = d.ArgCount; + for (var p = 1; p <= maxArg; p++) + { + var anyTrue = false; var anyFalse = false; + foreach (var d in defs) + { + var e = p > d.ArgCount || infos.GetValueOrDefault((d.Cls, d.Selector, p), new ParamInfo(true, [])).HardEscape; + if (e) anyTrue = true; else anyFalse = true; + } + if (anyTrue && anyFalse) ambiguous.Add((sel, p)); + } + } + + var selectorClasses = new Dictionary>(); + foreach (var (sel, defs) in selectorDefs) + { + var list = new List(defs.Count); + foreach (var d in defs) list.Add(d.Cls); + selectorClasses[sel] = list; + } + return new Summary(state, infos, ambiguous, selectorClasses); + } + + // Selectors invoked on param p as the RECEIVER (Send only). Each must be validated as a trivial + // accessor against the arg's class at query time. + static HashSet CollectReceiverSelectors(Method m, int p) + { + var ir = m.Ir; + var ins = ir.Instructions; + var aliases = AliasesOf(ir, p); + var sels = new HashSet(); + for (var i = 0; i < ins.Length; i++) + { + var u = ins[i]; + if (u.OpCode == RubyIROpCode.Send && aliases.Contains(u.Src0)) + { + sels.Add(ir.GetCallSiteSymbol(u.Aux)); + } + } + return sels; + } + + static HashSet AliasesOf(RubyIRMethod ir, int p) + { + var ins = ir.Instructions; + var aliases = new HashSet { p }; + var grew = true; + while (grew) + { + grew = false; + for (var i = 0; i < ins.Length; i++) + { + if (ins[i].OpCode == RubyIROpCode.Move && aliases.Contains(ins[i].Src0)) + { + if (aliases.Add(ins[i].Dst)) grew = true; + } + } + } + return aliases; + } + + // Class-INDEPENDENT retention: does method m retain param p in a way that holds regardless of + // p's class? (Receiver-as-param Send selectors are NOT hard — they're validated per-class at + // query time via ReceiverSelectors.) Returns true on any return/store/array/ctor-arg/onward- + // pass-that-retains/closure-capture/unmodeled use. Aliases via Move are followed. + static bool ParamHardRetained(Method m, int p, System.Func selectorRetains) + { + var ir = m.Ir; + var ins = ir.Instructions; + foreach (var captured in ir.ClosureCapturedValueIds) + { + if (captured == p) return true; + } + var aliases = AliasesOf(ir, p); + foreach (var captured in ir.ClosureCapturedValueIds) + { + if (aliases.Contains(captured)) return true; + } + + for (var i = 0; i < ins.Length; i++) + { + var u = ins[i]; + var op = u.OpCode; + if (op == RubyIROpCode.Return && aliases.Contains(u.Src0)) return true; + if (op is RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField) + { + if (aliases.Contains(u.Src1)) return true; // param stored as the value + continue; // Src0 == param == writing the param's own field == mutation, ok + } + if (op is RubyIROpCode.SetIndex) + { + if (aliases.Contains(u.Src1) || aliases.Contains(u.Src2)) return true; + continue; + } + if (op is RubyIROpCode.NewArray or RubyIROpCode.NewArray2) + { + var c = ir.GetOperandListCount(u.Aux); + for (var a = 0; a < c; a++) if (aliases.Contains(ir.GetOperandListValueId(u.Aux, a))) return true; + continue; + } + if (op is RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField) + { + continue; // reading the param's field is fine + } + if (IsSendOp(op) || op is RubyIROpCode.PureUnarySend) + { + var sel = ir.GetCallSiteSymbol(u.Aux); + var argc = ir.GetCallSiteArgumentCount(u.Aux); + // Receiver-as-param: a plain Send is deferred to per-class accessor validation + // (ReceiverSelectors). A PureUnarySend (numeric op) on the param is unmodeled. + if (aliases.Contains(u.Src0) && op is not RubyIROpCode.Send) return true; + for (var a = 0; a < argc; a++) + { + if (aliases.Contains(ir.GetCallSiteArgumentValueId(u.Aux, a)) && selectorRetains(sel, a + 1)) return true; + } + continue; + } + if (op is RubyIROpCode.VirtualNew) + { + if (aliases.Contains(u.Src0)) return true; + var argc = ir.GetCallSiteArgumentCount(u.Aux); + for (var a = 0; a < argc; a++) if (aliases.Contains(ir.GetCallSiteArgumentValueId(u.Aux, a))) return true; + continue; + } + if (aliases.Contains(u.Src0) || aliases.Contains(u.Src1) || aliases.Contains(u.Src2)) + { + if (op is RubyIROpCode.Move or RubyIROpCode.GuardInlineClass) continue; + return true; + } + } + return false; + } + + static bool IsSendOp(RubyIROpCode op) => op is RubyIROpCode.Send or RubyIROpCode.SendSelf; + + static bool TryReadArgCount(Irep irep, out int argCount) + { + argCount = 0; + var seq = irep.Sequence; + if (seq.Length == 0 || (OpCode)seq[0] != OpCode.Enter) return false; + var aspec = new ArgumentSpec(((uint)seq[1] << 16) | ((uint)seq[2] << 8) | seq[3]); + if (aspec.OptionalArgumentsCount != 0 || aspec.TakeRestArguments || + aspec.MandatoryArguments2Count != 0 || aspec.KeywordArgumentsCount != 0 || + aspec.TakeKeywordDict || aspec.TakeBlock) + { + return false; + } + argCount = aspec.MandatoryArguments1Count; + return true; + } +} diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRInstruction.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRInstruction.cs new file mode 100644 index 00000000..eea3b830 --- /dev/null +++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRInstruction.cs @@ -0,0 +1,262 @@ +using System; + +using ChibiRuby; +namespace ChibiRuby.JetPack; + +[Flags] +enum RubyIREffects : ushort +{ + None = 0, + Pure = 1 << 0, + Alloc = 1 << 1, + ReadField = 1 << 2, + WriteField = 1 << 3, + ReadGlobal = 1 << 4, + WriteGlobal = 1 << 5, + MayCall = 1 << 6, + MayEscape = 1 << 7, + MayRaise = 1 << 8, + ControlFlow = 1 << 9, +} + +enum RubyIROpCode : byte +{ + CheckArity, + Move, + LoadValue, + LoadSelf, + LoadBlock, + GetUpVar, + SetUpVar, + GetConstant, + GetModuleConstant, + GetInstanceVariable, + SetInstanceVariable, + GuardClass, + GuardMethod, + // Guard for an SSA-spliced inline body: checks the receiver's class identity + // and method-cache version against the call site stored in Src1. On a match it + // jumps to the spliced hot body (Aux); on a miss it falls through to the cold + // path (the original Send), which both paths' continuation merges back into. + GuardInlineClass, + InlineBody, + VirtualNew, + VirtualGetField, + VirtualSetField, + MaterializeObject, + GuardValueType, + TypeSwitch, + GetIndex, + GetIndex0, + SetIndex, + NewArray, + NewArray2, + NewHash, + ArrayRef, + ArraySet, + Jump, + JumpIfTruthy, + JumpIfFalsy, + JumpIfNil, + Send, + SendSelf, + SendBlock, + SendSelfBlock, + SendBlockDescriptor, + SendSelfBlockDescriptor, + PureUnarySend, + Add, + AddImmediate, + Sub, + SubImmediate, + AddImmediateFixnum, + SubImmediateFixnum, + AddImmediateFloat, + SubImmediateFloat, + Mul, + Div, + MulAdd, + MulSub, + SubMul, + AddFixnum, + SubFixnum, + MulFixnum, + DivFixnum, + AddFloat, + SubFloat, + MulFloat, + DivFloat, + MulAddFloat, + MulSubFloat, + SubMulFloat, + Eq, + Lt, + Le, + Gt, + Ge, + LtFixnum, + LeFixnum, + GtFixnum, + GeFixnum, + LtFloat, + LeFloat, + GtFloat, + GeFloat, + Return, + ReturnSelf, + ReturnValue, +} + +static class RubyIROpCodeExtensions +{ + public static RubyIREffects Effects(this RubyIROpCode opCode) => opCode switch + { + RubyIROpCode.CheckArity => RubyIREffects.MayRaise, + RubyIROpCode.Move or + RubyIROpCode.LoadValue or + RubyIROpCode.LoadSelf or + RubyIROpCode.ArrayRef => RubyIREffects.Pure, + RubyIROpCode.LoadBlock => RubyIREffects.Alloc | RubyIREffects.MayEscape, + RubyIROpCode.GetUpVar => RubyIREffects.ReadField, + RubyIROpCode.SetUpVar => RubyIREffects.WriteField | RubyIREffects.MayEscape, + RubyIROpCode.GetConstant or + RubyIROpCode.GetModuleConstant => RubyIREffects.ReadGlobal | RubyIREffects.MayCall | RubyIREffects.MayRaise, + RubyIROpCode.GetInstanceVariable or + RubyIROpCode.VirtualGetField => RubyIREffects.ReadField, + RubyIROpCode.SetInstanceVariable or + RubyIROpCode.VirtualSetField => RubyIREffects.WriteField | RubyIREffects.MayEscape, + RubyIROpCode.GuardClass => RubyIREffects.Pure | RubyIREffects.MayRaise, + RubyIROpCode.GuardInlineClass => RubyIREffects.ControlFlow, + RubyIROpCode.GuardMethod or + RubyIROpCode.GuardValueType or + RubyIROpCode.InlineBody => RubyIREffects.MayCall | RubyIREffects.MayEscape | RubyIREffects.MayRaise, + RubyIROpCode.VirtualNew => RubyIREffects.Alloc | RubyIREffects.MayCall | RubyIREffects.MayRaise, + RubyIROpCode.MaterializeObject => RubyIREffects.Alloc | RubyIREffects.MayEscape, + RubyIROpCode.TypeSwitch => RubyIREffects.ControlFlow | RubyIREffects.MayCall | RubyIREffects.MayEscape | RubyIREffects.MayRaise, + RubyIROpCode.GetIndex => RubyIREffects.MayCall | RubyIREffects.MayRaise, + RubyIROpCode.GetIndex0 => RubyIREffects.MayCall | RubyIREffects.MayRaise, + RubyIROpCode.SetIndex => RubyIREffects.WriteField | RubyIREffects.MayCall | RubyIREffects.MayEscape | RubyIREffects.MayRaise, + RubyIROpCode.NewArray or + RubyIROpCode.NewArray2 => RubyIREffects.Alloc, + RubyIROpCode.ArraySet => RubyIREffects.WriteField | RubyIREffects.MayEscape, + RubyIROpCode.Jump or + RubyIROpCode.JumpIfTruthy or + RubyIROpCode.JumpIfFalsy or + RubyIROpCode.JumpIfNil => RubyIREffects.ControlFlow, + RubyIROpCode.Send or + RubyIROpCode.SendSelf or + RubyIROpCode.SendBlock or + RubyIROpCode.SendSelfBlock or + RubyIROpCode.SendBlockDescriptor or + RubyIROpCode.SendSelfBlockDescriptor or + RubyIROpCode.PureUnarySend => RubyIREffects.Alloc | RubyIREffects.MayCall | RubyIREffects.MayEscape | RubyIREffects.MayRaise, + RubyIROpCode.Add or + RubyIROpCode.AddImmediate or + RubyIROpCode.Sub or + RubyIROpCode.SubImmediate or + RubyIROpCode.AddImmediateFixnum or + RubyIROpCode.SubImmediateFixnum or + RubyIROpCode.AddImmediateFloat or + RubyIROpCode.SubImmediateFloat or + RubyIROpCode.Mul or + RubyIROpCode.Div or + RubyIROpCode.MulAdd or + RubyIROpCode.MulSub or + RubyIROpCode.SubMul or + RubyIROpCode.AddFixnum or + RubyIROpCode.SubFixnum or + RubyIROpCode.MulFixnum or + RubyIROpCode.DivFixnum or + RubyIROpCode.AddFloat or + RubyIROpCode.SubFloat or + RubyIROpCode.MulFloat or + RubyIROpCode.DivFloat or + RubyIROpCode.MulAddFloat or + RubyIROpCode.MulSubFloat or + RubyIROpCode.SubMulFloat or + RubyIROpCode.Eq or + RubyIROpCode.Lt or + RubyIROpCode.Le or + RubyIROpCode.Gt or + RubyIROpCode.Ge or + RubyIROpCode.LtFixnum or + RubyIROpCode.LeFixnum or + RubyIROpCode.GtFixnum or + RubyIROpCode.GeFixnum or + RubyIROpCode.LtFloat or + RubyIROpCode.LeFloat or + RubyIROpCode.GtFloat or + RubyIROpCode.GeFloat => RubyIREffects.MayCall | RubyIREffects.MayRaise, + RubyIROpCode.Return or + RubyIROpCode.ReturnSelf => RubyIREffects.ControlFlow | RubyIREffects.MayEscape, + RubyIROpCode.ReturnValue => RubyIREffects.ControlFlow, + _ => RubyIREffects.None + }; +} + +readonly struct RubyIRInstruction( + RubyIROpCode opCode, + ushort dst = 0, + ushort src0 = 0, + ushort src1 = 0, + ushort src2 = 0, + int aux = 0) +{ + public readonly RubyIROpCode OpCode = opCode; + public readonly ushort Dst = dst; + public readonly ushort Src0 = src0; + public readonly ushort Src1 = src1; + public readonly ushort Src2 = src2; + public readonly int Aux = aux; +} + +readonly struct RubyIRInstructionStream +{ + public readonly RubyIROpCode[] OpCodes; + public readonly ushort[] Destinations; + public readonly ushort[] Source0s; + public readonly ushort[] Source1s; + public readonly ushort[] Source2s; + public readonly int[] Auxes; + + RubyIRInstructionStream( + RubyIROpCode[] opCodes, + ushort[] destinations, + ushort[] source0s, + ushort[] source1s, + ushort[] source2s, + int[] auxes) + { + OpCodes = opCodes; + Destinations = destinations; + Source0s = source0s; + Source1s = source1s; + Source2s = source2s; + Auxes = auxes; + } + + public int Count => OpCodes.Length; + + public static RubyIRInstructionStream Create(ReadOnlySpan instructions) + { + var opCodes = new RubyIROpCode[instructions.Length]; + var destinations = new ushort[instructions.Length]; + var source0s = new ushort[instructions.Length]; + var source1s = new ushort[instructions.Length]; + var source2s = new ushort[instructions.Length]; + var auxes = new int[instructions.Length]; + + for (var i = 0; i < instructions.Length; i++) + { + ref readonly var instruction = ref instructions[i]; + opCodes[i] = instruction.OpCode; + destinations[i] = instruction.Dst; + source0s[i] = instruction.Src0; + source1s[i] = instruction.Src1; + source2s[i] = instruction.Src2; + auxes[i] = instruction.Aux; + } + + return new RubyIRInstructionStream(opCodes, destinations, source0s, source1s, source2s, auxes); + } +} diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRMethod.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRMethod.cs new file mode 100644 index 00000000..98da8537 --- /dev/null +++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRMethod.cs @@ -0,0 +1,221 @@ +using System; +#if NET7_0_OR_GREATER +using static System.Runtime.InteropServices.MemoryMarshal; +#else +using static ChibiRuby.Internal.MemoryMarshalEx; +#endif + +namespace ChibiRuby.JetPack; + +sealed class RubyIRMethod( + RubyIRInstruction[] instructions, + int valueCount, + MRubyValue[] literalPool, + Irep[] childPool, + Symbol[] symbolPool, + int[] operandPool, + RubyIRCallSite[] callSites, + RubyIROperandList[] operandLists, + ushort[] closureCapturedValueIds, + bool hasBackwardBranch = false, + Irep? sourceIrep = null, + int[]? sourceBytecodePcs = null) +{ + public ReadOnlySpan Instructions => instructions; + public int ValueCount => valueCount; + + // True when the method contains a backward branch (a `while`/`until` loop back-edge). Such + // methods are compiled by the AOT codegen in a fully-boxed, ForceSend (deopt-free) mode with + // SSA / unboxing / scalar-replacement disabled — a loop body must never re-execute a partial + // iteration, mirroring the block-body contract. + public bool HasBackwardBranch => hasBackwardBranch; + + // The method this (generic) IR was built from, retained so the + // specializer can re-lower with an inline plan. Null on specialized variants. + public Irep? SourceIrep => sourceIrep; + + // Bytecode pc of the op that produced instruction `index`, or -1. Used to key + // an inline plan (which TryCompile resolves by bytecode pc) from a monomorphic + // send the profile observed at a given instruction index. + public int SourceBytecodePc(int index) => + sourceBytecodePcs is not null && (uint)index < (uint)sourceBytecodePcs.Length + ? sourceBytecodePcs[index] + : -1; + public MRubyValue GetLiteral(int literalIndex) => + (uint)literalIndex < (uint)literalPool.Length + ? literalPool[literalIndex] + : default; + + // Symbol pool access (e.g. ivar name for GetInstanceVariable, whose Aux is the + // symbol index). Used by the AOT codegen. + public Symbol GetSymbol(int symbolIndex) => + (uint)symbolIndex < (uint)symbolPool.Length + ? symbolPool[symbolIndex] + : default; + + // Call-site access for the AOT codegen (a Send's Aux indexes callSites; its args + // are value ids at operandPool[ArgumentStart + i]). + public Symbol GetCallSiteSymbol(int callSiteIndex) => + symbolPool[callSites[callSiteIndex].SymbolIndex]; + public int GetCallSiteArgumentCount(int callSiteIndex) => + callSites[callSiteIndex].ArgumentCount; + public int GetCallSiteArgumentValueId(int callSiteIndex, int argumentIndex) => + operandPool[callSites[callSiteIndex].ArgumentStart + argumentIndex]; + + // Child irep access for block inlining: a SendBlockDescriptor's Src1 is the index into + // the child pool of the block body's irep. + public Irep GetChildIrep(int index) => childPool[index]; + + // Registers captured by descendant blocks (their value-ids, since captured registers are + // merge slots whose value-id equals the register at registerBase 0). The AOT codegen + // forces these boxed so they can be passed to an inlined block's __block form by ref. + public ReadOnlySpan ClosureCapturedValueIds => closureCapturedValueIds; + + // Operand-list access for the AOT codegen (a NewArray's Aux indexes operandLists; + // its elements are value ids at operandPool[OperandStart + i]). + public int GetOperandListCount(int operandListIndex) => + operandLists[operandListIndex].OperandCount; + public int GetOperandListValueId(int operandListIndex, int elementIndex) => + operandPool[operandLists[operandListIndex].OperandStart + elementIndex]; + + public bool TryGetGuardInline(int callSiteIndex, out ulong calleeFingerprint) + { + if ((uint)callSiteIndex < (uint)callSites.Length) + { + return callSites[callSiteIndex].TryGetGuardInline(out calleeFingerprint); + } + + calleeFingerprint = 0; + return false; + } + + public RubyIRMethod CreateVariant( + RubyIRInstruction[] loweredInstructions, + RubyIRCallSite[]? loweredCallSites = null, + Irep? loweredSourceIrep = null, + int[]? loweredSourceBytecodePcs = null) => + new( + loweredInstructions, + valueCount, + literalPool, + childPool, + symbolPool, + operandPool, + loweredCallSites ?? callSites, + operandLists, + closureCapturedValueIds, + hasBackwardBranch, + // The escape-analysis rewrite produces the generic IR as a + // 1:1 variant (same instruction count/order), so the source map still + // aligns — carry it through so later passes stay aligned. + loweredSourceIrep ?? (loweredInstructions.Length == instructions.Length ? sourceIrep : null), + loweredSourceBytecodePcs ?? (loweredInstructions.Length == instructions.Length ? sourceBytecodePcs : null)); + + internal int OperandPoolValue(int index) => operandPool[index]; + internal int[] CloneOperandPool() => (int[])operandPool.Clone(); + internal int CallSiteArgumentStart(int callSiteIndex) => callSites[callSiteIndex].ArgumentStart; + internal int OperandListStart(int operandListIndex) => operandLists[operandListIndex].OperandStart; + + // Same pools/metadata as this IR, but with value-remapped instructions / + // operand pool / captured ids and an expanded value count. Renumbering is 1:1 over + // instructions (same count/order), so the source map stays aligned and is carried. + internal RubyIRMethod CreateSsaVariant( + RubyIRInstruction[] remappedInstructions, + int[] remappedOperandPool, + ushort[] remappedClosureCapturedValueIds, + int newValueCount) => + new( + remappedInstructions, + newValueCount, + literalPool, + childPool, + symbolPool, + remappedOperandPool, + callSites, + operandLists, + remappedClosureCapturedValueIds, + hasBackwardBranch, + sourceIrep, + sourceBytecodePcs); + + public int[] CountValueUses(ReadOnlySpan loweredInstructions) + { + var useCounts = new int[valueCount]; + foreach (var instruction in loweredInstructions) + { + CountUse(useCounts, instruction.Src0); + // GuardInlineClass.Src1 is a call-site index, not a value id. + // SendBlockDescriptor.Src1 is a child-pool index, not a value id. + if (instruction.OpCode is not ( + RubyIROpCode.GuardInlineClass or + RubyIROpCode.SendBlockDescriptor or + RubyIROpCode.SendSelfBlockDescriptor)) + { + CountUse(useCounts, instruction.Src1); + CountUse(useCounts, instruction.Src2); + } + + switch (instruction.OpCode) + { + case RubyIROpCode.LoadBlock: + foreach (var t in closureCapturedValueIds) + { + CountUse(useCounts, t); + } + break; + case RubyIROpCode.SendBlockDescriptor: + case RubyIROpCode.SendSelfBlockDescriptor: + { + foreach (var t in closureCapturedValueIds) + { + CountUse(useCounts, t); + } + var callSite = callSites[instruction.Aux]; + for (var j = 0; j < callSite.ArgumentCount; j++) + { + CountUse(useCounts, operandPool[callSite.ArgumentStart + j]); + } + break; + } + case RubyIROpCode.Send: + case RubyIROpCode.SendSelf: + case RubyIROpCode.SendBlock: + case RubyIROpCode.SendSelfBlock: + case RubyIROpCode.PureUnarySend: + case RubyIROpCode.GuardClass: + case RubyIROpCode.GuardMethod: + case RubyIROpCode.InlineBody: + case RubyIROpCode.VirtualNew: + { + var callSite = callSites[instruction.Aux]; + for (var j = 0; j < callSite.ArgumentCount; j++) + { + CountUse(useCounts, operandPool[callSite.ArgumentStart + j]); + } + break; + } + case RubyIROpCode.NewArray: + case RubyIROpCode.NewArray2: + case RubyIROpCode.NewHash: + { + var operandList = operandLists[instruction.Aux]; + for (var j = 0; j < operandList.OperandCount; j++) + { + CountUse(useCounts, operandPool[operandList.OperandStart + j]); + } + break; + } + } + } + + return useCounts; + } + + static void CountUse(int[] useCounts, int valueId) + { + if ((uint)valueId < (uint)useCounts.Length) + { + useCounts[valueId]++; + } + } +} diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIROpInfo.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIROpInfo.cs new file mode 100644 index 00000000..a385f61c --- /dev/null +++ b/src/ChibiRuby.JetPack/RubyIR/RubyIROpInfo.cs @@ -0,0 +1,52 @@ +namespace ChibiRuby.JetPack; + +// Op-code classification predicates over RubyIROpCode. Lives in the RubyIR layer so both the IR +// passes (escape summary, return-type inference) and the Mrb2Cs analyzer/emitter share them +// without the lower IR layer reaching up into the codegen layer. +static class RubyIROpInfo +{ + internal static bool IsSendOp(RubyIROpCode op) => op is + RubyIROpCode.Send or RubyIROpCode.SendSelf or + RubyIROpCode.SendBlock or RubyIROpCode.SendSelfBlock or + RubyIROpCode.SendBlockDescriptor or RubyIROpCode.SendSelfBlockDescriptor; + + // Fixnum arithmetic whose result, given the deopt-on-non-fixnum guard, is always a + // fixnum -> safe to hold unboxed as a C# long. + internal static bool IsFixnumArith(RubyIROpCode op) => op is + RubyIROpCode.Add or RubyIROpCode.AddFixnum or + RubyIROpCode.Sub or RubyIROpCode.SubFixnum or + RubyIROpCode.Mul or RubyIROpCode.MulFixnum or + RubyIROpCode.Div or RubyIROpCode.DivFixnum or + RubyIROpCode.AddImmediate or RubyIROpCode.AddImmediateFixnum or + RubyIROpCode.SubImmediate or RubyIROpCode.SubImmediateFixnum or + RubyIROpCode.MulAdd or RubyIROpCode.MulSub or RubyIROpCode.SubMul; + + internal static bool IsFixnumCompare(RubyIROpCode op) => op is + RubyIROpCode.Lt or RubyIROpCode.LtFixnum or RubyIROpCode.Le or RubyIROpCode.LeFixnum or + RubyIROpCode.Gt or RubyIROpCode.GtFixnum or RubyIROpCode.Ge or RubyIROpCode.GeFixnum or + RubyIROpCode.Eq; + + // The subset of arith/compare ops that double-unboxing emits as a guard-free raw-double form + // when every operand is provably Float. Excludes the fixnum-typed (AddFixnum, LtFixnum, ...) + // and immediate variants, which are fixnum-specialized. SubMul (reverse) is a fused form too. + internal static bool IsDoubleArith(RubyIROpCode op) => op is + RubyIROpCode.Add or RubyIROpCode.Sub or RubyIROpCode.Mul or RubyIROpCode.Div or + RubyIROpCode.MulAdd or RubyIROpCode.MulSub or RubyIROpCode.SubMul; + + internal static bool IsDoubleFused(RubyIROpCode op) => op is + RubyIROpCode.MulAdd or RubyIROpCode.MulSub or RubyIROpCode.SubMul; + + internal static bool IsDoubleCompare(RubyIROpCode op) => op is + RubyIROpCode.Lt or RubyIROpCode.Le or RubyIROpCode.Gt or RubyIROpCode.Ge or RubyIROpCode.Eq; + + // Ops that commit no side effect and cannot re-enter arbitrary code, so a speculation guard's + // deopt (which re-runs the whole method in the interpreter) is harmless to re-apply. + internal static bool IsPureSpeculationOp(RubyIROpCode op) => + IsFixnumArith(op) || IsFixnumCompare(op) || + op is RubyIROpCode.LoadValue or RubyIROpCode.Move or + RubyIROpCode.GetInstanceVariable or RubyIROpCode.VirtualGetField or + RubyIROpCode.GetConstant or RubyIROpCode.PureUnarySend or + RubyIROpCode.Return or RubyIROpCode.ReturnValue or RubyIROpCode.ReturnSelf or + RubyIROpCode.Jump or RubyIROpCode.JumpIfTruthy or RubyIROpCode.JumpIfFalsy or + RubyIROpCode.JumpIfNil or RubyIROpCode.CheckArity or RubyIROpCode.GuardInlineClass; +} diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRReturnTypes.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRReturnTypes.cs new file mode 100644 index 00000000..37a92bc3 --- /dev/null +++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRReturnTypes.cs @@ -0,0 +1,394 @@ +using System.Collections.Generic; +using ChibiRuby; + +using ChibiRuby.JetPack.Mrb2Cs; +namespace ChibiRuby.JetPack; + +// Numeric kind of a value / method result / ivar. Unknown also serves as the lattice bottom +// (a conflict between two known kinds), so a meet of disagreeing kinds collapses to Unknown. +enum RubyNumKind : byte { Unknown = 0, Integer, Float } + +// Whole-program numeric return-type inference over the AOT method set — the principled replacement +// for matching method NAMES. It infers, to a fixpoint: +// - each selector's return kind (Float / Integer), but ONLY when every method defining that +// selector provably returns that kind (a disagreement -> Unknown; mirrors accessor ambiguity); +// - each (class, ivar)'s kind, from every self-write in the class's methods, with the ivar's +// `initialize` param fed by the arg kinds at every resolvable `Class.new` call site. +// kind(value) is computed from the IR (literals, ivar reads, arith promotion, sends-by-inferred- +// selector). Everything is conservative: any unresolved send / ivar / `.new` leaves the result +// Unknown. Soundness matters because the float result feeds provesDouble, which emits unguarded +// double reads — a wrong "Float" would silently miscompile, so we only ever claim a kind we prove. +public static class RubyIRReturnTypes +{ + public sealed class Registry + { + readonly Dictionary selectorReturn; + readonly HashSet floatUsingClasses; + readonly Dictionary<(RClass, Symbol), RubyNumKind> ivarKind; + internal Registry( + Dictionary selectorReturn, + HashSet floatUsingClasses, + Dictionary<(RClass, Symbol), RubyNumKind> ivarKind) + { + this.selectorReturn = selectorReturn; + this.floatUsingClasses = floatUsingClasses; + this.ivarKind = ivarKind; + } + public bool ReturnsFloat(Symbol selector) => selectorReturn.GetValueOrDefault(selector) == RubyNumKind.Float; + public bool ReturnsInteger(Symbol selector) => selectorReturn.GetValueOrDefault(selector) == RubyNumKind.Integer; + // Does this class touch floats anywhere? Float speculation is only safe to attempt here. + public bool ClassUsesFloat(RClass? cls) => cls is not null && floatUsingClasses.Contains(cls); + // The whole-program-proven numeric kind of (class, @ivar) — Unknown unless EVERY self-write + // in the class agrees. Sound enough to feed UNGUARDED unboxing (see Build's least-fixpoint). + internal RubyNumKind IvarKind(RClass? cls, Symbol ivar) => + cls is null ? RubyNumKind.Unknown : ivarKind.GetValueOrDefault((cls, ivar)); + public bool IvarReturnsFloat(RClass? cls, Symbol ivar) => IvarKind(cls, ivar) == RubyNumKind.Float; + public bool IvarReturnsInteger(RClass? cls, Symbol ivar) => IvarKind(cls, ivar) == RubyNumKind.Integer; + } + + sealed class Method + { + public RClass Cls = null!; + public Symbol Selector; + public RubyIRMethod Ir = null!; + public int ArgCount; + public int[] DefIndex = null!; + public bool IsInitialize; + } + + public static Registry Build(MRubyState state) + { + var initializeSym = state.Intern("initialize"u8); + var methods = new List(); + state.EnumerateAotMethods((cls, methodId, irep) => + { + if (!TryReadArgCount(irep, out var argCount)) return; + RubyIRMethod? ir; + try + { + ir = RubyIRBuilder.Build(irep, 0, out _); + // Analyze SSA-renumbered IR: otherwise a conflated merge slot (e.g. `t` in + // intersect) reads as Unknown and poisons everything that flows from it. + if (ir is not null) ir = RubyIRSsaRenumber.Run(ir, argCount); + } + catch { ir = null; } + if (ir is null) return; + var ins = ir.Instructions; + var defIndex = new int[ir.ValueCount]; + for (var i = 0; i < defIndex.Length; i++) defIndex[i] = -1; + for (var i = 0; i < ins.Length; i++) { var d = ins[i].Dst; if ((uint)d < (uint)defIndex.Length) defIndex[d] = i; } + methods.Add(new Method + { + Cls = cls, + Selector = methodId, + Ir = ir, + ArgCount = argCount, + DefIndex = defIndex, + IsInitialize = methodId == initializeSym, + }); + }); + + // Ivars written on a non-self target anywhere are not attributable to a single class, so + // they are never trusted (kept Unknown) — keeps the (class, ivar) inference sound. + var untrustedIvars = new HashSet(); + // A `Class.new` whose class we cannot resolve could feed any initialize; poison all + // initialize-param inference rather than risk an unseen non-float arg. + var hasUnresolvedNew = false; + // Resolvable `.new` sites: (target class, the caller method, the VirtualNew callsite aux). + var newSites = new List<(RClass Target, Method Caller, int Aux)>(); + // Classes that demonstrably touch floats anywhere (a float literal/const or a Math/to_f + // send). Float speculation is enabled ONLY for these — an all-integer class (e.g. every + // optcarrot class) never speculates an int ivar as Float and constant-deopts. + var floatUsingClasses = new HashSet(); + foreach (var m in methods) + { + var ins = m.Ir.Instructions; + for (var i = 0; i < ins.Length; i++) + { + var op = ins[i].OpCode; + if (op is RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField) + { + if (ins[i].Src0 != 0) untrustedIvars.Add(m.Ir.GetSymbol(ins[i].Aux)); + } + else if (op == RubyIROpCode.VirtualNew) + { + if (TryResolveNewClass(state, m, i, out var target)) newSites.Add((target, m, ins[i].Aux)); + else hasUnresolvedNew = true; + } + else if ((op == RubyIROpCode.LoadValue && m.Ir.GetLiteral(ins[i].Aux).IsFloat) || + (op == RubyIROpCode.GetConstant && Analyzer.IsFloatConstantName(state, m.Ir.GetSymbol(ins[i].Aux))) || + (op is (RubyIROpCode.Send or RubyIROpCode.SendSelf) && Analyzer.IsBuiltinFloatMethod(state, m.Ir.GetCallSiteSymbol(ins[i].Aux)))) + { + floatUsingClasses.Add(m.Cls); + } + } + } + + // Least fixpoint (sound by construction): start with no proofs and only ADD a Float/Integer + // proof once a slot's sources establish it; anything cyclic or unresolved stays Unknown. + // This is the safe choice because the Float result feeds provesDouble, which emits UNGUARDED + // double reads — an over-claimed Float would silently miscompile. (A precise cyclic-float + // proof — e.g. Vec#@x written by its own `x=` whose arg reads @x — needs a greatest fixpoint + // with a speculation guard at the use; that's the next step. Here cyclic ivars stay Unknown + // and the pre-side-effect speculation covers them instead.) + var selectorReturn = new Dictionary(); + var ivarKind = new Dictionary<(RClass, Symbol), RubyNumKind>(); + var initParam = new Dictionary<(RClass, int), RubyNumKind>(); + var selectorParam = new Dictionary<(Symbol, int), RubyNumKind>(); + + // Jacobi fixpoint: recompute each registry from the previous iteration. Monotone (proofs + // only accumulate) so it converges; capped for safety. + for (var pass = 0; pass < 64; pass++) + { + var ctx = new Ctx(state, selectorReturn, ivarKind, initParam, selectorParam); + + var newInitParam = new Dictionary<(RClass, int), RubyNumKind>(); + if (!hasUnresolvedNew) + { + foreach (var (target, caller, aux) in newSites) + { + var argc = caller.Ir.GetCallSiteArgumentCount(aux); + for (var a = 0; a < argc; a++) + { + // `.new` arg a maps to initialize's param register a+1 (v0 is self). + MeetInto(newInitParam, (target, a + 1), ctx.KindOf(caller, caller.Ir.GetCallSiteArgumentValueId(aux, a))); + } + } + } + + // Every other selector's params from its call sites (the arg kinds at each `recv.sel(args)`). + var newSelectorParam = new Dictionary<(Symbol, int), RubyNumKind>(); + foreach (var m in methods) + { + var ins = m.Ir.Instructions; + for (var i = 0; i < ins.Length; i++) + { + if (ins[i].OpCode is not (RubyIROpCode.Send or RubyIROpCode.SendSelf)) continue; + var sel = m.Ir.GetCallSiteSymbol(ins[i].Aux); + var argc = m.Ir.GetCallSiteArgumentCount(ins[i].Aux); + for (var a = 0; a < argc; a++) + { + MeetInto(newSelectorParam, (sel, a + 1), ctx.KindOf(m, m.Ir.GetCallSiteArgumentValueId(ins[i].Aux, a))); + } + } + } + + var newIvar = new Dictionary<(RClass, Symbol), RubyNumKind>(); + foreach (var m in methods) + { + var ins = m.Ir.Instructions; + for (var i = 0; i < ins.Length; i++) + { + if (ins[i].OpCode is not (RubyIROpCode.SetInstanceVariable or RubyIROpCode.VirtualSetField)) continue; + if (ins[i].Src0 != 0) continue; // non-self target: handled via untrustedIvars + var ivar = m.Ir.GetSymbol(ins[i].Aux); + MeetInto(newIvar, (m.Cls, ivar), untrustedIvars.Contains(ivar) ? RubyNumKind.Unknown : ctx.KindOf(m, ins[i].Src1)); + } + } + + var newSelector = new Dictionary(); + foreach (var m in methods) + { + MeetInto(newSelector, m.Selector, ctx.ReturnKindOf(m)); + } + + if (DictEq(newSelector, selectorReturn) && DictEq(newIvar, ivarKind) && + DictEq(newInitParam, initParam) && DictEq(newSelectorParam, selectorParam)) + { + break; + } + selectorReturn = newSelector; + ivarKind = newIvar; + initParam = newInitParam; + selectorParam = newSelectorParam; + } + + return new Registry(selectorReturn, floatUsingClasses, ivarKind); + } + + // Per-pass kind computation, reading the previous pass's registries. + sealed class Ctx( + MRubyState state, + Dictionary selectorReturn, + Dictionary<(RClass, Symbol), RubyNumKind> ivarKind, + Dictionary<(RClass, int), RubyNumKind> initParam, + Dictionary<(Symbol, int), RubyNumKind> selectorParam) + { + public RubyNumKind ReturnKindOf(Method m) + { + var ins = m.Ir.Instructions; + var acc = (RubyNumKind?)null; + var any = false; + for (var i = 0; i < ins.Length; i++) + { + switch (ins[i].OpCode) + { + case RubyIROpCode.Return: + acc = Meet(acc, KindOf(m, ins[i].Src0)); any = true; break; + case RubyIROpCode.ReturnValue: + { + var lit = m.Ir.GetLiteral(ins[i].Aux); + acc = Meet(acc, lit.IsFloat ? RubyNumKind.Float : lit.IsFixnum ? RubyNumKind.Integer : RubyNumKind.Unknown); + any = true; break; + } + case RubyIROpCode.ReturnSelf: + acc = Meet(acc, RubyNumKind.Unknown); any = true; break; + } + } + return any ? acc ?? RubyNumKind.Unknown : RubyNumKind.Unknown; + } + + public RubyNumKind KindOf(Method m, int v) => KindOf(m, v, new RubyNumKind?[m.Ir.ValueCount]); + + RubyNumKind KindOf(Method m, int v, RubyNumKind?[] memo) + { + if ((uint)v >= (uint)memo.Length) return RubyNumKind.Unknown; + if (memo[v] is { } cached) return cached; + memo[v] = RubyNumKind.Unknown; // break cycles conservatively + var kind = ComputeKind(m, v, memo); + memo[v] = kind; + return kind; + } + + RubyNumKind ComputeKind(Method m, int v, RubyNumKind?[] memo) + { + var d = m.DefIndex[v]; + if (d < 0) + { + // No instruction defines it: a parameter. initialize's params are typed from .new + // call sites (class-keyed); other methods' params from their call sites (selector-keyed). + if (v >= 1 && v <= m.ArgCount) + { + return m.IsInitialize + ? initParam.GetValueOrDefault((m.Cls, v)) + : selectorParam.GetValueOrDefault((m.Selector, v)); + } + return RubyNumKind.Unknown; + } + var ins = m.Ir.Instructions[d]; + var op = ins.OpCode; + switch (op) + { + case RubyIROpCode.LoadValue: + { + var lit = m.Ir.GetLiteral(ins.Aux); + return lit.IsFloat ? RubyNumKind.Float : lit.IsFixnum ? RubyNumKind.Integer : RubyNumKind.Unknown; + } + case RubyIROpCode.GetConstant: + return Analyzer.IsFloatConstantName(state, m.Ir.GetSymbol(ins.Aux)) ? RubyNumKind.Float : RubyNumKind.Unknown; + case RubyIROpCode.GetInstanceVariable: + case RubyIROpCode.VirtualGetField: + return ivarKind.GetValueOrDefault((m.Cls, m.Ir.GetSymbol(ins.Aux))); + case RubyIROpCode.Move: + return KindOf(m, ins.Src0, memo); + case RubyIROpCode.Send: + case RubyIROpCode.SendSelf: + { + var sel = m.Ir.GetCallSiteSymbol(ins.Aux); + if (Analyzer.IsBuiltinFloatMethod(state, sel)) return RubyNumKind.Float; + // Numeric unary +@/-@ and abs preserve the receiver's kind (Float.-@ -> Float, + // Integer.-@ -> Integer). `-b` lowers to a `-@` send, so without this a negated + // float reads as Unknown and poisons everything downstream. + if (m.Ir.GetCallSiteArgumentCount(ins.Aux) == 0 && + state.NameOf(sel).ToString() is "-@" or "+@" or "abs") + { + return KindOf(m, ins.Src0, memo); + } + return selectorReturn.GetValueOrDefault(sel); + } + // typed arith carry their kind; generic arith promotes from operands. + case RubyIROpCode.AddFixnum: + case RubyIROpCode.SubFixnum: + case RubyIROpCode.MulFixnum: + case RubyIROpCode.DivFixnum: + case RubyIROpCode.AddImmediateFixnum: + case RubyIROpCode.SubImmediateFixnum: + return RubyNumKind.Integer; + case RubyIROpCode.AddFloat: + case RubyIROpCode.SubFloat: + case RubyIROpCode.MulFloat: + case RubyIROpCode.DivFloat: + case RubyIROpCode.MulAddFloat: + case RubyIROpCode.MulSubFloat: + case RubyIROpCode.SubMulFloat: + case RubyIROpCode.AddImmediateFloat: + case RubyIROpCode.SubImmediateFloat: + return RubyNumKind.Float; + case RubyIROpCode.AddImmediate: + case RubyIROpCode.SubImmediate: + { + var lit = m.Ir.GetLiteral(ins.Aux); + var litKind = lit.IsFloat ? RubyNumKind.Float : lit.IsFixnum ? RubyNumKind.Integer : RubyNumKind.Unknown; + return Promote(KindOf(m, ins.Src0, memo), litKind); + } + default: + if (RubyIROpInfo.IsDoubleArith(op)) + { + var k = Promote(KindOf(m, ins.Src0, memo), KindOf(m, ins.Src1, memo)); + if (RubyIROpInfo.IsDoubleFused(op)) k = Promote(k, KindOf(m, ins.Src2, memo)); + return k; + } + return RubyNumKind.Unknown; + } + } + + // Ruby numeric promotion for `+ - * / **`-style arith: Int op Int = Int; Float anywhere = Float; + // an Unknown (possibly non-numeric / untyped) operand can't be proven, so the result is Unknown. + static RubyNumKind Promote(RubyNumKind a, RubyNumKind b) + { + if (a == RubyNumKind.Unknown || b == RubyNumKind.Unknown) return RubyNumKind.Unknown; + return a == RubyNumKind.Float || b == RubyNumKind.Float ? RubyNumKind.Float : RubyNumKind.Integer; + } + } + + static bool TryResolveNewClass(MRubyState state, Method m, int newIndex, out RClass cls) + { + cls = null!; + var classVid = m.Ir.Instructions[newIndex].Src0; + var ins = m.Ir.Instructions; + for (var hops = 0; hops < ins.Length; hops++) + { + if ((uint)classVid >= (uint)m.DefIndex.Length) return false; + var d = m.DefIndex[classVid]; + if (d < 0) return false; + var def = ins[d]; + if (def.OpCode == RubyIROpCode.Move) { classVid = def.Src0; continue; } // trace copy chains + if (def.OpCode != RubyIROpCode.GetConstant) return false; + if (!state.TryGetConst(m.Ir.GetSymbol(def.Aux), out var value) || value.Object is not RClass klass) return false; + cls = klass; + return true; + } + return false; + } + + static bool TryReadArgCount(Irep irep, out int argCount) + { + argCount = 0; + var seq = irep.Sequence; + if (seq.Length == 0 || (OpCode)seq[0] != OpCode.Enter) return false; + var aspec = new ArgumentSpec(((uint)seq[1] << 16) | ((uint)seq[2] << 8) | seq[3]); + if (aspec.OptionalArgumentsCount != 0 || aspec.TakeRestArguments || + aspec.MandatoryArguments2Count != 0 || aspec.KeywordArgumentsCount != 0 || + aspec.TakeKeywordDict || aspec.TakeBlock) + { + return false; + } + argCount = aspec.MandatoryArguments1Count; + return true; + } + + static RubyNumKind Meet(RubyNumKind? acc, RubyNumKind x) => acc is not { } a ? x : a == x ? a : RubyNumKind.Unknown; + + static void MeetInto(Dictionary map, TKey key, RubyNumKind kind) where TKey : notnull => + map[key] = map.TryGetValue(key, out var prev) ? (prev == kind ? prev : RubyNumKind.Unknown) : kind; + + static bool DictEq(Dictionary a, Dictionary b) where TKey : notnull + { + if (a.Count != b.Count) return false; + foreach (var kv in a) + { + if (!b.TryGetValue(kv.Key, out var v) || v != kv.Value) return false; + } + return true; + } +} diff --git a/src/ChibiRuby.JetPack/RubyIR/RubyIRSsaRenumber.cs b/src/ChibiRuby.JetPack/RubyIR/RubyIRSsaRenumber.cs new file mode 100644 index 00000000..e77d4792 --- /dev/null +++ b/src/ChibiRuby.JetPack/RubyIR/RubyIRSsaRenumber.cs @@ -0,0 +1,520 @@ +using System; +using System.Collections.Generic; +using ChibiRuby; + +namespace ChibiRuby.JetPack; + +// SSA-grade live-range splitting, implemented as a value-id renumbering pass (on by default; +// AOT_NOSSA=1 disables). +// +// Why: the lowerer already gives each definition a fresh value-id EXCEPT for "merge-slot" +// registers, which reuse their register-index id across the whole method so the AOT +// codegen's shared default-init local merges branch joins (an implicit phi). That +// per-register reuse conflates a register's DISJOINT live ranges under one id — the +// classic workhorse-register problem (one id holding a float in one range and an object +// in another), which blocks ComputeUnboxing from unboxing the float-only range. +// +// What: this pass splits each reused id's live ranges into the minimal set of ids that +// still merge correctly. It does NOT build explicit SSA/phi — instead it computes, by +// reaching-definitions, which definitions of a value must share storage (any two defs +// that both reach one use), unions them, and gives each resulting congruence class its +// own id. For this loop-free, forward-branch-only CFG that partition is exactly what SSA +// + phi-web coalescing would produce, and the codegen's existing shared local is its +// out-of-SSA form. Downstream analysis (ComputeUnboxing) and emission are unchanged — +// they just see finer ids, so per-range type inference now succeeds. +// +// Correctness is constructive: the lowerer's "one shared local per merge register" is +// already correct; this pass only ever SPLITS that into finer shared locals, never merges +// distinct values. Reaching-defs guarantees every def that can reach a common use stays +// in one class, so no live value loses its merge. Any structural anomaly (unsupported +// opcode, backward branch, value-count overflow) aborts to the original IR. +static class RubyIRSsaRenumber +{ + public static RubyIRMethod Run(RubyIRMethod exe, int argCount) + { + try + { + return TryRun(exe, argCount) ?? exe; + } + catch + { + return exe; + } + } + + static RubyIRMethod? TryRun(RubyIRMethod exe, int argCount) + { + var ins = exe.Instructions; + var n = ins.Length; + var oldValueCount = exe.ValueCount; + if (n == 0 || oldValueCount == 0) return null; + + // "kept" ids are never split and keep a stable mapping: params (self v0..vArgCount) + // because they are method parameters in the emitted signature, and captured ids + // because each is a single ref-cell identity passed by ref to inlined blocks. + var isKept = new bool[oldValueCount]; + for (var v = 0; v <= argCount && v < oldValueCount; v++) isKept[v] = true; + foreach (var captured in exe.ClosureCapturedValueIds) + if (captured < oldValueCount) isKept[captured] = true; + + // Def model: classify every opcode (an unsupported one aborts the pass). + var defOldId = new int[n]; // old id defined by instruction i, or -1 if it defines nothing + var dsts = new ushort[n]; // materialized so local functions can read it (ins is a ref-struct span) + for (var i = 0; i < n; i++) + { + if (!TryDefinesValue(ins[i].OpCode, out var defines)) return null; + dsts[i] = ins[i].Dst; + defOldId[i] = defines && ins[i].Dst < oldValueCount ? ins[i].Dst : -1; + } + + // Dense slot per splittable old-id (everything not kept). Each splittable id gets a + // token space: its real defs use their instruction index as a token (0..n-1), and an + // "entry" pseudo-def token (n + slot) models the default/uninitialized value reaching + // a read-before-def — so out-of-SSA default-init handles that path with no special case. + var splitSlot = new int[oldValueCount]; + Array.Fill(splitSlot, -1); + var numSplit = 0; + for (var v = 0; v < oldValueCount; v++) + if (!isKept[v]) splitSlot[v] = numSplit++; + + var defsByOldId = new List[numSplit]; + for (var s = 0; s < numSplit; s++) defsByOldId[s] = new List(); + for (var i = 0; i < n; i++) + { + var d = defOldId[i]; + if (d >= 0 && splitSlot[d] >= 0) defsByOldId[splitSlot[d]].Add(i); + } + + var numTokens = n + numSplit; + var words = (numTokens + 63) >> 6; + + // --- CFG (forward branches only -> every predecessor has a smaller index, so a single + // in-order pass computes the reaching-defs fixpoint). --- + var preds = new List[n]; + for (var i = 0; i < n; i++) preds[i] = new List(); + for (var i = 0; i < n; i++) + { + var op = ins[i].OpCode; + switch (op) + { + case RubyIROpCode.Return: + case RubyIROpCode.ReturnSelf: + case RubyIROpCode.ReturnValue: + break; // terminal: no successor + case RubyIROpCode.Jump: + if (!AddBranchPred(preds, ins[i].Aux, i, n)) return null; + break; + case RubyIROpCode.JumpIfTruthy: + case RubyIROpCode.JumpIfFalsy: + case RubyIROpCode.JumpIfNil: + case RubyIROpCode.GuardInlineClass: + if (!AddBranchPred(preds, ins[i].Aux, i, n)) return null; + if (i + 1 < n) preds[i + 1].Add(i); + break; + default: + if (i + 1 < n) preds[i + 1].Add(i); + break; + } + } + + // entry seed: all entry pseudo-defs reach the method start (and any unreachable block, + // so its emitted-but-never-run code still maps to valid ids). + var entrySeed = new ulong[words]; + for (var s = 0; s < numSplit; s++) SetBit(entrySeed, n + s); + + // Reaching-defs fixpoint. Forward-only methods converge in a single in-order pass (every + // predecessor index is smaller); a loop back-edge (pred index > node index) feeds the loop + // header, so iterate until stable. Reaching sets only grow (monotone), so this terminates. + var inSets = new ulong[n][]; + for (var i = 0; i < n; i++) inSets[i] = new ulong[words]; + for (var i = 0; i < n; i++) + if (i == 0 || preds[i].Count == 0) Array.Copy(entrySeed, inSets[i], words); + var outBuf = new ulong[words]; + var inChanged = true; + while (inChanged) + { + inChanged = false; + for (var i = 0; i < n; i++) + { + if (i == 0 || preds[i].Count == 0) continue; // fixed entry seed + var inI = inSets[i]; + foreach (var p in preds[i]) + { + ComputeOut(inSets[p], defOldId[p], splitSlot, defsByOldId, p, n, words, outBuf); + if (OrChanged(inI, outBuf)) inChanged = true; + } + } + } + + // --- union-find over tokens: a use unions all defs of its value that reach it --- + var uf = new int[numTokens]; + for (var t = 0; t < numTokens; t++) uf[t] = t; + + for (var i = 0; i < n; i++) + { + foreach (var useId in EnumerateUses(exe, ins[i])) + { + if (useId < 0 || useId >= oldValueCount) continue; + var slot = splitSlot[useId]; + if (slot < 0) continue; // kept id: not split + UnionReaching(uf, inSets[i], useId, slot, defsByOldId[slot], n); + } + } + + + // --- assign new ids --- + // Kept ids (params + captured) keep their ORIGINAL id: params are the emitted method + // parameters, and the block-inline upvar mechanism binds a captured method-local by + // `ref v` where the captured value-id == its register number — renumbering a + // captured id would dangle that ref. Congruence classes get fresh ids that skip the + // reserved captured numbers so they never collide. + var keptNewId = new int[oldValueCount]; + for (var v = 0; v < oldValueCount; v++) keptNewId[v] = isKept[v] ? v : -1; + + var capturedReserved = new HashSet(); + var maxKept = argCount; + foreach (var captured in exe.ClosureCapturedValueIds) + { + if (captured >= oldValueCount) continue; + if (captured > argCount) capturedReserved.Add(captured); + if (captured > maxKept) maxKept = captured; + } + + var classNewId = new int[numTokens]; + Array.Fill(classNewId, -1); + var next = argCount + 1; + + int AllocId() + { + while (capturedReserved.Contains(next)) next++; + return next++; + } + + int RootId(int token) + { + var root = Find(uf, token); + if (classNewId[root] < 0) classNewId[root] = AllocId(); + return classNewId[root]; + } + + int MapUse(int instr, int useId) + { + if (useId < 0 || useId >= oldValueCount) return useId; + var slot = splitSlot[useId]; + if (slot < 0) return keptNewId[useId]; + var rep = ReachingRep(inSets[instr], slot, defsByOldId[slot], n); + if (rep < 0) throw new InvalidOperationException("ssa: use with no reaching def"); + return RootId(rep); + } + + int MapDef(int instr) + { + var d = defOldId[instr]; + if (d < 0) return dsts[instr]; // non-defining: Dst is unused (0) -> kept identity below + if (splitSlot[d] < 0) return keptNewId[d]; + return RootId(instr); // real def token == instruction index + } + + // --- rewrite (single deterministic pass; RootId assigns ids in first-seen order) --- + var newOperandPool = exe.CloneOperandPool(); + var newInstructions = new RubyIRInstruction[n]; + for (var i = 0; i < n; i++) + { + var instruction = ins[i]; + var op = instruction.OpCode; + + var newDst = (ushort)MapDef(i); + var newSrc0 = instruction.Src0; + var newSrc1 = instruction.Src1; + var newSrc2 = instruction.Src2; + + // Src0 is always a value operand (branch condition / receiver / store value / etc.; + // for ops that leave it unused it is 0 == self, which maps to itself). + newSrc0 = (ushort)MapUse(i, instruction.Src0); + + // Src1/Src2 are value operands EXCEPT where they encode an index, mirroring + // RubyIRMethod.CountValueUses. + var src1IsIndex = op is RubyIROpCode.GuardInlineClass + or RubyIROpCode.SendBlockDescriptor or RubyIROpCode.SendSelfBlockDescriptor; + if (!src1IsIndex) + { + newSrc1 = (ushort)MapUse(i, instruction.Src1); + newSrc2 = (ushort)MapUse(i, instruction.Src2); + } + + // Call-site arguments and array operand lists live in the operand pool. + switch (op) + { + case RubyIROpCode.Send: + case RubyIROpCode.SendSelf: + case RubyIROpCode.SendBlock: + case RubyIROpCode.SendSelfBlock: + case RubyIROpCode.SendBlockDescriptor: + case RubyIROpCode.SendSelfBlockDescriptor: + case RubyIROpCode.PureUnarySend: + case RubyIROpCode.VirtualNew: + { + var start = exe.CallSiteArgumentStart(instruction.Aux); + var argc = exe.GetCallSiteArgumentCount(instruction.Aux); + for (var a = 0; a < argc; a++) + newOperandPool[start + a] = MapUse(i, newOperandPool[start + a]); + break; + } + case RubyIROpCode.NewArray: + case RubyIROpCode.NewArray2: + case RubyIROpCode.NewHash: + { + var start = exe.OperandListStart(instruction.Aux); + var count = exe.GetOperandListCount(instruction.Aux); + for (var a = 0; a < count; a++) + newOperandPool[start + a] = MapUse(i, newOperandPool[start + a]); + break; + } + } + + newInstructions[i] = new RubyIRInstruction(op, newDst, newSrc0, newSrc1, newSrc2, instruction.Aux); + } + + // Captured ids are uses for LoadBlock / block sends but are read from this list, not from + // instruction fields — remap the list itself (each captured id is kept => stable). + var oldCaptured = exe.ClosureCapturedValueIds; + var newCaptured = new ushort[oldCaptured.Length]; + for (var j = 0; j < oldCaptured.Length; j++) + { + var c = oldCaptured[j]; + newCaptured[j] = c < oldValueCount && keptNewId[c] >= 0 ? (ushort)keptNewId[c] : c; + } + + var newValueCount = Math.Max(next, maxKept + 1); + if (newValueCount > ushort.MaxValue) return null; // value ids are ushort + + return exe.CreateSsaVariant(newInstructions, newOperandPool, newCaptured, newValueCount); + } + + // out[i] = (in[i] \ kill[i]) | gen[i], where a def of splittable id M kills M's entry token + // and all of M's def tokens, and generates this instruction's token. + static void ComputeOut(ulong[] inSet, int defId, int[] splitSlot, List[] defsByOldId, int instr, int n, int words, ulong[] outBuf) + { + Array.Copy(inSet, outBuf, words); + if (defId < 0) return; + var slot = splitSlot[defId]; + if (slot < 0) return; // kept id def: no token, nothing to kill/gen + ClearBit(outBuf, n + slot); // kill entry token + foreach (var d in defsByOldId[slot]) ClearBit(outBuf, d); // kill all defs of M + SetBit(outBuf, instr); // gen this def + } + + // A representative reaching-def token for a splittable id at a program point: its entry + // pseudo-def if it reaches (default/uninitialized), else any reaching real def (all of which + // are in one union-find class), or -1 if none reach (only on truly unreachable code). + static int ReachingRep(ulong[] inSet, int slot, List defs, int n) + { + var entryToken = n + slot; + if (TestBit(inSet, entryToken)) return entryToken; + foreach (var d in defs) + if (TestBit(inSet, d)) return d; + return -1; + } + + static void UnionReaching(int[] uf, ulong[] inSet, int useId, int slot, List defs, int n) + { + var first = -1; + var entryToken = n + slot; + if (TestBit(inSet, entryToken)) first = entryToken; + foreach (var d in defs) + { + if (!TestBit(inSet, d)) continue; + if (first < 0) first = d; + else Union(uf, first, d); + } + } + + static bool AddBranchPred(List[] preds, int target, int from, int n) + { + if (target < 0) return false; + if (target >= n) return true; // jump to the trailing end label: no successor instruction + preds[target].Add(from); // backward targets (loops) are allowed: the reaching- + return true; // defs solver below iterates to a fixpoint over them. + } + + // Every opcode must be classified. Common value-producing and side-effecting ops are + // handled precisely; ops whose def/use shape this pass does not model (guards other than + // GuardInlineClass, InlineBody, TypeSwitch, MaterializeObject) return false to abort the + // pass for that method (safe fallback to no renumbering). + static bool TryDefinesValue(RubyIROpCode op, out bool defines) + { + switch (op) + { + // value-producing + case RubyIROpCode.Move: + case RubyIROpCode.LoadValue: + case RubyIROpCode.LoadSelf: + case RubyIROpCode.LoadBlock: + case RubyIROpCode.GetUpVar: + case RubyIROpCode.GetConstant: + case RubyIROpCode.GetModuleConstant: + case RubyIROpCode.GetInstanceVariable: + case RubyIROpCode.VirtualGetField: + case RubyIROpCode.VirtualNew: + case RubyIROpCode.GetIndex: + case RubyIROpCode.GetIndex0: + case RubyIROpCode.SetIndex: // a[b] = c lowers via Define (returns the assigned value) + case RubyIROpCode.ArrayRef: + case RubyIROpCode.NewArray: + case RubyIROpCode.NewArray2: + case RubyIROpCode.NewHash: + case RubyIROpCode.Send: + case RubyIROpCode.SendSelf: + case RubyIROpCode.SendBlock: + case RubyIROpCode.SendSelfBlock: + case RubyIROpCode.SendBlockDescriptor: + case RubyIROpCode.SendSelfBlockDescriptor: + case RubyIROpCode.PureUnarySend: + case RubyIROpCode.Add: + case RubyIROpCode.AddImmediate: + case RubyIROpCode.Sub: + case RubyIROpCode.SubImmediate: + case RubyIROpCode.AddImmediateFixnum: + case RubyIROpCode.SubImmediateFixnum: + case RubyIROpCode.AddImmediateFloat: + case RubyIROpCode.SubImmediateFloat: + case RubyIROpCode.Mul: + case RubyIROpCode.Div: + case RubyIROpCode.MulAdd: + case RubyIROpCode.MulSub: + case RubyIROpCode.SubMul: + case RubyIROpCode.AddFixnum: + case RubyIROpCode.SubFixnum: + case RubyIROpCode.MulFixnum: + case RubyIROpCode.DivFixnum: + case RubyIROpCode.AddFloat: + case RubyIROpCode.SubFloat: + case RubyIROpCode.MulFloat: + case RubyIROpCode.DivFloat: + case RubyIROpCode.MulAddFloat: + case RubyIROpCode.MulSubFloat: + case RubyIROpCode.SubMulFloat: + case RubyIROpCode.Eq: + case RubyIROpCode.Lt: + case RubyIROpCode.Le: + case RubyIROpCode.Gt: + case RubyIROpCode.Ge: + case RubyIROpCode.LtFixnum: + case RubyIROpCode.LeFixnum: + case RubyIROpCode.GtFixnum: + case RubyIROpCode.GeFixnum: + case RubyIROpCode.LtFloat: + case RubyIROpCode.LeFloat: + case RubyIROpCode.GtFloat: + case RubyIROpCode.GeFloat: + defines = true; + return true; + + // no value def + case RubyIROpCode.CheckArity: + case RubyIROpCode.SetUpVar: + case RubyIROpCode.SetInstanceVariable: + case RubyIROpCode.VirtualSetField: + case RubyIROpCode.ArraySet: + case RubyIROpCode.Jump: + case RubyIROpCode.JumpIfTruthy: + case RubyIROpCode.JumpIfFalsy: + case RubyIROpCode.JumpIfNil: + case RubyIROpCode.GuardInlineClass: + case RubyIROpCode.Return: + case RubyIROpCode.ReturnSelf: + case RubyIROpCode.ReturnValue: + defines = false; + return true; + + // shapes this pass does not model -> abort + default: + defines = false; + return false; + } + } + + // Use operands of an instruction, matching RubyIRMethod.CountValueUses exactly. + // Captured ids (LoadBlock / block descriptors) are intentionally NOT yielded here — they + // are remapped via the captured-id list, not via instruction fields. + static IEnumerable EnumerateUses(RubyIRMethod exe, RubyIRInstruction instruction) + { + yield return instruction.Src0; + if (instruction.OpCode is not ( + RubyIROpCode.GuardInlineClass or + RubyIROpCode.SendBlockDescriptor or + RubyIROpCode.SendSelfBlockDescriptor)) + { + yield return instruction.Src1; + yield return instruction.Src2; + } + + switch (instruction.OpCode) + { + case RubyIROpCode.Send: + case RubyIROpCode.SendSelf: + case RubyIROpCode.SendBlock: + case RubyIROpCode.SendSelfBlock: + case RubyIROpCode.SendBlockDescriptor: + case RubyIROpCode.SendSelfBlockDescriptor: + case RubyIROpCode.PureUnarySend: + case RubyIROpCode.VirtualNew: + { + var start = exe.CallSiteArgumentStart(instruction.Aux); + var argc = exe.GetCallSiteArgumentCount(instruction.Aux); + for (var a = 0; a < argc; a++) yield return exe.OperandPoolValue(start + a); + break; + } + case RubyIROpCode.NewArray: + case RubyIROpCode.NewArray2: + case RubyIROpCode.NewHash: + { + var start = exe.OperandListStart(instruction.Aux); + var count = exe.GetOperandListCount(instruction.Aux); + for (var a = 0; a < count; a++) yield return exe.OperandPoolValue(start + a); + break; + } + } + } + + // --- union-find --- + static int Find(int[] uf, int x) + { + while (uf[x] != x) + { + uf[x] = uf[uf[x]]; + x = uf[x]; + } + return x; + } + + static void Union(int[] uf, int a, int b) + { + var ra = Find(uf, a); + var rb = Find(uf, b); + if (ra == rb) return; + // keep the smaller root for deterministic representatives + if (ra < rb) uf[rb] = ra; else uf[ra] = rb; + } + + // --- bitset --- + static void SetBit(ulong[] bits, int i) => bits[i >> 6] |= 1UL << (i & 63); + static void ClearBit(ulong[] bits, int i) => bits[i >> 6] &= ~(1UL << (i & 63)); + static bool TestBit(ulong[] bits, int i) => (bits[i >> 6] & (1UL << (i & 63))) != 0; + static void Or(ulong[] dst, ulong[] src) + { + for (var w = 0; w < dst.Length; w++) dst[w] |= src[w]; + } + // OR src into dst, returning true if any bit was added (for the reaching-defs fixpoint). + static bool OrChanged(ulong[] dst, ulong[] src) + { + var changed = false; + for (var w = 0; w < dst.Length; w++) + { + var before = dst[w]; + var after = before | src[w]; + if (after != before) { dst[w] = after; changed = true; } + } + return changed; + } +} diff --git a/src/ChibiRuby/AotGeneratedMethods.cs b/src/ChibiRuby/AotGeneratedMethods.cs new file mode 100644 index 00000000..aa6ff29f --- /dev/null +++ b/src/ChibiRuby/AotGeneratedMethods.cs @@ -0,0 +1,298 @@ +using System.Runtime.CompilerServices; +using ChibiRuby.Internals; +#if NET7_0_OR_GREATER +using static System.Runtime.InteropServices.MemoryMarshal; +#else +using static ChibiRuby.Internal.MemoryMarshalEx; +#endif + +namespace ChibiRuby; + +/// +/// Base type for AOT-generated method-body classes. It lives in the ChibiRuby assembly, so it can +/// touch internal VM state (the frame stack, the ivar table); it re-exposes exactly that state to +/// generated subclasses through protected static helpers. +/// +/// +/// The generated code is emitted into a separate assembly (a friend source-gen assembly in +/// production, a runtime-Roslyn assembly in the PoC). Rather than make the VM internals public or +/// grant a blanket InternalsVisibleTo, generated classes derive from this base and call the +/// helpers below. protected static members are reachable from a derived class's static +/// methods even across assembly boundaries (family access crosses assemblies, and the +/// derived-instance restriction does not apply to static members), so the VM internals stay +/// internal. The helpers are aggressively inlined, so this indirection costs nothing at runtime. +/// +public abstract class AotGeneratedMethods +{ + // The helpers are suffixed "Unsafe": they read raw VM internals (the frame stack via an unchecked + // ref offset, the in-flight call info) with no bounds/state validation, and are only sound when + // called from a generated body bound to the matching irep. The suffix flags that they are not a + // safe public API even though family access makes them reachable across the assembly boundary. + + /// The frame register at sp + index — used by wrappers to marshal self/args off the stack. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static MRubyValue RegisterUnsafe(MRubyState state, int sp, int index) + { + return Unsafe.Add(ref GetArrayDataReference(state.Context.Stack), sp + index); + } + + /// Argument count of the in-flight call — for the wrapper's arity guard. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static int ArgumentCountUnsafe(MRubyState state) + { + return state.Context.CurrentCallInfo.ArgumentCount; + } + + // --- Operations targeted by generated bodies --------------------------------------------- + // Moved here from MRubyState's public surface: they are only sound when called from a + // generated body (their guards are validated against per-call-site inline-cache fields the + // codegen emits), so they don't belong on the public VM API. Each takes the state explicitly + // and reaches the VM internals through it. All carry the "Unsafe" suffix for the same reason + // as the helpers above — reachable across the assembly boundary, but not a safe public API. + + // Monomorphic inline-cache guard for a devirtualized self-send. The codegen inlined (at build + // time) the callee whose irep fingerprint is `expectedCalleeFingerprint`, resolved against the + // method's defining class. This decides at runtime whether that inlined body is still valid for + // `receiver`: true => run the inline form; false => fall back to a normal Send (polymorphic + // receiver / overriding subclass / redefined method). icClass+icVersion are per-call-site static + // fields. Fast path: receiver's class and the method-cache version both match the cached pair. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static bool InlineGuardUnsafe( + MRubyState state, + MRubyValue receiver, + Symbol methodId, + ulong expectedCalleeFingerprint, + ref RClass? icClass, + ref int icVersion) + { + var cls = receiver.Object is { } o ? o.Class : state.ClassOf(receiver); + if (ReferenceEquals(cls, icClass) && icVersion == state.MethodCacheVersion) + { + return true; + } + return InlineGuardResolve(state, cls, methodId, expectedCalleeFingerprint, ref icClass, ref icVersion); + } + + // Slow path of InlineGuardUnsafe, split out (NoInlining) so the steady-state fast path stays + // small enough for the JIT to inline: re-resolve the method on the receiver's actual class and + // confirm it's the SAME body we inlined (fingerprint identity — so an override on a subclass can + // never run the base inline); on match, refresh the cache. + [MethodImpl(MethodImplOptions.NoInlining)] + static bool InlineGuardResolve( + MRubyState state, + RClass cls, + Symbol methodId, + ulong expectedCalleeFingerprint, + ref RClass? icClass, + ref int icVersion) + { + if (state.TryFindMethod(cls, methodId, out var method, out _) && + method.Proc is { } proc && + state.ComputeIrepFingerprint(proc.Irep) == expectedCalleeFingerprint) + { + icClass = cls; + icVersion = state.MethodCacheVersion; + return true; + } + icClass = null; + return false; + } + + // Guard for a scalar-replaced allocation. When the codegen elides a `Const.new(...)` and inlines + // its initialize / accessors, the emitted code is valid only while (a) `constName` still resolves + // to the class it was compiled against and (b) the inlined method `methodId` on that class still + // has the body fingerprint it was compiled from. Match => the call site is safe; miss => deopt. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static bool ClassMethodGuardUnsafe( + MRubyState state, + Symbol constName, + Symbol methodId, + ulong expectedFingerprint, + ref RClass? icClass, + ref int icVer) + { + if (icClass is not null && icVer == state.MethodCacheVersion) + { + return true; + } + if (state.TryGetConst(constName, out var value) && + value.Object is RClass klass && + state.TryFindMethod(klass, methodId, out var method, out _) && + method.Proc is { } proc && + state.ComputeIrepFingerprint(proc.Irep) == expectedFingerprint) + { + icClass = klass; + icVer = state.MethodCacheVersion; + return true; + } + icClass = null; + return false; + } + + // Resolve a candidate receiver class for a class-switch dispatch (stack-arg Send). Returns the + // class iff `constName` still resolves to a class whose `sel` method has body fingerprint `fp` + // (the exact body the struct variant was compiled from); null otherwise. The generated dispatch + // pointer-compares the receiver's class against the result — a hit runs the variant, a miss + // (incl. null) reifies + Sends. Fingerprint identity makes this sound (subclass override => null). + protected static RClass? ResolveGuardClassUnsafe(MRubyState state, Symbol constName, Symbol sel, ulong fp) + { + if (state.TryGetConst(constName, out var value) && + value.Object is RClass klass && + state.TryFindMethod(klass, sel, out var method, out _) && + method.Proc is { } proc && + state.ComputeIrepFingerprint(proc.Irep) == fp) + { + return klass; + } + return null; + } + + // Guard for inlined object construction (fast-new). Valid only while (a) `:new` on `classValue` + // is still the default C# builtin (a Ruby `def self.new` override has a Proc and must run) and + // (b) `:initialize` is the simple setter body it inlined (fingerprint unchanged). Miss => deopt. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static bool InlineNewGuardUnsafe( + MRubyState state, + MRubyValue classValue, + Symbol newId, + Symbol initId, + ulong initFingerprint, + ref RClass? icClass, + ref int icVer) + { + if (classValue.Object is not RClass cls) + { + icClass = null; + return false; + } + if (ReferenceEquals(cls, icClass) && icVer == state.MethodCacheVersion) + { + return true; + } + if (state.TryFindMethod(state.ClassOf(classValue), newId, out var newMethod, out _) && + newMethod.Proc is null && + state.TryFindMethod(cls, initId, out var init, out _) && + init.Proc is { } proc && + state.ComputeIrepFingerprint(proc.Irep) == initFingerprint) + { + icClass = cls; + icVer = state.MethodCacheVersion; + return true; + } + icClass = null; + return false; + } + + // Indexed get/set — mirror the interpreter's OP_GetIdx / OP_GetIdx0 / OP_SetIdx fast paths: a + // plain Array (exact ArrayClass) with a fixnum index goes straight through RArray (its indexer + // handles negative / out-of-range -> nil); anything else falls back to the same :[] / :[]= send + // the interpreter would do, so semantics match. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static MRubyValue GetIndexUnsafe(MRubyState state, MRubyValue receiver, MRubyValue key) + { + if (receiver.Object is RArray array && key.IsFixnum && array.Class == state.ArrayClass) + { + return array[(int)key.FixnumValue]; + } + return state.Send(receiver, Names.OpAref, key); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static MRubyValue GetIndexZeroUnsafe(MRubyState state, MRubyValue receiver) + { + if (receiver.Object is RArray array && array.Class == state.ArrayClass) + { + return array.Length > 0 ? array[0] : default; + } + return state.Send(receiver, Names.OpAref, new MRubyValue(0L)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static MRubyValue SetIndexUnsafe(MRubyState state, MRubyValue receiver, MRubyValue key, MRubyValue value) + { + if (receiver.Object is RArray array && key.IsFixnum && array.Class == state.ArrayClass + && !array.HasFlag(MRubyObjectFlags.Frozen)) + { + array.Set((int)key.FixnumValue, value); + return value; + } + return state.Send(receiver, Names.OpAset, key, value); + } + + // Fast path for one-argument C# methods registered with a pure-unary delegate (Math.sqrt/cos/sin + // in the benchmark harness). Still resolves the current method, so Ruby redefinition falls back + // to normal Send semantics. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static MRubyValue PureUnarySendUnsafe(MRubyState state, MRubyValue receiver, Symbol methodId, MRubyValue argument) + { + if (state.TryFindMethod(state.ClassOf(receiver), methodId, out var method, out _) && + method != MRubyMethod.Undef && + method.TryInvokePureUnaryNumeric(state, receiver, argument, out var result)) + { + return result; + } + return state.Send(receiver, methodId, argument); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static MRubyValue PureUnarySendUnsafe( + MRubyState state, + MRubyValue receiver, + Symbol methodId, + MRubyValue argument, + ref RClass? icClass, + ref int icVersion, + ref MRubyMethod icMethod) + { + var cls = state.ClassOf(receiver); + if (ReferenceEquals(cls, icClass) && + icVersion == state.MethodCacheVersion && + icMethod.TryInvokePureUnaryNumeric(state, receiver, argument, out var cachedResult)) + { + return cachedResult; + } + if (state.TryFindMethod(cls, methodId, out var method, out _) && + method != MRubyMethod.Undef && + method.TryInvokePureUnaryNumeric(state, receiver, argument, out var result)) + { + icClass = cls; + icVersion = state.MethodCacheVersion; + icMethod = method; + return result; + } + icClass = null; + return state.Send(receiver, methodId, argument); + } + + // Constant lookup — identical to OP_GetConst (fast path on the current frame's scope class, then + // ResolveConstantSlow which walks the proc's lexical Upper chain). Shares ResolveConstantSlow with + // the interpreter (kept on MRubyState) so the resolution can't drift. + protected static MRubyValue GetConstantUnsafe(MRubyState state, Symbol id) + { + ref var callInfo = ref state.Context.CurrentCallInfo; + var c = callInfo.Proc?.Scope?.TargetClass ?? state.ObjectClass; + if (c.ClassInstanceVariables.TryGet(id, out var value)) + { + return value; + } + return state.ResolveConstantSlow(ref callInfo, id, c); + } + + // Per-call-site cached constant read. Constant resolution at a given lexical site is stable until + // some constant is (re)assigned, which bumps ConstCacheVersion — so a version match returns the + // cached value and skips the scope-chain walk. The cache fields are static (shared across states), + // so the cached state is part of the key: a different MRubyState (different constant tables, even + // at the same version number) re-resolves. `cachedState` is null on first use. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static MRubyValue GetConstantCachedUnsafe(MRubyState state, Symbol id, ref MRubyValue cached, ref int cachedVer, ref MRubyState? cachedState) + { + if (cachedVer == state.ConstCacheVersion && ReferenceEquals(cachedState, state)) + { + return cached; + } + cached = GetConstantUnsafe(state, id); + cachedVer = state.ConstCacheVersion; + cachedState = state; + return cached; + } +} diff --git a/src/ChibiRuby/ChibiRuby.csproj b/src/ChibiRuby/ChibiRuby.csproj index c69809ee..7d2fbe2e 100644 --- a/src/ChibiRuby/ChibiRuby.csproj +++ b/src/ChibiRuby/ChibiRuby.csproj @@ -14,6 +14,7 @@ + diff --git a/src/ChibiRuby/Internals/MRubyCallInfo.cs b/src/ChibiRuby/Internals/MRubyCallInfo.cs index d2bd7997..3f24124f 100644 --- a/src/ChibiRuby/Internals/MRubyCallInfo.cs +++ b/src/ChibiRuby/Internals/MRubyCallInfo.cs @@ -73,6 +73,10 @@ internal static int CalculateKeywordArgumentOffset(int argc, int kargc) // for stacktrace.. public CallerType CallerType; public ICallScope Scope; + internal ChibiRuby.REnv? OptimizedBlockEnvironment; + internal ChibiRuby.RProc? OptimizedBlockUpperProc; + internal int OptimizedBlockRegisterCount; + internal int OptimizerRegisterVariableCount; public Symbol MethodId; public MRubyMethodVisibility Visibility; public bool VisibilityBreak; @@ -97,7 +101,11 @@ public int NumberOfRegisters var numberOfRegisters = BlockArgumentOffset + 1; // self + args + kargs + blk if (Proc is { } p && p.Irep.RegisterVariableCount > numberOfRegisters) { - return p.Irep.RegisterVariableCount; + numberOfRegisters = p.Irep.RegisterVariableCount; + } + if (OptimizerRegisterVariableCount > numberOfRegisters) + { + numberOfRegisters = OptimizerRegisterVariableCount; } return numberOfRegisters; } @@ -110,6 +118,10 @@ public void Clear() // Proc?.SetFlag(MRubyObjectFlags.ProcOrphan); Proc = null; Scope = null!; + OptimizedBlockEnvironment = null; + OptimizedBlockUpperProc = null; + OptimizedBlockRegisterCount = 0; + OptimizerRegisterVariableCount = 0; MethodId = default; ArgumentCount = 0; KeywordArgumentCount = 0; diff --git a/src/ChibiRuby/Internals/Operands.cs b/src/ChibiRuby/Internals/Operands.cs index 191a50a2..3d439ee4 100644 --- a/src/ChibiRuby/Internals/Operands.cs +++ b/src/ChibiRuby/Internals/Operands.cs @@ -140,6 +140,86 @@ public static OperandBBB Read(ref byte sequence, ref int pc) } } +[StructLayout(LayoutKind.Explicit)] +unsafe struct OperandBBBS +{ + [FieldOffset(0)] + public byte A; + + [FieldOffset(1)] + public byte B; + + [FieldOffset(2)] + public byte C; + + [FieldOffset(3)] + fixed byte bytesD[2]; + + public int D => (bytesD[0] << 8) | bytesD[1]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static OperandBBBS Read(ref byte sequence, ref int pc) + { + pc += 6; + return Unsafe.ReadUnaligned(ref Unsafe.Add(ref sequence, pc - 5)); + } +} + +[StructLayout(LayoutKind.Explicit)] +unsafe struct OperandBBSS +{ + [FieldOffset(0)] + public byte A; + + [FieldOffset(1)] + public byte B; + + [FieldOffset(2)] + fixed byte bytesC[2]; + + [FieldOffset(4)] + fixed byte bytesD[2]; + + public int C => (bytesC[0] << 8) | bytesC[1]; + public int D => (bytesD[0] << 8) | bytesD[1]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static OperandBBSS Read(ref byte sequence, ref int pc) + { + pc += 7; + return Unsafe.ReadUnaligned(ref Unsafe.Add(ref sequence, pc - 6)); + } +} + +[StructLayout(LayoutKind.Explicit)] +unsafe struct OperandBBBSS +{ + [FieldOffset(0)] + public byte A; + + [FieldOffset(1)] + public byte B; + + [FieldOffset(2)] + public byte C; + + [FieldOffset(3)] + fixed byte bytesD[2]; + + [FieldOffset(5)] + fixed byte bytesE[2]; + + public int D => (bytesD[0] << 8) | bytesD[1]; + public int E => (bytesE[0] << 8) | bytesE[1]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static OperandBBBSS Read(ref byte sequence, ref int pc) + { + pc += 8; + return Unsafe.ReadUnaligned(ref Unsafe.Add(ref sequence, pc - 7)); + } +} + [StructLayout(LayoutKind.Explicit)] unsafe struct OperandBSS { @@ -177,4 +257,4 @@ public static OperandW Read(ref byte sequence, ref int pc) pc += 4; return Unsafe.ReadUnaligned(ref Unsafe.Add(ref sequence, pc - 3)); } -} \ No newline at end of file +} diff --git a/src/ChibiRuby/Irep.cs b/src/ChibiRuby/Irep.cs index ad36a28f..5538fabb 100644 --- a/src/ChibiRuby/Irep.cs +++ b/src/ChibiRuby/Irep.cs @@ -1,7 +1,14 @@ -using System.Runtime.CompilerServices; - namespace ChibiRuby; +// AOT-compiled body for one irep — what a build-time Ruby->C# generator emits for a +// hot, statically-compilable method. It runs directly on the call frame instead of +// interpreting the irep's bytecode: `self` is at stack[stackPointer], args at +// stack[stackPointer + 1 ..]. Returns true with `result` set when its speculative +// type/shape guards hold; returns false (having mutated nothing) to deopt — the VM +// then interprets the irep's bytecode. Ordinary compiled C# (no runtime IL), so it +// works under IL2CPP / NativeAOT. +public delegate bool CompiledRubyMethodBody(MRubyState state, int stackPointer, out MRubyValue result); + public enum CatchHandlerType : byte { Rescue = 0, @@ -50,6 +57,21 @@ public class Irep /// public IrepDebugInfo? DebugInfo { get; internal set; } + // AOT-compiled C# body for THIS irep (null = interpret the bytecode). The + // bytecode VM branches on this where an irep begins executing: if set and its + // speculative guards hold, the compiled body runs and the bytecode is ignored; + // on a guard miss the body returns false and this same irep is interpreted + // (the irep is its own deopt fallback). Attached at load time by the codegen + // layer; keyed by the irep itself (no class/method names involved). + internal CompiledRubyMethodBody? CompiledBody; + + // Memoized content fingerprint (see MRubyState.ComputeIrepFingerprint). An irep is + // immutable once built and its fingerprint depends only on its content, so the first + // computation is cached here forever. Guard miss paths (poly-dispatch sites, first + // calls, post-redefinition) and the build-time codegen would otherwise re-hash the + // entire irep tree (bytecode + symbol names + pool + child ireps) on every lookup. + internal ulong? CachedFingerprint; + public bool TryFindCatchHandler(int pc, CatchHandlerType filter, out CatchHandler handler) { var noFilter = filter == CatchHandlerType.All; diff --git a/src/ChibiRuby/MRubyMethod.cs b/src/ChibiRuby/MRubyMethod.cs index 488b0534..85c1d8a4 100644 --- a/src/ChibiRuby/MRubyMethod.cs +++ b/src/ChibiRuby/MRubyMethod.cs @@ -4,6 +4,8 @@ namespace ChibiRuby; public delegate MRubyValue MRubyFunc(MRubyState state, MRubyValue self); +public delegate MRubyValue MRubyPureUnaryFunc(MRubyState state, MRubyValue self, MRubyValue argument); +public delegate MRubyValue MRubyPureUnaryFloatFunc(MRubyState state, MRubyValue self, double argument); public enum MRubyMethodKind { @@ -19,6 +21,13 @@ public enum MRubyMethodVisibility Protected, } +[Flags] +public enum MRubyMethodFlags +{ + None = 0, + Pure = 1, +} + public readonly struct MRubyMethod : IEquatable { public static readonly MRubyMethod Nop = new((_, _) => MRubyValue.Nil); @@ -28,8 +37,11 @@ public enum MRubyMethodVisibility public static readonly MRubyMethod Identity = new((_, self) => self); readonly object? body; + readonly MRubyPureUnaryFunc? pureUnaryFunc; + readonly MRubyPureUnaryFloatFunc? pureUnaryFloatFunc; public readonly MRubyMethodVisibility Visibility; public readonly MRubyMethodKind Kind; + public readonly MRubyMethodFlags Flags; /// /// When non-default, this method is a trivial ivar getter (no-arg method that returns this ivar). @@ -38,6 +50,12 @@ public enum MRubyMethodVisibility /// public readonly Symbol TrivialGetterIVarSymbol; + /// + /// When non-default, this method is a trivial ivar setter (one-arg method that writes this ivar). + /// Used by Send fast paths and scalar replacement to skip full dispatch. + /// + public readonly Symbol TrivialSetterIVarSymbol; + public RProc? Proc { [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -47,28 +65,110 @@ public RProc? Proc public MRubyMethod(RProc proc, MRubyMethodVisibility visibility = MRubyMethodVisibility.Default) { body = proc; + pureUnaryFunc = null; + pureUnaryFloatFunc = null; Kind = MRubyMethodKind.RProc; Visibility = visibility; + Flags = MRubyMethodFlags.None; + TrivialGetterIVarSymbol = default; + TrivialSetterIVarSymbol = default; + } + + public MRubyMethod( + MRubyFunc? func, + MRubyMethodVisibility visibility = MRubyMethodVisibility.Default, + MRubyMethodFlags flags = MRubyMethodFlags.None) + { + body = func; + pureUnaryFunc = null; + pureUnaryFloatFunc = null; + Kind = MRubyMethodKind.CSharpFunc; + Visibility = visibility; + Flags = flags; + TrivialGetterIVarSymbol = default; + TrivialSetterIVarSymbol = default; } - public MRubyMethod(MRubyFunc? func, MRubyMethodVisibility visibility = MRubyMethodVisibility.Default) + public MRubyMethod( + MRubyFunc? func, + MRubyPureUnaryFunc pureUnaryFunc, + MRubyMethodVisibility visibility = MRubyMethodVisibility.Default, + MRubyMethodFlags flags = MRubyMethodFlags.Pure) { body = func; + this.pureUnaryFunc = pureUnaryFunc; + pureUnaryFloatFunc = null; Kind = MRubyMethodKind.CSharpFunc; Visibility = visibility; + Flags = flags | MRubyMethodFlags.Pure; + TrivialGetterIVarSymbol = default; + TrivialSetterIVarSymbol = default; } - MRubyMethod(object body, MRubyMethodKind kind, MRubyMethodVisibility visibility, Symbol trivialGetterIVarSymbol) + public MRubyMethod( + MRubyFunc? func, + MRubyPureUnaryFunc pureUnaryFunc, + MRubyPureUnaryFloatFunc pureUnaryFloatFunc, + MRubyMethodVisibility visibility = MRubyMethodVisibility.Default, + MRubyMethodFlags flags = MRubyMethodFlags.Pure) + { + body = func; + this.pureUnaryFunc = pureUnaryFunc; + this.pureUnaryFloatFunc = pureUnaryFloatFunc; + Kind = MRubyMethodKind.CSharpFunc; + Visibility = visibility; + Flags = flags | MRubyMethodFlags.Pure; + TrivialGetterIVarSymbol = default; + TrivialSetterIVarSymbol = default; + } + + MRubyMethod( + object body, + MRubyMethodKind kind, + MRubyMethodVisibility visibility, + Symbol trivialGetterIVarSymbol, + Symbol trivialSetterIVarSymbol, + MRubyMethodFlags flags, + MRubyPureUnaryFunc? pureUnaryFunc = null, + MRubyPureUnaryFloatFunc? pureUnaryFloatFunc = null) { this.body = body; + this.pureUnaryFunc = pureUnaryFunc; + this.pureUnaryFloatFunc = pureUnaryFloatFunc; Kind = kind; Visibility = visibility; TrivialGetterIVarSymbol = trivialGetterIVarSymbol; + TrivialSetterIVarSymbol = trivialSetterIVarSymbol; + Flags = flags; } public MRubyMethod WithVisibility(MRubyMethodVisibility visibility) { - return new MRubyMethod(body!, Kind, visibility, TrivialGetterIVarSymbol); + if (TrivialGetterIVarSymbol.Value != 0 || + TrivialSetterIVarSymbol.Value != 0) + { + return new MRubyMethod( + body!, + Kind, + visibility, + TrivialGetterIVarSymbol, + TrivialSetterIVarSymbol, + Flags, + pureUnaryFunc, + pureUnaryFloatFunc); + } + + if (Kind == MRubyMethodKind.RProc) + { + return new MRubyMethod(Unsafe.As(body!), visibility); + } + + var func = Unsafe.As(body!); + return pureUnaryFunc is { } unaryFunc + ? pureUnaryFloatFunc is { } unaryFloatFunc + ? new MRubyMethod(func, unaryFunc, unaryFloatFunc, visibility, Flags) + : new MRubyMethod(func, unaryFunc, visibility, Flags) + : new MRubyMethod(func, visibility, Flags); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -77,6 +177,41 @@ public MRubyValue Invoke(MRubyState state, MRubyValue self) return Unsafe.As(body!).Invoke(state, self); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryInvokePureUnary(MRubyState state, MRubyValue self, MRubyValue argument, out MRubyValue result) + { + if (pureUnaryFunc is { } func && + (Flags & MRubyMethodFlags.Pure) != 0) + { + result = func(state, self, argument); + return true; + } + + result = default; + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryInvokePureUnaryNumeric(MRubyState state, MRubyValue self, MRubyValue argument, out MRubyValue result) + { + if (pureUnaryFloatFunc is { } func && + (Flags & MRubyMethodFlags.Pure) != 0) + { + if (argument.IsFloat) + { + result = func(state, self, argument.FloatValue); + return true; + } + if (argument.IsFixnum) + { + result = func(state, self, argument.FixnumValue); + return true; + } + } + + return TryInvokePureUnary(state, self, argument, out result); + } + public bool Equals(MRubyMethod other) { return body == other.body; @@ -102,7 +237,7 @@ public override int GetHashCode() // the right side is a non-generic static method. public static bool operator ==(MRubyMethod left, MRubyFunc right) { - return ReferenceEquals(left.body, right); + return left.body is MRubyFunc func && func.Equals(right); } public static bool operator !=(MRubyMethod left, MRubyFunc right) @@ -116,10 +251,11 @@ public override int GetHashCode() } /// - /// Create method from RProc, detecting trivial getter pattern. - /// Patterns: Enter(BBB) + GetIV(BB) + Return(B) = 9 bytes, or GetIV(BB) + Return(B) = 5 bytes + /// Create method from RProc, detecting trivial getter/setter patterns. /// - internal static MRubyMethod CreateFromProc(RProc proc, MRubyMethodVisibility visibility = MRubyMethodVisibility.Default) + internal static MRubyMethod CreateFromProc( + RProc proc, + MRubyMethodVisibility visibility = MRubyMethodVisibility.Default) { var irep = proc.Irep; var seq = irep.Sequence; @@ -134,7 +270,13 @@ internal static MRubyMethod CreateFromProc(RProc proc, MRubyMethodVisibility vis var symIdx = seq[6]; if (symIdx < irep.Symbols.Length) { - return new MRubyMethod(proc, MRubyMethodKind.RProc, visibility, irep.Symbols[symIdx]); + return new MRubyMethod( + proc, + MRubyMethodKind.RProc, + visibility, + irep.Symbols[symIdx], + default, + MRubyMethodFlags.None); } } @@ -147,10 +289,27 @@ internal static MRubyMethod CreateFromProc(RProc proc, MRubyMethodVisibility vis var symIdx = seq[2]; if (symIdx < irep.Symbols.Length) { - return new MRubyMethod(proc, MRubyMethodKind.RProc, visibility, irep.Symbols[symIdx]); + return new MRubyMethod( + proc, + MRubyMethodKind.RProc, + visibility, + irep.Symbols[symIdx], + default, + MRubyMethodFlags.None); } } + if (TryDetectTrivialSetter(proc, out var setterSymbol)) + { + return new MRubyMethod( + proc, + MRubyMethodKind.RProc, + visibility, + default, + setterSymbol, + MRubyMethodFlags.None); + } + return new MRubyMethod(proc, visibility); } @@ -159,6 +318,154 @@ internal static MRubyMethod CreateFromProc(RProc proc, MRubyMethodVisibility vis /// internal static MRubyMethod CreateTrivialGetter(MRubyFunc func, Symbol ivarSymbol, MRubyMethodVisibility visibility = MRubyMethodVisibility.Default) { - return new MRubyMethod(func, MRubyMethodKind.CSharpFunc, visibility, ivarSymbol); + return new MRubyMethod( + func, + MRubyMethodKind.CSharpFunc, + visibility, + ivarSymbol, + default, + MRubyMethodFlags.None); + } + + internal static MRubyMethod CreateTrivialSetter(MRubyFunc func, Symbol ivarSymbol, MRubyMethodVisibility visibility = MRubyMethodVisibility.Default) + { + return new MRubyMethod( + func, + MRubyMethodKind.CSharpFunc, + visibility, + default, + ivarSymbol, + MRubyMethodFlags.None); + } + + static bool TryDetectTrivialSetter(RProc proc, out Symbol symbol) + { + symbol = default; + if (proc.ProgramCounter != 0 || !proc.HasFlag(MRubyObjectFlags.ProcStrict)) + { + return false; + } + + var irep = proc.Irep; + var seq = irep.Sequence; + if (irep.CatchHandlers.Length != 0 || + irep.RegisterVariableCount == 0 || + seq.Length < 4 || + (OpCode)seq[0] != OpCode.Enter || + !TryReadSimpleEnterArgumentCount(seq, 0, out var argumentCount) || + argumentCount != 1 || + irep.RegisterVariableCount > 16) + { + return false; + } + + var pc = 4; + Span registerArguments = stackalloc int[16]; + registerArguments.Fill(-1); + registerArguments[1] = 0; + var foundSet = false; + + while (pc < seq.Length) + { + switch ((OpCode)seq[pc]) + { + case OpCode.Nop: + pc++; + break; + case OpCode.Move: + { + if (pc + 2 >= seq.Length) + { + return false; + } + + var destination = seq[pc + 1]; + var source = seq[pc + 2]; + if ((uint)destination >= (uint)registerArguments.Length || + (uint)source >= (uint)registerArguments.Length || + registerArguments[source] < 0) + { + return false; + } + + registerArguments[destination] = registerArguments[source]; + pc += 3; + break; + } + case OpCode.SetIV: + { + if (pc + 2 >= seq.Length) + { + return false; + } + + var source = seq[pc + 1]; + var symbolIndex = seq[pc + 2]; + if (foundSet || + (uint)source >= (uint)registerArguments.Length || + registerArguments[source] != 0 || + (uint)symbolIndex >= (uint)irep.Symbols.Length) + { + return false; + } + + symbol = irep.Symbols[symbolIndex]; + foundSet = true; + pc += 3; + break; + } + case OpCode.Return: + return pc + 1 < seq.Length && + foundSet && + (uint)seq[pc + 1] < (uint)registerArguments.Length && + registerArguments[seq[pc + 1]] == 0 && + FinishOnlyNops(seq, pc + 2); + default: + return false; + } + } + + return false; + } + + static bool TryReadSimpleEnterArgumentCount(byte[] sequence, int pc, out int argumentCount) + { + argumentCount = 0; + if (pc + 3 >= sequence.Length) + { + return false; + } + + var aspec = new ArgumentSpec(ReadUInt24(sequence, pc + 1)); + if (aspec.OptionalArgumentsCount != 0 || + aspec.TakeRestArguments || + aspec.MandatoryArguments2Count != 0 || + aspec.KeywordArgumentsCount != 0 || + aspec.TakeKeywordDict || + aspec.TakeBlock) + { + return false; + } + + argumentCount = aspec.MandatoryArguments1Count; + return true; + } + + static bool FinishOnlyNops(byte[] sequence, int pc) + { + while (pc < sequence.Length) + { + if ((OpCode)sequence[pc] != OpCode.Nop) + { + return false; + } + + pc++; + } + + return true; } + + static uint ReadUInt24(byte[] sequence, int offset) => + (uint)(sequence[offset] << 16 | sequence[offset + 1] << 8 | sequence[offset + 2]); } diff --git a/src/ChibiRuby/MRubyState.Class.cs b/src/ChibiRuby/MRubyState.Class.cs index c2aba416..10c8f78d 100644 --- a/src/ChibiRuby/MRubyState.Class.cs +++ b/src/ChibiRuby/MRubyState.Class.cs @@ -32,7 +32,21 @@ public RClass DefineClass(Symbol name, Action configure) => partial class MRubyState { - const int MethodCacheSize = 1 << 8; + const int MethodCacheSize = 1 << 12; + int methodCacheVersion; + + // Public so AOT-generated class-switch dispatch (in the runtime-emitted assembly, which + // only sees public members + AotGeneratedMethods helpers) can read it to gate its + // once-per-version candidate-class resolution. Monotonic; bumped on any (re)definition. + public int MethodCacheVersion => methodCacheVersion; + + // Bumped whenever any constant is (re)assigned (SetConst / DefineConst / class-name binding). + // AOT-generated constant reads cache the resolved value and re-resolve only when this changes, + // so the steady-state cost of `Math.sqrt`-style lookups is a version compare. Over-bumping (e.g. + // on class-ivar writes that share the table) only forces a harmless re-resolution. + int constCacheVersion; + public int ConstCacheVersion => constCacheVersion; + internal void BumpConstCacheVersion() { unchecked { constCacheVersion++; } } struct MethodCacheEntry { @@ -112,6 +126,7 @@ public void PrependModule(RClass c, RClass mod) public void DefineConst(RClass c, Symbol name, MRubyValue value) { + BumpConstCacheVersion(); EnsureNotFrozen(c); EnsureConstName(name); if (value.IsNamespace) @@ -318,6 +333,10 @@ public bool TryFindMethod(RClass c, Symbol methodId, out MRubyMethod method, out void ClearMethodCache() { + unchecked + { + methodCacheVersion++; + } Array.Clear(methodCache, 0, methodCache.Length); } @@ -455,7 +474,7 @@ RClass CloneSingletonClass(MRubyValue obj) // copy instance variables clone.InstanceVariables.Clear(); - klass.InstanceVariables.CopyTo(clone.InstanceVariables); + klass.InstanceVariables.CopyTo(ref clone.InstanceVariables); clone.InstanceVariables.Set(Names.AttachedKey, obj); // copy method table @@ -465,4 +484,3 @@ RClass CloneSingletonClass(MRubyValue obj) return clone; } } - diff --git a/src/ChibiRuby/MRubyState.CompiledMethods.cs b/src/ChibiRuby/MRubyState.CompiledMethods.cs new file mode 100644 index 00000000..941b4930 --- /dev/null +++ b/src/ChibiRuby/MRubyState.CompiledMethods.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using ChibiRuby.Internals; + +namespace ChibiRuby; + +// AOT-compiled method bodies are bound to ireps by a content fingerprint: a +// build-time codegen step computes each compilable irep's fingerprint and registers +// a CompiledRubyMethodBody under it; at load time the parser computes the same +// fingerprint for each parsed irep and attaches the matching body. Same bytecode -> +// same fingerprint, so a compiled body binds to exactly the irep it was generated +// from, with no class/method names involved. If the Ruby changed since the C# was +// generated, the fingerprint differs -> nothing binds -> Irep.CompiledBody stays null. +public partial class MRubyState +{ + // AOT-compiler driver surface: visit every RProc-backed method reachable from the + // class tree (Object's constants, walked recursively), together with its DEFINING + // class. A build step uses this to compile each (definingClass, methodId, irep) and + // resolve self-sends (self's class == the defining class) for devirtualize+inline. + public void EnumerateAotMethods(Action visit) + { + var seen = new HashSet(); + void Walk(RClass c) + { + if (!seen.Add(c)) + { + return; + } + foreach (var (methodId, method) in c.MethodTable) + { + if (method.Proc is { } proc) + { + visit(c, methodId, proc.Irep); + } + } + foreach (var (_, value) in c.InstanceVariables) + { + if (value.Object is RClass { VType: MRubyVType.Class or MRubyVType.Module } nested) + { + Walk(nested); + } + } + } + Walk(ObjectClass); + } + + // Visit every constant reachable from the class tree (constants live in a class's instance + // variables, same as the nested-class walk above). The AOT codegen uses this to find + // Float-valued constants so an arith op mixing one with an integer is recognized as a + // genuine fixnum/float mix at build time. + public void EnumerateConstants(Action visit) + { + var seen = new HashSet(); + void Walk(RClass c) + { + if (!seen.Add(c)) + { + return; + } + foreach (var (name, value) in c.InstanceVariables) + { + visit(name, value); + if (value.Object is RClass { VType: MRubyVType.Class or MRubyVType.Module } nested) + { + Walk(nested); + } + } + } + Walk(ObjectClass); + } + + // The operations that AOT-generated bodies invoke (guards, indexed get/set, pure-unary send, + // constant lookup, class-switch resolution) used to live here as public methods on MRubyState. + // They are only sound when driven by generated code (their guards are validated against the + // per-call-site inline-cache fields the codegen emits), so they were never a safe public API — + // they now live on ChibiRuby.AotGeneratedMethods (the base class generated classes derive from) + // as protected static `*Unsafe` helpers. ResolveConstantSlow stays here: it is shared with the + // interpreter, and GetConstantUnsafe calls back into it. + + // The OP_GetConst slow path, shared by the interpreter and compiled bodies: unwrap + // a singleton class, then walk the proc's lexical Upper chain, finally fall back to + // GetConst (ancestors + Object + const_missing). + [MethodImpl(MethodImplOptions.NoInlining)] + internal MRubyValue ResolveConstantSlow(ref MRubyCallInfo callInfo, Symbol id, RClass c) + { + var x = c; + MRubyValue value; + while (x is { VType: MRubyVType.SClass }) + { + if (!x.ClassInstanceVariables.TryGet(id, out value)) + { + x = null; + break; + } + x = c.Class; + } + if (x is { VType: MRubyVType.Class or MRubyVType.Module }) + { + c = x; + } + var proc = callInfo.Proc; + while (proc != null) + { + if (proc != callInfo.Proc) + { + x = proc.Scope?.TargetClass ?? ObjectClass; + if (x.ClassInstanceVariables.TryGet(id, out value)) + { + return value; + } + } + if (proc.OptimizedUpperEnvironment is { } optimizedEnv) + { + x = optimizedEnv.TargetClass; + if (x.ClassInstanceVariables.TryGet(id, out value)) + { + return value; + } + } + proc = proc.Upper ?? proc.OptimizedUpperProc; + } + return GetConst(id, c); + } + + Dictionary? compiledMethods; + + // Register a compiled body under an irep fingerprint (see ComputeIrepFingerprint). + // Called by generated code / the codegen registration trigger. + public void RegisterCompiledMethod(ulong fingerprint, CompiledRubyMethodBody body) + { + (compiledMethods ??= new Dictionary())[fingerprint] = body; + } + + // Walk an irep tree and attach any registered compiled bodies by fingerprint. + // Called automatically by ParseBytecode/LoadBytecode; public so a host that + // registered bodies AFTER an irep was already parsed can rebind it. No-op (and + // zero cost) when nothing is registered — the common dev / no-AOT case. + public void BindCompiledMethods(Irep irep) + { + if (compiledMethods is not { Count: > 0 } registry) + { + return; + } + + BindWalk(irep, registry); + } + + void BindWalk(Irep irep, Dictionary registry) + { + if (registry.TryGetValue(ComputeIrepFingerprint(irep), out var body)) + { + irep.CompiledBody = body; + } + + foreach (var child in irep.Children) + { + BindWalk(child, registry); + } + } + + const ulong FnvOffset = 14695981039346656037UL; + const ulong FnvPrime = 1099511628211UL; + + // Deterministic content hash of an irep: register count + bytecode + symbol NAMES + // (not state-local ids) + pool literals + child fingerprints. Must match the + // algorithm the build-time codegen uses so build-time and load-time agree. Public + // so the codegen/registration layer can key bodies by it. + public ulong ComputeIrepFingerprint(Irep irep) + { + if (irep.CachedFingerprint is { } cached) + { + return cached; + } + + var h = FnvOffset; + MixU32(ref h, irep.RegisterVariableCount); + + var seq = irep.Sequence; + MixU32(ref h, (uint)seq.Length); + foreach (var b in seq) + { + MixByte(ref h, b); + } + + MixU32(ref h, (uint)irep.Symbols.Length); + foreach (var sym in irep.Symbols) + { + var name = symbolTable.NameOf(sym); + MixU32(ref h, (uint)name.Length); + foreach (var b in name) + { + MixByte(ref h, b); + } + } + + MixU32(ref h, (uint)irep.PoolValues.Length); + foreach (var pv in irep.PoolValues) + { + MixPool(ref h, pv); + } + + MixU32(ref h, (uint)irep.Children.Length); + foreach (var child in irep.Children) + { + MixU64(ref h, ComputeIrepFingerprint(child)); + } + + irep.CachedFingerprint = h; + return h; + } + + void MixPool(ref ulong h, MRubyValue v) + { + MixByte(ref h, (byte)v.VType); + if (v.IsFixnum) + { + MixU64(ref h, (ulong)v.FixnumValue); + } + else if (v.IsFloat) + { + MixU64(ref h, (ulong)BitConverter.DoubleToInt64Bits(v.FloatValue)); + } + else if (v.VType == MRubyVType.String) + { + var s = v.As().AsSpan(); + MixU32(ref h, (uint)s.Length); + foreach (var b in s) + { + MixByte(ref h, b); + } + } + else if (v.VType == MRubyVType.Symbol) + { + var name = symbolTable.NameOf(v.SymbolValue); + foreach (var b in name) + { + MixByte(ref h, b); + } + } + } + + static void MixByte(ref ulong h, byte b) + { + h ^= b; + h *= FnvPrime; + } + + static void MixU32(ref ulong h, uint v) + { + MixByte(ref h, (byte)v); + MixByte(ref h, (byte)(v >> 8)); + MixByte(ref h, (byte)(v >> 16)); + MixByte(ref h, (byte)(v >> 24)); + } + + static void MixU64(ref ulong h, ulong v) + { + for (var i = 0; i < 8; i++) + { + MixByte(ref h, (byte)v); + v >>= 8; + } + } +} diff --git a/src/ChibiRuby/MRubyState.Dump.cs b/src/ChibiRuby/MRubyState.Dump.cs index 9b8ff71e..65297622 100644 --- a/src/ChibiRuby/MRubyState.Dump.cs +++ b/src/ChibiRuby/MRubyState.Dump.cs @@ -135,14 +135,13 @@ static ReadOnlySpan GetOpCodeName(OpCode code) => OpCode.EXT2 => "EXT2"u8, OpCode.EXT3 => "EXT3"u8, OpCode.Stop => "STOP"u8, - OpCode.SendInternal => "SEND_INTERNAL"u8, _ => throw new ArgumentOutOfRangeException(nameof(code), code, null) }; public void CodeDump(Irep irep, IBufferWriter writer, int tabWidth = 4) { - var longestOpCodeName = "RETURN_BLK"u8.Length; + var longestOpCodeName = "FIXNUM_GE_REG_JMPNOT"u8.Length; var maxTabCount = (longestOpCodeName) / tabWidth + 1; void WriteOpCodeWithTab(OpCode opcode) @@ -938,4 +937,4 @@ or OpCode.EXT1 or OpCode.EXT2 or OpCode.EXT3 writer.Write("\n"u8); } } -} \ No newline at end of file +} diff --git a/src/ChibiRuby/MRubyState.Init.cs b/src/ChibiRuby/MRubyState.Init.cs index 0b62796f..7246339e 100644 --- a/src/ChibiRuby/MRubyState.Init.cs +++ b/src/ChibiRuby/MRubyState.Init.cs @@ -81,7 +81,7 @@ public static MRubyState Create() public RiteParser RiteParser => riteParser ??= new RiteParser(this); readonly SymbolTable symbolTable = new(); - readonly VariableTable globalVariables = new(); + VariableTable globalVariables = new(); RiteParser? riteParser; @@ -465,7 +465,8 @@ void InitNumeric() DefineMethod(IntegerClass, Intern("truncate"u8), IntegerMembers.Truncate); DefineMethod(IntegerClass, Names.Hash, IntegerMembers.Hash); DefineMethod(IntegerClass, Intern("divmod"u8), IntegerMembers.DivMod); - DefineMethod(IntegerClass, Intern("to_f"u8), IntegerMembers.ToF); + DefineMethod(IntegerClass, Intern("to_f"u8), + new MRubyMethod(IntegerMembers.ToF, (s, self, _) => IntegerMembers.ToF(s, self))); DefineMethod(IntegerClass, Names.ToI, MRubyMethod.Identity); DefineMethod(IntegerClass, Intern("to_int"u8), MRubyMethod.Identity); DefineMethod(IntegerClass, Intern("bit_length"u8), IntegerMembers.BitLength); @@ -509,7 +510,8 @@ void InitNumeric() DefineMethod(FloatClass, Intern("floor"u8), FloatMembers.Floor); DefineMethod(FloatClass, Intern("infinite?"u8), FloatMembers.QInfinite); DefineMethod(FloatClass, Intern("round"u8), FloatMembers.Round); - DefineMethod(FloatClass, Intern("to_f"u8), FloatMembers.ToF); + DefineMethod(FloatClass, Intern("to_f"u8), + new MRubyMethod(FloatMembers.ToF, (s, self, _) => FloatMembers.ToF(s, self))); DefineMethod(FloatClass, Names.ToI, FloatMembers.ToI); DefineMethod(FloatClass, Intern("truncate"u8), FloatMembers.Truncate); DefineMethod(FloatClass, Intern("divmod"u8), FloatMembers.DivMod); @@ -928,6 +930,7 @@ bool TrySetClassPathLink(RClass outer, RClass c, Symbol name) { if (c.InstanceVariables.TryGet(Names.OuterKey, out _)) return false; + BumpConstCacheVersion(); // binds `name` as a constant on `outer` c.InstanceVariables.Set(Names.OuterKey, outer); outer.InstanceVariables.Set(name, c); diff --git a/src/ChibiRuby/MRubyState.Object.cs b/src/ChibiRuby/MRubyState.Object.cs index c366f2d9..be2e064b 100644 --- a/src/ChibiRuby/MRubyState.Object.cs +++ b/src/ChibiRuby/MRubyState.Object.cs @@ -295,12 +295,7 @@ public MRubyValue GetInstanceVariable(RObject obj, Symbol key) { return c.ClassInstanceVariables.Get(key); } - - if (obj is { } o) - { - return o.InstanceVariables.Get(key); - } - return MRubyValue.Nil; + return obj.InstanceVariables.Get(key); } public void SetInstanceVariable(RObject obj, Symbol key, MRubyValue value) @@ -528,9 +523,23 @@ internal RProc NewProc(Irep irep, RClass? targetClass = null, RClass? procClass internal RProc NewClosure(Irep irep, RClass? procClass = null) { + var scope = GetOrCreateClosureScope(); ref var callInfo = ref Context.CurrentCallInfo; - var env = callInfo.Scope as REnv; + var env = scope as REnv; + return new RProc(irep, 0, procClass ?? ProcClass) + { + Upper = callInfo.Proc, + Scope = env, + OptimizedUpperEnvironment = callInfo.Proc is null ? callInfo.OptimizedBlockEnvironment : null, + OptimizedUpperProc = callInfo.Proc is null ? callInfo.OptimizedBlockUpperProc : null, + }; + } + + internal ICallScope? GetOrCreateClosureScope() + { + ref var callInfo = ref Context.CurrentCallInfo; + var env = callInfo.Scope as REnv; if (env is null) { if (callInfo.Proc is { } upper) @@ -551,16 +560,37 @@ internal RProc NewClosure(Irep irep, RClass? procClass = null) BlockArgumentOffset = callInfo.BlockArgumentOffset, TargetClass = callInfo.Scope.TargetClass, Visibility = callInfo.Visibility, - VisibilityBreak = callInfo.VisibilityBreak + VisibilityBreak = callInfo.VisibilityBreak, + OptimizedUpperProc = upper + }; + callInfo.Scope = env; + } + else if (callInfo.OptimizedBlockEnvironment is { } optimizedUpperEnv) + { + var stackSize = callInfo.OptimizedBlockRegisterCount; + if (stackSize <= 0) + { + stackSize = callInfo.NumberOfRegisters; + } + + env = new REnv + { + Context = Context, + StackPointer = callInfo.StackPointer, + StackSize = stackSize, + MethodId = optimizedUpperEnv.MethodId, + BlockArgumentOffset = callInfo.BlockArgumentOffset, + TargetClass = callInfo.Scope.TargetClass, + Visibility = optimizedUpperEnv.Visibility, + VisibilityBreak = optimizedUpperEnv.VisibilityBreak, + OptimizedUpperEnvironment = callInfo.OptimizedBlockEnvironment, + OptimizedUpperProc = callInfo.OptimizedBlockUpperProc }; callInfo.Scope = env; } } - return new RProc(irep, 0, procClass ?? ProcClass) - { - Upper = callInfo.Proc, - Scope = env, - }; + + return env ?? callInfo.Scope; } internal MRubyValue GetProcSelf(RProc proc, out RClass targetClass) @@ -659,4 +689,4 @@ int NumberCompare(MRubyValue a, MRubyValue b) var y = AsFloat(b); return x.CompareTo(y); } -} \ No newline at end of file +} diff --git a/src/ChibiRuby/MRubyState.Variable.cs b/src/ChibiRuby/MRubyState.Variable.cs index 2e835def..df6238d0 100644 --- a/src/ChibiRuby/MRubyState.Variable.cs +++ b/src/ChibiRuby/MRubyState.Variable.cs @@ -65,6 +65,7 @@ public MRubyValue GetConst(Symbol name, RClass module) public void SetConst(Symbol name, RClass mod, MRubyValue value) { + BumpConstCacheVersion(); EnsureConstName(name); if (value.Object is RClass { VType: MRubyVType.Class or MRubyVType.Module } c) { diff --git a/src/ChibiRuby/MRubyState.Vm.cs b/src/ChibiRuby/MRubyState.Vm.cs index 6ef48577..71961560 100644 --- a/src/ChibiRuby/MRubyState.Vm.cs +++ b/src/ChibiRuby/MRubyState.Vm.cs @@ -19,8 +19,10 @@ namespace ChibiRuby; enum VmSignal : byte { Next, JumpAndNext, Return } + partial class MRubyState { + public MRubyValue Send(MRubyValue self, Symbol methodId) => Send(self, methodId, ReadOnlySpan.Empty); @@ -117,7 +119,17 @@ public MRubyValue Send( { ref var currentCallInfo = ref Context.CurrentCallInfo; var nextStackPointer = currentCallInfo.StackPointer + currentCallInfo.NumberOfRegisters; + return SendWithStackPointer(self, methodId, args, kargs, block, nextStackPointer); + } + internal MRubyValue SendWithStackPointer( + MRubyValue self, + Symbol methodId, + ReadOnlySpan args, + ReadOnlySpan> kargs, + RProc? block, + int nextStackPointer) + { var stackSize = MRubyCallInfo.CalculateBlockArgumentOffset( args.Length, kargs.IsEmpty ? 0 : MRubyCallInfo.CallMaxArgs) + 1; // argc + kargs(packed) + self + proc @@ -189,6 +201,17 @@ public MRubyValue Send( else { var irepProc = nextCallInfo.Proc!; + + // AOT-compiled body keyed by irep (same contract as the CSharpFunc path + // above: run on the frame, pop, return). On a guard miss fall through to + // interpret the irep's bytecode. + if (irepProc.Irep.CompiledBody is { } compiledBody && + compiledBody(this, nextCallInfo.StackPointer, out var compiledResult)) + { + Context.PopCallStack(); + return compiledResult; + } + nextCallInfo.CallerType = CallerType.VmExecuted; nextCallInfo.ProgramCounter = irepProc.ProgramCounter; return Execute(irepProc.Irep, irepProc.ProgramCounter, nextCallInfo.BlockArgumentOffset + 1); @@ -257,6 +280,15 @@ internal MRubyValue CallResolvedMethod( } var irepProc = nextCallInfo.Proc!; + + // AOT-compiled body keyed by irep (same contract as the CSharpFunc path above). + if (irepProc.Irep.CompiledBody is { } compiledBody && + compiledBody(this, nextCallInfo.StackPointer, out var compiledResult)) + { + Context.PopCallStack(); + return compiledResult; + } + nextCallInfo.CallerType = CallerType.VmExecuted; nextCallInfo.ProgramCounter = irepProc.ProgramCounter; return Execute(irepProc.Irep, irepProc.ProgramCounter, nextCallInfo.BlockArgumentOffset + 1); @@ -309,11 +341,17 @@ public RProc CreateProc(Irep irep) }; } - public Irep ParseBytecode(ReadOnlySpan bytecode) => RiteParser.Parse(bytecode); + public Irep ParseBytecode(ReadOnlySpan bytecode) + { + var irep = RiteParser.Parse(bytecode); + BindCompiledMethods(irep); + return irep; + } public MRubyValue LoadBytecode(ReadOnlySpan bytecode) { var irep = RiteParser.Parse(bytecode); + BindCompiledMethods(irep); return Execute(irep); } @@ -496,10 +534,12 @@ internal MRubyValue Execute(Irep irep, int pc, int stackKeep, RException? inject { Exception = null; - var registerVariableCount = irep.RegisterVariableCount; + ref var callInfo = ref Context.CurrentCallInfo; + GetExecutableArrays(irep, out var frameCode, out var frameSymbols, out var frameRegisterCount); + var registerVariableCount = frameRegisterCount; if (stackKeep > registerVariableCount) { - registerVariableCount = (ushort)stackKeep; + registerVariableCount = stackKeep; } // else // { @@ -511,10 +551,10 @@ internal MRubyValue Execute(Irep irep, int pc, int stackKeep, RException? inject // } // } - ref var sequence = ref GetArrayDataReference(irep.Sequence); - ref var symbols = ref GetArrayDataReference(irep.Symbols); + ref var sequence = ref GetArrayDataReference(frameCode); + ref var symbols = ref GetArrayDataReference(frameSymbols); + callInfo.OptimizerRegisterVariableCount = registerVariableCount; - ref var callInfo = ref Context.CurrentCallInfo; Context.ExtendStack(callInfo.StackPointer + registerVariableCount); Context.ClearStack(callInfo.StackPointer + stackKeep, registerVariableCount - stackKeep); @@ -535,9 +575,11 @@ internal MRubyValue Execute(Irep irep, int pc, int stackKeep, RException? inject } callInfo = ref Context.CurrentCallInfo; irep = callInfo.Proc!.Irep; + GetExecutableArrays(irep, out frameCode, out frameSymbols, out frameRegisterCount); + callInfo.OptimizerRegisterVariableCount = frameRegisterCount; registers = ref Unsafe.Add(ref GetArrayDataReference(Context.Stack), callInfo.StackPointer); - sequence = ref GetArrayDataReference(irep.Sequence); - symbols = ref GetArrayDataReference(irep.Symbols); + sequence = ref GetArrayDataReference(frameCode); + symbols = ref GetArrayDataReference(frameSymbols); } while (true) @@ -685,42 +727,8 @@ internal MRubyValue Execute(Irep irep, int pc, int stackKeep, RException? inject goto Next; } - GetConstSlowPath( - this, ref registerA, ref callInfo, id, c); - + registerA = ResolveConstantSlow(ref callInfo, id, c); goto Next; - - [MethodImpl(MethodImplOptions.NoInlining)] - static void GetConstSlowPath(MRubyState state, ref MRubyValue registerA, ref MRubyCallInfo callInfo, Symbol id, RClass c) - { - var x = c; - MRubyValue value; - while (x is { VType: MRubyVType.SClass }) - { - if (!x.ClassInstanceVariables.TryGet(id, out value)) - { - x = null; - break; - } - x = c.Class; - } - if (x is { VType: MRubyVType.Class or MRubyVType.Module }) - { - c = x; - } - var proc = callInfo.Proc?.Upper; - while (proc != null) - { - x = proc.Scope?.TargetClass ?? state.ObjectClass; - if (x.ClassInstanceVariables.TryGet(id, out value)) - { - registerA = value; - return; - } - proc = proc.Upper; - } - registerA = state.GetConst(id, c); - } } case OpCode.SetConst: { @@ -846,7 +854,7 @@ static void GetConstSlowPath(MRubyState state, ref MRubyValue registerA, ref MRu OperandBBB bbb; { bbb = OperandBBB.Read(ref sequence, ref callInfo.ProgramCounter); - var env = callInfo.Proc?.FindUpperEnvTo(bbb.C); + var env = GetCurrentUpVarEnv(ref callInfo, bbb.C); if (env != null && bbb.B < env.Stack.Length) { Unsafe.Add(ref registers, bbb.A) = env.Stack[bbb.B]; @@ -861,7 +869,7 @@ static void GetConstSlowPath(MRubyState state, ref MRubyValue registerA, ref MRu { Markers.SetUpVar(); bbb = OperandBBB.Read(ref sequence, ref callInfo.ProgramCounter); - var env = callInfo.Proc?.FindUpperEnvTo(bbb.C); + var env = GetCurrentUpVarEnv(ref callInfo, bbb.C); if (env != null && bbb.B < env.Stack.Length) { env.Stack[bbb.B] = Unsafe.Add(ref registers, bbb.A); @@ -1679,9 +1687,11 @@ static void PackKeywordArguments(MRubyState state, ref MRubyCallInfo callInfo, r } callInfo = ref Context.CurrentCallInfo; + GetExecutableArrays(irep, out frameCode, out frameSymbols, out frameRegisterCount); + callInfo.OptimizerRegisterVariableCount = frameRegisterCount; registers = ref Unsafe.Add(ref GetArrayDataReference(Context.Stack), callInfo.StackPointer); - sequence = ref GetArrayDataReference(irep.Sequence); - symbols = ref GetArrayDataReference(irep.Symbols); + sequence = ref GetArrayDataReference(frameCode); + symbols = ref GetArrayDataReference(frameSymbols); goto Next; static bool CallCSharpFunc(MRubyState state, MRubyMethod method, MRubyValue self, ref Irep irep, out MRubyValue result) @@ -1714,20 +1724,60 @@ static bool CallCSharpFunc(MRubyState state, MRubyMethod method, MRubyValue self irep = irepProc!.Irep; callInfo.ProgramCounter = irepProc.ProgramCounter; - Context.ExtendStack(callInfo.StackPointer + (irep.RegisterVariableCount < 4 ? 4 : irep.RegisterVariableCount) + 1); + // AOT-compiled body, keyed by the irep itself: args are already + // in the frame (self at StackPointer, args above). If its guards + // hold it returns the value frameless — no ExtendStack/interpret/ + // Return. On a guard miss it returns false and we fall through to + // interpret this same irep's bytecode (deopt), frame untouched. + if (irep.CompiledBody is { } compiledBody && + InvokeCompiledBody(this, compiledBody, ref irep, callInfo.StackPointer)) + { + callInfo = ref Context.CurrentCallInfo; + GetExecutableArrays(irep, out frameCode, out frameSymbols, out frameRegisterCount); + callInfo.OptimizerRegisterVariableCount = frameRegisterCount; + registers = ref Unsafe.Add(ref GetArrayDataReference(Context.Stack), callInfo.StackPointer); + sequence = ref GetArrayDataReference(frameCode); + symbols = ref GetArrayDataReference(frameSymbols); + goto Next; + } + + GetExecutableArrays(irep, out frameCode, out frameSymbols, out frameRegisterCount); + callInfo.OptimizerRegisterVariableCount = frameRegisterCount; + Context.ExtendStack(callInfo.StackPointer + (frameRegisterCount < 4 ? 4 : frameRegisterCount) + 1); registers = ref Unsafe.Add(ref GetArrayDataReference(Context.Stack), callInfo.StackPointer); - sequence = ref GetArrayDataReference(irep.Sequence); - symbols = ref GetArrayDataReference(irep.Symbols); + sequence = ref GetArrayDataReference(frameCode); + symbols = ref GetArrayDataReference(frameSymbols); goto Next; // pop on OpCode.Return + + [MethodImpl(MethodImplOptions.NoInlining)] + static bool InvokeCompiledBody(MRubyState state, CompiledRubyMethodBody body, ref Irep irep, int stackPointer) + { + if (!body(state, stackPointer, out var result)) + { + return false; // guard miss -> deopt to the bytecode interpreter + } + + // Success: return the value and pop the callee frame (mirrors + // the CSharpFunc return path). + ref var ci = ref state.Context.CurrentCallInfo; + state.Context.Stack[ci.StackPointer] = result; + state.Context.PopCallStack(); + ci = ref state.Context.CurrentCallInfo; + irep = ci.Proc!.Irep; + return true; + } } case OpCode.Call: // modify program counter { CallProc(this, out irep, ref callInfo); + GetExecutableArrays(irep, out frameCode, out frameSymbols, out frameRegisterCount); + callInfo.OptimizerRegisterVariableCount = frameRegisterCount; + Context.ExtendStack(callInfo.StackPointer + frameRegisterCount); registers = ref Unsafe.Add(ref GetArrayDataReference(Context.Stack), callInfo.StackPointer); - sequence = ref GetArrayDataReference(irep.Sequence); - symbols = ref GetArrayDataReference(irep.Symbols); + sequence = ref GetArrayDataReference(frameCode); + symbols = ref GetArrayDataReference(frameSymbols); goto Next; [MethodImpl(MethodImplOptions.NoInlining)] @@ -2278,7 +2328,9 @@ static void BlkPush(MRubyState state, ref MRubyCallInfo callInfo, Span methods). + public Dictionary.Enumerator GetEnumerator() => methods.GetEnumerator(); } diff --git a/src/ChibiRuby/OpCode.cs b/src/ChibiRuby/OpCode.cs index 6300932a..6ffbee4f 100644 --- a/src/ChibiRuby/OpCode.cs +++ b/src/ChibiRuby/OpCode.cs @@ -129,6 +129,6 @@ public enum OpCode : byte EXT3 = 117, // not implemented Stop = 118, - // use internally + // Internal sentinel used by the bytecode VM dispatch loop. SendInternal = 255, -} \ No newline at end of file +} diff --git a/src/ChibiRuby/Optimizer/OptimizerHooks.cs b/src/ChibiRuby/Optimizer/OptimizerHooks.cs new file mode 100644 index 00000000..bd3d2a54 --- /dev/null +++ b/src/ChibiRuby/Optimizer/OptimizerHooks.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +namespace ChibiRuby; + +public partial class MRubyState +{ + // Hot-loop frame resolution: read the irep's arrays directly. (The optimizer + // dispatch path and the mrb-bytecode-execution image path were both removed; + // AOT-compiled bodies dispatch via Irep.CompiledBody instead.) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void GetExecutableArrays(Irep irep, out byte[] code, out Symbol[] symbols, out int registerCount) + { + code = irep.Sequence; + symbols = irep.Symbols; + registerCount = irep.RegisterVariableCount; + } +} diff --git a/src/ChibiRuby/RArray.cs b/src/ChibiRuby/RArray.cs index a0ec6ade..a0dde79c 100644 --- a/src/ChibiRuby/RArray.cs +++ b/src/ChibiRuby/RArray.cs @@ -194,6 +194,13 @@ public void Clear() public void Push(MRubyValue newItem) { var currentLength = Length; + if (dataOwned && data.Length - offset > currentLength) + { + Length = currentLength + 1; + Unsafe.Add(ref GetArrayDataReference(data), offset + currentLength) = newItem; + return; + } + MakeModifiable(currentLength + 1, true); Unsafe.Add(ref GetArrayDataReference(data), offset + currentLength) = newItem; } @@ -299,7 +306,7 @@ public void ReplaceTo(RArray other) internal override RObject Clone() { var clone = new RArray(data.Length, Class); - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); return clone; } diff --git a/src/ChibiRuby/RClass.cs b/src/ChibiRuby/RClass.cs index de137329..eba0ebce 100644 --- a/src/ChibiRuby/RClass.cs +++ b/src/ChibiRuby/RClass.cs @@ -15,9 +15,9 @@ public required RClass Super internal MethodTable MethodTable { get; private set; } = new(); - internal VariableTable ClassInstanceVariables => VType == MRubyVType.IClass - ? Class.InstanceVariables - : InstanceVariables; + // ref-return so callers mutate the real struct in place (a by-value return would drop Set/Remove). + internal ref VariableTable ClassInstanceVariables => + ref VType == MRubyVType.IClass ? ref Class.InstanceVariables : ref InstanceVariables; RClass super = default!; @@ -60,7 +60,7 @@ internal override RObject Clone() if (VType is MRubyVType.Class or MRubyVType.Module) { - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); // clone.InstanceVariables.Remove(Ids.ClassName); } return clone; diff --git a/src/ChibiRuby/RData.cs b/src/ChibiRuby/RData.cs index be3ff702..e7c2032d 100644 --- a/src/ChibiRuby/RData.cs +++ b/src/ChibiRuby/RData.cs @@ -21,7 +21,7 @@ public RData(RClass c) : base(MRubyVType.CSharpData, c) internal override RObject Clone() { var clone = new RData(Class, Data); - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); return clone; } } diff --git a/src/ChibiRuby/RException.cs b/src/ChibiRuby/RException.cs index e8177bba..9c489f41 100644 --- a/src/ChibiRuby/RException.cs +++ b/src/ChibiRuby/RException.cs @@ -13,7 +13,7 @@ public sealed class RException( internal override RObject Clone() { var clone = new RException(Message, Class); - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); return clone; } diff --git a/src/ChibiRuby/RHash.cs b/src/ChibiRuby/RHash.cs index 256a0640..d02b65b5 100644 --- a/src/ChibiRuby/RHash.cs +++ b/src/ChibiRuby/RHash.cs @@ -192,7 +192,7 @@ public bool TryShift(out MRubyValue headKey, out MRubyValue headValue) internal override RObject Clone() { var clone = new RHash(Length, keyComparer, valueComparer, Class); - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); return clone; } diff --git a/src/ChibiRuby/RMethod.cs b/src/ChibiRuby/RMethod.cs index 402d4214..8bbaa4c1 100644 --- a/src/ChibiRuby/RMethod.cs +++ b/src/ChibiRuby/RMethod.cs @@ -18,7 +18,7 @@ public sealed class RMethod( internal override RObject Clone() { var clone = new RMethod(Receiver, MethodId, Owner, Method, Class); - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); return clone; } diff --git a/src/ChibiRuby/RObject.cs b/src/ChibiRuby/RObject.cs index 988097f0..2d668c12 100644 --- a/src/ChibiRuby/RObject.cs +++ b/src/ChibiRuby/RObject.cs @@ -2,7 +2,10 @@ namespace ChibiRuby; public class RObject : RBasic { - internal VariableTable InstanceVariables { get; set; } = new(); + // Public FIELD (not a property) so AOT-generated code reads/writes ivars directly + // (recv.InstanceVariables.Get/Set) and, since VariableTable is a struct, mutates it IN PLACE. + // A property getter would return a copy and silently drop Set mutations. + public VariableTable InstanceVariables = new(); public RObject(RClass klass) : this(klass.InstanceVType, klass) { @@ -22,7 +25,7 @@ internal RObject(MRubyVType vType, RClass klass) : base(vType, klass) internal virtual RObject Clone() { var clone = new RObject(VType, Class); - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); return clone; } } diff --git a/src/ChibiRuby/RProc.cs b/src/ChibiRuby/RProc.cs index f1fbada6..a1c1d4a3 100644 --- a/src/ChibiRuby/RProc.cs +++ b/src/ChibiRuby/RProc.cs @@ -21,6 +21,8 @@ class REnv() : RBasic(MRubyVType.Env, default!), ICallScope public required Symbol MethodId { get; init; } public required MRubyMethodVisibility Visibility { get; internal set; } public required bool VisibilityBreak { get; init; } + internal REnv? OptimizedUpperEnvironment { get; init; } + internal RProc? OptimizedUpperProc { get; init; } public bool OnStack => Context != null; @@ -46,6 +48,24 @@ public void CaptureStack() capturedStack = new Memory(Stack.ToArray()); } + + internal REnv? FindOptimizedUpperEnvTo(int up) + { + var env = this; + while (up > 0) + { + if (env.OptimizedUpperEnvironment is { } optimizedEnv) + { + env = optimizedEnv; + up--; + continue; + } + + return env.OptimizedUpperProc?.FindUpperEnvTo(up - 1); + } + + return env; + } } public class RProc(Irep irep, int programCounter, RClass procClass) : RObject(MRubyVType.Proc, procClass), IEquatable @@ -59,6 +79,8 @@ public required ICallScope? Scope public Irep Irep => irep; public int ProgramCounter => programCounter; + internal REnv? OptimizedUpperEnvironment { get; init; } + internal RProc? OptimizedUpperProc { get; init; } ICallScope? scope; @@ -81,10 +103,16 @@ internal RProc FindReturningDestination(out REnv? env) internal REnv? FindUpperEnvTo(int up) { RProc? proc = this; - while (up-- > 0) + while (up > 0) { + if (proc.Upper is null && proc.OptimizedUpperEnvironment is { } optimizedEnv) + { + return optimizedEnv.FindOptimizedUpperEnvTo(up - 1); + } + proc = proc.Upper; if (proc is null) return null; + up--; } return proc.Scope as REnv; } @@ -100,6 +128,8 @@ public RProc Dup() { Upper = Upper, Scope = Scope, + OptimizedUpperEnvironment = OptimizedUpperEnvironment, + OptimizedUpperProc = OptimizedUpperProc, }; clone.SetFlag(Flags); return clone; @@ -117,7 +147,7 @@ public bool Equals(RProc? other) internal override RObject Clone() { var clone = Dup(); - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); return clone; } diff --git a/src/ChibiRuby/RRange.cs b/src/ChibiRuby/RRange.cs index 63ccba21..d0471318 100644 --- a/src/ChibiRuby/RRange.cs +++ b/src/ChibiRuby/RRange.cs @@ -35,7 +35,7 @@ internal RRange(MRubyValue begin, MRubyValue end, bool exclusive, RClass rangeCl internal override RObject Clone() { var clone = new RRange(default, default, false, Class); - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); return clone; } diff --git a/src/ChibiRuby/RString.cs b/src/ChibiRuby/RString.cs index 2721072c..e7d6f33d 100644 --- a/src/ChibiRuby/RString.cs +++ b/src/ChibiRuby/RString.cs @@ -134,7 +134,7 @@ public override string ToString() internal override RObject Clone() { var clone = new RString(buffer.Length, Class); - InstanceVariables.CopyTo(clone.InstanceVariables); + InstanceVariables.CopyTo(ref clone.InstanceVariables); return clone; } diff --git a/src/ChibiRuby/StdLib/ArrayMembers.cs b/src/ChibiRuby/StdLib/ArrayMembers.cs index 6220d531..d1c1193c 100644 --- a/src/ChibiRuby/StdLib/ArrayMembers.cs +++ b/src/ChibiRuby/StdLib/ArrayMembers.cs @@ -578,6 +578,21 @@ public static MRubyValue RotateBang(MRubyState state, MRubyValue self) array.MakeModifiable(length); var span = array.AsSpan(); + if (count == 1) + { + var first = span[0]; + span[1..].CopyTo(span); + span[^1] = first; + return self; + } + if (count == length - 1) + { + var last = span[^1]; + span[..^1].CopyTo(span[1..]); + span[0] = last; + return self; + } + span[..(int)count].Reverse(); span[(int)count..].Reverse(); span.Reverse(); diff --git a/src/ChibiRuby/StdLib/IntegerMembers.cs b/src/ChibiRuby/StdLib/IntegerMembers.cs index 76dd84dc..0b29ba75 100644 --- a/src/ChibiRuby/StdLib/IntegerMembers.cs +++ b/src/ChibiRuby/StdLib/IntegerMembers.cs @@ -179,7 +179,7 @@ public static MRubyValue OpMul(MRubyState state, MRubyValue self) } if (other.IsFloat) { - return a - other.FloatValue; + return a * other.FloatValue; } state.Raise(Names.TypeError, Utf8String.Format($"can't convert {state.TypeNameOf(other)} into Integer")); return default; diff --git a/src/ChibiRuby/VariableTable.cs b/src/ChibiRuby/VariableTable.cs index 44f6e220..20c15c00 100644 --- a/src/ChibiRuby/VariableTable.cs +++ b/src/ChibiRuby/VariableTable.cs @@ -10,12 +10,30 @@ namespace ChibiRuby; -public class VariableTable : IEnumerable> +// A struct (embedded directly in RObject.InstanceVariables / RClass class-ivars) so an object with +// instance variables costs ONE heap allocation, not two. ≤4 ivars live in inline fields (no backing +// array); more promote to arrays. Because it is a struct it MUST be stored in a field and mutated in +// place (`obj.InstanceVariables.Set(...)`), and passed by `ref` when a callee mutates it (see CopyTo). +public struct VariableTable : IEnumerable> { + const int InlineCapacity = 4; + + Symbol key0; + Symbol key1; + Symbol key2; + Symbol key3; + MRubyValue value0; + MRubyValue value1; + MRubyValue value2; + MRubyValue value3; Symbol[] keys = []; MRubyValue[] values = []; int count; + // Field initializers (keys/values = []) only run via an explicit parameterless ctor; a `default` + // VariableTable would have null arrays. Every owner creates it with `new VariableTable()`. + public VariableTable() { } + public int Length { [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -25,12 +43,21 @@ public int Length [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool Defined(Symbol id) { - var keysLocal = keys; - ref var keysRef = ref GetArrayDataReference(keysLocal); - var l = count; - for (var i = 0; l > i; i++) + if (keys.Length != 0) + { + var keysLocal = keys; + ref var keysRef = ref GetArrayDataReference(keysLocal); + var l = count; + for (var i = 0; l > i; i++) + { + if (Unsafe.Add(ref keysRef, i) == id) return true; + } + return false; + } + + for (var i = 0; i < count; i++) { - if (Unsafe.Add(ref keysRef, i) == id) return true; + if (GetInlineKey(i) == id) return true; } return false; } @@ -38,16 +65,30 @@ public bool Defined(Symbol id) [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGet(Symbol id, out MRubyValue value) { - var keysLocal = keys; - var valsLocal = values; - ref var keysRef = ref GetArrayDataReference(keysLocal); - ref var valsRef = ref GetArrayDataReference(valsLocal); - var l = count; - for (var i = 0; i < l; i++) + if (keys.Length != 0) + { + var keysLocal = keys; + var valsLocal = values; + ref var keysRef = ref GetArrayDataReference(keysLocal); + ref var valsRef = ref GetArrayDataReference(valsLocal); + var l = count; + for (var i = 0; i < l; i++) + { + if (Unsafe.Add(ref keysRef, i) == id) + { + value = Unsafe.Add(ref valsRef, i); + return true; + } + } + value = default; + return false; + } + + for (var i = 0; i < count; i++) { - if (Unsafe.Add(ref keysRef, i) == id) + if (GetInlineKey(i) == id) { - value = Unsafe.Add(ref valsRef, i); + value = GetInlineValue(i); return true; } } @@ -58,16 +99,28 @@ public bool TryGet(Symbol id, out MRubyValue value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public MRubyValue Get(Symbol id) { - var keysLocal = keys; - var valsLocal = values; - ref var keysRef = ref GetArrayDataReference(keysLocal); - ref var valsRef = ref GetArrayDataReference(valsLocal); - var l = count; - for (var i = 0; i < l; i++) + if (keys.Length != 0) + { + var keysLocal = keys; + var valsLocal = values; + ref var keysRef = ref GetArrayDataReference(keysLocal); + ref var valsRef = ref GetArrayDataReference(valsLocal); + var l = count; + for (var i = 0; i < l; i++) + { + if (Unsafe.Add(ref keysRef, i) == id) + { + return Unsafe.Add(ref valsRef, i); + } + } + return default; + } + + for (var i = 0; i < count; i++) { - if (Unsafe.Add(ref keysRef, i) == id) + if (GetInlineKey(i) == id) { - return Unsafe.Add(ref valsRef, i); + return GetInlineValue(i); } } return default; @@ -76,21 +129,46 @@ public MRubyValue Get(Symbol id) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Set(Symbol id, MRubyValue value) { - var keysLocal = keys; - var valsLocal = values; - ref var keysRef = ref GetArrayDataReference(keysLocal); - ref var valsRef = ref GetArrayDataReference(valsLocal); - var l = count; - for (var i = 0; i < l; i++) + if (keys.Length != 0) + { + var keysLocal = keys; + var valsLocal = values; + ref var keysRef = ref GetArrayDataReference(keysLocal); + ref var valsRef = ref GetArrayDataReference(valsLocal); + var l = count; + for (var i = 0; i < l; i++) + { + if (Unsafe.Add(ref keysRef, i) == id) + { + Unsafe.Add(ref valsRef, i) = value; + return; + } + } + if (count >= keys.Length) Grow(); + + Unsafe.Add(ref GetArrayDataReference(keys), count) = id; + Unsafe.Add(ref GetArrayDataReference(values), count) = value; + count++; + return; + } + + for (var i = 0; i < count; i++) { - if (Unsafe.Add(ref keysRef, i) == id) + if (GetInlineKey(i) == id) { - Unsafe.Add(ref valsRef, i) = value; + SetInlineValue(i, value); return; } } - if (count >= keys.Length) Grow(); + if (count < InlineCapacity) + { + SetInline(count, id, value); + count++; + return; + } + + Grow(); Unsafe.Add(ref GetArrayDataReference(keys), count) = id; Unsafe.Add(ref GetArrayDataReference(values), count) = value; count++; @@ -99,26 +177,45 @@ public void Set(Symbol id, MRubyValue value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool Remove(Symbol id, out MRubyValue removedValue) { - var keysLocal = keys; - var valsLocal = values; - ref var keysRef = ref GetArrayDataReference(keysLocal); - ref var valsRef = ref GetArrayDataReference(valsLocal); - var l = count; - for (var i = 0; i < l; i++) + if (keys.Length != 0) { - if (Unsafe.Add(ref keysRef, i) == id) + var keysLocal = keys; + var valsLocal = values; + ref var keysRef = ref GetArrayDataReference(keysLocal); + ref var valsRef = ref GetArrayDataReference(valsLocal); + var l = count; + for (var i = 0; i < l; i++) { - removedValue = Unsafe.Add(ref valsRef, i); - count--; - for (var j = i; j < count; j++) + if (Unsafe.Add(ref keysRef, i) == id) { - Unsafe.Add(ref keysRef, j) = Unsafe.Add(ref keysRef, j + 1); - Unsafe.Add(ref valsRef, j) = Unsafe.Add(ref valsRef, j + 1); + removedValue = Unsafe.Add(ref valsRef, i); + count--; + for (var j = i; j < count; j++) + { + Unsafe.Add(ref keysRef, j) = Unsafe.Add(ref keysRef, j + 1); + Unsafe.Add(ref valsRef, j) = Unsafe.Add(ref valsRef, j + 1); + } + Unsafe.Add(ref keysRef, count) = default; + Unsafe.Add(ref valsRef, count) = default; + return true; } - Unsafe.Add(ref keysRef, count) = default; - Unsafe.Add(ref valsRef, count) = default; - return true; } + removedValue = default; + return false; + } + + for (var i = 0; i < count; i++) + { + if (GetInlineKey(i) != id) continue; + + removedValue = GetInlineValue(i); + count--; + for (var j = i; j < count; j++) + { + SetInline(j, GetInlineKey(j + 1), GetInlineValue(j + 1)); + } + ClearInline(count); + return true; } removedValue = default; return false; @@ -128,38 +225,174 @@ public void Clear() { if (count > 0) { - Array.Clear(keys, 0, count); - Array.Clear(values, 0, count); + if (keys.Length != 0) + { + Array.Clear(keys, 0, count); + Array.Clear(values, 0, count); + } + else + { + for (var i = 0; i < count; i++) + { + ClearInline(i); + } + } count = 0; } } - public void CopyTo(VariableTable other) + // `other` is mutated, so it must be passed by ref (a struct copy's mutations would be lost). + public void CopyTo(ref VariableTable other) { if (count == 0) return; - if (other.keys.Length < other.count + count) + if (other.keys.Length != 0 || other.count + count > InlineCapacity) { - var newSize = Math.Max(other.keys.Length == 0 ? 4 : other.keys.Length * 2, other.count + count); - Array.Resize(ref other.keys, newSize); - Array.Resize(ref other.values, newSize); + other.EnsureArrayCapacity(other.count + count); + for (var i = 0; i < count; i++) + { + other.keys[other.count + i] = GetKey(i); + other.values[other.count + i] = GetValue(i); + } + } + else + { + for (var i = 0; i < count; i++) + { + other.SetInline(other.count + i, GetKey(i), GetValue(i)); + } } - Array.Copy(keys, 0, other.keys, other.count, count); - Array.Copy(values, 0, other.values, other.count, count); other.count += count; } [MethodImpl(MethodImplOptions.NoInlining)] void Grow() { - var newSize = keys.Length == 0 ? 4 : keys.Length * 2; + if (keys.Length == 0) + { + PromoteInlineToArray(InlineCapacity * 2); + return; + } + + var newSize = keys.Length * 2; Array.Resize(ref keys, newSize); Array.Resize(ref values, newSize); } - public Enumerator GetEnumerator() => new(this); + void EnsureArrayCapacity(int capacity) + { + if (keys.Length == 0) + { + PromoteInlineToArray(Math.Max(InlineCapacity * 2, capacity)); + return; + } + + if (keys.Length < capacity) + { + var newSize = Math.Max(keys.Length * 2, capacity); + Array.Resize(ref keys, newSize); + Array.Resize(ref values, newSize); + } + } + + void PromoteInlineToArray(int capacity) + { + keys = new Symbol[capacity]; + values = new MRubyValue[capacity]; + for (var i = 0; i < count; i++) + { + keys[i] = GetInlineKey(i); + values[i] = GetInlineValue(i); + } + for (var i = 0; i < InlineCapacity; i++) + { + ClearInline(i); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + readonly Symbol GetKey(int index) => keys.Length != 0 ? keys[index] : GetInlineKey(index); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + readonly MRubyValue GetValue(int index) => keys.Length != 0 ? values[index] : GetInlineValue(index); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + readonly Symbol GetInlineKey(int index) => index switch + { + 0 => key0, + 1 => key1, + 2 => key2, + 3 => key3, + _ => throw new ArgumentOutOfRangeException(nameof(index)) + }; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + readonly MRubyValue GetInlineValue(int index) => index switch + { + 0 => value0, + 1 => value1, + 2 => value2, + 3 => value3, + _ => throw new ArgumentOutOfRangeException(nameof(index)) + }; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void SetInlineValue(int index, MRubyValue value) + { + switch (index) + { + case 0: + value0 = value; + break; + case 1: + value1 = value; + break; + case 2: + value2 = value; + break; + case 3: + value3 = value; + break; + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void SetInline(int index, Symbol key, MRubyValue value) + { + switch (index) + { + case 0: + key0 = key; + value0 = value; + break; + case 1: + key1 = key; + value1 = value; + break; + case 2: + key2 = key; + value2 = value; + break; + case 3: + key3 = key; + value3 = value; + break; + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void ClearInline(int index) + { + SetInline(index, default, default); + } + + public readonly Enumerator GetEnumerator() => new(this); - IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + readonly IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); + readonly IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public struct Enumerator : IEnumerator> { @@ -175,7 +408,7 @@ internal Enumerator(VariableTable table) public KeyValuePair Current { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => new(table.keys[index], table.values[index]); + get => new(table.GetKey(index), table.GetValue(index)); } object IEnumerator.Current => Current; diff --git a/tests/ChibiRuby.Tests/AotCodegenEndToEndTest.cs b/tests/ChibiRuby.Tests/AotCodegenEndToEndTest.cs new file mode 100644 index 00000000..f48c1795 --- /dev/null +++ b/tests/ChibiRuby.Tests/AotCodegenEndToEndTest.cs @@ -0,0 +1,561 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using ChibiRuby.Compiler; +using ChibiRuby.JetPack; +using ChibiRuby.JetPack.Mrb2Cs; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ChibiRuby.Tests; + +// Closes the codegen loop: Ruby -> generated C# (Mrb2Cs) -> Roslyn compile +// -> delegate -> register by fingerprint -> reload auto-binds it -> run the GENERATED +// code through the VM, including deopt. Proves the emitted C# actually compiles and +// runs correctly (not just that it looks right). +[TestFixture] +public class AotCodegenEndToEndTest +{ + MRubyState mrb = default!; + MRubyCompiler compiler = default!; + + [SetUp] + public void Before() + { + mrb = MRubyState.Create(); + compiler = MRubyCompiler.Create(mrb); + } + + [TearDown] + public void After() + { + compiler.Dispose(); + mrb.Dispose(); + } + + MRubyValue Exec(ReadOnlySpan code) + { + using var compilation = compiler.Compile(code); + return mrb.LoadBytecode(compilation.AsBytecode()); + } + + Irep MethodIrep(RClass owner, ReadOnlySpan name) + { + Assert.That(mrb.TryFindMethod(owner, mrb.Intern(name), out var method, out _), Is.True); + return method.Proc!.Irep; + } + + [Test] + public void GeneratedCSharpCompilesRunsAndDeopts() + { + byte[] bytes; + using (var compilation = compiler.Compile(""" + class Point + def initialize; @x = 3; @y = 4; end + def sum; @x + @y; end + end + $p = Point.new + """u8)) + { + bytes = compilation.AsBytecode().ToArray(); + } + + mrb.LoadBytecode(bytes); + var sumIrep = MethodIrep(mrb.ClassOf(Exec("$p"u8)), "sum"u8); + + // Generate C# from the method's RubyIR. + Mrb2CsCompiler.TryCompileMethod(mrb, sumIrep, "Sum", out var gen); + Assert.That(gen, Is.Not.Null); + + // Compile the generated C# into a real assembly and bind it as the body. + var asm = CompileToAssembly("public sealed class GeneratedAot : global::ChibiRuby.AotGeneratedMethods\n{\n" + gen!.Source + "\n}\n"); + var method = asm.GetType("GeneratedAot")!.GetMethod("Sum", BindingFlags.Public | BindingFlags.Static)!; + var body = (CompiledRubyMethodBody)method.CreateDelegate(typeof(CompiledRubyMethodBody)); + + // Register by fingerprint; re-load -> the fresh sum irep auto-binds the body. + mrb.RegisterCompiledMethod(mrb.ComputeIrepFingerprint(sumIrep), body); + mrb.LoadBytecode(bytes); + var reboundIrep = MethodIrep(mrb.ClassOf(Exec("$p"u8)), "sum"u8); + Assert.That(reboundIrep.CompiledBody, Is.Not.Null, "generated body auto-bound by fingerprint"); + + // The GENERATED C# runs (3 + 4 = 7). + Assert.That(Exec("$p.sum"u8), Is.EqualTo(new MRubyValue(7))); + + // Deopt: Float ivar -> generated fixnum guard misses -> interpreter -> 5.5. + Exec("$p.instance_variable_set(:@x, 1.5)"u8); + Assert.That(Exec("$p.sum"u8).FloatValue, Is.EqualTo(5.5)); + + // Back to fixnum -> generated fast path again. + Exec("$p.instance_variable_set(:@x, 10)"u8); + Assert.That(Exec("$p.sum"u8), Is.EqualTo(new MRubyValue(14))); + } + + [Test] + public void GeneratedBranchingMethodCompilesAndRuns() + { + Exec(""" + class Box + def initialize; @x = 5; @y = 9; end + def maxxy + if @x < @y + @y + else + @x + end + end + end + $b = Box.new + """u8); + + var irep = MethodIrep(mrb.ClassOf(Exec("$b"u8)), "maxxy"u8); + Mrb2CsCompiler.TryCompileMethod(mrb, irep, "MaxXY", out var gen); + Assert.That(gen, Is.Not.Null, "branch+compare method should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + + var asm = CompileToAssembly("public sealed class GeneratedAot : global::ChibiRuby.AotGeneratedMethods\n{\n" + gen.Source + "\n}\n"); + var method = asm.GetType("GeneratedAot")!.GetMethod("MaxXY", BindingFlags.Public | BindingFlags.Static)!; + irep.CompiledBody = (CompiledRubyMethodBody)method.CreateDelegate(typeof(CompiledRubyMethodBody)); + + Assert.That(Exec("$b.maxxy"u8), Is.EqualTo(new MRubyValue(9)), "@x<@y -> @y"); + Exec("$b.instance_variable_set(:@x, 20)"u8); + Assert.That(Exec("$b.maxxy"u8), Is.EqualTo(new MRubyValue(20)), "@x>=@y -> @x"); + } + + [Test] + public void GeneratedSendAndIndexMethodsCompileAndRun() + { + Exec(""" + class Box + def initialize; @arr = [10, 20, 30]; @i = 1; end + def helper(n); n * 2; end + def at; @arr[@i]; end # GetIndex -> [] + def via_send; helper(@i); end # self-send with 1 arg + end + $b = Box.new + """u8); + + var boxClass = mrb.ClassOf(Exec("$b"u8)); + CompileAndBind(boxClass, "at"u8, "At"); + CompileAndBind(boxClass, "via_send"u8, "ViaSend"); + + Assert.That(Exec("$b.at"u8), Is.EqualTo(new MRubyValue(20)), "@arr[@i] = @arr[1] = 20"); + Assert.That(Exec("$b.via_send"u8), Is.EqualTo(new MRubyValue(2)), "helper(@i) = 1*2 = 2"); + } + + [Test] + public void GeneratedFloatArithmeticCompilesAndRuns() + { + // All-float receivers/args exercise the dual-path's float branch (not the fixnum + // branch, not deopt). Results returned (boxed) so the chains stay boxed -> compiled. + Exec(""" + class FVec + def initialize; @x = 1.5; @y = 2.5; @z = 4.0; end + def addf(b); @x + b; end + def subf(b); @x - b; end + def mulf(b); @y * b; end + def divf(b); @z / b; end + def ltf(b); @x < b; end + def addi; @x + 2; end # float + fixnum immediate -> coercion to float + end + $f = FVec.new + """u8); + + var fvec = mrb.ClassOf(Exec("$f"u8)); + CompileAndBind(fvec, "addf"u8, "AddF"); + CompileAndBind(fvec, "subf"u8, "SubF"); + CompileAndBind(fvec, "mulf"u8, "MulF"); + CompileAndBind(fvec, "divf"u8, "DivF"); + CompileAndBind(fvec, "ltf"u8, "LtF"); + CompileAndBind(fvec, "addi"u8, "AddI"); + + Assert.That(Exec("$f.addf(2.25)"u8).FloatValue, Is.EqualTo(3.75), "1.5 + 2.25"); + Assert.That(Exec("$f.subf(0.5)"u8).FloatValue, Is.EqualTo(1.0), "1.5 - 0.5"); + Assert.That(Exec("$f.mulf(2.0)"u8).FloatValue, Is.EqualTo(5.0), "2.5 * 2.0"); + Assert.That(Exec("$f.divf(2.0)"u8).FloatValue, Is.EqualTo(2.0), "4.0 / 2.0"); + Assert.That(Exec("$f.ltf(2.0)"u8), Is.EqualTo(MRubyValue.True), "1.5 < 2.0"); + Assert.That(Exec("$f.ltf(1.0)"u8), Is.EqualTo(MRubyValue.False), "1.5 < 1.0 false"); + Assert.That(Exec("$f.addi"u8).FloatValue, Is.EqualTo(3.5), "1.5 + 2 -> 3.5 float"); + + // Mixed fixnum/float still deopts to the interpreter and stays correct (coercion). + Assert.That(Exec("$f.addf(3)"u8).FloatValue, Is.EqualTo(4.5), "1.5 + 3 (mixed) -> 4.5"); + } + + [Test] + public void ProvablyFloatTempsAreDoubleUnboxed() + { + // a,b are float literals (provably Float); x,y,z are float-arith-only temps. They are + // held as raw `double` locals and the chain emits guard-free double arithmetic (no + // per-op IsFloat check), re-boxing only at the returned boundary. + Exec(""" + class Dbl + def calc + a = 3.0 + b = 2.0 + x = a * b + y = a * a + z = b * b + x + y + z + end + end + $d = Dbl.new + """u8); + + var dbl = mrb.ClassOf(Exec("$d"u8)); + Mrb2CsCompiler.TryCompileMethod(mrb, MethodIrep(dbl, "calc"u8), "Calc", out var gen); + Assert.That(gen, Is.Not.Null); + TestContext.Out.WriteLine(gen!.Source); + // Double-unboxing fired: raw `double` locals, and a pure-double op reads a double local + // directly (` = d ` with a double operand) rather than the boxed `.IsFloat` dual-path. + Assert.That(gen.Source, Does.Contain("double d"), "float-arith temps held as raw double"); + Assert.That(gen.Source, Does.Match(@"= d\d+ [*/+-] "), "guard-free pure-double arithmetic emitted"); + + CompileAndBind(dbl, "calc"u8, "Calc2"); + Assert.That(Exec("$d.calc"u8).FloatValue, Is.EqualTo(19.0), "3*2 + 3*3 + 2*2 = 19.0"); + } + + [Test] + public void GeneratedToFFastPathCompilesAndFallsBack() + { + Exec(""" + class Conv + def add_half(x); x.to_f + 0.5; end + def convert(x); x.to_f; end + end + $conv = Conv.new + """u8); + + var conv = mrb.ClassOf(Exec("$conv"u8)); + Mrb2CsCompiler.TryCompileMethod(mrb, MethodIrep(conv, "add_half"u8), "AddHalf", out var gen); + Assert.That(gen, Is.Not.Null, "to_f fast path method should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + Assert.That(gen.Source, Does.Contain(".IsFixnum"), "Integer#to_f emits an immediate fast path"); + Assert.That(gen.Source, Does.Contain("state.Send"), "non-numeric receivers still fall back to Ruby dispatch"); + + CompileAndBind(conv, "add_half"u8, "AddHalf2"); + CompileAndBind(conv, "convert"u8, "Convert"); + + Assert.That(Exec("$conv.add_half(2)"u8).FloatValue, Is.EqualTo(2.5)); + Assert.That(Exec("$conv.add_half(2.25)"u8).FloatValue, Is.EqualTo(2.75)); + Assert.That(Exec("$conv.convert('3.5')"u8).FloatValue, Is.EqualTo(3.5), "String#to_f uses Send fallback"); + } + + [Test] + public void GeneratedPureUnarySendFastPathFallsBackAfterRedefinition() + { + mrb.DefineModule(mrb.Intern("FastMath"u8), mod => + { + mod.DefineClassMethod(mrb.Intern("sqrt"u8), new MRubyMethod( + (s, self) => System.Math.Sqrt(s.GetArgumentAsFloatAt(0)), + (s, self, argument) => System.Math.Sqrt(s.AsFloat(argument)), + (_, _, argument) => System.Math.Sqrt(argument))); + }); + Exec(""" + class UsesFastMath + def root_plus_one(x) + FastMath.sqrt(x) + 1.0 + end + end + $ufm = UsesFastMath.new + """u8); + + var usesFastMath = mrb.ClassOf(Exec("$ufm"u8)); + Mrb2CsCompiler.TryCompileMethod(mrb, MethodIrep(usesFastMath, "root_plus_one"u8), "RootPlusOne", out var gen); + Assert.That(gen, Is.Not.Null, "pure-unary send method should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + Assert.That(gen.Source, Does.Contain("PureUnarySendUnsafe")); + Assert.That(gen.Source, Does.Contain("RootPlusOne__pu0method"), "pure-unary send uses a per-site method cache"); + + CompileAndBind(usesFastMath, "root_plus_one"u8, "RootPlusOne2"); + Assert.That(Exec("$ufm.root_plus_one(9.0)"u8).FloatValue, Is.EqualTo(4.0)); + + Exec(""" + module FastMath + def self.sqrt(x) + 100.0 + end + end + """u8); + Assert.That(Exec("$ufm.root_plus_one(9.0)"u8).FloatValue, Is.EqualTo(101.0), "redefined method uses Send fallback"); + } + + [Test] + public void NonEscapingNewIsScalarReplaced() + { + // V.new is a temporary that never escapes d2/bump — it must be scalar-replaced: + // no allocation, accessor sends become field-local reads/writes. + Exec(""" + class V + def initialize(x, y); @x = x; @y = y; end + def x; @x; end + def y; @y; end + def x=(v); @x = v; end + end + class Calc + def initialize; @ox = 0.5; @oy = 0.25; end + def d2(px, py) + v = V.new(px - @ox, py - @oy) + v.x * v.x + v.y * v.y + end + def bump(px) + v = V.new(px, 0.5) + v.x = v.x + v.y + v.x + end + def isum(a, b) + v = V.new(a, b) + v.x + v.y + end + end + $c = Calc.new + """u8); + + var calc = mrb.ClassOf(Exec("$c"u8)); + + // Inspect the generated C# for d2: the V.new must be gone (no Send/VirtualNew dispatch), + // replaced by field locals + a validity guard. + var d2Irep = MethodIrep(calc, "d2"u8); + Mrb2CsCompiler.TryCompileMethod(mrb, d2Irep, "D2", out var gen); + Assert.That(gen, Is.Not.Null, "non-escaping new method should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + Assert.That(gen.Source, Does.Contain("ClassMethodGuardUnsafe"), "scalar new emits a validity guard"); + Assert.That(gen.Source, Does.Contain("so"), "the Vec fields live as scalar locals (soNN_*), not a heap object"); + + CompileAndBind(calc, "d2"u8, "D2b"); + CompileAndBind(calc, "bump"u8, "Bump"); + CompileAndBind(calc, "isum"u8, "ISum"); + + // d2(1.5, 2.0): vx=1.0, vy=1.75 -> 1.0 + 3.0625 = 4.0625 + Assert.That(Exec("$c.d2(1.5, 2.0)"u8).FloatValue, Is.EqualTo(4.0625)); + // bump(2.0): v.x = 2.0 + 0.5 = 2.5 (setter writes the field local, then read back) + Assert.That(Exec("$c.bump(2.0)"u8).FloatValue, Is.EqualTo(2.5)); + // Pure-integer non-escaping new stays scalar (no deopt): isum(3,4) = 7 fixnum. + Assert.That(Exec("$c.isum(3, 4)"u8), Is.EqualTo(new MRubyValue(7))); + } + + [Test] + public void SelfSendIrSpliceEnablesScalarReplacementAcrossCall() + { + Exec(""" + class VSplice + def initialize(x, y); @x = x; @y = y; end + def x; @x; end + def y; @y; end + def x=(v); @x = v; end + end + class DriverSplice + def touch(v) + v.x = v.x + 1.0 + end + def run(a) + v = VSplice.new(a, 0.5) + touch(v) + v.x + v.y + end + end + $ds = DriverSplice.new + """u8); + + var driver = mrb.ClassOf(Exec("$ds"u8)); + var touchIrep = MethodIrep(driver, "touch"u8); + var inlineRegistry = new Dictionary + { + [mrb.ComputeIrepFingerprint(touchIrep)] = 1 + }; + + Mrb2CsCompiler.TryCompileMethod( + mrb, + MethodIrep(driver, "run"u8), + "RunSplice", + driver, + inlineRegistry, + out var gen); + Assert.That(gen, Is.Not.Null, "self-send splice method should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + Assert.That(gen.Source, Does.Contain("InlineGuardUnsafe"), "spliced self-send is guarded"); + Assert.That(gen.Source, Does.Contain("so"), "object passed through the spliced call is scalar-replaced"); + + var aux = string.Concat(gen.AuxiliaryMethods); + var asm = CompileToAssembly("public sealed class RunSpliceHolder : global::ChibiRuby.AotGeneratedMethods\n{\n" + gen.Source + aux + "\n}\n"); + var method = asm.GetType("RunSpliceHolder")!.GetMethod("RunSplice", BindingFlags.Public | BindingFlags.Static)!; + MethodIrep(driver, "run"u8).CompiledBody = (CompiledRubyMethodBody)method.CreateDelegate(typeof(CompiledRubyMethodBody)); + + Assert.That(Exec("$ds.run(2.0)"u8).FloatValue, Is.EqualTo(3.5)); + + Exec(""" + class DriverSplice + def touch(v) + v.x = 100.0 + end + end + """u8); + Assert.That(Exec("$ds.run(2.0)"u8).FloatValue, Is.EqualTo(100.5)); + } + + [Test] + public void CrossObjectIrSpliceScalarReplacesReturnedObject() + { + Exec(""" + class VCross + def initialize(x, y); @x = x; @y = y; end + def x; @x; end + def y; @y; end + def vadd(b) + VCross.new(@x + b.x, @y + b.y) + end + def dot(b) + @x * b.x + @y * b.y + end + end + class CrossDriver + def run(a, b) + p = VCross.new(a, b) + q = VCross.new(1.0, 2.0) + r = p.vadd(q) + r.dot(q) + end + end + $cd = CrossDriver.new + """u8); + + var driver = mrb.ClassOf(Exec("$cd"u8)); + var vec = mrb.ClassOf(Exec("VCross.new(0.0, 0.0)"u8)); + var inlineRegistry = new Dictionary + { + [mrb.ComputeIrepFingerprint(MethodIrep(vec, "vadd"u8))] = 1, + [mrb.ComputeIrepFingerprint(MethodIrep(vec, "dot"u8))] = 1 + }; + var selectorRegistry = Analyzer.BuildInlineSelectorRegistry(mrb, inlineRegistry); + + Mrb2CsCompiler.TryCompileMethod( + mrb, + MethodIrep(driver, "run"u8), + "CrossRun", + driver, + inlineRegistry, + accessorRegistry: null, + inlineSelectorRegistry: selectorRegistry, + method: out var gen); + Assert.That(gen, Is.Not.Null, "cross-object splice method should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + Assert.That(gen.Source, Does.Contain("ClassMethodGuardUnsafe"), "cross-object sends are guarded"); + Assert.That(gen.Source, Does.Contain("so"), "returned VCross object is scalar-replaced through aliases"); + + var asm = CompileToAssembly("public sealed class CrossRunHolder : global::ChibiRuby.AotGeneratedMethods\n{\n" + gen.Source + "\n}\n"); + var method = asm.GetType("CrossRunHolder")!.GetMethod("CrossRun", BindingFlags.Public | BindingFlags.Static)!; + MethodIrep(driver, "run"u8).CompiledBody = (CompiledRubyMethodBody)method.CreateDelegate(typeof(CompiledRubyMethodBody)); + + Assert.That(Exec("$cd.run(3.0, 4.0)"u8).FloatValue, Is.EqualTo(16.0)); + } + + void CompileAndBind(RClass owner, ReadOnlySpan rubyName, string genName) + { + var irep = MethodIrep(owner, rubyName); + Mrb2CsCompiler.TryCompileMethod(mrb, irep, genName, out var gen); + Assert.That(gen, Is.Not.Null, $"{genName} should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + var aux = string.Concat(gen.AuxiliaryMethods); + var asm = CompileToAssembly("public sealed class " + genName + "Holder : global::ChibiRuby.AotGeneratedMethods\n{\n" + gen.Source + aux + "\n}\n"); + var method = asm.GetType(genName + "Holder")!.GetMethod(genName, BindingFlags.Public | BindingFlags.Static)!; + irep.CompiledBody = (CompiledRubyMethodBody)method.CreateDelegate(typeof(CompiledRubyMethodBody)); + } + + [Test] + public void SingleLevelTimesBlockIsInlinedAsLoop() + { + // sum_to captures `s` (written by the block via an upvar); the times block must inline + // as a C# for loop with `s` passed by ref. No allocation, no dispatch into a block proc. + Exec(""" + class Adder + def sum_to(n) + s = 0 + n.times { |i| s = s + i } + s + end + def sum_floats(n) + s = 0.0 + n.times { |i| s = s + 0.5 } + s + end + end + $a = Adder.new + """u8); + + var adder = mrb.ClassOf(Exec("$a"u8)); + var irep = MethodIrep(adder, "sum_to"u8); + Mrb2CsCompiler.TryCompileMethod(mrb, irep, "SumTo", out var gen); + Assert.That(gen, Is.Not.Null, "times-block method should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + Assert.That(gen.Source, Does.Contain("for (long"), "the times block lowered to a C# for loop"); + Assert.That(gen.AuxiliaryMethods, Is.Not.Empty, "the block body is emitted as an auxiliary __blk method"); + + CompileAndBind(adder, "sum_to"u8, "SumTo2"); + CompileAndBind(adder, "sum_floats"u8, "SumFloats"); + + Assert.That(Exec("$a.sum_to(100)"u8), Is.EqualTo(new MRubyValue(4950)), "0+1+...+99"); + Assert.That(Exec("$a.sum_to(0)"u8), Is.EqualTo(new MRubyValue(0)), "empty loop"); + Assert.That(Exec("$a.sum_floats(4)"u8).FloatValue, Is.EqualTo(2.0), "0.5 * 4 via float upvar"); + } + + [Test] + public void NestedTimesBlocksInlineWithUpvarPassThrough() + { + // The inner block writes `s` (a method local — a depth-1 upvar) and reads `i` (the outer + // block's param — depth-0). C2 must pass `s`'s cell through the outer __blk to the inner. + Exec(""" + class Grid + def sum(n) + s = 0 + n.times do |i| + n.times do |j| + s = s + i * j + end + end + s + end + end + $g = Grid.new + """u8); + + var grid = mrb.ClassOf(Exec("$g"u8)); + Mrb2CsCompiler.TryCompileMethod(mrb, MethodIrep(grid, "sum"u8), "GridSum", out var gen); + Assert.That(gen, Is.Not.Null, "nested times-block method should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + Assert.That(gen.AuxiliaryMethods, Has.Count.EqualTo(2), "outer + inner block bodies"); + + CompileAndBind(grid, "sum"u8, "GridSum2"); + // sum(n) = (sum 0..n-1)^2. sum(20) = 190*190 = 36100. + Assert.That(Exec("$g.sum(20)"u8), Is.EqualTo(new MRubyValue(36100))); + Assert.That(Exec("$g.sum(1)"u8), Is.EqualTo(new MRubyValue(0))); + } + + static Assembly CompileToAssembly(string source) + { + var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + var trusted = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Where(p => p.Length > 0) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .ToList(); + trusted.Add(MetadataReference.CreateFromFile(typeof(MRubyState).Assembly.Location)); + + var compilation = CSharpCompilation.Create( + "AotGeneratedTest", + new[] { tree }, + trusted, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release, allowUnsafe: true)); + + using var ms = new MemoryStream(); + var result = compilation.Emit(ms); + if (!result.Success) + { + var errors = string.Join("\n", result.Diagnostics + .Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .Select(d => d.ToString())); + throw new InvalidOperationException($"Generated C# failed to compile:\n{errors}\n--- source ---\n{source}"); + } + + ms.Position = 0; + return Assembly.Load(ms.ToArray()); + } +} diff --git a/tests/ChibiRuby.Tests/AotCompiledMethodTest.cs b/tests/ChibiRuby.Tests/AotCompiledMethodTest.cs new file mode 100644 index 00000000..9bb2f674 --- /dev/null +++ b/tests/ChibiRuby.Tests/AotCompiledMethodTest.cs @@ -0,0 +1,427 @@ +using System.Diagnostics; +using ChibiRuby.Compiler; + +namespace ChibiRuby.Tests; + +// PoC for the AOT codegen path: a hot Ruby method gets a hand-written C# body +// (standing in for what a build-time Ruby->C# source generator would emit), +// attached behind type/shape guards, with deopt to the bytecode interpreter on a +// guard miss. The body is ordinary compiled C# (no runtime IL), so it is +// IL2CPP / NativeAOT compatible. +[TestFixture] +public class AotCompiledMethodTest +{ + MRubyState mrb = default!; + MRubyCompiler compiler = default!; + + [SetUp] + public void Before() + { + mrb = MRubyState.Create(); + compiler = MRubyCompiler.Create(mrb); + } + + [TearDown] + public void After() + { + compiler.Dispose(); + mrb.Dispose(); + } + + MRubyValue Exec(ReadOnlySpan code) + { + using var compilation = compiler.Compile(code); + return mrb.LoadBytecode(compilation.AsBytecode()); + } + + RProc MethodProc(RClass owner, ReadOnlySpan name) + { + Assert.That(mrb.TryFindMethod(owner, mrb.Intern(name), out var method, out _), Is.True); + return method.Proc!; + } + + // The "generated" body for `def sum; @x + @y; end` on a class whose @x,@y are + // Integers. Guards: receiver is an RObject and both ivars are fixnums. Anything + // else -> return false -> the VM interprets the original bytecode (deopt). + CompiledRubyMethodBody SumCompiledBody() + { + var xSym = mrb.Intern("@x"u8); + var ySym = mrb.Intern("@y"u8); + return (MRubyState state, int sp, out MRubyValue result) => + { + var self = state.Context.Stack[sp]; + if (self.Object is RObject obj) + { + var x = obj.InstanceVariables.Get(xSym); + var y = obj.InstanceVariables.Get(ySym); + if (x.IsFixnum && y.IsFixnum) + { + result = new MRubyValue(x.FixnumValue + y.FixnumValue); + return true; + } + } + result = default; + return false; // deopt + }; + } + + [Test] + public void CompiledBodyReturnsCorrectValue() + { + Exec(""" + class Point + def initialize; @x = 3; @y = 4; end + def sum; @x + @y; end + end + $p = Point.new + """u8); + + var pointClass = mrb.ClassOf(Exec("$p"u8)); + MethodProc(pointClass, "sum"u8).Irep.CompiledBody = SumCompiledBody(); + + Assert.That(Exec("$p.sum"u8), Is.EqualTo(new MRubyValue(7))); + } + + [Test] + public void DeoptsToInterpreterWhenGuardFails() + { + Exec(""" + class Point + def initialize; @x = 3; @y = 4; end + def sum; @x + @y; end + end + $p = Point.new + """u8); + + var pointClass = mrb.ClassOf(Exec("$p"u8)); + MethodProc(pointClass, "sum"u8).Irep.CompiledBody = SumCompiledBody(); + + // Float ivar: compiled guard (both fixnum) misses -> interpreter runs the + // original @x + @y producing a Float. Proves deopt correctness. + Exec("$p.instance_variable_set(:@x, 1.5)"u8); + Assert.That(Exec("$p.sum"u8).FloatValue, Is.EqualTo(5.5)); + + // Back to fixnum -> compiled path again. + Exec("$p.instance_variable_set(:@x, 10)"u8); + Assert.That(Exec("$p.sum"u8), Is.EqualTo(new MRubyValue(14))); + } + + [Test] + public void CodegenEmitsCSharpForSimpleMethod() + { + Exec(""" + class Point + def initialize; @x = 3; @y = 4; end + def sum; @x + @y; end + end + $p = Point.new + """u8); + + var sumIrep = MethodProc(mrb.ClassOf(Exec("$p"u8)), "sum"u8).Irep; + ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.TryCompileMethod(mrb, sumIrep, "Sum", out var gen); + + Assert.That(gen, Is.Not.Null, "sum should be codegen-able"); + TestContext.Out.WriteLine(gen!.Source); + + // Symbols interned once into per-method static fields (not per call). The field name + // carries the sanitized symbol name for readability (@x -> __sym0_at_x), and the name is + // interned from statically-initialized UTF-8 bytes (@x -> { 64, 120 }), not a UTF-16 string. + Assert.That(gen.Source, Does.Contain("Sum__sym0_at_x_u8 = new byte[] { 64, 120 };")); + Assert.That(gen.Source, Does.Contain("Sum__sym0_at_x = state.Intern(Sum__sym0_at_x_u8)")); + Assert.That(gen.Source, Does.Contain("Sum__sym1_at_y = state.Intern(Sum__sym1_at_y_u8)")); + // Both ivar reads are on self, so the (RObject)self cast is hoisted ONCE into a local and + // both reads go through it via the RObject IvarGet overload (no per-access castclass). + Assert.That(gen.Source, Does.Contain("= v0.As();")); + Assert.That( + gen.Source.Split("v0.As()").Length - 1, Is.EqualTo(1), + "self cast emitted once, not per ivar access"); + Assert.That(gen.Source, Does.Contain("__ro0.InstanceVariables.Get(Sum__sym0_at_x)")); + Assert.That(gen.Source, Does.Contain("__ro0.InstanceVariables.Get(Sum__sym1_at_y)")); + Assert.That(gen.Source, Does.Contain(".IsFixnum")); + Assert.That(gen.Source, Does.Contain(".FixnumValue + ")); + Assert.That(gen.Source, Does.Contain("return true;")); + Assert.That(gen.Source, Does.Contain("return false;")); // deopt on guard miss + } + + [Test] + public void CodegenCompilesLoopMethod() + { + // A `while` loop is a backward branch: the codegen lowers it to a C# goto/label loop + // (fully boxed, deopt-free) rather than bailing to the interpreter. + Exec(""" + class Counter + def count_to(n) + i = 0 + while i < n + i = i + 1 + end + i + end + end + $c = Counter.new + """u8); + + var irep = MethodProc(mrb.ClassOf(Exec("$c"u8)), "count_to"u8).Irep; + ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.TryCompileMethod(mrb, irep, "CountTo", out var gen); + Assert.That(gen, Is.Not.Null); + Assert.That(gen!.Source, Does.Contain("goto L")); // loop back-edge lowered to a goto + Assert.That(gen.Source, Does.Match(@"L\d+: ;")); // loop header label + } + + [Test] + public void CodegenUnboxesFloatLoop() + { + // Phase 2+3: a float-heavy while loop whose arg is used numerically gets a method-entry + // Fixnum guard + sound MUST typing, so the float chain lowers to RAW `double` locals (FP + // registers, no per-op IsFixnum/IsFloat dispatch, no MRubyValue box). `2.0 * x / size` + // proves Float once `size` (arg) and `x` (counter) are typed Fixnum. + Exec(""" + class Mb + def run(size) + x = 0 + sum = 0.0 + while x < size + sum = sum + (2.0 * x / size) - 1.5 + x = x + 1 + end + sum + end + end + $m = Mb.new + """u8); + + var irep = MethodProc(mrb.ClassOf(Exec("$m"u8)), "run"u8).Irep; + ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.TryCompileMethod(mrb, irep, "Run", out var gen); + Assert.That(gen, Is.Not.Null); + // Method-entry argument guard for the numerically-used arg `size`. + Assert.That(gen!.Source, Does.Contain(".IsFixnum) { result = default; return false; }")); + // Raw double loop locals (FP registers) + the fixnum operand read as (double) in mixed arith. + Assert.That(gen.Source, Does.Match(@"\n\s*double d\d+")); // a raw double local is declared + Assert.That(gen.Source, Does.Contain("(double)")); // fixnum operand read as double + Assert.That(gen.Source, Does.Match(@"d\d+ = d\d+ [*/+-]")); // raw double arithmetic, no box + } + + [Test] + public void CodegenScalarReplacesLocalArrayLiteral() + { + // A non-escaping `[a,b,c]` accessed by constant index is replaced with per-element locals + // (no RArray allocation). An array that escapes (passed to a method) keeps the allocation. + Exec(""" + class Arr + def pick(a, b, c) + t = [a, b, c] + t[0] + t[2] + end + def keep(a, b) + t = [a, b] + t.length + end + end + $a = Arr.new + """u8); + + var cls = mrb.ClassOf(Exec("$a"u8)); + ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.TryCompileMethod(mrb, MethodProc(cls, "pick"u8).Irep, "Pick", out var pick); + Assert.That(pick, Is.Not.Null); + Assert.That(pick!.Source, Does.Not.Contain("state.NewArray")); // literal eliminated + Assert.That(pick.Source, Does.Match(@"av\d+_0")); // element local + + ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.TryCompileMethod(mrb, MethodProc(cls, "keep"u8).Irep, "Keep", out var keep); + Assert.That(keep, Is.Not.Null); + Assert.That(keep!.Source, Does.Contain("state.NewArray")); // escapes -> still allocated + } + + [Test] + public void CodegenScalarReplacesArrayNew() + { + // `Array.new(const)` with constant-index access and no escape becomes nil-init element + // locals (no `:new` dispatch, no allocation). + Exec(""" + class An + def build(a, b) + t = Array.new(3) + t[0] = a; t[1] = b; t[2] = a + b + t[0] + t[1] + t[2] + end + end + $n = An.new + """u8); + + ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.TryCompileMethod(mrb, MethodProc(mrb.ClassOf(Exec("$n"u8)), "build"u8).Irep, "Build", out var gen); + Assert.That(gen, Is.Not.Null); + Assert.That(gen!.Source, Does.Not.Contain(", new")); // no `:new` dispatch (selector send gone) + Assert.That(gen.Source, Does.Contain("MRubyValue.Nil")); // nil-initialized element locals + Assert.That(gen.Source, Does.Match(@"av\d+_2")); // element local for index 2 + } + + [Test] + public void CodegenScalarReplacesConstKeyHash() + { + // A `{k => v}` with constant keys, constant-key access, and no escape becomes per-key locals + // (no RHash allocation, no [] / []= dispatch). A method that compiles with a hash literal at + // all also proves OP_HASH lowering works (it previously bailed entirely). + Exec(""" + class Hh + def build(a, b) + t = { :x => a, :y => b } + t[:x] + t[:y] + end + def keep(a) + t = { :x => a } + t # escapes -> real hash + end + end + $h = Hh.new + """u8); + + var cls = mrb.ClassOf(Exec("$h"u8)); + ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.TryCompileMethod(mrb, MethodProc(cls, "build"u8).Irep, "Build", out var build); + Assert.That(build, Is.Not.Null); + Assert.That(build!.Source, Does.Not.Contain("state.NewHash")); // hash eliminated + Assert.That(build.Source, Does.Match(@"hv\d+_s\d+")); // per-key local + + ChibiRuby.JetPack.Mrb2Cs.Mrb2CsCompiler.TryCompileMethod(mrb, MethodProc(cls, "keep"u8).Irep, "Keep", out var keep); + Assert.That(keep, Is.Not.Null); + Assert.That(keep!.Source, Does.Contain("state.NewHash")); // escapes -> allocated + } + + [Test] + public void FingerprintBindsCompiledBodyAtLoad() + { + byte[] bytes; + using (var compilation = compiler.Compile(""" + class Point + def initialize; @x = 3; @y = 4; end + def sum; @x + @y; end + end + $p = Point.new + """u8)) + { + bytes = compilation.AsBytecode().ToArray(); + } + + // First load = stand-in for "build time": define Point, get sum's irep, + // compute its fingerprint, register a body under it. + mrb.LoadBytecode(bytes); + var sumIrep1 = MethodProc(mrb.ClassOf(Exec("$p"u8)), "sum"u8).Irep; + Assert.That(sumIrep1.CompiledBody, Is.Null, "nothing registered yet"); + + var fingerprint = mrb.ComputeIrepFingerprint(sumIrep1); + mrb.RegisterCompiledMethod(fingerprint, SumCompiledBody()); + + // Re-load the SAME bytecode: a fresh sum irep with the same fingerprint must + // auto-bind the body at parse time — no names, no explicit attach. + mrb.LoadBytecode(bytes); + var sumIrep2 = MethodProc(mrb.ClassOf(Exec("$p"u8)), "sum"u8).Irep; + Assert.That(sumIrep2.CompiledBody, Is.Not.Null, "auto-bound by fingerprint at load"); + Assert.That(Exec("$p.sum"u8), Is.EqualTo(new MRubyValue(7))); + } + + [Test] + public void DifferentBytecodeDoesNotBind() + { + var body = SumCompiledBody(); + + // Register the body under the fingerprint of one method... + mrb.LoadBytecode(compiler.Compile(""" + class A + def sum; @x + @y; end + end + """u8).AsBytecode().ToArray()); + var aSum = MethodProc(mrb.ClassOf(Exec("A.new"u8)), "sum"u8).Irep; + mrb.RegisterCompiledMethod(mrb.ComputeIrepFingerprint(aSum), body); + + // ...a structurally different method must NOT bind it (different fingerprint). + mrb.LoadBytecode(compiler.Compile(""" + class B + def sum; @x - @y; end + end + """u8).AsBytecode().ToArray()); + var bSum = MethodProc(mrb.ClassOf(Exec("B.new"u8)), "sum"u8).Irep; + Assert.That(bSum.CompiledBody, Is.Null, "different bytecode -> different fingerprint -> no bind"); + } + + [Test] + public void CompiledBodyViaCSharpSend() + { + Exec(""" + class Point + def initialize; @x = 3; @y = 4; end + def sum; @x + @y; end + end + $p = Point.new + """u8); + + var pointVal = Exec("$p"u8); + var pointClass = mrb.ClassOf(pointVal); + MethodProc(pointClass, "sum"u8).Irep.CompiledBody = SumCompiledBody(); + var sumSym = mrb.Intern("sum"u8); + + // C#-initiated Send (SendWithStackPointer path): must use the compiled body + // and keep the call stack consistent across repeated calls. + for (var i = 0; i < 5; i++) + { + Assert.That(mrb.Send(pointVal, sumSym), Is.EqualTo(new MRubyValue(7))); + } + + // Deopt through the C# Send path as well. + Exec("$p.instance_variable_set(:@x, 1.5)"u8); + Assert.That(mrb.Send(pointVal, sumSym).FloatValue, Is.EqualTo(5.5)); + Exec("$p.instance_variable_set(:@x, 3)"u8); + Assert.That(mrb.Send(pointVal, sumSym), Is.EqualTo(new MRubyValue(7))); + } + + [Test] + public void CompiledIsFasterThanInterpreted() + { + Exec(""" + class Point + def initialize; @x = 3; @y = 4; end + def sum; @x + @y; end + end + $p = Point.new + def run_bench(n) + i = 0 + s = 0 + while i < n + s = $p.sum + i = i + 1 + end + s + end + """u8); + + var pointClass = mrb.ClassOf(Exec("$p"u8)); + var sumProc = MethodProc(pointClass, "sum"u8); + var compiled = SumCompiledBody(); + + const int n = 3_000_000; + + var benchSrc = System.Text.Encoding.UTF8.GetBytes($"run_bench({n})"); + + // Warm both paths. + sumProc.Irep.CompiledBody = null; + Exec("run_bench(100000)"u8); + sumProc.Irep.CompiledBody = compiled; + Exec("run_bench(100000)"u8); + + // Interpreted. + sumProc.Irep.CompiledBody = null; + var swInterp = Stopwatch.StartNew(); + var interpResult = Exec(benchSrc); + swInterp.Stop(); + + // Compiled. + sumProc.Irep.CompiledBody = compiled; + var swCompiled = Stopwatch.StartNew(); + var compiledResult = Exec(benchSrc); + swCompiled.Stop(); + + TestContext.Out.WriteLine( + $"sum x{n}: interpreted={swInterp.Elapsed.TotalMilliseconds:F1}ms compiled={swCompiled.Elapsed.TotalMilliseconds:F1}ms " + + $"speedup={swInterp.Elapsed.TotalMilliseconds / swCompiled.Elapsed.TotalMilliseconds:F2}x"); + + Assert.That(compiledResult, Is.EqualTo(new MRubyValue(7))); + Assert.That(interpResult, Is.EqualTo(new MRubyValue(7))); + } +} diff --git a/tests/ChibiRuby.Tests/ChibiRuby.Tests.csproj b/tests/ChibiRuby.Tests/ChibiRuby.Tests.csproj index e175874c..be54f934 100644 --- a/tests/ChibiRuby.Tests/ChibiRuby.Tests.csproj +++ b/tests/ChibiRuby.Tests/ChibiRuby.Tests.csproj @@ -11,6 +11,7 @@ + @@ -23,6 +24,7 @@ + Analyzer diff --git a/tests/ChibiRuby.Tests/IrepDebugInfoTest.cs b/tests/ChibiRuby.Tests/IrepDebugInfoTest.cs index 55908822..eab3c491 100644 --- a/tests/ChibiRuby.Tests/IrepDebugInfoTest.cs +++ b/tests/ChibiRuby.Tests/IrepDebugInfoTest.cs @@ -41,10 +41,7 @@ public void CompileWithDebugInfo_PopulatesIrepDebugInfo() [Test] public void CompileWithoutDebugInfo_DoesNotProduceIrepDebugInfo() { - using var c = compiler.Compile("x = 1\nx"u8, filename: "test.rb", new MRubyCompileOptions - { - EnableDebugInfo = false - }); + using var c = compiler.Compile("x = 1\nx"u8, filename: "test.rb", options: new MRubyCompileOptions { EnableDebugInfo = false }); var irep = c.ToIrep(); Assert.That(irep.DebugInfo, Is.Null, "DebugInfo should be omitted when debugInfo=false"); } diff --git a/tests/ChibiRuby.Tests/ruby/test/integer.rb b/tests/ChibiRuby.Tests/ruby/test/integer.rb index aaa029ef..45ad285a 100644 --- a/tests/ChibiRuby.Tests/ruby/test/integer.rb +++ b/tests/ChibiRuby.Tests/ruby/test/integer.rb @@ -30,6 +30,10 @@ if Object.const_defined?(:Float) b = 1*1.0 assert_equal 1.0, b + # via method dispatch (not the OP_MUL fast path): a value where a buggy `a - f` + # would differ from `a * f` (6.0 vs -1.0). + assert_equal 6.0, 2.send(:*, 3.0) + assert_equal 6.0, 2 * 3.0 end assert_raise(TypeError){ 0*nil } assert_raise(TypeError){ 1*nil }