mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-15 06:39:17 +00:00
perf(packages/codegen): faster string flattening (#26108)
As noted in #26106, user code will end up flattening the output of `printSync`, and it's slow. Since that cost is inevitable, we might as well make flattening the string part of `printSync`s responsibility, and make sure the cost is as low as possible. V8 has 2 different methods of flattening a string. One of them is optimized for for exactly the shape of string that codegen produces - a rope which has been generated by repeated `output += segment` appends. The other (which would be more likely to be triggered by user code) is about 2x slower for ropes of this shape. So here we use the faster one, which we trigger with `output.indexOf(" ")`. Except in the weirdest of weird cases, a space will appear early in the output, so `indexOf` won't have to search far. Even if it _does_ have to search the whole string, that's still faster than the slower flatten path. The change to benchmarks made in #26106 flattened the output string with `output.charCodeAt(0)` which is the slower flatten path. In comparison, using the faster flatten, has a large effect (smaller is faster): | Fixture | Bytes | Change | |:---| ---:| ---:| | `tiny.js` | 26 | \-23.5% | | `RadixUIAdoptionSection.jsx` | 2,424 | \-41.8% | | `react.development.js` | 50,496 | \-24.2% | | `binder.ts` | 126,212 | \-23.4% | | `lodash.js` | 182,262 | \-17.6% | | `App.tsx` | 298,130 | \-14.9% | | `kitchen-sink.tsx` | 662,560 | \-15.6% | | `antd.js` | 5,100,647 | \-16.8% |
This commit is contained in:
1 parent
77855831a6
commit
487427aa20
4 files changed
+62
-4
No files matched your search
@@ -98,6 +98,9 @@ Result is a **4x slow-down**.
|
||||
So `oxc-codegen` has to find a different solution. Every time it pushes a string to output, it also records
|
||||
in `state.last` what the output now ends with. `state.output` never needs to be read from.
|
||||
|
||||
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.
|
||||
|
||||
### The answer: A category, not a character
|
||||
|
||||
It turns out the functions which decide whether spaces need to be inserted between keywords / identifiers
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* 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(" ");
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
// Reference: `oxc/crates/oxc_codegen/src/{gen.rs,lib.rs,str.rs,binary_expr_visitor.rs}`
|
||||
|
||||
import { debugAssert, typeAssertIs } from "../asserts.ts";
|
||||
import { flattenString } from "./flatten.ts";
|
||||
import { generateSourceMap } from "./source_map.ts";
|
||||
import { printProgram, printStatement } from "./statement.ts";
|
||||
|
||||
@@ -37,6 +38,10 @@ 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);
|
||||
|
||||
// This is removed by minifier in non-sourcemap builds.
|
||||
//
|
||||
// The `skipSourcemapGeneration` check exists only in benchmark builds -
|
||||
@@ -45,8 +50,8 @@ export function printSync(node: ESTree.Node, state: State, options: Options): Co
|
||||
// @ts-expect-error `skipSourcemapGeneration` is benchmarks-only, so is not in `Options`
|
||||
if (SOURCEMAPS && (!BENCHMARKS || !options.skipSourcemapGeneration)) {
|
||||
debugAssert(options.sourcemap === true, "`options.sourcemap` should be true in a maps build");
|
||||
return { code: state.output, map: generateSourceMap(state, options) };
|
||||
return { code: output, map: generateSourceMap(state, output, options) };
|
||||
}
|
||||
|
||||
return { code: state.output, map: null };
|
||||
return { code: output, map: null };
|
||||
}
|
||||
@@ -36,14 +36,21 @@ const ASCII_DECODER = /* @__PURE__ */ new TextDecoder();
|
||||
|
||||
/**
|
||||
* Convert deferred mapping data into a standard Source Map v3 object.
|
||||
*
|
||||
* Caller should flatten the `output` string before calling this.
|
||||
*
|
||||
* @param state - Printer state holding the recorded mappings
|
||||
* @param output - The generated code, already flattened.
|
||||
* @param options - Printer options
|
||||
* @returns The source map
|
||||
*/
|
||||
export function generateSourceMap(state: State, options: Options): SourceMap {
|
||||
export function generateSourceMap(state: State, output: string, options: Options): SourceMap {
|
||||
debugAssert(
|
||||
state.mapPositions !== null && state.mapNames !== null && state.sourceText !== null,
|
||||
"`mapPositions`, `mapNames` and `sourceText` should be defined when source maps are enabled",
|
||||
);
|
||||
|
||||
const { output, mapPositions, mapPositionsLen, mapNames, sourceText } = state;
|
||||
const { mapPositions, mapPositionsLen, mapNames, sourceText } = state;
|
||||
const mappingCount = mapPositionsLen >> 1;
|
||||
|
||||
if (mappingCount === 0) {
|
||||
|
||||
Reference in new issue
Block a user