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
|
@@ -74,23 +74,6 @@ export function diffPercentBenchmark(main: unknown, base: unknown): string {
|
|
|
74
74
|
return coloredPercent(diff, base, false); // negative is good for benchmarks
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/** Format fraction as colored +/- percentage */
|
|
78
|
-
function coloredPercent(
|
|
79
|
-
numerator: number,
|
|
80
|
-
denominator: number,
|
|
81
|
-
positiveIsGreen = true,
|
|
82
|
-
): string {
|
|
83
|
-
const fraction = numerator / denominator;
|
|
84
|
-
if (Number.isNaN(fraction) || !Number.isFinite(fraction)) {
|
|
85
|
-
return " ";
|
|
86
|
-
}
|
|
87
|
-
const positive = fraction >= 0;
|
|
88
|
-
const sign = positive ? "+" : "-";
|
|
89
|
-
const percentStr = `${sign}${percent(fraction)}`;
|
|
90
|
-
const isGood = positive === positiveIsGreen;
|
|
91
|
-
return isGood ? green(percentStr) : red(percentStr);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
77
|
/** Format memory size in KB with appropriate units */
|
|
95
78
|
export function memoryKB(kb: unknown): string | null {
|
|
96
79
|
if (typeof kb !== "number") return null;
|
|
@@ -98,14 +81,19 @@ export function memoryKB(kb: unknown): string | null {
|
|
|
98
81
|
return `${(kb / 1024).toFixed(1)}MB`;
|
|
99
82
|
}
|
|
100
83
|
|
|
101
|
-
/** Format bytes with appropriate units (B, KB, MB, GB)
|
|
102
|
-
|
|
84
|
+
/** Format bytes with appropriate units (B, KB, MB, GB).
|
|
85
|
+
* Use `space: true` for human-readable console output (`1.5 KB`). */
|
|
86
|
+
export function formatBytes(
|
|
87
|
+
bytes: unknown,
|
|
88
|
+
opts?: { space?: boolean },
|
|
89
|
+
): string | null {
|
|
103
90
|
if (typeof bytes !== "number") return null;
|
|
104
|
-
|
|
105
|
-
if (bytes < 1024
|
|
91
|
+
const s = opts?.space ? " " : "";
|
|
92
|
+
if (bytes < 1024) return `${bytes.toFixed(0)}${s}B`;
|
|
93
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}${s}KB`;
|
|
106
94
|
if (bytes < 1024 * 1024 * 1024)
|
|
107
|
-
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
108
|
-
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
|
|
95
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}${s}MB`;
|
|
96
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}${s}GB`;
|
|
109
97
|
}
|
|
110
98
|
|
|
111
99
|
/** Format percentage difference with confidence interval */
|
|
@@ -123,9 +111,31 @@ export function formatDiffWithCIHigherIsBetter(value: unknown): string | null {
|
|
|
123
111
|
return colorByDirection(diffCIText(-percent, [-ci[1], -ci[0]]), direction);
|
|
124
112
|
}
|
|
125
113
|
|
|
126
|
-
/** @return
|
|
127
|
-
function
|
|
128
|
-
return
|
|
114
|
+
/** @return truncated string with ellipsis if over maxLen */
|
|
115
|
+
export function truncate(str: string, maxLen = 30): string {
|
|
116
|
+
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Format fraction as colored +/- percentage */
|
|
120
|
+
function coloredPercent(
|
|
121
|
+
numerator: number,
|
|
122
|
+
denominator: number,
|
|
123
|
+
positiveIsGreen = true,
|
|
124
|
+
): string {
|
|
125
|
+
const fraction = numerator / denominator;
|
|
126
|
+
if (Number.isNaN(fraction) || !Number.isFinite(fraction)) {
|
|
127
|
+
return " ";
|
|
128
|
+
}
|
|
129
|
+
const positive = fraction >= 0;
|
|
130
|
+
const sign = positive ? "+" : "-";
|
|
131
|
+
const percentStr = `${sign}${percent(fraction)}`;
|
|
132
|
+
const isGood = positive === positiveIsGreen;
|
|
133
|
+
return isGood ? green(percentStr) : red(percentStr);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @return true if value is a DifferenceCI object */
|
|
137
|
+
function isDifferenceCI(x: unknown): x is DifferenceCI {
|
|
138
|
+
return typeof x === "object" && x !== null && "ci" in x && "direction" in x;
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
/** @return text colored green for faster, red for slower */
|
|
@@ -135,18 +145,13 @@ function colorByDirection(text: string, direction: CIDirection): string {
|
|
|
135
145
|
return text;
|
|
136
146
|
}
|
|
137
147
|
|
|
148
|
+
/** @return formatted "pct [lo, hi]" text for a diff with CI */
|
|
149
|
+
function diffCIText(pct: number, ci: [number, number]): string {
|
|
150
|
+
return `${formatBound(pct)} [${formatBound(ci[0])}, ${formatBound(ci[1])}]`;
|
|
151
|
+
}
|
|
152
|
+
|
|
138
153
|
/** @return signed percentage string (e.g. "+1.2%", "-3.4%") */
|
|
139
154
|
function formatBound(v: number): string {
|
|
140
155
|
const sign = v >= 0 ? "+" : "";
|
|
141
156
|
return `${sign}${v.toFixed(1)}%`;
|
|
142
157
|
}
|
|
143
|
-
|
|
144
|
-
/** @return true if value is a DifferenceCI object */
|
|
145
|
-
function isDifferenceCI(x: unknown): x is DifferenceCI {
|
|
146
|
-
return typeof x === "object" && x !== null && "ci" in x && "direction" in x;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** @return truncated string with ellipsis if over maxLen */
|
|
150
|
-
export function truncate(str: string, maxLen = 30): string {
|
|
151
|
-
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str;
|
|
152
|
-
}
|
|
@@ -3,9 +3,6 @@ import type { Alignment, SpanningCellConfig, TableUserConfig } from "table";
|
|
|
3
3
|
import { table } from "table";
|
|
4
4
|
import { diffPercent } from "./Formatters.ts";
|
|
5
5
|
|
|
6
|
-
const isTest = process.env.NODE_ENV === "test" || process.env.VITEST === "true";
|
|
7
|
-
const { bold } = isTest ? { bold: (str: string) => str } : pico;
|
|
8
|
-
|
|
9
6
|
/** Related table columns */
|
|
10
7
|
export interface ColumnGroup<T> {
|
|
11
8
|
groupTitle?: string;
|
|
@@ -20,6 +17,19 @@ export interface Column<T> extends ColumnFormat<T> {
|
|
|
20
17
|
diffKey?: undefined;
|
|
21
18
|
}
|
|
22
19
|
|
|
20
|
+
/** Table headers and configuration */
|
|
21
|
+
export interface TableSetup {
|
|
22
|
+
headerRows: string[][];
|
|
23
|
+
config: TableUserConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Data rows with optional baseline */
|
|
27
|
+
export interface ResultGroup<T extends Record<string, any>> {
|
|
28
|
+
results: T[];
|
|
29
|
+
|
|
30
|
+
baseline?: T;
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/** Comparison column against baseline */
|
|
24
34
|
interface DiffColumn<T> extends ColumnFormat<T> {
|
|
25
35
|
diffFormatter?: (value: unknown, baseline: unknown) => string | null;
|
|
@@ -38,18 +48,19 @@ interface ColumnFormat<T> {
|
|
|
38
48
|
width?: number;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
config: TableUserConfig;
|
|
51
|
+
interface Lines {
|
|
52
|
+
drawHorizontalLine: (index: number, size: number) => boolean;
|
|
53
|
+
drawVerticalLine: (index: number, size: number) => boolean;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
results: T[];
|
|
56
|
+
const isTest = process.env.NODE_ENV === "test" || process.env.VITEST === "true";
|
|
57
|
+
const { bold } = isTest ? { bold: (str: string) => str } : pico;
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
// Regex to strip ANSI escape codes (ESC [ ... m sequences)
|
|
60
|
+
const ansiEscapeRegex = new RegExp(
|
|
61
|
+
String.fromCharCode(27) + "\\[[0-9;]*m",
|
|
62
|
+
"g",
|
|
63
|
+
);
|
|
53
64
|
|
|
54
65
|
/** Build formatted table with column groups and baselines */
|
|
55
66
|
export function buildTable<T extends Record<string, any>>(
|
|
@@ -61,73 +72,6 @@ export function buildTable<T extends Record<string, any>>(
|
|
|
61
72
|
return createTable(columnGroups, allRecords);
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
/** Convert columns and records to formatted table */
|
|
65
|
-
function createTable<T extends Record<string, any>>(
|
|
66
|
-
groups: ColumnGroup<T>[],
|
|
67
|
-
records: T[],
|
|
68
|
-
): string {
|
|
69
|
-
const dataRows = toRows(records, groups);
|
|
70
|
-
const { headerRows, config } = setup(groups, dataRows);
|
|
71
|
-
const allRows = [...headerRows, ...dataRows];
|
|
72
|
-
return table(allRows, config);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Create header rows with group titles */
|
|
76
|
-
function createGroupHeaders<T>(
|
|
77
|
-
groups: ColumnGroup<T>[],
|
|
78
|
-
numColumns: number,
|
|
79
|
-
): string[][] {
|
|
80
|
-
if (!groups.some(g => g.groupTitle)) return [];
|
|
81
|
-
|
|
82
|
-
const sectionRow = groups.flatMap(g => {
|
|
83
|
-
const title = g.groupTitle ? [bold(g.groupTitle)] : [];
|
|
84
|
-
return padWithBlanks(title, g.columns.length);
|
|
85
|
-
});
|
|
86
|
-
const blankRow = padWithBlanks([], numColumns);
|
|
87
|
-
return [sectionRow, blankRow];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
interface Lines {
|
|
91
|
-
drawHorizontalLine: (index: number, size: number) => boolean;
|
|
92
|
-
drawVerticalLine: (index: number, size: number) => boolean;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** @return draw functions for horizontal/vertical table borders */
|
|
96
|
-
function createLines<T>(groups: ColumnGroup<T>[]): Lines {
|
|
97
|
-
const { sectionBorders, headerBottom } = calcBorders(groups);
|
|
98
|
-
|
|
99
|
-
function drawVerticalLine(index: number, size: number): boolean {
|
|
100
|
-
return index === 0 || index === size || sectionBorders.includes(index);
|
|
101
|
-
}
|
|
102
|
-
function drawHorizontalLine(index: number, size: number): boolean {
|
|
103
|
-
return index === 0 || index === size || index === headerBottom;
|
|
104
|
-
}
|
|
105
|
-
return { drawHorizontalLine, drawVerticalLine };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** @return spanning cell configs for group title headers */
|
|
109
|
-
function createSectionSpans<T>(groups: ColumnGroup<T>[]): SpanningCellConfig[] {
|
|
110
|
-
let col = 0;
|
|
111
|
-
const alignment: Alignment = "center";
|
|
112
|
-
return groups.map(g => {
|
|
113
|
-
const colSpan = g.columns.length;
|
|
114
|
-
const span = { row: 0, col, colSpan, alignment };
|
|
115
|
-
col += colSpan;
|
|
116
|
-
return span;
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** @return bolded column title strings */
|
|
121
|
-
function getTitles<T>(groups: ColumnGroup<T>[]): string[] {
|
|
122
|
-
return groups.flatMap(g => g.columns.map(c => bold(c.title || " ")));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** @return array padded with blank strings to the given length */
|
|
126
|
-
function padWithBlanks(arr: string[], length: number): string[] {
|
|
127
|
-
if (arr.length >= length) return arr;
|
|
128
|
-
return [...arr, ...Array(length - arr.length).fill(" ")];
|
|
129
|
-
}
|
|
130
|
-
|
|
131
75
|
/** Convert records to string arrays for table */
|
|
132
76
|
export function toRows<T extends Record<string, any>>(
|
|
133
77
|
records: T[],
|
|
@@ -145,28 +89,6 @@ export function toRows<T extends Record<string, any>>(
|
|
|
145
89
|
return rawRows.map(row => row.map(cell => cell ?? " "));
|
|
146
90
|
}
|
|
147
91
|
|
|
148
|
-
/** Add comparison values for diff columns */
|
|
149
|
-
function addComparisons<T extends Record<string, any>>(
|
|
150
|
-
groups: ColumnGroup<T>[],
|
|
151
|
-
mainRecord: T,
|
|
152
|
-
baselineRecord: T,
|
|
153
|
-
): T {
|
|
154
|
-
const diffColumns = groups.flatMap(g => g.columns).filter(col => col.diffKey);
|
|
155
|
-
const updatedMain = { ...mainRecord };
|
|
156
|
-
|
|
157
|
-
for (const col of diffColumns) {
|
|
158
|
-
const dcol = col as DiffColumn<T>;
|
|
159
|
-
const diffKey = dcol.diffKey;
|
|
160
|
-
const mainValue = mainRecord[diffKey];
|
|
161
|
-
const baselineValue = baselineRecord[diffKey];
|
|
162
|
-
const diffFormat = dcol.diffFormatter ?? diffPercent;
|
|
163
|
-
const diffStr = diffFormat(mainValue, baselineValue);
|
|
164
|
-
(updatedMain as any)[col.key] = diffStr;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return updatedMain;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
92
|
/** Flatten groups with spacing */
|
|
171
93
|
function flattenGroups<T extends Record<string, any>>(
|
|
172
94
|
columnGroups: ColumnGroup<T>[],
|
|
@@ -181,6 +103,17 @@ function flattenGroups<T extends Record<string, any>>(
|
|
|
181
103
|
});
|
|
182
104
|
}
|
|
183
105
|
|
|
106
|
+
/** Convert columns and records to formatted table */
|
|
107
|
+
function createTable<T extends Record<string, any>>(
|
|
108
|
+
groups: ColumnGroup<T>[],
|
|
109
|
+
records: T[],
|
|
110
|
+
): string {
|
|
111
|
+
const dataRows = toRows(records, groups);
|
|
112
|
+
const { headerRows, config } = setup(groups, dataRows);
|
|
113
|
+
const allRows = [...headerRows, ...dataRows];
|
|
114
|
+
return table(allRows, config);
|
|
115
|
+
}
|
|
116
|
+
|
|
184
117
|
/** Process results with baseline comparisons */
|
|
185
118
|
function addBaseline<T extends Record<string, any>>(
|
|
186
119
|
columnGroups: ColumnGroup<T>[],
|
|
@@ -203,22 +136,6 @@ function addBaseline<T extends Record<string, any>>(
|
|
|
203
136
|
return [...diffResults, markedBaseline];
|
|
204
137
|
}
|
|
205
138
|
|
|
206
|
-
/** Calculate vertical lines between sections and header bottom position */
|
|
207
|
-
function calcBorders<T>(groups: ColumnGroup<T>[]): {
|
|
208
|
-
sectionBorders: number[];
|
|
209
|
-
headerBottom: number;
|
|
210
|
-
} {
|
|
211
|
-
if (groups.length === 0) return { sectionBorders: [], headerBottom: 1 };
|
|
212
|
-
|
|
213
|
-
const sectionBorders: number[] = [];
|
|
214
|
-
let border = 0;
|
|
215
|
-
for (const g of groups) {
|
|
216
|
-
border += g.columns.length;
|
|
217
|
-
sectionBorders.push(border);
|
|
218
|
-
}
|
|
219
|
-
return { sectionBorders, headerBottom: 3 };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
139
|
/** Create headers and table configuration */
|
|
223
140
|
function setup<T>(groups: ColumnGroup<T>[], dataRows: string[][]): TableSetup {
|
|
224
141
|
const titles = getTitles(groups);
|
|
@@ -237,6 +154,60 @@ function setup<T>(groups: ColumnGroup<T>[], dataRows: string[][]): TableSetup {
|
|
|
237
154
|
return { headerRows, config };
|
|
238
155
|
}
|
|
239
156
|
|
|
157
|
+
/** Add comparison values for diff columns */
|
|
158
|
+
function addComparisons<T extends Record<string, any>>(
|
|
159
|
+
groups: ColumnGroup<T>[],
|
|
160
|
+
mainRecord: T,
|
|
161
|
+
baselineRecord: T,
|
|
162
|
+
): T {
|
|
163
|
+
const diffColumns = groups.flatMap(g => g.columns).filter(col => col.diffKey);
|
|
164
|
+
const updatedMain = { ...mainRecord };
|
|
165
|
+
|
|
166
|
+
for (const col of diffColumns) {
|
|
167
|
+
const dcol = col as DiffColumn<T>;
|
|
168
|
+
const diffKey = dcol.diffKey;
|
|
169
|
+
const mainValue = mainRecord[diffKey];
|
|
170
|
+
const baselineValue = baselineRecord[diffKey];
|
|
171
|
+
const diffFormat = dcol.diffFormatter ?? diffPercent;
|
|
172
|
+
const diffStr = diffFormat(mainValue, baselineValue);
|
|
173
|
+
(updatedMain as any)[col.key] = diffStr;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return updatedMain;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @return bolded column title strings */
|
|
180
|
+
function getTitles<T>(groups: ColumnGroup<T>[]): string[] {
|
|
181
|
+
return groups.flatMap(g => g.columns.map(c => bold(c.title || " ")));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Create header rows with group titles */
|
|
185
|
+
function createGroupHeaders<T>(
|
|
186
|
+
groups: ColumnGroup<T>[],
|
|
187
|
+
numColumns: number,
|
|
188
|
+
): string[][] {
|
|
189
|
+
if (!groups.some(g => g.groupTitle)) return [];
|
|
190
|
+
|
|
191
|
+
const sectionRow = groups.flatMap(g => {
|
|
192
|
+
const title = g.groupTitle ? [bold(g.groupTitle)] : [];
|
|
193
|
+
return padWithBlanks(title, g.columns.length);
|
|
194
|
+
});
|
|
195
|
+
const blankRow = padWithBlanks([], numColumns);
|
|
196
|
+
return [sectionRow, blankRow];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** @return spanning cell configs for group title headers */
|
|
200
|
+
function createSectionSpans<T>(groups: ColumnGroup<T>[]): SpanningCellConfig[] {
|
|
201
|
+
let col = 0;
|
|
202
|
+
const alignment: Alignment = "center";
|
|
203
|
+
return groups.map(g => {
|
|
204
|
+
const colSpan = g.columns.length;
|
|
205
|
+
const span = { row: 0, col, colSpan, alignment };
|
|
206
|
+
col += colSpan;
|
|
207
|
+
return span;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
240
211
|
/** Calculate column widths based on content, including group titles */
|
|
241
212
|
function calcColumnWidths<T>(
|
|
242
213
|
groups: ColumnGroup<T>[],
|
|
@@ -279,11 +250,24 @@ function calcColumnWidths<T>(
|
|
|
279
250
|
);
|
|
280
251
|
}
|
|
281
252
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
)
|
|
253
|
+
/** @return draw functions for horizontal/vertical table borders */
|
|
254
|
+
function createLines<T>(groups: ColumnGroup<T>[]): Lines {
|
|
255
|
+
const { sectionBorders, headerBottom } = calcBorders(groups);
|
|
256
|
+
|
|
257
|
+
function drawVerticalLine(index: number, size: number): boolean {
|
|
258
|
+
return index === 0 || index === size || sectionBorders.includes(index);
|
|
259
|
+
}
|
|
260
|
+
function drawHorizontalLine(index: number, size: number): boolean {
|
|
261
|
+
return index === 0 || index === size || index === headerBottom;
|
|
262
|
+
}
|
|
263
|
+
return { drawHorizontalLine, drawVerticalLine };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @return array padded with blank strings to the given length */
|
|
267
|
+
function padWithBlanks(arr: string[], length: number): string[] {
|
|
268
|
+
if (arr.length >= length) return arr;
|
|
269
|
+
return [...arr, ...Array(length - arr.length).fill(" ")];
|
|
270
|
+
}
|
|
287
271
|
|
|
288
272
|
/** Get visible length of a cell value (strips ANSI escape codes) */
|
|
289
273
|
function cellWidth(value: unknown): number {
|
|
@@ -291,3 +275,19 @@ function cellWidth(value: unknown): number {
|
|
|
291
275
|
const str = String(value);
|
|
292
276
|
return str.replace(ansiEscapeRegex, "").length;
|
|
293
277
|
}
|
|
278
|
+
|
|
279
|
+
/** Calculate vertical lines between sections and header bottom position */
|
|
280
|
+
function calcBorders<T>(groups: ColumnGroup<T>[]): {
|
|
281
|
+
sectionBorders: number[];
|
|
282
|
+
headerBottom: number;
|
|
283
|
+
} {
|
|
284
|
+
if (groups.length === 0) return { sectionBorders: [], headerBottom: 1 };
|
|
285
|
+
|
|
286
|
+
const sectionBorders: number[] = [];
|
|
287
|
+
let border = 0;
|
|
288
|
+
for (const g of groups) {
|
|
289
|
+
border += g.columns.length;
|
|
290
|
+
sectionBorders.push(border);
|
|
291
|
+
}
|
|
292
|
+
return { sectionBorders, headerBottom: 3 };
|
|
293
|
+
}
|
|
@@ -55,15 +55,6 @@ function getGroupColumnIndex(
|
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** Count total columns in groups before the target group index. */
|
|
59
|
-
function countColumnsBeforeGroup(
|
|
60
|
-
headers: string[],
|
|
61
|
-
groupIndex: number,
|
|
62
|
-
): number {
|
|
63
|
-
const perGroup = headers.map(col => splitColumns(col).length);
|
|
64
|
-
return perGroup.slice(0, groupIndex).reduce((sum, n) => sum + n, 0);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
58
|
/** Find a column's position index in a header line. */
|
|
68
59
|
function getColumnIndex(
|
|
69
60
|
header: string | undefined,
|
|
@@ -91,6 +82,15 @@ function splitColumnGroups(line: string): string[] {
|
|
|
91
82
|
return line.split("│").map(col => col.trim());
|
|
92
83
|
}
|
|
93
84
|
|
|
85
|
+
/** Count total columns in groups before the target group index. */
|
|
86
|
+
function countColumnsBeforeGroup(
|
|
87
|
+
headers: string[],
|
|
88
|
+
groupIndex: number,
|
|
89
|
+
): number {
|
|
90
|
+
const perGroup = headers.map(col => splitColumns(col).length);
|
|
91
|
+
return perGroup.slice(0, groupIndex).reduce((sum, n) => sum + n, 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
94
|
/** Split on 2+ whitespace or '│' borders, so single-space titles like "L1 miss" survive. */
|
|
95
95
|
function splitColumns(line: string): string[] {
|
|
96
96
|
return line
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { expect
|
|
1
|
+
import { expect } from "vitest";
|
|
2
2
|
import type { BenchSuite } from "../Benchmark.ts";
|
|
3
3
|
import type { BenchmarkReport } from "../BenchmarkReport.ts";
|
|
4
|
-
import { parseBenchArgs
|
|
4
|
+
import { parseBenchArgs } from "../cli/RunBenchCLI.ts";
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const _statisticalSuite: BenchSuite = {
|
|
7
7
|
name: "Statistical Test Suite",
|
|
8
8
|
groups: [
|
|
9
9
|
{
|
|
@@ -31,7 +31,7 @@ const statisticalSuite: BenchSuite = {
|
|
|
31
31
|
],
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
const
|
|
34
|
+
const _gcSuite: BenchSuite = {
|
|
35
35
|
name: "GC Test Suite",
|
|
36
36
|
groups: [
|
|
37
37
|
{
|
|
@@ -52,7 +52,7 @@ const gcSuite: BenchSuite = {
|
|
|
52
52
|
],
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
function
|
|
55
|
+
function _parseAdaptiveArgs() {
|
|
56
56
|
return parseBenchArgs((yargs: any) =>
|
|
57
57
|
yargs
|
|
58
58
|
.option("adaptive", { type: "boolean", default: true })
|
|
@@ -61,7 +61,7 @@ function parseAdaptiveArgs() {
|
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function
|
|
64
|
+
function _verifyStatisticalMetrics(report: BenchmarkReport): void {
|
|
65
65
|
const { time, convergence } = report.measuredResults;
|
|
66
66
|
expect(time).toBeDefined();
|
|
67
67
|
expect(time?.p25).toBeDefined();
|
|
@@ -77,7 +77,7 @@ function verifyStatisticalMetrics(report: BenchmarkReport): void {
|
|
|
77
77
|
expect(convergence?.reason).toBeDefined();
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function
|
|
80
|
+
function _verifyPercentileOrdering(report: BenchmarkReport): void {
|
|
81
81
|
const t = report.measuredResults.time;
|
|
82
82
|
if (t?.p25 && t?.p50 && t?.p75 && t?.p95) {
|
|
83
83
|
expect(t.p25).toBeLessThanOrEqual(t.p50);
|
|
@@ -85,35 +85,3 @@ function verifyPercentileOrdering(report: BenchmarkReport): void {
|
|
|
85
85
|
expect(t.p75).toBeLessThanOrEqual(t.p95);
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
test("adaptive mode reports statistical metrics correctly", async () => {
|
|
90
|
-
const results = await runBenchmarks(statisticalSuite, parseAdaptiveArgs());
|
|
91
|
-
expect(results).toHaveLength(1);
|
|
92
|
-
expect(results[0].reports).toHaveLength(2);
|
|
93
|
-
|
|
94
|
-
for (const report of results[0].reports) {
|
|
95
|
-
verifyStatisticalMetrics(report);
|
|
96
|
-
verifyPercentileOrdering(report);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const reports = results[0].reports;
|
|
100
|
-
const stableCV = reports.find(r => r.name === "stable-benchmark")
|
|
101
|
-
?.measuredResults.time?.cv;
|
|
102
|
-
const variableCV = reports.find(r => r.name === "variable-benchmark")
|
|
103
|
-
?.measuredResults.time?.cv;
|
|
104
|
-
if (stableCV && variableCV) {
|
|
105
|
-
expect(variableCV).toBeGreaterThanOrEqual(stableCV);
|
|
106
|
-
}
|
|
107
|
-
}, 20000);
|
|
108
|
-
|
|
109
|
-
test("adaptive mode handles GC-heavy workload", async () => {
|
|
110
|
-
const results = await runBenchmarks(gcSuite, parseAdaptiveArgs());
|
|
111
|
-
expect(results).toHaveLength(1);
|
|
112
|
-
expect(results[0].reports).toHaveLength(1);
|
|
113
|
-
|
|
114
|
-
const gcResult = results[0].reports[0].measuredResults;
|
|
115
|
-
expect(gcResult.convergence).toBeDefined();
|
|
116
|
-
expect(gcResult.time?.outlierRate).toBeDefined();
|
|
117
|
-
expect(gcResult.time?.cv).toBeDefined();
|
|
118
|
-
expect(gcResult.time?.mad).toBeGreaterThanOrEqual(0);
|
|
119
|
-
}, 20000);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
aggregateSites,
|
|
4
|
+
type HeapSite,
|
|
5
|
+
} from "../heap-sample/HeapSampleReport.ts";
|
|
6
|
+
|
|
7
|
+
test("unknown column does not merge distinct functions on same line", () => {
|
|
8
|
+
const sites: HeapSite[] = [
|
|
9
|
+
{ fn: "Foo", url: "test.ts", line: 10, col: undefined, bytes: 100 },
|
|
10
|
+
{ fn: "Bar", url: "test.ts", line: 10, col: undefined, bytes: 200 },
|
|
11
|
+
];
|
|
12
|
+
const aggregated = aggregateSites(sites);
|
|
13
|
+
expect(aggregated).toHaveLength(2);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("same column merges regardless of function name", () => {
|
|
17
|
+
const sites: HeapSite[] = [
|
|
18
|
+
{ fn: "Foo", url: "test.ts", line: 10, col: 5, bytes: 100 },
|
|
19
|
+
{ fn: "Foo", url: "test.ts", line: 10, col: 5, bytes: 200 },
|
|
20
|
+
];
|
|
21
|
+
const aggregated = aggregateSites(sites);
|
|
22
|
+
expect(aggregated).toHaveLength(1);
|
|
23
|
+
expect(aggregated[0].bytes).toBe(300);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("aggregation preserves distinct caller stacks", () => {
|
|
27
|
+
const stackA = [
|
|
28
|
+
{ fn: "root", url: "a.ts", line: 1, col: 0 },
|
|
29
|
+
{ fn: "foo", url: "a.ts", line: 10, col: 0 },
|
|
30
|
+
{ fn: "alloc", url: "a.ts", line: 20, col: 5 },
|
|
31
|
+
];
|
|
32
|
+
const stackB = [
|
|
33
|
+
{ fn: "root", url: "a.ts", line: 1, col: 0 },
|
|
34
|
+
{ fn: "bar", url: "a.ts", line: 15, col: 0 },
|
|
35
|
+
{ fn: "alloc", url: "a.ts", line: 20, col: 5 },
|
|
36
|
+
];
|
|
37
|
+
const sites: HeapSite[] = [
|
|
38
|
+
{ fn: "alloc", url: "a.ts", line: 20, col: 5, bytes: 800, stack: stackA },
|
|
39
|
+
{ fn: "alloc", url: "a.ts", line: 20, col: 5, bytes: 200, stack: stackB },
|
|
40
|
+
];
|
|
41
|
+
const aggregated = aggregateSites(sites);
|
|
42
|
+
|
|
43
|
+
expect(aggregated).toHaveLength(1);
|
|
44
|
+
expect(aggregated[0].bytes).toBe(1000);
|
|
45
|
+
expect(aggregated[0].callers).toHaveLength(2);
|
|
46
|
+
// Primary stack should be the highest-bytes path (foo)
|
|
47
|
+
expect(aggregated[0].stack![1].fn).toBe("foo");
|
|
48
|
+
// Callers sorted by bytes descending
|
|
49
|
+
expect(aggregated[0].callers![0].bytes).toBe(800);
|
|
50
|
+
expect(aggregated[0].callers![1].bytes).toBe(200);
|
|
51
|
+
});
|
|
@@ -49,6 +49,24 @@ const suiteWithSetup: BenchSuite = {
|
|
|
49
49
|
],
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
/** Execute test fixture script and return output */
|
|
53
|
+
function executeTestScript(args = ""): string {
|
|
54
|
+
const script = path.join(
|
|
55
|
+
import.meta.dirname!,
|
|
56
|
+
"fixtures/test-bench-script.ts",
|
|
57
|
+
);
|
|
58
|
+
return execSync(`node --expose-gc --allow-natives-syntax ${script} ${args}`, {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Run a fixture file via bin/benchforge and return output */
|
|
64
|
+
function executeBenchforgeFile(file: string, args = ""): string {
|
|
65
|
+
const bin = path.join(import.meta.dirname!, "../../bin/benchforge");
|
|
66
|
+
const fixture = path.join(import.meta.dirname!, "fixtures", file);
|
|
67
|
+
return execSync(`${bin} ${fixture} ${args}`, { encoding: "utf8" });
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
test("runs all benchmarks", { timeout: 30000 }, async () => {
|
|
53
71
|
const output = await runBenchCLITest(testSuite, "--time 0.1");
|
|
54
72
|
|
|
@@ -87,17 +105,6 @@ test("filter preserves suite structure", () => {
|
|
|
87
105
|
expect(filtered.groups[1].benchmarks).toHaveLength(0);
|
|
88
106
|
});
|
|
89
107
|
|
|
90
|
-
/** Execute test fixture script and return output */
|
|
91
|
-
function executeTestScript(args = ""): string {
|
|
92
|
-
const script = path.join(
|
|
93
|
-
import.meta.dirname!,
|
|
94
|
-
"fixtures/test-bench-script.ts",
|
|
95
|
-
);
|
|
96
|
-
return execSync(`node --expose-gc --allow-natives-syntax ${script} ${args}`, {
|
|
97
|
-
encoding: "utf8",
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
108
|
test("e2e: runs user script", { timeout: 30000 }, () => {
|
|
102
109
|
const output = executeTestScript("--time 0.1");
|
|
103
110
|
|
|
@@ -164,3 +171,21 @@ test(
|
|
|
164
171
|
expect(output).toContain("mean");
|
|
165
172
|
},
|
|
166
173
|
);
|
|
174
|
+
|
|
175
|
+
test("file mode: BenchSuite export", { timeout: 30000 }, () => {
|
|
176
|
+
const output = executeBenchforgeFile(
|
|
177
|
+
"suite-export-bench.ts",
|
|
178
|
+
"--iterations 5",
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(output).toContain("plus");
|
|
182
|
+
expect(output).toContain("multiply");
|
|
183
|
+
expect(output).toContain("runs");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("file mode: function export", { timeout: 30000 }, () => {
|
|
187
|
+
const output = executeBenchforgeFile("fn-export-bench.ts", "--iterations 5");
|
|
188
|
+
|
|
189
|
+
expect(output).toContain("fn-export-bench");
|
|
190
|
+
expect(output).toContain("runs");
|
|
191
|
+
});
|