benchforge 0.1.9 → 0.1.11

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 (66) hide show
  1. package/README.md +40 -6
  2. package/dist/{BenchRunner-CSKN9zPy.d.mts → BenchRunner-BzyUfiyB.d.mts} +32 -8
  3. package/dist/{BrowserHeapSampler-DCeL42RE.mjs → BrowserHeapSampler-B6asLKWQ.mjs} +57 -57
  4. package/dist/BrowserHeapSampler-B6asLKWQ.mjs.map +1 -0
  5. package/dist/{GcStats-ByEovUi1.mjs → GcStats-wX7Xyblu.mjs} +15 -15
  6. package/dist/GcStats-wX7Xyblu.mjs.map +1 -0
  7. package/dist/HeapSampler-B8dtKHn1.mjs.map +1 -1
  8. package/dist/{TimingUtils-ClclVQ7E.mjs → TimingUtils-DwOwkc8G.mjs} +225 -225
  9. package/dist/TimingUtils-DwOwkc8G.mjs.map +1 -0
  10. package/dist/bin/benchforge.mjs +1 -1
  11. package/dist/browser/index.js +210 -210
  12. package/dist/index.d.mts +102 -46
  13. package/dist/index.mjs +3 -3
  14. package/dist/runners/WorkerScript.d.mts +1 -1
  15. package/dist/runners/WorkerScript.mjs +66 -66
  16. package/dist/runners/WorkerScript.mjs.map +1 -1
  17. package/dist/{src-Cf_LXwlp.mjs → src-B-DDaCa9.mjs} +1225 -990
  18. package/dist/src-B-DDaCa9.mjs.map +1 -0
  19. package/package.json +2 -1
  20. package/src/BenchMatrix.ts +125 -125
  21. package/src/BenchmarkReport.ts +50 -45
  22. package/src/HtmlDataPrep.ts +21 -21
  23. package/src/PermutationTest.ts +24 -24
  24. package/src/StandardSections.ts +45 -45
  25. package/src/StatisticalUtils.ts +60 -61
  26. package/src/browser/BrowserGcStats.ts +5 -5
  27. package/src/browser/BrowserHeapSampler.ts +63 -63
  28. package/src/cli/CliArgs.ts +6 -3
  29. package/src/cli/FilterBenchmarks.ts +5 -5
  30. package/src/cli/RunBenchCLI.ts +526 -498
  31. package/src/export/JsonExport.ts +10 -10
  32. package/src/export/PerfettoExport.ts +74 -74
  33. package/src/export/SpeedscopeExport.ts +202 -0
  34. package/src/heap-sample/HeapSampleReport.ts +143 -70
  35. package/src/heap-sample/HeapSampler.ts +55 -12
  36. package/src/heap-sample/ResolvedProfile.ts +89 -0
  37. package/src/html/HtmlReport.ts +33 -33
  38. package/src/html/HtmlTemplate.ts +67 -67
  39. package/src/html/browser/CIPlot.ts +50 -50
  40. package/src/html/browser/HistogramKde.ts +13 -13
  41. package/src/html/browser/LegendUtils.ts +48 -48
  42. package/src/html/browser/RenderPlots.ts +98 -98
  43. package/src/html/browser/SampleTimeSeries.ts +79 -79
  44. package/src/index.ts +6 -0
  45. package/src/matrix/MatrixFilter.ts +6 -6
  46. package/src/matrix/MatrixReport.ts +96 -96
  47. package/src/matrix/VariantLoader.ts +5 -5
  48. package/src/runners/AdaptiveWrapper.ts +151 -151
  49. package/src/runners/BasicRunner.ts +175 -175
  50. package/src/runners/BenchRunner.ts +8 -8
  51. package/src/runners/GcStats.ts +22 -22
  52. package/src/runners/RunnerOrchestrator.ts +168 -168
  53. package/src/runners/WorkerScript.ts +96 -96
  54. package/src/table-util/Formatters.ts +41 -36
  55. package/src/table-util/TableReport.ts +122 -122
  56. package/src/table-util/test/TableValueExtractor.ts +9 -9
  57. package/src/test/AdaptiveStatistics.integration.ts +7 -39
  58. package/src/test/HeapAttribution.test.ts +51 -0
  59. package/src/test/RunBenchCLI.test.ts +18 -18
  60. package/src/test/TestUtils.ts +24 -24
  61. package/src/tests/BenchMatrix.test.ts +12 -12
  62. package/src/tests/MatrixFilter.test.ts +15 -15
  63. package/dist/BrowserHeapSampler-DCeL42RE.mjs.map +0 -1
  64. package/dist/GcStats-ByEovUi1.mjs.map +0 -1
  65. package/dist/TimingUtils-ClclVQ7E.mjs.map +0 -1
  66. package/dist/src-Cf_LXwlp.mjs.map +0 -1
@@ -8,23 +8,6 @@ import type {
8
8
  TimeSeriesPoint,
9
9
  } from "./Types.ts";
10
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
11
  interface SampleData {
29
12
  benchmark: string;
30
13
  sample: number;
@@ -49,6 +32,23 @@ interface PlotContext {
49
32
  benchmarks: string[];
50
33
  }
51
34
 
35
+ const OPT_STATUS_NAMES: Record<number, string> = {
36
+ 1: "interpreted",
37
+ 129: "sparkplug",
38
+ 17: "turbofan",
39
+ 33: "maglev",
40
+ 49: "turbofan+maglev",
41
+ 32769: "optimized",
42
+ };
43
+ const OPT_TIER_COLORS: Record<string, string> = {
44
+ turbofan: "#22c55e",
45
+ optimized: "#22c55e",
46
+ "turbofan+maglev": "#22c55e",
47
+ maglev: "#eab308",
48
+ sparkplug: "#f97316",
49
+ interpreted: "#dc3545",
50
+ };
51
+
52
52
  /** Create sample time series showing each sample in order */
53
53
  export function createSampleTimeSeries(
54
54
  timeSeries: TimeSeriesPoint[],
@@ -146,68 +146,6 @@ function buildPlotContext(timeSeries: TimeSeriesPoint[]): PlotContext {
146
146
  };
147
147
  }
148
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
149
  /** Scale heap byte values into the plot's Y coordinate range */
212
150
  function prepareHeapData(heapSeries: HeapPoint[], yMin: number, yMax: number) {
213
151
  if (heapSeries.length === 0) return [];
@@ -387,3 +325,65 @@ function buildLegendItems(
387
325
  }
388
326
  return items;
389
327
  }
328
+
329
+ function buildSampleData(
330
+ timeSeries: TimeSeriesPoint[],
331
+ benchmarks: string[],
332
+ ): Omit<SampleData, "displayValue">[] {
333
+ const result: Omit<SampleData, "displayValue">[] = [];
334
+ for (const benchmark of benchmarks) {
335
+ const isBaseline = benchmark.includes("(baseline)");
336
+ for (const d of timeSeries.filter(t => t.benchmark === benchmark)) {
337
+ const optTier =
338
+ d.optStatus !== undefined
339
+ ? OPT_STATUS_NAMES[d.optStatus] || "unknown"
340
+ : null;
341
+ result.push({
342
+ benchmark,
343
+ sample: d.iteration,
344
+ value: d.value,
345
+ isBaseline,
346
+ isWarmup: d.isWarmup || false,
347
+ optTier,
348
+ });
349
+ }
350
+ }
351
+ return result;
352
+ }
353
+
354
+ /** Pick display unit (ns/us/ms) based on average value magnitude */
355
+ function getTimeUnit(values: number[]) {
356
+ const avg = d3.mean(values)!;
357
+ const fmt0 = (d: number) => d3.format(",.0f")(d);
358
+ const fmt1 = (d: number) => d3.format(",.1f")(d);
359
+ if (avg < 0.001)
360
+ return {
361
+ unitSuffix: "ns",
362
+ convertValue: (ms: number) => ms * 1e6,
363
+ formatValue: fmt0,
364
+ };
365
+ if (avg < 1)
366
+ return {
367
+ unitSuffix: "μs",
368
+ convertValue: (ms: number) => ms * 1e3,
369
+ formatValue: fmt1,
370
+ };
371
+ return {
372
+ unitSuffix: "ms",
373
+ convertValue: (ms: number) => ms,
374
+ formatValue: fmt1,
375
+ };
376
+ }
377
+
378
+ /** Compute Y axis range with padding, snapping yMin to a round number */
379
+ function computeYRange(values: number[]) {
380
+ const dataMin = d3.min(values)!;
381
+ const dataMax = d3.max(values)!;
382
+ const dataRange = dataMax - dataMin;
383
+ const padding = dataRange * 0.15;
384
+ let yMin = dataMin - padding;
385
+ const magnitude = 10 ** Math.floor(Math.log10(Math.abs(yMin)));
386
+ yMin = Math.floor(yMin / magnitude) * magnitude;
387
+ if (dataMin > 0 && yMin < 0) yMin = 0;
388
+ return { yMin, yMax: dataMax + dataRange * 0.05 };
389
+ }
package/src/index.ts CHANGED
@@ -44,6 +44,12 @@ export {
44
44
  } from "./cli/RunBenchCLI.ts";
45
45
  export * from "./export/JsonFormat.ts";
46
46
  export { exportPerfettoTrace } from "./export/PerfettoExport.ts";
47
+ export {
48
+ exportAndLaunchSpeedscope,
49
+ exportSpeedscope,
50
+ heapProfileToSpeedscope,
51
+ launchSpeedscope,
52
+ } from "./export/SpeedscopeExport.ts";
47
53
  export type { GitVersion } from "./GitUtils.ts";
48
54
  export {
49
55
  formatDateWithTimezone,
@@ -8,6 +8,12 @@ export interface MatrixFilter {
8
8
  variant?: string;
9
9
  }
10
10
 
11
+ /** Filtered matrix with explicit case and variant lists */
12
+ export interface FilteredMatrix<T = unknown> extends BenchMatrix<T> {
13
+ filteredCases?: string[];
14
+ filteredVariants?: string[];
15
+ }
16
+
11
17
  /** Parse filter string: "case/variant", "case/", "/variant", or "case" */
12
18
  export function parseMatrixFilter(filter: string): MatrixFilter {
13
19
  if (filter.includes("/")) {
@@ -20,12 +26,6 @@ export function parseMatrixFilter(filter: string): MatrixFilter {
20
26
  return { case: filter };
21
27
  }
22
28
 
23
- /** Filtered matrix with explicit case and variant lists */
24
- export interface FilteredMatrix<T = unknown> extends BenchMatrix<T> {
25
- filteredCases?: string[];
26
- filteredVariants?: string[];
27
- }
28
-
29
29
  /** Apply filter to a matrix, merging with existing filters via intersection */
30
30
  export async function filterMatrix<T>(
31
31
  matrix: FilteredMatrix<T>,
@@ -43,6 +43,39 @@ interface MatrixReportRow extends Record<string, unknown> {
43
43
  diffCI?: DifferenceCI;
44
44
  }
45
45
 
46
+ /** GC statistics columns - derived from gcStatsSection for consistency */
47
+ export const gcStatsColumns: ExtraColumn[] = gcStatsSection
48
+ .columns()[0]
49
+ .columns.map(col => ({
50
+ key: col.key as string,
51
+ title: col.title,
52
+ groupTitle: "GC",
53
+ extract: (r: CaseResult) =>
54
+ gcStatsSection.extract(r.measured)[col.key as keyof GcStatsInfo],
55
+ formatter: (v: unknown) => col.formatter?.(v) ?? "-",
56
+ }));
57
+
58
+ /** GC pause time column */
59
+ export const gcPauseColumn: ExtraColumn = {
60
+ key: "gcPause",
61
+ title: "pause",
62
+ groupTitle: "GC",
63
+ extract: r => r.measured.gcStats?.gcPauseTime,
64
+ formatter: v => (v != null ? `${(v as number).toFixed(1)}ms` : "-"),
65
+ };
66
+
67
+ /** Heap sampling total bytes column */
68
+ export const heapTotalColumn: ExtraColumn = {
69
+ key: "heapTotal",
70
+ title: "heap",
71
+ extract: r => {
72
+ const profile = r.measured.heapProfile;
73
+ if (!profile?.head) return undefined;
74
+ return totalProfileBytes(profile);
75
+ },
76
+ formatter: formatBytesOrDash,
77
+ };
78
+
46
79
  /** Format matrix results as one table per case */
47
80
  export function reportMatrixResults(
48
81
  results: MatrixResults,
@@ -53,6 +86,11 @@ export function reportMatrixResults(
53
86
  return [header, ...tables].join("\n\n");
54
87
  }
55
88
 
89
+ /** Format bytes with fallback to "-" for missing values */
90
+ function formatBytesOrDash(value: unknown): string {
91
+ return formatBytes(value) ?? "-";
92
+ }
93
+
56
94
  /** Build one table for each case showing all variants */
57
95
  function buildCaseTables(
58
96
  results: MatrixResults,
@@ -86,6 +124,20 @@ function buildCaseTable(
86
124
  return `${caseTitle}\n${table}`;
87
125
  }
88
126
 
127
+ /** Format case title with metadata if available */
128
+ function formatCaseTitle(results: MatrixResults, caseId: string): string {
129
+ const caseResult = results.variants[0]?.cases.find(c => c.caseId === caseId);
130
+ const metadata = caseResult?.metadata;
131
+
132
+ if (metadata && Object.keys(metadata).length > 0) {
133
+ const metaParts = Object.entries(metadata)
134
+ .map(([k, v]) => `${v} ${k}`)
135
+ .join(", ");
136
+ return `${caseId} (${metaParts})`;
137
+ }
138
+ return caseId;
139
+ }
140
+
89
141
  /** Build table using ResultsMapper sections */
90
142
  function buildSectionTable(
91
143
  results: MatrixResults,
@@ -127,24 +179,6 @@ function buildSectionTable(
127
179
  return `${caseTitle}\n${table}`;
128
180
  }
129
181
 
130
- /** Build column groups from ResultsMapper sections */
131
- function buildSectionColumns(
132
- sections: ResultsMapper[],
133
- variantTitle: string,
134
- hasBaseline: boolean,
135
- ): ColumnGroup<Record<string, unknown>>[] {
136
- const nameCol: ColumnGroup<Record<string, unknown>> = {
137
- columns: [{ key: "name", title: variantTitle }],
138
- };
139
-
140
- const sectionColumns = sections.flatMap(s => s.columns());
141
- const columnGroups = hasBaseline
142
- ? injectDiffColumns(sectionColumns)
143
- : (sectionColumns as ColumnGroup<Record<string, unknown>>[]);
144
-
145
- return [nameCol, ...columnGroups];
146
- }
147
-
148
182
  /** Build rows for all variants for a given case */
149
183
  function buildCaseRows(
150
184
  results: MatrixResults,
@@ -157,35 +191,6 @@ function buildCaseRows(
157
191
  });
158
192
  }
159
193
 
160
- /** Build a single row from case result */
161
- function buildRow(
162
- variantId: string,
163
- caseResult: CaseResult,
164
- extraColumns?: ExtraColumn[],
165
- ): MatrixReportRow {
166
- const { measured, baseline } = caseResult;
167
- const samples = measured.samples;
168
- const time = measured.time?.avg ?? average(samples);
169
-
170
- const row: MatrixReportRow = {
171
- name: truncate(variantId, 25),
172
- time,
173
- samples: samples.length,
174
- };
175
-
176
- if (baseline) {
177
- row.diffCI = bootstrapDifferenceCI(baseline.samples, samples);
178
- }
179
-
180
- if (extraColumns) {
181
- for (const col of extraColumns) {
182
- row[col.key] = col.extract(caseResult);
183
- }
184
- }
185
-
186
- return row;
187
- }
188
-
189
194
  /** Build column configuration */
190
195
  function buildColumns(
191
196
  hasBaseline: boolean,
@@ -231,60 +236,55 @@ function buildColumns(
231
236
  return groups;
232
237
  }
233
238
 
234
- /** Format diff with CI, or "baseline" marker */
235
- function formatDiff(value: unknown): string | null {
236
- if (!value) return null;
237
- return formatDiffWithCI(value as DifferenceCI);
239
+ /** Build column groups from ResultsMapper sections */
240
+ function buildSectionColumns(
241
+ sections: ResultsMapper[],
242
+ variantTitle: string,
243
+ hasBaseline: boolean,
244
+ ): ColumnGroup<Record<string, unknown>>[] {
245
+ const nameCol: ColumnGroup<Record<string, unknown>> = {
246
+ columns: [{ key: "name", title: variantTitle }],
247
+ };
248
+
249
+ const sectionColumns = sections.flatMap(s => s.columns());
250
+ const columnGroups = hasBaseline
251
+ ? injectDiffColumns(sectionColumns)
252
+ : (sectionColumns as ColumnGroup<Record<string, unknown>>[]);
253
+
254
+ return [nameCol, ...columnGroups];
238
255
  }
239
256
 
240
- /** Format case title with metadata if available */
241
- function formatCaseTitle(results: MatrixResults, caseId: string): string {
242
- const caseResult = results.variants[0]?.cases.find(c => c.caseId === caseId);
243
- const metadata = caseResult?.metadata;
257
+ /** Build a single row from case result */
258
+ function buildRow(
259
+ variantId: string,
260
+ caseResult: CaseResult,
261
+ extraColumns?: ExtraColumn[],
262
+ ): MatrixReportRow {
263
+ const { measured, baseline } = caseResult;
264
+ const samples = measured.samples;
265
+ const time = measured.time?.avg ?? average(samples);
244
266
 
245
- if (metadata && Object.keys(metadata).length > 0) {
246
- const metaParts = Object.entries(metadata)
247
- .map(([k, v]) => `${v} ${k}`)
248
- .join(", ");
249
- return `${caseId} (${metaParts})`;
267
+ const row: MatrixReportRow = {
268
+ name: truncate(variantId, 25),
269
+ time,
270
+ samples: samples.length,
271
+ };
272
+
273
+ if (baseline) {
274
+ row.diffCI = bootstrapDifferenceCI(baseline.samples, samples);
250
275
  }
251
- return caseId;
252
- }
253
276
 
254
- /** GC statistics columns - derived from gcStatsSection for consistency */
255
- export const gcStatsColumns: ExtraColumn[] = gcStatsSection
256
- .columns()[0]
257
- .columns.map(col => ({
258
- key: col.key as string,
259
- title: col.title,
260
- groupTitle: "GC",
261
- extract: (r: CaseResult) =>
262
- gcStatsSection.extract(r.measured)[col.key as keyof GcStatsInfo],
263
- formatter: (v: unknown) => col.formatter?.(v) ?? "-",
264
- }));
277
+ if (extraColumns) {
278
+ for (const col of extraColumns) {
279
+ row[col.key] = col.extract(caseResult);
280
+ }
281
+ }
265
282
 
266
- /** Format bytes with fallback to "-" for missing values */
267
- function formatBytesOrDash(value: unknown): string {
268
- return formatBytes(value) ?? "-";
283
+ return row;
269
284
  }
270
285
 
271
- /** GC pause time column */
272
- export const gcPauseColumn: ExtraColumn = {
273
- key: "gcPause",
274
- title: "pause",
275
- groupTitle: "GC",
276
- extract: r => r.measured.gcStats?.gcPauseTime,
277
- formatter: v => (v != null ? `${(v as number).toFixed(1)}ms` : "-"),
278
- };
279
-
280
- /** Heap sampling total bytes column */
281
- export const heapTotalColumn: ExtraColumn = {
282
- key: "heapTotal",
283
- title: "heap",
284
- extract: r => {
285
- const profile = r.measured.heapProfile;
286
- if (!profile?.head) return undefined;
287
- return totalProfileBytes(profile);
288
- },
289
- formatter: formatBytesOrDash,
290
- };
286
+ /** Format diff with CI, or "baseline" marker */
287
+ function formatDiff(value: unknown): string | null {
288
+ if (!value) return null;
289
+ return formatDiffWithCI(value as DifferenceCI);
290
+ }
@@ -22,6 +22,11 @@ export async function loadVariant<T = unknown>(
22
22
  return extractVariant(module, variantId, moduleUrl);
23
23
  }
24
24
 
25
+ /** Get module URL for a variant in a directory */
26
+ export function variantModuleUrl(dirUrl: string, variantId: string): string {
27
+ return new URL(`${variantId}.ts`, dirUrl).href;
28
+ }
29
+
25
30
  /** Extract variant from module exports */
26
31
  function extractVariant<T>(
27
32
  module: Record<string, unknown>,
@@ -39,8 +44,3 @@ function extractVariant<T>(
39
44
  }
40
45
  return { setup: setup as (data: T) => unknown, run: run as () => void };
41
46
  }
42
-
43
- /** Get module URL for a variant in a directory */
44
- export function variantModuleUrl(dirUrl: string, variantId: string): string {
45
- return new URL(`${variantId}.ts`, dirUrl).href;
46
- }