Repository navigation
repsel: rule-1 provenance for unbound allocations — the 506 are 96% record literals, and Perry's own CJS IIFE is the wall (#7152 follow-up) #7170
Description
Follow-up to #7152, which measured that 506 of 746 Ptr<Shape> candidates in
dependency JS die at rule 1 (allocations never bound to a local) and concluded
"the dep-JS lever is rule 1, and its fix is anchoring the proof to an allocation
site rather than to a binding."
I re-measured on a second, independent slice of the same corpus and then
classified the population by mechanism rather than by position. The headline
number replicates. The reading of it does not survive, in three places, and the
correction changes what should be built first.
Short version: the dependency-JS wall is not a missing soundness argument. It
is Perry's own CommonJS wrapper — for the third time. #7139 found the wrap
preamble arming the rule-5 barrier in 100% of CJS modules; #7152 found __cjs_module
accounting for 100% of the rule-2 bare references; this issue finds that the
wrap's IIFE turns every module-level function into a closure, which makes
#7107's return-shape provenance structurally unreachable in CommonJS. Same
self-inflicted class, same kind of fix: recognition, not relaxation.
1. Method, and what in it is a proxy
Compiler arm. wt-7148 @ 6a3b5bff8 — the #7149 branch tip, tracked tree
clean, perry-dev binary. Caveat, stated because CLAUDE.md requires it: the
binary's mtime is ~90 s before the commit timestamp (built, then committed),
and I reused it rather than rebuilding — local disk fell from 26 GB to 12 GB free
during the session from other agents' builds. The tracked tree is clean at that
commit and the element rule is present in the binary; the risk is low for a
static census, and it is nil for the code-derived findings in §5–§6, which are
proofs from source, not samples.
Corpus. real-apps/scriptc/node_modules, every third __esModule .js
file by sorted path — 197 selected of 589, 196 producing a report.
perry compile <m>.js --opt-report=json --no-link, each module copied to its own
directory so it compiles natively rather than routing to the removed V8 fallback.
This is not #7152's exact 180-module selection, and I could not reconstruct
it. Treat the two as two samples of one corpus. They agree on the headline share
and disagree on the position mix — see §3.
Two measurement points, and they are not the same population. The
--opt-report buckets are measured on HIR after state_desugar,
deforestation, inline_functions, loop unrolling and the async/generator
transforms. My mechanism census is measured on source AST. Between them sit
transforms that move allocations across positions — inlining in particular turns
call arguments into bindings. Where the two disagree I say so and I trust the
compiler-side number for anything about what the analysis sees, and the
source-side number for anything about what dependency code actually contains.
Neither is a claim about any other corpus.
Nothing was timed. No runtime numbers appear below; the host was building for
other agents throughout. All evidence is static counts, emitted-IR facts, and
code.
2. Compiler census — #7152 replicates
196 modules, 1352 Ptr<Shape> candidates:
| bucket | count | share |
|---|---|---|
| rule 1 — allocated in expression position, never bound | 963 | 71.2% |
| rule 5 — module barrier still armed | 219 | 16.2% |
| rule 2 — containment | 149 | 11.0% |
| class admission | 7 | 0.5% |
Outcomes across the whole corpus: denied 1338, selected 3, consumed 11.
Only 5 of 196 modules (3%) get any promotion at all. 58 of 196 (30%) still
carry an armed rule-5 barrier after #7139. Of the 149 rule-2 denials, 140 are
__cjs_module — #7152's finding, replicated.
Rule 1 at 71.2% vs #7152's 67.8% — the headline is solid.
Three facts #7152 did not report, all from the same records:
- 95.6% of unbound allocations are object literals, not
new C(...)—
921 of 963. The 42 real constructors are led bynew Promise(23), which is a
native base and would fail class admission even with perfect provenance. This
population is record literals, not class instances. - 91.6% sit in
closureregions — 882 of 963 (method 46, function 34,
module-init 1). §6 explains why, and it is the whole story. - 92.2% are at loop depth 0 and only 20 of 963 are in a per-element
callback. By the report's own hotness proxy this population is cold.
3. The mechanism distribution (the deliverable)
Source-AST census over the same 197 modules, counting allocations that are not
the direct initializer of a variable declaration — i.e. exactly what
ptr_shape_report.rs::unbound_new_sites looks for — restricted to closed-shape
object literals, the 95.6% that Perry lowers to Expr::New{__AnonShape_N}.
n = 1651.
| true syntactic consumer | count | share |
|---|---|---|
| call argument | 538 | 32.6% |
→ Object.defineProperty descriptor |
265 | 16.1% |
| → bare-identifier callee | 149 | 9.0% |
| → method call | 85 | 5.1% |
→ IIFE / super / Object.freeze / assign |
39 | 2.4% |
| nested inside another object literal | 404 | 24.5% |
| array element | 167 | 10.1% |
| return | 146 | 8.8% |
| assign to an identifier (reassignment) | 100 | 6.1% |
| store to a property | 96 | 5.8% |
| conditional branch | 61 | 3.7% |
| default parameter value | 55 | 3.3% |
| binary operand | 39 | 2.4% |
constructor argument (new C({...})) |
33 | 2.0% |
| yield / class-field init / member access | 12 | 0.7% |
Of the 96 property stores, 44 target exports, 12 target this, 4 target
module; the rest are scattered. Of the 538 call arguments, 265 are
defineProperty descriptors — the __esModule marker and the re-export
forwarders, one or more per transpiled module.
Grouped by what a fix would have to be:
| tier | mechanism | count | share |
|---|---|---|---|
| T1 | the object becomes a component of another object (nested literal, array element, property store) — heap-resident by construction | 667 | 40.4% |
| T2 | consumed by an opaque runtime callee (Object.defineProperty/freeze/assign) |
279 | 16.9% |
| T3 | flows into a same-program callee — parameter-shape territory | 292 | 17.7% |
| T4 | returned to a caller — return-shape territory | 146 | 8.8% |
| T5 | bound, but by a shape rule 1 rejects (reassignment 100, conditional join 61) | 161 | 9.8% |
| T6 | other (default parameter, binary operand, yield, …) | 106 | 6.4% |
Sampling caveat. The top 5 modules contribute 42.5% of all sites (median
per module: 3). This is a bundle-heavy corpus — TypeScript, Vite chunks, ESLint.
The distribution is a proxy for "transpiled CJS dependency code", not for JS
generally.
4. The leads in the brief, answered from the data
require()results — zero. Not one unbound literal is bound from a
require()call in this corpus;requireappears as a callee of literal
arguments, never as a producer of one.- conditionals — 61 of 1651 (3.7%). Perry's own
initializerbucket is
47 of 963 (4.9%). Real, small, not the lever. - property gets — do not appear as a producer of these allocations at all,
and §5.3 shows seeding from them is actively dangerous. - method calls — 85 of 1651 (5.1%) as consumers of an unbound literal; as
producers they are the residue perf(repsel): return-shape facts — Ptr<Shape> survives the return escape (#7034 §4) #7107 cannot reach (§6).
5. Three corrections to #7152's reading
5.1 "constructor argument 182" is not new Outer(new Inner()) — it is nested object literals
A closed-shape literal lowers to new __AnonShape_N(v0, v1, …) whose
constructor arguments are the property values
(perry-hir/src/lower/expr_object.rs:632, perry-hir/src/dynamic_import.rs:1615).
ptr_shape_report.rs::scan_expr then labels every argument of any Expr::New
"constructor argument". So {a: {b: 1}} files the inner literal under
constructor argument.
At source level, genuine new C({...}) arguments are 33 of 1651 (2.0%) while
nested literals are 404 (24.5%). #7152's second-largest bucket is
overwhelmingly the nested-literal case.
This matters because a nested literal is not an independent promotion
opportunity. It is a field value of a parent allocation that is itself unbound.
Proving its shape licenses nothing on its own.
5.2 The return bucket is already served on the producer side — the denial over-counts
report::deny_alloc_site is called unconditionally at the top of
collect_shape_proven_ptr_locals (ptr_shape.rs:311), before any seeding runs,
and again on every early-bail path (report_early_bail, :274). It has no
knowledge of what the later passes do.
Meanwhile ptr_shape_returns.rs:169-188 already admits a bare
return new C(...) / return {…} with no local at all as a return-shape
producer.
So a return {a, b} site is reported as a rule-1 denial while simultaneously
feeding an existing mechanism. The 506 / 963 is a count of syntactic sites,
not of missed opportunities, and it is inflated by at least the served part of
the return bucket plus the entire T1/T2 population that no provenance rule could
ever want. Any scheduling decision taken off that number is taken off a number
that is not measuring what it appears to.
5.3 Widening the seed to PropertyGet would silently break #7139
#7139's exemption is sound on two legs, and the first is:
The target is not a candidate. … The third clause admits only initializers
that are not fresh allocations at all — a field read, a function value, or
nothing — so an exempted target can never be promoted, and, being a
whitelist, it stays true if the seed set widens.
—collectors/cjs_scaffolding.rs:47-55
init_is_never_a_seed's whitelist is exactly PropertyGet / Closure /
Undefined / absent. And the wrap template
(compile/cjs_wrap/wrap.rs:758-760) binds
const __cjs_module = { exports: {} };
var module = __cjs_module;
var exports = __cjs_module.exports; // ← PropertyGetSo exports is bound by a PropertyGet, and
Object.defineProperty(exports, "__esModule", …) is the site #7139 exempts from
arming the module barrier. Seed rule 1 from PropertyGet and exports becomes a
promotable candidate whose mutator no longer arms the barrier — the first leg is
gone, and only rule-2 containment (the "belt" that rule 5 is the "braces" for)
stands between that and an unsound promotion.
Binding constraint for every increment below: seeding from PropertyGet or
Closure requires re-deriving or reverting #7139 in the same PR. Seeding from
Call (#7107's existing widening) and from New in expression position do
not touch the whitelist, because neither is in it.
6. ★ The decisive experiment: Perry's own CJS IIFE is the wall
Same source body, compiled three ways, --opt-report=json:
| probe | wrapper | result for const p = mk(i) |
|---|---|---|
p7_esm_store.ts |
none | region function — selected, and consumed via ptr_shape_get_number |
p8_iife.ts |
(function(){ … })() |
region closure — not even a candidate |
p1_direct.js |
CJS (exports. + defineProperty) |
region closure — not even a candidate |
p6_cjs_nodp.js |
CJS, no defineProperty |
region closure — not even a candidate |
function mk(i) { return { a: i, b: i + 1 }; } // producer: bare `return {…}`
function run(n) {
let s = 0;
for (let i = 0; i < n; i++) { const p = mk(i); p.a = p.a + 1; s += p.a + p.b; }
return s;
}p8 differs from p7 only by the IIFE. p6 differs from p1 only by the
defineProperty, and behaves identically — so this is not the rule-5 barrier
and not #7139's territory. It is the wrapper.
Mechanism. cjs_wrap emits every CommonJS module body inside an IIFE
(wrap.rs:889, {cjs_preamble}\n\n{body_for_iife}, "reproduces the historical
IIFE text byte-for-byte"). Inside it, mk is a closure, not an entry in
hir.functions. #7107's consumer is
Stmt::Let { init: Expr::Call { callee: Expr::FuncRef(id) } }
(ptr_shape_returns.rs:348-369) — a bare Expr::FuncRef and nothing else. A
closure callee is not a FuncRef, so the seed never fires.
That is why 91.6% of dependency-JS alloc sites are in closure regions. It is
one cause, it is Perry's own, and it makes the entire return-shape mechanism —
the one mechanism that already handles unbound allocations — unreachable across
the CommonJS ecosystem.
6.1 …and even where it fires, scalar replacement usually wins
p4/p5 (ESM, same code without the in-loop field store) report
selected and then unconsumed — scalar_replaced: escape_news deleted the
object outright, so the Ptr<Shape> proof emitted nothing. Only p7, which adds
one in-loop field store, is consumed.
This is mechanism 3 from the plan's "a promotion goes unconsumed three ways",
measured on exactly this idiom. The benefit of any return-bucket work must be
measured on shapes that survive scalar replacement, or it will report promotions
that emit no bytes.
7. Composition — settled, not deferred
Scalar replacement (escape_news.rs, #7115): cannot collide at the seed.
All three of its candidate finders match only a Stmt::Let-bound allocation
(escape_check.rs:23-29, escape_objects.rs:219-252, escape_arrays.rs:351-380).
Expression-position allocations — the entire 963 — are never scalar-replacement
candidates. The collision surface is downstream: if an increment produces a
bound local (as #7107's does at the caller), it lands in scalar replacement's
territory, and let_stmt.rs checks ctx.scalar_replaced first, in file order,
at both the Let (:773-828) and every PropertyGet (property_get.rs:715-804,
comment at :786). The existing tie-break already logs the loser
(note_ptr_shape_scalar_replaced, let_stmt.rs:1973-1994). Requirement for any
new rule: consult non_escaping_news, or be consumed at a site that checks
ctx.scalar_replaced first — otherwise it reproduces the known dead-fact pattern.
Deforestation (#7150): no interaction with object provenance, at all.
Its seed is hard-coded to Expr::Array(elems) with elems.is_empty()
(deforest/detect.rs:206-208); there is no Expr::New/object variant anywhere in
detect.rs, producer_rewrite.rs or call_sites.rs. It is also an HIR pass
run from collect_modules.rs:1914, finishing for every module before
compile_module (run_pipeline.rs:4305) runs any collector. #7150 is a real
conflict with #7149's array rule (E1/E2) and is not a constraint on object
provenance. This closes the question the brief asked me to answer up front: for
the work below, there is nothing to compose with.
#7139's whitelist: §5.3. Call and New seeds are safe; PropertyGet and
Closure seeds are not.
GC contract (plan + #6990), and it is the same for every increment below.
None of them introduces a new pointer position. Every promoted value stays a
NaN-boxed slot bound by js_shadow_slot_bind; the raw pointer is region-local SSA
re-derived from that bound slot at each access; TaPtr's callee-side no-bind
shortcut is never copied (GC_TYPE_OBJECT is movable); write barriers on stores
are untouched. Increments R1/R2 promote a caller-side local that already
exists and already has a slot — they change which lowering reads it, not how it
is rooted. R3/R4 would introduce values with no binding, and that is where the
contract needs new argument; it is one of the reasons they rank last.
8. Ranked increments
R0 — instrument: stop reporting already-served and structurally-dead sites as rule-1 denials
Report-only; no emitted byte changes.
- What. Suppress the alloc-site denial when the site is (a) in return
position of a function that carries a return-shape fact — already served; or
(b) a property value of another unbound allocation — a component, never an
independent candidate. Optionally split the"constructor argument"label into
nested object literalvsnew C(arg), which today are merged (§5.1). - Admits. Nothing. Removes ≈ the served part of 201 return + the nested share
of 197 constructor-argument denials from a number that is currently used to
schedule work. - Soundness. N/A — the collector's returned facts are untouched; recording
happens next to thecontinue, never instead of it. - GC. Unchanged.
- Composition. None.
- Measurement. Rule-1 count falls on the dependency corpus;
repsel-census
fixture counters unchanged; a byte-identical-object A/B proves it is report-only.
Do this first. §5.1 and §5.2 are both cases of a number meaning something
other than what it looks like, and #7152 and this issue were both scheduled off
that number. This is CLAUDE.md failure mode 4 in the instrument itself.
R1 — ★ make #7107 reachable inside the CJS wrapper
- What. Two options; (b) is the smaller change.
(a) Havecjs_wraplower the module body so that top-level
functiondeclarations remainhir.functionsentries rather than closures.
(b) Extend perf(repsel): return-shape facts — Ptr<Shape> survives the return escape (#7034 §4) #7107's consumer to accept a callee that isLocalGet(id)
whereidis bound by exactly oneStmt::Let { init: Expr::Closure },
never reassigned and never written through a capture — and the corresponding
producer-side extension so such a closure is analysed as a producer. - Soundness sketch. The consumer's requirement is "the callee names one
statically-known function" (ptr_shape_returns.rs:360-362: "Anything computed
could be rebound"). A local bound by exactly oneLetto a closure literal and
never reassigned names one statically-known function by the same argument that
Expr::FuncRefdoes — the single-Letcondition is the identical test rule 1
already applies to object provenance. Everything else in perf(repsel): return-shape facts — Ptr<Shape> survives the return escape (#7034 §4) #7107 is unchanged:
the producer proof, the class agreement across returns, the module-barrier kill. - Admits. The return bucket, end-to-end, for CommonJS. Source-level: 146
return-position literals, of which 91 are in a named function decl/expr and
43 already have ≥1 same-moduleconst x = f(…)call site — that 43 is the
count that would promote today if the wrapper were not in the way. It also
unblocks perf(repsel): return-shape facts — Ptr<Shape> survives the return escape (#7034 §4) #7107 for the ~98.8% of dependency modules perf(repsel): exempt CommonJS module scaffolding from the Ptr<Shape> rule-5 barrier #7139 made rule-5-eligible,
whose regions are 91.6% closures. - GC. No new pointer position; the caller's local is an ordinary bound slot
(§7). - Composition. Does not widen the seed to
PropertyGet/Closure— the
seed staysLet { init: Call }, so perf(repsel): exempt CommonJS module scaffolding from the Ptr<Shape> rule-5 barrier #7139's whitelist is untouched (§5.3). It
does land in scalar replacement's territory at the caller, and §6.1 measured
that scalar replacement wins on the plain idiom: the acceptance fixture must
carry a field store, or the PR will report promotions that emit nothing. - Measurement. Opaque
js_*call count in a CJS-wrapped fixture, before
and after, with call sites checked in the emitted IR — not an--opt-report
counter (that is exactly what §6.1 shows lying). Plus dependency-corpus
consumed, today 11 across 196 modules.
R2 — return-shape consumers beyond the direct call
- What. Method-call producers (
obj.m()) and cross-module producers. - Soundness. Method calls need dispatch stability, which rule 4 already has
machinery for. Cross-module is structurally blocked, not merely
unimplemented: imported callees lower toExpr::ExternFuncRef { name },
which carries noFuncId, soreturn_shape_functions'sHashMap<u32, String>
cannot be keyed for them. It needs a name-keyed fact table plus a module
ordering guarantee. - Admits. 85 method-call consumers at source level (5.1%); cross-module is
unmeasured (§10). - Rank. After R1, and only if R1's measurement shows the return bucket pays.
R3 — parameter-shape facts (all-call-sites-agree)
- What. The mirror of perf(repsel): return-shape facts — Ptr<Shape> survives the return escape (#7034 §4) #7107: a parameter is shape-proven when every call site
passes a provably-fresh allocation of one class. perf(codegen): repsel Phase 5a — Ptr<Shape> proventhisin methods #6925's clone-and-route is the
precedent. - Admits. T3 = 292 (17.7%) — the second-largest tier. Note this is the
inbound direction; repsel: dependency JS is walled by rule 1 (unbound allocations), not containment — 506 of 746 candidates #7152 correctly de-prioritised the outbound call-argument
escape at 5/746, and these are not the same thing. - Why it still ranks below R1. The benefit accrues in the callee, so the
measurement must be callee-side, and the population is dominated by
options-bag arguments whose most common fate isthis.x = arg— a heap store,
which licenses nothing without T1 also being solved. A benefit story is
required before this is scheduled, per perf(repsel): refuse canonical i32 when every hot consumer wants a double (#7128) #7132/repsel: #7132's profitability refusal over-fires on x86-64 (fccmp-fusion term is AArch64-specific) — cost model needs a per-target term #7146. - GC. Introduces a proven value with no binding of its own; the contract needs
a fresh argument here, not a restatement of R1's.
R4 — heap-position provenance (T1) — do not attempt incrementally
T1 is 667 sites, 40.4% — the largest tier by a wide margin. Every one is "the
allocation becomes a field or element of another object". Containment is defined
as the object is unreachable from anywhere except this local; T1 is the negation
of that premise. No relaxation of rule 2 reaches it. This is what a self-describing
header and a shape tree buy, and it is the substance of §9.
R5 — the #7142 fallback tier
Where a static proof cannot reach — T3's non-agreeing call sites and any T1 access
— an inline keys-token guard is the right tier: it pays where the receiver has no
static class, which is precisely the T1/T3 shape. It is a runtime check, so it
composes with none of the above and needs no provenance at all. Sequence it after
#7142 lands and reports its own prize.
9. Does this change the #6759 calculus? — Yes, decisively, for dependency JS
The standing fork is "containment relaxation vs V8-model rebuild", and the plan
says each opened position tells you how much that escape shape was costing. This
distribution is the answer for dependency code:
- 40.4% (T1) is allocations that become part of another object. Containment
cannot reach these by construction — not "not yet", but by the definition of
the proof. - 17.7% (T3) needs interprocedural agreement across all call sites.
- 16.9% (T2) is
definePropertydescriptors — never promotable and never
worth promoting. - 8.8% (T4) is the return bucket, and §5.2/§6 show it is already served by an
existing mechanism that Perry's own wrapper disables.
So: containment relaxation is finished as a dependency-JS lever. #7149 opened
the array-element position and got 1 of 746 there; #7152 showed call-argument
is 5 of 746; this issue shows the remaining tractable position (return) is not a
missing proof at all. After R0 and R1 there is no third containment position worth
opening for this population.
That is not an argument to start #6759 now. The honest reading is:
- The cheap wins left in dependency JS are all Perry's own scaffolding —
perf(repsel): exempt CommonJS module scaffolding from the Ptr<Shape> rule-5 barrier #7139 (done), repsel: dependency JS is walled by rule 1 (unbound allocations), not containment — 506 of 746 candidates #7152's__cjs_module(in flight), and R1's IIFE. None of them is
a soundness relaxation; all are recognition. Finish them, because they are
nearly free and because each currently makes the corpus look harder than it is. - Then the fork is real, and it is between R5 (repsel Phase 5a: route the class-id dispatch tower to __pshape under an inline keys check (batch.ts: 4 guard calls + 3 by-name field calls per call) #7142's runtime tier) and
Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 — not between Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 and more containment positions. R5 is the cheaper
probe of the same hypothesis ("make escaping not matter"), and it produces a
measured prize on the T1/T3 population before anyone commits to the rebuild. - The app-TS population is different and this does not speak for it. perf(repsel): open the array-element escape for
Ptr<Shape>(#7034 §3) #7149
pays on build-then-consume TypeScript. The fork should be decided per
population, and a decision to rebuild should not be justified with these numbers
alone.
The thing to avoid, per the plan, is drifting into #6759 after five incremental
PRs. This distribution says: two more incremental PRs (R0, R1), then measure R5,
then decide.
10. What I could not measure, plainly
- Anything about speed. No benchmark was run; the host was building for other
agents throughout and disk fell to 12 GB free. Every number here is a static
count, an emitted-IR fact, or a source classification. - Scalar-replacement survival rate over the 146 return sites. §6.1 shows it
wins on the plain idiom and loses when a field store is added, but the corpus
split needs a compiler-side counter that does not exist. This is the single
most important unknown for R1's benefit story and should be the first thing
R1's PR instruments. - Whether the source-AST distribution survives the HIR transforms. My faithful
emulation ofunbound_new_sitesover source gives array-element 167 where the
compiler reports 14, and call-argument 562 where it reports 190. Inlining,
desugaring and the generator transforms run in between. §3 is a statement about
what dependency code contains, not about what the collector sees. - repsel: dependency JS is walled by rule 1 (unbound allocations), not containment — 506 of 746 candidates #7152's exact 180-module selection. Not reconstructable from the issue text;
§2's slice is a different sample. Headline share agrees (71.2% vs 67.8%),
position mix does not (my return share 20.9% vs 12.6%; constructor-argument
20.5% vs 36%) — consistent with §5.1, since the constructor-argument bucket is
really a nested-literal bucket and nesting depth varies by bundle. - Cross-module return-shape reach. R2's cross-module half is blocked
structurally; I did not measure how many producers live in a different module
from their consumers. - x86-64. Everything here is macOS arm64.
- Whether
hir.functionscontains closures, which decides whether R1(b) needs
a producer-side change as well as a consumer-side one. The code did not settle
it and I did not trace closure call-site lowering.
Reproduction
Census: perry compile <module>.js --opt-report=json --no-link on every third
__esModule .js in real-apps/scriptc/node_modules (197 of 589), each copied
to its own directory. The report goes to stderr, not stdout. Mechanism census:
TypeScript 5.9.3's parser, emulating ptr_shape_report.rs::scan_stmts/scan_expr
context propagation, counting allocations that are not a variable declaration's
direct initializer. Probes p1–p8 in §6 are 8–10 lines each and are the
load-bearing evidence; they need no corpus.
Activity
proggeramlug commentedon Aug 1, 2026
R0 done — the corrected distribution, and the wall number that replaces 506/963
PR #7176. The instrument's three defects are fixed with liveness fixtures and a
sabotage matrix; this comment is the re-derivation R0 asked for.
The headline: the correction runs the other way. §5.2 expected the served
part of the
returnbucket to be a material over-count. It is 4 sites of1842. The distortion that actually mattered was the per-function
de-duplication, and it was hiding rows: the rule-1 wall is 2.2x larger than
this issue reported, not smaller.
1. Method, and what is pinned
Both arms built from
b3d0541dd(currentmain, i.e. on top of #7171'sscaffolding suppression),
--profile perry-dev,-p perry -p perry-runtime-static -p perry-stdlib-static, one target dir per arm insequence. The arms differ only by #7176.
Corpus: §2's method exactly — every third
__esModule.jsinreal-apps/scriptc/node_modulesby sorted path, 197 of 589 (the 589replicates), each copied to its own directory,
perry compile <m>.js --opt-report=json --no-link. 196 produce a report inboth arms; the same one module fails in both
(
@vitest/utils/dist/chunk-_commonjsHelpers.js).Measured once before a rebase onto #7105 + #7175 and once after. Every number
below is identical across the two, so nothing here rests on a mixed baseline.
Nothing was timed. All of this is static counts and emitted-IR facts.
2. The wall
The number that replaces 506/963 is 1838 unserved rule-1 allocation sites of
2028
Ptr<Shape>candidates (90.6%).selectedis 3 andconsumedis 11 in both arms, on every module —identical to §2, and the proof that this is report-only.
deniedis the onlycolumn that moves, and every one of the 1005 extra rows is an allocation site
the dedup was collapsing.
Promotion reach is unchanged and worth restating: 1 of 196 modules gets any
Ptr<Shape>promotion at all, and 46 of 196 still carry an armed rule-5barrier.
3. The corrected distribution (n = 1842 allocation sites)
statementobject literal property valuecall argumentreturn(unserved)array elementinitializerconditionconstructor argumentreturn(served, #7107)Against base, position by position:
statementcall argumentreturnarray elementconstructor argumentinitializerconditionGrouped the way §3 grouped them, on the compiler-side instrument:
object literal property value+array element)call argument)statement)initializer,condition, real ctor arg, served)T1 is the largest classified tier, replicating §3's 40.4% on a different
instrument and a different population. §9's reading survives: containment
cannot reach T1 by construction.
4. Why the served correction is 4 and not ~200 — §6 replicated
closuremethodfunctionmodule-init§2 measured 91.6% closures; the corrected instrument says 92.9%. Only 62 of
1842 unbound allocations are in a
functionregion at all, and a return-shapefact can only exist for one of those —
collect_return_shape_functionsiterateshir.functions, and the caller-side seed requires a bareExpr::FuncRefcallee, which a closure call never is.
So §5.2's correction is real but tiny, and §6's diagnosis is strengthened rather
than weakened: the CommonJS IIFE is the wall, and it is upstream of every
return-shape question. R1 remains the ranked next increment, and its ceiling
is now measured — 323 unserved return sites, of which only the ones R1 moves out
of
closureregions can ever be reached.Stated precisely: "served" means the fact was issued for the enclosing
function. Whether a caller consumed it is a different question with a different
counter, and this measurement makes no claim about it.
5. §5.1, measured on the compiler side
new C(arg)The old label was 98.8% not constructor arguments. §5.1's reading holds and
is understated.
6. A fourth defect, measured, not fixed
statementis now the largest bucket — 455 of 1842, 24.7% — and it is acatch-all, not a position. It is
scan_stmts' default context, whichwalk_expr_childrenpropagates through every expression the walk has no armfor. §3's source census found, inside what is now this bucket: property stores
(96), reassignments (100), conditional branches (61), default parameter values
(55), binary operands (39). All indistinguishable today.
That is the same failure class this issue is about — a bucket a scheduler reads
as a mechanism when it is really "unclassified" — and it is now the single
largest one. It is deliberately not fixed in #7176: adding four more buckets
without the liveness fixture and sabotage arms the three above carry would
reintroduce exactly the counter-that-can-be-dead problem. Worth filing as R0b.
7. What the instrument now reports directly
summary.by_ruleandsummary.by_alloc_contextare computed by the compiler,and
alloc_contextsis written intobenchmarks/repsel_census/baseline.jsonper workload. The next re-derivation is a file read, not a
jqline in an issuecomment — which is how
506 / 963outlived the measurement it came from.summary.masked_by_dedupreports what the drain still collapses (29 rowscorpus-wide, all real double-lowerings), so "the dedup is not hiding anything"
becomes a number rather than an assumption.
Byte-neutrality is on
--trace llvmover 30 dependency modules with asame-compiler control, both arms run under
--opt-report(the only mode inwhich any of the changed code runs): identical 29, DIFFERENT 0, control-failed
0. The harness reports
DIFFERforPERRY_PTR_SHAPE_LOCALS=0against the samebase binary, so it is not vacuous. Details and the three run-identity
normalisations are in #7176 §4.
8. Unmeasured, plainly
The report separates issued from consumed for locals, not for producers.
"single most important unknown for R1's benefit story" still has no counter,
and R0 did not add one.
statementbucket's internal split matches §3's sourcecensus. §6 above is a size, not a breakdown.
perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedu…
fix(repsel): return is a POSITION, not anywhere under a returned expr…
proggeramlug commentedon Aug 1, 2026
Correction to the R0 distribution: the
returnbucket was over-stated by 28%Re-derived after review of #7176 found a fourth instrument defect, in the same
family as the three R0 was written for. The wall number stands; the number I
handed R1 does not.
What was wrong
RETURNwas set once, atStmt::Return(Some(e)), andscan_exprpropagatesits context unchanged through the fallback arm — only
New,Callargumentsand
Arrayoverride it. So every allocation nested inside a returnedexpression inherited the
returnlabel:None of these is the function's return value. The return-shape fact (#7107)
covers what the function hands back, not an operand of a conditional.
The corrected numbers
Both arms pinned at
5a06970b6, same 196 modules, same method.return, unservedreturn, servedreturntotalreturned expression operand§8's "R1's ceiling is 323 unserved return sites" should read 231. 88 sites
— 27% of that bucket — were operands that no return-shape work can reach,
because they were never in return position.
The rule-1 total is unchanged at 1838 of 2028 (90.6%): the 88 never left
rule 1, they left the
returnbucket. So the headline replacing 506/963 standsexactly as posted; only the per-position split moves.
Full corrected distribution (n = 1842)
statement(catch-all, §6 above)object literal property valuecall argumentreturn(unserved)array elementreturned expression operandinitializerconditionconstructor argumentreturn(served, #7107)Tiers, restated: T1 (component of another object) 591 / 32.1%, T3 (call
argument) 399 / 21.7%, T4 (returned, unserved) 231 / 12.5%, unclassified
455 / 24.7%.
Region split is unchanged and still the reason the served count is 4: closures
1711 (92.9%), functions 62 (3.4%).
Why this is worth its own comment
It is the third time in this chain that a bucket meant something other than its
label, and the second time a number derived from one was published as a
scheduling input. The instrument now computes the buckets itself
(
summary.by_alloc_context), the census floors the full(analysis, position, rule)tuple rather than position and ruleindependently, and
fixture_alloc_buckets.tsputs one allocation in each — sothe next such merge takes a gate red instead of reaching an issue comment.
Also worth recording, because it nearly produced a wrong result here: a later
byte-neutrality run reported 13 DIFFERENT for a report-only patch.
mainhad advanced five commits between the rebase and the base build, one changing
stmt/loops.rssafepoint emission. The base arm must be built from thebranch's own merge-base, not from a moving
origin/main. Rebuilt pinned:29 identical, 0 DIFFERENT, 0 control-failed, and
PERRY_PTR_SHAPE_LOCALS=0still reports
DIFFERthrough the same normalisations.perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedu…
proggeramlug commentedon Aug 1, 2026
The instruction account for
benchmarks/app-patterns/kernels/batch.tsHeadline: 57% of this program is GC bookkeeping for collections that never run, and the
.sort()that the fixture treats as one line is 69.8% of it — 2,597 retired instructions per comparison. The predicted top cost (js_closure_call2underjs_array_sort_with_comparator) is refuted as a mechanism and confirmed as a location: the sort is the program, but closure dispatch costs 0.00% —ComparatorCallresolves a direct function pointer once and never entersjs_closure_call2again. What the sort actually pays for is the write barrier and layout side-table fired by its own element moves, which no static census can see because they are inside the runtime's merge, not in the user's comparator.1. Method, and what is a proxy
5f5006aa6, tracked tree clean, release build (perry+libperry_runtime.a+libperry_stdlib.aall from this tree)6.12.47+rpt-rpi-2712,perf 6.12.94setarch -R), pinned to core 2,PERRY_NO_AUTO_OPTIMIZE=10.02at start →0.30at end (1-min avg), never above0.34; temp 56.5→60.9 °C,get_throttled=0x0throughoutnode v26.5.1 --experimental-strip-typesall printchecksum: 1601043.5000perf stat, 15 interleaved reps × 3 armsperf record -e instructions:u -c 150000, 22991/23020/22993 samples, 0 THROTTLE events, 99.85% instruction coverageperf record --call-graph fp -c 150000, 23007 samples, 0 THROTTLEA defect worth naming, because it would have silently biased the ranks. The first flat pass (
-c 40000, ~100k samples/s) hitkernel.perf_event_max_sample_rate=100000and logged 142 THROTTLE/UNTHROTTLE events.perfreports no lost records for these, so nothing in the output says the profile is incomplete — it just quietly covers 66% of the run, and throttling is wall-clock-scheduled, so the loss is not uniform across code. Every number below is from the re-recorded-c 150000runs withTHROTTLE == 0asserted. (The ranks happened to survive —classify_heap_generation18.39% throttled vs 19.03% clean — but that was luck, not method.perf report --statsshould be checked on every profile in this campaign.)Totals, 15 reps each:
batch(symbolized, profiled)batch(stripped)hello(startup baseline)Startup is 0.056% of the run, so nothing below needs a baseline subtraction. The stripped and symbolized binaries agree within the mode mix, so profiling the symbolized one is instruction-neutral.
The ~1% bimodality is real and clean. Pooling both batch arms (n=30) there are exactly two modes separated by a 29.1 M-instruction gap with nothing in between: 9 runs at 3,425.6 M and 21 runs at 3,457.9 M — a 0.93% delta. It is not binary-dependent (both arms produce both modes). The profile shows a matching effect: across the 3 clean flat runs
classify_heap_generationmoves 17.72 → 20.57% while the layout-map TLSwithmoves 8.56 → 6.00% in the opposite direction, and their sum is stable (26.28 / 27.06 / 26.57). Both symbols are hashed side-table lookups; the most likely cause is rehash/growth timing in the two maps shifting work between them. Stated as a hypothesis — I did not chase it.Pre-#7169 caveat. #7169 (route the class-id dispatch tower to the proven-
thisclone) landed after5f5006aa6and measured −0.16%. That cannot reorder rank 1 or ranks 4+. It is the same magnitude as the gap between ranks 2 and 3 (12.91% vs 12.75%), so treat those two as tied. Corroborating evidence for the caveat, from this build: the IR at5f5006aa6defines bothperry_method_batch_ts__Row__rescoreandperry_method_batch_ts__Row__rescore__pshape, but__pshapeis not in the linked binary at all — emitted, never called, dead-stripped. That is precisely the hole #7169 closed.GC census: zero collections.
PERRY_GC_DIAG=1andPERRY_GC_TRACE=1each emit 0 lines, and nomark_/sweep/collectsymbol appears anywhere in the profile. The only collector-side code present isincremental_mark_barrier_value(1.85%) andArenaObjectCursor::next_budgeted(0.03%). The heap never reaches the 64 MB trigger. Every GC instruction below is mutator-side bookkeeping for a collection that never happens.2. The ranked account by symbol class
Self-time, mean of 3 clean runs, × 3.4537 G instructions:
classify_heap_generation)layout_note_slot+ its TLS hashmap)batch.ts)js_*/perry_*helpersGC total (rows 1, 2, 3, 5, 11, 13) = 57.37% = 1.982 G instructions.
Call-graph inclusive, same run:
Per comparison. I instrumented a copy of the fixture with a counter: 927,985 comparator invocations (not 612k — that figure is
n·log₂nfor a pure merge sort; Perry's hybrid insertion-sorts 1250 runs of 32 first, which adds ~316k). Dividing the inclusive shares:sortcall3. (a) Opaque
js_*helpersAll
js_*symbols together are 16.06% of self-time. But self-time understates them badly — these are thin externs whose cost is in the runtime they call. Ranked by inclusive:js_array_sort_with_comparatorjs_rel_lt+js_rel_gta.key < b.key/a.key > b.keyjs_dynamic_string_or_number_addr.bucket + ":" + r.idjs_object_set_field_by_namejs_typed_feedback_observe_property_getjs_put_value_set_dyn_ic_missacc[idx]storejs_array_get_f64By self-time the top helpers are
js_shadow_slot_bind2.08%,js_jsvalue_equals1.78%,js_array_get_f641.00%,js_shadow_frame_enter0.98%,js_dynamic_sub0.80%,js_shadow_frame_pop0.75%,js_gc_temp_root_get0.73%,js_gc_temp_root_push0.65%,js_string_concat0.64%,js_gc_init_typed_shape_layout0.63%,js_typed_feedback_class_field_set_guard0.63%.Top 3 → lever
js_rel_lt/js_rel_gt, 9.81%. Chain:js_rel_lt → builtins::arithmetic::abstract_relational → rel_to_primitive → string::compare::utf16_cmp_bytes → str::from_utf8. Fully generic relational comparison on two values statically known to be strings. → none of the four levers. Needs a monomorphic string-vs-string relational form. (Note the fixture makes this path unusually hot by construction:value = (i%97)·0.75 + (i%7)has only ~679 distinct values across 40,000 rows, so thea.keytiebreak fires on most comparisons. Do not generalize the 9.81% to other workloads.)js_dynamic_string_or_number_add, 4.38%.r.bucket + ":" + r.idwherebucket: stringis declared. → none of the four. Needs static concat typing.js_object_set_field_by_name3.57% +js_put_value_set_dyn_ic_miss3.14%. The reducer'sslot.count = slot.count + 1onacc[idx]. → repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151 item 2 names this exact case ("A.map/forEach/reducecallback parameters … it is wherebatch.ts's hot field traffic actually lives (acc[idx]inside the reducer)"), and Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 for the store path generally.4. (b) Generated code — 9.57%
perry_closure_batch_ts__9sortcomparatorperry_closure_batch_ts__11reducereducerperry_closure_batch_ts__7mapcallbackbatch_ts__Row_constructornew Row(...)perry_method_batch_ts__Row__rescorer.rescore(1.5)batch_ts____AnonShape_e75d7e32e87dc826_constructor{key,value,tag}literalHot blocks ↔ guard/PIC shapes, from the traced IR (
--trace llvm, 11,338 lines):perry_closure_batch_ts__9is 114 basic blocks for a 5-line comparator. 72 arepget*and 32 arepic*— 104 of 114 (91%) are property-read machinery, for 8 source-level property reads (a.value,b.value×2 each;a.key,b.key×2 each). That is 13 blocks per read. Its call sites: 16×js_object_get_field_by_name_f64, 8× each ofjs_typed_feedback_{record_guard_pass,record_guard_fail,record_fallback_call,observe_property_get,object_get_field_by_name_f64},js_object_get_field_ic_miss,js_throw_type_error_property_access— i.e. every read carries a full guard/miss/deopt fan-out.pget, 108put, 76pic, 54class_field_set, 36class_field_inline, 15class_field_get, 12class_field_get_number, 6ptr_shape_get_number.--opt-reporton this fixture at this commit:Ptr<Shape>2 selected / 3 denied of 5 candidates, specialized ABI 0 of 3,I32/U32/Str5 of 5 → 7 unboxed, 6 boxed. One of the twoPtr<Shape>wins (totals) is selected but not consumed — dropped bymodule_init_context(repsel: module-init / program-entry bodies are excluded from canonical i32+Str selection, so a top-level hot loop promotes nothing #7109), so it is a counted win that emitted nothing.Top 3 → lever
perry_closure_batch_ts__9, 8.21% self / 29.63% incl.aandbaresortcallback parameters — the "call argument position, needs clone-and-route" case. → repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151 item 2 for the receiver fact; Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 shape tree to collapse the 32picblocks into a shape check. Note repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151 also lists item 5 (sortas a shape-preserving mutator that E2 refuses) — relevant to this fixture but not to this cost.perry_closure_batch_ts__7, 8.75% incl. Two separate denials in one function: the parameterr(→ repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151 item 2) and the returned object literal, which--opt-reportdenies under ptr-shape rule 1 with the text "allocated in expression position and never bound to alet/const… This is the.map(x => ({...}))idiom" (→ repsel: rule-1 provenance for unbound allocations — the 506 are 96% record literals, and Perry's own CJS IIFE is the wall (#7152 follow-up) #7170 R1, and repsel row 1: scoping Ptr<Shape> beyond locals — measured, promotion is 0 on the motivating workload; build --opt-report (#6952) first #7034 §4).batch_ts__Row_constructor5.25% incl +Row__rescore3.90% incl. Construction-time layout notes plus per-field typed-feedback guards. → Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 (self-describing headers make the layout note unnecessary). Partly perf(codegen): route the class-id dispatch tower to the proven-thisclone behind an inline keys check (#7142) #7169, already landed: the__pshapeclone existed here but was unreachable and dead-stripped.5. (c) GC — 57.37%
arena::page_meta::classify_heap_generationwrite_barrier_slot_inner(17.92 of its 28.12 incl)layout::layout_note_slotTLSHashMap<usize, LayoutSlotMask>withlayout_note_slotgc::barrier::mark_dirty_old_pagewrite_barrier_slot_innerlayout::layout_note_slotRootedArrayElems::set(vianote_array_slot)RuntimeHandle::get_nanbox_u64hashbrown HashMap<usize,()> PtrHasher::insertmark_dirty_old_page(dirty-page set)RuntimeHandleScope::pushTLSwithgc::barrier::write_barrier_slot_innerRootedArrayElems::setgc::barrier::incremental_mark_barrier_valuejs_shadow_slot_bind/frame_enter/frame_popjs_gc_temp_root_{get,push,truncate}Top 3 → lever
classify_heap_generation, 19.03% (657 M instructions) — the single largest symbol in the program, by 2.3×. It is an address→generation lookup: a one-entry TLS cache, then a TLSHashMapof page ranges. It is called from the write barrier, which fires on every one of the sort's element stores. → none of the four levers as scoped. The nearest planned work is Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 phase A ("explicit runtime state, self-describing headers") — a generation bit in theGcHeader, or a card table indexed by address arithmetic, makes this a shift-and-load with no side table. But Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 is scoped as an object-model change and does not currently claim the barrier. I would open this as its own lever; on this workload it is worth more than every repsel item combined.HashMapwith. The per-objectLayoutSlotMaskis exactly the state a shape node owns. → Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 shape tree. Direct hit, and the only one of the four levers that squarely addresses a top-3 GC cost.mark_dirty_old_pageinserting into a hashed set per dirty page. → none of the four. A card table replaces the hash insert with a byte store.Rank 3 overall, rooting / shadow stack at 12.75% (440 M), deserves its own note: this is the documented GC-safety contract, not an accident.
RootedArrayElems(sort.rs:87) re-derives the array header through aRuntimeHandleon every element get and set, by design, because a comparator can allocate → minor GC → the array moves. Roughly half of it (js_shadow_slot_bind,js_shadow_frame_*,js_gc_temp_root_*≈ 5.6%) is in generated/helper code and is what representation selection could shrink; the rest is runtime-internal to the sort. → none of the four for the runtime half.6. (d) Allocator and string machinery
Allocator: 0.58% (20 M instructions).
js_object_alloc_class_inline_keys0.19%,mi_free0.15%,mi_theap_malloc_aligned0.09%,_mi_page_malloc_zero0.07%. This fixture allocates 40,000Rows, 40,000 shaped literals, 40,000 heap strings and several arrays — and mimalloc is a rounding error. Any reading of "allocation-heavy fixture" as "allocator-bound" is wrong: the cost of an allocation here is entirely the bookkeeping around it (layout notes, barriers, feedback), not the bump.String: 5.53% (191 M). Top 3:
core::str::from_utf8, 2.14%. Comes fromstring::compare::utf16_cmp_bytes(string/compare.rs:15), which doesfrom_utf8on both operands, thenencode_utf16().cmp(encode_utf16()), on every comparison, falling back toa.cmp(b)if either fails. The keys here are ASCII ("alpha:12345"). → none of the four levers — but this is the cheapest win in the whole account. An ASCII fast path (if a.is_ascii() && b.is_ascii() { return a.cmp(b) }) deletesfrom_utf8+ the UTF-16 re-encode, i.e. ~3.1% of total program instructions, in a few lines, with no analysis work. Worth filing on its own.string::compare::utf16_cmp_bytes, 0.93% (the re-encode and iterator compare itself). Same fix.js_string_concat0.64% +js_dynamic_string_or_number_add0.53%.r.bucket + ":" + r.id. → none of the four.7. The two prior findings, checked against this data
"~612k sort-comparator invocations via
js_closure_call2insidejs_array_sort_with_comparatoris the likely top cost, runtime-internal and invisible to the static census."js_array_sort_with_comparatoris 69.79% inclusive — the sort is essentially the whole program. And it is invisible to the static census exactly as predicted:--opt-reportnames none of it, and the emitted IR contains no trace of it.40000·log₂40000, correct for a pure merge sort; Perry's hybrid runs insertion sort over 1250 runs of 32 first (INSERTION_THRESHOLD = 32), adding ~316k comparisons.js_closure_call2takes 0.00% — it is linked into the binary and takes zero samples in either the flat or the call-graph profile.ComparatorCall::new(array/sort.rs:23) resolvesdirect: resolve_call2_direct(comparator)once, andcompare_atcalls that function pointer for all 927,985 comparisons. Closure dispatch as a whole is 0.08% (js_closure_call40.05%,js_closure_call30.04%; both from elsewhere). There is no dispatch overhead to remove here. If the vdispatch-tower lever is being justified by this workload, this data does not support it.RootedArrayElems::setroutes every element write throughnote_array_slot→ write barrier →classify_heap_generationand →layout_note_slot→ TLS hashmap. The sort scaffolding costs 1,495 instructions per comparison, more than the user's comparator does at 1,103. That is the finding the prediction was reaching for, and it is a GC-barrier finding, not a dispatch finding."66% PIC diamonds." That figure was derived from a different file and should not be cited as a
batch.tsnumber. For the record, measured here at5f5006aa6:batch.tsmodule-wide is 64.2% of basic blocks in property machinery (478 of 744), and the sort comparator alone is 91% (104 of 114). The similarity to 66% is a coincidence of two different measurements; if you need the number for a given file, re-measure it on that file.8. What I did not measure
2,597 instructions per comparisonfigure has no comparison point in this run.js_rel_lt9.81% in particular is inflated by this fixture's deliberate value collisions.thisclone behind an inline keys check (#7142) #7169 re-measurement; the −0.16% is quoted from that PR, not reproduced here.perf(gc): stop the runtime write barrier re-deriving its parent addre…
wip(repsel): #7170 R1 tests + census liveness fixture
wip(repsel): census baseline for the #7170 R1 IIFE liveness fixture
wip(repsel): behavioural gap test for #7170 R1, registered in the mat…
wip(repsel): #7170 R1 — return-shape facts reach closure regions
wip(repsel): #7170 R1 tests + census liveness fixture
wip(repsel): census baseline for the #7170 R1 IIFE liveness fixture
wip(repsel): behavioural gap test for #7170 R1, registered in the mat…
perf(repsel): make Ptr<Shape> return-shape facts reachable inside the…
docs(changelog): #7233 fragment for the #7170 R1 CJS-IIFE return-shap…
fix(repsel): enforce FuncId key uniqueness for return-shape facts (#7170
perf(repsel): make Ptr<Shape> return-shape facts reachable inside the…
docs(changelog): #7233 fragment for the #7170 R1 CJS-IIFE return-shap…
fix(repsel): enforce FuncId key uniqueness for return-shape facts (#7170
proggeramlug commentedon Aug 2, 2026
R1 landed the wrapper fix, and the measurement redirects R2
PR #7233 implements R1. The wrapper wall is real and is gone; the numbers below
are the scoping for whoever takes R2, and they do not say what §8 said.
1. R1 fires 10 more times on dependency JS and promotes nothing
197 modules from
real-apps/scriptc/node_modules(every third__esModule.jsby sorted path, 196 producing a report — the §2 selection rule), bothcompilers pinned at one SHA and differing only by the patch:
return/rule 1 (provenance)return/ already served by return-shapeselected/consumedThe base arm reproduces #7176's published table exactly (231 unserved + 4
served = 235), so the instrument is the corrected one.
Zero new promotions. An entry-level diff over all 196 modules shows no row
changing except those 10 — the caller-side seed never fired at all. Not "fired
and was denied by rules 2–5": no call site. Inspected, the producers R1
reaches are exported helpers —
getFunctionHeadLoc'sreturn { end, start },ril.js'ssetTimeoutreturning{ dispose }— called from other modules.That is R2's cross-module half, and it is still structurally blocked:
Expr::ExternFuncRefcarries noFuncId, soreturn_shape_functions'HashMap<u32, String>cannot be keyed for an imported callee. It needs aname-keyed fact table plus a module-ordering guarantee, exactly as §8 said —
but §8 ranked it after R1's benefit story, and this is that story: on this
corpus the same-module consumer has nothing to consume.
2. ★ The residual producer wall, measured — and it is not fall-through
A throwaway instrumented compiler (built, run, never committed) recorded the
first refusing conjunct of
producer_return_classover the same 197modules. Ordering matters: with the fall-through check where it ships (before
the returns are collected) it attributed 746 bodies to itself, which is an
artifact of void functions. Moved after the return-form loop, the honest
histogram is:
return_form_not_fresh— some return is neithernew C(…)nor a proven localno_returns— a void body, never a candidatereturned_local_not_new_bound—return x,xnot bound by oneLet{init:New}bare_returnTwo conclusions:
agreeing fresh returns and can still fall off its end. Anyone tempted to
relax the terminator requirement should not bother.
perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedulers read them as (#7170 R0) #7176 §3b already measured the biggest sub-case from the other side: 88
returned expression operandsites, i.e.return cond ? {…} : {…},return flag && {…},return await {…},return {…}.x.So R2's first increment should be the producer, not the consumer: admit a
returned conditional whose arms all agree on one fresh class (and, separately,
&&/||whose object arm agrees), rather than widening callee reach to methodcalls. That is a local change to
producer_return_class's return-form matchwith the same freshness obligation per arm, and #7176 §3b's bucket already
counts its population.
Caveat, stated because it bounds the claim: the histogram counts every
candidate body, most of which never return an object at all, so it is a
first-refusal histogram and not a distribution over producer-shaped bodies.
The refined version needs a report bucket that does not exist.
3. Two corrections to this issue's own text
have ≥1 same-module
const x = f(…)call site — that 43 is the count thatwould promote today if the wrapper were not in the way") did not survive.
The source-level 43 and the compiler-side reality are different populations:
the functions with same-module const-bound call sites and the functions that
can carry a fact are almost disjoint on this corpus.
hir.functionscontains closures, whichdecides whether R1(b) needs a producer-side change as well as a consumer-side
one" — is settled: it does not.
--print-hiron the §6p8probe reportsFunctions: 0. Both halves were needed.4. Also found while measuring, filed separately
#7234:
--opt-reportpanics on real dependency JS in any debug-assertionsbuild —
RULE2carries two tiers andopt_report/render.rs:418(this issue'sown R0 S20 guard) asserts one. Pre-existing; exonerated by in-place A/B.
Relevant here because a debug build silently emits no report for such a
module, which reads as "0 promotions" — anyone re-deriving these numbers with
cargo build -p perryinstead of--profile perry-devwill get a wronganswer and no warning.
perf(repsel): make Ptr<Shape> return-shape facts reachable inside the…
perf(gc): stop re-recording the page the write barrier just recorded (#…
perf(repsel): admit conditional return-shape producers (#7170 R2) (#8007
perf(repsel): admit logical return-shape producers (#7170 R2) (#8015)
perf(repsel): admit method return-shape producers (#7170 R2) (#8029)
proggeramlug commentedon Aug 14, 2026
R0 and R1 are on
mainand R1 is measurably working — re-census on a fresh corpus, and what is left of the ranked listed5f1c749(#7176, R0) andc9cd73ba5(#7233, R1) are both onmain@
514ff7c9a.Caveat first, because it bounds everything below:
real-apps/scriptc/node_modulesno longer exists in the tree, so §2's197-module slice cannot be reproduced. I re-censused on a different sample of
the same kind of code — every third
__esModule.jsunder the repo's ownnode_modules(376 matched, 126 selected, one directory each),perry compile m.js --no-link --opt-report=json,--profile perry-dev,124 producing a report. This replicates the shape; it is not a strict
before/after against 1838/2028.
1. The wall replicates; the served bucket has grown 40×
ptr-shape: 10 selected / 838 denied / 36 consumed over 124 modules;10 of 124 modules get at least one consumed promotion (#7176 measured
1 of 196).
Rule 1 total = 796 / 838 = 95.0% (§2: 71.2%; #7176: 90.6%). The wall is the
wall.
But the served count is the interesting column. §5.2 expected the served
correction to be material and #7176 found it was 4 sites of 1842 (0.2%),
because 92.9% of rule-1 allocations sat in
closureregions the CJS IIFEcreated. Post-R1 it is 70 of 784 alloc-site rows (8.9%). That is R1 doing
exactly what §6 predicted it would.
By position (784 rows):
statement(still the catch-all, §6 / R0b)throwTiers, restated on this corpus: T1 (component of another object:
object literal property value+array element) = 121 / 15.4%;T3 (call argument) = 265 / 33.8%; T4 (returned, unserved) = 44 / 5.6%;
unclassified
statement= 232 / 29.6%.Two things worth recording:
T1 at 40.4% and perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedulers read them as (#7170 R0) #7176 at 32.1%; here it is 15.4%. That is a corpus difference
(this one is less bundle-heavy — §3's caveat that the top 5 modules
contributed 42.5% of sites is exactly the sensitivity being exposed), and it
is a reason not to schedule R3 vs R4 off either number alone.
masked_by_dedup= 209 against 784 alloc-site rows (27%). perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedulers read them as (#7170 R0) #7176 reported29 corpus-wide and called them "all real double-lowerings". At 27% that
claim is worth re-checking on this corpus before the next re-derivation.
2. What is left, and what it is worth
the
statementcatch-all, still 29.6% and still the largest classifiedbucket) is the one instrument item that remains, and perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedulers read them as (#7170 R0) #7176's comment already
says why it was deferred.
bucket went 0.2% → 8.9%, and consumed-per-module went 1/196 → 10/124.
return+ 25 returnedexpression operands = 69 of 838 (8.2%). Comment 3's recommendation (widen
the producer return-form to agreeing conditional arms before widening callee
reach) is still the right first increment, and its population is the 25. The
cross-module half is still structurally blocked (
Expr::ExternFuncRefcarriesno
FuncId).story §8 demanded before scheduling.
Recommendation
Close this issue as the R0/R1 vehicle it was, and let the two remaining tiers
live on their own trackers (#6759, #7142). If R2 is wanted, it wants its own
issue with the 25-site population above and — per §6.1, §10 and comment 3 — a
consumption counter, not a served counter: R1's own measurement on the old
corpus was "10 more served, zero new promotions", and the only thing that
distinguishes that from a win is
consumed.Unmeasured: the original scriptc corpus (deleted); anything about speed on
dependency JS; whether the 70 newly-served returns are consumed by any caller
(the report separates issued from consumed for locals, not for producers — the
same gap comment 3 named); x86-64.
proggeramlug commentedon Aug 14, 2026
Closing as the R0/R1 vehicle it was, per the re-census above.
ed5f1c749(perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedulers read them as (#7170 R0) #7176), plus its own published correction.c9cd73ba5(perf(repsel): make Ptr<Shape> return-shape facts reachable inside the CJS IIFE (#7170 R1) #7233), and now measured: the served-by-return-shape bucket went from 4 of 1842 (0.2%) pre-R1 to 70 of 784 alloc-site
rows (8.9%), and modules with at least one consumed
Ptr<Shape>promotionwent from 1 of 196 to 10 of 124. The CJS-IIFE wall §6 identified is
gone.
return+ 25returned expression operand). If it is wanted it should be its own issue,with the producer-side return-form widening comment 3 recommends and a
consumption counter rather than a served counter — R1's own measurement on
the previous corpus was "10 more served, zero new promotions".
story §8 demanded.
statementcatch-all is still the largest classified bucket at29.6%, and
masked_by_dedupis 209 against 784 alloc rows (27%) onthis corpus, against the 29 corpus-wide perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedulers read them as (#7170 R0) #7176 reported. Both are instrument
items worth their own issue if anyone re-derives off these buckets again.
#7152 is closed alongside this.