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
@@ -1,5 +1,5 @@
1
- import { a as createAdaptiveWrapper, c as BasicRunner, i as createRunner, l as computeStats, n as getElapsed, o as average, r as getPerfNow, s as bootstrapDifferenceCI, t as debugWorkerTiming, u as discoverVariants } from "./TimingUtils-ClclVQ7E.mjs";
2
- import { n as parseGcLine, t as aggregateGcStats } from "./GcStats-ByEovUi1.mjs";
1
+ import { a as createAdaptiveWrapper, c as BasicRunner, i as createRunner, l as computeStats, n as getElapsed, o as average, r as getPerfNow, s as bootstrapDifferenceCI, t as debugWorkerTiming, u as discoverVariants } from "./TimingUtils-DwOwkc8G.mjs";
2
+ import { n as parseGcLine, t as aggregateGcStats } from "./GcStats-wX7Xyblu.mjs";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { execSync, fork, spawn } from "node:child_process";
@@ -9,6 +9,7 @@ import pico from "picocolors";
9
9
  import { table } from "table";
10
10
  import yargs from "yargs";
11
11
  import { hideBin } from "yargs/helpers";
12
+ import { tmpdir } from "node:os";
12
13
  import { createServer } from "node:http";
13
14
  import open from "open";
14
15
 
@@ -50,6 +51,25 @@ async function runBenchmark({ spec, runner, options, useWorker = false, params }
50
51
  params
51
52
  });
52
53
  }
54
+ /** Run a matrix variant benchmark in isolated worker process */
55
+ async function runMatrixVariant(params) {
56
+ const { variantDir, variantId, caseId, caseData, casesModule, runner, options } = params;
57
+ const name = `${variantId}/${caseId}`;
58
+ return runWorkerWithMessage(name, options, {
59
+ type: "run",
60
+ spec: {
61
+ name,
62
+ fn: () => {}
63
+ },
64
+ runnerName: runner,
65
+ options,
66
+ variantDir,
67
+ variantId,
68
+ caseId,
69
+ caseData,
70
+ casesModule
71
+ });
72
+ }
53
73
  /** Resolve modulePath/exportName to a real function for non-worker mode */
54
74
  async function resolveModuleSpec(spec, params) {
55
75
  const module = await import(spec.modulePath);
@@ -81,6 +101,34 @@ async function runInWorker(workerParams) {
81
101
  const msg = createRunMessage(spec, runner, options, params);
82
102
  return runWorkerWithMessage(spec.name, options, msg);
83
103
  }
104
+ /** Spawn worker, wire handlers, send message, return results */
105
+ function runWorkerWithMessage(name, options, message) {
106
+ const startTime = getPerfNow();
107
+ const collectGcStats = options.gcStats ?? false;
108
+ logTiming(`Starting worker for ${name}`);
109
+ return new Promise((resolve, reject) => {
110
+ const { worker, createTime, gcEvents } = createWorkerWithTiming(collectGcStats);
111
+ setupWorkerHandlers(worker, name, createWorkerHandlers(name, startTime, gcEvents, resolve, reject));
112
+ sendWorkerMessage(worker, message, createTime);
113
+ });
114
+ }
115
+ /** Create message for worker execution */
116
+ function createRunMessage(spec, runnerName, options, params) {
117
+ const { fn, ...rest } = spec;
118
+ const message = {
119
+ type: "run",
120
+ spec: rest,
121
+ runnerName,
122
+ options,
123
+ params
124
+ };
125
+ if (spec.modulePath) {
126
+ message.modulePath = spec.modulePath;
127
+ message.exportName = spec.exportName;
128
+ if (spec.setupExportName) message.setupExportName = spec.setupExportName;
129
+ } else message.fnCode = fn.toString();
130
+ return message;
131
+ }
84
132
  /** Create worker process with timing logs */
85
133
  function createWorkerWithTiming(gcStats) {
86
134
  const workerStart = getPerfNow();
@@ -95,6 +143,49 @@ function createWorkerWithTiming(gcStats) {
95
143
  gcEvents
96
144
  };
97
145
  }
146
+ /** @return handlers that attach GC stats and heap profile to results */
147
+ function createWorkerHandlers(specName, startTime, gcEvents, resolve, reject) {
148
+ return {
149
+ resolve: (results, heapProfile) => {
150
+ logTiming(`Total worker time for ${specName}: ${getElapsed(startTime).toFixed(1)}ms`);
151
+ if (gcEvents?.length) {
152
+ const gcStats = aggregateGcStats(gcEvents);
153
+ for (const r of results) r.gcStats = gcStats;
154
+ }
155
+ if (heapProfile) for (const r of results) r.heapProfile = heapProfile;
156
+ resolve(results);
157
+ },
158
+ reject
159
+ };
160
+ }
161
+ /** Setup worker event handlers with cleanup */
162
+ function setupWorkerHandlers(worker, specName, handlers) {
163
+ const { resolve, reject } = handlers;
164
+ const cleanup = createCleanup(worker, specName, reject);
165
+ worker.on("message", createMessageHandler(specName, cleanup, resolve, reject));
166
+ worker.on("error", createErrorHandler(specName, cleanup, reject));
167
+ worker.on("exit", createExitHandler(specName, cleanup, reject));
168
+ }
169
+ /** Send message to worker with timing log */
170
+ function sendWorkerMessage(worker, message, createTime) {
171
+ const messageTime = getPerfNow();
172
+ worker.send(message);
173
+ logTiming(`Message sent to worker in ${getElapsed(createTime, messageTime).toFixed(1)}ms`);
174
+ }
175
+ /** Create worker process with configuration */
176
+ function createWorkerProcess(gcStats) {
177
+ const workerPath = resolveWorkerPath();
178
+ const execArgv = ["--expose-gc", "--allow-natives-syntax"];
179
+ if (gcStats) execArgv.push("--trace-gc-nvp");
180
+ return fork(workerPath, [], {
181
+ execArgv,
182
+ silent: gcStats,
183
+ env: {
184
+ ...process.env,
185
+ NODE_OPTIONS: ""
186
+ }
187
+ });
188
+ }
98
189
  /** Capture and parse GC lines from stdout (V8's --trace-gc-nvp outputs to stdout) */
99
190
  function setupGcCapture(worker, gcEvents) {
100
191
  let buffer = "";
@@ -109,30 +200,17 @@ function setupGcCapture(worker, gcEvents) {
109
200
  }
110
201
  });
111
202
  }
112
- /** Spawn worker, wire handlers, send message, return results */
113
- function runWorkerWithMessage(name, options, message) {
114
- const startTime = getPerfNow();
115
- const collectGcStats = options.gcStats ?? false;
116
- logTiming(`Starting worker for ${name}`);
117
- return new Promise((resolve, reject) => {
118
- const { worker, createTime, gcEvents } = createWorkerWithTiming(collectGcStats);
119
- setupWorkerHandlers(worker, name, createWorkerHandlers(name, startTime, gcEvents, resolve, reject));
120
- sendWorkerMessage(worker, message, createTime);
121
- });
122
- }
123
- /** Send message to worker with timing log */
124
- function sendWorkerMessage(worker, message, createTime) {
125
- const messageTime = getPerfNow();
126
- worker.send(message);
127
- logTiming(`Message sent to worker in ${getElapsed(createTime, messageTime).toFixed(1)}ms`);
128
- }
129
- /** Setup worker event handlers with cleanup */
130
- function setupWorkerHandlers(worker, specName, handlers) {
131
- const { resolve, reject } = handlers;
132
- const cleanup = createCleanup(worker, specName, reject);
133
- worker.on("message", createMessageHandler(specName, cleanup, resolve, reject));
134
- worker.on("error", createErrorHandler(specName, cleanup, reject));
135
- worker.on("exit", createExitHandler(specName, cleanup, reject));
203
+ /** Create cleanup for timeout and termination */
204
+ function createCleanup(worker, specName, reject) {
205
+ const timeoutId = setTimeout(() => {
206
+ cleanup();
207
+ reject(/* @__PURE__ */ new Error(`Benchmark "${specName}" timed out after 60 seconds`));
208
+ }, 6e4);
209
+ const cleanup = () => {
210
+ clearTimeout(timeoutId);
211
+ if (!worker.killed) worker.kill("SIGTERM");
212
+ };
213
+ return cleanup;
136
214
  }
137
215
  /** Handle worker messages (results or errors) */
138
216
  function createMessageHandler(specName, cleanup, resolve, reject) {
@@ -163,32 +241,6 @@ function createExitHandler(specName, cleanup, reject) {
163
241
  }
164
242
  };
165
243
  }
166
- /** Create cleanup for timeout and termination */
167
- function createCleanup(worker, specName, reject) {
168
- const timeoutId = setTimeout(() => {
169
- cleanup();
170
- reject(/* @__PURE__ */ new Error(`Benchmark "${specName}" timed out after 60 seconds`));
171
- }, 6e4);
172
- const cleanup = () => {
173
- clearTimeout(timeoutId);
174
- if (!worker.killed) worker.kill("SIGTERM");
175
- };
176
- return cleanup;
177
- }
178
- /** Create worker process with configuration */
179
- function createWorkerProcess(gcStats) {
180
- const workerPath = resolveWorkerPath();
181
- const execArgv = ["--expose-gc", "--allow-natives-syntax"];
182
- if (gcStats) execArgv.push("--trace-gc-nvp");
183
- return fork(workerPath, [], {
184
- execArgv,
185
- silent: gcStats,
186
- env: {
187
- ...process.env,
188
- NODE_OPTIONS: ""
189
- }
190
- });
191
- }
192
244
  /** Resolve WorkerScript path for dev (.ts) or dist (.mjs) */
193
245
  function resolveWorkerPath() {
194
246
  const dir = import.meta.dirname;
@@ -196,57 +248,6 @@ function resolveWorkerPath() {
196
248
  if (existsSync(tsPath)) return tsPath;
197
249
  return path.join(dir, "runners", "WorkerScript.mjs");
198
250
  }
199
- /** @return handlers that attach GC stats and heap profile to results */
200
- function createWorkerHandlers(specName, startTime, gcEvents, resolve, reject) {
201
- return {
202
- resolve: (results, heapProfile) => {
203
- logTiming(`Total worker time for ${specName}: ${getElapsed(startTime).toFixed(1)}ms`);
204
- if (gcEvents?.length) {
205
- const gcStats = aggregateGcStats(gcEvents);
206
- for (const r of results) r.gcStats = gcStats;
207
- }
208
- if (heapProfile) for (const r of results) r.heapProfile = heapProfile;
209
- resolve(results);
210
- },
211
- reject
212
- };
213
- }
214
- /** Create message for worker execution */
215
- function createRunMessage(spec, runnerName, options, params) {
216
- const { fn, ...rest } = spec;
217
- const message = {
218
- type: "run",
219
- spec: rest,
220
- runnerName,
221
- options,
222
- params
223
- };
224
- if (spec.modulePath) {
225
- message.modulePath = spec.modulePath;
226
- message.exportName = spec.exportName;
227
- if (spec.setupExportName) message.setupExportName = spec.setupExportName;
228
- } else message.fnCode = fn.toString();
229
- return message;
230
- }
231
- /** Run a matrix variant benchmark in isolated worker process */
232
- async function runMatrixVariant(params) {
233
- const { variantDir, variantId, caseId, caseData, casesModule, runner, options } = params;
234
- const name = `${variantId}/${caseId}`;
235
- return runWorkerWithMessage(name, options, {
236
- type: "run",
237
- spec: {
238
- name,
239
- fn: () => {}
240
- },
241
- runnerName: runner,
242
- options,
243
- variantDir,
244
- variantId,
245
- caseId,
246
- caseData,
247
- casesModule
248
- });
249
- }
250
251
 
251
252
  //#endregion
252
253
  //#region src/BenchMatrix.ts
@@ -270,31 +271,15 @@ function validateBaseline(matrix) {
270
271
  const msg = "BenchMatrix cannot have both 'baselineDir' and 'baselineVariant'";
271
272
  if (matrix.baselineDir && matrix.baselineVariant) throw new Error(msg);
272
273
  }
273
- function buildRunnerOptions(options) {
274
- return {
275
- maxIterations: options.iterations,
276
- maxTime: options.maxTime ?? 1e3,
277
- warmup: options.warmup ?? 0,
278
- collect: options.collect,
279
- cpuCounters: options.cpuCounters,
280
- traceOpt: options.traceOpt,
281
- noSettle: options.noSettle,
282
- pauseFirst: options.pauseFirst,
283
- pauseInterval: options.pauseInterval,
284
- pauseDuration: options.pauseDuration,
285
- gcStats: options.gcStats,
286
- heapSample: options.heapSample,
287
- heapInterval: options.heapInterval,
288
- heapDepth: options.heapDepth
289
- };
290
- }
291
- /** Load cases module and resolve filtered case IDs */
292
- async function resolveCases(matrix, options) {
293
- const casesModule = matrix.casesModule ? await loadCasesModule(matrix.casesModule) : void 0;
294
- const allCaseIds = casesModule?.cases ?? matrix.cases ?? ["default"];
274
+ /** Run matrix with variantDir (worker mode for memory isolation) */
275
+ async function runMatrixWithDir(matrix, options) {
276
+ const allVariantIds = await discoverVariants(matrix.variantDir);
277
+ if (allVariantIds.length === 0) throw new Error(`No variants found in ${matrix.variantDir}`);
278
+ const variants = await runDirVariants(options.filteredVariants ?? allVariantIds, await createDirContext(matrix, options));
279
+ if (matrix.baselineVariant) applyBaselineVariant(variants, matrix.baselineVariant);
295
280
  return {
296
- casesModule,
297
- caseIds: options.filteredCases ?? allCaseIds
281
+ name: matrix.name,
282
+ variants
298
283
  };
299
284
  }
300
285
  /** Run matrix with inline variants (non-worker mode) */
@@ -328,17 +313,6 @@ async function runMatrixInline(matrix, options) {
328
313
  variants
329
314
  };
330
315
  }
331
- /** Run matrix with variantDir (worker mode for memory isolation) */
332
- async function runMatrixWithDir(matrix, options) {
333
- const allVariantIds = await discoverVariants(matrix.variantDir);
334
- if (allVariantIds.length === 0) throw new Error(`No variants found in ${matrix.variantDir}`);
335
- const variants = await runDirVariants(options.filteredVariants ?? allVariantIds, await createDirContext(matrix, options));
336
- if (matrix.baselineVariant) applyBaselineVariant(variants, matrix.baselineVariant);
337
- return {
338
- name: matrix.name,
339
- variants
340
- };
341
- }
342
316
  /** Create context for directory-based matrix execution */
343
317
  async function createDirContext(matrix, options) {
344
318
  const baselineIds = matrix.baselineDir ? await discoverVariants(matrix.baselineDir) : [];
@@ -363,56 +337,6 @@ async function runDirVariants(variantIds, ctx) {
363
337
  }
364
338
  return variants;
365
339
  }
366
- /** Run all cases for a single variant */
367
- async function runDirVariantCases(variantId, ctx) {
368
- const { matrix, casesModule, caseIds, runnerOpts } = ctx;
369
- const cases = [];
370
- for (const caseId of caseIds) {
371
- const caseData = !matrix.casesModule && matrix.cases ? caseId : void 0;
372
- const [measured] = await runMatrixVariant({
373
- variantDir: matrix.variantDir,
374
- variantId,
375
- caseId,
376
- caseData,
377
- casesModule: matrix.casesModule,
378
- runner: "basic",
379
- options: runnerOpts
380
- });
381
- const loaded = await loadCaseData(casesModule, caseId);
382
- const baseline = await runBaselineIfExists(variantId, caseId, caseData, ctx);
383
- const deltaPercent = baseline ? computeDeltaPercent(baseline, measured) : void 0;
384
- const metadata = loaded.metadata;
385
- cases.push({
386
- caseId,
387
- measured,
388
- metadata,
389
- baseline,
390
- deltaPercent
391
- });
392
- }
393
- return cases;
394
- }
395
- /** Run baseline variant if it exists in baselineDir */
396
- async function runBaselineIfExists(variantId, caseId, caseData, ctx) {
397
- const { matrix, baselineIds, runnerOpts } = ctx;
398
- if (!matrix.baselineDir || !baselineIds.includes(variantId)) return void 0;
399
- const [measured] = await runMatrixVariant({
400
- variantDir: matrix.baselineDir,
401
- variantId,
402
- caseId,
403
- caseData,
404
- casesModule: matrix.casesModule,
405
- runner: "basic",
406
- options: runnerOpts
407
- });
408
- return measured;
409
- }
410
- /** Compute delta percentage: (current - baseline) / baseline * 100 */
411
- function computeDeltaPercent(baseline, current) {
412
- const baseAvg = average(baseline.samples);
413
- if (baseAvg === 0) return 0;
414
- return (average(current.samples) - baseAvg) / baseAvg * 100;
415
- }
416
340
  /** Apply baselineVariant comparison - one variant is the reference for all others */
417
341
  function applyBaselineVariant(variants, baselineVariantId) {
418
342
  const baselineVariant = variants.find((v) => v.id === baselineVariantId);
@@ -430,6 +354,33 @@ function applyBaselineVariant(variants, baselineVariantId) {
430
354
  }
431
355
  }
432
356
  }
357
+ /** Load cases module and resolve filtered case IDs */
358
+ async function resolveCases(matrix, options) {
359
+ const casesModule = matrix.casesModule ? await loadCasesModule(matrix.casesModule) : void 0;
360
+ const allCaseIds = casesModule?.cases ?? matrix.cases ?? ["default"];
361
+ return {
362
+ casesModule,
363
+ caseIds: options.filteredCases ?? allCaseIds
364
+ };
365
+ }
366
+ function buildRunnerOptions(options) {
367
+ return {
368
+ maxIterations: options.iterations,
369
+ maxTime: options.maxTime ?? 1e3,
370
+ warmup: options.warmup ?? 0,
371
+ collect: options.collect,
372
+ cpuCounters: options.cpuCounters,
373
+ traceOpt: options.traceOpt,
374
+ noSettle: options.noSettle,
375
+ pauseFirst: options.pauseFirst,
376
+ pauseInterval: options.pauseInterval,
377
+ pauseDuration: options.pauseDuration,
378
+ gcStats: options.gcStats,
379
+ heapSample: options.heapSample,
380
+ heapInterval: options.heapInterval,
381
+ heapDepth: options.heapDepth
382
+ };
383
+ }
433
384
  /** Run a single variant with case data */
434
385
  async function runVariant(variant, caseData, name, runner, options) {
435
386
  if (isStatefulVariant(variant)) {
@@ -446,6 +397,56 @@ async function runVariant(variant, caseData, name, runner, options) {
446
397
  }, options);
447
398
  return result;
448
399
  }
400
+ /** Run all cases for a single variant */
401
+ async function runDirVariantCases(variantId, ctx) {
402
+ const { matrix, casesModule, caseIds, runnerOpts } = ctx;
403
+ const cases = [];
404
+ for (const caseId of caseIds) {
405
+ const caseData = !matrix.casesModule && matrix.cases ? caseId : void 0;
406
+ const [measured] = await runMatrixVariant({
407
+ variantDir: matrix.variantDir,
408
+ variantId,
409
+ caseId,
410
+ caseData,
411
+ casesModule: matrix.casesModule,
412
+ runner: "basic",
413
+ options: runnerOpts
414
+ });
415
+ const loaded = await loadCaseData(casesModule, caseId);
416
+ const baseline = await runBaselineIfExists(variantId, caseId, caseData, ctx);
417
+ const deltaPercent = baseline ? computeDeltaPercent(baseline, measured) : void 0;
418
+ const metadata = loaded.metadata;
419
+ cases.push({
420
+ caseId,
421
+ measured,
422
+ metadata,
423
+ baseline,
424
+ deltaPercent
425
+ });
426
+ }
427
+ return cases;
428
+ }
429
+ /** Compute delta percentage: (current - baseline) / baseline * 100 */
430
+ function computeDeltaPercent(baseline, current) {
431
+ const baseAvg = average(baseline.samples);
432
+ if (baseAvg === 0) return 0;
433
+ return (average(current.samples) - baseAvg) / baseAvg * 100;
434
+ }
435
+ /** Run baseline variant if it exists in baselineDir */
436
+ async function runBaselineIfExists(variantId, caseId, caseData, ctx) {
437
+ const { matrix, baselineIds, runnerOpts } = ctx;
438
+ if (!matrix.baselineDir || !baselineIds.includes(variantId)) return void 0;
439
+ const [measured] = await runMatrixVariant({
440
+ variantDir: matrix.baselineDir,
441
+ variantId,
442
+ caseId,
443
+ caseData,
444
+ casesModule: matrix.casesModule,
445
+ runner: "basic",
446
+ options: runnerOpts
447
+ });
448
+ return measured;
449
+ }
449
450
 
450
451
  //#endregion
451
452
  //#region src/table-util/Formatters.ts
@@ -491,21 +492,15 @@ function diffPercent(main, base) {
491
492
  if (typeof main !== "number" || typeof base !== "number") return " ";
492
493
  return coloredPercent(main - base, base);
493
494
  }
494
- /** Format fraction as colored +/- percentage */
495
- function coloredPercent(numerator, denominator, positiveIsGreen = true) {
496
- const fraction = numerator / denominator;
497
- if (Number.isNaN(fraction) || !Number.isFinite(fraction)) return " ";
498
- const positive = fraction >= 0;
499
- const percentStr = `${positive ? "+" : "-"}${percent(fraction)}`;
500
- return positive === positiveIsGreen ? green(percentStr) : red$1(percentStr);
501
- }
502
- /** Format bytes with appropriate units (B, KB, MB, GB) */
503
- function formatBytes(bytes) {
495
+ /** Format bytes with appropriate units (B, KB, MB, GB).
496
+ * Use `space: true` for human-readable console output (`1.5 KB`). */
497
+ function formatBytes(bytes, opts) {
504
498
  if (typeof bytes !== "number") return null;
505
- if (bytes < 1024) return `${bytes.toFixed(0)}B`;
506
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
507
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
508
- return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
499
+ const s = opts?.space ? " " : "";
500
+ if (bytes < 1024) return `${bytes.toFixed(0)}${s}B`;
501
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}${s}KB`;
502
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}${s}MB`;
503
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}${s}GB`;
509
504
  }
510
505
  /** Format percentage difference with confidence interval */
511
506
  function formatDiffWithCI(value) {
@@ -519,9 +514,21 @@ function formatDiffWithCIHigherIsBetter(value) {
519
514
  const { percent, ci, direction } = value;
520
515
  return colorByDirection(diffCIText(-percent, [-ci[1], -ci[0]]), direction);
521
516
  }
522
- /** @return formatted "pct [lo, hi]" text for a diff with CI */
523
- function diffCIText(pct, ci) {
524
- return `${formatBound(pct)} [${formatBound(ci[0])}, ${formatBound(ci[1])}]`;
517
+ /** @return truncated string with ellipsis if over maxLen */
518
+ function truncate(str, maxLen = 30) {
519
+ return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str;
520
+ }
521
+ /** Format fraction as colored +/- percentage */
522
+ function coloredPercent(numerator, denominator, positiveIsGreen = true) {
523
+ const fraction = numerator / denominator;
524
+ if (Number.isNaN(fraction) || !Number.isFinite(fraction)) return " ";
525
+ const positive = fraction >= 0;
526
+ const percentStr = `${positive ? "+" : "-"}${percent(fraction)}`;
527
+ return positive === positiveIsGreen ? green(percentStr) : red$1(percentStr);
528
+ }
529
+ /** @return true if value is a DifferenceCI object */
530
+ function isDifferenceCI(x) {
531
+ return typeof x === "object" && x !== null && "ci" in x && "direction" in x;
525
532
  }
526
533
  /** @return text colored green for faster, red for slower */
527
534
  function colorByDirection(text, direction) {
@@ -529,78 +536,23 @@ function colorByDirection(text, direction) {
529
536
  if (direction === "slower") return red$1(text);
530
537
  return text;
531
538
  }
539
+ /** @return formatted "pct [lo, hi]" text for a diff with CI */
540
+ function diffCIText(pct, ci) {
541
+ return `${formatBound(pct)} [${formatBound(ci[0])}, ${formatBound(ci[1])}]`;
542
+ }
532
543
  /** @return signed percentage string (e.g. "+1.2%", "-3.4%") */
533
544
  function formatBound(v) {
534
545
  return `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`;
535
546
  }
536
- /** @return true if value is a DifferenceCI object */
537
- function isDifferenceCI(x) {
538
- return typeof x === "object" && x !== null && "ci" in x && "direction" in x;
539
- }
540
- /** @return truncated string with ellipsis if over maxLen */
541
- function truncate(str, maxLen = 30) {
542
- return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str;
543
- }
544
547
 
545
548
  //#endregion
546
549
  //#region src/table-util/TableReport.ts
547
550
  const { bold } = process.env.NODE_ENV === "test" || process.env.VITEST === "true" ? { bold: (str) => str } : pico;
551
+ const ansiEscapeRegex = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
548
552
  /** Build formatted table with column groups and baselines */
549
553
  function buildTable(columnGroups, resultGroups, nameKey = "name") {
550
554
  return createTable(columnGroups, flattenGroups(columnGroups, resultGroups, nameKey));
551
555
  }
552
- /** Convert columns and records to formatted table */
553
- function createTable(groups, records) {
554
- const dataRows = toRows(records, groups);
555
- const { headerRows, config } = setup(groups, dataRows);
556
- return table([...headerRows, ...dataRows], config);
557
- }
558
- /** Create header rows with group titles */
559
- function createGroupHeaders(groups, numColumns) {
560
- if (!groups.some((g) => g.groupTitle)) return [];
561
- return [groups.flatMap((g) => {
562
- return padWithBlanks(g.groupTitle ? [bold(g.groupTitle)] : [], g.columns.length);
563
- }), padWithBlanks([], numColumns)];
564
- }
565
- /** @return draw functions for horizontal/vertical table borders */
566
- function createLines(groups) {
567
- const { sectionBorders, headerBottom } = calcBorders(groups);
568
- function drawVerticalLine(index, size) {
569
- return index === 0 || index === size || sectionBorders.includes(index);
570
- }
571
- function drawHorizontalLine(index, size) {
572
- return index === 0 || index === size || index === headerBottom;
573
- }
574
- return {
575
- drawHorizontalLine,
576
- drawVerticalLine
577
- };
578
- }
579
- /** @return spanning cell configs for group title headers */
580
- function createSectionSpans(groups) {
581
- let col = 0;
582
- const alignment = "center";
583
- return groups.map((g) => {
584
- const colSpan = g.columns.length;
585
- const span = {
586
- row: 0,
587
- col,
588
- colSpan,
589
- alignment
590
- };
591
- col += colSpan;
592
- return span;
593
- });
594
- }
595
- /** @return bolded column title strings */
596
- function getTitles(groups) {
597
- return groups.flatMap((g) => g.columns.map((c) => bold(c.title || " ")));
598
- }
599
- /** @return array padded with blank strings to the given length */
600
- function padWithBlanks(arr, length) {
601
- if (arr.length >= length) return arr;
602
- return [...arr, ...Array(length - arr.length).fill(" ")];
603
- }
604
556
  /** Convert records to string arrays for table */
605
557
  function toRows(records, groups) {
606
558
  const allColumns = groups.flatMap((group) => group.columns);
@@ -609,20 +561,6 @@ function toRows(records, groups) {
609
561
  return col.formatter ? col.formatter(value) : value;
610
562
  })).map((row) => row.map((cell) => cell ?? " "));
611
563
  }
612
- /** Add comparison values for diff columns */
613
- function addComparisons(groups, mainRecord, baselineRecord) {
614
- const diffColumns = groups.flatMap((g) => g.columns).filter((col) => col.diffKey);
615
- const updatedMain = { ...mainRecord };
616
- for (const col of diffColumns) {
617
- const dcol = col;
618
- const diffKey = dcol.diffKey;
619
- const mainValue = mainRecord[diffKey];
620
- const baselineValue = baselineRecord[diffKey];
621
- const diffStr = (dcol.diffFormatter ?? diffPercent)(mainValue, baselineValue);
622
- updatedMain[col.key] = diffStr;
623
- }
624
- return updatedMain;
625
- }
626
564
  /** Flatten groups with spacing */
627
565
  function flattenGroups(columnGroups, resultGroups, nameKey) {
628
566
  return resultGroups.flatMap((group, i) => {
@@ -630,6 +568,12 @@ function flattenGroups(columnGroups, resultGroups, nameKey) {
630
568
  return i === resultGroups.length - 1 ? groupRecords : [...groupRecords, {}];
631
569
  });
632
570
  }
571
+ /** Convert columns and records to formatted table */
572
+ function createTable(groups, records) {
573
+ const dataRows = toRows(records, groups);
574
+ const { headerRows, config } = setup(groups, dataRows);
575
+ return table([...headerRows, ...dataRows], config);
576
+ }
633
577
  /** Process results with baseline comparisons */
634
578
  function addBaseline(columnGroups, group, nameKey) {
635
579
  const { results, baseline } = group;
@@ -641,23 +585,6 @@ function addBaseline(columnGroups, group, nameKey) {
641
585
  };
642
586
  return [...diffResults, markedBaseline];
643
587
  }
644
- /** Calculate vertical lines between sections and header bottom position */
645
- function calcBorders(groups) {
646
- if (groups.length === 0) return {
647
- sectionBorders: [],
648
- headerBottom: 1
649
- };
650
- const sectionBorders = [];
651
- let border = 0;
652
- for (const g of groups) {
653
- border += g.columns.length;
654
- sectionBorders.push(border);
655
- }
656
- return {
657
- sectionBorders,
658
- headerBottom: 3
659
- };
660
- }
661
588
  /** Create headers and table configuration */
662
589
  function setup(groups, dataRows) {
663
590
  const titles = getTitles(groups);
@@ -671,18 +598,59 @@ function setup(groups, dataRows) {
671
598
  }
672
599
  };
673
600
  }
674
- /** Calculate column widths based on content, including group titles */
675
- function calcColumnWidths(groups, titles, dataRows) {
676
- const widths = [];
677
- for (let i = 0; i < titles.length; i++) {
678
- const titleW = cellWidth(titles[i]);
679
- const maxDataW = dataRows.reduce((max, row) => Math.max(max, cellWidth(row[i])), 0);
680
- widths.push(Math.max(titleW, maxDataW));
681
- }
682
- let colIndex = 0;
683
- for (const group of groups) {
684
- const groupW = cellWidth(group.groupTitle);
685
- if (groupW > 0) {
601
+ /** Add comparison values for diff columns */
602
+ function addComparisons(groups, mainRecord, baselineRecord) {
603
+ const diffColumns = groups.flatMap((g) => g.columns).filter((col) => col.diffKey);
604
+ const updatedMain = { ...mainRecord };
605
+ for (const col of diffColumns) {
606
+ const dcol = col;
607
+ const diffKey = dcol.diffKey;
608
+ const mainValue = mainRecord[diffKey];
609
+ const baselineValue = baselineRecord[diffKey];
610
+ const diffStr = (dcol.diffFormatter ?? diffPercent)(mainValue, baselineValue);
611
+ updatedMain[col.key] = diffStr;
612
+ }
613
+ return updatedMain;
614
+ }
615
+ /** @return bolded column title strings */
616
+ function getTitles(groups) {
617
+ return groups.flatMap((g) => g.columns.map((c) => bold(c.title || " ")));
618
+ }
619
+ /** Create header rows with group titles */
620
+ function createGroupHeaders(groups, numColumns) {
621
+ if (!groups.some((g) => g.groupTitle)) return [];
622
+ return [groups.flatMap((g) => {
623
+ return padWithBlanks(g.groupTitle ? [bold(g.groupTitle)] : [], g.columns.length);
624
+ }), padWithBlanks([], numColumns)];
625
+ }
626
+ /** @return spanning cell configs for group title headers */
627
+ function createSectionSpans(groups) {
628
+ let col = 0;
629
+ const alignment = "center";
630
+ return groups.map((g) => {
631
+ const colSpan = g.columns.length;
632
+ const span = {
633
+ row: 0,
634
+ col,
635
+ colSpan,
636
+ alignment
637
+ };
638
+ col += colSpan;
639
+ return span;
640
+ });
641
+ }
642
+ /** Calculate column widths based on content, including group titles */
643
+ function calcColumnWidths(groups, titles, dataRows) {
644
+ const widths = [];
645
+ for (let i = 0; i < titles.length; i++) {
646
+ const titleW = cellWidth(titles[i]);
647
+ const maxDataW = dataRows.reduce((max, row) => Math.max(max, cellWidth(row[i])), 0);
648
+ widths.push(Math.max(titleW, maxDataW));
649
+ }
650
+ let colIndex = 0;
651
+ for (const group of groups) {
652
+ const groupW = cellWidth(group.groupTitle);
653
+ if (groupW > 0) {
686
654
  const numCols = group.columns.length;
687
655
  const separatorWidth = (numCols - 1) * 3;
688
656
  const needed = groupW - widths.slice(colIndex, colIndex + numCols).reduce((a, b) => a + b, 0) - separatorWidth;
@@ -695,20 +663,84 @@ function calcColumnWidths(groups, titles, dataRows) {
695
663
  wrapWord: false
696
664
  }]));
697
665
  }
698
- const ansiEscapeRegex = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
666
+ /** @return draw functions for horizontal/vertical table borders */
667
+ function createLines(groups) {
668
+ const { sectionBorders, headerBottom } = calcBorders(groups);
669
+ function drawVerticalLine(index, size) {
670
+ return index === 0 || index === size || sectionBorders.includes(index);
671
+ }
672
+ function drawHorizontalLine(index, size) {
673
+ return index === 0 || index === size || index === headerBottom;
674
+ }
675
+ return {
676
+ drawHorizontalLine,
677
+ drawVerticalLine
678
+ };
679
+ }
680
+ /** @return array padded with blank strings to the given length */
681
+ function padWithBlanks(arr, length) {
682
+ if (arr.length >= length) return arr;
683
+ return [...arr, ...Array(length - arr.length).fill(" ")];
684
+ }
699
685
  /** Get visible length of a cell value (strips ANSI escape codes) */
700
686
  function cellWidth(value) {
701
687
  if (value == null) return 0;
702
688
  return String(value).replace(ansiEscapeRegex, "").length;
703
689
  }
690
+ /** Calculate vertical lines between sections and header bottom position */
691
+ function calcBorders(groups) {
692
+ if (groups.length === 0) return {
693
+ sectionBorders: [],
694
+ headerBottom: 1
695
+ };
696
+ const sectionBorders = [];
697
+ let border = 0;
698
+ for (const g of groups) {
699
+ border += g.columns.length;
700
+ sectionBorders.push(border);
701
+ }
702
+ return {
703
+ sectionBorders,
704
+ headerBottom: 3
705
+ };
706
+ }
704
707
 
705
708
  //#endregion
706
709
  //#region src/BenchmarkReport.ts
710
+ /** All reports in a group, including the baseline if present */
711
+ function groupReports(group) {
712
+ return group.baseline ? [...group.reports, group.baseline] : group.reports;
713
+ }
707
714
  /** @return formatted table report with optional baseline comparisons */
708
715
  function reportResults(groups, sections) {
709
716
  const results = groups.map((group) => resultGroupValues(group, sections));
710
717
  return buildTable(createColumnGroups(sections, results.some((g) => g.baseline)), results);
711
718
  }
719
+ /** @return rows with stats from sections */
720
+ function valuesForReports(reports, sections) {
721
+ return reports.map((report) => ({
722
+ name: truncate(report.name),
723
+ ...extractReportValues(report, sections)
724
+ }));
725
+ }
726
+ /** @return groups with single CI column after first comparable field */
727
+ function injectDiffColumns(reportGroups) {
728
+ let ciAdded = false;
729
+ return reportGroups.map((group) => ({
730
+ groupTitle: group.groupTitle,
731
+ columns: group.columns.flatMap((col) => {
732
+ if (col.comparable && !ciAdded) {
733
+ ciAdded = true;
734
+ return [col, {
735
+ title: "Δ% CI",
736
+ key: "diffCI",
737
+ formatter: col.higherIsBetter ? formatDiffWithCIHigherIsBetter : formatDiffWithCI
738
+ }];
739
+ }
740
+ return [col];
741
+ })
742
+ }));
743
+ }
712
744
  /** @return values for report group */
713
745
  function resultGroupValues(group, sections) {
714
746
  const { reports, baseline } = group;
@@ -725,19 +757,6 @@ function resultGroupValues(group, sections) {
725
757
  baseline: baseline && valuesForReports([baseline], sections)[0]
726
758
  };
727
759
  }
728
- /** @return rows with stats from sections */
729
- function valuesForReports(reports, sections) {
730
- return reports.map((report) => ({
731
- name: truncate(report.name),
732
- ...extractReportValues(report, sections)
733
- }));
734
- }
735
- /** @return merged statistics from all sections */
736
- function extractReportValues(report, sections) {
737
- const { measuredResults, metadata } = report;
738
- const entries = sections.flatMap((s) => Object.entries(s.extract(measuredResults, metadata)));
739
- return Object.fromEntries(entries);
740
- }
741
760
  /** @return column groups with diff columns if baseline exists */
742
761
  function createColumnGroups(sections, hasBaseline) {
743
762
  const nameColumn = { columns: [{
@@ -747,23 +766,11 @@ function createColumnGroups(sections, hasBaseline) {
747
766
  const groups = sections.flatMap((section) => section.columns());
748
767
  return [nameColumn, ...hasBaseline ? injectDiffColumns(groups) : groups];
749
768
  }
750
- /** @return groups with single CI column after first comparable field */
751
- function injectDiffColumns(reportGroups) {
752
- let ciAdded = false;
753
- return reportGroups.map((group) => ({
754
- groupTitle: group.groupTitle,
755
- columns: group.columns.flatMap((col) => {
756
- if (col.comparable && !ciAdded) {
757
- ciAdded = true;
758
- return [col, {
759
- title: "Δ% CI",
760
- key: "diffCI",
761
- formatter: col.higherIsBetter ? formatDiffWithCIHigherIsBetter : formatDiffWithCI
762
- }];
763
- }
764
- return [col];
765
- })
766
- }));
769
+ /** @return merged statistics from all sections */
770
+ function extractReportValues(report, sections) {
771
+ const { measuredResults, metadata } = report;
772
+ const entries = sections.flatMap((s) => Object.entries(s.extract(measuredResults, metadata)));
773
+ return Object.fromEntries(entries);
767
774
  }
768
775
 
769
776
  //#endregion
@@ -846,11 +853,21 @@ const cliOptions = {
846
853
  requiresArg: true,
847
854
  describe: "export benchmark data to JSON file"
848
855
  },
849
- perfetto: {
856
+ "export-perfetto": {
850
857
  type: "string",
851
858
  requiresArg: true,
852
859
  describe: "export Perfetto trace file (view at ui.perfetto.dev)"
853
860
  },
861
+ speedscope: {
862
+ type: "boolean",
863
+ default: false,
864
+ describe: "open heap profile in speedscope (via npx)"
865
+ },
866
+ "export-speedscope": {
867
+ type: "string",
868
+ requiresArg: true,
869
+ describe: "export heap profile as speedscope JSON"
870
+ },
854
871
  "trace-opt": {
855
872
  type: "boolean",
856
873
  default: false,
@@ -915,6 +932,11 @@ const cliOptions = {
915
932
  default: false,
916
933
  describe: "verbose output with file:// paths and line numbers"
917
934
  },
935
+ "heap-raw": {
936
+ type: "boolean",
937
+ default: false,
938
+ describe: "dump every raw heap sample (ordinal, size, stack)"
939
+ },
918
940
  "heap-user-only": {
919
941
  type: "boolean",
920
942
  default: false,
@@ -983,6 +1005,12 @@ function prepareJsonData(groups, args, suiteName) {
983
1005
  }]
984
1006
  };
985
1007
  }
1008
+ /** Clean CLI args for JSON export (remove undefined values) */
1009
+ function cleanCliArgs(args) {
1010
+ const toCamel = (k) => k.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
1011
+ const entries = Object.entries(args).filter(([, v]) => v !== void 0 && v !== null).map(([k, v]) => [toCamel(k), v]);
1012
+ return Object.fromEntries(entries);
1013
+ }
986
1014
  /** Convert a report group, mapping each report to the JSON result format */
987
1015
  function convertGroup(group) {
988
1016
  return {
@@ -1026,12 +1054,6 @@ function convertReport(report) {
1026
1054
  }
1027
1055
  };
1028
1056
  }
1029
- /** Clean CLI args for JSON export (remove undefined values) */
1030
- function cleanCliArgs(args) {
1031
- const toCamel = (k) => k.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
1032
- const entries = Object.entries(args).filter(([, v]) => v !== void 0 && v !== null).map(([k, v]) => [toCamel(k), v]);
1033
- return Object.fromEntries(entries);
1034
- }
1035
1057
 
1036
1058
  //#endregion
1037
1059
  //#region src/export/PerfettoExport.ts
@@ -1065,60 +1087,6 @@ function buildTraceEvents(groups, args) {
1065
1087
  }
1066
1088
  return events;
1067
1089
  }
1068
- function instant(ts, name, args) {
1069
- return {
1070
- ph: "i",
1071
- ts,
1072
- pid,
1073
- tid,
1074
- cat: "bench",
1075
- name,
1076
- s: "t",
1077
- args
1078
- };
1079
- }
1080
- function counter(ts, name, args) {
1081
- return {
1082
- ph: "C",
1083
- ts,
1084
- pid,
1085
- tid,
1086
- cat: "bench",
1087
- name,
1088
- args
1089
- };
1090
- }
1091
- /** Build events for a single benchmark run */
1092
- function buildBenchmarkEvents(results) {
1093
- const { samples, heapSamples, timestamps, pausePoints } = results;
1094
- if (!timestamps?.length) return [];
1095
- const events = [];
1096
- for (let i = 0; i < samples.length; i++) {
1097
- const ts = timestamps[i];
1098
- const ms = Math.round(samples[i] * 100) / 100;
1099
- events.push(instant(ts, results.name, {
1100
- n: i,
1101
- ms
1102
- }));
1103
- events.push(counter(ts, "duration", { ms }));
1104
- if (heapSamples?.[i] !== void 0) {
1105
- const MB = Math.round(heapSamples[i] / 1024 / 1024 * 10) / 10;
1106
- events.push(counter(ts, "heap", { MB }));
1107
- }
1108
- }
1109
- for (const pause of pausePoints ?? []) {
1110
- const ts = timestamps[pause.sampleIndex];
1111
- if (ts) events.push(instant(ts, "pause", { ms: pause.durationMs }));
1112
- }
1113
- return events;
1114
- }
1115
- /** Normalize timestamps so events start at 0 */
1116
- function normalizeTimestamps(events) {
1117
- const times = events.filter((e) => e.ts > 0).map((e) => e.ts);
1118
- if (times.length === 0) return;
1119
- const minTs = Math.min(...times);
1120
- for (const e of events) if (e.ts > 0) e.ts -= minTs;
1121
- }
1122
1090
  /** Merge V8 trace events from a previous run, aligning timestamps */
1123
1091
  function mergeV8Trace(customEvents) {
1124
1092
  const v8Events = loadV8Events(readdirSync(".").filter((f) => f.startsWith("node_trace.") && f.endsWith(".log"))[0]);
@@ -1127,29 +1095,11 @@ function mergeV8Trace(customEvents) {
1127
1095
  normalizeTimestamps(v8Events);
1128
1096
  return [...v8Events, ...customEvents];
1129
1097
  }
1130
- /** Load V8 trace events from file, or undefined if unavailable */
1131
- function loadV8Events(v8TracePath) {
1132
- if (!v8TracePath) return void 0;
1133
- try {
1134
- const v8Data = JSON.parse(readFileSync(v8TracePath, "utf-8"));
1135
- console.log(`Merged ${v8Data.traceEvents.length} V8 events from ${v8TracePath}`);
1136
- return v8Data.traceEvents;
1137
- } catch {
1138
- console.warn(`Could not parse V8 trace file: ${v8TracePath}`);
1139
- return;
1140
- }
1141
- }
1142
1098
  /** Write trace events to JSON file */
1143
1099
  function writeTraceFile(outputPath, events) {
1144
1100
  const traceFile = { traceEvents: events };
1145
1101
  writeFileSync(outputPath, JSON.stringify(traceFile));
1146
1102
  }
1147
- /** Clean CLI args for metadata */
1148
- function cleanArgs(args) {
1149
- const skip = new Set(["_", "$0"]);
1150
- const entries = Object.entries(args).filter(([k, v]) => v !== void 0 && !skip.has(k));
1151
- return Object.fromEntries(entries);
1152
- }
1153
1103
  /** Spawn a detached child to merge V8 trace after process exit */
1154
1104
  function scheduleDeferredMerge(outputPath) {
1155
1105
  const cwd = process.cwd();
@@ -1182,25 +1132,234 @@ function scheduleDeferredMerge(outputPath) {
1182
1132
  }).unref();
1183
1133
  });
1184
1134
  }
1185
-
1186
- //#endregion
1187
- //#region src/HtmlDataPrep.ts
1188
- /** Find higherIsBetter from first comparable column in sections */
1189
- function findHigherIsBetter(sections) {
1190
- return (sections?.flatMap((s) => s.columns().flatMap((g) => g.columns)))?.find((c) => c.comparable)?.higherIsBetter ?? false;
1135
+ /** Clean CLI args for metadata */
1136
+ function cleanArgs(args) {
1137
+ const skip = new Set(["_", "$0"]);
1138
+ const entries = Object.entries(args).filter(([k, v]) => v !== void 0 && !skip.has(k));
1139
+ return Object.fromEntries(entries);
1191
1140
  }
1192
- /** Flip CI percent for metrics where higher is better (e.g., lines/sec) */
1193
- function flipCI(ci) {
1141
+ /** Build events for a single benchmark run */
1142
+ function buildBenchmarkEvents(results) {
1143
+ const { samples, heapSamples, timestamps, pausePoints } = results;
1144
+ if (!timestamps?.length) return [];
1145
+ const events = [];
1146
+ for (let i = 0; i < samples.length; i++) {
1147
+ const ts = timestamps[i];
1148
+ const ms = Math.round(samples[i] * 100) / 100;
1149
+ events.push(instant(ts, results.name, {
1150
+ n: i,
1151
+ ms
1152
+ }));
1153
+ events.push(counter(ts, "duration", { ms }));
1154
+ if (heapSamples?.[i] !== void 0) {
1155
+ const MB = Math.round(heapSamples[i] / 1024 / 1024 * 10) / 10;
1156
+ events.push(counter(ts, "heap", { MB }));
1157
+ }
1158
+ }
1159
+ for (const pause of pausePoints ?? []) {
1160
+ const ts = timestamps[pause.sampleIndex];
1161
+ if (ts) events.push(instant(ts, "pause", { ms: pause.durationMs }));
1162
+ }
1163
+ return events;
1164
+ }
1165
+ /** Load V8 trace events from file, or undefined if unavailable */
1166
+ function loadV8Events(v8TracePath) {
1167
+ if (!v8TracePath) return void 0;
1168
+ try {
1169
+ const v8Data = JSON.parse(readFileSync(v8TracePath, "utf-8"));
1170
+ console.log(`Merged ${v8Data.traceEvents.length} V8 events from ${v8TracePath}`);
1171
+ return v8Data.traceEvents;
1172
+ } catch {
1173
+ console.warn(`Could not parse V8 trace file: ${v8TracePath}`);
1174
+ return;
1175
+ }
1176
+ }
1177
+ /** Normalize timestamps so events start at 0 */
1178
+ function normalizeTimestamps(events) {
1179
+ const times = events.filter((e) => e.ts > 0).map((e) => e.ts);
1180
+ if (times.length === 0) return;
1181
+ const minTs = Math.min(...times);
1182
+ for (const e of events) if (e.ts > 0) e.ts -= minTs;
1183
+ }
1184
+ function instant(ts, name, args) {
1194
1185
  return {
1195
- percent: -ci.percent,
1196
- ci: [-ci.ci[1], -ci.ci[0]],
1197
- direction: ci.direction,
1198
- histogram: ci.histogram?.map((bin) => ({
1199
- x: -bin.x,
1200
- count: bin.count
1201
- }))
1186
+ ph: "i",
1187
+ ts,
1188
+ pid,
1189
+ tid,
1190
+ cat: "bench",
1191
+ name,
1192
+ s: "t",
1193
+ args
1194
+ };
1195
+ }
1196
+ function counter(ts, name, args) {
1197
+ return {
1198
+ ph: "C",
1199
+ ts,
1200
+ pid,
1201
+ tid,
1202
+ cat: "bench",
1203
+ name,
1204
+ args
1205
+ };
1206
+ }
1207
+
1208
+ //#endregion
1209
+ //#region src/heap-sample/ResolvedProfile.ts
1210
+ /** Walk a HeapProfile tree once, producing a fully resolved intermediate form */
1211
+ function resolveProfile(profile) {
1212
+ const nodes = [];
1213
+ const nodeMap = /* @__PURE__ */ new Map();
1214
+ let totalBytes = 0;
1215
+ function walk(node, parentStack) {
1216
+ const { functionName, url, lineNumber, columnNumber } = node.callFrame;
1217
+ const frame = {
1218
+ name: functionName || "(anonymous)",
1219
+ url: url || "",
1220
+ line: lineNumber + 1,
1221
+ col: columnNumber != null ? columnNumber + 1 : void 0
1222
+ };
1223
+ const stack = [...parentStack, frame];
1224
+ const resolved = {
1225
+ frame,
1226
+ stack,
1227
+ selfSize: node.selfSize,
1228
+ nodeId: node.id
1229
+ };
1230
+ nodes.push(resolved);
1231
+ nodeMap.set(node.id, resolved);
1232
+ totalBytes += node.selfSize;
1233
+ for (const child of node.children || []) walk(child, stack);
1234
+ }
1235
+ walk(profile.head, []);
1236
+ return {
1237
+ nodes,
1238
+ nodeMap,
1239
+ allocationNodes: nodes.filter((n) => n.selfSize > 0).sort((a, b) => b.selfSize - a.selfSize),
1240
+ sortedSamples: profile.samples ? [...profile.samples].sort((a, b) => a.ordinal - b.ordinal) : void 0,
1241
+ totalBytes
1242
+ };
1243
+ }
1244
+
1245
+ //#endregion
1246
+ //#region src/export/SpeedscopeExport.ts
1247
+ /** Export heap profiles from benchmark results to speedscope JSON format.
1248
+ * Creates one speedscope profile per benchmark that has a heapProfile.
1249
+ * @returns resolved output path, or undefined if no profiles were found */
1250
+ function exportSpeedscope(groups, outputPath) {
1251
+ const frames = [];
1252
+ const frameIndex = /* @__PURE__ */ new Map();
1253
+ const profiles = [];
1254
+ for (const group of groups) for (const report of groupReports(group)) {
1255
+ const { heapProfile } = report.measuredResults;
1256
+ if (!heapProfile) continue;
1257
+ const resolved = resolveProfile(heapProfile);
1258
+ profiles.push(buildProfile(report.name, resolved, frames, frameIndex));
1259
+ }
1260
+ if (profiles.length === 0) {
1261
+ console.log("No heap profiles to export.");
1262
+ return;
1263
+ }
1264
+ const file = {
1265
+ $schema: "https://www.speedscope.app/file-format-schema.json",
1266
+ shared: { frames },
1267
+ profiles,
1268
+ exporter: "benchforge"
1269
+ };
1270
+ const absPath = resolve(outputPath);
1271
+ writeFileSync(absPath, JSON.stringify(file));
1272
+ console.log(`Speedscope profile exported to: ${outputPath}`);
1273
+ return absPath;
1274
+ }
1275
+ /** Export to a temp file and open in speedscope via npx */
1276
+ function exportAndLaunchSpeedscope(groups) {
1277
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1278
+ const absPath = exportSpeedscope(groups, join(tmpdir(), `benchforge-${timestamp}.speedscope.json`));
1279
+ if (absPath) launchSpeedscope(absPath);
1280
+ }
1281
+ /** Launch speedscope viewer on a file via npx */
1282
+ function launchSpeedscope(filePath) {
1283
+ console.log("Opening speedscope...");
1284
+ const child = spawn("npx", ["speedscope", filePath], {
1285
+ detached: true,
1286
+ stdio: "ignore"
1287
+ });
1288
+ child.unref();
1289
+ child.on("error", () => {
1290
+ console.error(`Failed to launch speedscope. Run manually:\n npx speedscope ${filePath}`);
1291
+ });
1292
+ }
1293
+ /** Convert a single HeapProfile to speedscope format (for standalone use) */
1294
+ function heapProfileToSpeedscope(name, profile) {
1295
+ const frames = [];
1296
+ const frameIndex = /* @__PURE__ */ new Map();
1297
+ const p = buildProfile(name, resolveProfile(profile), frames, frameIndex);
1298
+ return {
1299
+ $schema: "https://www.speedscope.app/file-format-schema.json",
1300
+ shared: { frames },
1301
+ profiles: [p],
1302
+ exporter: "benchforge"
1303
+ };
1304
+ }
1305
+ /** Build a single speedscope profile from a resolved heap profile */
1306
+ function buildProfile(name, resolved, sharedFrames, frameIndex) {
1307
+ const nodeStacks = /* @__PURE__ */ new Map();
1308
+ for (const node of resolved.nodes) {
1309
+ const stack = node.stack.map((f) => internFrame(f, sharedFrames, frameIndex));
1310
+ nodeStacks.set(node.nodeId, stack);
1311
+ }
1312
+ const samples = [];
1313
+ const weights = [];
1314
+ if (!resolved.sortedSamples || resolved.sortedSamples.length === 0) {
1315
+ console.error(`Speedscope export: no samples in heap profile for "${name}", skipping`);
1316
+ return {
1317
+ type: "sampled",
1318
+ name,
1319
+ unit: "bytes",
1320
+ startValue: 0,
1321
+ endValue: 0,
1322
+ samples,
1323
+ weights
1324
+ };
1325
+ }
1326
+ for (const sample of resolved.sortedSamples) {
1327
+ const stack = nodeStacks.get(sample.nodeId);
1328
+ if (stack) {
1329
+ samples.push(stack);
1330
+ weights.push(sample.size);
1331
+ }
1332
+ }
1333
+ return {
1334
+ type: "sampled",
1335
+ name,
1336
+ unit: "bytes",
1337
+ startValue: 0,
1338
+ endValue: weights.reduce((sum, w) => sum + w, 0),
1339
+ samples,
1340
+ weights
1202
1341
  };
1203
1342
  }
1343
+ /** Intern a call frame, returning its index in the shared frames array */
1344
+ function internFrame(frame, sharedFrames, frameIndex) {
1345
+ const { name, url, line, col } = frame;
1346
+ const key = `${name}\0${url}\0${line}\0${col}`;
1347
+ let idx = frameIndex.get(key);
1348
+ if (idx === void 0) {
1349
+ idx = sharedFrames.length;
1350
+ const shortFile = url ? url.split("/").pop() : void 0;
1351
+ const entry = { name: name !== "(anonymous)" ? name : shortFile ? `(anonymous ${shortFile}:${line})` : "(anonymous)" };
1352
+ if (url) entry.file = url;
1353
+ if (line > 0) entry.line = line;
1354
+ if (col != null) entry.col = col;
1355
+ sharedFrames.push(entry);
1356
+ frameIndex.set(key, idx);
1357
+ }
1358
+ return idx;
1359
+ }
1360
+
1361
+ //#endregion
1362
+ //#region src/HtmlDataPrep.ts
1204
1363
  /** Prepare ReportData from benchmark results for HTML rendering */
1205
1364
  function prepareHtmlData(groups, options) {
1206
1365
  const { cliArgs, sections, currentVersion, baselineVersion } = options;
@@ -1217,6 +1376,10 @@ function prepareHtmlData(groups, options) {
1217
1376
  }
1218
1377
  };
1219
1378
  }
1379
+ /** Find higherIsBetter from first comparable column in sections */
1380
+ function findHigherIsBetter(sections) {
1381
+ return (sections?.flatMap((s) => s.columns().flatMap((g) => g.columns)))?.find((c) => c.comparable)?.higherIsBetter ?? false;
1382
+ }
1220
1383
  /** @return group data with bootstrap CI comparisons against baseline */
1221
1384
  function prepareGroupData(group, sections, higherIsBetter) {
1222
1385
  const baselineSamples = group.baseline?.measuredResults.samples;
@@ -1251,6 +1414,18 @@ function prepareBenchmarkData(report, sections) {
1251
1414
  sectionStats: sections ? extractSectionStats(report, sections) : void 0
1252
1415
  };
1253
1416
  }
1417
+ /** Flip CI percent for metrics where higher is better (e.g., lines/sec) */
1418
+ function flipCI(ci) {
1419
+ return {
1420
+ percent: -ci.percent,
1421
+ ci: [-ci.ci[1], -ci.ci[0]],
1422
+ direction: ci.direction,
1423
+ histogram: ci.histogram?.map((bin) => ({
1424
+ x: -bin.x,
1425
+ count: bin.count
1426
+ }))
1427
+ };
1428
+ }
1254
1429
  /** @return formatted stats from all sections for tooltip display */
1255
1430
  function extractSectionStats(report, sections) {
1256
1431
  return sections.flatMap((section) => {
@@ -1279,36 +1454,34 @@ function formatColumnStat(values, col, groupTitle) {
1279
1454
  //#region src/heap-sample/HeapSampleReport.ts
1280
1455
  /** Sum selfSize across all nodes in profile (before any filtering) */
1281
1456
  function totalProfileBytes(profile) {
1282
- let total = 0;
1283
- function walk(node) {
1284
- total += node.selfSize;
1285
- for (const child of node.children || []) walk(child);
1286
- }
1287
- walk(profile.head);
1288
- return total;
1457
+ return resolveProfile(profile).totalBytes;
1289
1458
  }
1290
- /** Flatten profile tree into sorted list of allocation sites with call stacks */
1291
- function flattenProfile(profile) {
1459
+ /** Flatten resolved profile into sorted list of allocation sites with call stacks.
1460
+ * When raw samples are available, attaches them to corresponding sites. */
1461
+ function flattenProfile(resolved) {
1292
1462
  const sites = [];
1293
- function walk(node, stack) {
1294
- const { functionName, url, lineNumber, columnNumber } = node.callFrame;
1295
- const fn = functionName || "(anonymous)";
1296
- const col = columnNumber ?? 0;
1297
- const frame = {
1298
- fn,
1299
- url: url || "",
1300
- line: lineNumber + 1,
1301
- col
1302
- };
1303
- const newStack = [...stack, frame];
1304
- if (node.selfSize > 0) sites.push({
1463
+ const nodeIdToSites = /* @__PURE__ */ new Map();
1464
+ for (const node of resolved.allocationNodes) {
1465
+ const frame = toCallFrame(node.frame);
1466
+ const stack = node.stack.map(toCallFrame);
1467
+ const site = {
1305
1468
  ...frame,
1306
1469
  bytes: node.selfSize,
1307
- stack: newStack
1308
- });
1309
- for (const child of node.children || []) walk(child, newStack);
1470
+ stack
1471
+ };
1472
+ sites.push(site);
1473
+ const existing = nodeIdToSites.get(node.nodeId);
1474
+ if (existing) existing.push(site);
1475
+ else nodeIdToSites.set(node.nodeId, [site]);
1476
+ }
1477
+ for (const sample of resolved.sortedSamples ?? []) {
1478
+ const matchingSites = nodeIdToSites.get(sample.nodeId);
1479
+ if (!matchingSites) continue;
1480
+ for (const site of matchingSites) {
1481
+ if (!site.samples) site.samples = [];
1482
+ site.samples.push(sample);
1483
+ }
1310
1484
  }
1311
- walk(profile.head, []);
1312
1485
  return sites.sort((a, b) => b.bytes - a.bytes);
1313
1486
  }
1314
1487
  /** Check if site is user code (not node internals) */
@@ -1331,22 +1504,31 @@ function isBrowserUserCode(site) {
1331
1504
  function filterSites(sites, isUser = isNodeUserCode) {
1332
1505
  return sites.filter(isUser);
1333
1506
  }
1334
- /** Aggregate sites by location (combine same file:line:col) */
1507
+ /** Aggregate sites by location (combine same file:line:col).
1508
+ * Tracks distinct caller stacks with byte weights when merging. */
1335
1509
  function aggregateSites(sites) {
1336
1510
  const byLocation = /* @__PURE__ */ new Map();
1337
1511
  for (const site of sites) {
1338
- const key = `${site.url}:${site.line}:${site.col}`;
1512
+ const key = site.col != null ? `${site.url}:${site.line}:${site.col}` : `${site.url}:${site.line}:?:${site.fn}`;
1339
1513
  const existing = byLocation.get(key);
1340
- if (existing) existing.bytes += site.bytes;
1341
- else byLocation.set(key, { ...site });
1514
+ if (existing) {
1515
+ existing.bytes += site.bytes;
1516
+ addCaller(existing, site);
1517
+ } else {
1518
+ const entry = { ...site };
1519
+ if (site.stack) entry.callers = [{
1520
+ stack: site.stack,
1521
+ bytes: site.bytes
1522
+ }];
1523
+ byLocation.set(key, entry);
1524
+ }
1525
+ }
1526
+ for (const site of byLocation.values()) if (site.callers && site.callers.length > 1) {
1527
+ site.callers.sort((a, b) => b.bytes - a.bytes);
1528
+ site.stack = site.callers[0].stack;
1342
1529
  }
1343
1530
  return [...byLocation.values()].sort((a, b) => b.bytes - a.bytes);
1344
1531
  }
1345
- function fmtBytes(bytes) {
1346
- if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
1347
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1348
- return `${bytes} B`;
1349
- }
1350
1532
  /** Format heap report for console output */
1351
1533
  function formatHeapReport(sites, options) {
1352
1534
  const { topN, stackDepth = 3, verbose = false } = options;
@@ -1362,34 +1544,79 @@ function formatHeapReport(sites, options) {
1362
1544
  if (sampleCount !== void 0) lines.push(`Samples: ${sampleCount.toLocaleString()}`);
1363
1545
  return lines.join("\n");
1364
1546
  }
1365
- /** Compact single-line format: `49 MB fn1 <- fn2 <- fn3` */
1366
- function formatCompactSite(lines, site, stackDepth, isUser) {
1547
+ /** Format every raw sample as one line, ordered by ordinal (time).
1548
+ * Output is tab-separated for easy piping/grep/diff. */
1549
+ function formatRawSamples(resolved) {
1550
+ if (!resolved.sortedSamples || resolved.sortedSamples.length === 0) return "No raw samples available.";
1551
+ const lines = ["ordinal size function location"];
1552
+ for (const s of resolved.sortedSamples) {
1553
+ const node = resolved.nodeMap.get(s.nodeId);
1554
+ const fn = node?.frame.name || "(unknown)";
1555
+ const url = node?.frame.url || "";
1556
+ const loc = url ? fmtLoc(url, node.frame.line, node.frame.col) : "(unknown)";
1557
+ lines.push(`${s.ordinal}\t${s.size}\t${fn}\t${loc}`);
1558
+ }
1559
+ return lines.join("\n");
1560
+ }
1561
+ function toCallFrame(f) {
1562
+ return {
1563
+ fn: f.name,
1564
+ url: f.url,
1565
+ line: f.line,
1566
+ col: f.col
1567
+ };
1568
+ }
1569
+ /** Add a caller stack to an aggregated site, merging if the same path exists */
1570
+ function addCaller(existing, site) {
1571
+ if (!site.stack) return;
1572
+ if (!existing.callers) existing.callers = [];
1573
+ const key = callerKey(site.stack);
1574
+ const match = existing.callers.find((c) => callerKey(c.stack) === key);
1575
+ if (match) match.bytes += site.bytes;
1576
+ else existing.callers.push({
1577
+ stack: site.stack,
1578
+ bytes: site.bytes
1579
+ });
1580
+ }
1581
+ /** Verbose multi-line format with file:// paths and line numbers */
1582
+ function formatVerboseSite(lines, site, stackDepth, isUser) {
1367
1583
  const bytes = fmtBytes(site.bytes).padStart(10);
1368
- const fns = [site.fn];
1584
+ const loc = site.url ? fmtLoc(site.url, site.line, site.col) : "(unknown)";
1585
+ const dimFn = isUser(site) ? (s) => s : pico.dim;
1586
+ lines.push(dimFn(`${bytes} ${site.fn} ${loc}`));
1369
1587
  if (site.stack && site.stack.length > 1) {
1370
1588
  const callers = site.stack.slice(0, -1).reverse().slice(0, stackDepth);
1371
1589
  for (const frame of callers) {
1372
1590
  if (!frame.url || !isUser(frame)) continue;
1373
- fns.push(frame.fn);
1591
+ const callerLoc = fmtLoc(frame.url, frame.line, frame.col);
1592
+ lines.push(dimFn(` <- ${frame.fn} ${callerLoc}`));
1374
1593
  }
1375
1594
  }
1376
- const line = `${bytes} ${fns.join(" <- ")}`;
1377
- lines.push(isUser(site) ? line : pico.dim(line));
1378
1595
  }
1379
- /** Verbose multi-line format with file:// paths and line numbers */
1380
- function formatVerboseSite(lines, site, stackDepth, isUser) {
1596
+ /** Compact single-line format: `49 MB fn1 <- fn2 <- fn3` */
1597
+ function formatCompactSite(lines, site, stackDepth, isUser) {
1381
1598
  const bytes = fmtBytes(site.bytes).padStart(10);
1382
- const loc = site.url ? `${site.url}:${site.line}:${site.col}` : "(unknown)";
1383
- const dimFn = isUser(site) ? (s) => s : pico.dim;
1384
- lines.push(dimFn(`${bytes} ${site.fn} ${loc}`));
1599
+ const fns = [site.fn];
1385
1600
  if (site.stack && site.stack.length > 1) {
1386
1601
  const callers = site.stack.slice(0, -1).reverse().slice(0, stackDepth);
1387
1602
  for (const frame of callers) {
1388
1603
  if (!frame.url || !isUser(frame)) continue;
1389
- const callerLoc = `${frame.url}:${frame.line}:${frame.col}`;
1390
- lines.push(dimFn(` <- ${frame.fn} ${callerLoc}`));
1604
+ fns.push(frame.fn);
1391
1605
  }
1392
1606
  }
1607
+ const line = `${bytes} ${fns.join(" <- ")}`;
1608
+ lines.push(isUser(site) ? line : pico.dim(line));
1609
+ }
1610
+ function fmtBytes(bytes) {
1611
+ return formatBytes(bytes, { space: true }) ?? `${bytes} B`;
1612
+ }
1613
+ /** Format location, omitting column when unknown */
1614
+ function fmtLoc(url, line, col) {
1615
+ return col != null ? `${url}:${line}:${col}` : `${url}:${line}`;
1616
+ }
1617
+ /** Serialize a call stack for dedup comparison */
1618
+ function callerKey(stack) {
1619
+ return stack.map((f) => `${f.url}:${f.line}:${f.col}`).join("|");
1393
1620
  }
1394
1621
 
1395
1622
  //#endregion
@@ -1400,6 +1627,18 @@ const skipArgs = new Set([
1400
1627
  "html",
1401
1628
  "export-html"
1402
1629
  ]);
1630
+ const badgeLabels = {
1631
+ faster: "Faster",
1632
+ slower: "Slower",
1633
+ uncertain: "Inconclusive"
1634
+ };
1635
+ const defaultArgs = {
1636
+ worker: true,
1637
+ time: 5,
1638
+ warmup: 500,
1639
+ "pause-interval": 0,
1640
+ "pause-duration": 100
1641
+ };
1403
1642
  /** Format ISO date as local time with UTC: "Jan 9, 2026, 3:45 PM (2026-01-09T23:45:00Z)" */
1404
1643
  function formatDateWithTimezone(isoDate) {
1405
1644
  const date = new Date(isoDate);
@@ -1428,62 +1667,6 @@ function formatRelativeTime(isoDate) {
1428
1667
  day: "numeric"
1429
1668
  });
1430
1669
  }
1431
- /** Format git version for display: "abc1234* (5m ago)" */
1432
- function formatVersion(version) {
1433
- if (!version || version.hash === "unknown") return "unknown";
1434
- const hashDisplay = version.dirty ? `${version.hash}*` : version.hash;
1435
- const timeDisplay = version.date ? formatRelativeTime(version.date) : "";
1436
- return timeDisplay ? `${hashDisplay} (${timeDisplay})` : hashDisplay;
1437
- }
1438
- /** Render current/baseline version info as an HTML div */
1439
- function versionInfoHtml(data) {
1440
- const { currentVersion, baselineVersion } = data.metadata;
1441
- if (!currentVersion && !baselineVersion) return "";
1442
- const parts = [];
1443
- if (currentVersion) parts.push(`Current: ${formatVersion(currentVersion)}`);
1444
- if (baselineVersion) parts.push(`Baseline: ${formatVersion(baselineVersion)}`);
1445
- return `<div class="version-info">${parts.join(" | ")}</div>`;
1446
- }
1447
- const badgeLabels = {
1448
- faster: "Faster",
1449
- slower: "Slower",
1450
- uncertain: "Inconclusive"
1451
- };
1452
- /** Render faster/slower/uncertain badge with CI plot container */
1453
- function comparisonBadge(group, groupIndex) {
1454
- const ci = group.benchmarks[0]?.comparisonCI;
1455
- if (!ci) return "";
1456
- const label = badgeLabels[ci.direction];
1457
- return `
1458
- <span class="badge badge-${ci.direction}">${label}</span>
1459
- <div id="ci-plot-${groupIndex}" class="ci-plot-container"></div>
1460
- `;
1461
- }
1462
- const defaultArgs = {
1463
- worker: true,
1464
- time: 5,
1465
- warmup: 500,
1466
- "pause-interval": 0,
1467
- "pause-duration": 100
1468
- };
1469
- /** @return true if this CLI arg should be hidden from the report header */
1470
- function shouldSkipArg(key, value, adaptive) {
1471
- if (skipArgs.has(key) || value === void 0 || value === false) return true;
1472
- if (defaultArgs[key] === value) return true;
1473
- if (!key.includes("-") && key !== key.toLowerCase()) return true;
1474
- if (key === "convergence" && !adaptive) return true;
1475
- return false;
1476
- }
1477
- /** Reconstruct the CLI invocation string, omitting default/internal args */
1478
- function formatCliArgs(args) {
1479
- if (!args) return "bb bench";
1480
- const parts = ["bb bench"];
1481
- for (const [key, value] of Object.entries(args)) {
1482
- if (shouldSkipArg(key, value, args.adaptive)) continue;
1483
- parts.push(value === true ? `--${key}` : `--${key} ${value}`);
1484
- }
1485
- return parts.join(" ");
1486
- }
1487
1670
  /** Generate complete HTML document with embedded data and visualizations */
1488
1671
  function generateHtmlDocument(data) {
1489
1672
  return `<!DOCTYPE html>
@@ -1658,15 +1841,59 @@ function generateHtmlDocument(data) {
1658
1841
  </body>
1659
1842
  </html>`;
1660
1843
  }
1661
-
1662
- //#endregion
1663
- //#region src/html/HtmlReport.ts
1664
- /** Generate HTML report from prepared data and optionally open in browser */
1665
- async function generateHtmlReport(data, options) {
1666
- const html = generateHtmlDocument(data);
1667
- const reportDir = options.outputPath || await createReportDir();
1668
- await mkdir(reportDir, { recursive: true });
1669
- await writeFile(join(reportDir, "index.html"), html, "utf-8");
1844
+ /** Reconstruct the CLI invocation string, omitting default/internal args */
1845
+ function formatCliArgs(args) {
1846
+ if (!args) return "bb bench";
1847
+ const parts = ["bb bench"];
1848
+ for (const [key, value] of Object.entries(args)) {
1849
+ if (shouldSkipArg(key, value, args.adaptive)) continue;
1850
+ parts.push(value === true ? `--${key}` : `--${key} ${value}`);
1851
+ }
1852
+ return parts.join(" ");
1853
+ }
1854
+ /** Render current/baseline version info as an HTML div */
1855
+ function versionInfoHtml(data) {
1856
+ const { currentVersion, baselineVersion } = data.metadata;
1857
+ if (!currentVersion && !baselineVersion) return "";
1858
+ const parts = [];
1859
+ if (currentVersion) parts.push(`Current: ${formatVersion(currentVersion)}`);
1860
+ if (baselineVersion) parts.push(`Baseline: ${formatVersion(baselineVersion)}`);
1861
+ return `<div class="version-info">${parts.join(" | ")}</div>`;
1862
+ }
1863
+ /** Render faster/slower/uncertain badge with CI plot container */
1864
+ function comparisonBadge(group, groupIndex) {
1865
+ const ci = group.benchmarks[0]?.comparisonCI;
1866
+ if (!ci) return "";
1867
+ const label = badgeLabels[ci.direction];
1868
+ return `
1869
+ <span class="badge badge-${ci.direction}">${label}</span>
1870
+ <div id="ci-plot-${groupIndex}" class="ci-plot-container"></div>
1871
+ `;
1872
+ }
1873
+ /** @return true if this CLI arg should be hidden from the report header */
1874
+ function shouldSkipArg(key, value, adaptive) {
1875
+ if (skipArgs.has(key) || value === void 0 || value === false) return true;
1876
+ if (defaultArgs[key] === value) return true;
1877
+ if (!key.includes("-") && key !== key.toLowerCase()) return true;
1878
+ if (key === "convergence" && !adaptive) return true;
1879
+ return false;
1880
+ }
1881
+ /** Format git version for display: "abc1234* (5m ago)" */
1882
+ function formatVersion(version) {
1883
+ if (!version || version.hash === "unknown") return "unknown";
1884
+ const hashDisplay = version.dirty ? `${version.hash}*` : version.hash;
1885
+ const timeDisplay = version.date ? formatRelativeTime(version.date) : "";
1886
+ return timeDisplay ? `${hashDisplay} (${timeDisplay})` : hashDisplay;
1887
+ }
1888
+
1889
+ //#endregion
1890
+ //#region src/html/HtmlReport.ts
1891
+ /** Generate HTML report from prepared data and optionally open in browser */
1892
+ async function generateHtmlReport(data, options) {
1893
+ const html = generateHtmlDocument(data);
1894
+ const reportDir = options.outputPath || await createReportDir();
1895
+ await mkdir(reportDir, { recursive: true });
1896
+ await writeFile(join(reportDir, "index.html"), html, "utf-8");
1670
1897
  const plots = await loadPlotsBundle();
1671
1898
  await writeFile(join(reportDir, "plots.js"), plots, "utf-8");
1672
1899
  await writeLatestRedirect(reportDir);
@@ -1688,6 +1915,35 @@ async function generateHtmlReport(data, options) {
1688
1915
  closeServer
1689
1916
  };
1690
1917
  }
1918
+ /** Create a timestamped report directory under ./bench-report/ */
1919
+ async function createReportDir() {
1920
+ const base = "./bench-report";
1921
+ await mkdir(base, { recursive: true });
1922
+ return join(base, `report-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
1923
+ }
1924
+ /** Read the pre-built browser plots bundle from dist/ */
1925
+ async function loadPlotsBundle() {
1926
+ const thisDir = dirname(fileURLToPath(import.meta.url));
1927
+ const builtPath = join(thisDir, "browser/index.js");
1928
+ const devPath = join(thisDir, "../../dist/browser/index.js");
1929
+ try {
1930
+ return await readFile(builtPath, "utf-8");
1931
+ } catch {}
1932
+ return readFile(devPath, "utf-8");
1933
+ }
1934
+ /** Write an index.html in the parent dir that redirects to this report */
1935
+ async function writeLatestRedirect(reportDir) {
1936
+ const baseDir = dirname(reportDir);
1937
+ const reportName = reportDir.split("/").pop();
1938
+ const html = `<!DOCTYPE html>
1939
+ <html><head>
1940
+ <meta http-equiv="refresh" content="0; url=./${reportName}/">
1941
+ <script>location.href = "./${reportName}/";<\/script>
1942
+ </head><body>
1943
+ <a href="./${reportName}/">Latest report</a>
1944
+ </body></html>`;
1945
+ await writeFile(join(baseDir, "index.html"), html, "utf-8");
1946
+ }
1691
1947
  /** Start HTTP server for report directory, trying fallback ports if needed */
1692
1948
  async function startReportServer(baseDir, ...ports) {
1693
1949
  const mimeTypes = {
@@ -1728,35 +1984,6 @@ function tryListen(server, port) {
1728
1984
  });
1729
1985
  });
1730
1986
  }
1731
- /** Create a timestamped report directory under ./bench-report/ */
1732
- async function createReportDir() {
1733
- const base = "./bench-report";
1734
- await mkdir(base, { recursive: true });
1735
- return join(base, `report-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
1736
- }
1737
- /** Read the pre-built browser plots bundle from dist/ */
1738
- async function loadPlotsBundle() {
1739
- const thisDir = dirname(fileURLToPath(import.meta.url));
1740
- const builtPath = join(thisDir, "browser/index.js");
1741
- const devPath = join(thisDir, "../../dist/browser/index.js");
1742
- try {
1743
- return await readFile(builtPath, "utf-8");
1744
- } catch {}
1745
- return readFile(devPath, "utf-8");
1746
- }
1747
- /** Write an index.html in the parent dir that redirects to this report */
1748
- async function writeLatestRedirect(reportDir) {
1749
- const baseDir = dirname(reportDir);
1750
- const reportName = reportDir.split("/").pop();
1751
- const html = `<!DOCTYPE html>
1752
- <html><head>
1753
- <meta http-equiv="refresh" content="0; url=./${reportName}/">
1754
- <script>location.href = "./${reportName}/";<\/script>
1755
- </head><body>
1756
- <a href="./${reportName}/">Latest report</a>
1757
- </body></html>`;
1758
- await writeFile(join(baseDir, "index.html"), html, "utf-8");
1759
- }
1760
1987
 
1761
1988
  //#endregion
1762
1989
  //#region src/matrix/MatrixFilter.ts
@@ -2034,13 +2261,6 @@ const adaptiveSection = {
2034
2261
  formatter: formatConvergence
2035
2262
  }] }]
2036
2263
  };
2037
- /** Build generic sections based on CLI flags */
2038
- function buildGenericSections(args) {
2039
- const sections = [];
2040
- if (args["gc-stats"]) sections.push(gcStatsSection);
2041
- sections.push(runsSection);
2042
- return sections;
2043
- }
2044
2264
  /** Section: V8 optimization tier distribution and deopt count */
2045
2265
  const optSection = {
2046
2266
  extract: (results) => {
@@ -2065,14 +2285,52 @@ const optSection = {
2065
2285
  }]
2066
2286
  }]
2067
2287
  };
2288
+ /** Build generic sections based on CLI flags */
2289
+ function buildGenericSections(args) {
2290
+ const sections = [];
2291
+ if (args["gc-stats"]) sections.push(gcStatsSection);
2292
+ sections.push(runsSection);
2293
+ return sections;
2294
+ }
2068
2295
 
2069
2296
  //#endregion
2070
2297
  //#region src/matrix/MatrixReport.ts
2298
+ /** GC statistics columns - derived from gcStatsSection for consistency */
2299
+ const gcStatsColumns = gcStatsSection.columns()[0].columns.map((col) => ({
2300
+ key: col.key,
2301
+ title: col.title,
2302
+ groupTitle: "GC",
2303
+ extract: (r) => gcStatsSection.extract(r.measured)[col.key],
2304
+ formatter: (v) => col.formatter?.(v) ?? "-"
2305
+ }));
2306
+ /** GC pause time column */
2307
+ const gcPauseColumn = {
2308
+ key: "gcPause",
2309
+ title: "pause",
2310
+ groupTitle: "GC",
2311
+ extract: (r) => r.measured.gcStats?.gcPauseTime,
2312
+ formatter: (v) => v != null ? `${v.toFixed(1)}ms` : "-"
2313
+ };
2314
+ /** Heap sampling total bytes column */
2315
+ const heapTotalColumn = {
2316
+ key: "heapTotal",
2317
+ title: "heap",
2318
+ extract: (r) => {
2319
+ const profile = r.measured.heapProfile;
2320
+ if (!profile?.head) return void 0;
2321
+ return totalProfileBytes(profile);
2322
+ },
2323
+ formatter: formatBytesOrDash
2324
+ };
2071
2325
  /** Format matrix results as one table per case */
2072
2326
  function reportMatrixResults(results, options) {
2073
2327
  const tables = buildCaseTables(results, options);
2074
2328
  return [`Matrix: ${results.name}`, ...tables].join("\n\n");
2075
2329
  }
2330
+ /** Format bytes with fallback to "-" for missing values */
2331
+ function formatBytesOrDash(value) {
2332
+ return formatBytes(value) ?? "-";
2333
+ }
2076
2334
  /** Build one table for each case showing all variants */
2077
2335
  function buildCaseTables(results, options) {
2078
2336
  if (results.variants.length === 0) return [];
@@ -2085,6 +2343,12 @@ function buildCaseTable(results, caseId, options) {
2085
2343
  const rows = buildCaseRows(results, caseId, options?.extraColumns);
2086
2344
  return `${caseTitle}\n${buildTable(buildColumns(rows.some((r) => r.diffCI), options), [{ results: rows }])}`;
2087
2345
  }
2346
+ /** Format case title with metadata if available */
2347
+ function formatCaseTitle(results, caseId) {
2348
+ const metadata = (results.variants[0]?.cases.find((c) => c.caseId === caseId))?.metadata;
2349
+ if (metadata && Object.keys(metadata).length > 0) return `${caseId} (${Object.entries(metadata).map(([k, v]) => `${v} ${k}`).join(", ")})`;
2350
+ return caseId;
2351
+ }
2088
2352
  /** Build table using ResultsMapper sections */
2089
2353
  function buildSectionTable(results, caseId, options, caseTitle) {
2090
2354
  const sections = options.sections;
@@ -2105,15 +2369,6 @@ function buildSectionTable(results, caseId, options, caseTitle) {
2105
2369
  }
2106
2370
  return `${caseTitle}\n${buildTable(buildSectionColumns(sections, variantTitle, hasBaseline), [{ results: rows }])}`;
2107
2371
  }
2108
- /** Build column groups from ResultsMapper sections */
2109
- function buildSectionColumns(sections, variantTitle, hasBaseline) {
2110
- const nameCol = { columns: [{
2111
- key: "name",
2112
- title: variantTitle
2113
- }] };
2114
- const sectionColumns = sections.flatMap((s) => s.columns());
2115
- return [nameCol, ...hasBaseline ? injectDiffColumns(sectionColumns) : sectionColumns];
2116
- }
2117
2372
  /** Build rows for all variants for a given case */
2118
2373
  function buildCaseRows(results, caseId, extraColumns) {
2119
2374
  return results.variants.flatMap((variant) => {
@@ -2121,20 +2376,6 @@ function buildCaseRows(results, caseId, extraColumns) {
2121
2376
  return caseResult ? [buildRow(variant.id, caseResult, extraColumns)] : [];
2122
2377
  });
2123
2378
  }
2124
- /** Build a single row from case result */
2125
- function buildRow(variantId, caseResult, extraColumns) {
2126
- const { measured, baseline } = caseResult;
2127
- const samples = measured.samples;
2128
- const time = measured.time?.avg ?? average(samples);
2129
- const row = {
2130
- name: truncate(variantId, 25),
2131
- time,
2132
- samples: samples.length
2133
- };
2134
- if (baseline) row.diffCI = bootstrapDifferenceCI(baseline.samples, samples);
2135
- if (extraColumns) for (const col of extraColumns) row[col.key] = col.extract(caseResult);
2136
- return row;
2137
- }
2138
2379
  /** Build column configuration */
2139
2380
  function buildColumns(hasBaseline, options) {
2140
2381
  const groups = [{ columns: [{
@@ -2168,48 +2409,34 @@ function buildColumns(hasBaseline, options) {
2168
2409
  }
2169
2410
  return groups;
2170
2411
  }
2412
+ /** Build column groups from ResultsMapper sections */
2413
+ function buildSectionColumns(sections, variantTitle, hasBaseline) {
2414
+ const nameCol = { columns: [{
2415
+ key: "name",
2416
+ title: variantTitle
2417
+ }] };
2418
+ const sectionColumns = sections.flatMap((s) => s.columns());
2419
+ return [nameCol, ...hasBaseline ? injectDiffColumns(sectionColumns) : sectionColumns];
2420
+ }
2421
+ /** Build a single row from case result */
2422
+ function buildRow(variantId, caseResult, extraColumns) {
2423
+ const { measured, baseline } = caseResult;
2424
+ const samples = measured.samples;
2425
+ const time = measured.time?.avg ?? average(samples);
2426
+ const row = {
2427
+ name: truncate(variantId, 25),
2428
+ time,
2429
+ samples: samples.length
2430
+ };
2431
+ if (baseline) row.diffCI = bootstrapDifferenceCI(baseline.samples, samples);
2432
+ if (extraColumns) for (const col of extraColumns) row[col.key] = col.extract(caseResult);
2433
+ return row;
2434
+ }
2171
2435
  /** Format diff with CI, or "baseline" marker */
2172
2436
  function formatDiff(value) {
2173
2437
  if (!value) return null;
2174
2438
  return formatDiffWithCI(value);
2175
2439
  }
2176
- /** Format case title with metadata if available */
2177
- function formatCaseTitle(results, caseId) {
2178
- const metadata = (results.variants[0]?.cases.find((c) => c.caseId === caseId))?.metadata;
2179
- if (metadata && Object.keys(metadata).length > 0) return `${caseId} (${Object.entries(metadata).map(([k, v]) => `${v} ${k}`).join(", ")})`;
2180
- return caseId;
2181
- }
2182
- /** GC statistics columns - derived from gcStatsSection for consistency */
2183
- const gcStatsColumns = gcStatsSection.columns()[0].columns.map((col) => ({
2184
- key: col.key,
2185
- title: col.title,
2186
- groupTitle: "GC",
2187
- extract: (r) => gcStatsSection.extract(r.measured)[col.key],
2188
- formatter: (v) => col.formatter?.(v) ?? "-"
2189
- }));
2190
- /** Format bytes with fallback to "-" for missing values */
2191
- function formatBytesOrDash(value) {
2192
- return formatBytes(value) ?? "-";
2193
- }
2194
- /** GC pause time column */
2195
- const gcPauseColumn = {
2196
- key: "gcPause",
2197
- title: "pause",
2198
- groupTitle: "GC",
2199
- extract: (r) => r.measured.gcStats?.gcPauseTime,
2200
- formatter: (v) => v != null ? `${v.toFixed(1)}ms` : "-"
2201
- };
2202
- /** Heap sampling total bytes column */
2203
- const heapTotalColumn = {
2204
- key: "heapTotal",
2205
- title: "heap",
2206
- extract: (r) => {
2207
- const profile = r.measured.heapProfile;
2208
- if (!profile?.head) return void 0;
2209
- return totalProfileBytes(profile);
2210
- },
2211
- formatter: formatBytesOrDash
2212
- };
2213
2440
 
2214
2441
  //#endregion
2215
2442
  //#region src/cli/FilterBenchmarks.ts
@@ -2244,32 +2471,21 @@ function createFilterRegex(filter) {
2244
2471
  function stripCaseSuffix(name) {
2245
2472
  return name.replace(/ \[.*?\]$/, "");
2246
2473
  }
2247
- /** Escape regex special characters */
2248
- function escapeRegex(str) {
2249
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2250
- }
2251
2474
  /** Ensure at least one benchmark matches filter */
2252
2475
  function validateFilteredSuite(groups, filter) {
2253
2476
  if (groups.every((g) => g.benchmarks.length === 0)) throw new Error(`No benchmarks match filter: "${filter}"`);
2254
2477
  }
2478
+ /** Escape regex special characters */
2479
+ function escapeRegex(str) {
2480
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2481
+ }
2255
2482
 
2256
2483
  //#endregion
2257
2484
  //#region src/cli/RunBenchCLI.ts
2258
- /** Validate CLI argument combinations */
2259
- function validateArgs(args) {
2260
- if (args["gc-stats"] && !args.worker && !args.url) throw new Error("--gc-stats requires worker mode (the default). Remove --no-worker flag.");
2261
- }
2262
- /** Warn about Node-only flags that are ignored in browser mode. */
2263
- function warnBrowserFlags(args) {
2264
- const ignored = [];
2265
- if (!args.worker) ignored.push("--no-worker");
2266
- if (args.cpu) ignored.push("--cpu");
2267
- if (args["trace-opt"]) ignored.push("--trace-opt");
2268
- if (args.collect) ignored.push("--collect");
2269
- if (args.adaptive) ignored.push("--adaptive");
2270
- if (args.batches > 1) ignored.push("--batches");
2271
- if (ignored.length) console.warn(yellow(`Ignored in browser mode: ${ignored.join(", ")}`));
2272
- }
2485
+ const { yellow, dim } = process.env.NODE_ENV === "test" || process.env.VITEST === "true" ? {
2486
+ yellow: (s) => s,
2487
+ dim: (s) => s
2488
+ } : pico;
2273
2489
  /** Parse CLI with custom configuration */
2274
2490
  function parseBenchArgs(configureArgs) {
2275
2491
  return parseCliArgs(hideBin(process.argv), configureArgs);
@@ -2287,144 +2503,6 @@ async function runBenchmarks(suite, args) {
2287
2503
  batches
2288
2504
  });
2289
2505
  }
2290
- /** Execute all groups in suite */
2291
- async function runSuite(params) {
2292
- const { suite, runner, options, useWorker, batches } = params;
2293
- const results = [];
2294
- for (const group of suite.groups) results.push(await runGroup(group, runner, options, useWorker, batches));
2295
- return results;
2296
- }
2297
- /** Execute group with shared setup, optionally batching to reduce ordering bias */
2298
- async function runGroup(group, runner, options, useWorker, batches = 1) {
2299
- const { name, benchmarks, baseline, setup, metadata } = group;
2300
- const setupParams = await setup?.();
2301
- validateBenchmarkParameters(group);
2302
- const runParams = {
2303
- runner,
2304
- options,
2305
- useWorker,
2306
- params: setupParams,
2307
- metadata
2308
- };
2309
- if (batches === 1) return runSingleBatch(name, benchmarks, baseline, runParams);
2310
- return runMultipleBatches(name, benchmarks, baseline, runParams, batches);
2311
- }
2312
- /** Run benchmarks in a single batch */
2313
- async function runSingleBatch(name, benchmarks, baseline, runParams) {
2314
- const baselineReport = baseline ? await runSingleBenchmark(baseline, runParams) : void 0;
2315
- return {
2316
- name,
2317
- reports: await serialMap(benchmarks, (b) => runSingleBenchmark(b, runParams)),
2318
- baseline: baselineReport
2319
- };
2320
- }
2321
- /** Run benchmarks in multiple batches, alternating order to reduce bias */
2322
- async function runMultipleBatches(name, benchmarks, baseline, runParams, batches) {
2323
- const timePerBatch = (runParams.options.maxTime || 5e3) / batches;
2324
- const batchParams = {
2325
- ...runParams,
2326
- options: {
2327
- ...runParams.options,
2328
- maxTime: timePerBatch
2329
- }
2330
- };
2331
- const baselineBatches = [];
2332
- const benchmarkBatches = /* @__PURE__ */ new Map();
2333
- for (let i = 0; i < batches; i++) await runBatchIteration(benchmarks, baseline, batchParams, i % 2 === 1, baselineBatches, benchmarkBatches);
2334
- const meta = runParams.metadata;
2335
- return mergeBatchResults(name, benchmarks, baseline, baselineBatches, benchmarkBatches, meta);
2336
- }
2337
- /** Run one batch iteration in either order */
2338
- async function runBatchIteration(benchmarks, baseline, runParams, reverseOrder, baselineBatches, benchmarkBatches) {
2339
- const runBaseline = async () => {
2340
- if (baseline) {
2341
- const r = await runSingleBenchmark(baseline, runParams);
2342
- baselineBatches.push(r.measuredResults);
2343
- }
2344
- };
2345
- const runBenches = async () => {
2346
- for (const b of benchmarks) {
2347
- const r = await runSingleBenchmark(b, runParams);
2348
- appendToMap(benchmarkBatches, b.name, r.measuredResults);
2349
- }
2350
- };
2351
- if (reverseOrder) {
2352
- await runBenches();
2353
- await runBaseline();
2354
- } else {
2355
- await runBaseline();
2356
- await runBenches();
2357
- }
2358
- }
2359
- /** Merge batch results into final ReportGroup */
2360
- function mergeBatchResults(name, benchmarks, baseline, baselineBatches, benchmarkBatches, metadata) {
2361
- const mergedBaseline = baseline ? {
2362
- name: baseline.name,
2363
- measuredResults: mergeResults(baselineBatches),
2364
- metadata
2365
- } : void 0;
2366
- return {
2367
- name,
2368
- reports: benchmarks.map((b) => ({
2369
- name: b.name,
2370
- measuredResults: mergeResults(benchmarkBatches.get(b.name) || []),
2371
- metadata
2372
- })),
2373
- baseline: mergedBaseline
2374
- };
2375
- }
2376
- /** Run single benchmark and create report */
2377
- async function runSingleBenchmark(spec, runParams) {
2378
- const { runner, options, useWorker, params, metadata } = runParams;
2379
- const [result] = await runBenchmark({
2380
- spec,
2381
- runner,
2382
- options,
2383
- useWorker,
2384
- params
2385
- });
2386
- return {
2387
- name: spec.name,
2388
- measuredResults: result,
2389
- metadata
2390
- };
2391
- }
2392
- /** Warn if parameterized benchmarks lack setup */
2393
- function validateBenchmarkParameters(group) {
2394
- const { name, setup, benchmarks, baseline } = group;
2395
- if (setup) return;
2396
- const allBenchmarks = baseline ? [...benchmarks, baseline] : benchmarks;
2397
- for (const benchmark of allBenchmarks) if (benchmark.fn.length > 0) console.warn(`Benchmark "${benchmark.name}" in group "${name}" expects parameters but no setup() provided.`);
2398
- }
2399
- /** Merge multiple batch results into a single MeasuredResults */
2400
- function mergeResults(results) {
2401
- if (results.length === 0) throw new Error("Cannot merge empty results array");
2402
- if (results.length === 1) return results[0];
2403
- const allSamples = results.flatMap((r) => r.samples);
2404
- const allWarmup = results.flatMap((r) => r.warmupSamples || []);
2405
- const time = computeStats(allSamples);
2406
- let offset = 0;
2407
- const allPausePoints = results.flatMap((r) => {
2408
- const pts = (r.pausePoints ?? []).map((p) => ({
2409
- sampleIndex: p.sampleIndex + offset,
2410
- durationMs: p.durationMs
2411
- }));
2412
- offset += r.samples.length;
2413
- return pts;
2414
- });
2415
- return {
2416
- name: results[0].name,
2417
- samples: allSamples,
2418
- warmupSamples: allWarmup.length ? allWarmup : void 0,
2419
- time,
2420
- totalTime: results.reduce((sum, r) => sum + (r.totalTime || 0), 0),
2421
- pausePoints: allPausePoints.length ? allPausePoints : void 0
2422
- };
2423
- }
2424
- function appendToMap(map, key, value) {
2425
- if (!map.has(key)) map.set(key, []);
2426
- map.get(key).push(value);
2427
- }
2428
2506
  /** Generate table with standard sections */
2429
2507
  function defaultReport(groups, args) {
2430
2508
  const { adaptive, "gc-stats": gcStats, "trace-opt": traceOpt } = args;
@@ -2432,15 +2510,6 @@ function defaultReport(groups, args) {
2432
2510
  const hasOpt = hasField(groups, "optStatus");
2433
2511
  return reportResults(groups, buildReportSections(adaptive, gcStats, hasCpu, traceOpt && hasOpt));
2434
2512
  }
2435
- /** Build report sections based on CLI options */
2436
- function buildReportSections(adaptive, gcStats, hasCpuData, hasOptData) {
2437
- const sections = adaptive ? [adaptiveSection, totalTimeSection] : [timeSection];
2438
- if (gcStats) sections.push(gcStatsSection);
2439
- if (hasCpuData) sections.push(cpuSection);
2440
- if (hasOptData) sections.push(optSection);
2441
- sections.push(runsSection);
2442
- return sections;
2443
- }
2444
2513
  /** Run benchmarks, display table, and optionally generate HTML report */
2445
2514
  async function benchExports(suite, args) {
2446
2515
  const results = await runBenchmarks(suite, args);
@@ -2453,7 +2522,7 @@ async function browserBenchExports(args) {
2453
2522
  warnBrowserFlags(args);
2454
2523
  let profileBrowser;
2455
2524
  try {
2456
- ({profileBrowser} = await import("./BrowserHeapSampler-DCeL42RE.mjs"));
2525
+ ({profileBrowser} = await import("./BrowserHeapSampler-B6asLKWQ.mjs"));
2457
2526
  } catch {
2458
2527
  throw new Error("playwright is required for browser benchmarking (--url).\n\nQuick start: npx benchforge-browser --url <your-url>\n\nOr install manually:\n npm install playwright\n npx playwright install chromium");
2459
2528
  }
@@ -2461,7 +2530,7 @@ async function browserBenchExports(args) {
2461
2530
  const { iterations, time } = args;
2462
2531
  const result = await profileBrowser({
2463
2532
  url,
2464
- heapSample: args["heap-sample"],
2533
+ heapSample: needsHeapSample(args),
2465
2534
  heapOptions: {
2466
2535
  samplingInterval: args["heap-interval"],
2467
2536
  stackDepth: args["heap-depth"]
@@ -2480,82 +2549,29 @@ async function browserBenchExports(args) {
2480
2549
  args
2481
2550
  });
2482
2551
  }
2483
- /** Print browser benchmark tables and heap reports */
2484
- function printBrowserReport(result, results, args) {
2485
- const hasSamples = result.samples && result.samples.length > 0;
2486
- const sections = [];
2487
- if (hasSamples || result.wallTimeMs != null) sections.push(timeSection);
2488
- if (result.gcStats) sections.push(browserGcStatsSection);
2489
- if (hasSamples || result.wallTimeMs != null) sections.push(runsSection);
2490
- if (sections.length > 0) console.log(reportResults(results, sections));
2491
- if (result.heapProfile) printHeapReports(results, {
2492
- ...cliHeapReportOptions(args),
2493
- isUserCode: isBrowserUserCode
2494
- });
2495
- }
2496
- /** Wrap browser profile result as ReportGroup[] for the standard pipeline */
2497
- function browserResultGroups(name, result) {
2498
- const { gcStats, heapProfile } = result;
2499
- let measured;
2500
- if (result.samples && result.samples.length > 0) {
2501
- const { samples } = result;
2502
- const totalTime = result.wallTimeMs ? result.wallTimeMs / 1e3 : void 0;
2503
- measured = {
2504
- name,
2505
- samples,
2506
- time: computeStats(samples),
2507
- totalTime,
2508
- gcStats,
2509
- heapProfile
2510
- };
2511
- } else {
2512
- const wallMs = result.wallTimeMs ?? 0;
2513
- measured = {
2514
- name,
2515
- samples: [wallMs],
2516
- time: {
2517
- min: wallMs,
2518
- max: wallMs,
2519
- avg: wallMs,
2520
- p50: wallMs,
2521
- p75: wallMs,
2522
- p99: wallMs,
2523
- p999: wallMs
2524
- },
2525
- gcStats,
2526
- heapProfile
2527
- };
2528
- }
2529
- return [{
2530
- name,
2531
- reports: [{
2532
- name,
2533
- measuredResults: measured
2534
- }]
2535
- }];
2536
- }
2537
2552
  /** Print heap allocation reports for benchmarks with heap profiles */
2538
2553
  function printHeapReports(groups, options) {
2539
- for (const group of groups) {
2540
- const allReports = group.baseline ? [...group.reports, group.baseline] : group.reports;
2541
- for (const report of allReports) {
2542
- const { heapProfile } = report.measuredResults;
2543
- if (!heapProfile) continue;
2544
- console.log(dim(`\n─── Heap profile: ${report.name} ───`));
2545
- const totalAll = totalProfileBytes(heapProfile);
2546
- const sites = flattenProfile(heapProfile);
2547
- const userSites = filterSites(sites, options.isUserCode);
2548
- const totalUserCode = userSites.reduce((sum, s) => sum + s.bytes, 0);
2549
- const aggregated = aggregateSites(options.userOnly ? userSites : sites);
2550
- const extra = {
2551
- totalAll,
2552
- totalUserCode,
2553
- sampleCount: heapProfile.samples?.length
2554
- };
2555
- console.log(formatHeapReport(aggregated, {
2556
- ...options,
2557
- ...extra
2558
- }));
2554
+ for (const group of groups) for (const report of groupReports(group)) {
2555
+ const { heapProfile } = report.measuredResults;
2556
+ if (!heapProfile) continue;
2557
+ console.log(dim(`\n─── Heap profile: ${report.name} ───`));
2558
+ const resolved = resolveProfile(heapProfile);
2559
+ const sites = flattenProfile(resolved);
2560
+ const userSites = filterSites(sites, options.isUserCode);
2561
+ const totalUserCode = userSites.reduce((sum, s) => sum + s.bytes, 0);
2562
+ const aggregated = aggregateSites(options.userOnly ? userSites : sites);
2563
+ const extra = {
2564
+ totalAll: resolved.totalBytes,
2565
+ totalUserCode,
2566
+ sampleCount: resolved.sortedSamples?.length
2567
+ };
2568
+ console.log(formatHeapReport(aggregated, {
2569
+ ...options,
2570
+ ...extra
2571
+ }));
2572
+ if (options.raw) {
2573
+ console.log(dim(`\n─── Raw samples: ${report.name} ───`));
2574
+ console.log(formatRawSamples(resolved));
2559
2575
  }
2560
2576
  }
2561
2577
  }
@@ -2567,24 +2583,6 @@ async function runDefaultBench(suite, configureArgs) {
2567
2583
  else if (args.file) await fileBenchExports(args.file, args);
2568
2584
  else throw new Error("Provide a benchmark file, --url for browser mode, or pass a BenchSuite directly.");
2569
2585
  }
2570
- /** Import a file and run it as a benchmark based on what it exports */
2571
- async function fileBenchExports(filePath, args) {
2572
- const candidate = (await import(pathToFileURL(resolve(filePath)).href)).default;
2573
- if (candidate && Array.isArray(candidate.groups)) await benchExports(candidate, args);
2574
- else if (typeof candidate === "function") {
2575
- const name = basename(filePath).replace(/\.[^.]+$/, "");
2576
- await benchExports({
2577
- name,
2578
- groups: [{
2579
- name,
2580
- benchmarks: [{
2581
- name,
2582
- fn: candidate
2583
- }]
2584
- }]
2585
- }, args);
2586
- }
2587
- }
2588
2586
  /** Convert CLI args to runner options */
2589
2587
  function cliToRunnerOptions(args) {
2590
2588
  const { profile, collect, iterations } = args;
@@ -2600,47 +2598,10 @@ function cliToRunnerOptions(args) {
2600
2598
  ...cliCommonOptions(args)
2601
2599
  };
2602
2600
  }
2603
- /** Create options for adaptive mode */
2604
- function createAdaptiveOptions(args) {
2605
- return {
2606
- minTime: (args["min-time"] ?? 1) * 1e3,
2607
- maxTime: defaultAdaptiveMaxTime * 1e3,
2608
- targetConfidence: args.convergence,
2609
- adaptive: true,
2610
- ...cliCommonOptions(args)
2611
- };
2612
- }
2613
- /** Runner/matrix options shared across all CLI modes */
2614
- function cliCommonOptions(args) {
2615
- const { collect, cpu, warmup } = args;
2616
- const { "trace-opt": traceOpt, "skip-settle": noSettle } = args;
2617
- const { "pause-first": pauseFirst, "pause-interval": pauseInterval } = args;
2618
- const { "pause-duration": pauseDuration, "gc-stats": gcStats } = args;
2619
- const { "heap-sample": heapSample, "heap-interval": heapInterval } = args;
2620
- const { "heap-depth": heapDepth } = args;
2621
- return {
2622
- collect,
2623
- cpuCounters: cpu,
2624
- warmup,
2625
- traceOpt,
2626
- noSettle,
2627
- pauseFirst,
2628
- pauseInterval,
2629
- pauseDuration,
2630
- gcStats,
2631
- heapSample,
2632
- heapInterval,
2633
- heapDepth
2634
- };
2635
- }
2636
- const { yellow, dim } = process.env.NODE_ENV === "test" || process.env.VITEST === "true" ? {
2637
- yellow: (s) => s,
2638
- dim: (s) => s
2639
- } : pico;
2640
2601
  /** Log V8 optimization tier distribution and deoptimizations */
2641
2602
  function reportOptStatus(groups) {
2642
- const optData = groups.flatMap(({ reports, baseline }) => {
2643
- return (baseline ? [...reports, baseline] : reports).filter((r) => r.measuredResults.optStatus).map((r) => ({
2603
+ const optData = groups.flatMap((group) => {
2604
+ return groupReports(group).filter((r) => r.measuredResults.optStatus).map((r) => ({
2644
2605
  name: r.name,
2645
2606
  opt: r.measuredResults.optStatus,
2646
2607
  samples: r.measuredResults.samples.length
@@ -2658,19 +2619,7 @@ function reportOptStatus(groups) {
2658
2619
  }
2659
2620
  /** @return true if any result has the specified field with a defined value */
2660
2621
  function hasField(results, field) {
2661
- return results.some(({ reports, baseline }) => {
2662
- return (baseline ? [...reports, baseline] : reports).some(({ measuredResults }) => measuredResults[field] !== void 0);
2663
- });
2664
- }
2665
- /** Print heap reports (if enabled) and export results */
2666
- async function finishReports(results, args, suiteName, exportOptions) {
2667
- if (args["heap-sample"]) printHeapReports(results, cliHeapReportOptions(args));
2668
- await exportReports({
2669
- results,
2670
- args,
2671
- suiteName,
2672
- ...exportOptions
2673
- });
2622
+ return results.some((group) => groupReports(group).some(({ measuredResults }) => measuredResults[field] !== void 0));
2674
2623
  }
2675
2624
  /** Export reports (HTML, JSON, Perfetto) based on CLI args */
2676
2625
  async function exportReports(options) {
@@ -2688,22 +2637,14 @@ async function exportReports(options) {
2688
2637
  outputPath: args["export-html"]
2689
2638
  })).closeServer;
2690
2639
  if (args.json) await exportBenchmarkJson(results, args.json, args, suiteName);
2691
- if (args.perfetto) exportPerfettoTrace(results, args.perfetto, args);
2640
+ if (args["export-perfetto"]) exportPerfettoTrace(results, args["export-perfetto"], args);
2641
+ if (args["export-speedscope"]) exportSpeedscope(results, args["export-speedscope"]);
2642
+ if (args.speedscope) exportAndLaunchSpeedscope(results);
2692
2643
  if (openInBrowser) {
2693
2644
  await waitForCtrlC();
2694
2645
  closeServer?.();
2695
2646
  }
2696
2647
  }
2697
- /** Wait for Ctrl+C before exiting */
2698
- function waitForCtrlC() {
2699
- return new Promise((resolve) => {
2700
- console.log(dim("\nPress Ctrl+C to exit"));
2701
- process.on("SIGINT", () => {
2702
- console.log();
2703
- resolve();
2704
- });
2705
- });
2706
- }
2707
2648
  /** Run matrix suite with CLI arguments.
2708
2649
  * no options ==> defaultCases/defaultVariants, --filter ==> subset of defaults,
2709
2650
  * --all --filter ==> subset of all, --all ==> all cases/variants */
@@ -2745,24 +2686,6 @@ function defaultMatrixReport(results, reportOptions, args) {
2745
2686
  const options = args ? mergeMatrixDefaults(reportOptions, args, results) : reportOptions;
2746
2687
  return results.map((r) => reportMatrixResults(r, options)).join("\n\n");
2747
2688
  }
2748
- /** @return HeapReportOptions from CLI args */
2749
- function cliHeapReportOptions(args) {
2750
- return {
2751
- topN: args["heap-rows"],
2752
- stackDepth: args["heap-stack"],
2753
- verbose: args["heap-verbose"],
2754
- userOnly: args["heap-user-only"]
2755
- };
2756
- }
2757
- /** Apply default sections and extra columns for matrix reports */
2758
- function mergeMatrixDefaults(reportOptions, args, results) {
2759
- const result = { ...reportOptions };
2760
- if (!result.sections?.length) {
2761
- const groups = matrixToReportGroups(results);
2762
- result.sections = buildReportSections(args.adaptive, args["gc-stats"], hasField(groups, "cpu"), args["trace-opt"] && hasField(groups, "optStatus"));
2763
- }
2764
- return result;
2765
- }
2766
2689
  /** Run matrix suite with full CLI handling (parse, run, report, export) */
2767
2690
  async function runDefaultMatrixBench(suite, configureArgs, reportOptions) {
2768
2691
  await matrixBenchExports(suite, parseBenchArgs(configureArgs), reportOptions);
@@ -2788,6 +2711,58 @@ function matrixToReportGroups(results) {
2788
2711
  };
2789
2712
  })));
2790
2713
  }
2714
+ /** Run matrix benchmarks, display table, and generate exports */
2715
+ async function matrixBenchExports(suite, args, reportOptions, exportOptions) {
2716
+ const results = await runMatrixSuite(suite, args);
2717
+ const report = defaultMatrixReport(results, reportOptions, args);
2718
+ console.log(report);
2719
+ await finishReports(matrixToReportGroups(results), args, suite.name, exportOptions);
2720
+ }
2721
+ /** Validate CLI argument combinations */
2722
+ function validateArgs(args) {
2723
+ if (args["gc-stats"] && !args.worker && !args.url) throw new Error("--gc-stats requires worker mode (the default). Remove --no-worker flag.");
2724
+ }
2725
+ /** Execute all groups in suite */
2726
+ async function runSuite(params) {
2727
+ const { suite, runner, options, useWorker, batches } = params;
2728
+ const results = [];
2729
+ for (const group of suite.groups) results.push(await runGroup(group, runner, options, useWorker, batches));
2730
+ return results;
2731
+ }
2732
+ /** Build report sections based on CLI options */
2733
+ function buildReportSections(adaptive, gcStats, hasCpuData, hasOptData) {
2734
+ const sections = adaptive ? [adaptiveSection, totalTimeSection] : [timeSection];
2735
+ if (gcStats) sections.push(gcStatsSection);
2736
+ if (hasCpuData) sections.push(cpuSection);
2737
+ if (hasOptData) sections.push(optSection);
2738
+ sections.push(runsSection);
2739
+ return sections;
2740
+ }
2741
+ /** Print heap reports (if enabled) and export results */
2742
+ async function finishReports(results, args, suiteName, exportOptions) {
2743
+ if (needsHeapSample(args)) printHeapReports(results, cliHeapReportOptions(args));
2744
+ await exportReports({
2745
+ results,
2746
+ args,
2747
+ suiteName,
2748
+ ...exportOptions
2749
+ });
2750
+ }
2751
+ /** Warn about Node-only flags that are ignored in browser mode. */
2752
+ function warnBrowserFlags(args) {
2753
+ const ignored = [];
2754
+ if (!args.worker) ignored.push("--no-worker");
2755
+ if (args.cpu) ignored.push("--cpu");
2756
+ if (args["trace-opt"]) ignored.push("--trace-opt");
2757
+ if (args.collect) ignored.push("--collect");
2758
+ if (args.adaptive) ignored.push("--adaptive");
2759
+ if (args.batches > 1) ignored.push("--batches");
2760
+ if (ignored.length) console.warn(yellow(`Ignored in browser mode: ${ignored.join(", ")}`));
2761
+ }
2762
+ /** @return true if any heap-related flag implies heap sampling */
2763
+ function needsHeapSample(args) {
2764
+ return args["heap-sample"] || args.speedscope || !!args["export-speedscope"] || args["heap-raw"] || args["heap-verbose"] || args["heap-user-only"];
2765
+ }
2791
2766
  /** Strip surrounding quotes from a chrome arg token.
2792
2767
  *
2793
2768
  * (Needed because --chrome-args values pass through yargs and spawn() without
@@ -2796,18 +2771,278 @@ function matrixToReportGroups(results) {
2796
2771
  function stripQuotes(s) {
2797
2772
  return s.replace(/^(['"])(.*)\1$/s, "$2").replace(/^(-[^=]+=)(['"])(.*)\2$/s, "$1$3");
2798
2773
  }
2774
+ /** Wrap browser profile result as ReportGroup[] for the standard pipeline */
2775
+ function browserResultGroups(name, result) {
2776
+ const { gcStats, heapProfile } = result;
2777
+ let measured;
2778
+ if (result.samples && result.samples.length > 0) {
2779
+ const { samples } = result;
2780
+ const totalTime = result.wallTimeMs ? result.wallTimeMs / 1e3 : void 0;
2781
+ measured = {
2782
+ name,
2783
+ samples,
2784
+ time: computeStats(samples),
2785
+ totalTime,
2786
+ gcStats,
2787
+ heapProfile
2788
+ };
2789
+ } else {
2790
+ const wallMs = result.wallTimeMs ?? 0;
2791
+ measured = {
2792
+ name,
2793
+ samples: [wallMs],
2794
+ time: {
2795
+ min: wallMs,
2796
+ max: wallMs,
2797
+ avg: wallMs,
2798
+ p50: wallMs,
2799
+ p75: wallMs,
2800
+ p99: wallMs,
2801
+ p999: wallMs
2802
+ },
2803
+ gcStats,
2804
+ heapProfile
2805
+ };
2806
+ }
2807
+ return [{
2808
+ name,
2809
+ reports: [{
2810
+ name,
2811
+ measuredResults: measured
2812
+ }]
2813
+ }];
2814
+ }
2815
+ /** Print browser benchmark tables and heap reports */
2816
+ function printBrowserReport(result, results, args) {
2817
+ const hasSamples = result.samples && result.samples.length > 0;
2818
+ const sections = [];
2819
+ if (hasSamples || result.wallTimeMs != null) sections.push(timeSection);
2820
+ if (result.gcStats) sections.push(browserGcStatsSection);
2821
+ if (hasSamples || result.wallTimeMs != null) sections.push(runsSection);
2822
+ if (sections.length > 0) console.log(reportResults(results, sections));
2823
+ if (result.heapProfile) printHeapReports(results, {
2824
+ ...cliHeapReportOptions(args),
2825
+ isUserCode: isBrowserUserCode
2826
+ });
2827
+ }
2828
+ /** Import a file and run it as a benchmark based on what it exports */
2829
+ async function fileBenchExports(filePath, args) {
2830
+ const candidate = (await import(pathToFileURL(resolve(filePath)).href)).default;
2831
+ if (candidate && Array.isArray(candidate.matrices)) await matrixBenchExports(candidate, args);
2832
+ else if (candidate && Array.isArray(candidate.groups)) await benchExports(candidate, args);
2833
+ else if (typeof candidate === "function") {
2834
+ const name = basename(filePath).replace(/\.[^.]+$/, "");
2835
+ await benchExports({
2836
+ name,
2837
+ groups: [{
2838
+ name,
2839
+ benchmarks: [{
2840
+ name,
2841
+ fn: candidate
2842
+ }]
2843
+ }]
2844
+ }, args);
2845
+ }
2846
+ }
2847
+ /** Create options for adaptive mode */
2848
+ function createAdaptiveOptions(args) {
2849
+ return {
2850
+ minTime: (args["min-time"] ?? 1) * 1e3,
2851
+ maxTime: defaultAdaptiveMaxTime * 1e3,
2852
+ targetConfidence: args.convergence,
2853
+ adaptive: true,
2854
+ ...cliCommonOptions(args)
2855
+ };
2856
+ }
2857
+ /** Runner/matrix options shared across all CLI modes */
2858
+ function cliCommonOptions(args) {
2859
+ const { collect, cpu, warmup } = args;
2860
+ const { "trace-opt": traceOpt, "skip-settle": noSettle } = args;
2861
+ const { "pause-first": pauseFirst, "pause-interval": pauseInterval } = args;
2862
+ const { "pause-duration": pauseDuration, "gc-stats": gcStats } = args;
2863
+ const heapSample = needsHeapSample(args);
2864
+ const { "heap-interval": heapInterval } = args;
2865
+ const { "heap-depth": heapDepth } = args;
2866
+ return {
2867
+ collect,
2868
+ cpuCounters: cpu,
2869
+ warmup,
2870
+ traceOpt,
2871
+ noSettle,
2872
+ pauseFirst,
2873
+ pauseInterval,
2874
+ pauseDuration,
2875
+ gcStats,
2876
+ heapSample,
2877
+ heapInterval,
2878
+ heapDepth
2879
+ };
2880
+ }
2881
+ /** Wait for Ctrl+C before exiting */
2882
+ function waitForCtrlC() {
2883
+ return new Promise((resolve) => {
2884
+ console.log(dim("\nPress Ctrl+C to exit"));
2885
+ process.on("SIGINT", () => {
2886
+ console.log();
2887
+ resolve();
2888
+ });
2889
+ });
2890
+ }
2891
+ /** Apply default sections and extra columns for matrix reports */
2892
+ function mergeMatrixDefaults(reportOptions, args, results) {
2893
+ const result = { ...reportOptions };
2894
+ if (!result.sections?.length) {
2895
+ const groups = matrixToReportGroups(results);
2896
+ result.sections = buildReportSections(args.adaptive, args["gc-stats"], hasField(groups, "cpu"), args["trace-opt"] && hasField(groups, "optStatus"));
2897
+ }
2898
+ return result;
2899
+ }
2900
+ /** Execute group with shared setup, optionally batching to reduce ordering bias */
2901
+ async function runGroup(group, runner, options, useWorker, batches = 1) {
2902
+ const { name, benchmarks, baseline, setup, metadata } = group;
2903
+ const setupParams = await setup?.();
2904
+ validateBenchmarkParameters(group);
2905
+ const runParams = {
2906
+ runner,
2907
+ options,
2908
+ useWorker,
2909
+ params: setupParams,
2910
+ metadata
2911
+ };
2912
+ if (batches === 1) return runSingleBatch(name, benchmarks, baseline, runParams);
2913
+ return runMultipleBatches(name, benchmarks, baseline, runParams, batches);
2914
+ }
2915
+ /** @return HeapReportOptions from CLI args */
2916
+ function cliHeapReportOptions(args) {
2917
+ return {
2918
+ topN: args["heap-rows"],
2919
+ stackDepth: args["heap-stack"],
2920
+ verbose: args["heap-verbose"],
2921
+ raw: args["heap-raw"],
2922
+ userOnly: args["heap-user-only"]
2923
+ };
2924
+ }
2925
+ /** Warn if parameterized benchmarks lack setup */
2926
+ function validateBenchmarkParameters(group) {
2927
+ const { name, setup, benchmarks, baseline } = group;
2928
+ if (setup) return;
2929
+ const allBenchmarks = baseline ? [...benchmarks, baseline] : benchmarks;
2930
+ for (const benchmark of allBenchmarks) if (benchmark.fn.length > 0) console.warn(`Benchmark "${benchmark.name}" in group "${name}" expects parameters but no setup() provided.`);
2931
+ }
2932
+ /** Run benchmarks in a single batch */
2933
+ async function runSingleBatch(name, benchmarks, baseline, runParams) {
2934
+ const baselineReport = baseline ? await runSingleBenchmark(baseline, runParams) : void 0;
2935
+ return {
2936
+ name,
2937
+ reports: await serialMap(benchmarks, (b) => runSingleBenchmark(b, runParams)),
2938
+ baseline: baselineReport
2939
+ };
2940
+ }
2941
+ /** Run benchmarks in multiple batches, alternating order to reduce bias */
2942
+ async function runMultipleBatches(name, benchmarks, baseline, runParams, batches) {
2943
+ const timePerBatch = (runParams.options.maxTime || 5e3) / batches;
2944
+ const batchParams = {
2945
+ ...runParams,
2946
+ options: {
2947
+ ...runParams.options,
2948
+ maxTime: timePerBatch
2949
+ }
2950
+ };
2951
+ const baselineBatches = [];
2952
+ const benchmarkBatches = /* @__PURE__ */ new Map();
2953
+ for (let i = 0; i < batches; i++) await runBatchIteration(benchmarks, baseline, batchParams, i % 2 === 1, baselineBatches, benchmarkBatches);
2954
+ const meta = runParams.metadata;
2955
+ return mergeBatchResults(name, benchmarks, baseline, baselineBatches, benchmarkBatches, meta);
2956
+ }
2957
+ /** Run single benchmark and create report */
2958
+ async function runSingleBenchmark(spec, runParams) {
2959
+ const { runner, options, useWorker, params, metadata } = runParams;
2960
+ const [result] = await runBenchmark({
2961
+ spec,
2962
+ runner,
2963
+ options,
2964
+ useWorker,
2965
+ params
2966
+ });
2967
+ return {
2968
+ name: spec.name,
2969
+ measuredResults: result,
2970
+ metadata
2971
+ };
2972
+ }
2799
2973
  /** Sequential map - like Promise.all(arr.map(fn)) but runs one at a time */
2800
2974
  async function serialMap(arr, fn) {
2801
2975
  const results = [];
2802
2976
  for (const item of arr) results.push(await fn(item));
2803
2977
  return results;
2804
2978
  }
2805
- /** Run matrix benchmarks, display table, and generate exports */
2806
- async function matrixBenchExports(suite, args, reportOptions, exportOptions) {
2807
- const results = await runMatrixSuite(suite, args);
2808
- const report = defaultMatrixReport(results, reportOptions, args);
2809
- console.log(report);
2810
- await finishReports(matrixToReportGroups(results), args, suite.name, exportOptions);
2979
+ /** Run one batch iteration in either order */
2980
+ async function runBatchIteration(benchmarks, baseline, runParams, reverseOrder, baselineBatches, benchmarkBatches) {
2981
+ const runBaseline = async () => {
2982
+ if (baseline) {
2983
+ const r = await runSingleBenchmark(baseline, runParams);
2984
+ baselineBatches.push(r.measuredResults);
2985
+ }
2986
+ };
2987
+ const runBenches = async () => {
2988
+ for (const b of benchmarks) {
2989
+ const r = await runSingleBenchmark(b, runParams);
2990
+ appendToMap(benchmarkBatches, b.name, r.measuredResults);
2991
+ }
2992
+ };
2993
+ if (reverseOrder) {
2994
+ await runBenches();
2995
+ await runBaseline();
2996
+ } else {
2997
+ await runBaseline();
2998
+ await runBenches();
2999
+ }
3000
+ }
3001
+ /** Merge batch results into final ReportGroup */
3002
+ function mergeBatchResults(name, benchmarks, baseline, baselineBatches, benchmarkBatches, metadata) {
3003
+ const mergedBaseline = baseline ? {
3004
+ name: baseline.name,
3005
+ measuredResults: mergeResults(baselineBatches),
3006
+ metadata
3007
+ } : void 0;
3008
+ return {
3009
+ name,
3010
+ reports: benchmarks.map((b) => ({
3011
+ name: b.name,
3012
+ measuredResults: mergeResults(benchmarkBatches.get(b.name) || []),
3013
+ metadata
3014
+ })),
3015
+ baseline: mergedBaseline
3016
+ };
3017
+ }
3018
+ function appendToMap(map, key, value) {
3019
+ if (!map.has(key)) map.set(key, []);
3020
+ map.get(key).push(value);
3021
+ }
3022
+ /** Merge multiple batch results into a single MeasuredResults */
3023
+ function mergeResults(results) {
3024
+ if (results.length === 0) throw new Error("Cannot merge empty results array");
3025
+ if (results.length === 1) return results[0];
3026
+ const allSamples = results.flatMap((r) => r.samples);
3027
+ const allWarmup = results.flatMap((r) => r.warmupSamples || []);
3028
+ const time = computeStats(allSamples);
3029
+ let offset = 0;
3030
+ const allPausePoints = results.flatMap((r) => {
3031
+ const pts = (r.pausePoints ?? []).map((p) => ({
3032
+ sampleIndex: p.sampleIndex + offset,
3033
+ durationMs: p.durationMs
3034
+ }));
3035
+ offset += r.samples.length;
3036
+ return pts;
3037
+ });
3038
+ return {
3039
+ name: results[0].name,
3040
+ samples: allSamples,
3041
+ warmupSamples: allWarmup.length ? allWarmup : void 0,
3042
+ time,
3043
+ totalTime: results.reduce((sum, r) => sum + (r.totalTime || 0), 0),
3044
+ pausePoints: allPausePoints.length ? allPausePoints : void 0
3045
+ };
2811
3046
  }
2812
3047
 
2813
3048
  //#endregion
@@ -2869,5 +3104,5 @@ function getMostRecentModifiedDate(dir) {
2869
3104
  }
2870
3105
 
2871
3106
  //#endregion
2872
- export { timeSection as A, parseCliArgs as B, adaptiveSection as C, gcStatsSection as D, gcSection as E, generateHtmlReport as F, truncate as G, formatBytes as H, formatDateWithTimezone as I, loadCaseData as J, isStatefulVariant as K, prepareHtmlData as L, formatConvergence as M, filterMatrix as N, optSection as O, parseMatrixFilter as P, exportPerfettoTrace as R, reportMatrixResults as S, cpuSection as T, integer as U, reportResults as V, timeMs as W, loadCasesModule as Y, runDefaultMatrixBench as _, cliToMatrixOptions as a, gcStatsColumns as b, exportReports as c, matrixToReportGroups as d, parseBenchArgs as f, runDefaultBench as g, runBenchmarks as h, benchExports as i, totalTimeSection as j, runsSection as k, hasField as l, reportOptStatus as m, getBaselineVersion as n, defaultMatrixReport as o, printHeapReports as p, runMatrix as q, getCurrentGitVersion as r, defaultReport as s, formatGitVersion as t, matrixBenchExports as u, runMatrixSuite as v, buildGenericSections as w, heapTotalColumn as x, gcPauseColumn as y, defaultCliArgs as z };
2873
- //# sourceMappingURL=src-Cf_LXwlp.mjs.map
3107
+ export { loadCasesModule as $, timeSection as A, heapProfileToSpeedscope as B, adaptiveSection as C, gcStatsSection as D, gcSection as E, generateHtmlReport as F, reportResults as G, exportPerfettoTrace as H, formatDateWithTimezone as I, timeMs as J, formatBytes as K, prepareHtmlData as L, formatConvergence as M, filterMatrix as N, optSection as O, parseMatrixFilter as P, loadCaseData as Q, exportAndLaunchSpeedscope as R, reportMatrixResults as S, cpuSection as T, defaultCliArgs as U, launchSpeedscope as V, parseCliArgs as W, isStatefulVariant as X, truncate as Y, runMatrix as Z, runDefaultMatrixBench as _, cliToMatrixOptions as a, gcStatsColumns as b, exportReports as c, matrixToReportGroups as d, parseBenchArgs as f, runDefaultBench as g, runBenchmarks as h, benchExports as i, totalTimeSection as j, runsSection as k, hasField as l, reportOptStatus as m, getBaselineVersion as n, defaultMatrixReport as o, printHeapReports as p, integer as q, getCurrentGitVersion as r, defaultReport as s, formatGitVersion as t, matrixBenchExports as u, runMatrixSuite as v, buildGenericSections as w, heapTotalColumn as x, gcPauseColumn as y, exportSpeedscope as z };
3108
+ //# sourceMappingURL=src-B-DDaCa9.mjs.map