Repository navigation
perf: functions using arguments are ~590× slower than Node (per-call registry-tracked object with side-table descriptors; residual after #10063) #10509
Description
Activity
- addedperformanceRuntime, compile-time, build-size, or memory performanceRuntime, compile-time, build-size, or memory performancepackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
on Sep 17, 2026 Current target (2026-09-30): the escaping
argumentsobject (cfg.args = arguments) is 82× Node; read-onlyargumentsis 7× its named-parameter twinPackage impact (#11464)
Bucket row 6, closures / boxed captures /
arguments, is 4.6% of equal-weight excess. It is ≥5% in 9 packages. Share of each package's excess: dayjs 12, qs 9, moment 8, axios 7, decimal.js 7, fastify 7, rate-limiter-flexible 6, ioredis 5, node-forge 5.Most of that bucket is not box cells or
arguments. Most of it enters under the method dispatcher (js_typed_feedback_native_call_method_by_id) and the IC miss. Its top chains end in function-object property leaves (closure_get_dynamic_prop,is_closure_ptr,closure_is_key_deleted). The part that enters through box andargumentsentry points is small:workload entry → leaf instr/iter % of excess dayjs/diff_startof js_arguments_object_alloc239k 2.6 dayjs/parse_format js_arguments_object_alloc58k 2.9 dayjs/parse_format js_closure_set_box_capture_ptr→is_registered_box_ptr46k 2.3 qs/stringify_nested js_closure_set_box_capture_ptr(side-channel factories)263k 1.7 qs/parse_nested js_closure_set_box_capture_ptr37k 1.0 mongodb/batch_query box_get_bits_named3.07M 1.4 rate-limiter-flexible/get_penalty box_get_bits_named+js_box_set_bits+set_box_capture_ptr4.5k 3.2 moment/parse_format js_arguments_object_alloc36k 1.0 redis/set_get set_box_capture_ptr+js_box_release86k 1.3 Boxes also add root-scan work to the copying minor (the
gc_minorbucket). The data cannot separate that share.For this issue, the
argumentsrows are dayjs (cfg.args = argumentsin everydayjs()call: 2.6–2.9% of excess) and moment (1.0%). These are the escaping shape, and they were profiled on a build that already had #11349. Three more chains payarguments-registry probes on paths that never usearguments, measured as % of the workload's Perry instructions:workload path instr/iter % of Perry node-forge/sha256 js_object_get_index_polymorphic→is_arguments_object212k 2.6 big.js js_dyn_index_set_strict→arguments_object_set_index(big.mjs:872 c[j] = 0)3.9M 1.8 commander js_class_field_add→arguments_object_set_field64k 1.7 The big.js site may be gone after #11588. I did not re-profile it.
What landed since the issue was filed
- perf(codegen): read-only
arguments[k]no longer builds an Arguments object (#10509) #11349 (merged 09-25) elides the object when every use isarguments.lengthorarguments[k], which is item 1. Its PR says the escaping case, item 2, is not done. - The strict
calleethrower is now configured once per singleton.
Reproducer, fresh numbers (the issue's
args.js, strict, instructions per call)function mergeArgs() { const obj = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; const d = arguments.length > 1 ? arguments[1] : undefined; return d ? obj : obj; } function mergeNamed(a, b) { const obj = a !== undefined ? a : {}; return b ? obj : obj; } function makeArgs(date, c) { const cfg = typeof c === "object" ? c : {}; cfg.date = date; cfg.args = arguments; return cfg; } function makeRest(...a) { const cfg = typeof a[1] === "object" ? a[1] : {}; cfg.date = a[0]; cfg.args = a; return cfg; }
variant Perry, 7661bc0 (issue) Perry, main Node Perry/Node read_named(control)279 155 56 2.8× read_arguments38,398 1,083 144 7.5× escape_rest(control)12,638 4,635 292 15.9× escape_arguments54,359 20,353 250 81.5× (The issue's column used whole-process ÷ 1.2·N. Main's uses two-N.)
Acceptance target
- Issue's own targets:
read_arguments≤ 2×read_named(now 7.0×) andescape_arguments≤ 1.5×escape_rest(now 4.4×). - No
ARGUMENTS_OBJECTSor descriptor-table entries for strict escaping objects. - The registry probes above should vanish from plain Array and class-field paths.
- No regression on dayjs, moment, validator, commander and big.js.
Package check (Linux, needs
perf). Build withcargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static, then run:(cd benchmarks/packages && npm ci --ignore-scripts) python3 scripts/package_bench.py compile --perry-bin-dir /tmp/pb --filter dayjs --filter moment --filter validator --filter commander --filter big.js python3 scripts/package_bench.py run --perry-bin-dir /tmp/pb --arms node,perry --modes instr --filter dayjs --filter moment --filter validator --filter commander --filter big.js --out /tmp/pb/instr.jsonRun it once on a base-commit build and once on the branch, and compare instructions per iteration.
--filteris a workload-id substring, andcontrol/*always runs. For attribution, runprofile --callgraphonPERRY_KEEP_SYMBOLS=1binaries (seebenchmarks/packages/PROFILE.md).Fresh numbers were measured on
origin/main5fbc2c3 (v0.5.1654) withcargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static. Binaries were compiled withPERRY_NO_AUTO_OPTIMIZE=1and compared with Node 26.5.1 on Linux x86-64 usingperf stat -e instructions:u. Each figure is the median of 3 runs at two sizes of N, with per-op = ΔI/ΔN. N1 is at least 200k operations (20k calls for the factory bench), which keeps most of Node's JIT warm-up inside the constant term. Output was byte-identical to Node on every row. The #11464 figures come frombenchmarks/packages/profile/callgraph.{md,json}, measured at Perry 36420d2 with auto-optimize. node-forge was measured from 2febf42 binaries. "% of excess" means the share of a package's (Perry − Node) instructions per iteration.- perf(codegen): read-only
- added a commit that references this issue
on Oct 3, 2026 Partial progress in #11794: escape_arguments 16,365 → 7,799 instructions/op (node 179, still ~44×); read_arguments unchanged at 1,064. Leaving open.
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).