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

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