Skip to content

perf: typed-array MappingList with slab-direct serializer + fromSourceMap#64

Merged
7rulnik merged 4 commits into
mainfrom
perf/typed-array-mapping-list
May 10, 2026
Merged

perf: typed-array MappingList with slab-direct serializer + fromSourceMap#64
7rulnik merged 4 commits into
mainfrom
perf/typed-array-mapping-list

Conversation

@7rulnik
Copy link
Copy Markdown
Owner

@7rulnik 7rulnik commented May 10, 2026

Summary

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.

Per bench-data-followups.md #2 — generator memory ratio ≈ generator speed ratio. The win is heap-pressure / GC / cache: ~350k mapping-object allocations per addMapping cycle become 350k × 24 bytes of contiguous slab. The slab-direct serializer also drops the indexOf per mapping (the int index was already resolved when adding).

Sequenced commits

  1. perf: typed-array MappingList — slab storage + slab-direct serializer + applySourceMap rebuildlib/mapping-list.js rewritten with Int32Array backing; SourceMapGenerator.addMapping takes positional integer args; _serializeMappings reads the slab directly via the exported F_* constants and uses a new ml._equalsPrev(i) dedup helper; applySourceMap walks the old slab and rebuilds a fresh MappingList bound to new sources / names ArraySets (replaces the unsortedForEach-with-mutation pattern that no longer works with materialized callbacks).
  2. perf: BasicSourceMapConsumer.fromSourceMap reads MappingList slab directly — drops the toArray().slice() materialization. The smc's _sources / _names are initialized from the same aSourceMap._sources / _names, so the integer indices in the slab transfer directly.
  3. test: coverage for typed-array MappingList and slab paths — new test/internal/test-mapping-list.js; source-less mapping test added to test-source-map-consumer-internals.js; direct compareByGeneratedPositionsInflated line/column tests added to test-util.js (the function used to be covered transitively through MappingList.add; the slab rewrite removed that coupling).
  4. test: cover applySourceMap with source-less mappings + drop unreachable origCol guard — final coverage cleanup. Tested the applySourceMap path on source-less mappings and dropped an unreachable origCol === -1 defensive check (addMapping pairs origLine and origCol so they're either both -1 or both numeric).

API surface: unchanged. MappingList.unsortedForEach and MappingList.toArray remain API-compatible by materializing JS objects on demand from the slab — only internal hot paths (_serializeMappings, BasicSourceMapConsumer.fromSourceMap) bypass them. _validateMapping, addMapping args, applySourceMap args, fromSourceMap args, toJSON / toString output — all unchanged.

Bench

Generate path — bench-diff.sh main generate (SOLO=1 PHASES=adding,generate)

Fixture Adding Δ Generate Δ
amp.js.map +33% +10%
babel.min.js.map +97% +29%
issue-41.js.map +28% +16%
preact.js.map -10% +45%
react.js.map -5% +39%
vscode.map +33% +10%
mean across 12 rows +28%

addMapping wins scale with map size — biggest fixtures (babel.min 347k, vscode 2M) see the largest improvements because per-mapping object allocation savings dominate. The smallest fixtures (preact 2k, react 6k) see a small regression on addMapping from the new positional-index resolution overhead, but recoup it on serialize.

fromSourceMap — scripts/bench-fromsourcemap.js

Fixture cand ops/sec base ops/sec Δ
amp.js.map 351 211 +66%
babel.min.js.map 30.0 29.6 +1% (noise)
issue-41.js.map 35.8 33.0 +9%
preact.js.map 10.1k 8.1k +25%
react.js.map 3.2k 2.6k +22%
vscode.map 5.14 4.03 +28%
mean +25%

Tests

  • All 205 existing tests pass. No public-API regressions.
  • 9 new tests added (6 internal MappingList tests, 1 source-less-mapping fromSourceMap test, 1 applySourceMap source-less pass-through test, 2 direct util comparator tests).
  • yarn test:coverage : mapping-list.js 100/100/100, source-map-generator.js 100/100/100, source-map-consumer.js 99.93/98.34/97.30. All-files: 98.35 / 97.21 / 96.75 — all above the 98 / 97 / 96 thresholds.

Open follow-ups (out of scope here)

  • applySourceMap could pass the old sources ArraySet's at(idx) result directly to the new ArraySet without going through string interning twice — micro-optimization.
  • ArraySet.add could return the inserted index, dropping the trailing indexOf call in addMapping. Worth a separate PR.
  • Sparse lastGeneratedColumn side storage (per the design proposal, option b) is not needed because the field is consumer-side only.

7rulnik added 4 commits May 11, 2026 00:12
… + 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.
…ectly

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/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.
…le 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.
@7rulnik 7rulnik merged commit d4ebfea into main May 10, 2026
3 checks passed
@7rulnik 7rulnik deleted the perf/typed-array-mapping-list branch May 10, 2026 20:34
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