perf(tooling): compositing probe, report command and route-wide clip-mask guard (#1858)

* perf(tooling): compositing probe, report command and route-wide clip-mask guard

Make the instrument that found the dashboard's clip masks part of the
repository: `src/performance/compositing-probe.ts` reads the layer tree
over CDP (bounds in device pixels, compositing reasons and owner nodes,
waiting for Blink's layer debug info), classifies synthesized clip masks
(no owner node and no compositing reason) and reads the renderer's
`cc/tile_memory` from a memory-infra dump. `pnpm run perf:compositing`
seeds one profile against the Xtream mock on a dedicated port, serves the
artwork from memory, fixes the window at 1600x1000 and records tile and
image memory, layer counts, the largest layers, masks and tile warnings
for Live TV, the movie and series details, the dashboard (idle,
crossfade, scrolled) and settings into
dist/performance/compositing/<timestamp>/summary.json and a console
table. Both helpers have node:test coverage in the performance harness.

The dashboard-only compositing E2E becomes `compositing.e2e.ts` and
guards Live TV, a movie detail, a series detail and the dashboard in one
launch, with the backdrop-filtered controls confirmed as composited
layers on the dashboard and the movie detail.

The rule now lives where it triggers: a Rendering Cost section in the
theme/style skill, a validation line in the UI design skill, a context
map row, and a Compositing budget section in the performance journeys
contract (budget, mask mechanism, instruments, why megabytes are
evidence rather than a ratchet). Validation map and README mention the
command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(tooling): sample the dashboard idle reading with rotation paused

The hero advances after 8 s, so a 9 s wait sampled the next slide one
second into its zoom (backdrop scale 1.116 instead of 1.1, the previous
slide's tiles still pooled): 224 MB read as idle, 122 MB with rotation
paused and the zoom and tile pool settled. The warning delta and its
checkpoint are now taken at one boundary after the sampling, so a
"tile memory limits exceeded" line logged while the layers or the dump
are read counts for that route instead of for none. The report's test
artifacts go under dist/test-results like the other configs'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(tooling): retry a memory dump that carries no renderer tile memory

One reading of seven came back without `cc/tile_memory`: the dump's
trace events reach the buffer shortly after the request resolves, and
a process can skip a dump it is busy for. The capture now waits briefly
before ending the trace and retries up to three times before a reading
stays null.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(tooling): run the compositing report with motion enabled

With the OS set to reduced motion the hero neither rotates nor renders
its pause button, so the report's click timed out before the summary was
written. The report now emulates `prefers-reduced-motion: no-preference`,
which also keeps the idle and crossfade readings on the same animation
set on every machine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(tooling): sample the crossfade frozen, count split stderr lines, drop stale layer samples

Review findings on the pushed head, plus the CI failure:

- A layer that vanished between the LayerTree snapshot and its reasons
  query was kept with empty reasons; without an owner node it would pass
  for a synthesized mask. Such a snapshot is now discarded and retaken.
- The crossfade reading sampled layers, owners and memory across the
  700 ms fade, so the row depended on how long the sampling took. The
  page's animations are frozen 300 ms into the fade while the reading is
  taken, resumed afterwards, and the incoming slide's zoom is allowed to
  finish before the scrolled reading.
- A stderr chunk can end mid-line; the warning counter now keeps the
  unfinished line for the next chunk.
- The mock server's launch spec lists the new Playwright config.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-09 21:34:57 +02:00
1 parent 8030ced95a
commit e29f36426b
18 files changed
+1374 -159

No files matched your search

@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
describeLayerSize,
findOversizedClipMasks,
isSynthesizedClipMask,
layerDevicePixels,
layerScale,
readTileMemoryFromTrace,
type CompositedLayerWithReasons,
type TraceEvent,
} from './compositing-probe';
function layer(
overrides: Partial<CompositedLayerWithReasons> = {}
): CompositedLayerWithReasons {
return {
drawsContent: true,
height: 1874,
layerId: '317',
reasons: [],
width: 3340,
...overrides,
};
}
/** A column-major 4x4 matrix scaling x and y. */
function scaleMatrix(x: number, y: number): number[] {
return [x, 0, 0, 0, 0, y, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
const contentArea = { dpr: 2, height: 1874, width: 3340 };
test('layerScale reads the x and y scale of a column-major transform', () => {
assert.deepEqual(layerScale(layer()), { x: 1, y: 1 });
assert.deepEqual(
layerScale(layer({ transform: scaleMatrix(0.96, 0.96) })),
{
x: 0.96,
y: 0.96,
}
);
// A rotation keeps the axis length.
const rotated = layer({
transform: [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
});
assert.deepEqual(layerScale(rotated), { x: 1, y: 1 });
});
test('layerDevicePixels applies the transform scale to the bounds', () => {
assert.equal(layerDevicePixels(layer({ height: 10, width: 10 })), 100);
assert.equal(
Math.round(
layerDevicePixels(
layer({ height: 10, transform: scaleMatrix(0.5, 2), width: 10 })
)
),
100
);
});
test('a synthesized clip mask draws content without an owner node or a reason', () => {
assert.equal(isSynthesizedClipMask(layer()), true);
assert.equal(isSynthesizedClipMask(layer({ backendNodeId: 42 })), false);
assert.equal(isSynthesizedClipMask(layer({ reasons: ['Overlap'] })), false);
assert.equal(isSynthesizedClipMask(layer({ drawsContent: false })), false);
// A content layer whose first paint chunk belongs to an anonymous box
// has no owner node but keeps its reason.
assert.equal(
isSynthesizedClipMask(
layer({ backendNodeId: undefined, reasons: ['Overlap'] })
),
false
);
});
test('findOversizedClipMasks keeps masks of at least half the content area, at their scale', () => {
const chevronMask = layer({
height: 1955,
layerId: '330',
transform: scaleMatrix(0.96, 0.96),
width: 3482,
});
const smallMask = layer({ height: 6, layerId: '400', width: 18 });
const track = layer({
backendNodeId: 7,
height: 635,
layerId: '324',
reasons: ['Overlap'],
width: 7520,
});
const nodelessTrack = layer({
height: 1874,
layerId: '209',
reasons: ['Overlap'],
width: 3340,
});
const halfMinusOne = layer({
height: 1874,
layerId: '500',
width: 1669,
});
const masks = findOversizedClipMasks(
[layer(), chevronMask, smallMask, track, nodelessTrack, halfMinusOne],
contentArea
);
assert.deepEqual(
masks.map((mask) => mask.layerId),
['317', '330']
);
assert.deepEqual(masks.map(describeLayerSize), [
'317: 3340x1874',
'330: 3482x1955',
]);
assert.deepEqual(
findOversizedClipMasks([halfMinusOne], contentArea, 0.4).map(
(mask) => mask.layerId
),
['500']
);
});
function dump(
pid: number,
allocators: Record<string, number | null>
): TraceEvent {
const entries = Object.entries(allocators).map(([name, bytes]) => [
name,
bytes === null
? {}
: {
attrs: {
size: { units: 'bytes', value: bytes.toString(16) },
},
},
]);
return {
args: { dumps: { allocators: Object.fromEntries(entries) } },
pid,
ph: 'v',
};
}
const MB = 1_048_576;
test('readTileMemoryFromTrace reports the renderer dump with the most tile memory', () => {
const reading = readTileMemoryFromTrace([
{ ph: 'X', pid: 1 },
dump(1, { 'skia/gpu_resources': 200 * MB }),
dump(2, {
'cc/image_memory': 118 * MB,
'cc/tile_memory': 537 * MB,
'cc/tile_memory/provider_1': null,
'cc/tile_memory/provider_1/resource_1': 24 * MB,
'cc/tile_memory/provider_1/resource_2': 24 * MB,
'cc/tile_memory/provider_1/resource_3': 6.5 * MB,
}),
dump(3, {
'cc/tile_memory': 12 * MB,
'cc/tile_memory/provider_2/resource_1': 12 * MB,
}),
]);
assert.deepEqual(reading, {
imageMB: 118,
largestResourcesMB: [24, 24, 6.5],
pid: 2,
resourceCount: 3,
tileMB: 537,
});
});
test('readTileMemoryFromTrace yields nulls without a tile memory dump', () => {
assert.deepEqual(
readTileMemoryFromTrace([dump(1, { 'skia/gpu_resources': 10 * MB })]),
{
imageMB: null,
largestResourcesMB: [],
pid: null,
resourceCount: 0,
tileMB: null,
}
);
assert.equal(readTileMemoryFromTrace([]).tileMB, null);
});
test('readTileMemoryFromTrace ignores sizes that are not byte counts', () => {
const reading = readTileMemoryFromTrace([
{
args: {
dumps: {
allocators: {
'cc/tile_memory': {
attrs: { size: { units: 'objects', value: '10' } },
},
'cc/tile_memory/provider_1/resource_1': {
attrs: { size: { units: 'bytes', value: 'zz' } },
},
},
},
},
ph: 'v',
pid: 9,
},
]);
assert.equal(reading.tileMB, null);
assert.equal(reading.resourceCount, 0);
});
@@ -0,0 +1,381 @@
import type { CDPSession, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import sharp = require('sharp');
/**
* Compositing probe: the page's cc layers with their compositing reasons
* and owner nodes (Chrome DevTools Protocol `LayerTree` domain) and the
* renderer's tile memory (`Tracing` with a memory-infra dump). Shared by
* the compositing E2E guard and the `perf:compositing` report. Contract:
* docs/architecture/performance-journeys.md#compositing-budget.
*/
export interface CompositedLayer {
backendNodeId?: number;
drawsContent: boolean;
height: number;
layerId: string;
paintCount?: number;
/** 4x4 matrix, column major; identity when absent. */
transform?: number[];
width: number;
}
export interface CompositedLayerWithReasons extends CompositedLayer {
/** `LayerTree.compositingReasons` ids, e.g. `BackdropFilter`. */
reasons: string[];
}
/** The clipped area a mask would have to cover, in device pixels. */
export interface ContentArea {
dpr: number;
height: number;
width: number;
}
export interface TileMemoryReading {
/** Decoded image memory held by cc (`cc/image_memory`). */
imageMB: number | null;
/** The largest tile resources, in MB, largest first. */
largestResourcesMB: number[];
pid: number | null;
/** Number of tile resources in the renderer's resource pool. */
resourceCount: number;
/** `cc/tile_memory` of the renderer process, null when no dump had it. */
tileMB: number | null;
}
/**
* The resource pool keeps a freed tile for reuse for a few seconds; a
* reading taken sooner after a change still counts the old tiles.
*/
export const TILE_POOL_SETTLE_MS = 6_500;
/** A layer's x and y scale from its transform (identity when absent). */
export function layerScale(layer: CompositedLayer): { x: number; y: number } {
const matrix = layer.transform;
if (!matrix || matrix.length < 6) {
return { x: 1, y: 1 };
}
return {
x: Math.hypot(matrix[0], matrix[1]),
y: Math.hypot(matrix[4], matrix[5]),
};
}
/** The device pixels a layer rasterizes at its current scale. */
export function layerDevicePixels(layer: CompositedLayer): number {
const scale = layerScale(layer);
return layer.width * scale.x * layer.height * scale.y;
}
/**
* A clip mask Blink synthesized for a rounded clip (or a clip path) around
* a composited effect. It is not painted for any element, so it has no
* owner node and no compositing reason; every content layer carries at
* least one reason, and a content layer can lack an owner node when its
* first paint chunk belongs to an anonymous box, so the node alone does
* not identify a mask.
*/
export function isSynthesizedClipMask(
layer: CompositedLayerWithReasons
): boolean {
return (
layer.drawsContent && !layer.backendNodeId && layer.reasons.length === 0
);
}
/**
* Synthesized clip masks at least `ratio` of the content area: a rounded
* scroller gives every composited effect inside it one of these, each the
* size of the whole clipped area.
*/
export function findOversizedClipMasks(
layers: readonly CompositedLayerWithReasons[],
area: ContentArea,
ratio = 0.5
): CompositedLayerWithReasons[] {
const threshold = area.width * area.height * ratio;
return layers.filter(
(layer) =>
isSynthesizedClipMask(layer) &&
layerDevicePixels(layer) >= threshold
);
}
export function describeLayerSize(layer: CompositedLayer): string {
return `${layer.layerId}: ${layer.width}x${layer.height}`;
}
/** The content scroller's client area in device pixels. */
export async function measureContentArea(
page: Page,
selector = 'main.workspace-content'
): Promise<ContentArea> {
return page.locator(selector).evaluate((element) => ({
dpr: window.devicePixelRatio,
height: element.clientHeight * window.devicePixelRatio,
width: element.clientWidth * window.devicePixelRatio,
}));
}
/**
* The composited layers of the page with their compositing reasons. Blink
* fills the layer debug info (reasons, owner nodes) in its first
* layerization after the domain is enabled, so the snapshot is retried
* until a layer reports a reason. Layer bounds are in device pixels.
*/
export async function collectCompositedLayers(
cdp: CDPSession,
page: Page
): Promise<CompositedLayerWithReasons[]> {
let layers: CompositedLayer[] = [];
const onChange = (event: { layers?: CompositedLayer[] }) => {
layers = event.layers ?? [];
};
cdp.on('LayerTree.layerTreeDidChange', onChange);
let withReasons: CompositedLayerWithReasons[] = [];
/**
* Reads every layer's reasons; null when a layer went away between the
* snapshot and its query (the tree changed under us). A vanished layer
* must not be kept with empty reasons: without an owner node it would
* pass for a synthesized mask.
*/
const snapshot = async (): Promise<CompositedLayerWithReasons[] | null> => {
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
)
);
const result: CompositedLayerWithReasons[] = [];
for (const layer of layers) {
try {
const answer = (await cdp.send('LayerTree.compositingReasons', {
layerId: layer.layerId,
})) as { compositingReasonIds?: string[] };
result.push({
...layer,
reasons: answer.compositingReasonIds ?? [],
});
} catch {
return null;
}
}
return result;
};
try {
await cdp.send('LayerTree.enable');
await expect
.poll(async () => {
const sample = await snapshot();
withReasons = sample ?? [];
return withReasons.filter((layer) => layer.reasons.length > 0)
.length;
})
.toBeGreaterThan(0);
await cdp.send('LayerTree.disable');
} finally {
cdp.off('LayerTree.layerTreeDidChange', onChange);
}
return withReasons;
}
/**
* `tag#id.class` of the layer's owner node, or `(no owner node)`: a
* synthesized mask has none, and so has a content layer whose first paint
* chunk belongs to an anonymous box.
*/
export async function describeLayerOwner(
cdp: CDPSession,
layer: CompositedLayer
): Promise<string> {
if (!layer.backendNodeId) {
return '(no owner node)';
}
try {
const described = (await cdp.send('DOM.describeNode', {
backendNodeId: layer.backendNodeId,
})) as { node: { attributes?: string[]; nodeName: string } };
const attributes = described.node.attributes ?? [];
const idIndex = attributes.indexOf('id');
const classIndex = attributes.indexOf('class');
const id = idIndex >= 0 ? `#${attributes[idIndex + 1]}` : '';
const classes =
classIndex >= 0
? attributes[classIndex + 1]
.trim()
.split(/\s+/)
.filter((name) => name && !name.startsWith('ng-'))
.map((name) => `.${name}`)
.join('')
: '';
return `${described.node.nodeName.toLowerCase()}${id}${classes}`;
} catch {
return `backendNodeId:${layer.backendNodeId}`;
}
}
interface TraceAllocator {
attrs?: Record<string, { units?: string; value?: string }>;
}
export interface TraceEvent {
args?: { dumps?: { allocators?: Record<string, TraceAllocator> } };
ph?: string;
pid?: number;
}
function allocatorMB(allocator: TraceAllocator | undefined): number | null {
const size = allocator?.attrs?.['size'];
if (!size || size.units !== 'bytes' || typeof size.value !== 'string') {
return null;
}
const bytes = Number.parseInt(size.value, 16);
return Number.isFinite(bytes)
? Number((bytes / 1_048_576).toFixed(1))
: null;
}
/**
* The renderer's tile memory from memory-infra dump events (`ph: "v"`).
* `cc/tile_memory` is the resource pool's total; its children
* `cc/tile_memory/provider_N/resource_M` are the tiles. Of several
* processes with tile memory the largest is reported.
*/
export function readTileMemoryFromTrace(
events: readonly TraceEvent[]
): TileMemoryReading {
let best: TileMemoryReading = {
imageMB: null,
largestResourcesMB: [],
pid: null,
resourceCount: 0,
tileMB: null,
};
for (const event of events) {
const allocators = event.args?.dumps?.allocators;
if (event.ph !== 'v' || !allocators?.['cc/tile_memory']) {
continue;
}
const resources: number[] = [];
for (const [name, allocator] of Object.entries(allocators)) {
const parts = name.split('/');
const isTile =
parts.length === 4 &&
parts[0] === 'cc' &&
parts[1] === 'tile_memory';
const size = isTile ? allocatorMB(allocator) : null;
if (size !== null) {
resources.push(size);
}
}
const reading: TileMemoryReading = {
imageMB: allocatorMB(allocators['cc/image_memory']),
largestResourcesMB: resources.sort((a, b) => b - a).slice(0, 6),
pid: event.pid ?? null,
resourceCount: resources.length,
tileMB: allocatorMB(allocators['cc/tile_memory']),
};
if ((reading.tileMB ?? 0) > (best.tileMB ?? -1)) {
best = reading;
}
}
return best;
}
async function captureTileMemoryOnce(
cdp: CDPSession
): Promise<TileMemoryReading> {
const events: TraceEvent[] = [];
const onData = (event: { value?: TraceEvent[] }) => {
events.push(...(event.value ?? []));
};
cdp.on('Tracing.dataCollected', onData);
const complete = new Promise<void>((resolve) =>
cdp.once('Tracing.tracingComplete', () => resolve())
);
try {
await cdp.send('Tracing.start', {
traceConfig: {
includedCategories: ['disabled-by-default-memory-infra'],
recordMode: 'recordContinuously',
},
transferMode: 'ReportEvents',
});
await cdp.send('Tracing.requestMemoryDump', {
levelOfDetail: 'detailed',
});
// The dump's trace events reach the buffer shortly after the
// request resolves; ending the trace at once can miss them.
await new Promise((resolve) => setTimeout(resolve, 250));
await cdp.send('Tracing.end');
await complete;
} finally {
cdp.off('Tracing.dataCollected', onData);
}
return readTileMemoryFromTrace(events);
}
/**
* One detailed memory-infra dump of the page's renderer. A dump that
* carries no renderer tile memory (a process can skip a dump it is busy
* for) is retried a few times before the reading stays null.
*/
export async function captureTileMemory(
cdp: CDPSession,
attempts = 3
): Promise<TileMemoryReading> {
let reading = await captureTileMemoryOnce(cdp);
for (let attempt = 1; attempt < attempts && reading.tileMB === null;) {
reading = await captureTileMemoryOnce(cdp);
attempt += 1;
}
return reading;
}
const artworkCache = new Map<string, Buffer>();
/**
* Serves every picsum.photos request of the mock catalogs from memory: an
* opaque gradient of the requested size, so the probe never leaves the
* machine and every image loads (tile and image memory depend on an image
* being there, not on what it shows).
*/
export async function routeDeterministicArtwork(page: Page): Promise<void> {
await page.route(
(url) => url.hostname === 'picsum.photos',
async (route) => {
const match = new URL(route.request().url()).pathname.match(
/\/seed\/([^/]+)\/(\d+)\/(\d+)/
);
if (!match) {
await route.fulfill({ body: '', status: 404 });
return;
}
const [, seed, width, height] = match;
const key = `${seed}-${width}x${height}`;
let body = artworkCache.get(key);
if (body === undefined) {
let hash = 0;
for (const char of seed) {
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
}
const r = 60 + (hash % 160);
const g = 60 + ((hash >> 8) % 160);
const b = 60 + ((hash >> 16) % 160);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="rgb(${r},${g},${b})"/><stop offset="1" stop-color="rgb(${b},${r},${g})"/></linearGradient></defs><rect width="100%" height="100%" fill="url(#g)"/></svg>`;
const generated: Buffer = await sharp(Buffer.from(svg))
.png()
.toBuffer();
artworkCache.set(key, generated);
body = generated;
}
await route.fulfill({
body,
contentType: 'image/png',
status: 200,
});
}
);
}
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
COMPOSITING_SUMMARY_SCHEMA_VERSION,
formatCompositingTable,
resolveCompositingSummaryPath,
writeCompositingSummary,
type CompositingSummary,
} from './compositing-report';
function summary(): CompositingSummary {
return {
generatedAt: '2026-10-09T10:00:00.000Z',
harness: {
arch: 'arm64',
electronVersion: '43.3.0',
gpuCompositing: 'enabled',
platform: 'darwin',
window: { dpr: 2, height: 1000, width: 1600 },
},
routes: [
{
backdropFilterLayers: 9,
clipMasks: [],
drawingLayers: 35,
imageMB: 117.4,
largestLayers: [
{
devicePixelsMB: 38.9,
height: 3060,
owner: 'main.workspace-content',
reasons: ['Overlap'],
scale: 1,
width: 3336,
},
],
route: 'dashboard',
tileMB: 241.6,
tileResources: 51,
tileWarnings: 0,
totalLayers: 43,
url: '/workspace/dashboard',
},
{
backdropFilterLayers: 0,
clipMasks: ['330: 3482x1955'],
drawingLayers: 46,
imageMB: null,
largestLayers: [],
route: 'settings',
tileMB: null,
tileResources: 0,
tileWarnings: 7,
totalLayers: 54,
url: '/workspace/settings',
},
],
schemaVersion: COMPOSITING_SUMMARY_SCHEMA_VERSION,
};
}
test('resolveCompositingSummaryPath puts one run under dist/performance/compositing', () => {
assert.equal(
resolveCompositingSummaryPath(
'/repo',
new Date('2026-10-09T10:11:12.345Z')
),
join(
'/repo',
'dist',
'performance',
'compositing',
'20261009T101112Z',
'summary.json'
)
);
});
test('writeCompositingSummary writes the JSON once and never overwrites it', async () => {
const directory = await mkdtemp(join(tmpdir(), 'compositing-summary-'));
try {
const path = join(directory, 'nested', 'summary.json');
await writeCompositingSummary(path, summary());
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), summary());
await assert.rejects(writeCompositingSummary(path, summary()), {
code: 'EEXIST',
});
} finally {
await rm(directory, { force: true, recursive: true });
}
});
test('formatCompositingTable lists every route with its memory, masks and warnings', () => {
const table = formatCompositingTable(summary());
const lines = table.split('\n');
assert.match(
lines[0],
/1600x1000 @2x, gpu compositing enabled, Electron 43\.3\.0/
);
assert.match(
lines[1],
/route\s+tile MB\s+image MB\s+layers\s+backdrop\s+masks\s+warn/
);
const dashboard = lines.find((line) => line.startsWith('dashboard'));
assert.ok(dashboard);
assert.match(dashboard, /241\.6\s+117\.4\s+35\/43\s+9\s+0\s+0$/);
assert.match(
lines[lines.indexOf(dashboard) + 1],
/38\.9 MB 3336x3060 s=1 main\.workspace-content \[Overlap\]/
);
const settings = lines.find((line) => line.startsWith('settings'));
assert.ok(settings);
assert.match(settings, /-\s+-\s+46\/54\s+0\s+1\s+7$/);
});
@@ -0,0 +1,103 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { formatJourneyOutputTimestamp } from './journey-summary';
/**
* Summary written by `pnpm run perf:compositing`
* (`src/compositing/compositing.report.ts`): one reading per route, with
* the renderer's tile memory and the largest composited layers. Contract:
* docs/architecture/performance-journeys.md#compositing-budget.
*/
export const COMPOSITING_SUMMARY_SCHEMA_VERSION = 1;
export interface CompositingLayerSummary {
/** Device pixels the layer rasterizes at its scale, in MB of RGBA. */
devicePixelsMB: number;
height: number;
/** `tag#id.class` of the owner node, or `(no owner node)`. */
owner: string;
reasons: string[];
scale: number;
width: number;
}
export interface CompositingRouteReading {
/** Composited layers whose reasons include `BackdropFilter`. */
backdropFilterLayers: number;
/** Synthesized clip masks at least half the content area (`id: WxH`). */
clipMasks: string[];
drawingLayers: number;
imageMB: number | null;
largestLayers: CompositingLayerSummary[];
route: string;
tileMB: number | null;
tileResources: number;
/** "tile memory limits exceeded" lines on stderr while on the route. */
tileWarnings: number;
totalLayers: number;
url: string;
}
export interface CompositingSummary {
generatedAt: string;
harness: {
arch: string;
electronVersion: string;
/** `gpu_compositing` from `app.getGPUFeatureStatus()`. */
gpuCompositing: string;
platform: string;
window: { dpr: number; height: number; width: number };
};
routes: CompositingRouteReading[];
schemaVersion: number;
}
export function resolveCompositingSummaryPath(
repositoryRoot: string,
date: Date = new Date()
): string {
return join(
repositoryRoot,
'dist',
'performance',
'compositing',
formatJourneyOutputTimestamp(date),
'summary.json'
);
}
/** Writes the summary; an existing file is never overwritten. */
export async function writeCompositingSummary(
summaryPath: string,
summary: CompositingSummary
): Promise<void> {
await mkdir(dirname(summaryPath), { recursive: true });
await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, {
flag: 'wx',
});
}
function pad(value: string | number, width: number): string {
return String(value).padStart(width);
}
/** A fixed-width table of the routes for the console. */
export function formatCompositingTable(summary: CompositingSummary): string {
const { window } = summary.harness;
const lines = [
`compositing budget: ${window.width}x${window.height} @${window.dpr}x, gpu compositing ${summary.harness.gpuCompositing}, Electron ${summary.harness.electronVersion}`,
`${'route'.padEnd(20)} ${pad('tile MB', 8)} ${pad('image MB', 9)} ${pad('layers', 7)} ${pad('backdrop', 9)} ${pad('masks', 6)} ${pad('warn', 5)}`,
];
for (const route of summary.routes) {
lines.push(
`${route.route.padEnd(20)} ${pad(route.tileMB ?? '-', 8)} ${pad(route.imageMB ?? '-', 9)} ${pad(`${route.drawingLayers}/${route.totalLayers}`, 7)} ${pad(route.backdropFilterLayers, 9)} ${pad(route.clipMasks.length, 6)} ${pad(route.tileWarnings, 5)}`
);
for (const layer of route.largestLayers) {
lines.push(
` ${pad(layer.devicePixelsMB.toFixed(1), 7)} MB ${layer.width}x${layer.height} s=${layer.scale} ${layer.owner} [${layer.reasons.join(',')}]`
);
}
}
return lines.join('\n');
}