Skip to content

perf: obj.method() through the runtime dispatcher is ~3,000× slower than Node (two O(own-keys) string-compare scans per call before any cache) #10502

Description

@proggeramlug

Found by the package performance audit (real npm packages compiled from source, profiled against Node 26.5.1) and
re-measured on Perry 7661bc0 (v0.5.1589), Linux x64. Every method call that reaches js_native_call_method scans
every own key of the receiver with a byte compare in class_vtable_fast_guard, and, when that does not dispatch,
scans them all again in dispatch_handle — so a call on a 64-property object literal costs ~22,800 instructions
(~2 µs, ~3,000× Node), growing ~270 instructions per own key.

Reproduction

ns.ts (the ns64 literal is written out in full so it keeps its static shape):

// Method calls that reach the runtime dispatch tower: (a) object literal with many function-valued own properties
// (axios `utils$1.isX(v)` shape); (b) class instance with many own fields, method on the prototype (parse5 Tokenizer shape).
const variant = process.argv[2]; const N = Number(process.argv[3]);
const ns64: any = { f00: (x: number) => x + 0, f01: (x: number) => x + 1, f02: (x: number) => x + 2, f03: (x: number) => x + 3, f04: (x: number) => x + 4, f05: (x: number) => x + 5, f06: (x: number) => x + 6, f07: (x: number) => x + 7, f08: (x: number) => x + 8, f09: (x: number) => x + 9, f10: (x: number) => x + 10, f11: (x: number) => x + 11, f12: (x: number) => x + 12, f13: (x: number) => x + 13, f14: (x: number) => x + 14, f15: (x: number) => x + 15, f16: (x: number) => x + 16, f17: (x: number) => x + 17, f18: (x: number) => x + 18, f19: (x: number) => x + 19, f20: (x: number) => x + 20, f21: (x: number) => x + 21, f22: (x: number) => x + 22, f23: (x: number) => x + 23, f24: (x: number) => x + 24, f25: (x: number) => x + 25, f26: (x: number) => x + 26, f27: (x: number) => x + 27, f28: (x: number) => x + 28, f29: (x: number) => x + 29, f30: (x: number) => x + 30, f31: (x: number) => x + 31, f32: (x: number) => x + 32, f33: (x: number) => x + 33, f34: (x: number) => x + 34, f35: (x: number) => x + 35, f36: (x: number) => x + 36, f37: (x: number) => x + 37, f38: (x: number) => x + 38, f39: (x: number) => x + 39, f40: (x: number) => x + 40, f41: (x: number) => x + 41, f42: (x: number) => x + 42, f43: (x: number) => x + 43, f44: (x: number) => x + 44, f45: (x: number) => x + 45, f46: (x: number) => x + 46, f47: (x: number) => x + 47, f48: (x: number) => x + 48, f49: (x: number) => x + 49, f50: (x: number) => x + 50, f51: (x: number) => x + 51, f52: (x: number) => x + 52, f53: (x: number) => x + 53, f54: (x: number) => x + 54, f55: (x: number) => x + 55, f56: (x: number) => x + 56, f57: (x: number) => x + 57, f58: (x: number) => x + 58, f59: (x: number) => x + 59, f60: (x: number) => x + 60, f61: (x: number) => x + 61, f62: (x: number) => x + 62, f63: (x: number) => x + 63 };
const ns4: any = { f00: (x: number) => x + 0, f01: (x: number) => x + 1, f02: (x: number) => x + 2, f40: (x: number) => x + 40 };
const f40 = ns64.f40;
class K64 { g00 = 0; g01 = 0; g02 = 0; g03 = 0; g04 = 0; g05 = 0; g06 = 0; g07 = 0; g08 = 0; g09 = 0; g10 = 0; g11 = 0; g12 = 0; g13 = 0; g14 = 0; g15 = 0; g16 = 0; g17 = 0; g18 = 0; g19 = 0; g20 = 0; g21 = 0; g22 = 0; g23 = 0; g24 = 0; g25 = 0; g26 = 0; g27 = 0; g28 = 0; g29 = 0; g30 = 0; g31 = 0; g32 = 0; g33 = 0; g34 = 0; g35 = 0; g36 = 0; g37 = 0; g38 = 0; g39 = 0; g40 = 0; g41 = 0; g42 = 0; g43 = 0; g44 = 0; g45 = 0; g46 = 0; g47 = 0; g48 = 0; g49 = 0; g50 = 0; g51 = 0; g52 = 0; g53 = 0; g54 = 0; g55 = 0; g56 = 0; g57 = 0; g58 = 0; g59 = 0; g60 = 0; g61 = 0; g62 = 0; g63 = 0; m(x: number) { return x + 40; } }
class K2 { g00 = 0; g01 = 0; m(x: number) { return x + 40; } }
const k64: any = new K64(); const k2: any = new K2();
function run(n: number): number {
  let s = 0;
  if (variant === "ns64") { for (let i = 0; i < n; i++) s += ns64.f40(i & 1023); }
  else if (variant === "ns4") { for (let i = 0; i < n; i++) s += ns4.f40(i & 1023); }
  else if (variant === "local") { for (let i = 0; i < n; i++) s += f40(i & 1023); } // control: same closure through a local
  else if (variant === "class64") { for (let i = 0; i < n; i++) s += k64.m(i & 1023); }
  else if (variant === "class2") { for (let i = 0; i < n; i++) s += k2.m(i & 1023); }
  return s;
}
run(N / 5 | 0); const t0 = performance.now(); const cs = run(N);
console.log(`variant=${variant} checksum=${cs} ms=${(performance.now() - t0).toFixed(2)}`);
PERRY_NO_AUTO_OPTIMIZE=1 perry compile ns.ts -o ns
for v in ns64 ns4 local class64 class2; do node ns.ts $v 2000000; ./ns $v 2000000; done

Measurements

Median of 3, N = 2,000,000, shared host (load average 100–300 on 64 threads; instruction counts are the
load-independent figure). Node inlines these calls (~0.7 ns each), so the loop ratios mostly measure Perry's absolute
per-call cost.

variant Node loop ms Perry loop ms ratio Perry instructions / call Node wall Perry wall
ns64.f40(x) — 64 own props 1.4 4,144 3,025× 22,799 141 ms 5,068 ms
ns4.f40(x) — 4 own props 1.5 1,177 785× 5,660 162 ms 1,448 ms
f40(x) through a local (control) 1.4 37.0 26× 215 131 ms 96 ms
k64.m(x) class, 64 fields, direct call (control) 1.4 20.1 14× 161 126 ms 102 ms
k2.m(x) class, 2 fields, direct call (control) 1.5 19.9 13× 161 142 ms 97 ms

Checksums identical. The same closure costs 215 instructions called through a local and 22,799 called as
ns64.f40(); going from 4 to 64 own keys adds ~17,100 instructions (~285 per key). Class instances with 64 fields are
fine when codegen emits the guarded direct call (161 instructions); the cost appears whenever the call falls to
the runtime tower (see #10503 for one codegen trigger).

perf record of ns64: js_array_get_f64 52.7 % self, js_string_key_matches_bytes 15.1 %, __memcmp 6.7 %;
inclusive dispatch_handle 45.9 %, class_vtable_fast_guard 38.7 %.

Impact

From the audit profiles (v0.5.1587):

  • axios 1.19.0 (axios.get against a local server, 9.7× Node): dynamic method dispatch on utils$1.* — a
    63-key object literal (utils$1.isUndefined(v), utils$1.forEach(...), …) — is 22.6 % of Perry CPU, ~25 % of the
    Perry-minus-Node time (io-group report). js_array_get_f64 was the Support custom menu bar items #1 self symbol (7.8 %) and is not array code.
  • cheerio 1.2.0 / parse5 (cheerio.load + 3 selector queries, 58× Node): 50 % of CPU in the dispatch bucket,
    js_array_get_f64 20 % self, 11 points of it from class_vtable_fast_guard (large-group report). Re-profiled on
    7661bc0 (300 rows): js_native_call_method 93 % inclusive, try_class_vtable_fast_dispatch 74 %,
    js_array_get_f64 18 % self with ≥ 8 points called from class_vtable_fast_guard. Perry 424 ms/iteration vs
    Node 6.9 ms (≈ 61×). (On 7661bc0 this bench dies after 2–3 iterations with
    TypeError: Cannot read properties of undefined (reading 'xmlMode'), the stale-pointer crash already seen by the
    audit, so only short runs were measured.)
  • pg 8.22 / mysql2 3.23: 3–5 % (method calls on EventEmitter-subclass instances, which carry ~15 installed own
    methods — see perf: class X extends EventEmitter is 440× slower than Node to construct and 200–2,400× to use (super() copies 15 bound closures onto every instance) #10508); typescript 5.8 transpileModule ≈ 7 %, rate-limiter-flexible ≈ 7 %.

Mechanism

Call path for a static-name call site that is not proven: js_typed_feedback_native_call_method_by_id
js_native_call_method.

  • crates/perry-runtime/src/typed_feedback/guards.rs:853-900 (verified): the typed-feedback entry records an
    observation (shape, name hash) and then always calls the full js_native_call_method; nothing it records is
    used to skip the tower on the next call.
  • crates/perry-runtime/src/object/native_call_method.rs:1292 (verified): first step of the tower is
    try_class_vtable_fast_dispatchclass_vtable_fast_guard (:115). To prove no own field shadows the method it
    loops over all logical_key_count own keys, reading each key through the generic js_array_get (the full
    receiver-classifying js_array_get_f64) and comparing it with js_string_key_matches_bytes (:179-184,
    verified). This runs on the cache hit path too — a hit on a 17-field parse5 Tokenizer still pays 17 key
    compares per call.
  • For an object literal the scan finds the method as an own key and returns None, so the tower continues through
    the native-module, disposal, TextDecoder, URLSearchParams (perf: methods named get/set/has/delete/keys/… on ordinary objects are 167–368× slower than Node, 2.3–3.8× slower than other names (URLSearchParams probe per call) #10506), AbortSignal and primitive probes to
    handle_methods::dispatch_handle (native_call_method.rs:1980), which scans the same keys again with the same
    js_array_get + byte compare (crates/perry-runtime/src/object/native_call_method/handle_methods.rs:948-957,
    verified) and finally calls the closure through js_native_call_value.
  • There is no cache from (shape id, name) → own slot index or → resolved callee for own-property methods, so both
    scans repeat on every call.

What fast looks like

  • A call-site cache keyed on the receiver's ShapeId (the shape already records key order and slot indices): an
    own-property method call on a known shape becomes a slot load + closure-magic check + call, and a prototype/vtable
    hit is a ShapeId compare, not a key scan (a ShapeId with no own key of that name proves "no shadow" once per
    shape, not once per call).
  • Target on this microbenchmark: ns64.f40() and ns4.f40() within 2× of the local control (≤ 450
    instructions/call) and independent of own-key count; cheerio's class_vtable_fast_guard + js_array_get_f64 share
    below 2 %.

Notes

Activity

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

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions