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.
- package/README.md +69 -42
- package/dist/{BenchRunner-CSKN9zPy.d.mts → BenchRunner-BzyUfiyB.d.mts} +32 -8
- package/dist/{BrowserHeapSampler-DCeL42RE.mjs → BrowserHeapSampler-B6asLKWQ.mjs} +57 -57
- package/dist/BrowserHeapSampler-B6asLKWQ.mjs.map +1 -0
- package/dist/{GcStats-ByEovUi1.mjs → GcStats-wX7Xyblu.mjs} +15 -15
- package/dist/GcStats-wX7Xyblu.mjs.map +1 -0
- package/dist/HeapSampler-B8dtKHn1.mjs.map +1 -1
- package/dist/{TimingUtils-ClclVQ7E.mjs → TimingUtils-DwOwkc8G.mjs} +225 -225
- package/dist/TimingUtils-DwOwkc8G.mjs.map +1 -0
- package/dist/bin/benchforge.mjs +1 -1
- package/dist/browser/index.js +210 -210
- package/dist/index.d.mts +106 -48
- package/dist/index.mjs +3 -3
- package/dist/runners/WorkerScript.d.mts +1 -1
- package/dist/runners/WorkerScript.mjs +66 -66
- package/dist/runners/WorkerScript.mjs.map +1 -1
- package/dist/{src-HfimYuW_.mjs → src-B-DDaCa9.mjs} +1250 -991
- package/dist/src-B-DDaCa9.mjs.map +1 -0
- package/package.json +4 -3
- package/src/BenchMatrix.ts +125 -125
- package/src/BenchmarkReport.ts +50 -45
- package/src/HtmlDataPrep.ts +21 -21
- package/src/PermutationTest.ts +24 -24
- package/src/StandardSections.ts +45 -45
- package/src/StatisticalUtils.ts +60 -61
- package/src/browser/BrowserGcStats.ts +5 -5
- package/src/browser/BrowserHeapSampler.ts +63 -63
- package/src/cli/CliArgs.ts +20 -6
- package/src/cli/FilterBenchmarks.ts +5 -5
- package/src/cli/RunBenchCLI.ts +533 -476
- package/src/export/JsonExport.ts +10 -10
- package/src/export/PerfettoExport.ts +74 -74
- package/src/export/SpeedscopeExport.ts +202 -0
- package/src/heap-sample/HeapSampleReport.ts +143 -70
- package/src/heap-sample/HeapSampler.ts +55 -12
- package/src/heap-sample/ResolvedProfile.ts +89 -0
- package/src/html/HtmlReport.ts +33 -33
- package/src/html/HtmlTemplate.ts +67 -67
- package/src/html/browser/CIPlot.ts +50 -50
- package/src/html/browser/HistogramKde.ts +13 -13
- package/src/html/browser/LegendUtils.ts +48 -48
- package/src/html/browser/RenderPlots.ts +98 -98
- package/src/html/browser/SampleTimeSeries.ts +79 -79
- package/src/index.ts +6 -0
- package/src/matrix/MatrixFilter.ts +6 -6
- package/src/matrix/MatrixReport.ts +96 -96
- package/src/matrix/VariantLoader.ts +5 -5
- package/src/runners/AdaptiveWrapper.ts +151 -151
- package/src/runners/BasicRunner.ts +175 -175
- package/src/runners/BenchRunner.ts +8 -8
- package/src/runners/GcStats.ts +22 -22
- package/src/runners/RunnerOrchestrator.ts +168 -168
- package/src/runners/WorkerScript.ts +96 -96
- package/src/table-util/Formatters.ts +41 -36
- package/src/table-util/TableReport.ts +122 -122
- package/src/table-util/test/TableValueExtractor.ts +9 -9
- package/src/test/AdaptiveStatistics.integration.ts +7 -39
- package/src/test/HeapAttribution.test.ts +51 -0
- package/src/test/RunBenchCLI.test.ts +36 -11
- package/src/test/TestUtils.ts +24 -24
- package/src/test/fixtures/fn-export-bench.ts +3 -0
- package/src/test/fixtures/suite-export-bench.ts +16 -0
- package/src/tests/BenchMatrix.test.ts +12 -12
- package/src/tests/MatrixFilter.test.ts +15 -15
- package/dist/BrowserHeapSampler-DCeL42RE.mjs.map +0 -1
- package/dist/GcStats-ByEovUi1.mjs.map +0 -1
- package/dist/TimingUtils-ClclVQ7E.mjs.map +0 -1
- package/dist/src-HfimYuW_.mjs.map +0 -1
|
@@ -1,60 +1,79 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
-
import type { HeapProfile, ProfileNode } from "./HeapSampler.ts";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
walk(profile.head);
|
|
12
|
-
return total;
|
|
13
|
-
}
|
|
3
|
+
import { formatBytes } from "../table-util/Formatters.ts";
|
|
4
|
+
import type { HeapProfile, HeapSample } from "./HeapSampler.ts";
|
|
5
|
+
import {
|
|
6
|
+
type ResolvedFrame,
|
|
7
|
+
type ResolvedProfile,
|
|
8
|
+
resolveProfile,
|
|
9
|
+
} from "./ResolvedProfile.ts";
|
|
14
10
|
|
|
15
11
|
export interface CallFrame {
|
|
16
12
|
fn: string;
|
|
17
13
|
url: string;
|
|
18
14
|
line: number; // 1-indexed for display
|
|
19
|
-
col
|
|
15
|
+
col?: number; // 1-indexed for display
|
|
20
16
|
}
|
|
21
17
|
|
|
22
18
|
export interface HeapSite {
|
|
23
19
|
fn: string;
|
|
24
20
|
url: string;
|
|
25
21
|
line: number; // 1-indexed for display
|
|
26
|
-
col
|
|
22
|
+
col?: number;
|
|
27
23
|
bytes: number;
|
|
28
24
|
stack?: CallFrame[]; // call stack from root to this frame
|
|
25
|
+
samples?: HeapSample[]; // individual allocation samples at this site
|
|
26
|
+
callers?: { stack: CallFrame[]; bytes: number }[]; // distinct caller paths
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type UserCodeFilter = (site: CallFrame) => boolean;
|
|
30
|
+
|
|
31
|
+
export interface HeapReportOptions {
|
|
32
|
+
topN: number;
|
|
33
|
+
stackDepth?: number;
|
|
34
|
+
verbose?: boolean;
|
|
35
|
+
raw?: boolean; // dump every raw sample
|
|
36
|
+
userOnly?: boolean; // filter to user code only (hide node internals)
|
|
37
|
+
isUserCode?: UserCodeFilter; // predicate for user vs internal code
|
|
38
|
+
totalAll?: number; // total across all nodes (before filtering)
|
|
39
|
+
totalUserCode?: number; // total for user code only
|
|
40
|
+
sampleCount?: number; // number of samples taken
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sum selfSize across all nodes in profile (before any filtering) */
|
|
44
|
+
export function totalProfileBytes(profile: HeapProfile): number {
|
|
45
|
+
return resolveProfile(profile).totalBytes;
|
|
29
46
|
}
|
|
30
47
|
|
|
31
|
-
/** Flatten profile
|
|
32
|
-
|
|
48
|
+
/** Flatten resolved profile into sorted list of allocation sites with call stacks.
|
|
49
|
+
* When raw samples are available, attaches them to corresponding sites. */
|
|
50
|
+
export function flattenProfile(resolved: ResolvedProfile): HeapSite[] {
|
|
33
51
|
const sites: HeapSite[] = [];
|
|
52
|
+
const nodeIdToSites = new Map<number, HeapSite[]>();
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
for (const node of resolved.allocationNodes) {
|
|
55
|
+
const frame = toCallFrame(node.frame);
|
|
56
|
+
const stack = node.stack.map(toCallFrame);
|
|
57
|
+
const site: HeapSite = { ...frame, bytes: node.selfSize, stack };
|
|
58
|
+
sites.push(site);
|
|
59
|
+
const existing = nodeIdToSites.get(node.nodeId);
|
|
60
|
+
if (existing) existing.push(site);
|
|
61
|
+
else nodeIdToSites.set(node.nodeId, [site]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Attach raw samples to their corresponding sites
|
|
65
|
+
for (const sample of resolved.sortedSamples ?? []) {
|
|
66
|
+
const matchingSites = nodeIdToSites.get(sample.nodeId);
|
|
67
|
+
if (!matchingSites) continue;
|
|
68
|
+
for (const site of matchingSites) {
|
|
69
|
+
if (!site.samples) site.samples = [];
|
|
70
|
+
site.samples.push(sample);
|
|
48
71
|
}
|
|
49
|
-
for (const child of node.children || []) walk(child, newStack);
|
|
50
72
|
}
|
|
51
73
|
|
|
52
|
-
walk(profile.head, []);
|
|
53
74
|
return sites.sort((a, b) => b.bytes - a.bytes);
|
|
54
75
|
}
|
|
55
76
|
|
|
56
|
-
export type UserCodeFilter = (site: CallFrame) => boolean;
|
|
57
|
-
|
|
58
77
|
/** Check if site is user code (not node internals) */
|
|
59
78
|
export function isNodeUserCode(site: CallFrame): boolean {
|
|
60
79
|
if (!site.url) return false;
|
|
@@ -81,38 +100,39 @@ export function filterSites(
|
|
|
81
100
|
return sites.filter(isUser);
|
|
82
101
|
}
|
|
83
102
|
|
|
84
|
-
/** Aggregate sites by location (combine same file:line:col)
|
|
103
|
+
/** Aggregate sites by location (combine same file:line:col).
|
|
104
|
+
* Tracks distinct caller stacks with byte weights when merging. */
|
|
85
105
|
export function aggregateSites(sites: HeapSite[]): HeapSite[] {
|
|
86
106
|
const byLocation = new Map<string, HeapSite>();
|
|
87
107
|
|
|
88
108
|
for (const site of sites) {
|
|
89
|
-
|
|
109
|
+
// When column is unknown, include fn name to avoid merging distinct sites
|
|
110
|
+
const key =
|
|
111
|
+
site.col != null
|
|
112
|
+
? `${site.url}:${site.line}:${site.col}`
|
|
113
|
+
: `${site.url}:${site.line}:?:${site.fn}`;
|
|
90
114
|
const existing = byLocation.get(key);
|
|
91
115
|
if (existing) {
|
|
92
116
|
existing.bytes += site.bytes;
|
|
117
|
+
addCaller(existing, site);
|
|
93
118
|
} else {
|
|
94
|
-
|
|
119
|
+
const entry = { ...site };
|
|
120
|
+
if (site.stack) {
|
|
121
|
+
entry.callers = [{ stack: site.stack, bytes: site.bytes }];
|
|
122
|
+
}
|
|
123
|
+
byLocation.set(key, entry);
|
|
95
124
|
}
|
|
96
125
|
}
|
|
97
126
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
127
|
+
// Sort callers by bytes descending, use top caller as primary stack
|
|
128
|
+
for (const site of byLocation.values()) {
|
|
129
|
+
if (site.callers && site.callers.length > 1) {
|
|
130
|
+
site.callers.sort((a, b) => b.bytes - a.bytes);
|
|
131
|
+
site.stack = site.callers[0].stack;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
106
134
|
|
|
107
|
-
|
|
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
|
|
135
|
+
return [...byLocation.values()].sort((a, b) => b.bytes - a.bytes);
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
/** Format heap report for console output */
|
|
@@ -145,52 +165,105 @@ export function formatHeapReport(
|
|
|
145
165
|
return lines.join("\n");
|
|
146
166
|
}
|
|
147
167
|
|
|
148
|
-
/**
|
|
149
|
-
function
|
|
168
|
+
/** Get total bytes from sites */
|
|
169
|
+
export function totalBytes(sites: HeapSite[]): number {
|
|
170
|
+
return sites.reduce((sum, s) => sum + s.bytes, 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Format every raw sample as one line, ordered by ordinal (time).
|
|
174
|
+
* Output is tab-separated for easy piping/grep/diff. */
|
|
175
|
+
export function formatRawSamples(resolved: ResolvedProfile): string {
|
|
176
|
+
if (!resolved.sortedSamples || resolved.sortedSamples.length === 0) {
|
|
177
|
+
return "No raw samples available.";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const lines: string[] = ["ordinal\tsize\tfunction\tlocation"];
|
|
181
|
+
for (const s of resolved.sortedSamples) {
|
|
182
|
+
const node = resolved.nodeMap.get(s.nodeId);
|
|
183
|
+
const fn = node?.frame.name || "(unknown)";
|
|
184
|
+
const url = node?.frame.url || "";
|
|
185
|
+
const loc = url
|
|
186
|
+
? fmtLoc(url, node!.frame.line, node!.frame.col)
|
|
187
|
+
: "(unknown)";
|
|
188
|
+
lines.push(`${s.ordinal}\t${s.size}\t${fn}\t${loc}`);
|
|
189
|
+
}
|
|
190
|
+
return lines.join("\n");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toCallFrame(f: ResolvedFrame): CallFrame {
|
|
194
|
+
return { fn: f.name, url: f.url, line: f.line, col: f.col };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Add a caller stack to an aggregated site, merging if the same path exists */
|
|
198
|
+
function addCaller(existing: HeapSite, site: HeapSite): void {
|
|
199
|
+
if (!site.stack) return;
|
|
200
|
+
if (!existing.callers) {
|
|
201
|
+
existing.callers = [];
|
|
202
|
+
}
|
|
203
|
+
const key = callerKey(site.stack);
|
|
204
|
+
const match = existing.callers.find(c => callerKey(c.stack) === key);
|
|
205
|
+
if (match) {
|
|
206
|
+
match.bytes += site.bytes;
|
|
207
|
+
} else {
|
|
208
|
+
existing.callers.push({ stack: site.stack, bytes: site.bytes });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Verbose multi-line format with file:// paths and line numbers */
|
|
213
|
+
function formatVerboseSite(
|
|
150
214
|
lines: string[],
|
|
151
215
|
site: HeapSite,
|
|
152
216
|
stackDepth: number,
|
|
153
217
|
isUser: UserCodeFilter,
|
|
154
218
|
): void {
|
|
155
219
|
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
156
|
-
const
|
|
220
|
+
const loc = site.url ? fmtLoc(site.url, site.line, site.col) : "(unknown)";
|
|
221
|
+
const dimFn = isUser(site) ? (s: string) => s : pc.dim;
|
|
222
|
+
|
|
223
|
+
lines.push(dimFn(`${bytes} ${site.fn} ${loc}`));
|
|
157
224
|
|
|
158
225
|
if (site.stack && site.stack.length > 1) {
|
|
159
226
|
const callers = site.stack.slice(0, -1).reverse().slice(0, stackDepth);
|
|
160
227
|
for (const frame of callers) {
|
|
161
228
|
if (!frame.url || !isUser(frame)) continue;
|
|
162
|
-
|
|
229
|
+
const callerLoc = fmtLoc(frame.url, frame.line, frame.col);
|
|
230
|
+
lines.push(dimFn(` <- ${frame.fn} ${callerLoc}`));
|
|
163
231
|
}
|
|
164
232
|
}
|
|
165
|
-
|
|
166
|
-
const line = `${bytes} ${fns.join(" <- ")}`;
|
|
167
|
-
lines.push(isUser(site) ? line : pc.dim(line));
|
|
168
233
|
}
|
|
169
234
|
|
|
170
|
-
/**
|
|
171
|
-
function
|
|
235
|
+
/** Compact single-line format: `49 MB fn1 <- fn2 <- fn3` */
|
|
236
|
+
function formatCompactSite(
|
|
172
237
|
lines: string[],
|
|
173
238
|
site: HeapSite,
|
|
174
239
|
stackDepth: number,
|
|
175
240
|
isUser: UserCodeFilter,
|
|
176
241
|
): void {
|
|
177
242
|
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
178
|
-
const
|
|
179
|
-
const dimFn = isUser(site) ? (s: string) => s : pc.dim;
|
|
180
|
-
|
|
181
|
-
lines.push(dimFn(`${bytes} ${site.fn} ${loc}`));
|
|
243
|
+
const fns = [site.fn];
|
|
182
244
|
|
|
183
245
|
if (site.stack && site.stack.length > 1) {
|
|
184
246
|
const callers = site.stack.slice(0, -1).reverse().slice(0, stackDepth);
|
|
185
247
|
for (const frame of callers) {
|
|
186
248
|
if (!frame.url || !isUser(frame)) continue;
|
|
187
|
-
|
|
188
|
-
lines.push(dimFn(` <- ${frame.fn} ${callerLoc}`));
|
|
249
|
+
fns.push(frame.fn);
|
|
189
250
|
}
|
|
190
251
|
}
|
|
252
|
+
|
|
253
|
+
const line = `${bytes} ${fns.join(" <- ")}`;
|
|
254
|
+
lines.push(isUser(site) ? line : pc.dim(line));
|
|
191
255
|
}
|
|
192
256
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
257
|
+
function fmtBytes(bytes: number): string {
|
|
258
|
+
return formatBytes(bytes, { space: true }) ?? `${bytes} B`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Format location, omitting column when unknown */
|
|
262
|
+
function fmtLoc(url: string, line: number, col?: number): string {
|
|
263
|
+
return col != null ? `${url}:${line}:${col}` : `${url}:${line}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Serialize a call stack for dedup comparison */
|
|
267
|
+
function callerKey(stack: CallFrame[]): string {
|
|
268
|
+
return stack.map(f => `${f.url}:${f.line}:${f.col}`).join("|");
|
|
196
269
|
}
|
|
@@ -1,26 +1,68 @@
|
|
|
1
1
|
import { Session } from "node:inspector/promises";
|
|
2
2
|
|
|
3
3
|
export interface HeapSampleOptions {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
/** Bytes between samples (default 32768) */
|
|
5
|
+
samplingInterval?: number;
|
|
6
|
+
|
|
7
|
+
/** Max stack frames (default 64) */
|
|
8
|
+
stackDepth?: number;
|
|
9
|
+
|
|
10
|
+
/** Keep objects collected by minor GC (default true) */
|
|
11
|
+
includeMinorGC?: boolean;
|
|
12
|
+
|
|
13
|
+
/** Keep objects collected by major GC (default true) */
|
|
14
|
+
includeMajorGC?: boolean;
|
|
8
15
|
}
|
|
9
16
|
|
|
17
|
+
/** V8 call frame location within a profiled script */
|
|
18
|
+
export interface CallFrame {
|
|
19
|
+
/** Function name (empty string for anonymous) */
|
|
20
|
+
functionName: string;
|
|
21
|
+
|
|
22
|
+
/** Script URL or file path */
|
|
23
|
+
url: string;
|
|
24
|
+
|
|
25
|
+
/** Zero-based line number */
|
|
26
|
+
lineNumber: number;
|
|
27
|
+
|
|
28
|
+
/** Zero-based column number */
|
|
29
|
+
columnNumber?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Node in the V8 sampling heap profile tree */
|
|
10
33
|
export interface ProfileNode {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
columnNumber?: number;
|
|
16
|
-
};
|
|
34
|
+
/** Call site for this allocation node */
|
|
35
|
+
callFrame: CallFrame;
|
|
36
|
+
|
|
37
|
+
/** Bytes allocated directly at this node (not children) */
|
|
17
38
|
selfSize: number;
|
|
39
|
+
|
|
40
|
+
/** Unique node ID, links to {@link HeapSample.nodeId} */
|
|
41
|
+
id: number;
|
|
42
|
+
|
|
43
|
+
/** Child nodes in the call tree */
|
|
18
44
|
children?: ProfileNode[];
|
|
19
45
|
}
|
|
20
46
|
|
|
47
|
+
/** Individual heap allocation sample from V8's SamplingHeapProfiler */
|
|
48
|
+
export interface HeapSample {
|
|
49
|
+
/** Links to {@link ProfileNode.id} for stack lookup */
|
|
50
|
+
nodeId: number;
|
|
51
|
+
|
|
52
|
+
/** Allocation size in bytes */
|
|
53
|
+
size: number;
|
|
54
|
+
|
|
55
|
+
/** Monotonically increasing, gives temporal ordering */
|
|
56
|
+
ordinal: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** V8 sampling heap profile tree with optional per-allocation samples */
|
|
21
60
|
export interface HeapProfile {
|
|
61
|
+
/** Root of the profile call tree */
|
|
22
62
|
head: ProfileNode;
|
|
23
|
-
|
|
63
|
+
|
|
64
|
+
/** Per-allocation samples, if collected */
|
|
65
|
+
samples?: HeapSample[];
|
|
24
66
|
}
|
|
25
67
|
|
|
26
68
|
const defaultOptions: Required<HeapSampleOptions> = {
|
|
@@ -74,5 +116,6 @@ async function startSampling(
|
|
|
74
116
|
|
|
75
117
|
async function stopSampling(session: Session): Promise<HeapProfile> {
|
|
76
118
|
const { profile } = await session.post("HeapProfiler.stopSampling");
|
|
77
|
-
|
|
119
|
+
// V8 returns id/samples fields not in @types/node's incomplete SamplingHeapProfile
|
|
120
|
+
return profile as unknown as HeapProfile;
|
|
78
121
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { HeapProfile, HeapSample, ProfileNode } from "./HeapSampler.ts";
|
|
2
|
+
|
|
3
|
+
/** A call frame with display-ready 1-indexed source positions */
|
|
4
|
+
export interface ResolvedFrame {
|
|
5
|
+
/** Function name, "(anonymous)" when empty */
|
|
6
|
+
name: string;
|
|
7
|
+
|
|
8
|
+
/** Script URL or file path, "" when unknown */
|
|
9
|
+
url: string;
|
|
10
|
+
|
|
11
|
+
/** 1-indexed line number */
|
|
12
|
+
line: number;
|
|
13
|
+
|
|
14
|
+
/** 1-indexed column number (undefined when unknown) */
|
|
15
|
+
col?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A profile node with its resolved call stack from root to this node */
|
|
19
|
+
export interface ResolvedNode {
|
|
20
|
+
/** The call frame at this node */
|
|
21
|
+
frame: ResolvedFrame;
|
|
22
|
+
|
|
23
|
+
/** Call stack from root to this node (inclusive) */
|
|
24
|
+
stack: ResolvedFrame[];
|
|
25
|
+
|
|
26
|
+
/** Bytes allocated directly at this node */
|
|
27
|
+
selfSize: number;
|
|
28
|
+
|
|
29
|
+
/** V8 node ID, used to match {@link HeapSample.nodeId} */
|
|
30
|
+
nodeId: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Pre-resolved heap profile: single tree walk produces all derived data */
|
|
34
|
+
export interface ResolvedProfile {
|
|
35
|
+
/** All nodes from the profile tree, flattened */
|
|
36
|
+
nodes: ResolvedNode[];
|
|
37
|
+
|
|
38
|
+
/** nodeId -> ResolvedNode lookup */
|
|
39
|
+
nodeMap: Map<number, ResolvedNode>;
|
|
40
|
+
|
|
41
|
+
/** Nodes with selfSize > 0, sorted by selfSize descending */
|
|
42
|
+
allocationNodes: ResolvedNode[];
|
|
43
|
+
|
|
44
|
+
/** Samples sorted by ordinal (temporal order), if available */
|
|
45
|
+
sortedSamples: HeapSample[] | undefined;
|
|
46
|
+
|
|
47
|
+
/** Total bytes across all nodes (sum of selfSize) */
|
|
48
|
+
totalBytes: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Walk a HeapProfile tree once, producing a fully resolved intermediate form */
|
|
52
|
+
export function resolveProfile(profile: HeapProfile): ResolvedProfile {
|
|
53
|
+
const nodes: ResolvedNode[] = [];
|
|
54
|
+
const nodeMap = new Map<number, ResolvedNode>();
|
|
55
|
+
let totalBytes = 0;
|
|
56
|
+
|
|
57
|
+
function walk(node: ProfileNode, parentStack: ResolvedFrame[]): void {
|
|
58
|
+
const { functionName, url, lineNumber, columnNumber } = node.callFrame;
|
|
59
|
+
const frame: ResolvedFrame = {
|
|
60
|
+
name: functionName || "(anonymous)",
|
|
61
|
+
url: url || "",
|
|
62
|
+
line: lineNumber + 1,
|
|
63
|
+
col: columnNumber != null ? columnNumber + 1 : undefined,
|
|
64
|
+
};
|
|
65
|
+
const stack = [...parentStack, frame];
|
|
66
|
+
const resolved: ResolvedNode = {
|
|
67
|
+
frame,
|
|
68
|
+
stack,
|
|
69
|
+
selfSize: node.selfSize,
|
|
70
|
+
nodeId: node.id,
|
|
71
|
+
};
|
|
72
|
+
nodes.push(resolved);
|
|
73
|
+
nodeMap.set(node.id, resolved);
|
|
74
|
+
totalBytes += node.selfSize;
|
|
75
|
+
for (const child of node.children || []) walk(child, stack);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
walk(profile.head, []);
|
|
79
|
+
|
|
80
|
+
const allocationNodes = nodes
|
|
81
|
+
.filter(n => n.selfSize > 0)
|
|
82
|
+
.sort((a, b) => b.selfSize - a.selfSize);
|
|
83
|
+
|
|
84
|
+
const sortedSamples = profile.samples
|
|
85
|
+
? [...profile.samples].sort((a, b) => a.ordinal - b.ordinal)
|
|
86
|
+
: undefined;
|
|
87
|
+
|
|
88
|
+
return { nodes, nodeMap, allocationNodes, sortedSamples, totalBytes };
|
|
89
|
+
}
|
package/src/html/HtmlReport.ts
CHANGED
|
@@ -44,6 +44,39 @@ export async function generateHtmlReport(
|
|
|
44
44
|
return { reportDir, server, closeServer };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Create a timestamped report directory under ./bench-report/ */
|
|
48
|
+
async function createReportDir(): Promise<string> {
|
|
49
|
+
const base = "./bench-report";
|
|
50
|
+
await mkdir(base, { recursive: true });
|
|
51
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
52
|
+
return join(base, `report-${ts}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read the pre-built browser plots bundle from dist/ */
|
|
56
|
+
async function loadPlotsBundle(): Promise<string> {
|
|
57
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
const builtPath = join(thisDir, "browser/index.js");
|
|
59
|
+
const devPath = join(thisDir, "../../dist/browser/index.js");
|
|
60
|
+
try {
|
|
61
|
+
return await readFile(builtPath, "utf-8");
|
|
62
|
+
} catch {}
|
|
63
|
+
return readFile(devPath, "utf-8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Write an index.html in the parent dir that redirects to this report */
|
|
67
|
+
async function writeLatestRedirect(reportDir: string): Promise<void> {
|
|
68
|
+
const baseDir = dirname(reportDir);
|
|
69
|
+
const reportName = reportDir.split("/").pop();
|
|
70
|
+
const html = `<!DOCTYPE html>
|
|
71
|
+
<html><head>
|
|
72
|
+
<meta http-equiv="refresh" content="0; url=./${reportName}/">
|
|
73
|
+
<script>location.href = "./${reportName}/";</script>
|
|
74
|
+
</head><body>
|
|
75
|
+
<a href="./${reportName}/">Latest report</a>
|
|
76
|
+
</body></html>`;
|
|
77
|
+
await writeFile(join(baseDir, "index.html"), html, "utf-8");
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
/** Start HTTP server for report directory, trying fallback ports if needed */
|
|
48
81
|
async function startReportServer(
|
|
49
82
|
baseDir: string,
|
|
@@ -96,36 +129,3 @@ function tryListen(
|
|
|
96
129
|
});
|
|
97
130
|
});
|
|
98
131
|
}
|
|
99
|
-
|
|
100
|
-
/** Create a timestamped report directory under ./bench-report/ */
|
|
101
|
-
async function createReportDir(): Promise<string> {
|
|
102
|
-
const base = "./bench-report";
|
|
103
|
-
await mkdir(base, { recursive: true });
|
|
104
|
-
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
105
|
-
return join(base, `report-${ts}`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Read the pre-built browser plots bundle from dist/ */
|
|
109
|
-
async function loadPlotsBundle(): Promise<string> {
|
|
110
|
-
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
111
|
-
const builtPath = join(thisDir, "browser/index.js");
|
|
112
|
-
const devPath = join(thisDir, "../../dist/browser/index.js");
|
|
113
|
-
try {
|
|
114
|
-
return await readFile(builtPath, "utf-8");
|
|
115
|
-
} catch {}
|
|
116
|
-
return readFile(devPath, "utf-8");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Write an index.html in the parent dir that redirects to this report */
|
|
120
|
-
async function writeLatestRedirect(reportDir: string): Promise<void> {
|
|
121
|
-
const baseDir = dirname(reportDir);
|
|
122
|
-
const reportName = reportDir.split("/").pop();
|
|
123
|
-
const html = `<!DOCTYPE html>
|
|
124
|
-
<html><head>
|
|
125
|
-
<meta http-equiv="refresh" content="0; url=./${reportName}/">
|
|
126
|
-
<script>location.href = "./${reportName}/";</script>
|
|
127
|
-
</head><body>
|
|
128
|
-
<a href="./${reportName}/">Latest report</a>
|
|
129
|
-
</body></html>`;
|
|
130
|
-
await writeFile(join(baseDir, "index.html"), html, "utf-8");
|
|
131
|
-
}
|