/ Documentation

Zero TVM Documentation

A complete LLM inference engine in the browser — no WebLLM, no TVM, no ONNX, no WASM runtime. 10 hand-written WGSL kernel roles, a BPE tokenizer, and raw WebGPU. Measured +16.0% on total wall-clock throughput and +31.4% on decode against WebLLM's TVM-autotuned kernels, identical weights, same session (M2 Max, 2026-07-30).

WebGPU 10 kernel roles Q4F16_1 Phi-3-mini 3.8B Paged KV Cache 0 dependencies

This project implements the full Phi-3-mini-4k-instruct transformer forward pass using only WebGPU compute shaders written by hand in WGSL. Weights are loaded directly from HuggingFace in MLC Q4F16_1 format (cached in OPFS after the first load). The tokenizer is implemented in pure TypeScript — no SentencePiece WASM.

Why?

WebLLM compiles its kernels with TVM; transformers.js runs ONNX Runtime Web. Both work well. Neither is something you read — this is. This project exists to show that you can understand every single step of a modern transformer inference pipeline, written at the GPU level, in a browser tab.

Quick start

No install. Just open the chat page. Weights load from your browser cache (if you've used WebLLM before) or download fresh from HuggingFace (~2 GB).

1
Open the chat

Navigate to zero-tvm.html. WebGPU initializes automatically. Requires WebGPU with shader-f16: Chrome 113+ or Edge 113+. Safari did not enable WebGPU by default until 26, and the writable OPFS API the weight cache needs (createWritable) is missing before then. Subgroups are required for the MoE models.

2
Wait for weights

First load downloads ~2 GB. Subsequent loads are instant from OPFS. On a small machine, ?ctx= shrinks the context window and with it the KV allocation (~1.5 GB at Phi-3’s default 4K). Progress shown per shard in the log panel.

3
Chat

Phi-3-mini runs at 69.6 tok/s total (83.1 tok/s decode) on M2 Max — +16.0% / +31.4% vs WebLLM on identical weights, same session. Your conversation never leaves the browser. No API calls during inference.

4
(Optional) Cache weights locally

Run node scripts/download-weights.mjs once to save the shards to .weights-local/. Subsequent loads are served from localhost at full disk speed.

GPU requirement

Requires a GPU with shader-f16 WebGPU feature (f16 arithmetic). Most M-series Macs and recent NVIDIA/AMD GPUs support this. Intel integrated graphics may not.

How it works

The engine has three phases per generated token:

  1. Prefill — process the prompt in chunks (per token on specs that cannot chunk), building up the KV cache
  2. First decode — the last prefill step produces the first generated token
  3. Decode loop — each step takes the previous token as input, runs the full forward pass with the KV cache providing attention context, produces the next token

Each forward pass runs 10 kernel roles through 32 transformer layers — 260 dispatches per token on the default path — split-K attention has been on by default since 2026-07-27 and adds a combine dispatch per layer; ?splitk=0 runs the 228-dispatch reference chain — then reads one i32 token ID back from the GPU. For comparison, WebLLM's TVM-generated decode path fires 342 dispatches per token — around 11 distinct shaders on that path, of 85 captured across a whole session.

Architecture overview

The engine’s main source files:

FileRole
src/zero-tvm/chat.tsMain decode engine + UI. Allocates buffers, builds bind groups, runs the decode loop.
src/zero-tvm/weight-loader.tsFetches ndarray-cache.json, downloads shards, uploads to GPU buffers.
src/zero-tvm/tokenizer.tsBPE tokenizer: encode text → token IDs, decode IDs → text.
src/compiler/compiler.tsCompiles every shader — the hand-written WGSL files plus the generated int4-matmul variants (tiled/subgroup/affine/MoE) — into GPUComputePipeline objects.

Weight loader

Weights are stored in MLC's ndarray-cache.json format — an index file listing every parameter, which shard binary it lives in, its byte offset, and byte size. The loader reads this index then fetches each referenced shard.

Fetch priority

  1. OPFS — where this engine caches; a returning visitor loads from here
  2. Browser Cache API — read-only leftover from prior WebLLM sessions
  3. HuggingFace — direct HTTPS fetch from huggingface.co/mlc-ai/Phi-3-mini-4k-instruct-q4f16_1-MLC
Version mismatch pitfall

Do not mix a locally-downloaded ndarray-cache.json with shards from the browser cache. The byte offsets in the index must match the shards exactly. If you download the index fresh but use cached shards from an older model version, all weight slices will be wrong → zero logits → <unk> output.

Parameter naming (MLC format)

MLC uses non-standard parameter names. The actual names in the cache:

MLC nameRole
transformer.embd.q_weightEmbedding weights (uint32 packed int4)
transformer.embd.q_scaleEmbedding scales (f16)
transformer.norm.weightFinal RMSNorm gamma (after all layers)
lm_head.q_weightLM head weights
transformer.h.N.ln.weightLayer N input_layernorm (normGamma1)
transformer.h.N.post_attention_layernorm.weightLayer N post-attention norm (normGamma2)
transformer.h.N.mixer.qkv_proj.q_weightLayer N QKV projection weights
transformer.h.N.mixer.out_proj.q_weightLayer N output projection weights
transformer.h.N.mlp.gate_up_proj.q_weightLayer N FFN gate+up weights
transformer.h.N.mlp.down_proj.q_weightLayer N FFN down weights

Tokenizer

A hand-written BPE tokenizer in TypeScript. No SentencePiece WASM, no HuggingFace tokenizers bundle. Reads tokenizer.json directly.

Key steps

Phi-3 chat template

📄 prompt format
<|system|>
You are a helpful assistant.<|end|>
<|user|>
What is the capital of Australia?<|end|>
<|assistant|>

Stop tokens: 2 (EOS), 32000 (<|end|>), 32007 (<|endoftext|>).

KV cache

Uses a paged KV cache layout. Memory is divided into fixed-size pages (16 slots each) and a page table maps logical positions to physical pages. This is not vLLM-style relocatable blocks: K is RoPE’d before the cache write on every path, so a cached page is valid only at the positions it was written at. It is a prefix pool, and cannot become a block pool without moving RoPE.

ParameterValueNotes
PAGE_SIZE16slots per page
MAX_PAGES257≈ 4096 context tokens
Bytes per page196,60832 heads × 16 slots × 96 dims × 2 (K+V) × 2 bytes
Total KV buffer~50 MB per layer32 layers = ~1.6 GB

Each layer has its own GPUBuffer for KV pages. The page table is a simple identity mapping (page i → physical page i) for single-sequence inference.

Decode loop

Each call to decodeToken(tokenId, position) submits one command encoder with the full forward pass:

src/zero-tvm/chat.ts · decodeToken()
// Per-token GPU state written via writeBuffer
B.inputIds   ← [tokenId]          // i32
B.posMap     ← [position]         // i32
B.pageIndptr ← [0, nnzPages]      // page range
B.lengthInfo ← [position+1, 0, 0] // seq length

// Forward pass (one command encoder)
embedding(B.residual)             // token → hidden state
rmsNorm(B.hidden1, B.residual)    // initial norm

for L in 0..32:
  // QKV matmul + RoPE + KV-append in ONE dispatch (M4 fusion)
  qkvFused(B.qOut, kvPages[L], B.hidden1)
  attention(B.attnOut, B.qOut, kvPages[L])
  int4Matmul(B.hidden2, B.attnOut)               // O projection
  addNorm(B.hidden2, resIn → B.hidden1, resOut)  // residual + RMSNorm, ping-pong
  // Gate + Up + SiLU + mul + Down in ONE dispatch
  fusedFfn(B.hidden2, B.hidden1)
  addNorm(B.hidden2, resIn → B.hidden1, resOut)  // residual + RMSNorm, ping-pong

int4Matmul(B.logits, B.hidden1)   // LM head
argmax(B.tokenOut, B.logits)      // → next token ID

Ping-pong residual buffers

WebGPU's validation rules forbid binding the same buffer as both read and read_write in the same dispatch. The add_norm shader needs to read the old residual and write the new one.

Solution: two residual buffers that alternate each dispatch.

ping-pong pattern
let resIn  = B.residual   // ping (starts with embedding)
let resOut = B.residual2  // pong (uninitialized)

// Each add_norm:
dispatch(addNorm, [delta, resIn, gamma, hidden1, resOut])
[resIn, resOut] = [resOut, resIn]  // swap — O(1), no GPU copy

The swap is just two JavaScript variable reassignments — no GPU buffer copy. Both buffers always exist on the GPU; we just change which one we tell the bind group to read vs write.

WGSL Kernel Roles

All shaders live in src/compiler/shaders/, implementing 10 distinct kernel roles. The rest are tiled and subgroup variants of the same role, selectable at runtime via URL flags. The compiler compiles them all at startup into GPUComputePipeline objects.

Binding convention: @group(0) always. Binding indices are zero-based and match the order you pass buffers to bg(device, pipeline, [...bufs]).

Decode vs prefill paths diverge

The decode loop uses the fused qkv_fused kernel (QKV matmul + RoPE + KV-append all in one dispatch). Prefill still uses separate int4_matmul + rope + kv_append dispatches because prefill processes many tokens at once, and the fusion win only lands for ntoken=1.


🔤 1 · Embedding embedding.wgsl

Token ID lookup with Q4F16 dequantization. Each output element is dequantized from a packed int4 value: (nibble - 7) × scale.

BindingTypeRole
@0read_write f16[]output hidden state
@1read i32[]input token IDs
@2read f16[]scales (group_size=32)
@3read u32[]packed weights (8 int4 per u32)
@4uniform{ seq_len, packGridDimX }

Dispatch: 12 workgroups × 256 threads = 3072 output elements (D=3072)

📐 2 · RMSNorm rms_norm.wgsl

Root mean square layer normalization. Computes x / sqrt(mean(x²) + ε) × gamma. Uses 256-thread tree reduction in workgroup shared memory.

BindingTypeRole
@0read_write f16[]normalized output
@1read f16[]input
@2read f16[]gamma weights
@3uniform{ packGridDimX }

Dispatch: 1 workgroup (one token, D=3072)

⚡ 3 · QKV + RoPE + KV-append (fused, decode) qkv_fused.wgsl

The big M4 fusion. One dispatch replaces three on the decode path: the int4 QKV matmul, the RoPE rotation of Q and K, and the write of K/V into the paged KV cache. Each workgroup computes two output rows that form a RoPE pair (dim and dim+48 within the same head), rotates the pair in registers, and writes K/V straight into kv_pages — the intermediate qkv / k_out / v_out buffers from the pre-fusion path are skipped entirely.

BindingTypeRole
@0read_write f16[]q_out [3072]
@1read_write f16[]kv_pages (paged KV cache)
@2read f16[]hidden [3072]
@3read f16[]scales [9216 × 96]
@4read u32[]packed weights [9216 × 384]
@5read i32[]position map
@6uniform{ position_map_elem_offset, pages_elem_offset, packGridDimX }

Dispatch: 4,608 workgroups (down from 9,216 matmul + 36 RoPE + 12 KV-append = 9,264 in the pre-fusion path). Decode-only; prefill still uses the 3-dispatch path (see shaders 8 and 9).

👁️ 4 · Paged Attention attention.wgsl

Multi-head attention over the paged KV cache. Reads K and V from pages, computes scaled dot-product attention with an online-softmax reduction in shared memory. Each workgroup handles one attention head.

BindingTypeRole
@0read f16[]Q [3072]
@1read i32[]page indptr
@2read i32[]page values (page table)
@3read f16[]KV pages
@4read i32[]length info
@5read_write f16[]attn output [3072]
@6uniformattention config (scale, pages)

Dispatch: 1 × HEADS workgroups (1 × 32). An attention_int8.wgsl variant reads an int8-quantized KV cache; enable via ?kv8=1.

✖️ 5 · int4 Matmul (output projection + LM head) int4_matmul.wgsl

General-purpose dequantize-on-the-fly int4 × f16 matmul. Used for the attention output projection (3072 → 3072) and the LM head (3072 → 32064). Weights are Q4F16_1: N output rows × 384 u32 columns (each u32 = 8 int4 values = 32 elements, group_size=32). The tiled / subgroup / vec4 variants are emitted by one generator beside it (int4_matmul.gen.ts); the runtime picks one via the ?matmul= URL flag.

Uniform fieldValue
K_groups384 (= input_dim / 8)
scale_stride96 (= input_dim / group_size)
N3072 (o-proj) or 32064 (lm_head)

Dispatch: N workgroups — 3,072 for o-proj, 32,064 for lm_head.

🔀 6 · Fused FFN (Gate · Up · SiLU · Mul · Down) fused_ffn.wgsl

Gate and up projections (both int4 matmuls sharing the 16,384-row gate_up_proj weight matrix), the SiLU activation and the elementwise multiply, in one dispatch. The down projection back to 3,072 dims is its own int4_matmul dispatch — which is why a layer costs 7 dispatches, not 6.

BindingTypeRole
@0read_write f16[]output [3072]
@1read f16[]input [3072]
@2read f16[]gate_up scales
@3read u32[]gate_up packed weights (16,384 × 384)
@4read f16[]down_proj scales
@5read u32[]down_proj packed weights (3,072 × 1,024)
@6uniformFFN config

Dispatch: 3,072 workgroups — one per output row of the down projection. A fused_ffn_tiled_sg.wgsl variant uses subgroup reductions; selectable via URL flag.

➕ 7 · Fused Add + RMSNorm add_norm.wgsl

Residual add + RMSNorm in one pass. Computes residual_out = A + B, then output = RMSNorm(residual_out) × gamma. Used twice per layer (post-attention and post-FFN). Mirrors TVM's fuse_add_norm_decode.

BindingTypeRole
@0read f16[]A — the new contribution (O-proj or FFN-down output)
@1read f16[]B — the running residual (resIn)
@2read f16[]gamma — normalization weights
@3read_write f16[]normalized output (B.hidden1)
@4read_write f16[]new residual (resOut — ping-pong)
@5uniform{ packGridDimX }

Dispatch: 1 workgroup · 256 threads · 12 elements each = 3,072.

💾 8 · KV Append (prefill path) kv_append.wgsl

Writes K and V vectors into the paged KV cache at the correct slot for each position. On the decode path this work is folded into qkv_fused; on prefill it runs as a separate dispatch because prefill processes many tokens at once and the per-token fusion no longer pays off.

BindingTypeRole
@0read f16[]k_out [3072]
@1read f16[]v_out [3072]
@2read_write f16[]KV pages buffer
@3read i32[]position map
@4uniformpage config

Dispatch: 12 workgroups per token (HEADS=32, HEAD_DIM=96).

🌀 9 · RoPE (prefill path) rope.wgsl

Rotary position embeddings applied to Q and K. Prefill-only — the decode path folds RoPE into qkv_fused. Splits the concatenated 9,216-dim QKV buffer into Q / K / V, rotates Q and K in place based on position, and copies V unchanged.

BindingTypeRole
@0read_write f16[]q_out [3072]
@1read_write f16[]k_out [3072]
@2read_write f16[]v_out [3072]
@3read f16[]qkv input [9216]
@4read i32[]position map
@5uniformRoPE config
Critical binding order

The binding order must be [q_out, k_out, v_out, qkv, posMap, uniform]. Swapping these caused a garbage-output bug during development.

Dispatch: 36 workgroups × 256 threads = 9,216 = 3 × 3,072.

🎯 10 · Argmax Sampler argmax.wgsl

Parallel-reduction argmax over the 32,064-entry logit buffer produced by the LM-head int4_matmul. Replaces TVM's ~20-dispatch sampling chain (penalty → softmax → cumsum → argsort → gather → …) with a single dispatch. Greedy decoding only; top-k / top-p not wired up yet.

BindingTypeRole
@0read f16[]logits [32064]
@1read_write i32[]output token id [1]

Dispatch: 1 workgroup (tree reduction over 32,064 logits). An argmax_sg.wgsl subgroup variant is available.

Phi-3 model constants

src/compiler/compiler.ts
export const PHI3 = {
  D:        3072,   // hidden dimension
  HEADS:    32,     // attention heads
  HEAD_DIM: 96,     // D / HEADS
  LAYERS:   32,     // transformer layers
  FFN:      8192,   // FFN intermediate dimension
  VOCAB:    32064,  // vocabulary size
  PAGE_SIZE:16,     // KV cache slots per page
  MAX_PAGES:257,    // max pages (≈ 4096 context)
}

Q4F16 quantization format

MLC's Q4F16_1 format packs 8 int4 values into each uint32. Scales are stored as float16 with group_size=32 — one scale per 32 weights.

Dequantization formula (from embedding.wgsl)
// Extract nibble for element i within a u32
let nibble = (packed_u32 >> (i * 4)) & 0xF;

// Dequantize: center around 0, multiply by scale
let value = f16(i32(nibble) - 7) * scale;

Weight shapes in Q4F16 (for Phi-3-mini):

Parameterq_weight shape (u32)q_scale shape (f16)
Embedding[32064, 384][32064, 96]
QKV proj (per layer)[9216, 384][9216, 96]
O proj (per layer)[3072, 384][3072, 96]
Gate+Up FFN (per layer)[16384, 384][16384, 96]
Down FFN (per layer)[3072, 1024][3072, 256]
LM head[32064, 384][32064, 96]

Qwen3-4B (?model=qwen3)

The engine is parameterized over a ModelSpec, and a v1 Qwen3-4B (q4f16_1) port ships alongside Phi-3. Append ?model=qwen3 to zero-tvm.html or validate.html — it works on the live site (weights stream from HuggingFace, ~2.3 GB), and node scripts/download-weights.mjs --model qwen3 primes the local dev mirror. Phi-3 stays the default; all existing URLs keep their exact behavior.

What the port exercises that Phi-3 doesn't:

Phi-3-mini (default)Qwen3-4B
AttentionMHA, 32/32 headsGQA, 32 query heads over 8 KV heads
QK-normnoneper-head RMSNorm on Q and K between projection and RoPE
TokenizerSentencePiecebyte-level BPE (Qwen2-style tokenizer.json)
LM headseparatetied — logits reuse the quantized embedding matrix (151,936 vocab)
Decode pathfused, 7 dispatches/layerunfused QKV + fused qk_norm+RoPE+append, 8 dispatches/layer

Measured 2026-07-30 on an Apple M2 Max under the corrected protocol (same session, identical local weight bytes, both engines paying a full prefill on every run): Zero-TVM 59.85 tok/s total (TTFT 453 ms, decode 75.49) vs WebLLM 0.2.84's prebuilt Qwen3-4B at 45.46 tok/s total (self-reported decode 47.77) — +31.7% total, +58.0% decode. Unlike the Phi-3 headline, these figures are static dated text, not synced from bench/results.json; the machine-readable record is bench/results/qwen3-4b.json.

Read it cautiously — one pair, one machine, and the previous number was withdrawn

The "75.74 vs 43.75, +73%" pair published on 2026-07-29 is withdrawn. It was measured after cross-turn prefix reuse shipped (PR #24) but before the bench harness was fixed to reset it, so the Zero-TVM half prefilled a single token per run while the WebLLM half prefilled the whole prompt — not like-for-like. Full writeup at the top of BENCH.md. The engine work itself is unchanged and its Zero-TVM-vs-Zero-TVM A/Bs still stand: QK-norm must run between the QKV matmul and RoPE, which is incompatible with the fused QKV+RoPE+KV-append kernel — the QKV matmul stays a separate dispatch — but since the 2026-07-29 tuning round everything after it is fused (qk_norm_rope_append: per-head norm + RoPE + paged KV write in one pass, 8 dispatches/layer; ?fuseqk=0 restores the 10-dispatch reference chain) and the K%512 _vec4h matmul variants give d=2560 / ffn=9728 wide loads (?vec4h=0 opts out). Same-day A/Bs: fused-qk +2.3%, vec4h +5.7%, combined +5.8% over the flags-off half. The earlier 2026-07-28 pair (25.43 vs 14.15) did not reproduce on the same machine — both engines moved ~3× together (degraded session; control-run details in BENCH.md's tuning-round session note). Qwen3-4B is also the model where WebLLM most clearly beats us on time-to-first-token: its 263–271 tok/s prefill implies ~150 ms against our 453 ms.

Qwen3.5-4B hybrid (?model=qwen35)

The third model, and the first hybrid architecture on the engine: 24 gated-DeltaNet (linear-attention) layers interleaved with 8 gated full-attention layers (attention on every 4th layer). To our knowledge this is the first hand-written-kernel int4 implementation of a gated-DeltaNet hybrid running in a browser. Append ?model=qwen35 to zero-tvm.html or validate.html; node scripts/download-weights.mjs --model qwen35 primes the local dev mirror (~2.6 GB).

What the hybrid adds over Qwen3-4B:

Qwen3-4BQwen3.5-4B
Layer stack36 × attention24 × gated DeltaNet + 8 × gated attention (every 4th layer)
Sequence mixer (most layers)GQA attention + KV cachedelta-rule recurrent state (16 k-heads / 32 v-heads, head dims 128, short conv K=4) — no KV cache on those layers
Attention layersGQA 32/8, head_dim 128, full RoPEGQA 16/4, head_dim 256, partial RoPE (64 of 256 dims), sigmoid output gate per head
Vocab151,936248,320 (renumbered specials — the shipped mlc-chat-config.json still lists stale Qwen3 stop ids; stops resolve from tokenizer.json)
Weight manifestndarray-cache.jsontensor-cache.json (MLC renamed it)

Measured 2026-07-30 on an Apple M2 Max under the corrected protocol (same session, identical local weight bytes, both engines paying a full prefill on every run): Zero-TVM 65.28 tok/s total (TTFT 171 ms, decode 73.30) vs WebLLM 0.2.84's prebuilt Qwen3.5-4B at 32.56 tok/s total (self-reported decode 34.32) — +100.5% total, +113.6% decode. This is the one model where first-token latency is roughly a wash rather than a loss (our 171 ms against an implied ~0.2 s from WebLLM's 175–177 tok/s prefill). Static dated text, not synced from bench/results.json; the machine-readable record is bench/results/qwen35-4b.json.

Read it cautiously — the GDN kernels are still scalar, so decode remains a floor, and the previous number was withdrawn

The "65.67 vs 34.04, +93%" cross-check published on 2026-07-29 is withdrawn — same defect as the Qwen3 pair: measured after cross-turn prefix reuse shipped and before the bench harness reset it, so only the WebLLM half was paying prefill. The two earlier pairs (53.07 vs 32.36, +64%, from the hybrid perf round; 47.99 vs 31.99, +50%, the v1 floor) predate prefix reuse and were like-for-like — superseded, not defective. Full writeup at the top of BENCH.md. Engine caveats unchanged: the 24 DeltaNet layers run scalar (non-subgroup) kernels, so the decode number is a floor; the input projections are fused 4→1 per GDN layer and prompts prefill in chunks of ≤64 since the 2026-07-29 prefill round, but the rest of the Phi-3 fusion story has not been applied to the GDN half. One machine, one pair; full protocol and caveats in BENCH.md.

Qwen3.6-35B-A3B MoE (?model=qwen36q3 / ?model=qwen36)

The fourth model, shipped 2026-08-05, and three firsts at once: the first sparse MoE (256 experts, top-8 plus a shared expert on every layer), the first MLX-format checkpoint (affine w = s·q + b, group 64, per-tensor biases, loaded by byte range — a 5.3 GB safetensors shard is never one ArrayBuffer), and the first model here with no WebLLM build to benchmark against. ?model=qwen36q3 is the 3-bit-expert build (~16.4 GB, needs ~20 GB free RAM, ~66 tok/s on a quiet 32 GB M2 Max); ?model=qwen36 is full 4-bit (~19.5 GB, needs ~24 GB free RAM). The MoE block runs in 7 dispatches with the expert index in grid z; every layer is validated against mlx_lm's own modules (npm run test:kernels:real). Full engineering notes live in the repo: README, BENCH.md, CLAUDE.md.

Port to Phi-4-mini or Qwen3

Both are available as MLC Q4F16 packages. The Qwen3 port has since landed (see Qwen3-4B above) — these are the steps it followed, and the recipe for the next model:

  1. Update PHI3 constants in compiler.ts — D, HEADS, HEAD_DIM, LAYERS, FFN, VOCAB
  2. Check parameter names — fetch ndarray-cache.json and log all keys. Update weight-loader.ts candidates to match
  3. Check for GQA — if KV heads ≠ Q heads (grouped-query attention), the attention shader needs a small change to repeat KV heads
  4. Update chat template — each model has its own special tokens and prompt format
  5. Update HuggingFace base URL — change PHI3_MODEL_BASE in weight-loader.ts
Phi-4-mini is the easiest port

Same family as Phi-3. MLC package is already available at mlc-ai/Phi-4-mini-instruct-q4f16_1-MLC. Parameter naming is likely identical or very similar.

Local weight serving

Run the download script once to save all shards locally. Subsequent page loads are instant (served from localhost, no network).

terminal
node scripts/download-weights.mjs

# Downloads to: .weights-local/Phi-3-mini-4k-instruct-q4f16_1-MLC/
# Served at:    /local-weights/Phi-3-mini-4k-instruct-q4f16_1-MLC/
# Size:         ~2 GB
Keep index + shards in sync

Always download everything together. Never mix a freshly-downloaded ndarray-cache.json with old cached shards — the byte offsets will not match and all weights will be corrupted.

Debugging tips

All output is <unk>

Garbage / repetitive output

WebGPU validation error about aliasing

Model not loading (Weight not found)

Bugs we fixed (and how)

BugSymptomFix
Wrong MLC param names Weight not found error on load Logged all 325 param names from console, updated candidates to transformer.h.* prefix
Buffer aliasing in add_norm <unk> × 500 at 314 tok/s + WebGPU validation error Added B.residual2 (pong buffer), ping-pong with JS variable swap
Wrong rope binding order Garbage: -,unlintzegesenma\dOCĆalloqueIAL repeated Read rope.wgsl — bindings are @0=q_out @1=k_out @2=v_out @3=qkv @4=posMap
Mixed ndarray-cache.json version <unk> after downloading index locally but using old cached shards Always fetch index and shards from the same source atomically

vs WebLLM

Head-to-head on Phi-3-mini-4k-instruct Q4F16_1, same weights, same session, same browser (Chrome 150 with WebGPU), Apple M2 Max, WebLLM 0.2.80 (the Qwen pairs below ran against 0.2.84) — npm run bench, 128-token target × 5 runs, median, 2026-07-30 corrected protocol (latest run recorded in bench/results.json). Every run pays a full prefill on both sides, and both metrics are reported: total is wall-clock throughput including prefill, decode excludes it.

Zero-TVMWebLLM
Total throughput (prefill + decode)69.55 tok/s60.0 tok/s
Decode only83.10 tok/s63.23 tok/s (self-reported)
Time to first token (~35-token prompt)291 ms~150 ms (implied from 251 tok/s prefill) — WebLLM ahead
Gap-16% on total relative to WebLLM (negative = Zero-TVM ahead), i.e. +16.0% total and +31.4% decode-only. Same-session pair; cross-session absolute tok/s drifts and the old "−28…−31% stable band" was retired on 2026-07-30 (see BENCH.md).
Dispatches / token260 default (228 with ?splitk=0)342
Distinct shaders10 hand-written kernel roles~11 TVM-generated on the decode path (85 captured across a session)
Shipped JS bundle~460 kB / ~126 kB gz (zero-tvm.html)~6.0 MB / ~2.2 MB gz (@mlc-ai/web-llm 0.2.84)
Bandwidth utilization (total / decode)~36% / ~44% of the 191 tok/s ceiling~31% / ~33% of the same ceiling
Paged attention✓ Hand-written✓ TVM compiled
Readable kernels?✓ Yes — every .wgsl file is in the repo✗ No — emitted by the TVM compiler

The corrected protocol (2026-07-30) — and what it invalidated

Our half of the A/B had stopped paying prefill

bench() in src/zero-tvm/bench-console.ts looped its runs against the same prompt and never called engine.resetKVTracking() — while benchPrefill(), specSim() and validate.ts all did. That was harmless until cross-turn prefix reuse shipped on 2026-07-29 (PR #24). After it, runs 2..N of every bench found the whole prompt already absorbed and prefilled exactly one token, while the WebLLM half — a fresh chat completion per run — kept paying a full prefill inside its wall clock. The two halves were measuring different work.

Withdrawn as a result: the Qwen3-4B "75.74 / 43.75, +73.1%" and Qwen3.5-4B "65.67 / 34.04, +92.9%" pairs, both 2026-07-29. Everything earlier predates prefix reuse and was like-for-like. Fixed: bench() resets before every run, both halves split TTFT from decode, and WebLLM's own per-run decode/prefill rates are captured instead of logged once and discarded. Prior published numbers stay in BENCH.md as dated history with the defect explained in place — nothing was silently overwritten.

The advantage grows with architecture recency

Monotonic on both metrics across the three models then shipped, all measured 2026-07-30 on the same machine (Qwen3.6-35B-A3B came later and has no WebLLM baseline to pair against — see BENCH.md):

ModelArchitecture releasedΔ totalΔ decode
Phi-3-mini2024+16.0%+31.4%
Qwen3-4B2025+31.7%+58.0%
Qwen3.5-4B2026+100.5%+113.6%

The reading that fits: compiler stacks have had less time to tune newer architectures, so there is more headroom for a hand-written kernel set to take. That is an observation across three points, not a proven law — one machine, one browser, one day, no mechanism isolated, no control for how differently each model stresses the two engines — and the baseline is not constant across the rows (WebLLM 0.2.80 for Phi-3, 0.2.84 for both Qwen rows). Worth testing on a fourth model.

Where we lose: time to first token on short prompts

WebLLM reaches the first token sooner than we do

WebLLM's self-reported prefill runs at 251 tok/s on Phi-3 and 263–271 tok/s on Qwen3-4B. Against the ~35-token bench prompt that implies a TTFT of roughly 150 ms on Phi-3, where we measure 291 ms; on Qwen3-4B our 453 ms is worse still. Only Qwen3.5-4B is a wash (our 171 ms vs an implied ~0.2 s). Stated plainly: we win sustained decode decisively and lose the first-token sprint on short inputs.

It is specifically a short-prompt weakness. Chunked prefill measures 202 tok/s on an 816-token prompt (2026-07-29), and cross-turn prefix reuse removes prefill entirely on follow-up turns. What is not competitive is the fixed cost of a short, cold prefill with nothing cached to reuse. It is the top open item on BENCH.md's levers list.

How the result flipped (22% behind → +16% total / +31% decode ahead)

An earlier head-to-head (M2 Pro, 2026-06) read 42.14 vs ~51.5 tok/s — Zero-TVM 22% behind. Both the hardware and the engine changed since, so the two Zero-TVM numbers are not a pure optimization delta; the same-session, same-machine WebLLM number is the valid comparator for the current Zero-TVM median (both in the table above). What changed in the engine:

Still default since 2026-07-27 (`?splitk=0` to disable): split-K attention (?splitk=N, ~+3% at short context, needs a long-context A/B); the best measured opt-in config of that era (?vec4=1&vec4qkv=1&splitk=8, 68.36 tok/s on 2026-07-25) is now simply the default path. Falsified and not shipped: FFN prologue fusion (?fuseprologue=1, −13.7% on M2 Max). Dispatch overhead was never the gap — Zero-TVM already submitted fewer dispatches per token (260 vs 342) when it was slower. Full A/B tables in BENCH.md.

The memory-bandwidth ceiling

Phi-3-mini Q4F16_1 touches ~2.09 GB of weights per decode token (the weight-shape table above, summed: 32 layers plus the LM head; the embedding contributes one row). On M2 Max's 400 GB/s memory bus that's ~5.2 ms/token, or ~191 tok/s theoretical max. Zero-TVM's measured decode-only median sits at roughly 44% of that ceiling (36% on total, which pays prefill too); WebLLM's at roughly 33% decode-only / 31% total (exact medians in the table above). Neither engine can exceed the ceiling without changing weight layout or quantization (int8 KV shaves a bit, which is why it's exposed behind a flag).