You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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):
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.
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 wholejs_native_call_methodprobe tower, re-resolving the callee by name on every call: 4,500–10,300 instructions percall where the same body called directly costs ~230.
Reproduction
dyn.ts(method names deliberately NOT in the URLSearchParams probe list, see #10506):Measurements
Median of 3, N = 2,000,000, shared host (loaded; instruction counts are the load-independent figure).
fnc.fetch(i)prototype method, fn-ctor instancehasOwnProperty.call(obj, "b")obj.hasOwnProperty("b")add.call(null, i, 1)add(i, 1)(control)arrAny.push(i)+.lengthonanynumber[](control)Checksums identical.
.calladds ~4,700 instructions over callingadddirectly;pushthroughanyadds ~4,250over 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 areis_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):
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: groupBy
call131k /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×.
x.plus(y)→P.pluson function-constructor instances) 12.6 % of theloop part.
has.call(obj, key),callBoundtrampolines).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_methodobserves (shape, class id, name hash) and unconditionally calls
js_native_call_method; no resolved target iscached 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 inorder: 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-modulenamespace probe, disposal/
usingnames, TextDecoder/TextEncoder, URLSearchParams (:1535), AbortSignal, …,primitive_methods::dispatch_primitive(:1952),handle_methods::dispatch_handle(:1980),common_methods::dispatch_common(:2022).dispatch_handleallocates the method-name string again andwalks 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), thenclone_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 earlierprobe has rejected the closure receiver.
arr.push(v)onany: resolved indispatch_handle's array arm (handle_methods.rs:422, verified) after thefast guard, primitive dispatch and a named-props lookup.
What fast looks like
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.
fnproto_any≤ 2×fn_direct(≤ 500 instructions/call);fn_call≤ 3×fn_direct;push_any≤ 2×push_typed.Notes
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, theserows: 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).
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.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; typed
pushpath — does not touch the dynamicpushdispatch measured here).