@mui/internal-benchmark 0.0.3-canary.16 → 0.0.3-canary.17
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/Metric.d.mts +19 -0
- package/Metric.mjs +36 -4
- package/ciReport.d.mts +10 -0
- package/ciReport.mjs +12 -2
- package/format.d.mts +10 -4
- package/format.mjs +63 -15
- package/index.d.mts +18 -0
- package/index.mjs +71 -3
- package/package.json +2 -2
- package/reporter.mjs +65 -49
- package/stats.d.mts +46 -4
- package/stats.mjs +74 -6
- package/types.d.mts +4 -1
package/Metric.d.mts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
|
+
import type { RunnerTestCase } from 'vitest';
|
|
1
2
|
import type { MetricConfig, MetricKind } from "./types.mjs";
|
|
2
3
|
import "./taskMetaAugmentation.mjs";
|
|
4
|
+
/** One alarmed scalar (sub-)series the adaptive stopping rule tracks, named for reporting. */
|
|
5
|
+
export interface AdaptiveMetricSamples {
|
|
6
|
+
/** Metric name, with a `#sub-series` suffix for named sub-series. */
|
|
7
|
+
name: string;
|
|
8
|
+
samples: number[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Snapshot of the raw samples collected so far for a test's alarmed scalar metrics — one entry per
|
|
12
|
+
* (metric, sub-series), each named. The adaptive stopping rule reads this so measurement keeps
|
|
13
|
+
* sampling while any metric that will be significance-tested downstream is still imprecise, not just
|
|
14
|
+
* the render duration, and so a non-convergence warning can name the specific metric that stayed
|
|
15
|
+
* noisy.
|
|
16
|
+
*
|
|
17
|
+
* Discrete metrics (compared as exact counts) and informational scalar metrics (shown but never
|
|
18
|
+
* flagged) are excluded: their precision never changes a verdict, and gating on a noisy one would
|
|
19
|
+
* needlessly block convergence.
|
|
20
|
+
*/
|
|
21
|
+
export declare function collectAdaptiveMetricSamples(test: RunnerTestCase): AdaptiveMetricSamples[];
|
|
3
22
|
export interface MetricRecordOptions {
|
|
4
23
|
/** Sub-series label. Recorded under `${name}#${id}` in the report; omit for the base series. */
|
|
5
24
|
id?: string;
|
package/Metric.mjs
CHANGED
|
@@ -12,10 +12,9 @@ function flush(test, accumulator) {
|
|
|
12
12
|
for (const [name, entry] of accumulator) {
|
|
13
13
|
const series = {};
|
|
14
14
|
for (const [seriesId, samples] of entry.series) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
};
|
|
15
|
+
// `aggregateSamples` reports the effective (post-outlier-removal) count as `count`, which is
|
|
16
|
+
// the `n` behind mean/stdDev — exactly what a downstream Welch's t-test needs.
|
|
17
|
+
series[seriesId] = aggregateSamples(samples);
|
|
19
18
|
}
|
|
20
19
|
store[name] = {
|
|
21
20
|
kind: entry.kind,
|
|
@@ -25,6 +24,39 @@ function flush(test, accumulator) {
|
|
|
25
24
|
}
|
|
26
25
|
test.meta.benchmarkMetrics = store;
|
|
27
26
|
}
|
|
27
|
+
|
|
28
|
+
/** One alarmed scalar (sub-)series the adaptive stopping rule tracks, named for reporting. */
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Snapshot of the raw samples collected so far for a test's alarmed scalar metrics — one entry per
|
|
32
|
+
* (metric, sub-series), each named. The adaptive stopping rule reads this so measurement keeps
|
|
33
|
+
* sampling while any metric that will be significance-tested downstream is still imprecise, not just
|
|
34
|
+
* the render duration, and so a non-convergence warning can name the specific metric that stayed
|
|
35
|
+
* noisy.
|
|
36
|
+
*
|
|
37
|
+
* Discrete metrics (compared as exact counts) and informational scalar metrics (shown but never
|
|
38
|
+
* flagged) are excluded: their precision never changes a verdict, and gating on a noisy one would
|
|
39
|
+
* needlessly block convergence.
|
|
40
|
+
*/
|
|
41
|
+
export function collectAdaptiveMetricSamples(test) {
|
|
42
|
+
const accumulator = accumulators.get(test);
|
|
43
|
+
if (!accumulator) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const sampleSets = [];
|
|
47
|
+
for (const [metricName, entry] of accumulator) {
|
|
48
|
+
if (entry.kind !== 'scalar' || entry.config.alarm === undefined) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const [seriesId, samples] of entry.series) {
|
|
52
|
+
sampleSets.push({
|
|
53
|
+
name: seriesId === '' ? metricName : `${metricName}#${seriesId}`,
|
|
54
|
+
samples
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return sampleSets;
|
|
59
|
+
}
|
|
28
60
|
/**
|
|
29
61
|
* Base class for custom benchmark metrics. Use `ScalarMetric` or `DiscreteMetric`.
|
|
30
62
|
*
|
package/ciReport.d.mts
CHANGED
|
@@ -10,15 +10,19 @@ declare const renderStatsSchema: z.ZodObject<{
|
|
|
10
10
|
actualDuration: z.ZodNumber;
|
|
11
11
|
stdDev: z.ZodNumber;
|
|
12
12
|
outliers: z.ZodNumber;
|
|
13
|
+
count: z.ZodOptional<z.ZodNumber>;
|
|
13
14
|
}, z.core.$strip>;
|
|
14
15
|
declare const metricStatsSchema: z.ZodObject<{
|
|
15
16
|
mean: z.ZodNumber;
|
|
16
17
|
stdDev: z.ZodNumber;
|
|
17
18
|
outliers: z.ZodNumber;
|
|
19
|
+
count: z.ZodOptional<z.ZodNumber>;
|
|
18
20
|
}, z.core.$strip>;
|
|
19
21
|
declare const benchmarkReportEntrySchema: z.ZodObject<{
|
|
20
22
|
iterations: z.ZodNumber;
|
|
21
23
|
totalDuration: z.ZodNumber;
|
|
24
|
+
totalStdDev: z.ZodOptional<z.ZodNumber>;
|
|
25
|
+
totalCount: z.ZodOptional<z.ZodNumber>;
|
|
22
26
|
renders: z.ZodArray<z.ZodObject<{
|
|
23
27
|
id: z.ZodString;
|
|
24
28
|
phase: z.ZodEnum<{
|
|
@@ -30,16 +34,20 @@ declare const benchmarkReportEntrySchema: z.ZodObject<{
|
|
|
30
34
|
actualDuration: z.ZodNumber;
|
|
31
35
|
stdDev: z.ZodNumber;
|
|
32
36
|
outliers: z.ZodNumber;
|
|
37
|
+
count: z.ZodOptional<z.ZodNumber>;
|
|
33
38
|
}, z.core.$strip>>;
|
|
34
39
|
metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
35
40
|
mean: z.ZodNumber;
|
|
36
41
|
stdDev: z.ZodNumber;
|
|
37
42
|
outliers: z.ZodNumber;
|
|
43
|
+
count: z.ZodOptional<z.ZodNumber>;
|
|
38
44
|
}, z.core.$strip>>;
|
|
39
45
|
}, z.core.$strip>;
|
|
40
46
|
declare const benchmarkReportSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
41
47
|
iterations: z.ZodNumber;
|
|
42
48
|
totalDuration: z.ZodNumber;
|
|
49
|
+
totalStdDev: z.ZodOptional<z.ZodNumber>;
|
|
50
|
+
totalCount: z.ZodOptional<z.ZodNumber>;
|
|
43
51
|
renders: z.ZodArray<z.ZodObject<{
|
|
44
52
|
id: z.ZodString;
|
|
45
53
|
phase: z.ZodEnum<{
|
|
@@ -51,11 +59,13 @@ declare const benchmarkReportSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
|
51
59
|
actualDuration: z.ZodNumber;
|
|
52
60
|
stdDev: z.ZodNumber;
|
|
53
61
|
outliers: z.ZodNumber;
|
|
62
|
+
count: z.ZodOptional<z.ZodNumber>;
|
|
54
63
|
}, z.core.$strip>>;
|
|
55
64
|
metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
56
65
|
mean: z.ZodNumber;
|
|
57
66
|
stdDev: z.ZodNumber;
|
|
58
67
|
outliers: z.ZodNumber;
|
|
68
|
+
count: z.ZodOptional<z.ZodNumber>;
|
|
59
69
|
}, z.core.$strip>>;
|
|
60
70
|
}, z.core.$strip>>;
|
|
61
71
|
declare const metricDefinitionSchema: z.ZodObject<{
|
package/ciReport.mjs
CHANGED
|
@@ -31,16 +31,26 @@ const renderStatsSchema = z.object({
|
|
|
31
31
|
startTime: z.number(),
|
|
32
32
|
actualDuration: z.number(),
|
|
33
33
|
stdDev: z.number(),
|
|
34
|
-
outliers: z.number()
|
|
34
|
+
outliers: z.number(),
|
|
35
|
+
// Effective sample count behind mean/stdDev, used as `n` for the comparison's Welch's t-test.
|
|
36
|
+
// Optional so baselines uploaded before it was added still parse.
|
|
37
|
+
count: z.number().optional()
|
|
35
38
|
});
|
|
36
39
|
const metricStatsSchema = z.object({
|
|
37
40
|
mean: z.number(),
|
|
38
41
|
stdDev: z.number(),
|
|
39
|
-
outliers: z.number()
|
|
42
|
+
outliers: z.number(),
|
|
43
|
+
// See `renderStatsSchema.count`. Optional for backward compatibility with older uploads.
|
|
44
|
+
count: z.number().optional()
|
|
40
45
|
});
|
|
41
46
|
const benchmarkReportEntrySchema = z.object({
|
|
42
47
|
iterations: z.number(),
|
|
43
48
|
totalDuration: z.number(),
|
|
49
|
+
// Spread and effective sample count of the per-iteration total duration, used as the stdDev/`n`
|
|
50
|
+
// for the comparison's Welch test on total duration. Optional for backward compatibility with
|
|
51
|
+
// uploads made before they were added.
|
|
52
|
+
totalStdDev: z.number().optional(),
|
|
53
|
+
totalCount: z.number().optional(),
|
|
44
54
|
renders: z.array(renderStatsSchema),
|
|
45
55
|
metrics: z.record(z.string(), metricStatsSchema)
|
|
46
56
|
});
|
package/format.d.mts
CHANGED
|
@@ -4,9 +4,15 @@ export declare const green: (s: string) => string;
|
|
|
4
4
|
export declare const yellow: (s: string) => string;
|
|
5
5
|
export declare const cyan: (s: string) => string;
|
|
6
6
|
export declare function fileUrl(filePath: string): string;
|
|
7
|
-
interface Column {
|
|
7
|
+
export interface Column {
|
|
8
8
|
header: string;
|
|
9
|
-
|
|
9
|
+
/** Width the column keeps when every cell is narrower. It grows to fit a wider cell. */
|
|
10
|
+
minWidth: number;
|
|
11
|
+
/**
|
|
12
|
+
* Width the column may not exceed; wider cells are truncated with an ellipsis. Omit to let the
|
|
13
|
+
* column grow freely, which is what a value the caller can't pre-measure (a metric formatted with
|
|
14
|
+
* an arbitrary unit) needs — truncating those would destroy the number the table exists to show.
|
|
15
|
+
*/
|
|
16
|
+
maxWidth?: number;
|
|
10
17
|
}
|
|
11
|
-
export declare function printTable(columns: Column[], rows: string[][], footer?: string, title?: string): void;
|
|
12
|
-
export {};
|
|
18
|
+
export declare function printTable(columns: Column[], rows: string[][], footer?: string, title?: string): void;
|
package/format.mjs
CHANGED
|
@@ -8,19 +8,72 @@ export const cyan = s => styleText('cyan', s);
|
|
|
8
8
|
export function fileUrl(filePath) {
|
|
9
9
|
return pathToFileURL(filePath).href;
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
|
|
12
|
+
/** Matches an ANSI colour code. Captured, so `split` keeps the codes alongside the text. */
|
|
13
|
+
// eslint-disable-next-line no-control-regex
|
|
14
|
+
const ANSI_PATTERN = /(\x1b\[[0-9;]*m)/g;
|
|
15
|
+
function stripAnsi(str) {
|
|
16
|
+
return str.replace(ANSI_PATTERN, '');
|
|
17
|
+
}
|
|
18
|
+
/** Printable width, i.e. ignoring the zero-width ANSI escape codes that colour a string. */
|
|
19
|
+
function visibleWidth(str) {
|
|
20
|
+
return stripAnsi(str).length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Truncates to a visible width, marking the cut with an ellipsis. Escape codes are carried over
|
|
25
|
+
* rather than counted, so a styled string keeps the closing code that would otherwise be dropped
|
|
26
|
+
* along with the text it terminated (leaving the rest of the terminal painted).
|
|
27
|
+
*/
|
|
28
|
+
function truncate(str, maxWidth) {
|
|
29
|
+
if (visibleWidth(str) <= maxWidth) {
|
|
13
30
|
return str;
|
|
14
31
|
}
|
|
15
|
-
|
|
32
|
+
let result = '';
|
|
33
|
+
let remaining = maxWidth - 1;
|
|
34
|
+
for (const segment of str.split(ANSI_PATTERN)) {
|
|
35
|
+
if (segment.startsWith('\x1b')) {
|
|
36
|
+
result += segment;
|
|
37
|
+
} else if (remaining > 0) {
|
|
38
|
+
result += segment.slice(0, remaining);
|
|
39
|
+
remaining -= Math.min(segment.length, remaining);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return `${result}…`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Right-aligns to a visible width, so colour codes in the cell don't eat into the padding. */
|
|
46
|
+
function padCell(cell, width) {
|
|
47
|
+
return ' '.repeat(Math.max(0, width - visibleWidth(cell))) + cell;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fits a string to an exact visible width, truncating or padding as needed. */
|
|
51
|
+
function fitCell(cell, width) {
|
|
52
|
+
return padCell(truncate(cell, width), width);
|
|
16
53
|
}
|
|
17
54
|
export function printTable(columns, rows, footer, title) {
|
|
18
|
-
|
|
19
|
-
|
|
55
|
+
// Cells are sized off content, because a caller can't know how wide a value will render (a metric
|
|
56
|
+
// carrying a unit — `0.456 ms±0.057 ms` — is far wider than a bare `0.46±0.06`). Padding a cell
|
|
57
|
+
// that already overflows its column is a no-op, which used to let one long value push that row's
|
|
58
|
+
// dividers out of line with every other row.
|
|
59
|
+
const colWidths = columns.map((col, index) => {
|
|
60
|
+
const content = Math.max(col.minWidth, visibleWidth(col.header), ...rows.map(row => visibleWidth(row[index] ?? '')));
|
|
61
|
+
return col.maxWidth === undefined ? content : Math.min(content, col.maxWidth);
|
|
62
|
+
});
|
|
63
|
+
const innerWidth = () => colWidths.reduce((sum, w) => sum + w + 2, 0) + colWidths.length - 1;
|
|
64
|
+
// The footer spans the whole table, so it too can be the widest thing in it. It reports counts
|
|
65
|
+
// that are worth nothing abbreviated, so the table stretches to fit it — the slack goes on the
|
|
66
|
+
// last column, keeping the columns left of it where the reader expects. A title, by contrast, is
|
|
67
|
+
// a name: arbitrarily long, and legible truncated, so it fits itself to the table below.
|
|
68
|
+
const footerWidth = footer ? visibleWidth(footer) + 2 : 0;
|
|
69
|
+
if (colWidths.length > 0 && footerWidth > innerWidth()) {
|
|
70
|
+
colWidths[colWidths.length - 1] += footerWidth - innerWidth();
|
|
71
|
+
}
|
|
72
|
+
const totalInner = innerWidth();
|
|
20
73
|
if (title) {
|
|
21
74
|
const titleTop = dim(`┌${'─'.repeat(totalInner)}┐`);
|
|
22
75
|
const titleContent = ` ${truncate(title, totalInner - 2)}`;
|
|
23
|
-
const titlePadding = totalInner - titleContent
|
|
76
|
+
const titlePadding = totalInner - visibleWidth(titleContent);
|
|
24
77
|
const titleLine = dim('│') + titleContent + ' '.repeat(Math.max(0, titlePadding)) + dim('│');
|
|
25
78
|
const titleSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┬')}┤`);
|
|
26
79
|
|
|
@@ -36,7 +89,7 @@ export function printTable(columns, rows, footer, title) {
|
|
|
36
89
|
console.log(topBorder);
|
|
37
90
|
}
|
|
38
91
|
const headerSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┼')}┤`);
|
|
39
|
-
const headerCells = columns.map(col => ` ${col.header
|
|
92
|
+
const headerCells = columns.map((col, index) => ` ${fitCell(col.header, colWidths[index])} `);
|
|
40
93
|
const headerLine = dim('│') + headerCells.join(dim('│')) + dim('│');
|
|
41
94
|
|
|
42
95
|
// eslint-disable-next-line no-console
|
|
@@ -44,14 +97,15 @@ export function printTable(columns, rows, footer, title) {
|
|
|
44
97
|
// eslint-disable-next-line no-console
|
|
45
98
|
console.log(headerSep);
|
|
46
99
|
for (const row of rows) {
|
|
47
|
-
|
|
100
|
+
// Driven by the columns, not the row, so a short row still emits every cell and divider.
|
|
101
|
+
const cells = colWidths.map((width, index) => ` ${fitCell(row[index] ?? '', width)} `);
|
|
48
102
|
// eslint-disable-next-line no-console
|
|
49
103
|
console.log(dim('│') + cells.join(dim('│')) + dim('│'));
|
|
50
104
|
}
|
|
51
105
|
if (footer) {
|
|
52
106
|
const footerSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┴')}┤`);
|
|
53
107
|
const footerContent = ` ${footer}`;
|
|
54
|
-
const padding = totalInner -
|
|
108
|
+
const padding = totalInner - visibleWidth(footerContent);
|
|
55
109
|
const footerLine = dim('│') + footerContent + ' '.repeat(Math.max(0, padding)) + dim('│');
|
|
56
110
|
const bottomBorder = dim(`└${'─'.repeat(totalInner)}┘`);
|
|
57
111
|
|
|
@@ -66,10 +120,4 @@ export function printTable(columns, rows, footer, title) {
|
|
|
66
120
|
// eslint-disable-next-line no-console
|
|
67
121
|
console.log(bottomBorder);
|
|
68
122
|
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Strip ANSI escape codes to measure visible string length
|
|
72
|
-
function stripAnsi(str) {
|
|
73
|
-
// eslint-disable-next-line no-control-regex
|
|
74
|
-
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
75
123
|
}
|
package/index.d.mts
CHANGED
|
@@ -14,8 +14,26 @@ declare global {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
interface BenchmarkOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Fixed number of measured iterations. When set, disables adaptive sampling (equivalent to
|
|
19
|
+
* `minRuns === maxRuns === runs`). Prefer leaving this unset and letting the harness sample
|
|
20
|
+
* adaptively; use it only to pin a benchmark to an exact iteration count.
|
|
21
|
+
*/
|
|
17
22
|
runs?: number;
|
|
23
|
+
/** Warmup iterations run before measurement begins (not recorded). Defaults to `5`. */
|
|
18
24
|
warmupRuns?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Adaptive sampling: the harness keeps measuring until the mean render duration is estimated to
|
|
27
|
+
* within `targetRme`, then stops. `minRuns` is the floor before the stopping rule can trigger,
|
|
28
|
+
* `maxRuns` the ceiling. Ignored when `runs` is set. Defaults: `minRuns` 10, `maxRuns` 50.
|
|
29
|
+
*/
|
|
30
|
+
minRuns?: number;
|
|
31
|
+
maxRuns?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Target relative margin of error (half-width of the 95% confidence interval of the mean, as a
|
|
34
|
+
* fraction of the mean) at which adaptive sampling stops. Defaults to `0.02` (2%).
|
|
35
|
+
*/
|
|
36
|
+
targetRme?: number;
|
|
19
37
|
afterEach?: () => Promise<void> | void;
|
|
20
38
|
/**
|
|
21
39
|
* Start each iteration with React render/paint recording paused. The interaction callback then
|
package/index.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import * as ReactDOMClient from 'react-dom/client'; // aliased to react-dom/prof
|
|
|
12
12
|
import * as ReactDOM from 'react-dom';
|
|
13
13
|
import { ElementTiming } from "./ElementTiming.mjs";
|
|
14
14
|
import { ScalarMetric } from "./ScalarMetric.mjs";
|
|
15
|
+
import { describeUnconvergedSignals, relativeMarginOfError } from "./stats.mjs";
|
|
16
|
+
import { collectAdaptiveMetricSamples } from "./Metric.mjs";
|
|
15
17
|
import { metricsGate } from "./metricsGate.mjs";
|
|
16
18
|
import { createReactRecordingControls } from "./reactRecording.mjs";
|
|
17
19
|
import { runProfileSession } from "./profileSession.mjs";
|
|
@@ -199,10 +201,30 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
|
|
|
199
201
|
it(name, async ({
|
|
200
202
|
task
|
|
201
203
|
}) => {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
const
|
|
204
|
+
const warmupRuns = options?.warmupRuns ?? 5;
|
|
205
|
+
// A fixed `runs` pins both bounds; otherwise sample adaptively between min and max.
|
|
206
|
+
const isAdaptive = options?.runs === undefined;
|
|
207
|
+
const minRuns = options?.runs ?? options?.minRuns ?? 10;
|
|
208
|
+
const maxRuns = options?.runs ?? options?.maxRuns ?? 50;
|
|
209
|
+
const targetRme = options?.targetRme ?? 0.02;
|
|
210
|
+
|
|
211
|
+
// Fail fast on misconfigured sampling bounds rather than silently running a surprising loop.
|
|
212
|
+
if (minRuns < 1 || maxRuns < minRuns || targetRme <= 0) {
|
|
213
|
+
throw new Error(`Invalid benchmark sampling options for "${name}": require 1 <= minRuns (${minRuns}) <= ` + `maxRuns (${maxRuns}) and targetRme (${targetRme}) > 0.`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Upper bound on the loop; the adaptive stopping rule usually breaks out earlier.
|
|
217
|
+
const totalRuns = warmupRuns + maxRuns;
|
|
205
218
|
const iterations = [];
|
|
219
|
+
// Per measured iteration: total render duration, the primary signal the stopping rule converges
|
|
220
|
+
// on (alongside each alarmed scalar metric).
|
|
221
|
+
const iterationDurations = [];
|
|
222
|
+
// Relative margin of error of the render duration at the last stopping check — reused for the
|
|
223
|
+
// non-convergence warning instead of recomputing the same trim/variance pass.
|
|
224
|
+
let durationRme = Infinity;
|
|
225
|
+
// Per-metric relative margin of error from the last stopping check (only populated once render
|
|
226
|
+
// duration has converged), named so the non-convergence warning can point at the noisy metric.
|
|
227
|
+
let metricRmes = [];
|
|
206
228
|
|
|
207
229
|
// Paint timings are recorded as one harness-owned `bench:paint` metric: the default sentinel
|
|
208
230
|
// is the base series (`bench:paint`) and named `elementtiming` markers are sub-series
|
|
@@ -297,11 +319,57 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
|
|
|
297
319
|
iterations.push({
|
|
298
320
|
renders: captures
|
|
299
321
|
});
|
|
322
|
+
// Total render duration of this iteration — the adaptive stopping rule's convergence signal.
|
|
323
|
+
iterationDurations.push(captures.reduce((sum, capture) => sum + capture.actualDuration, 0));
|
|
300
324
|
}
|
|
301
325
|
if (options?.afterEach) {
|
|
302
326
|
// eslint-disable-next-line no-await-in-loop
|
|
303
327
|
await options.afterEach();
|
|
304
328
|
}
|
|
329
|
+
|
|
330
|
+
// Adaptive stopping: once past the floor, stop as soon as every measured signal — the mean
|
|
331
|
+
// render duration and each alarmed scalar metric — is estimated tightly enough. Custom metrics
|
|
332
|
+
// can only *delay* the stop (the `minRuns` floor and duration target still apply), so a noisy
|
|
333
|
+
// metric keeps sampling rather than being left underpowered for the dashboard's significance
|
|
334
|
+
// test. `maxRuns` caps the work for benchmarks too noisy to reach `targetRme`.
|
|
335
|
+
if (!isWarmup && iterationDurations.length >= minRuns) {
|
|
336
|
+
durationRme = relativeMarginOfError(iterationDurations);
|
|
337
|
+
// Duration is the primary (and usually last-to-tighten) signal, so check it first: the
|
|
338
|
+
// per-metric margin-of-error pass only runs once duration itself has converged. A
|
|
339
|
+
// zero-centered metric (mean <= 0) yields a margin of error of 0 — relative precision is
|
|
340
|
+
// undefined for it — so it never blocks; such a metric can't fire a relative alarm anyway.
|
|
341
|
+
if (durationRme <= targetRme) {
|
|
342
|
+
metricRmes = collectAdaptiveMetricSamples(task).map(({
|
|
343
|
+
name: metricName,
|
|
344
|
+
samples
|
|
345
|
+
}) => ({
|
|
346
|
+
name: metricName,
|
|
347
|
+
rme: relativeMarginOfError(samples)
|
|
348
|
+
}));
|
|
349
|
+
if (metricRmes.every(({
|
|
350
|
+
rme
|
|
351
|
+
}) => rme <= targetRme)) {
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Warn only when adaptive sampling exhausted `maxRuns` without every signal reaching `targetRme`,
|
|
359
|
+
// naming each signal that stayed noisy so the author knows what to fix — the common (converged)
|
|
360
|
+
// case stays quiet so large suites don't flood CI logs. Skipped in fixed mode (`runs` set), where
|
|
361
|
+
// there is no convergence target to miss. Reuses the margins of error already computed above.
|
|
362
|
+
if (isAdaptive && iterationDurations.length >= maxRuns) {
|
|
363
|
+
const unconverged = describeUnconvergedSignals([{
|
|
364
|
+
label: 'duration',
|
|
365
|
+
rme: durationRme
|
|
366
|
+
}, ...metricRmes.map(metric => ({
|
|
367
|
+
label: `metric '${metric.name}'`,
|
|
368
|
+
rme: metric.rme
|
|
369
|
+
}))], targetRme);
|
|
370
|
+
if (unconverged.length > 0) {
|
|
371
|
+
console.warn(`Benchmark "${name}" reached maxRuns (${maxRuns} runs) without converging to the ` + `${(targetRme * 100).toFixed(2)}% target: ${unconverged.join(', ')}. Results may be ` + `noisier than intended — consider raising maxRuns or reducing variance.`);
|
|
372
|
+
}
|
|
305
373
|
}
|
|
306
374
|
task.meta.benchmarkIterations = iterations;
|
|
307
375
|
task.meta.benchmarkName = name;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mui/internal-benchmark",
|
|
3
|
-
"version": "0.0.3-canary.
|
|
3
|
+
"version": "0.0.3-canary.17",
|
|
4
4
|
"author": "MUI Team",
|
|
5
5
|
"description": "Benchmark utilities for MUI projects. Internal package.",
|
|
6
6
|
"repository": {
|
|
@@ -68,5 +68,5 @@
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
|
-
"gitSha": "
|
|
71
|
+
"gitSha": "69ae6a8458bc68be5bef922f72637180b2b160e5"
|
|
72
72
|
}
|
package/reporter.mjs
CHANGED
|
@@ -7,9 +7,6 @@ import { uploadCiReport } from "./upload.mjs";
|
|
|
7
7
|
import { syncPrComment } from "./syncPrComment.mjs";
|
|
8
8
|
// Import for TaskMeta augmentation side effect
|
|
9
9
|
import "./taskMetaAugmentation.mjs";
|
|
10
|
-
function getEventKey(event) {
|
|
11
|
-
return `${event.id}:${event.phase}`;
|
|
12
|
-
}
|
|
13
10
|
|
|
14
11
|
/** Order-insensitive deep equality, treating a missing key and an `undefined` value as equal. */
|
|
15
12
|
function deepEqual(first, second) {
|
|
@@ -56,21 +53,24 @@ function generateReportFromIterations(iterations) {
|
|
|
56
53
|
const renderStats = [];
|
|
57
54
|
for (let index = 0; index < expectedLength; index += 1) {
|
|
58
55
|
const durations = iterations.map(iteration => iteration.renders[index].actualDuration);
|
|
56
|
+
|
|
57
|
+
// The per-render coefficient of variation is surfaced as the "Var%" column of the results
|
|
58
|
+
// table (informational). It no longer warrants a warning: the Welch comparison already accounts
|
|
59
|
+
// for a render's spread (a noisy render yields a high p-value rather than a false flag), and
|
|
60
|
+
// adaptive sampling drives the *mean's* precision to `targetRme`, warning separately when it
|
|
61
|
+
// can't converge. High spread alone no longer implies an unreliable result.
|
|
59
62
|
const {
|
|
60
63
|
mean: iqrMean,
|
|
61
64
|
stdDev: iqrStdDev,
|
|
62
|
-
outliers
|
|
65
|
+
outliers,
|
|
66
|
+
count
|
|
63
67
|
} = aggregateSamples(durations);
|
|
64
|
-
const coefficientOfVariation = iqrMean > 0 ? iqrStdDev / iqrMean : 0;
|
|
65
|
-
if (iqrMean > 1 && coefficientOfVariation > 0.1) {
|
|
66
|
-
const event = firstIteration.renders[index];
|
|
67
|
-
console.warn(`High coefficient of variation (${(coefficientOfVariation * 100).toFixed(1)}%) for render #${index} event "${getEventKey(event)}". ` + `Mean: ${iqrMean.toFixed(2)}ms, StdDev: ${iqrStdDev.toFixed(2)}ms. Results may be unreliable.`);
|
|
68
|
-
}
|
|
69
68
|
renderStats.push({
|
|
70
69
|
event: firstIteration.renders[index],
|
|
71
70
|
iqrMean,
|
|
72
71
|
iqrStdDev,
|
|
73
|
-
outliers
|
|
72
|
+
outliers,
|
|
73
|
+
count
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -84,13 +84,13 @@ function generateReportFromIterations(iterations) {
|
|
|
84
84
|
meanGaps.push(calculateMean(gaps));
|
|
85
85
|
}
|
|
86
86
|
const renders = [];
|
|
87
|
-
let totalDuration = 0;
|
|
88
87
|
for (let index = 0; index < expectedLength; index += 1) {
|
|
89
88
|
const {
|
|
90
89
|
event,
|
|
91
90
|
iqrMean,
|
|
92
91
|
iqrStdDev,
|
|
93
|
-
outliers
|
|
92
|
+
outliers,
|
|
93
|
+
count
|
|
94
94
|
} = renderStats[index];
|
|
95
95
|
const startTime = index === 0 ? 0 : renders[index - 1].startTime + renders[index - 1].actualDuration + meanGaps[index];
|
|
96
96
|
renders.push({
|
|
@@ -99,16 +99,27 @@ function generateReportFromIterations(iterations) {
|
|
|
99
99
|
startTime,
|
|
100
100
|
actualDuration: iqrMean,
|
|
101
101
|
stdDev: iqrStdDev,
|
|
102
|
-
outliers
|
|
102
|
+
outliers,
|
|
103
|
+
count
|
|
103
104
|
});
|
|
104
|
-
totalDuration += iqrMean;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// Aggregate the *actual* per-iteration total duration (sum of that iteration's render durations).
|
|
108
|
+
// `totalDuration`, `totalStdDev`, and `totalCount` are all taken from this one distribution so the
|
|
109
|
+
// comparison's Welch test gets a mutually consistent (mean, stdDev, n) triple — and the spread
|
|
110
|
+
// reflects real cross-render correlation, which a sum of per-render variances cannot. (Because
|
|
111
|
+
// outliers are filtered here on the totals, not per render, `totalDuration` may differ marginally
|
|
112
|
+
// from summing the per-render means shown in the render table.)
|
|
113
|
+
const perIterationTotals = iterations.map(iteration => iteration.renders.reduce((sum, render) => sum + render.actualDuration, 0));
|
|
114
|
+
const totalStats = aggregateSamples(perIterationTotals);
|
|
115
|
+
|
|
107
116
|
// Custom + paint metrics are merged separately from `task.meta.benchmarkMetrics`.
|
|
108
117
|
const metrics = {};
|
|
109
118
|
return {
|
|
110
119
|
iterations: iterationCount,
|
|
111
|
-
totalDuration,
|
|
120
|
+
totalDuration: totalStats.mean,
|
|
121
|
+
totalStdDev: totalStats.stdDev,
|
|
122
|
+
totalCount: totalStats.count,
|
|
112
123
|
renders,
|
|
113
124
|
metrics
|
|
114
125
|
};
|
|
@@ -116,8 +127,9 @@ function generateReportFromIterations(iterations) {
|
|
|
116
127
|
const LABEL_WIDTH = 28;
|
|
117
128
|
const STAT_WIDTH = 16;
|
|
118
129
|
const CV_WIDTH = 8;
|
|
130
|
+
const OUT_WIDTH = 4;
|
|
119
131
|
function colorCV(cv) {
|
|
120
|
-
const str = `${cv.toFixed(1)}
|
|
132
|
+
const str = `${cv.toFixed(1)}%`;
|
|
121
133
|
if (cv > 10) {
|
|
122
134
|
return red(str);
|
|
123
135
|
}
|
|
@@ -126,6 +138,33 @@ function colorCV(cv) {
|
|
|
126
138
|
}
|
|
127
139
|
return dim(str);
|
|
128
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Columns shared by the duration and metric tables: a label pinned to a fixed width, then stats
|
|
144
|
+
* free to grow (a metric can be formatted with any unit, so its width isn't knowable here).
|
|
145
|
+
*/
|
|
146
|
+
function statColumns(labelHeader, statHeader) {
|
|
147
|
+
return [{
|
|
148
|
+
header: labelHeader,
|
|
149
|
+
minWidth: LABEL_WIDTH,
|
|
150
|
+
maxWidth: LABEL_WIDTH
|
|
151
|
+
}, {
|
|
152
|
+
header: statHeader,
|
|
153
|
+
minWidth: STAT_WIDTH
|
|
154
|
+
}, {
|
|
155
|
+
header: 'Var%',
|
|
156
|
+
minWidth: CV_WIDTH
|
|
157
|
+
}, {
|
|
158
|
+
header: 'Out',
|
|
159
|
+
minWidth: OUT_WIDTH
|
|
160
|
+
}];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A `label | mean±σ | variation | outliers` row, as used by both tables. */
|
|
164
|
+
function statRow(label, iqrStr, mean, stdDev, outliers) {
|
|
165
|
+
const cv = mean > 0 ? stdDev / mean * 100 : 0;
|
|
166
|
+
return [label, cyan(iqrStr), colorCV(cv), outliers > 0 ? yellow(String(outliers)) : dim('0')];
|
|
167
|
+
}
|
|
129
168
|
function printDurationMatrix(name, report, footer) {
|
|
130
169
|
if (report.renders.length === 0) {
|
|
131
170
|
return;
|
|
@@ -133,24 +172,10 @@ function printDurationMatrix(name, report, footer) {
|
|
|
133
172
|
const rows = [];
|
|
134
173
|
for (let r = 0; r < report.renders.length; r += 1) {
|
|
135
174
|
const render = report.renders[r];
|
|
136
|
-
const label = `#${r} ${render.id}:${render.phase}`;
|
|
137
175
|
const iqrStr = `${render.actualDuration.toFixed(2)}±${render.stdDev.toFixed(2)}`;
|
|
138
|
-
|
|
139
|
-
rows.push([label.slice(0, LABEL_WIDTH).padStart(LABEL_WIDTH), cyan(iqrStr.padStart(STAT_WIDTH)), colorCV(cv), render.outliers > 0 ? yellow(String(render.outliers).padStart(4)) : dim('0'.padStart(4))]);
|
|
176
|
+
rows.push(statRow(`#${r} ${render.id}:${render.phase}`, iqrStr, render.actualDuration, render.stdDev, render.outliers));
|
|
140
177
|
}
|
|
141
|
-
printTable(
|
|
142
|
-
header: 'Render',
|
|
143
|
-
width: LABEL_WIDTH
|
|
144
|
-
}, {
|
|
145
|
-
header: 'Mean±σ (ms)',
|
|
146
|
-
width: STAT_WIDTH
|
|
147
|
-
}, {
|
|
148
|
-
header: 'Var%',
|
|
149
|
-
width: CV_WIDTH
|
|
150
|
-
}, {
|
|
151
|
-
header: 'Out',
|
|
152
|
-
width: 4
|
|
153
|
-
}], rows, footer, name);
|
|
178
|
+
printTable(statColumns('Render', 'Mean±σ (ms)'), rows, footer, name);
|
|
154
179
|
}
|
|
155
180
|
|
|
156
181
|
/** Strips a `#sub-series` suffix to recover the metric name used to look up its definition. */
|
|
@@ -178,9 +203,13 @@ function mergeCustomMetrics(report, customMetrics, definitions) {
|
|
|
178
203
|
report.metrics[key] = {
|
|
179
204
|
mean: stats.mean,
|
|
180
205
|
stdDev: stats.stdDev,
|
|
181
|
-
outliers: stats.outliers
|
|
206
|
+
outliers: stats.outliers,
|
|
207
|
+
count: stats.count
|
|
182
208
|
};
|
|
183
|
-
|
|
209
|
+
// `stats.count` is the post-outlier-removal count; add back the dropped outliers to recover
|
|
210
|
+
// the raw number of recorded samples, which is what a metric-only benchmark reports as its
|
|
211
|
+
// iteration count below.
|
|
212
|
+
maxCount = Math.max(maxCount, stats.count + stats.outliers);
|
|
184
213
|
}
|
|
185
214
|
const definition = {
|
|
186
215
|
kind: metric.kind,
|
|
@@ -208,23 +237,10 @@ function printMetricsTable(name, metrics, iterationCount, definitions) {
|
|
|
208
237
|
const rows = entries.map(([metricName, stats]) => {
|
|
209
238
|
const definition = definitions[baseMetricName(metricName)];
|
|
210
239
|
const iqrStr = `${formatMetricValue(stats.mean, definition)}±${formatMetricValue(stats.stdDev, definition)}`;
|
|
211
|
-
const cv = stats.mean > 0 ? stats.stdDev / stats.mean * 100 : 0;
|
|
212
240
|
const label = definition?.alarm ? `${metricName} ⚠` : metricName;
|
|
213
|
-
return
|
|
241
|
+
return statRow(label, iqrStr, stats.mean, stats.stdDev, stats.outliers);
|
|
214
242
|
});
|
|
215
|
-
printTable(
|
|
216
|
-
header: 'Metric',
|
|
217
|
-
width: LABEL_WIDTH
|
|
218
|
-
}, {
|
|
219
|
-
header: 'Mean±σ',
|
|
220
|
-
width: STAT_WIDTH
|
|
221
|
-
}, {
|
|
222
|
-
header: 'Var%',
|
|
223
|
-
width: CV_WIDTH
|
|
224
|
-
}, {
|
|
225
|
-
header: 'Out',
|
|
226
|
-
width: 4
|
|
227
|
-
}], rows, dim(`${iterationCount} iterations`), `${name} — Metrics`);
|
|
243
|
+
printTable(statColumns('Metric', 'Mean±σ'), rows, dim(`${iterationCount} iterations`), `${name} — Metrics`);
|
|
228
244
|
}
|
|
229
245
|
async function loadBaselineReport(baselinePath) {
|
|
230
246
|
const raw = await fs.readFile(baselinePath, 'utf8');
|
package/stats.d.mts
CHANGED
|
@@ -9,12 +9,54 @@ export declare function quantile(sorted: number[], q: number): number;
|
|
|
9
9
|
*/
|
|
10
10
|
export declare function isOutlier(value: number, q1: number, q3: number): boolean;
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Drops IQR outliers (values outside the 1.5×IQR fences), returning the surviving samples. Falls
|
|
13
|
+
* back to the original values when filtering would remove everything. Shared by every consumer that
|
|
14
|
+
* wants to reason about typical performance rather than measurement artifacts (GC pauses, scheduling
|
|
15
|
+
* hiccups), so they all trim identically.
|
|
16
|
+
*/
|
|
17
|
+
export declare function removeOutliers(values: number[]): number[];
|
|
18
|
+
/**
|
|
19
|
+
* Aggregates a series of samples into a mean, standard deviation, outlier count, and effective
|
|
20
|
+
* sample count using IQR-based outlier removal. This is the shared aggregation core for custom
|
|
21
|
+
* metrics.
|
|
22
|
+
*
|
|
23
|
+
* `count` is the number of samples that actually back `mean`/`stdDev` (post-outlier-removal), so a
|
|
24
|
+
* downstream Welch's t-test can use it directly as the `n` behind those stats.
|
|
15
25
|
*/
|
|
16
26
|
export declare function aggregateSamples(values: number[]): {
|
|
17
27
|
mean: number;
|
|
18
28
|
stdDev: number;
|
|
19
29
|
outliers: number;
|
|
20
|
-
|
|
30
|
+
count: number;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Relative margin of error of the mean — half the 95% confidence interval width divided by the
|
|
34
|
+
* mean, using the normal approximation (`z = 1.96`). This is the adaptive-sampling stopping signal:
|
|
35
|
+
* measurement continues until this drops to a target. A precise t-quantile is unnecessary just to
|
|
36
|
+
* decide when enough samples have been collected.
|
|
37
|
+
*
|
|
38
|
+
* IQR outliers are removed first, so the margin of error is measured on the same trimmed
|
|
39
|
+
* distribution the reported stats and the Welch comparison use. Without this, a benchmark that is
|
|
40
|
+
* stable apart from recurring GC/scheduling spikes would keep a high raw margin of error and sample
|
|
41
|
+
* all the way to its maximum (and warn that it "did not converge") even though its typical estimate
|
|
42
|
+
* settled long ago.
|
|
43
|
+
*
|
|
44
|
+
* Returns `Infinity` below two (trimmed) samples (spread can't be estimated) and `0` when the mean
|
|
45
|
+
* is non-positive (e.g. a metric-only benchmark with no render duration to converge on), so such
|
|
46
|
+
* benchmarks stop at their minimum run count rather than sampling to the maximum.
|
|
47
|
+
*/
|
|
48
|
+
export declare function relativeMarginOfError(samples: number[]): number;
|
|
49
|
+
/** A named adaptive-sampling signal (render duration or a metric) and its achieved margin of error. */
|
|
50
|
+
export interface SignalConvergence {
|
|
51
|
+
/** Human label, e.g. `duration` or `metric 'input-latency'`. */
|
|
52
|
+
label: string;
|
|
53
|
+
/** Relative margin of error achieved; `Infinity` when there were too few samples to estimate it. */
|
|
54
|
+
rme: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Describes the signals that failed to reach `targetRme`, each as a short phrase naming the signal
|
|
58
|
+
* and the margin of error it settled at (or "too few samples" when it couldn't be estimated).
|
|
59
|
+
* Returns an empty array when every signal converged. Pure so the non-convergence warning can be
|
|
60
|
+
* unit-tested apart from the measured benchmark loop.
|
|
61
|
+
*/
|
|
62
|
+
export declare function describeUnconvergedSignals(signals: SignalConvergence[], targetRme: number): string[];
|
package/stats.mjs
CHANGED
|
@@ -27,21 +27,89 @@ export function isOutlier(value, q1, q3) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Drops IQR outliers (values outside the 1.5×IQR fences), returning the surviving samples. Falls
|
|
31
|
+
* back to the original values when filtering would remove everything. Shared by every consumer that
|
|
32
|
+
* wants to reason about typical performance rather than measurement artifacts (GC pauses, scheduling
|
|
33
|
+
* hiccups), so they all trim identically.
|
|
33
34
|
*/
|
|
34
|
-
export function
|
|
35
|
+
export function removeOutliers(values) {
|
|
35
36
|
const sorted = values.toSorted((first, second) => first - second);
|
|
36
37
|
const q1 = quantile(sorted, 0.25);
|
|
37
38
|
const q3 = quantile(sorted, 0.75);
|
|
38
39
|
const filtered = values.filter(value => !isOutlier(value, q1, q3));
|
|
39
|
-
|
|
40
|
+
return filtered.length > 0 ? filtered : values;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Aggregates a series of samples into a mean, standard deviation, outlier count, and effective
|
|
45
|
+
* sample count using IQR-based outlier removal. This is the shared aggregation core for custom
|
|
46
|
+
* metrics.
|
|
47
|
+
*
|
|
48
|
+
* `count` is the number of samples that actually back `mean`/`stdDev` (post-outlier-removal), so a
|
|
49
|
+
* downstream Welch's t-test can use it directly as the `n` behind those stats.
|
|
50
|
+
*/
|
|
51
|
+
export function aggregateSamples(values) {
|
|
52
|
+
const used = removeOutliers(values);
|
|
40
53
|
const mean = calculateMean(used);
|
|
41
54
|
const stdDev = calculateStdDev(used, mean);
|
|
42
55
|
return {
|
|
43
56
|
mean,
|
|
44
57
|
stdDev,
|
|
45
|
-
outliers: values.length - used.length
|
|
58
|
+
outliers: values.length - used.length,
|
|
59
|
+
count: used.length
|
|
46
60
|
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Relative margin of error of the mean — half the 95% confidence interval width divided by the
|
|
65
|
+
* mean, using the normal approximation (`z = 1.96`). This is the adaptive-sampling stopping signal:
|
|
66
|
+
* measurement continues until this drops to a target. A precise t-quantile is unnecessary just to
|
|
67
|
+
* decide when enough samples have been collected.
|
|
68
|
+
*
|
|
69
|
+
* IQR outliers are removed first, so the margin of error is measured on the same trimmed
|
|
70
|
+
* distribution the reported stats and the Welch comparison use. Without this, a benchmark that is
|
|
71
|
+
* stable apart from recurring GC/scheduling spikes would keep a high raw margin of error and sample
|
|
72
|
+
* all the way to its maximum (and warn that it "did not converge") even though its typical estimate
|
|
73
|
+
* settled long ago.
|
|
74
|
+
*
|
|
75
|
+
* Returns `Infinity` below two (trimmed) samples (spread can't be estimated) and `0` when the mean
|
|
76
|
+
* is non-positive (e.g. a metric-only benchmark with no render duration to converge on), so such
|
|
77
|
+
* benchmarks stop at their minimum run count rather than sampling to the maximum.
|
|
78
|
+
*/
|
|
79
|
+
export function relativeMarginOfError(samples) {
|
|
80
|
+
const used = removeOutliers(samples);
|
|
81
|
+
const n = used.length;
|
|
82
|
+
if (n < 2) {
|
|
83
|
+
return Infinity;
|
|
84
|
+
}
|
|
85
|
+
const mean = calculateMean(used);
|
|
86
|
+
if (mean <= 0) {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
// Bessel-corrected (sample) variance: the samples estimate the spread of the population.
|
|
90
|
+
const variance = used.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (n - 1);
|
|
91
|
+
const standardError = Math.sqrt(variance / n);
|
|
92
|
+
return 1.96 * standardError / mean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A named adaptive-sampling signal (render duration or a metric) and its achieved margin of error. */
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Describes the signals that failed to reach `targetRme`, each as a short phrase naming the signal
|
|
99
|
+
* and the margin of error it settled at (or "too few samples" when it couldn't be estimated).
|
|
100
|
+
* Returns an empty array when every signal converged. Pure so the non-convergence warning can be
|
|
101
|
+
* unit-tested apart from the measured benchmark loop.
|
|
102
|
+
*/
|
|
103
|
+
export function describeUnconvergedSignals(signals, targetRme) {
|
|
104
|
+
const unconverged = [];
|
|
105
|
+
for (const {
|
|
106
|
+
label,
|
|
107
|
+
rme
|
|
108
|
+
} of signals) {
|
|
109
|
+
if (rme > targetRme) {
|
|
110
|
+
const achieved = Number.isFinite(rme) ? `RME ${(rme * 100).toFixed(2)}%` : 'too few samples';
|
|
111
|
+
unconverged.push(`${label} (${achieved})`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return unconverged;
|
|
47
115
|
}
|
package/types.d.mts
CHANGED
|
@@ -83,7 +83,10 @@ export interface MetricSampleStats {
|
|
|
83
83
|
mean: number;
|
|
84
84
|
stdDev: number;
|
|
85
85
|
outliers: number;
|
|
86
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Effective sample count behind `mean`/`stdDev` (post-outlier-removal). Serialized into the
|
|
88
|
+
* report as the `n` a Welch's t-test uses to compare this series against a baseline.
|
|
89
|
+
*/
|
|
87
90
|
count: number;
|
|
88
91
|
}
|
|
89
92
|
/** A custom metric's aggregated data attached to `task.meta`, keyed by metric name. */
|