Repository navigation
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
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; doneMeasurements
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_method86 % →dispatch_handle50 % →resolve_proto_chain_field*22 %,
js_string_from_bytes_with_capacity4 % (a key string per call),dispatch_primitive12 %,
class_vtable_fast_guard6 %;js_native_call_methodself 12 %.fn_call:js_native_call_method85 % (16 % self) →dispatch_common25 %,dispatch_primitive14 %,
closure_has_own_dynamic_prop6 %,bound_native_callable_module_and_method5 %,
maybe_alias_explicit_this_construction5 %.push_any:js_native_call_method67 % →dispatch_handle21 %,dispatch_primitive13 %,
get_field_ic_miss_impl12 %,array::named_props::resolve5 %; the actualjs_array_push_f64_specis 5 %.hasown_call:dispatch_common64 % →js_object_has_own50 %, 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× andObject.hasOwn/getPrototypeOf40–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_idis 39 % (cloneDeep), 58 % (get), 66 % (groupBy),
33 % (merge) of Perry time; a uprobe showed the dynamically dispatched names: groupBycall131k /push122k
(hasOwnProperty.call(result, key) ? result[key].push(value) : …), cloneDeepcall106k /set79k /get53k /
push26k. 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.pluson 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),
callBoundtrampolines). - jsonwebtoken ≈ 7 %, lru-cache ≈ 4 % (Map
get/setviajs_native_call_method_by_id3.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 callsjs_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_lossyof 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/usingnames, 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_handleallocates the method-name string again and
walks the prototype chain by name viaresolve_proto_chain_field_with_receiver
(crates/perry-runtime/src/object/native_call_method/handle_methods.rs:1243-1257, verified), then
clone_closure_rebind_thisandjs_native_call_value. fn.call(thisArg, …): handled only indispatch_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)onany: resolved indispatch_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
- Distinct from 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 (the O(own-keys) scans inside the tower — they add to, but are not the bulk of, these
rows: receivers here have 1–3 own keys) and 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 URLSearchParams probe, which fires only for its 12
method names). - The function-constructor construction and
instanceofcosts are perf: function-constructor objects (new F(),x instanceof F) are 78–566× slower than Node (F.prototype and builtin identity re-derived from side tables per operation) #10507. - Related: Every dynamic method call pays 7 side-registry probes to exclude kinds the program never creates —
is_registered_symbolalone takes a mutex + SipHash (6.5% ofpipeline) #7850 (closed; per-call side-registry probes), perf(runtime): classify native-call receivers from the tracked header; no Buffer/typed-array registry probe for known kinds #9937 (open draft; receiver classification),
perf(runtime): stop re-deriving the receiver on every array push (−74%) #10414 (open PR; typedpushpath — does not touch the dynamicpushdispatch measured here).
Activity
proggeramlug commentedon Oct 2, 2026
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 declaredDocNode[]field emitsjs_typed_feedback_native_call_method_by_id; extracting the same array once intoconst children: DocNode[] = node.childrenunlocks existing direct array lowering (confirmed by disassembly).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-receivermakeTreedisassemblies. These paths are local artifacts, not publicly hosted links.