perf(semantic): resolve_references_for_current_scope without a temp Vec (#22599)

## Summary

`SemanticBuilder::resolve_references_for_current_scope` is the early-resolution hook fired at function / arrow / catch boundaries — it resolves the references collected since a checkpoint against the outer scope chain so that e.g. `function f(x = outerRef) {}` correctly binds `outerRef` to the outer scope rather than the function body.

The old implementation copied the pending slice into a fresh `Vec` on every call:

```rust
let refs = self.unresolved_references.slice_from(checkpoint).to_vec();
self.unresolved_references.truncate(checkpoint);
for (name, reference_id) in refs { ... }
```

The `to_vec()` is a fresh heap allocation, needed because `walk_up_resolve_reference` takes `&mut self` and would otherwise alias the borrow on the underlying `Vec<(Ident, ReferenceId)>`.

Rewrite in-place with a retain-style write cursor — each `(Ident, ReferenceId)` is read by **value** (both are `Copy`), which detaches it from the borrow on the inner Vec. Unresolved refs are compacted forward; resolved refs are dropped via the final `truncate`. No temporary `Vec`, no allocation, **no `unsafe`** — safe bounds-checked indexing via new `get` / `set` methods on `UnresolvedReferences`.

```rust
let mut write_idx = checkpoint;
for read_idx in checkpoint..end {
    let (name, reference_id) = self.unresolved_references.get(read_idx);
    if !self.walk_up_resolve_reference(name, reference_id) {
        if write_idx != read_idx {
            self.unresolved_references.set(write_idx, name, reference_id);
        }
        write_idx += 1;
    }
}
self.unresolved_references.truncate(write_idx);
```

## Allocation impact

`cargo allocs`, `allocs_semantic.snap` (baseline = parent commit, after var-hoist landed):

| File | Size | Sys allocs before | Sys allocs after | Δ |
|---|---|---|---|---|
| `checker.ts` | 2.92 MB | 2,309 | **53** | **−2,256 (−98%)** |
| `App.tsx` | 415 kB | 174 | **35** | **−139 (−80%)** |
| `binder.ts` | 193 kB | 196 | **19** | **−177 (−90%)** |
| `kitchen-sink.tsx` | 733 kB | 1,320 | **290** | **−1,030 (−78%)** |
| `pdf.mjs` | 567 kB | 1,366 | 1,355 | −11 |
| `antd.js` | 6.69 MB | 1,072 | 1,072 | 0 (ES5 bundled code rarely closes over outer scope from function params) |
| `RadixUI.jsx` | 2.5 kB | 10 | 10 | 0 |

## How I found this

After [#22580](https://github.com/oxc-project/oxc/pull/22580) and [#22590](https://github.com/oxc-project/oxc/pull/22590) the remaining semantic sys allocs were unexplained. Ran a backtrace-capturing `System` allocator wrapper on `checker.ts` and aggregated allocations by call stack — the overwhelming majority of captured allocations converged on a single site:

```
oxc_semantic::builder::SemanticBuilder::resolve_references_for_current_scope
  oxc_semantic::builder::SemanticBuilder as ...::Visit::visit_function
  ...
```

Tracing back to the `slice_from(checkpoint).to_vec()` line and confirming that the entire allocation pattern disappears with the in-place rewrite.

## Why this matters for downstream consumers

`resolve_references_for_current_scope` fires once per function / arrow / catch — for normal TS / TSX code that's hundreds to thousands of calls per build. Each call was a fresh heap allocation; eliminating them removes that allocator pressure entirely.

For rolldown's preprocessing pipeline (which builds semantic 3-4 times per file across hundreds of files per bundle), the per-build savings compound.

## No behavior change

The in-place algorithm preserves the original semantics exactly: `unresolved_references[..checkpoint]` is untouched, and `[checkpoint..]` is the list of refs that didn't resolve, in their original order. Same as the old code.

## Test Plan

- [x] `cargo test -p oxc_semantic --lib --tests` — pass
- [x] `cargo allocs` — semantic snapshot updated to reflect the reductions

AI disclosure: drafted with Claude Code, reviewed manually.
This commit is contained in:
Dunqing committed 2026-05-21 07:56:28 +00:00
1 parent e862c1552b
commit 4f289f1e61
4 files changed
+52 -20

No files matched your search

+15 -7
View File
@@ -623,19 +623,27 @@ impl<'a> SemanticBuilder<'a> {
/// list for later resolution by `resolve_all_references` (which handles
/// forward references to declarations not yet visited).
fn resolve_references_for_current_scope(&mut self) {
// Process in-place using a retain-style write-cursor — no temporary
// `Vec`. Reads each `(name, reference_id)` by value out of the flat
// list (both fields are `Copy`), so calling `walk_up_resolve_reference`
// (which takes `&mut self`) doesn't conflict with the index read.
let checkpoint = self.unresolved_references_checkpoint;
let refs = self.unresolved_references.slice_from(checkpoint).to_vec();
if refs.is_empty() {
let end = self.unresolved_references.len();
if end <= checkpoint {
return;
}
self.unresolved_references.truncate(checkpoint);
for (name, reference_id) in refs {
let mut write_idx = checkpoint;
for read_idx in checkpoint..end {
let (name, reference_id) = self.unresolved_references.get(read_idx);
if !self.walk_up_resolve_reference(name, reference_id) {
// Keep in the flat list — may resolve later via forward declarations
self.unresolved_references.push(name, reference_id);
// Keep in the flat list — may resolve later via forward declarations.
if write_idx != read_idx {
self.unresolved_references.set(write_idx, name, reference_id);
}
write_idx += 1;
}
}
self.unresolved_references.truncate(write_idx);
}
pub(crate) fn add_redeclare_variable(
+27 -3
View File
@@ -42,10 +42,34 @@ impl<'a> UnresolvedReferences<'a> {
std::mem::take(&mut self.references)
}
/// Get a slice of references from `start` to end and the length for truncation.
/// Current number of unresolved references.
#[inline]
pub(crate) fn slice_from(&self, start: usize) -> &[(Ident<'a>, ReferenceId)] {
&self.references[start..]
pub(crate) fn len(&self) -> usize {
self.references.len()
}
/// Read a reference by index, by value.
///
/// Used by [`crate::SemanticBuilder::resolve_references_for_current_scope`]
/// to process the list in-place without allocating a temporary `Vec`. Both
/// `Ident<'a>` and `ReferenceId` are `Copy`, so this hands the caller an
/// owned pair that's detached from the underlying borrow.
///
/// # Panics
/// Panics if `idx >= self.len()`.
#[inline]
pub(crate) fn get(&self, idx: usize) -> (Ident<'a>, ReferenceId) {
self.references[idx]
}
/// Overwrite the reference at `idx` (write-cursor support for in-place
/// processing).
///
/// # Panics
/// Panics if `idx >= self.len()`.
#[inline]
pub(crate) fn set(&mut self, idx: usize, name: Ident<'a>, reference_id: ReferenceId) {
self.references[idx] = (name, reference_id);
}
/// Truncate the list to `len`, removing references at the end.
@@ -1,16 +1,16 @@
File | File size || Sys allocs | Sys reallocs || Arena allocs | Arena reallocs
---------------------------------------------------------------------------------------------------------------------------
checker.ts | 2.92 MB || 226 | 7 || 152662 | 28244
checker.ts | 2.92 MB || 170 | 7 || 152662 | 28244
App.tsx | 415.34 kB || 121 | 13 || 16995 | 2702
App.tsx | 415.34 kB || 119 | 13 || 16995 | 2702
RadixUIAdoptionSection.jsx | 2.52 kB || 24 | 0 || 31 | 6
pdf.mjs | 567.30 kB || 3946 | 393 || 47465 | 7740
pdf.mjs | 567.30 kB || 3926 | 393 || 47465 | 7740
antd.js | 6.69 MB || 4652 | 1622 || 333609 | 69349
binder.ts | 193.08 kB || 47 | 1 || 7076 | 824
binder.ts | 193.08 kB || 41 | 1 || 7076 | 824
kitchen-sink.tsx | 732.90 kB || 3169 | 345 || 100206 | 17853
kitchen-sink.tsx | 732.90 kB || 3138 | 345 || 100206 | 17853
@@ -1,16 +1,16 @@
File | File size || Sys allocs | Sys reallocs || Arena allocs | Arena reallocs
---------------------------------------------------------------------------------------------------------------------------
checker.ts | 2.92 MB || 2309 | 3 || 0 | 0
checker.ts | 2.92 MB || 53 | 3 || 0 | 0
App.tsx | 415.34 kB || 174 | 6 || 0 | 0
App.tsx | 415.34 kB || 35 | 6 || 0 | 0
RadixUIAdoptionSection.jsx | 2.52 kB || 10 | 0 || 0 | 0
pdf.mjs | 567.30 kB || 1366 | 196 || 0 | 0
pdf.mjs | 567.30 kB || 1355 | 196 || 0 | 0
antd.js | 6.69 MB || 1072 | 0 || 0 | 0
binder.ts | 193.08 kB || 196 | 0 || 0 | 0
binder.ts | 193.08 kB || 19 | 0 || 0 | 0
kitchen-sink.tsx | 732.90 kB || 1320 | 164 || 0 | 0
kitchen-sink.tsx | 732.90 kB || 290 | 164 || 0 | 0