Skip to content

repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151

Closed

Description

@proggeramlug

PR #7149 (#7034 §3) licenses const r = A[i] on an element-shape-proven local
array and promotes r. It deliberately covers one read form. The others
are listed here so the gap is a decision, not an oversight.

Covered

  • const r = A[i] under for (let i = 0; i < A.length; i++).
  • for (const r of A) — the desugar
    (lower/stmt_loops.rs::lazy_or_index_elem) produces exactly the above, so
    this is covered by the same proof rather than by a second one.

Not covered

1. Direct A[i].field with no binding. total += rows[i].weight.
ArrayWalk admits it (a declared-chain field read/write cannot transition
the shape, so it does not disqualify the array), but nothing promotes it:
FnCtx::ptr_shape_receiver_fact matches only Expr::LocalGet and
Expr::This. Extending it to Expr::IndexGet needs the fact keyed on the
site — (array root, index local) is a sound key given E5 already requires
the index local to have exactly one governing loop — and each consumption site
(property_get.rs, property_get/helpers.rs, property_set.rs,
instance_misc1.rs, lower_call/property_get/dynamic_dispatch.rs) needs the
receiver lowered per access, which is one array element read per access rather
than one reload from a slot. Worth measuring against just asking users to bind
the element.

2. A.map(cb) / A.forEach(cb) / A.reduce(cb, …) callback parameters.
This is the call argument position (#7034 §1) — it needs clone-and-route,
not a containment relaxation. It is where batch.ts's hot field traffic
actually lives (acc[idx] inside the reducer).

3. Element reads bound to a let. E1's mutable: false on the array and
the single-Let requirement on the element local are both conservative; a
let r reassigned only from A[i] is provable but not proven.

4. Non-empty array literals. const a = [new C(1), new C(2)] is refused
because a literal can carry elisions, whose slots read back as undefined and
would break E5's density argument. A literal with no holes is fine and could
be admitted.

5. Shape-preserving mutators. sort, reverse, slice, concat of two
proven arrays, and fill with a class-C value all preserve "every element
is an instance of C" but are refused outright by E2, which admits only
push. pop/shift/splice/unshift/IndexSet must stay refused — they
are what E5's in-bounds argument rests on.

Each is independently landable, and each should be measured before it is
built: on the corpus measured in #7149, the element position accounts for 1 of
746 Ptr<Shape> candidates in dependency JS, so widening the READ side is only
worth it where app-shaped TypeScript is the target.

Activity

  1. proggeramlug commented on Jul 31, 2026

    @proggeramlug
    ContributorAuthor

    Correction to the "Not covered" list, after review of PR #7149 found a soundness hole and closed it:

    Direct A[i].field is no longer merely unpromoted — it now DISQUALIFIES the array.

    The original wording said an unlicensed element read "cannot transition a shape, so it does not disqualify the array". That is true of the read and false of the reference it produces:

    const rows: Row[] = [];
    rows.push(new Row(…));
    f(rows[i]);                 // callee does x.extra = 1
    for (let k = 0; k < rows.length; k++) {
      const s = rows[k];
      total += s.weight;        // guard-free, fixed offset, on a reshaped object
    }

    const r = A[0] (literal index, so unlicensed) is the same hazard one step shorter: rule 2 never walks a binding the pass did not seed, so r.extra = 1 is invisible.

    So ArrayWalk now disqualifies the array on any IndexGet occurrence outside the licensed Stmt::Let init. Two red tests cover it (an_element_passed_to_a_callee_denies_the_array, an_unlicensed_element_binding_denies_the_array), each with its own sabotage case.

    Consequence for this issue: covering direct A[i].field is now worth more than it looked, because today it does not just fail to promote — it takes the whole array's group with it. Doing it needs the element class available inside ArrayWalk, which is only known after the push scan, so it wants a two-phase walk or a deferred check.

  2. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    All five forms are still uncovered as written — but item 1 is now served by a different mechanism, item 4's prize is a different bug, and only item 2 has a material number

    Measured on main @ 514ff7c9a, macOS arm64, --release compiler and
    --release libperry_{runtime,stdlib}.a
    , PERRY_RUNTIME_DIR pinned. Every
    probe does the same work — a 20,000-element
    C[] built by push, read 200 times, two field reads per element (4M element
    reads) — and every one prints the identical t: 80000000000. Instrument:
    /usr/bin/time -l instructions retired, medians of 3 interleaved reps.

    Baseline = the covered form, const r = A[i] under
    for (let i = 0; i < A.length; i++): Ptr<Shape> 2 selected / 2 consumed,
    15,800,121,583 instructions.

    # form Ptr<Shape> consumed instructions vs baseline
    1 direct A[i].field 1 — elements of 'arr' 15,748,743,593 −0.3% (parity)
    3 let r = A[i] 1 — r only 16,037,267,564 +1.5%
    2 A.forEach(cb) param 0 17,391,614,644 +10.1%
    4 non-empty literal 1 (see below) +33.9% on the build side
    5 sort then read 1 sort-dominated ≲1% of that fixture

    Source state (all five are still open as written)

    FnCtx::ptr_shape_receiver_fact still matches only LocalGet / This
    (expr/mod.rs:1829-1833); ArrayWalk's IndexGet arm still disq()s the
    array on any unlicensed element read (ptr_shape_elements.rs:939-946, with a
    comment naming this issue); E1 still requires !*mutable && items.is_empty()
    (:276); E2 still admits only ArrayPush.

    Item 1 is done — by the runtime invariant, not by #7034 §3

    The direct-read probe reports local 'elements of 'arr'' -> Ptr<Shape>, a row
    emitted by stmt/element_shape_loop.rs:957 — the #7480/#7496 element-shape
    invariant plus versioned loop clone (consumers #7701/#7702/#7771), which
    post-dates this issue. It reaches direct A[i].field, and it reaches arrays the
    static E1–E5 proof refuses: parameters, non-empty literals and sorted
    arrays
    . It is at parity with the covered form here.

    That reframes the whole "Not covered" list. In every probe above the clone is
    the load-bearing mechanism and the static element fact is worth 0–1.5% on
    top of it:

    • item 3's +1.5% is not an E1/E5 limitation at all — the clone's
      element-binding matcher requires mutable: false
      (element_shape_loop.rs:626), deliberately, because HIR's Stmt::Let carries
      only mutable: bool and cannot tell let from var (the skipped Let would
      leave a function-scoped var slot stale). The static r -> Ptr<Shape> fact
      still fires for the let form; what is lost is the clone.
    • items 4 and 5 lose only the static fact (r); the clone still fires
      (<local 9> / <local 11> rows), and their read-loop cost is within
      ~0.1 G of the baseline's.

    Item 4's 33.5% is not this issue — it is a #7469 push-path bug

    Build-only isolation (identical 4M pushes, read loop removed): empty literal
    15,518,553,273 vs two-element literal 20,778,922,832 = +33.9%, i.e.
    the entire delta is on the build side. The nm -u diff between the two
    objects is exactly one symbol: _js_gc_note_slot_layout.

    Root cause, from source: js_array_declare_all_pointer_elements
    (perry-runtime/src/array/header.rs:1503) early-returns on
    if (*arr).length != 0. emit_all_pointer_array_declaration runs in the
    Stmt::Let tail — after the literal's elements are stored — so for any
    non-empty literal the declaration is a silent no-op, the per-slot side mask
    the element stores created survives, and every later arr.push fails the
    elided-push header test (_reserved == SIDE_MASK | ALL_POINTERS). Meanwhile
    collectors/all_pointer_arrays.rs explicitly admits such literals and has a
    unit test for it (literal_of_object_elements_is_admitted) — the proof is
    issued and then discarded at runtime.

    Scope-checked as array-local, not program-global: with the same two-element
    literal present but the 4M pushes going into a second array, the literal costs
    nothing (17,041,249,447 vs 16,751,737,812 on perry-dev artifacts — the literal
    arm is marginally faster). Filed separately with a fix sketch.

    Suggested re-scoping

    Unmeasured: x86-64; --profile dist; item 5's read-side loss could not be separated from the
    sort's ~2.2 G cost, so it is bounded rather than measured; dependency JS (this
    issue already records the element position at 1 of 746 there).

  3. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    Closing and re-scoping, per the measurement above. The five forms are all still
    uncovered as written, but their costs are now known and they do not belong in
    one issue:

    Method, full tables and the probes are in the comment above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions