Repository navigation
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
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 shareMeasurements
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=0For 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 generatedmb_ts_factoryshape (40 vars ×
40 inner functions) measured 520× Node. - dayjs
format: boxes 13.3 % of the mode (6.6 % of dayjs overall); qsstringify: boxes 8.6 % plus a GC share of 36 %
(side-channel factoriesvar $wm, $m, …captured by five method closures per call); validatorisURL: 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-221boxes a binding when it is declared, captured
and mutated, or when it is a self-recursive closure.:526-565collect_self_recursive_closure_idshas a fallback arm
(:556-564, "belt-and-suspenders") that boxes anylet/constwhose initializer is a closure literal and that is
referenced by any closure, self-reference or not. That this arm is what boxesarrowConst'sfis inferred (it is
the only rule that matches; the box itself is verified in the IR). Hoistedfunctiondeclarations are boxed as well
(box8: 16 boxes = 8 vars + 8 functions;fnDeclabove); the path is inferred to be theStmt::PreallocateBoxes
handling atboxed_vars.rs:39-45. - Per box:
crates/perry-runtime/src/box.rs:691-732js_box_alloc_bits— pool pop orallocof 8 bytes,
note_box_young_root, insert into the thread-localBOX_REGISTRYhash set (box.rs:99), cache record. - Per (closure, boxed capture):
crates/perry-runtime/src/closure/alloc.rs:787-795js_closure_set_box_capture_ptr→
box.rs:393-402registry probe →crates/perry-runtime/src/closure/box_captures.rs:82-114set_closure_box_capture
(entry in a per-closureHashMap<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
- 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:arrow8within 1.1× ofarr8(≈33 k instructions/call, GC share ≤10 %). - 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:box8within 1.5× of an equivalent
object-holder rewrite and ≤10× Node. - 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
- Related: 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 (open; cells never released in sync code — the leak and RSS side), perf(codegen): async-to-generator boxes are never cleared — completed activations retain 96% of asyncpipe's young generation #7933 (closed; async-activation boxes).
- A closure that captures a closure through a non-escaping local (
arrowSibling) allocates no box, so the extra box
appears only once the capturing closure escapes (inferred from the four probes above).
Activity
proggeramlug commentedon Sep 30, 2026
Current target (2026-09-30): stop boxing never-reassigned captured bindings;
arrow8is still 3.6× its unboxed 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.What landed since the issue was filed
[box-stats]showsreleases == allocs.arrow8still allocates 8 boxes per call (its never-reassignedconstarrows), andbox8allocates 16.pr-gatewas red on its last run.GC_TYPE_SCOPEobject per activation; it deletesBOX_REGISTRY-style tables): rebased 09-28, conflicting again, andpr-gatewas red on its last run. The merge queue held it on 09-25 over code size on the cc bundle:.text+6.8%, gcmap +25%, and ~950 functions demoted to O0.Reproducer, fresh numbers (the issue's
bench.ts, instructions per factory call)arr8(control)arrow8box8arrow8/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
arrow8within 1.1× ofarr8, with 0 boxes per call for never-reassigned bindings.box8≤ 10× Node (≈ ≤ 11.4k per call).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 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.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: use GC scope contexts for captured bindings
perf: use GC scope contexts for captured bindings
proggeramlug commentedon Oct 1, 2026
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.