You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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)constvariant=process.argv[2]||"box2";constN=Number(process.argv[3]||"300000");functionbox2(i: number){letcount=i,last=0;// 2 reassigned lets captured by 2 closuresreturn{inc(){count++;last=count;returncount;},get(){returncount+last;}};}functionobj2(i: number){constst={count: i,last: 0};// control: same state in a const objectreturn{inc(){st.count++;st.last=st.count;returnst.count;},get(){returnst.count+st.last;}};}functionbox8(x0: number){vara=x0,b=1,c=2,d=3,e=4,f=5,g=6,h=7;// TS style: vars + function declsfunctionf0(x: number){a=b+x;returnc+a;}functionf1(x: number){b=c+x;returnd+b;}functionf2(x: number){c=d+x;returne+c;}functionf3(x: number){d=e+x;returnf+d;}functionf4(x: number){e=f+x;returng+e;}functionf5(x: number){f=g+x;returnh+f;}functionf6(x: number){g=h+x;returna+g;}functionf7(x: number){h=a+x;returnb+h;}return{inc(){returnf0(1)+f4(1);},get(){returnf2(0)+f6(0)+f1(0)+f3(0)+f5(0)+f7(0);}};}functionarrow8(x0: number){consts={a: x0,b: 1,c: 2,d: 3,e: 4,f: 5,g: 6,h: 7};// state in a const, 8 const arrowsconstf0=(x: number)=>{s.a=s.b+x;returns.c+s.a;},f1=(x: number)=>{s.b=s.c+x;returns.d+s.b;};constf2=(x: number)=>{s.c=s.d+x;returns.e+s.c;},f3=(x: number)=>{s.d=s.e+x;returns.f+s.d;};constf4=(x: number)=>{s.e=s.f+x;returns.g+s.e;},f5=(x: number)=>{s.f=s.g+x;returns.h+s.f;};constf6=(x: number)=>{s.g=s.h+x;returns.a+s.g;},f7=(x: number)=>{s.h=s.a+x;returns.b+s.h;};return{inc(){returnf0(1)+f4(1);},get(){returnf2(0)+f6(0)+f1(0)+f3(0)+f5(0)+f7(0);}};}functionarr8(x0: number){consts={a: x0,b: 1,c: 2,d: 3,e: 4,f: 5,g: 6,h: 7};// control: same arrows via a const arrayconstfs=[(x: number)=>{s.a=s.b+x;returns.c+s.a;},(x: number)=>{s.b=s.c+x;returns.d+s.b;},(x: number)=>{s.c=s.d+x;returns.e+s.c;},(x: number)=>{s.d=s.e+x;returns.f+s.d;},(x: number)=>{s.e=s.f+x;returns.g+s.e;},(x: number)=>{s.f=s.g+x;returns.h+s.f;},(x: number)=>{s.g=s.h+x;returns.a+s.g;},(x: number)=>{s.h=s.a+x;returns.b+s.h;}];return{inc(){returnfs[0](1)+fs[4](1);},get(){returnfs[2](0)+fs[6](0)+fs[1](0)+fs[3](0)+fs[5](0)+fs[7](0);}};}constmake: (i: number)=>{inc(): number;get(): number}=({ box2, obj2, box8, arrow8, arr8 }asany)[variant];functionrun(n: number): number{lets=0;for(leti=0;i<n;i++){consto=make(i&1023);o.inc();s=(s+o.get())%1000003;}returns;}run(N/5|0);constt0=performance.now();constcs=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]):
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.
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-565collect_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-732js_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-795js_closure_set_box_capture_ptr → box.rs:393-402 registry 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 through decrement_cell_capture_count (:35-52) during GC pruning.
Access: reads validate the cell with is_registered_box_ptr (box.rs:1459-1472).
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 %).
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.
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).
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).