You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
classP{constructor(publicx: number,publicy: number){}}// (A) array as a PARAMETER — deniedfunctiontotal(ps: P[]): number{lets=0;for(leti=0;i<ps.length;i++)s+=ps[i].x+ps[i].y;returns;}// (B) same array, LOCAL, built by push, consumed in the same function — promotedfunctionrun(n: number): number{consta: P[]=[];for(leti=0;i<n;i++)a.push(newP(i,i+1));lets=0;for(leti=0;i<a.length;i++){constr=a[i];s+=r.x+r.y;}returns;}
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:
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 selected and its loop body emits nojs_object_get_field_by_name_f64, nojs_typed_feedback_class_field_get_guard, and nojs_number_coerce for ps[i].x.
Case (B) does not regress.
The soundness case is tested, not argued: a caller that passes an array whose elements are notP — 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.
Build with -p perry -p perry-runtime-static -p perry-stdlib-static and pin PERRY_RUNTIME_DIR, or perry compile links a stale .a and 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).
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.
perry compile … --opt-report --no-linkon main @ v0.5.1448:Emitted IR for the loop body, same read shape in both:
js_object_get_field_by_name_f64js_typed_feedback_class_field_get_guardjs_throw_type_error_property_accessPlus
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-reportnames 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:
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{k: v}) and{}builder sites, which lower toExpr::New { class_name: "__AnonShape_…" }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 passedPs. 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_shapeis 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.xshould be an offset load.Verify that framing before building on it: confirm whether the versioned clone is genuinely entered in (A) (
cond_brINTO the fast clone, per #7701'sassert_fast_clone_is_entered), or whether it degrades to the slow path andensure_element_shapeis a leftover.2. Clone-and-route at the call site — the
#7034 §1mechanism that#7151 §2proposes forA.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
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.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.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.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.-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).