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_implarguments_object_get_field 14–20 %, js_dyn_index_getarguments_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_getarguments_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

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