Read README.md first for what this is. This file is the context that is NOT obvious
from the code: ground-truth sources, hard-won gotchas, workflows. When picking up a
TODO.md item, read this whole file first — items acquire traps and the traps get
documented here.
Doc system: README.md is getting-started + the practical surface; docs/decisions.md
is the WHY, by topic — every deliberate choice, default, policy, and refuted direction,
with its evidence; docs/log.md is the chronological narrative both point into;
docs/parity.md is the verification runbook; TODO.md is the forward ledger.
- Design target: maximum tok/s for Qwen3.6-27B, Qwen3.6-35B-A3B and Qwen3.8-27B GGUF on this one machine (M5 Max, Metal). Batch 1. No portability hedging. (3.8-27B runs the 3.6-27B graph unchanged — it is a registry entry, not a port.)
- TODO.md is the deferred-work ledger. Scope is never silently dropped: it ships, or it becomes a ledger item with context. Ledger items are never deleted, only annotated.
- Every shipped arc updates the docs before it's done: dated log.md entry, README if the surface changed, decisions.md if a decision was made/changed/refuted. A TODO.md update alone is not sufficient.
- The reference implementations (
ReferenceExperts, and the DeltaNet reference once it lands) are frozen correctness oracles. Never "optimize" them. - Any change touching model math re-runs the parity gate (docs/parity.md) before it
ships. The harness is live:
bun scripts/parity-gate.tsfor the 35B,--model-size 27bfor the dense file.
- llama.cpp master
src/models/qwen35.cpp,src/models/qwen35moe.cpp, andsrc/models/delta-net-base.cpp(per-arch graphs moved out of llama-model.cpp) — the executable reference for both archs, including the delta-rule recurrence in recurrent and chunked (chunk=64) forms. - The GGUF metadata and tensor table of the blessed ggml-org files themselves.
- HF transformers
modular_qwen3_5.py/modular_qwen3_5_moe.py(thin shims overmodular_qwen3_next.py, which holds the real math) — for intent, NOT for tensor layout: the GGUF has conversion-baked deltas (next section). - HF
config.jsonof Qwen/Qwen3.6-* — last resort; its text params nest undertext_configand its singleeos_token_idis wrong for chat.
Shared by both models: head_dim 256; QK-RMSNorm over [256] per head (full-attention
layers only), applied before rope; partial NEoX rope over the first 64 dims, theta 1e7,
dims 64..255 unrotated (GGUF says IMROPE sections [11,11,10,0], which for text-only is
provably identical to NEoX over n_rot=64 — implement plain NEoX, still validate the
sections key); no biases anywhere except ssm_dt.bias-the-tensor (which is a
projection bias in name only — it's the dt offset vector); RMSNorm eps 1e-6; vocab
248320 (padded; real tokens end at 248076); untied embeddings, real output.weight
(Q6_K in Q4_K_M files); full attention at layer indices 3,7,11,… ((i+1) % 4 == 0),
gated DeltaNet everywhere else. Layer skeleton: x + attn(norm(x)), then
h + ffn(post_attention_norm(h)) — post_attention_norm is the PRE-MLP norm; there is
no ffn_norm tensor.
Full-attention layer: attn_q is double-width (per-head interleaved [q_h(256), gate_h(256)] × n_head); split q/gate by strided view, QK-norm on q AND k, rope q/k,
sdpa scale 1/√256, then out *= sigmoid(gate) BEFORE o_proj (attn_output).
Gated DeltaNet layer (all math per llama.cpp delta-net-base.cpp; state fp32):
attn_qkv → conv1d (depthwise, kernel 4, causal, NO bias, over the full fused width)
→ silu over the WHOLE fused stream (so q and k are silu'd before their L2 norm, not
just v) → split q [128×16H] / k [128×16H] / v [128×H_v] → L2-norm q,k in ggml's
clamp form x / max(‖x‖, eps) with eps = rms_norm_eps (ggml-cpu ops.cpp:4198-4204;
NOTE: HF uses x·rsqrt(Σx²+eps) — the two differ only for near-zero vectors, but
llama.cpp is the parity ground truth, so the clamp form is canonical here) →
repeat k-heads to H_v (GGUF V-order is TILED, so plain repeat, not interleave) →
delta rule with beta = sigmoid(ssm_beta @ x), g = ssm_a * softplus(ssm_alpha @ x + ssm_dt.bias) where ssm_a is pre-baked -exp(A_log); recurrent step:
S = S*exp(g); d = (v − (S·k)) * beta; S += k⊗d; o = S·q/√128. Then gated RMSNorm
(norm, × ssm_norm.weight [128], THEN × silu(z)) where z = attn_gate @ x, then
ssm_out. Conv state: last 3 columns of the fused qkv stream; delta state:
[128,128,H_v] fp32 per layer per seq.
27B (qwen35): 64 layers (16 full-attn), hidden 5120, 24 Q / 4 KV heads, dense SwiGLU
FFN 17408; DeltaNet: 16 K-heads, 48 V-heads, head dims 128 (inner 6144). GGUF ssm keys
mislead: time_step_rank=48=V-heads, group_count=16=K-heads, state_size=128=head
dim, inner_size=6144.
35B-A3B (qwen35moe): 40 layers (10 full-attn), hidden 2048, 16 Q / 2 KV heads;
DeltaNet: 16 K-heads, 32 V-heads (inner 4096). Every layer MoE (no dense FFN, no
feed_forward_length key): router ffn_gate_inp [2048,256] F32, softmax over all 256
THEN top-8 THEN renormalize (clamp sum ≥ 6.103515625e-5), no expert weight scale;
experts ffn_{gate,up,down}_exps Q4_K, 512-wide; shared expert ffn_*_shexp Q8_0
512-wide, output scaled by sigmoid(ffn_gate_inp_shexp @ x) (a [2048] vector → one
scalar per token), added to routed output.
Tokenizer/chat: ChatML. Specials: <|im_start|> 248045, <|im_end|> 248046,
<|endoftext|> 248044, <think> 248068 / </think> 248069 (single tokens but
special: false — handle by id in the gen loop), <tool_call> 248058/248059,
<tool_response> 248066/248067. No BOS, ever. Stop on 248046 OR 248044. Sampling
defaults are MODE-KEYED per the official cards, identical across all three checkpoints:
thinking 1.0 / 0.95 / 20, non-thinking 0.7 / 0.80 / 20 (SamplerOptions::recommended;
explicit flags/config/request values always win). Generation prompt ends inside an open
<think>\n unless thinking is disabled (then a closed empty block is emitted). The chat
template is a per-checkpoint DIALECT (Model::chat_dialect): the 3.8 template renders a
reasoning_effort system preamble (xhigh default / low have sentences, medium renders
nothing; only with thinking on; synthesizes a system block when the conversation has
none), defaults preserve_thinking TRUE (3.6: false), emits no block for an empty system
message (3.6 does), and does not split inline <think> out of assistant content
(3.6 does — split_reasoning is Qwen36-gated).
HF cache (HF_HUB_CACHE > HF_HOME/hub), cache-first via hf-hub, download on miss.
Default repos/files (hub.rs): ggml-org/Qwen3.6-27B-GGUF,
ggml-org/Qwen3.6-35B-A3B-GGUF and ggml-org/Qwen3.8-27B-GGUF, Q4_K_M. Sizes: 19.1 GB
(27B), 20.4 GB (35B), 19.0 GB (3.8-27B). Q8_0: 28.6 / 36.9 / 28.6 GB. Drafter sidecars,
one per checkpoint and TWO kinds: dflash-*-BF16.gguf on the 3.6 pair (3.5 GB / 0.8 GB)
and mtp-Qwen3.8-27B-Q8_0.gguf on the 3.8 (3.2 GB). Every drafter accessor on Model
is still Option — that shape is about a checkpoint that ships none, and no current one
is. MTP sidecars also exist for both 3.6 checkpoints and are unused (they have DFlash
heads, which are the better drafter there). mmproj-* files are the vision tower —
never load them.
Inherited hf-hub trap: refs/main is read verbatim; a trailing newline in a manually
edited ref costs a full re-download.
- Every norm weight arrives multiply-ready on the GGUF path — never add 1 to ANY of
them. (Upstream detail: HF stores zero-centered Gemma-style
(1+w)norms and the converter bakes the +1 in;ssm_norm.weightwas never zero-centered upstream, so the converter skips it — the end state is identical: multiply directly.) ssm_a=-exp(A_log)pre-baked. Use as-is:g = ssm_a * softplus(...).- V-head ordering is tiled (converter permutes attn_qkv V-rows, attn_gate, ssm_alpha,
ssm_beta, ssm_a, ssm_dt.bias, conv1d V-channels, ssm_out columns). Plain
repeat-style K-head broadcast is correct against GGUF weights; HF-stylerepeat_interleaveis WRONG here. - DeltaNet projections ship under attention names:
attn_qkv(fused q+k+v; the conv runs over this full width) andattn_gate(the z gate). There is nossm_in. - No
ffn_norm;post_attention_normis the pre-MLP norm. general.file_type15 = Q4_K_M, 7 = Q8_0. The Q4_K_M mix is a custom override: attn/ssm/shexp Q8_0, ffn/experts Q4_K (including down_exps — the usual Q6_K bump is absent), lm_head Q6_K, norms/routers/conv/ssm_a/dt F32, token_embd Q4_K. Qwen3.8's Q4_K_M differs in exactly one plane: its 16blk.N.attn_output.weighttensors are Q6_K, not Q8_0 (upstream'soutput.weight=q6_krule substring-catchesattn_output). Nothing asserts on that plane's quant and lm_head already exercises Q6_K.general.nameis what identifies a checkpoint, NOT the architecture: two releases ship the denseqwen35graph with byte-identical configs. The blessed files carry their exact full name ("Qwen3.6-27B", "Qwen3.6-35B-A3B", "Qwen3.8-27B"). The FILE decides (XwenConfig::checkpoint/Model::identify):general.namefirst, then the file name, each matched as an exact full name or a whole full name found inside it (never a bare "3.6"/"3.8" — that would make someone's 14B finetune the official 27B); a name matching two checkpoints identifies as neither.--model-sizeis a CROSS-CHECK, not an override: it must agree with a file that identifies itself (disagreement is a startup error) and only settles a file that identifies as nothing. A file that still says nothing runs asArch::model()with a logged warning, under its own file name. Qwen3.8's tokenizer.json is NOT byte-identical to 3.6's — it adds seven audio/TTS specials at 248070-248076 over an identical base vocab and merge table — but the embedded 3.6 tokenizer is what ships (TODO.md).- Both models are single-file GGUFs; tokenizer + chat template are embedded in the GGUF metadata AND vendored under reference/ (embedded into the binary via include_bytes!).
- The GGUF advertises only
eos_token_id = 248046and has no second-stop key; the full stop list [248046, 248044] exists only in the safetensors repo's generation_config.json and is therefore HARDCODED in xwen, never read from the GGUF. - llama.cpp's
gguf-py/constants.pydoes not match the shipped files (lists SSM_IN, omits ssm_beta and the shexp set). The shipped tensor tables are the spec; never read constants.py as one.
Identical to laguna, unchanged and not relitigated: candle git rev 21cca0b (ships
kernel_mul_mv_id_/kernel_mul_mm_id_ and the residency-set APIs), objc2 crates
=-pinned to what that rev resolves. See ../laguna/CLAUDE.md §"The candle situation"
for the full history; xwen inherits the conclusion, not the retelling.
docs/parity.md owns tiers, floors, taps, and runbook — don't restate them, re-read
them. The harness is live as of 2026-07-28 (P7): bun scripts/parity-gate.ts
(add --model-size 27b for the dense file) runs the whole Track-B cycle and exits
nonzero on any failure. It needs the oracle built once —
bash scripts/build-llamacpp.sh against the pinned clone in reference/llama.cpp.
cargo test --release (ops tests need a Metal device) still covers the
kernel-vs-reference invariants and is the fast pre-check.
- One large model process at a time or GPU OOM. Two 20 GB processes fit RAM but not comfort; the 27B Q8_0 (28.6 GB) plus anything else is asking for it.
- Never pipe model output through a pager (
glanceexists; an EOF-spinning llama-cli once fed 88 GB intolesson the laguna side). Scripted llama-cli needs-st -no-cnv </dev/null. - Anonymous RSS lies under mmap — the weights are file-backed. Judge memory by
footprint (
footprint <pid>), not RSS. - Never build with a nix Apple SDK in the env: flake.nix uses mkShellNoCC on purpose;
a nixpkgs SDKROOT links pre-Metal-4 and every tensor-kernel compile fails at
runtime. Diagnose with
otool -l target/release/xwen | grep -A4 LC_BUILD_VERSION. - Never report first-forward prefill as steady-state; state the power mode next to every number.
- Qwen-specific: the tokenizer has no BOS and chat stops on TWO eos ids — a gen loop that only checks 248044 runs through turn boundaries and looks like "the model won't stop", which is a config bug, not a sampling bug.
As of 2026-08-15 for the 3.8-27B, 2026-08-08 for the 3.6 pair (lowpowermode 0 —
NOT low-power mode; this machine emits no powermode key, so high-power mode is
never positively confirmable and must not be claimed. Interleaved protocol; full
history in log.md). Plain (--no-draft), measured inside the sweeps that graded
each: 35B-A3B decode 104-107 tok/s, 27B 24.8-25.3, 3.8-27B 23.7-24.8.
Prefill unchanged since 2026-07-29 and not re-measured: 35B ~1900-2550@4k; 27B
702@925 / 445@4k. Load 2.8-3.0s, 19.2 GB resident at max_ctx 8192; cold first run
adds ~9s of Metal pipeline compilation. With drafting (the default since P9a) at
the per-model defaults: 27B 37.5-38.2 code / 36.8-37.4 chat (+46-52% over
plain), 35B 133.6-134.8 code / 122.3-123.7 chat (+26-28% / +15-17%), both
fitted 2026-08-08; 3.8-27B 34.4-35.7 code / 33.1-34.0 chat (+44-45% / +37-38%)
at the p_min 0.7, depth 4 fitted 2026-08-15, acceptance 80.0% code / 77.8% chat.
Ranges span the medians the shipped configuration was measured at (for the 3.8,
three independent measurements in one session: stage 1, stage 2's shipped-margin
arm, and the depth probe).
Those drafted figures are WITHIN-SWEEP against the plain arm of the same sweep;
do not difference them against a drafted number from another session — the 27B's
between-session level shifts, and yesterday's 31.7 code figure at p_min 0.3 reads
36.5-37.6 in today's own 0.3 arm.
Within-session cross-drafter comparison, 2026-08-15 (the only way to compare the
two kinds honestly — same machine, same hour): the 3.6-27B's DFlash head runs
1.50x/1.47x over its own plain arm where the 3.8-27B's MTP head runs 1.45x/1.38x
over its own. Same trunk geometry, so the block drafter is still the stronger
drafter; the MTP head closes most of the gap and is worth roughly ten times less
KV (4 KiB/token against 40).
The 27B prefill gap is CLOSED (P8c, 2026-07-29). It was never the DeltaNet
scan — that is 3% of prefill — it was the dense SwiGLU FFN (66-85% of prefill
wall) running candle's kernel_mul_mm_q4_K_f32 at ~12-13 TFLOP/s where the
Metal-4 cooperative-tensor gemm does 28-36. src/ops/dense_mm.metal (Q4_K
source, in-kernel tile dequant, seq > 32) made 27B prefill 2.2-2.7x faster:
270 → 702 @925, 236 → 445 @4k, against llama.cpp's 486 / 502. Prefill no longer
degrades with length for FFN reasons, though a +350-560 µs/token residual
outside all measured stages still does (TODO.md — and it is most of why 4k fell
short of the profile's 496 upper bound while 925 met it). The kernel is
knowingly less accurate than the QMatMul chain (~4.1e-4 vs ~1.9e-4 rel_l2 from
the f32 oracle — matmul2d's reduced-precision path, the same trade the attention
prefill gemm made); it is pinned off on both sides of the strict parity tier and
graded by mm/decode/ppl.
Benching rules this machine has already enforced the hard way. Peak memory bandwidth here has NEVER been measured — do not argue "far below peak" from the 614 GB/s figure; compare bytes-moved against time between two arms instead (the Q4_K FFN gemm reads 3.6x fewer weight bytes than the f16 one and takes 2.4x longer, which settles bandwidth-vs-kernel without a peak). Use AMORTIZED rates (BATCH dispatches per sync, outputs held alive), never per-dispatch: a budget built from per-dispatch numbers sums to 127% of wall. Keep the duty cycle low — the same shape measured 23% slower in a 36 s run than in a 9 s one, with no thermal flag anywhere. llama.cpp's prefill thermal-boosts harder than xwen's (-17% vs -5% settling) — never read a first-reps prefill ratio as steady state. And on a machine shared with other agents, calibrate every prefill run against the classic arm's known baseline before believing absolutes: three separate contended runs read 3x low in BOTH arms while the ratio stayed put.
TWO drafter kinds, one verify machinery. --no-draft opts out; a zero-flag run fetches
and loads the checkpoint's own sidecar. Which kind a checkpoint ships is
Model::drafter_kind(), the file itself is the authority once opened
(drafter::classify), and src/drafter.rs is the seam. Everything downstream of the
proposal — checkpoint, batched forward_all_logits, accept_drafts, kv_rollback, the
retention cap, the auto-pause controller — is kind-agnostic, which is the whole reason a
second kind was affordable.
DFlash block drafting (the 3.6 pair). Adapted (P9), made a both-checkpoint win by the
K-snapshot fused verify (P9a) and flipped to opt-out the same day (3.5 GB 27B / 0.8 GB
35B). Sidecar facts (arch dflash; 27B: 5 layers, sliding_window 2048, taps
[2,17,32,47,62], mask 248070; 35B: 6 layers, sliding_window 4096, taps
[2,7,12,17,23,28,33,38], mask 248077; both block_size 16, fc.weight over concatenated
tapped layer outputs, own ffn_norm, q/k-norms [128]). It denoises a whole block in ONE
forward, so depth is nearly free and 15 is a cap, not a fitted value. Verify-walk
rollback uses the fused scan's K-snapshot planes (most-recent-first, llama.cpp's shape;
decisions.md "Model math").
MTP chain drafting (Qwen3.8-27B). mtp-Qwen3.8-27B-Q8_0.gguf, 3.2 GB, 18 tensors,
src/mtp.rs. The head is a 65th trunk-flavour full-attention layer with its own KV:
eh_proj over [enorm(embed) ⊕ hnorm(hidden)], then the trunk's own AttnBlock/Rope
(so partial NEoX rope, QK-norm and the sigmoid output gate are the blessed ones, not a
re-derivation), then shared_head_norm. It REUSES the target's quantized token_embd
and output — the sidecar's BF16 duplicates of both are deliberately ignored, which is
3 GB of its 3.16 saved for nothing lost. It chains one forward per step and self-feeds,
so depth costs linearly and pays off geometrically less: llama.cpp's fitted n_max is 3
and so is ours. Ground truth is llama.cpp graph_mtp in src/models/qwen35.cpp plus the
chain semantics in common/speculative.cpp.
TWO silent-garbage traps in that head, both pinned by tests because neither fails loudly:
(1) concat order is EMBEDDING FIRST — eh_proj takes the embedding in the low half;
swapping the halves yields a graph that runs and drafts noise. (2) both residuals
anchor on eh_proj's OUTPUT — inpSA = eh_proj(cat), and attention and FFN both add
back to that; there is no outer residual re-adding the embedding or the incoming hidden.
A third, invisible in the tensor names: the h input is the target's hidden AFTER
output_norm (upstream commit 166fe294 chose that deliberately). The DFlash spec taps
are PRE-norm layer outputs and are the WRONG source; XwenModel has a separate accessor.
The sync rule is the other thing to get exactly right and it lives in one function: the
head's KV row for position p is built from (token_p, hidden_{p-1}) — shifted right by
one, position 0 taking a zero hidden, mirroring llama.cpp's initial pending_h — and
sync takes tokens and hiddens at the SAME positions and owns the shift itself
(the_sync_pairs_each_token_with_the_previous_positions_hidden).
MTP limitations, both ledgered (TODO.md) rather than hidden: a rewind resets the head
— it keeps exactly one carry hidden, so truncate below what it holds drops it to zero
and that serve conversation stops speculating until a prefill from zero (the DFlash
drafter survives the same rewind, because each of its rows depends only on that
position's taps); and a stored MTP image resumes only at the exact position it ends
at, partial cover being refused by the kind-aware drafter_planes_usable predicate.
Both have the same root and the same fix.
Controller constants: p_min PER-CHECKPOINT via Model::draft_p_min_default() and depth
PER-KIND via Model::draft_max_default(), both in src/hub.rs; pause_margin stays a
shared 1.0. Values and the acceptance they buy are in "Perf state" below. p_min here is
a FULL-VOCAB probability and deliberately NOT llama.cpp's top-10-renormalized one
(decisions.md), so any cross-check against llama.cpp must run both sides at p_min 0 or
it is comparing two different gates. The standing retune tool is bun scripts/retune-draft.ts (two-stage, no cell reuse between stages, P9's qualification
criterion, print-only; --depth-grid crosses depth with p_min in stage 1) — if you
change hub.rs's arms you must also update the script's SHIPPED_P_MIN and
SHIPPED_DRAFT_MAX tables, or the next sweep grades against a status quo that no longer
ships. bun scripts/spec-equivalence.ts covers all three checkpoints; its GREEDY mode is
the gate, and its sampled mode diverges on the shipped 3.6 checkpoints too (near ties,
not a regression — see "Perf state").
The serve/ tree runs as forked. Not yet adapted: ChatML/tool-call parsing in the
dialect layers (Qwen's <function=...> XML-ish call format, string-args-raw rule) and
prefix-cache snapshots carrying recurrent state (see decisions.md "Serving"). Thinking
semantics ARE adapted as of 2026-08-19: open-<think> seeding, per-dialect
preserve_thinking (a request field on the native and OpenAI dialects, the checkpoint
template's default otherwise — the normalizers pass ALL replayed reasoning through in
native tools mode; the renderer's dialect rule alone decides what renders), the 3.8
reasoning_effort preamble (the OpenAI reasoning_effort field drives the think budget
AND the template level, off-scale levels nearest-mapped — a deliberate divergence from
llama.cpp, which passes them raw and lets the template raise; chat_template_kwargs
{enable_thinking, preserve_thinking, reasoning_effort} is STRICTLY validated with
400s, the one exception to accept-and-drop, and a request-level template effort — the
kwarg or the native field — on a 3.6 target is itself a 400; [thinking] effort /
serve --reasoning-effort set a server-wide default, inert-but-legal on 3.6), and
mode-keyed sampling resolved per request after thinking is known (the fixed
DEFAULT_TEMPERATURE/TOP_K/TOP_P constants are gone; ServeSettings sampling keys are
Options, and a pinned value pins both modes). Still open on thinking: the Anthropic
dialect has no per-request effort knob (server-wide default applies) and penalties stay
accept-and-drop — both ledgered (TODO.md 2026-08-19 section).
API model names are FULL names only (Qwen3.6-27B, Qwen3.6-35B-A3B, Qwen3.8-27B —
Model::full_name, matching general.name and the repo), plus the served file's own id
when that file is none of them. The CLI's 27b/35b/3.8-27b aliases are refused on
the wire; an unknown model is a 400 on every surface (both dialects, count_tokens and
the batch route), never a silent fall back to the default. /v1/models lists each id
exactly once and every listed id is selectable (2026-08-14).
A job names a serve::types::Target (checkpoint + "is this the served file"), not a bare
Model: on a custom-GGUF server the official checkpoint of the same architecture is a
DIFFERENT file, so an official name resolves the hub file while the file's own id
resolves the local one. Speculation is per checkpoint (DraftMode::{Off,Official, Custom}), resolved at load, so a sidecar-less default checkpoint no longer disables
drafting for the others.