Merge branch 'polish/compile-async' into redesign/overnight
This commit is contained in:
106
docs/overnight/compile-async-measure.mjs
Normal file
106
docs/overnight/compile-async-measure.mjs
Normal 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');
|
||||
152
docs/overnight/compile-async-results.md
Normal file
152
docs/overnight/compile-async-results.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Issue 2, option A: `compileAsync` shader warm-up — measurements
|
||||
|
||||
Branch `polish/compile-async`, against baseline `71465fa`.
|
||||
|
||||
## What changed
|
||||
|
||||
- `src/scene/world/shader-warmup.ts` (new): `warmUpShaders()` binds the
|
||||
composer's scene target, reveals hidden subtrees (so `compile()` gathers the
|
||||
same lights as the frame-0 warm render), calls `renderer.compileAsync()`,
|
||||
restores target and visibility right after the synchronous `compile()` part,
|
||||
then awaits the parallel link with a 2.5 s timeout (`ready` / `timeout` /
|
||||
`failed`).
|
||||
- `src/scene/post-processing.ts`: `PostProcessor.sceneTarget()` returns the
|
||||
EffectComposer `readBuffer` (the target RenderPass draws into). In three r185,
|
||||
`WebGLPrograms.getParameters` keys tone mapping and output colour space on the
|
||||
bound target. Any non-XR off-screen target gives the same key, so the
|
||||
precompiled programs are the ones the composer uses.
|
||||
- `src/scene/world/environment.ts`: at the end of the async build, call
|
||||
`resize()`, then `await warmUpShaders(...)`. The frame-0 warm render stays as
|
||||
the safety net. Post-pass programs are not in the scene, so the warm render
|
||||
still builds those. It also uploads textures and finishes whatever a
|
||||
timed-out or extension-less warm-up left behind. Test builds dispatch
|
||||
`portfolio-program-count` whenever `renderer.info.programs.length` changes.
|
||||
- Regression checks:
|
||||
- `tests/browser/shader-warmup.spec.ts`: after frame 0 no program is ever
|
||||
created (idle ignition, then 12 wheel steps). It also checks that the
|
||||
warm-up ran and that frame 0 adds at most 12 programs on top of it (only
|
||||
the post passes; a mis-keyed warm-up would add about 60). Passes on desktop-chromium and
|
||||
mobile-chromium. In SwiftShader, desktop compiles 59 programs in the
|
||||
warm-up and frame 0 adds 9 post-pass programs, for 68 total. Mobile:
|
||||
53 + 0 = 53.
|
||||
- `tests/unit/shader-warmup.test.ts` (fakes): target and visibility during
|
||||
compile, both restored afterwards, the timeout, the failure paths, and the
|
||||
timer cleanup.
|
||||
- `tests/unit/world-models.test.ts`: `sceneTarget()`.
|
||||
|
||||
## Method
|
||||
|
||||
- Hardware: `ANGLE (Intel, Mesa Intel(R) Graphics (ARL), OpenGL ES 3.2)`.
|
||||
Playwright Chromium 151.0.7922.34, **headed** on the desktop session. In a
|
||||
headless trial the frame-0 task took 3.1 s and every scroll frame took
|
||||
>50 ms, so headed is the representative setup.
|
||||
- Flags: `--use-gl=angle --use-angle=gl --ignore-gpu-blocklist
|
||||
--enable-gpu-rasterization --enable-webgl --disable-gpu-sandbox
|
||||
--disable-gpu-shader-disk-cache`. These are the hardware-GL flags from the
|
||||
scratchpad `shots.mjs`; `r6.mjs` no longer existed. Every run launches a new
|
||||
browser with a fresh temporary profile. Viewport 1440x900, DPR 1, production
|
||||
preview build served by `python3 -m http.server` on 127.0.0.1:5181.
|
||||
- Runs were interleaved A/B/A/B. The harness is
|
||||
`docs/overnight/compile-async-measure.mjs`. An init script wraps
|
||||
`linkProgram` (count and timestamps, so it works on production builds),
|
||||
watches `canvas[data-lit]` with a MutationObserver (it fires in the microtask
|
||||
right after the frame-0 render, still inside that task), records
|
||||
`PerformanceObserver('longtask')` and rAF deltas. After lit it idles 2.5 s,
|
||||
then wheels 200 px every 80 ms for 10 s.
|
||||
- **Power and load:** `ADP0/online` read `1` before every run, and
|
||||
`BAT0` reported "Not charging", 79 %. The coordinator reported battery power
|
||||
earlier. Load average was **6-11** because four other agents shared the
|
||||
machine. **Absolute times are inflated and noisy. Compare the A/B
|
||||
differences, not the absolute numbers.**
|
||||
|
||||
## Results (median [min-max])
|
||||
|
||||
### KHR_parallel_shader_compile available (6 + 6 runs)
|
||||
|
||||
| metric | baseline 71465fa | option A |
|
||||
|---|---|---|
|
||||
| time to first scene frame (`data-lit`, ms after navigation start) | 2788 [2733-3431] | 2667 [2422-2960] |
|
||||
| frame-0 task (long task that contains frame 0) | **1737 [1706-1771]** | **340 [323-405]** |
|
||||
| longest main-thread task | 1737 [1706-1771] | 458 [422-521] (page boot JS, not the scene) |
|
||||
| programs linked before frame 0 / inside frame 0 / after | 3 / 68 / 0 | 62 (async, during the build) / 9 (post passes) / 0 |
|
||||
| frame after frame 0 (presentation hitch) | 177 [154-229] | 98 [0-167] |
|
||||
| scroll frames >50 ms in 10 s (per run) | 13, 6, 5, 20, 72, 42 | 12, 6, 3, 29, 72, 28 |
|
||||
| worst scroll frame | 146 [71-229] | 158 [71-250] |
|
||||
| long tasks during scroll | none | none |
|
||||
|
||||
Timeline from three more interleaved runs per variant, ms after navigation:
|
||||
|
||||
| | first link | last link before frame 0 | frame 0 starts | first frame (lit) |
|
||||
|---|---|---|---|---|
|
||||
| baseline | 1497 / 834 / 842 | 3 links only | 1621 / 944 / 964 | 3144 / 2396 / 2453 |
|
||||
| option A | 752 / 810 / 818 | 918 / 978 / 994 (62 links) | 1933 / 2109 / 2152 | 2248 / 2417 / 2454 |
|
||||
|
||||
Option A issues the 59 scene links in about 170 ms, inside the build's
|
||||
existing ~420 ms boot task, so it adds no new long task. The build then
|
||||
awaits the driver for about 1.15 s while the main thread is free (poster up,
|
||||
page scrollable). Frame 0 follows at about 300 ms.
|
||||
|
||||
### KHR_parallel_shader_compile hidden (getExtension patched, 3 + 3 runs)
|
||||
|
||||
| metric | baseline | option A |
|
||||
|---|---|---|
|
||||
| first scene frame | 2611 [2604-2686] | 2473 [2433-2820] |
|
||||
| frame-0 task | 1647 [1631-1679] | 1418 [1400-1559] |
|
||||
| links inside frame 0 | 68 | 9 (the other 62 were queued during the build) |
|
||||
| links after frame 0 | 0 | 0 |
|
||||
|
||||
Without the extension three marks every program ready immediately, so
|
||||
`compileAsync` resolves after one 10 ms timeout. The links queued during the
|
||||
build overlap the rest of the build, and the warm render then blocks on the
|
||||
first status query. The result is still a little better than baseline,
|
||||
never worse. Behaviour is the same as before, with no timeout path.
|
||||
|
||||
### Visual correctness
|
||||
|
||||
Frame 0 was captured with `canvas.toDataURL()` inside the frame-0 task, 3
|
||||
interleaved runs per variant. All 15 pairwise comparisons (base-base,
|
||||
new-new, base-new) have **max channel difference 0**: option A's first
|
||||
frame is pixel-identical to the baseline's.
|
||||
|
||||
## Pros and cons (measured)
|
||||
|
||||
Pros
|
||||
- The frame-0 main-thread block drops from about 1.74 s to about 0.34 s (-80 %).
|
||||
The longest main-thread task on the page goes from the scene's 1.7 s to
|
||||
page boot (~0.45 s).
|
||||
- About 1.15 s of shader linking now runs on the driver's threads while the
|
||||
poster shows and the page stays responsive.
|
||||
- The hitch on the frame after frame 0 is also smaller (177 -> 98 ms median).
|
||||
- Frame 0 is pixel-identical. The regression test pins zero program growth
|
||||
after frame 0. On this machine no programs were linked after frame 0 in
|
||||
10 s of scrolling, in either variant.
|
||||
- No extension: slightly better than baseline. Slow driver: the 2.5 s
|
||||
timeout hands the remainder to the warm render, which is the old behaviour.
|
||||
|
||||
Cons
|
||||
- **Time to first scene frame does not improve much** (-120 ms median with
|
||||
KHR, within noise). The total link work is about the same; it moved off
|
||||
the main thread, it did not shrink. The win is responsiveness and long-task
|
||||
size, not an earlier frame.
|
||||
- The approach depends on three's key internals: the bound target decides
|
||||
tone mapping and colour space, and `compile()` gathers lights with
|
||||
`traverseVisible`. A three upgrade that changes either would quietly
|
||||
double-compile. The browser spec guards this by allowing frame 0 to add at
|
||||
most 12 programs on top of the warm-up (9 post passes today).
|
||||
- Frame 0 still costs about 330 ms: 9 post-pass programs, texture uploads
|
||||
and the first full draw. Options to cut it further: precompile the post
|
||||
passes' materials on a fullscreen quad through the same warm-up, or
|
||||
combine with option B (slicing).
|
||||
- After a timeout, three's `compileAsync` keeps polling every 10 ms until
|
||||
every program reports ready. This is harmless, but the poll would throw
|
||||
once if the generation were disposed in the meantime.
|
||||
- The build waits for the parallel compile, so the first-frame watchdog
|
||||
(5 s, armed before the build) now also covers the compile. The 2.5 s cap
|
||||
keeps that bounded.
|
||||
|
||||
## Verification
|
||||
|
||||
`npx vue-tsc --noEmit` clean. `npx vitest run`: 84 files, 978 tests passed.
|
||||
`npm run build:preview`: exit 0, `"violations": []`.
|
||||
`npx playwright test tests/browser/shader-warmup.spec.ts`: 2 passed
|
||||
(desktop-chromium and mobile-chromium).
|
||||
Reference in New Issue
Block a user