perf(minifier): index SymbolValues by SymbolId (#22441)

Stacked on top of #22425.

## Summary

Swap `SymbolValues`'s backing store from `FxHashMap<SymbolId, SymbolValue>` to `IndexVec<SymbolId, Option<SymbolValue>>`. Symbol IDs are dense `u32`s, so indexed access drops the hash + probe on every hot path — `inline_identifier_reference`, `is_symbol_mutated`, and the dead-code checks in `remove_unused_expression` / `minimize_statements`.

The buffer is sized once from `Scoping::symbols_len()` at `MinifierState::new` and reset in place between peephole iterations, so the whole fixed-point loop stays on the indexed-write fast path. No minifier pass currently mints `SymbolId`s mid-run (verified across `peephole/`, `normalize/`, the mangler, and `keep_var.rs` — only `traverse_context/` defines `generate_uid_name` / `create_symbol`, with zero callers), so `init_value` is a single indexed write. If a future pass starts minting UIDs, that write panics on out-of-range — the signal to add a grow path.

Local harness running `Compressor::build_with_scoping` against `TestFiles::minimal()` (`RadixUIAdoptionSection.jsx`, `react.development.js`, `cal.com.tsx`, `binder.ts`), 27 paired cycles in randomized order after warm-up:

| binary                | min       | median    | p75       | std      |
|-----------------------|----------:|----------:|----------:|---------:|
| bitset base (#22425)  | 6 905 125 | 6 958 167 | 6 975 501 |   37 944 |
| this PR on top        | 6 693 875 | 6 750 875 | 6 774 626 |   40 482 |

Paired delta: this PR wins **27 / 27** cycles, median **−207 µs (−2.97 %)**, sign-test p < 0.000001. `cargo test -p oxc_minifier --tests` passes 494/494; allocation snapshot shows reduced sys allocs on every file.
This commit is contained in:
Dunqing committed 2026-05-15 14:54:53 +00:00
1 parent d782b78d5f
commit 217d7d8a1c
5 files changed
+44 -18

No files matched your search

+3 -2
View File
@@ -30,7 +30,8 @@ impl<'a> Compressor<'a> {
options: CompressOptions,
) -> u8 {
let max_iterations = options.max_iterations;
let state = MinifierState::new(program.source_type, options, /* dce */ false);
let state =
MinifierState::new(program.source_type, options, /* dce */ false, &scoping);
let mut ctx = ReusableTraverseCtx::new(state, scoping, self.allocator);
let normalize_options = NormalizeOptions {
convert_while_to_fors: true,
@@ -54,7 +55,7 @@ impl<'a> Compressor<'a> {
options: CompressOptions,
) -> u8 {
let max_iterations = options.max_iterations;
let state = MinifierState::new(program.source_type, options, /* dce */ true);
let state = MinifierState::new(program.source_type, options, /* dce */ true, &scoping);
let mut ctx = ReusableTraverseCtx::new(state, scoping, self.allocator);
let normalize_options = NormalizeOptions {
convert_while_to_fors: false,
+1 -1
View File
@@ -160,7 +160,7 @@ impl<'a> PeepholeOptimizations {
impl<'a> Traverse<'a> for PeepholeOptimizations {
fn enter_program(&mut self, _program: &mut Program<'a>, ctx: &mut TraverseCtx<'a>) {
ctx.state.symbol_values.clear();
ctx.state.symbol_values.reset();
ctx.state.proto_write_symbols.clear();
ctx.state.changed = false;
}
+8 -2
View File
@@ -2,6 +2,7 @@ use oxc_ecmascript::constant_evaluation::ConstantValue;
use rustc_hash::{FxHashMap, FxHashSet};
use oxc_data_structures::stack::NonEmptyStack;
use oxc_semantic::Scoping;
use oxc_span::SourceType;
use oxc_str::Str;
use oxc_syntax::symbol::SymbolId;
@@ -33,13 +34,18 @@ pub struct MinifierState<'a> {
}
impl MinifierState<'_> {
pub fn new(source_type: SourceType, options: CompressOptions, dce: bool) -> Self {
pub fn new(
source_type: SourceType,
options: CompressOptions,
dce: bool,
scoping: &Scoping,
) -> Self {
Self {
source_type,
options,
dce,
pure_functions: FxHashMap::default(),
symbol_values: SymbolValues::default(),
symbol_values: SymbolValues::new(scoping.symbols_len()),
class_symbols_stack: ClassSymbolsStack::new(),
proto_write_symbols: FxHashSet::default(),
changed: false,
+27 -8
View File
@@ -1,6 +1,5 @@
use rustc_hash::FxHashMap;
use oxc_ecmascript::constant_evaluation::ConstantValue;
use oxc_index::IndexVec;
use oxc_syntax::{scope::ScopeId, symbol::SymbolId};
#[derive(Debug)]
@@ -28,21 +27,41 @@ pub struct SymbolValue<'a> {
pub scope_id: ScopeId,
}
#[derive(Debug, Default)]
/// Per-symbol scratch store indexed by `SymbolId`.
///
/// Symbol IDs are dense `u32`s, so an indexed `IndexVec` lookup beats a
/// `FxHashMap` (hash + probe) on every hot path in the peephole pass.
///
/// Sized once from `Scoping::symbols_len()`; no minifier pass mints new
/// symbols, so `init_value` panics on out-of-range — that's the signal to
/// add a grow path.
#[derive(Debug)]
pub struct SymbolValues<'a> {
values: FxHashMap<SymbolId, SymbolValue<'a>>,
values: IndexVec<SymbolId, Option<SymbolValue<'a>>>,
}
impl<'a> SymbolValues<'a> {
pub fn clear(&mut self) {
self.values.clear();
pub(crate) fn new(len: usize) -> Self {
let mut values = IndexVec::with_capacity(len);
values.resize_with(len, || None);
Self { values }
}
/// Reset slots to `None` without releasing the buffer, so the next peephole
/// iteration's `init_value` stays on the indexed-write fast path.
pub fn reset(&mut self) {
for slot in &mut self.values {
*slot = None;
}
}
#[inline]
pub fn init_value(&mut self, symbol_id: SymbolId, symbol_value: SymbolValue<'a>) {
self.values.insert(symbol_id, symbol_value);
self.values[symbol_id] = Some(symbol_value);
}
#[inline]
pub fn get_symbol_value(&self, symbol_id: SymbolId) -> Option<&SymbolValue<'a>> {
self.values.get(&symbol_id)
self.values.get(symbol_id)?.as_ref()
}
}
@@ -1,14 +1,14 @@
File | File size || Sys allocs | Sys reallocs || Arena allocs | Arena reallocs
---------------------------------------------------------------------------------------------------------------------------
checker.ts | 2.92 MB || 238 | 37 || 152662 | 28244
checker.ts | 2.92 MB || 226 | 37 || 152662 | 28244
cal.com.tsx | 1.06 MB || 16296 | 46 || 37156 | 4570
cal.com.tsx | 1.06 MB || 16287 | 46 || 37156 | 4570
RadixUIAdoptionSection.jsx | 2.52 kB || 24 | 6 || 31 | 6
pdf.mjs | 567.30 kB || 4026 | 417 || 47474 | 7746
pdf.mjs | 567.30 kB || 4016 | 417 || 47474 | 7746
antd.js | 6.69 MB || 7638 | 1657 || 333609 | 69349
antd.js | 6.69 MB || 7625 | 1657 || 333609 | 69349
binder.ts | 193.08 kB || 54 | 23 || 7076 | 824
binder.ts | 193.08 kB || 47 | 23 || 7076 | 824