You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
perf: method_missing dispatch 2.6x slower than CRuby (call-site IC) #391
Part of #378. method_missing dispatch is ~2.6× slower than CRuby. Measured on lawrences-mac-studio (M2 Max) with the #387 branch, the standard perf build (stdlib,jit-native,_fiber,_json_native,mimalloc), and CRuby 3.4.1. Numbers are ns/op, best of 7, with the empty loop subtracted.
shape
rubyrs
CRuby
o.nope → method_missing(name, *args)
357.6
135.0
same body called directly (o.direct(:nope))
~132
~83
body only (s.end_with?("=") ? nil : s)
69
69
Decomposition: the body is at parity. The base call costs ~63 vs ~14 ns (tracked separately). The miss → method_missing resolution costs ~187 vs ~17 ns, which is 76% of the gap.
The profile of the miss path shows three costs:
~23% in the slow do_call cascade of &*name == "..." arms, walked in full before try_method_missing at the tail;
~24% in the generic invoke_method_with_block;
intern("method_missing") on every call (2.6%), an uncached method_missing lookup (1%), and the args Vec built twice.
Proposal
A. Call-site method_missing inline cache.
After the first miss at a site, cache "(class, method_gen) → user method_missing" in the site's CallCache.
Later calls skip the cascade and invoke the method via the stack-args path (try_invoke_nfa_method_from_stack), prepending the name symbol.
Invalidation uses method_gen, as for regular entries.
Expected cost is ~170 ns/op.
B. Small fixes, independent of A:
pre-intern sym_method_missing on Vm;
use the cached lookup for method_missing;
build the forwarded args once.
Acceptance
o.nope with a user method_missing at ≤ CRuby (≤135 ns/op on the studio).
The diff suite passes, including the JIT runs; add a fixture for method_missing redefined or removed after a site warmed up.
Progress: #396 is merged. It implements proposal A and most of B.
A. Call-site method_missing IC
The tail of the do_call cascade records (receiver class, method_gen) → user method_missing in the site's CallCache, under the key class_ptr | 2.
That entry takes over the site's negative (class, None) entry, so each class uses one way and a site still holds IC_WAYS classes.
On later calls, the explicit-recv fast path inserts the name symbol and serves method_missing straight from the stack.
B.sym_method_missing is pre-interned, and the forwarded args are built once. B's cached lookup was not needed: with A in place, the uncached lookup only runs on a site's first miss or at megamorphic sites.
Studio, perf build, ns/op, best of 7 (the loop is inside a block, so absolute numbers are higher than the table above):
before
after
CRuby 3.4.1
o.nope → method_missing(name, *args)
490
283
171
same body called directly
290
289
155
polymorphic site, 3–5 classes, cost over a direct call
up to 662
~40
~48
The miss overhead is gone: o.nope now costs the same as a direct call. The acceptance target is not met yet. The remaining gap is the base call cost (~63 vs ~14 ns), which is tracked separately.
Remaining work under this issue:
Next: bare implicit-self misses (nope inside an instance method) still take the slow path. They need the same cache in try_invoke_self_recv_cached.
Tier-2 frameless (t2_lite_call_*) misses fall back to a full frame and re-dispatch. The result is correct, but the frameless serve is lost.
(#405 recalibrated the peak-RSS ratchet. It was flaking on runner noise while #402 was in CI. There are no code changes.)
Where this leaves the acceptance target: a method_missing miss now costs the same as calling the same body directly, in the interpreter and in tier 2. The ≤135 ns / ≤ CRuby target is still not met, because what remains is the base call cost. For example, bare nope is 147 ns against CRuby's 71, and a direct call is 149 against CRuby's 58. Nothing specific to method_missing is left.
Not covered, by design:
A rest-shaped method_missing(name, *args) still materializes on the tier-2 lite path. So does a rest-shaped direct method; that is a general lite-path limit.
Refinements, public_send/send, and force-primitive dispatch never fill the IC.
Pre-existing gaps found along the way, not fixed here:
A bare vcall with no method_missing raises NoMethodError; CRuby raises NameError.
Closing. method_missing dispatch is now at parity with a direct call (#396, #399, #402). The rest of the ≤ CRuby target is base call cost, now tracked in #406. Pre-existing gaps found along the way: #407, #408, #409, #410.
Activity
linyiru commentedon Sep 26, 2026
Progress: #396 is merged. It implements proposal A and most of B.
A. Call-site method_missing IC
do_callcascade records(receiver class, method_gen) → user method_missingin the site'sCallCache, under the keyclass_ptr | 2.(class, None)entry, so each class uses one way and a site still holdsIC_WAYSclasses.method_missingstraight from the stack.B.
sym_method_missingis pre-interned, and the forwarded args are built once. B's cached lookup was not needed: with A in place, the uncached lookup only runs on a site's first miss or at megamorphic sites.Studio, perf build, ns/op, best of 7 (the loop is inside a block, so absolute numbers are higher than the table above):
o.nope→method_missing(name, *args)The miss overhead is gone:
o.nopenow costs the same as a direct call. The acceptance target is not met yet. The remaining gap is the base call cost (~63 vs ~14 ns), which is tracked separately.Remaining work under this issue:
nopeinside an instance method) still take the slow path. They need the same cache intry_invoke_self_recv_cached.t2_lite_call_*) misses fall back to a full frame and re-dispatch. The result is correct, but the frameless serve is lost.linyiru commentedon Sep 27, 2026
Status: all three
method_missingIC pieces have landedmethod_missingIC for explicit receivers. The entry also serves as the class's negative lookup entry, so each class uses one way.o.nope490 → 283, vs 289 for a direct call to the same body. Polymorphic sites with 3–5 classes add ~40 over direct (CRuby ~48).nopeinside an instance method).method_missingwithout materializing (explicit, bare-self and fusedLoadLocalCallforms).o.nope157 → 125 (direct 125); bare 153 → 127 (direct 118)(#405 recalibrated the peak-RSS ratchet. It was flaking on runner noise while #402 was in CI. There are no code changes.)
Where this leaves the acceptance target: a
method_missingmiss now costs the same as calling the same body directly, in the interpreter and in tier 2. The ≤135 ns / ≤ CRuby target is still not met, because what remains is the base call cost. For example, barenopeis 147 ns against CRuby's 71, and a direct call is 149 against CRuby's 58. Nothing specific tomethod_missingis left.Not covered, by design:
method_missing(name, *args)still materializes on the tier-2 lite path. So does a rest-shaped direct method; that is a general lite-path limit.public_send/send, and force-primitive dispatch never fill the IC.Pre-existing gaps found along the way, not fixed here:
method_missingraisesNoMethodError; CRuby raisesNameError.NameError#namereturns nil."s" + 1raisesNoMethodError; CRuby raisesTypeError.caller_locations(...).labellacks theObject#prefix.I suggest either closing this issue with the base call cost tracked separately, or keeping it open until that work lands.
linyiru commentedon Sep 27, 2026
Closing. method_missing dispatch is now at parity with a direct call (#396, #399, #402). The rest of the ≤ CRuby target is base call cost, now tracked in #406. Pre-existing gaps found along the way: #407, #408, #409, #410.