Skip to content

Merge train 181b: #10189, #10196, #10185 - #10211

Merged
proggeramlug merged 10 commits into
mainfrom
train181b
Sep 13, 2026
Merged

proggeramlug merged 10 commits into
mainfrom
train181b

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Merge train 181b: lands #10189 (collapse the class-field GET tower to one exit) at eb4bee5d5d, #10196 (collapse the generic property-get tower to two exits) at fe2a9cfd94 and #10185 (element-shape loop clone for the fields and random access shapes) at 533b59247b. It adds one test-only fix and the workspace version bump to 0.5.1557. (Named 181b because another merge lane already used "181" for #10202.)

The PR commits were cherry-picked onto 26ed55cb74 without conflicts.

Added commit test(runtime): mint the prefix-test meta record under a GC suppress scope (#10196): #10196's new ic_slow.rs test read obj.get_raw_mut_ptr() inside an across_mut closure, and the bare raw_handle_debt.py run rejects that in a module with no ceiling. It is #10196's only CI difference from main. The test now mints the meta record with obj.with_mut_ptr under GcSuppressScope, so the receiver cannot move while the scoped pointer is live, and both ratchet runs pass.

Validation (macOS arm64)

Full validation first ran on these PRs over ad6925b09e. Main has since gained only the regex lane's #10201, #10205 and #10207, and this train changes none of their files.

  • scripts/run_lint_gates.sh: 79 of 83 pass. The only failure beyond main's three was the raw-handle ratchet on the site fixed above.
  • cargo test --release: perry-codegen 2028 passed / 0 failed (all targets, including the tests/ integration suites), perry-runtime 3750 / 0, perry-transform 128 / 0. cargo test --release -p perry --test issue_8774_argument_shape_clones: 2 / 0.
  • scripts/gc_root_dominance_corpus.sh --lowering shadow plus the checker: 164/164 sources, 15,001 root stores, 2 violations, both non-moving js_regexp_site_test_new -> js_regexp_site_test_get_method in test_gap_gc_regexp_receiver_rooting. The same two fingerprints reproduce with a 9b911855f8 compiler, so they predate this train (details on [scheduled-gate:gc-root-dominance.yml] GC Root Dominance is failing on main #9925).
  • Full gap suite (scripts/run_gap_tests.sh, 779 tests): 768 pass, 10 parity fail, 1 crash. Gated regressions are main's known four (2899_2779_2777_static_helpers, disposablestack_2875, iterator_prototype_next_patch, gc_http2_pending_event_callback_rooting), plus 9592_child_timeout_threads, where the Node oracle exits 1 on this host; it fails the same way with the 9b911855f8 compiler.
  • GC stress: test_gap_json_record_loop_clone and test_gap_generic_get_one_exit_arms match Node under PERRY_GC_SCHEDULE_SEED 11 and 181 at rate 0.5 with forced evacuation, verification and from-space protection (143–391 copying minors).

Confirmation on this exact tree: RUSTFLAGS=-Dwarnings cargo check -p perry --bins; cargo test --release -p perry-runtime: 3763 passed, 1 failed. The failure is gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds, added by #10205 and already on main; it expects a debug_assert to fire and is not gated on debug_assertions, so it fails under --release (noted on #10205).

CI attribution (vs main's run at b5a82cfeae)

Summary by CodeRabbit

  • Performance

    • Improved property access performance by streamlining fallback handling.
    • Optimized JSON record loops, including repeated element access, indexed traversal, string-length reads, and boolean value handling.
    • Reduced generated overhead for class-field and generic property reads.
  • Bug Fixes

    • Preserved correct behavior for nullish receivers, accessors, prototype properties, and mixed data shapes.
    • Improved handling of complex loop indexes and accumulator updates.
  • Documentation

    • Updated release documentation and changelog entries with performance details and compatibility coverage.
  • Chores

    • Bumped the current version from 0.5.1556 to 0.5.1557.

Ralph Küpper added 10 commits September 13, 2026 17:17
The monomorphic `this.field` / `obj.field` tower in expr/property_get.rs emitted
four runtime call sites per site -- `js_typed_feedback_class_field_get_guard`
when the #5093 inline pre-check missed, `js_throw_type_error_property_access`
for a nullish receiver, and one or two `js_object_get_field_by_name_f64` by-name
lookups -- spread over
class_field_get.{fast,fallback,merge,throw_nullish,fallback_lookup}. In
@babel/parser that is 13,636 sites and 27 % of the module's IR, and because
every call is a statepoint the .perry_gcmap scaled with it too.

Keep the pre-check and the fast slot load byte-identical, and replace the four
miss arms with ONE call to `js_class_field_get_ic` -- the runtime helper the
#5391 path-2 full outline a few lines above already calls. It runs the same
guard, reads the same slot at the same header-relative offset on a pass, throws
the same nullish TypeError (#7153 put that check there for exactly this
equivalence) and falls back to `js_object_get_field_by_name_f64`, recording the
fallback itself under the same `typed_feedback_enabled()` gate codegen used.

A plain number is self-boxing under NaN-boxing, so a `requires_raw_f64` site
reads the helper's return exactly as the old phi read the by-name fallback's --
neither arm converted. The key-handle load/bitcast/mask also sinks into the cold
miss block, its only remaining consumer.

Measured (fixture with one boxed field, two raw-f64 fields, a read on a bare
`Named` parameter, reads on `this`, and a raw-f64 read in a loop):

  * per site: 88 -> 47 IR instructions, 3 runtime calls -> 1, 7 -> 4 blocks;
  * the `class_field_inline.deref` pre-check is instruction-for-instruction
    identical (SSA numbering shifts by 3 because the entry block lost the sunk
    key load);
  * the `class_field_get.fast` block LOSES its two RS4GC relocation reloads (14
    instructions, 2 `call i64 asm "gc-leaf-function"`): it used to be reachable
    from the guard call, i.e. across a statepoint, and is now reachable only
    from the pre-check, which crosses none. The inline hit path is strictly
    shorter than before;
  * @babel/parser .text 17,640,473 -> 16,389,647 (-7.09 %), .perry_gcmap
    809,047 -> 792,749 (-2.01 %), O0-fallback units unchanged.

Runtime behaviour is unchanged: on benchmarks/tls-budget/interp.ts the
typed-feedback trace reports the same 273 sites with identical guard_passes
(817,155,616), guard_failures (202,359,184) and fallback_calls (202,359,184),
zero per-site differences, and identical program output.

The raw-f64 site's dynamic-fallback native-value record now names
`js_class_field_get_ic`; that pair is ADDED to verify/raw_f64.rs rather than
replacing the by-name pair, which verify/tests.rs still pins.

Tests: a new cargo-test-visible codegen module pins the one-exit shape (the
pre-check branches INTO the fast load, the miss arm is exactly one call, the
three retired blocks are gone, and the three retired symbols are absent as CALL
FORMS -- a bare substring is satisfied by the `declare` line alone). Four
runtime tests cover `js_class_field_get_ic`'s arms, which had none. Five
existing assertions that used the retired symbols as the proxy for "a guarded
class-field read was emitted" are re-pointed: the positives accept either
witness (other lowerings still call the old symbols), and the negatives gain the
IC as an excluded symbol so they cannot pass on a still-guarded body.

(cherry picked from commit cf7c6ca)
(cherry picked from commit eb4bee5)
Per untyped `obj.prop`, `lower_generic_property_get` expanded 33 basic
blocks, 191 pre-RS4GC IR instructions and SIX runtime call sites: an SSO
arm, an INT32 class-ref arm, a nullish-throw arm, a non-object-receiver
arm, an overflow-slot load, a deleted-slot miss, two Array-subclass
named-prefix ladders, and the miss+prime. Every call is a statepoint, so
`.text` and `.perry_gcmap` both scale with call sites x live GC values --
on @babel/parser that tower was 29% of all emitted IR across 6,487 sites.

The hit is worth its bytes; the arms around it are not. What stays inline
is what it was: the receiver-tag test, the small-handle test, the packed
header kind/descriptor word, the packed-MRU compare, the overflow-bit
test, the inline field load with its hole check, the polymorphic ways
(#7753), and the `.length`/`.size` short-circuits. Everything else
branches to one of two new runtime entries that reproduce the arms in the
same ORDER and with the same cache-priming decisions:

  js_object_get_field_ic_nonptr(obj_bits, key, site_id)
      the receiver-tag ladder -- SSO, INT32 class ref, nullish TypeError,
      and the by-name fallback for every other tag. Takes no cache: none
      of these arms can prime one.
  js_object_get_field_ic_slow(obj_handle, key, cache_slot, packed)
      the heap-pointer arms -- the overflow slot (including its fallback
      to the THREE-argument miss entry, i.e. WITHOUT republishing the
      packed word, which is what the old helper did), the deleted-slot
      miss, the Array-subclass named-prefix proof, and the priming
      `get_field_ic_miss_impl`.

Per site: 33 -> 14 tower blocks, 191 -> 106 IR instructions, 6 -> 2 calls.
@babel/parser .text -14.4%, .perry_gcmap -11.7%, O0-fallback units
unchanged.

TWO exits and not one, measured. With the receiver-tag test and the
small-handle test failing to the SAME block, SimplifyCFG folds them into
one flat predicate -- `cmp; sete; cmp; setae; test; je` where the chain
was `cmp; jne; cmp; ja` -- and every property-read HIT pays +4.00
instructions on a 10M-read monomorphic loop. That is #7883's
flat-predicate cost arriving from the optimiser instead of from codegen.
Distinct callees keep the chain branchy and let the unmasked receiver bits
die in the entry block instead of living across the whole hit path.

Three further shapes were needed to finish paying for the hit, each
measured on the same 10M-read loop (instructions retired, mine vs base):

  * the field address is a typed `gep double` rather than `shl 3` + `add`.
    With an explicit shift the slot is the packed word's last use, so
    InstCombine folds `(packed >> 32) << 3` into `(packed >> 29) & mask`
    and isel pays a 10-byte `movabs` plus an `and`; as a GEP index there
    is no shift to fold and the scaled addressing mode survives. +3.00 ->
    +1.00 per hit.
  * the spill-buffer read moved into a `#[cold] #[inline(never)]`
    `overflow_arm`. Inlined, its `overflow_get` call forced the entry to
    save callee-saved registers, which gave it a frame and stopped the
    miss handler from being a sibling call. +49 -> +31 -> +20 per
    megamorphic miss (the first step was the exit split).
  * the named-prefix conjunction asks `ObjectMeta` first: that is one load
    from the header line the entry has already touched, against two
    dependent loads through the site's cache slot, and an object with no
    metadata record cannot carry the token. +20 -> +17 per miss.

Final micro numbers, instructions retired per read vs base: monomorphic
hit +1.00 (one `jmp`, because the hit's and the way's hole checks become
congruent after their empty successors fold and SimplifyCFG tail-merges
them -- fixing it needs `!prof` branch weights the IR builder cannot emit
today), 4-shape polymorphic (the inline ways) -1.24, megamorphic miss
+17.02.

Registries updated with the new symbols: runtime declarations, the `cold`
placement hint, the eh_mode throwing-callee assertion, gc_call_effects'
allocating-helper list, and POLL_CAPABLE_RUNTIME in
scripts/gc_root_dominance_check.py (both directions of its property-GET
self-test fixture now run against both entries -- omitting them would
silently re-open the #7154 GET hole, since the calls they replaced no
longer appear at those sites).

`test-files/test_gap_generic_get_one_exit_arms.ts` is the behavioural
witness: every arm that moved out of line, checked against node's own
output. PERRY_IC_DIAG counters are identical between the two toolchains on
a deterministic fixture (41,216 misses over 5 sites, same per-reason
split, same 20,216 primes, same fresh/armed/megamorphic distribution), and
the gap-suite subset (10 filters, 290 tests) has byte-identical verdicts.

(cherry picked from commit a46769d)
(cherry picked from commit fe2a9cf)
…random access shapes

Three additions on top of #10171's shape-keyed arm, all needed together for
the JSON access benchmark's two remaining shapes:

- a LOOP-CARRIED index (`c = (a*c + b) % m; ... rows[c]`), folded to one
  affine pair and evaluated as `srem i64`, with the write-back placed at the
  END of the iteration so a mid-iteration side exit cannot double-apply the
  recurrence;
- K accumulator statements folded into one, so the whole iteration commits
  once, past every side exit it can take;
- `arr[i].prop.length` on a string field and `arr[i].prop ? A : B` on a
  boolean one, each tag-testing the loaded word and side-exiting otherwise.

Plus a shared once-per-iteration element deref/residual check, hung off the
body's leading virtual binding, so three reads of one element pay one check.

(cherry picked from commit c9ce44b)
…new reads

40 cases in test_gap_json_record_loop_clone.ts, all byte-identical to node
26.5.1: the recurrence with and without its alias, the carried value read
after the loop, negative and fractional entry values, a zero and an over-long
modulus, a multiplier past the exact-double range, a mid-loop side exit whose
sum AND final cursor observe the write-back protocol, SSO and heap names, a
non-string and a null name, five non-boolean `active` values, a two-statement
fold whose second read side-exits, and records carrying own `name`/`length`/
`size` properties.

Plus the changelog fragment with the measured 12-cell table and the
instruction counts.

(cherry picked from commit bf0904a)
The shape-descriptor census asserts that the element-shape guard reads the
authoritative ShapeId at header offset 4. That read moved out of
`emit_element_shape_field_load` into the deref helper the per-read path and
the shared prologue now share, so the census names the function that actually
emits it.

(cherry picked from commit 54765a9)
12-cell interleaved A/B against node 26.5.1 and bun 1.3.14, plus instructions
retired per iteration, all from one coherent build of this worktree at the
base commit and at HEAD. The one cell that does not reach parity (16k fields,
1.029x) is named rather than rounded off.

(cherry picked from commit 533b592)
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: d1136ba1-0a8f-48ff-90fe-5f10a7ed9458

📥 Commits

Reviewing files that changed from the base of the PR and between 26ed55c and 058a509.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (46)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10185-element-shape-fields-random.md
  • changelog.d/10189-class-field-get-one-exit.md
  • changelog.d/10196-generic-get-two-exits.md
  • crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs
  • crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs
  • crates/perry-codegen/src/eh_mode.rs
  • crates/perry-codegen/src/expr/array_callback_shape_tests.rs
  • crates/perry-codegen/src/expr/binary.rs
  • crates/perry-codegen/src/expr/class_field_get_shape_tests.rs
  • crates/perry-codegen/src/expr/element_shape_guard.rs
  • crates/perry-codegen/src/expr/element_shape_reads.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/property_get.rs
  • crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
  • crates/perry-codegen/src/expr/property_get/helpers.rs
  • crates/perry-codegen/src/expr/property_get/tests.rs
  • crates/perry-codegen/src/expr/shadow_slot.rs
  • crates/perry-codegen/src/gc_call_effects.rs
  • crates/perry-codegen/src/module/linkage.rs
  • crates/perry-codegen/src/native_value/verify/raw_f64.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-codegen/src/stmt/cached_field_index_return.rs
  • crates/perry-codegen/src/stmt/element_shape_carried.rs
  • crates/perry-codegen/src/stmt/element_shape_fields_random_tests.rs
  • crates/perry-codegen/src/stmt/element_shape_loop.rs
  • crates/perry-codegen/src/stmt/element_shape_loop_tests.rs
  • crates/perry-codegen/src/stmt/element_shape_shape_keyed_tests.rs
  • crates/perry-codegen/src/stmt/let_stmt.rs
  • crates/perry-codegen/src/stmt/mod.rs
  • crates/perry-codegen/src/type_analysis/numeric.rs
  • crates/perry-codegen/tests/native_proof_regressions.rs
  • crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs
  • crates/perry-codegen/tests/typed_feedback.rs
  • crates/perry-runtime/src/object/field_get_set.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss/ic_slow.rs
  • crates/perry-runtime/src/typed_feedback/tests.rs
  • crates/perry-transform/src/prop_cse.rs
  • crates/perry/tests/issue_8774_argument_shape_clones.rs
  • docs/src/internals/gc-rooting-invariant.md
  • scripts/gc_root_dominance_check.py
  • scripts/shape_descriptor_census.py
  • test-files/test_gap_generic_get_one_exit_arms.ts
  • test-files/test_gap_json_record_loop_clone.ts

📝 Walkthrough

Walkthrough

Changes

Compiler and runtime optimization

Layer / File(s) Summary
Element-shape loop clone extensions
crates/perry-codegen/src/expr/*, crates/perry-codegen/src/stmt/*, crates/perry-codegen/src/type_analysis/numeric.rs
Element-shape clones now support carried affine indices, folded accumulator statements, shared element prefetches, string .length reads, and boolean constant selections.
Property-get exit consolidation
crates/perry-codegen/src/expr/property_get*, crates/perry-runtime/src/object/field_get_set/*
Class-field misses use js_class_field_get_ic. Generic property-get misses use js_object_get_field_ic_nonptr or js_object_get_field_ic_slow.
Validation and integration
crates/perry-codegen/**/tests*, crates/perry-runtime/**/tests.rs, test-files/*, scripts/*, docs/src/internals/*
Tests and analysis checks cover the new clone shapes, runtime exits, GC behavior, IR structure, and JavaScript gap cases.
Documentation and release metadata
CLAUDE.md, Cargo.toml, changelog.d/*, crates/perry-transform/src/prop_cse.rs
The workspace version is updated to 0.5.1557, and changelog and internal documentation describe the compiler and runtime changes.

Estimated code review effort: 5 (Critical) | ~90 minutes

Change: Feature · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant LoopMatcher
  participant FastClone
  participant ElementGuard
  participant RuntimeExit
  LoopMatcher->>FastClone: match carried index and folded body
  FastClone->>ElementGuard: prefetch element and check shape
  ElementGuard->>FastClone: load specialized fields
  FastClone->>RuntimeExit: side-exit on failed residual or tag check
Loading

Possibly related PRs

  • PerryTS/perry#5198: Adds the class-field shape guard used by the property-get path consolidated here.
  • PerryTS/perry#6597: Adds source locations for nullish property-read throws that now execute through shared runtime exits.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch train181b

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.

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