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
Activity
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 thereturnbucket 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(currentmain, 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.jsin
real-apps/scriptc/node_modulesby 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
2028Ptr<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 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 statement455 24.7% catch-all — see §6 object literal property value418 22.7% new bucket call argument399 21.7% return(unserved)319 17.3% array element173 9.4% initializer60 3.3% condition9 0.5% constructor argument5 0.3% was conflated with the 418 return(served, #7107)4 0.2% new bucket Against base, position by position:
position base R0 statement358 455 call argument202 399 return201 323 array element15 173 constructor argument9 5 + 418 split initializer49 60 condition3 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 closure756 1711 (92.9%) method46 68 function34 62 (3.4%) module-init1 1 §2 measured 91.6% closures; the corrected instrument says 92.9%. Only 62 of
1842 unbound allocations are in afunctionregion at all, and a return-shape
fact can only exist for one of those —collect_return_shape_functionsiterates
hir.functions, and the caller-side seed requires a bareExpr::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
ofclosureregions 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
statementis now the largest bucket — 455 of 1842, 24.7% — and it is a
catch-all, not a position. It isscan_stmts' default context, which
walk_expr_childrenpropagates 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_ruleandsummary.by_alloc_contextare computed by the compiler,
andalloc_contextsis written intobenchmarks/repsel_census/baseline.json
per workload. The next re-derivation is a file read, not ajqline in an issue
comment — which is how506 / 963outlived the measurement it came from.summary.masked_by_dedupreports 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 llvmover 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 reportsDIFFERforPERRY_PTR_SHAPE_LOCALS=0against 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
statementbucket's internal split matches §3's source
census. §6 above is a size, not a breakdown. - x86-64. macOS arm64 only.
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_exprpropagates
its context unchanged through the fallback arm — onlyNew,Callarguments
andArrayoverride it. So every allocation nested inside a returned
expression inherited thereturnlabel: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, unserved319 231 return, served4 4 returntotal323 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 thereturnbucket. 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 value418 22.7% call argument399 21.7% return(unserved)231 12.5% array element173 9.4% returned expression operand88 4.8% initializer60 3.3% condition9 0.5% constructor argument5 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, andfixture_alloc_buckets.tsputs 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.rssafepoint emission. The base arm must be built from the
branch's own merge-base, not from a movingorigin/main. Rebuilt pinned:
29 identical, 0 DIFFERENT, 0 control-failed, andPERRY_PTR_SHAPE_LOCALS=0
still reportsDIFFERthrough the same normalisations.- added a commit that references this issue
on 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
Commit profiled 5f5006aa6, tracked tree clean, release build (perry+libperry_runtime.a+libperry_stdlib.aall from this tree)Host Raspberry Pi 5 (aarch64), kernel 6.12.47+rpt-rpi-2712,perf 6.12.94Conditions ASLR off ( setarch -R), pinned to core 2,PERRY_NO_AUTO_OPTIMIZE=1Load 0.02at start →0.30at end (1-min avg), never above0.34; temp 56.5→60.9 °C,get_throttled=0x0throughoutOracle re-verified immediately before profiling: stripped binary, symbolized binary and node v26.5.1 --experimental-strip-typesall printchecksum: 1601043.5000Totals perf stat, 15 interleaved reps × 3 armsProfile 3 × perf record -e instructions:u -c 150000, 22991/23020/22993 samples, 0 THROTTLE events, 99.85% instruction coverageCall graph perf 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:
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_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:
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_*helpers3.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₂nfor a pure merge sort; Perry's hybrid insertion-sorts 1250 runs of 32 first, which adds ~316k). Dividing the inclusive shares:instructions / comparison whole sortcall2,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_*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:helper incl self what it is js_array_sort_with_comparator69.79% 0.00% the umbrella — everything below lives inside it js_rel_lt+js_rel_gt9.81% 0.06% a.key < b.key/a.key > b.keyjs_dynamic_string_or_number_add4.38% 0.53% r.bucket + ":" + r.idjs_object_set_field_by_name3.57% 0.27% field stores js_typed_feedback_observe_property_get3.26% 0.57% PIC feedback recording js_put_value_set_dyn_ic_miss3.14% 0.25% IC miss on acc[idx]storejs_array_get_f643.06% 1.00% element reads By 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%
symbol self incl source perry_closure_batch_ts__98.21% 29.63% the sortcomparatorperry_closure_batch_ts__110.48% 3.52% the reducereducerperry_closure_batch_ts__70.27% 8.75% the mapcallbackbatch_ts__Row_constructor0.30% 5.25% new Row(...)perry_method_batch_ts__Row__rescore0.15% 3.90% r.rescore(1.5)batch_ts____AnonShape_e75d7e32e87dc826_constructor0.13% 2.58% the {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.- Module-wide: 478 of 744 blocks (64.2%) are property machinery — 171
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%
symbol self incl called from arena::page_meta::classify_heap_generation19.03% 19.20% write_barrier_slot_inner(17.92 of its 28.12 incl)layout::layout_note_slotTLSHashMap<usize, LayoutSlotMask>with7.60% — layout_note_slotgc::barrier::mark_dirty_old_page4.10% 6.73% write_barrier_slot_innerlayout::layout_note_slot3.83% 13.23% RootedArrayElems::set(vianote_array_slot)RuntimeHandle::get_nanbox_u643.69% 3.69% every rooted element access hashbrown HashMap<usize,()> PtrHasher::insert2.67% 2.67% mark_dirty_old_page(dirty-page set)RuntimeHandleScope::pushTLSwith2.64% 2.65% scope entry gc::barrier::write_barrier_slot_inner2.60% 28.12% RootedArrayElems::setgc::barrier::incremental_mark_barrier_value1.85% 1.95% barrier js_shadow_slot_bind/frame_enter/frame_pop2.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
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.- Layout side-table, 12.91% (446 M), 59% of it in the TLS
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. - Write barrier 8.64% + its dirty-page hashmap inserts 2.70% = 11.34% (391 M).
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."- Location: confirmed, and stronger than stated.
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. - 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_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. - 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::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
- No V8/Node side-by-side. The
2,597 instructions per comparisonfigure has no comparison point in this run. - The bimodality's cause is a hypothesis (side-table rehash timing), not a finding.
- Single host, single fixture. The
js_rel_lt9.81% in particular is inflated by this fixture's deliberate value collisions. - No post-perf(codegen): route the class-id dispatch tower to the proven-
thisclone behind an inline keys check (#7142) #7169 re-measurement; the −0.16% is quoted from that PR, not reproduced here.
- added 14 commits that reference this issue
on Aug 1, 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), 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-shape4 14 every other bucket unchanged unchanged corpus selected/consumed3 / 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'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 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 ofproducer_return_classover 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 neithernew C(…)nor a proven local1088 no_returns— a void body, never a candidate499 returned_local_not_new_bound—return x,xnot bound by oneLet{init:New}309 bare_return51 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:
- 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. - 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 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 method
calls. That is a local change toproducer_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-moduleconst 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.functionscontains closures, which
decides whether R1(b) needs a producer-side change as well as a consumer-side
one" — is settled: it does not.--print-hiron the §6p8probe reports
Functions: 0. Both halves were needed.
4. Also found while measuring, filed separately
#7234:
--opt-reportpanics on real dependency JS in any debug-assertions
build —RULE2carries two tiers andopt_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 perryinstead of--profile perry-devwill get a wrong
answer and no warning.- Fall-through is not a lever. Exactly one body in 197 modules has
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's
197-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 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 inclosureregions 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% throw9 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%;
unclassifiedstatement= 232 / 29.6%.Two things worth recording:
- T3 is now the largest classified tier on this corpus, not T1. §3 measured
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 reported
29 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
- R0 — done (perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedulers read them as (#7170 R0) #7176), plus its own follow-up correction. R0b (splitting
thestatementcatch-all, still 29.6% and still the largest classified
bucket) 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. - R1 — done (perf(repsel): make Ptr<Shape> return-shape facts reachable inside the CJS IIFE (#7170 R1) #7233), and §1 above is the measurement it was owed: the served
bucket went 0.2% → 8.9%, and consumed-per-module went 1/196 → 10/124. - R2 — its ceiling on this corpus is 44 unserved
return+ 25 returned
expression 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::ExternFuncRefcarries
noFuncId). - R3 — 265 call-argument sites (33.8%). Still needs the callee-side benefit
story §8 demanded before scheduling. - R4 — Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759's territory by construction, as §9 concluded.
- 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 territory, likewise.
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 isconsumed.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.- T3 is now the largest classified tier on this corpus, not T1. §3 measured
Closing as the R0/R1 vehicle it was, per the re-census above.
- R0 — done,
ed5f1c749(perf(repsel): make the Ptr<Shape> alloc-site buckets mean what schedulers read them as (#7170 R0) #7176), plus its own published correction. - R1 — done,
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 consumedPtr<Shape>promotion
went from 1 of 196 to 10 of 124. The CJS-IIFE wall §6 identified is
gone. - R2 — ceiling on this corpus is 69 of 838 (44 unserved
return+ 25
returned 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". - R3 — 265 call-argument sites (33.8%); still needs the callee-side benefit
story §8 demanded. - R4 — Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 by construction, as §9 concluded.
- 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.
- R0b — the
statementcatch-all is still the largest classified bucket at
29.6%, andmasked_by_dedupis 209 against 784 alloc rows (27%) on
this 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.
- R0 — done,
Follow-up to #7152, which measured that 506 of 746
Ptr<Shape>candidates independency 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_moduleaccounting 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 treeclean,
perry-devbinary. Caveat, stated because CLAUDE.md requires it: thebinary'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.jsfile 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 owndirectory 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-reportbuckets are measured on HIR afterstate_desugar,deforestation,
inline_functions, loop unrolling and the async/generatortransforms. 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: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:
new C(...)—921 of 963. The 42 real constructors are led by
new Promise(23), which is anative base and would fail class admission even with perfect provenance. This
population is record literals, not class instances.
closureregions — 882 of 963 (method 46, function 34,module-init 1). §6 explains why, and it is the whole story.
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_siteslooks for — restricted to closed-shapeobject literals, the 95.6% that Perry lowers to
Expr::New{__AnonShape_N}.n = 1651.
Object.definePropertydescriptorsuper/Object.freeze/assignnew C({...}))Of the 96 property stores, 44 target
exports, 12 targetthis, 4 targetmodule; the rest are scattered. Of the 538 call arguments, 265 aredefinePropertydescriptors — the__esModulemarker and the re-exportforwarders, one or more per transpiled module.
Grouped by what a fix would have to be:
Object.defineProperty/freeze/assign)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 arequire()call in this corpus;requireappears as a callee of literalarguments, never as a producer of one.
initializerbucket is47 of 963 (4.9%). Real, small, not the lever.
and §5.3 shows seeding from them is actively dangerous.
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 literalsA closed-shape literal lowers to
new __AnonShape_N(v0, v1, …)whoseconstructor 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_exprthen labels every argument of anyExpr::New"constructor argument". So{a: {b: 1}}files the inner literal underconstructor argument.
At source level, genuine
new C({...})arguments are 33 of 1651 (2.0%) whilenested 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
returnbucket is already served on the producer side — the denial over-countsreport::deny_alloc_siteis called unconditionally at the top ofcollect_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 noknowledge of what the later passes do.
Meanwhile
ptr_shape_returns.rs:169-188already admits a barereturn new C(...)/return {…}with no local at all as a return-shapeproducer.
So a
return {a, b}site is reported as a rule-1 denial while simultaneouslyfeeding 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
PropertyGetwould silently break #7139#7139's exemption is sound on two legs, and the first is:
init_is_never_a_seed's whitelist is exactlyPropertyGet/Closure/Undefined/ absent. And the wrap template(
compile/cjs_wrap/wrap.rs:758-760) bindsSo
exportsis bound by aPropertyGet, andObject.defineProperty(exports, "__esModule", …)is the site #7139 exempts fromarming the module barrier. Seed rule 1 from
PropertyGetandexportsbecomes apromotable 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
PropertyGetorClosurerequires re-deriving or reverting #7139 in the same PR. Seeding fromCall(#7107's existing widening) and fromNewin expression position donot 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:const p = mk(i)p7_esm_store.tsfunction— selected, andconsumedviaptr_shape_get_numberp8_iife.ts(function(){ … })()closure— not even a candidatep1_direct.jsexports.+defineProperty)closure— not even a candidatep6_cjs_nodp.jsdefinePropertyclosure— not even a candidatep8differs fromp7only by the IIFE.p6differs fromp1only by thedefineProperty, and behaves identically — so this is not the rule-5 barrierand not #7139's territory. It is the wrapper.
Mechanism.
cjs_wrapemits every CommonJS module body inside an IIFE(
wrap.rs:889,{cjs_preamble}\n\n{body_for_iife}, "reproduces the historicalIIFE text byte-for-byte"). Inside it,
mkis a closure, not an entry inhir.functions. #7107's consumer isStmt::Let { init: Expr::Call { callee: Expr::FuncRef(id) } }(
ptr_shape_returns.rs:348-369) — a bareExpr::FuncRefand nothing else. Aclosure callee is not a
FuncRef, so the seed never fires.That is why 91.6% of dependency-JS alloc sites are in
closureregions. It isone 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) reportselectedand thenunconsumed — scalar_replaced:escape_newsdeleted theobject outright, so the
Ptr<Shape>proof emitted nothing. Onlyp7, which addsone 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.rschecksctx.scalar_replacedfirst, in file order,at both the
Let(:773-828) and everyPropertyGet(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 anynew rule: consult
non_escaping_news, or be consumed at a site that checksctx.scalar_replacedfirst — 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)withelems.is_empty()(
deforest/detect.rs:206-208); there is noExpr::New/object variant anywhere indetect.rs,producer_rewrite.rsorcall_sites.rs. It is also an HIR passrun from
collect_modules.rs:1914, finishing for every module beforecompile_module(run_pipeline.rs:4305) runs any collector. #7150 is a realconflict 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.
CallandNewseeds are safe;PropertyGetandClosureseeds 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 SSAre-derived from that bound slot at each access;
TaPtr's callee-side no-bindshortcut is never copied (
GC_TYPE_OBJECTis movable); write barriers on storesare 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.
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 intonested object literalvsnew C(arg), which today are merged (§5.1).of 197 constructor-argument denials from a number that is currently used to
schedule work.
happens next to the
continue, never instead of it.repsel-censusfixture 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
(a) Have
cjs_wraplower the module body so that top-levelfunctiondeclarations 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 is
LocalGet(id)where
idis 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.
statically-known function" (
ptr_shape_returns.rs:360-362: "Anything computedcould be rebound"). A local bound by exactly one
Letto a closure literal andnever reassigned names one statically-known function by the same argument that
Expr::FuncRefdoes — the single-Letcondition is the identical test rule 1already 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.
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 thecount 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.
(§7).
PropertyGet/Closure— theseed 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). Itdoes 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.
js_*call count in a CJS-wrapped fixture, beforeand after, with call sites checked in the emitted IR — not an
--opt-reportcounter (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
obj.m()) and cross-module producers.machinery for. Cross-module is structurally blocked, not merely
unimplemented: imported callees lower to
Expr::ExternFuncRef { name },which carries no
FuncId, soreturn_shape_functions'sHashMap<u32, String>cannot be keyed for them. It needs a name-keyed fact table plus a module
ordering guarantee.
unmeasured (§10).
R3 — parameter-shape facts (all-call-sites-agree)
passes a provably-fresh allocation of one class. perf(codegen): repsel Phase 5a — Ptr<Shape> proven
thisin methods #6925's clone-and-route is theprecedent.
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.
measurement must be callee-side, and the population is dominated by
options-bag arguments whose most common fate is
this.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.
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:
cannot reach these by construction — not "not yet", but by the definition of
the proof.
definePropertydescriptors — never promotable and neverworth promoting.
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:
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 isa soundness relaxation; all are recognition. Finish them, because they are
nearly free and because each currently makes the corpus look harder than it is.
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.
Ptr<Shape>(#7034 §3) #7149pays 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
agents throughout and disk fell to 12 GB free. Every number here is a static
count, an emitted-IR fact, or a source classification.
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.
emulation of
unbound_new_sitesover source gives array-element 167 where thecompiler 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.
§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.
structurally; I did not measure how many producers live in a different module
from their consumers.
hir.functionscontains closures, which decides whether R1(b) needsa 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-linkon every third__esModule.jsinreal-apps/scriptc/node_modules(197 of 589), each copiedto 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_exprcontext propagation, counting allocations that are not a variable declaration's
direct initializer. Probes
p1–p8in §6 are 8–10 lines each and are theload-bearing evidence; they need no corpus.