perf(packages/codegen): store mapping positions in an Int32Array (#26085)

Perf optimization to sourcemap builds.

Use an `Int32Array` to store sourcemap positions, instead of a plain `Array`.

This `Int32Array` is held in a top-level var and reused for every file. It is replaced by a larger one when required, but will soon reach steady state - a size large enough to service all files being printed - and then ceases growing. Once it reaches that steady state, all the "does it need to grow?" branches become perfectly predictable.

`Int32Array` has 3 advantages over plain arrays:

1. 4 bytes per entry, instead of 8 - lower memory usage and less cache misses.
2. Low garbage collection cost - during garbage collection, GC has to loop through every item in an `Array` checking if it contains heap values (e.g. an `Object`) which the array references, and therefore can't be garbage-collected. `Int32Array`s are statically known to only contain small integers, which don't live on the heap, so this scan is skipped entirely.
3. Recycling - due to not having to worry about GC burden of keeping a typed array live across a GC run, a single `Int32Array` can be kept live and reused over and over for each file.

This change produces a decent perf bump:

| Fixture | Maps end-to-end | Recording pass only |
| --- | --- | --- |
| tiny.js | −7.3% | −34.5% |
| radix | −5.4% | −10.5% |
| react | −2.9% | −6.6% |
| App.tsx | −2.4% | −6.2% |
| binder.ts | −3.3% | −5.4% |
| kitchen-sink | −2.9% | −4.9% |
| lodash | −2.1% | −5.8% |
| antd | −5.1% | −5.3% |

### Why `Int32Array` not `Uint32Array`?

V8's native small integer type ("SMI") is a _signed_ 32-bit integer. Generally speaking, it's preferable to store numbers in this same format, as when reading from an `Int32Array`, V8 statically knows that the value is an SMI, and skips conversion logic and "does this number fit in an SMI?" checks. Ditto when writing - no conversion required to write a known SMI into an `Int32Array`.
This commit is contained in:
overlookmotel committed 2026-08-25 13:54:40 +00:00
1 parent d50c985fc2
commit 0dd4db384f
4 files changed
+75 -15

No files matched your search

+6 -2
View File
@@ -554,6 +554,10 @@ During printing, a mapped write records only an output offset and the node's ori
from `start` / `end`. Nodes without offsets are not mapped, and `sourceText` is required to convert
source offsets to line/column positions.
The offset pairs go into an `Int32Array` which, like the indent cache, is created once per process
and reused by every print - each `State` tracks how much of it that print has filled, and the buffer
doubles when a print fills it, so a steady-state print allocates nothing to record its mappings.
`generateSourceMap` then walks the output once at the end, counting ECMAScript line terminators,
builds the equivalent line table for `sourceText`, turns both sets of offsets into line/column,
and encodes the mappings as base64 VLQ. Tracking lines and columns throughout would cost every write
@@ -565,8 +569,8 @@ Producing source maps is exactly the kind of raw number-crunching that native co
And, unlike the AST, it's just small integers - the kind of data that transfers across the JS-native
boundary cheaply.
It might be worthwhile assembling all the offsets on JS side in an `Int32Array`, instead of a plain `Array`,
and making a single JS-Rust-JS round-trip, with the sourcemap generation (VLQ encoding etc) happening on Rust side.
The offsets are already assembled on the JS side in an `Int32Array`, so it might be worthwhile making
a single JS-Rust-JS round-trip with it, with the sourcemap generation (VLQ encoding etc) happening on Rust side.
WASM might also be a good option for this.
+3 -3
View File
@@ -43,8 +43,8 @@ export function generateSourceMap(state: State, options: Options): SourceMap {
"`mapPositions`, `mapNames` and `sourceText` should be defined when source maps are enabled",
);
const { output, mapPositions, mapNames, sourceText } = state;
const mappingCount = mapPositions.length >> 1;
const { output, mapPositions, mapPositionsLen, mapNames, sourceText } = state;
const mappingCount = mapPositionsLen >> 1;
if (mappingCount === 0) {
return {
@@ -84,7 +84,7 @@ export function generateSourceMap(state: State, options: Options): SourceMap {
const useSourceLineBoundaryCache =
mappingCount >= MIN_LF_FAST_PATH_MAPPINGS
&& sourceText.length <= mappingCount * MAX_LF_FAST_PATH_CHARS_PER_MAPPING
&& mapPositions[mapPositions.length - 1] * 2 >= sourceText.length;
&& mapPositions[mapPositionsLen - 1] * 2 >= sourceText.length;
const useSourceLineFeedFastPath =
useSourceLineBoundaryCache && hasOnlyLineFeedsAndCrLf(sourceText);
let nextLineStart = findNextLineStart(output, 0, useOutputLineFeedFastPath);
+18 -7
View File
@@ -520,8 +520,10 @@ function markMapNamed(
// `oxc_codegen` suppresses consecutive source positions as it records them. Do this before
// recovering a name or retaining the mapping, since member-level marks commonly duplicate keys.
const { mapPositions } = state;
if (mapPositions[mapPositions.length - 1] === start) return;
// On the first mapping the read is index -1, which on a typed array is `undefined` - never equal.
let { mapPositions } = state;
const { mapPositionsLen } = state;
if (mapPositions[mapPositionsLen - 1] === start) return;
// A mapping carries a name only when the identifier printed differs from the one in the source.
// When possible, the mapping records the name from source, but if the source range is invalid,
@@ -562,13 +564,16 @@ function markMapNamed(
// Preserve the existing fallback in that case instead of recording an arbitrary source substring.
if (originalName === undefined || !isSameToken(originalName, printedName, hashLength)) {
state.mapNames.push(
mapPositions.length >> 1,
mapPositionsLen >> 1,
originalName === undefined ? printedName : originalName,
);
}
}
mapPositions.push(state.output.length, start);
if (mapPositionsLen === mapPositions.length) mapPositions = state.growMapPositions();
mapPositions[mapPositionsLen] = state.output.length;
mapPositions[mapPositionsLen + 1] = start;
state.mapPositionsLen = mapPositionsLen + 2;
}
/**
@@ -608,9 +613,15 @@ function recordMapping(state: State, sourceOffset: number): void {
if (sourceOffset > state.sourceText.length) return;
const { mapPositions } = state;
if (mapPositions[mapPositions.length - 1] === sourceOffset) return;
mapPositions.push(state.output.length, sourceOffset);
// On the first mapping the read is index -1, which on a typed array is `undefined` - never equal
let { mapPositions } = state;
const { mapPositionsLen } = state;
if (mapPositions[mapPositionsLen - 1] === sourceOffset) return;
if (mapPositionsLen === mapPositions.length) mapPositions = state.growMapPositions();
mapPositions[mapPositionsLen] = state.output.length;
mapPositions[mapPositionsLen + 1] = sourceOffset;
state.mapPositionsLen = mapPositionsLen + 2;
}
/**
+48 -3
View File
@@ -35,6 +35,27 @@ const INDENT_REGEX = /^[ \t]+$/;
/** Upper bound for the process-wide indentation cache. */
const MAX_STARTING_INDENT_LEVEL = 1_000;
/**
* Initial capacity of the process-wide mapping-positions buffer.
* Must be even - positions are recorded in pairs, and the buffer only grows between pairs.
*/
const MIN_MAP_POSITIONS_LENGTH = 1024;
debugAssert(
MIN_MAP_POSITIONS_LENGTH % 2 === 0,
"`MIN_MAP_POSITIONS_LENGTH` must be an even number",
);
/**
* Deferred source mapping positions - pairs of output offset and source offset.
*
* Like `indents`, there is one buffer per process, and every maps-enabled `State` carries it,
* so both sourcemap builds record into the same buffer.
*
* `growMapPositions` doubles it when a print fills it.
*/
let mapPositions = new Int32Array(MIN_MAP_POSITIONS_LENGTH);
export class State {
// Current output.
// A string which is appended to as the printing process proceeds.
@@ -77,9 +98,12 @@ export class State {
// Only used in debug builds. See `debugAssertCategoryMatches`.
declare lastCharWritten: string;
// Deferred source mappings. Generated/source offset pairs exist when source maps are enabled.
// Deferred source mappings. When source maps are enabled, `mapPositions` is the process-wide
// buffer above, holding generated/source offset pairs, and `mapPositionsLen` is how much of it
// this print has filled.
// Names are sparse, so their index/name pairs exist only if a mapping carries an original name.
declare mapPositions: number[] | null;
declare mapPositions: Int32Array | null;
declare mapPositionsLen: number;
declare mapNames: (number | string)[] | null;
// Original source text, used to preserve names in source maps when the caller provides it.
@@ -145,8 +169,29 @@ export class State {
} else {
debugAssert(options.sourceText != null);
this.sourceText = options.sourceText;
this.mapPositions = [];
this.mapPositions = mapPositions;
this.mapNames = [];
}
this.mapPositionsLen = 0;
}
/**
* Replace the full mapping-positions buffer with one twice the size, copying its contents over.
*
* The new buffer also replaces the process-wide one, so later prints start at the new size.
*
* @returns The new buffer
*/
growMapPositions(): Int32Array {
const oldBuffer = this.mapPositions;
debugAssert(
oldBuffer === mapPositions,
"`growMapPositions` should only be called on a `State` with source maps enabled",
);
const newBuffer = new Int32Array(oldBuffer.length * 2);
newBuffer.set(oldBuffer);
mapPositions = newBuffer;
this.mapPositions = newBuffer;
return newBuffer;
}
}