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
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.
Reproduction
args.js:
// `arguments` objects: dayjs `cfg.args = arguments` (escapes), validator/lodash `arguments.length` / `arguments[i]` reads.
"use strict";
const variant = process.argv[2]; const N = Number(process.argv[3]);
const keep = new Array(1024);
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; }
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 run(n) {
let s = 0;
if (variant === "escape_arguments") for (let i = 0; i < n; i++) { const o = makeArgs(i, { k: 1 }); keep[i & 1023] = o; s += o.date + o.args.length; }
else if (variant === "escape_rest") for (let i = 0; i < n; i++) { const o = makeRest(i, { k: 1 }); keep[i & 1023] = o; s += o.date + o.args.length; }
else if (variant === "read_arguments") for (let i = 0; i < n; i++) { const o = mergeArgs({ date: i }, keep); s += o.date; }
else if (variant === "read_named") for (let i = 0; i < n; i++) { const o = mergeNamed({ date: i }, keep); s += o.date; }
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 args.js -o args
for v in read_arguments read_named escape_arguments escape_rest; do node args.js $v 500000; ./args $v 500000; doneMeasurements
Median of 3, N = 500,000, shared host (loaded; instruction counts are the load-independent figure).
| variant (strict mode) | Node loop ms | Perry loop ms | ratio | Perry instructions / iter | Node wall | Perry wall |
|---|---|---|---|---|---|---|
mergeArgs(...) — reads arguments.length, arguments[0], arguments[1] |
4.7 | 2,748 | 586× | 38,398 | 64 ms | 3,354 ms |
mergeNamed(a, b) (control) |
0.8 | 8.0 | 10× | 279 | 59 ms | 29 ms |
makeArgs(...) — cfg.args = arguments (escapes) |
11.1 | 3,177 | 287× | 54,359 | 71 ms | 3,974 ms |
makeRest(...a) — cfg.args = a (control) |
16.0 | 595.5 | 37× | 12,638 | 94 ms | 730 ms |
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 = argumentsin everydayjs()call — 4.4 % of dayjs overall, 6.0 % of add/diff. - validator:
util/merge(arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, Babel's
default-parameter output) — 6.3 % ofisEmail, 8.2 % ofisURL(7.2 % mean). - lodash: closure calls /
argumentsobjects 10.5 / 12 / 24 % of cloneDeep / get / sortBy (memoize
func.apply(this, arguments),baseRest); jsonwebtoken ≈ 5 %. - Babel/TypeScript down-levelled default and rest parameters emit exactly the
mergeArgsshape, 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 forlen,js_object_alloc, onejs_object_set_fieldper argument andlength; then- strict (
restricted_callee != 0,:253-270):set_property_attrs(obj, "length".to_string(), …)and
install_fresh_accessor_property(obj, "callee".to_string(), thrower)— twoStringallocations and entries in the
(usize, String)-keyed descriptor tables; - sloppy (
:271-285): acalleefield plusset_property_attrs_batchforlength/callee; :289-300:ARGUMENTS_OBJECTS.insert(obj, ArgumentsMeta { mapped: HashMap::new(), … })into a thread-local map.
- strict (
- Reads:
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 fromcrates/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). - The residual is the same mechanism perf(runtime): three-argument arguments-object loop costs 267–627x Node #10063 hypothesised (allocation + descriptor/registry bookkeeping + GC), minus
the per-index attributes fix(runtime): reduce arguments object construction overhead #10081 removed.
What fast looks like
- Codegen: when
argumentsdoes not escape (onlyarguments.length,arguments[i]with no writes, no
argumentspassed/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/calleeattributes are fixed by the
shape (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
- perf(runtime): three-argument arguments-object loop costs 267–627x Node #10063 (closed 2026-09-12, fixed by fix(runtime): reduce arguments object construction overhead #10081) — this is the remaining cost of the same construct on 7661bc0:
the audit's micro was 130–137× (v0.5.1587); this reproducer, with three reads per call, is 586×. - Open correctness issue on a neighbouring path:
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 a
value is empty / padded) — keep in mind when changing materialisation. - Sibling draft: perf: adding a property with
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).
Activity
perf(codegen): read-only `arguments[k]` no longer builds an Arguments…
perf(codegen): read-only `arguments[k]` no longer builds an Arguments…
proggeramlug commentedon Sep 30, 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:js_arguments_object_allocjs_arguments_object_allocjs_closure_set_box_capture_ptr→is_registered_box_ptrjs_closure_set_box_capture_ptr(side-channel factories)js_closure_set_box_capture_ptrbox_get_bits_namedbox_get_bits_named+js_box_set_bits+set_box_capture_ptrjs_arguments_object_allocset_box_capture_ptr+js_box_releaseBoxes 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:js_object_get_index_polymorphic→is_arguments_objectjs_dyn_index_set_strict→arguments_object_set_index(big.mjs:872 c[j] = 0)js_class_field_add→arguments_object_set_fieldThe big.js site may be gone after #11588. I did not re-profile it.
What landed since the issue was filed
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.calleethrower is now configured once per singleton.Reproducer, fresh numbers (the issue's
args.js, strict, instructions per call)read_named(control)read_argumentsescape_rest(control)escape_arguments(The issue's column used whole-process ÷ 1.2·N. Main's uses two-N.)
Acceptance target
read_arguments≤ 2×read_named(now 7.0×) andescape_arguments≤ 1.5×escape_rest(now 4.4×).ARGUMENTS_OBJECTSor descriptor-table entries for strict escaping objects.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(arguments): an arguments object is born with its final shape (#1…
proggeramlug commentedon 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.