feat(minifier): collect whole-program symbol liveness during compression#24350
feat(minifier): collect whole-program symbol liveness during compression#24350Dunqing wants to merge 4 commits into
Conversation
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has enabled the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
Merging this PR will degrade performance by 5.76%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
d7b1048 to
6893f78
Compare
7defb37 to
0f64006
Compare
0e84107 to
984ae8d
Compare
0f64006 to
78da987
Compare
984ae8d to
3151c40
Compare
1d41780 to
111c30d
Compare
3151c40 to
45f02d1
Compare
111c30d to
40c6046
Compare
40c6046 to
07fa3ab
Compare
07fa3ab to
111c30d
Compare
45f02d1 to
a9af17f
Compare
9a6bb95 to
2f6e011
Compare
a9af17f to
4e88e2b
Compare
2f6e011 to
a152fbe
Compare
a152fbe to
daac42d
Compare
4e88e2b to
cdc877f
Compare
ea0c4b2 to
daac42d
Compare
f9d669b to
d2cb6e2
Compare
cdc877f to
c415c23
Compare
d2cb6e2 to
2fa0d96
Compare
86e3737 to
c405a12
Compare
|
The implementation direction looks well-covered, but the current The new |
5abf9a7 to
6dab15b
Compare
Reference counting cannot tell that a declaration is dead when its only
references live inside itself or inside a cycle: `function c() { d() }
function d() { c() }` never reaches zero references. Removing these
needs reachability, not counts.
This is the analysis skeleton, dark: references in executing code are
roots; references inside a candidate function declaration's body are
edges that count only once the candidate itself is live; a dead cycle
is never reached, so there is no cycle detection. Candidates are
function declarations in ESM modules only — in strict ESM the
observability edge cases (script-globals, Annex B aliases) cannot
occur, and every other source type takes a zero-cost off path.
There is no standalone walk: Normalize seeds the first set, every
peephole pass re-collects while it mutates, and every flush publishes a
fresh set, always exactly one pass stale. References minted behind the
traversal cursor (the typeof rewrite) are logged and force-rooted at
the flush. Output does not change in this commit.
Removing a dead cycle deletes the references it held, so a reference count is not authoritative afterwards: a binding importers observe can reach zero references the moment the analysis removes the cycle that held its last read — and the count-based removal arm would then delete the very declaration the analysis meant to keep. `export var f;` carries no reference at all, yet a sibling `var f = ...` is a perfectly removable site once `f`'s count hits zero. Pin those bindings: export-wrapped declarations, and for-in/of head and `using` declarators (no removal site handles them, so their survival must not depend on counts). A pin is published alongside the dead set at every flush and, once consumed, vetoes BOTH removal arms. In a module these three rows are the complete table — script-globals and Annex B aliases are exactly why sloppy sources stay un-analyzed. Still dark: output does not change in this commit.
The in-pass collection interleaves with the mutating passes, so its one honest failure mode is publishing a live-referenced symbol as dead. In debug builds every flushed set is now checked against an INDEPENDENT standalone walk of the settled tree — its own visitor, its own export/for-head state, the same shared gate predicates — so the whole test and conformance corpus doubles as a differ between the collection and ground truth. A deliberate second implementation: sharing traversal code with the hooks would make the oracle blind to exactly the bugs it exists to catch.
…ection Module-resolved fixtures pay the collection's arena cost; script-resolved fixtures change only by the state's empty-bitset construction. System allocation counts are unchanged on every fixture.
6dab15b to
a46ba8a
Compare
|
Closing as superseded by #24125. The final implementation no longer uses the standalone traversal-time collector from this PR: #24125 replaces it with a post-flush semantic function graph and includes both analysis and removal consumers in one reviewable change. #24125 now targets main directly, so keeping this intermediate stacked PR open would describe and ask reviewers to evaluate a design that is no longer used. |

Reference counting cannot tell that a declaration is dead when its only references live inside itself or inside a cycle:
function c() { d() } function d() { c() }never reaches zero references. Removing these needs reachability, not counts. This PR adds the analysis; the PR above consumes it at the removal sites. Output does not change here.References in executing code are roots. References inside a candidate declaration's body only count once that candidate itself is live. A dead cycle is never reached, so there is no cycle detection. Candidacy is function declarations only — declarator and class candidacy were built, hardened, and then measured to contribute exactly zero output bytes on 17 real artifacts, so they were cut.
The analysis runs for ES modules only. In strict ESM the hard cases cannot occur: Annex B block-function aliases are sloppy-only, and module top-level bindings are module-local, not cross-script-observable globals. Sloppy sources (scripts,
.cjs) skip the analysis entirely — the existing option-off fast path: zero allocation, and per node only the one disarmed-hook boolean check — and behave exactly as main, byte-for-byte. Enabling sloppy sources, with the script-global pins and conservative Annex B handling they need, is a planned follow-up; every piece of it exists verified in this branch's history.There is no standalone analysis walk. The analysis is collected during the traversals the minifier already runs: Normalize seeds it, every peephole pass re-collects, every flush propagates and swaps in the fresh set. The set consumed by a pass is always exactly one pass stale.
Three kinds of declarations are pinned — counted live no matter what the numbers say — because removing a dead cycle discards the references it held, so a reference count of zero does not mean unobservable afterwards: export-wrapped declarations (importers observe the binding),
for-in/of heads (no removal site handles them), andusingdeclarators (removal always bails). The one rewrite the collection cannot see — a bound reference minted behind the traversal cursor (the typeof rewrite is the only minter today) — is logged and force-rooted at the flush. In debug builds every flushed set is checked against a standalone ground-truth walk, so the whole test and conformance corpus doubles as a checker.Per-pass freshness is the load-bearing choice, and the cheaper cadences were each built and falsified rather than assumed away. A one-shot variant — collect during Normalize, publish once, consume in the first pass — was fully implemented: byte-identical on the minsize fixtures at roughly half the overhead, but real npm packages expose cycles mid-loop through rewrites no collection-time analysis can anticipate. In js_of_ocaml-compiled bundles (flow-parser, prettier's flow plugins, @vue/server-renderer), dropping
t0 && B0(...)onceB0is proven pure removes a cycle's last live read; under one-shot the cycle then survives this minify and falls on a re-minify, and monitor-oxc's idempotency gate fails on exactly those files. A record-time dead-branch filter and a two-shot cadence were also built and falsified. Only per-pass re-collection passes the gate.Cost, measured with paired interleaved runs against main (wall time, includes parse and codegen): antd.js (6.7 MB script) +1.4% (+1.0ms) — the analysis never constructs for scripts and the residue is one boolean check per node, down from +2.4% when scripts were analyzed; vue.runtime.esm-browser (385 kB module) +1.5% (+0.2ms) and a rolldown-built Vue chunk (197 kB module) +2.0% (+0.2ms) — module files of rolldown's per-module size sit at the sub-millisecond noise floor.
Output is byte-identical on every fixture, and iteration counts do not change. The minsize corpus parses as sloppy script, so it is untouched by construction; the size win lands with the consumer PR's module-shaped inputs and, for scripts, with the follow-up.
Review map: the mechanism is
symbol_liveness/mod.rsbelow the module doc (read the doc's three rules first — everything else is their application);validate.rsis the debug-only mirror, mechanical by design; the hook blocks innormalize.rs/peephole/mod.rsare identical twins. The one paragraph to scrutinize is the pin rationale in the module doc: "removing a dead cycle deletes the references it held, so counts lie afterward."Implemented with AI assistance (Claude Code); design, review, and verification driven by the author per the repo AI-usage policy.