From 07a38be5a95153bd13c53fbc56ea7441e661f870 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 2 Oct 2026 21:04:56 +0700 Subject: [PATCH] fix(super-res): warm the export runtime's bytes, not the runtime, and release its session on the way out Opening the export menu imported onnxruntime-web, and importing that runtime is what starts its thread pool: one Web Worker per thread, each holding a copy of the 27MB wasm build, held by the page until it dies whether or not an export is ever asked for. Those workers are the child processes a visitor finds hanging off the tab (and the tab, and the browser, goes when one of them is killed), and the memory is what a laptop has none of when the studio is closed and opened again. The menu now fetches the files by name and lets the browser's cache do the warming; the runtime is imported by the export that needs it. The threads are capped at four, and pagehide hands the session back instead of leaving the renderer to reap it with the page. --- docker/frontend/src/engine/superRes.ts | 57 ++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) 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