mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
07a07933c7
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.
100 lines
4.9 KiB
TypeScript
100 lines
4.9 KiB
TypeScript
// Flattening the output rope.
|
|
//
|
|
// `state.output` is a cons-string rope, one cell per `+=`. Whatever the caller does with the code
|
|
// (write it to a file, index into it, pass it to Rust) flattens it, and `generateSourceMap` scans it,
|
|
// which flattens it too. Doing it here, the way that is cheapest, is worth it.
|
|
//
|
|
// V8 has two different tree walkers.
|
|
//
|
|
// 1. `String::Flatten` (the C++ path a read takes - `charCodeAt`, regex, `Buffer`, NAPI) uses the newer `WriteToFlat2`.
|
|
// 2. The Torque string builtins use the classic `WriteToFlat`, which special-cases exactly the left-heavy list
|
|
// that repeated `+=`s builds, and costs about half as much per cons cell for this pattern.
|
|
//
|
|
// `indexOf` (and `includes`) take the Torque path, and flatten the rope in place - the cons cell at the top
|
|
// becomes a wrapper around the new flat string, which every reader unwraps for free - and then scan for
|
|
// the search string, which stops at the first space.
|
|
//
|
|
// That is cheaper than `[s, " "].join("").slice(0, -1)`, the other route to the classic walker, which allocates
|
|
// an array and a slice on top of the copy, and hands back the slice rather than the flat string itself.
|
|
// A slice misses `JSON.stringify`'s fast path, among others.
|
|
//
|
|
// Output will contain a space close to the start in all but the weirdest cases, so the search itself is cheap.
|
|
//
|
|
// Other possible solutions which were tried and discarded:
|
|
// * `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`.
|
|
*
|
|
* TurboFan removes a call whose result is unused, and there would be no flatten - and nothing to notice it,
|
|
* only the cost creeping back. The store to the object property is what keeps the call. Do not tidy it away.
|
|
*/
|
|
const SINK = { dummy: 0 };
|
|
|
|
/**
|
|
* Flatten a rope in place.
|
|
*
|
|
* A no-op on a string which is already flat, apart from a short scan.
|
|
*/
|
|
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);
|
|
}
|
|
}
|