Skip to content

perf(repsel): the element-shape loop clone fires on arr.length and aliased element types (#7480) - #7701

Merged
proggeramlug merged 5 commits into
mainfrom
pr/7693-element-shape-loop-applicability
Aug 9, 2026
Merged

perf(repsel): the element-shape loop clone fires on arr.length and aliased element types (#7480)#7701
proggeramlug merged 5 commits into
mainfrom
pr/7693-element-shape-loop-applicability

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Part 1 of P2 (#7480 step 4 / repsel #5093): make the element-shape versioned
loop clone actually fire, and stop two lowerings from silently deleting it.

Why churn_read.ts never moved

#7612 landed the clone and #7669 taught it object-literal element types. Neither
moved churn_read.ts by a millisecond — it sat at 0.35 s across four measurement
rounds while everything around it improved 4–11×. The lowering was never the
problem. The matcher declined before reaching it, for two independent
reasons, each on its own fatal:

  1. for (let j = 0; j < arr.length; j++) failed the bound match. The bound
    admitted a literal or a loop-invariant local; keep.length is a
    PropertyGet, so the match returned None at the condition, before the class
    resolver was ever consulted. That is the bound form every one of repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480's own
    kernels — and churn_read, churn, retain — is written in.
  2. type Node = { v: number; w: number } shadowed the anon-shape resolver.
    element_class_name returns receiver_class_name's answer when it has one,
    and for an alias-typed array that answer is "Node" — a name no ctx.classes
    entry owns, because the literals allocate an __AnonShape_…. The early return
    meant the resolver perf(repsel): resolve object-literal element types in the element-shape loop clone (#7480) #7669 added for exactly this case was never reached for any
    alias-typed array.

A four-compile probe matrix localized it: inline element type + constant bound
fires; alias + constant declines; inline + .length declines; inline +
loop-invariant local bound fires.

And then: two calls that delete the clone rather than slow it

Both lower_element_shape_versioned_for and lower_class_field_versioned_for
build the fast clone first and prove it call-free second. On a failed proof they
terminate the guard with an unconditional branch to the slow clone and leave
the fast blocks as unreachable code. So a call emitted into one of these clones
does not make it slower — it removes it, silently, with every block label still
present for an IR census to find.

  • The GC back-edge poll. fix(gc): restore evacuation at precise safepoints — the pacing half of #7682 #7690 restores moving-loop polls to ON by default,
    putting a js_gc_loop_safepoint() in the clone's element-load block. Same
    compiler, churn_read.ts: polls off → clone runs, 0.03 s user; polls on →
    br label %element_shape.loop.slow.preheader, 0.54 s. Skipping the poll inside
    a clone is not a new licence — a poll exists so an allocating loop can defer
    a collection, which is why loop_may_allocate already gates it. That predicate
    answers from the HIR body, before specialization, so it cannot see that
    arr[j].f lowers to a bare load here.
  • The loop-invariant arr.length hoist. With a .length bound the clone got
    a second plen diamond (js_value_length_f64) whose result nothing reads,
    because the caller had already handed the trip count in as
    precomputed_i32_bound. Only the load is skipped; the bounded-index and
    buffer-width facts and the i32 counter slot are proofs and storage the clone's
    other lowering may depend on.

This is the failure mode stmt/element_shape_loop.rs's own module docs named in
advance — "a silent loss of the optimization … with no test failing" — and every
perry-codegen test was green throughout.

The class-field clone is not affected today, and that was checked rather than
assumed: with the suppression removed its three IR tests stay green, because
loop_may_allocate already proves an obj.field-only body inert. The
suppression covers it anyway (identical argument), and its tests gained the same
liveness assertion.

Results

Quiet M1 mini, best-of-3 interleaved. Both arms are rebased onto the #7690
stack
(20fce6daa): origin/main is currently GC-livelocked — #7687 shipped
only part 1 of the #7682 fix, and churn_alloc runs 0.43 s vs 8.7 s there — so
any allocation-touching measurement on plain main is meaningless.

bench base this PR + #7694
churn_read 0.468 0.042 11.1× — beats node 0.08 and scriptc 0.30
churn 0.764 0.471 1.62×
retain 1.417 1.382 read loop is 2.8% of it — see below
churn_alloc / push_cls / push_num 0.429 / 0.416 / 0.195 unchanged
cycles / deeplist / tree / tree_wide 1.00–1.03×
json_roundtrip (tape-defeating) 0.761 0.761 shared read path unchanged

All 13 benchmark stdouts byte-identical; peak RSS byte-identical (retain 367 MB
both arms).

retain improves by only 0.035 s, and it cannot do better from the read side:
by amplification (retain_read10 = build + 10 read passes vs retain = build +
1), one read pass costs 40.0 ms → 3.9 ms (10.3×) and is 2.8% of retain's
1.42 s
. Zeroing it entirely would save 0.040 s. The rest is 3M allocations +
GC.

Tests

The IR census is the regression gate for this optimization, and it gained the
assertion it was missing:

  • assert_fast_clone_is_entered — the guard must cond_br into the clone.
    Label-presence assertions cannot distinguish "optimized" from "optimization
    silently deleted". Added to all five positive tests, and to the class-field
    suite. Sabotage-tested: with the suppression removed, the two
    .length-bound tests go red with "the deref block ends in an unconditional
    branch to the slow clone", so a green run is evidence rather than decoration.
  • fast_clone_slice now selects blocks by name instead of slicing a span
    between the fast and slow cond blocks. An arr.length bound makes the slow
    clone hoist its own length read, and those plen.* blocks landed in the gap —
    so a call belonging to the slow clone was attributed to the fast one and the
    census failed on a fast clone that was, and still is, bare. This is the mirror
    image of the repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480 step 3 bug the function's own doc records; a span that
    depends on what a neighbour emits can report wrong in either direction.
  • New cases: .length bound fires and stays call-free; a foreign array's
    .length is declined (the preheader relates the two lengths not at all, so
    that would be an out-of-range read rather than a slow clone); an aliased
    element type resolves; an unresolvable Named element type with no class and
    no alias is declined rather than guessed; and churn_read.ts's exact shape
    (alias and .length together) reaches the clone.

Validation

  • cargo test -p perry-codegen: 783/0 lib, all integration suites green except
    large_object_barriers::large_local_array_push_inbounds_store_emits_precise_slot_barrier,
    which is red on the clean baseline commit with no local changes (verified).
    It slices a span between apush.inbounds. and apush.realloc. — the same
    fragile pattern fixed above. Reported separately; not touched here.
  • cargo test -p perry-runtime --release: 1930/0.
  • gc-root-dominance corpus: 0 violations over 2538 functions / 150 modules /
    9864 root stores, with --self-test confirming the checker can still fail.
  • cargo fmt --all -- --check, scripts/check_file_size.sh,
    scripts/addr_class_inventory.py: clean.
  • Gap suite (507 tests): no regression attributable to this change. The seven the
    snapshot flags — six network crashes and zlib_3285_params — reproduce
    identically on the base compiler, and the ten node_fail -> parity_fail
    status changes are the oracle running tests it previously could not. One
    improvement: test_gap_iterator_helpers_2874: parity_fail -> pass.

Notes for review

  • The changelog.d/ fragment is keyed on a guessed PR number and should be
    renamed to the real one.
  • A third instance of the same shape, lower_object_array_write_versioned_for,
    has the same call-free scan and no fact scope to key off. It is a write-path
    loop, is not covered by the benchmarks here, and is left for a follow-up rather
    than changed blind.

Summary by CodeRabbit

  • New Features

    • Element-shape loop cloning now supports bounds based on an array’s .length.
    • Cloning works with aliased array and element types.
    • Invalid foreign-array bounds and unresolved types are rejected safely.
  • Bug Fixes

    • Fast clones remain call-free by avoiding unnecessary garbage-collection checks and redundant length loads.
    • Improved clone reachability and block tracking ensures fast paths are actually entered.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 537dce6c-9e8d-4710-8732-c952b24807d0

📥 Commits

Reviewing files that changed from the base of the PR and between 0c0b42a and b8edb1b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • CLAUDE.md
  • Cargo.toml
  • crates/perry-codegen/src/stmt/loops.rs
📝 Walkthrough

Walkthrough

Element-shape loops now accept matching arr.length bounds and aliased element types. Guards return the selected trip count. Specialized fast clones suppress redundant length loads and GC polls. Tests verify reachability, call freedom, alias handling, and rejection cases.

Changes

Element-shape loop specialization

Layer / File(s) Summary
Applicability and trip-count guards
crates/perry-codegen/src/expr/element_shape_guard.rs, crates/perry-codegen/src/stmt/element_shape_loop.rs
The matcher resolves bounded aliases, accepts the tracked array’s .length, rejects foreign-array bounds, and passes the selected trip count through the preheader guard.
Call-free fast-clone lowering
crates/perry-codegen/src/stmt/loops.rs
Element-shape and class-field fast clones skip redundant length loads and GC polls while retaining normal lowering outside specialized fact scopes.
Clone reachability and regression coverage
crates/perry-codegen/src/stmt/element_shape_loop_tests.rs, crates/perry-codegen/src/stmt/class_field_loop_tests.rs, changelog.d/7701-element-shape-loop-applicability.md
Tests verify entered fast clones, block ownership, call freedom, .length bounds, alias resolution, unresolved types, foreign arrays, and repaired preheaders. The changelog records the behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant ElementShapeLoopMatcher
  participant ElementShapeGuard
  participant ElementShapeFastClone
  participant LoopLowering
  ElementShapeLoopMatcher->>ElementShapeGuard: select Bound or ArrayLength
  ElementShapeGuard-->>ElementShapeFastClone: return validated i32 trip count
  ElementShapeFastClone->>LoopLowering: lower specialized loop
  LoopLowering-->>ElementShapeFastClone: omit redundant length loads and GC polls
Loading

Possibly related PRs

  • PerryTS/perry#7425: Both changes update class-field loop cloning and call-free fast-clone reachability.
  • PerryTS/perry#7496: This change consumes the per-array element-shape invariant added by that PR.
  • PerryTS/perry#7612: This change extends the element-shape loop cloning introduced by that PR.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main changes: element-shape loop cloning for arr.length bounds and aliased element types.
Description check ✅ Passed The description provides a detailed summary, concrete changes, related issue references, test results, benchmark results, and validation details.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 pr/7693-element-shape-loop-applicability

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 pushed a commit that referenced this pull request Aug 9, 2026

@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: 2

🧹 Nitpick comments (3)
crates/perry-codegen/src/stmt/element_shape_loop.rs (1)

718-723: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Select the trip-count variant from matched.bound, not from Option::is_some.

materialized_bound encodes two facts: the materialized value, and which trip-count mode the guard must use. Today None means exactly ArrayLength. A future bound arm that materializes nothing would silently take the ArrayLength path and make the guard skip the length >= bound proof.

Match on matched.bound so the mode has one source of truth.

♻️ Proposed refactor
-    let trip_count = match &materialized_bound {
-        Some(bound) => {
-            crate::expr::element_shape_guard::ElementShapeLoopTripCount::Bound(bound.as_str())
-        }
-        None => crate::expr::element_shape_guard::ElementShapeLoopTripCount::ArrayLength,
-    };
+    let trip_count = match (matched.bound, &materialized_bound) {
+        (ElementShapeLoopBound::ArrayLength(_), _) => {
+            crate::expr::element_shape_guard::ElementShapeLoopTripCount::ArrayLength
+        }
+        (_, Some(bound)) => {
+            crate::expr::element_shape_guard::ElementShapeLoopTripCount::Bound(bound.as_str())
+        }
+        (_, None) => return Ok(false),
+    };
🤖 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-codegen/src/stmt/element_shape_loop.rs` around lines 718 - 723,
Update the trip-count selection near ElementShapeLoopTripCount to match on
matched.bound rather than testing materialized_bound with Some/None. Preserve
the materialized bound string for bound cases and select ArrayLength only when
matched.bound represents that mode, keeping the guard mode independent of
whether a value was materialized.
crates/perry-codegen/src/stmt/class_field_loop_tests.rs (1)

258-267: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

fast_clone_slice still uses span selection; the changelog says otherwise.

changelog.d/7701-element-shape-loop-applicability.md states the class-field tests gained "the same by-name slice fix". This helper still slices everything between for.class_field_fast.cond and for.class_field_slow.cond.

The span is correct today because ClassFieldLoopBound admits only Constant and Local, so no plen.* blocks land in the gap. It carries the same fragility the element-shape helper just removed: any future neighbour emitted between the two cond blocks is attributed to the fast clone.

Either apply the by-name selection used in element_shape_loop_tests.rs, or correct the changelog claim.

🤖 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-codegen/src/stmt/class_field_loop_tests.rs` around lines 258 -
267, Update fast_clone_slice to use the same by-name IR block selection as the
element-shape loop tests, selecting only the class-field fast-clone block
instead of slicing through the slow-condition boundary. Keep the helper’s
existing fast-clone output contract, and leave the changelog claim accurate
rather than weakening or removing it.
crates/perry-codegen/src/stmt/loops.rs (1)

4784-4802: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Suppression key is broader than the stated justification. Confirm the wider scope is intended.

The comment justifies the suppression with "the caller has ALREADY materialized the trip count and passed it in precomputed_i32_bound". The condition does not test precomputed_i32_bound. It tests whether any fact scope is active, so a nested loop lowered inside a fast clone body also loses its length hoist while receiving precomputed_i32_bound == None.

For the element-shape clone this cannot happen: the matcher admits a single Stmt::Expr(Expr::LocalSet(...)) body. For the class-field clone the body shape is wider.

The result is a slower nested loop, not a wrong one, and the bounded-index facts you keep remain valid. If you want the condition to match the comment, gate on precomputed_i32_bound.is_some() as well.

Also applies to: 4852-4852

🤖 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-codegen/src/stmt/loops.rs` around lines 4784 - 4802, Narrow the
length-hoist suppression in the loop-lowering logic around hoisted_length_slot
and the corresponding path near the second occurrence so it requires an active
precomputed_i32_bound, rather than any element_shape_loop_facts or
class_field_loop_facts scope alone. Preserve the existing suppression when a
bound was materialized and passed by the caller, while allowing nested loops
with precomputed_i32_bound == None to hoist their own lengths.
🤖 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-codegen/src/stmt/class_field_loop_tests.rs`:
- Around line 233-239: Update the class-field fast-clone assertion around
assert_fast_clone_is_entered to inspect only the block_slice for
class_field.loop.preheader.deref, adding the local block_slice helper equivalent
to element_shape_loop_tests.rs. Within that scoped block, assert only the branch
target class_field.loop.fast.preheader; remove the whole-IR check and alternate
fast condition label.

In `@crates/perry-codegen/src/stmt/element_shape_loop_tests.rs`:
- Around line 970-974: Update the assertion in the relevant element-shape loop
test to verify that the arr.length bound is materialized once in the preheader,
rather than asserting js_array_length is absent from the fast clone. Preserve
the existing call-free assertion, and rename or adjust the assertion message to
clearly identify the redundant per-iteration load it detects.

---

Nitpick comments:
In `@crates/perry-codegen/src/stmt/class_field_loop_tests.rs`:
- Around line 258-267: Update fast_clone_slice to use the same by-name IR block
selection as the element-shape loop tests, selecting only the class-field
fast-clone block instead of slicing through the slow-condition boundary. Keep
the helper’s existing fast-clone output contract, and leave the changelog claim
accurate rather than weakening or removing it.

In `@crates/perry-codegen/src/stmt/element_shape_loop.rs`:
- Around line 718-723: Update the trip-count selection near
ElementShapeLoopTripCount to match on matched.bound rather than testing
materialized_bound with Some/None. Preserve the materialized bound string for
bound cases and select ArrayLength only when matched.bound represents that mode,
keeping the guard mode independent of whether a value was materialized.

In `@crates/perry-codegen/src/stmt/loops.rs`:
- Around line 4784-4802: Narrow the length-hoist suppression in the
loop-lowering logic around hoisted_length_slot and the corresponding path near
the second occurrence so it requires an active precomputed_i32_bound, rather
than any element_shape_loop_facts or class_field_loop_facts scope alone.
Preserve the existing suppression when a bound was materialized and passed by
the caller, while allowing nested loops with precomputed_i32_bound == None to
hoist their own lengths.
🪄 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: 7acdc808-d1eb-43d3-b078-6f8de179419a

📥 Commits

Reviewing files that changed from the base of the PR and between 8d6f67e and 0c0b42a.

📒 Files selected for processing (6)
  • changelog.d/7701-element-shape-loop-applicability.md
  • crates/perry-codegen/src/expr/element_shape_guard.rs
  • crates/perry-codegen/src/stmt/class_field_loop_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/loops.rs

Comment on lines +233 to +239
assert!(
ir.contains("label %for.class_field_fast.cond")
|| ir.contains("label %class_field.loop.fast.preheader"),
"{what}: the guard must branch INTO the fast clone. If it ends in an \
unconditional branch to the slow clone, the call-free proof failed and \
the clone is dead code that every label assertion above still accepts"
);

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 | 🟠 Major | ⚡ Quick win

This assertion does not detect the state it describes. Scope it to the guard's deref block.

The check searches the whole IR. When the call-free proof fails, the lowering terminates the deref block with br label %class_field.loop.slow.preheader and leaves the fast blocks in place. Those fast blocks still contain their own branches, so label %for.class_field_fast.cond remains in the emitted IR and the first disjunct still matches. The assertion passes in exactly the state the comment says it catches.

The element-shape twin, assert_fast_clone_is_entered in element_shape_loop_tests.rs, first slices element_shape.loop.preheader.deref and then asserts the branch target inside that block. Apply the same scoping here against class_field_loop.preheader.deref, and assert only the branch into class_field.loop.fast.preheader.

💚 Proposed fix
-    assert!(
-        ir.contains("label %for.class_field_fast.cond")
-            || ir.contains("label %class_field.loop.fast.preheader"),
-        "{what}: the guard must branch INTO the fast clone. If it ends in an \
-         unconditional branch to the slow clone, the call-free proof failed and \
-         the clone is dead code that every label assertion above still accepts"
-    );
+    let deref = block_slice(ir, "class_field_loop.preheader.deref");
+    assert!(
+        deref.contains("label %class_field.loop.fast.preheader"),
+        "{what}: the guard must branch INTO the fast clone. The deref block \
+         ends in an unconditional branch to the slow clone, which means the \
+         call-free proof failed and the clone is dead code that every label \
+         assertion above still accepts:\n{deref}"
+    );

This needs a block_slice helper in this file, equivalent to the one in element_shape_loop_tests.rs. Do you want me to generate it?

🤖 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-codegen/src/stmt/class_field_loop_tests.rs` around lines 233 -
239, Update the class-field fast-clone assertion around
assert_fast_clone_is_entered to inspect only the block_slice for
class_field.loop.preheader.deref, adding the local block_slice helper equivalent
to element_shape_loop_tests.rs. Within that scoped block, assert only the branch
target class_field.loop.fast.preheader; remove the whole-IR check and alternate
fast condition label.

Comment thread crates/perry-codegen/src/stmt/element_shape_loop_tests.rs
Ralph Küpper added 5 commits August 9, 2026 16:21
…aliased element types (#7480)

The clone landed in #7612 and learned object-literal element types in #7669.
Neither moved churn_read.ts at all, because the matcher declined before the
lowering was ever reached:

  1. `j < arr.length` is a PropertyGet, which the bound match did not admit —
     and that is the bound form every #7480 kernel is written in.
  2. `type Node = {v: number}` makes receiver_class_name answer "Node", a name
     no class owns; element_class_name returned it and skipped the anon-shape
     resolver #7669 had just added for exactly this case.

Admit `ElementShapeLoopBound::ArrayLength` for the array the body reads (a
foreign array's length is declined — the preheader relates the two not at all),
take the trip count from the length word the guard already loads, and resolve
`type` aliases on both the array and element level.

Also fixes the IR census: fast_clone_slice sliced a SPAN between the two
clones, so the slow clone's `.length` hoist blocks were attributed to the fast
clone. It now selects blocks by name.
…ded fast clone (#7480)

Both versioned-loop clones build the fast body first and prove it call-free
second; a failed proof branches unconditionally to the slow clone and leaves
the fast blocks as unreachable code. A call emitted into the clone therefore
DELETES it rather than slowing it, with every IR-census label still present.

Two lowerings were doing that to the element-shape clone:

  - #7690's back-edge poll, in the element-load block. churn_read.ts: 0.03s
    with polls off, 0.54s with polls on -- same compiler, clone dead.
  - the loop-invariant arr.length hoist, which re-derived a bound the caller
    had already passed as precomputed_i32_bound. Only the load is skipped;
    the bounds proofs and the i32 counter slot stay.

The class-field clone is NOT affected today -- checked, not assumed: with the
suppression removed its three IR tests stay green, because loop_may_allocate
already proves an obj.field-only body inert. It is covered anyway, since the
two clones rest on the identical argument.

Both test files gained assert_*_is_entered: the guard must cond_br INTO the
clone, not merely emit its labels. Sabotage-tested -- removing the suppression
turns the two .length-bound tests red.
CLONE_LABELS is satisfied by a preheader whose deref block ends in an
unconditional branch to the slow clone -- the exact shape a failed call-free
proof emits. Verified the gap was real: with the pre-fix assertions and
`if false && fast_clone_call_free`, the test still passed.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
@proggeramlug
proggeramlug force-pushed the pr/7693-element-shape-loop-applicability branch from 0c0b42a to b8edb1b Compare August 9, 2026 14:21
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1403, with one added assertion

I tried hard to break the widening and could not. The one thing I did find was a vacuous test, and I fixed it on the branch rather than hand it back.

The soundness question, traced rather than assumed

Both new admission surfaces reach the same, unmodified call-free proof:

let fast_clone_call_free = !ctx.func.blocks()[fast_pre_idx].contains_gc_unsafe_call()
    && (fast_scan_start..fast_scan_end).all(|i| !ctx.func.blocks()[i].contains_gc_unsafe_call());

match_element_shape_versioned_loop has exactly one call site, and the scan has exactly one call site downstream of it. The .length bound only changes the preheader chain (never scanned); alias resolution only changes whether the matcher returns Some. No bypass exists.

What I tried to break it with:

attack why it fails
for (i=0;i<a.length;i++){ a.push(x) } body must be exactly one LocalSet; a push isn't one. Declined at the body-shape match.
for (j=0;j<other.length;j++) sum+=keep[j].v new recv_id != array_id check declines it — and it's sabotage-tested
alias-mutate mid-loop structurally inexpressible in a one-statement, store-free, call-free body
type A=B; type B=A cycle bounded at 8 hops, falls through to Named, declines cleanly
redundant .length hoist reintroducing #7690's silent deletion provably dead — short-circuited by used_precomputed_i32_cond before the hoisted slot is ever read

Worth noting "aliased element types" here means TS type aliases, not two bindings pointing at one array — runtime aliasing was already excluded pre-PR and stays excluded.

The vacuous test, and the proof it was vacuous

element_shape_versioned_loop_resolves_an_aliased_object_element_type — the only test covering alias resolution in isolation — asserted CLONE_LABELS and the anon-shape pin, but never that the clone is entered. CLONE_LABELS is satisfied by a preheader whose deref block ends in an unconditional branch to the slow clone, which is exactly what a failed call-free proof emits. So the test passed on a build where the thing it tests was dead code.

Not a hypothesis — measured. With the pre-fix assertions and if false && fast_clone_call_free:

test result: ok. 1 passed; 0 failed

With assert_fast_clone_is_entered + the call-free census added (commit 907616925), the same sabotage gives:

panicked at element_shape_loop_tests.rs:360:5:
the guard must branch INTO the fast clone. The deref block ends in an
unconditional branch to the slow clone, which means the call-free proof
failed and the clone is dead code

Your class_field_loop_tests.rs got this right by baking the assertion into the shared assert_versioned_loop_lowered helper so no future test can omit it; the element-shape file left it as a per-test opt-in, and this was the one place the opt-in was missed. Worth doing the same there eventually.

Gates

26/26. Full cargo test -p perry-codegen (not just --lib — per-PR CI never runs tests/*.rs): 6 failures, byte-identical to the set main already has. Rebased onto current main first; the branch was 13 versions behind, which is why its gate run initially failed on a script that didn't exist on its base.

@proggeramlug
proggeramlug merged commit 6106f69 into main Aug 9, 2026
11 of 13 checks passed
@proggeramlug
proggeramlug deleted the pr/7693-element-shape-loop-applicability branch August 9, 2026 14:24
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