Skip to content

perf(gc): don't mint per-object pointer masks for single-slot payloads - #7812

Merged
proggeramlug merged 1 commit into
mainfrom
perf/7801-single-slot-pointer-mask
Aug 11, 2026
Merged

perf(gc): don't mint per-object pointer masks for single-slot payloads#7812
proggeramlug merged 1 commit into
mainfrom
perf/7801-single-slot-pointer-mask

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What

layout_note_slot and layout_rebuild_from_slots_with_policy no longer mint a
per-object pointer mask for a payload of a single slot. Those objects take
GC_LAYOUT_UNKNOWN — the tag-checked scan-all-slots state — instead.

Draft: measurement and validation are complete (below), but the threshold is a
policy call I'd like a second opinion on before this goes green.

Why

gc-handoff/apps/interp.ts — the tree-walking interpreter, the corpus program
that best resembles real software — spent ~19% of its runtime in
layout_forget_object
, plus another ~6% in the hashbrown probe underneath it.
That is side-table bookkeeping, not user work, and by design it should have been
near zero: #7510's PER_OBJECT_LAYOUTS_NONEMPTY exists precisely so the
allocation/store/death/relocation paths can skip both maps while they are empty,
"which on a monomorphic workload they are".

They were not. Instrumented on iso_FIB.ts:

forget_total=15,000,000  fast=52  slow=14,999,948
residency: masks=313,875 -> 381,505 -> 400,430 (still climbing)

The disarmed fast path fired 52 times in 15 million calls; every other call
took two RefCell round-trips and two hashes against a 400k-entry, cache-cold
map.

The interpreter allocates { names: [p], vals: [a], parent } per interpreted
call, so it minted 1.8M masks over payloads of exactly one slot. A mask over
one slot cannot skip anything — the tracer consults
layout_pointer_bearing_bits on that slot either way — so the side-table entry
was the mask's entire contribution. Those entries also outlive their arrays
(reclaimed only when the recycled address is allocated over), and one live entry
anywhere keeps the flag armed for every allocation in the program. This is
#7510's "one immortal entry nullifies is_empty()", from the other direction.

Safety

GC_LAYOUT_UNKNOWN is already the established fallback on this exact path
(heap_payload_slot_selection sets it when a SIDE_MASK object has no mask),
and the tag check is exact here: neither mint site is reachable for an object
with an intact typed descriptor, so there are no raw-f64 slots whose bits could
be misread as a pointer. Both directions of the slot-count estimate are
correct, only differently priced — over-estimating mints a mask that was not
needed (the old behaviour), under-estimating routes the object to a scan that
visits a superset of what the mask would have selected. Neither can hide a
live child.

Two details that cost me a wrong turn each, both now in the code comments:

  • An array reports its length, but only for a store into an already-formed
    array. Every append protocol notes the slot before bumping length, so
    mid-construction length is the pre-append value; judging on it stranded
    every incrementally built array (a push loop, a JSON parse) in the scan
    state regardless of final size. Capacity is not a substitute either —
    MIN_ARRAY_CAPACITY is 16, so a one-element literal reports 16 and the
    distinction disappears entirely.
  • An object reports a bound derived from GcHeader::size, not field_count,
    because size is maintained for every GC allocation whatever its
    type-specific header holds.

Measured

Quiet M1 mini, best-of-5, interleaved against the same binaries with the
policy disabled
(PERRY_LAYOUT_MASK_MIN_SLOTS=0) so build drift cannot
masquerade as a win. Outputs byte-identical to node and exit codes checked per
cell.

bench before after
interp 1.894 1.697
iso_miss 2.371 2.157
bench/mask_tax (new probe) 0.1218 0.1049
bench/mask_tax_nopointer (control) 0.0929 0.0929

No regression on the rest of the 19-benchmark corpus, including the GC-heavy
tree (1.626), tree_wide (2.110), retain (0.538), retain_wide (1.094),
cycles (0.193) and deeplist (0.245). The policy-disabled arm reproduces the
pristine baseline on every row, which is the control for this patch adding cost
of its own.

Validation

  • cargo test -p perry-runtime2053 passed, 0 failed (cargo's own exit
    code, not a pipe's).
  • Correctness canary iso_miss prints checksum 437840 misses 0 plain and
    under PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800,
    PERRY_GC_VERIFY_EVACUATION=1, and PERRY_GC_FORCE_EVACUATE=1.
  • All 19 corpus binaries byte-identical to expected/ and to the pristine
    baseline build, exit 0.
  • check_file_size.sh, cargo fmt --all -- --check, addr_class_inventory.py
    all clean. (layout.rs went 19 lines over the 2000 cap, so the two new
    helpers live in layout_tables.rs — their natural home anyway.)

Tests

Two new tests in layout_trace/per_object_tables.rs, one per side of the
threshold: a single-slot pointer payload must leave the side tables empty
and still trace its child, and a payload at/above the threshold must still mint
a mask recording exactly the one pointer slot. The second exists so the first
would not pass equally well if per-object masks had been deleted outright.

Four existing fixtures changed, all preconditions rather than behaviour: three
widened from a 1-element to a 2-element array so they keep exercising the mask
grow/transfer/clear paths they are about, and two assertions in
test_array_mixed_bulk_producers_preserve_pointer_layout now assert None
where a one-element bulk result no longer carries a (vacuous) mask — the
behavioural assertions next to them, that the tracer reads the slot and marks
the child, are unchanged and still pass.

The threshold, and what is deliberately left on the table

DEFAULT_MASK_MIN_SLOTS = 2 is the provable end of the range: at one slot the
mask demonstrably skips nothing, so no judgement about tracing cost is being
made. Raising it pays roughly twice as much — 9 and above gives interp
1.619 and iso_miss 2.046, with still no regression anywhere on the
corpus — but 21 tests in this crate encode "a small mixed payload uses a mask"
as a precondition (5 at 2, 11 at 3, saturating at 21 from 9). That is a
contract change worth making on purpose, not as a side effect of a perf patch,
so it is left for a follow-up.

PERRY_LAYOUT_MASK_MIN_SLOTS overrides the threshold for bisection. Flagging it
explicitly against the knob kill-policy in CLAUDE.md: both regimes are exercised
by the two new tests, but if you would rather not carry a new GC env knob at
all, say so and I will drop the env read and keep the constant.

Summary by CodeRabbit

  • Performance

    • Improved garbage collection efficiency by avoiding unnecessary pointer-mask creation for very small payloads.
    • Added conservative scanning for single-slot objects while preserving accurate pointer tracing.
    • Added an optional setting to adjust the minimum payload size for pointer masks.
  • Bug Fixes

    • Preserved correct tracing for larger and nested payloads during layout changes, pointer clearing, and array transitions.
  • Tests

    • Expanded coverage for small and multi-slot payload layouts and tracing behavior.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c9a0c0a-cd0e-4e16-a0b9-2049a1b4a778

📥 Commits

Reviewing files that changed from the base of the PR and between 386f285 and 4a01bcd.

📒 Files selected for processing (3)
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/layout_tables.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs

📝 Walkthrough

Walkthrough

The GC now avoids per-object pointer masks for payloads smaller than a configurable threshold. Small payloads use conservative scanning, while larger payloads retain masks. Tests cover tracing, layout transitions, and threshold behavior.

Changes

GC layout mask optimization

Layer / File(s) Summary
Payload threshold and classification
crates/perry-runtime/src/gc/layout_tables.rs
The runtime reads PERRY_LAYOUT_MASK_MIN_SLOTS, defaults to two slots, and conservatively estimates array and object payload sizes.
Layout mask suppression
crates/perry-runtime/src/gc/layout.rs, crates/perry-runtime/src/gc/layout_tables.rs
Pointer-mask creation and reconstruction use conservative scanning when payloads are below the configured threshold or objects are in an immortal layout scope.
Tracing behavior and regression coverage
crates/perry-runtime/src/gc/tests/layout_trace/*, changelog.d/7812-single-slot-pointer-mask.md
Tests verify single-slot tracing without masks, mask retention for larger payloads, and layout transitions. The changelog documents the policy and validation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GcLayout
  participant LayoutTables
  participant PerObjectLayoutTable
  GcLayout->>LayoutTables: Check payload slots and threshold
  LayoutTables-->>GcLayout: Select conservative scanning or pointer mask
  GcLayout->>PerObjectLayoutTable: Add or remove per-object mask
Loading

Possibly related PRs

  • PerryTS/perry#7525: Adds the related per-object GC layout side tables and pointer-mask mechanisms.
  • PerryTS/perry#7809: Changes the same mask creation and reconstruction paths for immortal layout scopes.
  • PerryTS/perry#7138: Modifies array layout-mask handling in the same GC subsystem.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the primary GC performance change.
Description check ✅ Passed The description clearly explains the motivation, implementation, safety, benchmarks, validation, and tests, despite using different headings from the template.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7801-single-slot-pointer-mask

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug force-pushed the perf/7801-single-slot-pointer-mask branch from 5dbe513 to 386f285 Compare August 10, 2026 23:09
@proggeramlug
proggeramlug marked this pull request as ready for review August 11, 2026 05:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs`:
- Around line 262-269: The child pointer is not rooted across the allocating
call, so both test sites can retain stale addresses after GC. In
crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs#L262-L269
and `#L308-L319`, use RuntimeHandleScope to root the NaN-boxed child before
js_array_alloc_with_length, reload the child value afterward, and derive
child_header only from the reloaded pointer.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 119f90ae-8090-47a1-8b10-a4c6f2da5719

📥 Commits

Reviewing files that changed from the base of the PR and between 1804991 and 386f285.

📒 Files selected for processing (5)
  • changelog.d/7812-single-slot-pointer-mask.md
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/layout_tables.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs

Comment on lines +262 to +269
let child = crate::string::js_string_from_bytes(b"one-slot-child".as_ptr(), 14) as *mut u8;
let child_header = unsafe { header_from_user_ptr(child) };
let arr = crate::array::js_array_alloc_with_length(1);
crate::array::js_array_set_f64(
arr,
0,
f64::from_bits(STRING_TAG | (child as u64 & POINTER_MASK)),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Root the child value before allocating the array.

If js_array_alloc_with_length triggers GC, it can relocate child. Both tests then store the old address and later read child_header through a stale pointer.

Use RuntimeHandleScope to root the NaN-boxed child value before the array allocation. Reload the value after allocation. Derive child_header only after the reload.

  • crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs#L262-L269: Root and reload the single-slot child across js_array_alloc_with_length.
  • crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs#L308-L319: Root and reload the multi-slot child across js_array_alloc_with_length.

Based on learnings: raw Rust pointer locals are neither GC roots nor reliable pins across an allocating operation.

📍 Affects 1 file
  • crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs#L262-L269 (this comment)
  • crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs#L308-L319
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs` around
lines 262 - 269, The child pointer is not rooted across the allocating call, so
both test sites can retain stale addresses after GC. In
crates/perry-runtime/src/gc/tests/layout_trace/per_object_tables.rs#L262-L269
and `#L308-L319`, use RuntimeHandleScope to root the NaN-boxed child before
js_array_alloc_with_length, reload the child value afterward, and derive
child_header only from the reloaded pointer.

Source: Learnings

`interp.ts` spent ~19% of its runtime in `layout_forget_object`, plus ~6% in
the hashbrown probe underneath it, against a `LAYOUT_SLOT_MASKS` that had grown
past 400,000 live entries. Instrumented on the isolated FIB half, the
`PER_OBJECT_LAYOUTS_NONEMPTY` fast path fired 52 times in 15,000,000 calls.

The interpreter allocates `{ names: [p], vals: [a], parent }` per interpreted
call, so `layout_note_slot`'s "first pointer into a POINTER_FREE object" arm
minted 1.8M masks over payloads of exactly one slot. A mask over one slot can
skip nothing -- the tracer tag-checks that slot either way -- but the entry it
creates keeps the emptiness flag armed, which puts a two-map hash probe back on
every allocation in the program for as long as it lives.

Both mint sites now decline the mask below DEFAULT_MASK_MIN_SLOTS and use
GC_LAYOUT_UNKNOWN, the tag-checked scan-all-slots state that is already the
established fallback on this path. The tag check is exact here: neither site is
reachable for an object with an intact typed descriptor, so no raw-f64 slot can
be misread as a pointer.

Quiet M1 mini, best-of-5, interleaved against the same binaries with the policy
disabled: interp 1.894 -> 1.697, iso_miss 2.371 -> 2.157, new bench/mask_tax
probe 0.1218 -> 0.1049 with its numeric-element control flat at 1.000. No
regression on the rest of the 19-benchmark corpus, including tree, tree_wide,
retain*, cycles and deeplist.
@proggeramlug
proggeramlug force-pushed the perf/7801-single-slot-pointer-mask branch from 386f285 to 4a01bcd Compare August 11, 2026 06:14
@proggeramlug
proggeramlug merged commit 99705c9 into main Aug 11, 2026
0 of 18 checks passed
@proggeramlug
proggeramlug deleted the perf/7801-single-slot-pointer-mask branch August 11, 2026 06:14
proggeramlug pushed a commit that referenced this pull request Aug 11, 2026
Covers #7795 #7799 #7800 (compose-verified trio), #7809 #7812 (merged with a
jointly-verified gc::layout composition), and the two main hotfixes
(release_source.rs markers, batch fmt sweep).

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
proggeramlug added a commit that referenced this pull request Aug 11, 2026
* fix(lint): unbreak the 2000-line file-size gate on main

Two files crossed the cap in the 2026-08-11 batch and `lint` has been red
on `main` ever since:

  crates/perry-hir/src/lower/pre_scan.rs   1985 -> 2011  (#7828)
  crates/perry-runtime/src/gc/layout.rs    1982 -> 2023  (#7809, #7812)

Both are pure code moves, no logic change:

* `pre_scan_weakref_locals` and its doc comment move to
  `lower/pre_scan/weakref_locals.rs` (1653 lines left behind). It is the
  cohesive unit — one top-level pre-scan with its own five local sets —
  and it is where #7828 added the lines.

* `LayoutSlotMask` (the enum and its whole `impl`) moves to
  `gc/layout/slot_mask.rs` (1807 lines left behind). Its visibility widens
  from `pub(super)` to `pub(in crate::gc)` because the type is now one
  module deeper and `layout_tables.rs` / `hot_tls.rs` still name it; the
  reachable set is unchanged.

`scripts/check_file_size.sh` passes.

* docs: changelog fragment for #7830

* docs: condense #7830 changelog entry

---------

Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant