Skip to content

perf: functions using arguments are ~590× slower than Node (per-call registry-tracked object with side-table descriptors; residual after #10063) #10509

Description

@proggeramlug

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; done

Measurements

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 = arguments in every dayjs() 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 % of isEmail, 8.2 % of isURL (7.2 % mean).
  • lodash: closure calls / arguments objects 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 mergeArgs shape, so this is common
    in any package compiled to ES5.

Mechanism

  • crates/perry-runtime/src/object/arguments.rs:212-302 js_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:1799 prune_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 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.

Notes

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 17, 2026
  2. proggeramlug commented on Sep 30, 2026

    @proggeramlug
    ContributorAuthor

    Current target (2026-09-30): the escaping arguments object (cfg.args = arguments) is 82× Node; read-only arguments is 7× its named-parameter twin

    Package 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 and arguments entry points is small:

    workload entry → leaf instr/iter % of excess
    dayjs/diff_startof js_arguments_object_alloc 239k 2.6
    dayjs/parse_format js_arguments_object_alloc 58k 2.9
    dayjs/parse_format js_closure_set_box_capture_ptr → is_registered_box_ptr 46k 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_ptr 37k 1.0
    mongodb/batch_query box_get_bits_named 3.07M 1.4
    rate-limiter-flexible/get_penalty box_get_bits_named + js_box_set_bits + set_box_capture_ptr 4.5k 3.2
    moment/parse_format js_arguments_object_alloc 36k 1.0
    redis/set_get set_box_capture_ptr + js_box_release 86k 1.3

    Boxes also add root-scan work to the copying minor (the gc_minor bucket). The data cannot separate that share.

    For this issue, the arguments rows are dayjs (cfg.args = arguments in every dayjs() 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 pay arguments-registry probes on paths that never use arguments, measured as % of the workload's Perry instructions:

    workload path instr/iter % of Perry
    node-forge/sha256 js_object_get_index_polymorphic → is_arguments_object 212k 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_field 64k 1.7

    The big.js site may be gone after #11588. I did not re-profile it.

    What landed since the issue was filed

    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_arguments 38,398 1,083 144 7.5×
    escape_rest (control) 12,638 4,635 292 15.9×
    escape_arguments 54,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×) and escape_arguments ≤ 1.5× escape_rest (now 4.4×).
    • No ARGUMENTS_OBJECTS or 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 with cargo 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.json

    Run it once on a base-commit build and once on the branch, and compare instructions per iteration. --filter is a workload-id substring, and control/* always runs. For attribution, run profile --callgraph on PERRY_KEEP_SYMBOLS=1 binaries (see benchmarks/packages/PROFILE.md).

    Fresh numbers were measured on origin/main 5fbc2c3 (v0.5.1654) with cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static. Binaries were compiled with PERRY_NO_AUTO_OPTIMIZE=1 and compared with Node 26.5.1 on Linux x86-64 using perf 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 from benchmarks/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.

  3. proggeramlug commented on Oct 3, 2026

    @proggeramlug
    ContributorAuthor

    Partial progress in #11794: escape_arguments 16,365 → 7,799 instructions/op (node 179, still ~44×); read_arguments unchanged at 1,064. Leaving open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions