Repository navigation
perf(box): struct Box { value: u64 } has no header, so identity and capture counts live in address-keyed side tables — ~9.8% of native tsc, and likely a memory loss too #10703
Description
Summary
/// A box is simply a heap-allocated JSValue bit slot.
#[repr(C)]
pub struct Box { pub value: u64 }A box has no header — eight bytes of payload and nothing else. It therefore cannot answer any question about itself, so every question is answered by a thread-local side table keyed by its address:
BOX_REGISTRY(PtrHashSet<usize>) — "is this pointer a box?", because the object carries no identity;BOX_CAPTURE_COUNTS(RefCell<PtrHashMap<usize, usize>>) — the capture-edge count, because the object carries no count field.
Giving Box one header word would let both live in the object. On the profile below that is ~9.8% of a real run, and — because a hash entry costs more than the word it would replace — it also looks like a net memory win.
Measured
tsc --noEmit demo.ts (two-line input) compiled with Perry 0.5.1596 + #10656/#10685, sample leaf histogram, 4,917 attributed samples:
| symbol | samples | share | what it is |
|---|---|---|---|
is_registered_box_ptr |
254 | 5.2% | BOX_REGISTRY probe (+ plausibility heuristic + 8-slot cache) |
js_closure_set_box_capture_ptr |
171 | 3.5% | capture-slot publish |
increment_cell_capture_count |
107 | 2.2% | BOX_CAPTURE_COUNTS hash |
decrement_cell_capture_count |
75 | 1.5% | BOX_CAPTURE_COUNTS hash |
publish_box_cell |
44 | 0.9% |
is_registered_box_ptr is the single largest symbol in the whole profile, which is otherwise flat (next is 4.0%).
Both counting paths are the same shape:
fn increment_cell_capture_count(cell: usize, amount: usize) {
BOX_CAPTURE_COUNTS.with(|counts| { // thread-local resolution
let mut counts = counts.borrow_mut(); // RefCell borrow
let record = counts.entry(cell).or_default(); // hash lookup + insert
...Why the current design cannot be optimised further in place
box.rs's own comment explains the ceiling, and it is correct:
A negative cache would NOT be sound — an address that is not a box today can be minted as one tomorrow — so a miss always falls through to the hash set, and only a confirmed positive is recorded.
The same comment records that the positive cache already took this family from 8.2% + 5.9% + 5.5% down to today's numbers. That was the available win. What remains is structural: a slot holding a raw box pointer is indistinguishable from a slot holding a NaN-boxed value, so the runtime must ask an external authority, and negatives — the overwhelming majority — can never be cached because the question is about an address rather than about a value.
A header answers the question at the object, where "not a box today, box tomorrow" cannot arise: the bytes either are a box or are not, and the check is a load and a compare.
Proposal: give Box a header word
#[repr(C)]
pub struct Box { pub header: u64, pub value: u64 } // 8 -> 16 bytesWhat that removes:
BOX_REGISTRYentirely —is_registered_box_ptrbecomes a tag compare on a header the caller is about to touch anyway;- the three 8-slot
BoxPtrCachees inHotTlsand their eviction/quarantine interplay; is_plausible_box_ptr's heuristic, and with it the perry#4898 false-positive class (a read-only__TEXT.__cstringaddress that passes every structural check) — a tagged header cannot be forged by an unrelated allocation;BOX_CAPTURE_COUNTS— the count becomes a field, so increment/decrement is a load/add/store instead of TLS +RefCell+ hash.
Memory (estimate, labelled as such). Per live box today: 8 B of Box, plus a PtrHashSet entry (~10 B with control bytes and load factor), plus — when captured — a PtrHashMap<usize, usize> entry (~19 B). That is up to ~37 B. With a header: 16 B and no tables. So this should reduce footprint as well as work, which is unusual for a "make it faster" change and is worth measuring before and after. It also fits the GC census finding of 147 MB of side tables against a 23 MB live heap.
Scope and risks
Box,I32BoxandBoolBoxare all headerless (I32BoxandBoolBoxarealign(8)wrappers around a scalar), so all three would change shape. Codegen emits box allocations and capture-slot reads/writes directly, so the field offset change reachesperry-codegen, not just the runtime.- The GC traces boxes via the registry today; a header makes tracing more precise rather than less, but the tracing path must be moved over in the same change.
- Worth checking whether the header can be narrower than a word (a tag byte plus a 32-bit count would fit in one
u32pair) before committing to +8 B.
Relationship to the wider pattern
Predicates answering "what kind of pointer is this?" are 18.5% of this profile — is_registered_box_ptr 5.2%, is_registered_buffer_slow 2.8%, classify_heap_generation_uncached 1.3%, resolve_strategy_slow 1.2%, get_parent_class_id 1.2%, is_class_object_ptr 1.1%, keys_find_slot_by_bytes 1.0%, plus is_registered_map and classify_arena. Add the capture-count tables and it is roughly 27% of the run spent on metadata that is stored beside objects instead of in them.
Arrays already do this the right way and it is measurably cheaper: receiver_may_be_registered_exotic reads the GC header's obj_type and is one warm byte plus a compare — see #10694, where wiring the indexing path to it halved 79.7 M probes. Boxes have no equivalent because they have no header to read.
Activity
proggeramlug commentedon Sep 19, 2026
Two findings that unblock this — I was too cautious above
1. It is NOT an ABI break. Codegen never dereferences a box.
I claimed this reaches
perry-codegenand the wire format. It does not. Codegen treats a box pointer as an opaquei64and goes through runtime calls for every access —runtime_decls/strings.rs:1112-1117:and the capture-read lowering says so explicitly (
expr/literals_vars.rs:331-335):There is no GEP, no field offset, no inlined load anywhere in
perry-codegenfor a box. The layout is entirely a runtime concern, so wideningBoxis a runtime-local change.2. The real constraint is safe dereferencing — and the primitive already exists
A header alone does not solve it, which is worth stating because it is the non-obvious part.
is_registered_box_ptris handed an arbitraryu64out of a capture slot; to read a header you must dereference it, and if it is a NaN-boxed double instead you have a wild read. That is what the registry actually guards, and whyis_plausible_box_ptr's structural checks are not enough — perry#4898 is a read-only__TEXT.__cstringaddress that passes all of them.But
arena::page_meta::classify_heap_generation(addr)already answers "is this address inside a perry arena page?", returningHeapGeneration::Unknownfor anything foreign. It is with a one-entry hot-TLS cache on the hit arm (the out-of-line miss arm isclassify_heap_generation_uncached, itself 1.3% of the profile), and it already runs on every heap store the write barrier sees.So the sound sequence is:
No registry, no per-box hash entry, no 8-slot caches, and no "not a box today, box tomorrow" problem — the bytes either carry the magic or they do not. Step 3 also closes the #4898 class: a
__cstringaddress fails step 2 outright.The capture count then lives in the same header word (or a second field), so
increment_cell_capture_count/decrement_cell_capture_countbecome a load/add/store instead of TLS +RefCell+ hash.Scope, restated
Runtime-local, but not small:
Box,I32BoxandBoolBoxall change shape, and the allocation, quarantine/free-pool,scope_releaseand GC-tracing paths move with them (27 call sites inbox.rs, 17 inbox/scope_release.rs). The thing that made me hedge — codegen and the tag ABI — turns out not to apply, and the safe-deref primitive it needs is already written and already hot.Worth measuring before and after: per live box today is 8 B of
Boxplus aPtrHashSetentry plus, when captured, aPtrHashMapentry. A 16 B header'd box with no tables should win on both axes.proggeramlug commentedon Sep 19, 2026
Correction to my previous comment:
classify_heap_generationdoes NOT work hereI proposed gating the header read on
arena::page_meta::classify_heap_generation(addr) != Unknown. That is wrong. Boxes are not arena-allocated:Straight from the system allocator, which
BOX_REGISTRY's own doc comment already says — "boxes come fromstd::alloc::allocdirectly, not the GC arena". Soclassify_heap_generationwould answerUnknownfor every box and the guard would reject all of them. Anyone implementing from my last comment would hit that immediately. Apologies.Finding #1 from that comment still stands and is verified: codegen treats box pointers as opaque
i64and never dereferences one, so the layout is runtime-local.What the registry is actually doing, and why this is bigger than a header
BOX_REGISTRYcarries two responsibilities, and a header only addresses the first:u64from a capture slot a box?" It is the authoritative answer because there is no other way to know an address came fromjs_box_alloc_bits.A header cannot supply (2): you cannot enumerate boxes from their headers without scanning the heap. So "add a header, delete the registry" is not a viable shape.
The design that does work: allocate boxes from a dedicated slab
Move box allocation out of
std::allocinto a dedicated slab/region. Then:__TEXT.__cstringclass.increment_cell_capture_count/decrement_cell_capture_countstop being TLS +RefCell+ hash.BOX_REGISTRY,I32_BOX_REGISTRY,BOOL_BOX_REGISTRY, the three 8-slot caches andBOX_CAPTURE_COUNTSall go away — that is the ~9.8% plus their memory.It should also help locality: the registry is pre-sized to 128 k buckets (~2 MB) because
promise_all_chainsallocates ~150 k boxes per run, and those boxes are currently scattered across the malloc heap.Honest scoping
This is an allocator change, not a struct change — larger than I implied twice now. It touches allocation, the free pool and quarantine,
scope_release, and GC tracing (27 call sites inbox.rs, 17 inbox/scope_release.rs), and the slab needs its own lifetime story for released cells.The measurements motivating it are unchanged and stand:
is_registered_box_ptris the single largest symbol in the profile at 5.2%, the capture-count pair adds 3.7%, and the wider "what kind of pointer is this?" family is 18.5%.proggeramlug commentedon Sep 30, 2026
Current target (2026-09-30): the box still has no header; a boxed hot read/write costs +186 instructions per call over an object holder
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 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.For this issue specifically, the leaf
is_registered_box_ptrunderjs_closure_set_box_capture_ptris theBOX_REGISTRYprobe this issue wants to delete (dayjs/parse_format, 46k instr/iter).What landed since the issue was filed
Nothing structural. On main,
pub struct Box { pub value: u64 }is unchanged (box.rs:74),BOX_REGISTRYis still in place (box.rs:99), and so isBOX_CAPTURE_COUNTS(closure/box_captures.rs:67).perf(gc): scope context objects for captured-and-mutated bindings #11179 would make this issue moot rather than implement it: it keeps captured-and-mutated bindings in GC-heap
GC_TYPE_SCOPEobjects and deletes the box registries andBOX_CAPTURE_COUNTS. perf: share boxed closure capture layouts by function #11177 shares capture layouts per function.Both PRs look stalled and need a land / rebase / close decision:
If perf(gc): scope context objects for captured-and-mutated bindings #11179 is closed, this issue's header design is the fallback.
Reproducer, fresh numbers (instructions per call)
let(boxed)The whole-program cost (box creation and capture-count maintenance per closure) is measured on #10520:
arrow8is 36.5k vs 10.2k instructions per call.Acceptance target
boxedwithin 1.2× of the object control (≤ ~260 instr per call). Nois_registered_box_ptrorincrement/decrement_cell_capture_countin a profile of the reproducer.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 redis --filter rate-limiter-flexible python3 scripts/package_bench.py run --perry-bin-dir /tmp/pb --arms node,perry --modes instr --filter dayjs --filter qs --filter redis --filter rate-limiter-flexible --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