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
|
@@ -0,0 +1,3676 @@
|
|
|
1
|
+
import fs, { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { getHeapStatistics } from "node:v8";
|
|
4
|
+
import { execSync, fork, spawn } from "node:child_process";
|
|
5
|
+
import path, { dirname, extname, join, resolve } from "node:path";
|
|
6
|
+
import pico from "picocolors";
|
|
7
|
+
import { table } from "table";
|
|
8
|
+
import yargs from "yargs";
|
|
9
|
+
import { hideBin } from "yargs/helpers";
|
|
10
|
+
import { chromium } from "playwright";
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import open from "open";
|
|
14
|
+
|
|
15
|
+
//#region src/matrix/CaseLoader.ts
|
|
16
|
+
/** Load a cases module by URL */
|
|
17
|
+
async function loadCasesModule(moduleUrl) {
|
|
18
|
+
const module = await import(moduleUrl);
|
|
19
|
+
if (!Array.isArray(module.cases)) throw new Error(`Cases module at ${moduleUrl} must export 'cases' array`);
|
|
20
|
+
return {
|
|
21
|
+
cases: module.cases,
|
|
22
|
+
defaultCases: module.defaultCases,
|
|
23
|
+
defaultVariants: module.defaultVariants,
|
|
24
|
+
loadCase: module.loadCase
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Load case data from a CasesModule or pass through the caseId */
|
|
28
|
+
async function loadCaseData(casesModule, caseId) {
|
|
29
|
+
if (casesModule?.loadCase) return casesModule.loadCase(caseId);
|
|
30
|
+
return { data: caseId };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/matrix/VariantLoader.ts
|
|
35
|
+
/** Discover variant ids from a directory of .ts files */
|
|
36
|
+
async function discoverVariants(dirUrl) {
|
|
37
|
+
const dirPath = fileURLToPath(dirUrl);
|
|
38
|
+
return (await fs.readdir(dirPath, { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith(".ts")).map((e) => e.name.slice(0, -3)).sort();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/StatisticalUtils.ts
|
|
43
|
+
const bootstrapSamples = 1e4;
|
|
44
|
+
const confidence = .95;
|
|
45
|
+
/** @return relative standard deviation (coefficient of variation) */
|
|
46
|
+
function coefficientOfVariation(samples) {
|
|
47
|
+
const mean = average(samples);
|
|
48
|
+
if (mean === 0) return 0;
|
|
49
|
+
return standardDeviation(samples) / mean;
|
|
50
|
+
}
|
|
51
|
+
/** @return median absolute deviation for robust variability measure */
|
|
52
|
+
function medianAbsoluteDeviation(samples) {
|
|
53
|
+
const median = percentile$1(samples, .5);
|
|
54
|
+
return percentile$1(samples.map((x) => Math.abs(x - median)), .5);
|
|
55
|
+
}
|
|
56
|
+
/** @return mean of values */
|
|
57
|
+
function average(values) {
|
|
58
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
59
|
+
}
|
|
60
|
+
/** @return standard deviation with Bessel's correction */
|
|
61
|
+
function standardDeviation(samples) {
|
|
62
|
+
if (samples.length <= 1) return 0;
|
|
63
|
+
const mean = average(samples);
|
|
64
|
+
const variance = samples.reduce((sum, x) => sum + (x - mean) ** 2, 0) / (samples.length - 1);
|
|
65
|
+
return Math.sqrt(variance);
|
|
66
|
+
}
|
|
67
|
+
/** @return value at percentile p (0-1) */
|
|
68
|
+
function percentile$1(values, p) {
|
|
69
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
70
|
+
const index = Math.ceil(sorted.length * p) - 1;
|
|
71
|
+
return sorted[Math.max(0, index)];
|
|
72
|
+
}
|
|
73
|
+
/** @return bootstrap resample with replacement */
|
|
74
|
+
function createResample(samples) {
|
|
75
|
+
const n = samples.length;
|
|
76
|
+
const rand = () => samples[Math.floor(Math.random() * n)];
|
|
77
|
+
return Array.from({ length: n }, rand);
|
|
78
|
+
}
|
|
79
|
+
/** @return confidence interval [lower, upper] */
|
|
80
|
+
function computeInterval(medians, confidence) {
|
|
81
|
+
const alpha = (1 - confidence) / 2;
|
|
82
|
+
return [percentile$1(medians, alpha), percentile$1(medians, 1 - alpha)];
|
|
83
|
+
}
|
|
84
|
+
/** Bin values into histogram for compact visualization */
|
|
85
|
+
function binValues(values, binCount = 30) {
|
|
86
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
87
|
+
const min = sorted[0];
|
|
88
|
+
const max = sorted[sorted.length - 1];
|
|
89
|
+
if (min === max) return [{
|
|
90
|
+
x: min,
|
|
91
|
+
count: values.length
|
|
92
|
+
}];
|
|
93
|
+
const step = (max - min) / binCount;
|
|
94
|
+
const counts = new Array(binCount).fill(0);
|
|
95
|
+
for (const v of values) {
|
|
96
|
+
const bin = Math.min(Math.floor((v - min) / step), binCount - 1);
|
|
97
|
+
counts[bin]++;
|
|
98
|
+
}
|
|
99
|
+
return counts.map((count, i) => ({
|
|
100
|
+
x: min + (i + .5) * step,
|
|
101
|
+
count
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
/** @return bootstrap CI for percentage difference between baseline and current medians */
|
|
105
|
+
function bootstrapDifferenceCI(baseline, current, options = {}) {
|
|
106
|
+
const { resamples = bootstrapSamples, confidence: conf = confidence } = options;
|
|
107
|
+
const baselineMedian = percentile$1(baseline, .5);
|
|
108
|
+
const observedPercent = (percentile$1(current, .5) - baselineMedian) / baselineMedian * 100;
|
|
109
|
+
const diffs = [];
|
|
110
|
+
for (let i = 0; i < resamples; i++) {
|
|
111
|
+
const resB = createResample(baseline);
|
|
112
|
+
const resC = createResample(current);
|
|
113
|
+
const medB = percentile$1(resB, .5);
|
|
114
|
+
const medC = percentile$1(resC, .5);
|
|
115
|
+
diffs.push((medC - medB) / medB * 100);
|
|
116
|
+
}
|
|
117
|
+
const ci = computeInterval(diffs, conf);
|
|
118
|
+
const excludesZero = ci[0] > 0 || ci[1] < 0;
|
|
119
|
+
let direction = "uncertain";
|
|
120
|
+
if (excludesZero) direction = observedPercent < 0 ? "faster" : "slower";
|
|
121
|
+
const histogram = binValues(diffs);
|
|
122
|
+
return {
|
|
123
|
+
percent: observedPercent,
|
|
124
|
+
ci,
|
|
125
|
+
direction,
|
|
126
|
+
histogram
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/runners/RunnerUtils.ts
|
|
132
|
+
const msToNs = 1e6;
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/runners/AdaptiveWrapper.ts
|
|
136
|
+
const minTime = 1e3;
|
|
137
|
+
const maxTime = 1e4;
|
|
138
|
+
const targetConfidence = 95;
|
|
139
|
+
const fallbackThreshold = 80;
|
|
140
|
+
const windowSize = 50;
|
|
141
|
+
const stability = .05;
|
|
142
|
+
const initialBatch = 100;
|
|
143
|
+
const continueBatch = 100;
|
|
144
|
+
const continueIterations = 10;
|
|
145
|
+
/** @return adaptive sampling runner wrapper */
|
|
146
|
+
function createAdaptiveWrapper(baseRunner, options) {
|
|
147
|
+
return { async runBench(benchmark, runnerOptions, params) {
|
|
148
|
+
return runAdaptiveBench(baseRunner, benchmark, runnerOptions, options, params);
|
|
149
|
+
} };
|
|
150
|
+
}
|
|
151
|
+
/** @return results using adaptive sampling strategy */
|
|
152
|
+
async function runAdaptiveBench(baseRunner, benchmark, runnerOptions, options, params) {
|
|
153
|
+
const { minTime: min = options.minTime ?? minTime, maxTime: max = options.maxTime ?? maxTime, targetConfidence: target = options.convergence ?? targetConfidence } = runnerOptions;
|
|
154
|
+
const allSamples = [];
|
|
155
|
+
const warmup = await collectInitial(baseRunner, benchmark, runnerOptions, params, allSamples);
|
|
156
|
+
const startTime = performance.now();
|
|
157
|
+
await collectAdaptive(baseRunner, benchmark, runnerOptions, params, allSamples, {
|
|
158
|
+
minTime: min,
|
|
159
|
+
maxTime: max,
|
|
160
|
+
targetConfidence: target,
|
|
161
|
+
startTime
|
|
162
|
+
});
|
|
163
|
+
return buildResults(allSamples, startTime, checkConvergence(allSamples.map((s) => s * msToNs)), benchmark.name, warmup);
|
|
164
|
+
}
|
|
165
|
+
/** @return warmupSamples from initial batch */
|
|
166
|
+
async function collectInitial(baseRunner, benchmark, runnerOptions, params, allSamples) {
|
|
167
|
+
const opts = {
|
|
168
|
+
...runnerOptions,
|
|
169
|
+
maxTime: initialBatch,
|
|
170
|
+
maxIterations: void 0
|
|
171
|
+
};
|
|
172
|
+
const results = await baseRunner.runBench(benchmark, opts, params);
|
|
173
|
+
appendSamples(results[0], allSamples);
|
|
174
|
+
return results[0].warmupSamples;
|
|
175
|
+
}
|
|
176
|
+
/** @return samples until convergence or timeout */
|
|
177
|
+
async function collectAdaptive(baseRunner, benchmark, runnerOptions, params, allSamples, limits) {
|
|
178
|
+
const { minTime, maxTime, targetConfidence, startTime } = limits;
|
|
179
|
+
let lastLog = 0;
|
|
180
|
+
while (performance.now() - startTime < maxTime) {
|
|
181
|
+
const convergence = checkConvergence(allSamples.map((s) => s * msToNs));
|
|
182
|
+
const elapsed = performance.now() - startTime;
|
|
183
|
+
if (elapsed - lastLog > 1e3) {
|
|
184
|
+
const elapsedSec = (elapsed / 1e3).toFixed(1);
|
|
185
|
+
const conf = convergence.confidence.toFixed(0);
|
|
186
|
+
process.stderr.write(`\r◊ ${benchmark.name}: ${conf}% confident (${elapsedSec}s) `);
|
|
187
|
+
lastLog = elapsed;
|
|
188
|
+
}
|
|
189
|
+
if (shouldStop(convergence, targetConfidence, elapsed, minTime)) break;
|
|
190
|
+
const opts = {
|
|
191
|
+
...runnerOptions,
|
|
192
|
+
maxTime: continueBatch,
|
|
193
|
+
maxIterations: continueIterations,
|
|
194
|
+
skipWarmup: true
|
|
195
|
+
};
|
|
196
|
+
appendSamples((await baseRunner.runBench(benchmark, opts, params))[0], allSamples);
|
|
197
|
+
}
|
|
198
|
+
process.stderr.write("\r" + " ".repeat(60) + "\r");
|
|
199
|
+
}
|
|
200
|
+
/** Append samples one-by-one to avoid stack overflow from spread on large arrays */
|
|
201
|
+
function appendSamples(result, samples) {
|
|
202
|
+
if (!result.samples?.length) return;
|
|
203
|
+
for (const sample of result.samples) samples.push(sample);
|
|
204
|
+
}
|
|
205
|
+
/** @return true if convergence reached or timeout */
|
|
206
|
+
function shouldStop(convergence, targetConfidence, elapsedTime, minTime) {
|
|
207
|
+
if (convergence.converged && convergence.confidence >= targetConfidence) return true;
|
|
208
|
+
const threshold = Math.max(targetConfidence, fallbackThreshold);
|
|
209
|
+
return elapsedTime >= minTime && convergence.confidence >= threshold;
|
|
210
|
+
}
|
|
211
|
+
/** @return measured results with convergence metrics */
|
|
212
|
+
function buildResults(samplesMs, startTime, convergence, name, warmupSamples) {
|
|
213
|
+
const totalTime = (performance.now() - startTime) / 1e3;
|
|
214
|
+
return [{
|
|
215
|
+
name,
|
|
216
|
+
samples: samplesMs,
|
|
217
|
+
warmupSamples,
|
|
218
|
+
time: computeTimeStats(samplesMs.map((s) => s * msToNs)),
|
|
219
|
+
totalTime,
|
|
220
|
+
convergence
|
|
221
|
+
}];
|
|
222
|
+
}
|
|
223
|
+
/** @return time percentiles and statistics in ms */
|
|
224
|
+
function computeTimeStats(samplesNs) {
|
|
225
|
+
const samplesMs = samplesNs.map((s) => s / msToNs);
|
|
226
|
+
const { min, max, sum } = getMinMaxSum(samplesNs);
|
|
227
|
+
const percentiles = getPercentiles(samplesNs);
|
|
228
|
+
const robust = getRobustMetrics(samplesMs);
|
|
229
|
+
return {
|
|
230
|
+
min: min / msToNs,
|
|
231
|
+
max: max / msToNs,
|
|
232
|
+
avg: sum / samplesNs.length / msToNs,
|
|
233
|
+
...percentiles,
|
|
234
|
+
...robust
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** @return min, max, sum of samples */
|
|
238
|
+
function getMinMaxSum(samples) {
|
|
239
|
+
return {
|
|
240
|
+
min: samples.reduce((a, b) => Math.min(a, b), Number.POSITIVE_INFINITY),
|
|
241
|
+
max: samples.reduce((a, b) => Math.max(a, b), Number.NEGATIVE_INFINITY),
|
|
242
|
+
sum: samples.reduce((a, b) => a + b, 0)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** @return percentiles in ms */
|
|
246
|
+
function getPercentiles(samples) {
|
|
247
|
+
return {
|
|
248
|
+
p25: percentile$1(samples, .25) / msToNs,
|
|
249
|
+
p50: percentile$1(samples, .5) / msToNs,
|
|
250
|
+
p75: percentile$1(samples, .75) / msToNs,
|
|
251
|
+
p95: percentile$1(samples, .95) / msToNs,
|
|
252
|
+
p99: percentile$1(samples, .99) / msToNs,
|
|
253
|
+
p999: percentile$1(samples, .999) / msToNs
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/** @return robust variability metrics */
|
|
257
|
+
function getRobustMetrics(samplesMs) {
|
|
258
|
+
const impact = getOutlierImpact(samplesMs);
|
|
259
|
+
return {
|
|
260
|
+
cv: coefficientOfVariation(samplesMs),
|
|
261
|
+
mad: medianAbsoluteDeviation(samplesMs),
|
|
262
|
+
outlierRate: impact.ratio
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/** @return outlier impact as proportion of total time */
|
|
266
|
+
function getOutlierImpact(samples) {
|
|
267
|
+
if (samples.length === 0) return {
|
|
268
|
+
ratio: 0,
|
|
269
|
+
count: 0
|
|
270
|
+
};
|
|
271
|
+
const median = percentile$1(samples, .5);
|
|
272
|
+
const threshold = median + 1.5 * (percentile$1(samples, .75) - median);
|
|
273
|
+
let excessTime = 0;
|
|
274
|
+
let count = 0;
|
|
275
|
+
for (const sample of samples) if (sample > threshold) {
|
|
276
|
+
excessTime += sample - median;
|
|
277
|
+
count++;
|
|
278
|
+
}
|
|
279
|
+
const totalTime = samples.reduce((a, b) => a + b, 0);
|
|
280
|
+
return {
|
|
281
|
+
ratio: totalTime > 0 ? excessTime / totalTime : 0,
|
|
282
|
+
count
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/** @return convergence based on window stability */
|
|
286
|
+
function checkConvergence(samples) {
|
|
287
|
+
const windowSize = getWindowSize(samples);
|
|
288
|
+
const minSamples = windowSize * 2;
|
|
289
|
+
if (samples.length < minSamples) return buildProgressResult(samples.length, minSamples);
|
|
290
|
+
return buildConvergence(getStability(samples, windowSize));
|
|
291
|
+
}
|
|
292
|
+
/** @return progress when samples insufficient */
|
|
293
|
+
function buildProgressResult(currentSamples, minSamples) {
|
|
294
|
+
return {
|
|
295
|
+
converged: false,
|
|
296
|
+
confidence: currentSamples / minSamples * 100,
|
|
297
|
+
reason: `Collecting samples: ${currentSamples}/${minSamples}`
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** @return stability metrics between windows */
|
|
301
|
+
function getStability(samples, windowSize) {
|
|
302
|
+
const recent = samples.slice(-windowSize);
|
|
303
|
+
const previous = samples.slice(-windowSize * 2, -windowSize);
|
|
304
|
+
const recentMs = recent.map((s) => s / msToNs);
|
|
305
|
+
const previousMs = previous.map((s) => s / msToNs);
|
|
306
|
+
const medianRecent = percentile$1(recentMs, .5);
|
|
307
|
+
const medianPrevious = percentile$1(previousMs, .5);
|
|
308
|
+
const medianDrift = Math.abs(medianRecent - medianPrevious) / medianPrevious;
|
|
309
|
+
const impactRecent = getOutlierImpact(recentMs);
|
|
310
|
+
const impactPrevious = getOutlierImpact(previousMs);
|
|
311
|
+
const impactDrift = Math.abs(impactRecent.ratio - impactPrevious.ratio);
|
|
312
|
+
return {
|
|
313
|
+
medianDrift,
|
|
314
|
+
impactDrift,
|
|
315
|
+
medianStable: medianDrift < stability,
|
|
316
|
+
impactStable: impactDrift < stability
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** @return convergence from stability metrics */
|
|
320
|
+
function buildConvergence(metrics) {
|
|
321
|
+
const { medianDrift, impactDrift, medianStable, impactStable } = metrics;
|
|
322
|
+
if (medianStable && impactStable) return {
|
|
323
|
+
converged: true,
|
|
324
|
+
confidence: 100,
|
|
325
|
+
reason: "Stable performance pattern"
|
|
326
|
+
};
|
|
327
|
+
const confidence = Math.min(100, (1 - medianDrift / stability) * 50 + (1 - impactDrift / stability) * 50);
|
|
328
|
+
const reason = medianDrift > impactDrift ? `Median drifting: ${(medianDrift * 100).toFixed(1)}%` : `Outlier impact changing: ${(impactDrift * 100).toFixed(1)}%`;
|
|
329
|
+
return {
|
|
330
|
+
converged: false,
|
|
331
|
+
confidence: Math.max(0, confidence),
|
|
332
|
+
reason
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** @return window size scaled to execution time */
|
|
336
|
+
function getWindowSize(samples) {
|
|
337
|
+
if (samples.length < 20) return windowSize;
|
|
338
|
+
const recentMedian = percentile$1(samples.slice(-20).map((s) => s / msToNs), .5);
|
|
339
|
+
if (recentMedian < .01) return 200;
|
|
340
|
+
if (recentMedian < .1) return 100;
|
|
341
|
+
if (recentMedian < 1) return 50;
|
|
342
|
+
if (recentMedian < 10) return 30;
|
|
343
|
+
return 20;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region src/runners/BenchRunner.ts
|
|
348
|
+
/** Execute benchmark with optional parameters */
|
|
349
|
+
function executeBenchmark(benchmark, params) {
|
|
350
|
+
benchmark.fn(params);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/runners/BasicRunner.ts
|
|
355
|
+
/**
|
|
356
|
+
* Wait time after gc() for V8 to stabilize (ms).
|
|
357
|
+
*
|
|
358
|
+
* V8 has 4 compilation tiers: Ignition (interpreter) -> Sparkplug (baseline) ->
|
|
359
|
+
* Maglev (mid-tier optimizer) -> TurboFan (full optimizer). Tiering thresholds:
|
|
360
|
+
* - Ignition -> Sparkplug: 8 invocations
|
|
361
|
+
* - Sparkplug -> Maglev: 500 invocations
|
|
362
|
+
* - Maglev -> TurboFan: 6000 invocations
|
|
363
|
+
*
|
|
364
|
+
* Optimization compilation happens on background threads and requires idle time
|
|
365
|
+
* on the main thread to complete. Without sufficient warmup + settle time,
|
|
366
|
+
* benchmarks exhibit bimodal timing: slow Sparkplug samples (~30% slower) mixed
|
|
367
|
+
* with fast optimized samples.
|
|
368
|
+
*
|
|
369
|
+
* The warmup iterations trigger the optimization decision, then gcSettleTime
|
|
370
|
+
* provides idle time for background compilation to finish before measurement.
|
|
371
|
+
*
|
|
372
|
+
* @see https://v8.dev/blog/sparkplug
|
|
373
|
+
* @see https://v8.dev/blog/maglev
|
|
374
|
+
* @see https://v8.dev/blog/background-compilation
|
|
375
|
+
*/
|
|
376
|
+
const gcSettleTime = 1e3;
|
|
377
|
+
/** @return runner with time and iteration limits */
|
|
378
|
+
var BasicRunner = class {
|
|
379
|
+
async runBench(benchmark, options, params) {
|
|
380
|
+
const collected = await collectSamples({
|
|
381
|
+
benchmark,
|
|
382
|
+
params,
|
|
383
|
+
...defaultCollectOptions,
|
|
384
|
+
...options
|
|
385
|
+
});
|
|
386
|
+
return [buildMeasuredResults(benchmark.name, collected)];
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
const defaultCollectOptions = {
|
|
390
|
+
maxTime: 5e3,
|
|
391
|
+
maxIterations: 1e6,
|
|
392
|
+
warmup: 0,
|
|
393
|
+
traceOpt: false,
|
|
394
|
+
noSettle: false
|
|
395
|
+
};
|
|
396
|
+
function buildMeasuredResults(name, c) {
|
|
397
|
+
const time = computeStats(c.samples);
|
|
398
|
+
const convergence = checkConvergence(c.samples.map((s) => s * msToNs));
|
|
399
|
+
return {
|
|
400
|
+
name,
|
|
401
|
+
samples: c.samples,
|
|
402
|
+
warmupSamples: c.warmupSamples,
|
|
403
|
+
heapSamples: c.heapSamples,
|
|
404
|
+
timestamps: c.timestamps,
|
|
405
|
+
time,
|
|
406
|
+
heapSize: {
|
|
407
|
+
avg: c.heapGrowth,
|
|
408
|
+
min: c.heapGrowth,
|
|
409
|
+
max: c.heapGrowth
|
|
410
|
+
},
|
|
411
|
+
convergence,
|
|
412
|
+
optStatus: c.optStatus,
|
|
413
|
+
optSamples: c.optSamples,
|
|
414
|
+
pausePoints: c.pausePoints
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** @return timing samples and amortized allocation from benchmark execution */
|
|
418
|
+
async function collectSamples(p) {
|
|
419
|
+
if (!p.maxIterations && !p.maxTime) throw new Error(`At least one of maxIterations or maxTime must be set`);
|
|
420
|
+
const warmupSamples = p.skipWarmup ? [] : await runWarmup(p);
|
|
421
|
+
const heapBefore = process.memoryUsage().heapUsed;
|
|
422
|
+
const { samples, heapSamples, timestamps, optStatuses, pausePoints } = await runSampleLoop(p);
|
|
423
|
+
const heapGrowth = Math.max(0, process.memoryUsage().heapUsed - heapBefore) / 1024 / samples.length;
|
|
424
|
+
if (samples.length === 0) throw new Error(`No samples collected for benchmark: ${p.benchmark.name}`);
|
|
425
|
+
return {
|
|
426
|
+
samples,
|
|
427
|
+
warmupSamples,
|
|
428
|
+
heapGrowth,
|
|
429
|
+
heapSamples,
|
|
430
|
+
timestamps,
|
|
431
|
+
optStatus: p.traceOpt ? analyzeOptStatus(samples, optStatuses) : void 0,
|
|
432
|
+
optSamples: p.traceOpt && optStatuses.length > 0 ? optStatuses : void 0,
|
|
433
|
+
pausePoints
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/** Run warmup iterations with gc + settle time for V8 optimization */
|
|
437
|
+
async function runWarmup(p) {
|
|
438
|
+
const gc = gcFunction();
|
|
439
|
+
const samples = new Array(p.warmup);
|
|
440
|
+
for (let i = 0; i < p.warmup; i++) {
|
|
441
|
+
const start = performance.now();
|
|
442
|
+
executeBenchmark(p.benchmark, p.params);
|
|
443
|
+
samples[i] = performance.now() - start;
|
|
444
|
+
}
|
|
445
|
+
gc();
|
|
446
|
+
if (!p.noSettle) {
|
|
447
|
+
await new Promise((r) => setTimeout(r, gcSettleTime));
|
|
448
|
+
gc();
|
|
449
|
+
}
|
|
450
|
+
return samples;
|
|
451
|
+
}
|
|
452
|
+
/** Estimate sample count for pre-allocation */
|
|
453
|
+
function estimateSampleCount(maxTime, maxIterations) {
|
|
454
|
+
return maxIterations || Math.ceil(maxTime / .1);
|
|
455
|
+
}
|
|
456
|
+
/** Pre-allocate arrays to reduce GC pressure during measurement */
|
|
457
|
+
function createSampleArrays(n, trackHeap, trackOpt) {
|
|
458
|
+
const arr = (track) => track ? new Array(n) : [];
|
|
459
|
+
return {
|
|
460
|
+
samples: new Array(n),
|
|
461
|
+
timestamps: new Array(n),
|
|
462
|
+
heapSamples: arr(trackHeap),
|
|
463
|
+
optStatuses: arr(trackOpt),
|
|
464
|
+
pausePoints: []
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/** Trim arrays to actual sample count */
|
|
468
|
+
function trimArrays(a, count, trackHeap, trackOpt) {
|
|
469
|
+
a.samples.length = a.timestamps.length = count;
|
|
470
|
+
if (trackHeap) a.heapSamples.length = count;
|
|
471
|
+
if (trackOpt) a.optStatuses.length = count;
|
|
472
|
+
}
|
|
473
|
+
/** Collect timing samples with periodic pauses for V8 optimization */
|
|
474
|
+
async function runSampleLoop(p) {
|
|
475
|
+
const { maxTime, maxIterations, pauseFirst, pauseInterval = 0, pauseDuration = 100 } = p;
|
|
476
|
+
const trackHeap = true;
|
|
477
|
+
const getOptStatus = p.traceOpt ? createOptStatusGetter() : void 0;
|
|
478
|
+
const a = createSampleArrays(estimateSampleCount(maxTime, maxIterations), trackHeap, !!getOptStatus);
|
|
479
|
+
let count = 0;
|
|
480
|
+
let elapsed = 0;
|
|
481
|
+
let totalPauseTime = 0;
|
|
482
|
+
const loopStart = performance.now();
|
|
483
|
+
while ((!maxIterations || count < maxIterations) && (!maxTime || elapsed < maxTime)) {
|
|
484
|
+
const start = performance.now();
|
|
485
|
+
executeBenchmark(p.benchmark, p.params);
|
|
486
|
+
const end = performance.now();
|
|
487
|
+
a.samples[count] = end - start;
|
|
488
|
+
a.timestamps[count] = Number(process.hrtime.bigint() / 1000n);
|
|
489
|
+
a.heapSamples[count] = getHeapStatistics().used_heap_size;
|
|
490
|
+
if (getOptStatus) a.optStatuses[count] = getOptStatus(p.benchmark.fn);
|
|
491
|
+
count++;
|
|
492
|
+
if (shouldPause(count, pauseFirst, pauseInterval)) {
|
|
493
|
+
a.pausePoints.push({
|
|
494
|
+
sampleIndex: count - 1,
|
|
495
|
+
durationMs: pauseDuration
|
|
496
|
+
});
|
|
497
|
+
const pauseStart = performance.now();
|
|
498
|
+
await new Promise((r) => setTimeout(r, pauseDuration));
|
|
499
|
+
totalPauseTime += performance.now() - pauseStart;
|
|
500
|
+
}
|
|
501
|
+
elapsed = performance.now() - loopStart - totalPauseTime;
|
|
502
|
+
}
|
|
503
|
+
trimArrays(a, count, trackHeap, !!getOptStatus);
|
|
504
|
+
return {
|
|
505
|
+
samples: a.samples,
|
|
506
|
+
heapSamples: a.heapSamples,
|
|
507
|
+
timestamps: a.timestamps,
|
|
508
|
+
optStatuses: a.optStatuses,
|
|
509
|
+
pausePoints: a.pausePoints
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/** Check if we should pause at this iteration for V8 optimization */
|
|
513
|
+
function shouldPause(iter, first, interval) {
|
|
514
|
+
if (first !== void 0 && iter === first) return true;
|
|
515
|
+
if (interval <= 0) return false;
|
|
516
|
+
if (first === void 0) return iter % interval === 0;
|
|
517
|
+
return (iter - first) % interval === 0;
|
|
518
|
+
}
|
|
519
|
+
/** @return percentiles and basic statistics */
|
|
520
|
+
function computeStats(samples) {
|
|
521
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
522
|
+
const avg = samples.reduce((sum, s) => sum + s, 0) / samples.length;
|
|
523
|
+
return {
|
|
524
|
+
min: sorted[0],
|
|
525
|
+
max: sorted[sorted.length - 1],
|
|
526
|
+
avg,
|
|
527
|
+
p50: percentile(sorted, .5),
|
|
528
|
+
p75: percentile(sorted, .75),
|
|
529
|
+
p99: percentile(sorted, .99),
|
|
530
|
+
p999: percentile(sorted, .999)
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/** @return percentile value with linear interpolation */
|
|
534
|
+
function percentile(sortedArray, p) {
|
|
535
|
+
const index = (sortedArray.length - 1) * p;
|
|
536
|
+
const lower = Math.floor(index);
|
|
537
|
+
const upper = Math.ceil(index);
|
|
538
|
+
const weight = index % 1;
|
|
539
|
+
if (upper >= sortedArray.length) return sortedArray[sortedArray.length - 1];
|
|
540
|
+
return sortedArray[lower] * (1 - weight) + sortedArray[upper] * weight;
|
|
541
|
+
}
|
|
542
|
+
/** @return runtime gc() function, or no-op if unavailable */
|
|
543
|
+
function gcFunction() {
|
|
544
|
+
const gc = globalThis.gc || globalThis.__gc;
|
|
545
|
+
if (gc) return gc;
|
|
546
|
+
console.warn("gc() not available, run node/bun with --expose-gc");
|
|
547
|
+
return () => {};
|
|
548
|
+
}
|
|
549
|
+
/** @return function to get V8 optimization status (requires --allow-natives-syntax) */
|
|
550
|
+
function createOptStatusGetter() {
|
|
551
|
+
try {
|
|
552
|
+
const getter = new Function("f", "return %GetOptimizationStatus(f)");
|
|
553
|
+
getter(() => {});
|
|
554
|
+
return getter;
|
|
555
|
+
} catch {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* V8 optimization status bit meanings:
|
|
561
|
+
* Bit 0 (1): is_function
|
|
562
|
+
* Bit 4 (16): is_optimized (TurboFan)
|
|
563
|
+
* Bit 5 (32): is_optimized (Maglev)
|
|
564
|
+
* Bit 7 (128): is_baseline (Sparkplug)
|
|
565
|
+
* Bit 3 (8): maybe_deoptimized
|
|
566
|
+
*/
|
|
567
|
+
const statusNames = {
|
|
568
|
+
1: "interpreted",
|
|
569
|
+
129: "sparkplug",
|
|
570
|
+
17: "turbofan",
|
|
571
|
+
33: "maglev",
|
|
572
|
+
49: "turbofan+maglev",
|
|
573
|
+
32769: "optimized"
|
|
574
|
+
};
|
|
575
|
+
/** @return analysis of V8 optimization status per sample */
|
|
576
|
+
function analyzeOptStatus(samples, statuses) {
|
|
577
|
+
if (statuses.length === 0 || statuses[0] === void 0) return void 0;
|
|
578
|
+
const byStatusCode = /* @__PURE__ */ new Map();
|
|
579
|
+
let deoptCount = 0;
|
|
580
|
+
for (let i = 0; i < samples.length; i++) {
|
|
581
|
+
const status = statuses[i];
|
|
582
|
+
if (status === void 0) continue;
|
|
583
|
+
if (status & 8) deoptCount++;
|
|
584
|
+
if (!byStatusCode.has(status)) byStatusCode.set(status, []);
|
|
585
|
+
byStatusCode.get(status).push(samples[i]);
|
|
586
|
+
}
|
|
587
|
+
const byTier = {};
|
|
588
|
+
for (const [status, times] of byStatusCode) {
|
|
589
|
+
const name = statusNames[status] || `status=${status}`;
|
|
590
|
+
const sorted = [...times].sort((a, b) => a - b);
|
|
591
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
592
|
+
byTier[name] = {
|
|
593
|
+
count: times.length,
|
|
594
|
+
medianMs: median
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
byTier,
|
|
599
|
+
deoptCount
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
//#endregion
|
|
604
|
+
//#region src/runners/CreateRunner.ts
|
|
605
|
+
/** @return benchmark runner */
|
|
606
|
+
async function createRunner(_runnerName) {
|
|
607
|
+
return new BasicRunner();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/runners/GcStats.ts
|
|
612
|
+
/** Parse a single --trace-gc-nvp stderr line */
|
|
613
|
+
function parseGcLine(line) {
|
|
614
|
+
if (!line.includes("pause=")) return void 0;
|
|
615
|
+
const fields = parseNvpFields(line);
|
|
616
|
+
if (!fields.gc) return void 0;
|
|
617
|
+
const int = (k) => Number.parseInt(fields[k] || "0", 10);
|
|
618
|
+
const type = parseGcType(fields.gc);
|
|
619
|
+
const pauseMs = Number.parseFloat(fields.pause || "0");
|
|
620
|
+
const allocated = int("allocated");
|
|
621
|
+
const promoted = int("promoted");
|
|
622
|
+
const survived = int("new_space_survived") || int("survived");
|
|
623
|
+
const startSize = int("start_object_size");
|
|
624
|
+
const endSize = int("end_object_size");
|
|
625
|
+
const collected = startSize > endSize ? startSize - endSize : 0;
|
|
626
|
+
if (Number.isNaN(pauseMs)) return void 0;
|
|
627
|
+
return {
|
|
628
|
+
type,
|
|
629
|
+
pauseMs,
|
|
630
|
+
allocated,
|
|
631
|
+
collected,
|
|
632
|
+
promoted,
|
|
633
|
+
survived
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/** Parse name=value pairs from trace-gc-nvp line */
|
|
637
|
+
function parseNvpFields(line) {
|
|
638
|
+
const fields = {};
|
|
639
|
+
const matches = line.matchAll(/(\w+)=([^\s,]+)/g);
|
|
640
|
+
for (const [, key, value] of matches) fields[key] = value;
|
|
641
|
+
return fields;
|
|
642
|
+
}
|
|
643
|
+
/** Map V8 gc type codes to our types */
|
|
644
|
+
function parseGcType(gcField) {
|
|
645
|
+
if (gcField === "s" || gcField === "scavenge") return "scavenge";
|
|
646
|
+
if (gcField === "mc" || gcField === "ms" || gcField === "mark-compact") return "mark-compact";
|
|
647
|
+
if (gcField === "mmc" || gcField === "minor-mc" || gcField === "minor-ms") return "minor-ms";
|
|
648
|
+
return "unknown";
|
|
649
|
+
}
|
|
650
|
+
/** Aggregate GC events into summary stats */
|
|
651
|
+
function aggregateGcStats(events) {
|
|
652
|
+
let scavenges = 0;
|
|
653
|
+
let markCompacts = 0;
|
|
654
|
+
let gcPauseTime = 0;
|
|
655
|
+
let totalCollected = 0;
|
|
656
|
+
let hasNodeFields = false;
|
|
657
|
+
let totalAllocated = 0;
|
|
658
|
+
let totalPromoted = 0;
|
|
659
|
+
let totalSurvived = 0;
|
|
660
|
+
for (const e of events) {
|
|
661
|
+
if (e.type === "scavenge" || e.type === "minor-ms") scavenges++;
|
|
662
|
+
else if (e.type === "mark-compact") markCompacts++;
|
|
663
|
+
gcPauseTime += e.pauseMs;
|
|
664
|
+
totalCollected += e.collected;
|
|
665
|
+
if (e.allocated != null) {
|
|
666
|
+
hasNodeFields = true;
|
|
667
|
+
totalAllocated += e.allocated;
|
|
668
|
+
totalPromoted += e.promoted ?? 0;
|
|
669
|
+
totalSurvived += e.survived ?? 0;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
scavenges,
|
|
674
|
+
markCompacts,
|
|
675
|
+
totalCollected,
|
|
676
|
+
gcPauseTime,
|
|
677
|
+
...hasNodeFields && {
|
|
678
|
+
totalAllocated,
|
|
679
|
+
totalPromoted,
|
|
680
|
+
totalSurvived
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/runners/TimingUtils.ts
|
|
687
|
+
const debugWorkerTiming = false;
|
|
688
|
+
/** Get current time or 0 if debugging disabled */
|
|
689
|
+
function getPerfNow() {
|
|
690
|
+
return 0;
|
|
691
|
+
}
|
|
692
|
+
/** Calculate elapsed milliseconds between marks */
|
|
693
|
+
function getElapsed(startMark, endMark) {
|
|
694
|
+
return 0;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/runners/RunnerOrchestrator.ts
|
|
699
|
+
const logTiming = debugWorkerTiming ? (message) => console.log(`[RunnerOrchestrator] ${message}`) : () => {};
|
|
700
|
+
/** Execute benchmarks directly or in worker process */
|
|
701
|
+
async function runBenchmark({ spec, runner, options, useWorker = false, params }) {
|
|
702
|
+
if (!useWorker) {
|
|
703
|
+
const resolvedSpec = spec.modulePath ? await resolveModuleSpec(spec, params) : {
|
|
704
|
+
spec,
|
|
705
|
+
params
|
|
706
|
+
};
|
|
707
|
+
const base = await createRunner(runner);
|
|
708
|
+
return (options.adaptive ? createAdaptiveWrapper(base, options) : base).runBench(resolvedSpec.spec, options, resolvedSpec.params);
|
|
709
|
+
}
|
|
710
|
+
return runInWorker({
|
|
711
|
+
spec,
|
|
712
|
+
runner,
|
|
713
|
+
options,
|
|
714
|
+
params
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/** Resolve modulePath/exportName to a real function for non-worker mode */
|
|
718
|
+
async function resolveModuleSpec(spec, params) {
|
|
719
|
+
const module = await import(spec.modulePath);
|
|
720
|
+
const fn = spec.exportName ? module[spec.exportName] : module.default || module;
|
|
721
|
+
if (typeof fn !== "function") {
|
|
722
|
+
const name = spec.exportName || "default";
|
|
723
|
+
throw new Error(`Export '${name}' from ${spec.modulePath} is not a function`);
|
|
724
|
+
}
|
|
725
|
+
let resolvedParams = params;
|
|
726
|
+
if (spec.setupExportName) {
|
|
727
|
+
const setupFn = module[spec.setupExportName];
|
|
728
|
+
if (typeof setupFn !== "function") {
|
|
729
|
+
const msg = `Setup export '${spec.setupExportName}' from ${spec.modulePath} is not a function`;
|
|
730
|
+
throw new Error(msg);
|
|
731
|
+
}
|
|
732
|
+
resolvedParams = await setupFn(params);
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
spec: {
|
|
736
|
+
...spec,
|
|
737
|
+
fn
|
|
738
|
+
},
|
|
739
|
+
params: resolvedParams
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/** Run benchmark in isolated worker process */
|
|
743
|
+
async function runInWorker(workerParams) {
|
|
744
|
+
const { spec, runner, options, params } = workerParams;
|
|
745
|
+
const msg = createRunMessage(spec, runner, options, params);
|
|
746
|
+
return runWorkerWithMessage(spec.name, options, msg);
|
|
747
|
+
}
|
|
748
|
+
/** Create worker process with timing logs */
|
|
749
|
+
function createWorkerWithTiming(gcStats) {
|
|
750
|
+
const workerStart = getPerfNow();
|
|
751
|
+
const gcEvents = [];
|
|
752
|
+
const worker = createWorkerProcess(gcStats);
|
|
753
|
+
const createTime = getPerfNow();
|
|
754
|
+
if (gcStats && worker.stdout) setupGcCapture(worker, gcEvents);
|
|
755
|
+
logTiming(`Worker process created in ${getElapsed(workerStart, createTime).toFixed(1)}ms`);
|
|
756
|
+
return {
|
|
757
|
+
worker,
|
|
758
|
+
createTime,
|
|
759
|
+
gcEvents
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
/** Capture and parse GC lines from stdout (V8's --trace-gc-nvp outputs to stdout) */
|
|
763
|
+
function setupGcCapture(worker, gcEvents) {
|
|
764
|
+
let buffer = "";
|
|
765
|
+
worker.stdout.on("data", (data) => {
|
|
766
|
+
buffer += data.toString();
|
|
767
|
+
const lines = buffer.split("\n");
|
|
768
|
+
buffer = lines.pop() || "";
|
|
769
|
+
for (const line of lines) {
|
|
770
|
+
const event = parseGcLine(line);
|
|
771
|
+
if (event) gcEvents.push(event);
|
|
772
|
+
else if (line.trim()) process.stdout.write(line + "\n");
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
/** Spawn worker, wire handlers, send message, return results */
|
|
777
|
+
function runWorkerWithMessage(name, options, message) {
|
|
778
|
+
const startTime = getPerfNow();
|
|
779
|
+
const collectGcStats = options.gcStats ?? false;
|
|
780
|
+
logTiming(`Starting worker for ${name}`);
|
|
781
|
+
return new Promise((resolve, reject) => {
|
|
782
|
+
const { worker, createTime, gcEvents } = createWorkerWithTiming(collectGcStats);
|
|
783
|
+
setupWorkerHandlers(worker, name, createWorkerHandlers(name, startTime, gcEvents, resolve, reject));
|
|
784
|
+
sendWorkerMessage(worker, message, createTime);
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
/** Send message to worker with timing log */
|
|
788
|
+
function sendWorkerMessage(worker, message, createTime) {
|
|
789
|
+
const messageTime = getPerfNow();
|
|
790
|
+
worker.send(message);
|
|
791
|
+
logTiming(`Message sent to worker in ${getElapsed(createTime, messageTime).toFixed(1)}ms`);
|
|
792
|
+
}
|
|
793
|
+
/** Setup worker event handlers with cleanup */
|
|
794
|
+
function setupWorkerHandlers(worker, specName, handlers) {
|
|
795
|
+
const { resolve, reject } = handlers;
|
|
796
|
+
const cleanup = createCleanup(worker, specName, reject);
|
|
797
|
+
worker.on("message", createMessageHandler(specName, cleanup, resolve, reject));
|
|
798
|
+
worker.on("error", createErrorHandler(specName, cleanup, reject));
|
|
799
|
+
worker.on("exit", createExitHandler(specName, cleanup, reject));
|
|
800
|
+
}
|
|
801
|
+
/** Handle worker messages (results or errors) */
|
|
802
|
+
function createMessageHandler(specName, cleanup, resolve, reject) {
|
|
803
|
+
return (msg) => {
|
|
804
|
+
cleanup();
|
|
805
|
+
if (msg.type === "result") resolve(msg.results, msg.heapProfile);
|
|
806
|
+
else if (msg.type === "error") {
|
|
807
|
+
const error = /* @__PURE__ */ new Error(`Benchmark "${specName}" failed: ${msg.error}`);
|
|
808
|
+
if (msg.stack) error.stack = msg.stack;
|
|
809
|
+
reject(error);
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/** Handle worker process errors */
|
|
814
|
+
function createErrorHandler(specName, cleanup, reject) {
|
|
815
|
+
return (error) => {
|
|
816
|
+
cleanup();
|
|
817
|
+
reject(/* @__PURE__ */ new Error(`Worker process failed for benchmark "${specName}": ${error.message}`));
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
/** Handle worker process exit */
|
|
821
|
+
function createExitHandler(specName, cleanup, reject) {
|
|
822
|
+
return (code, _signal) => {
|
|
823
|
+
if (code !== 0 && code !== null) {
|
|
824
|
+
cleanup();
|
|
825
|
+
const msg = `Worker exited with code ${code} for benchmark "${specName}"`;
|
|
826
|
+
reject(new Error(msg));
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
/** Create cleanup for timeout and termination */
|
|
831
|
+
function createCleanup(worker, specName, reject) {
|
|
832
|
+
const timeoutId = setTimeout(() => {
|
|
833
|
+
cleanup();
|
|
834
|
+
reject(/* @__PURE__ */ new Error(`Benchmark "${specName}" timed out after 60 seconds`));
|
|
835
|
+
}, 6e4);
|
|
836
|
+
const cleanup = () => {
|
|
837
|
+
clearTimeout(timeoutId);
|
|
838
|
+
if (!worker.killed) worker.kill("SIGTERM");
|
|
839
|
+
};
|
|
840
|
+
return cleanup;
|
|
841
|
+
}
|
|
842
|
+
/** Create worker process with configuration */
|
|
843
|
+
function createWorkerProcess(gcStats) {
|
|
844
|
+
const workerPath = path.join(import.meta.dirname, "WorkerScript.ts");
|
|
845
|
+
const execArgv = [
|
|
846
|
+
"--expose-gc",
|
|
847
|
+
"--allow-natives-syntax",
|
|
848
|
+
"--experimental-strip-types",
|
|
849
|
+
"--no-warnings=ExperimentalWarning"
|
|
850
|
+
];
|
|
851
|
+
if (gcStats) execArgv.push("--trace-gc-nvp");
|
|
852
|
+
return fork(workerPath, [], {
|
|
853
|
+
execArgv,
|
|
854
|
+
silent: gcStats,
|
|
855
|
+
env: {
|
|
856
|
+
...process.env,
|
|
857
|
+
NODE_OPTIONS: ""
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
/** @return handlers that attach GC stats and heap profile to results */
|
|
862
|
+
function createWorkerHandlers(specName, startTime, gcEvents, resolve, reject) {
|
|
863
|
+
return {
|
|
864
|
+
resolve: (results, heapProfile) => {
|
|
865
|
+
logTiming(`Total worker time for ${specName}: ${getElapsed(startTime).toFixed(1)}ms`);
|
|
866
|
+
if (gcEvents?.length) {
|
|
867
|
+
const gcStats = aggregateGcStats(gcEvents);
|
|
868
|
+
for (const r of results) r.gcStats = gcStats;
|
|
869
|
+
}
|
|
870
|
+
if (heapProfile) for (const r of results) r.heapProfile = heapProfile;
|
|
871
|
+
resolve(results);
|
|
872
|
+
},
|
|
873
|
+
reject
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
/** Create message for worker execution */
|
|
877
|
+
function createRunMessage(spec, runnerName, options, params) {
|
|
878
|
+
const { fn, ...rest } = spec;
|
|
879
|
+
const message = {
|
|
880
|
+
type: "run",
|
|
881
|
+
spec: rest,
|
|
882
|
+
runnerName,
|
|
883
|
+
options,
|
|
884
|
+
params
|
|
885
|
+
};
|
|
886
|
+
if (spec.modulePath) {
|
|
887
|
+
message.modulePath = spec.modulePath;
|
|
888
|
+
message.exportName = spec.exportName;
|
|
889
|
+
if (spec.setupExportName) message.setupExportName = spec.setupExportName;
|
|
890
|
+
} else message.fnCode = fn.toString();
|
|
891
|
+
return message;
|
|
892
|
+
}
|
|
893
|
+
/** Run a matrix variant benchmark in isolated worker process */
|
|
894
|
+
async function runMatrixVariant(params) {
|
|
895
|
+
const { variantDir, variantId, caseId, caseData, casesModule, runner, options } = params;
|
|
896
|
+
const name = `${variantId}/${caseId}`;
|
|
897
|
+
return runWorkerWithMessage(name, options, {
|
|
898
|
+
type: "run",
|
|
899
|
+
spec: {
|
|
900
|
+
name,
|
|
901
|
+
fn: () => {}
|
|
902
|
+
},
|
|
903
|
+
runnerName: runner,
|
|
904
|
+
options,
|
|
905
|
+
variantDir,
|
|
906
|
+
variantId,
|
|
907
|
+
caseId,
|
|
908
|
+
caseData,
|
|
909
|
+
casesModule
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
//#endregion
|
|
914
|
+
//#region src/BenchMatrix.ts
|
|
915
|
+
/** @return true if variant is a StatefulVariant (has setup + run) */
|
|
916
|
+
function isStatefulVariant(v) {
|
|
917
|
+
return typeof v === "object" && "setup" in v && "run" in v;
|
|
918
|
+
}
|
|
919
|
+
/** Run a BenchMatrix with inline variants or variantDir */
|
|
920
|
+
async function runMatrix(matrix, options = {}) {
|
|
921
|
+
validateBaseline(matrix);
|
|
922
|
+
const effectiveOptions = {
|
|
923
|
+
...matrix.defaults,
|
|
924
|
+
...options
|
|
925
|
+
};
|
|
926
|
+
if (matrix.variantDir) return runMatrixWithDir(matrix, effectiveOptions);
|
|
927
|
+
if (matrix.variants) return runMatrixInline(matrix, effectiveOptions);
|
|
928
|
+
throw new Error("BenchMatrix requires either 'variants' or 'variantDir'");
|
|
929
|
+
}
|
|
930
|
+
/** @throws if both baselineDir and baselineVariant are set */
|
|
931
|
+
function validateBaseline(matrix) {
|
|
932
|
+
const msg = "BenchMatrix cannot have both 'baselineDir' and 'baselineVariant'";
|
|
933
|
+
if (matrix.baselineDir && matrix.baselineVariant) throw new Error(msg);
|
|
934
|
+
}
|
|
935
|
+
function buildRunnerOptions(options) {
|
|
936
|
+
return {
|
|
937
|
+
maxIterations: options.iterations,
|
|
938
|
+
maxTime: options.maxTime ?? 1e3,
|
|
939
|
+
warmup: options.warmup ?? 0,
|
|
940
|
+
collect: options.collect,
|
|
941
|
+
cpuCounters: options.cpuCounters,
|
|
942
|
+
traceOpt: options.traceOpt,
|
|
943
|
+
noSettle: options.noSettle,
|
|
944
|
+
pauseFirst: options.pauseFirst,
|
|
945
|
+
pauseInterval: options.pauseInterval,
|
|
946
|
+
pauseDuration: options.pauseDuration,
|
|
947
|
+
gcStats: options.gcStats,
|
|
948
|
+
heapSample: options.heapSample,
|
|
949
|
+
heapInterval: options.heapInterval,
|
|
950
|
+
heapDepth: options.heapDepth
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/** Load cases module and resolve filtered case IDs */
|
|
954
|
+
async function resolveCases(matrix, options) {
|
|
955
|
+
const casesModule = matrix.casesModule ? await loadCasesModule(matrix.casesModule) : void 0;
|
|
956
|
+
const allCaseIds = casesModule?.cases ?? matrix.cases ?? ["default"];
|
|
957
|
+
return {
|
|
958
|
+
casesModule,
|
|
959
|
+
caseIds: options.filteredCases ?? allCaseIds
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
/** Run matrix with inline variants (non-worker mode) */
|
|
963
|
+
async function runMatrixInline(matrix, options) {
|
|
964
|
+
const msg = "BenchMatrix with inline 'variants' cannot use 'baselineDir'. Use 'variantDir' instead.";
|
|
965
|
+
if (matrix.baselineDir) throw new Error(msg);
|
|
966
|
+
const { casesModule, caseIds } = await resolveCases(matrix, options);
|
|
967
|
+
const runner = new BasicRunner();
|
|
968
|
+
const runnerOpts = buildRunnerOptions(options);
|
|
969
|
+
const variantEntries = options.filteredVariants ? Object.entries(matrix.variants).filter(([id]) => options.filteredVariants.includes(id)) : Object.entries(matrix.variants);
|
|
970
|
+
const variants = [];
|
|
971
|
+
for (const [variantId, variant] of variantEntries) {
|
|
972
|
+
const cases = [];
|
|
973
|
+
for (const caseId of caseIds) {
|
|
974
|
+
const loaded = await loadCaseData(casesModule, caseId);
|
|
975
|
+
const measured = await runVariant(variant, casesModule || matrix.cases ? loaded.data : void 0, variantId, runner, runnerOpts);
|
|
976
|
+
cases.push({
|
|
977
|
+
caseId,
|
|
978
|
+
measured,
|
|
979
|
+
metadata: loaded.metadata
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
variants.push({
|
|
983
|
+
id: variantId,
|
|
984
|
+
cases
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
if (matrix.baselineVariant) applyBaselineVariant(variants, matrix.baselineVariant);
|
|
988
|
+
return {
|
|
989
|
+
name: matrix.name,
|
|
990
|
+
variants
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/** Run matrix with variantDir (worker mode for memory isolation) */
|
|
994
|
+
async function runMatrixWithDir(matrix, options) {
|
|
995
|
+
const allVariantIds = await discoverVariants(matrix.variantDir);
|
|
996
|
+
if (allVariantIds.length === 0) throw new Error(`No variants found in ${matrix.variantDir}`);
|
|
997
|
+
const variants = await runDirVariants(options.filteredVariants ?? allVariantIds, await createDirContext(matrix, options));
|
|
998
|
+
if (matrix.baselineVariant) applyBaselineVariant(variants, matrix.baselineVariant);
|
|
999
|
+
return {
|
|
1000
|
+
name: matrix.name,
|
|
1001
|
+
variants
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/** Create context for directory-based matrix execution */
|
|
1005
|
+
async function createDirContext(matrix, options) {
|
|
1006
|
+
const baselineIds = matrix.baselineDir ? await discoverVariants(matrix.baselineDir) : [];
|
|
1007
|
+
const { casesModule, caseIds } = await resolveCases(matrix, options);
|
|
1008
|
+
return {
|
|
1009
|
+
matrix,
|
|
1010
|
+
casesModule,
|
|
1011
|
+
baselineIds,
|
|
1012
|
+
caseIds,
|
|
1013
|
+
runnerOpts: buildRunnerOptions(options)
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
/** Run all variants using worker processes */
|
|
1017
|
+
async function runDirVariants(variantIds, ctx) {
|
|
1018
|
+
const variants = [];
|
|
1019
|
+
for (const variantId of variantIds) {
|
|
1020
|
+
const cases = await runDirVariantCases(variantId, ctx);
|
|
1021
|
+
variants.push({
|
|
1022
|
+
id: variantId,
|
|
1023
|
+
cases
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
return variants;
|
|
1027
|
+
}
|
|
1028
|
+
/** Run all cases for a single variant */
|
|
1029
|
+
async function runDirVariantCases(variantId, ctx) {
|
|
1030
|
+
const { matrix, casesModule, caseIds, runnerOpts } = ctx;
|
|
1031
|
+
const cases = [];
|
|
1032
|
+
for (const caseId of caseIds) {
|
|
1033
|
+
const caseData = !matrix.casesModule && matrix.cases ? caseId : void 0;
|
|
1034
|
+
const [measured] = await runMatrixVariant({
|
|
1035
|
+
variantDir: matrix.variantDir,
|
|
1036
|
+
variantId,
|
|
1037
|
+
caseId,
|
|
1038
|
+
caseData,
|
|
1039
|
+
casesModule: matrix.casesModule,
|
|
1040
|
+
runner: "basic",
|
|
1041
|
+
options: runnerOpts
|
|
1042
|
+
});
|
|
1043
|
+
const loaded = await loadCaseData(casesModule, caseId);
|
|
1044
|
+
const baseline = await runBaselineIfExists(variantId, caseId, caseData, ctx);
|
|
1045
|
+
const deltaPercent = baseline ? computeDeltaPercent(baseline, measured) : void 0;
|
|
1046
|
+
const metadata = loaded.metadata;
|
|
1047
|
+
cases.push({
|
|
1048
|
+
caseId,
|
|
1049
|
+
measured,
|
|
1050
|
+
metadata,
|
|
1051
|
+
baseline,
|
|
1052
|
+
deltaPercent
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
return cases;
|
|
1056
|
+
}
|
|
1057
|
+
/** Run baseline variant if it exists in baselineDir */
|
|
1058
|
+
async function runBaselineIfExists(variantId, caseId, caseData, ctx) {
|
|
1059
|
+
const { matrix, baselineIds, runnerOpts } = ctx;
|
|
1060
|
+
if (!matrix.baselineDir || !baselineIds.includes(variantId)) return void 0;
|
|
1061
|
+
const [measured] = await runMatrixVariant({
|
|
1062
|
+
variantDir: matrix.baselineDir,
|
|
1063
|
+
variantId,
|
|
1064
|
+
caseId,
|
|
1065
|
+
caseData,
|
|
1066
|
+
casesModule: matrix.casesModule,
|
|
1067
|
+
runner: "basic",
|
|
1068
|
+
options: runnerOpts
|
|
1069
|
+
});
|
|
1070
|
+
return measured;
|
|
1071
|
+
}
|
|
1072
|
+
/** Compute delta percentage: (current - baseline) / baseline * 100 */
|
|
1073
|
+
function computeDeltaPercent(baseline, current) {
|
|
1074
|
+
const baseAvg = average(baseline.samples);
|
|
1075
|
+
if (baseAvg === 0) return 0;
|
|
1076
|
+
return (average(current.samples) - baseAvg) / baseAvg * 100;
|
|
1077
|
+
}
|
|
1078
|
+
/** Apply baselineVariant comparison - one variant is the reference for all others */
|
|
1079
|
+
function applyBaselineVariant(variants, baselineVariantId) {
|
|
1080
|
+
const baselineVariant = variants.find((v) => v.id === baselineVariantId);
|
|
1081
|
+
if (!baselineVariant) return;
|
|
1082
|
+
const baselineByCase = /* @__PURE__ */ new Map();
|
|
1083
|
+
for (const c of baselineVariant.cases) baselineByCase.set(c.caseId, c.measured);
|
|
1084
|
+
for (const variant of variants) {
|
|
1085
|
+
if (variant.id === baselineVariantId) continue;
|
|
1086
|
+
for (const caseResult of variant.cases) {
|
|
1087
|
+
const baseline = baselineByCase.get(caseResult.caseId);
|
|
1088
|
+
if (baseline) {
|
|
1089
|
+
caseResult.baseline = baseline;
|
|
1090
|
+
caseResult.deltaPercent = computeDeltaPercent(baseline, caseResult.measured);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/** Run a single variant with case data */
|
|
1096
|
+
async function runVariant(variant, caseData, name, runner, options) {
|
|
1097
|
+
if (isStatefulVariant(variant)) {
|
|
1098
|
+
const state = await variant.setup(caseData);
|
|
1099
|
+
const [result] = await runner.runBench({
|
|
1100
|
+
name,
|
|
1101
|
+
fn: () => variant.run(state)
|
|
1102
|
+
}, options);
|
|
1103
|
+
return result;
|
|
1104
|
+
}
|
|
1105
|
+
const [result] = await runner.runBench({
|
|
1106
|
+
name,
|
|
1107
|
+
fn: () => variant(caseData)
|
|
1108
|
+
}, options);
|
|
1109
|
+
return result;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
//#endregion
|
|
1113
|
+
//#region src/table-util/Formatters.ts
|
|
1114
|
+
const { red: red$1, green } = process.env.NODE_ENV === "test" || process.env.VITEST === "true" ? {
|
|
1115
|
+
red: (str) => str,
|
|
1116
|
+
green: (str) => str
|
|
1117
|
+
} : pico;
|
|
1118
|
+
/** Format percentages with custom precision */
|
|
1119
|
+
function percentPrecision(precision) {
|
|
1120
|
+
return (x) => {
|
|
1121
|
+
if (typeof x !== "number") return null;
|
|
1122
|
+
return percent(x, precision);
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
/** Format duration in milliseconds with appropriate units */
|
|
1126
|
+
function duration(ms) {
|
|
1127
|
+
if (typeof ms !== "number") return null;
|
|
1128
|
+
if (ms < .001) return `${(ms * 1e6).toFixed(0)}ns`;
|
|
1129
|
+
if (ms < 1) return `${(ms * 1e3).toFixed(1)}μs`;
|
|
1130
|
+
if (ms < 1e3) return `${ms.toFixed(2)}ms`;
|
|
1131
|
+
return `${(ms / 1e3).toFixed(2)}s`;
|
|
1132
|
+
}
|
|
1133
|
+
/** Format time in milliseconds, showing very small values with units */
|
|
1134
|
+
function timeMs(ms) {
|
|
1135
|
+
if (typeof ms !== "number") return null;
|
|
1136
|
+
if (ms < .001) return `${(ms * 1e6).toFixed(0)}ns`;
|
|
1137
|
+
if (ms < .01) return `${(ms * 1e3).toFixed(1)}μs`;
|
|
1138
|
+
if (ms >= 10) return ms.toFixed(0);
|
|
1139
|
+
return ms.toFixed(2);
|
|
1140
|
+
}
|
|
1141
|
+
/** Format integer with thousand separators */
|
|
1142
|
+
function integer(x) {
|
|
1143
|
+
if (typeof x !== "number") return null;
|
|
1144
|
+
return new Intl.NumberFormat("en-US").format(Math.round(x));
|
|
1145
|
+
}
|
|
1146
|
+
/** Format fraction as percentage (0.473 → 47.3%) */
|
|
1147
|
+
function percent(fraction, precision = 1) {
|
|
1148
|
+
if (typeof fraction !== "number") return null;
|
|
1149
|
+
return `${Math.abs(fraction * 100).toFixed(precision)}%`;
|
|
1150
|
+
}
|
|
1151
|
+
/** Format percentage difference between two values */
|
|
1152
|
+
function diffPercent(main, base) {
|
|
1153
|
+
if (typeof main !== "number" || typeof base !== "number") return " ";
|
|
1154
|
+
return coloredPercent(main - base, base);
|
|
1155
|
+
}
|
|
1156
|
+
/** Format fraction as colored +/- percentage */
|
|
1157
|
+
function coloredPercent(numerator, denominator, positiveIsGreen = true) {
|
|
1158
|
+
const fraction = numerator / denominator;
|
|
1159
|
+
if (Number.isNaN(fraction) || !Number.isFinite(fraction)) return " ";
|
|
1160
|
+
const positive = fraction >= 0;
|
|
1161
|
+
const percentStr = `${positive ? "+" : "-"}${percent(fraction)}`;
|
|
1162
|
+
return positive === positiveIsGreen ? green(percentStr) : red$1(percentStr);
|
|
1163
|
+
}
|
|
1164
|
+
/** Format bytes with appropriate units (B, KB, MB, GB) */
|
|
1165
|
+
function formatBytes(bytes) {
|
|
1166
|
+
if (typeof bytes !== "number") return null;
|
|
1167
|
+
if (bytes < 1024) return `${bytes.toFixed(0)}B`;
|
|
1168
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1169
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
1170
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
|
|
1171
|
+
}
|
|
1172
|
+
/** Format percentage difference with confidence interval */
|
|
1173
|
+
function formatDiffWithCI(value) {
|
|
1174
|
+
if (!isDifferenceCI(value)) return null;
|
|
1175
|
+
const { percent, ci, direction } = value;
|
|
1176
|
+
return colorByDirection(diffCIText(percent, ci), direction);
|
|
1177
|
+
}
|
|
1178
|
+
/** Format percentage difference with CI for throughput metrics (higher is better) */
|
|
1179
|
+
function formatDiffWithCIHigherIsBetter(value) {
|
|
1180
|
+
if (!isDifferenceCI(value)) return null;
|
|
1181
|
+
const { percent, ci, direction } = value;
|
|
1182
|
+
return colorByDirection(diffCIText(-percent, [-ci[1], -ci[0]]), direction);
|
|
1183
|
+
}
|
|
1184
|
+
/** @return formatted "pct [lo, hi]" text for a diff with CI */
|
|
1185
|
+
function diffCIText(pct, ci) {
|
|
1186
|
+
return `${formatBound(pct)} [${formatBound(ci[0])}, ${formatBound(ci[1])}]`;
|
|
1187
|
+
}
|
|
1188
|
+
/** @return text colored green for faster, red for slower */
|
|
1189
|
+
function colorByDirection(text, direction) {
|
|
1190
|
+
if (direction === "faster") return green(text);
|
|
1191
|
+
if (direction === "slower") return red$1(text);
|
|
1192
|
+
return text;
|
|
1193
|
+
}
|
|
1194
|
+
/** @return signed percentage string (e.g. "+1.2%", "-3.4%") */
|
|
1195
|
+
function formatBound(v) {
|
|
1196
|
+
return `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`;
|
|
1197
|
+
}
|
|
1198
|
+
/** @return true if value is a DifferenceCI object */
|
|
1199
|
+
function isDifferenceCI(x) {
|
|
1200
|
+
return typeof x === "object" && x !== null && "ci" in x && "direction" in x;
|
|
1201
|
+
}
|
|
1202
|
+
/** @return truncated string with ellipsis if over maxLen */
|
|
1203
|
+
function truncate(str, maxLen = 30) {
|
|
1204
|
+
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
//#endregion
|
|
1208
|
+
//#region src/table-util/TableReport.ts
|
|
1209
|
+
const { bold } = process.env.NODE_ENV === "test" || process.env.VITEST === "true" ? { bold: (str) => str } : pico;
|
|
1210
|
+
/** Build formatted table with column groups and baselines */
|
|
1211
|
+
function buildTable(columnGroups, resultGroups, nameKey = "name") {
|
|
1212
|
+
return createTable(columnGroups, flattenGroups(columnGroups, resultGroups, nameKey));
|
|
1213
|
+
}
|
|
1214
|
+
/** Convert columns and records to formatted table */
|
|
1215
|
+
function createTable(groups, records) {
|
|
1216
|
+
const dataRows = toRows(records, groups);
|
|
1217
|
+
const { headerRows, config } = setup(groups, dataRows);
|
|
1218
|
+
return table([...headerRows, ...dataRows], config);
|
|
1219
|
+
}
|
|
1220
|
+
/** Create header rows with group titles */
|
|
1221
|
+
function createGroupHeaders(groups, numColumns) {
|
|
1222
|
+
if (!groups.some((g) => g.groupTitle)) return [];
|
|
1223
|
+
return [groups.flatMap((g) => {
|
|
1224
|
+
return padWithBlanks(g.groupTitle ? [bold(g.groupTitle)] : [], g.columns.length);
|
|
1225
|
+
}), padWithBlanks([], numColumns)];
|
|
1226
|
+
}
|
|
1227
|
+
/** @return draw functions for horizontal/vertical table borders */
|
|
1228
|
+
function createLines(groups) {
|
|
1229
|
+
const { sectionBorders, headerBottom } = calcBorders(groups);
|
|
1230
|
+
function drawVerticalLine(index, size) {
|
|
1231
|
+
return index === 0 || index === size || sectionBorders.includes(index);
|
|
1232
|
+
}
|
|
1233
|
+
function drawHorizontalLine(index, size) {
|
|
1234
|
+
return index === 0 || index === size || index === headerBottom;
|
|
1235
|
+
}
|
|
1236
|
+
return {
|
|
1237
|
+
drawHorizontalLine,
|
|
1238
|
+
drawVerticalLine
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
/** @return spanning cell configs for group title headers */
|
|
1242
|
+
function createSectionSpans(groups) {
|
|
1243
|
+
let col = 0;
|
|
1244
|
+
const alignment = "center";
|
|
1245
|
+
return groups.map((g) => {
|
|
1246
|
+
const colSpan = g.columns.length;
|
|
1247
|
+
const span = {
|
|
1248
|
+
row: 0,
|
|
1249
|
+
col,
|
|
1250
|
+
colSpan,
|
|
1251
|
+
alignment
|
|
1252
|
+
};
|
|
1253
|
+
col += colSpan;
|
|
1254
|
+
return span;
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
/** @return bolded column title strings */
|
|
1258
|
+
function getTitles(groups) {
|
|
1259
|
+
return groups.flatMap((g) => g.columns.map((c) => bold(c.title || " ")));
|
|
1260
|
+
}
|
|
1261
|
+
/** @return array padded with blank strings to the given length */
|
|
1262
|
+
function padWithBlanks(arr, length) {
|
|
1263
|
+
if (arr.length >= length) return arr;
|
|
1264
|
+
return [...arr, ...Array(length - arr.length).fill(" ")];
|
|
1265
|
+
}
|
|
1266
|
+
/** Convert records to string arrays for table */
|
|
1267
|
+
function toRows(records, groups) {
|
|
1268
|
+
const allColumns = groups.flatMap((group) => group.columns);
|
|
1269
|
+
return records.map((record) => allColumns.map((col) => {
|
|
1270
|
+
const value = record[col.key];
|
|
1271
|
+
return col.formatter ? col.formatter(value) : value;
|
|
1272
|
+
})).map((row) => row.map((cell) => cell ?? " "));
|
|
1273
|
+
}
|
|
1274
|
+
/** Add comparison values for diff columns */
|
|
1275
|
+
function addComparisons(groups, mainRecord, baselineRecord) {
|
|
1276
|
+
const diffColumns = groups.flatMap((g) => g.columns).filter((col) => col.diffKey);
|
|
1277
|
+
const updatedMain = { ...mainRecord };
|
|
1278
|
+
for (const col of diffColumns) {
|
|
1279
|
+
const dcol = col;
|
|
1280
|
+
const diffKey = dcol.diffKey;
|
|
1281
|
+
const mainValue = mainRecord[diffKey];
|
|
1282
|
+
const baselineValue = baselineRecord[diffKey];
|
|
1283
|
+
const diffStr = (dcol.diffFormatter ?? diffPercent)(mainValue, baselineValue);
|
|
1284
|
+
updatedMain[col.key] = diffStr;
|
|
1285
|
+
}
|
|
1286
|
+
return updatedMain;
|
|
1287
|
+
}
|
|
1288
|
+
/** Flatten groups with spacing */
|
|
1289
|
+
function flattenGroups(columnGroups, resultGroups, nameKey) {
|
|
1290
|
+
return resultGroups.flatMap((group, i) => {
|
|
1291
|
+
const groupRecords = addBaseline(columnGroups, group, nameKey);
|
|
1292
|
+
return i === resultGroups.length - 1 ? groupRecords : [...groupRecords, {}];
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
/** Process results with baseline comparisons */
|
|
1296
|
+
function addBaseline(columnGroups, group, nameKey) {
|
|
1297
|
+
const { results, baseline } = group;
|
|
1298
|
+
if (!baseline) return results;
|
|
1299
|
+
const diffResults = results.map((result) => addComparisons(columnGroups, result, baseline));
|
|
1300
|
+
const markedBaseline = {
|
|
1301
|
+
...baseline,
|
|
1302
|
+
[nameKey]: `--> ${baseline[nameKey]}`
|
|
1303
|
+
};
|
|
1304
|
+
return [...diffResults, markedBaseline];
|
|
1305
|
+
}
|
|
1306
|
+
/** Calculate vertical lines between sections and header bottom position */
|
|
1307
|
+
function calcBorders(groups) {
|
|
1308
|
+
if (groups.length === 0) return {
|
|
1309
|
+
sectionBorders: [],
|
|
1310
|
+
headerBottom: 1
|
|
1311
|
+
};
|
|
1312
|
+
const sectionBorders = [];
|
|
1313
|
+
let border = 0;
|
|
1314
|
+
for (const g of groups) {
|
|
1315
|
+
border += g.columns.length;
|
|
1316
|
+
sectionBorders.push(border);
|
|
1317
|
+
}
|
|
1318
|
+
return {
|
|
1319
|
+
sectionBorders,
|
|
1320
|
+
headerBottom: 3
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
/** Create headers and table configuration */
|
|
1324
|
+
function setup(groups, dataRows) {
|
|
1325
|
+
const titles = getTitles(groups);
|
|
1326
|
+
const numColumns = titles.length;
|
|
1327
|
+
return {
|
|
1328
|
+
headerRows: [...createGroupHeaders(groups, numColumns), titles],
|
|
1329
|
+
config: {
|
|
1330
|
+
spanningCells: createSectionSpans(groups),
|
|
1331
|
+
columns: calcColumnWidths(groups, titles, dataRows),
|
|
1332
|
+
...createLines(groups)
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
/** Calculate column widths based on content, including group titles */
|
|
1337
|
+
function calcColumnWidths(groups, titles, dataRows) {
|
|
1338
|
+
const widths = [];
|
|
1339
|
+
for (let i = 0; i < titles.length; i++) {
|
|
1340
|
+
const titleW = cellWidth(titles[i]);
|
|
1341
|
+
const maxDataW = dataRows.reduce((max, row) => Math.max(max, cellWidth(row[i])), 0);
|
|
1342
|
+
widths.push(Math.max(titleW, maxDataW));
|
|
1343
|
+
}
|
|
1344
|
+
let colIndex = 0;
|
|
1345
|
+
for (const group of groups) {
|
|
1346
|
+
const groupW = cellWidth(group.groupTitle);
|
|
1347
|
+
if (groupW > 0) {
|
|
1348
|
+
const numCols = group.columns.length;
|
|
1349
|
+
const separatorWidth = (numCols - 1) * 3;
|
|
1350
|
+
const needed = groupW - widths.slice(colIndex, colIndex + numCols).reduce((a, b) => a + b, 0) - separatorWidth;
|
|
1351
|
+
if (needed > 0) widths[colIndex + numCols - 1] += needed;
|
|
1352
|
+
}
|
|
1353
|
+
colIndex += group.columns.length;
|
|
1354
|
+
}
|
|
1355
|
+
return Object.fromEntries(widths.map((w, i) => [i, {
|
|
1356
|
+
width: w,
|
|
1357
|
+
wrapWord: false
|
|
1358
|
+
}]));
|
|
1359
|
+
}
|
|
1360
|
+
const ansiEscapeRegex = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
|
|
1361
|
+
/** Get visible length of a cell value (strips ANSI escape codes) */
|
|
1362
|
+
function cellWidth(value) {
|
|
1363
|
+
if (value == null) return 0;
|
|
1364
|
+
return String(value).replace(ansiEscapeRegex, "").length;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
//#endregion
|
|
1368
|
+
//#region src/BenchmarkReport.ts
|
|
1369
|
+
/** @return formatted table report with optional baseline comparisons */
|
|
1370
|
+
function reportResults(groups, sections) {
|
|
1371
|
+
const results = groups.map((group) => resultGroupValues(group, sections));
|
|
1372
|
+
return buildTable(createColumnGroups(sections, results.some((g) => g.baseline)), results);
|
|
1373
|
+
}
|
|
1374
|
+
/** @return values for report group */
|
|
1375
|
+
function resultGroupValues(group, sections) {
|
|
1376
|
+
const { reports, baseline } = group;
|
|
1377
|
+
const baselineSamples = baseline?.measuredResults.samples;
|
|
1378
|
+
return {
|
|
1379
|
+
results: reports.map((report) => {
|
|
1380
|
+
const row = {
|
|
1381
|
+
name: truncate(report.name),
|
|
1382
|
+
...extractReportValues(report, sections)
|
|
1383
|
+
};
|
|
1384
|
+
if (baselineSamples && report.measuredResults.samples) row.diffCI = bootstrapDifferenceCI(baselineSamples, report.measuredResults.samples);
|
|
1385
|
+
return row;
|
|
1386
|
+
}),
|
|
1387
|
+
baseline: baseline && valuesForReports([baseline], sections)[0]
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
/** @return rows with stats from sections */
|
|
1391
|
+
function valuesForReports(reports, sections) {
|
|
1392
|
+
return reports.map((report) => ({
|
|
1393
|
+
name: truncate(report.name),
|
|
1394
|
+
...extractReportValues(report, sections)
|
|
1395
|
+
}));
|
|
1396
|
+
}
|
|
1397
|
+
/** @return merged statistics from all sections */
|
|
1398
|
+
function extractReportValues(report, sections) {
|
|
1399
|
+
const { measuredResults, metadata } = report;
|
|
1400
|
+
const entries = sections.flatMap((s) => Object.entries(s.extract(measuredResults, metadata)));
|
|
1401
|
+
return Object.fromEntries(entries);
|
|
1402
|
+
}
|
|
1403
|
+
/** @return column groups with diff columns if baseline exists */
|
|
1404
|
+
function createColumnGroups(sections, hasBaseline) {
|
|
1405
|
+
const nameColumn = { columns: [{
|
|
1406
|
+
key: "name",
|
|
1407
|
+
title: "name"
|
|
1408
|
+
}] };
|
|
1409
|
+
const groups = sections.flatMap((section) => section.columns());
|
|
1410
|
+
return [nameColumn, ...hasBaseline ? injectDiffColumns(groups) : groups];
|
|
1411
|
+
}
|
|
1412
|
+
/** @return groups with single CI column after first comparable field */
|
|
1413
|
+
function injectDiffColumns(reportGroups) {
|
|
1414
|
+
let ciAdded = false;
|
|
1415
|
+
return reportGroups.map((group) => ({
|
|
1416
|
+
groupTitle: group.groupTitle,
|
|
1417
|
+
columns: group.columns.flatMap((col) => {
|
|
1418
|
+
if (col.comparable && !ciAdded) {
|
|
1419
|
+
ciAdded = true;
|
|
1420
|
+
return [col, {
|
|
1421
|
+
title: "Δ% CI",
|
|
1422
|
+
key: "diffCI",
|
|
1423
|
+
formatter: col.higherIsBetter ? formatDiffWithCIHigherIsBetter : formatDiffWithCI
|
|
1424
|
+
}];
|
|
1425
|
+
}
|
|
1426
|
+
return [col];
|
|
1427
|
+
})
|
|
1428
|
+
}));
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
//#endregion
|
|
1432
|
+
//#region src/cli/CliArgs.ts
|
|
1433
|
+
const defaultTime = .642;
|
|
1434
|
+
const defaultAdaptiveMaxTime = 20;
|
|
1435
|
+
const defaultPauseInterval = 0;
|
|
1436
|
+
const defaultPauseDuration = 100;
|
|
1437
|
+
const cliOptions = {
|
|
1438
|
+
time: {
|
|
1439
|
+
type: "number",
|
|
1440
|
+
default: defaultTime,
|
|
1441
|
+
requiresArg: true,
|
|
1442
|
+
describe: "test duration in seconds"
|
|
1443
|
+
},
|
|
1444
|
+
cpu: {
|
|
1445
|
+
type: "boolean",
|
|
1446
|
+
default: false,
|
|
1447
|
+
describe: "CPU counter measurements (requires root)"
|
|
1448
|
+
},
|
|
1449
|
+
collect: {
|
|
1450
|
+
type: "boolean",
|
|
1451
|
+
default: false,
|
|
1452
|
+
describe: "force GC after each iteration"
|
|
1453
|
+
},
|
|
1454
|
+
"gc-stats": {
|
|
1455
|
+
type: "boolean",
|
|
1456
|
+
default: false,
|
|
1457
|
+
describe: "collect GC statistics (Node: --trace-gc-nvp, browser: CDP tracing)"
|
|
1458
|
+
},
|
|
1459
|
+
profile: {
|
|
1460
|
+
type: "boolean",
|
|
1461
|
+
default: false,
|
|
1462
|
+
describe: "run once for profiling"
|
|
1463
|
+
},
|
|
1464
|
+
filter: {
|
|
1465
|
+
type: "string",
|
|
1466
|
+
requiresArg: true,
|
|
1467
|
+
describe: "filter benchmarks by regex or substring"
|
|
1468
|
+
},
|
|
1469
|
+
all: {
|
|
1470
|
+
type: "boolean",
|
|
1471
|
+
default: false,
|
|
1472
|
+
describe: "run all cases (ignore defaultCases)"
|
|
1473
|
+
},
|
|
1474
|
+
worker: {
|
|
1475
|
+
type: "boolean",
|
|
1476
|
+
default: true,
|
|
1477
|
+
describe: "run in worker process for isolation (default: true)"
|
|
1478
|
+
},
|
|
1479
|
+
adaptive: {
|
|
1480
|
+
type: "boolean",
|
|
1481
|
+
default: false,
|
|
1482
|
+
describe: "use adaptive sampling mode"
|
|
1483
|
+
},
|
|
1484
|
+
"min-time": {
|
|
1485
|
+
type: "number",
|
|
1486
|
+
default: 1,
|
|
1487
|
+
describe: "minimum time in seconds before adaptive convergence can stop"
|
|
1488
|
+
},
|
|
1489
|
+
convergence: {
|
|
1490
|
+
type: "number",
|
|
1491
|
+
default: 95,
|
|
1492
|
+
describe: "confidence threshold (0-100)"
|
|
1493
|
+
},
|
|
1494
|
+
warmup: {
|
|
1495
|
+
type: "number",
|
|
1496
|
+
default: 0,
|
|
1497
|
+
describe: "warmup iterations before measurement"
|
|
1498
|
+
},
|
|
1499
|
+
html: {
|
|
1500
|
+
type: "boolean",
|
|
1501
|
+
default: false,
|
|
1502
|
+
describe: "generate HTML report and open in browser"
|
|
1503
|
+
},
|
|
1504
|
+
"export-html": {
|
|
1505
|
+
type: "string",
|
|
1506
|
+
requiresArg: true,
|
|
1507
|
+
describe: "export HTML report to specified file"
|
|
1508
|
+
},
|
|
1509
|
+
json: {
|
|
1510
|
+
type: "string",
|
|
1511
|
+
requiresArg: true,
|
|
1512
|
+
describe: "export benchmark data to JSON file"
|
|
1513
|
+
},
|
|
1514
|
+
perfetto: {
|
|
1515
|
+
type: "string",
|
|
1516
|
+
requiresArg: true,
|
|
1517
|
+
describe: "export Perfetto trace file (view at ui.perfetto.dev)"
|
|
1518
|
+
},
|
|
1519
|
+
"trace-opt": {
|
|
1520
|
+
type: "boolean",
|
|
1521
|
+
default: false,
|
|
1522
|
+
describe: "trace V8 optimization tiers (requires --allow-natives-syntax)"
|
|
1523
|
+
},
|
|
1524
|
+
"skip-settle": {
|
|
1525
|
+
type: "boolean",
|
|
1526
|
+
default: false,
|
|
1527
|
+
describe: "skip post-warmup settle time (see V8 optimization cold start)"
|
|
1528
|
+
},
|
|
1529
|
+
"pause-first": {
|
|
1530
|
+
type: "number",
|
|
1531
|
+
describe: "iterations before first pause (then pause-interval applies)"
|
|
1532
|
+
},
|
|
1533
|
+
"pause-interval": {
|
|
1534
|
+
type: "number",
|
|
1535
|
+
default: defaultPauseInterval,
|
|
1536
|
+
describe: "iterations between pauses for V8 optimization (0 to disable)"
|
|
1537
|
+
},
|
|
1538
|
+
"pause-duration": {
|
|
1539
|
+
type: "number",
|
|
1540
|
+
default: defaultPauseDuration,
|
|
1541
|
+
describe: "pause duration in ms for V8 optimization"
|
|
1542
|
+
},
|
|
1543
|
+
batches: {
|
|
1544
|
+
type: "number",
|
|
1545
|
+
default: 1,
|
|
1546
|
+
describe: "divide time into N batches, alternating baseline/current order"
|
|
1547
|
+
},
|
|
1548
|
+
iterations: {
|
|
1549
|
+
type: "number",
|
|
1550
|
+
requiresArg: true,
|
|
1551
|
+
describe: "exact number of iterations (overrides --time)"
|
|
1552
|
+
},
|
|
1553
|
+
"heap-sample": {
|
|
1554
|
+
type: "boolean",
|
|
1555
|
+
default: false,
|
|
1556
|
+
describe: "heap sampling allocation attribution (includes garbage)"
|
|
1557
|
+
},
|
|
1558
|
+
"heap-interval": {
|
|
1559
|
+
type: "number",
|
|
1560
|
+
default: 32768,
|
|
1561
|
+
describe: "heap sampling interval in bytes"
|
|
1562
|
+
},
|
|
1563
|
+
"heap-depth": {
|
|
1564
|
+
type: "number",
|
|
1565
|
+
default: 64,
|
|
1566
|
+
describe: "heap sampling stack depth"
|
|
1567
|
+
},
|
|
1568
|
+
"heap-rows": {
|
|
1569
|
+
type: "number",
|
|
1570
|
+
default: 20,
|
|
1571
|
+
describe: "top allocation sites to show"
|
|
1572
|
+
},
|
|
1573
|
+
"heap-stack": {
|
|
1574
|
+
type: "number",
|
|
1575
|
+
default: 3,
|
|
1576
|
+
describe: "call stack depth to display"
|
|
1577
|
+
},
|
|
1578
|
+
"heap-verbose": {
|
|
1579
|
+
type: "boolean",
|
|
1580
|
+
default: false,
|
|
1581
|
+
describe: "verbose output with file:// paths and line numbers"
|
|
1582
|
+
},
|
|
1583
|
+
"heap-user-only": {
|
|
1584
|
+
type: "boolean",
|
|
1585
|
+
default: false,
|
|
1586
|
+
describe: "filter to user code only (hide node internals)"
|
|
1587
|
+
},
|
|
1588
|
+
url: {
|
|
1589
|
+
type: "string",
|
|
1590
|
+
requiresArg: true,
|
|
1591
|
+
describe: "page URL for browser profiling (enables browser mode)"
|
|
1592
|
+
},
|
|
1593
|
+
headless: {
|
|
1594
|
+
type: "boolean",
|
|
1595
|
+
default: true,
|
|
1596
|
+
describe: "run browser in headless mode"
|
|
1597
|
+
},
|
|
1598
|
+
timeout: {
|
|
1599
|
+
type: "number",
|
|
1600
|
+
default: 60,
|
|
1601
|
+
describe: "browser page timeout in seconds"
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
/** @return yargs with standard benchmark options */
|
|
1605
|
+
function defaultCliArgs(yargsInstance) {
|
|
1606
|
+
return yargsInstance.options(cliOptions).help().strict();
|
|
1607
|
+
}
|
|
1608
|
+
/** @return parsed command line arguments */
|
|
1609
|
+
function parseCliArgs(args, configure = defaultCliArgs) {
|
|
1610
|
+
return configure(yargs(args)).parseSync();
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
//#endregion
|
|
1614
|
+
//#region src/browser/BrowserGcStats.ts
|
|
1615
|
+
/** Parse CDP trace events (MinorGC/MajorGC) into GcEvent[] */
|
|
1616
|
+
function parseGcTraceEvents(traceEvents) {
|
|
1617
|
+
return traceEvents.flatMap((e) => {
|
|
1618
|
+
if (e.ph !== "X") return [];
|
|
1619
|
+
const type = gcType(e.name);
|
|
1620
|
+
if (!type) return [];
|
|
1621
|
+
const durUs = e.dur ?? 0;
|
|
1622
|
+
const heapBefore = e.args?.usedHeapSizeBefore ?? 0;
|
|
1623
|
+
const heapAfter = e.args?.usedHeapSizeAfter ?? 0;
|
|
1624
|
+
return [{
|
|
1625
|
+
type,
|
|
1626
|
+
pauseMs: durUs / 1e3,
|
|
1627
|
+
collected: Math.max(0, heapBefore - heapAfter)
|
|
1628
|
+
}];
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
function gcType(name) {
|
|
1632
|
+
if (name === "MinorGC") return "scavenge";
|
|
1633
|
+
if (name === "MajorGC") return "mark-compact";
|
|
1634
|
+
}
|
|
1635
|
+
/** Parse CDP trace events and aggregate into GcStats */
|
|
1636
|
+
function browserGcStats(traceEvents) {
|
|
1637
|
+
return aggregateGcStats(parseGcTraceEvents(traceEvents));
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region src/browser/BrowserHeapSampler.ts
|
|
1642
|
+
/** Run browser benchmark, auto-detecting page API mode.
|
|
1643
|
+
* Bench function (window.__bench): CLI controls iteration and timing.
|
|
1644
|
+
* Lap mode (__start/__lap/__done): page controls the measured region. */
|
|
1645
|
+
async function profileBrowser(params) {
|
|
1646
|
+
const { url, headless = true, timeout = 60 } = params;
|
|
1647
|
+
const { gcStats: collectGc } = params;
|
|
1648
|
+
const { samplingInterval = 32768 } = params.heapOptions ?? {};
|
|
1649
|
+
const browser = await chromium.launch({ headless });
|
|
1650
|
+
try {
|
|
1651
|
+
const page = await browser.newPage();
|
|
1652
|
+
page.setDefaultTimeout(timeout * 1e3);
|
|
1653
|
+
const cdp = await page.context().newCDPSession(page);
|
|
1654
|
+
const pageErrors = [];
|
|
1655
|
+
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
1656
|
+
const traceEvents = collectGc ? await startGcTracing(cdp) : [];
|
|
1657
|
+
const lapMode = await setupLapMode(page, cdp, params, samplingInterval, timeout, pageErrors);
|
|
1658
|
+
await page.goto(url, { waitUntil: "load" });
|
|
1659
|
+
const hasBench = await page.evaluate(() => typeof globalThis.__bench === "function");
|
|
1660
|
+
let result;
|
|
1661
|
+
if (hasBench) {
|
|
1662
|
+
lapMode.cancel();
|
|
1663
|
+
lapMode.promise.catch(() => {});
|
|
1664
|
+
result = await runBenchLoop(page, cdp, params, samplingInterval);
|
|
1665
|
+
} else {
|
|
1666
|
+
result = await lapMode.promise;
|
|
1667
|
+
lapMode.cancel();
|
|
1668
|
+
}
|
|
1669
|
+
if (collectGc) result = {
|
|
1670
|
+
...result,
|
|
1671
|
+
gcStats: await collectTracing(cdp, traceEvents)
|
|
1672
|
+
};
|
|
1673
|
+
return result;
|
|
1674
|
+
} finally {
|
|
1675
|
+
await browser.close();
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
/** Inject __start/__lap as in-page functions, expose __done for results collection.
|
|
1679
|
+
* __start/__lap are pure in-page (zero CDP overhead). First __start() triggers
|
|
1680
|
+
* instrument start. __done() stops instruments and collects timing data. */
|
|
1681
|
+
async function setupLapMode(page, cdp, params, samplingInterval, timeout, pageErrors) {
|
|
1682
|
+
const { heapSample } = params;
|
|
1683
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1684
|
+
let instrumentsStarted = false;
|
|
1685
|
+
await page.exposeFunction("__benchInstrumentStart", async () => {
|
|
1686
|
+
if (instrumentsStarted) return;
|
|
1687
|
+
instrumentsStarted = true;
|
|
1688
|
+
if (heapSample) await cdp.send("HeapProfiler.startSampling", heapSamplingParams(samplingInterval));
|
|
1689
|
+
});
|
|
1690
|
+
await page.exposeFunction("__benchCollect", async (samples, wallTimeMs) => {
|
|
1691
|
+
let heapProfile;
|
|
1692
|
+
if (heapSample && instrumentsStarted) heapProfile = (await cdp.send("HeapProfiler.stopSampling")).profile;
|
|
1693
|
+
resolve({
|
|
1694
|
+
samples,
|
|
1695
|
+
heapProfile,
|
|
1696
|
+
wallTimeMs
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
await page.addInitScript(injectLapFunctions);
|
|
1700
|
+
const timer = setTimeout(() => {
|
|
1701
|
+
const lines = [`Timed out after ${timeout}s`];
|
|
1702
|
+
if (pageErrors.length) lines.push("Page JS errors:", ...pageErrors.map((e) => ` ${e}`));
|
|
1703
|
+
else lines.push("Page did not call __done() or define window.__bench");
|
|
1704
|
+
reject(new Error(lines.join("\n")));
|
|
1705
|
+
}, timeout * 1e3);
|
|
1706
|
+
return {
|
|
1707
|
+
promise,
|
|
1708
|
+
cancel: () => clearTimeout(timer)
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
/** In-page timing functions injected via addInitScript (zero CDP overhead).
|
|
1712
|
+
* __start/__lap collect timestamps, __done delegates to exposed __benchCollect. */
|
|
1713
|
+
function injectLapFunctions() {
|
|
1714
|
+
const g = globalThis;
|
|
1715
|
+
g.__benchSamples = [];
|
|
1716
|
+
g.__benchLastTime = 0;
|
|
1717
|
+
g.__benchFirstStart = 0;
|
|
1718
|
+
g.__start = () => {
|
|
1719
|
+
const now = performance.now();
|
|
1720
|
+
g.__benchLastTime = now;
|
|
1721
|
+
if (!g.__benchFirstStart) {
|
|
1722
|
+
g.__benchFirstStart = now;
|
|
1723
|
+
return g.__benchInstrumentStart();
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
g.__lap = () => {
|
|
1727
|
+
const now = performance.now();
|
|
1728
|
+
g.__benchSamples.push(now - g.__benchLastTime);
|
|
1729
|
+
g.__benchLastTime = now;
|
|
1730
|
+
};
|
|
1731
|
+
g.__done = () => {
|
|
1732
|
+
const wall = g.__benchFirstStart ? performance.now() - g.__benchFirstStart : 0;
|
|
1733
|
+
return g.__benchCollect(g.__benchSamples.slice(), wall);
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
function heapSamplingParams(samplingInterval) {
|
|
1737
|
+
return {
|
|
1738
|
+
samplingInterval,
|
|
1739
|
+
includeObjectsCollectedByMajorGC: true,
|
|
1740
|
+
includeObjectsCollectedByMinorGC: true
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
/** Start CDP GC tracing, returns the event collector array. */
|
|
1744
|
+
async function startGcTracing(cdp) {
|
|
1745
|
+
const events = [];
|
|
1746
|
+
cdp.on("Tracing.dataCollected", ({ value }) => {
|
|
1747
|
+
for (const e of value) events.push(e);
|
|
1748
|
+
});
|
|
1749
|
+
await cdp.send("Tracing.start", { traceConfig: { includedCategories: ["v8", "v8.gc"] } });
|
|
1750
|
+
return events;
|
|
1751
|
+
}
|
|
1752
|
+
/** Bench function mode: run window.__bench in a timed iteration loop. */
|
|
1753
|
+
async function runBenchLoop(page, cdp, params, samplingInterval) {
|
|
1754
|
+
const { heapSample } = params;
|
|
1755
|
+
const maxTime = params.maxTime ?? 642;
|
|
1756
|
+
const maxIter = params.maxIterations ?? Number.MAX_SAFE_INTEGER;
|
|
1757
|
+
if (heapSample) await cdp.send("HeapProfiler.startSampling", heapSamplingParams(samplingInterval));
|
|
1758
|
+
const { samples, totalMs } = await page.evaluate(async ({ maxTime, maxIter }) => {
|
|
1759
|
+
const bench = globalThis.__bench;
|
|
1760
|
+
const samples = [];
|
|
1761
|
+
const startAll = performance.now();
|
|
1762
|
+
const deadline = startAll + maxTime;
|
|
1763
|
+
for (let i = 0; i < maxIter && performance.now() < deadline; i++) {
|
|
1764
|
+
const t0 = performance.now();
|
|
1765
|
+
await bench();
|
|
1766
|
+
samples.push(performance.now() - t0);
|
|
1767
|
+
}
|
|
1768
|
+
return {
|
|
1769
|
+
samples,
|
|
1770
|
+
totalMs: performance.now() - startAll
|
|
1771
|
+
};
|
|
1772
|
+
}, {
|
|
1773
|
+
maxTime,
|
|
1774
|
+
maxIter
|
|
1775
|
+
});
|
|
1776
|
+
let heapProfile;
|
|
1777
|
+
if (heapSample) heapProfile = (await cdp.send("HeapProfiler.stopSampling")).profile;
|
|
1778
|
+
return {
|
|
1779
|
+
samples,
|
|
1780
|
+
heapProfile,
|
|
1781
|
+
wallTimeMs: totalMs
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
/** Stop CDP tracing and parse GC events into GcStats. */
|
|
1785
|
+
async function collectTracing(cdp, traceEvents) {
|
|
1786
|
+
const complete = new Promise((resolve) => cdp.once("Tracing.tracingComplete", () => resolve()));
|
|
1787
|
+
await cdp.send("Tracing.end");
|
|
1788
|
+
await complete;
|
|
1789
|
+
return browserGcStats(traceEvents);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
//#endregion
|
|
1793
|
+
//#region src/export/JsonExport.ts
|
|
1794
|
+
/** Export benchmark results to JSON file */
|
|
1795
|
+
async function exportBenchmarkJson(groups, outputPath, args, suiteName = "Benchmark Suite") {
|
|
1796
|
+
const jsonData = prepareJsonData(groups, args, suiteName);
|
|
1797
|
+
await writeFile(outputPath, JSON.stringify(jsonData, null, 2), "utf-8");
|
|
1798
|
+
console.log(`Benchmark data exported to: ${outputPath}`);
|
|
1799
|
+
}
|
|
1800
|
+
/** Convert ReportGroup data to JSON format */
|
|
1801
|
+
function prepareJsonData(groups, args, suiteName) {
|
|
1802
|
+
return {
|
|
1803
|
+
meta: {
|
|
1804
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1805
|
+
version: process.env.npm_package_version || "unknown",
|
|
1806
|
+
args: cleanCliArgs(args),
|
|
1807
|
+
environment: {
|
|
1808
|
+
node: process.version,
|
|
1809
|
+
platform: process.platform,
|
|
1810
|
+
arch: process.arch
|
|
1811
|
+
}
|
|
1812
|
+
},
|
|
1813
|
+
suites: [{
|
|
1814
|
+
name: suiteName,
|
|
1815
|
+
groups: groups.map(convertGroup)
|
|
1816
|
+
}]
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
/** Convert a report group, mapping each report to the JSON result format */
|
|
1820
|
+
function convertGroup(group) {
|
|
1821
|
+
return {
|
|
1822
|
+
name: "Benchmark Group",
|
|
1823
|
+
baseline: group.baseline ? convertReport(group.baseline) : void 0,
|
|
1824
|
+
benchmarks: group.reports.map(convertReport)
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
/** Extract measured stats and optional metrics into JSON result shape */
|
|
1828
|
+
function convertReport(report) {
|
|
1829
|
+
const { name, measuredResults: m } = report;
|
|
1830
|
+
const { time, heapSize, gcTime, cpu } = m;
|
|
1831
|
+
const minMaxMean = (s) => s ? {
|
|
1832
|
+
min: s.min,
|
|
1833
|
+
max: s.max,
|
|
1834
|
+
mean: s.avg
|
|
1835
|
+
} : void 0;
|
|
1836
|
+
return {
|
|
1837
|
+
name,
|
|
1838
|
+
status: "completed",
|
|
1839
|
+
samples: m.samples || [],
|
|
1840
|
+
time: {
|
|
1841
|
+
...minMaxMean(time),
|
|
1842
|
+
p50: time.p50,
|
|
1843
|
+
p75: time.p75,
|
|
1844
|
+
p99: time.p99,
|
|
1845
|
+
p999: time.p999
|
|
1846
|
+
},
|
|
1847
|
+
heapSize: minMaxMean(heapSize),
|
|
1848
|
+
gcTime: minMaxMean(gcTime),
|
|
1849
|
+
cpu: cpu ? {
|
|
1850
|
+
instructions: cpu.instructions,
|
|
1851
|
+
cycles: cpu.cycles,
|
|
1852
|
+
cacheMisses: m.cpuCacheMiss,
|
|
1853
|
+
branchMisses: cpu.branchMisses
|
|
1854
|
+
} : void 0,
|
|
1855
|
+
execution: {
|
|
1856
|
+
iterations: m.samples?.length || 0,
|
|
1857
|
+
totalTime: m.totalTime || 0,
|
|
1858
|
+
warmupRuns: void 0
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
/** Clean CLI args for JSON export (remove undefined values) */
|
|
1863
|
+
function cleanCliArgs(args) {
|
|
1864
|
+
const toCamel = (k) => k.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
|
|
1865
|
+
const entries = Object.entries(args).filter(([, v]) => v !== void 0 && v !== null).map(([k, v]) => [toCamel(k), v]);
|
|
1866
|
+
return Object.fromEntries(entries);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
//#endregion
|
|
1870
|
+
//#region src/export/PerfettoExport.ts
|
|
1871
|
+
const pid = 1;
|
|
1872
|
+
const tid = 1;
|
|
1873
|
+
/** Export benchmark results to Perfetto-compatible trace file */
|
|
1874
|
+
function exportPerfettoTrace(groups, outputPath, args) {
|
|
1875
|
+
const absPath = resolve(outputPath);
|
|
1876
|
+
writeTraceFile(absPath, mergeV8Trace(buildTraceEvents(groups, args)));
|
|
1877
|
+
console.log(`Perfetto trace exported to: ${outputPath}`);
|
|
1878
|
+
scheduleDeferredMerge(absPath);
|
|
1879
|
+
}
|
|
1880
|
+
/** Build trace events from benchmark results */
|
|
1881
|
+
function buildTraceEvents(groups, args) {
|
|
1882
|
+
const meta = (name, a) => ({
|
|
1883
|
+
ph: "M",
|
|
1884
|
+
ts: 0,
|
|
1885
|
+
pid,
|
|
1886
|
+
tid,
|
|
1887
|
+
name,
|
|
1888
|
+
args: a
|
|
1889
|
+
});
|
|
1890
|
+
const events = [
|
|
1891
|
+
meta("process_name", { name: "wesl-bench" }),
|
|
1892
|
+
meta("thread_name", { name: "MainThread" }),
|
|
1893
|
+
meta("bench_settings", cleanArgs(args))
|
|
1894
|
+
];
|
|
1895
|
+
for (const group of groups) for (const report of group.reports) {
|
|
1896
|
+
const results = report.measuredResults;
|
|
1897
|
+
events.push(...buildBenchmarkEvents(results));
|
|
1898
|
+
}
|
|
1899
|
+
return events;
|
|
1900
|
+
}
|
|
1901
|
+
function instant(ts, name, args) {
|
|
1902
|
+
return {
|
|
1903
|
+
ph: "i",
|
|
1904
|
+
ts,
|
|
1905
|
+
pid,
|
|
1906
|
+
tid,
|
|
1907
|
+
cat: "bench",
|
|
1908
|
+
name,
|
|
1909
|
+
s: "t",
|
|
1910
|
+
args
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
function counter(ts, name, args) {
|
|
1914
|
+
return {
|
|
1915
|
+
ph: "C",
|
|
1916
|
+
ts,
|
|
1917
|
+
pid,
|
|
1918
|
+
tid,
|
|
1919
|
+
cat: "bench",
|
|
1920
|
+
name,
|
|
1921
|
+
args
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
/** Build events for a single benchmark run */
|
|
1925
|
+
function buildBenchmarkEvents(results) {
|
|
1926
|
+
const { samples, heapSamples, timestamps, pausePoints } = results;
|
|
1927
|
+
if (!timestamps?.length) return [];
|
|
1928
|
+
const events = [];
|
|
1929
|
+
for (let i = 0; i < samples.length; i++) {
|
|
1930
|
+
const ts = timestamps[i];
|
|
1931
|
+
const ms = Math.round(samples[i] * 100) / 100;
|
|
1932
|
+
events.push(instant(ts, results.name, {
|
|
1933
|
+
n: i,
|
|
1934
|
+
ms
|
|
1935
|
+
}));
|
|
1936
|
+
events.push(counter(ts, "duration", { ms }));
|
|
1937
|
+
if (heapSamples?.[i] !== void 0) {
|
|
1938
|
+
const MB = Math.round(heapSamples[i] / 1024 / 1024 * 10) / 10;
|
|
1939
|
+
events.push(counter(ts, "heap", { MB }));
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
for (const pause of pausePoints ?? []) {
|
|
1943
|
+
const ts = timestamps[pause.sampleIndex];
|
|
1944
|
+
if (ts) events.push(instant(ts, "pause", { ms: pause.durationMs }));
|
|
1945
|
+
}
|
|
1946
|
+
return events;
|
|
1947
|
+
}
|
|
1948
|
+
/** Normalize timestamps so events start at 0 */
|
|
1949
|
+
function normalizeTimestamps(events) {
|
|
1950
|
+
const times = events.filter((e) => e.ts > 0).map((e) => e.ts);
|
|
1951
|
+
if (times.length === 0) return;
|
|
1952
|
+
const minTs = Math.min(...times);
|
|
1953
|
+
for (const e of events) if (e.ts > 0) e.ts -= minTs;
|
|
1954
|
+
}
|
|
1955
|
+
/** Merge V8 trace events from a previous run, aligning timestamps */
|
|
1956
|
+
function mergeV8Trace(customEvents) {
|
|
1957
|
+
const v8Events = loadV8Events(readdirSync(".").filter((f) => f.startsWith("node_trace.") && f.endsWith(".log"))[0]);
|
|
1958
|
+
normalizeTimestamps(customEvents);
|
|
1959
|
+
if (!v8Events) return customEvents;
|
|
1960
|
+
normalizeTimestamps(v8Events);
|
|
1961
|
+
return [...v8Events, ...customEvents];
|
|
1962
|
+
}
|
|
1963
|
+
/** Load V8 trace events from file, or undefined if unavailable */
|
|
1964
|
+
function loadV8Events(v8TracePath) {
|
|
1965
|
+
if (!v8TracePath) return void 0;
|
|
1966
|
+
try {
|
|
1967
|
+
const v8Data = JSON.parse(readFileSync(v8TracePath, "utf-8"));
|
|
1968
|
+
console.log(`Merged ${v8Data.traceEvents.length} V8 events from ${v8TracePath}`);
|
|
1969
|
+
return v8Data.traceEvents;
|
|
1970
|
+
} catch {
|
|
1971
|
+
console.warn(`Could not parse V8 trace file: ${v8TracePath}`);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
/** Write trace events to JSON file */
|
|
1976
|
+
function writeTraceFile(outputPath, events) {
|
|
1977
|
+
const traceFile = { traceEvents: events };
|
|
1978
|
+
writeFileSync(outputPath, JSON.stringify(traceFile));
|
|
1979
|
+
}
|
|
1980
|
+
/** Clean CLI args for metadata */
|
|
1981
|
+
function cleanArgs(args) {
|
|
1982
|
+
const skip = new Set(["_", "$0"]);
|
|
1983
|
+
const entries = Object.entries(args).filter(([k, v]) => v !== void 0 && !skip.has(k));
|
|
1984
|
+
return Object.fromEntries(entries);
|
|
1985
|
+
}
|
|
1986
|
+
/** Spawn a detached child to merge V8 trace after process exit */
|
|
1987
|
+
function scheduleDeferredMerge(outputPath) {
|
|
1988
|
+
const cwd = process.cwd();
|
|
1989
|
+
const mergeScript = `
|
|
1990
|
+
const { readdirSync, readFileSync, writeFileSync } = require('fs');
|
|
1991
|
+
function normalize(events) {
|
|
1992
|
+
const times = events.filter(e => e.ts > 0).map(e => e.ts);
|
|
1993
|
+
if (!times.length) return;
|
|
1994
|
+
const min = Math.min(...times);
|
|
1995
|
+
for (const e of events) if (e.ts > 0) e.ts -= min;
|
|
1996
|
+
}
|
|
1997
|
+
setTimeout(() => {
|
|
1998
|
+
const traceFiles = readdirSync('.').filter(f => f.startsWith('node_trace.') && f.endsWith('.log'));
|
|
1999
|
+
if (traceFiles.length === 0) process.exit(0);
|
|
2000
|
+
try {
|
|
2001
|
+
const v8Data = JSON.parse(readFileSync(traceFiles[0], 'utf-8'));
|
|
2002
|
+
const ourData = JSON.parse(readFileSync('${outputPath}', 'utf-8'));
|
|
2003
|
+
normalize(v8Data.traceEvents);
|
|
2004
|
+
const merged = { traceEvents: [...v8Data.traceEvents, ...ourData.traceEvents] };
|
|
2005
|
+
writeFileSync('${outputPath}', JSON.stringify(merged));
|
|
2006
|
+
console.log('Merged ' + v8Data.traceEvents.length + ' V8 events into ' + '${outputPath}');
|
|
2007
|
+
} catch (e) { console.error('Merge failed:', e.message); }
|
|
2008
|
+
}, 100);
|
|
2009
|
+
`;
|
|
2010
|
+
process.on("exit", () => {
|
|
2011
|
+
spawn("node", ["-e", mergeScript], {
|
|
2012
|
+
detached: true,
|
|
2013
|
+
stdio: "inherit",
|
|
2014
|
+
cwd
|
|
2015
|
+
}).unref();
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
//#endregion
|
|
2020
|
+
//#region src/HtmlDataPrep.ts
|
|
2021
|
+
/** Find higherIsBetter from first comparable column in sections */
|
|
2022
|
+
function findHigherIsBetter(sections) {
|
|
2023
|
+
return (sections?.flatMap((s) => s.columns().flatMap((g) => g.columns)))?.find((c) => c.comparable)?.higherIsBetter ?? false;
|
|
2024
|
+
}
|
|
2025
|
+
/** Flip CI percent for metrics where higher is better (e.g., lines/sec) */
|
|
2026
|
+
function flipCI(ci) {
|
|
2027
|
+
return {
|
|
2028
|
+
percent: -ci.percent,
|
|
2029
|
+
ci: [-ci.ci[1], -ci.ci[0]],
|
|
2030
|
+
direction: ci.direction,
|
|
2031
|
+
histogram: ci.histogram?.map((bin) => ({
|
|
2032
|
+
x: -bin.x,
|
|
2033
|
+
count: bin.count
|
|
2034
|
+
}))
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
/** Prepare ReportData from benchmark results for HTML rendering */
|
|
2038
|
+
function prepareHtmlData(groups, options) {
|
|
2039
|
+
const { cliArgs, sections, currentVersion, baselineVersion } = options;
|
|
2040
|
+
const higherIsBetter = findHigherIsBetter(sections);
|
|
2041
|
+
return {
|
|
2042
|
+
groups: groups.map((group) => prepareGroupData(group, sections, higherIsBetter)),
|
|
2043
|
+
metadata: {
|
|
2044
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2045
|
+
bencherVersion: process.env.npm_package_version || "unknown",
|
|
2046
|
+
cliArgs,
|
|
2047
|
+
gcTrackingEnabled: cliArgs?.["gc-stats"] === true,
|
|
2048
|
+
currentVersion,
|
|
2049
|
+
baselineVersion
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
/** @return group data with bootstrap CI comparisons against baseline */
|
|
2054
|
+
function prepareGroupData(group, sections, higherIsBetter) {
|
|
2055
|
+
const baselineSamples = group.baseline?.measuredResults.samples;
|
|
2056
|
+
return {
|
|
2057
|
+
name: group.name,
|
|
2058
|
+
baseline: group.baseline ? prepareBenchmarkData(group.baseline, sections) : void 0,
|
|
2059
|
+
benchmarks: group.reports.map((report) => {
|
|
2060
|
+
const samples = report.measuredResults.samples;
|
|
2061
|
+
const rawCI = baselineSamples && samples ? bootstrapDifferenceCI(baselineSamples, samples) : void 0;
|
|
2062
|
+
const comparisonCI = rawCI && higherIsBetter ? flipCI(rawCI) : rawCI;
|
|
2063
|
+
return {
|
|
2064
|
+
...prepareBenchmarkData(report, sections),
|
|
2065
|
+
comparisonCI
|
|
2066
|
+
};
|
|
2067
|
+
})
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
/** @return benchmark data with samples, stats, and formatted section values */
|
|
2071
|
+
function prepareBenchmarkData(report, sections) {
|
|
2072
|
+
const { measuredResults } = report;
|
|
2073
|
+
return {
|
|
2074
|
+
name: report.name,
|
|
2075
|
+
samples: measuredResults.samples,
|
|
2076
|
+
warmupSamples: measuredResults.warmupSamples,
|
|
2077
|
+
allocationSamples: measuredResults.allocationSamples,
|
|
2078
|
+
heapSamples: measuredResults.heapSamples,
|
|
2079
|
+
gcEvents: measuredResults.nodeGcTime?.events,
|
|
2080
|
+
optSamples: measuredResults.optSamples,
|
|
2081
|
+
pausePoints: measuredResults.pausePoints,
|
|
2082
|
+
stats: measuredResults.time,
|
|
2083
|
+
heapSize: measuredResults.heapSize,
|
|
2084
|
+
sectionStats: sections ? extractSectionStats(report, sections) : void 0
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
/** @return formatted stats from all sections for tooltip display */
|
|
2088
|
+
function extractSectionStats(report, sections) {
|
|
2089
|
+
return sections.flatMap((section) => {
|
|
2090
|
+
const vals = section.extract(report.measuredResults, report.metadata);
|
|
2091
|
+
return section.columns().flatMap((g) => formatGroupStats(vals, g));
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
/** @return formatted stats for one column group, skipping undefined values */
|
|
2095
|
+
function formatGroupStats(values, group) {
|
|
2096
|
+
return group.columns.map((c) => formatColumnStat(values, c, group.groupTitle)).filter((s) => s !== void 0);
|
|
2097
|
+
}
|
|
2098
|
+
/** @return formatted stat for a single column, or undefined if empty/placeholder */
|
|
2099
|
+
function formatColumnStat(values, col, groupTitle) {
|
|
2100
|
+
const raw = values[col.key];
|
|
2101
|
+
if (raw === void 0) return void 0;
|
|
2102
|
+
const formatted = col.formatter ? col.formatter(raw) : String(raw);
|
|
2103
|
+
if (!formatted || formatted === "—" || formatted === "") return void 0;
|
|
2104
|
+
return {
|
|
2105
|
+
label: col.title,
|
|
2106
|
+
value: formatted,
|
|
2107
|
+
groupTitle
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
//#endregion
|
|
2112
|
+
//#region src/heap-sample/HeapSampleReport.ts
|
|
2113
|
+
/** Sum selfSize across all nodes in profile (before any filtering) */
|
|
2114
|
+
function totalProfileBytes(profile) {
|
|
2115
|
+
let total = 0;
|
|
2116
|
+
function walk(node) {
|
|
2117
|
+
total += node.selfSize;
|
|
2118
|
+
for (const child of node.children || []) walk(child);
|
|
2119
|
+
}
|
|
2120
|
+
walk(profile.head);
|
|
2121
|
+
return total;
|
|
2122
|
+
}
|
|
2123
|
+
/** Flatten profile tree into sorted list of allocation sites with call stacks */
|
|
2124
|
+
function flattenProfile(profile) {
|
|
2125
|
+
const sites = [];
|
|
2126
|
+
function walk(node, stack) {
|
|
2127
|
+
const { functionName, url, lineNumber, columnNumber } = node.callFrame;
|
|
2128
|
+
const fn = functionName || "(anonymous)";
|
|
2129
|
+
const col = columnNumber ?? 0;
|
|
2130
|
+
const frame = {
|
|
2131
|
+
fn,
|
|
2132
|
+
url: url || "",
|
|
2133
|
+
line: lineNumber + 1,
|
|
2134
|
+
col
|
|
2135
|
+
};
|
|
2136
|
+
const newStack = [...stack, frame];
|
|
2137
|
+
if (node.selfSize > 0) sites.push({
|
|
2138
|
+
...frame,
|
|
2139
|
+
bytes: node.selfSize,
|
|
2140
|
+
stack: newStack
|
|
2141
|
+
});
|
|
2142
|
+
for (const child of node.children || []) walk(child, newStack);
|
|
2143
|
+
}
|
|
2144
|
+
walk(profile.head, []);
|
|
2145
|
+
return sites.sort((a, b) => b.bytes - a.bytes);
|
|
2146
|
+
}
|
|
2147
|
+
/** Check if site is user code (not node internals) */
|
|
2148
|
+
function isNodeUserCode(site) {
|
|
2149
|
+
if (!site.url) return false;
|
|
2150
|
+
if (site.url.startsWith("node:")) return false;
|
|
2151
|
+
if (site.url.includes("(native)")) return false;
|
|
2152
|
+
if (site.url.includes("internal/")) return false;
|
|
2153
|
+
return true;
|
|
2154
|
+
}
|
|
2155
|
+
/** Check if site is user code (not browser internals) */
|
|
2156
|
+
function isBrowserUserCode(site) {
|
|
2157
|
+
if (!site.url) return false;
|
|
2158
|
+
if (site.url.startsWith("chrome-extension://")) return false;
|
|
2159
|
+
if (site.url.startsWith("devtools://")) return false;
|
|
2160
|
+
if (site.url.includes("(native)")) return false;
|
|
2161
|
+
return true;
|
|
2162
|
+
}
|
|
2163
|
+
/** Filter sites to user code only */
|
|
2164
|
+
function filterSites(sites, isUser = isNodeUserCode) {
|
|
2165
|
+
return sites.filter(isUser);
|
|
2166
|
+
}
|
|
2167
|
+
/** Aggregate sites by location (combine same file:line:col) */
|
|
2168
|
+
function aggregateSites(sites) {
|
|
2169
|
+
const byLocation = /* @__PURE__ */ new Map();
|
|
2170
|
+
for (const site of sites) {
|
|
2171
|
+
const key = `${site.url}:${site.line}:${site.col}`;
|
|
2172
|
+
const existing = byLocation.get(key);
|
|
2173
|
+
if (existing) existing.bytes += site.bytes;
|
|
2174
|
+
else byLocation.set(key, { ...site });
|
|
2175
|
+
}
|
|
2176
|
+
return [...byLocation.values()].sort((a, b) => b.bytes - a.bytes);
|
|
2177
|
+
}
|
|
2178
|
+
function fmtBytes(bytes) {
|
|
2179
|
+
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
2180
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2181
|
+
return `${bytes} B`;
|
|
2182
|
+
}
|
|
2183
|
+
/** Format heap report for console output */
|
|
2184
|
+
function formatHeapReport(sites, options) {
|
|
2185
|
+
const { topN, stackDepth = 3, verbose = false } = options;
|
|
2186
|
+
const { totalAll, totalUserCode, sampleCount } = options;
|
|
2187
|
+
const isUser = options.isUserCode ?? isNodeUserCode;
|
|
2188
|
+
const lines = [];
|
|
2189
|
+
lines.push(`Heap allocation sites (top ${topN}, garbage included):`);
|
|
2190
|
+
for (const site of sites.slice(0, topN)) if (verbose) formatVerboseSite(lines, site, stackDepth, isUser);
|
|
2191
|
+
else formatCompactSite(lines, site, stackDepth, isUser);
|
|
2192
|
+
lines.push("");
|
|
2193
|
+
if (totalAll !== void 0) lines.push(`Total (all): ${fmtBytes(totalAll)}`);
|
|
2194
|
+
if (totalUserCode !== void 0) lines.push(`Total (user-code): ${fmtBytes(totalUserCode)}`);
|
|
2195
|
+
if (sampleCount !== void 0) lines.push(`Samples: ${sampleCount.toLocaleString()}`);
|
|
2196
|
+
return lines.join("\n");
|
|
2197
|
+
}
|
|
2198
|
+
/** Compact single-line format: `49 MB fn1 <- fn2 <- fn3` */
|
|
2199
|
+
function formatCompactSite(lines, site, stackDepth, isUser) {
|
|
2200
|
+
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
2201
|
+
const fns = [site.fn];
|
|
2202
|
+
if (site.stack && site.stack.length > 1) {
|
|
2203
|
+
const callers = site.stack.slice(0, -1).reverse().slice(0, stackDepth);
|
|
2204
|
+
for (const frame of callers) {
|
|
2205
|
+
if (!frame.url || !isUser(frame)) continue;
|
|
2206
|
+
fns.push(frame.fn);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
const line = `${bytes} ${fns.join(" <- ")}`;
|
|
2210
|
+
lines.push(isUser(site) ? line : pico.dim(line));
|
|
2211
|
+
}
|
|
2212
|
+
/** Verbose multi-line format with file:// paths and line numbers */
|
|
2213
|
+
function formatVerboseSite(lines, site, stackDepth, isUser) {
|
|
2214
|
+
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
2215
|
+
const loc = site.url ? `${site.url}:${site.line}:${site.col}` : "(unknown)";
|
|
2216
|
+
const dimFn = isUser(site) ? (s) => s : pico.dim;
|
|
2217
|
+
lines.push(dimFn(`${bytes} ${site.fn} ${loc}`));
|
|
2218
|
+
if (site.stack && site.stack.length > 1) {
|
|
2219
|
+
const callers = site.stack.slice(0, -1).reverse().slice(0, stackDepth);
|
|
2220
|
+
for (const frame of callers) {
|
|
2221
|
+
if (!frame.url || !isUser(frame)) continue;
|
|
2222
|
+
const callerLoc = `${frame.url}:${frame.line}:${frame.col}`;
|
|
2223
|
+
lines.push(dimFn(` <- ${frame.fn} ${callerLoc}`));
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
//#endregion
|
|
2229
|
+
//#region src/html/HtmlTemplate.ts
|
|
2230
|
+
const skipArgs = new Set([
|
|
2231
|
+
"_",
|
|
2232
|
+
"$0",
|
|
2233
|
+
"html",
|
|
2234
|
+
"export-html"
|
|
2235
|
+
]);
|
|
2236
|
+
/** Format ISO date as local time with UTC: "Jan 9, 2026, 3:45 PM (2026-01-09T23:45:00Z)" */
|
|
2237
|
+
function formatDateWithTimezone(isoDate) {
|
|
2238
|
+
const date = new Date(isoDate);
|
|
2239
|
+
return `${date.toLocaleString("en-US", {
|
|
2240
|
+
month: "short",
|
|
2241
|
+
day: "numeric",
|
|
2242
|
+
year: "numeric",
|
|
2243
|
+
hour: "numeric",
|
|
2244
|
+
minute: "2-digit"
|
|
2245
|
+
})} (${date.toISOString().replace(".000Z", "Z")})`;
|
|
2246
|
+
}
|
|
2247
|
+
/** Format relative time: "5m ago", "2h ago", "yesterday", "3 days ago" */
|
|
2248
|
+
function formatRelativeTime(isoDate) {
|
|
2249
|
+
const date = new Date(isoDate);
|
|
2250
|
+
const diffMs = (/* @__PURE__ */ new Date()).getTime() - date.getTime();
|
|
2251
|
+
const diffMins = Math.floor(diffMs / 6e4);
|
|
2252
|
+
const diffHours = Math.floor(diffMs / 36e5);
|
|
2253
|
+
const diffDays = Math.floor(diffMs / 864e5);
|
|
2254
|
+
if (diffMins < 1) return "just now";
|
|
2255
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
2256
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
2257
|
+
if (diffDays === 1) return "yesterday";
|
|
2258
|
+
if (diffDays < 30) return `${diffDays} days ago`;
|
|
2259
|
+
return date.toLocaleDateString("en-US", {
|
|
2260
|
+
month: "short",
|
|
2261
|
+
day: "numeric"
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
/** Format git version for display: "abc1234* (5m ago)" */
|
|
2265
|
+
function formatVersion(version) {
|
|
2266
|
+
if (!version || version.hash === "unknown") return "unknown";
|
|
2267
|
+
const hashDisplay = version.dirty ? `${version.hash}*` : version.hash;
|
|
2268
|
+
const timeDisplay = version.date ? formatRelativeTime(version.date) : "";
|
|
2269
|
+
return timeDisplay ? `${hashDisplay} (${timeDisplay})` : hashDisplay;
|
|
2270
|
+
}
|
|
2271
|
+
/** Render current/baseline version info as an HTML div */
|
|
2272
|
+
function versionInfoHtml(data) {
|
|
2273
|
+
const { currentVersion, baselineVersion } = data.metadata;
|
|
2274
|
+
if (!currentVersion && !baselineVersion) return "";
|
|
2275
|
+
const parts = [];
|
|
2276
|
+
if (currentVersion) parts.push(`Current: ${formatVersion(currentVersion)}`);
|
|
2277
|
+
if (baselineVersion) parts.push(`Baseline: ${formatVersion(baselineVersion)}`);
|
|
2278
|
+
return `<div class="version-info">${parts.join(" | ")}</div>`;
|
|
2279
|
+
}
|
|
2280
|
+
const badgeLabels = {
|
|
2281
|
+
faster: "Faster",
|
|
2282
|
+
slower: "Slower",
|
|
2283
|
+
uncertain: "Inconclusive"
|
|
2284
|
+
};
|
|
2285
|
+
/** Render faster/slower/uncertain badge with CI plot container */
|
|
2286
|
+
function comparisonBadge(group, groupIndex) {
|
|
2287
|
+
const ci = group.benchmarks[0]?.comparisonCI;
|
|
2288
|
+
if (!ci) return "";
|
|
2289
|
+
const label = badgeLabels[ci.direction];
|
|
2290
|
+
return `
|
|
2291
|
+
<span class="badge badge-${ci.direction}">${label}</span>
|
|
2292
|
+
<div id="ci-plot-${groupIndex}" class="ci-plot-container"></div>
|
|
2293
|
+
`;
|
|
2294
|
+
}
|
|
2295
|
+
const defaultArgs = {
|
|
2296
|
+
worker: true,
|
|
2297
|
+
time: 5,
|
|
2298
|
+
warmup: 500,
|
|
2299
|
+
"pause-interval": 0,
|
|
2300
|
+
"pause-duration": 100
|
|
2301
|
+
};
|
|
2302
|
+
/** @return true if this CLI arg should be hidden from the report header */
|
|
2303
|
+
function shouldSkipArg(key, value, adaptive) {
|
|
2304
|
+
if (skipArgs.has(key) || value === void 0 || value === false) return true;
|
|
2305
|
+
if (defaultArgs[key] === value) return true;
|
|
2306
|
+
if (!key.includes("-") && key !== key.toLowerCase()) return true;
|
|
2307
|
+
if (key === "convergence" && !adaptive) return true;
|
|
2308
|
+
return false;
|
|
2309
|
+
}
|
|
2310
|
+
/** Reconstruct the CLI invocation string, omitting default/internal args */
|
|
2311
|
+
function formatCliArgs(args) {
|
|
2312
|
+
if (!args) return "bb bench";
|
|
2313
|
+
const parts = ["bb bench"];
|
|
2314
|
+
for (const [key, value] of Object.entries(args)) {
|
|
2315
|
+
if (shouldSkipArg(key, value, args.adaptive)) continue;
|
|
2316
|
+
parts.push(value === true ? `--${key}` : `--${key} ${value}`);
|
|
2317
|
+
}
|
|
2318
|
+
return parts.join(" ");
|
|
2319
|
+
}
|
|
2320
|
+
/** Generate complete HTML document with embedded data and visualizations */
|
|
2321
|
+
function generateHtmlDocument(data) {
|
|
2322
|
+
return `<!DOCTYPE html>
|
|
2323
|
+
<html lang="en">
|
|
2324
|
+
<head>
|
|
2325
|
+
<meta charset="UTF-8">
|
|
2326
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2327
|
+
<title>Benchmark Report - ${(/* @__PURE__ */ new Date()).toLocaleDateString()}</title>
|
|
2328
|
+
<style>
|
|
2329
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2330
|
+
body {
|
|
2331
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
2332
|
+
background: #f5f5f5;
|
|
2333
|
+
padding: 20px;
|
|
2334
|
+
line-height: 1.6;
|
|
2335
|
+
}
|
|
2336
|
+
.header {
|
|
2337
|
+
background: white;
|
|
2338
|
+
padding: 10px 15px;
|
|
2339
|
+
border-radius: 8px;
|
|
2340
|
+
margin-bottom: 20px;
|
|
2341
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
2342
|
+
display: flex;
|
|
2343
|
+
justify-content: space-between;
|
|
2344
|
+
align-items: center;
|
|
2345
|
+
}
|
|
2346
|
+
h1 { display: none; }
|
|
2347
|
+
h2 {
|
|
2348
|
+
color: #555;
|
|
2349
|
+
margin: 30px 0 20px;
|
|
2350
|
+
font-size: 20px;
|
|
2351
|
+
border-bottom: 2px solid #e0e0e0;
|
|
2352
|
+
padding-bottom: 10px;
|
|
2353
|
+
}
|
|
2354
|
+
.metadata { color: #666; font-size: 12px; }
|
|
2355
|
+
.cli-args {
|
|
2356
|
+
font-family: "SF Mono", Monaco, "Consolas", monospace;
|
|
2357
|
+
font-size: 11px;
|
|
2358
|
+
color: #555;
|
|
2359
|
+
background: #f0f0f0;
|
|
2360
|
+
padding: 6px 10px;
|
|
2361
|
+
border-radius: 4px;
|
|
2362
|
+
word-break: break-word;
|
|
2363
|
+
}
|
|
2364
|
+
.comparison-mode {
|
|
2365
|
+
background: #fff3cd;
|
|
2366
|
+
color: #856404;
|
|
2367
|
+
padding: 8px 12px;
|
|
2368
|
+
border-radius: 4px;
|
|
2369
|
+
display: inline-block;
|
|
2370
|
+
margin-top: 10px;
|
|
2371
|
+
font-weight: 500;
|
|
2372
|
+
}
|
|
2373
|
+
.plot-grid {
|
|
2374
|
+
display: grid;
|
|
2375
|
+
grid-template-columns: 1fr 1fr;
|
|
2376
|
+
gap: 20px;
|
|
2377
|
+
margin-bottom: 30px;
|
|
2378
|
+
}
|
|
2379
|
+
.plot-grid.second-row { grid-template-columns: 1fr; }
|
|
2380
|
+
.plot-container {
|
|
2381
|
+
background: white;
|
|
2382
|
+
padding: 20px;
|
|
2383
|
+
border-radius: 8px;
|
|
2384
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
2385
|
+
}
|
|
2386
|
+
.plot-container.full-width { grid-column: 1 / -1; }
|
|
2387
|
+
.plot-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; color: #333; }
|
|
2388
|
+
.plot-description { font-size: 14px; color: #666; margin-bottom: 15px; }
|
|
2389
|
+
.plot-area {
|
|
2390
|
+
display: flex;
|
|
2391
|
+
justify-content: center;
|
|
2392
|
+
align-items: center;
|
|
2393
|
+
min-height: 300px;
|
|
2394
|
+
}
|
|
2395
|
+
.plot-area svg { overflow: visible; }
|
|
2396
|
+
.plot-area svg g[aria-label="x-axis label"] text { font-size: 14px; }
|
|
2397
|
+
.summary-stats { background: #f8f9fa; padding: 15px; border-radius: 6px; margin-top: 20px; }
|
|
2398
|
+
.stats-grid {
|
|
2399
|
+
display: grid;
|
|
2400
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
2401
|
+
gap: 10px;
|
|
2402
|
+
margin-top: 10px;
|
|
2403
|
+
}
|
|
2404
|
+
.stat-item { background: white; padding: 10px; border-radius: 4px; text-align: center; }
|
|
2405
|
+
.stat-label { font-size: 12px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
2406
|
+
.stat-value { font-size: 18px; font-weight: 600; color: #333; margin-top: 4px; }
|
|
2407
|
+
.loading { color: #666; font-style: italic; padding: 20px; text-align: center; }
|
|
2408
|
+
.error { color: #d32f2f; background: #ffebee; padding: 15px; border-radius: 4px; margin: 10px 0; }
|
|
2409
|
+
.ci-faster { color: #22c55e; }
|
|
2410
|
+
.ci-slower { color: #ef4444; }
|
|
2411
|
+
.ci-uncertain { color: #6b7280; }
|
|
2412
|
+
.group-header {
|
|
2413
|
+
display: flex;
|
|
2414
|
+
align-items: center;
|
|
2415
|
+
gap: 12px;
|
|
2416
|
+
margin: 30px 0 20px;
|
|
2417
|
+
padding-bottom: 10px;
|
|
2418
|
+
border-bottom: 2px solid #e0e0e0;
|
|
2419
|
+
}
|
|
2420
|
+
.group-header h2 { margin: 0; border: none; padding: 0; }
|
|
2421
|
+
.badge {
|
|
2422
|
+
font-size: 12px;
|
|
2423
|
+
font-weight: 600;
|
|
2424
|
+
padding: 4px 10px;
|
|
2425
|
+
border-radius: 12px;
|
|
2426
|
+
text-transform: uppercase;
|
|
2427
|
+
letter-spacing: 0.5px;
|
|
2428
|
+
}
|
|
2429
|
+
.badge-faster { background: #dcfce7; color: #166534; }
|
|
2430
|
+
.badge-slower { background: #fee2e2; color: #991b1b; }
|
|
2431
|
+
.badge-uncertain { background: #dbeafe; color: #1e40af; }
|
|
2432
|
+
.version-info { font-size: 12px; color: #666; margin-top: 6px; }
|
|
2433
|
+
.header-right { text-align: right; }
|
|
2434
|
+
.ci-plot-container { display: inline-block; vertical-align: middle; margin-left: 8px; }
|
|
2435
|
+
.ci-plot-container svg { display: block; }
|
|
2436
|
+
</style>
|
|
2437
|
+
</head>
|
|
2438
|
+
<body>
|
|
2439
|
+
<div class="header">
|
|
2440
|
+
<div class="cli-args">${formatCliArgs(data.metadata.cliArgs)}</div>
|
|
2441
|
+
<div class="header-right">
|
|
2442
|
+
<div class="metadata">Generated: ${formatDateWithTimezone((/* @__PURE__ */ new Date()).toISOString())}</div>
|
|
2443
|
+
${versionInfoHtml(data)}
|
|
2444
|
+
</div>
|
|
2445
|
+
</div>
|
|
2446
|
+
|
|
2447
|
+
${data.groups.map((group, i) => `
|
|
2448
|
+
<div id="group-${i}">
|
|
2449
|
+
${group.benchmarks.length > 0 ? `
|
|
2450
|
+
<div class="group-header">
|
|
2451
|
+
<h2>${group.name}</h2>
|
|
2452
|
+
${comparisonBadge(group, i)}
|
|
2453
|
+
</div>
|
|
2454
|
+
|
|
2455
|
+
<div class="plot-grid">
|
|
2456
|
+
<div class="plot-container">
|
|
2457
|
+
<div class="plot-title">Time per Sample</div>
|
|
2458
|
+
<div class="plot-description">Execution time for each sample in collection order</div>
|
|
2459
|
+
<div id="sample-timeseries-${i}" class="plot-area">
|
|
2460
|
+
<div class="loading">Loading time series...</div>
|
|
2461
|
+
</div>
|
|
2462
|
+
</div>
|
|
2463
|
+
|
|
2464
|
+
<div class="plot-container">
|
|
2465
|
+
<div class="plot-title">Time Distribution</div>
|
|
2466
|
+
<div class="plot-description">Frequency distribution of execution times</div>
|
|
2467
|
+
<div id="histogram-${i}" class="plot-area">
|
|
2468
|
+
<div class="loading">Loading histogram...</div>
|
|
2469
|
+
</div>
|
|
2470
|
+
</div>
|
|
2471
|
+
</div>
|
|
2472
|
+
|
|
2473
|
+
<div id="stats-${i}"></div>
|
|
2474
|
+
` : "<div class=\"error\">No benchmark data available for this group</div>"}
|
|
2475
|
+
</div>
|
|
2476
|
+
`).join("")}
|
|
2477
|
+
|
|
2478
|
+
<script type="importmap">
|
|
2479
|
+
{
|
|
2480
|
+
"imports": {
|
|
2481
|
+
"d3": "https://cdn.jsdelivr.net/npm/d3@7/+esm",
|
|
2482
|
+
"@observablehq/plot": "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm"
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
<\/script>
|
|
2486
|
+
<script type="module">
|
|
2487
|
+
import { renderPlots } from "./plots.js";
|
|
2488
|
+
const benchmarkData = ${JSON.stringify(data, null, 2)};
|
|
2489
|
+
renderPlots(benchmarkData);
|
|
2490
|
+
<\/script>
|
|
2491
|
+
</body>
|
|
2492
|
+
</html>`;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
//#endregion
|
|
2496
|
+
//#region src/html/HtmlReport.ts
|
|
2497
|
+
/** Generate HTML report from prepared data and optionally open in browser */
|
|
2498
|
+
async function generateHtmlReport(data, options) {
|
|
2499
|
+
const html = generateHtmlDocument(data);
|
|
2500
|
+
const reportDir = options.outputPath || await createReportDir();
|
|
2501
|
+
await mkdir(reportDir, { recursive: true });
|
|
2502
|
+
await writeFile(join(reportDir, "index.html"), html, "utf-8");
|
|
2503
|
+
const plots = await loadPlotsBundle();
|
|
2504
|
+
await writeFile(join(reportDir, "plots.js"), plots, "utf-8");
|
|
2505
|
+
await writeLatestRedirect(reportDir);
|
|
2506
|
+
let server;
|
|
2507
|
+
let closeServer;
|
|
2508
|
+
if (options.openBrowser) {
|
|
2509
|
+
const baseDir = dirname(reportDir);
|
|
2510
|
+
const reportName = reportDir.split("/").pop();
|
|
2511
|
+
const result = await startReportServer(baseDir, 7979, 7978, 7977);
|
|
2512
|
+
server = result.server;
|
|
2513
|
+
closeServer = () => result.server.close();
|
|
2514
|
+
const openUrl = `http://localhost:${result.port}/${reportName}/`;
|
|
2515
|
+
await open(openUrl);
|
|
2516
|
+
console.log(`Report opened in browser: ${openUrl}`);
|
|
2517
|
+
} else console.log(`Report saved to: ${reportDir}/`);
|
|
2518
|
+
return {
|
|
2519
|
+
reportDir,
|
|
2520
|
+
server,
|
|
2521
|
+
closeServer
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
/** Start HTTP server for report directory, trying fallback ports if needed */
|
|
2525
|
+
async function startReportServer(baseDir, ...ports) {
|
|
2526
|
+
const mimeTypes = {
|
|
2527
|
+
".html": "text/html",
|
|
2528
|
+
".js": "application/javascript",
|
|
2529
|
+
".css": "text/css",
|
|
2530
|
+
".json": "application/json"
|
|
2531
|
+
};
|
|
2532
|
+
const server = createServer(async (req, res) => {
|
|
2533
|
+
const url = req.url || "/";
|
|
2534
|
+
const filePath = join(baseDir, url.endsWith("/") ? url + "index.html" : url);
|
|
2535
|
+
try {
|
|
2536
|
+
const content = await readFile(filePath);
|
|
2537
|
+
const mime = mimeTypes[extname(filePath)] || "application/octet-stream";
|
|
2538
|
+
res.setHeader("Content-Type", mime);
|
|
2539
|
+
res.end(content);
|
|
2540
|
+
} catch {
|
|
2541
|
+
res.statusCode = 404;
|
|
2542
|
+
res.end("Not found");
|
|
2543
|
+
}
|
|
2544
|
+
});
|
|
2545
|
+
for (const port of ports) try {
|
|
2546
|
+
return await tryListen(server, port);
|
|
2547
|
+
} catch {}
|
|
2548
|
+
return tryListen(server, 0);
|
|
2549
|
+
}
|
|
2550
|
+
/** Listen on a port, resolving with the actual port or rejecting on error */
|
|
2551
|
+
function tryListen(server, port) {
|
|
2552
|
+
return new Promise((resolve, reject) => {
|
|
2553
|
+
server.once("error", reject);
|
|
2554
|
+
server.listen(port, () => {
|
|
2555
|
+
server.removeListener("error", reject);
|
|
2556
|
+
const addr = server.address();
|
|
2557
|
+
resolve({
|
|
2558
|
+
server,
|
|
2559
|
+
port: typeof addr === "object" && addr ? addr.port : port
|
|
2560
|
+
});
|
|
2561
|
+
});
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
/** Create a timestamped report directory under ./bench-report/ */
|
|
2565
|
+
async function createReportDir() {
|
|
2566
|
+
const base = "./bench-report";
|
|
2567
|
+
await mkdir(base, { recursive: true });
|
|
2568
|
+
return join(base, `report-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
2569
|
+
}
|
|
2570
|
+
/** Read the pre-built browser plots bundle from dist/ */
|
|
2571
|
+
async function loadPlotsBundle() {
|
|
2572
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
2573
|
+
const builtPath = join(thisDir, "browser/index.js");
|
|
2574
|
+
const devPath = join(thisDir, "../../dist/browser/index.js");
|
|
2575
|
+
try {
|
|
2576
|
+
return await readFile(builtPath, "utf-8");
|
|
2577
|
+
} catch {}
|
|
2578
|
+
return readFile(devPath, "utf-8");
|
|
2579
|
+
}
|
|
2580
|
+
/** Write an index.html in the parent dir that redirects to this report */
|
|
2581
|
+
async function writeLatestRedirect(reportDir) {
|
|
2582
|
+
const baseDir = dirname(reportDir);
|
|
2583
|
+
const reportName = reportDir.split("/").pop();
|
|
2584
|
+
const html = `<!DOCTYPE html>
|
|
2585
|
+
<html><head>
|
|
2586
|
+
<meta http-equiv="refresh" content="0; url=./${reportName}/">
|
|
2587
|
+
<script>location.href = "./${reportName}/";<\/script>
|
|
2588
|
+
</head><body>
|
|
2589
|
+
<a href="./${reportName}/">Latest report</a>
|
|
2590
|
+
</body></html>`;
|
|
2591
|
+
await writeFile(join(baseDir, "index.html"), html, "utf-8");
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
//#endregion
|
|
2595
|
+
//#region src/matrix/MatrixFilter.ts
|
|
2596
|
+
/** Parse filter string: "case/variant", "case/", "/variant", or "case" */
|
|
2597
|
+
function parseMatrixFilter(filter) {
|
|
2598
|
+
if (filter.includes("/")) {
|
|
2599
|
+
const [casePart, variantPart] = filter.split("/", 2);
|
|
2600
|
+
return {
|
|
2601
|
+
case: casePart || void 0,
|
|
2602
|
+
variant: variantPart || void 0
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
return { case: filter };
|
|
2606
|
+
}
|
|
2607
|
+
/** Apply filter to a matrix, merging with existing filters via intersection */
|
|
2608
|
+
async function filterMatrix(matrix, filter) {
|
|
2609
|
+
if (!filter || !filter.case && !filter.variant) return matrix;
|
|
2610
|
+
const caseList = await getFilteredCases(matrix, filter.case);
|
|
2611
|
+
const variantList = await getFilteredVariants(matrix, filter.variant);
|
|
2612
|
+
const filteredCases = caseList && matrix.filteredCases ? caseList.filter((c) => matrix.filteredCases.includes(c)) : caseList ?? matrix.filteredCases;
|
|
2613
|
+
const filteredVariants = variantList && matrix.filteredVariants ? variantList.filter((v) => matrix.filteredVariants.includes(v)) : variantList ?? matrix.filteredVariants;
|
|
2614
|
+
return {
|
|
2615
|
+
...matrix,
|
|
2616
|
+
filteredCases,
|
|
2617
|
+
filteredVariants
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
/** Get case IDs matching filter pattern */
|
|
2621
|
+
async function getFilteredCases(matrix, casePattern) {
|
|
2622
|
+
if (!casePattern) return void 0;
|
|
2623
|
+
const caseIds = matrix.casesModule ? (await loadCasesModule(matrix.casesModule)).cases : matrix.cases;
|
|
2624
|
+
if (!caseIds) return ["default"];
|
|
2625
|
+
const filtered = caseIds.filter((id) => matchPattern(id, casePattern));
|
|
2626
|
+
if (filtered.length === 0) throw new Error(`No cases match filter: "${casePattern}"`);
|
|
2627
|
+
return filtered;
|
|
2628
|
+
}
|
|
2629
|
+
/** Get variant IDs matching filter pattern */
|
|
2630
|
+
async function getFilteredVariants(matrix, variantPattern) {
|
|
2631
|
+
if (!variantPattern) return void 0;
|
|
2632
|
+
if (matrix.variants) {
|
|
2633
|
+
const ids = Object.keys(matrix.variants).filter((id) => matchPattern(id, variantPattern));
|
|
2634
|
+
if (ids.length === 0) throw new Error(`No variants match filter: "${variantPattern}"`);
|
|
2635
|
+
return ids;
|
|
2636
|
+
}
|
|
2637
|
+
if (matrix.variantDir) {
|
|
2638
|
+
const filtered = (await discoverVariants(matrix.variantDir)).filter((id) => matchPattern(id, variantPattern));
|
|
2639
|
+
if (filtered.length === 0) throw new Error(`No variants match filter: "${variantPattern}"`);
|
|
2640
|
+
return filtered;
|
|
2641
|
+
}
|
|
2642
|
+
throw new Error("BenchMatrix requires 'variants' or 'variantDir'");
|
|
2643
|
+
}
|
|
2644
|
+
/** Match id against pattern (case-insensitive substring) */
|
|
2645
|
+
function matchPattern(id, pattern) {
|
|
2646
|
+
return id.toLowerCase().includes(pattern.toLowerCase());
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
//#endregion
|
|
2650
|
+
//#region src/table-util/ConvergenceFormatters.ts
|
|
2651
|
+
const { red } = process.env.NODE_ENV === "test" || process.env.VITEST === "true" ? { red: (str) => str } : pico;
|
|
2652
|
+
const lowConfidence = 80;
|
|
2653
|
+
/** @return convergence percentage with color for low values */
|
|
2654
|
+
function formatConvergence(v) {
|
|
2655
|
+
if (typeof v !== "number") return "—";
|
|
2656
|
+
const pct = `${Math.round(v)}%`;
|
|
2657
|
+
return v < lowConfidence ? red(pct) : pct;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
//#endregion
|
|
2661
|
+
//#region src/StandardSections.ts
|
|
2662
|
+
/** Section: mean, p50, p99 timing with convergence */
|
|
2663
|
+
const timeSection = {
|
|
2664
|
+
extract: (results) => ({
|
|
2665
|
+
mean: results.time?.avg,
|
|
2666
|
+
p50: results.time?.p50,
|
|
2667
|
+
p99: results.time?.p99,
|
|
2668
|
+
convergence: results.convergence?.confidence
|
|
2669
|
+
}),
|
|
2670
|
+
columns: () => [{
|
|
2671
|
+
groupTitle: "time",
|
|
2672
|
+
columns: [
|
|
2673
|
+
{
|
|
2674
|
+
key: "mean",
|
|
2675
|
+
title: "mean",
|
|
2676
|
+
formatter: timeMs,
|
|
2677
|
+
comparable: true
|
|
2678
|
+
},
|
|
2679
|
+
{
|
|
2680
|
+
key: "p50",
|
|
2681
|
+
title: "p50",
|
|
2682
|
+
formatter: timeMs,
|
|
2683
|
+
comparable: true
|
|
2684
|
+
},
|
|
2685
|
+
{
|
|
2686
|
+
key: "p99",
|
|
2687
|
+
title: "p99",
|
|
2688
|
+
formatter: timeMs,
|
|
2689
|
+
comparable: true
|
|
2690
|
+
}
|
|
2691
|
+
]
|
|
2692
|
+
}, { columns: [{
|
|
2693
|
+
key: "convergence",
|
|
2694
|
+
title: "conv%",
|
|
2695
|
+
formatter: formatConvergence
|
|
2696
|
+
}] }]
|
|
2697
|
+
};
|
|
2698
|
+
/** Section: GC time as fraction of total benchmark time (Node performance hooks) */
|
|
2699
|
+
const gcSection = {
|
|
2700
|
+
extract: (results) => {
|
|
2701
|
+
const { nodeGcTime, time, samples } = results;
|
|
2702
|
+
if (!nodeGcTime || !time?.avg) return { gc: void 0 };
|
|
2703
|
+
const totalBenchTime = time.avg * samples.length;
|
|
2704
|
+
if (totalBenchTime <= 0) return { gc: void 0 };
|
|
2705
|
+
const gcTime = nodeGcTime.inRun / totalBenchTime;
|
|
2706
|
+
return { gc: gcTime <= 1 ? gcTime : void 0 };
|
|
2707
|
+
},
|
|
2708
|
+
columns: () => [{
|
|
2709
|
+
groupTitle: "gc",
|
|
2710
|
+
columns: [{
|
|
2711
|
+
key: "gc",
|
|
2712
|
+
title: "mean",
|
|
2713
|
+
formatter: percent,
|
|
2714
|
+
comparable: true
|
|
2715
|
+
}]
|
|
2716
|
+
}]
|
|
2717
|
+
};
|
|
2718
|
+
/** Section: detailed GC stats from --trace-gc-nvp (allocation, promotion, pauses) */
|
|
2719
|
+
const gcStatsSection = {
|
|
2720
|
+
extract: (results) => {
|
|
2721
|
+
const { gcStats, samples } = results;
|
|
2722
|
+
if (!gcStats) return {};
|
|
2723
|
+
const iterations = samples.length || 1;
|
|
2724
|
+
const { totalAllocated, totalPromoted } = gcStats;
|
|
2725
|
+
const promoPercent = totalAllocated && totalAllocated > 0 ? (totalPromoted ?? 0) / totalAllocated : void 0;
|
|
2726
|
+
return {
|
|
2727
|
+
allocPerIter: totalAllocated != null ? totalAllocated / iterations : void 0,
|
|
2728
|
+
collected: gcStats.totalCollected || void 0,
|
|
2729
|
+
scavenges: gcStats.scavenges,
|
|
2730
|
+
fullGCs: gcStats.markCompacts,
|
|
2731
|
+
promoPercent,
|
|
2732
|
+
pausePerIter: gcStats.gcPauseTime / iterations
|
|
2733
|
+
};
|
|
2734
|
+
},
|
|
2735
|
+
columns: () => [{
|
|
2736
|
+
groupTitle: "gc",
|
|
2737
|
+
columns: [
|
|
2738
|
+
{
|
|
2739
|
+
key: "allocPerIter",
|
|
2740
|
+
title: "alloc/iter",
|
|
2741
|
+
formatter: formatBytes
|
|
2742
|
+
},
|
|
2743
|
+
{
|
|
2744
|
+
key: "collected",
|
|
2745
|
+
title: "collected",
|
|
2746
|
+
formatter: formatBytes
|
|
2747
|
+
},
|
|
2748
|
+
{
|
|
2749
|
+
key: "scavenges",
|
|
2750
|
+
title: "scav",
|
|
2751
|
+
formatter: integer
|
|
2752
|
+
},
|
|
2753
|
+
{
|
|
2754
|
+
key: "fullGCs",
|
|
2755
|
+
title: "full",
|
|
2756
|
+
formatter: integer
|
|
2757
|
+
},
|
|
2758
|
+
{
|
|
2759
|
+
key: "promoPercent",
|
|
2760
|
+
title: "promo%",
|
|
2761
|
+
formatter: percent
|
|
2762
|
+
},
|
|
2763
|
+
{
|
|
2764
|
+
key: "pausePerIter",
|
|
2765
|
+
title: "pause/iter",
|
|
2766
|
+
formatter: timeMs
|
|
2767
|
+
}
|
|
2768
|
+
]
|
|
2769
|
+
}]
|
|
2770
|
+
};
|
|
2771
|
+
/** Browser GC section: only fields available from CDP tracing */
|
|
2772
|
+
const browserGcStatsSection = {
|
|
2773
|
+
extract: gcStatsSection.extract,
|
|
2774
|
+
columns: () => [{
|
|
2775
|
+
groupTitle: "gc",
|
|
2776
|
+
columns: [
|
|
2777
|
+
{
|
|
2778
|
+
key: "collected",
|
|
2779
|
+
title: "collected",
|
|
2780
|
+
formatter: formatBytes
|
|
2781
|
+
},
|
|
2782
|
+
{
|
|
2783
|
+
key: "scavenges",
|
|
2784
|
+
title: "scav",
|
|
2785
|
+
formatter: integer
|
|
2786
|
+
},
|
|
2787
|
+
{
|
|
2788
|
+
key: "fullGCs",
|
|
2789
|
+
title: "full",
|
|
2790
|
+
formatter: integer
|
|
2791
|
+
},
|
|
2792
|
+
{
|
|
2793
|
+
key: "pausePerIter",
|
|
2794
|
+
title: "pause",
|
|
2795
|
+
formatter: timeMs
|
|
2796
|
+
}
|
|
2797
|
+
]
|
|
2798
|
+
}]
|
|
2799
|
+
};
|
|
2800
|
+
/** Section: CPU L1 cache miss rate and stall rate (requires @mitata/counters) */
|
|
2801
|
+
const cpuSection = {
|
|
2802
|
+
extract: (results) => ({
|
|
2803
|
+
cpuCacheMiss: results.cpuCacheMiss,
|
|
2804
|
+
cpuStall: results.cpuStall
|
|
2805
|
+
}),
|
|
2806
|
+
columns: () => [{
|
|
2807
|
+
groupTitle: "cpu",
|
|
2808
|
+
columns: [{
|
|
2809
|
+
key: "cpuCacheMiss",
|
|
2810
|
+
title: "L1 miss",
|
|
2811
|
+
formatter: percent
|
|
2812
|
+
}, {
|
|
2813
|
+
key: "cpuStall",
|
|
2814
|
+
title: "stalls",
|
|
2815
|
+
formatter: percentPrecision(2)
|
|
2816
|
+
}]
|
|
2817
|
+
}]
|
|
2818
|
+
};
|
|
2819
|
+
/** Section: number of sample iterations */
|
|
2820
|
+
const runsSection = {
|
|
2821
|
+
extract: (results) => ({ runs: results.samples.length }),
|
|
2822
|
+
columns: () => [{ columns: [{
|
|
2823
|
+
key: "runs",
|
|
2824
|
+
title: "runs",
|
|
2825
|
+
formatter: integer
|
|
2826
|
+
}] }]
|
|
2827
|
+
};
|
|
2828
|
+
/** Section: total sampling duration in seconds (brackets if >= 30s) */
|
|
2829
|
+
const totalTimeSection = {
|
|
2830
|
+
extract: (results) => ({ totalTime: results.totalTime }),
|
|
2831
|
+
columns: () => [{ columns: [{
|
|
2832
|
+
key: "totalTime",
|
|
2833
|
+
title: "time",
|
|
2834
|
+
formatter: (v) => {
|
|
2835
|
+
if (typeof v !== "number") return "";
|
|
2836
|
+
return v >= 30 ? `[${v.toFixed(1)}s]` : `${v.toFixed(1)}s`;
|
|
2837
|
+
}
|
|
2838
|
+
}] }]
|
|
2839
|
+
};
|
|
2840
|
+
/** Section: median, mean, p99, and convergence for adaptive mode */
|
|
2841
|
+
const adaptiveSection = {
|
|
2842
|
+
extract: (results) => ({
|
|
2843
|
+
median: results.time?.p50,
|
|
2844
|
+
mean: results.time?.avg,
|
|
2845
|
+
p99: results.time?.p99,
|
|
2846
|
+
convergence: results.convergence?.confidence
|
|
2847
|
+
}),
|
|
2848
|
+
columns: () => [{
|
|
2849
|
+
groupTitle: "time",
|
|
2850
|
+
columns: [
|
|
2851
|
+
{
|
|
2852
|
+
key: "median",
|
|
2853
|
+
title: "median",
|
|
2854
|
+
formatter: timeMs,
|
|
2855
|
+
comparable: true
|
|
2856
|
+
},
|
|
2857
|
+
{
|
|
2858
|
+
key: "mean",
|
|
2859
|
+
title: "mean",
|
|
2860
|
+
formatter: timeMs,
|
|
2861
|
+
comparable: true
|
|
2862
|
+
},
|
|
2863
|
+
{
|
|
2864
|
+
key: "p99",
|
|
2865
|
+
title: "p99",
|
|
2866
|
+
formatter: timeMs
|
|
2867
|
+
}
|
|
2868
|
+
]
|
|
2869
|
+
}, { columns: [{
|
|
2870
|
+
key: "convergence",
|
|
2871
|
+
title: "conv%",
|
|
2872
|
+
formatter: formatConvergence
|
|
2873
|
+
}] }]
|
|
2874
|
+
};
|
|
2875
|
+
/** Build generic sections based on CLI flags */
|
|
2876
|
+
function buildGenericSections(args) {
|
|
2877
|
+
const sections = [];
|
|
2878
|
+
if (args["gc-stats"]) sections.push(gcStatsSection);
|
|
2879
|
+
sections.push(runsSection);
|
|
2880
|
+
return sections;
|
|
2881
|
+
}
|
|
2882
|
+
/** Section: V8 optimization tier distribution and deopt count */
|
|
2883
|
+
const optSection = {
|
|
2884
|
+
extract: (results) => {
|
|
2885
|
+
const opt = results.optStatus;
|
|
2886
|
+
if (!opt) return {};
|
|
2887
|
+
const total = Object.values(opt.byTier).reduce((s, t) => s + t.count, 0);
|
|
2888
|
+
return {
|
|
2889
|
+
tiers: Object.entries(opt.byTier).sort((a, b) => b[1].count - a[1].count).map(([name, t]) => `${name}:${(t.count / total * 100).toFixed(0)}%`).join(" "),
|
|
2890
|
+
deopt: opt.deoptCount > 0 ? opt.deoptCount : void 0
|
|
2891
|
+
};
|
|
2892
|
+
},
|
|
2893
|
+
columns: () => [{
|
|
2894
|
+
groupTitle: "v8 opt",
|
|
2895
|
+
columns: [{
|
|
2896
|
+
key: "tiers",
|
|
2897
|
+
title: "tiers",
|
|
2898
|
+
formatter: (v) => typeof v === "string" ? v : ""
|
|
2899
|
+
}, {
|
|
2900
|
+
key: "deopt",
|
|
2901
|
+
title: "deopt",
|
|
2902
|
+
formatter: (v) => typeof v === "number" ? String(v) : ""
|
|
2903
|
+
}]
|
|
2904
|
+
}]
|
|
2905
|
+
};
|
|
2906
|
+
|
|
2907
|
+
//#endregion
|
|
2908
|
+
//#region src/matrix/MatrixReport.ts
|
|
2909
|
+
/** Format matrix results as one table per case */
|
|
2910
|
+
function reportMatrixResults(results, options) {
|
|
2911
|
+
const tables = buildCaseTables(results, options);
|
|
2912
|
+
return [`Matrix: ${results.name}`, ...tables].join("\n\n");
|
|
2913
|
+
}
|
|
2914
|
+
/** Build one table for each case showing all variants */
|
|
2915
|
+
function buildCaseTables(results, options) {
|
|
2916
|
+
if (results.variants.length === 0) return [];
|
|
2917
|
+
return results.variants[0].cases.map((c) => c.caseId).map((caseId) => buildCaseTable(results, caseId, options));
|
|
2918
|
+
}
|
|
2919
|
+
/** Build table for a single case showing all variants */
|
|
2920
|
+
function buildCaseTable(results, caseId, options) {
|
|
2921
|
+
const caseTitle = formatCaseTitle(results, caseId);
|
|
2922
|
+
if (options?.sections?.length) return buildSectionTable(results, caseId, options, caseTitle);
|
|
2923
|
+
const rows = buildCaseRows(results, caseId, options?.extraColumns);
|
|
2924
|
+
return `${caseTitle}\n${buildTable(buildColumns(rows.some((r) => r.diffCI), options), [{ results: rows }])}`;
|
|
2925
|
+
}
|
|
2926
|
+
/** Build table using ResultsMapper sections */
|
|
2927
|
+
function buildSectionTable(results, caseId, options, caseTitle) {
|
|
2928
|
+
const sections = options.sections;
|
|
2929
|
+
const variantTitle = options.variantTitle ?? "name";
|
|
2930
|
+
const rows = [];
|
|
2931
|
+
let hasBaseline = false;
|
|
2932
|
+
for (const variant of results.variants) {
|
|
2933
|
+
const caseResult = variant.cases.find((c) => c.caseId === caseId);
|
|
2934
|
+
if (!caseResult) continue;
|
|
2935
|
+
const row = { name: truncate(variant.id, 25) };
|
|
2936
|
+
for (const section of sections) Object.assign(row, section.extract(caseResult.measured, caseResult.metadata));
|
|
2937
|
+
if (caseResult.baseline) {
|
|
2938
|
+
hasBaseline = true;
|
|
2939
|
+
const { samples: base } = caseResult.baseline;
|
|
2940
|
+
row.diffCI = bootstrapDifferenceCI(base, caseResult.measured.samples);
|
|
2941
|
+
}
|
|
2942
|
+
rows.push(row);
|
|
2943
|
+
}
|
|
2944
|
+
return `${caseTitle}\n${buildTable(buildSectionColumns(sections, variantTitle, hasBaseline), [{ results: rows }])}`;
|
|
2945
|
+
}
|
|
2946
|
+
/** Build column groups from ResultsMapper sections */
|
|
2947
|
+
function buildSectionColumns(sections, variantTitle, hasBaseline) {
|
|
2948
|
+
const nameCol = { columns: [{
|
|
2949
|
+
key: "name",
|
|
2950
|
+
title: variantTitle
|
|
2951
|
+
}] };
|
|
2952
|
+
const sectionColumns = sections.flatMap((s) => s.columns());
|
|
2953
|
+
return [nameCol, ...hasBaseline ? injectDiffColumns(sectionColumns) : sectionColumns];
|
|
2954
|
+
}
|
|
2955
|
+
/** Build rows for all variants for a given case */
|
|
2956
|
+
function buildCaseRows(results, caseId, extraColumns) {
|
|
2957
|
+
return results.variants.flatMap((variant) => {
|
|
2958
|
+
const caseResult = variant.cases.find((c) => c.caseId === caseId);
|
|
2959
|
+
return caseResult ? [buildRow(variant.id, caseResult, extraColumns)] : [];
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
/** Build a single row from case result */
|
|
2963
|
+
function buildRow(variantId, caseResult, extraColumns) {
|
|
2964
|
+
const { measured, baseline } = caseResult;
|
|
2965
|
+
const samples = measured.samples;
|
|
2966
|
+
const time = measured.time?.avg ?? average(samples);
|
|
2967
|
+
const row = {
|
|
2968
|
+
name: truncate(variantId, 25),
|
|
2969
|
+
time,
|
|
2970
|
+
samples: samples.length
|
|
2971
|
+
};
|
|
2972
|
+
if (baseline) row.diffCI = bootstrapDifferenceCI(baseline.samples, samples);
|
|
2973
|
+
if (extraColumns) for (const col of extraColumns) row[col.key] = col.extract(caseResult);
|
|
2974
|
+
return row;
|
|
2975
|
+
}
|
|
2976
|
+
/** Build column configuration */
|
|
2977
|
+
function buildColumns(hasBaseline, options) {
|
|
2978
|
+
const groups = [{ columns: [{
|
|
2979
|
+
key: "name",
|
|
2980
|
+
title: options?.variantTitle ?? "variant"
|
|
2981
|
+
}] }, { columns: [{
|
|
2982
|
+
key: "time",
|
|
2983
|
+
title: "time",
|
|
2984
|
+
formatter: duration
|
|
2985
|
+
}, ...hasBaseline ? [{
|
|
2986
|
+
key: "diffCI",
|
|
2987
|
+
title: "Δ% CI",
|
|
2988
|
+
formatter: formatDiff
|
|
2989
|
+
}] : []] }];
|
|
2990
|
+
const extraColumns = options?.extraColumns;
|
|
2991
|
+
if (extraColumns?.length) {
|
|
2992
|
+
const byGroup = /* @__PURE__ */ new Map();
|
|
2993
|
+
for (const col of extraColumns) {
|
|
2994
|
+
const group = byGroup.get(col.groupTitle) ?? [];
|
|
2995
|
+
group.push(col);
|
|
2996
|
+
byGroup.set(col.groupTitle, group);
|
|
2997
|
+
}
|
|
2998
|
+
for (const [groupTitle, cols] of byGroup) groups.push({
|
|
2999
|
+
groupTitle,
|
|
3000
|
+
columns: cols.map((col) => ({
|
|
3001
|
+
key: col.key,
|
|
3002
|
+
title: col.title,
|
|
3003
|
+
formatter: col.formatter ?? String
|
|
3004
|
+
}))
|
|
3005
|
+
});
|
|
3006
|
+
}
|
|
3007
|
+
return groups;
|
|
3008
|
+
}
|
|
3009
|
+
/** Format diff with CI, or "baseline" marker */
|
|
3010
|
+
function formatDiff(value) {
|
|
3011
|
+
if (!value) return null;
|
|
3012
|
+
return formatDiffWithCI(value);
|
|
3013
|
+
}
|
|
3014
|
+
/** Format case title with metadata if available */
|
|
3015
|
+
function formatCaseTitle(results, caseId) {
|
|
3016
|
+
const metadata = (results.variants[0]?.cases.find((c) => c.caseId === caseId))?.metadata;
|
|
3017
|
+
if (metadata && Object.keys(metadata).length > 0) return `${caseId} (${Object.entries(metadata).map(([k, v]) => `${v} ${k}`).join(", ")})`;
|
|
3018
|
+
return caseId;
|
|
3019
|
+
}
|
|
3020
|
+
/** GC statistics columns - derived from gcStatsSection for consistency */
|
|
3021
|
+
const gcStatsColumns = gcStatsSection.columns()[0].columns.map((col) => ({
|
|
3022
|
+
key: col.key,
|
|
3023
|
+
title: col.title,
|
|
3024
|
+
groupTitle: "GC",
|
|
3025
|
+
extract: (r) => gcStatsSection.extract(r.measured)[col.key],
|
|
3026
|
+
formatter: (v) => col.formatter?.(v) ?? "-"
|
|
3027
|
+
}));
|
|
3028
|
+
/** Format bytes with fallback to "-" for missing values */
|
|
3029
|
+
function formatBytesOrDash(value) {
|
|
3030
|
+
return formatBytes(value) ?? "-";
|
|
3031
|
+
}
|
|
3032
|
+
/** GC pause time column */
|
|
3033
|
+
const gcPauseColumn = {
|
|
3034
|
+
key: "gcPause",
|
|
3035
|
+
title: "pause",
|
|
3036
|
+
groupTitle: "GC",
|
|
3037
|
+
extract: (r) => r.measured.gcStats?.gcPauseTime,
|
|
3038
|
+
formatter: (v) => v != null ? `${v.toFixed(1)}ms` : "-"
|
|
3039
|
+
};
|
|
3040
|
+
/** Heap sampling total bytes column */
|
|
3041
|
+
const heapTotalColumn = {
|
|
3042
|
+
key: "heapTotal",
|
|
3043
|
+
title: "heap",
|
|
3044
|
+
extract: (r) => {
|
|
3045
|
+
const profile = r.measured.heapProfile;
|
|
3046
|
+
if (!profile?.head) return void 0;
|
|
3047
|
+
return totalProfileBytes(profile);
|
|
3048
|
+
},
|
|
3049
|
+
formatter: formatBytesOrDash
|
|
3050
|
+
};
|
|
3051
|
+
|
|
3052
|
+
//#endregion
|
|
3053
|
+
//#region src/cli/FilterBenchmarks.ts
|
|
3054
|
+
/** Filter benchmarks by name pattern */
|
|
3055
|
+
function filterBenchmarks(suite, filter, removeEmpty = true) {
|
|
3056
|
+
if (!filter) return suite;
|
|
3057
|
+
const regex = createFilterRegex(filter);
|
|
3058
|
+
const groups = suite.groups.map((group) => ({
|
|
3059
|
+
...group,
|
|
3060
|
+
benchmarks: group.benchmarks.filter((bench) => regex.test(stripCaseSuffix(bench.name))),
|
|
3061
|
+
baseline: group.baseline && regex.test(stripCaseSuffix(group.baseline.name)) ? group.baseline : void 0
|
|
3062
|
+
})).filter((group) => !removeEmpty || group.benchmarks.length > 0);
|
|
3063
|
+
validateFilteredSuite(groups, filter);
|
|
3064
|
+
return {
|
|
3065
|
+
name: suite.name,
|
|
3066
|
+
groups
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
/** Create regex from filter (literal unless regex-like) */
|
|
3070
|
+
function createFilterRegex(filter) {
|
|
3071
|
+
if (filter.startsWith("/") && filter.endsWith("/") || filter.includes("*") || filter.includes("?") || filter.includes("[") || filter.includes("|") || filter.startsWith("^") || filter.endsWith("$")) {
|
|
3072
|
+
const pattern = filter.startsWith("/") && filter.endsWith("/") ? filter.slice(1, -1) : filter;
|
|
3073
|
+
try {
|
|
3074
|
+
return new RegExp(pattern, "i");
|
|
3075
|
+
} catch {
|
|
3076
|
+
return new RegExp(escapeRegex(filter), "i");
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
return new RegExp("^" + escapeRegex(filter), "i");
|
|
3080
|
+
}
|
|
3081
|
+
/** Strip case suffix like " [large]" from benchmark name for filtering */
|
|
3082
|
+
function stripCaseSuffix(name) {
|
|
3083
|
+
return name.replace(/ \[.*?\]$/, "");
|
|
3084
|
+
}
|
|
3085
|
+
/** Escape regex special characters */
|
|
3086
|
+
function escapeRegex(str) {
|
|
3087
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3088
|
+
}
|
|
3089
|
+
/** Ensure at least one benchmark matches filter */
|
|
3090
|
+
function validateFilteredSuite(groups, filter) {
|
|
3091
|
+
if (groups.every((g) => g.benchmarks.length === 0)) throw new Error(`No benchmarks match filter: "${filter}"`);
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
//#endregion
|
|
3095
|
+
//#region src/cli/RunBenchCLI.ts
|
|
3096
|
+
/** Validate CLI argument combinations */
|
|
3097
|
+
function validateArgs(args) {
|
|
3098
|
+
if (args["gc-stats"] && !args.worker && !args.url) throw new Error("--gc-stats requires worker mode (the default). Remove --no-worker flag.");
|
|
3099
|
+
}
|
|
3100
|
+
/** Warn about Node-only flags that are ignored in browser mode. */
|
|
3101
|
+
function warnBrowserFlags(args) {
|
|
3102
|
+
const ignored = [];
|
|
3103
|
+
if (!args.worker) ignored.push("--no-worker");
|
|
3104
|
+
if (args.cpu) ignored.push("--cpu");
|
|
3105
|
+
if (args["trace-opt"]) ignored.push("--trace-opt");
|
|
3106
|
+
if (args.collect) ignored.push("--collect");
|
|
3107
|
+
if (args.adaptive) ignored.push("--adaptive");
|
|
3108
|
+
if (args.batches > 1) ignored.push("--batches");
|
|
3109
|
+
if (ignored.length) console.warn(yellow(`Ignored in browser mode: ${ignored.join(", ")}`));
|
|
3110
|
+
}
|
|
3111
|
+
/** Parse CLI with custom configuration */
|
|
3112
|
+
function parseBenchArgs(configureArgs) {
|
|
3113
|
+
return parseCliArgs(hideBin(process.argv), configureArgs);
|
|
3114
|
+
}
|
|
3115
|
+
/** Run suite with CLI arguments */
|
|
3116
|
+
async function runBenchmarks(suite, args) {
|
|
3117
|
+
validateArgs(args);
|
|
3118
|
+
const { filter, worker: useWorker, batches = 1 } = args;
|
|
3119
|
+
const options = cliToRunnerOptions(args);
|
|
3120
|
+
return runSuite({
|
|
3121
|
+
suite: filterBenchmarks(suite, filter),
|
|
3122
|
+
runner: "basic",
|
|
3123
|
+
options,
|
|
3124
|
+
useWorker,
|
|
3125
|
+
batches
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
/** Execute all groups in suite */
|
|
3129
|
+
async function runSuite(params) {
|
|
3130
|
+
const { suite, runner, options, useWorker, batches } = params;
|
|
3131
|
+
const results = [];
|
|
3132
|
+
for (const group of suite.groups) results.push(await runGroup(group, runner, options, useWorker, batches));
|
|
3133
|
+
return results;
|
|
3134
|
+
}
|
|
3135
|
+
/** Execute group with shared setup, optionally batching to reduce ordering bias */
|
|
3136
|
+
async function runGroup(group, runner, options, useWorker, batches = 1) {
|
|
3137
|
+
const { name, benchmarks, baseline, setup, metadata } = group;
|
|
3138
|
+
const setupParams = await setup?.();
|
|
3139
|
+
validateBenchmarkParameters(group);
|
|
3140
|
+
const runParams = {
|
|
3141
|
+
runner,
|
|
3142
|
+
options,
|
|
3143
|
+
useWorker,
|
|
3144
|
+
params: setupParams,
|
|
3145
|
+
metadata
|
|
3146
|
+
};
|
|
3147
|
+
if (batches === 1) return runSingleBatch(name, benchmarks, baseline, runParams);
|
|
3148
|
+
return runMultipleBatches(name, benchmarks, baseline, runParams, batches);
|
|
3149
|
+
}
|
|
3150
|
+
/** Run benchmarks in a single batch */
|
|
3151
|
+
async function runSingleBatch(name, benchmarks, baseline, runParams) {
|
|
3152
|
+
const baselineReport = baseline ? await runSingleBenchmark(baseline, runParams) : void 0;
|
|
3153
|
+
return {
|
|
3154
|
+
name,
|
|
3155
|
+
reports: await serialMap(benchmarks, (b) => runSingleBenchmark(b, runParams)),
|
|
3156
|
+
baseline: baselineReport
|
|
3157
|
+
};
|
|
3158
|
+
}
|
|
3159
|
+
/** Run benchmarks in multiple batches, alternating order to reduce bias */
|
|
3160
|
+
async function runMultipleBatches(name, benchmarks, baseline, runParams, batches) {
|
|
3161
|
+
const timePerBatch = (runParams.options.maxTime || 5e3) / batches;
|
|
3162
|
+
const batchParams = {
|
|
3163
|
+
...runParams,
|
|
3164
|
+
options: {
|
|
3165
|
+
...runParams.options,
|
|
3166
|
+
maxTime: timePerBatch
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
const baselineBatches = [];
|
|
3170
|
+
const benchmarkBatches = /* @__PURE__ */ new Map();
|
|
3171
|
+
for (let i = 0; i < batches; i++) await runBatchIteration(benchmarks, baseline, batchParams, i % 2 === 1, baselineBatches, benchmarkBatches);
|
|
3172
|
+
const meta = runParams.metadata;
|
|
3173
|
+
return mergeBatchResults(name, benchmarks, baseline, baselineBatches, benchmarkBatches, meta);
|
|
3174
|
+
}
|
|
3175
|
+
/** Run one batch iteration in either order */
|
|
3176
|
+
async function runBatchIteration(benchmarks, baseline, runParams, reverseOrder, baselineBatches, benchmarkBatches) {
|
|
3177
|
+
const runBaseline = async () => {
|
|
3178
|
+
if (baseline) {
|
|
3179
|
+
const r = await runSingleBenchmark(baseline, runParams);
|
|
3180
|
+
baselineBatches.push(r.measuredResults);
|
|
3181
|
+
}
|
|
3182
|
+
};
|
|
3183
|
+
const runBenches = async () => {
|
|
3184
|
+
for (const b of benchmarks) {
|
|
3185
|
+
const r = await runSingleBenchmark(b, runParams);
|
|
3186
|
+
appendToMap(benchmarkBatches, b.name, r.measuredResults);
|
|
3187
|
+
}
|
|
3188
|
+
};
|
|
3189
|
+
if (reverseOrder) {
|
|
3190
|
+
await runBenches();
|
|
3191
|
+
await runBaseline();
|
|
3192
|
+
} else {
|
|
3193
|
+
await runBaseline();
|
|
3194
|
+
await runBenches();
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
/** Merge batch results into final ReportGroup */
|
|
3198
|
+
function mergeBatchResults(name, benchmarks, baseline, baselineBatches, benchmarkBatches, metadata) {
|
|
3199
|
+
const mergedBaseline = baseline ? {
|
|
3200
|
+
name: baseline.name,
|
|
3201
|
+
measuredResults: mergeResults(baselineBatches),
|
|
3202
|
+
metadata
|
|
3203
|
+
} : void 0;
|
|
3204
|
+
return {
|
|
3205
|
+
name,
|
|
3206
|
+
reports: benchmarks.map((b) => ({
|
|
3207
|
+
name: b.name,
|
|
3208
|
+
measuredResults: mergeResults(benchmarkBatches.get(b.name) || []),
|
|
3209
|
+
metadata
|
|
3210
|
+
})),
|
|
3211
|
+
baseline: mergedBaseline
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
/** Run single benchmark and create report */
|
|
3215
|
+
async function runSingleBenchmark(spec, runParams) {
|
|
3216
|
+
const { runner, options, useWorker, params, metadata } = runParams;
|
|
3217
|
+
const [result] = await runBenchmark({
|
|
3218
|
+
spec,
|
|
3219
|
+
runner,
|
|
3220
|
+
options,
|
|
3221
|
+
useWorker,
|
|
3222
|
+
params
|
|
3223
|
+
});
|
|
3224
|
+
return {
|
|
3225
|
+
name: spec.name,
|
|
3226
|
+
measuredResults: result,
|
|
3227
|
+
metadata
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
/** Warn if parameterized benchmarks lack setup */
|
|
3231
|
+
function validateBenchmarkParameters(group) {
|
|
3232
|
+
const { name, setup, benchmarks, baseline } = group;
|
|
3233
|
+
if (setup) return;
|
|
3234
|
+
const allBenchmarks = baseline ? [...benchmarks, baseline] : benchmarks;
|
|
3235
|
+
for (const benchmark of allBenchmarks) if (benchmark.fn.length > 0) console.warn(`Benchmark "${benchmark.name}" in group "${name}" expects parameters but no setup() provided.`);
|
|
3236
|
+
}
|
|
3237
|
+
/** Merge multiple batch results into a single MeasuredResults */
|
|
3238
|
+
function mergeResults(results) {
|
|
3239
|
+
if (results.length === 0) throw new Error("Cannot merge empty results array");
|
|
3240
|
+
if (results.length === 1) return results[0];
|
|
3241
|
+
const allSamples = results.flatMap((r) => r.samples);
|
|
3242
|
+
const allWarmup = results.flatMap((r) => r.warmupSamples || []);
|
|
3243
|
+
const time = computeStats(allSamples);
|
|
3244
|
+
const convergence = checkConvergence(allSamples.map((s) => s * msToNs));
|
|
3245
|
+
let offset = 0;
|
|
3246
|
+
const allPausePoints = results.flatMap((r) => {
|
|
3247
|
+
const pts = (r.pausePoints ?? []).map((p) => ({
|
|
3248
|
+
sampleIndex: p.sampleIndex + offset,
|
|
3249
|
+
durationMs: p.durationMs
|
|
3250
|
+
}));
|
|
3251
|
+
offset += r.samples.length;
|
|
3252
|
+
return pts;
|
|
3253
|
+
});
|
|
3254
|
+
return {
|
|
3255
|
+
name: results[0].name,
|
|
3256
|
+
samples: allSamples,
|
|
3257
|
+
warmupSamples: allWarmup.length ? allWarmup : void 0,
|
|
3258
|
+
time,
|
|
3259
|
+
totalTime: results.reduce((sum, r) => sum + (r.totalTime || 0), 0),
|
|
3260
|
+
convergence,
|
|
3261
|
+
pausePoints: allPausePoints.length ? allPausePoints : void 0
|
|
3262
|
+
};
|
|
3263
|
+
}
|
|
3264
|
+
function appendToMap(map, key, value) {
|
|
3265
|
+
if (!map.has(key)) map.set(key, []);
|
|
3266
|
+
map.get(key).push(value);
|
|
3267
|
+
}
|
|
3268
|
+
/** Generate table with standard sections */
|
|
3269
|
+
function defaultReport(groups, args) {
|
|
3270
|
+
const { adaptive, "gc-stats": gcStats, "trace-opt": traceOpt } = args;
|
|
3271
|
+
const hasCpu = hasField(groups, "cpu");
|
|
3272
|
+
const hasOpt = hasField(groups, "optStatus");
|
|
3273
|
+
return reportResults(groups, buildReportSections(adaptive, gcStats, hasCpu, traceOpt && hasOpt));
|
|
3274
|
+
}
|
|
3275
|
+
/** Build report sections based on CLI options */
|
|
3276
|
+
function buildReportSections(adaptive, gcStats, hasCpuData, hasOptData) {
|
|
3277
|
+
const sections = adaptive ? [
|
|
3278
|
+
adaptiveSection,
|
|
3279
|
+
runsSection,
|
|
3280
|
+
totalTimeSection
|
|
3281
|
+
] : [timeSection, runsSection];
|
|
3282
|
+
if (gcStats) sections.push(gcStatsSection);
|
|
3283
|
+
if (hasCpuData) sections.push(cpuSection);
|
|
3284
|
+
if (hasOptData) sections.push(optSection);
|
|
3285
|
+
return sections;
|
|
3286
|
+
}
|
|
3287
|
+
/** Run benchmarks, display table, and optionally generate HTML report */
|
|
3288
|
+
async function benchExports(suite, args) {
|
|
3289
|
+
const results = await runBenchmarks(suite, args);
|
|
3290
|
+
const report = defaultReport(results, args);
|
|
3291
|
+
console.log(report);
|
|
3292
|
+
await finishReports(results, args, suite.name);
|
|
3293
|
+
}
|
|
3294
|
+
/** Run browser profiling via Playwright + CDP, report with standard pipeline */
|
|
3295
|
+
async function browserBenchExports(args) {
|
|
3296
|
+
warnBrowserFlags(args);
|
|
3297
|
+
const url = args.url;
|
|
3298
|
+
const { iterations, time } = args;
|
|
3299
|
+
const result = await profileBrowser({
|
|
3300
|
+
url,
|
|
3301
|
+
heapSample: args["heap-sample"],
|
|
3302
|
+
heapOptions: {
|
|
3303
|
+
samplingInterval: args["heap-interval"],
|
|
3304
|
+
stackDepth: args["heap-depth"]
|
|
3305
|
+
},
|
|
3306
|
+
headless: args.headless,
|
|
3307
|
+
timeout: args.timeout,
|
|
3308
|
+
gcStats: args["gc-stats"],
|
|
3309
|
+
maxTime: iterations ? Number.MAX_SAFE_INTEGER : time * 1e3,
|
|
3310
|
+
maxIterations: iterations
|
|
3311
|
+
});
|
|
3312
|
+
const name = new URL(url).pathname.split("/").pop() || "browser";
|
|
3313
|
+
const hasSamples = result.samples && result.samples.length > 0;
|
|
3314
|
+
const results = browserResultGroups(name, result);
|
|
3315
|
+
if (hasSamples || result.wallTimeMs != null) console.log(reportResults(results, [timeSection, runsSection]));
|
|
3316
|
+
if (result.gcStats) console.log(reportResults(results, [browserGcStatsSection]));
|
|
3317
|
+
if (result.heapProfile) printHeapReports(results, {
|
|
3318
|
+
...cliHeapReportOptions(args),
|
|
3319
|
+
isUserCode: isBrowserUserCode
|
|
3320
|
+
});
|
|
3321
|
+
await exportReports({
|
|
3322
|
+
results,
|
|
3323
|
+
args
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
/** Wrap browser profile result as ReportGroup[] for the standard pipeline */
|
|
3327
|
+
function browserResultGroups(name, result) {
|
|
3328
|
+
const { gcStats, heapProfile } = result;
|
|
3329
|
+
let measured;
|
|
3330
|
+
if (result.samples && result.samples.length > 0) {
|
|
3331
|
+
const { samples } = result;
|
|
3332
|
+
const totalTime = result.wallTimeMs ? result.wallTimeMs / 1e3 : void 0;
|
|
3333
|
+
measured = {
|
|
3334
|
+
name,
|
|
3335
|
+
samples,
|
|
3336
|
+
time: computeStats(samples),
|
|
3337
|
+
totalTime,
|
|
3338
|
+
gcStats,
|
|
3339
|
+
heapProfile
|
|
3340
|
+
};
|
|
3341
|
+
} else {
|
|
3342
|
+
const wallMs = result.wallTimeMs ?? 0;
|
|
3343
|
+
measured = {
|
|
3344
|
+
name,
|
|
3345
|
+
samples: [wallMs],
|
|
3346
|
+
time: {
|
|
3347
|
+
min: wallMs,
|
|
3348
|
+
max: wallMs,
|
|
3349
|
+
avg: wallMs,
|
|
3350
|
+
p50: wallMs,
|
|
3351
|
+
p75: wallMs,
|
|
3352
|
+
p99: wallMs,
|
|
3353
|
+
p999: wallMs
|
|
3354
|
+
},
|
|
3355
|
+
gcStats,
|
|
3356
|
+
heapProfile
|
|
3357
|
+
};
|
|
3358
|
+
}
|
|
3359
|
+
return [{
|
|
3360
|
+
name,
|
|
3361
|
+
reports: [{
|
|
3362
|
+
name,
|
|
3363
|
+
measuredResults: measured
|
|
3364
|
+
}]
|
|
3365
|
+
}];
|
|
3366
|
+
}
|
|
3367
|
+
/** Print heap allocation reports for benchmarks with heap profiles */
|
|
3368
|
+
function printHeapReports(groups, options) {
|
|
3369
|
+
for (const group of groups) {
|
|
3370
|
+
const allReports = group.baseline ? [...group.reports, group.baseline] : group.reports;
|
|
3371
|
+
for (const report of allReports) {
|
|
3372
|
+
const { heapProfile } = report.measuredResults;
|
|
3373
|
+
if (!heapProfile) continue;
|
|
3374
|
+
console.log(dim(`\n─── Heap profile: ${report.name} ───`));
|
|
3375
|
+
const totalAll = totalProfileBytes(heapProfile);
|
|
3376
|
+
const sites = flattenProfile(heapProfile);
|
|
3377
|
+
const userSites = filterSites(sites, options.isUserCode);
|
|
3378
|
+
const totalUserCode = userSites.reduce((sum, s) => sum + s.bytes, 0);
|
|
3379
|
+
const aggregated = aggregateSites(options.userOnly ? userSites : sites);
|
|
3380
|
+
const extra = {
|
|
3381
|
+
totalAll,
|
|
3382
|
+
totalUserCode,
|
|
3383
|
+
sampleCount: heapProfile.samples?.length
|
|
3384
|
+
};
|
|
3385
|
+
console.log(formatHeapReport(aggregated, {
|
|
3386
|
+
...options,
|
|
3387
|
+
...extra
|
|
3388
|
+
}));
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
/** Run benchmarks and display table. Suite is optional with --url (browser mode). */
|
|
3393
|
+
async function runDefaultBench(suite, configureArgs) {
|
|
3394
|
+
const args = parseBenchArgs(configureArgs);
|
|
3395
|
+
if (args.url) await browserBenchExports(args);
|
|
3396
|
+
else if (suite) await benchExports(suite, args);
|
|
3397
|
+
else throw new Error("Either --url or a BenchSuite is required.");
|
|
3398
|
+
}
|
|
3399
|
+
/** Convert CLI args to runner options */
|
|
3400
|
+
function cliToRunnerOptions(args) {
|
|
3401
|
+
const { profile, collect, iterations } = args;
|
|
3402
|
+
if (profile) return {
|
|
3403
|
+
maxIterations: iterations ?? 1,
|
|
3404
|
+
warmupTime: 0,
|
|
3405
|
+
collect
|
|
3406
|
+
};
|
|
3407
|
+
if (args.adaptive) return createAdaptiveOptions(args);
|
|
3408
|
+
return {
|
|
3409
|
+
maxTime: iterations ? Number.POSITIVE_INFINITY : args.time * 1e3,
|
|
3410
|
+
maxIterations: iterations,
|
|
3411
|
+
...cliCommonOptions(args)
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
/** Create options for adaptive mode */
|
|
3415
|
+
function createAdaptiveOptions(args) {
|
|
3416
|
+
return {
|
|
3417
|
+
minTime: (args["min-time"] ?? 1) * 1e3,
|
|
3418
|
+
maxTime: defaultAdaptiveMaxTime * 1e3,
|
|
3419
|
+
targetConfidence: args.convergence,
|
|
3420
|
+
adaptive: true,
|
|
3421
|
+
...cliCommonOptions(args)
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
/** Runner/matrix options shared across all CLI modes */
|
|
3425
|
+
function cliCommonOptions(args) {
|
|
3426
|
+
const { collect, cpu, warmup } = args;
|
|
3427
|
+
const { "trace-opt": traceOpt, "skip-settle": noSettle } = args;
|
|
3428
|
+
const { "pause-first": pauseFirst, "pause-interval": pauseInterval } = args;
|
|
3429
|
+
const { "pause-duration": pauseDuration, "gc-stats": gcStats } = args;
|
|
3430
|
+
const { "heap-sample": heapSample, "heap-interval": heapInterval } = args;
|
|
3431
|
+
const { "heap-depth": heapDepth } = args;
|
|
3432
|
+
return {
|
|
3433
|
+
collect,
|
|
3434
|
+
cpuCounters: cpu,
|
|
3435
|
+
warmup,
|
|
3436
|
+
traceOpt,
|
|
3437
|
+
noSettle,
|
|
3438
|
+
pauseFirst,
|
|
3439
|
+
pauseInterval,
|
|
3440
|
+
pauseDuration,
|
|
3441
|
+
gcStats,
|
|
3442
|
+
heapSample,
|
|
3443
|
+
heapInterval,
|
|
3444
|
+
heapDepth
|
|
3445
|
+
};
|
|
3446
|
+
}
|
|
3447
|
+
const { yellow, dim } = process.env.NODE_ENV === "test" || process.env.VITEST === "true" ? {
|
|
3448
|
+
yellow: (s) => s,
|
|
3449
|
+
dim: (s) => s
|
|
3450
|
+
} : pico;
|
|
3451
|
+
/** Log V8 optimization tier distribution and deoptimizations */
|
|
3452
|
+
function reportOptStatus(groups) {
|
|
3453
|
+
const optData = groups.flatMap(({ reports, baseline }) => {
|
|
3454
|
+
return (baseline ? [...reports, baseline] : reports).filter((r) => r.measuredResults.optStatus).map((r) => ({
|
|
3455
|
+
name: r.name,
|
|
3456
|
+
opt: r.measuredResults.optStatus,
|
|
3457
|
+
samples: r.measuredResults.samples.length
|
|
3458
|
+
}));
|
|
3459
|
+
});
|
|
3460
|
+
if (optData.length === 0) return;
|
|
3461
|
+
console.log(dim("\nV8 optimization:"));
|
|
3462
|
+
for (const { name, opt, samples } of optData) {
|
|
3463
|
+
const total = Object.values(opt.byTier).reduce((s, t) => s + t.count, 0);
|
|
3464
|
+
const tierParts = Object.entries(opt.byTier).sort((a, b) => b[1].count - a[1].count).map(([tier, info]) => `${tier} ${(info.count / total * 100).toFixed(0)}%`).join(", ");
|
|
3465
|
+
console.log(` ${name}: ${tierParts} ${dim(`(${samples} samples)`)}`);
|
|
3466
|
+
}
|
|
3467
|
+
const totalDeopts = optData.reduce((s, d) => s + d.opt.deoptCount, 0);
|
|
3468
|
+
if (totalDeopts > 0) console.log(yellow(` ⚠ ${totalDeopts} deoptimization${totalDeopts > 1 ? "s" : ""} detected`));
|
|
3469
|
+
}
|
|
3470
|
+
/** @return true if any result has the specified field with a defined value */
|
|
3471
|
+
function hasField(results, field) {
|
|
3472
|
+
return results.some(({ reports, baseline }) => {
|
|
3473
|
+
return (baseline ? [...reports, baseline] : reports).some(({ measuredResults }) => measuredResults[field] !== void 0);
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
3476
|
+
/** Print heap reports (if enabled) and export results */
|
|
3477
|
+
async function finishReports(results, args, suiteName, exportOptions) {
|
|
3478
|
+
if (args["heap-sample"]) printHeapReports(results, cliHeapReportOptions(args));
|
|
3479
|
+
await exportReports({
|
|
3480
|
+
results,
|
|
3481
|
+
args,
|
|
3482
|
+
suiteName,
|
|
3483
|
+
...exportOptions
|
|
3484
|
+
});
|
|
3485
|
+
}
|
|
3486
|
+
/** Export reports (HTML, JSON, Perfetto) based on CLI args */
|
|
3487
|
+
async function exportReports(options) {
|
|
3488
|
+
const { results, args, sections, suiteName } = options;
|
|
3489
|
+
const { currentVersion, baselineVersion } = options;
|
|
3490
|
+
const openInBrowser = args.html && !args["export-html"];
|
|
3491
|
+
let closeServer;
|
|
3492
|
+
if (args.html || args["export-html"]) closeServer = (await generateHtmlReport(prepareHtmlData(results, {
|
|
3493
|
+
cliArgs: args,
|
|
3494
|
+
sections,
|
|
3495
|
+
currentVersion,
|
|
3496
|
+
baselineVersion
|
|
3497
|
+
}), {
|
|
3498
|
+
openBrowser: openInBrowser,
|
|
3499
|
+
outputPath: args["export-html"]
|
|
3500
|
+
})).closeServer;
|
|
3501
|
+
if (args.json) await exportBenchmarkJson(results, args.json, args, suiteName);
|
|
3502
|
+
if (args.perfetto) exportPerfettoTrace(results, args.perfetto, args);
|
|
3503
|
+
if (openInBrowser) {
|
|
3504
|
+
await waitForCtrlC();
|
|
3505
|
+
closeServer?.();
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
/** Wait for Ctrl+C before exiting */
|
|
3509
|
+
function waitForCtrlC() {
|
|
3510
|
+
return new Promise((resolve) => {
|
|
3511
|
+
console.log(dim("\nPress Ctrl+C to exit"));
|
|
3512
|
+
process.on("SIGINT", () => {
|
|
3513
|
+
console.log();
|
|
3514
|
+
resolve();
|
|
3515
|
+
});
|
|
3516
|
+
});
|
|
3517
|
+
}
|
|
3518
|
+
/** Run matrix suite with CLI arguments.
|
|
3519
|
+
* no options ==> defaultCases/defaultVariants, --filter ==> subset of defaults,
|
|
3520
|
+
* --all --filter ==> subset of all, --all ==> all cases/variants */
|
|
3521
|
+
async function runMatrixSuite(suite, args) {
|
|
3522
|
+
validateArgs(args);
|
|
3523
|
+
const filter = args.filter ? parseMatrixFilter(args.filter) : void 0;
|
|
3524
|
+
const options = cliToMatrixOptions(args);
|
|
3525
|
+
const results = [];
|
|
3526
|
+
for (const matrix of suite.matrices) {
|
|
3527
|
+
const casesModule = matrix.casesModule ? await loadCasesModule(matrix.casesModule) : void 0;
|
|
3528
|
+
let filtered = matrix;
|
|
3529
|
+
if (!args.all && casesModule) filtered = {
|
|
3530
|
+
...matrix,
|
|
3531
|
+
filteredCases: casesModule.defaultCases,
|
|
3532
|
+
filteredVariants: casesModule.defaultVariants
|
|
3533
|
+
};
|
|
3534
|
+
if (filter) filtered = await filterMatrix(filtered, filter);
|
|
3535
|
+
const { filteredCases, filteredVariants } = filtered;
|
|
3536
|
+
results.push(await runMatrix(filtered, {
|
|
3537
|
+
...options,
|
|
3538
|
+
filteredCases,
|
|
3539
|
+
filteredVariants
|
|
3540
|
+
}));
|
|
3541
|
+
}
|
|
3542
|
+
return results;
|
|
3543
|
+
}
|
|
3544
|
+
/** Convert CLI args to matrix run options */
|
|
3545
|
+
function cliToMatrixOptions(args) {
|
|
3546
|
+
const { time, iterations, worker } = args;
|
|
3547
|
+
return {
|
|
3548
|
+
iterations,
|
|
3549
|
+
maxTime: iterations ? void 0 : time * 1e3,
|
|
3550
|
+
useWorker: worker,
|
|
3551
|
+
...cliCommonOptions(args)
|
|
3552
|
+
};
|
|
3553
|
+
}
|
|
3554
|
+
/** Generate report for matrix results. Uses same sections as regular benchmarks. */
|
|
3555
|
+
function defaultMatrixReport(results, reportOptions, args) {
|
|
3556
|
+
const options = args ? mergeMatrixDefaults(reportOptions, args, results) : reportOptions;
|
|
3557
|
+
return results.map((r) => reportMatrixResults(r, options)).join("\n\n");
|
|
3558
|
+
}
|
|
3559
|
+
/** @return HeapReportOptions from CLI args */
|
|
3560
|
+
function cliHeapReportOptions(args) {
|
|
3561
|
+
return {
|
|
3562
|
+
topN: args["heap-rows"],
|
|
3563
|
+
stackDepth: args["heap-stack"],
|
|
3564
|
+
verbose: args["heap-verbose"],
|
|
3565
|
+
userOnly: args["heap-user-only"]
|
|
3566
|
+
};
|
|
3567
|
+
}
|
|
3568
|
+
/** Apply default sections and extra columns for matrix reports */
|
|
3569
|
+
function mergeMatrixDefaults(reportOptions, args, results) {
|
|
3570
|
+
const result = { ...reportOptions };
|
|
3571
|
+
if (!result.sections?.length) {
|
|
3572
|
+
const groups = matrixToReportGroups(results);
|
|
3573
|
+
result.sections = buildReportSections(args.adaptive, args["gc-stats"], hasField(groups, "cpu"), args["trace-opt"] && hasField(groups, "optStatus"));
|
|
3574
|
+
}
|
|
3575
|
+
return result;
|
|
3576
|
+
}
|
|
3577
|
+
/** Run matrix suite with full CLI handling (parse, run, report, export) */
|
|
3578
|
+
async function runDefaultMatrixBench(suite, configureArgs, reportOptions) {
|
|
3579
|
+
await matrixBenchExports(suite, parseBenchArgs(configureArgs), reportOptions);
|
|
3580
|
+
}
|
|
3581
|
+
/** Convert MatrixResults to ReportGroup[] for export compatibility */
|
|
3582
|
+
function matrixToReportGroups(results) {
|
|
3583
|
+
return results.flatMap((matrix) => matrix.variants.flatMap((variant) => variant.cases.map((c) => {
|
|
3584
|
+
const { metadata } = c;
|
|
3585
|
+
const report = {
|
|
3586
|
+
name: variant.id,
|
|
3587
|
+
measuredResults: c.measured,
|
|
3588
|
+
metadata
|
|
3589
|
+
};
|
|
3590
|
+
const baseline = c.baseline ? {
|
|
3591
|
+
name: `${variant.id} (baseline)`,
|
|
3592
|
+
measuredResults: c.baseline,
|
|
3593
|
+
metadata
|
|
3594
|
+
} : void 0;
|
|
3595
|
+
return {
|
|
3596
|
+
name: `${variant.id} / ${c.caseId}`,
|
|
3597
|
+
reports: [report],
|
|
3598
|
+
baseline
|
|
3599
|
+
};
|
|
3600
|
+
})));
|
|
3601
|
+
}
|
|
3602
|
+
/** Sequential map - like Promise.all(arr.map(fn)) but runs one at a time */
|
|
3603
|
+
async function serialMap(arr, fn) {
|
|
3604
|
+
const results = [];
|
|
3605
|
+
for (const item of arr) results.push(await fn(item));
|
|
3606
|
+
return results;
|
|
3607
|
+
}
|
|
3608
|
+
/** Run matrix benchmarks, display table, and generate exports */
|
|
3609
|
+
async function matrixBenchExports(suite, args, reportOptions, exportOptions) {
|
|
3610
|
+
const results = await runMatrixSuite(suite, args);
|
|
3611
|
+
const report = defaultMatrixReport(results, reportOptions, args);
|
|
3612
|
+
console.log(report);
|
|
3613
|
+
await finishReports(matrixToReportGroups(results), args, suite.name, exportOptions);
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
//#endregion
|
|
3617
|
+
//#region src/GitUtils.ts
|
|
3618
|
+
/** Get current git version info. For dirty repos, uses most recent modified file date. */
|
|
3619
|
+
function getCurrentGitVersion() {
|
|
3620
|
+
try {
|
|
3621
|
+
const exec = (cmd) => execSync(cmd, { encoding: "utf-8" }).trim();
|
|
3622
|
+
const hash = exec("git rev-parse --short HEAD");
|
|
3623
|
+
const commitDate = exec("git log -1 --format=%aI");
|
|
3624
|
+
const dirty = exec("git status --porcelain").length > 0;
|
|
3625
|
+
return {
|
|
3626
|
+
hash,
|
|
3627
|
+
date: dirty ? getMostRecentModifiedDate(".") ?? commitDate : commitDate,
|
|
3628
|
+
dirty
|
|
3629
|
+
};
|
|
3630
|
+
} catch {
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
/** Read baseline version from .baseline-version file */
|
|
3635
|
+
function getBaselineVersion(baselineDir = "_baseline") {
|
|
3636
|
+
const versionFile = join(baselineDir, ".baseline-version");
|
|
3637
|
+
if (!existsSync(versionFile)) return void 0;
|
|
3638
|
+
try {
|
|
3639
|
+
const content = readFileSync(versionFile, "utf-8");
|
|
3640
|
+
const data = JSON.parse(content);
|
|
3641
|
+
return {
|
|
3642
|
+
hash: data.hash,
|
|
3643
|
+
date: data.date
|
|
3644
|
+
};
|
|
3645
|
+
} catch {
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
/** Format git version for display: "abc1234 (Jan 9, 2026, 3:45 PM)" or "abc1234*" if dirty */
|
|
3650
|
+
function formatGitVersion(version) {
|
|
3651
|
+
return `${version.dirty ? `${version.hash}*` : version.hash} (${formatDateWithTimezone(version.date)})`;
|
|
3652
|
+
}
|
|
3653
|
+
/** Get most recent modified file date in a directory (for dirty repos) */
|
|
3654
|
+
function getMostRecentModifiedDate(dir) {
|
|
3655
|
+
try {
|
|
3656
|
+
const modifiedFiles = execSync("git status --porcelain", {
|
|
3657
|
+
encoding: "utf-8",
|
|
3658
|
+
cwd: dir
|
|
3659
|
+
}).trim().split("\n").filter((line) => line.length > 0).map((line) => line.slice(3));
|
|
3660
|
+
if (modifiedFiles.length === 0) return void 0;
|
|
3661
|
+
let mostRecent = 0;
|
|
3662
|
+
for (const file of modifiedFiles) try {
|
|
3663
|
+
const filePath = join(dir, file);
|
|
3664
|
+
if (!existsSync(filePath)) continue;
|
|
3665
|
+
const mtime = statSync(filePath).mtimeMs;
|
|
3666
|
+
if (mtime > mostRecent) mostRecent = mtime;
|
|
3667
|
+
} catch {}
|
|
3668
|
+
return mostRecent > 0 ? new Date(mostRecent).toISOString() : void 0;
|
|
3669
|
+
} catch {
|
|
3670
|
+
return;
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
//#endregion
|
|
3675
|
+
export { timeSection as A, parseCliArgs as B, adaptiveSection as C, gcStatsSection as D, gcSection as E, generateHtmlReport as F, truncate as G, formatBytes as H, formatDateWithTimezone as I, average as J, isStatefulVariant as K, prepareHtmlData as L, formatConvergence as M, filterMatrix as N, optSection as O, parseMatrixFilter as P, exportPerfettoTrace as R, reportMatrixResults as S, cpuSection as T, integer as U, reportResults as V, timeMs as W, loadCasesModule as X, loadCaseData as Y, runDefaultMatrixBench as _, cliToMatrixOptions as a, gcStatsColumns as b, exportReports as c, matrixToReportGroups as d, parseBenchArgs as f, runDefaultBench as g, runBenchmarks as h, benchExports as i, totalTimeSection as j, runsSection as k, hasField as l, reportOptStatus as m, getBaselineVersion as n, defaultMatrixReport as o, printHeapReports as p, runMatrix as q, getCurrentGitVersion as r, defaultReport as s, formatGitVersion as t, matrixBenchExports as u, runMatrixSuite as v, buildGenericSections as w, heapTotalColumn as x, gcPauseColumn as y, defaultCliArgs as z };
|
|
3676
|
+
//# sourceMappingURL=src-CGuaC3Wo.mjs.map
|