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.
This commit is contained in:
2026-10-02 21:04:56 +07:00
parent 7a333c1be5
commit 07a38be5a9
+53 -4
View File
@@ -22,6 +22,10 @@ const MODEL_URL = '/models/realesr-general-x4v3.onnx';
const MODEL_F16_URL = '/models/realesr-general-x4v3-f16.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. // The wasm dir, not the file: the runtime picks its own name inside it.
const WASM_DIR = '/wasm/ort/'; 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 // 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 // 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). // than assembled at 4x and shrunk (which would cost the memory of both).
@@ -86,8 +90,14 @@ function load(): Promise<Loaded> {
// check is for the case the headers ever go missing: asking for more than // 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 // one thread without isolation makes the runtime throw instead of falling
// back, and a slow export beats a broken one. // 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 ort.env.wasm.numThreads = self.crossOriginIsolated
? Math.min(8, navigator.hardwareConcurrency || 1) ? Math.min(4, navigator.hardwareConcurrency || 1)
: 1; : 1;
// A laptop with two GPUs would otherwise hand this to the one built into // 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. // the processor. The model is the export, so ask for the fast one.
@@ -117,10 +127,49 @@ function load(): Promise<Loaded> {
// Start fetching the 32MB of runtime and model before the export that needs // 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 // 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 // when the export menu opens — the only door onto an upscale.
// afterwards: every later call finds `load()` already in flight. //
// 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 { 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<void> {
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 // The photo's bytes, enlarged so its longest edge is `targetLongest`. The caller