benchforge 0.1.9 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +99 -260
  3. package/bin/benchforge +1 -2
  4. package/dist/AnalyzeArchive-8NCJhmhS.mjs +145 -0
  5. package/dist/AnalyzeArchive-8NCJhmhS.mjs.map +1 -0
  6. package/dist/BenchMatrix-BZVrBB_h.mjs +1050 -0
  7. package/dist/BenchMatrix-BZVrBB_h.mjs.map +1 -0
  8. package/dist/BenchRunner-DglX1NOn.d.mts +302 -0
  9. package/dist/CoverageSampler-D5T9DRqe.mjs +27 -0
  10. package/dist/CoverageSampler-D5T9DRqe.mjs.map +1 -0
  11. package/dist/Formatters-BWj3d4sv.mjs +95 -0
  12. package/dist/Formatters-BWj3d4sv.mjs.map +1 -0
  13. package/dist/{HeapSampler-B8dtKHn1.mjs → HeapSampler-Dq-hpXem.mjs} +4 -4
  14. package/dist/HeapSampler-Dq-hpXem.mjs.map +1 -0
  15. package/dist/RunBenchCLI-C17DrJz8.mjs +3075 -0
  16. package/dist/RunBenchCLI-C17DrJz8.mjs.map +1 -0
  17. package/dist/StatisticalUtils-BD92crgM.mjs +255 -0
  18. package/dist/StatisticalUtils-BD92crgM.mjs.map +1 -0
  19. package/dist/TimeSampler-Ds8n7l2B.mjs +29 -0
  20. package/dist/TimeSampler-Ds8n7l2B.mjs.map +1 -0
  21. package/dist/ViewerServer-BJhdnxlN.mjs +639 -0
  22. package/dist/ViewerServer-BJhdnxlN.mjs.map +1 -0
  23. package/dist/ViewerServer-CuMNdNBz.mjs +2 -0
  24. package/dist/bin/benchforge.mjs +4 -5
  25. package/dist/bin/benchforge.mjs.map +1 -1
  26. package/dist/index.d.mts +731 -522
  27. package/dist/index.mjs +98 -3
  28. package/dist/index.mjs.map +1 -0
  29. package/dist/runners/WorkerScript.d.mts +12 -4
  30. package/dist/runners/WorkerScript.mjs +92 -120
  31. package/dist/runners/WorkerScript.mjs.map +1 -1
  32. package/dist/viewer/assets/CIPlot-BkOvMoMa.js +1 -0
  33. package/dist/viewer/assets/HistogramKde-CmSyUFY0.js +1 -0
  34. package/dist/viewer/assets/LegendUtils-BJpbn_jr.js +55 -0
  35. package/dist/viewer/assets/SampleTimeSeries-C4VBhXr3.js +1 -0
  36. package/dist/viewer/assets/index-Br9bp_cX.js +153 -0
  37. package/dist/viewer/assets/index-NzXXe_CC.css +1 -0
  38. package/dist/viewer/index.html +19 -0
  39. package/dist/viewer/speedscope/LICENSE +21 -0
  40. package/dist/viewer/speedscope/SourceCodePro-Regular.ttf-ILST5JV6.woff2 +0 -0
  41. package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js +2 -0
  42. package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js.map +7 -0
  43. package/dist/viewer/speedscope/favicon-16x16-VSI62OPJ.png +0 -0
  44. package/dist/viewer/speedscope/favicon-32x32-3EB2YCUY.png +0 -0
  45. package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js +2 -0
  46. package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js.map +7 -0
  47. package/dist/viewer/speedscope/favicon-FOKUP5Y5.ico +0 -0
  48. package/dist/viewer/speedscope/favicon-M34RF7BI.js +2 -0
  49. package/dist/viewer/speedscope/favicon-M34RF7BI.js.map +7 -0
  50. package/dist/viewer/speedscope/file-format-schema.json +274 -0
  51. package/dist/viewer/speedscope/index.html +19 -0
  52. package/dist/viewer/speedscope/jfrview_bg-BLJXNNQB.wasm +0 -0
  53. package/dist/viewer/speedscope/perf-vertx-stacks-01-collapsed-all-ZNUIGAJL.txt +199 -0
  54. package/dist/viewer/speedscope/release.txt +3 -0
  55. package/dist/viewer/speedscope/source-code-pro.LICENSE.md +93 -0
  56. package/dist/viewer/speedscope/speedscope-GHPHNKXC.css +2 -0
  57. package/dist/viewer/speedscope/speedscope-GHPHNKXC.css.map +7 -0
  58. package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js +212 -0
  59. package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js.map +7 -0
  60. package/package.json +52 -26
  61. package/src/bin/benchforge.ts +2 -2
  62. package/src/cli/AnalyzeArchive.ts +232 -0
  63. package/src/cli/BrowserBench.ts +322 -0
  64. package/src/cli/CliArgs.ts +164 -48
  65. package/src/cli/CliExport.ts +179 -0
  66. package/src/cli/CliOptions.ts +147 -0
  67. package/src/cli/CliReport.ts +197 -0
  68. package/src/cli/FilterBenchmarks.ts +18 -30
  69. package/src/cli/RunBenchCLI.ts +138 -844
  70. package/src/cli/SuiteRunner.ts +160 -0
  71. package/src/cli/ViewerServer.ts +282 -0
  72. package/src/export/AllocExport.ts +121 -0
  73. package/src/export/ArchiveExport.ts +146 -0
  74. package/src/export/ArchiveFormat.ts +50 -0
  75. package/src/export/CoverageExport.ts +148 -0
  76. package/src/export/EditorUri.ts +10 -0
  77. package/src/export/PerfettoExport.ts +91 -126
  78. package/src/export/SpeedscopeTypes.ts +98 -0
  79. package/src/export/TimeExport.ts +115 -0
  80. package/src/index.ts +87 -62
  81. package/src/matrix/BenchMatrix.ts +230 -0
  82. package/src/matrix/CaseLoader.ts +8 -6
  83. package/src/matrix/MatrixDirRunner.ts +153 -0
  84. package/src/matrix/MatrixFilter.ts +55 -53
  85. package/src/matrix/MatrixInlineRunner.ts +50 -0
  86. package/src/matrix/MatrixReport.ts +94 -254
  87. package/src/matrix/VariantLoader.ts +9 -9
  88. package/src/profiling/browser/BenchLoop.ts +51 -0
  89. package/src/profiling/browser/BrowserCDP.ts +133 -0
  90. package/src/profiling/browser/BrowserGcStats.ts +33 -0
  91. package/src/profiling/browser/BrowserProfiler.ts +160 -0
  92. package/src/profiling/browser/CdpClient.ts +82 -0
  93. package/src/profiling/browser/CdpPage.ts +138 -0
  94. package/src/profiling/browser/ChromeLauncher.ts +158 -0
  95. package/src/profiling/browser/ChromeTraceEvent.ts +28 -0
  96. package/src/profiling/browser/PageLoadMode.ts +61 -0
  97. package/src/profiling/node/CoverageSampler.ts +27 -0
  98. package/src/profiling/node/CoverageTypes.ts +23 -0
  99. package/src/profiling/node/HeapSampleReport.ts +261 -0
  100. package/src/{heap-sample → profiling/node}/HeapSampler.ts +55 -13
  101. package/src/profiling/node/ResolvedProfile.ts +98 -0
  102. package/src/profiling/node/TimeSampler.ts +57 -0
  103. package/src/report/BenchmarkReport.ts +146 -0
  104. package/src/report/Colors.ts +9 -0
  105. package/src/report/Formatters.ts +110 -0
  106. package/src/report/GcSections.ts +151 -0
  107. package/src/{GitUtils.ts → report/GitUtils.ts} +18 -19
  108. package/src/report/HtmlReport.ts +223 -0
  109. package/src/report/ParseStats.ts +73 -0
  110. package/src/report/StandardSections.ts +147 -0
  111. package/src/report/ViewerSections.ts +286 -0
  112. package/src/report/text/TableReport.ts +253 -0
  113. package/src/report/text/TextReport.ts +123 -0
  114. package/src/runners/AdaptiveWrapper.ts +167 -287
  115. package/src/runners/BenchRunner.ts +27 -22
  116. package/src/{Benchmark.ts → runners/BenchmarkSpec.ts} +5 -6
  117. package/src/runners/CreateRunner.ts +5 -7
  118. package/src/runners/GcStats.ts +58 -61
  119. package/src/{MeasuredResults.ts → runners/MeasuredResults.ts} +43 -37
  120. package/src/runners/MergeBatches.ts +123 -0
  121. package/src/{NodeGC.ts → runners/NodeGC.ts} +2 -3
  122. package/src/runners/RunnerOrchestrator.ts +180 -296
  123. package/src/runners/RunnerUtils.ts +75 -1
  124. package/src/runners/SampleStats.ts +100 -0
  125. package/src/runners/TimingRunner.ts +244 -0
  126. package/src/runners/TimingUtils.ts +3 -2
  127. package/src/runners/WorkerScript.ts +162 -178
  128. package/src/stats/BootstrapDifference.ts +282 -0
  129. package/src/{PermutationTest.ts → stats/PermutationTest.ts} +31 -40
  130. package/src/stats/StatisticalUtils.ts +445 -0
  131. package/src/{tests → test}/AdaptiveConvergence.test.ts +10 -10
  132. package/src/test/AdaptiveRunner.test.ts +39 -41
  133. package/src/{tests → test}/AdaptiveSampling.test.ts +9 -9
  134. package/src/test/AdaptiveStatistics.integration.ts +9 -41
  135. package/src/{tests → test}/BenchMatrix.test.ts +31 -28
  136. package/src/test/BenchmarkReport.test.ts +63 -13
  137. package/src/test/BrowserBench.e2e.test.ts +186 -17
  138. package/src/test/BrowserBench.test.ts +10 -5
  139. package/src/test/BuildTimeSection.test.ts +130 -0
  140. package/src/test/CapSamples.test.ts +82 -0
  141. package/src/test/CoverageExport.test.ts +115 -0
  142. package/src/test/CoverageSampler.test.ts +33 -0
  143. package/src/test/HeapAttribution.test.ts +51 -0
  144. package/src/{tests → test}/MatrixFilter.test.ts +16 -16
  145. package/src/{tests → test}/MatrixReport.test.ts +1 -1
  146. package/src/test/PermutationTest.test.ts +1 -1
  147. package/src/{tests → test}/RealDataValidation.test.ts +6 -6
  148. package/src/test/RunBenchCLI.test.ts +57 -56
  149. package/src/test/RunnerOrchestrator.test.ts +12 -12
  150. package/src/test/StatisticalUtils.test.ts +48 -12
  151. package/src/{table-util/test → test}/TableReport.test.ts +2 -2
  152. package/src/test/TestUtils.ts +35 -30
  153. package/src/test/TimeExport.test.ts +139 -0
  154. package/src/test/TimeSampler.test.ts +37 -0
  155. package/src/test/ViewerLive.e2e.test.ts +159 -0
  156. package/src/test/ViewerStatic.static.e2e.test.ts +137 -0
  157. package/src/{tests → test}/fixtures/baseline/impl.ts +1 -1
  158. package/src/{tests → test}/fixtures/bevy30-samples.ts +3 -1
  159. package/src/test/fixtures/cases/asyncCases.ts +9 -0
  160. package/src/{tests → test}/fixtures/cases/cases.ts +5 -2
  161. package/src/test/fixtures/cases/variants/product.ts +2 -0
  162. package/src/test/fixtures/cases/variants/sum.ts +2 -0
  163. package/src/test/fixtures/discover/fast.ts +1 -0
  164. package/src/{tests → test}/fixtures/discover/slow.ts +1 -1
  165. package/src/test/fixtures/invalid/bad.ts +1 -0
  166. package/src/test/fixtures/loader/fast.ts +1 -0
  167. package/src/{tests → test}/fixtures/loader/slow.ts +1 -1
  168. package/src/test/fixtures/loader/stateful.ts +2 -0
  169. package/src/test/fixtures/stateful/stateful.ts +2 -0
  170. package/src/test/fixtures/variants/extra.ts +1 -0
  171. package/src/test/fixtures/variants/impl.ts +1 -0
  172. package/src/test/fixtures/worker/fast.ts +1 -0
  173. package/src/{tests → test}/fixtures/worker/slow.ts +1 -1
  174. package/src/viewer/DateFormat.ts +30 -0
  175. package/src/viewer/Helpers.ts +23 -0
  176. package/src/viewer/LineData.ts +120 -0
  177. package/src/viewer/Providers.ts +191 -0
  178. package/src/viewer/ReportData.ts +123 -0
  179. package/src/viewer/State.ts +49 -0
  180. package/src/viewer/Theme.ts +15 -0
  181. package/src/viewer/components/App.tsx +73 -0
  182. package/src/viewer/components/DropZone.tsx +71 -0
  183. package/src/viewer/components/LazyPlot.ts +33 -0
  184. package/src/viewer/components/SamplesPanel.tsx +214 -0
  185. package/src/viewer/components/Shell.tsx +26 -0
  186. package/src/viewer/components/SourcePanel.tsx +216 -0
  187. package/src/viewer/components/SummaryPanel.tsx +332 -0
  188. package/src/viewer/components/TabBar.tsx +131 -0
  189. package/src/viewer/components/TabContent.tsx +46 -0
  190. package/src/viewer/components/ThemeToggle.tsx +50 -0
  191. package/src/viewer/index.html +20 -0
  192. package/src/viewer/main.tsx +4 -0
  193. package/src/viewer/plots/CIPlot.ts +313 -0
  194. package/src/{html/browser → viewer/plots}/HistogramKde.ts +42 -47
  195. package/src/viewer/plots/LegendUtils.ts +134 -0
  196. package/src/viewer/plots/PlotTypes.ts +85 -0
  197. package/src/viewer/plots/RenderPlots.ts +230 -0
  198. package/src/viewer/plots/SampleTimeSeries.ts +306 -0
  199. package/src/viewer/plots/SvgHelpers.ts +136 -0
  200. package/src/viewer/plots/TimeSeriesMarks.ts +319 -0
  201. package/src/viewer/report.css +427 -0
  202. package/src/viewer/shell.css +357 -0
  203. package/src/viewer/tsconfig.json +11 -0
  204. package/dist/BenchRunner-CSKN9zPy.d.mts +0 -225
  205. package/dist/BrowserHeapSampler-DCeL42RE.mjs +0 -202
  206. package/dist/BrowserHeapSampler-DCeL42RE.mjs.map +0 -1
  207. package/dist/GcStats-ByEovUi1.mjs +0 -77
  208. package/dist/GcStats-ByEovUi1.mjs.map +0 -1
  209. package/dist/HeapSampler-B8dtKHn1.mjs.map +0 -1
  210. package/dist/TimingUtils-ClclVQ7E.mjs +0 -597
  211. package/dist/TimingUtils-ClclVQ7E.mjs.map +0 -1
  212. package/dist/browser/index.js +0 -914
  213. package/dist/src-Cf_LXwlp.mjs +0 -2873
  214. package/dist/src-Cf_LXwlp.mjs.map +0 -1
  215. package/src/BenchMatrix.ts +0 -380
  216. package/src/BenchmarkReport.ts +0 -156
  217. package/src/HtmlDataPrep.ts +0 -148
  218. package/src/StandardSections.ts +0 -261
  219. package/src/StatisticalUtils.ts +0 -176
  220. package/src/TypeUtil.ts +0 -8
  221. package/src/browser/BrowserGcStats.ts +0 -44
  222. package/src/browser/BrowserHeapSampler.ts +0 -271
  223. package/src/export/JsonExport.ts +0 -103
  224. package/src/export/JsonFormat.ts +0 -91
  225. package/src/heap-sample/HeapSampleReport.ts +0 -196
  226. package/src/html/HtmlReport.ts +0 -131
  227. package/src/html/HtmlTemplate.ts +0 -284
  228. package/src/html/Types.ts +0 -88
  229. package/src/html/browser/CIPlot.ts +0 -287
  230. package/src/html/browser/LegendUtils.ts +0 -163
  231. package/src/html/browser/RenderPlots.ts +0 -263
  232. package/src/html/browser/SampleTimeSeries.ts +0 -389
  233. package/src/html/browser/Types.ts +0 -96
  234. package/src/html/browser/index.ts +0 -1
  235. package/src/html/index.ts +0 -17
  236. package/src/runners/BasicRunner.ts +0 -364
  237. package/src/table-util/ConvergenceFormatters.ts +0 -19
  238. package/src/table-util/Formatters.ts +0 -152
  239. package/src/table-util/README.md +0 -70
  240. package/src/table-util/TableReport.ts +0 -293
  241. package/src/tests/fixtures/cases/asyncCases.ts +0 -7
  242. package/src/tests/fixtures/cases/variants/product.ts +0 -2
  243. package/src/tests/fixtures/cases/variants/sum.ts +0 -2
  244. package/src/tests/fixtures/discover/fast.ts +0 -1
  245. package/src/tests/fixtures/invalid/bad.ts +0 -1
  246. package/src/tests/fixtures/loader/fast.ts +0 -1
  247. package/src/tests/fixtures/loader/stateful.ts +0 -2
  248. package/src/tests/fixtures/stateful/stateful.ts +0 -2
  249. package/src/tests/fixtures/variants/extra.ts +0 -1
  250. package/src/tests/fixtures/variants/impl.ts +0 -1
  251. package/src/tests/fixtures/worker/fast.ts +0 -1
  252. package/src/{table-util/test → test}/TableValueExtractor.test.ts +0 -0
  253. package/src/{table-util/test → test}/TableValueExtractor.ts +9 -9
@@ -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