mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
5e6d537fca
Over the weekend, I asked Fable to work on optimizing write operations when assembling the output string. This uncovered a disturbing finding. ### Unrealistic benchmarks The output string is assembled with repeated `output += segment` calls. This creates "cons strings" - a rope where each append creates a new string which references the previous `output` as its left side, and `segment` as its right side. On each append the rope gets longer - forming a linked list of segments. That rope-string is what `printSync` returns to the user. It is a string, so it's correct, but there's a problem. In order to actually use that string for anything (write it to a file, index into it, search it, run a regex on it, etc) it has to be flattened. V8 does that transparently, so it works fine, but it's a costly operation. So the disturbing finding was that we were ignoring that cost - we pushed it onto the user, and didn't measure it in our benchmarks. So, once this hidden cost is included, the codegen isn't as fast as we thought it was. ### Including the flatten cost This PR alters the benchmarks to include string flattening via the most common path that V8 takes in user code. The effect is pronounced (higher is worse): | Fixture | Bytes | Change | |:---| ---:| ---:| | `tiny.js` | 26 | \+92.4% | | `RadixUIAdoptionSection.jsx` | 2,424 | \+303.8% | | `react.development.js` | 50,496 | \+75.2% | | `binder.ts` | 126,212 | \+85.9% | | `lodash.js` | 182,262 | \+161.5% | | `App.tsx` | 298,130 | \+146.8% | | `kitchen-sink.tsx` | 662,560 | \+116.8% | | `antd.js` | 5,100,647 | \+72.5% | `RadixUIAdoptionSection.jsx` suffers in particular (4x slower) because one of the `JSXText` elements contain non-ASCII characters. This means the string has to be flattened to a 2-byte string (UTF-16) instead of the faster 1-byte (Latin1) representation. This encoding is twice as large, and hits V8's slow paths (because its optimized for 1-byte strings). This doesn't mean that codegen has got slower - we're just now measuring it more realistically. ### Source maps builds Source map builds are not affected - their perf doesn't change. They already flattened the `output` string by calling `indexOf` on it, so they were already paying the cost, we just didn't realize it. ### What to do? The good news is that there are some things we can do to mitigate the impact - see following PRs.
170 lines
7.5 KiB
TypeScript
170 lines
7.5 KiB
TypeScript
// Benchmarks. Run with `pnpm run bench`.
|
|
//
|
|
// Each fixture is printed three ways:
|
|
//
|
|
// 1. Without source maps:
|
|
// No `sourcemap` option is given, so `printSync` selects the builds compiled without source map support,
|
|
// which is what most callers use.
|
|
//
|
|
// 2. With source maps:
|
|
// `sourcemap: true` selects the source-map build, and `sourceText` is provided, so every mapped write
|
|
// records a mapping and `generateSourceMap` encodes them all into a Source Map v3 object at the end.
|
|
// This is the end-to-end cost of printing with a source map.
|
|
//
|
|
// 3. With source maps, without generating the map:
|
|
// Same as 2, except it skips the final `generateSourceMap` pass, measuring the print pass with its pushes
|
|
// to the mapping arrays alone - the difference from 2 is the cost of encoding the map.
|
|
//
|
|
// All 3 print the same AST. Mappings come from the `start` / `end` offsets every node already carries,
|
|
// so the source-map build sees exactly the node shapes the plain build does.
|
|
//
|
|
// Imports from `dist`, which must hold a RELEASE build with `BENCHMARKS` enabled.
|
|
// `pnpm run build-dev` replaces `dist` with a debug build, which keeps `debugAssert` calls live
|
|
// and does not represent shipped performance.
|
|
// `pnpm run bench` builds the right build first, so benchmarking that way is always correct.
|
|
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { join as pathJoin } from "node:path";
|
|
import { parseSync } from "oxc-parser";
|
|
import { bench, describe } from "vitest";
|
|
|
|
import { printSync as oxcPrintSync } from "./dist/index.js";
|
|
|
|
// `TestFiles::minimal()` from `tasks/common/src/test_file.rs`, which the Rust `codegen` benchmark
|
|
// uses, plus 2 larger pure-JS files from `TestFiles::minifier()` for throughput on real bundles.
|
|
const FIXTURE_URLS = [
|
|
"https://cdn.jsdelivr.net/gh/oxc-project/benchmark-files@cd3bc3d431452b640f5dfcabbc22a8d8a388f393/RadixUIAdoptionSection.jsx",
|
|
"https://cdn.jsdelivr.net/npm/react@17.0.2/cjs/react.development.js",
|
|
"https://cdn.jsdelivr.net/gh/excalidraw/excalidraw@f6d85bc80fe328e8f472636eb0d541f7bb891aa0/packages/excalidraw/components/App.tsx",
|
|
"https://cdn.jsdelivr.net/gh/microsoft/TypeScript@v5.3.3/src/compiler/binder.ts",
|
|
"https://cdn.jsdelivr.net/gh/oxc-project/benchmark-files@cd3bc3d431452b640f5dfcabbc22a8d8a388f393/kitchen-sink.tsx",
|
|
"https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.js",
|
|
"https://cdn.jsdelivr.net/npm/antd@4.16.1/dist/antd.js",
|
|
];
|
|
|
|
// Same directory the Rust benchmarks download to, so nothing is downloaded twice
|
|
const CACHE_DIR_PATH = pathJoin(import.meta.dirname, "../../target");
|
|
|
|
// `experimentalRawTransfer` is not in the published `ParserOptions` type - it is experimental
|
|
// and untyped. Declaring the object here rather than inline at the call site keeps TypeScript's
|
|
// excess property check (which only applies to object literals) from rejecting it, without a cast.
|
|
//
|
|
// `preserveParens: false` because this printer deliberately does not support the redundant
|
|
// `ParenthesizedExpression` / `TSParenthesizedType` nodes.
|
|
//
|
|
// `experimentalRawTransfer` builds the AST with the JS deserializer rather than via JSON,
|
|
// which is the pairing this package is meant to be used in - node `type` strings come out
|
|
// as source literals, so the printer's type switches compare pointers rather than characters.
|
|
const PARSE_OPTIONS = {
|
|
preserveParens: false,
|
|
experimentalRawTransfer: true,
|
|
};
|
|
|
|
// Timings, raised well above Vitest's defaults of 500ms timed and 100ms warmup.
|
|
//
|
|
// The defaults leave enough run-to-run variance to swamp a 1-2% change,
|
|
// which is the size of win a printer optimization typically produces.
|
|
// The long warmup matters just as much as the long run:
|
|
// V8 has to tier the printer up to optimized code before any of the timed iterations start,
|
|
// or the first results measure the interpreter instead.
|
|
const BENCH_OPTIONS = { time: 3000, warmupTime: 1000, iterations: 50, warmupIterations: 20 };
|
|
|
|
/**
|
|
* Load a fixture, downloading it and caching it on disk if it isn't there already.
|
|
*
|
|
* @param url - URL to download the fixture from
|
|
* @returns Fixture's filename and source text
|
|
*/
|
|
async function loadFixture(url: string): Promise<{ filename: string; code: string }> {
|
|
const filename = url.split("/").at(-1)!,
|
|
path = pathJoin(CACHE_DIR_PATH, filename);
|
|
|
|
let code: string;
|
|
try {
|
|
code = await readFile(path, "utf8");
|
|
} catch {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`Failed to download ${url}: ${res.status}`);
|
|
code = await res.text();
|
|
await mkdir(CACHE_DIR_PATH, { recursive: true });
|
|
await writeFile(path, code);
|
|
}
|
|
|
|
return { filename, code };
|
|
}
|
|
|
|
// Dummy var to prevent dead code removal in benchmark functions (see below).
|
|
let flattenSink = 0;
|
|
|
|
// `tiny.js` is one line long, so its result is a comparison of the two printers' fixed overhead
|
|
// rather than of their throughput. Fixtures are ordered by size.
|
|
const fixtures = [
|
|
{ filename: "tiny.js", code: "export default printSync;\n" },
|
|
...(await Promise.all(FIXTURE_URLS.map(loadFixture))),
|
|
];
|
|
fixtures.sort((fixture1, fixture2) => fixture1.code.length - fixture2.code.length);
|
|
|
|
for (const { filename, code } of fixtures) {
|
|
const { program, errors } = parseSync(filename, code, PARSE_OPTIONS);
|
|
if (errors.length > 0) throw new Error(`Failed to parse ${filename}: ${errors[0].message}`);
|
|
|
|
// `.tsx`/`.jsx` fixtures need JSX-safe printing of type parameter lists (`<T,>`)
|
|
const options = { jsx: filename.endsWith("x"), ts: /\.tsx?$/.test(filename) };
|
|
|
|
// Options for the 2 source-map variants. `sourceText` is what mapped writes read to record
|
|
// an original name, and what `generateSourceMap` scans for line breaks at the end.
|
|
//
|
|
// `skipSourcemapGeneration` is spelled out in both, so the 2 objects share one shape -
|
|
// `printSync` and the `State` constructor read these options on every call.
|
|
// It is benchmarks-only, so it is absent from `Options`, which is why neither object is passed
|
|
// as a literal - an excess property is an error on a literal, but not on a variable.
|
|
const mapOptions = {
|
|
...options,
|
|
sourcemap: true,
|
|
sourceText: code,
|
|
sourceFilename: filename,
|
|
skipSourcemapGeneration: false,
|
|
};
|
|
const mapNoGenerationOptions = { ...mapOptions, skipSourcemapGeneration: true };
|
|
|
|
// Print once before timing, so a benchmark can never measure the printer bailing out
|
|
oxcPrintSync(program, options);
|
|
oxcPrintSync(program, mapOptions);
|
|
oxcPrintSync(program, mapNoGenerationOptions);
|
|
|
|
// Each benchmark includes flattening the string as whatever user does with the returned code string
|
|
// (indexing into it, slicing it, writing it to a file) will involve flattening first.
|
|
// We don't want to hide a burden we push onto users from our benchmarks.
|
|
//
|
|
// `code.charCodeAt(0)` triggers flattening `code`. Writing the result to `flattenSink` prevents V8
|
|
// from optimizing out the `charCodeAt` call as dead code.
|
|
describe(`${filename} (${code.length} bytes)`, () => {
|
|
bench(
|
|
"oxc-codegen",
|
|
() => {
|
|
const { code } = oxcPrintSync(program, options);
|
|
flattenSink ^= code.charCodeAt(0);
|
|
},
|
|
BENCH_OPTIONS,
|
|
);
|
|
|
|
bench(
|
|
"oxc-codegen sourcemaps",
|
|
() => {
|
|
const { code } = oxcPrintSync(program, mapOptions);
|
|
flattenSink ^= code.charCodeAt(0);
|
|
},
|
|
BENCH_OPTIONS,
|
|
);
|
|
|
|
bench(
|
|
"oxc-codegen sourcemaps no generation",
|
|
() => {
|
|
const { code } = oxcPrintSync(program, mapNoGenerationOptions);
|
|
flattenSink ^= code.charCodeAt(0);
|
|
},
|
|
BENCH_OPTIONS,
|
|
);
|
|
});
|
|
}
|