@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 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
- // `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);
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
- export interface Column {
7
+ interface Column {
8
8
  header: string;
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;
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
- /** 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) {
11
+ function truncate(str, maxLength) {
12
+ if (str.length <= maxLength) {
30
13
  return str;
31
14
  }
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);
15
+ return `${str.slice(0, maxLength - 1)}…`;
53
16
  }
54
17
  export function printTable(columns, rows, footer, title) {
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();
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 - visibleWidth(titleContent);
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((col, index) => ` ${fitCell(col.header, colWidths[index])} `);
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
- // 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)} `);
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 - visibleWidth(footerContent);
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 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;
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.17",
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": "69ae6a8458bc68be5bef922f72637180b2b160e5"
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: totalStats.mean,
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
- rows.push(statRow(`#${r} ${render.id}:${render.phase}`, iqrStr, render.actualDuration, render.stdDev, render.outliers));
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(statColumns('Render', 'Mean±σ (ms)'), rows, footer, name);
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
- // `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);
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 statRow(label, iqrStr, stats.mean, stats.stdDev, stats.outliers);
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(statColumns('Metric', 'Mean±σ'), rows, dim(`${iterationCount} iterations`), `${name} — Metrics`);
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
- * 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.
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
- 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[];
20
+ };
package/stats.mjs CHANGED
@@ -27,89 +27,21 @@ export function isOutlier(value, q1, q3) {
27
27
  }
28
28
 
29
29
  /**
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.
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 removeOutliers(values) {
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
- 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);
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. */