Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/exojs-bench/docs/harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ Rules the generator enforces rather than merely intends:
- Every row names the mechanism behind its difference, drawn from the structural counters. A row whose mechanism cannot be evidenced is not published - it is listed under Omissions with the reason, so a dropped row stays auditable.
- One column per competitor, no "best competitor" composite. Phaser occupies its own WebGL1 block, CPU time only, explicitly carrying no mechanism.
- Cells where ExoJS loses are published exactly like the cells where it wins.
- A scenario whose arms turn out not to do the same work is repaired and re-measured. It is never published with a "not comparable" label in place of its comparison, and never hidden: a label would keep a known-unfair row on the page, and hiding it would let the choice of what to show depend on the outcome.

### Machine profiles

Expand Down
30 changes: 3 additions & 27 deletions site/src/components/BenchComparisonTable.astro
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ import {
pooledFactor,
ratioBand,
rungSide,
withheldScenario,
type ProfileCell,
type ProfileSpread,
} from '../lib/bench-profiles';
Expand Down Expand Up @@ -219,15 +218,8 @@ const winnerOf = (cell: ProfileCell): string => {
{row.load}
</td>
)}
{/*
* A row whose arms are known not to do the same work
* publishes their times and no comparison: the factor,
* the bar and the winner all go, the same way they do
* for a pair the clock could not separate.
*/}
{row.entries.map((entry, column) => {
const cell = entry.cell;
const withheld = withheldScenario(row.archetype) !== undefined;

if (cell === null) {
const reason =
Expand All @@ -248,7 +240,7 @@ const winnerOf = (cell: ProfileCell): string => {
const mixed = outcome === 'unstable';
const level = outcome === 'level';
// A pair the clock never separated publishes neither a figure nor a bar; only its two times survive.
const unresolved = withheld || outcome === 'timer-limited' || outcome === 'timer-unknown';
const unresolved = outcome === 'timer-limited' || outcome === 'timer-unknown';
const band = mixed ? ratioBand(cell) : null;
const factor = mixed ? pooledFactor(cell) : cell.verdict.factor;
const ratio = cell.verdict.ratio;
Expand Down Expand Up @@ -279,16 +271,8 @@ const winnerOf = (cell: ProfileCell): string => {
{/* A pair inside the noise band is named, not scored: its factor is pinned to 1 by the ladder, so printing 1.00x beside two medians that differ would claim a precision the ladder never measured. */}
<b>{unresolved ? '-' : mixed ? (factor === null ? '-' : formatApproximate(factor)) : level ? 'similar' : formatFactor(factor)}</b>
{/* The word alone; what it means is stated once in the legend rather than in every cell that carries it. */}
<i title={withheld ? withheldScenario(row.archetype) : unresolved || mixed ? OUTCOME_NOTES[outcome] : undefined}>
{withheld
? 'not comparable'
: unresolved
? OUTCOME_STATUS[outcome]
: mixed
? OUTCOME_STATUS.unstable
: level
? ''
: winnerOf(cell)}
<i title={unresolved || mixed ? OUTCOME_NOTES[outcome] : undefined}>
{unresolved ? OUTCOME_STATUS[outcome] : mixed ? OUTCOME_STATUS.unstable : level ? '' : winnerOf(cell)}
</i>
</span>
<span class="medians">
Expand All @@ -308,7 +292,6 @@ const winnerOf = (cell: ProfileCell): string => {
<details class="row-more">
{/* The row already names its archetype; the label repeats it only where a reader reaches the summary without that context. */}
<summary aria-label={`How ${archetypeTitle(row.archetype)} was measured`}>Measurement detail</summary>
{withheldScenario(row.archetype) !== undefined && <p class="row-withheld">{withheldScenario(row.archetype)}</p>}
<div class="arms">
{row.entries.map((entry, column) => {
const cell = entry.cell;
Expand Down Expand Up @@ -796,13 +779,6 @@ const winnerOf = (cell: ProfileCell): string => {
border-bottom: 1px solid var(--line-soft);
}

.row-withheld {
margin: 0.4rem 0 0.6rem;
font-size: 0.8rem;
line-height: 1.45;
color: var(--fg-muted);
}

.row-more summary {
cursor: pointer;
font-family: var(--f-mono);
Expand Down
47 changes: 10 additions & 37 deletions site/src/components/BenchResultCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -172,44 +172,25 @@ const statesOf = (load: BenchCard['loads'][number]): readonly CellOutcome[] => [
{card.loads.map(load => (
<div class="bench-load-panel" data-load={load.id} hidden={load.id !== opening?.id}>
{/*
* The load's own note about itself: why its comparison is
* withheld, or what state every arm on it shares. Nothing to say
* leaves no row, so the paragraph is only rendered where there
* is something to read - an always-present element with nothing
* inside it is invisible on screen but still there for `:empty`
* and similar checks to miss.
* The load's own note about itself: what state every arm on it
* shares. Nothing to say leaves no row, so the paragraph is only
* rendered where there is something to read - an always-present
* element with nothing inside it is invisible on screen but still
* there for `:empty` and similar checks to miss.
*/}
{(load.withheld !== undefined || statesOf(load).length > 0) && (
{statesOf(load).length > 0 && (
<p class="meta">
{load.withheld !== undefined ? (
<>
<span class="status withheld">Not comparable</span>
<span class="reason">{load.withheld}</span>
</>
) : (
<span class="status">{OUTCOME_STATUS[statesOf(load)[0] ?? 'absent']}</span>
)}
<span class="status">{OUTCOME_STATUS[statesOf(load)[0] ?? 'absent']}</span>
</p>
)}

<ul class="arms">
{load.arms.map(arm => (
<li class:list={['arm', { reference: arm.reference, absent: arm.ms === null }]}>
<span class="name">{arm.label}</span>
{/*
* A withheld row draws no track at all. Its bars would
* be durations like any other, but side by side they
* are read as the comparison the row is published
* without, and an empty track under every row reads as
* a chart that failed to draw.
*/}
{load.withheld === undefined ? (
<span class="track">
{widthOf(arm.ms, load.maxMs) !== null && <span class="bar" style={`width:${String(widthOf(arm.ms, load.maxMs))}%`} />}
</span>
) : (
<span />
)}
<span class="track">
{widthOf(arm.ms, load.maxMs) !== null && <span class="bar" style={`width:${String(widthOf(arm.ms, load.maxMs))}%`} />}
</span>
<span class="time">
{arm.ms === null ? (
<span class="state">{OUTCOME_STATUS[arm.outcome]}</span>
Expand Down Expand Up @@ -328,14 +309,6 @@ const statesOf = (load: BenchCard['loads'][number]): readonly CellOutcome[] => [
margin-left: auto;
}

/* Forced onto its own line under the status badge: the reason reads as a caption to it, not a second fact beside it. */
.reason {
flex-basis: 100%;
font-size: 0.74rem;
line-height: 1.45;
text-wrap: pretty;
}

/* No frame, no ground: the card is already the box, and the global `.panel` rule is what used to draw a second one here. */
.bench-load-panel {
border: 0;
Expand Down
3 changes: 1 addition & 2 deletions site/src/components/pages/BenchmarksPage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
* `bench-cards.ts` before any run happens, so the top of the page cannot become
* a selection of whatever ExoJS won.
*
* The page carries cards and nothing else. A withheld comparison states its own
* reason on the card that withholds it, and the full per-row report - every
* The page carries cards and nothing else. The full per-row report - every
* measured load, its spread and the rows the harness left out - is its own
* page, linked once rather than repeated on every card that would otherwise
* say the same thing.
Expand Down
2 changes: 2 additions & 0 deletions site/src/content/guide/debugging/renderer-sdk-contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Recorded batches reference a `RetainedGroupBundle` you own. One field carries th

Set `nodeCount` on a batch whenever one render node expands into several GPU instances — a tile chunk, a nine-slice, a repeating sprite, a text run. `instanceCount` is the replay draw's instance argument, not a node count, and without `nodeCount` the recorded tier reports instances where the live and entry-replay tiers report nodes. A node is booked once, against whichever batch was open when its first instance was written.

For WebGL2, return `false` from `replayRetainedBatch` when a defensive check prevents drawing. The backend then leaves `batches` and `drawCalls` unchanged. Returning `true` or nothing keeps descriptor-based accounting, so existing SDK replayers remain compatible. WebGPU replayers increment these two counters after issuing a draw. Both backends count `submittedNodes` before replay, including submissions that cannot draw.

## Seam 4 — the pass coordinator

Target, view, clear, the scissor stack and the stencil-clip stack belong to the backend's `passCoordinator`. Reach it through the backend when you need a pass of your own:
Expand Down
22 changes: 2 additions & 20 deletions site/src/lib/bench-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import type { BenchProfileDocument, ProfileBackendName, ProfileCell, ProfileRow, ProfileSection } from './bench-profiles';
import { armLabel, formatLoad, isQuantitative, isWasmReferenceArm, orderArms, OUTCOME_ORDER, outcomeOf, publishedMs, withheldScenario } from './bench-profiles';
import { armLabel, formatLoad, isQuantitative, isWasmReferenceArm, orderArms, OUTCOME_ORDER, outcomeOf, publishedMs } from './bench-profiles';

/** One arm's time on one load of one scenario. */
export interface CardArm {
Expand Down Expand Up @@ -61,15 +61,6 @@ export interface CardLoad {
readonly arms: readonly CardArm[];
/** Largest plottable figure on this load, for scaling the bars. */
readonly maxMs: number;
/**
* Why this load publishes no cross-arm comparison, or `undefined` where it
* publishes one; see `withheldScenario`.
*
* A withheld load keeps every arm's time and loses every bar, factor and
* winner: the arms ran the same scene and are not doing the same work in it,
* which a bar length would assert they were.
*/
readonly withheld: string | undefined;
}

/** One scenario's card. */
Expand Down Expand Up @@ -201,16 +192,8 @@ const loadOf = (row: ProfileRow): CardLoad | null => {
return null;
}

const withheld = withheldScenario(row.archetype);
const cells = orderArms(row.cells, cell => cell.competitor);
// A withheld row loses its quantitative treatment wholesale rather than per
// arm: the doubt is about the comparison, so no arm in it may keep a bar.
const canonical = [reference, ...cells.map(competitorArm)].map(arm => (withheld === undefined ? arm : { ...arm, quantitative: false }));
// Sorted even where the comparison is withheld. The order is the reader's way
// through the rows and the same one on every card, and leaving a withheld
// load in canonical order does not stop anyone ranking four printed numbers,
// it only makes them do it by eye. That these times are not a ranking is said
// where it belongs, on the load's own marker.
const canonical = [reference, ...cells.map(competitorArm)];
const arms = fastestFirst(canonical);
// Every published figure sets the scale, because the bars are durations: an
// arm whose PAIR the clock could not separate still took the time it reports,
Expand All @@ -225,7 +208,6 @@ const loadOf = (row: ProfileRow): CardLoad | null => {
primary: row.primary ?? false,
arms,
maxMs: plotted.length > 0 ? Math.max(...plotted) : 0,
withheld,
};
};

Expand Down
19 changes: 0 additions & 19 deletions site/src/lib/bench-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,25 +660,6 @@ const ARCHETYPE_TITLES: Readonly<Record<string, string>> = {
'settling-pile': 'Settling pile',
};

/**
* Scenarios whose CROSS-ARM comparison is withheld, and why.
*
* Not a scenario that is hidden, and not one that is dropped from the page: the
* arms all ran it, and each arm's own times are published. What is withheld is
* the comparison between them - no factor, no winner, no bar read as a
* performance claim - because the arms are known not to be doing the same work.
*
* Withholding is a statement about the measurement and never about the result.
* A row is listed here only for a documented reason that applies whichever way
* the figures came out, and a row is never listed because ExoJS trails on it;
* removing a losing card and quietly keeping a winning one is exactly what a
* fixed headline set exists to prevent.
*/
const WITHHELD_SCENARIOS: Readonly<Record<string, string>> = {};

/** Why a scenario publishes no cross-arm comparison, or `undefined` where it publishes one. */
export const withheldScenario = (archetype: string): string | undefined => WITHHELD_SCENARIOS[archetype];

/** The readable title for a scenario, falling back to its id where none is written. */
export const archetypeTitle = (archetype: string): string => ARCHETYPE_TITLES[archetype] ?? archetype;

Expand Down
19 changes: 6 additions & 13 deletions src/rendering/coreRendererBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@ import { BitmapText } from '#rendering/text/BitmapText';
import { Text } from '#rendering/text/Text';
import { Video } from '#rendering/video/Video';
import { WebGl2MeshRenderer } from '#rendering/webgl2/WebGl2MeshRenderer';
import { WebGl2NineSliceSpriteRenderer } from '#rendering/webgl2/WebGl2NineSliceSpriteRenderer';
import { WebGl2RepeatingSpriteRenderer } from '#rendering/webgl2/WebGl2RepeatingSpriteRenderer';
import { WebGl2ScalableSpriteRenderer } from '#rendering/webgl2/WebGl2ScalableSpriteRenderer';
import { WebGl2SpriteRenderer } from '#rendering/webgl2/WebGl2SpriteRenderer';
import { WebGl2TextRenderer } from '#rendering/webgl2/WebGl2TextRenderer';
import { WebGpuMeshRenderer } from '#rendering/webgpu/WebGpuMeshRenderer';
import { WebGpuNineSliceSpriteRenderer } from '#rendering/webgpu/WebGpuNineSliceSpriteRenderer';
import { WebGpuRepeatingSpriteRenderer } from '#rendering/webgpu/WebGpuRepeatingSpriteRenderer';
import { WebGpuScalableSpriteRenderer } from '#rendering/webgpu/WebGpuScalableSpriteRenderer';
import { WebGpuSpriteRenderer } from '#rendering/webgpu/WebGpuSpriteRenderer';
import { WebGpuTextRenderer } from '#rendering/webgpu/WebGpuTextRenderer';
import { WebGpuVideoRenderer } from '#rendering/webgpu/WebGpuVideoRenderer';
Expand Down Expand Up @@ -48,13 +46,9 @@ export const buildCoreRendererBindings = (options: RenderingApplicationOptions):
[RenderBackendType.WebGl2]: () => new WebGl2TextRenderer(),
[RenderBackendType.WebGpu]: () => new WebGpuTextRenderer(),
};
const nineSliceRenderers: BackendRendererMap<NineSliceSprite> = {
[RenderBackendType.WebGl2]: () => new WebGl2NineSliceSpriteRenderer(spriteRendererBatchSize),
[RenderBackendType.WebGpu]: () => new WebGpuNineSliceSpriteRenderer(),
};
const repeatingSpriteRenderers: BackendRendererMap<RepeatingSprite> = {
[RenderBackendType.WebGl2]: () => new WebGl2RepeatingSpriteRenderer(spriteRendererBatchSize),
[RenderBackendType.WebGpu]: () => new WebGpuRepeatingSpriteRenderer(),
const scalableSpriteRenderers: BackendRendererMap<NineSliceSprite | RepeatingSprite> = {
[RenderBackendType.WebGl2]: () => new WebGl2ScalableSpriteRenderer(spriteRendererBatchSize),
[RenderBackendType.WebGpu]: () => new WebGpuScalableSpriteRenderer(),
};
const videoRenderers: BackendRendererMap<Video> = {
[RenderBackendType.WebGpu]: () => new WebGpuVideoRenderer(),
Expand All @@ -66,7 +60,6 @@ export const buildCoreRendererBindings = (options: RenderingApplicationOptions):
defineRendererBinding([Mesh], backend => meshRenderers[backend.backendType]?.()),
// Text and BitmapText share the same renderer class - one multi-target binding.
defineRendererBinding([Text, BitmapText], backend => textRenderers[backend.backendType]?.()),
defineRendererBinding([NineSliceSprite], backend => nineSliceRenderers[backend.backendType]?.()),
defineRendererBinding([RepeatingSprite], backend => repeatingSpriteRenderers[backend.backendType]?.()),
defineRendererBinding([NineSliceSprite, RepeatingSprite], backend => scalableSpriteRenderers[backend.backendType]?.()),
];
};
Loading
Loading