Skip to content

perf: method calls on untyped receivers (prototype methods, fn.call, push on any) are 44–520× slower than Node (no call-site cache; name re-resolved per call) #10505

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. A method call whose receiver is not statically proven
compiles to js_typed_feedback_native_call_method_by_id, which only records feedback and then runs the whole
js_native_call_method probe tower, re-resolving the callee by name on every call: 4,500–10,300 instructions per
call where the same body called directly costs ~230.

Reproduction

dyn.ts (method names deliberately NOT in the URLSearchParams probe list, see #10506):

// Dynamic method calls on untyped receivers: prototype methods (lodash Stack/MapCache, decimal.js P.plus),
// Function.prototype.call trampolines (hasOwnProperty.call, iteratee.call), Array push on `any`.
const variant = process.argv[2]; const N = Number(process.argv[3]);
function Cache(this: any) { this.size = 0; }
Cache.prototype.fetch = function (v: number) { return v + 1; };
const hasOwnProperty = Object.prototype.hasOwnProperty;
const add = function (a: number, b: number) { return a + b; };
const fnc: any = new (Cache as any)();
const obj: any = { a: 1, b: 2, c: 3 };
const arrAny: any = []; const arrTyped: number[] = [];
function run(n: number): number {
  let s = 0;
  if (variant === "fnproto_any") for (let i = 0; i < n; i++) s += fnc.fetch(i);            // function-ctor instance, prototype method
  else if (variant === "hasown_call") for (let i = 0; i < n; i++) s += hasOwnProperty.call(obj, "b") ? 1 : 0;
  else if (variant === "hasown_method") for (let i = 0; i < n; i++) s += obj.hasOwnProperty("b") ? 1 : 0;
  else if (variant === "fn_call") for (let i = 0; i < n; i++) s += add.call(null, i, 1);
  else if (variant === "fn_direct") for (let i = 0; i < n; i++) s += add(i, 1);           // control
  else if (variant === "push_any") for (let i = 0; i < n; i++) { arrAny.push(i); if (arrAny.length > 8) arrAny.length = 0; s += arrAny.length; }
  else if (variant === "push_typed") for (let i = 0; i < n; i++) { arrTyped.push(i); if (arrTyped.length > 8) arrTyped.length = 0; s += arrTyped.length; }
  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 dyn.ts -o dyn
for v in fnproto_any hasown_call hasown_method fn_call fn_direct push_any push_typed; do node dyn.ts $v 2000000; ./dyn $v 2000000; done

Measurements

Median of 3, N = 2,000,000, shared host (loaded; instruction counts are the load-independent figure).

variant Node loop ms Perry loop ms ratio Perry instructions / iter Node wall Perry wall
fnc.fetch(i) prototype method, fn-ctor instance 3.0 1,579 521× 6,012 138 ms 1,964 ms
hasOwnProperty.call(obj, "b") 30.3 2,998 99× 10,278 172 ms 3,637 ms
obj.hasOwnProperty("b") 22.5 1,302 58× 5,551 154 ms 1,595 ms
add.call(null, i, 1) 19.4 1,154 60× 4,974 218 ms 1,452 ms
add(i, 1) (control) 3.0 35.3 12× 230 127 ms 82 ms
arrAny.push(i) + .length on any 24.5 1,078 44× 4,558 189 ms 1,316 ms
same on number[] (control) 24.6 50.5 2.1× 305 165 ms 100 ms

Checksums identical. .call adds ~4,700 instructions over calling add directly; push through any adds ~4,250
over the typed array.

perf record (inclusive):

  • fnproto_any: js_native_call_method 86 % → dispatch_handle 50 % → resolve_proto_chain_field* 22 %,
    js_string_from_bytes_with_capacity 4 % (a key string per call), dispatch_primitive 12 %,
    class_vtable_fast_guard 6 %; js_native_call_method self 12 %.
  • fn_call: js_native_call_method 85 % (16 % self) → dispatch_common 25 %, dispatch_primitive 14 %,
    closure_has_own_dynamic_prop 6 %, bound_native_callable_module_and_method 5 %,
    maybe_alias_explicit_this_construction 5 %.
  • push_any: js_native_call_method 67 % → dispatch_handle 21 %, dispatch_primitive 13 %,
    get_field_ic_miss_impl 12 %, array::named_props::resolve 5 %; the actual js_array_push_f64_spec is 5 %.
  • hasown_call: dispatch_common 64 % → js_object_has_own 50 %, of which 41 points are
    is_function_prototype_object_value → js_get_global_this_builtin_value (see perf: missing-property reads on functions are ~2,600× and Object.hasOwn/getPrototypeOf 40–90× slower than Node (Function.prototype re-resolved by name per call) #10497).

Impact

From the audit profiles (v0.5.1587; objects- and strings-group reports):

  • lodash 4.18.1: js_typed_feedback_native_call_method_by_id is 39 % (cloneDeep), 58 % (get), 66 % (groupBy),
    33 % (merge) of Perry time; a uprobe showed the dynamically dispatched names: groupBy call 131k / push 122k
    (hasOwnProperty.call(result, key) ? result[key].push(value) : …), cloneDeep call 106k / set 79k / get 53k /
    push 26k. Part of get/cloneDeep/merge is the URLSearchParams probe (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); the rest is this mechanism.
    The groupBy-shaped microbenchmark was 66×.
  • decimal.js 10.6: dynamic method calls (x.plus(y) → P.plus on function-constructor instances) 12.6 % of the
    loop part.
  • dayjs / date-fns / qs / validator: dynamic dispatch 9.3 / 5.5 / 15.8 / 3.9 % (qs includes has.call(obj, key),
    callBound trampolines).
  • jsonwebtoken ≈ 7 %, lru-cache ≈ 4 % (Map get/set via js_native_call_method_by_id 3.9 %).

Mechanism

  • crates/perry-runtime/src/typed_feedback/guards.rs:853-900 (verified): js_typed_feedback_native_call_method
    observes (shape, class id, name hash) and unconditionally calls js_native_call_method; no resolved target is
    cached or consulted.
  • crates/perry-runtime/src/object/native_call_method.rs:1219 (verified) js_native_call_method, per call:
    String::from_utf8_lossy of the name, to_vec() of the arguments (:1312) plus a handle per argument, then in
    order: class-vtable fast guard (:1292, O(own keys), 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), per-instance prototype probe, native-module
    namespace probe, disposal/using names, TextDecoder/TextEncoder, URLSearchParams (:1535), AbortSignal, …,
    primitive_methods::dispatch_primitive (:1952), handle_methods::dispatch_handle (:1980),
    common_methods::dispatch_common (:2022).
  • Prototype method on a function-constructor instance: dispatch_handle allocates the method-name string again and
    walks the prototype chain by name via resolve_proto_chain_field_with_receiver
    (crates/perry-runtime/src/object/native_call_method/handle_methods.rs:1243-1257, verified), then
    clone_closure_rebind_this and js_native_call_value.
  • fn.call(thisArg, …): handled only in dispatch_common's "call" arm
    (crates/perry-runtime/src/object/native_call_method/common_methods.rs:544, verified), i.e. after every earlier
    probe has rejected the closure receiver.
  • arr.push(v) on any: resolved in dispatch_handle's array arm (handle_methods.rs:422, verified) after the
    fast guard, primitive dispatch and a named-props lookup.

What fast looks like

  • A per-call-site inline cache in front of the tower keyed on (receiver kind/ShapeId or class id, name) that stores
    the resolved callee (closure pointer, vtable entry, or builtin fast entry such as array-push / Function.prototype.call),
    validated by a shape compare plus the existing prototype-mutation epoch; the tower runs only on a miss.
  • Targets on this benchmark: fnproto_any ≤ 2× fn_direct (≤ 500 instructions/call); fn_call ≤ 3×
    fn_direct; push_any ≤ 2× push_typed.

Notes

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 17, 2026
  2. proggeramlug commented on Oct 2, 2026

    @proggeramlug
    ContributorAuthor

    Additional measured workload exercising this issue's exact generic dispatch path:

    Measured 2026-10-01 on macOS arm64, Perry main d40ed1a47019bcae547819ebb5756bcd96ba1057 (v0.5.1655), with matching compiler and runtime archives. This snapshot already includes #11645. These are diagnostic probes; no compiler/runtime fix was applied and the original comparison remains unchanged.

    node.children.push(makeTree(...)) on a declared DocNode[] field emits js_typed_feedback_native_call_method_by_id; extracting the same array once into const children: DocNode[] = node.children unlocks existing direct array lowering (confirmed by disassembly).

    Arm CPU seconds, median Peak RSS MiB, median
    Original/default 10.64 131.63
    Typed local/default 4.90 131.55

    Three sequential repeats per arm; CPU = child user + system time from wait4, RSS = peak resident memory. Every run matched the original full stdout and exit status, including parent identity and closure/cache checks. Default, nursery-8, nursery-4 and typed-local arms rotated order across three rounds; the combined arm ran afterward. Same constants, node count and 1 GiB limit.

    55.70% of 19,648 main-thread observations across three 8-second/1-ms stack captures pass through the typed-feedback method entry; all sampled calls originate in makeTree's pushes. Nested native dispatch is 50.89%. Inclusive rows overlap and include actual operation and triggered GC; these are not dispatch-only percentages. A separate GC diagnostic timer reports 5.25% of wall time in collection buckets, with different measurement boundaries.

    The probe also removes repeated field reads. No exact compiler pass has been identified as the proof-loss point, and arbitrary getter/property hoisting would not be sound. A focused compiler-side issue is linked below; it complements this ticket's runtime dispatch/cache work rather than claiming to solve all dynamic receivers.

    Compiler-side opportunity and complete unchanged reproducer: #11743.

    Evidence in the project-comparison workspace: demo/results/stress/perry-profile/summary.md, profile-manifest.json, cpu-summary.json, experiment-summary.json, experiments.json, gc-diagnostics.stderr, nursery-4-gc-diagnostics.stderr, census.jsonl, vmmap-*.txt, and the original/typed-receiver makeTree disassemblies. These paths are local artifacts, not publicly hosted links.

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