Skip to content
Open
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
8 changes: 8 additions & 0 deletions .chronus/changes/feature-benchmark-tool-2026-0-29-14-46-47.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/compiler"
---

[API] Add performance reporting utilities for emitters [See docs for more info](https://typespec.io/docs/extending-typespec/performance-reporting/)
8 changes: 8 additions & 0 deletions .chronus/changes/feature-benchmark-tool-2026-0-29-14-52-37.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/openapi3"
---

Expose performance information when running with `--stats`
10 changes: 6 additions & 4 deletions packages/compiler/src/core/cli/actions/compile/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,18 +212,20 @@ function printStats(stats: Stats) {
printRuntime(stats, "checker", Performance.stage, 4);
printGroup(stats, "validation", "validators", Performance.validator, 4);
printGroup(stats, "linter", "rules", Performance.lintingRule, 4);
printGroup(stats, "emit", "emitters", Performance.stage, 4);
for (const emitter of Object.keys(stats.emit.emitters).sort()) {
printGroup(stats.emit.emitters, emitter, "steps", Performance.stage, 6);
}
}

function printGroup<K extends keyof RuntimeStats, L extends keyof RuntimeStats[K]>(
base: RuntimeStats,
function printGroup<B, K extends keyof B, L extends keyof B[K]>(
base: B,
groupName: K,
itemsKey: L,
perf: readonly [number, number],
indent: number = 0,
) {
const group: any = base[groupName];
printKV(groupName, runtimeStr(group["total"] ?? 0), indent);
printKV(groupName as any, runtimeStr(group["total"] ?? 0), indent);
for (const [key, value] of Object.entries(group[itemsKey]).sort((a, b) =>
a[0].localeCompare(b[0]),
)) {
Expand Down
8 changes: 4 additions & 4 deletions packages/compiler/src/core/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { defineLinter } from "./library.js";
import { createUnusedTemplateParameterLinterRule } from "./linter-rules/unused-template-parameter.rule.js";
import { createUnusedUsingLinterRule } from "./linter-rules/unused-using.rule.js";
import { createDiagnostic } from "./messages.js";
import { perf } from "./perf.js";
import type { Program } from "./program.js";
import { EventEmitter, mapEventEmitterToNodeListener, navigateProgram } from "./semantic-walker.js";
import { startTimer } from "./stats.js";
import {
Diagnostic,
DiagnosticMessages,
Expand Down Expand Up @@ -197,17 +197,17 @@ export function createLinter(
[...filteredRules.keys()].map((x) => ` - ${x}`).join("\n"),
);

const timer = startTimer();
const timer = perf.startTimer();
const exitCallbacks = [];
const EXIT_EVENT_NAME = "exit";
const allPromises: Promise<any>[] = [];
for (const rule of filteredRules.values()) {
const createTiming = startTimer();
const createTiming = perf.startTimer();
const listener = rule.create(createLinterRuleContext(program, rule, diagnostics));
stats.runtime.rules[rule.id] = createTiming.end();
for (const [name, cb] of Object.entries(listener)) {
const timedCb = (...args: any[]) => {
const timer = startTimer();
const timer = perf.startTimer();
const result = (cb as any)(...args);
if (name === EXIT_EVENT_NAME && isPromise(result)) {
compilerAssert(
Expand Down
63 changes: 63 additions & 0 deletions packages/compiler/src/core/perf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { PerfReporter, Timer } from "./types.js";

export function startTimer(): Timer {
const start = performance.now();
return {
end: () => {
return performance.now() - start;
},
};
}

export function time(fn: () => void): number {
const timer = startTimer();
fn();
return timer.end();
}

export async function timeAsync(fn: () => Promise<void>): Promise<number> {
const timer = startTimer();
await fn();
return timer.end();
}

/** Perf utils */
export const perf = {
startTimer,
time,
timeAsync,
};

export function createPerfReporter(): PerfReporter {
const measures: Record<string, number> = {};
function startReportingTimer(label: string): Timer {
const timer = startTimer();
return {
end: () => {
const time = timer.end();
measures[label] = time;
return time;
},
};
}

return {
startTimer: startReportingTimer,
time: <T>(label: string, fn: () => T): T => {
const timer = startReportingTimer(label);
const result = fn();
timer.end();
return result;
},
timeAsync: async <T>(label: string, fn: () => Promise<T>): Promise<T> => {
const timer = startReportingTimer(label);
const result = await fn();
timer.end();
return result;
},
report: (label: string, duration: number) => {
measures[label] = duration;
},
measures,
};
}
36 changes: 22 additions & 14 deletions packages/compiler/src/core/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { Numeric } from "./numeric.js";
import { CompilerOptions } from "./options.js";
import { parse, parseStandaloneTypeReference } from "./parser.js";
import { getDirectoryPath, joinPaths, resolvePath } from "./path-utils.js";
import { createPerfReporter, perf } from "./perf.js";
import {
SourceLoader,
SourceResolution,
Expand All @@ -42,7 +43,7 @@ import {
moduleResolutionErrorToDiagnostic,
} from "./source-loader.js";
import { createStateAccessors } from "./state-accessors.js";
import { ComplexityStats, RuntimeStats, Stats, startTimer, time, timeAsync } from "./stats.js";
import { ComplexityStats, RuntimeStats, Stats } from "./stats.js";
import {
CompilerHost,
Diagnostic,
Expand All @@ -61,6 +62,7 @@ import {
Namespace,
NoTarget,
Node,
PerfReporter,
SourceFile,
Sym,
SymbolFlags,
Expand Down Expand Up @@ -186,15 +188,15 @@ export async function compile(
total: 0,
emitters: {},
};
const timer = startTimer();
const timer = perf.startTimer();
// Emitter stage
for (const emitter of program.emitters) {
// If in dry mode run and an emitter doesn't support it we have to skip it.
if (program.compilerOptions.dryRun && !emitter.library.definition?.capabilities?.dryRun) {
continue;
}
const { duration } = await emit(emitter, program);
emitStats.emitters[emitter.metadata.name ?? "<unnamed>"] = duration;
const emitterStats = await emit(emitter, program);
emitStats.emitters[emitter.metadata.name ?? "<unnamed>"] = emitterStats;
if (options.listFiles) {
logEmittedFilesPath(host.logSink);
}
Expand Down Expand Up @@ -278,7 +280,7 @@ async function createProgram(
const basedir = getDirectoryPath(resolvedMain) || "/";
await checkForCompilerVersionMismatch(basedir);

runtimeStats.loader = await timeAsync(() => loadSources(resolvedMain));
runtimeStats.loader = await perf.timeAsync(() => loadSources(resolvedMain));

const emit = options.noEmit ? [] : (options.emit ?? []);
const emitterOptions = options.options;
Expand All @@ -297,7 +299,7 @@ async function createProgram(
oldProgram = undefined;

const resolver = (program.resolver = createResolver(program));
runtimeStats.resolver = time(() => resolver.resolveProgram());
runtimeStats.resolver = perf.time(() => resolver.resolveProgram());

const linter = createLinter(program, (name) => loadLibrary(basedir, name));
linter.registerLinterLibrary(builtInLinterLibraryName, createBuiltInLinterLibrary());
Expand All @@ -306,7 +308,7 @@ async function createProgram(
}

program.checker = createChecker(program, resolver);
runtimeStats.checker = time(() => program.checker.checkProgram());
runtimeStats.checker = perf.time(() => program.checker.checkProgram());

complexityStats.createdTypes = program.checker.stats.createdTypes;
complexityStats.finishedTypes = program.checker.stats.finishedTypes;
Expand Down Expand Up @@ -634,12 +636,12 @@ async function createProgram(
}

async function runValidators() {
const start = startTimer();
const start = perf.startTimer();
runtimeStats.validation = { total: 0, validators: {} };
runCompilerValidators();
runtimeStats.validation.validators.compiler = start.end();
for (const validator of validateCbs) {
const start = startTimer();
const start = perf.startTimer();
const diagnostics = await runValidator(validator);
if (diagnostics && Array.isArray(diagnostics)) {
program.reportDiagnostics(diagnostics);
Expand Down Expand Up @@ -972,7 +974,10 @@ function resolveOptions(options: CompilerOptions): CompilerOptions {
return { ...options };
}

async function emit(emitter: EmitterRef, program: Program): Promise<{ duration: number }> {
async function emit(
emitter: EmitterRef,
program: Program,
): Promise<{ total: number; steps: Record<string, number> }> {
const emitterName = emitter.metadata.name ?? "";
const relativePathForEmittedFiles =
transformPathForSink(program.host.logSink, emitter.emitterOutputDir) + "/";
Expand All @@ -981,8 +986,8 @@ async function emit(emitter: EmitterRef, program: Program): Promise<{ duration:
const warnCount = program.diagnostics.filter((x) => x.severity === "warning").length;
const logger = createLogger({ sink: program.host.logSink });
return await logger.trackAction(`Running ${emitterName}...`, "", async (task) => {
const start = startTimer();
await runEmitter(emitter, program);
const start = perf.startTimer();
const emitterPerfReporter = await runEmitter(emitter, program);
const duration = start.end();
const message = `${emitterName} ${pc.green(`${Math.round(duration)}ms`)} ${pc.dim(relativePathForEmittedFiles)}`;
const newErrorCount = program.diagnostics.filter((x) => x.severity === "error").length;
Expand All @@ -994,21 +999,24 @@ async function emit(emitter: EmitterRef, program: Program): Promise<{ duration:
} else {
task.succeed(message);
}
return { duration };
return { total: duration, steps: emitterPerfReporter.measures };
});
}

/**
* @param emitter Emitter ref to run
*/
async function runEmitter(emitter: EmitterRef, program: Program) {
async function runEmitter(emitter: EmitterRef, program: Program): Promise<PerfReporter> {
const perfReporter = createPerfReporter();
const context: EmitContext<any> = {
program,
emitterOutputDir: emitter.emitterOutputDir,
options: emitter.options,
perf: perfReporter,
};
try {
await emitter.emitFunction(context);
return perfReporter;
} catch (error: unknown) {
throw new ExternalError({ kind: "emitter", metadata: emitter.metadata, error });
}
Expand Down
32 changes: 6 additions & 26 deletions packages/compiler/src/core/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,32 +28,12 @@ export interface RuntimeStats {
emit: {
total: number;
emitters: {
[rule: string]: number;
[emitterName: string]: {
total: number;
steps: {
[stepName: string]: number;
};
};
};
};
}

export interface Timer {
end: () => number;
}

export function startTimer(): Timer {
const start = performance.now();
return {
end: () => {
return performance.now() - start;
},
};
}

export function time(fn: () => void): number {
const timer = startTimer();
fn();
return timer.end();
}

export async function timeAsync(fn: () => Promise<void>): Promise<number> {
const timer = startTimer();
await fn();
return timer.end();
}
49 changes: 49 additions & 0 deletions packages/compiler/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2525,6 +2525,55 @@ export interface EmitContext<TOptions extends object = Record<string, never>> {
* Emitter custom options defined in createTypeSpecLibrary
*/
options: TOptions;

/**
* Performance measurement utilities.
* Use this to report performance of areas of your emitter.
* The information will be displayed when the compiler is run with `--stats` flag.
*/
readonly perf: PerfReporter;
}

export interface Timer {
end: () => number;
}

export interface PerfReporter {
/**
* Start timer for the given label.
*
* @example
* ```ts
* const timer = emitContext.perf.startTimer("my-emitter-task");
* // ... do work
* const elapsed = timer.end(); // my-emitter-task automatically reported to the compiler
* ```
*/
startTimer(label: string): Timer;
/** Report a sync function elapsed time. */
time<T>(label: string, callback: () => T): T;
/** Report an async function elapsed time. */
timeAsync<T>(label: string, callback: () => Promise<T>): Promise<T>;

/**
* Report a custom elapsed time for the given label.
* Can be used with {@link import("./perf.js").perf}
* @example
* ```ts
* import { perf } from "@typespec/compiler";
*
* // somewhere in your emitter
* const start = perf.now();
* await doSomething();
* const end = perf.now();
*
* emitContext.perf.report("doSomething", end - start);
* ```
*/
report(label: string, milliseconds: number): void;

/** @internal */
readonly measures: Readonly<Record<string, number>>;
}

export type LogLevel = "trace" | "warning" | "error";
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Exports for `@typespec/compiler/utils`.
// Be explicit about what get exported so we don't export utils that are not meant to be public.
// ---------------------------------------
export { createPerfReporter, perf } from "../core/perf.js";
export { DuplicateTracker } from "./duplicate-tracker.js";
export { Queue, TwoLevelMap, createRekeyableMap, deepClone, deepEquals } from "./misc.js";
export { useStateMap, useStateSet } from "./state-accessor.js";
2 changes: 2 additions & 0 deletions packages/compiler/test/typekit/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createPerfReporter } from "../../src/core/perf.js";
import type { Program } from "../../src/core/program.js";
import type { EmitContext, Type } from "../../src/core/types.js";
import { createTestHost } from "../../src/testing/test-host.js";
Expand All @@ -15,6 +16,7 @@ export async function createContextMock(program?: Program): Promise<EmitContext<
program,
emitterOutputDir: "",
options: {},
perf: createPerfReporter(),
};
}

Expand Down
Loading
Loading