TL;DR
Parallel investigation to #6. The MLC path produces a smaller, WebGPU-native bundle than ONNX (2.5 GB vs 3.2 GB) thanks to PR #3485's PLE table packing, and all three Gemma 4 variants produce coherent prompt-1 output in-browser after our patches. It is not the shipping path right now because of a confirmed web-llm state-leakage bug that makes multi-call usage unsafe without an engine-reload workaround. This issue documents where the MLC path stands so we can pick it up later (if ONNX hits a wall, or once upstream fixes land).
Goal
Ship the fine-tune to the browser via @mlc-ai/web-llm + WebGPU, replacing transformers.js + ONNX in client/lib/gemma/local-runtime.ts. Concretely:
- WAVE's various task calls (check-in chat, chunk generation, reflection, insights) all run cleanly with no cross-task state contamination.
- Cold-load time competitive with current ONNX path (~30s for 2.5 GB into OPFS).
- Tok/s on Apple WebGPU comparable to current ~15 tok/s via transformers.js, ideally higher (MLC's WebGPU kernels are typically 2-3× faster on modern Apple silicon).
Why we want this
- Smaller bundle: 2.5 GB (q4f16_1 with
Gemma4SplitScaledEmbedding packing the PLE tables) vs 3.2 GB ONNX (which can only int4-pack regular Gather, not the PLE Gather via MatMulNBitsQuantizer). PR #3485's quantization is Gemma-4-aware.
- Native WebGPU: MLC compiles to WebGPU shaders directly via TVM. Tok/s on Apple M-series and modern dGPUs should beat transformers.js + onnxruntime-web by a healthy margin.
- Better KV cache memory: TVM's paged KV cache is more memory-efficient than ORT's contiguous approach. Helps on lower-RAM iPhones / Macs running multiple models concurrently (we have Whisper + Gemma + Kokoro in parallel — see
client/README.md).
- Future-proof: MLC is actively adding Gemma family support; once PR #3485 lands properly, we benefit from upstream maintenance.
Background — what's already done
Build environment (paid for)
Per docs/postmortems/mlc-build.md, we source-built the full toolchain on Mac at /private/tmp/mlc-workspace/:
Setting up this build on Windows is non-trivial (LLVM, emscripten, ~half-day plus Windows-specific gotchas). If we resume MLC work on Windows, expect to redo this from scratch unless we can transfer the built .bc and .dylib artifacts.
Pipeline (works end-to-end)
Per docs/postmortems/mlc-finetune.md:
# 1. PEFT re-merge (same as ONNX path — fixes broken unsloth merge)
uv run --project models python models/merge_lora_peft.py \
--base unsloth/gemma-4-E2B-it \
--adapter Maelstrome/lora-wave-session-r32 \
--out-dir models/runs/merge-peft \
--device cpu --dtype bfloat16
# 2. Convert weights — peak RAM ~4.6 GB (streaming), output 2.5 GB
uv run --project models python -m mlc_llm convert_weight \
models/runs/merge-peft \
--quantization q4f16_1 --device metal:0 \
--output models/runs/mlc-export-v2
# 3. Generate chat config — then patch the conv_template (see below)
uv run --project models python -m mlc_llm gen_config \
models/runs/merge-peft \
--quantization q4f16_1 \
--conv-template gemma3_instruction \
--output models/runs/mlc-export-v2
# 4. CRITICAL: patch mlc-chat-config.json conv_template to use <|turn> tokens
# (see "Patches required" section below)
# 5. Compile WebGPU bundle (~13 min on Apple Silicon)
MLC_LLM_SOURCE_DIR=/private/tmp/mlc-workspace/mlc-llm-base \
uv run --project models python -m mlc_llm compile \
models/runs/mlc-export-v2/mlc-chat-config.json \
--device webgpu \
--output models/runs/mlc-export-v2/wave-r32-q4f16_1-webgpu.wasm
MLC_LLM_SOURCE_DIR (not MLC_LLM_HOME) is checked by auto_target.py:241 — without it, compile fails with Cannot find library: mlc_wasm_runtime.bc.
Patches required to ship (already applied locally)
Patch 1: mlc-chat-config.json conv_template for Gemma 4's actual tokens.
PR #3485 doesn't ship a gemma4_instruction template. gen_config requires picking one; gemma3_instruction is the closest existing but its strings don't match Gemma 4's tokenizer. Verified directly against google/gemma-4-E2B-it's official tokenizer:
| Token ID |
Gemma 3 (per gemma3_instruction) |
Gemma 4 (actual, all variants) |
| 105 |
<start_of_turn> |
<|turn> |
| 106 |
<end_of_turn> |
<turn|> |
So replace the generated conv_template block with:
"conv_template": {
"name": "gemma4_turn",
"system_template": "{system_message}",
"system_message": "",
"system_prefix_token_ids": [2],
"add_role_after_system_message": true,
"roles": {
"user": "<|turn>user",
"assistant": "<|turn>model"
},
"role_templates": {
"user": "{user_message}",
"assistant": "{assistant_message}",
"tool": "{tool_message}"
},
"messages": [],
"seps": ["<turn|>\n"],
"role_content_sep": "\n",
"role_empty_sep": "\n",
"stop_str": ["<turn|>"],
"stop_token_ids": [1, 106],
"function_string": "",
"use_function_calling": false
}
No recompile needed — mlc-chat-config.json is read at runtime by web-llm.
Patch 2 (browser only): Next.js rewrite for /resolve/main/.
web-llm constructs HF-style fetch URLs ${model}/resolve/main/${file}. Add to client/next.config.ts:
async rewrites() {
return [
{ source: "/mlc-export/resolve/main/:path*", destination: "/mlc-export/:path*" },
{ source: "/mlc-base-export/resolve/main/:path*", destination: "/mlc-base-export/:path*" },
{ source: "/mlc-google-it-export/resolve/main/:path*", destination: "/mlc-google-it-export/:path*" },
];
}
Patch 3 (dev only): hard-copy weights into client/public/, don't symlink.
Turbopack panics on symlinks that escape client/ (FileSystemPath("").join("../models/runs/...") leaves the filesystem root). Use rsync -a --exclude='resolve' <model-dir>/ client/public/<model-name>/.
Verified working (prompt-1 only, see blockers below)
The 3-way compare page loads all three Gemma 4 variants through PR #3485 + our patches:
| Model |
Prompt-1 output |
| Our fine-tune (PEFT-merged LoRA) |
"1, 2, 3, 4, 5" / "The capital of France is Paris." / coherent haikus / coherent anxiety advice |
unsloth/gemma-4-E2B-it |
Same — coherent factual + creative + therapeutic |
google/gemma-4-E2B-it |
Same — official upstream weights work |
All three at ~45 tok/s on Apple M-series WebGPU.
Blocker — web-llm state leakage
This is the only thing keeping MLC out of production. Documented exhaustively in docs/postmortems/mlc-finetune.md#6.
Symptom: sequential engine.chat.completions.create({messages: [{role: "user", content: prompt}]}) calls leak KV-cache state between calls. The first call's output is coherent; subsequent calls' outputs are contaminated by the first call's context.
| Prompt order (with prompt #N as the FIRST call after engine load) |
Output |
| #1 "Count from 1 to 5" |
1, 2, 3, 4, 5 ✓ |
| #2 "I'm feeling anxious..." (after Count) |
0 tokens emitted ✗ |
| #3 "Capital of France?" (after Anxious) |
"Please provide the sentence or question you would like me to answer." ✗ |
| #4 "Write a haiku..." (after Capital) |
degenerate wave deep wave deep loop ✗ |
When the same prompts are run with the order shuffled, whichever prompt runs first is always coherent, and the rest degrade.
Diagnosis: engine.resetChat() IS called by web-llm's prefill path when conversations don't match (verified by reading engine.js). The implementation calls pipeline.resetKVCache() which calls vm.builtin.kv_state_clear + vm.builtin.kv_state_add_sequence. On paper this is a full reset. Empirically it isn't — confirmed by inserting explicit engine.resetChat() between calls (no behavioral change). The actual bug appears to be in TVM's paged KV cache C++ runtime function.
Confirmed by existing upstream issues (don't file another):
Workaround that does fix it: full engine reload between each chat.completions.create() call (engine.unload() + CreateMLCEngine(...)). ~3-5s warm-cache overhead. Verified — all four prompts produce coherent output when wrapped in this pattern. See the diagnostic loop in client/app/training/mlc-test/compare-all/compare-all-client.tsx.
What needs to be done
Two parallel paths. Either or both, depending on timeline:
Short-term: production wiring with the engine-reload workaround
Owner: us (when we revisit). Estimated effort: 1-2 days.
- Refactor
client/lib/gemma/local-runtime.ts to swap @huggingface/transformers + @browser-ai/transformers-js for @mlc-ai/web-llm. Two integration points:
- The simple
generateChatText path (chunk gen, insights, reflection, voice-test) — direct chat.completions.create swap.
- The check-in path uses Vercel AI SDK + tool calling for the
endConversation structured tool. No web-llm equivalent exists — would need to either prompt the model to emit JSON and parse client-side, or wrap web-llm in a custom AI SDK provider. Existing parseCheckInPayload + inferEndConversationFromHistory fallback at client/lib/gemma/local-runtime.ts:545+ gives us a JSON-only path.
- Implement the hybrid engine-lifecycle pattern:
- One shared engine instance globally.
- Within a single task type's session (e.g. a multi-turn check-in chat), pass full conversation history per call → web-llm's "multiround chatting" path reuses KV cache cleanly per
engine.js:12982.
- When switching task TYPE (chunk → reflection → insights, etc.), call
engine.unload() + CreateMLCEngine() to fully reset. ~3-5s cost on task switch only.
- Push
models/runs/mlc-export-v2/ (2.5 GB) to a new HF repo (e.g. Maelstrome/wave-r32-mlc) so production fetches don't depend on a local dev server.
- Replace
client/public/mlc-export/ hard copy with the HF URL in the production AppConfig.
Long-term: upstream contributions to remove the patches
Owner: open source. Best case: someone files / lands these. Worst case: we maintain the patch stack.
- mlc-ai/mlc-llm: register a
gemma4_instruction conv_template in python/mlc_llm/conversation_template/gemma.py using <|turn>user / <|turn>model / <turn|> / [1, 106]. Either make it the default for model_type=gemma4 or document --conv-template gemma4_instruction. This would be a small, clean PR contribution worth making if we have a couple hours. Reference the same template patch we already use.
- mlc-ai/mlc-llm or mlc-ai/relax: root-cause and fix the TVM paged KV cache clear bug (the underlying state leak). Out of scope for us — likely requires C++ runtime debugging.
Things to NOT re-litigate
These are decided. See linked memory notes and postmortems.
- Don't apply
self.scaling = 1.0 / math.sqrt(local_head_dim) patch to gemma4_model.py:430. PR #3485's self.scaling = 1.0 is correct — MLC's attention op applies the sqrt internally. Patching double-applies and kills generation. Verified empirically by reverting our own (wrong) patch.
- Don't use
Maelstrome/lora-wave-session-r32-merged as the source. Broken at the safetensors level. Re-merge with PEFT via models/merge_lora_peft.py.
- Don't use
gemma3_instruction conv_template for Gemma 4. Tokens are different despite same IDs.
- Don't try MLC PR #3485 with a webgpu-only test. Both WebGPU and Metal builds are affected by the same state-leak bug — Metal isn't a workaround.
Reference material
Done criteria (if/when we ship MLC)
Decision context
User decision recorded 2026-05-13: skip MLC migration for the current ship, ship via ONNX (issue #6). MLC remains a viable alternative if:
- ONNX hits an unforeseen blocker in production
- We need the tok/s boost from native WebGPU kernels
- Upstream lands the resetChat fix and our patch stack shrinks
TL;DR
Parallel investigation to #6. The MLC path produces a smaller, WebGPU-native bundle than ONNX (2.5 GB vs 3.2 GB) thanks to PR #3485's PLE table packing, and all three Gemma 4 variants produce coherent prompt-1 output in-browser after our patches. It is not the shipping path right now because of a confirmed
web-llmstate-leakage bug that makes multi-call usage unsafe without an engine-reload workaround. This issue documents where the MLC path stands so we can pick it up later (if ONNX hits a wall, or once upstream fixes land).Goal
Ship the fine-tune to the browser via
@mlc-ai/web-llm+ WebGPU, replacing transformers.js + ONNX inclient/lib/gemma/local-runtime.ts. Concretely:Why we want this
Gemma4SplitScaledEmbeddingpacking the PLE tables) vs 3.2 GB ONNX (which can only int4-pack regular Gather, not the PLE Gather viaMatMulNBitsQuantizer). PR #3485's quantization is Gemma-4-aware.client/README.md).Background — what's already done
Build environment (paid for)
Per
docs/postmortems/mlc-build.md, we source-built the full toolchain on Mac at/private/tmp/mlc-workspace/:relax-baseat commitac9cf7aac(relax PR #346 — Gemma 4 E2B prerequisites)mlc-llm-baseat commitfa7cf711(mlc-llm PR #3485 — Gemma 4 E2B text-only support)*/build/mlc_wasm_runtime.bcatmlc-llm-base/web/dist/wasm/models/.venvvia.pthfiles. Invoke aspython -m mlc_llm <subcommand>.Setting up this build on Windows is non-trivial (LLVM, emscripten, ~half-day plus Windows-specific gotchas). If we resume MLC work on Windows, expect to redo this from scratch unless we can transfer the built
.bcand.dylibartifacts.Pipeline (works end-to-end)
Per
docs/postmortems/mlc-finetune.md:MLC_LLM_SOURCE_DIR(notMLC_LLM_HOME) is checked byauto_target.py:241— without it, compile fails withCannot find library: mlc_wasm_runtime.bc.Patches required to ship (already applied locally)
Patch 1:
mlc-chat-config.jsonconv_template for Gemma 4's actual tokens.PR #3485 doesn't ship a
gemma4_instructiontemplate.gen_configrequires picking one;gemma3_instructionis the closest existing but its strings don't match Gemma 4's tokenizer. Verified directly againstgoogle/gemma-4-E2B-it's official tokenizer:<start_of_turn><|turn><end_of_turn><turn|>So replace the generated
conv_templateblock with:No recompile needed —
mlc-chat-config.jsonis read at runtime by web-llm.Patch 2 (browser only): Next.js rewrite for
/resolve/main/.web-llm constructs HF-style fetch URLs
${model}/resolve/main/${file}. Add toclient/next.config.ts:Patch 3 (dev only): hard-copy weights into
client/public/, don't symlink.Turbopack panics on symlinks that escape
client/(FileSystemPath("").join("../models/runs/...") leaves the filesystem root). Usersync -a --exclude='resolve' <model-dir>/ client/public/<model-name>/.Verified working (prompt-1 only, see blockers below)
The 3-way compare page loads all three Gemma 4 variants through PR #3485 + our patches:
unsloth/gemma-4-E2B-itgoogle/gemma-4-E2B-itAll three at ~45 tok/s on Apple M-series WebGPU.
Blocker — web-llm state leakage
This is the only thing keeping MLC out of production. Documented exhaustively in
docs/postmortems/mlc-finetune.md#6.Symptom: sequential
engine.chat.completions.create({messages: [{role: "user", content: prompt}]})calls leak KV-cache state between calls. The first call's output is coherent; subsequent calls' outputs are contaminated by the first call's context.1, 2, 3, 4, 5✓wave deep wave deeploop ✗When the same prompts are run with the order shuffled, whichever prompt runs first is always coherent, and the rest degrade.
Diagnosis:
engine.resetChat()IS called by web-llm's prefill path when conversations don't match (verified by readingengine.js). The implementation callspipeline.resetKVCache()which callsvm.builtin.kv_state_clear+vm.builtin.kv_state_add_sequence. On paper this is a full reset. Empirically it isn't — confirmed by inserting explicitengine.resetChat()between calls (no behavioral change). The actual bug appears to be in TVM's paged KV cache C++ runtime function.Confirmed by existing upstream issues (don't file another):
resetChat()causing Mistral model to output scrambled responses. Same symptom, different model. Open since 2024, no fix.Workaround that does fix it: full engine reload between each
chat.completions.create()call (engine.unload()+CreateMLCEngine(...)). ~3-5s warm-cache overhead. Verified — all four prompts produce coherent output when wrapped in this pattern. See the diagnostic loop inclient/app/training/mlc-test/compare-all/compare-all-client.tsx.What needs to be done
Two parallel paths. Either or both, depending on timeline:
Short-term: production wiring with the engine-reload workaround
Owner: us (when we revisit). Estimated effort: 1-2 days.
client/lib/gemma/local-runtime.tsto swap@huggingface/transformers+@browser-ai/transformers-jsfor@mlc-ai/web-llm. Two integration points:generateChatTextpath (chunk gen, insights, reflection, voice-test) — directchat.completions.createswap.endConversationstructured tool. No web-llm equivalent exists — would need to either prompt the model to emit JSON and parse client-side, or wrap web-llm in a custom AI SDK provider. ExistingparseCheckInPayload+inferEndConversationFromHistoryfallback atclient/lib/gemma/local-runtime.ts:545+gives us a JSON-only path.engine.js:12982.engine.unload()+CreateMLCEngine()to fully reset. ~3-5s cost on task switch only.models/runs/mlc-export-v2/(2.5 GB) to a new HF repo (e.g.Maelstrome/wave-r32-mlc) so production fetches don't depend on a local dev server.client/public/mlc-export/hard copy with the HF URL in the productionAppConfig.Long-term: upstream contributions to remove the patches
Owner: open source. Best case: someone files / lands these. Worst case: we maintain the patch stack.
gemma4_instructionconv_template inpython/mlc_llm/conversation_template/gemma.pyusing<|turn>user/<|turn>model/<turn|>/[1, 106]. Either make it the default formodel_type=gemma4or document--conv-template gemma4_instruction. This would be a small, clean PR contribution worth making if we have a couple hours. Reference the same template patch we already use.Things to NOT re-litigate
These are decided. See linked memory notes and postmortems.
self.scaling = 1.0 / math.sqrt(local_head_dim)patch togemma4_model.py:430. PR #3485'sself.scaling = 1.0is correct — MLC's attention op applies the sqrt internally. Patching double-applies and kills generation. Verified empirically by reverting our own (wrong) patch.Maelstrome/lora-wave-session-r32-mergedas the source. Broken at the safetensors level. Re-merge with PEFT viamodels/merge_lora_peft.py.gemma3_instructionconv_template for Gemma 4. Tokens are different despite same IDs.Reference material
docs/postmortems/mlc-build.md— source build of the toolchaindocs/postmortems/mlc-finetune.md— full investigation, patches applied, state-leakage diagnosisdocs/postmortems/README.md— postmortem indexmodels/runs/mlc-export-v2/(gitignored) — the working 2.5 GB MLC bundle for our fine-tuneclient/app/training/mlc-test/compare-all/— 3-way browser comparison pageDone criteria (if/when we ship MLC)
client/lib/gemma/local-runtime.tsuses@mlc-ai/web-llmfor all task callsMaelstrome/wave-r32-mlc(or equivalent) HF repo created with weight shards + WASM + patchedmlc-chat-config.jsoncd client && pnpm exec tsc --noEmitpassescd client && pnpm dev→ load/training/voice-testor/training/onnx-test/compare→ in-browser generation produces coherent textDecision context
User decision recorded 2026-05-13: skip MLC migration for the current ship, ship via ONNX (issue #6). MLC remains a viable alternative if: