perf(packages/codegen): flatten output in chunks (#26109)

Continuation of #26108.

Previously we built the whole output as a single rope, and then flattened it in one go at the end. This is fine for smaller files, but for larger files it has 2 problems:

1. Each `output += segment` append allocates a 32-byte cons string cell. When printing a large AST, so many of these are allocated that they can fill "new space" and trigger garbage collection. When that happens, all these little cons strings have to be copied into the other half of new space, which is slow. Even worse, if they survive 2 rounds of GC, they get promoted into "old space" where (a) they may get scattered across memory, ruining cache coherence, and (b) they sit there, taking up memory, until the next major GC round.
2. Flattening a rope string which contains any segments which are 2-byte strings (non-Latin1 characters) is way more expensive than flattening a rope which is pure Latin1/ASCII. If a single segment contains Unicode, the entire flatten operation deopts and becomes several times slower.

This PR attacks both these problems by flattening a large output in chunks.

- When output reaches 16 KiB, flatten that as a chunk and store it in an array.
- This leaves all the little cons string cells unreferenced, so garbage collection will discard them all, instead of copying them across to keep them alive.
- If file contains some non-Latin1 characters, that does still heavily impact the performance of flattening the chunk they're in, but only that chunk - other chunks which are pure ASCII still take the fast path.

The 16 KiB limit is a heuristic, chosen after experimentation with different values.

### Benchmarks

The effect of this change is minimal on small files, but dramatic on large ones (negative is faster):

| Fixture | Bytes | Change |
|:---| ---:| ---:|
| `tiny.js` | 26 | \+3.2% |
| `RadixUIAdoptionSection.jsx` | 2,424 | \-0.1% |
| `react.development.js` | 50,496 | \+2.0% |
| `binder.ts` | 126,212 | \-1.9% |
| `lodash.js` | 182,262 | \-32.5% |
| `App.tsx` | 298,130 | \-26.4% |
| `kitchen-sink.tsx` | 662,560 | \-14.2% |
| `antd.js` | 5,100,647 | \-28.4% |

### Total cost of flattening

After this PR, compared to where we were prior to #26106 (positive is slower):

| Fixture | Bytes | Change |
|:---| ---:| ---:|
| `tiny.js` | 26 | \+51.5% |
| `RadixUIAdoptionSection.jsx` | 2,424 | \+134.2% |
| `react.development.js` | 50,496 | \+35.5% |
| `binder.ts` | 126,212 | \+39.0% |
| `lodash.js` | 182,262 | \+45.3% |
| `App.tsx` | 298,130 | \+55.0% |
| `kitchen-sink.tsx` | 662,560 | \+51.3% |
| `antd.js` | 5,100,647 | \+2.2% |

The effect of flattening is still very negative, but (as mentioned in #26106) it was always a cost, just one that we were ignoring. At least we've now managed to mitigate it.

In the case of really large files (`antd.js`), benchmark is pretty much unchanged from before flattening came into the picture. But now our measure _includes_ the large flattening cost which was previously _on top of_ the cost of printing - so the real cost for the user is much reduced from where it was previously.

### Sourcemap builds

Printing with sourcemaps on large files actually gets faster after this change. It was already paying the cost of string flattening, now it pays less. Printing `lodash.js` with sourcemaps is about 15% faster now.
This commit is contained in:
overlookmotel committed 2026-08-26 17:51:49 +00:00
1 parent 487427aa20
commit 07a07933c7
7 files changed
+100 -14

No files matched your search

+2
View File
@@ -100,6 +100,8 @@ in `state.last` what the output now ends with. `state.output` never needs to be
The rope is flattened once, at the end. Whatever the caller does with the returned code will flatten it anyway
(and `generateSourceMap` scans it), so `printSync` pre-flattens with the fastest method available.
A large output is flattened in chunks along the way rather than all at once at the end -
see `print/flatten.ts` for the two reasons.
### The answer: A category, not a character
+9 -9
View File
@@ -142,13 +142,13 @@ Representative time per `printSync` call:
| Fixture | Bytes | Time |
| :--------------------------- | --------: | ---------: |
| `tiny.js` | 26 | 0.0001 ms |
| `RadixUIAdoptionSection.jsx` | 2,518 | 0.0033 ms |
| `react.development.js` | 72,141 | 0.1138 ms |
| `binder.ts` | 193,077 | 0.2472 ms |
| `App.tsx` | 415,340 | 0.7490 ms |
| `lodash.js` | 544,096 | 0.4995 ms |
| `kitchen-sink.tsx` | 732,222 | 2.5682 ms |
| `antd.js` | 6,683,633 | 11.3914 ms |
| `RadixUIAdoptionSection.jsx` | 2,518 | 0.0070 ms |
| `react.development.js` | 72,141 | 0.1518 ms |
| `binder.ts` | 193,077 | 0.3364 ms |
| `App.tsx` | 415,340 | 1.2912 ms |
| `lodash.js` | 544,096 | 0.7977 ms |
| `kitchen-sink.tsx` | 732,222 | 4.2924 ms |
| `antd.js` | 6,683,633 | 16.9652 ms |
These figures come from one machine and are illustrative, not a regression baseline. Results—most
noticeably for large fixtures such as `antd.js`—vary between runs.
These figures come from one machine and are illustrative, not a regression baseline.
Results vary between runs, most noticeably for large fixtures such as `antd.js`.
+56
View File
@@ -24,6 +24,11 @@
// * `startsWith(" ")` / `endsWith(" ")` - with a constant search string TurboFan inlines them as `charCodeAt` loads,
// whose non-flat path is `String::Flatten`, the slow walker.
// * `indexOf("")` / `includes("")` - they early return without flattening.
//
// A large output is not left to grow into one rope and flattened at the end. It is flattened in chunks along the way -
// see `OUTPUT_CHUNK_LENGTH` for why, and `printIndent` in `indent.ts` for where.
import type { State } from "../state.ts";
/**
* Sink for the result of `indexOf`.
@@ -41,3 +46,54 @@ const SINK = { dummy: 0 };
export function flattenString(s: string): void {
SINK.dummy ^= s.indexOf(" ");
}
/**
* Length at which `state.output` is flattened and moved to `state.outputChunks`.
*
* Left to grow to the end of a large print, the rope costs in two separate ways, and flattening it
* in chunks along the way removes both:
*
* 1. Any non-Latin1 character in the output (a string literal or JSX text with an accent, emoji, CJK,
* a typographic quote) makes the rope, and its flattened result, two-byte, and V8 copies a two-byte rope
* at ~12ns per cell against ~2ns for a one-byte one, on every flatten path. Each chunk is its own rope,
* so only the chunks which contain such a character pay that rate, not the whole output.
*
* 2. Everything the rope references is live. A young-generation GC landing mid-print copies every cell
* written so far, and promotes them at the next one. A print which allocates a fair share of the
* semi-space (which is 1MB in a fresh or idle process, and at most 32MB) sees one or more of those,
* and a big print sees them in almost every run. Flattened chunks are a few large objects instead.
*
* Measured against never flattening: two-byte outputs print 25-35% faster with any limit up to 32KB,
* and one-byte outputs of 300KB+ print 5-25% faster once GC is counted. The cost is the per-line check
* plus about half a microsecond per chunk: +1-4% on a 50-120KB one-byte print in a warm, roomy heap,
* halving as the limit doubles. 16KB keeps almost all of the wins for half the cost of 8KB,
* 64KB and above lose the two-byte win on files where such characters are dense, and lose to
* never flattening in a 1MB semi-space, where the partial rope is itself most of what a GC copies.
*
* The check is in `printIndent`, which runs once per line at every nesting depth - so a chunk can overshoot
* by a line, which doesn't really matter, the limit is not precise anyway. It's tempting to check less often,
* e.g. once per top-level statement, but that wouldn't be often enough in files that have large top-level
* statements, e.g. libraries written as one big IIFE.
*/
export const OUTPUT_CHUNK_LENGTH = 16 * 1024;
/**
* Flatten `output` into `state.outputChunks` and start a new chunk.
*
* The chunks array is created on the first spill, so a small print never allocates it.
*/
export function spillOutputChunk(state: State): void {
const { output } = state;
state.output = "";
// Only sourcemap builds need this
if (SOURCEMAPS) state.spilledOutputLength += output.length;
flattenString(output);
const { outputChunks } = state;
if (outputChunks === null) {
state.outputChunks = [output];
} else {
outputChunks.push(output);
}
}
+5
View File
@@ -7,6 +7,7 @@
// so the levels one build has grown are there for the next one - see `state.ts`.
import { CAT_OTHER } from "./categories.ts";
import { OUTPUT_CHUNK_LENGTH, spillOutputChunk } from "./flatten.ts";
import { write } from "./write.ts";
import type { State } from "../state.ts";
@@ -17,6 +18,10 @@ import type { State } from "../state.ts";
* Inline statement bodies ask for a space where an indent would go, which keeps `if (x) foo()` on one line.
*/
export function printIndent(state: State): void {
// Flatten chunk if `state.output` has grown beyond limit.
// Once per line, not once per write - see comment on `OUTPUT_CHUNK_LENGTH` in `flatten.ts`.
if (state.output.length >= OUTPUT_CHUNK_LENGTH) spillOutputChunk(state);
if (state.pendingIndentAsSpace) {
write(state, " ", CAT_OTHER);
state.pendingIndentAsSpace = false;
+18 -3
View File
@@ -38,9 +38,24 @@ export function printSync(node: ESTree.Node, state: State, options: Options): Co
printStatement(node, state);
}
// Flatten the output before handing it on - see `flatten.ts` for why, and why this way
const { output } = state;
flattenString(output);
// Flatten the output before handing it on - see `flatten.ts` for why, and why this way.
//
// A large print has already flattened most of its output into `state.outputChunks` chunk by chunk (see `flatten.ts`),
// leaving `state.output` as the tail. Those flat chunks and the tail are joined here, which is a straight copy,
// and the result is a proper flat string.
const { outputChunks } = state;
let output;
if (outputChunks === null) {
output = state.output;
flattenString(output);
} else {
// `state.output` may be empty, which `join` ignores. A lone chunk comes back as-is, and it is already flat.
outputChunks.push(state.output);
output = outputChunks.join("");
// The chunks are now redundant. Drop them now rather than when `state` dies, so a GC during `generateSourceMap`
// collects them, instead of pointlessly retaining and copying them
state.outputChunks = null;
}
// This is removed by minifier in non-sourcemap builds.
//
+2 -2
View File
@@ -571,7 +571,7 @@ function markMapNamed(
}
if (mapPositionsLen === mapPositions.length) mapPositions = state.growMapPositions();
mapPositions[mapPositionsLen] = state.output.length;
mapPositions[mapPositionsLen] = state.spilledOutputLength + state.output.length;
mapPositions[mapPositionsLen + 1] = start;
state.mapPositionsLen = mapPositionsLen + 2;
}
@@ -619,7 +619,7 @@ function recordMapping(state: State, sourceOffset: number): void {
if (mapPositions[mapPositionsLen - 1] === sourceOffset) return;
if (mapPositionsLen === mapPositions.length) mapPositions = state.growMapPositions();
mapPositions[mapPositionsLen] = state.output.length;
mapPositions[mapPositionsLen] = state.spilledOutputLength + state.output.length;
mapPositions[mapPositionsLen + 1] = sourceOffset;
state.mapPositionsLen = mapPositionsLen + 2;
}
+8
View File
@@ -61,6 +61,12 @@ export class State {
// A string which is appended to as the printing process proceeds.
declare output: string;
// Flattened chunks of `output`, in order, once it has grown past `OUTPUT_CHUNK_LENGTH` (see `print/flatten.ts`),
// or `null` while it has not - a small print never allocates the array. Sourcemap builds also keep the total
// length spilled so far - the true output offset the recorders need is `spilledOutputLength + output.length`.
declare outputChunks: string[] | null;
declare spilledOutputLength: number;
// Current indentation level.
declare indentLevel: number;
@@ -111,6 +117,8 @@ export class State {
constructor(options: Options) {
this.output = "";
this.outputChunks = null;
this.spilledOutputLength = 0;
let { startingIndentLevel: indentLevel } = options;
if (indentLevel === undefined) {