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
Activity
- addedperformanceRuntime, compile-time, build-size, or memory performanceRuntime, compile-time, build-size, or memory performancepackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
on Sep 17, 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:workload entry → leaf instr/iter % of excess dayjs/diff_startof js_arguments_object_alloc239k 2.6 dayjs/parse_format js_arguments_object_alloc58k 2.9 dayjs/parse_format js_closure_set_box_capture_ptr→is_registered_box_ptr46k 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_ptr37k 1.0 mongodb/batch_query box_get_bits_named3.07M 1.4 rate-limiter-flexible/get_penalty box_get_bits_named+js_box_set_bits+set_box_capture_ptr4.5k 3.2 moment/parse_format js_arguments_object_alloc36k 1.0 redis/set_get set_box_capture_ptr+js_box_release86k 1.3 Boxes 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 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 is closed (2026-09-18). Cells are now released: on main,
[box-stats]showsreleases == allocs. - The boxing decision itself is unchanged.
arrow8still allocates 8 boxes per call (its never-reassignedconstarrows), andbox8allocates 16. - Two open PRs target this, and both look stalled and need a land / rebase / close decision:
- perf: share boxed closure capture layouts by function #11177 share boxed closure capture layouts by function: opened 09-24, last pushed 09-24, now conflicting with main, and
pr-gatewas red on its last run. - perf(gc): scope context objects for captured-and-mutated bindings #11179 scope context objects for captured bindings (one
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.
- perf: share boxed closure capture layouts by function #11177 share boxed closure capture layouts by function: opened 09-24, last pushed 09-24, now conflicting with main, and
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 arrow836,527 1,110 32.9× 8 box891,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
arrow8within 1.1× ofarr8, with 0 boxes per call for never-reassigned bindings.box8≤ 10× Node (≈ ≤ 11.4k per call).- No regression on dayjs, qs, moment, axios, rate-limiter-flexible and redis.
- No peak-RSS regression on tsc and Zod. This is the cost question that stopped perf(gc): scope context objects for captured-and-mutated bindings #11179.
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.- 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 is closed (2026-09-18). Cells are now released: on main,
- added a commit that references this issue
on Sep 30, 2026 - added a commit that references this issue
on Sep 30, 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.
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
constbindings holding a closure that are never reassigned. In aTypeScript-compiler-style factory (8
vars and 8 innerfunctions) that is 16 boxes per call and 259× Node; the sameclosures reached through a
constarray 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: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 arefrom one separate
PERRY_GC_DIAG=1run each.box2(2 reassignedlets)obj2(control)box8(8vars + 8functions)arrow8(8constarrows, no reassignment)arr8(control: same arrows in aconstarray)Checksums identical.
arrow8andarr8run the same closures over the same state; the only difference is thatarrow8'seight 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.
box8adds eight genuinely mutatedvars and usesfunctiondeclarations.The
obj2/arr8controls 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]):For
arrowConst,--print-hirshowsLet { name: "f", mutable: false }andmutable_captures: [], yet--trace llvmshows
js_box_alloc_bits,js_box_set_bitsandjs_closure_set_box_capture_ptrin the function.Profile of
arrow8(inclusive): copying minor 48 % (scan_box_roots_mut26 %),js_closure_set_box_capture_ptr9.1 %,js_box_set_bits5.9 %,is_registered_box_ptr5.0 %,prune_dead_closure_box_capture_owners4.7 %,js_box_alloc_bits4.5 %,decrement_cell_capture_count4.2 %,increment_cell_capture_count4.0 %.Impact
From the audit profiles (v0.5.1587, large and strings group reports):
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.
format: boxes 13.3 % of the mode (6.6 % of dayjs overall); qsstringify: boxes 8.6 % plus a GC share of 36 %(side-channel factories
var $wm, $m, …captured by five method closures per call); validatorisURL: 42 boxes per call.Mechanism
Citations read at 7661bc0 (verified) unless marked.
crates/perry-codegen/src/boxed_vars.rs:207-221boxes a binding when it is declared, capturedand 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 isreferenced by any closure, self-reference or not. That this arm is what boxes
arrowConst'sfis inferred (it isthe only rule that matches; the box itself is verified in the IR). Hoisted
functiondeclarations are boxed as well(
box8: 16 boxes = 8 vars + 8 functions;fnDeclabove); the path is inferred to be theStmt::PreallocateBoxeshandling at
boxed_vars.rs:39-45.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.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-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 throughdecrement_cell_capture_count(:35-52) during GC pruning.is_registered_box_ptr(box.rs:1459-1472).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
consts and function declarations that nothingreassigns) 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 %).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 equivalentobject-holder rewrite and ≤10× Node.
Notes
arrowSibling) allocates no box, so the extra boxappears only once the capturing closure escapes (inferred from the four probes above).