benchforge 0.1.11 → 0.2.4
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/LICENSE +20 -0
- package/README.md +99 -294
- package/bin/benchforge +1 -2
- package/dist/AnalyzeArchive-8NCJhmhS.mjs +145 -0
- package/dist/AnalyzeArchive-8NCJhmhS.mjs.map +1 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs +1050 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs.map +1 -0
- package/dist/{BenchRunner-BzyUfiyB.d.mts → BenchRunner-DglX1NOn.d.mts} +119 -66
- package/dist/CoverageSampler-D5T9DRqe.mjs +27 -0
- package/dist/CoverageSampler-D5T9DRqe.mjs.map +1 -0
- package/dist/Formatters-BWj3d4sv.mjs +95 -0
- package/dist/Formatters-BWj3d4sv.mjs.map +1 -0
- package/dist/{HeapSampler-B8dtKHn1.mjs → HeapSampler-Dq-hpXem.mjs} +4 -4
- package/dist/HeapSampler-Dq-hpXem.mjs.map +1 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs +3075 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs.map +1 -0
- package/dist/StatisticalUtils-BD92crgM.mjs +255 -0
- package/dist/StatisticalUtils-BD92crgM.mjs.map +1 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs +29 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs.map +1 -0
- package/dist/ViewerServer-BJhdnxlN.mjs +639 -0
- package/dist/ViewerServer-BJhdnxlN.mjs.map +1 -0
- package/dist/ViewerServer-CuMNdNBz.mjs +2 -0
- package/dist/bin/benchforge.mjs +4 -5
- package/dist/bin/benchforge.mjs.map +1 -1
- package/dist/index.d.mts +711 -558
- package/dist/index.mjs +98 -3
- package/dist/index.mjs.map +1 -0
- package/dist/runners/WorkerScript.d.mts +12 -4
- package/dist/runners/WorkerScript.mjs +77 -105
- package/dist/runners/WorkerScript.mjs.map +1 -1
- package/dist/viewer/assets/CIPlot-BkOvMoMa.js +1 -0
- package/dist/viewer/assets/HistogramKde-CmSyUFY0.js +1 -0
- package/dist/viewer/assets/LegendUtils-BJpbn_jr.js +55 -0
- package/dist/viewer/assets/SampleTimeSeries-C4VBhXr3.js +1 -0
- package/dist/viewer/assets/index-Br9bp_cX.js +153 -0
- package/dist/viewer/assets/index-NzXXe_CC.css +1 -0
- package/dist/viewer/index.html +19 -0
- package/dist/viewer/speedscope/LICENSE +21 -0
- package/dist/viewer/speedscope/SourceCodePro-Regular.ttf-ILST5JV6.woff2 +0 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js +2 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js.map +7 -0
- package/dist/viewer/speedscope/favicon-16x16-VSI62OPJ.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-3EB2YCUY.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js +2 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js.map +7 -0
- package/dist/viewer/speedscope/favicon-FOKUP5Y5.ico +0 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js +2 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js.map +7 -0
- package/dist/viewer/speedscope/file-format-schema.json +274 -0
- package/dist/viewer/speedscope/index.html +19 -0
- package/dist/viewer/speedscope/jfrview_bg-BLJXNNQB.wasm +0 -0
- package/dist/viewer/speedscope/perf-vertx-stacks-01-collapsed-all-ZNUIGAJL.txt +199 -0
- package/dist/viewer/speedscope/release.txt +3 -0
- package/dist/viewer/speedscope/source-code-pro.LICENSE.md +93 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css +2 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css.map +7 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js +212 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js.map +7 -0
- package/package.json +52 -27
- package/src/bin/benchforge.ts +2 -2
- package/src/cli/AnalyzeArchive.ts +232 -0
- package/src/cli/BrowserBench.ts +322 -0
- package/src/cli/CliArgs.ts +164 -51
- package/src/cli/CliExport.ts +179 -0
- package/src/cli/CliOptions.ts +147 -0
- package/src/cli/CliReport.ts +197 -0
- package/src/cli/FilterBenchmarks.ts +18 -30
- package/src/cli/RunBenchCLI.ts +132 -866
- package/src/cli/SuiteRunner.ts +160 -0
- package/src/cli/ViewerServer.ts +282 -0
- package/src/export/AllocExport.ts +121 -0
- package/src/export/ArchiveExport.ts +146 -0
- package/src/export/ArchiveFormat.ts +50 -0
- package/src/export/CoverageExport.ts +148 -0
- package/src/export/EditorUri.ts +10 -0
- package/src/export/PerfettoExport.ts +64 -99
- package/src/export/SpeedscopeTypes.ts +98 -0
- package/src/export/TimeExport.ts +115 -0
- package/src/index.ts +86 -67
- package/src/matrix/BenchMatrix.ts +230 -0
- package/src/matrix/CaseLoader.ts +8 -6
- package/src/matrix/MatrixDirRunner.ts +153 -0
- package/src/matrix/MatrixFilter.ts +49 -47
- package/src/matrix/MatrixInlineRunner.ts +50 -0
- package/src/matrix/MatrixReport.ts +90 -250
- package/src/matrix/VariantLoader.ts +5 -5
- package/src/profiling/browser/BenchLoop.ts +51 -0
- package/src/profiling/browser/BrowserCDP.ts +133 -0
- package/src/profiling/browser/BrowserGcStats.ts +33 -0
- package/src/profiling/browser/BrowserProfiler.ts +160 -0
- package/src/profiling/browser/CdpClient.ts +82 -0
- package/src/profiling/browser/CdpPage.ts +138 -0
- package/src/profiling/browser/ChromeLauncher.ts +158 -0
- package/src/profiling/browser/ChromeTraceEvent.ts +28 -0
- package/src/profiling/browser/PageLoadMode.ts +61 -0
- package/src/profiling/node/CoverageSampler.ts +27 -0
- package/src/profiling/node/CoverageTypes.ts +23 -0
- package/src/profiling/node/HeapSampleReport.ts +261 -0
- package/src/{heap-sample → profiling/node}/HeapSampler.ts +1 -2
- package/src/{heap-sample → profiling/node}/ResolvedProfile.ts +18 -9
- package/src/profiling/node/TimeSampler.ts +57 -0
- package/src/report/BenchmarkReport.ts +146 -0
- package/src/report/Colors.ts +9 -0
- package/src/report/Formatters.ts +110 -0
- package/src/report/GcSections.ts +151 -0
- package/src/{GitUtils.ts → report/GitUtils.ts} +18 -19
- package/src/report/HtmlReport.ts +223 -0
- package/src/report/ParseStats.ts +73 -0
- package/src/report/StandardSections.ts +147 -0
- package/src/report/ViewerSections.ts +286 -0
- package/src/report/text/TableReport.ts +253 -0
- package/src/report/text/TextReport.ts +123 -0
- package/src/runners/AdaptiveWrapper.ts +116 -236
- package/src/runners/BenchRunner.ts +20 -15
- package/src/{Benchmark.ts → runners/BenchmarkSpec.ts} +5 -6
- package/src/runners/CreateRunner.ts +5 -7
- package/src/runners/GcStats.ts +47 -50
- package/src/{MeasuredResults.ts → runners/MeasuredResults.ts} +43 -37
- package/src/runners/MergeBatches.ts +123 -0
- package/src/{NodeGC.ts → runners/NodeGC.ts} +2 -3
- package/src/runners/RunnerOrchestrator.ts +127 -243
- package/src/runners/RunnerUtils.ts +75 -1
- package/src/runners/SampleStats.ts +100 -0
- package/src/runners/TimingRunner.ts +244 -0
- package/src/runners/TimingUtils.ts +3 -2
- package/src/runners/WorkerScript.ts +135 -151
- package/src/stats/BootstrapDifference.ts +282 -0
- package/src/{PermutationTest.ts → stats/PermutationTest.ts} +8 -17
- package/src/stats/StatisticalUtils.ts +445 -0
- package/src/{tests → test}/AdaptiveConvergence.test.ts +10 -10
- package/src/test/AdaptiveRunner.test.ts +39 -41
- package/src/{tests → test}/AdaptiveSampling.test.ts +9 -9
- package/src/test/AdaptiveStatistics.integration.ts +2 -2
- package/src/{tests → test}/BenchMatrix.test.ts +19 -16
- package/src/test/BenchmarkReport.test.ts +63 -13
- package/src/test/BrowserBench.e2e.test.ts +186 -17
- package/src/test/BrowserBench.test.ts +10 -5
- package/src/test/BuildTimeSection.test.ts +130 -0
- package/src/test/CapSamples.test.ts +82 -0
- package/src/test/CoverageExport.test.ts +115 -0
- package/src/test/CoverageSampler.test.ts +33 -0
- package/src/test/HeapAttribution.test.ts +14 -14
- package/src/{tests → test}/MatrixFilter.test.ts +1 -1
- package/src/{tests → test}/MatrixReport.test.ts +1 -1
- package/src/test/PermutationTest.test.ts +1 -1
- package/src/{tests → test}/RealDataValidation.test.ts +6 -6
- package/src/test/RunBenchCLI.test.ts +39 -38
- package/src/test/RunnerOrchestrator.test.ts +12 -12
- package/src/test/StatisticalUtils.test.ts +48 -12
- package/src/{table-util/test → test}/TableReport.test.ts +2 -2
- package/src/test/TestUtils.ts +12 -7
- package/src/test/TimeExport.test.ts +139 -0
- package/src/test/TimeSampler.test.ts +37 -0
- package/src/test/ViewerLive.e2e.test.ts +159 -0
- package/src/test/ViewerStatic.static.e2e.test.ts +137 -0
- package/src/{tests → test}/fixtures/baseline/impl.ts +1 -1
- package/src/{tests → test}/fixtures/bevy30-samples.ts +3 -1
- package/src/test/fixtures/cases/asyncCases.ts +9 -0
- package/src/{tests → test}/fixtures/cases/cases.ts +5 -2
- package/src/test/fixtures/cases/variants/product.ts +2 -0
- package/src/test/fixtures/cases/variants/sum.ts +2 -0
- package/src/test/fixtures/discover/fast.ts +1 -0
- package/src/{tests → test}/fixtures/discover/slow.ts +1 -1
- package/src/test/fixtures/invalid/bad.ts +1 -0
- package/src/test/fixtures/loader/fast.ts +1 -0
- package/src/{tests → test}/fixtures/loader/slow.ts +1 -1
- package/src/test/fixtures/loader/stateful.ts +2 -0
- package/src/test/fixtures/stateful/stateful.ts +2 -0
- package/src/test/fixtures/variants/extra.ts +1 -0
- package/src/test/fixtures/variants/impl.ts +1 -0
- package/src/test/fixtures/worker/fast.ts +1 -0
- package/src/{tests → test}/fixtures/worker/slow.ts +1 -1
- package/src/viewer/DateFormat.ts +30 -0
- package/src/viewer/Helpers.ts +23 -0
- package/src/viewer/LineData.ts +120 -0
- package/src/viewer/Providers.ts +191 -0
- package/src/viewer/ReportData.ts +123 -0
- package/src/viewer/State.ts +49 -0
- package/src/viewer/Theme.ts +15 -0
- package/src/viewer/components/App.tsx +73 -0
- package/src/viewer/components/DropZone.tsx +71 -0
- package/src/viewer/components/LazyPlot.ts +33 -0
- package/src/viewer/components/SamplesPanel.tsx +214 -0
- package/src/viewer/components/Shell.tsx +26 -0
- package/src/viewer/components/SourcePanel.tsx +216 -0
- package/src/viewer/components/SummaryPanel.tsx +332 -0
- package/src/viewer/components/TabBar.tsx +131 -0
- package/src/viewer/components/TabContent.tsx +46 -0
- package/src/viewer/components/ThemeToggle.tsx +50 -0
- package/src/viewer/index.html +20 -0
- package/src/viewer/main.tsx +4 -0
- package/src/viewer/plots/CIPlot.ts +313 -0
- package/src/{html/browser → viewer/plots}/HistogramKde.ts +33 -38
- package/src/viewer/plots/LegendUtils.ts +134 -0
- package/src/viewer/plots/PlotTypes.ts +85 -0
- package/src/viewer/plots/RenderPlots.ts +230 -0
- package/src/viewer/plots/SampleTimeSeries.ts +306 -0
- package/src/viewer/plots/SvgHelpers.ts +136 -0
- package/src/viewer/plots/TimeSeriesMarks.ts +319 -0
- package/src/viewer/report.css +427 -0
- package/src/viewer/shell.css +357 -0
- package/src/viewer/tsconfig.json +11 -0
- package/dist/BrowserHeapSampler-B6asLKWQ.mjs +0 -202
- package/dist/BrowserHeapSampler-B6asLKWQ.mjs.map +0 -1
- package/dist/GcStats-wX7Xyblu.mjs +0 -77
- package/dist/GcStats-wX7Xyblu.mjs.map +0 -1
- package/dist/HeapSampler-B8dtKHn1.mjs.map +0 -1
- package/dist/TimingUtils-DwOwkc8G.mjs +0 -597
- package/dist/TimingUtils-DwOwkc8G.mjs.map +0 -1
- package/dist/browser/index.js +0 -914
- package/dist/src-B-DDaCa9.mjs +0 -3108
- package/dist/src-B-DDaCa9.mjs.map +0 -1
- package/src/BenchMatrix.ts +0 -380
- package/src/BenchmarkReport.ts +0 -161
- package/src/HtmlDataPrep.ts +0 -148
- package/src/StandardSections.ts +0 -261
- package/src/StatisticalUtils.ts +0 -175
- package/src/TypeUtil.ts +0 -8
- package/src/browser/BrowserGcStats.ts +0 -44
- package/src/browser/BrowserHeapSampler.ts +0 -271
- package/src/export/JsonExport.ts +0 -103
- package/src/export/JsonFormat.ts +0 -91
- package/src/export/SpeedscopeExport.ts +0 -202
- package/src/heap-sample/HeapSampleReport.ts +0 -269
- package/src/html/HtmlReport.ts +0 -131
- package/src/html/HtmlTemplate.ts +0 -284
- package/src/html/Types.ts +0 -88
- package/src/html/browser/CIPlot.ts +0 -287
- package/src/html/browser/LegendUtils.ts +0 -163
- package/src/html/browser/RenderPlots.ts +0 -263
- package/src/html/browser/SampleTimeSeries.ts +0 -389
- package/src/html/browser/Types.ts +0 -96
- package/src/html/browser/index.ts +0 -1
- package/src/html/index.ts +0 -17
- package/src/runners/BasicRunner.ts +0 -364
- package/src/table-util/ConvergenceFormatters.ts +0 -19
- package/src/table-util/Formatters.ts +0 -157
- package/src/table-util/README.md +0 -70
- package/src/table-util/TableReport.ts +0 -293
- package/src/tests/fixtures/cases/asyncCases.ts +0 -7
- package/src/tests/fixtures/cases/variants/product.ts +0 -2
- package/src/tests/fixtures/cases/variants/sum.ts +0 -2
- package/src/tests/fixtures/discover/fast.ts +0 -1
- package/src/tests/fixtures/invalid/bad.ts +0 -1
- package/src/tests/fixtures/loader/fast.ts +0 -1
- package/src/tests/fixtures/loader/stateful.ts +0 -2
- package/src/tests/fixtures/stateful/stateful.ts +0 -2
- package/src/tests/fixtures/variants/extra.ts +0 -1
- package/src/tests/fixtures/variants/impl.ts +0 -1
- package/src/tests/fixtures/worker/fast.ts +0 -1
- /package/src/{table-util/test → test}/TableValueExtractor.test.ts +0 -0
- /package/src/{table-util/test → test}/TableValueExtractor.ts +0 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BenchmarkReport,
|
|
3
|
+
ReportGroup,
|
|
4
|
+
} from "../report/BenchmarkReport.ts";
|
|
5
|
+
import type {
|
|
6
|
+
BenchGroup,
|
|
7
|
+
BenchmarkSpec,
|
|
8
|
+
BenchSuite,
|
|
9
|
+
} from "../runners/BenchmarkSpec.ts";
|
|
10
|
+
import type { RunnerOptions } from "../runners/BenchRunner.ts";
|
|
11
|
+
import type { KnownRunner } from "../runners/CreateRunner.ts";
|
|
12
|
+
import { runBatched } from "../runners/MergeBatches.ts";
|
|
13
|
+
import { runBenchmark } from "../runners/RunnerOrchestrator.ts";
|
|
14
|
+
import type { DefaultCliArgs } from "./CliArgs.ts";
|
|
15
|
+
import { cliToRunnerOptions, validateArgs } from "./CliOptions.ts";
|
|
16
|
+
import { filterBenchmarks } from "./FilterBenchmarks.ts";
|
|
17
|
+
|
|
18
|
+
type RunParams = {
|
|
19
|
+
runner: KnownRunner;
|
|
20
|
+
options: RunnerOptions;
|
|
21
|
+
useWorker: boolean;
|
|
22
|
+
params: unknown;
|
|
23
|
+
metadata?: Record<string, any>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type SuiteParams = {
|
|
27
|
+
runner: KnownRunner;
|
|
28
|
+
options: RunnerOptions;
|
|
29
|
+
useWorker: boolean;
|
|
30
|
+
batches: number;
|
|
31
|
+
warmupBatch: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Run a benchmark suite with CLI arguments. */
|
|
35
|
+
export async function runBenchmarks(
|
|
36
|
+
suite: BenchSuite,
|
|
37
|
+
args: DefaultCliArgs,
|
|
38
|
+
): Promise<ReportGroup[]> {
|
|
39
|
+
validateArgs(args);
|
|
40
|
+
const { filter, worker: useWorker, batches = 1 } = args;
|
|
41
|
+
const warmupBatch = args["warmup-batch"] ?? false;
|
|
42
|
+
const options = cliToRunnerOptions(args);
|
|
43
|
+
const filtered = filterBenchmarks(suite, filter);
|
|
44
|
+
|
|
45
|
+
const runner = "timing";
|
|
46
|
+
const suiteParams: SuiteParams = {
|
|
47
|
+
runner,
|
|
48
|
+
options,
|
|
49
|
+
useWorker,
|
|
50
|
+
batches,
|
|
51
|
+
warmupBatch,
|
|
52
|
+
};
|
|
53
|
+
return serialMap(filtered.groups, g => runGroup(g, suiteParams));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Like Promise.all(arr.map(fn)) but runs one at a time. */
|
|
57
|
+
async function serialMap<T, R>(
|
|
58
|
+
arr: T[],
|
|
59
|
+
fn: (item: T) => Promise<R>,
|
|
60
|
+
): Promise<R[]> {
|
|
61
|
+
const results: R[] = [];
|
|
62
|
+
for (const item of arr) {
|
|
63
|
+
results.push(await fn(item));
|
|
64
|
+
}
|
|
65
|
+
return results;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Execute group with shared setup, optionally batching to reduce ordering bias. */
|
|
69
|
+
async function runGroup(
|
|
70
|
+
group: BenchGroup,
|
|
71
|
+
suiteParams: SuiteParams,
|
|
72
|
+
): Promise<ReportGroup> {
|
|
73
|
+
const { batches, warmupBatch, ...rest } = suiteParams;
|
|
74
|
+
const { name, benchmarks, baseline, setup, metadata } = group;
|
|
75
|
+
const setupParams = await setup?.();
|
|
76
|
+
validateBenchmarkParameters(group);
|
|
77
|
+
|
|
78
|
+
const runParams: RunParams = { ...rest, params: setupParams, metadata };
|
|
79
|
+
if (batches === 1)
|
|
80
|
+
return runSingleBatch(name, benchmarks, baseline, runParams);
|
|
81
|
+
return runMultipleBatches(
|
|
82
|
+
name,
|
|
83
|
+
benchmarks,
|
|
84
|
+
baseline,
|
|
85
|
+
runParams,
|
|
86
|
+
batches,
|
|
87
|
+
warmupBatch,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Warn if parameterized benchmarks lack a setup function. */
|
|
92
|
+
function validateBenchmarkParameters(group: BenchGroup): void {
|
|
93
|
+
if (group.setup) return;
|
|
94
|
+
const { benchmarks, baseline } = group;
|
|
95
|
+
const all = baseline ? [...benchmarks, baseline] : benchmarks;
|
|
96
|
+
for (const bench of all.filter(b => b.fn.length > 0)) {
|
|
97
|
+
console.warn(
|
|
98
|
+
`Benchmark "${bench.name}" in group "${group.name}" expects parameters but no setup() provided.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Run benchmarks in a single batch. */
|
|
104
|
+
async function runSingleBatch(
|
|
105
|
+
name: string,
|
|
106
|
+
benchmarks: BenchmarkSpec[],
|
|
107
|
+
baseline: BenchmarkSpec | undefined,
|
|
108
|
+
runParams: RunParams,
|
|
109
|
+
): Promise<ReportGroup> {
|
|
110
|
+
const baselineReport = baseline
|
|
111
|
+
? await runSingleBenchmark(baseline, runParams)
|
|
112
|
+
: undefined;
|
|
113
|
+
const reports = await serialMap(benchmarks, b =>
|
|
114
|
+
runSingleBenchmark(b, runParams),
|
|
115
|
+
);
|
|
116
|
+
return { name, reports, baseline: baselineReport };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Run benchmarks in multiple batches, alternating order to reduce bias. */
|
|
120
|
+
async function runMultipleBatches(
|
|
121
|
+
name: string,
|
|
122
|
+
benchmarks: BenchmarkSpec[],
|
|
123
|
+
baseline: BenchmarkSpec | undefined,
|
|
124
|
+
runParams: RunParams,
|
|
125
|
+
batches: number,
|
|
126
|
+
warmupBatch: boolean,
|
|
127
|
+
): Promise<ReportGroup> {
|
|
128
|
+
const { metadata } = runParams;
|
|
129
|
+
const run = (spec: BenchmarkSpec) => async () =>
|
|
130
|
+
(await runSingleBenchmark(spec, runParams)).measuredResults;
|
|
131
|
+
const runners = benchmarks.map(run);
|
|
132
|
+
const baselineFn = baseline ? run(baseline) : undefined;
|
|
133
|
+
|
|
134
|
+
const batched = await runBatched(runners, baselineFn, batches, warmupBatch);
|
|
135
|
+
const reports = benchmarks.map((b, i) => ({
|
|
136
|
+
name: b.name,
|
|
137
|
+
measuredResults: batched.results[i],
|
|
138
|
+
metadata,
|
|
139
|
+
}));
|
|
140
|
+
const baselineReport =
|
|
141
|
+
batched.baseline && baseline
|
|
142
|
+
? { name: baseline.name, measuredResults: batched.baseline, metadata }
|
|
143
|
+
: undefined;
|
|
144
|
+
return { name, reports, baseline: baselineReport };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Run single benchmark and create report. */
|
|
148
|
+
async function runSingleBenchmark(
|
|
149
|
+
spec: BenchmarkSpec,
|
|
150
|
+
{ runner, options, useWorker, params, metadata }: RunParams,
|
|
151
|
+
): Promise<BenchmarkReport> {
|
|
152
|
+
const [result] = await runBenchmark({
|
|
153
|
+
spec,
|
|
154
|
+
runner,
|
|
155
|
+
options,
|
|
156
|
+
useWorker,
|
|
157
|
+
params,
|
|
158
|
+
});
|
|
159
|
+
return { name: spec.name, measuredResults: result, metadata };
|
|
160
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
createServer,
|
|
4
|
+
type IncomingMessage,
|
|
5
|
+
type Server,
|
|
6
|
+
type ServerResponse,
|
|
7
|
+
} from "node:http";
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import open from "open";
|
|
11
|
+
import sirv from "sirv";
|
|
12
|
+
import {
|
|
13
|
+
buildArchiveObject,
|
|
14
|
+
collectProfileFrames,
|
|
15
|
+
collectSources,
|
|
16
|
+
defaultArchiveName,
|
|
17
|
+
fetchSource,
|
|
18
|
+
} from "../export/ArchiveExport.ts";
|
|
19
|
+
import {
|
|
20
|
+
archiveSchemaVersion,
|
|
21
|
+
migrateArchive,
|
|
22
|
+
} from "../export/ArchiveFormat.ts";
|
|
23
|
+
|
|
24
|
+
export interface ViewerServerOptions {
|
|
25
|
+
/** Speedscope JSON profile data (allocation) */
|
|
26
|
+
profileData?: string;
|
|
27
|
+
/** Speedscope JSON profile data (time/CPU) */
|
|
28
|
+
timeProfileData?: string;
|
|
29
|
+
/** Per-function coverage data (JSON-serialized Record<url, LineCoverage[]>) */
|
|
30
|
+
coverageData?: string;
|
|
31
|
+
/** HTML report JSON data */
|
|
32
|
+
reportData?: string;
|
|
33
|
+
/** Editor URI prefix for Cmd+Shift+click (e.g. "vscode://file") */
|
|
34
|
+
editorUri?: string;
|
|
35
|
+
/** Port to listen on (default 3939) */
|
|
36
|
+
port?: number;
|
|
37
|
+
/** Open browser on start (default true) */
|
|
38
|
+
open?: boolean;
|
|
39
|
+
/** Pre-loaded sources (e.g. from an archive) to seed the source cache */
|
|
40
|
+
sources?: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RouteHandler = (
|
|
44
|
+
res: ServerResponse,
|
|
45
|
+
query: string,
|
|
46
|
+
method: string,
|
|
47
|
+
) => Promise<void> | void;
|
|
48
|
+
|
|
49
|
+
/** Start the viewer HTTP server and open in browser. */
|
|
50
|
+
export async function startViewerServer(
|
|
51
|
+
options: ViewerServerOptions,
|
|
52
|
+
): Promise<{ server: Server; port: number; close: () => void }> {
|
|
53
|
+
const port = options.port ?? 3939;
|
|
54
|
+
|
|
55
|
+
const sourceCache = new Map(Object.entries(options.sources ?? {}));
|
|
56
|
+
|
|
57
|
+
const assets = sirv(join(packageRoot(), "dist/viewer"), { single: true });
|
|
58
|
+
const handler = createRequestHandler(options, sourceCache, assets);
|
|
59
|
+
const server = createServer(handler);
|
|
60
|
+
|
|
61
|
+
const bound = await tryListen(server, port);
|
|
62
|
+
const url = `http://localhost:${bound.port}`;
|
|
63
|
+
if (options.open !== false) await open(url);
|
|
64
|
+
console.log(`Viewer: ${url}`);
|
|
65
|
+
|
|
66
|
+
const close = () => {
|
|
67
|
+
bound.server.closeAllConnections();
|
|
68
|
+
bound.server.close();
|
|
69
|
+
};
|
|
70
|
+
return { server: bound.server, port: bound.port, close };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Open a .benchforge archive in the viewer. */
|
|
74
|
+
export async function viewArchive(filePath: string): Promise<void> {
|
|
75
|
+
const absPath = resolve(filePath);
|
|
76
|
+
const content = await readFile(absPath, "utf-8");
|
|
77
|
+
const raw = JSON.parse(content);
|
|
78
|
+
|
|
79
|
+
const schema = raw.schema ?? 0;
|
|
80
|
+
if (schema > archiveSchemaVersion) {
|
|
81
|
+
const msg = `Archive schema version ${schema} is newer than supported (${archiveSchemaVersion}).`;
|
|
82
|
+
console.error(`${msg} Please update benchforge to view this archive.`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const archive = migrateArchive(raw);
|
|
87
|
+
const sources = archive.sources as Record<string, string> | undefined;
|
|
88
|
+
const { close } = await startViewerServer({
|
|
89
|
+
profileData: optionalJson(archive.allocProfile),
|
|
90
|
+
timeProfileData: optionalJson(archive.timeProfile),
|
|
91
|
+
coverageData: optionalJson(archive.coverage),
|
|
92
|
+
reportData: optionalJson(archive.report),
|
|
93
|
+
sources,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await waitForCtrlC();
|
|
97
|
+
close();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Serialize a value to JSON if truthy, otherwise return undefined. */
|
|
101
|
+
export function optionalJson(v: unknown): string | undefined {
|
|
102
|
+
return v ? JSON.stringify(v) : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Wait for Ctrl+C (SIGINT) before resolving. */
|
|
106
|
+
export function waitForCtrlC(): Promise<void> {
|
|
107
|
+
return new Promise(resolve => {
|
|
108
|
+
console.log("\nPress Ctrl+C to exit");
|
|
109
|
+
process.once("SIGINT", () => {
|
|
110
|
+
console.log();
|
|
111
|
+
resolve();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Resolve the package root (dev: src/cli/ ==> up 2, dist: dist/ ==> up 1). */
|
|
117
|
+
function packageRoot(): string {
|
|
118
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
119
|
+
if (basename(thisDir) === "cli") return join(thisDir, "../..");
|
|
120
|
+
return join(thisDir, "..");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Build HTTP request handler with API routes and static asset fallback. */
|
|
124
|
+
function createRequestHandler(
|
|
125
|
+
ctx: ViewerServerOptions,
|
|
126
|
+
sourceCache: Map<string, string>,
|
|
127
|
+
assets: ReturnType<typeof sirv>,
|
|
128
|
+
): (req: IncomingMessage, res: ServerResponse) => void {
|
|
129
|
+
const routes: Record<string, RouteHandler> = {
|
|
130
|
+
"/api/config": res => {
|
|
131
|
+
const config = {
|
|
132
|
+
editorUri: ctx.editorUri || null,
|
|
133
|
+
hasReport: !!ctx.reportData,
|
|
134
|
+
hasProfile: !!ctx.profileData,
|
|
135
|
+
hasTimeProfile: !!ctx.timeProfileData,
|
|
136
|
+
hasCoverage: !!ctx.coverageData,
|
|
137
|
+
};
|
|
138
|
+
res.setHeader("Content-Type", "application/json");
|
|
139
|
+
res.end(JSON.stringify(config));
|
|
140
|
+
},
|
|
141
|
+
"/api/report-data": res => sendJson(res, ctx.reportData, "report data"),
|
|
142
|
+
"/api/coverage": res => sendJson(res, ctx.coverageData, "coverage data"),
|
|
143
|
+
"/api/profile": res => sendJson(res, ctx.profileData, "profile data", true),
|
|
144
|
+
"/api/profile/alloc": res =>
|
|
145
|
+
sendJson(res, ctx.profileData, "profile data", true),
|
|
146
|
+
"/api/profile/time": res =>
|
|
147
|
+
sendJson(res, ctx.timeProfileData, "time profile data", true),
|
|
148
|
+
"/api/source": (res, query) => handleSourceRequest(res, query, sourceCache),
|
|
149
|
+
"/api/archive": (res, _q, method) => {
|
|
150
|
+
if (method !== "POST") {
|
|
151
|
+
res.statusCode = 405;
|
|
152
|
+
return void res.end("Method not allowed");
|
|
153
|
+
}
|
|
154
|
+
return handleArchiveRequest(res, ctx, sourceCache);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return async (req, res) => {
|
|
159
|
+
const url = req.url || "/";
|
|
160
|
+
const qIdx = url.indexOf("?");
|
|
161
|
+
const pathname = qIdx >= 0 ? url.slice(0, qIdx) : url;
|
|
162
|
+
const query = qIdx >= 0 ? url.slice(qIdx + 1) : "";
|
|
163
|
+
|
|
164
|
+
const handler = routes[pathname];
|
|
165
|
+
if (handler) {
|
|
166
|
+
await handler(res, query, req.method || "GET");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
assets(req, res, () => {
|
|
171
|
+
res.statusCode = 404;
|
|
172
|
+
res.end("Not found");
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Listen on port, retrying on next port if EADDRINUSE. */
|
|
178
|
+
function tryListen(
|
|
179
|
+
server: Server,
|
|
180
|
+
port: number,
|
|
181
|
+
maxRetries = 10,
|
|
182
|
+
): Promise<{ server: Server; port: number }> {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
let attempt = 0;
|
|
185
|
+
const listen = (p: number) => {
|
|
186
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
187
|
+
if (err.code === "EADDRINUSE" && attempt < maxRetries) {
|
|
188
|
+
attempt++;
|
|
189
|
+
listen(p + 1);
|
|
190
|
+
} else {
|
|
191
|
+
reject(err);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
server.listen(p, () => {
|
|
195
|
+
server.removeAllListeners("error");
|
|
196
|
+
const addr = server.address();
|
|
197
|
+
const listenPort = typeof addr === "object" && addr ? addr.port : p;
|
|
198
|
+
resolve({ server, port: listenPort });
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
listen(port);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Send pre-serialized JSON or 404 if data is absent. */
|
|
206
|
+
function sendJson(
|
|
207
|
+
res: ServerResponse,
|
|
208
|
+
data: string | undefined,
|
|
209
|
+
label: string,
|
|
210
|
+
cors = false,
|
|
211
|
+
): void {
|
|
212
|
+
if (!data) {
|
|
213
|
+
res.statusCode = 404;
|
|
214
|
+
res.end(`No ${label}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
res.setHeader("Content-Type", "application/json");
|
|
218
|
+
if (cors) res.setHeader("Access-Control-Allow-Origin", "*");
|
|
219
|
+
res.end(data);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Fetch source text by URL query param, caching for subsequent requests. */
|
|
223
|
+
async function handleSourceRequest(
|
|
224
|
+
res: ServerResponse,
|
|
225
|
+
query: string,
|
|
226
|
+
cache: Map<string, string>,
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
const params = new URLSearchParams(query);
|
|
229
|
+
const sourceUrl = params.get("url");
|
|
230
|
+
if (!sourceUrl) {
|
|
231
|
+
res.statusCode = 400;
|
|
232
|
+
res.end("Missing url parameter");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
let source = cache.get(sourceUrl);
|
|
237
|
+
if (source === undefined) {
|
|
238
|
+
source = await fetchSource(sourceUrl);
|
|
239
|
+
if (source === undefined) throw new Error("not found");
|
|
240
|
+
cache.set(sourceUrl, source);
|
|
241
|
+
}
|
|
242
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
243
|
+
res.end(source);
|
|
244
|
+
} catch {
|
|
245
|
+
res.statusCode = 404;
|
|
246
|
+
res.end("Source unavailable");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Build a .benchforge archive from current session data and send as download. */
|
|
251
|
+
async function handleArchiveRequest(
|
|
252
|
+
res: ServerResponse,
|
|
253
|
+
ctx: ViewerServerOptions,
|
|
254
|
+
sourceCache: Map<string, string>,
|
|
255
|
+
): Promise<void> {
|
|
256
|
+
try {
|
|
257
|
+
const parse = (s?: string) => (s ? JSON.parse(s) : undefined);
|
|
258
|
+
const profile = parse(ctx.profileData);
|
|
259
|
+
const timeProfile = parse(ctx.timeProfileData);
|
|
260
|
+
const coverage = parse(ctx.coverageData);
|
|
261
|
+
const report = parse(ctx.reportData);
|
|
262
|
+
const allFrames = collectProfileFrames(profile, timeProfile);
|
|
263
|
+
const sources = allFrames.length
|
|
264
|
+
? await collectSources(allFrames, sourceCache)
|
|
265
|
+
: Object.fromEntries(sourceCache);
|
|
266
|
+
const { archive, timestamp } = buildArchiveObject({
|
|
267
|
+
allocProfile: profile,
|
|
268
|
+
timeProfile,
|
|
269
|
+
coverage,
|
|
270
|
+
report,
|
|
271
|
+
sources,
|
|
272
|
+
});
|
|
273
|
+
const body = JSON.stringify(archive);
|
|
274
|
+
const filename = defaultArchiveName(profile, timestamp);
|
|
275
|
+
res.setHeader("Content-Type", "application/json");
|
|
276
|
+
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
|
277
|
+
res.end(body);
|
|
278
|
+
} catch {
|
|
279
|
+
res.statusCode = 500;
|
|
280
|
+
res.end("Archive failed");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Heap profile export to Speedscope format. */
|
|
2
|
+
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import type { HeapProfile } from "../profiling/node/HeapSampler.ts";
|
|
6
|
+
import {
|
|
7
|
+
type ResolvedProfile,
|
|
8
|
+
resolveProfile,
|
|
9
|
+
} from "../profiling/node/ResolvedProfile.ts";
|
|
10
|
+
import { groupReports, type ReportGroup } from "../report/BenchmarkReport.ts";
|
|
11
|
+
import {
|
|
12
|
+
type FrameContext,
|
|
13
|
+
frameContext,
|
|
14
|
+
internFrame,
|
|
15
|
+
type SpeedscopeFile,
|
|
16
|
+
type SpeedscopeHeapProfile,
|
|
17
|
+
speedscopeFile,
|
|
18
|
+
} from "./SpeedscopeTypes.ts";
|
|
19
|
+
|
|
20
|
+
/** Export heap profiles to speedscope JSON. Returns output path, or undefined if no profiles. */
|
|
21
|
+
export function exportSpeedscope(
|
|
22
|
+
groups: ReportGroup[],
|
|
23
|
+
outputPath: string,
|
|
24
|
+
): string | undefined {
|
|
25
|
+
const file = buildSpeedscopeFile(groups);
|
|
26
|
+
if (!file) {
|
|
27
|
+
console.log("No heap profiles to export.");
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const absPath = resolve(outputPath);
|
|
32
|
+
writeFileSync(absPath, JSON.stringify(file));
|
|
33
|
+
console.log(`Speedscope profile exported to: ${outputPath}`);
|
|
34
|
+
return absPath;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Convert a single HeapProfile to speedscope format. */
|
|
38
|
+
export function heapProfileToSpeedscope(
|
|
39
|
+
name: string,
|
|
40
|
+
profile: HeapProfile,
|
|
41
|
+
): SpeedscopeFile {
|
|
42
|
+
const ctx = frameContext();
|
|
43
|
+
const p = buildProfile(name, resolveProfile(profile), ctx);
|
|
44
|
+
return speedscopeFile(ctx, [p]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build SpeedscopeFile from report groups. Returns undefined if no profiles found. */
|
|
48
|
+
export function buildSpeedscopeFile(
|
|
49
|
+
groups: ReportGroup[],
|
|
50
|
+
): SpeedscopeFile | undefined {
|
|
51
|
+
const ctx = frameContext();
|
|
52
|
+
const profiles: SpeedscopeHeapProfile[] = [];
|
|
53
|
+
|
|
54
|
+
for (const group of groups) {
|
|
55
|
+
for (const report of groupReports(group)) {
|
|
56
|
+
const { heapProfile } = report.measuredResults;
|
|
57
|
+
if (!heapProfile) continue;
|
|
58
|
+
const resolved = resolveProfile(heapProfile);
|
|
59
|
+
profiles.push(buildProfile(report.name, resolved, ctx));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (profiles.length === 0) return undefined;
|
|
64
|
+
|
|
65
|
+
return speedscopeFile(ctx, profiles);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build a single speedscope profile from a resolved heap profile. */
|
|
69
|
+
function buildProfile(
|
|
70
|
+
name: string,
|
|
71
|
+
resolved: ResolvedProfile,
|
|
72
|
+
ctx: FrameContext,
|
|
73
|
+
): SpeedscopeHeapProfile {
|
|
74
|
+
type Frame = { name: string; url: string; line: number; col?: number | null };
|
|
75
|
+
const intern = (f: Frame) => internFrame(f.name, f.url, f.line, f.col, ctx);
|
|
76
|
+
|
|
77
|
+
const nodeStacks = new Map(
|
|
78
|
+
resolved.nodes.map(node => [node.nodeId, node.stack.map(intern)] as const),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (!resolved.sortedSamples?.length) {
|
|
82
|
+
console.error(
|
|
83
|
+
`Speedscope export: no samples in heap profile for "${name}", skipping`,
|
|
84
|
+
);
|
|
85
|
+
return emptyProfile(name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const samples: number[][] = [];
|
|
89
|
+
const weights: number[] = [];
|
|
90
|
+
for (const sample of resolved.sortedSamples) {
|
|
91
|
+
const stack = nodeStacks.get(sample.nodeId);
|
|
92
|
+
if (stack) {
|
|
93
|
+
samples.push(stack);
|
|
94
|
+
weights.push(sample.size);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const totalBytes = weights.reduce((sum, w) => sum + w, 0);
|
|
99
|
+
return {
|
|
100
|
+
type: "sampled",
|
|
101
|
+
name,
|
|
102
|
+
unit: "bytes",
|
|
103
|
+
startValue: 0,
|
|
104
|
+
endValue: totalBytes,
|
|
105
|
+
samples,
|
|
106
|
+
weights,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Placeholder profile with no samples (used when heap data is missing). */
|
|
111
|
+
function emptyProfile(name: string): SpeedscopeHeapProfile {
|
|
112
|
+
return {
|
|
113
|
+
type: "sampled",
|
|
114
|
+
name,
|
|
115
|
+
unit: "bytes",
|
|
116
|
+
startValue: 0,
|
|
117
|
+
endValue: 0,
|
|
118
|
+
samples: [],
|
|
119
|
+
weights: [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** .benchforge archive creation, source collection, and archive filename derivation. */
|
|
2
|
+
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import type { ReportGroup } from "../report/BenchmarkReport.ts";
|
|
8
|
+
import type { ReportData } from "../viewer/ReportData.ts";
|
|
9
|
+
import { buildSpeedscopeFile } from "./AllocExport.ts";
|
|
10
|
+
import {
|
|
11
|
+
archiveSchemaVersion,
|
|
12
|
+
type BenchforgeArchive,
|
|
13
|
+
} from "./ArchiveFormat.ts";
|
|
14
|
+
import type { LineCoverage } from "./CoverageExport.ts";
|
|
15
|
+
import type { SpeedscopeFile } from "./SpeedscopeTypes.ts";
|
|
16
|
+
|
|
17
|
+
export interface ArchiveOptions {
|
|
18
|
+
groups: ReportGroup[];
|
|
19
|
+
reportData?: ReportData;
|
|
20
|
+
timeProfileData?: string;
|
|
21
|
+
coverageData?: string;
|
|
22
|
+
outputPath?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ArchiveInput {
|
|
26
|
+
allocProfile?: SpeedscopeFile;
|
|
27
|
+
timeProfile?: SpeedscopeFile;
|
|
28
|
+
coverage?: Record<string, LineCoverage[]>;
|
|
29
|
+
report?: ReportData;
|
|
30
|
+
sources: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Build a .benchforge archive file. Returns output path, or undefined if nothing to archive. */
|
|
34
|
+
export async function archiveBenchmark(
|
|
35
|
+
options: ArchiveOptions,
|
|
36
|
+
): Promise<string | undefined> {
|
|
37
|
+
const { groups, reportData, timeProfileData, coverageData, outputPath } =
|
|
38
|
+
options;
|
|
39
|
+
const allocProfile = buildSpeedscopeFile(groups) ?? undefined;
|
|
40
|
+
const timeProfile = timeProfileData ? JSON.parse(timeProfileData) : undefined;
|
|
41
|
+
if (!allocProfile && !timeProfile && !reportData) {
|
|
42
|
+
console.log("No data to archive.");
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const allFrames = collectProfileFrames(allocProfile, timeProfile);
|
|
47
|
+
const sources = allFrames.length ? await collectSources(allFrames) : {};
|
|
48
|
+
const coverage = coverageData ? JSON.parse(coverageData) : undefined;
|
|
49
|
+
const input: ArchiveInput = {
|
|
50
|
+
allocProfile,
|
|
51
|
+
timeProfile,
|
|
52
|
+
coverage,
|
|
53
|
+
report: reportData,
|
|
54
|
+
sources,
|
|
55
|
+
};
|
|
56
|
+
const { archive, timestamp } = buildArchiveObject(input);
|
|
57
|
+
const filename = outputPath || defaultArchiveName(allocProfile, timestamp);
|
|
58
|
+
const absPath = resolve(filename);
|
|
59
|
+
writeFileSync(absPath, JSON.stringify(archive));
|
|
60
|
+
console.log(`Archive written to: ${filename}`);
|
|
61
|
+
return absPath;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildArchiveObject(input: ArchiveInput): {
|
|
65
|
+
archive: BenchforgeArchive;
|
|
66
|
+
timestamp: string;
|
|
67
|
+
} {
|
|
68
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
69
|
+
const archive = {
|
|
70
|
+
schema: archiveSchemaVersion,
|
|
71
|
+
allocProfile: input.allocProfile,
|
|
72
|
+
timeProfile: input.timeProfile,
|
|
73
|
+
coverage: input.coverage,
|
|
74
|
+
report: input.report,
|
|
75
|
+
sources: input.sources,
|
|
76
|
+
metadata: {
|
|
77
|
+
timestamp,
|
|
78
|
+
benchforgeVersion: process.env.npm_package_version || "unknown",
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
return { archive, timestamp };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function collectProfileFrames(
|
|
85
|
+
allocProfile: SpeedscopeFile | null | undefined,
|
|
86
|
+
timeProfile: { shared?: { frames: { file?: string }[] } } | null | undefined,
|
|
87
|
+
): { file?: string }[] {
|
|
88
|
+
const heapFrames = allocProfile?.shared?.frames ?? [];
|
|
89
|
+
const timeFrames = timeProfile?.shared?.frames ?? [];
|
|
90
|
+
return [...heapFrames, ...timeFrames];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Fetch source code for all unique file URLs in profile frames. */
|
|
94
|
+
export async function collectSources(
|
|
95
|
+
frames: { file?: string }[],
|
|
96
|
+
cache?: Map<string, string>,
|
|
97
|
+
): Promise<Record<string, string>> {
|
|
98
|
+
const urls = new Set(frames.map(f => f.file).filter((u): u is string => !!u));
|
|
99
|
+
|
|
100
|
+
const sources: Record<string, string> = {};
|
|
101
|
+
for (const url of urls) {
|
|
102
|
+
const cached = cache?.get(url);
|
|
103
|
+
const text = cached ?? (await fetchSource(url));
|
|
104
|
+
if (text === undefined) continue;
|
|
105
|
+
sources[url] = text;
|
|
106
|
+
if (!cached) cache?.set(url, text);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return sources;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Derive archive filename from profile (or generic fallback). */
|
|
113
|
+
export function defaultArchiveName(
|
|
114
|
+
profile: SpeedscopeFile | null | undefined,
|
|
115
|
+
timestamp: string,
|
|
116
|
+
): string {
|
|
117
|
+
return profile
|
|
118
|
+
? archiveFileName(profile, timestamp)
|
|
119
|
+
: `benchforge-${timestamp}.benchforge`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Fetch source text from a file:// or http(s):// URL. */
|
|
123
|
+
export async function fetchSource(url: string): Promise<string | undefined> {
|
|
124
|
+
try {
|
|
125
|
+
if (url.startsWith("file://")) {
|
|
126
|
+
return await readFile(fileURLToPath(url), "utf-8");
|
|
127
|
+
}
|
|
128
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
129
|
+
if (!resp.ok) return undefined;
|
|
130
|
+
return await resp.text();
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Derive an archive filename from the profile name (sanitizes URLs to safe filenames). */
|
|
137
|
+
function archiveFileName(file: SpeedscopeFile, timestamp: string): string {
|
|
138
|
+
const raw = file.profiles[0]?.name || "profile";
|
|
139
|
+
const sanitized = raw
|
|
140
|
+
.replace(/^https?:\/\//, "")
|
|
141
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
142
|
+
.replace(/-+/g, "-")
|
|
143
|
+
.replace(/^-|-$/g, "");
|
|
144
|
+
const base = sanitized || "profile";
|
|
145
|
+
return `${base}-${timestamp}.benchforge`;
|
|
146
|
+
}
|