perf: compile scene shaders off-thread before the first frame

I warm the world's programs with renderer.compileAsync() at the end of the
async build, with the composer's scene target bound so tone mapping and
output colour space key the same programs the composer draws with, and with
hidden subtrees revealed so the light count matches the frame-0 warm render.
The wait is capped at 2.5 s; the warm render stays as the safety net for the
post passes, texture uploads and anything a slow or extension-less driver
left. Test builds report renderer.info.programs, and a browser spec pins zero
program growth after frame 0.

On the Intel ARL laptop the frame-0 task drops from ~1.74 s to ~0.34 s with a
pixel-identical first frame; measurements in
docs/overnight/compile-async-results.md.
This commit is contained in:
2026-10-12 18:34:12 +03:00
parent 71465fa481
commit 26e5ff01fd
8 changed files with 576 additions and 5 deletions

View File

@@ -0,0 +1,106 @@
// usage: node measure.mjs <label> <runs> [--shot] [--nokhr] [--noscroll]
import { chromium } from 'playwright';
import { writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
const [label, runsArg] = process.argv.slice(2);
const SHOT = process.argv.includes('--shot');
const NOKHR = process.argv.includes('--nokhr');
const NOSCROLL = process.argv.includes('--noscroll');
const runs = Number(runsArg ?? 5);
const out = process.env.OUT ?? 'compile-async-out'; // runs from the repo root; serve dist on 127.0.0.1:5181 first
mkdirSync(out, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const init = `(() => {
const T = { links: [], lit: null, linksAtLit: null, longtasks: [], frames: [], shot: null };
window.__m = T;
for (const C of [WebGL2RenderingContext, WebGLRenderingContext]) {
const link = C.prototype.linkProgram;
C.prototype.linkProgram = function (p) { T.links.push(performance.now()); return link.call(this, p); };
if (${NOKHR}) {
const ge = C.prototype.getExtension;
C.prototype.getExtension = function (n) { return n === 'KHR_parallel_shader_compile' ? null : ge.call(this, n); };
const gs = C.prototype.getSupportedExtensions;
C.prototype.getSupportedExtensions = function () { return gs.call(this).filter((e) => e !== 'KHR_parallel_shader_compile'); };
}
}
new PerformanceObserver((l) => { for (const e of l.getEntries()) T.longtasks.push([e.startTime, e.duration]); })
.observe({ type: 'longtask', buffered: true });
const mo = new MutationObserver(() => {
if (T.lit !== null) return;
const c = document.querySelector('canvas.journey-canvas');
if (!c || c.dataset.lit === undefined) return;
T.lit = performance.now();
T.linksAtLit = T.links.length;
if (${SHOT}) T.shot = c.toDataURL('image/png');
});
mo.observe(document, { subtree: true, attributes: true, attributeFilter: ['data-lit'] });
let last = null;
const loop = (t) => { if (last !== null) T.frames.push([t, t - last]); last = t; requestAnimationFrame(loop); };
requestAnimationFrame(loop);
})();`;
const results = [];
for (let i = 0; i < runs; i += 1) {
const browser = await chromium.launch({
channel: 'chromium',
headless: process.env.HEADED !== '1',
args: ['--use-gl=angle', '--use-angle=gl', '--ignore-gpu-blocklist', '--enable-gpu-rasterization',
'--enable-webgl', '--disable-gpu-sandbox', '--disable-gpu-shader-disk-cache'],
});
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
const page = await context.newPage();
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.addInitScript(init);
await page.goto('http://127.0.0.1:5181/', { waitUntil: 'load' });
await page.waitForFunction(() => window.__m.lit !== null, null, { timeout: 20000 });
const env = await page.evaluate(() => {
const gl = document.createElement('canvas').getContext('webgl2');
const ext = gl.getExtension('WEBGL_debug_renderer_info');
return {
renderer: ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
khr: gl.getExtension('KHR_parallel_shader_compile') !== null,
};
});
await sleep(2500);
const scrollStart = await page.evaluate(() => performance.now());
if (!NOSCROLL) {
await page.mouse.move(720, 450);
const t0 = Date.now();
while (Date.now() - t0 < 10000) {
await page.mouse.wheel(0, 200);
await sleep(80);
}
} else {
await sleep(10000);
}
const scrollEnd = await page.evaluate(() => performance.now());
const m = await page.evaluate(() => window.__m);
const nav = await page.evaluate(() => performance.getEntriesByType('navigation')[0].toJSON());
const r = {
run: i,
renderer: env.renderer,
khr: env.khr,
litMs: Math.round(m.lit),
linksBeforeLit: m.linksAtLit,
linksAfterLit: m.links.length - m.linksAtLit,
linksAfterLitTimes: m.links.slice(m.linksAtLit).map((t) => Math.round(t - m.lit)),
frame0Task: (() => { const t = m.longtasks.find(([st, d]) => st <= m.lit && m.lit <= st + d + 5); return t ? Math.round(t[1]) : null; })(),
linksInFrame0Task: (() => { const t = m.longtasks.find(([st, d]) => st <= m.lit && m.lit <= st + d + 5); return t ? m.links.filter((x) => x >= t[0] && x <= m.lit).length : null; })(),
timeline: (() => { const t = m.longtasks.find(([st, d]) => st <= m.lit && m.lit <= st + d + 5); const f0 = t ? t[0] : m.lit; const pre = m.links.filter((x) => x < f0); return { firstLink: Math.round(m.links[0]), lastBuildLink: pre.length ? Math.round(pre[pre.length - 1]) : null, buildLinks: pre.length, frame0Start: Math.round(f0), lit: Math.round(m.lit) }; })(),
longestTask: Math.round(Math.max(0, ...m.longtasks.map((x) => x[1]))),
longTasks: m.longtasks.filter((x) => x[1] > 100).map((x) => [Math.round(x[0]), Math.round(x[1])]),
framesOver50Idle: m.frames.filter(([t, d]) => t > m.lit + 50 && t < scrollStart && d > 50).map(([t, d]) => [Math.round(t - m.lit), Math.round(d)]),
framesOver50Scroll: m.frames.filter(([t, d]) => t >= scrollStart && t <= scrollEnd && d > 50).map(([t, d]) => [Math.round(t - scrollStart), Math.round(d)]),
worstScrollFrame: Math.round(Math.max(0, ...m.frames.filter(([t]) => t >= scrollStart && t <= scrollEnd).map((x) => x[1]))),
scrollY: await page.evaluate(() => scrollY),
domContentLoaded: Math.round(nav.domContentLoadedEventEnd),
errors,
};
if (SHOT && m.shot) writeFileSync(`${out}/${label}-frame0-${Date.now()}.png`, Buffer.from(m.shot.split(',')[1], 'base64'));
console.log(JSON.stringify(r));
results.push(r);
await browser.close();
}
appendFileSync(`${out}/${label}.jsonl`, results.map((r) => JSON.stringify(r)).join('\n') + '\n');