Skip to content

Design: safepoint-delimited receiver descriptors — one model to replace six ad-hoc guard-fact tables #9254

Description

@proggeramlug

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 proposal

Make "validated receiver, scoped to a safepoint-delimited region" a first-class concept.

  1. 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, the gc-leaf-function annotations, and the poll-emission logic.
  2. 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.
  3. Refresh at boundaries, not at uses. A safepoint invalidates descriptors; the next region re-materialises the live ones. This is packed_receiver_refresh generalised — 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.
  4. Per-use residue is the genuinely variant part, idx u< len, which is unavoidable per element.
  5. 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.

https://claude.ai/code/session_012Ys25ni6VwDKE71o1NTYAT

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