Skip to content

perf(GC): make per-object layout O(1)-loadable — kill per-operation thread-local layout tracking (umbrella: method_calls/array-downgrade/object-property) #5094

Closed

Description

@TheHypnoo

Summary

Several of Perry's worst benchmark gaps share one systemic root cause, not separate ones: Perry tracks per-object / per-slot GC layout (which slots hold raw-f64 numbers vs NaN-boxed pointers) in thread-local hashmaps (TYPED_LAYOUTS, LAYOUT_SLOT_MASKS) and queries or updates them on every field/element access. On macOS each access is a _tlv_get_addr (TLS accessor) + a hashmap hash+lookup. The all-numeric "unboxed" fast paths bypass it, but any heterogeneous shape, downgraded array, or raw-f64 class field falls into the per-op hashmap path, which dominates.

Benchmark Gap vs Node Hot path Per-op cost
method_calls (#5093) ~290× (3300 ms vs 11 ms) this.field get/set in class methods js_typed_feedback_class_field_{get,set}_guard → class_field_fast_contract → layout_typed_raw_f64_slot_for_user (TLS TYPED_LAYOUTS)
bench_numeric_array_downgrade ~781×; ~21× over the same-shape numeric array arr[i]=… on heterogeneous/any[] arrays js_array_set_f64_extend → note_array_slot → layout_note_slot (TLS), per write
bench_object_property ~17× after #5084 dynamic property writes same field-access guard family

Evidence (measured)

  • Profile (sample): the downgrade hot loop is a storm of _tlv_get_addr (TLS) calls.
  • method_calls: removing the per-access register_site call (perf(codegen): make typed-feedback site registration opt-in (3.6x on dynamic property access) #5084) → ~2%; an MRU cache over layout_typed_raw_f64_slot_for_user made it 2× SLOWER (3300 → 7050 ms). Inlining the guard is blocked because the raw-f64 check is a TLS hashmap lookup, not an O(1) header field.
  • numeric_array_downgrade: an obj_type pre-filter that skips the per-element typedarray/buffer/set/map registry lookups gave only ~5%; a "skip the in-bounds RuntimeHandleScope rooting" fast path made it worse. So the cost is note_array_slot → layout_note_slot (the TLS layout write), per element.

There is no cheap shortcut. The fix is to make per-object layout O(1)-loadable from the object/GC header so the layout check/update is an inline bit-test/bit-set instead of a thread-local hashmap op.

Proposed fix — "layout is canonical" header bit + static class mask

GcHeader is 8 bytes with a free _reserved: u16 and spare gc_flags bits. Add a bit meaning "this object's slot layout still matches its declared/canonical shape" (no downgrade yet):

  • Class instances: when set, the authoritative raw-f64 mask is the compile-time perry_typed_shape_raw_f64_mask_<class> global codegen already emits — a constant codegen can bit-test inline (no TLS). With the method already inlined (perf(transform): inline small this-using methods on exact receivers (1.6x on method_calls) #5092), LLVM LICM hoists the loop-invariant check out of the hot loop.
  • Arrays: when set, skip the per-write layout_note_slot for scalar-over-scalar in-bounds stores.
  • On downgrade (a pointer/string written into a canonical slot): clear the bit and fall back to today's per-object TYPED_LAYOUTS/LAYOUT_SLOT_MASKS path (unchanged). The GC scanner consults the bit.

The TLS hashmap exists today only to track downgrades; the common (no-downgrade) case does not need it.

Optional complement: store a compact inline mask in _reserved for small objects/arrays (≤16 slots) so even small downgraded shapes avoid the hashmap.

Correctness invariants (the crux — this is GC-internals, memory-corruption risk)

  1. GC scanner sees the truth. Per-slot pointer/raw-f64 determination after the change must equal today's (gc/trace.rs:758, gc/copying.rs:309,579 consult per-slot layout_kind). A wrong mask = trace a number as a pointer (crash) or miss a pointer (use-after-free).
  2. Representation. A slot read as raw double must hold raw f64; the canonical bit must be cleared before the first non-number is observable in a canonical slot (publish-order discipline, like descriptors_in_use).
  3. Downgrade is monotonic + complete; the fallback path stays byte-for-byte current behavior.
  4. GC moves transfer the bit + mask (gc/copying.rs:504 layout_transfer).

Phasing (each independently shippable + verifiable)

  1. Spike + microbench harness; prototype the bit read-only and confirm it tracks downgrade under GC stress.
  2. Arrays first (lowest blast radius): wire the bit for arrays; skip layout_note_slot + write barrier for scalar-over-scalar in-bounds writes; scanner honors the bit.
  3. Class fields: emit the inline guard in codegen (expr/property_get.rs:1551, property_set.rs) using the header bit + static class mask, by-name fallback for the cleared-bit case.
  4. Object property path if the mechanism generalizes.

Verification

  • Full local parity (./run_parity_tests.sh) — zero NEW regressions vs base (compare combined stdout+stderr per file). cargo test --release --workspace.
  • GC correctness: run under PERRY_GC_VERIFY_EVACUATION=1, PERRY_GC_FORCE_EVACUATE=1, PERRY_GC_DIAG=1, and PERRY_GEN_GC=0 (full mark-sweep) — these panic on the corruption modes a wrong mask causes.
  • Targeted tests: (a) write a non-number into a number-typed class field via an any alias, then read it back; (b) a numeric array that receives an object slot then is GC-evacuated mid-loop; (c) holey/sparse downgraded arrays.
  • Per-benchmark perf regression gate.

Risk: HIGH (memory-corruption class). Effort: L (GC-internals). Maintainer-driven; not for autonomous execution.

Prior groundwork on this line

Files

gc/types.rs (GcHeader), gc/layout.rs (TYPED_LAYOUTS/LAYOUT_SLOT_MASKS, TypedLayoutDescriptor, layout_note_slot, layout_typed_raw_f64_slot_for_user), gc/trace.rs + gc/copying.rs (scanner + layout_transfer), array/indexing.rs + array/header.rs (note_array_slot), typed_feedback/guards.rs (class_field_fast_contract), codegen expr/property_get.rs:1551, expr/property_set.rs, typed_shape.rs.

Activity

  1. TheHypnoo commented on Jun 13, 2026

    @TheHypnoo
    MemberAuthor

    Update — precise localization of bench_numeric_array_downgrade (M1 Pro)

    Investigated the array side directly. The cost is not inside the array setter functions, and it is not dominated by a single call — three surgical attempts moved the needle 0–5%:

    • skip the per-element typedarray/buffer/set/map registry lookups via an obj_type pre-filter in js_array_get_f64/js_array_set_f64* → ~5% (helps the GET path)
    • make note_array_slot scalar-aware (skip the layout-mask update + barrier when neither old nor new value is a pointer) → 0%
    • reorder the write barrier so the non-pointer skip runs before incremental_mark_barrier_value's thread-local touch → 0%

    Why 0%: for an in-bounds arr[i] = value, codegen does not call the array setter — it inlines the slot store and emits separate per-write runtime calls:

    ; idxset fast path (per element, ~6.25M times)
    store double %v, ptr %slot
    call void @js_gc_note_slot_layout(i64 %arr, i32 %i, i64 %vbits)   ; → layout_note_slot (TLS hashmap on SIDE_MASK arrays)
    call void @js_write_barrier_slot(i64 %arr, i64 %slotaddr, i64 %vbits)
    call void @js_array_note_numeric_write(i64 %arr, i64 %vbits)      ; no-op for numbers, but a call
    ; + js_shadow_slot rooting

    js_array_set_f64_extend only appears on the realloc fallback path. So the setter-internal layout tracking I optimized is dead code for this loop.

    Measured ~736 ns per arr[i] = arr[i] + 1 on M1 Pro (vs ~21 ns for a plain read) — split across the inline store + ~4 codegen-emitted non-inlined runtime calls (layout note, write barrier, numeric note, shadow rooting) + the js_array_get_f64 read. No single one dominates; it is death-by-many-per-element-calls.

    Implication for the fix

    This is the codegen emission side of the same systemic issue. The clean wins are at codegen (crates/perry-codegen/src/expr/write_barrier.rs):

    1. For a store whose value is statically non-pointer (or the element type is numeric), don't emit js_write_barrier_slot / js_array_note_numeric_write — both are no-ops for numbers. (Limited for any[] where the value is statically any, but covers typed numeric arrays.)
    2. The js_gc_note_slot_layout per-write call is the layout-mask update; making it skip cheaply for non-pointer stores needs the O(1) header-state work described in this issue (the SIDE_MASK clear is a thread-local hashmap touch).
    3. Longer-term: inline the element get/set so these collapse and LLVM can hoist/eliminate the loop-invariant parts (parallels the class-field inline-guard plan for method_calls).

    A properly symbolicated profile is needed to rank the ~4 calls precisely — the release binary is stripped (-Wl,--strip-debug via auto-optimize), so sample only shows _tlv_get_addr storms without function names.

  2. TheHypnoo commented on Jun 13, 2026

    @TheHypnoo
    MemberAuthor

    Update — first concrete win landed: PR #5098 (bench_numeric_array_downgrade 4482 → 427ms, ~10.5× on M1 Pro).

    This is the array side of the umbrella: the per-write js_gc_note_slot_layout (→ layout_note_slot) thread-local hashmap touch on every arr[i] = value. An A/B stub experiment confirmed it was the dominant cost (stubbing it: 11× faster; stubbing the write barrier: ~2%).

    The fix is the targeted, low-risk version of the design here: js_gc_note_slot_layout_aware(parent, slot, new, old) skips the hashmap when neither old nor new is a heap pointer (slot pointer-ness unchanged → mask invariant preserved). Codegen loads the previous slot value before an in-place array element overwrite and routes only those through it. GC-stress verified (pointer↔number transitions under VERIFY_EVACUATION/FORCE_EVACUATE/full mark-sweep), full parity zero-new-regression.

    Still open under this umbrella:

    The js_gc_note_slot_layout_aware predicate + the GcHeader _reserved layout-state machine (GC_LAYOUT_POINTER_FREE/SIDE_MASK, already present) are the building blocks for those.

  3. proggeramlug commented on Jun 16, 2026

    @proggeramlug
    Contributor

    Investigation: why #5093/#5198's inline class-field guard stays ~10%, and what the real blocker is

    Followed up on the method_calls leg after #5198 ("inline class-field shape guard fast path") merged. Findings (all measured locally; benchmarks are noisy on this box so numbers are best-of):

    1. The ~10% ceiling is the per-access flag load, not the fallback safepoint. #5198's emit_class_field_inline_precheck reads @PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED with a load_volatile on every access. That load is pinned inside the hot loop — the cold js_typed_feedback_class_field_*_guard fallback is an opaque external store, so LLVM must assume it writes the global and cannot hoist the load. That single load defeats LICM. #5198's NOTE attributes the non-collapse to "the cold fallback call remains a GC safepoint that forces the shadow-rooted receiver to reload"; the safepoint is present in a collapsing variant too, so the dominant blocker is the flag load. (Relaxing volatile→plain load changes nothing — still pinned by the cold call.)

    2. Removing the flag from the hot path does unlock the targeted collapse. With the per-access flag load gone (gating the escape-hatch/verify at compile time instead), LLVM hoists the whole loop-invariant shape check:

    • 09_method_calls (foldable): ~3200 ms → ~36 ms (collapses; LLVM even const-folds the counter loop)
    • non-foldable method-call loop: ~7240 ms → ~2660 ms (~2.7×, a real generalizable win)
    • correct vs Node on delete / defineProperty (instance + prototype) / polymorphism; GC-safe under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 and full mark-sweep.

    3. But the flag is load-bearing for correctness, so dropping it isn't safe yet. Removing it regresses test_gap_dup_class_name_field_layout (reads isOptional: true instead of false). The inline deref logic is byte-identical to main's, so main is correct here only because its flag disables the inline path when descriptors come into use — masking a latent inline-path slot bug in the cross-module duplicate-class-name (#26/#321) scenario. The flag's global "descriptors-in-use ⇒ disable inline" is doing real work the per-object OBJ_FLAG_HAS_DESCRIPTORS check doesn't cover.

    Takeaway for the collapse (#5093): the path isn't "relax the volatile flag" — it's "fix the underlying inline-path slot bug in the dup-class-name case so the global disable-on-descriptors can be dropped." Once the inline path is correct without the global flag, removing the per-access load unlocks the ~2.7× collapse. This is the loop-versioning/deopt-exit work #5198 already flagged as open here.

    (No shippable code change fell out of this — the feature is already in via #5198 — so I'm closing the exploratory PR. Recording this so the next attempt starts from the right blocker.)

  4. added a commit that references this issue on Jul 6, 2026
  5. proggeramlug commented on Jul 10, 2026

    @proggeramlug
    Contributor

    Scoping note from the #6180 default-on campaign (2026-07-10): this remains the top GC-adjacent PERF item after the incremental graduation, and the campaign added one synergy worth folding in — runtime allocations now stamp birth flags from a thread-local (GC_BIRTH_EXTRA_FLAGS, allocate-black during budgeted cycles), which is exactly the kind of per-alloc TLS the header-bit strategy here would subsume: a canonical-layout bit AND the birth-mark could ride the same header word write. Sequencing recommendation: land after the flip PR (the collector's write patterns are now stable), as its own measured campaign — the per-op TLS elimination touches the codegen inline paths (lower_call.rs bump allocator, typed-feedback guards) that the GC work deliberately avoided. The issue's plan (canonical bit + compile-time class mask + downgrade fallback to today's TYPED_LAYOUTS) still matches the code as of main @ #6239.

  6. added
    performanceRuntime, compile-time, build-size, or memory performance
    triagedMaintainer reviewed; type, scope, and next step are clear
    on Jul 26, 2026
  7. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    Contributor

    New data point for this umbrella, from a symbolicated profile of an allocation-heavy workload (gc-handoff/bench/churn_alloc.ts — 20M {v: number, w: number} literals pushed into an array), filed as #7510.

    The table in this issue covers the access paths (this.field get/set, array element writes, dynamic property writes). The construction and death path is a fourth member of the family, and on allocation-heavy code it is now the single largest cost:

    group share of self time
    gc::layout side tables 33.6%
    — layout_forget_object (gc/layout.rs:336) 14.5%
    — layout_note_slot (gc/layout.rs:685) 7.9%
    — js_gc_init_typed_shape_layout (gc/layout.rs:962) 7.7%
    _tlv_get_addr 17.0%
    the allocation itself 7.7%

    layout_forget_object alone is nearly twice the allocator. js_gc_init_typed_shape_layout runs per new, layout_forget_object per object death — so this scales with allocation rate, not live set, which is why it dominates churn-shaped code and not retain/tree.

    The "layout is canonical" header-bit design proposed here applies directly; #7510 spells out what it means for construction/death specifically. Note #6893 closed the memory half of TYPED_LAYOUTS (289 → 133 B/record) but left the CPU half untouched.

  8. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    Contributor

    Symbolicated profile data putting a number on the CPU half of this issue — it is now the single largest cost in object construction.

    churn_alloc (object literal + push), quiet pinned host, PERRY_DEBUG_SYMBOLS=1, 1500 leaf samples:

    group share
    gc::layout side tables — layout_forget_* 14.5%, layout_note_slot 7.9%, js_gc_init_typed_shape_layout 7.7%, shape_install_* 2.2%, LayoutSlot* 1.3% 33.6%
    _tlv_get_addr 17.0%
    write barriers 16.1%
    typed-feedback guards 9.2%
    array helpers 6.2%
    the actual allocation (js_object_alloc_class_inline_keys 6.3%, arena_alloc* 1.4%) 7.7%
    user code 3.7%

    Object construction is ~79% of churn (subtracting push_num, which runs at 2.7× vs node and shows the array machinery is basically fine). Of that construction cost, ~76% is GC and feedback bookkeeping and 8% is the allocation itself.

    Two things worth pulling out:

    • layout_forget_* alone is 14.5% — nearly twice the allocation. It runs on object death as well as construction, so this is not only a construction-path cost.
    • These are thread-local lookups, so a slice of the 17% _tlv_get_addr is this same subsystem. perf(runtime): cache the hot thread-locals so one allocation pays one _tlv_get_addr #7474 cached the hot thread-locals and took TLS from 34% to ~14–17%; the residue is partly here. One fix, two wins.

    #6893 fixed the memory half of TYPED_LAYOUTS (289 → 133 B/record). The CPU half was never touched and is now the top lever in the whole mutator profile.

    Partial progress just landed: #7501 declares an all-pointer element layout once at array allocation instead of noting each pointer push, removing the per-push layout_note_slot / js_array_note_numeric_write pair on proven arrays (verified by IR call census, 2 calls → 0 in the hot block). That addresses a slice of the 7.9% layout_note_slot row on the array-store path only — it does not touch layout_forget_* or js_gc_init_typed_shape_layout, which are per-object and together are 22.2%.

    One measurement caveat for anyone benchmarking here: PERRY_WRITE_BARRIERS=0 makes churn_alloc slower (2.44 s → 5.21 s) because it also switches the collector out of evacuating mode. It cannot be used to bound barrier cost, and a run with it set is not a barriers-free baseline.

  9. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    Contributor

    Resolution: this umbrella's proposal is implemented, and its evidence base has been superseded

    Re-audited against origin/main @ 514ff7c9a (v0.5.1510). Every claim below is
    a citation into current main or a closed sibling ticket; nothing here rests on
    "the issue is old".

    1. The proposed fix shipped, in the functions this issue named

    The issue proposed a "layout is canonical" bit in GcHeader plus a
    compile-time class raw-f64 mask, with the thread-local hashmaps kept only as
    the downgrade fallback. That is what main does.

    GC_OBJ_TYPED_LAYOUT_INTACT: u16 = 0x1000 — bit 12 of GcHeader._reserved —
    crates/perry-runtime/src/gc/layout.rs:80, whose doc comment states this
    issue's invariant verbatim:

    intact bit set ⟹ a canonical typed descriptor exists for this object, either
    per-object in TYPED_LAYOUTS OR (the #6893 common case) shared by shape in
    SHAPE_LAYOUTS … so combined with a class_id/keys_array match the
    codegen-inlined class-field shape guard can conclude "slot K is raw-f64" from
    this single bit — no cross-crate guard call, no thread-local hashmap probe

    62 references tree-wide. The four phases the issue laid out:

    phase status on main citation
    1 — header bit prototype done gc/layout.rs:80-107
    2 — arrays done GC_ARRAY_RAW_F64_LAYOUT 0x80, GC_ARRAY_RAW_F64_HOLES 0x1000 (#6011), GC_ARRAY_ELEMENT_SHAPE 0x800 (#7480), GC_LAYOUT_ALL_POINTERS 0x2000, GC_LAYOUT_POINTER_FREE 0x4000 — gc/types.rs:1050-1092, gc/layout.rs:19-82. Codegen elides the per-element note via array_store_needs_layout_note (perry-codegen/src/expr/helpers.rs:186)
    3 — class fields done perry-codegen/src/expr/class_field_inline_guard.rs — "The single per-object GC_OBJ_TYPED_LAYOUT_INTACT bit … stands in for the thread-local raw-f64 layout probe" (lines 32-37)
    4 — object property done class_field_store_needs_layout_note / class_field_store_layout_note_is_conforming, both labelled "Phase 4b.1"/"Phase 4b.2 (#5094, refs #7510)" — expr/helpers.rs:196-240

    The named per-operation lookup is gated by the bit inside the very function
    this issue pointed at, with the issue number in the comment
    (gc/layout.rs:584-618):

    The canonical typed-shape descriptor probe below is a thread-local hashmap
    lookup, paid on every field/element store. Gate it on the O(1)
    GC_OBJ_TYPED_LAYOUT_INTACT header bit … Skipping it removes the per-write
    TLS touch on the common dynamic-shape / pointer-free object and array store
    path (#5094).

    Beyond the original proposal, the storage was collapsed twice more:
    #6893 (shape-keyed SHAPE_LAYOUTS, "shared keys_array IS a shape", churn
    peak RSS 830 → 262 MB) and #7510/#7525 (PER_OBJECT_LAYOUTS_NONEMPTY plus a
    4096-bit inline address filter, gc/layout_tables.rs).

    2. The _tlv_get_addr premise is dead

    This issue and its comments attribute a large share to the Darwin TLS
    accessor. docs/engine-plan.md:137 tracks that row to completion:

    row then now
    _tlv_get_addr 17.0% → 27.0% 1.1% — closed by #7565
    gc::layout_tables::layout_forget_object 14.5% 1.7–2.9% — #7525/#7532

    and states the conclusion in the plan itself:

    Nothing regressed — the two survivors grew as shares because the rows
    around them collapsed. _tlv_get_addr and layout_forget_object are no
    longer levers and should not be worked.

    (Also worth recording for anyone re-reading the old comments: the "Darwin has
    no local-exec TLS so the accessor must stay call-free" framing is true of
    std::thread_local! and false of crate::perry_thread_local!, which resolves
    through pthread TSD from TPIDRRO_EL0 — established by #7469, whose finding
    was that _tlv_get_addr cost is call count, not call price.)

    3. Every benchmark row in the issue's evidence table has a closed successor

    row in this issue gap then successor outcome
    method_calls (#5093) ~290× #7287 (closed 2026-08-05) changelog.d/7425: 90 ms → 10 ms (9.0×), "puts Perry at Node 26.5.1's 11 ms instead of 8× behind it"; hot loop 71 IR instrs → 10
    bench_numeric_array_downgrade ~781× #5098 + the #5094-tagged codegen elision ~10.5× then a further ~9× (expr/index.rs, expr/write_barrier.rs comments)
    bench_object_property ~17× #5084 then the #6812 write-PIC family —
    construction/death (33.6% gc::layout) — #7510 (closed 2026-08-09) #7525 + #7535: repeat construction of an installed shape is two header bit-writes; shape_install_shared entered once in a 20 M-object run

    changelog.d/7525 also retires this issue's own headline datum directly:

    layout_forget_object is no longer 14.5% of churn_alloc — after
    #7469/#7474/#7486/#7487/#7501 it is 3.0%, so removing it entirely could
    never have delivered 1.5×.

    Two of the comments on this issue proposed that removing the per-access
    @PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED volatile load would unlock a ~2.7×
    collapse. That has now been measured twice and refuted both times —
    class_field_inline_guard.rs:13-27 (repsel Phase 3b audit) and
    changelog.d/7425 ("De-volatilizing it in the emitted IR and re-running
    opt -O3 gives 71 hot-path instructions either way"). The real fix was
    Perry's own preheader hoist, which shipped.

    4. The remaining "obvious extension" is a documented correctness trap

    docs/engine-plan.md:177-208, "⛔ Two remedies that look obvious and are wrong.
    Do not rebuild them", item 2:

    ⛔ Having codegen OR GC_OBJ_TYPED_LAYOUT_INTACT into the inline new's
    header word is a use-after-free factory.
    … It breaks the unwritten
    invariant "intact ⟹ a descriptor is reachable", which layout_note_slot
    silently depends on.

    5. Re-measured on current main (v0.5.1510, Node 26.5.1)

    Compiler and both static archives rebuilt from a clean worktree at
    514ff7c9a (libperry_runtime.a 23:23, libperry_stdlib.a 23:30, perry
    23:31), PERRY_RUNTIME_DIR pinned to that release/, default auto-optimizing
    pipeline. The host is heavily contended (node's own
    bench_numeric_array_downgrade varied 7 → 44 ms across five runs), so the
    load-independent figures are /usr/bin/time -l, best of 3, with each engine's
    own startup floor subtracted (node 537,045,322 instr / 193,521,152 cycles;
    perry 25,092,026 / 10,644,738, from console.log("x")).

    this issue's benchmark gap when filed instructions cycles
    09_method_calls ~290x 0.48x 0.45x
    bench_numeric_array_downgrade ~781x 3.01x 2.21x
    bench_object_property ~17x 5.53x 4.28x

    In-program Date.now(), 5 alternated pairs, checksums identical on every run:
    09_method_calls node 16 ms / Perry 13 ms — Perry beats Node
    (value:10000000 both). bench_object_property node ~33 / Perry ~243 (~7.4x,
    was 17x). bench_numeric_array_downgrade is drowned by host noise; take the
    2.21x cycles figure.

    churn_alloc (the 20M-{v,w} workload #7510 profiled, where the family was
    17.4x): now 1.96x instructions / 1.53x cycles, work-only.

    Two notes on what the two remaining rows are today, because neither is
    per-object layout any more:

    6. Two defects found while auditing, both filed separately

    • root_reload.rs's NON_COLLECTING contained js_gc_layout_note_slot, a
      symbol that has never existed
      (the runtime exports js_gc_note_slot_layout
      and js_gc_note_slot_layout_aware). Safe-direction, hence silent — but it
      forced a root reload at every emitted slot-layout note, including the one per
      guarded array element store, which is this issue's hot path. Fixed with
      regression coverage; see the linked PR.
    • docs/engine-plan.md's "⛔ do not rebuild" item 2 forbids OR-ing
      GC_OBJ_TYPED_LAYOUT_INTACT into the inline new's header constant. perf(codegen): stamp a pointer-free shape's typed layout into the allocation header #7834
      shipped exactly that four days later, under three extra conditions the ⛔ text
      does not consider, and the doc was never updated. The ⛔'s mechanical claim is
      still literally true of layout_note_slot — gc/layout.rs:627 still asserts
      "exactly one [descriptor] is present for an INTACT object", the Downgrade
      arm is reachable only from Some(verdict), and the fall-through pointer path
      (:702-752) sets SIDE_MASK without clearing the bit — but there is a
      defence the ⛔ does not mention: every by-name own-slot overwrite calls
      mark_object_dynamic_shape_unknown first
      (object/field_set_by_name/fast_paths.rs:101 + 10 more sites), which for a
      baked object does not early-return and does clear the bit. I could not
      settle it by experiment — my probe compiled to declare, not the bake
      (--trace llvm shows @PERRY_PER_OBJECT_LAYOUTS_ANY is never loaded), so it
      proves nothing. Flagging for perf(codegen): stamp a pointer-free shape's typed layout into the allocation header #7834/perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047 rather than claiming a clean bill.

    7. Where the live work is

    The object-representation ladder that grew out of this line is tracked
    granularly and is still moving: #8067 (make ShapeId authoritative for keys,
    live slots and class-object kind) → #8047 (finish the 56 B → 40 B common
    object header). Keeping this umbrella open on top of them adds no information
    and its own evidence table is now three profiles out of date.

    Recommendation

    Close as delivered + superseded. Six shipped changelog fragments name this
    issue (7193, 7250, 7525, 7535, 7686, 7698), all four phases are in
    main, all three benchmark rows have closed successors, and the plan of record
    says the two named cost rows should not be worked. Follow-on work belongs to
    #8067/#8047.

  10. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    Contributor

    Closing per the audit above: all four phases of the proposed fix are in main and tagged with this issue number in ~30 source sites; every named child (#5093, #6893, #7510, #7578, #7469, #7511, #7480) is closed; the headline row 09_method_calls went from ~290x to beating Node (13 ms vs 16 ms, 0.45x on cycles); _tlv_get_addr is 1.1% (#7565) and docs/engine-plan.md records that it and layout_forget_object "are no longer levers and should not be worked".

    The two remaining benchmark rows are no longer per-object layout problems: bench_object_property is an allocating dynamic key on a class_id == 0 receiver, now tracked as #8098 and #6812; bench_numeric_array_downgrade is the heterogeneous-array element path. Live successor work is #8067 -> #8047.

    One defect found during the audit is fixed in #8106 (a root-reload allowlist entry that has never matched a symbol, forcing a spurious reload at every slot-layout note). One is flagged unresolved for #7834/#8047: docs/engine-plan.md's "⛔ do not rebuild" item 2 forbids exactly what #7834 shipped four days later, and the doc was never updated — see §6 above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performancetriagedMaintainer reviewed; type, scope, and next step are clear

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions