mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 18:43:44 +00:00
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:
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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user