Skip to content

TYPED_LAYOUTS stores per-class-constant layout masks per OBJECT — O(objects) memory (272MB on churn bench) + hashmap insert on every new #6893

Description

@proggeramlug

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:

  1. 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.
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions