File size: 17,063 Bytes
1944112 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | # Qwen3.8-2B-Distill β MLC/WebGPU: built, and what it cost
**Status: done.** `dist/Qwen3.8-2B-q4f16_1-MLC/` loads in Firefox and passes `npm run e2e`.
Everything labelled **measured** below was verified on this machine (M4 MacBook Air, 16 GB,
Firefox 154, macOS 26.5.2) during the session that built it.
The compile itself was the easy part. The toolchain was not: **no published pair of MLC nightlies
works together**, and the fix is not a version pin. See "The toolchain does not ship working" β
read it before touching the venv, because a fresh `pip install` reintroduces every one of these.
## What was produced
| | |
| --- | --- |
| weights | 26 shards, **1.059 GB** on disk (1,089,449,808 bytes materialized) |
| params | **1,881,825,088** at **4.503 bits/param** (0.987 GB post-quantization) |
| library | `Qwen3.8-2B-q4f16_1_cs1k-webgpu.wasm`, 6,889,261 bytes, 154 kernels |
| config | q4f16_1, context 4096, prefill chunk 1024, conv template `qwen3_5` |
Reproduce with `tools/setup-mlc-toolchain.sh`, then:
```sh
python3 tools/strip-vision.py --keep-prefix # 632 -> 320 tensors, 1.88B params
.venv-mlc/bin/python -m mlc_llm gen_config model/Qwen3.8-2B-text \
--quantization q4f16_1 --conv-template qwen3_5 \
--context-window-size 4096 --prefill-chunk-size 1024 -o dist/Qwen3.8-2B-q4f16_1-MLC
.venv-mlc/bin/python -m mlc_llm convert_weight model/Qwen3.8-2B-text \
--quantization q4f16_1 --device cpu -o dist/Qwen3.8-2B-q4f16_1-MLC
. ~/emsdk/emsdk_env.sh
export TVM_HOME=~/.cache/mlc-web-build/tvm
export MLC_LLM_SOURCE_DIR="$PWD/.venv-mlc/lib/python3.12/site-packages/mlc_llm"
.venv-mlc/bin/python -m mlc_llm compile dist/Qwen3.8-2B-q4f16_1-MLC/mlc-chat-config.json \
--device webgpu -o dist/Qwen3.8-2B-q4f16_1-MLC/Qwen3.8-2B-q4f16_1_cs1k-webgpu.wasm
```
`--device cpu` on `convert_weight` is not a preference. **Measured:** `--device metal` dies in
TVM's LLVM ORC JIT with `Unexpected definitions in module TVMMod-jitted-objectbuffer` on the first
quantize kernel. The CPU path quantizes all 1.88 B params in 7.4 s, so there is nothing to miss.
## Measured performance
```
decode 16.9 tok/s over 127 tokens (projection was 14-18 β held)
prefill 47.9 tok/s, ttft 0.71 s
load ready in 51.1 s (ingest 4.8 s of that)
decode probe 664 kernels/tok = 639 forward + 25 sampling
cpu-encode 3.9 ms/tok, gpu+tick 62.7 ms/tok
16.1 flushes/tok -> 41.3 kernels per flush
batch 4 prompts, 96 tok in 9.7 s = 9.9 tok/s aggregate at pool size 1
```
`639 forward kernels Γ· 24 layers = 26.6`, i.e. the same ~27 the 0.8B showed. dlight made the same
call here β more reduction threads over more work per thread β so the previously measured **1.83x
from going 2 -> 32 iters/thread is still on the table**, and the toolchain is now up to try it.
### The cost of having no KV reuse β now measured
This was the handoff's one explicitly-unmeasured risk ("measure it before assuming it is fine for
long chats"). It is not fine for long chats:
| history | wall | tok/s |
| --- | --- | --- |
| 29 tok | 624 ms | 71 |
| 249 tok | 2,604 ms | 104 |
| 915 tok | 9,251 ms | 101 |
| 2,249 tok | 12,318 ms | 186 |
**Slope: 5.27 ms per history token (190 tok/s marginal), floor 624 ms.** Every turn re-prefills the
whole conversation, so at the 4096-token context limit a late turn pays roughly `624 ms + 5.27 ms Γ
4000 β 22 s` before its first token. Short chats are unaffected; long ones are not usable. If this
model is meant for long conversations, restoring KV reuse (see below) is no longer optional.
## The storage-buffer goal: what actually happened
The aim was a build where nothing the engine dispatches binds more than 9 storage buffers, reached
**by deleting** dead kernels. Deletion turned out not to be available.
**Measured** β the 2B's histogram is identical to the verified 0.8B's, kernel for kernel:
| storage bindings | kernels |
| --- | --- |
| 1-6 | 146 |
| 8 | 1 |
| 9 | 3 |
| **10** | **4** |
The same four at 10: `batch_prefill_paged_kv_kernel`, `batch_prefill_paged_kv_sliding_window_kernel`,
`batch_tree_attn_kernel`, `tree_attn_paged_kv_kernel`.
**There is no flag, and there cannot be one.** In
`tvm/relax/frontend/nn/llm/kv_cache.py`, `_create_tir_paged_kv_cache` registers the sliding-window,
tree-attention and paged-prefill kernels unconditionally, as a **fixed positional tuple** handed to
the C++ `PagedKVCache` constructor. The runtime indexes that tuple by position, so omitting an entry
does not delete a kernel β it misaligns every kernel after it. This is the case the original handoff
anticipated ("if MLC emits the full set unconditionally, that is acceptable"), now confirmed at the
source rather than assumed.
So the position is unchanged from the 0.8B: three of the four are dead by config, and the live one,
`batch_prefill_paged_kv_kernel`, stays unreachable only because of the engine-side guard in
`src/background/engine-worker.js` (`resetChat()` before every prefill when
`maxStorageBuffersPerShaderStage < 10`). `tools/audit-wasm.mjs` still exits 1, correctly β it judges
the file, and it cannot see a guard that lives in the engine.
**Measured, and worth stating precisely:** the e2e's multi-round A/B came back *identical*. That
proves there is no silent garbage. It does **not** prove paged prefill works β the guard means both
branches of the A/B ran through the ragged kernel. The harness's own "paged prefill is fine" wording
overstates it.
### If you want KV reuse back
Unchanged from before, and now motivated by the measured 5.27 ms/token slope. Do not re-fuse
anything: `batch_prefill_paged_kv_kernel` binds six `array<i32>` metadata buffers whose uniform block
already carries `*_elem_offset` for each, and the body already indexes as `name[expr + name_elem_offset]`.
Packing them into one buffer with six offsets is a signature change, not an algorithmic one: 10 -> 5.
Merging any two reaches 9. Grep `q_indptr` / `page_values` / `k_rope_pos_offset` in TVM β with the
toolchain now standing up, this is a tractable change rather than a research project.
## The toolchain does not ship working
Five independent breakages, none of which is a version-pin problem. `tools/setup-mlc-toolchain.sh`
applies all of them; `tools/patch-mlc-nightly.py` and `tools/resign-venv.sh` are idempotent and must
be re-run after **any** `pip install` touching these packages.
**1. Every wheel dylib has an invalid signature.** `import tvm` is SIGKILLed by the kernel
(`CODESIGNING / Invalid Page`) inside `dlopen`. The ad-hoc signatures do not match the bytes.
`codesign --force --sign -` over each recomputes them. Silent β no Python traceback, just exit 137.
**2. `apache-tvm-ffi` must be pinned to exactly `0.1.13`.** The metadata says `>=0.1.13`, which
resolves to `0.1.13.post3`, whose ABI does not match the bundled runtime: it corrupts the heap in a
static initializer and aborts. (`0.1.13` is marked yanked on PyPI. It is still the one that works.)
**3. `psutil` is missing from the wheel's dependencies** but imported by `mlc_llm.serve`.
**4. The published nightlies are mutually incompatible β this is the big one.**
`mlc-llm-nightly-cpu 0.26.dev6` is written against an *unreleased* TVM in which `PrimFunc.params`
holds `Buffer`s directly and `tirx.is_buffer_var` exists. Every published `mlc-ai` wheel
(dev61/dev203/dev246) still has `params: Array<Var>` plus a separate `buffer_map` β verified in
`include/tvm/tirx/function.h` β and none defines `is_buffer_var`. Going the other way fails too:
`mlc-llm 0.20.dev162`'s Python *does* use `buffer_map`, but its dylib needs `TVMBackendParallelLaunch`,
which dev246 no longer exports.
dev6 is kept, because it has the newest `qwen3_5` model definition and the correct `qwen3_5`
conversation template. The places where it reaches for the unreleased API are rewritten against
`buffer_map` β **not invented**: each rewrite restores what `mlc-llm 0.20.dev162` does in that same
function, kept on dev6's relax spelling (`TensorType`/`ty_args`, where dev162 said
`TensorStructInfo`/`sinfo_args`). Affects `low_batch_specialization.py` and
`lift_global_buffer_alloc.py`.
Two more source-level fixes in the same script:
- **`nn/rnn_state.py` emits ill-formed TIR.** It binds the recurrent-state gather/scatter indices to
annotated locals (`seq_id: T.int32 = seq_slot_ids[vi]`); this TVM then infers a block read region
whose bound names a buffer `seq_id` that was never declared, and the **C++ verifier** rejects it β
so silencing the TVMScript parser's `check_well_formed` only defers the failure to a later pass.
Inlining each load at its single use site is the same computation and verifies clean. Qwen3.5 is
75% GatedDeltaNet, so RNNState is on the critical path and cannot be skipped.
- **Every loader passes `mlc_param.dtype` into `numpy.astype`**, but here that is a
`tvm.ir.type.PrimType` (`T.float16`), not the `str` numpy needs. `str()` yields exactly `float16`.
**5. `--device webgpu` needs `web/dist/wasm/*.bc`, which the wheels neither ship nor can build.**
Their header tree is pruned to inconsistency (`include/tvm/` has only `runtime/`, yet
`runtime/tensor.h` includes the absent `tvm/support/io.h`). The runtime is therefore built from an
`apache/tvm` checkout pinned to **the commit the wheel records** (`tvm._version.__commit_id__`,
here `837cb9de1`) β the linked runtime must come from the same commit as the codegen that produced
the kernels. Two further wrinkles:
- **Build it outside a path containing spaces.** TVM's `web/Makefile` interpolates `TVM_ROOT` into
unquoted `-I` flags, so "Everything WebGPU" splits and clang fails on phantom directories. Hence
`~/.cache/mlc-web-build`.
- **`mlc_wasm_runtime.cc` is now only `#define`s** β its actual runtime moved into TVM's
`wasm_runtime.cc` β so it compiles standalone with no include path at all.
### And the one that only shows up at runtime
TVM's `web/emcc/wasm_runtime.cc` pulls in tvm-ffi's sources one file at a time and **misses
`custom_allocator.cc`**. `TVMFFIGetCustomAllocator` is then left undefined and becomes a wasm
*import* β and no published `@mlc-ai/web-llm` supplies it (0.2.84 is latest; there is no newer
version to upgrade to). The model compiles, ships, ingests, and then dies at instantiation:
```
FAIL: load: LinkError: import object field 'TVMFFIGetCustomAllocator' is not a Function
```
Adding that one `#include` fixes it. The check that matters is not "does it compile" but **does the
import list match a known-good build** β compare against the reference wasm:
```sh
node tools/wasm-imports.mjs dist/Qwen3.8-2B-q4f16_1-MLC/*.wasm \
~/Downloads/Qwen3.5-0.8B-q4f16_1-MLC/*.wasm
```
Pass a reference as the second argument and it exits non-zero on any import the reference does not
need. This build now asks for 10, all of them in the reference's 11 (it does not need `proc_exit`).
Anything *extra* means the runtime is newer than the JS glue, and it will fail only at load time.
TVM also links the final `.wasm` with `emcc`, which under emscripten 6.x no longer pulls in the C++
stdlib for bitcode inputs β `wasm-ld` fails on `std::cerr` and the `ostringstream` vtables. Patched
to `em++`, which is emscripten's own suggestion in the error.
## Resolved unknowns from the original handoff
| question | answer |
| --- | --- |
| keep `model.language_model.` prefix or rewrite? | **Keep it.** `qwen35_loader.py` hardcodes `hf = "model.language_model"` and maps MLC `model.X` -> `model.language_model.X`. Use `--keep-prefix`. |
| is flattening `text_config` the step most likely to fail? | **No β it is handled upstream.** `Qwen35Config.__post_init__` already unwraps both `text_config` and nested `rope_parameters`. The strip script still flattens, which is equivalent. |
| flags to exclude tree-attn / sliding-window / paged-prefill? | **None exist**, and none can β fixed positional tuple, see above. |
| cost of no KV reuse? | **5.27 ms per history token**, floor 624 ms. Measured, and it matters. |
| does MLC support this architecture? | Yes β `qwen3_5` in the registry, `mlc_llm/model/qwen35/`, hybrid `kv_state_kind: 'hybrid'` (paged KV + RNN state). |
### Two things the reference 0.8B gets wrong that this build does not
- **Stop tokens.** The reference was built with `--conv-template qwen2`, so its `stop_token_ids` are
`[151643, 151645]` β **Qwen2 ids, which do not exist in this 248320-token vocab**. It survives
only on `stop_str` text matching. This build uses `--conv-template qwen3_5`, whose template
already carries the correct `[248046, 248044]` (`<|im_end|>`, `<|endoftext|>`), and `gen_config`
independently picked the same pair out of `generation_config.json`.
- **Thinking.** `qwen3_5` opens the assistant turn with `<think>`, which is what this checkpoint
expects β the model card says every answer opens with a `<think>` block. Confirmed in the e2e
output. Use `qwen3_5_nothink` to suppress it.
## Notes on the checkpoint
`empero-ai/Qwen3.8-2B-Distill`, at `model/Qwen3.8-2B-Distill/` (gitignored). **Measured** from the
safetensors header: 632 tensors, 2.27 B params, cleanly separated by prefix β
`model.language_model` (320 / 1.88 B, keep), `model.visual` (297 / 331 M, drop), `mtp` (15 / 61 M,
drop). The model card confirms the fine-tune is text-only with vision inherited from the base.
`tools/strip-vision.py` streams byte ranges out of the source safetensors rather than loading it, so
it runs in a few MB rather than the 4.5 GB the tensors occupy β worth keeping on a 16 GB machine.
It also flattens the config and hoists `rope_theta` / `partial_rotary_factor`, and folds both EOS
ids from `generation_config.json` into `eos_token_id`.
Architecture vs the verified 0.8B: only `hidden_size` (1024 -> 2048) and `intermediate_size`
(3584 -> 6144) differ. Layers 0-2 of each group of 4 are GatedDeltaNet linear attention
(`A_log`, `dt_bias`, `conv1d`, `in_proj_{qkv,a,b,z}`); every 4th is full attention with a gated
`q_proj` of `[4096, 2048]` = 2 x 8 heads x 256 (`attn_output_gate: true`).
**Keep `q4f16_1`.** Dequantisation is ~free (41 GB/s with unpack-and-scale vs 41-46 without), so a
wider format only adds bytes, and bytes are what decode pays for.
### Sampling
The model card recommends `temperature=0.6, top_p=0.95, top_k=20`, and warns that greedy decoding is
a repetition-loop failure mode for this class. `gen_config` writes MLC's defaults (1.0 / 1.0), so
**this build overwrites two of the three** in `mlc-chat-config.json`:
| | value | effective? |
| --- | --- | --- |
| `temperature` | 0.6 | **yes** β the engine default was moved to match |
| `top_p` | 0.95 | **yes** β nothing overrides it |
| `top_k` | 20 | **no** β compiled in, but unreachable |
`gen_config` rewrites this file, so a rebuild resets both. Re-apply after it:
```sh
python3 - <<'EOF'
import json, pathlib
p = pathlib.Path("dist/Qwen3.8-2B-q4f16_1-MLC/mlc-chat-config.json")
c = json.loads(p.read_text()); c["temperature"] = 0.6; c["top_p"] = 0.95
p.write_text(json.dumps(c, indent=2) + "\n")
EOF
```
**`top_k` has nowhere to land.** The library genuinely supports it β `get_renorm_prob` takes
`top_k: Tensor([batch, 1], int32)` and the WGSL applies a real top-k cutoff beside top-p β but
`top_k` appears **zero** times in `vendor/web-llm.js`, `src/background/multistep.js` and
`src/background/engine-worker.js`. The burst sampler calls `fsampleWithTopP` with temperature and
top_p only. So it is the same shape of problem as the 10-buffer kernels: present in the file, dead
on the live path. Reaching it means wiring a `top_k` through *both* the multistep burst sampler and
the vendored WebLLM path β partial wiring would make burst and non-burst decoding sample
differently, which is worse than leaving it. The reference 0.8B is in exactly the same position.
**`temperature` needed a second change.** `src/background/background.js` sets
`temperature: payload.temperature ?? settings.temperature` on every request, so
`DEFAULT_SETTINGS.temperature` in `src/lib/model-store.js` shadows whatever the model ships β
setting `mlc-chat-config.json` alone would have done nothing. That default is now `0.6` to match.
It is a single global, not per-model, so it applies to any other model loaded in the extension.
`top_p` is never injected by `buildParams`, which is why 0.95 takes effect straight from the model
config.
## Verify
```sh
node tools/audit-wasm.mjs dist/Qwen3.8-2B-q4f16_1-MLC # exits 1: see the buffer section above
node tools/wasm-imports.mjs dist/Qwen3.8-2B-q4f16_1-MLC/*.wasm
MODEL_DIR="$PWD/dist/Qwen3.8-2B-q4f16_1-MLC" ENGINE_COUNT=1 npm run e2e
```
The ingest contract (`src/lib/ingest.js`) is satisfied: `mlc-chat-config.json`, `tensor-cache.json`
with 26 `dataPath` records all present, `tokenizer.json` both present and listed in
`tokenizer_files`, and exactly one `.wasm`. `gen_config` also emits `tensor-cache-b16.json` (same 26
shards); it is ignored by ingest and harmless.
|