Skip to content

Commit d51004e

Browse files
robhoganfacebook-github-bot
authored andcommitted
Optionally store source maps as VLQ encoded (2/2): Transformer output, unstable_compactSourceMaps
Summary: ## This stack Decoded tuple arrays are the single largest contributor to Metro's dev-server heap on large bundles (~10 million retained small arrays on FBiOS entry bundle, for example). Storing the same data as a compact VLQ string instead removes most of that footprint. This reduces source map memory by ~51% on the heap and ~48% RSS for that ~16K module bundle. The emitted whole-bundle source map is unchanged. When a module's map is stored as VLQ, `fromRawMappings` decodes it back to tuples just-in-time, with request-scoped caching. The trade-off is therefore decode + re-encode CPU when a `.map` is actually requested or `/symbolicate` request is made. A plain `string` is used for `mappings` for now, since VLQ is ASCII by design. A `UInt8Array` would be marginally more efficient and potentially transferrable to/from worker threads, but would require more invasive changes to cache (de)serialisation. I did some benchmarking with this and it doesn't justify the complexity right now. ## This diff Adds `unstable_compactSourceMaps` (default `false`). When enabled, the transform worker stores each module's source map as a compact VLQ string (`VlqMap`) instead of a decoded `Array<MetroSourceMapSegmentTuple>`. Each module's map originates from one of three sources, so we encode the VLQ the cheapest way available in each case (all byte-identical to the decoded-tuple output): - transformJS, not minifying (the dominant path — Hermes targets don't minify): encode the `VlqMap` straight from `result.decodedMap`, which `babel/generator` computes eagerly while generating, via `vlqMapFromBabelDecodedMap` — never materialising tuples. - transformJS, minifying: the minifier returns its own map (not Babel's), so we re-encode the resulting tuples with `vlqMapFromTuples`. - transformJSON: builds tuples directly (no Babel generate), so it likewise re-encodes with `vlqMapFromTuples`. `countLines` is split out of `countLinesAndTerminateMap` so the decoded-map fast path can compute the terminating mapping without building and terminating a tuple array first. ## Benchmarks *Cold cache (n=3, means)* | Metric | base | compact | |---|---|---|---| | **Heap used** | 1653.7 MB | **809.7 MB (−51.0%)** | | **RSS** | 1854.2 MB | 955.2 MB (−48.5%) | | Heap growth (build) | 1606.5 MB | 761.2 MB (−52.6%) | | Build CPU (`.bundle`) | 23.05 s | 22.42 s (n.s.) | | **Serialize CPU (`.map`)** | 11.99 s | **14.19 s (+18.4%)** | *Warm cache (n=3, means)* | Metric | base | compact | |---|---|---|---| | **Heap used** | 1552 MB | **731 MB (−52.9%)** | | **RSS** | 1775 MB | 923 MB (−48.0%) | | Build CPU (`.bundle`) | 10.92 s | 8.86 s (−18.9%) | | **Serialize CPU (`.map`)** | 11.87 s | **13.89 s (+17.0%)** | ## Why behind a flag? 1) The `map` structure is exposed to custom serialisers, so changing it is semver-breaking. Landing this as experimental opt-in in a non-breaking release allows integrators to experiment with it. 2) This is a trade-off of retained memory vs CPU required to emit a flat source map or symbolicate errors. The trade-off largely goes away with indexed maps (coming next) - but that is a semver-breaking change to output. Changelog: ``` - **[Experimental]**: Add `unstable_compactSourceMaps` to use a more memory-efficient source map format. ``` Differential Revision: D109216060
1 parent 94d95ac commit d51004e

5 files changed

Lines changed: 234 additions & 29 deletions

File tree

packages/metro-config/src/defaults/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({
135135
unstable_disableNormalizePseudoGlobals: false,
136136
unstable_renameRequire: true,
137137
unstable_compactOutput: false,
138+
unstable_compactSourceMaps: false,
138139
unstable_memoizeInlineRequires: false,
139140
unstable_workerThreads: false,
140141
},

packages/metro-source-map/src/__tests__/source-map-test.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99
* @oncall react_native
1010
*/
1111

12-
import type {MetroSourceMapSegmentTuple} from '../source-map';
12+
import type {BabelDecodedMap, MetroSourceMapSegmentTuple} from '../source-map';
1313

1414
import Generator from '../Generator';
1515
import {
1616
fromRawMappings,
1717
isVlqMap,
1818
toBabelSegments,
1919
toSegmentTuple,
20+
vlqMapFromBabelDecodedMap,
2021
vlqMapFromTuples,
2122
} from '../source-map';
2223

@@ -344,3 +345,47 @@ describe('vlqMapFromTuples', () => {
344345
expect(decode(vlqMap)).toEqual([]);
345346
});
346347
});
348+
349+
describe('vlqMapFromBabelDecodedMap', () => {
350+
test('matches vlqMapFromTuples, appending a terminator when needed', () => {
351+
// Decoded format: grouped by generated line (0-based), source lines 0-based.
352+
const decodedMap: BabelDecodedMap = {
353+
names: ['foo'],
354+
mappings: [
355+
[[0, 0, 0, 0]], // gen 1:0 -> src 1:0
356+
[[2, 0, 0, 4, 0]], // gen 2:2 -> src 1:4 name 'foo'
357+
[[0]], // gen 3:0 generated-only
358+
],
359+
};
360+
// Equivalent Metro tuples (source lines 1-based) + terminator at gen 3:5.
361+
const tuples: Array<MetroSourceMapSegmentTuple> = [
362+
[1, 0, 1, 0],
363+
[2, 2, 1, 4, 'foo'],
364+
[3, 0],
365+
[3, 5],
366+
];
367+
expect(vlqMapFromBabelDecodedMap(decodedMap, [3, 5])).toEqual(
368+
vlqMapFromTuples(tuples),
369+
);
370+
});
371+
372+
test('does not append a terminator already present', () => {
373+
const decodedMap: BabelDecodedMap = {
374+
names: [],
375+
mappings: [[[0], [5]]],
376+
};
377+
expect(vlqMapFromBabelDecodedMap(decodedMap, [1, 5])).toEqual(
378+
vlqMapFromTuples([
379+
[1, 0],
380+
[1, 5],
381+
]),
382+
);
383+
});
384+
385+
test('handles an empty decoded map (terminator only)', () => {
386+
const decodedMap: BabelDecodedMap = {names: [], mappings: []};
387+
expect(vlqMapFromBabelDecodedMap(decodedMap, [1, 0])).toEqual(
388+
vlqMapFromTuples([[1, 0]]),
389+
);
390+
});
391+
});

packages/metro-source-map/src/source-map.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,76 @@ function vlqMapFromTuples(
425425
return {mappings: map.mappings, names: map.names};
426426
}
427427

428+
/**
429+
* Encodes a `VlqMap` directly from a Babel/gen-mapping "decoded" source map
430+
* (`result.decodedMap` from `@babel/generator`), without ever materialising the
431+
* intermediate `Array<MetroSourceMapSegmentTuple>`.
432+
*
433+
* `@babel/generator` computes `decodedMap` eagerly while generating, so reusing
434+
* it avoids the separate, more expensive `result.rawMappings` decode (which
435+
* allocates a flat array of segment objects) plus the per-segment tuple
436+
* allocation that `vlqMapFromTuples` would otherwise consume. The result is
437+
* byte-identical to `vlqMapFromTuples(decoded -> tuples)`.
438+
*
439+
* `terminatingMapping` is a `[generatedLine1Based, generatedColumn0Based]`
440+
* generated-only mapping appended at the end (matching the transform worker's
441+
* `countLinesAndTerminateMap`) unless the last real mapping already sits there.
442+
*/
443+
function vlqMapFromBabelDecodedMap(
444+
decodedMap: BabelDecodedMap,
445+
terminatingMapping: [number, number],
446+
): VlqMap {
447+
const generator = new Generator();
448+
generator.startFile('', '', null);
449+
const {mappings, names} = decodedMap;
450+
let lastGeneratedLine = -1;
451+
let lastGeneratedColumn = -1;
452+
for (let line = 0, n = mappings.length; line < n; ++line) {
453+
// Decoded mappings are grouped by generated line (0-based); Generator
454+
// expects 1-based generated lines.
455+
const generatedLine = line + 1;
456+
const segments = mappings[line];
457+
for (let i = 0, m = segments.length; i < m; ++i) {
458+
const segment = segments[i];
459+
const generatedColumn = segment[0];
460+
switch (segment.length) {
461+
case 1:
462+
generator.addSimpleMapping(generatedLine, generatedColumn);
463+
break;
464+
case 4:
465+
// Decoded source lines are 0-based; Generator expects 1-based.
466+
generator.addSourceMapping(
467+
generatedLine,
468+
generatedColumn,
469+
segment[2] + 1,
470+
segment[3],
471+
);
472+
break;
473+
case 5:
474+
generator.addNamedSourceMapping(
475+
generatedLine,
476+
generatedColumn,
477+
segment[2] + 1,
478+
segment[3],
479+
names[segment[4]],
480+
);
481+
break;
482+
}
483+
lastGeneratedLine = generatedLine;
484+
lastGeneratedColumn = generatedColumn;
485+
}
486+
}
487+
if (
488+
lastGeneratedLine !== terminatingMapping[0] ||
489+
lastGeneratedColumn !== terminatingMapping[1]
490+
) {
491+
generator.addSimpleMapping(terminatingMapping[0], terminatingMapping[1]);
492+
}
493+
generator.endFile();
494+
const map = generator.toMap();
495+
return {mappings: map.mappings, names: map.names};
496+
}
497+
428498
export {
429499
BundleBuilder,
430500
composeSourceMaps,
@@ -439,6 +509,7 @@ export {
439509
toBabelSegments,
440510
toSegmentTuple,
441511
tuplesFromBabelDecodedMap,
512+
vlqMapFromBabelDecodedMap,
442513
vlqMapFromTuples,
443514
};
444515

packages/metro-transform-worker/src/__tests__/index-test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import type {JsTransformerConfig, JsTransformOptions} from '../index';
3232
import typeof * as TransformerType from '../index';
3333
import typeof FSType from 'fs';
3434

35+
import {vlqMapFromTuples} from 'metro-source-map';
36+
3537
const {Buffer} = require('buffer');
3638
const path = require('path');
3739

@@ -409,6 +411,56 @@ test('uses a reserved dependency map name and prevents it from being minified',
409411
`);
410412
});
411413

414+
test('unstable_compactSourceMaps emits a VlqMap byte-identical to the tuple path', async () => {
415+
const source = Buffer.from(
416+
[
417+
'function foo(aaa, bbb) {',
418+
' const ccc = aaa + bbb;',
419+
' return ccc * 2;',
420+
'}',
421+
'export default function entry(items) {',
422+
' return items.map(x => x.value).filter(Boolean);',
423+
'}',
424+
'',
425+
].join('\n'),
426+
'utf8',
427+
);
428+
429+
// Default path stores decoded tuples (line-counted + terminated).
430+
const tupleResult = await Transformer.transform(
431+
{...baseConfig, unstable_compactSourceMaps: false},
432+
'/root',
433+
'local/file.js',
434+
source,
435+
{...baseTransformOptions, experimentalImportSupport: true},
436+
);
437+
// Compact path encodes VLQ straight from Babel's decoded map (no tuples).
438+
const vlqResult = await Transformer.transform(
439+
{...baseConfig, unstable_compactSourceMaps: true},
440+
'/root',
441+
'local/file.js',
442+
source,
443+
{...baseTransformOptions, experimentalImportSupport: true},
444+
);
445+
446+
const tupleMap = tupleResult.output[0].data.map;
447+
const vlqMap = vlqResult.output[0].data.map;
448+
449+
// Generated code and line count are unaffected by map storage.
450+
expect(vlqResult.output[0].data.code).toBe(tupleResult.output[0].data.code);
451+
expect(vlqResult.output[0].data.lineCount).toBe(
452+
tupleResult.output[0].data.lineCount,
453+
);
454+
455+
if (Array.isArray(vlqMap) || !Array.isArray(tupleMap)) {
456+
throw new Error('Expected a VlqMap (compact) and a tuple array (default)');
457+
}
458+
// The compact fast path is byte-identical to re-encoding the tuple output.
459+
expect(vlqMap).toEqual(vlqMapFromTuples(tupleMap));
460+
expect(typeof vlqMap.mappings).toBe('string');
461+
expect(vlqMap.mappings.length).toBeGreaterThan(0);
462+
});
463+
412464
test('throws if the reserved dependency map name appears in the input', async () => {
413465
await expect(
414466
Transformer.transform(

packages/metro-transform-worker/src/index.js

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
BasicSourceMap,
2121
FBSourceFunctionMap,
2222
MetroSourceMapSegmentTuple,
23+
VlqMap,
2324
} from 'metro-source-map';
2425
import type {
2526
ImportExportPluginOptions,
@@ -47,6 +48,8 @@ import {
4748
toBabelSegments,
4849
toSegmentTuple,
4950
tuplesFromBabelDecodedMap,
51+
vlqMapFromBabelDecodedMap,
52+
vlqMapFromTuples,
5053
} from 'metro-source-map';
5154
import metroTransformPlugins from 'metro-transform-plugins';
5255
import collectDependencies from 'metro/private/ModuleGraph/worker/collectDependencies';
@@ -111,6 +114,8 @@ export type JsTransformerConfig = Readonly<{
111114
unstable_nonMemoizedInlineRequires?: ReadonlyArray<string>,
112115
/** Whether to rename scoped `require` functions to `_$$_REQUIRE`, usually an extraneous operation when serializing to iife (default). */
113116
unstable_renameRequire?: boolean,
117+
/** Store source maps as compact VLQ-encoded strings (`VlqMap`) instead of decoded tuple arrays. Reduces source-map memory ~51% on the heap. Opt-in; changes `JsOutput.data.map` for consumers. */
118+
unstable_compactSourceMaps?: boolean,
114119
}>;
115120

116121
export type {CustomTransformOptions} from 'metro-babel-transformer';
@@ -169,7 +174,7 @@ export type JsOutput = Readonly<{
169174
data: Readonly<{
170175
code: string,
171176
lineCount: number,
172-
map: Array<MetroSourceMapSegmentTuple>,
177+
map: Array<MetroSourceMapSegmentTuple> | VlqMap,
173178
functionMap: ?FBSourceFunctionMap,
174179
}>,
175180
type: JSFileType,
@@ -472,29 +477,49 @@ async function transformJS(
472477
file.code,
473478
);
474479

475-
// Derive tuples from Babel's eagerly-computed decoded map rather than
476-
// `result.rawMappings`, which would trigger a second, more expensive decode
477-
// (`allMappings`). Byte-identical to `result.rawMappings.map(toSegmentTuple)`.
478-
let map = result.decodedMap
479-
? tuplesFromBabelDecodedMap(result.decodedMap)
480-
: [];
481480
let code = result.code;
481+
let map: Array<MetroSourceMapSegmentTuple> | VlqMap;
482+
let lineCount: number;
483+
484+
if (config.unstable_compactSourceMaps === true && !minify) {
485+
// Dominant path (e.g. Hermes, which doesn't minify): encode the compact VLQ
486+
// map straight from Babel's eagerly-computed decoded map, never
487+
// materialising tuples. Byte-identical to the tuple path below.
488+
const {lineCount: lines, lastLineColumn} = countLines(code);
489+
lineCount = lines;
490+
map = vlqMapFromBabelDecodedMap(nullthrows(result.decodedMap), [
491+
lines,
492+
lastLineColumn,
493+
]);
494+
} else {
495+
// Derive tuples from Babel's eagerly-computed decoded map rather than
496+
// `result.rawMappings`, which would trigger a second, more expensive decode
497+
// (`allMappings`). Byte-identical to `result.rawMappings.map(toSegmentTuple)`.
498+
let tuples = result.decodedMap
499+
? tuplesFromBabelDecodedMap(result.decodedMap)
500+
: [];
501+
502+
if (minify) {
503+
// The minifier returns its own map (not Babel's `decodedMap`), so the
504+
// fast path above can't apply; re-encode the resulting tuples if compact.
505+
({map: tuples, code} = await minifyCode(
506+
config,
507+
projectRoot,
508+
file.filename,
509+
result.code,
510+
file.code,
511+
tuples,
512+
reserved,
513+
));
514+
}
482515

483-
if (minify) {
484-
({map, code} = await minifyCode(
485-
config,
486-
projectRoot,
487-
file.filename,
488-
result.code,
489-
file.code,
490-
map,
491-
reserved,
492-
));
516+
({lineCount, map: tuples} = countLinesAndTerminateMap(code, tuples));
517+
map =
518+
config.unstable_compactSourceMaps === true
519+
? vlqMapFromTuples(tuples)
520+
: tuples;
493521
}
494522

495-
let lineCount;
496-
({lineCount, map} = countLinesAndTerminateMap(code, map));
497-
498523
const output: Array<JsOutput> = [
499524
{
500525
data: {
@@ -622,9 +647,13 @@ async function transformJSON(
622647

623648
let lineCount;
624649
({lineCount, map} = countLinesAndTerminateMap(code, map));
650+
// The JSON path builds tuples directly (no Babel `decodedMap`), so when
651+
// compact we re-encode the finished tuples to VLQ.
652+
const outputMap =
653+
config.unstable_compactSourceMaps === true ? vlqMapFromTuples(map) : map;
625654
const output: Array<JsOutput> = [
626655
{
627-
data: {code, functionMap: null, lineCount, map},
656+
data: {code, functionMap: null, lineCount, map: outputMap},
628657
type: jsType,
629658
},
630659
];
@@ -761,12 +790,9 @@ export const getCacheKey = (
761790
].join('$');
762791
};
763792

764-
function countLinesAndTerminateMap(
765-
code: string,
766-
map: ReadonlyArray<MetroSourceMapSegmentTuple>,
767-
): {
793+
function countLines(code: string): {
768794
lineCount: number,
769-
map: Array<MetroSourceMapSegmentTuple>,
795+
lastLineColumn: number,
770796
} {
771797
const NEWLINE = /\r\n?|\n|\u2028|\u2029/g;
772798
let lineCount = 1;
@@ -777,9 +803,19 @@ function countLinesAndTerminateMap(
777803
lineCount++;
778804
lastLineStart = match.index + match[0].length;
779805
}
780-
const lastLineLength = code.length - lastLineStart;
806+
return {lineCount, lastLineColumn: code.length - lastLineStart};
807+
}
808+
809+
function countLinesAndTerminateMap(
810+
code: string,
811+
map: ReadonlyArray<MetroSourceMapSegmentTuple>,
812+
): {
813+
lineCount: number,
814+
map: Array<MetroSourceMapSegmentTuple>,
815+
} {
816+
const {lineCount, lastLineColumn} = countLines(code);
781817
const lastLineIndex1Based = lineCount;
782-
const lastLineNextColumn0Based = lastLineLength;
818+
const lastLineNextColumn0Based = lastLineColumn;
783819

784820
// If there isn't a mapping at one-past-the-last column of the last line,
785821
// add one that maps to nothing. This ensures out-of-bounds lookups hit the

0 commit comments

Comments
 (0)