Skip to content

Commit 351d4ff

Browse files
robhoganmeta-codesync[bot]
authored andcommitted
Optionally store source maps as VLQ encoded (2/2): Transformer output, unstable_compactSourceMaps (#1743)
Summary: Pull Request resolved: #1743 ## 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. ``` Reviewed By: huntie Differential Revision: D109216060 fbshipit-source-id: c3cfbc97e5e46e8af86aefb2116d8d1365260406
1 parent dd4f1d9 commit 351d4ff

7 files changed

Lines changed: 262 additions & 32 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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,78 @@ 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+
default:
483+
throw new Error(`Invalid mapping: [${segment.join(', ')}]`);
484+
}
485+
lastGeneratedLine = generatedLine;
486+
lastGeneratedColumn = generatedColumn;
487+
}
488+
}
489+
if (
490+
lastGeneratedLine !== terminatingMapping[0] ||
491+
lastGeneratedColumn !== terminatingMapping[1]
492+
) {
493+
generator.addSimpleMapping(terminatingMapping[0], terminatingMapping[1]);
494+
}
495+
generator.endFile();
496+
const map = generator.toMap();
497+
return {mappings: map.mappings, names: map.names};
498+
}
499+
428500
export {
429501
BundleBuilder,
430502
composeSourceMaps,
@@ -439,6 +511,7 @@ export {
439511
toBabelSegments,
440512
toSegmentTuple,
441513
tuplesFromBabelDecodedMap,
514+
vlqMapFromBabelDecodedMap,
442515
vlqMapFromTuples,
443516
};
444517

packages/metro-source-map/types/source-map.d.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*
77
* @noformat
88
* @oncall react_native
9-
* @generated SignedSource<<313a3bbbf29c3ac69821b3124678d4e0>>
9+
* @generated SignedSource<<9ec89353742743678e422f0bf81e488d>>
1010
*
1111
* This file was translated from Flow by scripts/generateTypeScriptDefinitions.js
1212
* Original file: packages/metro-source-map/src/source-map.js
@@ -157,6 +157,25 @@ declare function tuplesFromBabelDecodedMap(
157157
declare function vlqMapFromTuples(
158158
mappings: ReadonlyArray<MetroSourceMapSegmentTuple>,
159159
): VlqMap;
160+
/**
161+
* Encodes a `VlqMap` directly from a Babel/gen-mapping "decoded" source map
162+
* (`result.decodedMap` from `@babel/generator`), without ever materialising the
163+
* intermediate `Array<MetroSourceMapSegmentTuple>`.
164+
*
165+
* `@babel/generator` computes `decodedMap` eagerly while generating, so reusing
166+
* it avoids the separate, more expensive `result.rawMappings` decode (which
167+
* allocates a flat array of segment objects) plus the per-segment tuple
168+
* allocation that `vlqMapFromTuples` would otherwise consume. The result is
169+
* byte-identical to `vlqMapFromTuples(decoded -> tuples)`.
170+
*
171+
* `terminatingMapping` is a `[generatedLine1Based, generatedColumn0Based]`
172+
* generated-only mapping appended at the end (matching the transform worker's
173+
* `countLinesAndTerminateMap`) unless the last real mapping already sits there.
174+
*/
175+
declare function vlqMapFromBabelDecodedMap(
176+
decodedMap: BabelDecodedMap,
177+
terminatingMapping: [number, number],
178+
): VlqMap;
160179
export {
161180
BundleBuilder,
162181
composeSourceMaps,
@@ -171,6 +190,7 @@ export {
171190
toBabelSegments,
172191
toSegmentTuple,
173192
tuplesFromBabelDecodedMap,
193+
vlqMapFromBabelDecodedMap,
174194
vlqMapFromTuples,
175195
};
176196
/**

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(

0 commit comments

Comments
 (0)