benchforge 0.1.0
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.
- package/README.md +432 -0
- package/bin/benchforge +3 -0
- package/dist/bin/benchforge.mjs +9 -0
- package/dist/bin/benchforge.mjs.map +1 -0
- package/dist/browser/index.js +914 -0
- package/dist/index.mjs +3 -0
- package/dist/src-CGuaC3Wo.mjs +3676 -0
- package/dist/src-CGuaC3Wo.mjs.map +1 -0
- package/package.json +49 -0
- package/src/BenchMatrix.ts +380 -0
- package/src/Benchmark.ts +33 -0
- package/src/BenchmarkReport.ts +156 -0
- package/src/GitUtils.ts +79 -0
- package/src/HtmlDataPrep.ts +148 -0
- package/src/MeasuredResults.ts +127 -0
- package/src/NodeGC.ts +48 -0
- package/src/PermutationTest.ts +115 -0
- package/src/StandardSections.ts +268 -0
- package/src/StatisticalUtils.ts +176 -0
- package/src/TypeUtil.ts +8 -0
- package/src/bin/benchforge.ts +4 -0
- package/src/browser/BrowserGcStats.ts +44 -0
- package/src/browser/BrowserHeapSampler.ts +248 -0
- package/src/cli/CliArgs.ts +64 -0
- package/src/cli/FilterBenchmarks.ts +68 -0
- package/src/cli/RunBenchCLI.ts +856 -0
- package/src/export/JsonExport.ts +103 -0
- package/src/export/JsonFormat.ts +91 -0
- package/src/export/PerfettoExport.ts +203 -0
- package/src/heap-sample/HeapSampleReport.ts +196 -0
- package/src/heap-sample/HeapSampler.ts +78 -0
- package/src/html/HtmlReport.ts +131 -0
- package/src/html/HtmlTemplate.ts +284 -0
- package/src/html/Types.ts +88 -0
- package/src/html/browser/CIPlot.ts +287 -0
- package/src/html/browser/HistogramKde.ts +118 -0
- package/src/html/browser/LegendUtils.ts +163 -0
- package/src/html/browser/RenderPlots.ts +263 -0
- package/src/html/browser/SampleTimeSeries.ts +389 -0
- package/src/html/browser/Types.ts +96 -0
- package/src/html/browser/index.ts +1 -0
- package/src/html/index.ts +17 -0
- package/src/index.ts +92 -0
- package/src/matrix/CaseLoader.ts +36 -0
- package/src/matrix/MatrixFilter.ts +103 -0
- package/src/matrix/MatrixReport.ts +290 -0
- package/src/matrix/VariantLoader.ts +46 -0
- package/src/runners/AdaptiveWrapper.ts +391 -0
- package/src/runners/BasicRunner.ts +368 -0
- package/src/runners/BenchRunner.ts +60 -0
- package/src/runners/CreateRunner.ts +11 -0
- package/src/runners/GcStats.ts +107 -0
- package/src/runners/RunnerOrchestrator.ts +374 -0
- package/src/runners/RunnerUtils.ts +2 -0
- package/src/runners/TimingUtils.ts +13 -0
- package/src/runners/WorkerScript.ts +256 -0
- package/src/table-util/ConvergenceFormatters.ts +19 -0
- package/src/table-util/Formatters.ts +152 -0
- package/src/table-util/README.md +70 -0
- package/src/table-util/TableReport.ts +293 -0
- package/src/table-util/test/TableReport.test.ts +105 -0
- package/src/table-util/test/TableValueExtractor.test.ts +41 -0
- package/src/table-util/test/TableValueExtractor.ts +100 -0
- package/src/test/AdaptiveRunner.test.ts +185 -0
- package/src/test/AdaptiveStatistics.integration.ts +119 -0
- package/src/test/BenchmarkReport.test.ts +82 -0
- package/src/test/BrowserBench.e2e.test.ts +44 -0
- package/src/test/BrowserBench.test.ts +79 -0
- package/src/test/GcStats.test.ts +94 -0
- package/src/test/PermutationTest.test.ts +121 -0
- package/src/test/RunBenchCLI.test.ts +166 -0
- package/src/test/RunnerOrchestrator.test.ts +102 -0
- package/src/test/StatisticalUtils.test.ts +112 -0
- package/src/test/TestUtils.ts +93 -0
- package/src/test/fixtures/test-bench-script.ts +30 -0
- package/src/tests/AdaptiveConvergence.test.ts +177 -0
- package/src/tests/AdaptiveSampling.test.ts +240 -0
- package/src/tests/BenchMatrix.test.ts +366 -0
- package/src/tests/MatrixFilter.test.ts +117 -0
- package/src/tests/MatrixReport.test.ts +139 -0
- package/src/tests/RealDataValidation.test.ts +177 -0
- package/src/tests/fixtures/baseline/impl.ts +4 -0
- package/src/tests/fixtures/bevy30-samples.ts +158 -0
- package/src/tests/fixtures/cases/asyncCases.ts +7 -0
- package/src/tests/fixtures/cases/cases.ts +8 -0
- package/src/tests/fixtures/cases/variants/product.ts +2 -0
- package/src/tests/fixtures/cases/variants/sum.ts +2 -0
- package/src/tests/fixtures/discover/fast.ts +1 -0
- package/src/tests/fixtures/discover/slow.ts +4 -0
- package/src/tests/fixtures/invalid/bad.ts +1 -0
- package/src/tests/fixtures/loader/fast.ts +1 -0
- package/src/tests/fixtures/loader/slow.ts +4 -0
- package/src/tests/fixtures/loader/stateful.ts +2 -0
- package/src/tests/fixtures/stateful/stateful.ts +2 -0
- package/src/tests/fixtures/variants/extra.ts +1 -0
- package/src/tests/fixtures/variants/impl.ts +1 -0
- package/src/tests/fixtures/worker/fast.ts +1 -0
- package/src/tests/fixtures/worker/slow.ts +4 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import type { ReportGroup } from "../BenchmarkReport.ts";
|
|
3
|
+
import type { DefaultCliArgs } from "../cli/CliArgs.ts";
|
|
4
|
+
import type {
|
|
5
|
+
BenchmarkGroup,
|
|
6
|
+
BenchmarkJsonData,
|
|
7
|
+
BenchmarkResult,
|
|
8
|
+
} from "./JsonFormat.ts";
|
|
9
|
+
|
|
10
|
+
/** Export benchmark results to JSON file */
|
|
11
|
+
export async function exportBenchmarkJson(
|
|
12
|
+
groups: ReportGroup[],
|
|
13
|
+
outputPath: string,
|
|
14
|
+
args: DefaultCliArgs,
|
|
15
|
+
suiteName = "Benchmark Suite",
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const jsonData = prepareJsonData(groups, args, suiteName);
|
|
18
|
+
const jsonString = JSON.stringify(jsonData, null, 2);
|
|
19
|
+
|
|
20
|
+
await writeFile(outputPath, jsonString, "utf-8");
|
|
21
|
+
console.log(`Benchmark data exported to: ${outputPath}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Convert ReportGroup data to JSON format */
|
|
25
|
+
function prepareJsonData(
|
|
26
|
+
groups: ReportGroup[],
|
|
27
|
+
args: DefaultCliArgs,
|
|
28
|
+
suiteName: string,
|
|
29
|
+
): BenchmarkJsonData {
|
|
30
|
+
return {
|
|
31
|
+
meta: {
|
|
32
|
+
timestamp: new Date().toISOString(),
|
|
33
|
+
version: process.env.npm_package_version || "unknown",
|
|
34
|
+
args: cleanCliArgs(args),
|
|
35
|
+
environment: {
|
|
36
|
+
node: process.version,
|
|
37
|
+
platform: process.platform,
|
|
38
|
+
arch: process.arch,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
suites: [
|
|
42
|
+
{
|
|
43
|
+
name: suiteName,
|
|
44
|
+
groups: groups.map(convertGroup),
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Convert a report group, mapping each report to the JSON result format */
|
|
51
|
+
function convertGroup(group: ReportGroup): BenchmarkGroup {
|
|
52
|
+
return {
|
|
53
|
+
name: "Benchmark Group", // Could be enhanced to include actual group names
|
|
54
|
+
baseline: group.baseline ? convertReport(group.baseline) : undefined,
|
|
55
|
+
benchmarks: group.reports.map(convertReport),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Extract measured stats and optional metrics into JSON result shape */
|
|
60
|
+
function convertReport(report: any): BenchmarkResult {
|
|
61
|
+
const { name, measuredResults: m } = report;
|
|
62
|
+
const { time, heapSize, gcTime, cpu } = m;
|
|
63
|
+
const minMaxMean = (s: any) =>
|
|
64
|
+
s ? { min: s.min, max: s.max, mean: s.avg } : undefined;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
status: "completed",
|
|
69
|
+
samples: m.samples || [],
|
|
70
|
+
time: {
|
|
71
|
+
...minMaxMean(time)!,
|
|
72
|
+
p50: time.p50,
|
|
73
|
+
p75: time.p75,
|
|
74
|
+
p99: time.p99,
|
|
75
|
+
p999: time.p999,
|
|
76
|
+
},
|
|
77
|
+
heapSize: minMaxMean(heapSize),
|
|
78
|
+
gcTime: minMaxMean(gcTime),
|
|
79
|
+
cpu: cpu
|
|
80
|
+
? {
|
|
81
|
+
instructions: cpu.instructions,
|
|
82
|
+
cycles: cpu.cycles,
|
|
83
|
+
cacheMisses: m.cpuCacheMiss,
|
|
84
|
+
branchMisses: cpu.branchMisses,
|
|
85
|
+
}
|
|
86
|
+
: undefined,
|
|
87
|
+
execution: {
|
|
88
|
+
iterations: m.samples?.length || 0,
|
|
89
|
+
totalTime: m.totalTime || 0,
|
|
90
|
+
warmupRuns: undefined, // Not available in current data structure
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Complete benchmark data structure for JSON export */
|
|
2
|
+
export interface BenchmarkJsonData {
|
|
3
|
+
meta: {
|
|
4
|
+
timestamp: string;
|
|
5
|
+
version: string;
|
|
6
|
+
args: Record<string, any>;
|
|
7
|
+
environment: {
|
|
8
|
+
node: string;
|
|
9
|
+
platform: string;
|
|
10
|
+
arch?: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
suites: BenchmarkSuite[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BenchmarkSuite {
|
|
17
|
+
name: string;
|
|
18
|
+
groups: BenchmarkGroup[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BenchmarkGroup {
|
|
22
|
+
name: string;
|
|
23
|
+
baseline?: BenchmarkResult;
|
|
24
|
+
benchmarks: BenchmarkResult[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BenchmarkResult {
|
|
28
|
+
name: string;
|
|
29
|
+
status: "completed" | "running" | "failed";
|
|
30
|
+
|
|
31
|
+
/** Raw execution time samples in milliseconds */
|
|
32
|
+
samples: number[];
|
|
33
|
+
|
|
34
|
+
/** Statistical summaries */
|
|
35
|
+
time: {
|
|
36
|
+
min: number;
|
|
37
|
+
max: number;
|
|
38
|
+
mean: number;
|
|
39
|
+
p50: number;
|
|
40
|
+
p75: number;
|
|
41
|
+
p99: number;
|
|
42
|
+
p999: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Optional performance metrics */
|
|
46
|
+
heapSize?: {
|
|
47
|
+
min: number;
|
|
48
|
+
max: number;
|
|
49
|
+
mean: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
gcTime?: {
|
|
53
|
+
min: number;
|
|
54
|
+
max: number;
|
|
55
|
+
mean: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
cpu?: {
|
|
59
|
+
instructions?: number;
|
|
60
|
+
cycles?: number;
|
|
61
|
+
cacheMisses?: number;
|
|
62
|
+
branchMisses?: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Execution metadata */
|
|
66
|
+
execution: {
|
|
67
|
+
iterations: number;
|
|
68
|
+
totalTime: number;
|
|
69
|
+
warmupRuns?: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Adaptive mode results */
|
|
73
|
+
adaptive?: {
|
|
74
|
+
confidenceInterval: {
|
|
75
|
+
lower: number;
|
|
76
|
+
upper: number;
|
|
77
|
+
margin: number;
|
|
78
|
+
marginPercent: number;
|
|
79
|
+
confidence: number;
|
|
80
|
+
};
|
|
81
|
+
converged: boolean;
|
|
82
|
+
stopReason: "threshold_met" | "max_time" | "max_iterations";
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Error information */
|
|
86
|
+
error?: {
|
|
87
|
+
message: string;
|
|
88
|
+
type: string;
|
|
89
|
+
stackTrace?: string;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import type { ReportGroup } from "../BenchmarkReport.ts";
|
|
5
|
+
import type { DefaultCliArgs } from "../cli/CliArgs.ts";
|
|
6
|
+
import type { MeasuredResults } from "../MeasuredResults.ts";
|
|
7
|
+
|
|
8
|
+
/** Chrome Trace Event format event */
|
|
9
|
+
interface TraceEvent {
|
|
10
|
+
ph: string; // event type: M=metadata, C=counter, i=instant, B/E=begin/end
|
|
11
|
+
ts: number; // timestamp in microseconds
|
|
12
|
+
pid?: number;
|
|
13
|
+
tid?: number;
|
|
14
|
+
cat?: string;
|
|
15
|
+
name: string;
|
|
16
|
+
args?: Record<string, unknown>;
|
|
17
|
+
s?: string; // scope for instant events: "t"=thread, "p"=process, "g"=global
|
|
18
|
+
dur?: number; // duration for complete events
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Chrome Trace Event format file structure */
|
|
22
|
+
interface TraceFile {
|
|
23
|
+
traceEvents: TraceEvent[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const pid = 1;
|
|
27
|
+
const tid = 1;
|
|
28
|
+
|
|
29
|
+
/** Export benchmark results to Perfetto-compatible trace file */
|
|
30
|
+
export function exportPerfettoTrace(
|
|
31
|
+
groups: ReportGroup[],
|
|
32
|
+
outputPath: string,
|
|
33
|
+
args: DefaultCliArgs,
|
|
34
|
+
): void {
|
|
35
|
+
const absPath = resolve(outputPath);
|
|
36
|
+
const events = buildTraceEvents(groups, args);
|
|
37
|
+
|
|
38
|
+
// Try to merge any existing V8 trace from a previous run
|
|
39
|
+
const merged = mergeV8Trace(events);
|
|
40
|
+
writeTraceFile(absPath, merged);
|
|
41
|
+
console.log(`Perfetto trace exported to: ${outputPath}`);
|
|
42
|
+
|
|
43
|
+
// V8 writes trace files after process exit, so spawn a child to merge later
|
|
44
|
+
scheduleDeferredMerge(absPath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build trace events from benchmark results */
|
|
48
|
+
function buildTraceEvents(
|
|
49
|
+
groups: ReportGroup[],
|
|
50
|
+
args: DefaultCliArgs,
|
|
51
|
+
): TraceEvent[] {
|
|
52
|
+
const meta = (name: string, a: Record<string, unknown>): TraceEvent => ({
|
|
53
|
+
ph: "M",
|
|
54
|
+
ts: 0,
|
|
55
|
+
pid,
|
|
56
|
+
tid,
|
|
57
|
+
name,
|
|
58
|
+
args: a,
|
|
59
|
+
});
|
|
60
|
+
const events: TraceEvent[] = [
|
|
61
|
+
meta("process_name", { name: "wesl-bench" }),
|
|
62
|
+
meta("thread_name", { name: "MainThread" }),
|
|
63
|
+
meta("bench_settings", cleanArgs(args)),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
for (const group of groups) {
|
|
67
|
+
for (const report of group.reports) {
|
|
68
|
+
const results = report.measuredResults as MeasuredResults;
|
|
69
|
+
events.push(...buildBenchmarkEvents(results));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return events;
|
|
74
|
+
}
|
|
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
|
+
/** Merge V8 trace events from a previous run, aligning timestamps */
|
|
125
|
+
function mergeV8Trace(customEvents: TraceEvent[]): TraceEvent[] {
|
|
126
|
+
const traceFiles = readdirSync(".").filter(
|
|
127
|
+
f => f.startsWith("node_trace.") && f.endsWith(".log"),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const v8Events = loadV8Events(traceFiles[0]);
|
|
131
|
+
normalizeTimestamps(customEvents);
|
|
132
|
+
if (!v8Events) return customEvents;
|
|
133
|
+
|
|
134
|
+
normalizeTimestamps(v8Events);
|
|
135
|
+
return [...v8Events, ...customEvents];
|
|
136
|
+
}
|
|
137
|
+
|
|
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
|
+
/** Write trace events to JSON file */
|
|
156
|
+
function writeTraceFile(outputPath: string, events: TraceEvent[]): void {
|
|
157
|
+
const traceFile: TraceFile = { traceEvents: events };
|
|
158
|
+
writeFileSync(outputPath, JSON.stringify(traceFile));
|
|
159
|
+
}
|
|
160
|
+
|
|
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
|
+
/** Spawn a detached child to merge V8 trace after process exit */
|
|
171
|
+
function scheduleDeferredMerge(outputPath: string): void {
|
|
172
|
+
const cwd = process.cwd();
|
|
173
|
+
const mergeScript = `
|
|
174
|
+
const { readdirSync, readFileSync, writeFileSync } = require('fs');
|
|
175
|
+
function normalize(events) {
|
|
176
|
+
const times = events.filter(e => e.ts > 0).map(e => e.ts);
|
|
177
|
+
if (!times.length) return;
|
|
178
|
+
const min = Math.min(...times);
|
|
179
|
+
for (const e of events) if (e.ts > 0) e.ts -= min;
|
|
180
|
+
}
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
const traceFiles = readdirSync('.').filter(f => f.startsWith('node_trace.') && f.endsWith('.log'));
|
|
183
|
+
if (traceFiles.length === 0) process.exit(0);
|
|
184
|
+
try {
|
|
185
|
+
const v8Data = JSON.parse(readFileSync(traceFiles[0], 'utf-8'));
|
|
186
|
+
const ourData = JSON.parse(readFileSync('${outputPath}', 'utf-8'));
|
|
187
|
+
normalize(v8Data.traceEvents);
|
|
188
|
+
const merged = { traceEvents: [...v8Data.traceEvents, ...ourData.traceEvents] };
|
|
189
|
+
writeFileSync('${outputPath}', JSON.stringify(merged));
|
|
190
|
+
console.log('Merged ' + v8Data.traceEvents.length + ' V8 events into ' + '${outputPath}');
|
|
191
|
+
} catch (e) { console.error('Merge failed:', e.message); }
|
|
192
|
+
}, 100);
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
process.on("exit", () => {
|
|
196
|
+
const child = spawn("node", ["-e", mergeScript], {
|
|
197
|
+
detached: true,
|
|
198
|
+
stdio: "inherit",
|
|
199
|
+
cwd,
|
|
200
|
+
});
|
|
201
|
+
child.unref();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import type { HeapProfile, ProfileNode } from "./HeapSampler.ts";
|
|
3
|
+
|
|
4
|
+
/** Sum selfSize across all nodes in profile (before any filtering) */
|
|
5
|
+
export function totalProfileBytes(profile: HeapProfile): number {
|
|
6
|
+
let total = 0;
|
|
7
|
+
function walk(node: ProfileNode): void {
|
|
8
|
+
total += node.selfSize;
|
|
9
|
+
for (const child of node.children || []) walk(child);
|
|
10
|
+
}
|
|
11
|
+
walk(profile.head);
|
|
12
|
+
return total;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CallFrame {
|
|
16
|
+
fn: string;
|
|
17
|
+
url: string;
|
|
18
|
+
line: number; // 1-indexed for display
|
|
19
|
+
col: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HeapSite {
|
|
23
|
+
fn: string;
|
|
24
|
+
url: string;
|
|
25
|
+
line: number; // 1-indexed for display
|
|
26
|
+
col: number;
|
|
27
|
+
bytes: number;
|
|
28
|
+
stack?: CallFrame[]; // call stack from root to this frame
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Flatten profile tree into sorted list of allocation sites with call stacks */
|
|
32
|
+
export function flattenProfile(profile: HeapProfile): HeapSite[] {
|
|
33
|
+
const sites: HeapSite[] = [];
|
|
34
|
+
|
|
35
|
+
function walk(node: ProfileNode, stack: CallFrame[]): void {
|
|
36
|
+
const { functionName, url, lineNumber, columnNumber } = node.callFrame;
|
|
37
|
+
const fn = functionName || "(anonymous)";
|
|
38
|
+
const col = columnNumber ?? 0;
|
|
39
|
+
const frame: CallFrame = { fn, url: url || "", line: lineNumber + 1, col };
|
|
40
|
+
const newStack = [...stack, frame];
|
|
41
|
+
|
|
42
|
+
if (node.selfSize > 0) {
|
|
43
|
+
sites.push({
|
|
44
|
+
...frame,
|
|
45
|
+
bytes: node.selfSize,
|
|
46
|
+
stack: newStack,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
for (const child of node.children || []) walk(child, newStack);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
walk(profile.head, []);
|
|
53
|
+
return sites.sort((a, b) => b.bytes - a.bytes);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type UserCodeFilter = (site: CallFrame) => boolean;
|
|
57
|
+
|
|
58
|
+
/** Check if site is user code (not node internals) */
|
|
59
|
+
export function isNodeUserCode(site: CallFrame): boolean {
|
|
60
|
+
if (!site.url) return false;
|
|
61
|
+
if (site.url.startsWith("node:")) return false;
|
|
62
|
+
if (site.url.includes("(native)")) return false;
|
|
63
|
+
if (site.url.includes("internal/")) return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check if site is user code (not browser internals) */
|
|
68
|
+
export function isBrowserUserCode(site: CallFrame): boolean {
|
|
69
|
+
if (!site.url) return false;
|
|
70
|
+
if (site.url.startsWith("chrome-extension://")) return false;
|
|
71
|
+
if (site.url.startsWith("devtools://")) return false;
|
|
72
|
+
if (site.url.includes("(native)")) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Filter sites to user code only */
|
|
77
|
+
export function filterSites(
|
|
78
|
+
sites: HeapSite[],
|
|
79
|
+
isUser: UserCodeFilter = isNodeUserCode,
|
|
80
|
+
): HeapSite[] {
|
|
81
|
+
return sites.filter(isUser);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Aggregate sites by location (combine same file:line:col) */
|
|
85
|
+
export function aggregateSites(sites: HeapSite[]): HeapSite[] {
|
|
86
|
+
const byLocation = new Map<string, HeapSite>();
|
|
87
|
+
|
|
88
|
+
for (const site of sites) {
|
|
89
|
+
const key = `${site.url}:${site.line}:${site.col}`;
|
|
90
|
+
const existing = byLocation.get(key);
|
|
91
|
+
if (existing) {
|
|
92
|
+
existing.bytes += site.bytes;
|
|
93
|
+
} else {
|
|
94
|
+
byLocation.set(key, { ...site });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return [...byLocation.values()].sort((a, b) => b.bytes - a.bytes);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fmtBytes(bytes: number): string {
|
|
102
|
+
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
103
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
104
|
+
return `${bytes} B`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface HeapReportOptions {
|
|
108
|
+
topN: number;
|
|
109
|
+
stackDepth?: number;
|
|
110
|
+
verbose?: boolean;
|
|
111
|
+
userOnly?: boolean; // filter to user code only (hide node internals)
|
|
112
|
+
isUserCode?: UserCodeFilter; // predicate for user vs internal code
|
|
113
|
+
totalAll?: number; // total across all nodes (before filtering)
|
|
114
|
+
totalUserCode?: number; // total for user code only
|
|
115
|
+
sampleCount?: number; // number of samples taken
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Format heap report for console output */
|
|
119
|
+
export function formatHeapReport(
|
|
120
|
+
sites: HeapSite[],
|
|
121
|
+
options: HeapReportOptions,
|
|
122
|
+
): string {
|
|
123
|
+
const { topN, stackDepth = 3, verbose = false } = options;
|
|
124
|
+
const { totalAll, totalUserCode, sampleCount } = options;
|
|
125
|
+
const isUser = options.isUserCode ?? isNodeUserCode;
|
|
126
|
+
const lines: string[] = [];
|
|
127
|
+
lines.push(`Heap allocation sites (top ${topN}, garbage included):`);
|
|
128
|
+
|
|
129
|
+
for (const site of sites.slice(0, topN)) {
|
|
130
|
+
if (verbose) {
|
|
131
|
+
formatVerboseSite(lines, site, stackDepth, isUser);
|
|
132
|
+
} else {
|
|
133
|
+
formatCompactSite(lines, site, stackDepth, isUser);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
lines.push("");
|
|
138
|
+
if (totalAll !== undefined)
|
|
139
|
+
lines.push(`Total (all): ${fmtBytes(totalAll)}`);
|
|
140
|
+
if (totalUserCode !== undefined)
|
|
141
|
+
lines.push(`Total (user-code): ${fmtBytes(totalUserCode)}`);
|
|
142
|
+
if (sampleCount !== undefined)
|
|
143
|
+
lines.push(`Samples: ${sampleCount.toLocaleString()}`);
|
|
144
|
+
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Compact single-line format: `49 MB fn1 <- fn2 <- fn3` */
|
|
149
|
+
function formatCompactSite(
|
|
150
|
+
lines: string[],
|
|
151
|
+
site: HeapSite,
|
|
152
|
+
stackDepth: number,
|
|
153
|
+
isUser: UserCodeFilter,
|
|
154
|
+
): void {
|
|
155
|
+
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
156
|
+
const fns = [site.fn];
|
|
157
|
+
|
|
158
|
+
if (site.stack && site.stack.length > 1) {
|
|
159
|
+
const callers = site.stack.slice(0, -1).reverse().slice(0, stackDepth);
|
|
160
|
+
for (const frame of callers) {
|
|
161
|
+
if (!frame.url || !isUser(frame)) continue;
|
|
162
|
+
fns.push(frame.fn);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const line = `${bytes} ${fns.join(" <- ")}`;
|
|
167
|
+
lines.push(isUser(site) ? line : pc.dim(line));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Verbose multi-line format with file:// paths and line numbers */
|
|
171
|
+
function formatVerboseSite(
|
|
172
|
+
lines: string[],
|
|
173
|
+
site: HeapSite,
|
|
174
|
+
stackDepth: number,
|
|
175
|
+
isUser: UserCodeFilter,
|
|
176
|
+
): void {
|
|
177
|
+
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
178
|
+
const loc = site.url ? `${site.url}:${site.line}:${site.col}` : "(unknown)";
|
|
179
|
+
const dimFn = isUser(site) ? (s: string) => s : pc.dim;
|
|
180
|
+
|
|
181
|
+
lines.push(dimFn(`${bytes} ${site.fn} ${loc}`));
|
|
182
|
+
|
|
183
|
+
if (site.stack && site.stack.length > 1) {
|
|
184
|
+
const callers = site.stack.slice(0, -1).reverse().slice(0, stackDepth);
|
|
185
|
+
for (const frame of callers) {
|
|
186
|
+
if (!frame.url || !isUser(frame)) continue;
|
|
187
|
+
const callerLoc = `${frame.url}:${frame.line}:${frame.col}`;
|
|
188
|
+
lines.push(dimFn(` <- ${frame.fn} ${callerLoc}`));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Get total bytes from sites */
|
|
194
|
+
export function totalBytes(sites: HeapSite[]): number {
|
|
195
|
+
return sites.reduce((sum, s) => sum + s.bytes, 0);
|
|
196
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Session } from "node:inspector/promises";
|
|
2
|
+
|
|
3
|
+
export interface HeapSampleOptions {
|
|
4
|
+
samplingInterval?: number; // bytes between samples, default 32768
|
|
5
|
+
stackDepth?: number; // max stack frames, default 64
|
|
6
|
+
includeMinorGC?: boolean; // keep objects collected by minor GC, default true
|
|
7
|
+
includeMajorGC?: boolean; // keep objects collected by major GC, default true
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProfileNode {
|
|
11
|
+
callFrame: {
|
|
12
|
+
functionName: string;
|
|
13
|
+
url: string;
|
|
14
|
+
lineNumber: number;
|
|
15
|
+
columnNumber?: number;
|
|
16
|
+
};
|
|
17
|
+
selfSize: number;
|
|
18
|
+
children?: ProfileNode[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HeapProfile {
|
|
22
|
+
head: ProfileNode;
|
|
23
|
+
samples?: number[]; // sample IDs (length = number of samples taken)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const defaultOptions: Required<HeapSampleOptions> = {
|
|
27
|
+
samplingInterval: 32768,
|
|
28
|
+
stackDepth: 64,
|
|
29
|
+
includeMinorGC: true,
|
|
30
|
+
includeMajorGC: true,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Run a function while sampling heap allocations, return profile */
|
|
34
|
+
export async function withHeapSampling<T>(
|
|
35
|
+
options: HeapSampleOptions,
|
|
36
|
+
fn: () => Promise<T> | T,
|
|
37
|
+
): Promise<{ result: T; profile: HeapProfile }> {
|
|
38
|
+
const opts = { ...defaultOptions, ...options };
|
|
39
|
+
const session = new Session();
|
|
40
|
+
session.connect();
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await startSampling(session, opts);
|
|
44
|
+
const result = await fn();
|
|
45
|
+
const profile = await stopSampling(session);
|
|
46
|
+
return { result, profile };
|
|
47
|
+
} finally {
|
|
48
|
+
session.disconnect();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Start heap sampling, falling back if include-collected params aren't supported */
|
|
53
|
+
async function startSampling(
|
|
54
|
+
session: Session,
|
|
55
|
+
opts: Required<HeapSampleOptions>,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const { samplingInterval, stackDepth } = opts;
|
|
58
|
+
const base = { samplingInterval, stackDepth };
|
|
59
|
+
const params = {
|
|
60
|
+
...base,
|
|
61
|
+
includeObjectsCollectedByMinorGC: opts.includeMinorGC,
|
|
62
|
+
includeObjectsCollectedByMajorGC: opts.includeMajorGC,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await session.post("HeapProfiler.startSampling", params);
|
|
67
|
+
} catch {
|
|
68
|
+
console.warn(
|
|
69
|
+
"HeapProfiler: include-collected params not supported, falling back",
|
|
70
|
+
);
|
|
71
|
+
await session.post("HeapProfiler.startSampling", base);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function stopSampling(session: Session): Promise<HeapProfile> {
|
|
76
|
+
const { profile } = await session.post("HeapProfiler.stopSampling");
|
|
77
|
+
return profile as HeapProfile;
|
|
78
|
+
}
|