Repository navigation
repsel: rule-1 provenance does not survive a function boundary — a typed P[] parameter still reads fields by name (first-party half of #7152/#7170) #7766
Description
The gap, in one measurement
A fully-typed field read through a parameter array is still a by-name property lookup with a feedback guard and a boxing round-trip. The same code with the array as a local compiles to offset loads.
class P { constructor(public x: number, public y: number) {} }
// (A) array as a PARAMETER — denied
function total(ps: P[]): number {
let s = 0;
for (let i = 0; i < ps.length; i++) s += ps[i].x + ps[i].y;
return s;
}
// (B) same array, LOCAL, built by push, consumed in the same function — promoted
function run(n: number): number {
const a: P[] = [];
for (let i = 0; i < n; i++) a.push(new P(i, i + 1));
let s = 0;
for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; }
return s;
}perry compile … --opt-report --no-link on main @ v0.5.1448:
(A) Ptr<Shape> 0 selected / 1 denied (0% of 1 candidates)
ptr-shape rule 1 (provenance)
in function total -> Boxed
(B) Ptr<Shape> 1 selected / 1 denied (50% of 2 candidates)
local `r` -> Ptr<Shape> (in function run)
Emitted IR for the loop body, same read shape in both:
js_object_get_field_by_name_f64 |
js_typed_feedback_class_field_get_guard |
js_throw_type_error_property_access |
|
|---|---|---|---|
| (A) parameter | ×2 | ×2 | ×2 |
| (B) local | gone | gone | gone |
Plus js_number_coerce ×2 in (A) — the boxing round-trip, because the by-name read hands back a NaN-boxed double.
The machinery works. It cannot see through a function boundary.
Why this is the right lever
--opt-report names the rule, and #7152 already measured its size on real dependency JS: rule 1 accounts for 506 of 746 denied candidates — four times the next bucket (rule 5, 99). #7170 then re-measured and found 96% of those 506 are record literals.
So the same rule is the wall in two populations that look unrelated:
- dependency JS — tracked in repsel: dependency JS is walled by rule 1 (unbound allocations), not containment — 506 of 746 candidates #7152 / repsel: rule-1 provenance for unbound allocations — the 506 are 96% record literals, and Perry's own CJS IIFE is the wall (#7152 follow-up) #7170, framed around CommonJS wrappers
- first-party TypeScript — the case above, tracked nowhere
They are one defect. This ticket is the single place to fix it.
What rule 1 currently requires
collectors/ptr_shape.rs, provenance is a whitelist of initializer forms:
Stmt::Letwhose init isnew C(...)— dynamic class is exactlyC- anon-shape literals (
{k: v}) and{}builder sites, which lower toExpr::New { class_name: "__AnonShape_…" } - since repsel row 1: scoping Ptr<Shape> beyond locals — measured, promotion is 0 on the motivating workload; build --opt-report (#6952) first #7034 §4, a direct call to a module function carrying a return-shape fact (
collectors/ptr_shape_returns.rs)
A parameter is in none of them, and a declared type cannot simply be added as a fourth: Perry does not enforce declared types at runtime (CLAUDE.md, Known Limitations), so ps: P[] is not a proof that the caller passed Ps. Any fix has to establish the fact, not assume it.
Three candidate mechanisms
Evaluate all three and say why the chosen one wins — do not just implement the first.
1. Extend the element-shape guarded clone (#7480 / #7669 / #7701). Most promising, because the guard machinery already fires on case (A): js_array_ensure_element_shape is in the emitted IR, and the by-name read survives anyway. So the clone is admitted but its body does not specialize element field reads. If that is right, this is the smallest correct change — inside a region already guarded on element shape, r.x should be an offset load.
Verify that framing before building on it: confirm whether the versioned clone is genuinely entered in (A) (cond_br INTO the fast clone, per #7701's assert_fast_clone_is_entered), or whether it degrades to the slow path and ensure_element_shape is a leftover.
2. Clone-and-route at the call site — the #7034 §1 mechanism that #7151 §2 proposes for A.map(cb) callback parameters. Prove the shape in the caller, emit a specialized callee, route proving call sites to it. Generalises to ordinary parameters. Cost: code size, and it needs every call site to prove.
3. Entry guard + specialized body — check the array's element shape once on entry, then run a specialized loop. Cheapest to reason about, but pays a guard per call and needs a bail-out path.
Acceptance criteria
- Case (A) reports
Ptr<Shape> 1 selectedand its loop body emits nojs_object_get_field_by_name_f64, nojs_typed_feedback_class_field_get_guard, and nojs_number_coerceforps[i].x. - Case (B) does not regress.
- The soundness case is tested, not argued: a caller that passes an array whose elements are not
P— a mixed array, a subclass with extra fields, a plain object literal of the same shape, an array with a hole, an array mutated mid-loop — must still produce Node-identical output. Declared types are not enforced at runtime; this is the direction the whole change can be wrong in. --opt-reporton the dependency-JS corpus from repsel: dependency JS is walled by rule 1 (unbound allocations), not containment — 506 of 746 candidates #7152 shows the rule-1 bucket falling from 506, with the new number recorded. That is the check that this fixed the general defect rather than one benchmark.- Protected floors hold:
churn,churn_alloc,push_cls,push_num,churn_read,cycles,deeplist,tree,tree_wide,retain,retain_wide,fib40— all within noise on the pinned quiet mini, interleaved, best-of-N, output byte-verified against Node before timing.
Traps, all of which have already cost time here
--opt-reportis the instrument, not the IR. It names the denying rule directly. Read it first; do not infer the rule from emitted calls.- The read form is not the gate.
const r = ps[i](the form repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151 lists as covered) behaves identically tops[i].xwhen rule 1 denies. I predicted otherwise and was wrong; measure the rule, not the syntax. PERRY_NO_AUTO_OPTIMIZE=1changes nothing here — I verified the IR is identical with and without it, so it is safe for iteration.object_static_prototype()materializes on first call (fix(runtime): generic specializations share the generic's prototype object (#7757, prototype half) #7762): a probe that merely reads it flips the branch under test. Two instrumented builds were lost to that.- A gate must assert its subject ran. If a versioned clone is involved, assert the guard branches into it (fix(gc): restore evacuation at precise safepoints — the pacing half of #7682 #7690: a call inside a call-free-by-construction clone silently deletes the clone rather than slowing it).
- Build with
-p perry -p perry-runtime-static -p perry-stdlib-staticand pinPERRY_RUNTIME_DIR, orperry compilelinks a stale.aand both arms of an A/B behave identically.
Related
Fixes the first-party half of the same rule as #7152 and #7170; #7150 is a specific rule-1 instance (deforestation rewrites the producer before the analysis runs). #7151 is about read forms and is not blocking this — measured above. Closed context: #7034 (scoping), #7149 (§3, the element rule), #7480 / #7669 / #7701 (the element-shape guarded clone).
Activity
perf(repsel): element-shape loop clone serves the element-binding for…
docs(repsel): note the element-binding form in the clone's matcher co…
perf(repsel): element-shape loop clone serves the element-binding for…
docs(repsel): note the element-binding form in the clone's matcher co…
perf(repsel): element-shape loop clone serves the element-binding for…
test(hir): pin the for-of desugar counter's literal kind at both emis…
test(hir): pin the for-of desugar counter's literal kind at both emis…