Skip to content

perf: cache last source/name indexOf result in _serializeMappings - #58

Merged
7rulnik merged 1 commit into
mainfrom
perf/serialize-indexof-memo
May 10, 2026
Merged

perf: cache last source/name indexOf result in _serializeMappings#58
7rulnik merged 1 commit into
mainfrom
perf/serialize-indexof-memo

Conversation

@7rulnik

@7rulnik 7rulnik commented May 10, 2026

Copy link
Copy Markdown
Owner

Summary

SourceMapGenerator._serializeMappings calls this._sources.indexOf(mapping.source) and this._names.indexOf(mapping.name) once per mapping. The underlying ArraySet.indexOf does a Map.get(str) — cheap individually, but consecutive mappings almost always share their source string (bundler output groups by source) and frequently share their name string too, so the lookup is mostly redundant.

This PR adds single-slot caches — (lastSourceStr, lastSourceIdx) and (lastNameStr, lastNameIdx) — kept across loop iterations. On a cache hit (same string as last call) we skip indexOf; on a miss we fall back and refresh the slot. Identity-checked with ===, no allocation. Public surface unchanged.

Bench

scripts/bench-diff.sh main generate (two-process, SOLO=1 PHASES=generate, node v24.13.0):

Fixture cand ops/sec base ops/sec Δ
amp.js.map 379 350 +8.3%
babel.min.js.map 54 53 +1.9%
issue-41.js.map 85 74 +14.8%
preact.js.map 10.6k 10.2k +4.0%
react.js.map 4.0k 3.8k +4.0%
vscode.map 6 6 +5.0%
mean +6.3%

Tests

  • All 180 existing tests pass.
  • yarn test:coverage thresholds (98/97/96) green:
    • lib/source-map-generator.js: 100/100/100
    • all files: 98.24 / 97.19 / 96.67

Consecutive mappings almost always share `mapping.source` (and frequently
`mapping.name`) — bundler output groups mappings by source, then by name.
The previous code called `this._sources.indexOf(mapping.source)` and
`this._names.indexOf(mapping.name)` on every iteration, each hitting the
ArraySet's Map.get for a string already resolved on the previous pass.

Single-slot caches (`lastSourceStr → lastSourceIdx`, same for name) skip
the redundant lookup whenever the string matches. On miss, fall back to
indexOf and refresh the slot. No public surface change.

bench-diff.sh main generate (two-process, SOLO=1 PHASES=generate):

  amp.js.map        +8.3%
  babel.min.js.map  +1.9%
  issue-41.js.map  +14.8%
  preact.js.map     +4.0%
  react.js.map      +4.0%
  vscode.map        +5.0%
  mean              +6.3%
@7rulnik
7rulnik merged commit 4f1360f into main May 10, 2026
3 checks passed
@7rulnik
7rulnik deleted the perf/serialize-indexof-memo branch May 10, 2026 18:52
7rulnik added a commit that referenced this pull request May 10, 2026
…eMap (#64)

* perf: typed-array MappingList — slab storage + slab-direct serializer + applySourceMap rebuild

Replace lib/mapping-list.js's `Array<{...mappingObject}>` backing store with
an Int32Array slab. 6 i32 slots per mapping (genLine, genCol, srcIdx,
origLine, origCol, nameIdx), -1 sentinel for "no value". Source/name
strings live in the owning SourceMapGenerator's ArraySets; MappingList
stores the resolved indices so the serializer doesn't need a per-mapping
`indexOf` (which previously hit the ArraySet's Map.get even after the
single-slot indexOf cache from #58).

Per bench-data-followups #2 — generator memory ratio ≈ generator speed
ratio. The win is heap-pressure / GC / cache: 350k mapping objects per
addMapping cycle becomes 350k * 24 bytes contiguous slab.

`MappingList.add(genLine, genCol, origLine, origCol, srcIdx, nameIdx)`
takes positional integer args. `SourceMapGenerator.addMapping` resolves
sources/names through the existing ArraySets (which addMapping was already
calling) and passes the integer index in. No more `{ ... }` object literal
allocation per addMapping.

`SourceMapGenerator.applySourceMap` no longer mutates mapping objects
through the unsortedForEach callback — that pattern relied on the callback
receiving the actual stored reference, which slab storage can't provide.
The rewrite walks the old slab via the F_* constants, applies the
transformation, and emits into a fresh MappingList bound to the new
sources/names ArraySets, then swaps all three at once.

`SourceMapGenerator._serializeMappings` reads the slab directly instead of
toArray(). Source/name fields stored in the slab are already the int
indices needed for serialization (resolved when added) — no per-mapping
`indexOf` lookup. The compareByGeneratedPositionsInflated dedup check is
replaced with `ml._equalsPrev(i)` — a slab method that does six i32
equality checks. Same equality classes as the old check (same srcIdx ⇔
same source string after interning) so the dedup still works.

`unsortedForEach` and `toArray` remain API-compatible by materializing
JS objects on demand from the slab. Internal hot paths bypass them.

* perf: BasicSourceMapConsumer.fromSourceMap reads MappingList slab directly

Drop the `aSourceMap._mappings.toArray().slice()` materialization. The
generator's MappingList already stores source/name as integer indices
into its _sources / _names ArraySets, and the new consumer's _sources /
_names were just initialized from the same toArray() output — so the
indices are identical and no per-mapping `indexOf` is needed.

Reads i32 fields straight from `ml._buf` via the F_* layout constants
exported from `lib/mapping-list.js`.

* test: coverage for typed-array MappingList and slab paths

- test/internal/test-mapping-list.js (new): unit tests for the MappingList
  surface — unsortedForEach / toArray materialization, the 6-level
  sortedness cascade in `add`, the matching cascade in `_sort`'s
  comparator, the `_equalsPrev` dedup helper, and slab capacity growth.
- test/internal/test-source-map-consumer-internals.js: add a fromSourceMap
  test that exercises a source-less generated mapping (case-1 per
  _validateMapping) so the `srcIdx === -1` slab-read branch is covered.
- test/internal/test-util.js: pin direct genLine/genCol coverage for
  compareByGeneratedPositionsInflated. The function was previously
  covered transitively through MappingList.add's generatedPositionAfter;
  the new slab-backed MappingList doesn't call it, so those early-return
  branches need direct test coverage.

* test: cover applySourceMap with source-less mappings + drop unreachable origCol guard

Two coverage holes flagged by the per-branch lcov:
- lib/source-map-generator.js:253 — the `srcIdx === -1 ? null : ...`
  ternary's `null` arm only fires when applySourceMap walks a source-less
  mapping, and existing applySourceMap tests are all fully-sourced.
- lib/source-map-generator.js:259 — `origCol === -1 ? 0 : origCol` was
  unreachable. addMapping sets `origLine` and `origCol` together, so
  inside the `origLine !== -1` branch above, `origCol === -1` never
  fires. Dropping the guard removes the dead branch.

Adds an applySourceMap test in test-source-map-consumer-internals.js
that feeds the outer generator a source-less mapping alongside a
fully-sourced one and asserts the source-less mapping passes through
unchanged while the sourced one transforms.

Bumps all-files branch coverage 96.94 → 97.21 (above the 97 threshold).
source-map-generator.js is now 100/100/100.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant