mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-15 06:39:17 +00:00
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:
1 parent
d50c985fc2
commit
0dd4db384f
4 files changed
+75
-15
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user