Skip to content

perf: factories whose locals are captured by closures are 115–260× slower than Node (registered box cell + hash-map wiring per captured binding, even never-reassigned consts) #10520

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. Every captured binding that codegen decides to box costs a
malloc'd cell inserted into a thread-local registry, plus, for every (closure, boxed capture) pair, a registry probe and two
hash-map updates. The analysis also boxes const bindings holding a closure that are never reassigned. In a
TypeScript-compiler-style factory (8 vars and 8 inner functions) that is 16 boxes per call and 259× Node; the same
closures reached through a const array need no boxes and are 8× faster in Perry. The cells are never released (#10464),
which turns the per-call cost into a growing GC root set.

Reproduction

bench.ts:

// Factory whose locals are captured by inner functions (typescript createScanner/createChecker, qs side-channel)
const variant = process.argv[2] || "box2"; const N = Number(process.argv[3] || "300000");
function box2(i: number) { let count = i, last = 0;                          // 2 reassigned lets captured by 2 closures
  return { inc() { count++; last = count; return count; }, get() { return count + last; } }; }
function obj2(i: number) { const st = { count: i, last: 0 };                 // control: same state in a const object
  return { inc() { st.count++; st.last = st.count; return st.count; }, get() { return st.count + st.last; } }; }
function box8(x0: number) { var a = x0, b = 1, c = 2, d = 3, e = 4, f = 5, g = 6, h = 7;   // TS style: vars + function decls
  function f0(x: number) { a = b + x; return c + a; } function f1(x: number) { b = c + x; return d + b; }
  function f2(x: number) { c = d + x; return e + c; } function f3(x: number) { d = e + x; return f + d; }
  function f4(x: number) { e = f + x; return g + e; } function f5(x: number) { f = g + x; return h + f; }
  function f6(x: number) { g = h + x; return a + g; } function f7(x: number) { h = a + x; return b + h; }
  return { inc() { return f0(1) + f4(1); }, get() { return f2(0) + f6(0) + f1(0) + f3(0) + f5(0) + f7(0); } }; }
function arrow8(x0: number) { const s = { a: x0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6, h: 7 }; // state in a const, 8 const arrows
  const f0 = (x: number) => { s.a = s.b + x; return s.c + s.a; }, f1 = (x: number) => { s.b = s.c + x; return s.d + s.b; };
  const f2 = (x: number) => { s.c = s.d + x; return s.e + s.c; }, f3 = (x: number) => { s.d = s.e + x; return s.f + s.d; };
  const f4 = (x: number) => { s.e = s.f + x; return s.g + s.e; }, f5 = (x: number) => { s.f = s.g + x; return s.h + s.f; };
  const f6 = (x: number) => { s.g = s.h + x; return s.a + s.g; }, f7 = (x: number) => { s.h = s.a + x; return s.b + s.h; };
  return { inc() { return f0(1) + f4(1); }, get() { return f2(0) + f6(0) + f1(0) + f3(0) + f5(0) + f7(0); } }; }
function arr8(x0: number) { const s = { a: x0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6, h: 7 }; // control: same arrows via a const array
  const fs = [(x: number) => { s.a = s.b + x; return s.c + s.a; }, (x: number) => { s.b = s.c + x; return s.d + s.b; },
    (x: number) => { s.c = s.d + x; return s.e + s.c; }, (x: number) => { s.d = s.e + x; return s.f + s.d; },
    (x: number) => { s.e = s.f + x; return s.g + s.e; }, (x: number) => { s.f = s.g + x; return s.h + s.f; },
    (x: number) => { s.g = s.h + x; return s.a + s.g; }, (x: number) => { s.h = s.a + x; return s.b + s.h; }];
  return { inc() { return fs[0](1) + fs[4](1); }, get() { return fs[2](0) + fs[6](0) + fs[1](0) + fs[3](0) + fs[5](0) + fs[7](0); } }; }
const make: (i: number) => { inc(): number; get(): number } = ({ box2, obj2, box8, arrow8, arr8 } as any)[variant];
function run(n: number): number { let s = 0; for (let i = 0; i < n; i++) { const o = make(i & 1023); o.inc(); s = (s + o.get()) % 1000003; } 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 bench.ts -o bench
node bench.ts box8 100000; ./bench box8 100000                         # repeat per variant
PERRY_GC_DIAG=1 ./bench box8 100000 2>&1 | grep -E "box-stats|gc-time"  # boxes and GC share

Measurements

N = 100,000 factory calls. Median of 3, shared host (loaded; instruction counts are the load-independent figure).
"Instructions/call" = whole-process instructions:u ÷ 1.2·N (timed + warm-up calls). Boxes, GC share and max RSS are
from one separate PERRY_GC_DIAG=1 run each.

variant Node loop ms Perry loop ms ratio Perry instructions/call boxes/call GC share max RSS Node wall ms Perry wall ms
box2 (2 reassigned lets) 4.7 536.1 115× 23.8 k 2 9.3 % 120 MB 136 672
obj2 (control) 3.6 309.2 86× 20.1 k 0 3.9 % 64 MB 138 403
box8 (8 vars + 8 functions) 16.1 4,163.0 259× 130.6 k 16 49.4 % 504 MB 173 4,558
arrow8 (8 const arrows, no reassignment) 13.2 1,574.1 119× 71.6 k 8 43.8 % 243 MB 156 1,888
arr8 (control: same arrows in a const array) 13.9 521.5 37× 33.3 k 0 6.0 % 75 MB 152 663

Checksums identical. arrow8 and arr8 run the same closures over the same state; the only difference is that arrow8's
eight never-reassigned const f = (x) => … bindings are boxed because the returned methods capture them: 3× the time,
2.2× the instructions, 7× the GC share. box8 adds eight genuinely mutated vars and uses function declarations.
The obj2/arr8 controls are still 37–86× Node for other reasons (object-literal/closure allocation), not this issue.

Minimal boxing check (PERRY_GC_DIAG=1, 100,000 calls each, allocs= from [box-stats]):

function numConst(i: number) { const n = i; return () => n + 1; }                            // allocs=0
function arrowConst(i: number) { const f = (x: number) => x + i; return () => f(1); }         // allocs=100000
function fnDecl(i: number) { function f(x: number) { return x + i; } return () => f(1); }     // allocs=100000
function arrowSibling(i: number) { const f = (x: number) => x + i; const g = () => f(1); return g(); } // allocs=0

For arrowConst, --print-hir shows Let { name: "f", mutable: false } and mutable_captures: [], yet --trace llvm
shows js_box_alloc_bits, js_box_set_bits and js_closure_set_box_capture_ptr in the function.

Profile of arrow8 (inclusive): copying minor 48 % (scan_box_roots_mut 26 %), js_closure_set_box_capture_ptr 9.1 %,
js_box_set_bits 5.9 %, is_registered_box_ptr 5.0 %, prune_dead_closure_box_capture_owners 4.7 %,
js_box_alloc_bits 4.5 %, decrement_cell_capture_count 4.2 %, increment_cell_capture_count 4.0 %.

Impact

From the audit profiles (v0.5.1587, large and strings group reports):

  • typescript 5.8.2 transpileModule: box/box-capture machinery 15.6 % inclusive, GC 41.6 % of samples (29.7 % of wall);
    741,555 box cells allocated in 200 calls (≈3,700 per call), 0 released. Every compiler service is a factory of this shape
    (createScanner, createParser, createTypeChecker, createPrinter); the generated mb_ts_factory shape (40 vars ×
    40 inner functions) measured 520× Node.
  • dayjs format: boxes 13.3 % of the mode (6.6 % of dayjs overall); qs stringify: boxes 8.6 % plus a GC share of 36 %
    (side-channel factories var $wm, $m, … captured by five method closures per call); validator isURL: 42 boxes per call.
  • rate-limiter-flexible 11.2.0: 300,086 box cells per 100k operations, 0 released, GC 11 % of wall.

Mechanism

Citations read at 7661bc0 (verified) unless marked.

  • Codegen boxing decision: crates/perry-codegen/src/boxed_vars.rs:207-221 boxes a binding when it is declared, captured
    and mutated, or when it is a self-recursive closure. :526-565 collect_self_recursive_closure_ids has a fallback arm
    (:556-564, "belt-and-suspenders") that boxes any let/const whose initializer is a closure literal and that is
    referenced by any closure, self-reference or not. That this arm is what boxes arrowConst's f is inferred (it is
    the only rule that matches; the box itself is verified in the IR). Hoisted function declarations are boxed as well
    (box8: 16 boxes = 8 vars + 8 functions; fnDecl above); the path is inferred to be the Stmt::PreallocateBoxes
    handling at boxed_vars.rs:39-45.
  • Per box: crates/perry-runtime/src/box.rs:691-732 js_box_alloc_bits — pool pop or alloc of 8 bytes,
    note_box_young_root, insert into the thread-local BOX_REGISTRY hash set (box.rs:99), cache record.
  • Per (closure, boxed capture): crates/perry-runtime/src/closure/alloc.rs:787-795 js_closure_set_box_capture_ptr →
    box.rs:393-402 registry probe → crates/perry-runtime/src/closure/box_captures.rs:82-114 set_closure_box_capture
    (entry in a per-closure HashMap<closure, Vec<(index, cell)>> with a linear position scan) →
    increment_cell_capture_count (:25-33, a second hash map). Dead closures undo it through
    decrement_cell_capture_count (:35-52) during GC pruning.
  • Access: reads validate the cell with is_registered_box_ptr (box.rs:1459-1472).
  • Lifetime: no release is emitted outside async state machines, so every cell is a permanent root visited by
    scan_box_roots_mut (box.rs:954) — that leak is Box cells for mutable closure captures are never released outside async state machines: every call leaks a registered GC root plus everything the captured variable points to #10464 and is why the GC share grows with call count.

What fast looks like

  1. Do not box bindings that are never reassigned (closure-valued consts and function declarations that nothing
    reassigns) unless a closure genuinely captures the binding before its initialization; restrict the fallback arm to real
    self-capture. Target: arrow8 within 1.1× of arr8 (≈33 k instructions/call, GC share ≤10 %).
  2. For genuinely mutated captures, keep the cell's bookkeeping O(1) and hash-free: e.g. one GC-heap environment record per
    activation shared by all closures created in it, or a cell that is itself a traced heap object, so there is no registry
    insert, no per-(closure, capture) map entry and nothing to prune. Target: box8 within 1.5× of an equivalent
    object-holder rewrite and ≤10× Node.
  3. Box cells for mutable closure captures are never released outside async state machines: every call leaks a registered GC root plus everything the captured variable points to #10464's release (or 2., which makes release unnecessary) removes the growing root set.

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): stop boxing never-reassigned captured bindings; arrow8 is still 3.6× its unboxed 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.

    What landed since the issue was filed

    Reproducer, fresh numbers (the issue's bench.ts, instructions per factory call)

    function arrow8(x0: number) { const s = { a: x0, b: 1, /* … */ h: 7 };
      const f0 = (x: number) => { s.a = s.b + x; return s.c + s.a; } /* , f1 … f7 */;
      return { inc() { return f0(1) + f4(1); }, get() { return f2(0) + f6(0) + f1(0) + f3(0) + f5(0) + f7(0); } }; }
    // arr8: the same eight arrows held in `const fs = [...]` and called as fs[0](1) … (no boxes)
    // box8: 8 mutated `var`s + 8 `function` declarations
    variant Perry Node Perry/Node boxes/call
    arr8 (control) 10,176 1,205 8.4× 0
    arrow8 36,527 1,110 32.9× 8
    box8 91,551 1,141 80.2× 16

    arrow8 / arr8 = 3.6×. The same closures over the same state cost 26k more instructions per call only because the binding holding each closure is boxed. This is item 1 of the issue.

    Acceptance target

    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 qs --filter moment --filter axios --filter rate-limiter-flexible --filter redis
    python3 scripts/package_bench.py run --perry-bin-dir /tmp/pb --arms node,perry --modes instr --filter dayjs --filter qs --filter moment --filter axios --filter rate-limiter-flexible --filter redis --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. added a commit that references this issue on Sep 30, 2026
    ec4370b
  4. added a commit that references this issue on Sep 30, 2026
    e6b897e
  5. proggeramlug commented on Oct 1, 2026

    @proggeramlug
    ContributorAuthor

    Queue audit update: the scope-context implementation already landed on main in e6b897e. Current main has the scope grouping pass, movable box/scope objects, capture-by-value analysis and test_gap_10520_const_closure_capture.ts. I compared a three-way refresh of #11179 against current main: preserving the newer ABI/rooting fixes leaves only its historical changeset, so #11179 and its superseded predecessor #11177 are now closed.

    Leaving this performance tracker open: its requested arrow8/arr8 and box8/Node instruction ratios, package regressions and peak-RSS targets still require fresh measurements. This queue pass did not run those benchmarks.

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