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,176 @@
|
|
|
1
|
+
const outlierMultiplier = 1.5; // Tukey's fence multiplier
|
|
2
|
+
const bootstrapSamples = 10000;
|
|
3
|
+
const confidence = 0.95;
|
|
4
|
+
|
|
5
|
+
/** Options for bootstrap resampling methods */
|
|
6
|
+
type BootstrapOptions = {
|
|
7
|
+
resamples?: number;
|
|
8
|
+
confidence?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Bootstrap estimate with confidence interval and raw resample data */
|
|
12
|
+
export interface BootstrapResult {
|
|
13
|
+
estimate: number;
|
|
14
|
+
ci: [number, number];
|
|
15
|
+
samples: number[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @return relative standard deviation (coefficient of variation) */
|
|
19
|
+
export function coefficientOfVariation(samples: number[]): number {
|
|
20
|
+
const mean = average(samples);
|
|
21
|
+
if (mean === 0) return 0;
|
|
22
|
+
const stdDev = standardDeviation(samples);
|
|
23
|
+
return stdDev / mean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @return median absolute deviation for robust variability measure */
|
|
27
|
+
export function medianAbsoluteDeviation(samples: number[]): number {
|
|
28
|
+
const median = percentile(samples, 0.5);
|
|
29
|
+
const deviations = samples.map(x => Math.abs(x - median));
|
|
30
|
+
return percentile(deviations, 0.5);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @return outliers detected via Tukey's interquartile range method */
|
|
34
|
+
export function findOutliers(samples: number[]): {
|
|
35
|
+
rate: number;
|
|
36
|
+
indices: number[];
|
|
37
|
+
} {
|
|
38
|
+
const q1 = percentile(samples, 0.25);
|
|
39
|
+
const q3 = percentile(samples, 0.75);
|
|
40
|
+
const iqr = q3 - q1;
|
|
41
|
+
const lowerBound = q1 - outlierMultiplier * iqr;
|
|
42
|
+
const upperBound = q3 + outlierMultiplier * iqr;
|
|
43
|
+
|
|
44
|
+
const indices = samples
|
|
45
|
+
.map((v, i) => (v < lowerBound || v > upperBound ? i : -1))
|
|
46
|
+
.filter(i => i >= 0);
|
|
47
|
+
return { rate: indices.length / samples.length, indices };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @return bootstrap confidence interval for median */
|
|
51
|
+
export function bootstrapMedian(
|
|
52
|
+
samples: number[],
|
|
53
|
+
options: BootstrapOptions = {},
|
|
54
|
+
): BootstrapResult {
|
|
55
|
+
const { resamples = bootstrapSamples, confidence: conf = confidence } =
|
|
56
|
+
options;
|
|
57
|
+
const medians = generateMedians(samples, resamples);
|
|
58
|
+
const ci = computeInterval(medians, conf);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
estimate: percentile(samples, 0.5),
|
|
62
|
+
ci,
|
|
63
|
+
samples: medians,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @return mean of values */
|
|
68
|
+
export function average(values: number[]): number {
|
|
69
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
70
|
+
return sum / values.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @return standard deviation with Bessel's correction */
|
|
74
|
+
export function standardDeviation(samples: number[]): number {
|
|
75
|
+
if (samples.length <= 1) return 0;
|
|
76
|
+
const mean = average(samples);
|
|
77
|
+
const variance =
|
|
78
|
+
samples.reduce((sum, x) => sum + (x - mean) ** 2, 0) / (samples.length - 1);
|
|
79
|
+
return Math.sqrt(variance);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @return value at percentile p (0-1) */
|
|
83
|
+
export function percentile(values: number[], p: number): number {
|
|
84
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
85
|
+
const index = Math.ceil(sorted.length * p) - 1;
|
|
86
|
+
return sorted[Math.max(0, index)];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @return medians from bootstrap resamples */
|
|
90
|
+
function generateMedians(samples: number[], resamples: number): number[] {
|
|
91
|
+
return Array.from({ length: resamples }, () =>
|
|
92
|
+
percentile(createResample(samples), 0.5),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @return bootstrap resample with replacement */
|
|
97
|
+
export function createResample(samples: number[]): number[] {
|
|
98
|
+
const n = samples.length;
|
|
99
|
+
const rand = () => samples[Math.floor(Math.random() * n)];
|
|
100
|
+
return Array.from({ length: n }, rand);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @return confidence interval [lower, upper] */
|
|
104
|
+
function computeInterval(
|
|
105
|
+
medians: number[],
|
|
106
|
+
confidence: number,
|
|
107
|
+
): [number, number] {
|
|
108
|
+
const alpha = (1 - confidence) / 2;
|
|
109
|
+
const lower = percentile(medians, alpha);
|
|
110
|
+
const upper = percentile(medians, 1 - alpha);
|
|
111
|
+
return [lower, upper];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type CIDirection = "faster" | "slower" | "uncertain";
|
|
115
|
+
|
|
116
|
+
/** Binned histogram for efficient transfer to browser */
|
|
117
|
+
export interface HistogramBin {
|
|
118
|
+
x: number; // bin center
|
|
119
|
+
count: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Bootstrap confidence interval for percentage difference between two samples */
|
|
123
|
+
export interface DifferenceCI {
|
|
124
|
+
percent: number;
|
|
125
|
+
ci: [number, number];
|
|
126
|
+
direction: CIDirection;
|
|
127
|
+
/** Histogram of bootstrap distribution for visualization */
|
|
128
|
+
histogram?: HistogramBin[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Bin values into histogram for compact visualization */
|
|
132
|
+
function binValues(values: number[], binCount = 30): HistogramBin[] {
|
|
133
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
134
|
+
const min = sorted[0];
|
|
135
|
+
const max = sorted[sorted.length - 1];
|
|
136
|
+
if (min === max) return [{ x: min, count: values.length }];
|
|
137
|
+
|
|
138
|
+
const step = (max - min) / binCount;
|
|
139
|
+
const counts = new Array(binCount).fill(0);
|
|
140
|
+
for (const v of values) {
|
|
141
|
+
const bin = Math.min(Math.floor((v - min) / step), binCount - 1);
|
|
142
|
+
counts[bin]++;
|
|
143
|
+
}
|
|
144
|
+
return counts.map((count, i) => ({ x: min + (i + 0.5) * step, count }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @return bootstrap CI for percentage difference between baseline and current medians */
|
|
148
|
+
export function bootstrapDifferenceCI(
|
|
149
|
+
baseline: number[],
|
|
150
|
+
current: number[],
|
|
151
|
+
options: BootstrapOptions = {},
|
|
152
|
+
): DifferenceCI {
|
|
153
|
+
const { resamples = bootstrapSamples, confidence: conf = confidence } =
|
|
154
|
+
options;
|
|
155
|
+
|
|
156
|
+
const baselineMedian = percentile(baseline, 0.5);
|
|
157
|
+
const currentMedian = percentile(current, 0.5);
|
|
158
|
+
const observedPercent =
|
|
159
|
+
((currentMedian - baselineMedian) / baselineMedian) * 100;
|
|
160
|
+
|
|
161
|
+
const diffs: number[] = [];
|
|
162
|
+
for (let i = 0; i < resamples; i++) {
|
|
163
|
+
const resB = createResample(baseline);
|
|
164
|
+
const resC = createResample(current);
|
|
165
|
+
const medB = percentile(resB, 0.5);
|
|
166
|
+
const medC = percentile(resC, 0.5);
|
|
167
|
+
diffs.push(((medC - medB) / medB) * 100);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ci = computeInterval(diffs, conf);
|
|
171
|
+
const excludesZero = ci[0] > 0 || ci[1] < 0;
|
|
172
|
+
let direction: CIDirection = "uncertain";
|
|
173
|
+
if (excludesZero) direction = observedPercent < 0 ? "faster" : "slower";
|
|
174
|
+
const histogram = binValues(diffs);
|
|
175
|
+
return { percent: observedPercent, ci, direction, histogram };
|
|
176
|
+
}
|
package/src/TypeUtil.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aggregateGcStats,
|
|
3
|
+
type GcEvent,
|
|
4
|
+
type GcStats,
|
|
5
|
+
} from "../runners/GcStats.ts";
|
|
6
|
+
|
|
7
|
+
/** CDP trace event from Tracing.dataCollected */
|
|
8
|
+
export interface TraceEvent {
|
|
9
|
+
cat: string;
|
|
10
|
+
name: string;
|
|
11
|
+
ph: string;
|
|
12
|
+
dur?: number; // microseconds
|
|
13
|
+
args?: Record<string, any>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Parse CDP trace events (MinorGC/MajorGC) into GcEvent[] */
|
|
17
|
+
export function parseGcTraceEvents(traceEvents: TraceEvent[]): GcEvent[] {
|
|
18
|
+
return traceEvents.flatMap(e => {
|
|
19
|
+
if (e.ph !== "X") return [];
|
|
20
|
+
const type = gcType(e.name);
|
|
21
|
+
if (!type) return [];
|
|
22
|
+
const durUs = e.dur ?? 0;
|
|
23
|
+
const heapBefore: number = e.args?.usedHeapSizeBefore ?? 0;
|
|
24
|
+
const heapAfter: number = e.args?.usedHeapSizeAfter ?? 0;
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
type,
|
|
28
|
+
pauseMs: durUs / 1000,
|
|
29
|
+
collected: Math.max(0, heapBefore - heapAfter),
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function gcType(name: string): GcEvent["type"] | undefined {
|
|
36
|
+
if (name === "MinorGC") return "scavenge";
|
|
37
|
+
if (name === "MajorGC") return "mark-compact";
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parse CDP trace events and aggregate into GcStats */
|
|
42
|
+
export function browserGcStats(traceEvents: TraceEvent[]): GcStats {
|
|
43
|
+
return aggregateGcStats(parseGcTraceEvents(traceEvents));
|
|
44
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { type CDPSession, chromium, type Page } from "playwright";
|
|
2
|
+
import type {
|
|
3
|
+
HeapProfile,
|
|
4
|
+
HeapSampleOptions,
|
|
5
|
+
} from "../heap-sample/HeapSampler.ts";
|
|
6
|
+
import type { GcStats } from "../runners/GcStats.ts";
|
|
7
|
+
import { browserGcStats, type TraceEvent } from "./BrowserGcStats.ts";
|
|
8
|
+
|
|
9
|
+
export interface BrowserProfileParams {
|
|
10
|
+
url: string;
|
|
11
|
+
heapSample?: boolean;
|
|
12
|
+
heapOptions?: HeapSampleOptions;
|
|
13
|
+
gcStats?: boolean;
|
|
14
|
+
headless?: boolean;
|
|
15
|
+
timeout?: number; // seconds
|
|
16
|
+
maxTime?: number; // ms, bench function iteration time limit
|
|
17
|
+
maxIterations?: number; // exact iteration count (bench function mode)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BrowserProfileResult {
|
|
21
|
+
heapProfile?: HeapProfile;
|
|
22
|
+
gcStats?: GcStats;
|
|
23
|
+
/** Wall-clock ms (lap mode: first start to done, bench function: total loop) */
|
|
24
|
+
wallTimeMs?: number;
|
|
25
|
+
/** Per-iteration timing samples (ms) from bench function or lap mode */
|
|
26
|
+
samples?: number[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface LapModeHandle {
|
|
30
|
+
promise: Promise<BrowserProfileResult>;
|
|
31
|
+
cancel: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Run browser benchmark, auto-detecting page API mode.
|
|
35
|
+
* Bench function (window.__bench): CLI controls iteration and timing.
|
|
36
|
+
* Lap mode (__start/__lap/__done): page controls the measured region. */
|
|
37
|
+
export async function profileBrowser(
|
|
38
|
+
params: BrowserProfileParams,
|
|
39
|
+
): Promise<BrowserProfileResult> {
|
|
40
|
+
const { url, headless = true, timeout = 60 } = params;
|
|
41
|
+
const { gcStats: collectGc } = params;
|
|
42
|
+
const { samplingInterval = 32768 } = params.heapOptions ?? {};
|
|
43
|
+
|
|
44
|
+
const browser = await chromium.launch({ headless });
|
|
45
|
+
try {
|
|
46
|
+
const page = await browser.newPage();
|
|
47
|
+
page.setDefaultTimeout(timeout * 1000);
|
|
48
|
+
const cdp = await page.context().newCDPSession(page);
|
|
49
|
+
|
|
50
|
+
const pageErrors: string[] = [];
|
|
51
|
+
page.on("pageerror", err => pageErrors.push(err.message));
|
|
52
|
+
|
|
53
|
+
const traceEvents = collectGc ? await startGcTracing(cdp) : [];
|
|
54
|
+
const lapMode = await setupLapMode(
|
|
55
|
+
page,
|
|
56
|
+
cdp,
|
|
57
|
+
params,
|
|
58
|
+
samplingInterval,
|
|
59
|
+
timeout,
|
|
60
|
+
pageErrors,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await page.goto(url, { waitUntil: "load" });
|
|
64
|
+
const hasBench = await page.evaluate(
|
|
65
|
+
() => typeof (globalThis as any).__bench === "function",
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
let result: BrowserProfileResult;
|
|
69
|
+
if (hasBench) {
|
|
70
|
+
lapMode.cancel();
|
|
71
|
+
lapMode.promise.catch(() => {}); // suppress unused rejection
|
|
72
|
+
result = await runBenchLoop(page, cdp, params, samplingInterval);
|
|
73
|
+
} else {
|
|
74
|
+
result = await lapMode.promise;
|
|
75
|
+
lapMode.cancel();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (collectGc) {
|
|
79
|
+
result = { ...result, gcStats: await collectTracing(cdp, traceEvents) };
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
} finally {
|
|
83
|
+
await browser.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Inject __start/__lap as in-page functions, expose __done for results collection.
|
|
88
|
+
* __start/__lap are pure in-page (zero CDP overhead). First __start() triggers
|
|
89
|
+
* instrument start. __done() stops instruments and collects timing data. */
|
|
90
|
+
async function setupLapMode(
|
|
91
|
+
page: Page,
|
|
92
|
+
cdp: CDPSession,
|
|
93
|
+
params: BrowserProfileParams,
|
|
94
|
+
samplingInterval: number,
|
|
95
|
+
timeout: number,
|
|
96
|
+
pageErrors: string[],
|
|
97
|
+
): Promise<LapModeHandle> {
|
|
98
|
+
const { heapSample } = params;
|
|
99
|
+
const { promise, resolve, reject } =
|
|
100
|
+
Promise.withResolvers<BrowserProfileResult>();
|
|
101
|
+
let instrumentsStarted = false;
|
|
102
|
+
|
|
103
|
+
await page.exposeFunction("__benchInstrumentStart", async () => {
|
|
104
|
+
if (instrumentsStarted) return;
|
|
105
|
+
instrumentsStarted = true;
|
|
106
|
+
if (heapSample) {
|
|
107
|
+
await cdp.send(
|
|
108
|
+
"HeapProfiler.startSampling",
|
|
109
|
+
heapSamplingParams(samplingInterval),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await page.exposeFunction(
|
|
115
|
+
"__benchCollect",
|
|
116
|
+
async (samples: number[], wallTimeMs: number) => {
|
|
117
|
+
let heapProfile: HeapProfile | undefined;
|
|
118
|
+
if (heapSample && instrumentsStarted) {
|
|
119
|
+
const result = await cdp.send("HeapProfiler.stopSampling");
|
|
120
|
+
heapProfile = result.profile as unknown as HeapProfile;
|
|
121
|
+
}
|
|
122
|
+
resolve({ samples, heapProfile, wallTimeMs });
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
await page.addInitScript(injectLapFunctions);
|
|
127
|
+
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
const lines = [`Timed out after ${timeout}s`];
|
|
130
|
+
if (pageErrors.length) {
|
|
131
|
+
lines.push("Page JS errors:", ...pageErrors.map(e => ` ${e}`));
|
|
132
|
+
} else {
|
|
133
|
+
lines.push("Page did not call __done() or define window.__bench");
|
|
134
|
+
}
|
|
135
|
+
reject(new Error(lines.join("\n")));
|
|
136
|
+
}, timeout * 1000);
|
|
137
|
+
|
|
138
|
+
return { promise, cancel: () => clearTimeout(timer) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** In-page timing functions injected via addInitScript (zero CDP overhead).
|
|
142
|
+
* __start/__lap collect timestamps, __done delegates to exposed __benchCollect. */
|
|
143
|
+
function injectLapFunctions(): void {
|
|
144
|
+
const g = globalThis as any;
|
|
145
|
+
g.__benchSamples = [];
|
|
146
|
+
g.__benchLastTime = 0;
|
|
147
|
+
g.__benchFirstStart = 0;
|
|
148
|
+
|
|
149
|
+
g.__start = () => {
|
|
150
|
+
const now = performance.now();
|
|
151
|
+
g.__benchLastTime = now;
|
|
152
|
+
if (!g.__benchFirstStart) {
|
|
153
|
+
g.__benchFirstStart = now;
|
|
154
|
+
return g.__benchInstrumentStart();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
g.__lap = () => {
|
|
159
|
+
const now = performance.now();
|
|
160
|
+
g.__benchSamples.push(now - g.__benchLastTime);
|
|
161
|
+
g.__benchLastTime = now;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
g.__done = () => {
|
|
165
|
+
const wall = g.__benchFirstStart
|
|
166
|
+
? performance.now() - g.__benchFirstStart
|
|
167
|
+
: 0;
|
|
168
|
+
return g.__benchCollect(g.__benchSamples.slice(), wall);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function heapSamplingParams(samplingInterval: number) {
|
|
173
|
+
return {
|
|
174
|
+
samplingInterval,
|
|
175
|
+
includeObjectsCollectedByMajorGC: true,
|
|
176
|
+
includeObjectsCollectedByMinorGC: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Start CDP GC tracing, returns the event collector array. */
|
|
181
|
+
async function startGcTracing(cdp: CDPSession): Promise<TraceEvent[]> {
|
|
182
|
+
const events: TraceEvent[] = [];
|
|
183
|
+
cdp.on("Tracing.dataCollected", ({ value }) => {
|
|
184
|
+
for (const e of value) events.push(e as unknown as TraceEvent);
|
|
185
|
+
});
|
|
186
|
+
await cdp.send("Tracing.start", {
|
|
187
|
+
traceConfig: { includedCategories: ["v8", "v8.gc"] },
|
|
188
|
+
});
|
|
189
|
+
return events;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Bench function mode: run window.__bench in a timed iteration loop. */
|
|
193
|
+
async function runBenchLoop(
|
|
194
|
+
page: Page,
|
|
195
|
+
cdp: CDPSession,
|
|
196
|
+
params: BrowserProfileParams,
|
|
197
|
+
samplingInterval: number,
|
|
198
|
+
): Promise<BrowserProfileResult> {
|
|
199
|
+
const { heapSample } = params;
|
|
200
|
+
const maxTime = params.maxTime ?? 642;
|
|
201
|
+
const maxIter = params.maxIterations ?? Number.MAX_SAFE_INTEGER;
|
|
202
|
+
|
|
203
|
+
if (heapSample) {
|
|
204
|
+
await cdp.send(
|
|
205
|
+
"HeapProfiler.startSampling",
|
|
206
|
+
heapSamplingParams(samplingInterval),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { samples, totalMs } = await page.evaluate(
|
|
211
|
+
async ({ maxTime, maxIter }) => {
|
|
212
|
+
const bench = (globalThis as any).__bench;
|
|
213
|
+
const samples: number[] = [];
|
|
214
|
+
const startAll = performance.now();
|
|
215
|
+
const deadline = startAll + maxTime;
|
|
216
|
+
for (let i = 0; i < maxIter && performance.now() < deadline; i++) {
|
|
217
|
+
const t0 = performance.now();
|
|
218
|
+
await bench();
|
|
219
|
+
samples.push(performance.now() - t0);
|
|
220
|
+
}
|
|
221
|
+
return { samples, totalMs: performance.now() - startAll };
|
|
222
|
+
},
|
|
223
|
+
{ maxTime, maxIter },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
let heapProfile: HeapProfile | undefined;
|
|
227
|
+
if (heapSample) {
|
|
228
|
+
const result = await cdp.send("HeapProfiler.stopSampling");
|
|
229
|
+
heapProfile = result.profile as unknown as HeapProfile;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { samples, heapProfile, wallTimeMs: totalMs };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Stop CDP tracing and parse GC events into GcStats. */
|
|
236
|
+
async function collectTracing(
|
|
237
|
+
cdp: CDPSession,
|
|
238
|
+
traceEvents: TraceEvent[],
|
|
239
|
+
): Promise<GcStats> {
|
|
240
|
+
const complete = new Promise<void>(resolve =>
|
|
241
|
+
cdp.once("Tracing.tracingComplete", () => resolve()),
|
|
242
|
+
);
|
|
243
|
+
await cdp.send("Tracing.end");
|
|
244
|
+
await complete;
|
|
245
|
+
return browserGcStats(traceEvents);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export { profileBrowser as profileBrowserHeap };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Argv } from "yargs";
|
|
2
|
+
import yargs from "yargs";
|
|
3
|
+
|
|
4
|
+
export const defaultTime = 0.642;
|
|
5
|
+
export const defaultAdaptiveMaxTime = 20;
|
|
6
|
+
export const defaultPauseInterval = 0;
|
|
7
|
+
export const defaultPauseDuration = 100;
|
|
8
|
+
|
|
9
|
+
export type Configure<T> = (yargs: Argv) => Argv<T>;
|
|
10
|
+
|
|
11
|
+
/** @return CLI args type from builder function */
|
|
12
|
+
export type DefaultCliArgs =
|
|
13
|
+
ReturnType<typeof defaultCliArgs> extends Argv<infer T> ? T : never;
|
|
14
|
+
|
|
15
|
+
// biome-ignore format: compact option definitions
|
|
16
|
+
const cliOptions = {
|
|
17
|
+
time: { type: "number", default: defaultTime, requiresArg: true, describe: "test duration in seconds" },
|
|
18
|
+
cpu: { type: "boolean", default: false, describe: "CPU counter measurements (requires root)" },
|
|
19
|
+
collect: { type: "boolean", default: false, describe: "force GC after each iteration" },
|
|
20
|
+
"gc-stats": { type: "boolean", default: false, describe: "collect GC statistics (Node: --trace-gc-nvp, browser: CDP tracing)" },
|
|
21
|
+
profile: { type: "boolean", default: false, describe: "run once for profiling" },
|
|
22
|
+
filter: { type: "string", requiresArg: true, describe: "filter benchmarks by regex or substring" },
|
|
23
|
+
all: { type: "boolean", default: false, describe: "run all cases (ignore defaultCases)" },
|
|
24
|
+
worker: { type: "boolean", default: true, describe: "run in worker process for isolation (default: true)" },
|
|
25
|
+
adaptive: { type: "boolean", default: false, describe: "use adaptive sampling mode" },
|
|
26
|
+
"min-time": { type: "number", default: 1, describe: "minimum time in seconds before adaptive convergence can stop" },
|
|
27
|
+
convergence: { type: "number", default: 95, describe: "confidence threshold (0-100)" },
|
|
28
|
+
warmup: { type: "number", default: 0, describe: "warmup iterations before measurement" },
|
|
29
|
+
html: { type: "boolean", default: false, describe: "generate HTML report and open in browser" },
|
|
30
|
+
"export-html": { type: "string", requiresArg: true, describe: "export HTML report to specified file" },
|
|
31
|
+
json: { type: "string", requiresArg: true, describe: "export benchmark data to JSON file" },
|
|
32
|
+
perfetto: { type: "string", requiresArg: true, describe: "export Perfetto trace file (view at ui.perfetto.dev)" },
|
|
33
|
+
"trace-opt": { type: "boolean", default: false, describe: "trace V8 optimization tiers (requires --allow-natives-syntax)" },
|
|
34
|
+
"skip-settle": { type: "boolean", default: false, describe: "skip post-warmup settle time (see V8 optimization cold start)" },
|
|
35
|
+
"pause-first": { type: "number", describe: "iterations before first pause (then pause-interval applies)" },
|
|
36
|
+
"pause-interval": { type: "number", default: defaultPauseInterval, describe: "iterations between pauses for V8 optimization (0 to disable)" },
|
|
37
|
+
"pause-duration": { type: "number", default: defaultPauseDuration, describe: "pause duration in ms for V8 optimization" },
|
|
38
|
+
batches: { type: "number", default: 1, describe: "divide time into N batches, alternating baseline/current order" },
|
|
39
|
+
iterations: { type: "number", requiresArg: true, describe: "exact number of iterations (overrides --time)" },
|
|
40
|
+
"heap-sample": { type: "boolean", default: false, describe: "heap sampling allocation attribution (includes garbage)" },
|
|
41
|
+
"heap-interval": { type: "number", default: 32768, describe: "heap sampling interval in bytes" },
|
|
42
|
+
"heap-depth": { type: "number", default: 64, describe: "heap sampling stack depth" },
|
|
43
|
+
"heap-rows": { type: "number", default: 20, describe: "top allocation sites to show" },
|
|
44
|
+
"heap-stack": { type: "number", default: 3, describe: "call stack depth to display" },
|
|
45
|
+
"heap-verbose": { type: "boolean", default: false, describe: "verbose output with file:// paths and line numbers" },
|
|
46
|
+
"heap-user-only": { type: "boolean", default: false, describe: "filter to user code only (hide node internals)" },
|
|
47
|
+
url: { type: "string", requiresArg: true, describe: "page URL for browser profiling (enables browser mode)" },
|
|
48
|
+
headless: { type: "boolean", default: true, describe: "run browser in headless mode" },
|
|
49
|
+
timeout: { type: "number", default: 60, describe: "browser page timeout in seconds" },
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/** @return yargs with standard benchmark options */
|
|
53
|
+
export function defaultCliArgs(yargsInstance: Argv) {
|
|
54
|
+
return yargsInstance.options(cliOptions).help().strict();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @return parsed command line arguments */
|
|
58
|
+
export function parseCliArgs<T = DefaultCliArgs>(
|
|
59
|
+
args: string[],
|
|
60
|
+
configure: Configure<T> = defaultCliArgs as Configure<T>,
|
|
61
|
+
): T {
|
|
62
|
+
const yargsInstance = configure(yargs(args));
|
|
63
|
+
return yargsInstance.parseSync() as T;
|
|
64
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { BenchGroup, BenchSuite } from "../Benchmark.ts";
|
|
2
|
+
|
|
3
|
+
/** Filter benchmarks by name pattern */
|
|
4
|
+
export function filterBenchmarks(
|
|
5
|
+
suite: BenchSuite,
|
|
6
|
+
filter?: string,
|
|
7
|
+
removeEmpty = true,
|
|
8
|
+
): BenchSuite {
|
|
9
|
+
if (!filter) return suite;
|
|
10
|
+
const regex = createFilterRegex(filter);
|
|
11
|
+
const groups = suite.groups
|
|
12
|
+
.map(group => ({
|
|
13
|
+
...group,
|
|
14
|
+
benchmarks: group.benchmarks.filter(bench =>
|
|
15
|
+
regex.test(stripCaseSuffix(bench.name)),
|
|
16
|
+
),
|
|
17
|
+
baseline:
|
|
18
|
+
group.baseline && regex.test(stripCaseSuffix(group.baseline.name))
|
|
19
|
+
? group.baseline
|
|
20
|
+
: undefined,
|
|
21
|
+
}))
|
|
22
|
+
.filter(group => !removeEmpty || group.benchmarks.length > 0);
|
|
23
|
+
validateFilteredSuite(groups, filter);
|
|
24
|
+
return { name: suite.name, groups };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Create regex from filter (literal unless regex-like) */
|
|
28
|
+
function createFilterRegex(filter: string): RegExp {
|
|
29
|
+
const looksLikeRegex =
|
|
30
|
+
(filter.startsWith("/") && filter.endsWith("/")) ||
|
|
31
|
+
filter.includes("*") ||
|
|
32
|
+
filter.includes("?") ||
|
|
33
|
+
filter.includes("[") ||
|
|
34
|
+
filter.includes("|") ||
|
|
35
|
+
filter.startsWith("^") ||
|
|
36
|
+
filter.endsWith("$");
|
|
37
|
+
|
|
38
|
+
if (looksLikeRegex) {
|
|
39
|
+
const pattern =
|
|
40
|
+
filter.startsWith("/") && filter.endsWith("/")
|
|
41
|
+
? filter.slice(1, -1)
|
|
42
|
+
: filter;
|
|
43
|
+
try {
|
|
44
|
+
return new RegExp(pattern, "i");
|
|
45
|
+
} catch {
|
|
46
|
+
return new RegExp(escapeRegex(filter), "i");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return new RegExp("^" + escapeRegex(filter), "i");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Strip case suffix like " [large]" from benchmark name for filtering */
|
|
54
|
+
function stripCaseSuffix(name: string): string {
|
|
55
|
+
return name.replace(/ \[.*?\]$/, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Escape regex special characters */
|
|
59
|
+
function escapeRegex(str: string): string {
|
|
60
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Ensure at least one benchmark matches filter */
|
|
64
|
+
function validateFilteredSuite(groups: BenchGroup[], filter?: string): void {
|
|
65
|
+
if (groups.every(g => g.benchmarks.length === 0)) {
|
|
66
|
+
throw new Error(`No benchmarks match filter: "${filter}"`);
|
|
67
|
+
}
|
|
68
|
+
}
|