benchforge 0.1.11 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +99 -294
  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-BzyUfiyB.d.mts → BenchRunner-DglX1NOn.d.mts} +119 -66
  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 +711 -558
  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 +77 -105
  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 -27
  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 -51
  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 +132 -866
  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 +64 -99
  78. package/src/export/SpeedscopeTypes.ts +98 -0
  79. package/src/export/TimeExport.ts +115 -0
  80. package/src/index.ts +86 -67
  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 +49 -47
  85. package/src/matrix/MatrixInlineRunner.ts +50 -0
  86. package/src/matrix/MatrixReport.ts +90 -250
  87. package/src/matrix/VariantLoader.ts +5 -5
  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 +1 -2
  101. package/src/{heap-sample → profiling/node}/ResolvedProfile.ts +18 -9
  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 +116 -236
  115. package/src/runners/BenchRunner.ts +20 -15
  116. package/src/{Benchmark.ts → runners/BenchmarkSpec.ts} +5 -6
  117. package/src/runners/CreateRunner.ts +5 -7
  118. package/src/runners/GcStats.ts +47 -50
  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 +127 -243
  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 +135 -151
  128. package/src/stats/BootstrapDifference.ts +282 -0
  129. package/src/{PermutationTest.ts → stats/PermutationTest.ts} +8 -17
  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 +2 -2
  135. package/src/{tests → test}/BenchMatrix.test.ts +19 -16
  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 +14 -14
  144. package/src/{tests → test}/MatrixFilter.test.ts +1 -1
  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 +39 -38
  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 +12 -7
  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 +33 -38
  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/BrowserHeapSampler-B6asLKWQ.mjs +0 -202
  205. package/dist/BrowserHeapSampler-B6asLKWQ.mjs.map +0 -1
  206. package/dist/GcStats-wX7Xyblu.mjs +0 -77
  207. package/dist/GcStats-wX7Xyblu.mjs.map +0 -1
  208. package/dist/HeapSampler-B8dtKHn1.mjs.map +0 -1
  209. package/dist/TimingUtils-DwOwkc8G.mjs +0 -597
  210. package/dist/TimingUtils-DwOwkc8G.mjs.map +0 -1
  211. package/dist/browser/index.js +0 -914
  212. package/dist/src-B-DDaCa9.mjs +0 -3108
  213. package/dist/src-B-DDaCa9.mjs.map +0 -1
  214. package/src/BenchMatrix.ts +0 -380
  215. package/src/BenchmarkReport.ts +0 -161
  216. package/src/HtmlDataPrep.ts +0 -148
  217. package/src/StandardSections.ts +0 -261
  218. package/src/StatisticalUtils.ts +0 -175
  219. package/src/TypeUtil.ts +0 -8
  220. package/src/browser/BrowserGcStats.ts +0 -44
  221. package/src/browser/BrowserHeapSampler.ts +0 -271
  222. package/src/export/JsonExport.ts +0 -103
  223. package/src/export/JsonFormat.ts +0 -91
  224. package/src/export/SpeedscopeExport.ts +0 -202
  225. package/src/heap-sample/HeapSampleReport.ts +0 -269
  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 -157
  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 +0 -0
@@ -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,73 +26,52 @@ 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
- /** Merge V8 trace events from a previous run, aligning timestamps */
77
- function mergeV8Trace(customEvents: TraceEvent[]): TraceEvent[] {
78
- const traceFiles = readdirSync(".").filter(
56
+ function mergeV8Trace(events: TraceEvent[]): TraceEvent[] {
57
+ const v8TracePath = readdirSync(".").find(
79
58
  f => f.startsWith("node_trace.") && f.endsWith(".log"),
80
59
  );
81
-
82
- const v8Events = loadV8Events(traceFiles[0]);
83
- normalizeTimestamps(customEvents);
84
- if (!v8Events) return customEvents;
85
-
86
- normalizeTimestamps(v8Events);
87
- return [...v8Events, ...customEvents];
60
+ const v8Events = loadV8Events(v8TracePath);
61
+ const merged = v8Events ? [...v8Events, ...events] : events;
62
+ normalizeTimestamps(merged);
63
+ return merged;
88
64
  }
89
65
 
90
- /** Write trace events to JSON file */
91
- function writeTraceFile(outputPath: string, events: TraceEvent[]): void {
92
- const traceFile: TraceFile = { traceEvents: events };
93
- writeFileSync(outputPath, JSON.stringify(traceFile));
94
- }
95
-
96
- /** Spawn a detached child to merge V8 trace after process exit */
66
+ /** V8 writes trace files after process exit, so we spawn a deferred merge. */
97
67
  function scheduleDeferredMerge(outputPath: string): void {
98
68
  const cwd = process.cwd();
99
69
  const mergeScript = `
100
70
  const { readdirSync, readFileSync, writeFileSync } = require('fs');
101
71
  function normalize(events) {
102
- const times = events.filter(e => e.ts > 0).map(e => e.ts);
103
- if (!times.length) return;
104
- const min = Math.min(...times);
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;
105
75
  for (const e of events) if (e.ts > 0) e.ts -= min;
106
76
  }
107
77
  setTimeout(() => {
@@ -110,38 +80,30 @@ function scheduleDeferredMerge(outputPath: string): void {
110
80
  try {
111
81
  const v8Data = JSON.parse(readFileSync(traceFiles[0], 'utf-8'));
112
82
  const ourData = JSON.parse(readFileSync('${outputPath}', 'utf-8'));
113
- normalize(v8Data.traceEvents);
114
- const merged = { traceEvents: [...v8Data.traceEvents, ...ourData.traceEvents] };
115
- writeFileSync('${outputPath}', JSON.stringify(merged));
83
+ const allEvents = [...v8Data.traceEvents, ...ourData.traceEvents];
84
+ normalize(allEvents);
85
+ writeFileSync('${outputPath}', JSON.stringify({ traceEvents: allEvents }));
116
86
  console.log('Merged ' + v8Data.traceEvents.length + ' V8 events into ' + '${outputPath}');
117
87
  } catch (e) { console.error('Merge failed:', e.message); }
118
88
  }, 100);
119
89
  `;
120
90
 
121
91
  process.on("exit", () => {
122
- const child = spawn("node", ["-e", mergeScript], {
123
- detached: true,
124
- stdio: "inherit",
125
- cwd,
126
- });
127
- child.unref();
92
+ const opts = { detached: true, stdio: "inherit" as const, cwd };
93
+ spawn("node", ["-e", mergeScript], opts).unref();
128
94
  });
129
95
  }
130
96
 
131
- /** Clean CLI args for metadata */
132
- function cleanArgs(args: DefaultCliArgs): Record<string, unknown> {
133
- const skip = new Set(["_", "$0"]);
134
- const entries = Object.entries(args).filter(
135
- ([k, v]) => v !== undefined && !skip.has(k),
136
- );
137
- return Object.fromEntries(entries);
97
+ function meta(name: string, args: Args): TraceEvent {
98
+ return { ph: "M", ts: 0, pid, tid, name, args };
138
99
  }
139
100
 
140
- /** Build events for a single benchmark run */
101
+ /** Build events for a single benchmark run, deriving timestamps from cumulative sample durations. */
141
102
  function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
142
- const { samples, heapSamples, timestamps, pausePoints } = results;
143
- if (!timestamps?.length) return [];
103
+ const { samples, heapSamples, pausePoints, startTime = 0 } = results;
104
+ if (!samples?.length) return [];
144
105
 
106
+ const timestamps = cumulativeTimestamps(samples, startTime);
145
107
  const events: TraceEvent[] = [];
146
108
  for (let i = 0; i < samples.length; i++) {
147
109
  const ts = timestamps[i];
@@ -149,8 +111,8 @@ function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
149
111
  events.push(instant(ts, results.name, { n: i, ms }));
150
112
  events.push(counter(ts, "duration", { ms }));
151
113
  if (heapSamples?.[i] !== undefined) {
152
- const MB = Math.round((heapSamples[i] / 1024 / 1024) * 10) / 10;
153
- 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 }));
154
116
  }
155
117
  }
156
118
 
@@ -161,17 +123,15 @@ function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
161
123
  return events;
162
124
  }
163
125
 
164
- /** Load V8 trace events from file, or undefined if unavailable */
165
126
  function loadV8Events(
166
127
  v8TracePath: string | undefined,
167
128
  ): TraceEvent[] | undefined {
168
129
  if (!v8TracePath) return undefined;
169
130
  try {
170
131
  const v8Data = JSON.parse(readFileSync(v8TracePath, "utf-8")) as TraceFile;
171
- console.log(
172
- `Merged ${v8Data.traceEvents.length} V8 events from ${v8TracePath}`,
173
- );
174
- return v8Data.traceEvents;
132
+ const { traceEvents } = v8Data;
133
+ console.log(`Merged ${traceEvents.length} V8 events from ${v8TracePath}`);
134
+ return traceEvents;
175
135
  } catch {
176
136
  console.warn(`Could not parse V8 trace file: ${v8TracePath}`);
177
137
  return undefined;
@@ -180,24 +140,29 @@ function loadV8Events(
180
140
 
181
141
  /** Normalize timestamps so events start at 0 */
182
142
  function normalizeTimestamps(events: TraceEvent[]): void {
183
- const times = events.filter(e => e.ts > 0).map(e => e.ts);
184
- if (times.length === 0) return;
185
- const minTs = Math.min(...times);
186
- for (const e of events) if (e.ts > 0) e.ts -= minTs;
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;
147
+ }
148
+
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;
187
158
  }
188
159
 
189
- function instant(
190
- ts: number,
191
- name: string,
192
- args: Record<string, unknown>,
193
- ): TraceEvent {
160
+ /** Create a thread-scoped instant event */
161
+ function instant(ts: number, name: string, args: Args): TraceEvent {
194
162
  return { ph: "i", ts, pid, tid, cat: "bench", name, s: "t", args };
195
163
  }
196
164
 
197
- function counter(
198
- ts: number,
199
- name: string,
200
- args: Record<string, unknown>,
201
- ): TraceEvent {
165
+ /** Create a counter event (shown as a time-series chart in Perfetto) */
166
+ function counter(ts: number, name: string, args: Args): TraceEvent {
202
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
+ }
@@ -0,0 +1,115 @@
1
+ /** CPU time profile conversion to Speedscope sampled format. */
2
+
3
+ import { resolveCallFrame } from "../profiling/node/ResolvedProfile.ts";
4
+ import type {
5
+ TimeProfile,
6
+ TimeProfileNode,
7
+ } from "../profiling/node/TimeSampler.ts";
8
+ import {
9
+ type FrameContext,
10
+ frameContext,
11
+ internFrame,
12
+ type SpeedscopeFile,
13
+ type SpeedscopeTimeProfile,
14
+ speedscopeFile,
15
+ } from "./SpeedscopeTypes.ts";
16
+
17
+ /** Convert a TimeProfile to speedscope format */
18
+ export function timeProfileToSpeedscope(
19
+ name: string,
20
+ profile: TimeProfile,
21
+ ): SpeedscopeFile {
22
+ const ctx = frameContext();
23
+ const p = buildTimeProfile(name, profile, ctx);
24
+ return speedscopeFile(ctx, [p]);
25
+ }
26
+
27
+ /** Build a SpeedscopeFile from multiple named time profiles (shared frames). */
28
+ export function buildTimeSpeedscopeFile(
29
+ entries: { name: string; profile: TimeProfile }[],
30
+ ): SpeedscopeFile | undefined {
31
+ if (entries.length === 0) return undefined;
32
+
33
+ const ctx = frameContext();
34
+ const profiles = entries.map(e => buildTimeProfile(e.name, e.profile, ctx));
35
+ return speedscopeFile(ctx, profiles);
36
+ }
37
+
38
+ /** Build a speedscope profile from a V8 TimeProfile */
39
+ function buildTimeProfile(
40
+ name: string,
41
+ profile: TimeProfile,
42
+ ctx: FrameContext,
43
+ ): SpeedscopeTimeProfile {
44
+ const { samples: sampleIds, timeDeltas, nodes } = profile;
45
+
46
+ if (!sampleIds?.length || !timeDeltas) {
47
+ return {
48
+ type: "sampled",
49
+ name,
50
+ unit: "microseconds",
51
+ startValue: 0,
52
+ endValue: 0,
53
+ samples: [],
54
+ weights: [],
55
+ };
56
+ }
57
+
58
+ const nodeMap = new Map<number, TimeProfileNode>(nodes.map(n => [n.id, n]));
59
+ const parentMap = new Map<number, number>(); // childId -> parentId
60
+ for (const node of nodes) {
61
+ for (const childId of node.children ?? []) {
62
+ parentMap.set(childId, node.id);
63
+ }
64
+ }
65
+
66
+ const cache = new Map<number, number[]>();
67
+ const resolve = (id: number) =>
68
+ resolveStack(id, nodeMap, parentMap, cache, ctx);
69
+
70
+ const samples = sampleIds.map(resolve);
71
+ const total = timeDeltas.reduce((sum, w) => sum + w, 0);
72
+ return {
73
+ type: "sampled",
74
+ name,
75
+ unit: "microseconds",
76
+ startValue: 0,
77
+ endValue: total,
78
+ samples,
79
+ weights: timeDeltas,
80
+ };
81
+ }
82
+
83
+ /** Walk from node to root, building a stack of frame indices (root-first) */
84
+ function resolveStack(
85
+ nodeId: number,
86
+ nodeMap: Map<number, TimeProfileNode>,
87
+ parentMap: Map<number, number>,
88
+ cache: Map<number, number[]>,
89
+ ctx: FrameContext,
90
+ ): number[] {
91
+ const cached = cache.get(nodeId);
92
+ if (cached) return cached;
93
+
94
+ const path: number[] = [];
95
+ let current: number | undefined = nodeId;
96
+ while (current !== undefined) {
97
+ path.push(current);
98
+ current = parentMap.get(current);
99
+ }
100
+
101
+ // Reverse to root-first order
102
+ const stack: number[] = [];
103
+ for (let i = path.length - 1; i >= 0; i--) {
104
+ const node = nodeMap.get(path[i]);
105
+ if (!node) continue;
106
+ const { functionName, url, lineNumber } = node.callFrame;
107
+ // Skip the synthetic (root) node
108
+ if (!functionName && !url && lineNumber <= 0) continue;
109
+ const frame = resolveCallFrame(node.callFrame);
110
+ stack.push(internFrame(frame.name, frame.url, frame.line, frame.col, ctx));
111
+ }
112
+
113
+ cache.set(nodeId, stack);
114
+ return stack;
115
+ }