Date: 2026-04-01 Author: Ahmet Baris Gunaydin Hardware: Apple M2 Pro, macOS, Chrome WebGPU Model: Phi-3-mini-4k-instruct (3.6B params, Q4 quantized) Runtime: WebLLM (@mlc-ai/web-llm) backed by Apache TVM
We demonstrate the first runtime interception and replacement of TVM-compiled WebGPU compute shaders inside a production browser LLM (WebLLM). By monkey-patching the WebGPU API at the createShaderModule, createComputePipeline, and dispatchWorkgroups levels, we capture all 85 WGSL shader modules, map them to the model's execution graph, and successfully replace 3 core matmul shaders with tiled variants — producing numerically correct, coherent text output.
Each decode token requires exactly 342 GPU dispatches:
| Component | Dispatches | Shader | Entry Point |
|---|---|---|---|
| Embedding lookup | 1 | [77] | fused_dequantize_take1_kernel |
| Initial RMSNorm | 1 | [66] | rms_norm2_kernel |
| Per layer (×32): | |||
| QKV projection | 1 | [6] | fused_dequantize1_NT_matmul10_kernel |
| RoPE | 1 | [40] | fused_rope_kernel |
| KV cache append | 1 | [26] | tir_kv_cache_transpose_append_kernel |
| Paged attention | 1 | [29] | batch_decode_paged_kv_kernel |
| Output projection | 1 | [46] | fused_dequantize2_NT_matmul11_kernel |
| Residual + RMSNorm | 1 | [22] | fuse_add_norm_decode_kernel |
| FFN gate+up | 1 | [47] | fused_dequantize3_NT_matmul12_kernel |
| SiLU activation | 1 | [19] | fused_split2_silu2_multiply2_kernel |
| FFN down | 1 | [27] | fused_dequantize4_NT_matmul13_kernel |
| Residual + RMSNorm | 1 | [22] | fuse_add_norm_decode_kernel |
| LM head + sampling | 20 | various | argsort, cumsum, sample |
| Total | 342 |
Additionally, 357 writeBuffer calls per token update uniforms between every dispatch. This means a writeBuffer precedes every single dispatch — there are zero consecutive dispatches that could be batched without modifying the shader pipeline.
TVM is not naive — it already fuses across elementwise boundaries:
fused_dequantize + NT_matmul— int4 dequantization fused into matmulfuse_add_norm_decode— residual addition fused with RMSNormfused_split_silu_multiply— gate split + SiLU + elementwise multiply
It does NOT fuse across matmul boundaries, which is where cross-operation fusion would need to happen.
We attempted two submit-level optimizations:
Attempt 1: setTimeout batching — Accumulate command buffers, flush on next event loop tick.
- Result: 2x decode speedup (47→99 tok/s) but corrupted output. WebLLM's
mapAsyncread-backs executed before the deferred submit.
Attempt 2: Flush-on-read coalescing — Accumulate command buffers, flush synchronously before mapAsync/writeBuffer/onSubmittedWorkDone.
- Result: 1.003x reduction (essentially 1:1). TVM calls
writeBufferbetween every submit, so there is nothing to coalesce.
Conclusion: Submit-level optimization cannot work because TVM writes unique uniforms before every dispatch.
We successfully:
- Captured all 85 WGSL shader modules (13,042 lines total) via
createShaderModuleinterception - Mapped all 85 compute pipelines to their shader modules via
createComputePipeline/createComputePipelineAsyncinterception - Detected int4 matmul shaders by structural pattern matching (workgroup reduction buffer, dequant formula, weight stride)
- Generated tiled replacement shaders (4 rows per workgroup, shared memory input caching)
- Replaced 3 decode-path matmul shaders at runtime
- Adjusted dispatch workgroup counts via
dispatchWorkgroupsinterception - Produced coherent text output with the replaced shaders — responses are valid English but terminate prematurely (~12-16 tokens vs ~50-70 baseline), indicating small numerical drift from f16 accumulation order differences that compounds across 32 layers and shifts the sampling distribution toward early EOS
The tiled matmul (shared memory input caching) showed no speedup on M2 Pro (10-22 tok/s vs 27-48 tok/s baseline — actually slower due to shared memory loading overhead).
Root cause: Apple M2's unified memory architecture has a large L2 cache that automatically caches the 6KB input vector across workgroups. The redundant global memory reads that tiling eliminates are served from L2 cache at near-shared-memory speeds. The shared memory loading adds overhead for zero bandwidth benefit.
This optimization would likely help on discrete GPUs (NVIDIA/AMD) where:
- L2 cache is smaller relative to working set
- Global memory has higher latency (GDDR6/HBM vs unified)
- Shared memory provides a meaningful latency advantage
Browser (Chrome)
↓
navigator.gpu.requestAdapter() ← intercepted
↓
adapter.requestDevice() ← intercepted
↓
GPUDevice ← monkey-patched:
├── createShaderModule() → capture WGSL source, optionally replace
├── createComputePipeline() → map pipeline to shader, tag if replaced
├── createComputePipelineAsync() → same
├── createCommandEncoder() → patch beginComputePass
│ └── beginComputePass() → patch setPipeline + dispatchWorkgroups
│ ├── setPipeline() → detect replaced pipeline
│ └── dispatchWorkgroups() → adjust workgroup count if replaced
├── queue.submit() → count submits
├── queue.writeBuffer() → count writes, track per-dispatch
└── createBuffer() → intercept mapAsync for token boundary detection
TVM's int4 matmul (117 lines WGSL):
- Workgroup size: 64 threads
- Output: 1 element per workgroup
- Strategy: Each thread loads 1 packed u32 (8 int4 weights), performs 8 fma ops, repeats 6 times (6 × 64 × 8 = 3072 dot product). Workgroup reduction via 6-stage barrier tree (64→32→16→8→4→2→1).
- Dequant:
(f16(nibble) - 7.0) * group_scale, group size = 32 elements - Bindings: output (f16 rw), input (f16 r), scales (f16 r), weights (u32 r), uniforms
Our tiled replacement (92 lines WGSL):
- Workgroup size: 256 threads (4 rows × 64 threads/row)
- Output: 4 elements per workgroup
- Strategy: Cooperative shared memory load of input vector (3072 f16), then per-row dot product with same reduction.
- Same bindings, same dequant, near-identical results. Small numerical differences from f16 accumulation order (shared memory round-trip + different loop structure) compound across 32 layers, causing premature EOS (~12-16 tokens vs ~50-70 baseline). The output is coherent English but not bit-identical to TVM's original.
| File | Purpose |
|---|---|
src/profiler.ts |
GPU dispatch profiler + shader/pipeline capture |
src/tiled-matmul.ts |
Tiled int4 matmul WGSL generator + detection |
src/interceptor.ts |
Runtime shader replacement via WebGPU monkey-patching |
src/shader-dump.ts |
WGSL source extraction utilities |
src/engine.ts |
WebLLM wrapper |
src/main.ts |
App orchestration, ?fuse=1 enables interception |
Attempted to record one decode token's dispatch tape (pipelines, bind groups, workgroups, copies), freeze it with stable buffers, and replay on subsequent tokens while no-oping TVM's GPU calls.
Phase 1 (Tape Freezing): Successfully captured 343 ops (342 dispatches + 1 copy), created stable buffer copies, rebuilt bind groups with stable refs, identified 6 patch slots from write diff. ✓
Phase 2 (Replay): Failed. Two fundamental issues:
- TVM's WASM write sequence is non-deterministic during replay — buffer allocation order changes when processing fake GPU results, so write-by-index patching targets wrong buffers
- TVM's WASM grinds to 0.1 tok/s when receiving fake GPU operations — internal error/timeout paths activate
Root cause: Replay requires replacing TVM's runtime loop entirely (our own JS token loop: GPU→detokenize→re-encode→submit), not just no-oping GPU calls. TVM's WASM maintains complex internal state that diverges immediately when GPU results don't match expectations.
The batching experiment (Phase 6) is the most conclusive result: 337x fewer submits, correct output, 30% slower. This proves:
- Chrome's GPU driver on Apple M2 already pipelines CPU→GPU work optimally
- There is no scheduling overhead to eliminate — the driver hides it through pipelining
- The only way to go faster is to reduce the actual compute/memory work (smaller weights, fewer layers, speculative decoding)
WebLLM at ~26-48 tok/s on M2 Pro is operating at ~50-85% of the theoretical memory bandwidth limit (1.8GB weights / 200GB/s = 9ms = 111 tok/s theoretical max).
Built a standalone decode loop that drives TVM's compiled GPU shaders directly, with zero TVM WASM in the token generation path.
Architecture:
- Let WebLLM/TVM load model normally (prefill + first decode token)
- Capture one decode token's full GPU state: 342 dispatches, 357 writeBuffer calls, all bind group layouts/entries
- Build stable buffer copies and reconstructed bind groups
- Own token loop: write uniforms → 342 dispatches in pipelined batches → read token via mapAsync → repeat
Results:
- 35-55 tok/s own loop vs ~27 tok/s baseline = 1.3-2x speedup
- First 6 tokens match baseline perfectly
- GPU determinism test confirms: baseline diverges from itself at token 7 (f16 parallel reduction non-determinism). Our own loop diverges at token 6 — within 1 token of the hardware limit.
- Our decode loop is as correct as the GPU hardware allows.
- Clean EOS detection (tokens 2, 32000, 32007)
- Zero TVM WASM calls during generation
What this proves:
- TVM's WASM runtime overhead accounts for ~30-50% of per-token latency
- The GPU compute itself (342 dispatches at ~9ms) is only half the story
- Eliminating the JS↔WASM↔GPU bridge yields a real, measurable speedup
- GPU f16 non-determinism makes bit-exact replay impossible beyond ~6-7 tokens regardless of approach
Token-0 divergence: Output differs from TVM baseline at token 0 (4685 vs 4955). Root cause: batched encoder submission (32 compute passes per encoder) changes f16 parallel reduction ordering vs TVM's 1:1 encoder:submit. The output is coherent English — valid but different sampling path. Same class as baseline-vs-baseline non-determinism (measured at index 7-156 depending on prompt).
Key insight — the whiteboard problem: TVM writes uniform data to buffers immediately before each dispatch, then TVM's next token overwrites those buffers. Our approach: snapshot all 357 writes during capture, replay snapshot data to original buffers before each token, then batch 342 dispatches. This preserves correct uniform values per dispatch.
Architecture: Uses TVM's original bind groups (captured during recording, kept alive via destroy prevention). Writes only to original buffers — no copies. Patches token_id (write[0]), position (writes[4,11,12]), and nnz_pages at page boundaries.
Current limitation: Page table management needed for responses longer than ~16 tokens past the recording position. The nnz_pages uniform in the 56B attention struct needs incrementing at page boundaries.
Files:
src/own-loop.ts— The decode engine: capture, build, generatesrc/own-loop-test.ts— Automated test comparing own loop vs baselineown-loop-test.html— Browser test page
Potential optimization paths for Apple silicon:
- Vectorized loads — Use
vec4<f16>for weight/input access (4x fewer load instructions) - Wider workgroups — 128+ threads per row to reduce instruction count via ILP
- Fused RoPE+KV — Combine RoPE and KV cache append (saves 32 dispatches/token)
- Fused matmul+activation — Combine FFN gate+up matmul with SiLU (saves 32 dispatches/token)
- Sampling optimization — Replace 20-dispatch hierarchical argsort with single top-k shader
cd webgpu-fusion-webllm
npm install
npx vite --port 5180
# Baseline (no interception):
# Open http://localhost:5180/
# Fusion mode (shader replacement):
# Open http://localhost:5180/?fuse=1
# DevTools console:
# __dumpShader(6) — view QKV matmul WGSL
# __dumpLayerShaders() — view all 11 decode shaders
# __capturedShaders — full shader/pipeline dataDate: 2026-04-20
Where the sections above interrogated TVM's compiled runtime from the outside, Zero-TVM answers a narrower question from the other direction:
For one specific model (Phi-3 Mini, Q4F16_1) on one specific runtime (the browser, via WebGPU), how much of the compiler / runtime stack is actually load-bearing?
The answer this repo ships: ten hand-written WGSL kernel roles (src/compiler/shaders/), ~14 kB of TypeScript runtime (src/compiler/runtime.ts + src/zero-tvm/), and no ML framework runtime in the hot path. Decode uses 228 dispatches/token on this repo's forward pass, versus the 342 dispatches/token measured from TVM-WebLLM running the same Q4 weights.
In February 2026, Hugging Face shipped Transformers.js v4 — a C++ WebGPU runtime co-developed with Microsoft's ONNX Runtime team — as the production answer for browser LLM inference. It covers ~200 architectures, ships a substantial JS + native bundle, and is the right choice when you want "run any model in the browser."
In April 2026, this repo takes the opposite bet: for Phi-3 Mini specifically, the correct implementation is 10 WGSL files and a few hundred lines of TypeScript. The argument is the llm.c / nanoGPT argument applied to the browser: most of the stack isn't load-bearing for one model.
Maczan, J. (2026). Characterizing WebGPU Dispatch Overhead for LLM Inference Across Four GPU Vendors, Three Backends, and Three Browsers. arXiv:2604.02344.
Key measurements from that paper:
- Vulkan: 24–36 µs per dispatch
- Metal: 32–71 µs per dispatch
- ~95 µs total per-operation overhead once CPU/JS plumbing is included
- Naive microbenchmarks that dispatch one op in a tight loop overestimate peak throughput by ~20× because they measure a warm cache / pipelined case that never occurs in a real forward pass
Maczan measured the cost. Zero-TVM is one demonstration of the corresponding remedy: fewer, fatter kernels.
Worked back-of-envelope for this repo on Metal: (342 − 228) × 32 µs ≈ 3.6 ms saved per token from kernel-count alone. The decode path fuses QKV projection, RoPE, and KV-cache append into a single shader (qkv_fused.wgsl), so each layer costs 7 dispatches instead of 9.
The per-decode dispatch table for this repo (src/zero-tvm/chat.ts):
| Component | Dispatches | Shader file |
|---|---|---|
| Embedding | 1 | embedding.wgsl |
| Initial RMSNorm | 1 | rms_norm.wgsl |
| Per layer (×32): | ||
| Fused QKV proj + RoPE + KV append | 1 | qkv_fused.wgsl |
| Paged attention | 1 | attention.wgsl |
| O projection | 1 | int4_matmul (generated) |
| AddNorm #1 | 1 | add_norm.wgsl |
| Fused FFN (gate+up+SiLU) | 1 | fused_ffn.wgsl |
| FFN down | 1 | int4_matmul (generated) |
| AddNorm #2 | 1 | add_norm.wgsl |
| Final RMSNorm | 1 | rms_norm.wgsl |
| LM head | 1 | int4_matmul_f32 (generated) |
| Argmax | 1 | argmax.wgsl |
| Total | 228 |
Eleven unique kernels (int4_matmul and int4_matmul_f32 are two variants of the same generated kernel shape — see src/compiler/shaders/int4_matmul.gen.ts — one f16-out, one f32-out for stable argmax; qkv_fused is the decode-path fusion; rope.wgsl and kv_append.wgsl are retained for prefill and parity testing).
| Zero-TVM (this repo) | TVM / WebLLM | Transformers.js v4 + ORT-WebGPU | |
|---|---|---|---|
| Unique WGSL kernels | 11 | 85 captured; ~11 active on decode path | ONNX Runtime-generated (not user-visible) |
| Total hand-written WGSL LOC | ~966 | 0 (compiler-generated, 13,042 lines captured) | 0 (runtime-generated) |
| Dispatches / decode token | 228 | 342 (measured) | not measured here |
| Runtime language | TypeScript | Apache TVM WASM runtime | C++ via ONNX Runtime WebGPU EP |
| Approx. runtime footprint (non-weight) | ~14 kB TS | ~50 MB WASM | ~10 MB JS + native runtime |
| Model scope | Phi-3 Mini (Q4F16_1) only | ~20 models via MLC compilation | ~200 architectures |
| Designed for | "readable in one sitting" | general browser LLM production | general browser LLM production |
Transformers.js v4 is the right choice for most production work. Zero-TVM is not trying to replace it. The point of the contrast is that when a stack can assume a single model/precision, most of the compiler and runtime surface is optional.
Speed (2026-07-30 same-session head-to-head): Zero-TVM measures 69.55 tok/s total wall-clock (prefill + decode) and 83.10 tok/s decode-only on an Apple M2 Max; WebLLM on the same machine, same session, same weights measures 59.95 tok/s total and 63.23 tok/s decode (via webllm-bench.html / npm run bench, recorded in BENCH.md). Zero-TVM is +16.0% on total and +31.4% on decode — one machine, one model, one browser, short-context, one pair. WebLLM has the better time-to-first-token on short prompts (~150 ms implied vs our 291 ms); that negative is published in BENCH.md, not buried. An earlier M2 Pro measurement with a since-fixed engine read ~22% behind, and a 2026-07-30 harness fix withdrew two 2026-07-29 Qwen pairs that were not like-for-like; both histories are preserved in BENCH.md.
- Not a compiler. Nothing here emits WGSL. The ten kernel roles are human-written.
- Not cross-architecture. Hard-coded to Phi-3 Mini with Q4F16_1 weights. No Llama / Qwen / Gemma path without shader work.
- Not cross-runtime. Browser WebGPU only. No Node / Bun / Deno target.
- Not a production replacement for Transformers.js v4. It does fewer things, on purpose.
- Not currently faster than WebLLM on Apple silicon. That's the subject of Milestones 2–5.
It IS a minimal-surface-area demonstration that for one fixed model shape, the compiler/runtime complexity is not required to do browser LLM inference end-to-end.
An opt-in int8 KV cache halves KV memory. As of 2026-08-17/18 it runs on the unfused path, on hybrid (GDN) specs and through chunked prefill — it was gated to the fused QKV composition before that, which meant Phi-3 alone and so no model where context is the constraint. MLA is the one exclusion: it caches a latent rather than per-head K/V.
Shaders: kv_quantize_int8.wgsl
(quantise on append, one f16 scale per (page, slot, head, K|V)),
attention_int8.wgsl (decode) and
attention_prefill_int8.wgsl
(chunked prefill); the fused path additionally uses
qkv_fused_scratch.wgsl.
Enable with ?kv8=1 on the chat page, or int8KV via the library. Cost is one
extra dispatch per layer and ~5-8% of prefill throughput; memory is 1.98x
smaller.
VALIDATED, which the earlier version of this section said it was not: paired perplexity against an f16 cache on Qwen3.6-35B-A3B measured -0.09% at 1k windows and +0.10% at 4k, both within noise; greedy output is token-identical to f16 on llama32 (5/6 prompts, sixth differs by a trailing period), qwen35 (6/6) and qwen36q3 (5/6, sixth a correct paraphrase). See docs/TURBOQUANT_PLAN.md for the measurements and for why 4-bit was rejected.
Motivation. Decode is memory-bandwidth bound: each forward reads ~1.86 GB of weights. Two user-visible problems follow from the current one-token-at-a-time engine:
- Prefill is slow. A 200-token prompt fires 200 sequential forwards = 372 GB of weight reads. At ~80 GB/s effective bandwidth on M1 that's ~4.7 s before the first generated token appears, despite each forward being well pipelined by the GPU command chain.
- Speculative decoding can't help. Prompt-lookup spec decoding wants to verify N candidates in parallel, but sequential N-token forwards pay N × weight bytes — with typical accept rates (~1.5–2) that's slower than plain decode on this hardware. Spec decoding only wins if the N forwards share a single weight read — i.e. M-dim batching at the kernel level.
Proposal. Generalise every activation buffer and every compute shader
from [D] / [1, K] × [K, N] to [M, D] / [M, K] × [K, N], where M is
the batch dim in the query axis. Weights stay [K, N] (or packed int4 of
same shape) — they are read once per tile and reused across all M rows.
Shader changes (keeping current f16-KV path; int8-KV can follow):
| shader | change |
|---|---|
embedding.wgsl |
lookup M token ids, write [M, D] |
rms_norm.wgsl |
per-row norm across M rows |
qkv_fused*.wgsl |
matmul yields [M, QKV_DIM]; RoPE reads per-row position; writes M new slots to KV pages |
attention.wgsl / _sg |
each of M queries attends to its own history slice; naturally a grid of (M, HEADS) workgroups |
int4_matmul* (generated) |
GEMM [M, K] × [K, N] instead of GEMV — the key shape change. Each output WG tiles both M and N rows |
fused_ffn*.wgsl |
gate/up GEMM [M, D] × [D, FFN]; per-row SiLU and elementwise multiply |
add_norm.wgsl |
per-row residual add + norm across M rows |
argmax*.wgsl |
per-row argmax → M token ids |
| LM head matmul | already uses the same int4 GEMV kernel — gets M-dim automatically when the matmul is generalised |
Engine changes (src/zero-tvm/chat.ts):
- Activation buffer size →
PIPELINE_DEPTH × M × D × 2bytes (dynamic M up to someM_MAX, e.g. 8 for prefill chunks + spec decode). submitSteptakestokenIds: Int32Arrayandpositions: Int32Arrayof length M; writes both to their respective uniforms.- Prefill path: chunk the prompt into groups of
M_MAX, submit one batched forward per chunk, only read back the last argmax of the last chunk. - Decode path: still M=1 by default, plus an opt-in spec-decode mode that runs M=4 or M=5 with candidates from a prompt-lookup lookback.
Expected wins (on M1, against the current 42 tok/s / ~4 s prefill):
- Prefill for a 200-token prompt: ~4.7 s → ~0.3 s (batched = 1 weight read instead of 200; activation work scales linearly but is tiny vs weights).
- Decode with working spec decoding + 2× accept rate: ~42 → ~70 tok/s.
- Decode with 3× accept rate (repetitive prompts): ~42 → ~95 tok/s.
Risk. This is a ~10-shader rewrite with new bind layouts and a new engine path. Correctness risk is non-trivial — attention masking per-row is the subtlest piece. Landing strategy: gate the whole batched path behind a URL flag, keep the current M=1 engine untouched, ship one shader at a time, A/B test each with the existing tooling.
qwatts-dev/bitnet.js pursues a very similar philosophy — no TVM, no WASM runtime, hand-written WGSL — but targets BitNet b1.58 ternary weights rather than standard int4. Different architectural bet (ternary vs int4), same llm.c-style argument about surface area. Worth knowing about when evaluating whether "hand-written WGSL for one model family" is a niche or a pattern.