From 1763aee6ad428378a76cf9ef8a1f773f2ed2c234 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sat, 4 Jul 2026 06:08:41 -0700 Subject: [PATCH] Per-line lazy VLQ decode for /symbolicate cold path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: ## Background `/symbolicate` resolves each stack frame by looking up its generated (line, column) in the frame module's source map. With `transformer.unstable_compactSourceMaps` (https://github.com/react/metro/pull/1743), modules keep their maps as VLQ `mappings` strings rather than decoded tuples, which means we must decode at `/symbolicate` time. That regressed `/symbolicate` performance by about ~45ms mean (300ms P99), and this stack aims to recover that. The current decode runs the whole module through `toBabelSegments(map).map(toSegmentTuple)` - a full `SourceMapConsumer` pass that allocates a Babel segment object and a tuple array per segment. ## Change The insight here is that for symbolication we typically only need to decode a small number of lines per module. We can’t randomly access lines in an VLQ string, but we can traverse it cheaply, indexing lines so that we can decode what we need. This adds a per-line, allocation-light, package-private `LineIndexedMappings` to `metro-source-map`. A single pass builds a per-line index: for each generated line, its byte offset into `mappings` and the running source line/column delta accumulators as they stand entering that line. A lookup jumps straight to the target line and decodes only that line's segments. `originalPositionFor` is byte-identical to a `greatestLowerBound` over `toBabelSegments(map).map(toSegmentTuple)` (1-based lines, 0-based columns, and generated-only segments resolving to null). `Server/symbolicate.js` caches the `LineIndexedMappings` per module for the duration of a request (I’m going to look into whether it’s worth expanding this given `LineIndexedMappings` has a small footprint). Tuple-backed modules are searched directly, as before, though I’m intending to delete that path. ## Performance With some representative modules of various sizes, picked from a real dev bundle: | module (lines / segments / mappings) | BASE | NEW | speedup | | 127 / 539 / 3.0K | 318 µs | 20 µs | ~16x | | 297 / 1180 / 6.7K | 755 µs | 42 µs | ~18x | | 8041 / 68259 / 400K | 62.4 ms | 2.7 ms | ~23x | | 11294 / 95814 / 565K (`ReactFabric-dev`) | 102.5 ms | 3.8 ms | ~27x | Gains scale with module size, so they concentrate on the largest modules, some of which appear frequently in traces. The React renderer, for instance, is one of the biggest modules in a typical bundle (~11k generated lines); its cold decode drops from ~102ms to ~4ms (~27x) - that difference alone likely recovers most of the regression. Because large modules dominate total symbolication time, time *decoding* is reduced ~25x for a typical request (note: there's an unchanged per-request overhead from `_getExplodedSourceMapsForBundleOptions` not included in these microbenchmarks, which becomes the next target for optimisation). Differential Revision: D110603508 --- .../src/LineIndexedMappings.js | 211 ++++++++++++++++++ .../src/__tests__/source-map-test.js | 103 +++++++++ .../types/LineIndexedMappings.d.ts | 40 ++++ packages/metro/src/Server/symbolicate.js | 111 +++++---- 4 files changed, 417 insertions(+), 48 deletions(-) create mode 100644 packages/metro-source-map/src/LineIndexedMappings.js create mode 100644 packages/metro-source-map/types/LineIndexedMappings.d.ts diff --git a/packages/metro-source-map/src/LineIndexedMappings.js b/packages/metro-source-map/src/LineIndexedMappings.js new file mode 100644 index 0000000000..89e4d0b658 --- /dev/null +++ b/packages/metro-source-map/src/LineIndexedMappings.js @@ -0,0 +1,211 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +/* eslint-disable no-bitwise */ + +// Base64 digit lookup for the VLQ decoder below. Indexed by char code; -1 +// marks a non-base64 character (never hit inside a well-formed segment). +const BASE64_VALUES: Int16Array = (() => { + const table = new Int16Array(128).fill(-1); + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + for (let i = 0; i < chars.length; i++) { + table[chars.charCodeAt(i)] = i; + } + return table; +})(); + +const VLQ_SEMICOLON = 59; // ';' generated-line separator +const VLQ_COMMA = 44; // ',' segment separator + +// Consume one base64-VLQ value from `mappings` starting at `cursor[0]`, +// advancing `cursor[0]` past it. A one-element array is used as a mutable +// cursor so the scan can share this helper without per-call closures. +function readVlq(mappings: string, cursor: [number]): number { + let shift = 0; + let value = 0; + let continuation; + let i = cursor[0]; + do { + const digit = BASE64_VALUES[mappings.charCodeAt(i++)]; + continuation = digit & 32; + value = value + ((digit & 31) << shift); + shift = shift + 5; + } while (continuation); + cursor[0] = i; + const shouldNegate = value & 1; + value = value >>> 1; + return shouldNegate ? -value : value; +} + +// True if another segment field (not a ';'/',' separator) begins at `index`. +function isFieldAhead( + mappings: string, + index: number, + length: number, +): boolean { + return ( + index < length && + mappings.charCodeAt(index) !== VLQ_SEMICOLON && + mappings.charCodeAt(index) !== VLQ_COMMA + ); +} + +/** + * A compact, lazily-decoded view of one module's VLQ `mappings` for the + * generated -> original position lookup that `/symbolicate` performs. + * + * Construction builds a per-line index: one entry per generated line holding the + * byte offset into `mappings` where that line's segments begin and the + * source-line/source-column delta accumulators as they stand entering the line. + * A lookup jumps to the target line and decodes only that line's segments in + * place over the retained `mappings` string, allocating nothing. + * + * VLQ deltas are cumulative across the whole string, so the index snapshots the + * accumulator state at each line start to make any line independently + * decodable. The index holds O(lines) integers; generated columns reset per + * line and are not stored. + */ +export default class LineIndexedMappings { + #mappings: string; + // One entry per generated line, indexed by (generatedLine1Based - 1). + #lineByteOffset: Int32Array; // start of the line's segments in `#mappings` + #srcLineAtLineStart: Int32Array; // originalLine accumulator (1-based) entering the line + #srcColAtLineStart: Int32Array; // originalColumn accumulator (0-based) entering the line + #lineCount: number; + + constructor(mappings: string) { + const length = mappings.length; + + // One generated line per ';' separator, plus one; an empty string is a + // single empty line. + let lineCount = 1; + for (let i = 0; i < length; i++) { + if (mappings.charCodeAt(i) === VLQ_SEMICOLON) { + lineCount++; + } + } + + const lineByteOffset = new Int32Array(lineCount); + const srcLineAtLineStart = new Int32Array(lineCount); + const srcColAtLineStart = new Int32Array(lineCount); + + // Record the offset and source-line/source-column accumulators at each + // line's start. Source lines are 1-based, columns 0-based. Segment fields + // are decoded only far enough to advance the accumulators; generated + // columns and source/name indices are dropped. + let originalLine = 1; + let originalColumn = 0; + let line = 0; + const cursor: [number] = [0]; + + lineByteOffset[0] = 0; + srcLineAtLineStart[0] = originalLine; + srcColAtLineStart[0] = originalColumn; + + while (cursor[0] < length) { + const c = mappings.charCodeAt(cursor[0]); + if (c === VLQ_SEMICOLON) { + cursor[0]++; + line++; + lineByteOffset[line] = cursor[0]; + srcLineAtLineStart[line] = originalLine; + srcColAtLineStart[line] = originalColumn; + continue; + } + if (c === VLQ_COMMA) { + cursor[0]++; + continue; + } + + // Segment: [genCol, srcIndex, srcLine, srcCol(, name)]. genCol resets per + // line and doesn't feed the accumulators, so skip its digits. + readVlq(mappings, cursor); // generated column delta (unused) + if (isFieldAhead(mappings, cursor[0], length)) { + readVlq(mappings, cursor); // sourceIndex delta (unused) + originalLine = originalLine + readVlq(mappings, cursor); + originalColumn = originalColumn + readVlq(mappings, cursor); + if (isFieldAhead(mappings, cursor[0], length)) { + readVlq(mappings, cursor); // nameIndex delta (unused) + } + } + } + + this.#mappings = mappings; + this.#lineByteOffset = lineByteOffset; + this.#srcLineAtLineStart = srcLineAtLineStart; + this.#srcColAtLineStart = srcColAtLineStart; + this.#lineCount = lineCount; + } + + originalPositionFor( + generatedLine1Based: number, + generatedColumn0Based: number, + ): ?{line1Based: number, column0Based: number} { + if (generatedLine1Based < 1 || generatedLine1Based > this.#lineCount) { + return null; + } + const lineIndex = generatedLine1Based - 1; + const mappings = this.#mappings; + const length = mappings.length; + + const cursor: [number] = [this.#lineByteOffset[lineIndex]]; + let originalLine = this.#srcLineAtLineStart[lineIndex]; + let originalColumn = this.#srcColAtLineStart[lineIndex]; + let generatedColumn = 0; + + // Segments are sorted by generated column, so the last one with column <= + // the target is the match; stop as soon as one overshoots. A generated-only + // match carries no source position and resolves to null. + let found = false; + let foundHasSource = false; + let foundLine = 0; + let foundColumn = 0; + + while (cursor[0] < length) { + const c = mappings.charCodeAt(cursor[0]); + if (c === VLQ_SEMICOLON) { + break; // end of this generated line + } + if (c === VLQ_COMMA) { + cursor[0]++; + continue; + } + + generatedColumn = generatedColumn + readVlq(mappings, cursor); + if (generatedColumn > generatedColumn0Based) { + break; // this and all later segments on the line exceed the target + } + + if (isFieldAhead(mappings, cursor[0], length)) { + readVlq(mappings, cursor); // sourceIndex delta (unused) + originalLine = originalLine + readVlq(mappings, cursor); + originalColumn = originalColumn + readVlq(mappings, cursor); + if (isFieldAhead(mappings, cursor[0], length)) { + readVlq(mappings, cursor); // nameIndex delta (unused) + } + found = true; + foundHasSource = true; + foundLine = originalLine; + foundColumn = originalColumn; + } else { + // Generated-only mapping: no original position. + found = true; + foundHasSource = false; + } + } + + if (!found || !foundHasSource) { + return null; + } + return {line1Based: foundLine, column0Based: foundColumn}; + } +} diff --git a/packages/metro-source-map/src/__tests__/source-map-test.js b/packages/metro-source-map/src/__tests__/source-map-test.js index 29817e6834..130b9295c1 100644 --- a/packages/metro-source-map/src/__tests__/source-map-test.js +++ b/packages/metro-source-map/src/__tests__/source-map-test.js @@ -16,7 +16,9 @@ import type { MixedSourceMap, } from '../source-map'; +import {greatestLowerBound} from '../Consumer/search'; import Generator from '../Generator'; +import LineIndexedMappings from '../LineIndexedMappings'; import { fromRawMappings, fromRawMappingsIndexed, @@ -494,3 +496,104 @@ describe('vlqMapFromBabelDecodedMap', () => { ); }); }); + +describe('LineIndexedMappings', () => { + // Reference lookup: decode to tuples via toBabelSegments + toSegmentTuple, + // then greatestLowerBound over (generatedLine, generatedColumn), returning the + // original position only when the matched segment is on the target line and + // carries source info. + const referenceOriginalPositionFor = ( + tuples: Array, + line1Based: number, + column0Based: number, + ) => { + const index = greatestLowerBound( + tuples, + {line1Based, column0Based}, + (target, candidate) => + target.line1Based === candidate[0] + ? target.column0Based - candidate[1] + : target.line1Based - candidate[0], + ); + if (index == null) { + return null; + } + const mapping = tuples[index]; + if (mapping[0] !== line1Based || mapping.length < 4) { + return null; + } + return { + // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. + line1Based: mapping[2], + // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. + column0Based: mapping[3], + }; + }; + + const cases: {[string]: Array} = { + 'single segment': [[1, 0, 10, 4]], + 'multiple segments on one line': [ + [1, 0, 10, 4], + [1, 8, 10, 12, 'greet'], + [2, 0, 11, 0], + ], + 'generated-only segments (no source)': [ + [1, 0], + [1, 5, 3, 2], + [2, 0], + [3, 0, 4, 0, 'x'], + ], + 'gap in generated lines (blank line -> ";;")': [ + [1, 0, 1, 0], + [3, 4, 3, 2, 'bar'], + [5, 0], + ], + 'large multi-line map with names': [ + [1, 2], + [3, 4, 5, 6, 'apples'], + [7, 8, 9, 10], + [11, 12, 13, 14, 'pears'], + [11, 20, 30, 40], + ], + }; + + for (const name of Object.keys(cases)) { + test(`matches the tuple path exactly across a position grid: ${name}`, () => { + const tuples = cases[name]; + const vlqMap = vlqMapFromTuples(tuples); + // The exact tuples the old path would have produced from this VLQ map. + const reference = toBabelSegments({ + version: 3, + sources: [''], + names: [...vlqMap.names], + mappings: vlqMap.mappings, + }).map(toSegmentTuple); + + const decoded = new LineIndexedMappings(vlqMap.mappings); + + const maxLine = Math.max(...tuples.map(t => t[0])) + 1; + for (let line = 1; line <= maxLine; line++) { + for (let column = 0; column <= 24; column++) { + expect(decoded.originalPositionFor(line, column)).toEqual( + referenceOriginalPositionFor(reference, line, column), + ); + } + } + }); + } + + test('empty mappings never resolves a position', () => { + const decoded = new LineIndexedMappings(''); + expect(decoded.originalPositionFor(1, 0)).toBeNull(); + expect(decoded.originalPositionFor(5, 3)).toBeNull(); + }); + + test('out-of-range generated lines resolve to null', () => { + const decoded = new LineIndexedMappings( + vlqMapFromTuples([[1, 0, 10, 4]]).mappings, + ); + expect(decoded.originalPositionFor(0, 0)).toBeNull(); + expect(decoded.originalPositionFor(-1, 0)).toBeNull(); + expect(decoded.originalPositionFor(99, 0)).toBeNull(); + }); +}); diff --git a/packages/metro-source-map/types/LineIndexedMappings.d.ts b/packages/metro-source-map/types/LineIndexedMappings.d.ts new file mode 100644 index 0000000000..a4dc09be9e --- /dev/null +++ b/packages/metro-source-map/types/LineIndexedMappings.d.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noformat + * @oncall react_native + * @generated SignedSource<> + * + * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js + * Original file: packages/metro-source-map/src/LineIndexedMappings.js + * To regenerate, run: + * js1 build metro-ts-defs (internal) OR + * yarn run build-ts-defs (OSS) + */ + +/** + * A compact, lazily-decoded view of one module's VLQ `mappings` for the + * generated -> original position lookup that `/symbolicate` performs. + * + * Construction builds a per-line index: one entry per generated line holding the + * byte offset into `mappings` where that line's segments begin and the + * source-line/source-column delta accumulators as they stand entering the line. + * A lookup jumps to the target line and decodes only that line's segments in + * place over the retained `mappings` string, allocating nothing. + * + * VLQ deltas are cumulative across the whole string, so the index snapshots the + * accumulator state at each line start to make any line independently + * decodable. The index holds O(lines) integers; generated columns reset per + * line and are not stored. + */ +declare class LineIndexedMappings { + constructor(mappings: string); + originalPositionFor( + generatedLine1Based: number, + generatedColumn0Based: number, + ): null | undefined | {line1Based: number; column0Based: number}; +} +export default LineIndexedMappings; diff --git a/packages/metro/src/Server/symbolicate.js b/packages/metro/src/Server/symbolicate.js index 9452367dca..c8aaf34f23 100644 --- a/packages/metro/src/Server/symbolicate.js +++ b/packages/metro/src/Server/symbolicate.js @@ -17,8 +17,8 @@ import type { import type {ExplodedSourceMap} from '../DeltaBundler/Serializers/getExplodedSourceMap'; import type {ConfigT} from 'metro-config'; -import {toBabelSegments, toSegmentTuple} from 'metro-source-map'; import {greatestLowerBound} from 'metro-source-map/private/Consumer/search'; +import LineIndexedMappings from 'metro-source-map/private/LineIndexedMappings'; import {SourceMetadataMapConsumer} from 'metro-symbolicate/private/Symbolication'; export type StackFrameInput = { @@ -37,24 +37,66 @@ export type StackFrameOutput = Readonly; type ExplodedSourceMapModule = ExplodedSourceMap[number]; type Position = {readonly line1Based: number, column0Based: number}; -function ensureDecodedMap( +// Resolve a generated (line, column) within a module to its original position. +// Tuple-backed modules keep their decoded segments, so we search them directly; +// VLQ-backed modules go through a compact per-line `LineIndexedMappings` (cached +// for the request) that decodes only the target line. Byte-identical either way. +function originalPositionInModule( map: Array | VlqMap, - decodedMapCache: Map>, -): Array { + generatedLine1Based: number, + generatedColumn0Based: number, + decodedMapCache: Map, +): ?Position { if (Array.isArray(map)) { - return map; + return originalPositionInTuples( + map, + generatedLine1Based, + generatedColumn0Based, + ); } let decoded = decodedMapCache.get(map); if (decoded == null) { - decoded = toBabelSegments({ - version: 3, - sources: [''], - names: [...map.names], - mappings: map.mappings, - }).map(toSegmentTuple); + decoded = new LineIndexedMappings(map.mappings); decodedMapCache.set(map, decoded); } - return decoded; + return decoded.originalPositionFor( + generatedLine1Based, + generatedColumn0Based, + ); +} + +// greatestLowerBound over pre-decoded tuples, ordered by (line, column). +function originalPositionInTuples( + mappings: Array, + generatedLine1Based: number, + generatedColumn0Based: number, +): ?Position { + const target = { + line1Based: generatedLine1Based, + column0Based: generatedColumn0Based, + }; + const mappingIndex = greatestLowerBound(mappings, target, (t, candidate) => { + if (t.line1Based === candidate[0]) { + return t.column0Based - candidate[1]; + } + return t.line1Based - candidate[0]; + }); + if (mappingIndex == null) { + return null; + } + const mapping = mappings[mappingIndex]; + if ( + mapping[0] !== target.line1Based || + mapping.length < 4 /* no source line/column info */ + ) { + return null; + } + return { + // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. + line1Based: mapping[2], + // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. + column0Based: mapping[3], + }; } function createFunctionNameGetter( @@ -98,12 +140,10 @@ export default async function symbolicate( (Position) => ?string, >(); - // Decoded VLQ maps are cached only for the duration of this request, then - // discarded. The cache dedupes decoding across frames that resolve to the - // same module, while keeping the (large) decoded tuples short-lived — the - // VlqMaps themselves are retained by the long-lived module graph, so caching - // beyond request scope would defeat the memory savings of storing them as VLQ. - const decodedMapCache = new Map>(); + // Dedupes VLQ decoding across frames that resolve to the same module. Scoped + // to this request so decoded maps are released once it completes, rather than + // being retained alongside the long-lived module graph. + const decodedMapCache = new Map(); function findModule(frame: StackFrameInput): ?ExplodedSourceMapModule { const map = mapsByUrl.get(frame.file); @@ -130,37 +170,12 @@ export default async function symbolicate( if (module.map == null || lineNumber == null || column == null) { return null; } - const decodedMap = ensureDecodedMap(module.map, decodedMapCache); - const generatedPosInModule = { - line1Based: lineNumber - module.firstLine1Based + 1, - column0Based: column, - }; - const mappingIndex = greatestLowerBound( - decodedMap, - generatedPosInModule, - (target, candidate) => { - if (target.line1Based === candidate[0]) { - return target.column0Based - candidate[1]; - } - return target.line1Based - candidate[0]; - }, + return originalPositionInModule( + module.map, + lineNumber - module.firstLine1Based + 1, + column, + decodedMapCache, ); - if (mappingIndex == null) { - return null; - } - const mapping = decodedMap[mappingIndex]; - if ( - mapping[0] !== generatedPosInModule.line1Based || - mapping.length < 4 /* no source line/column info */ - ) { - return null; - } - return { - // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. - line1Based: mapping[2], - // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. - column0Based: mapping[3], - }; } function findFunctionName(