Skip to content

Load external data without copying it onto the JS heap - #1747

Open
astefanutti wants to merge 3 commits into
huggingface:mainfrom
astefanutti:blob-stream-on-main
Open

Load external data without copying it onto the JS heap#1747
astefanutti wants to merge 3 commits into
huggingface:mainfrom
astefanutti:blob-stream-on-main

Conversation

@astefanutti

Copy link
Copy Markdown

Description

Loading a model's external data currently routes every byte through the JS heap. On a cache hit, loadResourceFile reads the Response into a Uint8Array and getModelDataFiles passes that buffer to onnxruntime-web, which allocates its own — so a multi-gigabyte model is resident twice at the same moment. On a cache miss the same buffer is built in order to report download progress. And getModelDataFiles starts every chunk concurrently, so the peak is the sum of the chunks, not the largest one.

For a model with eight ~2 GB chunks that is ~16 GB of Uint8Array before the runtime has allocated anything, and it fails.

A Cache Storage Response.blob() is a file reference rather than a copy, and onnxruntime-web accepts one for external data as of #29477 (merged 2026-07-14). cache.put() likewise takes a Response and streams it to disk without materialising it. Between them the bytes can go network → disk → runtime and never touch the heap.

Measured

Warm load — same model, same runtime, same session, peak RSS sampled at 200 ms, external data passed as Uint8Array vs Blob, on a 2.887 GB model:

peak RSS × the weights
before 6,056 MB 2.10×
after 4,221 MB 1.46×

The 1,835 MB saved is one full copy of the weights, so it scales with model size. (Caveats: one session rather than the two an app may load concurrently, wasm EP so the GPU's own residency is not counted, and summed RSS double-counts pages shared between processes. The ratio is the finding; the absolutes are indicative.)

Cold load — a 17 GB model (8 external-data files, largest 1.996 GB), empty cache, first attempt:

before after
Array buffer allocation failed 2 0
downloaded stalled at 16.2 / 17.0 GB 17.0 / 17.0 GB
outcome 13 GB cached, load failed, needed a retry loaded and generated

Before this, a first load of a model that size fails and only completes on a later attempt, because each file is cached as it completes so the retry has less left to buffer. Progress reporting still works throughout (1.6 → 4.2 → 6.8 → 9.2 → 11.8 → 14.3 → 16.1 → 17.0 GB).

That model has also run three full conversation suites through the new path — 84 generated answers, no failures.

Design notes

Progress is why the buffer existed, so the cold path keeps it with a pass-through TransformStream that counts bytes as they go past. It holds one chunk, not the file, and backpressure keeps it that way.

It is opt-in, threaded from the one caller that knows it is looking at external data. loadResourceFile is generic — it also serves config.json and tokenizer.json, whose callers parse the result as text — so returning a Blob unconditionally would break them. Hence the as_blob parameter rather than a change in behaviour. Node is unaffected and keeps returning a path.

If the cache refuses the write (QuotaExceededError being the expected one) it falls back to the buffered path. That has to re-fetch rather than reuse the response, whose body the failed attempt consumed; the re-fetch goes through getFile() so a gated repo keeps its Authorization header.

The onnxruntime-web bump is load-bearing, not incidental. The current pin (2026-04-16) predates #29477, so the runtime materialises any Blob handed to it — which is the peak this removes, and is also why it throws over the 2 GiB ArrayBuffer limit. The new pin is dated after that merge and is what the numbers above were measured against. The package already pins a dev build, so this is a bump rather than a new kind of dependency.

Not covered

getModelDataFiles still starts every chunk concurrently. That no longer costs heap, but it does mean N simultaneous connections; serialising them is a separate question and not addressed here.

onnxruntime PR #29477, "[web] Support Blob-backed external data for on-demand loading in JSPI
builds", merged on 2026-07-14. The pin here predates it (2026-04-16), so the runtime rejects a Blob
handed to it as external data and the change in the next commit has nothing to hand it to.

1.29.0-dev.20260811-e415ef9afd is dated after that merge and is the build the change was developed
and measured against.
Loading a model from Cache Storage currently materialises every external-data file into the JS heap
before onnxruntime-web ever sees it: `loadResourceFile` reads the `Response` into a `Uint8Array`, and
`getModelDataFiles` passes that buffer on. For a multi-gigabyte model that is a full second copy of
the weights, live at the same moment the runtime is allocating its own.

A Cache Storage `Response.blob()` is a file reference rather than a copy, and onnxruntime-web accepts
one for external data as of the runtime bumped in the previous commit. So on a cache hit the bytes
can go straight from disk to the runtime and never enter the heap at all.

Measured on a 2.887 GB model, same runtime and same session: peak resident goes from 6,056 MB to
4,221 MB. The saving is the size of the weights, so it grows with the model.

Two deliberate limits.

It only applies on a cache HIT. Reading the stream to report download progress would defeat the
purpose — the chunks would be resident twice, once as buffers and once in Blob storage — and while
several gigabytes are arriving, progress is worth more than peak memory. Every load after the first
takes the new path, which is where the peak actually matters, and is unchanged for Node, which keeps
returning a path.

And it is opt-in, threaded from the one caller that knows it is looking at external data.
`loadResourceFile` is generic — it also serves config.json and tokenizer.json, whose callers parse
the result as text — so returning a Blob unconditionally would break them.
…ing it

The previous commit made a WARM load cheap: a cached file comes back as a Blob and its bytes never
reach the JS heap. A cold one was untouched, and a cold one is the case that fails.

`getModelDataFiles` starts every external-data chunk concurrently, and each is read into a
`Uint8Array` so download progress can be reported — so a first load peaks at the SUM of the chunks
rather than the largest. Measured on a 17 GB model from an empty cache: `Array buffer allocation
failed` at 16.2 of 17.0 GB, 13 GB cached, load failed. It succeeds on a later attempt only because
each file is cached as it completes, so the retry has less left to buffer.

The body can go straight into Cache Storage instead. `cache.put` takes a `Response` and writes it to
disk without materialising it, and reading it back with `cache.match` gives the same Blob a warm load
gets — so the bytes travel network -> disk -> runtime and never sit on the heap.

Progress survives, which is the only reason the buffer existed: a pass-through `TransformStream`
counts bytes as they go past. It holds one chunk, not the file, and backpressure keeps it that way.

If the cache refuses the write — QuotaExceededError being the expected one — it falls back to the
buffered path. That has to re-fetch rather than reuse the response, whose body the failed attempt
consumed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant