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,50 @@
1
+ /** Serialized `.benchforge` archive format. */
2
+
3
+ import type { ReportData } from "../viewer/ReportData.ts";
4
+ import type { LineCoverage } from "./CoverageExport.ts";
5
+ import type { SpeedscopeFile } from "./SpeedscopeTypes.ts";
6
+
7
+ export interface BenchforgeArchive {
8
+ /** Archive format version. */
9
+ schema: number;
10
+
11
+ /** Heap allocation profile in Speedscope format. */
12
+ allocProfile?: SpeedscopeFile;
13
+
14
+ /** CPU time profile in Speedscope format. */
15
+ timeProfile?: SpeedscopeFile;
16
+
17
+ /** Per-line coverage data keyed by source URL. */
18
+ coverage?: Record<string, LineCoverage[]>;
19
+
20
+ /** Benchmark report with suite results and statistics. */
21
+ report?: ReportData;
22
+
23
+ /** Source file contents keyed by file URL. */
24
+ sources: Record<string, string>;
25
+
26
+ /** Archive creation metadata. */
27
+ metadata: ArchiveMetadata;
28
+ }
29
+
30
+ export interface ArchiveMetadata {
31
+ /** ISO timestamp (colons/periods replaced with dashes for filename safety). */
32
+ timestamp: string;
33
+
34
+ /** Benchforge package version. */
35
+ benchforgeVersion: string;
36
+ }
37
+
38
+ export const archiveSchemaVersion = 2;
39
+
40
+ /** Migrate a parsed archive from older schema versions to current. */
41
+ export function migrateArchive(
42
+ raw: Record<string, unknown>,
43
+ ): Partial<BenchforgeArchive> {
44
+ const schema = (raw.schema as number) ?? 0;
45
+ if (schema <= 1 && "profile" in raw && !("allocProfile" in raw)) {
46
+ raw.allocProfile = raw.profile;
47
+ delete raw.profile;
48
+ }
49
+ return raw as Partial<BenchforgeArchive>;
50
+ }
@@ -0,0 +1,148 @@
1
+ /** Line-level coverage maps from V8/CDP coverage data and frame annotation with execution counts. */
2
+
3
+ import type { CoverageData } from "../profiling/node/CoverageTypes.ts";
4
+
5
+ /** Per-function execution count at a specific source line. */
6
+ export interface LineCoverage {
7
+ /** 1-indexed line number of the function start */
8
+ startLine: number;
9
+ /** Function name (empty string for anonymous top-level) */
10
+ functionName: string;
11
+ /** Number of times the function was invoked */
12
+ count: number;
13
+ }
14
+
15
+ /** Map from source URL to per-function execution counts. */
16
+ export type CoverageMap = Map<string, LineCoverage[]>;
17
+
18
+ /** Coverage data: per-URL entries and a name-only fallback lookup. */
19
+ export interface CoverageResult {
20
+ /** Per-URL coverage entries (for frames with matching file URLs) */
21
+ map: CoverageMap;
22
+ /** Name ==> count lookup across all scripts (for frames without file URLs) */
23
+ byName: Map<string, number>;
24
+ }
25
+
26
+ /** Build coverage data from raw CDP/inspector coverage and source texts. */
27
+ export function buildCoverageMap(
28
+ coverage: CoverageData,
29
+ sources: Record<string, string>,
30
+ ): CoverageResult {
31
+ const map: CoverageMap = new Map();
32
+ const byName = new Map<string, number>();
33
+
34
+ for (const script of coverage.scripts) {
35
+ processScript(script, sources, map, byName);
36
+ }
37
+
38
+ return { map, byName };
39
+ }
40
+
41
+ /** Annotate speedscope frame names with execution counts (e.g. "fn [1.2K]"). */
42
+ export function annotateFramesWithCounts(
43
+ frames: { name: string; file?: string; line?: number }[],
44
+ coverage: CoverageResult,
45
+ ): void {
46
+ for (const frame of frames) {
47
+ const entries = frame.file ? coverage.map.get(frame.file) : undefined;
48
+ const count = entries && findCount(frame.name, frame.line, entries);
49
+ const isAnon = frame.name.startsWith("(anonymous");
50
+ const resolved =
51
+ count ?? (isAnon ? undefined : coverage.byName.get(frame.name));
52
+ if (resolved !== undefined && resolved > 0)
53
+ frame.name = `${frame.name} [${formatCount(resolved)}]`;
54
+ }
55
+ }
56
+
57
+ /** Extract per-function coverage entries from a single script. */
58
+ function processScript(
59
+ script: CoverageData["scripts"][number],
60
+ sources: Record<string, string>,
61
+ map: CoverageMap,
62
+ byName: Map<string, number>,
63
+ ): void {
64
+ const { url, functions } = script;
65
+ const source = url ? sources[url] : undefined;
66
+ const lineOffsets = source ? buildLineOffsets(source) : undefined;
67
+ const entries: LineCoverage[] = [];
68
+
69
+ for (const fn of functions) {
70
+ const range = fn.ranges[0];
71
+ if (!range) continue;
72
+
73
+ if (lineOffsets && url) {
74
+ entries.push({
75
+ startLine: offsetToLine(range.startOffset, lineOffsets),
76
+ functionName: fn.functionName,
77
+ count: range.count,
78
+ });
79
+ }
80
+
81
+ if (fn.functionName && range.count > 0) {
82
+ const prev = byName.get(fn.functionName) ?? 0;
83
+ byName.set(fn.functionName, prev + range.count);
84
+ }
85
+ }
86
+
87
+ if (entries.length > 0 && url) map.set(url, entries);
88
+ }
89
+
90
+ /** Match a frame to a coverage entry by function name (or closest line for anonymous). */
91
+ function findCount(
92
+ frameName: string,
93
+ frameLine: number | undefined,
94
+ entries: LineCoverage[],
95
+ ): number | undefined {
96
+ const isAnon =
97
+ frameName === "(anonymous)" || frameName.startsWith("(anonymous ");
98
+
99
+ if (isAnon) {
100
+ if (!frameLine) return undefined;
101
+ const anonymous = entries.filter(e => e.functionName === "");
102
+ return closestByLine(anonymous, frameLine)?.count;
103
+ }
104
+
105
+ const nameMatches = entries.filter(e => e.functionName === frameName);
106
+ if (nameMatches.length === 0) return undefined;
107
+ if (nameMatches.length === 1) return nameMatches[0].count;
108
+ if (frameLine) return closestByLine(nameMatches, frameLine)?.count;
109
+ return nameMatches[0].count;
110
+ }
111
+
112
+ /** Format a count for display (e.g. 1234567 ==> "1.2M"). */
113
+ function formatCount(n: number): string {
114
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
115
+ if (n >= 10_000) return `${(n / 1_000).toFixed(1)}K`;
116
+ return String(n);
117
+ }
118
+
119
+ /** Build array where index i is the character offset where line (i+1) starts. */
120
+ function buildLineOffsets(source: string): number[] {
121
+ const offsets = [0]; // line 1 starts at offset 0
122
+ for (let i = 0; i < source.length; i++) {
123
+ if (source[i] === "\n") offsets.push(i + 1);
124
+ }
125
+ return offsets;
126
+ }
127
+
128
+ /** Convert character offset to 1-indexed line number via binary search. */
129
+ function offsetToLine(offset: number, lineOffsets: number[]): number {
130
+ let lo = 0;
131
+ let hi = lineOffsets.length - 1;
132
+ while (lo < hi) {
133
+ const mid = (lo + hi + 1) >> 1;
134
+ if (lineOffsets[mid] <= offset) lo = mid;
135
+ else hi = mid - 1;
136
+ }
137
+ return lo + 1; // 1-indexed
138
+ }
139
+
140
+ /** Find the entry whose startLine is closest to the given line. */
141
+ function closestByLine(
142
+ entries: LineCoverage[],
143
+ line: number,
144
+ ): LineCoverage | undefined {
145
+ if (!entries.length) return undefined;
146
+ const dist = (e: LineCoverage) => Math.abs(e.startLine - line);
147
+ return entries.reduce((best, e) => (dist(e) < dist(best) ? e : best));
148
+ }
@@ -0,0 +1,10 @@
1
+ const presets: Record<string, string> = {
2
+ vscode: "vscode://file",
3
+ cursor: "cursor://file",
4
+ };
5
+
6
+ /** Resolve editor name or custom URI to a prefix.
7
+ * Links are formatted as `{prefix}{absolutePath}:{line}:{col}` */
8
+ export function resolveEditorUri(editor: string): string {
9
+ return presets[editor] ?? editor;
10
+ }
@@ -1,28 +1,19 @@
1
+ /** Export benchmark samples to Chrome Trace Event format for viewing in Perfetto. */
2
+
1
3
  import { spawn } from "node:child_process";
2
4
  import { readdirSync, readFileSync, writeFileSync } from "node:fs";
3
5
  import { resolve } from "node:path";
4
- import type { ReportGroup } from "../BenchmarkReport.ts";
5
- import type { DefaultCliArgs } from "../cli/CliArgs.ts";
6
- import type { MeasuredResults } from "../MeasuredResults.ts";
7
-
8
- /** Chrome Trace Event format event */
9
- interface TraceEvent {
10
- ph: string; // event type: M=metadata, C=counter, i=instant, B/E=begin/end
11
- ts: number; // timestamp in microseconds
12
- pid?: number;
13
- tid?: number;
14
- cat?: string;
15
- name: string;
16
- args?: Record<string, unknown>;
17
- s?: string; // scope for instant events: "t"=thread, "p"=process, "g"=global
18
- dur?: number; // duration for complete events
19
- }
6
+ import { cleanCliArgs, type DefaultCliArgs } from "../cli/CliArgs.ts";
7
+ import type { TraceEvent } from "../profiling/browser/ChromeTraceEvent.ts";
8
+ import type { ReportGroup } from "../report/BenchmarkReport.ts";
9
+ import type { MeasuredResults } from "../runners/MeasuredResults.ts";
20
10
 
21
- /** Chrome Trace Event format file structure */
22
11
  interface TraceFile {
23
12
  traceEvents: TraceEvent[];
24
13
  }
25
14
 
15
+ type Args = Record<string, unknown>;
16
+
26
17
  const pid = 1;
27
18
  const tid = 1;
28
19
 
@@ -35,65 +26,84 @@ export function exportPerfettoTrace(
35
26
  const absPath = resolve(outputPath);
36
27
  const events = buildTraceEvents(groups, args);
37
28
 
38
- // Try to merge any existing V8 trace from a previous run
39
29
  const merged = mergeV8Trace(events);
40
- writeTraceFile(absPath, merged);
30
+ const traceFile: TraceFile = { traceEvents: merged };
31
+ writeFileSync(absPath, JSON.stringify(traceFile));
41
32
  console.log(`Perfetto trace exported to: ${outputPath}`);
42
33
 
43
- // V8 writes trace files after process exit, so spawn a child to merge later
44
34
  scheduleDeferredMerge(absPath);
45
35
  }
46
36
 
47
- /** Build trace events from benchmark results */
48
37
  function buildTraceEvents(
49
38
  groups: ReportGroup[],
50
- args: DefaultCliArgs,
39
+ cliArgs: DefaultCliArgs,
51
40
  ): TraceEvent[] {
52
- const meta = (name: string, a: Record<string, unknown>): TraceEvent => ({
53
- ph: "M",
54
- ts: 0,
55
- pid,
56
- tid,
57
- name,
58
- args: a,
59
- });
60
- const events: TraceEvent[] = [
41
+ const metadata: TraceEvent[] = [
61
42
  meta("process_name", { name: "wesl-bench" }),
62
43
  meta("thread_name", { name: "MainThread" }),
63
- meta("bench_settings", cleanArgs(args)),
44
+ meta("bench_settings", cleanCliArgs(cliArgs)),
64
45
  ];
65
46
 
66
- for (const group of groups) {
67
- for (const report of group.reports) {
68
- const results = report.measuredResults as MeasuredResults;
69
- events.push(...buildBenchmarkEvents(results));
70
- }
71
- }
47
+ const benchEvents = groups.flatMap(group =>
48
+ group.reports.flatMap(report =>
49
+ buildBenchmarkEvents(report.measuredResults as MeasuredResults),
50
+ ),
51
+ );
72
52
 
73
- return events;
53
+ return [...metadata, ...benchEvents];
74
54
  }
75
55
 
76
- function instant(
77
- ts: number,
78
- name: string,
79
- args: Record<string, unknown>,
80
- ): TraceEvent {
81
- return { ph: "i", ts, pid, tid, cat: "bench", name, s: "t", args };
56
+ function mergeV8Trace(events: TraceEvent[]): TraceEvent[] {
57
+ const v8TracePath = readdirSync(".").find(
58
+ f => f.startsWith("node_trace.") && f.endsWith(".log"),
59
+ );
60
+ const v8Events = loadV8Events(v8TracePath);
61
+ const merged = v8Events ? [...v8Events, ...events] : events;
62
+ normalizeTimestamps(merged);
63
+ return merged;
82
64
  }
83
65
 
84
- function counter(
85
- ts: number,
86
- name: string,
87
- args: Record<string, unknown>,
88
- ): TraceEvent {
89
- return { ph: "C", ts, pid, tid, cat: "bench", name, args };
66
+ /** V8 writes trace files after process exit, so we spawn a deferred merge. */
67
+ function scheduleDeferredMerge(outputPath: string): void {
68
+ const cwd = process.cwd();
69
+ const mergeScript = `
70
+ const { readdirSync, readFileSync, writeFileSync } = require('fs');
71
+ function normalize(events) {
72
+ let min = Infinity;
73
+ for (const e of events) if (e.ts > 0 && e.ts < min) min = e.ts;
74
+ if (min === Infinity) return;
75
+ for (const e of events) if (e.ts > 0) e.ts -= min;
76
+ }
77
+ setTimeout(() => {
78
+ const traceFiles = readdirSync('.').filter(f => f.startsWith('node_trace.') && f.endsWith('.log'));
79
+ if (traceFiles.length === 0) process.exit(0);
80
+ try {
81
+ const v8Data = JSON.parse(readFileSync(traceFiles[0], 'utf-8'));
82
+ const ourData = JSON.parse(readFileSync('${outputPath}', 'utf-8'));
83
+ const allEvents = [...v8Data.traceEvents, ...ourData.traceEvents];
84
+ normalize(allEvents);
85
+ writeFileSync('${outputPath}', JSON.stringify({ traceEvents: allEvents }));
86
+ console.log('Merged ' + v8Data.traceEvents.length + ' V8 events into ' + '${outputPath}');
87
+ } catch (e) { console.error('Merge failed:', e.message); }
88
+ }, 100);
89
+ `;
90
+
91
+ process.on("exit", () => {
92
+ const opts = { detached: true, stdio: "inherit" as const, cwd };
93
+ spawn("node", ["-e", mergeScript], opts).unref();
94
+ });
95
+ }
96
+
97
+ function meta(name: string, args: Args): TraceEvent {
98
+ return { ph: "M", ts: 0, pid, tid, name, args };
90
99
  }
91
100
 
92
- /** Build events for a single benchmark run */
101
+ /** Build events for a single benchmark run, deriving timestamps from cumulative sample durations. */
93
102
  function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
94
- const { samples, heapSamples, timestamps, pausePoints } = results;
95
- if (!timestamps?.length) return [];
103
+ const { samples, heapSamples, pausePoints, startTime = 0 } = results;
104
+ if (!samples?.length) return [];
96
105
 
106
+ const timestamps = cumulativeTimestamps(samples, startTime);
97
107
  const events: TraceEvent[] = [];
98
108
  for (let i = 0; i < samples.length; i++) {
99
109
  const ts = timestamps[i];
@@ -101,8 +111,8 @@ function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
101
111
  events.push(instant(ts, results.name, { n: i, ms }));
102
112
  events.push(counter(ts, "duration", { ms }));
103
113
  if (heapSamples?.[i] !== undefined) {
104
- const MB = Math.round((heapSamples[i] / 1024 / 1024) * 10) / 10;
105
- events.push(counter(ts, "heap", { MB }));
114
+ const mb = Math.round((heapSamples[i] / 1024 / 1024) * 10) / 10;
115
+ events.push(counter(ts, "heap", { MB: mb }));
106
116
  }
107
117
  }
108
118
 
@@ -113,91 +123,46 @@ function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
113
123
  return events;
114
124
  }
115
125
 
116
- /** Normalize timestamps so events start at 0 */
117
- function normalizeTimestamps(events: TraceEvent[]): void {
118
- const times = events.filter(e => e.ts > 0).map(e => e.ts);
119
- if (times.length === 0) return;
120
- const minTs = Math.min(...times);
121
- for (const e of events) if (e.ts > 0) e.ts -= minTs;
122
- }
123
-
124
- /** Merge V8 trace events from a previous run, aligning timestamps */
125
- function mergeV8Trace(customEvents: TraceEvent[]): TraceEvent[] {
126
- const traceFiles = readdirSync(".").filter(
127
- f => f.startsWith("node_trace.") && f.endsWith(".log"),
128
- );
129
-
130
- const v8Events = loadV8Events(traceFiles[0]);
131
- normalizeTimestamps(customEvents);
132
- if (!v8Events) return customEvents;
133
-
134
- normalizeTimestamps(v8Events);
135
- return [...v8Events, ...customEvents];
136
- }
137
-
138
- /** Load V8 trace events from file, or undefined if unavailable */
139
126
  function loadV8Events(
140
127
  v8TracePath: string | undefined,
141
128
  ): TraceEvent[] | undefined {
142
129
  if (!v8TracePath) return undefined;
143
130
  try {
144
131
  const v8Data = JSON.parse(readFileSync(v8TracePath, "utf-8")) as TraceFile;
145
- console.log(
146
- `Merged ${v8Data.traceEvents.length} V8 events from ${v8TracePath}`,
147
- );
148
- return v8Data.traceEvents;
132
+ const { traceEvents } = v8Data;
133
+ console.log(`Merged ${traceEvents.length} V8 events from ${v8TracePath}`);
134
+ return traceEvents;
149
135
  } catch {
150
136
  console.warn(`Could not parse V8 trace file: ${v8TracePath}`);
151
137
  return undefined;
152
138
  }
153
139
  }
154
140
 
155
- /** Write trace events to JSON file */
156
- function writeTraceFile(outputPath: string, events: TraceEvent[]): void {
157
- const traceFile: TraceFile = { traceEvents: events };
158
- writeFileSync(outputPath, JSON.stringify(traceFile));
141
+ /** Normalize timestamps so events start at 0 */
142
+ function normalizeTimestamps(events: TraceEvent[]): void {
143
+ let min = Number.POSITIVE_INFINITY;
144
+ for (const e of events) if (e.ts > 0 && e.ts < min) min = e.ts;
145
+ if (min === Number.POSITIVE_INFINITY) return;
146
+ for (const e of events) if (e.ts > 0) e.ts -= min;
159
147
  }
160
148
 
161
- /** Clean CLI args for metadata */
162
- function cleanArgs(args: DefaultCliArgs): Record<string, unknown> {
163
- const skip = new Set(["_", "$0"]);
164
- const entries = Object.entries(args).filter(
165
- ([k, v]) => v !== undefined && !skip.has(k),
166
- );
167
- return Object.fromEntries(entries);
149
+ /** Derive μs timestamps from cumulative sample durations (ms), offset by startTime. */
150
+ function cumulativeTimestamps(samples: number[], offset = 0): number[] {
151
+ const timestamps = new Array<number>(samples.length);
152
+ let cumulative = 0;
153
+ for (let i = 0; i < samples.length; i++) {
154
+ cumulative += samples[i];
155
+ timestamps[i] = offset + Math.round(cumulative * 1000); // ms ==> μs
156
+ }
157
+ return timestamps;
168
158
  }
169
159
 
170
- /** Spawn a detached child to merge V8 trace after process exit */
171
- function scheduleDeferredMerge(outputPath: string): void {
172
- const cwd = process.cwd();
173
- const mergeScript = `
174
- const { readdirSync, readFileSync, writeFileSync } = require('fs');
175
- function normalize(events) {
176
- const times = events.filter(e => e.ts > 0).map(e => e.ts);
177
- if (!times.length) return;
178
- const min = Math.min(...times);
179
- for (const e of events) if (e.ts > 0) e.ts -= min;
180
- }
181
- setTimeout(() => {
182
- const traceFiles = readdirSync('.').filter(f => f.startsWith('node_trace.') && f.endsWith('.log'));
183
- if (traceFiles.length === 0) process.exit(0);
184
- try {
185
- const v8Data = JSON.parse(readFileSync(traceFiles[0], 'utf-8'));
186
- const ourData = JSON.parse(readFileSync('${outputPath}', 'utf-8'));
187
- normalize(v8Data.traceEvents);
188
- const merged = { traceEvents: [...v8Data.traceEvents, ...ourData.traceEvents] };
189
- writeFileSync('${outputPath}', JSON.stringify(merged));
190
- console.log('Merged ' + v8Data.traceEvents.length + ' V8 events into ' + '${outputPath}');
191
- } catch (e) { console.error('Merge failed:', e.message); }
192
- }, 100);
193
- `;
160
+ /** Create a thread-scoped instant event */
161
+ function instant(ts: number, name: string, args: Args): TraceEvent {
162
+ return { ph: "i", ts, pid, tid, cat: "bench", name, s: "t", args };
163
+ }
194
164
 
195
- process.on("exit", () => {
196
- const child = spawn("node", ["-e", mergeScript], {
197
- detached: true,
198
- stdio: "inherit",
199
- cwd,
200
- });
201
- child.unref();
202
- });
165
+ /** Create a counter event (shown as a time-series chart in Perfetto) */
166
+ function counter(ts: number, name: string, args: Args): TraceEvent {
167
+ return { ph: "C", ts, pid, tid, cat: "bench", name, args };
203
168
  }
@@ -0,0 +1,98 @@
1
+ /** Shared speedscope file format types and frame interning utilities. */
2
+
3
+ /** speedscope file format (https://www.speedscope.app/file-format-schema.json) */
4
+ export interface SpeedscopeFile {
5
+ $schema: "https://www.speedscope.app/file-format-schema.json";
6
+ shared: { frames: SpeedscopeFrame[] };
7
+ profiles: SpeedscopeProfile[];
8
+ name?: string;
9
+ exporter?: string;
10
+ }
11
+
12
+ /** A single call frame with optional source location */
13
+ export interface SpeedscopeFrame {
14
+ name: string;
15
+ file?: string;
16
+ line?: number;
17
+ col?: number;
18
+ }
19
+
20
+ /** Union of heap and time profile shapes (unit differs) */
21
+ export type SpeedscopeProfile = SpeedscopeHeapProfile | SpeedscopeTimeProfile;
22
+
23
+ /** Heap allocation profile weighted by bytes */
24
+ export interface SpeedscopeHeapProfile {
25
+ type: "sampled";
26
+ name: string;
27
+ unit: "bytes";
28
+ startValue: number;
29
+ endValue: number;
30
+ samples: number[][];
31
+ weights: number[];
32
+ }
33
+
34
+ /** CPU time profile weighted by microseconds */
35
+ export interface SpeedscopeTimeProfile {
36
+ type: "sampled";
37
+ name: string;
38
+ unit: "microseconds";
39
+ startValue: number;
40
+ endValue: number;
41
+ samples: number[][];
42
+ weights: number[];
43
+ }
44
+
45
+ /** Shared mutable state for frame interning across profiles. */
46
+ export interface FrameContext {
47
+ frames: SpeedscopeFrame[];
48
+ index: Map<string, number>;
49
+ }
50
+
51
+ /** Create an empty FrameContext for building speedscope profiles. */
52
+ export function frameContext(): FrameContext {
53
+ return { frames: [], index: new Map() };
54
+ }
55
+
56
+ /** Wrap profiles in a SpeedscopeFile envelope */
57
+ export function speedscopeFile(
58
+ ctx: FrameContext,
59
+ profiles: SpeedscopeProfile[],
60
+ ): SpeedscopeFile {
61
+ return {
62
+ $schema: "https://www.speedscope.app/file-format-schema.json",
63
+ shared: { frames: ctx.frames },
64
+ profiles,
65
+ exporter: "benchforge",
66
+ };
67
+ }
68
+
69
+ /** Intern a call frame, returning its index in the shared frames array.
70
+ * All values should be 1-indexed (caller converts from V8's 0-indexed if needed). */
71
+ export function internFrame(
72
+ name: string,
73
+ url: string,
74
+ line: number,
75
+ col: number | undefined | null,
76
+ ctx: FrameContext,
77
+ ): number {
78
+ const key = `${name}\0${url}\0${line}\0${col}`;
79
+
80
+ const existing = ctx.index.get(key);
81
+ if (existing !== undefined) return existing;
82
+
83
+ const idx = ctx.frames.length;
84
+ const entry: SpeedscopeFrame = { name: displayName(name, url, line) };
85
+ if (url) entry.file = url;
86
+ if (line > 0) entry.line = line;
87
+ if (col != null) entry.col = col;
88
+ ctx.frames.push(entry);
89
+ ctx.index.set(key, idx);
90
+ return idx;
91
+ }
92
+
93
+ /** Display name for a frame: named functions use their name, anonymous get a location hint */
94
+ function displayName(name: string, url: string, line: number): string {
95
+ if (name !== "(anonymous)") return name;
96
+ const file = url?.split("/").pop();
97
+ return file ? `(anonymous ${file}:${line})` : "(anonymous)";
98
+ }