[Web] Improve large tensor loading in wasm runtime#19771
Conversation
There was a problem hiding this comment.
Code Review
This pull request restructures TVM-FFI includes in wasm_runtime.cc to prevent static initialization crashes and updates ArrayDecodeStorage to tolerate uncompressed float32 weights under the 'f32-to-bf16' format. In runtime.ts, it introduces chunked record loading and copying (up to 128MB chunks) to handle large tensors efficiently, and adds support for kTVMFFIShape types. The review feedback suggests optimizing these chunking loops by utilizing the cached makeShapeTuple method on the Instance class rather than invoking the FFI this.ctx.makeShapeTuple repeatedly, which reduces redundant FFI round-trips.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
012380a to
9c4334d
Compare
| artifactCache: ArtifactCacheTemplate, | ||
| signal?: AbortSignal, | ||
| ) { | ||
| const maxChunkBytes = 128 * 1024 * 1024; |
There was a problem hiding this comment.
how is the number determined, would be good to have a sense of what WebGPU runtime supports, the motivation here is not as clear
There was a problem hiding this comment.
I reran the historical 1120 MiB CPU reproducer with the same artifact and host:
32 MiB pass, 36 chunks
64 MiB pass, 18 chunks
128 MiB pass, 9 chunks
256 MiB pass, 5 chunks
512 MiB fail
1024 MiB fail
CachedCallStack grows geometrically and retains its backing allocation. At
512 and 1024 MiB, that growth drives the next Wasm allocation close to 1 GiB.
The 128 MiB default stays below the largest passing value to leave headroom for
the final tensor, retained staging memory, call metadata, and other Wasm
allocations.
|
@akaashrp would be great if you can help review |
|
Please elaborate on "reorder FFI implementation includes before runtime implementation includes in the wasm single-translation-unit build, avoiding static initialization ordering issues during module startup" Since static init ordering should not impact the startup, if there is an impact we need to know details |
|
would be great to get elaboration on "load large tensor-cache records in chunks to avoid oversized JS-to-wasm decode/copy calls", is there a specific scenario that motivate this, generally, we would favor simplicity as long as such case is covered by webgpu runtime |
9c4334d to
1fea1f5
Compare
|
Thanks for the review. I updated the PR to narrow the scope and make the motivation more explicit:
Local checks still pass after the update: |
|
Thanks, is there a real usecase for the chunking? .eg. if maxStorageBufferBindingSize limit is set really to 128MB, seems that means we canot allocate tensor larger than that anyway as a result we won't have copy in such shape. And in cases where maxStorageBufferBindingSize is larger, we don't need chunking. |
1fea1f5 to
a83d3a4
Compare
|
You are right that The concrete use case is a very large tensor-cache record that is valid for the target device but fragile as one JS/Wasm staging call. The Gemma 4 E2B artifact has three records above 128 MiB:
In the local Chrome/WebGPU validation lane, the failure was deterministic on the 1120 MiB record. Instrumentation reached |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces chunked loading for large tensor-cache records to avoid massive single JS-to-wasm byte-array calls, and updates the WASM runtime to handle uncompressed float32 payloads labeled as 'f32-to-bf16'. Feedback on these changes highlights several critical issues: the custom f32-to-bf16 decoding path in C++ ignores cpu_arr->byte_offset which causes data corruption during chunked loading; the storageBytes helper in TypeScript fails for "bool" data types; a potential resource leak exists in the GPU chunked copy path if an exception occurs during view creation; and a defensive null check is needed for shapeObjPtr before loading memory.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
a83d3a4 to
f1e69f2
Compare
|
Addressed the Gemini follow-up comments in the latest commit:
Local checks still pass: |
f1e69f2 to
1d2a3f1
Compare
|
Pushed a small follow-up on the new head
Local validation passes: |
1d2a3f1 to
a2359c1
Compare
|
Pushed a small follow-up on head The smoke first exposed one more scope issue in the chunked path: Local TVM web checks pass on the pushed head:
Downstream Chrome/WebGPU smoke also passes with a WebLLM JS bundle rebuilt against the pushed TVM source:
Prompt smoke results:
Boundary note: this validates the current PR-head TypeScript/WebLLM loading path with the latest available Apache model wasm. It is not a fresh model-wasm rebuild from |
|
Hi @tqchen, just checking whether the latest head addresses your concern about the chunking motivation and runtime scope. One extra bit of context: the large tensor-cache entries here are tied to Gemma 4's documented E2B/E4B Per-Layer Embedding design, not just to an arbitrary local conversion artifact. Google's current Gemma 4 model card notes that the smaller models keep large per-layer token embedding tables while exposing a lower "effective" parameter count. In the WebLLM artifact, that per-layer embedding weight totals 1120 MiB and is currently split into large tensor-cache records, which is the concrete case this runtime staging change handles. The PR itself remains scoped to the Web runtime path, required CI is green on Please let me know if you would prefer an additional runtime test or a smaller adjustment before re-review. |
|
thanks @MakotoUwu i think main thing is to get reviews on the PR to make sure the logics are polished and readable, i don't have full bandwidth atm. On chunking, i think as long as it is scoped to tensor cache loading it seems to be OK, and we should have comments about the motivation (e.g. it is possible to allocate large tensor but sometimes webgpu runtime do not like copy with too large tensor) maybe @akaashrp can help a bit |
2673a80 to
42ed716
Compare
|
@tvm-bot rerun |
|
The remaining red I tried |
025b563 to
076f1c1
Compare
|
Hi @MakotoUwu, apologize for the delay. Will try to review this week. |
95d985b to
aef1919
Compare
|
Hi @tqchen @akaashrp, quick update: the PR is now rebased on current The latest follow-up tightens the generic Web runtime path and adds repository-local regression tests:
A clean local Emscripten 3.1.56 Wasm build, bundle, and 22 focused tests also pass. Could you please take another look when you have bandwidth? |
|
Thanks for iterating on this. I think the PR is doing too many unrelated things and should be narrowed to chunking. The parts that seem directly relevant are:
I don't think we should also change the meaning of If such a record exists, why can't it be tagged as I'm also not yet convinced we know where the original failure occurred. Seeing There is also an issue with the reported 1120 MiB tensor. TVM requests only 1 GiB for Could you provide a reproducible comparison showing, with the same artifact and environment:
Finally, 128 MiB still looks arbitrary. Could you try a few chunk sizes (perhaps 32, 64, 256, 512, 1024 MiB) and report which ones work and where failures begin (if at all)? |
aef1919 to
fc42af3
Compare
|
Thanks for the detailed review. I've pushed a narrower revision limited to the I removed the native-float32 I also added the requested same-artifact validation using this Results:
The chunk-size sweep passes at 32, 64, 128, and 256 MiB. It fails at 512 and Fresh CI is green on this head. The updated PR description contains the exact Could you please take another look when you have bandwidth? |
This splits the Web runtime portion of #19766 into a focused tensor-cache
chunking change.
What changes
cpu_arr->byte_offsetwhen BF16 data is expanded into a chunk view.ArrayBuffer.slicecopy before Wasm staging.The planner chunks only when an outer-dimension split can keep both source and
target payloads within the cap and, for WebGPU, keep every copy offset and
length 4-byte aligned. Unsupported layouts retain the existing full-record
path. This PR does not change tensor-cache format semantics or general JS/Wasm
integer and Shape marshalling.
Motivation and failure boundary
The historical Gemma 4 E2B artifact at Hugging Face revision
4e7d43f11998bac8aa25e46bc43d6a16e6d78131contains this record:
Because the dtype is
uint32, this record uses the normal raw-copy branch; itdoes not require a native-float32 fallthrough under
f32-to-bf16.On the unchunked path, the final CPU tensor first grows Wasm memory to
1174994944bytes. JavaScript then creates the record-sized call-stack buffer,but
CachedCallStack.allocThenSetArgBytesfails while allocating a secondrecord-sized block in Wasm argument space. No bytes are copied and the C++
packed function is never entered. The runtime Wasm memory has a 2 GiB maximum.
With the same artifact and environment:
apache/mainfull-record loading fails at that allocation.Number.MAX_SAFE_INTEGERdeliberately disables chunking and fails at the same allocation.
calls.
WebGPU and content validation
The current artifact
shards the historical 1120 MiB weight. The adapter advertises
4294967292bytes for both buffer and binding limits, while the requested device exposes 1
GiB for
maxBufferSizeandmaxStorageBufferBindingSize. The WebGPU check usesthe current 384 MiB shard, which fits those requested limits:
Chrome 150 on Apple Metal WebGPU loaded that tensor with 128 MiB chunks,
synchronized it, copied it back to CPU, and matched the source SHA-256:
There was no device loss or uncaptured WebGPU error. A separate 256 MiB raw CPU
record also produced identical content through full-record and chunked loading:
Chunk cap
The 1120 MiB CPU reproducer produced this diagnostic sweep:
CachedCallStackgrows geometrically and retains its backing allocation. The128 MiB cap stays below the largest passing value to leave headroom for that
growth, call metadata, the final tensor, and other Wasm allocations.
Validation
--noEmit: passgit diff --check: pass