-
The artifact did not fit the address space
The CLI's Unlimited-OCR artifact is 4,157,448,783 bytes. wasm32
addresses at most 4 GiB — 4,294,967,296 bytes — of linear
memory, total, for everything.
That leaves about 137 MB for the engine, the activations, the KV
ring and the vision scratch buffers. It is not a tight fit; it is not a fit.
A wasm-only quantization recipe: int4 on the expert bulk, int8 on
attention, lm_head and embeddings, bf16 on the vision tower and the router.
6.67 GB of bf16 source becomes a 3.00 GB artifact that the native resolver refuses to
serve as a default — this file exists for one lane only.
-
Fitting on disk is not fitting in memory
Even with the smaller artifact, peak resident memory during a page
measured 5.7 GB. Browsers do not hand you that.
Hydration widened the whole vision tower at once, kept the embedding
table in f32, and copied weight bytes into owned buffers on the way in.
Vision weights hydrate per block and stream; the embedding table
stays int8-resident and dequantizes per row on access; the hot paths borrow the staged
bytes zero-copy instead of owning copies. Peak fell to 3.6 GB, which is
why the playground gates the model to desktop.
-
One allocation cannot exceed 2 GiB
wasm32 caps a single allocation at 2 GiB. A 3.00 GB blob
has nowhere to land as one object, however much total memory is free.
The obvious workaround — chop it every 1 GiB — cuts tensors in half,
and a tensor that spans two allocations cannot be borrowed as one slice by a kernel.
The staging planner reads the artifact's own header out of the first
4 MiB of the stream, then cuts the blob into ≤1 GiB segments at tensor
boundaries. Every tensor stays contiguous inside exactly one segment, so every
kernel still sees one slice.
-
Chrome refuses a 3 GB ArrayBuffer
Buffering the download to hash it before handing it over fails
outright: Array buffer allocation failed, measured at 3.0 GB.
Verification appears to need the whole file at once — you cannot
check a SHA-256 you have not finished reading — and WebCrypto's digest API cannot be
streamed.
The weight bytes are never materialized JavaScript-side. Each fetch
chunk is fed to an incremental SHA-256, pushed into the wasm staging sink, and teed into
the Cache API in the same pass. Peak JS residency is one chunk plus a 4 MiB header
prefix. The digest is checked per part and across the whole stream; a failure
deletes the cache entry rather than hydrating unverified bytes.
-
Threads are a link-time decision
wasm threads need a shared memory and TLS exports chosen when the
module is linked, not a flag you pass at runtime.
And feature detection is not enough: SharedArrayBuffer
also exists on iOS under COOP/COEP, where growing a shared memory toward 2 GB reclaims
the tab.
Two modules ship — serial, and shared-memory with atomics — chosen by
an allow-list: cross-origin-isolated Blink only. A byte-sized blob
worker proves the CSP permits the rayon pool's spawn path before a single weight
byte downloads, the instantiated memory is checked to be genuinely shared, and the pool
is armed only after hydration, because a worker parked in Atomics.wait
blocks the memory growth that hydration is made of.