Skip to content

Commit 1cf9f8a

Browse files
authored
test(core): add perf A/B against kernels (#38)
* test(core): add the stats behind the kernel A/B verdict * test(core): add the kernel A/B runner and bench:ab command Runs base and HEAD copies of src in one headless Chromium page, interleaving timed rounds after a warm-up that waits for the GPU clock to settle. * test(core): harden the kernel A/B against noise Confirms a flagged row with a second run that allocates B first, warms up with full-size rounds, frees executor pools per case, adds a device without subgroups, and reads hwc4 outputs through toChw. * test(core): add kernel A/B cases for every dispatch route 147 cases over matmul, attention, reductions, elementwise, conv, shape and SSM kernels, with shapes from the zoo models. * test(core): add a tiled matmul A/B case past 2^24 weight elements * docs: document the kernel A/B in the testing skill * test(core): print the kernel A/B report as a boxed table in the terminal The markdown table stays in .bench/results/report.md for the pull request. * refactor(core): keep each kernel A/B report column's name, alignment and value in one list * refactor(core): rename the kernel A/B Side to Version and keep to freeAfterCase * refactor(core): rename the kernel A/B Version.up to upload * refactor(core): rename the kernel A/B World to GpuSetup and ex to executors * refactor(core): rename the kernel A/B c to testCase and reword the runCase comments * chore(core): shorten the kernel A/B MIN_NOISE comment * test(core): name kernel A/B cases after the route they actually reach * fix(core): reject unknown -k kernel names in bench:ab * chore: keep the bench:ab base copy out of ESLint * test(core): cover scalar and no-subgroups softmax and rmsNorm in the reduce A/B * test(core): drop the quantized matmulGather A/B cases, main's matmulGather takes no scales
1 parent a3ad6dc commit 1cf9f8a

19 files changed

Lines changed: 1972 additions & 4 deletions

‎.claude/skills/testing/SKILL.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,17 @@ description: How to write and run tests and benchmarks in this repo. Use wheneve
7676
report. Performance stays the same or gets better.
7777
- Benchmarks always run on the same machine, and every run is saved to a file,
7878
so a slow regression over many PRs shows up.
79+
- A change to a kernel gets a kernel A/B against `main`:
80+
`pnpm --filter runntime bench:ab kernels [-b <ref>] [-k matmul,reduce]`.
81+
It copies the base ref's `src` to `.bench/base`, runs both copies in one
82+
headless Chromium page on the same inputs with their rounds interleaved,
83+
and prints a table, with a markdown copy for the pull request in
84+
`.bench/results/report.md`. A row says `faster` or `slower` only past the
85+
noise: both versions' interquartile spread, at least 3%.
86+
- The table's last column is the largest difference between A's and B's
87+
outputs. Anything but 0 on a kernel the change did not mean to touch is a
88+
bug, or a case the GPU tests are missing.
89+
- Cases live in `bench/kernels/<kernel>.ab.ts`, one per route the dispatch
90+
can pick, with shapes from the zoo models. A new route gets a case.
91+
- Close the GPU-heavy apps before a run, and run on power. A row that flips
92+
between runs is noise, not a result.

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ apps/docs/src/content/docs/api/
1414

1515
# vitest browser-mode failure screenshots
1616
.vitest/
17+
18+
# kernel A/B base copy and results
19+
.bench/

‎eslint.config.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import globals from 'globals';
66
export default tseslint.config(
77
{
88
// `ignores` must be the only key here, otherwise it stops applying globally
9-
ignores: ['**/dist/**', '**/node_modules/**', '**/.astro/**', '.claude/**'],
9+
ignores: ['**/dist/**', '**/node_modules/**', '**/.astro/**', '**/.bench/**', '.claude/**'],
1010
},
1111
js.configs.recommended,
1212
...tseslint.configs.recommended,

‎packages/runntime/bench/ab.ts‎

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/** The kernel A/B runner. A is the base ref's `src` (copied to `.bench/base`
2+
* by `bench/cli.ts`), B is this checkout's `src`. Both run in one page on the
3+
* same device and inputs, with their rounds interleaved so GPU clock
4+
* changes hit both versions alike. */
5+
import tgpu, { type TgpuRoot } from 'typegpu';
6+
import { afterAll, describe, inject, it } from 'vitest';
7+
import { commands } from 'vitest/browser';
8+
import * as libA from '@ab/a/core/index.ts';
9+
import * as libB from '@ab/b/core/index.ts';
10+
import { randomF32 } from '../test/gpu.ts';
11+
import { installProfiler, type Profiler } from './profiler.ts';
12+
import {
13+
callsPerRound,
14+
compare,
15+
confirm,
16+
maxAbsDiff,
17+
median,
18+
spread,
19+
type CaseResult,
20+
} from './stats.ts';
21+
22+
/** The core API. Both versions are typed as this checkout's; a case that uses
23+
* something the base lacks throws on A and is reported as skipped. */
24+
export type Lib = typeof libB;
25+
type Value = libB.Value;
26+
type ValueMeta = libB.ValueMeta;
27+
28+
/** What a case builds with, for one of the two library versions: A (the
29+
* base ref) or B (this checkout). */
30+
export interface Version {
31+
label: 'A' | 'B';
32+
lib: Lib;
33+
root: TgpuRoot;
34+
/** `tensor()` on this version's device, freed after the case. */
35+
upload: (data: Float32Array, meta: ValueMeta) => Value;
36+
/** Destroys the Value's buffer when the case ends and returns the Value as
37+
* is. For buffers `upload` cannot make (`materialized` + `uploadF16`). */
38+
freeAfterCase: (value: Value) => Value;
39+
/** Seeded values in [-1, 1). The seed restarts for each version, so A and B
40+
* draw the same numbers in the same order. */
41+
rand: (n: number) => Float32Array;
42+
}
43+
44+
export interface AbCase {
45+
/** Route and where the shape comes from, e.g. 'tiled, MiniLM qkv'. */
46+
name: string;
47+
/** Device features the route needs. Missing ones skip the case. */
48+
needs?: GPUFeatureName[];
49+
/** Runs on a second device without `subgroups`, for the routes that only
50+
* exist there. */
51+
noSubgroups?: boolean;
52+
/** Uploads the inputs and returns a function that builds one call. */
53+
build: (version: Version) => () => Value;
54+
}
55+
56+
/** Rounds per version. Alternating ABBA over 8 rounds puts each version first
57+
* equally often. */
58+
const ROUNDS = 8;
59+
60+
/** Warm-up runs at least MIN_WARMUP_MS and stops once three rounds in a row
61+
* agree within SETTLED_SPREAD, or at MAX_WARMUP_MS. */
62+
const MIN_WARMUP_MS = 300;
63+
const MAX_WARMUP_MS = 5000;
64+
const SETTLED_SPREAD = 0.02;
65+
66+
/** The GPU device a case runs on, and one executor per version on it. */
67+
interface GpuSetup {
68+
root: TgpuRoot;
69+
profiler: Profiler;
70+
executors: [libA.RunntimeExecutor, libB.RunntimeExecutor];
71+
}
72+
73+
/** Keyed by noSubgroups. */
74+
const gpuSetups = new Map<boolean, Promise<GpuSetup>>();
75+
76+
/** Creates the device once and reuses it: the production feature set, or the
77+
* same without `subgroups`. A second device is needed for that, since
78+
* features are fixed when a device is created. */
79+
function gpuSetup(noSubgroups: boolean): Promise<GpuSetup> {
80+
let setup = gpuSetups.get(noSubgroups);
81+
if (!setup) {
82+
const features: GPUFeatureName[] = ['shader-f16', 'timestamp-query'];
83+
if (!noSubgroups) features.push('subgroups');
84+
setup = tgpu.init({ device: { optionalFeatures: features } }).then((root) => ({
85+
root,
86+
profiler: installProfiler(root.device),
87+
executors: [libA.gpuExecutor(root), libB.gpuExecutor(root)],
88+
}));
89+
gpuSetups.set(noSubgroups, setup);
90+
}
91+
return setup;
92+
}
93+
94+
/** GPU µs per call over one round of `calls` calls in a single submit. */
95+
async function round(gpu: GpuSetup, i: 0 | 1, call: () => Value, calls: number): Promise<number> {
96+
const lib = i === 0 ? (libA as Lib) : libB;
97+
const ys = Array.from({ length: calls }, call);
98+
lib.evalValues(ys, gpu.executors[i]);
99+
gpu.executors[i].releaseValues(ys);
100+
await gpu.root.device.queue.onSubmittedWorkDone();
101+
const ns = (await gpu.profiler.collect()).reduce((a, b) => a + b, 0);
102+
if (ns === 0) throw new Error(`no GPU time recorded on ${i === 0 ? 'A' : 'B'}`);
103+
return ns / 1000 / calls;
104+
}
105+
106+
/** Measures a case. If it comes out faster or slower, measures it again on
107+
* fresh buffers and keeps that verdict only if both runs agree. */
108+
async function runCase(kernel: string, testCase: AbCase, gpu: GpuSetup): Promise<CaseResult> {
109+
try {
110+
const first = await measure(kernel, testCase, gpu, false);
111+
if (first.comparison?.verdict !== 'same' && first.comparison) {
112+
// Run again with B's buffers created first. If the first result only
113+
// came from where the buffers landed in memory, it goes away or flips,
114+
// and confirm() turns the verdict into 'same'.
115+
const second = await measure(kernel, testCase, gpu, true);
116+
if (second.comparison) {
117+
return { ...first, comparison: confirm(first.comparison, second.comparison) };
118+
}
119+
}
120+
return first;
121+
} finally {
122+
// The buffer pools keep a buffer for every output size they have seen.
123+
// Without freeing them after each case, a whole file's cases use enough
124+
// GPU memory to crash the browser tab.
125+
gpu.executors[0].dispose();
126+
gpu.executors[1].dispose();
127+
}
128+
}
129+
130+
async function measure(
131+
kernel: string,
132+
testCase: AbCase,
133+
gpu: GpuSetup,
134+
bFirst: boolean,
135+
): Promise<CaseResult> {
136+
const owned: Value[] = [];
137+
const freeAfterCase = (v: Value) => (owned.push(v), v);
138+
const calls: (() => Value)[] = [];
139+
try {
140+
for (const i of bFirst ? [1, 0] : [0, 1]) {
141+
const label = i === 0 ? 'A' : 'B';
142+
const lib = i === 0 ? (libA as Lib) : libB;
143+
const upload = (data: Float32Array, meta: ValueMeta) =>
144+
freeAfterCase(lib.tensor(gpu.root, data, meta));
145+
let seed = 1;
146+
const rand = (n: number) => randomF32(n, seed++);
147+
try {
148+
calls[i] = testCase.build({ label, lib, root: gpu.root, upload, freeAfterCase, rand });
149+
} catch (err) {
150+
return {
151+
kernel,
152+
case: testCase.name,
153+
note: `skipped, threw on ${label}: ${messageOf(err)}`,
154+
};
155+
}
156+
}
157+
const [callA, callB] = [calls[0]!, calls[1]!];
158+
159+
// Output check, which also compiles both versions' pipelines.
160+
const yA = callA();
161+
const yB = callB();
162+
const outA = await readOut(libA as Lib, yA, gpu.executors[0]);
163+
const outB = await readOut(libB, yB, gpu.executors[1]);
164+
gpu.executors[0].releaseValues([yA]);
165+
gpu.executors[1].releaseValues([yB]);
166+
const outBytes = yB.shape.elems * (yB.shape.dtype === 'f16' ? 2 : 4);
167+
168+
// Apple GPUs idle at a low clock and ramp up only under sustained load,
169+
// so warm up with full-size rounds on both versions until B's time settles,
170+
// and size the timed rounds from there.
171+
const start = performance.now();
172+
const recent: number[] = [];
173+
let n = 4;
174+
for (;;) {
175+
await round(gpu, 0, callA, n);
176+
recent.push(await round(gpu, 1, callB, n));
177+
n = callsPerRound(recent.at(-1)!, outBytes);
178+
const elapsed = performance.now() - start;
179+
const settled = recent.length >= 3 && spread(recent.slice(-3)) <= SETTLED_SPREAD;
180+
if (elapsed >= MAX_WARMUP_MS || (elapsed >= MIN_WARMUP_MS && settled)) break;
181+
}
182+
n = callsPerRound(median(recent.slice(-3)), outBytes);
183+
184+
const times: [number[], number[]] = [[], []];
185+
for (let r = 0; r < ROUNDS; r++) {
186+
const order = r % 4 === 0 || r % 4 === 3 ? ([0, 1] as const) : ([1, 0] as const);
187+
for (const i of order) times[i].push(await round(gpu, i, calls[i]!, n));
188+
}
189+
return {
190+
kernel,
191+
case: testCase.name,
192+
comparison: compare(times[0], times[1]),
193+
maxAbsDiff: maxAbsDiff(outA, outB),
194+
calls: n,
195+
};
196+
} finally {
197+
for (const v of owned) if (v.state === 'materialized') v.buffer.destroy();
198+
}
199+
}
200+
201+
/** Reads an output for the A/B check. hwc4 cannot be read back as is, so it
202+
* goes through toChw first; that dispatch is never timed. */
203+
function readOut(lib: Lib, y: Value, executor: libB.RunntimeExecutor): Promise<Float32Array> {
204+
return lib.toArray(y.shape.layout === 'hwc4' ? lib.toChw(y, 'f32') : y, executor);
205+
}
206+
207+
function messageOf(err: unknown): string {
208+
return (err instanceof Error ? err.message : String(err)).split('\n')[0]!.slice(0, 120);
209+
}
210+
211+
/** Registers one test per case and writes the kernel's results to
212+
* `.bench/results/<kernel>.json` for `bench/cli.ts` to report. */
213+
export function abSuite(kernel: string, cases: readonly AbCase[]): void {
214+
const results: CaseResult[] = [];
215+
describe(kernel, () => {
216+
for (const testCase of cases) {
217+
it(testCase.name, async (ctx) => {
218+
const gpu = await gpuSetup(testCase.noSubgroups === true);
219+
const missing = testCase.needs?.find((f) => !gpu.root.enabledFeatures.has(f));
220+
let result: CaseResult;
221+
try {
222+
result = missing
223+
? { kernel, case: testCase.name, note: `skipped, device has no ${missing}` }
224+
: await runCase(kernel, testCase, gpu);
225+
} catch (err) {
226+
// Still a row in the report, and still a failed test.
227+
results.push({ kernel, case: testCase.name, note: `failed: ${messageOf(err)}` });
228+
throw err;
229+
}
230+
results.push(result);
231+
if (result.note) ctx.skip(result.note);
232+
});
233+
}
234+
});
235+
afterAll(async () => {
236+
await commands.writeFile(
237+
`${inject('abResultsDir')}/${kernel}.json`,
238+
JSON.stringify(results, null, 2),
239+
);
240+
});
241+
}
242+
243+
declare module 'vitest' {
244+
export interface ProvidedContext {
245+
abResultsDir: string;
246+
}
247+
}

‎packages/runntime/bench/cli.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/** `pnpm bench:ab kernels [-b <ref>] [-k matmul,reduce]`: copies the base
2+
* ref's `src` to `.bench/base`, runs the kernel A/B in headless Chromium and
3+
* prints the report as a table, with a markdown copy for the pull request in
4+
* `.bench/results/report.md`. */
5+
import { execFileSync, spawnSync } from 'node:child_process';
6+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7+
import { fileURLToPath } from 'node:url';
8+
import { parseArgs, styleText } from 'node:util';
9+
import { formatTable, formatTerminal, type CaseResult, type Verdict } from './stats.ts';
10+
11+
const pkg = fileURLToPath(new URL('..', import.meta.url));
12+
const base = `${pkg}.bench/base`;
13+
const results = `${pkg}.bench/results`;
14+
15+
const { values, positionals } = parseArgs({
16+
allowPositionals: true,
17+
options: {
18+
base: { type: 'string', short: 'b', default: 'main' },
19+
kernels: { type: 'string', short: 'k' },
20+
},
21+
});
22+
if (positionals[0] !== 'kernels') {
23+
console.error('usage: pnpm bench:ab kernels [-b <ref>] [-k matmul,reduce]');
24+
process.exit(1);
25+
}
26+
27+
const known = readdirSync(`${pkg}bench/kernels`)
28+
.filter((f) => f.endsWith('.ab.ts'))
29+
.map((f) => f.slice(0, -'.ab.ts'.length));
30+
const unknown = values.kernels?.split(',').filter((k) => !known.includes(k)) ?? [];
31+
if (unknown.length > 0) {
32+
console.error(`no bench/kernels/<name>.ab.ts for: ${unknown.join(', ')}`);
33+
console.error(`-k takes: ${known.join(', ')}`);
34+
process.exit(1);
35+
}
36+
37+
const git = (...args: string[]) => execFileSync('git', args, { cwd: pkg, encoding: 'utf8' }).trim();
38+
const sha = git('rev-parse', '--short', values.base);
39+
const head = git('rev-parse', '--short', 'HEAD');
40+
const dirty = git('status', '--porcelain', 'src') ? ' + uncommitted changes' : '';
41+
42+
rmSync(`${pkg}.bench`, { recursive: true, force: true });
43+
mkdirSync(base, { recursive: true });
44+
mkdirSync(results, { recursive: true });
45+
execFileSync('sh', ['-c', `git archive ${sha} src | tar -x -C "${base}"`], { cwd: pkg });
46+
47+
const files = values.kernels?.split(',').map((k) => `bench/kernels/${k}.ab.ts`) ?? [];
48+
const run = spawnSync(
49+
'pnpm',
50+
['exec', 'vitest', 'run', '--config', 'bench/vitest.config.ts', ...files],
51+
{ cwd: pkg, stdio: 'inherit' },
52+
);
53+
54+
const rows = readdirSync(results)
55+
.filter((f) => f.endsWith('.json'))
56+
.flatMap((f) => JSON.parse(readFileSync(`${results}/${f}`, 'utf8')) as CaseResult[]);
57+
const title = `A: \`${values.base}\` (${sha}), B: \`HEAD\` (${head})${dirty}`;
58+
writeFileSync(`${results}/report.md`, `${title}\n\n${formatTable(rows)}\n`);
59+
60+
const colors: Record<Verdict, 'green' | 'red' | 'dim'> = {
61+
faster: 'green',
62+
slower: 'red',
63+
same: 'dim',
64+
};
65+
const paint = (v: Verdict, text: string) => styleText(colors[v], text);
66+
console.log(`\n${title.replaceAll('`', '')}\n\n${formatTerminal(rows, paint)}\n`);
67+
console.log(`Markdown for the pull request: ${results}/report.md`);
68+
process.exit(run.status ?? 1);

0 commit comments

Comments
 (0)