fix(codegen): root_reload's cost cap counts root loads, not derived values - #9854
fix(codegen): root_reload's cost cap counts root loads, not derived values#9854proggeramlug wants to merge 1 commit into
Conversation
…alues `MAX_BLOCK_LOAD_PRODUCT` guards the reachability walk in `apply_to_function`, and that walk runs once per `groups` entry — one per ROOT LOAD. The check multiplied by `values.len()` instead, which since PerryTS#7664 counts every pure-bit-op derivation as its own `Reloadable`. On a wide function that is several times the group count, so the pass declined on functions whose real cost was well inside the bound. Declining is not correctness-neutral under the native root lowering. The constant's comment claimed "the pass is an improvement, not a correctness precondition, so declining is safe"; that is false, and it is why the cliff went unnoticed. When the pass does not run, nothing re-reads the slot: a receiver read out of its root, unmasked to an i64/double and carried across a call is a value RS4GC cannot relocate, so the store lands in a from-space object. Found on Claude-of-Duty's `Arm.constructor` (4924 blocks, 747 root loads, 2102 values): 10,350,248 by the old metric against an 8M cap, 3,678,228 by the new one. It declined, and `this.upper = buildSleeve(...)` wrote through a stale receiver — a SIGBUS under `PERRY_GC_PROTECT_FROMSPACE=1`, and silent field corruption without it (`THREE.Object3D.add: object not an instance of THREE.Object3D. undefined` two frames later). The bound itself is unchanged; only the term it is measured against. A function that genuinely exceeds `blocks x groups` still declines and can still carry a stale register — that residual risk is now stated at the constant rather than denied. Note that `scripts/gc_root_dominance_check.py --stale-registers --moving-only` does NOT flag this shape: it reported 0 stale uses on the faulting module (17 found, all `source=global`), so it cannot serve as a guard here. The regression test replicates the `masked_receiver` shape across 1100 blocks with a MAX_RECIPE-length derivation, sized to clear the cap by the old metric and sit an order of magnitude inside it by the new one. It inserts 0 reloads before this change and 1100 after.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 8 included reviews per hour; 4 remain after this review. 📝 WalkthroughWalkthroughThe root reload pass now measures its reachability cap using distinct root-load groups instead of all reloadable values. A regression test covers masked derivations across 1,100 blocks and verifies that stale operands are reloaded. ChangesRoot Reload Reachability Cap
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to This change prevents root reload from skipping functions solely because derived values inflate the cost estimate, with regression coverage confirming stale operands are reloaded across collecting calls. The change is ready to merge. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
Landed on |
The bug
MAX_BLOCK_LOAD_PRODUCTguards the reachability walk inroot_reload::apply_to_function. That walk runs once pergroupsentry — one per root load; the module's own comment says so ("Grouping by root load also puts the cost back at O(blocks × loads)").The check multiplied by
values.len()instead. Since #7664 extended a recipe through pure bit ops,valuescounts every derivation as its ownReloadable, so on a wide function it is several times the group count. The pass therefore declined on functions whose actual cost was well inside the bound.Why declining is not safe
The constant's comment read:
Under the native root lowering that is false, and it is why this went unnoticed. When the pass does not run, nothing else re-reads the slot. A receiver read out of its root and unmasked to an
i64/doubleis exactly the "case 2" shape this module's header documents: RS4GC relocates theaddrspace(1)load, but the unmasked copy that actually crosses the call is beyond its reach.Where it was found
Claude-of-Duty's
Arm.constructor:blocks × values(old)blocks × groups(new)With the pass declined,
this.upper = buildSleeve(...)stored through a stale receiver. UnderPERRY_GC_PROTECT_FROMSPACE=1:The faulting instruction is the inline class-field store's own shape check,
ldurh w8, [x19, #-0x6], wherex19is the receiver unmasked from callee-savedx22— loaded beforebl buildSleeveand never relocated. Without from-space protection it degrades silently: the write lands in the dead copy, so the later read ofthis.upperreturnsundefinedand THREE reportsObject3D.add: object not an instance of THREE.Object3D. undefined.The change
One term.
groupsis built before the cap check and the product measured against it. The bound is unchanged at 8M.A function that genuinely exceeds
blocks × groupsstill declines and can still carry a stale register. That residual risk is real; the constant's doc now states it instead of denying it.Test
the_cap_counts_root_loads_not_derived_valuesreplicates the existingmasked_receivershape across 1100 blocks, each with aMAX_RECIPE-length derivation, sized to clear the cap by the old metric (~9.7M) and sit an order of magnitude inside it by the new one (~1.2M).Verified in both directions:
values.len()(before)groups.len()(after)A note on the checker
scripts/gc_root_dominance_check.py --stale-registers --moving-onlydoes not flag this shape. On the faulting module it reported 0 stale uses of this kind (17 total, allsource=global). So it cannot be relied on as a guard here — worth a separate look.Verification
cargo test --release -p perry-codegen— 1912 passed, 0 failedcargo test --release -p perry-runtime— 2878 passed, 0 failed (on the earlier base)WeaponSystem.init(3 weapons · 136.8k tris viewmodel) and renders.Summary by CodeRabbit