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