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
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):
PERRY_NO_AUTO_OPTIMIZE=1 perry compile ns.ts -o ns
forvin 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).
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.)
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_dispatch → class_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.
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 %.
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_methodscansevery 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(thens64literal is written out in full so it keeps its static shape):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.
ns64.f40(x)— 64 own propsns4.f40(x)— 4 own propsf40(x)through a local (control)k64.m(x)class, 64 fields, direct call (control)k2.m(x)class, 2 fields, direct call (control)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 arefine 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 recordofns64:js_array_get_f6452.7 % self,js_string_key_matches_bytes15.1 %,__memcmp6.7 %;inclusive
dispatch_handle45.9 %,class_vtable_fast_guard38.7 %.Impact
From the audit profiles (v0.5.1587):
axios.getagainst a local server, 9.7× Node): dynamic method dispatch onutils$1.*— a63-key object literal (
utils$1.isUndefined(v),utils$1.forEach(...), …) — is 22.6 % of Perry CPU, ~25 % of thePerry-minus-Node time (io-group report).
js_array_get_f64was the Support custom menu bar items #1 self symbol (7.8 %) and is not array code.cheerio.load+ 3 selector queries, 58× Node): 50 % of CPU in the dispatch bucket,js_array_get_f6420 % self, 11 points of it fromclass_vtable_fast_guard(large-group report). Re-profiled on7661bc0 (300 rows):
js_native_call_method93 % inclusive,try_class_vtable_fast_dispatch74 %,js_array_get_f6418 % self with ≥ 8 points called fromclass_vtable_fast_guard. Perry 424 ms/iteration vsNode 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 theaudit, so only short runs were measured.)
methods — see perf:
class X extends EventEmitteris 440× slower than Node to construct and 200–2,400× to use (super() copies 15 bound closures onto every instance) #10508); typescript 5.8transpileModule≈ 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 anobservation (shape, name hash) and then always calls the full
js_native_call_method; nothing it records isused to skip the tower on the next call.
crates/perry-runtime/src/object/native_call_method.rs:1292(verified): first step of the tower istry_class_vtable_fast_dispatch→class_vtable_fast_guard(:115). To prove no own field shadows the method itloops over all
logical_key_countown keys, reading each key through the genericjs_array_get(the fullreceiver-classifying
js_array_get_f64) and comparing it withjs_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.
None, so the tower continues throughthe 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 samejs_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.scans repeat on every call.
What fast looks like
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).
ns64.f40()andns4.f40()within 2× of thelocalcontrol (≤ 450instructions/call) and independent of own-key count; cheerio's
class_vtable_fast_guard+js_array_get_f64sharebelow 2 %.
Notes
__pshapeunder an inline keys check), 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 (probecascade), perf(runtime): classify native-call receivers from the tracked header; no Buffer/typed-array registry probe for known kinds #9937 (open draft: receiver classification from the tracked header — does not remove the scans).
this.m()on a class whose constructor adds fields inside anifis ~490× slower than Node (exact birth-ShapeId guard misses every call) #10503 and perf: oneObject.setPrototypeOf/util.inheritson a plain object makes every class method call in the process 5× slower, 155–189× Node (sticky global latch retires all direct-call guards) #10504 (two triggers that sendthis.m()to this tower), perf: method calls on untyped receivers (prototype methods,fn.call,pushonany) are 44–520× slower than Node (no call-site cache; name re-resolved per call) #10505 (prototype methods /.call/pushon untyped receivers), 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 (URLSearchParams probe in the same tower), perf:class X extends EventEmitteris 440× slower than Node to construct and 200–2,400× to use (super() copies 15 bound closures onto every instance) #10508.mb_this_v4.js(parse5-shaped Tokenizer) no longer reaches the tower on 7661bc0 — its calls aredirect now and its remaining 50× is by-name field access — but real parse5 in cheerio still does (profile above).
Which condition sends parse5's calls there was not isolated. Ruled out on 7661bc0: construction from another
module, a constructor closure capturing
this, fields assigned outside the constructor, and the prototype-surgerylatch from perf: one
Object.setPrototypeOf/util.inheritson a plain object makes every class method call in the process 5× slower, 155–189× Node (sticky global latch retires all direct-call guards) #10504 (gdb: not armed in the cheerio+undici build).