Skip to content

perf: method_missing dispatch 2.6x slower than CRuby (call-site IC) #391

Closed

Description

@linyiru

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.

Activity

  1. linyiru commented on Sep 26, 2026

    @linyiru
    OwnerAuthor

    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:

    1. 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.
    2. 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.
  2. linyiru commented on Sep 27, 2026

    @linyiru
    OwnerAuthor

    Status: all three method_missing IC pieces have landed

    PR What Result (ns/op)
    #396 Call-site method_missing IC for explicit receivers. The entry also serves as the class's negative lookup entry, so each class uses one way. o.nope 490 → 283, vs 289 for a direct call to the same body. Polymorphic sites with 3–5 classes add ~40 over direct (CRuby ~48).
    #399 The same IC for bare implicit-self misses (nope inside an instance method). 256 → 147, vs ~149 direct
    #402 Tier-2 frameless ("lite") callers serve a cached fixed-arity method_missing without materializing (explicit, bare-self and fused LoadLocalCall forms). tier 2: o.nope 157 → 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_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.
    • NameError#name returns nil.
    • "s" + 1 raises NoMethodError; CRuby raises TypeError.
    • caller_locations(...).label lacks the Object# prefix.

    I suggest either closing this issue with the base call cost tracked separately, or keeping it open until that work lands.

  3. linyiru commented on Sep 27, 2026

    @linyiru
    OwnerAuthor

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions