perf(minifier): fix O(n²) perf on very many var decls (#21062)

What
---

Fix two O(n²) bottlenecks in the compressor that cause severe slowdowns
on bundler output (esbuild, webpack CJS) containing thousands of
root-level `var import_X = __toESM(require_Y())` declarations, the
pattern reported in #17369, where a file only 6% larger took 2.5x longer
to minify.

Fixes #17369

Specifically:

Batch reference retention in `exit_program`
---

After each compressor iteration, stale references are cleaned up by
diffing live references against the symbol table. The per-reference
`delete_resolved_reference` method uses `.position()` internally, and
that's a linear scan of the symbol's reference list, so repeated
deletions are O(n²) when many references to the same symbol are removed.

Replace with a new `Scoping::retain_resolved_references` method that
iterates each symbol's reference list once, retaining only references
present in the live set. Total cost: O(total_refs).

Cached `is_symbol_mutated` helper
---

`Scoping::symbol_is_mutated(symbol_id)` scans all resolved references
for the symbol looking for a write. For heavily-referenced symbols like
`__toESM` (10k+ refs in the issue's bad.js) each call is O(num_refs),
and when it is called repeatedly during compression the total becomes
O(n²).

Add `PeepholeOptimizations::is_symbol_mutated`, which reads the
`write_references_count` already cached in `SymbolValue` (populated
during `exit_variable_declarator` → `init_symbol_value`). This is O(1).
It falls back to the full scan for symbols without cached values
(function declarations and other non-variable bindings).

Use this helper instead of `Scoping::symbol_is_mutated` in two places:

- Directly in `substitute_single_use_symbol_in_expression`, which walks
target expressions looking for single-use symbols to inline. When it
encounters an identifier it checks whether reordering the replacement
past that identifier is safe. With ~10k substitution attempts per
iteration on bad.js, this was the dominant O(n²) contributor.

- Via `is_expression_that_reference_may_change`, which checks whether
the base object of a member-expression assignment target can be mutated.
It is called from:
- `substitute_single_use_symbol_in_expression`, when the assignment
target is `foo.bar = …`
- `member_object_may_be_mutated`, which is called from
`minimize_logical_expression` and `remove_unused_expression`

Performance
---

On the real-world files from #17369, on my machine, the time taken by
```
Compressor::new(&allocator).build_with_scoping(
    &mut program,
    scoping,
    CompressOptions::smallest(),
)
```
changes thus:

```
good.js (10.4 MB):  48.61 → 47.56 ms  (2.2% faster, or maybe that's just noise)
bad.js  (11.1 MB): 389.18 → 57.18 ms  (6.8x faster)
ratio bad/good:      8.0x →  1.2x
```

The total minification time (parse + compress + mangle) changes thus:
```
good.js (10.4 MB):  91.50 → 91.04 ms  (0.5% faster, probably mostly noise!)
bad.js  (11.1 MB): 436.28 → 104.40 ms  (4.2x faster)
ratio bad/good:      4.8x →  1.15x
```

For both compress and total, the bad/good ratio is now much closer to
the ratio between the file sizes.
This commit is contained in:
Gunnlaugur Thor Briem authored and GitHub committed 2026-04-08 14:09:50 +08:00
1 parent 4da53e5cfd
commit 61adedd6f5
4 files changed
+46 -14

No files matched your search

@@ -1308,7 +1308,7 @@ impl<'a> PeepholeOptimizations {
// If the identifier is not a getter and the identifier is read-only,
// we know that the value is same even if we reordered the expression.
if let Some(symbol_id) = ctx.scoping().get_reference(id.reference_id()).symbol_id()
&& !ctx.scoping().symbol_is_mutated(symbol_id)
&& !Self::is_symbol_mutated(symbol_id, ctx)
{
return None;
}
+25 -7
View File
@@ -19,6 +19,7 @@ mod substitute_alternate_syntax;
use oxc_ast_visit::Visit;
use oxc_semantic::ReferenceId;
use oxc_syntax::symbol::SymbolId;
use rustc_hash::FxHashSet;
use oxc_allocator::Vec;
@@ -95,7 +96,7 @@ impl<'a> PeepholeOptimizations {
Expression::Identifier(id) => {
if let Some(symbol_id) = ctx.scoping().get_reference(id.reference_id()).symbol_id()
{
ctx.scoping().symbol_is_mutated(symbol_id)
Self::is_symbol_mutated(symbol_id, ctx)
} else {
true
}
@@ -104,6 +105,21 @@ impl<'a> PeepholeOptimizations {
_ => true,
}
}
/// Check if a symbol is mutated, using the O(1) cached `write_references_count`
/// from `SymbolValue` when available, falling back to the O(num_refs) scan in
/// `Scoping::symbol_is_mutated` for symbols without cached values.
///
/// Only variable declarators have cached values (populated during
/// `exit_variable_declarator` → `init_symbol_value`); function declarations
/// and other binding kinds still take the fallback path.
fn is_symbol_mutated(symbol_id: SymbolId, ctx: &TraverseCtx<'a>) -> bool {
if let Some(sv) = ctx.state.symbol_values.get_symbol_value(symbol_id) {
sv.write_references_count > 0
} else {
ctx.scoping().symbol_is_mutated(symbol_id)
}
}
}
impl<'a> Traverse<'a> for PeepholeOptimizations {
@@ -114,14 +130,16 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
fn exit_program(&mut self, program: &mut Program<'a>, ctx: &mut TraverseCtx<'a>) {
if ctx.state.changed {
// Remove unused references by visiting the AST again and diff the collected references.
let refs_before =
ctx.scoping().resolved_references().flatten().copied().collect::<FxHashSet<_>>();
// Remove stale references by collecting the set of live references from
// the AST and retaining only those in each symbol's reference list.
// This is done as a batch rather than deleting references one at a time,
// because individual deletion (`delete_resolved_reference`) uses a linear
// scan (`.position()`) per call, so removing many references to the same
// symbol is O(n²) (happens in bundler output with thousands of unused
// `var import_X = __toESM(require_Y())` declarations).
let mut counter = ReferencesCounter::default();
counter.visit_program(program);
for reference_id_to_remove in refs_before.difference(&counter.refs) {
ctx.scoping_mut().delete_reference(*reference_id_to_remove);
}
ctx.scoping_mut().retain_resolved_references(&counter.refs);
}
// Only check class_symbols_stack in full optimization mode (not DCE mode)
debug_assert!(ctx.state.dce || ctx.state.class_symbols_stack.is_exhausted());
+14
View File
@@ -572,6 +572,20 @@ impl Scoping {
});
}
/// Retain only resolved references that are in the given set.
///
/// This is an O(n) batch operation across all symbols, much more efficient than
/// calling `delete_resolved_reference` repeatedly when many references from the
/// same symbol need to be removed (which would be O(n²) due to the linear scan
/// in each deletion).
pub fn retain_resolved_references(&mut self, live_references: &FxHashSet<ReferenceId>) {
self.cell.with_dependent_mut(|_allocator, cell| {
for reference_ids in &mut cell.resolved_references {
reference_ids.retain(|id| live_references.contains(id));
}
});
}
/// Reserve additional capacity for symbols, references, and scopes.
pub fn reserve(
&mut self,
@@ -1,14 +1,14 @@
File | File size || Sys allocs | Sys reallocs || Arena allocs | Arena reallocs | Arena bytes
-------------------------------------------------------------------------------------------------------------------------------------------
checker.ts | 2.92 MB || 333 | 37 || 152659 | 28244
checker.ts | 2.92 MB || 285 | 37 || 152659 | 28244
cal.com.tsx | 1.06 MB || 20242 | 46 || 37147 | 4570
cal.com.tsx | 1.06 MB || 20214 | 46 || 37147 | 4570
RadixUIAdoptionSection.jsx | 2.52 kB || 34 | 6 || 30 | 6
RadixUIAdoptionSection.jsx | 2.52 kB || 29 | 6 || 30 | 6
pdf.mjs | 567.30 kB || 3994 | 417 || 47464 | 7734
pdf.mjs | 567.30 kB || 3968 | 417 || 47464 | 7734
antd.js | 6.69 MB || 7751 | 1657 || 331673 | 69344
antd.js | 6.69 MB || 7687 | 1657 || 331673 | 69344
binder.ts | 193.08 kB || 78 | 23 || 7075 | 824
binder.ts | 193.08 kB || 66 | 23 || 7075 | 824