benchforge 0.1.8 → 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 (68) hide show
  1. package/README.md +69 -42
  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 +106 -48
  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-HfimYuW_.mjs → src-B-DDaCa9.mjs} +1250 -991
  18. package/dist/src-B-DDaCa9.mjs.map +1 -0
  19. package/package.json +4 -3
  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 +20 -6
  29. package/src/cli/FilterBenchmarks.ts +5 -5
  30. package/src/cli/RunBenchCLI.ts +533 -476
  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 +36 -11
  60. package/src/test/TestUtils.ts +24 -24
  61. package/src/test/fixtures/fn-export-bench.ts +3 -0
  62. package/src/test/fixtures/suite-export-bench.ts +16 -0
  63. package/src/tests/BenchMatrix.test.ts +12 -12
  64. package/src/tests/MatrixFilter.test.ts +15 -15
  65. package/dist/BrowserHeapSampler-DCeL42RE.mjs.map +0 -1
  66. package/dist/GcStats-ByEovUi1.mjs.map +0 -1
  67. package/dist/TimingUtils-ClclVQ7E.mjs.map +0 -1
  68. package/dist/src-HfimYuW_.mjs.map +0 -1
@@ -1,3 +1,5 @@
1
+ import { basename, resolve } from "node:path";
2
+ import { pathToFileURL } from "node:url";
1
3
  import pico from "picocolors";
2
4
  import { hideBin } from "yargs/helpers";
3
5
  import type {
@@ -12,10 +14,14 @@ import type {
12
14
  ReportGroup,
13
15
  ResultsMapper,
14
16
  } from "../BenchmarkReport.ts";
15
- import { reportResults } from "../BenchmarkReport.ts";
17
+ import { groupReports, reportResults } from "../BenchmarkReport.ts";
16
18
  import type { BrowserProfileResult } from "../browser/BrowserHeapSampler.ts";
17
19
  import { exportBenchmarkJson } from "../export/JsonExport.ts";
18
20
  import { exportPerfettoTrace } from "../export/PerfettoExport.ts";
21
+ import {
22
+ exportAndLaunchSpeedscope,
23
+ exportSpeedscope,
24
+ } from "../export/SpeedscopeExport.ts";
19
25
  import type { GitVersion } from "../GitUtils.ts";
20
26
  import { prepareHtmlData } from "../HtmlDataPrep.ts";
21
27
  import {
@@ -23,10 +29,11 @@ import {
23
29
  filterSites,
24
30
  flattenProfile,
25
31
  formatHeapReport,
32
+ formatRawSamples,
26
33
  type HeapReportOptions,
27
34
  isBrowserUserCode,
28
- totalProfileBytes,
29
35
  } from "../heap-sample/HeapSampleReport.ts";
36
+ import { resolveProfile } from "../heap-sample/ResolvedProfile.ts";
30
37
  import { generateHtmlReport } from "../html/index.ts";
31
38
  import type { MeasuredResults } from "../MeasuredResults.ts";
32
39
  import { loadCasesModule } from "../matrix/CaseLoader.ts";
@@ -61,27 +68,19 @@ import {
61
68
  } from "./CliArgs.ts";
62
69
  import { filterBenchmarks } from "./FilterBenchmarks.ts";
63
70
 
64
- /** Validate CLI argument combinations */
65
- function validateArgs(args: DefaultCliArgs): void {
66
- if (args["gc-stats"] && !args.worker && !args.url) {
67
- throw new Error(
68
- "--gc-stats requires worker mode (the default). Remove --no-worker flag.",
69
- );
70
- }
71
+ export interface ExportOptions {
72
+ results: ReportGroup[];
73
+ args: DefaultCliArgs;
74
+ sections?: any[];
75
+ suiteName?: string;
76
+ currentVersion?: GitVersion;
77
+ baselineVersion?: GitVersion;
71
78
  }
72
79
 
73
- /** Warn about Node-only flags that are ignored in browser mode. */
74
- function warnBrowserFlags(args: DefaultCliArgs): void {
75
- const ignored: string[] = [];
76
- if (!args.worker) ignored.push("--no-worker");
77
- if (args.cpu) ignored.push("--cpu");
78
- if (args["trace-opt"]) ignored.push("--trace-opt");
79
- if (args.collect) ignored.push("--collect");
80
- if (args.adaptive) ignored.push("--adaptive");
81
- if (args.batches > 1) ignored.push("--batches");
82
- if (ignored.length) {
83
- console.warn(yellow(`Ignored in browser mode: ${ignored.join(", ")}`));
84
- }
80
+ export interface MatrixExportOptions {
81
+ sections?: any[];
82
+ currentVersion?: GitVersion;
83
+ baselineVersion?: GitVersion;
85
84
  }
86
85
 
87
86
  type RunParams = {
@@ -100,6 +99,11 @@ type SuiteParams = {
100
99
  batches: number;
101
100
  };
102
101
 
102
+ const isTest = process.env.NODE_ENV === "test" || process.env.VITEST === "true";
103
+ const { yellow, dim } = isTest
104
+ ? { yellow: (s: string) => s, dim: (s: string) => s }
105
+ : pico;
106
+
103
107
  /** Parse CLI with custom configuration */
104
108
  export function parseBenchArgs<T = DefaultCliArgs>(
105
109
  configureArgs?: Configure<T>,
@@ -127,217 +131,6 @@ export async function runBenchmarks(
127
131
  });
128
132
  }
129
133
 
130
- /** Execute all groups in suite */
131
- async function runSuite(params: SuiteParams): Promise<ReportGroup[]> {
132
- const { suite, runner, options, useWorker, batches } = params;
133
- const results: ReportGroup[] = [];
134
- for (const group of suite.groups) {
135
- results.push(await runGroup(group, runner, options, useWorker, batches));
136
- }
137
- return results;
138
- }
139
-
140
- /** Execute group with shared setup, optionally batching to reduce ordering bias */
141
- async function runGroup(
142
- group: BenchGroup,
143
- runner: KnownRunner,
144
- options: RunnerOptions,
145
- useWorker: boolean,
146
- batches = 1,
147
- ): Promise<ReportGroup> {
148
- const { name, benchmarks, baseline, setup, metadata } = group;
149
- const setupParams = await setup?.();
150
- validateBenchmarkParameters(group);
151
-
152
- const runParams = {
153
- runner,
154
- options,
155
- useWorker,
156
- params: setupParams,
157
- metadata,
158
- };
159
- if (batches === 1) {
160
- return runSingleBatch(name, benchmarks, baseline, runParams);
161
- }
162
- return runMultipleBatches(name, benchmarks, baseline, runParams, batches);
163
- }
164
-
165
- /** Run benchmarks in a single batch */
166
- async function runSingleBatch(
167
- name: string,
168
- benchmarks: BenchmarkSpec[],
169
- baseline: BenchmarkSpec | undefined,
170
- runParams: RunParams,
171
- ): Promise<ReportGroup> {
172
- const baselineReport = baseline
173
- ? await runSingleBenchmark(baseline, runParams)
174
- : undefined;
175
- const reports = await serialMap(benchmarks, b =>
176
- runSingleBenchmark(b, runParams),
177
- );
178
- return { name, reports, baseline: baselineReport };
179
- }
180
-
181
- /** Run benchmarks in multiple batches, alternating order to reduce bias */
182
- async function runMultipleBatches(
183
- name: string,
184
- benchmarks: BenchmarkSpec[],
185
- baseline: BenchmarkSpec | undefined,
186
- runParams: RunParams,
187
- batches: number,
188
- ): Promise<ReportGroup> {
189
- const timePerBatch = (runParams.options.maxTime || 5000) / batches;
190
- const batchParams = {
191
- ...runParams,
192
- options: { ...runParams.options, maxTime: timePerBatch },
193
- };
194
- const baselineBatches: MeasuredResults[] = [];
195
- const benchmarkBatches = new Map<string, MeasuredResults[]>();
196
-
197
- for (let i = 0; i < batches; i++) {
198
- const reverseOrder = i % 2 === 1;
199
- await runBatchIteration(
200
- benchmarks,
201
- baseline,
202
- batchParams,
203
- reverseOrder,
204
- baselineBatches,
205
- benchmarkBatches,
206
- );
207
- }
208
-
209
- const meta = runParams.metadata;
210
- return mergeBatchResults(
211
- name,
212
- benchmarks,
213
- baseline,
214
- baselineBatches,
215
- benchmarkBatches,
216
- meta,
217
- );
218
- }
219
-
220
- /** Run one batch iteration in either order */
221
- async function runBatchIteration(
222
- benchmarks: BenchmarkSpec[],
223
- baseline: BenchmarkSpec | undefined,
224
- runParams: RunParams,
225
- reverseOrder: boolean,
226
- baselineBatches: MeasuredResults[],
227
- benchmarkBatches: Map<string, MeasuredResults[]>,
228
- ): Promise<void> {
229
- const runBaseline = async () => {
230
- if (baseline) {
231
- const r = await runSingleBenchmark(baseline, runParams);
232
- baselineBatches.push(r.measuredResults);
233
- }
234
- };
235
- const runBenches = async () => {
236
- for (const b of benchmarks) {
237
- const r = await runSingleBenchmark(b, runParams);
238
- appendToMap(benchmarkBatches, b.name, r.measuredResults);
239
- }
240
- };
241
-
242
- if (reverseOrder) {
243
- await runBenches();
244
- await runBaseline();
245
- } else {
246
- await runBaseline();
247
- await runBenches();
248
- }
249
- }
250
-
251
- /** Merge batch results into final ReportGroup */
252
- function mergeBatchResults(
253
- name: string,
254
- benchmarks: BenchmarkSpec[],
255
- baseline: BenchmarkSpec | undefined,
256
- baselineBatches: MeasuredResults[],
257
- benchmarkBatches: Map<string, MeasuredResults[]>,
258
- metadata?: Record<string, unknown>,
259
- ): ReportGroup {
260
- const mergedBaseline = baseline
261
- ? {
262
- name: baseline.name,
263
- measuredResults: mergeResults(baselineBatches),
264
- metadata,
265
- }
266
- : undefined;
267
- const reports = benchmarks.map(b => ({
268
- name: b.name,
269
- measuredResults: mergeResults(benchmarkBatches.get(b.name) || []),
270
- metadata,
271
- }));
272
- return { name, reports, baseline: mergedBaseline };
273
- }
274
-
275
- /** Run single benchmark and create report */
276
- async function runSingleBenchmark(
277
- spec: BenchmarkSpec,
278
- runParams: RunParams,
279
- ): Promise<BenchmarkReport> {
280
- const { runner, options, useWorker, params, metadata } = runParams;
281
- const benchmarkParams = { spec, runner, options, useWorker, params };
282
- const [result] = await runBenchmark(benchmarkParams);
283
- return { name: spec.name, measuredResults: result, metadata };
284
- }
285
-
286
- /** Warn if parameterized benchmarks lack setup */
287
- function validateBenchmarkParameters(group: BenchGroup): void {
288
- const { name, setup, benchmarks, baseline } = group;
289
- if (setup) return;
290
-
291
- const allBenchmarks = baseline ? [...benchmarks, baseline] : benchmarks;
292
- for (const benchmark of allBenchmarks) {
293
- if (benchmark.fn.length > 0) {
294
- console.warn(
295
- `Benchmark "${benchmark.name}" in group "${name}" expects parameters but no setup() provided.`,
296
- );
297
- }
298
- }
299
- }
300
-
301
- /** Merge multiple batch results into a single MeasuredResults */
302
- function mergeResults(results: MeasuredResults[]): MeasuredResults {
303
- if (results.length === 0) {
304
- throw new Error("Cannot merge empty results array");
305
- }
306
- if (results.length === 1) return results[0];
307
-
308
- const allSamples = results.flatMap(r => r.samples);
309
- const allWarmup = results.flatMap(r => r.warmupSamples || []);
310
- const time = computeStats(allSamples);
311
-
312
- let offset = 0;
313
- const allPausePoints = results.flatMap(r => {
314
- const pts = (r.pausePoints ?? []).map(p => ({
315
- sampleIndex: p.sampleIndex + offset,
316
- durationMs: p.durationMs,
317
- }));
318
- offset += r.samples.length;
319
- return pts;
320
- });
321
-
322
- return {
323
- name: results[0].name,
324
- samples: allSamples,
325
- warmupSamples: allWarmup.length ? allWarmup : undefined,
326
- time,
327
- totalTime: results.reduce((sum, r) => sum + (r.totalTime || 0), 0),
328
- pausePoints: allPausePoints.length ? allPausePoints : undefined,
329
- };
330
- }
331
-
332
- function appendToMap(
333
- map: Map<string, MeasuredResults[]>,
334
- key: string,
335
- value: MeasuredResults,
336
- ) {
337
- if (!map.has(key)) map.set(key, []);
338
- map.get(key)!.push(value);
339
- }
340
-
341
134
  /** Generate table with standard sections */
342
135
  export function defaultReport(
343
136
  groups: ReportGroup[],
@@ -355,25 +148,6 @@ export function defaultReport(
355
148
  return reportResults(groups, sections);
356
149
  }
357
150
 
358
- /** Build report sections based on CLI options */
359
- function buildReportSections(
360
- adaptive: boolean,
361
- gcStats: boolean,
362
- hasCpuData: boolean,
363
- hasOptData: boolean,
364
- ) {
365
- const sections = adaptive
366
- ? [adaptiveSection, totalTimeSection]
367
- : [timeSection];
368
-
369
- if (gcStats) sections.push(gcStatsSection);
370
- if (hasCpuData) sections.push(cpuSection);
371
- if (hasOptData) sections.push(optSection);
372
- sections.push(runsSection);
373
-
374
- return sections;
375
- }
376
-
377
151
  /** Run benchmarks, display table, and optionally generate HTML report */
378
152
  export async function benchExports(
379
153
  suite: BenchSuite,
@@ -406,7 +180,7 @@ export async function browserBenchExports(args: DefaultCliArgs): Promise<void> {
406
180
  const { iterations, time } = args;
407
181
  const result = await profileBrowser({
408
182
  url,
409
- heapSample: args["heap-sample"],
183
+ heapSample: needsHeapSample(args),
410
184
  heapOptions: {
411
185
  samplingInterval: args["heap-interval"],
412
186
  stackDepth: args["heap-depth"],
@@ -428,98 +202,32 @@ export async function browserBenchExports(args: DefaultCliArgs): Promise<void> {
428
202
  await exportReports({ results, args });
429
203
  }
430
204
 
431
- /** Print browser benchmark tables and heap reports */
432
- function printBrowserReport(
433
- result: BrowserProfileResult,
434
- results: ReportGroup[],
435
- args: DefaultCliArgs,
436
- ): void {
437
- const hasSamples = result.samples && result.samples.length > 0;
438
- const sections: ResultsMapper<any>[] = [];
439
- if (hasSamples || result.wallTimeMs != null) {
440
- sections.push(timeSection);
441
- }
442
- if (result.gcStats) {
443
- sections.push(browserGcStatsSection);
444
- }
445
- if (hasSamples || result.wallTimeMs != null) {
446
- sections.push(runsSection);
447
- }
448
- if (sections.length > 0) {
449
- console.log(reportResults(results, sections));
450
- }
451
- if (result.heapProfile) {
452
- printHeapReports(results, {
453
- ...cliHeapReportOptions(args),
454
- isUserCode: isBrowserUserCode,
455
- });
456
- }
457
- }
458
-
459
- /** Wrap browser profile result as ReportGroup[] for the standard pipeline */
460
- function browserResultGroups(
461
- name: string,
462
- result: BrowserProfileResult,
463
- ): ReportGroup[] {
464
- const { gcStats, heapProfile } = result;
465
- let measured: MeasuredResults;
466
-
467
- // Bench function mode: multiple timing samples with real statistics
468
- if (result.samples && result.samples.length > 0) {
469
- const { samples } = result;
470
- const totalTime = result.wallTimeMs ? result.wallTimeMs / 1000 : undefined;
471
- measured = {
472
- name,
473
- samples,
474
- time: computeStats(samples),
475
- totalTime,
476
- gcStats,
477
- heapProfile,
478
- };
479
- } else {
480
- // Lap mode: 0 laps = single wall-clock, N laps handled above
481
- const wallMs = result.wallTimeMs ?? 0;
482
- const time = {
483
- min: wallMs,
484
- max: wallMs,
485
- avg: wallMs,
486
- p50: wallMs,
487
- p75: wallMs,
488
- p99: wallMs,
489
- p999: wallMs,
490
- };
491
- measured = { name, samples: [wallMs], time, gcStats, heapProfile };
492
- }
493
-
494
- return [{ name, reports: [{ name, measuredResults: measured }] }];
495
- }
496
-
497
205
  /** Print heap allocation reports for benchmarks with heap profiles */
498
206
  export function printHeapReports(
499
207
  groups: ReportGroup[],
500
208
  options: HeapReportOptions,
501
209
  ): void {
502
210
  for (const group of groups) {
503
- const allReports = group.baseline
504
- ? [...group.reports, group.baseline]
505
- : group.reports;
506
-
507
- for (const report of allReports) {
211
+ for (const report of groupReports(group)) {
508
212
  const { heapProfile } = report.measuredResults;
509
213
  if (!heapProfile) continue;
510
214
 
511
215
  console.log(dim(`\n─── Heap profile: ${report.name} ───`));
512
- const totalAll = totalProfileBytes(heapProfile);
513
- const sites = flattenProfile(heapProfile);
216
+ const resolved = resolveProfile(heapProfile);
217
+ const sites = flattenProfile(resolved);
514
218
  const userSites = filterSites(sites, options.isUserCode);
515
219
  const totalUserCode = userSites.reduce((sum, s) => sum + s.bytes, 0);
516
220
  const aggregated = aggregateSites(options.userOnly ? userSites : sites);
517
221
  const extra = {
518
- totalAll,
222
+ totalAll: resolved.totalBytes,
519
223
  totalUserCode,
520
- sampleCount: heapProfile.samples?.length,
224
+ sampleCount: resolved.sortedSamples?.length,
521
225
  };
522
226
  console.log(formatHeapReport(aggregated, { ...options, ...extra }));
227
+ if (options.raw) {
228
+ console.log(dim(`\n─── Raw samples: ${report.name} ───`));
229
+ console.log(formatRawSamples(resolved));
230
+ }
523
231
  }
524
232
  }
525
233
  }
@@ -534,8 +242,12 @@ export async function runDefaultBench(
534
242
  await browserBenchExports(args);
535
243
  } else if (suite) {
536
244
  await benchExports(suite, args);
245
+ } else if (args.file) {
246
+ await fileBenchExports(args.file, args);
537
247
  } else {
538
- throw new Error("Either --url or a BenchSuite is required.");
248
+ throw new Error(
249
+ "Provide a benchmark file, --url for browser mode, or pass a BenchSuite directly.",
250
+ );
539
251
  }
540
252
  }
541
253
 
@@ -553,51 +265,10 @@ export function cliToRunnerOptions(args: DefaultCliArgs): RunnerOptions {
553
265
  };
554
266
  }
555
267
 
556
- /** Create options for adaptive mode */
557
- function createAdaptiveOptions(args: DefaultCliArgs): RunnerOptions {
558
- return {
559
- minTime: (args["min-time"] ?? 1) * 1000,
560
- maxTime: defaultAdaptiveMaxTime * 1000,
561
- targetConfidence: args.convergence,
562
- adaptive: true,
563
- ...cliCommonOptions(args),
564
- } as any;
565
- }
566
-
567
- /** Runner/matrix options shared across all CLI modes */
568
- function cliCommonOptions(args: DefaultCliArgs) {
569
- const { collect, cpu, warmup } = args;
570
- const { "trace-opt": traceOpt, "skip-settle": noSettle } = args;
571
- const { "pause-first": pauseFirst, "pause-interval": pauseInterval } = args;
572
- const { "pause-duration": pauseDuration, "gc-stats": gcStats } = args;
573
- const { "heap-sample": heapSample, "heap-interval": heapInterval } = args;
574
- const { "heap-depth": heapDepth } = args;
575
- return {
576
- collect,
577
- cpuCounters: cpu,
578
- warmup,
579
- traceOpt,
580
- noSettle,
581
- pauseFirst,
582
- pauseInterval,
583
- pauseDuration,
584
- gcStats,
585
- heapSample,
586
- heapInterval,
587
- heapDepth,
588
- };
589
- }
590
-
591
- const isTest = process.env.NODE_ENV === "test" || process.env.VITEST === "true";
592
- const { yellow, dim } = isTest
593
- ? { yellow: (s: string) => s, dim: (s: string) => s }
594
- : pico;
595
-
596
268
  /** Log V8 optimization tier distribution and deoptimizations */
597
269
  export function reportOptStatus(groups: ReportGroup[]): void {
598
- const optData = groups.flatMap(({ reports, baseline }) => {
599
- const all = baseline ? [...reports, baseline] : reports;
600
- return all
270
+ const optData = groups.flatMap(group => {
271
+ return groupReports(group)
601
272
  .filter(r => r.measuredResults.optStatus)
602
273
  .map(r => ({
603
274
  name: r.name,
@@ -634,34 +305,11 @@ export function hasField(
634
305
  results: ReportGroup[],
635
306
  field: keyof MeasuredResults,
636
307
  ): boolean {
637
- return results.some(({ reports, baseline }) => {
638
- const all = baseline ? [...reports, baseline] : reports;
639
- return all.some(
308
+ return results.some(group =>
309
+ groupReports(group).some(
640
310
  ({ measuredResults }) => measuredResults[field] !== undefined,
641
- );
642
- });
643
- }
644
-
645
- export interface ExportOptions {
646
- results: ReportGroup[];
647
- args: DefaultCliArgs;
648
- sections?: any[];
649
- suiteName?: string;
650
- currentVersion?: GitVersion;
651
- baselineVersion?: GitVersion;
652
- }
653
-
654
- /** Print heap reports (if enabled) and export results */
655
- async function finishReports(
656
- results: ReportGroup[],
657
- args: DefaultCliArgs,
658
- suiteName?: string,
659
- exportOptions?: MatrixExportOptions,
660
- ): Promise<void> {
661
- if (args["heap-sample"]) {
662
- printHeapReports(results, cliHeapReportOptions(args));
663
- }
664
- await exportReports({ results, args, suiteName, ...exportOptions });
311
+ ),
312
+ );
665
313
  }
666
314
 
667
315
  /** Export reports (HTML, JSON, Perfetto) based on CLI args */
@@ -690,8 +338,16 @@ export async function exportReports(options: ExportOptions): Promise<void> {
690
338
  await exportBenchmarkJson(results, args.json, args, suiteName);
691
339
  }
692
340
 
693
- if (args.perfetto) {
694
- exportPerfettoTrace(results, args.perfetto, args);
341
+ if (args["export-perfetto"]) {
342
+ exportPerfettoTrace(results, args["export-perfetto"], args);
343
+ }
344
+
345
+ if (args["export-speedscope"]) {
346
+ exportSpeedscope(results, args["export-speedscope"]);
347
+ }
348
+
349
+ if (args.speedscope) {
350
+ exportAndLaunchSpeedscope(results);
695
351
  }
696
352
 
697
353
  // Keep process running when HTML report is opened in browser
@@ -701,17 +357,6 @@ export async function exportReports(options: ExportOptions): Promise<void> {
701
357
  }
702
358
  }
703
359
 
704
- /** Wait for Ctrl+C before exiting */
705
- function waitForCtrlC(): Promise<void> {
706
- return new Promise(resolve => {
707
- console.log(dim("\nPress Ctrl+C to exit"));
708
- process.on("SIGINT", () => {
709
- console.log();
710
- resolve();
711
- });
712
- });
713
- }
714
-
715
360
  /** Run matrix suite with CLI arguments.
716
361
  * no options ==> defaultCases/defaultVariants, --filter ==> subset of defaults,
717
362
  * --all --filter ==> subset of all, --all ==> all cases/variants */
@@ -778,37 +423,6 @@ export function defaultMatrixReport(
778
423
  return results.map(r => reportMatrixResults(r, options)).join("\n\n");
779
424
  }
780
425
 
781
- /** @return HeapReportOptions from CLI args */
782
- function cliHeapReportOptions(args: DefaultCliArgs): HeapReportOptions {
783
- return {
784
- topN: args["heap-rows"],
785
- stackDepth: args["heap-stack"],
786
- verbose: args["heap-verbose"],
787
- userOnly: args["heap-user-only"],
788
- };
789
- }
790
-
791
- /** Apply default sections and extra columns for matrix reports */
792
- function mergeMatrixDefaults(
793
- reportOptions: MatrixReportOptions | undefined,
794
- args: DefaultCliArgs,
795
- results: MatrixResults[],
796
- ): MatrixReportOptions {
797
- const result: MatrixReportOptions = { ...reportOptions };
798
-
799
- if (!result.sections?.length) {
800
- const groups = matrixToReportGroups(results);
801
- result.sections = buildReportSections(
802
- args.adaptive,
803
- args["gc-stats"],
804
- hasField(groups, "cpu"),
805
- args["trace-opt"] && hasField(groups, "optStatus"),
806
- );
807
- }
808
-
809
- return result;
810
- }
811
-
812
426
  /** Run matrix suite with full CLI handling (parse, run, report, export) */
813
427
  export async function runDefaultMatrixBench(
814
428
  suite: MatrixSuite,
@@ -847,40 +461,6 @@ export function matrixToReportGroups(results: MatrixResults[]): ReportGroup[] {
847
461
  );
848
462
  }
849
463
 
850
- export interface MatrixExportOptions {
851
- sections?: any[];
852
- currentVersion?: GitVersion;
853
- baselineVersion?: GitVersion;
854
- }
855
-
856
- /** Strip surrounding quotes from a chrome arg token.
857
- *
858
- * (Needed because --chrome-args values pass through yargs and spawn() without
859
- * shell processing, so literal quote characters reach Chrome/V8 unrecognized.)
860
- */
861
- function stripQuotes(s: string): string {
862
- /* (['"]): opening quote; (.*): content; \1: require same closing quote */
863
- const unquote = s.replace(/^(['"])(.*)\1$/s, "$2");
864
-
865
- /* value portion: --flag="--value" or --flag='--value'
866
- (-[^=]+=): flag name and =; (['"])(.*)\2: quoted value */
867
- const valueUnquote = unquote.replace(/^(-[^=]+=)(['"])(.*)\2$/s, "$1$3");
868
-
869
- return valueUnquote;
870
- }
871
-
872
- /** Sequential map - like Promise.all(arr.map(fn)) but runs one at a time */
873
- async function serialMap<T, R>(
874
- arr: T[],
875
- fn: (item: T) => Promise<R>,
876
- ): Promise<R[]> {
877
- const results: R[] = [];
878
- for (const item of arr) {
879
- results.push(await fn(item));
880
- }
881
- return results;
882
- }
883
-
884
464
  /** Run matrix benchmarks, display table, and generate exports */
885
465
  export async function matrixBenchExports(
886
466
  suite: MatrixSuite,
@@ -895,3 +475,480 @@ export async function matrixBenchExports(
895
475
  const reportGroups = matrixToReportGroups(results);
896
476
  await finishReports(reportGroups, args, suite.name, exportOptions);
897
477
  }
478
+
479
+ /** Validate CLI argument combinations */
480
+ function validateArgs(args: DefaultCliArgs): void {
481
+ if (args["gc-stats"] && !args.worker && !args.url) {
482
+ throw new Error(
483
+ "--gc-stats requires worker mode (the default). Remove --no-worker flag.",
484
+ );
485
+ }
486
+ }
487
+
488
+ /** Execute all groups in suite */
489
+ async function runSuite(params: SuiteParams): Promise<ReportGroup[]> {
490
+ const { suite, runner, options, useWorker, batches } = params;
491
+ const results: ReportGroup[] = [];
492
+ for (const group of suite.groups) {
493
+ results.push(await runGroup(group, runner, options, useWorker, batches));
494
+ }
495
+ return results;
496
+ }
497
+
498
+ /** Build report sections based on CLI options */
499
+ function buildReportSections(
500
+ adaptive: boolean,
501
+ gcStats: boolean,
502
+ hasCpuData: boolean,
503
+ hasOptData: boolean,
504
+ ) {
505
+ const sections = adaptive
506
+ ? [adaptiveSection, totalTimeSection]
507
+ : [timeSection];
508
+
509
+ if (gcStats) sections.push(gcStatsSection);
510
+ if (hasCpuData) sections.push(cpuSection);
511
+ if (hasOptData) sections.push(optSection);
512
+ sections.push(runsSection);
513
+
514
+ return sections;
515
+ }
516
+
517
+ /** Print heap reports (if enabled) and export results */
518
+ async function finishReports(
519
+ results: ReportGroup[],
520
+ args: DefaultCliArgs,
521
+ suiteName?: string,
522
+ exportOptions?: MatrixExportOptions,
523
+ ): Promise<void> {
524
+ if (needsHeapSample(args)) {
525
+ printHeapReports(results, cliHeapReportOptions(args));
526
+ }
527
+ await exportReports({ results, args, suiteName, ...exportOptions });
528
+ }
529
+
530
+ /** Warn about Node-only flags that are ignored in browser mode. */
531
+ function warnBrowserFlags(args: DefaultCliArgs): void {
532
+ const ignored: string[] = [];
533
+ if (!args.worker) ignored.push("--no-worker");
534
+ if (args.cpu) ignored.push("--cpu");
535
+ if (args["trace-opt"]) ignored.push("--trace-opt");
536
+ if (args.collect) ignored.push("--collect");
537
+ if (args.adaptive) ignored.push("--adaptive");
538
+ if (args.batches > 1) ignored.push("--batches");
539
+ if (ignored.length) {
540
+ console.warn(yellow(`Ignored in browser mode: ${ignored.join(", ")}`));
541
+ }
542
+ }
543
+
544
+ /** @return true if any heap-related flag implies heap sampling */
545
+ function needsHeapSample(args: DefaultCliArgs): boolean {
546
+ return (
547
+ args["heap-sample"] ||
548
+ args.speedscope ||
549
+ !!args["export-speedscope"] ||
550
+ args["heap-raw"] ||
551
+ args["heap-verbose"] ||
552
+ args["heap-user-only"]
553
+ );
554
+ }
555
+
556
+ /** Strip surrounding quotes from a chrome arg token.
557
+ *
558
+ * (Needed because --chrome-args values pass through yargs and spawn() without
559
+ * shell processing, so literal quote characters reach Chrome/V8 unrecognized.)
560
+ */
561
+ function stripQuotes(s: string): string {
562
+ /* (['"]): opening quote; (.*): content; \1: require same closing quote */
563
+ const unquote = s.replace(/^(['"])(.*)\1$/s, "$2");
564
+
565
+ /* value portion: --flag="--value" or --flag='--value'
566
+ (-[^=]+=): flag name and =; (['"])(.*)\2: quoted value */
567
+ const valueUnquote = unquote.replace(/^(-[^=]+=)(['"])(.*)\2$/s, "$1$3");
568
+
569
+ return valueUnquote;
570
+ }
571
+
572
+ /** Wrap browser profile result as ReportGroup[] for the standard pipeline */
573
+ function browserResultGroups(
574
+ name: string,
575
+ result: BrowserProfileResult,
576
+ ): ReportGroup[] {
577
+ const { gcStats, heapProfile } = result;
578
+ let measured: MeasuredResults;
579
+
580
+ // Bench function mode: multiple timing samples with real statistics
581
+ if (result.samples && result.samples.length > 0) {
582
+ const { samples } = result;
583
+ const totalTime = result.wallTimeMs ? result.wallTimeMs / 1000 : undefined;
584
+ measured = {
585
+ name,
586
+ samples,
587
+ time: computeStats(samples),
588
+ totalTime,
589
+ gcStats,
590
+ heapProfile,
591
+ };
592
+ } else {
593
+ // Lap mode: 0 laps = single wall-clock, N laps handled above
594
+ const wallMs = result.wallTimeMs ?? 0;
595
+ const time = {
596
+ min: wallMs,
597
+ max: wallMs,
598
+ avg: wallMs,
599
+ p50: wallMs,
600
+ p75: wallMs,
601
+ p99: wallMs,
602
+ p999: wallMs,
603
+ };
604
+ measured = { name, samples: [wallMs], time, gcStats, heapProfile };
605
+ }
606
+
607
+ return [{ name, reports: [{ name, measuredResults: measured }] }];
608
+ }
609
+
610
+ /** Print browser benchmark tables and heap reports */
611
+ function printBrowserReport(
612
+ result: BrowserProfileResult,
613
+ results: ReportGroup[],
614
+ args: DefaultCliArgs,
615
+ ): void {
616
+ const hasSamples = result.samples && result.samples.length > 0;
617
+ const sections: ResultsMapper<any>[] = [];
618
+ if (hasSamples || result.wallTimeMs != null) {
619
+ sections.push(timeSection);
620
+ }
621
+ if (result.gcStats) {
622
+ sections.push(browserGcStatsSection);
623
+ }
624
+ if (hasSamples || result.wallTimeMs != null) {
625
+ sections.push(runsSection);
626
+ }
627
+ if (sections.length > 0) {
628
+ console.log(reportResults(results, sections));
629
+ }
630
+ if (result.heapProfile) {
631
+ printHeapReports(results, {
632
+ ...cliHeapReportOptions(args),
633
+ isUserCode: isBrowserUserCode,
634
+ });
635
+ }
636
+ }
637
+
638
+ /** Import a file and run it as a benchmark based on what it exports */
639
+ async function fileBenchExports(
640
+ filePath: string,
641
+ args: DefaultCliArgs,
642
+ ): Promise<void> {
643
+ const fileUrl = pathToFileURL(resolve(filePath)).href;
644
+ const mod = await import(fileUrl);
645
+ const candidate = mod.default;
646
+
647
+ if (candidate && Array.isArray(candidate.matrices)) {
648
+ // MatrixSuite export
649
+ await matrixBenchExports(candidate as MatrixSuite, args);
650
+ } else if (candidate && Array.isArray(candidate.groups)) {
651
+ // BenchSuite export
652
+ await benchExports(candidate as BenchSuite, args);
653
+ } else if (typeof candidate === "function") {
654
+ // Default function export: wrap as a single benchmark
655
+ const name = basename(filePath).replace(/\.[^.]+$/, "");
656
+ await benchExports(
657
+ { name, groups: [{ name, benchmarks: [{ name, fn: candidate }] }] },
658
+ args,
659
+ );
660
+ }
661
+ // else: self-executing file already ran on import
662
+ }
663
+
664
+ /** Create options for adaptive mode */
665
+ function createAdaptiveOptions(args: DefaultCliArgs): RunnerOptions {
666
+ return {
667
+ minTime: (args["min-time"] ?? 1) * 1000,
668
+ maxTime: defaultAdaptiveMaxTime * 1000,
669
+ targetConfidence: args.convergence,
670
+ adaptive: true,
671
+ ...cliCommonOptions(args),
672
+ } as any;
673
+ }
674
+
675
+ /** Runner/matrix options shared across all CLI modes */
676
+ function cliCommonOptions(args: DefaultCliArgs) {
677
+ const { collect, cpu, warmup } = args;
678
+ const { "trace-opt": traceOpt, "skip-settle": noSettle } = args;
679
+ const { "pause-first": pauseFirst, "pause-interval": pauseInterval } = args;
680
+ const { "pause-duration": pauseDuration, "gc-stats": gcStats } = args;
681
+ const heapSample = needsHeapSample(args);
682
+ const { "heap-interval": heapInterval } = args;
683
+ const { "heap-depth": heapDepth } = args;
684
+ return {
685
+ collect,
686
+ cpuCounters: cpu,
687
+ warmup,
688
+ traceOpt,
689
+ noSettle,
690
+ pauseFirst,
691
+ pauseInterval,
692
+ pauseDuration,
693
+ gcStats,
694
+ heapSample,
695
+ heapInterval,
696
+ heapDepth,
697
+ };
698
+ }
699
+
700
+ /** Wait for Ctrl+C before exiting */
701
+ function waitForCtrlC(): Promise<void> {
702
+ return new Promise(resolve => {
703
+ console.log(dim("\nPress Ctrl+C to exit"));
704
+ process.on("SIGINT", () => {
705
+ console.log();
706
+ resolve();
707
+ });
708
+ });
709
+ }
710
+
711
+ /** Apply default sections and extra columns for matrix reports */
712
+ function mergeMatrixDefaults(
713
+ reportOptions: MatrixReportOptions | undefined,
714
+ args: DefaultCliArgs,
715
+ results: MatrixResults[],
716
+ ): MatrixReportOptions {
717
+ const result: MatrixReportOptions = { ...reportOptions };
718
+
719
+ if (!result.sections?.length) {
720
+ const groups = matrixToReportGroups(results);
721
+ result.sections = buildReportSections(
722
+ args.adaptive,
723
+ args["gc-stats"],
724
+ hasField(groups, "cpu"),
725
+ args["trace-opt"] && hasField(groups, "optStatus"),
726
+ );
727
+ }
728
+
729
+ return result;
730
+ }
731
+
732
+ /** Execute group with shared setup, optionally batching to reduce ordering bias */
733
+ async function runGroup(
734
+ group: BenchGroup,
735
+ runner: KnownRunner,
736
+ options: RunnerOptions,
737
+ useWorker: boolean,
738
+ batches = 1,
739
+ ): Promise<ReportGroup> {
740
+ const { name, benchmarks, baseline, setup, metadata } = group;
741
+ const setupParams = await setup?.();
742
+ validateBenchmarkParameters(group);
743
+
744
+ const runParams = {
745
+ runner,
746
+ options,
747
+ useWorker,
748
+ params: setupParams,
749
+ metadata,
750
+ };
751
+ if (batches === 1) {
752
+ return runSingleBatch(name, benchmarks, baseline, runParams);
753
+ }
754
+ return runMultipleBatches(name, benchmarks, baseline, runParams, batches);
755
+ }
756
+
757
+ /** @return HeapReportOptions from CLI args */
758
+ function cliHeapReportOptions(args: DefaultCliArgs): HeapReportOptions {
759
+ return {
760
+ topN: args["heap-rows"],
761
+ stackDepth: args["heap-stack"],
762
+ verbose: args["heap-verbose"],
763
+ raw: args["heap-raw"],
764
+ userOnly: args["heap-user-only"],
765
+ };
766
+ }
767
+
768
+ /** Warn if parameterized benchmarks lack setup */
769
+ function validateBenchmarkParameters(group: BenchGroup): void {
770
+ const { name, setup, benchmarks, baseline } = group;
771
+ if (setup) return;
772
+
773
+ const allBenchmarks = baseline ? [...benchmarks, baseline] : benchmarks;
774
+ for (const benchmark of allBenchmarks) {
775
+ if (benchmark.fn.length > 0) {
776
+ console.warn(
777
+ `Benchmark "${benchmark.name}" in group "${name}" expects parameters but no setup() provided.`,
778
+ );
779
+ }
780
+ }
781
+ }
782
+
783
+ /** Run benchmarks in a single batch */
784
+ async function runSingleBatch(
785
+ name: string,
786
+ benchmarks: BenchmarkSpec[],
787
+ baseline: BenchmarkSpec | undefined,
788
+ runParams: RunParams,
789
+ ): Promise<ReportGroup> {
790
+ const baselineReport = baseline
791
+ ? await runSingleBenchmark(baseline, runParams)
792
+ : undefined;
793
+ const reports = await serialMap(benchmarks, b =>
794
+ runSingleBenchmark(b, runParams),
795
+ );
796
+ return { name, reports, baseline: baselineReport };
797
+ }
798
+
799
+ /** Run benchmarks in multiple batches, alternating order to reduce bias */
800
+ async function runMultipleBatches(
801
+ name: string,
802
+ benchmarks: BenchmarkSpec[],
803
+ baseline: BenchmarkSpec | undefined,
804
+ runParams: RunParams,
805
+ batches: number,
806
+ ): Promise<ReportGroup> {
807
+ const timePerBatch = (runParams.options.maxTime || 5000) / batches;
808
+ const batchParams = {
809
+ ...runParams,
810
+ options: { ...runParams.options, maxTime: timePerBatch },
811
+ };
812
+ const baselineBatches: MeasuredResults[] = [];
813
+ const benchmarkBatches = new Map<string, MeasuredResults[]>();
814
+
815
+ for (let i = 0; i < batches; i++) {
816
+ const reverseOrder = i % 2 === 1;
817
+ await runBatchIteration(
818
+ benchmarks,
819
+ baseline,
820
+ batchParams,
821
+ reverseOrder,
822
+ baselineBatches,
823
+ benchmarkBatches,
824
+ );
825
+ }
826
+
827
+ const meta = runParams.metadata;
828
+ return mergeBatchResults(
829
+ name,
830
+ benchmarks,
831
+ baseline,
832
+ baselineBatches,
833
+ benchmarkBatches,
834
+ meta,
835
+ );
836
+ }
837
+
838
+ /** Run single benchmark and create report */
839
+ async function runSingleBenchmark(
840
+ spec: BenchmarkSpec,
841
+ runParams: RunParams,
842
+ ): Promise<BenchmarkReport> {
843
+ const { runner, options, useWorker, params, metadata } = runParams;
844
+ const benchmarkParams = { spec, runner, options, useWorker, params };
845
+ const [result] = await runBenchmark(benchmarkParams);
846
+ return { name: spec.name, measuredResults: result, metadata };
847
+ }
848
+
849
+ /** Sequential map - like Promise.all(arr.map(fn)) but runs one at a time */
850
+ async function serialMap<T, R>(
851
+ arr: T[],
852
+ fn: (item: T) => Promise<R>,
853
+ ): Promise<R[]> {
854
+ const results: R[] = [];
855
+ for (const item of arr) {
856
+ results.push(await fn(item));
857
+ }
858
+ return results;
859
+ }
860
+
861
+ /** Run one batch iteration in either order */
862
+ async function runBatchIteration(
863
+ benchmarks: BenchmarkSpec[],
864
+ baseline: BenchmarkSpec | undefined,
865
+ runParams: RunParams,
866
+ reverseOrder: boolean,
867
+ baselineBatches: MeasuredResults[],
868
+ benchmarkBatches: Map<string, MeasuredResults[]>,
869
+ ): Promise<void> {
870
+ const runBaseline = async () => {
871
+ if (baseline) {
872
+ const r = await runSingleBenchmark(baseline, runParams);
873
+ baselineBatches.push(r.measuredResults);
874
+ }
875
+ };
876
+ const runBenches = async () => {
877
+ for (const b of benchmarks) {
878
+ const r = await runSingleBenchmark(b, runParams);
879
+ appendToMap(benchmarkBatches, b.name, r.measuredResults);
880
+ }
881
+ };
882
+
883
+ if (reverseOrder) {
884
+ await runBenches();
885
+ await runBaseline();
886
+ } else {
887
+ await runBaseline();
888
+ await runBenches();
889
+ }
890
+ }
891
+
892
+ /** Merge batch results into final ReportGroup */
893
+ function mergeBatchResults(
894
+ name: string,
895
+ benchmarks: BenchmarkSpec[],
896
+ baseline: BenchmarkSpec | undefined,
897
+ baselineBatches: MeasuredResults[],
898
+ benchmarkBatches: Map<string, MeasuredResults[]>,
899
+ metadata?: Record<string, unknown>,
900
+ ): ReportGroup {
901
+ const mergedBaseline = baseline
902
+ ? {
903
+ name: baseline.name,
904
+ measuredResults: mergeResults(baselineBatches),
905
+ metadata,
906
+ }
907
+ : undefined;
908
+ const reports = benchmarks.map(b => ({
909
+ name: b.name,
910
+ measuredResults: mergeResults(benchmarkBatches.get(b.name) || []),
911
+ metadata,
912
+ }));
913
+ return { name, reports, baseline: mergedBaseline };
914
+ }
915
+
916
+ function appendToMap(
917
+ map: Map<string, MeasuredResults[]>,
918
+ key: string,
919
+ value: MeasuredResults,
920
+ ) {
921
+ if (!map.has(key)) map.set(key, []);
922
+ map.get(key)!.push(value);
923
+ }
924
+
925
+ /** Merge multiple batch results into a single MeasuredResults */
926
+ function mergeResults(results: MeasuredResults[]): MeasuredResults {
927
+ if (results.length === 0) {
928
+ throw new Error("Cannot merge empty results array");
929
+ }
930
+ if (results.length === 1) return results[0];
931
+
932
+ const allSamples = results.flatMap(r => r.samples);
933
+ const allWarmup = results.flatMap(r => r.warmupSamples || []);
934
+ const time = computeStats(allSamples);
935
+
936
+ let offset = 0;
937
+ const allPausePoints = results.flatMap(r => {
938
+ const pts = (r.pausePoints ?? []).map(p => ({
939
+ sampleIndex: p.sampleIndex + offset,
940
+ durationMs: p.durationMs,
941
+ }));
942
+ offset += r.samples.length;
943
+ return pts;
944
+ });
945
+
946
+ return {
947
+ name: results[0].name,
948
+ samples: allSamples,
949
+ warmupSamples: allWarmup.length ? allWarmup : undefined,
950
+ time,
951
+ totalTime: results.reduce((sum, r) => sum + (r.totalTime || 0), 0),
952
+ pausePoints: allPausePoints.length ? allPausePoints : undefined,
953
+ };
954
+ }