Skip to content

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

@proggeramlug

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:

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:

  1. Stmt::Let whose init is new C(...) — dynamic class is exactly C
  2. anon-shape literals ({k: v}) and {} builder sites, which lower to Expr::New { class_name: "__AnonShape_…" }
  3. 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

  1. Case (A) reports Ptr<Shape> 1 selected and its loop body emits no js_object_get_field_by_name_f64, no js_typed_feedback_class_field_get_guard, and no js_number_coerce for ps[i].x.
  2. Case (B) does not regress.
  3. 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.
  4. --opt-report on 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.
  5. 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

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions