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_valuejs_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

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