Skip to content

perf(codegen): route typed-clone fallbacks to the Phase 5a proven-this clone (#7128) - #7141

Open
proggeramlug wants to merge 6 commits into
mainfrom
fix/7128-pshape-routing
Open

perf(codegen): route typed-clone fallbacks to the Phase 5a proven-this clone (#7128)#7141
proggeramlug wants to merge 6 commits into
mainfrom
fix/7128-pshape-routing

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes the Phase 5a defect recorded in #7128: the {method}__pshape proven-this
clone had zero call sites across the measured corpus. Every clone was
emitted, reachable from nothing, and dead-stripped by the linker.

Why neither existing check could see it

  • An object-hash A/B scores the phase as working. suite_09_method_calls'
    object genuinely does differ with PERRY_PTR_SHAPE_LOCALS=0 — by two dead
    clone bodies.
  • The promotion census scores it as working. The clone's this is a real
    Ptr<Shape> consumption, recorded at every this.field site inside the body
    nobody calls
    .

That is also the reconciliation of the two contradictory reports from the same
day: #7117's "7 receiver consumptions corpus-wide" and #7128's "zero call sites"
are both true, and describe the same dead code. note_ptr_shape_consumed files
an Expr::This consumption under Position::Param, which the census counts as
consumed_receiver and excludes — and proven_this is Some only while
lowering a __pshape body. Verified in IR, not inferred.

Root cause

Arm ordering at both routing sites.

  • lower_call/method_override.rsemit_guarded_direct_method_call tried five
    typed-clone arms and consulted pshape_methods only in the final else.
  • lower_call/property_get/dynamic_dispatch.rs — the Phase 3b guard-free site
    routed to the clone on its plain exit, but its typed-receiver arm's own
    generic fallback called the guard-ridden public body 25 lines above.

A method can only admit a proven-this clone if it touches a declared field of
its own chain — which is very nearly the definition of a typed-receiver-clone
candidate. So the typed arm won essentially whenever both were eligible, and the
clone was emitted with no caller at all.

Both sites now resolve the clone once, up front, and use it for the generic
fallback as well as the plain exit. The typed clone is still preferred on the
fast path; what changed is that falling off it no longer discards a receiver
proof the enclosing block had already established.

Soundness

No new proof obligation. Every rerouted block is dominated either by the
class-id + keys-token guard (method_direct.fast) or by Phase 3b containment —
exactly what the existing else arm already relied on. Unchanged
(double this, args…) ABI; the clone still stores its receiver to a slot and
js_shadow_slot_binds it with no safepoint between, which the new tests assert
directly rather than trusting the comment (GC_TYPE_OBJECT moves in the shipped
config, #7019).

Tests

collectors/proven_this_routing_tests.rs ratchets call sites, never symbol
presence: pshape_call_targets matches the callee position of a call, so a
ptrtoint ptr @…__pshape operand (how the typed-feedback guard receives a
function pointer) can never be miscounted. These are --lib unit tests, so they
run on every PR (#5960), not only nightly.

Sabotage-verified in both directions on a Raspberry Pi 5:

  • fixed compiler — 3/3 pass;
  • routing files reverted to main, tests kept —
    typed_clone_fallback_routes_to_proven_this_clone FAILS with
    defined=[…__bump__pshape, …__scale__pshape] called=[…__bump__pshape], while
    the untyped_method_routes_to_proven_this_clone control still passes,
    confirming the control is a control and the new test targets the real defect.

Deliberately not done, filed instead

  • Routing the class-id-switch tower (idispatch.caseN) would be unsound.
    delete inst.field compacts packed slots while preserving class_id
    (js_object_delete_field → keys-scan/compaction; class instances get no
    tombstone branch), which is precisely what the keys token catches. That tower
    checks class-id only.
  • Whether the typed arm should yield to the clone on its fast path is a
    cost-model question — it trades per-field js_typed_feedback_class_field_get_guard
    calls at the call site against a NaN-boxed load double in the body. That
    belongs in the collectors/repsel_benefit.rs gate added by perf(repsel): refuse canonical i32 when every hot consumer wants a double (#7128) #7132.

Summary by CodeRabbit

  • Performance

    • Improved method dispatch routing for proven receiver types.
    • Preserved optimized fast paths while improving generic and typed fallback behavior.
    • Ensured consistent receiver handling across supported dispatch scenarios.
  • Bug Fixes

    • Fixed cases where optimized method variants could be omitted or bypassed during routing.
  • Tests

    • Added regression coverage for routing, fallback selection, and receiver binding.
  • Documentation

    • Documented routing behavior and remaining known limitations.

Ralph Küpper added 2 commits July 31, 2026 22:04
…is` clone

The `{method}__pshape` clone emitted by representation-selection Phase 5a had
zero call sites across the whole measured corpus: every clone was emitted,
reachable from nothing, and dead-stripped by the linker.

Root cause is arm ordering at both routing sites. emit_guarded_direct_method_call
tried five typed-clone arms and consulted pshape_methods only in the final
`else`; the Phase 3b guard-free site had the same shape, routing to the clone on
its plain exit while its typed-receiver arm's generic fallback called the
guard-ridden public body 25 lines above. A method can only admit a proven-`this`
clone if it touches a declared field of its own chain, which is very nearly the
definition of a typed-receiver-clone candidate — so the typed arm won whenever
both were eligible.

Both sites now resolve the clone once, up front, and use it for the generic
fallback as well as the plain exit. Every rerouted block is dominated either by
the class-id + keys-token guard or by Phase 3b containment, which is exactly what
the existing `else` arm already relied on — no new proof obligation, unchanged
ABI, unchanged shadow-bound receiver slot.
@coderabbitai

coderabbitai Bot commented Jul 31, 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: 50 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

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: 23c0b837-fb7d-4a4b-b7ea-faeffb41f574

📥 Commits

Reviewing files that changed from the base of the PR and between 45eb94e and aff97da.

📒 Files selected for processing (4)
  • changelog.d/7141-pshape-routing.md
  • crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
  • crates/perry-codegen/src/linker.rs
  • crates/perry-codegen/src/lower_call/method_override.rs
📝 Walkthrough

Walkthrough

Changes

Proven-this routing

Layer / File(s) Summary
Guarded method routing
crates/perry-codegen/src/lower_call/method_override.rs, changelog.d/7141-pshape-routing.md
Eligible guarded method calls compute one proven-this clone. Typed fallback and untyped paths reuse that target. Static methods retain existing behavior.
Dynamic dispatch target selection
crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs
Ptr-shape dispatch resolves one generic target before typed and direct branches. Both branches use the proven-this clone when available.
LLVM IR routing tests
crates/perry-codegen/src/collectors/mod.rs, crates/perry-codegen/src/collectors/proven_this.rs, crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
Tests compile synthetic Counter modules and verify call targets, typed fast-path emission, receiver storage, and shadow-slot binding order.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6925: Both changes modify proven-this __pshape clone routing in the same lowering paths.

Sequence Diagram(s)

sequenceDiagram
  participant MethodLowering
  participant ProvenThisClone
  participant TypedFallback
  participant LLVMIR
  MethodLowering->>ProvenThisClone: Resolve eligible __pshape clone
  MethodLowering->>TypedFallback: Pass shared generic target
  TypedFallback->>LLVMIR: Emit call to proven-this clone
  MethodLowering->>LLVMIR: Emit untyped direct call to shared target
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main codegen change: routing typed-clone fallbacks to Phase 5a proven-this clones.
Description check ✅ Passed The description explains the defect, root cause, implementation, soundness, tests, and deferred work, but it does not use the repository template headings or checklist.
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 fix/7128-pshape-routing

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.

find("@name(") matches a routed CALL site first — which appears earlier in the
module than the definition — so the slice could cover the caller's body and pass
on some other function's shadow bind.

@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

🧹 Nitpick comments (1)
crates/perry-codegen/src/lower_call/method_override.rs (1)

183-205: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hoist generic_body_fn once instead of recomputing it in five arms.

Each of the five typed arms repeats the same 3-line expression: pshape_fn.clone().unwrap_or_else(|| crate::codegen::generic_method_body_name(direct_fn)). pshape_fn and direct_fn do not change across arms, so this value is the same wherever it is computed. Compute it once next to pshape_fn and reference it by borrow in each arm.

♻️ Proposed refactor to hoist the shared fallback target
     let pshape_fn: Option<String> = (!direct_fn.starts_with("perry_static_")
         && ctx
             .pshape_methods
             .contains_key(&(receiver_class_name.to_string(), property.to_string())))
     .then(|| crate::collectors::pshape_method_name(direct_fn));
+    let generic_body_fn: String = pshape_fn
+        .clone()
+        .unwrap_or_else(|| crate::codegen::generic_method_body_name(direct_fn));

Then in each of the five typed arms (lines 272-274, 402-404, 489-491, 578-580, 680-682), drop the local recomputation and rely on the hoisted binding, for example:

         if let Some((typed_fn, typed_formal_count, receiver_info)) = typed_f64_receiver_direct_fn {
-            let generic_body_fn = pshape_fn
-                .clone()
-                .unwrap_or_else(|| crate::codegen::generic_method_body_name(direct_fn));
             let formal_args: Vec<&str> = direct_arg_slices

Apply the same removal at the other four arms; the existing &generic_body_fn usages further down each arm continue to work unchanged since they only borrow the value.

Also applies to: 272-274, 402-404, 489-491, 578-580, 680-682

🤖 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/lower_call/method_override.rs` around lines 183 -
205, Hoist the shared generic body target next to the existing pshape_fn binding
in the surrounding method-override function, computing it once from pshape_fn
and direct_fn. Remove the repeated pshape_fn.clone().unwrap_or_else(...)
expressions from all five typed arms and have their existing &generic_body_fn
usages borrow the hoisted value unchanged.
🤖 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/collectors/proven_this_routing_tests.rs`:
- Around line 23-29: Align the module documentation with the actual coverage in
guarded_site_module and its three emit(..., false) tests: either add a Phase 3b
fixture that declares a shape-proven new Counter() receiver and exercises its
method calls, or revise the header to claim coverage only for the guarded
method_direct.fast routing site.

---

Nitpick comments:
In `@crates/perry-codegen/src/lower_call/method_override.rs`:
- Around line 183-205: Hoist the shared generic body target next to the existing
pshape_fn binding in the surrounding method-override function, computing it once
from pshape_fn and direct_fn. Remove the repeated
pshape_fn.clone().unwrap_or_else(...) expressions from all five typed arms and
have their existing &generic_body_fn usages borrow the hoisted value unchanged.
🪄 Autofix (Beta)

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: 5a4ed4ee-d764-44b5-ae38-0399d4a5d28e

📥 Commits

Reviewing files that changed from the base of the PR and between a3b31c0 and 83a1dd4.

📒 Files selected for processing (6)
  • changelog.d/7141-pshape-routing.md
  • crates/perry-codegen/src/collectors/mod.rs
  • crates/perry-codegen/src/collectors/proven_this.rs
  • crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
  • crates/perry-codegen/src/lower_call/method_override.rs
  • crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs

Comment thread crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Measurements (Raspberry Pi 5, aarch64, perry-dev, one CARGO_TARGET_DIR per arm)

Base = a3b31c0d8 (main). Fixed = this branch. Both built with the identical
package set -p perry -p perry-runtime-static -p perry-stdlib-static.

__pshape call sites, counted in the callee position of a call in the emitted IR

workload clones emitted call sites BASE call sites FIXED
benchmarks/repsel_census/fixtures/fixture_ptr_shape.ts 1 0 2
benchmarks/repsel_census/fixtures/fixture_ptr_shape_sites.ts 1 0 2
benchmarks/suite/09_method_calls.ts 2 0 1
benchmarks/app-patterns/kernels/batch.ts 1 0 0 → #7142
benchmarks/suite/07_object_create.ts 0 0 0 (no clone)
benchmarks/suite/12_binary_trees.ts 0 0 0 (no clone)

Every workload that emits a clone reachable through a keys-token-guarded or
Phase-3b-contained site now routes to it. batch.ts still does not, because its
r.rescore(1.5) is inside rows.map((r) => …) where r has no static class, so
the call lowers to the class-id switch tower — filed as #7142 with the
soundness argument for why this PR did not take it.

What the routed call site now enters

Point::norm2 in fixture_ptr_shape.ts, comparing the body the routed call
targets before and after:

callee IR lines js_typed_feedback_class_field_*_guard js_object_get_field_by_name* opaque js_* calls
…__Point__norm2__generic (before) 304 4 4 19
…__Point__norm2__pshape (after) 104 0 0 7

The 7 that remain are shadow-frame enter/pop/bind plus the cold
js_number_coerce arm. Module totals for those counters are unchanged, and
should be — the public vtable body is still emitted either way; what moved is
which body the routed call enters.

Regression checks

  • cargo test -p perry-codegen --lib409 passed, 0 failed.
  • python3 scripts/compiler_output_regression.py census --gateCensus OK;
    ptr-shape 7 selected / 3 consumed, batch's consumed floor of 2 held, no
    floor moved.
  • test_gap_repsel_proven_this_frozen, test_gap_repsel_ptr_shape_barriers,
    test_gap_repsel_ptr_shape_locals — compiled and run on both arms,
    byte-identical stdout, rc=0 on all three.

One thing the census structurally cannot show

ptr-shape-consumed does not move, and cannot. That column counts values with
a Phase 3b local proof
; a proven-this receiver is deliberately excluded from
it (the census's own coherence rule — consumed may never exceed selected,
and Phase 5a's receiver is consumed without ever being selected). The census
reports those separately, as "8 consumption(s) of a proven this receiver" —
and every one of those was, before this PR, recorded inside a body with no
caller. That is exactly why the ratchet added here reads call sites out of the
IR instead of trusting the counter.

Ralph Küpper added 3 commits July 31, 2026 22:30
Pre-existing on main since #7135 — `cargo fmt --all -- --check` is red at
a3b31c0 with exactly this hunk and nothing else. Carried here in its own
commit because the `lint` gate would otherwise fail this PR for it.
The module doc claimed both routing sites were covered, but every test used a
typed PARAMETER receiver, which only reaches emit_guarded_direct_method_call.
The dynamic_dispatch.rs half of the fix had no unit test at all.

Adds a shape-proven LOCAL fixture (single Let initialised by `new`, only
field-accessed and method-called) whose method has a typed-receiver clone, so it
takes the same shadowed fallback on the guard-free site. Fails against main's
routing, passes with the fix; the receiver-bind ratchet now scans both modules.

Note for the next person: returning `p.norm2()` directly disqualifies the local
— the containment walk sees a LocalGet(p) inside the Return and does not
distinguish a call receiver from an escape — which is why the result goes
through an accumulator, exactly as fixture_ptr_shape.ts does.

Also hoists generic_body_fn next to pshape_fn (CodeRabbit): both inputs are
arm-invariant, so the five arms were recomputing one value.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Both review points applied in aff97dac2.

Coverage claim vs tested module — you were right, and it was worse than a doc
mismatch.
Every test used a typed parameter receiver, which only reaches
emit_guarded_direct_method_call. The dynamic_dispatch.rs half of this PR's
fix had no unit test at all — it was covered only by a corpus IR measurement.

Rather than narrow the header I added the missing fixture: a Phase 3b
shape-proven local (single Let initialised by new, only field-accessed
and method-called) whose norm2(): number has a typed-receiver clone, so it hits
the same shadowed fallback on the guard-free site. Sabotage-verified — against
main's routing it fails with defined=[…Point__norm2__pshape] called=[], and
it passes with the fix. proven_this_clone_binds_its_receiver_slot now scans
both modules.

One thing worth recording, because it bit me while writing it: returning
p.norm2() directly silently disqualifies the local. The containment walk sees a
LocalGet(p) inside the Return and does not distinguish a call receiver from
an escape, so the local stops being shape-proven and the test would have passed
vacuously by asserting on a clone that was never eligible. The fixture routes the
result through an accumulator instead, exactly as fixture_ptr_shape.ts does,
and the test panics with an explicit "no clone emitted — this test would pass
vacuously" message if that ever regresses.

generic_body_fn hoist — applied. Both inputs are arm-invariant and
generic_method_body_name is pure format!, so computing it once next to
pshape_fn is a straight win; the five arms now just borrow it. No emitted-IR
change (cargo test -p perry-codegen --lib 410/410).

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