Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions doc/Benchmarking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Pipeline performance benchmark

Run the benchmark from the repository root:

```bash
npm run benchmark:pipeline
```

The command builds `quicktype-core`, warms up every case, and reports median,
p95, and minimum end-to-end times plus maximum observed used heap. It covers
small and large JSON samples, small and large JSON Schemas, and the TypeScript
and Rust renderers. Inputs are generated deterministically in memory so
results do not include filesystem I/O.

## Canonical real-world benchmark

Run the canonical benchmark set with:

```bash
npm run benchmark:canonical
```

This command builds `quicktype-core`, gives Node an 8 GiB heap, and runs one
warmup plus three measured passes for both TypeScript and Rust over exactly
these inputs:

- USGS all-month earthquakes GeoJSON
- GitHub REST OpenAPI description
- NVD CVE 2024 feed
- HL7 FHIR R5 combined JSON Schema
- Kestra 0.19 JSON Schema

The inputs are downloaded on the first run and cached outside the repository in
the operating system's user cache directory. Downloads, decompression, and
filesystem reads are not timed. Repeated runs use the same snapshots; download
current copies with:

```bash
npm run benchmark:canonical -- --refresh
```

Set `QUICKTYPE_BENCHMARK_CACHE` or pass `--cache-dir DIR` to choose a different
cache location. The canonical command accepts the same `--warmup`,
`--iterations`, and `--json` options as the synthetic pipeline benchmark.

The end-to-end timer begins before quicktype parses or registers the input and
ends after the generated source has been serialized. The phase breakdown is:

- **Parse**: compressed JSON parsing for JSON samples, or YAML/JSON parsing for
JSON Schema.
- **Infer/schema**: type inference from compressed JSON, or conversion from the
parsed schema to quicktype's initial type graph.
- **Transform**: graph rewrites, map and enum inference, transformations,
garbage collection, and name gathering.
- **Codegen**: target-language rendering and serialization.
- **Other**: input setup, graph setup, callback overhead, and uninstrumented
control flow.

The phase breakdown uses the run at the median end-to-end time. When the
iteration count is even, the two middle runs are interpolated. This keeps the
phases additive and their percentages at 100%.

Individual pass timings are also printed. Pass timings are inclusive, and
repeated passes such as `flatten unions` are combined within each run.

`Max heap` is the largest `process.memoryUsage().heapUsed` value observed across
the measured runs for a case. Heap is sampled before and after input parsing,
after every instrumented quicktype pass, after rendering, and after output
serialization. Node does not expose an exact JavaScript-heap high-water mark,
so allocations created and released entirely within one synchronous pass might
not appear in this value. The JSON output records it as
`memory.maximumHeapUsedBytes`.

Use fewer samples for a quick smoke test or emit JSON for comparison tooling:

```bash
npm run benchmark:pipeline -- --warmup 0 --iterations 1
npm run benchmark:pipeline -- --iterations 10 --json > benchmark.json
```

For less garbage-collection noise, invoke the built benchmark directly with
Node's explicit GC enabled:

```bash
npm run build --workspace quicktype-core
node --expose-gc script/benchmark-pipeline.mjs
```

Compare results only on the same machine, Node version, power mode, and command
line. The benchmark intentionally reports distributions rather than enforcing
a fixed pass/fail threshold.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"test:release": "tsx test/release-version.ts",
"benchmark:markov-chain": "npm run build --workspace quicktype-core && node --expose-gc script/benchmark-markov-chain.mjs",
"benchmark:markov-chain:fidelity": "npm run build --workspace quicktype-core && node script/benchmark-markov-fidelity.mjs",
"benchmark:pipeline": "npm run build --workspace quicktype-core && node script/benchmark-pipeline.mjs",
"benchmark:canonical": "npm run build --workspace quicktype-core && node --expose-gc --max-old-space-size=8192 script/benchmark-pipeline.mjs --canonical --iterations 3",
"start": "script/watch",
"clean": "rm -rf dist *~ packages/*/{dist,out}",
"debug": "node --inspect-brk --max-old-space-size=4096 ./dist/index.js",
Expand Down
39 changes: 29 additions & 10 deletions packages/quicktype-core/src/Naming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export class Namespace {

private readonly _members = new Set<Name>();

private _forbiddenNameds: ReadonlySet<Name> | undefined;

public constructor(
_name: string,
parent: Namespace | undefined,
Expand All @@ -50,11 +52,14 @@ export class Namespace {
}

public get forbiddenNameds(): ReadonlySet<Name> {
// FIXME: cache
return setUnion(
this.additionalForbidden,
...Array.from(this.forbiddenNamespaces).map((ns) => ns.members),
);
if (this._forbiddenNameds === undefined) {
this._forbiddenNameds = setUnion(
this.additionalForbidden,
...Array.from(this.forbiddenNamespaces).map((ns) => ns.members),
);
}

return this._forbiddenNameds;
}

public add<TName extends Name>(named: TName): TName {
Expand Down Expand Up @@ -451,17 +456,23 @@ export function assignNames(
}
}

const unfinishedNamespaces = setFilter(ctx.namespaces, (ns) =>
iterableSome(ns.members, (member) => !ctx.names.has(member)),
);

for (;;) {
// 1. Find a namespace whose forbiddens are all fully named, and which has
// at least one unnamed Named that has all its dependencies satisfied.
// If no such namespace exists we're either done, or there's an unallowed
// cycle.

const unfinishedNamespaces = setFilter(ctx.namespaces, (ns) =>
ctx.areForbiddensFullyNamed(ns),
);
const readyNamespace = iterableFind(unfinishedNamespaces, (ns) =>
iterableSome(ns.members, (member) => ctx.isReadyToBeNamed(member)),
const readyNamespace = iterableFind(
unfinishedNamespaces,
(ns) =>
ctx.areForbiddensFullyNamed(ns) &&
iterableSome(ns.members, (member) =>
ctx.isReadyToBeNamed(member),
),
);

if (readyNamespace === undefined) {
Expand Down Expand Up @@ -519,5 +530,13 @@ export function assignNames(
}
}
}

if (
iterableEvery(readyNamespace.members, (member) =>
ctx.names.has(member),
)
) {
unfinishedNamespaces.delete(readyNamespace);
}
}
}
48 changes: 31 additions & 17 deletions packages/quicktype-core/src/Run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export interface NonInferenceOptions<Lang extends LanguageName = LanguageName> {
leadingComments?: Comment[];
/** Don't render output. This is mainly useful for benchmarking. */
noRender: boolean;
/** Called after each measured pipeline pass. This is mainly useful for benchmarking. */
onTiming?: (timing: QuicktypeTiming) => void;
/** Name of the output file. Note that quicktype will not write that file, but you'll get its name
* back as a key in the resulting `Map`.
*/
Expand All @@ -116,6 +118,11 @@ export interface NonInferenceOptions<Lang extends LanguageName = LanguageName> {
rendererOptions: Partial<RendererOptions<Lang>>;
}

export interface QuicktypeTiming {
milliseconds: number;
name: string;
}

export type Options<Lang extends LanguageName = LanguageName> =
NonInferenceOptions<Lang> & InferenceFlags;

Expand All @@ -126,6 +133,7 @@ const defaultOptions: NonInferenceOptions = {
allPropertiesOptional: false,
fixedTopLevels: false,
noRender: false,
onTiming: undefined,
leadingComments: undefined,
rendererOptions: {},
indentation: undefined,
Expand Down Expand Up @@ -195,24 +203,28 @@ class Run implements RunContext {
}

public async timeSync<T>(name: string, f: () => Promise<T>): Promise<T> {
const start = Date.now();
const start = performance.now();
const result = await f();
const end = Date.now();
const milliseconds = performance.now() - start;
if (this._options.debugPrintTimes) {
console.log(`${name} took ${end - start}ms`);
console.log(`${name} took ${milliseconds.toFixed(3)}ms`);
}

this._options.onTiming?.({ name, milliseconds });

return result;
}

public time<T>(name: string, f: () => T): T {
const start = Date.now();
const start = performance.now();
const result = f();
const end = Date.now();
const milliseconds = performance.now() - start;
if (this._options.debugPrintTimes) {
console.log(`${name} took ${end - start}ms`);
console.log(`${name} took ${milliseconds.toFixed(3)}ms`);
}

this._options.onTiming?.({ name, milliseconds });

return result;
}

Expand Down Expand Up @@ -580,18 +592,20 @@ class Run implements RunContext {
targetLanguage: TargetLanguage,
graph: TypeGraph,
): MultiFileRenderResult {
if (this._options.noRender) {
return this.makeSimpleTextResult(["Done.", ""]);
}
return this.time("render", () => {
if (this._options.noRender) {
return this.makeSimpleTextResult(["Done.", ""]);
}

return targetLanguage.renderGraphAndSerialize(
graph,
this._options.outputFilename,
this._options.alphabetizeProperties,
this._options.leadingComments,
this._options.rendererOptions,
this._options.indentation,
);
return targetLanguage.renderGraphAndSerialize(
graph,
this._options.outputFilename,
this._options.alphabetizeProperties,
this._options.leadingComments,
this._options.rendererOptions,
this._options.indentation,
);
});
}
}

Expand Down
35 changes: 32 additions & 3 deletions packages/quicktype-core/src/UnionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ function addAttributesToBuilder<T extends TypeKind>(
if (arr === undefined) {
arr = [];
builder.set(kind, arr);
} else if (newAttributes.size === 0) {
return;
}

arr.push(newAttributes);
Expand Down Expand Up @@ -108,6 +110,8 @@ export class UnionAccumulator<TArray, TObject>
private readonly _stringTypeAttributes: TypeAttributeMapBuilder<PrimitiveStringTypeKind> =
new Map();

private readonly _stringCases = new Map<string, number>();

public readonly arrayData: TArray[] = [];

public readonly objectData: TObject[] = [];
Expand Down Expand Up @@ -251,15 +255,27 @@ export class UnionAccumulator<TArray, TObject>
}

public addStringCases(cases: string[], attributes: TypeAttributes): void {
this.addFullStringType(attributes, StringTypes.fromCases(cases));
addAttributesToBuilder(
this._stringTypeAttributes,
"string",
attributes,
);
for (const s of new Set(cases)) {
this._stringCases.set(s, (this._stringCases.get(s) ?? 0) + 1);
}
}

public addStringCase(
s: string,
count: number,
attributes: TypeAttributes,
): void {
this.addFullStringType(attributes, StringTypes.fromCase(s, count));
addAttributesToBuilder(
this._stringTypeAttributes,
"string",
attributes,
);
this._stringCases.set(s, (this._stringCases.get(s) ?? 0) + count);
}

public get enumCases(): ReadonlySet<string> {
Expand All @@ -272,9 +288,22 @@ export class UnionAccumulator<TArray, TObject>
"We can't have both strings and enums in the same union",
);

const stringTypeAttributes = buildTypeAttributeMap(
this._stringTypeAttributes,
);
if (this._stringCases.size > 0) {
setAttributes(
stringTypeAttributes,
"string",
stringTypesTypeAttributeKind.makeAttributes(
new StringTypes(new Map(this._stringCases), new Set()),
),
);
}

const merged = mapMerge(
buildTypeAttributeMap(this._nonStringTypeAttributes),
buildTypeAttributeMap(this._stringTypeAttributes),
stringTypeAttributes,
);

if (merged.size === 0) {
Expand Down
1 change: 1 addition & 0 deletions packages/quicktype-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
type Options,
type QuicktypeTiming,
getTargetLanguage,
quicktypeMultiFile,
quicktypeMultiFileSync,
Expand Down
10 changes: 7 additions & 3 deletions packages/quicktype-core/src/input/JSONSchemaInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,7 @@ async function refsInSchemaForURI(
class InputJSONSchemaStore extends JSONSchemaStore {
public constructor(
private readonly _inputs: Map<string, string>,
private readonly _ctx: RunContext,
private readonly _delegate?: JSONSchemaStore,
) {
super();
Expand All @@ -1466,9 +1467,11 @@ class InputJSONSchemaStore extends JSONSchemaStore {
public async fetch(address: string): Promise<JSONSchema | undefined> {
const maybeInput = this._inputs.get(address);
if (maybeInput !== undefined) {
return checkJSONSchema(
parseJSON(maybeInput, "JSON Schema", address),
() => Ref.root(address),
return this._ctx.time("parse JSON Schema", () =>
checkJSONSchema(
parseJSON(maybeInput, "JSON Schema", address),
() => Ref.root(address),
),
);
}

Expand Down Expand Up @@ -1540,6 +1543,7 @@ export class JSONSchemaInput implements Input<JSONSchemaSourceData> {
} else {
this._schemaStore = new InputJSONSchemaStore(
this._schemaInputs,
ctx,
maybeSchemaStore,
);
maybeSchemaStore = this._schemaStore;
Expand Down
6 changes: 5 additions & 1 deletion packages/quicktype-core/src/support/ParseJSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ export function parseJSON(
text = text.slice(1);
}

return YAML.parse(text);
try {
return JSON.parse(text);
} catch {
return YAML.parse(text);
}
} catch (e) {
let message: string;

Expand Down
Loading
Loading