@mui/internal-benchmark 0.0.3-canary.17 → 0.0.3-canary.18
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 +0 -19
- package/Metric.mjs +4 -36
- package/ciReport.d.mts +0 -10
- package/ciReport.mjs +2 -12
- package/format.d.mts +4 -10
- package/format.mjs +15 -63
- package/index.d.mts +0 -18
- package/index.mjs +3 -71
- package/package.json +2 -2
- package/reporter.mjs +49 -65
- package/stats.d.mts +4 -46
- package/stats.mjs +6 -74
- package/types.d.mts +1 -4
package/Metric.d.mts
CHANGED
|
@@ -1,24 +1,5 @@
|
|
|
1
|
-
import type { RunnerTestCase } from 'vitest';
|
|
2
1
|
import type { MetricConfig, MetricKind } from "./types.mjs";
|
|
3
2
|
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[];
|
|
22
3
|
export interface MetricRecordOptions {
|
|
23
4
|
/** Sub-series label. Recorded under `${name}#${id}` in the report; omit for the base series. */
|
|
24
5
|
id?: string;
|
package/Metric.mjs
CHANGED
|
@@ -12,9 +12,10 @@ 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
|
-
|
|
15
|
+
series[seriesId] = {
|
|
16
|
+
...aggregateSamples(samples),
|
|
17
|
+
count: samples.length
|
|
18
|
+
};
|
|
18
19
|
}
|
|
19
20
|
store[name] = {
|
|
20
21
|
kind: entry.kind,
|
|
@@ -24,39 +25,6 @@ function flush(test, accumulator) {
|
|
|
24
25
|
}
|
|
25
26
|
test.meta.benchmarkMetrics = store;
|
|
26
27
|
}
|
|
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
|
-
}
|
|
60
28
|
/**
|
|
61
29
|
* Base class for custom benchmark metrics. Use `ScalarMetric` or `DiscreteMetric`.
|
|
62
30
|
*
|
package/ciReport.d.mts
CHANGED
|
@@ -10,19 +10,15 @@ 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>;
|
|
14
13
|
}, z.core.$strip>;
|
|
15
14
|
declare const metricStatsSchema: z.ZodObject<{
|
|
16
15
|
mean: z.ZodNumber;
|
|
17
16
|
stdDev: z.ZodNumber;
|
|
18
17
|
outliers: z.ZodNumber;
|
|
19
|
-
count: z.ZodOptional<z.ZodNumber>;
|
|
20
18
|
}, z.core.$strip>;
|
|
21
19
|
declare const benchmarkReportEntrySchema: z.ZodObject<{
|
|
22
20
|
iterations: z.ZodNumber;
|
|
23
21
|
totalDuration: z.ZodNumber;
|
|
24
|
-
totalStdDev: z.ZodOptional<z.ZodNumber>;
|
|
25
|
-
totalCount: z.ZodOptional<z.ZodNumber>;
|
|
26
22
|
renders: z.ZodArray<z.ZodObject<{
|
|
27
23
|
id: z.ZodString;
|
|
28
24
|
phase: z.ZodEnum<{
|
|
@@ -34,20 +30,16 @@ declare const benchmarkReportEntrySchema: z.ZodObject<{
|
|
|
34
30
|
actualDuration: z.ZodNumber;
|
|
35
31
|
stdDev: z.ZodNumber;
|
|
36
32
|
outliers: z.ZodNumber;
|
|
37
|
-
count: z.ZodOptional<z.ZodNumber>;
|
|
38
33
|
}, z.core.$strip>>;
|
|
39
34
|
metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
40
35
|
mean: z.ZodNumber;
|
|
41
36
|
stdDev: z.ZodNumber;
|
|
42
37
|
outliers: z.ZodNumber;
|
|
43
|
-
count: z.ZodOptional<z.ZodNumber>;
|
|
44
38
|
}, z.core.$strip>>;
|
|
45
39
|
}, z.core.$strip>;
|
|
46
40
|
declare const benchmarkReportSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
47
41
|
iterations: z.ZodNumber;
|
|
48
42
|
totalDuration: z.ZodNumber;
|
|
49
|
-
totalStdDev: z.ZodOptional<z.ZodNumber>;
|
|
50
|
-
totalCount: z.ZodOptional<z.ZodNumber>;
|
|
51
43
|
renders: z.ZodArray<z.ZodObject<{
|
|
52
44
|
id: z.ZodString;
|
|
53
45
|
phase: z.ZodEnum<{
|
|
@@ -59,13 +51,11 @@ declare const benchmarkReportSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
|
59
51
|
actualDuration: z.ZodNumber;
|
|
60
52
|
stdDev: z.ZodNumber;
|
|
61
53
|
outliers: z.ZodNumber;
|
|
62
|
-
count: z.ZodOptional<z.ZodNumber>;
|
|
63
54
|
}, z.core.$strip>>;
|
|
64
55
|
metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
65
56
|
mean: z.ZodNumber;
|
|
66
57
|
stdDev: z.ZodNumber;
|
|
67
58
|
outliers: z.ZodNumber;
|
|
68
|
-
count: z.ZodOptional<z.ZodNumber>;
|
|
69
59
|
}, z.core.$strip>>;
|
|
70
60
|
}, z.core.$strip>>;
|
|
71
61
|
declare const metricDefinitionSchema: z.ZodObject<{
|
package/ciReport.mjs
CHANGED
|
@@ -31,26 +31,16 @@ const renderStatsSchema = z.object({
|
|
|
31
31
|
startTime: z.number(),
|
|
32
32
|
actualDuration: z.number(),
|
|
33
33
|
stdDev: 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()
|
|
34
|
+
outliers: z.number()
|
|
38
35
|
});
|
|
39
36
|
const metricStatsSchema = z.object({
|
|
40
37
|
mean: z.number(),
|
|
41
38
|
stdDev: z.number(),
|
|
42
|
-
outliers: z.number()
|
|
43
|
-
// See `renderStatsSchema.count`. Optional for backward compatibility with older uploads.
|
|
44
|
-
count: z.number().optional()
|
|
39
|
+
outliers: z.number()
|
|
45
40
|
});
|
|
46
41
|
const benchmarkReportEntrySchema = z.object({
|
|
47
42
|
iterations: z.number(),
|
|
48
43
|
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(),
|
|
54
44
|
renders: z.array(renderStatsSchema),
|
|
55
45
|
metrics: z.record(z.string(), metricStatsSchema)
|
|
56
46
|
});
|
package/format.d.mts
CHANGED
|
@@ -4,15 +4,9 @@ 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
|
-
|
|
7
|
+
interface Column {
|
|
8
8
|
header: string;
|
|
9
|
-
|
|
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;
|
|
9
|
+
width: number;
|
|
17
10
|
}
|
|
18
|
-
export declare function printTable(columns: Column[], rows: string[][], footer?: string, title?: string): void;
|
|
11
|
+
export declare function printTable(columns: Column[], rows: string[][], footer?: string, title?: string): void;
|
|
12
|
+
export {};
|
package/format.mjs
CHANGED
|
@@ -8,72 +8,19 @@ export const cyan = s => styleText('cyan', s);
|
|
|
8
8
|
export function fileUrl(filePath) {
|
|
9
9
|
return pathToFileURL(filePath).href;
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
|
|
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) {
|
|
11
|
+
function truncate(str, maxLength) {
|
|
12
|
+
if (str.length <= maxLength) {
|
|
30
13
|
return str;
|
|
31
14
|
}
|
|
32
|
-
|
|
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);
|
|
15
|
+
return `${str.slice(0, maxLength - 1)}…`;
|
|
53
16
|
}
|
|
54
17
|
export function printTable(columns, rows, footer, title) {
|
|
55
|
-
|
|
56
|
-
|
|
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();
|
|
18
|
+
const colWidths = columns.map(col => col.width);
|
|
19
|
+
const totalInner = colWidths.reduce((sum, w) => sum + w + 2, 0) + colWidths.length - 1;
|
|
73
20
|
if (title) {
|
|
74
21
|
const titleTop = dim(`┌${'─'.repeat(totalInner)}┐`);
|
|
75
22
|
const titleContent = ` ${truncate(title, totalInner - 2)}`;
|
|
76
|
-
const titlePadding = totalInner -
|
|
23
|
+
const titlePadding = totalInner - titleContent.length;
|
|
77
24
|
const titleLine = dim('│') + titleContent + ' '.repeat(Math.max(0, titlePadding)) + dim('│');
|
|
78
25
|
const titleSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┬')}┤`);
|
|
79
26
|
|
|
@@ -89,7 +36,7 @@ export function printTable(columns, rows, footer, title) {
|
|
|
89
36
|
console.log(topBorder);
|
|
90
37
|
}
|
|
91
38
|
const headerSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┼')}┤`);
|
|
92
|
-
const headerCells = columns.map(
|
|
39
|
+
const headerCells = columns.map(col => ` ${col.header.padStart(col.width)} `);
|
|
93
40
|
const headerLine = dim('│') + headerCells.join(dim('│')) + dim('│');
|
|
94
41
|
|
|
95
42
|
// eslint-disable-next-line no-console
|
|
@@ -97,15 +44,14 @@ export function printTable(columns, rows, footer, title) {
|
|
|
97
44
|
// eslint-disable-next-line no-console
|
|
98
45
|
console.log(headerSep);
|
|
99
46
|
for (const row of rows) {
|
|
100
|
-
|
|
101
|
-
const cells = colWidths.map((width, index) => ` ${fitCell(row[index] ?? '', width)} `);
|
|
47
|
+
const cells = row.map((cell, i) => ` ${cell.padStart(colWidths[i])} `);
|
|
102
48
|
// eslint-disable-next-line no-console
|
|
103
49
|
console.log(dim('│') + cells.join(dim('│')) + dim('│'));
|
|
104
50
|
}
|
|
105
51
|
if (footer) {
|
|
106
52
|
const footerSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┴')}┤`);
|
|
107
53
|
const footerContent = ` ${footer}`;
|
|
108
|
-
const padding = totalInner -
|
|
54
|
+
const padding = totalInner - stripAnsi(footerContent).length;
|
|
109
55
|
const footerLine = dim('│') + footerContent + ' '.repeat(Math.max(0, padding)) + dim('│');
|
|
110
56
|
const bottomBorder = dim(`└${'─'.repeat(totalInner)}┘`);
|
|
111
57
|
|
|
@@ -120,4 +66,10 @@ export function printTable(columns, rows, footer, title) {
|
|
|
120
66
|
// eslint-disable-next-line no-console
|
|
121
67
|
console.log(bottomBorder);
|
|
122
68
|
}
|
|
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, '');
|
|
123
75
|
}
|
package/index.d.mts
CHANGED
|
@@ -14,26 +14,8 @@ 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
|
-
*/
|
|
22
17
|
runs?: number;
|
|
23
|
-
/** Warmup iterations run before measurement begins (not recorded). Defaults to `5`. */
|
|
24
18
|
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;
|
|
37
19
|
afterEach?: () => Promise<void> | void;
|
|
38
20
|
/**
|
|
39
21
|
* Start each iteration with React render/paint recording paused. The interaction callback then
|
package/index.mjs
CHANGED
|
@@ -12,8 +12,6 @@ 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";
|
|
17
15
|
import { metricsGate } from "./metricsGate.mjs";
|
|
18
16
|
import { createReactRecordingControls } from "./reactRecording.mjs";
|
|
19
17
|
import { runProfileSession } from "./profileSession.mjs";
|
|
@@ -201,30 +199,10 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
|
|
|
201
199
|
it(name, async ({
|
|
202
200
|
task
|
|
203
201
|
}) => {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
const
|
|
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;
|
|
202
|
+
const runs = options?.runs ?? 20;
|
|
203
|
+
const warmupRuns = options?.warmupRuns ?? 10;
|
|
204
|
+
const totalRuns = warmupRuns + runs;
|
|
218
205
|
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 = [];
|
|
228
206
|
|
|
229
207
|
// Paint timings are recorded as one harness-owned `bench:paint` metric: the default sentinel
|
|
230
208
|
// is the base series (`bench:paint`) and named `elementtiming` markers are sub-series
|
|
@@ -319,57 +297,11 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
|
|
|
319
297
|
iterations.push({
|
|
320
298
|
renders: captures
|
|
321
299
|
});
|
|
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));
|
|
324
300
|
}
|
|
325
301
|
if (options?.afterEach) {
|
|
326
302
|
// eslint-disable-next-line no-await-in-loop
|
|
327
303
|
await options.afterEach();
|
|
328
304
|
}
|
|
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
|
-
}
|
|
373
305
|
}
|
|
374
306
|
task.meta.benchmarkIterations = iterations;
|
|
375
307
|
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.18",
|
|
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": "29a55c8557630acaee4f2b750c6a9813563a0bdf"
|
|
72
72
|
}
|
package/reporter.mjs
CHANGED
|
@@ -7,6 +7,9 @@ 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
|
+
}
|
|
10
13
|
|
|
11
14
|
/** Order-insensitive deep equality, treating a missing key and an `undefined` value as equal. */
|
|
12
15
|
function deepEqual(first, second) {
|
|
@@ -53,24 +56,21 @@ function generateReportFromIterations(iterations) {
|
|
|
53
56
|
const renderStats = [];
|
|
54
57
|
for (let index = 0; index < expectedLength; index += 1) {
|
|
55
58
|
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.
|
|
62
59
|
const {
|
|
63
60
|
mean: iqrMean,
|
|
64
61
|
stdDev: iqrStdDev,
|
|
65
|
-
outliers
|
|
66
|
-
count
|
|
62
|
+
outliers
|
|
67
63
|
} = 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
|
+
}
|
|
68
69
|
renderStats.push({
|
|
69
70
|
event: firstIteration.renders[index],
|
|
70
71
|
iqrMean,
|
|
71
72
|
iqrStdDev,
|
|
72
|
-
outliers
|
|
73
|
-
count
|
|
73
|
+
outliers
|
|
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;
|
|
87
88
|
for (let index = 0; index < expectedLength; index += 1) {
|
|
88
89
|
const {
|
|
89
90
|
event,
|
|
90
91
|
iqrMean,
|
|
91
92
|
iqrStdDev,
|
|
92
|
-
outliers
|
|
93
|
-
count
|
|
93
|
+
outliers
|
|
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,27 +99,16 @@ function generateReportFromIterations(iterations) {
|
|
|
99
99
|
startTime,
|
|
100
100
|
actualDuration: iqrMean,
|
|
101
101
|
stdDev: iqrStdDev,
|
|
102
|
-
outliers
|
|
103
|
-
count
|
|
102
|
+
outliers
|
|
104
103
|
});
|
|
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
|
-
|
|
116
107
|
// Custom + paint metrics are merged separately from `task.meta.benchmarkMetrics`.
|
|
117
108
|
const metrics = {};
|
|
118
109
|
return {
|
|
119
110
|
iterations: iterationCount,
|
|
120
|
-
totalDuration
|
|
121
|
-
totalStdDev: totalStats.stdDev,
|
|
122
|
-
totalCount: totalStats.count,
|
|
111
|
+
totalDuration,
|
|
123
112
|
renders,
|
|
124
113
|
metrics
|
|
125
114
|
};
|
|
@@ -127,9 +116,8 @@ function generateReportFromIterations(iterations) {
|
|
|
127
116
|
const LABEL_WIDTH = 28;
|
|
128
117
|
const STAT_WIDTH = 16;
|
|
129
118
|
const CV_WIDTH = 8;
|
|
130
|
-
const OUT_WIDTH = 4;
|
|
131
119
|
function colorCV(cv) {
|
|
132
|
-
const str = `${cv.toFixed(1)}
|
|
120
|
+
const str = `${cv.toFixed(1)}%`.padStart(CV_WIDTH);
|
|
133
121
|
if (cv > 10) {
|
|
134
122
|
return red(str);
|
|
135
123
|
}
|
|
@@ -138,33 +126,6 @@ function colorCV(cv) {
|
|
|
138
126
|
}
|
|
139
127
|
return dim(str);
|
|
140
128
|
}
|
|
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
|
-
}
|
|
168
129
|
function printDurationMatrix(name, report, footer) {
|
|
169
130
|
if (report.renders.length === 0) {
|
|
170
131
|
return;
|
|
@@ -172,10 +133,24 @@ function printDurationMatrix(name, report, footer) {
|
|
|
172
133
|
const rows = [];
|
|
173
134
|
for (let r = 0; r < report.renders.length; r += 1) {
|
|
174
135
|
const render = report.renders[r];
|
|
136
|
+
const label = `#${r} ${render.id}:${render.phase}`;
|
|
175
137
|
const iqrStr = `${render.actualDuration.toFixed(2)}±${render.stdDev.toFixed(2)}`;
|
|
176
|
-
|
|
138
|
+
const cv = render.actualDuration > 0 ? render.stdDev / render.actualDuration * 100 : 0;
|
|
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))]);
|
|
177
140
|
}
|
|
178
|
-
printTable(
|
|
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);
|
|
179
154
|
}
|
|
180
155
|
|
|
181
156
|
/** Strips a `#sub-series` suffix to recover the metric name used to look up its definition. */
|
|
@@ -203,13 +178,9 @@ function mergeCustomMetrics(report, customMetrics, definitions) {
|
|
|
203
178
|
report.metrics[key] = {
|
|
204
179
|
mean: stats.mean,
|
|
205
180
|
stdDev: stats.stdDev,
|
|
206
|
-
outliers: stats.outliers
|
|
207
|
-
count: stats.count
|
|
181
|
+
outliers: stats.outliers
|
|
208
182
|
};
|
|
209
|
-
|
|
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);
|
|
183
|
+
maxCount = Math.max(maxCount, stats.count);
|
|
213
184
|
}
|
|
214
185
|
const definition = {
|
|
215
186
|
kind: metric.kind,
|
|
@@ -237,10 +208,23 @@ function printMetricsTable(name, metrics, iterationCount, definitions) {
|
|
|
237
208
|
const rows = entries.map(([metricName, stats]) => {
|
|
238
209
|
const definition = definitions[baseMetricName(metricName)];
|
|
239
210
|
const iqrStr = `${formatMetricValue(stats.mean, definition)}±${formatMetricValue(stats.stdDev, definition)}`;
|
|
211
|
+
const cv = stats.mean > 0 ? stats.stdDev / stats.mean * 100 : 0;
|
|
240
212
|
const label = definition?.alarm ? `${metricName} ⚠` : metricName;
|
|
241
|
-
return
|
|
213
|
+
return [label.slice(0, LABEL_WIDTH).padStart(LABEL_WIDTH), cyan(iqrStr.padStart(STAT_WIDTH)), colorCV(cv), stats.outliers > 0 ? yellow(String(stats.outliers).padStart(4)) : dim('0'.padStart(4))];
|
|
242
214
|
});
|
|
243
|
-
printTable(
|
|
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`);
|
|
244
228
|
}
|
|
245
229
|
async function loadBaselineReport(baselinePath) {
|
|
246
230
|
const raw = await fs.readFile(baselinePath, 'utf8');
|
package/stats.d.mts
CHANGED
|
@@ -9,54 +9,12 @@ 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
|
-
* back to the
|
|
14
|
-
*
|
|
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.
|
|
12
|
+
* Aggregates a series of samples into a mean, standard deviation, and outlier count using
|
|
13
|
+
* IQR-based outlier removal. Falls back to the raw values when filtering would remove
|
|
14
|
+
* everything. This is the shared aggregation core for custom metrics.
|
|
25
15
|
*/
|
|
26
16
|
export declare function aggregateSamples(values: number[]): {
|
|
27
17
|
mean: number;
|
|
28
18
|
stdDev: number;
|
|
29
19
|
outliers: number;
|
|
30
|
-
|
|
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[];
|
|
20
|
+
};
|
package/stats.mjs
CHANGED
|
@@ -27,89 +27,21 @@ export function isOutlier(value, q1, q3) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
* back to the
|
|
32
|
-
*
|
|
33
|
-
* hiccups), so they all trim identically.
|
|
30
|
+
* Aggregates a series of samples into a mean, standard deviation, and outlier count using
|
|
31
|
+
* IQR-based outlier removal. Falls back to the raw values when filtering would remove
|
|
32
|
+
* everything. This is the shared aggregation core for custom metrics.
|
|
34
33
|
*/
|
|
35
|
-
export function
|
|
34
|
+
export function aggregateSamples(values) {
|
|
36
35
|
const sorted = values.toSorted((first, second) => first - second);
|
|
37
36
|
const q1 = quantile(sorted, 0.25);
|
|
38
37
|
const q3 = quantile(sorted, 0.75);
|
|
39
38
|
const filtered = values.filter(value => !isOutlier(value, q1, q3));
|
|
40
|
-
|
|
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);
|
|
39
|
+
const used = filtered.length > 0 ? filtered : values;
|
|
53
40
|
const mean = calculateMean(used);
|
|
54
41
|
const stdDev = calculateStdDev(used, mean);
|
|
55
42
|
return {
|
|
56
43
|
mean,
|
|
57
44
|
stdDev,
|
|
58
|
-
outliers: values.length - used.length
|
|
59
|
-
count: used.length
|
|
45
|
+
outliers: values.length - used.length
|
|
60
46
|
};
|
|
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;
|
|
115
47
|
}
|
package/types.d.mts
CHANGED
|
@@ -83,10 +83,7 @@ export interface MetricSampleStats {
|
|
|
83
83
|
mean: number;
|
|
84
84
|
stdDev: number;
|
|
85
85
|
outliers: number;
|
|
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
|
-
*/
|
|
86
|
+
/** Number of recorded samples (used to derive iteration counts; stripped from the report). */
|
|
90
87
|
count: number;
|
|
91
88
|
}
|
|
92
89
|
/** A custom metric's aggregated data attached to `task.meta`, keyed by metric name. */
|