benchforge 0.1.8 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +69 -42
  2. package/dist/{BenchRunner-CSKN9zPy.d.mts → BenchRunner-BzyUfiyB.d.mts} +32 -8
  3. package/dist/{BrowserHeapSampler-DCeL42RE.mjs → BrowserHeapSampler-B6asLKWQ.mjs} +57 -57
  4. package/dist/BrowserHeapSampler-B6asLKWQ.mjs.map +1 -0
  5. package/dist/{GcStats-ByEovUi1.mjs → GcStats-wX7Xyblu.mjs} +15 -15
  6. package/dist/GcStats-wX7Xyblu.mjs.map +1 -0
  7. package/dist/HeapSampler-B8dtKHn1.mjs.map +1 -1
  8. package/dist/{TimingUtils-ClclVQ7E.mjs → TimingUtils-DwOwkc8G.mjs} +225 -225
  9. package/dist/TimingUtils-DwOwkc8G.mjs.map +1 -0
  10. package/dist/bin/benchforge.mjs +1 -1
  11. package/dist/browser/index.js +210 -210
  12. package/dist/index.d.mts +106 -48
  13. package/dist/index.mjs +3 -3
  14. package/dist/runners/WorkerScript.d.mts +1 -1
  15. package/dist/runners/WorkerScript.mjs +66 -66
  16. package/dist/runners/WorkerScript.mjs.map +1 -1
  17. package/dist/{src-HfimYuW_.mjs → src-B-DDaCa9.mjs} +1250 -991
  18. package/dist/src-B-DDaCa9.mjs.map +1 -0
  19. package/package.json +4 -3
  20. package/src/BenchMatrix.ts +125 -125
  21. package/src/BenchmarkReport.ts +50 -45
  22. package/src/HtmlDataPrep.ts +21 -21
  23. package/src/PermutationTest.ts +24 -24
  24. package/src/StandardSections.ts +45 -45
  25. package/src/StatisticalUtils.ts +60 -61
  26. package/src/browser/BrowserGcStats.ts +5 -5
  27. package/src/browser/BrowserHeapSampler.ts +63 -63
  28. package/src/cli/CliArgs.ts +20 -6
  29. package/src/cli/FilterBenchmarks.ts +5 -5
  30. package/src/cli/RunBenchCLI.ts +533 -476
  31. package/src/export/JsonExport.ts +10 -10
  32. package/src/export/PerfettoExport.ts +74 -74
  33. package/src/export/SpeedscopeExport.ts +202 -0
  34. package/src/heap-sample/HeapSampleReport.ts +143 -70
  35. package/src/heap-sample/HeapSampler.ts +55 -12
  36. package/src/heap-sample/ResolvedProfile.ts +89 -0
  37. package/src/html/HtmlReport.ts +33 -33
  38. package/src/html/HtmlTemplate.ts +67 -67
  39. package/src/html/browser/CIPlot.ts +50 -50
  40. package/src/html/browser/HistogramKde.ts +13 -13
  41. package/src/html/browser/LegendUtils.ts +48 -48
  42. package/src/html/browser/RenderPlots.ts +98 -98
  43. package/src/html/browser/SampleTimeSeries.ts +79 -79
  44. package/src/index.ts +6 -0
  45. package/src/matrix/MatrixFilter.ts +6 -6
  46. package/src/matrix/MatrixReport.ts +96 -96
  47. package/src/matrix/VariantLoader.ts +5 -5
  48. package/src/runners/AdaptiveWrapper.ts +151 -151
  49. package/src/runners/BasicRunner.ts +175 -175
  50. package/src/runners/BenchRunner.ts +8 -8
  51. package/src/runners/GcStats.ts +22 -22
  52. package/src/runners/RunnerOrchestrator.ts +168 -168
  53. package/src/runners/WorkerScript.ts +96 -96
  54. package/src/table-util/Formatters.ts +41 -36
  55. package/src/table-util/TableReport.ts +122 -122
  56. package/src/table-util/test/TableValueExtractor.ts +9 -9
  57. package/src/test/AdaptiveStatistics.integration.ts +7 -39
  58. package/src/test/HeapAttribution.test.ts +51 -0
  59. package/src/test/RunBenchCLI.test.ts +36 -11
  60. package/src/test/TestUtils.ts +24 -24
  61. package/src/test/fixtures/fn-export-bench.ts +3 -0
  62. package/src/test/fixtures/suite-export-bench.ts +16 -0
  63. package/src/tests/BenchMatrix.test.ts +12 -12
  64. package/src/tests/MatrixFilter.test.ts +15 -15
  65. package/dist/BrowserHeapSampler-DCeL42RE.mjs.map +0 -1
  66. package/dist/GcStats-ByEovUi1.mjs.map +0 -1
  67. package/dist/TimingUtils-ClclVQ7E.mjs.map +0 -1
  68. package/dist/src-HfimYuW_.mjs.map +0 -1
@@ -47,6 +47,16 @@ function prepareJsonData(
47
47
  };
48
48
  }
49
49
 
50
+ /** Clean CLI args for JSON export (remove undefined values) */
51
+ function cleanCliArgs(args: DefaultCliArgs): Record<string, any> {
52
+ const toCamel = (k: string) =>
53
+ k.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
54
+ const entries = Object.entries(args)
55
+ .filter(([, v]) => v !== undefined && v !== null)
56
+ .map(([k, v]) => [toCamel(k), v]);
57
+ return Object.fromEntries(entries);
58
+ }
59
+
50
60
  /** Convert a report group, mapping each report to the JSON result format */
51
61
  function convertGroup(group: ReportGroup): BenchmarkGroup {
52
62
  return {
@@ -91,13 +101,3 @@ function convertReport(report: any): BenchmarkResult {
91
101
  },
92
102
  };
93
103
  }
94
-
95
- /** Clean CLI args for JSON export (remove undefined values) */
96
- function cleanCliArgs(args: DefaultCliArgs): Record<string, any> {
97
- const toCamel = (k: string) =>
98
- k.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
99
- const entries = Object.entries(args)
100
- .filter(([, v]) => v !== undefined && v !== null)
101
- .map(([k, v]) => [toCamel(k), v]);
102
- return Object.fromEntries(entries);
103
- }
@@ -73,54 +73,6 @@ function buildTraceEvents(
73
73
  return events;
74
74
  }
75
75
 
76
- function instant(
77
- ts: number,
78
- name: string,
79
- args: Record<string, unknown>,
80
- ): TraceEvent {
81
- return { ph: "i", ts, pid, tid, cat: "bench", name, s: "t", args };
82
- }
83
-
84
- function counter(
85
- ts: number,
86
- name: string,
87
- args: Record<string, unknown>,
88
- ): TraceEvent {
89
- return { ph: "C", ts, pid, tid, cat: "bench", name, args };
90
- }
91
-
92
- /** Build events for a single benchmark run */
93
- function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
94
- const { samples, heapSamples, timestamps, pausePoints } = results;
95
- if (!timestamps?.length) return [];
96
-
97
- const events: TraceEvent[] = [];
98
- for (let i = 0; i < samples.length; i++) {
99
- const ts = timestamps[i];
100
- const ms = Math.round(samples[i] * 100) / 100;
101
- events.push(instant(ts, results.name, { n: i, ms }));
102
- events.push(counter(ts, "duration", { ms }));
103
- if (heapSamples?.[i] !== undefined) {
104
- const MB = Math.round((heapSamples[i] / 1024 / 1024) * 10) / 10;
105
- events.push(counter(ts, "heap", { MB }));
106
- }
107
- }
108
-
109
- for (const pause of pausePoints ?? []) {
110
- const ts = timestamps[pause.sampleIndex];
111
- if (ts) events.push(instant(ts, "pause", { ms: pause.durationMs }));
112
- }
113
- return events;
114
- }
115
-
116
- /** Normalize timestamps so events start at 0 */
117
- function normalizeTimestamps(events: TraceEvent[]): void {
118
- const times = events.filter(e => e.ts > 0).map(e => e.ts);
119
- if (times.length === 0) return;
120
- const minTs = Math.min(...times);
121
- for (const e of events) if (e.ts > 0) e.ts -= minTs;
122
- }
123
-
124
76
  /** Merge V8 trace events from a previous run, aligning timestamps */
125
77
  function mergeV8Trace(customEvents: TraceEvent[]): TraceEvent[] {
126
78
  const traceFiles = readdirSync(".").filter(
@@ -135,38 +87,12 @@ function mergeV8Trace(customEvents: TraceEvent[]): TraceEvent[] {
135
87
  return [...v8Events, ...customEvents];
136
88
  }
137
89
 
138
- /** Load V8 trace events from file, or undefined if unavailable */
139
- function loadV8Events(
140
- v8TracePath: string | undefined,
141
- ): TraceEvent[] | undefined {
142
- if (!v8TracePath) return undefined;
143
- try {
144
- const v8Data = JSON.parse(readFileSync(v8TracePath, "utf-8")) as TraceFile;
145
- console.log(
146
- `Merged ${v8Data.traceEvents.length} V8 events from ${v8TracePath}`,
147
- );
148
- return v8Data.traceEvents;
149
- } catch {
150
- console.warn(`Could not parse V8 trace file: ${v8TracePath}`);
151
- return undefined;
152
- }
153
- }
154
-
155
90
  /** Write trace events to JSON file */
156
91
  function writeTraceFile(outputPath: string, events: TraceEvent[]): void {
157
92
  const traceFile: TraceFile = { traceEvents: events };
158
93
  writeFileSync(outputPath, JSON.stringify(traceFile));
159
94
  }
160
95
 
161
- /** Clean CLI args for metadata */
162
- function cleanArgs(args: DefaultCliArgs): Record<string, unknown> {
163
- const skip = new Set(["_", "$0"]);
164
- const entries = Object.entries(args).filter(
165
- ([k, v]) => v !== undefined && !skip.has(k),
166
- );
167
- return Object.fromEntries(entries);
168
- }
169
-
170
96
  /** Spawn a detached child to merge V8 trace after process exit */
171
97
  function scheduleDeferredMerge(outputPath: string): void {
172
98
  const cwd = process.cwd();
@@ -201,3 +127,77 @@ function scheduleDeferredMerge(outputPath: string): void {
201
127
  child.unref();
202
128
  });
203
129
  }
130
+
131
+ /** Clean CLI args for metadata */
132
+ function cleanArgs(args: DefaultCliArgs): Record<string, unknown> {
133
+ const skip = new Set(["_", "$0"]);
134
+ const entries = Object.entries(args).filter(
135
+ ([k, v]) => v !== undefined && !skip.has(k),
136
+ );
137
+ return Object.fromEntries(entries);
138
+ }
139
+
140
+ /** Build events for a single benchmark run */
141
+ function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
142
+ const { samples, heapSamples, timestamps, pausePoints } = results;
143
+ if (!timestamps?.length) return [];
144
+
145
+ const events: TraceEvent[] = [];
146
+ for (let i = 0; i < samples.length; i++) {
147
+ const ts = timestamps[i];
148
+ const ms = Math.round(samples[i] * 100) / 100;
149
+ events.push(instant(ts, results.name, { n: i, ms }));
150
+ events.push(counter(ts, "duration", { ms }));
151
+ if (heapSamples?.[i] !== undefined) {
152
+ const MB = Math.round((heapSamples[i] / 1024 / 1024) * 10) / 10;
153
+ events.push(counter(ts, "heap", { MB }));
154
+ }
155
+ }
156
+
157
+ for (const pause of pausePoints ?? []) {
158
+ const ts = timestamps[pause.sampleIndex];
159
+ if (ts) events.push(instant(ts, "pause", { ms: pause.durationMs }));
160
+ }
161
+ return events;
162
+ }
163
+
164
+ /** Load V8 trace events from file, or undefined if unavailable */
165
+ function loadV8Events(
166
+ v8TracePath: string | undefined,
167
+ ): TraceEvent[] | undefined {
168
+ if (!v8TracePath) return undefined;
169
+ try {
170
+ const v8Data = JSON.parse(readFileSync(v8TracePath, "utf-8")) as TraceFile;
171
+ console.log(
172
+ `Merged ${v8Data.traceEvents.length} V8 events from ${v8TracePath}`,
173
+ );
174
+ return v8Data.traceEvents;
175
+ } catch {
176
+ console.warn(`Could not parse V8 trace file: ${v8TracePath}`);
177
+ return undefined;
178
+ }
179
+ }
180
+
181
+ /** Normalize timestamps so events start at 0 */
182
+ function normalizeTimestamps(events: TraceEvent[]): void {
183
+ const times = events.filter(e => e.ts > 0).map(e => e.ts);
184
+ if (times.length === 0) return;
185
+ const minTs = Math.min(...times);
186
+ for (const e of events) if (e.ts > 0) e.ts -= minTs;
187
+ }
188
+
189
+ function instant(
190
+ ts: number,
191
+ name: string,
192
+ args: Record<string, unknown>,
193
+ ): TraceEvent {
194
+ return { ph: "i", ts, pid, tid, cat: "bench", name, s: "t", args };
195
+ }
196
+
197
+ function counter(
198
+ ts: number,
199
+ name: string,
200
+ args: Record<string, unknown>,
201
+ ): TraceEvent {
202
+ return { ph: "C", ts, pid, tid, cat: "bench", name, args };
203
+ }
@@ -0,0 +1,202 @@
1
+ import { spawn } from "node:child_process";
2
+ import { writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+
6
+ import { groupReports, type ReportGroup } from "../BenchmarkReport.ts";
7
+ import type { HeapProfile } from "../heap-sample/HeapSampler.ts";
8
+ import {
9
+ type ResolvedFrame,
10
+ type ResolvedProfile,
11
+ resolveProfile,
12
+ } from "../heap-sample/ResolvedProfile.ts";
13
+
14
+ /** speedscope file format (https://www.speedscope.app/file-format-schema.json) */
15
+ interface SpeedscopeFile {
16
+ $schema: "https://www.speedscope.app/file-format-schema.json";
17
+ shared: { frames: SpeedscopeFrame[] };
18
+ profiles: SpeedscopeProfile[];
19
+ name?: string;
20
+ exporter?: string;
21
+ }
22
+
23
+ interface SpeedscopeFrame {
24
+ name: string;
25
+ file?: string;
26
+ line?: number;
27
+ col?: number;
28
+ }
29
+
30
+ interface SpeedscopeProfile {
31
+ type: "sampled";
32
+ name: string;
33
+ unit: "bytes";
34
+ startValue: number;
35
+ endValue: number;
36
+ samples: number[][]; // each sample is stack of frame indices
37
+ weights: number[]; // bytes per sample
38
+ }
39
+
40
+ /** Export heap profiles from benchmark results to speedscope JSON format.
41
+ * Creates one speedscope profile per benchmark that has a heapProfile.
42
+ * @returns resolved output path, or undefined if no profiles were found */
43
+ export function exportSpeedscope(
44
+ groups: ReportGroup[],
45
+ outputPath: string,
46
+ ): string | undefined {
47
+ const frames: SpeedscopeFrame[] = [];
48
+ const frameIndex = new Map<string, number>();
49
+ const profiles: SpeedscopeProfile[] = [];
50
+
51
+ for (const group of groups) {
52
+ for (const report of groupReports(group)) {
53
+ const { heapProfile } = report.measuredResults;
54
+ if (!heapProfile) continue;
55
+ const resolved = resolveProfile(heapProfile);
56
+ profiles.push(buildProfile(report.name, resolved, frames, frameIndex));
57
+ }
58
+ }
59
+
60
+ if (profiles.length === 0) {
61
+ console.log("No heap profiles to export.");
62
+ return undefined;
63
+ }
64
+
65
+ const file: SpeedscopeFile = {
66
+ $schema: "https://www.speedscope.app/file-format-schema.json",
67
+ shared: { frames },
68
+ profiles,
69
+ exporter: "benchforge",
70
+ };
71
+
72
+ const absPath = resolve(outputPath);
73
+ writeFileSync(absPath, JSON.stringify(file));
74
+ console.log(`Speedscope profile exported to: ${outputPath}`);
75
+ return absPath;
76
+ }
77
+
78
+ /** Export to a temp file and open in speedscope via npx */
79
+ export function exportAndLaunchSpeedscope(groups: ReportGroup[]): void {
80
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
81
+ const outputPath = join(tmpdir(), `benchforge-${timestamp}.speedscope.json`);
82
+ const absPath = exportSpeedscope(groups, outputPath);
83
+ if (absPath) {
84
+ launchSpeedscope(absPath);
85
+ }
86
+ }
87
+
88
+ /** Launch speedscope viewer on a file via npx */
89
+ export function launchSpeedscope(filePath: string): void {
90
+ console.log("Opening speedscope...");
91
+ const child = spawn("npx", ["speedscope", filePath], {
92
+ detached: true,
93
+ stdio: "ignore",
94
+ });
95
+ child.unref();
96
+ child.on("error", () => {
97
+ console.error(
98
+ `Failed to launch speedscope. Run manually:\n npx speedscope ${filePath}`,
99
+ );
100
+ });
101
+ }
102
+
103
+ /** Convert a single HeapProfile to speedscope format (for standalone use) */
104
+ export function heapProfileToSpeedscope(
105
+ name: string,
106
+ profile: HeapProfile,
107
+ ): SpeedscopeFile {
108
+ const frames: SpeedscopeFrame[] = [];
109
+ const frameIndex = new Map<string, number>();
110
+ const resolved = resolveProfile(profile);
111
+ const p = buildProfile(name, resolved, frames, frameIndex);
112
+
113
+ return {
114
+ $schema: "https://www.speedscope.app/file-format-schema.json",
115
+ shared: { frames },
116
+ profiles: [p],
117
+ exporter: "benchforge",
118
+ };
119
+ }
120
+
121
+ /** Build a single speedscope profile from a resolved heap profile */
122
+ function buildProfile(
123
+ name: string,
124
+ resolved: ResolvedProfile,
125
+ sharedFrames: SpeedscopeFrame[],
126
+ frameIndex: Map<string, number>,
127
+ ): SpeedscopeProfile {
128
+ // Build nodeId -> stack of frame indices
129
+ const nodeStacks = new Map<number, number[]>();
130
+ for (const node of resolved.nodes) {
131
+ const stack = node.stack.map(f => internFrame(f, sharedFrames, frameIndex));
132
+ nodeStacks.set(node.nodeId, stack);
133
+ }
134
+
135
+ const samples: number[][] = [];
136
+ const weights: number[] = [];
137
+
138
+ if (!resolved.sortedSamples || resolved.sortedSamples.length === 0) {
139
+ console.error(
140
+ `Speedscope export: no samples in heap profile for "${name}", skipping`,
141
+ );
142
+ return {
143
+ type: "sampled",
144
+ name,
145
+ unit: "bytes",
146
+ startValue: 0,
147
+ endValue: 0,
148
+ samples,
149
+ weights,
150
+ };
151
+ }
152
+
153
+ for (const sample of resolved.sortedSamples) {
154
+ const stack = nodeStacks.get(sample.nodeId);
155
+ if (stack) {
156
+ samples.push(stack);
157
+ weights.push(sample.size);
158
+ }
159
+ }
160
+
161
+ const totalBytes = weights.reduce((sum, w) => sum + w, 0);
162
+
163
+ return {
164
+ type: "sampled",
165
+ name,
166
+ unit: "bytes",
167
+ startValue: 0,
168
+ endValue: totalBytes,
169
+ samples,
170
+ weights,
171
+ };
172
+ }
173
+
174
+ /** Intern a call frame, returning its index in the shared frames array */
175
+ function internFrame(
176
+ frame: ResolvedFrame,
177
+ sharedFrames: SpeedscopeFrame[],
178
+ frameIndex: Map<string, number>,
179
+ ): number {
180
+ const { name, url, line, col } = frame;
181
+ const key = `${name}\0${url}\0${line}\0${col}`;
182
+
183
+ let idx = frameIndex.get(key);
184
+ if (idx === undefined) {
185
+ idx = sharedFrames.length;
186
+ // Match speedscope's convention: anonymous functions include location in name
187
+ const shortFile = url ? url.split("/").pop() : undefined;
188
+ const displayName =
189
+ name !== "(anonymous)"
190
+ ? name
191
+ : shortFile
192
+ ? `(anonymous ${shortFile}:${line})`
193
+ : "(anonymous)";
194
+ const entry: SpeedscopeFrame = { name: displayName };
195
+ if (url) entry.file = url;
196
+ if (line > 0) entry.line = line;
197
+ if (col != null) entry.col = col;
198
+ sharedFrames.push(entry);
199
+ frameIndex.set(key, idx);
200
+ }
201
+ return idx;
202
+ }