Skip to content

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

Open
astefanutti wants to merge 9 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 9 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.

@nico-martin nico-martin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @astefanutti, thank you so much for looking into this! I like the streaming direction, but the memory benefit is JSPI-only while the default runtime is still asyncify. The ORT lockfile also needs updating, and as_blob should be part of the in-flight key. Could you address those points and add focused warm/cold cache tests? I'm holding off on merging because the default path does not yet match the memory claim and concurrent callers can receive the wrong type.

"@huggingface/tokenizers": "^0.1.3",
"onnxruntime-node": "1.24.3",
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
"onnxruntime-web": "1.29.0-dev.20260811-e415ef9afd",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please regenerate pnpm-lock.yaml. The lockfile still resolves the old runtime, so frozen installs fail and validation may not exercise this load-bearing change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96281e5

return_path,
// In the browser, hand onnxruntime-web a Blob rather than a materialised buffer.
// Node keeps returning a path, which is cheaper still.
!return_path,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upstream on-demand Blob loading is JSPI-only, while Transformers.js defaults to asyncify, whose fallback fully materializes the Blob. Please select/gate a supported JSPI path or clearly separate and test the asyncify behavior from the claimed memory result.

Comment thread packages/transformers/src/utils/hub.js Outdated
* @param {PretrainedOptions} [options] An object containing optional parameters.
* @param {boolean} [return_path=false] Whether to return the path of the file instead of the file content.
* @param {import('./cache.js').CacheInterface | null} [cache] The cache instance to use.
* @param {boolean} [as_blob=false] Whether to return a `Blob` when the file is served from the cache,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer cache-hit-only: the cold path streams to cache and returns the new Blob in the same call. Please update the parameter and return documentation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in face029.

fatal = true,
options = {},
return_path = false,
as_blob = false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as_blob changes the promise result type and must be part of this key. Mixed concurrent callers currently share whichever representation was requested first.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 60e92d5.

@nico-martin nico-martin self-assigned this Aug 24, 2026
`pnpm install --frozen-lockfile` fails on Node 18, 20 and 22 — eight
seconds in, before anything is built or tested — because the ORT bump
changed `packages/transformers/package.json` and left `pnpm-lock.yaml`
pinning 1.26.0-dev.20260416-b7804b056c.

Regenerated with `pnpm install --lockfile-only`, so the change is the
resolution and nothing else.
`as_blob` changes the RESOLVED TYPE of `getModelFile`, not just how the
bytes are fetched, so two callers asking for the same file with different
values are not asking the same question.  Left out of the key, whichever
one arrives first decides for both and the other receives a `Blob` where
it expects a `Uint8Array`, or the reverse — silently, and only under
concurrency.  `return_path` is in the key already for exactly this reason.

Adds focused warm/cold tests against an in-memory cache and a stubbed
`env.fetch`, so they assert which branch ran rather than wall-clock
behaviour and touch no network:

  · cold — streams into the cache, resolves a Blob, stores exactly once
    (not a second time by the block at the end of loadResourceFile), and
    still reports progress up to the total, which is the reason the
    buffer existed;
  · warm — resolves a Blob from the cache without re-fetching, and
    reports the single completed event;
  · a cache that refuses the write falls back to a buffer and re-fetches,
    rather than failing the load;
  · `as_blob` unset still resolves a Uint8Array;
  · both orderings of a concurrent Blob/bytes pair get the type they
    asked for.

The last two fail without the key change and pass with it — checked by
reverting it.
The review is right that the claim and the default path do not match, and
the reason is that this PR has TWO savings which the comments ran
together:

  · The DOWNLOAD no longer buffers.  Streaming the body into Cache Storage
    and reading it back means the file never sits on the JS heap on its
    way in.  This happens before onnxruntime-web sees anything and holds
    whichever build is in use.  It is the half that stops a cold load of a
    model with several multi-gigabyte chunks dying in getModelDataFiles,
    where every chunk starts concurrently and the peak is their sum.
  · The SESSION may or may not copy.  Handing the runtime a Blob only
    avoids materialising it if the runtime can read it that way: the JSPI
    build can, the default asyncify build cannot.

So "the bytes never sit on the JS heap at all" was true of the download
and overstated end to end.  The comments now say which is which, and say
plainly that on the default build this buys the download and not the
session.

No behaviour change — comments only.
`pnpm build` runs `tsc --build`, and it has been failing on this branch
since the Blob was introduced — five errors, unnoticed because CI never
got past `pnpm install`.  Fixing the lockfile in the first commit would
have moved the red from install to build rather than clearing it.

  · `buffer` was annotated `Uint8Array` and now legitimately holds a Blob
    on both `as_blob` branches.  Widened rather than cast away at each
    use, so a future branch that forgets which it is fails at build.
  · `cache.match` is typed for every backend including the Node file
    cache; only a `Response` has `.blob()`, and only a `Response` reaches
    that line.  Cast instead of the blanket `any` it had.
  · `INFLIGHT_LOADS` holds Blob-resolving promises now.
  · `getCoreModelFile` never passes `as_blob`, so a Blob cannot come back;
    narrowed there rather than widening every caller of a function that
    only ever wants bytes or a path.
  · The store-at-the-end block cannot see a Blob either — a warm read is a
    cache hit, which never sets `toCacheResponse`, and a cold stream
    writes the entry itself and then clears the flag.  The narrowing says
    so, so a third branch that produced a Blob without storing it would
    land there loudly.

tsc --build is clean, the bundles build, and tests/utils is 296 passing.
@astefanutti

Copy link
Copy Markdown
Author

Thanks a lot @nico-martin for the review. I've addressed your comments as extra commits rather than a rewrite so the review stays readable:

Lockfile (96281e5). The ORT bump left pnpm-lock.yaml pinning 1.26.0-dev.20260416, so pnpm install --frozen-lockfile was dying eight seconds in on Node 18, 20 and 22. Regenerated with --lockfile-only, so the change is the resolution and nothing else.

as_blob in the in-flight key (60e92d5). Agreed, and I think the sharp version of it is that as_blob changes the resolved type, not just how the bytes are fetched — so two callers passing different values aren't asking the same question, which is why return_path is in the key already. Two of the new tests cover both orderings of a concurrent Blob/bytes pair; I confirmed they fail with the key change reverted and pass with it.

Warm/cold tests (60e92d5). Six, against an in-memory cache and a stubbed env.fetch, so they touch no network and assert which branch ran: cold streams into the cache, stores exactly once and still reports progress up to the total; warm resolves from the cache without re-fetching; a cache that refuses the write falls back to a buffer rather than failing the load; and as_blob unset still returns a Uint8Array.

The memory claim vs the default runtime (a731c00b). You're right that they don't match, and I think the PR was overstating rather than wrong — there are two savings here and my comments ran them together:

  • the download no longer buffers. That happens before onnxruntime-web sees anything and holds whichever build is in use. It's the half that stops a cold load of a model with several multi-gigabyte chunks dying in getModelDataFiles, where every chunk starts concurrently and the peak is their sum.
  • the session may still copy. Only the JSPI build reads external data from a Blob without materialising it; the default asyncify build copies it in.

So "the bytes never sit on the JS heap at all" was true of the download and overstated end to end. The comments now say which is which, and say plainly that on the default build this buys the download and not the session. No behaviour change. If you'd prefer the PR claim only the download half, I'm happy to retitle it accordingly.

One more (a04faae2). tsc --build has been failing on this branch since the Blob was introduced — five errors, invisible because CI never got past pnpm install. Fixing the lockfile on its own would have moved the red from install to build rather than clearing it. So: buffer widened to Uint8Array|Blob rather than cast away at each use, the cache.match result cast to Response instead of a blanket any, INFLIGHT_LOADS widened, and two places narrowed because a Blob genuinely cannot reach them — a warm read is a cache hit and never sets toCacheResponse, a cold stream writes the entry itself and then clears it — with that reasoning written down so a future branch which breaks the assumption fails at build.

Two review points that the last round left open.

`as_blob`'s documentation still said cache-hit-only on both
`loadResourceFile` and `getModelFile`, which stopped being true when the
cold path started streaming into the cache and returning the new Blob in
the same call.  Both now name the two paths it IS honoured on, and say
that a Uint8Array comes back otherwise — a cold file with no cache, and
the fallback when the cache refuses the write — so callers know they have
to handle both.

And the asyncify/JSPI split is now tested, not just asserted in a comment.
Whether onnxruntime-web copies the Blob at session creation depends on its
build and is not observable from this layer; what is observable, and is
the saving this file is responsible for, is that the DOWNLOAD never
materialises the body.  The new case spies on the fetched response and
asserts `arrayBuffer()` is never called on the streaming path — paired
with the buffered path, where the same spy must SEE the call, so it cannot
pass by being wired to the wrong object.
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@nico-martin nico-martin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough follow-up! LGTM!

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.

3 participants