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,50 @@
|
|
|
1
|
+
/** Serialized `.benchforge` archive format. */
|
|
2
|
+
|
|
3
|
+
import type { ReportData } from "../viewer/ReportData.ts";
|
|
4
|
+
import type { LineCoverage } from "./CoverageExport.ts";
|
|
5
|
+
import type { SpeedscopeFile } from "./SpeedscopeTypes.ts";
|
|
6
|
+
|
|
7
|
+
export interface BenchforgeArchive {
|
|
8
|
+
/** Archive format version. */
|
|
9
|
+
schema: number;
|
|
10
|
+
|
|
11
|
+
/** Heap allocation profile in Speedscope format. */
|
|
12
|
+
allocProfile?: SpeedscopeFile;
|
|
13
|
+
|
|
14
|
+
/** CPU time profile in Speedscope format. */
|
|
15
|
+
timeProfile?: SpeedscopeFile;
|
|
16
|
+
|
|
17
|
+
/** Per-line coverage data keyed by source URL. */
|
|
18
|
+
coverage?: Record<string, LineCoverage[]>;
|
|
19
|
+
|
|
20
|
+
/** Benchmark report with suite results and statistics. */
|
|
21
|
+
report?: ReportData;
|
|
22
|
+
|
|
23
|
+
/** Source file contents keyed by file URL. */
|
|
24
|
+
sources: Record<string, string>;
|
|
25
|
+
|
|
26
|
+
/** Archive creation metadata. */
|
|
27
|
+
metadata: ArchiveMetadata;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ArchiveMetadata {
|
|
31
|
+
/** ISO timestamp (colons/periods replaced with dashes for filename safety). */
|
|
32
|
+
timestamp: string;
|
|
33
|
+
|
|
34
|
+
/** Benchforge package version. */
|
|
35
|
+
benchforgeVersion: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const archiveSchemaVersion = 2;
|
|
39
|
+
|
|
40
|
+
/** Migrate a parsed archive from older schema versions to current. */
|
|
41
|
+
export function migrateArchive(
|
|
42
|
+
raw: Record<string, unknown>,
|
|
43
|
+
): Partial<BenchforgeArchive> {
|
|
44
|
+
const schema = (raw.schema as number) ?? 0;
|
|
45
|
+
if (schema <= 1 && "profile" in raw && !("allocProfile" in raw)) {
|
|
46
|
+
raw.allocProfile = raw.profile;
|
|
47
|
+
delete raw.profile;
|
|
48
|
+
}
|
|
49
|
+
return raw as Partial<BenchforgeArchive>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** Line-level coverage maps from V8/CDP coverage data and frame annotation with execution counts. */
|
|
2
|
+
|
|
3
|
+
import type { CoverageData } from "../profiling/node/CoverageTypes.ts";
|
|
4
|
+
|
|
5
|
+
/** Per-function execution count at a specific source line. */
|
|
6
|
+
export interface LineCoverage {
|
|
7
|
+
/** 1-indexed line number of the function start */
|
|
8
|
+
startLine: number;
|
|
9
|
+
/** Function name (empty string for anonymous top-level) */
|
|
10
|
+
functionName: string;
|
|
11
|
+
/** Number of times the function was invoked */
|
|
12
|
+
count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Map from source URL to per-function execution counts. */
|
|
16
|
+
export type CoverageMap = Map<string, LineCoverage[]>;
|
|
17
|
+
|
|
18
|
+
/** Coverage data: per-URL entries and a name-only fallback lookup. */
|
|
19
|
+
export interface CoverageResult {
|
|
20
|
+
/** Per-URL coverage entries (for frames with matching file URLs) */
|
|
21
|
+
map: CoverageMap;
|
|
22
|
+
/** Name ==> count lookup across all scripts (for frames without file URLs) */
|
|
23
|
+
byName: Map<string, number>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Build coverage data from raw CDP/inspector coverage and source texts. */
|
|
27
|
+
export function buildCoverageMap(
|
|
28
|
+
coverage: CoverageData,
|
|
29
|
+
sources: Record<string, string>,
|
|
30
|
+
): CoverageResult {
|
|
31
|
+
const map: CoverageMap = new Map();
|
|
32
|
+
const byName = new Map<string, number>();
|
|
33
|
+
|
|
34
|
+
for (const script of coverage.scripts) {
|
|
35
|
+
processScript(script, sources, map, byName);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { map, byName };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Annotate speedscope frame names with execution counts (e.g. "fn [1.2K]"). */
|
|
42
|
+
export function annotateFramesWithCounts(
|
|
43
|
+
frames: { name: string; file?: string; line?: number }[],
|
|
44
|
+
coverage: CoverageResult,
|
|
45
|
+
): void {
|
|
46
|
+
for (const frame of frames) {
|
|
47
|
+
const entries = frame.file ? coverage.map.get(frame.file) : undefined;
|
|
48
|
+
const count = entries && findCount(frame.name, frame.line, entries);
|
|
49
|
+
const isAnon = frame.name.startsWith("(anonymous");
|
|
50
|
+
const resolved =
|
|
51
|
+
count ?? (isAnon ? undefined : coverage.byName.get(frame.name));
|
|
52
|
+
if (resolved !== undefined && resolved > 0)
|
|
53
|
+
frame.name = `${frame.name} [${formatCount(resolved)}]`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract per-function coverage entries from a single script. */
|
|
58
|
+
function processScript(
|
|
59
|
+
script: CoverageData["scripts"][number],
|
|
60
|
+
sources: Record<string, string>,
|
|
61
|
+
map: CoverageMap,
|
|
62
|
+
byName: Map<string, number>,
|
|
63
|
+
): void {
|
|
64
|
+
const { url, functions } = script;
|
|
65
|
+
const source = url ? sources[url] : undefined;
|
|
66
|
+
const lineOffsets = source ? buildLineOffsets(source) : undefined;
|
|
67
|
+
const entries: LineCoverage[] = [];
|
|
68
|
+
|
|
69
|
+
for (const fn of functions) {
|
|
70
|
+
const range = fn.ranges[0];
|
|
71
|
+
if (!range) continue;
|
|
72
|
+
|
|
73
|
+
if (lineOffsets && url) {
|
|
74
|
+
entries.push({
|
|
75
|
+
startLine: offsetToLine(range.startOffset, lineOffsets),
|
|
76
|
+
functionName: fn.functionName,
|
|
77
|
+
count: range.count,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (fn.functionName && range.count > 0) {
|
|
82
|
+
const prev = byName.get(fn.functionName) ?? 0;
|
|
83
|
+
byName.set(fn.functionName, prev + range.count);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (entries.length > 0 && url) map.set(url, entries);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Match a frame to a coverage entry by function name (or closest line for anonymous). */
|
|
91
|
+
function findCount(
|
|
92
|
+
frameName: string,
|
|
93
|
+
frameLine: number | undefined,
|
|
94
|
+
entries: LineCoverage[],
|
|
95
|
+
): number | undefined {
|
|
96
|
+
const isAnon =
|
|
97
|
+
frameName === "(anonymous)" || frameName.startsWith("(anonymous ");
|
|
98
|
+
|
|
99
|
+
if (isAnon) {
|
|
100
|
+
if (!frameLine) return undefined;
|
|
101
|
+
const anonymous = entries.filter(e => e.functionName === "");
|
|
102
|
+
return closestByLine(anonymous, frameLine)?.count;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const nameMatches = entries.filter(e => e.functionName === frameName);
|
|
106
|
+
if (nameMatches.length === 0) return undefined;
|
|
107
|
+
if (nameMatches.length === 1) return nameMatches[0].count;
|
|
108
|
+
if (frameLine) return closestByLine(nameMatches, frameLine)?.count;
|
|
109
|
+
return nameMatches[0].count;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Format a count for display (e.g. 1234567 ==> "1.2M"). */
|
|
113
|
+
function formatCount(n: number): string {
|
|
114
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
115
|
+
if (n >= 10_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
116
|
+
return String(n);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Build array where index i is the character offset where line (i+1) starts. */
|
|
120
|
+
function buildLineOffsets(source: string): number[] {
|
|
121
|
+
const offsets = [0]; // line 1 starts at offset 0
|
|
122
|
+
for (let i = 0; i < source.length; i++) {
|
|
123
|
+
if (source[i] === "\n") offsets.push(i + 1);
|
|
124
|
+
}
|
|
125
|
+
return offsets;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Convert character offset to 1-indexed line number via binary search. */
|
|
129
|
+
function offsetToLine(offset: number, lineOffsets: number[]): number {
|
|
130
|
+
let lo = 0;
|
|
131
|
+
let hi = lineOffsets.length - 1;
|
|
132
|
+
while (lo < hi) {
|
|
133
|
+
const mid = (lo + hi + 1) >> 1;
|
|
134
|
+
if (lineOffsets[mid] <= offset) lo = mid;
|
|
135
|
+
else hi = mid - 1;
|
|
136
|
+
}
|
|
137
|
+
return lo + 1; // 1-indexed
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Find the entry whose startLine is closest to the given line. */
|
|
141
|
+
function closestByLine(
|
|
142
|
+
entries: LineCoverage[],
|
|
143
|
+
line: number,
|
|
144
|
+
): LineCoverage | undefined {
|
|
145
|
+
if (!entries.length) return undefined;
|
|
146
|
+
const dist = (e: LineCoverage) => Math.abs(e.startLine - line);
|
|
147
|
+
return entries.reduce((best, e) => (dist(e) < dist(best) ? e : best));
|
|
148
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const presets: Record<string, string> = {
|
|
2
|
+
vscode: "vscode://file",
|
|
3
|
+
cursor: "cursor://file",
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
/** Resolve editor name or custom URI to a prefix.
|
|
7
|
+
* Links are formatted as `{prefix}{absolutePath}:{line}:{col}` */
|
|
8
|
+
export function resolveEditorUri(editor: string): string {
|
|
9
|
+
return presets[editor] ?? editor;
|
|
10
|
+
}
|
|
@@ -1,28 +1,19 @@
|
|
|
1
|
+
/** Export benchmark samples to Chrome Trace Event format for viewing in Perfetto. */
|
|
2
|
+
|
|
1
3
|
import { spawn } from "node:child_process";
|
|
2
4
|
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
5
|
import { resolve } from "node:path";
|
|
4
|
-
import type
|
|
5
|
-
import type {
|
|
6
|
-
import type {
|
|
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
|
-
}
|
|
6
|
+
import { cleanCliArgs, type DefaultCliArgs } from "../cli/CliArgs.ts";
|
|
7
|
+
import type { TraceEvent } from "../profiling/browser/ChromeTraceEvent.ts";
|
|
8
|
+
import type { ReportGroup } from "../report/BenchmarkReport.ts";
|
|
9
|
+
import type { MeasuredResults } from "../runners/MeasuredResults.ts";
|
|
20
10
|
|
|
21
|
-
/** Chrome Trace Event format file structure */
|
|
22
11
|
interface TraceFile {
|
|
23
12
|
traceEvents: TraceEvent[];
|
|
24
13
|
}
|
|
25
14
|
|
|
15
|
+
type Args = Record<string, unknown>;
|
|
16
|
+
|
|
26
17
|
const pid = 1;
|
|
27
18
|
const tid = 1;
|
|
28
19
|
|
|
@@ -35,73 +26,52 @@ export function exportPerfettoTrace(
|
|
|
35
26
|
const absPath = resolve(outputPath);
|
|
36
27
|
const events = buildTraceEvents(groups, args);
|
|
37
28
|
|
|
38
|
-
// Try to merge any existing V8 trace from a previous run
|
|
39
29
|
const merged = mergeV8Trace(events);
|
|
40
|
-
|
|
30
|
+
const traceFile: TraceFile = { traceEvents: merged };
|
|
31
|
+
writeFileSync(absPath, JSON.stringify(traceFile));
|
|
41
32
|
console.log(`Perfetto trace exported to: ${outputPath}`);
|
|
42
33
|
|
|
43
|
-
// V8 writes trace files after process exit, so spawn a child to merge later
|
|
44
34
|
scheduleDeferredMerge(absPath);
|
|
45
35
|
}
|
|
46
36
|
|
|
47
|
-
/** Build trace events from benchmark results */
|
|
48
37
|
function buildTraceEvents(
|
|
49
38
|
groups: ReportGroup[],
|
|
50
|
-
|
|
39
|
+
cliArgs: DefaultCliArgs,
|
|
51
40
|
): TraceEvent[] {
|
|
52
|
-
const
|
|
53
|
-
ph: "M",
|
|
54
|
-
ts: 0,
|
|
55
|
-
pid,
|
|
56
|
-
tid,
|
|
57
|
-
name,
|
|
58
|
-
args: a,
|
|
59
|
-
});
|
|
60
|
-
const events: TraceEvent[] = [
|
|
41
|
+
const metadata: TraceEvent[] = [
|
|
61
42
|
meta("process_name", { name: "wesl-bench" }),
|
|
62
43
|
meta("thread_name", { name: "MainThread" }),
|
|
63
|
-
meta("bench_settings",
|
|
44
|
+
meta("bench_settings", cleanCliArgs(cliArgs)),
|
|
64
45
|
];
|
|
65
46
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
47
|
+
const benchEvents = groups.flatMap(group =>
|
|
48
|
+
group.reports.flatMap(report =>
|
|
49
|
+
buildBenchmarkEvents(report.measuredResults as MeasuredResults),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
72
52
|
|
|
73
|
-
return
|
|
53
|
+
return [...metadata, ...benchEvents];
|
|
74
54
|
}
|
|
75
55
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const traceFiles = readdirSync(".").filter(
|
|
56
|
+
function mergeV8Trace(events: TraceEvent[]): TraceEvent[] {
|
|
57
|
+
const v8TracePath = readdirSync(".").find(
|
|
79
58
|
f => f.startsWith("node_trace.") && f.endsWith(".log"),
|
|
80
59
|
);
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
normalizeTimestamps(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
normalizeTimestamps(v8Events);
|
|
87
|
-
return [...v8Events, ...customEvents];
|
|
60
|
+
const v8Events = loadV8Events(v8TracePath);
|
|
61
|
+
const merged = v8Events ? [...v8Events, ...events] : events;
|
|
62
|
+
normalizeTimestamps(merged);
|
|
63
|
+
return merged;
|
|
88
64
|
}
|
|
89
65
|
|
|
90
|
-
/**
|
|
91
|
-
function writeTraceFile(outputPath: string, events: TraceEvent[]): void {
|
|
92
|
-
const traceFile: TraceFile = { traceEvents: events };
|
|
93
|
-
writeFileSync(outputPath, JSON.stringify(traceFile));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Spawn a detached child to merge V8 trace after process exit */
|
|
66
|
+
/** V8 writes trace files after process exit, so we spawn a deferred merge. */
|
|
97
67
|
function scheduleDeferredMerge(outputPath: string): void {
|
|
98
68
|
const cwd = process.cwd();
|
|
99
69
|
const mergeScript = `
|
|
100
70
|
const { readdirSync, readFileSync, writeFileSync } = require('fs');
|
|
101
71
|
function normalize(events) {
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
72
|
+
let min = Infinity;
|
|
73
|
+
for (const e of events) if (e.ts > 0 && e.ts < min) min = e.ts;
|
|
74
|
+
if (min === Infinity) return;
|
|
105
75
|
for (const e of events) if (e.ts > 0) e.ts -= min;
|
|
106
76
|
}
|
|
107
77
|
setTimeout(() => {
|
|
@@ -110,38 +80,30 @@ function scheduleDeferredMerge(outputPath: string): void {
|
|
|
110
80
|
try {
|
|
111
81
|
const v8Data = JSON.parse(readFileSync(traceFiles[0], 'utf-8'));
|
|
112
82
|
const ourData = JSON.parse(readFileSync('${outputPath}', 'utf-8'));
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
writeFileSync('${outputPath}', JSON.stringify(
|
|
83
|
+
const allEvents = [...v8Data.traceEvents, ...ourData.traceEvents];
|
|
84
|
+
normalize(allEvents);
|
|
85
|
+
writeFileSync('${outputPath}', JSON.stringify({ traceEvents: allEvents }));
|
|
116
86
|
console.log('Merged ' + v8Data.traceEvents.length + ' V8 events into ' + '${outputPath}');
|
|
117
87
|
} catch (e) { console.error('Merge failed:', e.message); }
|
|
118
88
|
}, 100);
|
|
119
89
|
`;
|
|
120
90
|
|
|
121
91
|
process.on("exit", () => {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
stdio: "inherit",
|
|
125
|
-
cwd,
|
|
126
|
-
});
|
|
127
|
-
child.unref();
|
|
92
|
+
const opts = { detached: true, stdio: "inherit" as const, cwd };
|
|
93
|
+
spawn("node", ["-e", mergeScript], opts).unref();
|
|
128
94
|
});
|
|
129
95
|
}
|
|
130
96
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const skip = new Set(["_", "$0"]);
|
|
134
|
-
const entries = Object.entries(args).filter(
|
|
135
|
-
([k, v]) => v !== undefined && !skip.has(k),
|
|
136
|
-
);
|
|
137
|
-
return Object.fromEntries(entries);
|
|
97
|
+
function meta(name: string, args: Args): TraceEvent {
|
|
98
|
+
return { ph: "M", ts: 0, pid, tid, name, args };
|
|
138
99
|
}
|
|
139
100
|
|
|
140
|
-
/** Build events for a single benchmark run */
|
|
101
|
+
/** Build events for a single benchmark run, deriving timestamps from cumulative sample durations. */
|
|
141
102
|
function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
|
|
142
|
-
const { samples, heapSamples,
|
|
143
|
-
if (!
|
|
103
|
+
const { samples, heapSamples, pausePoints, startTime = 0 } = results;
|
|
104
|
+
if (!samples?.length) return [];
|
|
144
105
|
|
|
106
|
+
const timestamps = cumulativeTimestamps(samples, startTime);
|
|
145
107
|
const events: TraceEvent[] = [];
|
|
146
108
|
for (let i = 0; i < samples.length; i++) {
|
|
147
109
|
const ts = timestamps[i];
|
|
@@ -149,8 +111,8 @@ function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
|
|
|
149
111
|
events.push(instant(ts, results.name, { n: i, ms }));
|
|
150
112
|
events.push(counter(ts, "duration", { ms }));
|
|
151
113
|
if (heapSamples?.[i] !== undefined) {
|
|
152
|
-
const
|
|
153
|
-
events.push(counter(ts, "heap", { MB }));
|
|
114
|
+
const mb = Math.round((heapSamples[i] / 1024 / 1024) * 10) / 10;
|
|
115
|
+
events.push(counter(ts, "heap", { MB: mb }));
|
|
154
116
|
}
|
|
155
117
|
}
|
|
156
118
|
|
|
@@ -161,17 +123,15 @@ function buildBenchmarkEvents(results: MeasuredResults): TraceEvent[] {
|
|
|
161
123
|
return events;
|
|
162
124
|
}
|
|
163
125
|
|
|
164
|
-
/** Load V8 trace events from file, or undefined if unavailable */
|
|
165
126
|
function loadV8Events(
|
|
166
127
|
v8TracePath: string | undefined,
|
|
167
128
|
): TraceEvent[] | undefined {
|
|
168
129
|
if (!v8TracePath) return undefined;
|
|
169
130
|
try {
|
|
170
131
|
const v8Data = JSON.parse(readFileSync(v8TracePath, "utf-8")) as TraceFile;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return v8Data.traceEvents;
|
|
132
|
+
const { traceEvents } = v8Data;
|
|
133
|
+
console.log(`Merged ${traceEvents.length} V8 events from ${v8TracePath}`);
|
|
134
|
+
return traceEvents;
|
|
175
135
|
} catch {
|
|
176
136
|
console.warn(`Could not parse V8 trace file: ${v8TracePath}`);
|
|
177
137
|
return undefined;
|
|
@@ -180,24 +140,29 @@ function loadV8Events(
|
|
|
180
140
|
|
|
181
141
|
/** Normalize timestamps so events start at 0 */
|
|
182
142
|
function normalizeTimestamps(events: TraceEvent[]): void {
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
for (const e of events) if (e.ts > 0) e.ts -=
|
|
143
|
+
let min = Number.POSITIVE_INFINITY;
|
|
144
|
+
for (const e of events) if (e.ts > 0 && e.ts < min) min = e.ts;
|
|
145
|
+
if (min === Number.POSITIVE_INFINITY) return;
|
|
146
|
+
for (const e of events) if (e.ts > 0) e.ts -= min;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Derive μs timestamps from cumulative sample durations (ms), offset by startTime. */
|
|
150
|
+
function cumulativeTimestamps(samples: number[], offset = 0): number[] {
|
|
151
|
+
const timestamps = new Array<number>(samples.length);
|
|
152
|
+
let cumulative = 0;
|
|
153
|
+
for (let i = 0; i < samples.length; i++) {
|
|
154
|
+
cumulative += samples[i];
|
|
155
|
+
timestamps[i] = offset + Math.round(cumulative * 1000); // ms ==> μs
|
|
156
|
+
}
|
|
157
|
+
return timestamps;
|
|
187
158
|
}
|
|
188
159
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
name: string,
|
|
192
|
-
args: Record<string, unknown>,
|
|
193
|
-
): TraceEvent {
|
|
160
|
+
/** Create a thread-scoped instant event */
|
|
161
|
+
function instant(ts: number, name: string, args: Args): TraceEvent {
|
|
194
162
|
return { ph: "i", ts, pid, tid, cat: "bench", name, s: "t", args };
|
|
195
163
|
}
|
|
196
164
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
name: string,
|
|
200
|
-
args: Record<string, unknown>,
|
|
201
|
-
): TraceEvent {
|
|
165
|
+
/** Create a counter event (shown as a time-series chart in Perfetto) */
|
|
166
|
+
function counter(ts: number, name: string, args: Args): TraceEvent {
|
|
202
167
|
return { ph: "C", ts, pid, tid, cat: "bench", name, args };
|
|
203
168
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Shared speedscope file format types and frame interning utilities. */
|
|
2
|
+
|
|
3
|
+
/** speedscope file format (https://www.speedscope.app/file-format-schema.json) */
|
|
4
|
+
export interface SpeedscopeFile {
|
|
5
|
+
$schema: "https://www.speedscope.app/file-format-schema.json";
|
|
6
|
+
shared: { frames: SpeedscopeFrame[] };
|
|
7
|
+
profiles: SpeedscopeProfile[];
|
|
8
|
+
name?: string;
|
|
9
|
+
exporter?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A single call frame with optional source location */
|
|
13
|
+
export interface SpeedscopeFrame {
|
|
14
|
+
name: string;
|
|
15
|
+
file?: string;
|
|
16
|
+
line?: number;
|
|
17
|
+
col?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Union of heap and time profile shapes (unit differs) */
|
|
21
|
+
export type SpeedscopeProfile = SpeedscopeHeapProfile | SpeedscopeTimeProfile;
|
|
22
|
+
|
|
23
|
+
/** Heap allocation profile weighted by bytes */
|
|
24
|
+
export interface SpeedscopeHeapProfile {
|
|
25
|
+
type: "sampled";
|
|
26
|
+
name: string;
|
|
27
|
+
unit: "bytes";
|
|
28
|
+
startValue: number;
|
|
29
|
+
endValue: number;
|
|
30
|
+
samples: number[][];
|
|
31
|
+
weights: number[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** CPU time profile weighted by microseconds */
|
|
35
|
+
export interface SpeedscopeTimeProfile {
|
|
36
|
+
type: "sampled";
|
|
37
|
+
name: string;
|
|
38
|
+
unit: "microseconds";
|
|
39
|
+
startValue: number;
|
|
40
|
+
endValue: number;
|
|
41
|
+
samples: number[][];
|
|
42
|
+
weights: number[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Shared mutable state for frame interning across profiles. */
|
|
46
|
+
export interface FrameContext {
|
|
47
|
+
frames: SpeedscopeFrame[];
|
|
48
|
+
index: Map<string, number>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Create an empty FrameContext for building speedscope profiles. */
|
|
52
|
+
export function frameContext(): FrameContext {
|
|
53
|
+
return { frames: [], index: new Map() };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Wrap profiles in a SpeedscopeFile envelope */
|
|
57
|
+
export function speedscopeFile(
|
|
58
|
+
ctx: FrameContext,
|
|
59
|
+
profiles: SpeedscopeProfile[],
|
|
60
|
+
): SpeedscopeFile {
|
|
61
|
+
return {
|
|
62
|
+
$schema: "https://www.speedscope.app/file-format-schema.json",
|
|
63
|
+
shared: { frames: ctx.frames },
|
|
64
|
+
profiles,
|
|
65
|
+
exporter: "benchforge",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Intern a call frame, returning its index in the shared frames array.
|
|
70
|
+
* All values should be 1-indexed (caller converts from V8's 0-indexed if needed). */
|
|
71
|
+
export function internFrame(
|
|
72
|
+
name: string,
|
|
73
|
+
url: string,
|
|
74
|
+
line: number,
|
|
75
|
+
col: number | undefined | null,
|
|
76
|
+
ctx: FrameContext,
|
|
77
|
+
): number {
|
|
78
|
+
const key = `${name}\0${url}\0${line}\0${col}`;
|
|
79
|
+
|
|
80
|
+
const existing = ctx.index.get(key);
|
|
81
|
+
if (existing !== undefined) return existing;
|
|
82
|
+
|
|
83
|
+
const idx = ctx.frames.length;
|
|
84
|
+
const entry: SpeedscopeFrame = { name: displayName(name, url, line) };
|
|
85
|
+
if (url) entry.file = url;
|
|
86
|
+
if (line > 0) entry.line = line;
|
|
87
|
+
if (col != null) entry.col = col;
|
|
88
|
+
ctx.frames.push(entry);
|
|
89
|
+
ctx.index.set(key, idx);
|
|
90
|
+
return idx;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Display name for a frame: named functions use their name, anonymous get a location hint */
|
|
94
|
+
function displayName(name: string, url: string, line: number): string {
|
|
95
|
+
if (name !== "(anonymous)") return name;
|
|
96
|
+
const file = url?.split("/").pop();
|
|
97
|
+
return file ? `(anonymous ${file}:${line})` : "(anonymous)";
|
|
98
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** CPU time profile conversion to Speedscope sampled format. */
|
|
2
|
+
|
|
3
|
+
import { resolveCallFrame } from "../profiling/node/ResolvedProfile.ts";
|
|
4
|
+
import type {
|
|
5
|
+
TimeProfile,
|
|
6
|
+
TimeProfileNode,
|
|
7
|
+
} from "../profiling/node/TimeSampler.ts";
|
|
8
|
+
import {
|
|
9
|
+
type FrameContext,
|
|
10
|
+
frameContext,
|
|
11
|
+
internFrame,
|
|
12
|
+
type SpeedscopeFile,
|
|
13
|
+
type SpeedscopeTimeProfile,
|
|
14
|
+
speedscopeFile,
|
|
15
|
+
} from "./SpeedscopeTypes.ts";
|
|
16
|
+
|
|
17
|
+
/** Convert a TimeProfile to speedscope format */
|
|
18
|
+
export function timeProfileToSpeedscope(
|
|
19
|
+
name: string,
|
|
20
|
+
profile: TimeProfile,
|
|
21
|
+
): SpeedscopeFile {
|
|
22
|
+
const ctx = frameContext();
|
|
23
|
+
const p = buildTimeProfile(name, profile, ctx);
|
|
24
|
+
return speedscopeFile(ctx, [p]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build a SpeedscopeFile from multiple named time profiles (shared frames). */
|
|
28
|
+
export function buildTimeSpeedscopeFile(
|
|
29
|
+
entries: { name: string; profile: TimeProfile }[],
|
|
30
|
+
): SpeedscopeFile | undefined {
|
|
31
|
+
if (entries.length === 0) return undefined;
|
|
32
|
+
|
|
33
|
+
const ctx = frameContext();
|
|
34
|
+
const profiles = entries.map(e => buildTimeProfile(e.name, e.profile, ctx));
|
|
35
|
+
return speedscopeFile(ctx, profiles);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build a speedscope profile from a V8 TimeProfile */
|
|
39
|
+
function buildTimeProfile(
|
|
40
|
+
name: string,
|
|
41
|
+
profile: TimeProfile,
|
|
42
|
+
ctx: FrameContext,
|
|
43
|
+
): SpeedscopeTimeProfile {
|
|
44
|
+
const { samples: sampleIds, timeDeltas, nodes } = profile;
|
|
45
|
+
|
|
46
|
+
if (!sampleIds?.length || !timeDeltas) {
|
|
47
|
+
return {
|
|
48
|
+
type: "sampled",
|
|
49
|
+
name,
|
|
50
|
+
unit: "microseconds",
|
|
51
|
+
startValue: 0,
|
|
52
|
+
endValue: 0,
|
|
53
|
+
samples: [],
|
|
54
|
+
weights: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const nodeMap = new Map<number, TimeProfileNode>(nodes.map(n => [n.id, n]));
|
|
59
|
+
const parentMap = new Map<number, number>(); // childId -> parentId
|
|
60
|
+
for (const node of nodes) {
|
|
61
|
+
for (const childId of node.children ?? []) {
|
|
62
|
+
parentMap.set(childId, node.id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cache = new Map<number, number[]>();
|
|
67
|
+
const resolve = (id: number) =>
|
|
68
|
+
resolveStack(id, nodeMap, parentMap, cache, ctx);
|
|
69
|
+
|
|
70
|
+
const samples = sampleIds.map(resolve);
|
|
71
|
+
const total = timeDeltas.reduce((sum, w) => sum + w, 0);
|
|
72
|
+
return {
|
|
73
|
+
type: "sampled",
|
|
74
|
+
name,
|
|
75
|
+
unit: "microseconds",
|
|
76
|
+
startValue: 0,
|
|
77
|
+
endValue: total,
|
|
78
|
+
samples,
|
|
79
|
+
weights: timeDeltas,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Walk from node to root, building a stack of frame indices (root-first) */
|
|
84
|
+
function resolveStack(
|
|
85
|
+
nodeId: number,
|
|
86
|
+
nodeMap: Map<number, TimeProfileNode>,
|
|
87
|
+
parentMap: Map<number, number>,
|
|
88
|
+
cache: Map<number, number[]>,
|
|
89
|
+
ctx: FrameContext,
|
|
90
|
+
): number[] {
|
|
91
|
+
const cached = cache.get(nodeId);
|
|
92
|
+
if (cached) return cached;
|
|
93
|
+
|
|
94
|
+
const path: number[] = [];
|
|
95
|
+
let current: number | undefined = nodeId;
|
|
96
|
+
while (current !== undefined) {
|
|
97
|
+
path.push(current);
|
|
98
|
+
current = parentMap.get(current);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reverse to root-first order
|
|
102
|
+
const stack: number[] = [];
|
|
103
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
104
|
+
const node = nodeMap.get(path[i]);
|
|
105
|
+
if (!node) continue;
|
|
106
|
+
const { functionName, url, lineNumber } = node.callFrame;
|
|
107
|
+
// Skip the synthetic (root) node
|
|
108
|
+
if (!functionName && !url && lineNumber <= 0) continue;
|
|
109
|
+
const frame = resolveCallFrame(node.callFrame);
|
|
110
|
+
stack.push(internFrame(frame.name, frame.url, frame.line, frame.col, ctx));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
cache.set(nodeId, stack);
|
|
114
|
+
return stack;
|
|
115
|
+
}
|