next.js
3d018005 - Turbopack: stop copying sourcesContent into every serialized source map (#95934)

Commit
27 days ago
Turbopack: stop copying sourcesContent into every serialized source map (#95934) This reduces peak memory consumption with the original repro case in [vercel/next.js#81161](https://github.com/vercel/next.js/issues/81161). If you walk the 15 routes in a quick succession and capture vmmap --summary at the end of each fast sweep (before memory is released), with this change the number reliably goes down from 3 GB to 2.6 GB: ``` before 1 3.0G 17 after 1 2.6G 16 before 2 3.0G 16 after 2 2.6G 16 before 3 3.1G 18 after 3 2.6G 17 before 4 3.1G 17 after 4 2.6G 16 before 5 3.1G 17 after 5 2.6G 17 before 6 3.0G 17 after 6 2.5G 17 before 7 3.1G 17 after 7 2.6G 17 before 8 3.0G 25 after 8 2.6G 24 before: [3.0, 3.0, 3.1, 3.1, 3.1, 3.0, 3.1, 3.0] after: [2.6, 2.6, 2.6, 2.6, 2.6, 2.5, 2.6, 2.6] mean before=3.050G after=2.587G diff=0.462G (-15.2%) exact permutation test: p = 2/12870 = 0.00016 ``` More runs on this app: <img width="938" height="623" alt="Screenshot 2026-07-22 at 20 59 12" src="https://github.com/user-attachments/assets/01c41656-eb5a-41f9-8871-b4ce262c54d5" /> And here's an internal app showing ~20% dev memory win at peak (although reclaiming memory settles it): <img width="832" height="402" alt="Screenshot 2026-07-22 at 16 45 34" src="https://github.com/user-attachments/assets/8c39f668-d431-43f9-b533-d39dfaa97c15" /> --- <details> Module source maps are serialized to a JSON rope right after code generation and treated as opaque bytes from then on. Since `sourcesContent` — the full original source text — is inlined into that JSON, every later step that modifies the map has to copy all of it: rewriting the `sources` URLs for each chunking context copies the entire map per context, and embedding the module map into a chunk's sectioned map copies it again, once per layer that includes the module. In a dev session on a dependency-heavy app, each module's source text ends up resident ~5 times over, and source maps end up dominating the memory the task store holds for codegen output (in one measured app, maps were 57% of module factory bytes, and `sourcesContent` alone was 36%). This PR stops copying the text. `StructuredSourceMap` keeps a map in field form until the moment it is actually emitted: - Fields we never touch (`mappings`, `names`, `version`, ...) are stored as verbatim raw JSON snippets, so producers' bytes round-trip untouched and re-emission is rope sharing rather than re-serialization. - `sources` is the one field rewrites modify, so it is stored typed, and the per-chunking-context "fileify" rewrites become a plain string rewrite of that field instead of a parse–modify–reserialize of the whole map. - `sourcesContent` entries are individual, already-JSON-escaped ropes. Rewritten maps and chunk maps share them; the source text is escaped once per module and never copied again. Two construction paths, chosen by where the bytes come from: maps we generate ourselves are built directly from the `swc_sourcemap` object (`from_swc_map` takes the contents out, serializes the small remainder, and never round-trips source text through JSON), while maps that arrive as bytes (prebuilt maps next to external code, the single-complete-original fast path) go through `from_json`. The emitter writes fields in exactly `swc_sourcemap`'s order and format, pinned by golden tests that assert byte-identity with `to_writer` — so emitted chunks and maps are byte-for-byte unchanged, and no snapshot fixtures change. The CSS pipeline gets the same treatment; the single-file output path is left alone since it serializes straight into the final artifact and there is nothing to share. The win is proportional to how much duplicated source-map text an app holds, so results range accordingly. On a dependency-heavy reproduction app (13 routes sharing large wallet SDKs; rapid route sweep in `next dev`, macOS physical footprint, 8 interleaved cold runs per side): 3.05 GB → 2.59 GB mean (−15%), with zero overlap between the two distributions (exact permutation test p ≈ 0.00016) and per-route latencies unchanged. On `bench/basic-app` (small first-party modules): a full sweep of all 42 routes drops the post-sweep footprint 3.4–3.5 GB → 2.4–2.5 GB (−29%, interleaved runs); cold compile −4%, idle footprint identical (eviction reclaims both sides equally at idle). On `bench/heavy-npm-deps` (heavy deps disabled in its default config) and on very large first-party apps whose memory is dominated by the JS heap and application state rather than map text, the change measures flat — as expected, since there is little duplicated text for sharing to recover. `small_apps` build benchmarks are flat except `date-fns-all` at +1–2% (~2.5 ms): that app is 1000+ tiny modules and `from_swc_map` adds one small-skeleton JSON parse per module, with no redundancy to win back in a single-context one-shot build. The single-complete-original fast path (per prebuilt module with a shipped map, and per minified chunk) is cost-neutral: the structured map is built by splitting the raw map's serde fields directly (`from_serialize`), so the serialized document is never materialized or re-parsed — 93 ms per 5 iterations on a synthetic 21.7 MB map, matching the pre-PR baseline exactly, with byte-identical output pinned by a property test. Serialized task-store size is unchanged (bincode expands sharing back out), so this is purely a live-memory change. Verified: snapshot suite passes with zero fixture changes, execution suite matches canary (the single pre-existing failure reproduces on the base tag), unit suites pass including new golden byte-identity tests, and emitted `.map` files were validated end-to-end in a running dev server (multi-section chunk maps, fileified `file://` sources, `sourcesContent` present and byte-identical between base and this branch). </details>
Author
Parents
Loading