Summary
TYPED_LAYOUTS (gc/layout.rs) is a thread-local PtrHashMap<usize, TypedLayoutDescriptor> keyed by object address, with one entry per live object of any class with raw-f64 fields. But the descriptor it stores is, per its own #5093 comment, "exactly the compile-time canonical mask codegen emits for the class" — a per-class constant. lower_call/new.rs emits a js_gc_init_typed_shape_layout runtime call on every construction, so every new Foo() pays a hashmap insert, and the map's memory scales O(live objects).
On the churn benchmark from #6882 (200 rounds × 20K linked-list nodes), this is the dominant memory consumer and a large allocation-rate tax.
Evidence
A counting GlobalAlloc wrapper (histogram by size class, dumped at exit) over the same benchmark, with arena blocks moved out of the global allocator so only Rust-side traffic remains:
<=2^20: 5 allocs 2,646,032 bytes ← one structure, doubling…
<=2^21: 4 allocs 4,243,472
<=2^22: 4 allocs 8,749,080
<=2^23: 3 allocs 12,779,536
<=2^24: 2 allocs 17,170,448
<=2^25: 2 allocs 34,340,880
<=2^26: 2 allocs 68,681,744
<=2^27: 2 allocs 137,363,472
<=2^28: 2 allocs 274,726,928
<=2^29: 1 alloc 272,629,768 ← 272 MB single allocation
A classic doubling chain to a single 272 MB table ≈ ~68 bytes × 4M objects (key + TypedLayoutDescriptor { slot_count, raw_f64_mask, pointer_mask } + open-addressing overhead). Small-allocation traffic is negligible (≤2^8: 1,740 allocs total) — this chain is the runtime's Rust-side memory story on object churn.
Because entries are only removed when objects die at a sweep, and the doubling generations are purged with MADV_FREE (which macOS keeps resident — see the memory-model docs note from #6889), the process holds: mimalloc committed: 690 MB for a JS arena that never exceeds 160 MB. Node runs the identical workload in 99 MB total.
Perf side: every new of a typed-field class crosses the FFI to do a hashmap insert; every death does a removal. This is squarely on the "beat Node/Bun on allocation" path.
Fix sketch
The masks are class constants; the per-object table exists so the collector can find a layout from a bare address. Options:
- Intern per class: store a small class/layout id in
GcHeader bits (or reach the existing class registry via the ObjectHeader) and look masks up in a per-class table. TYPED_LAYOUTS then only holds objects whose layout diverged (expando/downgrade paths) — normally empty. Deletes the per-new insert entirely.
- Failing that, intern
TypedLayoutDescriptor (one allocation per class, shared Arc/index) so the per-object entry shrinks to key+index — smaller, but keeps the O(objects) map and the per-new insert.
Option 1 also removes the matching layout_transfer/finalize bookkeeping from the copying/evacuation paths.
Related, separate items
build_valid_pointer_set allocates ~110 B/object at cycle start (+295 MB one-shot on this benchmark) — separate structure, same "side tables scale O(objects)" theme.
- The arena-block/mimalloc RSS interaction and
is_valid_obj_ptr's 2 TB placement floor are tracked in the wt-improve investigation notes (branch improve/audit-20260727, GC-RSS-INVESTIGATION-NOTES.md).
Environment: macOS 26.5 Apple Silicon, Perry v0.5.1264+ (main @ 8eddd8e).
Summary
TYPED_LAYOUTS(gc/layout.rs) is a thread-localPtrHashMap<usize, TypedLayoutDescriptor>keyed by object address, with one entry per live object of any class with raw-f64 fields. But the descriptor it stores is, per its own #5093 comment, "exactly the compile-time canonical mask codegen emits for the class" — a per-class constant.lower_call/new.rsemits ajs_gc_init_typed_shape_layoutruntime call on every construction, so everynew Foo()pays a hashmap insert, and the map's memory scales O(live objects).On the churn benchmark from #6882 (200 rounds × 20K linked-list nodes), this is the dominant memory consumer and a large allocation-rate tax.
Evidence
A counting
GlobalAllocwrapper (histogram by size class, dumped at exit) over the same benchmark, with arena blocks moved out of the global allocator so only Rust-side traffic remains:A classic doubling chain to a single 272 MB table ≈ ~68 bytes × 4M objects (key +
TypedLayoutDescriptor { slot_count, raw_f64_mask, pointer_mask }+ open-addressing overhead). Small-allocation traffic is negligible (≤2^8: 1,740 allocs total) — this chain is the runtime's Rust-side memory story on object churn.Because entries are only removed when objects die at a sweep, and the doubling generations are purged with
MADV_FREE(which macOS keeps resident — see the memory-model docs note from #6889), the process holds: mimalloccommitted: 690 MBfor a JS arena that never exceeds 160 MB. Node runs the identical workload in 99 MB total.Perf side: every
newof a typed-field class crosses the FFI to do a hashmap insert; every death does a removal. This is squarely on the "beat Node/Bun on allocation" path.Fix sketch
The masks are class constants; the per-object table exists so the collector can find a layout from a bare address. Options:
GcHeaderbits (or reach the existing class registry via the ObjectHeader) and look masks up in a per-class table.TYPED_LAYOUTSthen only holds objects whose layout diverged (expando/downgrade paths) — normally empty. Deletes the per-newinsert entirely.TypedLayoutDescriptor(one allocation per class, sharedArc/index) so the per-object entry shrinks to key+index — smaller, but keeps the O(objects) map and the per-newinsert.Option 1 also removes the matching
layout_transfer/finalize bookkeeping from the copying/evacuation paths.Related, separate items
build_valid_pointer_setallocates ~110 B/object at cycle start (+295 MB one-shot on this benchmark) — separate structure, same "side tables scale O(objects)" theme.is_valid_obj_ptr's 2 TB placement floor are tracked in the wt-improve investigation notes (branchimprove/audit-20260727,GC-RSS-INVESTIGATION-NOTES.md).Environment: macOS 26.5 Apple Silicon, Perry v0.5.1264+ (main @ 8eddd8e).