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
{{ message }}
Repository navigation
repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151
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. ArrayWalkadmits 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.
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:
constrows: Row[]=[];rows.push(newRow(…));f(rows[i]);// callee does x.extra = 1for(letk=0;k<rows.length;k++){consts=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 anyIndexGet 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.
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 --releaselibperry_{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
item 1 — close as served (record that the mechanism is the runtime
invariant, not containment).
item 2 — the only one with a material number (+7.7%), and the only one
neither mechanism reaches. It is #7034 §1 clone-and-route, as the issue
already says.
items 3 and 5 — ~1–1.5%; item 3's fix is in element_shape_loop.rs, not
in ptr_shape_elements.rs, and needs a var-vs-let distinction HIR does
not currently carry.
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).
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:
item 3 (let r = A[i]) — +1.5%, and the fix is in element_shape_loop.rs:626 (mutable: false), not in ptr_shape_elements.rs;
it needs a var-vs-let distinction HIR's Stmt::Let does not carry.
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.