benchforge 0.1.0

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 (98) hide show
  1. package/README.md +432 -0
  2. package/bin/benchforge +3 -0
  3. package/dist/bin/benchforge.mjs +9 -0
  4. package/dist/bin/benchforge.mjs.map +1 -0
  5. package/dist/browser/index.js +914 -0
  6. package/dist/index.mjs +3 -0
  7. package/dist/src-CGuaC3Wo.mjs +3676 -0
  8. package/dist/src-CGuaC3Wo.mjs.map +1 -0
  9. package/package.json +49 -0
  10. package/src/BenchMatrix.ts +380 -0
  11. package/src/Benchmark.ts +33 -0
  12. package/src/BenchmarkReport.ts +156 -0
  13. package/src/GitUtils.ts +79 -0
  14. package/src/HtmlDataPrep.ts +148 -0
  15. package/src/MeasuredResults.ts +127 -0
  16. package/src/NodeGC.ts +48 -0
  17. package/src/PermutationTest.ts +115 -0
  18. package/src/StandardSections.ts +268 -0
  19. package/src/StatisticalUtils.ts +176 -0
  20. package/src/TypeUtil.ts +8 -0
  21. package/src/bin/benchforge.ts +4 -0
  22. package/src/browser/BrowserGcStats.ts +44 -0
  23. package/src/browser/BrowserHeapSampler.ts +248 -0
  24. package/src/cli/CliArgs.ts +64 -0
  25. package/src/cli/FilterBenchmarks.ts +68 -0
  26. package/src/cli/RunBenchCLI.ts +856 -0
  27. package/src/export/JsonExport.ts +103 -0
  28. package/src/export/JsonFormat.ts +91 -0
  29. package/src/export/PerfettoExport.ts +203 -0
  30. package/src/heap-sample/HeapSampleReport.ts +196 -0
  31. package/src/heap-sample/HeapSampler.ts +78 -0
  32. package/src/html/HtmlReport.ts +131 -0
  33. package/src/html/HtmlTemplate.ts +284 -0
  34. package/src/html/Types.ts +88 -0
  35. package/src/html/browser/CIPlot.ts +287 -0
  36. package/src/html/browser/HistogramKde.ts +118 -0
  37. package/src/html/browser/LegendUtils.ts +163 -0
  38. package/src/html/browser/RenderPlots.ts +263 -0
  39. package/src/html/browser/SampleTimeSeries.ts +389 -0
  40. package/src/html/browser/Types.ts +96 -0
  41. package/src/html/browser/index.ts +1 -0
  42. package/src/html/index.ts +17 -0
  43. package/src/index.ts +92 -0
  44. package/src/matrix/CaseLoader.ts +36 -0
  45. package/src/matrix/MatrixFilter.ts +103 -0
  46. package/src/matrix/MatrixReport.ts +290 -0
  47. package/src/matrix/VariantLoader.ts +46 -0
  48. package/src/runners/AdaptiveWrapper.ts +391 -0
  49. package/src/runners/BasicRunner.ts +368 -0
  50. package/src/runners/BenchRunner.ts +60 -0
  51. package/src/runners/CreateRunner.ts +11 -0
  52. package/src/runners/GcStats.ts +107 -0
  53. package/src/runners/RunnerOrchestrator.ts +374 -0
  54. package/src/runners/RunnerUtils.ts +2 -0
  55. package/src/runners/TimingUtils.ts +13 -0
  56. package/src/runners/WorkerScript.ts +256 -0
  57. package/src/table-util/ConvergenceFormatters.ts +19 -0
  58. package/src/table-util/Formatters.ts +152 -0
  59. package/src/table-util/README.md +70 -0
  60. package/src/table-util/TableReport.ts +293 -0
  61. package/src/table-util/test/TableReport.test.ts +105 -0
  62. package/src/table-util/test/TableValueExtractor.test.ts +41 -0
  63. package/src/table-util/test/TableValueExtractor.ts +100 -0
  64. package/src/test/AdaptiveRunner.test.ts +185 -0
  65. package/src/test/AdaptiveStatistics.integration.ts +119 -0
  66. package/src/test/BenchmarkReport.test.ts +82 -0
  67. package/src/test/BrowserBench.e2e.test.ts +44 -0
  68. package/src/test/BrowserBench.test.ts +79 -0
  69. package/src/test/GcStats.test.ts +94 -0
  70. package/src/test/PermutationTest.test.ts +121 -0
  71. package/src/test/RunBenchCLI.test.ts +166 -0
  72. package/src/test/RunnerOrchestrator.test.ts +102 -0
  73. package/src/test/StatisticalUtils.test.ts +112 -0
  74. package/src/test/TestUtils.ts +93 -0
  75. package/src/test/fixtures/test-bench-script.ts +30 -0
  76. package/src/tests/AdaptiveConvergence.test.ts +177 -0
  77. package/src/tests/AdaptiveSampling.test.ts +240 -0
  78. package/src/tests/BenchMatrix.test.ts +366 -0
  79. package/src/tests/MatrixFilter.test.ts +117 -0
  80. package/src/tests/MatrixReport.test.ts +139 -0
  81. package/src/tests/RealDataValidation.test.ts +177 -0
  82. package/src/tests/fixtures/baseline/impl.ts +4 -0
  83. package/src/tests/fixtures/bevy30-samples.ts +158 -0
  84. package/src/tests/fixtures/cases/asyncCases.ts +7 -0
  85. package/src/tests/fixtures/cases/cases.ts +8 -0
  86. package/src/tests/fixtures/cases/variants/product.ts +2 -0
  87. package/src/tests/fixtures/cases/variants/sum.ts +2 -0
  88. package/src/tests/fixtures/discover/fast.ts +1 -0
  89. package/src/tests/fixtures/discover/slow.ts +4 -0
  90. package/src/tests/fixtures/invalid/bad.ts +1 -0
  91. package/src/tests/fixtures/loader/fast.ts +1 -0
  92. package/src/tests/fixtures/loader/slow.ts +4 -0
  93. package/src/tests/fixtures/loader/stateful.ts +2 -0
  94. package/src/tests/fixtures/stateful/stateful.ts +2 -0
  95. package/src/tests/fixtures/variants/extra.ts +1 -0
  96. package/src/tests/fixtures/variants/impl.ts +1 -0
  97. package/src/tests/fixtures/worker/fast.ts +1 -0
  98. package/src/tests/fixtures/worker/slow.ts +4 -0
@@ -0,0 +1,263 @@
1
+ import { createCIPlot } from "./CIPlot.ts";
2
+ import { createHistogramKde } from "./HistogramKde.ts";
3
+ import { createSampleTimeSeries } from "./SampleTimeSeries.ts";
4
+ import type {
5
+ BenchmarkEntry,
6
+ GcEvent,
7
+ HeapPoint,
8
+ PausePoint,
9
+ ReportData,
10
+ Sample,
11
+ TimeSeriesPoint,
12
+ } from "./Types.ts";
13
+
14
+ /** Render all plots for the benchmark report */
15
+ export function renderPlots(data: ReportData): void {
16
+ const gcEnabled = data.metadata.gcTrackingEnabled ?? false;
17
+ data.groups.forEach((group, groupIndex) => {
18
+ try {
19
+ renderGroup(group, groupIndex, gcEnabled);
20
+ } catch (error) {
21
+ console.error("Error rendering plots for group", groupIndex, error);
22
+ showError(
23
+ groupIndex,
24
+ `Error rendering visualizations: ${(error as Error).message}`,
25
+ );
26
+ }
27
+ });
28
+ }
29
+
30
+ function renderGroup(
31
+ group: ReportData["groups"][0],
32
+ groupIndex: number,
33
+ gcEnabled: boolean,
34
+ ): void {
35
+ const benchmarks = prepareBenchmarks(group);
36
+ if (benchmarks.length === 0 || !benchmarks[0].samples?.length) {
37
+ showError(groupIndex, "No sample data available for visualization");
38
+ return;
39
+ }
40
+ const flattened = flattenSamples(benchmarks);
41
+ const benchmarkNames = benchmarks.map(b => b.name);
42
+
43
+ const currentBenchmark = benchmarks.find(b => !b.isBaseline);
44
+ if (currentBenchmark?.comparisonCI?.histogram) {
45
+ renderToContainer(`#ci-plot-${groupIndex}`, true, () =>
46
+ createCIPlot(currentBenchmark.comparisonCI!),
47
+ );
48
+ }
49
+
50
+ renderToContainer(
51
+ `#histogram-${groupIndex}`,
52
+ flattened.allSamples.length > 0,
53
+ () => createHistogramKde(flattened.allSamples, benchmarkNames),
54
+ );
55
+ const { timeSeries, allGcEvents, allPausePoints, heapSeries } = flattened;
56
+ renderToContainer(
57
+ `#sample-timeseries-${groupIndex}`,
58
+ timeSeries.length > 0,
59
+ () =>
60
+ createSampleTimeSeries(
61
+ timeSeries,
62
+ allGcEvents,
63
+ allPausePoints,
64
+ heapSeries,
65
+ ),
66
+ );
67
+
68
+ const statsContainer = document.querySelector(`#stats-${groupIndex}`);
69
+ if (statsContainer)
70
+ statsContainer.innerHTML = benchmarks
71
+ .map(b => generateStatsHtml(b, gcEnabled))
72
+ .join("");
73
+ }
74
+
75
+ /** Clear a container element and append a freshly created plot */
76
+ function renderToContainer(
77
+ selector: string,
78
+ condition: boolean,
79
+ create: () => SVGSVGElement | HTMLElement,
80
+ ): void {
81
+ const container = document.querySelector(selector);
82
+ if (!container || !condition) return;
83
+ container.innerHTML = "";
84
+ container.appendChild(create());
85
+ }
86
+
87
+ interface PreparedBenchmark extends BenchmarkEntry {
88
+ name: string;
89
+ }
90
+
91
+ /** Combine baseline and benchmarks into a single list with display names */
92
+ function prepareBenchmarks(
93
+ group: ReportData["groups"][0],
94
+ ): PreparedBenchmark[] {
95
+ const benchmarks: PreparedBenchmark[] = [];
96
+ if (group.baseline) {
97
+ const name = group.baseline.name + " (baseline)";
98
+ benchmarks.push({ ...group.baseline, name, isBaseline: true });
99
+ }
100
+ for (const b of group.benchmarks)
101
+ benchmarks.push({ ...b, isBaseline: false });
102
+ return benchmarks;
103
+ }
104
+
105
+ interface FlattenedData {
106
+ allSamples: Sample[];
107
+ timeSeries: TimeSeriesPoint[];
108
+ heapSeries: HeapPoint[];
109
+ allGcEvents: GcEvent[];
110
+ allPausePoints: PausePoint[];
111
+ }
112
+
113
+ function flattenSamples(benchmarks: PreparedBenchmark[]): FlattenedData {
114
+ const result: FlattenedData = {
115
+ allSamples: [],
116
+ timeSeries: [],
117
+ heapSeries: [],
118
+ allGcEvents: [],
119
+ allPausePoints: [],
120
+ };
121
+ for (const b of benchmarks)
122
+ if (b.samples?.length) flattenBenchmark(b, result);
123
+ return result;
124
+ }
125
+
126
+ /** Extract time series, heap, GC, and pause data from one benchmark */
127
+ function flattenBenchmark(b: PreparedBenchmark, out: FlattenedData): void {
128
+ const warmupCount = b.warmupSamples?.length || 0;
129
+ b.warmupSamples?.forEach((value, i) => {
130
+ out.timeSeries.push({
131
+ benchmark: b.name,
132
+ iteration: i - warmupCount,
133
+ value,
134
+ isWarmup: true,
135
+ });
136
+ });
137
+
138
+ const sampleEndTimes = cumulativeSum(b.samples);
139
+ b.samples.forEach((value, i) => {
140
+ out.allSamples.push({ benchmark: b.name, value, iteration: i });
141
+ out.timeSeries.push({
142
+ benchmark: b.name,
143
+ iteration: i,
144
+ value,
145
+ isWarmup: false,
146
+ optStatus: b.optSamples?.[i],
147
+ });
148
+ if (b.heapSamples?.[i] !== undefined) {
149
+ out.heapSeries.push({
150
+ benchmark: b.name,
151
+ iteration: i,
152
+ value: b.heapSamples[i],
153
+ });
154
+ }
155
+ });
156
+
157
+ b.gcEvents?.forEach(gc => {
158
+ const idx = sampleEndTimes.findIndex(t => t >= gc.offset);
159
+ out.allGcEvents.push({
160
+ benchmark: b.name,
161
+ sampleIndex: idx >= 0 ? idx : b.samples.length - 1,
162
+ duration: gc.duration,
163
+ });
164
+ });
165
+ b.pausePoints?.forEach(p => {
166
+ out.allPausePoints.push({
167
+ benchmark: b.name,
168
+ sampleIndex: p.sampleIndex,
169
+ durationMs: p.durationMs,
170
+ });
171
+ });
172
+ }
173
+
174
+ function cumulativeSum(arr: number[]): number[] {
175
+ const result: number[] = [];
176
+ let sum = 0;
177
+ for (const v of arr) {
178
+ sum += v;
179
+ result.push(sum);
180
+ }
181
+ return result;
182
+ }
183
+
184
+ function showError(groupIndex: number, message: string): void {
185
+ const container = document.querySelector(`#group-${groupIndex}`);
186
+ if (container) container.innerHTML = `<div class="error">${message}</div>`;
187
+ }
188
+
189
+ function formatPct(v: number): string {
190
+ const sign = v >= 0 ? "+" : "";
191
+ return sign + v.toFixed(1) + "%";
192
+ }
193
+
194
+ function generateCIHtml(ci: BenchmarkEntry["comparisonCI"]): string {
195
+ if (!ci) return "";
196
+ const text = `${formatPct(ci.percent)} [${formatPct(ci.ci[0])}, ${formatPct(ci.ci[1])}]`;
197
+ return `
198
+ <div class="stat-item">
199
+ <div class="stat-label">vs Baseline</div>
200
+ <div class="stat-value ci-${ci.direction}">${text}</div>
201
+ </div>
202
+ `;
203
+ }
204
+
205
+ function generateStatsHtml(b: PreparedBenchmark, gcEnabled: boolean): string {
206
+ const ciHtml = generateCIHtml(b.comparisonCI);
207
+
208
+ if (b.sectionStats?.length) {
209
+ const stats = gcEnabled
210
+ ? b.sectionStats
211
+ : b.sectionStats.filter(s => s.groupTitle !== "gc");
212
+ const statsHtml = stats
213
+ .map(
214
+ stat => `
215
+ <div class="stat-item">
216
+ <div class="stat-label">${stat.groupTitle ? stat.groupTitle + " " : ""}${stat.label}</div>
217
+ <div class="stat-value">${stat.value}</div>
218
+ </div>
219
+ `,
220
+ )
221
+ .join("");
222
+ return `
223
+ <div class="summary-stats">
224
+ <h3 style="margin-bottom: 10px; color: #333;">${b.name}</h3>
225
+ <div class="stats-grid">${ciHtml}${statsHtml}</div>
226
+ </div>
227
+ `;
228
+ }
229
+
230
+ // Fallback to hardcoded stats
231
+ return `
232
+ <div class="summary-stats">
233
+ <h3 style="margin-bottom: 10px; color: #333;">${b.name}</h3>
234
+ <div class="stats-grid">
235
+ ${ciHtml}
236
+ <div class="stat-item">
237
+ <div class="stat-label">Min</div>
238
+ <div class="stat-value">${b.stats.min.toFixed(3)}ms</div>
239
+ </div>
240
+ <div class="stat-item">
241
+ <div class="stat-label">Median</div>
242
+ <div class="stat-value">${b.stats.p50.toFixed(3)}ms</div>
243
+ </div>
244
+ <div class="stat-item">
245
+ <div class="stat-label">Mean</div>
246
+ <div class="stat-value">${b.stats.avg.toFixed(3)}ms</div>
247
+ </div>
248
+ <div class="stat-item">
249
+ <div class="stat-label">Max</div>
250
+ <div class="stat-value">${b.stats.max.toFixed(3)}ms</div>
251
+ </div>
252
+ <div class="stat-item">
253
+ <div class="stat-label">P75</div>
254
+ <div class="stat-value">${b.stats.p75.toFixed(3)}ms</div>
255
+ </div>
256
+ <div class="stat-item">
257
+ <div class="stat-label">P99</div>
258
+ <div class="stat-value">${b.stats.p99.toFixed(3)}ms</div>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ `;
263
+ }
@@ -0,0 +1,389 @@
1
+ import * as Plot from "@observablehq/plot";
2
+ import * as d3 from "d3";
3
+ import { buildLegend, type LegendItem } from "./LegendUtils.ts";
4
+ import type {
5
+ GcEvent,
6
+ HeapPoint,
7
+ PausePoint,
8
+ TimeSeriesPoint,
9
+ } from "./Types.ts";
10
+
11
+ const OPT_STATUS_NAMES: Record<number, string> = {
12
+ 1: "interpreted",
13
+ 129: "sparkplug",
14
+ 17: "turbofan",
15
+ 33: "maglev",
16
+ 49: "turbofan+maglev",
17
+ 32769: "optimized",
18
+ };
19
+ const OPT_TIER_COLORS: Record<string, string> = {
20
+ turbofan: "#22c55e",
21
+ optimized: "#22c55e",
22
+ "turbofan+maglev": "#22c55e",
23
+ maglev: "#eab308",
24
+ sparkplug: "#f97316",
25
+ interpreted: "#dc3545",
26
+ };
27
+
28
+ interface SampleData {
29
+ benchmark: string;
30
+ sample: number;
31
+ value: number;
32
+ displayValue: number;
33
+ isBaseline: boolean;
34
+ isWarmup: boolean;
35
+ optTier: string | null;
36
+ }
37
+
38
+ interface PlotContext {
39
+ convertedData: SampleData[];
40
+ xMin: number;
41
+ xMax: number;
42
+ yMin: number;
43
+ yMax: number;
44
+ unitSuffix: string;
45
+ formatValue: (d: number) => string;
46
+ convertValue: (ms: number) => number;
47
+ hasWarmup: boolean;
48
+ optTiers: string[];
49
+ benchmarks: string[];
50
+ }
51
+
52
+ /** Create sample time series showing each sample in order */
53
+ export function createSampleTimeSeries(
54
+ timeSeries: TimeSeriesPoint[],
55
+ gcEvents: GcEvent[] = [],
56
+ pausePoints: PausePoint[] = [],
57
+ heapSeries: HeapPoint[] = [],
58
+ ): SVGSVGElement | HTMLElement {
59
+ const ctx = buildPlotContext(timeSeries);
60
+ const heapData = prepareHeapData(heapSeries, ctx.yMin, ctx.yMax);
61
+
62
+ return Plot.plot({
63
+ marginTop: 24,
64
+ marginLeft: 70,
65
+ marginBottom: 60,
66
+ marginRight: 110,
67
+ width: 550,
68
+ height: 300,
69
+ style: { fontSize: "14px" },
70
+ x: {
71
+ label: "Sample",
72
+ labelAnchor: "center",
73
+ labelOffset: 45,
74
+ grid: true,
75
+ domain: [ctx.xMin, ctx.xMax],
76
+ },
77
+ y: {
78
+ label: `Time (${ctx.unitSuffix})`,
79
+ labelAnchor: "top",
80
+ labelArrow: false,
81
+ grid: true,
82
+ domain: [ctx.yMin, ctx.yMax],
83
+ tickFormat: ctx.formatValue,
84
+ },
85
+ color: { legend: false, scheme: "observable10" },
86
+ marks: [
87
+ ...heapMarks(heapData, ctx.yMin),
88
+ ...(ctx.hasWarmup
89
+ ? [
90
+ Plot.ruleX([0], {
91
+ stroke: "#999",
92
+ strokeWidth: 1,
93
+ strokeDasharray: "4,4",
94
+ }),
95
+ ]
96
+ : []),
97
+ gcMark(gcEvents, ctx.yMin, ctx.convertValue),
98
+ ...pauseMarks(pausePoints, ctx.yMin, ctx.yMax),
99
+ ...sampleDotMarks(ctx),
100
+ Plot.ruleY([ctx.yMin], { stroke: "black", strokeWidth: 1 }),
101
+ ...buildLegend(
102
+ { xMin: ctx.xMin, xMax: ctx.xMax, yMax: ctx.yMax },
103
+ buildLegendItems(
104
+ ctx.hasWarmup,
105
+ gcEvents.length,
106
+ pausePoints.length,
107
+ heapData.length > 0,
108
+ ctx.optTiers,
109
+ ctx.benchmarks,
110
+ ),
111
+ ),
112
+ ],
113
+ });
114
+ }
115
+
116
+ function buildPlotContext(timeSeries: TimeSeriesPoint[]): PlotContext {
117
+ const benchmarks = [...new Set(timeSeries.map(d => d.benchmark))];
118
+ const sampleData = buildSampleData(timeSeries, benchmarks);
119
+ const { unitSuffix, convertValue, formatValue } = getTimeUnit(
120
+ sampleData.map(d => d.value),
121
+ );
122
+ const convertedData = sampleData.map(d => ({
123
+ ...d,
124
+ displayValue: convertValue(d.value),
125
+ }));
126
+ const { yMin, yMax } = computeYRange(convertedData.map(d => d.displayValue));
127
+ const xMin = d3.min(convertedData, d => d.sample)!;
128
+ const xMax = d3.max(convertedData, d => d.sample)!;
129
+ const hasWarmup = convertedData.some(d => d.isWarmup);
130
+ const tierSet = new Set(
131
+ convertedData.filter(d => d.optTier && !d.isWarmup).map(d => d.optTier),
132
+ );
133
+ const optTiers = [...tierSet].filter((t): t is string => t !== null);
134
+ return {
135
+ convertedData,
136
+ xMin,
137
+ xMax,
138
+ yMin,
139
+ yMax,
140
+ unitSuffix,
141
+ formatValue,
142
+ convertValue,
143
+ hasWarmup,
144
+ optTiers,
145
+ benchmarks,
146
+ };
147
+ }
148
+
149
+ function buildSampleData(
150
+ timeSeries: TimeSeriesPoint[],
151
+ benchmarks: string[],
152
+ ): Omit<SampleData, "displayValue">[] {
153
+ const result: Omit<SampleData, "displayValue">[] = [];
154
+ for (const benchmark of benchmarks) {
155
+ const isBaseline = benchmark.includes("(baseline)");
156
+ for (const d of timeSeries.filter(t => t.benchmark === benchmark)) {
157
+ const optTier =
158
+ d.optStatus !== undefined
159
+ ? OPT_STATUS_NAMES[d.optStatus] || "unknown"
160
+ : null;
161
+ result.push({
162
+ benchmark,
163
+ sample: d.iteration,
164
+ value: d.value,
165
+ isBaseline,
166
+ isWarmup: d.isWarmup || false,
167
+ optTier,
168
+ });
169
+ }
170
+ }
171
+ return result;
172
+ }
173
+
174
+ /** Pick display unit (ns/us/ms) based on average value magnitude */
175
+ function getTimeUnit(values: number[]) {
176
+ const avg = d3.mean(values)!;
177
+ const fmt0 = (d: number) => d3.format(",.0f")(d);
178
+ const fmt1 = (d: number) => d3.format(",.1f")(d);
179
+ if (avg < 0.001)
180
+ return {
181
+ unitSuffix: "ns",
182
+ convertValue: (ms: number) => ms * 1e6,
183
+ formatValue: fmt0,
184
+ };
185
+ if (avg < 1)
186
+ return {
187
+ unitSuffix: "μs",
188
+ convertValue: (ms: number) => ms * 1e3,
189
+ formatValue: fmt1,
190
+ };
191
+ return {
192
+ unitSuffix: "ms",
193
+ convertValue: (ms: number) => ms,
194
+ formatValue: fmt1,
195
+ };
196
+ }
197
+
198
+ /** Compute Y axis range with padding, snapping yMin to a round number */
199
+ function computeYRange(values: number[]) {
200
+ const dataMin = d3.min(values)!;
201
+ const dataMax = d3.max(values)!;
202
+ const dataRange = dataMax - dataMin;
203
+ const padding = dataRange * 0.15;
204
+ let yMin = dataMin - padding;
205
+ const magnitude = 10 ** Math.floor(Math.log10(Math.abs(yMin)));
206
+ yMin = Math.floor(yMin / magnitude) * magnitude;
207
+ if (dataMin > 0 && yMin < 0) yMin = 0;
208
+ return { yMin, yMax: dataMax + dataRange * 0.05 };
209
+ }
210
+
211
+ /** Scale heap byte values into the plot's Y coordinate range */
212
+ function prepareHeapData(heapSeries: HeapPoint[], yMin: number, yMax: number) {
213
+ if (heapSeries.length === 0) return [];
214
+ const heapMin = d3.min(heapSeries, d => d.value)!;
215
+ const heapRange = d3.max(heapSeries, d => d.value)! - heapMin || 1;
216
+ const scale = ((yMax - yMin) * 0.25) / heapRange;
217
+ return heapSeries.map(d => ({
218
+ sample: d.iteration,
219
+ y: yMin + (d.value - heapMin) * scale,
220
+ heapMB: d.value / 1024 / 1024,
221
+ }));
222
+ }
223
+
224
+ function heapMarks(
225
+ heapData: { sample: number; y: number; heapMB: number }[],
226
+ yMin: number,
227
+ ): any[] {
228
+ if (heapData.length === 0) return [];
229
+ return [
230
+ Plot.areaY(heapData, {
231
+ x: "sample",
232
+ y: "y",
233
+ y1: yMin,
234
+ fill: "#9333ea",
235
+ fillOpacity: 0.15,
236
+ stroke: "#9333ea",
237
+ strokeWidth: 1,
238
+ strokeOpacity: 0.4,
239
+ }),
240
+ Plot.tip(
241
+ heapData,
242
+ Plot.pointerX({
243
+ x: "sample",
244
+ y: "y",
245
+ title: (d: { heapMB: number }) => `Heap: ${d.heapMB.toFixed(1)} MB`,
246
+ }),
247
+ ),
248
+ ];
249
+ }
250
+
251
+ function gcMark(
252
+ gcEvents: GcEvent[],
253
+ yMin: number,
254
+ convertValue: (ms: number) => number,
255
+ ): any {
256
+ const data = gcEvents.map(gc => ({
257
+ x1: gc.sampleIndex,
258
+ y1: yMin,
259
+ x2: gc.sampleIndex,
260
+ y2: yMin + convertValue(gc.duration),
261
+ duration: gc.duration,
262
+ }));
263
+ return Plot.link(data, {
264
+ x1: "x1",
265
+ y1: "y1",
266
+ x2: "x2",
267
+ y2: "y2",
268
+ stroke: "#22c55e",
269
+ strokeWidth: 2,
270
+ strokeOpacity: 0.8,
271
+ title: (d: { duration: number }) => `GC: ${d.duration.toFixed(2)}ms`,
272
+ });
273
+ }
274
+
275
+ function pauseMarks(
276
+ pausePoints: PausePoint[],
277
+ yMin: number,
278
+ yMax: number,
279
+ ): any[] {
280
+ return pausePoints.map(p =>
281
+ Plot.ruleX([p.sampleIndex], {
282
+ y1: yMin,
283
+ y2: yMax,
284
+ stroke: "#888",
285
+ strokeWidth: 1,
286
+ strokeDasharray: "4,4",
287
+ strokeOpacity: 0.7,
288
+ title: `Pause: ${p.durationMs}ms`,
289
+ }),
290
+ );
291
+ }
292
+
293
+ function sampleDotMarks(ctx: PlotContext): any[] {
294
+ const { convertedData, unitSuffix, formatValue } = ctx;
295
+ const tipTitle = (d: SampleData) =>
296
+ d.optTier
297
+ ? `Sample ${d.sample}: ${formatValue(d.displayValue)}${unitSuffix} [${d.optTier}]`
298
+ : `Sample ${d.sample}: ${formatValue(d.displayValue)}${unitSuffix}`;
299
+ return [
300
+ Plot.dot(
301
+ convertedData.filter(d => d.isWarmup),
302
+ {
303
+ x: "sample",
304
+ y: "displayValue",
305
+ stroke: "#dc3545",
306
+ fill: "none",
307
+ strokeWidth: 1.5,
308
+ r: 3,
309
+ opacity: 0.7,
310
+ title: (d: SampleData) =>
311
+ `Warmup ${d.sample}: ${formatValue(d.displayValue)}${unitSuffix}`,
312
+ },
313
+ ),
314
+ Plot.dot(
315
+ convertedData.filter(d => d.isBaseline && !d.isWarmup),
316
+ {
317
+ x: "sample",
318
+ y: "displayValue",
319
+ stroke: "#ffa500",
320
+ fill: "none",
321
+ strokeWidth: 2,
322
+ r: 3,
323
+ opacity: 0.8,
324
+ title: tipTitle,
325
+ },
326
+ ),
327
+ Plot.dot(
328
+ convertedData.filter(d => !d.isBaseline && !d.isWarmup),
329
+ {
330
+ x: "sample",
331
+ y: "displayValue",
332
+ fill: (d: SampleData) =>
333
+ d.optTier ? OPT_TIER_COLORS[d.optTier] || "#4682b4" : "#4682b4",
334
+ r: 3,
335
+ opacity: 0.8,
336
+ title: tipTitle,
337
+ },
338
+ ),
339
+ ];
340
+ }
341
+
342
+ function buildLegendItems(
343
+ hasWarmup: boolean,
344
+ gcCount: number,
345
+ pauseCount: number,
346
+ hasHeap: boolean,
347
+ optTiers: string[],
348
+ benchmarks: string[],
349
+ ): LegendItem[] {
350
+ const items: LegendItem[] = [];
351
+ if (hasWarmup)
352
+ items.push({ color: "#dc3545", label: "warmup", style: "hollow-dot" });
353
+ if (gcCount > 0)
354
+ items.push({
355
+ color: "#22c55e",
356
+ label: `gc (${gcCount})`,
357
+ style: "vertical-line",
358
+ });
359
+ if (pauseCount > 0)
360
+ items.push({
361
+ color: "#888",
362
+ label: `pause (${pauseCount})`,
363
+ style: "vertical-line",
364
+ strokeDash: "4,4",
365
+ });
366
+ if (hasHeap) items.push({ color: "#9333ea", label: "heap", style: "rect" });
367
+ for (const tier of optTiers)
368
+ items.push({
369
+ color: OPT_TIER_COLORS[tier] || "#4682b4",
370
+ label: tier,
371
+ style: "filled-dot",
372
+ });
373
+ if (optTiers.length === 0) {
374
+ const sorted = [...benchmarks].sort((a, b) => {
375
+ const aBase = a.includes("(baseline)");
376
+ const bBase = b.includes("(baseline)");
377
+ return aBase === bBase ? 0 : aBase ? 1 : -1;
378
+ });
379
+ for (const bm of sorted) {
380
+ const isBase = bm.includes("(baseline)");
381
+ items.push({
382
+ color: isBase ? "#ffa500" : "#4682b4",
383
+ label: bm,
384
+ style: isBase ? "hollow-dot" : "filled-dot",
385
+ });
386
+ }
387
+ }
388
+ return items;
389
+ }