Skip to content

feat(codegen/runtime): stamp class ShapeIds at birth - #8009

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:feat/7916-eager-class-shape-stamping
Aug 13, 2026
Merged

feat(codegen/runtime): stamp class ShapeIds at birth#8009
proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:feat/7916-eager-class-shape-stamping

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • mint each compiled class's canonical ShapeId once at module initialization, beside its rooted keys global
  • stamp inline, outlined, and scalar-materialized class instances with that id before publication
  • preserve the existing allocator ABI and the lazy-stamp fallback when the ShapeId range is exhausted
  • add IR and runtime coverage proving the id is minted once and present before any by-name lookup

This is #6759 C3 rung 2, the next prerequisite for #7916's remaining header shrink. It closes the lazy-stamp window so the guard-migration rung can rely on a uniform shape word. It does not close #7916; the guard migration and header-layout change remain follow-up work.

Measurements

  • per-object storage added by this rung: 0 bytes
  • two-field literal representation remains 56 bytes
  • retain remains 168 MB written for 48 MB of numeric payload (3.5x amplification)
  • no memory or wall-clock improvement is claimed for this invariant/proof step

No version files are changed and there is no version bump.

Validation

  • cargo fmt --all -- --check
  • cargo test -p perry-codegen --lib — 935 passed
  • cargo test -p perry-runtime --lib — 2,278 passed, 4 ignored
  • targeted codegen and runtime regression tests rerun after rebasing onto current main
  • compiled test-files/test_edge_objects_records.ts with the built Perry binary and verified byte-for-byte stdout parity with Node
  • scripts/check_file_size.sh
  • git diff --check origin/main...HEAD

Refs #6759
Refs #7916

Summary by CodeRabbit

  • Improvements

    • Compiled class instances now receive stable shape information as they are created across supported allocation paths.
    • Improved consistency when reading class shape data immediately after allocation.
    • Preserved existing object representation and memory characteristics.
    • Added fallback behavior for cases where stable shape identifiers are unavailable.
  • Bug Fixes

    • Improved reliability of class shape tracking for inline, outlined, and optimized allocations.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3641ab87-4826-462c-8862-7afd370d190b

📥 Commits

Reviewing files that changed from the base of the PR and between 1b53332 and 8288938.

📒 Files selected for processing (16)
  • changelog.d/7916-eager-class-shape-stamping.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/string_pool.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/gc_call_effects.rs
  • crates/perry-codegen/src/lower_call/alloc_hot_tests.rs
  • crates/perry-codegen/src/lower_call/new_alloc.rs
  • crates/perry-codegen/src/lower_call/scalar_method.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-codegen/src/typed_shape.rs
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry-runtime/src/object/shapes.rs

📝 Walkthrough

Walkthrough

Compiled class instances now receive canonical ShapeId values during allocation. Code generation creates and initializes per-class ShapeId globals, while inline, outlined, and scalar-replacement paths pass ShapeIds to the runtime allocator.

Changes

Class ShapeId stamping

Layer / File(s) Summary
Runtime ShapeId lookup and stamping
crates/perry-runtime/src/object/shapes.rs, crates/perry-runtime/src/object/alloc.rs, crates/perry-codegen/src/runtime_decls/strings.rs
The runtime exposes ShapeId lookup and stamped class allocation. The existing allocator remains available through a compatibility wrapper.
Codegen ShapeId metadata
crates/perry-codegen/src/typed_shape.rs, crates/perry-codegen/src/codegen/*, crates/perry-codegen/src/expr/mod.rs
Code generation creates per-class ShapeId globals, initializes them from class key arrays, and stores cached ShapeId slots in FnCtx.
Allocation path integration and validation
crates/perry-codegen/src/lower_call/*, crates/perry-codegen/src/gc_call_effects.rs, crates/perry-runtime/src/object/shapes.rs, changelog.d/7916-eager-class-shape-stamping.md
Inline, outlined, and scalar-receiver allocations load and pass ShapeIds. IR and runtime tests verify stamping and initialization.
Estimated code review effort: 4 (Complex) ~45 minutes

Mergeability Score: ⚪ Minimal · up to 82889

The change eagerly assigns class ShapeIds before publication while preserving the existing fallback behavior, with the supplied validation covering codegen, runtime, formatting, and output parity. No actionable merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant ModuleInit
  participant Codegen
  participant ShapeRegistry
  participant Allocator
  participant Object
  ModuleInit->>Codegen: initialize class keys and ShapeId globals
  Codegen->>ShapeRegistry: js_object_shape_id_for_keys(keys_ptr, field_count)
  ShapeRegistry-->>Codegen: canonical ShapeId
  Codegen->>Allocator: allocate class instance with ShapeId
  Allocator->>Object: store ShapeId in object header
Loading

Possibly related PRs

Suggested reviewers: thehypnoo, jdalton

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements a ShapeId prerequisite but does not satisfy #7916 acceptance criteria for compact representation and measured proposal metrics. Implement and measure the compact representation for common fixed-field object literals, including bytes-per-object and total write volume, or link this PR only to the prerequisite issue.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: stamping class ShapeIds during allocation.
Description check ✅ Passed The description provides the change summary, validation details, related issues, measurements, and scope limitations, despite using non-template section names.
Out of Scope Changes check ✅ Passed The code and tests stay within the stated ShapeId stamping prerequisite and do not introduce unrelated changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Copy link
Copy Markdown
Contributor Author

Reviewed against the rung-1 handoff (gc-handoff/RUNG1-NOTES.md). Substantively sound — I checked the four ways this rung could have gone wrong and it avoids all of them. Two gaps and one timing request below.

What I verified

  • It does not touch typed_feedback::object_shape() — the site rung 1 deliberately left gated. Relaxing it makes the guard family fail closed (their contracts compare its return against a codegen-supplied keys pointer, which an id can never equal), silently deleting the direct-method-call route and the class-field fast paths with byte-identical output. Correctly avoided.
  • The guard is unaffected. class_field_inline_guard loads field_count (offset 12) and keys_array (offset 16); ka_ok compares keys_array against expected_keys. Neither is the shape word, so stamping parent_class_id cannot perturb it.
  • Inheritance is safe. Every remaining runtime reader — array/subclass.rs:31, event_target.rs:397/571, json/stringify_tojson_probe.rs:172 — goes through get_parent_class_id(class_id), i.e. the registry, not the header word. fix(thread): take the serialized class-parent edge from the registry, not the header word #7981 converted the last holdout (thread.rs::serialize_object). proxy_reflect.rs:539/865 already reads that word as a shape id (parent_class_id - 2147483648).
  • The claims are honest and match: 0 bytes added, 56-byte two-field literal unchanged, retain still 168 MB / 3.5×, and no perf improvement claimed for an invariant step.

Gap 1 — no assertion that the fast path still fires

18 lines of test, none asserting the emitted hit path still serves a stamped receiver.

Rung 1 shipped a_class_instance_primes_an_id_token_after_rung1 with assert_ne!(cache[0], keys) for a stated reason: "priming a keys pointer for a stamped receiver would be a permanent miss at that site, so this is the test that keeps the runtime's choice and the IR's choice the same."

This rung widens exactly that population — every class instance now carries an id from birth instead of lazily. A regression in which eagerly-stamped instances prime the wrong token would produce correct output, green tests, and a permanently dead fast path. That is the failure mode this ladder has hit twice.

Please add the equivalent assertion for an eagerly-stamped instance. That is the discriminating quantity here; cargo test counts and a byte-exact test_edge_objects_records.ts run cannot distinguish it.

Gap 2 — the epoch-guard bypass becomes universal and should be restated

Rung 1 flagged this explicitly for the rungs after it:

the read PIC's epoch guard (epoch_ok = is_stamp || epoch_eq) is now bypassed on essentially every hit, because essentially every primed token is an id. Id-token soundness therefore rests entirely on shapes::prune_dead_shape_keys dropping a dead keys array's record before its address is recycled (wired into gc/copying.rs:1962 and gc/oldgen.rs:1171).

Rung 1 argued safety because a class instance's keys array is the process-rooted canonical one — immortal — with the only mortal case being the post-delete clone, identical in kind to a plain object's.

That argument still holds for this rung, but eager stamping makes it universal rather than incidental, and it is currently inherited silently. Please restate it in the PR body or a code comment, so the next reader knows the load-bearing assumption is "class keys arrays are immortal" and not something weaker.

Request — hold the merge briefly

main is currently regressed: the quiet-mini sweep at 843ef621f is 10/19 beating node, down from 12/19 at 0a21611fe (cycles +58%, deeplist +38%, interp +28%, asyncpipe +27%, pipeline +24%, iso_miss +19%; churn and retain each lost a node win). Both sweeps were clean-verdict on the same pinned host, so it is real at the large end.

A bisect over 0a21611fe..843ef621f is running now. This PR is codegen-side and touches the allocation paths (new_alloc.rs, scalar_method.rs, closure.rs, alloc.rs), so landing it mid-bisect adds a variable to a hunt that is already ~20 commits wide.

Nothing here suggests this PR is the cause — it is not in the regressed range. This is purely about not widening the search while it is in progress. Happy to merge as soon as the bisect names the commit.

@proggeramlug
proggeramlug merged commit 144867b into PerryTS:main Aug 13, 2026
9 of 58 checks passed
proggeramlug added a commit that referenced this pull request Aug 13, 2026
…ate the split population (#8010)

* fix(runtime): birth-stamp the class allocators #8009 left lazy, and gate the split

#8009 (C3 rung 2) stamps a class instance's ShapeId at birth on the COMPILED
path — `js_object_alloc_class_inline_keys_stamped`, called from the inline
`new C(…)` lowering. Three other class-instance allocators were left on rung 1's
lazy self-heal, which its own doc comment states:

  js_object_alloc_class_with_keys
  js_object_alloc_class_dynamic_parent
  js_object_alloc_class_inline_keys        (the compatibility entry point)

For any class that lands on one of those, the shape's population is still SPLIT
— and a split population is not a slow start, it is a permanent 0% PIC hit rate.
The emitted read PIC derives its entire cache token from the header shape word:

    is_stamp = (parent_class_id - 0x8000_0000) u< 0x4000_0000
    token    = is_stamp ? (parent_class_id | 1<<62) : keys_array

so instance #1 misses, is stamped, primes the id token; instance #2 is newborn,
computes the keys pointer, misses; the handler re-primes the same id; instance
#3 misses. Forever.

That is exactly #7983's defect, which this bisected: on instructions retired,
isolated against its own parent, `cycles` +54.3%, `deeplist` +45.2%, `interp`
+28.3%, `pipeline` +23.9%, `iso_miss` +22.9%, while the object-literal
benchmarks (`churn` +1.2%, `retain` +0.2%) and `fib40` (+0.04%) did not move —
literals have been birth-stamped since #6804.

All three now stamp at birth. The two shape-cached allocators read the id out of
the `ShapeCacheEntry::runtime_shape_id` their existing probe already returns
(`shape_cache_get` -> `shape_cache_get_with_id`): one extra u32 from a loaded
cache line, a compare and a store. The compatibility entry point mints from its
canonical keys array — one shape-table probe, and it is not the compiled hot
path.

THE GATE. `a_fresh_class_instance_computes_the_token_the_miss_handler_primed`
asserts the token the miss handler PRIMES equals the token a freshly-allocated
sibling COMPUTES, using `emitted_pic_token` — the emitted IR's formula
transcribed into the test. It FAILS on `main` as of #8009 and passes here, which
is the whole point: #8009's own test asserts that a newborn CARRIES a stamp, and
that is a presence check. Both-stamped and both-unstamped each satisfy it; only
the MIXTURE is the bug, and only a test that compares the two sides can see it.
This one also passes in either uniform state, so it does not need rewriting if
the policy ever flips back.

Two premise assertions in `delete_rest::shape_transition_tests_6759` said a
fresh instance is unstamped. They are updated, not deleted, and the parent-chain
test is strictly stronger for it: the word is now clobbered from birth, so there
is no window in which it was ever valid inheritance data.

* docs(changelog): fragment for #8010 (class-shape uniformity gate)

* docs(gc-handoff): bisect notes for the #7983 shape-population regression

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
@proggeramlug
proggeramlug deleted the feat/7916-eager-class-shape-stamping branch August 13, 2026 04:45
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.

perf: object representation is now the binding constraint on the retain cluster (72 bytes per 2-field literal; 216 MB written to store 48 MB)

1 participant