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,230 @@
1
+ import {
2
+ average,
3
+ splitByOffsets,
4
+ tukeyKeep,
5
+ } from "../../stats/StatisticalUtils.ts";
6
+ import type { BenchmarkEntry, ReportData } from "../ReportData.ts";
7
+ import type {
8
+ FlatGcEvent,
9
+ FlatPausePoint,
10
+ HeapPoint,
11
+ Sample,
12
+ TimeSeriesPoint,
13
+ } from "./PlotTypes.ts";
14
+
15
+ /** Benchmark entry tagged with whether it's the baseline for comparison */
16
+ export interface PreparedBenchmark extends BenchmarkEntry {
17
+ isBaseline: boolean;
18
+ }
19
+
20
+ /** All sample data flattened across benchmarks into arrays for plotting */
21
+ export interface FlattenedData {
22
+ allSamples: Sample[];
23
+ timeSeries: TimeSeriesPoint[];
24
+ heapSeries: HeapPoint[];
25
+ baselineHeapSeries: HeapPoint[];
26
+ allGcEvents: FlatGcEvent[];
27
+ allPausePoints: FlatPausePoint[];
28
+ }
29
+
30
+ /** Combine baseline and benchmarks into a single list with display names */
31
+ export function prepareBenchmarks(
32
+ group: ReportData["groups"][0],
33
+ ): PreparedBenchmark[] {
34
+ const base = group.baseline;
35
+ const current = group.benchmarks.map(b => ({ ...b, isBaseline: false }));
36
+ if (!base) return current;
37
+
38
+ const baseName = base.name.endsWith("(baseline)")
39
+ ? base.name
40
+ : base.name + " (baseline)";
41
+ return [{ ...base, name: baseName, isBaseline: true }, ...current];
42
+ }
43
+
44
+ /** Collect all sample data across benchmarks into flat arrays for plotting */
45
+ export function flattenSamples(benchmarks: PreparedBenchmark[]): FlattenedData {
46
+ const out: FlattenedData = {
47
+ allSamples: [],
48
+ timeSeries: [],
49
+ heapSeries: [],
50
+ baselineHeapSeries: [],
51
+ allGcEvents: [],
52
+ allPausePoints: [],
53
+ };
54
+ for (const b of benchmarks) {
55
+ if (b.samples?.length) flattenBenchmark(b, out);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** @return batch count from the first benchmark with batchOffsets, or 0 */
61
+ export function batchCount(benchmarks: PreparedBenchmark[]): number {
62
+ return (
63
+ benchmarks.find(b => b.batchOffsets?.length)?.batchOffsets?.length ?? 0
64
+ );
65
+ }
66
+
67
+ /** Filter flattened data to a single batch, re-indexing iterations from 0 */
68
+ export function filterToBatch(
69
+ flat: FlattenedData,
70
+ benchmarks: PreparedBenchmark[],
71
+ batchIndex: number,
72
+ ): FlattenedData {
73
+ const ranges = new Map<string, [number, number]>();
74
+ for (const b of benchmarks) {
75
+ const offsets = b.batchOffsets;
76
+ if (!offsets?.length) continue;
77
+ const start = offsets[batchIndex];
78
+ const end =
79
+ batchIndex + 1 < offsets.length
80
+ ? offsets[batchIndex + 1]
81
+ : b.samples.length;
82
+ ranges.set(b.name, [start, end]);
83
+ }
84
+
85
+ const inBatch = (name: string, iter: number) => {
86
+ const r = ranges.get(name);
87
+ return r ? iter >= r[0] && iter < r[1] : true;
88
+ };
89
+ const reindex = (name: string, iter: number) => {
90
+ const r = ranges.get(name);
91
+ return r ? iter - r[0] : iter;
92
+ };
93
+ const sliceIter = <T extends { benchmark: string; iteration: number }>(
94
+ arr: T[],
95
+ ) =>
96
+ arr
97
+ .filter(d => inBatch(d.benchmark, d.iteration))
98
+ .map(d => ({ ...d, iteration: reindex(d.benchmark, d.iteration) }));
99
+
100
+ return {
101
+ allSamples: sliceIter(flat.allSamples),
102
+ timeSeries: sliceIter(flat.timeSeries.filter(d => !d.isWarmup)),
103
+ heapSeries: sliceIter(flat.heapSeries),
104
+ baselineHeapSeries: sliceIter(flat.baselineHeapSeries),
105
+ allGcEvents: flat.allGcEvents.filter(d =>
106
+ inBatch(d.benchmark, d.sampleIndex),
107
+ ),
108
+ allPausePoints: flat.allPausePoints.filter(d =>
109
+ inBatch(d.benchmark, d.sampleIndex),
110
+ ),
111
+ };
112
+ }
113
+
114
+ /** Extract time series, heap, GC, and pause data from one benchmark */
115
+ function flattenBenchmark(b: PreparedBenchmark, out: FlattenedData): void {
116
+ flattenWarmup(b, b.name, out);
117
+ flattenSamplesAndHeap(b, b.name, out);
118
+ flattenGcEvents(b, b.name, out);
119
+ flattenPausePoints(b, b.name, out);
120
+ }
121
+
122
+ /** Warmup samples get negative iteration indices so they appear left of zero */
123
+ function flattenWarmup(
124
+ b: PreparedBenchmark,
125
+ name: string,
126
+ out: FlattenedData,
127
+ ): void {
128
+ const warmupCount = b.warmupSamples?.length || 0;
129
+ b.warmupSamples?.forEach((value, i) => {
130
+ out.timeSeries.push({
131
+ benchmark: name,
132
+ iteration: i - warmupCount,
133
+ value,
134
+ isWarmup: true,
135
+ });
136
+ });
137
+ }
138
+
139
+ /** Populate timeSeries, allSamples (excluding rejected), and heap data */
140
+ function flattenSamplesAndHeap(
141
+ b: PreparedBenchmark,
142
+ name: string,
143
+ out: FlattenedData,
144
+ ): void {
145
+ const rejected = rejectedIndices(b);
146
+ const isBase = b.isBaseline || undefined;
147
+ b.samples.forEach((value, i) => {
148
+ const isRejected = rejected?.has(i) || undefined;
149
+ if (!isRejected)
150
+ out.allSamples.push({ benchmark: name, value, iteration: i });
151
+ const optStatus = b.optSamples?.[i];
152
+ out.timeSeries.push({
153
+ benchmark: name,
154
+ iteration: i,
155
+ value,
156
+ isWarmup: false,
157
+ isBaseline: isBase,
158
+ isRejected,
159
+ optStatus,
160
+ });
161
+ if (b.heapSamples?.[i] !== undefined) {
162
+ const target = b.isBaseline ? out.baselineHeapSeries : out.heapSeries;
163
+ target.push({ benchmark: name, iteration: i, value: b.heapSamples[i] });
164
+ }
165
+ });
166
+ }
167
+
168
+ /** Map GC events to sample indices using cumulative sample durations */
169
+ function flattenGcEvents(
170
+ b: PreparedBenchmark,
171
+ name: string,
172
+ out: FlattenedData,
173
+ ): void {
174
+ if (!b.gcEvents?.length) return;
175
+ const endTimes = cumulativeSum(b.samples);
176
+ for (const gc of b.gcEvents) {
177
+ const idx = endTimes.findIndex(t => t >= gc.offset);
178
+ const sampleIndex = idx >= 0 ? idx : b.samples.length - 1;
179
+ out.allGcEvents.push({
180
+ benchmark: name,
181
+ sampleIndex,
182
+ duration: gc.duration,
183
+ });
184
+ }
185
+ }
186
+
187
+ /** Flatten benchmark pause points into the shared output arrays */
188
+ function flattenPausePoints(
189
+ b: PreparedBenchmark,
190
+ name: string,
191
+ out: FlattenedData,
192
+ ): void {
193
+ if (!b.pausePoints) return;
194
+ for (const p of b.pausePoints)
195
+ out.allPausePoints.push({
196
+ benchmark: name,
197
+ sampleIndex: p.sampleIndex,
198
+ durationMs: p.durationMs,
199
+ });
200
+ }
201
+
202
+ /** @return sample indices in Tukey-rejected batches, or undefined if none */
203
+ function rejectedIndices(b: PreparedBenchmark): Set<number> | undefined {
204
+ const offsets = b.batchOffsets;
205
+ if (!offsets || offsets.length < 4) return undefined;
206
+
207
+ const means = splitByOffsets(b.samples, offsets).map(s => average(s));
208
+ const kept = new Set(tukeyKeep(means));
209
+
210
+ const rejected = new Set<number>();
211
+ for (let bi = 0; bi < means.length; bi++) {
212
+ if (!kept.has(bi)) {
213
+ const start = offsets[bi];
214
+ const end = bi + 1 < offsets.length ? offsets[bi + 1] : b.samples.length;
215
+ for (let j = start; j < end; j++) rejected.add(j);
216
+ }
217
+ }
218
+ return rejected.size > 0 ? rejected : undefined;
219
+ }
220
+
221
+ /** Running total of sample durations, used to map GC offsets to sample indices */
222
+ function cumulativeSum(arr: number[]): number[] {
223
+ const result: number[] = [];
224
+ let sum = 0;
225
+ for (const v of arr) {
226
+ sum += v;
227
+ result.push(sum);
228
+ }
229
+ return result;
230
+ }
@@ -0,0 +1,306 @@
1
+ import * as Plot from "@observablehq/plot";
2
+ import * as d3 from "d3";
3
+ import { optStatusNames } from "../../runners/MeasuredResults.ts";
4
+ import { buildLegend, type LegendItem } from "./LegendUtils.ts";
5
+ import {
6
+ type FlatGcEvent,
7
+ type FlatPausePoint,
8
+ getTimeUnit,
9
+ type HeapPoint,
10
+ plotLayout,
11
+ type TimeSeriesPoint,
12
+ } from "./PlotTypes.ts";
13
+ import {
14
+ buildLegendItems,
15
+ gcMark,
16
+ type HeapScale,
17
+ heapAxisMarks,
18
+ heapMarks,
19
+ type PlotContext,
20
+ pauseMarks,
21
+ type SampleData,
22
+ sampleDotMarks,
23
+ } from "./TimeSeriesMarks.ts";
24
+
25
+ /** Controls which data series are visible in the time series plot */
26
+ export interface SeriesVisibility {
27
+ baseline: boolean;
28
+ heap: boolean;
29
+ baselineHeap: boolean;
30
+ rejected: boolean;
31
+ }
32
+
33
+ type HeapPlotPoint = { sample: number; y: number };
34
+
35
+ interface MarkParams {
36
+ ctx: PlotContext;
37
+ heapData: HeapPlotPoint[];
38
+ baselineHeapData: HeapPlotPoint[];
39
+ heapScale: HeapScale | undefined;
40
+ gcEvents: FlatGcEvent[];
41
+ pausePoints: FlatPausePoint[];
42
+ legendItems: LegendItem[];
43
+ showRejected: boolean;
44
+ }
45
+
46
+ const defaultVisibility: SeriesVisibility = {
47
+ baseline: true,
48
+ heap: true,
49
+ baselineHeap: false,
50
+ rejected: true,
51
+ };
52
+
53
+ /** Time series plot with samples, GC events, heap overlay, and opt tiers */
54
+ export function createSampleTimeSeries(
55
+ timeSeries: TimeSeriesPoint[],
56
+ gcEvents: FlatGcEvent[] = [],
57
+ pausePoints: FlatPausePoint[] = [],
58
+ heapSeries: HeapPoint[] = [],
59
+ baselineHeapSeries: HeapPoint[] = [],
60
+ visibility: SeriesVisibility = defaultVisibility,
61
+ ): SVGSVGElement | HTMLElement {
62
+ const filtered = visibility.baseline
63
+ ? timeSeries
64
+ : timeSeries.filter(d => !d.isBaseline);
65
+ const ctx = buildPlotContext(filtered);
66
+ const series = prepareSeriesData(
67
+ ctx,
68
+ heapSeries,
69
+ baselineHeapSeries,
70
+ visibility,
71
+ gcEvents,
72
+ pausePoints,
73
+ );
74
+
75
+ return Plot.plot({
76
+ ...plotLayout,
77
+ x: {
78
+ label: "Iteration",
79
+ labelAnchor: "center",
80
+ labelOffset: 45,
81
+ grid: true,
82
+ domain: [ctx.xMin, ctx.xMax],
83
+ },
84
+ y: {
85
+ label: `Time (${ctx.unitSuffix})`,
86
+ labelAnchor: "top",
87
+ labelArrow: false,
88
+ grid: true,
89
+ domain: [ctx.yMin, ctx.yMax],
90
+ tickFormat: ctx.formatValue,
91
+ },
92
+ color: { legend: false, scheme: "observable10" },
93
+ marks: buildMarks({ ctx, ...series, gcEvents, pausePoints }),
94
+ });
95
+ }
96
+
97
+ /** Derive scales, units, and metadata from time series data */
98
+ function buildPlotContext(timeSeries: TimeSeriesPoint[]): PlotContext {
99
+ const benchmarks = [...new Set(timeSeries.map(d => d.benchmark))];
100
+ const sampleData = buildSampleData(timeSeries);
101
+ const values = sampleData.map(d => d.value);
102
+ const { unitSuffix, convertValue, formatValue } = getTimeUnit(values);
103
+ const convertedData: SampleData[] = sampleData.map(d => ({
104
+ ...d,
105
+ displayValue: convertValue(d.value),
106
+ }));
107
+ const { yMin, yMax } = computeYRange(convertedData.map(d => d.displayValue));
108
+ let xMin = d3.min(convertedData, d => d.sample)!;
109
+ let xMax = d3.max(convertedData, d => d.sample)!;
110
+ if (xMin === xMax) {
111
+ xMin -= 0.5;
112
+ xMax += 0.5;
113
+ }
114
+ const hasWarmup = convertedData.some(d => d.isWarmup);
115
+ const hasRejected = convertedData.some(d => d.isRejected);
116
+ const baselineNames = new Set(
117
+ convertedData.filter(d => d.isBaseline).map(d => d.benchmark),
118
+ );
119
+ const optTiers = [
120
+ ...new Set(
121
+ convertedData.filter(d => d.optTier && !d.isWarmup).map(d => d.optTier!),
122
+ ),
123
+ ];
124
+ return {
125
+ convertedData,
126
+ xMin,
127
+ xMax,
128
+ yMin,
129
+ yMax,
130
+ unitSuffix,
131
+ formatValue,
132
+ convertValue,
133
+ hasWarmup,
134
+ hasRejected,
135
+ baselineNames,
136
+ optTiers,
137
+ benchmarks,
138
+ };
139
+ }
140
+
141
+ /** Prepare heap, legend, and visibility state for the time series plot */
142
+ function prepareSeriesData(
143
+ ctx: PlotContext,
144
+ heapSeries: HeapPoint[],
145
+ baselineHeapSeries: HeapPoint[],
146
+ visibility: SeriesVisibility,
147
+ gcEvents: FlatGcEvent[],
148
+ pausePoints: FlatPausePoint[],
149
+ ) {
150
+ const visibleHeap = [
151
+ ...(visibility.heap ? heapSeries : []),
152
+ ...(visibility.baselineHeap ? baselineHeapSeries : []),
153
+ ];
154
+ const heapScale = computeHeapScale(visibleHeap, ctx.yMin, ctx.yMax);
155
+ const heapData =
156
+ heapScale && visibility.heap ? prepareHeapData(heapSeries, heapScale) : [];
157
+ const baselineHeapData =
158
+ heapScale && visibility.baselineHeap
159
+ ? prepareHeapData(baselineHeapSeries, heapScale)
160
+ : [];
161
+ const showRejected = visibility.rejected && ctx.hasRejected;
162
+ const legendItems = buildLegendItems({
163
+ hasWarmup: ctx.hasWarmup,
164
+ gcCount: gcEvents.length,
165
+ pauseCount: pausePoints.length,
166
+ hasHeap: heapData.length > 0,
167
+ hasBaselineHeap: baselineHeapData.length > 0,
168
+ hasRejected: showRejected,
169
+ optTiers: ctx.optTiers,
170
+ benchmarks: ctx.benchmarks,
171
+ baselineNames: ctx.baselineNames,
172
+ });
173
+ return { heapScale, heapData, baselineHeapData, showRejected, legendItems };
174
+ }
175
+
176
+ /** Assemble all Observable Plot marks for the time series chart */
177
+ function buildMarks(p: MarkParams): Plot.Markish[] {
178
+ const { ctx, heapData, baselineHeapData, heapScale } = p;
179
+ const { gcEvents, pausePoints, legendItems, showRejected } = p;
180
+ const dashStyle = { stroke: "#999", strokeWidth: 1, strokeDasharray: "4,4" };
181
+ const warmupRule = ctx.hasWarmup ? [Plot.ruleX([0], dashStyle)] : [];
182
+ const { xMin, xMax, yMin, yMax } = ctx;
183
+ return [
184
+ ...heapMarks(baselineHeapData, yMin, "#fcd34d"),
185
+ ...heapMarks(heapData, yMin, "#93c5fd"),
186
+ ...heapAxisMarks(heapScale, xMax, xMin),
187
+ ...warmupRule,
188
+ gcMark(gcEvents, yMin, ctx.convertValue),
189
+ ...pauseMarks(pausePoints, yMin, yMax),
190
+ ...sampleDotMarks(ctx, showRejected, lttb),
191
+ Plot.ruleY([yMin], { stroke: "black", strokeWidth: 1 }),
192
+ ...buildLegend({ xMin, xMax, yMin, yMax }, legendItems),
193
+ ];
194
+ }
195
+
196
+ /** Convert TimeSeriesPoint data to SampleData with opt tier names */
197
+ function buildSampleData(
198
+ timeSeries: TimeSeriesPoint[],
199
+ ): Omit<SampleData, "displayValue">[] {
200
+ return timeSeries.map(d => ({
201
+ benchmark: d.benchmark,
202
+ sample: d.iteration,
203
+ value: d.value,
204
+ isBaseline: d.isBaseline || false,
205
+ isWarmup: d.isWarmup || false,
206
+ isRejected: d.isRejected || false,
207
+ optTier:
208
+ d.optStatus !== undefined
209
+ ? optStatusNames[d.optStatus] || "unknown"
210
+ : null,
211
+ }));
212
+ }
213
+
214
+ /** Pad Y range and snap yMin to a round number for clean axis ticks */
215
+ function computeYRange(values: number[]) {
216
+ const dataMin = d3.min(values)!;
217
+ const dataMax = d3.max(values)!;
218
+ const range = dataMax - dataMin;
219
+ let yMin = dataMin - range * 0.15;
220
+ const mag = 10 ** Math.floor(Math.log10(Math.abs(yMin)));
221
+ yMin = Math.floor(yMin / mag) * mag;
222
+ if (dataMin > 0 && yMin < 0) yMin = 0;
223
+ return { yMin, yMax: dataMax + range * 0.05 };
224
+ }
225
+
226
+ /** Compute scale to map heap byte values into the bottom 25% of the Y axis */
227
+ function computeHeapScale(
228
+ allHeap: HeapPoint[],
229
+ yMin: number,
230
+ yMax: number,
231
+ ): HeapScale | undefined {
232
+ if (allHeap.length === 0) return undefined;
233
+ const heapMinBytes = d3.min(allHeap, d => d.value)!;
234
+ const heapRangeBytes = d3.max(allHeap, d => d.value)! - heapMinBytes || 1;
235
+ return {
236
+ heapMinBytes,
237
+ heapRangeBytes,
238
+ scale: ((yMax - yMin) * 0.25) / heapRangeBytes,
239
+ yMin,
240
+ };
241
+ }
242
+
243
+ /** Map heap byte values to the time-series Y scale and downsample via LTTB */
244
+ function prepareHeapData(
245
+ heapSeries: HeapPoint[],
246
+ hs: HeapScale,
247
+ ): HeapPlotPoint[] {
248
+ if (heapSeries.length === 0) return [];
249
+ const mapped = heapSeries.map(d => ({
250
+ sample: d.iteration,
251
+ y: hs.yMin + (d.value - hs.heapMinBytes) * hs.scale,
252
+ }));
253
+ return lttb(
254
+ mapped,
255
+ 500,
256
+ d => d.sample,
257
+ d => d.y,
258
+ );
259
+ }
260
+
261
+ /** LTTB downsampling: select n points that best preserve visual shape */
262
+ function lttb<T>(
263
+ data: T[],
264
+ n: number,
265
+ getX: (d: T) => number,
266
+ getY: (d: T) => number,
267
+ ): T[] {
268
+ if (data.length <= n) return data;
269
+ const bucketSize = (data.length - 2) / (n - 2);
270
+ const result: T[] = [data[0]];
271
+ for (let i = 0; i < n - 2; i++) {
272
+ const bStart = Math.floor(i * bucketSize) + 1;
273
+ const bEnd = Math.floor((i + 1) * bucketSize) + 1;
274
+ const nStart = bEnd;
275
+ const nEnd = Math.min(
276
+ Math.floor((i + 2) * bucketSize) + 1,
277
+ data.length - 1,
278
+ );
279
+ let avgX = 0;
280
+ let avgY = 0;
281
+ for (let j = nStart; j < nEnd; j++) {
282
+ avgX += getX(data[j]);
283
+ avgY += getY(data[j]);
284
+ }
285
+ const cnt = nEnd - nStart || 1;
286
+ avgX /= cnt;
287
+ avgY /= cnt;
288
+ const prev = result[result.length - 1];
289
+ const px = getX(prev);
290
+ const py = getY(prev);
291
+ let maxArea = -1;
292
+ let maxIdx = bStart;
293
+ for (let j = bStart; j < bEnd; j++) {
294
+ const area = Math.abs(
295
+ (px - avgX) * (getY(data[j]) - py) - (px - getX(data[j])) * (avgY - py),
296
+ );
297
+ if (area > maxArea) {
298
+ maxArea = area;
299
+ maxIdx = j;
300
+ }
301
+ }
302
+ result.push(data[maxIdx]);
303
+ }
304
+ result.push(data[data.length - 1]);
305
+ return result;
306
+ }
@@ -0,0 +1,136 @@
1
+ export const svgNS = "http://www.w3.org/2000/svg";
2
+
3
+ const toKebab = (k: string) => k.replace(/[A-Z]/g, c => "-" + c.toLowerCase());
4
+ const svgEl = (tag: string) => document.createElementNS(svgNS, tag);
5
+
6
+ /** Apply camelCase attributes to an SVG element, converting to kebab-case */
7
+ export function setAttrs(el: SVGElement, attrs: Record<string, string>): void {
8
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(toKebab(k), v);
9
+ }
10
+
11
+ /** Create an SVG root element with viewBox. */
12
+ export function createSvg(w: number, h: number): SVGSVGElement {
13
+ const svg = document.createElementNS(svgNS, "svg");
14
+ svg.setAttribute("width", String(w));
15
+ svg.setAttribute("height", String(h));
16
+ if (w && h) svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
17
+ return svg;
18
+ }
19
+
20
+ export function rect(
21
+ x: number,
22
+ y: number,
23
+ w: number,
24
+ h: number,
25
+ attrs: Record<string, string>,
26
+ ): SVGRectElement {
27
+ const el = document.createElementNS(svgNS, "rect");
28
+ el.setAttribute("x", String(x));
29
+ el.setAttribute("y", String(y));
30
+ el.setAttribute("width", String(w));
31
+ el.setAttribute("height", String(h));
32
+ setAttrs(el, attrs);
33
+ return el;
34
+ }
35
+
36
+ export function line(
37
+ x1: number,
38
+ y1: number,
39
+ x2: number,
40
+ y2: number,
41
+ attrs: Record<string, string>,
42
+ ): SVGLineElement {
43
+ const el = document.createElementNS(svgNS, "line");
44
+ el.setAttribute("x1", String(x1));
45
+ el.setAttribute("y1", String(y1));
46
+ el.setAttribute("x2", String(x2));
47
+ el.setAttribute("y2", String(y2));
48
+ setAttrs(el, attrs);
49
+ return el;
50
+ }
51
+
52
+ export function text(
53
+ x: number,
54
+ y: number,
55
+ content: string,
56
+ anchor = "start",
57
+ size = "9",
58
+ fill = "#666",
59
+ weight = "400",
60
+ ): SVGTextElement {
61
+ const el = document.createElementNS(svgNS, "text");
62
+ el.setAttribute("x", String(x));
63
+ el.setAttribute("y", String(y));
64
+ el.setAttribute("text-anchor", anchor);
65
+ el.setAttribute("font-size", size);
66
+ el.setAttribute("font-weight", weight);
67
+ el.setAttribute("fill", fill);
68
+ el.textContent = content;
69
+ return el;
70
+ }
71
+
72
+ export function path(d: string, attrs: Record<string, string>): SVGPathElement {
73
+ const el = document.createElementNS(svgNS, "path");
74
+ el.setAttribute("d", d);
75
+ setAttrs(el, attrs);
76
+ return el;
77
+ }
78
+
79
+ /** Add a turbulence displacement filter for a sketchy/wobbly look */
80
+ export function ensureSketchFilter(svg: SVGSVGElement): string {
81
+ const id = "ci-sketch";
82
+ if (svg.querySelector(`#${id}`)) return id;
83
+ const defs = ensureDefs(svg);
84
+ const filter = svgEl("filter");
85
+ setAttrs(filter, { id, x: "-5%", y: "-5%", width: "110%", height: "110%" });
86
+ const turb = svgEl("feTurbulence");
87
+ setAttrs(turb, {
88
+ type: "turbulence",
89
+ baseFrequency: "0.06",
90
+ numOctaves: "4",
91
+ seed: "1",
92
+ result: "noise",
93
+ });
94
+ const disp = svgEl("feDisplacementMap");
95
+ setAttrs(disp, {
96
+ in: "SourceGraphic",
97
+ in2: "noise",
98
+ scale: "10",
99
+ xChannelSelector: "R",
100
+ yChannelSelector: "G",
101
+ });
102
+ filter.appendChild(turb);
103
+ filter.appendChild(disp);
104
+ defs.appendChild(filter);
105
+ return id;
106
+ }
107
+
108
+ /** Add a diagonal hatch pattern to the SVG defs, reusing if already present */
109
+ export function ensureHatchPattern(svg: SVGSVGElement): string {
110
+ const id = "margin-hatch";
111
+ if (svg.querySelector(`#${id}`)) return id;
112
+ const defs = ensureDefs(svg);
113
+ const pattern = svgEl("pattern");
114
+ setAttrs(pattern, {
115
+ id,
116
+ patternUnits: "userSpaceOnUse",
117
+ width: "5",
118
+ height: "5",
119
+ patternTransform: "rotate(45)",
120
+ });
121
+ const stripe = svgEl("line");
122
+ setAttrs(stripe, { x1: "0", y1: "0", x2: "0", y2: "5" });
123
+ stripe.classList.add("margin-hatch-stroke");
124
+ pattern.appendChild(stripe);
125
+ defs.appendChild(pattern);
126
+ return id;
127
+ }
128
+
129
+ function ensureDefs(svg: SVGSVGElement): SVGDefsElement {
130
+ let defs = svg.querySelector("defs") as SVGDefsElement | null;
131
+ if (!defs) {
132
+ defs = document.createElementNS(svgNS, "defs") as SVGDefsElement;
133
+ svg.insertBefore(defs, svg.firstChild);
134
+ }
135
+ return defs;
136
+ }