benchforge 0.1.11 → 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 -294
- 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-BzyUfiyB.d.mts → BenchRunner-DglX1NOn.d.mts} +119 -66
- 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 +711 -558
- 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 +77 -105
- 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 -27
- 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 -51
- 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 +132 -866
- 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 +64 -99
- package/src/export/SpeedscopeTypes.ts +98 -0
- package/src/export/TimeExport.ts +115 -0
- package/src/index.ts +86 -67
- 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 +49 -47
- package/src/matrix/MatrixInlineRunner.ts +50 -0
- package/src/matrix/MatrixReport.ts +90 -250
- package/src/matrix/VariantLoader.ts +5 -5
- 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 +1 -2
- package/src/{heap-sample → profiling/node}/ResolvedProfile.ts +18 -9
- 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 +116 -236
- package/src/runners/BenchRunner.ts +20 -15
- package/src/{Benchmark.ts → runners/BenchmarkSpec.ts} +5 -6
- package/src/runners/CreateRunner.ts +5 -7
- package/src/runners/GcStats.ts +47 -50
- 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 +127 -243
- 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 +135 -151
- package/src/stats/BootstrapDifference.ts +282 -0
- package/src/{PermutationTest.ts → stats/PermutationTest.ts} +8 -17
- 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 +2 -2
- package/src/{tests → test}/BenchMatrix.test.ts +19 -16
- 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 +14 -14
- package/src/{tests → test}/MatrixFilter.test.ts +1 -1
- 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 +39 -38
- 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 +12 -7
- 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 +33 -38
- 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/BrowserHeapSampler-B6asLKWQ.mjs +0 -202
- package/dist/BrowserHeapSampler-B6asLKWQ.mjs.map +0 -1
- package/dist/GcStats-wX7Xyblu.mjs +0 -77
- package/dist/GcStats-wX7Xyblu.mjs.map +0 -1
- package/dist/HeapSampler-B8dtKHn1.mjs.map +0 -1
- package/dist/TimingUtils-DwOwkc8G.mjs +0 -597
- package/dist/TimingUtils-DwOwkc8G.mjs.map +0 -1
- package/dist/browser/index.js +0 -914
- package/dist/src-B-DDaCa9.mjs +0 -3108
- package/dist/src-B-DDaCa9.mjs.map +0 -1
- package/src/BenchMatrix.ts +0 -380
- package/src/BenchmarkReport.ts +0 -161
- package/src/HtmlDataPrep.ts +0 -148
- package/src/StandardSections.ts +0 -261
- package/src/StatisticalUtils.ts +0 -175
- 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/export/SpeedscopeExport.ts +0 -202
- package/src/heap-sample/HeapSampleReport.ts +0 -269
- 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 -157
- 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 +0 -0
|
@@ -0,0 +1,3075 @@
|
|
|
1
|
+
import { C as resolveProfile, S as resolveCallFrame, _ as groupReports, a as archiveBenchmark, b as binBootstrapResult, d as internFrame, f as speedscopeFile, g as findPrimaryColumn, h as extractSectionValues, i as waitForCtrlC, m as computeDiffCI, n as startViewerServer, o as collectSources, p as computeColumnValues, s as buildSpeedscopeFile, t as optionalJson, u as frameContext, v as hasField, x as diffCIs, y as isHigherIsBetter } from "./ViewerServer-BJhdnxlN.mjs";
|
|
2
|
+
import { C as swapDirection, g as percentile, l as flipCI, n as bootstrapCIs, p as median, t as average, u as isBootstrappable } from "./StatisticalUtils-BD92crgM.mjs";
|
|
3
|
+
import { c as timeMs, i as formatDiffWithCI, l as truncate, n as formatBytes, o as integer, r as formatConvergence, s as percent, t as diffPercent, u as colors } from "./Formatters-BWj3d4sv.mjs";
|
|
4
|
+
import { d as mergeGcStats, f as runBatched, h as loadCasesModule, l as discoverVariants, n as runMatrix, p as computeStats, r as runBenchmark, u as aggregateGcStats } from "./BenchMatrix-BZVrBB_h.mjs";
|
|
5
|
+
import yargs from "yargs";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { basename, join, resolve } from "node:path";
|
|
8
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
11
|
+
import { table } from "table";
|
|
12
|
+
import { hideBin } from "yargs/helpers";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
//#region src/cli/CliArgs.ts
|
|
15
|
+
const cliOptions = {
|
|
16
|
+
duration: {
|
|
17
|
+
type: "number",
|
|
18
|
+
requiresArg: true,
|
|
19
|
+
describe: "duration per batch in seconds (default: 0.642)"
|
|
20
|
+
},
|
|
21
|
+
iterations: {
|
|
22
|
+
type: "number",
|
|
23
|
+
requiresArg: true,
|
|
24
|
+
describe: "iterations per batch (page loads for page-load mode, inner loop for bench)"
|
|
25
|
+
},
|
|
26
|
+
warmup: {
|
|
27
|
+
type: "number",
|
|
28
|
+
default: 0,
|
|
29
|
+
describe: "warmup iterations before measurement"
|
|
30
|
+
},
|
|
31
|
+
filter: {
|
|
32
|
+
type: "string",
|
|
33
|
+
requiresArg: true,
|
|
34
|
+
describe: "filter by name/regex. Matrix: case/variant, case/, /variant"
|
|
35
|
+
},
|
|
36
|
+
all: {
|
|
37
|
+
type: "boolean",
|
|
38
|
+
default: false,
|
|
39
|
+
describe: "run all cases (ignore defaultCases)"
|
|
40
|
+
},
|
|
41
|
+
list: {
|
|
42
|
+
type: "boolean",
|
|
43
|
+
default: false,
|
|
44
|
+
describe: "list available benchmarks (or matrix cases/variants)"
|
|
45
|
+
},
|
|
46
|
+
worker: {
|
|
47
|
+
type: "boolean",
|
|
48
|
+
default: true,
|
|
49
|
+
describe: "run in worker process for isolation (default: true)"
|
|
50
|
+
},
|
|
51
|
+
batches: {
|
|
52
|
+
type: "number",
|
|
53
|
+
default: 1,
|
|
54
|
+
describe: "divide time into N batches, alternating baseline/current order"
|
|
55
|
+
},
|
|
56
|
+
"warmup-batch": {
|
|
57
|
+
type: "boolean",
|
|
58
|
+
default: false,
|
|
59
|
+
describe: "include first batch in results (normally dropped to avoid OS cache warmup)"
|
|
60
|
+
},
|
|
61
|
+
"equiv-margin": {
|
|
62
|
+
type: "number",
|
|
63
|
+
default: 2,
|
|
64
|
+
describe: "equivalence margin % for baseline comparison (0 to disable)"
|
|
65
|
+
},
|
|
66
|
+
"no-batch-trim": {
|
|
67
|
+
type: "boolean",
|
|
68
|
+
default: false,
|
|
69
|
+
describe: "disable Tukey trimming of outlier batches"
|
|
70
|
+
},
|
|
71
|
+
"pause-first": {
|
|
72
|
+
type: "number",
|
|
73
|
+
describe: "iterations before first pause (then pause-interval applies)"
|
|
74
|
+
},
|
|
75
|
+
"pause-interval": {
|
|
76
|
+
type: "number",
|
|
77
|
+
default: 0,
|
|
78
|
+
describe: "iterations between pauses for V8 optimization (0 to disable)"
|
|
79
|
+
},
|
|
80
|
+
"pause-duration": {
|
|
81
|
+
type: "number",
|
|
82
|
+
default: 100,
|
|
83
|
+
describe: "pause duration in ms for V8 optimization"
|
|
84
|
+
},
|
|
85
|
+
"gc-stats": {
|
|
86
|
+
type: "boolean",
|
|
87
|
+
default: false,
|
|
88
|
+
describe: "collect GC statistics (Node: --trace-gc-nvp, browser: CDP tracing)"
|
|
89
|
+
},
|
|
90
|
+
"gc-force": {
|
|
91
|
+
type: "boolean",
|
|
92
|
+
default: false,
|
|
93
|
+
describe: "force GC after each iteration"
|
|
94
|
+
},
|
|
95
|
+
adaptive: {
|
|
96
|
+
type: "boolean",
|
|
97
|
+
default: false,
|
|
98
|
+
describe: "adaptive sampling (experimental)"
|
|
99
|
+
},
|
|
100
|
+
"min-time": {
|
|
101
|
+
type: "number",
|
|
102
|
+
default: 1,
|
|
103
|
+
describe: "minimum time before adaptive convergence can stop"
|
|
104
|
+
},
|
|
105
|
+
convergence: {
|
|
106
|
+
type: "number",
|
|
107
|
+
default: 95,
|
|
108
|
+
describe: "adaptive confidence threshold (0-100)"
|
|
109
|
+
},
|
|
110
|
+
alloc: {
|
|
111
|
+
type: "boolean",
|
|
112
|
+
default: false,
|
|
113
|
+
describe: "allocation sampling attribution (includes garbage)"
|
|
114
|
+
},
|
|
115
|
+
"alloc-interval": {
|
|
116
|
+
type: "number",
|
|
117
|
+
default: 32768,
|
|
118
|
+
describe: "allocation sampling interval in bytes"
|
|
119
|
+
},
|
|
120
|
+
"alloc-depth": {
|
|
121
|
+
type: "number",
|
|
122
|
+
default: 64,
|
|
123
|
+
describe: "allocation sampling stack depth"
|
|
124
|
+
},
|
|
125
|
+
"alloc-rows": {
|
|
126
|
+
type: "number",
|
|
127
|
+
default: 20,
|
|
128
|
+
describe: "top allocation sites to show"
|
|
129
|
+
},
|
|
130
|
+
"alloc-stack": {
|
|
131
|
+
type: "number",
|
|
132
|
+
default: 3,
|
|
133
|
+
describe: "call stack depth to display"
|
|
134
|
+
},
|
|
135
|
+
"alloc-verbose": {
|
|
136
|
+
type: "boolean",
|
|
137
|
+
default: false,
|
|
138
|
+
describe: "verbose output with file:// paths and line numbers"
|
|
139
|
+
},
|
|
140
|
+
"alloc-raw": {
|
|
141
|
+
type: "boolean",
|
|
142
|
+
default: false,
|
|
143
|
+
describe: "dump every raw allocation sample (ordinal, size, stack)"
|
|
144
|
+
},
|
|
145
|
+
"alloc-user-only": {
|
|
146
|
+
type: "boolean",
|
|
147
|
+
default: false,
|
|
148
|
+
describe: "filter to user code only (hide node internals)"
|
|
149
|
+
},
|
|
150
|
+
profile: {
|
|
151
|
+
type: "boolean",
|
|
152
|
+
default: false,
|
|
153
|
+
alias: "time-sample",
|
|
154
|
+
describe: "V8 CPU time sampling profiler"
|
|
155
|
+
},
|
|
156
|
+
"profile-interval": {
|
|
157
|
+
type: "number",
|
|
158
|
+
default: 1e3,
|
|
159
|
+
alias: "time-interval",
|
|
160
|
+
describe: "CPU sampling interval in microseconds"
|
|
161
|
+
},
|
|
162
|
+
"call-counts": {
|
|
163
|
+
type: "boolean",
|
|
164
|
+
default: false,
|
|
165
|
+
describe: "collect per-function execution counts via V8 precise coverage"
|
|
166
|
+
},
|
|
167
|
+
stats: {
|
|
168
|
+
type: "string",
|
|
169
|
+
default: "mean,p50,p99",
|
|
170
|
+
describe: "timing columns: mean|median|min|max|p<N> (e.g. mean,p70,p99)"
|
|
171
|
+
},
|
|
172
|
+
view: {
|
|
173
|
+
type: "boolean",
|
|
174
|
+
default: false,
|
|
175
|
+
alias: "html",
|
|
176
|
+
describe: "open viewer in browser"
|
|
177
|
+
},
|
|
178
|
+
"view-serve": {
|
|
179
|
+
type: "boolean",
|
|
180
|
+
default: false,
|
|
181
|
+
describe: "start viewer server without opening browser (reload an existing tab)"
|
|
182
|
+
},
|
|
183
|
+
"export-perfetto": {
|
|
184
|
+
type: "string",
|
|
185
|
+
requiresArg: true,
|
|
186
|
+
describe: "export Perfetto trace file (view at ui.perfetto.dev)"
|
|
187
|
+
},
|
|
188
|
+
"export-profile": {
|
|
189
|
+
type: "string",
|
|
190
|
+
requiresArg: true,
|
|
191
|
+
alias: "export-time",
|
|
192
|
+
describe: "export CPU profile as .cpuprofile (V8/Chrome DevTools format)"
|
|
193
|
+
},
|
|
194
|
+
archive: {
|
|
195
|
+
type: "string",
|
|
196
|
+
describe: "archive profile + sources to .benchforge file"
|
|
197
|
+
},
|
|
198
|
+
editor: {
|
|
199
|
+
type: "string",
|
|
200
|
+
default: "vscode",
|
|
201
|
+
describe: "editor for source links: vscode, cursor, or custom://scheme"
|
|
202
|
+
},
|
|
203
|
+
inspect: {
|
|
204
|
+
type: "boolean",
|
|
205
|
+
default: false,
|
|
206
|
+
describe: "run once for external profiler attach"
|
|
207
|
+
},
|
|
208
|
+
"trace-opt": {
|
|
209
|
+
type: "boolean",
|
|
210
|
+
default: false,
|
|
211
|
+
describe: "trace V8 optimization tiers (requires --allow-natives-syntax)"
|
|
212
|
+
},
|
|
213
|
+
"pause-warmup": {
|
|
214
|
+
type: "number",
|
|
215
|
+
default: 0,
|
|
216
|
+
requiresArg: true,
|
|
217
|
+
describe: "post-warmup settle time in ms for V8 background compilation (0 to skip)"
|
|
218
|
+
},
|
|
219
|
+
url: {
|
|
220
|
+
type: "string",
|
|
221
|
+
requiresArg: true,
|
|
222
|
+
describe: "page URL for browser profiling (enables browser mode)"
|
|
223
|
+
},
|
|
224
|
+
"page-load": {
|
|
225
|
+
type: "boolean",
|
|
226
|
+
default: false,
|
|
227
|
+
describe: "passive page-load profiling (no __bench needed)"
|
|
228
|
+
},
|
|
229
|
+
"wait-for": {
|
|
230
|
+
type: "string",
|
|
231
|
+
requiresArg: true,
|
|
232
|
+
describe: "page-load completion: CSS selector, JS expression, 'load', or 'domcontentloaded'"
|
|
233
|
+
},
|
|
234
|
+
headless: {
|
|
235
|
+
type: "boolean",
|
|
236
|
+
default: false,
|
|
237
|
+
describe: "run browser in headless mode (default: headed)"
|
|
238
|
+
},
|
|
239
|
+
timeout: {
|
|
240
|
+
type: "number",
|
|
241
|
+
default: 60,
|
|
242
|
+
describe: "browser page timeout in seconds"
|
|
243
|
+
},
|
|
244
|
+
chrome: {
|
|
245
|
+
type: "string",
|
|
246
|
+
requiresArg: true,
|
|
247
|
+
describe: "Chrome binary path (default: auto-detect or CHROME_PATH)"
|
|
248
|
+
},
|
|
249
|
+
"chrome-profile": {
|
|
250
|
+
type: "string",
|
|
251
|
+
requiresArg: true,
|
|
252
|
+
describe: "Chrome user profile directory (default: temp profile)"
|
|
253
|
+
},
|
|
254
|
+
"baseline-url": {
|
|
255
|
+
type: "string",
|
|
256
|
+
requiresArg: true,
|
|
257
|
+
describe: "baseline URL for A/B comparison (fresh tab per batch)"
|
|
258
|
+
},
|
|
259
|
+
"chrome-args": {
|
|
260
|
+
type: "string",
|
|
261
|
+
array: true,
|
|
262
|
+
requiresArg: true,
|
|
263
|
+
describe: "extra Chromium flags"
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const defaultDuration = .642;
|
|
267
|
+
/** Default values for all CLI options, including alias keys for yargs filtering. */
|
|
268
|
+
const cliDefaults = Object.fromEntries(Object.entries(cliOptions).filter(([, opt]) => "default" in opt).flatMap(([key, opt]) => {
|
|
269
|
+
const o = opt;
|
|
270
|
+
const entries = [[key, o.default]];
|
|
271
|
+
if (o.alias) entries.push([o.alias, o.default]);
|
|
272
|
+
return entries;
|
|
273
|
+
}));
|
|
274
|
+
const optionGroups = {
|
|
275
|
+
"Run:": ["duration", "iterations"],
|
|
276
|
+
"Batching:": [
|
|
277
|
+
"batches",
|
|
278
|
+
"warmup-batch",
|
|
279
|
+
"no-batch-trim"
|
|
280
|
+
],
|
|
281
|
+
"Node:": ["worker", "inspect"],
|
|
282
|
+
"Browser:": [
|
|
283
|
+
"url",
|
|
284
|
+
"baseline-url",
|
|
285
|
+
"page-load",
|
|
286
|
+
"wait-for",
|
|
287
|
+
"headless",
|
|
288
|
+
"timeout",
|
|
289
|
+
"chrome",
|
|
290
|
+
"chrome-profile",
|
|
291
|
+
"chrome-args"
|
|
292
|
+
],
|
|
293
|
+
"GC:": ["gc-stats", "gc-force"],
|
|
294
|
+
"Allocation Profiling:": [
|
|
295
|
+
"alloc",
|
|
296
|
+
"alloc-interval",
|
|
297
|
+
"alloc-depth",
|
|
298
|
+
"alloc-rows",
|
|
299
|
+
"alloc-stack",
|
|
300
|
+
"alloc-verbose",
|
|
301
|
+
"alloc-raw",
|
|
302
|
+
"alloc-user-only"
|
|
303
|
+
],
|
|
304
|
+
"CPU Profiling:": [
|
|
305
|
+
"profile",
|
|
306
|
+
"profile-interval",
|
|
307
|
+
"call-counts"
|
|
308
|
+
],
|
|
309
|
+
"Output:": [
|
|
310
|
+
"stats",
|
|
311
|
+
"view",
|
|
312
|
+
"view-serve",
|
|
313
|
+
"equiv-margin",
|
|
314
|
+
"archive",
|
|
315
|
+
"export-perfetto",
|
|
316
|
+
"export-profile",
|
|
317
|
+
"editor"
|
|
318
|
+
],
|
|
319
|
+
"Selecting Benchmarks:": [
|
|
320
|
+
"filter",
|
|
321
|
+
"all",
|
|
322
|
+
"list"
|
|
323
|
+
],
|
|
324
|
+
"V8 Tuning:": [
|
|
325
|
+
"warmup",
|
|
326
|
+
"trace-opt",
|
|
327
|
+
"pause-first",
|
|
328
|
+
"pause-interval",
|
|
329
|
+
"pause-duration",
|
|
330
|
+
"pause-warmup"
|
|
331
|
+
],
|
|
332
|
+
"Adaptive:": [
|
|
333
|
+
"adaptive",
|
|
334
|
+
"min-time",
|
|
335
|
+
"convergence"
|
|
336
|
+
]
|
|
337
|
+
};
|
|
338
|
+
const { url: _url, ...browserOnlyOptions } = cliOptions;
|
|
339
|
+
/** Parse command line arguments with optional custom yargs configuration. */
|
|
340
|
+
function parseCliArgs(args, configure = defaultCliArgs) {
|
|
341
|
+
return configure(yargs(args)).parseSync();
|
|
342
|
+
}
|
|
343
|
+
/** Configure yargs for browser benchmarking with url as a required positional. */
|
|
344
|
+
function browserCliArgs(yargsInstance) {
|
|
345
|
+
return applyGroups(yargsInstance.command("$0 <url>", "run browser benchmarks", (y) => {
|
|
346
|
+
y.positional("url", {
|
|
347
|
+
type: "string",
|
|
348
|
+
describe: "page URL for browser profiling"
|
|
349
|
+
});
|
|
350
|
+
}).options(browserOnlyOptions).help().strict());
|
|
351
|
+
}
|
|
352
|
+
/** Configure yargs with standard benchmark options and file positional. */
|
|
353
|
+
function defaultCliArgs(yargsInstance) {
|
|
354
|
+
return applyGroups(yargsInstance.command("$0 [file]", "run benchmarks", (y) => {
|
|
355
|
+
y.positional("file", {
|
|
356
|
+
type: "string",
|
|
357
|
+
describe: "benchmark file to run"
|
|
358
|
+
});
|
|
359
|
+
}).options(cliOptions).help().strict());
|
|
360
|
+
}
|
|
361
|
+
/** Strip yargs internals (`_`, `$0`) and undefined values, converting kebab-case to camelCase. */
|
|
362
|
+
function cleanCliArgs(args) {
|
|
363
|
+
const skip = new Set(["_", "$0"]);
|
|
364
|
+
const camel = (k) => k.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
|
|
365
|
+
return Object.fromEntries(Object.entries(args).filter(([k, v]) => v !== void 0 && v !== null && !skip.has(k)).map(([k, v]) => [camel(k), v]));
|
|
366
|
+
}
|
|
367
|
+
/** Assign options to their labeled groups in yargs help output. */
|
|
368
|
+
function applyGroups(y) {
|
|
369
|
+
return Object.entries(optionGroups).reduce((acc, [label, keys]) => acc.group(keys, label), y);
|
|
370
|
+
}
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/export/CoverageExport.ts
|
|
373
|
+
/** Build coverage data from raw CDP/inspector coverage and source texts. */
|
|
374
|
+
function buildCoverageMap(coverage, sources) {
|
|
375
|
+
const map = /* @__PURE__ */ new Map();
|
|
376
|
+
const byName = /* @__PURE__ */ new Map();
|
|
377
|
+
for (const script of coverage.scripts) processScript(script, sources, map, byName);
|
|
378
|
+
return {
|
|
379
|
+
map,
|
|
380
|
+
byName
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
/** Annotate speedscope frame names with execution counts (e.g. "fn [1.2K]"). */
|
|
384
|
+
function annotateFramesWithCounts(frames, coverage) {
|
|
385
|
+
for (const frame of frames) {
|
|
386
|
+
const entries = frame.file ? coverage.map.get(frame.file) : void 0;
|
|
387
|
+
const count = entries && findCount(frame.name, frame.line, entries);
|
|
388
|
+
const isAnon = frame.name.startsWith("(anonymous");
|
|
389
|
+
const resolved = count ?? (isAnon ? void 0 : coverage.byName.get(frame.name));
|
|
390
|
+
if (resolved !== void 0 && resolved > 0) frame.name = `${frame.name} [${formatCount(resolved)}]`;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/** Extract per-function coverage entries from a single script. */
|
|
394
|
+
function processScript(script, sources, map, byName) {
|
|
395
|
+
const { url, functions } = script;
|
|
396
|
+
const source = url ? sources[url] : void 0;
|
|
397
|
+
const lineOffsets = source ? buildLineOffsets(source) : void 0;
|
|
398
|
+
const entries = [];
|
|
399
|
+
for (const fn of functions) {
|
|
400
|
+
const range = fn.ranges[0];
|
|
401
|
+
if (!range) continue;
|
|
402
|
+
if (lineOffsets && url) entries.push({
|
|
403
|
+
startLine: offsetToLine(range.startOffset, lineOffsets),
|
|
404
|
+
functionName: fn.functionName,
|
|
405
|
+
count: range.count
|
|
406
|
+
});
|
|
407
|
+
if (fn.functionName && range.count > 0) {
|
|
408
|
+
const prev = byName.get(fn.functionName) ?? 0;
|
|
409
|
+
byName.set(fn.functionName, prev + range.count);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (entries.length > 0 && url) map.set(url, entries);
|
|
413
|
+
}
|
|
414
|
+
/** Match a frame to a coverage entry by function name (or closest line for anonymous). */
|
|
415
|
+
function findCount(frameName, frameLine, entries) {
|
|
416
|
+
if (frameName === "(anonymous)" || frameName.startsWith("(anonymous ")) {
|
|
417
|
+
if (!frameLine) return void 0;
|
|
418
|
+
return closestByLine(entries.filter((e) => e.functionName === ""), frameLine)?.count;
|
|
419
|
+
}
|
|
420
|
+
const nameMatches = entries.filter((e) => e.functionName === frameName);
|
|
421
|
+
if (nameMatches.length === 0) return void 0;
|
|
422
|
+
if (nameMatches.length === 1) return nameMatches[0].count;
|
|
423
|
+
if (frameLine) return closestByLine(nameMatches, frameLine)?.count;
|
|
424
|
+
return nameMatches[0].count;
|
|
425
|
+
}
|
|
426
|
+
/** Format a count for display (e.g. 1234567 ==> "1.2M"). */
|
|
427
|
+
function formatCount(n) {
|
|
428
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
429
|
+
if (n >= 1e4) return `${(n / 1e3).toFixed(1)}K`;
|
|
430
|
+
return String(n);
|
|
431
|
+
}
|
|
432
|
+
/** Build array where index i is the character offset where line (i+1) starts. */
|
|
433
|
+
function buildLineOffsets(source) {
|
|
434
|
+
const offsets = [0];
|
|
435
|
+
for (let i = 0; i < source.length; i++) if (source[i] === "\n") offsets.push(i + 1);
|
|
436
|
+
return offsets;
|
|
437
|
+
}
|
|
438
|
+
/** Convert character offset to 1-indexed line number via binary search. */
|
|
439
|
+
function offsetToLine(offset, lineOffsets) {
|
|
440
|
+
let lo = 0;
|
|
441
|
+
let hi = lineOffsets.length - 1;
|
|
442
|
+
while (lo < hi) {
|
|
443
|
+
const mid = lo + hi + 1 >> 1;
|
|
444
|
+
if (lineOffsets[mid] <= offset) lo = mid;
|
|
445
|
+
else hi = mid - 1;
|
|
446
|
+
}
|
|
447
|
+
return lo + 1;
|
|
448
|
+
}
|
|
449
|
+
/** Find the entry whose startLine is closest to the given line. */
|
|
450
|
+
function closestByLine(entries, line) {
|
|
451
|
+
if (!entries.length) return void 0;
|
|
452
|
+
const dist = (e) => Math.abs(e.startLine - line);
|
|
453
|
+
return entries.reduce((best, e) => dist(e) < dist(best) ? e : best);
|
|
454
|
+
}
|
|
455
|
+
//#endregion
|
|
456
|
+
//#region src/export/EditorUri.ts
|
|
457
|
+
const presets = {
|
|
458
|
+
vscode: "vscode://file",
|
|
459
|
+
cursor: "cursor://file"
|
|
460
|
+
};
|
|
461
|
+
/** Resolve editor name or custom URI to a prefix.
|
|
462
|
+
* Links are formatted as `{prefix}{absolutePath}:{line}:{col}` */
|
|
463
|
+
function resolveEditorUri(editor) {
|
|
464
|
+
return presets[editor] ?? editor;
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/export/PerfettoExport.ts
|
|
468
|
+
/** Export benchmark samples to Chrome Trace Event format for viewing in Perfetto. */
|
|
469
|
+
const pid = 1;
|
|
470
|
+
const tid = 1;
|
|
471
|
+
/** Export benchmark results to Perfetto-compatible trace file */
|
|
472
|
+
function exportPerfettoTrace(groups, outputPath, args) {
|
|
473
|
+
const absPath = resolve(outputPath);
|
|
474
|
+
const traceFile = { traceEvents: mergeV8Trace(buildTraceEvents(groups, args)) };
|
|
475
|
+
writeFileSync(absPath, JSON.stringify(traceFile));
|
|
476
|
+
console.log(`Perfetto trace exported to: ${outputPath}`);
|
|
477
|
+
scheduleDeferredMerge(absPath);
|
|
478
|
+
}
|
|
479
|
+
function buildTraceEvents(groups, cliArgs) {
|
|
480
|
+
const metadata = [
|
|
481
|
+
meta("process_name", { name: "wesl-bench" }),
|
|
482
|
+
meta("thread_name", { name: "MainThread" }),
|
|
483
|
+
meta("bench_settings", cleanCliArgs(cliArgs))
|
|
484
|
+
];
|
|
485
|
+
const benchEvents = groups.flatMap((group) => group.reports.flatMap((report) => buildBenchmarkEvents(report.measuredResults)));
|
|
486
|
+
return [...metadata, ...benchEvents];
|
|
487
|
+
}
|
|
488
|
+
function mergeV8Trace(events) {
|
|
489
|
+
const v8Events = loadV8Events(readdirSync(".").find((f) => f.startsWith("node_trace.") && f.endsWith(".log")));
|
|
490
|
+
const merged = v8Events ? [...v8Events, ...events] : events;
|
|
491
|
+
normalizeTimestamps(merged);
|
|
492
|
+
return merged;
|
|
493
|
+
}
|
|
494
|
+
/** V8 writes trace files after process exit, so we spawn a deferred merge. */
|
|
495
|
+
function scheduleDeferredMerge(outputPath) {
|
|
496
|
+
const cwd = process.cwd();
|
|
497
|
+
const mergeScript = `
|
|
498
|
+
const { readdirSync, readFileSync, writeFileSync } = require('fs');
|
|
499
|
+
function normalize(events) {
|
|
500
|
+
let min = Infinity;
|
|
501
|
+
for (const e of events) if (e.ts > 0 && e.ts < min) min = e.ts;
|
|
502
|
+
if (min === Infinity) return;
|
|
503
|
+
for (const e of events) if (e.ts > 0) e.ts -= min;
|
|
504
|
+
}
|
|
505
|
+
setTimeout(() => {
|
|
506
|
+
const traceFiles = readdirSync('.').filter(f => f.startsWith('node_trace.') && f.endsWith('.log'));
|
|
507
|
+
if (traceFiles.length === 0) process.exit(0);
|
|
508
|
+
try {
|
|
509
|
+
const v8Data = JSON.parse(readFileSync(traceFiles[0], 'utf-8'));
|
|
510
|
+
const ourData = JSON.parse(readFileSync('${outputPath}', 'utf-8'));
|
|
511
|
+
const allEvents = [...v8Data.traceEvents, ...ourData.traceEvents];
|
|
512
|
+
normalize(allEvents);
|
|
513
|
+
writeFileSync('${outputPath}', JSON.stringify({ traceEvents: allEvents }));
|
|
514
|
+
console.log('Merged ' + v8Data.traceEvents.length + ' V8 events into ' + '${outputPath}');
|
|
515
|
+
} catch (e) { console.error('Merge failed:', e.message); }
|
|
516
|
+
}, 100);
|
|
517
|
+
`;
|
|
518
|
+
process.on("exit", () => {
|
|
519
|
+
spawn("node", ["-e", mergeScript], {
|
|
520
|
+
detached: true,
|
|
521
|
+
stdio: "inherit",
|
|
522
|
+
cwd
|
|
523
|
+
}).unref();
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
function meta(name, args) {
|
|
527
|
+
return {
|
|
528
|
+
ph: "M",
|
|
529
|
+
ts: 0,
|
|
530
|
+
pid,
|
|
531
|
+
tid,
|
|
532
|
+
name,
|
|
533
|
+
args
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
/** Build events for a single benchmark run, deriving timestamps from cumulative sample durations. */
|
|
537
|
+
function buildBenchmarkEvents(results) {
|
|
538
|
+
const { samples, heapSamples, pausePoints, startTime = 0 } = results;
|
|
539
|
+
if (!samples?.length) return [];
|
|
540
|
+
const timestamps = cumulativeTimestamps(samples, startTime);
|
|
541
|
+
const events = [];
|
|
542
|
+
for (let i = 0; i < samples.length; i++) {
|
|
543
|
+
const ts = timestamps[i];
|
|
544
|
+
const ms = Math.round(samples[i] * 100) / 100;
|
|
545
|
+
events.push(instant(ts, results.name, {
|
|
546
|
+
n: i,
|
|
547
|
+
ms
|
|
548
|
+
}));
|
|
549
|
+
events.push(counter(ts, "duration", { ms }));
|
|
550
|
+
if (heapSamples?.[i] !== void 0) {
|
|
551
|
+
const mb = Math.round(heapSamples[i] / 1024 / 1024 * 10) / 10;
|
|
552
|
+
events.push(counter(ts, "heap", { MB: mb }));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
for (const pause of pausePoints ?? []) {
|
|
556
|
+
const ts = timestamps[pause.sampleIndex];
|
|
557
|
+
if (ts) events.push(instant(ts, "pause", { ms: pause.durationMs }));
|
|
558
|
+
}
|
|
559
|
+
return events;
|
|
560
|
+
}
|
|
561
|
+
function loadV8Events(v8TracePath) {
|
|
562
|
+
if (!v8TracePath) return void 0;
|
|
563
|
+
try {
|
|
564
|
+
const { traceEvents } = JSON.parse(readFileSync(v8TracePath, "utf-8"));
|
|
565
|
+
console.log(`Merged ${traceEvents.length} V8 events from ${v8TracePath}`);
|
|
566
|
+
return traceEvents;
|
|
567
|
+
} catch {
|
|
568
|
+
console.warn(`Could not parse V8 trace file: ${v8TracePath}`);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/** Normalize timestamps so events start at 0 */
|
|
573
|
+
function normalizeTimestamps(events) {
|
|
574
|
+
let min = Number.POSITIVE_INFINITY;
|
|
575
|
+
for (const e of events) if (e.ts > 0 && e.ts < min) min = e.ts;
|
|
576
|
+
if (min === Number.POSITIVE_INFINITY) return;
|
|
577
|
+
for (const e of events) if (e.ts > 0) e.ts -= min;
|
|
578
|
+
}
|
|
579
|
+
/** Derive μs timestamps from cumulative sample durations (ms), offset by startTime. */
|
|
580
|
+
function cumulativeTimestamps(samples, offset = 0) {
|
|
581
|
+
const timestamps = new Array(samples.length);
|
|
582
|
+
let cumulative = 0;
|
|
583
|
+
for (let i = 0; i < samples.length; i++) {
|
|
584
|
+
cumulative += samples[i];
|
|
585
|
+
timestamps[i] = offset + Math.round(cumulative * 1e3);
|
|
586
|
+
}
|
|
587
|
+
return timestamps;
|
|
588
|
+
}
|
|
589
|
+
/** Create a thread-scoped instant event */
|
|
590
|
+
function instant(ts, name, args) {
|
|
591
|
+
return {
|
|
592
|
+
ph: "i",
|
|
593
|
+
ts,
|
|
594
|
+
pid,
|
|
595
|
+
tid,
|
|
596
|
+
cat: "bench",
|
|
597
|
+
name,
|
|
598
|
+
s: "t",
|
|
599
|
+
args
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
/** Create a counter event (shown as a time-series chart in Perfetto) */
|
|
603
|
+
function counter(ts, name, args) {
|
|
604
|
+
return {
|
|
605
|
+
ph: "C",
|
|
606
|
+
ts,
|
|
607
|
+
pid,
|
|
608
|
+
tid,
|
|
609
|
+
cat: "bench",
|
|
610
|
+
name,
|
|
611
|
+
args
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/export/TimeExport.ts
|
|
616
|
+
/** CPU time profile conversion to Speedscope sampled format. */
|
|
617
|
+
/** Build a SpeedscopeFile from multiple named time profiles (shared frames). */
|
|
618
|
+
function buildTimeSpeedscopeFile(entries) {
|
|
619
|
+
if (entries.length === 0) return void 0;
|
|
620
|
+
const ctx = frameContext();
|
|
621
|
+
return speedscopeFile(ctx, entries.map((e) => buildTimeProfile(e.name, e.profile, ctx)));
|
|
622
|
+
}
|
|
623
|
+
/** Build a speedscope profile from a V8 TimeProfile */
|
|
624
|
+
function buildTimeProfile(name, profile, ctx) {
|
|
625
|
+
const { samples: sampleIds, timeDeltas, nodes } = profile;
|
|
626
|
+
if (!sampleIds?.length || !timeDeltas) return {
|
|
627
|
+
type: "sampled",
|
|
628
|
+
name,
|
|
629
|
+
unit: "microseconds",
|
|
630
|
+
startValue: 0,
|
|
631
|
+
endValue: 0,
|
|
632
|
+
samples: [],
|
|
633
|
+
weights: []
|
|
634
|
+
};
|
|
635
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
636
|
+
const parentMap = /* @__PURE__ */ new Map();
|
|
637
|
+
for (const node of nodes) for (const childId of node.children ?? []) parentMap.set(childId, node.id);
|
|
638
|
+
const cache = /* @__PURE__ */ new Map();
|
|
639
|
+
const resolve = (id) => resolveStack(id, nodeMap, parentMap, cache, ctx);
|
|
640
|
+
const samples = sampleIds.map(resolve);
|
|
641
|
+
return {
|
|
642
|
+
type: "sampled",
|
|
643
|
+
name,
|
|
644
|
+
unit: "microseconds",
|
|
645
|
+
startValue: 0,
|
|
646
|
+
endValue: timeDeltas.reduce((sum, w) => sum + w, 0),
|
|
647
|
+
samples,
|
|
648
|
+
weights: timeDeltas
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
/** Walk from node to root, building a stack of frame indices (root-first) */
|
|
652
|
+
function resolveStack(nodeId, nodeMap, parentMap, cache, ctx) {
|
|
653
|
+
const cached = cache.get(nodeId);
|
|
654
|
+
if (cached) return cached;
|
|
655
|
+
const path = [];
|
|
656
|
+
let current = nodeId;
|
|
657
|
+
while (current !== void 0) {
|
|
658
|
+
path.push(current);
|
|
659
|
+
current = parentMap.get(current);
|
|
660
|
+
}
|
|
661
|
+
const stack = [];
|
|
662
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
663
|
+
const node = nodeMap.get(path[i]);
|
|
664
|
+
if (!node) continue;
|
|
665
|
+
const { functionName, url, lineNumber } = node.callFrame;
|
|
666
|
+
if (!functionName && !url && lineNumber <= 0) continue;
|
|
667
|
+
const frame = resolveCallFrame(node.callFrame);
|
|
668
|
+
stack.push(internFrame(frame.name, frame.url, frame.line, frame.col, ctx));
|
|
669
|
+
}
|
|
670
|
+
cache.set(nodeId, stack);
|
|
671
|
+
return stack;
|
|
672
|
+
}
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/profiling/node/HeapSampleReport.ts
|
|
675
|
+
/** Flatten resolved profile into sorted list of allocation sites with call stacks.
|
|
676
|
+
* When raw samples are available, attaches them to corresponding sites. */
|
|
677
|
+
function flattenProfile(resolved) {
|
|
678
|
+
const sites = [];
|
|
679
|
+
const nodeIdToSites = /* @__PURE__ */ new Map();
|
|
680
|
+
for (const node of resolved.allocationNodes) {
|
|
681
|
+
const site = {
|
|
682
|
+
...node.frame,
|
|
683
|
+
bytes: node.selfSize,
|
|
684
|
+
stack: node.stack
|
|
685
|
+
};
|
|
686
|
+
sites.push(site);
|
|
687
|
+
const bucket = nodeIdToSites.get(node.nodeId) ?? [];
|
|
688
|
+
if (!bucket.length) nodeIdToSites.set(node.nodeId, bucket);
|
|
689
|
+
bucket.push(site);
|
|
690
|
+
}
|
|
691
|
+
for (const sample of resolved.sortedSamples ?? []) {
|
|
692
|
+
const matchingSites = nodeIdToSites.get(sample.nodeId);
|
|
693
|
+
if (!matchingSites) continue;
|
|
694
|
+
for (const site of matchingSites) {
|
|
695
|
+
if (!site.samples) site.samples = [];
|
|
696
|
+
site.samples.push(sample);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return sites.sort((a, b) => b.bytes - a.bytes);
|
|
700
|
+
}
|
|
701
|
+
/** Return true if the call frame is user code (excludes node: and internal/ URLs) */
|
|
702
|
+
function isNodeUserCode(site) {
|
|
703
|
+
const { url } = site;
|
|
704
|
+
return !!url && !url.startsWith("node:") && !url.includes("(native)") && !url.includes("internal/");
|
|
705
|
+
}
|
|
706
|
+
/** Return true if the call frame is user code (excludes chrome-extension:// and devtools:// URLs) */
|
|
707
|
+
function isBrowserUserCode(site) {
|
|
708
|
+
const { url } = site;
|
|
709
|
+
return !!url && !url.startsWith("chrome-extension://") && !url.startsWith("devtools://") && !url.includes("(native)");
|
|
710
|
+
}
|
|
711
|
+
/** Return only sites matching a user-code predicate (default: {@link isNodeUserCode}) */
|
|
712
|
+
function filterSites(sites, isUser = isNodeUserCode) {
|
|
713
|
+
return sites.filter(isUser);
|
|
714
|
+
}
|
|
715
|
+
/** Aggregate sites by location (combine same file:line:col).
|
|
716
|
+
* Tracks distinct caller stacks with byte weights when merging. */
|
|
717
|
+
function aggregateSites(sites) {
|
|
718
|
+
const byLocation = /* @__PURE__ */ new Map();
|
|
719
|
+
for (const site of sites) {
|
|
720
|
+
const colKey = site.col != null ? `${site.col}` : `?:${site.name}`;
|
|
721
|
+
const key = `${site.url}:${site.line}:${colKey}`;
|
|
722
|
+
const existing = byLocation.get(key);
|
|
723
|
+
if (existing) {
|
|
724
|
+
existing.bytes += site.bytes;
|
|
725
|
+
addCaller(existing, site);
|
|
726
|
+
} else {
|
|
727
|
+
const callers = site.stack ? [{
|
|
728
|
+
stack: site.stack,
|
|
729
|
+
bytes: site.bytes
|
|
730
|
+
}] : void 0;
|
|
731
|
+
byLocation.set(key, {
|
|
732
|
+
...site,
|
|
733
|
+
callers
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
for (const site of byLocation.values()) {
|
|
738
|
+
if (!site.callers || site.callers.length <= 1) continue;
|
|
739
|
+
site.callers.sort((a, b) => b.bytes - a.bytes);
|
|
740
|
+
site.stack = site.callers[0].stack;
|
|
741
|
+
}
|
|
742
|
+
return [...byLocation.values()].sort((a, b) => b.bytes - a.bytes);
|
|
743
|
+
}
|
|
744
|
+
/** Format heap report for console output */
|
|
745
|
+
function formatHeapReport(sites, options) {
|
|
746
|
+
const { topN, stackDepth = 3, verbose = false } = options;
|
|
747
|
+
const { totalAll, totalUserCode, sampleCount, isUserCode } = options;
|
|
748
|
+
const isUser = isUserCode ?? isNodeUserCode;
|
|
749
|
+
const formatSite = verbose ? formatVerboseSite : formatCompactSite;
|
|
750
|
+
const lines = [];
|
|
751
|
+
lines.push(`Heap allocation sites (top ${topN}, garbage included):`);
|
|
752
|
+
for (const site of sites.slice(0, topN)) formatSite(lines, site, stackDepth, isUser);
|
|
753
|
+
lines.push("");
|
|
754
|
+
if (totalAll !== void 0) lines.push(`Total (all): ${fmtBytes(totalAll)}`);
|
|
755
|
+
if (totalUserCode !== void 0) lines.push(`Total (user-code): ${fmtBytes(totalUserCode)}`);
|
|
756
|
+
if (sampleCount !== void 0) lines.push(`Samples: ${sampleCount.toLocaleString()}`);
|
|
757
|
+
return lines.join("\n");
|
|
758
|
+
}
|
|
759
|
+
/** Sum bytes across all sites */
|
|
760
|
+
function totalBytes(sites) {
|
|
761
|
+
return sites.reduce((sum, s) => sum + s.bytes, 0);
|
|
762
|
+
}
|
|
763
|
+
/** Format every raw sample as one line, ordered by ordinal (time).
|
|
764
|
+
* Output is tab-separated for easy piping/grep/diff. */
|
|
765
|
+
function formatRawSamples(resolved) {
|
|
766
|
+
const { sortedSamples, nodeMap } = resolved;
|
|
767
|
+
if (!sortedSamples || sortedSamples.length === 0) return "No raw samples available.";
|
|
768
|
+
return ["ordinal size function location", ...sortedSamples.map((s) => {
|
|
769
|
+
const frame = nodeMap.get(s.nodeId)?.frame;
|
|
770
|
+
const fn = frame?.name || "(unknown)";
|
|
771
|
+
const url = frame?.url || "";
|
|
772
|
+
const loc = url ? fmtLoc(url, frame.line, frame.col) : "(unknown)";
|
|
773
|
+
return `${s.ordinal}\t${s.size}\t${fn}\t${loc}`;
|
|
774
|
+
})].join("\n");
|
|
775
|
+
}
|
|
776
|
+
/** Add a caller stack to an aggregated site, merging if the same path exists */
|
|
777
|
+
function addCaller(existing, site) {
|
|
778
|
+
if (!site.stack) return;
|
|
779
|
+
existing.callers ??= [];
|
|
780
|
+
const key = callerKey(site.stack);
|
|
781
|
+
const match = existing.callers.find((c) => callerKey(c.stack) === key);
|
|
782
|
+
if (match) match.bytes += site.bytes;
|
|
783
|
+
else existing.callers.push({
|
|
784
|
+
stack: site.stack,
|
|
785
|
+
bytes: site.bytes
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
/** Verbose multi-line format with file:// paths and line numbers */
|
|
789
|
+
function formatVerboseSite(lines, site, stackDepth, isUser) {
|
|
790
|
+
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
791
|
+
const loc = site.url ? fmtLoc(site.url, site.line, site.col) : "(unknown)";
|
|
792
|
+
const style = isUser(site) ? (s) => s : colors.dim;
|
|
793
|
+
lines.push(style(`${bytes} ${site.name} ${loc}`));
|
|
794
|
+
const userCallers = callerFrames(site, stackDepth).filter((f) => f.url && isUser(f));
|
|
795
|
+
for (const frame of userCallers) {
|
|
796
|
+
const loc = fmtLoc(frame.url, frame.line, frame.col);
|
|
797
|
+
lines.push(style(` <- ${frame.name} ${loc}`));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/** Compact single-line format: `49 MB fn1 <- fn2 <- fn3` */
|
|
801
|
+
function formatCompactSite(lines, site, stackDepth, isUser) {
|
|
802
|
+
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
803
|
+
const callers = callerFrames(site, stackDepth).filter((f) => f.url && isUser(f)).map((f) => f.name);
|
|
804
|
+
const line = `${bytes} ${[site.name, ...callers].join(" <- ")}`;
|
|
805
|
+
lines.push(isUser(site) ? line : colors.dim(line));
|
|
806
|
+
}
|
|
807
|
+
/** Format bytes with a space separator, falling back to raw bytes */
|
|
808
|
+
function fmtBytes(bytes) {
|
|
809
|
+
return formatBytes(bytes, { space: true }) ?? `${bytes} B`;
|
|
810
|
+
}
|
|
811
|
+
/** Format location, omitting column when unknown */
|
|
812
|
+
function fmtLoc(url, line, col) {
|
|
813
|
+
return col != null ? `${url}:${line}:${col}` : `${url}:${line}`;
|
|
814
|
+
}
|
|
815
|
+
/** Serialize a call stack for dedup comparison */
|
|
816
|
+
function callerKey(stack) {
|
|
817
|
+
return stack.map((f) => `${f.url}:${f.line}:${f.col}`).join("|");
|
|
818
|
+
}
|
|
819
|
+
/** Get caller frames (parent stack excluding self, reversed, truncated) */
|
|
820
|
+
function callerFrames(site, depth) {
|
|
821
|
+
if (!site.stack || site.stack.length <= 1) return [];
|
|
822
|
+
return site.stack.slice(0, -1).reverse().slice(0, depth);
|
|
823
|
+
}
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/report/GcSections.ts
|
|
826
|
+
/** Report section: GC time as fraction of total benchmark time. */
|
|
827
|
+
const gcSection = {
|
|
828
|
+
title: "gc",
|
|
829
|
+
columns: [{
|
|
830
|
+
key: "gc",
|
|
831
|
+
title: "mean",
|
|
832
|
+
formatter: percent,
|
|
833
|
+
comparable: true,
|
|
834
|
+
value: (r) => {
|
|
835
|
+
const { nodeGcTime, time, samples } = r;
|
|
836
|
+
if (!nodeGcTime || !time?.avg) return void 0;
|
|
837
|
+
const totalBenchTime = time.avg * samples.length;
|
|
838
|
+
if (totalBenchTime <= 0) return void 0;
|
|
839
|
+
const gcFraction = nodeGcTime.inRun / totalBenchTime;
|
|
840
|
+
return gcFraction <= 1 ? gcFraction : void 0;
|
|
841
|
+
}
|
|
842
|
+
}]
|
|
843
|
+
};
|
|
844
|
+
/** Report section: detailed GC stats from --trace-gc-nvp. */
|
|
845
|
+
const gcStatsSection = {
|
|
846
|
+
title: "gc",
|
|
847
|
+
columns: [
|
|
848
|
+
{
|
|
849
|
+
key: "allocPerIter",
|
|
850
|
+
title: "alloc/iter",
|
|
851
|
+
formatter: formatBytes,
|
|
852
|
+
comparable: true,
|
|
853
|
+
value: (r) => {
|
|
854
|
+
const { gcStats, samples } = r;
|
|
855
|
+
if (!gcStats) return void 0;
|
|
856
|
+
const alloc = gcStats.totalAllocated;
|
|
857
|
+
return alloc != null ? alloc / (samples.length || 1) : void 0;
|
|
858
|
+
}
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
key: "collected",
|
|
862
|
+
title: "collected",
|
|
863
|
+
formatter: formatBytes,
|
|
864
|
+
comparable: true,
|
|
865
|
+
value: (r) => r.gcStats?.totalCollected || void 0
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
key: "scavenges",
|
|
869
|
+
title: "scav",
|
|
870
|
+
formatter: integer,
|
|
871
|
+
comparable: true,
|
|
872
|
+
value: (r) => r.gcStats?.scavenges
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
key: "fullGCs",
|
|
876
|
+
title: "full",
|
|
877
|
+
formatter: integer,
|
|
878
|
+
comparable: true,
|
|
879
|
+
value: (r) => r.gcStats?.markCompacts
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
key: "promoPercent",
|
|
883
|
+
title: "promo%",
|
|
884
|
+
formatter: percent,
|
|
885
|
+
comparable: true,
|
|
886
|
+
value: (r) => {
|
|
887
|
+
const gs = r.gcStats;
|
|
888
|
+
if (!gs) return void 0;
|
|
889
|
+
const alloc = gs.totalAllocated;
|
|
890
|
+
return alloc && alloc > 0 ? (gs.totalPromoted ?? 0) / alloc : void 0;
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
key: "pausePerIter",
|
|
895
|
+
title: "pause/iter",
|
|
896
|
+
formatter: timeMs,
|
|
897
|
+
comparable: true,
|
|
898
|
+
value: (r) => {
|
|
899
|
+
const gs = r.gcStats;
|
|
900
|
+
return gs ? gs.gcPauseTime / (r.samples.length || 1) : void 0;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
]
|
|
904
|
+
};
|
|
905
|
+
/** Report section: browser GC stats from CDP tracing (subset of gcStatsSection). */
|
|
906
|
+
const browserGcStatsSection = {
|
|
907
|
+
title: "gc",
|
|
908
|
+
columns: [
|
|
909
|
+
gcStatsSection.columns.find((c) => c.key === "collected"),
|
|
910
|
+
gcStatsSection.columns.find((c) => c.key === "scavenges"),
|
|
911
|
+
gcStatsSection.columns.find((c) => c.key === "fullGCs"),
|
|
912
|
+
{
|
|
913
|
+
key: "pausePerIter",
|
|
914
|
+
title: "pause",
|
|
915
|
+
formatter: timeMs,
|
|
916
|
+
comparable: true,
|
|
917
|
+
value: gcStatsSection.columns.find((c) => c.key === "pausePerIter").value
|
|
918
|
+
}
|
|
919
|
+
]
|
|
920
|
+
};
|
|
921
|
+
/** Report sections: page-load stats (mean/p50/p99) across multiple iterations. */
|
|
922
|
+
const pageLoadStatsSections = [
|
|
923
|
+
pageLoadSection("DCL", (n) => n.domContentLoaded || void 0),
|
|
924
|
+
pageLoadSection("load", (n) => n.loadEvent || void 0),
|
|
925
|
+
pageLoadSection("LCP", (n) => n.lcp)
|
|
926
|
+
];
|
|
927
|
+
/** @return GC stats sections if enabled by CLI flags */
|
|
928
|
+
function gcSections(args) {
|
|
929
|
+
return args["gc-stats"] ? [gcStatsSection] : [];
|
|
930
|
+
}
|
|
931
|
+
/** Build a page-load section with mean/p50/p99 columns from NavTiming data */
|
|
932
|
+
function pageLoadSection(title, extract) {
|
|
933
|
+
const vals = (r) => navValues(r.navTimings, extract);
|
|
934
|
+
const col = (suffix, stat) => ({
|
|
935
|
+
key: `${title.toLowerCase()}${suffix}`,
|
|
936
|
+
title: suffix.toLowerCase(),
|
|
937
|
+
formatter: timeMs,
|
|
938
|
+
value: (r) => {
|
|
939
|
+
const v = vals(r);
|
|
940
|
+
return v.length ? stat(v) : void 0;
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
return {
|
|
944
|
+
title,
|
|
945
|
+
columns: [
|
|
946
|
+
col("Mean", average),
|
|
947
|
+
col("P50", median),
|
|
948
|
+
col("P99", (v) => percentile(v, .99))
|
|
949
|
+
]
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
/** Extract one field from all NavTimings, filtering undefineds. */
|
|
953
|
+
function navValues(navs, fn) {
|
|
954
|
+
if (!navs?.length) return [];
|
|
955
|
+
return navs.map(fn).filter((v) => v != null);
|
|
956
|
+
}
|
|
957
|
+
//#endregion
|
|
958
|
+
//#region src/report/ParseStats.ts
|
|
959
|
+
/** Parse --stats into column specs. Throws on empty/invalid tokens. */
|
|
960
|
+
function parseStatsArg(stats) {
|
|
961
|
+
const tokens = stats.split(",").map((t) => t.trim()).filter(Boolean);
|
|
962
|
+
if (tokens.length === 0) throw new Error("--stats must list at least one column");
|
|
963
|
+
const seen = /* @__PURE__ */ new Set();
|
|
964
|
+
const specs = [];
|
|
965
|
+
for (const token of tokens) {
|
|
966
|
+
const spec = parseStatToken(token);
|
|
967
|
+
if (seen.has(spec.key)) continue;
|
|
968
|
+
seen.add(spec.key);
|
|
969
|
+
specs.push(spec);
|
|
970
|
+
}
|
|
971
|
+
return specs;
|
|
972
|
+
}
|
|
973
|
+
/** @return stat spec for a single --stats token. Throws on invalid input. */
|
|
974
|
+
function parseStatToken(token) {
|
|
975
|
+
const lower = token.toLowerCase();
|
|
976
|
+
if (lower === "mean" || lower === "avg") return {
|
|
977
|
+
key: "mean",
|
|
978
|
+
title: "mean",
|
|
979
|
+
statKind: "mean"
|
|
980
|
+
};
|
|
981
|
+
if (lower === "median") return {
|
|
982
|
+
key: "p50",
|
|
983
|
+
title: "p50",
|
|
984
|
+
statKind: { percentile: .5 }
|
|
985
|
+
};
|
|
986
|
+
if (lower === "min") return {
|
|
987
|
+
key: "min",
|
|
988
|
+
title: "min",
|
|
989
|
+
statKind: "min"
|
|
990
|
+
};
|
|
991
|
+
if (lower === "max") return {
|
|
992
|
+
key: "max",
|
|
993
|
+
title: "max",
|
|
994
|
+
statKind: "max"
|
|
995
|
+
};
|
|
996
|
+
const m = lower.match(/^p(\d+)$/);
|
|
997
|
+
if (m) return parsePercentileToken(token, m[1]);
|
|
998
|
+
throw new Error(`invalid --stats token "${token}": expected mean, median, min, max, or p<N> (e.g. p50, p99, p999)`);
|
|
999
|
+
}
|
|
1000
|
+
/** @return spec for a p<N> token, enforcing the 2-digit minimum and 9-prefix rule. */
|
|
1001
|
+
function parsePercentileToken(token, digits) {
|
|
1002
|
+
if (digits.length < 2) throw new Error(`invalid --stats token "${token}": percentile needs at least 2 digits (e.g. p05, p50, p99, p999)`);
|
|
1003
|
+
if (digits.length > 2 && digits[0] !== "9") throw new Error(`invalid --stats token "${token}": percentiles with 3+ digits must start with 9 (e.g. p999, p9999); otherwise use 2-digit form (e.g. p50)`);
|
|
1004
|
+
const q = Number(digits) / 10 ** digits.length;
|
|
1005
|
+
return {
|
|
1006
|
+
key: `p${digits}`,
|
|
1007
|
+
title: `p${digits}`,
|
|
1008
|
+
statKind: { percentile: q }
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/report/StandardSections.ts
|
|
1013
|
+
/** Default timing section: mean, p50, p99. */
|
|
1014
|
+
const timeSection = buildTimeSection();
|
|
1015
|
+
/** Report section: number of sample iterations. */
|
|
1016
|
+
const runsSection = {
|
|
1017
|
+
title: "",
|
|
1018
|
+
columns: [{
|
|
1019
|
+
key: "runs",
|
|
1020
|
+
title: "runs",
|
|
1021
|
+
formatter: (v) => String(v),
|
|
1022
|
+
value: (r) => r.samples.length
|
|
1023
|
+
}]
|
|
1024
|
+
};
|
|
1025
|
+
/** Report section: total sampling duration. */
|
|
1026
|
+
const totalTimeSection = {
|
|
1027
|
+
title: "",
|
|
1028
|
+
columns: [{
|
|
1029
|
+
key: "totalTime",
|
|
1030
|
+
title: "time",
|
|
1031
|
+
formatter: formatTotalTime,
|
|
1032
|
+
value: (r) => r.totalTime
|
|
1033
|
+
}]
|
|
1034
|
+
};
|
|
1035
|
+
/** Report sections: timing stats and convergence for adaptive mode. */
|
|
1036
|
+
const adaptiveSections = [{
|
|
1037
|
+
title: "time",
|
|
1038
|
+
columns: [
|
|
1039
|
+
{
|
|
1040
|
+
key: "median",
|
|
1041
|
+
title: "median",
|
|
1042
|
+
formatter: timeMs,
|
|
1043
|
+
comparable: true,
|
|
1044
|
+
statKind: { percentile: .5 }
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
key: "mean",
|
|
1048
|
+
title: "mean",
|
|
1049
|
+
formatter: timeMs,
|
|
1050
|
+
comparable: true,
|
|
1051
|
+
statKind: "mean"
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
key: "p99",
|
|
1055
|
+
title: "p99",
|
|
1056
|
+
formatter: timeMs,
|
|
1057
|
+
statKind: { percentile: .99 }
|
|
1058
|
+
}
|
|
1059
|
+
]
|
|
1060
|
+
}, {
|
|
1061
|
+
title: "",
|
|
1062
|
+
columns: [{
|
|
1063
|
+
key: "convergence",
|
|
1064
|
+
title: "conv%",
|
|
1065
|
+
formatter: formatConvergence,
|
|
1066
|
+
value: (r) => r.convergence?.confidence
|
|
1067
|
+
}]
|
|
1068
|
+
}];
|
|
1069
|
+
/** Report section: V8 optimization tier distribution and deopt count. */
|
|
1070
|
+
const optSection = {
|
|
1071
|
+
title: "v8 opt",
|
|
1072
|
+
columns: [{
|
|
1073
|
+
key: "tiers",
|
|
1074
|
+
title: "tiers",
|
|
1075
|
+
formatter: (v) => typeof v === "string" ? v : "",
|
|
1076
|
+
value: (r) => {
|
|
1077
|
+
const opt = r.optStatus;
|
|
1078
|
+
return opt ? formatTierSummary(opt) : void 0;
|
|
1079
|
+
}
|
|
1080
|
+
}, {
|
|
1081
|
+
key: "deopt",
|
|
1082
|
+
title: "deopt",
|
|
1083
|
+
formatter: (v) => typeof v === "number" ? String(v) : "",
|
|
1084
|
+
value: (r) => {
|
|
1085
|
+
const opt = r.optStatus;
|
|
1086
|
+
return opt && opt.deoptCount > 0 ? opt.deoptCount : void 0;
|
|
1087
|
+
}
|
|
1088
|
+
}]
|
|
1089
|
+
};
|
|
1090
|
+
/** Build a time section with user-chosen percentile/stat columns. */
|
|
1091
|
+
function buildTimeSection(stats = "mean,p50,p99") {
|
|
1092
|
+
return {
|
|
1093
|
+
title: "time",
|
|
1094
|
+
columns: parseStatsArg(stats).map((s) => ({
|
|
1095
|
+
key: s.key,
|
|
1096
|
+
title: s.title,
|
|
1097
|
+
formatter: timeMs,
|
|
1098
|
+
comparable: isBootstrappable(s.statKind),
|
|
1099
|
+
statKind: s.statKind
|
|
1100
|
+
}))
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
/** Format V8 tier distribution sorted by count (e.g. "turbofan:85% sparkplug:15%"). */
|
|
1104
|
+
function formatTierSummary(opt, sep = ":", glue = " ") {
|
|
1105
|
+
const tiers = Object.entries(opt.byTier);
|
|
1106
|
+
const total = tiers.reduce((s, [, t]) => s + t.count, 0);
|
|
1107
|
+
const pct = (n) => `${(n / total * 100).toFixed(0)}%`;
|
|
1108
|
+
return tiers.sort((a, b) => b[1].count - a[1].count).map(([name, t]) => `${name}${sep}${pct(t.count)}`).join(glue);
|
|
1109
|
+
}
|
|
1110
|
+
/** @return default report sections from CLI flags (GC stats if enabled, plus run count). */
|
|
1111
|
+
function buildGenericSections(args) {
|
|
1112
|
+
return [...gcSections(args), runsSection];
|
|
1113
|
+
}
|
|
1114
|
+
/** Format total time; brackets indicate >= 30s. */
|
|
1115
|
+
function formatTotalTime(v) {
|
|
1116
|
+
if (typeof v !== "number") return "";
|
|
1117
|
+
return v >= 30 ? `[${v.toFixed(1)}s]` : `${v.toFixed(1)}s`;
|
|
1118
|
+
}
|
|
1119
|
+
/** @return true if comparing with fewer than minBatches on either side */
|
|
1120
|
+
function hasLowBatchCount(baseline, current) {
|
|
1121
|
+
if (!baseline) return false;
|
|
1122
|
+
return batchCount(baseline) < 20 || batchCount(current) < 20;
|
|
1123
|
+
}
|
|
1124
|
+
/** @return true if either side has no real batch structure */
|
|
1125
|
+
function isSingleBatch(baseline, current) {
|
|
1126
|
+
if (!baseline) return batchCount(current) < 2;
|
|
1127
|
+
return batchCount(baseline) < 2 || batchCount(current) < 2;
|
|
1128
|
+
}
|
|
1129
|
+
/** Add label, mark unreliable, and override direction when batch count is low */
|
|
1130
|
+
function annotateCI(ci, title, lowBatches) {
|
|
1131
|
+
if (!ci) return ci;
|
|
1132
|
+
if (lowBatches) ci.direction = "uncertain";
|
|
1133
|
+
ci.ciReliable = !lowBatches && ci.ciLevel !== "sample";
|
|
1134
|
+
if (title) ci.label = `${title} Δ%`;
|
|
1135
|
+
return ci;
|
|
1136
|
+
}
|
|
1137
|
+
/** Build ViewerSections from ReportSections, with bootstrap CIs for comparable columns */
|
|
1138
|
+
function buildViewerSections(sections, base) {
|
|
1139
|
+
const { current, baseline, currentMeta, baselineMeta } = base;
|
|
1140
|
+
return sections.flatMap((section) => {
|
|
1141
|
+
const curVals = computeColumnValues(section, current, currentMeta);
|
|
1142
|
+
const baseVals = baseline ? computeColumnValues(section, baseline, baselineMeta) : void 0;
|
|
1143
|
+
const ctx = {
|
|
1144
|
+
...base,
|
|
1145
|
+
curVals,
|
|
1146
|
+
baseVals
|
|
1147
|
+
};
|
|
1148
|
+
const rows = buildGroupRows(section.columns, ctx);
|
|
1149
|
+
if (!rows.length) return [];
|
|
1150
|
+
return [{
|
|
1151
|
+
title: section.title,
|
|
1152
|
+
rows
|
|
1153
|
+
}];
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
function batchCount(m) {
|
|
1157
|
+
return m?.batchOffsets?.length ?? 0;
|
|
1158
|
+
}
|
|
1159
|
+
/** Build ViewerRow[] for a column group, using shared resampling for statKind columns */
|
|
1160
|
+
function buildGroupRows(columns, ctx) {
|
|
1161
|
+
const ciMap = buildCIMap(columns, ctx);
|
|
1162
|
+
const rows = [];
|
|
1163
|
+
for (const col of columns) {
|
|
1164
|
+
const key = col.key ?? col.title;
|
|
1165
|
+
const row = buildRow(col, key, ctx, ciMap.get(key));
|
|
1166
|
+
if (row) rows.push(row);
|
|
1167
|
+
}
|
|
1168
|
+
const first = rows.find((r) => r.entries.some((e) => e.bootstrapCI));
|
|
1169
|
+
if (first) first.primary = true;
|
|
1170
|
+
return rows;
|
|
1171
|
+
}
|
|
1172
|
+
/** Compute batched bootstrap CIs, returning a Map keyed by column key */
|
|
1173
|
+
function buildCIMap(columns, ctx) {
|
|
1174
|
+
const ciCols = columns.filter((c) => c.comparable && c.statKind && isBootstrappable(c.statKind));
|
|
1175
|
+
const statKinds = ciCols.map((c) => c.statKind);
|
|
1176
|
+
const map = /* @__PURE__ */ new Map();
|
|
1177
|
+
if (statKinds.length === 0) return map;
|
|
1178
|
+
const curSamples = ctx.current.samples;
|
|
1179
|
+
const baseSamples = ctx.baseline?.samples;
|
|
1180
|
+
const curResults = curSamples?.length > 1 ? bootstrapCIs(curSamples, ctx.current.batchOffsets, statKinds) : void 0;
|
|
1181
|
+
const baseResults = baseSamples?.length && baseSamples.length > 1 ? bootstrapCIs(baseSamples, ctx.baseline.batchOffsets, statKinds) : void 0;
|
|
1182
|
+
const diffResults = buildDiffResults(ciCols, statKinds, ctx);
|
|
1183
|
+
for (let i = 0; i < ciCols.length; i++) {
|
|
1184
|
+
const key = ciCols[i].key ?? ciCols[i].title;
|
|
1185
|
+
map.set(key, {
|
|
1186
|
+
cur: curResults?.[i],
|
|
1187
|
+
base: baseResults?.[i],
|
|
1188
|
+
diff: diffResults?.[i]
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
return map;
|
|
1192
|
+
}
|
|
1193
|
+
/** Build a ViewerRow for a column, using pre-computed CIs if available */
|
|
1194
|
+
function buildRow(col, key, ctx, cis) {
|
|
1195
|
+
const curRaw = ctx.curVals[key];
|
|
1196
|
+
const baseRaw = ctx.baseVals?.[key];
|
|
1197
|
+
if (curRaw === void 0 && baseRaw === void 0) return void 0;
|
|
1198
|
+
const format = (v) => {
|
|
1199
|
+
if (v === void 0) return "";
|
|
1200
|
+
return (col.formatter ? col.formatter(v) : String(v)) ?? "";
|
|
1201
|
+
};
|
|
1202
|
+
if (!col.comparable) {
|
|
1203
|
+
const value = format(curRaw ?? baseRaw);
|
|
1204
|
+
if (!value || value === "—") return void 0;
|
|
1205
|
+
return {
|
|
1206
|
+
label: col.title,
|
|
1207
|
+
entries: [{
|
|
1208
|
+
runName: ctx.current.name,
|
|
1209
|
+
value
|
|
1210
|
+
}],
|
|
1211
|
+
shared: true
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
const entries = [buildEntry(ctx.current.name, format(curRaw), col, cis?.cur, ctx.current.batchOffsets, ctx.currentMeta)];
|
|
1215
|
+
if (ctx.baseline && baseRaw !== void 0) {
|
|
1216
|
+
const baseEntry = buildEntry("baseline", format(baseRaw), col, cis?.base, ctx.baseline.batchOffsets, ctx.baselineMeta);
|
|
1217
|
+
entries.push(baseEntry);
|
|
1218
|
+
}
|
|
1219
|
+
return {
|
|
1220
|
+
label: col.title,
|
|
1221
|
+
entries,
|
|
1222
|
+
comparisonCI: cis?.diff
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
/** Compute difference CIs with annotation and higher-is-better flip */
|
|
1226
|
+
function buildDiffResults(cols, stats, ctx) {
|
|
1227
|
+
const { baseline, current, comparison } = ctx;
|
|
1228
|
+
if (!baseline?.samples?.length || !current.samples?.length) return void 0;
|
|
1229
|
+
const opts = {
|
|
1230
|
+
equivMargin: comparison?.equivMargin,
|
|
1231
|
+
noBatchTrim: comparison?.noBatchTrim
|
|
1232
|
+
};
|
|
1233
|
+
const rawCIs = diffCIs(baseline.samples, baseline.batchOffsets, current.samples, current.batchOffsets, stats, opts);
|
|
1234
|
+
const lowBatches = hasLowBatchCount(baseline, current);
|
|
1235
|
+
return rawCIs.map((ci, i) => {
|
|
1236
|
+
if (!ci) return void 0;
|
|
1237
|
+
const col = cols[i];
|
|
1238
|
+
return annotateCI(col.higherIsBetter ? swapDirection(flipCI(ci)) : ci, col.title, lowBatches);
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
/** Build a ViewerEntry, attaching bootstrap CI data if available */
|
|
1242
|
+
function buildEntry(runName, value, col, result, batchOffsets, metadata) {
|
|
1243
|
+
if (!result) return {
|
|
1244
|
+
runName,
|
|
1245
|
+
value
|
|
1246
|
+
};
|
|
1247
|
+
return {
|
|
1248
|
+
runName,
|
|
1249
|
+
value,
|
|
1250
|
+
bootstrapCI: formatBootstrapCI(col, result, batchOffsets, metadata)
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
/** Format a BootstrapResult into display-domain BootstrapCIData */
|
|
1254
|
+
function formatBootstrapCI(col, result, batchOffsets, metadata) {
|
|
1255
|
+
const toDisplay = col.toDisplay ? (v) => col.toDisplay(v, metadata) : (v) => v;
|
|
1256
|
+
const formatValue = (v) => (col.formatter ? col.formatter(v) : String(v)) ?? String(v);
|
|
1257
|
+
const binned = binBootstrapResult(result);
|
|
1258
|
+
const dLo = toDisplay(binned.ci[0]);
|
|
1259
|
+
const dHi = toDisplay(binned.ci[1]);
|
|
1260
|
+
const ci = dLo <= dHi ? [dLo, dHi] : [dHi, dLo];
|
|
1261
|
+
const histogram = binned.histogram.map((b) => ({
|
|
1262
|
+
x: toDisplay(b.x),
|
|
1263
|
+
count: b.count
|
|
1264
|
+
}));
|
|
1265
|
+
const ciLabels = [formatValue(ci[0]), formatValue(ci[1])];
|
|
1266
|
+
const nBatches = batchOffsets?.length ?? 0;
|
|
1267
|
+
const ciReliable = result.ciLevel === "block" && nBatches >= 20;
|
|
1268
|
+
return {
|
|
1269
|
+
estimate: toDisplay(binned.estimate),
|
|
1270
|
+
ci,
|
|
1271
|
+
histogram,
|
|
1272
|
+
ciLabels,
|
|
1273
|
+
ciLevel: result.ciLevel,
|
|
1274
|
+
ciReliable
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/report/HtmlReport.ts
|
|
1279
|
+
/** Convert benchmark results into a ReportData payload for the HTML viewer */
|
|
1280
|
+
function prepareHtmlData(groups, options) {
|
|
1281
|
+
const { cliArgs, currentVersion, baselineVersion, equivMargin, noBatchTrim } = options;
|
|
1282
|
+
const comparison = {
|
|
1283
|
+
equivMargin,
|
|
1284
|
+
noBatchTrim
|
|
1285
|
+
};
|
|
1286
|
+
const sections = options.sections ?? defaultSections$1(groups, cliArgs);
|
|
1287
|
+
return {
|
|
1288
|
+
groups: groups.map((g) => prepareGroupData(g, sections, comparison)),
|
|
1289
|
+
metadata: {
|
|
1290
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1291
|
+
bencherVersion: process.env.npm_package_version || "unknown",
|
|
1292
|
+
cliArgs,
|
|
1293
|
+
cliDefaults,
|
|
1294
|
+
gcTrackingEnabled: cliArgs?.["gc-stats"] === true,
|
|
1295
|
+
currentVersion,
|
|
1296
|
+
baselineVersion,
|
|
1297
|
+
environment: {
|
|
1298
|
+
node: process.version,
|
|
1299
|
+
platform: process.platform,
|
|
1300
|
+
arch: process.arch
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
/** Build default sections when caller doesn't provide custom ones */
|
|
1306
|
+
function defaultSections$1(groups, cliArgs) {
|
|
1307
|
+
const hasGc = cliArgs?.["gc-stats"] === true;
|
|
1308
|
+
const hasOpt = hasField(groups, "optStatus");
|
|
1309
|
+
return [
|
|
1310
|
+
buildTimeSection(typeof cliArgs?.stats === "string" ? cliArgs.stats : void 0),
|
|
1311
|
+
hasGc ? gcStatsSection : void 0,
|
|
1312
|
+
hasOpt ? optSection : void 0,
|
|
1313
|
+
runsSection
|
|
1314
|
+
].filter((s) => s !== void 0);
|
|
1315
|
+
}
|
|
1316
|
+
/** @return group data with structured ViewerSections and bootstrap CIs */
|
|
1317
|
+
function prepareGroupData(group, sections, comparison) {
|
|
1318
|
+
const base = group.baseline;
|
|
1319
|
+
const baseM = base?.measuredResults;
|
|
1320
|
+
const baseline = base ? {
|
|
1321
|
+
...prepareBenchmarkData(base),
|
|
1322
|
+
comparisonCI: void 0
|
|
1323
|
+
} : void 0;
|
|
1324
|
+
const curM = group.reports[0]?.measuredResults;
|
|
1325
|
+
const singleBatch = isSingleBatch(baseM, curM);
|
|
1326
|
+
const lowBatches = hasLowBatchCount(baseM, curM);
|
|
1327
|
+
const ctx = {
|
|
1328
|
+
baseM,
|
|
1329
|
+
baseMeta: base?.metadata,
|
|
1330
|
+
sections,
|
|
1331
|
+
comparison,
|
|
1332
|
+
lowBatches
|
|
1333
|
+
};
|
|
1334
|
+
return {
|
|
1335
|
+
name: group.name,
|
|
1336
|
+
baseline,
|
|
1337
|
+
warnings: buildWarnings(singleBatch, lowBatches),
|
|
1338
|
+
benchmarks: group.reports.map((r) => prepareReportEntry(r, ctx))
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
/** @return benchmark data with samples, stats, and profiling summaries */
|
|
1342
|
+
function prepareBenchmarkData(report) {
|
|
1343
|
+
const { measuredResults: m, name } = report;
|
|
1344
|
+
return {
|
|
1345
|
+
name,
|
|
1346
|
+
samples: m.samples,
|
|
1347
|
+
warmupSamples: m.warmupSamples,
|
|
1348
|
+
allocationSamples: m.allocationSamples,
|
|
1349
|
+
heapSamples: m.heapSamples,
|
|
1350
|
+
gcEvents: m.nodeGcTime?.events,
|
|
1351
|
+
optSamples: m.optSamples,
|
|
1352
|
+
pausePoints: m.pausePoints,
|
|
1353
|
+
batchOffsets: m.batchOffsets,
|
|
1354
|
+
stats: m.time,
|
|
1355
|
+
heapSize: m.heapSize,
|
|
1356
|
+
totalTime: m.totalTime,
|
|
1357
|
+
heapSummary: m.heapProfile ? summarizeHeap(m.heapProfile) : void 0,
|
|
1358
|
+
coverageSummary: m.coverage ? summarizeCoverage(m.coverage) : void 0
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
function buildWarnings(singleBatch, lowBatches) {
|
|
1362
|
+
const parts = [];
|
|
1363
|
+
const singleMsg = "Confidence intervals may be too narrow (single batch). Use --batches for more accurate intervals.";
|
|
1364
|
+
if (singleBatch) parts.push(singleMsg);
|
|
1365
|
+
if (lowBatches) parts.push(`Too few batches for reliable comparison (need 20+).`);
|
|
1366
|
+
return parts.length ? parts : void 0;
|
|
1367
|
+
}
|
|
1368
|
+
/** @return a single benchmark entry with sections and comparison CI */
|
|
1369
|
+
function prepareReportEntry(report, ctx) {
|
|
1370
|
+
const sectionCtx = {
|
|
1371
|
+
current: report.measuredResults,
|
|
1372
|
+
baseline: ctx.baseM,
|
|
1373
|
+
currentMeta: report.metadata,
|
|
1374
|
+
baselineMeta: ctx.baseMeta,
|
|
1375
|
+
comparison: ctx.comparison
|
|
1376
|
+
};
|
|
1377
|
+
const sections = ctx.sections ? buildViewerSections(ctx.sections, sectionCtx) : void 0;
|
|
1378
|
+
const comparisonCI = findPrimarySectionCI(sections);
|
|
1379
|
+
return {
|
|
1380
|
+
...prepareBenchmarkData(report),
|
|
1381
|
+
sections,
|
|
1382
|
+
comparisonCI
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
/** Compute heap allocation summary from profile */
|
|
1386
|
+
function summarizeHeap(profile) {
|
|
1387
|
+
const resolved = resolveProfile(profile);
|
|
1388
|
+
const userSites = filterSites(flattenProfile(resolved));
|
|
1389
|
+
return {
|
|
1390
|
+
totalBytes: resolved.totalBytes,
|
|
1391
|
+
userBytes: totalBytes(userSites)
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
/** Compute coverage summary from V8 coverage data */
|
|
1395
|
+
function summarizeCoverage(coverage) {
|
|
1396
|
+
const called = coverage.scripts.flatMap((s) => s.functions).filter((fn) => fn.ranges.length > 0 && fn.ranges[0].count > 0);
|
|
1397
|
+
const totalCalls = called.reduce((sum, fn) => sum + fn.ranges[0].count, 0);
|
|
1398
|
+
return {
|
|
1399
|
+
functionCount: called.length,
|
|
1400
|
+
totalCalls
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
/** Extract the comparison CI from the first primary row across all sections */
|
|
1404
|
+
function findPrimarySectionCI(sections) {
|
|
1405
|
+
if (!sections) return void 0;
|
|
1406
|
+
for (const section of sections) for (const row of section.rows) if (row.primary && row.comparisonCI) return row.comparisonCI;
|
|
1407
|
+
}
|
|
1408
|
+
//#endregion
|
|
1409
|
+
//#region src/cli/CliOptions.ts
|
|
1410
|
+
/** Convert CLI args to matrix runner options. */
|
|
1411
|
+
function cliToMatrixOptions(args) {
|
|
1412
|
+
const { iterations, worker, batches } = args;
|
|
1413
|
+
const { maxTime } = resolveLimits(args);
|
|
1414
|
+
return {
|
|
1415
|
+
iterations,
|
|
1416
|
+
maxTime,
|
|
1417
|
+
useWorker: worker,
|
|
1418
|
+
batches,
|
|
1419
|
+
warmupBatch: args["warmup-batch"],
|
|
1420
|
+
...cliCommonOptions(args)
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
/** Validate CLI argument combinations. */
|
|
1424
|
+
function validateArgs(args) {
|
|
1425
|
+
if (args["gc-stats"] && !args.worker && !args.url) throw new Error("--gc-stats requires worker mode (the default). Remove --no-worker flag.");
|
|
1426
|
+
if (args.stats) buildTimeSection(args.stats);
|
|
1427
|
+
}
|
|
1428
|
+
/** Convert CLI args to benchmark runner options. */
|
|
1429
|
+
function cliToRunnerOptions(args) {
|
|
1430
|
+
const { inspect, iterations, adaptive } = args;
|
|
1431
|
+
const gcForce = args["gc-force"];
|
|
1432
|
+
if (inspect) return {
|
|
1433
|
+
maxIterations: iterations ?? 1,
|
|
1434
|
+
warmupTime: 0,
|
|
1435
|
+
gcForce
|
|
1436
|
+
};
|
|
1437
|
+
if (adaptive) return createAdaptiveOptions(args);
|
|
1438
|
+
return {
|
|
1439
|
+
...resolveLimits(args),
|
|
1440
|
+
...cliCommonOptions(args)
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
/** Convert CLI args to heap report display options. */
|
|
1444
|
+
function cliHeapReportOptions(args) {
|
|
1445
|
+
return {
|
|
1446
|
+
topN: args["alloc-rows"],
|
|
1447
|
+
stackDepth: args["alloc-stack"],
|
|
1448
|
+
verbose: args["alloc-verbose"],
|
|
1449
|
+
raw: args["alloc-raw"],
|
|
1450
|
+
userOnly: args["alloc-user-only"]
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
/** True if any alloc-related flag implies allocation sampling. */
|
|
1454
|
+
function needsAlloc(args) {
|
|
1455
|
+
return args.alloc || args.archive != null || args["alloc-raw"] || args["alloc-verbose"] || args["alloc-user-only"];
|
|
1456
|
+
}
|
|
1457
|
+
/** True if any profiling flag implies CPU time sampling. */
|
|
1458
|
+
function needsProfile(args) {
|
|
1459
|
+
return args.profile || !!args["export-profile"];
|
|
1460
|
+
}
|
|
1461
|
+
/** Extract baseline comparison options from CLI args. */
|
|
1462
|
+
function cliComparisonOptions(args) {
|
|
1463
|
+
return {
|
|
1464
|
+
equivMargin: args["equiv-margin"],
|
|
1465
|
+
noBatchTrim: args["no-batch-trim"]
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
function resolveLimits(args) {
|
|
1469
|
+
const { duration, iterations } = args;
|
|
1470
|
+
if (duration == null && iterations == null) return {
|
|
1471
|
+
maxTime: defaultDuration * 1e3,
|
|
1472
|
+
maxIterations: void 0
|
|
1473
|
+
};
|
|
1474
|
+
return {
|
|
1475
|
+
maxTime: duration != null ? duration * 1e3 : void 0,
|
|
1476
|
+
maxIterations: iterations
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
/** Runner/matrix options shared across all CLI modes. */
|
|
1480
|
+
function cliCommonOptions(args) {
|
|
1481
|
+
const { warmup } = args;
|
|
1482
|
+
const { "gc-force": gcForce, "gc-stats": gcStats } = args;
|
|
1483
|
+
const { "trace-opt": traceOpt, "call-counts": callCounts } = args;
|
|
1484
|
+
const { "pause-warmup": pauseWarmup, "pause-first": pauseFirst } = args;
|
|
1485
|
+
const { "pause-interval": pauseInterval, "pause-duration": pauseDuration } = args;
|
|
1486
|
+
const { "alloc-interval": allocInterval, "alloc-depth": allocDepth } = args;
|
|
1487
|
+
const { "profile-interval": profileInterval } = args;
|
|
1488
|
+
return {
|
|
1489
|
+
gcForce,
|
|
1490
|
+
warmup,
|
|
1491
|
+
traceOpt,
|
|
1492
|
+
gcStats,
|
|
1493
|
+
callCounts,
|
|
1494
|
+
pauseWarmup,
|
|
1495
|
+
pauseFirst,
|
|
1496
|
+
pauseInterval,
|
|
1497
|
+
pauseDuration,
|
|
1498
|
+
alloc: needsAlloc(args),
|
|
1499
|
+
allocInterval,
|
|
1500
|
+
allocDepth,
|
|
1501
|
+
profile: needsProfile(args),
|
|
1502
|
+
profileInterval
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
/** Build runner options for adaptive sampling mode. */
|
|
1506
|
+
function createAdaptiveOptions(args) {
|
|
1507
|
+
return {
|
|
1508
|
+
minTime: (args["min-time"] ?? 1) * 1e3,
|
|
1509
|
+
maxTime: 20 * 1e3,
|
|
1510
|
+
targetConfidence: args.convergence,
|
|
1511
|
+
adaptive: true,
|
|
1512
|
+
...cliCommonOptions(args)
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
//#endregion
|
|
1516
|
+
//#region src/report/text/TableReport.ts
|
|
1517
|
+
const { bold } = colors;
|
|
1518
|
+
const ansiEscapeRegex = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
|
|
1519
|
+
/** Build formatted table with column groups and baseline diffs. */
|
|
1520
|
+
function buildTable(columnGroups, resultGroups, nameKey = "name") {
|
|
1521
|
+
return createTable(columnGroups, flattenGroups(columnGroups, resultGroups, nameKey));
|
|
1522
|
+
}
|
|
1523
|
+
/** Convert records to string arrays for table rendering. */
|
|
1524
|
+
function toRows(records, groups) {
|
|
1525
|
+
const allColumns = groups.flatMap((group) => group.columns);
|
|
1526
|
+
return records.map((record) => allColumns.map((col) => {
|
|
1527
|
+
const value = record[col.key];
|
|
1528
|
+
return col.formatter ? col.formatter(value) : value;
|
|
1529
|
+
})).map((row) => row.map((cell) => cell ?? " "));
|
|
1530
|
+
}
|
|
1531
|
+
/** Flatten result groups into a single array, inserting blank separator rows. */
|
|
1532
|
+
function flattenGroups(groups, resultGroups, nameKey) {
|
|
1533
|
+
return resultGroups.flatMap((group, i) => {
|
|
1534
|
+
const records = addBaseline(groups, group, nameKey);
|
|
1535
|
+
return i === resultGroups.length - 1 ? records : [...records, {}];
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
/** Render column groups and records into a formatted table string. */
|
|
1539
|
+
function createTable(groups, records) {
|
|
1540
|
+
const dataRows = toRows(records, groups);
|
|
1541
|
+
const { headerRows, config } = buildTableConfig(groups, dataRows);
|
|
1542
|
+
return table([...headerRows, ...dataRows], config);
|
|
1543
|
+
}
|
|
1544
|
+
/** Append baseline row and inject diff values into result rows. */
|
|
1545
|
+
function addBaseline(groups, group, nameKey) {
|
|
1546
|
+
const { results, baseline } = group;
|
|
1547
|
+
if (!baseline) return results;
|
|
1548
|
+
const diffResults = results.map((r) => addComparisons(groups, r, baseline));
|
|
1549
|
+
const marked = {
|
|
1550
|
+
...baseline,
|
|
1551
|
+
[nameKey]: `--> ${baseline[nameKey]}`
|
|
1552
|
+
};
|
|
1553
|
+
return [...diffResults, marked];
|
|
1554
|
+
}
|
|
1555
|
+
/** Build header rows, spanning cells, column widths, and border rules. */
|
|
1556
|
+
function buildTableConfig(groups, dataRows) {
|
|
1557
|
+
const titles = getTitles(groups);
|
|
1558
|
+
return {
|
|
1559
|
+
headerRows: [...createGroupHeaders(groups, titles.length), titles],
|
|
1560
|
+
config: {
|
|
1561
|
+
spanningCells: createSectionSpans(groups),
|
|
1562
|
+
columns: calcColumnWidths(groups, titles, dataRows),
|
|
1563
|
+
...createLines(groups)
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
/** Compute formatted diff values by comparing a row against baseline. */
|
|
1568
|
+
function addComparisons(groups, main, baseline) {
|
|
1569
|
+
const cols = groups.flatMap((g) => g.columns).filter((col) => col.diffKey !== void 0);
|
|
1570
|
+
const diffs = Object.fromEntries(cols.map((col) => {
|
|
1571
|
+
const fmt = col.diffFormatter ?? diffPercent;
|
|
1572
|
+
return [col.key, fmt(main[col.diffKey], baseline[col.diffKey])];
|
|
1573
|
+
}));
|
|
1574
|
+
return {
|
|
1575
|
+
...main,
|
|
1576
|
+
...diffs
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
/** @return bolded column title strings */
|
|
1580
|
+
function getTitles(groups) {
|
|
1581
|
+
return groups.flatMap((g) => g.columns.map((c) => bold(c.title || " ")));
|
|
1582
|
+
}
|
|
1583
|
+
/** @return header rows with group titles, or empty if no groups have titles. */
|
|
1584
|
+
function createGroupHeaders(groups, numColumns) {
|
|
1585
|
+
if (!groups.some((g) => g.groupTitle)) return [];
|
|
1586
|
+
return [groups.flatMap((g) => {
|
|
1587
|
+
return padWithBlanks(g.groupTitle ? [bold(g.groupTitle)] : [], g.columns.length);
|
|
1588
|
+
}), padWithBlanks([], numColumns)];
|
|
1589
|
+
}
|
|
1590
|
+
/** @return spanning cell configs for group title headers */
|
|
1591
|
+
function createSectionSpans(groups) {
|
|
1592
|
+
const offsets = groupOffsets(groups);
|
|
1593
|
+
return groups.map((g, i) => ({
|
|
1594
|
+
row: 0,
|
|
1595
|
+
col: offsets[i],
|
|
1596
|
+
colSpan: g.columns.length,
|
|
1597
|
+
alignment: "center"
|
|
1598
|
+
}));
|
|
1599
|
+
}
|
|
1600
|
+
/** Calculate column widths based on content, widening to fit group titles. */
|
|
1601
|
+
function calcColumnWidths(groups, titles, dataRows) {
|
|
1602
|
+
const maxData = (i) => dataRows.reduce((m, row) => Math.max(m, cellWidth(row[i])), 0);
|
|
1603
|
+
const widths = titles.map((t, i) => Math.max(cellWidth(t), maxData(i)));
|
|
1604
|
+
const offsets = groupOffsets(groups);
|
|
1605
|
+
for (const [i, group] of groups.entries()) {
|
|
1606
|
+
const titleWidth = cellWidth(group.groupTitle);
|
|
1607
|
+
if (titleWidth <= 0) continue;
|
|
1608
|
+
const col = offsets[i];
|
|
1609
|
+
const n = group.columns.length;
|
|
1610
|
+
const sepWidth = (n - 1) * 3;
|
|
1611
|
+
const needed = titleWidth - widths.slice(col, col + n).reduce((a, b) => a + b, 0) - sepWidth;
|
|
1612
|
+
if (needed > 0) widths[col + n - 1] += needed;
|
|
1613
|
+
}
|
|
1614
|
+
return Object.fromEntries(widths.map((w, i) => [i, {
|
|
1615
|
+
width: w,
|
|
1616
|
+
wrapWord: false
|
|
1617
|
+
}]));
|
|
1618
|
+
}
|
|
1619
|
+
/** @return draw functions for horizontal/vertical table borders */
|
|
1620
|
+
function createLines(groups) {
|
|
1621
|
+
const { sectionBorders, headerBottom } = calcBorders(groups);
|
|
1622
|
+
return {
|
|
1623
|
+
drawVerticalLine: (i, size) => i === 0 || i === size || sectionBorders.includes(i),
|
|
1624
|
+
drawHorizontalLine: (i, size) => i === 0 || i === size || i === headerBottom
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
/** @return array padded with blank strings to the given length */
|
|
1628
|
+
function padWithBlanks(arr, length) {
|
|
1629
|
+
if (arr.length >= length) return arr;
|
|
1630
|
+
return [...arr, ...Array(length - arr.length).fill(" ")];
|
|
1631
|
+
}
|
|
1632
|
+
/** @return cumulative column offsets for each group boundary */
|
|
1633
|
+
function groupOffsets(groups) {
|
|
1634
|
+
let offset = 0;
|
|
1635
|
+
return groups.map((g) => {
|
|
1636
|
+
const start = offset;
|
|
1637
|
+
offset += g.columns.length;
|
|
1638
|
+
return start;
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
/** @return visible length of a cell value, stripping ANSI escape codes. */
|
|
1642
|
+
function cellWidth(value) {
|
|
1643
|
+
if (value == null) return 0;
|
|
1644
|
+
return String(value).replace(ansiEscapeRegex, "").length;
|
|
1645
|
+
}
|
|
1646
|
+
/** @return vertical line positions between sections and header bottom row. */
|
|
1647
|
+
function calcBorders(groups) {
|
|
1648
|
+
return {
|
|
1649
|
+
sectionBorders: groupOffsets(groups).map((o, i) => o + groups[i].columns.length),
|
|
1650
|
+
headerBottom: groups.length === 0 ? 1 : 3
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
//#endregion
|
|
1654
|
+
//#region src/report/text/TextReport.ts
|
|
1655
|
+
/** Build a formatted text table from benchmark groups, with baseline diff columns when present. */
|
|
1656
|
+
function reportResults(groups, sections, options) {
|
|
1657
|
+
const primary = findPrimaryColumn(sections);
|
|
1658
|
+
const results = groups.map((g) => resultGroupValues(g, sections, primary, options));
|
|
1659
|
+
const table = buildTable(sectionColumnGroups(sections, results.some((g) => g.baseline)), results);
|
|
1660
|
+
if (!results.some((g) => g.results.some((r) => r.diffCI && r.diffCI.ciLevel === "sample"))) return table;
|
|
1661
|
+
return table + "\n* Confidence intervals may be too narrow (single batch). Use --batches for more accurate intervals.\n";
|
|
1662
|
+
}
|
|
1663
|
+
/** Extract stats from all sections into row objects for each report. */
|
|
1664
|
+
function valuesForReports(reports, sections) {
|
|
1665
|
+
return reports.map((r) => ({
|
|
1666
|
+
name: truncate(r.name),
|
|
1667
|
+
...extractSectionValues(r.measuredResults, sections, r.metadata)
|
|
1668
|
+
}));
|
|
1669
|
+
}
|
|
1670
|
+
/** Insert a "delta% CI" column after the first comparable column. */
|
|
1671
|
+
function injectDiffColumns(groups) {
|
|
1672
|
+
const higher = isHigherIsBetter(groups.map((g) => ({
|
|
1673
|
+
title: g.groupTitle ?? "",
|
|
1674
|
+
columns: g.columns
|
|
1675
|
+
})));
|
|
1676
|
+
const fmt = (v) => formatDiffWithCI(v, higher);
|
|
1677
|
+
const ciCol = {
|
|
1678
|
+
title: "Δ% CI",
|
|
1679
|
+
key: "diffCI",
|
|
1680
|
+
formatter: fmt
|
|
1681
|
+
};
|
|
1682
|
+
let ciAdded = false;
|
|
1683
|
+
return groups.map((group) => ({
|
|
1684
|
+
groupTitle: group.groupTitle,
|
|
1685
|
+
columns: group.columns.flatMap((col) => {
|
|
1686
|
+
if (col.comparable && !ciAdded) {
|
|
1687
|
+
ciAdded = true;
|
|
1688
|
+
return [col, ciCol];
|
|
1689
|
+
}
|
|
1690
|
+
return [col];
|
|
1691
|
+
})
|
|
1692
|
+
}));
|
|
1693
|
+
}
|
|
1694
|
+
/** Build table columns from sections, with name column and optional CI diff columns. */
|
|
1695
|
+
function sectionColumnGroups(sections, hasBaseline, nameTitle = "name") {
|
|
1696
|
+
const nameCol = { columns: [{
|
|
1697
|
+
key: "name",
|
|
1698
|
+
title: nameTitle
|
|
1699
|
+
}] };
|
|
1700
|
+
const groups = sections.map((s) => ({
|
|
1701
|
+
groupTitle: s.title || void 0,
|
|
1702
|
+
columns: s.columns.map((c) => ({
|
|
1703
|
+
...c,
|
|
1704
|
+
key: c.key ?? c.title
|
|
1705
|
+
}))
|
|
1706
|
+
}));
|
|
1707
|
+
return [nameCol, ...hasBaseline ? injectDiffColumns(groups) : groups];
|
|
1708
|
+
}
|
|
1709
|
+
/** Extract section stats and bootstrap CI diffs for all reports in a group. */
|
|
1710
|
+
function resultGroupValues(group, sections, primary, options) {
|
|
1711
|
+
const { reports, baseline } = group;
|
|
1712
|
+
const baseM = baseline?.measuredResults;
|
|
1713
|
+
const { statKind, higherIsBetter } = primary ?? {};
|
|
1714
|
+
return {
|
|
1715
|
+
results: reports.map((r) => {
|
|
1716
|
+
const { measuredResults: m, metadata } = r;
|
|
1717
|
+
const diffCI = statKind ? computeDiffCI(baseM, m, statKind, options, higherIsBetter) : void 0;
|
|
1718
|
+
const values = extractSectionValues(m, sections, metadata);
|
|
1719
|
+
return {
|
|
1720
|
+
name: truncate(r.name),
|
|
1721
|
+
...values,
|
|
1722
|
+
...diffCI && { diffCI }
|
|
1723
|
+
};
|
|
1724
|
+
}),
|
|
1725
|
+
baseline: baseline && valuesForReports([baseline], sections)[0]
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
//#endregion
|
|
1729
|
+
//#region src/matrix/MatrixReport.ts
|
|
1730
|
+
const defaultSections = [timeSection, runsSection];
|
|
1731
|
+
/** Format matrix results as text, with one table per case */
|
|
1732
|
+
function reportMatrixResults(results, options) {
|
|
1733
|
+
if (results.variants.length === 0) return `Matrix: ${results.name}`;
|
|
1734
|
+
const tables = results.variants[0].cases.map((c) => c.caseId).map((caseId) => buildCaseTable(results, caseId, options));
|
|
1735
|
+
return [`Matrix: ${results.name}`, ...tables].join("\n\n");
|
|
1736
|
+
}
|
|
1737
|
+
/** Build table for a single case showing all variants */
|
|
1738
|
+
function buildCaseTable(results, caseId, options) {
|
|
1739
|
+
const title = formatCaseTitle(results, caseId);
|
|
1740
|
+
const sections = options?.sections ?? defaultSections;
|
|
1741
|
+
const variantTitle = options?.variantTitle ?? "variant";
|
|
1742
|
+
const primaryCol = findPrimaryColumn(sections);
|
|
1743
|
+
const caseResults = collectCaseResults(results, caseId);
|
|
1744
|
+
const shared = sharedBaseline(caseResults);
|
|
1745
|
+
const rows = caseResults.flatMap(({ variant, cr }) => {
|
|
1746
|
+
const vals = extractSectionValues(cr.measured, sections, cr.metadata);
|
|
1747
|
+
const row = {
|
|
1748
|
+
name: truncate(variant.id, 25),
|
|
1749
|
+
...vals
|
|
1750
|
+
};
|
|
1751
|
+
if (cr.baseline && primaryCol?.statKind) {
|
|
1752
|
+
const { statKind, higherIsBetter } = primaryCol;
|
|
1753
|
+
row.diffCI = computeDiffCI(cr.baseline, cr.measured, statKind, options?.comparison, higherIsBetter);
|
|
1754
|
+
}
|
|
1755
|
+
const out = [row];
|
|
1756
|
+
if (cr.baseline && !shared) out.push({
|
|
1757
|
+
name: " ↳ baseline",
|
|
1758
|
+
...extractSectionValues(cr.baseline, sections, cr.metadata)
|
|
1759
|
+
});
|
|
1760
|
+
return out;
|
|
1761
|
+
});
|
|
1762
|
+
if (shared) rows.push({
|
|
1763
|
+
name: "=> baseline",
|
|
1764
|
+
...extractSectionValues(shared, sections)
|
|
1765
|
+
});
|
|
1766
|
+
return `${title}\n${buildTable(sectionColumnGroups(sections, rows.some((r) => r.diffCI), variantTitle), [{ results: rows }])}`;
|
|
1767
|
+
}
|
|
1768
|
+
/** Format case title with metadata if available */
|
|
1769
|
+
function formatCaseTitle(results, caseId) {
|
|
1770
|
+
const metadata = (results.variants[0]?.cases.find((c) => c.caseId === caseId))?.metadata;
|
|
1771
|
+
if (!metadata || Object.keys(metadata).length === 0) return caseId;
|
|
1772
|
+
return `${caseId} (${Object.entries(metadata).map(([k, v]) => `${v} ${k}`).join(", ")})`;
|
|
1773
|
+
}
|
|
1774
|
+
/** Collect (variant, caseResult) pairs for a given caseId */
|
|
1775
|
+
function collectCaseResults(results, caseId) {
|
|
1776
|
+
return results.variants.flatMap((variant) => {
|
|
1777
|
+
const cr = variant.cases.find((c) => c.caseId === caseId);
|
|
1778
|
+
return cr ? [{
|
|
1779
|
+
variant,
|
|
1780
|
+
cr
|
|
1781
|
+
}] : [];
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
/** @return shared baseline if all variants reference the same one (baselineVariant mode) */
|
|
1785
|
+
function sharedBaseline(caseResults) {
|
|
1786
|
+
const baselines = caseResults.map(({ cr }) => cr.baseline).filter(Boolean);
|
|
1787
|
+
if (baselines.length < 2) return void 0;
|
|
1788
|
+
return baselines.every((b) => b === baselines[0]) ? baselines[0] : void 0;
|
|
1789
|
+
}
|
|
1790
|
+
//#endregion
|
|
1791
|
+
//#region src/cli/CliReport.ts
|
|
1792
|
+
const { yellow: yellow$1, dim } = colors;
|
|
1793
|
+
/** Show a transient status message on stderr, run a sync computation, then clear. */
|
|
1794
|
+
function withStatus(msg, fn) {
|
|
1795
|
+
process.stderr.write(`◊ ${msg}...\r`);
|
|
1796
|
+
const result = fn();
|
|
1797
|
+
process.stderr.write("\r" + " ".repeat(40) + "\r");
|
|
1798
|
+
return result;
|
|
1799
|
+
}
|
|
1800
|
+
/** Generate text report table with standard sections based on CLI args. */
|
|
1801
|
+
function defaultReport(groups, args, opts) {
|
|
1802
|
+
return reportResults(groups, opts?.sections?.length ? opts.sections : cliDefaultSections(groups, args), cliComparisonOptions(args));
|
|
1803
|
+
}
|
|
1804
|
+
/** Log V8 optimization tier distribution and deoptimizations. */
|
|
1805
|
+
function reportOptStatus(groups) {
|
|
1806
|
+
const optData = groups.flatMap((group) => groupReports(group).filter((r) => r.measuredResults.optStatus).map(({ name, measuredResults: m }) => ({
|
|
1807
|
+
name,
|
|
1808
|
+
opt: m.optStatus,
|
|
1809
|
+
samples: m.samples.length
|
|
1810
|
+
})));
|
|
1811
|
+
if (optData.length === 0) return;
|
|
1812
|
+
console.log(dim("\nV8 optimization:"));
|
|
1813
|
+
for (const { name, opt, samples } of optData) {
|
|
1814
|
+
const tierParts = formatTierSummary(opt, " ", ", ");
|
|
1815
|
+
console.log(` ${name}: ${tierParts} ${dim(`(${samples} samples)`)}`);
|
|
1816
|
+
}
|
|
1817
|
+
const totalDeopts = optData.reduce((sum, d) => sum + d.opt.deoptCount, 0);
|
|
1818
|
+
if (totalDeopts > 0) console.log(yellow$1(` ⚠ ${totalDeopts} deoptimization${totalDeopts > 1 ? "s" : ""} detected`));
|
|
1819
|
+
}
|
|
1820
|
+
/** Print heap allocation profiles for each benchmark in the report groups. */
|
|
1821
|
+
function printHeapReports(groups, options) {
|
|
1822
|
+
for (const report of groups.flatMap((g) => groupReports(g))) {
|
|
1823
|
+
const { heapProfile } = report.measuredResults;
|
|
1824
|
+
if (!heapProfile) continue;
|
|
1825
|
+
console.log(dim(`\n─── Heap profile: ${report.name} ───`));
|
|
1826
|
+
const resolved = resolveProfile(heapProfile);
|
|
1827
|
+
const sites = flattenProfile(resolved);
|
|
1828
|
+
const userSites = filterSites(sites, options.isUserCode);
|
|
1829
|
+
const agg = aggregateSites(options.userOnly ? userSites : sites);
|
|
1830
|
+
const { totalBytes, sortedSamples } = resolved;
|
|
1831
|
+
const totalUserCode = userSites.reduce((sum, s) => sum + s.bytes, 0);
|
|
1832
|
+
const sampleCount = sortedSamples?.length;
|
|
1833
|
+
const heapOpts = {
|
|
1834
|
+
...options,
|
|
1835
|
+
totalAll: totalBytes,
|
|
1836
|
+
totalUserCode,
|
|
1837
|
+
sampleCount
|
|
1838
|
+
};
|
|
1839
|
+
console.log(formatHeapReport(agg, heapOpts));
|
|
1840
|
+
if (options.raw) {
|
|
1841
|
+
console.log(dim(`\n─── Raw samples: ${report.name} ───`));
|
|
1842
|
+
console.log(formatRawSamples(resolved));
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
/** Format matrix benchmark results as text, applying default sections from CLI args. */
|
|
1847
|
+
function defaultMatrixReport(results, reportOptions, args) {
|
|
1848
|
+
const options = args ? mergeMatrixDefaults(reportOptions, args, results) : reportOptions;
|
|
1849
|
+
return results.map((r) => reportMatrixResults(r, options)).join("\n\n");
|
|
1850
|
+
}
|
|
1851
|
+
/** Convert MatrixResults to ReportGroup[] for the standard export pipeline. */
|
|
1852
|
+
function matrixToReportGroups(results) {
|
|
1853
|
+
return results.flatMap((matrix) => matrix.variants.flatMap((variant) => variant.cases.map((c) => caseToReportGroup(variant.id, c))));
|
|
1854
|
+
}
|
|
1855
|
+
/** Assemble report sections from CLI flags. Under --adaptive, the
|
|
1856
|
+
* adaptive section provides its own time columns and `stats` is ignored. */
|
|
1857
|
+
function buildReportSections(adaptive, gcStats, hasOptData, stats) {
|
|
1858
|
+
return [
|
|
1859
|
+
...adaptive ? [...adaptiveSections, totalTimeSection] : [buildTimeSection(stats)],
|
|
1860
|
+
...gcStats ? [gcStatsSection] : [],
|
|
1861
|
+
...hasOptData ? [optSection] : [],
|
|
1862
|
+
runsSection
|
|
1863
|
+
];
|
|
1864
|
+
}
|
|
1865
|
+
/** Build sections from CLI feature flags (time/gc/opt/runs). */
|
|
1866
|
+
function cliDefaultSections(groups, args) {
|
|
1867
|
+
const { adaptive, "gc-stats": gcStats, "trace-opt": traceOpt, stats } = args;
|
|
1868
|
+
const hasOpt = hasField(groups, "optStatus");
|
|
1869
|
+
return buildReportSections(adaptive, gcStats, traceOpt && hasOpt, stats);
|
|
1870
|
+
}
|
|
1871
|
+
/** Apply default sections and extra columns for matrix reports. */
|
|
1872
|
+
function mergeMatrixDefaults(opts, args, results) {
|
|
1873
|
+
const merged = { ...opts };
|
|
1874
|
+
if (!merged.sections?.length) {
|
|
1875
|
+
const groups = matrixToReportGroups(results);
|
|
1876
|
+
const hasOpt = args["trace-opt"] && hasField(groups, "optStatus");
|
|
1877
|
+
merged.sections = buildReportSections(args.adaptive, args["gc-stats"], hasOpt, args.stats);
|
|
1878
|
+
}
|
|
1879
|
+
if (!merged.comparison) merged.comparison = cliComparisonOptions(args);
|
|
1880
|
+
return merged;
|
|
1881
|
+
}
|
|
1882
|
+
/** Wrap a single matrix case and its optional baseline into a ReportGroup. */
|
|
1883
|
+
function caseToReportGroup(variantId, c) {
|
|
1884
|
+
const { metadata, baseline: baselineMeasured } = c;
|
|
1885
|
+
const report = {
|
|
1886
|
+
name: variantId,
|
|
1887
|
+
measuredResults: c.measured,
|
|
1888
|
+
metadata
|
|
1889
|
+
};
|
|
1890
|
+
const baseline = baselineMeasured ? {
|
|
1891
|
+
name: `${variantId} (baseline)`,
|
|
1892
|
+
measuredResults: baselineMeasured,
|
|
1893
|
+
metadata
|
|
1894
|
+
} : void 0;
|
|
1895
|
+
return {
|
|
1896
|
+
name: `${variantId} / ${c.caseId}`,
|
|
1897
|
+
reports: [report],
|
|
1898
|
+
baseline
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
//#endregion
|
|
1902
|
+
//#region src/cli/CliExport.ts
|
|
1903
|
+
/** Export reports (JSON, Perfetto, archive, viewer) based on CLI args. */
|
|
1904
|
+
async function exportReports(options) {
|
|
1905
|
+
const { results, args, sections, currentVersion, baselineVersion } = options;
|
|
1906
|
+
const wantViewer = args.view || args["view-serve"] || args.archive != null;
|
|
1907
|
+
const htmlOpts = {
|
|
1908
|
+
cliArgs: args,
|
|
1909
|
+
sections,
|
|
1910
|
+
currentVersion,
|
|
1911
|
+
baselineVersion,
|
|
1912
|
+
...cliComparisonOptions(args)
|
|
1913
|
+
};
|
|
1914
|
+
const reportData = wantViewer ? withStatus("computing viewer data", () => prepareHtmlData(results, htmlOpts)) : void 0;
|
|
1915
|
+
exportFileFormats(results, args);
|
|
1916
|
+
const profileFile = buildSpeedscopeFile(results);
|
|
1917
|
+
const timeFile = buildAllTimeProfiles(results);
|
|
1918
|
+
const coverageData = await annotateCoverage(results, profileFile, timeFile);
|
|
1919
|
+
const timeData = timeFile ? JSON.stringify(timeFile) : void 0;
|
|
1920
|
+
if (args.archive != null) await archiveBenchmark({
|
|
1921
|
+
groups: results,
|
|
1922
|
+
reportData,
|
|
1923
|
+
timeProfileData: timeData,
|
|
1924
|
+
coverageData,
|
|
1925
|
+
outputPath: args.archive || void 0
|
|
1926
|
+
});
|
|
1927
|
+
if (args.view || args["view-serve"]) await openViewer(profileFile, timeData, coverageData, reportData, args);
|
|
1928
|
+
}
|
|
1929
|
+
/** Print heap reports (if enabled) and export results. */
|
|
1930
|
+
async function finishReports(results, args, exportOptions) {
|
|
1931
|
+
if (needsAlloc(args)) printHeapReports(results, cliHeapReportOptions(args));
|
|
1932
|
+
await exportReports({
|
|
1933
|
+
results,
|
|
1934
|
+
args,
|
|
1935
|
+
...exportOptions
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
/** Write Perfetto and time profile files if requested by CLI args. */
|
|
1939
|
+
function exportFileFormats(results, args) {
|
|
1940
|
+
if (args["export-perfetto"]) exportPerfettoTrace(results, args["export-perfetto"], args);
|
|
1941
|
+
if (args["export-profile"]) exportTimeProfile(results, args["export-profile"]);
|
|
1942
|
+
}
|
|
1943
|
+
/** Build combined Speedscope file from all time profiles in results. */
|
|
1944
|
+
function buildAllTimeProfiles(results) {
|
|
1945
|
+
return buildTimeSpeedscopeFile(results.flatMap((group) => groupReports(group).filter((r) => r.measuredResults.timeProfile).map((r) => ({
|
|
1946
|
+
name: r.name,
|
|
1947
|
+
profile: r.measuredResults.timeProfile
|
|
1948
|
+
}))));
|
|
1949
|
+
}
|
|
1950
|
+
/** Annotate speedscope frame names with coverage counts. Returns serialized coverage map. */
|
|
1951
|
+
async function annotateCoverage(results, profileFile, timeFile) {
|
|
1952
|
+
const coverage = mergeCoverage(results);
|
|
1953
|
+
if (!coverage) return void 0;
|
|
1954
|
+
const covMap = buildCoverageMap(coverage, await collectSources(coverage.scripts.map((s) => ({ file: s.url }))));
|
|
1955
|
+
if (profileFile) annotateFramesWithCounts(profileFile.shared.frames, covMap);
|
|
1956
|
+
if (timeFile) annotateFramesWithCounts(timeFile.shared.frames, covMap);
|
|
1957
|
+
return JSON.stringify(Object.fromEntries(covMap.map));
|
|
1958
|
+
}
|
|
1959
|
+
/** Start viewer server with profile data and block until Ctrl+C. */
|
|
1960
|
+
async function openViewer(profileFile, timeData, coverageData, reportData, args) {
|
|
1961
|
+
const viewer = await startViewerServer({
|
|
1962
|
+
profileData: optionalJson(profileFile),
|
|
1963
|
+
timeProfileData: timeData,
|
|
1964
|
+
coverageData,
|
|
1965
|
+
reportData: optionalJson(reportData),
|
|
1966
|
+
editorUri: resolveEditorUri(args.editor),
|
|
1967
|
+
open: !args["view-serve"]
|
|
1968
|
+
});
|
|
1969
|
+
await waitForCtrlC();
|
|
1970
|
+
viewer.close();
|
|
1971
|
+
}
|
|
1972
|
+
/** Export the first raw V8 TimeProfile to a JSON file. */
|
|
1973
|
+
function exportTimeProfile(results, path) {
|
|
1974
|
+
const profile = results.flatMap((g) => groupReports(g)).find((r) => r.measuredResults.timeProfile)?.measuredResults.timeProfile;
|
|
1975
|
+
if (!profile) return void console.log("No time profiles to export.");
|
|
1976
|
+
writeFileSync(resolve(path), JSON.stringify(profile));
|
|
1977
|
+
console.log(`Time profile exported to: ${path}`);
|
|
1978
|
+
}
|
|
1979
|
+
/** Merge coverage data from all results into a single CoverageData. */
|
|
1980
|
+
function mergeCoverage(results) {
|
|
1981
|
+
const scripts = results.flatMap((group) => groupReports(group).flatMap((r) => r.measuredResults.coverage?.scripts ?? []));
|
|
1982
|
+
return scripts.length > 0 ? { scripts } : void 0;
|
|
1983
|
+
}
|
|
1984
|
+
//#endregion
|
|
1985
|
+
//#region src/matrix/MatrixFilter.ts
|
|
1986
|
+
/** Parse filter string: "case/variant", "case/", "/variant", or "case" */
|
|
1987
|
+
function parseMatrixFilter(filter) {
|
|
1988
|
+
if (filter.includes("/")) {
|
|
1989
|
+
const [casePart, varPart] = filter.split("/", 2);
|
|
1990
|
+
return {
|
|
1991
|
+
case: casePart || void 0,
|
|
1992
|
+
variant: varPart || void 0
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
return { case: filter };
|
|
1996
|
+
}
|
|
1997
|
+
/** Apply filter to a matrix, merging with existing filters via intersection */
|
|
1998
|
+
async function filterMatrix(matrix, filter) {
|
|
1999
|
+
if (!filter || !filter.case && !filter.variant) return matrix;
|
|
2000
|
+
const caseList = await getFilteredCases(matrix, filter.case);
|
|
2001
|
+
const variantList = await getFilteredVariants(matrix, filter.variant);
|
|
2002
|
+
const filteredCases = intersectFilters(caseList, matrix.filteredCases);
|
|
2003
|
+
const filteredVariants = intersectFilters(variantList, matrix.filteredVariants);
|
|
2004
|
+
return {
|
|
2005
|
+
...matrix,
|
|
2006
|
+
filteredCases,
|
|
2007
|
+
filteredVariants
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
/** Collect all case IDs from either casesModule or inline cases */
|
|
2011
|
+
async function resolveCaseIds(matrix) {
|
|
2012
|
+
if (matrix.casesModule) return (await loadCasesModule(matrix.casesModule)).cases;
|
|
2013
|
+
return matrix.cases;
|
|
2014
|
+
}
|
|
2015
|
+
/** Collect all variant IDs from either inline variants or variantDir */
|
|
2016
|
+
async function resolveVariantIds(matrix) {
|
|
2017
|
+
if (matrix.variants) return Object.keys(matrix.variants);
|
|
2018
|
+
if (matrix.variantDir) return discoverVariants(matrix.variantDir);
|
|
2019
|
+
throw new Error("BenchMatrix requires 'variants' or 'variantDir'");
|
|
2020
|
+
}
|
|
2021
|
+
/** Return case IDs matching a substring pattern, or all if no pattern */
|
|
2022
|
+
async function getFilteredCases(matrix, casePattern) {
|
|
2023
|
+
if (!casePattern) return void 0;
|
|
2024
|
+
const caseIds = await resolveCaseIds(matrix);
|
|
2025
|
+
if (!caseIds) return ["default"];
|
|
2026
|
+
return filterByPattern(caseIds, casePattern, "cases");
|
|
2027
|
+
}
|
|
2028
|
+
/** Return variant IDs matching a substring pattern, or all if no pattern */
|
|
2029
|
+
async function getFilteredVariants(matrix, variantPattern) {
|
|
2030
|
+
if (!variantPattern) return void 0;
|
|
2031
|
+
return filterByPattern(await resolveVariantIds(matrix), variantPattern, "variants");
|
|
2032
|
+
}
|
|
2033
|
+
/** Intersect two optional filter lists: both present ==> intersection, otherwise the one that exists */
|
|
2034
|
+
function intersectFilters(a, b) {
|
|
2035
|
+
if (a && b) return a.filter((v) => b.includes(v));
|
|
2036
|
+
return a ?? b;
|
|
2037
|
+
}
|
|
2038
|
+
/** Filter IDs by substring pattern, throwing if no matches */
|
|
2039
|
+
function filterByPattern(ids, pattern, label) {
|
|
2040
|
+
const filtered = ids.filter((id) => matchPattern(id, pattern));
|
|
2041
|
+
if (filtered.length === 0) throw new Error(`No ${label} match filter: "${pattern}"`);
|
|
2042
|
+
return filtered;
|
|
2043
|
+
}
|
|
2044
|
+
/** Case-insensitive substring match */
|
|
2045
|
+
function matchPattern(id, pattern) {
|
|
2046
|
+
return id.toLowerCase().includes(pattern.toLowerCase());
|
|
2047
|
+
}
|
|
2048
|
+
//#endregion
|
|
2049
|
+
//#region src/profiling/browser/BrowserGcStats.ts
|
|
2050
|
+
/** Convert MinorGC/MajorGC trace events into GcEvent[]. */
|
|
2051
|
+
function parseGcTraceEvents(traceEvents) {
|
|
2052
|
+
return traceEvents.filter((e) => e.ph === "X" && gcType(e.name)).map((e) => ({
|
|
2053
|
+
type: gcType(e.name),
|
|
2054
|
+
pauseMs: (e.dur ?? 0) / 1e3,
|
|
2055
|
+
collected: Math.max(0, Number(e.args?.usedHeapSizeBefore ?? 0) - Number(e.args?.usedHeapSizeAfter ?? 0))
|
|
2056
|
+
}));
|
|
2057
|
+
}
|
|
2058
|
+
/** Parse and aggregate CDP trace events into GcStats. */
|
|
2059
|
+
function browserGcStats(traceEvents) {
|
|
2060
|
+
return aggregateGcStats(parseGcTraceEvents(traceEvents));
|
|
2061
|
+
}
|
|
2062
|
+
/** Map CDP event names (MinorGC/MajorGC) to GcEvent type. */
|
|
2063
|
+
function gcType(name) {
|
|
2064
|
+
if (name === "MinorGC") return "scavenge";
|
|
2065
|
+
if (name === "MajorGC") return "mark-compact";
|
|
2066
|
+
}
|
|
2067
|
+
//#endregion
|
|
2068
|
+
//#region src/profiling/browser/BrowserCDP.ts
|
|
2069
|
+
/** Build InstrumentOpts from profile params and heap sampling interval. */
|
|
2070
|
+
function instrumentOpts(params, samplingInterval) {
|
|
2071
|
+
const { alloc = false, profile = false, callCounts = false, profileInterval } = params;
|
|
2072
|
+
return {
|
|
2073
|
+
alloc,
|
|
2074
|
+
profile,
|
|
2075
|
+
callCounts,
|
|
2076
|
+
samplingInterval,
|
|
2077
|
+
profileInterval
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
/** Start CDP GC tracing; returns the mutable array that collects trace events. */
|
|
2081
|
+
async function startGcTracing(cdp) {
|
|
2082
|
+
const events = [];
|
|
2083
|
+
cdp.on("Tracing.dataCollected", ({ value }) => {
|
|
2084
|
+
events.push(...value);
|
|
2085
|
+
});
|
|
2086
|
+
await cdp.send("Tracing.start", { traceConfig: { includedCategories: ["v8", "v8.gc"] } });
|
|
2087
|
+
return events;
|
|
2088
|
+
}
|
|
2089
|
+
/** End CDP tracing and aggregate collected events into GcStats. */
|
|
2090
|
+
async function collectTracing(cdp, traceEvents) {
|
|
2091
|
+
const done = new Promise((r) => cdp.once("Tracing.tracingComplete", () => r()));
|
|
2092
|
+
await cdp.send("Tracing.end");
|
|
2093
|
+
await done;
|
|
2094
|
+
return browserGcStats(traceEvents);
|
|
2095
|
+
}
|
|
2096
|
+
/** Start CDP Profiler for CPU time sampling (caller manages Profiler.enable/disable) */
|
|
2097
|
+
async function startTimeProfiling(cdp, interval) {
|
|
2098
|
+
if (interval) await cdp.send("Profiler.setSamplingInterval", { interval });
|
|
2099
|
+
await cdp.send("Profiler.start");
|
|
2100
|
+
}
|
|
2101
|
+
/** Stop CDP CPU sampling and return the profile. */
|
|
2102
|
+
async function stopTimeProfiling(cdp) {
|
|
2103
|
+
const { profile } = await cdp.send("Profiler.stop");
|
|
2104
|
+
return profile;
|
|
2105
|
+
}
|
|
2106
|
+
/** Start precise coverage (caller manages Profiler.enable/disable). */
|
|
2107
|
+
async function startCoverageCollection(cdp) {
|
|
2108
|
+
await cdp.send("Profiler.startPreciseCoverage", {
|
|
2109
|
+
callCount: true,
|
|
2110
|
+
detailed: true
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
/** Collect precise coverage, filtering out browser-internal scripts. */
|
|
2114
|
+
async function collectCoverage(cdp) {
|
|
2115
|
+
const { result } = await cdp.send("Profiler.takePreciseCoverage");
|
|
2116
|
+
await cdp.send("Profiler.stopPreciseCoverage");
|
|
2117
|
+
return { scripts: result.filter(isPageScript) };
|
|
2118
|
+
}
|
|
2119
|
+
/** Stop active instruments and return collected profiles/coverage. */
|
|
2120
|
+
async function stopInstruments(cdp, opts) {
|
|
2121
|
+
const heapProfile = opts.alloc ? (await cdp.send("HeapProfiler.stopSampling")).profile : void 0;
|
|
2122
|
+
const timeProfile = opts.profile ? await stopTimeProfiling(cdp) : void 0;
|
|
2123
|
+
const coverage = opts.callCounts ? await collectCoverage(cdp) : void 0;
|
|
2124
|
+
if (opts.profile || opts.callCounts) await cdp.send("Profiler.disable");
|
|
2125
|
+
return {
|
|
2126
|
+
heapProfile,
|
|
2127
|
+
timeProfile,
|
|
2128
|
+
coverage
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
/** Start requested CDP instruments (heap, CPU, coverage). */
|
|
2132
|
+
async function startInstruments(cdp, opts) {
|
|
2133
|
+
if (opts.alloc) await cdp.send("HeapProfiler.startSampling", {
|
|
2134
|
+
samplingInterval: opts.samplingInterval,
|
|
2135
|
+
includeObjectsCollectedByMajorGC: true,
|
|
2136
|
+
includeObjectsCollectedByMinorGC: true
|
|
2137
|
+
});
|
|
2138
|
+
if (opts.profile || opts.callCounts) await cdp.send("Profiler.enable");
|
|
2139
|
+
if (opts.profile) await startTimeProfiling(cdp, opts.profileInterval);
|
|
2140
|
+
if (opts.callCounts) await startCoverageCollection(cdp);
|
|
2141
|
+
}
|
|
2142
|
+
/** Exclude chrome:// and devtools:// internal scripts. */
|
|
2143
|
+
function isPageScript(s) {
|
|
2144
|
+
return !!s.url && !s.url.startsWith("chrome") && !s.url.startsWith("devtools");
|
|
2145
|
+
}
|
|
2146
|
+
//#endregion
|
|
2147
|
+
//#region src/profiling/browser/BenchLoop.ts
|
|
2148
|
+
/**
|
|
2149
|
+
* Bench function mode: run window.__bench in a timed iteration loop.
|
|
2150
|
+
*
|
|
2151
|
+
* Simplified vs TimingRunner because it runs inside page.evaluate()
|
|
2152
|
+
* where shared code, Node APIs, and V8 intrinsics are unavailable.
|
|
2153
|
+
*
|
|
2154
|
+
* Not feasible in browser page context:
|
|
2155
|
+
* - heap tracking (no getHeapStatistics)
|
|
2156
|
+
* - V8 opt status tracing (no %GetOptimizationStatus)
|
|
2157
|
+
* - explicit GC or pause-for-compilation
|
|
2158
|
+
*/
|
|
2159
|
+
async function runBenchLoop(ctx) {
|
|
2160
|
+
const { page, cdp, params, samplingInterval } = ctx;
|
|
2161
|
+
const maxTime = params.maxTime ?? Number.MAX_SAFE_INTEGER;
|
|
2162
|
+
const maxIter = params.maxIterations ?? Number.MAX_SAFE_INTEGER;
|
|
2163
|
+
const opts = instrumentOpts(params, samplingInterval);
|
|
2164
|
+
await startInstruments(cdp, opts);
|
|
2165
|
+
const { samples, totalMs } = await page.evaluate(async ({ maxTime, maxIter }) => {
|
|
2166
|
+
const bench = globalThis.__bench;
|
|
2167
|
+
const estimated = Math.min(maxIter, Math.ceil(maxTime / .1));
|
|
2168
|
+
const samples = new Array(estimated);
|
|
2169
|
+
let count = 0;
|
|
2170
|
+
const startAll = performance.now();
|
|
2171
|
+
const deadline = startAll + maxTime;
|
|
2172
|
+
for (let i = 0; i < maxIter && performance.now() < deadline; i++) {
|
|
2173
|
+
const t0 = performance.now();
|
|
2174
|
+
await bench();
|
|
2175
|
+
samples[count++] = performance.now() - t0;
|
|
2176
|
+
}
|
|
2177
|
+
samples.length = count;
|
|
2178
|
+
return {
|
|
2179
|
+
samples,
|
|
2180
|
+
totalMs: performance.now() - startAll
|
|
2181
|
+
};
|
|
2182
|
+
}, {
|
|
2183
|
+
maxTime,
|
|
2184
|
+
maxIter
|
|
2185
|
+
});
|
|
2186
|
+
return {
|
|
2187
|
+
samples,
|
|
2188
|
+
wallTimeMs: totalMs,
|
|
2189
|
+
...await stopInstruments(cdp, opts)
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
//#endregion
|
|
2193
|
+
//#region src/profiling/browser/CdpClient.ts
|
|
2194
|
+
/** Connect to a CDP WebSocket endpoint and return a client. */
|
|
2195
|
+
async function connectCdp(wsUrl) {
|
|
2196
|
+
const ws = await openWebSocket(wsUrl);
|
|
2197
|
+
let nextId = 1;
|
|
2198
|
+
const pending = /* @__PURE__ */ new Map();
|
|
2199
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
2200
|
+
ws.addEventListener("message", (event) => {
|
|
2201
|
+
const msg = JSON.parse(String(event.data));
|
|
2202
|
+
if ("id" in msg) {
|
|
2203
|
+
const p = pending.get(msg.id);
|
|
2204
|
+
if (!p) return;
|
|
2205
|
+
pending.delete(msg.id);
|
|
2206
|
+
if (msg.error) p.reject(/* @__PURE__ */ new Error(`CDP: ${msg.error.message}`));
|
|
2207
|
+
else p.resolve(msg.result ?? {});
|
|
2208
|
+
} else if ("method" in msg) for (const h of listeners.get(msg.method) ?? []) h(msg.params ?? {});
|
|
2209
|
+
});
|
|
2210
|
+
const client = {
|
|
2211
|
+
send(method, params) {
|
|
2212
|
+
return new Promise((resolve, reject) => {
|
|
2213
|
+
const id = nextId++;
|
|
2214
|
+
const timer = setTimeout(() => {
|
|
2215
|
+
if (pending.delete(id)) reject(/* @__PURE__ */ new Error(`CDP timeout after 60s: ${method}`));
|
|
2216
|
+
}, 6e4);
|
|
2217
|
+
const clear = () => clearTimeout(timer);
|
|
2218
|
+
pending.set(id, {
|
|
2219
|
+
resolve(v) {
|
|
2220
|
+
clear();
|
|
2221
|
+
resolve(v);
|
|
2222
|
+
},
|
|
2223
|
+
reject(e) {
|
|
2224
|
+
clear();
|
|
2225
|
+
reject(e);
|
|
2226
|
+
}
|
|
2227
|
+
});
|
|
2228
|
+
ws.send(JSON.stringify({
|
|
2229
|
+
id,
|
|
2230
|
+
method,
|
|
2231
|
+
params
|
|
2232
|
+
}));
|
|
2233
|
+
});
|
|
2234
|
+
},
|
|
2235
|
+
on(event, handler) {
|
|
2236
|
+
const set = listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
2237
|
+
listeners.set(event, set);
|
|
2238
|
+
set.add(handler);
|
|
2239
|
+
},
|
|
2240
|
+
once(event, handler) {
|
|
2241
|
+
const wrap = (params) => {
|
|
2242
|
+
listeners.get(event)?.delete(wrap);
|
|
2243
|
+
handler(params);
|
|
2244
|
+
};
|
|
2245
|
+
client.on(event, wrap);
|
|
2246
|
+
},
|
|
2247
|
+
close() {
|
|
2248
|
+
for (const [, p] of pending) p.reject(/* @__PURE__ */ new Error("CDP connection closed"));
|
|
2249
|
+
pending.clear();
|
|
2250
|
+
ws.close();
|
|
2251
|
+
}
|
|
2252
|
+
};
|
|
2253
|
+
return client;
|
|
2254
|
+
}
|
|
2255
|
+
/** Open a WebSocket connection, rejecting if the handshake fails. */
|
|
2256
|
+
async function openWebSocket(wsUrl) {
|
|
2257
|
+
const ws = new WebSocket(wsUrl);
|
|
2258
|
+
const err = /* @__PURE__ */ new Error(`CDP connect failed: ${wsUrl}`);
|
|
2259
|
+
await new Promise((resolve, reject) => {
|
|
2260
|
+
ws.addEventListener("open", () => resolve());
|
|
2261
|
+
ws.addEventListener("error", () => reject(err));
|
|
2262
|
+
});
|
|
2263
|
+
return ws;
|
|
2264
|
+
}
|
|
2265
|
+
//#endregion
|
|
2266
|
+
//#region src/profiling/browser/CdpPage.ts
|
|
2267
|
+
/** Create a page abstraction over a CDP client connected to a page target. */
|
|
2268
|
+
async function createCdpPage(cdp, opts) {
|
|
2269
|
+
const timeout = opts?.timeout ?? 3e4;
|
|
2270
|
+
await cdp.send("Page.enable");
|
|
2271
|
+
await cdp.send("Runtime.enable");
|
|
2272
|
+
return {
|
|
2273
|
+
navigate: (url, navOpts) => cdpNavigate(cdp, url, navOpts),
|
|
2274
|
+
evaluate: (fn, arg) => cdpEvaluate(cdp, fn, arg),
|
|
2275
|
+
exposeFunction: (name, fn) => cdpExpose(cdp, name, fn),
|
|
2276
|
+
async addInitScript(fn) {
|
|
2277
|
+
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `(${fn.toString()})()` });
|
|
2278
|
+
},
|
|
2279
|
+
waitForSelector(sel) {
|
|
2280
|
+
return pollEval(cdp, `!!document.querySelector(${JSON.stringify(sel)})`, timeout);
|
|
2281
|
+
},
|
|
2282
|
+
waitForFunction: (expr) => pollEval(cdp, expr, timeout),
|
|
2283
|
+
onPageError(handler) {
|
|
2284
|
+
cdp.on("Runtime.exceptionThrown", ({ exceptionDetails: d }) => {
|
|
2285
|
+
handler(d.exception?.description || d.text);
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
/** Navigate to a URL and wait for the specified load condition. */
|
|
2291
|
+
async function cdpNavigate(cdp, url, navOpts) {
|
|
2292
|
+
const event = (navOpts?.waitUntil ?? "load") === "domcontentloaded" ? "Page.domContentEventFired" : "Page.loadEventFired";
|
|
2293
|
+
const loaded = new Promise((r) => cdp.once(event, () => r()));
|
|
2294
|
+
await cdp.send("Page.navigate", { url });
|
|
2295
|
+
await loaded;
|
|
2296
|
+
}
|
|
2297
|
+
/** Evaluate a function in the page and return the result. */
|
|
2298
|
+
async function cdpEvaluate(cdp, fn, arg) {
|
|
2299
|
+
const argStr = arg !== void 0 ? JSON.stringify(arg) : "";
|
|
2300
|
+
const opts = {
|
|
2301
|
+
expression: `(${fn.toString()})(${argStr})`,
|
|
2302
|
+
awaitPromise: true,
|
|
2303
|
+
returnByValue: true
|
|
2304
|
+
};
|
|
2305
|
+
const { result, exceptionDetails: err } = await cdp.send("Runtime.evaluate", opts);
|
|
2306
|
+
if (err) throw new Error(err.exception?.description || err.text);
|
|
2307
|
+
return result.value;
|
|
2308
|
+
}
|
|
2309
|
+
/** Expose a Node function to the page via Runtime.addBinding. */
|
|
2310
|
+
async function cdpExpose(cdp, name, fn) {
|
|
2311
|
+
const binding = `__cdp_${name}`;
|
|
2312
|
+
await cdp.send("Runtime.addBinding", { name: binding });
|
|
2313
|
+
const wrapper = `(() => {
|
|
2314
|
+
const g = globalThis;
|
|
2315
|
+
if (!g.__cdpSeq) { g.__cdpSeq = 0; g.__cdpCbs = {}; }
|
|
2316
|
+
g[${JSON.stringify(name)}] = (...args) => new Promise((resolve, reject) => {
|
|
2317
|
+
const seq = ++g.__cdpSeq;
|
|
2318
|
+
g.__cdpCbs[seq] = { resolve, reject };
|
|
2319
|
+
g[${JSON.stringify(binding)}](JSON.stringify({ seq, args }));
|
|
2320
|
+
});
|
|
2321
|
+
})()`;
|
|
2322
|
+
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: wrapper });
|
|
2323
|
+
await cdp.send("Runtime.evaluate", { expression: wrapper });
|
|
2324
|
+
const pageEval = (expr) => cdp.send("Runtime.evaluate", { expression: expr });
|
|
2325
|
+
cdp.on("Runtime.bindingCalled", async (params) => {
|
|
2326
|
+
if (params.name !== binding) return;
|
|
2327
|
+
const { seq, args } = JSON.parse(params.payload);
|
|
2328
|
+
const cb = `globalThis.__cdpCbs[${seq}]`;
|
|
2329
|
+
try {
|
|
2330
|
+
const val = await fn(...args);
|
|
2331
|
+
await pageEval(`${cb}?.resolve(${JSON.stringify(val ?? null)})`);
|
|
2332
|
+
} catch (err) {
|
|
2333
|
+
await pageEval(`${cb}?.reject(new Error(${JSON.stringify(String(err.message))}))`);
|
|
2334
|
+
}
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
/** Poll a JS expression until truthy, with timeout. */
|
|
2338
|
+
async function pollEval(cdp, expression, timeout) {
|
|
2339
|
+
const deadline = Date.now() + timeout;
|
|
2340
|
+
const evalOpts = {
|
|
2341
|
+
expression,
|
|
2342
|
+
returnByValue: true
|
|
2343
|
+
};
|
|
2344
|
+
while (Date.now() < deadline) {
|
|
2345
|
+
const { result } = await cdp.send("Runtime.evaluate", evalOpts);
|
|
2346
|
+
if (result.value) return;
|
|
2347
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
2348
|
+
}
|
|
2349
|
+
throw new Error(`Timed out waiting for: ${expression}`);
|
|
2350
|
+
}
|
|
2351
|
+
//#endregion
|
|
2352
|
+
//#region src/profiling/browser/ChromeLauncher.ts
|
|
2353
|
+
/** Flags to suppress background services irrelevant to benchmarking. */
|
|
2354
|
+
const quietFlags = [
|
|
2355
|
+
"--disable-background-networking",
|
|
2356
|
+
"--disable-client-side-phishing-detection",
|
|
2357
|
+
"--disable-component-update",
|
|
2358
|
+
"--disable-field-trial-config",
|
|
2359
|
+
"--disable-sync",
|
|
2360
|
+
"--disable-breakpad",
|
|
2361
|
+
"--noerrdialogs",
|
|
2362
|
+
"--disable-features=OptimizationHints,Translate,MediaRouter,DialMediaRouteProvider",
|
|
2363
|
+
"--disable-extensions",
|
|
2364
|
+
"--disable-component-extensions-with-background-pages",
|
|
2365
|
+
"--disable-default-apps",
|
|
2366
|
+
"--metrics-recording-only",
|
|
2367
|
+
"--no-service-autorun",
|
|
2368
|
+
"--password-store=basic",
|
|
2369
|
+
"--use-mock-keychain"
|
|
2370
|
+
];
|
|
2371
|
+
/** Stderr patterns to suppress (irrelevant to benchmarking). */
|
|
2372
|
+
const chromeNoise = /SharedImageManager|skia_output_device_buffer_queue|task_policy_set/;
|
|
2373
|
+
/** Launch Chrome with remote debugging and return instance handle. */
|
|
2374
|
+
async function launchChrome(opts) {
|
|
2375
|
+
const { headless = false, chromeProfile, chromePath, args = [] } = opts;
|
|
2376
|
+
const chrome = chromePath || process.env.CHROME_PATH || findChrome();
|
|
2377
|
+
const tmpDir = chromeProfile ? void 0 : await mkdtemp(join(tmpdir(), "benchforge-"));
|
|
2378
|
+
const proc = spawn(chrome, [
|
|
2379
|
+
"--remote-debugging-port=0",
|
|
2380
|
+
`--user-data-dir=${chromeProfile ?? tmpDir}`,
|
|
2381
|
+
"--no-first-run",
|
|
2382
|
+
"--no-default-browser-check",
|
|
2383
|
+
...quietFlags,
|
|
2384
|
+
...headless ? ["--headless=new"] : [],
|
|
2385
|
+
...args
|
|
2386
|
+
], { stdio: [
|
|
2387
|
+
"pipe",
|
|
2388
|
+
"pipe",
|
|
2389
|
+
"pipe"
|
|
2390
|
+
] });
|
|
2391
|
+
const wsUrlPromise = parseWsUrl(proc);
|
|
2392
|
+
pipeChromeOutput(proc);
|
|
2393
|
+
const wsUrl = await wsUrlPromise;
|
|
2394
|
+
return {
|
|
2395
|
+
port: Number(new URL(wsUrl).port),
|
|
2396
|
+
process: proc,
|
|
2397
|
+
async close() {
|
|
2398
|
+
proc.kill();
|
|
2399
|
+
await new Promise((r) => proc.on("exit", () => r()));
|
|
2400
|
+
if (tmpDir) await rm(tmpDir, {
|
|
2401
|
+
recursive: true,
|
|
2402
|
+
force: true
|
|
2403
|
+
}).catch(() => {});
|
|
2404
|
+
}
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
/** Create a new browser tab and return its CDP WebSocket URL and target ID. */
|
|
2408
|
+
async function createTab(port) {
|
|
2409
|
+
const url = `http://127.0.0.1:${port}/json/new`;
|
|
2410
|
+
const text = await (await fetch(url, { method: "PUT" })).text();
|
|
2411
|
+
try {
|
|
2412
|
+
const json = JSON.parse(text);
|
|
2413
|
+
return {
|
|
2414
|
+
wsUrl: json.webSocketDebuggerUrl,
|
|
2415
|
+
targetId: json.id
|
|
2416
|
+
};
|
|
2417
|
+
} catch {
|
|
2418
|
+
const msg = `Chrome /json/new returned non-JSON: ${text.slice(0, 200)}`;
|
|
2419
|
+
throw new Error(msg);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
/** Close a browser tab by target ID. */
|
|
2423
|
+
async function closeTab(port, targetId) {
|
|
2424
|
+
const url = `http://127.0.0.1:${port}/json/close/${targetId}`;
|
|
2425
|
+
await fetch(url).catch(() => {});
|
|
2426
|
+
}
|
|
2427
|
+
/** Find Chrome/Chromium on the system. */
|
|
2428
|
+
function findChrome() {
|
|
2429
|
+
if (process.platform === "darwin") {
|
|
2430
|
+
const path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
2431
|
+
if (existsSync(path)) return path;
|
|
2432
|
+
}
|
|
2433
|
+
if (process.platform === "win32") for (const env of ["ProgramFiles", "ProgramFiles(x86)"]) {
|
|
2434
|
+
const base = process.env[env];
|
|
2435
|
+
if (!base) continue;
|
|
2436
|
+
const p = join(base, "Google", "Chrome", "Application", "chrome.exe");
|
|
2437
|
+
if (existsSync(p)) return p;
|
|
2438
|
+
}
|
|
2439
|
+
for (const name of [
|
|
2440
|
+
"google-chrome",
|
|
2441
|
+
"chromium-browser",
|
|
2442
|
+
"chromium"
|
|
2443
|
+
]) try {
|
|
2444
|
+
return execFileSync("which", [name], { encoding: "utf8" }).trim();
|
|
2445
|
+
} catch {}
|
|
2446
|
+
throw new Error("Chrome not found. Install Chrome or set CHROME_PATH, or use --chrome <path>.");
|
|
2447
|
+
}
|
|
2448
|
+
/** Parse the DevTools WebSocket URL from Chrome's stderr. */
|
|
2449
|
+
function parseWsUrl(proc) {
|
|
2450
|
+
return new Promise((resolve, reject) => {
|
|
2451
|
+
const wsPattern = /DevTools listening on (ws:\/\/\S+)/;
|
|
2452
|
+
const onData = (chunk) => {
|
|
2453
|
+
const match = chunk.toString().match(wsPattern);
|
|
2454
|
+
if (match) {
|
|
2455
|
+
proc.stderr?.off("data", onData);
|
|
2456
|
+
resolve(match[1]);
|
|
2457
|
+
}
|
|
2458
|
+
};
|
|
2459
|
+
proc.stderr?.on("data", onData);
|
|
2460
|
+
proc.on("error", reject);
|
|
2461
|
+
proc.on("exit", (code) => reject(/* @__PURE__ */ new Error(`Chrome exited (code ${code}) before DevTools ready`)));
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
/** Forward Chrome stdout/stderr to terminal, filtering known noise. */
|
|
2465
|
+
function pipeChromeOutput(proc) {
|
|
2466
|
+
const forward = (stream) => stream?.on("data", (chunk) => {
|
|
2467
|
+
const lines = chunk.toString().split("\n").map((l) => l.trim()).filter((l) => l && !chromeNoise.test(l));
|
|
2468
|
+
for (const line of lines) process.stderr.write(`[chrome] ${line}\n`);
|
|
2469
|
+
});
|
|
2470
|
+
forward(proc.stdout);
|
|
2471
|
+
forward(proc.stderr);
|
|
2472
|
+
}
|
|
2473
|
+
//#endregion
|
|
2474
|
+
//#region src/profiling/browser/PageLoadMode.ts
|
|
2475
|
+
/** Run passive page-load profiling: instrument ==> navigate ==> wait ==> collect. */
|
|
2476
|
+
async function runPageLoad(ctx) {
|
|
2477
|
+
const { page, cdp, params, samplingInterval } = ctx;
|
|
2478
|
+
const opts = instrumentOpts(params, samplingInterval);
|
|
2479
|
+
await startInstruments(cdp, opts);
|
|
2480
|
+
await page.addInitScript(() => {
|
|
2481
|
+
const g = globalThis;
|
|
2482
|
+
g.__lcpTime = void 0;
|
|
2483
|
+
new PerformanceObserver((list) => {
|
|
2484
|
+
const entries = list.getEntries();
|
|
2485
|
+
if (entries.length) g.__lcpTime = entries.at(-1).startTime;
|
|
2486
|
+
}).observe({
|
|
2487
|
+
type: "largest-contentful-paint",
|
|
2488
|
+
buffered: true
|
|
2489
|
+
});
|
|
2490
|
+
});
|
|
2491
|
+
const { url, waitFor } = params;
|
|
2492
|
+
const isBuiltinWait = waitFor === "load" || waitFor === "domcontentloaded";
|
|
2493
|
+
const waitUntil = isBuiltinWait ? waitFor : "load";
|
|
2494
|
+
await page.navigate(url, { waitUntil });
|
|
2495
|
+
if (waitFor && !isBuiltinWait) if (/^[#.[]/.test(waitFor)) await page.waitForSelector(waitFor);
|
|
2496
|
+
else await page.waitForFunction(waitFor);
|
|
2497
|
+
const navTiming = await readNavTiming(page);
|
|
2498
|
+
return {
|
|
2499
|
+
...await stopInstruments(cdp, opts),
|
|
2500
|
+
navTiming,
|
|
2501
|
+
wallTimeMs: navTiming.loadEvent
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
/** Read navigation timing from the page via Performance API. */
|
|
2505
|
+
async function readNavTiming(page) {
|
|
2506
|
+
return page.evaluate(() => {
|
|
2507
|
+
const nav = performance.getEntriesByType("navigation")[0] ?? {};
|
|
2508
|
+
return {
|
|
2509
|
+
domContentLoaded: nav.domContentLoadedEventEnd ?? 0,
|
|
2510
|
+
loadEvent: nav.loadEventEnd ?? 0,
|
|
2511
|
+
lcp: globalThis.__lcpTime
|
|
2512
|
+
};
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
//#endregion
|
|
2516
|
+
//#region src/profiling/browser/BrowserProfiler.ts
|
|
2517
|
+
/**
|
|
2518
|
+
* Run browser benchmark, auto-detecting mode:
|
|
2519
|
+
* - Bench function (window.__bench): CLI controls iteration and timing.
|
|
2520
|
+
* - Page load (no __bench, or --page-load): measures navigation timing.
|
|
2521
|
+
*/
|
|
2522
|
+
async function profileBrowser(params) {
|
|
2523
|
+
const { headless = false, chromePath, chromeProfile, chromeArgs: args } = params;
|
|
2524
|
+
const owned = !params.chrome;
|
|
2525
|
+
const launch = {
|
|
2526
|
+
headless,
|
|
2527
|
+
chromePath,
|
|
2528
|
+
chromeProfile,
|
|
2529
|
+
args
|
|
2530
|
+
};
|
|
2531
|
+
const chrome = params.chrome ?? await launchChrome(launch);
|
|
2532
|
+
try {
|
|
2533
|
+
const { wsUrl, targetId } = await createTab(chrome.port);
|
|
2534
|
+
const cdp = await connectCdp(wsUrl);
|
|
2535
|
+
try {
|
|
2536
|
+
return await runProfile(await createCdpPage(cdp, { timeout: (params.timeout ?? 60) * 1e3 }), cdp, params);
|
|
2537
|
+
} finally {
|
|
2538
|
+
cdp.close();
|
|
2539
|
+
await closeTab(chrome.port, targetId);
|
|
2540
|
+
}
|
|
2541
|
+
} finally {
|
|
2542
|
+
if (owned) await chrome.close();
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
/**
|
|
2546
|
+
* Run profiling on an open CDP page, auto-detecting mode:
|
|
2547
|
+
* - **bench**: page exports `window.__bench` ==> CLI iterates and times it
|
|
2548
|
+
* - **page-load**: no `__bench` found (or `--page-load` flag) ==> profile navigation
|
|
2549
|
+
*
|
|
2550
|
+
* When auto-detecting, navigates once to check for `__bench`. If not found,
|
|
2551
|
+
* reloads via `runPageLoad` which starts instruments before navigation.
|
|
2552
|
+
*/
|
|
2553
|
+
async function runProfile(page, cdp, params) {
|
|
2554
|
+
const samplingInterval = params.allocOptions?.samplingInterval ?? 32768;
|
|
2555
|
+
const traceEvents = params.gcStats ? await startGcTracing(cdp) : [];
|
|
2556
|
+
const ctx = {
|
|
2557
|
+
page,
|
|
2558
|
+
cdp,
|
|
2559
|
+
params,
|
|
2560
|
+
samplingInterval
|
|
2561
|
+
};
|
|
2562
|
+
let result;
|
|
2563
|
+
if (params.pageLoad) result = await runPageLoad(ctx);
|
|
2564
|
+
else {
|
|
2565
|
+
await page.navigate(params.url, { waitUntil: "load" });
|
|
2566
|
+
if (await page.evaluate(() => typeof globalThis.__bench === "function")) result = await runBenchLoop(ctx);
|
|
2567
|
+
else {
|
|
2568
|
+
console.warn("No __bench found. Reloading in --page-load mode.");
|
|
2569
|
+
result = await runPageLoad(ctx);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
if (params.gcStats) return {
|
|
2573
|
+
...result,
|
|
2574
|
+
gcStats: await collectTracing(cdp, traceEvents)
|
|
2575
|
+
};
|
|
2576
|
+
return result;
|
|
2577
|
+
}
|
|
2578
|
+
//#endregion
|
|
2579
|
+
//#region src/cli/BrowserBench.ts
|
|
2580
|
+
const { yellow } = colors;
|
|
2581
|
+
/** Run browser profiling via CDP and report with standard pipeline. */
|
|
2582
|
+
async function browserBenchExports(args) {
|
|
2583
|
+
warnBrowserFlags(args);
|
|
2584
|
+
const params = buildBrowserParams(args);
|
|
2585
|
+
const name = nameFromUrl(args.url);
|
|
2586
|
+
const baselineUrl = args["baseline-url"];
|
|
2587
|
+
if (!(args.batches > 1 || !!baselineUrl || (args.iterations ?? 0) > 1 || params.pageLoad)) {
|
|
2588
|
+
const result = await profileBrowser(params);
|
|
2589
|
+
const results = browserResultGroups(name, result);
|
|
2590
|
+
printBrowserReport(result, results, args);
|
|
2591
|
+
await exportReports({
|
|
2592
|
+
results,
|
|
2593
|
+
args
|
|
2594
|
+
});
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
const { lastRaw, results } = await runBrowserBatches(params, name, args);
|
|
2598
|
+
printBrowserReport(lastRaw, results, args);
|
|
2599
|
+
await exportReports({
|
|
2600
|
+
results,
|
|
2601
|
+
args
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
/** Warn about Node-only flags ignored in browser mode. */
|
|
2605
|
+
function warnBrowserFlags(args) {
|
|
2606
|
+
const ignored = [
|
|
2607
|
+
[!args.worker, "--no-worker"],
|
|
2608
|
+
[!!args["trace-opt"], "--trace-opt"],
|
|
2609
|
+
[!!args["gc-force"], "--gc-force"],
|
|
2610
|
+
[!!args.adaptive, "--adaptive"]
|
|
2611
|
+
].filter(([active]) => active).map(([, flag]) => flag);
|
|
2612
|
+
if (ignored.length > 0) console.warn(yellow(`Ignored in browser mode: ${ignored.join(", ")}`));
|
|
2613
|
+
}
|
|
2614
|
+
/** Convert CLI args to browser profiler parameters. */
|
|
2615
|
+
function buildBrowserParams(args) {
|
|
2616
|
+
const { maxTime, maxIterations } = resolveLimits(args);
|
|
2617
|
+
const chromeArgs = args["chrome-args"]?.flatMap((a) => a.split(/\s+/)).map(stripQuotes).filter(Boolean);
|
|
2618
|
+
return {
|
|
2619
|
+
url: args.url,
|
|
2620
|
+
pageLoad: args["page-load"] || !!args["wait-for"],
|
|
2621
|
+
maxTime,
|
|
2622
|
+
maxIterations,
|
|
2623
|
+
chromeArgs,
|
|
2624
|
+
allocOptions: {
|
|
2625
|
+
samplingInterval: args["alloc-interval"],
|
|
2626
|
+
stackDepth: args["alloc-depth"]
|
|
2627
|
+
},
|
|
2628
|
+
alloc: needsAlloc(args),
|
|
2629
|
+
profile: needsProfile(args),
|
|
2630
|
+
profileInterval: args["profile-interval"],
|
|
2631
|
+
headless: args.headless,
|
|
2632
|
+
chromePath: args.chrome,
|
|
2633
|
+
chromeProfile: args["chrome-profile"],
|
|
2634
|
+
timeout: args.timeout,
|
|
2635
|
+
gcStats: args["gc-stats"],
|
|
2636
|
+
callCounts: args["call-counts"],
|
|
2637
|
+
waitFor: args["wait-for"]
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
/** Extract a short name from a URL for report labels. */
|
|
2641
|
+
function nameFromUrl(url) {
|
|
2642
|
+
return new URL(url).pathname.split("/").pop() || "browser";
|
|
2643
|
+
}
|
|
2644
|
+
/** Wrap browser profile result as ReportGroup[] for the standard export pipeline. */
|
|
2645
|
+
function browserResultGroups(name, result) {
|
|
2646
|
+
return [{
|
|
2647
|
+
name,
|
|
2648
|
+
reports: [{
|
|
2649
|
+
name,
|
|
2650
|
+
measuredResults: toBrowserMeasured(name, result)
|
|
2651
|
+
}]
|
|
2652
|
+
}];
|
|
2653
|
+
}
|
|
2654
|
+
/** Print text report and optional heap profile for browser results. */
|
|
2655
|
+
function printBrowserReport(result, results, args) {
|
|
2656
|
+
const hasPageLoad = ((results[0]?.reports[0]?.measuredResults)?.navTimings?.length ?? 0) > 0 || !!result.navTiming;
|
|
2657
|
+
const hasIterSamples = !!result.samples?.length;
|
|
2658
|
+
const sections = [
|
|
2659
|
+
...hasPageLoad ? pageLoadStatsSections : [],
|
|
2660
|
+
...hasIterSamples ? [buildTimeSection(args.stats)] : [],
|
|
2661
|
+
...result.gcStats ? [browserGcStatsSection] : [],
|
|
2662
|
+
...hasPageLoad || hasIterSamples ? [runsSection] : []
|
|
2663
|
+
];
|
|
2664
|
+
if (sections.length > 0) console.log(withStatus("computing report", () => reportResults(results, sections)));
|
|
2665
|
+
if (!result.heapProfile) return;
|
|
2666
|
+
printHeapReports(results, {
|
|
2667
|
+
...cliHeapReportOptions(args),
|
|
2668
|
+
isUserCode: isBrowserUserCode
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
/** Launch Chrome, run batched fresh tabs, merge results. */
|
|
2672
|
+
async function runBrowserBatches(params, name, args) {
|
|
2673
|
+
const { headless, chrome: chromePath } = args;
|
|
2674
|
+
const chromeProfile = args["chrome-profile"];
|
|
2675
|
+
const chrome = await launchChrome({
|
|
2676
|
+
headless,
|
|
2677
|
+
chromePath,
|
|
2678
|
+
chromeProfile,
|
|
2679
|
+
args: params.chromeArgs
|
|
2680
|
+
});
|
|
2681
|
+
try {
|
|
2682
|
+
return await runBatchedTabs(params, name, args, chrome);
|
|
2683
|
+
} finally {
|
|
2684
|
+
await chrome.close();
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
/** Strip surrounding quotes from a chrome-args token. */
|
|
2688
|
+
function stripQuotes(s) {
|
|
2689
|
+
return s.replace(/^(['"])(.*)\1$/s, "$2").replace(/^(-[^=]+=)(['"])(.*)\2$/s, "$1$3");
|
|
2690
|
+
}
|
|
2691
|
+
/** Convert a browser profile result into a MeasuredResults for the report pipeline. */
|
|
2692
|
+
function toBrowserMeasured(name, result) {
|
|
2693
|
+
const { gcStats, heapProfile, timeProfile, coverage, navTiming, samples } = result;
|
|
2694
|
+
const base = {
|
|
2695
|
+
name,
|
|
2696
|
+
gcStats,
|
|
2697
|
+
heapProfile,
|
|
2698
|
+
timeProfile,
|
|
2699
|
+
coverage,
|
|
2700
|
+
navTimings: navTiming ? [navTiming] : void 0
|
|
2701
|
+
};
|
|
2702
|
+
if (samples?.length) {
|
|
2703
|
+
const totalTime = result.wallTimeMs ? result.wallTimeMs / 1e3 : void 0;
|
|
2704
|
+
return {
|
|
2705
|
+
...base,
|
|
2706
|
+
samples,
|
|
2707
|
+
time: computeStats(samples),
|
|
2708
|
+
totalTime
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
const wallTime = result.wallTimeMs ?? 0;
|
|
2712
|
+
return {
|
|
2713
|
+
...base,
|
|
2714
|
+
samples: [wallTime],
|
|
2715
|
+
time: computeStats([wallTime])
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
/** Execute batched browser tabs within an already-launched Chrome instance. */
|
|
2719
|
+
async function runBatchedTabs(params, name, args, chrome) {
|
|
2720
|
+
const baselineUrl = args["baseline-url"];
|
|
2721
|
+
const { maxTime, maxIterations } = params;
|
|
2722
|
+
const limits = {
|
|
2723
|
+
maxTime,
|
|
2724
|
+
maxIterations
|
|
2725
|
+
};
|
|
2726
|
+
const state = { detectedPageLoad: params.pageLoad };
|
|
2727
|
+
const warmup = !(args["warmup-batch"] ?? false) && args.batches > 1;
|
|
2728
|
+
const mk = (url, label) => makeTabRunner(params, chrome, limits, warmup, state, url, label);
|
|
2729
|
+
const runCurrent = mk(params.url, name);
|
|
2730
|
+
const runBaseline = baselineUrl ? mk(baselineUrl, nameFromUrl(baselineUrl)) : void 0;
|
|
2731
|
+
const progress = (p) => {
|
|
2732
|
+
const sec = (p.elapsed / 1e3).toFixed(0);
|
|
2733
|
+
const msg = `\r◊ batch ${p.batch + 1}/${p.batches} ${p.label} (${sec}s) `;
|
|
2734
|
+
process.stderr.write(msg);
|
|
2735
|
+
};
|
|
2736
|
+
const { results: [current], baseline } = await runBatched([runCurrent], runBaseline, Math.max(args.batches, 2), args["warmup-batch"] ?? false, progress);
|
|
2737
|
+
process.stderr.write("\r" + " ".repeat(50) + "\r");
|
|
2738
|
+
const baseName = baselineUrl ? nameFromUrl(baselineUrl) : void 0;
|
|
2739
|
+
const baselineEntry = baseline && baseName ? {
|
|
2740
|
+
name: baseName,
|
|
2741
|
+
measuredResults: baseline
|
|
2742
|
+
} : void 0;
|
|
2743
|
+
const reports = [{
|
|
2744
|
+
name,
|
|
2745
|
+
measuredResults: current
|
|
2746
|
+
}];
|
|
2747
|
+
return {
|
|
2748
|
+
lastRaw: state.lastRaw,
|
|
2749
|
+
results: [{
|
|
2750
|
+
name,
|
|
2751
|
+
reports,
|
|
2752
|
+
baseline: baselineEntry
|
|
2753
|
+
}]
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
/** Create a batch runner closure for a single URL (current or baseline). */
|
|
2757
|
+
function makeTabRunner(params, chrome, limits, warmup, state, url, label) {
|
|
2758
|
+
let firstCall = warmup;
|
|
2759
|
+
return async () => {
|
|
2760
|
+
const isWarmup = firstCall;
|
|
2761
|
+
firstCall = false;
|
|
2762
|
+
const p = {
|
|
2763
|
+
...params,
|
|
2764
|
+
chrome,
|
|
2765
|
+
url
|
|
2766
|
+
};
|
|
2767
|
+
if (state.detectedPageLoad) {
|
|
2768
|
+
const batchLimits = isWarmup ? { maxIterations: 1 } : limits;
|
|
2769
|
+
const result = await runMultiPageLoad({
|
|
2770
|
+
...p,
|
|
2771
|
+
pageLoad: true
|
|
2772
|
+
}, label, batchLimits);
|
|
2773
|
+
state.lastRaw ??= {
|
|
2774
|
+
navTiming: result.navTimings?.[0],
|
|
2775
|
+
wallTimeMs: result.time.p50
|
|
2776
|
+
};
|
|
2777
|
+
return result;
|
|
2778
|
+
}
|
|
2779
|
+
const raw = await profileBrowser(p);
|
|
2780
|
+
state.lastRaw = raw;
|
|
2781
|
+
if (!raw.samples?.length && raw.navTiming) state.detectedPageLoad = true;
|
|
2782
|
+
return toBrowserMeasured(label, raw);
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
/** Run page loads until duration or iteration limit, collecting wallTimeMs as samples. */
|
|
2786
|
+
async function runMultiPageLoad(params, name, limits) {
|
|
2787
|
+
const { maxTime, maxIterations } = limits;
|
|
2788
|
+
const raws = [];
|
|
2789
|
+
let accumulated = 0;
|
|
2790
|
+
for (let i = 0;; i++) {
|
|
2791
|
+
if (maxIterations != null && i >= maxIterations) break;
|
|
2792
|
+
const raw = await profileBrowser(params);
|
|
2793
|
+
raws.push(raw);
|
|
2794
|
+
accumulated += raw.wallTimeMs ?? 0;
|
|
2795
|
+
if (maxTime != null && accumulated >= maxTime) break;
|
|
2796
|
+
}
|
|
2797
|
+
const samples = raws.map((r) => r.wallTimeMs ?? 0);
|
|
2798
|
+
const navTimings = raws.map((r) => r.navTiming).filter(Boolean);
|
|
2799
|
+
const { heapProfile, timeProfile, coverage } = raws[raws.length - 1];
|
|
2800
|
+
const totalTime = accumulated / 1e3;
|
|
2801
|
+
const gcStats = mergeGcStats(raws);
|
|
2802
|
+
return {
|
|
2803
|
+
name,
|
|
2804
|
+
samples,
|
|
2805
|
+
time: computeStats(samples),
|
|
2806
|
+
totalTime,
|
|
2807
|
+
navTimings: navTimings.length ? navTimings : void 0,
|
|
2808
|
+
gcStats,
|
|
2809
|
+
heapProfile,
|
|
2810
|
+
timeProfile,
|
|
2811
|
+
coverage
|
|
2812
|
+
};
|
|
2813
|
+
}
|
|
2814
|
+
//#endregion
|
|
2815
|
+
//#region src/cli/FilterBenchmarks.ts
|
|
2816
|
+
/** Filter suite benchmarks by name pattern (substring or regex). */
|
|
2817
|
+
function filterBenchmarks(suite, filter, removeEmpty = true) {
|
|
2818
|
+
if (!filter) return suite;
|
|
2819
|
+
const regex = createFilterRegex(filter);
|
|
2820
|
+
const groups = suite.groups.map((group) => ({
|
|
2821
|
+
...group,
|
|
2822
|
+
benchmarks: group.benchmarks.filter((bench) => regex.test(stripCaseSuffix(bench.name))),
|
|
2823
|
+
baseline: group.baseline && regex.test(stripCaseSuffix(group.baseline.name)) ? group.baseline : void 0
|
|
2824
|
+
})).filter((group) => !removeEmpty || group.benchmarks.length > 0);
|
|
2825
|
+
if (groups.every((g) => g.benchmarks.length === 0)) throw new Error(`No benchmarks match filter: "${filter}"`);
|
|
2826
|
+
return {
|
|
2827
|
+
name: suite.name,
|
|
2828
|
+
groups
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
/** Create regex from filter string. Uses literal prefix match unless the string looks like regex. */
|
|
2832
|
+
function createFilterRegex(filter) {
|
|
2833
|
+
const isSlashed = filter.startsWith("/") && filter.endsWith("/");
|
|
2834
|
+
if (!(isSlashed || /[*?[|]/.test(filter) || filter.startsWith("^") || filter.endsWith("$"))) return new RegExp("^" + escapeRegex(filter), "i");
|
|
2835
|
+
const pattern = isSlashed ? filter.slice(1, -1) : filter;
|
|
2836
|
+
try {
|
|
2837
|
+
return new RegExp(pattern, "i");
|
|
2838
|
+
} catch {
|
|
2839
|
+
return new RegExp(escapeRegex(filter), "i");
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
/** Strip case suffix like " [large]" from benchmark name for filtering. */
|
|
2843
|
+
function stripCaseSuffix(name) {
|
|
2844
|
+
return name.replace(/ \[.*?\]$/, "");
|
|
2845
|
+
}
|
|
2846
|
+
/** Escape special regex characters for literal matching. */
|
|
2847
|
+
function escapeRegex(str) {
|
|
2848
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2849
|
+
}
|
|
2850
|
+
//#endregion
|
|
2851
|
+
//#region src/cli/SuiteRunner.ts
|
|
2852
|
+
/** Run a benchmark suite with CLI arguments. */
|
|
2853
|
+
async function runBenchmarks(suite, args) {
|
|
2854
|
+
validateArgs(args);
|
|
2855
|
+
const { filter, worker: useWorker, batches = 1 } = args;
|
|
2856
|
+
const warmupBatch = args["warmup-batch"] ?? false;
|
|
2857
|
+
const options = cliToRunnerOptions(args);
|
|
2858
|
+
const filtered = filterBenchmarks(suite, filter);
|
|
2859
|
+
const suiteParams = {
|
|
2860
|
+
runner: "timing",
|
|
2861
|
+
options,
|
|
2862
|
+
useWorker,
|
|
2863
|
+
batches,
|
|
2864
|
+
warmupBatch
|
|
2865
|
+
};
|
|
2866
|
+
return serialMap(filtered.groups, (g) => runGroup(g, suiteParams));
|
|
2867
|
+
}
|
|
2868
|
+
/** Like Promise.all(arr.map(fn)) but runs one at a time. */
|
|
2869
|
+
async function serialMap(arr, fn) {
|
|
2870
|
+
const results = [];
|
|
2871
|
+
for (const item of arr) results.push(await fn(item));
|
|
2872
|
+
return results;
|
|
2873
|
+
}
|
|
2874
|
+
/** Execute group with shared setup, optionally batching to reduce ordering bias. */
|
|
2875
|
+
async function runGroup(group, suiteParams) {
|
|
2876
|
+
const { batches, warmupBatch, ...rest } = suiteParams;
|
|
2877
|
+
const { name, benchmarks, baseline, setup, metadata } = group;
|
|
2878
|
+
const setupParams = await setup?.();
|
|
2879
|
+
validateBenchmarkParameters(group);
|
|
2880
|
+
const runParams = {
|
|
2881
|
+
...rest,
|
|
2882
|
+
params: setupParams,
|
|
2883
|
+
metadata
|
|
2884
|
+
};
|
|
2885
|
+
if (batches === 1) return runSingleBatch(name, benchmarks, baseline, runParams);
|
|
2886
|
+
return runMultipleBatches(name, benchmarks, baseline, runParams, batches, warmupBatch);
|
|
2887
|
+
}
|
|
2888
|
+
/** Warn if parameterized benchmarks lack a setup function. */
|
|
2889
|
+
function validateBenchmarkParameters(group) {
|
|
2890
|
+
if (group.setup) return;
|
|
2891
|
+
const { benchmarks, baseline } = group;
|
|
2892
|
+
const all = baseline ? [...benchmarks, baseline] : benchmarks;
|
|
2893
|
+
for (const bench of all.filter((b) => b.fn.length > 0)) console.warn(`Benchmark "${bench.name}" in group "${group.name}" expects parameters but no setup() provided.`);
|
|
2894
|
+
}
|
|
2895
|
+
/** Run benchmarks in a single batch. */
|
|
2896
|
+
async function runSingleBatch(name, benchmarks, baseline, runParams) {
|
|
2897
|
+
const baselineReport = baseline ? await runSingleBenchmark(baseline, runParams) : void 0;
|
|
2898
|
+
return {
|
|
2899
|
+
name,
|
|
2900
|
+
reports: await serialMap(benchmarks, (b) => runSingleBenchmark(b, runParams)),
|
|
2901
|
+
baseline: baselineReport
|
|
2902
|
+
};
|
|
2903
|
+
}
|
|
2904
|
+
/** Run benchmarks in multiple batches, alternating order to reduce bias. */
|
|
2905
|
+
async function runMultipleBatches(name, benchmarks, baseline, runParams, batches, warmupBatch) {
|
|
2906
|
+
const { metadata } = runParams;
|
|
2907
|
+
const run = (spec) => async () => (await runSingleBenchmark(spec, runParams)).measuredResults;
|
|
2908
|
+
const batched = await runBatched(benchmarks.map(run), baseline ? run(baseline) : void 0, batches, warmupBatch);
|
|
2909
|
+
return {
|
|
2910
|
+
name,
|
|
2911
|
+
reports: benchmarks.map((b, i) => ({
|
|
2912
|
+
name: b.name,
|
|
2913
|
+
measuredResults: batched.results[i],
|
|
2914
|
+
metadata
|
|
2915
|
+
})),
|
|
2916
|
+
baseline: batched.baseline && baseline ? {
|
|
2917
|
+
name: baseline.name,
|
|
2918
|
+
measuredResults: batched.baseline,
|
|
2919
|
+
metadata
|
|
2920
|
+
} : void 0
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
/** Run single benchmark and create report. */
|
|
2924
|
+
async function runSingleBenchmark(spec, { runner, options, useWorker, params, metadata }) {
|
|
2925
|
+
const [result] = await runBenchmark({
|
|
2926
|
+
spec,
|
|
2927
|
+
runner,
|
|
2928
|
+
options,
|
|
2929
|
+
useWorker,
|
|
2930
|
+
params
|
|
2931
|
+
});
|
|
2932
|
+
return {
|
|
2933
|
+
name: spec.name,
|
|
2934
|
+
measuredResults: result,
|
|
2935
|
+
metadata
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2938
|
+
//#endregion
|
|
2939
|
+
//#region src/cli/RunBenchCLI.ts
|
|
2940
|
+
/** Top-level CLI dispatch: route to view, analyze, or default bench runner. */
|
|
2941
|
+
async function dispatchCli() {
|
|
2942
|
+
const argv = hideBin(process.argv);
|
|
2943
|
+
const [command] = argv;
|
|
2944
|
+
if (command === "view") {
|
|
2945
|
+
const { viewArchive } = await import("./ViewerServer-CuMNdNBz.mjs");
|
|
2946
|
+
return viewArchive(requireFile(argv[1], "view"));
|
|
2947
|
+
}
|
|
2948
|
+
if (command === "analyze") {
|
|
2949
|
+
const { analyzeArchive } = await import("./AnalyzeArchive-8NCJhmhS.mjs");
|
|
2950
|
+
return analyzeArchive(requireFile(argv[1], "analyze"));
|
|
2951
|
+
}
|
|
2952
|
+
await runDefaultBench(void 0, void 0, argv);
|
|
2953
|
+
}
|
|
2954
|
+
/** Run benchmarks and display results. Suite is optional with --url (browser mode). */
|
|
2955
|
+
async function runDefaultBench(suite, configureArgs, argv, opts) {
|
|
2956
|
+
const args = parseBenchArgs(configureArgs, argv);
|
|
2957
|
+
if (args.url) return browserBenchExports(args);
|
|
2958
|
+
if (args.list && suite) return listSuite(suite);
|
|
2959
|
+
if (suite) return benchExports(suite, args, opts);
|
|
2960
|
+
if (args.file) return fileBenchExports(args.file, args);
|
|
2961
|
+
throw new Error("Provide a benchmark file, --url for browser mode, or pass a BenchSuite directly.");
|
|
2962
|
+
}
|
|
2963
|
+
/** Parse CLI args with optional custom yargs configuration. */
|
|
2964
|
+
function parseBenchArgs(configureArgs, argv) {
|
|
2965
|
+
return parseCliArgs(argv ?? hideBin(process.argv), configureArgs);
|
|
2966
|
+
}
|
|
2967
|
+
/** Run a BenchSuite and print results with standard reporting. */
|
|
2968
|
+
async function benchExports(suite, args, opts) {
|
|
2969
|
+
const results = await runBenchmarks(suite, args);
|
|
2970
|
+
console.log(withStatus("computing report", () => defaultReport(results, args, opts)));
|
|
2971
|
+
await finishReports(results, args, opts);
|
|
2972
|
+
}
|
|
2973
|
+
/** Run matrix suite with full CLI handling (parse, run, report, export). */
|
|
2974
|
+
async function runDefaultMatrixBench(suite, configureArgs, reportOptions) {
|
|
2975
|
+
await matrixBenchExports(suite, parseBenchArgs(configureArgs), reportOptions);
|
|
2976
|
+
}
|
|
2977
|
+
/** Run a matrix suite, print results, and handle exports. */
|
|
2978
|
+
async function matrixBenchExports(suite, args, reportOptions, exportOptions) {
|
|
2979
|
+
const results = await runMatrixSuite(suite, args);
|
|
2980
|
+
const report = withStatus("computing report", () => defaultMatrixReport(results, reportOptions, args));
|
|
2981
|
+
console.log(report);
|
|
2982
|
+
await finishReports(matrixToReportGroups(results), args, exportOptions);
|
|
2983
|
+
}
|
|
2984
|
+
/** Run matrix suite with CLI arguments. --filter narrows defaults, --all --filter narrows all. */
|
|
2985
|
+
async function runMatrixSuite(suite, args) {
|
|
2986
|
+
if (args.list) {
|
|
2987
|
+
await listMatrixSuite(suite);
|
|
2988
|
+
return [];
|
|
2989
|
+
}
|
|
2990
|
+
validateArgs(args);
|
|
2991
|
+
const filter = args.filter ? parseMatrixFilter(args.filter) : void 0;
|
|
2992
|
+
const options = cliToMatrixOptions(args);
|
|
2993
|
+
const results = [];
|
|
2994
|
+
for (const matrix of suite.matrices) {
|
|
2995
|
+
const filtered = await applyMatrixFilters(matrix, args.all, filter);
|
|
2996
|
+
const { filteredCases, filteredVariants } = filtered;
|
|
2997
|
+
results.push(await runMatrix(filtered, {
|
|
2998
|
+
...options,
|
|
2999
|
+
filteredCases,
|
|
3000
|
+
filteredVariants
|
|
3001
|
+
}));
|
|
3002
|
+
}
|
|
3003
|
+
return results;
|
|
3004
|
+
}
|
|
3005
|
+
/** Require a file argument for a subcommand, exiting with usage on missing. */
|
|
3006
|
+
function requireFile(filePath, subcommand) {
|
|
3007
|
+
if (filePath) return filePath;
|
|
3008
|
+
console.error(`Usage: benchforge ${subcommand} <file.benchforge>`);
|
|
3009
|
+
process.exit(1);
|
|
3010
|
+
}
|
|
3011
|
+
/** Print available benchmarks in a suite for --list. */
|
|
3012
|
+
function listSuite(suite) {
|
|
3013
|
+
for (const group of suite.groups) {
|
|
3014
|
+
console.log(group.name);
|
|
3015
|
+
for (const bench of group.benchmarks) console.log(` ${bench.name}`);
|
|
3016
|
+
if (group.baseline) console.log(` ${group.baseline.name} (baseline)`);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
/** Import a file and run it as a benchmark based on what it exports. */
|
|
3020
|
+
async function fileBenchExports(filePath, args) {
|
|
3021
|
+
const { default: candidate } = await import(pathToFileURL(resolve(filePath)).href);
|
|
3022
|
+
if (candidate && Array.isArray(candidate.matrices)) {
|
|
3023
|
+
if (args.list) return listMatrixSuite(candidate);
|
|
3024
|
+
return matrixBenchExports(candidate, args);
|
|
3025
|
+
}
|
|
3026
|
+
if (candidate && Array.isArray(candidate.groups)) {
|
|
3027
|
+
if (args.list) return listSuite(candidate);
|
|
3028
|
+
return benchExports(candidate, args);
|
|
3029
|
+
}
|
|
3030
|
+
if (typeof candidate === "function") {
|
|
3031
|
+
const name = basename(filePath).replace(/\.[^.]+$/, "");
|
|
3032
|
+
return benchExports({
|
|
3033
|
+
name,
|
|
3034
|
+
groups: [{
|
|
3035
|
+
name,
|
|
3036
|
+
benchmarks: [{
|
|
3037
|
+
name,
|
|
3038
|
+
fn: candidate
|
|
3039
|
+
}]
|
|
3040
|
+
}]
|
|
3041
|
+
}, args);
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
/** Print available cases and variants in a matrix suite for --list. */
|
|
3045
|
+
async function listMatrixSuite(suite) {
|
|
3046
|
+
for (const matrix of suite.matrices) {
|
|
3047
|
+
console.log(matrix.name);
|
|
3048
|
+
const caseIds = await resolveCaseIds(matrix);
|
|
3049
|
+
if (caseIds) {
|
|
3050
|
+
console.log(" cases:");
|
|
3051
|
+
for (const id of caseIds) console.log(` ${id}`);
|
|
3052
|
+
}
|
|
3053
|
+
const variantIds = await resolveVariantIds(matrix);
|
|
3054
|
+
console.log(" variants:");
|
|
3055
|
+
for (const id of variantIds) console.log(` ${id}`);
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
/** --filter bypasses defaults (implies --all for the filtered dimension). */
|
|
3059
|
+
async function applyMatrixFilters(matrix, runAll, filter) {
|
|
3060
|
+
const mod = matrix.casesModule ? await loadCasesModule(matrix.casesModule) : void 0;
|
|
3061
|
+
let withDefaults = matrix;
|
|
3062
|
+
if (!runAll && !filter && mod) {
|
|
3063
|
+
const { defaultCases: filteredCases, defaultVariants: filteredVariants } = mod;
|
|
3064
|
+
withDefaults = {
|
|
3065
|
+
...matrix,
|
|
3066
|
+
filteredCases,
|
|
3067
|
+
filteredVariants
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
return filter ? filterMatrix(withDefaults, filter) : withDefaults;
|
|
3071
|
+
}
|
|
3072
|
+
//#endregion
|
|
3073
|
+
export { gcStatsSection as A, buildTimeSection as C, totalTimeSection as D, timeSection as E, browserCliArgs as M, defaultCliArgs as N, gcSection as O, parseCliArgs as P, buildGenericSections as S, runsSection as T, reportMatrixResults as _, runDefaultBench as a, prepareHtmlData as b, runBenchmarks as c, exportReports as d, defaultMatrixReport as f, reportOptStatus as g, printHeapReports as h, parseBenchArgs as i, exportPerfettoTrace as j, gcSections as k, filterMatrix as l, matrixToReportGroups as m, dispatchCli as n, runDefaultMatrixBench as o, defaultReport as p, matrixBenchExports as r, runMatrixSuite as s, benchExports as t, parseMatrixFilter as u, reportResults as v, optSection as w, adaptiveSections as x, cliToMatrixOptions as y };
|
|
3074
|
+
|
|
3075
|
+
//# sourceMappingURL=RunBenchCLI-C17DrJz8.mjs.map
|