Repository navigation
Design: safepoint-delimited receiver descriptors — one model to replace six ad-hoc guard-fact tables #9254
Description
A design proposal, not a patch. It comes out of #9253, but the pattern it describes is why that issue needed "a new loop tier" instead of reusing three existing ones.
The defect, stated once
Codegen re-proves object properties at every use, because there is no way to express "this receiver is validated, and stays validated until here." A moving collector can relocate at any safepoint, so each use conservatively re-derives the lot: reload from the shadow slot, re-check the NaN-box tag, re-dereference the header, re-test the integrity flags, re-read length and capacity.
16_matrix_multiply is the clean specimen (#9253). 97.3% of its time is inside the generated function — no runtime calls at all — and perf annotate puts the hot instructions here:
2.32 cmp $0xf42400,%r9d ; 16,000,000 — length_sane
2.08 cmp $0xf42400,%r9d ; …capacity_sane
1.82 mov (%rax),%eax ; header length
1.32 movzwl -0x6(%rax),%ecx ; _reserved flags
1.31 movabs $0x7ffd000000000000 ; tag mask
vmulsd/vaddsd are nowhere near the top. The receivers are loop-invariant parameters, and their guard is re-run 16.7M times. The IR agrees: 27 shadow-root stores and 5 atomic barrier-count loads to accomplish 2 load doubles.
Why we keep solving it privately
Every tier has answered this question on its own terms:
| table | fact | scope | invalidation |
|---|---|---|---|
cached_lengths |
array length | one loop | manual remove |
bounded_index_pairs |
(arr, idx) in range |
scope_id |
scope pop |
buffer_view_slots |
view + pointer state | region | hazard reasons |
packed_f64_loop_facts |
packed layout | scope_id + guard_id |
side exit |
masked_window_array_facts |
window proven | scope_id |
scope pop |
packed_receiver_box/handle_slots + refresh |
receiver handle | clone body | after each poll |
Six tables, six invalidation disciplines, no shared vocabulary. The consequences are concrete:
- The tier matrix multiplies. Admission is decided per (tier × receiver kind × index shape × bound shape).
matmulfails on three axes at once, so it fits nothing. - The recurring bug class is scope-and-invalidation. perf(codegen): a throw that does not construct keeps the packed fast path (4.76 → 0.58 ns) #9185 (an unwind edge does not flush loop-carried state — a fact outliving its region), the
is_denseover-restriction in perf(codegen): the inline array-store tier covers tagged receivers (prime_sieve 4.5× → 1.8× node) #9250, the side-exit reasoning in perf(codegen): packed clones read a foreign counter inline (10_nested_loops 51 → 17 ms, Node parity) #9161. Same shape each time. - LLVM cannot help. The header reload and the atomic barrier read are motion barriers, so LICM and SCEV cannot hoist what is plainly invariant. We hand-roll hoisting per tier instead.
The proposal
Make "validated receiver, scoped to a safepoint-delimited region" a first-class concept.
- Region analysis. Partition each body into regions delimited by everything that can collect — GC polls, allocations, non-leaf calls — and by unwind edges. Nothing moves within a region. Seeds already exist:
loop_may_allocate, thegc-leaf-functionannotations, and the poll-emission logic. - Receiver descriptors. For a reference used more than once in a region, materialise once at region entry: base handle, header-derived facts (type, flags, length, capacity), validity bit. Uses consume the descriptor rather than re-deriving it.
- Refresh at boundaries, not at uses. A safepoint invalidates descriptors; the next region re-materialises the live ones. This is
packed_receiver_refreshgeneralised — and applying it at unwind edges is what makes perf(codegen): a throw that does not construct keeps the packed fast path (4.76 → 0.58 ns) #9185's class structurally impossible rather than caught by review. - Per-use residue is the genuinely variant part,
idx u< len, which is unavoidable per element. - The six tables collapse into queries against one descriptor table with one invalidation rule.
Sequencing — deliberately not a rewrite
- Phase 1 — model + equivalence lint, emitting nothing. Build the region/descriptor model and assert that wherever an existing tier claims a fact, the model agrees. This validates the abstraction against six working implementations before anything depends on it, and it is revertible by deletion.
- Phase 2 — one consumer. Route the versioned clone's receiver hoist through it (perf(codegen): packed-clone endgame — receiver caching, poll striding, integer count accumulators, check-free genuine stores #9111 already carries the right refresh semantics) and delete that tier's bespoke maps.
- Phase 3 — ordinary counted loops. Where
matmul's win lands, with no new tier. - Phase 4 — retire the remaining tables one at a time, each removal gated by the Phase 1 lint.
Risks, stated plainly
- Conservatism about what can collect is a correctness property, not a tuning knob. A region that wrongly excludes a collection point is a GC bug, not a slow benchmark. Phase 1 exists to test that judgement against six existing implementations before any code depends on it.
- Derived pointers. A cached element address is invalid after a move. Descriptors should cache base handles and recompute interiors, unless the interior is registered in the stack map.
- The equivalence lint is the load-bearing artifact. If it cannot be made to agree with the existing tiers, that is the signal to stop — the abstraction would be wrong, and finding that out in Phase 1 costs nothing.
Prior art
Speculative assumptions with dependency-based invalidation (V8 maps, Graal assumptions) solve the same problem in a JIT; the AOT-with-moving-GC analogue is HotSpot's GC-safe regions and derived-pointer handling in stack maps. What is proposed here is narrower than either: no speculation and no deoptimisation, only "prove once per region, refresh at the boundary."
Happy to take Phase 1 if the direction is agreed. It touches GC correctness across the compiler, so it wants a maintainer's agreement on the region-formation rule before code, not after.
Activity
codegen: receiver-region model + equivalence lint (PerryTS#9254 phase 1)
codegen: receiver-region model + equivalence lint (#9254 phase 1) (#9257