diff --git a/sandbox/ChibiRuby.Benchmark/Program.cs b/sandbox/ChibiRuby.Benchmark/Program.cs index dd2b711d..ba8f2cbb 100644 --- a/sandbox/ChibiRuby.Benchmark/Program.cs +++ b/sandbox/ChibiRuby.Benchmark/Program.cs @@ -3,6 +3,7 @@ using System.Reflection; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; +using ChibiRuby; using ChibiRuby.Benchmark; // Profiling-friendly mode: warm up the VM, then run the measured optcarrot @@ -73,6 +74,36 @@ // return; // } +// Quick wall-clock runner for a single ruby script (no BenchmarkDotNet): +// --quick-script [iterations] [warmups] +// Prints per-iteration milliseconds and the median. +if (args is ["--quick-script", var scriptFile, ..]) +{ + var iterations = args.Length >= 3 && int.TryParse(args[2], out var it) ? it : 10; + var warmups = args.Length >= 4 && int.TryParse(args[3], out var w) ? w : 3; + + using var loader = new RubyScriptLoader(); + loader.PreloadScriptFromFile(scriptFile); + for (var i = 0; i < warmups; i++) + { + loader.RunChibiRuby(); + } + var times = new double[iterations]; + MRubyValue lastResult = default; + for (var i = 0; i < iterations; i++) + { + var sw = Stopwatch.StartNew(); + lastResult = loader.RunChibiRuby(); + sw.Stop(); + times[i] = sw.Elapsed.TotalMilliseconds; + } + Array.Sort(times); + var median = times[iterations / 2]; + Console.WriteLine($"result: {lastResult}"); + Console.WriteLine($"median: {median:F3} ms (min {times[0]:F3} / max {times[^1]:F3}, n={iterations})"); + return; +} + if (args is ["--quick-optcarrot", ..]) { var frames = args.Length >= 2 && int.TryParse(args[1], out var parsedFrames) diff --git a/sandbox/ChibiRuby.Benchmark/ruby/bm_ivar.rb b/sandbox/ChibiRuby.Benchmark/ruby/bm_ivar.rb new file mode 100644 index 00000000..8e645296 --- /dev/null +++ b/sandbox/ChibiRuby.Benchmark/ruby/bm_ivar.rb @@ -0,0 +1,85 @@ +# ivar-access microbenchmark: an object with many instance variables +# (like optcarrot's PPU, which has ~79) where the hot ones sit late in +# insertion order — worst case for a linear key scan. +class IvarHeavy + def initialize + @pad0 = 0 + @pad1 = 1 + @pad2 = 2 + @pad3 = 3 + @pad4 = 4 + @pad5 = 5 + @pad6 = 6 + @pad7 = 7 + @pad8 = 8 + @pad9 = 9 + @pad10 = 10 + @pad11 = 11 + @pad12 = 12 + @pad13 = 13 + @pad14 = 14 + @pad15 = 15 + @pad16 = 16 + @pad17 = 17 + @pad18 = 18 + @pad19 = 19 + @pad20 = 20 + @pad21 = 21 + @pad22 = 22 + @pad23 = 23 + @pad24 = 24 + @pad25 = 25 + @pad26 = 26 + @pad27 = 27 + @pad28 = 28 + @pad29 = 29 + @pad30 = 30 + @pad31 = 31 + @pad32 = 32 + @pad33 = 33 + @pad34 = 34 + @pad35 = 35 + @pad36 = 36 + @pad37 = 37 + @pad38 = 38 + @pad39 = 39 + @pad40 = 40 + @pad41 = 41 + @pad42 = 42 + @pad43 = 43 + @pad44 = 44 + @pad45 = 45 + @pad46 = 46 + @pad47 = 47 + @pad48 = 48 + @pad49 = 49 + @pad50 = 50 + @pad51 = 51 + @pad52 = 52 + @pad53 = 53 + @pad54 = 54 + @pad55 = 55 + @pad56 = 56 + @pad57 = 57 + @pad58 = 58 + @pad59 = 59 + @x = 0 + @y = 1 + @z = 2 + end + + def step(n) + i = 0 + while i < n + @x = @x + @y + @z + @y = @z + i + @z = @x - @y + @x = @x & 0xffff + i += 1 + end + @x + end +end + +h = IvarHeavy.new +h.step(400_000) diff --git a/src/ChibiRuby/Irep.cs b/src/ChibiRuby/Irep.cs index c03062a9..85dc773a 100644 --- a/src/ChibiRuby/Irep.cs +++ b/src/ChibiRuby/Irep.cs @@ -56,6 +56,23 @@ public class Irep public Irep[] Children { get; init; } = []; public CatchHandler[] CatchHandlers { get; init; } = []; + ushort[]? variableSlotCache; + + /// + /// Per-symbol-index inline cache used by variable-access opcodes (GetIV/SetIV, + /// GetGV/SetGV, GetConst). Holds the last slot where the symbol was found in the + /// target . Entries are guesses: every use is verified + /// against the table, so a stale value only costs the fallback lookup. + /// + internal ushort[] VariableSlotCache + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => variableSlotCache ?? CreateVariableSlotCache(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + ushort[] CreateVariableSlotCache() => variableSlotCache = new ushort[Symbols.Length]; + /// /// Source-position information recovered from the .mrb file's DBG section, if it was /// emitted (controlled by MRubyCompiler.Compile(..., debugInfo: true)). Null diff --git a/src/ChibiRuby/MRubyState.Class.cs b/src/ChibiRuby/MRubyState.Class.cs index c2aba416..44e75ca1 100644 --- a/src/ChibiRuby/MRubyState.Class.cs +++ b/src/ChibiRuby/MRubyState.Class.cs @@ -40,6 +40,12 @@ struct MethodCacheEntry public Symbol MethodId; public MRubyMethod Method; public RClass? DefiningClass; + + /// + /// Slot guess for lookups. + /// Verified against the receiver's table on every use, so staleness is harmless. + /// + public ushort TrivialGetterSlot; } public RClass SingletonClassOf(MRubyValue value) diff --git a/src/ChibiRuby/MRubyState.Vm.cs b/src/ChibiRuby/MRubyState.Vm.cs index 55502453..5867150d 100644 --- a/src/ChibiRuby/MRubyState.Vm.cs +++ b/src/ChibiRuby/MRubyState.Vm.cs @@ -489,9 +489,30 @@ internal MRubyValue EvalUnder(MRubyValue self, RProc block, RClass c) return self; } - /// - /// Execute irep assuming the Stack values are placed - /// + // Inline-cache miss paths for variable-access opcodes. Kept out of line so the + // dispatch switch's hot case bodies stay small; the caches store the found slot, + // and every cached slot is re-verified by VariableTable.TryGetAt/TrySetAt. + + [MethodImpl(MethodImplOptions.NoInlining)] + static MRubyValue GetVariableMiss(VariableTable table, Symbol id, ushort[] slotCache, int cacheIndex) + { + slotCache[cacheIndex] = unchecked((ushort)table.GetWithSlot(id, out var value)); + return value; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static void SetVariableMiss(VariableTable table, Symbol id, MRubyValue value, ushort[] slotCache, int cacheIndex) + { + slotCache[cacheIndex] = unchecked((ushort)table.SetWithSlot(id, value)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static MRubyValue TrivialGetterMiss(ref MethodCacheEntry entry, VariableTable table) + { + entry.TrivialGetterSlot = unchecked((ushort)table.GetWithSlot(entry.Method.TrivialGetterIVarSymbol, out var value)); + return value; + } + /// /// OP_SYMBOL slow path: intern the pool string and memoize it on the irep. /// A cache owned by another state is replaced wholesale (owner id and symbol @@ -511,6 +532,9 @@ static Symbol PoolSymbolMiss(MRubyState state, Irep irep, int poolIndex) return symbol; } + /// + /// Execute irep assuming the Stack values are placed + /// internal MRubyValue Execute(Irep irep, int pc, int stackKeep, RException? injectedRaise = null) { Exception = null; @@ -648,15 +672,28 @@ internal MRubyValue Execute(Irep irep, int pc, int stackKeep, RException? inject Unsafe.Add(ref registers, a) = MRubyValue.False; goto Next; case OpCode.GetGV: + { Markers.GetGV(); bb = OperandBB.Read(ref sequence, ref callInfo.ProgramCounter); - Unsafe.Add(ref registers, bb.A) = globalVariables.Get(Unsafe.Add(ref symbols, bb.B)); + var slotCache = irep.VariableSlotCache; + if (!globalVariables.TryGetAt(slotCache[bb.B], Unsafe.Add(ref symbols, bb.B), out var value)) + { + value = GetVariableMiss(globalVariables, Unsafe.Add(ref symbols, bb.B), slotCache, bb.B); + } + Unsafe.Add(ref registers, bb.A) = value; goto Next; + } case OpCode.SetGV: + { Markers.SetGV(); bb = OperandBB.Read(ref sequence, ref callInfo.ProgramCounter); - globalVariables.Set(Unsafe.Add(ref symbols, bb.B), Unsafe.Add(ref registers, bb.A)); + var slotCache = irep.VariableSlotCache; + if (!globalVariables.TrySetAt(slotCache[bb.B], Unsafe.Add(ref symbols, bb.B), Unsafe.Add(ref registers, bb.A))) + { + SetVariableMiss(globalVariables, Unsafe.Add(ref symbols, bb.B), Unsafe.Add(ref registers, bb.A), slotCache, bb.B); + } goto Next; + } case OpCode.GetSV: Markers.GetSV(); bb = OperandBB.Read(ref sequence, ref callInfo.ProgramCounter); @@ -668,18 +705,30 @@ internal MRubyValue Execute(Irep irep, int pc, int stackKeep, RException? inject globalVariables.Set(Unsafe.Add(ref symbols, bb.B), Unsafe.Add(ref registers, bb.A)); goto Next; case OpCode.GetIV: + { Markers.GetIV(); bb = OperandBB.Read(ref sequence, ref callInfo.ProgramCounter); - Unsafe.Add(ref registers, bb.A) = Unsafe.Add(ref registers, 0).As().InstanceVariables.Get(Unsafe.Add(ref symbols, bb.B)); + var table = Unsafe.Add(ref registers, 0).As().InstanceVariables; + var slotCache = irep.VariableSlotCache; + if (!table.TryGetAt(slotCache[bb.B], Unsafe.Add(ref symbols, bb.B), out var value)) + { + value = GetVariableMiss(table, Unsafe.Add(ref symbols, bb.B), slotCache, bb.B); + } + Unsafe.Add(ref registers, bb.A) = value; goto Next; + } case OpCode.SetIV: + { Markers.SetIV(); bb = OperandBB.Read(ref sequence, ref callInfo.ProgramCounter); - Unsafe.Add(ref registers, 0) - .As() - .InstanceVariables.Set(Unsafe.Add(ref symbols, bb.B), - Unsafe.Add(ref registers, bb.A)); + var table = Unsafe.Add(ref registers, 0).As().InstanceVariables; + var slotCache = irep.VariableSlotCache; + if (!table.TrySetAt(slotCache[bb.B], Unsafe.Add(ref symbols, bb.B), Unsafe.Add(ref registers, bb.A))) + { + SetVariableMiss(table, Unsafe.Add(ref symbols, bb.B), Unsafe.Add(ref registers, bb.A), slotCache, bb.B); + } goto Next; + } case OpCode.GetCV: Markers.GetCV(); bb = OperandBB.Read(ref sequence, ref callInfo.ProgramCounter); @@ -698,17 +747,31 @@ internal MRubyValue Execute(Irep irep, int pc, int stackKeep, RException? inject { var id = Unsafe.Add(ref symbols, bb.B); var c = callInfo.Proc?.Scope?.TargetClass ?? ObjectClass; - if (c.ClassInstanceVariables.TryGet(id, out var value)) + var slotCache = irep.VariableSlotCache; + if (c.ClassInstanceVariables.TryGetAt(slotCache[bb.B], id, out var value)) { registerA = value; goto Next; } - GetConstSlowPath( - this, ref registerA, ref callInfo, id, c); + GetConstMiss( + this, ref registerA, ref callInfo, id, c, slotCache, bb.B); goto Next; + [MethodImpl(MethodImplOptions.NoInlining)] + static void GetConstMiss(MRubyState state, ref MRubyValue registerA, ref MRubyCallInfo callInfo, Symbol id, RClass c, ushort[] slotCache, int cacheIndex) + { + var slot = c.ClassInstanceVariables.GetWithSlot(id, out var found); + if (slot >= 0) + { + slotCache[cacheIndex] = unchecked((ushort)slot); + registerA = found; + return; + } + GetConstSlowPath(state, ref registerA, ref callInfo, id, c); + } + [MethodImpl(MethodImplOptions.NoInlining)] static void GetConstSlowPath(MRubyState state, ref MRubyValue registerA, ref MRubyCallInfo callInfo, Symbol id, RClass c) { @@ -1571,8 +1634,12 @@ static VmSignal RaiseIf(MRubyState state, ref MRubyCallInfo callInfo, ref MRubyV if (ce.Class == selfObj.Class && ce.MethodId == mid && ce.Method.TrivialGetterIVarSymbol.Value != 0) { - Unsafe.Add(ref registers, bbb.A) = selfObj.InstanceVariables.Get( - ce.Method.TrivialGetterIVarSymbol); + var getterTable = selfObj.InstanceVariables; + if (!getterTable.TryGetAt(ce.TrivialGetterSlot, ce.Method.TrivialGetterIVarSymbol, out var getterValue)) + { + getterValue = TrivialGetterMiss(ref ce, getterTable); + } + Unsafe.Add(ref registers, bbb.A) = getterValue; goto Next; } } diff --git a/src/ChibiRuby/VariableTable.cs b/src/ChibiRuby/VariableTable.cs index 44f6e220..05137f8d 100644 --- a/src/ChibiRuby/VariableTable.cs +++ b/src/ChibiRuby/VariableTable.cs @@ -22,103 +22,113 @@ public int Length get => count; } + // Vectorized scan: Symbol is a single uint, so the key array can be searched + // with the SIMD IndexOf over uints instead of a scalar loop. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool Defined(Symbol id) + int IndexOfKey(Symbol id) => + System.Runtime.InteropServices.MemoryMarshal + .Cast(keys.AsSpan(0, count)) + .IndexOf(id.Value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Defined(Symbol id) => IndexOfKey(id) >= 0; + + // Slot-verified accessors used by the interpreter's inline caches. `slot` is an + // untrusted guess: it hits only when the entry at that index is exactly `id`, + // so a stale or foreign slot value can never read/write the wrong variable. + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool TryGetAt(int slot, Symbol id, out MRubyValue value) { - var keysLocal = keys; - ref var keysRef = ref GetArrayDataReference(keysLocal); - var l = count; - for (var i = 0; l > i; i++) + if ((uint)slot < (uint)count && + Unsafe.Add(ref GetArrayDataReference(keys), slot) == id) { - if (Unsafe.Add(ref keysRef, i) == id) return true; + value = Unsafe.Add(ref GetArrayDataReference(values), slot); + return true; } + value = default; return false; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryGet(Symbol id, out MRubyValue value) + internal bool TrySetAt(int slot, 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 ((uint)slot < (uint)count && + Unsafe.Add(ref GetArrayDataReference(keys), slot) == id) { - if (Unsafe.Add(ref keysRef, i) == id) - { - value = Unsafe.Add(ref valsRef, i); - return true; - } + Unsafe.Add(ref GetArrayDataReference(values), slot) = value; + return true; } - value = default; return false; } + /// Get that also reports the slot the symbol was found at (-1 when missing). [MethodImpl(MethodImplOptions.AggressiveInlining)] - public MRubyValue Get(Symbol id) + internal int GetWithSlot(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 (Unsafe.Add(ref keysRef, i) == id) - { - return Unsafe.Add(ref valsRef, i); - } - } - return default; + var i = IndexOfKey(id); + value = i >= 0 ? Unsafe.Add(ref GetArrayDataReference(values), i) : default; + return i; } + /// Set that also reports the slot the value was stored at. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Set(Symbol id, MRubyValue value) + internal int SetWithSlot(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++) + var i = IndexOfKey(id); + if (i >= 0) { - if (Unsafe.Add(ref keysRef, i) == id) - { - Unsafe.Add(ref valsRef, i) = value; - return; - } + Unsafe.Add(ref GetArrayDataReference(values), i) = value; + return i; } if (count >= keys.Length) Grow(); Unsafe.Add(ref GetArrayDataReference(keys), count) = id; Unsafe.Add(ref GetArrayDataReference(values), count) = value; - count++; + return count++; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryGet(Symbol id, out MRubyValue value) + { + var i = IndexOfKey(id); + if (i >= 0) + { + value = Unsafe.Add(ref GetArrayDataReference(values), i); + return true; + } + value = default; + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public MRubyValue Get(Symbol id) + { + var i = IndexOfKey(id); + return i >= 0 ? Unsafe.Add(ref GetArrayDataReference(values), i) : default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Set(Symbol id, MRubyValue value) => SetWithSlot(id, 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++) + var i = IndexOfKey(id); + if (i >= 0) { - if (Unsafe.Add(ref keysRef, i) == id) + ref var keysRef = ref GetArrayDataReference(keys); + ref var valsRef = ref GetArrayDataReference(values); + removedValue = Unsafe.Add(ref valsRef, i); + count--; + for (var j = i; j < count; j++) { - 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, 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; } removedValue = default; return false; diff --git a/tests/ChibiRuby.Tests/SpecTest.cs b/tests/ChibiRuby.Tests/SpecTest.cs index ae8e37d8..810fe723 100644 --- a/tests/ChibiRuby.Tests/SpecTest.cs +++ b/tests/ChibiRuby.Tests/SpecTest.cs @@ -101,6 +101,7 @@ public void After() [TestCase("class.rb")] [TestCase("module.rb")] [TestCase("methods.rb")] + [TestCase("variables.rb")] // lib [TestCase("integer.rb")] [TestCase("float.rb")] diff --git a/tests/ChibiRuby.Tests/ruby/test/variables.rb b/tests/ChibiRuby.Tests/ruby/test/variables.rb new file mode 100644 index 00000000..ec1f2084 --- /dev/null +++ b/tests/ChibiRuby.Tests/ruby/test/variables.rb @@ -0,0 +1,93 @@ +## +# Variable access tests, including the interpreter's inline-cache edge cases +# (slot guesses must be re-verified when a table's layout changes). + +assert('GetIV/SetIV inline cache survives remove_instance_variable') do + class IvIcRemove + def initialize + @a = 1 + @b = 2 + @c = 3 + end + def get_c + @c + end + def set_c(v) + @c = v + end + def drop_b + remove_instance_variable(:@b) + end + end + + o = IvIcRemove.new + assert_equal 3, o.get_c # warm the call site: @c found at slot 2 + o.drop_b # @c shifts down to slot 1 + assert_equal 3, o.get_c # stale slot guess must be rejected + o.set_c(30) + assert_equal 30, o.get_c +end + +assert('ivar site alternating between different layouts') do + class IvIcWide + def initialize + @pad0 = 0 + @pad1 = 0 + @x = 100 + end + def get_x + @x + end + end + class IvIcNarrow + def initialize + @x = 200 + end + def get_x + @x + end + end + + wide = IvIcWide.new + narrow = IvIcNarrow.new + acc = 0 + i = 0 + while i < 100 + acc += wide.get_x + narrow.get_x + i += 1 + end + assert_equal 30_000, acc +end + +assert('ivar read before assignment returns nil through a warmed site') do + class IvIcLazy + def get_v + @v + end + def set_v(v) + @v = v + end + end + + o = IvIcLazy.new + assert_nil o.get_v + o.set_v(42) + assert_equal 42, o.get_v + assert_nil IvIcLazy.new.get_v # fresh (empty) table through the same site +end + +assert('global variable slots stay correct as the table grows') do + $iv_ic_g1 = 10 + sum = 0 + i = 0 + while i < 10 + sum += $iv_ic_g1 + # defining new globals mid-loop grows/moves the table + $iv_ic_g2 = i if i == 3 + $iv_ic_g3 = i if i == 6 + i += 1 + end + assert_equal 100, sum + assert_equal 3, $iv_ic_g2 + assert_equal 6, $iv_ic_g3 +end