benchforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +432 -0
- package/bin/benchforge +3 -0
- package/dist/bin/benchforge.mjs +9 -0
- package/dist/bin/benchforge.mjs.map +1 -0
- package/dist/browser/index.js +914 -0
- package/dist/index.mjs +3 -0
- package/dist/src-CGuaC3Wo.mjs +3676 -0
- package/dist/src-CGuaC3Wo.mjs.map +1 -0
- package/package.json +49 -0
- package/src/BenchMatrix.ts +380 -0
- package/src/Benchmark.ts +33 -0
- package/src/BenchmarkReport.ts +156 -0
- package/src/GitUtils.ts +79 -0
- package/src/HtmlDataPrep.ts +148 -0
- package/src/MeasuredResults.ts +127 -0
- package/src/NodeGC.ts +48 -0
- package/src/PermutationTest.ts +115 -0
- package/src/StandardSections.ts +268 -0
- package/src/StatisticalUtils.ts +176 -0
- package/src/TypeUtil.ts +8 -0
- package/src/bin/benchforge.ts +4 -0
- package/src/browser/BrowserGcStats.ts +44 -0
- package/src/browser/BrowserHeapSampler.ts +248 -0
- package/src/cli/CliArgs.ts +64 -0
- package/src/cli/FilterBenchmarks.ts +68 -0
- package/src/cli/RunBenchCLI.ts +856 -0
- package/src/export/JsonExport.ts +103 -0
- package/src/export/JsonFormat.ts +91 -0
- package/src/export/PerfettoExport.ts +203 -0
- package/src/heap-sample/HeapSampleReport.ts +196 -0
- package/src/heap-sample/HeapSampler.ts +78 -0
- package/src/html/HtmlReport.ts +131 -0
- package/src/html/HtmlTemplate.ts +284 -0
- package/src/html/Types.ts +88 -0
- package/src/html/browser/CIPlot.ts +287 -0
- package/src/html/browser/HistogramKde.ts +118 -0
- package/src/html/browser/LegendUtils.ts +163 -0
- package/src/html/browser/RenderPlots.ts +263 -0
- package/src/html/browser/SampleTimeSeries.ts +389 -0
- package/src/html/browser/Types.ts +96 -0
- package/src/html/browser/index.ts +1 -0
- package/src/html/index.ts +17 -0
- package/src/index.ts +92 -0
- package/src/matrix/CaseLoader.ts +36 -0
- package/src/matrix/MatrixFilter.ts +103 -0
- package/src/matrix/MatrixReport.ts +290 -0
- package/src/matrix/VariantLoader.ts +46 -0
- package/src/runners/AdaptiveWrapper.ts +391 -0
- package/src/runners/BasicRunner.ts +368 -0
- package/src/runners/BenchRunner.ts +60 -0
- package/src/runners/CreateRunner.ts +11 -0
- package/src/runners/GcStats.ts +107 -0
- package/src/runners/RunnerOrchestrator.ts +374 -0
- package/src/runners/RunnerUtils.ts +2 -0
- package/src/runners/TimingUtils.ts +13 -0
- package/src/runners/WorkerScript.ts +256 -0
- package/src/table-util/ConvergenceFormatters.ts +19 -0
- package/src/table-util/Formatters.ts +152 -0
- package/src/table-util/README.md +70 -0
- package/src/table-util/TableReport.ts +293 -0
- package/src/table-util/test/TableReport.test.ts +105 -0
- package/src/table-util/test/TableValueExtractor.test.ts +41 -0
- package/src/table-util/test/TableValueExtractor.ts +100 -0
- package/src/test/AdaptiveRunner.test.ts +185 -0
- package/src/test/AdaptiveStatistics.integration.ts +119 -0
- package/src/test/BenchmarkReport.test.ts +82 -0
- package/src/test/BrowserBench.e2e.test.ts +44 -0
- package/src/test/BrowserBench.test.ts +79 -0
- package/src/test/GcStats.test.ts +94 -0
- package/src/test/PermutationTest.test.ts +121 -0
- package/src/test/RunBenchCLI.test.ts +166 -0
- package/src/test/RunnerOrchestrator.test.ts +102 -0
- package/src/test/StatisticalUtils.test.ts +112 -0
- package/src/test/TestUtils.ts +93 -0
- package/src/test/fixtures/test-bench-script.ts +30 -0
- package/src/tests/AdaptiveConvergence.test.ts +177 -0
- package/src/tests/AdaptiveSampling.test.ts +240 -0
- package/src/tests/BenchMatrix.test.ts +366 -0
- package/src/tests/MatrixFilter.test.ts +117 -0
- package/src/tests/MatrixReport.test.ts +139 -0
- package/src/tests/RealDataValidation.test.ts +177 -0
- package/src/tests/fixtures/baseline/impl.ts +4 -0
- package/src/tests/fixtures/bevy30-samples.ts +158 -0
- package/src/tests/fixtures/cases/asyncCases.ts +7 -0
- package/src/tests/fixtures/cases/cases.ts +8 -0
- package/src/tests/fixtures/cases/variants/product.ts +2 -0
- package/src/tests/fixtures/cases/variants/sum.ts +2 -0
- package/src/tests/fixtures/discover/fast.ts +1 -0
- package/src/tests/fixtures/discover/slow.ts +4 -0
- package/src/tests/fixtures/invalid/bad.ts +1 -0
- package/src/tests/fixtures/loader/fast.ts +1 -0
- package/src/tests/fixtures/loader/slow.ts +4 -0
- package/src/tests/fixtures/loader/stateful.ts +2 -0
- package/src/tests/fixtures/stateful/stateful.ts +2 -0
- package/src/tests/fixtures/variants/extra.ts +1 -0
- package/src/tests/fixtures/variants/impl.ts +1 -0
- package/src/tests/fixtures/worker/fast.ts +1 -0
- package/src/tests/fixtures/worker/slow.ts +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "benchforge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"benchforge": "dist/bin/benchforge.mjs"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"dist",
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/index.mjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"esbuild": "^0.27.3",
|
|
20
|
+
"open": "^11.0.0",
|
|
21
|
+
"picocolors": "^1.1.1",
|
|
22
|
+
"table": "^6.9.0",
|
|
23
|
+
"yargs": "^18.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@biomejs/biome": "^2.4.4",
|
|
27
|
+
"@types/node": "^25.3.0",
|
|
28
|
+
"@types/yargs": "^17.0.35",
|
|
29
|
+
"@typescript/native-preview": "7.0.0-dev.20260220.1",
|
|
30
|
+
"npm-run-all": "^4.1.5",
|
|
31
|
+
"playwright": "^1.58.2",
|
|
32
|
+
"tsdown": "0.20.3",
|
|
33
|
+
"vite": "^7.3.1",
|
|
34
|
+
"vitest": "^4.0.18"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"build:plots": "tsdown --config tsdown.plots.config.ts",
|
|
39
|
+
"example:bench": "bin/benchforge --url file://$PWD/examples/browser-bench/index.html --gc-stats --heap-sample --html",
|
|
40
|
+
"example:heap": "bin/benchforge --url file://$PWD/examples/browser-heap/index.html --heap-sample --gc-stats",
|
|
41
|
+
"example:lap": "bin/benchforge --url file://$PWD/examples/browser-lap/index.html --gc-stats --heap-sample --html",
|
|
42
|
+
"fix": "biome check --fix --unsafe --error-on-warnings",
|
|
43
|
+
"lint": "biome check --error-on-warnings",
|
|
44
|
+
"test": "pnpm --node-options=--expose-gc vitest --hideSkippedTests --exclude '**/*.e2e.test.ts'",
|
|
45
|
+
"test:e2e": "pnpm --node-options=--expose-gc vitest run '.e2e.'",
|
|
46
|
+
"test:once": "pnpm --node-options=--expose-gc vitest run --exclude '**/*.e2e.test.ts'",
|
|
47
|
+
"typecheck": "tsgo"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type { MeasuredResults } from "./MeasuredResults.ts";
|
|
2
|
+
import { loadCaseData, loadCasesModule } from "./matrix/CaseLoader.ts";
|
|
3
|
+
import { discoverVariants } from "./matrix/VariantLoader.ts";
|
|
4
|
+
import { BasicRunner } from "./runners/BasicRunner.ts";
|
|
5
|
+
import type { RunnerOptions } from "./runners/BenchRunner.ts";
|
|
6
|
+
import { runMatrixVariant } from "./runners/RunnerOrchestrator.ts";
|
|
7
|
+
import { average } from "./StatisticalUtils.ts";
|
|
8
|
+
|
|
9
|
+
/** Stateless variant - called each iteration with case data */
|
|
10
|
+
export type VariantFn<T = unknown> = (caseData: T) => void;
|
|
11
|
+
|
|
12
|
+
/** Stateful variant - setup once, run many */
|
|
13
|
+
export interface StatefulVariant<T = unknown, S = unknown> {
|
|
14
|
+
setup: (caseData: T) => S | Promise<S>;
|
|
15
|
+
run: (state: S) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A variant is either a plain function or a stateful setup+run pair */
|
|
19
|
+
export type Variant<T = unknown, S = unknown> =
|
|
20
|
+
| VariantFn<T>
|
|
21
|
+
| StatefulVariant<T, S>;
|
|
22
|
+
|
|
23
|
+
/** Variant with any state type - used in BenchMatrix to allow mixed variants */
|
|
24
|
+
export type AnyVariant<T = unknown> = VariantFn<T> | StatefulVariant<T, any>;
|
|
25
|
+
|
|
26
|
+
/** Result from casesModule.loadCase() */
|
|
27
|
+
export interface LoadedCase<T = unknown> {
|
|
28
|
+
data: T;
|
|
29
|
+
metadata?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MatrixDefaults {
|
|
33
|
+
warmup?: number;
|
|
34
|
+
maxTime?: number;
|
|
35
|
+
iterations?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Bench matrix configuration */
|
|
39
|
+
export interface BenchMatrix<T = unknown> {
|
|
40
|
+
name: string;
|
|
41
|
+
variantDir?: string;
|
|
42
|
+
variants?: Record<string, AnyVariant<T>>;
|
|
43
|
+
cases?: string[];
|
|
44
|
+
casesModule?: string;
|
|
45
|
+
baselineDir?: string;
|
|
46
|
+
baselineVariant?: string;
|
|
47
|
+
defaults?: MatrixDefaults;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Collection of matrices */
|
|
51
|
+
export interface MatrixSuite {
|
|
52
|
+
name: string;
|
|
53
|
+
matrices: BenchMatrix<any>[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Results for a single variant across all cases */
|
|
57
|
+
export interface VariantResult {
|
|
58
|
+
id: string;
|
|
59
|
+
cases: CaseResult[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Results for a single (variant, case) pair */
|
|
63
|
+
export interface CaseResult {
|
|
64
|
+
caseId: string;
|
|
65
|
+
measured: MeasuredResults;
|
|
66
|
+
metadata?: Record<string, unknown>;
|
|
67
|
+
baseline?: MeasuredResults;
|
|
68
|
+
deltaPercent?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Results from running a matrix */
|
|
72
|
+
export interface MatrixResults {
|
|
73
|
+
name: string;
|
|
74
|
+
variants: VariantResult[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @return true if variant is a StatefulVariant (has setup + run) */
|
|
78
|
+
export function isStatefulVariant<T, S>(
|
|
79
|
+
v: Variant<T, S>,
|
|
80
|
+
): v is StatefulVariant<T, S> {
|
|
81
|
+
return typeof v === "object" && "setup" in v && "run" in v;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Options for runMatrix */
|
|
85
|
+
export interface RunMatrixOptions {
|
|
86
|
+
iterations?: number;
|
|
87
|
+
maxTime?: number;
|
|
88
|
+
warmup?: number;
|
|
89
|
+
useWorker?: boolean; // use worker process isolation (default: true for variantDir)
|
|
90
|
+
filteredCases?: string[]; // run only these cases (from filter)
|
|
91
|
+
filteredVariants?: string[]; // run only these variants (from filter)
|
|
92
|
+
// Runner options passthrough
|
|
93
|
+
collect?: boolean;
|
|
94
|
+
cpuCounters?: boolean;
|
|
95
|
+
traceOpt?: boolean;
|
|
96
|
+
noSettle?: boolean;
|
|
97
|
+
pauseFirst?: number;
|
|
98
|
+
pauseInterval?: number;
|
|
99
|
+
pauseDuration?: number;
|
|
100
|
+
gcStats?: boolean;
|
|
101
|
+
heapSample?: boolean;
|
|
102
|
+
heapInterval?: number;
|
|
103
|
+
heapDepth?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Run a BenchMatrix with inline variants or variantDir */
|
|
107
|
+
export async function runMatrix<T>(
|
|
108
|
+
matrix: BenchMatrix<T>,
|
|
109
|
+
options: RunMatrixOptions = {},
|
|
110
|
+
): Promise<MatrixResults> {
|
|
111
|
+
validateBaseline(matrix);
|
|
112
|
+
const effectiveOptions = { ...matrix.defaults, ...options };
|
|
113
|
+
|
|
114
|
+
if (matrix.variantDir) {
|
|
115
|
+
return runMatrixWithDir(matrix, effectiveOptions);
|
|
116
|
+
}
|
|
117
|
+
if (matrix.variants) {
|
|
118
|
+
return runMatrixInline(matrix, effectiveOptions);
|
|
119
|
+
}
|
|
120
|
+
throw new Error("BenchMatrix requires either 'variants' or 'variantDir'");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @throws if both baselineDir and baselineVariant are set */
|
|
124
|
+
function validateBaseline<T>(matrix: BenchMatrix<T>): void {
|
|
125
|
+
const msg =
|
|
126
|
+
"BenchMatrix cannot have both 'baselineDir' and 'baselineVariant'";
|
|
127
|
+
if (matrix.baselineDir && matrix.baselineVariant) throw new Error(msg);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildRunnerOptions(options: RunMatrixOptions): RunnerOptions {
|
|
131
|
+
return {
|
|
132
|
+
maxIterations: options.iterations,
|
|
133
|
+
maxTime: options.maxTime ?? 1000,
|
|
134
|
+
warmup: options.warmup ?? 0,
|
|
135
|
+
collect: options.collect,
|
|
136
|
+
cpuCounters: options.cpuCounters,
|
|
137
|
+
traceOpt: options.traceOpt,
|
|
138
|
+
noSettle: options.noSettle,
|
|
139
|
+
pauseFirst: options.pauseFirst,
|
|
140
|
+
pauseInterval: options.pauseInterval,
|
|
141
|
+
pauseDuration: options.pauseDuration,
|
|
142
|
+
gcStats: options.gcStats,
|
|
143
|
+
heapSample: options.heapSample,
|
|
144
|
+
heapInterval: options.heapInterval,
|
|
145
|
+
heapDepth: options.heapDepth,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Load cases module and resolve filtered case IDs */
|
|
150
|
+
async function resolveCases<T>(
|
|
151
|
+
matrix: BenchMatrix<T>,
|
|
152
|
+
options: RunMatrixOptions,
|
|
153
|
+
) {
|
|
154
|
+
const casesModule = matrix.casesModule
|
|
155
|
+
? await loadCasesModule<T>(matrix.casesModule)
|
|
156
|
+
: undefined;
|
|
157
|
+
const allCaseIds = casesModule?.cases ?? matrix.cases ?? ["default"];
|
|
158
|
+
const caseIds = options.filteredCases ?? allCaseIds;
|
|
159
|
+
return { casesModule, caseIds };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Run matrix with inline variants (non-worker mode) */
|
|
163
|
+
async function runMatrixInline<T>(
|
|
164
|
+
matrix: BenchMatrix<T>,
|
|
165
|
+
options: RunMatrixOptions,
|
|
166
|
+
): Promise<MatrixResults> {
|
|
167
|
+
// baselineDir is only valid with variantDir
|
|
168
|
+
const msg =
|
|
169
|
+
"BenchMatrix with inline 'variants' cannot use 'baselineDir'. Use 'variantDir' instead.";
|
|
170
|
+
if (matrix.baselineDir) throw new Error(msg);
|
|
171
|
+
|
|
172
|
+
const { casesModule, caseIds } = await resolveCases(matrix, options);
|
|
173
|
+
const runner = new BasicRunner();
|
|
174
|
+
const runnerOpts = buildRunnerOptions(options);
|
|
175
|
+
|
|
176
|
+
const variantEntries = options.filteredVariants
|
|
177
|
+
? Object.entries(matrix.variants!).filter(([id]) =>
|
|
178
|
+
options.filteredVariants!.includes(id),
|
|
179
|
+
)
|
|
180
|
+
: Object.entries(matrix.variants!);
|
|
181
|
+
|
|
182
|
+
const variants: VariantResult[] = [];
|
|
183
|
+
for (const [variantId, variant] of variantEntries) {
|
|
184
|
+
const cases: CaseResult[] = [];
|
|
185
|
+
for (const caseId of caseIds) {
|
|
186
|
+
const loaded = await loadCaseData(casesModule, caseId);
|
|
187
|
+
const caseData =
|
|
188
|
+
casesModule || matrix.cases ? loaded.data : (undefined as T);
|
|
189
|
+
const measured = await runVariant(
|
|
190
|
+
variant,
|
|
191
|
+
caseData,
|
|
192
|
+
variantId,
|
|
193
|
+
runner,
|
|
194
|
+
runnerOpts,
|
|
195
|
+
);
|
|
196
|
+
cases.push({ caseId, measured, metadata: loaded.metadata });
|
|
197
|
+
}
|
|
198
|
+
variants.push({ id: variantId, cases });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (matrix.baselineVariant) {
|
|
202
|
+
applyBaselineVariant(variants, matrix.baselineVariant);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { name: matrix.name, variants };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Context for running matrix benchmarks in worker mode */
|
|
209
|
+
interface DirMatrixContext<T> {
|
|
210
|
+
matrix: BenchMatrix<T>;
|
|
211
|
+
casesModule?: import("./matrix/CaseLoader.ts").CasesModule<T>;
|
|
212
|
+
baselineIds: string[];
|
|
213
|
+
caseIds: string[];
|
|
214
|
+
runnerOpts: RunnerOptions;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Run matrix with variantDir (worker mode for memory isolation) */
|
|
218
|
+
async function runMatrixWithDir<T>(
|
|
219
|
+
matrix: BenchMatrix<T>,
|
|
220
|
+
options: RunMatrixOptions,
|
|
221
|
+
): Promise<MatrixResults> {
|
|
222
|
+
const allVariantIds = await discoverVariants(matrix.variantDir!);
|
|
223
|
+
if (allVariantIds.length === 0) {
|
|
224
|
+
throw new Error(`No variants found in ${matrix.variantDir}`);
|
|
225
|
+
}
|
|
226
|
+
const variantIds = options.filteredVariants ?? allVariantIds;
|
|
227
|
+
|
|
228
|
+
const ctx = await createDirContext(matrix, options);
|
|
229
|
+
const variants = await runDirVariants(variantIds, ctx);
|
|
230
|
+
|
|
231
|
+
if (matrix.baselineVariant) {
|
|
232
|
+
applyBaselineVariant(variants, matrix.baselineVariant);
|
|
233
|
+
}
|
|
234
|
+
return { name: matrix.name, variants };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Create context for directory-based matrix execution */
|
|
238
|
+
async function createDirContext<T>(
|
|
239
|
+
matrix: BenchMatrix<T>,
|
|
240
|
+
options: RunMatrixOptions,
|
|
241
|
+
): Promise<DirMatrixContext<T>> {
|
|
242
|
+
const baselineIds = matrix.baselineDir
|
|
243
|
+
? await discoverVariants(matrix.baselineDir)
|
|
244
|
+
: [];
|
|
245
|
+
const { casesModule, caseIds } = await resolveCases(matrix, options);
|
|
246
|
+
const runnerOpts = buildRunnerOptions(options);
|
|
247
|
+
return { matrix, casesModule, baselineIds, caseIds, runnerOpts };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Run all variants using worker processes */
|
|
251
|
+
async function runDirVariants<T>(
|
|
252
|
+
variantIds: string[],
|
|
253
|
+
ctx: DirMatrixContext<T>,
|
|
254
|
+
): Promise<VariantResult[]> {
|
|
255
|
+
const variants: VariantResult[] = [];
|
|
256
|
+
for (const variantId of variantIds) {
|
|
257
|
+
const cases = await runDirVariantCases(variantId, ctx);
|
|
258
|
+
variants.push({ id: variantId, cases });
|
|
259
|
+
}
|
|
260
|
+
return variants;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Run all cases for a single variant */
|
|
264
|
+
async function runDirVariantCases<T>(
|
|
265
|
+
variantId: string,
|
|
266
|
+
ctx: DirMatrixContext<T>,
|
|
267
|
+
): Promise<CaseResult[]> {
|
|
268
|
+
const { matrix, casesModule, caseIds, runnerOpts } = ctx;
|
|
269
|
+
const cases: CaseResult[] = [];
|
|
270
|
+
|
|
271
|
+
for (const caseId of caseIds) {
|
|
272
|
+
const caseData = !matrix.casesModule && matrix.cases ? caseId : undefined;
|
|
273
|
+
const [measured] = await runMatrixVariant({
|
|
274
|
+
variantDir: matrix.variantDir!,
|
|
275
|
+
variantId,
|
|
276
|
+
caseId,
|
|
277
|
+
caseData,
|
|
278
|
+
casesModule: matrix.casesModule,
|
|
279
|
+
runner: "basic",
|
|
280
|
+
options: runnerOpts,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const loaded = await loadCaseData(casesModule, caseId);
|
|
284
|
+
const baseline = await runBaselineIfExists(
|
|
285
|
+
variantId,
|
|
286
|
+
caseId,
|
|
287
|
+
caseData,
|
|
288
|
+
ctx,
|
|
289
|
+
);
|
|
290
|
+
const deltaPercent = baseline
|
|
291
|
+
? computeDeltaPercent(baseline, measured)
|
|
292
|
+
: undefined;
|
|
293
|
+
const metadata = loaded.metadata;
|
|
294
|
+
cases.push({ caseId, measured, metadata, baseline, deltaPercent });
|
|
295
|
+
}
|
|
296
|
+
return cases;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Run baseline variant if it exists in baselineDir */
|
|
300
|
+
async function runBaselineIfExists<T>(
|
|
301
|
+
variantId: string,
|
|
302
|
+
caseId: string,
|
|
303
|
+
caseData: unknown,
|
|
304
|
+
ctx: DirMatrixContext<T>,
|
|
305
|
+
): Promise<MeasuredResults | undefined> {
|
|
306
|
+
const { matrix, baselineIds, runnerOpts } = ctx;
|
|
307
|
+
if (!matrix.baselineDir || !baselineIds.includes(variantId)) return undefined;
|
|
308
|
+
|
|
309
|
+
const [measured] = await runMatrixVariant({
|
|
310
|
+
variantDir: matrix.baselineDir,
|
|
311
|
+
variantId,
|
|
312
|
+
caseId,
|
|
313
|
+
caseData,
|
|
314
|
+
casesModule: matrix.casesModule,
|
|
315
|
+
runner: "basic",
|
|
316
|
+
options: runnerOpts,
|
|
317
|
+
});
|
|
318
|
+
return measured;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Compute delta percentage: (current - baseline) / baseline * 100 */
|
|
322
|
+
function computeDeltaPercent(
|
|
323
|
+
baseline: MeasuredResults,
|
|
324
|
+
current: MeasuredResults,
|
|
325
|
+
): number {
|
|
326
|
+
const baseAvg = average(baseline.samples);
|
|
327
|
+
if (baseAvg === 0) return 0;
|
|
328
|
+
return ((average(current.samples) - baseAvg) / baseAvg) * 100;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Apply baselineVariant comparison - one variant is the reference for all others */
|
|
332
|
+
function applyBaselineVariant(
|
|
333
|
+
variants: VariantResult[],
|
|
334
|
+
baselineVariantId: string,
|
|
335
|
+
): void {
|
|
336
|
+
const baselineVariant = variants.find(v => v.id === baselineVariantId);
|
|
337
|
+
if (!baselineVariant) return;
|
|
338
|
+
|
|
339
|
+
const baselineByCase = new Map<string, MeasuredResults>();
|
|
340
|
+
for (const c of baselineVariant.cases) {
|
|
341
|
+
baselineByCase.set(c.caseId, c.measured);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (const variant of variants) {
|
|
345
|
+
if (variant.id === baselineVariantId) continue;
|
|
346
|
+
for (const caseResult of variant.cases) {
|
|
347
|
+
const baseline = baselineByCase.get(caseResult.caseId);
|
|
348
|
+
if (baseline) {
|
|
349
|
+
caseResult.baseline = baseline;
|
|
350
|
+
caseResult.deltaPercent = computeDeltaPercent(
|
|
351
|
+
baseline,
|
|
352
|
+
caseResult.measured,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Run a single variant with case data */
|
|
360
|
+
async function runVariant<T>(
|
|
361
|
+
variant: AnyVariant<T>,
|
|
362
|
+
caseData: T,
|
|
363
|
+
name: string,
|
|
364
|
+
runner: BasicRunner,
|
|
365
|
+
options: RunnerOptions,
|
|
366
|
+
): Promise<MeasuredResults> {
|
|
367
|
+
if (isStatefulVariant(variant)) {
|
|
368
|
+
const state = await variant.setup(caseData);
|
|
369
|
+
const [result] = await runner.runBench(
|
|
370
|
+
{ name, fn: () => variant.run(state) },
|
|
371
|
+
options,
|
|
372
|
+
);
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
const [result] = await runner.runBench(
|
|
376
|
+
{ name, fn: () => variant(caseData) },
|
|
377
|
+
options,
|
|
378
|
+
);
|
|
379
|
+
return result;
|
|
380
|
+
}
|
package/src/Benchmark.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Single benchmark function specification */
|
|
2
|
+
export interface BenchmarkSpec<T = unknown> {
|
|
3
|
+
name: string;
|
|
4
|
+
fn: BenchmarkFunction<T>;
|
|
5
|
+
/** Path to module exporting the benchmark function (for worker mode) */
|
|
6
|
+
modulePath?: string;
|
|
7
|
+
/** Name of the exported function in the module (defaults to default export) */
|
|
8
|
+
exportName?: string;
|
|
9
|
+
/** Setup function export name - called once in worker, result passed to fn */
|
|
10
|
+
setupExportName?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type BenchmarkFunction<T = unknown> =
|
|
14
|
+
| ((params: T) => void)
|
|
15
|
+
| (() => void);
|
|
16
|
+
|
|
17
|
+
/** Group of benchmarks with shared setup */
|
|
18
|
+
export interface BenchGroup<T = unknown> {
|
|
19
|
+
name: string;
|
|
20
|
+
/** Prepare parameters for all benchmarks in this group */
|
|
21
|
+
setup?: () => T | Promise<T>;
|
|
22
|
+
benchmarks: BenchmarkSpec<T>[];
|
|
23
|
+
/** Baseline benchmark for comparison */
|
|
24
|
+
baseline?: BenchmarkSpec<T>;
|
|
25
|
+
/** Metadata for reporting (e.g., lines of code) */
|
|
26
|
+
metadata?: Record<string, any>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Collection of benchmark groups */
|
|
30
|
+
export interface BenchSuite {
|
|
31
|
+
name: string;
|
|
32
|
+
groups: BenchGroup<any>[];
|
|
33
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { MeasuredResults } from "./MeasuredResults.ts";
|
|
2
|
+
import { bootstrapDifferenceCI } from "./StatisticalUtils.ts";
|
|
3
|
+
import type { UnionToIntersection } from "./TypeUtil.ts";
|
|
4
|
+
import {
|
|
5
|
+
formatDiffWithCI,
|
|
6
|
+
formatDiffWithCIHigherIsBetter,
|
|
7
|
+
truncate,
|
|
8
|
+
} from "./table-util/Formatters.ts";
|
|
9
|
+
import {
|
|
10
|
+
type AnyColumn,
|
|
11
|
+
buildTable,
|
|
12
|
+
type ColumnGroup,
|
|
13
|
+
type ResultGroup,
|
|
14
|
+
} from "./table-util/TableReport.ts";
|
|
15
|
+
|
|
16
|
+
/** Benchmark results with optional baseline for comparison */
|
|
17
|
+
export interface ReportGroup {
|
|
18
|
+
name: string;
|
|
19
|
+
reports: BenchmarkReport[];
|
|
20
|
+
baseline?: BenchmarkReport;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Results from a single benchmark run */
|
|
24
|
+
export interface BenchmarkReport {
|
|
25
|
+
name: string;
|
|
26
|
+
measuredResults: MeasuredResults;
|
|
27
|
+
metadata?: UnknownRecord;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReportColumnGroup<T> {
|
|
31
|
+
groupTitle?: string;
|
|
32
|
+
columns: ReportColumn<T>[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ReportColumn<T> = AnyColumn<T> & {
|
|
36
|
+
/** Add diff column after this column when baseline exists */
|
|
37
|
+
comparable?: boolean;
|
|
38
|
+
/** Set true for throughput metrics where higher values are better (e.g., lines/sec) */
|
|
39
|
+
higherIsBetter?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Maps benchmark results to table columns */
|
|
43
|
+
export interface ResultsMapper<
|
|
44
|
+
T extends Record<string, any> = Record<string, any>,
|
|
45
|
+
> {
|
|
46
|
+
extract(results: MeasuredResults, metadata?: UnknownRecord): T;
|
|
47
|
+
columns(): ReportColumnGroup<T>[];
|
|
48
|
+
}
|
|
49
|
+
export type UnknownRecord = Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
type SectionStats<S> = S extends ResultsMapper<infer T> ? T : never;
|
|
52
|
+
|
|
53
|
+
interface ReportRowBase {
|
|
54
|
+
name: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Row data combining all section statistics */
|
|
58
|
+
type ReportRowData<S extends ReadonlyArray<ResultsMapper<any>>> =
|
|
59
|
+
ReportRowBase & UnionToIntersection<SectionStats<S[number]>>;
|
|
60
|
+
|
|
61
|
+
/** @return formatted table report with optional baseline comparisons */
|
|
62
|
+
export function reportResults<S extends ReadonlyArray<ResultsMapper<any>>>(
|
|
63
|
+
groups: ReportGroup[],
|
|
64
|
+
sections: S,
|
|
65
|
+
): string {
|
|
66
|
+
const results = groups.map(group => resultGroupValues(group, sections));
|
|
67
|
+
const hasBaseline = results.some(g => g.baseline);
|
|
68
|
+
return buildTable(createColumnGroups(sections, hasBaseline), results);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @return values for report group */
|
|
72
|
+
function resultGroupValues<S extends ReadonlyArray<ResultsMapper<any>>>(
|
|
73
|
+
group: ReportGroup,
|
|
74
|
+
sections: S,
|
|
75
|
+
): ResultGroup<ReportRowData<S>> {
|
|
76
|
+
const { reports, baseline } = group;
|
|
77
|
+
const baselineSamples = baseline?.measuredResults.samples;
|
|
78
|
+
|
|
79
|
+
const results = reports.map(report => {
|
|
80
|
+
const row = {
|
|
81
|
+
name: truncate(report.name),
|
|
82
|
+
...extractReportValues(report, sections),
|
|
83
|
+
} as ReportRowData<S>;
|
|
84
|
+
|
|
85
|
+
if (baselineSamples && report.measuredResults.samples) {
|
|
86
|
+
(row as any).diffCI = bootstrapDifferenceCI(
|
|
87
|
+
baselineSamples,
|
|
88
|
+
report.measuredResults.samples,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return row;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const baselineRow = baseline && valuesForReports([baseline], sections)[0];
|
|
95
|
+
return { results, baseline: baselineRow };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @return rows with stats from sections */
|
|
99
|
+
export function valuesForReports<S extends ReadonlyArray<ResultsMapper<any>>>(
|
|
100
|
+
reports: BenchmarkReport[],
|
|
101
|
+
sections: S,
|
|
102
|
+
): ReportRowData<S>[] {
|
|
103
|
+
return reports.map(report => ({
|
|
104
|
+
name: truncate(report.name),
|
|
105
|
+
...extractReportValues(report, sections),
|
|
106
|
+
})) as ReportRowData<S>[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @return merged statistics from all sections */
|
|
110
|
+
function extractReportValues(
|
|
111
|
+
report: BenchmarkReport,
|
|
112
|
+
sections: ReadonlyArray<ResultsMapper<any>>,
|
|
113
|
+
): UnknownRecord {
|
|
114
|
+
const { measuredResults, metadata } = report;
|
|
115
|
+
const entries = sections.flatMap(s =>
|
|
116
|
+
Object.entries(s.extract(measuredResults, metadata)),
|
|
117
|
+
);
|
|
118
|
+
return Object.fromEntries(entries);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @return column groups with diff columns if baseline exists */
|
|
122
|
+
function createColumnGroups<S extends ReadonlyArray<ResultsMapper<any>>>(
|
|
123
|
+
sections: S,
|
|
124
|
+
hasBaseline: boolean,
|
|
125
|
+
): ColumnGroup<ReportRowData<S>>[] {
|
|
126
|
+
const nameColumn: ColumnGroup<ReportRowData<S>> = {
|
|
127
|
+
columns: [{ key: "name" as keyof ReportRowData<S>, title: "name" }],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const groups = sections.flatMap(section => section.columns());
|
|
131
|
+
return [nameColumn, ...(hasBaseline ? injectDiffColumns(groups) : groups)];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @return groups with single CI column after first comparable field */
|
|
135
|
+
export function injectDiffColumns<T>(
|
|
136
|
+
reportGroups: ReportColumnGroup<T>[],
|
|
137
|
+
): ColumnGroup<T>[] {
|
|
138
|
+
let ciAdded = false;
|
|
139
|
+
|
|
140
|
+
return reportGroups.map(group => ({
|
|
141
|
+
groupTitle: group.groupTitle,
|
|
142
|
+
columns: group.columns.flatMap(col => {
|
|
143
|
+
if (col.comparable && !ciAdded) {
|
|
144
|
+
ciAdded = true;
|
|
145
|
+
const fmt = col.higherIsBetter
|
|
146
|
+
? formatDiffWithCIHigherIsBetter
|
|
147
|
+
: formatDiffWithCI;
|
|
148
|
+
return [
|
|
149
|
+
col,
|
|
150
|
+
{ title: "Δ% CI", key: "diffCI" as keyof T, formatter: fmt },
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
return [col];
|
|
154
|
+
}),
|
|
155
|
+
}));
|
|
156
|
+
}
|
package/src/GitUtils.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { GitVersion } from "./html/index.ts";
|
|
5
|
+
import { formatDateWithTimezone } from "./html/index.ts";
|
|
6
|
+
|
|
7
|
+
export type { GitVersion } from "./html/index.ts";
|
|
8
|
+
export { formatDateWithTimezone, formatRelativeTime } from "./html/index.ts";
|
|
9
|
+
|
|
10
|
+
/** Get current git version info. For dirty repos, uses most recent modified file date. */
|
|
11
|
+
export function getCurrentGitVersion(): GitVersion | undefined {
|
|
12
|
+
try {
|
|
13
|
+
const exec = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
|
|
14
|
+
const hash = exec("git rev-parse --short HEAD");
|
|
15
|
+
const commitDate = exec("git log -1 --format=%aI");
|
|
16
|
+
const dirty = exec("git status --porcelain").length > 0;
|
|
17
|
+
|
|
18
|
+
const date = dirty
|
|
19
|
+
? (getMostRecentModifiedDate(".") ?? commitDate)
|
|
20
|
+
: commitDate;
|
|
21
|
+
return { hash, date, dirty };
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read baseline version from .baseline-version file */
|
|
28
|
+
export function getBaselineVersion(
|
|
29
|
+
baselineDir = "_baseline",
|
|
30
|
+
): GitVersion | undefined {
|
|
31
|
+
const versionFile = join(baselineDir, ".baseline-version");
|
|
32
|
+
if (!existsSync(versionFile)) return undefined;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const content = readFileSync(versionFile, "utf-8");
|
|
36
|
+
const data = JSON.parse(content);
|
|
37
|
+
return { hash: data.hash, date: data.date };
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Format git version for display: "abc1234 (Jan 9, 2026, 3:45 PM)" or "abc1234*" if dirty */
|
|
44
|
+
export function formatGitVersion(version: GitVersion): string {
|
|
45
|
+
const hashDisplay = version.dirty ? `${version.hash}*` : version.hash;
|
|
46
|
+
const dateDisplay = formatDateWithTimezone(version.date);
|
|
47
|
+
return `${hashDisplay} (${dateDisplay})`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Get most recent modified file date in a directory (for dirty repos) */
|
|
51
|
+
export function getMostRecentModifiedDate(dir: string): string | undefined {
|
|
52
|
+
try {
|
|
53
|
+
const raw = execSync("git status --porcelain", {
|
|
54
|
+
encoding: "utf-8",
|
|
55
|
+
cwd: dir,
|
|
56
|
+
});
|
|
57
|
+
const modifiedFiles = raw
|
|
58
|
+
.trim()
|
|
59
|
+
.split("\n")
|
|
60
|
+
.filter(line => line.length > 0)
|
|
61
|
+
.map(line => line.slice(3));
|
|
62
|
+
|
|
63
|
+
if (modifiedFiles.length === 0) return undefined;
|
|
64
|
+
|
|
65
|
+
let mostRecent = 0;
|
|
66
|
+
for (const file of modifiedFiles) {
|
|
67
|
+
try {
|
|
68
|
+
const filePath = join(dir, file);
|
|
69
|
+
if (!existsSync(filePath)) continue;
|
|
70
|
+
const mtime = statSync(filePath).mtimeMs;
|
|
71
|
+
if (mtime > mostRecent) mostRecent = mtime;
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return mostRecent > 0 ? new Date(mostRecent).toISOString() : undefined;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|