perf(repsel): stop charging Perry's own cjs_wrap preamble to the Ptr<Shape> report (#7152) - #7171
Conversation
…Shape> report
The `cjs_wrap` template emits `const __cjs_module = { exports: {} }` followed
by `var module = __cjs_module` into every wrapped CommonJS module. Rule 1 seeds
the record as a `Ptr<Shape>` candidate; the alias on the next line denies it
under rule 2 as a "bare reference". Over 195 real `__esModule` dependency
modules that pair — plus the `{}` inside it, reported as an unbound
constructor-argument allocation — is 379 of 1231 candidates, 31 % of the whole
report, and it was the evidence behind #7139's "containment is the wall"
reading and #7152's rule-1 headline.
The reference is not exemptable: `module` is a reassignable `var` the preamble
stores into `require.main` and that CJS bodies write `module.exports = X`
through, so the record genuinely escapes for its whole life. What is wrong is
that it was ever a candidate. `collectors/cjs_scaffolding.rs` now recognises
the template's record — R1 a `const __cjs_module` bound to an `__AnonShape_…`
allocation, R2 whose literal is exactly `{ exports: {} }`, R3 uniquely in the
region, R4 aliased by the `var module` binding that denies it — and drops it at
the seed, together with the object literals of the four allocating preamble
statements behind it (the two `defineProperty` sites #7139 already recognises,
reusing that predicate verbatim, plus `require.cache` and `require.extensions`).
R4 is the soundness argument: it IS the denial, so a region carrying it cannot
promote its record and the returned facts are bit-identical. Verified on
emitted LLVM IR over 49 dependency modules with a same-compiler control run per
module, and behaviourally against Node 26.5.1 on a CJS dependency fixture.
Denials: rule-2 bare reference 140 -> 4, rule-1 constructor argument 196 -> 8,
rule-5 187 -> 132, candidates 1231 -> 914. Selected/consumed unchanged at 3/11
in both arms: nothing is promoted, by construction.
|
Warning Review limit reached
Next review available in: 5 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Closes the
__cjs_modulehalf of #7152.--opt-report'sPtr<Shape>section stops charging Perry's own CommonJSwrapper to the user's code. Over 195 real
__esModuledependency modulesfrom
real-apps/scriptc/node_modules(every third by sorted path, compiledstandalone,
--opt-report=json --no-link, 195/195 in both arms), 31 % of allPtr<Shape>candidates — 379 of 1231 — are two statements thecjs_wraptemplate emits itself.
ptr-shapecandidatesNamed, not inferred: 136 of the 140 rule-2 bare references were the local
__cjs_module, as were 55 of the rule-5 denials, and 188 of the 194constructor-argument
object literal { ... }rows were the{}insideconst __cjs_module = { exports: {} }. Zero__cjs_modulerows remain in anybucket. The two arms compile the same 195 modules and fail on the same 2
(
rollup/dist/shared/index.js,@vitest/utils/dist/chunk-_commonjsHelpers.js,identical in both — pre-existing).
1. The shape, and why the reference is NOT exemptable
cjs_wrap's preamble (wrap.rs, thecjs_preambleliteral) emits into everywrapped module:
Confirmed in HIR (
--print-hiron a wrapped CJS dependency):__cjs_moduleis aStmt::Letof an object literal, so rule 1 seeds it; thenext statement aliases it into a
var, so rule 2 denies it as a barereference.
It cannot be exempted the way #7139 exempted the
definePropertysites.moduleis a reassignablevarthat the preamble itself stores intorequire.main, and that CommonJS bodies writemodule.exports = Xthrough —real packages also read
module.id/.filename/.loaded/.parent, anyof which transitions the shape. The record genuinely and permanently escapes;
no narrowing of rule 2 could promote it, and a guard-free fixed-offset load on
it would be wrong.
What is wrong is that it was ever a candidate. Promotion would buy nothing
even if it were provable — a one-field record, read three times at module init,
loop depth 0, no numeric field. So this suppresses it at the seed rather than
exempting it at the rule, which is what #7152 itself proposed ("if it is not, it
should not be a candidate").
2. The recogniser (
collectors/cjs_scaffolding.rs, extending #7139's)A region is a CommonJS preamble when its top level carries all of:
mutable: falseStmt::Letnamed__cjs_module, initialized by anExpr::Newof an__AnonShape_…class;__AnonShape_…allocation —the template's
{ exports: {} };mutable: trueStmt::Letnamedmodulewhose init is a bareExpr::LocalGetof that record.Soundness, per conjunct
This is a candidate SUPPRESSION, not a proof relaxation — the opposite
direction from #7139. Dropping a candidate can only remove facts, never add
one, so it cannot make codegen unsound. The whole obligation is to show it
never removes a fact that would otherwise exist.
R4 discharges that outright, and is the only conjunct that carries soundness
weight. It is not a heuristic about the template — it is the denial:
Stmt::Let { mutable: true, init: Some(LocalGet(record)) }takesUseWalk'splain-local branch, walks the init under the default escape context, hits the
Expr::LocalGetarm and callsdisq(record, ESC_BARE_REFERENCE)on everypath;
mutable: trueis what keeps the alias pre-pass from tracking itinstead. A region satisfying R4 therefore cannot promote its record, so
removing it from the candidate set leaves the returned
HashMapbit-identical.R1-R3 are hygiene, not soundness. They pin recognition to the template's
exact literal so a user binding that merely shares the name keeps its
candidacy. If any of them drifts the candidate simply reappears in the report —
the failure direction is more candidates, never a lost promotion.
The claim is also tested rather than asserted:
a_var_alias_denies_a_local_that_otherwise_promotesbuilds aPointlocalthat IS promoted, inserts exactly R4's statement, and shows the promotion
disappear.
3. Why the whole preamble, not just the record
--opt-reportdedups alloc-site rows per function on(module, function, name, position, analysis, outcome, rule), and every objectliteral renders as
object literal { ... }. So each wrapped module contributesexactly one alloc-site row, and which context it reports is whichever
preamble literal the walk reaches first. Suppressing only the record would have
moved the row from
constructor argumenttostatementand changed the totalby zero.
So the recognised region also stops walking the four allocating preamble
statements behind the record: the two
definePropertysites #7139 alreadyrecognises (reusing
CjsScaffolding::exempts_shape_barrierverbatim, sothe two exemptions cannot disagree about what scaffolding is) plus
require.cache = {}andrequire.extensions = { … }. That half is report-onlyin the strongest sense —
unbound_new_sitesis called exclusively underopt_report::enabled().The same dedup is why
statementrises 273 → 320 andcall argument175 →187: with the scaffolding row gone, 62 real user allocations that were being
masked by it become visible. Removing 379 scaffolding rows made 62 real ones
appear; net 317.
4. Evidence
Codegen-neutral, measured on emitted IR. Object-file hashes are not
usable here and saying so is part of the result: the same compiler run twice on
the same input differs by ~2 KB, because the
--no-linktemp output directory(
perry-objs-<pid>-<ts>) lands in the Mach-O debug records. So the comparisonis on
--trace llvmoutput over 49 dependency modules, each with asame-compiler control run, normalising exactly one pre-existing
nondeterminism (HashMap-ordered
js_register_function_namestring constants —16 of ~12 900 lines, itself inside the CJS preamble) and nothing else.
Behaviour, against the pinned oracle. A CJS dependency fixture
(
__esModule, class with methods, record literal, 1000-iteration loop) built asa
compilePackagesdependency: both arms produce byte-identical program output,byte-exact against Node 26.5.1. Anti-vacuity on the same fixture — the
report goes 5 candidates / 2 denied → 3 candidates / 0 denied, with the
same three selections and the same consumption sites.
Sabotage matrix — 16 rows, control green in all 17 runs.
mutable: falsea_mutable_record_binding_is_not_recogniseda_differently_named_record_binding_is_not_recogniseda_record_of_a_declared_class_is_not_recogniseda_record_literal_with_a_second_field_is_not_recogniseda_record_whose_exports_literal_is_not_empty_is_not_recognisedtwo_record_bindings_in_one_region_are_ambiguouswithout_the_module_alias_the_record_is_not_recognised,a_const_module_alias_does_not_satisfy_r4,a_module_alias_of_another_local_does_not_satisfy_r4,an_unrecognised_region_suppresses_nothingmutable: truea_const_module_alias_does_not_satisfy_r4a_module_alias_of_another_local_does_not_satisfy_r4requireliteral keysuser_allocations_are_never_suppressedthe_record_is_not_a_ptr_shape_candidatethe_report_walk_drops_the_scaffolding_allocations_onlythe_cjs_preamble_is_still_recognised_as_scaffolding_allocationvar modulealias{ exports: {} }literalrequire.cacheThe first pass had four GREEN holes, and all four were fixed in the code, not
in the tests. The binding name and the record-uniqueness count each had two
enforcement points that masked one another — either could be deleted with every
test still green — so each conjunct now has exactly one. And "is a record
recognised?" was a removable
if self.record.is_none() { return false; }thatno sabotage could kill, because
Default's scaffolding sets were empty anyway;the record and its bindings now live in one
Option<(u32, CjsScaffolding)>, so"unrecognised but still suppressing" has no representation and the losing
mode does not compile. That row is gone from the matrix rather than passing
vacuously.
Template canary (
cjs_wrap/preamble_canary_tests.rs, extending #7139's):wrap → parse → lower →
perry_codegen::cjs_preamble_census, with oneanti-vacuity assertion per conjunct so a template edit names the conjunct it
broke, an exact expected count of recognised preamble statements, and a
negative control that a module which was never
cjs_wrapped recognisesnothing.
Gates:
cargo test -p perry-codegen --lib472 passed (30 new);cargo test -p perry --bin perry preamble_canary4 passed;compiler_output_regression.py census --gategreen with no floor moved;cargo fmt --all -- --checkclean;scripts/check_file_size.shadds no file tothe over-2000 list (
ptr_shape.rswas pushed to 2005 by this change andbrought back to 1991 by collapsing the two early-bail arms).
5. What this does NOT do — stated plainly
Conversion to selected/consumed is ZERO, and that is by construction: R4
asserts every suppressed value was already denied.
selectedandconsumedare 3 and 11 in both arms, on every module. This PR moves no code, retires no
guard, and makes nothing faster. It removes 379 rows of Perry's own scaffolding
from a report whose readings scheduled #7149.
The dependency-JS wall is still rule 1 — allocations never bound to a local
— but its size needs restating rather than subtracting. In this corpus rule 1
goes 885 → 759 (188 scaffolding rows out, 62 masked user rows in). #7152's
506is a different 180-module sample; by the same per-module rate roughly 180of those 506 are this same literal, but the residual is not
506 − 180because of the unmasking above. Re-derive it against this compiler rather than
subtracting.
Unmeasured / caveats, in full:
gc_repsel_matrix.sh --arms all --pressure 8was NOT run to completion.It was started and killed during its compile phase: a sibling agent (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)
was running the identical 21-arm matrix concurrently and free disk on the
host had fallen from 52 GB to 13 GB, below the campaign's 15 GB floor. I
aborted mine so theirs could finish. The substitute evidence is §4's IR
comparison, which for a change that emits no code is the stronger statement:
the matrix asks "does behaviour survive 21 GC arms", the IR diff answers
"there is no behaviour to survive them, the emitted code is identical". Worth
re-running before merge if a reviewer wants the arm-liveness columns.
across processes on this host (see §4). IR neutrality is.
same-compiler control run differs:
perry_method_<Class>__<name>callemission (and the matching class-id compare constants) is HashMap-ordered, so
a module with the same method name on several classes —
vite'smodule-runner.js(EvaluatedModules.clear/HMRClient.clear),rollup'sshared/index.js— reorders between runs of the same binary. That is apre-existing compiler nondeterminism in the codegen: object emission is nondeterministic on Linux — the LLVM temp module name embeds pid + nanotime #7131 family, not introduced
here; the harness reports those cells as
control-failedrather thancounting them as identical, and no verdict is claimed for them.
Ptr<Shape>(#7034 §3) #7149's180-module set (that tree has drifted), so base-arm counts here are not
directly comparable to the numbers in repsel: dependency JS is walled by rule 1 (unbound allocations), not containment — 506 of 746 candidates #7152's table. Both arms in this PR
were measured on the same list with the same harness.
df7214b0d— the commit thisbranch was cut from — differing only by this patch. The branch has since been
rebased onto
5f5006aa6; a spot re-measurement on the rebased HEAD keeps thescaffolding result exactly (rule-2 bare reference 4, zero
__cjs_modulerows in any bucket) but moves the other buckets, because anunrelated
mainchange maderollup/dist/shared/index.js— a large modulethat failed to compile in both original arms — compile, adding ~109 denials
of its own. That is a different baseline, not a different result, and
mixing the two would be exactly the kind of A/B this campaign keeps warning
about. The pinned pair is the measurement; the re-run is a liveness check on
it.
future
cjs_wrapthat emits the preamble inside a conditional would stopbeing recognised — degrading to today's behaviour, and the canary would go
red.