Repository navigation
Description
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]underfor (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
proggeramlug commentedon Jul 31, 2026
Correction to the "Not covered" list, after review of PR #7149 found a soundness hole and closed it:
Direct
A[i].fieldis 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 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, sor.extra = 1is invisible.So
ArrayWalknow disqualifies the array on anyIndexGetoccurrence outside the licensedStmt::Letinit. 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].fieldis 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 insideArrayWalk, which is only known after the push scan, so it wants a two-phase walk or a deferred check.proggeramlug commentedon Aug 14, 2026
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,--releasecompiler and--releaselibperry_{runtime,stdlib}.a,PERRY_RUNTIME_DIRpinned. Everyprobe does the same work — a 20,000-element
C[]built bypush, read 200 times, two field reads per element (4M elementreads) — and every one prints the identical
t: 80000000000. Instrument:/usr/bin/time -linstructions retired, medians of 3 interleaved reps.Baseline = the covered form,
const r = A[i]underfor (let i = 0; i < A.length; i++):Ptr<Shape>2 selected / 2 consumed,15,800,121,583 instructions.
Ptr<Shape>consumedA[i].fieldelements of 'arr'let r = A[i]ronlyA.forEach(cb)paramsortthen readSource state (all five are still open as written)
FnCtx::ptr_shape_receiver_factstill matches onlyLocalGet/This(
expr/mod.rs:1829-1833);ArrayWalk'sIndexGetarm stilldisq()s thearray on any unlicensed element read (
ptr_shape_elements.rs:939-946, with acomment naming this issue);
E1still requires!*mutable && items.is_empty()(
:276);E2still admits onlyArrayPush.Item 1 is done — by the runtime invariant, not by #7034 §3
The direct-read probe reports
local 'elements of 'arr'' -> Ptr<Shape>, a rowemitted by
stmt/element_shape_loop.rs:957— the #7480/#7496 element-shapeinvariant plus versioned loop clone (consumers #7701/#7702/#7771), which
post-dates this issue. It reaches direct
A[i].field, and it reaches arrays thestatic 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:
+1.5%is not anE1/E5limitation at all — the clone'selement-binding matcher requires
mutable: false(
element_shape_loop.rs:626), deliberately, because HIR'sStmt::Letcarriesonly
mutable: booland cannot tellletfromvar(the skippedLetwouldleave a function-scoped
varslot stale). The staticr -> Ptr<Shape>factstill fires for the
letform; what is lost is the clone.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 -udiff between the twoobjects 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 onif (*arr).length != 0.emit_all_pointer_array_declarationruns in theStmt::Lettail — after the literal's elements are stored — so for anynon-empty literal the declaration is a silent no-op, the per-slot side mask
the element stores created survives, and every later
arr.pushfails theelided-push header test (
_reserved == SIDE_MASK | ALL_POINTERS). Meanwhilecollectors/all_pointer_arrays.rsexplicitly admits such literals and has aunit test for it (
literal_of_object_elements_is_admitted) — the proof isissued 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-devartifacts — the literalarm is marginally faster). Filed separately with a fix sketch.
Suggested re-scoping
invariant, not containment).
neither mechanism reaches. It is
#7034 §1clone-and-route, as the issuealready says.
element_shape_loop.rs, notin
ptr_shape_elements.rs, and needs avar-vs-letdistinction HIR doesnot currently carry.
bug above.
Unmeasured: x86-64;
--profile dist; item 5's read-side loss could not be separated from thesort'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).
proggeramlug commentedon Aug 14, 2026
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:
A[i].field) — served, at parity (−0.3%), by theruntime element-shape invariant + versioned loop clone
(
stmt/element_shape_loop.rs, repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480/perf(repsel): per-array homogeneous element-shape invariant (#7480) #7496/perf(repsel): the element-shape loop clone fires onarr.lengthand aliased element types (#7480) #7701/perf(typed-feedback): stop emitting recording calls into default builds (#7480) #7702/repsel: a proven element fetch is still a runtime call — specialize a[i] inside the element-shape guarded clone #7771), which post-datesthis issue and also reaches parameter arrays, non-empty literals and sorted
arrays.
map/forEach/reducecallback parameters) — the only materialone, +10.1%. Split out as repsel: array-callback parameters carry no element-shape fact — 10.1% vs the equivalent for-loop (#7151 item 2) #8103 with the measurement.
let r = A[i]) — +1.5%, and the fix is inelement_shape_loop.rs:626(mutable: false), not inptr_shape_elements.rs;it needs a
var-vs-letdistinction HIR'sStmt::Letdoes not carry.33.9% that shape actually costs is a perf(runtime): allocation path spends 34% of self time in _tlv_get_addr — 24× behind Node on object churn with the collector already idle #7469 runtime bug, filed as perf(gc): a non-empty array literal silently voids its #7469 all-pointer declaration — 33.9% on a push loop #8102.
Method, full tables and the probes are in the comment above.