diff --git a/docker/frontend/src/engine/superRes.ts b/docker/frontend/src/engine/superRes.ts index d07a1b5..d62c38e 100644 --- a/docker/frontend/src/engine/superRes.ts +++ b/docker/frontend/src/engine/superRes.ts @@ -22,6 +22,10 @@ const MODEL_URL = '/models/realesr-general-x4v3.onnx'; const MODEL_F16_URL = '/models/realesr-general-x4v3-f16.onnx'; // The wasm dir, not the file: the runtime picks its own name inside it. const WASM_DIR = '/wasm/ort/'; +// What the export menu warms, named so the warming costs no runtime: the script +// the runtime hands its threads, the binary they all load, and the two copies of +// the model. See `preloadSuperRes`. +const RUNTIME_FILES = ['ort-wasm-simd-threaded.jsep.mjs', 'ort-wasm-simd-threaded.jsep.wasm']; // The model's own factor is fixed at 4; the destination is whatever the export // asked for, so each finished tile is drawn to its place in the output rather // than assembled at 4x and shrunk (which would cost the memory of both). @@ -86,8 +90,14 @@ function load(): Promise { // check is for the case the headers ever go missing: asking for more than // one thread without isolation makes the runtime throw instead of falling // back, and a slow export beats a broken one. + // + // Four, not the processor's whole width. Every thread is a Web Worker of + // its own holding a copy of that 27MB binary, and on a laptop the memory + // and the bandwidth are both spent well before the eighth one: the tile is + // 256 pixels square, and the threads that matter are here. A machine with + // fewer cores asks for fewer. ort.env.wasm.numThreads = self.crossOriginIsolated - ? Math.min(8, navigator.hardwareConcurrency || 1) + ? Math.min(4, navigator.hardwareConcurrency || 1) : 1; // A laptop with two GPUs would otherwise hand this to the one built into // the processor. The model is the export, so ask for the fast one. @@ -117,10 +127,49 @@ function load(): Promise { // Start fetching the 32MB of runtime and model before the export that needs // them, so the visitor waits for the pixels and not for the download. Called -// when the export menu opens — the only door onto an upscale — and ignored -// afterwards: every later call finds `load()` already in flight. +// when the export menu opens — the only door onto an upscale. +// +// The bytes, and only the bytes: they are fetched by name rather than imported, +// because importing the runtime is what starts its thread pool — one Web Worker +// per thread, one copy of the 27MB binary each — and a pool that then belongs to +// the page until it dies, whether or not an export is ever asked for. Opening a +// menu is not the work. What the menu is worth is the download, and the download +// is what the browser's cache keeps: the export's own `import` then pays a second +// of instantiation it cannot avoid anyway, and finds the files already on disk. export function preloadSuperRes(): void { - void load().catch(() => {}); + for (const file of [...RUNTIME_FILES.map((f) => WASM_DIR + f), MODEL_URL, MODEL_F16_URL]) { + void fetch(file).catch(() => {}); + } +} + +// The page is being put away — closed, reloaded, restored from the back/forward +// cache — and this renderer is holding the session's own buffers and a thread +// pool with it. Hand the session back rather than leaving the browser to reap it +// with the page: a machine short of memory at the moment the studio closes is a +// machine that hangs on the studio opening again, which is the one symptom this +// can answer for. +// +// The pool is the runtime's and goes with the page; the session is ours. `loaded` +// goes with it, so a page that comes back loads the runtime again instead of +// calling into a released session — the files are cached, so that is a second. +export async function releaseSuperRes(): Promise { + const pending = loaded; + loaded = null; + if (!pending) return; + try { + await (await pending).session.release(); + } catch { + // A load that failed, or a runtime too old to have `release`: nothing to + // give back, and nothing to say about it either. + } +} + +// The last moment the studio is on screen. Nothing else in this module listens +// for anything, so the listener goes with the one resource that needs it. +if (typeof addEventListener === 'function') { + addEventListener('pagehide', () => { + void releaseSuperRes(); + }); } // The photo's bytes, enlarged so its longest edge is `targetLongest`. The caller