Skip to content

Optionally store source maps as VLQ encoded (2/2): Transformer output, unstable_compactSourceMaps (#1743)#1743

Closed
robhogan wants to merge 2 commits into
mainfrom
export-D109216060
Closed

Optionally store source maps as VLQ encoded (2/2): Transformer output, unstable_compactSourceMaps (#1743)#1743
robhogan wants to merge 2 commits into
mainfrom
export-D109216060

Conversation

@robhogan

@robhogan robhogan commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

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.

Reviewed By: huntie

Differential Revision: D109216060

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 23, 2026
@meta-codesync

meta-codesync Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

@robhogan has exported this pull request. If you are a Meta employee, you can view the originating Diff in D109216060.

meta-codesync Bot pushed a commit that referenced this pull request Jun 24, 2026
…, `unstable_compactSourceMaps` (#1743)

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
@meta-codesync meta-codesync Bot force-pushed the export-D109216060 branch from d51004e to b3f9840 Compare June 24, 2026 16:02
@meta-codesync meta-codesync Bot changed the title Optionally store source maps as VLQ encoded (2/2): Transformer output, unstable_compactSourceMaps Optionally store source maps as VLQ encoded (2/2): Transformer output, unstable_compactSourceMaps (#1743) Jun 24, 2026
robhogan added 2 commits June 25, 2026 08:02
…sumer support (#1742)

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 a `VlqMap` type (`{mappings: string, names: ReadonlyArray<string>}`) as an
alternative to the current `Array<MetroSourceMapSegmentTuple>` for storing
per-module source maps in `Module` graph nodes (and transform results, and cache artifacts). 

Adds the ability to store, thread, decode and (flat-)emit VLQ
maps - **nothing actually produces them yet**, so these code paths are unused except by tests. The opt-in producer flag lands in the next diff.

## Follow up
After this mini-stack, we'll add an opt-in for emitting index source maps, directly re-using per-module VLQ and eliminating the trade-off mentioned above.

Reviewed By: huntie, javache

Differential Revision: D107973884
…, `unstable_compactSourceMaps` (#1743)

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.
```

Reviewed By: huntie

Differential Revision: D109216060
@meta-codesync meta-codesync Bot force-pushed the export-D109216060 branch from b3f9840 to 0eacc9d Compare June 25, 2026 15:02
@meta-codesync meta-codesync Bot closed this in 351d4ff Jun 25, 2026
@meta-codesync meta-codesync Bot added the Merged label Jun 25, 2026
@meta-codesync

meta-codesync Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

This pull request has been merged in 351d4ff.

meta-codesync Bot pushed a commit that referenced this pull request Jul 6, 2026
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` (#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).

## Changelog
```
 - **[Performance]**: More lazily decode VLQ to improve `/symbolicate` performance
```

Reviewed By: motiz88

Differential Revision: D110603508

fbshipit-source-id: 69d8f1eb02a859a12cc9235f214baec52c2ef31d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Merged meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant