Skip to content

Speedup: method JIT, calls, blocks, exceptions and library fast paths - #22

Open
Largo wants to merge 130 commits into
masterfrom
speedup
Open

Largo wants to merge 130 commits into
masterfrom
speedup

Conversation

@Largo

@Largo Largo commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Performance work on IronRuby, 129 commits on top of 1672d83 (4.0.1). Every change was gated on its own branch: Debug and Release builds, IronRuby.Tests 0 failures, ruby/spec spec/language 2934 examples / 0 failures / 0 errors, the spec/core and spec/library directories of the touched code compared against the previous tip, and a CRuby 4.0.6 differential for the behaviour it touches. The later rounds were also checked by an independent adversarial reviewer before merging.

Measured effect (Release, pinned and interleaved A/B on a shared, loaded machine: expect ±15 %)

  • Util/bench (50 micro benchmarks) vs master at 33e7775: geomean 1.21x, total 26.1 s -> 11.2 s. Biggest: fiber_switch 7.1x, raise_rescue 5.5x, module_function_call 5.3x, send_call 3.0x, respond_to 2.0x, call_kwargs 1.75x.
  • Startup ir -e 1: 1.86 s -> about 1.25 s.
  • ruby-bench, later rounds on top of that: keyword_args ~9x (now about CRuby's time), binarytrees 1.8x, gcbench 1.65x, object-new-initialize 1.5x, ruby-json 1.4x, block_methods 1.27x, splay 1.16x, nbody 1.16x.

Main changes

  • Method JIT / OSR: re-specializes after method table changes instead of staying on the generic path (and module creation no longer counts as one); specializes keyword methods, simple initializers (optional literal defaults, up to 6 arguments, batched initial ivar stores) and short array literals; direct receiverless keyword calls; no jitentry$ frames in backtraces.
  • Calls and blocks: precompiled dispatchers for bare-identifier calls, cheaper dispatcher guards, respond_to?(:lit) / send(:lit) intrinsics, keyword-splat merging, smaller method/block scopes (rare state out of line), scope reuse across the yields of one call, a literal block's Proc as its own flow-control frame.
  • Exceptions and fibers: no backtrace capture for internal unwinders, reuse of finished fibers' threads.
  • Library: Math in C# for Float/Integer arguments, typed Array#sort for String/Integer/Float, single-pass Hash#each, regexp escape analysis cached, StringScanner deferred MatchData, interpolation literal parts built once, lock-free class lookups.
  • DLR: patches in Build/dlr-patches (0100-compile-pipeline, 0100-exceptions) applied in order onto upstream.

Behaviour fixes found along the way (now match MRI)

  • m(nil) to a keyword method raises wrong number of arguments instead of treating nil as "no keywords".
  • Array#reverse_each / #rindex follow MRI when the block shrinks the array (no element visited twice).
  • String interpolation result encoding follows rb_str_concat_literals; nil.to_s is US-ASCII.
  • Hash#each no longer raises KeyNotFoundException when the block deletes a later key.

Known

  • Arity messages for keyword methods still lack MRI's ; required keyword: k suffix (pre-existing).
  • Util/bench cmp_branch shows +6 ms from where a gen0 GC lands (0.4 MB more allocated at startup).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added fiber thread reuse and faster Array-backed enumeration.
    • Expanded JIT support for keyword arguments, including required and optional keywords, defaults, and keyword splats.
  • Performance Improvements
    • Improved common array, hash, string, math, and method-call operations.
    • Reduced overhead in block and method execution, regular-expression matching, and exception backtrace handling.
  • Bug Fixes
    • Improved hash iteration when entries change during traversal and keyword argument validation.
    • Fixed scope and local-variable behavior across blocks, bindings, and tracing.

Largo and others added 30 commits October 11, 2026 20:59
…ler 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 <noreply@anthropic.com>
…er'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 <noreply@anthropic.com>
…ands

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 <noreply@anthropic.com>
…or#next over Array

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ard 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lls, cheaper dispatcher guard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…locks

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 <noreply@anthropic.com>
…he 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 <noreply@anthropic.com>
…irectly

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 <noreply@anthropic.com>
Gated state of the previous (WIP) commit b334b18; 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<Key<string, RubyCallSignature>> 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 <noreply@anthropic.com>
… lookup, OSR ivar reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ble reference

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ork for plain blocks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…per spans and args in the bridge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…send and respond_to?

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ions while ruby4.rb boots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…acktrace

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…o 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 3cac183), 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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
Verified on 09001d8 (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 <noreply@anthropic.com>
Largo and others added 28 commits October 13, 2026 10:03
…o no keywords

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elow

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 <noreply@anthropic.com>
…ata; 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 <noreply@anthropic.com>
… 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 = <param|literal|self>' 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 843df62 -> 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…state in bytes

Gates and measurements for the two commits before this one (bbc94ee, 90d42db).

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 <noreply@anthropic.com>
…ommit below

Content is in ee45fe8 (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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 843df62 -> 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 <noreply@anthropic.com>
…kips, regex tier-up, cheaper Integer()/Float() of Strings

ruby-json 1703 -> 1222 ms (1.39x), strscan_tokens 1.94 -> 1.25 s (reviewer's pinned A/B vs 843df62).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l frame; smaller BlockParam and Proc

Bytes per block-passing call 168 -> 112; send_cfunc_block 1.07-1.18x (best/median, reviewer's A/B).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	Util/bench/bench.rb
…efaults, arity up to 6, initial ivar stores) and short array literals

binarytrees 1.83x, gcbench 1.65x, object-new-initialize 1.53x (reviewer's pinned A/B vs 843df62).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	Util/bench/bench.rb
…t receiverless keyword calls; keyword prologue no longer accepts a positional nil

keyword_args 2075 -> 227 ms (9.1x, CRuby ~246), call_kwargs 1.29x (reviewer's pinned A/B vs 843df62).

Merged with w2-object-alloc's stub changes: JitStub/JitCompiler/JitScreen take both a
keyword method's keyword locals and a positional method's literal-default optionals (the
two never meet: a signature mixing them is lowered to a splat), stored-only parameters are
worked out over the typed parameters including keyword locals, JitStub4-6 take keywords,
and the backtrace filter also hides jitdirect$ entries. Util/jit-matrix.rb identical to
CRuby with the JIT on and with -X:NoJIT -X:NoOSR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kParam/Proc

Brings the block scope reuse of sp/w2-block-scope (843df62..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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Brings the block scope reuse of sp/w2-block-scope (843df62..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 d6474b6 -> 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
427206c 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 d6474b6 / 185b9be / 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
… BlockParam parking only)

Fixes the review of dfbb66f (commits 3ac3d35..78d4133):
- 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 dfbb66f (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 d6474b6, 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 <noreply@anthropic.com>
…one call

A block yielded repeatedly by one call (each, map, select, times, a yield in a loop) takes
the scope its previous call left on the BlockParam instead of allocating a new one; the
safe point runs before the scope is taken, so a trap handler or finalizer cannot end up
sharing it. Scopes kept by a Proc, Binding, nested block, eval or TracePoint are never
reused.

Reviewer's pinned A/B vs d6474b6: each/map/select/any/times/yield loops 16-55% faster,
~165 -> 12 B per yield; ruby-bench block_methods 7473 -> 5918 ms. Single-call shapes
+1-3% (inside the 5-7% noise floor); Util/bench cmp_branch +6.6 ms from a moved gen0 GC
(0.43 MB more allocated at startup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 62aba83b-a2cb-4cd0-bc2b-04b2f3407303
📥 Commits

Reviewing files that changed from the base of the PR and between 844d2cf and 5525cc0.

⛔ Files ignored due to path filters: 6
  • Src/Libraries/Initializers.Generated.cs is excluded by !**/*.generated.*
  • Src/Prism/Generated/PrismLoader.Generated.cs is excluded by !**/*.generated.*, !**/generated/**
  • Src/Prism/Generated/PrismNodes.Generated.cs is excluded by !**/*.generated.*, !**/generated/**
  • Src/Ruby/Compiler/Ast/Walkers/Walker.Generated.cs is excluded by !**/*.generated.*
  • Src/Ruby/Compiler/ReflectionCache.Generated.cs is excluded by !**/*.generated.*
  • Src/Ruby/Runtime/Calls/MethodDispatcher.Generated.cs is excluded by !**/*.generated.*
📒 Files selected for processing: 1
  • Src/Prism/PrismLoader.cs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

📝 Walkthrough

The pull request updates IronRuby compiler and runtime execution paths. It adds keyword-aware JIT compilation, deferred Prism method bodies, reusable block scopes, fiber worker pooling, and dispatch and global-variable caches. It also changes collection and regex operations, exception handling, built-ins, and standard-library integration.

Changes: Runtime and compiler updates

Layer / File(s) Summary
JIT dispatch and keyword specialization
Build/dlr-patches/0100-compile-pipeline.patch, Src/Ruby/Runtime/Calls/*, Src/Ruby/Runtime/Jit/*, Src/Libraries/Builtins/KernelOps.cs, Src/Libraries/Builtins/RubyMath.cs
Adds cached call instructions, shared method-version rules, dispatch specializations, keyword-aware JIT entries, and expanded JIT support for method arities, loops, and instance-variable operations.
Keyword lowering and lazy Prism bodies
Src/Prism/*, Src/Ruby/Compiler/Ast/*, Src/Ruby/Runtime/RubyOps.cs, Src/Ruby/Builtins/Hash.*
Adds keyword and splat AST helpers and runtime support. Prism can skim eligible method bodies and map them when requested.
Scope reuse and fibers
Src/Ruby/Runtime/RubyScope.cs, Src/Ruby/Runtime/BlockParam.cs, Src/Libraries/Builtins/ThreadOps*, Src/StdLib/ironruby/ruby4.rb
Adds captured-scope tracking and reuse paths. Adds fiber mailboxes, worker reuse, and Ruby-level fiber integration.
Collections, regex, and scanning
Src/Libraries/Builtins/ArrayOps.cs, Src/Libraries/Extensions/*, Src/Libraries/Builtins/Enumerator.cs, Src/Ruby/Builtins/RubyRegex.cs, Src/Libraries/StringScanner/StringScanner.cs
Adds specialized array sorting and traversal, hash mutation-aware iteration, an array enumerator cursor, and deferred regex match data.
Exceptions, storage, and loader paths
Build/dlr-patches/0100-exceptions.patch, Src/Ruby/Runtime/RubyExceptionData.cs, Src/Ruby/Runtime/Globals/*, Src/Ruby/Runtime/RubyContext.cs, Src/Ruby/Runtime/Loader.cs
Changes exception trace materialization and exception data access. Adds versioned global-variable lookup and CLR type caches, and tracks core-prelude loading.
Built-ins, standard library, and validation
Src/Libraries/Builtins/*, Src/StdLib/ironruby/ruby4.rb, Src/StdLib/ruby/4.0/rubygems/defaults/ironruby.rb, Util/*
Updates math and struct dispatch, standard-library aliases, encoding setup, RubyGems initialization, and adds benchmarks and differential scripts.

Priority: ➖ Normal

Estimated code review effort: 5 (Critical) | ~120 minutes

Merge Risk: ⚪ Minimal · up to 5525c

No actionable issue was established in the selected deferred-body loader change; it is mergeable after normal checks.

Security architecture risk: 🟡 Moderate · up to 5525c

Deferred method-body loading changes when code is parsed and adds a public way to load it later. The normal execution path retains its parse-error check and synchronizes loading, and no security bypass was established. External use of the new API and the wider execution-path changes remain uncertain.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated effect is within a host’s Ruby parsing and method-execution path. The supplied evidence does not establish a tenant, service, credential, or deployment boundary across which deferred loading is invoked.

Security Findings and Attack Paths

  • inferred — A caller holding loaders and mutable nodes could supply a node to a different loader, causing it to read that loader’s buffer at the node’s offset. No repository caller does this, and no lower-trust caller or security outcome was demonstrated, so it is not retained as an active PR concern.

Trust Boundaries and Controls

  • observed — Public acceptance of caller-supplied serialized bytes predates this PR. On the inspected normal path, native serialization supplies a new managed buffer and parse errors are checked before AST mapping; header checks establish format compatibility, not caller authorization.

Resilience and Maintainability Implications

  • observed — A failed deferred build does not cache a body, so a later access can retry; the inspected restoration covers loader position and bridge state, not an explicit transaction over lexical-scope mutations.

Hardening Proposals

  • proposed — If external hosts use lazy deserialization as a boundary for less-trusted serialized data, bind deferred nodes to their owning loader, validate offsets before reading, and establish whether the retained byte array must be copied. This is conditional hardening, not an observed bypass in the repository path.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 37.97% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 316 functions across 50 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarizes the pull request's main performance changes across method JIT, calls, blocks, exceptions, and library fast paths.
  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant