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: functions using arguments are ~590× slower than Node (per-call registry-tracked object with side-table descriptors; residual after #10063) #10509
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. #10063 (closed by #10081) removed the per-index attribute
entries, but every call of a function that mentions arguments still allocates an arguments object, registers it in a
thread-local HashMap (with its own inner HashMap), installs length attributes and — in strict code — a callee thrower accessor in the string-keyed descriptor side tables, and the GC later prunes those entries. Reads
then resolve by name. A merge()-style function costs 38,400 instructions per call vs 279 with named parameters.
Checksums identical. Sloppy mode (same file without "use strict", 3 runs each): read_arguments 525×,
31,002 instructions/iter; escape_arguments 354×, 72,654 instructions/iter — so the strict callee accessor is not
the only cost.
perf record of read_arguments (inclusive): mergeArgs 68 % → js_arguments_object_alloc 33 %
(install_fresh_accessor_property 10 %, set_property_attrs 9 %, thrower_closure_value 9 %), get_field_ic_miss_impl → arguments_object_get_field 14–20 %, js_dyn_index_get → arguments_object_get_index
11–13 %; copying-minor GC 29.5 % (finalize_dead_copied_minor_from_space_side_allocations 13 %, prune_dead_descriptor_owner_entries_young 12 %, scan_descriptor_roots_mut 12 %). In escape_arguments a further
43 % is the property adds cfg.date =/cfg.args = (#10496).
Impact
From the audit profiles (v0.5.1587; strings- and objects-group reports):
dayjs: cfg.args = arguments in every dayjs() call — 4.4 % of dayjs overall, 6.0 % of add/diff.
Babel/TypeScript down-levelled default and rest parameters emit exactly the mergeArgs shape, so this is common
in any package compiled to ES5.
Mechanism
crates/perry-runtime/src/object/arguments.rs:212-302js_arguments_object_alloc (verified), per call: handle
scope, keys array for len, js_object_alloc, one js_object_set_field per argument and length; then
strict (restricted_callee != 0, :253-270): set_property_attrs(obj, "length".to_string(), …) and install_fresh_accessor_property(obj, "callee".to_string(), thrower) — two String allocations and entries in the (usize, String)-keyed descriptor tables;
sloppy (:271-285): a callee field plus set_property_attrs_batch for length/callee;
:289-300: ARGUMENTS_OBJECTS.insert(obj, ArgumentsMeta { mapped: HashMap::new(), … }) into a thread-local map.
Reads: arguments.length misses the IC and resolves by name through arguments_object_get_field
(arguments.rs:401); arguments[i] goes js_dyn_index_get → arguments_object_get_index (arguments.rs:362,
called from crates/perry-runtime/src/value/dyn_index.rs:424), each consulting the registry (verified by profile).
Every dead arguments object leaves descriptor-table and registry entries that the copying minor must prune
(crates/perry-runtime/src/object/descriptor_state.rs:1799prune_dead_descriptor_owner_entries_young, verified).
Codegen: when arguments does not escape (only arguments.length, arguments[i] with no writes, no arguments passed/stored/apply'd, no sloppy mapped-parameter aliasing), lower those reads to the incoming
argument count and argument slots — no object at all (read_arguments ≤ 2× read_named).
Runtime, for escaping objects: a dedicated arguments shape whose length/callee attributes are fixed by the
shape (no per-object descriptor entries, no registry insert in strict mode where nothing is mapped), so escape_arguments costs about what escape_rest does (≤ 1.5×) and GC has nothing to prune.
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. #10063 (closed by #10081) removed the per-index attribute
entries, but every call of a function that mentions
argumentsstill allocates an arguments object, registers it in athread-local
HashMap(with its own innerHashMap), installslengthattributes and — in strict code — acalleethrower accessor in the string-keyed descriptor side tables, and the GC later prunes those entries. Readsthen resolve by name. A
merge()-style function costs 38,400 instructions per call vs 279 with named parameters.Reproduction
args.js:Measurements
Median of 3, N = 500,000, shared host (loaded; instruction counts are the load-independent figure).
mergeArgs(...)— readsarguments.length,arguments[0],arguments[1]mergeNamed(a, b)(control)makeArgs(...)—cfg.args = arguments(escapes)makeRest(...a)—cfg.args = a(control)Checksums identical. Sloppy mode (same file without
"use strict", 3 runs each):read_arguments525×,31,002 instructions/iter;
escape_arguments354×, 72,654 instructions/iter — so the strictcalleeaccessor is notthe only cost.
perf recordofread_arguments(inclusive):mergeArgs68 % →js_arguments_object_alloc33 %(
install_fresh_accessor_property10 %,set_property_attrs9 %,thrower_closure_value9 %),get_field_ic_miss_impl→arguments_object_get_field14–20 %,js_dyn_index_get→arguments_object_get_index11–13 %; copying-minor GC 29.5 % (
finalize_dead_copied_minor_from_space_side_allocations13 %,prune_dead_descriptor_owner_entries_young12 %,scan_descriptor_roots_mut12 %). Inescape_argumentsa further43 % is the property adds
cfg.date =/cfg.args =(#10496).Impact
From the audit profiles (v0.5.1587; strings- and objects-group reports):
cfg.args = argumentsin everydayjs()call — 4.4 % of dayjs overall, 6.0 % of add/diff.util/merge(arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, Babel'sdefault-parameter output) — 6.3 % of
isEmail, 8.2 % ofisURL(7.2 % mean).argumentsobjects 10.5 / 12 / 24 % of cloneDeep / get / sortBy (memoizefunc.apply(this, arguments),baseRest); jsonwebtoken ≈ 5 %.mergeArgsshape, so this is commonin any package compiled to ES5.
Mechanism
crates/perry-runtime/src/object/arguments.rs:212-302js_arguments_object_alloc(verified), per call: handlescope, keys array for
len,js_object_alloc, onejs_object_set_fieldper argument andlength; thenrestricted_callee != 0,:253-270):set_property_attrs(obj, "length".to_string(), …)andinstall_fresh_accessor_property(obj, "callee".to_string(), thrower)— twoStringallocations and entries in the(usize, String)-keyed descriptor tables;:271-285): acalleefield plusset_property_attrs_batchforlength/callee;:289-300:ARGUMENTS_OBJECTS.insert(obj, ArgumentsMeta { mapped: HashMap::new(), … })into a thread-local map.arguments.lengthmisses the IC and resolves by name througharguments_object_get_field(
arguments.rs:401);arguments[i]goesjs_dyn_index_get→arguments_object_get_index(arguments.rs:362,called from
crates/perry-runtime/src/value/dyn_index.rs:424), each consulting the registry (verified by profile).(
crates/perry-runtime/src/object/descriptor_state.rs:1799prune_dead_descriptor_owner_entries_young, verified).the per-index attributes fix(runtime): reduce arguments object construction overhead #10081 removed.
What fast looks like
argumentsdoes not escape (onlyarguments.length,arguments[i]with no writes, noargumentspassed/stored/apply'd, no sloppy mapped-parameter aliasing), lower those reads to the incomingargument count and argument slots — no object at all (
read_arguments≤ 2×read_named).length/calleeattributes are fixed by theshape (no per-object descriptor entries, no registry insert in strict mode where nothing is mapped), so
escape_argumentscosts about whatescape_restdoes (≤ 1.5×) and GC has nothing to prune.Notes
the audit's micro was 130–137× (v0.5.1587); this reproducer, with three reads per call, is 586×.
argumentsin a class constructor is empty when the class is constructed through a value, and padded to the declared parameter count on a staticnew#10484 (argumentsin a class constructor constructed through avalue is empty / padded) — keep in mind when changing materialisation.
o.k = vis ~100× slower than Node (static-key write IC primes only overwrites; no add-transition cache) #10496 (the property adds in the escape rows).