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,313 @@
1
+ import type {
2
+ CILevel,
3
+ DifferenceCI,
4
+ HistogramBin,
5
+ } from "../../stats/StatisticalUtils.ts";
6
+ import { formatPct } from "./PlotTypes.ts";
7
+ import {
8
+ createSvg,
9
+ ensureHatchPattern,
10
+ ensureSketchFilter,
11
+ line,
12
+ path,
13
+ rect,
14
+ text,
15
+ } from "./SvgHelpers.ts";
16
+
17
+ export interface DistributionPlotOptions {
18
+ width?: number;
19
+ height?: number;
20
+ title?: string;
21
+ smooth?: boolean;
22
+ direction?: "faster" | "slower" | "uncertain" | "equivalent";
23
+ /** Pre-formatted CI bound labels (overrides default formatPct) */
24
+ ciLabels?: [string, string];
25
+ /** Include zero in x scale (default true, set false for absolute-value plots) */
26
+ includeZero?: boolean;
27
+ /** Centered label above chart (e.g., the formatted point estimate) */
28
+ pointLabel?: string;
29
+ /** Equivalence margin in percent (draws shaded band at +/- margin) */
30
+ equivMargin?: number;
31
+ /** Block-level or sample-level CI */
32
+ ciLevel?: CILevel;
33
+ /** false ==> dashed border (insufficient batches for reliable CI) */
34
+ ciReliable?: boolean;
35
+ }
36
+
37
+ type Scales = { x: (v: number) => number; y: (v: number) => number };
38
+ type Layout = {
39
+ width: number;
40
+ height: number;
41
+ margin: { top: number; right: number; bottom: number; left: number };
42
+ plot: { w: number; h: number };
43
+ };
44
+ const defaultMargin = { top: 22, right: 12, bottom: 22, left: 12 };
45
+
46
+ const defaultOpts = {
47
+ width: 260,
48
+ height: 85,
49
+ title: "Δ%",
50
+ smooth: true,
51
+ direction: "uncertain" as const,
52
+ includeZero: true,
53
+ };
54
+
55
+ const colors = {
56
+ faster: { fill: "#bbf7d0", stroke: "#22c55e" },
57
+ slower: { fill: "#fee2e2", stroke: "#ef4444" },
58
+ uncertain: { fill: "#dbeafe", stroke: "#3b82f6" },
59
+ equivalent: { fill: "#dcfce7", stroke: "#86efac" },
60
+ };
61
+
62
+ /** Create a small distribution plot showing histogram with CI shading */
63
+ export function createDistributionPlot(
64
+ histogram: HistogramBin[],
65
+ ci: [number, number],
66
+ pointEstimate: number,
67
+ options: DistributionPlotOptions = {},
68
+ ): SVGSVGElement {
69
+ const opts = { ...defaultOpts, ...options };
70
+ const layout = buildLayout(opts.width, opts.height, !!opts.pointLabel);
71
+ const svg = createSvg(layout.width, layout.height);
72
+ if (!histogram?.length) return svg;
73
+
74
+ const { fill, stroke } = colors[opts.direction];
75
+ const { includeZero, equivMargin } = opts;
76
+ const scales = buildScales(
77
+ histogram,
78
+ ci,
79
+ layout,
80
+ includeZero,
81
+ equivMargin,
82
+ pointEstimate,
83
+ );
84
+ const { margin, plot } = layout;
85
+ const ptX = scales.x(pointEstimate);
86
+
87
+ drawTitles(svg, opts, margin, ptX);
88
+
89
+ if (equivMargin && includeZero)
90
+ drawMarginZone(svg, equivMargin, scales, layout);
91
+
92
+ const ciX = scales.x(ci[0]);
93
+ const ciRect = rect(ciX, margin.top, scales.x(ci[1]) - ciX, plot.h, { fill });
94
+ const strength = includeZero ? "ci-region-strong" : "ci-region";
95
+ ciRect.classList.add(strength, `ci-${opts.direction}`);
96
+ if (opts.ciReliable === false) {
97
+ ciRect.classList.add("ci-unreliable");
98
+ ciRect.setAttribute("filter", `url(#${ensureSketchFilter(svg)})`);
99
+ }
100
+ svg.appendChild(ciRect);
101
+
102
+ if (opts.smooth) drawSmoothedDist(svg, histogram, scales, stroke);
103
+ else drawHistogramBars(svg, histogram, scales, layout, stroke);
104
+
105
+ drawReferenceLine(svg, scales, layout, includeZero);
106
+ svg.appendChild(
107
+ line(ptX, margin.top, ptX, margin.top + plot.h, {
108
+ stroke,
109
+ strokeWidth: "2",
110
+ }),
111
+ );
112
+
113
+ drawCILabels(svg, ci, scales, layout, opts);
114
+ return svg;
115
+ }
116
+
117
+ /** Convenience wrapper for DifferenceCI data */
118
+ export function createCIPlot(
119
+ ci: DifferenceCI,
120
+ options: Partial<DistributionPlotOptions> = {},
121
+ ): SVGSVGElement {
122
+ if (!ci.histogram) return createSvg(0, 0);
123
+ return createDistributionPlot(ci.histogram, ci.ci, ci.percent, {
124
+ title: ci.label,
125
+ direction: ci.direction,
126
+ ciLevel: ci.ciLevel,
127
+ ciReliable: ci.ciReliable,
128
+ ...options,
129
+ });
130
+ }
131
+
132
+ /** Use minimal margins when the chart is too small for standard spacing. */
133
+ function buildLayout(
134
+ width: number,
135
+ height: number,
136
+ hasPointLabel?: boolean,
137
+ ): Layout {
138
+ const compact = height < defaultMargin.top + defaultMargin.bottom + 10;
139
+ const margin = compact
140
+ ? { top: 4, right: 6, bottom: 4, left: 6 }
141
+ : { ...defaultMargin, top: hasPointLabel ? 30 : defaultMargin.top };
142
+ const plot = {
143
+ w: width - margin.left - margin.right,
144
+ h: height - margin.top - margin.bottom,
145
+ };
146
+ return { width, height, margin, plot };
147
+ }
148
+
149
+ function buildScales(
150
+ histogram: HistogramBin[],
151
+ ci: [number, number],
152
+ layout: Layout,
153
+ includeZero: boolean,
154
+ equivMargin?: number,
155
+ pointEstimate?: number,
156
+ ): Scales {
157
+ const { margin, plot } = layout;
158
+ const xs = histogram.map(b => b.x);
159
+ const extra = includeZero ? [0] : [];
160
+ const marginBounds = equivMargin ? [-equivMargin, equivMargin] : [];
161
+ const ptBounds = pointEstimate != null ? [pointEstimate] : [];
162
+ const xMin = Math.min(...xs, ci[0], ...extra, ...marginBounds, ...ptBounds);
163
+ const xMax = Math.max(...xs, ci[1], ...extra, ...marginBounds, ...ptBounds);
164
+ const yMax = Math.max(...histogram.map(b => b.count));
165
+ const xRange = xMax - xMin || 1;
166
+ return {
167
+ x: (v: number) => margin.left + ((v - xMin) / xRange) * plot.w,
168
+ y: (v: number) => margin.top + plot.h - (v / yMax) * plot.h,
169
+ };
170
+ }
171
+
172
+ function drawTitles(
173
+ svg: SVGSVGElement,
174
+ opts: DistributionPlotOptions,
175
+ margin: Layout["margin"],
176
+ pointX: number,
177
+ ): void {
178
+ if (opts.title)
179
+ svg.appendChild(
180
+ text(margin.left, 14, opts.title, "start", "13", "currentColor", "600"),
181
+ );
182
+ if (opts.pointLabel) {
183
+ const el = text(
184
+ pointX,
185
+ margin.top - 6,
186
+ opts.pointLabel,
187
+ "middle",
188
+ "15",
189
+ "currentColor",
190
+ "700",
191
+ );
192
+ svg.appendChild(el);
193
+ }
194
+ }
195
+
196
+ /** Draw equivalence margin zone: hatched band centered vertically */
197
+ function drawMarginZone(
198
+ svg: SVGSVGElement,
199
+ equivMargin: number,
200
+ scales: Scales,
201
+ layout: Layout,
202
+ ): void {
203
+ const { margin, plot } = layout;
204
+ const x1 = scales.x(-equivMargin);
205
+ const x2 = scales.x(equivMargin);
206
+ const fill = `url(#${ensureHatchPattern(svg)})`;
207
+ const bandH = plot.h / 3;
208
+ const bandY = margin.top + (plot.h - bandH) / 2;
209
+ const zone = rect(x1, bandY, x2 - x1, bandH, { fill, strokeWidth: "1.5" });
210
+ zone.classList.add("margin-zone");
211
+ svg.appendChild(zone);
212
+ }
213
+
214
+ /** Draw a filled area + stroke path using gaussian-smoothed histogram data */
215
+ function drawSmoothedDist(
216
+ svg: SVGSVGElement,
217
+ histogram: HistogramBin[],
218
+ scales: Scales,
219
+ stroke: string,
220
+ ): void {
221
+ const sorted = [...histogram].sort((a, b) => a.x - b.x);
222
+ const smoothed = gaussianSmooth(sorted, 2);
223
+ const pts = smoothed.map(b => `${scales.x(b.x)},${scales.y(b.count)}`);
224
+ const base = scales.y(0);
225
+ const startX = scales.x(smoothed[0].x);
226
+ const endX = scales.x(smoothed.at(-1)!.x);
227
+ const fillD = `M${startX},${base}L${pts.join("L")}L${endX},${base}Z`;
228
+ const fillPath = path(fillD, { fill: stroke });
229
+ fillPath.classList.add("dist-fill");
230
+ svg.appendChild(fillPath);
231
+ const strokePath = path(`M${pts.join("L")}`, {
232
+ stroke,
233
+ fill: "none",
234
+ strokeWidth: "1.5",
235
+ });
236
+ strokePath.classList.add("dist-stroke");
237
+ svg.appendChild(strokePath);
238
+ }
239
+
240
+ function drawHistogramBars(
241
+ svg: SVGSVGElement,
242
+ histogram: HistogramBin[],
243
+ scales: Scales,
244
+ layout: Layout,
245
+ stroke: string,
246
+ ): void {
247
+ const sorted = [...histogram].sort((a, b) => a.x - b.x);
248
+ const binW = sorted.length > 1 ? sorted[1].x - sorted[0].x : 1;
249
+ const xRange = scales.x(sorted.at(-1)!.x) - scales.x(sorted[0].x) + binW;
250
+ const barW = (binW / xRange) * layout.plot.w * 0.9;
251
+ const base = scales.y(0);
252
+ const attrs = { fill: stroke, opacity: "0.6" };
253
+ for (const bin of sorted) {
254
+ const top = scales.y(bin.count);
255
+ svg.appendChild(
256
+ rect(scales.x(bin.x) - barW / 2, top, barW, base - top, attrs),
257
+ );
258
+ }
259
+ }
260
+
261
+ /** Draw zero reference line extending past plot area (comparison CIs only) */
262
+ function drawReferenceLine(
263
+ svg: SVGSVGElement,
264
+ scales: Scales,
265
+ layout: Layout,
266
+ includeZero: boolean,
267
+ ): void {
268
+ const { margin, plot } = layout;
269
+ const zeroX = scales.x(0);
270
+ const inBounds = zeroX >= margin.left && zeroX <= layout.width - margin.right;
271
+ if (!includeZero || !inBounds) return;
272
+
273
+ svg.appendChild(
274
+ line(zeroX, margin.top - 4, zeroX, margin.top + plot.h + 4, {
275
+ stroke: "#000",
276
+ strokeWidth: "1",
277
+ }),
278
+ );
279
+ }
280
+
281
+ function drawCILabels(
282
+ svg: SVGSVGElement,
283
+ ci: [number, number],
284
+ scales: Scales,
285
+ layout: Layout,
286
+ opts: DistributionPlotOptions & { includeZero: boolean },
287
+ ): void {
288
+ if (layout.margin.bottom < 15) return;
289
+ const labelY = layout.height - 4;
290
+ const loLabel = opts.ciLabels?.[0] ?? formatPct(ci[0], 0);
291
+ const hiLabel = opts.ciLabels?.[1] ?? formatPct(ci[1], 0);
292
+ const loX = scales.x(ci[0]);
293
+ const hiX = scales.x(ci[1]);
294
+ const minGap = Math.max(loLabel.length, hiLabel.length) * 6;
295
+ if (!opts.includeZero || hiX - loX >= minGap) {
296
+ svg.appendChild(text(loX, labelY, loLabel, "middle", "11"));
297
+ svg.appendChild(text(hiX, labelY, hiLabel, "middle", "11"));
298
+ }
299
+ }
300
+
301
+ /** Apply gaussian kernel smoothing to histogram bins */
302
+ function gaussianSmooth(bins: HistogramBin[], sigma: number): HistogramBin[] {
303
+ return bins.map((bin, i) => {
304
+ let sum = 0;
305
+ let wt = 0;
306
+ for (let j = 0; j < bins.length; j++) {
307
+ const w = Math.exp(-((i - j) ** 2) / (2 * sigma ** 2));
308
+ sum += bins[j].count * w;
309
+ wt += w;
310
+ }
311
+ return { x: bin.x, count: sum / wt };
312
+ });
313
+ }
@@ -1,34 +1,40 @@
1
1
  import * as Plot from "@observablehq/plot";
2
2
  import * as d3 from "d3";
3
3
  import { buildLegend, type LegendItem } from "./LegendUtils.ts";
4
- import type { Sample } from "./Types.ts";
4
+ import { getTimeUnit, plotLayout, type Sample } from "./PlotTypes.ts";
5
+
6
+ interface Bar {
7
+ benchmark: string;
8
+ count: number;
9
+ x1: number;
10
+ x2: number;
11
+ }
5
12
 
6
13
  /** Create histogram + KDE plot for sample distribution */
7
14
  export function createHistogramKde(
8
15
  allSamples: Sample[],
9
16
  benchmarkNames: string[],
10
17
  ): SVGSVGElement | HTMLElement {
18
+ const values = allSamples.map(d => d.value);
19
+ const { unitSuffix, convertValue, formatValue } = getTimeUnit(values);
20
+ const converted = allSamples.map(d => ({
21
+ ...d,
22
+ value: convertValue(d.value),
23
+ }));
11
24
  const { barData, binMin, binMax, yMax } = buildBarData(
12
- allSamples,
25
+ converted,
13
26
  benchmarkNames,
14
27
  );
15
28
  const { colorMap, legendItems } = buildColorData(benchmarkNames);
16
- const xMax = binMax + (binMax - binMin) * 0.45; // extend for legend
17
29
 
18
30
  return Plot.plot({
19
- marginTop: 24,
20
- marginLeft: 70,
21
- marginRight: 10,
22
- marginBottom: 60,
23
- width: 550,
24
- height: 300,
25
- style: { fontSize: "14px" },
31
+ ...plotLayout,
26
32
  x: {
27
- label: "Time (ms)",
33
+ label: `Time (${unitSuffix})`,
28
34
  labelAnchor: "center",
29
- domain: [binMin, xMax],
35
+ domain: [binMin, binMax],
30
36
  labelOffset: 45,
31
- tickFormat: (d: number) => d.toFixed(1),
37
+ tickFormat: formatValue,
32
38
  ticks: 5,
33
39
  },
34
40
  y: {
@@ -43,58 +49,34 @@ export function createHistogramKde(
43
49
  x1: "x1",
44
50
  x2: "x2",
45
51
  y: "count",
46
- fill: (d: (typeof barData)[0]) => colorMap.get(d.benchmark),
52
+ fill: (d: Bar) => colorMap.get(d.benchmark),
47
53
  fillOpacity: 0.6,
48
- tip: true,
49
- title: (d: (typeof barData)[0]) => `${d.benchmark}: ${d.count}`,
50
54
  }),
51
55
  Plot.ruleY([0]),
52
- ...buildLegend({ xMin: binMin, xMax, yMax }, legendItems),
56
+ ...buildLegend({ xMin: binMin, xMax: binMax, yMax }, legendItems),
53
57
  ],
54
58
  });
55
59
  }
56
60
 
57
- function buildColorData(benchmarkNames: string[]) {
58
- const scheme = (d3 as any).schemeObservable10;
59
- const colorMap = new Map(
60
- benchmarkNames.map((name, i) => [name, scheme[i % 10]]),
61
- );
62
- const legendItems: LegendItem[] = benchmarkNames.map((name, i) => ({
63
- color: scheme[i % 10],
64
- label: name,
65
- style: "vertical-bar",
66
- }));
67
- return { colorMap, legendItems };
68
- }
69
-
70
61
  /** Bin samples into grouped histogram bars for each benchmark */
71
62
  function buildBarData(allSamples: Sample[], benchmarkNames: string[]) {
72
- const values = allSamples.map(d => d.value);
73
- const sorted = values.sort((a, b) => a - b);
74
- const binMin = d3.quantile(sorted, 0.01)!;
75
- const binMax = d3.quantile(sorted, 0.99)!;
63
+ const sortedValues = allSamples.map(d => d.value).sort((a, b) => a - b);
64
+ const binMin = d3.quantile(sortedValues, 0.01)!;
65
+ const binMax = d3.quantile(sortedValues, 0.99)!;
76
66
  const binCount = 25;
77
67
  const step = (binMax - binMin) / binCount;
78
68
  const thresholds = d3.range(1, binCount).map(i => binMin + i * step);
79
- const plotWidth = 550;
80
-
81
69
  const bins = d3
82
70
  .bin<Sample, number>()
83
71
  .domain([binMin, binMax])
84
72
  .thresholds(thresholds)
85
73
  .value(d => d.value)(allSamples);
86
74
 
87
- const barData: {
88
- benchmark: string;
89
- count: number;
90
- x1: number;
91
- x2: number;
92
- }[] = [];
93
75
  const n = benchmarkNames.length;
94
- const unitsPerPx = (binMax - binMin) / plotWidth;
76
+ const unitsPerPx = (binMax - binMin) / plotLayout.width;
95
77
  const groupGapPx = 8;
96
78
 
97
- for (const bin of bins) {
79
+ const barData: Bar[] = bins.flatMap(bin => {
98
80
  const counts = new Map<string, number>();
99
81
  for (const d of bin)
100
82
  counts.set(d.benchmark, (counts.get(d.benchmark) || 0) + 1);
@@ -104,15 +86,28 @@ function buildBarData(allSamples: Sample[], benchmarkNames: string[]) {
104
86
  const start = bin.x0! + groupGap / 2;
105
87
  const w = (full - groupGap) / n;
106
88
 
107
- benchmarkNames.forEach((benchmark, i) => {
89
+ return benchmarkNames.map((benchmark, i) => {
108
90
  const x1 = start + i * w;
109
91
  const x2 = start + (i + 1) * w;
110
- barData.push({ benchmark, count: counts.get(benchmark) || 0, x1, x2 });
92
+ return { benchmark, count: counts.get(benchmark) || 0, x1, x2 };
111
93
  });
112
- }
94
+ });
113
95
 
114
96
  const maxCount = d3.max(barData, d => d.count)! || 1;
115
97
  const yMax = maxCount * 1.15;
116
98
 
117
99
  return { barData, binMin, binMax, yMax };
118
100
  }
101
+
102
+ /** Map benchmark names to colors and legend items using Observable 10 palette */
103
+ function buildColorData(benchmarkNames: string[]) {
104
+ const scheme = (d3 as any).schemeObservable10;
105
+ const color = (i: number) => scheme[i % 10];
106
+ const colorMap = new Map(benchmarkNames.map((name, i) => [name, color(i)]));
107
+ const legendItems: LegendItem[] = benchmarkNames.map((name, i) => ({
108
+ color: color(i),
109
+ label: name,
110
+ style: "vertical-bar",
111
+ }));
112
+ return { colorMap, legendItems };
113
+ }
@@ -0,0 +1,134 @@
1
+ import * as Plot from "@observablehq/plot";
2
+
3
+ /** Plot data bounds used to position the legend overlay */
4
+ export interface LegendBounds {
5
+ xMin: number;
6
+ xMax: number;
7
+ yMin?: number;
8
+ yMax: number;
9
+ }
10
+
11
+ /** A single entry in the plot legend with color, label, and symbol style */
12
+ export interface LegendItem {
13
+ color: string;
14
+ label: string;
15
+ style:
16
+ | "filled-dot"
17
+ | "hollow-dot"
18
+ | "vertical-bar"
19
+ | "vertical-line"
20
+ | "rect";
21
+ strokeDash?: string;
22
+ }
23
+
24
+ interface LegendPos {
25
+ legendX: number;
26
+ y: number;
27
+ textX: number;
28
+ xRange: number;
29
+ yRange: number;
30
+ }
31
+
32
+ const rectFields = { x1: "x1", x2: "x2", y1: "y1", y2: "y2" } as const;
33
+
34
+ /** Build complete legend marks array, positioned in the right margin */
35
+ export function buildLegend(bounds: LegendBounds, items: LegendItem[]): any[] {
36
+ const xRange = Math.max(bounds.xMax - bounds.xMin, bounds.xMax * 0.1 || 1);
37
+ const yRange = bounds.yMax - (bounds.yMin ?? 0);
38
+ const legendX = bounds.xMax + xRange * 0.04;
39
+ const textX = legendX + xRange * 0.03;
40
+ const itemHeight = yRange * 0.07;
41
+ const topY = bounds.yMax - yRange * 0.02;
42
+
43
+ const pos = (i: number): LegendPos => ({
44
+ legendX,
45
+ y: topY - i * itemHeight,
46
+ textX,
47
+ xRange,
48
+ yRange,
49
+ });
50
+ return items.flatMap((item, i) => [
51
+ symbolMark(pos(i), item),
52
+ textMark(pos(i), item.label),
53
+ ]);
54
+ }
55
+
56
+ function symbolMark(pos: LegendPos, item: LegendItem): any {
57
+ switch (item.style) {
58
+ case "filled-dot":
59
+ return dotMark(pos.legendX, pos.y, item.color, true);
60
+ case "hollow-dot":
61
+ return dotMark(pos.legendX, pos.y, item.color, false);
62
+ case "vertical-bar":
63
+ return verticalBarMark(pos, item.color);
64
+ case "vertical-line":
65
+ return verticalLineMark(pos, item.color, item.strokeDash);
66
+ case "rect":
67
+ return rectMark(pos, item.color);
68
+ }
69
+ }
70
+
71
+ function textMark(pos: LegendPos, label: string): any {
72
+ return Plot.text([{ x: pos.textX, y: pos.y, text: label }], {
73
+ x: "x",
74
+ y: "y",
75
+ text: "text",
76
+ fontSize: 11,
77
+ textAnchor: "start",
78
+ fill: "#333",
79
+ clip: false,
80
+ });
81
+ }
82
+
83
+ function dotMark(x: number, y: number, color: string, filled: boolean): any {
84
+ const base = { x: "x", y: "y", r: 4, clip: false };
85
+ const style = filled
86
+ ? { ...base, fill: color }
87
+ : { ...base, stroke: color, fill: "none", strokeWidth: 1.5 };
88
+ return Plot.dot([{ x, y }], style);
89
+ }
90
+
91
+ function verticalBarMark(pos: LegendPos, color: string): any {
92
+ const { legendX, y, xRange, yRange } = pos;
93
+ const hw = xRange * 0.006;
94
+ const hh = yRange * 0.025;
95
+ const data = [{ x1: legendX - hw, x2: legendX + hw, y1: y - hh, y2: y + hh }];
96
+ return Plot.rect(data, {
97
+ ...rectFields,
98
+ fill: color,
99
+ fillOpacity: 0.6,
100
+ clip: false,
101
+ });
102
+ }
103
+
104
+ function verticalLineMark(
105
+ pos: LegendPos,
106
+ color: string,
107
+ strokeDash?: string,
108
+ ): any {
109
+ const { legendX, y, yRange } = pos;
110
+ const half = yRange * 0.025;
111
+ return Plot.ruleX([legendX], {
112
+ y1: y - half,
113
+ y2: y + half,
114
+ stroke: color,
115
+ strokeWidth: 2,
116
+ strokeDasharray: strokeDash,
117
+ clip: false,
118
+ });
119
+ }
120
+
121
+ function rectMark(pos: LegendPos, color: string): any {
122
+ const { legendX, y, xRange, yRange } = pos;
123
+ const hw = xRange * 0.015;
124
+ const hh = yRange * 0.02;
125
+ const data = [{ x1: legendX - hw, x2: legendX + hw, y1: y - hh, y2: y + hh }];
126
+ return Plot.rect(data, {
127
+ ...rectFields,
128
+ fill: color,
129
+ fillOpacity: 0.3,
130
+ stroke: color,
131
+ strokeWidth: 1,
132
+ clip: false,
133
+ });
134
+ }
@@ -0,0 +1,85 @@
1
+ /** A single timing sample from a benchmark run */
2
+ export interface Sample {
3
+ benchmark: string;
4
+ value: number;
5
+ iteration: number;
6
+ }
7
+
8
+ /** A sample with warmup/optimization metadata for time series plots */
9
+ export interface TimeSeriesPoint {
10
+ benchmark: string;
11
+ iteration: number;
12
+ value: number;
13
+ isWarmup: boolean;
14
+ isBaseline?: boolean;
15
+ isRejected?: boolean;
16
+ /** V8 optimization status code (e.g. 17=turbofan, 33=maglev) */
17
+ optStatus?: number;
18
+ }
19
+
20
+ /** Heap usage sample (in bytes) at a given iteration */
21
+ export interface HeapPoint {
22
+ benchmark: string;
23
+ iteration: number;
24
+ value: number;
25
+ }
26
+
27
+ /** GcEvent flattened with benchmark name for multi-series plots */
28
+ export interface FlatGcEvent {
29
+ benchmark: string;
30
+ sampleIndex: number;
31
+ duration: number;
32
+ }
33
+
34
+ /** PausePoint flattened with benchmark name for multi-series plots */
35
+ export interface FlatPausePoint {
36
+ benchmark: string;
37
+ sampleIndex: number;
38
+ durationMs: number;
39
+ }
40
+
41
+ /** Display unit (ns/us/ms) with conversion and formatting functions */
42
+ export interface TimeUnit {
43
+ unitSuffix: string;
44
+ convertValue: (ms: number) => number;
45
+ formatValue: (d: number) => string;
46
+ }
47
+
48
+ /** Shared Observable Plot layout: margins, dimensions, font size */
49
+ export const plotLayout = {
50
+ marginTop: 24,
51
+ marginLeft: 70,
52
+ marginRight: 110,
53
+ marginBottom: 60,
54
+ width: 550,
55
+ height: 300,
56
+ style: { fontSize: "14px" },
57
+ } as const;
58
+
59
+ /** Format a number as a signed percentage string (e.g. "+1.2%", "-3.4%") */
60
+ export function formatPct(v: number, precision = 1): string {
61
+ const sign = v >= 0 ? "+" : "";
62
+ return `${sign}${v.toFixed(precision)}%`;
63
+ }
64
+
65
+ /** Pick display unit (ns/us/ms) based on average value magnitude (in ms) */
66
+ export function getTimeUnit(values: number[]): TimeUnit {
67
+ const avg = values.reduce((s, v) => s + v, 0) / values.length;
68
+ const locale = (digits: number) => (d: number) =>
69
+ d.toLocaleString("en-US", { maximumFractionDigits: digits });
70
+ const fmt0 = locale(0);
71
+ const fmt1 = locale(1);
72
+ if (avg < 0.001)
73
+ return {
74
+ unitSuffix: "ns",
75
+ convertValue: ms => ms * 1e6,
76
+ formatValue: fmt0,
77
+ };
78
+ if (avg < 1)
79
+ return {
80
+ unitSuffix: "\u00b5s",
81
+ convertValue: ms => ms * 1e3,
82
+ formatValue: fmt1,
83
+ };
84
+ return { unitSuffix: "ms", convertValue: ms => ms, formatValue: fmt1 };
85
+ }