From f868dc5e795eedb6e4e2685578dc3952cd14aba0 Mon Sep 17 00:00:00 2001 From: Largo Date: Sun, 11 Oct 2026 20:59:13 +0200 Subject: [PATCH 01/94] RubyGems: prepend the no-extension-build hook lazily, keep the installer stack out of boot defaults/ironruby.rb reopened Gem::Installer at boot to prepend IronRubyNoExtensionBuild. Reopening an autoloaded constant triggers the autoload, so every IronRuby process loaded rubygems/installer.rb and with it package, the tar readers, the extension builders, security, user interaction and the openssl shim - 34 files CRuby does not load until something installs a gem, about a third of the cost of require "rubygems", and an OpenSSL that was defined in every process without a require (and an "openssl" entry in Gem.loaded_specs). Prepend from a Gem.const_added hook instead, the moment rubygems/installer.rb creates the class, whether it gets there through the autoload or one of the plain requires in RubyGems and Bundler. Boot now loads the same 22 rubygems/rbconfig/monitor features CRuby does. WIP: timing and gates pending. Co-Authored-By: Claude Opus 5.5 --- .../ruby/4.0/rubygems/defaults/ironruby.rb | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Src/StdLib/ruby/4.0/rubygems/defaults/ironruby.rb b/Src/StdLib/ruby/4.0/rubygems/defaults/ironruby.rb index 1fa2ad91..4a5f827d 100644 --- a/Src/StdLib/ruby/4.0/rubygems/defaults/ironruby.rb +++ b/Src/StdLib/ruby/4.0/rubygems/defaults/ironruby.rb @@ -535,8 +535,15 @@ def build_extensions # :nodoc: # # Prepended rather than reopened: rubygems/installer.rb is loaded long after # this file and would overwrite a plain definition. - - class Installer; end + # + # And prepended only once that file has created the class (const_added below), + # however it gets there - Gem's autoload or one of the plain requires of + # rubygems/installer scattered through RubyGems and Bundler. Reopening + # Gem::Installer here would trigger the autoload at boot, and with it the + # whole installer stack - package, the tar readers, the extension builders, + # security and the openssl it requires - which is a third of the cost of + # `require "rubygems"`, and which CRuby does not load until something + # actually installs a gem. module IronRubyNoExtensionBuild # :nodoc: def build_extensions @@ -548,7 +555,13 @@ def build_extensions end end - Installer.prepend(IronRubyNoExtensionBuild) + def self.const_added(name) # :nodoc: + super + # Registering an autoload fires this too; only a real class is prepended to. + Installer.prepend(IronRubyNoExtensionBuild) if name == :Installer && !autoload?(:Installer) + end + + Installer.prepend(IronRubyNoExtensionBuild) if const_defined?(:Installer, false) && !autoload?(:Installer) class BasicSpecification; end From 1efe273314c1f6492bdcc20ebd8b5031d3ab3037 Mon Sep 17 00:00:00 2001 From: Largo Date: Sun, 11 Oct 2026 21:08:35 +0200 Subject: [PATCH 02/94] Struct#initialize in C# for the common call; no lock to find an Integer's class Struct#initialize was Ruby (ruby4.rb): every Point.new(1, 2) built the members array, asked respond_to?(:keyword_init?), called the class's keyword_init? block method, checked args.last.is_a?(Hash) and splatted into __struct_initialize__. It is now RubyStructOps.Initialize: one positional value per member, the last not a Hash, on a class not built with keyword_init: true, stores the values in place; every other call goes to the unchanged Ruby code, renamed __struct_initialize_slow__ and handed the arguments as `*args` would have been (keyword mark cleared). __struct_initialize__ is registered directly instead of being an alias of the old initialize. RubyContext.TryGetInstanceData took a lock and a Dictionary lookup for every value-type object - every `1.is_a?(Hash)` asks for an Integer's immediate class that way. Entries of that table are never removed, so a published count of zero now answers without the lock. Point.new(1, 2) loop (1M): 3801ms -> see Gates line; CRuby 340ms. Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/StructOps.cs | 22 +++++++++++++++++++++- Src/Libraries/Initializers.Generated.cs | 7 ++++++- Src/Ruby/Builtins/RubyStruct.cs | 13 +++++++++++++ Src/Ruby/Runtime/RubyContext.cs | 9 +++++++++ Src/StdLib/ironruby/ruby4.rb | 9 +++++---- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Src/Libraries/Builtins/StructOps.cs b/Src/Libraries/Builtins/StructOps.cs index 30e2612e..5837d454 100644 --- a/Src/Libraries/Builtins/StructOps.cs +++ b/Src/Libraries/Builtins/StructOps.cs @@ -26,6 +26,7 @@ using Microsoft.Scripting.Runtime; using Microsoft.Scripting.Utils; using IronRuby.Runtime; +using IronRuby.Runtime.Calls; using System.Runtime.CompilerServices; namespace IronRuby.Builtins { @@ -93,8 +94,27 @@ private static object Create(BlockParam block, RubyClass/*!*/ self, string class return result; } - // Reinitialization. Called only from derived struct's initializer. + // Struct#initialize. The overwhelmingly common call - one positional value per member, the + // last of them not a Hash that could be keywords, on a class not built with keyword_init: + // true - stores the values right here, which is what keeps Point.new(1, 2) cheap. Every + // other call goes to Struct#__struct_initialize_slow__ in ruby4.rb with the arguments as + // one array, as a `def initialize(*args)' would have received them. [RubyMethod("initialize", RubyMethodAttributes.PrivateInstance)] + public static object Initialize(CallSiteStorage>/*!*/ slowPath, + RubyStruct/*!*/ self, params object[]/*!*/ items) { + + int count = self.ItemCount; + if (items.Length == count && (count == 0 || !(items[count - 1] is IronRuby.Builtins.Hash)) && !self.IsKeywordInit) { + self.SetValues(items); + return null; + } + + var site = slowPath.GetCallSite("__struct_initialize_slow__", new RubyCallSignature(1, RubyCallFlags.HasImplicitSelf)); + return site.Target(site, self, RubyOps.NormalizeRestArgument(new RubyArray(items), false)); + } + + // The plain store, for __struct_initialize_slow__. + [RubyMethod("__struct_initialize__", RubyMethodAttributes.PrivateInstance)] public static void Reinitialize(RubyStruct/*!*/ self, params object[]/*!*/ items) { self.SetValues(items); } diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index 9013034c..b096620b 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -8403,6 +8403,11 @@ private static void LoadStruct_Constants(IronRuby.Builtins.RubyModule/*!*/ modul } private static void LoadStruct_Instance(IronRuby.Builtins.RubyModule/*!*/ module) { + DefineLibraryMethod(module, "__struct_initialize__", 0x52, + 0x80000000U, + new Action(IronRuby.Builtins.RubyStructOps.Reinitialize) + ); + DefineLibraryMethod(module, "[]", 0x51, 0x00000000U, 0x00000002U, 0x00000002U, 0x00000000U, new Func(IronRuby.Builtins.RubyStructOps.GetValue), @@ -8446,7 +8451,7 @@ private static void LoadStruct_Instance(IronRuby.Builtins.RubyModule/*!*/ module DefineLibraryMethod(module, "initialize", 0x52, 0x80000000U, - new Action(IronRuby.Builtins.RubyStructOps.Reinitialize) + new Func>, IronRuby.Builtins.RubyStruct, System.Object[], System.Object>(IronRuby.Builtins.RubyStructOps.Initialize) ); DefineLibraryMethod(module, "initialize_copy", 0x52, diff --git a/Src/Ruby/Builtins/RubyStruct.cs b/Src/Ruby/Builtins/RubyStruct.cs index cd1e5d80..a2816d35 100644 --- a/Src/Ruby/Builtins/RubyStruct.cs +++ b/Src/Ruby/Builtins/RubyStruct.cs @@ -333,5 +333,18 @@ private void RequireNotFrozen() { public int ItemCount { get { return _data.Length; } } + + /// + /// Whether the struct's class was built by Struct.new(..., keyword_init: true): ruby4.rb's + /// Struct.new keeps the option in the class's @__keyword_init__, which is what the class's + /// #keyword_init? answers. A subclass has none of its own. + /// + public bool IsKeywordInit { + get { + var data = ImmediateClass.GetNonSingletonClass().TryGetInstanceData(); + object value; + return data != null && data.TryGetInstanceVariable("@__keyword_init__", out value) && RubyOps.IsTrue(value); + } + } } } diff --git a/Src/Ruby/Runtime/RubyContext.cs b/Src/Ruby/Runtime/RubyContext.cs index 2a324d82..f5155f55 100644 --- a/Src/Ruby/Runtime/RubyContext.cs +++ b/Src/Ruby/Runtime/RubyContext.cs @@ -258,6 +258,11 @@ public Random/*!*/ RandomNumberGenerator { private readonly Dictionary/*!*/ _valueTypeInstanceData; private object/*!*/ ValueTypeInstanceDataLock { get { return _valueTypeInstanceData; } } + // The number of entries in _valueTypeInstanceData, published after each add. Entries are + // never removed, so zero means there is nothing to find: then a lookup - every + // `1.is_a?(Hash)` asks for the immediate class of an Integer - takes no lock. + private int _valueTypeInstanceDataCount; + // not thread-safe: private readonly RubyInstanceData/*!*/ _nilInstanceData = new RubyInstanceData(RubyUtils.NilObjectId); @@ -2110,6 +2115,9 @@ internal RubyInstanceData TryGetInstanceData(object obj) { RubyInstanceData result; if (!RubyUtils.HasObjectState(obj) && !(obj is BigInteger)) { + if (Volatile.Read(ref _valueTypeInstanceDataCount) == 0) { + return null; + } lock (ValueTypeInstanceDataLock) { _valueTypeInstanceData.TryGetValue(obj, out result); } @@ -2176,6 +2184,7 @@ public void ReplaceClrObjectClass(object/*!*/ obj, RubyClass/*!*/ cls) { lock (ValueTypeInstanceDataLock) { if (!_valueTypeInstanceData.TryGetValue(obj, out result)) { _valueTypeInstanceData.Add(obj, result = new RubyInstanceData()); + Volatile.Write(ref _valueTypeInstanceDataCount, _valueTypeInstanceData.Count); } } return result; diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index 40ab1c47..5cab2d7d 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -13027,9 +13027,10 @@ def new(*args, &block) end class Struct - alias_method :__struct_initialize__, :initialize - - def initialize(*args) + # Struct#initialize is RubyStructOps.Initialize: it stores one positional value per member + # itself and hands every other call here, the arguments in one array. __struct_initialize__ + # is the plain store. + def __struct_initialize_slow__(args) # self.class.members, not members: a struct may have a member called "members" klass = self.class names = klass.members @@ -13070,7 +13071,7 @@ def initialize(*args) __struct_initialize__(*values) end end - private :initialize + private :__struct_initialize_slow__ end # -------------------------------------------------------------------------- From 84d40478aeb2b1634bcb1e047911c66efcc085e7 Mon Sep 17 00:00:00 2001 From: Largo Date: Sun, 11 Oct 2026 21:08:36 +0200 Subject: [PATCH 03/94] OSR: instance variable reads in outlined loops, typed as numeric operands An outlined (OSR) loop never had its self: an @x read referenced an unbound parameter, the lambda failed to compile and every loop touching an instance variable stayed generic. The loop site now passes self after the locals tuples and JitLoopCompiler binds it. `sum += @x` was refused even then: the read is an object, and only numeric operands compile. The loop is compiled with its self at hand, so what the variable holds right now is a type profile: an Integer or Float there is read through a guard (JitRuntime.GuardInt/GuardLong/GuardDouble) that unboxes it while it still is one and deopts when it is not - the generic loop then re-runs the iteration and does whatever the operator does with the new value. Reads are not observable, so the body stays restartable. Instance variable writes are still refused in an outlined loop: a FrozenError leaving the copy part way through would strand the scope's locals at their entry values. Co-Authored-By: Claude Opus 5.5 --- .../Ast/Expressions/WhileLoopExpression.cs | 3 ++ Src/Ruby/Runtime/Jit/JitCompiler.cs | 52 +++++++++++++++++-- Src/Ruby/Runtime/Jit/JitLoopCompiler.cs | 28 ++++++++-- Src/Ruby/Runtime/Jit/JitRuntime.cs | 19 +++++++ Src/Ruby/Runtime/Osr/OsrLoopSite.cs | 4 +- 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/Src/Ruby/Compiler/Ast/Expressions/WhileLoopExpression.cs b/Src/Ruby/Compiler/Ast/Expressions/WhileLoopExpression.cs index 3815d1e7..caa1da82 100644 --- a/Src/Ruby/Compiler/Ast/Expressions/WhileLoopExpression.cs +++ b/Src/Ruby/Compiler/Ast/Expressions/WhileLoopExpression.cs @@ -130,6 +130,9 @@ public WhileLoopExpression(Expression/*!*/ condition, bool isWhileLoop, bool isP } } osrSite.TupleArgIndex = tupleArgIndex; + // and self, which the instance variables the loop reads belong to + osrSite.SelfArgIndex = tupleArguments.Count; + tupleArguments.Add(AstUtils.Box(gen.CurrentSelfVariable)); osrArguments = Ast.NewArrayInit(typeof(object), tupleArguments); } diff --git a/Src/Ruby/Runtime/Jit/JitCompiler.cs b/Src/Ruby/Runtime/Jit/JitCompiler.cs index 108a3b8e..de7a2af2 100644 --- a/Src/Ruby/Runtime/Jit/JitCompiler.cs +++ b/Src/Ruby/Runtime/Jit/JitCompiler.cs @@ -139,8 +139,13 @@ private JitCompiler(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, JT[]/* private readonly List _slots; private MSA.LabelTarget _breakLabel, _continueLabel; + // Loop mode: what an instance variable of the loop's self holds right now, as a type + // (see NumericIVar). Null when the loop was entered without its self, which is then + // unbound and the loop may not touch self or an instance variable. + private readonly Func _ivarProfile; + private JitCompiler(WhileLoopExpression/*!*/ loop, RubyContext/*!*/ context, int scopeDepth, - Func/*!*/ bindSlot) { + Func/*!*/ bindSlot, MSA.ParameterExpression self, Func ivarProfile) { _ast = null; _loop = loop; _context = context; @@ -149,21 +154,30 @@ private JitCompiler(WhileLoopExpression/*!*/ loop, RubyContext/*!*/ context, int _slots = new List(); _paramTypes = new JT[0]; _paramNames = new string[0]; - _self = Ast.Parameter(typeof(object), "#self"); + _self = self ?? Ast.Parameter(typeof(object), "#self"); + _ivarProfile = (self != null) ? ivarProfile : null; PushScope(); } /// /// Emits a typed body for one `while' loop. The slots it bound - the scope locals it /// touched, and the type each of them held when the loop turned hot - come back in - /// ; the caller loads and stores them. + /// ; the caller loads and stores them. is + /// the variable the caller loads the loop's self into (null if it has none), and + /// what self's instance variables hold right now. /// internal static MSA.Expression TryCompileLoop(WhileLoopExpression/*!*/ loop, RubyContext/*!*/ context, int scopeDepth, Func/*!*/ bindSlot, out List slots, out bool canDeopt) { + return TryCompileLoop(loop, context, scopeDepth, bindSlot, null, null, out slots, out canDeopt); + } + + internal static MSA.Expression TryCompileLoop(WhileLoopExpression/*!*/ loop, RubyContext/*!*/ context, + int scopeDepth, Func/*!*/ bindSlot, MSA.ParameterExpression self, + Func ivarProfile, out List slots, out bool canDeopt) { slots = null; canDeopt = false; - var c = new JitCompiler(loop, context, scopeDepth, bindSlot); + var c = new JitCompiler(loop, context, scopeDepth, bindSlot, self, ivarProfile); MSA.Expression body; try { JT type; @@ -478,12 +492,14 @@ private static Type DelegateType(JT[]/*!*/ ps, JT ret) { var ivar = node as InstanceVariable; if (ivar != null) { + if (_loop != null && _ivarProfile == null) { throw Bail("instance variable in a loop entered without self"); } type = JT.Obj; return Ast.Call(JitRuntime.M("GetIVar"), Ast.Constant(_context), _self, Ast.Constant(new InstanceVariableSite(ivar.Name))); } var self = node as SelfReference; if (self != null) { + if (_loop != null && _ivarProfile == null) { throw Bail("self in a loop entered without it"); } type = JT.Obj; return _self; } @@ -551,6 +567,9 @@ private static Type DelegateType(JT[]/*!*/ ps, JT ret) { var ivarTarget = node.Left as InstanceVariable; if (ivarTarget != null) { if (op != null) { throw JitBailout.Instance; } + // A write can raise (FrozenError), and an exception leaving an outlined loop + // part way through would leave the scope's locals as they were on entry. + if (_loop != null) { throw Bail("instance variable write in an outlined loop"); } JT vt; var v = Emit(node.Right, out vt); _pure = false; // an ivar write is observable, so the body may not deopt @@ -576,6 +595,7 @@ private static Type DelegateType(JT[]/*!*/ ps, JT ret) { if (op != null) { // `x op= v' -> `x = x op v' if (!known) { throw JitBailout.Instance; } + rhs = NumericIVar(node.Right, rhs, ref rhsType); if (!IsNumeric(lt) || !IsNumeric(rhsType)) { throw JitBailout.Instance; } rhs = EmitBinary(op, (_loop != null) ? LoadLoopSlot(p) : p, lt, rhs, rhsType, out rhsType); } @@ -852,6 +872,8 @@ private static JT Unify(JT a, JT b) { JT lt, rt; var l = Emit(node.Target, out lt); var r = Emit(args.Expressions[0], out rt); + l = NumericIVar(node.Target, l, ref lt); + r = NumericIVar(args.Expressions[0], r, ref rt); if (IsNumeric(lt) && IsNumeric(rt)) { return EmitBinary(node.MethodName, l, lt, r, rt, out type); } @@ -864,6 +886,28 @@ private static bool IsNumeric(JT t) { return t == JT.Int || t == JT.Lng || t == JT.Dbl; } + /// + /// An instance variable read used as a numeric operand - `sum += @x', `@n * 2'. An + /// outlined loop is compiled with its self at hand, so what the variable holds right now + /// is a type profile: an Integer or a Float there is read through a guard that unboxes it + /// while it still is one and deopts when it is not, and the generic loop then does + /// whatever the operator does with whatever the variable holds. Reads are not + /// observable, so this keeps a body restartable. Anything else stays an object. + /// + private MSA.Expression/*!*/ NumericIVar(RExpr/*!*/ node, MSA.Expression/*!*/ e, ref JT type) { + var ivar = node as InstanceVariable; + if (type != JT.Obj || ivar == null || _ivarProfile == null) { return e; } + string guard; + switch (_ivarProfile(ivar.Name)) { + case JT.Int: type = JT.Int; guard = "GuardInt"; break; + case JT.Lng: type = JT.Lng; guard = "GuardLong"; break; + case JT.Dbl: type = JT.Dbl; guard = "GuardDouble"; break; + default: return e; + } + _canDeopt = true; + return Ast.Call(JitRuntime.M(guard), e); + } + /// The two representations of a Ruby Integer the specialized code carries. private static bool IsInteger(JT t) { return t == JT.Int || t == JT.Lng; diff --git a/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs b/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs index 5dbb9a1b..ec2e3d7f 100644 --- a/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs +++ b/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs @@ -69,10 +69,11 @@ internal static class JitLoopCompiler { /// Builds a typed entry for a loop, or returns null when the loop is not the shape /// this can specialize. are the locals tuples the site passes, /// one per lexical depth; they carry the live values the types are read from, which is - /// a type profile a loop that is already running gets for free. + /// a type profile a loop that is already running gets for free. The loop's self is + /// args[], or there is none if that is negative. /// internal static Func TryCompile(WhileLoopExpression/*!*/ loop, RubyContext/*!*/ context, - int scopeDepth, Dictionary/*!*/ tupleArgIndex, object[]/*!*/ args) { + int scopeDepth, Dictionary/*!*/ tupleArgIndex, object[]/*!*/ args, int selfArgIndex) { if (JitRuntime.Disabled || context.HasRefinements) { return null; } @@ -149,9 +150,24 @@ internal static Func TryCompile(WhileLoopExpression/*!*/ loop, }; }; + // The loop's self, which its instance variable reads go through. The live object is a + // profile as well: what one of its variables holds when the loop turns hot is the type + // a numeric use of the variable is specialized for (JitCompiler.NumericIVar). + MSA.ParameterExpression self = null; + Func ivarProfile = null; + if (selfArgIndex >= 0 && selfArgIndex < args.Length) { + object liveSelf = args[selfArgIndex]; + self = Ast.Parameter(typeof(object), "#self"); + ivarProfile = name => { + object value; + if (!context.TryGetInstanceVariable(liveSelf, name, out value)) { return JT.Obj; } + return (value is int) ? JT.Int : (value is long) ? JT.Lng : (value is double) ? JT.Dbl : JT.Obj; + }; + } + List slots; bool canDeopt; - MSA.Expression body = JitCompiler.TryCompileLoop(loop, context, scopeDepth, bind, out slots, out canDeopt); + MSA.Expression body = JitCompiler.TryCompileLoop(loop, context, scopeDepth, bind, self, ivarProfile, out slots, out canDeopt); if (body == null || slots.Count == 0) { if (OsrLoopSite.Verbose) { Console.Error.WriteLine("[osr] emitter gave up (body={0} slots={1})", body != null, slots == null ? -1 : slots.Count); } return null; @@ -160,6 +176,9 @@ internal static Func TryCompile(WhileLoopExpression/*!*/ loop, var locals = new List(tuples.Values); var probe = Ast.Parameter(typeof(object), "#probe"); locals.Add(probe); + if (self != null) { + locals.Add(self); + } // The scope this loop belongs to always makes tuples of the same shape, but nothing // in the type system says so: check before the cast rather than let an unexpected @@ -169,6 +188,9 @@ internal static Func TryCompile(WhileLoopExpression/*!*/ loop, prologue.Add(Ast.IfThen(guard, Ast.Return(exit, Ast.Constant(OsrLoopSite.Retry, typeof(object))))); } prologue.AddRange(tupleInit); + if (self != null) { + prologue.Add(Ast.Assign(self, Ast.ArrayIndex(argsParameter, Ast.Constant(selfArgIndex)))); + } // Nothing may have redefined Integer#+ (or anything else) since the types were // read: one global version covers every def, undef, alias and include. diff --git a/Src/Ruby/Runtime/Jit/JitRuntime.cs b/Src/Ruby/Runtime/Jit/JitRuntime.cs index 06df34e4..a5131529 100644 --- a/Src/Ruby/Runtime/Jit/JitRuntime.cs +++ b/Src/Ruby/Runtime/Jit/JitRuntime.cs @@ -317,6 +317,25 @@ public static object SetIVar(RubyContext/*!*/ context, object self, object value return RubyOps.WriteInstanceVariable(context, self, value, site); } + // An instance variable used as a numeric operand of an outlined loop (JitCompiler.NumericIVar): + // unboxed while it holds the type it held when the loop was compiled, a deopt once it does not. + + public static int GuardInt(object value) { + if (value is int) { return (int)value; } + throw Deopt(); + } + + public static long GuardLong(object value) { + if (value is int) { return (int)value; } + if (value is long) { return (long)value; } + throw Deopt(); + } + + public static double GuardDouble(object value) { + if (value is double) { return (double)value; } + throw Deopt(); + } + // ---- reflection handles the compiler emits --------------------------------------- internal static MethodInfo/*!*/ M(string/*!*/ name) { diff --git a/Src/Ruby/Runtime/Osr/OsrLoopSite.cs b/Src/Ruby/Runtime/Osr/OsrLoopSite.cs index 7b03c1fe..cae0f8c8 100644 --- a/Src/Ruby/Runtime/Osr/OsrLoopSite.cs +++ b/Src/Ruby/Runtime/Osr/OsrLoopSite.cs @@ -103,6 +103,8 @@ private static long ReadThreshold() { internal RubyContext Context; internal int ScopeDepth; internal Dictionary TupleArgIndex; + // Where in the argument array the loop's self is; -1 if it is not passed. + internal int SelfArgIndex = -1; private readonly object/*!*/ _lock = new object(); private readonly string/*!*/ _name; @@ -194,7 +196,7 @@ private Func Compile(object[]/*!*/ tuples) { long start = Stopwatch.GetTimestamp(); try { - _typed = JitLoopCompiler.TryCompile(LoopAst, Context, ScopeDepth, TupleArgIndex, tuples); + _typed = JitLoopCompiler.TryCompile(LoopAst, Context, ScopeDepth, TupleArgIndex, tuples, SelfArgIndex); } catch (Exception e) { if (Verbose) { Console.Error.WriteLine("[osr] {0}: specialization failed: {1}", _name, e.Message); From c463a51997a9354585b21eec9f72b080583673ab Mon Sep 17 00:00:00 2001 From: Largo Date: Sun, 11 Oct 2026 21:31:35 +0200 Subject: [PATCH 04/94] WIP: spinning fiber mailbox, cheap Fiber.current, fiber-free Enumerator#next over Array Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/Enumerator.cs | 94 ++++++++++ Src/Libraries/Builtins/ThreadOps.Fiber.cs | 202 ++++++++++++++++++++++ Src/Libraries/Builtins/ThreadOps.cs | 2 +- Src/Libraries/Initializers.Generated.cs | 40 +++++ Src/StdLib/ironruby/ruby4.rb | 76 ++++++-- Util/bench/bench.rb | 23 +++ 6 files changed, 417 insertions(+), 20 deletions(-) create mode 100644 Src/Libraries/Builtins/ThreadOps.Fiber.cs diff --git a/Src/Libraries/Builtins/Enumerator.cs b/Src/Libraries/Builtins/Enumerator.cs index cd148878..24bc0afb 100644 --- a/Src/Libraries/Builtins/Enumerator.cs +++ b/Src/Libraries/Builtins/Enumerator.cs @@ -201,6 +201,100 @@ public static object GetSizeInfo(RubyContext/*!*/ context, Enumerator/*!*/ self) return result; } + #region external iteration over an Array + + /// + /// Where Enumerator#next is in an Array it walks without a fiber (see GetArrayCursor). + /// Held in an instance variable, so that a #dup shares it the way it would share the fiber. + /// + public sealed class ArrayCursor { + internal readonly RubyArray/*!*/ Array; + internal readonly System.Threading.Thread/*!*/ Thread; + internal int Index; + + internal ArrayCursor(RubyArray/*!*/ array, System.Threading.Thread/*!*/ thread) { + Array = array; + Thread = thread; + } + } + + /// + /// A cursor for an enumerator whose iteration is nothing but the built-in Array#each over an + /// Array (`ary.each`, `ary.to_enum`), or nil. Enumerator#next then steps through the Array by + /// index instead of suspending #each in a fiber - a thread of its own here. The two cannot be + /// told apart: Array#each runs no Ruby code between elements and re-reads the length before + /// each one, which is exactly what the cursor does, so an Array that grows or shrinks half + /// way through behaves the same, and #each's result (the Array) is StopIteration#result. + /// + [RubyMethod("__ir_array_cursor__", RubyMethodAttributes.PrivateInstance)] + public static ArrayCursor GetArrayCursor(RubyContext/*!*/ context, Enumerator/*!*/ self) { + var wrapper = self._impl as Wrapper; + RubyArray array; + if (wrapper == null || wrapper.TargetName != "each" || wrapper.TargetArguments.Length != 0 || + (array = wrapper.TargetObject as RubyArray) == null) { + return null; + } + + object generator; + if (context.TryGetInstanceVariable(self, "@generator", out generator) && generator != null) { + return null; + } + + // Neither the enumerator's #each nor the Array's may have been replaced (redefined, + // aliased, overridden in a subclass or a singleton, prepended to, or removed). + if (!IsBuiltinMethod(context, self, "each", typeof(Enumerator), "EachFromCaller") || + !IsBuiltinMethod(context, array, "each", typeof(IListOps), "Each")) { + return null; + } + + return new ArrayCursor(array, RubyUtils.CurrentRubyThread); + } + + private static bool IsBuiltinMethod(RubyContext/*!*/ context, object target, string/*!*/ name, Type/*!*/ declaringType, string/*!*/ clrName) { + var method = context.ResolveMethod(target, name, VisibilityContext.AllVisible); + if (!method.Found || !(method.Info is RubyLibraryMethodInfo)) { + return false; + } + var members = method.Info.GetMembers(); + if (members.Length == 0) { + return false; + } + foreach (var member in members) { + if (member.DeclaringType != declaringType || member.Name != clrName) { + return false; + } + } + return true; + } + + /// + /// The next element as a one-element Array (what the fiber would have yielded), nil at the + /// end, false when called from a thread other than the one that started the iteration - + /// where the fiber would raise FiberError. + /// + [RubyMethod("__ir_array_cursor_next__", RubyMethodAttributes.PrivateInstance)] + public static object ArrayCursorNext(Enumerator/*!*/ self, [NotNull]ArrayCursor/*!*/ cursor) { + if (cursor.Thread != RubyUtils.CurrentRubyThread) { + return ScriptingRuntimeHelpers.False; + } + RubyArray array = cursor.Array; + int index = cursor.Index; + if (index < array.Count) { + cursor.Index = index + 1; + RubyArray values = new RubyArray(1); + values.Add(array[index]); + return values; + } + return null; + } + + [RubyMethod("__ir_array_cursor_source__", RubyMethodAttributes.PrivateInstance)] + public static RubyArray/*!*/ ArrayCursorSource(Enumerator/*!*/ self, [NotNull]ArrayCursor/*!*/ cursor) { + return cursor.Array; + } + + #endregion + // TODO: 1.9: // :each_with_index, :each_with_object, :with_index, :with_object, :next, :rewind } diff --git a/Src/Libraries/Builtins/ThreadOps.Fiber.cs b/Src/Libraries/Builtins/ThreadOps.Fiber.cs new file mode 100644 index 00000000..af21ec16 --- /dev/null +++ b/Src/Libraries/Builtins/ThreadOps.Fiber.cs @@ -0,0 +1,202 @@ +/* **************************************************************************** + * + * Copyright (c) Microsoft Corporation. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ +#if FEATURE_THREAD +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using IronRuby.Runtime; +using Microsoft.Scripting.Runtime; + +namespace IronRuby.Builtins { + + // The primitives the Ruby-level Fiber (Src/StdLib/ironruby/ruby4.rb) switches with. + public static partial class ThreadOps { + + /// + /// The mailbox a fiber waits on while it is not running. Every fiber is a CLR thread of its + /// own and exactly one fiber of a group runs at a time, so a switch is "post to the target's + /// mailbox, then take from my own". + /// + /// Taking spins for a few microseconds before it blocks. A resume / Fiber.yield round trip + /// whose other side answers quickly - Enumerator#next over a generator, a fiber that yields + /// in a loop - then costs two cache-line transfers instead of two kernel wake-ups, which is + /// most of what a switch used to cost. The spin budget adapts per mailbox: it doubles while + /// answers arrive in time and halves when one does not, so a fiber that is resumed rarely + /// soon stops burning a core on it. Single-core machines never spin. + /// + /// A blocked take is a Monitor wait, so Thread#kill / Thread#raise (delivered with + /// Thread.Interrupt) wake it exactly as they wake Queue#pop, which the mailbox used to be. + /// + public sealed class FiberMailbox { + private const long MinSpinTicks = 0; + private static readonly long _maxSpinTicks = Stopwatch.Frequency / 20000; // 50us + private static readonly long _initialSpinTicks = Stopwatch.Frequency / 50000; // 20us + private static readonly long _spinStepTicks = Stopwatch.Frequency / 500000; // 2us + private static readonly bool _canSpin = Environment.ProcessorCount > 1; + + private readonly object/*!*/ _lock = new object(); + + // Pending messages: practically always none or one, so the first one has a slot of its own. + private object _message; + private Queue _overflow; + private volatile int _count; + + // The receiver is parked in Monitor.Wait (guarded by _lock). + private bool _blocked; + + private long _spinTicks = _initialSpinTicks; + + // The fiber group's owner, and its RubyThreadInfo, cached for FiberTake. + private Thread _owner; + private RubyThreadInfo _ownerInfo; + + public void Post(object message) { + lock (_lock) { + int count = _count; + if (count == 0) { + _message = message; + } else { + if (_overflow == null) { + _overflow = new Queue(); + } + _overflow.Enqueue(message); + } + _count = count + 1; + if (_blocked) { + Monitor.Pulse(_lock); + } + } + } + + public object Take() { + if (_count == 0 && _canSpin) { + Spin(); + } + + while (true) { + bool interrupted = false; + lock (_lock) { + if (_count == 0) { + _blocked = true; + try { + Monitor.Wait(_lock); + } catch (ThreadInterruptedException) { + interrupted = true; + } finally { + _blocked = false; + } + } + + if (!interrupted && _count != 0) { + object message = _message; + int count = _count - 1; + _message = (count > 0) ? _overflow.Dequeue() : null; + _count = count; + return message; + } + } + + if (interrupted) { + // Thread#kill / Thread#raise nudged us: deliver the parked exception (outside the + // lock, it runs Ruby code). If nothing is parked the wait simply goes on. + RubyUtils.TranslateThreadInterrupt(); + } + } + } + + private void Spin() { + long budget = _spinTicks; + if (budget <= MinSpinTicks) { + // Recent answers all came too late to catch. Try again every now and then, the + // fiber may have changed its rhythm. + _spinTicks = _spinStepTicks; + return; + } + + long start = Stopwatch.GetTimestamp(); + while (true) { + for (int i = 0; i < 8; i++) { + Thread.SpinWait(4); + if (_count != 0) { + long next = budget * 2; + _spinTicks = (next > _maxSpinTicks) ? _maxSpinTicks : next; + return; + } + } + if (Stopwatch.GetTimestamp() - start > budget) { + _spinTicks = budget / 2 > _spinStepTicks ? budget / 2 : MinSpinTicks; + return; + } + } + } + + internal RubyThreadInfo/*!*/ GetOwnerInfo(Thread/*!*/ owner) { + RubyThreadInfo info = _ownerInfo; + if (info == null || !ReferenceEquals(_owner, owner)) { + info = RubyThreadInfo.FromThread(owner); + _owner = owner; + _ownerInfo = info; + } + return info; + } + } + + // Fiber.current of this CLR thread (a fiber never leaves the thread it runs on), for the + // context it belongs to. The same thing is kept in the thread's locals as :__ir_fiber_current__ + // for the C# code that looks there; this copy is what makes Fiber.current cheap. + [ThreadStatic] + private static object _currentFiber; + [ThreadStatic] + private static RubyContext _currentFiberContext; + + [RubyMethod("__fiber_mailbox__", RubyMethodAttributes.PublicSingleton)] + public static FiberMailbox/*!*/ CreateFiberMailbox(object self) { + return new FiberMailbox(); + } + + [RubyMethod("__fiber_current__", RubyMethodAttributes.PublicSingleton)] + public static object GetCurrentFiber(RubyContext/*!*/ context, object self) { + return ReferenceEquals(_currentFiberContext, context) ? _currentFiber : null; + } + + [RubyMethod("__fiber_current_set__", RubyMethodAttributes.PublicSingleton)] + public static object SetCurrentFiber(RubyContext/*!*/ context, object self, object fiber) { + RubyThreadInfo.FromThread(Thread.CurrentThread)[context.CreateAsciiSymbol("__ir_fiber_current__")] = fiber; + _currentFiber = fiber; + _currentFiberContext = context; + return fiber; + } + + [RubyMethod("__fiber_post__", RubyMethodAttributes.PublicSingleton)] + public static object FiberPost(object self, [NotNull]FiberMailbox/*!*/ mailbox, object message) { + mailbox.Post(message); + return null; + } + + /// + /// Waits for the next message to the fiber running on this thread, then makes this thread + /// the one that runs on behalf of (what __set_fiber_owner__ does). + /// + [RubyMethod("__fiber_take__", RubyMethodAttributes.PublicSingleton)] + public static object FiberTake(object self, [NotNull]FiberMailbox/*!*/ mailbox, [NotNull]Thread/*!*/ owner) { + object message = mailbox.Take(); + RubyUtils.SetFiberOwnerThread(owner); + mailbox.GetOwnerInfo(owner).ActiveFiberThread = Thread.CurrentThread; + return message; + } + } +} +#endif diff --git a/Src/Libraries/Builtins/ThreadOps.cs b/Src/Libraries/Builtins/ThreadOps.cs index 47380059..dd2cef20 100644 --- a/Src/Libraries/Builtins/ThreadOps.cs +++ b/Src/Libraries/Builtins/ThreadOps.cs @@ -39,7 +39,7 @@ namespace IronRuby.Builtins { /// and this will not be an issue then. /// [RubyClass("Thread", Extends = typeof(Thread), Inherits = typeof(object), BuildConfig = "FEATURE_THREAD")] - public static class ThreadOps { + public static partial class ThreadOps { static bool _globalAbortOnException; static bool _globalReportOnException = true; diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index 9013034c..d586d73f 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -1503,6 +1503,21 @@ private static void LoadEnumerator_Instance(IronRuby.Builtins.RubyModule/*!*/ mo new Func(IronRuby.Builtins.Enumerator.GetTarget) ); + DefineLibraryMethod(module, "__ir_array_cursor__", 0x52, + 0x00000000U, + new Func(IronRuby.Builtins.Enumerator.GetArrayCursor) + ); + + DefineLibraryMethod(module, "__ir_array_cursor_next__", 0x52, + 0x00000002U, + new Func(IronRuby.Builtins.Enumerator.ArrayCursorNext) + ); + + DefineLibraryMethod(module, "__ir_array_cursor_source__", 0x52, + 0x00000002U, + new Func(IronRuby.Builtins.Enumerator.ArrayCursorSource) + ); + DefineLibraryMethod(module, "each", 0x51, 0x80000000U, new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, IronRuby.Builtins.Enumerator, System.Object[], System.Object>(IronRuby.Builtins.Enumerator.EachFromCaller) @@ -10007,6 +10022,31 @@ private static void LoadThread_Class(IronRuby.Builtins.RubyModule/*!*/ module) { new Func(IronRuby.Builtins.ThreadOps.GetBacktraceLimit) ); + DefineLibraryMethod(module, "__fiber_current__", 0x61, + 0x00000000U, + new Func(IronRuby.Builtins.ThreadOps.GetCurrentFiber) + ); + + DefineLibraryMethod(module, "__fiber_current_set__", 0x61, + 0x00000000U, + new Func(IronRuby.Builtins.ThreadOps.SetCurrentFiber) + ); + + DefineLibraryMethod(module, "__fiber_mailbox__", 0x61, + 0x00000000U, + new Func(IronRuby.Builtins.ThreadOps.CreateFiberMailbox) + ); + + DefineLibraryMethod(module, "__fiber_post__", 0x61, + 0x00000002U, + new Func(IronRuby.Builtins.ThreadOps.FiberPost) + ); + + DefineLibraryMethod(module, "__fiber_take__", 0x61, + 0x00000006U, + new Func(IronRuby.Builtins.ThreadOps.FiberTake) + ); + DefineLibraryMethod(module, "__set_fiber_owner__", 0x61, 0x00000002U, new Func(IronRuby.Builtins.ThreadOps.SetFiberOwner) diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index 40ab1c47..7ba14a69 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -2397,9 +2397,10 @@ def clone(opts = nil) # .NET has no coroutines, so every Fiber is backed by a dedicated thread plus a # one-slot mailbox. Thread.new marks CLR threads IsBackground, so an abandoned # fiber can never keep the process alive. Exactly one fiber of a group ever -# runs: switching means "post to the target's mailbox, then block on my own", -# which is the SemaphoreSlim handshake spelled with Queue (Queue is a -# Monitor-based blocking queue here, so the wait is a real kernel wait). +# runs: switching means "post to the target's mailbox, then block on my own". +# The mailbox is a C# FiberMailbox (Src/Libraries/Builtins/ThreadOps.Fiber.cs), +# which spins for a few microseconds before it falls back to a Monitor wait, so +# a quick resume / Fiber.yield round trip does not cost two kernel wake-ups. # # resume / Fiber.yield / transfer / kill / raise / storage / scheduler follow # MRI, including the rule that a fiber which was entered by #transfer returns, @@ -2430,9 +2431,10 @@ def self.__err__(cls, msg) ::Kernel.raise(cls, msg) end + # A C# FiberMailbox (Src/Libraries/Builtins/ThreadOps.Fiber.cs): a one-slot handoff + # whose take spins briefly before it blocks. def self.__mailbox__ - require 'thread' unless defined?(::Queue) - ::Queue.new + ::Thread.__fiber_mailbox__ end # Storage keys are Symbols; anything with #to_str (but not #to_sym) is @@ -2473,23 +2475,28 @@ def self.__init_storage__(storage, parent) # ---- the root fiber of a thread -------------------------------------- + # Thread.__fiber_current__ is a thread-static copy of Thread.current[:__ir_fiber_current__] + # (a fiber never leaves its CLR thread); __fiber_current_set__ writes both. def self.current - Thread.current[:__ir_fiber_current__] || __root__ + ::Thread.__fiber_current__ || __root__ end def self.__root__ t = Thread.current - f = t[:__ir_fiber_root__] + f = t[:__ir_fiber_current__] unless f - f = allocate - f.__init_root__ - # A thread's root fiber starts with the storage of the fiber that created the - # thread; Thread.new leaves that fiber here for us. - parent = t[:__ir_fiber_parent__] - f.__inherit_storage__(parent) if parent - t[:__ir_fiber_root__] = f - t[:__ir_fiber_current__] = f + f = t[:__ir_fiber_root__] + unless f + f = allocate + f.__init_root__ + # A thread's root fiber starts with the storage of the fiber that created the + # thread; Thread.new leaves that fiber here for us. + parent = t[:__ir_fiber_parent__] + f.__inherit_storage__(parent) if parent + t[:__ir_fiber_root__] = f + end end + ::Thread.__fiber_current_set__(f) f end @@ -2552,7 +2559,7 @@ def __set_resuming__(f); @resuming = f; end def __set_status__(s); @status = s; end def __set_blocking__(b); @blocking = b; end def __set_transferred__; @transferred = true; end - def __post__(msg); @mailbox.push(msg); end + def __post__(msg); ::Thread.__fiber_post__(@mailbox, msg); end def __scheduler_get__; @scheduler; end def __scheduler_set__(s); @scheduler = s; end @@ -2759,7 +2766,7 @@ def __start__ fiber = self owner = @root_fiber.__thread__ @thread = Thread.new do - Thread.current[:__ir_fiber_current__] = fiber + ::Thread.__fiber_current_set__(fiber) # Ruby ownership (Mutex, deadlock detection) is per thread, not per fiber Thread.__set_fiber_owner__(owner) if owner fiber.__run__ @@ -2767,9 +2774,10 @@ def __start__ self end + # Takes the next message and makes this CLR thread the one that runs on behalf of the + # group's owner (Thread.__set_fiber_owner__). def __await__ - msg = @mailbox.pop - Thread.__set_fiber_owner__(@root_fiber.__thread__) + msg = ::Thread.__fiber_take__(@mailbox, @root_fiber.__thread__) @status = :resumed msg end @@ -6683,6 +6691,14 @@ def __iter_stop__ def __iter_advance__ return if @__peeked__ __iter_stop__ if @__iter_done__ + # The built-in Array#each over an Array needs no fiber: a cursor walks it by + # index the way #each would (Enumerator#__ir_array_cursor__ says when). + cursor = @__iter_cursor__ + if cursor.nil? && @__fiber__.nil? + cursor = @__iter_cursor__ = __ir_array_cursor__ + @__iter_started__ = false + end + return __iter_cursor_advance__(cursor) if cursor fiber = __iter_fiber__ fed = nil # A fed value is what the suspended yield returns, so it waits while the @@ -6712,6 +6728,27 @@ def __iter_advance__ end private :__iter_advance__ + def __iter_cursor_advance__(cursor) + values = __ir_array_cursor_next__(cursor) + # false: the iteration belongs to another thread, as a fiber would + ::Kernel.raise(::FiberError, "fiber called across threads") if values.equal?(false) + if @__iter_started__ + @__feed__ = nil + @__has_feed__ = false + end + @__iter_started__ = true + if values + @__peek__ = values + @__peeked__ = true + else + @__iter_done__ = true + @__iter_result__ = __ir_array_cursor_source__(cursor) + @__iter_cursor__ = nil + __iter_stop__ + end + end + private :__iter_cursor_advance__ + def next_values __iter_advance__ values = @__peek__ @@ -6746,6 +6783,7 @@ def feed(value) def rewind fiber = @__fiber__ @__fiber__ = nil + @__iter_cursor__ = nil @__iter_done__ = false @__iter_result__ = nil @__peeked__ = false diff --git a/Util/bench/bench.rb b/Util/bench/bench.rb index 1ba573ad..25bedbfb 100644 --- a/Util/bench/bench.rb +++ b/Util/bench/bench.rb @@ -324,6 +324,29 @@ def o.sing; 1; end while i < m; f.resume; i += 1; end end +# Enumerator#next over an Array, over a generator block, and Fiber creation. +bench("enum_next_array") do + a = Array.new(1_000) { |k| k } + e = a.each + i = 0; m = n(100_000) + while i < m + e.next + i += 1 + e.rewind if i % 1_000 == 0 + end +end + +bench("enum_next_gen") do + e = Enumerator.new { |y| k = 0; loop { y << k; k += 1 } } + i = 0; m = n(50_000) + while i < m; e.next; i += 1; end +end + +bench("fiber_new") do + i = 0; m = n(5_000) + while i < m; Fiber.new { i }.resume; i += 1; end +end + bench("proc_new_call") do i = 0; m = n(500_000) while i < m; (proc { |x| x }).call(i); i += 1; end From 8db03bcda6302f71ebb0286450d4e4bba92a6f2c Mon Sep 17 00:00:00 2001 From: Largo Date: Sun, 11 Oct 2026 21:34:42 +0200 Subject: [PATCH 05/94] Bind keyword parameters with direct runtime calls The keyword-parameter prologue the prism bridge emits read the caller's keyword hash with Ruby-level calls: per call `size`, a `key?` and a `+` per keyword to rule out unknown keywords, then `key?` and `[]` for every keyword, and `dup` plus one `delete` per keyword for **rest - about ten dynamic calls for `def kw(a: 1, b: 2)`. They are now a new AST node, KeywordParameterRead, that compiles to three static helpers: RubyOps.KeywordArgumentsMatch (one call that is true exactly when there is no missing or unknown keyword - the existing array-difference code still words the ArgumentError, only on the error path), RubyOps.GetKeywordArgument (value or the DefaultArgument sentinel, so the default expression is still evaluated lazily and in order), and RubyOps.MakeKeywordRest. MRI binds keywords without calling Hash methods either, so a redefined Hash#[] / Hash#key? no longer changes how keywords bind (it used to; now it matches MRI). RubySymbol caches its hash code. The symbol's string is frozen, but GetHashCode re-hashed it (for some content representations copying it to a System.String first) on every Hash insert and lookup with a Symbol key - 15% of the call_kwargs profile after the change above. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 --- Src/Prism/PrismAstBridge.cs | 35 ++-- Src/Ruby/Builtins/RubySymbol.cs | 8 +- .../Ast/Expressions/KeywordParameterRead.cs | 150 ++++++++++++++++++ Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs | 2 + .../Compiler/Ast/Walkers/Walker.Generated.cs | 2 + Src/Ruby/Compiler/Ast/Walkers/Walker.cs | 10 ++ .../Compiler/ReflectionCache.Generated.cs | 6 + Src/Ruby/Runtime/RubyOps.cs | 60 +++++++ 8 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index a801b889..d4e5abef 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -2368,6 +2368,7 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List var prologue = new List(); var names = new List(); var requiredNames = new List(); + var optionalNames = new List(); foreach (var keyword in node.Keywords) { var kwSpan = Span(keyword); @@ -2377,18 +2378,16 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List requiredNames.Add(required.Name); var local = CurrentScope.ResolveOrAddVariable(required.Name, kwSpan); prologue.Add(new SimpleAssignmentExpression(local, - new MethodCall(kwVar, "[]", new Arguments(new SymbolLiteral(required.Name, _encoding, kwSpan)), kwSpan), + KeywordParameterRead.Value(kwVar, required.Name, null, _encoding, kwSpan), null, kwSpan)); break; } case Pm.OptionalKeywordParameterNode optKeyword: { names.Add(optKeyword.Name); + optionalNames.Add(optKeyword.Name); var local = CurrentScope.ResolveOrAddVariable(optKeyword.Name, kwSpan); prologue.Add(new SimpleAssignmentExpression(local, - new ConditionalExpression( - new MethodCall(kwVar, "key?", new Arguments(new SymbolLiteral(optKeyword.Name, _encoding, kwSpan)), kwSpan), - new MethodCall(kwVar, "[]", new Arguments(new SymbolLiteral(optKeyword.Name, _encoding, kwSpan)), kwSpan), - Expr(optKeyword.Value), kwSpan), + KeywordParameterRead.Value(kwVar, optKeyword.Name, Expr(optKeyword.Value), _encoding, kwSpan), null, kwSpan)); break; } @@ -2401,21 +2400,17 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List var kwSpan = Span(node.KeywordRest); var restLocal = CurrentScope.ResolveOrAddVariable(keywordRest.Name ?? AnonymousKeywordRestName, kwSpan); prologue.Add(new SimpleAssignmentExpression(restLocal, - new MethodCall(kwVar, "dup", null, kwSpan), null, kwSpan)); - foreach (var name in names) { - prologue.Add(new MethodCall(restLocal, "delete", - new Arguments(new SymbolLiteral(name, _encoding, kwSpan)), kwSpan)); - } + KeywordParameterRead.Rest(kwVar, names.ToArray(), _encoding, kwSpan), null, kwSpan)); } // MRI reports every missing keyword in one error, and does so before complaining // about keywords it does not know; a signature with **rest knows them all. // // Both errors are spelled with an array difference, which allocates three arrays and - // builds a set to say "nothing is wrong" - on every call. Each one therefore sits - // behind a test that costs nothing and can only be true when there is nothing to + // builds a set to say "nothing is wrong" - on every call. So they sit behind one + // runtime call that allocates nothing and is true exactly when there is nothing to // report; the array difference runs only to word the error. - var checks = new List(); + var checks = new Statements(); if (requiredNames.Count > 0) { // Every required keyword is there, or something is missing and the list says what. Expression allPresent = null; @@ -2449,8 +2444,18 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List new Arguments(SymbolArray(names, span)), span), "unknown keyword: ", "unknown keywords: ", span)), null, span)); } - checks.AddRange(prologue); - return checks; + + var result = new List(); + if (checks.Count > 0) { + var ordered = new List(requiredNames); + ordered.AddRange(optionalNames); + result.Add(new UnlessExpression( + KeywordParameterRead.Match(kwVar, ordered.ToArray(), requiredNames.Count, + node.KeywordRest is Pm.KeywordRestParameterNode, _encoding, span), + checks, null, span)); + } + result.AddRange(prologue); + return result; } private Expression/*!*/ SymbolArray(List/*!*/ names, SourceSpan span) { diff --git a/Src/Ruby/Builtins/RubySymbol.cs b/Src/Ruby/Builtins/RubySymbol.cs index 14ca5271..34072a58 100644 --- a/Src/Ruby/Builtins/RubySymbol.cs +++ b/Src/Ruby/Builtins/RubySymbol.cs @@ -26,6 +26,11 @@ public class RubySymbol : IComparable, IComparable, IEquatable different hash? different symbols? - return _string.GetHashCode(); + return _hashCode; } public bool Equals(RubySymbol other) { diff --git a/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs b/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs new file mode 100644 index 00000000..de83cc44 --- /dev/null +++ b/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs @@ -0,0 +1,150 @@ +/* **************************************************************************** + * + * Copyright (c) Microsoft Corporation. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ + +#if FEATURE_CORE_DLR +using MSA = System.Linq.Expressions; +#else +using MSA = Microsoft.Scripting.Ast; +#endif + +using Microsoft.Scripting; +using Microsoft.Scripting.Utils; +using IronRuby.Builtins; +using AstUtils = Microsoft.Scripting.Ast.Utils; + +namespace IronRuby.Compiler.Ast { + using Ast = MSA.Expression; + + /// + /// The parts of a keyword-parameter prologue (PrismAstBridge.BindKeywordsFrom) that read the + /// hash a keyword call passed: whether it matches the declared keywords, the value of one + /// keyword parameter, and the **rest parameter. Each one is a single runtime call (see + /// ) where the prologue used to + /// make a handful of dynamic Hash calls per keyword. + /// + public partial class KeywordParameterRead : Expression { + private enum ReadKind { + Match, + Value, + Rest + } + + private readonly ReadKind _kind; + // the hidden local holding the keyword hash + private readonly Expression/*!*/ _keywords; + private readonly string/*!*/[]/*!*/ _names; + private readonly RubyEncoding/*!*/ _encoding; + private readonly int _requiredCount; + private readonly bool _acceptsRest; + private readonly Expression _defaultValue; + + public Expression/*!*/ Keywords { + get { return _keywords; } + } + + public Expression DefaultValue { + get { return _defaultValue; } + } + + private KeywordParameterRead(ReadKind kind, Expression/*!*/ keywords, string/*!*/[]/*!*/ names, RubyEncoding/*!*/ encoding, + int requiredCount, bool acceptsRest, Expression defaultValue, SourceSpan location) + : base(location) { + Assert.NotNull(keywords, names, encoding); + _kind = kind; + _keywords = keywords; + _names = names; + _encoding = encoding; + _requiredCount = requiredCount; + _acceptsRest = acceptsRest; + _defaultValue = defaultValue; + } + + /// + /// True when the hash holds the first of + /// and, unless , no other key. + /// + public static KeywordParameterRead/*!*/ Match(Expression/*!*/ keywords, string/*!*/[]/*!*/ names, int requiredCount, + bool acceptsRest, RubyEncoding/*!*/ encoding, SourceSpan location) { + return new KeywordParameterRead(ReadKind.Match, keywords, names, encoding, requiredCount, acceptsRest, null, location); + } + + /// + /// The value the call passed for keyword . If it passed none, + /// is evaluated instead, or nil if there is none. + /// + public static KeywordParameterRead/*!*/ Value(Expression/*!*/ keywords, string/*!*/ name, Expression defaultValue, + RubyEncoding/*!*/ encoding, SourceSpan location) { + return new KeywordParameterRead(ReadKind.Value, keywords, new[] { name }, encoding, 0, false, defaultValue, location); + } + + /// + /// A copy of the hash without the declared : the **rest parameter. + /// + public static KeywordParameterRead/*!*/ Rest(Expression/*!*/ keywords, string/*!*/[]/*!*/ names, + RubyEncoding/*!*/ encoding, SourceSpan location) { + return new KeywordParameterRead(ReadKind.Rest, keywords, names, encoding, 0, true, null, location); + } + + private RubySymbol/*!*/[]/*!*/ GetSymbols(AstGenerator/*!*/ gen) { + var result = new RubySymbol[_names.Length]; + for (int i = 0; i < result.Length; i++) { + result[i] = gen.Context.CreateSymbol(_names[i], _encoding); + } + return result; + } + + private MSA.Expression/*!*/ TransformMatch(AstGenerator/*!*/ gen) { + return Methods.KeywordArgumentsMatch.OpCall( + AstUtils.Box(_keywords.TransformRead(gen)), + AstUtils.Constant(GetSymbols(gen)), + AstUtils.Constant(_requiredCount), + AstUtils.Constant(_acceptsRest) + ); + } + + internal override MSA.Expression/*!*/ TransformRead(AstGenerator/*!*/ gen) { + switch (_kind) { + case ReadKind.Match: + return AstUtils.Box(TransformMatch(gen)); + + case ReadKind.Rest: + return Methods.MakeKeywordRest.OpCall(AstUtils.Box(_keywords.TransformRead(gen)), AstUtils.Constant(GetSymbols(gen))); + + default: + // value = GetKeywordArgument(kw, :name); value == DefaultArgument ? : value + var value = gen.CurrentScope.DefineHiddenVariable("#keyword", typeof(object)); + return Ast.Condition( + Ast.Equal( + Ast.Assign(value, Methods.GetKeywordArgument.OpCall( + AstUtils.Box(_keywords.TransformRead(gen)), + AstUtils.Constant(gen.Context.CreateSymbol(_names[0], _encoding)) + )), + Ast.Field(null, Fields.DefaultArgument) + ), + (_defaultValue != null) ? AstUtils.Box(_defaultValue.TransformRead(gen)) : AstUtils.Constant(null, typeof(object)), + value + ); + } + } + + internal override MSA.Expression/*!*/ TransformReadBoolean(AstGenerator/*!*/ gen, bool positive) { + if (_kind == ReadKind.Match) { + var match = TransformMatch(gen); + return positive ? match : Ast.Not(match); + } + return base.TransformReadBoolean(gen, positive); + } + } +} diff --git a/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs b/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs index 32e9398b..d58399e7 100644 --- a/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs +++ b/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs @@ -30,6 +30,7 @@ public enum NodeTypes { Parameters, SplattedArgument, KeywordArgumentsTest, + KeywordParameterRead, // definitions: ClassDefinition, @@ -119,6 +120,7 @@ public enum NodeTypes { public partial class Parameters { public override NodeTypes NodeType { get { return NodeTypes.Parameters; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class SplattedArgument { public override NodeTypes NodeType { get { return NodeTypes.SplattedArgument; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class KeywordArgumentsTest { public override NodeTypes NodeType { get { return NodeTypes.KeywordArgumentsTest; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } + public partial class KeywordParameterRead { public override NodeTypes NodeType { get { return NodeTypes.KeywordParameterRead; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class ClassDefinition { public override NodeTypes NodeType { get { return NodeTypes.ClassDefinition; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class ModuleDefinition { public override NodeTypes NodeType { get { return NodeTypes.ModuleDefinition; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } diff --git a/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs b/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs index 4eb6968a..32a26203 100644 --- a/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs +++ b/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs @@ -41,6 +41,8 @@ public virtual void Exit(Arguments/*!*/ node) { } public virtual void Exit(SplattedArgument/*!*/ node) { } public virtual bool Enter(KeywordArgumentsTest/*!*/ node) { return true; } public virtual void Exit(KeywordArgumentsTest/*!*/ node) { } + public virtual bool Enter(KeywordParameterRead/*!*/ node) { return true; } + public virtual void Exit(KeywordParameterRead/*!*/ node) { } // declarations: public virtual bool Enter(ClassDefinition/*!*/ node) { return true; } diff --git a/Src/Ruby/Compiler/Ast/Walkers/Walker.cs b/Src/Ruby/Compiler/Ast/Walkers/Walker.cs index 1ff3936e..746e6bc1 100644 --- a/Src/Ruby/Compiler/Ast/Walkers/Walker.cs +++ b/Src/Ruby/Compiler/Ast/Walkers/Walker.cs @@ -67,6 +67,16 @@ internal protected virtual void Walk(KeywordArgumentsTest/*!*/ node) { Exit(node); } + internal protected virtual void Walk(KeywordParameterRead/*!*/ node) { + if (Enter(node)) { + node.Keywords.Walk(this); + if (node.DefaultValue != null) { + node.DefaultValue.Walk(this); + } + } + Exit(node); + } + internal protected virtual void Walk(BlockReference/*!*/ node) { if (Enter(node)) { node.Expression.Walk(this); diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index 407bf2a0..fd67c93e 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -276,6 +276,8 @@ public static partial class Methods { private static MethodInfo _GetInstanceData; public static MethodInfo/*!*/ GetInstanceVariable { get { return _GetInstanceVariable ?? (_GetInstanceVariable = CallInstruction.CacheFunc(RubyOps.GetInstanceVariable)); } } private static MethodInfo _GetInstanceVariable; + public static MethodInfo/*!*/ GetKeywordArgument { get { return _GetKeywordArgument ?? (_GetKeywordArgument = CallInstruction.CacheFunc(RubyOps.GetKeywordArgument)); } } + private static MethodInfo _GetKeywordArgument; public static MethodInfo/*!*/ GetLambdaUnwinderReturnValue { get { return _GetLambdaUnwinderReturnValue ?? (_GetLambdaUnwinderReturnValue = CallInstruction.CacheFunc(RubyOps.GetLambdaUnwinderReturnValue)); } } private static MethodInfo _GetLambdaUnwinderReturnValue; public static MethodInfo/*!*/ GetLocals { get { return _GetLocals ?? (_GetLocals = CallInstruction.CacheFunc(RubyOps.GetLocals)); } } @@ -400,6 +402,8 @@ public static partial class Methods { private static MethodInfo _IsTopLevelReturn; public static MethodInfo/*!*/ IsTrue { get { return _IsTrue ?? (_IsTrue = CallInstruction.CacheFunc(RubyOps.IsTrue)); } } private static MethodInfo _IsTrue; + public static MethodInfo/*!*/ KeywordArgumentsMatch { get { return _KeywordArgumentsMatch ?? (_KeywordArgumentsMatch = CallInstruction.CacheFunc(RubyOps.KeywordArgumentsMatch)); } } + private static MethodInfo _KeywordArgumentsMatch; public static MethodInfo/*!*/ LeaveLambdaReturnScope { get { return _LeaveLambdaReturnScope ?? (_LeaveLambdaReturnScope = CallInstruction.CacheAction(RubyOps.LeaveLambdaReturnScope)); } } private static MethodInfo _LeaveLambdaReturnScope; public static MethodInfo/*!*/ LeaveLoop { get { return _LeaveLoop ?? (_LeaveLoop = CallInstruction.CacheAction(RubyOps.LeaveLoop)); } } @@ -450,6 +454,8 @@ public static partial class Methods { private static MethodInfo _MakeKeywordArgumentsHash; public static MethodInfo/*!*/ MakeKeywordArgumentsHash0 { get { return _MakeKeywordArgumentsHash0 ?? (_MakeKeywordArgumentsHash0 = CallInstruction.CacheFunc(RubyOps.MakeKeywordArgumentsHash0)); } } private static MethodInfo _MakeKeywordArgumentsHash0; + public static MethodInfo/*!*/ MakeKeywordRest { get { return _MakeKeywordRest ?? (_MakeKeywordRest = CallInstruction.CacheFunc(RubyOps.MakeKeywordRest)); } } + private static MethodInfo _MakeKeywordRest; public static MethodInfo/*!*/ MakeMissingDefaultConstructorError { get { return _MakeMissingDefaultConstructorError ?? (_MakeMissingDefaultConstructorError = CallInstruction.CacheFunc(RubyOps.MakeMissingDefaultConstructorError)); } } private static MethodInfo _MakeMissingDefaultConstructorError; public static MethodInfo/*!*/ MakeMissingMemberError { get { return _MakeMissingMemberError ?? (_MakeMissingMemberError = CallInstruction.CacheFunc(RubyOps.MakeMissingMemberError)); } } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 33e68e2d..e2f1b820 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -1698,6 +1698,66 @@ public static bool IsKeywordArgumentsHash(object obj) { return hash != null && hash.IsKeywordArguments; } + // The keyword-parameter prologue (see PrismAstBridge.BindKeywordsFrom) reads the hash a + // keyword call passed with the helpers below rather than with Ruby-level Hash calls. MRI + // binds keywords without dispatching to Hash methods either, so a redefined Hash#[] or + // Hash#key? has no say in it. The hash is the one the call site built (or a copy of a + // splatted one), so it is a plain Hash with the default comparer. + + /// + /// True when holds every required keyword - the first + /// of - and, unless the method + /// takes **rest, no keyword it does not declare. False means the prologue has an + /// ArgumentError to word, which it does the slow way. + /// + [Emitted] + public static bool KeywordArgumentsMatch(object keywords, RubySymbol/*!*/[]/*!*/ names, int requiredCount, bool acceptsRest) { + var hash = keywords as Hash; + if (hash == null) { + return false; + } + + int count = hash.Count; + if (count == 0) { + return requiredCount == 0; + } + + int found = 0; + for (int i = 0; i < names.Length; i++) { + if (hash.ContainsKey(names[i])) { + found++; + } else if (i < requiredCount) { + return false; + } + } + return acceptsRest || found == count; + } + + /// + /// The value the call passed for keyword , or + /// if it passed none. + /// + [Emitted] + public static object GetKeywordArgument(object keywords, RubySymbol/*!*/ name) { + var hash = keywords as Hash; + object value; + return (hash != null && hash.TryGetValue(name, out value)) ? value : DefaultArgument; + } + + /// + /// The **rest parameter: a new hash with whatever the call passed beyond the declared + /// . It is a copy, so the method may change it freely. + /// + [Emitted] + public static Hash/*!*/ MakeKeywordRest(object keywords, RubySymbol/*!*/[]/*!*/ names) { + var hash = (Hash)keywords; + var result = new Hash(hash); + for (int i = 0; i < names.Length && result.Count > 0; i++) { + result.Remove(names[i]); + } + return result; + } + /// /// The argument a parameter of a method that declares no keyword parameters is about to /// be given. `f(a: 1)` there is a call that passes a positional Hash - Ruby 3 has no From 3cac1835b6893de0b44e2d18aa080952b21744a0 Mon Sep 17 00:00:00 2001 From: Largo Date: Sun, 11 Oct 2026 21:36:38 +0200 Subject: [PATCH 06/94] Precompiled dispatch for bare-identifier calls; cheaper dispatcher guard and body constants WIP: gates pending. - RubyCallAction.BindPrecompiled: IsVariableCall (a bare `foo`) was missing from the flags a precompiled dispatcher accepts, so every receiver-less, argument-less call got a rule of its own (interpreted first, then compiled per site) instead of the shared dispatcher. The flag only selects the NameError a missing method raises, and a dispatcher is only made for a method that was found. - MemberDispatcher.IsCurrentFor: test for an exact RubyObject before casting to IRubyObject; the interface cast is a runtime helper call on every dispatch. - MethodDefinition.TransformBody: the declaring scope and module constants are typed as their exact class, so the per-call cast out of the constants array is an inline compare instead of two CHKCASTCLASS_SPECIAL helper calls. Co-Authored-By: Claude Opus 5.5 --- .../Ast/Definitions/MethodDefinition.cs | 13 +++- .../Calls/MethodDispatcher.Generated.cs | 72 +++++++------------ Src/Ruby/Runtime/Calls/MethodDispatcher.cs | 16 +++++ Src/Ruby/Runtime/Calls/RubyCallAction.cs | 7 +- 4 files changed, 57 insertions(+), 51 deletions(-) diff --git a/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs b/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs index e3112e38..47db5cc7 100644 --- a/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs +++ b/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs @@ -156,10 +156,21 @@ public MethodDefinition(LexicalScope/*!*/ definedScope, Expression target, strin internal MSA.LambdaExpression/*!*/ TransformBody(AstGenerator/*!*/ gen, RubyScope/*!*/ declaringScope, RubyModule/*!*/ declaringModule, StrongBox definitionRefinements) { - return TransformBody(gen, Ast.Constant(declaringScope, typeof(RubyScope)), Ast.Constant(declaringModule, typeof(RubyModule)), + return TransformBody(gen, ExactConstant(declaringScope, typeof(RubyScope)), ExactConstant(declaringModule, typeof(RubyModule)), QualifyFrameLabel(_name, declaringModule), definitionRefinements); } + /// + /// A compiled lambda keeps its object constants in an object[] and casts each one back on + /// every load. Typed as their base class the declaring scope (a top-level, module or method + /// scope) and module (a class, mostly) miss the inline exact-type check and go to the + /// runtime's class-cast helper, twice per call of the method; typed as what they are, the + /// cast is one compare, and the up-cast to the parameter type is free. + /// + private static MSA.Expression/*!*/ ExactConstant(object/*!*/ value, Type/*!*/ type) { + return AstUtils.Convert(Ast.Constant(value, value.GetType()), type); + } + /// /// The body with the declaring scope and module given as expressions rather than baked-in /// objects. Ahead-of-time compilation (Util/aot) uses it: there, both are only known when diff --git a/Src/Ruby/Runtime/Calls/MethodDispatcher.Generated.cs b/Src/Ruby/Runtime/Calls/MethodDispatcher.Generated.cs index 4980cde3..b9955637 100644 --- a/Src/Ruby/Runtime/Calls/MethodDispatcher.Generated.cs +++ b/Src/Ruby/Runtime/Calls/MethodDispatcher.Generated.cs @@ -171,8 +171,7 @@ public class RubyObjectMethodDispatcher/*$GenericDecl*/ : MethodDispatcher>)callSite). @@ -189,8 +188,7 @@ public class RubyObjectMethodDispatcherWithScope/*$GenericDecl*/ : MethodDispatc } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self /*$Parameters*/) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { return Method(self, null /*$Arguments*/); } else { return ((CallSite>)callSite). @@ -207,8 +205,7 @@ public class RubyObjectMethodDispatcherWithBlock/*$GenericDecl*/ : MethodDispatc } public object Invoke(CallSite/*!*/ callSite, object self, TProc proc /*$Parameters*/) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc /*$Arguments*/); } else { @@ -227,8 +224,7 @@ public class RubyObjectMethodDispatcherWithScopeAndBlock/*$GenericDecl*/ : Metho } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self, TProc proc /*$Parameters*/) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc /*$Arguments*/); } else { @@ -246,8 +242,7 @@ public class RubyObjectMethodDispatcher : MethodDispatcher>)callSite). @@ -264,8 +259,7 @@ public class RubyObjectMethodDispatcherWithScope : MethodDispatcher(CallSite/*!*/ callSite, TScope/*!*/ scope, object self ,T0 arg0) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { return Method(self, null ,arg0); } else { return ((CallSite>)callSite). @@ -282,8 +276,7 @@ public class RubyObjectMethodDispatcherWithBlock : MethodDispatcher(CallSite/*!*/ callSite, object self, TProc proc ,T0 arg0) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0); } else { @@ -302,8 +295,7 @@ public class RubyObjectMethodDispatcherWithScopeAndBlock : MethodDispatcher< } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self, TProc proc ,T0 arg0) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0); } else { @@ -318,8 +310,7 @@ public class RubyObjectMethodDispatcher : MethodDispatcher>)callSite). @@ -336,8 +327,7 @@ public class RubyObjectMethodDispatcherWithScope : MethodDispatcher(CallSite/*!*/ callSite, TScope/*!*/ scope, object self ,T0 arg0, T1 arg1) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { return Method(self, null ,arg0, arg1); } else { return ((CallSite>)callSite). @@ -354,8 +344,7 @@ public class RubyObjectMethodDispatcherWithBlock : MethodDispatcher(CallSite/*!*/ callSite, object self, TProc proc ,T0 arg0, T1 arg1) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0, arg1); } else { @@ -374,8 +363,7 @@ public class RubyObjectMethodDispatcherWithScopeAndBlock : MethodDispatc } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self, TProc proc ,T0 arg0, T1 arg1) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0, arg1); } else { @@ -390,8 +378,7 @@ public class RubyObjectMethodDispatcher : MethodDispatcher>)callSite). @@ -408,8 +395,7 @@ public class RubyObjectMethodDispatcherWithScope : MethodDispatcher< } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self ,T0 arg0, T1 arg1, T2 arg2) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { return Method(self, null ,arg0, arg1, arg2); } else { return ((CallSite>)callSite). @@ -426,8 +412,7 @@ public class RubyObjectMethodDispatcherWithBlock : MethodDispatcher< } public object Invoke(CallSite/*!*/ callSite, object self, TProc proc ,T0 arg0, T1 arg1, T2 arg2) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0, arg1, arg2); } else { @@ -446,8 +431,7 @@ public class RubyObjectMethodDispatcherWithScopeAndBlock : MethodDis } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self, TProc proc ,T0 arg0, T1 arg1, T2 arg2) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0, arg1, arg2); } else { @@ -462,8 +446,7 @@ public class RubyObjectMethodDispatcher : MethodDispatcher>)callSite). @@ -480,8 +463,7 @@ public class RubyObjectMethodDispatcherWithScope : MethodDispatc } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self ,T0 arg0, T1 arg1, T2 arg2, T3 arg3) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { return Method(self, null ,arg0, arg1, arg2, arg3); } else { return ((CallSite>)callSite). @@ -498,8 +480,7 @@ public class RubyObjectMethodDispatcherWithBlock : MethodDispatc } public object Invoke(CallSite/*!*/ callSite, object self, TProc proc ,T0 arg0, T1 arg1, T2 arg2, T3 arg3) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0, arg1, arg2, arg3); } else { @@ -518,8 +499,7 @@ public class RubyObjectMethodDispatcherWithScopeAndBlock : Metho } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self, TProc proc ,T0 arg0, T1 arg1, T2 arg2, T3 arg3) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0, arg1, arg2, arg3); } else { @@ -534,8 +514,7 @@ public class RubyObjectMethodDispatcher : MethodDispatcher>)callSite). @@ -552,8 +531,7 @@ public class RubyObjectMethodDispatcherWithScope : MethodDis } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self ,T0 arg0, T1 arg1, T2 arg2, T3 arg3, T4 arg4) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { return Method(self, null ,arg0, arg1, arg2, arg3, arg4); } else { return ((CallSite>)callSite). @@ -570,8 +548,7 @@ public class RubyObjectMethodDispatcherWithBlock : MethodDis } public object Invoke(CallSite/*!*/ callSite, object self, TProc proc ,T0 arg0, T1 arg1, T2 arg2, T3 arg3, T4 arg4) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0, arg1, arg2, arg3, arg4); } else { @@ -590,8 +567,7 @@ public class RubyObjectMethodDispatcherWithScopeAndBlock : M } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self, TProc proc ,T0 arg0, T1 arg1, T2 arg2, T3 arg3, T4 arg4) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { + if (IsCurrentFor(self)) { // Dispatching to a Ruby method - the method scope sets the proc's converter to itself, hence we don't need to do it here: return Method(self, (Proc)(object)proc ,arg0, arg1, arg2, arg3, arg4); } else { diff --git a/Src/Ruby/Runtime/Calls/MethodDispatcher.cs b/Src/Ruby/Runtime/Calls/MethodDispatcher.cs index 3074200b..9d31844f 100644 --- a/Src/Ruby/Runtime/Calls/MethodDispatcher.cs +++ b/Src/Ruby/Runtime/Calls/MethodDispatcher.cs @@ -41,6 +41,22 @@ public abstract partial class MemberDispatcher { public abstract object/*!*/ CreateDelegate(bool isUntyped); + /// + /// The guard a precompiled dispatcher runs on every call: the receiver is a Ruby object + /// whose class has not had a method table change since the dispatcher was made. + /// RubyObject is the class of nearly every such receiver, and an exact type test is two + /// instructions inline, where the IRubyObject cast it saves is a call to the runtime's + /// interface-cast helper. Any other IRubyObject goes the old way. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool IsCurrentFor(object self) { + if (self != null && self.GetType() == typeof(RubyObject)) { + return Unsafe.As(self).ImmediateClass.Version.Method == Version; + } + IRubyObject obj = self as IRubyObject; + return obj != null && obj.ImmediateClass.Version.Method == Version; + } + internal static object CreateDispatcher(Type/*!*/ func, int mandatoryParamCount, bool hasScope, bool hasBlock, int version, Func parameterlessFactory, Type[] genericFactories) { Type[] funcArgs = func.GetGenericArguments(); diff --git a/Src/Ruby/Runtime/Calls/RubyCallAction.cs b/Src/Ruby/Runtime/Calls/RubyCallAction.cs index 07afd8c5..4b529616 100644 --- a/Src/Ruby/Runtime/Calls/RubyCallAction.cs +++ b/Src/Ruby/Runtime/Calls/RubyCallAction.cs @@ -108,9 +108,12 @@ internal protected RubyCallAction(RubyContext context, string/*!*/ methodName, R #region Precompiled Rules protected override object BindPrecompiled(Type/*!*/ delegateType, object[]/*!*/ args) { - if (Context == null || + // IsVariableCall only decides which error a call that finds no method raises; a dispatcher + // is only made for a method that was found, and hands every other case back to the + // binder. Leaving it out of the mask sent every bare `foo` to a rule of its own. + if (Context == null || Signature.ResolveOnly || - (Signature.Flags & ~(RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasScope | RubyCallFlags.HasBlock | RubyCallFlags.HasRhsArgument | RubyCallFlags.HasSourceBlock)) != 0) { + (Signature.Flags & ~(RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasScope | RubyCallFlags.HasBlock | RubyCallFlags.HasRhsArgument | RubyCallFlags.HasSourceBlock | RubyCallFlags.IsVariableCall)) != 0) { return null; } From 5785ffe8d85ba8bbd9ae8dff952c832e01511aac Mon Sep 17 00:00:00 2001 From: Largo Date: Sun, 11 Oct 2026 21:38:03 +0200 Subject: [PATCH 07/94] Cache the GlobalVariable object per global variable reference A read or write of a global ($x) looked the name up in the context's dictionary under GlobalVariablesLock on every access: in the gvar_get loop RubyContext.TryGetGlobalVariable was 83% of the time. Each `$x' / `$x = v' in compiled code now gets a GlobalVariableSite that remembers the GlobalVariable object the name denotes, stamped with a version of the name table. The version moves only when a name starts or stops denoting an object (define, alias, delete, the first assignment of a new global), never on a plain assignment, and comes from one process-wide counter so a stamp can never match another context's table. An ordinary global (GlobalVariableInfo) is then one version check and a field read or write; special, read-only and library-defined variables still run their GetValue/SetValue under the lock, and an assignment of anything but an ordinary global takes the old general path (validation, creation). trace_var handlers fire as before, after the assignment. The verbose "not initialized" warning is unchanged. Code saved to disk keeps the old uncached calls. Release, Util/bench, best of 5 interleaved (box heavily loaded, base spread 0.79-1.93 s): gvar_get 0.786 s -> 0.328 s gvar_set 0.941 s -> 0.601 s Co-Authored-By: Claude Opus 5.5 --- .../Compiler/Ast/LeftValues/GlobalVariable.cs | 14 +++- .../Compiler/ReflectionCache.Generated.cs | 6 ++ .../Runtime/Globals/GlobalVariableInfo.cs | 9 +++ .../Runtime/Globals/GlobalVariableSite.cs | 73 +++++++++++++++++++ Src/Ruby/Runtime/RubyContext.cs | 51 +++++++++++++ Src/Ruby/Runtime/RubyOps.cs | 51 +++++++++++++ 6 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 Src/Ruby/Runtime/Globals/GlobalVariableSite.cs diff --git a/Src/Ruby/Compiler/Ast/LeftValues/GlobalVariable.cs b/Src/Ruby/Compiler/Ast/LeftValues/GlobalVariable.cs index 6214f75d..3ec2bf88 100644 --- a/Src/Ruby/Compiler/Ast/LeftValues/GlobalVariable.cs +++ b/Src/Ruby/Compiler/Ast/LeftValues/GlobalVariable.cs @@ -38,11 +38,21 @@ public GlobalVariable(string/*!*/ name, SourceSpan location) } internal override MSA.Expression/*!*/ TransformReadVariable(AstGenerator/*!*/ gen, bool tryRead) { - return (tryRead ? Methods.GetGlobalVariable : Methods.ReadGlobalVariable).OpCall(gen.CurrentScopeVariable, TransformName(gen)); + if (gen.SavingToDisk) { + return (tryRead ? Methods.GetGlobalVariable : Methods.ReadGlobalVariable).OpCall(gen.CurrentScopeVariable, TransformName(gen)); + } + return (tryRead ? Methods.GetCachedGlobalVariable : Methods.ReadCachedGlobalVariable).OpCall( + gen.CurrentScopeVariable, AstUtils.Constant(new IronRuby.Runtime.GlobalVariableSite(Name)) + ); } internal override MSA.Expression/*!*/ TransformWriteVariable(AstGenerator/*!*/ gen, MSA.Expression/*!*/ rightValue) { - return Methods.SetGlobalVariable.OpCall(AstUtils.Box(rightValue), gen.CurrentScopeVariable, TransformName(gen)); + if (gen.SavingToDisk) { + return Methods.SetGlobalVariable.OpCall(AstUtils.Box(rightValue), gen.CurrentScopeVariable, TransformName(gen)); + } + return Methods.SetCachedGlobalVariable.OpCall( + AstUtils.Box(rightValue), gen.CurrentScopeVariable, AstUtils.Constant(new IronRuby.Runtime.GlobalVariableSite(Name)) + ); } internal override MSA.Expression TransformDefinedCondition(AstGenerator/*!*/ gen) { diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index 407bf2a0..61cd1d85 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -270,6 +270,8 @@ public static partial class Methods { private static MethodInfo _GetGlobalMissingConstant; public static MethodInfo/*!*/ GetGlobalScopeFromScope { get { return _GetGlobalScopeFromScope ?? (_GetGlobalScopeFromScope = CallInstruction.CacheFunc(RubyOps.GetGlobalScopeFromScope)); } } private static MethodInfo _GetGlobalScopeFromScope; + public static MethodInfo/*!*/ GetCachedGlobalVariable { get { return _GetCachedGlobalVariable ?? (_GetCachedGlobalVariable = CallInstruction.CacheFunc(RubyOps.GetCachedGlobalVariable)); } } + private static MethodInfo _GetCachedGlobalVariable; public static MethodInfo/*!*/ GetGlobalVariable { get { return _GetGlobalVariable ?? (_GetGlobalVariable = CallInstruction.CacheFunc(RubyOps.GetGlobalVariable)); } } private static MethodInfo _GetGlobalVariable; public static MethodInfo/*!*/ GetInstanceData { get { return _GetInstanceData ?? (_GetInstanceData = GetMethod(typeof(RubyOps), "GetInstanceData")); } } @@ -514,6 +516,8 @@ public static partial class Methods { private static MethodInfo _PrintInteractiveResult; public static MethodInfo/*!*/ PropagateRetrySingleton { get { return _PropagateRetrySingleton ?? (_PropagateRetrySingleton = CallInstruction.CacheFunc(RubyOps.PropagateRetrySingleton)); } } private static MethodInfo _PropagateRetrySingleton; + public static MethodInfo/*!*/ ReadCachedGlobalVariable { get { return _ReadCachedGlobalVariable ?? (_ReadCachedGlobalVariable = CallInstruction.CacheFunc(RubyOps.ReadCachedGlobalVariable)); } } + private static MethodInfo _ReadCachedGlobalVariable; public static MethodInfo/*!*/ ReadGlobalVariable { get { return _ReadGlobalVariable ?? (_ReadGlobalVariable = CallInstruction.CacheFunc(RubyOps.ReadGlobalVariable)); } } private static MethodInfo _ReadGlobalVariable; public static MethodInfo/*!*/ RegisterShutdownHandler { get { return _RegisterShutdownHandler ?? (_RegisterShutdownHandler = CallInstruction.CacheAction(RubyOps.RegisterShutdownHandler)); } } @@ -532,6 +536,8 @@ public static partial class Methods { private static MethodInfo _SetDataConstant; public static MethodInfo/*!*/ SetGlobalConstant { get { return _SetGlobalConstant ?? (_SetGlobalConstant = CallInstruction.CacheFunc(RubyOps.SetGlobalConstant)); } } private static MethodInfo _SetGlobalConstant; + public static MethodInfo/*!*/ SetCachedGlobalVariable { get { return _SetCachedGlobalVariable ?? (_SetCachedGlobalVariable = CallInstruction.CacheFunc(RubyOps.SetCachedGlobalVariable)); } } + private static MethodInfo _SetCachedGlobalVariable; public static MethodInfo/*!*/ SetGlobalVariable { get { return _SetGlobalVariable ?? (_SetGlobalVariable = CallInstruction.CacheFunc(RubyOps.SetGlobalVariable)); } } private static MethodInfo _SetGlobalVariable; public static MethodInfo/*!*/ SetInstanceVariable { get { return _SetInstanceVariable ?? (_SetInstanceVariable = CallInstruction.CacheFunc(RubyOps.SetInstanceVariable)); } } diff --git a/Src/Ruby/Runtime/Globals/GlobalVariableInfo.cs b/Src/Ruby/Runtime/Globals/GlobalVariableInfo.cs index bdcf4978..a6f5e29d 100644 --- a/Src/Ruby/Runtime/Globals/GlobalVariableInfo.cs +++ b/Src/Ruby/Runtime/Globals/GlobalVariableInfo.cs @@ -40,6 +40,15 @@ public override object GetValue(RubyContext/*!*/ context, RubyScope scope) { } public override void SetValue(RubyContext/*!*/ context, RubyScope scope, string/*!*/ name, object value) { + Assign(value); + } + + // What a GlobalVariableSite reads and writes directly: an ordinary global is a slot. + internal object Value { + get { return _value; } + } + + internal void Assign(object value) { _value = value; _isDefined = true; } diff --git a/Src/Ruby/Runtime/Globals/GlobalVariableSite.cs b/Src/Ruby/Runtime/Globals/GlobalVariableSite.cs new file mode 100644 index 00000000..83fededa --- /dev/null +++ b/Src/Ruby/Runtime/Globals/GlobalVariableSite.cs @@ -0,0 +1,73 @@ +/* **************************************************************************** + * + * Copyright (c) Microsoft Corporation. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ + +using System; +using System.Threading; + +namespace IronRuby.Runtime { + /// + /// One per global variable reference in compiled code (`$x', `$x = v'). Remembers the + /// GlobalVariable object the name denoted, stamped with the version of the context's name + /// table it was looked up at, so a read or write of the global needs neither the dictionary + /// nor the GlobalVariablesLock while no global has been defined, aliased or removed since. + /// + [CLSCompliant(false)] + public sealed class GlobalVariableSite { + private sealed class Binding { + internal readonly int Version; + // null: the name denotes no global at this version + internal readonly GlobalVariable Variable; + + internal Binding(int version, GlobalVariable variable) { + Version = version; + Variable = variable; + } + } + + internal readonly string/*!*/ Name; + + // Version and variable travel together in one immutable object, so a reader never pairs + // a version with the variable of another one. + private volatile Binding _binding; + + public GlobalVariableSite(string/*!*/ name) { + Name = name; + } + + /// The variable the name denotes in the context, or null if it denotes none. + internal GlobalVariable GetVariable(RubyContext/*!*/ context) { + Binding binding = _binding; + if (binding != null && binding.Version == context.GlobalVariablesVersion) { + return binding.Variable; + } + + GlobalVariable variable; + int version; + context.TryGetGlobalVariable(Name, out variable, out version); + _binding = new Binding(version, variable); + return variable; + } + + /// + /// Versions come from one process-wide counter, so a version number belongs to exactly + /// one context and a site can never take another context's binding for its own. + /// + private static int _lastVersion; + + internal static int NextVersion() { + return Interlocked.Increment(ref _lastVersion); + } + } +} diff --git a/Src/Ruby/Runtime/RubyContext.cs b/Src/Ruby/Runtime/RubyContext.cs index 2a324d82..c999f495 100644 --- a/Src/Ruby/Runtime/RubyContext.cs +++ b/Src/Ruby/Runtime/RubyContext.cs @@ -161,6 +161,19 @@ public void SetNativeProcessTitle(MutableString/*!*/ title) { private readonly Dictionary/*!*/ _globalVariables; public object/*!*/ GlobalVariablesLock { get { return _globalVariables; } } + /// + /// Changes whenever a name starts or stops denoting a GlobalVariable object - a define, + /// an alias, a delete, the first assignment of a new global - but not when a value is + /// assigned. A GlobalVariableSite keeps the object it found until this moves. + /// Written under GlobalVariablesLock (or before any Ruby code runs). + /// + private volatile int _globalVariablesVersion = GlobalVariableSite.NextVersion(); + internal int GlobalVariablesVersion { get { return _globalVariablesVersion; } } + + private void GlobalVariablesChanged() { + _globalVariablesVersion = GlobalVariableSite.NextVersion(); + } + // not thread safe: use GlobalVariablesLock to synchronize access to the variables: public IEnumerable>/*!*/ GlobalVariables { get { return _globalVariables; } @@ -811,6 +824,7 @@ private void InitializeGlobalConstants() { } else { _globalVariables["-i"] = new GlobalVariableInfo(null); } + GlobalVariablesChanged(); // Hash // SCRIPT_LINES__ @@ -2499,12 +2513,14 @@ public object GetGlobalVariable(string/*!*/ name) { public void DefineGlobalVariable(string/*!*/ name, object value) { lock (GlobalVariablesLock) { _globalVariables[name] = new GlobalVariableInfo(value); + GlobalVariablesChanged(); } } public void DefineReadOnlyGlobalVariable(string/*!*/ name, object value) { lock (GlobalVariablesLock) { _globalVariables[name] = new ReadOnlyGlobalVariableInfo(value); + GlobalVariablesChanged(); } } @@ -2512,15 +2528,18 @@ public void DefineGlobalVariable(string/*!*/ name, GlobalVariable/*!*/ variable) ContractUtils.RequiresNotNull(variable, "variable"); lock (GlobalVariablesLock) { _globalVariables[name] = variable; + GlobalVariablesChanged(); } } internal void DefineGlobalVariableNoLock(string/*!*/ name, GlobalVariable/*!*/ variable) { _globalVariables[name] = variable; + GlobalVariablesChanged(); } public bool DeleteGlobalVariable(string/*!*/ name) { lock (GlobalVariablesLock) { + GlobalVariablesChanged(); return _globalVariables.Remove(name); } } @@ -2532,6 +2551,7 @@ public void AliasGlobalVariable(string/*!*/ newName, string/*!*/ oldName) { DefineGlobalVariableNoLock(oldName, existing = new GlobalVariableInfo(null, false)); } _globalVariables[newName] = existing; + GlobalVariablesChanged(); } } @@ -2544,6 +2564,7 @@ public void SetGlobalVariable(RubyScope scope, string/*!*/ name, object value) { global.SetValue(this, scope, name, value); } else { _globalVariables[name] = new GlobalVariableInfo(value); + GlobalVariablesChanged(); } } @@ -2575,6 +2596,36 @@ internal bool TryGetGlobalVariable(string/*!*/ name, out GlobalVariable variable } } + /// The variable the name denotes and the version of the name table it was found at. + internal bool TryGetGlobalVariable(string/*!*/ name, out GlobalVariable variable, out int version) { + lock (GlobalVariablesLock) { + version = _globalVariablesVersion; + return _globalVariables.TryGetValue(name, out variable); + } + } + + /// + /// The value of a variable a GlobalVariableSite found: what TryGetGlobalVariable would + /// answer for its name, minus the lookup. An ordinary global is a field read; anything + /// else (a special or read-only variable, or one a library defined) keeps running under + /// the lock. + /// + internal object GetGlobalVariableValue(GlobalVariable/*!*/ variable, RubyScope scope) { + lock (GlobalVariablesLock) { + return variable.GetValue(this, scope); + } + } + + /// + /// Runs the Kernel#trace_var handlers of a global that has just been assigned outside + /// SetGlobalVariable (which runs them itself). + /// + internal void GlobalVariableAssigned(RubyScope scope, string/*!*/ name, object value) { + if (_anyGlobalVariableTraced) { + FireGlobalVariableTraces(scope, name, value); + } + } + #endregion #region Global Variables: Kernel#trace_var handlers (thread-safe) diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 33e68e2d..2a365c6e 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -2023,6 +2023,57 @@ public static object ReadGlobalVariable(RubyScope/*!*/ scope, string/*!*/ name) return value; } + /// + /// GetGlobalVariable through the site's cached variable: an ordinary global is one + /// version check and a field read, with no lookup and no lock. + /// + [Emitted] + public static object GetCachedGlobalVariable(RubyScope/*!*/ scope, GlobalVariableSite/*!*/ site) { + var context = scope.RubyContext; + var variable = site.GetVariable(context); + var plain = variable as GlobalVariableInfo; + if (plain != null) { + return plain.Value; + } + return (variable != null) ? context.GetGlobalVariableValue(variable, scope) : null; + } + + /// ReadGlobalVariable through the site's cached variable. + [Emitted] + public static object ReadCachedGlobalVariable(RubyScope/*!*/ scope, GlobalVariableSite/*!*/ site) { + var context = scope.RubyContext; + var variable = site.GetVariable(context); + var plain = variable as GlobalVariableInfo; + if (plain != null) { + return plain.Value; + } + if (variable != null) { + return context.GetGlobalVariableValue(variable, scope); + } + if (context.Verbose is bool && (bool)context.Verbose) { + context.ReportWarning(String.Format("global variable '${0}' not initialized", site.Name), true); + } + return null; + } + + /// + /// SetGlobalVariable through the site's cached variable. An ordinary global is assigned + /// in place; everything else - a new global, a special or read-only one - takes the + /// general path, which validates and creates. + /// + [Emitted] + public static object SetCachedGlobalVariable(object value, RubyScope/*!*/ scope, GlobalVariableSite/*!*/ site) { + var context = scope.RubyContext; + var plain = site.GetVariable(context) as GlobalVariableInfo; + if (plain != null) { + plain.Assign(value); + context.GlobalVariableAssigned(scope, site.Name, value); + } else { + context.SetGlobalVariable(scope, site.Name, value); + } + return value; + } + [Emitted] public static bool IsDefinedGlobalVariable(RubyScope/*!*/ scope, string/*!*/ name) { GlobalVariable variable; From b334b189cd41b1f59e3dcb79986a83b877957bd7 Mon Sep 17 00:00:00 2001 From: Largo Date: Sun, 11 Oct 2026 21:39:39 +0200 Subject: [PATCH 08/94] WIP: per-site storages for send/__send__/public_send and respond_to? Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/BasicObjectOps.cs | 51 +++-- Src/Libraries/Builtins/KernelOps.cs | 128 +++++++----- Src/Libraries/Initializers.Generated.cs | 48 ++--- .../Builtins/RubyModule.MethodLookupCache.cs | 17 +- Src/Ruby/Runtime/DispatchStorages.cs | 188 ++++++++++++++++++ 5 files changed, 341 insertions(+), 91 deletions(-) create mode 100644 Src/Ruby/Runtime/DispatchStorages.cs diff --git a/Src/Libraries/Builtins/BasicObjectOps.cs b/Src/Libraries/Builtins/BasicObjectOps.cs index 2b203544..55cc316a 100644 --- a/Src/Libraries/Builtins/BasicObjectOps.cs +++ b/Src/Libraries/Builtins/BasicObjectOps.cs @@ -14,6 +14,7 @@ * ***************************************************************************/ using System; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Microsoft.Scripting.Runtime; using Microsoft.Scripting.Utils; @@ -68,70 +69,80 @@ public static object SendMessage(RubyScope/*!*/ scope, object self) { // ARGS: 0 [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName) { - return KernelOps.SendMessage(scope, self, methodName); + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName) { + return KernelOps.SendMessage(storage, scope, self, methodName); } // ARGS: 0& [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName) { - return KernelOps.SendMessage(scope, block, self, methodName); + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName) { + return KernelOps.SendMessage(storage, scope, block, self, methodName); } // ARGS: 1 [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1) { - return KernelOps.SendMessage(scope, self, methodName, arg1); + return KernelOps.SendMessage(storage, scope, self, methodName, arg1); } // ARGS: 1& [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1) { - return KernelOps.SendMessage(scope, block, self, methodName, arg1); + return KernelOps.SendMessage(storage, scope, block, self, methodName, arg1); } // ARGS: 2 [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1, object arg2) { - return KernelOps.SendMessage(scope, self, methodName, arg1, arg2); + return KernelOps.SendMessage(storage, scope, self, methodName, arg1, arg2); } // ARGS: 2& [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1, object arg2) { - return KernelOps.SendMessage(scope, block, self, methodName, arg1, arg2); + return KernelOps.SendMessage(storage, scope, block, self, methodName, arg1, arg2); } // ARGS: 3 [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1, object arg2, object arg3) { - return KernelOps.SendMessage(scope, self, methodName, arg1, arg2, arg3); + return KernelOps.SendMessage(storage, scope, self, methodName, arg1, arg2, arg3); } // ARGS: 3& [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1, object arg2, object arg3) { - return KernelOps.SendMessage(scope, block, self, methodName, arg1, arg2, arg3); + return KernelOps.SendMessage(storage, scope, block, self, methodName, arg1, arg2, arg3); } // ARGS: N [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, params object[]/*!*/ args) { - return KernelOps.SendMessage(scope, self, methodName, args); + return KernelOps.SendMessage(storage, scope, self, methodName, args); } // ARGS: N& [RubyMethod("__send__")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, params object[]/*!*/ args) { - return KernelOps.SendMessage(scope, block, self, methodName, args); + return KernelOps.SendMessage(storage, scope, block, self, methodName, args); } #endregion diff --git a/Src/Libraries/Builtins/KernelOps.cs b/Src/Libraries/Builtins/KernelOps.cs index 247c3881..2cc1b038 100644 --- a/Src/Libraries/Builtins/KernelOps.cs +++ b/Src/Libraries/Builtins/KernelOps.cs @@ -1453,22 +1453,31 @@ public static MutableString GetAutoloadedConstantPath(RubyScope/*!*/ scope, obje // This method is a binder intrinsic and the behavior of the binder needs to be adjusted appropriately if changed. [RubyMethod("respond_to?")] - public static bool RespondTo(CallSiteStorage>/*!*/ respondToMissingStorage, + public static bool RespondTo(RespondToCacheStorage/*!*/ respondToMissingStorage, RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, [Optional]bool includePrivate) { var context = scope.RubyContext; RubyClass cls = context.GetImmediateClassOf(self); // A refinement active where #respond_to? was called counts, as it does for a call. Without - // one - nearly always - the answer comes from the class's lock-free method-lookup cache. + // one - nearly always - the answer comes from the class's lock-free method-lookup cache, + // and the site keeps it while the class's method version stays the same. + int version = -1; if (scope.GetActiveRefinements().IsEmpty) { - var lookup = cls.GetMethodLookup(methodName); + bool answer; + if (respondToMissingStorage.TryGetAnswer(cls, methodName, includePrivate, out answer)) { + return answer; + } + + var lookup = cls.GetMethodLookup(methodName, out version); // Without include_private only *public* methods count; MRI has answered false for // protected ones since 2.0. The visibility is the one at the name's entry point, so // `public :m' re-exporting an inherited private m counts. if (lookup.Found && (includePrivate || lookup.Visibility == RubyMethodVisibility.Public)) { - return IsImplemented(lookup.Info); + answer = IsImplemented(lookup.Info); + respondToMissingStorage.SetAnswer(cls, version, methodName, includePrivate, answer); + return answer; } } else { var visibility = includePrivate @@ -1490,8 +1499,12 @@ public static bool RespondTo(CallSiteStorage/*!*/ stringCast, RubyScope/*!*/ scope, BlockParam block, object self, - object name, params object[]/*!*/ args) { + public static object PublicSendMessage(ConversionStorage/*!*/ stringCast, + SendSiteStorage>/*!*/ sendStorage, + RubyScope/*!*/ scope, BlockParam block, object self, object name, params object[]/*!*/ args) { string methodName = Protocols.CastToSymbol(stringCast, name); - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(1, - RubyCallFlags.HasScope | RubyCallFlags.HasSplattedArgument | RubyCallFlags.HasBlock | RubyCallFlags.IsInteropCall) + var site = GetSendSite(sendStorage, scope, methodName, new RubyCallSignature(1, + RubyCallFlags.HasScope | RubyCallFlags.HasSplattedArgument | RubyCallFlags.HasBlock | RubyCallFlags.IsInteropCall) ); return site.Target(site, scope, self, block != null ? block.Proc : null, RubyOps.MakeArrayN(args)); } // ARGS: 0 [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(0, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf) + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName) { + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(0, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf) ); return site.Target(site, scope, self); } // ARGS: 0& [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(0, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasBlock) + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName) { + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(0, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasBlock) ); return site.Target(site, scope, self, block != null ? block.Proc : null); } // ARGS: 1 [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(1, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf) + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(1, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf) ); return site.Target(site, scope, self, arg1); @@ -1602,22 +1618,24 @@ public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProt // ARGS: 1& [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(1, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasBlock) + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(1, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasBlock) ); return site.Target(site, scope, self, block != null ? block.Proc : null, arg1); } // ARGS: 2 [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1, object arg2) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(2, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf) + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(2, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf) ); return site.Target(site, scope, self, arg1, arg2); @@ -1625,22 +1643,24 @@ public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProt // ARGS: 2& [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1, object arg2) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(2, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasBlock) + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(2, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasBlock) ); return site.Target(site, scope, self, block != null ? block.Proc : null, arg1, arg2); } // ARGS: 3 [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1, object arg2, object arg3) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(3, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf) + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(3, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf) ); return site.Target(site, scope, self, arg1, arg2, arg3); @@ -1648,22 +1668,24 @@ public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProt // ARGS: 3& [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, object arg1, object arg2, object arg3) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(3, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasBlock) + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(3, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasBlock) ); return site.Target(site, scope, self, block != null ? block.Proc : null, arg1, arg2, arg3); } // ARGS: N [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, params object[]/*!*/ args) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(1, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasSplattedArgument) + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(1, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasSplattedArgument) ); return site.Target(site, scope, self, RubyOps.MakeArrayN(args)); @@ -1671,23 +1693,39 @@ public static object SendMessage(RubyScope/*!*/ scope, object self, [DefaultProt // ARGS: N& [RubyMethod("send")] - public static object SendMessage(RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, + public static object SendMessage(SendSiteStorage>/*!*/ storage, + RubyScope/*!*/ scope, BlockParam block, object self, [DefaultProtocol, NotNull]string/*!*/ methodName, params object[]/*!*/ args) { - var site = scope.RubyContext.GetOrCreateSendSite>( - methodName, new RubyCallSignature(1, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasSplattedArgument | + var site = GetSendSite(storage, scope, methodName, + new RubyCallSignature(1, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf | RubyCallFlags.HasSplattedArgument | RubyCallFlags.HasBlock) ); return site.Target(site, scope, self, block != null ? block.Proc : null, RubyOps.MakeArrayN(args)); } + // The site a send dispatches through: the send site's own (its storage), or - for a send made + // from library code, which has no site of its own - the context's shared one. + private static CallSite/*!*/ GetSendSite(SendSiteStorage storage, RubyScope/*!*/ scope, + string/*!*/ methodName, RubyCallSignature signature) where TSiteFunc : class { + + return (storage != null) + ? storage.GetCallSite(methodName, signature) + : scope.RubyContext.GetOrCreateSendSite(methodName, signature); + } + internal static object SendMessageOpt(RubyScope/*!*/ scope, BlockParam block, object self, string/*!*/ methodName, object[] args) { switch ((args != null ? args.Length : 0)) { - case 0: return SendMessage(scope, block, self, methodName); - case 1: return SendMessage(scope, block, self, methodName, args[0]); - case 2: return SendMessage(scope, block, self, methodName, args[0], args[1]); - case 3: return SendMessage(scope, block, self, methodName, args[0], args[1], args[2]); - default: return SendMessage(scope, block, self, methodName, args); + case 0: return SendMessage((SendSiteStorage>)null, + scope, block, self, methodName); + case 1: return SendMessage((SendSiteStorage>)null, + scope, block, self, methodName, args[0]); + case 2: return SendMessage((SendSiteStorage>)null, + scope, block, self, methodName, args[0], args[1]); + case 3: return SendMessage((SendSiteStorage>)null, + scope, block, self, methodName, args[0], args[1], args[2]); + default: return SendMessage((SendSiteStorage>)null, + scope, block, self, methodName, args); } } diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index 9013034c..27b846a6 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -569,18 +569,18 @@ private static void LoadBasicObject_Instance(IronRuby.Builtins.RubyModule/*!*/ m ); DefineLibraryMethod(module, "__send__", 0x51, - new[] { 0x00000000U, 0x00020004U, 0x00040008U, 0x00020004U, 0x00040008U, 0x00020004U, 0x00040008U, 0x00020004U, 0x00040008U, 0x80020004U, 0x80040008U}, + new[] { 0x00000000U, 0x00040008U, 0x00080010U, 0x00040008U, 0x00080010U, 0x00040008U, 0x00080010U, 0x00040008U, 0x00080010U, 0x80040008U, 0x80080010U}, new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage), - new Func(IronRuby.Builtins.BasicObjectOps.SendMessage) + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object, System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object, System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object, System.Object, System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object, System.Object, System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object[], System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object[], System.Object>(IronRuby.Builtins.BasicObjectOps.SendMessage) ); DefineLibraryMethod(module, "!", 0x51, @@ -4949,7 +4949,7 @@ private static void LoadKernel_Instance(IronRuby.Builtins.RubyModule/*!*/ module DefineLibraryMethod(module, "public_send", 0x51, 0x00000000U, 0x80000000U, new Func(IronRuby.Builtins.KernelOps.PublicSendMessage), - new Func, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.Object, System.Object[], System.Object>(IronRuby.Builtins.KernelOps.PublicSendMessage) + new Func, IronRuby.Runtime.SendSiteStorage>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.Object, System.Object[], System.Object>(IronRuby.Builtins.KernelOps.PublicSendMessage) ); DefineLibraryMethod(module, "putc", 0x52, @@ -5000,7 +5000,7 @@ private static void LoadKernel_Instance(IronRuby.Builtins.RubyModule/*!*/ module DefineLibraryMethod(module, "respond_to?", 0x51, 0x00040008U, - new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Boolean, System.Boolean>(IronRuby.Builtins.KernelOps.RespondTo) + new Func(IronRuby.Builtins.KernelOps.RespondTo) ); DefineLibraryMethod(module, "select", 0x52, @@ -5009,18 +5009,18 @@ private static void LoadKernel_Instance(IronRuby.Builtins.RubyModule/*!*/ module ); DefineLibraryMethod(module, "send", 0x51, - new[] { 0x00000000U, 0x00020004U, 0x00040008U, 0x00020004U, 0x00040008U, 0x00020004U, 0x00040008U, 0x00020004U, 0x00040008U, 0x80020004U, 0x80040008U}, + new[] { 0x00000000U, 0x00040008U, 0x00080010U, 0x00040008U, 0x00080010U, 0x00040008U, 0x00080010U, 0x00040008U, 0x00080010U, 0x80040008U, 0x80080010U}, new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage), - new Func(IronRuby.Builtins.KernelOps.SendMessage) + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object>(IronRuby.Builtins.KernelOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object>(IronRuby.Builtins.KernelOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object, System.Object>(IronRuby.Builtins.KernelOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object, System.Object>(IronRuby.Builtins.KernelOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, System.Object, System.String, System.Object[], System.Object>(IronRuby.Builtins.KernelOps.SendMessage), + new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.String, System.Object[], System.Object>(IronRuby.Builtins.KernelOps.SendMessage) ); DefineLibraryMethod(module, "set_trace_func", 0x52, diff --git a/Src/Ruby/Builtins/RubyModule.MethodLookupCache.cs b/Src/Ruby/Builtins/RubyModule.MethodLookupCache.cs index 0fcb1a3b..81da0843 100644 --- a/Src/Ruby/Builtins/RubyModule.MethodLookupCache.cs +++ b/Src/Ruby/Builtins/RubyModule.MethodLookupCache.cs @@ -89,15 +89,27 @@ internal MethodLookupTable(int version, Dictionary/* /// All-visible lookup of , ignoring refinements. Lock-free once warm for a class. /// public CachedMethodLookup/*!*/ GetMethodLookup(string/*!*/ name) { + int version; + return GetMethodLookup(name, out version); + } + + /// + /// , and the method version the answer belongs to, for a caller + /// that keeps the answer itself (a call site's storage): it stays right while .Method + /// equals . -1 when the answer must not be kept. + /// + public CachedMethodLookup/*!*/ GetMethodLookup(string/*!*/ name, out int version) { var table = Volatile.Read(ref _methodLookups); CachedMethodLookup entry; if (table != null && table.Version == Version.Method && table.Entries.TryGetValue(name, out entry)) { + version = table.Version; return entry; } - return FillMethodLookup(name); + return FillMethodLookup(name, out version); } - private CachedMethodLookup/*!*/ FillMethodLookup(string/*!*/ name) { + private CachedMethodLookup/*!*/ FillMethodLookup(string/*!*/ name, out int publishedVersion) { + publishedVersion = -1; using (Context.ClassHierarchyLocker()) { int version = Version.Method; var entry = ResolveMethodLookupNoLock(name); @@ -125,6 +137,7 @@ internal MethodLookupTable(int version, Dictionary/* entries[name] = entry; Volatile.Write(ref _methodLookups, new MethodLookupTable(version, entries)); + publishedVersion = version; return entry; } } diff --git a/Src/Ruby/Runtime/DispatchStorages.cs b/Src/Ruby/Runtime/DispatchStorages.cs new file mode 100644 index 00000000..91c60e3e --- /dev/null +++ b/Src/Ruby/Runtime/DispatchStorages.cs @@ -0,0 +1,188 @@ +/* **************************************************************************** + * + * Copyright (c) Microsoft Corporation. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ + +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using IronRuby.Builtins; +using IronRuby.Compiler.Generation; +using IronRuby.Runtime.Calls; + +namespace IronRuby.Runtime { + + // Storages of the dynamic-dispatch helpers (#send, #__send__, #public_send, #respond_to?). + // + // A library method's storage is created for the rule that binds the call, so it is a per-site + // cache - shared only with the other sites of the same binder that the binder's rule cache + // hands the same rule (sites of the same arity that saw a receiver of the same class). That is + // why each of these remembers a few names rather than one. + + /// + /// The call sites #send and friends dispatch through, by method name. A send of a literal name + /// finds its site by comparing one reference instead of taking the context's lock and hashing the + /// name (), and each name gets a site of its own, as + /// monomorphic as the send site itself is. + /// + public sealed class SendSiteStorage : RubyCallSiteStorage where TSiteFunc : class { + private sealed class Entry { + internal readonly string/*!*/ Name; + internal readonly RubyCallSignature Signature; + internal readonly CallSite/*!*/ Site; + + internal Entry(string/*!*/ name, RubyCallSignature signature, CallSite/*!*/ site) { + Name = name; + Signature = signature; + Site = site; + } + } + + // Past this many names a site sends computed names; those share the context's sites. + private const int Capacity = 8; + + // Filled in order and never replaced, so a reader needs no lock. + private readonly Entry[]/*!*/ _entries = new Entry[Capacity]; + private int _count; + + [Emitted] + public SendSiteStorage(RubyContext/*!*/ context) : base(context) { } + + public CallSite/*!*/ GetCallSite(string/*!*/ methodName, RubyCallSignature signature) { + var entries = _entries; + for (int i = 0; i < entries.Length; i++) { + var entry = entries[i]; + if (entry == null) { + break; + } + if ((object)entry.Name == (object)methodName && entry.Signature.Equals(signature)) { + return entry.Site; + } + } + return GetCallSiteSlow(methodName, signature); + } + + private CallSite/*!*/ GetCallSiteSlow(string/*!*/ methodName, RubyCallSignature signature) { + // the same name in another string instance: + var entries = _entries; + for (int i = 0; i < entries.Length; i++) { + var entry = entries[i]; + if (entry == null) { + break; + } + if (entry.Name == methodName && entry.Signature.Equals(signature)) { + return entry.Site; + } + } + + lock (entries) { + int count = _count; + for (int i = 0; i < count; i++) { + var entry = entries[i]; + if (entry.Name == methodName && entry.Signature.Equals(signature)) { + return entry.Site; + } + } + + if (count < Capacity) { + var site = CallSite.Create(RubyCallAction.Make(Context, methodName, signature)); + Volatile.Write(ref entries[count], new Entry(methodName, signature, site)); + _count = count + 1; + return site; + } + } + + return Context.GetOrCreateSendSite(methodName, signature); + } + } + + /// + /// #respond_to?'s answers at one site, and the site it calls a user-defined respond_to_missing? through. + /// An answer is kept only when it came from method lookups alone, and only for as long as the + /// receiver class's method version stays where it was when they were made - the validity the + /// class's own lookup cache has (), which + /// registers its lookups for invalidation exactly as call sites do. + /// + public sealed class RespondToCacheStorage : CallSiteStorage> { + private sealed class Answer { + internal readonly RubyClass/*!*/ Class; + internal readonly int Version; + internal readonly string/*!*/ Name; + internal readonly bool IncludePrivate; + internal readonly bool Result; + + internal Answer(RubyClass/*!*/ cls, int version, string/*!*/ name, bool includePrivate, bool result) { + Class = cls; + Version = version; + Name = name; + IncludePrivate = includePrivate; + Result = result; + } + } + + private const int Capacity = 8; + + // Each slot is replaced as a whole, so a reader needs no lock. + private readonly Answer[]/*!*/ _answers = new Answer[Capacity]; + private int _next; + + [Emitted] + public RespondToCacheStorage(RubyContext/*!*/ context) : base(context) { } + + public bool TryGetAnswer(RubyClass/*!*/ cls, string/*!*/ name, bool includePrivate, out bool result) { + var answers = _answers; + for (int i = 0; i < answers.Length; i++) { + var answer = answers[i]; + if (answer == null) { + break; + } + if ((object)answer.Name == (object)name && answer.Class == cls && answer.IncludePrivate == includePrivate + && answer.Version == cls.Version.Method) { + result = answer.Result; + return true; + } + } + result = false; + return false; + } + + /// + /// Keeps an answer computed from lookups made at method version of + /// (-1: lookups that must not be kept). + /// + public void SetAnswer(RubyClass/*!*/ cls, int version, string/*!*/ name, bool includePrivate, bool result) { + if (version < 0) { + return; + } + + var answers = _answers; + var newAnswer = new Answer(cls, version, name, includePrivate, result); + + // an answer for the same question made at an older version is replaced in place: + for (int i = 0; i < answers.Length; i++) { + var answer = answers[i]; + if (answer == null) { + break; + } + if ((object)answer.Name == (object)name && answer.Class == cls && answer.IncludePrivate == includePrivate) { + Volatile.Write(ref answers[i], newAnswer); + return; + } + } + + int index = _next; + _next = (index + 1) % Capacity; + Volatile.Write(ref answers[index], newAnswer); + } + } +} From c635c4968807e53cefe730005a99abede518cc2d Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 06:37:24 +0200 Subject: [PATCH 09/94] Skip the CLR stack walk for const_set locations while ruby4.rb boots Module#const_set and #autoload record the caller's location. The cheap path reads it off the interpreter's frame chain; when every frame there belongs to an internal file it falls back to a CLR stack walk with a full backtrace (~1ms). Under --disable-gems the host loads ruby4.rb directly, so every frame on the stack is the prelude's own and every one of its const_set calls (the Errno and Encoding tables make several hundred) paid for a walk that could only come back empty: a startup profile had 14% of `ir --disable-gems -e 1` in RubyStackTraceBuilder under Module#const_set. Loader.LoadCorePrelude now marks the thread while ruby4.rb runs as the host's prelude, and TryGetCallerSourceLocation answers "no location" at once in that state - what the walk answered. With RubyGems on, gem_prelude.rb's frame is always found first, so nothing changes there. const_source_location of the prelude's constants is unchanged in both modes ([] without gems, gem_prelude.rb:9 with). ir --disable-gems -e 1 (Release, best/median of 7 interleaved, one bench-lock call; box loaded, base spread 0.63s): base 2.366 / 2.770 s -> 2.316 / 2.633 s (an earlier run: base 2.315 / 2.425 -> 2.015 / 2.158, base spread 0.89s) ir -e 1: unchanged within noise. Gates: sp-tests 0, sp-lang 2934/0/0, spec/core/module 1050/0/0 (= base), ir --disable-gems -e 1 2.366s -> 2.316s (median 2.770 -> 2.633) Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Loader.cs | 32 ++++++++++++++++++++++++++++++++ Src/Ruby/Runtime/RubyScope.cs | 5 +++-- Src/Ruby/Runtime/RubyUtils.cs | 7 +++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Src/Ruby/Runtime/Loader.cs b/Src/Ruby/Runtime/Loader.cs index 491ccac3..27571f69 100644 --- a/Src/Ruby/Runtime/Loader.cs +++ b/Src/Ruby/Runtime/Loader.cs @@ -1208,6 +1208,38 @@ private void EndAutoloads(List autoloads, string/ #endregion + // The thread running ruby4.rb as the host's core prelude (--disable-gems), while it does. The + // only Ruby code on its stack then is the prelude's own, which backtraces leave out. + private Thread _corePreludeThread; + + /// + /// True while this thread runs ruby4.rb as the core prelude, loaded by the host itself: a + /// backtrace taken now has no frame in it (they are all the prelude's, which backtraces + /// skip), so a caller that only wants the innermost location need not walk the CLR stack. + /// + internal bool IsLoadingCorePrelude { + get { return _corePreludeThread == Thread.CurrentThread; } + } + + /// + /// Requires the core prelude - gem_prelude.rb, or ruby4.rb under --disable-gems - and then + /// lists the features MRI has in $" from the start. + /// + internal void LoadCorePrelude(Scope/*!*/ globalScope, object self, string/*!*/ path) { + bool direct = path == "ruby4.rb"; + if (direct) { + _corePreludeThread = Thread.CurrentThread; + } + try { + LoadFile(globalScope, self, MutableString.Create(path, RubyEncoding.UTF8), LoadFlags.Require); + } finally { + if (direct) { + _corePreludeThread = null; + } + } + ProvideCoreFeatures(); + } + // Features the core prelude loaded: kept out of $" (MRI's own Ruby-defined core is not in // it either) but still loaded, so that requiring one again does nothing. private readonly HashSet/*!*/ _hiddenLoadedFiles = new HashSet(StringComparer.Ordinal); diff --git a/Src/Ruby/Runtime/RubyScope.cs b/Src/Ruby/Runtime/RubyScope.cs index 38d20007..9af2991c 100644 --- a/Src/Ruby/Runtime/RubyScope.cs +++ b/Src/Ruby/Runtime/RubyScope.cs @@ -1445,10 +1445,11 @@ internal RubyTopLevelScope(RubyGlobalScope/*!*/ globalScope, RubyModule scopeMod if (context.RubyOptions.RequirePaths != null) { bool first = true; foreach (var path in context.RubyOptions.RequirePaths) { - context.Loader.LoadFile(globalScope, rubyGlobalScope.MainObject, MutableString.Create(path, RubyEncoding.UTF8), LoadFlags.Require); // the first is the core prelude (gem_prelude.rb or ruby4.rb) if (first && (path == "gem_prelude.rb" || path == "ruby4.rb")) { - context.Loader.ProvideCoreFeatures(); + context.Loader.LoadCorePrelude(globalScope, rubyGlobalScope.MainObject, path); + } else { + context.Loader.LoadFile(globalScope, rubyGlobalScope.MainObject, MutableString.Create(path, RubyEncoding.UTF8), LoadFlags.Require); } first = false; } diff --git a/Src/Ruby/Runtime/RubyUtils.cs b/Src/Ruby/Runtime/RubyUtils.cs index 468a724d..42aedf78 100644 --- a/Src/Ruby/Runtime/RubyUtils.cs +++ b/Src/Ruby/Runtime/RubyUtils.cs @@ -808,6 +808,13 @@ public static bool TryGetCallerSourceLocation(RubyContext/*!*/ context, out stri sourcePath = null; sourceLine = 0; + // Nothing but the core prelude is running, and its frames are not in a backtrace: the + // walk below would come back empty, after ~1ms, for each of the prelude's const_set calls + // (Errno and Encoding make hundreds). + if (context.Loader.IsLoadingCorePrelude) { + return false; + } + RubyArray trace; try { trace = RubyExceptionData.CreateBacktrace(context, 0); From bd5ef970a345e7fe86a2cc2774e7d4e1aac064a5 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 06:43:16 +0200 Subject: [PATCH 10/94] Blocks: call the compiled block directly, skip arity work for plain blocks A block defined in interpreted code (top-level script code, the first calls of any method) is handed the interpreter's LightLambda delegate, and the block's dispatcher keeps that delegate for every later Proc of the block. Even after the lambda has compiled itself, each invocation went through LightLambda.RunN - a check and a generic cast - before reaching the compiled code. The dispatcher now subscribes to LightLambda.Compile and swaps in the compiled delegate, which is the very delegate RunN would have called. Every yield also called RequireLambdaArity, which walked the parameter signature (RefusesKeywords loops over the parameters) only to return for a plain block. The check is now an inlined test of proc kind plus a flag the dispatcher caches when its signature is set; the full check runs only for lambdas and keyword/**nil signatures, exactly when it did anything before. Smaller: a BlockParam made for a yield or a Proc#call skips the managed thread id read (it only ever runs on its creating thread, so Break's thread check always passed), and the post-yield checks return early when the block finished normally. Release, on-CPU seconds of the timed section (main thread schedstat), best of 5, base/mine alternating in one bench-lock call; box load avg 50-140: times_loop 0.668 -> 0.484 (base spread 0.668-0.737) yield_0 0.745 -> 0.667 (base spread 0.745-1.68) proc_call 0.457 -> 0.421 (base spread 0.457-0.800) Full bench.rb list, best of 3: yield_3 0.478 -> 0.397, upto_loop 0.477 -> 0.402, each_loop 0.461 -> 0.407, proc_new_call 0.186 -> 0.152; no regression outside the noise (re-ran the outliers 5x: deep_hierarchy 0.975, while_loop 0.949, fib 1.02 - none of them reach the changed code). Gates: sp-tests 0, sp-lang 2934/0/0, times_loop 0.668s -> 0.484s Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/BlockParam.cs | 21 +++++++++++++--- Src/Ruby/Runtime/Calls/BlockDispatcher.cs | 30 ++++++++++++++++++++++- Src/Ruby/Runtime/RubyOps.FlowControl.cs | 3 +++ Src/Ruby/Runtime/RubyOps.cs | 9 +++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/Src/Ruby/Runtime/BlockParam.cs b/Src/Ruby/Runtime/BlockParam.cs index 60645cd1..87153664 100644 --- a/Src/Ruby/Runtime/BlockParam.cs +++ b/Src/Ruby/Runtime/BlockParam.cs @@ -103,7 +103,8 @@ public sealed partial class BlockParam { // Friend: RubyOps internal readonly bool _isLibProcConverter; - // the thread the call taking the block runs on; a break cannot reach that call from another + // the thread the call taking the block runs on; a break cannot reach that call from another. + // 0 for a yield or a Proc#call, which run the block on the thread that made the BlockParam. private readonly int _threadId; // the block is the body of a Thread (see RubyBlockScope.IsClosureScope) @@ -165,6 +166,13 @@ internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind, bool isLibProcCo _isLibProcConverter = isLibProcConverter; } + // A BlockParam that is only ever used on the thread creating it, for the duration of a call: + // a yield or a Proc#call. There the thread check in Break always passes, so skip it. + internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind) { + _callerKind = callerKind; + _proc = proc; + } + internal void SetFlowControl(BlockReturnReason reason, RuntimeFlowControl targetFrame, ProcKind sourceProcKind) { Debug.Assert((reason == BlockReturnReason.Break) == (targetFrame != null)); @@ -189,6 +197,11 @@ internal object GetUnwinderResult(EvalUnwinder/*!*/ unwinder) { /// A library method that creates a block yet doesn't take one needs to manage RFC on its own. /// internal bool BlockJumped(object returnValue) { + // the block finished normally (no break/return/retry), which is what nearly every yield does: + if (_returnReason == BlockReturnReason.Undefined) { + return false; + } + // if this method is a proc converter then the current frame is Proc.Converter, otherwise it is not available: return RubyOps.MethodYieldRfc(_isLibProcConverter ? _proc.Converter : null, this, returnValue); } @@ -226,7 +239,7 @@ public object Break(object returnValue) { // proc whose home frame is gone (e.g. Proc#call on an orphaned block), or one running // on another thread than the call it would break out of (Thread.new { break }): - if (_proc.Converter == null || _threadId != Environment.CurrentManagedThreadId) { + if (_proc.Converter == null || (_threadId != 0 && _threadId != Environment.CurrentManagedThreadId)) { throw new LocalJumpError("break from proc-closure", "break", returnValue); } @@ -307,7 +320,7 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* [Emitted] public static BlockParam/*!*/ CreateBfcForYield(Proc proc) { if (proc != null) { - return new BlockParam(proc, BlockCallerKind.Yield, false); + return new BlockParam(proc, BlockCallerKind.Yield); } else { throw RubyExceptions.NoBlockGiven(); } @@ -316,7 +329,7 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* [Emitted] public static BlockParam/*!*/ CreateBfcForProcCall(Proc/*!*/ proc) { Assert.NotNull(proc); - return new BlockParam(proc, BlockCallerKind.Call, false) { IsActiveLambdaCall = proc.Kind == ProcKind.Lambda }; + return new BlockParam(proc, BlockCallerKind.Call) { IsActiveLambdaCall = proc.Kind == ProcKind.Lambda }; } [Emitted] diff --git a/Src/Ruby/Runtime/Calls/BlockDispatcher.cs b/Src/Ruby/Runtime/Calls/BlockDispatcher.cs index 6b2c2288..bd83bfff 100644 --- a/Src/Ruby/Runtime/Calls/BlockDispatcher.cs +++ b/Src/Ruby/Runtime/Calls/BlockDispatcher.cs @@ -23,6 +23,7 @@ using System.Collections.Generic; using Microsoft.Scripting; using Microsoft.Scripting.Actions; +using Microsoft.Scripting.Interpreter; using Microsoft.Scripting.Runtime; using Microsoft.Scripting.Utils; using System.Reflection; @@ -73,8 +74,24 @@ public override Delegate/*!*/ Method { // Note: this might potentially be executed by multiple threads. So we can assert that _block == null here. // It's ok if the delegate is overwritten multiple times since all the target methods are equivalent. _block = (T)method; + + // A block defined in interpreted code gets the interpreter's delegate, which keeps going + // through LightLambda.RunN - a check and a cast - on every call even once the lambda has + // compiled itself, and every later Proc of this block reuses that delegate. Call the + // compiled one directly as soon as there is one: it is the very delegate RunN would call. + var light = ((Delegate)method).Target as LightLambda; + if (light != null) { + light.Compile += OnLightLambdaCompiled; + } return this; } + + private void OnLightLambdaCompiled(object sender, LightLambdaCompileEventArgs/*!*/ e) { + var compiled = e.Compiled as T; + if (compiled != null) { + _block = compiled; + } + } } internal abstract class BlockDispatcherN : BlockDispatcher where T : class { @@ -176,7 +193,18 @@ public bool Ruby2Keywords { /// public RubyParameterSignature ParameterSignature { get { return _parameterSignature; } - set { _parameterSignature = value; } + set { + _parameterSignature = value; + _checksKeywords = value != null && (value.TakesKeywords || value.RefusesKeywords); + } + } + + // the signature declares keywords or **nil, so even a proc's arguments are checked on a yield + // (see RubyOps.RequireLambdaArity); cached because RefusesKeywords walks the parameter list + private bool _checksKeywords; + + internal bool ChecksKeywords { + get { return _checksKeywords; } } public bool HasUnsplatParameter { diff --git a/Src/Ruby/Runtime/RubyOps.FlowControl.cs b/Src/Ruby/Runtime/RubyOps.FlowControl.cs index bbddd257..f5b51d1d 100644 --- a/Src/Ruby/Runtime/RubyOps.FlowControl.cs +++ b/Src/Ruby/Runtime/RubyOps.FlowControl.cs @@ -377,6 +377,9 @@ public static bool BlockYield(RubyScope/*!*/ scope, BlockParam/*!*/ ownerBlockFl /// True if the method should terminate returning the result of yield. [Emitted] public static bool MethodYield(RubyScope/*!*/ scope, BlockParam/*!*/ yieldedBlockFlowControl, object returnValue) { + if (yieldedBlockFlowControl.ReturnReason == BlockReturnReason.Undefined) { + return false; + } return MethodYieldRfc(scope.FlowControlScope, yieldedBlockFlowControl, returnValue); } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 33e68e2d..89961e0e 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -513,7 +513,16 @@ public static void RequireLambdaArity(Proc/*!*/ proc, int argCount) { /// only the positional arguments against a lambda, and `|**nil|` refuses keywords in a /// proc as well as in a lambda. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void RequireLambdaArity(Proc/*!*/ proc, int argCount, object lastArg) { + // A plain block takes any arguments - the common case, so it does not make the call: + // only a lambda, or a signature with keywords or **nil, has anything to check. + if (proc.Kind == ProcKind.Lambda || proc.Dispatcher.ChecksKeywords) { + CheckLambdaArity(proc, argCount, lastArg); + } + } + + private static void CheckLambdaArity(Proc/*!*/ proc, int argCount, object lastArg) { var signature = proc.Dispatcher.ParameterSignature; if (signature != null && (signature.TakesKeywords || signature.RefusesKeywords)) { var keywords = argCount > 0 ? lastArg as Hash : null; From 14be9033aa6dfad82fa323daa199d176876af576 Mon Sep 17 00:00:00 2001 From: Largo Date: Sun, 11 Oct 2026 21:38:18 +0200 Subject: [PATCH 11/94] LightCompiler call-instruction cache hit; cheaper spans and args in the bridge LightCompiler (DLR patch 0100): CompileMethodCallExpression and EmitCall called MethodInfo.GetParameters() - which copies the parameter array - and ran CallInstruction.Create's checks for every call expression, only to find the instruction in CallInstruction's cache. A cache hit now skips both: a method is only ever cached once it is known to need none of that handling (no by-ref parameters, not a value-type instance method, not an array accessor or dynamic method). In a profile of loading a 110k-line file, GetParameters and CallInstruction.Create were 38% of LightCompiler.CompileTop. PrismAstBridge: SourceSpans were found with a binary search of the line starts for both ends of every node; nodes come in source order, so the lookup now starts at the line the last one ended on (and the end from the start's line). Line starts are found with IndexOf. BuildArguments fills an array instead of a List it then copies. Regexp literals were translated in full at parse time only to look for Onigmo's nested-quantifier warning; a pattern without two quantifier characters in a row (and no whitespace or # for extended mode) cannot draw it and skips the translation. Measured (Release, best of 3, user CPU; one bench-lock call, base and this tree alternating): loading a generated 22k-line file (400 modules/classes, 4800 defs) 6.40s -> 5.53s (base spread 6.40-6.44); `ir -e 1` 3.82s -> 3.72s (best of 5, base spread 3.82-4.08 - within noise). Gates: sp-tests 0, sp-lang 2934/0/0, spec/core method+unboundmethod+proc+regexp+binding 908/0/0 (= base), big400.rb load 6.40s -> 5.53s user Co-Authored-By: Claude Opus 5.5 --- Build/dlr-patches/0100-compile-pipeline.patch | 59 ++++++++++ Src/Prism/PrismAstBridge.cs | 105 +++++++++++++++--- 2 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 Build/dlr-patches/0100-compile-pipeline.patch diff --git a/Build/dlr-patches/0100-compile-pipeline.patch b/Build/dlr-patches/0100-compile-pipeline.patch new file mode 100644 index 00000000..9d155134 --- /dev/null +++ b/Build/dlr-patches/0100-compile-pipeline.patch @@ -0,0 +1,59 @@ +diff --git a/src/core/Microsoft.Dynamic/Interpreter/Instructions/CallInstruction.cs b/src/core/Microsoft.Dynamic/Interpreter/Instructions/CallInstruction.cs +index 3b7bbb5..a979b25 100644 +--- a/src/core/Microsoft.Dynamic/Interpreter/Instructions/CallInstruction.cs ++++ b/src/core/Microsoft.Dynamic/Interpreter/Instructions/CallInstruction.cs +@@ -25,6 +25,19 @@ namespace Microsoft.Scripting.Interpreter { + + private static readonly Dictionary _cache = new(); + ++ /// ++ /// The instruction made and cached for ++ /// before, if any. A method is cached only once it is known to need none of the special handling Create ++ /// checks for (array accessors, by-ref parameters, value-type instance methods, dynamic methods, too many ++ /// arguments), so a hit can skip GetParameters() - which copies the parameter array on every call - and ++ /// those checks altogether. ++ /// ++ internal static bool TryGetCached(MethodInfo info, out CallInstruction instruction) { ++ lock (_cache) { ++ return _cache.TryGetValue(info, out instruction); ++ } ++ } ++ + /// Instruction can't be created due to insufficient privileges. + public static CallInstruction Create(MethodInfo info) { + return Create(info, info.GetParameters()); +diff --git a/src/core/Microsoft.Dynamic/Interpreter/LightCompiler.cs b/src/core/Microsoft.Dynamic/Interpreter/LightCompiler.cs +index 457ec85..84b88f7 100644 +--- a/src/core/Microsoft.Dynamic/Interpreter/LightCompiler.cs ++++ b/src/core/Microsoft.Dynamic/Interpreter/LightCompiler.cs +@@ -1153,6 +1153,19 @@ namespace Microsoft.Scripting.Interpreter { + private void CompileMethodCallExpression(Expression expr) { + var node = (MethodCallExpression)expr; + ++ // A method compiled before has its instruction cached, which also says it takes no by-ref ++ // parameters and is not an instance method of a value type (see CallInstruction.TryGetCached). ++ if (CallInstruction.TryGetCached(node.Method, out CallInstruction cached)) { ++ if (!node.Method.IsStatic) { ++ Compile(node.Object); ++ } ++ foreach (var arg in node.Arguments) { ++ Compile(arg); ++ } ++ Instructions.Emit(cached); ++ return; ++ } ++ + var parameters = node.Method.GetParameters(); + + // TODO: +@@ -1180,6 +1193,10 @@ namespace Microsoft.Scripting.Interpreter { + } + + public void EmitCall(MethodInfo method) { ++ if (CallInstruction.TryGetCached(method, out CallInstruction cached)) { ++ Instructions.Emit(cached); ++ return; ++ } + EmitCall(method, method.GetParameters()); + } + diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index a801b889..053be813 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -22,7 +22,12 @@ public sealed class PrismAstBridge { private readonly string/*!*/ _source; private readonly string _path; private readonly int _lineOffset; - private readonly List/*!*/ _lineStarts; + // the offset each line starts at; the first _lineCount entries are used + private readonly int[]/*!*/ _lineStarts; + private readonly int _lineCount; + // the line the last location was found on: nodes come in source order, so the next + // lookup is almost always on that line or a few lines further down + private int _lineHint; // The line the first line of this source is reported as. 1 for a file; whatever // eval/module_eval/Binding#eval was told for a string, which is what makes __LINE__ @@ -52,10 +57,12 @@ private PrismAstBridge(string/*!*/ source, string path, RubyEncoding/*!*/ encodi _lineOffset = RubyUtils.DecodeEvalLineOffset(ref path); _path = path; _encoding = encoding; - _lineStarts = new List { 0 }; - for (int i = 0; i < source.Length; i++) { - if (source[i] == '\n') _lineStarts.Add(i + 1); + var lineStarts = new List { 0 }; + for (int i = source.IndexOf('\n'); i >= 0; i = source.IndexOf('\n', i + 1)) { + lineStarts.Add(i + 1); } + _lineStarts = lineStarts.ToArray(); + _lineCount = _lineStarts.Length; } public static SourceUnitTree Parse(SourceUnit/*!*/ sourceUnit, RubyCompilerOptions/*!*/ options, ErrorSink/*!*/ errorSink) { @@ -259,11 +266,19 @@ private static string DeclaredEncodingName(string/*!*/ code) { // ---- helpers ---- private SourceSpan Span(Pm.PmNode node) { - return new SourceSpan(Location(node.StartOffset), Location(node.StartOffset + node.Length)); + return Span(node.StartOffset, node.Length); } private SourceSpan Span(Pm.PmLocation location) { - return new SourceSpan(Location(location.Start), Location(location.Start + location.Length)); + return Span(location.Start, location.Length); + } + + private SourceSpan Span(int start, int length) { + int startLine = FindLine(start); + // the end is at or after the start: search on from the start's line + int end = start + length; + int endLine = FindLineFrom(end, startLine); + return new SourceSpan(MakeLocation(start, startLine), MakeLocation(end, endLine)); } private string/*!*/ IdentifierText(string/*!*/ name) { @@ -279,12 +294,47 @@ private SourceSpan Span(Pm.PmLocation location) { } private SourceLocation Location(int index) { - int line = _lineStarts.BinarySearch(index); - if (line < 0) line = ~line - 1; + return MakeLocation(index, FindLine(index)); + } + + private SourceLocation MakeLocation(int index, int line) { if (index > _source.Length) index = _source.Length; return new SourceLocation(index, line + _startLine, index - _lineStarts[line] + 1); } + /// + /// The last line that starts at or before (what a binary search of + /// the line starts finds), looked for from the line the previous lookup ended on. + /// + private int FindLine(int index) { + int hint = _lineHint; + int line = (_lineStarts[hint] <= index) ? FindLineFrom(index, hint) : FindLineFrom(index, 0); + _lineHint = line; + return line; + } + + // the same, given a line that starts at or before index + private int FindLineFrom(int index, int line) { + int[] starts = _lineStarts; + int count = _lineCount; + for (int i = 0; i < 4; i++) { + if (line + 1 >= count || starts[line + 1] > index) { + return line; + } + line++; + } + int lo = line, hi = count - 1; + while (lo < hi) { + int mid = lo + ((hi - lo + 1) >> 1); + if (starts[mid] <= index) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; + } + private Exception Unsupported(Pm.PmNode node) { var span = Span(node); return new NotSupportedException( @@ -970,6 +1020,9 @@ private void WarnRegexp(byte[]/*!*/ pattern, SourceSpan span) { return; } string text = System.Text.Encoding.UTF8.GetString(pattern); + if (!MayHaveNestedQuantifier(text)) { + return; + } var warnings = RegexpTransformer.GetWarnings(text); if (warnings != null) { foreach (var warning in warnings) { @@ -978,6 +1031,27 @@ private void WarnRegexp(byte[]/*!*/ pattern, SourceSpan span) { } } + /// + /// The only warnings RegexpTransformer reports are about a quantifier applied directly to + /// another (`a**`, `a{2}*`, `a*{0,1}`), which needs two quantifier characters in a row. Whitespace + /// or a comment may sit between them in extended mode, so a pattern with either is always checked. + /// + private static bool MayHaveNestedQuantifier(string/*!*/ pattern) { + for (int i = 0; i < pattern.Length; i++) { + char c = pattern[i]; + if (c == '#' || Char.IsWhiteSpace(c)) { + return true; + } + if ((c == '?' || c == '*' || c == '+' || c == '}') && i + 1 < pattern.Length) { + char d = pattern[i + 1]; + if (d == '?' || d == '*' || d == '+' || d == '{') { + return true; + } + } + } + return false; + } + private Expression/*!*/ BigIntegerLiteral(BigInteger value, SourceSpan span) { if (value >= int.MinValue && value <= int.MaxValue) return Literal.Integer((int)value, span); return Literal.BigInteger(value, span); @@ -1427,24 +1501,27 @@ private Block OptionalBlock(Pm.PmNode block) { /// private Arguments/*!*/ BuildArguments(Pm.PmNode argumentsNode, ref Block block) { if (argumentsNode == null) return new Arguments(); - var exprs = new List(); - foreach (var arg in ((Pm.ArgumentsNode)argumentsNode).Arguments) { + var arguments = ((Pm.ArgumentsNode)argumentsNode).Arguments; + // each argument maps to exactly one expression + var exprs = new Expression[arguments.Length]; + for (int i = 0; i < arguments.Length; i++) { + var arg = arguments[i]; if (arg is Pm.ForwardingArgumentsNode) { var rest = CurrentScope.ResolveVariable(ForwardingRestName); var fwdBlock = CurrentScope.ResolveVariable(ForwardingBlockName); if (rest == null) throw Unsupported(arg); - exprs.Add(new SplattedArgument(rest)); + exprs[i] = new SplattedArgument(rest); if (block == null && fwdBlock != null) { block = new BlockReference(fwdBlock, Span(arg)); } } else if (IsSplattedKeywordHash(arg)) { // `f(**opts)` must pass nothing when opts is empty - see KeywordArgumentSplat. - exprs.Add(new KeywordArgumentSplat(Argument(arg))); + exprs[i] = new KeywordArgumentSplat(Argument(arg)); } else { - exprs.Add(Argument(arg)); + exprs[i] = Argument(arg); } } - return new Arguments(exprs.ToArray()); + return new Arguments(exprs); } /// From 09001d8e26c83c708cf8fa23cda8b61c31d3589e Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 06:55:59 +0200 Subject: [PATCH 12/94] Look keyword arguments up by identity; build literal keyword hashes directly Binding keyword parameters no longer hashes anything: a keyword is a Symbol, equal only to itself, so Hash#HasKeywords and Hash#TryGetValueByIdentity (Hash.Keywords.cs) scan the few entries of a keyword hash for the very object - starting at the entry where the method declares the keyword, which is where callers mostly put it - and probe the table as usual only for hashes of more than 8 entries. `f(a: x)`, `f(a: x, b: y)` and `f(a: x, b: y, c: z)` with Symbol-literal keys build their keyword hash with RubyOps.MakeKeywordArgumentsHash1-3: no item array, none of SetHashElement's String/nil key conversions. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 --- Src/Prism/PrismAstBridge.cs | 4 +- Src/Ruby/Builtins/Hash.Keywords.cs | 98 +++++++++++++++++++ .../Ast/Expressions/HashConstructor.cs | 30 ++++++ .../Ast/Expressions/KeywordParameterRead.cs | 13 ++- .../Compiler/ReflectionCache.Generated.cs | 8 +- Src/Ruby/Runtime/RubyOps.cs | 59 +++++++---- 6 files changed, 183 insertions(+), 29 deletions(-) create mode 100644 Src/Ruby/Builtins/Hash.Keywords.cs diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index d4e5abef..7ac54123 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -2378,7 +2378,7 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List requiredNames.Add(required.Name); var local = CurrentScope.ResolveOrAddVariable(required.Name, kwSpan); prologue.Add(new SimpleAssignmentExpression(local, - KeywordParameterRead.Value(kwVar, required.Name, null, _encoding, kwSpan), + KeywordParameterRead.Value(kwVar, required.Name, names.Count - 1, null, _encoding, kwSpan), null, kwSpan)); break; } @@ -2387,7 +2387,7 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List optionalNames.Add(optKeyword.Name); var local = CurrentScope.ResolveOrAddVariable(optKeyword.Name, kwSpan); prologue.Add(new SimpleAssignmentExpression(local, - KeywordParameterRead.Value(kwVar, optKeyword.Name, Expr(optKeyword.Value), _encoding, kwSpan), + KeywordParameterRead.Value(kwVar, optKeyword.Name, names.Count - 1, Expr(optKeyword.Value), _encoding, kwSpan), null, kwSpan)); break; } diff --git a/Src/Ruby/Builtins/Hash.Keywords.cs b/Src/Ruby/Builtins/Hash.Keywords.cs new file mode 100644 index 00000000..f71b3747 --- /dev/null +++ b/Src/Ruby/Builtins/Hash.Keywords.cs @@ -0,0 +1,98 @@ +/* **************************************************************************** + * + * Copyright (c) Microsoft Corporation. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ + +namespace IronRuby.Builtins { + + /// + /// What binding keyword parameters needs from the hash a keyword call passed (see + /// RubyOps.KeywordArgumentsMatch). The keys looked for are Symbols, and a Symbol is equal to + /// nothing but itself, so an entry holds one exactly when its key is that very object. A + /// keyword hash is small, and scanning a handful of entries for a reference is cheaper than + /// hashing the key and probing for it; a larger hash is probed as usual. + /// + public partial class Hash { + private const int IdentityScanLimit = 8; + + /// + /// The value stored under , which must be a key that is equal only to + /// itself (a Symbol). Entry is tried first: callers mostly pass + /// keywords in the order they are declared in. + /// + internal bool TryGetValueByIdentity(object/*!*/ key, int hint, out object value) { + Entry[] entries = _entries; + if (entries != null && _liveCount <= IdentityScanLimit) { + int count = _count; + if ((uint)hint < (uint)count && entries[hint].Key == key && entries[hint].Next != TombstoneNext) { + value = entries[hint].Value; + return true; + } + for (int i = _start; i < count; i++) { + if (entries[i].Key == key && entries[i].Next != TombstoneNext) { + value = entries[i].Value; + return true; + } + } + value = null; + return false; + } + return TryGetValue(key, out value); + } + + /// + /// True when the first of are all keys + /// of this hash and, unless , it has no key but those names. + /// The names are compared by identity, see . + /// + internal bool HasKeywords(object/*!*/[]/*!*/ names, int requiredCount, bool acceptsOthers) { + int live = _liveCount; + if (live == 0) { + return requiredCount == 0; + } + + if (live <= IdentityScanLimit && names.Length <= 64) { + Entry[] entries = _entries; + int count = _count; + ulong seen = 0; + for (int i = _start; i < count; i++) { + if (entries[i].Next == TombstoneNext) { + continue; + } + object key = entries[i].Key; + int j = 0; + while (j < names.Length && names[j] != key) { + j++; + } + if (j < names.Length) { + seen |= 1UL << j; + } else if (!acceptsOthers) { + return false; + } + } + ulong required = (requiredCount >= 64) ? ulong.MaxValue : (1UL << requiredCount) - 1; + return (seen & required) == required; + } + + int found = 0; + for (int i = 0; i < names.Length; i++) { + if (ContainsKey(names[i])) { + found++; + } else if (i < requiredCount) { + return false; + } + } + return acceptsOthers || found == live; + } + } +} diff --git a/Src/Ruby/Compiler/Ast/Expressions/HashConstructor.cs b/Src/Ruby/Compiler/Ast/Expressions/HashConstructor.cs index 0c35b5ba..9d225f38 100644 --- a/Src/Ruby/Compiler/Ast/Expressions/HashConstructor.cs +++ b/Src/Ruby/Compiler/Ast/Expressions/HashConstructor.cs @@ -20,8 +20,10 @@ #endif using System.Collections.Generic; +using System.Runtime.CompilerServices; using Microsoft.Scripting; using Microsoft.Scripting.Utils; +using AstUtils = Microsoft.Scripting.Ast.Utils; namespace IronRuby.Compiler.Ast { using Ast = Expression; @@ -55,8 +57,36 @@ public HashConstructor(Maplet/*!*/[]/*!*/ maplets, bool isKeywordArguments, Sour } internal override MSA.Expression/*!*/ TransformRead(AstGenerator/*!*/ gen) { + if (_isKeywordArguments && _maplets.Length >= 1 && _maplets.Length <= 3 && HasSymbolLiteralKeys()) { + return TransformKeywordArguments(gen); + } var items = gen.TransformMapletsToExpressions(_maplets); return _isKeywordArguments ? gen.MakeKeywordArgumentsHashOpCall(items) : gen.MakeHashOpCall(items); } + + private bool HasSymbolLiteralKeys() { + foreach (var maplet in _maplets) { + if (!(maplet.Key is SymbolLiteral)) { + return false; + } + } + return true; + } + + // `f(a: x, b: y)`: the keyword hash is built by a helper of its own arity (keys and values + // still evaluated in order), see RubyOps.MakeKeywordArgumentsHash2. + private MSA.Expression/*!*/ TransformKeywordArguments(AstGenerator/*!*/ gen) { + var args = new ReadOnlyCollectionBuilder(1 + 2 * _maplets.Length); + args.Add(gen.CurrentScopeVariable); + foreach (var maplet in _maplets) { + args.Add(AstUtils.Box(maplet.Key.TransformRead(gen))); + args.Add(AstUtils.Box(maplet.Value.TransformRead(gen))); + } + switch (_maplets.Length) { + case 1: return Methods.MakeKeywordArgumentsHash1.OpCall(args); + case 2: return Methods.MakeKeywordArgumentsHash2.OpCall(args); + default: return Methods.MakeKeywordArgumentsHash3.OpCall(args); + } + } } } diff --git a/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs b/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs index de83cc44..6e1b6ac6 100644 --- a/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs +++ b/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs @@ -46,6 +46,7 @@ private enum ReadKind { private readonly Expression/*!*/ _keywords; private readonly string/*!*/[]/*!*/ _names; private readonly RubyEncoding/*!*/ _encoding; + // Match: how many of the names are required; Value: where the keyword is declared private readonly int _requiredCount; private readonly bool _acceptsRest; private readonly Expression _defaultValue; @@ -81,12 +82,13 @@ private KeywordParameterRead(ReadKind kind, Expression/*!*/ keywords, string/*!* } /// - /// The value the call passed for keyword . If it passed none, - /// is evaluated instead, or nil if there is none. + /// The value the call passed for keyword , the method's keyword number + /// . If it passed none, is evaluated + /// instead, or nil if there is none. /// - public static KeywordParameterRead/*!*/ Value(Expression/*!*/ keywords, string/*!*/ name, Expression defaultValue, + public static KeywordParameterRead/*!*/ Value(Expression/*!*/ keywords, string/*!*/ name, int position, Expression defaultValue, RubyEncoding/*!*/ encoding, SourceSpan location) { - return new KeywordParameterRead(ReadKind.Value, keywords, new[] { name }, encoding, 0, false, defaultValue, location); + return new KeywordParameterRead(ReadKind.Value, keywords, new[] { name }, encoding, position, false, defaultValue, location); } /// @@ -129,7 +131,8 @@ private KeywordParameterRead(ReadKind kind, Expression/*!*/ keywords, string/*!* Ast.Equal( Ast.Assign(value, Methods.GetKeywordArgument.OpCall( AstUtils.Box(_keywords.TransformRead(gen)), - AstUtils.Constant(gen.Context.CreateSymbol(_names[0], _encoding)) + AstUtils.Constant(gen.Context.CreateSymbol(_names[0], _encoding)), + AstUtils.Constant(_requiredCount) )), Ast.Field(null, Fields.DefaultArgument) ), diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index fd67c93e..472a9908 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -276,7 +276,7 @@ public static partial class Methods { private static MethodInfo _GetInstanceData; public static MethodInfo/*!*/ GetInstanceVariable { get { return _GetInstanceVariable ?? (_GetInstanceVariable = CallInstruction.CacheFunc(RubyOps.GetInstanceVariable)); } } private static MethodInfo _GetInstanceVariable; - public static MethodInfo/*!*/ GetKeywordArgument { get { return _GetKeywordArgument ?? (_GetKeywordArgument = CallInstruction.CacheFunc(RubyOps.GetKeywordArgument)); } } + public static MethodInfo/*!*/ GetKeywordArgument { get { return _GetKeywordArgument ?? (_GetKeywordArgument = CallInstruction.CacheFunc(RubyOps.GetKeywordArgument)); } } private static MethodInfo _GetKeywordArgument; public static MethodInfo/*!*/ GetLambdaUnwinderReturnValue { get { return _GetLambdaUnwinderReturnValue ?? (_GetLambdaUnwinderReturnValue = CallInstruction.CacheFunc(RubyOps.GetLambdaUnwinderReturnValue)); } } private static MethodInfo _GetLambdaUnwinderReturnValue; @@ -454,6 +454,12 @@ public static partial class Methods { private static MethodInfo _MakeKeywordArgumentsHash; public static MethodInfo/*!*/ MakeKeywordArgumentsHash0 { get { return _MakeKeywordArgumentsHash0 ?? (_MakeKeywordArgumentsHash0 = CallInstruction.CacheFunc(RubyOps.MakeKeywordArgumentsHash0)); } } private static MethodInfo _MakeKeywordArgumentsHash0; + public static MethodInfo/*!*/ MakeKeywordArgumentsHash1 { get { return _MakeKeywordArgumentsHash1 ?? (_MakeKeywordArgumentsHash1 = CallInstruction.CacheFunc(RubyOps.MakeKeywordArgumentsHash1)); } } + private static MethodInfo _MakeKeywordArgumentsHash1; + public static MethodInfo/*!*/ MakeKeywordArgumentsHash2 { get { return _MakeKeywordArgumentsHash2 ?? (_MakeKeywordArgumentsHash2 = CallInstruction.CacheFunc(RubyOps.MakeKeywordArgumentsHash2)); } } + private static MethodInfo _MakeKeywordArgumentsHash2; + public static MethodInfo/*!*/ MakeKeywordArgumentsHash3 { get { return _MakeKeywordArgumentsHash3 ?? (_MakeKeywordArgumentsHash3 = CallInstruction.CacheFunc(RubyOps.MakeKeywordArgumentsHash3)); } } + private static MethodInfo _MakeKeywordArgumentsHash3; public static MethodInfo/*!*/ MakeKeywordRest { get { return _MakeKeywordRest ?? (_MakeKeywordRest = CallInstruction.CacheFunc(RubyOps.MakeKeywordRest)); } } private static MethodInfo _MakeKeywordRest; public static MethodInfo/*!*/ MakeMissingDefaultConstructorError { get { return _MakeMissingDefaultConstructorError ?? (_MakeMissingDefaultConstructorError = CallInstruction.CacheFunc(RubyOps.MakeMissingDefaultConstructorError)); } } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index e2f1b820..0c4feb12 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -1691,6 +1691,39 @@ public static object SetQualifiedConstant(object value, object target, RubyScope return result; } + // `f(a: x)`, `f(a: x, b: y)` and `f(a: x, b: y, c: z)` with Symbol literals for keys, the + // usual keyword call: no item array to allocate, and none of the conversions + // SetHashElement applies to a String or nil key. A repeated key keeps the last value, as + // in MRI (and SetHashElements). + + [Emitted] + public static Hash/*!*/ MakeKeywordArgumentsHash1(RubyScope/*!*/ scope, object key1, object value1) { + var result = new Hash(scope.RubyContext.EqualityComparer, 1); + result.IsKeywordArguments = true; + result[key1] = value1; + return result; + } + + [Emitted] + public static Hash/*!*/ MakeKeywordArgumentsHash2(RubyScope/*!*/ scope, object key1, object value1, object key2, object value2) { + var result = new Hash(scope.RubyContext.EqualityComparer, 2); + result.IsKeywordArguments = true; + result[key1] = value1; + result[key2] = value2; + return result; + } + + [Emitted] + public static Hash/*!*/ MakeKeywordArgumentsHash3(RubyScope/*!*/ scope, object key1, object value1, object key2, object value2, + object key3, object value3) { + var result = new Hash(scope.RubyContext.EqualityComparer, 3); + result.IsKeywordArguments = true; + result[key1] = value1; + result[key2] = value2; + result[key3] = value3; + return result; + } + /// True for the hash a call site built out of keyword syntax. [Emitted] public static bool IsKeywordArgumentsHash(object obj) { @@ -1713,35 +1746,19 @@ public static bool IsKeywordArgumentsHash(object obj) { [Emitted] public static bool KeywordArgumentsMatch(object keywords, RubySymbol/*!*/[]/*!*/ names, int requiredCount, bool acceptsRest) { var hash = keywords as Hash; - if (hash == null) { - return false; - } - - int count = hash.Count; - if (count == 0) { - return requiredCount == 0; - } - - int found = 0; - for (int i = 0; i < names.Length; i++) { - if (hash.ContainsKey(names[i])) { - found++; - } else if (i < requiredCount) { - return false; - } - } - return acceptsRest || found == count; + return hash != null && hash.HasKeywords(names, requiredCount, acceptsRest); } /// /// The value the call passed for keyword , or - /// if it passed none. + /// if it passed none. is where the + /// method declares the keyword, which is where a call usually puts it too. /// [Emitted] - public static object GetKeywordArgument(object keywords, RubySymbol/*!*/ name) { + public static object GetKeywordArgument(object keywords, RubySymbol/*!*/ name, int position) { var hash = keywords as Hash; object value; - return (hash != null && hash.TryGetValue(name, out value)) ? value : DefaultArgument; + return (hash != null && hash.TryGetValueByIdentity(name, position, out value)) ? value : DefaultArgument; } /// From 8df63f0e43dad11aa1bd380d2498e0acee3a7299 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:04:42 +0200 Subject: [PATCH 13/94] Give send/__send__/public_send and respond_to? per-site storages Gated state of the previous (WIP) commit b334b189; no further change. Kernel#send, BasicObject#__send__ and Kernel#public_send looked their inner call site up in RubyContext.GetOrCreateSendSite on every call: a lock plus a Dictionary> lookup that hashes the name and boxes the signature - 94% of #send's own time in a profile of send_call. Each overload now takes a SendSiteStorage (created per rule, so per send site): a few name -> CallSite entries compared by reference, filled once, with the context's shared sites as the fallback past 8 names (and for the library-internal SendMessageOpt callers). The inner site is also private to the send site now, so it stays as monomorphic as the send site is. Kernel#respond_to? keeps its answers in a RespondToCacheStorage: (class, method version, name, include_all) -> bool, kept only when the answer came from method lookups alone (a user-defined respond_to_missing? is still called every time) and only while the class's method version is unchanged. RubyModule.GetMethodLookup(name, out version) reports the version a lookup was published under (-1 when it must not be kept), so the memo is exactly as sound as the class's own lookup cache, which registers its lookups for invalidation the way call sites do. Release, best of 5 interleaved A/B runs (bench-lock): send_call 0.434s -> 0.216s (base spread 0.434-1.10, mine 0.216-0.266) respond_to 0.249s -> 0.216s (base spread 0.249-0.272, mine 0.216-0.299) Initializers regenerated; registered names unchanged. Specs: spec/core/kernel/{respond_to,respond_to_missing,send,public_send, method,public_method}, spec/core/basicobject, spec/core/module/{refine, refinements,using,prepend,define_method,alias_method,undef_method, remove_method,private,public}: 580 examples, 0 failures (base: same). Gates: sp-tests 0, sp-lang 2934/0/0, send_call 0.434s -> 0.216s Co-Authored-By: Claude Opus 5.5 From 12c2c7fdf44bb2ccb22c218469036de70d36593e Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:14:26 +0200 Subject: [PATCH 14/94] Format raised exceptions' backtraces lazily; skip the prelude's set_backtrace Every raise used to build its backtrace eagerly in the first Ruby frame that saw the exception: two CLR stack walks, then a MutableString per frame (method resolution, encoded-name parsing, library labels), and then a dynamic call to #set_backtrace - which the prelude (ruby4.rb) overrides, so it ran Ruby code that parsed every frame into a Thread::Backtrace::Location on every raise. Now CaptureExceptionTrace keeps the stack walks and the interpreter's saved frames (RubyStackTraceBuilder.CapturedTrace) and RubyExceptionData.Backtrace formats them on first use, with exactly the inputs the eager build used. #set_backtrace is only called when it has been redefined - IronRuby's own (library or prelude) is skipped, as MRI's setup_exception skips the basic definition; the prelude's #backtrace_locations parses the strings on demand as before. A raised exception's instance_variables are now [] as in MRI (they used to show the prelude's @__backtrace_* bookkeeping). Kernel#raise asks HasBacktrace so re-raising doesn't format the trace. raise_rescue (Util/bench, scale 0.1, Release, 5 interleaved pairs in one bench-lock call): base 6.45 s best (6.45-10.38) -> 1.98 s best (1.98-4.49). raise/rescue loop in an interpreted method, 5000 raises: 2.45 s -> 0.87 s. Output identical to base on a backtrace/full_message/cause/thread/eval/ catch/loop/Enumerator probe script. Gates: sp-tests 0, sp-lang 2934/0/0, raise_rescue(0.1) 6.45s -> 1.98s; spec/core/exception+kernel raise/fail/caller/caller_locations/catch/throw/loop+thread raise/backtrace+enumerator next+fiber 742 ex 1 F = base; spec/command_line 175/0/0 = base Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/KernelOps.cs | 4 +- Src/Ruby/Runtime/RubyExceptionData.cs | 79 +++++++++++++++++++++-- Src/Ruby/Runtime/RubyStackTraceBuilder.cs | 54 ++++++++++++++-- 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/Src/Libraries/Builtins/KernelOps.cs b/Src/Libraries/Builtins/KernelOps.cs index 247c3881..420ca2e4 100644 --- a/Src/Libraries/Builtins/KernelOps.cs +++ b/Src/Libraries/Builtins/KernelOps.cs @@ -641,7 +641,7 @@ public static void RaiseException(RespondToStorage/*!*/ respondToStorage, UnaryO // An exception raised before whose backtrace was since set to nil gets a new one, as in // MRI. The interpreter keeps the frames of the first throw on the exception and would // hand those back again. - if (RubyExceptionData.GetInstance(exception).Backtrace == null) { + if (!RubyExceptionData.GetInstance(exception).HasBacktrace) { exception.RemoveData(typeof(Microsoft.Scripting.Interpreter.InterpretedFrameInfo)); } @@ -721,7 +721,7 @@ public static MutableString GetSourceFileLocation(RubyContext/*!*/ context, obje // An exception with no backtrace - new, or cleared by set_backtrace(nil) - gets one from // where it is raised now. The interpreter keeps the frames of the first throw on the // exception and would otherwise report those again. - if (RubyExceptionData.GetInstance(exception).Backtrace == null) { + if (!RubyExceptionData.GetInstance(exception).HasBacktrace) { exception.SetData(typeof(Microsoft.Scripting.Interpreter.InterpretedFrameInfo), null); } return exception; diff --git a/Src/Ruby/Runtime/RubyExceptionData.cs b/Src/Ruby/Runtime/RubyExceptionData.cs index 0564ddb7..d19c384e 100644 --- a/Src/Ruby/Runtime/RubyExceptionData.cs +++ b/Src/Ruby/Runtime/RubyExceptionData.cs @@ -64,6 +64,13 @@ public sealed class RubyExceptionData { // can be set explicitly by the user (even to nil): private RubyArray _backtrace; +#if FEATURE_STACK_TRACE + // The backtrace the runtime captured when the exception was raised, until someone asks for + // it (see CaptureExceptionTrace). Only ever set while _backtrace is null. + [NonSerialized] + private RubyStackTraceBuilder.CapturedTrace _capturedTrace; +#endif + // Exception#cause; _hasCause tells "decided" from "not raised yet", see TrySetCause. private Exception _cause; private bool _hasCause; @@ -136,16 +143,39 @@ public static RubyArray CreateBacktrace(RubyContext/*!*/ context, Thread/*!*/ th /// Called from compiled code. /// internal void CaptureExceptionTrace(RubyScope/*!*/ scope) { - if (_backtrace == null) { + if (!HasBacktrace) { + var context = scope.RubyContext; #if FEATURE_STACK_TRACE StackTrace catchSiteTrace = RubyStackTraceBuilder.GetClrStackTrace(null, - scope.RubyContext.DomainManager.Configuration.DebugMode); - _backtrace = new RubyStackTraceBuilder(scope.RubyContext, _exception, catchSiteTrace, scope.InterpretedFrame != null).RubyTrace; + context.DomainManager.Configuration.DebugMode); + + // formatted on first use, see Backtrace: + _capturedTrace = new RubyStackTraceBuilder.CapturedTrace(context, _exception, catchSiteTrace, scope.InterpretedFrame != null); #else _backtrace = new RubyArray(); #endif - DynamicSetBacktrace(scope.RubyContext, _backtrace); + // MRI (setup_exception) only calls #set_backtrace when it has been redefined: + if (!HasBuiltinSetBacktrace(context)) { + DynamicSetBacktrace(context, Backtrace); + } + } + } + + /// + /// Whether #set_backtrace of the exception is IronRuby's own: the library method, or the + /// prelude's (Src/StdLib/ironruby/ruby4.rb) which keeps Thread::Backtrace::Locations around + /// and passes strings on to the library method. Called with the backtrace the runtime has just + /// captured, the prelude's would only parse the strings into Locations eagerly - which its + /// #backtrace_locations does on demand all the same. + /// + private bool HasBuiltinSetBacktrace(RubyContext/*!*/ context) { + var method = context.GetImmediateClassOf(_exception).ResolveMethod("set_backtrace", VisibilityContext.AllVisible).Info; + if (method is RubyLibraryMethodInfo) { + return true; } + + var rubyMethod = method as RubyMethodInfo; + return rubyMethod != null && rubyMethod.Document != null && RubyStackTraceBuilder.IsInternalFile(rubyMethod.Document.FileName); } /// @@ -227,13 +257,51 @@ public object Message { public RubyArray Backtrace { get { +#if FEATURE_STACK_TRACE + if (_capturedTrace != null) { + FormatCapturedTrace(); + } +#endif return _backtrace; } set { +#if FEATURE_STACK_TRACE + _capturedTrace = null; +#endif _backtrace = value; } } + /// + /// Whether the exception has a backtrace, without formatting a captured one. + /// + public bool HasBacktrace { + get { +#if FEATURE_STACK_TRACE + return _backtrace != null || _capturedTrace != null; +#else + return _backtrace != null; +#endif + } + } + +#if FEATURE_STACK_TRACE + private void FormatCapturedTrace() { + var captured = _capturedTrace; + if (captured == null) { + return; + } + + // another thread may ask for the same exception's backtrace (Thread#value, #join) + lock (captured) { + if (_capturedTrace == captured) { + _backtrace = captured.Build(); + _capturedTrace = null; + } + } + } +#endif + /// /// Exception#cause. MRI fixes the cause the first time an exception is raised and never /// changes it afterwards, so re-raising a rescued exception keeps the original chain. @@ -372,6 +440,9 @@ public void SetCause(Exception cause) { newInstanceData.Handled = true; newInstanceData._backtrace = instanceData._backtrace; +#if FEATURE_STACK_TRACE + newInstanceData._capturedTrace = instanceData._capturedTrace; +#endif return newException; } diff --git a/Src/Ruby/Runtime/RubyStackTraceBuilder.cs b/Src/Ruby/Runtime/RubyStackTraceBuilder.cs index fb67f6d4..fd0a63aa 100644 --- a/Src/Ruby/Runtime/RubyStackTraceBuilder.cs +++ b/Src/Ruby/Runtime/RubyStackTraceBuilder.cs @@ -46,21 +46,34 @@ public sealed class RubyStackTraceBuilder { // core method written in Ruby is that method's own, not its caller's. private bool _keepInternalFrames; - private RubyStackTraceBuilder(RubyContext/*!*/ context) { + private RubyStackTraceBuilder(RubyContext/*!*/ context) + : this(context, context.GetPathEncoding()) { + } + + private RubyStackTraceBuilder(RubyContext/*!*/ context, RubyEncoding/*!*/ encoding) { _context = context; _needsClrFileInfo = NeedsClrFileInfo(context); _hasFileAccessPermission = _needsClrFileInfo && DetectFileAccessPermissions(); _exceptionDetail = context.Options.ExceptionDetail; - _encoding = context.GetPathEncoding(); + _encoding = encoding; _trace = new RubyArray(); } - - internal RubyStackTraceBuilder(RubyContext/*!*/ context, Exception/*!*/ exception, StackTrace catchSiteTrace, bool isCatchSiteInterpreted) + + internal RubyStackTraceBuilder(RubyContext/*!*/ context, Exception/*!*/ exception, StackTrace catchSiteTrace, bool isCatchSiteInterpreted) : this(context) { // Compiled trace: contains frames starting with the throw site up to the first filter/catch that the exception was caught by: StackTrace throwSiteTrace = GetClrStackTrace(exception, _needsClrFileInfo); _interpretedFrames = InterpretedFrame.GetExceptionStackTrace(exception); + AddExceptionBacktrace(throwSiteTrace, catchSiteTrace, isCatchSiteInterpreted); + } + + private RubyStackTraceBuilder(CapturedTrace/*!*/ captured) + : this(captured.Context, captured.Encoding) { + _interpretedFrames = captured.InterpretedFrames; + AddExceptionBacktrace(captured.ThrowSiteTrace, captured.CatchSiteTrace, captured.IsCatchSiteInterpreted); + } + private void AddExceptionBacktrace(StackTrace/*!*/ throwSiteTrace, StackTrace catchSiteTrace, bool isCatchSiteInterpreted) { AddBacktrace(throwSiteTrace.GetFrames(), 0, false); // Compiled trace: contains frames above and including the first Ruby filter/catch site that the exception was caught by: @@ -71,6 +84,39 @@ internal RubyStackTraceBuilder(RubyContext/*!*/ context, Exception/*!*/ exceptio } } + /// + /// What the backtrace of a raised exception is made of, taken where the first Ruby frame saw + /// the exception go by - the stack walks, and the interpreted frames the interpreter saved on + /// the exception - but not yet turned into strings. Most exceptions are rescued and dropped + /// without anyone asking for their backtrace, and formatting it (resolving every frame's + /// method, parsing the encoded names, building a string per frame) is most of what a raise + /// used to cost. does it on first use and yields exactly what building it + /// eagerly yielded: everything it reads is either captured here or fixed for the context. + /// + internal sealed class CapturedTrace { + internal readonly RubyContext/*!*/ Context; + internal readonly RubyEncoding/*!*/ Encoding; + internal readonly StackTrace/*!*/ ThrowSiteTrace; + internal readonly StackTrace CatchSiteTrace; + internal readonly IList InterpretedFrames; + internal readonly bool IsCatchSiteInterpreted; + + internal CapturedTrace(RubyContext/*!*/ context, Exception/*!*/ exception, StackTrace catchSiteTrace, bool isCatchSiteInterpreted) { + Context = context; + Encoding = context.GetPathEncoding(); + // the exception's own trace grows as it is rethrown, and a raise of it starts it over: + ThrowSiteTrace = GetClrStackTrace(exception, NeedsClrFileInfo(context)); + // the interpreter's frames are saved on the exception once, and raise clears them: + InterpretedFrames = InterpretedFrame.GetExceptionStackTrace(exception); + CatchSiteTrace = catchSiteTrace; + IsCatchSiteInterpreted = isCatchSiteInterpreted; + } + + internal RubyArray/*!*/ Build() { + return new RubyStackTraceBuilder(this).RubyTrace; + } + } + internal RubyStackTraceBuilder(RubyContext/*!*/ context, int skipFrames) : this(context, skipFrames, false) { } From c7957991fda44e42f8155c111ec3c21bb29ad3cc Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:15:11 +0200 Subject: [PATCH 15/94] WIP: map method bodies lazily, on first call prism prefixes every serialized DefNode with its length so that method bodies can be loaded lazily. The loader (generate.rb) can now skim a def body instead of building it - noting whether it yields or calls super without a block (UsesBlock is needed when the def runs) and whether it holds anything the AST bridge reports at parse time (defined?, a regexp that may warn, class << x, class/module) - and the bridge then creates the MethodDefinition with a body built on first use: loaded from prism's buffer and mapped in the method's own scope, after its parameters, when the method is first compiled. Bodies with parse-time reports, eval'd code and Coverage runs stay eager. Most methods a file defines are never called (startup defines ~2300 and calls ~150), so their bodies are never loaded or mapped. Status: Debug and Release build; sp-tests Debug running; semantic checks (yield, super/zsuper, nested defs, binding.local_variables, caller lines, regexp/defined? warnings, Invalid yield, unused-block warning, threads) match base/CRuby. Not gated yet. Co-Authored-By: Claude Opus 5.5 --- Src/Prism/Generated/PrismLoader.Generated.cs | 1165 ++++++++++++++++- Src/Prism/Generated/PrismNodes.Generated.cs | 6 + Src/Prism/PrismAstBridge.cs | 70 +- Src/Prism/PrismLoader.cs | 96 +- Src/Prism/PrismParser.cs | 12 +- Src/Prism/generate.rb | 105 +- .../Ast/Definitions/MethodDefinition.cs | 20 +- .../Ast/Expressions/DefinitionExpression.cs | 25 +- 8 files changed, 1483 insertions(+), 16 deletions(-) diff --git a/Src/Prism/Generated/PrismLoader.Generated.cs b/Src/Prism/Generated/PrismLoader.Generated.cs index 349fa4ad..ccc3a6eb 100644 --- a/Src/Prism/Generated/PrismLoader.Generated.cs +++ b/Src/Prism/Generated/PrismLoader.Generated.cs @@ -444,22 +444,31 @@ private PmNode LoadNode() { } case 45: { LoadRawUInt32(); - return new DefNode { + var def = new DefNode { NodeId = nodeId, StartOffset = location.Start, Length = location.Length, Flags = LoadVarUInt(), Name = LoadConstant(), NameLoc = LoadLocation(), Receiver = LoadOptionalNode(), Parameters = LoadOptionalNode(), - Body = LoadOptionalNode(), - Locals = LoadConstantList(), - DefKeywordLoc = LoadLocation(), - OperatorLoc = LoadOptionalLocation(), - LparenLoc = LoadOptionalLocation(), - RparenLoc = LoadOptionalLocation(), - EqualLoc = LoadOptionalLocation(), - EndKeywordLoc = LoadOptionalLocation(), }; + if (_lazyDefBodies) { + def.DeferredBodyLoader = this; + def.DeferredBodyOffset = _pos; + _contents = 0; + SkimOptionalNode(); + def.BodyContents = _contents; + } else { + def.Body = LoadOptionalNode(); + } + def.Locals = LoadConstantList(); + def.DefKeywordLoc = LoadLocation(); + def.OperatorLoc = LoadOptionalLocation(); + def.LparenLoc = LoadOptionalLocation(); + def.RparenLoc = LoadOptionalLocation(); + def.EqualLoc = LoadOptionalLocation(); + def.EndKeywordLoc = LoadOptionalLocation(); + return def; } case 46: { return new DefinedNode { @@ -1432,6 +1441,1144 @@ private PmNode LoadNode() { RparenLoc = LoadOptionalLocation(), }; } + default: + throw new System.InvalidOperationException("Unknown prism node type: " + type); + } +} + +// Reads past a node without building it, noting in _contents what the bridge has to see +// at parse time (see PrismLoader.Contents*). Mirrors LoadNode field for field. +private void SkimNode() { + byte type = LoadByte(); + LoadVarUInt(); // node id + LoadLocation(); + switch (type) { + case 1: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + LoadLocation(); + return; + } + case 2: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + LoadLocation(); + return; + } + case 3: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + LoadLocation(); + return; + } + case 4: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + LoadLocation(); + return; + } + case 5: { + LoadVarUInt(); // flags + SkimNodeList(); + return; + } + case 6: { + LoadVarUInt(); // flags + SkimNodeList(); + LoadOptionalLocation(); + LoadOptionalLocation(); + return; + } + case 7: { + LoadVarUInt(); // flags + SkimOptionalNode(); + SkimNodeList(); + SkimOptionalNode(); + SkimNodeList(); + LoadOptionalLocation(); + LoadOptionalLocation(); + return; + } + case 8: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + LoadOptionalLocation(); + return; + } + case 9: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadLocation(); + return; + } + case 10: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 11: { + LoadVarUInt(); // flags + LoadOptionalLocation(); + SkimOptionalNode(); + SkimOptionalNode(); + SkimOptionalNode(); + SkimOptionalNode(); + LoadOptionalLocation(); + return; + } + case 12: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadLocation(); + return; + } + case 13: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 14: { + LoadVarUInt(); // flags + SkipVarUIntList(); + SkimOptionalNode(); + SkimOptionalNode(); + LoadLocation(); + LoadLocation(); + return; + } + case 15: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadOptionalLocation(); + LoadLocation(); + return; + } + case 16: { + LoadVarUInt(); // flags + SkimOptionalNode(); + SkimNodeList(); + LoadOptionalLocation(); + LoadOptionalLocation(); + return; + } + case 17: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadLocation(); + return; + } + case 18: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadOptionalLocation(); + LoadOptionalLocation(); + LoadVarUInt(); + LoadVarUInt(); + LoadLocation(); + SkimNode(); + return; + } + case 19: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadOptionalLocation(); + LoadVarUInt(); + LoadOptionalLocation(); + LoadOptionalLocation(); + SkimOptionalNode(); + LoadOptionalLocation(); + LoadOptionalLocation(); + SkimOptionalNode(); + return; + } + case 20: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadOptionalLocation(); + LoadOptionalLocation(); + LoadVarUInt(); + LoadVarUInt(); + LoadVarUInt(); + LoadLocation(); + SkimNode(); + return; + } + case 21: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadOptionalLocation(); + LoadOptionalLocation(); + LoadVarUInt(); + LoadVarUInt(); + LoadLocation(); + SkimNode(); + return; + } + case 22: { + LoadVarUInt(); // flags + SkimNode(); + LoadLocation(); + LoadVarUInt(); + LoadLocation(); + return; + } + case 23: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + LoadLocation(); + return; + } + case 24: { + LoadVarUInt(); // flags + SkimOptionalNode(); + SkimNodeList(); + SkimOptionalNode(); + LoadLocation(); + LoadLocation(); + return; + } + case 25: { + LoadVarUInt(); // flags + SkimOptionalNode(); + SkimNodeList(); + SkimOptionalNode(); + LoadLocation(); + LoadLocation(); + return; + } + case 26: { + LoadVarUInt(); // flags + _contents |= ContentsEager; + SkipVarUIntList(); + LoadLocation(); + SkimNode(); + LoadOptionalLocation(); + SkimOptionalNode(); + SkimOptionalNode(); + LoadLocation(); + LoadVarUInt(); + return; + } + case 27: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 28: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + LoadVarUInt(); + return; + } + case 29: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 30: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 31: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 32: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + SkimNode(); + LoadLocation(); + return; + } + case 33: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 34: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + LoadVarUInt(); + return; + } + case 35: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 36: { + LoadVarUInt(); // flags + SkimNode(); + LoadLocation(); + SkimNode(); + return; + } + case 37: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + return; + } + case 38: { + LoadVarUInt(); // flags + SkimNode(); + LoadLocation(); + SkimNode(); + LoadVarUInt(); + return; + } + case 39: { + LoadVarUInt(); // flags + SkimNode(); + LoadLocation(); + SkimNode(); + return; + } + case 40: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + return; + } + case 41: { + LoadVarUInt(); // flags + SkimNode(); + LoadLocation(); + SkimNode(); + return; + } + case 42: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 43: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 44: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + SkimNode(); + LoadLocation(); + return; + } + case 45: { + _pos += 4; + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + SkimOptionalNode(); + uint outer = _contents; + _contents = 0; + SkimOptionalNode(); + SkimOptionalNode(); + _contents = outer | (_contents & ContentsEager); + SkipVarUIntList(); + LoadLocation(); + LoadOptionalLocation(); + LoadOptionalLocation(); + LoadOptionalLocation(); + LoadOptionalLocation(); + LoadOptionalLocation(); + return; + } + case 46: { + LoadVarUInt(); // flags + _contents |= ContentsEager; + LoadOptionalLocation(); + SkimNode(); + LoadOptionalLocation(); + LoadLocation(); + return; + } + case 47: { + LoadVarUInt(); // flags + LoadLocation(); + SkimOptionalNode(); + LoadOptionalLocation(); + return; + } + case 48: { + LoadVarUInt(); // flags + LoadLocation(); + SkimOptionalNode(); + LoadLocation(); + return; + } + case 49: { + LoadVarUInt(); // flags + LoadLocation(); + SkimNode(); + return; + } + case 50: { + LoadVarUInt(); // flags + LoadLocation(); + SkimOptionalNode(); + LoadLocation(); + return; + } + case 51: { + LoadVarUInt(); // flags + SkimOptionalNode(); + return; + } + case 52: { + LoadVarUInt(); // flags + return; + } + case 53: { + LoadVarUInt(); // flags + SkimOptionalNode(); + SkimNode(); + SkimNodeList(); + SkimNode(); + LoadOptionalLocation(); + LoadOptionalLocation(); + return; + } + case 54: { + LoadVarUInt(); // flags + SkimOptionalNode(); + SkimOptionalNode(); + LoadLocation(); + return; + } + case 55: { + LoadVarUInt(); // flags + _pos += 8; + return; + } + case 56: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + SkimOptionalNode(); + LoadLocation(); + LoadLocation(); + LoadOptionalLocation(); + LoadLocation(); + return; + } + case 57: { + LoadVarUInt(); // flags + return; + } + case 58: { + LoadVarUInt(); // flags + return; + } + case 59: { + LoadVarUInt(); // flags + LoadLocation(); + if (_buffer[_pos] == 0) _contents |= ContentsUsesBlock; + SkimOptionalNode(); + return; + } + case 60: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 61: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + LoadVarUInt(); + return; + } + case 62: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 63: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 64: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 65: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + SkimNode(); + LoadLocation(); + return; + } + case 66: { + LoadVarUInt(); // flags + LoadLocation(); + SkimNodeList(); + LoadLocation(); + return; + } + case 67: { + LoadVarUInt(); // flags + SkimOptionalNode(); + SkimNodeList(); + SkimOptionalNode(); + LoadOptionalLocation(); + LoadOptionalLocation(); + return; + } + case 68: { + LoadVarUInt(); // flags + LoadOptionalLocation(); + SkimNode(); + LoadOptionalLocation(); + SkimOptionalNode(); + SkimOptionalNode(); + LoadOptionalLocation(); + return; + } + case 69: { + LoadVarUInt(); // flags + SkimNode(); + return; + } + case 70: { + LoadVarUInt(); // flags + SkimNode(); + return; + } + case 71: { + LoadVarUInt(); // flags + return; + } + case 72: { + LoadVarUInt(); // flags + SkimNode(); + SkimOptionalNode(); + LoadLocation(); + LoadOptionalLocation(); + return; + } + case 73: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadOptionalLocation(); + LoadLocation(); + SkimOptionalNode(); + LoadLocation(); + SkimOptionalNode(); + LoadLocation(); + SkimNode(); + return; + } + case 74: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadOptionalLocation(); + LoadLocation(); + SkimOptionalNode(); + LoadLocation(); + SkimOptionalNode(); + LoadVarUInt(); + LoadLocation(); + SkimNode(); + return; + } + case 75: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadOptionalLocation(); + LoadLocation(); + SkimOptionalNode(); + LoadLocation(); + SkimOptionalNode(); + LoadLocation(); + SkimNode(); + return; + } + case 76: { + LoadVarUInt(); // flags + SkimNode(); + LoadLocation(); + SkimOptionalNode(); + LoadLocation(); + SkimOptionalNode(); + return; + } + case 77: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 78: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + LoadVarUInt(); + return; + } + case 79: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 80: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 81: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 82: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + SkimNode(); + LoadLocation(); + return; + } + case 83: { + LoadVarUInt(); // flags + LoadInteger(); + return; + } + case 84: { + LoadVarUInt(); // flags + LoadLocation(); + SkimNodeList(); + LoadLocation(); + return; + } + case 85: { + LoadVarUInt(); // flags + LoadLocation(); + SkimNodeList(); + LoadLocation(); + return; + } + case 86: { + LoadVarUInt(); // flags + LoadOptionalLocation(); + SkimNodeList(); + LoadOptionalLocation(); + return; + } + case 87: { + LoadVarUInt(); // flags + LoadOptionalLocation(); + SkimNodeList(); + LoadOptionalLocation(); + return; + } + case 88: { + LoadVarUInt(); // flags + LoadLocation(); + SkimNodeList(); + LoadLocation(); + return; + } + case 89: { + LoadVarUInt(); // flags + return; + } + case 90: { + LoadVarUInt(); // flags + return; + } + case 91: { + LoadVarUInt(); // flags + SkimNodeList(); + return; + } + case 92: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadOptionalLocation(); + LoadLocation(); + return; + } + case 93: { + LoadVarUInt(); // flags + SkipVarUIntList(); + LoadLocation(); + LoadLocation(); + LoadLocation(); + SkimOptionalNode(); + SkimOptionalNode(); + return; + } + case 94: { + LoadVarUInt(); // flags + LoadLocation(); + LoadLocation(); + SkimNode(); + LoadVarUInt(); + LoadVarUInt(); + return; + } + case 95: { + LoadVarUInt(); // flags + LoadLocation(); + LoadLocation(); + SkimNode(); + LoadVarUInt(); + LoadVarUInt(); + LoadVarUInt(); + return; + } + case 96: { + LoadVarUInt(); // flags + LoadLocation(); + LoadLocation(); + SkimNode(); + LoadVarUInt(); + LoadVarUInt(); + return; + } + case 97: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadVarUInt(); + return; + } + case 98: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadVarUInt(); + return; + } + case 99: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadVarUInt(); + LoadLocation(); + SkimNode(); + LoadLocation(); + return; + } + case 100: { + LoadVarUInt(); // flags + LoadLocation(); + LoadLocation(); + LoadLocation(); + SkipRegexpSource(); + return; + } + case 101: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + LoadLocation(); + return; + } + case 102: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + LoadLocation(); + return; + } + case 103: { + LoadVarUInt(); // flags + SkimNode(); + SkimNodeList(); + return; + } + case 104: { + LoadVarUInt(); // flags + _contents |= ContentsEager; + SkipVarUIntList(); + LoadLocation(); + SkimNode(); + SkimOptionalNode(); + LoadLocation(); + LoadVarUInt(); + return; + } + case 105: { + LoadVarUInt(); // flags + SkimNodeList(); + SkimOptionalNode(); + SkimNodeList(); + LoadOptionalLocation(); + LoadOptionalLocation(); + return; + } + case 106: { + LoadVarUInt(); // flags + SkimNodeList(); + SkimOptionalNode(); + SkimNodeList(); + LoadOptionalLocation(); + LoadOptionalLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 107: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadLocation(); + return; + } + case 108: { + LoadVarUInt(); // flags + return; + } + case 109: { + LoadVarUInt(); // flags + LoadLocation(); + LoadLocation(); + return; + } + case 110: { + LoadVarUInt(); // flags + LoadLocation(); + LoadLocation(); + return; + } + case 111: { + LoadVarUInt(); // flags + _pos++; + return; + } + case 112: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 113: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + SkimNode(); + return; + } + case 114: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + LoadLocation(); + SkimNode(); + return; + } + case 115: { + LoadVarUInt(); // flags + SkimNode(); + SkimNode(); + LoadLocation(); + return; + } + case 116: { + LoadVarUInt(); // flags + SkimNodeList(); + SkimNodeList(); + SkimOptionalNode(); + SkimNodeList(); + SkimNodeList(); + SkimOptionalNode(); + SkimOptionalNode(); + return; + } + case 117: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadLocation(); + LoadLocation(); + return; + } + case 118: { + LoadVarUInt(); // flags + SkimNode(); + LoadLocation(); + LoadLocation(); + LoadLocation(); + return; + } + case 119: { + LoadVarUInt(); // flags + SkimNode(); + LoadLocation(); + return; + } + case 120: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadLocation(); + LoadLocation(); + LoadLocation(); + return; + } + case 121: { + LoadVarUInt(); // flags + SkimOptionalNode(); + LoadLocation(); + LoadLocation(); + LoadLocation(); + return; + } + case 122: { + LoadVarUInt(); // flags + SkipVarUIntList(); + SkimNode(); + return; + } + case 123: { + LoadVarUInt(); // flags + SkimOptionalNode(); + SkimOptionalNode(); + LoadLocation(); + return; + } + case 124: { + LoadVarUInt(); // flags + LoadInteger(); + LoadInteger(); + return; + } + case 125: { + LoadVarUInt(); // flags + return; + } + case 126: { + LoadVarUInt(); // flags + LoadLocation(); + LoadLocation(); + LoadLocation(); + SkipRegexpSource(); + return; + } + case 127: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadLocation(); + return; + } + case 128: { + LoadVarUInt(); // flags + LoadVarUInt(); + return; + } + case 129: { + LoadVarUInt(); // flags + SkimNode(); + LoadLocation(); + SkimNode(); + return; + } + case 130: { + LoadVarUInt(); // flags + LoadLocation(); + SkimNodeList(); + LoadOptionalLocation(); + SkimOptionalNode(); + LoadOptionalLocation(); + SkimOptionalNode(); + SkimOptionalNode(); + return; + } + case 131: { + LoadVarUInt(); // flags + LoadVarUInt(); + LoadOptionalLocation(); + LoadLocation(); + return; + } + case 132: { + LoadVarUInt(); // flags + return; + } + case 133: { + LoadVarUInt(); // flags + LoadLocation(); + SkimOptionalNode(); + return; + } + case 134: { + LoadVarUInt(); // flags + return; + } + case 135: { + LoadVarUInt(); // flags + SkimNode(); + return; + } + case 136: { + LoadVarUInt(); // flags + _contents |= ContentsEager; + SkipVarUIntList(); + LoadLocation(); + LoadLocation(); + SkimNode(); + SkimOptionalNode(); + LoadLocation(); + return; + } + case 137: { + LoadVarUInt(); // flags + return; + } + case 138: { + LoadVarUInt(); // flags + SkipString(); + return; + } + case 139: { + LoadVarUInt(); // flags + return; + } + case 140: { + LoadVarUInt(); // flags + LoadLocation(); + SkimOptionalNode(); + return; + } + case 141: { + LoadVarUInt(); // flags + SkimNodeList(); + return; + } + case 142: { + LoadVarUInt(); // flags + LoadOptionalLocation(); + LoadLocation(); + LoadOptionalLocation(); + SkipString(); + return; + } + case 143: { + LoadVarUInt(); // flags + LoadLocation(); + LoadOptionalLocation(); + SkimOptionalNode(); + LoadOptionalLocation(); + if (_buffer[_pos] == 0) _contents |= ContentsUsesBlock; + SkimOptionalNode(); + return; + } + case 144: { + LoadVarUInt(); // flags + LoadOptionalLocation(); + LoadOptionalLocation(); + LoadOptionalLocation(); + SkipString(); + return; + } + case 145: { + LoadVarUInt(); // flags + return; + } + case 146: { + LoadVarUInt(); // flags + SkimNodeList(); + LoadLocation(); + return; + } + case 147: { + LoadVarUInt(); // flags + LoadLocation(); + SkimNode(); + LoadOptionalLocation(); + SkimOptionalNode(); + SkimOptionalNode(); + LoadOptionalLocation(); + return; + } + case 148: { + LoadVarUInt(); // flags + LoadLocation(); + LoadOptionalLocation(); + LoadOptionalLocation(); + SkimNode(); + SkimOptionalNode(); + return; + } + case 149: { + LoadVarUInt(); // flags + LoadLocation(); + SkimNodeList(); + LoadOptionalLocation(); + SkimOptionalNode(); + return; + } + case 150: { + LoadVarUInt(); // flags + LoadLocation(); + LoadOptionalLocation(); + LoadOptionalLocation(); + SkimNode(); + SkimOptionalNode(); + return; + } + case 151: { + LoadVarUInt(); // flags + LoadLocation(); + LoadLocation(); + LoadLocation(); + SkipString(); + return; + } + case 152: { + LoadVarUInt(); // flags + _contents |= ContentsUsesBlock; + LoadLocation(); + LoadOptionalLocation(); + SkimOptionalNode(); + LoadOptionalLocation(); + return; + } default: throw new System.InvalidOperationException("Unknown prism node type: " + type); } diff --git a/Src/Prism/Generated/PrismNodes.Generated.cs b/Src/Prism/Generated/PrismNodes.Generated.cs index 9654bfff..907469f4 100644 --- a/Src/Prism/Generated/PrismNodes.Generated.cs +++ b/Src/Prism/Generated/PrismNodes.Generated.cs @@ -417,6 +417,12 @@ public sealed class DefNode : PmNode { public PmLocation? RparenLoc; public PmLocation? EqualLoc; public PmLocation? EndKeywordLoc; + // Set by a loader asked for lazy method bodies: Body is null and the body is still in the + // serialized buffer, for PrismLoader.LoadDeferredBody; BodyContents is what the body holds + // (PrismLoader.Contents* bits). + public IronRuby.Prism.PrismLoader DeferredBodyLoader; + public int DeferredBodyOffset; + public uint BodyContents; } public sealed class DefinedNode : PmNode { diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index 053be813..ea48ac21 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -149,11 +149,16 @@ public static SourceUnitTree ParseText(string/*!*/ code, string path, List + /// A method body left in prism's buffer, mapped the first time the method's AST body is asked + /// for - normally when the method is first called and compiled. It is mapped as Def would have + /// mapped it: in the method's own scope, after its parameters, with the same super arguments. + /// + private sealed class DeferredMethodBody { + private readonly PrismAstBridge/*!*/ _bridge; + private readonly Pm.DefNode/*!*/ _node; + private readonly LexicalScope/*!*/ _scope; + private readonly SourceSpan _span; + private readonly Statements _prologue; + private readonly Arguments _zsuperArguments; + + public DeferredMethodBody(PrismAstBridge/*!*/ bridge, Pm.DefNode/*!*/ node, LexicalScope/*!*/ scope, SourceSpan span, + Statements prologue, Arguments zsuperArguments) { + _bridge = bridge; + _node = node; + _scope = scope; + _span = span; + _prologue = prologue; + _zsuperArguments = zsuperArguments; + } + + public Body/*!*/ Materialize() { + return _bridge.MaterializeBody(_node, _scope, _span, _prologue, _zsuperArguments); + } + } + + private Body/*!*/ MaterializeBody(Pm.DefNode/*!*/ node, LexicalScope/*!*/ scope, SourceSpan span, Statements prologue, + Arguments zsuperArguments) { + // the bridge keeps state while it maps; methods of one file may be first called on different threads + lock (this) { + Pm.PmNode bodyNode = node.DeferredBodyLoader.LoadDeferredBody(node); + var enclosingUsesBlock = _usesBlock; + var enclosingZSuperArguments = _zsuperArguments; + _scopes.Push(scope); + _usesBlock = false; + _zsuperArguments = zsuperArguments; + try { + var body = DefinitionBody(bodyNode, span, prologue); + // the skim has to have seen what mapping the body sees + System.Diagnostics.Debug.Assert(_usesBlock == ((node.BodyContents & PrismLoader.ContentsUsesBlock) != 0)); + return body; + } finally { + _scopes.Pop(); + _usesBlock = enclosingUsesBlock; + _zsuperArguments = enclosingZSuperArguments; + } + } + } + /// /// MRI rejects a yield whose nearest enclosing method-or-class body is a class, module or /// singleton class body, or the top level. Blocks and lambdas are transparent. diff --git a/Src/Prism/PrismLoader.cs b/Src/Prism/PrismLoader.cs index be3ca656..9ae8546c 100644 --- a/Src/Prism/PrismLoader.cs +++ b/Src/Prism/PrismLoader.cs @@ -42,15 +42,57 @@ public sealed partial class PrismLoader { private uint _cpoolBase; private string[] _constants; - private PrismLoader(byte[]/*!*/ buffer) { + // Lazy method bodies: a DefNode's body is skimmed rather than built (prism prefixes each + // DefNode with its length for this) and loaded from the buffer when it is asked for. + private readonly bool _lazyDefBodies; + + // What the body being skimmed holds, as Contents* bits. + private uint _contents; + + /// The method uses its block: a yield, or a super that passes the block on. + public const uint ContentsUsesBlock = 1; + + /// + /// Something the AST bridge reports while it maps it - a warning or an error that belongs to + /// parse time - so a body holding it is not put off: `defined?' (void-context warning), a + /// regexp literal that may draw a nested-quantifier warning, `class << x' (yield in it is an + /// error), class and module. + /// + public const uint ContentsEager = 2; + + private PrismLoader(byte[]/*!*/ buffer, bool lazyDefBodies) { _buffer = buffer; + _lazyDefBodies = lazyDefBodies; } public static PrismParseResult/*!*/ LoadParse(byte[]/*!*/ serialized) { - var loader = new PrismLoader(serialized); + return LoadParse(serialized, false); + } + + /// + /// With every DefNode comes back with a null Body and its + /// DeferredBodyLoader set: builds the body when it is needed. + /// + public static PrismParseResult/*!*/ LoadParse(byte[]/*!*/ serialized, bool lazyDefBodies) { + var loader = new PrismLoader(serialized, lazyDefBodies); return loader.Load(); } + /// + /// Builds the body a lazy load left in the buffer. Safe to call from any thread. + /// + public PmNode LoadDeferredBody(DefNode/*!*/ node) { + lock (this) { + int saved = _pos; + _pos = node.DeferredBodyOffset; + try { + return LoadOptionalNode(); + } finally { + _pos = saved; + } + } + } + private PrismParseResult/*!*/ Load() { if (_buffer.Length < 8 || _buffer[0] != 'P' || _buffer[1] != 'R' || _buffer[2] != 'I' || _buffer[3] != 'S' || _buffer[4] != 'M') { throw new InvalidOperationException("Invalid prism serialization (bad magic)"); @@ -202,6 +244,56 @@ private string LoadOptionalConstant() { return result; } + // ---- skimming (see SkimNode) ---- + + private void SkimOptionalNode() { + if (_buffer[_pos] == 0) { + _pos++; + return; + } + SkimNode(); + } + + private void SkimNodeList() { + uint count = LoadVarUInt(); + for (uint i = 0; i < count; i++) SkimNode(); + } + + private void SkipString() { + int length = (int)LoadVarUInt(); + _pos += length; + } + + private void SkipVarUIntList() { + uint count = LoadVarUInt(); + for (uint i = 0; i < count; i++) LoadVarUInt(); + } + + /// + /// A regexp literal's source. RegexpTransformer only warns about a quantifier applied directly + /// to another, which takes two quantifier characters in a row; extended mode lets whitespace or a + /// comment sit between them, and non-ASCII bytes may be whitespace, so any of those counts too. + /// + private void SkipRegexpSource() { + int length = (int)LoadVarUInt(); + int end = _pos + length; + for (int i = _pos; i < end; i++) { + byte c = _buffer[i]; + if (c == (byte)'#' || c == (byte)' ' || (c >= 9 && c <= 13) || c >= 0x80) { + _contents |= ContentsEager; + break; + } + if ((c == (byte)'?' || c == (byte)'*' || c == (byte)'+' || c == (byte)'}') && i + 1 < end) { + byte d = _buffer[i + 1]; + if (d == (byte)'?' || d == (byte)'*' || d == (byte)'+' || d == (byte)'{') { + _contents |= ContentsEager; + break; + } + } + } + _pos = end; + } + private BigInteger LoadInteger() { bool negative = LoadByte() != 0; uint words = LoadVarUInt(); diff --git a/Src/Prism/PrismParser.cs b/Src/Prism/PrismParser.cs index fdfb6c99..f46916af 100644 --- a/Src/Prism/PrismParser.cs +++ b/Src/Prism/PrismParser.cs @@ -57,11 +57,21 @@ public static class PrismParser { public static PrismParseResult/*!*/ Parse(string/*!*/ source, string path, int startLine, IList outerLocals, int frozenStringLiteral, Encoding/*!*/ sourceEncoding, string encodingName, byte commandLine, bool mainScript, bool encodingLocked) { + return Parse(source, path, startLine, outerLocals, frozenStringLiteral, sourceEncoding, encodingName, commandLine, + mainScript, encodingLocked, false); + } + + /// + /// leaves method bodies in prism's buffer (see PrismLoader.LoadParse). + /// + public static PrismParseResult/*!*/ Parse(string/*!*/ source, string path, int startLine, IList outerLocals, + int frozenStringLiteral, Encoding/*!*/ sourceEncoding, string encodingName, byte commandLine, bool mainScript, + bool encodingLocked, bool lazyDefBodies) { byte[] sourceBytes = sourceEncoding.GetBytes(source); PrismParseResult result = PrismLoader.LoadParse( ParseSerializedBytes(sourceBytes, BuildOptionsData(path, startLine, outerLocals, frozenStringLiteral, encodingName, - commandLine, mainScript, encodingLocked))); + commandLine, mainScript, encodingLocked)), lazyDefBodies); result.DataOffset = ComputeDataOffset(result.DataLocation, sourceBytes); return result; } diff --git a/Src/Prism/generate.rb b/Src/Prism/generate.rb index cca08842..5c0821fd 100644 --- a/Src/Prism/generate.rb +++ b/Src/Prism/generate.rb @@ -97,6 +97,16 @@ def field_name(node, field) (node["fields"] || []).each do |field| nodes_cs << " public #{cs_type(field)} #{field_name(node, field)};\n" end + if node.fetch("name") == "DefNode" + nodes_cs << <<-CS + // Set by a loader asked for lazy method bodies: Body is null and the body is still in the + // serialized buffer, for PrismLoader.LoadDeferredBody; BodyContents is what the body holds + // (PrismLoader.Contents* bits). + public IronRuby.Prism.PrismLoader DeferredBodyLoader; + public int DeferredBodyOffset; + public uint BodyContents; + CS + end nodes_cs << " }\n\n" end nodes_cs << "}\n" @@ -121,7 +131,34 @@ def field_name(node, field) CS nodes.each_with_index do |node, index| loader_cs << " case #{index + 1}: {\n" - loader_cs << " LoadRawUInt32();\n" if node.fetch("name") == "DefNode" + if node.fetch("name") == "DefNode" + # The body can be left in the buffer (lazy method bodies): everything else is read as usual. + fields = node["fields"] + body_index = fields.index { |f| f.fetch("name") == "body" } + loader_cs << " LoadRawUInt32();\n" + loader_cs << " var def = new DefNode {\n" + loader_cs << " NodeId = nodeId, StartOffset = location.Start, Length = location.Length,\n" + loader_cs << " Flags = LoadVarUInt(),\n" + fields[0...body_index].each do |field| + loader_cs << " #{field_name(node, field)} = #{cs_reader(field)},\n" + end + loader_cs << " };\n" + loader_cs << " if (_lazyDefBodies) {\n" + loader_cs << " def.DeferredBodyLoader = this;\n" + loader_cs << " def.DeferredBodyOffset = _pos;\n" + loader_cs << " _contents = 0;\n" + loader_cs << " SkimOptionalNode();\n" + loader_cs << " def.BodyContents = _contents;\n" + loader_cs << " } else {\n" + loader_cs << " def.Body = LoadOptionalNode();\n" + loader_cs << " }\n" + fields[(body_index + 1)..].each do |field| + loader_cs << " def.#{field_name(node, field)} = #{cs_reader(field)};\n" + end + loader_cs << " return def;\n" + loader_cs << " }\n" + next + end loader_cs << " return new #{node.fetch("name")} {\n" loader_cs << " NodeId = nodeId, StartOffset = location.Start, Length = location.Length,\n" loader_cs << " Flags = LoadVarUInt(),\n" @@ -131,6 +168,72 @@ def field_name(node, field) loader_cs << " };\n" loader_cs << " }\n" end +loader_cs << <<~CS + default: + throw new System.InvalidOperationException("Unknown prism node type: " + type); + } + } + + // Reads past a node without building it, noting in _contents what the bridge has to see + // at parse time (see PrismLoader.Contents*). Mirrors LoadNode field for field. + private void SkimNode() { + byte type = LoadByte(); + LoadVarUInt(); // node id + LoadLocation(); + switch (type) { +CS + +def cs_skimmer(field) + case field.fetch("type") + when "node" then "SkimNode();" + when "node?" then "SkimOptionalNode();" + when "node[]" then "SkimNodeList();" + when "string" then "SkipString();" + when "constant", "constant?", "uint32" then "LoadVarUInt();" + when "constant[]" then "SkipVarUIntList();" + when "location" then "LoadLocation();" + when "location?" then "LoadOptionalLocation();" + when "uint8" then "_pos++;" + when "integer" then "LoadInteger();" + when "double" then "_pos += 8;" + end +end + +# what the bridge must not put off: bridged later, these would warn (or fail) late +eager_nodes = %w[DefinedNode SingletonClassNode ClassNode ModuleNode] +nodes.each_with_index do |node, index| + name = node.fetch("name") + fields = node["fields"] || [] + loader_cs << " case #{index + 1}: {\n" + loader_cs << " _pos += 4;\n" if name == "DefNode" + loader_cs << " LoadVarUInt(); // flags\n" + loader_cs << " _contents |= ContentsEager;\n" if eager_nodes.include?(name) + loader_cs << " _contents |= ContentsUsesBlock;\n" if name == "YieldNode" + saved = false + fields.each do |field| + fname = field.fetch("name") + if name == "DefNode" && fname == "parameters" + # a nested method's use of its block is its own; what forces the outer one eager is not + loader_cs << " uint outer = _contents;\n" + loader_cs << " _contents = 0;\n" + saved = true + end + if (name == "SuperNode" || name == "ForwardingSuperNode") && fname == "block" + # super with no block of its own passes the method's block on + loader_cs << " if (_buffer[_pos] == 0) _contents |= ContentsUsesBlock;\n" + end + if (name == "RegularExpressionNode" || name == "MatchLastLineNode") && fname == "unescaped" + loader_cs << " SkipRegexpSource();\n" + next + end + loader_cs << " #{cs_skimmer(field)}\n" + if name == "DefNode" && fname == "body" + loader_cs << " _contents = outer | (_contents & ContentsEager);\n" + end + end + loader_cs << " return;\n" + loader_cs << " }\n" +end loader_cs << <<~CS default: throw new System.InvalidOperationException("Unknown prism node type: " + type); diff --git a/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs b/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs index e3112e38..3bff4a9e 100644 --- a/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs +++ b/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs @@ -79,6 +79,21 @@ public MethodDefinition(LexicalScope/*!*/ definedScope, Expression target, strin _parameters = parameters ?? Parameters.Empty; } + /// + /// A method whose body the front end builds when it is first asked for - normally when the method + /// is first called. Building it adds the body's locals to . + /// + public MethodDefinition(LexicalScope/*!*/ definedScope, Expression target, string/*!*/ name, Parameters parameters, + Func/*!*/ bodyFactory, SourceSpan location) + : base(definedScope, bodyFactory, location) { + Assert.NotNull(name); + Debug.Assert(parameters == null || parameters.Unsplat == null || parameters.Unsplat is LocalVariable); + + _target = target; + _name = name; + _parameters = parameters ?? Parameters.Empty; + } + private ScopeBuilder/*!*/ DefineLocals(out AstParameters/*!*/ parameters) { // Method signature: // , &, , , , * @@ -169,6 +184,9 @@ public MethodDefinition(LexicalScope/*!*/ definedScope, Expression target, strin string/*!*/ frameLabel, StrongBox definitionRefinements) { string encodedName = RubyStackTraceBuilder.EncodeMethodName(frameLabel, gen.SourcePath, Location, gen.DebugMode); + // a body built on demand adds its locals to the scope: build it before they are allocated + Body methodBody = Body; + AstParameters parameters; ScopeBuilder scope = DefineLocals(out parameters); @@ -251,7 +269,7 @@ public MethodDefinition(LexicalScope/*!*/ definedScope, Expression target, strin profileStart, _parameters.TransformOptionalsInitialization(gen), traceCall, - Body.TransformResult(gen, ResultOperation.Return) + methodBody.TransformResult(gen, ResultOperation.Return) ).Filter(unwinder = Ast.Parameter(typeof(Exception), "#u"), Methods.IsMethodUnwinderTargetFrame.OpCall(scopeVariable, unwinder), Ast.Return(gen.ReturnLabel, Methods.GetMethodUnwinderReturnValue.OpCall(unwinder)) ).Finally( diff --git a/Src/Ruby/Compiler/Ast/Expressions/DefinitionExpression.cs b/Src/Ruby/Compiler/Ast/Expressions/DefinitionExpression.cs index bd1aa699..a1c18c5a 100644 --- a/Src/Ruby/Compiler/Ast/Expressions/DefinitionExpression.cs +++ b/Src/Ruby/Compiler/Ast/Expressions/DefinitionExpression.cs @@ -20,7 +20,9 @@ namespace IronRuby.Compiler.Ast { public abstract class DefinitionExpression : Expression { private readonly LexicalScope/*!*/ _definedScope; - private readonly Body/*!*/ _body; + private volatile Body _body; + // A body the front end builds when it is first asked for (see MethodDefinition), or null. + private System.Func _bodyFactory; public LexicalScope/*!*/ DefinedScope { get { @@ -30,6 +32,18 @@ public LexicalScope/*!*/ DefinedScope { public Body/*!*/ Body { get { + return _body ?? BuildBody(); + } + } + + private Body/*!*/ BuildBody() { + lock (this) { + if (_body == null) { + var body = _bodyFactory(); + ContractUtils.RequiresNotNull(body, "body"); + _body = body; + _bodyFactory = null; + } return _body; } } @@ -42,5 +56,14 @@ protected DefinitionExpression(LexicalScope/*!*/ definedScope, Body/*!*/ body, S _definedScope = definedScope; _body = body; } + + protected DefinitionExpression(LexicalScope/*!*/ definedScope, System.Func/*!*/ bodyFactory, SourceSpan location) + : base(location) { + ContractUtils.RequiresNotNull(definedScope, "definedScope"); + ContractUtils.RequiresNotNull(bodyFactory, "bodyFactory"); + + _definedScope = definedScope; + _bodyFactory = bodyFactory; + } } } From a3319f97395c26e1cbb3795d441fed535e8ccff0 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:15:35 +0200 Subject: [PATCH 16/94] WIP: bind respond_to?(:literal) to a constant and send(:literal) as a direct call DispatchIntrinsics: the call binder folds Kernel#respond_to? with a symbol argument (answered by lookups alone, no refinements) into a constant rule guarded by the symbol and the receiver's class version, and binds send/__send__ with a symbol as the call of the named method (one dispatch instead of two). Per-class cap of 32 specialized rules each. Fold passes local semantics tests on Debug; send intrinsic not yet built or gated. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Calls/CallArguments.cs | 10 ++ Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs | 173 +++++++++++++++++++ Src/Ruby/Runtime/Calls/RubyCallAction.cs | 9 + 3 files changed, 192 insertions(+) create mode 100644 Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs diff --git a/Src/Ruby/Runtime/Calls/CallArguments.cs b/Src/Ruby/Runtime/Calls/CallArguments.cs index 5a533631..85f16894 100644 --- a/Src/Ruby/Runtime/Calls/CallArguments.cs +++ b/Src/Ruby/Runtime/Calls/CallArguments.cs @@ -311,6 +311,16 @@ public void InsertSimple(int index, DynamicMetaObject/*!*/ arg) { _signature = new RubyCallSignature(_signature.ArgumentCount + 1, _signature.Flags); } + /// + /// The call #send makes: the method name dropped, and visibility ignored as for a call with implicit self. + /// + internal void RemoveSendMethodName() { + _args = ArrayUtils.RemoveAt(_args, GetSimpleArgumentIndex(0)); + _copyArgsOnWrite = false; + _signature = new RubyCallSignature(_signature.ArgumentCount - 1, + (_signature.Flags & ~(RubyCallFlags.HasSourceBlock | RubyCallFlags.IsVariableCall)) | RubyCallFlags.HasImplicitSelf); + } + internal void InsertMethodName(string/*!*/ methodName) { // insert the method name argument into the args object symbol = RubyContext.EncodeIdentifier(methodName); diff --git a/Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs b/Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs new file mode 100644 index 00000000..30aaf38a --- /dev/null +++ b/Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs @@ -0,0 +1,173 @@ +/* **************************************************************************** + * + * Copyright (c) Microsoft Corporation. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ + +#if FEATURE_CORE_DLR +using System.Linq.Expressions; +#else +using Microsoft.Scripting.Ast; +#endif + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using IronRuby.Builtins; +using IronRuby.Compiler; +using Microsoft.Scripting.Runtime; +using AstUtils = Microsoft.Scripting.Ast.Utils; + +namespace IronRuby.Runtime.Calls { + using Ast = Expression; + + /// + /// Rules the call binder builds itself for calls of Kernel's dynamic-dispatch helpers. + /// + internal static class DispatchIntrinsics { + // The method infos last found to be Kernel#respond_to? and Kernel#respond_to_missing? (or aliases of them). + private static RubyMemberInfo _respondTo, _respondToMissing; + + // The method infos last found to be Kernel#send and BasicObject#__send__ (or aliases of them). + private static RubyMemberInfo _send, _basicSend; + + // Rules specialized on a name, per receiver class, so that a site sending or asking about computed + // names does not bind a rule per name: past the limit the class's sites get the general rules. + private sealed class FoldCounts { + internal int RespondTo; + internal int Send; + } + + private const int MaxFoldsPerClass = 32; + private static readonly ConditionalWeakTable/*!*/ _folds = new ConditionalWeakTable(); + + private static bool IsKernelMethod(RubyMemberInfo/*!*/ method, ref RubyMemberInfo last, string/*!*/ clrName) { + if (ReferenceEquals(method, last)) { + return true; + } + + var libraryMethod = method as RubyLibraryMethodInfo; + if (libraryMethod == null) { + return false; + } + + var members = libraryMethod.GetMembers(); + if (members.Length == 1 && members[0].Name == clrName && members[0].DeclaringType.FullName == "IronRuby.Builtins.KernelOps") { + last = method; + return true; + } + return false; + } + + /// + /// `obj.respond_to?(:name)' answered by method lookups alone - no refinements anywhere, the name's + /// method or Kernel#respond_to_missing? found - is a constant for as long as the receiver class's + /// method version stays the same, which the rule's receiver test already checks: the lookups are + /// registered for invalidation the way a call site's own resolution is (RubyModule.GetMethodLookup). + /// The symbol is part of the rule's test, so each literal site gets a rule of its own. + /// + internal static bool TryBuildRespondTo(MetaObjectBuilder/*!*/ metaBuilder, CallArguments/*!*/ args, RubyMemberInfo/*!*/ method) { + var signature = args.Signature; + if (signature.HasBlock || signature.HasSplattedArgument || signature.HasRhsArgument || signature.ResolveOnly || + args.SimpleArgumentCount != 1 || !IsKernelMethod(method, ref _respondTo, "RespondTo")) { + return false; + } + + // Only a Ruby object's rule tests its exact class; a CLR object's may cover an instance with singleton methods. + var symbol = args.GetSimpleArgument(0) as RubySymbol; + var targetClass = args.TargetClass; + if (symbol == null || !(args.Target is IRubyObject) || targetClass.Context.HasRefinements) { + return false; + } + + string name = symbol.ToString(); + int version; + var lookup = targetClass.GetMethodLookup(name, out version); + if (version < 0) { + return false; + } + + bool answer; + if (lookup.Found && lookup.Visibility == RubyMethodVisibility.Public) { + var libraryMethod = lookup.Info as RubyLibraryMethodInfo; + answer = libraryMethod == null || !libraryMethod.IsNotImplemented; + } else { + // a user-defined respond_to_missing? is called every time: + int missingVersion; + var missing = targetClass.GetMethodLookup(Symbols.RespondToMissing, out missingVersion); + if (missingVersion != version || missing.Found && !IsKernelMethod(missing.Info, ref _respondToMissing, "RespondToMissing")) { + return false; + } + answer = false; + } + + if (Interlocked.Increment(ref _folds.GetOrCreateValue(targetClass).RespondTo) > MaxFoldsPerClass) { + return false; + } + + metaBuilder.AddCondition(Ast.Equal(AstUtils.Convert(args.GetSimpleArgumentExpression(0), typeof(object)), Ast.Constant(symbol, typeof(object)))); + metaBuilder.Result = Ast.Constant(ScriptingRuntimeHelpers.BooleanToObject(answer), typeof(object)); + return true; + } + + private static bool IsSendMethod(RubyMemberInfo/*!*/ method) { + if (ReferenceEquals(method, _send) || ReferenceEquals(method, _basicSend)) { + return true; + } + + var libraryMethod = method as RubyLibraryMethodInfo; + if (libraryMethod == null) { + return false; + } + + var members = libraryMethod.GetMembers(); + if (members.Length == 0 || members[0].Name != "SendMessage") { + return false; + } + + switch (members[0].DeclaringType.FullName) { + case "IronRuby.Builtins.KernelOps": _send = method; return true; + case "IronRuby.Builtins.BasicObjectOps": _basicSend = method; return true; + default: return false; + } + } + + /// + /// `obj.send(:name, ...)' (or __send__) is bound as the call of name it makes - the call with implicit + /// self, scope and block that Kernel#send's own site makes - so that it costs one dispatch rather than + /// two, as MRI's optimized send does (no send frame appears in a backtrace there either). The symbol is + /// part of the rule's test; the caller binds the call of on the rewritten + /// . + /// + internal static bool TryBuildSend(MetaObjectBuilder/*!*/ metaBuilder, CallArguments/*!*/ args, RubyMemberInfo/*!*/ method, + out string sentName) { + + sentName = null; + var signature = args.Signature; + if (!signature.HasScope || signature.HasSplattedArgument || signature.HasRhsArgument || signature.ResolveOnly || + signature.IsVirtualCall || signature.IsSuperCall || signature.IsInteropCall || args.SimpleArgumentCount < 1 || + !IsSendMethod(method)) { + return false; + } + + var symbol = args.GetSimpleArgument(0) as RubySymbol; + if (symbol == null || Interlocked.Increment(ref _folds.GetOrCreateValue(args.TargetClass).Send) > MaxFoldsPerClass) { + return false; + } + + metaBuilder.AddCondition(Ast.Equal(AstUtils.Convert(args.GetSimpleArgumentExpression(0), typeof(object)), Ast.Constant(symbol, typeof(object)))); + args.RemoveSendMethodName(); + sentName = symbol.ToString(); + return true; + } + } +} diff --git a/Src/Ruby/Runtime/Calls/RubyCallAction.cs b/Src/Ruby/Runtime/Calls/RubyCallAction.cs index 07afd8c5..c92953b7 100644 --- a/Src/Ruby/Runtime/Calls/RubyCallAction.cs +++ b/Src/Ruby/Runtime/Calls/RubyCallAction.cs @@ -206,6 +206,15 @@ internal static bool BuildCall(MetaObjectBuilder/*!*/ metaBuilder, string/*!*/ m return true; } + if (DispatchIntrinsics.TryBuildRespondTo(metaBuilder, args, method.Info)) { + return true; + } + + string sentName; + if (defaultFallback && callClrMethods && DispatchIntrinsics.TryBuildSend(metaBuilder, args, method.Info, out sentName)) { + return BuildCall(metaBuilder, sentName, args, defaultFallback, callClrMethods); + } + method.Info.BuildCall(metaBuilder, args, methodName); // MRI 3.4+: a block passed to a method that never uses it may be ignored From 485297158853823bad1153bff4bd291481dc9fe0 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:15:55 +0200 Subject: [PATCH 17/94] WIP: prelude: alias keyword for alias_method, no guards on fresh Errno classes ruby4.rb runs at every startup and most of its cost is binding a call-site rule for each top-level call it makes. - The 169 `alias_method :a, :b` statements in class/module bodies become `alias :a :b`. Both end in RubyModule.AddMethodAlias on the same module; the keyword compiles to a direct call and binds nothing. Method tables after startup (every module's methods with owner, visibility, arity, parameters, original name) are identical with and without RubyGems. - The Errno table loop no longer asks the classes it has just made whether they have an Errno/Message constant of their own: a fresh class has none, and every one of them is a new receiver, so each question bound its own rule (~200 binds). Classes the C# library defined are still asked. ir -e 1 (Release, same binary, StdLib with/without this change, best/median of 7 interleaved in one bench-lock call): 2.924 / 3.005 s -> 2.829 / 2.931 s (spread 0.33 s); CPU 3.226 -> 3.034 s. Gates so far: sp-tests 0; spec/core/enumerable 536/0/0, exception 250/0/0, encoding 633/0/0 (= base); sp-lang pending. Co-Authored-By: Claude Opus 5.5 --- Src/StdLib/ironruby/ruby4.rb | 351 ++++++++++++++++++----------------- 1 file changed, 178 insertions(+), 173 deletions(-) diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index 40ab1c47..9a288daf 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -91,7 +91,7 @@ def yield_self(&block) return to_enum(:yield_self) { 1 } unless block yield self end unless method_defined?(:yield_self) - alias_method :then, :yield_self unless method_defined?(:then) + alias :then :yield_self unless method_defined?(:then) # Written in Ruby, as MRI's is (): its frame is reported with its caller's # location, and it has a source_location. @@ -298,7 +298,7 @@ def sum(init = 0) compensating ? acc + compensation : acc end unless method_defined?(:sum) - alias_method :filter, :select unless method_defined?(:filter) + alias :filter :select unless method_defined?(:filter) # A `yield` with several values is one element, packed into an Array; a yield # with none is nil. Everything below goes through this so that @@ -321,7 +321,7 @@ def each_entry(*args, &block) end unless method_defined?(:each_entry) unless method_defined?(:each_with_index_without_args) - alias_method :each_with_index_without_args, :each_with_index + alias :each_with_index_without_args :each_with_index def each_with_index(*args, &block) return to_enum(:each_with_index, *args) { size if respond_to?(:size) } unless block @@ -339,10 +339,10 @@ def each_with_index(*args, &block) # The built-ins only know the block form, and because they are defined on # Enumerable they shadow rather than extend, so wrap them. unless method_defined?(:all_without_pattern?) - alias_method :all_without_pattern?, :all? - alias_method :any_without_pattern?, :any? - alias_method :none_without_pattern?, :none? - alias_method :one_without_pattern?, :one? + alias :all_without_pattern? :all? + alias :any_without_pattern? :any? + alias :none_without_pattern? :none? + alias :one_without_pattern? :one? def __check_pattern_args__(args, block = nil) if args.size > 1 @@ -398,7 +398,7 @@ def one?(*args, &block) # inspecting the object rather than by naming its class - and takes at most # two arguments. A block alongside a method name is never called. unless method_defined?(:inject_without_name_check) - alias_method :inject_without_name_check, :inject + alias :inject_without_name_check :inject def inject(*args, &block) if args.size > 2 @@ -421,13 +421,13 @@ def inject(*args, &block) end inject_without_name_check(*args, &block) end - alias_method :reduce, :inject + alias :reduce :inject end # min/max also grew an `n` form. unless method_defined?(:min_without_count) - alias_method :min_without_count, :min - alias_method :max_without_count, :max + alias :min_without_count :min + alias :max_without_count :max def min(*args, &block) n = args.empty? ? nil : __count_arg__(args[0]) @@ -549,7 +549,7 @@ def flat_map end result end unless method_defined?(:flat_map) - alias_method :collect_concat, :flat_map unless method_defined?(:collect_concat) + alias :collect_concat :flat_map unless method_defined?(:collect_concat) def uniq seen = {} @@ -723,7 +723,7 @@ def chunk(&block) # #sort_by has to walk the receiver exactly once - the block may be expensive # or have side effects - which the decorate/sort/undecorate helper does. unless method_defined?(:sort_by_without_enumerator) - alias_method :sort_by_without_enumerator, :sort_by + alias :sort_by_without_enumerator :sort_by def sort_by(&block) return to_enum(:sort_by) { size if respond_to?(:size) } unless block @@ -734,8 +734,8 @@ def sort_by(&block) # Neither of these can know how long the answer will be without running the # block, so their Enumerators report no size even when the receiver has one. unless method_defined?(:take_while_without_enumerator) - alias_method :take_while_without_enumerator, :take_while - alias_method :drop_while_without_enumerator, :drop_while + alias :take_while_without_enumerator :take_while + alias :drop_while_without_enumerator :drop_while def take_while(&block) return to_enum(:take_while) { nil } unless block @@ -751,8 +751,8 @@ def drop_while(&block) # Both return the receiver when they are given a block (they returned nil), # and both reject a slice size that cannot produce any slice at all. unless method_defined?(:each_cons_without_self) - alias_method :each_cons_without_self, :each_cons - alias_method :each_slice_without_self, :each_slice + alias :each_cons_without_self :each_cons + alias :each_slice_without_self :each_slice def __slice_size__(n, message) size = n @@ -782,7 +782,7 @@ def each_slice(n, &block) # otherwise iterated with #each - a Range or a lazy Enumerator zips fine. # Only something that can do neither is an error. unless method_defined?(:zip_without_conversion) - alias_method :zip_without_conversion, :zip + alias :zip_without_conversion :zip def __zip_source__(other) return other if other.is_a?(Array) @@ -894,8 +894,8 @@ def __reverse_each_size__ # are defined on Range they hide Enumerable#first(n) rather than falling # through to it - so `(0..Float::INFINITY).first(3)` was an ArgumentError. unless method_defined?(:first_without_count) - alias_method :first_without_count, :first - alias_method :last_without_count, :last + alias :first_without_count :first + alias :last_without_count :last def first(*args) if args.empty? @@ -1323,10 +1323,10 @@ def __array_repeat__(count, sorted, &block) public - alias_method :filter, :select unless method_defined?(:filter) - alias_method :filter!, :select! if method_defined?(:select!) && !method_defined?(:filter!) - alias_method :append, :push unless method_defined?(:append) - alias_method :prepend, :unshift unless method_defined?(:prepend) + alias :filter :select unless method_defined?(:filter) + alias :filter! :select! if method_defined?(:select!) && !method_defined?(:filter!) + alias :append :push unless method_defined?(:append) + alias :prepend :unshift unless method_defined?(:prepend) end class Hash @@ -1432,7 +1432,7 @@ def compact # method_defined? is true and Hash would be left returning the wrong class. end unless instance_methods(false).include?(:compact) - alias_method :filter, :select unless method_defined?(:filter) + alias :filter :select unless method_defined?(:filter) end class String @@ -1506,7 +1506,7 @@ module Math class DomainError < StandardError; end unless const_defined?(:DomainError) class << self - alias_method :__ir_frexp__, :frexp + alias :__ir_frexp__ :frexp # frexp answered the magnitude for a negative argument - Math.frexp(-0.5) # came back [0.5, 0] where MRI says [-0.5, 0] - and 0.0 came back with the @@ -1653,7 +1653,7 @@ class IO # Keyword arguments and a Hash in the last positional slot are different things, # and only a Ruby-level signature can tell them apart: the library method behind # this one takes options as an ordinary parameter and so sees them as the same. - alias_method :__ir_initialize__, :initialize + alias :__ir_initialize__ :initialize private :__ir_initialize__ def initialize(*args, **opts) @@ -1665,7 +1665,7 @@ def initialize(*args, **opts) end class << self - alias_method :__ir_new__, :new + alias :__ir_new__ :new # IO.open is the form that takes a block; IO.new ignores one and says so. def new(*args, **opts, &block) @@ -1678,7 +1678,7 @@ def new(*args, **opts, &block) end class File - alias_method :__ir_initialize__, :initialize + alias :__ir_initialize__ :initialize private :__ir_initialize__ # File takes a permission argument between the mode and the options. @@ -1819,7 +1819,7 @@ def self.binread(name, length = nil, offset = 0) # they return :wait_readable / :wait_writable / nil. net/protocol drives its # read buffer through this form. unless method_defined?(:__read_nonblock_raising__) - alias_method :__read_nonblock_raising__, :read_nonblock + alias :__read_nonblock_raising__ :read_nonblock def read_nonblock(len, buf = nil, exception: true) unless exception == true || exception == false @@ -1846,7 +1846,7 @@ def read_nonblock(len, buf = nil, exception: true) end unless method_defined?(:__write_nonblock_raising__) - alias_method :__write_nonblock_raising__, :write_nonblock + alias :__write_nonblock_raising__ :write_nonblock def write_nonblock(buf, exception: true) unless exception == true || exception == false @@ -2132,7 +2132,7 @@ def world_readable? class Array # 1.9 added the count form of shift; the bundled core only has the no-arg one unless (begin; [1].shift(1); true; rescue ArgumentError; false; end) - alias_method :shift_without_count, :shift + alias :shift_without_count :shift def shift(*args) if args.size > 1 @@ -2170,7 +2170,7 @@ def shift(*args) class Module # (method_defined? and its public_/private_/protected_ forms take `inherit' natively.) unless (begin; Module.const_defined?(:Module, true); true; rescue ArgumentError; false; end) - alias_method :const_defined_without_inherit?, :const_defined? + alias :const_defined_without_inherit? :const_defined? def const_defined?(name, inherit = true) return const_defined_without_inherit?(name) if inherit @@ -2179,7 +2179,7 @@ def const_defined?(name, inherit = true) end unless (begin; Module.const_get(:Module, true); true; rescue ArgumentError; false; end) - alias_method :const_get_without_inherit, :const_get + alias :const_get_without_inherit :const_get def const_get(name, inherit = true) return const_get_without_inherit(name) if inherit @@ -2358,7 +2358,7 @@ def $LOAD_PATH.resolve_feature_path(feature) # argument-less #clone and refuse clone(freeze: false). module Kernel unless (begin; Object.new.clone(:freeze => nil); true; rescue ArgumentError; false; end) - alias_method :clone_without_options, :clone + alias :clone_without_options :clone # `clone(freeze: nil)` is the default (copy the frozen state), `freeze: true` # always freezes, `freeze: false` never does. The runtime's #clone always @@ -2588,7 +2588,7 @@ def inspect @location ? "#" : "#" end - alias_method :to_s, :inspect + alias :to_s :inspect def storage unless Fiber.current.equal?(self) @@ -2912,7 +2912,7 @@ def __opts_exception__(value) # String is parsed, and anything else is asked for #to_int, then #to_i, with # #to_str consulted first when the object offers one. if private_method_defined?(:Integer) || method_defined?(:Integer) - alias_method :__ir_Integer__, :Integer + alias :__ir_Integer__ :Integer private :__ir_Integer__ def Integer(arg, base = nil, exception: true) @@ -2973,7 +2973,7 @@ def Integer(arg, base = nil, exception: true) end if private_method_defined?(:Float) || method_defined?(:Float) - alias_method :__ir_Float__, :Float + alias :__ir_Float__ :Float private :__ir_Float__ def Float(arg, exception: true) @@ -3038,7 +3038,7 @@ def Rational(*args, exception: true) # process for ever - spec/core/kernel/sleep_spec.rb stops dead there. module Kernel if private_method_defined?(:sleep) || method_defined?(:sleep) - alias_method :__ir_sleep__, :sleep + alias :__ir_sleep__ :sleep private :__ir_sleep__ def sleep(*args) @@ -3082,7 +3082,7 @@ class Integer # ordinary code use. class Float unless instance_method(:round).arity == -1 - alias_method :__ir_round__, :round + alias :__ir_round__ :round def round(ndigits = 0) n = ndigits.to_int @@ -3128,7 +3128,7 @@ def truncate(ndigits = 0) class Integer unless instance_method(:round).arity == -1 - alias_method :__ir_round__, :round + alias :__ir_round__ :round def round(ndigits = 0) n = ndigits.to_int @@ -3185,7 +3185,7 @@ def div(other) def modulo(other) self - other * div(other) end - alias_method :%, :modulo + alias :% :modulo def divmod(other) [div(other), modulo(other)] @@ -3223,8 +3223,8 @@ def quo(other) def arg self < 0 ? ::Math::PI : 0 end - alias_method :angle, :arg - alias_method :phase, :arg + alias :angle :arg + alias :phase :arg # A number is already what a copy of it would be. def clone(freeze: nil) @@ -3246,8 +3246,8 @@ def arg return self if nan? (self < 0 || (zero? && (1.0 / self) < 0)) ? ::Math::PI : 0 end - alias_method :angle, :arg - alias_method :phase, :arg + alias :angle :arg + alias :phase :arg end if defined?(Rational) && Rational.instance_method(:round).arity == 0 @@ -3420,7 +3420,7 @@ module Kernel # The built-in warn takes exactly one message and no keywords; 2.5 added # multiple messages plus uplevel:, and 3.0 added category:. if private_method_defined?(:warn) && instance_method(:warn).arity == 1 - alias_method :__ir_warn__, :warn + alias :__ir_warn__ :warn private :__ir_warn__ def warn(*messages, **options) @@ -4067,9 +4067,9 @@ def __selected__(ch, selectors) end private :__selected__ - alias_method :__ir_strip__, :strip - alias_method :__ir_lstrip__, :lstrip - alias_method :__ir_rstrip__, :rstrip + alias :__ir_strip__ :strip + alias :__ir_lstrip__ :lstrip + alias :__ir_rstrip__ :rstrip private :__ir_strip__, :__ir_lstrip__, :__ir_rstrip__ # #count is asked once per character, so the selectors are converted up front: @@ -4320,19 +4320,19 @@ def fdiv(other) to_f / other end unless method_defined?(:fdiv) - alias_method :magnitude, :abs unless method_defined?(:magnitude) + alias :magnitude :abs unless method_defined?(:magnitude) # #imag and #imaginary are one method in MRI, and so are Float#quo and Float#fdiv. - alias_method :imaginary, :imag if method_defined?(:imag) + alias :imaginary :imag if method_defined?(:imag) end class Float - alias_method :quo, :fdiv + alias :quo :fdiv end class Rational - alias_method :magnitude, :abs - alias_method :quo, :/ + alias :magnitude :abs + alias :quo :/ end class Float @@ -4438,12 +4438,12 @@ def real def imaginary 0 end unless method_defined?(:imaginary) - alias_method :imag, :imaginary unless method_defined?(:imag) + alias :imag :imaginary unless method_defined?(:imag) def conjugate self end unless method_defined?(:conjugate) - alias_method :conj, :conjugate unless method_defined?(:conj) + alias :conj :conjugate unless method_defined?(:conj) def abs2 self * self @@ -4452,13 +4452,13 @@ def abs2 def rectangular [self, 0] end unless method_defined?(:rectangular) - alias_method :rect, :rectangular unless method_defined?(:rect) + alias :rect :rectangular unless method_defined?(:rect) def arg self < 0 ? Math::PI : 0 end unless method_defined?(:arg) - alias_method :angle, :arg unless method_defined?(:angle) - alias_method :phase, :arg unless method_defined?(:phase) + alias :angle :arg unless method_defined?(:angle) + alias :phase :arg unless method_defined?(:phase) def polar [abs, arg] @@ -4528,7 +4528,7 @@ def inspect # One method under two names, which is what `Complex.instance_method(:imag) == # Complex.instance_method(:imaginary)` compares - a second method that called the # first answered the same number but was not the same method. - alias_method :imaginary, :imag + alias :imaginary :imag def finite? real.finite? && imag.finite? @@ -4541,7 +4541,7 @@ def infinite? def rectangular [real, imag] end - alias_method :rect, :rectangular + alias :rect :rectangular end # The rest of Complex, on top of 1.8's complex.rb. @@ -4605,7 +4605,7 @@ def self.rectangular(real, imag = 0) __canon__(real, imag) end class << self - alias_method :rect, :rectangular + alias :rect :rectangular end def self.polar(abs, arg = 0) @@ -4686,7 +4686,7 @@ def /(other) __coerce_bin__(other, :quo) end end - alias_method :quo, :/ + alias :quo :/ def fdiv(other) if other.is_a?(::Complex) @@ -4766,7 +4766,7 @@ def abs ::Math.hypot(r, i) end end - alias_method :magnitude, :abs + alias :magnitude :abs def abs2 real * real + imag * imag @@ -4775,8 +4775,8 @@ def abs2 def arg ::Math.atan2(imag, real) end - alias_method :angle, :arg - alias_method :phase, :arg + alias :angle :arg + alias :phase :arg def polar [abs, arg] @@ -4785,7 +4785,7 @@ def polar def conjugate ::Complex.__raw__(real, -imag) end - alias_method :conj, :conjugate + alias :conj :conjugate def ==(other) if other.is_a?(::Complex) @@ -5233,7 +5233,7 @@ def clone(freeze: nil) class Rational # rational18's Rational.reduce still builds instances behind our back, so the # freeze goes on #initialize rather than only on the constructors below. - alias_method :__ir_rational_init__, :initialize + alias :__ir_rational_init__ :initialize def initialize(num, den) __ir_rational_init__(num, den) freeze @@ -5582,7 +5582,7 @@ def each_child(path, *args, &block) end unless respond_to?(:__clr_foreach__, true) - alias_method :__clr_foreach__, :foreach + alias :__clr_foreach__ :foreach private :__clr_foreach__ def foreach(path, *args, &block) @@ -5603,7 +5603,7 @@ def empty?(path) # #each_child and #chdir, and an Enumerator from #each when no block is given. class Dir unless method_defined?(:__clr_each__) - alias_method :__clr_each__, :each + alias :__clr_each__ :each private :__clr_each__ def each(&block) @@ -5724,14 +5724,19 @@ module Errno ERRNO_TABLE.each do |errno, (name, message)| if const_defined?(name, false) klass = const_get(name) + # const_defined? without the second argument would find the enclosing Errno + # module through the lexical scope, so restrict the lookup to the class. + klass.const_set(:Errno, errno) unless klass.const_defined?(:Errno, false) + klass.const_set(:Message, message) unless klass.const_defined?(:Message, false) else klass = Class.new(SystemCallError) const_set(name, klass) + # A class made just now has no constants of its own, so there is nothing to ask it + # first - and asking is not free: every class is a new receiver for these call + # sites, so each question binds a rule of its own. + klass.const_set(:Errno, errno) + klass.const_set(:Message, message) end - # const_defined? without the second argument would find the enclosing Errno - # module through the lexical scope, so restrict the lookup to the class. - klass.const_set(:Errno, errno) unless klass.const_defined?(:Errno, false) - klass.const_set(:Message, message) unless klass.const_defined?(:Message, false) ERRNO_CLASSES[errno] ||= klass end @@ -6006,7 +6011,7 @@ def select!(&block) size == before ? nil : self end unless method_defined?(:select!) - alias_method :filter!, :select! unless method_defined?(:filter!) + alias :filter! :select! unless method_defined?(:filter!) def keep_if(&block) return ::Enumerator.new { |y| each { |x| y << x } } unless block @@ -6039,8 +6044,8 @@ def to_h # #uniq and #uniq! compare the block's result, not the element itself; the # core versions ignore the block entirely, so they are kept for the no-block # case and wrapped here. - alias_method :__uniq_without_block__, :uniq - alias_method :__uniq_in_place_without_block__, :uniq! + alias :__uniq_without_block__ :uniq + alias :__uniq_in_place_without_block__ :uniq! def uniq(&block) return __uniq_without_block__ unless block @@ -6071,7 +6076,7 @@ def uniq!(&block) # #zip takes anything that responds to #each, not just arrays: an Enumerator # (including an infinite one, which is why this reads lazily and stops at the # receiver's size) or any object with #each. - alias_method :__zip_arrays__, :zip + alias :__zip_arrays__ :zip def zip(*others, &block) length = size @@ -6251,7 +6256,7 @@ def self.start(full_mark: true, immediate_mark: true, immediate_sweep: true) end class << self - alias_method :garbage_collect, :start + alias :garbage_collect :start end def self.total_time @@ -6493,7 +6498,7 @@ def with_object(memo) end unless method_defined?(:with_object) # Same story as #each_with_index: Enumerable's version would shadow this one, # and the specs check that the two really are the same method. - alias_method :each_with_object, :with_object + alias :each_with_object :with_object # --- #size ----------------------------------------------------------- # MRI gives every enumerator a size function: an Integer, a Proc, or nil when @@ -6651,7 +6656,7 @@ def inspect detail += "(#{args.map { |a| a.inspect }.join(', ')})" unless args.empty? "#<#{self.class}: #{detail}>" end - alias_method :to_s, :inspect + alias :to_s :inspect # --- external iteration ------------------------------------------------ # #next has to suspend `each` half way through, which needs a coroutine. @@ -6834,14 +6839,14 @@ def inspect return "#<#{self.class}: uninitialized>" if @generator.nil? "#<#{self.class}: ...>" end - alias_method :to_s, :inspect + alias :to_s :inspect def map(&block) __need_block__("map", block) source = self __chain__(size) { |y| source.each { |*values| y << block.call(*values) } } end - alias_method :collect, :map + alias :collect :map def flat_map(&block) __need_block__("flat_map", block) @@ -6863,15 +6868,15 @@ def flat_map(&block) end end end - alias_method :collect_concat, :flat_map + alias :collect_concat :flat_map def select(&block) __need_block__("select", block) source = self __chain__ { |y| source.each { |*values| item = __value__(values); y << item if block.call(item) } } end - alias_method :filter, :select - alias_method :find_all, :select + alias :filter :select + alias :find_all :select def filter_map(&block) __need_block__("filter_map", block) @@ -6904,8 +6909,8 @@ def __ir_lazy_grep_impl__(pattern, invert, caller_frame, block) end private :__ir_lazy_grep_impl__ - alias_method :grep, :__ir_lazy_grep__ - alias_method :grep_v, :__ir_lazy_grep_v__ + alias :grep :__ir_lazy_grep__ + alias :grep_v :__ir_lazy_grep_v__ public :grep, :grep_v def compact @@ -7029,7 +7034,7 @@ def with_object(memo) source.each { |*values| y << [values.size <= 1 ? values[0] : values, memo] } end end - alias_method :each_with_object, :with_object + alias :each_with_object :with_object def zip(*others, &block) # Only the block form falls back to the eager Enumerable#zip. Everything @@ -7204,7 +7209,7 @@ def to_enum(method = :each, *args, &size_block) end end end - alias_method :enum_for, :to_enum + alias :enum_for :to_enum def first(n = nil) if n.nil? @@ -7313,7 +7318,7 @@ def inspect return "#" if @__enums__.nil? "#" end - alias_method :to_s, :inspect + alias :to_s :inspect def +(other) Chain.new(self, other) @@ -7409,7 +7414,7 @@ def inspect ::Thread.current[:__enumerator_product_inspect__] = nil if seen.empty? end end - alias_method :to_s, :inspect + alias :to_s :inspect def __check_frozen__ return unless frozen? @@ -7511,7 +7516,7 @@ module Kernel enum.__set_size__(size_block) if size_block enum end - alias_method :enum_for, :to_enum + alias :enum_for :to_enum # The C# Kernel#loop does not know about StopIteration, which is what makes # `loop { e.next }` terminate instead of blowing up. @@ -7563,7 +7568,7 @@ def unpack1(format, offset: 0) # #chars and #bytes build the Array in C# (MutableStringOps); only #lines is still # an Enumerator there. unless "a".lines.is_a?(Array) - alias_method :lines_enumerator, :lines + alias :lines_enumerator :lines def lines(*args, &block) return lines_enumerator(*args, &block) if block @@ -7582,7 +7587,7 @@ def lines(*args, &block) # splitter so encodings and the String-instances-for-subclasses rule keep # working; only the grouping and the trimming are redone. class String - alias_method :__ir_each_line__, :each_line + alias :__ir_each_line__ :each_line def each_line(*args, chomp: false, &block) lines = __ir_split_lines__(args, chomp) @@ -7694,7 +7699,7 @@ def __ir_chomp_line__(line, separator, chomp) class Hash # the core raises IndexError; Ruby 1.9 introduced KeyError for this unless (begin; {}.fetch(:missing); rescue KeyError; true; rescue IndexError; false; end) - alias_method :fetch_raising_index_error, :fetch + alias :fetch_raising_index_error :fetch def fetch(key, *default, &block) return fetch_raising_index_error(key, *default, &block) if !default.empty? || block @@ -7733,7 +7738,7 @@ def inspect stack.pop end end - alias_method :to_s, :inspect + alias :to_s :inspect end # Mutex/Queue/SizedQueue/ConditionVariable needed `require "thread"` in 1.9; they @@ -7762,7 +7767,7 @@ def match?(str, pos = 0) # three plain CLR methods added to MatchData.cs; everything else is built on # top of them and of #string, which is the subject the offsets refer to. class MatchData - alias_method :__ir_index__, :[] + alias :__ir_index__ :[] def [](*args) if args.size == 1 @@ -7807,7 +7812,7 @@ def named_captures(symbolize_names: false) # #deconstruct is not merely equivalent to #captures, it is the same method: # MatchData.instance_method(:deconstruct) == MatchData.instance_method(:captures). - alias_method :deconstruct, :captures unless method_defined?(:deconstruct) + alias :deconstruct :captures unless method_defined?(:deconstruct) def deconstruct_keys(keys) all = names @@ -7841,7 +7846,7 @@ def match_length(n) m && m.length end unless method_defined?(:match_length) - alias_method :__ir_values_at__, :values_at + alias :__ir_values_at__ :values_at # Ranges, names and Integers can be mixed. Array#values_at already has the # Range rules - nil fill past the end, RangeError for a start that is negative @@ -7947,12 +7952,12 @@ def -@ __ir_fstring__ end unless method_defined?(:-@) - alias_method :dedup, :-@ + alias :dedup :-@ # The C# #upto is Range#each in disguise, which loses the numeric-sequence # case ("8".upto("11")), the exclusive-end argument, the "stop is shorter than # self" cut-off and the type and encoding checks. - alias_method :__ir_upto__, :upto + alias :__ir_upto__ :upto private :__ir_upto__ def upto(stop, exclusive = false, &block) @@ -8162,7 +8167,7 @@ def self.__step_each__(from, to, unit, excl, &block) # Only Range#step, Range#% and Numeric#step make one: .new is undefined and .allocate refuses. class << self - alias_method :__allocate__, :allocate + alias :__allocate__ :allocate private :__allocate__ undef_method :new @@ -8222,8 +8227,8 @@ def to_a __arith_each__ { |v| result << v } result end - alias_method :entries, :to_a - alias_method :force, :to_a + alias :entries :to_a + alias :force :to_a def size return ::Float::INFINITY if @end.nil? && @step != 0 @@ -8240,7 +8245,7 @@ def ==(other) self.begin == other.begin && self.end == other.end && step == other.step && exclude_end? == other.exclude_end? end - alias_method :eql?, :== + alias :eql? :== def hash [self.begin, self.end, step, exclude_end?].hash @@ -8250,7 +8255,7 @@ def inspect return @inspect_str if @inspect_str "((#{@source.inspect}).%(#{@step.inspect}))" end - alias_method :to_s, :inspect + alias :to_s :inspect end end @@ -8303,7 +8308,7 @@ def new(*args, **kwargs, &block) instance end - alias_method :[], :new + alias :[] :new def inspect ::Module.instance_method(:to_s).bind(self).call @@ -8481,7 +8486,7 @@ def to_s stack.pop end end - alias_method :inspect, :to_s + alias :inspect :to_s private @@ -8580,7 +8585,7 @@ def filename __stream__ @current_path || "-" end - alias_method :path, :filename + alias :path :filename def to_io file @@ -8589,12 +8594,12 @@ def to_io def to_s "ARGF" end - alias_method :inspect, :to_s + alias :inspect :to_s def fileno file.fileno end - alias_method :to_i, :fileno + alias :to_i :fileno def binmode @binmode = true @@ -8633,7 +8638,7 @@ def eof? end s.nil? || s.eof? end - alias_method :eof, :eof? + alias :eof :eof? def gets(*args) loop do @@ -8666,7 +8671,7 @@ def each_line(*args) end self end - alias_method :each, :each_line + alias :each :each_line def readlines(*args) result = [] @@ -8675,7 +8680,7 @@ def readlines(*args) end result end - alias_method :to_a, :readlines + alias :to_a :readlines def read(length = nil, buffer = nil) result = +"" @@ -8728,19 +8733,19 @@ def each_char end self end - alias_method :chars, :each_char + alias :chars :each_char def each_byte return ::Enumerator.new { |y| each_byte { |b| y << b } } unless block_given? each_char { |c| c.each_byte { |b| yield b } } self end - alias_method :bytes, :each_byte + alias :bytes :each_byte def pos file.pos end - alias_method :tell, :pos + alias :tell :pos def pos=(value) file.pos = value @@ -8805,8 +8810,8 @@ def __env_check_name__(name) private :__env_check_name__ unless respond_to?(:__clr_store__, true) - alias_method :__clr_store__, :[]= - alias_method :__clr_fetch__, :[] + alias :__clr_store__ :[]= + alias :__clr_fetch__ :[] private :__clr_store__ private :__clr_fetch__ @@ -8823,7 +8828,7 @@ def []=(name, value) value end - alias_method :store, :[]= + alias :store :[]= end def [](name) @@ -8875,7 +8880,7 @@ def each(&block) to_hash.each(&block) self end - alias_method :each_pair, :each + alias :each_pair :each def each_key return to_enum(:each_key) { size } unless block_given? @@ -8915,9 +8920,9 @@ def select(&block) return to_enum(:select) { size } unless block to_hash.select(&block) end - alias_method :filter, :select + alias :filter :select - alias_method :member?, :include? + alias :member? :include? # Pairs are applied one at a time and in order, so a bad pair leaves everything # before it in place -- which is what MRI's own specs pin down. @@ -8934,7 +8939,7 @@ def merge!(*others) self end - alias_method :update, :merge! + alias :update :merge! # replace is all-or-nothing: everything is converted and checked before the first # variable is touched, so a TypeError halfway through leaves ENV untouched. @@ -8981,7 +8986,7 @@ def select! changed ? self : nil end unless respond_to?(:select!) - alias_method :filter!, :select! unless respond_to?(:filter!) + alias :filter! :select! unless respond_to?(:filter!) # MRI keeps the objects it was handed as the result keys, not their #to_str. def slice(*keys) @@ -9020,7 +9025,7 @@ def value?(value) to_hash.each_value { |v| return true if v == value } false end - alias_method :has_value?, :value? + alias :has_value? :value? def to_h(&block) to_hash.to_h(&block) @@ -9091,10 +9096,10 @@ def bind_call(receiver, *args, &block) module Math class DomainError < StandardError; end unless const_defined?(:DomainError, false) - alias_method :__ir_gamma__, :gamma - alias_method :__ir_lgamma__, :lgamma - alias_method :__ir_erf__, :erf - alias_method :__ir_erfc__, :erfc + alias :__ir_gamma__ :gamma + alias :__ir_lgamma__ :lgamma + alias :__ir_erf__ :erf + alias :__ir_erfc__ :erfc module_function :__ir_gamma__, :__ir_lgamma__, :__ir_erf__, :__ir_erfc__ # MRI's rb_to_float: Numeric only. An object that merely answers #to_f is a @@ -10023,7 +10028,7 @@ def self.__check__(result, message = nil) # The CLR half of kill hands back -errno rather than throwing, for the same reason. class << self unless method_defined?(:__clr_kill__) || private_method_defined?(:__clr_kill__) - alias_method :__clr_kill__, :kill + alias :__clr_kill__ :kill def kill(signal, *pids) Process.__check__(__clr_kill__(signal, *pids)) @@ -10236,8 +10241,8 @@ def self.wait2(pid = -1, flags = 0) # Not four methods that call one another: MRI defines two and aliases them, and a spec # compares Process.method(:waitpid) with Process.method(:wait). class << self - alias_method :waitpid, :wait - alias_method :waitpid2, :wait2 + alias :waitpid :wait + alias :waitpid2 :wait2 end def self.waitall @@ -10651,7 +10656,7 @@ module Kernel # includes every Float below 1 -- means "give me a Float" rather than an error. # The builtin only ever understood a positive Integer. unless private_method_defined?(:__ir_rand__) - alias_method :__ir_rand__, :rand + alias :__ir_rand__ :rand private :__ir_rand__ # rb_f_rand draws from the same default generator as Random.rand, which is @@ -10736,8 +10741,8 @@ def getkey(key) def key?(key) @table.key?(Key.new(key)) end - alias_method :member?, :key? - alias_method :include?, :key? + alias :member? :key? + alias :include? :key? def clear @table.clear @@ -11291,7 +11296,7 @@ def get_string(offset = 0, length = nil, encoding = ::Encoding::BINARY) result.force_encoding(encoding) if result.respond_to?(:force_encoding) result end - alias_method :to_str, :get_string + alias :to_str :get_string # The write goes into the allocation in place, so a String-backed buffer and # every slice sharing the allocation see it. @@ -11545,7 +11550,7 @@ def ~ # Same for IO.read: the text form is tagged with the encoding asked for, # the length form is bytes. IO.binread stays binary, which is its whole job. class << self - alias_method :__ir_class_read__, :read + alias :__ir_class_read__ :read private :__ir_class_read__ # IO.read opens the file itself, so mode:, encoding: and :open_args all get @@ -11608,7 +11613,7 @@ def read(name, *args, **options) # read with no length answers text in the stream's external encoding; read # with a length answers bytes, and is ASCII-8BIT. The C# read always handed # back ASCII-8BIT, so File.read(path, encoding: "utf-8") came out binary. - alias_method :__ir_read__, :read + alias :__ir_read__ :read private :__ir_read__ def read(*args) @@ -11630,7 +11635,7 @@ def read(*args) # set_encoding takes "external:internal" in one string as well as the two # separately; the C# one only understood a single encoding name and answered # "unknown encoding name - utf-8:ISO-8859-1". - alias_method :__ir_set_encoding__, :set_encoding + alias :__ir_set_encoding__ :set_encoding def set_encoding(*args, **opts) args = args + [opts] unless opts.empty? @@ -11729,7 +11734,7 @@ def __readable_stream__? # a socket, the standard streams. On this platform it has nothing to do but # say "these are bytes", so record that and set the external encoding, rather # than throwing a CLR exception at anything that is not a File. - alias_method :__ir_binmode__, :binmode + alias :__ir_binmode__ :binmode def binmode ::Kernel.raise(::IOError, "closed stream") if closed? @@ -11826,7 +11831,7 @@ def __encode_for_write__(text) end private :__encode_for_write__ - alias_method :__ir_write__, :write + alias :__ir_write__ :write def write(*args) total = 0 @@ -11885,7 +11890,7 @@ def __chomp_line__(line, args) # #gets and #readline do chomp: and the transcoding in the built-ins (IoOps.cs): # they set $_ for their caller, and a Ruby wrapper would set its own instead. - alias_method :__ir_readlines__, :readlines + alias :__ir_readlines__ :readlines def readlines(*args, **opts) args, chomp = __take_chomp__(args, opts) @@ -11893,7 +11898,7 @@ def readlines(*args, **opts) chomp ? lines.map { |l| __chomp_line__(l, args) } : lines end - alias_method :__ir_each_line__, :each_line + alias :__ir_each_line__ :each_line def each_line(*args, **opts, &block) args, chomp = __take_chomp__(args, opts) @@ -11909,12 +11914,12 @@ def each_line(*args, **opts, &block) # #each is #each_line under another name, so it is aliased rather than defined to # call it: the two are one method in MRI and a spec compares them. if method_defined?(:each) - alias_method :__ir_each__, :each - alias_method :each, :each_line + alias :__ir_each__ :each + alias :each :each_line end class << self - alias_method :__ir_class_readlines__, :readlines + alias :__ir_class_readlines__ :readlines # IO.readlines and IO.foreach are the instance methods on a stream opened for # the occasion, which is how the mode: and encoding options in the trailing @@ -11983,7 +11988,7 @@ class << self # pair, closes both afterwards and answers what the block answered. The # built-in took no arguments at all and gave the pair back from the block # form instead of the block's value. - alias_method :__ir_pipe__, :pipe + alias :__ir_pipe__ :pipe def pipe(*args) options = args.last.is_a?(::Hash) ? args.pop : nil @@ -12067,7 +12072,7 @@ def __ir_skip_bom__ # answered a one-character String since 1.9, reading as many bytes as the # character needs. if instance_method(:getc).arity == 0 - alias_method :__ir_getc__, :getc + alias :__ir_getc__ :getc private :__ir_getc__ def getc @@ -12100,14 +12105,14 @@ def __seek_whence__(whence) end private :__seek_whence__ - alias_method :__ir_seek__, :seek + alias :__ir_seek__ :seek private :__ir_seek__ def seek(amount, whence = 0) __ir_seek__(amount, __seek_whence__(whence)) end - alias_method :__ir_sysseek__, :sysseek + alias :__ir_sysseek__ :sysseek private :__ir_sysseek__ def sysseek(amount, whence = 0) @@ -12137,7 +12142,7 @@ def readbyte # ungetc takes a String as well as a codepoint, and a codepoint is a character # in the stream's external encoding rather than a single byte. The built-in only # ever pushed one byte back. - alias_method :__ir_ungetc__, :ungetc + alias :__ir_ungetc__ :ungetc private :__ir_ungetc__ def ungetc(value) @@ -12190,7 +12195,7 @@ def each_codepoint self end unless method_defined?(:each_codepoint) - alias_method :codepoints, :each_codepoint unless method_defined?(:codepoints) + alias :codepoints :each_codepoint unless method_defined?(:codepoints) # ---- descriptor flags --------------------------------------------------- # @@ -12216,7 +12221,7 @@ def close_on_exec? # because the built-in binmode throws on one. Fall back to the flag that # the binmode above records. if method_defined?(:binmode?) - alias_method :__ir_binmode_p__, :binmode? + alias :__ir_binmode_p__ :binmode? private :__ir_binmode_p__ end @@ -12277,7 +12282,7 @@ def path end end unless method_defined?(:path) - alias_method :to_path, :path unless method_defined?(:to_path) + alias :to_path :path unless method_defined?(:to_path) # Positional read/write, done by saving and restoring the file position since # there is no pread/pwrite underneath. @@ -12633,8 +12638,8 @@ def default_proc=(proc) end unless method_defined?(:default_proc=) # Hash#merge/#merge! take any number of hashes since 2.6 (zero included). - alias_method :__merge_one__, :merge - alias_method :__update_one__, :merge! + alias :__merge_one__ :merge + alias :__update_one__ :merge! def merge(*others, &block) result = dup @@ -12647,16 +12652,16 @@ def merge!(*others, &block) others.each { |other| __update_one__(other, &block) } self end - alias_method :update, :merge! + alias :update :merge! # CRuby's #filter/#filter! are genuine aliases of #select/#select!, and # Hash#select is not Enumerable#select, so re-alias them here. - alias_method :filter, :select - alias_method :filter!, :select! + alias :filter :select + alias :filter! :select! # #delete_if / #keep_if / #reject / #reject! return an Enumerator when called # without a block; the 1.9 core raised LocalJumpError instead. - alias_method :__delete_if_block__, :delete_if + alias :__delete_if_block__ :delete_if def delete_if(&block) raise FrozenError.new("can't modify frozen Hash: #{inspect}", receiver: self) if frozen? && block return to_enum(:delete_if) unless block @@ -12669,7 +12674,7 @@ def keep_if(&block) delete_if { |k, v| !block.call(k, v) } end - alias_method :__reject_bang_block__, :reject! + alias :__reject_bang_block__ :reject! def reject!(&block) return to_enum(:reject!) unless block raise FrozenError.new("can't modify frozen Hash: #{inspect}", receiver: self) if frozen? @@ -12974,7 +12979,7 @@ def values_at(*args) end # #each / #each_pair / #select return an Enumerator when no block is given. - alias_method :__struct_each__, :each + alias :__struct_each__ :each def each(&block) return to_enum(:each) unless block __struct_each__(&block) @@ -12989,13 +12994,13 @@ def each_pair(&block) self end - alias_method :deconstruct, :to_a - alias_method :filter, :select + alias :deconstruct :to_a + alias :filter :select end # Struct.new(..., keyword_init: true) (2.5) and StructClass#keyword_init? (3.1). class << Struct - alias_method :__struct_new__, :new + alias :__struct_new__ :new def new(*args, &block) keyword_init = nil @@ -13027,7 +13032,7 @@ def new(*args, &block) end class Struct - alias_method :__struct_initialize__, :initialize + alias :__struct_initialize__ :initialize def initialize(*args) # self.class.members, not members: a struct may have a member called "members" @@ -13094,7 +13099,7 @@ class Exception # locations either, which is MRI's answer as well. The one case where there are real # Locations to begin with is `raise Klass, message, caller_locations`: set_backtrace # was handed them, so they are kept as they are. - alias_method :__core_set_backtrace__, :set_backtrace + alias :__core_set_backtrace__ :set_backtrace def set_backtrace(value) if ::Array === value && !value.empty? && @@ -13318,7 +13323,7 @@ def include?(value) each { |x| return true if x == value } false end - alias_method :member?, :include? + alias :member? :include? def __linear__(x) x.is_a?(::Numeric) || x.is_a?(::Time) @@ -13395,7 +13400,7 @@ def __empty_range__(r) # and MRI searches those too - bisecting the interval rather than the # integers in it. if method_defined?(:bsearch) - alias_method :__ir_bsearch__, :bsearch + alias :__ir_bsearch__ :bsearch private :__ir_bsearch__ end @@ -13730,7 +13735,7 @@ def to_a raise RangeError, "cannot convert endless range to an array" if self.end.nil? super end - alias_method :entries, :to_a + alias :entries :to_a end # Rebind ARGF to a real instance so ARGF.class is a class. Done at the very end From 8c4df449dfb1c0ef044271f29f23af2ef94c66fd Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:15:55 +0200 Subject: [PATCH 18/94] WIP: prelude: set the Encoding constants from a recorded table The Encoding section derives some 270 constant names from Encoding.name_list with regexp matches, a gsub and two blocks per name - a tenth of a second of CPU at every start. The outcome depends only on the names the runtime knows and the constants Encoding starts with, so it is recorded (name=CONSTANT, in the order the loops set them) and replayed when both match exactly; any other runtime runs the original loops. Encoding.constants (order included) and their values are identical, and the fallback path gives the same result. ir -e 1 (same protocol): CPU 3.034 -> 2.959 s, wall within noise (2.834 / 2.901 -> 2.824 / 2.942 s). Cumulative with the previous commit, base StdLib -> this: ir -e 1 2.873 -> 2.687 s, ir --disable-gems -e 1 1.731 -> 1.627 s (best of 7). Gates so far: sp-tests 0; spec/core/encoding 633/0/0 (= base), enumerable 536/0/0, exception 250/0/0; sp-lang pending. Co-Authored-By: Claude Opus 5.5 --- Src/StdLib/ironruby/ruby4.rb | 162 ++++++++++++++++++++++++++++++----- 1 file changed, 139 insertions(+), 23 deletions(-) diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index 9a288daf..2e606843 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -2281,29 +2281,145 @@ class Encoding end end - begin - Encoding.name_list.each { |__n__| __add__.call(__n__) } - rescue StandardError - end - - # Names CRuby knows that are missing from (or spelled differently in) the - # runtime's own name list. Unsupported ones are silently skipped. - %w[ - ISO-8859-1 ISO-8859-2 ISO-8859-3 ISO-8859-4 ISO-8859-5 ISO-8859-6 - ISO-8859-7 ISO-8859-8 ISO-8859-9 ISO-8859-10 ISO-8859-11 ISO-8859-13 - ISO-8859-14 ISO-8859-15 ISO-8859-16 - Windows-31J Windows-874 - Windows-1250 Windows-1251 Windows-1252 Windows-1253 Windows-1254 - Windows-1255 Windows-1256 Windows-1257 Windows-1258 - CP437 CP737 CP775 CP850 CP852 CP855 CP857 CP860 CP861 CP862 CP863 - CP864 CP865 CP866 CP869 CP932 CP936 CP949 CP950 CP1252 - IBM437 IBM737 IBM775 IBM852 IBM857 IBM861 IBM862 IBM866 IBM869 - KOI8-R KOI8-U GB18030 GBK Big5 EUC-JP EUC-KR EUC-CN - macRoman macCentEuro macCroatian macCyrillic macGreek macIceland - macRomania macThai macTurkish macUkraine - ASCII-8BIT BINARY US-ASCII UTF-8 UTF-16 UTF-32 UTF-16BE UTF-16LE - UTF-32BE UTF-32LE Shift_JIS SJIS - ].each { |__n__| __add__.call(__n__) } + # What those loops make of the names depends on nothing but the names the runtime + # knows and the constants Encoding starts out with, yet working it out - a few + # regexp matches, a gsub and two blocks for each of some 300 names - is a tenth of a + # second of every start. So the outcome is written down here, as name=CONSTANT in + # the order the loops set them, for the runtime this file ships with; it was taken + # from the loops themselves. A runtime that knows other names, or starts out with + # other constants, takes the loops as before - a stale table costs time, nothing more. + __known__ = %i[ + ANSI_X3_4_1968 ASCII ASCII_8BIT Big5 BIG5 BINARY CESU_8 CP50221 CP874 Emacs_Mule EMACS_MULE + EUC_JP ISO_2022_JP ISO_8859_15 ISO_8859_9 ISO2022_JP ISO8859_15 ISO8859_9 KOI8_R Shift_JIS + SHIFT_JIS TIS_620 US_ASCII UTF_16 UTF_16BE UTF_16LE UTF_32 UTF_32BE UTF_32LE UTF_7 UTF_8 + Windows_874 WINDOWS_874 Converter CompatibilityError ConverterNotFoundError + InvalidByteSequenceError UndefinedConversionError + ] + __names__ = <<~'NAMES'.split + ASCII-8BIT UTF-8 US-ASCII Windows-31J EUC-JP UTF-16 UTF-32 UTF-16BE UTF-16LE UTF-32BE UTF-32LE + CESU-8 TIS-620 Emacs-Mule GB2312 EUC-KR GB18030 ISO-2022-JP CP50221 Shift_JIS UTF8-MAC CP51932 + eucJP-ms stateless-ISO-2022-JP ISO-2022-JP-2 GB12345 EUC-TW GB1988 ISO-8859-10 ISO-8859-14 + ISO-8859-16 IBM860 IBM861 ibm880 IBM862 IBM863 GBK IBM864 IBM865 IBM866 KOI8-U IBM037 IBM869 + ibm500 macIceland ibm01140 ibm01141 ibm01142 ibm273 ibm01143 ibm01144 ibm01145 Windows-1250 + ibm01146 Windows-1251 ibm01147 macRoman Windows-1252 IBM720 ibm277 ibm01148 MacJapanese + Windows-1253 IBM437 ibm278 ibm01149 x-mac-chinesetrad Windows-1254 Windows-1255 johab + Windows-1256 x-mac-arabic Windows-1257 x-mac-hebrew Windows-1258 macGreek macCyrillic ibm00924 + ISO-8859-2 ISO-8859-3 ISO-8859-4 ISO-8859-5 ISO-8859-6 ibm870 ISO-8859-7 ISO-8859-8 ISO-8859-9 + macTurkish macCroatian Windows-874 cp875 ibm420 CP949 ibm423 ibm424 ibm280 ibm01047 ibm284 + ibm285 macRomania CP20932 macUkraine x-europa IBM737 x-ia5 Big5 x-cp20936 x-ia5-german + x-ia5-swedish x-ia5-norwegian KOI8-R IBM775 ISO-8859-13 ibm290 ISO-8859-15 x-chinese-cns + asmo-708 ibm297 macThai x-cp20001 ibm905 x-chinese-eten x-ebcdic-koreanextended x-cp20003 + x-cp20004 x-cp20005 CP850 ibm-thai IBM852 ibm871 macCentEuro IBM855 cp1025 x-cp20949 IBM857 + ibm00858 x-cp20261 ibm1026 x-cp20269 ISO-8859-1 UTF-7 646 ASCII ANSI_X3.4-1968 BINARY CP437 + CP737 CP775 CP857 CP860 CP861 CP862 CP863 CP864 CP865 CP866 CP869 CP874 CP878 CP932 CP936 CP720 + EUC-CN eucCN MacJapan ebcdic-cp-us CP950 CP951 CP1258 CP1252 CP1250 CP1256 CP1251 CP1253 CP1255 + CP1254 CP1257 CP65000 CP65001 IBM850 eucJP eucKR eucTW ISO2022-JP ISO2022-JP2 ISO8859-1 + ISO8859-2 ISO8859-3 ISO8859-4 ISO8859-5 ISO8859-6 ISO8859-7 ISO8859-8 ISO8859-9 ISO8859-10 + ISO8859-11 ISO8859-13 ISO8859-14 ISO8859-15 ISO8859-16 SJIS PCK euc-jp-ms csWindows31J + UTF-8-MAC UTF-8-HFS UCS-2BE UCS-4BE UCS-4LE locale external filesystem + NAMES + __table__ = <<~'TABLE'.split + Windows-31J=Windows_31J Windows-31J=WINDOWS_31J GB2312=GB2312 EUC-KR=EUC_KR GB18030=GB18030 + UTF8-MAC=UTF8_MAC CP51932=CP51932 eucJP-ms=EucJP_ms eucJP-ms=EUCJP_MS + stateless-ISO-2022-JP=Stateless_ISO_2022_JP stateless-ISO-2022-JP=STATELESS_ISO_2022_JP + ISO-2022-JP-2=ISO_2022_JP_2 GB12345=GB12345 EUC-TW=EUC_TW GB1988=GB1988 ISO-8859-10=ISO_8859_10 + ISO-8859-14=ISO_8859_14 ISO-8859-16=ISO_8859_16 IBM860=IBM860 IBM861=IBM861 ibm880=Ibm880 + ibm880=IBM880 IBM862=IBM862 IBM863=IBM863 GBK=GBK IBM864=IBM864 IBM865=IBM865 IBM866=IBM866 + KOI8-U=KOI8_U IBM037=IBM037 IBM869=IBM869 ibm500=Ibm500 ibm500=IBM500 macIceland=MacIceland + macIceland=MACICELAND ibm01140=Ibm01140 ibm01140=IBM01140 ibm01141=Ibm01141 ibm01141=IBM01141 + ibm01142=Ibm01142 ibm01142=IBM01142 ibm273=Ibm273 ibm273=IBM273 ibm01143=Ibm01143 + ibm01143=IBM01143 ibm01144=Ibm01144 ibm01144=IBM01144 ibm01145=Ibm01145 ibm01145=IBM01145 + Windows-1250=Windows_1250 Windows-1250=WINDOWS_1250 ibm01146=Ibm01146 ibm01146=IBM01146 + Windows-1251=Windows_1251 Windows-1251=WINDOWS_1251 ibm01147=Ibm01147 ibm01147=IBM01147 + macRoman=MacRoman macRoman=MACROMAN Windows-1252=Windows_1252 Windows-1252=WINDOWS_1252 + IBM720=IBM720 ibm277=Ibm277 ibm277=IBM277 ibm01148=Ibm01148 ibm01148=IBM01148 + MacJapanese=MacJapanese MacJapanese=MACJAPANESE Windows-1253=Windows_1253 + Windows-1253=WINDOWS_1253 IBM437=IBM437 ibm278=Ibm278 ibm278=IBM278 ibm01149=Ibm01149 + ibm01149=IBM01149 x-mac-chinesetrad=X_mac_chinesetrad x-mac-chinesetrad=X_MAC_CHINESETRAD + Windows-1254=Windows_1254 Windows-1254=WINDOWS_1254 Windows-1255=Windows_1255 + Windows-1255=WINDOWS_1255 johab=Johab johab=JOHAB Windows-1256=Windows_1256 + Windows-1256=WINDOWS_1256 x-mac-arabic=X_mac_arabic x-mac-arabic=X_MAC_ARABIC + Windows-1257=Windows_1257 Windows-1257=WINDOWS_1257 x-mac-hebrew=X_mac_hebrew + x-mac-hebrew=X_MAC_HEBREW Windows-1258=Windows_1258 Windows-1258=WINDOWS_1258 macGreek=MacGreek + macGreek=MACGREEK macCyrillic=MacCyrillic macCyrillic=MACCYRILLIC ibm00924=Ibm00924 + ibm00924=IBM00924 ISO-8859-2=ISO_8859_2 ISO-8859-3=ISO_8859_3 ISO-8859-4=ISO_8859_4 + ISO-8859-5=ISO_8859_5 ISO-8859-6=ISO_8859_6 ibm870=Ibm870 ibm870=IBM870 ISO-8859-7=ISO_8859_7 + ISO-8859-8=ISO_8859_8 macTurkish=MacTurkish macTurkish=MACTURKISH macCroatian=MacCroatian + macCroatian=MACCROATIAN cp875=Cp875 cp875=CP875 ibm420=Ibm420 ibm420=IBM420 CP949=CP949 + ibm423=Ibm423 ibm423=IBM423 ibm424=Ibm424 ibm424=IBM424 ibm280=Ibm280 ibm280=IBM280 + ibm01047=Ibm01047 ibm01047=IBM01047 ibm284=Ibm284 ibm284=IBM284 ibm285=Ibm285 ibm285=IBM285 + macRomania=MacRomania macRomania=MACROMANIA CP20932=CP20932 macUkraine=MacUkraine + macUkraine=MACUKRAINE x-europa=X_europa x-europa=X_EUROPA IBM737=IBM737 x-ia5=X_ia5 x-ia5=X_IA5 + x-cp20936=X_cp20936 x-cp20936=X_CP20936 x-ia5-german=X_ia5_german x-ia5-german=X_IA5_GERMAN + x-ia5-swedish=X_ia5_swedish x-ia5-swedish=X_IA5_SWEDISH x-ia5-norwegian=X_ia5_norwegian + x-ia5-norwegian=X_IA5_NORWEGIAN IBM775=IBM775 ISO-8859-13=ISO_8859_13 ibm290=Ibm290 + ibm290=IBM290 x-chinese-cns=X_chinese_cns x-chinese-cns=X_CHINESE_CNS asmo-708=Asmo_708 + asmo-708=ASMO_708 ibm297=Ibm297 ibm297=IBM297 macThai=MacThai macThai=MACTHAI + x-cp20001=X_cp20001 x-cp20001=X_CP20001 ibm905=Ibm905 ibm905=IBM905 + x-chinese-eten=X_chinese_eten x-chinese-eten=X_CHINESE_ETEN + x-ebcdic-koreanextended=X_ebcdic_koreanextended x-ebcdic-koreanextended=X_EBCDIC_KOREANEXTENDED + x-cp20003=X_cp20003 x-cp20003=X_CP20003 x-cp20004=X_cp20004 x-cp20004=X_CP20004 + x-cp20005=X_cp20005 x-cp20005=X_CP20005 CP850=CP850 ibm-thai=Ibm_thai ibm-thai=IBM_THAI + IBM852=IBM852 ibm871=Ibm871 ibm871=IBM871 macCentEuro=MacCentEuro macCentEuro=MACCENTEURO + IBM855=IBM855 cp1025=Cp1025 cp1025=CP1025 x-cp20949=X_cp20949 x-cp20949=X_CP20949 IBM857=IBM857 + ibm00858=Ibm00858 ibm00858=IBM00858 x-cp20261=X_cp20261 x-cp20261=X_CP20261 ibm1026=Ibm1026 + ibm1026=IBM1026 x-cp20269=X_cp20269 x-cp20269=X_CP20269 ISO-8859-1=ISO_8859_1 CP437=CP437 + CP737=CP737 CP775=CP775 CP857=CP857 CP860=CP860 CP861=CP861 CP862=CP862 CP863=CP863 CP864=CP864 + CP865=CP865 CP866=CP866 CP869=CP869 CP878=CP878 CP932=CP932 CP936=CP936 CP720=CP720 + EUC-CN=EUC_CN eucCN=EucCN eucCN=EUCCN MacJapan=MacJapan MacJapan=MACJAPAN + ebcdic-cp-us=Ebcdic_cp_us ebcdic-cp-us=EBCDIC_CP_US CP950=CP950 CP951=CP951 CP1258=CP1258 + CP1252=CP1252 CP1250=CP1250 CP1256=CP1256 CP1251=CP1251 CP1253=CP1253 CP1255=CP1255 + CP1254=CP1254 CP1257=CP1257 CP65000=CP65000 CP65001=CP65001 IBM850=IBM850 eucJP=EucJP + eucJP=EUCJP eucKR=EucKR eucKR=EUCKR eucTW=EucTW eucTW=EUCTW ISO2022-JP2=ISO2022_JP2 + ISO8859-1=ISO8859_1 ISO8859-2=ISO8859_2 ISO8859-3=ISO8859_3 ISO8859-4=ISO8859_4 + ISO8859-5=ISO8859_5 ISO8859-6=ISO8859_6 ISO8859-7=ISO8859_7 ISO8859-8=ISO8859_8 + ISO8859-10=ISO8859_10 ISO8859-11=ISO8859_11 ISO8859-13=ISO8859_13 ISO8859-14=ISO8859_14 + ISO8859-16=ISO8859_16 SJIS=SJIS PCK=PCK euc-jp-ms=Euc_jp_ms euc-jp-ms=EUC_JP_MS + csWindows31J=CsWindows31J csWindows31J=CSWINDOWS31J UTF-8-MAC=UTF_8_MAC UTF-8-HFS=UTF_8_HFS + UCS-2BE=UCS_2BE UCS-4BE=UCS_4BE UCS-4LE=UCS_4LE locale=Locale locale=LOCALE external=External + external=EXTERNAL filesystem=Filesystem filesystem=FILESYSTEM ISO-8859-11=ISO_8859_11 + CP852=CP852 CP855=CP855 + TABLE + + if (begin; constants(false) == __known__ && Encoding.name_list == __names__; rescue StandardError; false; end) + __table__.each do |__entry__| + __name__, __c__ = __entry__.split("=", 2) + __enc__ = begin + Encoding.find(__name__) + rescue StandardError + nil + end + next unless __enc__.is_a?(Encoding) + begin + const_set(__c__, __enc__) + rescue StandardError + end + end + else + begin + Encoding.name_list.each { |__n__| __add__.call(__n__) } + rescue StandardError + end + + # Names CRuby knows that are missing from (or spelled differently in) the + # runtime's own name list. Unsupported ones are silently skipped. + %w[ + ISO-8859-1 ISO-8859-2 ISO-8859-3 ISO-8859-4 ISO-8859-5 ISO-8859-6 + ISO-8859-7 ISO-8859-8 ISO-8859-9 ISO-8859-10 ISO-8859-11 ISO-8859-13 + ISO-8859-14 ISO-8859-15 ISO-8859-16 + Windows-31J Windows-874 + Windows-1250 Windows-1251 Windows-1252 Windows-1253 Windows-1254 + Windows-1255 Windows-1256 Windows-1257 Windows-1258 + CP437 CP737 CP775 CP850 CP852 CP855 CP857 CP860 CP861 CP862 CP863 + CP864 CP865 CP866 CP869 CP932 CP936 CP949 CP950 CP1252 + IBM437 IBM737 IBM775 IBM852 IBM857 IBM861 IBM862 IBM866 IBM869 + KOI8-R KOI8-U GB18030 GBK Big5 EUC-JP EUC-KR EUC-CN + macRoman macCentEuro macCroatian macCyrillic macGreek macIceland + macRomania macThai macTurkish macUkraine + ASCII-8BIT BINARY US-ASCII UTF-8 UTF-16 UTF-32 UTF-16BE UTF-16LE + UTF-32BE UTF-32LE Shift_JIS SJIS + ].each { |__n__| __add__.call(__n__) } + end end # -------------------------------------------------------------------------- From 6852c9a11af25724599dc60ffa6c794a528b9831 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:23:52 +0200 Subject: [PATCH 19/94] WIP: point dispatchers at the compiled method body; keep line tracing out of line Gates so far: sp-build Debug/Release OK, sp-tests Debug failures=0, spec/core/method + spec/core/module + spec/core/unboundmethod + kernel caller/binding/local_variables/ __method__/send specs 1499 examples 0F/0E. spec/language run still to do (cancelled for the coordinator's pause). - RubyMethodBody: a method body's primary delegate is the light compiler's LightLambda, which stays in front of the IL it compiles after a few dozen calls - one more delegate hop and a cast helper (CHKCASTANY) on every call. Dispatchers made over the interpreted delegate are recorded and repointed at the compiled delegate when LightLambda.Compile fires; binds after that get the compiled delegate from GetDelegate. - RubyOps.TraceLineEvent: NoInlining. The JIT inlined its never-taken body into the TracePoint test in front of every statement: f1 in a method-call chain was 873 bytes of machine code, 486 now - which matters to 30k_methods' instruction cache. ruby-bench 30k_methods (with 3cac1835), best iteration, 2 runs each under bench-lock: base 5803ms -> 2885ms (base runs 5803/6337, mine 2885/2981). Reduced 1000-method chain (thread CPU time, best of 4): 6.80s -> 4.75s; call_empty -X:NoJIT 1.79s -> 1.41s. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Calls/MethodDispatcher.cs | 13 +++++ Src/Ruby/Runtime/Calls/RubyMethodBody.cs | 68 ++++++++++++++++++++++ Src/Ruby/Runtime/Calls/RubyMethodInfo.cs | 9 ++- Src/Ruby/Runtime/RubyOps.cs | 5 +- 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/Src/Ruby/Runtime/Calls/MethodDispatcher.cs b/Src/Ruby/Runtime/Calls/MethodDispatcher.cs index 9d31844f..1ca30a06 100644 --- a/Src/Ruby/Runtime/Calls/MethodDispatcher.cs +++ b/Src/Ruby/Runtime/Calls/MethodDispatcher.cs @@ -118,6 +118,13 @@ internal static MethodDispatcher CreateRubyObjectDispatcher(Type/*!*/ func, Dele } internal abstract void Initialize(Delegate/*!*/ method, int version); + + /// + /// Points the dispatcher at the compiled form of the body it was made for (see + /// RubyMethodBody.RegisterDispatcher). Either delegate runs the same body, so a call racing + /// with this may use either one. + /// + internal abstract void SetMethod(Delegate/*!*/ method); } public abstract class MethodDispatcher : MethodDispatcher { @@ -128,6 +135,12 @@ internal override void Initialize(Delegate/*!*/ method, int version) { Method = (TRubyFunc)(object)method; Version = version; } + + internal override void SetMethod(Delegate/*!*/ method) { + if (method is TRubyFunc typed) { + Method = typed; + } + } } public abstract class AttributeDispatcher : MemberDispatcher { diff --git a/Src/Ruby/Runtime/Calls/RubyMethodBody.cs b/Src/Ruby/Runtime/Calls/RubyMethodBody.cs index 65bfd1fa..99e77d1f 100644 --- a/Src/Ruby/Runtime/Calls/RubyMethodBody.cs +++ b/Src/Ruby/Runtime/Calls/RubyMethodBody.cs @@ -106,6 +106,7 @@ internal Delegate GetDelegate(RubyScope/*!*/ declaringScope, RubyModule/*!*/ dec _singletonLexicalModules = GetObjectSingletonLexicalModules(declaringScope); _delegateModule = declaringModule; _delegate = primary = Compile(declaringScope, declaringModule); + WatchLightCompilation(primary); } } } @@ -153,6 +154,70 @@ internal Delegate GetDelegate(RubyScope/*!*/ declaringScope, RubyModule/*!*/ dec return primary; } + #region Compiled-body forwarding + + // The primary delegate starts out as the light compiler's interpreted LightLambda, which goes + // on standing in front of the IL it compiles after a few dozen calls: every call through it is + // one more delegate hop plus a cast of the compiled delegate. Precompiled dispatchers made + // while the body was interpreted are recorded here and pointed at the compiled delegate when + // it arrives; later binds get it from GetDelegate. Rules already built keep the LightLambda, + // which forwards correctly - just not as cheaply. + private Delegate _interpretedDelegate; + private List _interpretedDispatchers; + private Delegate _compiledFrom, _compiledTo; + + private void WatchLightCompilation(Delegate/*!*/ primary) { + var light = primary.Target as Microsoft.Scripting.Interpreter.LightLambda; + if (light != null) { + _interpretedDelegate = primary; + light.Compile += (sender, e) => OnLightCompiled(primary, e.Compiled); + } + } + + private void OnLightCompiled(Delegate/*!*/ interpreted, Delegate/*!*/ compiled) { + List dispatchers; + lock (this) { + if (_delegate != interpreted || compiled.GetType() != interpreted.GetType()) { + return; + } + _delegate = compiled; + _interpretedDelegate = null; + _compiledFrom = interpreted; + _compiledTo = compiled; + dispatchers = _interpretedDispatchers; + _interpretedDispatchers = null; + } + if (dispatchers != null) { + foreach (var dispatcher in dispatchers) { + dispatcher.SetMethod(compiled); + } + } + } + + /// + /// Called for each dispatcher made over , a delegate GetDelegate returned. + /// + internal void RegisterDispatcher(MethodDispatcher/*!*/ dispatcher, Delegate/*!*/ method) { + Delegate compiled; + lock (this) { + if (method == _interpretedDelegate) { + if (_interpretedDispatchers == null) { + _interpretedDispatchers = new List(); + } + _interpretedDispatchers.Add(dispatcher); + return; + } + if (method != _compiledFrom) { + return; + } + compiled = _compiledTo; + } + // compiled between GetDelegate and here: + dispatcher.SetMethod(compiled); + } + + #endregion + private Delegate _moduleFunctionDelegate; // The refinements that were active where the body was last defined for a module, which is @@ -181,6 +246,9 @@ internal void SetDefinitionRefinements(RubyModule/*!*/ declaringModule, Refineme _singletonLexicalModules = null; _lexicalVariants = null; _moduleFunctionDelegate = null; + _interpretedDelegate = null; + _interpretedDispatchers = null; + _compiledFrom = _compiledTo = null; } GetDefinitionRefinementsNoLock(declaringModule).Value = refinements; } diff --git a/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs b/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs index c1970bfa..99e2bc26 100644 --- a/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs +++ b/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs @@ -217,9 +217,14 @@ internal override MemberDispatcher GetDispatcher(Type/*!*/ delegateType, RubyCal return null; } - return MethodDispatcher.CreateRubyObjectDispatcher( - delegateType, GetDelegate(), Parameters.Mandatory.Length, signature.HasScope, signature.HasBlock, version + var method = GetDelegate(); + var dispatcher = MethodDispatcher.CreateRubyObjectDispatcher( + delegateType, method, Parameters.Mandatory.Length, signature.HasScope, signature.HasBlock, version ); + if (dispatcher != null && !_isModuleFunctionCopy) { + _body.RegisterDispatcher(dispatcher, method); + } + return dispatcher; } internal override void BuildCallNoFlow(MetaObjectBuilder/*!*/ metaBuilder, CallArguments/*!*/ args, string/*!*/ name) { diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 33e68e2d..edcaad67 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -240,7 +240,10 @@ public static object MarkAliasCall(object lastArgument, string/*!*/ calleeName, // TracePoint hooks; emitted code only calls them when TracePoint.ActiveEvents has the event. - [Emitted] + // Emitted behind a test of TracePoint.ActiveEvents in front of every statement: kept out of + // line, the never-taken branch is one call; inlined, it was a few hundred bytes of machine + // code per statement in every compiled method body. + [Emitted, MethodImpl(MethodImplOptions.NoInlining)] public static void TraceLineEvent(RubyScope scope, string path, int line) { // objspace's allocation tracing forces this event on and takes the allocation site of // every object created by the statement from here. From b452d0673956dd32050c78af88370c6ba5ea6474 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:30:56 +0200 Subject: [PATCH 20/94] Share call-site rules across receiver classes that resolve to the same method A rule bound for a Ruby-object receiver tests the receiver's immediate class version against a constant, so every class - and every new version of one - needs a rule of its own: built as an expression tree, overload-resolved and light-compiled, although all of them call the same method. Class bodies are the worst case (private, attr_reader, alias_method, const_set, module_function, define_method... all receive a different singleton class), and so is any class whose version a method definition elsewhere has bumped. `ir -e 1` built 2326 rules, 1681 of them for a binder that had already bound one for a receiver of the same CLR type. Method versions are unique across classes and never reused, so a version identifies a class in one state. RubyCallAction now keeps, per binder, the methods it has bound rules for. When a second class (or version) resolves the call to the same library or Ruby method, the rule it gets tests the class version against a RuleVersionSet instead of a constant; every further class is resolved again under the hierarchy lock and, if it finds the same method, its version is added to the set and the existing rule is handed out - no rule built or compiled. Monomorphic sites keep the constant test. Only rules whose code depends on nothing but the method, the call signature and the arguments are shared (RubyLibraryMethodInfo, RubyMethodInfo; not protected methods, refinements, c_call tracing, super/virtual/interop calls); a shared rule that does not match a call (other argument types) is not handed out twice in a row, so the site gets a rule of its own. `ir -e 1`: 2326 rules built -> 1052 built + 88 shared + 719 reuses. Startup, Release, best of 7 in one bench-lock call (box load avg ~80, so base runs spread 5.9-8.2 s): `ir -e 1` 5856 ms -> 4974 ms (-15%); an earlier best of 6: 3379 -> 2819 ms (-17%). `--disable-gems -e 1` 2984 -> 2895 ms (within noise). Util/bench call/yield/send/respond_to/ method_missing/super_call subset, best of 3: no change beyond noise. spec/core module, class, basicobject, method, kernel: 3705 examples, 0 failures, as base. Gates: sp-tests 0, sp-lang 2934/0/0, ir -e 1 5.856s -> 4.974s (best of 7, load ~80) Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs | 23 +++ Src/Ruby/Runtime/Calls/RubyCallAction.cs | 172 +++++++++++++++++++- Src/Ruby/Runtime/Calls/RubyMetaBinder.cs | 11 ++ Src/Ruby/Runtime/Calls/RuleVersionSet.cs | 134 +++++++++++++++ 4 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 Src/Ruby/Runtime/Calls/RuleVersionSet.cs diff --git a/Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs b/Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs index 21fae0f2..71749ba6 100644 --- a/Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs +++ b/Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs @@ -92,6 +92,12 @@ public Expression Result { public ParameterExpression BfcVariable { get; set; } + /// + /// Set if the rule being built is to be shared by receivers of several classes (see ). + /// Taken by the first target type test of a method call. + /// + internal RuleVersionSet TargetVersionSet { get; set; } + /// /// A rule builder sets this up if the resulting rule is required to be wrapped in a non-local control flow handler. /// This delegate must be called exactly once ( method). @@ -246,6 +252,15 @@ public void AddCondition(Expression/*!*/ condition) { public void AddTargetTypeTest(object target, RubyClass/*!*/ targetClass, Expression/*!*/ targetParameter, DynamicMetaObject/*!*/ metaContext, IEnumerable/*!*/ resolvedNames) { + AddTargetTypeTest(target, targetClass, targetParameter, metaContext, resolvedNames, null); + } + + /// + /// If is given and the target is a Ruby object the rule tests the class version against the set + /// (and the current version is added to it) instead of against a constant: see . + /// + internal void AddTargetTypeTest(object target, RubyClass/*!*/ targetClass, Expression/*!*/ targetParameter, DynamicMetaObject/*!*/ metaContext, + IEnumerable/*!*/ resolvedNames, RuleVersionSet versionSet) { // no changes to the module's class hierarchy while building the test: targetClass.Context.RequiresClassHierarchyLock(); @@ -262,6 +277,14 @@ public void AddTargetTypeTest(object target, RubyClass/*!*/ targetClass, Express // Ruby objects (get the method directly to prevent interface dispatch): MethodInfo classGetter = type.GetMethod(Methods.IRubyObject_get_ImmediateClass.Name, BindingFlags.Public | BindingFlags.Instance); if (type.IsVisible && classGetter != null && classGetter.ReturnType == typeof(RubyClass)) { + if (versionSet != null && versionSet.TryAdd(targetClass.Version.Method)) { + // (#{type})target.ImmediateClass.Version.Method in #{versionSet} + AddCondition(versionSet.MakeTest( + Ast.Field(Ast.Field(Ast.Call(Ast.Convert(targetParameter, type), classGetter), Fields.RubyModule_Version), Fields.VersionHandle_Method) + )); + return; + } + AddCondition( // (#{type})target.ImmediateClass.Version.Method == #{immediateClass.Version.Method} Ast.Equal( diff --git a/Src/Ruby/Runtime/Calls/RubyCallAction.cs b/Src/Ruby/Runtime/Calls/RubyCallAction.cs index 07afd8c5..05a3a746 100644 --- a/Src/Ruby/Runtime/Calls/RubyCallAction.cs +++ b/Src/Ruby/Runtime/Calls/RubyCallAction.cs @@ -175,12 +175,174 @@ protected override object BindPrecompiled(Type/*!*/ delegateType, object[]/*!*/ #endregion + #region Shared Rules + + // A rule that serves receivers of several classes, or of one class in several states, that resolve the call to the same method. + // The first rule bound for a method is an ordinary one (tests a constant version); the second class (version) to resolve the call + // to that method gets a rule that tests the class version against a set (see RuleVersionSet), and every further one is added to + // the set once the call has been resolved for it again - with no rule built or compiled. + private sealed class SharedRule { + internal readonly RubyMemberInfo/*!*/ Member; + internal readonly Type/*!*/ TargetType; + internal readonly Type/*!*/ DelegateType; + internal readonly int FirstVersion; + + // null until the shared rule is built: + internal RuleVersionSet Versions; + internal volatile object Rule; + + internal SharedRule(RubyMemberInfo/*!*/ member, Type/*!*/ targetType, Type/*!*/ delegateType, int firstVersion) { + Member = member; + TargetType = targetType; + DelegateType = delegateType; + FirstVersion = firstVersion; + } + } + + private const int MaxSharedRules = 16; + + private const RubyCallFlags SharableCallFlags = RubyCallFlags.HasScope | RubyCallFlags.HasSplattedArgument | RubyCallFlags.HasRhsArgument | + RubyCallFlags.HasBlock | RubyCallFlags.HasImplicitSelf | RubyCallFlags.IsVariableCall | RubyCallFlags.HasSourceBlock; + + private static readonly object _SharedRulesLock = new object(); + private SharedRule[] _sharedRules; + + // Handed from BindSharedRule to Build of the same binder: + [ThreadStatic] + private static RuleVersionSet _PendingVersionSet; + [ThreadStatic] + private static RubyCallAction _PendingVersionSetOwner; + + // The shared rule last handed out on this thread. A site that comes right back for another rule didn't match it + // (its arguments are of other types, say) and gets a rule of its own. + [ThreadStatic] + private static SharedRule _LastSharedRule; + + internal override T BindShared(object[]/*!*/ args) { + var lastShared = _LastSharedRule; + _LastSharedRule = null; + + RubyContext context = Context; + if (context == null || Signature.ResolveOnly || (Signature.Flags & ~SharableCallFlags) != 0 || + context.HasRefinements || TracePoint.CCallTracing) { + return null; + } + + RubyScope scope; + object target; + if (Signature.HasScope) { + scope = (RubyScope)args[0]; + target = args[1]; + } else { + scope = context.EmptyScope; + target = args[0]; + } + + // The type test of a Ruby object is only a version check (see MetaObjectBuilder.AddTargetTypeTest): + if (!(target is IRubyObject)) { + return null; + } + + // an object of another runtime: + RubyClass targetClass = context.GetImmediateClassOf(target); + if (targetClass.Context != context) { + return null; + } + + int version; + MethodResolutionResult method; + using (context.ClassHierarchyLocker()) { + targetClass.InitializeMethodsNoLock(); + version = targetClass.Version.Method; + method = targetClass.ResolveMethodForSiteNoLock(_methodName, GetVisibilityContext(Signature, scope), MethodLookup.Default); + } + + // Rules calling these methods depend on nothing but the method, the call signature and the arguments. + // A protected method's rule also tests the caller's class. + if (!method.Found || method.Info.IsProtected || + method.Info.GetType() != typeof(RubyLibraryMethodInfo) && method.Info.GetType() != typeof(RubyMethodInfo)) { + return null; + } + + Type targetType = target.GetType(); + SharedRule shared = null; + var sharedRules = _sharedRules; + if (sharedRules != null) { + foreach (var rule in sharedRules) { + if (rule.Member == method.Info && rule.TargetType == targetType && rule.DelegateType == typeof(T)) { + shared = rule; + break; + } + } + } + + if (shared == null) { + lock (_SharedRulesLock) { + sharedRules = _sharedRules; + if (sharedRules == null || sharedRules.Length < MaxSharedRules) { + _sharedRules = ArrayUtils.Append(sharedRules ?? new SharedRule[0], new SharedRule(method.Info, targetType, typeof(T), version)); + } + } + return null; + } + + object sharedRule = shared.Rule; + if (sharedRule == null) { + // the first rule bound for the method didn't match for some other reason than its class version: + if (version == shared.FirstVersion) { + return null; + } + + return BindSharedRule(shared, args); + } + + if (lastShared == shared || !shared.Versions.TryAdd(version)) { + return null; + } + + _LastSharedRule = shared; + return (T)sharedRule; + } + + private T/*!*/ BindSharedRule(SharedRule/*!*/ shared, object[]/*!*/ args) where T : class { + var versionSet = new RuleVersionSet(); + T result; + + _PendingVersionSet = versionSet; + _PendingVersionSetOwner = this; + try { + result = this.LightBind(args, Context.Options.CompilationThreshold); + } finally { + _PendingVersionSet = null; + _PendingVersionSetOwner = null; + } + CacheTarget(result); + + // The rule tests the version set iff Build has taken it and bound it to a version the method was resolved for: + if (versionSet.Member == shared.Member) { + lock (_SharedRulesLock) { + if (shared.Rule == null) { + shared.Versions = versionSet; + shared.Rule = result; + } + } + } + return result; + } + + #endregion + private static bool RequiresUnusedBlockCheck(RubyMemberInfo/*!*/ method) { var rubyMethod = method as RubyMethodInfo; return rubyMethod != null && !rubyMethod.UsesBlock; } protected override bool Build(MetaObjectBuilder/*!*/ metaBuilder, CallArguments/*!*/ args, bool defaultFallback) { + if (_PendingVersionSetOwner == this) { + metaBuilder.TargetVersionSet = _PendingVersionSet; + _PendingVersionSet = null; + _PendingVersionSetOwner = null; + } return BuildCall(metaBuilder, _methodName, args, defaultFallback, true); } @@ -287,8 +449,11 @@ internal static MethodResolutionResult Resolve(MetaObjectBuilder/*!*/ metaBuilde var visibilityContext = GetVisibilityContext(args.Signature, args.Scope); RefinementActivation refinements = null; using (targetClass.Context.ClassHierarchyLocker()) { + var versionSet = metaBuilder.TargetVersionSet; + metaBuilder.TargetVersionSet = null; + metaBuilder.AddTargetTypeTest(args.Target, targetClass, args.TargetExpression, args.MetaContext, - new[] { methodName, Symbols.MethodMissing } + new[] { methodName, Symbols.MethodMissing }, versionSet ); // Refinements are lexically scoped, so resolution has to consult the scope the call site was @@ -312,6 +477,11 @@ internal static MethodResolutionResult Resolve(MetaObjectBuilder/*!*/ metaBuilde } else { methodMissing = null; } + + // the version the test was bound against resolves to this method: + if (versionSet != null && versionSet.Count > 0) { + versionSet.Member = method.Found ? method.Info : null; + } } // The rule is only valid while the caller's lexical activation is the one it was bound against. diff --git a/Src/Ruby/Runtime/Calls/RubyMetaBinder.cs b/Src/Ruby/Runtime/Calls/RubyMetaBinder.cs index 2c7860e5..bfade91c 100644 --- a/Src/Ruby/Runtime/Calls/RubyMetaBinder.cs +++ b/Src/Ruby/Runtime/Calls/RubyMetaBinder.cs @@ -102,6 +102,10 @@ public sealed override T BindDelegate(CallSite site, object[] args) { ), "BIND"); } #endif + if (argArray == null && (result = BindShared(args)) != null) { + return result; + } + result = this.LightBind(args, context.Options.CompilationThreshold); CacheTarget(result); return result; @@ -114,6 +118,13 @@ protected virtual object BindPrecompiled(Type/*!*/ delegateType, object[]/*!*/ a return null; } + /// + /// Returns a rule that is shared with receivers of other classes the call resolves the same for, or null (see ). + /// + internal virtual T BindShared(object[]/*!*/ args) where T : class { + return null; + } + public override DynamicMetaObject/*!*/ Bind(DynamicMetaObject/*!*/ scopeOrContextOrTargetOrArgArray, DynamicMetaObject/*!*/[]/*!*/ args) { PerfTrack.NoteEvent(PerfTrack.Categories.Binding, "Ruby: " + GetType().Name + Signature.ToString() + ": Bind"); diff --git a/Src/Ruby/Runtime/Calls/RuleVersionSet.cs b/Src/Ruby/Runtime/Calls/RuleVersionSet.cs new file mode 100644 index 00000000..a50943e2 --- /dev/null +++ b/Src/Ruby/Runtime/Calls/RuleVersionSet.cs @@ -0,0 +1,134 @@ +/* **************************************************************************** + * + * Copyright (c) Microsoft Corporation. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ + +using System.Linq.Expressions; + +using System; +using System.Reflection; +using System.Threading; +using Microsoft.Scripting.Interpreter; +using AstUtils = Microsoft.Scripting.Ast.Utils; + +namespace IronRuby.Runtime.Calls { + /// + /// The class versions () a shared call-site rule holds for. + /// + /// A rule bound for a receiver whose class is an is valid as long as the + /// receiver's immediate class has the version it was bound against. Method versions are unique + /// across all classes and never reused, so a version identifies a class in one particular state. + /// When receivers of many classes - or one class in many states - resolve a call to the same method, + /// one rule can serve all of them: its test checks the receiver's class version against this set, + /// and every version is added only after the method has been resolved again for that class + /// (see RubyCallAction.BindShared). This saves building and compiling a rule per class, which is + /// what calls in class bodies (private, attr_reader, alias_method, const_set...) and calls on + /// classes whose version was bumped by a method definition elsewhere would otherwise cost. + /// + public sealed class RuleVersionSet { + // open addressing, 0 marks an empty slot (method versions start at 1); + // readers see either the old or the new array, an insert writes a single slot: + private int[] _slots; + private int _count; + + // stop growing: further classes get rules of their own + private const int MaxCount = 4096; + + /// + /// The method the rule calls; set while the rule is built (under the class hierarchy lock). + /// + internal RubyMemberInfo Member; + + internal RuleVersionSet() { + _slots = new int[8]; + } + + internal int Count { + get { return _count; } + } + + public bool Contains(int version) { + int[] slots = Volatile.Read(ref _slots); + int mask = slots.Length - 1; + int i = version & mask; + while (true) { + int slot = slots[i]; + if (slot == version) { + return slot != 0; + } + if (slot == 0) { + return false; + } + i = (i + 1) & mask; + } + } + + /// + /// Adds a version the rule has been verified to hold for. Returns false if the set is full. + /// + internal bool TryAdd(int version) { + if (version == 0) { + return false; + } + + lock (this) { + if (Contains(version)) { + return true; + } + + if (_count >= MaxCount) { + return false; + } + + int[] slots = _slots; + if ((_count + 1) * 2 > slots.Length) { + var newSlots = new int[slots.Length * 2]; + foreach (int v in slots) { + if (v != 0) { + Insert(newSlots, v); + } + } + Insert(newSlots, version); + Volatile.Write(ref _slots, newSlots); + } else { + Insert(slots, version); + } + _count++; + return true; + } + } + + private static void Insert(int[]/*!*/ slots, int version) { + int mask = slots.Length - 1; + int i = version & mask; + while (slots[i] != 0) { + i = (i + 1) & mask; + } + slots[i] = version; + } + + public static bool IsValid(RuleVersionSet/*!*/ set, int version) { + return set.Contains(version); + } + + private static MethodInfo _IsValidMethod; + + /// + /// (#{versionExpression}) in #{set} + /// + internal Expression/*!*/ MakeTest(Expression/*!*/ versionExpression) { + var method = _IsValidMethod ?? (_IsValidMethod = CallInstruction.CacheFunc(IsValid)); + return Expression.Call(method, AstUtils.Constant(this), versionExpression); + } + } +} From 1e9f21789a7f288e11f2bc20b7621f945edeb0b0 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:33:12 +0200 Subject: [PATCH 21/94] Fiber: spinning C# mailbox, cheap Fiber.current; Enumerator#next over an Array without a fiber Every Fiber is a CLR thread here, so a resume/yield round trip is two thread handoffs. The handoff was Queue#push/#pop (Monitor wait + PulseAll) and every Fiber.current went through Thread.current[] (three locks, three dictionary lookups). Now: - Thread.__fiber_mailbox__/__fiber_post__/__fiber_take__ (ThreadOps.Fiber.cs): a one-slot C# FiberMailbox. Take spins briefly (adaptive per mailbox, capped at 40us, never on one CPU) before it falls back to a Monitor wait, so a quick round trip costs cache-line transfers instead of two kernel wake-ups. Take also does __set_fiber_owner__'s work with the owner's RubyThreadInfo cached. Thread.Interrupt (Thread#kill/#raise) wakes a blocked take as it woke Queue#pop; the parked exception is delivered outside the lock. - Fiber.current reads a [ThreadStatic] copy (per RubyContext) of Thread.current[:__ir_fiber_current__]; Thread.__fiber_current_set__ writes both, so the C# readers of the fiber-local key are unchanged. - Enumerator#next/#peek over the built-in Array#each (ary.each, ary.to_enum) walks the Array with a cursor (Enumerator#__ir_array_cursor__) instead of suspending #each in a fiber thread. Array#each runs no Ruby code between elements and re-reads the length before each, which the cursor mirrors, so growing/shrinking arrays, peek, feed, rewind, StopIteration#result (the Array) and dup (shared position) behave as before. Only taken when neither the enumerator's #each nor the Array's has been redefined, aliased, overridden or prepended. Driving it from another thread raises FiberError without abandoning the iteration, as MRI does. New benchmarks enum_next_array, enum_next_gen, fiber_new in Util/bench/bench.rb. A/B, one bench-lock call, 5 interleaved rounds, best (spread): fiber_switch 0.25 base 2.066s (2.07-2.62) -> 0.890s (0.89-2.76) enum_next_gen 0.2 base 0.770s (0.77-0.81) -> 0.580s (0.58-1.02) enum_next_array 0.2 base 1.505s (1.51-1.68) -> 0.193s (0.19-0.28) The spin pays when the two threads get CPUs at the same time; on a busy bench slot the mine runs spread up to the base numbers. Specs: spec/core/fiber, spec/core/enumerator, spec/core/thread, spec/library/prime, spec/library/zlib/gzipreader: 1160 examples, 1 failure, identical to base on the same run (thread/backtrace/location/label_spec.rb:61, "BasicObject#instance_exec" label, fails on base too in a full-dir run). Util/enum-matrix.rb output identical to base. Gates: sp-tests 0, sp-lang 2934/0/0, fiber_switch 2.066s -> 0.890s, enum_next_array 1.505s -> 0.193s Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/ThreadOps.Fiber.cs | 47 ++++++++++++----------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/Src/Libraries/Builtins/ThreadOps.Fiber.cs b/Src/Libraries/Builtins/ThreadOps.Fiber.cs index af21ec16..e05dd1d6 100644 --- a/Src/Libraries/Builtins/ThreadOps.Fiber.cs +++ b/Src/Libraries/Builtins/ThreadOps.Fiber.cs @@ -33,18 +33,18 @@ public static partial class ThreadOps { /// Taking spins for a few microseconds before it blocks. A resume / Fiber.yield round trip /// whose other side answers quickly - Enumerator#next over a generator, a fiber that yields /// in a loop - then costs two cache-line transfers instead of two kernel wake-ups, which is - /// most of what a switch used to cost. The spin budget adapts per mailbox: it doubles while - /// answers arrive in time and halves when one does not, so a fiber that is resumed rarely - /// soon stops burning a core on it. Single-core machines never spin. + /// most of what a switch used to cost. The spin budget adapts per mailbox: it goes back to + /// the cap whenever the message arrived shortly after the spin gave up, and halves when it + /// took long, so a fiber that is resumed rarely soon stops burning a core on it. Single-core + /// machines never spin. /// /// A blocked take is a Monitor wait, so Thread#kill / Thread#raise (delivered with /// Thread.Interrupt) wake it exactly as they wake Queue#pop, which the mailbox used to be. /// public sealed class FiberMailbox { - private const long MinSpinTicks = 0; - private static readonly long _maxSpinTicks = Stopwatch.Frequency / 20000; // 50us - private static readonly long _initialSpinTicks = Stopwatch.Frequency / 50000; // 20us - private static readonly long _spinStepTicks = Stopwatch.Frequency / 500000; // 2us + // How long a take spins before it blocks: between a step and the cap, adapted per mailbox. + private static readonly long _maxSpinTicks = Stopwatch.Frequency / 25000; // 40us + private static readonly long _minSpinTicks = Stopwatch.Frequency / 1000000; // 1us private static readonly bool _canSpin = Environment.ProcessorCount > 1; private readonly object/*!*/ _lock = new object(); @@ -57,7 +57,7 @@ public sealed class FiberMailbox { // The receiver is parked in Monitor.Wait (guarded by _lock). private bool _blocked; - private long _spinTicks = _initialSpinTicks; + private long _spinTicks = _maxSpinTicks; // The fiber group's owner, and its RubyThreadInfo, cached for FiberTake. private Thread _owner; @@ -82,8 +82,10 @@ public void Post(object message) { } public object Take() { + long spinStart = 0; if (_count == 0 && _canSpin) { - Spin(); + spinStart = Stopwatch.GetTimestamp(); + Spin(spinStart); } while (true) { @@ -98,6 +100,16 @@ public object Take() { } finally { _blocked = false; } + + if (spinStart != 0) { + // The spin gave up. If the message came soon after, a longer spin would + // have caught it; if it took long, the other side does real work (or + // waits for something) between switches, and spinning is wasted. + long waited = Stopwatch.GetTimestamp() - spinStart; + long budget = _spinTicks; + _spinTicks = (waited < _maxSpinTicks) ? _maxSpinTicks : Math.Max(_minSpinTicks, budget / 2); + spinStart = 0; + } } if (!interrupted && _count != 0) { @@ -117,27 +129,16 @@ public object Take() { } } - private void Spin() { + private void Spin(long start) { long budget = _spinTicks; - if (budget <= MinSpinTicks) { - // Recent answers all came too late to catch. Try again every now and then, the - // fiber may have changed its rhythm. - _spinTicks = _spinStepTicks; - return; - } - - long start = Stopwatch.GetTimestamp(); while (true) { - for (int i = 0; i < 8; i++) { - Thread.SpinWait(4); + for (int i = 0; i < 4; i++) { + Thread.SpinWait(2); if (_count != 0) { - long next = budget * 2; - _spinTicks = (next > _maxSpinTicks) ? _maxSpinTicks : next; return; } } if (Stopwatch.GetTimestamp() - start > budget) { - _spinTicks = budget / 2 > _spinStepTicks ? budget / 2 : MinSpinTicks; return; } } From 069d5d08eb9bedf8d4cf9d8b31f87f656133ed74 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:35:47 +0200 Subject: [PATCH 22/94] Gate the keyword-argument binding speedups (8db03bcd, 09001d8e) Verified on 09001d8e (this commit changes no files): - Util/bench call_kwargs, best of 6 interleaved in one bench-lock call: base 0.958 s -> 0.524 s (base runs 0.96-2.41 s, mine 0.52-1.18 s; the box is loaded and the base runs are bimodal). - ruby-bench keyword_args (Util/rubybench/run.sh --ir-only, ms per iteration, 2 runs each): base 3919/4218 -> 1980/2273. - In-process best-of-4 thread CPU time per 1M calls: call_kwargs 0.77 -> 0.40 s, add(left:, right:) 0.74 -> 0.38 s, required keywords 0.74 -> 0.41 s, positional + keyword 0.60 -> 0.39 s. - spec/core method, unboundmethod, proc, hash, symbol, kernel: 3616 examples, 0 failures, 0 errors (base: the same 3616/0/0). Gates: sp-tests 0, sp-lang 2934/0/0, call_kwargs 0.958s -> 0.524s Co-Authored-By: Claude Opus 5.5 From ec97886c5f46ea434fe6ddc5d0b22c0449ef3fd5 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:36:09 +0200 Subject: [PATCH 23/94] Precompile keyword calls to methods that take keywords A keyword call always went through a full DLR rule: BindPrecompiled refused any site passing a keyword-arguments hash, and RubyMethodInfo.GetDispatcher refused any method with an optional parameter - which every keyword-taking method has, since its keywords are lowered onto one trailing optional parameter. The refusal exists because BuildCallNoFlow unmarks the hash for a method that declares no keywords, which a precompiled dispatcher cannot do; for a method that takes or refuses keywords the rule passes the hash through untouched. So such a method now gets the version-checked RubyObject dispatcher when the call fills all its parameters (a keyword call does); a call that omits the keyword hash still gets a rule (CreateDispatcher refuses a site whose argument count differs), and so does a keyword call to a method without keywords. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Calls/RubyCallAction.cs | 12 ++++++++++-- Src/Ruby/Runtime/Calls/RubyMethodInfo.cs | 18 ++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Src/Ruby/Runtime/Calls/RubyCallAction.cs b/Src/Ruby/Runtime/Calls/RubyCallAction.cs index 07afd8c5..cf93b5ac 100644 --- a/Src/Ruby/Runtime/Calls/RubyCallAction.cs +++ b/Src/Ruby/Runtime/Calls/RubyCallAction.cs @@ -123,10 +123,13 @@ protected override object BindPrecompiled(Type/*!*/ delegateType, object[]/*!*/ // Nor can it hold RubyMethodInfo.BuildCallNoFlow's unmarking of a keyword-arguments // hash handed to a method that declares no keywords. A call site's syntax decides // whether it passes keywords, so seeing one here means every call at this site - // passes one: hand the site to the rule builder instead of precompiling it. + // passes one: unless the method takes keywords (checked below), hand the site to the + // rule builder instead of precompiling it. + bool passesKeywords = false; for (int i = 0; i < args.Length; i++) { if (RubyOps.IsKeywordArgumentsHash(args[i])) { - return null; + passesKeywords = true; + break; } } @@ -163,6 +166,11 @@ protected override object BindPrecompiled(Type/*!*/ delegateType, object[]/*!*/ return null; } + // a method that takes or refuses keywords reads the hash as it is - nothing to unmark + if (passesKeywords && (rubyMethod == null || !rubyMethod.TakesOrRefusesKeywords)) { + return null; + } + var dispatcher = method.Info.GetDispatcher(delegateType, Signature, target, version); if (dispatcher != null) { object result = dispatcher.CreateDelegate(MethodDispatcher.UntypedFuncs.Contains(delegateType)); diff --git a/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs b/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs index c1970bfa..371638c4 100644 --- a/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs +++ b/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs @@ -209,16 +209,30 @@ public override int GetArity() { #region Dynamic Sites internal override MemberDispatcher GetDispatcher(Type/*!*/ delegateType, RubyCallSignature signature, object target, int version) { - if (Parameters.Unsplat != null || Parameters.Optional.Length > 0) { + if (Parameters.Unsplat != null) { return null; } + // The keywords of a method that declares nothing but mandatory positionals besides them + // were lowered onto one trailing optional parameter (see PrismAstBridge.LowerKeywords and + // RefuseKeywords). A call that fills it - a keyword call - passes the keyword hash to it + // unchanged, which is all the rule would do (BuildCallNoFlow leaves the trailing hash of a + // method that takes or refuses keywords alone). A call that omits it still gets a rule: + // CreateDispatcher refuses a site whose argument count differs. + int parameterCount = Parameters.Mandatory.Length; + if (Parameters.Optional.Length > 0) { + if (!TakesOrRefusesKeywords) { + return null; + } + parameterCount += Parameters.Optional.Length; + } + if (!(target is IRubyObject)) { return null; } return MethodDispatcher.CreateRubyObjectDispatcher( - delegateType, GetDelegate(), Parameters.Mandatory.Length, signature.HasScope, signature.HasBlock, version + delegateType, GetDelegate(), parameterCount, signature.HasScope, signature.HasBlock, version ); } From 0e70f13d4c57d9eb1ae057adb2878ffa32e2572b Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:36:09 +0200 Subject: [PATCH 24/94] Take apart the general keyword lowering's splat with direct runtime calls A signature that mixes keywords with optional, rest or post positionals (`def m(a, b = 1, *c, k: 1)`) is lowered to a single splat its prologue unpacks by hand. That prologue made a dozen dynamic Array calls per call (dup, last, pop, size, shift, shift(n), size comparisons for the arity check and for each optional). They are now steps of a new AST node, SplatParameterRead, each one runtime helper (RubyOps.ShiftSplatArgument and neighbours). Two bugs go with them, both matching MRI now: - a block auto-splatting its one Array argument took apart the caller's array itself: `a = [1, 2]; proc { |x, y = 5, k: 1| }.call(a)` left `a` empty. The prologue now works on a copy. - a proc given fewer arguments than its post parameters raised "negative array size" from Array#shift: `proc { |a, *r, z, k: 1| } .call(1)` now binds a=1, r=[], z=nil like MRI. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 --- Src/Prism/PrismAstBridge.cs | 56 ++---- .../Ast/Expressions/SplatParameterRead.cs | 164 ++++++++++++++++++ Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs | 2 + .../Compiler/Ast/Walkers/Walker.Generated.cs | 2 + Src/Ruby/Compiler/Ast/Walkers/Walker.cs | 10 ++ .../Compiler/ReflectionCache.Generated.cs | 16 ++ Src/Ruby/Runtime/RubyOps.cs | 100 +++++++++++ 7 files changed, 304 insertions(+), 46 deletions(-) create mode 100644 Src/Ruby/Compiler/Ast/Expressions/SplatParameterRead.cs diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index 7ac54123..80f30ee1 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -2126,31 +2126,23 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List // arrived - trailing keyword hash included - and let SuperCall splat that. var superArgs = CurrentScope.AddVariable("?superargs?", span); statements.Add(new SimpleAssignmentExpression(superArgs, - new MethodCall(args, "dup", null, span), null, span)); + SplatParameterRead.Copy(args, span), null, span)); // A trailing Hash is how a keyword call arrives here, since keywords have no // calling-convention slot of their own. But when a block auto-splats a single // Array argument no keywords were passed, so the Hash stays positional // (Ruby 3 keyword separation). var popKeywords = new SimpleAssignmentExpression(kwVar, - new ConditionalExpression( - new KeywordArgumentsTest(new MethodCall(args, "last", null, span), span), - new MethodCall(args, "pop", null, span), - new HashConstructor(new Maplet[0], span), span), - null, span); + SplatParameterRead.PopKeywords(args, span), null, span); int formalCount = node.Requireds.Length + node.Optionals.Length + postCount + (node.Rest != null ? 1 : 0); if (autoSplat && formalCount > 1) { + // the Array is copied: taking it apart must not empty the caller's array statements.Add(new IfExpression( - new AndExpression( - new MethodCall(new MethodCall(args, "size", null, span), "==", - new Arguments(Literal.Integer(1, span)), span), - CaseEqual(new ConstantVariable("Array", span), - new MethodCall(args, "[]", new Arguments(Literal.Integer(0, span)), span), span), - span), + SplatParameterRead.IsAutoSplat(args, span), MakeStatements( new SimpleAssignmentExpression(args, - new MethodCall(args, "[]", new Arguments(Literal.Integer(0, span)), span), null, span), + SplatParameterRead.AutoSplat(args, span), null, span), new SimpleAssignmentExpression(kwVar, new HashConstructor(new Maplet[0], span), null, span)), new List { new ElseIfClause(null, MakeStatements(popKeywords), span) }, span)); @@ -2179,26 +2171,18 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List var optParam = (Pm.OptionalParameterNode)opt; var local = CurrentScope.ResolveOrAddVariable(optParam.Name, Span(opt)); statements.Add(new SimpleAssignmentExpression(local, - new ConditionalExpression( - new MethodCall(new MethodCall(args, "size", null, span), ">", - new Arguments(Literal.Integer(postCount, span)), span), - new MethodCall(args, "shift", null, span), - Expr(optParam.Value), span), + SplatParameterRead.ShiftOptional(args, postCount, Expr(optParam.Value), span), null, span)); } if (node.Rest is Pm.RestParameterNode rest && (rest.Name != null || isMethod)) { var local = CurrentScope.ResolveOrAddVariable(rest.Name ?? Symbols.RestArgsLocal, Span(node.Rest)); statements.Add(new SimpleAssignmentExpression(local, - new MethodCall(args, "shift", new Arguments( - new MethodCall(new MethodCall(args, "size", null, span), "-", - new Arguments(Literal.Integer(postCount, span)), span)), span), + SplatParameterRead.ShiftRest(args, postCount, span), null, span)); } else if (node.Rest != null && postCount > 0) { // anonymous rest still has to consume what the posts do not take - statements.Add(new MethodCall(args, "shift", new Arguments( - new MethodCall(new MethodCall(args, "size", null, span), "-", - new Arguments(Literal.Integer(postCount, span)), span)), span)); + statements.Add(SplatParameterRead.ShiftRest(args, postCount, span)); } foreach (var post in node.Posts) { @@ -2230,7 +2214,7 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List /// take; handing one to SimpleAssignmentExpression trips its Debug.Assert. /// private Expression/*!*/ AssignNext(Pm.PmNode/*!*/ target, LocalVariable/*!*/ args, SourceSpan span) { - var shift = new MethodCall(args, "shift", null, span); + var shift = SplatParameterRead.Shift(args, span); var lhs = Target(target); return lhs is CompoundLeftValue compound ? (Expression)new DestructuringAssignmentExpression(compound, shift, span) @@ -2331,27 +2315,7 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List /// took the cap off, which MRI reports as "expected N+". /// private Expression/*!*/ ArityCheck(LocalVariable/*!*/ args, int minimum, int maximum, SourceSpan span) { - Expression condition = new MethodCall(new MethodCall(args, "size", null, span), "<", - new Arguments(Literal.Integer(minimum, span)), span); - - if (maximum >= 0) { - condition = new OrExpression(condition, - new MethodCall(new MethodCall(args, "size", null, span), ">", - new Arguments(Literal.Integer(maximum, span)), span), span); - } - - string expected = maximum < 0 ? minimum + "+" : - minimum == maximum ? minimum.ToString() : minimum + ".." + maximum; - - Expression message = new MethodCall( - new MethodCall(new StringLiteral("wrong number of arguments (given ", _encoding, span), "+", - new Arguments(new MethodCall(new MethodCall(args, "size", null, span), "to_s", null, span)), span), - "+", new Arguments(new StringLiteral(", expected " + expected + ")", _encoding, span)), span); - - return new IfExpression(condition, new Statements( - new MethodCall(null, "raise", new Arguments(new Expression[] { - new ConstantVariable("ArgumentError", span), message - }), span)), new List(), span); + return SplatParameterRead.CheckArity(args, minimum, maximum, span); } private Expression/*!*/ RaiseArityError(int given, int expected, SourceSpan span) { diff --git a/Src/Ruby/Compiler/Ast/Expressions/SplatParameterRead.cs b/Src/Ruby/Compiler/Ast/Expressions/SplatParameterRead.cs new file mode 100644 index 00000000..473b85af --- /dev/null +++ b/Src/Ruby/Compiler/Ast/Expressions/SplatParameterRead.cs @@ -0,0 +1,164 @@ +/* **************************************************************************** + * + * Copyright (c) Microsoft Corporation. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ + +#if FEATURE_CORE_DLR +using MSA = System.Linq.Expressions; +#else +using MSA = Microsoft.Scripting.Ast; +#endif + +using Microsoft.Scripting; +using Microsoft.Scripting.Utils; +using AstUtils = Microsoft.Scripting.Ast.Utils; + +namespace IronRuby.Compiler.Ast { + using Ast = MSA.Expression; + + /// + /// The steps of the prologue PrismAstBridge.LowerGeneralParameters emits to take apart the + /// single splat a signature mixing keywords with optional, rest or post parameters is lowered + /// to. Each is one runtime call (see + /// and its neighbours) where the prologue used to make one or more dynamic Array calls. + /// + public partial class SplatParameterRead : Expression { + private enum ReadKind { + Copy, + PopKeywords, + IsAutoSplat, + AutoSplat, + CheckArity, + Shift, + ShiftOptional, + ShiftRest + } + + private readonly ReadKind _kind; + // the hidden local holding the splat + private readonly Expression/*!*/ _arguments; + private readonly int _count1, _count2; + private readonly Expression _defaultValue; + + public Expression/*!*/ Arguments { + get { return _arguments; } + } + + public Expression DefaultValue { + get { return _defaultValue; } + } + + private SplatParameterRead(ReadKind kind, Expression/*!*/ arguments, int count1, int count2, Expression defaultValue, SourceSpan location) + : base(location) { + Assert.NotNull(arguments); + _kind = kind; + _arguments = arguments; + _count1 = count1; + _count2 = count2; + _defaultValue = defaultValue; + } + + /// A copy of the arguments as they arrived (for a parameterless `super`). + public static SplatParameterRead/*!*/ Copy(Expression/*!*/ arguments, SourceSpan location) { + return new SplatParameterRead(ReadKind.Copy, arguments, 0, 0, null, location); + } + + /// The keyword hash taken off the end of the arguments, or an empty hash. + public static SplatParameterRead/*!*/ PopKeywords(Expression/*!*/ arguments, SourceSpan location) { + return new SplatParameterRead(ReadKind.PopKeywords, arguments, 0, 0, null, location); + } + + /// True if the arguments are a single Array, which a block auto-splats. + public static SplatParameterRead/*!*/ IsAutoSplat(Expression/*!*/ arguments, SourceSpan location) { + return new SplatParameterRead(ReadKind.IsAutoSplat, arguments, 0, 0, null, location); + } + + /// A copy of the single Array argument a block auto-splats. + public static SplatParameterRead/*!*/ AutoSplat(Expression/*!*/ arguments, SourceSpan location) { + return new SplatParameterRead(ReadKind.AutoSplat, arguments, 0, 0, null, location); + } + + /// Raises ArgumentError unless there are minimum..maximum arguments (maximum < 0: no cap). + public static SplatParameterRead/*!*/ CheckArity(Expression/*!*/ arguments, int minimum, int maximum, SourceSpan location) { + return new SplatParameterRead(ReadKind.CheckArity, arguments, minimum, maximum, null, location); + } + + /// The next argument, nil if none is left. + public static SplatParameterRead/*!*/ Shift(Expression/*!*/ arguments, SourceSpan location) { + return new SplatParameterRead(ReadKind.Shift, arguments, 0, 0, null, location); + } + + /// + /// The next argument if the post parameters leave one, + /// otherwise the value of . + /// + public static SplatParameterRead/*!*/ ShiftOptional(Expression/*!*/ arguments, int postCount, Expression/*!*/ defaultValue, SourceSpan location) { + Assert.NotNull(defaultValue); + return new SplatParameterRead(ReadKind.ShiftOptional, arguments, postCount, 0, defaultValue, location); + } + + /// The arguments the post parameters leave, as an Array. + public static SplatParameterRead/*!*/ ShiftRest(Expression/*!*/ arguments, int postCount, SourceSpan location) { + return new SplatParameterRead(ReadKind.ShiftRest, arguments, postCount, 0, null, location); + } + + internal override MSA.Expression/*!*/ TransformRead(AstGenerator/*!*/ gen) { + var arguments = AstUtils.Box(_arguments.TransformRead(gen)); + switch (_kind) { + case ReadKind.Copy: + return Methods.CopySplatArguments.OpCall(arguments); + + case ReadKind.PopKeywords: + return Methods.PopKeywordArguments.OpCall(gen.CurrentScopeVariable, arguments); + + case ReadKind.IsAutoSplat: + return AstUtils.Box(Methods.IsAutoSplatArgument.OpCall(arguments)); + + case ReadKind.AutoSplat: + return Methods.AutoSplatArgument.OpCall(arguments); + + case ReadKind.CheckArity: + return Ast.Block( + Methods.CheckSplatArity.OpCall(arguments, AstUtils.Constant(_count1), AstUtils.Constant(_count2)), + AstUtils.Constant(null, typeof(object)) + ); + + case ReadKind.Shift: + return Methods.ShiftSplatArgument.OpCall(arguments); + + case ReadKind.ShiftRest: + return Methods.ShiftRestSplatArguments.OpCall(arguments, AstUtils.Constant(_count1)); + + default: + // value = ShiftOptionalSplatArgument(args, postCount); value == DefaultArgument ? : value + var value = gen.CurrentScope.DefineHiddenVariable("#optional", typeof(object)); + return Ast.Condition( + Ast.Equal( + Ast.Assign(value, Methods.ShiftOptionalSplatArgument.OpCall(arguments, AstUtils.Constant(_count1))), + Ast.Field(null, Fields.DefaultArgument) + ), + AstUtils.Box(_defaultValue.TransformRead(gen)), + value + ); + } + } + + internal override MSA.Expression/*!*/ TransformReadBoolean(AstGenerator/*!*/ gen, bool positive) { + if (_kind == ReadKind.IsAutoSplat) { + var test = Methods.IsAutoSplatArgument.OpCall(AstUtils.Box(_arguments.TransformRead(gen))); + return positive ? test : Ast.Not(test); + } + return base.TransformReadBoolean(gen, positive); + } + } +} diff --git a/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs b/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs index d58399e7..f4d80b91 100644 --- a/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs +++ b/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs @@ -31,6 +31,7 @@ public enum NodeTypes { SplattedArgument, KeywordArgumentsTest, KeywordParameterRead, + SplatParameterRead, // definitions: ClassDefinition, @@ -121,6 +122,7 @@ public enum NodeTypes { public partial class SplattedArgument { public override NodeTypes NodeType { get { return NodeTypes.SplattedArgument; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class KeywordArgumentsTest { public override NodeTypes NodeType { get { return NodeTypes.KeywordArgumentsTest; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class KeywordParameterRead { public override NodeTypes NodeType { get { return NodeTypes.KeywordParameterRead; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } + public partial class SplatParameterRead { public override NodeTypes NodeType { get { return NodeTypes.SplatParameterRead; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class ClassDefinition { public override NodeTypes NodeType { get { return NodeTypes.ClassDefinition; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class ModuleDefinition { public override NodeTypes NodeType { get { return NodeTypes.ModuleDefinition; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } diff --git a/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs b/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs index 32a26203..f7b3bd90 100644 --- a/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs +++ b/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs @@ -43,6 +43,8 @@ public virtual void Exit(SplattedArgument/*!*/ node) { } public virtual void Exit(KeywordArgumentsTest/*!*/ node) { } public virtual bool Enter(KeywordParameterRead/*!*/ node) { return true; } public virtual void Exit(KeywordParameterRead/*!*/ node) { } + public virtual bool Enter(SplatParameterRead/*!*/ node) { return true; } + public virtual void Exit(SplatParameterRead/*!*/ node) { } // declarations: public virtual bool Enter(ClassDefinition/*!*/ node) { return true; } diff --git a/Src/Ruby/Compiler/Ast/Walkers/Walker.cs b/Src/Ruby/Compiler/Ast/Walkers/Walker.cs index 746e6bc1..4363d7cf 100644 --- a/Src/Ruby/Compiler/Ast/Walkers/Walker.cs +++ b/Src/Ruby/Compiler/Ast/Walkers/Walker.cs @@ -77,6 +77,16 @@ internal protected virtual void Walk(KeywordParameterRead/*!*/ node) { Exit(node); } + internal protected virtual void Walk(SplatParameterRead/*!*/ node) { + if (Enter(node)) { + node.Arguments.Walk(this); + if (node.DefaultValue != null) { + node.DefaultValue.Walk(this); + } + } + Exit(node); + } + internal protected virtual void Walk(BlockReference/*!*/ node) { if (Enter(node)) { node.Expression.Walk(this); diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index 472a9908..5fbdbdf8 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -42,6 +42,8 @@ public static partial class Methods { private static MethodInfo _AliasMethod; public static MethodInfo/*!*/ AllocateStructInstance { get { return _AllocateStructInstance ?? (_AllocateStructInstance = CallInstruction.CacheFunc(RubyOps.AllocateStructInstance)); } } private static MethodInfo _AllocateStructInstance; + public static MethodInfo/*!*/ AutoSplatArgument { get { return _AutoSplatArgument ?? (_AutoSplatArgument = CallInstruction.CacheFunc(RubyOps.AutoSplatArgument)); } } + private static MethodInfo _AutoSplatArgument; public static MethodInfo/*!*/ BlockBreak { get { return _BlockBreak ?? (_BlockBreak = CallInstruction.CacheFunc(RubyOps.BlockBreak)); } } private static MethodInfo _BlockBreak; public static MethodInfo/*!*/ BlockPropagateReturn { get { return _BlockPropagateReturn ?? (_BlockPropagateReturn = CallInstruction.CacheFunc(RubyOps.BlockPropagateReturn)); } } @@ -54,6 +56,8 @@ public static partial class Methods { private static MethodInfo _BlockYield; public static MethodInfo/*!*/ CanRescue { get { return _CanRescue ?? (_CanRescue = CallInstruction.CacheFunc(RubyOps.CanRescue)); } } private static MethodInfo _CanRescue; + public static MethodInfo/*!*/ CheckSplatArity { get { return _CheckSplatArity ?? (_CheckSplatArity = CallInstruction.CacheAction(RubyOps.CheckSplatArity)); } } + private static MethodInfo _CheckSplatArity; public static MethodInfo/*!*/ ClearKeywordArguments { get { return _ClearKeywordArguments ?? (_ClearKeywordArguments = CallInstruction.CacheFunc(RubyOps.ClearKeywordArguments)); } } private static MethodInfo _ClearKeywordArguments; public static MethodInfo/*!*/ CompareDefaultException { get { return _CompareDefaultException ?? (_CompareDefaultException = CallInstruction.CacheFunc(RubyOps.CompareDefaultException)); } } @@ -82,6 +86,8 @@ public static partial class Methods { private static MethodInfo _ConvertSymbolToClrString; public static MethodInfo/*!*/ ConvertSymbolToMutableString { get { return _ConvertSymbolToMutableString ?? (_ConvertSymbolToMutableString = CallInstruction.CacheFunc(RubyOps.ConvertSymbolToMutableString)); } } private static MethodInfo _ConvertSymbolToMutableString; + public static MethodInfo/*!*/ CopySplatArguments { get { return _CopySplatArguments ?? (_CopySplatArguments = CallInstruction.CacheFunc(RubyOps.CopySplatArguments)); } } + private static MethodInfo _CopySplatArguments; public static MethodInfo/*!*/ CreateArgumentsError { get { return _CreateArgumentsError ?? (_CreateArgumentsError = CallInstruction.CacheFunc(RubyOps.CreateArgumentsError)); } } private static MethodInfo _CreateArgumentsError; public static MethodInfo/*!*/ CreateArgumentsErrorForMissingBlock { get { return _CreateArgumentsErrorForMissingBlock ?? (_CreateArgumentsErrorForMissingBlock = CallInstruction.CacheFunc(RubyOps.CreateArgumentsErrorForMissingBlock)); } } @@ -358,6 +364,8 @@ public static partial class Methods { private static MethodInfo _IRubyObjectState_set_IsUntrusted; public static MethodInfo/*!*/ IsAdoptedClrRuleValid { get { return _IsAdoptedClrRuleValid ?? (_IsAdoptedClrRuleValid = CallInstruction.CacheFunc(RubyOps.IsAdoptedClrRuleValid)); } } private static MethodInfo _IsAdoptedClrRuleValid; + public static MethodInfo/*!*/ IsAutoSplatArgument { get { return _IsAutoSplatArgument ?? (_IsAutoSplatArgument = CallInstruction.CacheFunc(RubyOps.IsAutoSplatArgument)); } } + private static MethodInfo _IsAutoSplatArgument; public static MethodInfo/*!*/ IsClrNonSingletonRuleValid { get { return _IsClrNonSingletonRuleValid ?? (_IsClrNonSingletonRuleValid = CallInstruction.CacheFunc(RubyOps.IsClrNonSingletonRuleValid)); } } private static MethodInfo _IsClrNonSingletonRuleValid; public static MethodInfo/*!*/ IsClrSingletonRuleValid { get { return _IsClrSingletonRuleValid ?? (_IsClrSingletonRuleValid = CallInstruction.CacheFunc(RubyOps.IsClrSingletonRuleValid)); } } @@ -522,6 +530,8 @@ public static partial class Methods { private static MethodInfo _ObjectToMutableString; public static MethodInfo/*!*/ ObjectToString { get { return _ObjectToString ?? (_ObjectToString = CallInstruction.CacheFunc(RubyOps.ObjectToString)); } } private static MethodInfo _ObjectToString; + public static MethodInfo/*!*/ PopKeywordArguments { get { return _PopKeywordArguments ?? (_PopKeywordArguments = CallInstruction.CacheFunc(RubyOps.PopKeywordArguments)); } } + private static MethodInfo _PopKeywordArguments; public static MethodInfo/*!*/ PrintInteractiveResult { get { return _PrintInteractiveResult ?? (_PrintInteractiveResult = CallInstruction.CacheAction(RubyOps.PrintInteractiveResult)); } } private static MethodInfo _PrintInteractiveResult; public static MethodInfo/*!*/ PropagateRetrySingleton { get { return _PropagateRetrySingleton ?? (_PropagateRetrySingleton = CallInstruction.CacheFunc(RubyOps.PropagateRetrySingleton)); } } @@ -560,6 +570,12 @@ public static partial class Methods { private static MethodInfo _SetQualifiedConstant; public static MethodInfo/*!*/ SetUnqualifiedConstant { get { return _SetUnqualifiedConstant ?? (_SetUnqualifiedConstant = CallInstruction.CacheFunc(RubyOps.SetUnqualifiedConstant)); } } private static MethodInfo _SetUnqualifiedConstant; + public static MethodInfo/*!*/ ShiftOptionalSplatArgument { get { return _ShiftOptionalSplatArgument ?? (_ShiftOptionalSplatArgument = CallInstruction.CacheFunc(RubyOps.ShiftOptionalSplatArgument)); } } + private static MethodInfo _ShiftOptionalSplatArgument; + public static MethodInfo/*!*/ ShiftRestSplatArguments { get { return _ShiftRestSplatArguments ?? (_ShiftRestSplatArguments = CallInstruction.CacheFunc(RubyOps.ShiftRestSplatArguments)); } } + private static MethodInfo _ShiftRestSplatArguments; + public static MethodInfo/*!*/ ShiftSplatArgument { get { return _ShiftSplatArgument ?? (_ShiftSplatArgument = CallInstruction.CacheFunc(RubyOps.ShiftSplatArgument)); } } + private static MethodInfo _ShiftSplatArgument; public static MethodInfo/*!*/ Splat { get { return _Splat ?? (_Splat = CallInstruction.CacheFunc(RubyOps.Splat)); } } private static MethodInfo _Splat; public static MethodInfo/*!*/ SplatAppend { get { return _SplatAppend ?? (_SplatAppend = CallInstruction.CacheFunc(RubyOps.SplatAppend)); } } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 0c4feb12..a0ca63b9 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -1761,6 +1761,106 @@ public static object GetKeywordArgument(object keywords, RubySymbol/*!*/ name, i return (hash != null && hash.TryGetValueByIdentity(name, position, out value)) ? value : DefaultArgument; } + // A signature that mixes keywords with optional, rest or post positionals is lowered to a + // single splat that its prologue takes apart (PrismAstBridge.LowerGeneralParameters) with + // the helpers below rather than with Ruby-level Array calls. The splat is the array the + // call made for this call - or, for a block that auto-splats an Array argument, a copy of + // it (see AutoSplatArgument) - so consuming it changes nothing anyone else can see. + + /// The arguments as they arrived, kept aside for a parameterless `super`. + [Emitted] + public static RubyArray/*!*/ CopySplatArguments(object args) { + return new RubyArray((RubyArray)args); + } + + /// + /// Takes the hash a keyword call passed off the end of the arguments, or makes an empty one + /// if the call passed no keywords. + /// + [Emitted] + public static Hash/*!*/ PopKeywordArguments(RubyScope/*!*/ scope, object args) { + var list = (RubyArray)args; + int last = list.Count - 1; + if (last >= 0 && IsKeywordArgumentsHash(list[last])) { + var result = (Hash)list[last]; + list.RemoveAt(last); + return result; + } + return MakeHash0(scope); + } + + /// A block's single Array argument, which it auto-splats. + [Emitted] + public static bool IsAutoSplatArgument(object args) { + var list = (RubyArray)args; + return list.Count == 1 && list[0] is RubyArray; + } + + /// + /// The arguments a block that auto-splats its single Array argument binds: a copy of the + /// array, which the prologue consumes - the caller's array is left as it was. + /// + [Emitted] + public static RubyArray/*!*/ AutoSplatArgument(object args) { + return new RubyArray((RubyArray)((RubyArray)args)[0]); + } + + /// + /// The arity check of a method or lambda whose positionals are in the splat. A negative + /// means a rest parameter took the cap off. + /// + [Emitted] + public static void CheckSplatArity(object args, int minimum, int maximum) { + int count = ((RubyArray)args).Count; + if (count < minimum || (maximum >= 0 && count > maximum)) { + var inv = System.Globalization.CultureInfo.InvariantCulture; + throw (minimum == maximum) ? MakeWrongNumberOfArgumentsError(count, minimum) + : MakeWrongNumberOfArgumentsErrorN(count, minimum.ToString(inv) + (maximum < 0 ? "+" : ".." + maximum.ToString(inv))); + } + } + + /// The next positional argument; nil if there is none left (a block's). + [Emitted] + public static object ShiftSplatArgument(object args) { + var list = (RubyArray)args; + if (list.Count == 0) { + return null; + } + object result = list[0]; + list.RemoveAt(0); + return result; + } + + /// + /// The argument of an optional parameter: the next one if the + /// post parameters leave one, otherwise. + /// + [Emitted] + public static object ShiftOptionalSplatArgument(object args, int postCount) { + var list = (RubyArray)args; + if (list.Count <= postCount) { + return DefaultArgument; + } + object result = list[0]; + list.RemoveAt(0); + return result; + } + + /// + /// What the rest parameter takes: everything the post parameters + /// leave - nothing if a block was given too few arguments to fill even those. + /// + [Emitted] + public static RubyArray/*!*/ ShiftRestSplatArguments(object args, int postCount) { + var list = (RubyArray)args; + int count = Math.Max(0, list.Count - postCount); + var result = new RubyArray(list, 0, count); + if (count > 0) { + list.RemoveRange(0, count); + } + return result; + } + /// /// The **rest parameter: a new hash with whatever the call passed beyond the declared /// . It is a copy, so the method may change it freely. From f91fe2938b7d6315748309a0d540e1b3195e61c7 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:36:09 +0200 Subject: [PATCH 25/94] Build the empty default keyword hash without an item array The hidden keyword parameter's default (no keywords passed) went through MakeKeywordArgumentsHash with an empty object[]; use the existing MakeKeywordArgumentsHash0. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Compiler/Ast/Expressions/HashConstructor.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Src/Ruby/Compiler/Ast/Expressions/HashConstructor.cs b/Src/Ruby/Compiler/Ast/Expressions/HashConstructor.cs index 9d225f38..b05fdbc9 100644 --- a/Src/Ruby/Compiler/Ast/Expressions/HashConstructor.cs +++ b/Src/Ruby/Compiler/Ast/Expressions/HashConstructor.cs @@ -57,7 +57,7 @@ public HashConstructor(Maplet/*!*/[]/*!*/ maplets, bool isKeywordArguments, Sour } internal override MSA.Expression/*!*/ TransformRead(AstGenerator/*!*/ gen) { - if (_isKeywordArguments && _maplets.Length >= 1 && _maplets.Length <= 3 && HasSymbolLiteralKeys()) { + if (_isKeywordArguments && _maplets.Length <= 3 && HasSymbolLiteralKeys()) { return TransformKeywordArguments(gen); } var items = gen.TransformMapletsToExpressions(_maplets); @@ -83,6 +83,8 @@ private bool HasSymbolLiteralKeys() { args.Add(AstUtils.Box(maplet.Value.TransformRead(gen))); } switch (_maplets.Length) { + // the default of the hidden keyword parameter: no keywords passed + case 0: return Methods.MakeKeywordArgumentsHash0.OpCall(gen.CurrentScopeVariable); case 1: return Methods.MakeKeywordArgumentsHash1.OpCall(args); case 2: return Methods.MakeKeywordArgumentsHash2.OpCall(args); default: return Methods.MakeKeywordArgumentsHash3.OpCall(args); From a902e244e753f8890ca80fdfcab84d37cda632f8 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:44:29 +0200 Subject: [PATCH 26/94] Blocks: no RubyBlockScope for a body that never uses it Every block invocation allocated a RubyBlockScope (168 bytes) whether or not anything in the body could look at it. A body that only binds plain parameters and reads/assigns its own locals, literals and self - { }, { |x| x }, { |a, b, c| a } - never does: every call site takes the scope, and so do constants, ivars, gvars and closures, so the body is call-free. Such a block now only runs the safe point on entry, and makes the scope on demand for the TracePoint hooks, which evaluate their scope operand only while a TracePoint listens (b_call at entry, line and b_return in the body). The syntactic check is backed by a rewrite of the transformed body that bails out on any other use of the scope or on a node it cannot look into, so the lazy form is only used when the tree proves it safe. TracePoint b_call / line / b_return output, tp.binding locals included, is identical to the base build in a test that enables the events at entry and mid-way. Also: CheckLambdaArity uses the dispatcher's cached keyword flag instead of re-walking the parameter list on every lambda call, and InstantiateBlock tests a non-virtual flag instead of calling the virtual Method property on every call with a literal block. Bytes allocated per iteration: proc_call 290 -> 119, lambda_call 288 -> 113, yield_0 539 -> 373, yield_3 584 -> 417, blk_call_in_method 488 -> 316. Release, on-CPU seconds (main thread schedstat), best of 5, base/mine alternating in one bench-lock call; numbers include the previous commit: proc_call 0.484 -> 0.332 (base spread 0.484-0.821) lambda_call 0.458 -> 0.341 (base spread 0.458-0.689) yield_0 0.694 -> 0.566 (base spread 0.694-0.735) yield_3 0.458 -> 0.362 (base spread 0.458-0.511) blk_call_in_method 0.449 -> 0.351 (base spread 0.449-0.466) Full bench.rb list: no regression outside the noise (outliers re-run 5x: str_interp 1.04, module_function_call 1.01, singleton_call 0.99, block_pass 0.97; case_when and fib inside the base's own spread). Spec dirs vs base: core/proc, kernel proc/lambda/block_given/binding, integer times/upto/downto, array each/map, enumerable, hash/range each, tracepoint, module/define_method, basicobject instance_exec/eval, fiber: 135 files, 1301 examples, 0 failures, 0 errors on both. Gates: sp-tests 0, sp-lang 2934/0/0, proc_call 0.484s -> 0.332s Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Compiler/Ast/BlockDefinition.cs | 103 +++++++++++++++++- Src/Ruby/Compiler/Ast/ScopeBuilder.cs | 9 ++ .../Compiler/ReflectionCache.Generated.cs | 4 + Src/Ruby/Runtime/Calls/BlockDispatcher.cs | 9 ++ Src/Ruby/Runtime/RubyOps.cs | 31 +++++- 5 files changed, 150 insertions(+), 6 deletions(-) diff --git a/Src/Ruby/Compiler/Ast/BlockDefinition.cs b/Src/Ruby/Compiler/Ast/BlockDefinition.cs index 8d735f1e..45ccbcde 100644 --- a/Src/Ruby/Compiler/Ast/BlockDefinition.cs +++ b/Src/Ruby/Compiler/Ast/BlockDefinition.cs @@ -190,6 +190,27 @@ public BlockDefinition(LexicalScope/*!*/ definedScope, Parameters parameters, St MSA.Expression statements = gen.TransformStatements(_body, ResultOperation.Return); bool hasNestedReturn = gen.CurrentBlock.HasNestedReturn; + // A body that never uses its scope - { }, { |x| x } - has no use for the RubyBlockScope + // every invocation would allocate, except to hand to the TracePoint hooks, which only + // evaluate their scope while a TracePoint listens: there it is made on demand. + MSA.Expression lazyScope = null, filterScope = scopeVariable; + if (!hasNestedReturn && !gen.TraceEnabled && !gen.CompilerOptions.IsEval && !scope.HasClosures && IsScopeFreeBody()) { + var names = scope.GetVariableNamesExpression(); + Func makeScope = () => Methods.CreateBlockScopeLazy.OpCall( + scope.LocalsTupleVariable, names, blockParameter, selfParameter, EnterInterpretedFrameExpression.Instance + ); + var lazy = Ast.Coalesce(scopeVariable, Ast.Assign(scopeVariable, makeScope())); + var rewriter = new LazyScopeRewriter(scopeVariable, lazy); + var lazyParamInit = rewriter.Visit(paramInit); + var lazyStatements = rewriter.Visit(statements); + if (!rewriter.NeedsScope) { + paramInit = lazyParamInit; + statements = lazyStatements; + lazyScope = lazy; + filterScope = Ast.Coalesce(scopeVariable, makeScope()); + } + } + var bodyTry = AstUtils.Try( statements ).Catch(blockUnwinder = Ast.Parameter(typeof(BlockUnwinder), "#u"), @@ -221,7 +242,7 @@ public BlockDefinition(LexicalScope/*!*/ definedScope, Parameters parameters, St Ast.Label(redoLabel), bodyTry ).Filter(filterVariable = Ast.Parameter(typeof(Exception), "#e"), - Methods.FilterBlockException.OpCall(scopeVariable, filterVariable) + Methods.FilterBlockException.OpCall(filterScope, filterVariable) ).Finally( traceReturn, leaveNestedReturn, @@ -230,7 +251,7 @@ public BlockDefinition(LexicalScope/*!*/ definedScope, Parameters parameters, St body = scope.CreateScope( scopeVariable, - Methods.CreateBlockScope.OpCall(new AstExpressions { + (lazyScope != null ? Methods.CreateBlockScopeIfTraced : Methods.CreateBlockScope).OpCall(new AstExpressions { scope.MakeLocalsStorage(), scope.GetVariableNamesExpression(), blockParameter, @@ -238,7 +259,7 @@ public BlockDefinition(LexicalScope/*!*/ definedScope, Parameters parameters, St EnterInterpretedFrameExpression.Instance }), gen.Traceable - ? new TraceReturnExpression(scopeVariable, gen.AddReturnTarget(body), TraceEvents.BReturn | TraceEvents.Return, gen.SourcePath, Location.End.Line) + ? new TraceReturnExpression(lazyScope ?? scopeVariable, gen.AddReturnTarget(body), TraceEvents.BReturn | TraceEvents.Return, gen.SourcePath, Location.End.Line) : gen.AddReturnTarget(body) ); @@ -295,6 +316,82 @@ public BlockDefinition(LexicalScope/*!*/ definedScope, Parameters parameters, St return AstGenerator.FormatBlockLabel(enclosing, levels); } + /// + /// Nothing in the parameters or the body needs the block's scope at runtime: plain + /// parameters, and statements that only read and assign the block's own locals, literals and + /// self. Every call site takes the scope, and so do constants, instance and global variables, + /// so this is the call-free body: { }, { |x| x }, { |a, b| a }. + /// The transformed body is still checked (LazyScopeRewriter); this is the cheap filter. + /// + private bool IsScopeFreeBody() { + if (_parameters.Optional.Length > 0 || _parameters.Unsplat != null || _parameters.Block != null || HasPostParameters) { + return false; + } + var signature = _parameters.Signature; + if (signature != null && (signature.TakesKeywords || signature.RefusesKeywords)) { + return false; + } + foreach (var parameter in _parameters.Mandatory) { + if (!(parameter is LocalVariable) && !(parameter is Placeholder)) { + return false; + } + } + foreach (var statement in _body) { + if (!IsScopeFree(statement)) { + return false; + } + } + return true; + } + + private static bool IsScopeFree(Expression/*!*/ node) { + if (node is LocalVariable || node is Literal || node is SelfReference) { + return true; + } + var assignment = node as SimpleAssignmentExpression; + return assignment != null && assignment.Operation == null && assignment.Left is LocalVariable && IsScopeFree(assignment.Right); + } + + /// + /// Points the TracePoint hooks of a scope-free body at the scope made on demand. Any other use + /// of the scope, or a node it cannot look into, means the body needs its scope after all. + /// + private sealed class LazyScopeRewriter : MSA.ExpressionVisitor { + private readonly MSA.ParameterExpression/*!*/ _scope; + private readonly MSA.Expression/*!*/ _lazyScope; + private int _inTraceHook; + + internal bool NeedsScope; + + internal LazyScopeRewriter(MSA.ParameterExpression/*!*/ scope, MSA.Expression/*!*/ lazyScope) { + _scope = scope; + _lazyScope = lazyScope; + } + + protected override MSA.Expression/*!*/ VisitParameter(MSA.ParameterExpression/*!*/ node) { + if (node == _scope) { + if (_inTraceHook > 0) { + return _lazyScope; + } + NeedsScope = true; + } + return node; + } + + protected override MSA.Expression/*!*/ VisitExtension(MSA.Expression/*!*/ node) { + if (node is TraceLineExpression) { + _inTraceHook++; + try { + return base.VisitExtension(node); + } finally { + _inTraceHook--; + } + } + NeedsScope = true; + return node; + } + } + private MSA.Expression/*!*/ GetParameterAccess(AstParameters/*!*/ parameters, MSA.Expression paramsArray, int i) { return (paramsArray != null) ? (MSA.Expression)Ast.ArrayAccess(paramsArray, AstUtils.Constant(i)) : parameters[HiddenParameterCount + i]; } diff --git a/Src/Ruby/Compiler/Ast/ScopeBuilder.cs b/Src/Ruby/Compiler/Ast/ScopeBuilder.cs index 23d3e7c0..01c77505 100644 --- a/Src/Ruby/Compiler/Ast/ScopeBuilder.cs +++ b/Src/Ruby/Compiler/Ast/ScopeBuilder.cs @@ -110,6 +110,15 @@ internal int LexicalDepth { get { return _lexicalScope.Depth; } } + // whether anything in the scope reads an outer scope's locals (see GetClosure) + internal bool HasClosures { + get { return _closures != null; } + } + + internal MSA.ParameterExpression/*!*/ LocalsTupleVariable { + get { return _localsTuple; } + } + internal ScopeBuilder Parent { get { return _parent; } } diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index 407bf2a0..630ac2b4 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -96,6 +96,10 @@ public static partial class Methods { private static MethodInfo _CreateBfcForYield; public static MethodInfo/*!*/ CreateBlockScope { get { return _CreateBlockScope ?? (_CreateBlockScope = CallInstruction.CacheFunc(RubyOps.CreateBlockScope)); } } private static MethodInfo _CreateBlockScope; + public static MethodInfo/*!*/ CreateBlockScopeIfTraced { get { return _CreateBlockScopeIfTraced ?? (_CreateBlockScopeIfTraced = CallInstruction.CacheFunc(RubyOps.CreateBlockScopeIfTraced)); } } + private static MethodInfo _CreateBlockScopeIfTraced; + public static MethodInfo/*!*/ CreateBlockScopeLazy { get { return _CreateBlockScopeLazy ?? (_CreateBlockScopeLazy = CallInstruction.CacheFunc(RubyOps.CreateBlockScopeLazy)); } } + private static MethodInfo _CreateBlockScopeLazy; public static MethodInfo/*!*/ CreateBoundMember { get { return _CreateBoundMember ?? (_CreateBoundMember = CallInstruction.CacheFunc(RubyOps.CreateBoundMember)); } } private static MethodInfo _CreateBoundMember; public static MethodInfo/*!*/ CreateBoundMissingMember { get { return _CreateBoundMissingMember ?? (_CreateBoundMissingMember = CallInstruction.CacheFunc(RubyOps.CreateBoundMissingMember)); } } diff --git a/Src/Ruby/Runtime/Calls/BlockDispatcher.cs b/Src/Ruby/Runtime/Calls/BlockDispatcher.cs index bd83bfff..8b835bb9 100644 --- a/Src/Ruby/Runtime/Calls/BlockDispatcher.cs +++ b/Src/Ruby/Runtime/Calls/BlockDispatcher.cs @@ -31,6 +31,7 @@ using System.Collections.ObjectModel; using System.Collections; using System.Diagnostics; +using System.Threading; namespace IronRuby.Runtime.Calls { using Ast = Expression; @@ -74,6 +75,7 @@ public override Delegate/*!*/ Method { // Note: this might potentially be executed by multiple threads. So we can assert that _block == null here. // It's ok if the delegate is overwritten multiple times since all the target methods are equivalent. _block = (T)method; + Volatile.Write(ref _hasMethod, true); // A block defined in interpreted code gets the interpreter's delegate, which keeps going // through LightLambda.RunN - a check and a cast - on every call even once the lambda has @@ -237,6 +239,13 @@ public static BlockSignatureAttributes MakeAttributes(BlockSignatureAttributes a // Includes anonymous parameter. public abstract int ParameterCount { get; } public abstract Delegate/*!*/ Method { get; } + + // Method != null, without the virtual call: InstantiateBlock asks on every call with a literal block + private protected bool _hasMethod; + + internal bool HasMethod { + get { return Volatile.Read(ref _hasMethod); } + } internal abstract BlockDispatcher/*!*/ SetMethod(object/*!*/ method); public string SourcePath { get { return _sourcePath; } } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 89961e0e..c6644fd4 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -238,6 +238,30 @@ public static object MarkAliasCall(object lastArgument, string/*!*/ calleeName, return scope; } + /// + /// The entry of a block whose body never uses its scope (see BlockDefinition.IsScopeFreeBody): + /// the safe point, and the scope only if a TracePoint wants the call event. Null otherwise; a + /// TracePoint hook enabled later makes it then (). + /// + [Emitted] + public static RubyBlockScope CreateBlockScopeIfTraced(MutableTuple locals, string[] variableNames, + BlockParam/*!*/ blockParam, object selfObject, InterpretedFrame interpretedFrame) { + + RubyUtils.SafePoint(); + if ((TracePoint.ActiveEvents & (int)(TraceEvents.BCall | TraceEvents.Call)) != 0) { + var scope = new RubyBlockScope(locals, variableNames ?? ArrayUtils.EmptyStrings, blockParam, selfObject, interpretedFrame); + TracePoint.OnBlockCall(scope); + return scope; + } + return null; + } + + [Emitted] + public static RubyBlockScope/*!*/ CreateBlockScopeLazy(MutableTuple locals, string[] variableNames, + BlockParam/*!*/ blockParam, object selfObject, InterpretedFrame interpretedFrame) { + return new RubyBlockScope(locals, variableNames ?? ArrayUtils.EmptyStrings, blockParam, selfObject, interpretedFrame); + } + // TracePoint hooks; emitted code only calls them when TracePoint.ActiveEvents has the event. [Emitted] @@ -438,11 +462,11 @@ private static bool IsRefinedHere(RubyScope/*!*/ scope, object value, string/*!* [Emitted] public static Proc InstantiateBlock(RubyScope/*!*/ scope, object self, BlockDispatcher/*!*/ dispatcher) { - return (dispatcher.Method != null) ? new Proc(ProcKind.Block, self, scope, dispatcher) : null; + return dispatcher.HasMethod ? new Proc(ProcKind.Block, self, scope, dispatcher) : null; } [Emitted] public static Proc InstantiateLambda(RubyScope/*!*/ scope, object self, BlockDispatcher/*!*/ dispatcher) { - return (dispatcher.Method != null) ? new Proc(ProcKind.Lambda, self, scope, dispatcher) : null; + return dispatcher.HasMethod ? new Proc(ProcKind.Lambda, self, scope, dispatcher) : null; } [Emitted] @@ -524,7 +548,8 @@ public static void RequireLambdaArity(Proc/*!*/ proc, int argCount, object lastA private static void CheckLambdaArity(Proc/*!*/ proc, int argCount, object lastArg) { var signature = proc.Dispatcher.ParameterSignature; - if (signature != null && (signature.TakesKeywords || signature.RefusesKeywords)) { + // = signature != null && (signature.TakesKeywords || signature.RefusesKeywords), cached + if (proc.Dispatcher.ChecksKeywords) { var keywords = argCount > 0 ? lastArg as Hash : null; if (keywords != null && !keywords.IsKeywordArguments) { keywords = null; From b8f98a403061157ffb38891daffb8b1734f3f84b Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:48:06 +0200 Subject: [PATCH 27/94] FiberMailbox: spin up to 100us, pause after two misses, yield now and then The adaptive budget of the first version (halve on a miss, back to the cap when the message came soon after blocking) fed on itself when the two threads could not run side by side: the spinner kept the other thread off the CPU, the message then came right after it blocked, and the budget went back up. Now a take spins up to 100us (a wake-up costs tens of microseconds on a busy box, so a long spin is still the cheaper miss), calls Thread.Yield every few microseconds in case the thread it waits for is queued behind it, and two misses in a row pause spinning for that mailbox; every 32nd take probes again. A/B, one bench-lock call, 5 interleaved rounds, best (spread): fiber_switch 0.25 base 1.905s (1.91-2.12) -> 0.452s (0.45-0.54) enum_next_gen 0.2 base 0.747s (0.75-0.80) -> 0.376s (0.38-0.43) (previous commit: 0.890s (0.89-2.76) and 0.580s (0.58-1.02) on the same benchmarks; the spread is gone along with the feedback.) Specs: spec/core/fiber, spec/core/enumerator, spec/core/thread, spec/library/prime, spec/library/zlib/gzipreader: 1160 examples, 1 failure, the same one base has on that run (thread/backtrace/location/label_spec.rb:61). Gates: sp-tests 0, sp-lang 2934/0/0, fiber_switch 1.905s -> 0.452s, enum_next_gen 0.747s -> 0.376s Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/ThreadOps.Fiber.cs | 66 +++++++++++++---------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/Src/Libraries/Builtins/ThreadOps.Fiber.cs b/Src/Libraries/Builtins/ThreadOps.Fiber.cs index e05dd1d6..a0df0a46 100644 --- a/Src/Libraries/Builtins/ThreadOps.Fiber.cs +++ b/Src/Libraries/Builtins/ThreadOps.Fiber.cs @@ -30,21 +30,24 @@ public static partial class ThreadOps { /// own and exactly one fiber of a group runs at a time, so a switch is "post to the target's /// mailbox, then take from my own". /// - /// Taking spins for a few microseconds before it blocks. A resume / Fiber.yield round trip - /// whose other side answers quickly - Enumerator#next over a generator, a fiber that yields - /// in a loop - then costs two cache-line transfers instead of two kernel wake-ups, which is - /// most of what a switch used to cost. The spin budget adapts per mailbox: it goes back to - /// the cap whenever the message arrived shortly after the spin gave up, and halves when it - /// took long, so a fiber that is resumed rarely soon stops burning a core on it. Single-core - /// machines never spin. + /// Taking spins for a while before it blocks. A resume / Fiber.yield round trip whose other + /// side answers quickly - Enumerator#next over a generator, a fiber that yields in a loop - + /// then costs two cache-line transfers instead of two kernel wake-ups, which is most of what + /// a switch used to cost. A wake-up costs tens of microseconds on a busy machine, so the spin + /// may run about as long before it gives up. Two misses in a row pause spinning for that + /// mailbox - a fiber that is resumed rarely, or threads that cannot run side by side, would + /// only burn a core on it - and every 32nd take tries again. The spin yields the CPU now and + /// then, in case the thread it waits for is queued behind it. Single-core machines never + /// spin. /// /// A blocked take is a Monitor wait, so Thread#kill / Thread#raise (delivered with /// Thread.Interrupt) wake it exactly as they wake Queue#pop, which the mailbox used to be. /// public sealed class FiberMailbox { - // How long a take spins before it blocks: between a step and the cap, adapted per mailbox. - private static readonly long _maxSpinTicks = Stopwatch.Frequency / 25000; // 40us - private static readonly long _minSpinTicks = Stopwatch.Frequency / 1000000; // 1us + // How long a take spins before it blocks, and how spinning is paused and probed. + private static readonly long _spinTicks = Stopwatch.Frequency / 10000; // 100us + private const int SpinMissesToPause = 2; + private const int PausedTakesPerProbe = 32; private static readonly bool _canSpin = Environment.ProcessorCount > 1; private readonly object/*!*/ _lock = new object(); @@ -57,7 +60,10 @@ public sealed class FiberMailbox { // The receiver is parked in Monitor.Wait (guarded by _lock). private bool _blocked; - private long _spinTicks = _maxSpinTicks; + // Spin misses in a row; takes since spinning was paused (spinning is paused while + // _misses has reached SpinMissesToPause). + private int _misses; + private int _pausedTakes; // The fiber group's owner, and its RubyThreadInfo, cached for FiberTake. private Thread _owner; @@ -82,10 +88,15 @@ public void Post(object message) { } public object Take() { - long spinStart = 0; if (_count == 0 && _canSpin) { - spinStart = Stopwatch.GetTimestamp(); - Spin(spinStart); + if (_misses < SpinMissesToPause || ++_pausedTakes >= PausedTakesPerProbe) { + _pausedTakes = 0; + if (Spin()) { + _misses = 0; + } else if (_misses < SpinMissesToPause) { + _misses++; + } + } } while (true) { @@ -100,16 +111,6 @@ public object Take() { } finally { _blocked = false; } - - if (spinStart != 0) { - // The spin gave up. If the message came soon after, a longer spin would - // have caught it; if it took long, the other side does real work (or - // waits for something) between switches, and spinning is wasted. - long waited = Stopwatch.GetTimestamp() - spinStart; - long budget = _spinTicks; - _spinTicks = (waited < _maxSpinTicks) ? _maxSpinTicks : Math.Max(_minSpinTicks, budget / 2); - spinStart = 0; - } } if (!interrupted && _count != 0) { @@ -129,17 +130,24 @@ public object Take() { } } - private void Spin(long start) { - long budget = _spinTicks; + private bool Spin() { + long start = Stopwatch.GetTimestamp(); + int round = 0; while (true) { for (int i = 0; i < 4; i++) { Thread.SpinWait(2); if (_count != 0) { - return; + return true; + } + } + if ((++round & 7) == 0) { + Thread.Yield(); + if (_count != 0) { + return true; } } - if (Stopwatch.GetTimestamp() - start > budget) { - return; + if (Stopwatch.GetTimestamp() - start > _spinTicks) { + return false; } } } From aacf5f2b69213ecf5200d8c7e9b6d5cc62c217ef Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:51:31 +0200 Subject: [PATCH 28/94] DLR: keep exception data in Exception.Data on .NET Core (0100-exceptions.patch) ExceptionUtils.GetData/SetData/RemoveData - where the interpreter saves its frames on an exception and IronRuby keeps RubyExceptionData - only used Exception.Data under FEATURE_REMOTING (desktop). On .NET Core they went through a ConditionalWeakTable under one global lock with a LINQ scan (and a closure allocation) per lookup, plus a dependent handle per exception that every GC scans. A raise does ~8-10 lookups. Use Exception.Data (locked per dictionary) on NETCOREAPP; a type whose Data is read-only falls back to the table. Profile of Util/bench raise_rescue (sampled, Release): GetData per raise 118 us -> 16.5 us. End-to-end numbers are in the next commit, which was gated together with this one. Co-Authored-By: Claude Opus 5.5 --- Build/dlr-patches/0100-exceptions.patch | 96 +++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 Build/dlr-patches/0100-exceptions.patch diff --git a/Build/dlr-patches/0100-exceptions.patch b/Build/dlr-patches/0100-exceptions.patch new file mode 100644 index 00000000..c1d6a4ae --- /dev/null +++ b/Build/dlr-patches/0100-exceptions.patch @@ -0,0 +1,96 @@ +diff --git a/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs b/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs +index 7176c59..fec49a4 100644 +--- a/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs ++++ b/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs +@@ -30,6 +30,91 @@ namespace Microsoft.Scripting.Utils { + public static void RemoveData(this Exception e, object key) { + e.Data.Remove(key); + } ++#elif NETCOREAPP ++ // Exception.Data, as on the desktop: languages attach data to every exception they see go ++ // by (the interpreter its frames, IronRuby its exception state), several times per raise. ++ // The ConditionalWeakTable below costs a dependent handle per exception, which every GC has ++ // to scan, and a global lock plus a LINQ scan per lookup. Exception.Data's dictionary is not ++ // thread-safe, hence the lock on it - uncontended in practice. An exception type whose Data ++ // is not writable gets the table instead. ++ public static object GetData(this Exception e, object key) { ++ var data = e.Data; ++ if (data is null || data.IsReadOnly) { ++ return GetTableData(e, key); ++ } ++ lock (data) { ++ return data[key]; ++ } ++ } ++ ++ public static void SetData(this Exception e, object key, object value) { ++ var data = e.Data; ++ if (data is null || data.IsReadOnly) { ++ SetTableData(e, key, value); ++ return; ++ } ++ lock (data) { ++ data[key] = value; ++ } ++ } ++ ++ public static void RemoveData(this Exception e, object key) { ++ var data = e.Data; ++ if (data is null || data.IsReadOnly) { ++ RemoveTableData(e, key); ++ return; ++ } ++ lock (data) { ++ data.Remove(key); ++ } ++ } ++ ++ private static ConditionalWeakTable>> _exceptionData; ++ ++ private static void SetTableData(Exception e, object key, object value) { ++ if (_exceptionData is null) { ++ Interlocked.CompareExchange(ref _exceptionData, new ConditionalWeakTable>>(), null); ++ } ++ ++ lock (_exceptionData) { ++ var data = _exceptionData.GetOrCreateValue(e); ++ int index = data.FindIndex(entry => entry.Key == key); ++ if (index >= 0) { ++ data[index] = new KeyValuePair(key, value); ++ } else { ++ data.Add(new KeyValuePair(key, value)); ++ } ++ } ++ } ++ ++ private static object GetTableData(Exception e, object key) { ++ if (_exceptionData is null) { ++ return null; ++ } ++ ++ lock (_exceptionData) { ++ if (!_exceptionData.TryGetValue(e, out List> data)) { ++ return null; ++ } ++ return data.FirstOrDefault(entry => entry.Key == key).Value; ++ } ++ } ++ ++ private static void RemoveTableData(Exception e, object key) { ++ if (_exceptionData is null) { ++ return; ++ } ++ ++ lock (_exceptionData) { ++ if (!_exceptionData.TryGetValue(e, out List> data)) { ++ return; ++ } ++ int index = data.FindIndex(entry => entry.Key == key); ++ if (index >= 0) { ++ data.RemoveAt(index); ++ } ++ } ++ } + #else + private static ConditionalWeakTable>> _exceptionData; + From f20cae02c5deb9614c140390c84db3b368821ed5 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:51:31 +0200 Subject: [PATCH 29/94] Reuse an interpreted frame's catch-site stack walk; cache :exception in raise After the lazy formatting, the catch-site StackTrace taken by CaptureExceptionTrace was most of a raise: the whole CLR stack with every frame's MethodBase resolved (GetTypicalMethodDefinition on each generic interpreter/call-site frame). Everything in that walk below the catching frame stays as it is while the frame lives, and the frames above it are IronRuby/DLR plumbing that a backtrace skips (outside -X:ExceptionDetail and debug mode). So when the catching frame is interpreted - its InterpretedFrame is a single invocation, unlike a scope, which eval code shares with its binding - the walk is kept per thread and reused while the same frame keeps catching (a loop around begin/rescue; also OSR-compiled loops, which run in their interpreted frame). A walk taken while the interpreter re-enters the frame to run finally blocks (Run <- HandleException <- Run) is not kept. Kernel#raise's respond_to?(:exception) check interned the symbol anew on every raise (MutableString + symbol table lock); it now takes it from the context's identifier cache. raise_rescue (Util/bench, Release, interleaved in one bench-lock call), with the previous commit (Exception.Data): scale 1: base 16.90 s best (16.90-20.20) -> 3.20 s best (3.20-3.44) scale 0.1: base 2.57 s best (2.57-2.81) -> 0.45 s best (0.45-0.48) (1.98 s at scale 0.1 after the lazy-backtrace commit, measured under heavier load.) rescue_nothrow unchanged (0.27-0.31 vs 0.26-0.39). Backtrace/full_message/cause/thread/eval/catch/loop probe output identical to base. Gates: sp-tests 0, sp-lang 2934/0/0, raise_rescue 16.90s -> 3.20s; spec/core/exception+kernel raise/fail/caller/caller_locations/catch/throw/loop+thread raise/backtrace+enumerator next+fiber 742 ex 1 F = base; spec/command_line 175/0/0 = base Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/KernelOps.cs | 5 ++- Src/Ruby/Runtime/RubyExceptionData.cs | 39 +++++++++++++++++++++-- Src/Ruby/Runtime/RubyStackTraceBuilder.cs | 31 ++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/Src/Libraries/Builtins/KernelOps.cs b/Src/Libraries/Builtins/KernelOps.cs index 420ca2e4..31d2b049 100644 --- a/Src/Libraries/Builtins/KernelOps.cs +++ b/Src/Libraries/Builtins/KernelOps.cs @@ -743,7 +743,10 @@ public static MutableString GetSourceFileLocation(RubyContext/*!*/ context, obje return RubyExceptionData.InitializeException(new RuntimeError(message.ToClrString()), message); } - if (!Protocols.RespondTo(respondToStorage, obj, "exception")) { + // the symbol comes from the context's identifier cache: interning it anew was most of + // what this check cost + var respondTo = respondToStorage.GetCallSite(); + if (!RubyOps.IsTrue(respondTo.Target(respondTo, obj, respondToStorage.Context.StringifyIdentifier("exception")))) { throw RubyExceptions.CreateTypeError("exception class/object expected"); } diff --git a/Src/Ruby/Runtime/RubyExceptionData.cs b/Src/Ruby/Runtime/RubyExceptionData.cs index d19c384e..b545fd1d 100644 --- a/Src/Ruby/Runtime/RubyExceptionData.cs +++ b/Src/Ruby/Runtime/RubyExceptionData.cs @@ -69,6 +69,12 @@ public sealed class RubyExceptionData { // it (see CaptureExceptionTrace). Only ever set while _backtrace is null. [NonSerialized] private RubyStackTraceBuilder.CapturedTrace _capturedTrace; + + // The catch-site walk of the interpreted frame that last caught an exception on this thread. + [ThreadStatic] + private static WeakReference _lastCatchFrame; + [ThreadStatic] + private static StackTrace _lastCatchSiteTrace; #endif // Exception#cause; _hasCause tells "decided" from "not raised yet", see TrySetCause. @@ -146,8 +152,37 @@ internal void CaptureExceptionTrace(RubyScope/*!*/ scope) { if (!HasBacktrace) { var context = scope.RubyContext; #if FEATURE_STACK_TRACE - StackTrace catchSiteTrace = RubyStackTraceBuilder.GetClrStackTrace(null, - context.DomainManager.Configuration.DebugMode); + bool debugMode = context.DomainManager.Configuration.DebugMode; + + // The catch-site walk is the expensive part of a raise: the whole CLR stack, every frame's + // method resolved. All but its top - the frames this one sees the exception from - lies + // below the catching frame, and nothing below a frame changes while the frame lives. So an + // interpreted frame that keeps catching (a loop around begin/rescue) reuses the walk it + // took the first time. Interpreted frames because their identity is that of a single + // invocation (a scope is not: eval code runs in the scope of its binding); not in the modes + // that report CLR frames, which would show the top of the walk too. + StackTrace catchSiteTrace = null; + InterpretedFrame frame = scope.InterpretedFrame; + WeakReference lastFrame = _lastCatchFrame; + bool reusable = frame != null && !debugMode && !context.Options.ExceptionDetail && + frame == InterpretedFrame.CurrentFrame.Value; + + InterpretedFrame last; + if (reusable && lastFrame != null && lastFrame.TryGetTarget(out last) && last == frame) { + catchSiteTrace = _lastCatchSiteTrace; + } + + if (catchSiteTrace == null) { + catchSiteTrace = RubyStackTraceBuilder.GetClrStackTrace(null, debugMode); + if (reusable && RubyStackTraceBuilder.IsReusableCatchSiteTrace(catchSiteTrace)) { + _lastCatchSiteTrace = catchSiteTrace; + if (lastFrame != null) { + lastFrame.SetTarget(frame); + } else { + _lastCatchFrame = new WeakReference(frame); + } + } + } // formatted on first use, see Backtrace: _capturedTrace = new RubyStackTraceBuilder.CapturedTrace(context, _exception, catchSiteTrace, scope.InterpretedFrame != null); diff --git a/Src/Ruby/Runtime/RubyStackTraceBuilder.cs b/Src/Ruby/Runtime/RubyStackTraceBuilder.cs index fd0a63aa..b6ffe15c 100644 --- a/Src/Ruby/Runtime/RubyStackTraceBuilder.cs +++ b/Src/Ruby/Runtime/RubyStackTraceBuilder.cs @@ -262,6 +262,37 @@ internal static StackTrace GetClrStackTrace(Exception exception, bool needFileIn return exception != null ? new StackTrace(exception, needFileInfo) : new StackTrace(needFileInfo); } + /// + /// Whether a catch-site walk taken in an interpreted frame (see RubyExceptionData.CaptureExceptionTrace) + /// can stand for the walk of any other exception the frame catches: whether it shows the frame + /// once. While the interpreter runs a frame's finally blocks on the way out of an exception it + /// re-enters the frame's Run method from HandleException, and the walk then has two runs of it. + /// + internal static bool IsReusableCatchSiteTrace(StackTrace/*!*/ trace) { + var frames = trace.GetFrames(); + int i = 0; + while (i < frames.Length && !IsInterpreterRunFrame(frames[i])) { + i++; + } + if (i == frames.Length) { + return false; + } + var interpreterType = frames[i].GetMethod().DeclaringType; + while (i < frames.Length && IsInterpreterRunFrame(frames[i])) { + i++; + } + if (i == frames.Length) { + return false; + } + var next = frames[i].GetMethod(); + return next != null && !(next.DeclaringType == interpreterType && next.Name == "HandleException"); + } + + private static bool IsInterpreterRunFrame(StackFrame/*!*/ frame) { + var method = frame.GetMethod(); + return method != null && InterpretedFrame.IsInterpretedFrame(method); + } + [MethodImpl(MethodImplOptions.NoInlining)] // CF private static string GetFileName(StackFrame/*!*/ frame) { return frame.GetFileName(); From 19b24c7b08fec9686bc1a0f09717a4d0f7c2f596 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:55:55 +0200 Subject: [PATCH 30/94] Gate the keyword-call speedups (ec97886c, 0e70f13d, f91fe293) Verified on f91fe293 (this commit changes no files): - Util/bench call_kwargs, best of 5 interleaved in one bench-lock call: base 0.729 s -> 0.423 s (base runs 0.73-1.05 s, mine 0.42-0.48 s). - ruby-bench keyword_args (Util/rubybench/run.sh --ir-only, ms per iteration): base 2960 -> 1627. - In-process best-of-4 thread CPU time per 1M calls, base -> mine: call_kwargs 0.58 -> 0.31 s, add(left:, right:) 0.56 -> 0.28 s, def gen(x, y = 1, *r, k: 1) called gen(i, 2, k: 3) 2.42 -> 0.59 s, keyword method called without keywords 0.34 -> 0.24 s, **rest 0.74 -> 0.43 s. - Util/bench --list (all but raise_rescue and fiber_switch), base and mine alternating: no regression; the runs that looked slower on one sample (yield_3, lambda_call, ivar_set, const_lookup, gvar_get, send_call) are within noise on best of 3. - spec/core method, unboundmethod, proc, hash, symbol, kernel: 3616 examples, 0 failures, 0 errors (base: 3616/0/0). Gates: sp-tests 0, sp-lang 2934/0/0, call_kwargs 0.729s -> 0.423s Co-Authored-By: Claude Opus 5.5 From 431297d0fb5992bed1d6d14358e7df6e56f1c57f Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 08:04:46 +0200 Subject: [PATCH 31/94] Share rules for Method#call and CLR receivers; fix a rebind loop Method#call: a rule for `m.call` restricted the call to that very Method object and burnt its receiver into the rule as a constant, so every `instance_method(:m).bind(obj).call` - IronRuby's own require_relative, for one - bound and light-compiled a rule of its own (121 at `ir -e 1`). For library, Ruby and define_method methods the rule now tests the Method's member, name and receiver CLR type and reads the receiver from the Method object, so one rule serves every such Method. Shared rules (previous commit) now also cover nil and CLR receivers whose type test is a plain class-version check (no singletons, no instances of a Ruby subclass): a String, Array, Hash or Integer site whose class version was bumped by a definition elsewhere reuses the rule instead of building one. A shared rule's test compares with the version it was last bound for before it looks in the set, which keeps hot rules as cheap as before. Fix: a shared rule is now handed out only for a version the set did not hold yet. Before, a site whose shared rule had a compiled replacement ready and whose arguments failed one of the rule's conditions (a splat of another length) alternated between the interpreted rule and its compiled form forever. `ir -e 1`: 1052 rules built -> 823 (both commits: 2326 -> 823). Best of N in one bench-lock call each: 3000 x `um.bind(o).call` 0.947 s -> 0.011 s; 400 anonymous class bodies (attr_reader, attr_accessor, private, alias_method, const_set, const_defined?) 1.700 s -> 0.143 s (best of 3). Startup vs base, both commits, best of 7: `ir -e 1` 2555 -> 2110 ms (-17%; base runs 2555-2789), `--disable-gems -e 1` 1803 -> 1495 ms (-17%; base 1803-2298). Util/bench array_index, hash_lookup, str_concat, respond_to, int_arith (best of 3): no change beyond noise (array_index 0.252 -> 0.260 with base runs 0.252-0.325). spec/core unboundmethod, nil, hash, array, symbol: 3865 examples, 0 failures, as base. Gates: sp-tests 0, sp-lang 2934/0/0, ir -e 1 2.555s -> 2.110s (best of 7, both commits), bind_call 0.947s -> 0.011s Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/RubyMethod.cs | 37 +++++++++++++++++++ Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs | 13 +++++-- Src/Ruby/Runtime/Calls/RubyCallAction.cs | 39 ++++++++++++++------- Src/Ruby/Runtime/Calls/RuleVersionSet.cs | 22 +++++++++--- 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/Src/Ruby/Builtins/RubyMethod.cs b/Src/Ruby/Builtins/RubyMethod.cs index 7cd421d2..554f12dd 100644 --- a/Src/Ruby/Builtins/RubyMethod.cs +++ b/Src/Ruby/Builtins/RubyMethod.cs @@ -167,6 +167,33 @@ internal void BuildInvoke(MetaObjectBuilder/*!*/ metaBuilder, CallArguments/*!*/ Assert.NotNull(metaBuilder, args); Debug.Assert(args.Target == this); + // A call to these methods depends on the method, the name it is called by and the arguments (self included), not on + // which Method object holds them. Each `instance_method(:m).bind(obj).call` makes a new Method, so a rule that took + // the Method and its receiver as constants was bound and compiled anew for every call of such a site. + if (_methodMissingName == null && IsReceiverIndependent(_info)) { + var method = Ast.Convert(args.TargetExpression, typeof(RubyMethod)); + var receiver = Ast.Property(method, TargetProperty); + + // The receiver's CLR type is restricted here: the overload resolver takes self for granted. + metaBuilder.AddRestriction(Ast.AndAlso( + Ast.TypeIs(args.TargetExpression, typeof(RubyMethod)), + Ast.AndAlso( + Ast.AndAlso( + Ast.Equal(Ast.Property(method, InfoProperty), AstUtils.Constant(_info, typeof(RubyMemberInfo))), + Ast.Equal(Ast.Property(method, NameProperty), AstUtils.Constant(_name)) + ), + Ast.AndAlso( + Ast.Not(Ast.Property(method, IsMethodMissingProperty)), + (_target == null) ? (Expression)Ast.Equal(receiver, AstUtils.Constant(null)) : Ast.TypeEqual(receiver, _target.GetType()) + ) + ) + )); + + args.SetTarget(receiver, _target); + _info.BuildCall(metaBuilder, args, _name); + return; + } + // first argument must be this method: metaBuilder.AddRestriction(Ast.Equal(args.TargetExpression, AstUtils.Constant(this))); @@ -192,6 +219,16 @@ internal void BuildInvoke(MetaObjectBuilder/*!*/ metaBuilder, CallArguments/*!*/ } } + private static bool IsReceiverIndependent(RubyMemberInfo/*!*/ info) { + Type type = info.GetType(); + return type == typeof(RubyLibraryMethodInfo) || type == typeof(RubyMethodInfo) || type == typeof(RubyLambdaMethodInfo); + } + + private static readonly System.Reflection.PropertyInfo/*!*/ InfoProperty = typeof(RubyMethod).GetProperty("Info"); + private static readonly System.Reflection.PropertyInfo/*!*/ NameProperty = typeof(RubyMethod).GetProperty("Name"); + private static readonly System.Reflection.PropertyInfo/*!*/ TargetProperty = typeof(RubyMethod).GetProperty("Target"); + private static readonly System.Reflection.PropertyInfo/*!*/ IsMethodMissingProperty = typeof(RubyMethod).GetProperty("IsMethodMissing"); + #endregion #region Debug View diff --git a/Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs b/Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs index 71749ba6..726fa0eb 100644 --- a/Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs +++ b/Src/Ruby/Runtime/Calls/MetaObjectBuilder.cs @@ -310,7 +310,7 @@ internal void AddTargetTypeTest(object target, RubyClass/*!*/ targetClass, Expre // singleton nil: if (target == null) { AddRestriction(Ast.Equal(targetParameter, AstUtils.Constant(null))); - AddVersionTest(context.NilClass); + AddVersionTest(context.NilClass, versionSet); return; } @@ -348,7 +348,7 @@ internal void AddTargetTypeTest(object target, RubyClass/*!*/ targetClass, Expre // no: there is no singleton subclass of target class that defines any method being called: AddTypeRestriction(target.GetType(), targetParameter); - AddVersionTest(targetClass); + AddVersionTest(targetClass, versionSet); } else if (targetClass.IsSingletonClass) { @@ -392,6 +392,15 @@ internal void AddVersionTest(RubyClass/*!*/ cls) { AddCondition(Ast.Equal(Ast.Field(AstUtils.Constant(cls.Version), Fields.VersionHandle_Method), AstUtils.Constant(cls.Version.Method))); } + private void AddVersionTest(RubyClass/*!*/ cls, RuleVersionSet versionSet) { + if (versionSet != null && versionSet.TryAdd(cls.Version.Method)) { + // #{cls.Version}.Method in #{versionSet} + AddCondition(versionSet.MakeTest(Ast.Field(AstUtils.Constant(cls.Version), Fields.VersionHandle_Method))); + } else { + AddVersionTest(cls); + } + } + internal void AddSplattedArgumentTest(IList/*!*/ value, Expression/*!*/ expression, out int listLength, out ParameterExpression/*!*/ listVariable) { // Every splatted call comes through here, whoever built the list - `f(*args)' in Ruby // source, Kernel#send, an Enumerator replaying the arguments it was made with. A hash diff --git a/Src/Ruby/Runtime/Calls/RubyCallAction.cs b/Src/Ruby/Runtime/Calls/RubyCallAction.cs index 05a3a746..0e5de80e 100644 --- a/Src/Ruby/Runtime/Calls/RubyCallAction.cs +++ b/Src/Ruby/Runtime/Calls/RubyCallAction.cs @@ -213,15 +213,7 @@ internal SharedRule(RubyMemberInfo/*!*/ member, Type/*!*/ targetType, Type/*!*/ [ThreadStatic] private static RubyCallAction _PendingVersionSetOwner; - // The shared rule last handed out on this thread. A site that comes right back for another rule didn't match it - // (its arguments are of other types, say) and gets a rule of its own. - [ThreadStatic] - private static SharedRule _LastSharedRule; - internal override T BindShared(object[]/*!*/ args) { - var lastShared = _LastSharedRule; - _LastSharedRule = null; - RubyContext context = Context; if (context == null || Signature.ResolveOnly || (Signature.Flags & ~SharableCallFlags) != 0 || context.HasRefinements || TracePoint.CCallTracing) { @@ -238,9 +230,16 @@ internal override T BindShared(object[]/*!*/ args) { target = args[0]; } - // The type test of a Ruby object is only a version check (see MetaObjectBuilder.AddTargetTypeTest): - if (!(target is IRubyObject)) { + // The type test of a Ruby object is only a version check (see MetaObjectBuilder.AddTargetTypeTest), and so is that of nil + // and of a CLR object whose class has neither singletons nor instances of a Ruby subclass. Not true/false: they are + // told apart by value. + Type targetType; + if (target == null) { + targetType = typeof(DynamicNull); + } else if (target is bool) { return null; + } else { + targetType = target.GetType(); } // an object of another runtime: @@ -253,6 +252,9 @@ internal override T BindShared(object[]/*!*/ args) { MethodResolutionResult method; using (context.ClassHierarchyLocker()) { targetClass.InitializeMethodsNoLock(); + if (target != null && !(target is IRubyObject) && !HasPlainClrTypeTest(targetClass, _methodName)) { + return null; + } version = targetClass.Version.Method; method = targetClass.ResolveMethodForSiteNoLock(_methodName, GetVisibilityContext(Signature, scope), MethodLookup.Default); } @@ -264,7 +266,6 @@ internal override T BindShared(object[]/*!*/ args) { return null; } - Type targetType = target.GetType(); SharedRule shared = null; var sharedRules = _sharedRules; if (sharedRules != null) { @@ -296,14 +297,26 @@ internal override T BindShared(object[]/*!*/ args) { return BindSharedRule(shared, args); } - if (lastShared == shared || !shared.Versions.TryAdd(version)) { + // The shared rule is handed out for a version it does not hold yet, and only then: a call that comes here although + // the rule holds its version didn't match the rule (its arguments are of other types, say) and gets a rule of its own. + if (!shared.Versions.TryAdd(version)) { return null; } - _LastSharedRule = shared; return (T)sharedRule; } + // MetaObjectBuilder.AddTargetTypeTest tests a CLR object's type and its class version - and nothing else - in this case: + private static bool HasPlainClrTypeTest(RubyClass/*!*/ targetClass, string/*!*/ methodName) { + if (targetClass.IsSingletonClass || targetClass.GetNonSingletonClass().IsRubyClass) { + return false; + } + var nominalClass = targetClass.NominalClass; + var singletonMethods = nominalClass.ClrSingletonMethods; + return !nominalClass.HasAdoptedInstances && (singletonMethods == null || + !singletonMethods.ContainsKey(methodName) && !singletonMethods.ContainsKey(Symbols.MethodMissing)); + } + private T/*!*/ BindSharedRule(SharedRule/*!*/ shared, object[]/*!*/ args) where T : class { var versionSet = new RuleVersionSet(); T result; diff --git a/Src/Ruby/Runtime/Calls/RuleVersionSet.cs b/Src/Ruby/Runtime/Calls/RuleVersionSet.cs index a50943e2..7b915749 100644 --- a/Src/Ruby/Runtime/Calls/RuleVersionSet.cs +++ b/Src/Ruby/Runtime/Calls/RuleVersionSet.cs @@ -41,6 +41,12 @@ public sealed class RuleVersionSet { private int[] _slots; private int _count; + /// + /// The version added last. A rule that runs hot mostly runs for the class (state) it was last bound for, + /// so its test compares with this field before it looks in the set. + /// + public int LastVersion; + // stop growing: further classes get rules of their own private const int MaxCount = 4096; @@ -74,7 +80,7 @@ public bool Contains(int version) { } /// - /// Adds a version the rule has been verified to hold for. Returns false if the set is full. + /// Adds a version the rule has been verified to hold for. Returns false if the set already holds it or is full. /// internal bool TryAdd(int version) { if (version == 0) { @@ -83,7 +89,7 @@ internal bool TryAdd(int version) { lock (this) { if (Contains(version)) { - return true; + return false; } if (_count >= MaxCount) { @@ -104,6 +110,7 @@ internal bool TryAdd(int version) { Insert(slots, version); } _count++; + LastVersion = version; return true; } } @@ -123,12 +130,19 @@ public static bool IsValid(RuleVersionSet/*!*/ set, int version) { private static MethodInfo _IsValidMethod; + private static FieldInfo _LastVersionField; + /// - /// (#{versionExpression}) in #{set} + /// #{versionExpression} == #{set}.LastVersion || (#{versionExpression}) in #{set} /// internal Expression/*!*/ MakeTest(Expression/*!*/ versionExpression) { var method = _IsValidMethod ?? (_IsValidMethod = CallInstruction.CacheFunc(IsValid)); - return Expression.Call(method, AstUtils.Constant(this), versionExpression); + var lastVersion = _LastVersionField ?? (_LastVersionField = typeof(RuleVersionSet).GetField("LastVersion")); + var set = AstUtils.Constant(this); + return Expression.OrElse( + Expression.Equal(versionExpression, Expression.Field(set, lastVersion)), + Expression.Call(method, set, versionExpression) + ); } } } From c90d4a307af54da6a186bd4d801c8b1d78dabce4 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 07:46:08 +0200 Subject: [PATCH 32/94] Class lookups without locks: a read cache for CLR types' classes, lock-free HasAncestor RubyContext.GetOrCreateClass took ModuleCacheLock and probed a Dictionary for every GetClassOf on a CLR value - every call on an Integer, Float or other non-Ruby object asks for its class - and RubyModule.HasAncestor took the class hierarchy lock and allocated a closure for every is_a?, kind_of? and Module#===. - GetOrCreateClass reads a ConcurrentDictionary first. Entries are written under the lock, and ReplaceModuleInCacheNoLock (the one place a CLR type's class changes, at library load) evicts its type. - HasAncestor walks _prepends, _mixins and the superclass chain without the lock. The arrays are replaced, never mutated in place, under the lock, so the walk is memory safe; a concurrent include/prepend is seen or not, as before. Release, best of 3 interleaved under bench-lock (loaded box), tip -> this: case_when_class 0.574s -> 0.406s (1.41x) module_function_call 1.610s -> 1.291s (1.25x) ruby-bench nbody 454ms -> 421ms (1.08x) ruby-bench blurhash: GetOrCreateClass was 40% of self time; the traced run (warmup + 4 iterations) went 26.4s -> 19.6s with HasAncestor/locks gone from the top. Gates: sp-tests 0, sp-lang 2934/0/0, spec/core class+module+kernel is_a/kind_of/instance_of+integer ===: 1123/0/0 (= base; in module-before-class order Class#subclasses can list a not-yet-collected anonymous class from undef_method_spec - GC timing, the pushed tip without this change does the same) Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/RubyModule.cs | 26 ++++++++++++++++++++++++-- Src/Ruby/Runtime/RubyContext.cs | 16 +++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Src/Ruby/Builtins/RubyModule.cs b/Src/Ruby/Builtins/RubyModule.cs index c36e0912..cefc548a 100644 --- a/Src/Ruby/Builtins/RubyModule.cs +++ b/Src/Ruby/Builtins/RubyModule.cs @@ -2939,10 +2939,32 @@ public static bool IsModuleType(Type/*!*/ type) { } // thread-safe: + // thread-safe without the class hierarchy lock: is_a?, kind_of? and Module#=== call this on + // every check, and the lock plus the closure ForEachAncestor needs were most of their cost. + // The walk only reads _prepends and _mixins, which are replaced, never mutated in place, under + // the lock, and the superclass chain; an include/prepend running concurrently is seen or not, + // just as with the lock, which only orders this check before or after it. public bool HasAncestor(RubyModule/*!*/ module) { - using (Context.ClassHierarchyLocker()) { - return HasAncestorNoLock(module); + RubyClass cls = this as RubyClass; + if (cls == null) { + return IsDeclaredAncestor(module); + } + for (RubyClass c = cls; c != null; c = c.SuperClass) { + if (c.IsDeclaredAncestor(module)) return true; + } + return false; + } + + // The modules ForEachDeclaredAncestor visits, read without the lock (see HasAncestor). + private bool IsDeclaredAncestor(RubyModule/*!*/ module) { + if (this == module) return true; + foreach (RubyModule p in _prepends) { + if (p == module) return true; } + foreach (RubyModule m in _mixins) { + if (m == module) return true; + } + return false; } public bool HasAncestorNoLock(RubyModule/*!*/ module) { diff --git a/Src/Ruby/Runtime/RubyContext.cs b/Src/Ruby/Runtime/RubyContext.cs index c9e38f6e..70c00d3e 100644 --- a/Src/Ruby/Runtime/RubyContext.cs +++ b/Src/Ruby/Runtime/RubyContext.cs @@ -20,6 +20,7 @@ #endif using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Dynamic; @@ -1054,6 +1055,7 @@ internal void AddModuleToCacheNoLock(Type/*!*/ type, RubyModule/*!*/ module) { internal void ReplaceModuleInCacheNoLock(Type/*!*/ type, RubyModule/*!*/ module) { Assert.NotNull(type, module); _moduleCache[type] = module; + _classReadCache.TryRemove(type, out _); } internal void AddNamespaceToCacheNoLock(NamespaceTracker/*!*/ namespaceTracker, RubyModule/*!*/ module) { @@ -1108,9 +1110,21 @@ internal bool TryGetClassNoLock(Type/*!*/ type, out RubyClass result) { } } + // Lock-free read side of GetOrCreateClass. Every call on an Integer, Float or other CLR value + // asks for its class (GetClassOf), and taking ModuleCacheLock for a dictionary probe each time + // was a third or more of numeric code. A CLR type's class, once created, is only replaced by + // ReplaceModuleInCacheNoLock, which evicts the type here; entries are written under the lock. + private readonly ConcurrentDictionary/*!*/ _classReadCache = new ConcurrentDictionary(); + internal RubyClass/*!*/ GetOrCreateClass(Type/*!*/ type) { + RubyClass result; + if (_classReadCache.TryGetValue(type, out result)) { + return result; + } lock (ModuleCacheLock) { - return GetOrCreateClassNoLock(type); + result = GetOrCreateClassNoLock(type); + _classReadCache[type] = result; + return result; } } From 80f014574753083ad7513fb2440cfd258e0f0d71 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 08:08:28 +0200 Subject: [PATCH 33/94] Array: its own map/collect and select/filter, as in MRI; each without interface calls Array#map and #select were Enumerable's: a dynamic call of #each with a C# block of their own that yielded on to the given block - two block invocations, and for #map a freshly packed argument array, per element. MRI gives Array its own (rb_ary_collect, rb_ary_select), which walk the array and yield each element once. IListOps now does the same, which also fixes three deviations from MRI the Enumerable route had: a subclass that overrides #each no longer changes what #map/#select see, and #select adds the element as it is after the block ran (rb_ary_elt) - [1,2,3,4].select { delete_at(0) on the first; true } is [2,3,4] as in MRI, not [1,3,4]. #filter is registered on the same method (MRI: an alias of Array#select; ruby4.rb's alias_method would otherwise find Enumerable#filter already defined). Without a block they return the same Enumerator as before. Array#each (and the new loops) read a RubyArray through its non-virtual Count and indexer instead of IList. Initializers regenerated (map, collect, select, filter added on IList; no registered name lost). bench.rb gains map_loop and select_loop. Release, on-CPU seconds (main thread schedstat), best of 5, base/mine alternating in one bench-lock call (cumulative with the previous commits): map_loop 0.504 -> 0.251 (base spread 0.504-0.530; CRuby 0.139) select_loop 0.498 -> 0.296 (base spread 0.498-0.608; CRuby 0.165) each_loop 0.414 -> 0.348 (base spread 0.414-0.484) ruby-bench block_methods (ms/iteration, wall, best of 2): 15685 -> 13431. Spec dirs: core/array + core/enumerable 193 files, 3434 examples, 0 failures, 0 errors (base: array 132/2898/0/0, enumerable 0/0). Gates: sp-tests 0, sp-lang 2934/0/0, map_loop 0.504s -> 0.251s Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Extensions/IListOps.cs | 66 +++++++++++++++++++++++++ Src/Libraries/Initializers.Generated.cs | 24 +++++++++ Util/bench/bench.rb | 10 ++++ 3 files changed, 100 insertions(+) diff --git a/Src/Libraries/Extensions/IListOps.cs b/Src/Libraries/Extensions/IListOps.cs index 69d27503..49671b5a 100644 --- a/Src/Libraries/Extensions/IListOps.cs +++ b/Src/Libraries/Extensions/IListOps.cs @@ -1127,6 +1127,18 @@ private static object DeleteIf(BlockParam/*!*/ block, IList/*!*/ self, out bool [RubyMethod("each")] public static object Each([NotNull]BlockParam/*!*/ block, IList/*!*/ self) { + var array = self as RubyArray; + if (array != null) { + // the same loop, without an interface call for Count and the element every time round + for (int i = 0; i < array.Count; i++) { + object result; + if (block.Yield(array[i], out result)) { + return result; + } + } + return self; + } + for (int i = 0; i < self.Count; i++) { object result; if (block.Yield(self[i], out result)) { @@ -1136,6 +1148,60 @@ public static object Each([NotNull]BlockParam/*!*/ block, IList/*!*/ self) { return self; } + #region map, collect, select + + // Array's own, as in MRI (rb_ary_collect, rb_ary_select): they walk the array rather than + // run Enumerable's versions, which call #each with a block of their own that yields to the + // given block - two block invocations and a packed argument array per element. Like MRI's, + // they do not call an #each that a subclass defines. + + [RubyMethod("collect")] + [RubyMethod("map")] + public static Enumerator/*!*/ GetMapEnumerator(IList/*!*/ self) { + return new Enumerator(self, "map"); + } + + [RubyMethod("collect")] + [RubyMethod("map")] + public static object Map([NotNull]BlockParam/*!*/ collector, IList/*!*/ self) { + var result = new RubyArray(self.Count); + var array = self as RubyArray; + for (int i = 0; i < (array != null ? array.Count : self.Count); i++) { + object blockResult; + if (collector.Yield(array != null ? array[i] : self[i], out blockResult)) { + return blockResult; + } + result.Add(blockResult); + } + return result; + } + + [RubyMethod("filter")] + [RubyMethod("select")] + public static Enumerator/*!*/ GetSelectEnumerator(IList/*!*/ self) { + return new Enumerator(self, "select"); + } + + [RubyMethod("filter")] + [RubyMethod("select")] + public static object Select([NotNull]BlockParam/*!*/ predicate, IList/*!*/ self) { + var result = new RubyArray(); + var array = self as RubyArray; + for (int i = 0; i < (array != null ? array.Count : self.Count); i++) { + object blockResult; + if (predicate.Yield(array != null ? array[i] : self[i], out blockResult)) { + return blockResult; + } + if (Protocols.IsTrue(blockResult)) { + // MRI adds the element as it is once the block has run (rb_ary_elt) + result.Add(i < self.Count ? self[i] : null); + } + } + return result; + } + + #endregion + [RubyMethod("each_index")] public static Enumerator/*!*/ EachIndex(IList/*!*/ self) { return new Enumerator(self, "each_index"); diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index 9013034c..49923f74 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -9073,6 +9073,12 @@ private static void LoadSystem__Collections__IList_Instance(IronRuby.Builtins.Ru new Func(IronRuby.Builtins.IListOps.Clear) ); + DefineLibraryMethod(module, "collect", 0x51, + 0x00000000U, 0x00000001U, + new Func(IronRuby.Builtins.IListOps.GetMapEnumerator), + new Func(IronRuby.Builtins.IListOps.Map) + ); + DefineLibraryMethod(module, "collect!", 0x51, 0x00000000U, new Func(IronRuby.Builtins.IListOps.CollectInPlace) @@ -9166,6 +9172,12 @@ private static void LoadSystem__Collections__IList_Instance(IronRuby.Builtins.Ru new Func, IronRuby.Runtime.BlockParam, System.Collections.IList, IronRuby.Builtins.Range, System.Object>(IronRuby.Builtins.IListOps.Fill) ); + DefineLibraryMethod(module, "filter", 0x51, + 0x00000000U, 0x00000001U, + new Func(IronRuby.Builtins.IListOps.GetSelectEnumerator), + new Func(IronRuby.Builtins.IListOps.Select) + ); + DefineLibraryMethod(module, "find_index", 0x51, 0x00000000U, 0x00000001U, 0x00000000U, new Func(IronRuby.Builtins.IListOps.GetFindIndexEnumerator), @@ -9249,6 +9261,12 @@ private static void LoadSystem__Collections__IList_Instance(IronRuby.Builtins.Ru new Func(IronRuby.Builtins.IListOps.Length) ); + DefineLibraryMethod(module, "map", 0x51, + 0x00000000U, 0x00000001U, + new Func(IronRuby.Builtins.IListOps.GetMapEnumerator), + new Func(IronRuby.Builtins.IListOps.Map) + ); + DefineLibraryMethod(module, "map!", 0x51, 0x00000000U, new Func(IronRuby.Builtins.IListOps.CollectInPlace) @@ -9335,6 +9353,12 @@ private static void LoadSystem__Collections__IList_Instance(IronRuby.Builtins.Ru new Func(IronRuby.Builtins.IListOps.ReverseIndex) ); + DefineLibraryMethod(module, "select", 0x51, + 0x00000000U, 0x00000001U, + new Func(IronRuby.Builtins.IListOps.GetSelectEnumerator), + new Func(IronRuby.Builtins.IListOps.Select) + ); + DefineLibraryMethod(module, "shift", 0x51, 0x00000000U, new Func(IronRuby.Builtins.IListOps.Shift) diff --git a/Util/bench/bench.rb b/Util/bench/bench.rb index 1ba573ad..84332019 100644 --- a/Util/bench/bench.rb +++ b/Util/bench/bench.rb @@ -391,6 +391,16 @@ def ack(mm, k) end end +bench("map_loop") do + a = (0...1000).to_a; r = n(2_000) + r.times { a.map { |x| x + 1 } } +end + +bench("select_loop") do + a = (0...1000).to_a; r = n(2_000) + r.times { a.select { |x| x > 500 } } +end + name = ARGV[0] unless BENCH.key?(name) if name == "--list" From 97e6ca89c3c94adb05b61d431dec2a9349abaeb7 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 08:09:42 +0200 Subject: [PATCH 34/94] Do not copy a block's arguments for a super it cannot make The general keyword lowering kept a copy of the incoming argument list for a parameterless `super` in the body - for blocks too, where it is never read: a `super` in a block forwards the arguments of the method the block is in (SuperCall uses gen.CurrentMethod.Parameters). Every call of a block with keyword parameters (all of which go through this lowering, since a proc auto-splats) allocated that copy for nothing. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 --- Src/Prism/PrismAstBridge.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index 80f30ee1..47441979 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -2124,9 +2124,14 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List // Everything below consumes ?args? destructively, so a parameterless `super` // inside the body would find nothing left to forward. Keep the list as it // arrived - trailing keyword hash included - and let SuperCall splat that. - var superArgs = CurrentScope.AddVariable("?superargs?", span); - statements.Add(new SimpleAssignmentExpression(superArgs, - SplatParameterRead.Copy(args, span), null, span)); + // Only a method needs it: a `super` in a block forwards the arguments of the + // method the block is in (SuperCall reads gen.CurrentMethod.Parameters). + LocalVariable superArgs = null; + if (isMethod) { + superArgs = CurrentScope.AddVariable("?superargs?", span); + statements.Add(new SimpleAssignmentExpression(superArgs, + SplatParameterRead.Copy(args, span), null, span)); + } // A trailing Hash is how a keyword call arrives here, since keywords have no // calling-convention slot of their own. But when a block auto-splats a single From ca6b799b7ba0c9a64b3391383c29fa35a5aa23de Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 08:09:42 +0200 Subject: [PATCH 35/94] Take the lowered splat apart without reallocating; build **rest in one pass RubyArray.RemoveAt shrinks a small array by copying it into a new one on nearly every removal, so shifting a two-element splat apart allocated twice. The prologue helpers now use RubyArray.TakeFirst / TakeLast, which only move the start or the end of the live range - the splat is consumed and dropped, so its spare capacity does not matter. The **rest parameter was a full copy of the keyword hash followed by a Remove (leaving a tombstone) per declared keyword; Hash.CopyWithoutKeywords copies only the other entries, with the default and comparer as before. Thread CPU, best of 3-4 in-process, per 300K/1M calls (gated f91fe293 -> this): yield(1, k: 2) to { |a, k: 1| } 0.15/0.18 -> 0.12/0.13 s, def gen(x, y = 1, *r, k: 1) 0.42/0.54 -> 0.39/0.44 s, **rest 0.42/0.47 -> 0.37/0.41 s. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/Hash.Keywords.cs | 29 ++++++++++++++++++++ Src/Ruby/Builtins/RubyArray.cs | 44 ++++++++++++++++++++++++++++++ Src/Ruby/Runtime/RubyOps.cs | 33 ++++------------------ 3 files changed, 78 insertions(+), 28 deletions(-) diff --git a/Src/Ruby/Builtins/Hash.Keywords.cs b/Src/Ruby/Builtins/Hash.Keywords.cs index f71b3747..fab98565 100644 --- a/Src/Ruby/Builtins/Hash.Keywords.cs +++ b/Src/Ruby/Builtins/Hash.Keywords.cs @@ -25,6 +25,35 @@ namespace IronRuby.Builtins { public partial class Hash { private const int IdentityScanLimit = 8; + /// + /// A copy of this hash - default value or proc and comparer included, as the copy + /// constructor makes it - without the keys among , which are + /// compared by identity (see ): a **rest parameter. + /// + internal Hash/*!*/ CopyWithoutKeywords(object/*!*/[]/*!*/ names) { + var result = new Hash(_comparer, _liveCount); + result._defaultProc = _defaultProc; + result._defaultValue = DefaultValue; + result._comparesByIdentity = _comparesByIdentity; + + Entry[] entries = _entries; + int count = _count; + for (int i = _start; i < count; i++) { + if (entries[i].Next == TombstoneNext) { + continue; + } + object key = entries[i].Key; + int j = 0; + while (j < names.Length && names[j] != key) { + j++; + } + if (j == names.Length) { + result[key] = entries[i].Value; + } + } + return result; + } + /// /// The value stored under , which must be a key that is equal only to /// itself (a Symbol). Entry is tried first: callers mostly pass diff --git a/Src/Ruby/Builtins/RubyArray.cs b/Src/Ruby/Builtins/RubyArray.cs index fcaea669..609f4a38 100644 --- a/Src/Ruby/Builtins/RubyArray.cs +++ b/Src/Ruby/Builtins/RubyArray.cs @@ -573,6 +573,50 @@ public void RemoveAt(int index) { RemoveRange(index, 1); } + // Taking an array apart from the ends without RemoveRange's reallocation, which it does to + // shrink a small array on nearly every removal. For the splat of a lowered parameter list, + // which is consumed and then dropped (RubyOps.ShiftSplatArgument and its neighbours). + + /// Removes and returns the first element; nil if there is none. + internal object TakeFirst() { + if (_count == 0) { + return null; + } + Mutate(); + object result = _content[_start]; + _content[_start] = null; + _start++; + _count--; + ObjectInvariant(); + return result; + } + + /// Removes the first elements and returns them as a new array. + internal RubyArray/*!*/ TakeFirst(int count) { + ContractUtils.RequiresArrayRange(_count, 0, count, "index", "count"); + var result = new RubyArray(this, 0, count); + if (count > 0) { + Mutate(); + Utils.Fill(_content, _start, null, count); + _start += count; + _count -= count; + ObjectInvariant(); + } + return result; + } + + /// Removes and returns the last element, of which there must be one. + internal object TakeLast() { + Debug.Assert(_count > 0); + Mutate(); + int last = _start + _count - 1; + object result = _content[last]; + _content[last] = null; + _count--; + ObjectInvariant(); + return result; + } + public bool Remove(object item) { int index = IndexOf(item); if (index >= 0) { diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index a0ca63b9..255aec2a 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -1782,9 +1782,7 @@ public static object GetKeywordArgument(object keywords, RubySymbol/*!*/ name, i var list = (RubyArray)args; int last = list.Count - 1; if (last >= 0 && IsKeywordArgumentsHash(list[last])) { - var result = (Hash)list[last]; - list.RemoveAt(last); - return result; + return (Hash)list.TakeLast(); } return MakeHash0(scope); } @@ -1822,13 +1820,7 @@ public static void CheckSplatArity(object args, int minimum, int maximum) { /// The next positional argument; nil if there is none left (a block's). [Emitted] public static object ShiftSplatArgument(object args) { - var list = (RubyArray)args; - if (list.Count == 0) { - return null; - } - object result = list[0]; - list.RemoveAt(0); - return result; + return ((RubyArray)args).TakeFirst(); } /// @@ -1838,12 +1830,7 @@ public static object ShiftSplatArgument(object args) { [Emitted] public static object ShiftOptionalSplatArgument(object args, int postCount) { var list = (RubyArray)args; - if (list.Count <= postCount) { - return DefaultArgument; - } - object result = list[0]; - list.RemoveAt(0); - return result; + return (list.Count <= postCount) ? DefaultArgument : list.TakeFirst(); } /// @@ -1853,12 +1840,7 @@ public static object ShiftOptionalSplatArgument(object args, int postCount) { [Emitted] public static RubyArray/*!*/ ShiftRestSplatArguments(object args, int postCount) { var list = (RubyArray)args; - int count = Math.Max(0, list.Count - postCount); - var result = new RubyArray(list, 0, count); - if (count > 0) { - list.RemoveRange(0, count); - } - return result; + return list.TakeFirst(Math.Max(0, list.Count - postCount)); } /// @@ -1867,12 +1849,7 @@ public static object ShiftOptionalSplatArgument(object args, int postCount) { /// [Emitted] public static Hash/*!*/ MakeKeywordRest(object keywords, RubySymbol/*!*/[]/*!*/ names) { - var hash = (Hash)keywords; - var result = new Hash(hash); - for (int i = 0; i < names.Length && result.Count > 0; i++) { - result.Remove(names[i]); - } - return result; + return ((Hash)keywords).CopyWithoutKeywords(names); } /// From de760e0804b110797c48d41877e22d21715acaad Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 08:13:44 +0200 Subject: [PATCH 36/94] DLR: reach Exception._data directly instead of the virtual Data property IronRuby's generated subclasses for Ruby-defined exception classes override every CLR virtual - Exception.get_Data included - with a stub that looks for a Ruby method first, which made each exception-data lookup (8-10 per raise) cost microseconds: GetData was 28% of raise_rescue's loop. ExceptionUtils now reads and initialises Exception's own _data field through [UnsafeAccessor] (.NET 8+), falling back to the Data property if the runtime has no such field, and to the ConditionalWeakTable for a read-only Data. raise_rescue (Util/bench, scale 1, Release, best of 3 in one bench-lock call): 3.20 s (3.20-3.44, previous commit) -> 2.40 s (2.40-2.51); base in the same call 13.92 s. Gates: sp-tests 0, sp-lang 2934/0/0, raise_rescue 3.20s -> 2.40s (base 13.92s); spec/core/exception+kernel raise/fail/caller/caller_locations/catch/throw/loop+thread raise/backtrace+enumerator next+fiber 742 ex 1 F = base; spec/command_line 175/0/0 = base Co-Authored-By: Claude Opus 5.5 --- Build/dlr-patches/0100-exceptions.patch | 72 +++++++++++++++++++------ 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/Build/dlr-patches/0100-exceptions.patch b/Build/dlr-patches/0100-exceptions.patch index c1d6a4ae..fe1a0c72 100644 --- a/Build/dlr-patches/0100-exceptions.patch +++ b/Build/dlr-patches/0100-exceptions.patch @@ -1,22 +1,26 @@ diff --git a/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs b/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs -index 7176c59..fec49a4 100644 +index 7176c59..047827a 100644 --- a/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs +++ b/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs -@@ -30,6 +30,91 @@ namespace Microsoft.Scripting.Utils { +@@ -30,6 +30,131 @@ namespace Microsoft.Scripting.Utils { public static void RemoveData(this Exception e, object key) { e.Data.Remove(key); } +#elif NETCOREAPP -+ // Exception.Data, as on the desktop: languages attach data to every exception they see go -+ // by (the interpreter its frames, IronRuby its exception state), several times per raise. -+ // The ConditionalWeakTable below costs a dependent handle per exception, which every GC has -+ // to scan, and a global lock plus a LINQ scan per lookup. Exception.Data's dictionary is not -+ // thread-safe, hence the lock on it - uncontended in practice. An exception type whose Data -+ // is not writable gets the table instead. ++ // Exception.Data's dictionary, as on the desktop: languages attach data to every exception they ++ // see go by (the interpreter its frames, IronRuby its exception state), several times per raise. ++ // The ConditionalWeakTable below costs a dependent handle per exception, which every GC has to ++ // scan, and a global lock plus a LINQ scan per lookup. The dictionary is not thread-safe, hence ++ // the lock on it - uncontended in practice. ++ // ++ // The dictionary is reached through Exception's own field where the runtime has it: the Data ++ // property is virtual, and the subclasses a language generates for its own exception classes ++ // override every virtual member to dispatch to the language first (IronRuby's do), which made a ++ // Data lookup cost microseconds. An exception type whose Data is not writable gets the table. + public static object GetData(this Exception e, object key) { -+ var data = e.Data; -+ if (data is null || data.IsReadOnly) { -+ return GetTableData(e, key); ++ var data = GetDictionary(e, false); ++ if (data is null) { ++ return _hasTableData ? GetTableData(e, key) : null; + } + lock (data) { + return data[key]; @@ -24,8 +28,8 @@ index 7176c59..fec49a4 100644 + } + + public static void SetData(this Exception e, object key, object value) { -+ var data = e.Data; -+ if (data is null || data.IsReadOnly) { ++ var data = GetDictionary(e, true); ++ if (data is null) { + SetTableData(e, key, value); + return; + } @@ -35,9 +39,11 @@ index 7176c59..fec49a4 100644 + } + + public static void RemoveData(this Exception e, object key) { -+ var data = e.Data; -+ if (data is null || data.IsReadOnly) { -+ RemoveTableData(e, key); ++ var data = GetDictionary(e, false); ++ if (data is null) { ++ if (_hasTableData) { ++ RemoveTableData(e, key); ++ } + return; + } + lock (data) { @@ -45,9 +51,43 @@ index 7176c59..fec49a4 100644 + } + } + ++ // null: no dictionary yet (and create is false), or one that cannot be written to ++ private static System.Collections.IDictionary GetDictionary(Exception e, bool create) { ++#if NET8_0_OR_GREATER ++ if (_dataFieldAvailable) { ++ try { ++ ref System.Collections.IDictionary field = ref DataField(e); ++ var dictionary = field; ++ if (dictionary is null) { ++ if (!create) { ++ return null; ++ } ++ Interlocked.CompareExchange(ref field, new System.Collections.Specialized.ListDictionary(), null); ++ dictionary = field; ++ } ++ return dictionary.IsReadOnly ? null : dictionary; ++ } catch (MissingFieldException) { ++ _dataFieldAvailable = false; ++ } ++ } ++#endif ++ var data = e.Data; ++ return (data is null || data.IsReadOnly) ? null : data; ++ } ++ ++#if NET8_0_OR_GREATER ++ [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_data")] ++ private static extern ref System.Collections.IDictionary DataField(Exception e); ++ ++ private static bool _dataFieldAvailable = true; ++#endif ++ ++ private static volatile bool _hasTableData; ++ + private static ConditionalWeakTable>> _exceptionData; + + private static void SetTableData(Exception e, object key, object value) { ++ _hasTableData = true; + if (_exceptionData is null) { + Interlocked.CompareExchange(ref _exceptionData, new ConditionalWeakTable>>(), null); + } From 06129b67f7c274a39b3f46a1ff4db77675c96a28 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 08:20:26 +0200 Subject: [PATCH 37/94] Gate the block and splat keyword speedups (97e6ca89, ca6b799b) Verified on ca6b799b (this commit changes no files): - Util/bench call_kwargs, best of 5 interleaved in one bench-lock call: base 0.901 s -> 0.523 s (base runs 0.90-0.97 s, mine 0.52-1.51 s - the box was busy). - yield(1, k: 2) to { |a, k: 1| k }, 300K calls, thread CPU best of 3 in-process: base 0.46 s -> 0.18 s. - Against the previous gated state (f91fe293): yield with keywords 0.15/0.18 -> 0.12/0.13 s, def gen(x, y = 1, *r, k: 1) 0.42/0.54 -> 0.39/0.44 s, **rest 0.42/0.47 -> 0.37/0.41 s. - Util/bench block benchmarks (yield_0, yield_3, proc_call, lambda_call, blk_call_in_method, each_loop, times_loop, block_pass), best of 2 alternating: within noise of base. - spec/core method, unboundmethod, proc, hash, symbol, kernel: 3616 examples, 0 failures, 0 errors (base: 3616/0/0). Gates: sp-tests 0, sp-lang 2934/0/0, call_kwargs 0.901s -> 0.523s Co-Authored-By: Claude Opus 5.5 From 87bfd70aff9601f20dd8e6ebe8e913a144cc2d62 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 08:24:09 +0200 Subject: [PATCH 38/94] Splat `f(**opts)` directly when opts is a Hash A call whose keyword arguments are a single `**opts` built `{}.merge(opts || {})` - a call to the Ruby-level Hash#merge override in ruby4.rb, with its dup, its block and its each - and then copied the result once more in RubyOps.SplatKeywordHash. The merge is there for the #to_hash conversion of a non-Hash; a Hash or nil needs none. Now the value is kept in a temporary and SplatKeywordHashIfHash copies it straight away when it is a Hash or nil, and only anything else takes the merge path (Expression.Coalesce, so the merge is not evaluated otherwise). Same result either way: a fresh keyword hash, or no argument at all for an empty one. Thread CPU, best of 3 in-process, per 300K calls (gated 06129b67 state -> this): def f(*a, **o) = t(*a, **o) 0.51 -> 0.26-0.30 s, def f(**o) = t(1, **o) 0.50 -> 0.25 s. WIP: gates pending. Co-Authored-By: Claude Opus 5.5 --- Src/Prism/PrismAstBridge.cs | 9 ++++++++ Src/Ruby/Compiler/Ast/KeywordArgumentSplat.cs | 21 +++++++++++++++++++ .../Compiler/ReflectionCache.Generated.cs | 2 ++ Src/Ruby/Runtime/RubyOps.cs | 9 ++++++++ 4 files changed, 41 insertions(+) diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index 47441979..4186d744 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -1437,6 +1437,15 @@ private Block OptionalBlock(Pm.PmNode block) { if (block == null && fwdBlock != null) { block = new BlockReference(fwdBlock, Span(arg)); } + } else if (arg is Pm.KeywordHashNode single && single.Elements.Length == 1 && + single.Elements[0] is Pm.AssocSplatNode singleSplat) { + // `f(**opts)`: a Hash (or nil) is splatted as it is; only anything else goes + // through the `{}.merge(opts)` HashExpression would build, for #to_hash + var span = Span(arg); + var temp = NewTemp(singleSplat.Value != null ? Expr(singleSplat.Value) : AnonymousParameter(AnonymousKeywordRestName, singleSplat), + span, out var assignment); + exprs.Add(new KeywordArgumentSplat(assignment, new MethodCall(new HashConstructor(new Maplet[0], true, span), "merge", + new Arguments(new OrExpression(temp, new HashConstructor(new Maplet[0], span), span)), span))); } else if (IsSplattedKeywordHash(arg)) { // `f(**opts)` must pass nothing when opts is empty - see KeywordArgumentSplat. exprs.Add(new KeywordArgumentSplat(Argument(arg))); diff --git a/Src/Ruby/Compiler/Ast/KeywordArgumentSplat.cs b/Src/Ruby/Compiler/Ast/KeywordArgumentSplat.cs index 4ab03a85..3bdbea76 100644 --- a/Src/Ruby/Compiler/Ast/KeywordArgumentSplat.cs +++ b/Src/Ruby/Compiler/Ast/KeywordArgumentSplat.cs @@ -39,11 +39,32 @@ namespace IronRuby.Compiler.Ast { /// Arguments.IndexOfSplatted see it, so no new NodeTypes entry is needed. /// public partial class KeywordArgumentSplat : SplattedArgument { + // `f(**h)` alone: Argument stores h in a temporary, and this is the hash to splat instead + // when h is neither a Hash nor nil - `{}.merge(temporary)`, which applies #to_hash. + private readonly Expression _converted; + public KeywordArgumentSplat(Expression/*!*/ hash) : base(hash) { } + /// + /// `f(**h)`: evaluates h (and keeps it for ), + /// which is splatted as it is when it is a Hash or nil - a copy is made either way - and + /// through otherwise. Building `{}.merge(h)` for every call only + /// to copy the result again cost a Ruby-level Hash#merge per call. + /// + public KeywordArgumentSplat(Expression/*!*/ hash, Expression/*!*/ converted) + : base(hash) { + _converted = converted; + } + internal override MSA.Expression/*!*/ TransformRead(AstGenerator/*!*/ gen) { + if (_converted != null) { + return MSA.Expression.Coalesce( + Methods.SplatKeywordHashIfHash.OpCall(gen.CurrentScopeVariable, AstUtils.Box(Argument.TransformRead(gen))), + Methods.SplatKeywordHash.OpCall(gen.CurrentScopeVariable, AstUtils.Box(_converted.TransformRead(gen))) + ); + } return Methods.SplatKeywordHash.OpCall(gen.CurrentScopeVariable, AstUtils.Box(Argument.TransformRead(gen))); } } diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index 5fbdbdf8..a09ccfdc 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -582,6 +582,8 @@ public static partial class Methods { private static MethodInfo _SplatAppend; public static MethodInfo/*!*/ SplatKeywordHash { get { return _SplatKeywordHash ?? (_SplatKeywordHash = CallInstruction.CacheFunc(RubyOps.SplatKeywordHash)); } } private static MethodInfo _SplatKeywordHash; + public static MethodInfo/*!*/ SplatKeywordHashIfHash { get { return _SplatKeywordHashIfHash ?? (_SplatKeywordHashIfHash = CallInstruction.CacheFunc(RubyOps.SplatKeywordHashIfHash)); } } + private static MethodInfo _SplatKeywordHashIfHash; public static MethodInfo/*!*/ SplatPair { get { return _SplatPair ?? (_SplatPair = CallInstruction.CacheFunc(RubyOps.SplatPair)); } } private static MethodInfo _SplatPair; public static MethodInfo/*!*/ SplatRuby2Keywords { get { return _SplatRuby2Keywords ?? (_SplatRuby2Keywords = CallInstruction.CacheFunc(RubyOps.SplatRuby2Keywords)); } } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 255aec2a..ead2abec 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -1578,6 +1578,15 @@ public static object SetQualifiedConstant(object value, object target, RubyScope /// is only emitted for argument hashes that actually contain a `**` splat. /// The result is fed to the ordinary splatting machinery, so it is either [] or [hash]. /// + /// + /// `f(**h)` with h a Hash or nil: what makes of it. Null for + /// anything else, which the call site first converts with #to_hash (by way of Hash#merge). + /// + [Emitted] + public static RubyArray SplatKeywordHashIfHash(RubyScope/*!*/ scope, object hash) { + return (hash == null || hash is Hash) ? SplatKeywordHash(scope, hash) : null; + } + [Emitted] public static RubyArray/*!*/ SplatKeywordHash(RubyScope/*!*/ scope, object hash) { // `f(**nil)` passes no keywords at all From a98737a0eca83b87ff343ea81e154167639cbacc Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 08:25:56 +0200 Subject: [PATCH 39/94] Fiber: reuse the threads of finished fibers; release a fiber's mutexes before the hand-back Starting a CLR thread waits until the OS has scheduled it, which was most of what Fiber.new + first resume cost (~1-2 ms each on this box). The thread of a fiber that has finished now parks in a small pool (8 threads, 2 s idle) and the next fiber to start runs on it (Thread.__fiber_reuse__ / __fiber_park__ in ThreadOps.Fiber.cs). Before parking it does what the end of the thread did: drops the fiber's mutexes and any undelivered Thread#raise, and it clears the CLR thread's fiber-locals and the cached Fiber.current, so the next fiber starts as on a new thread. A parked thread is left out of Thread.list and ThreadGroup#list (RubyThreadInfo.IsIdleFiberWorker), and a reused one joins the ThreadGroup of the thread that starts the fiber, as a new one would. Abandoned (suspended) fibers keep their own threads, as before. A finished fiber also releases its mutexes before it hands control back (Thread.__fiber_release_locks__ in Fiber#__finish__) instead of when its thread dies. With the spinning mailbox the resumer runs on within nanoseconds, and spec/core/mutex/lock_spec.rb "does not raise deadlock if a fiber's attempt to lock was interrupted" could see the dead fiber's lock still held by a live thread ("deadlock; recursive locking"); it fails 2 of 3 runs without this and passed 4 of 4 with it. A/B, one bench-lock call, 5 interleaved rounds, best (spread): fiber_new 0.2 base 1.055s (1.06-1.62) -> 0.289s (0.29-0.32) enum_next_gen 0.2 base 0.753s (0.75-0.81) -> 0.397s (0.40-0.82) fiber_switch 0.25 base 2.078s (2.08-2.36) -> 0.456s (0.46-1.44) Specs: spec/core/{fiber,enumerator,thread,threadgroup,mutex,exception}, spec/library/prime, spec/library/zlib/gzipreader: 1453 examples, 2 failures; base on the same set: 2 failures. Both sides fail label_spec.rb:61 and one of the timing-based "does not block the caller if not locked" examples (Mutex#lock here, Mutex#synchronize on base; base also fails both on other runs). A fiber/pool differential script (locals, $!, mutexes, kill/raise/break/throw then reuse, thread groups, Thread.list, Thread#raise into a fiber) prints the same on base and here. Gates: sp-tests 0, sp-lang 2934/0/0, fiber_new 1.055s -> 0.289s, fiber_switch 2.078s -> 0.456s Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/ThreadOps.Fiber.cs | 156 ++++++++++++++++++++++ Src/Libraries/Builtins/ThreadOps.cs | 15 ++- Src/Libraries/Initializers.Generated.cs | 15 +++ Src/StdLib/ironruby/ruby4.rb | 23 +++- 4 files changed, 202 insertions(+), 7 deletions(-) diff --git a/Src/Libraries/Builtins/ThreadOps.Fiber.cs b/Src/Libraries/Builtins/ThreadOps.Fiber.cs index a0df0a46..e72d7fa2 100644 --- a/Src/Libraries/Builtins/ThreadOps.Fiber.cs +++ b/Src/Libraries/Builtins/ThreadOps.Fiber.cs @@ -130,6 +130,45 @@ public object Take() { } } + /// + /// Take without spinning, giving up after ; for the pool. + /// + internal bool TryTake(int milliseconds, out object message) { + long deadline = Environment.TickCount64 + milliseconds; + while (true) { + bool interrupted = false; + lock (_lock) { + if (_count == 0) { + int left = (int)(deadline - Environment.TickCount64); + if (left <= 0) { + message = null; + return false; + } + _blocked = true; + try { + Monitor.Wait(_lock, left); + } catch (ThreadInterruptedException) { + interrupted = true; + } finally { + _blocked = false; + } + } + + if (!interrupted && _count != 0) { + message = _message; + int count = _count - 1; + _message = (count > 0) ? _overflow.Dequeue() : null; + _count = count; + return true; + } + } + + if (interrupted) { + RubyUtils.TranslateThreadInterrupt(); + } + } + } + private bool Spin() { long start = Stopwatch.GetTimestamp(); int round = 0; @@ -171,6 +210,123 @@ private bool Spin() { [ThreadStatic] private static RubyContext _currentFiberContext; + #region the pool of fiber threads + + // Starting a CLR thread waits until the OS has scheduled it, which is most of what creating + // a fiber cost. The thread of a fiber that has finished therefore parks here for a while, + // and the next fiber to start runs on it. Before it parks it does for the finished fiber + // what the end of the thread would have done (drop its mutexes and any undelivered + // Thread#raise), and it forgets the fiber's locals, so the next fiber starts as on a new + // thread. A parked thread is left out of Thread.list and ThreadGroup#list. + private sealed class FiberWorker { + internal readonly Thread/*!*/ Thread; + internal readonly FiberMailbox/*!*/ Jobs = new FiberMailbox(); + + internal FiberWorker(Thread/*!*/ thread) { + Thread = thread; + } + } + + private static readonly List/*!*/ _idleFiberWorkers = new List(); + private const int MaxIdleFiberWorkers = 8; + private const int FiberWorkerIdleMilliseconds = 2000; + + [ThreadStatic] + private static FiberWorker _fiberWorker; + + /// + /// Hands to a parked fiber thread and answers that thread, or nil when + /// none is parked (the fiber then gets a new thread). + /// + [RubyMethod("__fiber_reuse__", RubyMethodAttributes.PublicSingleton)] + public static Thread ReuseFiberThread(object self, object fiber) { + FiberWorker worker; + while (true) { + lock (_idleFiberWorkers) { + int last = _idleFiberWorkers.Count - 1; + if (last < 0) { + return null; + } + worker = _idleFiberWorkers[last]; + _idleFiberWorkers.RemoveAt(last); + } + if (worker.Thread.IsAlive) { + break; + } + } + + // The thread now runs a fiber of the current thread's, as a new one would have. + RubyThreadInfo info = RubyThreadInfo.FromThread(worker.Thread); + info.Group = RubyThreadInfo.FromThread(Thread.CurrentThread).Group; + info.IsIdleFiberWorker = false; + worker.Jobs.Post(fiber); + return worker.Thread; + } + + /// + /// Called on a fiber thread whose fiber has finished: cleans up after the fiber and waits + /// for the next one. Nil means the thread should end (the pool is full, nobody came in + /// time, or the program is ending). + /// + [RubyMethod("__fiber_park__", RubyMethodAttributes.PublicSingleton)] + public static object ParkFiberThread(object self) { + Thread current = Thread.CurrentThread; + IronRuby.StandardLibrary.Threading.RubyMutex.ReleaseLocksOf(current); + RubyUtils.GetPendingAsyncException(current); + RubyThreadInfo info = RubyThreadInfo.FromThread(current); + info.ClearLocals(); + _currentFiber = null; + _currentFiberContext = null; + + FiberWorker worker = _fiberWorker ?? (_fiberWorker = new FiberWorker(current)); + lock (_idleFiberWorkers) { + if (_terminating || _idleFiberWorkers.Count >= MaxIdleFiberWorkers) { + return null; + } + info.IsIdleFiberWorker = true; + _idleFiberWorkers.Add(worker); + } + + bool parked = true; + try { + object fiber; + if (worker.Jobs.TryTake(FiberWorkerIdleMilliseconds, out fiber)) { + parked = false; + return fiber; + } + lock (_idleFiberWorkers) { + if (_idleFiberWorkers.Remove(worker)) { + // retired; stays hidden until the thread is gone + parked = false; + return null; + } + } + // ReuseFiberThread took this thread just now: its fiber is on the way + parked = false; + return worker.Jobs.Take(); + } finally { + if (parked) { + // left by an exception (Thread#kill): nobody may hand this thread a fiber now + lock (_idleFiberWorkers) { + _idleFiberWorkers.Remove(worker); + } + } + } + } + + /// + /// Releases the mutexes the fiber running on this thread still holds, as the end of the + /// thread would. Called by a fiber that has finished, before it hands control back: the + /// fiber it hands it to may run (and lock) right away, while this thread is still alive. + /// + [RubyMethod("__fiber_release_locks__", RubyMethodAttributes.PublicSingleton)] + public static object ReleaseFiberLocks(object self) { + IronRuby.StandardLibrary.Threading.RubyMutex.ReleaseLocksOf(Thread.CurrentThread); + return null; + } + + #endregion + [RubyMethod("__fiber_mailbox__", RubyMethodAttributes.PublicSingleton)] public static FiberMailbox/*!*/ CreateFiberMailbox(object self) { return new FiberMailbox(); diff --git a/Src/Libraries/Builtins/ThreadOps.cs b/Src/Libraries/Builtins/ThreadOps.cs index dd2cef20..fe783c5a 100644 --- a/Src/Libraries/Builtins/ThreadOps.cs +++ b/Src/Libraries/Builtins/ThreadOps.cs @@ -137,6 +137,13 @@ internal object this[RubySymbol/*!*/ key] { } } + /// Forgets every fiber-local, for a fiber thread that goes back to the pool. + internal void ClearLocals() { + lock (_threadLocalStorage) { + _threadLocalStorage.Clear(); + } + } + internal bool HasKey(RubySymbol/*!*/ key) { lock (_threadLocalStorage) { return _threadLocalStorage.ContainsKey(key); @@ -188,6 +195,12 @@ internal Thread Thread { /// internal volatile string BlockedLabel; internal volatile Thread ActiveFiberThread; + + /// + /// A fiber thread parked in the pool between two fibers (ThreadOps.Fiber.cs). It is no + /// Ruby thread of anybody's while it waits, so the thread lists leave it out. + /// + internal volatile bool IsIdleFiberWorker; /// /// MRI's Thread#priority is a small integer clamped to -3..3 that outlives the thread; @@ -267,7 +280,7 @@ internal static RubyThreadInfo[] Threads { lock (_mapping) { List result = new List(_mapping.Count); foreach (KeyValuePair entry in _mapping) { - if (entry.Value.Thread.IsAlive) { + if (entry.Value.Thread.IsAlive && !entry.Value.IsIdleFiberWorker) { result.Add(entry.Value); } } diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index d586d73f..5cd7cf88 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -10037,11 +10037,26 @@ private static void LoadThread_Class(IronRuby.Builtins.RubyModule/*!*/ module) { new Func(IronRuby.Builtins.ThreadOps.CreateFiberMailbox) ); + DefineLibraryMethod(module, "__fiber_park__", 0x61, + 0x00000000U, + new Func(IronRuby.Builtins.ThreadOps.ParkFiberThread) + ); + DefineLibraryMethod(module, "__fiber_post__", 0x61, 0x00000002U, new Func(IronRuby.Builtins.ThreadOps.FiberPost) ); + DefineLibraryMethod(module, "__fiber_release_locks__", 0x61, + 0x00000000U, + new Func(IronRuby.Builtins.ThreadOps.ReleaseFiberLocks) + ); + + DefineLibraryMethod(module, "__fiber_reuse__", 0x61, + 0x00000000U, + new Func(IronRuby.Builtins.ThreadOps.ReuseFiberThread) + ); + DefineLibraryMethod(module, "__fiber_take__", 0x61, 0x00000006U, new Func(IronRuby.Builtins.ThreadOps.FiberTake) diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index 7ba14a69..169be6b8 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -2761,15 +2761,23 @@ def self.__switch__(cur, target, msg) cur.__act__(cur.__await__) end + # A fiber runs on a thread of its own: one whose last fiber has finished if one is + # parked (Thread.__fiber_reuse__, ThreadOps.Fiber.cs), a new one otherwise. A thread + # goes back to the pool when its fiber is done (Thread.__fiber_park__), clean of that + # fiber's locals, and ends when nobody wants it for a while. def __start__ return self if @thread fiber = self - owner = @root_fiber.__thread__ - @thread = Thread.new do - ::Thread.__fiber_current_set__(fiber) - # Ruby ownership (Mutex, deadlock detection) is per thread, not per fiber - Thread.__set_fiber_owner__(owner) if owner - fiber.__run__ + @thread = ::Thread.__fiber_reuse__(fiber) || Thread.new do + while fiber + ::Thread.__fiber_current_set__(fiber) + # Ruby ownership (Mutex, deadlock detection) is per thread, not per fiber + owner = fiber.__root_fiber__.__thread__ + Thread.__set_fiber_owner__(owner) if owner + fiber.__run__ + fiber = nil + fiber = ::Thread.__fiber_park__ + end end self end @@ -2868,6 +2876,9 @@ def __finish__(msg) # The program has ended and this fiber was killed with its thread: the # fiber that resumed it is suspended, and stays that way (as in MRI). return if ::Thread.__terminating__ + # What the end of this thread does for the mutexes the fiber still holds, + # done before the fiber we hand control to can run and lock. + ::Thread.__fiber_release_locks__ target = @prev if target @prev = nil From 7ca39fc1e664961860fa986d5583554b87ba8a21 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 08:33:57 +0200 Subject: [PATCH 40/94] Gate the `f(**opts)` splat speedup (87bfd70a) Verified on 87bfd70a (this commit changes no files): - Thread CPU, best of 2 runs of best-of-3 in-process, per 300K calls, base -> 87bfd70a: def f(*a, **o) = t(*a, **o) 1.06 -> 0.32 s, def f(**o) = t(1, **o) 0.62 -> 0.25 s, the same with &b forwarded 1.28 -> 0.46 s, yield(1, k: 2) 0.37 -> 0.17 s, send(:t, i, k: 2) 0.21 -> 0.17 s, `super(a, k: k, **o)` 0.87 -> 0.71 s. Against f91fe293 (which lacks ca6b799b too): f(*a, **o) 0.51 -> 0.26-0.30 s, f(**o) 0.50 -> 0.25 s. - Util/bench call_kwargs is not affected by this commit (no `**`); it stays at the 06129b67 measurement, base 0.901 s -> 0.523 s. - spec/core method, unboundmethod, proc, hash, symbol, kernel: 3616 examples, 0 failures, 0 errors (base: 3616/0/0). Gates: sp-tests 0, sp-lang 2934/0/0, f(*a, **o) forwarding 1.06s -> 0.32s Co-Authored-By: Claude Opus 5.5 From 29aaec3357e7e58f18ba0aa2e4e3e81577583816 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 10:13:09 +0200 Subject: [PATCH 41/94] Bridge: f(**opts) splat assigns its argument slot (merge of compile-pipeline's argument array with kwargs' splat lowering) Co-Authored-By: Claude Opus 5.5 --- Src/Prism/PrismAstBridge.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index 18d35895..adba6bd1 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -1526,8 +1526,8 @@ private Block OptionalBlock(Pm.PmNode block) { var span = Span(arg); var temp = NewTemp(singleSplat.Value != null ? Expr(singleSplat.Value) : AnonymousParameter(AnonymousKeywordRestName, singleSplat), span, out var assignment); - exprs.Add(new KeywordArgumentSplat(assignment, new MethodCall(new HashConstructor(new Maplet[0], true, span), "merge", - new Arguments(new OrExpression(temp, new HashConstructor(new Maplet[0], span), span)), span))); + exprs[i] = new KeywordArgumentSplat(assignment, new MethodCall(new HashConstructor(new Maplet[0], true, span), "merge", + new Arguments(new OrExpression(temp, new HashConstructor(new Maplet[0], span), span)), span)); } else if (IsSplattedKeywordHash(arg)) { // `f(**opts)` must pass nothing when opts is empty - see KeywordArgumentSplat. exprs[i] = new KeywordArgumentSplat(Argument(arg)); From 0c26d62d937b053cede2422d5ed5532b900341b8 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 10:14:19 +0200 Subject: [PATCH 42/94] Merge `**h` into a keyword call's own hash in place Keyword arguments that mix pairs and splats (`f(k: v, **h)`, `super(a, k: k, **o)`, `f(**h, k: v)`) were built as a chain of Ruby-level Hash#merge calls, each a dup plus a block-driven update. The hash being built is the call's own and nobody else holds it, so a new node, KeywordSplatMerge, now copies a Hash (or nothing, for nil) into it in place with RubyOps.MergeKeywordSplat; only a non-Hash takes the old `hash.merge(h || {})`, which applies #to_hash. Pairs after a splat go the same way. Plain hash literals (`{a: 1, **h}`) are unchanged. Thread CPU, best of 2 runs of best-of-3 in-process, 300K calls, base -> f91fe293 -> this: super(a, k: k, **o) 0.98 -> 0.68 -> 0.41 s, t(i, k: 2, **H) 0.79 -> 0.60 -> 0.29 s (CRuby 0.20 / 0.11). WIP: gates pending. Co-Authored-By: Claude Opus 5.5 --- Src/Prism/PrismAstBridge.cs | 20 ++++++ .../Ast/Expressions/KeywordSplatMerge.cs | 69 +++++++++++++++++++ Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs | 2 + .../Compiler/Ast/Walkers/Walker.Generated.cs | 2 + Src/Ruby/Compiler/Ast/Walkers/Walker.cs | 9 +++ .../Compiler/ReflectionCache.Generated.cs | 2 + Src/Ruby/Runtime/RubyOps.cs | 21 ++++++ 7 files changed, 125 insertions(+) create mode 100644 Src/Ruby/Compiler/Ast/Expressions/KeywordSplatMerge.cs diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index 4186d744..9ef10ba0 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -1269,6 +1269,10 @@ private Expression DefinednessGuard(Pm.PmNode/*!*/ receiver, SourceSpan span) { case Pm.AssocNode assoc: maplets.Add(new Maplet(Expr(assoc.Key), Expr(assoc.Value), Span(assoc))); break; + case Pm.AssocSplatNode splat when isKeywordArguments: + result = MergeKeywordSplat(MergeHash(result, maplets, isKeywordArguments, span) ?? new HashConstructor(new Maplet[0], true, span), + splat.Value != null ? Expr(splat.Value) : AnonymousParameter(AnonymousKeywordRestName, splat), span); + break; case Pm.AssocSplatNode splat: result = MergeHash(result, maplets, isKeywordArguments, span); // `**nil` contributes nothing (Ruby 3.4); it is only known at run time @@ -1291,9 +1295,25 @@ private Expression MergeHash(Expression result, List/*!*/ maplets, bool if (maplets.Count == 0) return result; var ctor = new HashConstructor(maplets.ToArray(), isKeywordArguments, span); maplets.Clear(); + if (result != null && isKeywordArguments) { + return MergeKeywordSplat(result, ctor, span); + } return result == null ? (Expression)ctor : new MethodCall(result, "merge", new Arguments(ctor), span); } + /// + /// `hash.merge(splatted || {})` for keyword arguments, where the hash is the call's own: a + /// Hash (or nil) is copied into it in place, and only anything else takes the merge, which + /// applies #to_hash - see KeywordSplatMerge. + /// + private Expression/*!*/ MergeKeywordSplat(Expression/*!*/ hash, Expression/*!*/ splatted, SourceSpan span) { + var hashTemp = NewTemp(hash, span, out var hashAssignment); + var splatTemp = NewTemp(splatted, span, out var splatAssignment); + return new KeywordSplatMerge(hashAssignment, splatAssignment, + new MethodCall(hashTemp, "merge", new Arguments( + new OrExpression(splatTemp, new HashConstructor(new Maplet[0], span), span)), span), span); + } + // ---- calls ---- private Expression/*!*/ Call(Pm.CallNode/*!*/ node, SourceSpan span) { diff --git a/Src/Ruby/Compiler/Ast/Expressions/KeywordSplatMerge.cs b/Src/Ruby/Compiler/Ast/Expressions/KeywordSplatMerge.cs new file mode 100644 index 00000000..6af5148b --- /dev/null +++ b/Src/Ruby/Compiler/Ast/Expressions/KeywordSplatMerge.cs @@ -0,0 +1,69 @@ +/* **************************************************************************** + * + * Copyright (c) Microsoft Corporation. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ + +#if FEATURE_CORE_DLR +using MSA = System.Linq.Expressions; +#else +using MSA = Microsoft.Scripting.Ast; +#endif + +using Microsoft.Scripting; +using Microsoft.Scripting.Utils; +using AstUtils = Microsoft.Scripting.Ast.Utils; + +namespace IronRuby.Compiler.Ast { + using Ast = MSA.Expression; + + /// + /// One `**h` of keyword arguments that also has `k: v` pairs or other splats, e.g. `f(k: v, **h)`: + /// h's entries go into the keyword hash built so far (see RubyOps.MergeKeywordSplat) when h is + /// a Hash or nil. Anything else takes - `hash.merge(h || {})`, which + /// applies #to_hash - as every `**h` used to, at the price of a Ruby-level Hash#merge. + /// + public partial class KeywordSplatMerge : Expression { + // `temp = ` and `temp2 = `, the temporaries Converted reads + private readonly Expression/*!*/ _hash; + private readonly Expression/*!*/ _splatted; + private readonly Expression/*!*/ _converted; + + public Expression/*!*/ Hash { + get { return _hash; } + } + + public Expression/*!*/ Splatted { + get { return _splatted; } + } + + public Expression/*!*/ Converted { + get { return _converted; } + } + + public KeywordSplatMerge(Expression/*!*/ hash, Expression/*!*/ splatted, Expression/*!*/ converted, SourceSpan location) + : base(location) { + Assert.NotNull(hash, splatted, converted); + _hash = hash; + _splatted = splatted; + _converted = converted; + } + + internal override MSA.Expression/*!*/ TransformRead(AstGenerator/*!*/ gen) { + return Ast.Coalesce( + Methods.MergeKeywordSplat.OpCall(gen.CurrentScopeVariable, + AstUtils.Box(_hash.TransformRead(gen)), AstUtils.Box(_splatted.TransformRead(gen))), + AstUtils.Box(_converted.TransformRead(gen)) + ); + } + } +} diff --git a/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs b/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs index f4d80b91..fd8b3da5 100644 --- a/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs +++ b/Src/Ruby/Compiler/Ast/Walkers/NodeTypes.cs @@ -32,6 +32,7 @@ public enum NodeTypes { KeywordArgumentsTest, KeywordParameterRead, SplatParameterRead, + KeywordSplatMerge, // definitions: ClassDefinition, @@ -123,6 +124,7 @@ public enum NodeTypes { public partial class KeywordArgumentsTest { public override NodeTypes NodeType { get { return NodeTypes.KeywordArgumentsTest; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class KeywordParameterRead { public override NodeTypes NodeType { get { return NodeTypes.KeywordParameterRead; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class SplatParameterRead { public override NodeTypes NodeType { get { return NodeTypes.SplatParameterRead; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } + public partial class KeywordSplatMerge { public override NodeTypes NodeType { get { return NodeTypes.KeywordSplatMerge; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class ClassDefinition { public override NodeTypes NodeType { get { return NodeTypes.ClassDefinition; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } public partial class ModuleDefinition { public override NodeTypes NodeType { get { return NodeTypes.ModuleDefinition; } } internal protected override void Walk(Walker/*!*/ walker) { walker.Walk(this); } } diff --git a/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs b/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs index f7b3bd90..58a7bd16 100644 --- a/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs +++ b/Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs @@ -45,6 +45,8 @@ public virtual void Exit(KeywordArgumentsTest/*!*/ node) { } public virtual void Exit(KeywordParameterRead/*!*/ node) { } public virtual bool Enter(SplatParameterRead/*!*/ node) { return true; } public virtual void Exit(SplatParameterRead/*!*/ node) { } + public virtual bool Enter(KeywordSplatMerge/*!*/ node) { return true; } + public virtual void Exit(KeywordSplatMerge/*!*/ node) { } // declarations: public virtual bool Enter(ClassDefinition/*!*/ node) { return true; } diff --git a/Src/Ruby/Compiler/Ast/Walkers/Walker.cs b/Src/Ruby/Compiler/Ast/Walkers/Walker.cs index 4363d7cf..d3fb75d7 100644 --- a/Src/Ruby/Compiler/Ast/Walkers/Walker.cs +++ b/Src/Ruby/Compiler/Ast/Walkers/Walker.cs @@ -77,6 +77,15 @@ internal protected virtual void Walk(KeywordParameterRead/*!*/ node) { Exit(node); } + internal protected virtual void Walk(KeywordSplatMerge/*!*/ node) { + if (Enter(node)) { + node.Hash.Walk(this); + node.Splatted.Walk(this); + node.Converted.Walk(this); + } + Exit(node); + } + internal protected virtual void Walk(SplatParameterRead/*!*/ node) { if (Enter(node)) { node.Arguments.Walk(this); diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index a09ccfdc..e1e3aabf 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -506,6 +506,8 @@ public static partial class Methods { private static MethodInfo _MatchLastInputLine; public static MethodInfo/*!*/ MatchString { get { return _MatchString ?? (_MatchString = CallInstruction.CacheFunc(RubyOps.MatchString)); } } private static MethodInfo _MatchString; + public static MethodInfo/*!*/ MergeKeywordSplat { get { return _MergeKeywordSplat ?? (_MergeKeywordSplat = CallInstruction.CacheFunc(RubyOps.MergeKeywordSplat)); } } + private static MethodInfo _MergeKeywordSplat; public static MethodInfo/*!*/ MethodBreak { get { return _MethodBreak ?? (_MethodBreak = CallInstruction.CacheAction(RubyOps.MethodBreak)); } } private static MethodInfo _MethodBreak; public static MethodInfo/*!*/ MethodNext { get { return _MethodNext ?? (_MethodNext = CallInstruction.CacheAction(RubyOps.MethodNext)); } } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index ead2abec..b08d2a84 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -1578,6 +1578,27 @@ public static object SetQualifiedConstant(object value, object target, RubyScope /// is only emitted for argument hashes that actually contain a `**` splat. /// The result is fed to the ordinary splatting machinery, so it is either [] or [hash]. /// + /// + /// `f(k: v, **h)`: h's entries added to the keyword hash being built for the call - the + /// call's own, so it is filled in place - which is returned; nil adds nothing. Null if h is + /// anything else: that goes through Hash#merge, for #to_hash. + /// + [Emitted] + public static object MergeKeywordSplat(RubyScope/*!*/ scope, object keywords, object splatted) { + if (splatted == null) { + return keywords; + } + var source = splatted as Hash; + var target = keywords as Hash; + if (source == null || target == null) { + return null; + } + foreach (var entry in source) { + RubyUtils.SetHashElement(scope.RubyContext, target, entry.Key, entry.Value); + } + return target; + } + /// /// `f(**h)` with h a Hash or nil: what makes of it. Null for /// anything else, which the call site first converts with #to_hash (by way of Hash#merge). From 5a1d9bac429bd62e32f46474ff5949c6a56c2270 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 10:14:43 +0200 Subject: [PATCH 43/94] Array#all?/any?/none?: walk the array, as MRI's do For an Array these were Enumerable's: a dynamic #each call with a splatting C# block that packed every element into an argument array and yielded it on with YieldSplat - two block invocations per element. MRI's Array has its own (rb_ary_all_p, rb_ary_any_p, rb_ary_none_p) that yield each element once and never call #each. Enumerable's shared helper now takes that path for a RubyArray receiver (block form and no-argument form; the pattern form is the Ruby wrapper in ruby4.rb and still goes through it). This also matches MRI where the old route did not: an Array subclass overriding #each no longer changes the answer (A3.new([1, 2]).any? { |x| x == 99 } is false, [] form too). Visible side effect, MRI-consistent: a backtrace taken inside such a block is one frame shorter (no Array#each below Array#all?). mspec runs every example through Array#all?, so the shared set_backtrace example in language/predefined_spec.rb, which makes 6 expectations per backtrace frame, counts 6 fewer (5563 vs 5569 expectations in spec/language; same 2934 examples, all passing). bench.rb gains any_loop. Release, on-CPU seconds (main thread schedstat), best of 5, base/mine alternating in one bench-lock call: any_loop 0.514 -> 0.279 (base spread 0.514-0.596; CRuby 0.122) Spec dirs: core/array + core/enumerable 193 files, 3434 examples, 0 failures, 0 errors (base: array 132/2898/0/0, enumerable 0/0). Gates: sp-tests 0, sp-lang 2934/0/0, any_loop 0.514s -> 0.279s Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/Enumerable.cs | 20 ++++++++++++++++++++ Util/bench/bench.rb | 5 +++++ 2 files changed, 25 insertions(+) diff --git a/Src/Libraries/Builtins/Enumerable.cs b/Src/Libraries/Builtins/Enumerable.cs index 1809a0b0..12abde15 100644 --- a/Src/Libraries/Builtins/Enumerable.cs +++ b/Src/Libraries/Builtins/Enumerable.cs @@ -111,6 +111,26 @@ public static object TrueForAny(CallSiteStorage/*!*/ each, BlockParam } private static object TrueForItems(CallSiteStorage/*!*/ each, BlockParam predicate, object self, bool stop, bool positiveResult) { + // An Array's #all?, #any? and #none? are its own in MRI (rb_ary_all_p etc.): they walk the + // array and yield each element once rather than going through #each and a block of ours. + var array = self as RubyArray; + if (array != null) { + for (int i = 0; i < array.Count; i++) { + object item = array[i]; + if (predicate != null) { + object blockResult; + if (predicate.Yield(item, out blockResult)) { + return blockResult; + } + item = blockResult; + } + if (Protocols.IsTrue(item) == stop) { + return ScriptingRuntimeHelpers.BooleanToObject(positiveResult); + } + } + return ScriptingRuntimeHelpers.BooleanToObject(!positiveResult); + } + object result = ScriptingRuntimeHelpers.BooleanToObject(!positiveResult); Each(each, self, SplattingBlock(each.Context, delegate(BlockParam/*!*/ selfBlock, RubyArray/*!*/ args) { object item = PackValues(args); diff --git a/Util/bench/bench.rb b/Util/bench/bench.rb index 84332019..e7a43aea 100644 --- a/Util/bench/bench.rb +++ b/Util/bench/bench.rb @@ -401,6 +401,11 @@ def ack(mm, k) r.times { a.select { |x| x > 500 } } end +bench("any_loop") do + a = (0...1000).to_a; r = n(2_000) + r.times { a.any? { |x| x > 5000 } } +end + name = ARGV[0] unless BENCH.key?(name) if name == "--list" From c6ea0f5a55e6715db69a6ceb17b4ccffe72b55cf Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 10:21:55 +0200 Subject: [PATCH 44/94] Attribute dispatchers: tell an exact RubyObject apart without the interface cast RubyObjectAttributeReaderDispatcherWithScope and the writer dispatcher cast the receiver to IRubyObject (a runtime helper call) and then read ImmediateClass through the interface twice per call. AttributeDispatcher.GetRubyObject tests for an exact RubyObject inline, as MemberDispatcher.IsCurrentFor does for method dispatchers, and returns the class once. Since 3cac1835 these dispatchers also serve bare-identifier reads such as `levar` in ruby-bench attr_accessor. attr_get (bench.rb, best of 5, base = master tree): 0.2214s -> 0.2055s (base spread 0.18s on a box at load ~70); attr_set (thread CPU time, scale 3, best of 5): 0.4662s -> 0.4266s (spread 0.12s). spec/core/module + kernel instance_variable_get/set + freeze: 1093 examples, 0 failures, 0 errors. Gates: sp-tests 0, sp-lang 2934/0/0, attr_set 0.4662s -> 0.4266s Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Calls/MethodDispatcher.cs | 30 +++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/Src/Ruby/Runtime/Calls/MethodDispatcher.cs b/Src/Ruby/Runtime/Calls/MethodDispatcher.cs index 1ca30a06..f39eed96 100644 --- a/Src/Ruby/Runtime/Calls/MethodDispatcher.cs +++ b/Src/Ruby/Runtime/Calls/MethodDispatcher.cs @@ -158,6 +158,22 @@ internal static AttributeDispatcher CreateRubyObjectWriterDispatcher(Type/*!*/ d } internal abstract void Initialize(string/*!*/ name, int version); + + /// + /// The receiver as an IRubyObject and its class, or null: an exact RubyObject is told apart + /// inline, without the interface cast (see MemberDispatcher.IsCurrentFor). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static IRubyObject GetRubyObject(object self, out RubyClass cls) { + if (self != null && self.GetType() == typeof(RubyObject)) { + var obj = Unsafe.As(self); + cls = obj.ImmediateClass; + return obj; + } + IRubyObject result = self as IRubyObject; + cls = (result != null) ? result.ImmediateClass : null; + return result; + } } public sealed class RubyObjectAttributeReaderDispatcherWithScope : AttributeDispatcher { @@ -174,9 +190,10 @@ internal override void Initialize(string/*!*/ name, int version) { } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { - return RubyOps.ReadInstanceVariable(obj.ImmediateClass.Context, obj, Site); + RubyClass cls; + IRubyObject obj = GetRubyObject(self, out cls); + if (obj != null && cls.Version.Method == Version) { + return RubyOps.ReadInstanceVariable(cls.Context, obj, Site); } else { return ((CallSite>)callSite).Update(callSite, scope, self); } @@ -197,9 +214,10 @@ internal override void Initialize(string/*!*/ name, int version) { } public object Invoke(CallSite/*!*/ callSite, TScope/*!*/ scope, object self, T0 arg0) { - IRubyObject obj = self as IRubyObject; - if (obj != null && obj.ImmediateClass.Version.Method == Version) { - return RubyOps.WriteInstanceVariable(obj.ImmediateClass.Context, obj, (object)arg0, Site); + RubyClass cls; + IRubyObject obj = GetRubyObject(self, out cls); + if (obj != null && cls.Version.Method == Version) { + return RubyOps.WriteInstanceVariable(cls.Context, obj, (object)arg0, Site); } else { return ((CallSite>)callSite).Update(callSite, scope, self, arg0); } From fbed1bd1368f35e75b4b211c54439b8bd433f19a Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 10:22:42 +0200 Subject: [PATCH 45/94] Bind respond_to?(:literal) to a constant and send(:literal) as a direct call Gated state of the previous (WIP) commit a3319f97; no further change. DispatchIntrinsics (new) is consulted by RubyCallAction.BuildCall once the call has resolved to one of Kernel's dispatch helpers: - Kernel#respond_to? with one symbol argument, on a Ruby object, with no refinements in the program: when the answer comes from method lookups alone (the name's method, or Kernel#respond_to_missing? - a user-defined one is still called every time), the rule returns it as a constant. It is guarded by the symbol and by the receiver test the rule already has; the lookups are registered for invalidation (RubyModule.GetMethodLookup with the version they were published at), so redefinition, undef, visibility changes, include/prepend/extend and a later `refine' all rebind the site, exactly as they would a call to that name. - Kernel#send / BasicObject#__send__ with a symbol first argument: bound as the call of that method the send would make (implicit self, so any visibility; same scope, block and refinements), guarded by the symbol - one dispatch instead of two. As with MRI's optimized send, no send frame appears in a backtrace any more. Strings and splats keep the library method (and its per-site storage). Both are capped at 32 symbol-specialized rules per receiver class, so a site that sends or asks about computed names stops binding a rule per name. Release, interleaved A/B via bench-lock (best of 5 unless noted): send_call 0.379s -> 0.114s (base 0.379-1.06, mine 0.114-0.130; CRuby 0.072) respond_to 0.200s -> 0.084s (base 0.200-0.227, mine 0.084-0.132; CRuby 0.045) ruby-bench respond_to (RB_WARMUP=3 RB_ITRS=4, 2 runs each): 365/505ms -> 102/108ms (CRuby 342-399ms) call_1arg, method_missing, singleton_call, super_call: unchanged within noise (best of 3). Semantics: local tests for blocks, kwargs, splats, NoMethodError details, method_missing, redefinition and singleton methods after binding, own #send, aliases, send(:send), BasicObject, builtin receivers, 80 computed names, refinements, block_given?/__method__/binding through send all print the same as the base build. Specs: the same 30 kernel/basicobject/module files as the previous commit, 580 examples, 0 failures (base: same). Gates: sp-tests 0, sp-lang 2934/0/0, send_call 0.379s -> 0.114s, respond_to 0.200s -> 0.084s Co-Authored-By: Claude Opus 5.5 From eb8f1f55d38b111488a3d7c3f95fab3583cb414a Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 10:24:30 +0200 Subject: [PATCH 46/94] Yield: a yield site in a loop reuses its BlockParam Every `yield` allocated a BlockParam (64 bytes). A yield site that runs again in the same frame - `while ...; yield x; end`, the shape of a hand-written #each - always yields to the same proc, and the BlockParam is only looked at right after its yield returns (MethodYield/BlockYield/ EvalYield decide on it before anything else can run). The site's hidden variable already holds the previous one, so ReuseBfcForYield hands it back with its flow-control state cleared (or makes a new one the first time, or if the proc differs). The variable is per frame, so recursion, threads and fibers each have their own. Break, return, next, ensure around the yield, procs captured per iteration, lambda arity errors, yield from threads and from eval inside the loop, and recursion through &block all give CRuby's output in a test run against both builds. Bytes allocated per yield in such a loop: 308 -> 243. bench.rb gains yield_loop (CallTarget#yield_upto, a while loop around yield). Release, on-CPU seconds (main thread schedstat), best of 5, base/mine alternating in one bench-lock call (cumulative with the previous commits): yield_loop 0.430 -> 0.315 (base spread 0.430-0.506; CRuby 0.144) yield_0 0.639 -> 0.482 (base spread 0.639-0.671) yield_3 0.420 -> 0.315 (base spread 0.420-0.486) Spec dirs vs base: core/proc, kernel proc/lambda/block_given, integer times/upto/downto, array each/map, enumerable, hash/range each: 97 files, 883 examples, 0 failures, 0 errors on both. Gates: sp-tests 0, sp-lang 2934/0/0, yield_loop 0.430s -> 0.315s Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Compiler/Ast/Expressions/YieldCall.cs | 2 +- Src/Ruby/Compiler/ReflectionCache.Generated.cs | 2 ++ Src/Ruby/Runtime/BlockParam.cs | 15 +++++++++++++++ Util/bench/bench.rb | 12 ++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Src/Ruby/Compiler/Ast/Expressions/YieldCall.cs b/Src/Ruby/Compiler/Ast/Expressions/YieldCall.cs index 4a31afdd..b37284f6 100644 --- a/Src/Ruby/Compiler/Ast/Expressions/YieldCall.cs +++ b/Src/Ruby/Compiler/Ast/Expressions/YieldCall.cs @@ -59,7 +59,7 @@ public YieldCall(Arguments args, SourceSpan location) return new AstBlock { gen.DebugMarker("#RB: yield begin"), - Ast.Assign(bfcVariable, Methods.CreateBfcForYield.OpCall(gen.MakeMethodBlockParameterRead())), + Ast.Assign(bfcVariable, Methods.ReuseBfcForYield.OpCall(bfcVariable, gen.MakeMethodBlockParameterRead())), Ast.Assign( resultVariable, diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index 630ac2b4..0377d50a 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -522,6 +522,8 @@ public static partial class Methods { private static MethodInfo _ReadGlobalVariable; public static MethodInfo/*!*/ RegisterShutdownHandler { get { return _RegisterShutdownHandler ?? (_RegisterShutdownHandler = CallInstruction.CacheAction(RubyOps.RegisterShutdownHandler)); } } private static MethodInfo _RegisterShutdownHandler; + public static MethodInfo/*!*/ ReuseBfcForYield { get { return _ReuseBfcForYield ?? (_ReuseBfcForYield = CallInstruction.CacheFunc(RubyOps.ReuseBfcForYield)); } } + private static MethodInfo _ReuseBfcForYield; public static MethodInfo/*!*/ RubyStruct_GetValue { get { return _RubyStruct_GetValue ?? (_RubyStruct_GetValue = CallInstruction.CacheFunc(RubyStruct.GetValue)); } } private static MethodInfo _RubyStruct_GetValue; public static MethodInfo/*!*/ RubyStruct_SetValue { get { return _RubyStruct_SetValue ?? (_RubyStruct_SetValue = CallInstruction.CacheFunc(RubyStruct.SetValue)); } } diff --git a/Src/Ruby/Runtime/BlockParam.cs b/Src/Ruby/Runtime/BlockParam.cs index 87153664..9a17edeb 100644 --- a/Src/Ruby/Runtime/BlockParam.cs +++ b/Src/Ruby/Runtime/BlockParam.cs @@ -326,6 +326,21 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* } } + /// + /// A yield site that runs again in the same frame - a loop around `yield` - yields to the same + /// proc every time, and its BlockParam is only looked at right after each yield returns + /// (MethodYield/BlockYield), so the site hands the previous one back with its flow-control + /// state cleared instead of allocating another. + /// + [Emitted] + public static BlockParam/*!*/ ReuseBfcForYield(BlockParam previous, Proc proc) { + if (previous != null && previous.Proc == proc) { + previous.SetFlowControl(BlockReturnReason.Undefined, null, ProcKind.Block); + return previous; + } + return CreateBfcForYield(proc); + } + [Emitted] public static BlockParam/*!*/ CreateBfcForProcCall(Proc/*!*/ proc) { Assert.NotNull(proc); diff --git a/Util/bench/bench.rb b/Util/bench/bench.rb index e7a43aea..f1a536b2 100644 --- a/Util/bench/bench.rb +++ b/Util/bench/bench.rb @@ -406,6 +406,18 @@ def ack(mm, k) r.times { a.any? { |x| x > 5000 } } end +class CallTarget + def yield_upto(k) + i = 0 + while i < k; yield i; i += 1; end + end +end + +bench("yield_loop") do + o = CallTarget.new; s = 0; r = n(2_000) + r.times { o.yield_upto(1000) { |x| s += x } } +end + name = ARGV[0] unless BENCH.key?(name) if name == "--list" From 315bd2339443696d88dddc42ed6dbfcbf49b6825 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 10:26:15 +0200 Subject: [PATCH 47/94] Capture no backtrace for flow-control unwinders; snapshot interpreted frames without LINQ Block and method frames filter every exception that passes them to capture its backtrace - also the StackUnwinders IronRuby uses for flow control (Kernel#throw's ThrowCatchUnwinder, retry/redo, break/return unwinders, Thread#kill's ThreadExitSignal), which Ruby code never sees: CanRescue lets them through and $! is never set to them. Skip the capture (and the RubyExceptionData allocation) for them; one that escaped to the top level would still be traced there. DLR (0100-exceptions.patch): InterpretedFrame.SaveTraceToException, taken for every exception that passes an interpreted frame, built its snapshot of the whole frame chain with an iterator + Enumerable.ToArray (a growing List and a copy); it now counts the chain and fills an exact-size array. Release, one bench-lock call each (box loaded by concurrent gate runs): catch { throw } x20000: previous 2.96 s -> 1.38 s (base 6.95 s) catch { each { each { throw } } } 5.79 s -> 3.56 s (base 10.48 s) begin/ensure/rescue x20000: 2.69 s -> 2.44 s (base 5.04 s) raise through 2 methods x20000: 3.92 s -> 3.47 s (base 7.73 s) raise_rescue (Util/bench): 2.65-2.96 s, within the load noise of the previous commit's 2.40-2.51 s (base 13.9-16.9 s). Gates: sp-tests 0, sp-lang 2934/0/0, catch/throw 2.96s -> 1.38s (base 6.95s); spec/core/exception+kernel raise/fail/caller/caller_locations/catch/throw/loop+thread raise/backtrace/kill/exit+enumerator next+fiber 757 ex 1 F = base; spec/command_line 175/0/0 = base Co-Authored-By: Claude Opus 5.5 --- Build/dlr-patches/0100-exceptions.patch | 32 +++++++++++++++++++++++++ Src/Ruby/Runtime/RubyOps.cs | 11 +++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Build/dlr-patches/0100-exceptions.patch b/Build/dlr-patches/0100-exceptions.patch index fe1a0c72..51caeb89 100644 --- a/Build/dlr-patches/0100-exceptions.patch +++ b/Build/dlr-patches/0100-exceptions.patch @@ -1,3 +1,35 @@ +diff --git a/src/core/Microsoft.Dynamic/Interpreter/InterpretedFrame.cs b/src/core/Microsoft.Dynamic/Interpreter/InterpretedFrame.cs +index 1a32e58..0a97958 100644 +--- a/src/core/Microsoft.Dynamic/Interpreter/InterpretedFrame.cs ++++ b/src/core/Microsoft.Dynamic/Interpreter/InterpretedFrame.cs +@@ -146,10 +146,26 @@ namespace Microsoft.Scripting.Interpreter { + + internal void SaveTraceToException(Exception exception) { + if (exception.GetData(typeof(InterpretedFrameInfo)) is null) { +- exception.SetData(typeof(InterpretedFrameInfo), GetStackTraceDebugInfo().ToArray()); ++ exception.SetData(typeof(InterpretedFrameInfo), GetStackTraceDebugInfoArray()); + } + } + ++ // GetStackTraceDebugInfo().ToArray() without the iterator and the list it grows: every ++ // exception that passes an interpreted frame takes this snapshot of the whole chain. ++ private InterpretedFrameInfo[] GetStackTraceDebugInfoArray() { ++ int count = 0; ++ for (var frame = this; frame is not null; frame = frame.Parent) { ++ count++; ++ } ++ ++ var result = new InterpretedFrameInfo[count]; ++ int i = 0; ++ for (var frame = this; frame is not null; frame = frame.Parent) { ++ result[i++] = new InterpretedFrameInfo(frame.Name, frame.GetDebugInfo(frame.InstructionIndex)); ++ } ++ return result; ++ } ++ + public static InterpretedFrameInfo[] GetExceptionStackTrace(Exception exception) { + return exception.GetData(typeof(InterpretedFrameInfo)) as InterpretedFrameInfo[]; + } diff --git a/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs b/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs index 7176c59..047827a 100644 --- a/src/core/Microsoft.Dynamic/Utils/ExceptionUtils.cs diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 33e68e2d..e6a77ee4 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -2519,7 +2519,11 @@ public static object NullIfTrue(object obj) { /// [Emitted] public static bool FilterBlockException(RubyScope/*!*/ scope, Exception/*!*/ exception) { - RubyExceptionData.GetInstance(exception).CaptureExceptionTrace(scope); + // Flow control (throw/catch, retry, Thread#kill) is never seen by Ruby code, so it needs no + // backtrace. One that escaped anyway would still get one at the top level. + if (!(exception is StackUnwinder)) { + RubyExceptionData.GetInstance(exception).CaptureExceptionTrace(scope); + } return false; } @@ -2589,7 +2593,10 @@ public static bool IsTopLevelReturn(RubyScope/*!*/ scope, Exception/*!*/ excepti public static bool IsMethodUnwinderTargetFrame(RubyScope/*!*/ scope, Exception/*!*/ exception) { var unwinder = exception as MethodUnwinder; if (unwinder == null) { - RubyExceptionData.GetInstance(exception).CaptureExceptionTrace(scope); + // see FilterBlockException + if (!(exception is StackUnwinder)) { + RubyExceptionData.GetInstance(exception).CaptureExceptionTrace(scope); + } return false; } else { return unwinder.TargetFrame == scope.FlowControlScope; From 816e30f2be275528a826a5c67b7ac28cfca7f440 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 10:27:18 +0200 Subject: [PATCH 48/94] Gate the in-place keyword splat merge (0c26d62d) Verified on 0c26d62d (this commit changes no files): - Thread CPU, best of 2 runs of best-of-3 in-process, 300K calls, base -> 0c26d62d: super(a, k: k, **o) 0.98 -> 0.41 s, t(i, k: 2, **H) 0.79 -> 0.29 s. - spec/core method, unboundmethod, proc, hash, symbol, kernel: 3616 examples, 0 failures, 0 errors (base: 3616/0/0). Gates: sp-tests 0, sp-lang 2934/0/0, t(i, k: 2, **H) 0.79s -> 0.29s Co-Authored-By: Claude Opus 5.5 From 3a6b66d139b42b4c342dfac43f5220a5e748e958 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 10:29:04 +0200 Subject: [PATCH 49/94] Don't share respond_to?/send rules across receiver classes startup-binder's shared rules (RubyCallAction.BindShared) assume a library method's rule depends only on the method, the signature and the arguments. respond-send-mm's DispatchIntrinsics broke that for Kernel#respond_to? and #send/#__send__: their rules fold the receiver class's method table into a constant answer or a direct call. Merged together, a respond_to?(m) site in the prelude (Fiber.set_scheduler, Fiber#raise's #exception check) reused one receiver's answer for another receiver's singleton class - 6 errors in spec/core/fiber that neither branch had alone. BindShared now leaves the methods DispatchIntrinsics may specialize to the per-class rules. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs | 8 ++++++++ Src/Ruby/Runtime/Calls/RubyCallAction.cs | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs b/Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs index 30aaf38a..b34a4735 100644 --- a/Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs +++ b/Src/Ruby/Runtime/Calls/DispatchIntrinsics.cs @@ -169,5 +169,13 @@ internal static bool TryBuildSend(MetaObjectBuilder/*!*/ metaBuilder, CallArgume sentName = symbol.ToString(); return true; } + + /// + /// The rules TryBuildRespondTo and TryBuildSend build depend on the receiver's class, not only on the + /// method, so RubyCallAction.BindShared must not hand them to receivers of other classes. + /// + internal static bool MaySpecialize(RubyMemberInfo/*!*/ method) { + return IsKernelMethod(method, ref _respondTo, "RespondTo") || IsSendMethod(method); + } } } diff --git a/Src/Ruby/Runtime/Calls/RubyCallAction.cs b/Src/Ruby/Runtime/Calls/RubyCallAction.cs index ab52ca4b..c40b24d5 100644 --- a/Src/Ruby/Runtime/Calls/RubyCallAction.cs +++ b/Src/Ruby/Runtime/Calls/RubyCallAction.cs @@ -277,6 +277,11 @@ internal override T BindShared(object[]/*!*/ args) { return null; } + // respond_to?(:sym) and send(:sym) rules are specialized on the receiver's class (DispatchIntrinsics): + if (DispatchIntrinsics.MaySpecialize(method.Info)) { + return null; + } + SharedRule shared = null; var sharedRules = _sharedRules; if (sharedRules != null) { From 067bf1a5ee001512bb6309552af513cb964b057e Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 17:57:29 +0200 Subject: [PATCH 50/94] OSR: bind self into an outlined loop only when its body uses it 84d40478 (OSR ivar reads) passed the loop's self to every outlined loop and loaded it into a #self local in the prologue. A loop that never touches self or an instance variable kept the unused local anyway, and the JIT's register allocation of the loop paid for it: `while i < m; i += 1; end' ran 1.4x slower per iteration (Util/bench while_loop x10: base 41.5ms, before this 60.2ms). The prologue now leaves self out unless the compiled body references it. Release, best of 5 interleaved under bench-lock: while_loop x10 base 41.5ms -> 60.2ms (84d40478) -> 44.4ms ruby-bench getivar base 248ms -> 66ms (OSR ivar reads kept) Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Jit/JitLoopCompiler.cs | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs b/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs index ec2e3d7f..3cf77019 100644 --- a/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs +++ b/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs @@ -173,6 +173,13 @@ internal static Func TryCompile(WhileLoopExpression/*!*/ loop, return null; } + // Bind self only if the body reads it (an instance variable, `self'): an unused #self + // local loaded in the prologue cost the JIT's register allocation of the loop itself - + // `while i < m; i += 1; end' ran 1.4x slower per iteration with it. + if (self != null && !UsesParameter.In(body, self)) { + self = null; + } + var locals = new List(tuples.Values); var probe = Ast.Parameter(typeof(object), "#probe"); locals.Add(probe); @@ -273,4 +280,31 @@ internal static Func TryCompile(WhileLoopExpression/*!*/ loop, } } } + + /// Whether an expression tree references a given parameter or variable. + internal sealed class UsesParameter : MSA.ExpressionVisitor { + private readonly MSA.ParameterExpression/*!*/ _parameter; + private bool _found; + + private UsesParameter(MSA.ParameterExpression/*!*/ parameter) { + _parameter = parameter; + } + + internal static bool In(MSA.Expression/*!*/ tree, MSA.ParameterExpression/*!*/ parameter) { + var finder = new UsesParameter(parameter); + finder.Visit(tree); + return finder._found; + } + + public override MSA.Expression Visit(MSA.Expression node) { + return _found ? node : base.Visit(node); + } + + protected override MSA.Expression VisitParameter(MSA.ParameterExpression/*!*/ node) { + if (node == _parameter) { + _found = true; + } + return node; + } + } } From b430571758becce1b3d0af3960b5d718b2581e8c Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 21:58:40 +0200 Subject: [PATCH 51/94] Dispatcher guard: class and module receivers without the interface cast MemberDispatcher.IsCurrentFor and GetRubyObject tested an exact RubyObject inline and cast everything else to IRubyObject - a runtime interface-cast helper call (7.5% of Math.sqrt's profile) on every call whose receiver is a class or a module: Foo.new, Foo.bar, every module_function. RubyClass is sealed and RubyModule.ImmediateClass is a field, so test those two exact types inline too. Release, best of 5 vs 067bf1a5: module_function_call 1.09x, singleton_call 1.06x. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Calls/MethodDispatcher.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Src/Ruby/Runtime/Calls/MethodDispatcher.cs b/Src/Ruby/Runtime/Calls/MethodDispatcher.cs index f39eed96..1382972e 100644 --- a/Src/Ruby/Runtime/Calls/MethodDispatcher.cs +++ b/Src/Ruby/Runtime/Calls/MethodDispatcher.cs @@ -50,8 +50,15 @@ public abstract partial class MemberDispatcher { /// [MethodImpl(MethodImplOptions.AggressiveInlining)] internal bool IsCurrentFor(object self) { - if (self != null && self.GetType() == typeof(RubyObject)) { - return Unsafe.As(self).ImmediateClass.Version.Method == Version; + if (self != null) { + Type type = self.GetType(); + if (type == typeof(RubyObject)) { + return Unsafe.As(self).ImmediateClass.Version.Method == Version; + } + // a class or module receiver - Foo.new, Math.sqrt, any class method: + if (type == typeof(RubyClass) || type == typeof(RubyModule)) { + return Unsafe.As(self).ImmediateClass.Version.Method == Version; + } } IRubyObject obj = self as IRubyObject; return obj != null && obj.ImmediateClass.Version.Method == Version; @@ -170,6 +177,11 @@ internal static IRubyObject GetRubyObject(object self, out RubyClass cls) { cls = obj.ImmediateClass; return obj; } + if (self != null && (self.GetType() == typeof(RubyClass) || self.GetType() == typeof(RubyModule))) { + var module = Unsafe.As(self); + cls = module.ImmediateClass; + return module; + } IRubyObject result = self as IRubyObject; cls = (result != null) ? result.ImmediateClass : null; return result; From c5bb31ed8e4ed7702d9b954fccf873af3cf128c1 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 21:58:40 +0200 Subject: [PATCH 52/94] Math: Float and Fixnum arguments in C#, the Ruby definitions for everything else ruby4.rb defined acos, asin, atan, atan2, cos, sin, tan, cosh, sinh, tanh, asinh, exp, cbrt, sqrt and hypot in Ruby (for MRI's rb_to_float conversion and errors), so each Math.sqrt(x) was a Ruby method, a call to __flt__ with an is_a?, nan?, comparisons and a CLR interop call - ~420ns against CRuby's 42ns. RubyMath now handles a Float, Fixnum or Int64 argument inside the function's domain itself (NaN passes through, sqrt(-0.0) is 0.0 as in MRI) and hands every other argument and every domain error to the Ruby definitions, renamed __ir___, so conversions and error messages are unchanged. complex18.rb, still loaded at boot, overrides these with Complex-returning versions and keeps the originals as name!; the prelude aliases those back, as its own definitions used to override complex18's. Util/math-matrix.rb: output identical to before (2038 lines; same 296 known diffs vs CRuby). Release, best of 5 vs 067bf1a5: module_function_call 1.390s -> 0.293s (4.7x); ruby-bench nbody 497 -> 347ms (1.43x), blurhash 1441 -> 1248ms (1.15x). Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/RubyMath.cs | 131 ++++++++++++++++++------ Src/Libraries/Initializers.Generated.cs | 120 +++++++++++----------- Src/StdLib/ironruby/ruby4.rb | 48 +++++---- 3 files changed, 189 insertions(+), 110 deletions(-) diff --git a/Src/Libraries/Builtins/RubyMath.cs b/Src/Libraries/Builtins/RubyMath.cs index 4b3987a8..f3cdc06b 100644 --- a/Src/Libraries/Builtins/RubyMath.cs +++ b/Src/Libraries/Builtins/RubyMath.cs @@ -13,6 +13,9 @@ * * ***************************************************************************/ +using System; +using System.Runtime.CompilerServices; +using IronRuby.Runtime.Calls; using Microsoft.Scripting.Utils; using IronRuby.Runtime; using SM = System.Math; @@ -37,6 +40,28 @@ private static double DomainCheck(double result, string/*!*/ functionName) { return result; } + // The fast path of the functions ruby4.rb used to define in Ruby: a Float or a Fixnum argument, + // in the function's domain. Anything else - a Bignum, a Rational, nil, a String, an object with + // #to_f - and every domain error go to the Ruby definitions (__ir___ in ruby4.rb), which do + // MRI's rb_to_float conversion and raise its errors. + private static bool TryGetDouble(object x, out double d) { + if (x is double) { d = (double)x; return true; } + if (x is int) { d = (int)x; return true; } + if (x is long) { d = (long)x; return true; } + d = 0.0; + return false; + } + + private static object General(CallSiteStorage>/*!*/ general, string/*!*/ name, object self, object x) { + var site = general.GetCallSite(name, new RubyCallSignature(1, RubyCallFlags.HasImplicitSelf)); + return site.Target(site, self, x); + } + + private static object General(CallSiteStorage>/*!*/ general, string/*!*/ name, object self, object x, object y) { + var site = general.GetCallSite(name, new RubyCallSignature(2, RubyCallFlags.HasImplicitSelf)); + return site.Target(site, self, x, y); + } + private static ushort Exponent(byte[] v) { return (ushort)((((ushort)(v[7] & 0x7F)) << (ushort)4) | (((ushort)(v[6] & 0xF0)) >> 4)); } @@ -54,8 +79,13 @@ private static ulong Mantissa(byte[] v) { [RubyMethodAttribute("acos", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("acos", RubyMethodAttributes.PublicSingleton)] - public static double Acos(object self, [DefaultProtocol]double x) { - return DomainCheck(SM.Acos(x), "acos"); + public static object Acos(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + if (TryGetDouble(x, out d)) { + if (double.IsNaN(d)) return d; + if (d >= -1.0 && d <= 1.0) return SM.Acos(d); + } + return General(general, "__ir_acos__", self, x); } [RubyMethodAttribute("acosh", RubyMethodAttributes.PrivateInstance)] @@ -67,27 +97,39 @@ public static double Acosh(object self, [DefaultProtocol]double x) { [RubyMethodAttribute("asin", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("asin", RubyMethodAttributes.PublicSingleton)] - public static double Asin(object self, [DefaultProtocol]double x) { - return DomainCheck(SM.Asin(x), "asin"); + public static object Asin(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + if (TryGetDouble(x, out d)) { + if (double.IsNaN(d)) return d; + if (d >= -1.0 && d <= 1.0) return SM.Asin(d); + } + return General(general, "__ir_asin__", self, x); } [RubyMethodAttribute("asinh", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("asinh", RubyMethodAttributes.PublicSingleton)] - public static double Asinh(object self, [DefaultProtocol]double x) { - //ln(x + sqrt(x*x + 1)) - return SM.Log(x + SM.Sqrt(x * x + 1)); + public static object Asinh(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Asinh(d) : General(general, "__ir_asinh__", self, x); } [RubyMethodAttribute("atan", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("atan", RubyMethodAttributes.PublicSingleton)] - public static double Atan(object self, [DefaultProtocol]double x) { - return SM.Atan(x); + public static object Atan(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Atan(d) : General(general, "__ir_atan__", self, x); } [RubyMethodAttribute("atan2", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("atan2", RubyMethodAttributes.PublicSingleton)] - public static double Atan2(object self, [DefaultProtocol]double y, [DefaultProtocol]double x) { - return SM.Atan2(y, x); + public static object Atan2(CallSiteStorage>/*!*/ general, object self, object y, object x) { + double dy, dx; + if (TryGetDouble(y, out dy) && TryGetDouble(x, out dx)) { + if (double.IsNaN(dy)) return dy; + if (double.IsNaN(dx)) return dx; + return SM.Atan2(dy, dx); + } + return General(general, "__ir_atan2__", self, y, x); } [RubyMethodAttribute("atanh", RubyMethodAttributes.PrivateInstance)] @@ -99,20 +141,23 @@ public static double Atanh(object self, [DefaultProtocol]double x) { [RubyMethodAttribute("cos", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("cos", RubyMethodAttributes.PublicSingleton)] - public static double Cos(object self, [DefaultProtocol]double x) { - return SM.Cos(x); + public static object Cos(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Cos(d) : General(general, "__ir_cos__", self, x); } [RubyMethodAttribute("cosh", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("cosh", RubyMethodAttributes.PublicSingleton)] - public static double Cosh(object self, [DefaultProtocol]double x) { - return SM.Cosh(x); + public static object Cosh(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Cosh(d) : General(general, "__ir_cosh__", self, x); } [RubyMethodAttribute("cbrt", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("cbrt", RubyMethodAttributes.PublicSingleton)] - public static double CubeRoot(object self, [DefaultProtocol]double x) { - return System.Math.Pow(x, 1.0 / 3.0); + public static object CubeRoot(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Cbrt(d) : General(general, "__ir_cbrt__", self, x); } [RubyMethodAttribute("erf", RubyMethodAttributes.PrivateInstance)] @@ -129,8 +174,9 @@ public static double Erfc(object self, [DefaultProtocol]double x) { [RubyMethodAttribute("exp", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("exp", RubyMethodAttributes.PublicSingleton)] - public static double Exp(object self, [DefaultProtocol]double x) { - return SM.Exp(x); + public static object Exp(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Exp(d) : General(general, "__ir_exp__", self, x); } [RubyMethodAttribute("gamma", RubyMethodAttributes.PrivateInstance)] @@ -141,8 +187,21 @@ public static double Gamma(object self, [DefaultProtocol]double x) { [RubyMethodAttribute("hypot", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("hypot", RubyMethodAttributes.PublicSingleton)] - public static double Hypot(object self, [DefaultProtocol]double x, [DefaultProtocol]double y) { - return DomainCheck(SM.Sqrt(x*x+y*y), "hypot"); + public static object Hypot(CallSiteStorage>/*!*/ general, object self, object x, object y) { + double dx, dy; + if (!TryGetDouble(x, out dx) || !TryGetDouble(y, out dy)) { + return General(general, "__ir_hypot__", self, x, y); + } + dx = SM.Abs(dx); + dy = SM.Abs(dy); + // an infinity wins over a NaN, which is what C's hypot() promises + if (double.IsInfinity(dx) || double.IsInfinity(dy)) return double.PositiveInfinity; + if (double.IsNaN(dx) || double.IsNaN(dy)) return double.NaN; + if (dx < dy) { double t = dx; dx = dy; dy = t; } + if (dy == 0.0) return dx; + // scaling by the larger operand keeps x*x from overflowing for x ~ 1e300 + double r = dy / dx; + return dx * SM.Sqrt(1.0 + r * r); } [RubyMethodAttribute("frexp", RubyMethodAttributes.PrivateInstance)] @@ -194,32 +253,42 @@ public static double Log2(object self, [DefaultProtocol]double x) { [RubyMethodAttribute("sin", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("sin", RubyMethodAttributes.PublicSingleton)] - public static double Sin(object self, [DefaultProtocol]double x) { - return SM.Sin(x); + public static object Sin(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Sin(d) : General(general, "__ir_sin__", self, x); } [RubyMethodAttribute("sinh", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("sinh", RubyMethodAttributes.PublicSingleton)] - public static double Sinh(object self, [DefaultProtocol]double x) { - return SM.Sinh(x); + public static object Sinh(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Sinh(d) : General(general, "__ir_sinh__", self, x); } [RubyMethodAttribute("sqrt", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("sqrt", RubyMethodAttributes.PublicSingleton)] - public static double Sqrt(object self, [DefaultProtocol]double x) { - return DomainCheck(SM.Sqrt(x), "sqrt"); + public static object Sqrt(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + if (TryGetDouble(x, out d)) { + if (d > 0.0) return SM.Sqrt(d); + if (d == 0.0) return 0.0; // sqrt(-0.0) is -0.0 in IEEE but 0.0 in MRI + if (double.IsNaN(d)) return d; + } + return General(general, "__ir_sqrt__", self, x); } [RubyMethodAttribute("tan", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("tan", RubyMethodAttributes.PublicSingleton)] - public static double Tan(object self, [DefaultProtocol]double x) { - return SM.Tan(x); + public static object Tan(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Tan(d) : General(general, "__ir_tan__", self, x); } [RubyMethodAttribute("tanh", RubyMethodAttributes.PrivateInstance)] [RubyMethodAttribute("tanh", RubyMethodAttributes.PublicSingleton)] - public static double Tanh(object self, [DefaultProtocol]double x) { - return SM.Tanh(x); + public static object Tanh(CallSiteStorage>/*!*/ general, object self, object x) { + double d; + return TryGetDouble(x, out d) ? (object)SM.Tanh(d) : General(general, "__ir_tanh__", self, x); } #endregion diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index 6324a511..39150012 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -5642,8 +5642,8 @@ private static void LoadMath_Constants(IronRuby.Builtins.RubyModule/*!*/ module) private static void LoadMath_Instance(IronRuby.Builtins.RubyModule/*!*/ module) { DefineLibraryMethod(module, "acos", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Acos) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Acos) ); DefineLibraryMethod(module, "acosh", 0x52, @@ -5652,23 +5652,23 @@ private static void LoadMath_Instance(IronRuby.Builtins.RubyModule/*!*/ module) ); DefineLibraryMethod(module, "asin", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Asin) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Asin) ); DefineLibraryMethod(module, "asinh", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Asinh) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Asinh) ); DefineLibraryMethod(module, "atan", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Atan) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Atan) ); DefineLibraryMethod(module, "atan2", 0x52, - 0x00030000U, - new Func(IronRuby.Builtins.RubyMath.Atan2) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Atan2) ); DefineLibraryMethod(module, "atanh", 0x52, @@ -5677,18 +5677,18 @@ private static void LoadMath_Instance(IronRuby.Builtins.RubyModule/*!*/ module) ); DefineLibraryMethod(module, "cbrt", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.CubeRoot) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.CubeRoot) ); DefineLibraryMethod(module, "cos", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Cos) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Cos) ); DefineLibraryMethod(module, "cosh", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Cosh) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Cosh) ); DefineLibraryMethod(module, "erf", 0x52, @@ -5702,8 +5702,8 @@ private static void LoadMath_Instance(IronRuby.Builtins.RubyModule/*!*/ module) ); DefineLibraryMethod(module, "exp", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Exp) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Exp) ); DefineLibraryMethod(module, "frexp", 0x52, @@ -5717,8 +5717,8 @@ private static void LoadMath_Instance(IronRuby.Builtins.RubyModule/*!*/ module) ); DefineLibraryMethod(module, "hypot", 0x52, - 0x00030000U, - new Func(IronRuby.Builtins.RubyMath.Hypot) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Hypot) ); DefineLibraryMethod(module, "ldexp", 0x52, @@ -5747,36 +5747,36 @@ private static void LoadMath_Instance(IronRuby.Builtins.RubyModule/*!*/ module) ); DefineLibraryMethod(module, "sin", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Sin) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Sin) ); DefineLibraryMethod(module, "sinh", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Sinh) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Sinh) ); DefineLibraryMethod(module, "sqrt", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Sqrt) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Sqrt) ); DefineLibraryMethod(module, "tan", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Tan) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Tan) ); DefineLibraryMethod(module, "tanh", 0x52, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Tanh) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Tanh) ); } private static void LoadMath_Class(IronRuby.Builtins.RubyModule/*!*/ module) { DefineLibraryMethod(module, "acos", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Acos) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Acos) ); DefineLibraryMethod(module, "acosh", 0x61, @@ -5785,23 +5785,23 @@ private static void LoadMath_Class(IronRuby.Builtins.RubyModule/*!*/ module) { ); DefineLibraryMethod(module, "asin", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Asin) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Asin) ); DefineLibraryMethod(module, "asinh", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Asinh) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Asinh) ); DefineLibraryMethod(module, "atan", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Atan) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Atan) ); DefineLibraryMethod(module, "atan2", 0x61, - 0x00030000U, - new Func(IronRuby.Builtins.RubyMath.Atan2) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Atan2) ); DefineLibraryMethod(module, "atanh", 0x61, @@ -5810,18 +5810,18 @@ private static void LoadMath_Class(IronRuby.Builtins.RubyModule/*!*/ module) { ); DefineLibraryMethod(module, "cbrt", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.CubeRoot) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.CubeRoot) ); DefineLibraryMethod(module, "cos", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Cos) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Cos) ); DefineLibraryMethod(module, "cosh", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Cosh) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Cosh) ); DefineLibraryMethod(module, "erf", 0x61, @@ -5835,8 +5835,8 @@ private static void LoadMath_Class(IronRuby.Builtins.RubyModule/*!*/ module) { ); DefineLibraryMethod(module, "exp", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Exp) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Exp) ); DefineLibraryMethod(module, "frexp", 0x61, @@ -5850,8 +5850,8 @@ private static void LoadMath_Class(IronRuby.Builtins.RubyModule/*!*/ module) { ); DefineLibraryMethod(module, "hypot", 0x61, - 0x00030000U, - new Func(IronRuby.Builtins.RubyMath.Hypot) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Hypot) ); DefineLibraryMethod(module, "ldexp", 0x61, @@ -5880,28 +5880,28 @@ private static void LoadMath_Class(IronRuby.Builtins.RubyModule/*!*/ module) { ); DefineLibraryMethod(module, "sin", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Sin) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Sin) ); DefineLibraryMethod(module, "sinh", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Sinh) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Sinh) ); DefineLibraryMethod(module, "sqrt", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Sqrt) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Sqrt) ); DefineLibraryMethod(module, "tan", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Tan) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Tan) ); DefineLibraryMethod(module, "tanh", 0x61, - 0x00010000U, - new Func(IronRuby.Builtins.RubyMath.Tanh) + 0x00000000U, + new Func>, System.Object, System.Object, System.Object>(IronRuby.Builtins.RubyMath.Tanh) ); } diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index bfd6b27f..a88df643 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -9286,25 +9286,25 @@ def __dom__(name) module_function :__flt__, :__dom__ private_class_method :__flt__, :__dom__ - def acos(x) + def __ir_acos__(x) x = __flt__(x) return x if x.nan? __dom__("acos") if x < -1.0 || x > 1.0 ::System::Math.Acos(x) end - def asin(x) + def __ir_asin__(x) x = __flt__(x) return x if x.nan? __dom__("asin") if x < -1.0 || x > 1.0 ::System::Math.Asin(x) end - def atan(x) + def __ir_atan__(x) ::System::Math.Atan(__flt__(x)) end - def atan2(y, x) + def __ir_atan2__(y, x) y = __flt__(y) x = __flt__(x) return y if y.nan? @@ -9319,7 +9319,7 @@ def acosh(x) ::System::Math.Acosh(x) end - def asinh(x) + def __ir_asinh__(x) ::System::Math.Asinh(__flt__(x)) end @@ -9332,40 +9332,40 @@ def atanh(x) ::System::Math.Atanh(x) end - def cos(x) + def __ir_cos__(x) ::System::Math.Cos(__flt__(x)) end - def sin(x) + def __ir_sin__(x) ::System::Math.Sin(__flt__(x)) end - def tan(x) + def __ir_tan__(x) ::System::Math.Tan(__flt__(x)) end - def cosh(x) + def __ir_cosh__(x) ::System::Math.Cosh(__flt__(x)) end - def sinh(x) + def __ir_sinh__(x) ::System::Math.Sinh(__flt__(x)) end - def tanh(x) + def __ir_tanh__(x) ::System::Math.Tanh(__flt__(x)) end - def exp(x) + def __ir_exp__(x) ::System::Math.Exp(__flt__(x)) end # pow(x, 1/3.0) is NaN for every negative x; cbrt is defined there. - def cbrt(x) + def __ir_cbrt__(x) ::System::Math.Cbrt(__flt__(x)) end - def sqrt(x) + def __ir_sqrt__(x) x = __flt__(x) return x if x.nan? __dom__("sqrt") if x < 0.0 @@ -9461,7 +9461,7 @@ def erfc(x) __ir_erfc__(__flt__(x)) end - def hypot(x, y) + def __ir_hypot__(x, y) x = __flt__(x).abs y = __flt__(y).abs # An infinity wins over a NaN, which is what C's hypot() promises. @@ -9548,10 +9548,20 @@ def frexp(x) end module_function :frexp - module_function :acos, :acosh, :asin, :asinh, :atan, :atan2, :atanh, :cbrt, - :cos, :cosh, :erf, :erfc, :exp, :expm1, :gamma, :hypot, - :ldexp, :lgamma, :log, :log10, :log1p, :log2, :sin, :sinh, - :sqrt, :tan, :tanh + module_function :acosh, :atanh, :erf, :erfc, :expm1, :gamma, + :ldexp, :lgamma, :log, :log10, :log1p, :log2 + # complex18.rb (the 1.9 Complex still loaded at boot) redefined most of these to return + # Complex numbers and kept the C# originals as name!. Put the C# methods - RubyMath, whose + # Float/Fixnum fast path falls back to the __ir___ definitions above - back in place. + %i[acos asin atan atan2 cos sin tan cosh sinh tanh asinh exp sqrt].each do |name| + bang = :"#{name}!" + alias_method name, bang if private_method_defined?(bang) || method_defined?(bang) + end + module_function :acos, :asin, :atan, :atan2, :cos, :sin, :tan, :cosh, :sinh, :tanh, :asinh, :exp, :cbrt, + :sqrt, :hypot + # The Float/Fixnum fast path of these is C# (RubyMath); their general Ruby half: + module_function :__ir_acos__, :__ir_asin__, :__ir_atan__, :__ir_atan2__, :__ir_cos__, :__ir_sin__, :__ir_tan__, :__ir_cosh__, :__ir_sinh__, :__ir_tanh__, :__ir_asinh__, :__ir_exp__, :__ir_cbrt__, :__ir_sqrt__, :__ir_hypot__ + private_class_method :__ir_acos__, :__ir_asin__, :__ir_atan__, :__ir_atan2__, :__ir_cos__, :__ir_sin__, :__ir_tan__, :__ir_cosh__, :__ir_sinh__, :__ir_tanh__, :__ir_asinh__, :__ir_exp__, :__ir_cbrt__, :__ir_sqrt__, :__ir_hypot__ end # `ruby2_keywords' written at the top level is Object.ruby2_keywords. MRI defines it on From 88184fb83cda62755b4db560826d9609d7ef6edc Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 22:06:22 +0200 Subject: [PATCH 53/94] Regexp: work out once whether the pattern has a non-ASCII escape RubyRegex.Encoding and IsFixedEncoding, consulted on every match, rescanned the whole pattern source character by character for \xNN, \uNNNN and octal escapes - 21.5% of ruby-bench's ruby-json. The pattern is frozen once set, so the answer is cached per Regexp and reset where the pattern is (re)assigned. ruby-bench ruby-json, best iteration, vs 067bf1a5: 2172ms -> 1487ms (1.46x). Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/RubyRegex.cs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Src/Ruby/Builtins/RubyRegex.cs b/Src/Ruby/Builtins/RubyRegex.cs index 19b3894e..d5fe27b7 100644 --- a/Src/Ruby/Builtins/RubyRegex.cs +++ b/Src/Ruby/Builtins/RubyRegex.cs @@ -28,6 +28,9 @@ public partial class RubyRegex : IEquatable, IDuplicable, IRubyObject // 1.9: correctly encoded, switched to characters // 1.8: k-coded binary data, if _options specify encoding, or raw binary data otherwise. private MutableString/*!*/ _pattern; + // HasNonAsciiEscape(_pattern) - the pattern is frozen, so it is worked out once: -1 not yet, + // 0 none, 1 a byte escape, 2 a \u escape. Encoding and IsFixedEncoding ask on every match. + private int _nonAsciiEscape = -1; private RubyRegexOptions _options; private bool _hasGAnchor; @@ -160,6 +163,7 @@ public void Set(MutableString/*!*/ pattern, RubyRegexOptions options) { _options = options & ~RubyRegexOptions.Once; _cachedRegex = null; _cachedAstralRegex = null; + _nonAsciiEscape = -1; RubyEncoding encoding = RubyEncoding.GetRegexEncoding(options); if (encoding != null) { @@ -435,14 +439,14 @@ public RubyEncoding/*!*/ Encoding { // above 7 bits without /n is "invalid multibyte escape" in MRI; we don't raise, // and the pattern's own encoding is as good an answer as any.) bool isUnicode; - if (HasNonAsciiEscape(_pattern, out isUnicode)) { + if (PatternHasNonAsciiEscape(out isUnicode)) { return isUnicode ? RubyEncoding.UTF8 : _pattern.Encoding; } return RubyEncoding.Ascii; } - if (encodingOptions == RubyRegexOptions.FIXED && _pattern.IsAscii() && !HasNonAsciiEscape(_pattern)) { + if (encodingOptions == RubyRegexOptions.FIXED && _pattern.IsAscii() && !PatternHasNonAsciiEscape(out _)) { return RubyEncoding.Ascii; } @@ -570,6 +574,16 @@ public static bool HasBackReference(MutableString/*!*/ pattern) { /// written with byte escapes would silently lose its encoding when unioned with a plain /// ASCII string. /// + private bool PatternHasNonAsciiEscape(out bool isUnicode) { + int cached = _nonAsciiEscape; + if (cached < 0) { + cached = HasNonAsciiEscape(_pattern, out isUnicode) ? (isUnicode ? 2 : 1) : 0; + _nonAsciiEscape = cached; + } + isUnicode = cached == 2; + return cached != 0; + } + public static bool HasNonAsciiEscape(MutableString/*!*/ pattern) { bool isUnicode; return HasNonAsciiEscape(pattern, out isUnicode); @@ -656,7 +670,8 @@ public bool IsFixedEncoding { // /n on its own says nothing - /abc/n matches any ASCII compatible string. It // pins the encoding only once the pattern really compiles to non-ASCII bytes, // which it does for a byte escape as much as for a literal non-ASCII character. - return !_pattern.IsAscii() || HasNonAsciiEscape(_pattern); + bool isUnicode; + return !_pattern.IsAscii() || PatternHasNonAsciiEscape(out isUnicode); } return (_options & (RubyRegexOptions.EUC | RubyRegexOptions.SJIS | RubyRegexOptions.UTF8 | RubyRegexOptions.FixedEncoding)) != 0 From 3bf02fb974edf293d1e1bf0f628839644b312ba9 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 22:17:00 +0200 Subject: [PATCH 54/94] Array#sort: Strings, Fixnums and Floats compared directly when <=> is the built-in one sort without a block dispatched #<=> through a call site for every comparison of the merge sort (sort_strings: MergeSortRange 36% self). An array of only Strings (exact class, no singleton class), only Fixnums or only non-NaN Floats, whose class's <=> is still the library method, is now sorted by a typed, stable merge sort over a key array - the comparisons String#<=>, Integer#<=> and Float#<=> make for two such values, so the order is the same. A redefined <=>, a String subclass or singleton, a NaN or a mixed array goes the general way as before. Release, vs 067bf1a5 (loaded box): sort_strings 0.778s -> 0.645s (1.21x); sorting 200k Fixnums 5x 0.956s -> 0.453s (2.1x), 200k Floats 1.032s -> 0.532s (1.9x). Output identical to base on a redefinition/singleton/NaN/subclass script, and equal to sort { |a, b| a <=> b } (object identities included) on randomized arrays. Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/ArrayOps.cs | 144 ++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/Src/Libraries/Builtins/ArrayOps.cs b/Src/Libraries/Builtins/ArrayOps.cs index 7efde1c1..04d07814 100644 --- a/Src/Libraries/Builtins/ArrayOps.cs +++ b/Src/Libraries/Builtins/ArrayOps.cs @@ -316,7 +316,9 @@ internal static RubyArray SortInPlace(ComparisonStorage/*!*/ comparisonStorage, var context = comparisonStorage.Context; if (block == null) { - MergeSort(self, (x, y) => Protocols.Compare(comparisonStorage, x, y)); + if (!TrySortWithoutDispatch(context, self)) { + MergeSort(self, (x, y) => Protocols.Compare(comparisonStorage, x, y)); + } } else { object nonRefBreakResult = null; try { @@ -343,6 +345,146 @@ internal static RubyArray SortInPlace(ComparisonStorage/*!*/ comparisonStorage, return self; } + /// + /// sort without a block over an array of only Strings, only Fixnums or only Floats (no NaN) - the + /// common case - compares the elements directly instead of dispatching #<=> for every pair, as + /// long as the class's #<=> is still the built-in one and no element is an instance of a + /// subclass or has a singleton class. The comparisons are the ones those #<=> make, so the + /// order is the same; anything else goes the general way. + /// + private static bool TrySortWithoutDispatch(RubyContext/*!*/ context, RubyArray/*!*/ self) { + int count = self.Count; + // (#<=> called from a C# sort ignores refinements, as rb_funcall does in MRI's sort) + if (count < 2) { + return false; + } + + object first = self[0]; + if (first != null && first.GetType() == typeof(MutableString)) { + var stringClass = context.GetClass(typeof(MutableString)); + var keys = new StringKey[count]; + for (int i = 0; i < count; i++) { + object item = self[i]; + if (item == null || item.GetType() != typeof(MutableString) || context.GetImmediateClassOf(item) != stringClass) { + return false; + } + keys[i] = new StringKey((MutableString)item); + } + if (!HasBuiltinComparison(stringClass)) { + return false; + } + SortByKeys(self, keys); + return true; + } + + if (first is int) { + var keys = new int[count]; + for (int i = 0; i < count; i++) { + object item = self[i]; + if (!(item is int)) { + return false; + } + keys[i] = (int)item; + } + if (!HasBuiltinComparison(context.GetClassOf(first))) { + return false; + } + SortByKeys(self, keys); + return true; + } + + if (first is double) { + var keys = new double[count]; + for (int i = 0; i < count; i++) { + object item = self[i]; + if (!(item is double) || Double.IsNaN((double)item)) { + return false; + } + keys[i] = (double)item; + } + if (!HasBuiltinComparison(context.GetClassOf(first))) { + return false; + } + SortByKeys(self, keys); + return true; + } + + return false; + } + + // A struct, so that the generic sort below is compiled for it and calls MutableString.CompareTo + // directly; over the class itself it would share one body and dispatch every comparison. + private struct StringKey : IComparable { + private readonly MutableString/*!*/ _value; + public StringKey(MutableString/*!*/ value) { _value = value; } + public int CompareTo(StringKey other) { return _value.CompareTo(other._value); } + } + + // The same stable merge sort as MergeSort, over keys of one type compared by their own CompareTo + // (what String#<=>, Integer#<=> and Float#<=> do for two of them), carrying the elements along. + private static void SortByKeys(RubyArray/*!*/ self, TKey[]/*!*/ keys) where TKey : IComparable { + int count = keys.Length; + object[] items = new object[count]; + self.CopyTo(items, 0); + MergeSortKeys(keys, items, new TKey[count], new object[count], 0, count); + for (int i = 0; i < count; i++) { + self[i] = items[i]; + } + } + + private static void MergeSortKeys(TKey[]/*!*/ keys, object[]/*!*/ items, TKey[]/*!*/ keyBuffer, object[]/*!*/ itemBuffer, + int start, int length) where TKey : IComparable { + + if (length <= 12) { + // insertion sort, stable: an element moves left only past strictly greater ones + for (int i = start + 1; i < start + length; i++) { + TKey key = keys[i]; + object item = items[i]; + int j = i - 1; + while (j >= start && key.CompareTo(keys[j]) < 0) { + keys[j + 1] = keys[j]; + items[j + 1] = items[j]; + j--; + } + keys[j + 1] = key; + items[j + 1] = item; + } + return; + } + + int half = length / 2; + MergeSortKeys(keys, items, keyBuffer, itemBuffer, start, half); + MergeSortKeys(keys, items, keyBuffer, itemBuffer, start + half, length - half); + + int left = start, right = start + half, target = start; + int leftEnd = start + half, rightEnd = start + length; + while (left < leftEnd && right < rightEnd) { + if (keys[right].CompareTo(keys[left]) < 0) { + keyBuffer[target] = keys[right]; + itemBuffer[target++] = items[right++]; + } else { + keyBuffer[target] = keys[left]; + itemBuffer[target++] = items[left++]; + } + } + while (left < leftEnd) { + keyBuffer[target] = keys[left]; + itemBuffer[target++] = items[left++]; + } + while (right < rightEnd) { + keyBuffer[target] = keys[right]; + itemBuffer[target++] = items[right++]; + } + + Array.Copy(keyBuffer, start, keys, start, length); + Array.Copy(itemBuffer, start, items, start, length); + } + + private static bool HasBuiltinComparison(RubyClass/*!*/ cls) { + var method = cls.ResolveMethod("<=>", VisibilityContext.AllVisible); + return method.Found && method.Info is RubyLibraryMethodInfo; + } + /// /// List<T>.Sort cannot be used here: it wraps whatever the comparer throws in an /// InvalidOperationException (so a Ruby exception or a block's break never arrives From 5ac5a36ddf4e4f174c99e42fd9eed8fa319cf983 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 22:24:03 +0200 Subject: [PATCH 55/94] Hash#each: one pass over the entries, no second lookup per key Hash#each/each_pair copied the keys and then looked each one up again (self[key]), hashing every key twice - for a key of a Ruby class a #hash and an #eql? call per entry, which MRI's each never makes. Keys and values now come out of one pass over the entry array (Hash.CopyKeysAndValues); each value is used as is while the hash is unchanged, which a new Hash.MutationStamp tracks (it also moves on value overwrites, which _version deliberately ignores). Once the block mutates the hash the rest is looked up as before, except that a key the block deleted before its turn is now skipped, as in MRI - it used to raise KeyNotFoundException. Release vs 067bf1a5: each over 1000 String keys x2000 0.416s -> 0.343s (1.21x). Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Extensions/IDictionaryOps.cs | 34 ++++++++++++++++++++-- Src/Ruby/Builtins/Hash.Storage.cs | 29 ++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/Src/Libraries/Extensions/IDictionaryOps.cs b/Src/Libraries/Extensions/IDictionaryOps.cs index 3c5383c7..b88bffb0 100644 --- a/Src/Libraries/Extensions/IDictionaryOps.cs +++ b/Src/Libraries/Extensions/IDictionaryOps.cs @@ -251,13 +251,43 @@ public static object DeleteIf(BlockParam block, IDictionary/*!*/ [RubyMethod("each_pair")] public static object Each(RubyContext/*!*/ context, [NotNull]BlockParam/*!*/ block, IDictionary/*!*/ self) { if (self.Count > 0) { + bool yieldPair = IsPairYieldOptimizable(block); + + // A Ruby Hash: keys and values come out of one pass over its entries, and each value + // is used as is while the block leaves the hash alone; once it mutates it, the rest is + // looked up as below. That is the same answer without hashing every key a second + // time - which, for a key of a Ruby class, was a #hash and an #eql? call per entry. + var hash = self as Hash; + if (hash != null) { + int count = hash.Count; + object[] hashKeys = new object[count]; + object[] hashValues = new object[count]; + hash.CopyKeysAndValues(hashKeys, hashValues); + int stamp = hash.MutationStamp; + for (int i = 0; i < count; i++) { + object key = hashKeys[i]; + object value; + if (hash.MutationStamp == stamp) { + value = hashValues[i]; + } else if (!hash.TryGetValue(key, out value)) { + // deleted by the block before its turn came: MRI skips it + continue; + } + object result; + if (yieldPair + ? block.Yield(CustomStringDictionary.ObjToNull(key), value, out result) + : block.Yield(MakeArray(key, value), out result)) { + return result; + } + } + return self; + } + // Must make a copy of the Keys array so that we can iterate over a static set of keys. Remember // that the block can modify the hash, hence the need for a copy of the keys object[] keys = new object[self.Count]; self.Keys.CopyTo(keys, 0); - bool yieldPair = IsPairYieldOptimizable(block); - // TODO: what are all the scenarios where the block can mutate the hash? can it remove keys? if so, what happens? for (int i = 0; i < keys.Length; i++) { object result; diff --git a/Src/Ruby/Builtins/Hash.Storage.cs b/Src/Ruby/Builtins/Hash.Storage.cs index 09b766ed..00bdd221 100644 --- a/Src/Ruby/Builtins/Hash.Storage.cs +++ b/Src/Ruby/Builtins/Hash.Storage.cs @@ -89,6 +89,9 @@ private struct Entry { /// private int _start; private int _version; + // Changes with every mutation, an overwrite of an existing key's value included (which + // _version leaves alone, so that enumerators don't fail on it). See MutationStamp. + private int _stamp; private IEqualityComparer/*!*/ _comparer; private KeyCollection _keys; @@ -426,6 +429,7 @@ private bool Insert(object key, object value, int behavior) { if (existing.HashCode == hashCode && comparer.Equals(existing.Key, key)) { if (behavior == InsertOverwrite) { existing.Value = value; + _stamp++; return false; } if (behavior == InsertThrow) { @@ -455,6 +459,7 @@ private bool Insert(object key, object value, int behavior) { b = index + 1; _liveCount++; _version++; + _stamp++; return true; } @@ -503,6 +508,7 @@ public bool Remove(object key, out object value) { found.Value = null; _liveCount--; _version++; + _stamp++; // Removing the newest entry needs no tombstone at all; dropping it (and any // tombstones it was sitting on) keeps push/pop patterns from growing the // entry array. Removing the oldest one moves the enumeration start instead, @@ -550,6 +556,7 @@ public void Clear() { _start = 0; } _version++; + _stamp++; } #endregion @@ -611,6 +618,28 @@ bool ICollection>.Remove(KeyValuePair + /// Changes with every mutation of the hash, value overwrites included: an iteration that + /// took a snapshot of the entries can use it for as long as this stays the same. + /// + public int MutationStamp { + get { return _stamp; } + } + + /// Keys and values in insertion order, in one pass over the entries. + public void CopyKeysAndValues(object[]/*!*/ keys, object[]/*!*/ values) { + Entry[] entries = _entries; + int count = _count; + int index = 0; + for (int i = _start; i < count; i++) { + ref Entry e = ref entries[i]; + if (e.Next != TombstoneNext) { + keys[index] = e.Key; + values[index++] = e.Value; + } + } + } + public void CopyTo(KeyValuePair[]/*!*/ array, int index) { CheckCopyToArgs(array != null ? array.Length : 0, array == null, index); Entry[] entries = _entries; From 33e7775de1002642d20407c4f8443d62b461bb7e Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 22:31:19 +0200 Subject: [PATCH 56/94] Array literals: build the content array directly; adopt the one a long literal makes MakeArray1..5 created a RubyArray and Add-ed each element (a frozen check and an append per element); MakeArrayN allocated a second array and copied the one the compiled code had just built. The small ones now hand a filled array to RubyArray's internal constructor, and an array literal of more than five elements goes to a new MakeArrayOwned that adopts the array built for it alone. MakeArrayN, which library code calls with params arrays that may be reused (IListOps re-yields its buffer), still copies. `a = [i, i]; b = [i, i, i, i, i, i, i, i]` x3M: 0.433s -> 0.296s (CRuby 0.290s). ruby-bench binarytrees 1083 -> 1042ms, splay unchanged within noise. Co-Authored-By: Claude Opus 5.5 --- .../Compiler/ReflectionCache.Generated.cs | 2 + Src/Ruby/Compiler/ReflectionCache.cs | 2 +- Src/Ruby/Runtime/RubyOps.cs | 38 +++++++------------ 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index eb878a02..a0c38b05 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -450,6 +450,8 @@ public static partial class Methods { private static MethodInfo _MakeArray5; public static MethodInfo/*!*/ MakeArrayN { get { return _MakeArrayN ?? (_MakeArrayN = CallInstruction.CacheFunc(RubyOps.MakeArrayN)); } } private static MethodInfo _MakeArrayN; + public static MethodInfo/*!*/ MakeArrayOwned { get { return _MakeArrayOwned ?? (_MakeArrayOwned = CallInstruction.CacheFunc(RubyOps.MakeArrayOwned)); } } + private static MethodInfo _MakeArrayOwned; public static MethodInfo/*!*/ MakeClrProtectedMethodCalledError { get { return _MakeClrProtectedMethodCalledError ?? (_MakeClrProtectedMethodCalledError = CallInstruction.CacheFunc(RubyOps.MakeClrProtectedMethodCalledError)); } } private static MethodInfo _MakeClrProtectedMethodCalledError; public static MethodInfo/*!*/ MakeClrVirtualMethodCalledError { get { return _MakeClrVirtualMethodCalledError ?? (_MakeClrVirtualMethodCalledError = CallInstruction.CacheFunc(RubyOps.MakeClrVirtualMethodCalledError)); } } diff --git a/Src/Ruby/Compiler/ReflectionCache.cs b/Src/Ruby/Compiler/ReflectionCache.cs index ad56e929..975ba899 100644 --- a/Src/Ruby/Compiler/ReflectionCache.cs +++ b/Src/Ruby/Compiler/ReflectionCache.cs @@ -169,7 +169,7 @@ public static MethodInfo/*!*/ MutableString_Freeze { default: Debug.Assert(args.Count > Runtime.RubyOps.OptimizedOpCallParamCount); - return Methods.MakeArrayN.OpCall(AstUtils.NewArrayHelper(typeof(object), args)); + return Methods.MakeArrayOwned.OpCall(AstUtils.NewArrayHelper(typeof(object), args)); } } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 6d9d25d4..78b5c5b2 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -1675,47 +1675,35 @@ public static RubyArray SplatKeywordHashIfHash(RubyScope/*!*/ scope, object hash [Emitted] public static RubyArray/*!*/ MakeArray1(object item1) { - RubyArray result = new RubyArray(1); - result.Add(item1); - return result; + return new RubyArray(new object[] { item1, null, null, null }, 0, 1); } [Emitted] public static RubyArray/*!*/ MakeArray2(object item1, object item2) { - RubyArray result = new RubyArray(2); - result.Add(item1); - result.Add(item2); - return result; + return new RubyArray(new object[] { item1, item2, null, null }, 0, 2); } [Emitted] public static RubyArray/*!*/ MakeArray3(object item1, object item2, object item3) { - RubyArray result = new RubyArray(3); - result.Add(item1); - result.Add(item2); - result.Add(item3); - return result; + return new RubyArray(new object[] { item1, item2, item3, null }, 0, 3); } [Emitted] public static RubyArray/*!*/ MakeArray4(object item1, object item2, object item3, object item4) { - RubyArray result = new RubyArray(4); - result.Add(item1); - result.Add(item2); - result.Add(item3); - result.Add(item4); - return result; + return new RubyArray(new object[] { item1, item2, item3, item4 }, 0, 4); } [Emitted] public static RubyArray/*!*/ MakeArray5(object item1, object item2, object item3, object item4, object item5) { - RubyArray result = new RubyArray(5); - result.Add(item1); - result.Add(item2); - result.Add(item3); - result.Add(item4); - result.Add(item5); - return result; + return new RubyArray(new object[] { item1, item2, item3, item4, item5 }, 0, 5); + } + + // The elements of an array literal of more than five, in an array built for it alone + // (ReflectionCache.MakeArrayOpCall): the Array takes it over instead of copying it. + [Emitted] + public static RubyArray/*!*/ MakeArrayOwned(object[]/*!*/ items) { + Debug.Assert(items.Length >= Utils.MinListSize); + return new RubyArray(items, 0, items.Length); } [Emitted] From 271979f818889327a777eb5a295e6a0239c4dfe8 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 23:05:59 +0200 Subject: [PATCH 57/94] JIT: re-specialize after a method table change; module creation no longer stales entries - RubyModule.GlobalMethodVersion, the JIT's one global guard, now counts method table changes only (IncrementMethodVersion). It used to share the counter every new module draws its version from, so Class.new, Struct.new or a fresh singleton class anywhere after a method was specialized failed that method's entry guard on every later call. - A specialized entry whose version snapshot is stale now takes itself down (JitStub.VersionChanged) and the method is profiled and specialized again under the new version, at most 8 times before it settles on the generic body. Before, the entry stayed installed and every call paid the failed guard plus the generic body for good. - An OSR loop copy built under an older version asks to be rebuilt (Retype) instead of counting a deopt, so it is not retired after eight entries. A hot method called after one Class.new: 0.66-0.87 s -> 0.30-0.35 s; after a real override: 0.72-0.80 s -> 0.34-0.42 s; a while loop in a method after a def: 0.53-0.57 s -> 0.12-0.16 s (CRuby 0.75 s). Util/jit-matrix.rb identical to CRuby with and without the JIT. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/RubyModule.cs | 15 ++++++-- Src/Ruby/Runtime/Jit/JitLoopCompiler.cs | 6 ++- Src/Ruby/Runtime/Jit/JitRuntime.cs | 11 ++++++ Src/Ruby/Runtime/Jit/JitStub.cs | 50 ++++++++++++++++++++++--- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/Src/Ruby/Builtins/RubyModule.cs b/Src/Ruby/Builtins/RubyModule.cs index cefc548a..ef7f3556 100644 --- a/Src/Ruby/Builtins/RubyModule.cs +++ b/Src/Ruby/Builtins/RubyModule.cs @@ -91,13 +91,19 @@ public partial class RubyModule : IDuplicable, IRubyObject { // interlocked internal static int _globalMethodVersion = 0; + // interlocked; bumped only where a class's method version is (IncrementMethodVersion) + private static int _methodTableVersion = 0; + /// - /// Read by JIT-compiled code as its one global guard: every method table change anywhere - /// bumps it, so a specialization that snapshots it is invalidated by any def, undef, - /// alias, include, extend or singleton-method definition. + /// Read by JIT-compiled code as its one global guard: every method table change that can + /// alter a resolution call sites depend on bumps it, so a specialization that snapshots it is + /// invalidated by any def, undef, alias, include, extend or singleton-method definition that + /// overrides something. Creating a class or module does not: a new module changes no existing + /// class's resolution, and Class.new, Struct.new or a fresh singleton class after a method + /// had been specialized would otherwise send that method down the generic path for good. /// public static int GlobalMethodVersion { - get { return System.Threading.Volatile.Read(ref _globalMethodVersion); } + get { return System.Threading.Volatile.Read(ref _methodTableVersion); } } internal static int _globalModuleId = 0; @@ -986,6 +992,7 @@ internal void AddDependentClass(RubyClass/*!*/ dependentClass) { private void IncrementMethodVersion() { if (IsClass) { Version.Method = Interlocked.Increment(ref _globalMethodVersion); + Interlocked.Increment(ref _methodTableVersion); } } diff --git a/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs b/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs index 3cf77019..ccf6761b 100644 --- a/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs +++ b/Src/Ruby/Runtime/Jit/JitLoopCompiler.cs @@ -200,10 +200,12 @@ internal static Func TryCompile(WhileLoopExpression/*!*/ loop, } // Nothing may have redefined Integer#+ (or anything else) since the types were - // read: one global version covers every def, undef, alias and include. + // read: one global version covers every def, undef, alias and include. A copy that + // fails it fails it on every later entry, so it asks to be rebuilt under the new + // version rather than counting as one more deopt towards retiring the loop. prologue.Add(Ast.IfThen( Ast.Not(Ast.Call(JitRuntime.M("VersionOk"), Ast.Constant(RubyModule.GlobalMethodVersion))), - Ast.Return(exit, Ast.Call(JitRuntime.M("OsrGuardFailed"), Ast.Constant("method version"))) + Ast.Return(exit, Ast.Call(JitRuntime.M("OsrVersionStale"))) )); var storeCurrent = new List(); diff --git a/Src/Ruby/Runtime/Jit/JitRuntime.cs b/Src/Ruby/Runtime/Jit/JitRuntime.cs index a5131529..12ea91c4 100644 --- a/Src/Ruby/Runtime/Jit/JitRuntime.cs +++ b/Src/Ruby/Runtime/Jit/JitRuntime.cs @@ -278,6 +278,17 @@ public static bool VersionOk(int snapshot) { return !Disabled && RubyModule.GlobalMethodVersion == snapshot; } + /// A method entry's VersionOk failed: it is stale for good (see JitStub.VersionChanged). + public static void EntryStale(object/*!*/ stub) { + ((JitStub)stub).VersionChanged(); + } + + /// An outlined loop was compiled under an older method version; build it again. + public static object OsrVersionStale() { + if (Osr.OsrLoopSite.Verbose) { Console.Error.WriteLine("[osr] guard failed: method version"); } + return Osr.OsrLoopSite.Retype; + } + /// An entry guard of an outlined loop did not hold; hand the loop back. public static object OsrGuardFailed(string/*!*/ what) { if (Osr.OsrLoopSite.Verbose) { Console.Error.WriteLine("[osr] guard failed: {0}", what); } diff --git a/Src/Ruby/Runtime/Jit/JitStub.cs b/Src/Ruby/Runtime/Jit/JitStub.cs index f11224ef..ad424fdf 100644 --- a/Src/Ruby/Runtime/Jit/JitStub.cs +++ b/Src/Ruby/Runtime/Jit/JitStub.cs @@ -129,6 +129,28 @@ internal void Invalidate() { _calls = 0; } + // How many times a method is specialized again after a method table change made its entry + // stale. A program that keeps defining methods (a `def obj.x' per object, define_method in + // a loop) would otherwise pay a compile every Threshold calls for as long as it runs. + private const int MaxRespecializations = 8; + private int _respecializations; + + /// + /// Called by the entry when the global method version no longer matches its snapshot. That + /// guard can never pass again, so leaving the entry installed would send every later call + /// through the failed guard into the generic body: profile again and specialize under the + /// new version instead, or settle on the generic body once that has happened too often. + /// + internal void VersionChanged() { + if (_givenUp) { return; } + if (++_respecializations > MaxRespecializations) { + _givenUp = true; + Install(_generic); + return; + } + Invalidate(); + } + private bool TrySpecialize() { if (JitRuntime.Disabled || _context.HasRefinements) { return false; } @@ -200,19 +222,19 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { args[i] = Ast.Parameter(typeof(object), "a" + i); } - MSA.Expression test = Ast.Call(JitRuntime.M("VersionOk"), Ast.Constant(version)); + MSA.Expression test = null; if (pinnedClass != null) { - test = Ast.AndAlso(test, Ast.ReferenceEqual( + test = And(test, Ast.ReferenceEqual( Ast.Call(JitRuntime.M("ClassOf"), Ast.Constant(_context), self), Ast.Constant(pinnedClass, typeof(RubyClass)))); } for (int i = 0; i < _arity; i++) { switch (code.ParameterTypes[i]) { - case JT.Int: test = Ast.AndAlso(test, Ast.TypeIs(args[i], typeof(int))); break; - case JT.Dbl: test = Ast.AndAlso(test, Ast.TypeIs(args[i], typeof(double))); break; + case JT.Int: test = And(test, Ast.TypeIs(args[i], typeof(int))); break; + case JT.Dbl: test = And(test, Ast.TypeIs(args[i], typeof(double))); break; // An Integer parameter carried as a long accepts either representation: a // value in Int32 range arrives as an Int32 and only a larger one as an Int64. - case JT.Lng: test = Ast.AndAlso(test, Ast.Call(JitRuntime.M("IsIntegral"), args[i])); break; + case JT.Lng: test = And(test, Ast.Call(JitRuntime.M("IsIntegral"), args[i])); break; } } @@ -241,7 +263,19 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { Ast.Catch(typeof(JitDeoptException), GenericCall(self, blk, args))); } - var body = Ast.Condition(test, fast, GenericCall(self, blk, args), typeof(object)); + MSA.Expression body = (test != null) ? Ast.Condition(test, fast, GenericCall(self, blk, args), typeof(object)) : fast; + + // A stale version fails this guard on every later call; the stub takes the entry down + // (JitStub.VersionChanged) and this one call runs the generic body. + body = Ast.Condition( + Ast.Call(JitRuntime.M("VersionOk"), Ast.Constant(version)), + body, + Ast.Block( + Ast.Call(JitRuntime.M("EntryStale"), Ast.Constant(this, typeof(object))), + GenericCall(self, blk, args) + ), + typeof(object) + ); var all = new MSA.ParameterExpression[_arity + 2]; all[0] = self; @@ -250,6 +284,10 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { return Ast.Lambda(_generic.GetType(), body, "jitentry$" + _ast.Name, all); } + private static MSA.Expression/*!*/ And(MSA.Expression left, MSA.Expression/*!*/ right) { + return (left != null) ? Ast.AndAlso(left, right) : right; + } + private MSA.Expression/*!*/ GenericCall(MSA.ParameterExpression/*!*/ self, MSA.ParameterExpression/*!*/ blk, MSA.ParameterExpression[]/*!*/ args) { var callArgs = new MSA.Expression[_arity + 2]; callArgs[0] = self; From cb8a57299433ff4d0e4aaf19612a730bc39b3711 Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 23:39:26 +0200 Subject: [PATCH 58/94] Smaller calls: keyword hashes without a bucket array; rare scope state out of line - f(a: x, b: y) with Symbol literal keys builds its hash with Hash.CreateKeywordArguments and AppendUnlinked: a prime-sized entry array, hash codes taken as an insert would take them, and no bucket array. Binding keyword parameters only scans the entries, so most keyword hashes never get one; the first lookup, insert or delete that probes links the buckets (LinkPendingBuckets), publishing the array only once it is complete. - RubyScope keeps what only eval/Binding locals and refinements need - the local-name map, eval-defined locals, the `using' lists, the memoized refinement activation and RubyMethodScope.DefinitionRefinements - in a RareState allocated on first use. Every method call and every block call that needs a scope allocates one, and these fields were 44 bytes of each. Bytes allocated per call (GC.GetAllocatedBytesForCurrentThread over 1M calls, no JIT): empty method called from a block 392 -> 304; two-keyword call 720 -> 592. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/Hash.Keywords.cs | 46 ++++++ Src/Ruby/Builtins/Hash.Storage.cs | 43 +++++- .../Ast/Definitions/MethodDefinition.cs | 2 +- Src/Ruby/Runtime/RubyOps.cs | 21 ++- Src/Ruby/Runtime/RubyScope.cs | 138 ++++++++++++------ 5 files changed, 186 insertions(+), 64 deletions(-) diff --git a/Src/Ruby/Builtins/Hash.Keywords.cs b/Src/Ruby/Builtins/Hash.Keywords.cs index fab98565..fa95e509 100644 --- a/Src/Ruby/Builtins/Hash.Keywords.cs +++ b/Src/Ruby/Builtins/Hash.Keywords.cs @@ -13,6 +13,10 @@ * * ***************************************************************************/ +using System; +using System.Collections.Generic; +using System.Diagnostics; + namespace IronRuby.Builtins { /// @@ -25,6 +29,48 @@ namespace IronRuby.Builtins { public partial class Hash { private const int IdentityScanLimit = 8; + /// + /// An empty keyword-call hash with room for Symbol keys, to be + /// filled with . Binding keyword parameters only scans the + /// entries (, ), so such a hash + /// gets no bucket array unless something probes it later (LinkPendingBuckets). + /// + internal static Hash/*!*/ CreateKeywordArguments(IEqualityComparer/*!*/ comparer, int capacity) { + var result = new Hash(comparer, 0); + int size = GetPrime(capacity); + result._entries = new Entry[size]; + result._fastModMultiplier = UInt64.MaxValue / (uint)size + 1; + result.IsKeywordArguments = true; + return result; + } + + /// + /// Adds a Symbol key to a hash made by , within the + /// capacity it was made with. A Symbol is equal only to itself, so a repeated key is found by + /// identity and, as an insert would, keeps its position and takes the new value. The hash + /// code is taken now, as an insert would take it, so linking the buckets later asks nothing + /// of the keys. + /// + internal void AppendUnlinked(object/*!*/ key, object value) { + Debug.Assert(_buckets == null && _count < _entries.Length); + Entry[] entries = _entries; + for (int i = 0; i < _count; i++) { + if (entries[i].Key == key) { + entries[i].Value = value; + _stamp++; + return; + } + } + ref Entry e = ref entries[_count++]; + e.HashCode = _comparer.GetHashCode(key); + e.Next = -1; + e.Key = key; + e.Value = value; + _liveCount++; + _version++; + _stamp++; + } + /// /// A copy of this hash - default value or proc and comparer included, as the copy /// constructor makes it - without the keys among , which are diff --git a/Src/Ruby/Builtins/Hash.Storage.cs b/Src/Ruby/Builtins/Hash.Storage.cs index 00bdd221..d7c7482d 100644 --- a/Src/Ruby/Builtins/Hash.Storage.cs +++ b/Src/Ruby/Builtins/Hash.Storage.cs @@ -253,6 +253,29 @@ private void RebuildBuckets() { LinkBuckets(); } + /// + /// Builds the bucket array of a hash whose entries were laid out without one (see + /// AppendUnlinked), on the first operation that probes. The entry array is already + /// prime-sized and the multiplier set, and the array is published only once every entry is + /// linked, so a reader racing with this sees either no buckets (and links its own, writing + /// the same chains) or complete ones. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private int[]/*!*/ LinkPendingBuckets() { + Entry[] entries = _entries; + var buckets = new int[entries.Length]; + for (int i = _start; i < _count; i++) { + ref Entry e = ref entries[i]; + if (e.Next != TombstoneNext) { + int bucket = (int)FastMod((uint)e.HashCode, (uint)buckets.Length, _fastModMultiplier); + e.Next = buckets[bucket] - 1; + buckets[bucket] = i + 1; + } + } + System.Threading.Volatile.Write(ref _buckets, buckets); + return buckets; + } + private void LinkBuckets() { int[] buckets = _buckets; Entry[] entries = _entries; @@ -321,7 +344,8 @@ private ref Entry FindEntryRef(object key, out bool found) { if (entries != null) { IEqualityComparer comparer = _comparer; int hashCode = comparer.GetHashCode(key); - int i = _buckets[BucketIndex(hashCode)] - 1; + int[] buckets = _buckets ?? LinkPendingBuckets(); + int i = buckets[(int)FastMod((uint)hashCode, (uint)buckets.Length, _fastModMultiplier)] - 1; int collisions = 0; while ((uint)i < (uint)entries.Length) { ref Entry e = ref entries[i]; @@ -414,7 +438,11 @@ private bool Insert(object key, object value, int behavior) { throw new ArgumentNullException("key"); } if (_buckets == null) { - Initialize(0); + if (_entries == null) { + Initialize(0); + } else { + LinkPendingBuckets(); + } } IEqualityComparer comparer = _comparer; @@ -481,8 +509,11 @@ public bool Remove(object key, out object value) { throw new ArgumentNullException("key"); } if (_buckets == null) { - value = null; - return false; + if (_entries == null) { + value = null; + return false; + } + LinkPendingBuckets(); } IEqualityComparer comparer = _comparer; @@ -549,7 +580,9 @@ public bool Remove(object key, out object value) { public void Clear() { if (_count > 0) { - Array.Clear(_buckets, 0, _buckets.Length); + if (_buckets != null) { + Array.Clear(_buckets, 0, _buckets.Length); + } Array.Clear(_entries, 0, _count); _count = 0; _liveCount = 0; diff --git a/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs b/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs index 488bdcae..19a8be5f 100644 --- a/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs +++ b/Src/Ruby/Compiler/Ast/Definitions/MethodDefinition.cs @@ -261,7 +261,7 @@ public MethodDefinition(LexicalScope/*!*/ definedScope, Expression target, strin // see RubyScope.GetActiveRefinements: MSA.Expression recordRefinements = (definitionRefinements != null) ? (MSA.Expression)Ast.Assign( - Ast.Field(scopeVariable, typeof(RubyMethodScope).GetField("DefinitionRefinements")), + Ast.Property(scopeVariable, typeof(RubyMethodScope).GetProperty("DefinitionRefinements")), Ast.Constant(definitionRefinements, typeof(StrongBox))) : AstUtils.Empty(); diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 78b5c5b2..6c51e978 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -1753,29 +1753,26 @@ public static RubyArray SplatKeywordHashIfHash(RubyScope/*!*/ scope, object hash [Emitted] public static Hash/*!*/ MakeKeywordArgumentsHash1(RubyScope/*!*/ scope, object key1, object value1) { - var result = new Hash(scope.RubyContext.EqualityComparer, 1); - result.IsKeywordArguments = true; - result[key1] = value1; + var result = Hash.CreateKeywordArguments(scope.RubyContext.EqualityComparer, 1); + result.AppendUnlinked(key1, value1); return result; } [Emitted] public static Hash/*!*/ MakeKeywordArgumentsHash2(RubyScope/*!*/ scope, object key1, object value1, object key2, object value2) { - var result = new Hash(scope.RubyContext.EqualityComparer, 2); - result.IsKeywordArguments = true; - result[key1] = value1; - result[key2] = value2; + var result = Hash.CreateKeywordArguments(scope.RubyContext.EqualityComparer, 2); + result.AppendUnlinked(key1, value1); + result.AppendUnlinked(key2, value2); return result; } [Emitted] public static Hash/*!*/ MakeKeywordArgumentsHash3(RubyScope/*!*/ scope, object key1, object value1, object key2, object value2, object key3, object value3) { - var result = new Hash(scope.RubyContext.EqualityComparer, 3); - result.IsKeywordArguments = true; - result[key1] = value1; - result[key2] = value2; - result[key3] = value3; + var result = Hash.CreateKeywordArguments(scope.RubyContext.EqualityComparer, 3); + result.AppendUnlinked(key1, value1); + result.AppendUnlinked(key2, value2); + result.AppendUnlinked(key3, value3); return result; } diff --git a/Src/Ruby/Runtime/RubyScope.cs b/Src/Ruby/Runtime/RubyScope.cs index 9af2991c..f9326654 100644 --- a/Src/Ruby/Runtime/RubyScope.cs +++ b/Src/Ruby/Runtime/RubyScope.cs @@ -92,8 +92,6 @@ public abstract class RubyScope : RuntimeFlowControl { internal bool InRescue; // closure: - private Dictionary _staticLocalMapping; - private Dictionary _dynamicLocals; internal /*and protected*/ MutableTuple _locals; // null if there are no variables internal /*and protected*/ string/*!*/[] _variableNames; // empty if there are no variables @@ -108,15 +106,57 @@ public abstract class RubyScope : RuntimeFlowControl { // set by private/public/protected/module_function internal /*and protected*/ RubyMethodAttributes _methodAttributes; - // Refinements activated by `using' in this scope, in activation order; null until `using' runs here. - private List _usedModules; - // Parallel to _usedModules: what each `using' saw of the module's refinements (null = all, live). - private List> _usedModuleSnapshots; + /// + /// The state only eval, Binding and refinements need, out of line: a scope is allocated for + /// every method call and every block call that needs one, and each field here would be paid + /// for by all of them. Null until one of these is first set. + /// + internal sealed class RareState { + // closure: name -> slot of the compiled locals, built for eval and Binding on demand + public Dictionary StaticLocalMapping; + // closure: locals an eval or Binding#local_variable_set introduced + public Dictionary DynamicLocals; + + // Refinements activated by `using' in this scope, in activation order; null until `using' runs here. + public List UsedModules; + // Parallel to UsedModules: what each `using' saw of the module's refinements (null = all, live). + public List> UsedModuleSnapshots; + + // Memoized effective activation table for this scope (own + everything lexically enclosing), + // stamped with RubyContext.RefinementVersion so that a `using' anywhere forces a recompute. + public RefinementActivation Refinements; + public int RefinementsVersion; + + // see RubyMethodScope.DefinitionRefinements + public StrongBox MethodDefinitionRefinements; + } + + private RareState _rare; - // Memoized effective activation table for this scope (own + everything lexically enclosing), - // stamped with RubyContext.RefinementVersion so that a `using' anywhere forces a recompute. - private RefinementActivation _refinements; - private int _refinementsVersion; + internal RareState/*!*/ Rare { + get { return _rare ?? Interlocked.CompareExchange(ref _rare, new RareState(), null) ?? _rare; } + } + + internal RareState RareOrNull { + get { return _rare; } + } + + private Dictionary StaticLocalMapping { + get { var r = _rare; return (r != null) ? r.StaticLocalMapping : null; } + set { Rare.StaticLocalMapping = value; } + } + + private Dictionary DynamicLocals { + get { var r = _rare; return (r != null) ? r.DynamicLocals : null; } + } + + private List UsedModules { + get { var r = _rare; return (r != null) ? r.UsedModules : null; } + } + + private List> UsedModuleSnapshots { + get { var r = _rare; return (r != null) ? r.UsedModuleSnapshots : null; } + } internal InterpretedFrame InterpretedFrame { get; set; } @@ -270,7 +310,7 @@ internal void SetEmptyLocals() { } private void EnsureBoxes() { - if (_staticLocalMapping == null) { + if (StaticLocalMapping == null) { if (_variableNames == null) { // Locals not installed yet (see GetDeclaredLocalVariables). Don't cache an // empty mapping - it would permanently hide the real locals once SetLocals runs. @@ -281,7 +321,7 @@ private void EnsureBoxes() { for (int i = 0; i < count; i++) { boxes[_variableNames[i]] = i; } - _staticLocalMapping = boxes; + StaticLocalMapping = boxes; } } @@ -297,7 +337,7 @@ public object GetImplicitParameterValue(string/*!*/ name) { EnsureBoxes(); int index; - if (_staticLocalMapping != null && _staticLocalMapping.TryGetValue(name, out index)) { + if (StaticLocalMapping != null && StaticLocalMapping.TryGetValue(name, out index)) { return _locals.GetValue(index); } return null; @@ -307,19 +347,19 @@ private bool TryGetLocal(string/*!*/ name, out object value) { EnsureBoxes(); int index; - if (_staticLocalMapping != null && _staticLocalMapping.TryGetValue(name, out index) && !IsImplicitParameter(name)) { + if (StaticLocalMapping != null && StaticLocalMapping.TryGetValue(name, out index) && !IsImplicitParameter(name)) { Debug.Assert(_locals != null); value = _locals.GetValue(index); return true; } - if (_dynamicLocals == null) { + if (DynamicLocals == null) { value = null; return false; } - lock (_dynamicLocals) { - return _dynamicLocals.TryGetValue(name, out value); + lock (DynamicLocals) { + return DynamicLocals.TryGetValue(name, out value); } } @@ -327,22 +367,22 @@ private bool TrySetLocal(string/*!*/ name, object value) { EnsureBoxes(); int index; - if (_staticLocalMapping != null && _staticLocalMapping.TryGetValue(name, out index) && !IsImplicitParameter(name)) { + if (StaticLocalMapping != null && StaticLocalMapping.TryGetValue(name, out index) && !IsImplicitParameter(name)) { Debug.Assert(_locals != null); _locals.SetValue(index, value); return true; } - if (_dynamicLocals == null) { + if (DynamicLocals == null) { return false; } - lock (_dynamicLocals) { - if (!_dynamicLocals.ContainsKey(name)) { + lock (DynamicLocals) { + if (!DynamicLocals.ContainsKey(name)) { return false; } - _dynamicLocals[name] = value; + DynamicLocals[name] = value; } return true; } @@ -357,9 +397,9 @@ private bool TrySetLocal(string/*!*/ name, object value) { } } - if (_dynamicLocals != null) { - lock (_dynamicLocals) { - foreach (var entry in _dynamicLocals) { + if (DynamicLocals != null) { + lock (DynamicLocals) { + foreach (var entry in DynamicLocals) { yield return entry; } } @@ -369,9 +409,9 @@ private bool TrySetLocal(string/*!*/ name, object value) { private IEnumerable/*!*/ GetDeclaredLocalSymbols() { // a variable an eval or Binding#local_variable_set introduced is newer than the ones // the scope was compiled with, and MRI lists it first - if (_dynamicLocals != null) { - lock (_dynamicLocals) { - foreach (string name in _dynamicLocals.Keys) { + if (DynamicLocals != null) { + lock (DynamicLocals) { + foreach (string name in DynamicLocals.Keys) { yield return name; } } @@ -478,12 +518,12 @@ internal object ResolveAndSetLocalVariable(string/*!*/ name, object value) { /// module-eval scopes need to override this since they should set the variable in their parent scope. /// internal virtual void DefineDynamicVariable(string/*!*/ name, object value) { - if (_dynamicLocals == null) { - Interlocked.CompareExchange(ref _dynamicLocals, new Dictionary(), null); + if (DynamicLocals == null) { + Interlocked.CompareExchange(ref Rare.DynamicLocals, new Dictionary(), null); } - lock (_dynamicLocals) { - _dynamicLocals[name] = value; + lock (DynamicLocals) { + DynamicLocals[name] = value; } } @@ -514,19 +554,20 @@ public void ActivateRefinements(RubyModule/*!*/ module, bool snapshot) { refinements = new HashSet(list); } - if (_usedModules == null) { - _usedModules = new List(); - _usedModuleSnapshots = new List>(); + if (UsedModules == null) { + var rare = Rare; + rare.UsedModules = new List(); + rare.UsedModuleSnapshots = new List>(); } else { // re-activating moves it to the front of this scope's precedence order - int index = _usedModules.IndexOf(module); + int index = UsedModules.IndexOf(module); if (index >= 0) { - _usedModules.RemoveAt(index); - _usedModuleSnapshots.RemoveAt(index); + UsedModules.RemoveAt(index); + UsedModuleSnapshots.RemoveAt(index); } } - _usedModules.Add(module); - _usedModuleSnapshots.Add(refinements); + UsedModules.Add(module); + UsedModuleSnapshots.Add(refinements); // Invalidate every memoized table and, through the rule guards built on them, every cached // call site that could be affected. @@ -546,8 +587,9 @@ public void ActivateRefinements(RubyModule/*!*/ module, bool snapshot) { } int version = context.RefinementVersion; - if (_refinements != null && _refinementsVersion == version) { - return _refinements; + var cached = _rare; + if (cached != null && cached.Refinements != null && cached.RefinementsVersion == version) { + return cached.Refinements; } RefinementActivation outer; @@ -572,10 +614,11 @@ public void ActivateRefinements(RubyModule/*!*/ module, bool snapshot) { } else { outer = (_parent != null) ? _parent.GetActiveRefinements() : RefinementActivation.Empty; } - RefinementActivation result = (_usedModules != null) ? RefinementActivation.Create(outer, _usedModules, _usedModuleSnapshots) : outer; + RefinementActivation result = (UsedModules != null) ? RefinementActivation.Create(outer, UsedModules, UsedModuleSnapshots) : outer; - _refinements = result; - _refinementsVersion = version; + var rare = Rare; + rare.Refinements = result; + rare.RefinementsVersion = version; return result; } @@ -1097,7 +1140,10 @@ public Proc BlockParameter { /// it creates this scope; null for a body compiled before anything was refined. Public because /// the method body assigns it directly. /// - public StrongBox DefinitionRefinements; + public StrongBox DefinitionRefinements { + get { var r = RareOrNull; return (r != null) ? r.MethodDefinitionRefinements : null; } + set { Rare.MethodDefinitionRefinements = value; } + } internal RubyMethodScope(MutableTuple locals, string/*!*/[]/*!*/ variableNames, int visibleParameterCountAndSignatureFlags, RubyScope/*!*/ parent, RubyModule/*!*/ declaringModule, string/*!*/ definitionName, From fdeb554c3e690671f7f675b6c28d08a77aaa6bec Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 23:54:09 +0200 Subject: [PATCH 59/94] Array#reverse_each and #rindex: a plain loop, and MRI's handling of a shrinking array The reverse walks went through an iterator (an allocation per call, two interface calls per element) that, when the block shrank the array, restarted from the new end compared with the *original* size: [1,2,3,4,5].reverse_each { a.pop if x == 4 } visited 4 twice, and a shift after the last element re-ran the walk. They now follow rb_ary_reverse_each and rb_ary_rindex: on from the new end only when the array is shorter than the index just visited, and rindex(obj) stops when an #== shrank it below the next index. Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Extensions/IListOps.cs | 32 +++++++++++++++++----------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/Src/Libraries/Extensions/IListOps.cs b/Src/Libraries/Extensions/IListOps.cs index 49671b5a..92f32559 100644 --- a/Src/Libraries/Extensions/IListOps.cs +++ b/Src/Libraries/Extensions/IListOps.cs @@ -233,15 +233,9 @@ internal static void AddRange(IList/*!*/ collection, IList/*!*/ items) { throw RubyExceptions.CreateTypeError("{0}#allocate should return IList", cls.Name); } - internal static IEnumerable/*!*/ ReverseEnumerateIndexes(IList/*!*/ collection) { - for (int originalSize = collection.Count, i = originalSize - 1; i >= 0; i--) { - yield return i; - if (collection.Count < originalSize) { - i = originalSize - (originalSize - collection.Count); - originalSize = collection.Count; - } - } - } + // The reverse walks below are MRI's (rb_ary_reverse_each, rb_ary_rindex): index Count - 1 + // down to 0, and when the block has shrunk the array below the index just visited, on from + // its new end - elements appended meanwhile are not visited, and none is visited twice. #endregion @@ -1228,11 +1222,15 @@ public static object EachIndex([NotNull]BlockParam/*!*/ block, IList/*!*/ self) [RubyMethod("reverse_each")] public static object ReverseEach([NotNull]BlockParam/*!*/ block, RubyArray/*!*/ self) { - foreach (int index in IListOps.ReverseEnumerateIndexes(self)) { + for (int i = self.Count; i-- > 0; ) { object result; - if (block.Yield(self[index], out result)) { + if (block.Yield(self[i], out result)) { return result; } + int count = self.Count; + if (count < i) { + i = count; + } } return self; } @@ -1702,7 +1700,7 @@ public static object FindIndex(BinaryOpStorage/*!*/ equals, BlockParam predicate [RubyMethod("rindex")] public static object ReverseIndex([NotNull]BlockParam/*!*/ predicate, IList/*!*/ self) { - foreach (int i in IListOps.ReverseEnumerateIndexes(self)) { + for (int i = self.Count; i-- > 0; ) { object blockResult; if (predicate.Yield(self[i], out blockResult)) { return blockResult; @@ -1711,6 +1709,10 @@ public static object ReverseIndex([NotNull]BlockParam/*!*/ predicate, IList/*!*/ if (Protocols.IsTrue(blockResult)) { return ScriptingRuntimeHelpers.Int32ToObject(i); } + int count = self.Count; + if (count < i) { + i = count; + } } return null; } @@ -1721,10 +1723,14 @@ public static object ReverseIndex(BinaryOpStorage/*!*/ equals, BlockParam predic equals.Context.ReportWarning("given block not used"); } - foreach (int i in IListOps.ReverseEnumerateIndexes(self)) { + for (int i = self.Count; i-- > 0; ) { if (Protocols.IsEqual(equals, self[i], item)) { return ScriptingRuntimeHelpers.Int32ToObject(i); } + // an == that shrank the array below the next index ends the search, as in MRI + if (i > self.Count) { + break; + } } return null; } From 843df62e85b851362b53813feb1a35a845210eec Mon Sep 17 00:00:00 2001 From: Largo Date: Mon, 12 Oct 2026 23:54:09 +0200 Subject: [PATCH 60/94] String interpolation: literal parts made once; MRI's result encoding - The literal text between interpolations is one frozen MutableString made at compile time instead of a new string on every evaluation; every helper the parts go to copies out of them. CreateMutableStringN sizes its result for all the parts up front. "String for key #{tag} in leaf node": 632 -> 384 bytes allocated per evaluation. - The result encoding follows rb_str_concat_literals: it starts from the first part's encoding, and a result still US-ASCII takes the encoding of the first part that is not. "a#{1}b" in a UTF-8 file was US-ASCII, "#{nil}a" US-ASCII and "#{nil}#{1}#{bin}" binary for the wrong reason. - nil.to_s is US-ASCII, as in MRI (it was binary). Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/NilClassOps.cs | 3 +- .../Ast/Expressions/StringConstructor.cs | 14 +++++++-- Src/Ruby/Runtime/RubyOps.cs | 31 +++++++++++++++++-- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Src/Libraries/Builtins/NilClassOps.cs b/Src/Libraries/Builtins/NilClassOps.cs index 1d955bf2..d2fc065b 100644 --- a/Src/Libraries/Builtins/NilClassOps.cs +++ b/Src/Libraries/Builtins/NilClassOps.cs @@ -99,7 +99,8 @@ public static MutableString Inspect(object self) { /// One frozen string, answered again every time: MRI's nil.to_s is a single object, and /// a spec checks that two calls hand back the very same one. /// - private static readonly MutableString/*!*/ _EmptyString = MutableString.CreateEmpty().Freeze(); + // US-ASCII, as MRI's nil.to_s is + private static readonly MutableString/*!*/ _EmptyString = MutableString.Create(String.Empty, RubyEncoding.Ascii).Freeze(); [RubyMethodAttribute("to_s")] public static MutableString/*!*/ ToString(object self) { diff --git a/Src/Ruby/Compiler/Ast/Expressions/StringConstructor.cs b/Src/Ruby/Compiler/Ast/Expressions/StringConstructor.cs index 0e1a8ec7..4bb6646c 100644 --- a/Src/Ruby/Compiler/Ast/Expressions/StringConstructor.cs +++ b/Src/Ruby/Compiler/Ast/Expressions/StringConstructor.cs @@ -292,7 +292,7 @@ public bool Add(StringLiteral/*!*/ literal) { // finish trailing literals: if (concat.Count > 0) { - merged.Add(StringLiteral.Transform(concat.GetValue(), concat.Encoding)); + merged.Add(TransformLiteralPart(concat.GetValue(), concat.Encoding)); } if (merged.Count <= RubyOps.MakeStringParamCount) { @@ -307,6 +307,16 @@ public bool Add(StringLiteral/*!*/ literal) { } } + // The literal text between interpolations. Every CreateMutableString*, CreateSymbol* and + // CreateRegex* helper the parts go to copies out of them, so one instance made here serves + // every evaluation instead of a new string each time; frozen, so that a helper which ever + // kept a part rather than copying it would fail loudly instead of sharing it. + private static MSA.Expression/*!*/ TransformLiteralPart(object/*!*/ value, RubyEncoding/*!*/ encoding) { + var str = value as string; + var part = (str != null) ? MutableString.Create(str, encoding) : MutableString.CreateBinary((byte[])value, encoding); + return AstUtils.Constant(part.Freeze(), typeof(MutableString)); + } + // // Traverses expressions in "parts" and concats all contiguous literal strings/bytes. // Notes: @@ -336,7 +346,7 @@ private static bool ConcatLiteralsAndTransformRecursive(AstGenerator/*!*/ gen, L } } else { if (concat.Count > 0) { - result.Add(StringLiteral.Transform(concat.GetValue(), concat.Encoding)); + result.Add(TransformLiteralPart(concat.GetValue(), concat.Encoding)); concat.Clear(); } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 6c51e978..fa0797fc 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -2675,17 +2675,42 @@ public int GetHashCode(MutableString str) { [Emitted] public static MutableString/*!*/ CreateMutableStringMM(MutableString str1, MutableString str2, RubyEncoding/*!*/ encoding) { - return MutableString.CreateInternal(str1, encoding).Append(str2); + var result = MutableString.CreateInternal(str1, encoding); + AppendInterpolatedPart(result, str2); + return result; + } + + /// + /// MRI's rb_str_concat_literals: append, and a result that is still US-ASCII takes the encoding + /// of the first part that is not - "#{1}a" in a UTF-8 file is UTF-8, "#{y}#{bin}" is binary. + /// + private static void AppendInterpolatedPart(MutableString/*!*/ result, MutableString part) { + if (part != null) { + result.Append(part); + if (part.Encoding != RubyEncoding.Ascii && result.Encoding == RubyEncoding.Ascii) { + result.ForceEncoding(part.Encoding); + } + } } // TODO: we should emit Append calls directly, and not create an array first [Emitted] public static MutableString/*!*/ CreateMutableStringN(MutableString/*!*/[]/*!*/ parts) { Debug.Assert(parts.Length > 0); - var result = MutableString.CreateMutable(RubyEncoding.Ascii); + // room for all of it up front rather than a regrowth per part (a part counts its + // characters or its bytes, whichever it holds, which is what a capacity hint needs) + int capacity = 0; + for (int i = 0; i < parts.Length; i++) { + if (parts[i] != null) { + capacity += parts[i].Length; + } + } + // MRI's rb_str_concat_literals starts from the first part's encoding: "a#{1}b" in a UTF-8 + // file is UTF-8, though every part is ASCII and the Integer's is US-ASCII + var result = MutableString.CreateMutable(capacity, (parts[0] != null) ? parts[0].Encoding : RubyEncoding.Ascii); for (int i = 0; i < parts.Length; i++) { - result.Append(parts[i]); + AppendInterpolatedPart(result, parts[i]); } return result; From 64f3cf25c9b9c50bbb18481f9c9ed9c9bf760946 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:35:06 +0200 Subject: [PATCH 61/94] WIP: method JIT specializes methods with keyword parameters Co-Authored-By: Claude Opus 5.5 --- .../Ast/Expressions/KeywordParameterRead.cs | 9 + Src/Ruby/Runtime/Jit/JitCompiler.cs | 87 +++++++-- Src/Ruby/Runtime/Jit/JitKeywords.cs | 184 ++++++++++++++++++ Src/Ruby/Runtime/Jit/JitRuntime.cs | 12 ++ Src/Ruby/Runtime/Jit/JitStub.cs | 133 +++++++++---- Util/jit-matrix.rb | 137 +++++++++++++ 6 files changed, 506 insertions(+), 56 deletions(-) create mode 100644 Src/Ruby/Runtime/Jit/JitKeywords.cs diff --git a/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs b/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs index 6e1b6ac6..cdeaf493 100644 --- a/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs +++ b/Src/Ruby/Compiler/Ast/Expressions/KeywordParameterRead.cs @@ -59,6 +59,15 @@ public Expression DefaultValue { get { return _defaultValue; } } + // What the method JIT (Runtime/Jit/JitKeywords.cs) needs to recognize a keyword prologue. + internal bool IsMatch { get { return _kind == ReadKind.Match; } } + internal bool IsValue { get { return _kind == ReadKind.Value; } } + internal string/*!*/[]/*!*/ Names { get { return _names; } } + internal RubyEncoding/*!*/ Encoding { get { return _encoding; } } + // Match: how many of the names are required; Value: where the keyword is declared + internal int RequiredCountOrPosition { get { return _requiredCount; } } + internal bool AcceptsRest { get { return _acceptsRest; } } + private KeywordParameterRead(ReadKind kind, Expression/*!*/ keywords, string/*!*/[]/*!*/ names, RubyEncoding/*!*/ encoding, int requiredCount, bool acceptsRest, Expression defaultValue, SourceSpan location) : base(location) { diff --git a/Src/Ruby/Runtime/Jit/JitCompiler.cs b/Src/Ruby/Runtime/Jit/JitCompiler.cs index de7a2af2..40123508 100644 --- a/Src/Ruby/Runtime/Jit/JitCompiler.cs +++ b/Src/Ruby/Runtime/Jit/JitCompiler.cs @@ -108,23 +108,36 @@ internal sealed class JitCompiler { return JitBailout.Instance; } - private JitCompiler(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, JT[]/*!*/ paramTypes) { + // A method with keyword parameters: its keyword locals are typed parameters too, after the + // positional ones, and the body starts after the prologue that binds them (JitKeywords). + private readonly JitKeywords _keywords; + + private JitCompiler(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, JT[]/*!*/ paramTypes, JitKeywords keywords) { _ast = ast; _context = context; _paramTypes = paramTypes; + _keywords = keywords; _scopeDepth = ast.DefinedScope.Depth; _self = Ast.Parameter(typeof(object), "#self"); PushScope(); var mandatory = ast.Parameters.Mandatory; - _paramNames = new string[mandatory.Length]; - for (int i = 0; i < mandatory.Length; i++) { - var lv = mandatory[i] as LocalVariable; - if (lv == null) { throw JitBailout.Instance; } - _paramNames[i] = lv.Name; - var p = Ast.Parameter(ClrType(paramTypes[i]), lv.Name); - _vars[0].Add(lv.Name, p); - _types[0].Add(lv.Name, paramTypes[i]); + _paramNames = new string[paramTypes.Length]; + if (paramTypes.Length != mandatory.Length + (keywords != null ? keywords.Count : 0)) { throw JitBailout.Instance; } + for (int i = 0; i < paramTypes.Length; i++) { + string name; + if (i < mandatory.Length) { + var lv = mandatory[i] as LocalVariable; + if (lv == null) { throw JitBailout.Instance; } + name = lv.Name; + } else { + name = keywords.LocalNames[i - mandatory.Length]; + } + if (_vars[0].ContainsKey(name)) { throw JitBailout.Instance; } + _paramNames[i] = name; + var p = Ast.Parameter(ClrType(paramTypes[i]), name); + _vars[0].Add(name, p); + _types[0].Add(name, paramTypes[i]); } } @@ -315,13 +328,17 @@ private bool TryLookup(string/*!*/ name, out MSA.ParameterExpression p, out JT t /// cannot be specialized. /// internal static JitCode TryCompile(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, JT[]/*!*/ paramTypes) { + return TryCompile(ast, context, paramTypes, null); + } + + internal static JitCode TryCompile(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, JT[]/*!*/ paramTypes, JitKeywords keywords) { // The self-recursive call needs the method's return type before the body has been // typed. Guess, compile, and re-run once if the guess was wrong. // Rounds: each retry widens the guess (Int -> Lng -> Dbl/Obj), so the lattice bounds // how many there can be; a guess that does not settle is rejected. JT guess = JT.None; for (int round = 0; round < 5; round++) { - var c = new JitCompiler(ast, context, paramTypes); + var c = new JitCompiler(ast, context, paramTypes, keywords); c._firstRound = (guess == JT.None); c._selfReturn = c._firstRound ? JT.Int : guess; c._selfDelegateType = DelegateType(paramTypes, c._selfReturn); @@ -398,7 +415,8 @@ private static Type DelegateType(JT[]/*!*/ ps, JT ret) { case 1: return typeof(Func<,,>).MakeGenericType(types); case 2: return typeof(Func<,,,>).MakeGenericType(types); case 3: return typeof(Func<,,,,>).MakeGenericType(types); - default: return null; + // positionals plus keywords (JitStub.MaxArity - 1 + JitKeywords.MaxKeywords) + default: return (ps.Length <= 14) ? Ast.GetFuncType(types) : null; } } @@ -409,9 +427,27 @@ private static Type DelegateType(JT[]/*!*/ ps, JT ret) { if (body.RescueClauses != null || body.ElseStatements != null || body.EnsureStatements != null) { throw JitBailout.Instance; } + if (_keywords != null) { + // the keyword prologue is what the entry did; the parameters hold its result + return EmitStatements(body.Statements, _keywords.PrologueLength, out type); + } return EmitStatements(body.Statements, out type); } + private MSA.Expression/*!*/ EmitStatements(Statements/*!*/ statements, int start, out JT type) { + int count = statements.Count - start; + if (count <= 0) { + type = JT.Obj; + return Ast.Constant(null, typeof(object)); + } + var parts = new List(count); + type = JT.None; + for (int i = start; i < statements.Count; i++) { + parts.Add(Emit(statements[i], out type)); + } + return (parts.Count == 1) ? parts[0] : Ast.Block(ClrType(type), parts); + } + private MSA.Expression/*!*/ EmitStatements(Statements statements, out JT type) { if (statements == null || statements.Count == 0) { type = JT.Obj; @@ -851,7 +887,7 @@ private static JT Unify(JT a, JT b) { // Self-recursion: `fib(n - 1)' inside `def fib'. Sound because the entry stub pins // the receiver class and the global method version, so no override can slip in. - if (_ast != null && node.Target == null && !node.IsVariableCall && node.MethodName == _ast.Name && argc == _paramNames.Length) { + if (_ast != null && _keywords == null && node.Target == null && !node.IsVariableCall && node.MethodName == _ast.Name && argc == _paramNames.Length) { var callArgs = new MSA.Expression[argc + 1]; callArgs[0] = _self; for (int i = 0; i < argc; i++) { @@ -1106,10 +1142,14 @@ internal sealed class JitScreen { /// The node kind that said no, for the histogram. private string _rejectedBy; - private JitScreen(MethodDeclaration/*!*/ ast) { + private readonly JitKeywords _keywords; + + private JitScreen(MethodDeclaration/*!*/ ast, JitKeywords keywords) { _scopeDepth = ast.DefinedScope.Depth; _methodName = ast.Name; - _arity = ast.Parameters.Mandatory.Length; + // a method with keywords is never called directly from its typed body (EmitCall) + _arity = (keywords != null) ? -1 : ast.Parameters.Mandatory.Length; + _keywords = keywords; } /// @@ -1117,10 +1157,14 @@ private JitScreen(MethodDeclaration/*!*/ ast) { /// this body. names the node kind that decided it. /// internal static bool CanEverCompile(MethodDeclaration/*!*/ ast, out string rejectedBy) { - var s = new JitScreen(ast); + return CanEverCompile(ast, null, out rejectedBy); + } + + internal static bool CanEverCompile(MethodDeclaration/*!*/ ast, JitKeywords keywords, out string rejectedBy) { + var s = new JitScreen(ast, keywords); bool ok; try { - ok = s.WalkBody(ast.Body); + ok = (keywords != null) ? s.WalkMethodBody(ast.Body, keywords.PrologueLength) : s.WalkBody(ast.Body); } catch (Exception) { // A screen that throws must not take the process down with it; it just means the // method is not wrapped. @@ -1144,6 +1188,17 @@ private bool WalkBody(Body/*!*/ body) { return WalkStatements(body.Statements); } + /// Mirrors JitCompiler.EmitBody of a method with keywords: the prologue is not emitted. + private bool WalkMethodBody(Body/*!*/ body, int prologueLength) { + if (body.RescueClauses != null || body.ElseStatements != null || body.EnsureStatements != null) { + return No(body); + } + for (int i = prologueLength; i < body.Statements.Count; i++) { + if (!Walk(body.Statements[i])) { return false; } + } + return true; + } + private bool WalkStatements(Statements statements) { if (statements == null) { return true; } foreach (var s in statements) { diff --git a/Src/Ruby/Runtime/Jit/JitKeywords.cs b/Src/Ruby/Runtime/Jit/JitKeywords.cs new file mode 100644 index 00000000..df4be509 --- /dev/null +++ b/Src/Ruby/Runtime/Jit/JitKeywords.cs @@ -0,0 +1,184 @@ +/* **************************************************************************** + * + * -X:JIT - an experimental ZJIT-style method JIT for IronRuby. + * + * Keyword parameters. + * + * PrismAstBridge.LowerKeywords turns `def m(a, k:, j: 1)' into `def m(a, ?kw? = {})' plus a + * prologue that binds the keyword locals out of that hash: + * + * ?kw? ||= {} + * raise ArgumentError, ... if KeywordArgumentsTest(a) # separation checks + * raise ArgumentError, ... unless KeywordArgumentsTest(?kw?) + * unless KeywordArgumentsMatch(?kw?, [:k, :j], 1, false) # missing / unknown + * ...word the ArgumentError the slow way... + * end + * k = GetKeywordArgument(?kw?, :k, 0) + * j = (v = GetKeywordArgument(?kw?, :j, 1)) == DefaultArgument ? 1 : v + * + * + * This class recognizes exactly that shape. A recognized method is specialized with the + * keyword locals as typed parameters of its own, after the positional ones, and the entry + * stub reads them out of the hash - the very helpers the prologue uses - after checking that + * the prologue would have bound them without raising. Any call the prologue would complain + * about (a missing or unknown keyword, a Hash passed positionally, keywords landing in the + * last positional) fails that check and runs the generic body, which raises the error MRI + * raises. A default that is not a plain literal (it could have side effects, or depend on + * another parameter), and **rest, are not recognized: such a method is never wrapped. + * + * ***************************************************************************/ + +using System; +using System.Collections.Generic; +using IronRuby.Builtins; +using IronRuby.Compiler.Ast; + +namespace IronRuby.Runtime.Jit { + using RExpr = IronRuby.Compiler.Ast.Expression; + using MethodDeclaration = IronRuby.Compiler.Ast.MethodDefinition; + + internal sealed class JitKeywords { + internal const int MaxKeywords = 6; + + // How many leading statements of the body are the prologue; the typed body starts after them. + internal int PrologueLength; + + // KeywordArgumentsMatch's arguments: required names first, then the optional ones. + internal RubySymbol/*!*/[]/*!*/ MatchNames; + internal int RequiredCount; + + // Per keyword, in declaration order: its local, its symbol, its position, its default. + internal string/*!*/[]/*!*/ LocalNames; + internal RubySymbol/*!*/[]/*!*/ Symbols; + internal int[]/*!*/ Positions; + internal object[]/*!*/ Defaults; + + internal int Count { get { return LocalNames.Length; } } + + /// + /// The keyword signature of a method whose parameters are mandatory positionals plus + /// keywords lowered by LowerKeywords, or null if the method is anything else. + /// + internal static JitKeywords TryRecognize(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context) { + var ps = ast.Parameters; + if (ps.Optional.Length != 1 || ps.Unsplat != null || ps.Block != null) { return null; } + var kwVar = ps.Optional[0].Left as LocalVariable; + if (kwVar == null || kwVar.Name != "?kw?") { return null; } + + var body = ast.Body; + if (body == null || body.Statements == null) { return null; } + var statements = body.Statements; + int n = statements.Count; + int i = 0; + + // ?kw? ||= {} + if (i >= n) { return null; } + var reset = statements[i] as SimpleAssignmentExpression; + if (reset == null || reset.Operation != "||" || !IsVar(reset.Left, kwVar) || !(reset.Right is HashConstructor)) { return null; } + i++; + + // The separation checks: an if/unless on KeywordArgumentsTest whose body only raises. + while (i < n) { + var ifExpr = statements[i] as IfExpression; + if (ifExpr != null && ifExpr.Condition is KeywordArgumentsTest && + (ifExpr.ElseIfClauses == null || ifExpr.ElseIfClauses.Count == 0)) { + i++; + continue; + } + var unlessExpr = statements[i] as UnlessExpression; + if (unlessExpr != null && unlessExpr.ElseClause == null) { + var test = unlessExpr.Condition as KeywordArgumentsTest; + if (test != null && IsVar(test.Value, kwVar)) { + i++; + continue; + } + } + break; + } + + // unless KeywordArgumentsMatch(?kw?, names, required, false) ... end + if (i >= n) { return null; } + var matchStmt = statements[i] as UnlessExpression; + if (matchStmt == null || matchStmt.ElseClause != null) { return null; } + var match = matchStmt.Condition as KeywordParameterRead; + if (match == null || !match.IsMatch || match.AcceptsRest || !IsVar(match.Keywords, kwVar)) { return null; } + i++; + + var names = match.Names; + if (names.Length == 0 || names.Length > MaxKeywords) { return null; } + + var result = new JitKeywords(); + result.RequiredCount = match.RequiredCountOrPosition; + result.MatchNames = new RubySymbol[names.Length]; + for (int j = 0; j < names.Length; j++) { + result.MatchNames[j] = context.CreateSymbol(names[j], match.Encoding); + } + + // k = , one per declared keyword, in declaration order + var locals = new List(); + var symbols = new List(); + var positions = new List(); + var defaults = new List(); + while (i < n) { + var bind = statements[i] as SimpleAssignmentExpression; + if (bind == null || bind.Operation != null) { break; } + var read = bind.Right as KeywordParameterRead; + if (read == null || !read.IsValue || !IsVar(read.Keywords, kwVar)) { break; } + var local = bind.Left as LocalVariable; + if (local == null || local.DefinitionLexicalDepth != ast.DefinedScope.Depth) { return null; } + if (read.RequiredCountOrPosition != locals.Count) { return null; } + + object defaultValue = null; + if (read.DefaultValue != null) { + // A literal is the same value every time and evaluating it does nothing. + var literal = read.DefaultValue as Literal; + if (literal == null) { return null; } + object v = literal.Value; + if (!(v == null || v is int || v is double || v is bool)) { return null; } + defaultValue = v; + } + + locals.Add(local.Name); + symbols.Add(context.CreateSymbol(read.Names[0], read.Encoding)); + positions.Add(read.RequiredCountOrPosition); + defaults.Add(defaultValue); + i++; + } + if (locals.Count != names.Length) { return null; } + + // every name Match checks is one a binding reads, and vice versa + for (int j = 0; j < names.Length; j++) { + if (!symbols.Contains(result.MatchNames[j])) { return null; } + } + + result.PrologueLength = i; + result.LocalNames = locals.ToArray(); + result.Symbols = symbols.ToArray(); + result.Positions = positions.ToArray(); + result.Defaults = defaults.ToArray(); + return result; + } + + private static bool IsVar(RExpr node, LocalVariable/*!*/ kwVar) { + var lv = node as LocalVariable; + return lv != null && lv.Name == kwVar.Name && lv.DefinitionLexicalDepth == kwVar.DefinitionLexicalDepth; + } + + /// + /// True when the prologue would bind every keyword from without + /// raising: it is the hash a keyword call made (or the call passed no keywords at all), + /// it has every required keyword and no other. + /// + internal bool Matches(object kw) { + if (kw == RubyOps.DefaultArgument) { return RequiredCount == 0; } + var hash = kw as Hash; + return hash != null && hash.IsKeywordArguments && hash.HasKeywords(MatchNames, RequiredCount, false); + } + + /// What the prologue binds keyword to, given . + internal object Value(object kw, int index) { + object v = RubyOps.GetKeywordArgument(kw, Symbols[index], Positions[index]); + return (v == RubyOps.DefaultArgument) ? Defaults[index] : v; + } + } +} diff --git a/Src/Ruby/Runtime/Jit/JitRuntime.cs b/Src/Ruby/Runtime/Jit/JitRuntime.cs index 12ea91c4..e20a7ccd 100644 --- a/Src/Ruby/Runtime/Jit/JitRuntime.cs +++ b/Src/Ruby/Runtime/Jit/JitRuntime.cs @@ -311,6 +311,18 @@ public static object OsrTypeGuardFailed(string/*!*/ what) { return Deopt(); } + // ---- keyword parameters (see JitKeywords) --------------------------------------- + + /// The entry guard of a method with keywords: the prologue would bind them without raising. + public static bool KeywordsMatch(object/*!*/ keywords, object kw) { + return ((JitKeywords)keywords).Matches(kw); + } + + /// The value the prologue binds keyword number to. + public static object KeywordValue(object/*!*/ keywords, object kw, int index) { + return ((JitKeywords)keywords).Value(kw, index); + } + public static RubyClass/*!*/ ClassOf(RubyContext/*!*/ context, object self) { IRubyObject obj = self as IRubyObject; return obj != null ? obj.ImmediateClass : context.GetImmediateClassOf(self); diff --git a/Src/Ruby/Runtime/Jit/JitStub.cs b/Src/Ruby/Runtime/Jit/JitStub.cs index ad424fdf..65aead9d 100644 --- a/Src/Ruby/Runtime/Jit/JitStub.cs +++ b/Src/Ruby/Runtime/Jit/JitStub.cs @@ -50,6 +50,11 @@ internal abstract class JitStub { protected readonly RubyModule/*!*/ _declaringModule; protected readonly Delegate/*!*/ _generic; protected readonly int _arity; + // A method with keyword parameters (see JitKeywords): its last delegate parameter is the + // keyword hash, and the typed body takes the keyword values instead, after the positionals. + protected readonly JitKeywords _keywords; + private readonly int _positional; // delegate parameters that are positional parameters + private readonly int _typedCount; // parameters of the typed body // profiling feedback private int _calls; @@ -58,13 +63,17 @@ internal abstract class JitStub { private bool _polymorphicReceiver; private volatile bool _givenUp; - protected JitStub(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, RubyModule/*!*/ declaringModule, Delegate/*!*/ generic, int arity) { + protected JitStub(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, RubyModule/*!*/ declaringModule, Delegate/*!*/ generic, int arity, + JitKeywords keywords) { _ast = ast; _context = context; _declaringModule = declaringModule; _generic = generic; _arity = arity; - _argKind = new int[arity]; + _keywords = keywords; + _positional = (keywords != null) ? arity - 1 : arity; + _typedCount = (keywords != null) ? _positional + keywords.Count : arity; + _argKind = new int[_typedCount]; } protected abstract void Install(Delegate/*!*/ entry); @@ -74,17 +83,18 @@ protected JitStub(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, RubyModu protected void Profile(object self, object[] args) { if (_givenUp) { return; } - for (int i = 0; i < _arity; i++) { - int k = (args[i] is int) ? 1 : (args[i] is double) ? 2 : (args[i] is long) ? 4 : 3; - if (_argKind[i] == 0) { - _argKind[i] = k; - } else if (_argKind[i] != k) { - // int and long are one Ruby class seen in two representations, so a - // parameter that has been both is still an Integer: carry it as a long. - bool bothIntegral = (k == 1 || k == 4) && (_argKind[i] == 1 || _argKind[i] == 4); - _argKind[i] = bothIntegral ? 4 : 3; + if (_keywords != null) { + // what the prologue binds the keywords to, if it binds them at all + object kw = args[_positional]; + if (_keywords.Matches(kw)) { + for (int j = 0; j < _keywords.Count; j++) { + Observe(_positional + j, _keywords.Value(kw, j)); + } } } + for (int i = 0; i < _positional; i++) { + Observe(i, args[i]); + } RubyClass cls = JitRuntime.ClassOf(_context, self); if (_receiverClass == null) { _receiverClass = cls; } else if (_receiverClass != cls) { _polymorphicReceiver = true; } @@ -101,6 +111,18 @@ protected void Profile(object self, object[] args) { } } + private void Observe(int i, object arg) { + int k = (arg is int) ? 1 : (arg is double) ? 2 : (arg is long) ? 4 : 3; + if (_argKind[i] == 0) { + _argKind[i] = k; + } else if (_argKind[i] != k) { + // int and long are one Ruby class seen in two representations, so a + // parameter that has been both is still an Integer: carry it as a long. + bool bothIntegral = (k == 1 || k == 4) && (_argKind[i] == 1 || _argKind[i] == 4); + _argKind[i] = bothIntegral ? 4 : 3; + } + } + /// /// Stop profiling this method for good, and put the generic body back in the fast slot. /// Leaving the slot empty would be much worse than doing nothing: the trampoline would @@ -154,8 +176,8 @@ internal void VersionChanged() { private bool TrySpecialize() { if (JitRuntime.Disabled || _context.HasRefinements) { return false; } - var types = new JT[_arity]; - for (int i = 0; i < _arity; i++) { + var types = new JT[_typedCount]; + for (int i = 0; i < _typedCount; i++) { switch (_argKind[i]) { case 1: types[i] = JT.Int; break; case 2: types[i] = JT.Dbl; break; @@ -165,7 +187,7 @@ private bool TrySpecialize() { } long start = Stopwatch.GetTimestamp(); - var code = JitCompiler.TryCompile(_ast, _context, types); + var code = JitCompiler.TryCompile(_ast, _context, types, _keywords); if (code == null) { return false; } bool needsClassGuard = code.EmittedSelfCall; @@ -228,24 +250,49 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { Ast.Call(JitRuntime.M("ClassOf"), Ast.Constant(_context), self), Ast.Constant(pinnedClass, typeof(RubyClass)))); } - for (int i = 0; i < _arity; i++) { + + // The values the typed parameters are made of: the positional arguments, then - for a + // method with keywords - what the prologue would bind each keyword to. + var values = new MSA.Expression[_typedCount]; + for (int i = 0; i < _positional; i++) { + values[i] = args[i]; + } + MSA.ParameterExpression[] keywordValues = null; + if (_keywords != null) { + MSA.Expression kw = args[_positional]; + // the separation check on the last positional: `m("a" => 1)' gave it the keywords + if (_positional > 0 && code.ParameterTypes[_positional - 1] == JT.Obj) { + test = And(test, Ast.Not(Ast.Call(typeof(RubyOps).GetMethod("IsKeywordArgumentsHash"), args[_positional - 1]))); + } + test = And(test, Ast.Call(JitRuntime.M("KeywordsMatch"), Ast.Constant(_keywords, typeof(object)), kw)); + keywordValues = new MSA.ParameterExpression[_keywords.Count]; + for (int j = 0; j < keywordValues.Length; j++) { + var v = keywordValues[j] = Ast.Variable(typeof(object), "kw" + j); + values[_positional + j] = v; + test = And(test, Ast.Block( + Ast.Assign(v, Ast.Call(JitRuntime.M("KeywordValue"), Ast.Constant(_keywords, typeof(object)), kw, Ast.Constant(j))), + Ast.Constant(true))); + } + } + + for (int i = 0; i < _typedCount; i++) { switch (code.ParameterTypes[i]) { - case JT.Int: test = And(test, Ast.TypeIs(args[i], typeof(int))); break; - case JT.Dbl: test = And(test, Ast.TypeIs(args[i], typeof(double))); break; + case JT.Int: test = And(test, Ast.TypeIs(values[i], typeof(int))); break; + case JT.Dbl: test = And(test, Ast.TypeIs(values[i], typeof(double))); break; // An Integer parameter carried as a long accepts either representation: a // value in Int32 range arrives as an Int32 and only a larger one as an Int64. - case JT.Lng: test = And(test, Ast.Call(JitRuntime.M("IsIntegral"), args[i])); break; + case JT.Lng: test = And(test, Ast.Call(JitRuntime.M("IsIntegral"), values[i])); break; } } - var typedArgs = new MSA.Expression[_arity + 1]; + var typedArgs = new MSA.Expression[_typedCount + 1]; typedArgs[0] = self; - for (int i = 0; i < _arity; i++) { + for (int i = 0; i < _typedCount; i++) { switch (code.ParameterTypes[i]) { - case JT.Int: typedArgs[i + 1] = Ast.Unbox(args[i], typeof(int)); break; - case JT.Dbl: typedArgs[i + 1] = Ast.Unbox(args[i], typeof(double)); break; - case JT.Lng: typedArgs[i + 1] = Ast.Call(JitRuntime.M("ToLong"), args[i]); break; - default: typedArgs[i + 1] = args[i]; break; + case JT.Int: typedArgs[i + 1] = Ast.Unbox(values[i], typeof(int)); break; + case JT.Dbl: typedArgs[i + 1] = Ast.Unbox(values[i], typeof(double)); break; + case JT.Lng: typedArgs[i + 1] = Ast.Call(JitRuntime.M("ToLong"), values[i]); break; + default: typedArgs[i + 1] = values[i]; break; } } @@ -264,6 +311,9 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { } MSA.Expression body = (test != null) ? Ast.Condition(test, fast, GenericCall(self, blk, args), typeof(object)) : fast; + if (keywordValues != null) { + body = Ast.Block(typeof(object), keywordValues, body); + } // A stale version fails this guard on every later call; the stub takes the entry down // (JitStub.VersionChanged) and this one call runs the generic body. @@ -308,10 +358,13 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { JitRuntime.HookStats(); var ps = ast.Parameters; - if (ps.Optional.Length != 0 || ps.Unsplat != null || ps.Block != null || ast.UsesBlock) { return generic; } - int arity = ps.Mandatory.Length; + if (ps.Unsplat != null || ps.Block != null || ast.UsesBlock) { return generic; } + // Keyword parameters arrive in one trailing optional parameter; nothing else optional is taken. + JitKeywords keywords = null; + if (ps.Optional.Length != 0 && (keywords = JitKeywords.TryRecognize(ast, context)) == null) { return generic; } + int arity = ps.Mandatory.Length + ps.Optional.Length; if (arity > MaxArity) { return generic; } - for (int i = 0; i < arity; i++) { + for (int i = 0; i < ps.Mandatory.Length; i++) { if (!(ps.Mandatory[i] is LocalVariable)) { return generic; } } // Tracing and coverage need the real frame. @@ -322,17 +375,17 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { // into call site rules, so a method declined later still pays one extra indirect // call on every call for the life of the process. string rejectedBy; - if (!JitScreen.CanEverCompile(ast, out rejectedBy)) { + if (!JitScreen.CanEverCompile(ast, keywords, out rejectedBy)) { JitRuntime.RecordScreenRejection(rejectedBy); return generic; } JitStub stub; switch (arity) { - case 0: stub = new JitStub0(ast, context, declaringModule, generic); break; - case 1: stub = new JitStub1(ast, context, declaringModule, generic); break; - case 2: stub = new JitStub2(ast, context, declaringModule, generic); break; - default: stub = new JitStub3(ast, context, declaringModule, generic); break; + case 0: stub = new JitStub0(ast, context, declaringModule, generic, keywords); break; + case 1: stub = new JitStub1(ast, context, declaringModule, generic, keywords); break; + case 2: stub = new JitStub2(ast, context, declaringModule, generic, keywords); break; + default: stub = new JitStub3(ast, context, declaringModule, generic, keywords); break; } return stub.Trampoline; } @@ -348,8 +401,8 @@ internal sealed class JitStub0 : JitStub { private readonly Func/*!*/ _gen; private readonly Func/*!*/ _tramp; - internal JitStub0(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic) - : base(ast, context, m, generic, 0) { + internal JitStub0(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic, JitKeywords keywords) + : base(ast, context, m, generic, 0, keywords) { _gen = (Func)generic; _tramp = Call; } @@ -371,8 +424,8 @@ internal sealed class JitStub1 : JitStub { private readonly Func/*!*/ _gen; private readonly Func/*!*/ _tramp; - internal JitStub1(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic) - : base(ast, context, m, generic, 1) { + internal JitStub1(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic, JitKeywords keywords) + : base(ast, context, m, generic, 1, keywords) { _gen = (Func)generic; _tramp = Call; } @@ -394,8 +447,8 @@ internal sealed class JitStub2 : JitStub { private readonly Func/*!*/ _gen; private readonly Func/*!*/ _tramp; - internal JitStub2(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic) - : base(ast, context, m, generic, 2) { + internal JitStub2(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic, JitKeywords keywords) + : base(ast, context, m, generic, 2, keywords) { _gen = (Func)generic; _tramp = Call; } @@ -417,8 +470,8 @@ internal sealed class JitStub3 : JitStub { private readonly Func/*!*/ _gen; private readonly Func/*!*/ _tramp; - internal JitStub3(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic) - : base(ast, context, m, generic, 3) { + internal JitStub3(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic, JitKeywords keywords) + : base(ast, context, m, generic, 3, keywords) { _gen = (Func)generic; _tramp = Call; } diff --git a/Util/jit-matrix.rb b/Util/jit-matrix.rb index 1075b08c..28ab7b71 100644 --- a/Util/jit-matrix.rb +++ b/Util/jit-matrix.rb @@ -400,6 +400,143 @@ def no_osr_return(n) t('return of a splat') { no_splat_return(4) } t('return from inside a hot loop (OSR)') { [no_osr_return(100000), no_osr_return(10)] } +# ---------------------------------------------------------------- 8b. keyword parameters + +# Like t, but an ArgumentError's message is part of the answer: a keyword method's entry hands +# every call the prologue would complain about to the generic body, and the wording of the +# complaint is what shows that it did. (The few probes that use t instead are arity errors +# IronRuby words differently from CRuby with the JIT off as well: MRI appends the required +# keywords to them.) +def kt(label) + print label.ljust(56), ' => ' + begin + v = yield + puts "#{v.inspect} (#{v.class})" + rescue ArgumentError => e + puts "ArgumentError: #{e.message}" + rescue Exception => e + puts e.class + end +end + +def sp_kw_add(left:, right:) + left + right +end + +def sp_kw_opt(a: 1, b: 2.5) + a * b +end + +def sp_kw_mixed(x, k:, j: 10) + x - k * j +end + +def sp_kw_obj(a, k: nil) + k +end + +def sp_kw_ret(n:, m: 0) + return 0 if n < m + n * 2 +end + +def sp_kw_assign(a:, b: 1) + a = a + b + a * 2 +end + +def no_kw_rest(a:, **rest) + a + rest.size +end + +def no_kw_default_expr(a:, b: a + 1) + a + b +end + +def no_kw_default_string(a:, b: "s") + a +end + +puts +puts '--- 8b. keyword parameters ---' +warm { |i| sp_kw_add(left: i, right: 1) } +kt('kw add') { sp_kw_add(left: 2, right: 3) } +kt('kw add, keywords in the other order') { sp_kw_add(right: 3, left: 20) } +kt('kw add Int32 max + 1') { sp_kw_add(left: 2**31 - 1, right: 1) } +kt('kw add Int64 max + 1') { sp_kw_add(left: 2**63 - 1, right: 1) } +kt('kw add Float') { sp_kw_add(left: 1.5, right: 1) } +kt('kw add String') { sp_kw_add(left: 'a', right: 'b') } +kt('kw add nil') { sp_kw_add(left: nil, right: 1) } +kt('kw add missing one') { sp_kw_add(left: 1) } +kt('kw add missing both') { sp_kw_add } +kt('kw add unknown') { sp_kw_add(left: 1, right: 2, up: 3) } +kt('kw add two unknown') { sp_kw_add(left: 1, right: 2, up: 3, down: 4) } +kt('kw add missing and unknown') { sp_kw_add(left: 1, up: 3) } +kt('kw add positional Hash') { sp_kw_add({ left: 1, right: 2 }) rescue $!.class } +kt('kw add String key') { sp_kw_add(left: 1, right: 2, 'x' => 3) } +kt('kw add **hash') { h = { left: 4, right: 5 }; [sp_kw_add(**h), h] } +kt('kw add **hash + literal') { h = { left: 4 }; [sp_kw_add(**h, right: 6), h] } +kt('kw add **{} extra') { sp_kw_add(left: 1, right: 2, **{}) } +kt('kw add **hash with a default proc') { h = Hash.new { 99 }; h[:left] = 1; sp_kw_add(**h, right: 2) } +kt('kw add via send') { send(:sp_kw_add, left: 7, right: 8) } +kt('kw add via Method#call') { method(:sp_kw_add).call(left: 7, right: 9) } +kt('kw add via public_send splat') { a = [{ left: 1, right: 1 }]; sp_kw_add(*a) rescue $!.class } +warm { |i| sp_kw_add(left: i * 0.5, right: 0.25) } +kt('kw add re-profiled on Float') { sp_kw_add(left: 1.5, right: 0.25) } +kt('kw add re-profiled on Float, Integer') { sp_kw_add(left: 1, right: 2) } + +warm { |i| sp_kw_opt(a: i) } +kt('kw opt none') { sp_kw_opt } +kt('kw opt a') { sp_kw_opt(a: 4) } +kt('kw opt b') { sp_kw_opt(b: 3) } +kt('kw opt both') { sp_kw_opt(b: 2, a: 7) } +kt('kw opt a Float') { sp_kw_opt(a: 0.5) } +kt('kw opt a nil') { sp_kw_opt(a: nil) } +kt('kw opt unknown') { sp_kw_opt(c: 1) } +kt('kw opt positional') { sp_kw_opt(1) } +kt('kw opt positional Hash') { sp_kw_opt({ a: 1 }) } +warm { |i| sp_kw_opt } +kt('kw opt hot without keywords') { sp_kw_opt } +kt('kw opt hot without keywords, given one') { sp_kw_opt(b: 4) } + +warm { |i| sp_kw_mixed(i, k: 2) } +kt('kw mixed') { sp_kw_mixed(5, k: 1) } +kt('kw mixed with optional') { sp_kw_mixed(5, k: 1, j: 2) } +t('kw mixed missing positional') { sp_kw_mixed(k: 1) } +kt('kw mixed missing keyword') { sp_kw_mixed(5) } +t('kw mixed extra positional') { sp_kw_mixed(5, 6, k: 1) } +t('kw mixed positional Hash') { sp_kw_mixed(5, { k: 1 }) } +kt('kw mixed overflow') { sp_kw_mixed(-2**62, k: 2**62, j: 2) } + +warm { |i| sp_kw_obj('x', k: i) } +kt('kw obj') { sp_kw_obj('y', k: 5) } +kt('kw obj no keyword') { sp_kw_obj('y') } +kt('kw obj string-keyed hash') { sp_kw_obj('a' => 1) } +kt('kw obj hash and keyword') { sp_kw_obj({ 'a' => 1 }, k: 2) } + +warm { |i| sp_kw_ret(n: i - 60) } +kt('kw return') { [sp_kw_ret(n: -1), sp_kw_ret(n: 4), sp_kw_ret(n: 4, m: 5)] } +warm { |i| sp_kw_assign(a: i) } +kt('kw local reassigned') { [sp_kw_assign(a: 3), sp_kw_assign(a: 3, b: 4)] } + +warm { |i| no_kw_rest(a: i, z: 1) } +kt('kw **rest') { [no_kw_rest(a: 1), no_kw_rest(a: 1, b: 2, c: 3)] } +warm { |i| no_kw_default_expr(a: i) } +kt('kw default reads a parameter') { [no_kw_default_expr(a: 1), no_kw_default_expr(a: 1, b: 5)] } +warm { |i| no_kw_default_string(a: i) } +kt('kw String default') { no_kw_default_string(a: 1) } + +def sp_kw_add(left:, right:) + left - right +end +kt('kw add redefined') { sp_kw_add(left: 2, right: 3) } +warm { |i| sp_kw_add(left: i, right: 1) } +kt('kw add redefined, hot again') { sp_kw_add(left: 2, right: 3) } +def sp_kw_add(left:, right: 100) + left + right +end +kt('kw add redefined with a default') { sp_kw_add(left: 2) } + # ---------------------------------------------------------------- 8. Integer#+ redefined def sp_plus(a, b) From 61759b2e7bc451e0cd4ccdb5b4104b891a843182 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:37:17 +0200 Subject: [PATCH 62/94] WIP: literal block Proc is its call's flow-control frame; smaller Proc and BlockParam Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/Proc.cs | 40 +++++++++++++++++++++---- Src/Ruby/Runtime/BlockParam.cs | 34 +++++++++++---------- Src/Ruby/Runtime/RubyOps.FlowControl.cs | 19 +++++++++--- Src/Ruby/Runtime/RubyOps.cs | 5 +++- 4 files changed, 71 insertions(+), 27 deletions(-) diff --git a/Src/Ruby/Builtins/Proc.cs b/Src/Ruby/Builtins/Proc.cs index d4d202eb..91446d98 100644 --- a/Src/Ruby/Builtins/Proc.cs +++ b/Src/Ruby/Builtins/Proc.cs @@ -50,7 +50,13 @@ public enum ProcKind { Lambda } - public partial class Proc : IDuplicable { + /// + /// A Proc is also a : a literal block passed to a call is a new Proc + /// that only that call converts, so the Proc itself stands for the converting frame (see + /// and ) + /// instead of a separate RuntimeFlowControl object allocated for every such call. + /// + public partial class Proc : RuntimeFlowControl, IDuplicable { // Self object captured by the block definition, if any. // Although we could load self from scope in Ruby defined blocks, we cannot do so when we don't have a scope. private readonly object _self; @@ -62,7 +68,21 @@ public partial class Proc : IDuplicable { // TODO: can we remove _kind and use _method and Converter fields intead? private ProcKind _kind; - private RubyLambdaMethodInfo _method; + + /// + /// The state few procs have, out of line: a Proc is allocated for every call with a literal block. + /// + private sealed class RareState { + public RubyLambdaMethodInfo Method; + public RefinementActivation RefinementOverride; + public string SymbolName; + } + + private RareState _rare; + + private RareState/*!*/ Rare { + get { return _rare ?? (_rare = new RareState()); } + } // The frame that converted this block to a proc: internal RuntimeFlowControl Converter { get; set; } @@ -74,7 +94,7 @@ public ProcKind Kind { } internal RubyLambdaMethodInfo Method { - get { return _method; } + get { var r = _rare; return (r != null) ? r.Method : null; } } public BlockDispatcher/*!*/ Dispatcher { @@ -95,13 +115,19 @@ public RubyScope/*!*/ LocalScope { /// it is being defined in, and only for the duration of the block - which is a lexical extent that /// exists only at runtime, so it rides on the Proc rather than on a scope object. /// - internal RefinementActivation RefinementOverride; + internal RefinementActivation RefinementOverride { + get { var r = _rare; return (r != null) ? r.RefinementOverride : null; } + set { if (value != null || _rare != null) { Rare.RefinementOverride = value; } } + } /// /// The symbol this proc came from, for a proc Symbol#to_proc built; null otherwise. /// Proc#to_s prints it as "(&:name)". /// - public string SymbolName { get; internal set; } + public string SymbolName { + get { var r = _rare; return (r != null) ? r.SymbolName : null; } + internal set { if (value != null || _rare != null) { Rare.SymbolName = value; } } + } public string SourcePath { get { return _dispatcher.SourcePath; } @@ -142,7 +168,9 @@ protected Proc(Proc/*!*/ proc) public Proc/*!*/ ToLambda(RubyLambdaMethodInfo method) { Proc result = new Proc(this); result.Kind = ProcKind.Lambda; - result._method = method; + if (method != null) { + result.Rare.Method = method; + } return result; } diff --git a/Src/Ruby/Runtime/BlockParam.cs b/Src/Ruby/Runtime/BlockParam.cs index 9a17edeb..8e3c8dc9 100644 --- a/Src/Ruby/Runtime/BlockParam.cs +++ b/Src/Ruby/Runtime/BlockParam.cs @@ -93,7 +93,8 @@ public DynamicMetaObject Restrict(Type/*!*/ type) { public sealed partial class BlockParam { // -- in -- private readonly Proc/*!*/ _proc; - private readonly BlockCallerKind _callerKind; + // the enums are kept in bytes: a BlockParam is allocated for every call of a library method with a block + private readonly byte _callerKind; // filled by module_eval: if not null than method definition uses the module private RubyModule _methodLookupModule; @@ -111,17 +112,17 @@ public sealed partial class BlockParam { public bool IsThreadRoot { get; set; } // -- out -- - private BlockReturnReason _returnReason; + private byte _returnReason; private RuntimeFlowControl _targetFrame; - private ProcKind _sourceProcKind; + private byte _sourceProcKind; - internal BlockCallerKind CallerKind { get { return _callerKind; } } + internal BlockCallerKind CallerKind { get { return (BlockCallerKind)_callerKind; } } // True while a lambda called through Proc#call (or as a define_method body) is running, so // a return in a block nested in it can unwind to that call. internal bool IsActiveLambdaCall; - internal ProcKind SourceProcKind { get { return _sourceProcKind; } } - internal BlockReturnReason ReturnReason { get { return _returnReason; } set { _returnReason = value; } } + internal ProcKind SourceProcKind { get { return (ProcKind)_sourceProcKind; } } + internal BlockReturnReason ReturnReason { get { return (BlockReturnReason)_returnReason; } set { _returnReason = (byte)value; } } internal RuntimeFlowControl TargetFrame { get { return _targetFrame; } } internal RubyModule MethodLookupModule { get { return _methodLookupModule; } set { _methodLookupModule = value; } } internal bool IsLibProcConverter { get { return _isLibProcConverter; } } @@ -138,8 +139,9 @@ public bool IsLiteralBlock { if (_isLibProcConverter) { return true; } + // a call that converted a literal block stands for its frame by that Proc (see CreateRfcForMethod): var converter = _proc.Converter; - return converter != null && converter.GetType() == typeof(RuntimeFlowControl) && converter.IsActiveMethod; + return converter is Proc && converter.IsActiveMethod; } } @@ -161,7 +163,7 @@ public bool IsMethod { // friend: RubyOps internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind, bool isLibProcConverter) { _threadId = Environment.CurrentManagedThreadId; - _callerKind = callerKind; + _callerKind = (byte)callerKind; _proc = proc; _isLibProcConverter = isLibProcConverter; } @@ -169,16 +171,16 @@ internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind, bool isLibProcCo // A BlockParam that is only ever used on the thread creating it, for the duration of a call: // a yield or a Proc#call. There the thread check in Break always passes, so skip it. internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind) { - _callerKind = callerKind; + _callerKind = (byte)callerKind; _proc = proc; } internal void SetFlowControl(BlockReturnReason reason, RuntimeFlowControl targetFrame, ProcKind sourceProcKind) { Debug.Assert((reason == BlockReturnReason.Break) == (targetFrame != null)); - _returnReason = reason; + _returnReason = (byte)reason; _targetFrame = targetFrame; - _sourceProcKind = sourceProcKind; + _sourceProcKind = (byte)sourceProcKind; } internal object GetUnwinderResult(EvalUnwinder/*!*/ unwinder) { @@ -198,7 +200,7 @@ internal object GetUnwinderResult(EvalUnwinder/*!*/ unwinder) { /// internal bool BlockJumped(object returnValue) { // the block finished normally (no break/return/retry), which is what nearly every yield does: - if (_returnReason == BlockReturnReason.Undefined) { + if (_returnReason == (byte)BlockReturnReason.Undefined) { return false; } @@ -222,7 +224,7 @@ public object PropagateFlow(BlockParam/*!*/ yieldedBlock, object returnValue) { if (yieldedBlock.ReturnReason == BlockReturnReason.Break) { return Break(returnValue); } else { - _returnReason = yieldedBlock.ReturnReason; + _returnReason = (byte)yieldedBlock.ReturnReason; return returnValue; } } @@ -353,9 +355,9 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* bool isProcConverter; if (proc.Kind == ProcKind.Block) { - var rfc = new RuntimeFlowControl(); - rfc._activeFlowControlScope = rfc; - proc.Converter = rfc; + // the literal block is new for this call: it is the call's flow-control frame (see CreateRfcForMethod) + proc._activeFlowControlScope = proc; + proc.Converter = proc; proc.Kind = ProcKind.Proc; isProcConverter = true; } else { diff --git a/Src/Ruby/Runtime/RubyOps.FlowControl.cs b/Src/Ruby/Runtime/RubyOps.FlowControl.cs index f5b51d1d..a35f05b4 100644 --- a/Src/Ruby/Runtime/RubyOps.FlowControl.cs +++ b/Src/Ruby/Runtime/RubyOps.FlowControl.cs @@ -26,14 +26,25 @@ namespace IronRuby.Runtime { public static partial class RubyOps { #region RFC Flags + /// + /// The flow-control frame of a call to a Ruby method with a block: a break from the block unwinds to it. + /// A literal block (ProcKind.Block) is a Proc made for this call alone, so the Proc itself is that frame - + /// active until LeaveMethodFrame - and becomes the block's converter. Any other block has been converted + /// already, so nothing can target this call: it shares one frame that is never active and never a converter. + /// [Emitted] public static RuntimeFlowControl/*!*/ CreateRfcForMethod(Proc proc) { - var result = new RuntimeFlowControl(); - result._activeFlowControlScope = result; - result.InitializeRfc(proc); - return result; + if (proc != null && proc.Kind == ProcKind.Block) { + proc._activeFlowControlScope = proc; + proc.Kind = ProcKind.Proc; + proc.Converter = proc; + return proc; + } + return InactiveRfc; } + private static readonly RuntimeFlowControl/*!*/ InactiveRfc = new RuntimeFlowControl(); + [Emitted] public static void EnterLoop(RubyScope/*!*/ scope) { scope.InLoop = true; diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index fa0797fc..aaa0d6e5 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -2892,7 +2892,10 @@ public static object GetMethodUnwinderReturnValue(Exception/*!*/ exception) { [Emitted] public static void LeaveMethodFrame(RuntimeFlowControl/*!*/ rfc) { - rfc.LeaveMethod(); + // no write to a frame that is not active: CreateRfcForMethod's shared inactive frame stays untouched + if (rfc._activeFlowControlScope != null) { + rfc.LeaveMethod(); + } } /// From 981aed3f50611a480ac7cc2b1806c200f32b1521 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:39:20 +0200 Subject: [PATCH 63/94] WIP: JIT simple initializers - optional literal defaults, arity 6, stored-only params boxed Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Jit/JitCompiler.cs | 50 +++++- Src/Ruby/Runtime/Jit/JitRuntime.cs | 15 +- Src/Ruby/Runtime/Jit/JitStub.cs | 188 ++++++++++++++++++++-- Src/Ruby/Runtime/RubyStackTraceBuilder.cs | 7 + 4 files changed, 236 insertions(+), 24 deletions(-) diff --git a/Src/Ruby/Runtime/Jit/JitCompiler.cs b/Src/Ruby/Runtime/Jit/JitCompiler.cs index de7a2af2..9ac66a38 100644 --- a/Src/Ruby/Runtime/Jit/JitCompiler.cs +++ b/Src/Ruby/Runtime/Jit/JitCompiler.cs @@ -87,6 +87,10 @@ internal sealed class JitCompiler { // _canDeopt: the body contains an operation that can signal a deopt. private bool _pure = true; private bool _canDeopt; + // The body writes an instance variable. A write to a frozen object deopts (JitRuntime.SetIVar) + // so that the generic body raises the FrozenError from a real frame; it is always the first + // write that does, before anything observable, so the restart is safe whatever _pure says. + private bool _ivarWrite; private bool _selfCall; private int _nodes; @@ -116,10 +120,13 @@ private JitCompiler(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, JT[]/* _self = Ast.Parameter(typeof(object), "#self"); PushScope(); + // The typed body's parameters are the generic delegate's: every mandatory one, then + // the optional ones, whose defaults the entry has already filled in (JitStub). var mandatory = ast.Parameters.Mandatory; - _paramNames = new string[mandatory.Length]; - for (int i = 0; i < mandatory.Length; i++) { - var lv = mandatory[i] as LocalVariable; + var optional = ast.Parameters.Optional; + _paramNames = new string[mandatory.Length + optional.Length]; + for (int i = 0; i < _paramNames.Length; i++) { + var lv = (i < mandatory.Length) ? mandatory[i] as LocalVariable : optional[i - mandatory.Length].Left as LocalVariable; if (lv == null) { throw JitBailout.Instance; } _paramNames[i] = lv.Name; var p = Ast.Parameter(ClrType(paramTypes[i]), lv.Name); @@ -380,7 +387,7 @@ internal static JitCode TryCompile(MethodDeclaration/*!*/ ast, RubyContext/*!*/ SelfCell = c._selfCell, ReturnType = actual, ParameterTypes = paramTypes, - CanDeopt = c._canDeopt, + CanDeopt = c._canDeopt || c._ivarWrite, EmittedSelfCall = c._selfCall, }; } @@ -398,6 +405,9 @@ private static Type DelegateType(JT[]/*!*/ ps, JT ret) { case 1: return typeof(Func<,,>).MakeGenericType(types); case 2: return typeof(Func<,,,>).MakeGenericType(types); case 3: return typeof(Func<,,,,>).MakeGenericType(types); + case 4: return typeof(Func<,,,,,>).MakeGenericType(types); + case 5: return typeof(Func<,,,,,,>).MakeGenericType(types); + case 6: return typeof(Func<,,,,,,,>).MakeGenericType(types); default: return null; } } @@ -573,6 +583,7 @@ private static Type DelegateType(JT[]/*!*/ ps, JT ret) { JT vt; var v = Emit(node.Right, out vt); _pure = false; // an ivar write is observable, so the body may not deopt + _ivarWrite = true; type = JT.Obj; return Ast.Call(JitRuntime.M("SetIVar"), Ast.Constant(_context), _self, Coerce(v, vt, JT.Obj), Ast.Constant(new InstanceVariableSite(ivarTarget.Name))); } @@ -816,7 +827,12 @@ private static JT Unify(JT a, JT b) { // A long leaves through the representation funnel: a value that fits in an Int32 // IS an Int32, which is what #hash, #eql? and Hash keys are defined on. if (from == JT.Lng) { return Ast.Call(JitRuntime.M("NarrowLong"), e); } - // Fixnum is a boxed int, Float a boxed double, true/false boxed bools. + // Fixnum is a boxed int, Float a boxed double, true/false boxed bools. A literal + // is boxed once, here, as the generic body does, rather than on every evaluation + // (`@count = 0' in an initializer would otherwise allocate per object). + var constant = e as MSA.ConstantExpression; + if (constant != null) { return Ast.Constant(constant.Value, typeof(object)); } + if (from == JT.Bool) { return Ast.Call(typeof(Microsoft.Scripting.Runtime.ScriptingRuntimeHelpers).GetMethod("BooleanToObject"), e); } return Ast.Convert(e, typeof(object)); } throw JitBailout.Instance; @@ -1106,10 +1122,15 @@ internal sealed class JitScreen { /// The node kind that said no, for the histogram. private string _rejectedBy; + // Locals read anywhere other than straight into an instance variable (`@a = a'), or + // assigned. A parameter not in here is only ever stored, so it is best carried boxed: + // unboxing it on entry would only mean boxing it again for the store. + private readonly HashSet/*!*/ _otherUses = new HashSet(); + private JitScreen(MethodDeclaration/*!*/ ast) { _scopeDepth = ast.DefinedScope.Depth; _methodName = ast.Name; - _arity = ast.Parameters.Mandatory.Length; + _arity = ast.Parameters.Mandatory.Length + ast.Parameters.Optional.Length; } /// @@ -1117,7 +1138,17 @@ private JitScreen(MethodDeclaration/*!*/ ast) { /// this body. names the node kind that decided it. /// internal static bool CanEverCompile(MethodDeclaration/*!*/ ast, out string rejectedBy) { + HashSet otherUses; + return CanEverCompile(ast, out rejectedBy, out otherUses); + } + + /// + /// : the locals the body reads other than as the value of an + /// instance variable assignment, or assigns. + /// + internal static bool CanEverCompile(MethodDeclaration/*!*/ ast, out string rejectedBy, out HashSet otherUses) { var s = new JitScreen(ast); + otherUses = s._otherUses; bool ok; try { ok = s.WalkBody(ast.Body); @@ -1195,6 +1226,7 @@ private bool Walk(RExpr/*!*/ node) { if (local != null) { // A local from an enclosing scope is never read from a typed body. (Emit also // bails on a read before any assignment, which is a flow property: say yes.) + _otherUses.Add(local.Name); return local.DefinitionLexicalDepth == _scopeDepth || No(node); } @@ -1207,10 +1239,14 @@ private bool Walk(RExpr/*!*/ node) { var ivarTarget = assign.Left as InstanceVariable; if (ivarTarget != null) { - return (op == null) ? Walk(assign.Right) : No(node); + if (op != null) { return No(node); } + var stored = assign.Right as LocalVariable; + if (stored != null && stored.DefinitionLexicalDepth == _scopeDepth) { ++_nodes; return true; } + return Walk(assign.Right); } var target = assign.Left as LocalVariable; if (target == null || target.DefinitionLexicalDepth != _scopeDepth) { return No(node); } + _otherUses.Add(target.Name); return (op != null) ? WalkOperand(assign.Right) : Walk(assign.Right); } diff --git a/Src/Ruby/Runtime/Jit/JitRuntime.cs b/Src/Ruby/Runtime/Jit/JitRuntime.cs index 12ea91c4..8abc9092 100644 --- a/Src/Ruby/Runtime/Jit/JitRuntime.cs +++ b/Src/Ruby/Runtime/Jit/JitRuntime.cs @@ -325,7 +325,20 @@ public static object GetIVar(RubyContext/*!*/ context, object self, InstanceVari } public static object SetIVar(RubyContext/*!*/ context, object self, object value, InstanceVariableSite/*!*/ site) { - return RubyOps.WriteInstanceVariable(context, self, value, site); + var obj = self as RubyObject; + if (obj != null) { + var data = obj._instanceData ?? RubyOps.GetInstanceData(ref obj._instanceData); + if (!data._frozen && data.TrySetCached(site.Cache, value, obj)) { + return value; + } + } + // A frozen receiver raises FrozenError, and a typed body has no frame to raise it from: + // let the generic body run instead (see JitCompiler._ivarWrite). + if (context.IsObjectFrozen(self)) { + throw Deopt(); + } + context.SetInstanceVariable(self, value, site); + return value; } // An instance variable used as a numeric operand of an outlined loop (JitCompiler.NumericIVar): diff --git a/Src/Ruby/Runtime/Jit/JitStub.cs b/Src/Ruby/Runtime/Jit/JitStub.cs index ad424fdf..9c38da03 100644 --- a/Src/Ruby/Runtime/Jit/JitStub.cs +++ b/Src/Ruby/Runtime/Jit/JitStub.cs @@ -43,7 +43,11 @@ namespace IronRuby.Runtime.Jit { internal abstract class JitStub { internal const int Threshold = 40; - internal const int MaxArity = 3; + internal const int MaxArity = 6; + internal const string EntryNamePrefix = "jitentry$"; + + // What a typed body's call evaluates to in the entry when it deopted. + private static readonly object Deopted = new object(); protected readonly MethodDeclaration/*!*/ _ast; protected readonly RubyContext/*!*/ _context; @@ -51,6 +55,16 @@ internal abstract class JitStub { protected readonly Delegate/*!*/ _generic; protected readonly int _arity; + // Optional parameters (after the mandatory ones, as in the generic delegate) have a literal + // default; a call that omits one passes RubyOps.DefaultArgument in its place, which the + // entry and the profiler replace with the value. _hasDefault is null with no optionals. + private bool[] _hasDefault; + private object[] _defaults; + + // Parameters the body only ever stores into an instance variable: kept as the boxed + // objects they arrived as, whatever their type (JitScreen's otherUses). + private bool[] _storedOnly; + // profiling feedback private int _calls; private readonly int[]/*!*/ _argKind; // 0 = unseen, 1 = int, 2 = double, 3 = other/mixed, 4 = long @@ -75,6 +89,9 @@ protected void Profile(object self, object[] args) { if (_givenUp) { return; } for (int i = 0; i < _arity; i++) { + if (_hasDefault != null && _hasDefault[i] && args[i] == RubyOps.DefaultArgument) { + args[i] = _defaults[i]; + } int k = (args[i] is int) ? 1 : (args[i] is double) ? 2 : (args[i] is long) ? 4 : 3; if (_argKind[i] == 0) { _argKind[i] = k; @@ -156,6 +173,7 @@ private bool TrySpecialize() { var types = new JT[_arity]; for (int i = 0; i < _arity; i++) { + if (_storedOnly != null && _storedOnly[i]) { types[i] = JT.Obj; continue; } switch (_argKind[i]) { case 1: types[i] = JT.Int; break; case 2: types[i] = JT.Dbl; break; @@ -222,6 +240,25 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { args[i] = Ast.Parameter(typeof(object), "a" + i); } + // What the typed body sees of each argument: an omitted optional one is its default. + // The generic body is still handed the arguments as they came. + var values = new MSA.ParameterExpression[_arity]; + var defaulted = new List(); + var fillDefaults = new List(); + for (int i = 0; i < _arity; i++) { + if (_hasDefault != null && _hasDefault[i]) { + values[i] = Ast.Variable(typeof(object), "v" + i); + defaulted.Add(values[i]); + fillDefaults.Add(Ast.Assign(values[i], Ast.Condition( + Ast.ReferenceEqual(args[i], Ast.Field(null, typeof(RubyOps).GetField("DefaultArgument"))), + Ast.Constant(_defaults[i], typeof(object)), + args[i] + ))); + } else { + values[i] = args[i]; + } + } + MSA.Expression test = null; if (pinnedClass != null) { test = And(test, Ast.ReferenceEqual( @@ -230,11 +267,11 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { } for (int i = 0; i < _arity; i++) { switch (code.ParameterTypes[i]) { - case JT.Int: test = And(test, Ast.TypeIs(args[i], typeof(int))); break; - case JT.Dbl: test = And(test, Ast.TypeIs(args[i], typeof(double))); break; + case JT.Int: test = And(test, Ast.TypeIs(values[i], typeof(int))); break; + case JT.Dbl: test = And(test, Ast.TypeIs(values[i], typeof(double))); break; // An Integer parameter carried as a long accepts either representation: a // value in Int32 range arrives as an Int32 and only a larger one as an Int64. - case JT.Lng: test = And(test, Ast.Call(JitRuntime.M("IsIntegral"), args[i])); break; + case JT.Lng: test = And(test, Ast.Call(JitRuntime.M("IsIntegral"), values[i])); break; } } @@ -242,10 +279,10 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { typedArgs[0] = self; for (int i = 0; i < _arity; i++) { switch (code.ParameterTypes[i]) { - case JT.Int: typedArgs[i + 1] = Ast.Unbox(args[i], typeof(int)); break; - case JT.Dbl: typedArgs[i + 1] = Ast.Unbox(args[i], typeof(double)); break; - case JT.Lng: typedArgs[i + 1] = Ast.Call(JitRuntime.M("ToLong"), args[i]); break; - default: typedArgs[i + 1] = args[i]; break; + case JT.Int: typedArgs[i + 1] = Ast.Unbox(values[i], typeof(int)); break; + case JT.Dbl: typedArgs[i + 1] = Ast.Unbox(values[i], typeof(double)); break; + case JT.Lng: typedArgs[i + 1] = Ast.Call(JitRuntime.M("ToLong"), values[i]); break; + default: typedArgs[i + 1] = values[i]; break; } } @@ -259,11 +296,25 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { } if (code.CanDeopt) { - fast = Ast.TryCatch(fast, - Ast.Catch(typeof(JitDeoptException), GenericCall(self, blk, args))); + // The generic body runs after the handler, not inside it: an exception it raises + // from within a catch block would leave the frames outside this one out of its + // backtrace. + var result = Ast.Variable(typeof(object), "#result"); + var deopted = Ast.Constant(Deopted, typeof(object)); + fast = Ast.Block(typeof(object), new[] { result }, + Ast.TryCatch( + Ast.Assign(result, fast), + Ast.Catch(typeof(JitDeoptException), Ast.Assign(result, deopted)) + ), + Ast.Condition(Ast.ReferenceEqual(result, deopted), GenericCall(self, blk, args), result, typeof(object)) + ); } MSA.Expression body = (test != null) ? Ast.Condition(test, fast, GenericCall(self, blk, args), typeof(object)) : fast; + if (defaulted.Count > 0) { + fillDefaults.Add(body); + body = Ast.Block(typeof(object), defaulted, fillDefaults); + } // A stale version fails this guard on every later call; the stub takes the entry down // (JitStub.VersionChanged) and this one call runs the generic body. @@ -281,7 +332,7 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { all[0] = self; all[1] = blk; Array.Copy(args, 0, all, 2, _arity); - return Ast.Lambda(_generic.GetType(), body, "jitentry$" + _ast.Name, all); + return Ast.Lambda(_generic.GetType(), body, EntryNamePrefix + _ast.Name, all); } private static MSA.Expression/*!*/ And(MSA.Expression left, MSA.Expression/*!*/ right) { @@ -308,12 +359,32 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { JitRuntime.HookStats(); var ps = ast.Parameters; - if (ps.Optional.Length != 0 || ps.Unsplat != null || ps.Block != null || ast.UsesBlock) { return generic; } - int arity = ps.Mandatory.Length; + if (ps.Unsplat != null || ps.Block != null || ast.UsesBlock) { return generic; } + int mandatoryCount = ps.Mandatory.Length; + int arity = mandatoryCount + ps.Optional.Length; if (arity > MaxArity) { return generic; } - for (int i = 0; i < arity; i++) { + for (int i = 0; i < mandatoryCount; i++) { if (!(ps.Mandatory[i] is LocalVariable)) { return generic; } } + + // An optional parameter is taken when its default is a literal the typed body could + // have produced itself - nil, true, false, a Fixnum or a Float: evaluating it has no + // effect and cannot see the other parameters, so the entry may fill it in up front. + bool[] hasDefault = null; + object[] defaults = null; + if (ps.Optional.Length > 0) { + hasDefault = new bool[arity]; + defaults = new object[arity]; + for (int i = 0; i < ps.Optional.Length; i++) { + var opt = ps.Optional[i]; + var literal = opt.Right as Literal; + if (!(opt.Left is LocalVariable) || literal == null) { return generic; } + object v = literal.Value; + if (!(v == null || v is bool || v is int || v is double)) { return generic; } + hasDefault[mandatoryCount + i] = true; + defaults[mandatoryCount + i] = v; + } + } // Tracing and coverage need the real frame. if (context.RubyOptions.EnableTracing || context.RubyOptions.Profile) { return generic; } @@ -322,18 +393,34 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { // into call site rules, so a method declined later still pays one extra indirect // call on every call for the life of the process. string rejectedBy; - if (!JitScreen.CanEverCompile(ast, out rejectedBy)) { + HashSet otherUses; + if (!JitScreen.CanEverCompile(ast, out rejectedBy, out otherUses)) { JitRuntime.RecordScreenRejection(rejectedBy); return generic; } + bool[] storedOnly = null; + for (int i = 0; i < arity; i++) { + var lv = (LocalVariable)((i < mandatoryCount) ? ps.Mandatory[i] : ps.Optional[i - mandatoryCount].Left); + if (!otherUses.Contains(lv.Name)) { + if (storedOnly == null) { storedOnly = new bool[arity]; } + storedOnly[i] = true; + } + } + JitStub stub; switch (arity) { case 0: stub = new JitStub0(ast, context, declaringModule, generic); break; case 1: stub = new JitStub1(ast, context, declaringModule, generic); break; case 2: stub = new JitStub2(ast, context, declaringModule, generic); break; - default: stub = new JitStub3(ast, context, declaringModule, generic); break; + case 3: stub = new JitStub3(ast, context, declaringModule, generic); break; + case 4: stub = new JitStub4(ast, context, declaringModule, generic); break; + case 5: stub = new JitStub5(ast, context, declaringModule, generic); break; + default: stub = new JitStub6(ast, context, declaringModule, generic); break; } + stub._hasDefault = hasDefault; + stub._defaults = defaults; + stub._storedOnly = storedOnly; return stub.Trampoline; } @@ -435,4 +522,73 @@ private object Call(object self, Proc block, object a0, object a1, object a2) { } } + internal sealed class JitStub4 : JitStub { + private volatile Func _fast; + private readonly Func/*!*/ _gen; + private readonly Func/*!*/ _tramp; + + internal JitStub4(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic) + : base(ast, context, m, generic, 4) { + _gen = (Func)generic; + _tramp = Call; + } + + internal override Delegate/*!*/ Trampoline { get { return _tramp; } } + protected override void Install(Delegate entry) { _fast = (Func)entry; } + + private object Call(object self, Proc block, object a0, object a1, object a2, object a3) { + var f = _fast; + if (f != null) { return f(self, block, a0, a1, a2, a3); } + Profile(self, new[] { a0, a1, a2, a3 }); + f = _fast; + return (f != null) ? f(self, block, a0, a1, a2, a3) : _gen(self, block, a0, a1, a2, a3); + } + } + + internal sealed class JitStub5 : JitStub { + private volatile Func _fast; + private readonly Func/*!*/ _gen; + private readonly Func/*!*/ _tramp; + + internal JitStub5(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic) + : base(ast, context, m, generic, 5) { + _gen = (Func)generic; + _tramp = Call; + } + + internal override Delegate/*!*/ Trampoline { get { return _tramp; } } + protected override void Install(Delegate entry) { _fast = (Func)entry; } + + private object Call(object self, Proc block, object a0, object a1, object a2, object a3, object a4) { + var f = _fast; + if (f != null) { return f(self, block, a0, a1, a2, a3, a4); } + Profile(self, new[] { a0, a1, a2, a3, a4 }); + f = _fast; + return (f != null) ? f(self, block, a0, a1, a2, a3, a4) : _gen(self, block, a0, a1, a2, a3, a4); + } + } + + internal sealed class JitStub6 : JitStub { + private volatile Func _fast; + private readonly Func/*!*/ _gen; + private readonly Func/*!*/ _tramp; + + internal JitStub6(MethodDeclaration ast, RubyContext context, RubyModule m, Delegate generic) + : base(ast, context, m, generic, 6) { + _gen = (Func)generic; + _tramp = Call; + } + + internal override Delegate/*!*/ Trampoline { get { return _tramp; } } + protected override void Install(Delegate entry) { _fast = (Func)entry; } + + private object Call(object self, Proc block, object a0, object a1, object a2, object a3, object a4, object a5) { + var f = _fast; + if (f != null) { return f(self, block, a0, a1, a2, a3, a4, a5); } + Profile(self, new[] { a0, a1, a2, a3, a4, a5 }); + f = _fast; + return (f != null) ? f(self, block, a0, a1, a2, a3, a4, a5) : _gen(self, block, a0, a1, a2, a3, a4, a5); + } + } + } diff --git a/Src/Ruby/Runtime/RubyStackTraceBuilder.cs b/Src/Ruby/Runtime/RubyStackTraceBuilder.cs index b6ffe15c..ebedd9b6 100644 --- a/Src/Ruby/Runtime/RubyStackTraceBuilder.cs +++ b/Src/Ruby/Runtime/RubyStackTraceBuilder.cs @@ -561,6 +561,13 @@ private static bool IsVisibleClrFrame(MethodBase/*!*/ method) { return false; } + // The entry of a JIT-specialized method (JitStub.BuildEntry): its fast path raises + // nothing, and its slow path calls the method's generic body, which is the frame that + // stands for the method. + if (method.DeclaringType == null && method.Name.StartsWith(Jit.JitStub.EntryNamePrefix, StringComparison.Ordinal)) { + return false; + } + Type type = method.DeclaringType; if (type != null) { if (type.Assembly == typeof(RubyOps).Assembly || type.Namespace != null && From 0e995dd28d3241ba1b3190b0c2c734dee42e3ed1 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:43:11 +0200 Subject: [PATCH 64/94] Method JIT: specialize methods with keyword parameters JitStub.Wrap refused every method with an optional parameter, and keyword methods are lowered onto one trailing optional parameter holding the keyword hash (PrismAstBridge.LowerKeywords), so `def add(left:, right:)' was never specialized and every call ran the generic body: a RubyMethodScope, a MutableTuple and the prologue's keyword reads. JitKeywords recognizes exactly the prologue LowerKeywords emits (mandatory positionals plus keywords, defaults that are nil/true/false/Integer/Float literals, no **rest) and the typed body takes the keyword values as typed parameters after the positionals, starting after the prologue. The entry reads them with the prologue's own helpers once it has checked that the prologue would bind them without raising (keyword hash, every required keyword, no unknown one, no keywords in the last positional); any other call runs the generic body, which raises the ArgumentError it always raised. Self-calls are not emitted for keyword methods. The tree is the one of the previous commit (64f3cf25); this commit only marks it gated. Util/jit-matrix.rb gets a keyword section (missing/unknown keywords with MRI's messages, ** splats, defaults, Float/Bignum/nil values, send, Method#call, redefinition): identical to CRuby 4.0.6 with and without -X:NoJIT. ruby-bench keyword_args (rb-compare, best of 3 rounds x 5 itrs, tipref 843df62e -> this): 1004 ms -> 583 ms (1.72x; CRuby 246 ms). Util/bench call_kwargs, best of 5 interleaved under bench-lock: 0.350 s -> 0.277 s (tipref spread 0.350-0.508). Allocation per `add(left: 1, right: i)' call: 488 B -> 256 B (what is left is the call site's keyword Hash and boxing). `kw()' with only defaulted keywords: 336 B -> 48 B. Spot specs spec/core/method + Kernel#send/public_send/__send__: 261 examples, 0 failures, same as tipref. Gates: sp-tests 0, sp-lang 2934/0/0, keyword_args 1004ms -> 583ms Co-Authored-By: Claude Opus 5.5 From 15d8a0292b440ed2b95ee1dbce5306f794f305d0 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:44:34 +0200 Subject: [PATCH 65/94] WIP: tiered compiled regexes, Integer()/Float() String fast path, conversion action cache Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/RubyRegex.cs | 63 ++++++++++++++++--- .../Conversions/ProtocolConversionAction.cs | 12 +++- Src/StdLib/ironruby/ruby4.rb | 10 +++ 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/Src/Ruby/Builtins/RubyRegex.cs b/Src/Ruby/Builtins/RubyRegex.cs index d5fe27b7..2cc83d1c 100644 --- a/Src/Ruby/Builtins/RubyRegex.cs +++ b/Src/Ruby/Builtins/RubyRegex.cs @@ -53,6 +53,11 @@ public partial class RubyRegex : IEquatable, IDuplicable, IRubyObject private Regex _cachedAnchoredRegex; private Regex _cachedAnchoredSource; + // How many matches _cachedRegex and _cachedAnchoredRegex have run while still interpreted. + // A regex that keeps being used is rebuilt with RegexOptions.Compiled (see TierUp). + private int _cachedRegexUses; + private int _cachedAnchoredUses; + // Ruby 1.8: match operations use KCODE encoding so we need to remember the one for which we have cached CLR Regex. private RubyRegexOptions _cachedKCode; @@ -229,6 +234,15 @@ private void RequireMatchableEncoding(MutableString/*!*/ input) { /// a whole pair. /// private Regex/*!*/ Transform(ref RubyEncoding encoding, MutableString/*!*/ input, int start, out string strInput, out bool astral) { + return Transform(ref encoding, input, start, out strInput, out astral, true); + } + + /// + /// is false when the caller is not going to match with the + /// regex it gets back (MatchWindow, which derives an anchored one from it), so that only + /// the regex that actually runs works its way up to being compiled. + /// + private Regex/*!*/ Transform(ref RubyEncoding encoding, MutableString/*!*/ input, int start, out string strInput, out bool astral, bool countUse) { ContractUtils.RequiresNotNull(input, "input"); RequireMatchableEncoding(input); @@ -289,7 +303,7 @@ private void RequireMatchableEncoding(MutableString/*!*/ input) { astral = input.HasSurrogatePairs(); } - return astral ? TransformAstralPattern(encoding, kc) : TransformPattern(encoding, kc); + return astral ? TransformAstralPattern(encoding, kc) : TransformPattern(encoding, kc, countUse); } private Regex/*!*/ TransformAstralPattern(RubyEncoding encoding, RubyRegexOptions kc) { @@ -319,11 +333,19 @@ private void RequireMatchableEncoding(MutableString/*!*/ input) { } private Regex/*!*/ TransformPattern(RubyEncoding encoding, RubyRegexOptions kc) { + return TransformPattern(encoding, kc, false); + } + + private Regex/*!*/ TransformPattern(RubyEncoding encoding, RubyRegexOptions kc, bool countUse) { // We can reuse cached CLR regex if it was created for the same k-coding and the // same timeout: TimeSpan timeout = EffectiveTimeout; - if (_cachedRegex != null && kc == _cachedKCode && timeout == _cachedTimeout) { - return _cachedRegex; + Regex cached = _cachedRegex; + if (cached != null && kc == _cachedKCode && timeout == _cachedTimeout) { + if (countUse && (cached.Options & RegexOptions.Compiled) == 0 && ++_cachedRegexUses >= CompileThreshold) { + _cachedRegex = cached = TierUp(cached); + } + return cached; } string pattern = GetClrPatternSource(encoding, kc); @@ -337,10 +359,32 @@ private void RequireMatchableEncoding(MutableString/*!*/ input) { _cachedTimeout = timeout; _cachedKCode = kc; + _cachedRegexUses = 0; _cachedRegex = result; return result; } + /// + /// Matches a regex runs before it is compiled. The CLR interprets a Regex unless told to + /// compile it, and compiling costs a millisecond or more - about what the compiled code + /// then saves over ten thousand matches of a typical pattern - so only a regex that has + /// already shown it is hot pays for it: one in a loop over a large input, a tokenizer's. + /// + private const int CompileThreshold = 10000; + + /// + /// The same CLR regex compiled to IL. Compiled and interpreted regexes are the same + /// engine and the same pattern tree and match identically; only the speed differs. If it + /// cannot be built for any reason, the interpreted one is kept. + /// + private static Regex/*!*/ TierUp(Regex/*!*/ regex) { + try { + return new Regex(regex.ToString(), regex.Options | RegexOptions.Compiled, regex.MatchTimeout); + } catch (Exception) { + return regex; + } + } + /// /// Searches the pattern for hexadecimal and octal character escapes that represent a non-ASCII character. /// @@ -909,7 +953,7 @@ public MatchData MatchWindow(MutableString/*!*/ input, int start, bool anchored, string str; RubyEncoding kcode = null; bool astral; - Regex regex = Transform(ref kcode, input, 0, out str, out astral); + Regex regex = Transform(ref kcode, input, 0, out str, out astral, !anchored); // Under a k-coding the offsets are bytes, and "start" only indexes the converted // string directly when that coding spells every character in one byte - which the @@ -917,7 +961,7 @@ public MatchData MatchWindow(MutableString/*!*/ input, int start, bool anchored, int offset = 0; if (kcode != null && !kcode.IsSingleByteCharacterSet) { kcode = null; - regex = Transform(ref kcode, input, start, out str, out astral); + regex = Transform(ref kcode, input, start, out str, out astral, !anchored); offset = (start < 0) ? start + input.GetByteCount() : start; start = 0; } @@ -953,8 +997,12 @@ public MatchData MatchWindow(MutableString/*!*/ input, int start, bool anchored, /// the caller falls back to searching: slower, never wrong. /// private Regex/*!*/ AnchorAtSearchStart(Regex/*!*/ regex) { - if (_cachedAnchoredSource == regex && _cachedAnchoredRegex != null) { - return _cachedAnchoredRegex; + Regex cached = _cachedAnchoredRegex; + if (_cachedAnchoredSource == regex && cached != null) { + if ((cached.Options & RegexOptions.Compiled) == 0 && ++_cachedAnchoredUses >= CompileThreshold) { + _cachedAnchoredRegex = cached = TierUp(cached); + } + return cached; } Regex result; @@ -969,6 +1017,7 @@ public MatchData MatchWindow(MutableString/*!*/ input, int start, bool anchored, } _cachedAnchoredSource = regex; + _cachedAnchoredUses = 0; _cachedAnchoredRegex = result; return result; } diff --git a/Src/Ruby/Runtime/Conversions/ProtocolConversionAction.cs b/Src/Ruby/Runtime/Conversions/ProtocolConversionAction.cs index 31e7fc27..f6dd8790 100644 --- a/Src/Ruby/Runtime/Conversions/ProtocolConversionAction.cs +++ b/Src/Ruby/Runtime/Conversions/ProtocolConversionAction.cs @@ -441,8 +441,18 @@ protected virtual void SetError(MetaObjectBuilder/*!*/ metaBuilder, CallArgument public abstract class ProtocolConversionAction : ProtocolConversionAction where TSelf : ProtocolConversionAction, new() { + // The action Make handed out last. Library methods ask for their conversion on every + // call (Protocols.CastToFixnum and friends), and the factory's locked, type-keyed lookup + // costs far more than the conversion itself. One action per runtime, so a single slot + // answers every call of a process that has one runtime; a second runtime just misses. + private static TSelf _lastMade; + public static TSelf/*!*/ Make(RubyContext/*!*/ context) { - return context.MetaBinderFactory.Conversion(); + TSelf last = _lastMade; + if (last != null && ReferenceEquals(last.Context, context)) { + return last; + } + return _lastMade = context.MetaBinderFactory.Conversion(); } [Emitted] diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index a88df643..ffb17f43 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -3051,6 +3051,12 @@ def __opts_exception__(value) private :__ir_Integer__ def Integer(arg, base = nil, exception: true) + # The common case - a String, no base, failures raised - is what the block below + # does first anyway; going straight there skips the block and its rescue frame, + # which cost many times the parse itself. + if base.nil? && true.equal?(exception) && !arg.nil? && arg.is_a?(::String) + return __ir_Integer__(arg) + end exception = ::Kernel.__opts_exception__(exception) __numeric_conversion__(exception) do if arg.nil? @@ -3112,6 +3118,10 @@ def Integer(arg, base = nil, exception: true) private :__ir_Float__ def Float(arg, exception: true) + # A String with failures raised goes to the C# parser below in any case; see Integer. + if true.equal?(exception) && !arg.nil? && ::String === arg + return __ir_Float__(arg) + end exception = ::Kernel.__opts_exception__(exception) __numeric_conversion__(exception) do raise ::TypeError, "can't convert nil into Float" if arg.nil? From 474bdc1390e3b12e8bf91ed5dcce128cb31c2020 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:52:58 +0200 Subject: [PATCH 66/94] Literal block: its Proc is the call's flow-control frame; smaller Proc and BlockParam A library method taking a BlockParam, called with a literal block, allocated a RuntimeFlowControl (24 B) per call only to give the block's break a frame identity and an "active" flag. The literal block's Proc is itself new for that one call, so Proc now derives from RuntimeFlowControl and stands for the converting frame (Converter = the proc, active until LeaveProcConverter). CreateRfcForMethod does the same and returns a shared never-active frame for an already converted proc; the Ruby-method rule that calls it is currently unused, LeaveMethodFrame skips the write for an inactive frame. IsLiteralBlock tests "converter is Proc" where it tested "converter is a plain RuntimeFlowControl" - the same frames as before. Proc keeps Method, RefinementOverride and SymbolName out of line (rare), so it is 72 B instead of 80 B with the extra base field; BlockParam keeps its three enums in bytes, 56 B instead of 64 B. Bytes per call (GetAllocatedBytesForCurrentThread, 1M calls): [].reverse_each {} / [].index {} 168 -> 128 [].reverse_each(&pr) 64 -> 56 obj.ruby_func {} 232 -> 224 proc {} 168 -> 128 ruby-bench (rb-compare, best ms of 3 rounds x 5 iterations, pinned): send_cfunc_block 1226 -> 1045 (1.17x) send_rubyfunc_block 473 -> 428 (1.11x) Util/bench best of 5-6 interleaved (noise spread 6-45%): block_pass 0.249 -> 0.225, proc_call 0.265 -> 0.257, yield_0 0.392 -> 0.398 (noise), new block_literal_cfunc 0.238 -> 0.226; no regression in yield_3, lambda_call, blk_call_in_method, times_loop, each_loop, map_loop, select_loop, any_loop, yield_loop. Adds Util/bench block_literal_cfunc (a literal block to a library method). Gates: sp-tests 0, sp-lang 2934/0/0, send_cfunc_block 1226ms -> 1045ms Co-Authored-By: Claude Opus 5.5 --- Util/bench/bench.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Util/bench/bench.rb b/Util/bench/bench.rb index c5f4e377..674b9ac1 100644 --- a/Util/bench/bench.rb +++ b/Util/bench/bench.rb @@ -441,6 +441,12 @@ def yield_upto(k) r.times { o.yield_upto(1000) { |x| s += x } } end +# a literal block handed to a library method that takes a BlockParam (ruby-bench send_cfunc_block) +bench("block_literal_cfunc") do + a = []; i = 0; m = n(2_000_000) + while i < m; a.reverse_each { }; i += 1; end +end + name = ARGV[0] unless BENCH.key?(name) if name == "--list" From 76b0873e4dd8f2a3c61785db08fe3d720ebe0e2c Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:53:02 +0200 Subject: [PATCH 67/94] WIP: JIT keyword entry matches and binds in one call Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Jit/JitKeywords.cs | 67 +++++++++++++++++++++++++++++ Src/Ruby/Runtime/Jit/JitRuntime.cs | 14 ++++++ Src/Ruby/Runtime/Jit/JitStub.cs | 23 +++++++--- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/Src/Ruby/Runtime/Jit/JitKeywords.cs b/Src/Ruby/Runtime/Jit/JitKeywords.cs index df4be509..480e881e 100644 --- a/Src/Ruby/Runtime/Jit/JitKeywords.cs +++ b/Src/Ruby/Runtime/Jit/JitKeywords.cs @@ -52,6 +52,7 @@ internal sealed class JitKeywords { internal RubySymbol/*!*/[]/*!*/ Symbols; internal int[]/*!*/ Positions; internal object[]/*!*/ Defaults; + internal bool[]/*!*/ Required; internal int Count { get { return LocalNames.Length; } } @@ -119,6 +120,7 @@ internal static JitKeywords TryRecognize(MethodDeclaration/*!*/ ast, RubyContext var symbols = new List(); var positions = new List(); var defaults = new List(); + var required = new List(); while (i < n) { var bind = statements[i] as SimpleAssignmentExpression; if (bind == null || bind.Operation != null) { break; } @@ -142,6 +144,7 @@ internal static JitKeywords TryRecognize(MethodDeclaration/*!*/ ast, RubyContext symbols.Add(context.CreateSymbol(read.Names[0], read.Encoding)); positions.Add(read.RequiredCountOrPosition); defaults.Add(defaultValue); + required.Add(read.DefaultValue == null); i++; } if (locals.Count != names.Length) { return null; } @@ -156,6 +159,7 @@ internal static JitKeywords TryRecognize(MethodDeclaration/*!*/ ast, RubyContext result.Symbols = symbols.ToArray(); result.Positions = positions.ToArray(); result.Defaults = defaults.ToArray(); + result.Required = required.ToArray(); return result; } @@ -180,5 +184,68 @@ internal object Value(object kw, int index) { object v = RubyOps.GetKeywordArgument(kw, Symbols[index], Positions[index]); return (v == RubyOps.DefaultArgument) ? Defaults[index] : v; } + + // ---- Matches and Value in one go: what a specialized entry calls ---------------------- + // + // A keyword call's hash is small, and Hash.TryGetValueByIdentity scans a small hash for the + // Symbol rather than probing for it (8 entries, Hash.IdentityScanLimit). Looking each declared + // keyword up once both binds it and counts the keys that are declared ones, so the hash has + // no key the method does not know exactly when that count is all the keys it has - which is + // what Matches answers with a scan of its own. Anything else takes the two steps above. + + private const int ScanLimit = 8; + + private bool Bind(Hash/*!*/ hash, int index, ref int found, out object value) { + if (hash.TryGetValueByIdentity(Symbols[index], Positions[index], out value)) { + found++; + return true; + } + value = Defaults[index]; + return !Required[index]; + } + + private static Hash SmallKeywordHash(object kw) { + var hash = kw as Hash; + return (hash != null && hash.IsKeywordArguments && hash.Count <= ScanLimit) ? hash : null; + } + + internal bool Read(object kw, out object v0) { + var hash = SmallKeywordHash(kw); + if (hash != null) { + int found = 0; + return Bind(hash, 0, ref found, out v0) && found == hash.Count; + } + bool ok = Matches(kw); + v0 = ok ? Value(kw, 0) : null; + return ok; + } + + internal bool Read(object kw, out object v0, out object v1) { + var hash = SmallKeywordHash(kw); + if (hash != null) { + int found = 0; + v1 = null; + return Bind(hash, 0, ref found, out v0) && Bind(hash, 1, ref found, out v1) && found == hash.Count; + } + bool ok = Matches(kw); + v0 = ok ? Value(kw, 0) : null; + v1 = ok ? Value(kw, 1) : null; + return ok; + } + + internal bool Read(object kw, out object v0, out object v1, out object v2) { + var hash = SmallKeywordHash(kw); + if (hash != null) { + int found = 0; + v1 = v2 = null; + return Bind(hash, 0, ref found, out v0) && Bind(hash, 1, ref found, out v1) && Bind(hash, 2, ref found, out v2) && + found == hash.Count; + } + bool ok = Matches(kw); + v0 = ok ? Value(kw, 0) : null; + v1 = ok ? Value(kw, 1) : null; + v2 = ok ? Value(kw, 2) : null; + return ok; + } } } diff --git a/Src/Ruby/Runtime/Jit/JitRuntime.cs b/Src/Ruby/Runtime/Jit/JitRuntime.cs index e20a7ccd..38707663 100644 --- a/Src/Ruby/Runtime/Jit/JitRuntime.cs +++ b/Src/Ruby/Runtime/Jit/JitRuntime.cs @@ -323,6 +323,20 @@ public static object KeywordValue(object/*!*/ keywords, object kw, int index) { return ((JitKeywords)keywords).Value(kw, index); } + // KeywordsMatch and KeywordValue in one call, for a method with up to three keywords. + + public static bool KeywordsRead1(object/*!*/ keywords, object kw, out object v0) { + return ((JitKeywords)keywords).Read(kw, out v0); + } + + public static bool KeywordsRead2(object/*!*/ keywords, object kw, out object v0, out object v1) { + return ((JitKeywords)keywords).Read(kw, out v0, out v1); + } + + public static bool KeywordsRead3(object/*!*/ keywords, object kw, out object v0, out object v1, out object v2) { + return ((JitKeywords)keywords).Read(kw, out v0, out v1, out v2); + } + public static RubyClass/*!*/ ClassOf(RubyContext/*!*/ context, object self) { IRubyObject obj = self as IRubyObject; return obj != null ? obj.ImmediateClass : context.GetImmediateClassOf(self); diff --git a/Src/Ruby/Runtime/Jit/JitStub.cs b/Src/Ruby/Runtime/Jit/JitStub.cs index 65aead9d..eb6fcad0 100644 --- a/Src/Ruby/Runtime/Jit/JitStub.cs +++ b/Src/Ruby/Runtime/Jit/JitStub.cs @@ -264,14 +264,25 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { if (_positional > 0 && code.ParameterTypes[_positional - 1] == JT.Obj) { test = And(test, Ast.Not(Ast.Call(typeof(RubyOps).GetMethod("IsKeywordArgumentsHash"), args[_positional - 1]))); } - test = And(test, Ast.Call(JitRuntime.M("KeywordsMatch"), Ast.Constant(_keywords, typeof(object)), kw)); keywordValues = new MSA.ParameterExpression[_keywords.Count]; for (int j = 0; j < keywordValues.Length; j++) { - var v = keywordValues[j] = Ast.Variable(typeof(object), "kw" + j); - values[_positional + j] = v; - test = And(test, Ast.Block( - Ast.Assign(v, Ast.Call(JitRuntime.M("KeywordValue"), Ast.Constant(_keywords, typeof(object)), kw, Ast.Constant(j))), - Ast.Constant(true))); + values[_positional + j] = keywordValues[j] = Ast.Variable(typeof(object), "kw" + j); + } + var info = Ast.Constant(_keywords, typeof(object)); + if (keywordValues.Length <= 3) { + // match and bind in one call, the values coming back through out parameters + var readArgs = new MSA.Expression[keywordValues.Length + 2]; + readArgs[0] = info; + readArgs[1] = kw; + Array.Copy(keywordValues, 0, readArgs, 2, keywordValues.Length); + test = And(test, Ast.Call(JitRuntime.M("KeywordsRead" + keywordValues.Length), readArgs)); + } else { + test = And(test, Ast.Call(JitRuntime.M("KeywordsMatch"), info, kw)); + for (int j = 0; j < keywordValues.Length; j++) { + test = And(test, Ast.Block( + Ast.Assign(keywordValues[j], Ast.Call(JitRuntime.M("KeywordValue"), info, kw, Ast.Constant(j))), + Ast.Constant(true))); + } } } From dfa68aa87a55263fc610516a9d48faaaf3873313 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:53:45 +0200 Subject: [PATCH 68/94] WIP: MatchData group count cache + allocation-free offsets in StringScanner; cached regex literal without closure Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/StringScanner/StringScanner.cs | 17 ++++++---- Src/Ruby/Builtins/MatchData.cs | 34 ++++++++++++++++++-- Src/Ruby/Runtime/RubyOps.cs | 12 +++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/Src/Libraries/StringScanner/StringScanner.cs b/Src/Libraries/StringScanner/StringScanner.cs index 5b8db7b7..7735c7eb 100644 --- a/Src/Libraries/StringScanner/StringScanner.cs +++ b/Src/Libraries/StringScanner/StringScanner.cs @@ -302,19 +302,19 @@ private bool MatchRegex(RubyRegex/*!*/ pattern, bool anchored, bool advance) { return false; } - int[] offsets = match.GetGroupOffsets(0, true); - if (offsets == null) { + int matchStart, matchEnd; + if (!match.TryGetGroupByteOffsets(0, out matchStart, out matchEnd)) { return false; } - int start = matchBase + offsets[0]; + int start = matchBase + matchStart; if (anchored && start != _position) { return false; } _match = match; _matchBase = matchBase; - return Matched(start, matchBase + offsets[1], advance); + return Matched(start, matchBase + matchEnd, advance); } private bool MatchLiteral(MutableString/*!*/ pattern, bool anchored, bool advance) { @@ -392,7 +392,8 @@ public static MutableString GetMatchSubgroup(ConversionStorage/*!*/ fixnumC return (named == null) ? null : self.ByteSlice(self._matchBase + named[0], named[1] - named[0]); } - int subgroup = Protocols.CastToFixnum(fixnumCast, index); + // An Integer index is the usual case; it converts to itself. + int subgroup = (index is int) ? (int)index : Protocols.CastToFixnum(fixnumCast, index); if (!self._matched) { return null; } @@ -407,8 +408,10 @@ public static MutableString GetMatchSubgroup(ConversionStorage/*!*/ fixnumC if (subgroup < 0 || subgroup >= count) { return null; } - int[] offsets = self._match.GetGroupOffsets(subgroup, true); - return (offsets == null) ? null : self.ByteSlice(self._matchBase + offsets[0], offsets[1] - offsets[0]); + int start, end; + return self._match.TryGetGroupByteOffsets(subgroup, out start, out end) + ? self.ByteSlice(self._matchBase + start, end - start) + : null; } private static string GroupName(object index) { diff --git a/Src/Ruby/Builtins/MatchData.cs b/Src/Ruby/Builtins/MatchData.cs index 4bc9c998..acbcfe5f 100644 --- a/Src/Ruby/Builtins/MatchData.cs +++ b/Src/Ruby/Builtins/MatchData.cs @@ -55,6 +55,11 @@ public partial class MatchData : IDuplicable, IRubyObjectState { // #string answers the same frozen String every time it is called, so it is made once. private MutableString _frozenInput; + // GroupCount, worked out once per match: it is asked for by every group access, and + // telling a hidden group from a Ruby one parses group names. Zero means not known yet + // (there is always group 0). + private int _groupCount; + public MutableString/*!*/ OriginalString { get { return _originalString; } } public RubyRegex Regexp { get { return _regexp; } } @@ -77,12 +82,16 @@ public MutableString/*!*/ FrozenOriginalString { /// public int GroupCount { get { + int count = _groupCount; + if (count > 0) { + return count; + } var groups = _match.Groups; - int count = groups.Count; + count = groups.Count; while (count > 1 && IsHiddenGroup(groups[count - 1])) { count--; } - return count; + return _groupCount = count; } } @@ -166,6 +175,7 @@ object IDuplicable.Duplicate(RubyContext/*!*/ context, bool copySingletonMembers public void InitializeFrom(MatchData/*!*/ other) { _match = other._match; + _groupCount = 0; _originalString = other._originalString; _encodedInput = other._encodedInput; _encoding = other._encoding; @@ -284,6 +294,26 @@ public int[] GetGroupOffsets(int groupIndex, bool inBytes) { return OffsetsOf(_match.Groups[groupIndex], inBytes); } + /// + /// What answers in bytes, without an array: false + /// when the group did not take part in the match. For a scanner that asks for an offset + /// pair on every token. + /// + public bool TryGetGroupByteOffsets(int groupIndex, out int start, out int end) { + if (groupIndex != 0) { // group 0 always exists + RequireExistingGroup(groupIndex); + } + // group 0 is the Match itself, which spares the group collection + Group group = (groupIndex == 0) ? _match : _match.Groups[groupIndex]; + if (!group.Success) { + start = end = 0; + return false; + } + start = ToByteIndex(group.Index); + end = ToByteIndex(group.Index + group.Length); + return true; + } + public int[] GetGroupOffsets(string/*!*/ name, bool inBytes) { if (!HasNamedGroup(name)) { throw RubyExceptions.CreateIndexError("undefined group name reference: {0}", name); diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index fa0797fc..58c5c088 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -2506,14 +2506,26 @@ public static object MatchString(MutableString str, RubyRegex/*!*/ regex, RubySc return result; } + // A literal without substitutions is built once and cached in its site's box, so every + // later evaluation only reads the box. The construction is split off: the delegate it + // makes captures the parameters, and a closure over parameters is allocated on entry to + // the method that declares them - on every evaluation, even one that never needs it. [Emitted] public static RubyRegex/*!*/ CreateRegexB(byte[]/*!*/ bytes, RubyEncoding/*!*/ encoding, RubyRegexOptions options, StrongBox regexpCache) { + return regexpCache.Value ?? CreateRegexBWorker(bytes, encoding, options, regexpCache); + } + + private static RubyRegex/*!*/ CreateRegexBWorker(byte[]/*!*/ bytes, RubyEncoding/*!*/ encoding, RubyRegexOptions options, StrongBox regexpCache) { Func createRegex = delegate { return new RubyRegex(CreateMutableStringB(bytes, encoding), options); }; return CreateRegexWorker(options, regexpCache, true, createRegex); } [Emitted] public static RubyRegex/*!*/ CreateRegexL(string/*!*/ str1, RubyEncoding/*!*/ encoding, RubyRegexOptions options, StrongBox regexpCache) { + return regexpCache.Value ?? CreateRegexLWorker(str1, encoding, options, regexpCache); + } + + private static RubyRegex/*!*/ CreateRegexLWorker(string/*!*/ str1, RubyEncoding/*!*/ encoding, RubyRegexOptions options, StrongBox regexpCache) { Func createRegex = delegate { return new RubyRegex(CreateMutableStringL(str1, encoding), options); }; return CreateRegexWorker(options, regexpCache, true, createRegex); } From bbc94ee87fbdd7092d9199fa28201e2a3fa4ff07 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:54:52 +0200 Subject: [PATCH 69/94] WIP: BlockParam state packed into 4 bytes (48 B) Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/BlockParam.cs | 45 +++++++++++++++++----------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/Src/Ruby/Runtime/BlockParam.cs b/Src/Ruby/Runtime/BlockParam.cs index 8e3c8dc9..c4e22c77 100644 --- a/Src/Ruby/Runtime/BlockParam.cs +++ b/Src/Ruby/Runtime/BlockParam.cs @@ -93,17 +93,16 @@ public DynamicMetaObject Restrict(Type/*!*/ type) { public sealed partial class BlockParam { // -- in -- private readonly Proc/*!*/ _proc; - // the enums are kept in bytes: a BlockParam is allocated for every call of a library method with a block - private readonly byte _callerKind; + // The small state is packed into bytes, which keeps a BlockParam at 48 bytes: one is allocated for every + // call of a library method with a block, and for every yield and Proc#call. + // bit 0: BlockCallerKind.Call; bit 1: the library method call taking this BlockParam is a proc converter. + private readonly byte _callerFlags; + private const int CallFlag = 1; + private const int LibProcConverterFlag = 2; // filled by module_eval: if not null than method definition uses the module private RubyModule _methodLookupModule; - // Is the library method call taking this BlockParam a proc converter? - // Used only for BlockParams that are passed to library method calls. - // Friend: RubyOps - internal readonly bool _isLibProcConverter; - // the thread the call taking the block runs on; a break cannot reach that call from another. // 0 for a yield or a Proc#call, which run the block on the thread that made the BlockParam. private readonly int _threadId; @@ -112,20 +111,22 @@ public sealed partial class BlockParam { public bool IsThreadRoot { get; set; } // -- out -- - private byte _returnReason; + // bits 0-1: BlockReturnReason; bits 2-3: the ProcKind of the block that set it + private byte _flow; private RuntimeFlowControl _targetFrame; - private byte _sourceProcKind; - internal BlockCallerKind CallerKind { get { return (BlockCallerKind)_callerKind; } } + internal BlockCallerKind CallerKind { get { return (_callerFlags & CallFlag) != 0 ? BlockCallerKind.Call : BlockCallerKind.Yield; } } // True while a lambda called through Proc#call (or as a define_method body) is running, so // a return in a block nested in it can unwind to that call. internal bool IsActiveLambdaCall; - internal ProcKind SourceProcKind { get { return (ProcKind)_sourceProcKind; } } - internal BlockReturnReason ReturnReason { get { return (BlockReturnReason)_returnReason; } set { _returnReason = (byte)value; } } + internal ProcKind SourceProcKind { get { return (ProcKind)(_flow >> 2); } } + internal BlockReturnReason ReturnReason { get { return (BlockReturnReason)(_flow & 3); } set { _flow = (byte)((_flow & ~3) | (int)value); } } internal RuntimeFlowControl TargetFrame { get { return _targetFrame; } } internal RubyModule MethodLookupModule { get { return _methodLookupModule; } set { _methodLookupModule = value; } } - internal bool IsLibProcConverter { get { return _isLibProcConverter; } } + // Is the library method call taking this BlockParam a proc converter? + // Used only for BlockParams that are passed to library method calls. + internal bool IsLibProcConverter { get { return (_callerFlags & LibProcConverterFlag) != 0; } } /// /// True if the library method got a literal block rather than a Proc object passed with &: @@ -136,7 +137,7 @@ public sealed partial class BlockParam { /// public bool IsLiteralBlock { get { - if (_isLibProcConverter) { + if (IsLibProcConverter) { return true; } // a call that converted a literal block stands for its frame by that Proc (see CreateRfcForMethod): @@ -163,24 +164,22 @@ public bool IsMethod { // friend: RubyOps internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind, bool isLibProcConverter) { _threadId = Environment.CurrentManagedThreadId; - _callerKind = (byte)callerKind; + _callerFlags = (byte)((callerKind == BlockCallerKind.Call ? CallFlag : 0) | (isLibProcConverter ? LibProcConverterFlag : 0)); _proc = proc; - _isLibProcConverter = isLibProcConverter; } // A BlockParam that is only ever used on the thread creating it, for the duration of a call: // a yield or a Proc#call. There the thread check in Break always passes, so skip it. internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind) { - _callerKind = (byte)callerKind; + _callerFlags = (byte)(callerKind == BlockCallerKind.Call ? CallFlag : 0); _proc = proc; } internal void SetFlowControl(BlockReturnReason reason, RuntimeFlowControl targetFrame, ProcKind sourceProcKind) { Debug.Assert((reason == BlockReturnReason.Break) == (targetFrame != null)); - _returnReason = (byte)reason; + _flow = (byte)((int)reason | ((int)sourceProcKind << 2)); _targetFrame = targetFrame; - _sourceProcKind = (byte)sourceProcKind; } internal object GetUnwinderResult(EvalUnwinder/*!*/ unwinder) { @@ -200,12 +199,12 @@ internal object GetUnwinderResult(EvalUnwinder/*!*/ unwinder) { /// internal bool BlockJumped(object returnValue) { // the block finished normally (no break/return/retry), which is what nearly every yield does: - if (_returnReason == (byte)BlockReturnReason.Undefined) { + if ((_flow & 3) == (int)BlockReturnReason.Undefined) { return false; } // if this method is a proc converter then the current frame is Proc.Converter, otherwise it is not available: - return RubyOps.MethodYieldRfc(_isLibProcConverter ? _proc.Converter : null, this, returnValue); + return RubyOps.MethodYieldRfc(IsLibProcConverter ? _proc.Converter : null, this, returnValue); } public bool Returning(object returnValue, out object result) { @@ -224,7 +223,7 @@ public object PropagateFlow(BlockParam/*!*/ yieldedBlock, object returnValue) { if (yieldedBlock.ReturnReason == BlockReturnReason.Break) { return Break(returnValue); } else { - _returnReason = (byte)yieldedBlock.ReturnReason; + ReturnReason = yieldedBlock.ReturnReason; return returnValue; } } @@ -370,7 +369,7 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* [Emitted] public static void LeaveProcConverter(BlockParam/*!*/ bfc) { Debug.Assert(bfc.Proc != null); - if (bfc._isLibProcConverter) { + if (bfc.IsLibProcConverter) { Debug.Assert(bfc.Proc.Converter != null); bfc.Proc.Converter.LeaveMethod(); } From 875e7938ec327b821e18b318726a762b8cfd8d33 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 09:56:10 +0200 Subject: [PATCH 70/94] WIP: JIT initializers publish their opening ivar run in one slot array Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Jit/JitCompiler.cs | 88 +++++++++++++++++++++++++++- Src/Ruby/Runtime/Jit/JitRuntime.cs | 62 ++++++++++++++++++++ Src/Ruby/Runtime/RubyInstanceData.cs | 2 +- 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/Src/Ruby/Runtime/Jit/JitCompiler.cs b/Src/Ruby/Runtime/Jit/JitCompiler.cs index 9ac66a38..be66d7a3 100644 --- a/Src/Ruby/Runtime/Jit/JitCompiler.cs +++ b/Src/Ruby/Runtime/Jit/JitCompiler.cs @@ -419,7 +419,93 @@ private static Type DelegateType(JT[]/*!*/ ps, JT ret) { if (body.RescueClauses != null || body.ElseStatements != null || body.EnsureStatements != null) { throw JitBailout.Instance; } - return EmitStatements(body.Statements, out type); + return EmitInitialIVars(body.Statements, out type) ?? EmitStatements(body.Statements, out type); + } + + // ---- an initializer's opening run of `@x = v' -------------------------------------- + // `def initialize(a, b) @a = a; @b = b; @n = 0 ...': a fresh object gets every one of these + // variables at once - one slot array, already in its final shape, published with one CAS - + // rather than one shape transition (and interlocked operation) per variable. Only for a run + // of writes of distinct variables whose values have no effect and read no instance variable + // (a parameter, a literal, self), so that doing all the stores together is the same as doing + // them one by one. Any object that is not a fresh RubyObject takes the writes one by one. + + private const int MinInitialIVars = 2; + + private MSA.Expression EmitInitialIVars(Statements statements, out JT type) { + type = JT.None; + if (_loop != null || statements == null) { return null; } + + var run = new List(); + var names = new List(); + foreach (var statement in statements) { + var assign = statement as SimpleAssignmentExpression; + var ivar = (assign != null && assign.Operation == null) ? assign.Left as InstanceVariable : null; + if (ivar == null || names.Contains(ivar.Name) || !IsInitialIVarValue(assign.Right)) { break; } + run.Add(assign); + names.Add(ivar.Name); + } + if (run.Count < MinInitialIVars) { return null; } + + var site = JitRuntime.InitialIVarsSite.TryCreate(names.ToArray()); + if (site == null) { return null; } + + var temps = new MSA.ParameterExpression[run.Count]; + var parts = new List(); + var stores = new List(); + var oneByOne = new List(); + var slots = Ast.Variable(typeof(object[]), "#slots"); + for (int i = 0; i < run.Count; i++) { + ++_nodes; + JT vt; + var v = Emit(run[i].Right, out vt); + temps[i] = Ast.Variable(typeof(object), "#iv" + i); + parts.Add(Ast.Assign(temps[i], Coerce(v, vt, JT.Obj))); + stores.Add(Ast.Assign(Ast.ArrayAccess(slots, Ast.Constant(i + 1)), temps[i])); + oneByOne.Add(Ast.Call(JitRuntime.M("SetIVar"), Ast.Constant(_context), _self, temps[i], Ast.Constant(new InstanceVariableSite(names[i])))); + } + _pure = false; + _ivarWrite = true; + + stores.Add(Ast.Call(JitRuntime.M("CommitInitialIVars"), _self, slots)); + parts.Add(Ast.Assign(slots, Ast.Call(JitRuntime.M("BeginInitialIVars"), _self, Ast.Constant(site)))); + parts.Add(Ast.IfThen( + Ast.Not(Ast.AndAlso(Ast.NotEqual(slots, Ast.Constant(null, typeof(object[]))), Ast.Block(typeof(bool), stores))), + Ast.Block(typeof(void), oneByOne) + )); + + int rest = statements.Count - run.Count; + if (rest == 0) { + // the value of the last assignment is the method's + type = JT.Obj; + parts.Add(temps[run.Count - 1]); + } else { + int skip = run.Count; + JT t = JT.Obj; + int k = 0; + foreach (var statement in statements) { + if (k++ < skip) { continue; } + parts.Add(Emit(statement, out t)); + } + type = t; + } + + var variables = new List(temps); + variables.Add(slots); + return Ast.Block(ClrType(type), variables, parts); + } + + /// A value an opening run of instance variable writes may have: see EmitInitialIVars. + private bool IsInitialIVarValue(RExpr/*!*/ value) { + if (value is SelfReference) { return true; } + var literal = value as Literal; + if (literal != null) { + object v = literal.Value; + return v == null || v is bool || v is int || v is long || v is double; + } + // Nothing before the run assigns a local, so a parameter still holds its argument. + var local = value as LocalVariable; + return local != null && local.DefinitionLexicalDepth == _scopeDepth && Array.IndexOf(_paramNames, local.Name) >= 0; } private MSA.Expression/*!*/ EmitStatements(Statements statements, out JT type) { diff --git a/Src/Ruby/Runtime/Jit/JitRuntime.cs b/Src/Ruby/Runtime/Jit/JitRuntime.cs index 8abc9092..adbadbe6 100644 --- a/Src/Ruby/Runtime/Jit/JitRuntime.cs +++ b/Src/Ruby/Runtime/Jit/JitRuntime.cs @@ -341,6 +341,68 @@ public static object SetIVar(RubyContext/*!*/ context, object self, object value return value; } + /// + /// The opening run of instance variable writes of a specialized method (JitCompiler. + /// EmitInitialIVars): the shape a fresh object has after all of them. + /// + public sealed class InitialIVarsSite { + internal readonly InstanceVariableShape/*!*/ Shape; + + private InitialIVarsSite(InstanceVariableShape/*!*/ shape) { + Shape = shape; + } + + /// Null if the variables would make an object too complex for shapes. + internal static InitialIVarsSite TryCreate(string/*!*/[]/*!*/ names) { + var shape = InstanceVariableShape.Root; + foreach (var name in names) { + shape = shape.GetChild(name); + if (shape == null) { return null; } + } + return new InitialIVarsSite(shape); + } + } + + /// + /// The slot array a fresh object would get the run's variables in - element 0 already the + /// final shape, the values to be stored by the caller - or null if self is not a RubyObject + /// without instance variables, or is frozen: then the writes are made one by one. + /// + public static object[] BeginInitialIVars(object self, InitialIVarsSite/*!*/ site) { + var obj = self as RubyObject; + if (obj == null) { return null; } + var data = obj._instanceData; + if (data != null && (data._frozen || data._ivars != InstanceVariableShape.EmptySlots)) { return null; } + int count = site.Shape.Count; + var result = new object[Math.Max(count, obj.ImmediateClass.InstanceVariableCapacity) + 1]; + result[0] = site.Shape; + return result; + } + + /// + /// Publishes the array BeginInitialIVars made and the caller filled. False if the object + /// got variables, or was frozen, in the meantime: the caller then makes the writes one by one. + /// + public static bool CommitInitialIVars(object self, object[]/*!*/ slots) { + var obj = (RubyObject)self; + var data = obj._instanceData; + if (data == null) { + var fresh = new RubyInstanceData(); + fresh._ivars = slots; + if (System.Threading.Interlocked.CompareExchange(ref obj._instanceData, fresh, null) == null) { + RubyInstanceData.UpdateCapacityHint(obj.ImmediateClass, ((InstanceVariableShape)slots[0]).Count); + return true; + } + data = obj._instanceData; + } + if (data._frozen) { return false; } + if (System.Threading.Interlocked.CompareExchange(ref data._ivars, slots, InstanceVariableShape.EmptySlots) == InstanceVariableShape.EmptySlots) { + RubyInstanceData.UpdateCapacityHint(obj.ImmediateClass, ((InstanceVariableShape)slots[0]).Count); + return true; + } + return false; + } + // An instance variable used as a numeric operand of an outlined loop (JitCompiler.NumericIVar): // unboxed while it holds the type it held when the loop was compiled, a deopt once it does not. diff --git a/Src/Ruby/Runtime/RubyInstanceData.cs b/Src/Ruby/Runtime/RubyInstanceData.cs index 861d0245..33bc4c06 100644 --- a/Src/Ruby/Runtime/RubyInstanceData.cs +++ b/Src/Ruby/Runtime/RubyInstanceData.cs @@ -275,7 +275,7 @@ private static int GetCapacity(RubyClass hintClass, int needed, int current) { return Math.Max(capacity, needed); } - private static void UpdateCapacityHint(RubyClass hintClass, int count) { + internal static void UpdateCapacityHint(RubyClass hintClass, int count) { if (hintClass != null && count > hintClass.InstanceVariableCapacity && count <= MaxCapacityHint) { // racy but benign: a lost update only costs a later object a move hintClass.InstanceVariableCapacity = count; From e19227ea410aaeb7c83400249fcee173285c4e9f Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 10:01:37 +0200 Subject: [PATCH 71/94] JIT keyword entry: match and bind the keywords in one call The entry of a specialized keyword method checked the keyword hash with KeywordsMatch (Hash.HasKeywords: a scan of every entry against every declared name) and then made one KeywordValue call per keyword. For up to three keywords it now makes a single JitRuntime.KeywordsReadN call: each declared keyword is looked up once by identity (Hash.TryGetValueByIdentity, which hits its position hint for a call that passes them in declaration order), which binds it and counts it, and the hash has no unknown key exactly when that count is all the keys it holds. Defaults, the no-keywords call and hashes too big for the identity scan go the old two-step way. The values come back through out parameters. The tree is the one of the previous commit (76b0873e); this commit only marks it gated. A/B of the two builds (best of 6, interleaved under bench-lock): Util/bench call_kwargs 0.268 s -> 0.246 s (spreads 0.268-0.299 / 0.246-0.294), 2 x 3M `add(left:, right:)' calls 767 ms -> 692 ms (spread 767-874). Against tipref 843df62e: ruby-bench keyword_args 1138 ms -> 587 ms (rb-compare, best of 3 rounds; box heavily loaded, tipref ran 1004-1138 ms across sessions). call_1arg/call_2arg/fib/ackermann/super_call unchanged within noise. Util/jit-matrix.rb identical to CRuby with and without -X:NoJIT. Gates: sp-tests 0, sp-lang 2934/0/0, keyword_args 1138ms -> 587ms Co-Authored-By: Claude Opus 5.5 From 5377669fd31aed426c19b151c27e7135f463ea7a Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 10:03:28 +0200 Subject: [PATCH 72/94] WIP: a method's keyword prologue no longer turns a positional nil into no keywords Co-Authored-By: Claude Opus 5.5 --- Src/Prism/PrismAstBridge.cs | 10 +++++++--- Src/Ruby/Runtime/Jit/JitKeywords.cs | 8 ++++---- Util/jit-matrix.rb | 2 ++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index 3b0ab873..2993e26a 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -2417,9 +2417,13 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List var prologue = new Statements(); // blocks and lambdas pass nil for parameters the caller omitted, so the - // optional-parameter default above does not fire for them - prologue.Add(new SimpleAssignmentExpression(kwVar, - new HashConstructor(new Maplet[0], true, span), "||", span)); + // optional-parameter default above does not fire for them. A method gets the + // default whenever the caller omitted it, so a nil there is one the caller passed + // positionally - `m(nil)' - which the separation check below has to refuse. + if (!isMethod) { + prologue.Add(new SimpleAssignmentExpression(kwVar, + new HashConstructor(new Maplet[0], true, span), "||", span)); + } if (strict) { foreach (var check in KeywordSeparationChecks(kwVar, mandatory, true, span)) { diff --git a/Src/Ruby/Runtime/Jit/JitKeywords.cs b/Src/Ruby/Runtime/Jit/JitKeywords.cs index 480e881e..cc681127 100644 --- a/Src/Ruby/Runtime/Jit/JitKeywords.cs +++ b/Src/Ruby/Runtime/Jit/JitKeywords.cs @@ -7,7 +7,6 @@ * PrismAstBridge.LowerKeywords turns `def m(a, k:, j: 1)' into `def m(a, ?kw? = {})' plus a * prologue that binds the keyword locals out of that hash: * - * ?kw? ||= {} * raise ArgumentError, ... if KeywordArgumentsTest(a) # separation checks * raise ArgumentError, ... unless KeywordArgumentsTest(?kw?) * unless KeywordArgumentsMatch(?kw?, [:k, :j], 1, false) # missing / unknown @@ -72,11 +71,12 @@ internal static JitKeywords TryRecognize(MethodDeclaration/*!*/ ast, RubyContext int n = statements.Count; int i = 0; - // ?kw? ||= {} + // ?kw? ||= {} (a block's prologue starts with it; a method's does not) if (i >= n) { return null; } var reset = statements[i] as SimpleAssignmentExpression; - if (reset == null || reset.Operation != "||" || !IsVar(reset.Left, kwVar) || !(reset.Right is HashConstructor)) { return null; } - i++; + if (reset != null && reset.Operation == "||" && IsVar(reset.Left, kwVar) && reset.Right is HashConstructor) { + i++; + } // The separation checks: an if/unless on KeywordArgumentsTest whose body only raises. while (i < n) { diff --git a/Util/jit-matrix.rb b/Util/jit-matrix.rb index 28ab7b71..fb0d388c 100644 --- a/Util/jit-matrix.rb +++ b/Util/jit-matrix.rb @@ -495,6 +495,7 @@ def no_kw_default_string(a:, b: "s") kt('kw opt unknown') { sp_kw_opt(c: 1) } kt('kw opt positional') { sp_kw_opt(1) } kt('kw opt positional Hash') { sp_kw_opt({ a: 1 }) } +kt('kw opt nil') { sp_kw_opt(nil) } warm { |i| sp_kw_opt } kt('kw opt hot without keywords') { sp_kw_opt } kt('kw opt hot without keywords, given one') { sp_kw_opt(b: 4) } @@ -506,6 +507,7 @@ def no_kw_default_string(a:, b: "s") kt('kw mixed missing keyword') { sp_kw_mixed(5) } t('kw mixed extra positional') { sp_kw_mixed(5, 6, k: 1) } t('kw mixed positional Hash') { sp_kw_mixed(5, { k: 1 }) } +t('kw mixed nil for the keywords') { sp_kw_mixed(5, nil) } kt('kw mixed overflow') { sp_kw_mixed(-2**62, k: 2**62, j: 2) } warm { |i| sp_kw_obj('x', k: i) } From ba9d2aaed675a7bcc368227f452d69d1c324a05f Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 10:07:31 +0200 Subject: [PATCH 73/94] Keyword prologue of a method: a positional nil is not "no keywords" LowerKeywords began every keyword prologue with `?kw? ||= {}', because a block or lambda is passed nil for a parameter its caller omitted. A method never is (its caller omitted it => the optional-parameter default fires), so there the statement only turned a nil passed positionally into an empty keyword hash: `def m(k: 1); m(nil)' returned 1 instead of raising, and so did `def m(x, k: 1); m(1, nil)', `Klass.new(nil)' for a keyword initializer, `method(:m).call(nil)' and `send(:m, nil)'. A method's prologue no longer has it, so the separation check raises MRI's "wrong number of arguments (given 1, expected 0)"; blocks and lambdas are unchanged. JitKeywords accepts the prologue with or without the statement. Also: Util/jit-matrix.rb probes `m(nil)' and a keyword initializer (instance variable stores, a frozen receiver, super with keywords); identical to CRuby with and without -X:NoJIT. Util/bench gets new_kwargs (a keyword initializer, which the JIT now specializes): tipref 0.518 s -> 0.376 s (best of 6 interleaved under bench-lock, tipref spread 0.518-0.664; CRuby 0.275 s), 592 -> 408 B per `Pt.new(x: i, y: 2)'. Spot specs (spec/core/method, proc, unboundmethod, struct, data, basicobject, Kernel#send/public_send, define_method, Class.new): 1134 examples, 0 failures, same as tipref. Gates: sp-tests 0, sp-lang 2934/0/0, new_kwargs 0.518s -> 0.376s Co-Authored-By: Claude Opus 5.5 --- Util/bench/bench.rb | 14 ++++++++++++++ Util/jit-matrix.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Util/bench/bench.rb b/Util/bench/bench.rb index c5f4e377..18a992e3 100644 --- a/Util/bench/bench.rb +++ b/Util/bench/bench.rb @@ -441,6 +441,20 @@ def yield_upto(k) r.times { o.yield_upto(1000) { |x| s += x } } end +# ---------------- keyword parameters ---------------- + +class KwPoint + def initialize(x:, y: 0) + @x = x + @y = y + end +end + +bench("new_kwargs") do + i = 0; m = n(1_000_000) + while i < m; KwPoint.new(x: i, y: 2); i += 1; end +end + name = ARGV[0] unless BENCH.key?(name) if name == "--list" diff --git a/Util/jit-matrix.rb b/Util/jit-matrix.rb index fb0d388c..856748b3 100644 --- a/Util/jit-matrix.rb +++ b/Util/jit-matrix.rb @@ -528,6 +528,32 @@ def no_kw_default_string(a:, b: "s") warm { |i| no_kw_default_string(a: i) } kt('kw String default') { no_kw_default_string(a: 1) } +# A keyword initializer: an instance variable store is a typed body too. +class KwIvars + def initialize(x:, y: 0) + @x = x + @y = y + end + + def pair + [@x, @y] + end +end +class KwIvarsSub < KwIvars + def initialize(x:, z: 5) + super(x: x, y: z) + end +end + +warm { |i| KwIvars.new(x: i) } +kt('kw initialize') { KwIvars.new(x: 1, y: 2).pair } +kt('kw initialize with the default') { KwIvars.new(x: 1).pair } +kt('kw initialize with Float and String') { KwIvars.new(x: 1.5, y: 'b').pair } +kt('kw initialize missing') { KwIvars.new(y: 1) } +kt('kw initialize unknown') { KwIvars.new(x: 1, w: 1) } +kt('kw initialize of a frozen object') { o = KwIvars.new(x: 1).freeze; o.send(:initialize, x: 2) } +kt('kw initialize via super') { KwIvarsSub.new(x: 3).pair } + def sp_kw_add(left:, right:) left - right end From ef69ce42970ddcd75db9b4c9ebddbac867b928bc Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 10:07:52 +0200 Subject: [PATCH 74/94] Add strscan_tokens benchmark (StringScanner tokenizer loop) Co-Authored-By: Claude Opus 5.5 --- Util/bench/bench.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Util/bench/bench.rb b/Util/bench/bench.rb index c5f4e377..3dfbf7d2 100644 --- a/Util/bench/bench.rb +++ b/Util/bench/bench.rb @@ -441,6 +441,24 @@ def yield_upto(k) r.times { o.yield_upto(1000) { |x| s += x } } end +# A tokenizer's inner loop: StringScanner scan/skip with Regexps and Strings, group access. +bench("strscan_tokens") do + require "strscan" + src = (%q({"name": "Team \\"A\\"", "score": [1, 22.5, -3e2], "ok": true, "x": null}, ) * 200).b + r = n(60) + r.times do + s = StringScanner.new(src) + until s.eos? + if s.skip(/\s+/) then next + elsif s.scan(/"((?:[^"\\]|\\.)*)"/n) then s[1] + elsif s.scan(/-?\d+(\.\d+)?([eE][-+]?\d+)?/) then (s[1] || s[2]) ? Float(s[0]) : Integer(s[0]) + elsif s.skip("true") || s.skip("null") then nil + else s.getch + end + end + end +end + name = ARGV[0] unless BENCH.key?(name) if name == "--list" From 90d42db60579ef282a69d589b3c2e3b14fce4c1c Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 10:13:12 +0200 Subject: [PATCH 75/94] WIP: Proc keeps its converter in the frame field (64 B) Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/Proc.cs | 46 ++++++++++++++++++++----- Src/Ruby/Runtime/BlockParam.cs | 4 +-- Src/Ruby/Runtime/RubyOps.FlowControl.cs | 4 +-- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/Src/Ruby/Builtins/Proc.cs b/Src/Ruby/Builtins/Proc.cs index 91446d98..32e3743f 100644 --- a/Src/Ruby/Builtins/Proc.cs +++ b/Src/Ruby/Builtins/Proc.cs @@ -67,7 +67,12 @@ public partial class Proc : RuntimeFlowControl, IDuplicable { private readonly BlockDispatcher/*!*/ _dispatcher; // TODO: can we remove _kind and use _method and Converter fields intead? - private ProcKind _kind; + // The ProcKind in the low bits, and SelfConverterFlag. + private int _kind; + private const int KindMask = 3; + + // The proc is its own converter: a call it was the literal block of converted it (see Converter). + private const int SelfConverterFlag = 4; /// /// The state few procs have, out of line: a Proc is allocated for every call with a literal block. @@ -84,13 +89,38 @@ private RareState/*!*/ Rare { get { return _rare ?? (_rare = new RareState()); } } - // The frame that converted this block to a proc: - internal RuntimeFlowControl Converter { get; set; } + /// + /// The frame that converted this block to a proc. No field of its own: a proc is only ever active as a + /// frame (RuntimeFlowControl) while it is its own converter, so the base class's field holds any other + /// converter, and SelfConverterFlag says the converter is this proc - active or not. + /// + internal RuntimeFlowControl Converter { + get { + var converter = _activeFlowControlScope; + return (converter != null) ? converter : ((_kind & SelfConverterFlag) != 0) ? this : null; + } + set { + if (value == this) { + _kind |= SelfConverterFlag; + } else { + _kind &= ~SelfConverterFlag; + _activeFlowControlScope = value; + } + } + } + + /// + /// The call this literal block is passed to converts it: the proc becomes that call's active frame. + /// + internal void ConvertAsFrame() { + _kind = (int)ProcKind.Proc | SelfConverterFlag; + _activeFlowControlScope = this; + } public ProcKind Kind { - get { return _kind; } + get { return (ProcKind)(_kind & KindMask); } // friend: RuntimeFlowControl - internal set { _kind = value; } + internal set { _kind = (_kind & ~KindMask) | (int)value; } } internal RubyLambdaMethodInfo Method { @@ -142,7 +172,7 @@ public int SourceLine { internal Proc(ProcKind kind, object self, RubyScope/*!*/ scope, BlockDispatcher/*!*/ dispatcher) { Assert.NotNull(scope, dispatcher); Debug.Assert(dispatcher.Method != null); - _kind = kind; + _kind = (int)kind; _self = self; _scope = scope; _dispatcher = dispatcher; @@ -273,7 +303,7 @@ public object Call(Proc procArg, object arg1) { var blockParam = RubyOps.CreateBfcForProcCall(this); try { // lambda calls are weird: - var result = (_kind == ProcKind.Lambda) ? + var result = (Kind == ProcKind.Lambda) ? RubyOps.YieldNoAutoSplat1(arg1, procArg, _self, blockParam) : RubyOps.Yield1(arg1, procArg, _self, blockParam); @@ -462,7 +492,7 @@ public object CallN(Proc procArg, object[]/*!*/ args) { #endregion public override string/*!*/ ToString() { - return String.Format("{0}: {1}:{2}", _kind, SourcePath ?? "(unknown)", SourceLine); + return String.Format("{0}: {1}:{2}", Kind, SourcePath ?? "(unknown)", SourceLine); } } } diff --git a/Src/Ruby/Runtime/BlockParam.cs b/Src/Ruby/Runtime/BlockParam.cs index c4e22c77..847e5ab1 100644 --- a/Src/Ruby/Runtime/BlockParam.cs +++ b/Src/Ruby/Runtime/BlockParam.cs @@ -355,9 +355,7 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* if (proc.Kind == ProcKind.Block) { // the literal block is new for this call: it is the call's flow-control frame (see CreateRfcForMethod) - proc._activeFlowControlScope = proc; - proc.Converter = proc; - proc.Kind = ProcKind.Proc; + proc.ConvertAsFrame(); isProcConverter = true; } else { isProcConverter = false; diff --git a/Src/Ruby/Runtime/RubyOps.FlowControl.cs b/Src/Ruby/Runtime/RubyOps.FlowControl.cs index a35f05b4..7f6d794b 100644 --- a/Src/Ruby/Runtime/RubyOps.FlowControl.cs +++ b/Src/Ruby/Runtime/RubyOps.FlowControl.cs @@ -35,9 +35,7 @@ public static partial class RubyOps { [Emitted] public static RuntimeFlowControl/*!*/ CreateRfcForMethod(Proc proc) { if (proc != null && proc.Kind == ProcKind.Block) { - proc._activeFlowControlScope = proc; - proc.Kind = ProcKind.Proc; - proc.Converter = proc; + proc.ConvertAsFrame(); return proc; } return InactiveRfc; From 864fc28cd68dbf46772b0b82d482787d79bb12cd Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 10:25:48 +0200 Subject: [PATCH 76/94] WIP: keyword calls go straight into a specialized keyword method Co-Authored-By: Claude Opus 5.5 --- .../Compiler/Ast/Expressions/MethodCall.cs | 8 + Src/Ruby/Runtime/Jit/JitKeywordCall.cs | 261 ++++++++++++++++++ Src/Ruby/Runtime/Jit/JitStub.cs | 82 ++++++ 3 files changed, 351 insertions(+) create mode 100644 Src/Ruby/Runtime/Jit/JitKeywordCall.cs diff --git a/Src/Ruby/Compiler/Ast/Expressions/MethodCall.cs b/Src/Ruby/Compiler/Ast/Expressions/MethodCall.cs index 3cad4e8a..be63ae06 100644 --- a/Src/Ruby/Compiler/Ast/Expressions/MethodCall.cs +++ b/Src/Ruby/Compiler/Ast/Expressions/MethodCall.cs @@ -96,6 +96,14 @@ public MethodCall(Expression target, string/*!*/ methodName, Arguments args, Blo Assert.NotNull(gen, transformedTarget); Assert.NotEmpty(methodName); + // -X:JIT: `m(x, k: v)' may go straight into m's specialized body (Runtime/Jit/JitKeywordCall.cs) + if (hasImplicitSelf && block == null && singleArgument == null && assignmentRhsArgument == null && node is MethodCall) { + var direct = IronRuby.Runtime.Jit.JitKeywordCall.TryTransform(gen, methodName, transformedTarget, arguments); + if (direct != null) { + return direct; + } + } + // Pass args in this order: // 1. instance // 2. block (if present) diff --git a/Src/Ruby/Runtime/Jit/JitKeywordCall.cs b/Src/Ruby/Runtime/Jit/JitKeywordCall.cs new file mode 100644 index 00000000..48e1f402 --- /dev/null +++ b/Src/Ruby/Runtime/Jit/JitKeywordCall.cs @@ -0,0 +1,261 @@ +/* **************************************************************************** + * + * -X:JIT - an experimental ZJIT-style method JIT for IronRuby. + * + * Keyword calls straight into a specialized body. + * + * A keyword call builds a Hash for its keywords, and the entry of a specialized keyword + * method (see JitKeywords) only reads the values back out of it. For the common shape - + * a receiverless call, Symbol-literal keywords, a few positional arguments and nothing + * else - the call is compiled with an inline cache in front of the dynamic call site: + * + * r = JitKeywordCall.Invoke(cache, self, p0, p1, k0, k1, k2) + * r != JitKeywordCall.NoDirect ? r : + * + * The cache remembers, per receiver class and global method version, which method the + * name resolves to. When that is a method the JIT has specialized, and the keywords the + * call passes are ones its prologue would bind without complaint, the values go straight + * into the specialized body's "direct" entry (JitStub.Direct) in declaration order, with + * the defaults of the keywords the call leaves out. Anything else - another kind of method, + * a method not specialized (yet), a type guard or the method version failing, a deopt - + * answers NoDirect and the call runs exactly as it did before, so the generic body raises + * whatever ArgumentError there is to raise. + * + * A receiverless call may call a method of any visibility, so resolving the name on the + * receiver's class with every visibility allowed is what the call site does too. Calls + * where refinements could be in play are never sent this way (JitRuntime.Disabled, + * RubyContext.HasRefinements), and neither is anything once tracing is on. + * + * ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Reflection; +using IronRuby.Builtins; +using IronRuby.Compiler; +using IronRuby.Compiler.Ast; +using IronRuby.Runtime.Calls; +using AstUtils = Microsoft.Scripting.Ast.Utils; + +namespace IronRuby.Runtime.Jit { + using MSA = System.Linq.Expressions; + using Ast = System.Linq.Expressions.Expression; + using RExpr = IronRuby.Compiler.Ast.Expression; + + public sealed class JitKeywordCall { + internal const int MaxPositional = 2; + internal const int MaxKeywords = 3; + + // A site whose receiver keeps changing class (or that keeps meeting new method versions) + // stops looking: resolving a name is far dearer than the hash it would save. + private const int MaxMisses = 8; + + /// "Not called here: make the call the ordinary way." + public static readonly object NoDirect = new object(); + + private readonly RubyContext/*!*/ _context; + private readonly string/*!*/ _name; + private readonly int _positional; + private readonly RubySymbol/*!*/[]/*!*/ _keys; // in the order the call passes them + + private sealed class State { + internal RubyClass Class; + internal int Version; + internal JitStub Stub; // null: the name does not resolve to a method this site can call directly + internal int[] Slots; // per declared keyword: the call's argument holding it, or -1 for its default + } + + private volatile State _state; + private int _misses; + private volatile bool _megamorphic; + + private JitKeywordCall(RubyContext/*!*/ context, string/*!*/ name, int positional, RubySymbol/*!*/[]/*!*/ keys) { + _context = context; + _name = name; + _positional = positional; + _keys = keys; + } + + private static readonly MethodInfo/*!*/ InvokeMethod = typeof(JitKeywordCall).GetMethod("Invoke"); + private static readonly FieldInfo/*!*/ NoDirectField = typeof(JitKeywordCall).GetField("NoDirect"); + + public static object Invoke(object/*!*/ site, object self, object p0, object p1, object k0, object k1, object k2) { + return ((JitKeywordCall)site).Call(self, p0, p1, k0, k1, k2); + } + + private object Call(object self, object p0, object p1, object k0, object k1, object k2) { + if (_megamorphic || JitRuntime.Disabled || _context.HasRefinements) { return NoDirect; } + + RubyClass cls = JitRuntime.ClassOf(_context, self); + int version = RubyModule.GlobalMethodVersion; + State state = _state; + if (state == null || state.Class != cls || state.Version != version) { + if (++_misses > MaxMisses) { + _megamorphic = true; + return NoDirect; + } + _state = state = Resolve(cls, version); + } + + JitStub stub = state.Stub; + if (stub == null) { return NoDirect; } + var direct = stub.Direct; + if (direct == null) { return NoDirect; } + + int[] slots = state.Slots; + object d0 = null, d1 = null, d2 = null; + switch (slots.Length) { + case 3: d2 = Pick(stub, 2, slots[2], k0, k1, k2); goto case 2; + case 2: d1 = Pick(stub, 1, slots[1], k0, k1, k2); goto case 1; + case 1: d0 = Pick(stub, 0, slots[0], k0, k1, k2); break; + } + return direct(self, p0, p1, d0, d1, d2); + } + + private static object Pick(JitStub/*!*/ stub, int declared, int slot, object k0, object k1, object k2) { + switch (slot) { + case 0: return k0; + case 1: return k1; + case 2: return k2; + default: return stub.Keywords.Defaults[declared]; + } + } + + /// + /// What means on an instance of , under method + /// version , for this site. + /// + private State/*!*/ Resolve(RubyClass/*!*/ cls, int version) { + var state = new State { Class = cls, Version = version }; + try { + var resolved = cls.ResolveMethod(_name, VisibilityContext.AllVisible); + var info = resolved.Found ? resolved.Info as RubyMethodInfo : null; + if (info == null) { return state; } + + var stub = info.GetDelegate().Target as JitStub; + if (stub == null || stub.Keywords == null || stub.Positional != _positional) { return state; } + + // The keywords this call passes must be ones the method's prologue binds without + // raising: each a declared one, and every required one among them. + var keywords = stub.Keywords; + var slots = new int[keywords.Count]; + int matched = 0; + for (int j = 0; j < slots.Length; j++) { + slots[j] = Array.IndexOf(_keys, keywords.Symbols[j]); + if (slots[j] >= 0) { + matched++; + } else if (keywords.Required[j]) { + return state; + } + } + if (matched != _keys.Length) { return state; } + + state.Stub = stub; + state.Slots = slots; + } catch (Exception) { + state.Stub = null; + } + return state; + } + + // ---- compilation of the call --------------------------------------------------------- + + /// + /// The call `name(p0, p1, k: v, ...)' with the inline cache in front of it, or null when the + /// call does not have that shape. + /// + internal static MSA.Expression TryTransform(AstGenerator/*!*/ gen, string/*!*/ methodName, MSA.Expression/*!*/ self, + Arguments arguments) { + + var options = gen.Context.RubyOptions; + if (!options.Jit || options.EnableTracing || options.Profile || JitRuntime.Disabled) { return null; } + if (arguments == null) { return null; } + + var exprs = arguments.Expressions; + int count = exprs.Length; + if (count == 0 || count - 1 > MaxPositional) { return null; } + var hash = exprs[count - 1] as HashConstructor; + if (hash == null || !hash.IsKeywordArguments) { return null; } + var maplets = hash.Maplets; + if (maplets.Length == 0 || maplets.Length > MaxKeywords) { return null; } + + var keys = new RubySymbol[maplets.Length]; + for (int i = 0; i < maplets.Length; i++) { + var key = maplets[i].Key as SymbolLiteral; + if (key == null || !(key.Value is string)) { return null; } + keys[i] = gen.Context.CreateSymbol((string)key.Value, key.Encoding); + // a repeated key is the hash's business + if (Array.IndexOf(keys, keys[i], 0, i) >= 0) { return null; } + } + int positional = count - 1; + for (int i = 0; i < positional; i++) { + if (exprs[i] is SplattedArgument || exprs[i] is HashConstructor) { return null; } + } + + var site = new JitKeywordCall(gen.Context, methodName, positional, keys); + + // The arguments are evaluated once, in source order, into temporaries both paths share. + var temps = new List(); + var body = new List(); + var selfTemp = Ast.Variable(typeof(object), "#kwself"); + temps.Add(selfTemp); + body.Add(Ast.Assign(selfTemp, AstUtils.Box(self))); + + var p = new MSA.Expression[MaxPositional]; + for (int i = 0; i < MaxPositional; i++) { + if (i < positional) { + var t = Ast.Variable(typeof(object), "#kwp" + i); + temps.Add(t); + body.Add(Ast.Assign(t, AstUtils.Box(exprs[i].TransformRead(gen)))); + p[i] = t; + } else { + p[i] = Ast.Constant(null, typeof(object)); + } + } + var k = new MSA.Expression[MaxKeywords]; + for (int i = 0; i < MaxKeywords; i++) { + if (i < maplets.Length) { + var t = Ast.Variable(typeof(object), "#kwv" + i); + temps.Add(t); + body.Add(Ast.Assign(t, AstUtils.Box(maplets[i].Value.TransformRead(gen)))); + k[i] = t; + } else { + k[i] = Ast.Constant(null, typeof(object)); + } + } + + // the ordinary call: the keyword hash, then the dynamic site + var hashArgs = new List(1 + 2 * maplets.Length); + hashArgs.Add(gen.CurrentScopeVariable); + for (int i = 0; i < maplets.Length; i++) { + hashArgs.Add(AstUtils.Box(Ast.Constant(keys[i]))); + hashArgs.Add(k[i]); + } + MethodInfo make; + switch (maplets.Length) { + case 1: make = Methods.MakeKeywordArgumentsHash1; break; + case 2: make = Methods.MakeKeywordArgumentsHash2; break; + default: make = Methods.MakeKeywordArgumentsHash3; break; + } + var makeHash = Ast.Call(make, hashArgs); + var siteBuilder = new CallSiteBuilder(gen, selfTemp, null); + for (int i = 0; i < positional; i++) { + siteBuilder.Add(p[i]); + } + siteBuilder.Add(makeHash); + var ordinary = siteBuilder.MakeCallAction(methodName, true); + + var result = Ast.Variable(typeof(object), "#kwresult"); + temps.Add(result); + body.Add(Ast.Assign(result, Ast.Call(InvokeMethod, + Ast.Constant(site, typeof(object)), selfTemp, p[0], p[1], k[0], k[1], k[2]))); + body.Add(Ast.Condition( + Ast.ReferenceNotEqual(result, Ast.Field(null, NoDirectField)), + result, + AstUtils.Convert(ordinary, typeof(object)), + typeof(object) + )); + return Ast.Block(typeof(object), temps, body); + } + } +} diff --git a/Src/Ruby/Runtime/Jit/JitStub.cs b/Src/Ruby/Runtime/Jit/JitStub.cs index eb6fcad0..434140ce 100644 --- a/Src/Ruby/Runtime/Jit/JitStub.cs +++ b/Src/Ruby/Runtime/Jit/JitStub.cs @@ -56,6 +56,15 @@ internal abstract class JitStub { private readonly int _positional; // delegate parameters that are positional parameters private readonly int _typedCount; // parameters of the typed body + // A specialized keyword method's entry for JitKeywordCall: (self, p0, p1, k0, k1, k2) with the + // keyword values in declaration order and unused slots null. Answers JitKeywordCall.NoDirect + // when a guard fails. Null when the method is not specialized. + private volatile Func _direct; + + internal Func Direct { get { return _direct; } } + internal JitKeywords Keywords { get { return _keywords; } } + internal int Positional { get { return _positional; } } + // profiling feedback private int _calls; private readonly int[]/*!*/ _argKind; // 0 = unseen, 1 = int, 2 = double, 3 = other/mixed, 4 = long @@ -140,12 +149,14 @@ private void GiveUp() { Console.Error.WriteLine("[jit] declined {0}/{1} arity {2} (passed the pre-screen)", _declaringModule.Name ?? "main", _ast.Name, _arity); } + _direct = null; Install(_generic); } /// Deoptimize: drop the specialized entry and let profiling start over. internal void Invalidate() { JitRuntime.Invalidations++; + _direct = null; // A method that was already declined must not fall back into profiling here. Install(_givenUp ? _generic : null); _calls = 0; @@ -167,6 +178,7 @@ internal void VersionChanged() { if (_givenUp) { return; } if (++_respecializations > MaxRespecializations) { _givenUp = true; + _direct = null; Install(_generic); return; } @@ -213,6 +225,10 @@ private bool TrySpecialize() { var entry = BuildEntry(code, callee, version, needsClassGuard ? _receiverClass : null); Install(entry.Compile()); + if (_keywords != null && !needsClassGuard && _positional <= JitKeywordCall.MaxPositional && + _keywords.Count <= JitKeywordCall.MaxKeywords) { + _direct = BuildDirectEntry(code, callee, version).Compile(); + } JitRuntime.CompileTicks += Stopwatch.GetTimestamp() - start; JitRuntime.Specialized++; @@ -345,6 +361,72 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls) { return Ast.Lambda(_generic.GetType(), body, "jitentry$" + _ast.Name, all); } + /// + /// The entry JitKeywordCall calls with the keyword values already bound (and the defaults of the + /// ones the call left out filled in): the same guards as BuildEntry's, but a failed one - or a + /// deopt - answers NoDirect, and the caller then makes the call the ordinary way. + /// + private MSA.Expression>/*!*/ BuildDirectEntry(JitCode/*!*/ code, + MSA.Expression/*!*/ callee, int version) { + + var self = Ast.Parameter(typeof(object), "self"); + var slots = new MSA.ParameterExpression[JitKeywordCall.MaxPositional + JitKeywordCall.MaxKeywords]; + for (int i = 0; i < slots.Length; i++) { + slots[i] = Ast.Parameter(typeof(object), "s" + i); + } + var values = new MSA.Expression[_typedCount]; + for (int i = 0; i < _positional; i++) { + values[i] = slots[i]; + } + for (int j = 0; j < _keywords.Count; j++) { + values[_positional + j] = slots[JitKeywordCall.MaxPositional + j]; + } + + MSA.Expression test = Ast.Call(JitRuntime.M("VersionOk"), Ast.Constant(version)); + // the separation check on the last positional, as in BuildEntry + if (_positional > 0 && code.ParameterTypes[_positional - 1] == JT.Obj) { + test = And(test, Ast.Not(Ast.Call(typeof(RubyOps).GetMethod("IsKeywordArgumentsHash"), values[_positional - 1]))); + } + var typedArgs = new MSA.Expression[_typedCount + 1]; + typedArgs[0] = self; + for (int i = 0; i < _typedCount; i++) { + switch (code.ParameterTypes[i]) { + case JT.Int: + test = And(test, Ast.TypeIs(values[i], typeof(int))); + typedArgs[i + 1] = Ast.Unbox(values[i], typeof(int)); + break; + case JT.Dbl: + test = And(test, Ast.TypeIs(values[i], typeof(double))); + typedArgs[i + 1] = Ast.Unbox(values[i], typeof(double)); + break; + case JT.Lng: + test = And(test, Ast.Call(JitRuntime.M("IsIntegral"), values[i])); + typedArgs[i + 1] = Ast.Call(JitRuntime.M("ToLong"), values[i]); + break; + default: + typedArgs[i + 1] = values[i]; + break; + } + } + + MSA.Expression fast = Ast.Invoke(callee, typedArgs); + if (code.ReturnType == JT.Lng) { + fast = Ast.Call(JitRuntime.M("NarrowLong"), fast); + } else if (fast.Type != typeof(object)) { + fast = Ast.Convert(fast, typeof(object)); + } + var noDirect = Ast.Field(null, typeof(JitKeywordCall).GetField("NoDirect")); + if (code.CanDeopt) { + fast = Ast.TryCatch(fast, Ast.Catch(typeof(JitDeoptException), noDirect)); + } + var body = Ast.Condition(test, fast, noDirect, typeof(object)); + + var all = new MSA.ParameterExpression[slots.Length + 1]; + all[0] = self; + Array.Copy(slots, 0, all, 1, slots.Length); + return Ast.Lambda>(body, "jitdirect$" + _ast.Name, all); + } + private static MSA.Expression/*!*/ And(MSA.Expression left, MSA.Expression/*!*/ right) { return (left != null) ? Ast.AndAlso(left, right) : right; } From cf1ca9f872201c4921083fa7ef1c0172c444534e Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 11:12:38 +0200 Subject: [PATCH 77/94] Regexp/StringScanner speedups for ruby-json: gate the three commits below Content is in the three commits before this one (this commit only records that their combined tree passed every gate): - RubyRegex: a CLR Regex that has run 10000 matches is rebuilt with RegexOptions.Compiled (plain and \G-anchored variants counted separately, so a StringScanner-only pattern compiles only the regex it runs). Same engine and pattern tree, identical results; the interpreter is ~3x slower on JSON's string pattern. - Kernel#Integer/#Float (ruby4.rb): a String with exception: true goes straight to the C# parser instead of through the block and its rescue frame (also drops the __numeric_conversion__ frame from backtraces, closer to MRI). - ProtocolConversionAction.Make caches the last action per runtime instead of a locked dictionary lookup on every library call. - MatchData caches GroupCount (was re-parsing group names on every access) and offers TryGetGroupByteOffsets; StringScanner uses it instead of allocating int[2] per match/group, and takes an Integer index without a conversion site. - RubyOps.CreateRegexL/B: a cached literal no longer allocates the closure for the factory delegate on every evaluation. - Util/bench/bench.rb: strscan_tokens. Differential tests vs CRuby 4.0.6 and tipref (regex tier-up past the threshold, Integer()/Float() incl. errors, StringScanner groups/named/pre/post/mutation): output identical to tipref; ruby-json parse result identical to CRuby. Specs (tipref vs this): spec/library/stringscanner+core/regexp+core/matchdata 681 ex 0 F both; spec/core/string+kernel/Integer+Float 4307 ex 0 F both. Measured (bench-lock, interleaved; box load 20-70, noise +-20-40%): - ruby-bench ruby-json (rb-compare, 3 rounds): 1546 -> 1364 ms (1.13x) - ruby-json driver (100 parses, best of 3): 914 -> 748 ms - strscan_tokens: 2.18 -> 1.66 s; per op (best of 24 batches): scan(str re) 764 -> 525 ns, self[1] 223 -> 147 ns, Integer() 894 -> 449 ns, Float() 1298 -> 433 ns; scan allocates 695 -> 527 B - bench.rb --list sweep (57 benches, best of 3): geomean 0.98, all outliers re-run 6x alternating order: within noise (0.97-1.05) Gates: sp-tests 0, sp-lang 2934/0/0, ruby-json 1546ms -> 1364ms Co-Authored-By: Claude Opus 5.5 From ee45fe8de96bcadd111f3f1087ce5c47b72aaed3 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 11:12:45 +0200 Subject: [PATCH 78/94] WIP: StringScanner #skip/#match?/#exist?/#skip_until defer the MatchData; ASCII literal skip without copying; gsub no-match path - RubyRegex.MatchWindowBounds finds a scanning match with Regex.EnumerateMatches over the window (no Match, no MatchData) where CLR chars are the bytes, and returns a PendingMatch that re-runs the same CLR regex over the same immutable text when a group is asked for (identical MatchData, unaffected by later mutation of the subject). - StringScanner uses it for the operations that answer a length/position. - MatchLiteral compares an anchored ASCII pattern in place (no ToByteArray). - RubyRegex.Matches asks for the first match directly (Match/NextMatch is how Regex.Matches is defined), so a #gsub with no match builds no collection. skip(/\s*/) allocates 367 -> 71 B. Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/StringScanner/StringScanner.cs | 94 ++++++++++++--- Src/Ruby/Builtins/RubyRegex.cs | 116 ++++++++++++++++++- 2 files changed, 191 insertions(+), 19 deletions(-) diff --git a/Src/Libraries/StringScanner/StringScanner.cs b/Src/Libraries/StringScanner/StringScanner.cs index 7735c7eb..e8e50e4e 100644 --- a/Src/Libraries/StringScanner/StringScanner.cs +++ b/Src/Libraries/StringScanner/StringScanner.cs @@ -65,6 +65,13 @@ public Error(string message, Exception inner) : base(message ?? "Error", inner) /// private MatchData _match; + /// + /// A regexp match whose MatchData is only made when its groups are asked for (see + /// CurrentMatch): the operations that answer a length or a position (#skip, #match?, + /// #exist?, #skip_until) rarely have their groups read. + /// + private RubyRegex.PendingMatch _pendingMatch; + /// The byte offset _match's own offsets are relative to. private int _matchBase; @@ -100,6 +107,7 @@ private void InitializeFrom(StringScanner/*!*/ other) { _matchStart = other._matchStart; _matchEnd = other._matchEnd; _match = other._match; + _pendingMatch = other._pendingMatch; _matchBase = other._matchBase; } @@ -243,6 +251,7 @@ private void ResetScanner() { private void ClearMatch() { _matched = false; _match = null; + _pendingMatch = default(RubyRegex.PendingMatch); _matchBase = 0; _matchStart = 0; _matchEnd = 0; @@ -275,15 +284,49 @@ private void ClearMatch() { } private bool Match(object/*!*/ pattern, bool anchored, bool advance) { + return Match(pattern, anchored, advance, false); + } + + /// The caller answers a length or a position, not text: the + /// MatchData can wait until something asks for a group. + private bool Match(object/*!*/ pattern, bool anchored, bool advance, bool lazyGroups) { var regex = pattern as RubyRegex; return (regex != null) - ? MatchRegex(regex, anchored, advance) + ? MatchRegex(regex, anchored, advance, lazyGroups) : MatchLiteral((MutableString)pattern, anchored, advance); } - private bool MatchRegex(RubyRegex/*!*/ pattern, bool anchored, bool advance) { + /// + /// The MatchData of the last regexp match, made now if the match left it pending; null + /// when the last match had no pattern. + /// + private MatchData CurrentMatch { + get { + if (_match == null && !_pendingMatch.IsEmpty) { + _match = _pendingMatch.Complete(); + _pendingMatch = default(RubyRegex.PendingMatch); + } + return _match; + } + } + + private bool MatchRegex(RubyRegex/*!*/ pattern, bool anchored, bool advance, bool lazyGroups) { ClearMatch(); + if (lazyGroups && !_fixedAnchor) { + int boundsStart, boundsEnd; + RubyRegex.PendingMatch pending; + if (!pattern.MatchWindowBounds(_scanString, CharIndexOf(_position), anchored, out boundsStart, out boundsEnd, out pending)) { + return false; + } + if (anchored && boundsStart != _position) { + return false; + } + _pendingMatch = pending; + _matchBase = 0; + return Matched(boundsStart, boundsEnd, advance); + } + MatchData match; int matchBase = 0; if (_fixedAnchor) { @@ -320,6 +363,21 @@ private bool MatchRegex(RubyRegex/*!*/ pattern, bool anchored, bool advance) { private bool MatchLiteral(MutableString/*!*/ pattern, bool anchored, bool advance) { ClearMatch(); + if (anchored && pattern.IsAscii()) { + // The usual #skip("{"): an ASCII pattern's characters are its bytes, so it can be + // compared where it stands instead of being copied out to a byte array first. + int length = pattern.GetByteCount(); + if (_position + length > ByteLength) { + return false; + } + for (int i = 0; i < length; i++) { + if (_scanString.GetByte(_position + i) != pattern.GetByte(i)) { + return false; + } + } + return Matched(_position, _position + length, advance); + } + byte[] needle = pattern.ToByteArray(); int start = -1; if (anchored) { @@ -385,10 +443,10 @@ public static MutableString GetMatchSubgroup(ConversionStorage/*!*/ fixnumC } // A match with no pattern behind it has no named groups at all, so every name // is undefined rather than merely unmatched. - if (self._match == null || !self._match.HasNamedGroup(name)) { + if (self.CurrentMatch == null || !self.CurrentMatch.HasNamedGroup(name)) { throw RubyExceptions.CreateIndexError("undefined group name reference: {0}", name); } - int[] named = self._match.GetGroupOffsets(name, true); + int[] named = self.CurrentMatch.GetGroupOffsets(name, true); return (named == null) ? null : self.ByteSlice(self._matchBase + named[0], named[1] - named[0]); } @@ -397,11 +455,11 @@ public static MutableString GetMatchSubgroup(ConversionStorage/*!*/ fixnumC if (!self._matched) { return null; } - if (self._match == null) { + if (self.CurrentMatch == null) { return (subgroup == 0) ? self.ByteSlice(self._matchStart, self._matchEnd - self._matchStart) : null; } - int count = self._match.GroupCount; + int count = self.CurrentMatch.GroupCount; if (subgroup < 0) { subgroup += count; } @@ -409,7 +467,7 @@ public static MutableString GetMatchSubgroup(ConversionStorage/*!*/ fixnumC return null; } int start, end; - return self._match.TryGetGroupByteOffsets(subgroup, out start, out end) + return self.CurrentMatch.TryGetGroupByteOffsets(subgroup, out start, out end) ? self.ByteSlice(self._matchBase + start, end - start) : null; } @@ -429,11 +487,11 @@ public static RubyArray Captures(StringScanner/*!*/ self) { return null; } var result = new RubyArray(); - if (self._match == null) { + if (self.CurrentMatch == null) { return result; } - for (int i = 1; i < self._match.GroupCount; i++) { - int[] offsets = self._match.GetGroupOffsets(i, true); + for (int i = 1; i < self.CurrentMatch.GroupCount; i++) { + int[] offsets = self.CurrentMatch.GetGroupOffsets(i, true); result.Add((offsets == null) ? null : self.ByteSlice(self._matchBase + offsets[0], offsets[1] - offsets[0])); } return result; @@ -454,11 +512,11 @@ public static RubyArray ValuesAt(ConversionStorage/*!*/ fixnumCast, StringS [RubyMethod("named_captures")] public static Hash/*!*/ NamedCaptures(RubyContext/*!*/ context, StringScanner/*!*/ self) { var result = new Hash(context); - if (!self._matched || self._match == null) { + if (!self._matched || self.CurrentMatch == null) { return result; } - foreach (var name in self._match.GetGroupNames()) { - int[] offsets = self._match.GetGroupOffsets(name, true); + foreach (var name in self.CurrentMatch.GetGroupNames()) { + int[] offsets = self.CurrentMatch.GetGroupOffsets(name, true); result[MutableString.CreateAscii(name).Freeze()] = (offsets == null) ? null : self.ByteSlice(self._matchBase + offsets[0], offsets[1] - offsets[0]); } @@ -470,7 +528,7 @@ public static RubyArray ValuesAt(ConversionStorage/*!*/ fixnumCast, StringS if (!self._matched) { return null; } - return (self._match != null) ? self._match.GroupCount : 1; + return (self.CurrentMatch != null) ? self.CurrentMatch.GroupCount : 1; } [RubyMethod("fixed_anchor?")] @@ -502,7 +560,7 @@ public static bool EndOfLine(StringScanner/*!*/ self) { [RubyMethod("exist?")] public static int? Exist(ConversionStorage/*!*/ stringCast, StringScanner/*!*/ self, object pattern) { - if (!self.Match(ToPattern(stringCast, pattern), false, false)) { + if (!self.Match(ToPattern(stringCast, pattern), false, false, true)) { return null; } return self._matchEnd - self._position; @@ -563,7 +621,7 @@ public static MutableString ToString(StringScanner/*!*/ self) { [RubyMethod("match?")] public static int? Match(ConversionStorage/*!*/ stringCast, StringScanner/*!*/ self, object pattern) { - if (!self.Match(ToPattern(stringCast, pattern), true, false)) { + if (!self.Match(ToPattern(stringCast, pattern), true, false, true)) { return null; } return self._matchEnd - self._matchStart; @@ -702,7 +760,7 @@ public static object SearchFull(ConversionStorage/*!*/ stringCast [RubyMethod("skip")] public static int? Skip(ConversionStorage/*!*/ stringCast, StringScanner/*!*/ self, object pattern) { - if (!self.Match(ToPattern(stringCast, pattern), true, true)) { + if (!self.Match(ToPattern(stringCast, pattern), true, true, true)) { return null; } return self._position - self._previousPosition; @@ -710,7 +768,7 @@ public static object SearchFull(ConversionStorage/*!*/ stringCast [RubyMethod("skip_until")] public static int? SkipUntil(ConversionStorage/*!*/ stringCast, StringScanner/*!*/ self, object pattern) { - if (!self.Match(ToPattern(stringCast, pattern), false, true)) { + if (!self.Match(ToPattern(stringCast, pattern), false, true, true)) { return null; } return self._position - self._previousPosition; diff --git a/Src/Ruby/Builtins/RubyRegex.cs b/Src/Ruby/Builtins/RubyRegex.cs index 2cc83d1c..b2032cac 100644 --- a/Src/Ruby/Builtins/RubyRegex.cs +++ b/Src/Ruby/Builtins/RubyRegex.cs @@ -984,6 +984,103 @@ public MatchData MatchWindow(MutableString/*!*/ input, int start, bool anchored, return MatchData.Create(match, input, freezeInput, str, kcode, offset, this, astral); } + /// + /// A match MatchWindowBounds found, whose MatchData has not been made yet. Most scanning + /// operations only want to know where the match is, and building the CLR Match with its + /// groups and the MatchData around it costs more than finding the match does. + /// makes the MatchData MatchWindow would have made, by running the + /// same CLR regex over the same immutable text again - so a later change to the subject + /// does not change the groups, as it does not in MRI either. + /// + public struct PendingMatch { + internal RubyRegex _owner; + internal Regex _regex; + internal MutableString _input; + internal string _text; + internal RubyEncoding _kcode; + internal int _start; + internal MatchData _data; + + public bool IsEmpty { + get { return _owner == null; } + } + + public MatchData/*!*/ Complete() { + if (_data != null) { + return _data; + } + Match match = _regex.Match(_text, _start, _text.Length - _start); + Debug.Assert(match.Success); + return MatchData.Create(match, _input, false, _text, _kcode, 0, _owner, false); + } + } + + /// + /// MatchWindow without a frozen copy of the input, answering the byte offsets of the whole + /// match and a for its groups. Where one CLR character stands + /// for one byte of the input - a binary or single-byte k-coding, or a subject whose + /// characters are all single bytes - the match is found without building any match + /// objects at all; anything else goes through MatchWindow. + /// + public bool MatchWindowBounds(MutableString/*!*/ input, int start, bool anchored, + out int matchStart, out int matchEnd, out PendingMatch pending) { + + matchStart = matchEnd = 0; + pending = default(PendingMatch); + + string str; + RubyEncoding kcode = null; + bool astral; + Regex regex = Transform(ref kcode, input, 0, out str, out astral, !anchored); + + bool bytesAreChars = (kcode != null) ? kcode.IsSingleByteCharacterSet : input.DetectSingleByteCharacters(); + if (astral || !bytesAreChars) { + MatchData data = MatchWindow(input, start, anchored, false); + if (data == null || !data.TryGetGroupByteOffsets(0, out matchStart, out matchEnd)) { + return false; + } + pending._owner = this; + pending._data = data; + return true; + } + + if (str == null) { + return false; + } + if (start < 0) { + start += str.Length; + } + if (start < 0 || start > str.Length) { + return false; + } + + if (anchored) { + regex = AnchorAtSearchStart(regex); + } + + // A span cut at "start" is the window Match(str, start, length) searches: \A, ^, \G + // and lookbehind all stop at its first character. + foreach (ValueMatch match in regex.EnumerateMatches(str.AsSpan(start))) { + int clrStart = start + match.Index; + // MatchData's offsets: the CLR offset is the byte offset, capped at the subject + matchStart = Math.Min(clrStart, str.Length); + matchEnd = Math.Min(clrStart + match.Length, str.Length); + if (kcode == null) { + int byteCount = input.GetByteCount(); + matchStart = Math.Min(matchStart, byteCount); + matchEnd = Math.Min(matchEnd, byteCount); + } + pending._owner = this; + pending._regex = regex; + pending._input = input; + pending._text = str; + pending._kcode = kcode; + pending._start = start; + return true; + } + return false; + } + /// /// The same pattern with a \G in front of it, which in a forward CLR match stands for the /// position the search was told to start at. The body goes in a non-capturing group so that @@ -1144,7 +1241,24 @@ private static Match LastMatch(Regex/*!*/ regex, string/*!*/ input, int start, b RubyEncoding kcode = null; bool astral; Regex regex = Transform(ref kcode, input, 0, out str, out astral); - IList matches = astral ? MatchesOverPairs(regex, str) : (IList)regex.Matches(str); + IList matches; + if (astral) { + matches = MatchesOverPairs(regex, str); + } else { + // Match and NextMatch are what Regex.Matches is defined as; asking for the first + // one directly answers "no match" - the usual outcome of a #gsub over a short + // string - without the collection. + Match match = regex.Match(str); + if (!match.Success) { + return Array.Empty(); + } + var list = new List(); + do { + list.Add(match); + match = match.NextMatch(); + } while (match.Success); + matches = list; + } var result = new MatchData[matches.Count]; if (result.Length > 0 && inputMayMutate) { From c30864154660d14ce5ae940761b421ffc8750167 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 11:22:24 +0200 Subject: [PATCH 79/94] JIT simple initializers: optional literal defaults, arity 6, one-shot ivar run Class#new on a class with a plain Ruby initialize spent most of its time in the generic method body: a RubyMethodScope + MutableTuple per call (~200 B), then one shape transition (an interlocked CAS) per instance variable. The method JIT already takes `def initialize(a, b) @a = a; @b = b end`, but not the common shapes: - optional parameters with a literal default (nil/true/false/Fixnum/Float): the entry fills an omitted one in, so `def initialize(left = nil, right = nil)` is specialized (gcbench's Node); arity goes from 3 to 6 (JitStub4..6); - parameters that are only ever stored into an ivar stay boxed (no unbox on entry, no re-box on the store), and `@n = 0' boxes its literal once; - an initializer's opening run of `@x = ' writes to distinct variables gives a fresh RubyObject its final-shape slot array and instance data in one CAS (JitRuntime.Begin/CommitInitialIVars); anything else (existing ivars, frozen, non-RubyObject self) falls back to the one-by-one writes. A write to a frozen object in a typed body now deopts, so FrozenError comes from the generic body; the entry runs the generic body after the catch handler rather than in it, and its own frame (jitentry$) is left out of Ruby backtraces. Adds Util/bench/bench.rb object_new_init. Allocation per Node.new (4 ivars, optional args): 344 B -> 160 B; C.new(1,2,3,4): 360 -> 160 B. Node.new loop (3M, bench-lock, best of 5): 1.163 s -> 0.643 s. ruby-bench, tipref 843df62e -> this, one bench-lock call, interleaved, best of 2x3 itrs (box heavily loaded, tipref spread ~20-30%): object-new-initialize 656 -> 402 ms, gcbench 17866 -> 11093 ms, splay 1072 -> 904 ms, binarytrees 2152 -> 2165 ms (flat). Util/bench/bench.rb --list: no change beyond noise (best of 3, spreads up to 80%). Spot specs (core/class, kernel ivar/freeze/dup/clone/caller, basicobject, exception backtrace, marshal dump, objectspace): same 0 failures / 20 errors as tipref. Gates: sp-tests 0, sp-lang 2934/0/0, gcbench 17.87s -> 11.09s Co-Authored-By: Claude Opus 5.5 --- Util/bench/bench.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Util/bench/bench.rb b/Util/bench/bench.rb index c5f4e377..bf45409b 100644 --- a/Util/bench/bench.rb +++ b/Util/bench/bench.rb @@ -441,6 +441,17 @@ def yield_upto(k) r.times { o.yield_upto(1000) { |x| s += x } } end +class NewTarget + def initialize(left = nil, right = nil) + @left = left; @right = right; @i = 0; @j = 0 + end +end + +bench("object_new_init") do + i = 0; m = n(2_000_000); x = nil + while i < m; x = NewTarget.new(x, nil); x = nil if i % 64 == 0; i += 1; end +end + name = ARGV[0] unless BENCH.key?(name) if name == "--list" From e00db07b4792184adc9afe06f16699b0dc169411 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 11:24:16 +0200 Subject: [PATCH 80/94] WIP: JIT array literals of up to five elements Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/Jit/JitCompiler.cs | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Src/Ruby/Runtime/Jit/JitCompiler.cs b/Src/Ruby/Runtime/Jit/JitCompiler.cs index be66d7a3..e2fbedae 100644 --- a/Src/Ruby/Runtime/Jit/JitCompiler.cs +++ b/Src/Ruby/Runtime/Jit/JitCompiler.cs @@ -621,6 +621,9 @@ private bool IsInitialIVarValue(RExpr/*!*/ value) { var ret = node as ReturnStatement; if (ret != null) { return EmitReturn(ret, out type); } + var arrayLiteral = node as ArrayConstructor; + if (arrayLiteral != null) { return EmitArrayLiteral(arrayLiteral, out type); } + var body = node as Body; if (body != null) { if (body.RescueClauses != null || body.ElseStatements != null || body.EnsureStatements != null) { @@ -632,6 +635,26 @@ private bool IsInitialIVarValue(RExpr/*!*/ value) { throw Bail("node: " + node.GetType().Name); } + /// + /// `[a, b]': a new Array of boxed values, made by the helper the generic body calls. Building + /// one has no effect anything could observe, so it is no obstacle to a deopt. A splat (or + /// anything else Emit does not know) among the elements bails as usual. + /// + internal const int MaxArrayLiteral = 5; + + private MSA.Expression/*!*/ EmitArrayLiteral(ArrayConstructor/*!*/ node, out JT type) { + var items = node.Arguments.Expressions; + if (items.Length > MaxArrayLiteral) { throw Bail("array literal of " + items.Length); } + var values = new List(items.Length); + foreach (var item in items) { + JT t; + var e = Emit(item, out t); + values.Add(Coerce(e, t, JT.Obj)); + } + type = JT.Obj; + return IronRuby.Compiler.Methods.MakeArrayOpCall(values); + } + private MSA.Expression/*!*/ EmitLiteral(Literal/*!*/ literal, out JT type) { object v = literal.Value; if (v is int) { type = JT.Int; return Ast.Constant((int)v, typeof(int)); } @@ -1401,6 +1424,16 @@ private bool Walk(RExpr/*!*/ node) { return Walk(retArgs.Expressions[0]); } + var arrayLiteral = node as ArrayConstructor; + if (arrayLiteral != null) { + var items = arrayLiteral.Arguments.Expressions; + if (items.Length > JitCompiler.MaxArrayLiteral) { return No(node); } + foreach (var item in items) { + if (!Walk(item)) { return false; } + } + return true; + } + // break/next are only emitted in loop mode, which a method body never is, so they // fall through to the rejection below along with every other unknown node. From d630eb8a8b3b6858250d8a6180a444e96df9d71a Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 11:24:30 +0200 Subject: [PATCH 81/94] Proc 64 B, BlockParam 48 B: converter kept in the frame field, small state in bytes Gates and measurements for the two commits before this one (bbc94ee8, 90d42db6). BlockParam: the caller kind and the proc-converter bit share one readonly byte, the return reason and the source ProcKind another; with the two bools that leaves 4 bytes of small state, so a BlockParam is 48 B (was 56 B, 64 B on the speedup tip). Proc: a proc is only ever active as a frame while it is its own converter, so the RuntimeFlowControl field it inherits holds any other converter and a flag in _kind says "converted by itself"; the separate Converter field is gone and a library call converting a literal block writes one reference instead of two. Proc is 64 B (was 72 B, 80 B on the speedup tip). Kind masks the flag off. Bytes per call (GetAllocatedBytesForCurrentThread, 1M calls), speedup tip -> now: [].reverse_each {} / [].index {} 168 -> 112 obj.ruby_func {} 232 -> 216 proc {} 168 -> 112 lambda {} 248 -> 176 Whole Util/bench runs (GC.GetTotalAllocatedBytes, load-independent): block_literal_cfunc 390.5 -> 278.5 MB, yield_0 646.5 -> 582.8, proc_call 230.7 -> 198.9, lambda_call 230.5 -> 198.7, proc_new_call 146.7 -> 111.8; unchanged (+-0.2 MB) for fiber_switch, float_arith, case_when, sort_strings, raise_rescue, call_empty, block_pass, map_loop. Timing (the box was heavily loaded, spreads 20-130%): rb-compare best of 3 rounds x 5 iterations send_cfunc_block 1413 -> 1294 ms (1.09x; 1.17-1.19x in two quieter runs on the previous commit), send_rubyfunc_block within noise (1.11x, 1.00x, 0.85x in three runs; interleaved ruby_func {} microbench 6 pairs: mine faster in 4, medians 198 -> 184 ns). Full Util/bench --list sweep (3 rounds, interleaved): no change outside the noise; the outliers (float_arith, fiber_switch) re-run at 6 rounds are within their spread and allocate exactly the same. Spot specs (proc, kernel lambda/proc/block_given/loop/tap/then/catch/binding, binding, array reverse_each/index/each/map, integer times, module define_method/refine, instance_eval/exec, enumerator, thread/new, method/symbol to_proc, fiber, each_slice, hash each, exception): 187 files, 1557 examples, 0 failures - same as the tip. Differential scripts vs CRuby 4.0.6 (break/next/redo/return through library and Ruby calls, escaped procs, dup/clone/lambda copies, threads, fibers, Proc identity): identical. Gates: sp-tests 0, sp-lang 2934/0/0, send_cfunc_block 1413ms -> 1294ms Co-Authored-By: Claude Opus 5.5 From 4cf8d3e00f11960cedd10ecb07507281b600dcc3 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 11:27:48 +0200 Subject: [PATCH 82/94] StringScanner: defer MatchData for length-answering scans; gate the commit below Content is in ee45fe8d (this commit only records that its tree passed every gate): #skip/#match?/#exist?/#skip_until find the match with Regex.EnumerateMatches and build the MatchData only when a group is read (re-running the same CLR regex on the same immutable text, so the groups are exactly what MatchWindow would have produced, also after the subject is mutated); an anchored ASCII String pattern is compared in place; a #gsub with no match builds no MatchCollection. Differential tests (StringScanner skip/match?/exist?/skip_until/scan/check x fixed_anchor x 6 subjects incl. binary, multibyte, astral; groups, named, captures, values_at, pre/post_match, dup, unscan, string mutation and replacement after a deferred match; gsub/gsub!/sub/scan incl. $~ after no match): output identical to tipref. spec/library/stringscanner+core/regexp+ core/matchdata 681 ex 0 F; spec/core/string+kernel/Integer+Float 4307 ex 0 F (same as tipref). Measured (bench-lock, interleaved; box load 20-70, noise +-20-40%): - ruby-bench ruby-json, tipref -> this (rb-compare, 3 rounds): 1670 -> 1181 ms (1.41x) - strscan_tokens, previous gate -> this (best of 6, alternating): 1.93 -> 1.60 s - skip(/\s*/) allocates 367 -> 71 B per call Gates: sp-tests 0, sp-lang 2934/0/0, ruby-json 1670ms -> 1181ms Co-Authored-By: Claude Opus 5.5 From 0238aacecb89c19e8c326323ed9a6ad2787d836f Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 11:32:09 +0200 Subject: [PATCH 83/94] Receiverless keyword calls go straight into a specialized keyword method With the keyword method specialized, what was left of a call like `add(left: 1, right: i)' was the caller's work: the keyword Hash it builds (~200 B, ~40% of ruby-bench keyword_args), the dispatch, and the entry reading the values back out of the Hash. A receiverless (or self.) call with 1-3 Symbol-literal keywords, at most two positional arguments and nothing else is now compiled with an inline cache in front of its dynamic site (Runtime/Jit/JitKeywordCall.cs). The arguments are evaluated once, in order, into temporaries; the cache resolves the name on the receiver's class (all visibilities, as a receiverless call may), keyed by class and global method version, and when it is a specialized keyword method whose prologue would bind exactly these keywords, calls its direct entry (JitStub.Direct: the keyword values in declaration order, defaults filled in, the same type and version guards as the ordinary entry). Any other case - a different kind of method, method_missing, unknown or missing keywords, a failed guard, a deopt, refinements anywhere, tracing - answers NoDirect and the call is made exactly as before, Hash and all, from the same temporaries. A site whose receiver class keeps changing gives up after 8 misses. Only with -X:JIT. Util/jit-matrix.rb probes the direct path (private target, override in a subclass, singleton method, method_missing, overflow/Float/String values); identical to CRuby with and without -X:NoJIT. A 50-seed random keyword-method differential (signatures, defaults, values, missing/unknown keywords) is identical with the direct path and without it. Measured under bench-lock, best of 3 interleaved (the box was very loaded: tipref's keyword_args ran 2.3 s here vs 1.0 s earlier): ruby-bench keyword_args: tipref 2299 ms, previous commit 1196 ms, this 304 ms (spreads 2299-2321 / 1196-1236 / 304-382; CRuby 246 ms unloaded). 2 x 3M `add(left:, right:)' in a method: 1606 -> 478 ms. Allocation per call 256 -> 48 B. A receiverless keyword call to a method the JIT does not specialize: 2282 -> 2248 ms (no cost). `ir -e 1': best 2189 -> 2123 ms, median 2421 -> 2463 (noise). Spot specs (method, proc, unboundmethod, struct, data, basicobject, Kernel#send/public_send, define_method, Class.new, refine, private): 1193 examples, 0 failures, same as tipref. Gates: sp-tests 0, sp-lang 2934/0/0, keyword_args 1196ms -> 304ms (tipref 2299ms) Co-Authored-By: Claude Opus 5.5 --- Util/jit-matrix.rb | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Util/jit-matrix.rb b/Util/jit-matrix.rb index 856748b3..babf1863 100644 --- a/Util/jit-matrix.rb +++ b/Util/jit-matrix.rb @@ -554,6 +554,47 @@ def initialize(x:, z: 5) kt('kw initialize of a frozen object') { o = KwIvars.new(x: 1).freeze; o.send(:initialize, x: 2) } kt('kw initialize via super') { KwIvarsSub.new(x: 3).pair } +# A receiverless keyword call goes straight into the specialized body when it can +# (JitKeywordCall): what it resolves to has to follow the receiver's class. +class KwCaller + def sp_kw_scale(x, by: 2) + x * by + end + private :sp_kw_scale + + def go(x) + sp_kw_scale(x, by: 3) + end + + def go_missing(x) + sp_kw_not_here(x, by: 3) + end + + def method_missing(name, *args, **kw) + [name, args, kw] + end +end +class KwCallerSub < KwCaller + def sp_kw_scale(x, by: 2) + [:sub, x, by] + end +end + +kc = KwCaller.new +warm { |i| kc.go(i) } +kt('kw call into a private method') { kc.go(5) } +kt('kw call, a Float') { kc.go(0.5) } +kt('kw call, overflow') { kc.go(2**62) } +kt('kw call, a String') { kc.go('ab') } +kt('kw call, overridden in a subclass') { KwCallerSub.new.go(5) } +kt('kw call, the superclass again') { kc.go(5) } +kt('kw call to method_missing') { kc.go_missing(1) } +def kc.sp_kw_scale(x, by: 2) + :singleton +end +kt('kw call, a singleton method') { kc.go(5) } +kt('kw call, another instance') { KwCaller.new.go(5) } + def sp_kw_add(left:, right:) left - right end From 1790353c4df746c8540069dae4767677dd79c7af Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 11:42:33 +0200 Subject: [PATCH 84/94] JIT: array literals of up to five elements (gates for the commit before) The method JIT and OSR now take `[a, b]' (no splat, at most five elements: the fixed-arity RubyOps.MakeArrayN helpers the generic body uses). Building an array has no observable effect, so it does not make a body unsafe to deopt. This makes binarytrees' bottom_up_tree a typed, self-recursive body with no RubyMethodScope: def bottom_up_tree(depth) return [nil, nil] unless depth > 0 depth -= 1 [bottom_up_tree(depth), bottom_up_tree(depth)] end ruby-bench binarytrees, tipref 843df62e -> this, one bench-lock call, interleaved, best of 2x3 itrs (tipref spread ~30%): 2377 -> 1443 ms. Micro spot check (best of 5): array_index, while_loop, fib, ackermann unchanged. Differential tests vs CRuby (arrays from typed bodies are fresh, mutable, unfrozen; Bignum elements; OSR loops building arrays): identical. Gates: sp-tests 0, sp-lang 2934/0/0, binarytrees 2.377s -> 1.443s Co-Authored-By: Claude Opus 5.5 From b2beaa66fca8ecc0f7699f1c73a04a42b57679a5 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 13:08:01 +0200 Subject: [PATCH 85/94] WIP: reuse block scopes (from sp/w2-block-scope) without growing BlockParam/Proc Brings the block scope reuse of sp/w2-block-scope (843df62e..fcea2410) onto speedup with block-pass: the BlockParam keeps the parked scope in its break-target slot while no break is pending, the proc keeps it in its rare state, and neither keeps a scope on the first call. Adds yield_once_call and then_call benchmarks. Co-Authored-By: Claude Opus 5.5 --- Src/Libraries/Builtins/MutableStringOps.cs | 2 + Src/Ruby/Builtins/Binding.cs | 1 + Src/Ruby/Builtins/Proc.cs | 38 ++ Src/Ruby/Builtins/TracePoint.cs | 5 + Src/Ruby/Compiler/Ast/BlockDefinition.cs | 31 +- Src/Ruby/Compiler/Ast/ScopeBuilder.cs | 32 ++ .../Compiler/ReflectionCache.Generated.cs | 6 + Src/Ruby/Runtime/BlockParam.cs | 68 +++- Src/Ruby/Runtime/Calls/RubyMethodInfo.cs | 1 + Src/Ruby/Runtime/ObjectTracking.cs | 3 + Src/Ruby/Runtime/RubyContext.cs | 3 + Src/Ruby/Runtime/RubyOps.cs | 89 +++++ Src/Ruby/Runtime/RubyScope.cs | 67 +++- Util/bench/bench.rb | 18 + Util/block-scope-reuse-matrix.rb | 351 ++++++++++++++++++ 15 files changed, 700 insertions(+), 15 deletions(-) create mode 100644 Util/block-scope-reuse-matrix.rb diff --git a/Src/Libraries/Builtins/MutableStringOps.cs b/Src/Libraries/Builtins/MutableStringOps.cs index 1dd5aaee..df5d2dd3 100644 --- a/Src/Libraries/Builtins/MutableStringOps.cs +++ b/Src/Libraries/Builtins/MutableStringOps.cs @@ -3100,12 +3100,14 @@ private static Union, MutableString> ToReplacement( [RubyMethod("gsub")] public static object ReplaceAll(ConversionStorage/*!*/ tosConversion, RubyScope/*!*/ scope, MutableString/*!*/ self, [DefaultProtocol, NotNull]RubyRegex/*!*/ pattern) { + scope.MarkCaptured(); return new Enumerator((_, block) => BlockReplaceAll(tosConversion, scope, block, self, pattern)); } [RubyMethod("gsub!")] public static object ReplaceAllInPlace(ConversionStorage/*!*/ tosConversion, RubyScope/*!*/ scope, MutableString/*!*/ self, [DefaultProtocol, NotNull]RubyRegex/*!*/ pattern) { + scope.MarkCaptured(); return new Enumerator((_, block) => BlockReplaceInPlace(tosConversion, scope, block, self, pattern, true)); } diff --git a/Src/Ruby/Builtins/Binding.cs b/Src/Ruby/Builtins/Binding.cs index 392c9341..a2d2ad26 100644 --- a/Src/Ruby/Builtins/Binding.cs +++ b/Src/Ruby/Builtins/Binding.cs @@ -61,6 +61,7 @@ public Binding(RubyScope/*!*/ localScope) public Binding(RubyScope/*!*/ localScope, object self) { Assert.NotNull(localScope); + localScope.MarkCaptured(); _localScope = localScope; _self = self; } diff --git a/Src/Ruby/Builtins/Proc.cs b/Src/Ruby/Builtins/Proc.cs index 32e3743f..3176965f 100644 --- a/Src/Ruby/Builtins/Proc.cs +++ b/Src/Ruby/Builtins/Proc.cs @@ -74,6 +74,9 @@ public partial class Proc : RuntimeFlowControl, IDuplicable { // The proc is its own converter: a call it was the literal block of converted it (see Converter). private const int SelfConverterFlag = 4; + // A single call of the proc (a yield or Proc#call) has finished (see KeepReusableScope). + private const int CalledFlag = 8; + /// /// The state few procs have, out of line: a Proc is allocated for every call with a literal block. /// @@ -81,6 +84,9 @@ private sealed class RareState { public RubyLambdaMethodInfo Method; public RefinementActivation RefinementOverride; public string SymbolName; + // The scope of a finished call of this proc that nothing captured, for the next call on the + // same thread to reuse (see RubyOps.CreateReusableBlockScope). + public RubyBlockScope ReusableScope; } private RareState _rare; @@ -123,6 +129,37 @@ public ProcKind Kind { internal set { _kind = (_kind & ~KindMask) | (int)value; } } + /// + /// The scope a finished call kept (see KeepReusableScope), or null. + /// + internal RubyBlockScope ReusableScope { + get { var r = _rare; return (r != null) ? r.ReusableScope : null; } + set { + // (another thread may be making the rare state too: keep the one that is there) + var r = _rare ?? System.Threading.Interlocked.CompareExchange(ref _rare, new RareState(), null) ?? _rare; + r.ReusableScope = value; + } + } + + // The ReusableScope is taken for a call of the proc. + internal void TakeReusableScope() { + _rare.ReusableScope = null; + } + + /// + /// Whether a single call of the proc that has finished keeps its scope for the next call: not on the first + /// call, which most often is the only one - a literal block is a new Proc for every call it is passed to. + /// The flag is only ever set: the kind and converter bits are written before the proc's first call. + /// + internal bool KeepReusableScope() { + int kind = _kind; + if ((kind & CalledFlag) != 0) { + return true; + } + _kind = kind | CalledFlag; + return false; + } + internal RubyLambdaMethodInfo Method { get { var r = _rare; return (r != null) ? r.Method : null; } } @@ -176,6 +213,7 @@ internal Proc(ProcKind kind, object self, RubyScope/*!*/ scope, BlockDispatcher/ _self = self; _scope = scope; _dispatcher = dispatcher; + scope.MarkCaptured(); } protected Proc(Proc/*!*/ proc) diff --git a/Src/Ruby/Builtins/TracePoint.cs b/Src/Ruby/Builtins/TracePoint.cs index 0d057d1b..6a887497 100644 --- a/Src/Ruby/Builtins/TracePoint.cs +++ b/Src/Ruby/Builtins/TracePoint.cs @@ -212,6 +212,11 @@ public static void Deliver(TraceEventInfo/*!*/ info) { return; } + // a hook can keep the scope (TracePoint#binding): it is not reused for another block call + if (info.Scope != null) { + info.Scope.MarkCaptured(); + } + var tracePoints = _enabledTracePoints; Thread thread = null; diff --git a/Src/Ruby/Compiler/Ast/BlockDefinition.cs b/Src/Ruby/Compiler/Ast/BlockDefinition.cs index 45ccbcde..441aaec5 100644 --- a/Src/Ruby/Compiler/Ast/BlockDefinition.cs +++ b/Src/Ruby/Compiler/Ast/BlockDefinition.cs @@ -249,18 +249,39 @@ public BlockDefinition(LexicalScope/*!*/ definedScope, Parameters parameters, St Ast.Empty() ); + // A body that needs its scope but defines no closure over it can reuse the scope of the + // previous call of the block, unless something captured that (see RubyScope.IsCaptured). + bool reuseScope = lazyScope == null && !hasNestedReturn && !gen.TraceEnabled && !gen.CompilerOptions.IsEval; + + body = gen.Traceable + ? new TraceReturnExpression(lazyScope ?? scopeVariable, gen.AddReturnTarget(body), TraceEvents.BReturn | TraceEvents.Return, gen.SourcePath, Location.End.Line) + : gen.AddReturnTarget(body); + + if (reuseScope) { + // the body stays in statement position: as a call argument it would have to be spilled + var result = scope.DefineHiddenVariable("#result", typeof(object)); + bool keepLocals = scope.HasReusableLocals; + MSA.Expression leave = Methods.LeaveReusableBlockScope.OpCall(scopeVariable, AstUtils.Constant(keepLocals)); + if (keepLocals) { + leave = Ast.IfThen(leave, scope.MakeClearLocals()); + } + body = Ast.Block( + Ast.Assign(result, AstUtils.Convert(body, typeof(object))), + leave, + result + ); + } + body = scope.CreateScope( scopeVariable, - (lazyScope != null ? Methods.CreateBlockScopeIfTraced : Methods.CreateBlockScope).OpCall(new AstExpressions { - scope.MakeLocalsStorage(), + (lazyScope != null ? Methods.CreateBlockScopeIfTraced : reuseScope ? Methods.CreateReusableBlockScope : Methods.CreateBlockScope).OpCall(new AstExpressions { + reuseScope ? scope.MakeReusableLocalsStorage(blockParameter) : scope.MakeLocalsStorage(), scope.GetVariableNamesExpression(), blockParameter, selfParameter, EnterInterpretedFrameExpression.Instance }), - gen.Traceable - ? new TraceReturnExpression(lazyScope ?? scopeVariable, gen.AddReturnTarget(body), TraceEvents.BReturn | TraceEvents.Return, gen.SourcePath, Location.End.Line) - : gen.AddReturnTarget(body) + body ); gen.LeaveBlockDefinition(); diff --git a/Src/Ruby/Compiler/Ast/ScopeBuilder.cs b/Src/Ruby/Compiler/Ast/ScopeBuilder.cs index 01c77505..e1da7135 100644 --- a/Src/Ruby/Compiler/Ast/ScopeBuilder.cs +++ b/Src/Ruby/Compiler/Ast/ScopeBuilder.cs @@ -219,6 +219,38 @@ private int LiftedVisibleVariableCount { return result; } + /// + /// Whether a block that reuses its scope (see RubyOps.CreateReusableBlockScope) reuses the + /// scope's locals tuple too: one of up to 8 locals, cleared when the call finishes. + /// + internal bool HasReusableLocals { + get { + int count = LiftedVisibleVariableCount; + return count > 0 && count <= 8 && _parameters == null; + } + } + + public MSA.Expression/*!*/ MakeReusableLocalsStorage(MSA.Expression/*!*/ blockParameter) { + if (!HasReusableLocals) { + return MakeLocalsStorage(); + } + return Ast.Assign(_localsTuple, Ast.Coalesce( + Ast.TypeAs(Methods.TakeReusableLocals.OpCall(blockParameter), _localsTuple.Type), + Ast.New(_localsTuple.Type) + )); + } + + public MSA.Expression/*!*/ MakeClearLocals() { + Debug.Assert(HasReusableLocals); + int count = LiftedVisibleVariableCount; + var result = new AstExpressions(count + 1); + for (int i = 0; i < count; i++) { + result.Add(Ast.Assign(GetVariableAccessor(_localsTuple, i), AstUtils.Constant(null))); + } + result.Add(AstUtils.Empty()); + return Ast.Block(result); + } + public MSA.Expression/*!*/ GetVariableNamesExpression() { if (LiftedVisibleVariableCount == 0) { return Ast.Constant(null, typeof(string[])); diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index a0c38b05..20f6062a 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -106,6 +106,8 @@ public static partial class Methods { private static MethodInfo _CreateBlockScopeIfTraced; public static MethodInfo/*!*/ CreateBlockScopeLazy { get { return _CreateBlockScopeLazy ?? (_CreateBlockScopeLazy = CallInstruction.CacheFunc(RubyOps.CreateBlockScopeLazy)); } } private static MethodInfo _CreateBlockScopeLazy; + public static MethodInfo/*!*/ CreateReusableBlockScope { get { return _CreateReusableBlockScope ?? (_CreateReusableBlockScope = CallInstruction.CacheFunc(RubyOps.CreateReusableBlockScope)); } } + private static MethodInfo _CreateReusableBlockScope; public static MethodInfo/*!*/ CreateBoundMember { get { return _CreateBoundMember ?? (_CreateBoundMember = CallInstruction.CacheFunc(RubyOps.CreateBoundMember)); } } private static MethodInfo _CreateBoundMember; public static MethodInfo/*!*/ CreateBoundMissingMember { get { return _CreateBoundMissingMember ?? (_CreateBoundMissingMember = CallInstruction.CacheFunc(RubyOps.CreateBoundMissingMember)); } } @@ -422,6 +424,10 @@ public static partial class Methods { private static MethodInfo _LeaveLambdaReturnScope; public static MethodInfo/*!*/ LeaveLoop { get { return _LeaveLoop ?? (_LeaveLoop = CallInstruction.CacheAction(RubyOps.LeaveLoop)); } } private static MethodInfo _LeaveLoop; + public static MethodInfo/*!*/ LeaveReusableBlockScope { get { return _LeaveReusableBlockScope ?? (_LeaveReusableBlockScope = CallInstruction.CacheFunc(RubyOps.LeaveReusableBlockScope)); } } + private static MethodInfo _LeaveReusableBlockScope; + public static MethodInfo/*!*/ TakeReusableLocals { get { return _TakeReusableLocals ?? (_TakeReusableLocals = CallInstruction.CacheFunc(RubyOps.TakeReusableLocals)); } } + private static MethodInfo _TakeReusableLocals; public static MethodInfo/*!*/ LeaveMethodFrame { get { return _LeaveMethodFrame ?? (_LeaveMethodFrame = CallInstruction.CacheAction(RubyOps.LeaveMethodFrame)); } } private static MethodInfo _LeaveMethodFrame; public static MethodInfo/*!*/ LeaveProcCall { get { return _LeaveProcCall ?? (_LeaveProcCall = CallInstruction.CacheAction(RubyOps.LeaveProcCall)); } } diff --git a/Src/Ruby/Runtime/BlockParam.cs b/Src/Ruby/Runtime/BlockParam.cs index 847e5ab1..1411dc65 100644 --- a/Src/Ruby/Runtime/BlockParam.cs +++ b/Src/Ruby/Runtime/BlockParam.cs @@ -95,10 +95,12 @@ public sealed partial class BlockParam { private readonly Proc/*!*/ _proc; // The small state is packed into bytes, which keeps a BlockParam at 48 bytes: one is allocated for every // call of a library method with a block, and for every yield and Proc#call. - // bit 0: BlockCallerKind.Call; bit 1: the library method call taking this BlockParam is a proc converter. - private readonly byte _callerFlags; + // bit 0: BlockCallerKind.Call; bit 1: the library method call taking this BlockParam is a proc converter; + // bit 2: made for a single call of the block (a yield or a Proc#call; see RubyOps.LeaveReusableBlockScope). + private byte _callerFlags; private const int CallFlag = 1; private const int LibProcConverterFlag = 2; + internal const int SingleCallFlag = 4; // filled by module_eval: if not null than method definition uses the module private RubyModule _methodLookupModule; @@ -111,16 +113,54 @@ public sealed partial class BlockParam { public bool IsThreadRoot { get; set; } // -- out -- - // bits 0-1: BlockReturnReason; bits 2-3: the ProcKind of the block that set it + // bits 0-1: BlockReturnReason; bits 2-3: the ProcKind of the block that set it; + // bit 4: a call of the block through this BlockParam has finished (see ParkScope) private byte _flow; + private const int CalledFlag = 16; + + // The break target while ReturnReason is Break. While it is Undefined: null, or the scope of the last + // call of the block that nothing captured, for the next call through this BlockParam to reuse (see + // ReusableScope) - no field of its own, a BlockParam is allocated for every call with a block. private RuntimeFlowControl _targetFrame; internal BlockCallerKind CallerKind { get { return (_callerFlags & CallFlag) != 0 ? BlockCallerKind.Call : BlockCallerKind.Yield; } } + // Made for a single call of the block: a scope it leaves is kept on the proc instead. + internal bool IsSingleCall { get { return (_callerFlags & SingleCallFlag) != 0; } } + + /// + /// The scope ParkScope kept, or null. + /// + internal RubyBlockScope ReusableScope { + get { return ((_flow & 3) == (int)BlockReturnReason.Undefined) ? (RubyBlockScope)_targetFrame : null; } + } + + // The scope is taken for a call of the block (it was the ReusableScope). + internal void TakeReusableScope() { + _targetFrame = null; + } + + /// + /// Keeps the scope of a call of the block that finished normally, for the next call to reuse; not on the + /// first call through this BlockParam, which most often is the only one (a library method calling its + /// block once), nor after a break/return/retry, which use the target frame. + /// + internal bool ParkScope(RubyBlockScope/*!*/ scope) { + int flow = _flow; + if (flow == CalledFlag) { + _targetFrame = scope; + return true; + } + if (flow == 0) { + _flow = CalledFlag; + } + return false; + } + // True while a lambda called through Proc#call (or as a define_method body) is running, so // a return in a block nested in it can unwind to that call. internal bool IsActiveLambdaCall; - internal ProcKind SourceProcKind { get { return (ProcKind)(_flow >> 2); } } + internal ProcKind SourceProcKind { get { return (ProcKind)((_flow >> 2) & 3); } } internal BlockReturnReason ReturnReason { get { return (BlockReturnReason)(_flow & 3); } set { _flow = (byte)((_flow & ~3) | (int)value); } } internal RuntimeFlowControl TargetFrame { get { return _targetFrame; } } internal RubyModule MethodLookupModule { get { return _methodLookupModule; } set { _methodLookupModule = value; } } @@ -170,11 +210,21 @@ internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind, bool isLibProcCo // A BlockParam that is only ever used on the thread creating it, for the duration of a call: // a yield or a Proc#call. There the thread check in Break always passes, so skip it. - internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind) { - _callerFlags = (byte)(callerKind == BlockCallerKind.Call ? CallFlag : 0); + internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind, int flags) { + _callerFlags = (byte)((callerKind == BlockCallerKind.Call ? CallFlag : 0) | flags); _proc = proc; } + // ReuseBfcForYield: the flow-control state is cleared, but the scope the last call kept (see ParkScope) stays, + // and the calls through this BlockParam are no longer a single call. + internal void ResetForNextYield() { + if ((_flow & ~CalledFlag) != 0) { + _targetFrame = null; + } + _flow = CalledFlag; + _callerFlags &= unchecked((byte)~SingleCallFlag); + } + internal void SetFlowControl(BlockReturnReason reason, RuntimeFlowControl targetFrame, ProcKind sourceProcKind) { Debug.Assert((reason == BlockReturnReason.Break) == (targetFrame != null)); @@ -321,7 +371,7 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* [Emitted] public static BlockParam/*!*/ CreateBfcForYield(Proc proc) { if (proc != null) { - return new BlockParam(proc, BlockCallerKind.Yield); + return new BlockParam(proc, BlockCallerKind.Yield, BlockParam.SingleCallFlag); } else { throw RubyExceptions.NoBlockGiven(); } @@ -336,7 +386,7 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* [Emitted] public static BlockParam/*!*/ ReuseBfcForYield(BlockParam previous, Proc proc) { if (previous != null && previous.Proc == proc) { - previous.SetFlowControl(BlockReturnReason.Undefined, null, ProcKind.Block); + previous.ResetForNextYield(); return previous; } return CreateBfcForYield(proc); @@ -345,7 +395,7 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* [Emitted] public static BlockParam/*!*/ CreateBfcForProcCall(Proc/*!*/ proc) { Assert.NotNull(proc); - return new BlockParam(proc, BlockCallerKind.Call) { IsActiveLambdaCall = proc.Kind == ProcKind.Lambda }; + return new BlockParam(proc, BlockCallerKind.Call, BlockParam.SingleCallFlag) { IsActiveLambdaCall = proc.Kind == ProcKind.Lambda }; } [Emitted] diff --git a/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs b/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs index 7a1e0055..8ec2ad10 100644 --- a/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs +++ b/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs @@ -60,6 +60,7 @@ internal RubyMethodInfo(RubyMethodBody/*!*/ body, RubyScope/*!*/ declaringScope, _body = body; _declaringScope = declaringScope; + declaringScope.MarkCaptured(); } protected internal override RubyMemberInfo/*!*/ Copy(RubyMemberFlags flags, RubyModule/*!*/ module) { diff --git a/Src/Ruby/Runtime/ObjectTracking.cs b/Src/Ruby/Runtime/ObjectTracking.cs index a544dd60..0615d184 100644 --- a/Src/Ruby/Runtime/ObjectTracking.cs +++ b/Src/Ruby/Runtime/ObjectTracking.cs @@ -162,6 +162,9 @@ internal static void SetCurrentLine(RubyScope scope, string path, int line) { _path = path; _line = line; _scope = scope; + if (scope != null) { + scope.MarkCaptured(); + } _cachedSite = null; } diff --git a/Src/Ruby/Runtime/RubyContext.cs b/Src/Ruby/Runtime/RubyContext.cs index 70c00d3e..767dfdb0 100644 --- a/Src/Ruby/Runtime/RubyContext.cs +++ b/Src/Ruby/Runtime/RubyContext.cs @@ -2670,6 +2670,9 @@ public sealed class GlobalVariableTraceCommand { public GlobalVariableTraceCommand(MutableString/*!*/ code, RubyScope scope, object self) { Code = code; Scope = scope; + if (scope != null) { + scope.MarkCaptured(); + } Self = self; } } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 9325b28a..2013d317 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -256,6 +256,95 @@ public static RubyBlockScope CreateBlockScopeIfTraced(MutableTuple locals, strin return null; } + /// + /// The entry of a block whose body needs its scope but does not define a closure over it: the + /// scope a finished call left on the BlockParam (each, map, times, a yield in a loop) or, for + /// a BlockParam of a single call (Proc#call, a plain yield), on the proc is reused, unless + /// something captured it (see RubyScope.IsCaptured). + /// hands it back. Only the thread that made a scope takes it off a proc, so a proc called on + /// several threads needs no lock; a scope is taken while its call runs, and a nested call + /// of the same block makes its own. + /// + [Emitted] + public static RubyBlockScope/*!*/ CreateReusableBlockScope(MutableTuple locals, string[] variableNames, + BlockParam/*!*/ blockParam, object selfObject, InterpretedFrame interpretedFrame) { + + RubyUtils.SafePoint(); + + // (a scope that something captured after its call had returned is not reused either) + var scope = blockParam.ReusableScope; + if (scope != null && !scope.IsCaptured) { + blockParam.TakeReusableScope(); + } else { + // The block passes the locals TakeReusableLocals found on the proc's scope - which it + // checked just now - or a tuple of its own; one without a reusable tuple, null or a new one. + var proc = blockParam.Proc; + scope = proc.ReusableScope; + if (scope != null && (locals != null && scope.Locals == locals || IsReusableBlockScope(scope))) { + proc.TakeReusableScope(); + } else { + scope = null; + } + } + + if (scope != null) { + scope.Reinitialize(locals, blockParam, selfObject, interpretedFrame); + } else { + scope = new RubyBlockScope(locals, variableNames ?? ArrayUtils.EmptyStrings, blockParam, selfObject, interpretedFrame); + scope.OwnerThreadId = Environment.CurrentManagedThreadId; + } + + if ((TracePoint.ActiveEvents & (int)(TraceEvents.BCall | TraceEvents.Call)) != 0) { + scope.MarkCaptured(); + TracePoint.OnBlockCall(scope); + } + return scope; + } + + /// + /// The locals tuple of the scope is about to reuse, for the + /// block to clear and keep instead of allocating another; null if there is no such scope. + /// + [Emitted] + public static MutableTuple TakeReusableLocals(BlockParam/*!*/ blockParam) { + var scope = blockParam.ReusableScope; + if (scope != null && !scope.IsCaptured) { + return scope.Locals; + } + scope = blockParam.Proc.ReusableScope; + return (scope != null && IsReusableBlockScope(scope)) ? scope.Locals : null; + } + + // Out of line: the path of a scope kept on the proc, not the BlockParam's own. + [MethodImpl(MethodImplOptions.NoInlining)] + private static bool IsReusableBlockScope(RubyBlockScope/*!*/ scope) { + return scope.OwnerThreadId == Environment.CurrentManagedThreadId && !scope.IsCaptured; + } + + /// + /// The normal exit of a block that made its scope by . + /// An exception leaving the body skips this, and the scope is simply not reused. True if the + /// scope was kept, with its locals tuple when : the block then + /// clears the tuple, so that the next call finds its locals nil. The first call through a + /// BlockParam or of a proc keeps nothing (see BlockParam.ParkScope, Proc.KeepReusableScope): + /// a block called once pays no more than a flag. + /// + [Emitted] + public static bool LeaveReusableBlockScope(RubyBlockScope/*!*/ scope, bool keepLocals) { + if (scope.IsCaptured) { + return false; + } + var blockParam = scope.BlockFlowControl; + if (!blockParam.IsSingleCall) { + return blockParam.ParkScope(scope); + } + if (!blockParam.Proc.KeepReusableScope()) { + return false; + } + scope.Release(keepLocals); + return true; + } + [Emitted] public static RubyBlockScope/*!*/ CreateBlockScopeLazy(MutableTuple locals, string[] variableNames, BlockParam/*!*/ blockParam, object selfObject, InterpretedFrame interpretedFrame) { diff --git a/Src/Ruby/Runtime/RubyScope.cs b/Src/Ruby/Runtime/RubyScope.cs index f9326654..625e8643 100644 --- a/Src/Ruby/Runtime/RubyScope.cs +++ b/Src/Ruby/Runtime/RubyScope.cs @@ -91,6 +91,18 @@ public abstract class RubyScope : RuntimeFlowControl { internal bool InLoop; internal bool InRescue; + // Set once something may hold on to this scope after the call it belongs to has returned: a + // Proc, Binding, method or nested scope made in it, a TracePoint event. A block scope that is + // not captured is reused for the next call of its proc (see RubyOps.CreateReusableBlockScope). + internal bool IsCaptured; + + // Libraries call this for a scope they keep after returning. + public void MarkCaptured() { + if (!IsCaptured) { + IsCaptured = true; + } + } + // closure: internal /*and protected*/ MutableTuple _locals; // null if there are no variables internal /*and protected*/ string/*!*/[] _variableNames; // empty if there are no variables @@ -227,6 +239,31 @@ internal RubyScope() { // Perf note: Initialization is performed completely in the most derived scope classes to improve perf. } + // The per-call state of a scope reused for another call of its proc, as a new scope has it. + internal void ResetForReuse(MutableTuple locals, object selfObject, InterpretedFrame interpretedFrame) { + _activeFlowControlScope = _parent.FlowControlScope; + InLoop = false; + InRescue = false; + _locals = locals; + _selfObject = selfObject; + _selfImmediateClass = null; + _methodAttributes = RubyMethodAttributes.PublicInstance; + _rare = null; + InterpretedFrame = interpretedFrame; + } + + // Drops what the finished call referred to, so that a scope kept on its proc holds on to no values. + internal void ClearForReuse(bool keepLocals) { + _activeFlowControlScope = null; + if (!keepLocals) { + _locals = null; + } + _selfObject = null; + _selfImmediateClass = null; + _rare = null; + InterpretedFrame = null; + } + public virtual RubyModule Module { get { return null; } } @@ -1202,6 +1239,7 @@ public sealed class RubyModuleScope : RubyClosureScope { internal RubyModuleScope(RubyScope/*!*/ parent, RubyModule/*!*/ module) { Assert.NotNull(parent, module); + parent.MarkCaptured(); // RuntimeFlowControl: _activeFlowControlScope = parent.FlowControlScope; @@ -1257,6 +1295,7 @@ internal void SetLexicalConstantModule(RubyModule module) { internal RubyModuleEvalScope(RubyScope/*!*/ parent, RubyModule module, object selfObject) { Assert.NotNull(parent); + parent.MarkCaptured(); // RuntimeFlowControl: _activeFlowControlScope = parent.FlowControlScope; @@ -1284,6 +1323,8 @@ public sealed class RubyFileInitializerScope : RubyClosureScope { public override bool InheritsLocalVariables { get { return false; } } internal RubyFileInitializerScope(MutableTuple locals, string[]/*!*/ variableNames, RubyScope/*!*/ parent) { + parent.MarkCaptured(); + // RuntimeFlowControl: _activeFlowControlScope = parent.FlowControlScope; @@ -1304,7 +1345,10 @@ internal override void DefineDynamicVariable(string/*!*/ name, object value) { } public sealed class RubyBlockScope : RubyClosureScope { - private readonly BlockParam/*!*/ _blockFlowControl; + private BlockParam/*!*/ _blockFlowControl; + + // The thread that made the scope, the only one that may reuse it (see RubyOps.CreateReusableBlockScope). + internal int OwnerThreadId; // The block a thread runs keeps its own $~ and $_, as MRI's thread root frame does; any // other block shares them with the method it is in. @@ -1364,6 +1408,25 @@ internal RubyBlockScope(MutableTuple locals, string/*!*/[]/*!*/ variableNames, // RubyBlockScope: _blockFlowControl = blockFlowControl; } + + // Makes this scope, which nothing has captured, the scope of another call of its proc. + internal void Reinitialize(MutableTuple locals, BlockParam/*!*/ blockFlowControl, object selfObject, InterpretedFrame interpretedFrame) { + _blockFlowControl = blockFlowControl; + ResetForReuse(locals, selfObject, interpretedFrame); + CurrentMatch = null; + LastInputLine = null; + LambdaReturnState = 0; + } + + // The call has finished and nothing captured the scope: keep it, cleared, on the proc. + internal void Release(bool keepLocals) { + Proc proc = _blockFlowControl.Proc; + ClearForReuse(keepLocals); + CurrentMatch = null; + LastInputLine = null; + _blockFlowControl = null; + proc.ReusableScope = this; + } } /// @@ -1380,6 +1443,8 @@ public sealed class RubyBindingCopyScope : RubyScope { // No module of its own: lexical walks (Module.nesting, constant lookup) pass through to the parent. public RubyBindingCopyScope(RubyScope/*!*/ parent, object selfObject) { + parent.MarkCaptured(); + // RuntimeFlowControl: _activeFlowControlScope = parent.FlowControlScope; diff --git a/Util/bench/bench.rb b/Util/bench/bench.rb index 78431815..abe23aff 100644 --- a/Util/bench/bench.rb +++ b/Util/bench/bench.rb @@ -490,6 +490,24 @@ def initialize(x:, y: 0) while i < m; KwPoint.new(x: i, y: 2); i += 1; end end +# ---------------- single-call blocks whose body makes a call ---------------- + +class YieldOnce + def one(x); yield x; end +end + +# a Ruby method yielding once to a new literal block (block scope reuse must cost it nothing) +bench("yield_once_call") do + o = YieldOnce.new; i = 0; m = n(2_000_000) + while i < m; o.one(i) { |y| y * 2 }; i += 1; end +end + +# a library method calling a new literal block once +bench("then_call") do + i = 0; m = n(2_000_000) + while i < m; i.then { |y| y * 2 }; i += 1; end +end + name = ARGV[0] unless BENCH.key?(name) if name == "--list" diff --git a/Util/block-scope-reuse-matrix.rb b/Util/block-scope-reuse-matrix.rb new file mode 100644 index 00000000..a70b46a0 --- /dev/null +++ b/Util/block-scope-reuse-matrix.rb @@ -0,0 +1,351 @@ +# Differential checks for block scope reuse. +# +# A block whose body needs its RubyScope but defines no closure reuses the scope (and its locals +# tuple) of the previous call through the same BlockParam, unless something captured the scope: +# a Binding, a Proc or method defined in it, a nested scope (eval, class body), a TracePoint +# event. Every block body below makes calls, so it takes that path; the output must match CRuby: +# +# ruby Util/block-scope-reuse-matrix.rb > /tmp/cruby.txt +# ./ir.sh Util/block-scope-reuse-matrix.rb > /tmp/ir.txt +# diff /tmp/cruby.txt /tmp/ir.txt +# +# (also with -X:NoJIT -X:NoOSR, which keeps the blocks in the interpreter) + +# Differential checks for block scope reuse (every block body here makes calls) +# Differential checks for block scope reuse (every block body here makes calls) +bs = [] +[1, 2, 3].each { |x| y = x * 2; bs << binding } +p bs.map { |b| b.local_variable_get(:y) } +p bs.map { |b| b.local_variable_get(:x) } + +r = [] +[1, 2, 3].each { |x| r << eval("defined?(zz)").inspect; r << eval("zz = x * 10; zz") } +p r + +r = [] +[1, 2].each { |x| r << local_variables.inspect; w = x.succ; r << local_variables.inspect } +p r + +r = [] +[1, 2, 3].each { |x| y ||= x.succ; r << y } +p r + +procs = [] +[1, 2, 3].each { |x| y = x * 3; procs << -> { y } } +p procs.map(&:call) + +ps = [] +[1, 2, 3].each { |x| y = x + 100; ps << eval("proc { y }") } +p ps.map(&:call) + +ps = [] +[1, 2, 3].each { |x| y = x.to_s; ps << binding.local_variable_get(:y) } +p ps + +class DefIn + [1, 2].each { |x| define_method("m#{x}") { x } } + [3, 4].each { |x| y = x * 2; class_eval "def k#{x}; #{y}; end" } +end +p [DefIn.new.m1, DefIn.new.m2, DefIn.new.k3, DefIn.new.k4] + +$b = [] +tp = TracePoint.new(:line) { |t| $b << t.binding if t.path == __FILE__ } +tp.enable +[5, 6].each { |x| + q = x * 7 + q.to_s +} +tp.disable +p $b.map { |b| b.local_variable_defined?(:q) ? b.local_variable_get(:q) : :undef } + +r = [] +%w[a1 b2 c3].each { |s| s =~ /\d/; r << $~[0] } +p r, $~ && $~[0] + +def m_given; r = []; [1, 2].each { |x| r << [block_given?, __method__, x.succ] }; r; end +p m_given, m_given {} + +r = [] +[1, 2, 3, 4].each { |x| next if x.even?; r << x * 2 } +p r +p [1, 2, 3, 4].each { |x| break x * 100 if x == 3; x.succ } + +r = [] +cnt = 0 +[1, 2].each { |x| cnt += 1; r << x * 2; redo if cnt == 1 } +p r + +r = [] +[1, 2, 3].each { |x| + begin + raise ArgumentError, "x#{x}" if x.odd? + r << x.succ + rescue => e + r << e.message + end +} +p r + +p Thread.new { [1, 2, 3].map { |x| x * 2 } }.value + +def tw; yield 1; yield 2; yield 3; end +r = [] +tw { |x| r << x * 2 } +p r + +def rec(n, &b); return if n == 0; yield n; rec(n - 1, &b); end +r = [] +rec(4) { |x| r << x * 3 } +p r + +fib = Hash.new { |h, n| h[n] = n < 2 ? n : h[n - 1] + h[n - 2] } +p fib[40] + +e = [1, 2, 3].each +r = [] +loop { v = e.next; r << v * 2 } +p r +p [1, 2, 3].map.with_index { |x, i| x * i } +p [1, 2, 3].each_slice(2).map { |a| a.sum } + +o = Object.new +r = [] +[1, 2].each { |x| o.instance_exec(x) { |y| @v = y * 2 }; r << o.instance_variable_get(:@v) } +p r + +class Dm + define_method(:dm) { |x| x * 2 + 1 } +end +p (1..3).map { |i| Dm.new.dm(i) } + +p [3, 1, 2].sort { |a, b| b <=> a } +p [1, 2, 3].inject { |s, x| s + x * 2 } +p catch(:t) { [1, 2, 3].each { |x| throw :t, x * 9 if x == 2 } } + +r = [] +[1, 2].each { |x| r << self.to_s; r << x.frozen? } +p r + +r = [] +[[1, 2], [3, 4]].each { |a, b| c = a * b; r << c } +p r + +r = [] +[1, 2].each { |x| [10, 20].each { |y| r << x * y } } +p r + +# a binding taken in iteration 1, iteration 2 must not overwrite it +bb = nil +[1, 2].each { |x| z = x * 5; bb ||= binding } +p bb.local_variable_get(:z) + +# lambda with calls +l = ->(x) { x * 2 } +p (1..3).map { |i| l.(i) } +p (1..3).map(&l) + +# Symbol#to_proc inside a block +p [[1, 2], [3]].map { |a| a.map(&:to_s) } + +# eval with binding from the block, then reuse +bnd = [] +[1, 2].each { |x| bnd << binding if x == 1; y = x.succ } +p bnd[0].local_variable_get(:y) + +# raise inside, caller info +r = [] +[1, 2].each { |x| r << caller(0).first[/in .*/] } +p r.uniq.size + +# instance_variable / global in body +$g = 0 +[1, 2, 3].each { |x| $g += x * 2; @iv = $g } +p $g, @iv + +# Proc#call reuse +pr = proc { |x| x * 2 + (x > 1 ? 1 : 0) } +p (1..4).map { |i| pr.call(i) } + +r = [] +[1, 2].each { |x| q = x * 4; r << send(:binding).local_variable_get(:q) } +p r + +def each2; i = 0; while i < 3; yield i; i += 1; end; end +r = [] +each2 { |x| r << x * 2 } +p r +def walk(n, &b); i = 0; while i < 2; yield n; walk(n - 1, &b) if n > 0; i += 1; end; end +r = [] +walk(2) { |x| r << x * 10 } +p r +# block via yield in loop capturing binding on some iterations +bs = [] +each2 { |x| y = x * 3; bs << binding if x != 1 } +p bs.map { |b| b.local_variable_get(:y) } +# external enumeration over a yielding method +e = to_enum(:each2) +p [e.next * 2, e.next * 2, e.next * 2] +gen = Enumerator.new { |y| [1, 2, 3].each { |x| y << x * 5 } } +p gen.next, gen.next, gen.to_a +# lazy +p (1..Float::INFINITY).lazy.map { |x| x * 2 }.select { |x| x % 3 == 0 }.first(3) +# zip/each_with_index etc. +p [1, 2, 3].each_with_index.map { |x, i| x * i } +p [1, 2, 3].each_with_object([]) { |x, a| a << x * 2 } +# fibers +f = Fiber.new { [1, 2, 3].each { |x| Fiber.yield x * 7 }; :done } +p [f.resume, f.resume, f.resume, f.resume] +# threads using same proc +pr = proc { |x| x * 2 } +p 4.times.map { |i| Thread.new { (1..1000).map(&pr).sum } }.map(&:value) +# block params with defaults/splat/kw +p [[1, 2], [3, 4]].map { |a, b = 5, *c| [a * 1, b, c] } +p [1, 2].map { |x, k: 3| x * k } +# numbered/it params +p [1, 2, 3].map { _1 * 2 }, [1, 2, 3].map { it * 3 } +# block-local vars +y = 10 +p [1, 2].map { |x; y| y = x * 2; y }, y +# $_ in block +r = [] +["a\n", "b\n"].each { |l| $_ = l; r << $_.chomp.upcase } +p r +# self switching +class K; def run; [1, 2].map { |x| self.class.name + x.to_s }; end; end +p K.new.run +p 3.times.map { |i| instance_exec(i) { |j| j * 2 } } +# __method__ / block_given? in define_method +class K2; define_method(:z) { [1, 2].map { |x| [__method__, x * 2] } }; end +p K2.new.z +# eval of local after previous iteration's eval +r = [] +[1, 2].each { |x| r << (eval("defined?(qq)") || :none); eval("qq = 1"); } +p r +# ObjectSpace / GC +p [1, 2].map { |x| GC.start if x == 1; x * 2 } +# exception backtrace in reused scope +def raiser(x) = raise("e#{x}") +p [1, 2].map { |x| begin; raiser(x); rescue => e; e.backtrace[1][/block.*/]; end } +# local_variable_set via binding then next iteration +r = [] +[1, 2].each { |x| b = binding; b.local_variable_set(:nn, x * 2); r << b.local_variable_get(:nn) } +p r +# proc from method(:...) in block +p [1, 2].map { |x| method(:Integer).call(x.to_s) * 2 } +# retry inside block +cnt = 0 +p [1, 2].map { |x| begin; cnt += 1; raise "x" if cnt == 1; x * 2; rescue; retry; end } + +# locals of a reused scope start out nil again (in a method: its own locals) +def locals_part + r = [] + [1, 2, 3].each { |x| z = (z || 0) + x; r << z } + p r + r = [] + [1, 2, 3].each { |x| if x.odd?; w = x * 2; end; r << w } + p r + r = [] + [1, 2, 3].each { |x| a = (a || []) << x.succ; b ||= x; r << [a, b] } + p r + r = [] + [1, 2, 3].each { |x| a1 = x; a2 = a1 + 1; a3 = a2 + 1; a4 = a3 + 1; a5 = a4 + 1; a6 = a5 + 1; a7 = a6 + 1; a8 = a7 + 1; a9 = (a9 || 0) + a8; r << a9 } + p r + r = [] + [[1, 2], [3, 4]].each { |a, (b, c)| d = (d || 10) + a.to_i; r << [a, b, c, d] } + p r + r = [] + [1, 2].each { |x, y = ((y || 5) + 1)| r << [x, y.inspect] } + p r + r = [] + [1, 2, 3].each_with_index { |x, i| t = (t.to_i) + x * i; r << t } + p r + r = [] + [1, 2].each { |x| i = 0; while i < 2; k = (k || 0) + 1; i += 1; end; r << k } + p r + cnt = 0 + r = [] + [1, 2].each { |x| q = (q || 0) + 1; cnt += 1; r << q; redo if cnt == 1 } + p r + r = [] + h = {a: 1, b: 2} + h.each { |k, v| s = (s || "") + k.to_s * v; r << s } + p r + r = [] + 3.times { |i| case i when 0 then m = :zero when 2 then r << m.inspect end; r << m.inspect } + p r + r = [] + [1, 2].each { |x| begin; e = (e || 0) + x; raise "x" if x == 1; rescue; f = (f || 0) + 1; end; r << [e, f] } + p r +end +locals_part + +# one proc called through many BlockParams: the scope is kept on the proc, for its thread +class ReuseD + define_method(:dbl) { |x| y = x * 2; y + (z ||= 0) } + define_method(:bnd) { |x| y = x * 3; binding } +end +class ReuseE + include Enumerable + def initialize(a); @a = a; end + def each; @a.each { |x| yield x }; self; end +end + +def proc_part + # one proc, called through many BlockParams + pr = proc { |x| y = x * 2; Thread.pass if x % 97 == 0; y + 1 } + p 4.times.map { |t| Thread.new { s = 0; 20000.times { |i| s += pr.call(i + t) }; s } }.map(&:value) + + fact = proc { |n| n <= 1 ? 1 : n * fact.call(n - 1) } + p fact.call(25) + + d = ReuseD.new + p (1..5).map { |i| d.dbl(i) } + bs = (1..3).map { |i| d.bnd(i) } + p bs.map { |b| b.local_variable_get(:y) } + + l = ->(x) { t = x.to_s; t * 2 } + p (1..3).map { |i| l.(i) }, (1..3).map(&l) + + pr = proc { |k| to_s + k.to_s } + p [1, 2, 3].map { |i| i.instance_exec(i * 10, &pr) } + + $bs = [] + pr2 = proc { |n| v = n * 7; $bs << binding if n.odd?; pr2.call(n - 1) if n > 0; v } + p pr2.call(4), $bs.map { |b| b.local_variable_get(:v) } + + pr3 = proc { |x| a = x; Fiber.yield a if x == 1; a * 10 } + f = Fiber.new { pr3.call(1) } + p f.resume + p pr3.call(2), pr3.call(3) + p f.resume + + e = ReuseE.new([3, 1, 2]) + p e.map { |x| x * 2 }, e.select { |x| x.odd? }, e.sort_by { |x| -x }, e.inject { |s, x| s + x } + r = [] + e.each { |x| w = (w || 0) + x; r << w } + p r + p e.each_slice(2).map { |a| a.sum } + + # a proc whose call raises: its scope is not kept, the next call works + pr4 = proc { |x| q = x.to_s; raise "boom" if x == 2; q * 2 } + p [1, 2, 3].map { |i| begin; pr4.call(i); rescue => ex; ex.message; end } + + # break / next out of a proc-called block + p [1, 2, 3, 4].each { |x| y = x * 2; break y if x == 3 } + pr5 = proc { |x| next x * 100 if x.even?; x.to_s } + p (1..4).map { |i| pr5.call(i) } + + # same proc used as block of different methods + blk = proc { |x| x.to_s * 2 } + p [1, 2].map(&blk), { a: 1 }.map(&blk), (1..2).each_with_object([]) { |x, acc| acc << blk.call(x) } + + # lambda with nested return + lr = ->(x) { [1, 2].each { |y| return y * x if y == 2 }; :none } + p lr.(5), lr.(6) + + # a proc that captures its scope only on some calls + $keep = [] + pk = proc { |x| zz = x * 9; $keep << binding if x == 2; zz } + p (1..4).map { |i| pk.call(i) }, $keep.map { |b| b.local_variable_get(:zz) } +end +proc_part From 1d45fd013f1e9bbf781545ffaa31ee35f9996454 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 13:38:37 +0200 Subject: [PATCH 86/94] WIP: keep the reuse bookkeeping off a block's first calls The entry and the locals fetch test inline for a kept scope and call out only when there is one; the type test of a reused tuple runs only for a tuple; keeping a scope on the proc (and its thread id) is out of line and starts with the proc's third single call, so a block yielded once or twice allocates nothing more than before. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/Proc.cs | 16 ++++---- Src/Ruby/Compiler/Ast/ScopeBuilder.cs | 8 +++- Src/Ruby/Runtime/BlockParam.cs | 5 +++ Src/Ruby/Runtime/RubyOps.cs | 55 +++++++++++++++++++-------- Src/Ruby/Runtime/RubyScope.cs | 4 +- 5 files changed, 63 insertions(+), 25 deletions(-) diff --git a/Src/Ruby/Builtins/Proc.cs b/Src/Ruby/Builtins/Proc.cs index 3176965f..a0a07aae 100644 --- a/Src/Ruby/Builtins/Proc.cs +++ b/Src/Ruby/Builtins/Proc.cs @@ -74,8 +74,9 @@ public partial class Proc : RuntimeFlowControl, IDuplicable { // The proc is its own converter: a call it was the literal block of converted it (see Converter). private const int SelfConverterFlag = 4; - // A single call of the proc (a yield or Proc#call) has finished (see KeepReusableScope). - private const int CalledFlag = 8; + // How many single calls of the proc (a yield or Proc#call) have finished, up to two (see KeepReusableScope). + private const int CallCountUnit = 8; + private const int CallCountMask = 3 * CallCountUnit; /// /// The state few procs have, out of line: a Proc is allocated for every call with a literal block. @@ -147,16 +148,17 @@ internal void TakeReusableScope() { } /// - /// Whether a single call of the proc that has finished keeps its scope for the next call: not on the first - /// call, which most often is the only one - a literal block is a new Proc for every call it is passed to. - /// The flag is only ever set: the kind and converter bits are written before the proc's first call. + /// Whether a single call of the proc that has finished keeps its scope for the next call: from the third + /// call on. A literal block is a new Proc for every call it is passed to and most are called once or twice, + /// where keeping a scope (and the rare state that holds it) would not pay off. The count only ever goes + /// up: the kind and converter bits are written before the proc's first call. /// internal bool KeepReusableScope() { int kind = _kind; - if ((kind & CalledFlag) != 0) { + if ((kind & CallCountMask) == 2 * CallCountUnit) { return true; } - _kind = kind | CalledFlag; + _kind = kind + CallCountUnit; return false; } diff --git a/Src/Ruby/Compiler/Ast/ScopeBuilder.cs b/Src/Ruby/Compiler/Ast/ScopeBuilder.cs index e1da7135..f698b527 100644 --- a/Src/Ruby/Compiler/Ast/ScopeBuilder.cs +++ b/Src/Ruby/Compiler/Ast/ScopeBuilder.cs @@ -234,8 +234,14 @@ internal bool HasReusableLocals { if (!HasReusableLocals) { return MakeLocalsStorage(); } + // (the type test only when there is a tuple to reuse: a block called once has none) + var reusable = DefineHiddenVariable("#reusable", typeof(MutableTuple)); return Ast.Assign(_localsTuple, Ast.Coalesce( - Ast.TypeAs(Methods.TakeReusableLocals.OpCall(blockParameter), _localsTuple.Type), + Ast.Condition( + Ast.Equal(Ast.Assign(reusable, Methods.TakeReusableLocals.OpCall(blockParameter)), AstUtils.Constant(null)), + AstUtils.Constant(null, _localsTuple.Type), + Ast.TypeAs(reusable, _localsTuple.Type) + ), Ast.New(_localsTuple.Type) )); } diff --git a/Src/Ruby/Runtime/BlockParam.cs b/Src/Ruby/Runtime/BlockParam.cs index 1411dc65..2e5cbe6c 100644 --- a/Src/Ruby/Runtime/BlockParam.cs +++ b/Src/Ruby/Runtime/BlockParam.cs @@ -135,6 +135,11 @@ internal RubyBlockScope ReusableScope { get { return ((_flow & 3) == (int)BlockReturnReason.Undefined) ? (RubyBlockScope)_targetFrame : null; } } + // False if there is no ReusableScope; a quick test for the common case. + internal bool MayHaveReusableScope { + get { return _targetFrame != null; } + } + // The scope is taken for a call of the block (it was the ReusableScope). internal void TakeReusableScope() { _targetFrame = null; diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 2013d317..9c2501a5 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -261,16 +261,36 @@ public static RubyBlockScope CreateBlockScopeIfTraced(MutableTuple locals, strin /// scope a finished call left on the BlockParam (each, map, times, a yield in a loop) or, for /// a BlockParam of a single call (Proc#call, a plain yield), on the proc is reused, unless /// something captured it (see RubyScope.IsCaptured). - /// hands it back. Only the thread that made a scope takes it off a proc, so a proc called on + /// hands it back. Only the thread whose call kept a scope on a proc takes it off, so a proc called on /// several threads needs no lock; a scope is taken while its call runs, and a nested call /// of the same block makes its own. /// - [Emitted] + [Emitted, MethodImpl(MethodImplOptions.AggressiveInlining)] public static RubyBlockScope/*!*/ CreateReusableBlockScope(MutableTuple locals, string[] variableNames, BlockParam/*!*/ blockParam, object selfObject, InterpretedFrame interpretedFrame) { RubyUtils.SafePoint(); + // a block called once finds nothing kept on its BlockParam or proc: the test stays small for that + RubyBlockScope scope = null; + if (blockParam.MayHaveReusableScope || blockParam.Proc.ReusableScope != null) { + scope = TakeReusableBlockScope(locals, blockParam, selfObject, interpretedFrame); + } + if (scope == null) { + scope = new RubyBlockScope(locals, variableNames ?? ArrayUtils.EmptyStrings, blockParam, selfObject, interpretedFrame); + } + + if ((TracePoint.ActiveEvents & (int)(TraceEvents.BCall | TraceEvents.Call)) != 0) { + scope.MarkCaptured(); + TracePoint.OnBlockCall(scope); + } + return scope; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static RubyBlockScope TakeReusableBlockScope(MutableTuple locals, BlockParam/*!*/ blockParam, + object selfObject, InterpretedFrame interpretedFrame) { + // (a scope that something captured after its call had returned is not reused either) var scope = blockParam.ReusableScope; if (scope != null && !scope.IsCaptured) { @@ -283,21 +303,11 @@ public static RubyBlockScope CreateBlockScopeIfTraced(MutableTuple locals, strin if (scope != null && (locals != null && scope.Locals == locals || IsReusableBlockScope(scope))) { proc.TakeReusableScope(); } else { - scope = null; + return null; } } - if (scope != null) { - scope.Reinitialize(locals, blockParam, selfObject, interpretedFrame); - } else { - scope = new RubyBlockScope(locals, variableNames ?? ArrayUtils.EmptyStrings, blockParam, selfObject, interpretedFrame); - scope.OwnerThreadId = Environment.CurrentManagedThreadId; - } - - if ((TracePoint.ActiveEvents & (int)(TraceEvents.BCall | TraceEvents.Call)) != 0) { - scope.MarkCaptured(); - TracePoint.OnBlockCall(scope); - } + scope.Reinitialize(locals, blockParam, selfObject, interpretedFrame); return scope; } @@ -305,8 +315,16 @@ public static RubyBlockScope CreateBlockScopeIfTraced(MutableTuple locals, strin /// The locals tuple of the scope is about to reuse, for the /// block to clear and keep instead of allocating another; null if there is no such scope. /// - [Emitted] + [Emitted, MethodImpl(MethodImplOptions.AggressiveInlining)] public static MutableTuple TakeReusableLocals(BlockParam/*!*/ blockParam) { + if (!blockParam.MayHaveReusableScope && blockParam.Proc.ReusableScope == null) { + return null; + } + return GetReusableLocals(blockParam); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static MutableTuple GetReusableLocals(BlockParam/*!*/ blockParam) { var scope = blockParam.ReusableScope; if (scope != null && !scope.IsCaptured) { return scope.Locals; @@ -326,7 +344,7 @@ private static bool IsReusableBlockScope(RubyBlockScope/*!*/ scope) { /// An exception leaving the body skips this, and the scope is simply not reused. True if the /// scope was kept, with its locals tuple when : the block then /// clears the tuple, so that the next call finds its locals nil. The first call through a - /// BlockParam or of a proc keeps nothing (see BlockParam.ParkScope, Proc.KeepReusableScope): + /// BlockParam and the first two of a proc keep nothing (see BlockParam.ParkScope, Proc.KeepReusableScope): /// a block called once pays no more than a flag. /// [Emitted] @@ -341,6 +359,11 @@ public static bool LeaveReusableBlockScope(RubyBlockScope/*!*/ scope, bool keepL if (!blockParam.Proc.KeepReusableScope()) { return false; } + return ReleaseToProc(scope, keepLocals); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static bool ReleaseToProc(RubyBlockScope/*!*/ scope, bool keepLocals) { scope.Release(keepLocals); return true; } diff --git a/Src/Ruby/Runtime/RubyScope.cs b/Src/Ruby/Runtime/RubyScope.cs index 625e8643..963a3192 100644 --- a/Src/Ruby/Runtime/RubyScope.cs +++ b/Src/Ruby/Runtime/RubyScope.cs @@ -1347,7 +1347,7 @@ internal override void DefineDynamicVariable(string/*!*/ name, object value) { public sealed class RubyBlockScope : RubyClosureScope { private BlockParam/*!*/ _blockFlowControl; - // The thread that made the scope, the only one that may reuse it (see RubyOps.CreateReusableBlockScope). + // The thread whose call kept the scope on its proc, the only one that may reuse it (see RubyOps.CreateReusableBlockScope). internal int OwnerThreadId; // The block a thread runs keeps its own $~ and $_, as MRI's thread root frame does; any @@ -1425,6 +1425,8 @@ internal void Release(bool keepLocals) { CurrentMatch = null; LastInputLine = null; _blockFlowControl = null; + // the thread that ran the call, the only one that may reuse it + OwnerThreadId = Environment.CurrentManagedThreadId; proc.ReusableScope = this; } } From 185b9bed06a3058ddf4ae08a299b541e20c15639 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 14:29:53 +0200 Subject: [PATCH 87/94] Reuse block scopes across calls without slowing single-call blocks Brings the block scope reuse of sp/w2-block-scope (843df62e..fcea2410) onto speedup after block-pass, and removes its single-yield regression: - the BlockParam keeps the scope of its last call in its break-target slot while no break/return/retry is pending (no new field: BlockParam stays 48 bytes), and only from its second call; a single-call flag rides in the packed caller flags; - the proc keeps it in its rare state (no new field: Proc stays the same size), from its third single call (yield or Proc#call), so a literal block yielded once or twice allocates nothing more than before; the call count lives in the proc's kind bits; - the entry and the locals fetch test inline for a kept scope and call out only when there is one, the tuple type test runs only for a tuple, and keeping a scope on the proc (and recording its thread) is out of line. Allocation per op (load-independent, GetAllocatedBytesForCurrentThread over 1M ops), speedup tip d6474b6a -> this: a.each { |x| x * 2 } 164 -> 12.7 B/elem N.times { |i| i * 2 } 200 -> 48 pr.call(i) { x * 2 } 248 -> 96 define_method body 248 -> 96 m1(i) { |y| y * 2 } (1x) 488 -> 488 yield twice 808 -> 808 i.then { |y| y * 2 } 488 -> 488 Mutex#synchronize 288 -> 288 yield 3x 1032 -> 1080 (rare state, scope not reused yet), 4x 1256 -> 1152 Util/bench (best of 6, alternating order, bench-lock; noise floor 0.07-0.13 s): each_loop 0.337 -> 0.292, times_loop 0.347 -> 0.312, upto_loop 0.321 -> 0.294, yield_once_call 0.768 -> 0.754, then_call 0.738 -> 0.685, yield_0 0.549 -> 0.500, yield_3 0.390 -> 0.359, block_pass 0.258 -> 0.249, block_literal_cfunc 0.294 -> 0.293, proc_call 0.349 -> 0.343 (best of 8). New entries yield_once_call and then_call cover a single-call block whose body makes a call. Differential scripts (matrix, reviewer's t4/t5/m, Util/block-scope-reuse-blockpass.rb) match CRuby with and without -X:NoJIT -X:NoOSR. Gates: sp-tests 0, sp-lang 2934/0/0, each_loop 0.337s -> 0.292s, yield_once_call 0.768s -> 0.754s Co-Authored-By: Claude Opus 5.5 --- Util/block-scope-reuse-blockpass.rb | 128 ++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 Util/block-scope-reuse-blockpass.rb diff --git a/Util/block-scope-reuse-blockpass.rb b/Util/block-scope-reuse-blockpass.rb new file mode 100644 index 00000000..890aceaf --- /dev/null +++ b/Util/block-scope-reuse-blockpass.rb @@ -0,0 +1,128 @@ +# Differential checks for block scope reuse together with literal-block procs (block-pass): +# break/next/return through reused scopes, scopes kept on the BlockParam (in its break-target +# slot) and on the proc (from its third single call), proc identity, escaped procs and bindings, +# threads and fibers. Every block body here makes calls, so it takes the reuse path; the output +# must match CRuby, with and without -X:NoJIT -X:NoOSR: +# +# ruby Util/block-scope-reuse-blockpass.rb > /tmp/cruby.txt +# ./ir.sh Util/block-scope-reuse-blockpass.rb > /tmp/ir.txt +# diff /tmp/cruby.txt /tmp/ir.txt + +def show(label, v) = puts("#{label}: #{v.inspect}") +# block-pass interactions with block scope reuse: every body makes calls +def loop_yield(n) i = 0; r = []; while i < n; r << yield(i); i += 1; end; r; end +def two_yields; [yield(1), yield(2)]; end +def three_yields; [yield(1), yield(2), yield(3)]; end + +show :next_mid, loop_yield(6) { |x| y = x * 2; next(-y) if x == 2; y.to_s } +show :break_mid, loop_yield(6) { |x| y = x * 3; break [:brk, y] if x == 3; y } +show :break_then_again, 3.times.map { |k| loop_yield(5) { |x| z = x + k; break z if x == k + 1; z.to_s } } +def ret_from_block; loop_yield(5) { |x| w = x.succ; return [:ret, w] if x == 2; w }; :no; end +show :return_through, ret_from_block +show :two_yields, two_yields { |x| a = x * 10; a.to_s } +show :three_yields, three_yields { |x| b = defined?(b).inspect; b = x; [b, x.succ] } +show :locals_nil, loop_yield(4) { |x| q = (q.nil? ? :fresh : q); q = x; [q, x.pred] } + +# proc identity / escaped procs +def keep(&b) b end +pr = keep { |x| v = x * 2; v + 1 } +show :pr_identity, [pr.equal?(keep(&pr)), pr.lambda?, 5.times.map { |i| pr.call(i) }] +procs = []; loop_yield(4) { |x| t = x * 2; procs << proc { [x, t] } if x.odd?; t } +show :escaped, procs.map(&:call) +bs = []; 4.times { |x| m = x.to_s; bs << binding if x == 2; m } +show :binding_mid, bs.map { |b| [b.local_variable_get(:m), b.local_variable_get(:x)] } +pr2 = proc { |x| s = x.to_s; break s * 2 if x == 3; s } +show :proc_break, (begin; 5.times.map { |i| pr2.call(i) }; rescue LocalJumpError => e; [e.class, e.reason]; end) +l = lambda { |x| u = x * 2; return u if x > 1; u.succ } +show :lambda_return, 4.times.map { |i| l.call(i) } +f = proc { |n| t = n * 1; n == 0 ? 0 : t + f.call(n - 1) } +show :recursion, [f.call(10), f.call(5), f.call(20)] +class DM; define_method(:dm) { |x| k = x * 2; k.to_s }; define_method(:rec) { |n| j = n; n == 0 ? [] : [j] + rec(n - 1) }; end +show :define_method, [5.times.map { |i| DM.new.dm(i) }, DM.new.rec(4)] +# then/tap/synchronize single-call library blocks, repeated +mu = Mutex.new +show :then_tap, 4.times.map { |i| [i.then { |y| y * 2 }, i.tap { |y| y * 3 }, mu.synchronize { z = i.succ; z.to_s }] } +show :then_break, 3.times.map { |i| i.then { |y| break y * 100 if y == 1; y.to_s } } +# threads: one proc called on several threads, one blockparam per thread +pt = proc { |x| a = x * 2; Thread.pass; [a, x] } +show :threads, 4.times.map { |t| Thread.new { 300.times.map { |i| pt.call(i + t)[0] }.sum } }.map(&:value) +show :thread_each, 3.times.map { |t| Thread.new { r = 0; 2000.times { |i| c = i + t; r += c } ; r } }.map(&:value) +# fibers: each driven externally, the same block across fiber switches +e = [1, 2, 3, 4].each_with_index.lazy.map { |x, i| s = x * i; s.to_s } +show :lazy, e.first(3) +en = Enumerator.new { |y| 5.times { |i| w = i * i; y << w } } +show :ext_next, [en.next, en.next, en.peek, en.next, en.take(2)] +fb = Fiber.new { 3.times { |i| v = i * 7; Fiber.yield v }; :done } +show :fiber, 4.times.map { fb.resume } +fs = 2.times.map { |k| Fiber.new { loop_yield(3) { |x| Fiber.yield(x + k); x * 10 } } } +show :fibers_interleaved, 4.times.flat_map { fs.map { |f| f.alive? ? f.resume : nil } } +# exception through a reused scope, then reuse again +show :raise_mid, loop_yield(5) { |x| begin; g = x * 2; raise "e#{g}" if x == 2; g; rescue => ex; ex.message; end } +show :raise_out, (loop_yield(5) { |x| h = x; raise ArgumentError, h.to_s if x == 3; h } rescue $!.message) +show :catch_throw, catch(:t) { loop_yield(5) { |x| c = x * 4; throw :t, c if x == 2; c } } +# block with &blk passed further (block-pass) in a loop +def pass_on(&b) [1, 2, 3].map(&b) end +show :pass_on, 3.times.map { |k| pass_on { |x| d = x + k; d.to_s } } +def yield_pass(&b) loop_yield(3, &b) end +show :yield_pass, yield_pass { |x| e2 = x * 5; e2 } +# $~ and $_ scoping in reused scopes +show :match, loop_yield(4) { |x| m = "a#{x}b" =~ /\d/; [m, $~ && $~[0]] } +show :match_nil, loop_yield(3) { |x| x.even? ? ("q" =~ /z/) : 1; $~.inspect } +# local_variables / eval in reused scopes +show :eval, loop_yield(3) { |x| eval("ev = x * 2; ev") + (defined?(ev) ? 1 : 0) } +show :lv, loop_yield(2) { |x| r1 = local_variables.sort; nn = 1; [r1, local_variables.sort] } +def four(x) [yield(x), yield(x + 1), yield(x + 2), yield(x + 3)]; end +def five_sep(x) a = yield(x); b = yield(x + 1); c = yield(x + 2); d = yield(x + 3); e = yield(x + 4); [a, b, c, d, e]; end +# parked scope on the proc (third single call on) then reuse +show :four, four(1) { |y| q = defined?(q).inspect; z = y * 2; [q, z] } +show :five_sep, five_sep(1) { |y| w = (w || :nil); w2 = y; [w, w2.to_s] } +bnd = [] +show :five_bind, five_sep(10) { |y| t = y * 3; bnd << binding if y == 12; t } +show :five_bind_vals, bnd.map { |b| [b.local_variable_get(:t), b.local_variable_get(:y)] } +pr = proc { |x| y = x * 2; $b = binding if x == 3; y } +show :pr_calls, 6.times.map { |i| pr.call(i) } +show :pr_binding_kept, [$b.local_variable_get(:y), $b.local_variable_get(:x)] +pq = proc { |x| u = x; u } +6.times { |i| pq.call(i) } +show :pr_threads, 3.times.map { |t| Thread.new { 50.times.map { |i| pq.call(i * t) }.sum } }.map(&:value) +show :pr_after_threads, 4.times.map { |i| pq.call(i) } +# breaks/returns after a scope was parked on the BlockParam +show :break_late, loop_yield(8) { |x| v = x * 5; break [:late, v] if x == 5; v } +def ret_late; loop_yield(8) { |x| v = x.succ; return [:ret_late, v] if x == 6; v }; :none; end +show :ret_late, ret_late +show :lib_break_late, (1..10).each { |x| y = x * x; break y if x == 7 } +show :lib_next_late, (1..6).map { |x| y = x * x; next -y if x > 3; y } +show :lib_after_break, 3.times.map { (1..10).each { |x| y = x; break y if x == 4 } } +# break target vs parked scope: the break from a later call targets the right frame +def outer; [1, 2, 3].each { |a| loop_yield(5) { |x| c = x + a; break c if x == 3 } }; :done; end +show :nested_break, outer +r = [] +def inner_break; [1, 2].map { |k| loop_yield(6) { |x| m = x * k; break m if x == 4; m } }; end +show :inner_break, inner_break +# a lambda yielded repeatedly by a library method, returns +lam = lambda { |x| l2 = x * 2; return l2 if x > 2; -l2 } +show :lambda_each, [1, 2, 3, 4].map(&lam) +# exceptions after parking leave the scope behind, then reuse resumes +show :raise_late, loop_yield(7) { |x| begin; e = x; raise "x" if x == 4; e; rescue; :resc; end } +show :raise_through, (begin; loop_yield(7) { |x| e = x; raise "out#{e}" if x == 5; e }; rescue => ex; ex.message; end) +show :after_raise, loop_yield(4) { |x| e = x * 2; e } +# recursion through one BlockParam (each_with_object with recursive lambda) +fib = proc { |n| a = n; a < 2 ? a : fib.call(a - 1) + fib.call(a - 2) } +show :fib, (0..12).map { |i| fib.call(i) } +def walk(t, &b) t.is_a?(Array) ? t.map { |c| walk(c, &b) } : b.call(t) end +show :walk, walk([1, [2, [3, 4]], 5, [6]]) { |v| s = v * 10; s } +# Enumerator over a reused block via external next across fibers +en = (1..8).each_slice(3) +show :ext, [en.next, en.next, en.next, (en.next rescue :stop)] +e2 = Enumerator.new { |y| i = 0; loop { k = i * 2; y << k; i += 1 } } +show :ext2, [e2.next, e2.next, e2.next, e2.take(4)] +# $~ in reused scopes across calls +show :match_reuse, loop_yield(5) { |x| ("ab#{x}" =~ /b(\d)/) && $1 } +show :match_after, loop_yield(4) { |x| x.even? ? ("z" =~ /q/) : ("q#{x}" =~ /q(.)/); $~ && $1 } +# ObjectSpace / trace_var / TracePoint capturing late +tps = [] +tp = TracePoint.new(:b_call) { |t| tps << t.binding.local_variables.size if tps.size < 3 } +loop_yield(3) { |x| y = x; y } +tp.enable { loop_yield(3) { |x| y = x; y } } +show :tracepoint, tps +show :after_tp, loop_yield(4) { |x| g = x; local_variables.size + g } From 427206c97aba9a42bc1c8624e80bb34c105459e1 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 14:39:15 +0200 Subject: [PATCH 88/94] WIP: take a scope kept on the BlockParam inline Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/RubyOps.cs | 56 ++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 9c2501a5..abb92077 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -271,10 +271,19 @@ public static RubyBlockScope CreateBlockScopeIfTraced(MutableTuple locals, strin RubyUtils.SafePoint(); - // a block called once finds nothing kept on its BlockParam or proc: the test stays small for that + // A block called once finds nothing kept on its BlockParam or proc, and a block called repeatedly + // through one BlockParam (each, times, a yield in a loop) finds the scope there: both inline. RubyBlockScope scope = null; - if (blockParam.MayHaveReusableScope || blockParam.Proc.ReusableScope != null) { - scope = TakeReusableBlockScope(locals, blockParam, selfObject, interpretedFrame); + if (blockParam.MayHaveReusableScope) { + scope = blockParam.ReusableScope; + if (scope != null && !scope.IsCaptured) { + blockParam.TakeReusableScope(); + scope.Reinitialize(locals, blockParam, selfObject, interpretedFrame); + } else { + scope = TakeReusableProcScope(locals, blockParam, selfObject, interpretedFrame); + } + } else if (blockParam.Proc.ReusableScope != null) { + scope = TakeReusableProcScope(locals, blockParam, selfObject, interpretedFrame); } if (scope == null) { scope = new RubyBlockScope(locals, variableNames ?? ArrayUtils.EmptyStrings, blockParam, selfObject, interpretedFrame); @@ -287,24 +296,20 @@ public static RubyBlockScope CreateBlockScopeIfTraced(MutableTuple locals, strin return scope; } + // Out of line: the path of a scope kept on the proc, not the BlockParam's own. + // (a scope that something captured after its call had returned is not reused either) [MethodImpl(MethodImplOptions.NoInlining)] - private static RubyBlockScope TakeReusableBlockScope(MutableTuple locals, BlockParam/*!*/ blockParam, + private static RubyBlockScope TakeReusableProcScope(MutableTuple locals, BlockParam/*!*/ blockParam, object selfObject, InterpretedFrame interpretedFrame) { - // (a scope that something captured after its call had returned is not reused either) - var scope = blockParam.ReusableScope; - if (scope != null && !scope.IsCaptured) { - blockParam.TakeReusableScope(); + // The block passes the locals TakeReusableLocals found on the proc's scope - which it + // checked just now - or a tuple of its own; one without a reusable tuple, null or a new one. + var proc = blockParam.Proc; + var scope = proc.ReusableScope; + if (scope != null && (locals != null && scope.Locals == locals || IsReusableBlockScope(scope))) { + proc.TakeReusableScope(); } else { - // The block passes the locals TakeReusableLocals found on the proc's scope - which it - // checked just now - or a tuple of its own; one without a reusable tuple, null or a new one. - var proc = blockParam.Proc; - scope = proc.ReusableScope; - if (scope != null && (locals != null && scope.Locals == locals || IsReusableBlockScope(scope))) { - proc.TakeReusableScope(); - } else { - return null; - } + return null; } scope.Reinitialize(locals, blockParam, selfObject, interpretedFrame); @@ -317,19 +322,20 @@ private static RubyBlockScope TakeReusableBlockScope(MutableTuple locals, BlockP /// [Emitted, MethodImpl(MethodImplOptions.AggressiveInlining)] public static MutableTuple TakeReusableLocals(BlockParam/*!*/ blockParam) { - if (!blockParam.MayHaveReusableScope && blockParam.Proc.ReusableScope == null) { + if (blockParam.MayHaveReusableScope) { + var scope = blockParam.ReusableScope; + if (scope != null && !scope.IsCaptured) { + return scope.Locals; + } + } else if (blockParam.Proc.ReusableScope == null) { return null; } - return GetReusableLocals(blockParam); + return GetReusableProcLocals(blockParam); } [MethodImpl(MethodImplOptions.NoInlining)] - private static MutableTuple GetReusableLocals(BlockParam/*!*/ blockParam) { - var scope = blockParam.ReusableScope; - if (scope != null && !scope.IsCaptured) { - return scope.Locals; - } - scope = blockParam.Proc.ReusableScope; + private static MutableTuple GetReusableProcLocals(BlockParam/*!*/ blockParam) { + var scope = blockParam.Proc.ReusableScope; return (scope != null && IsReusableBlockScope(scope)) ? scope.Locals : null; } From dfbb66f23553418361c2651c2afd544ea0dae0a1 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 14:45:17 +0200 Subject: [PATCH 89/94] Gate the inline BlockParam scope reuse 427206c9 takes a scope kept on the BlockParam inline in the block (each, times, a yield in a loop) instead of through an out-of-line call; only the proc's own kept scope goes out of line. Probe (thread CPU ns/op, best of 6 alternating processes; tipref d6474b6a / 185b9bed / this): each { |x| x * 2 } 61.6 / 36.8 / 32.3, each { |x| y = x * 2 } 59.2 / 37.4 / 33.5, N.times 64.6 / 45.7 / 39.3, pr.call 115.3 / 106.1 / 101.3, single yield 182.8 / 181.8 / 183.0 (noise 2-35). Util/bench vs tipref (best of 5-10, alternating): each_loop 0.278 -> 0.220, times_loop 0.261 -> 0.231, upto_loop 0.248 -> 0.226, proc_call 0.230 -> 0.222, block_pass 0.210 -> 0.214, block_literal_cfunc 0.215 -> 0.213, yield_0 0.349 -> 0.350, yield_3 0.242 -> 0.246, yield_once_call 0.453 -> 0.452 (n=10), then_call 0.477 -> 0.461 (n=10); noise floor 0.03-0.18 s. Gates: sp-tests 0, sp-lang 2934/0/0, each_loop 0.278s -> 0.220s, yield_once_call 0.453s -> 0.452s Co-Authored-By: Claude Opus 5.5 From 3ac3d35551ec55f95f83dd3b4efa7af0f82d0e2a Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 17:14:52 +0200 Subject: [PATCH 90/94] WIP: take a block scope and its tuple together, after the safe point; drop the proc-kept scope The block entry took the locals tuple of a scope kept on the BlockParam or proc and only then ran the safe point, where a trap handler or finalizer calling the same block could take that scope and leave the interrupted call a new scope over the same tuple. TakeReusableBlockScope now runs the safe point and then takes the scope off the BlockParam; the tuple is read from the taken scope. The proc-kept scope (Proc.RareState.ReusableScope, the call counter, single-call BlockParams) is gone: scopes are only kept on a BlockParam that calls its block repeatedly. Util/block-scope-reuse-race.rb: trap/finalizer regression checks (fail on the previous commit). Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/Proc.cs | 39 ------ Src/Ruby/Compiler/Ast/BlockDefinition.cs | 26 +++- Src/Ruby/Compiler/Ast/ScopeBuilder.cs | 32 +++-- .../Compiler/ReflectionCache.Generated.cs | 8 +- Src/Ruby/Runtime/BlockParam.cs | 21 +-- Src/Ruby/Runtime/RubyOps.cs | 126 ++++++------------ Src/Ruby/Runtime/RubyScope.cs | 33 +---- Util/block-scope-reuse-blockpass.rb | 4 +- Util/block-scope-reuse-matrix.rb | 2 +- Util/block-scope-reuse-race.rb | 101 ++++++++++++++ 10 files changed, 200 insertions(+), 192 deletions(-) create mode 100644 Util/block-scope-reuse-race.rb diff --git a/Src/Ruby/Builtins/Proc.cs b/Src/Ruby/Builtins/Proc.cs index a0a07aae..c688da00 100644 --- a/Src/Ruby/Builtins/Proc.cs +++ b/Src/Ruby/Builtins/Proc.cs @@ -74,10 +74,6 @@ public partial class Proc : RuntimeFlowControl, IDuplicable { // The proc is its own converter: a call it was the literal block of converted it (see Converter). private const int SelfConverterFlag = 4; - // How many single calls of the proc (a yield or Proc#call) have finished, up to two (see KeepReusableScope). - private const int CallCountUnit = 8; - private const int CallCountMask = 3 * CallCountUnit; - /// /// The state few procs have, out of line: a Proc is allocated for every call with a literal block. /// @@ -85,9 +81,6 @@ private sealed class RareState { public RubyLambdaMethodInfo Method; public RefinementActivation RefinementOverride; public string SymbolName; - // The scope of a finished call of this proc that nothing captured, for the next call on the - // same thread to reuse (see RubyOps.CreateReusableBlockScope). - public RubyBlockScope ReusableScope; } private RareState _rare; @@ -130,38 +123,6 @@ public ProcKind Kind { internal set { _kind = (_kind & ~KindMask) | (int)value; } } - /// - /// The scope a finished call kept (see KeepReusableScope), or null. - /// - internal RubyBlockScope ReusableScope { - get { var r = _rare; return (r != null) ? r.ReusableScope : null; } - set { - // (another thread may be making the rare state too: keep the one that is there) - var r = _rare ?? System.Threading.Interlocked.CompareExchange(ref _rare, new RareState(), null) ?? _rare; - r.ReusableScope = value; - } - } - - // The ReusableScope is taken for a call of the proc. - internal void TakeReusableScope() { - _rare.ReusableScope = null; - } - - /// - /// Whether a single call of the proc that has finished keeps its scope for the next call: from the third - /// call on. A literal block is a new Proc for every call it is passed to and most are called once or twice, - /// where keeping a scope (and the rare state that holds it) would not pay off. The count only ever goes - /// up: the kind and converter bits are written before the proc's first call. - /// - internal bool KeepReusableScope() { - int kind = _kind; - if ((kind & CallCountMask) == 2 * CallCountUnit) { - return true; - } - _kind = kind + CallCountUnit; - return false; - } - internal RubyLambdaMethodInfo Method { get { var r = _rare; return (r != null) ? r.Method : null; } } diff --git a/Src/Ruby/Compiler/Ast/BlockDefinition.cs b/Src/Ruby/Compiler/Ast/BlockDefinition.cs index 441aaec5..5afd7110 100644 --- a/Src/Ruby/Compiler/Ast/BlockDefinition.cs +++ b/Src/Ruby/Compiler/Ast/BlockDefinition.cs @@ -272,15 +272,31 @@ public BlockDefinition(LexicalScope/*!*/ definedScope, Parameters parameters, St ); } - body = scope.CreateScope( - scopeVariable, - (lazyScope != null ? Methods.CreateBlockScopeIfTraced : reuseScope ? Methods.CreateReusableBlockScope : Methods.CreateBlockScope).OpCall(new AstExpressions { - reuseScope ? scope.MakeReusableLocalsStorage(blockParameter) : scope.MakeLocalsStorage(), + MSA.Expression enterScope; + if (reuseScope) { + // the scope to reuse is taken first, its tuple from it (see RubyOps.TakeReusableBlockScope) + var reusableScope = scope.DefineHiddenVariable("#reusable", typeof(RubyBlockScope)); + enterScope = Methods.CreateReusableBlockScope.OpCall(new AstExpressions { + scope.MakeReusableLocalsStorage(blockParameter, reusableScope), + scope.GetVariableNamesExpression(), + blockParameter, + selfParameter, + EnterInterpretedFrameExpression.Instance, + reusableScope + }); + } else { + enterScope = (lazyScope != null ? Methods.CreateBlockScopeIfTraced : Methods.CreateBlockScope).OpCall(new AstExpressions { + scope.MakeLocalsStorage(), scope.GetVariableNamesExpression(), blockParameter, selfParameter, EnterInterpretedFrameExpression.Instance - }), + }); + } + + body = scope.CreateScope( + scopeVariable, + enterScope, body ); diff --git a/Src/Ruby/Compiler/Ast/ScopeBuilder.cs b/Src/Ruby/Compiler/Ast/ScopeBuilder.cs index f698b527..b6298357 100644 --- a/Src/Ruby/Compiler/Ast/ScopeBuilder.cs +++ b/Src/Ruby/Compiler/Ast/ScopeBuilder.cs @@ -220,7 +220,7 @@ private int LiftedVisibleVariableCount { } /// - /// Whether a block that reuses its scope (see RubyOps.CreateReusableBlockScope) reuses the + /// Whether a block that reuses its scope (see RubyOps.TakeReusableBlockScope) reuses the /// scope's locals tuple too: one of up to 8 locals, cleared when the call finishes. /// internal bool HasReusableLocals { @@ -230,20 +230,28 @@ internal bool HasReusableLocals { } } - public MSA.Expression/*!*/ MakeReusableLocalsStorage(MSA.Expression/*!*/ blockParameter) { + /// + /// Takes the scope the last call of the block kept (into , null if there is + /// none) and then makes the locals storage: the taken scope's tuple, if the block reuses it, or a new one. + /// A tuple is only ever reused together with the scope it came from. + /// + public MSA.Expression/*!*/ MakeReusableLocalsStorage(MSA.Expression/*!*/ blockParameter, MSA.ParameterExpression/*!*/ reusableScope) { + var take = Ast.Assign(reusableScope, Methods.TakeReusableBlockScope.OpCall(blockParameter)); if (!HasReusableLocals) { - return MakeLocalsStorage(); + return Ast.Block(take, MakeLocalsStorage()); } // (the type test only when there is a tuple to reuse: a block called once has none) - var reusable = DefineHiddenVariable("#reusable", typeof(MutableTuple)); - return Ast.Assign(_localsTuple, Ast.Coalesce( - Ast.Condition( - Ast.Equal(Ast.Assign(reusable, Methods.TakeReusableLocals.OpCall(blockParameter)), AstUtils.Constant(null)), - AstUtils.Constant(null, _localsTuple.Type), - Ast.TypeAs(reusable, _localsTuple.Type) - ), - Ast.New(_localsTuple.Type) - )); + return Ast.Block( + take, + Ast.Assign(_localsTuple, Ast.Coalesce( + Ast.Condition( + Ast.Equal(reusableScope, AstUtils.Constant(null)), + AstUtils.Constant(null, _localsTuple.Type), + Ast.TypeAs(Methods.GetReusableLocals.OpCall(reusableScope), _localsTuple.Type) + ), + Ast.New(_localsTuple.Type) + )) + ); } public MSA.Expression/*!*/ MakeClearLocals() { diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index 20f6062a..bdf504ad 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -106,7 +106,7 @@ public static partial class Methods { private static MethodInfo _CreateBlockScopeIfTraced; public static MethodInfo/*!*/ CreateBlockScopeLazy { get { return _CreateBlockScopeLazy ?? (_CreateBlockScopeLazy = CallInstruction.CacheFunc(RubyOps.CreateBlockScopeLazy)); } } private static MethodInfo _CreateBlockScopeLazy; - public static MethodInfo/*!*/ CreateReusableBlockScope { get { return _CreateReusableBlockScope ?? (_CreateReusableBlockScope = CallInstruction.CacheFunc(RubyOps.CreateReusableBlockScope)); } } + public static MethodInfo/*!*/ CreateReusableBlockScope { get { return _CreateReusableBlockScope ?? (_CreateReusableBlockScope = CallInstruction.CacheFunc(RubyOps.CreateReusableBlockScope)); } } private static MethodInfo _CreateReusableBlockScope; public static MethodInfo/*!*/ CreateBoundMember { get { return _CreateBoundMember ?? (_CreateBoundMember = CallInstruction.CacheFunc(RubyOps.CreateBoundMember)); } } private static MethodInfo _CreateBoundMember; @@ -426,8 +426,10 @@ public static partial class Methods { private static MethodInfo _LeaveLoop; public static MethodInfo/*!*/ LeaveReusableBlockScope { get { return _LeaveReusableBlockScope ?? (_LeaveReusableBlockScope = CallInstruction.CacheFunc(RubyOps.LeaveReusableBlockScope)); } } private static MethodInfo _LeaveReusableBlockScope; - public static MethodInfo/*!*/ TakeReusableLocals { get { return _TakeReusableLocals ?? (_TakeReusableLocals = CallInstruction.CacheFunc(RubyOps.TakeReusableLocals)); } } - private static MethodInfo _TakeReusableLocals; + public static MethodInfo/*!*/ TakeReusableBlockScope { get { return _TakeReusableBlockScope ?? (_TakeReusableBlockScope = CallInstruction.CacheFunc(RubyOps.TakeReusableBlockScope)); } } + private static MethodInfo _TakeReusableBlockScope; + public static MethodInfo/*!*/ GetReusableLocals { get { return _GetReusableLocals ?? (_GetReusableLocals = CallInstruction.CacheFunc(RubyOps.GetReusableLocals)); } } + private static MethodInfo _GetReusableLocals; public static MethodInfo/*!*/ LeaveMethodFrame { get { return _LeaveMethodFrame ?? (_LeaveMethodFrame = CallInstruction.CacheAction(RubyOps.LeaveMethodFrame)); } } private static MethodInfo _LeaveMethodFrame; public static MethodInfo/*!*/ LeaveProcCall { get { return _LeaveProcCall ?? (_LeaveProcCall = CallInstruction.CacheAction(RubyOps.LeaveProcCall)); } } diff --git a/Src/Ruby/Runtime/BlockParam.cs b/Src/Ruby/Runtime/BlockParam.cs index 2e5cbe6c..776c737e 100644 --- a/Src/Ruby/Runtime/BlockParam.cs +++ b/Src/Ruby/Runtime/BlockParam.cs @@ -95,12 +95,10 @@ public sealed partial class BlockParam { private readonly Proc/*!*/ _proc; // The small state is packed into bytes, which keeps a BlockParam at 48 bytes: one is allocated for every // call of a library method with a block, and for every yield and Proc#call. - // bit 0: BlockCallerKind.Call; bit 1: the library method call taking this BlockParam is a proc converter; - // bit 2: made for a single call of the block (a yield or a Proc#call; see RubyOps.LeaveReusableBlockScope). - private byte _callerFlags; + // bit 0: BlockCallerKind.Call; bit 1: the library method call taking this BlockParam is a proc converter. + private readonly byte _callerFlags; private const int CallFlag = 1; private const int LibProcConverterFlag = 2; - internal const int SingleCallFlag = 4; // filled by module_eval: if not null than method definition uses the module private RubyModule _methodLookupModule; @@ -125,9 +123,6 @@ public sealed partial class BlockParam { internal BlockCallerKind CallerKind { get { return (_callerFlags & CallFlag) != 0 ? BlockCallerKind.Call : BlockCallerKind.Yield; } } - // Made for a single call of the block: a scope it leaves is kept on the proc instead. - internal bool IsSingleCall { get { return (_callerFlags & SingleCallFlag) != 0; } } - /// /// The scope ParkScope kept, or null. /// @@ -215,19 +210,17 @@ internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind, bool isLibProcCo // A BlockParam that is only ever used on the thread creating it, for the duration of a call: // a yield or a Proc#call. There the thread check in Break always passes, so skip it. - internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind, int flags) { - _callerFlags = (byte)((callerKind == BlockCallerKind.Call ? CallFlag : 0) | flags); + internal BlockParam(Proc/*!*/ proc, BlockCallerKind callerKind) { + _callerFlags = (byte)(callerKind == BlockCallerKind.Call ? CallFlag : 0); _proc = proc; } - // ReuseBfcForYield: the flow-control state is cleared, but the scope the last call kept (see ParkScope) stays, - // and the calls through this BlockParam are no longer a single call. + // ReuseBfcForYield: the flow-control state is cleared, but the scope the last call kept (see ParkScope) stays. internal void ResetForNextYield() { if ((_flow & ~CalledFlag) != 0) { _targetFrame = null; } _flow = CalledFlag; - _callerFlags &= unchecked((byte)~SingleCallFlag); } internal void SetFlowControl(BlockReturnReason reason, RuntimeFlowControl targetFrame, ProcKind sourceProcKind) { @@ -376,7 +369,7 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* [Emitted] public static BlockParam/*!*/ CreateBfcForYield(Proc proc) { if (proc != null) { - return new BlockParam(proc, BlockCallerKind.Yield, BlockParam.SingleCallFlag); + return new BlockParam(proc, BlockCallerKind.Yield); } else { throw RubyExceptions.NoBlockGiven(); } @@ -400,7 +393,7 @@ public static bool IsProcConverterTarget(BlockParam/*!*/ bfc, MethodUnwinder/*!* [Emitted] public static BlockParam/*!*/ CreateBfcForProcCall(Proc/*!*/ proc) { Assert.NotNull(proc); - return new BlockParam(proc, BlockCallerKind.Call, BlockParam.SingleCallFlag) { IsActiveLambdaCall = proc.Kind == ProcKind.Lambda }; + return new BlockParam(proc, BlockCallerKind.Call) { IsActiveLambdaCall = proc.Kind == ProcKind.Lambda }; } [Emitted] diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index abb92077..daae26b3 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -257,121 +257,75 @@ public static RubyBlockScope CreateBlockScopeIfTraced(MutableTuple locals, strin } /// - /// The entry of a block whose body needs its scope but does not define a closure over it: the - /// scope a finished call left on the BlockParam (each, map, times, a yield in a loop) or, for - /// a BlockParam of a single call (Proc#call, a plain yield), on the proc is reused, unless - /// something captured it (see RubyScope.IsCaptured). - /// hands it back. Only the thread whose call kept a scope on a proc takes it off, so a proc called on - /// several threads needs no lock; a scope is taken while its call runs, and a nested call - /// of the same block makes its own. + /// The first step of the entry of a block whose body needs its scope but does not define a closure + /// over it: the safe point, then the scope the last finished call through this BlockParam left there + /// (each, map, times, a yield in a loop; see BlockParam.ParkScope) unless something captured it, or null. + /// The scope comes off the BlockParam here, after the safe point (which can run a trap handler or a + /// finalizer) and together with its locals tuple: a call that starts in between finds nothing kept and + /// makes a scope and tuple of its own, and a call that does not finish normally keeps nothing. + /// The block reuses the scope's tuple (see ) and hands the scope to + /// ; parks it again. /// [Emitted, MethodImpl(MethodImplOptions.AggressiveInlining)] - public static RubyBlockScope/*!*/ CreateReusableBlockScope(MutableTuple locals, string[] variableNames, - BlockParam/*!*/ blockParam, object selfObject, InterpretedFrame interpretedFrame) { - + public static RubyBlockScope TakeReusableBlockScope(BlockParam/*!*/ blockParam) { + // Block entry too: `n.times { }` and `each { }` loop in C#, where no back edge is emitted. RubyUtils.SafePoint(); - // A block called once finds nothing kept on its BlockParam or proc, and a block called repeatedly - // through one BlockParam (each, times, a yield in a loop) finds the scope there: both inline. - RubyBlockScope scope = null; if (blockParam.MayHaveReusableScope) { - scope = blockParam.ReusableScope; + var scope = blockParam.ReusableScope; if (scope != null && !scope.IsCaptured) { blockParam.TakeReusableScope(); - scope.Reinitialize(locals, blockParam, selfObject, interpretedFrame); - } else { - scope = TakeReusableProcScope(locals, blockParam, selfObject, interpretedFrame); + return scope; } - } else if (blockParam.Proc.ReusableScope != null) { - scope = TakeReusableProcScope(locals, blockParam, selfObject, interpretedFrame); - } - if (scope == null) { - scope = new RubyBlockScope(locals, variableNames ?? ArrayUtils.EmptyStrings, blockParam, selfObject, interpretedFrame); - } - - if ((TracePoint.ActiveEvents & (int)(TraceEvents.BCall | TraceEvents.Call)) != 0) { - scope.MarkCaptured(); - TracePoint.OnBlockCall(scope); } - return scope; + return null; } - // Out of line: the path of a scope kept on the proc, not the BlockParam's own. - // (a scope that something captured after its call had returned is not reused either) - [MethodImpl(MethodImplOptions.NoInlining)] - private static RubyBlockScope TakeReusableProcScope(MutableTuple locals, BlockParam/*!*/ blockParam, - object selfObject, InterpretedFrame interpretedFrame) { - - // The block passes the locals TakeReusableLocals found on the proc's scope - which it - // checked just now - or a tuple of its own; one without a reusable tuple, null or a new one. - var proc = blockParam.Proc; - var scope = proc.ReusableScope; - if (scope != null && (locals != null && scope.Locals == locals || IsReusableBlockScope(scope))) { - proc.TakeReusableScope(); - } else { - return null; - } - - scope.Reinitialize(locals, blockParam, selfObject, interpretedFrame); - return scope; + /// + /// The locals tuple of a scope took, for the block to reuse (the + /// last call cleared it); null if it took none. + /// + [Emitted, MethodImpl(MethodImplOptions.AggressiveInlining)] + public static MutableTuple GetReusableLocals(RubyBlockScope reusable) { + return (reusable != null) ? reusable._locals : null; } /// - /// The locals tuple of the scope is about to reuse, for the - /// block to clear and keep instead of allocating another; null if there is no such scope. + /// The scope of a block call that began: the scope it took, made the + /// scope of this call, or a new one. /// [Emitted, MethodImpl(MethodImplOptions.AggressiveInlining)] - public static MutableTuple TakeReusableLocals(BlockParam/*!*/ blockParam) { - if (blockParam.MayHaveReusableScope) { - var scope = blockParam.ReusableScope; - if (scope != null && !scope.IsCaptured) { - return scope.Locals; - } - } else if (blockParam.Proc.ReusableScope == null) { - return null; - } - return GetReusableProcLocals(blockParam); - } + public static RubyBlockScope/*!*/ CreateReusableBlockScope(MutableTuple locals, string[] variableNames, + BlockParam/*!*/ blockParam, object selfObject, InterpretedFrame interpretedFrame, RubyBlockScope reusable) { - [MethodImpl(MethodImplOptions.NoInlining)] - private static MutableTuple GetReusableProcLocals(BlockParam/*!*/ blockParam) { - var scope = blockParam.Proc.ReusableScope; - return (scope != null && IsReusableBlockScope(scope)) ? scope.Locals : null; - } + RubyBlockScope scope; + if (reusable != null) { + scope = reusable; + scope.Reinitialize(locals, blockParam, selfObject, interpretedFrame); + } else { + scope = new RubyBlockScope(locals, variableNames ?? ArrayUtils.EmptyStrings, blockParam, selfObject, interpretedFrame); + } - // Out of line: the path of a scope kept on the proc, not the BlockParam's own. - [MethodImpl(MethodImplOptions.NoInlining)] - private static bool IsReusableBlockScope(RubyBlockScope/*!*/ scope) { - return scope.OwnerThreadId == Environment.CurrentManagedThreadId && !scope.IsCaptured; + if ((TracePoint.ActiveEvents & (int)(TraceEvents.BCall | TraceEvents.Call)) != 0) { + scope.MarkCaptured(); + TracePoint.OnBlockCall(scope); + } + return scope; } /// /// The normal exit of a block that made its scope by . /// An exception leaving the body skips this, and the scope is simply not reused. True if the - /// scope was kept, with its locals tuple when : the block then - /// clears the tuple, so that the next call finds its locals nil. The first call through a - /// BlockParam and the first two of a proc keep nothing (see BlockParam.ParkScope, Proc.KeepReusableScope): - /// a block called once pays no more than a flag. + /// scope was kept on the BlockParam, with its locals tuple when : the + /// block then clears the tuple, so that the next call finds its locals nil. The first call through + /// a BlockParam keeps nothing (see BlockParam.ParkScope): a block called once pays no more than a flag. /// [Emitted] public static bool LeaveReusableBlockScope(RubyBlockScope/*!*/ scope, bool keepLocals) { if (scope.IsCaptured) { return false; } - var blockParam = scope.BlockFlowControl; - if (!blockParam.IsSingleCall) { - return blockParam.ParkScope(scope); - } - if (!blockParam.Proc.KeepReusableScope()) { - return false; - } - return ReleaseToProc(scope, keepLocals); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static bool ReleaseToProc(RubyBlockScope/*!*/ scope, bool keepLocals) { - scope.Release(keepLocals); - return true; + return scope.BlockFlowControl.ParkScope(scope); } [Emitted] diff --git a/Src/Ruby/Runtime/RubyScope.cs b/Src/Ruby/Runtime/RubyScope.cs index 963a3192..bf917e0d 100644 --- a/Src/Ruby/Runtime/RubyScope.cs +++ b/Src/Ruby/Runtime/RubyScope.cs @@ -93,7 +93,7 @@ public abstract class RubyScope : RuntimeFlowControl { // Set once something may hold on to this scope after the call it belongs to has returned: a // Proc, Binding, method or nested scope made in it, a TracePoint event. A block scope that is - // not captured is reused for the next call of its proc (see RubyOps.CreateReusableBlockScope). + // not captured is reused for the next call of its block (see RubyOps.TakeReusableBlockScope). internal bool IsCaptured; // Libraries call this for a scope they keep after returning. @@ -239,7 +239,7 @@ internal RubyScope() { // Perf note: Initialization is performed completely in the most derived scope classes to improve perf. } - // The per-call state of a scope reused for another call of its proc, as a new scope has it. + // The per-call state of a scope reused for another call of its block, as a new scope has it. internal void ResetForReuse(MutableTuple locals, object selfObject, InterpretedFrame interpretedFrame) { _activeFlowControlScope = _parent.FlowControlScope; InLoop = false; @@ -252,18 +252,6 @@ internal void ResetForReuse(MutableTuple locals, object selfObject, InterpretedF InterpretedFrame = interpretedFrame; } - // Drops what the finished call referred to, so that a scope kept on its proc holds on to no values. - internal void ClearForReuse(bool keepLocals) { - _activeFlowControlScope = null; - if (!keepLocals) { - _locals = null; - } - _selfObject = null; - _selfImmediateClass = null; - _rare = null; - InterpretedFrame = null; - } - public virtual RubyModule Module { get { return null; } } @@ -1347,9 +1335,6 @@ internal override void DefineDynamicVariable(string/*!*/ name, object value) { public sealed class RubyBlockScope : RubyClosureScope { private BlockParam/*!*/ _blockFlowControl; - // The thread whose call kept the scope on its proc, the only one that may reuse it (see RubyOps.CreateReusableBlockScope). - internal int OwnerThreadId; - // The block a thread runs keeps its own $~ and $_, as MRI's thread root frame does; any // other block shares them with the method it is in. protected override bool IsClosureScope { @@ -1409,7 +1394,7 @@ internal RubyBlockScope(MutableTuple locals, string/*!*/[]/*!*/ variableNames, _blockFlowControl = blockFlowControl; } - // Makes this scope, which nothing has captured, the scope of another call of its proc. + // Makes this scope, which nothing has captured, the scope of another call of its block. internal void Reinitialize(MutableTuple locals, BlockParam/*!*/ blockFlowControl, object selfObject, InterpretedFrame interpretedFrame) { _blockFlowControl = blockFlowControl; ResetForReuse(locals, selfObject, interpretedFrame); @@ -1417,18 +1402,6 @@ internal void Reinitialize(MutableTuple locals, BlockParam/*!*/ blockFlowControl LastInputLine = null; LambdaReturnState = 0; } - - // The call has finished and nothing captured the scope: keep it, cleared, on the proc. - internal void Release(bool keepLocals) { - Proc proc = _blockFlowControl.Proc; - ClearForReuse(keepLocals); - CurrentMatch = null; - LastInputLine = null; - _blockFlowControl = null; - // the thread that ran the call, the only one that may reuse it - OwnerThreadId = Environment.CurrentManagedThreadId; - proc.ReusableScope = this; - } } /// diff --git a/Util/block-scope-reuse-blockpass.rb b/Util/block-scope-reuse-blockpass.rb index 890aceaf..3089a7df 100644 --- a/Util/block-scope-reuse-blockpass.rb +++ b/Util/block-scope-reuse-blockpass.rb @@ -1,6 +1,6 @@ # Differential checks for block scope reuse together with literal-block procs (block-pass): # break/next/return through reused scopes, scopes kept on the BlockParam (in its break-target -# slot) and on the proc (from its third single call), proc identity, escaped procs and bindings, +# slot), separate yields of one block (a BlockParam each), proc identity, escaped procs and bindings, # threads and fibers. Every block body here makes calls, so it takes the reuse path; the output # must match CRuby, with and without -X:NoJIT -X:NoOSR: # @@ -73,7 +73,7 @@ def yield_pass(&b) loop_yield(3, &b) end show :lv, loop_yield(2) { |x| r1 = local_variables.sort; nn = 1; [r1, local_variables.sort] } def four(x) [yield(x), yield(x + 1), yield(x + 2), yield(x + 3)]; end def five_sep(x) a = yield(x); b = yield(x + 1); c = yield(x + 2); d = yield(x + 3); e = yield(x + 4); [a, b, c, d, e]; end -# parked scope on the proc (third single call on) then reuse +# separate yields of one block: each yield has a BlockParam of its own show :four, four(1) { |y| q = defined?(q).inspect; z = y * 2; [q, z] } show :five_sep, five_sep(1) { |y| w = (w || :nil); w2 = y; [w, w2.to_s] } bnd = [] diff --git a/Util/block-scope-reuse-matrix.rb b/Util/block-scope-reuse-matrix.rb index a70b46a0..4eb1300a 100644 --- a/Util/block-scope-reuse-matrix.rb +++ b/Util/block-scope-reuse-matrix.rb @@ -279,7 +279,7 @@ def locals_part end locals_part -# one proc called through many BlockParams: the scope is kept on the proc, for its thread +# one proc called through many BlockParams (each call makes its own scope) class ReuseD define_method(:dbl) { |x| y = x * 2; y + (z ||= 0) } define_method(:bnd) { |x| y = x * 3; binding } diff --git a/Util/block-scope-reuse-race.rb b/Util/block-scope-reuse-race.rb new file mode 100644 index 00000000..e837f22b --- /dev/null +++ b/Util/block-scope-reuse-race.rb @@ -0,0 +1,101 @@ +# Regression checks for block scope reuse against re-entrant calls of the same block from a +# Signal.trap handler or an ObjectSpace finalizer. Both run at a safe point - a block entry is +# one - so they can start a call of a block while another call of it is being entered. A scope +# and its locals tuple must only ever be reused together: a call must never see the locals of +# another call, nor share its tuple with a Binding that another call captured. +# +# ruby Util/block-scope-reuse-race.rb +# ./ir.sh Util/block-scope-reuse-race.rb (also with -X:NoJIT -X:NoOSR) +# +# Every line must end in "stale 0 leaked 0" (CRuby prints the same); the exit status is 1 otherwise. +# ROUNDS=n scales the work (CRuby runs a trap handler only at its next thread switch: fewer rounds). + +ROUNDS = (ENV['ROUNDS'] || (RUBY_ENGINE == 'ruby' ? 20 : 200)).to_i +$failed = false +def report(name, stale, leaked = 0) + $failed = true unless stale == 0 && leaked == 0 + puts "#{name}: stale #{stale} leaked #{leaked}" +end + +# Fires a trap handler (or, with :gc, a finalizer) while the block runs over and over, and returns +# whether the handler ran. +def interrupt_while(kind) + $hit = false + th = Thread.new do + if kind == :gc + ObjectSpace.define_finalizer(Object.new, FIN) + GC.start + else + sleep 0.002 + Process.kill(:USR1, Process.pid) + end + end + t0 = Time.now + yield until $hit || Time.now - t0 > 2 + th.join + $hit +end + +# a local of the block that every call reads before assigning it: always nil +PR = proc { |m| z = 0 if false; r = z; z = m; $b = binding if m == :cap; r } +5.times { PR.call(1) } + +class Svc + define_method(:step) { |m| seen = nil if false; prev = seen; seen = m; raise "fail" if m == :h; prev } +end +SVC = Svc.new +5.times { SVC.step(1) } + +$stale = 0 +EACH = proc { |m| w = 0 if false; r = w; w = m; $b = binding if m == :cap; $stale += 1 unless r.nil?; r } +ARR = [1, 2, 3, 4, 5, 6, 7, 8] +ARR.each(&EACH) + +FIN = proc { |_| $hit = true; PR.call(:cap) } + +if Signal.list.key?('USR1') + # Proc#call: the handler's call captures its scope in a Binding + Signal.trap(:USR1) { $hit = true; PR.call(:cap) } + stale = leaked = 0 + ROUNDS.times do + $b = nil + interrupt_while(:signal) { stale += 1 unless PR.call(2).nil? } + next unless $b + 4.times { PR.call(3) } + leaked += 1 unless $b.local_variable_get(:z) == :cap + end + report :trap_proc_call, stale, leaked + + # define_method: the handler's call raises after assigning a local + Signal.trap(:USR1) { $hit = true; SVC.step(:h) rescue nil } + stale = 0 + ROUNDS.times { interrupt_while(:signal) { v = SVC.step(2); stale += 1 unless v.nil? } } + report :trap_define_method, stale + + # each: repeated calls through one BlockParam, interrupted by calls of the same block + Signal.trap(:USR1) { $hit = true; EACH.call(:cap) } + $stale = 0 + leaked = 0 + ROUNDS.times do + $b = nil + interrupt_while(:signal) { ARR.each(&EACH) } + next unless $b + ARR.each(&EACH) + leaked += 1 unless $b.local_variable_get(:w) == :cap + end + report :trap_each, $stale, leaked + Signal.trap(:USR1, 'DEFAULT') +end + +# a finalizer calling the proc +stale = leaked = 0 +(ROUNDS / 2).times do + $b = nil + interrupt_while(:gc) { stale += 1 unless PR.call(2).nil? } + next unless $b + 4.times { PR.call(3) } + leaked += 1 unless $b.local_variable_get(:z) == :cap +end +report :finalizer_proc_call, stale, leaked + +exit 1 if $failed From 11e1c57d97dffcd03bdfe20dd9d5088c680a50a9 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 17:30:46 +0200 Subject: [PATCH 91/94] WIP: inline the block scope leave, test the capture only when parking Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Runtime/BlockParam.cs | 6 +++++- Src/Ruby/Runtime/RubyOps.cs | 5 +---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Src/Ruby/Runtime/BlockParam.cs b/Src/Ruby/Runtime/BlockParam.cs index 776c737e..fff83f5b 100644 --- a/Src/Ruby/Runtime/BlockParam.cs +++ b/Src/Ruby/Runtime/BlockParam.cs @@ -143,11 +143,15 @@ internal void TakeReusableScope() { /// /// Keeps the scope of a call of the block that finished normally, for the next call to reuse; not on the /// first call through this BlockParam, which most often is the only one (a library method calling its - /// block once), nor after a break/return/retry, which use the target frame. + /// block once), nor after a break/return/retry, which use the target frame, nor if something captured it. /// + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] internal bool ParkScope(RubyBlockScope/*!*/ scope) { int flow = _flow; if (flow == CalledFlag) { + if (scope.IsCaptured) { + return false; + } _targetFrame = scope; return true; } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index daae26b3..6250e880 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -320,11 +320,8 @@ public static MutableTuple GetReusableLocals(RubyBlockScope reusable) { /// block then clears the tuple, so that the next call finds its locals nil. The first call through /// a BlockParam keeps nothing (see BlockParam.ParkScope): a block called once pays no more than a flag. /// - [Emitted] + [Emitted, MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool LeaveReusableBlockScope(RubyBlockScope/*!*/ scope, bool keepLocals) { - if (scope.IsCaptured) { - return false; - } return scope.BlockFlowControl.ParkScope(scope); } From f8558055e547c2984d5d841a2e6f4f63e043a842 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 18:02:44 +0200 Subject: [PATCH 92/94] WIP: no scope reuse for a block with a nested block literal Its Proc captures the scope on every call, so the reuse path was pure overhead (ruby-bench send_cfunc_block: 20 literal blocks per outer block call). Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Compiler/Ast/BlockDefinition.cs | 4 +++- Src/Ruby/Compiler/AstGenerator.cs | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Src/Ruby/Compiler/Ast/BlockDefinition.cs b/Src/Ruby/Compiler/Ast/BlockDefinition.cs index 5afd7110..7d8e7bd7 100644 --- a/Src/Ruby/Compiler/Ast/BlockDefinition.cs +++ b/Src/Ruby/Compiler/Ast/BlockDefinition.cs @@ -251,7 +251,9 @@ public BlockDefinition(LexicalScope/*!*/ definedScope, Parameters parameters, St // A body that needs its scope but defines no closure over it can reuse the scope of the // previous call of the block, unless something captured that (see RubyScope.IsCaptured). - bool reuseScope = lazyScope == null && !hasNestedReturn && !gen.TraceEnabled && !gen.CompilerOptions.IsEval; + // A nested block literal is a Proc over the scope on every call: nothing to reuse. + bool reuseScope = lazyScope == null && !hasNestedReturn && !gen.CurrentBlock.HasNestedBlock && + !gen.TraceEnabled && !gen.CompilerOptions.IsEval; body = gen.Traceable ? new TraceReturnExpression(lazyScope ?? scopeVariable, gen.AddReturnTarget(body), TraceEvents.BReturn | TraceEvents.Return, gen.SourcePath, Location.End.Line) diff --git a/Src/Ruby/Compiler/AstGenerator.cs b/Src/Ruby/Compiler/AstGenerator.cs index dad45a0a..89c51072 100644 --- a/Src/Ruby/Compiler/AstGenerator.cs +++ b/Src/Ruby/Compiler/AstGenerator.cs @@ -341,6 +341,9 @@ public MSA.LabelTarget/*!*/ RedoLabel { // that return leaves it, so its body catches the LambdaUnwinder. public bool HasNestedReturn; + // A block literal (or lambda) is nested in this one: its Proc captures this block's scope. + public bool HasNestedBlock; + public BlockScope(ScopeBuilder/*!*/ builder, MSA.Expression/*!*/ selfVariable, MSA.ParameterExpression/*!*/ runtimeScopeVariable, MSA.Expression/*!*/ bfcVariable, MSA.LabelTarget/*!*/ redoLabel) : base(builder, selfVariable, runtimeScopeVariable) { @@ -604,6 +607,9 @@ public void EnterBlockDefinition( Assert.NotNull(redoLabel); BlockScope block = new BlockScope(locals, selfVariable, runtimeScopeVariable, bfcVariable, redoLabel); + if (_currentBlock != null) { + _currentBlock.HasNestedBlock = true; + } block.Parent = _currentElement; block.ParentRescue = _currentRescue; block.ParentLoop = _currentLoop; From 78d413388df040558ae9070b11bf25a94166feb8 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 18:07:37 +0200 Subject: [PATCH 93/94] WIP: mark a scope captured where a non-literal Proc or an eval takes it, not in every Proc A literal block's Proc is made on every call of its enclosing code; with blocks that nest a block literal excluded from scope reuse, only Method#to_proc, Symbol#to_proc and string eval can still put a reusable block scope into a Proc. Co-Authored-By: Claude Opus 5.5 --- Src/Ruby/Builtins/Proc.cs | 4 +++- Src/Ruby/Builtins/RubyMethod.cs | 1 + Src/Ruby/Runtime/RubyUtils.cs | 3 +++ Util/block-scope-reuse-matrix.rb | 24 ++++++++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Src/Ruby/Builtins/Proc.cs b/Src/Ruby/Builtins/Proc.cs index c688da00..01ba5c27 100644 --- a/Src/Ruby/Builtins/Proc.cs +++ b/Src/Ruby/Builtins/Proc.cs @@ -176,7 +176,8 @@ internal Proc(ProcKind kind, object self, RubyScope/*!*/ scope, BlockDispatcher/ _self = self; _scope = scope; _dispatcher = dispatcher; - scope.MarkCaptured(); + // (no RubyScope.MarkCaptured here: a literal block makes a Proc over its enclosing scope on every + // call, and a block with a nested block literal never reuses its scope - see BlockDefinition) } protected Proc(Proc/*!*/ proc) @@ -457,6 +458,7 @@ public object CallN(Proc procArg, object[]/*!*/ args) { // MRI's symbol proc is a lambda: it reports #lambda? true, arity -2 and // [[:req], [:rest]] for #parameters + scope.MarkCaptured(); return new Proc(ProcKind.Lambda, scope.SelfObject, scope, procDispatcher) { SymbolName = methodName }; } diff --git a/Src/Ruby/Builtins/RubyMethod.cs b/Src/Ruby/Builtins/RubyMethod.cs index 554f12dd..a051fa82 100644 --- a/Src/Ruby/Builtins/RubyMethod.cs +++ b/Src/Ruby/Builtins/RubyMethod.cs @@ -158,6 +158,7 @@ object IDuplicable.Duplicate(RubyContext/*!*/ context, bool copySingletonMembers // method, both of which are lambda behaviour, so MRI's Method#to_proc answers // #lambda? with true. Its self is the method's receiver rather than whatever self // the to_proc call happened under, which is what #binding hands back. + scope.MarkCaptured(); return new Proc(ProcKind.Lambda, _target, scope, _procDispatcher); } diff --git a/Src/Ruby/Runtime/RubyUtils.cs b/Src/Ruby/Runtime/RubyUtils.cs index 42aedf78..da89b3da 100644 --- a/Src/Ruby/Runtime/RubyUtils.cs +++ b/Src/Ruby/Runtime/RubyUtils.cs @@ -1323,6 +1323,9 @@ public static object Evaluate(MutableString/*!*/ code, RubyScope/*!*/ targetScop RubyModule lexicalConstantModule, MutableString file, int line) { Assert.NotNull(code, targetScope); + // the code can make a Proc, Binding or method over the scope (a block scope is then not reused) + targetScope.MarkCaptured(); + RubyContext context = targetScope.RubyContext; RubyMethodScope methodScope = targetScope.GetInnerMostMethodScope(); diff --git a/Util/block-scope-reuse-matrix.rb b/Util/block-scope-reuse-matrix.rb index 4eb1300a..6e7943d9 100644 --- a/Util/block-scope-reuse-matrix.rb +++ b/Util/block-scope-reuse-matrix.rb @@ -349,3 +349,27 @@ def proc_part p (1..4).map { |i| pk.call(i) }, $keep.map { |b| b.local_variable_get(:zz) } end proc_part + +# procs over a block's scope made without a block literal in it: eval, Method#to_proc, +# Symbol#to_proc, instance_eval/class_eval strings, a define_method'd method's binding +def capture_part + r = [] + [1, 2, 3].each { |x| y = x * 4; r << eval("proc { [x, y] }") } + p r.map(&:call) + r = [] + [1, 2, 3].each { |x| y = x * 5; r << [method(:p).to_proc, y] } + p r.map { |m, y| [m.lambda?, y] } + r = [] + [1, 2, 3].each { |x| y = x.to_s; r << [y.method(:upcase).to_proc, :succ.to_proc, y] } + p r.map { |up, su, y| [up.call, su.call(y), y] } + r = [] + %w[a b c].each { |s| t = s * 2; r << s.instance_eval("proc { [self, t] }") } + p r.map(&:call) + r = [] + [1, 2, 3].each { |x| w = x * 6; r << Object.class_eval("lambda { w + x }") } + p r.map(&:call) + r = [] + [1, 2, 3].each { |x| v = x * 7; r << eval("binding") } + p r.map { |b| [b.local_variable_get(:x), b.local_variable_get(:v)] } +end +capture_part From 19e596e9628f5a2ce020ac5213e5486389561d99 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 18:48:33 +0200 Subject: [PATCH 94/94] Gate the block scope reuse fix round (scope and tuple taken together, BlockParam parking only) Fixes the review of dfbb66f2 (commits 3ac3d355..78d41338): - The entry takes the parked scope off the BlockParam right after the safe point (TakeReusableBlockScope) and reads the locals tuple from the scope it took, so a trap handler or finalizer running at the safe point can no longer leave a call a new scope over another call's tuple. Util/block-scope-reuse-race.rb (trap/finalizer regressions) fails on dfbb66f2 (stale 12-17, 13-17, 3-6) and passes here and on CRuby. - The proc-kept scope (Proc RareState slot, call counter, single-call BlockParams) is gone; only a BlockParam that calls its block repeatedly parks a scope. - No reuse path for a block with a nested block literal (its Proc captures the scope on every call); the Proc constructor no longer marks scopes, Method#to_proc, Symbol#to_proc and string eval do. A/B vs tipref d6474b6a, Release, bench-lock, interleaved, order flipped per round: thread-CPU ns/op, best of 6-8 processes (noise = median-best spread 2-25%): each{x*2} 68.0 -> 59.9, each{y=x*2} 62.9 -> 38.1, map 101.8 -> 66.3, any? 121.1 -> 44.1, times 81.5 -> 45.3, yield loop 109.8 -> 53.4 (164 -> 12 B/op); yield once/2x/3x 172.4/252.8/336.3 -> 175.2/261.2/340.4 (noise 3.5-9%), then 371.5 -> 370.3, Mutex#synchronize 393.1 -> 394.8, pr.call 103.9 -> 105.7, define_method 99.5 -> 99.9 (noise 2-8%); warm-up 3x300k fresh process within 2-6% (noise 2-8%). Util/bench (s, best of 5): each_loop 0.268 -> 0.208, times_loop 0.254 -> 0.221, yield_loop 0.246 -> 0.211, any_loop 0.210 -> 0.169, map_loop 0.212 -> 0.193, yield_once_call 0.444 -> 0.453, then_call 0.473 -> 0.482 (noise 6-30%); ruby-bench block_methods 7517 -> 5832 ms (best of 4). Gates: sp-tests 0, sp-lang 2934/0/0, each_loop 0.268s -> 0.208s, block_methods 7517ms -> 5832ms Co-Authored-By: Claude Opus 5.5