benchforge 0.1.9 → 0.2.4
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/LICENSE +20 -0
- package/README.md +99 -260
- package/bin/benchforge +1 -2
- package/dist/AnalyzeArchive-8NCJhmhS.mjs +145 -0
- package/dist/AnalyzeArchive-8NCJhmhS.mjs.map +1 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs +1050 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs.map +1 -0
- package/dist/BenchRunner-DglX1NOn.d.mts +302 -0
- package/dist/CoverageSampler-D5T9DRqe.mjs +27 -0
- package/dist/CoverageSampler-D5T9DRqe.mjs.map +1 -0
- package/dist/Formatters-BWj3d4sv.mjs +95 -0
- package/dist/Formatters-BWj3d4sv.mjs.map +1 -0
- package/dist/{HeapSampler-B8dtKHn1.mjs → HeapSampler-Dq-hpXem.mjs} +4 -4
- package/dist/HeapSampler-Dq-hpXem.mjs.map +1 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs +3075 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs.map +1 -0
- package/dist/StatisticalUtils-BD92crgM.mjs +255 -0
- package/dist/StatisticalUtils-BD92crgM.mjs.map +1 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs +29 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs.map +1 -0
- package/dist/ViewerServer-BJhdnxlN.mjs +639 -0
- package/dist/ViewerServer-BJhdnxlN.mjs.map +1 -0
- package/dist/ViewerServer-CuMNdNBz.mjs +2 -0
- package/dist/bin/benchforge.mjs +4 -5
- package/dist/bin/benchforge.mjs.map +1 -1
- package/dist/index.d.mts +731 -522
- package/dist/index.mjs +98 -3
- package/dist/index.mjs.map +1 -0
- package/dist/runners/WorkerScript.d.mts +12 -4
- package/dist/runners/WorkerScript.mjs +92 -120
- package/dist/runners/WorkerScript.mjs.map +1 -1
- package/dist/viewer/assets/CIPlot-BkOvMoMa.js +1 -0
- package/dist/viewer/assets/HistogramKde-CmSyUFY0.js +1 -0
- package/dist/viewer/assets/LegendUtils-BJpbn_jr.js +55 -0
- package/dist/viewer/assets/SampleTimeSeries-C4VBhXr3.js +1 -0
- package/dist/viewer/assets/index-Br9bp_cX.js +153 -0
- package/dist/viewer/assets/index-NzXXe_CC.css +1 -0
- package/dist/viewer/index.html +19 -0
- package/dist/viewer/speedscope/LICENSE +21 -0
- package/dist/viewer/speedscope/SourceCodePro-Regular.ttf-ILST5JV6.woff2 +0 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js +2 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js.map +7 -0
- package/dist/viewer/speedscope/favicon-16x16-VSI62OPJ.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-3EB2YCUY.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js +2 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js.map +7 -0
- package/dist/viewer/speedscope/favicon-FOKUP5Y5.ico +0 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js +2 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js.map +7 -0
- package/dist/viewer/speedscope/file-format-schema.json +274 -0
- package/dist/viewer/speedscope/index.html +19 -0
- package/dist/viewer/speedscope/jfrview_bg-BLJXNNQB.wasm +0 -0
- package/dist/viewer/speedscope/perf-vertx-stacks-01-collapsed-all-ZNUIGAJL.txt +199 -0
- package/dist/viewer/speedscope/release.txt +3 -0
- package/dist/viewer/speedscope/source-code-pro.LICENSE.md +93 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css +2 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css.map +7 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js +212 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js.map +7 -0
- package/package.json +52 -26
- package/src/bin/benchforge.ts +2 -2
- package/src/cli/AnalyzeArchive.ts +232 -0
- package/src/cli/BrowserBench.ts +322 -0
- package/src/cli/CliArgs.ts +164 -48
- package/src/cli/CliExport.ts +179 -0
- package/src/cli/CliOptions.ts +147 -0
- package/src/cli/CliReport.ts +197 -0
- package/src/cli/FilterBenchmarks.ts +18 -30
- package/src/cli/RunBenchCLI.ts +138 -844
- package/src/cli/SuiteRunner.ts +160 -0
- package/src/cli/ViewerServer.ts +282 -0
- package/src/export/AllocExport.ts +121 -0
- package/src/export/ArchiveExport.ts +146 -0
- package/src/export/ArchiveFormat.ts +50 -0
- package/src/export/CoverageExport.ts +148 -0
- package/src/export/EditorUri.ts +10 -0
- package/src/export/PerfettoExport.ts +91 -126
- package/src/export/SpeedscopeTypes.ts +98 -0
- package/src/export/TimeExport.ts +115 -0
- package/src/index.ts +87 -62
- package/src/matrix/BenchMatrix.ts +230 -0
- package/src/matrix/CaseLoader.ts +8 -6
- package/src/matrix/MatrixDirRunner.ts +153 -0
- package/src/matrix/MatrixFilter.ts +55 -53
- package/src/matrix/MatrixInlineRunner.ts +50 -0
- package/src/matrix/MatrixReport.ts +94 -254
- package/src/matrix/VariantLoader.ts +9 -9
- package/src/profiling/browser/BenchLoop.ts +51 -0
- package/src/profiling/browser/BrowserCDP.ts +133 -0
- package/src/profiling/browser/BrowserGcStats.ts +33 -0
- package/src/profiling/browser/BrowserProfiler.ts +160 -0
- package/src/profiling/browser/CdpClient.ts +82 -0
- package/src/profiling/browser/CdpPage.ts +138 -0
- package/src/profiling/browser/ChromeLauncher.ts +158 -0
- package/src/profiling/browser/ChromeTraceEvent.ts +28 -0
- package/src/profiling/browser/PageLoadMode.ts +61 -0
- package/src/profiling/node/CoverageSampler.ts +27 -0
- package/src/profiling/node/CoverageTypes.ts +23 -0
- package/src/profiling/node/HeapSampleReport.ts +261 -0
- package/src/{heap-sample → profiling/node}/HeapSampler.ts +55 -13
- package/src/profiling/node/ResolvedProfile.ts +98 -0
- package/src/profiling/node/TimeSampler.ts +57 -0
- package/src/report/BenchmarkReport.ts +146 -0
- package/src/report/Colors.ts +9 -0
- package/src/report/Formatters.ts +110 -0
- package/src/report/GcSections.ts +151 -0
- package/src/{GitUtils.ts → report/GitUtils.ts} +18 -19
- package/src/report/HtmlReport.ts +223 -0
- package/src/report/ParseStats.ts +73 -0
- package/src/report/StandardSections.ts +147 -0
- package/src/report/ViewerSections.ts +286 -0
- package/src/report/text/TableReport.ts +253 -0
- package/src/report/text/TextReport.ts +123 -0
- package/src/runners/AdaptiveWrapper.ts +167 -287
- package/src/runners/BenchRunner.ts +27 -22
- package/src/{Benchmark.ts → runners/BenchmarkSpec.ts} +5 -6
- package/src/runners/CreateRunner.ts +5 -7
- package/src/runners/GcStats.ts +58 -61
- package/src/{MeasuredResults.ts → runners/MeasuredResults.ts} +43 -37
- package/src/runners/MergeBatches.ts +123 -0
- package/src/{NodeGC.ts → runners/NodeGC.ts} +2 -3
- package/src/runners/RunnerOrchestrator.ts +180 -296
- package/src/runners/RunnerUtils.ts +75 -1
- package/src/runners/SampleStats.ts +100 -0
- package/src/runners/TimingRunner.ts +244 -0
- package/src/runners/TimingUtils.ts +3 -2
- package/src/runners/WorkerScript.ts +162 -178
- package/src/stats/BootstrapDifference.ts +282 -0
- package/src/{PermutationTest.ts → stats/PermutationTest.ts} +31 -40
- package/src/stats/StatisticalUtils.ts +445 -0
- package/src/{tests → test}/AdaptiveConvergence.test.ts +10 -10
- package/src/test/AdaptiveRunner.test.ts +39 -41
- package/src/{tests → test}/AdaptiveSampling.test.ts +9 -9
- package/src/test/AdaptiveStatistics.integration.ts +9 -41
- package/src/{tests → test}/BenchMatrix.test.ts +31 -28
- package/src/test/BenchmarkReport.test.ts +63 -13
- package/src/test/BrowserBench.e2e.test.ts +186 -17
- package/src/test/BrowserBench.test.ts +10 -5
- package/src/test/BuildTimeSection.test.ts +130 -0
- package/src/test/CapSamples.test.ts +82 -0
- package/src/test/CoverageExport.test.ts +115 -0
- package/src/test/CoverageSampler.test.ts +33 -0
- package/src/test/HeapAttribution.test.ts +51 -0
- package/src/{tests → test}/MatrixFilter.test.ts +16 -16
- package/src/{tests → test}/MatrixReport.test.ts +1 -1
- package/src/test/PermutationTest.test.ts +1 -1
- package/src/{tests → test}/RealDataValidation.test.ts +6 -6
- package/src/test/RunBenchCLI.test.ts +57 -56
- package/src/test/RunnerOrchestrator.test.ts +12 -12
- package/src/test/StatisticalUtils.test.ts +48 -12
- package/src/{table-util/test → test}/TableReport.test.ts +2 -2
- package/src/test/TestUtils.ts +35 -30
- package/src/test/TimeExport.test.ts +139 -0
- package/src/test/TimeSampler.test.ts +37 -0
- package/src/test/ViewerLive.e2e.test.ts +159 -0
- package/src/test/ViewerStatic.static.e2e.test.ts +137 -0
- package/src/{tests → test}/fixtures/baseline/impl.ts +1 -1
- package/src/{tests → test}/fixtures/bevy30-samples.ts +3 -1
- package/src/test/fixtures/cases/asyncCases.ts +9 -0
- package/src/{tests → test}/fixtures/cases/cases.ts +5 -2
- package/src/test/fixtures/cases/variants/product.ts +2 -0
- package/src/test/fixtures/cases/variants/sum.ts +2 -0
- package/src/test/fixtures/discover/fast.ts +1 -0
- package/src/{tests → test}/fixtures/discover/slow.ts +1 -1
- package/src/test/fixtures/invalid/bad.ts +1 -0
- package/src/test/fixtures/loader/fast.ts +1 -0
- package/src/{tests → test}/fixtures/loader/slow.ts +1 -1
- package/src/test/fixtures/loader/stateful.ts +2 -0
- package/src/test/fixtures/stateful/stateful.ts +2 -0
- package/src/test/fixtures/variants/extra.ts +1 -0
- package/src/test/fixtures/variants/impl.ts +1 -0
- package/src/test/fixtures/worker/fast.ts +1 -0
- package/src/{tests → test}/fixtures/worker/slow.ts +1 -1
- package/src/viewer/DateFormat.ts +30 -0
- package/src/viewer/Helpers.ts +23 -0
- package/src/viewer/LineData.ts +120 -0
- package/src/viewer/Providers.ts +191 -0
- package/src/viewer/ReportData.ts +123 -0
- package/src/viewer/State.ts +49 -0
- package/src/viewer/Theme.ts +15 -0
- package/src/viewer/components/App.tsx +73 -0
- package/src/viewer/components/DropZone.tsx +71 -0
- package/src/viewer/components/LazyPlot.ts +33 -0
- package/src/viewer/components/SamplesPanel.tsx +214 -0
- package/src/viewer/components/Shell.tsx +26 -0
- package/src/viewer/components/SourcePanel.tsx +216 -0
- package/src/viewer/components/SummaryPanel.tsx +332 -0
- package/src/viewer/components/TabBar.tsx +131 -0
- package/src/viewer/components/TabContent.tsx +46 -0
- package/src/viewer/components/ThemeToggle.tsx +50 -0
- package/src/viewer/index.html +20 -0
- package/src/viewer/main.tsx +4 -0
- package/src/viewer/plots/CIPlot.ts +313 -0
- package/src/{html/browser → viewer/plots}/HistogramKde.ts +42 -47
- package/src/viewer/plots/LegendUtils.ts +134 -0
- package/src/viewer/plots/PlotTypes.ts +85 -0
- package/src/viewer/plots/RenderPlots.ts +230 -0
- package/src/viewer/plots/SampleTimeSeries.ts +306 -0
- package/src/viewer/plots/SvgHelpers.ts +136 -0
- package/src/viewer/plots/TimeSeriesMarks.ts +319 -0
- package/src/viewer/report.css +427 -0
- package/src/viewer/shell.css +357 -0
- package/src/viewer/tsconfig.json +11 -0
- package/dist/BenchRunner-CSKN9zPy.d.mts +0 -225
- package/dist/BrowserHeapSampler-DCeL42RE.mjs +0 -202
- package/dist/BrowserHeapSampler-DCeL42RE.mjs.map +0 -1
- package/dist/GcStats-ByEovUi1.mjs +0 -77
- package/dist/GcStats-ByEovUi1.mjs.map +0 -1
- package/dist/HeapSampler-B8dtKHn1.mjs.map +0 -1
- package/dist/TimingUtils-ClclVQ7E.mjs +0 -597
- package/dist/TimingUtils-ClclVQ7E.mjs.map +0 -1
- package/dist/browser/index.js +0 -914
- package/dist/src-Cf_LXwlp.mjs +0 -2873
- package/dist/src-Cf_LXwlp.mjs.map +0 -1
- package/src/BenchMatrix.ts +0 -380
- package/src/BenchmarkReport.ts +0 -156
- package/src/HtmlDataPrep.ts +0 -148
- package/src/StandardSections.ts +0 -261
- package/src/StatisticalUtils.ts +0 -176
- package/src/TypeUtil.ts +0 -8
- package/src/browser/BrowserGcStats.ts +0 -44
- package/src/browser/BrowserHeapSampler.ts +0 -271
- package/src/export/JsonExport.ts +0 -103
- package/src/export/JsonFormat.ts +0 -91
- package/src/heap-sample/HeapSampleReport.ts +0 -196
- package/src/html/HtmlReport.ts +0 -131
- package/src/html/HtmlTemplate.ts +0 -284
- package/src/html/Types.ts +0 -88
- package/src/html/browser/CIPlot.ts +0 -287
- package/src/html/browser/LegendUtils.ts +0 -163
- package/src/html/browser/RenderPlots.ts +0 -263
- package/src/html/browser/SampleTimeSeries.ts +0 -389
- package/src/html/browser/Types.ts +0 -96
- package/src/html/browser/index.ts +0 -1
- package/src/html/index.ts +0 -17
- package/src/runners/BasicRunner.ts +0 -364
- package/src/table-util/ConvergenceFormatters.ts +0 -19
- package/src/table-util/Formatters.ts +0 -152
- package/src/table-util/README.md +0 -70
- package/src/table-util/TableReport.ts +0 -293
- package/src/tests/fixtures/cases/asyncCases.ts +0 -7
- package/src/tests/fixtures/cases/variants/product.ts +0 -2
- package/src/tests/fixtures/cases/variants/sum.ts +0 -2
- package/src/tests/fixtures/discover/fast.ts +0 -1
- package/src/tests/fixtures/invalid/bad.ts +0 -1
- package/src/tests/fixtures/loader/fast.ts +0 -1
- package/src/tests/fixtures/loader/stateful.ts +0 -2
- package/src/tests/fixtures/stateful/stateful.ts +0 -2
- package/src/tests/fixtures/variants/extra.ts +0 -1
- package/src/tests/fixtures/variants/impl.ts +0 -1
- package/src/tests/fixtures/worker/fast.ts +0 -1
- package/src/{table-util/test → test}/TableValueExtractor.test.ts +0 -0
- package/src/{table-util/test → test}/TableValueExtractor.ts +9 -9
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CIDirection,
|
|
3
|
+
type DifferenceCI,
|
|
4
|
+
flipCI,
|
|
5
|
+
} from "../stats/StatisticalUtils.ts";
|
|
6
|
+
import colors from "./Colors.ts";
|
|
7
|
+
|
|
8
|
+
const { red, green } = colors;
|
|
9
|
+
|
|
10
|
+
const lowConfidence = 80;
|
|
11
|
+
|
|
12
|
+
/** Format time in milliseconds with appropriate units */
|
|
13
|
+
export function timeMs(ms: unknown): string | null {
|
|
14
|
+
if (typeof ms !== "number") return null;
|
|
15
|
+
if (ms < 0.001) return `${(ms * 1000000).toFixed(0)}ns`;
|
|
16
|
+
if (ms < 0.01) return `${(ms * 1000).toFixed(1)}μs`;
|
|
17
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
|
18
|
+
if (ms >= 10) return `${ms.toFixed(0)}ms`;
|
|
19
|
+
return `${ms.toFixed(2)}ms`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Format integer with thousand separators */
|
|
23
|
+
export function integer(x: unknown): string | null {
|
|
24
|
+
if (typeof x !== "number") return null;
|
|
25
|
+
return new Intl.NumberFormat("en-US").format(Math.round(x));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Format fraction as percentage (0.473 → 47.3%) */
|
|
29
|
+
export function percent(fraction: unknown, precision = 1): string | null {
|
|
30
|
+
if (typeof fraction !== "number") return null;
|
|
31
|
+
return `${Math.abs(fraction * 100).toFixed(precision)}%`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Format percentage difference between two values */
|
|
35
|
+
export function diffPercent(main: unknown, base: unknown): string {
|
|
36
|
+
if (typeof main !== "number" || typeof base !== "number") return " ";
|
|
37
|
+
const diff = main - base;
|
|
38
|
+
return coloredPercent(diff, base);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Format bytes with appropriate units. Use `space: true` for `1.5 KB` style. */
|
|
42
|
+
export function formatBytes(
|
|
43
|
+
bytes: unknown,
|
|
44
|
+
opts?: { space?: boolean },
|
|
45
|
+
): string | null {
|
|
46
|
+
if (typeof bytes !== "number") return null;
|
|
47
|
+
const s = opts?.space ? " " : "";
|
|
48
|
+
const [kb, mb, gb] = [1024, 1024 ** 2, 1024 ** 3];
|
|
49
|
+
if (bytes < kb) return `${bytes.toFixed(0)}${s}B`;
|
|
50
|
+
if (bytes < mb) return `${(bytes / kb).toFixed(1)}${s}KB`;
|
|
51
|
+
if (bytes < gb) return `${(bytes / mb).toFixed(1)}${s}MB`;
|
|
52
|
+
return `${(bytes / gb).toFixed(1)}${s}GB`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Format percentage difference with confidence interval.
|
|
56
|
+
* When higherIsBetter is true, flips the CI so positive = improvement. */
|
|
57
|
+
export function formatDiffWithCI(
|
|
58
|
+
value: unknown,
|
|
59
|
+
higherIsBetter?: boolean,
|
|
60
|
+
): string | null {
|
|
61
|
+
if (!isDifferenceCI(value)) return null;
|
|
62
|
+
const ci = higherIsBetter ? flipCI(value) : value;
|
|
63
|
+
const suffix = value.ciLevel === "sample" ? " *" : "";
|
|
64
|
+
const text = diffCIText(ci.percent, ci.ci) + suffix;
|
|
65
|
+
return colorByDirection(text, ci.direction);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @return truncated string with ellipsis if over maxLen */
|
|
69
|
+
export function truncate(str: string, maxLen = 30): string {
|
|
70
|
+
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @return signed percentage string (e.g. "+1.2%", "-3.4%") */
|
|
74
|
+
export function formatSignedPercent(v: number): string {
|
|
75
|
+
const sign = v >= 0 ? "+" : "";
|
|
76
|
+
return `${sign}${v.toFixed(1)}%`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @return convergence percentage with color for low values */
|
|
80
|
+
export function formatConvergence(v: unknown): string {
|
|
81
|
+
if (typeof v !== "number") return "—";
|
|
82
|
+
const pct = `${Math.round(v)}%`;
|
|
83
|
+
return v < lowConfidence ? red(pct) : pct;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Format fraction as colored +/- percentage (positive = green, negative = red) */
|
|
87
|
+
function coloredPercent(numerator: number, denominator: number): string {
|
|
88
|
+
const fraction = numerator / denominator;
|
|
89
|
+
if (!Number.isFinite(fraction)) return " ";
|
|
90
|
+
const sign = fraction >= 0 ? "+" : "-";
|
|
91
|
+
const percentStr = `${sign}${percent(fraction)}`;
|
|
92
|
+
return fraction >= 0 ? green(percentStr) : red(percentStr);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isDifferenceCI(x: unknown): x is DifferenceCI {
|
|
96
|
+
return typeof x === "object" && x !== null && "ci" in x && "direction" in x;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @return formatted "pct [lo, hi]" text for a diff with CI */
|
|
100
|
+
function diffCIText(pct: number, ci: [number, number]): string {
|
|
101
|
+
const [lo, hi] = ci.map(formatSignedPercent);
|
|
102
|
+
return `${formatSignedPercent(pct)} [${lo}, ${hi}]`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @return text colored green for faster/equivalent, red for slower */
|
|
106
|
+
function colorByDirection(text: string, direction: CIDirection): string {
|
|
107
|
+
if (direction === "faster" || direction === "equivalent") return green(text);
|
|
108
|
+
if (direction === "slower") return red(text);
|
|
109
|
+
return text;
|
|
110
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { NavTiming } from "../profiling/browser/BrowserProfiler.ts";
|
|
2
|
+
import type { MeasuredResults } from "../runners/MeasuredResults.ts";
|
|
3
|
+
import { average, median, percentile } from "../stats/StatisticalUtils.ts";
|
|
4
|
+
import type { ReportSection } from "./BenchmarkReport.ts";
|
|
5
|
+
import { formatBytes, integer, percent, timeMs } from "./Formatters.ts";
|
|
6
|
+
|
|
7
|
+
/** Report section: GC time as fraction of total benchmark time. */
|
|
8
|
+
export const gcSection: ReportSection = {
|
|
9
|
+
title: "gc",
|
|
10
|
+
columns: [
|
|
11
|
+
{
|
|
12
|
+
key: "gc",
|
|
13
|
+
title: "mean",
|
|
14
|
+
formatter: percent,
|
|
15
|
+
comparable: true,
|
|
16
|
+
value: (r: MeasuredResults) => {
|
|
17
|
+
const { nodeGcTime, time, samples } = r;
|
|
18
|
+
if (!nodeGcTime || !time?.avg) return undefined;
|
|
19
|
+
const totalBenchTime = time.avg * samples.length;
|
|
20
|
+
if (totalBenchTime <= 0) return undefined;
|
|
21
|
+
const gcFraction = nodeGcTime.inRun / totalBenchTime;
|
|
22
|
+
return gcFraction <= 1 ? gcFraction : undefined;
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Report section: detailed GC stats from --trace-gc-nvp. */
|
|
29
|
+
export const gcStatsSection: ReportSection = {
|
|
30
|
+
title: "gc",
|
|
31
|
+
columns: [
|
|
32
|
+
{
|
|
33
|
+
key: "allocPerIter",
|
|
34
|
+
title: "alloc/iter",
|
|
35
|
+
formatter: formatBytes,
|
|
36
|
+
comparable: true,
|
|
37
|
+
value: (r: MeasuredResults) => {
|
|
38
|
+
const { gcStats, samples } = r;
|
|
39
|
+
if (!gcStats) return undefined;
|
|
40
|
+
const alloc = gcStats.totalAllocated;
|
|
41
|
+
return alloc != null ? alloc / (samples.length || 1) : undefined;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: "collected",
|
|
46
|
+
title: "collected",
|
|
47
|
+
formatter: formatBytes,
|
|
48
|
+
comparable: true,
|
|
49
|
+
value: (r: MeasuredResults) => r.gcStats?.totalCollected || undefined,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: "scavenges",
|
|
53
|
+
title: "scav",
|
|
54
|
+
formatter: integer,
|
|
55
|
+
comparable: true,
|
|
56
|
+
value: (r: MeasuredResults) => r.gcStats?.scavenges,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: "fullGCs",
|
|
60
|
+
title: "full",
|
|
61
|
+
formatter: integer,
|
|
62
|
+
comparable: true,
|
|
63
|
+
value: (r: MeasuredResults) => r.gcStats?.markCompacts,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: "promoPercent",
|
|
67
|
+
title: "promo%",
|
|
68
|
+
formatter: percent,
|
|
69
|
+
comparable: true,
|
|
70
|
+
value: (r: MeasuredResults) => {
|
|
71
|
+
const gs = r.gcStats;
|
|
72
|
+
if (!gs) return undefined;
|
|
73
|
+
const alloc = gs.totalAllocated;
|
|
74
|
+
return alloc && alloc > 0 ? (gs.totalPromoted ?? 0) / alloc : undefined;
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: "pausePerIter",
|
|
79
|
+
title: "pause/iter",
|
|
80
|
+
formatter: timeMs,
|
|
81
|
+
comparable: true,
|
|
82
|
+
value: (r: MeasuredResults) => {
|
|
83
|
+
const gs = r.gcStats;
|
|
84
|
+
return gs ? gs.gcPauseTime / (r.samples.length || 1) : undefined;
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Report section: browser GC stats from CDP tracing (subset of gcStatsSection). */
|
|
91
|
+
export const browserGcStatsSection: ReportSection = {
|
|
92
|
+
title: "gc",
|
|
93
|
+
columns: [
|
|
94
|
+
gcStatsSection.columns.find(c => c.key === "collected")!,
|
|
95
|
+
gcStatsSection.columns.find(c => c.key === "scavenges")!,
|
|
96
|
+
gcStatsSection.columns.find(c => c.key === "fullGCs")!,
|
|
97
|
+
{
|
|
98
|
+
key: "pausePerIter",
|
|
99
|
+
title: "pause",
|
|
100
|
+
formatter: timeMs,
|
|
101
|
+
comparable: true,
|
|
102
|
+
value: gcStatsSection.columns.find(c => c.key === "pausePerIter")!.value!,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Report sections: page-load stats (mean/p50/p99) across multiple iterations. */
|
|
108
|
+
export const pageLoadStatsSections: ReportSection[] = [
|
|
109
|
+
pageLoadSection("DCL", n => n.domContentLoaded || undefined),
|
|
110
|
+
pageLoadSection("load", n => n.loadEvent || undefined),
|
|
111
|
+
pageLoadSection("LCP", n => n.lcp),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
/** @return GC stats sections if enabled by CLI flags */
|
|
115
|
+
export function gcSections(args: { "gc-stats"?: boolean }): ReportSection[] {
|
|
116
|
+
return args["gc-stats"] ? [gcStatsSection] : [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Build a page-load section with mean/p50/p99 columns from NavTiming data */
|
|
120
|
+
function pageLoadSection(
|
|
121
|
+
title: string,
|
|
122
|
+
extract: (n: NavTiming) => number | undefined,
|
|
123
|
+
): ReportSection {
|
|
124
|
+
const vals = (r: MeasuredResults) => navValues(r.navTimings, extract);
|
|
125
|
+
const col = (suffix: string, stat: (v: number[]) => number) => ({
|
|
126
|
+
key: `${title.toLowerCase()}${suffix}`,
|
|
127
|
+
title: suffix.toLowerCase(),
|
|
128
|
+
formatter: timeMs,
|
|
129
|
+
value: (r: MeasuredResults) => {
|
|
130
|
+
const v = vals(r);
|
|
131
|
+
return v.length ? stat(v) : undefined;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
title,
|
|
136
|
+
columns: [
|
|
137
|
+
col("Mean", average),
|
|
138
|
+
col("P50", median),
|
|
139
|
+
col("P99", v => percentile(v, 0.99)),
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Extract one field from all NavTimings, filtering undefineds. */
|
|
145
|
+
function navValues(
|
|
146
|
+
navs: NavTiming[] | undefined,
|
|
147
|
+
fn: (n: NavTiming) => number | undefined,
|
|
148
|
+
): number[] {
|
|
149
|
+
if (!navs?.length) return [];
|
|
150
|
+
return navs.map(fn).filter((v): v is number => v != null);
|
|
151
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import
|
|
5
|
-
import { formatDateWithTimezone } from "./html/index.ts";
|
|
4
|
+
import { formatDateWithTimezone } from "../viewer/DateFormat.ts";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
export
|
|
6
|
+
/** Git commit hash, date, and dirty status for version tracking */
|
|
7
|
+
export interface GitVersion {
|
|
8
|
+
hash: string;
|
|
9
|
+
date: string;
|
|
10
|
+
dirty?: boolean;
|
|
11
|
+
}
|
|
9
12
|
|
|
10
13
|
/** Get current git version info. For dirty repos, uses most recent modified file date. */
|
|
11
14
|
export function getCurrentGitVersion(): GitVersion | undefined {
|
|
@@ -14,7 +17,6 @@ export function getCurrentGitVersion(): GitVersion | undefined {
|
|
|
14
17
|
const hash = exec("git rev-parse --short HEAD");
|
|
15
18
|
const commitDate = exec("git log -1 --format=%aI");
|
|
16
19
|
const dirty = exec("git status --porcelain").length > 0;
|
|
17
|
-
|
|
18
20
|
const date = dirty
|
|
19
21
|
? (getMostRecentModifiedDate(".") ?? commitDate)
|
|
20
22
|
: commitDate;
|
|
@@ -54,24 +56,21 @@ export function getMostRecentModifiedDate(dir: string): string | undefined {
|
|
|
54
56
|
encoding: "utf-8",
|
|
55
57
|
cwd: dir,
|
|
56
58
|
});
|
|
57
|
-
const
|
|
59
|
+
const files = raw
|
|
58
60
|
.trim()
|
|
59
61
|
.split("\n")
|
|
60
|
-
.filter(
|
|
61
|
-
.map(
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map(l => l.slice(3));
|
|
64
|
+
if (!files.length) return undefined;
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
let mostRecent = 0;
|
|
66
|
-
for (const file of modifiedFiles) {
|
|
66
|
+
const mtime = (f: string): number => {
|
|
67
67
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
return statSync(join(dir, f)).mtimeMs;
|
|
69
|
+
} catch {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const mostRecent = Math.max(0, ...files.map(mtime));
|
|
75
74
|
return mostRecent > 0 ? new Date(mostRecent).toISOString() : undefined;
|
|
76
75
|
} catch {
|
|
77
76
|
return undefined;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { cliDefaults } from "../cli/CliArgs.ts";
|
|
2
|
+
import type { CoverageData } from "../profiling/node/CoverageTypes.ts";
|
|
3
|
+
import {
|
|
4
|
+
filterSites,
|
|
5
|
+
flattenProfile,
|
|
6
|
+
totalBytes,
|
|
7
|
+
} from "../profiling/node/HeapSampleReport.ts";
|
|
8
|
+
import type { HeapProfile } from "../profiling/node/HeapSampler.ts";
|
|
9
|
+
import { resolveProfile } from "../profiling/node/ResolvedProfile.ts";
|
|
10
|
+
import type { MeasuredResults } from "../runners/MeasuredResults.ts";
|
|
11
|
+
import type { DifferenceCI } from "../stats/StatisticalUtils.ts";
|
|
12
|
+
import type {
|
|
13
|
+
BenchmarkEntry,
|
|
14
|
+
BenchmarkGroup,
|
|
15
|
+
CoverageSummary,
|
|
16
|
+
HeapSummary,
|
|
17
|
+
ReportData,
|
|
18
|
+
ViewerSection,
|
|
19
|
+
} from "../viewer/ReportData.ts";
|
|
20
|
+
import {
|
|
21
|
+
type BenchmarkReport,
|
|
22
|
+
type ComparisonOptions,
|
|
23
|
+
hasField,
|
|
24
|
+
type ReportGroup,
|
|
25
|
+
type ReportSection,
|
|
26
|
+
type UnknownRecord,
|
|
27
|
+
} from "./BenchmarkReport.ts";
|
|
28
|
+
import { gcStatsSection } from "./GcSections.ts";
|
|
29
|
+
import type { GitVersion } from "./GitUtils.ts";
|
|
30
|
+
import {
|
|
31
|
+
buildTimeSection,
|
|
32
|
+
optSection,
|
|
33
|
+
runsSection,
|
|
34
|
+
} from "./StandardSections.ts";
|
|
35
|
+
import {
|
|
36
|
+
buildViewerSections,
|
|
37
|
+
hasLowBatchCount,
|
|
38
|
+
isSingleBatch,
|
|
39
|
+
minBatches,
|
|
40
|
+
} from "./ViewerSections.ts";
|
|
41
|
+
|
|
42
|
+
/** Options for prepareHtmlData: report sections, git versions, and CLI args */
|
|
43
|
+
export interface PrepareHtmlOptions extends ComparisonOptions {
|
|
44
|
+
cliArgs?: Record<string, unknown>;
|
|
45
|
+
sections?: ReportSection[];
|
|
46
|
+
currentVersion?: GitVersion;
|
|
47
|
+
baselineVersion?: GitVersion;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Context shared across reports in a group */
|
|
51
|
+
interface GroupContext {
|
|
52
|
+
baseM?: MeasuredResults;
|
|
53
|
+
baseMeta?: UnknownRecord;
|
|
54
|
+
sections?: ReportSection[];
|
|
55
|
+
comparison?: ComparisonOptions;
|
|
56
|
+
lowBatches: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Convert benchmark results into a ReportData payload for the HTML viewer */
|
|
60
|
+
export function prepareHtmlData(
|
|
61
|
+
groups: ReportGroup[],
|
|
62
|
+
options: PrepareHtmlOptions,
|
|
63
|
+
): ReportData {
|
|
64
|
+
const { cliArgs, currentVersion, baselineVersion, equivMargin, noBatchTrim } =
|
|
65
|
+
options;
|
|
66
|
+
const comparison: ComparisonOptions = { equivMargin, noBatchTrim };
|
|
67
|
+
const sections = options.sections ?? defaultSections(groups, cliArgs);
|
|
68
|
+
return {
|
|
69
|
+
groups: groups.map(g => prepareGroupData(g, sections, comparison)),
|
|
70
|
+
metadata: {
|
|
71
|
+
timestamp: new Date().toISOString(),
|
|
72
|
+
bencherVersion: process.env.npm_package_version || "unknown",
|
|
73
|
+
cliArgs,
|
|
74
|
+
cliDefaults,
|
|
75
|
+
gcTrackingEnabled: cliArgs?.["gc-stats"] === true,
|
|
76
|
+
currentVersion,
|
|
77
|
+
baselineVersion,
|
|
78
|
+
environment: {
|
|
79
|
+
node: process.version,
|
|
80
|
+
platform: process.platform,
|
|
81
|
+
arch: process.arch,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build default sections when caller doesn't provide custom ones */
|
|
88
|
+
function defaultSections(
|
|
89
|
+
groups: ReportGroup[],
|
|
90
|
+
cliArgs?: Record<string, unknown>,
|
|
91
|
+
): ReportSection[] {
|
|
92
|
+
const hasGc = cliArgs?.["gc-stats"] === true;
|
|
93
|
+
const hasOpt = hasField(groups, "optStatus");
|
|
94
|
+
const stats = typeof cliArgs?.stats === "string" ? cliArgs.stats : undefined;
|
|
95
|
+
return [
|
|
96
|
+
buildTimeSection(stats),
|
|
97
|
+
hasGc ? gcStatsSection : undefined,
|
|
98
|
+
hasOpt ? optSection : undefined,
|
|
99
|
+
runsSection,
|
|
100
|
+
].filter((s): s is ReportSection => s !== undefined);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @return group data with structured ViewerSections and bootstrap CIs */
|
|
104
|
+
function prepareGroupData(
|
|
105
|
+
group: ReportGroup,
|
|
106
|
+
sections?: ReportSection[],
|
|
107
|
+
comparison?: ComparisonOptions,
|
|
108
|
+
): BenchmarkGroup {
|
|
109
|
+
const base = group.baseline;
|
|
110
|
+
const baseM = base?.measuredResults;
|
|
111
|
+
const baseline = base
|
|
112
|
+
? { ...prepareBenchmarkData(base), comparisonCI: undefined }
|
|
113
|
+
: undefined;
|
|
114
|
+
const curM = group.reports[0]?.measuredResults;
|
|
115
|
+
const singleBatch = isSingleBatch(baseM, curM);
|
|
116
|
+
const lowBatches = hasLowBatchCount(baseM, curM);
|
|
117
|
+
const baseMeta = base?.metadata;
|
|
118
|
+
const ctx: GroupContext = {
|
|
119
|
+
baseM,
|
|
120
|
+
baseMeta,
|
|
121
|
+
sections,
|
|
122
|
+
comparison,
|
|
123
|
+
lowBatches,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
name: group.name,
|
|
128
|
+
baseline,
|
|
129
|
+
warnings: buildWarnings(singleBatch, lowBatches),
|
|
130
|
+
benchmarks: group.reports.map(r => prepareReportEntry(r, ctx)),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @return benchmark data with samples, stats, and profiling summaries */
|
|
135
|
+
function prepareBenchmarkData(report: {
|
|
136
|
+
name: string;
|
|
137
|
+
measuredResults: MeasuredResults;
|
|
138
|
+
metadata?: UnknownRecord;
|
|
139
|
+
}): Omit<BenchmarkEntry, "comparisonCI" | "sections"> {
|
|
140
|
+
const { measuredResults: m, name } = report;
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
samples: m.samples,
|
|
144
|
+
warmupSamples: m.warmupSamples,
|
|
145
|
+
allocationSamples: m.allocationSamples,
|
|
146
|
+
heapSamples: m.heapSamples,
|
|
147
|
+
gcEvents: m.nodeGcTime?.events,
|
|
148
|
+
optSamples: m.optSamples,
|
|
149
|
+
pausePoints: m.pausePoints,
|
|
150
|
+
batchOffsets: m.batchOffsets,
|
|
151
|
+
stats: m.time,
|
|
152
|
+
heapSize: m.heapSize,
|
|
153
|
+
totalTime: m.totalTime,
|
|
154
|
+
heapSummary: m.heapProfile ? summarizeHeap(m.heapProfile) : undefined,
|
|
155
|
+
coverageSummary: m.coverage ? summarizeCoverage(m.coverage) : undefined,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildWarnings(
|
|
160
|
+
singleBatch: boolean,
|
|
161
|
+
lowBatches: boolean,
|
|
162
|
+
): string[] | undefined {
|
|
163
|
+
const parts: string[] = [];
|
|
164
|
+
const singleMsg =
|
|
165
|
+
"Confidence intervals may be too narrow (single batch). Use --batches for more accurate intervals.";
|
|
166
|
+
if (singleBatch) parts.push(singleMsg);
|
|
167
|
+
if (lowBatches)
|
|
168
|
+
parts.push(
|
|
169
|
+
`Too few batches for reliable comparison (need ${minBatches}+).`,
|
|
170
|
+
);
|
|
171
|
+
return parts.length ? parts : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @return a single benchmark entry with sections and comparison CI */
|
|
175
|
+
function prepareReportEntry(
|
|
176
|
+
report: BenchmarkReport,
|
|
177
|
+
ctx: GroupContext,
|
|
178
|
+
): BenchmarkEntry {
|
|
179
|
+
const m = report.measuredResults;
|
|
180
|
+
const sectionCtx = {
|
|
181
|
+
current: m,
|
|
182
|
+
baseline: ctx.baseM,
|
|
183
|
+
currentMeta: report.metadata,
|
|
184
|
+
baselineMeta: ctx.baseMeta,
|
|
185
|
+
comparison: ctx.comparison,
|
|
186
|
+
};
|
|
187
|
+
const sections = ctx.sections
|
|
188
|
+
? buildViewerSections(ctx.sections, sectionCtx)
|
|
189
|
+
: undefined;
|
|
190
|
+
// Primary CI comes from the first primary row's comparisonCI (avoids duplicate bootstrap)
|
|
191
|
+
const comparisonCI = findPrimarySectionCI(sections);
|
|
192
|
+
return { ...prepareBenchmarkData(report), sections, comparisonCI };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Compute heap allocation summary from profile */
|
|
196
|
+
function summarizeHeap(profile: HeapProfile): HeapSummary {
|
|
197
|
+
const resolved = resolveProfile(profile);
|
|
198
|
+
const userSites = filterSites(flattenProfile(resolved));
|
|
199
|
+
return { totalBytes: resolved.totalBytes, userBytes: totalBytes(userSites) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Compute coverage summary from V8 coverage data */
|
|
203
|
+
function summarizeCoverage(coverage: CoverageData): CoverageSummary {
|
|
204
|
+
const fns = coverage.scripts.flatMap(s => s.functions);
|
|
205
|
+
const called = fns.filter(
|
|
206
|
+
fn => fn.ranges.length > 0 && fn.ranges[0].count > 0,
|
|
207
|
+
);
|
|
208
|
+
const totalCalls = called.reduce((sum, fn) => sum + fn.ranges[0].count, 0);
|
|
209
|
+
return { functionCount: called.length, totalCalls };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Extract the comparison CI from the first primary row across all sections */
|
|
213
|
+
function findPrimarySectionCI(
|
|
214
|
+
sections: ViewerSection[] | undefined,
|
|
215
|
+
): DifferenceCI | undefined {
|
|
216
|
+
if (!sections) return undefined;
|
|
217
|
+
for (const section of sections) {
|
|
218
|
+
for (const row of section.rows) {
|
|
219
|
+
if (row.primary && row.comparisonCI) return row.comparisonCI;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { StatKind } from "../stats/StatisticalUtils.ts";
|
|
2
|
+
|
|
3
|
+
/** Parsed spec for one timing column selected via --stats. */
|
|
4
|
+
export interface StatSpec {
|
|
5
|
+
key: string;
|
|
6
|
+
title: string;
|
|
7
|
+
statKind: StatKind;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Parse --stats into column specs. Throws on empty/invalid tokens. */
|
|
11
|
+
export function parseStatsArg(stats: string): StatSpec[] {
|
|
12
|
+
const tokens = stats
|
|
13
|
+
.split(",")
|
|
14
|
+
.map(t => t.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
if (tokens.length === 0) {
|
|
17
|
+
throw new Error("--stats must list at least one column");
|
|
18
|
+
}
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
const specs: StatSpec[] = [];
|
|
21
|
+
for (const token of tokens) {
|
|
22
|
+
const spec = parseStatToken(token);
|
|
23
|
+
if (seen.has(spec.key)) continue;
|
|
24
|
+
seen.add(spec.key);
|
|
25
|
+
specs.push(spec);
|
|
26
|
+
}
|
|
27
|
+
return specs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @return stat spec for a single --stats token. Throws on invalid input. */
|
|
31
|
+
function parseStatToken(token: string): StatSpec {
|
|
32
|
+
const lower = token.toLowerCase();
|
|
33
|
+
if (lower === "mean" || lower === "avg") {
|
|
34
|
+
return { key: "mean", title: "mean", statKind: "mean" };
|
|
35
|
+
}
|
|
36
|
+
if (lower === "median") {
|
|
37
|
+
return { key: "p50", title: "p50", statKind: { percentile: 0.5 } };
|
|
38
|
+
}
|
|
39
|
+
if (lower === "min") {
|
|
40
|
+
return { key: "min", title: "min", statKind: "min" };
|
|
41
|
+
}
|
|
42
|
+
if (lower === "max") {
|
|
43
|
+
return { key: "max", title: "max", statKind: "max" };
|
|
44
|
+
}
|
|
45
|
+
const m = lower.match(/^p(\d+)$/);
|
|
46
|
+
if (m) return parsePercentileToken(token, m[1]);
|
|
47
|
+
throw new Error(
|
|
48
|
+
`invalid --stats token "${token}": expected mean, median, min, max, or p<N> (e.g. p50, p99, p999)`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @return spec for a p<N> token, enforcing the 2-digit minimum and 9-prefix rule. */
|
|
53
|
+
function parsePercentileToken(token: string, digits: string): StatSpec {
|
|
54
|
+
if (digits.length < 2) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`invalid --stats token "${token}": percentile needs at least 2 digits (e.g. p05, p50, p99, p999)`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
// 3+ digit tokens express sub-percentile precision (p999 = 99.9%,
|
|
60
|
+
// p9999 = 99.99%). Require leading 9 so p100/p500 don't silently
|
|
61
|
+
// map to 10%/50% — use 2-digit p10/p50 for those.
|
|
62
|
+
if (digits.length > 2 && digits[0] !== "9") {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`invalid --stats token "${token}": percentiles with 3+ digits must start with 9 (e.g. p999, p9999); otherwise use 2-digit form (e.g. p50)`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const q = Number(digits) / 10 ** digits.length;
|
|
68
|
+
return {
|
|
69
|
+
key: `p${digits}`,
|
|
70
|
+
title: `p${digits}`,
|
|
71
|
+
statKind: { percentile: q },
|
|
72
|
+
};
|
|
73
|
+
}
|