Skip to content

perf(gc): stop re-deriving compile-time mask facts on every construction (#7578) - #7586

Merged
proggeramlug merged 2 commits into
mainfrom
perf/7578-typed-shape-install-inline
Aug 7, 2026
Merged

perf(gc): stop re-deriving compile-time mask facts on every construction (#7578)#7586
proggeramlug merged 2 commits into
mainfrom
perf/7578-typed-shape-install-inline

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes the measurement half of #7578 and lands the fix.

Re-measured first, and it had not collapsed

Three tickets in this campaign were worked from a headline that was already
stale, so this started with a fresh leaf profile on the pinned quiet host
(perry-macos.local, load < 2), not with code.
gc::layout::typed_shape_layout_entry reads 22.7% of push_cls self time
still the largest single symbol in object construction — with
layout_forget_object a further 4.1%. 26.7% between them, for a
steady-state path whose entire content is two header bit writes.

The hypothesis in the ticket is false, and I tested it rather than assuming

#7578 proposed the cost is the FFI call and its thread-local resolutions, and
the remedy is to emit the hit path inline at the new site. The prologue looked
like it agreed — sub sp, sp, #0x150 and six stp pairs, a 336-byte frame and
twelve callee-saved registers, sized by LLVM for a descriptor-build path that
runs once per shape.

I built that fix. Outlining the install behind #[cold] #[inline(never)] cut
the frame to 80 bytes and the twelve spills to zero, and made the benchmark
slower
: push_cls 0.72 → 0.75 s, churn_alloc 0.72 → 0.79 s, reproduced
across two runs against a main that reproduced to the millisecond three times.
On this core those spills are cheap dual-issued stores off the critical path;
removing them bought nothing, while keeping six arguments live to forward to the
outlined call cost a dozen register moves that were not there before. This
function is bound by instruction count, not frame size
, which is why #7566's
result does not transfer.

Where the 22.7% actually goes

Counted off the disassembly: roughly 30 of the ~70 instructions on the hit path
re-derive compile-time constants of the class, per call, because the FFI
boundary makes them opaque parameters — 12 normalising two (pointer, length)
pairs into slices the hit path only compares as integers, ~11 of
words_intersect setup over two immutable globals, ~6 computing
gc_type_layout_slot_kind a second time.

So: carry the raw pairs and materialise a slice only where one is indexed; move
the disjointness check below the memo probe (a hit proves an install already ran
it over the same globals — an intersecting shape is downgraded before it can
reach record, so no intersecting tuple can be in the table to hit); store the
pointer-mask-empty bit in the memo; and compute the slot kind once.

Why replaying two mask predicates is not what the memo's soundness bar
forbids.
That bar is about the objectfield_count == slot_count and the
per-slot validation stay per-instance, because an object's contents change under
the mutator. These two read only the mask globals, which are codegen-emitted
private unnamed_addr constants: read-only image, never written, never freed.
An entry matches on their addresses and lengths, so a matching address is a
matching byte string for the life of the process. The residual failure mode is
still a miss, never a wrong hit.

Results

bench main this ratio
push_cls 0.72 s 0.66 s 1.091x
churn_alloc 0.72 s 0.67 s 1.075x
churn 1.00 s 0.96 s 1.042x
cycles 0.83 s 0.83 s 1.00x
retain 2.89 s 2.88 s 1.00x
tree 8.45–8.63 s 8.47–8.55 s ~1.00x
deeplist 1.52 s 1.53–1.54 s 0.987–0.993x

Best-of-7 wall clock, two independent runs per arm, main re-measured between
them, and the set re-confirmed after rebasing onto main's newer head.
deeplist pays 0.7–1.3%, reproducibly across three runs — its nodes have
pointer fields so it takes the validating entry point, whose per-slot loop now
builds its slices inside its own branch rather than finding them hoisted.

The leaf profile moves the way the mechanism predicts: 26.7% of 710 ms
becomes 20.4% of 650 ms — 190 ms → 132 ms against a 60 ms wall-clock
improvement.

Binary size: +0 bytes, structurally. The diff is two files, both in
perry-runtime; no codegen crate is touched, so emitted IR is unchanged by
construction. Measured both ways anyway: all seven benchmark binaries are
byte-for-byte the same size as main's (12,222,200 each), and the
symbol-carrying build is 320 bytes smaller.

The codegen remedy the ticket proposed is unsound — recorded so it is not revisited

It looks free: declare-path classes must have an empty pointer mask, so their
declared state is byte-identical to what the allocator writes, and since #7566 a
new in a loop writes its GcHeader as one i64 constant — OR-ing
GC_OBJ_TYPED_LAYOUT_INTACT in would cost +0 instructions and +0 bytes.

It is also a use-after-free factory. Setting the bit without an installed
descriptor breaks "intact ⟹ some descriptor is reachable", and layout_note_slot
has a hole only that invariant closes: on a contradicting store to an
intact-but-descriptor-less object it resolves a None verdict, falls through to
ordinary pointer-mask bookkeeping, moves the object to SIDE_MASK — and never
clears the intact bit, because layout_set_typed_unknown is reached only from
the Some(verdict) arm. The object is then simultaneously SIDE_MASK (collector:
slot K holds a live pointer) and intact (the codegen-inlined guard in
expr/class_field_inline_guard.rs, which consults no map by design: slot K is
raw-f64). The guard passes, property_set.rs's raw-store fast path writes a
double over the pointer with no write barrier and no layout note, and the
next collection walks slot K as a heap pointer. layout_transfer re-derives the
bit correctly, but only for objects actually evacuated, so the window is the
object's lifetime.

Validation

cargo test -p perry-runtime (1820) and -p perry-codegen --lib (672) green;
check_file_size.sh, addr_class_inventory.py, raw_handle_debt.py (998,
unchanged), cargo fmt --all -- --check clean; gc_root_dominance_check.py
--self-test plus both gated modes over a freshly emitted corpus with
--seeded-violations 40.

Two new tests, sabotage-verified rather than merely run:

  • the_pointer_mask_empty_bit_round_trips_per_entry — dropping the bit from
    pack_dims turns it red; making hit return a constant Some(true) turns it
    red and takes down
    memo_installed_objects_survive_a_copying_minor_with_their_children (the GC
    witness) and a_memo_hit_produces_the_same_header_state_as_the_install.
  • packed_dims_fields_do_not_overlap_the_empty_bit — the word-count fields
    narrowed 20 → 19 bits for bit 62; widening one back turns it red. An overlap
    would make a wide-mask shape read back as POINTER_FREE and the collector
    would skip live pointer slots.

The existing a_contradicting_field_is_refused_even_with_the_memo_warm earned
its keep: an earlier draft probed the memo before the per-slot validation and
that test caught it on the counter assertion its message names. The probe now
sits after validation, exactly where it was.

CI has a deep backlog and may not report on this branch; everything above is
local validation on the pinned host.

https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH

Summary by CodeRabbit

  • Performance

    • Improved typed-shape installation efficiency by reusing cached shape information and reducing repeated mask processing.
    • Preserved correct object layout decisions when cached results are reused.
  • Bug Fixes

    • Strengthened validation and handling of pointer-mask state during shape installation.
  • Documentation

    • Added performance benchmarks, validation details, and test coverage notes.
  • Chores

    • Updated the release version to 0.5.1332.

@coderabbitai

coderabbitai Bot commented Aug 7, 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: 80709885-af0a-4cc7-a70d-a2d10984b092

📥 Commits

Reviewing files that changed from the base of the PR and between b7cd2fb and b29ab0f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7586-typed-shape-install-constant-folding.md
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/shape_install.rs

📝 Walkthrough

Walkthrough

Typed shape layout initialization now passes raw mask pointers and word counts to shape-install memoization. The memo stores pointer-mask emptiness in packed dimensions. Memo hits restore the correct header layout state without rebuilding a descriptor.

Changes

Typed shape layout installation

Layer / File(s) Summary
Shape-install memo packing and raw-mask API
crates/perry-runtime/src/gc/shape_install.rs
The memo reserves a packed bit for pointer-mask emptiness. hit and record accept raw pointers and word counts.
Raw-mask layout initialization
crates/perry-runtime/src/gc/layout.rs
The initializer defers mask slicing, performs disjointness checks on the slow path, restores header state on memo hits, and records raw masks with the computed emptiness flag.
Memo API, packing validation, and release documentation
crates/perry-runtime/src/gc/shape_install.rs, changelog.d/7586-typed-shape-install-constant-folding.md, CLAUDE.md, Cargo.toml
Tests cover raw-pointer interfaces, optional hit results, flag round-tripping, invalidation, and packed-dimension limits. The changelog and version metadata describe the optimization and release version.

|

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FFIEntry
  participant LayoutInitializer
  participant ShapeInstallMemo
  participant LayoutHeader
  FFIEntry->>LayoutInitializer: pass raw masks and word counts
  LayoutInitializer->>ShapeInstallMemo: probe raw masks and dimensions
  ShapeInstallMemo-->>LayoutInitializer: pointer-mask emptiness or miss
  LayoutInitializer->>LayoutHeader: restore layout state on hit
  LayoutInitializer->>ShapeInstallMemo: record masks and emptiness on slow path
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely identifies the GC performance optimization that avoids repeated compile-time mask derivation.
Description check ✅ Passed The description thoroughly explains the motivation, implementation, benchmark results, safety analysis, related issue, and extensive validation.
✨ 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/7578-typed-shape-install-inline

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/7578-typed-shape-install-inline branch from 0df86c7 to 5e9266d Compare August 7, 2026 07:19

@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/layout.rs`:
- Around line 978-983: Update the caller that processes raw mask pairs before
invoking mask_words: when either non-zero mask count has a null pointer, call
layout_set_typed_unknown and return. Keep mask_words for valid pairs, including
zero-count masks, and preserve existing handling for valid pointer masks.
🪄 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: b7a17cb5-c612-48ee-b2af-c4c083ec872c

📥 Commits

Reviewing files that changed from the base of the PR and between b7cd2fb and 0df86c7.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/shape_install.rs

Comment thread crates/perry-runtime/src/gc/layout.rs
@proggeramlug
proggeramlug force-pushed the perf/7578-typed-shape-install-inline branch from 5e9266d to 6039ec0 Compare August 7, 2026 07:21
Ralph Küpper added 2 commits August 7, 2026 09:27
…ion (#7578)

`gc::layout::typed_shape_layout_entry` re-measured at 22.7% of `push_cls` self
time on the pinned quiet host, plus 4.1% in `layout_forget_object` — 26.7% for
a steady-state path whose content is two header bit writes.

#7578's hypothesis (the FFI call frame, per #7566) was tested and is false:
outlining the install cut the prologue from a 336-byte frame with twelve
callee-saved spills to 80 bytes with none, and made `push_cls` 0.72 -> 0.75 s
and `churn_alloc` 0.72 -> 0.79 s. The function is bound by instruction count.

Roughly 30 of the ~70 instructions on the hit path re-derived compile-time
constants of the class, per call, because the FFI boundary makes them opaque:
twelve normalising two (pointer, length) pairs into slices only compared as
integers, ~11 of `words_intersect` setup over two immutable globals, and ~6
computing `gc_type_layout_slot_kind` a second time.

Carry the raw pairs and build a slice only where one is indexed; move the
disjointness check below the memo probe (an intersecting shape is downgraded
before it can reach `record`, so no intersecting tuple can be in the table to
hit); store the pointer-mask-empty bit in `dims` bit 62; compute the slot kind
once.

push_cls 1.091x, churn_alloc 1.075x, churn 1.042x, deeplist 0.993x, rest flat.
+0 bytes: no codegen crate is touched, so emitted IR is unchanged.

Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
@proggeramlug
proggeramlug force-pushed the perf/7578-typed-shape-install-inline branch from 6039ec0 to b29ab0f Compare August 7, 2026 07:31
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@proggeramlug
proggeramlug merged commit e4854c3 into main Aug 7, 2026
0 of 12 checks passed
@proggeramlug
proggeramlug deleted the perf/7578-typed-shape-install-inline branch August 7, 2026 07:32
proggeramlug added a commit that referenced this pull request Aug 7, 2026
…7588)

The plan recommended inlining the shape-install hit path at the new site. #7586 showed the frame is not the lever (outlining is a regression) and that OR-ing GC_OBJ_TYPED_LAYOUT_INTACT in codegen breaks the intact-implies-descriptor invariant, producing a use-after-free.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1332

I proposed the remedy this PR refutes, so the refutation is the most valuable
part of it.
Both halves of my hypothesis were tested and both are wrong, and
the plan has been corrected in #7588 — it was pointing the next reader at a
use-after-free.

Verified the soundness argument directly, since it is the load-bearing
claim. At gc/layout.rs:782–788, layout_set_typed_unknown — the only thing
that clears GC_OBJ_TYPED_LAYOUT_INTACT — is reachable only from the
Some(verdict) arm
. So an object that is intact with no descriptor resolves
None, falls through to the pointer-mask path, and keeps the bit permanently.
That is exactly the state my proposed codegen change would create at every
allocation. The chain from there to a raw double written over a pointer slot
follows.

The detail that makes this worth writing down rather than just fixing: the
comment at layout.rs:742 says a None verdict "can only cost an extra
fall-through, never mis-track a slot". That is true only while the invariant
holds
— it is a consequence of "intact ⟹ descriptor reachable", not an
independent guarantee. It reads as reassurance to precisely the person about to
break it.

(One citation slip: the guard is class_field_inline_guard_enabled in
object/descriptor_state.rs, not a class_field_inline_guard.rs. Doesn't
affect the argument.)

Sabotage-verified myself: dropping the pointer-mask-empty bit from
pack_dims reddens packed_dims_fields_do_not_overlap_the_empty_bit and
the_pointer_mask_empty_bit_round_trips_per_entry. 11/11 shape-install tests
green when restored.

Gates re-run here: perry-runtime --lib 1838 passed / 0 failed,
perry-codegen --lib 671 / 0, addr_class_inventory passes, raw_handle_debt
998 (baseline 998), check_file_size.sh and cargo fmt --check clean.

The +0-bytes and root-dominance claims hold structurally, not just
empirically:
the diff touches two perry-runtime files and no codegen crate,
so the emitted IR cannot change. That is a stronger argument than the byte-count
measurement, and worth stating in those terms.

On the negative result — outlining cutting the frame 336 → 80 bytes and
twelve spills → zero, and coming out slower (push_cls 0.72 → 0.75 s,
churn_alloc 0.72 → 0.79 s) — building the losing version before the winning
one is what made the real cause findable. The generalisable lesson, now in the
plan: an FFI-boundary perf problem is not automatically a call-overhead
problem.
Check whether the callee is re-deriving facts the caller already knew
before assuming the fix is to remove the call. Here ~30 of ~70 hit-path
instructions were reconstituting compile-time constants of the class that the
ABI had made opaque.

deeplist's 0.7–1.3% cost is disclosed and reproduced across three runs, same
honesty as #7566's tree row. Accepted.

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