Skip to content

feat(minifier): collect whole-program symbol liveness during compression#24350

Closed
Dunqing wants to merge 4 commits into
mainfrom
minifier-symbol-liveness
Closed

feat(minifier): collect whole-program symbol liveness during compression#24350
Dunqing wants to merge 4 commits into
mainfrom
minifier-symbol-liveness

Conversation

@Dunqing

@Dunqing Dunqing commented Jul 10, 2026

Copy link
Copy Markdown
Member

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), and using declarators (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(...) once B0 is 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.rs below the module doc (read the doc's three rules first — everything else is their application); validate.rs is the debug-only mirror, mechanical by design; the hook blocks in normalize.rs/peephole/mod.rs are 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.

Dunqing commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent changes, fast-track this PR to the front of 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.

@codspeed-hq

codspeed-hq Bot commented Jul 10, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 5.76%

❌ 3 regressed benchmarks
✅ 49 untouched benchmarks
⏩ 19 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation minifier[binder.ts] 3.6 ms 3.9 ms -8.32%
Simulation minifier[kitchen-sink.tsx] 52 ms 54.9 ms -5.4%
Simulation minifier[App.tsx] 11.7 ms 12.1 ms -3.48%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing minifier-symbol-liveness (a46ba8a) with main (91541dd)2

Open in CodSpeed

Footnotes

  1. 19 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (4279eca) during the generation of this report, so 91541dd was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@Dunqing
Dunqing changed the base branch from minifier-class-heritage-throws to graphite-base/24350 July 11, 2026 14:31
@Dunqing
Dunqing force-pushed the graphite-base/24350 branch from d7b1048 to 6893f78 Compare July 11, 2026 14:31
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch from 7defb37 to 0f64006 Compare July 11, 2026 14:31
@Dunqing
Dunqing changed the base branch from graphite-base/24350 to minifier-class-removability-classifier July 11, 2026 14:31
@graphite-app
graphite-app Bot force-pushed the minifier-class-removability-classifier branch 2 times, most recently from 0e84107 to 984ae8d Compare July 11, 2026 14:49
@graphite-app
graphite-app Bot force-pushed the minifier-symbol-liveness branch from 0f64006 to 78da987 Compare July 11, 2026 14:49
@Dunqing
Dunqing force-pushed the minifier-class-removability-classifier branch from 984ae8d to 3151c40 Compare July 12, 2026 12:47
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch 2 times, most recently from 1d41780 to 111c30d Compare July 13, 2026 00:15
@Dunqing
Dunqing force-pushed the minifier-class-removability-classifier branch from 3151c40 to 45f02d1 Compare July 13, 2026 00:15
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch from 111c30d to 40c6046 Compare July 13, 2026 01:50
@Dunqing Dunqing changed the title feat(minifier): collect whole-program symbol liveness during compression feat(minifier): compute whole-program symbol liveness during normalization Jul 13, 2026
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch from 40c6046 to 07fa3ab Compare July 13, 2026 02:13
@Dunqing
Dunqing marked this pull request as ready for review July 13, 2026 02:33
@Dunqing
Dunqing requested a review from overlookmotel as a code owner July 13, 2026 02:33
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch from 07fa3ab to 111c30d Compare July 13, 2026 03:06
@Dunqing Dunqing changed the title feat(minifier): compute whole-program symbol liveness during normalization feat(minifier): collect whole-program symbol liveness during compression Jul 13, 2026
@Dunqing
Dunqing force-pushed the minifier-class-removability-classifier branch from 45f02d1 to a9af17f Compare July 13, 2026 08:15
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch 3 times, most recently from 9a6bb95 to 2f6e011 Compare July 13, 2026 09:04
@Dunqing
Dunqing force-pushed the minifier-class-removability-classifier branch from a9af17f to 4e88e2b Compare July 13, 2026 09:04
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch from 2f6e011 to a152fbe Compare July 13, 2026 09:30
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch from a152fbe to daac42d Compare July 14, 2026 00:51
@Dunqing
Dunqing force-pushed the minifier-class-removability-classifier branch from 4e88e2b to cdc877f Compare July 14, 2026 00:51
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch from ea0c4b2 to daac42d Compare July 14, 2026 01:43
@graphite-app
graphite-app Bot changed the base branch from minifier-class-removability-classifier to graphite-base/24350 July 14, 2026 13:59
@graphite-app
graphite-app Bot force-pushed the minifier-symbol-liveness branch from f9d669b to d2cb6e2 Compare July 14, 2026 14:04
@graphite-app
graphite-app Bot force-pushed the graphite-base/24350 branch from cdc877f to c415c23 Compare July 14, 2026 14:04
@graphite-app
graphite-app Bot changed the base branch from graphite-base/24350 to main July 14, 2026 14:05
@graphite-app
graphite-app Bot force-pushed the minifier-symbol-liveness branch from d2cb6e2 to 2fa0d96 Compare July 14, 2026 14:05
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch 2 times, most recently from 86e3737 to c405a12 Compare July 14, 2026 16:03
@jeevan6996

Copy link
Copy Markdown
Contributor

The implementation direction looks well-covered, but the current Doc check is failing on a broken intra-doc link:

error: unresolved link to `MinifierState::symbol_is_pinned`
--> crates/oxc_minifier/src/state.rs:134:11

The new pinned_symbols field doc references [MinifierState::symbol_is_pinned], but I do not see that method being added in this patch. Looks like this either needs to link to the actual helper that reads the pin state, or be plain code text (MinifierState::symbol_is_pinned) if the helper is going to be added later.

@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch 2 times, most recently from 5abf9a7 to 6dab15b Compare July 15, 2026 02:41
Dunqing added 4 commits July 15, 2026 13:19
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.
@Dunqing
Dunqing force-pushed the minifier-symbol-liveness branch from 6dab15b to a46ba8a Compare July 15, 2026 05:21
@Dunqing

Dunqing commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

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.

@Dunqing Dunqing closed this Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-minifier Area - Minifier

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants