Skip to content

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

@proggeramlug

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 by new 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 closure regions — 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; require appears as a callee of literal
    arguments, never as a producer of one.
  • conditionals — 61 of 1651 (3.7%). Perry's own initializer bucket 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;   // ← PropertyGet

So 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 literal vs new 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 the continue, 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) Have cjs_wrap lower the module body so that top-level
    function declarations remain hir.functions entries 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 is LocalGet(id)
    where id is bound by exactly one Stmt::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 one Let to a closure literal and
    never reassigned names one statically-known function by the same argument that
    Expr::FuncRef does — the single-Let condition 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-module const 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 stays Let { 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 to Expr::ExternFuncRef { name },
    which carries no FuncId, so return_shape_functions's HashMap<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)

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 defineProperty descriptors — 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:

  1. 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.
  2. 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.
  3. 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 of unbound_new_sites over 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.functions contains 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

  1. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    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 return bucket to be a material over-count. It is 4 sites of
    1842
    . 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 (current main, i.e. on top of #7171's
    scaffolding suppression), --profile perry-dev, -p perry -p perry-runtime-static -p perry-stdlib-static, one target dir per arm in
    sequence. The arms differ only by #7176.

    Corpus: §2's method exactly — every third __esModule .js in
    real-apps/scriptc/node_modules by sorted path, 197 of 589 (the 589
    replicates), each copied to its own directory,
    perry compile <m>.js --opt-report=json --no-link. 196 produce a report in
    both 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

    #7152 this issue §2 base (post-#7171) R0
    rule-1 denials 506 963 837 1838
    of candidates 746 1352 1023 2028
    share 67.8% 71.2% 81.8% 90.6%
    served-by-return-shape — — — 4

    The number that replaces 506/963 is 1838 unserved rule-1 allocation sites of
    2028 Ptr<Shape> candidates (90.6%).

    selected is 3 and consumed is 11 in both arms, on every module —
    identical to §2, and the proof that this is report-only. denied is the only
    column 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-5
    barrier.


    3. The corrected distribution (n = 1842 allocation sites)

    allocation position count share note
    statement 455 24.7% catch-all — see §6
    object literal property value 418 22.7% new bucket
    call argument 399 21.7%
    return (unserved) 319 17.3%
    array element 173 9.4%
    initializer 60 3.3%
    condition 9 0.5%
    constructor argument 5 0.3% was conflated with the 418
    return (served, #7107) 4 0.2% new bucket

    Against base, position by position:

    position base R0
    statement 358 455
    call argument 202 399
    return 201 323
    array element 15 173
    constructor argument 9 5 + 418 split
    initializer 49 60
    condition 3 9
    total 837 1842

    Grouped the way §3 grouped them, on the compiler-side instrument:

    tier mechanism count share
    T1 component of another object (object literal property value + array element) 591 32.1%
    T3 flows into a callee (call argument) 399 21.7%
    T4 returned, not served 319 17.3%
    — unclassified (statement) 455 24.7%
    — other (initializer, condition, real ctor arg, served) 78 4.2%

    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

    region of the rule-1 allocation base R0
    closure 756 1711 (92.9%)
    method 46 68
    function 34 62 (3.4%)
    module-init 1 1

    §2 measured 91.6% closures; the corrected instrument says 92.9%. Only 62 of
    1842 unbound allocations are in a function region at all
    , and a return-shape
    fact can only exist for one of those — collect_return_shape_functions iterates
    hir.functions, and the caller-side seed requires a bare Expr::FuncRef
    callee, 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 closure regions 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

    source census (§3) compiler, R0
    genuine new C(arg) 33 (2.0%) 5 (0.3%)
    anonymous-shape component 404 (24.5%) 418 (22.7%)

    The old label was 98.8% not constructor arguments. §5.1's reading holds and
    is understated.


    6. A fourth defect, measured, not fixed

    statement is now the largest bucket — 455 of 1842, 24.7% — and it is a
    catch-all, not a position.
    It is scan_stmts' default context, which
    walk_expr_children propagates through every expression the walk has no arm
    for. §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_rule and summary.by_alloc_context are computed by the compiler,
    and alloc_contexts is written into benchmarks/repsel_census/baseline.json
    per workload. The next re-derivation is a file read, not a jq line in an issue
    comment — which is how 506 / 963 outlived the measurement it came from.

    summary.masked_by_dedup reports what the drain still collapses (29 rows
    corpus-wide, all real double-lowerings), so "the dedup is not hiding anything"
    becomes a number rather than an assumption.

    Byte-neutrality is on --trace llvm over 30 dependency modules with a
    same-compiler control, both arms run under --opt-report (the only mode in
    which any of the changed code runs): identical 29, DIFFERENT 0, control-failed
    0. The harness reports DIFFER for PERRY_PTR_SHAPE_LOCALS=0 against the same
    base binary, so it is not vacuous. Details and the three run-identity
    normalisations are in #7176 §4.


    8. Unmeasured, plainly

    • Anything about speed. No benchmark was run.
    • How many of the 4 served returns have a caller that consumes the fact.
      The report separates issued from consumed for locals, not for producers.
    • Scalar-replacement survival over the 319 unserved return sites — §10's
      "single most important unknown for R1's benefit story" still has no counter,
      and R0 did not add one.
    • Whether the statement bucket's internal split matches §3's source
      census.
      §6 above is a size, not a breakdown.
    • x86-64. macOS arm64 only.
  2. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Correction to the R0 distribution: the return bucket 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

    RETURN was set once, at Stmt::Return(Some(e)), and scan_expr propagates
    its context unchanged through the fallback arm — only New, Call arguments
    and Array override it. So every allocation nested inside a returned
    expression inherited the return label:

    return cond ? {a: 1} : {b: 2};   // both arms filed as return positions
    return flag && {a: 1};
    return await {a: 1};
    return {a: 1}.x;                 // a member-access BASE

    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.

    bucket published above corrected
    return, unserved 319 231
    return, served 4 4
    return total 323 235
    returned expression operand — 88

    §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 return bucket. So the headline replacing 506/963 stands
    exactly as posted; only the per-position split moves.

    Full corrected distribution (n = 1842)

    allocation position count share
    statement (catch-all, §6 above) 455 24.7%
    object literal property value 418 22.7%
    call argument 399 21.7%
    return (unserved) 231 12.5%
    array element 173 9.4%
    returned expression operand 88 4.8%
    initializer 60 3.3%
    condition 9 0.5%
    constructor argument 5 0.3%
    return (served, #7107) 4 0.2%

    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 rule
    independently, and fixture_alloc_buckets.ts puts one allocation in each — so
    the 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. main
    had advanced five commits between the rebase and the base build, one changing
    stmt/loops.rs safepoint emission. The base arm must be built from the
    branch's own merge-base, not from a moving origin/main. Rebuilt pinned:
    29 identical, 0 DIFFERENT, 0 control-failed, and PERRY_PTR_SHAPE_LOCALS=0
    still reports DIFFER through the same normalisations.

  3. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    The instruction account for benchmarks/app-patterns/kernels/batch.ts

    Headline: 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_call2 under js_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% — ComparatorCall resolves a direct function pointer once and never enters js_closure_call2 again. 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

    Commit profiled 5f5006aa6, tracked tree clean, release build (perry + libperry_runtime.a + libperry_stdlib.a all from this tree)
    Host Raspberry Pi 5 (aarch64), kernel 6.12.47+rpt-rpi-2712, perf 6.12.94
    Conditions ASLR off (setarch -R), pinned to core 2, PERRY_NO_AUTO_OPTIMIZE=1
    Load 0.02 at start → 0.30 at end (1-min avg), never above 0.34; temp 56.5→60.9 °C, get_throttled=0x0 throughout
    Oracle re-verified immediately before profiling: stripped binary, symbolized binary and node v26.5.1 --experimental-strip-types all print checksum: 1601043.5000
    Totals perf stat, 15 interleaved reps × 3 arms
    Profile 3 × perf record -e instructions:u -c 150000, 22991/23020/22993 samples, 0 THROTTLE events, 99.85% instruction coverage
    Call graph perf record --call-graph fp -c 150000, 23007 samples, 0 THROTTLE

    A defect worth naming, because it would have silently biased the ranks. The first flat pass (-c 40000, ~100k samples/s) hit kernel.perf_event_max_sample_rate=100000 and logged 142 THROTTLE/UNTHROTTLE events. perf reports 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 150000 runs with THROTTLE == 0 asserted. (The ranks happened to survive — classify_heap_generation 18.39% throttled vs 19.03% clean — but that was luck, not method. perf report --stats should be checked on every profile in this campaign.)

    Totals, 15 reps each:

    arm mean instructions stdev min–max spread IPC
    batch (symbolized, profiled) 3,453,656,786 0.32% 0.98% 2.82
    batch (stripped) 3,442,699,591 0.49% 1.03% 2.81
    hello (startup baseline) 1,929,598 0.42% 1.33% 0.72

    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_generation moves 17.72 → 20.57% while the layout-map TLS with moves 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-this clone) landed after 5f5006aa6 and 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 at 5f5006aa6 defines both perry_method_batch_ts__Row__rescore and perry_method_batch_ts__Row__rescore__pshape, but __pshape is 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=1 and PERRY_GC_TRACE=1 each emit 0 lines, and no mark_/sweep/collect symbol appears anywhere in the profile. The only collector-side code present is incremental_mark_barrier_value (1.85%) and ArenaObjectCursor::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:

    rank class share instructions
    1 GC — heap-address classify (classify_heap_generation) 19.03% 657 M
    2 GC — layout side-table (layout_note_slot + its TLS hashmap) 12.91% 446 M
    3 GC — rooting / shadow stack 12.75% 440 M
    4 Generated code (compiled batch.ts) 9.57% 330 M
    5 GC — write barrier 8.64% 298 M
    6 Array ops incl. sort scaffolding 8.07% 279 M
    7 Typed feedback / PIC 7.51% 259 M
    8 String machinery 5.53% 191 M
    9 other libc / rust std / misc 5.14% 178 M
    10 other js_* / perry_* helpers 3.25% 112 M
    11 GC — dirty-page / side-table hashmap inserts 2.70% 93 M
    12 property access / shape 2.31% 80 M
    13 GC — mark / arena 1.34% 46 M
    14 allocator (mimalloc) + memcpy 0.58% 20 M
    15 closure / call dispatch 0.08% 2.8 M

    GC total (rows 1, 2, 3, 5, 11, 13) = 57.37% = 1.982 G instructions.

    Call-graph inclusive, same run:

    94.65% main
     69.79%   js_array_sort_with_comparator          (self 0.00%)
      29.63%     perry_closure_batch_ts__9            (self 8.12%)  <- the user comparator
      28.12%     gc::barrier::write_barrier_slot_inner (self 2.19%)
       19.20%       arena::page_meta::classify_heap_generation (self 19.20%)
        6.73%       gc::barrier::mark_dirty_old_page   (self 4.30%)
      24.80%     array::sort::insertion_sort_rooted    (self 2.16%)
      13.23%     gc::layout::layout_note_slot          (self 4.01%)
      13.04%     array::sort::stable_merge_sort_rooted (self 1.44%)
    

    Per comparison. I instrumented a copy of the fixture with a counter: 927,985 comparator invocations (not 612k — that figure is n·log₂n for a pure merge sort; Perry's hybrid insertion-sorts 1250 runs of 32 first, which adds ~316k). Dividing the inclusive shares:

    instructions / comparison
    whole sort call 2,597
    of which: comparator body (inclusive) 1,103
    of which: comparator body (self only) 306
    of which: sort scaffolding + its GC bookkeeping 1,495

    3. (a) Opaque js_* helpers

    All 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:

    helper incl self what it is
    js_array_sort_with_comparator 69.79% 0.00% the umbrella — everything below lives inside it
    js_rel_lt + js_rel_gt 9.81% 0.06% a.key < b.key / a.key > b.key
    js_dynamic_string_or_number_add 4.38% 0.53% r.bucket + ":" + r.id
    js_object_set_field_by_name 3.57% 0.27% field stores
    js_typed_feedback_observe_property_get 3.26% 0.57% PIC feedback recording
    js_put_value_set_dyn_ic_miss 3.14% 0.25% IC miss on acc[idx] store
    js_array_get_f64 3.06% 1.00% element reads

    By self-time the top helpers are js_shadow_slot_bind 2.08%, js_jsvalue_equals 1.78%, js_array_get_f64 1.00%, js_shadow_frame_enter 0.98%, js_dynamic_sub 0.80%, js_shadow_frame_pop 0.75%, js_gc_temp_root_get 0.73%, js_gc_temp_root_push 0.65%, js_string_concat 0.64%, js_gc_init_typed_shape_layout 0.63%, js_typed_feedback_class_field_set_guard 0.63%.

    Top 3 → lever

    1. 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 the a.key tiebreak fires on most comparisons. Do not generalize the 9.81% to other workloads.)
    2. js_dynamic_string_or_number_add, 4.38%. r.bucket + ":" + r.id where bucket: string is declared. → none of the four. Needs static concat typing.
    3. js_object_set_field_by_name 3.57% + js_put_value_set_dyn_ic_miss 3.14%. The reducer's slot.count = slot.count + 1 on acc[idx]. → repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151 item 2 names this exact case ("A.map/forEach/reduce callback parameters … it is where batch.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%

    symbol self incl source
    perry_closure_batch_ts__9 8.21% 29.63% the sort comparator
    perry_closure_batch_ts__11 0.48% 3.52% the reduce reducer
    perry_closure_batch_ts__7 0.27% 8.75% the map callback
    batch_ts__Row_constructor 0.30% 5.25% new Row(...)
    perry_method_batch_ts__Row__rescore 0.15% 3.90% r.rescore(1.5)
    batch_ts____AnonShape_e75d7e32e87dc826_constructor 0.13% 2.58% the {key,value,tag} literal

    Hot blocks ↔ guard/PIC shapes, from the traced IR (--trace llvm, 11,338 lines):

    • perry_closure_batch_ts__9 is 114 basic blocks for a 5-line comparator. 72 are pget* and 32 are pic* — 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 of js_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.
    • Module-wide: 478 of 744 blocks (64.2%) are property machinery — 171 pget, 108 put, 76 pic, 54 class_field_set, 36 class_field_inline, 15 class_field_get, 12 class_field_get_number, 6 ptr_shape_get_number.
    • --opt-report on this fixture at this commit: Ptr<Shape> 2 selected / 3 denied of 5 candidates, specialized ABI 0 of 3, I32/U32/Str 5 of 5 → 7 unboxed, 6 boxed. One of the two Ptr<Shape> wins (totals) is selected but not consumed — dropped by module_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

    1. perry_closure_batch_ts__9, 8.21% self / 29.63% incl. a and b are sort callback 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 32 pic blocks into a shape check. Note repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151 also lists item 5 (sort as a shape-preserving mutator that E2 refuses) — relevant to this fixture but not to this cost.
    2. perry_closure_batch_ts__7, 8.75% incl. Two separate denials in one function: the parameter r (→ repsel: Ptr<Shape> element reads — the four forms #7034 §3 does not cover #7151 item 2) and the returned object literal, which --opt-report denies under ptr-shape rule 1 with the text "allocated in expression position and never bound to a let/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).
    3. batch_ts__Row_constructor 5.25% incl + Row__rescore 3.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-this clone behind an inline keys check (#7142) #7169, already landed: the __pshape clone existed here but was unreachable and dead-stripped.

    5. (c) GC — 57.37%

    symbol self incl called from
    arena::page_meta::classify_heap_generation 19.03% 19.20% write_barrier_slot_inner (17.92 of its 28.12 incl)
    layout::layout_note_slot TLS HashMap<usize, LayoutSlotMask> with 7.60% — layout_note_slot
    gc::barrier::mark_dirty_old_page 4.10% 6.73% write_barrier_slot_inner
    layout::layout_note_slot 3.83% 13.23% RootedArrayElems::set (via note_array_slot)
    RuntimeHandle::get_nanbox_u64 3.69% 3.69% every rooted element access
    hashbrown HashMap<usize,()> PtrHasher::insert 2.67% 2.67% mark_dirty_old_page (dirty-page set)
    RuntimeHandleScope::push TLS with 2.64% 2.65% scope entry
    gc::barrier::write_barrier_slot_inner 2.60% 28.12% RootedArrayElems::set
    gc::barrier::incremental_mark_barrier_value 1.85% 1.95% barrier
    js_shadow_slot_bind / frame_enter / frame_pop 2.08 / 0.98 / 0.75% — generated prologues
    js_gc_temp_root_{get,push,truncate} 0.73 / 0.65 / 0.42% — helper rooting

    Top 3 → lever

    1. 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 TLS HashMap of 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 the GcHeader, 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.
    2. Layout side-table, 12.91% (446 M), 59% of it in the TLS HashMap with. The per-object LayoutSlotMask is 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.
    3. Write barrier 8.64% + its dirty-page hashmap inserts 2.70% = 11.34% (391 M). mark_dirty_old_page inserting 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 a RuntimeHandle on 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_keys 0.19%, mi_free 0.15%, mi_theap_malloc_aligned 0.09%, _mi_page_malloc_zero 0.07%. This fixture allocates 40,000 Rows, 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:

    1. core::str::from_utf8, 2.14%. Comes from string::compare::utf16_cmp_bytes (string/compare.rs:15), which does from_utf8 on both operands, then encode_utf16().cmp(encode_utf16()), on every comparison, falling back to a.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) }) deletes from_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.
    2. string::compare::utf16_cmp_bytes, 0.93% (the re-encode and iterator compare itself). Same fix.
    3. js_string_concat 0.64% + js_dynamic_string_or_number_add 0.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_call2 inside js_array_sort_with_comparator is the likely top cost, runtime-internal and invisible to the static census."

    • Location: confirmed, and stronger than stated. js_array_sort_with_comparator is 69.79% inclusive — the sort is essentially the whole program. And it is invisible to the static census exactly as predicted: --opt-report names none of it, and the emitted IR contains no trace of it.
    • Count: corrected. 927,985, not 612k (measured with an instrumented copy of the fixture). 612k is 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.
    • Mechanism: refuted. js_closure_call2 takes 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) resolves direct: resolve_call2_direct(comparator) once, and compare_at calls that function pointer for all 927,985 comparisons. Closure dispatch as a whole is 0.08% (js_closure_call4 0.05%, js_closure_call3 0.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.
    • What actually costs 69.79%: the comparator body (29.63%, of which only 8.21% is its own instructions — the rest is property-read machinery) and, larger, the sort's own buffer moves at 40.16%. RootedArrayElems::set routes every element write through note_array_slot → write barrier → classify_heap_generation and → 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.ts number. For the record, measured here at 5f5006aa6: batch.ts module-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

  4. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    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
    .js by sorted path, 196 producing a report — the §2 selection rule), both
    compilers pinned at one SHA and differing only by the patch:

    alloc-site bucket base R1
    return / rule 1 (provenance) 231 221
    return / already served by return-shape 4 14
    every other bucket unchanged unchanged
    corpus selected / consumed 3 / 11 3 / 11

    The 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's return { end, start },
    ril.js's setTimeout returning { dispose } — called from other modules.

    That is R2's cross-module half, and it is still structurally blocked:
    Expr::ExternFuncRef carries no FuncId, so return_shape_functions'
    HashMap<u32, String> cannot be keyed for an imported callee. It needs a
    name-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_class over the same 197
    modules. 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:

    first refusing conjunct count
    return_form_not_fresh — some return is neither new C(…) nor a proven local 1088
    no_returns — a void body, never a candidate 499
    returned_local_not_new_bound — return x, x not bound by one Let{init:New} 309
    bare_return 51
    async / generator 10
    non-pointer declared return type 6
    returns disagree on class 4
    class not admissible 2
    can fall off the end 1
    body proof failed 1
    (passed — a fact was issued) 14

    Two conclusions:

    1. Fall-through is not a lever. Exactly one body in 197 modules has
      agreeing fresh returns and can still fall off its end. Anyone tempted to
      relax the terminator requirement should not bother.
    2. The return FORM is the wall — 1088 + 309 = 1397 of 1971 refusals.
      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 operand
      sites, 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 method
    calls. That is a local change to producer_return_class's return-form match
    with 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

    • §8 R1's admission estimate ("146 return-position literals … 43 already
      have ≥1 same-module const x = f(…) call site — that 43 is the count that
      would 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.
    • §10's open question — "whether hir.functions contains closures, which
      decides whether R1(b) needs a producer-side change as well as a consumer-side
      one" — is settled: it does not. --print-hir on the §6 p8 probe reports
      Functions: 0. Both halves were needed.

    4. Also found while measuring, filed separately

    #7234: --opt-report panics on real dependency JS in any debug-assertions
    build — RULE2 carries two tiers and opt_report/render.rs:418 (this issue's
    own 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 perry instead of --profile perry-dev will get a wrong
    answer and no warning.

  5. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    R0 and R1 are on main and R1 is measurably working — re-census on a fresh corpus, and what is left of the ranked list

    ed5f1c749 (#7176, R0) and c9cd73ba5 (#7233, R1) are both on main
    @ 514ff7c9a.

    Caveat first, because it bounds everything below:
    real-apps/scriptc/node_modules no longer exists in the tree, so §2's
    197-module slice cannot be reproduced. I re-censused on a different sample of
    the same kind of code — every third __esModule .js under the repo's own
    node_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 denied share
    rule 1 (provenance) 726 86.6%
    rule 1 — already served by return-shape 70 8.4%
    rule 2 (containment) 23 2.7%
    rule 5 (module-wide barrier) 16 1.9%
    class admission 3 0.4%

    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 closure regions the CJS IIFE
    created. 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):

    position rows share
    call argument 265 33.8%
    statement (still the catch-all, §6 / R0b) 232 29.6%
    object literal property value 118 15.1%
    return — served 70 8.9%
    return — unserved 44 5.6%
    returned expression operand 25 3.2%
    initializer 17 2.2%
    throw 9 1.1%
    array element 3 0.4%
    condition 1 0.1%

    Tiers, 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:

    2. What is left, and what it is worth

    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.

  6. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    Closing as the R0/R1 vehicle it was, per the re-census above.

    #7152 is closed alongside this.

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