Dense, Mixture-of-Experts and Hybrid Architectures
By the end of this lesson you will be able to compute what a mixture-of-experts router does to one
token, separate the bytes a model keeps resident from the bytes it reads per token, explain what a
state-space or linear-attention layer keeps instead of a key-value cache and why hybrids still keep
some attention, price the cache of a dense, sliding-window or hybrid model from its config.json,
read a GGUF tensor table to see what quantisation touches in a mixture of experts, and decide for
your own track whether an architecture relieves the constraint your machine actually has.
The practical question underneath it: Qwen3-32B and Qwen3-30B-A3B, both Apache-2.0, take almost the same memory at Q4_K_M, and the arithmetic below predicts the second decodes about five times faster at a 32k context on any machine that holds both. The “30B” in the name does not tell you that. Part 2 counted Qwen3-30B-A3B’s active parameters and Part 3 turned bytes read into a decode ceiling; this lesson reuses both and goes one level down.
Dense: every parameter, every token
Section titled “Dense: every parameter, every token”A dense model multiplies each token’s representation through every weight matrix of every layer. The cost model is the one Part 1’s precision lesson and Part 3’s inference lesson set out:
resident bytes = every tensor in the filebytes read per token = every tensor except the embedding table (one row of it is looked up)decode ceiling ≈ bandwidth / (bytes read per token + cache bytes read per token)The course’s Qwen3-32B file, Qwen3-32B-Q4_K_M.gguf from unsloth/Qwen3-32B-GGUF, is 19,762,150,048
bytes. Its tensor table holds 19,756,174,336 bytes of tensors, of which the embedding table is
437,575,680, so 19.32 GB is read for every token out of 19.76 GB resident. Double the parameters and
both numbers double. That simplicity is also the problem: every token, easy or hard, pays for all of
them.
Mixture of experts: many parameters, few of them per token
Section titled “Mixture of experts: many parameters, few of them per token”A mixture-of-experts model breaks that link. The Hugging Face explainer defines it as replacing the dense feed-forward block in a layer with a set of parallel feed-forward blocks, the experts, plus a small router that decides which experts each token is sent to. Attention is untouched.
The Switch Transformer paper (Fedus, Zoph and Shazeer, 2021) set out the modern form: send each token to few experts, so the computation per token stays roughly constant while the parameter count grows; it reports “up to 7x increases in pre-training speed with the same computational resources” against dense baselines. The DeepSeek-V3 technical report (December 2024) describes the refinements most current models use: many smaller, finely divided experts so the router’s choice is more expressive, shared experts that every token passes through, and what it calls auxiliary-loss-free load balancing, set out below.
What happens to one token in a mixture-of-experts layer
- Attention blockRuns for every token, using the same weights every time. This part is identical to a dense model.
- Router scores the expertsOne matrix multiply gives one score per expert for this token; the highest few are selected. Qwen3-30B-A3B selects 8 of 128; gpt-oss-120b selects 4 of 128.
- Selected experts computeOnly those experts' weights are read and multiplied. A shared expert, where the design has one, runs for every token as well.
- Outputs are combinedThe selected experts' outputs are weighted by their gate values and summed, and the result carries on to the next layer.
- Next token: different expertsThe routing is per token and cannot be known in advance, which is why every expert has to stay resident.
The router, one level down
Section titled “The router, one level down”For one token with hidden state h in one layer:
scores = W_router · h one number per expert; W_router is num_experts × hidden_sizep = softmax(scores)chosen = the num_experts_per_tok largest entries of pgate_i = p_i / (sum of p over chosen) for each chosen expert i (norm_topk_prob: true)output = sum over chosen of gate_i × expert_i(h) + shared_expert(h), where the design has one; Qwen3-Next scales it by sigmoid(w_shared · h)That is Qwen3MoeTopKRouter in Transformers 5.16.1, and Qwen3-Next’s router is the same.
GptOssTopKRouter in the same release adds a learned bias to the scores, takes the top four raw
scores first and applies the softmax to those four only. The two orders give identical gates,
because a softmax’s ratios depend only on differences between scores, and renormalising over the
chosen experts cancels the rest. The fields that configure all of it sit in each config.json:
| config.json field | Qwen3-30B-A3B | Qwen3-235B-A22B | gpt-oss-120b | Qwen3-Next-80B-A3B | Nemotron 3 Nano 30B-A3B |
|---|---|---|---|---|---|
| Experts per layer | num_experts 128 |
num_experts 128 |
num_local_experts 128 |
num_experts 512 |
n_routed_experts 128 |
| Experts per token | num_experts_per_tok 8 |
8 | 4 | 10 | 6 |
| Shared experts | none | none | none | one, shared_expert_intermediate_size 512 |
n_shared_experts 1 |
| Width of one expert | moe_intermediate_size 768 |
1,536 | intermediate_size 2,880 |
512 | 1,856 |
| Score function (Transformers 5.16.1) | softmax | softmax | softmax over the top 4; the router has a bias | softmax | sigmoid; e_score_correction_bias added for selection only; gates × routed_scaling_factor 2.5 |
| Chosen gates renormalised | norm_topk_prob true |
true | softmax over the 4 | true | true |
| Balance-loss coefficient | router_aux_loss_coef 0.001 |
0.001 | 0.9 | 0.001 | not in the config |
| Licence | Apache-2.0 | Apache-2.0 | Apache-2.0 | Apache-2.0 | NVIDIA Nemotron Open Model License |
Nemotron 3 Nano’s router, NemotronHTopkRouter in the same release, is the exception: it scores
each expert with a sigmoid, adds a per-expert selection bias before choosing, takes the gates from
the unbiased scores, renormalises them and multiplies by the scaling factor. That is the balancing
method of the DeepSeek-V3 technical report, and it is why the Nemotron column has no balance-loss
coefficient. Without a softmax, neither the formula nor the equivalence above holds for this model:
applied to it, they give the wrong gates.
This routes 512 token states through a router shaped like Qwen3-30B-A3B’s. The weights are random,
so it shows the mechanism, not the model’s real routing. Save it as route.py in ~/llm-course
with Part 2’s environment active, as every snippet on this page. On Track S, inside the container,
type /workspace/course wherever this page writes ~/llm-course, including the config and GGUF
paths below:
RunnableAll tracks
import numpy as np
rng = np.random.default_rng(0)hidden, n_experts, k = 2048, 128, 8 # sizes from Qwen3-30B-A3B's config.jsonW = rng.normal(0, 0.02, (n_experts, hidden)) # the router: one row of weights per experth = rng.normal(0, 1, (512, hidden)) # 512 token states arriving at one layer
def softmax(x): e = np.exp(x - x.max(axis=1, keepdims=True)) return e / e.sum(axis=1, keepdims=True)
logits = h @ W.T # one score per token per expert: [512, 128]p = softmax(logits)top = np.argsort(-p, axis=1)[:, :k] # the k experts each token is sent togates = np.take_along_axis(p, top, 1)gates /= gates.sum(axis=1, keepdims=True) # norm_topk_prob: truegates_gptoss = softmax(np.take_along_axis(logits, top, 1)) # top-k first, then softmaxprint("same gates in both orders:", np.allclose(gates, gates_gptoss))print("token 0 -> experts", top[0].tolist())print(" gate weights", np.round(gates[0], 3).tolist())
for t in (1, 8, 64, 512): used = len(np.unique(top[:t])) print(f"distinct experts used by the first {t:>3} tokens: {used:>3} of {n_experts}")
def balance_loss(p, top): # N x sum(f_i x P_i), as Transformers computes it f = np.bincount(top.ravel(), minlength=n_experts) / len(p) return n_experts * np.sum(f * p.mean(axis=0))
collapsed = np.zeros_like(p)collapsed[:, :k] = 1 / k # every token sends everything to experts 0 to 7first_k = np.tile(np.arange(k), (len(p), 1))print(f"balance loss, this router {balance_loss(p, top):6.2f}")print(f"balance loss, collapsed router {balance_loss(collapsed, first_k):6.2f}")RunnableAll tracks
python ~/llm-course/route.pyOutput — what you should see
same gates in both orders: Truetoken 0 -> experts [14, 80, 20, 25, 5, 58, 42, 77] gate weights [0.176, 0.167, 0.138, 0.13, 0.106, 0.1, 0.096, 0.087]distinct experts used by the first 1 tokens: 8 of 128distinct experts used by the first 8 tokens: 47 of 128distinct experts used by the first 64 tokens: 128 of 128distinct experts used by the first 512 tokens: 128 of 128balance loss, this router 8.05balance loss, collapsed router 128.00One token reads 8 of the 128 experts; eight tokens have already touched 47 of them. A trained router is less even than a random one, so real counts differ, and the course has no measurement of them. What no router offers is advance notice of which experts the next token needs, and fetching one from storage mid-token would put the slowest link in Part 1’s memory hierarchy into every decode step. Every expert therefore stays resident.
Load balancing, and what it prevents
Section titled “Load balancing, and what it prevents”The last two lines print the balance term Transformers 5.16.1 adds to the training loss, in the form its docstring attributes to the Switch Transformer paper:
balance_loss = num_experts × sum over experts of f_i × P_i f_i = share of the batch's expert assignments that went to expert i (sums to experts per token) P_i = mean router probability given to expert i (sums to 1) perfectly even routing → experts per token (8 for Qwen3-30B-A3B) complete collapse → num_experts (128)Training multiplies it by router_aux_loss_coef and adds it to the language-modelling loss. The
Hugging Face explainer describes what it prevents: “the gating network converges to mostly activate
the same few experts. This self-reinforces as favored experts are trained quicker and hence selected
more.” The collapsed router is that end state, with 120 experts resident and idle. The design in the
DeepSeek-V3 technical report relies mainly on a different method: it adds a per-expert bias to the
scores “to determine the top-K routing”, lowers the bias of an overloaded expert and raises that of
an underloaded one after each training step, and notes that “the bias term is only used for
routing”; the gate still comes from the original score. The design keeps only a small complementary
sequence-wise balance loss, “to prevent extreme imbalance within any single sequence”, and computes
its scores with a sigmoid rather than a softmax. You meet balance again at inference in two places: expert parallelism
in Part 18, where a popular expert is a busy device, and batching in Part 3, where the spread of
routing sets how many experts one step reads.
Two numbers, two different costs
Section titled “Two numbers, two different costs”Part 3 put the trade as “capacity is paid in memory, speed is paid in bytes read” and
priced it in bytes
for both mixtures of experts. The table repeats Part 3’s tensor-table figures for those two, adds the
dense Qwen3-32B and one new row, the capacity multiplier; gguf-groups.py, in the quantisation
section, reads its tensor byte counts from the files:
resident bytes = always-read tensors + embedding table + all routed expertsread per token = always-read tensors + (experts per token ÷ experts per layer) × all routed expertscapacity multiplier = resident bytes ÷ read per token| At batch 1, from the GGUF tensor tables (retrieved 2026-09-12) | Qwen3-32B Q4_K_M, dense | Qwen3-30B-A3B Q4_K_M | gpt-oss-120b MXFP4 |
|---|---|---|---|
| File size, bytes | 19,762,150,048 | 18,556,686,912 | 63,387,346,208 |
| Routed-expert tensors, bytes | none | 17,553,162,240 | 61,073,326,080 |
| Always read: everything but the embedding table and the routed experts | 19,318,598,656 | 822,523,904 | 1,685,668,608 |
| Experts read per token, bytes | none | 8 of 128: 1,097,072,640 | 4 of 128: 1,908,541,440 |
| Read per token | 19.32 GB | 1.92 GB | 3.59 GB |
| Capacity multiplier | 1.0 | 9.7 | 17.6 |
| Block parameters multiplied per token | 31,206,298,624 | 2,730,702,848 | 4,553,716,032 |
| Arithmetic per token in the blocks, 2 × that | 62.4 GFLOP | 5.5 GFLOP | 9.1 GFLOP |
Read the multiplier as an exchange rate: gpt-oss-120b turns 17.6 GB of memory into the per-token reading cost of 1 GB. The last row matters wherever a step is compute-bound, which is prefill, so a mixture of experts cuts both the bytes and the arithmetic per token by about an order of magnitude against the dense model its memory resembles.
Qwen3-32B (dense) at Q4_K_M, 32k context, on a 64 GB machine
- Weights, Q4_K_M
- 19.8 GB
- KV cache at 32k tokens, FP16
- 8.6 GB
- Free
- 35.6 GB
- Total
- 64 GB
Qwen3-30B-A3B (mixture of experts) at Q4_K_M, 32k context, on a 64 GB machine
- Weights, Q4_K_M
- 18.6 GB
- KV cache at 32k tokens, FP16
- 3.2 GB
- Free
- 42.2 GB
- Total
- 64 GB
Two things the arithmetic cannot settle. Whether Qwen3-30B-A3B, “30.5B in total and 3.3B activated” on its card, answers your task as well as the 32.8B dense parameters on Qwen3-32B’s card is a measurement: the Qwen3-Next card, for one, claims its 80B-A3B base model “outperforms Qwen3-32B-Base on downstream tasks with 10% of the total training cost”, and that stays a claim with an owner and a date until Part 16’s evaluation methods reproduce it on your work. And the advantage is a batch-1 figure: each sequence in a batch routes on its own, so a step reads the union of their experts, which Part 3 quantifies.
Hybrid: replacing some attention with a recurrent state
Section titled “Hybrid: replacing some attention with a recurrent state”Attention reads every cached key and value at every step, which is why the cache and the bytes read per token grow with context. The alternative line of work is state-space models, of which Mamba (Gu and Dao, 2023) is the reference: its abstract describes making the model’s parameters functions of the input so it can selectively propagate or forget information, with linear scaling in sequence length. The property that matters to a memory budget is that such a layer carries a fixed-size state forward instead of a cache.
What a recurrent layer keeps instead of a cache
Section titled “What a recurrent layer keeps instead of a cache”Take the softmax out of attention and the sum over the cache can be regrouped into one matrix, updated in place:
softmax attention o_t = sum over j ≤ t of softmax_j(q_t · k_j) v_j reads t cached (k, v) pairslinear attention o_t = sum over j ≤ t of (q_t · k_j) v_j = S_t q_t, S_t = S_(t-1) + v_t k_tᵀGated DeltaNet S_t = α_t S_(t-1); S_t = S_t + β_t (v_t − S_t k_t) k_tᵀ; o_t = S_t q_tMamba-2 h_t = dA_t h_(t-1) + dB_t x_t; y_t = C_t h_tS is a value-dimension by key-dimension matrix (Transformers stores the transpose, [key, value])
whatever t is. The Gated DeltaNet line is the recurrent rule in Transformers 5.16.1’s modeling_qwen3_next.py: α_t, between 0 and 1, decays
everything stored, and β_t sets how strongly the new value replaces what key k_t currently
retrieves. The Gated Delta Networks paper calls the two complementary: “gating enables rapid memory
erasure while the delta rule facilitates targeted updates”. The Mamba-2 line is the state update in
modeling_nemotron_h.py, where h plays the role of S.
The first half of this script shows the recurrent form is exactly linear attention; the second shows what a fixed state costs, by storing key-to-value facts in a 64 × 64 state and in an attention cache and asking for each fact back:
RunnableAll tracks
import numpy as np
rng = np.random.default_rng(0)d, T = 64, 1000q, k, v = (rng.normal(size=(T, d)) / np.sqrt(d) for _ in range(3))
# Linear attention (no softmax), computed two waysby_cache = np.stack([v[: t + 1].T @ (k[: t + 1] @ q[t]) for t in range(T)]) # re-read all pairsS, by_state = np.zeros((d, d)), np.empty((T, d))for t in range(T): S += np.outer(v[t], k[t]) # S_t = S_(t-1) + v_t k_t^T by_state[t] = S @ q[t] # o_t = S_t q_tprint(f"largest difference between the two: {np.abs(by_cache - by_state).max():.1e}")print(f"held after {T} tokens: cache {2 * T * d:,} numbers, state {d * d:,} numbers")
def recall(n): # store n key -> value facts, then ask for each keys = rng.normal(size=(n, d)) keys /= np.linalg.norm(keys, axis=1, keepdims=True) vals = rng.normal(size=(n, d)) S = np.zeros((d, d)) for kk, vv in zip(keys, vals): # delta rule: replace what this key retrieves now S += np.outer(vv - S @ kk, kk) weights = np.exp(20 * keys @ keys.T) # softmax attention over a cache of all n pairs from_attn = (weights / weights.sum(axis=1, keepdims=True)) @ vals unit = vals / np.linalg.norm(vals, axis=1, keepdims=True) hit = lambda got: np.mean((got @ unit.T).argmax(axis=1) == np.arange(n)) return hit(keys @ S.T), hit(from_attn)
for n in (16, 64, 256, 1024): s, a = recall(n) print(f"{n:>5} facts: fixed {d}x{d} state recalls {s:4.0%} attention over the cache {a:4.0%}")RunnableAll tracks
python ~/llm-course/state.pyOutput — what you should see
largest difference between the two: 2.4e-15held after 1000 tokens: cache 128,000 numbers, state 4,096 numbers 16 facts: fixed 64x64 state recalls 100% attention over the cache 100% 64 facts: fixed 64x64 state recalls 88% attention over the cache 100% 256 facts: fixed 64x64 state recalls 25% attention over the cache 100% 1024 facts: fixed 64x64 state recalls 6% attention over the cache 100%The first difference is rounding (its last digits vary by machine): the recurrent form is the same computation, holding 4,096 numbers where the cache form holds 128,000 and counting. The recall lines are the price. Once the facts outnumber the state’s dimensions, new writes overwrite old ones, while attention over a cache that never overwrote anything recalls every fact. That is the limitation the Gated Delta Networks abstract names, performance “in retrieval and long-context tasks has been limited”, and it is why current hybrids keep a minority of real attention layers for exact recall and give the rest of the depth to recurrent layers.
Two hybrids, read from their configs
Section titled “Two hybrids, read from their configs”| Qwen3-Next-80B-A3B (Apache-2.0) | Nemotron 3 Nano 30B-A3B (NVIDIA Nemotron Open Model License, not gated) | |
|---|---|---|
| Card: parameters | “80B in total and 3B activated” | “3.5B active parameters and 30B parameters in total” |
| Card: layout | “12 * (3 * (Gated DeltaNet -> MoE) -> 1 * (Gated Attention -> MoE))” | “23 Mamba-2 and MoE layers, along with 6 Attention layers” |
| Config: how the layout is declared | num_hidden_layers 48 and full_attention_interval 4 |
hybrid_override_pattern, 52 characters: 23 M, 23 E, 6 * |
| Attention layers, which keep a growing cache | 12, each num_key_value_heads 2 × head_dim 256 |
6, each num_key_value_heads 2 × head_dim 128 |
| Recurrent layers | 36 Gated DeltaNet | 23 Mamba-2 |
| State of one recurrent layer | [linear_num_value_heads, linear_key_head_dim, linear_value_head_dim] = 32 × 128 × 128 = 524,288, plus a convolution state of 8,192 × linear_conv_kernel_dim 4 |
[mamba_num_heads, mamba_head_dim, ssm_state_size] = 64 × 64 × 128 = 524,288, plus a convolution state of 6,144 × conv_kernel 4 |
| Experts | 512 routed, 10 per token, 1 shared | 128 routed, 6 per token, 1 shared |
| Context on the card | “262,144 natively” | “up to a 1M context size”; 256k default in the Hugging Face configuration |
A “layer” means different things in the two configs. Each of Qwen3-Next’s 48 contains a mixer and an
expert block; Nemotron’s pattern gives mixers and expert blocks a character each, so its 52 are 23
recurrent mixers, 23 expert blocks and 6 attention mixers. A calculator that trusts
num_hidden_layers gets both wrong. Qwen3-Next’s card also claims “10 times inference throughput for
context over 32K tokens” against Qwen3-32B-Base; the next section gives the cache arithmetic behind a
claim of that shape.
What changes for the key-value cache
Section titled “What changes for the key-value cache”Part 2 derived the cache of a plain transformer, 2 × layers × kv_heads × head_dim × bytes_per_element
per token, and the attention lesson showed that grouped-query attention
is why kv_heads is 8 rather than 64 for Qwen3-32B. Architecture changes which layers that formula
applies to:
cache(t) = 2 × kv_heads × head_dim × bytes_per_element × (full_layers × t + sliding_layers × min(t, window)) + recurrent_layers × numbers_in_one_state × bytes_per_state_number (constant in t)Experts appear nowhere. Routing changes the feed-forward half of a block and the cache belongs to attention, so a mixture of experts has whatever cache its attention configuration gives it.
Fetch the five configurations. Each is a few kilobytes and none of the repositories is gated:
RunnableAll tracks
hf download Qwen/Qwen3-32B config.json --local-dir ~/llm-course/configs/qwen3-32bhf download Qwen/Qwen3-30B-A3B config.json --local-dir ~/llm-course/configs/qwen3-30b-a3bhf download openai/gpt-oss-120b config.json --local-dir ~/llm-course/configs/gpt-oss-120bhf download Qwen/Qwen3-Next-80B-A3B-Instruct config.json \ --local-dir ~/llm-course/configs/qwen3-next-80b-a3bhf download nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16 config.json \ --local-dir ~/llm-course/configs/nemotron-3-nano-30b-a3bOutput — what you should see
✓ Downloaded path: /home/you/llm-course/configs/qwen3-32b/config.json✓ Downloaded path: /home/you/llm-course/configs/qwen3-30b-a3b/config.json✓ Downloaded path: /home/you/llm-course/configs/gpt-oss-120b/config.json✓ Downloaded path: /home/you/llm-course/configs/qwen3-next-80b-a3b/config.json✓ Downloaded path: /home/you/llm-course/configs/nemotron-3-nano-30b-a3b/config.jsonGrey Hint: lines, and a warning about unauthenticated requests if you have not logged in to the
Hub, may appear between these; none of them affects the download. The calculator decodes each of the
three layout declarations and prices four context lengths. It counts the recurrent state at 4 bytes
per number, the float32 in which Transformers computes the Gated DeltaNet rule and the
mamba_ssm_cache_dtype Nemotron’s config declares:
RunnableAll tracks
import json, sys
c = json.load(open(sys.argv[1]))L, kv_heads = c["num_hidden_layers"], c["num_key_value_heads"]head_dim = c.get("head_dim") or c["hidden_size"] // c["num_attention_heads"]window = c.get("sliding_window") or 0
if "hybrid_override_pattern" in c: # Nemotron-H: M = Mamba-2, E = MoE, * = attention kinds = [{"*": "full", "M": "state"}.get(ch, "other") for ch in c["hybrid_override_pattern"]]elif "layer_types" in c: # gpt-oss: sliding_attention or full_attention kinds = ["window" if t == "sliding_attention" else "full" for t in c["layer_types"]]elif "full_attention_interval" in c: # Qwen3-Next: every n-th layer is full attention n = c["full_attention_interval"] kinds = ["full" if (i + 1) % n == 0 else "state" for i in range(L)]else: # a plain transformer, dense or mixture of experts kinds = ["full"] * L
if "linear_num_value_heads" in c: # Gated DeltaNet: [v_heads, k_dim, v_dim] + conv conv = 2 * c["linear_num_key_heads"] * c["linear_key_head_dim"] conv += c["linear_num_value_heads"] * c["linear_value_head_dim"] per_state = c["linear_num_value_heads"] * c["linear_key_head_dim"] * c["linear_value_head_dim"] per_state += conv * c["linear_conv_kernel_dim"]elif "mamba_num_heads" in c: # Mamba-2: [heads, head_dim, state_size] + conv inner = c["mamba_num_heads"] * c["mamba_head_dim"] conv = inner + 2 * c["n_groups"] * c["ssm_state_size"] per_state = inner * c["ssm_state_size"] + conv * c["conv_kernel"]else: per_state = 0
full, slid, rec = kinds.count("full"), kinds.count("window"), kinds.count("state")other = len(kinds) - full - slid - recprint(f"{len(kinds)} layers: {full} full attention, {slid} sliding window {window}, " f"{rec} recurrent, {other} with no cache")kv_per_token = 2 * kv_heads * head_dim * 2 # key and value, 2 bytes per element (FP16)state = rec * per_state * 4 # recurrent states, 4 bytes per element (float32)for t in (4_096, 32_768, 131_072, 262_144): kv = kv_per_token * (full * t + slid * min(t, window)) print(f"{t:>8,} tokens: KV cache {kv / 1e9:6.2f} GB + fixed state {state / 1e9:.3f} GB")RunnableAll tracks
python ~/llm-course/arch-cache.py ~/llm-course/configs/qwen3-next-80b-a3b/config.jsonpython ~/llm-course/arch-cache.py ~/llm-course/configs/nemotron-3-nano-30b-a3b/config.jsonOutput — what you should see
48 layers: 12 full attention, 0 sliding window 0, 36 recurrent, 0 with no cache 4,096 tokens: KV cache 0.10 GB + fixed state 0.080 GB 32,768 tokens: KV cache 0.81 GB + fixed state 0.080 GB 131,072 tokens: KV cache 3.22 GB + fixed state 0.080 GB 262,144 tokens: KV cache 6.44 GB + fixed state 0.080 GB52 layers: 6 full attention, 0 sliding window 0, 23 recurrent, 23 with no cache 4,096 tokens: KV cache 0.03 GB + fixed state 0.050 GB 32,768 tokens: KV cache 0.20 GB + fixed state 0.050 GB 131,072 tokens: KV cache 0.81 GB + fixed state 0.050 GB 262,144 tokens: KV cache 1.61 GB + fixed state 0.050 GBRunnableAll tracks
python ~/llm-course/arch-cache.py ~/llm-course/configs/qwen3-32b/config.jsonpython ~/llm-course/arch-cache.py ~/llm-course/configs/qwen3-30b-a3b/config.jsonpython ~/llm-course/arch-cache.py ~/llm-course/configs/gpt-oss-120b/config.jsonOutput — what you should see
64 layers: 64 full attention, 0 sliding window 0, 0 recurrent, 0 with no cache 4,096 tokens: KV cache 1.07 GB + fixed state 0.000 GB 32,768 tokens: KV cache 8.59 GB + fixed state 0.000 GB 131,072 tokens: KV cache 34.36 GB + fixed state 0.000 GB 262,144 tokens: KV cache 68.72 GB + fixed state 0.000 GB48 layers: 48 full attention, 0 sliding window 0, 0 recurrent, 0 with no cache 4,096 tokens: KV cache 0.40 GB + fixed state 0.000 GB 32,768 tokens: KV cache 3.22 GB + fixed state 0.000 GB 131,072 tokens: KV cache 12.88 GB + fixed state 0.000 GB 262,144 tokens: KV cache 25.77 GB + fixed state 0.000 GB36 layers: 18 full attention, 18 sliding window 128, 0 recurrent, 0 with no cache 4,096 tokens: KV cache 0.16 GB + fixed state 0.000 GB 32,768 tokens: KV cache 1.21 GB + fixed state 0.000 GB 131,072 tokens: KV cache 4.84 GB + fixed state 0.000 GB 262,144 tokens: KV cache 9.67 GB + fixed state 0.000 GBCollected, the five are below. They are arithmetic from the configs, not measurements, and columns past a model’s context window, which Part 2 lists, are for comparison only:
| Model | Layers whose cache grows | KV bytes per token, FP16 | 4,096 tokens | 32,768 | 131,072 | 262,144 | Fixed state |
|---|---|---|---|---|---|---|---|
| Qwen3-32B, dense | 64 full | 262,144 | 1.07 GB | 8.59 GB | 34.36 GB | 68.72 GB | none |
| Qwen3-30B-A3B, mixture of experts | 48 full | 98,304 | 0.40 GB | 3.22 GB | 12.88 GB | 25.77 GB | none |
| gpt-oss-120b, experts and sliding window | 18 full; 18 stop at 128 tokens | 36,864 past the window | 0.16 GB | 1.21 GB | 4.84 GB | 9.67 GB | none |
| Qwen3-Next-80B-A3B, hybrid | 12 full | 24,576 | 0.10 GB | 0.81 GB | 3.22 GB | 6.44 GB | 0.080 GB |
| Nemotron 3 Nano 30B-A3B, hybrid | 6 full | 6,144 | 0.03 GB | 0.20 GB | 0.81 GB | 1.61 GB | 0.050 GB |
Against Qwen3-32B, whose base model the Qwen3-Next card compares with, Qwen3-Next stores 262,144 ÷ 24,576, about 10.7 times fewer, KV bytes per token, which is the shape of the card’s long-context throughput claim; the claim itself stays a claim until measured.
The hybrid’s saving is in the slope. At 262,144 tokens the 80-billion-parameter Qwen3-Next holds the same 6.44 GB of cache that Qwen3-30B-A3B holds at 65,536, a quarter of the context. The fixed state is small, but it is read and rewritten at every token, so it behaves like an extra 80 MB of weights in the bandwidth arithmetic.
Two engine behaviours follow from these mechanisms. For sliding layers, llama-server at v0.4.0 lists
--swa-full, “use full-size SWA cache (default: false)”, so by default the smaller figure applies.
The pull request that added checkpoints for that cache states the trade: with --swa-full “we can
branch from any past positions of the context (so no need to do checkpoints), but the drawback is
that the SWA memory size is much larger”. A recurrent state is the stronger case of the same problem:
overwritten at every token, it cannot be rolled back to an earlier position without a saved copy,
which matters for the prompt reuse Part 17
teaches. The same README lists --ctx-checkpoints, also spelled --swa-checkpoints, “max number of
context checkpoints to create per slot (default: 32)”; how a given engine and version checkpoints a
hybrid is a detail to confirm in its documentation before relying on prompt reuse with one.
Where the cache overtakes the weights
Section titled “Where the cache overtakes the weights”A decode step reads the weights it uses plus the cache, and only the cache grows. The context at which the two are equal:
crossover tokens = weight bytes read per token ÷ KV bytes per token of context| Model | Weight bytes read per token | KV bytes per token | Crossover |
|---|---|---|---|
| Qwen3-32B, dense | 19,318,598,656 | 262,144 | 73,695 tokens |
| Qwen3-30B-A3B | 1,919,596,544 | 98,304 | 19,527 tokens |
| gpt-oss-120b | 3,594,210,048 | 36,864 | 97,499 tokens |
Qwen3-30B-A3B crosses before 20k tokens: past that, most of what a decode step reads is cache, which is why Part 3 found that long context erodes a mixture of experts’ advantage. The decision rule:
| Your typical context | What to compare between candidate models |
|---|---|
| Well below the crossover | Weight bytes read per token, from the tensor table: always-read tensors + (experts per token ÷ experts per layer) × routed-expert bytes (not active parameters × average bytes per parameter, which Part 3 shows undercounts gpt-oss-120b) |
| Near or above the crossover | KV bytes per token: full-attention layers × kv_heads × head_dim, less any sliding or recurrent layers |
| Any, on a machine short of capacity | Resident bytes: the file plus the cache at the length you allocate |
What changes for quantisation
Section titled “What changes for quantisation”Quantisation is Part 16’s subject; architecture decides where it lands. The tensor tables of the course’s two mixture-of-experts files, grouped:
| Tensor group (GGUF names) | Qwen3-30B-A3B Q4_K_M: types | Bytes | Share | gpt-oss-120b MXFP4: types | Bytes | Share |
|---|---|---|---|---|---|---|
Routed experts: ffn_gate_exps, ffn_up_exps, ffn_down_exps |
Q4_K; half of the ffn_down_exps layers Q6_K |
17,553,162,240 | 94.6% | MXFP4; biases F32 | 61,073,326,080 | 96.4% |
Attention: attn_q, attn_k, attn_v, attn_output |
Q4_K; half of the attn_v layers Q6_K |
516,096,000 | 2.8% | Q8_0; biases F32 | 1,016,386,560 | 1.6% |
Output head: output |
Q6_K | 255,252,480 | 1.4% | Q8_0 | 615,329,280 | 1.0% |
Embedding table: token_embd |
Q4_K | 175,030,272 | 0.9% | Q8_0 | 615,329,280 | 1.0% |
Router: ffn_gate_inp |
F32 | 50,331,648 | 0.27% | F32 | 53,102,592 | 0.08% |
| Norms and attention sinks | F32 | 843,776 | under 0.01% | F32 | 850,176 | under 0.01% |
Every byte count in that table, and the tensor byte counts in the two-numbers section, come from the
start of each file: the header and tensor infos end within the first 16 MiB in all three, so a range
request stands in for downloads of up to 63 GB. --range asks for those bytes only, --location follows the Hub’s
redirect to its storage, and --fail stops on an HTTP error instead of saving the error page as a
file. The three requests total 48 MiB:
RunnableAll tracks
curl --silent --show-error --fail --location --range 0-16777215 --create-dirs \ --output ~/llm-course/gguf-heads/Qwen3-30B-A3B-Q4_K_M.gguf \ https://huggingface.co/unsloth/Qwen3-30B-A3B-GGUF/resolve/main/Qwen3-30B-A3B-Q4_K_M.ggufcurl --silent --show-error --fail --location --range 0-16777215 --create-dirs \ --output ~/llm-course/gguf-heads/Qwen3-32B-Q4_K_M.gguf \ https://huggingface.co/unsloth/Qwen3-32B-GGUF/resolve/main/Qwen3-32B-Q4_K_M.ggufcurl --silent --show-error --fail --location --range 0-16777215 --create-dirs \ --output ~/llm-course/gguf-heads/gpt-oss-120b-MXFP4.gguf \ https://huggingface.co/ggml-org/gpt-oss-120b-GGUF/resolve/main/gpt-oss-120b-MXFP4.ggufwc -c ~/llm-course/gguf-heads/Qwen3-30B-A3B-Q4_K_M.gguf \ ~/llm-course/gguf-heads/Qwen3-32B-Q4_K_M.gguf \ ~/llm-course/gguf-heads/gpt-oss-120b-MXFP4.ggufOutput — what you should see
16777216 /home/you/llm-course/gguf-heads/Qwen3-30B-A3B-Q4_K_M.gguf16777216 /home/you/llm-course/gguf-heads/Qwen3-32B-Q4_K_M.gguf16777216 /home/you/llm-course/gguf-heads/gpt-oss-120b-MXFP4.gguf50331648 totalThe curl commands print nothing when they succeed; macOS’s wc pads the counts with spaces. A line
such as curl: (22) The requested URL returned error: 404 means the file name has changed in the
repository: check its file listing. The reader below is Part 2’s
gguf-header.py cut down
to the tensor infos, with ggml’s block sizes to turn each tensor’s parameter count into bytes and a
name rule to group the tensors. Save it as gguf-groups.py; it works on a complete .gguf file too:
RunnableAll tracks
import struct, sysfrom collections import Counter, defaultdict
# ggml type id: (name, bytes per block, values per block), from ggml-common.h at llama.cpp v0.4.0BLOCK = {0: ("F32", 4, 1), 1: ("F16", 2, 1), 8: ("Q8_0", 34, 32), 12: ("Q4_K", 144, 256), 13: ("Q5_K", 176, 256), 14: ("Q6_K", 210, 256), 30: ("BF16", 2, 1), 39: ("MXFP4", 17, 32)}RULES = [("_exps", "routed experts"), ("ffn_gate_inp", "router"), ("norm", "norms and sinks"), ("sinks", "norms and sinks"), ("token_embd", "embedding table"), ("attn_", "attention")]SCALAR = {0: "<B", 1: "<b", 2: "<H", 3: "<h", 4: "<I", 5: "<i", 6: "<f", 7: "<?", 10: "<Q", 11: "<q", 12: "<d"}fh = open(sys.argv[1], "rb")
def get(fmt): data = fh.read(struct.calcsize(fmt)) if len(data) < struct.calcsize(fmt): sys.exit("the file ends inside the header: download a longer range") return struct.unpack(fmt, data)[0]
def text(): return fh.read(get("<Q")).decode("utf-8", "replace")
def value(kind): # 8 is a string, 9 an array, the rest scalars if kind == 9: inner, count = get("<I"), get("<Q") return [value(inner) for _ in range(count)] return text() if kind == 8 else get(SCALAR[kind])
if fh.read(4) != b"GGUF": # the magic, then a uint32 version sys.exit(f"{sys.argv[1]} is not a GGUF file")get("<I")n_tensors, n_kv = get("<Q"), get("<Q")meta = dict((text(), value(get("<I"))) for _ in range(n_kv))size, types = defaultdict(int), defaultdict(Counter)for _ in range(n_tensors): name, n_dims = text(), get("<I") values = 1 for _ in range(n_dims): values *= get("<Q") kind, _offset = get("<I"), get("<Q") if kind not in BLOCK: sys.exit(f"{name}: ggml type {kind} is not in BLOCK; add its block size first") label = "output head" if name.startswith("output.") else next( (group for part, group in RULES if part in name), "dense feed-forward") type_name, block_bytes, block_values = BLOCK[kind] size[label] += values // block_values * block_bytes types[label][type_name] += 1print(f"header and tensor infos end at byte {fh.tell():,}")
total, arch = sum(size.values()), meta["general.architecture"]for label in sorted(size, key=size.get, reverse=True): kinds = " ".join(f"{t} x{n}" for t, n in types[label].most_common()) print(f" {label:18s} {size[label]:>15,} {100 * size[label] / total:5.2f}% {kinds}")used, experts = meta.get(f"{arch}.expert_used_count", 0), meta.get(f"{arch}.expert_count", 0)always = total - size["routed experts"] - size["embedding table"]routed = size["routed experts"] * used // experts if experts else 0print(f"tensor bytes {total:,}; always read {always:,}")print(f"read per token {always + routed:,}, with {used} of {experts} experts per layer")RunnableAll tracks
python ~/llm-course/gguf-groups.py ~/llm-course/gguf-heads/Qwen3-30B-A3B-Q4_K_M.ggufpython ~/llm-course/gguf-groups.py ~/llm-course/gguf-heads/Qwen3-32B-Q4_K_M.ggufpython ~/llm-course/gguf-groups.py ~/llm-course/gguf-heads/gpt-oss-120b-MXFP4.ggufOutput — what you should see
header and tensor infos end at byte 5,970,481 routed experts 17,553,162,240 94.62% Q4_K x120 Q6_K x24 attention 516,096,000 2.78% Q4_K x168 Q6_K x24 output head 255,252,480 1.38% Q6_K x1 embedding table 175,030,272 0.94% Q4_K x1 router 50,331,648 0.27% F32 x48 norms and sinks 843,776 0.00% F32 x193tensor bytes 18,550,716,416; always read 822,523,904read per token 1,919,596,544, with 8 of 128 experts per layerheader and tensor infos end at byte 5,975,700 dense feed-forward 15,237,120,000 77.13% Q4_K x160 Q6_K x32 attention 3,440,640,000 17.42% Q4_K x224 Q6_K x32 output head 638,131,200 3.23% Q6_K x1 embedding table 437,575,680 2.21% Q4_K x1 norms and sinks 2,707,456 0.01% F32 x257tensor bytes 19,756,174,336; always read 19,318,598,656read per token 19,318,598,656, with 0 of 0 experts per layerheader and tensor infos end at byte 13,022,233 routed experts 61,073,326,080 96.37% F32 x108 MXFP4 x108 attention 1,016,386,560 1.60% F32 x144 Q8_0 x144 output head 615,329,280 0.97% Q8_0 x1 embedding table 615,329,280 0.97% Q8_0 x1 router 53,102,592 0.08% F32 x72 norms and sinks 850,176 0.00% F32 x109tensor bytes 63,374,323,968; always read 1,685,668,608read per token 3,594,210,048, with 4 of 128 experts per layerA file whose header runs past the range stops with “the file ends inside the header”; a larger
--range fixes it. The group bytes and the always-read and read-per-token lines are the two tables’
figures, and the dense file has “0 of 0 experts”, so its last two figures match. The count after
each type is how many tensors use it, which is where the table’s “half of the layers Q6_K” comes from.
The experts are the file. Compressing only the experts captures nearly all of the saving, and
gpt-oss ships that way: its config’s quantization_config names mxfp4 and lists
model.layers.*.self_attn, model.layers.*.mlp.router, model.embed_tokens and lm_head under
modules_to_not_convert; the ggml-org conversion stores those at Q8_0, except the router, which is
F32. The expert weights cost exactly 4.25 bits each in the file, 60,914,073,600 bytes for
114,661,785,600 weights: four bits per value plus one 8-bit shared scale per block of 32, the format Part 16’s
MXFP4 section
takes apart.
The router stays at full precision by rule. llama-quant.cpp at v0.4.0 carries the comment “do
not quantize expert gating tensors” above the line that skips every ffn_gate_inp.weight, which is
why both files hold it as F32. The saving forgone is small: at 4.5 bits Qwen3-30B-A3B’s router would
save 50,331,648 × (1 − 4.5 ÷ 32) = 43,253,760 bytes, 0.23 per cent of the file. The risk is not small,
because the router’s output is a choice. This gives a router and an expert matrix the same 4-bit
rounding and compares the damage:
RunnableAll tracks
import numpy as np
rng = np.random.default_rng(0)hidden, n_experts, k, tokens = 2048, 128, 8, 4096router = rng.normal(0, 0.02, (n_experts, hidden))expert = rng.normal(0, 0.02, (768, hidden)) # an expert's up-projection, for comparisonh = rng.normal(0, 1, (tokens, hidden))
def round_4bit(w, block=32): # symmetric 4-bit, one scale per block of 32 x = w.reshape(-1, block) scale = np.abs(x).max(axis=1, keepdims=True) / 7 return (np.clip(np.round(x / scale), -8, 7) * scale).reshape(w.shape)
def rel_err(a, b): return np.linalg.norm(a - b) / np.linalg.norm(a)
router_q, expert_q = round_4bit(router), round_4bit(expert)print(f"weight error: router {rel_err(router, router_q):.1%} expert {rel_err(expert, expert_q):.1%}")print(f"output error: router scores {rel_err(h @ router.T, h @ router_q.T):.1%}" f" expert output {rel_err(h @ expert.T, h @ expert_q.T):.1%}")
pick = lambda W: np.argsort(-(h @ W.T), axis=1)[:, :k]changed = np.array([len(set(a) - set(b)) for a, b in zip(pick(router), pick(router_q))])print(f"tokens sent to a different set of experts: {np.mean(changed > 0):.1%}")print(f"experts swapped when that happens: {changed[changed > 0].mean():.2f} of {k} on average")RunnableAll tracks
python ~/llm-course/router-rounding.pyOutput — what you should see
weight error: router 9.7% expert 9.7%output error: router scores 9.7% expert output 9.7%tokens sent to a different set of experts: 62.2%experts swapped when that happens: 1.18 of 8 on averageBoth matrices take the same relative error, and for the expert that is the whole story: its output is off by that much. The router’s scores are off by that much too, and for most tokens that is enough to swap an expert, usually one of the eight. Random weights put the scores closer together than a trained router may, so treat the rate as an illustration of the mechanism, not a prediction for any model. The swaps happen where the eighth and ninth scores are nearly tied, which is also where the gate weights are smallest.
Calibration only sees the experts the text routes to. An importance matrix records the activations
each expert receives, and an expert receives them only from tokens routed to it. llama-imatrix at
v0.4.0 warns that an entry “has partial data” and prints the percentage of experts that received any,
and the source names the cause: “this can happen with MoE models where some of the experts end up not
being exercised by the provided training data”. Part 16’s
calibration section
treats the choice of calibration text as a decision to write down.
Hybrids add small tensors that are never rounded, and need engine support first. The same
llama-quant.cpp function refuses every tensor with fewer than two dimensions, so norms, biases and
per-head scalars keep their precision, and a comment exempts the recurrent layers’ convolution: “do
not quantize Mamba/Kimi’s small conv1d weights”. None of that helps until an engine implements the
layer type. llama.cpp’s architecture list at v0.4.0 includes qwen3next, nemotron_h and
nemotron_h_moe, but the course’s model reference records no GGUF source for
either hybrid, so check that a format exists for your engine before planning work around one.
The three choices this section leaves you with, as rules:
| What you see or choose | What it means | What to do |
|---|---|---|
llama-imatrix warns “entry ‘…’ has partial data (NN.NN%)” for an ffn_*_exps tensor |
the calibration text routed no tokens to some experts | add more, and more varied, calibration text and re-run until no expert tensor is reported with partial data |
gguf-groups.py shows router in a type other than F32 in a community file |
router rounding can change which experts a token reaches | treat the file as an experiment: evaluate it with Part 16’s methods before use |
| Choosing a quantisation tier for a mixture of experts | the routed experts are about 95 per cent of the bytes | the expert type sets the file size, so compare tiers by the types gguf-groups.py prints for routed experts |
Why mixture of experts suits a unified-memory machine
Section titled “Why mixture of experts suits a unified-memory machine”Two tests per model and machine, both arithmetic:
fits : file bytes + cache at the context you allocate + reserve ≤ memory the GPU can useceiling : bandwidth ÷ (weight bytes read per token + cache bytes read per token)With the three models at a 32,768-token context, the reserves from the memory-budget lesson (10 GB on a 128 GB unified-memory machine, 1.5 GB on a discrete card) and bandwidth from the hardware reference:
| Machine (track) | GPU-usable memory | Bandwidth, GB/s | Qwen3-32B dense: 28.35 GB resident, 27.91 GB read per token | Qwen3-30B-A3B: 21.78 GB, 5.14 GB | gpt-oss-120b: 64.60 GB, 4.81 GB |
|---|---|---|---|---|---|
| DGX Spark (S) | 128 GB | 273 | fits; ceiling 9.8 tokens/s | fits; 53.1 | fits; 56.8 |
| Ryzen AI Max+ 395, 128 GB (X) | below 128 GB: the GTT limit the kernel sets on Linux (check it); about 96 GB on Windows | 256 | fits; 9.2 | fits; 49.8 | fits if the GPU can address 74.6 GB, reserve included; 53.3 |
| M4 Max, 128 GB (M) | 128 GB once the wired limit is raised | 546 | fits; 19.6 | fits; 106.2 | fits; 113.6 |
| RTX 5090 (N) | 32 GB | 1,792 | fits, 2.15 GB left after the reserve; 64.2 | fits; 348.6 | does not fit |
| RTX 4090 (N) | 24 GB | 1,008 | does not fit | fits, 0.72 GB left after the reserve; 196.1 | does not fit |
Every ceiling is arithmetic from the tensor tables and vendor bandwidth, before attention arithmetic, dequantisation and real-world bandwidth, all of which push a measurement lower; Part 5’s bandwidth lab and Part 6’s benchmark lab replace them with your numbers.
Down a column, the architecture’s speed-up is the same on every machine, 27.91 ÷ 5.14 or about 5.4 times at this context, because bandwidth cancels. What differs is where it lands: on the Spark and Ryzen AI Max+ rows the dense model’s ceiling is in single digits and on the M4 Max it is under 20, while the mixture of experts with more than three times the parameters is faster than it on all three. Across a row, capacity decides: gpt-oss-120b exists only on the unified-memory machines. They are short of bandwidth and long on capacity, and a mixture of experts spends capacity to save bandwidth, which is the one trade those machines can afford. The discrete cards are the mirror image.
Experts in system memory on a discrete card
Section titled “Experts in system memory on a discrete card”llama-server documents a middle path at v0.4.0: --cpu-moe, “keep all Mixture of Experts (MoE)
weights in the CPU”, and --n-cpu-moe N for the experts of the first N layers only. With the layers
otherwise offloaded, the always-read tensors and the cache stay on the card and the experts sit in
system memory, so every expert byte a token uses is read at system-memory bandwidth. Decode time per
token becomes two terms:
time per token = (always-read bytes + cache bytes) ÷ card bandwidth + expert bytes read ÷ system memory bandwidthFor gpt-oss-120b at 32k, taking system memory at Part 1’s ceiling for two channels of DDR5-5600:
| Card | On the card: resident, read per token | In system memory: resident, read per token | Card term | System-memory term, at 89.6 GB/s | Ceiling |
|---|---|---|---|---|---|
| RTX 5090, 1,792 GB/s | 3.51 GB, 2.90 GB | 61.07 GB, 1.91 GB | 1.62 ms | 21.30 ms | 43.6 tokens/s |
| RTX 4090, 1,008 GB/s | 3.51 GB, 2.90 GB | 61.07 GB, 1.91 GB | 2.88 ms | 21.30 ms | 41.4 tokens/s |
About nine tenths of the time is the system-memory term, so the card’s speed barely moves the result and the ceiling lands below the Spark’s. The arithmetic also leaves out the processor time for the expert multiplications and the PCIe crossings between the two pools, both of which lower a measurement further, and the machine needs 61 GB of system memory free for the experts alone. It is a legitimate way to run a model the card cannot hold; it is not a way to get the card’s bandwidth for it.
| Your machine | Short of | What the arithmetic favours |
|---|---|---|
| Unified memory, 96 GB or more | Bandwidth | The largest-total mixture of experts that fits with its cache; a dense model only where its read per token still meets the speed you need |
| Unified memory, 64 GB | Both | A 30B-class mixture of experts at Q4_K_M; gpt-oss-120b’s 64.6 GB does not fit beside an operating system |
| Discrete card, 24 to 32 GB | Capacity | Whatever fits entirely in the card, dense or mixture of experts; its bandwidth makes a dense 32B usable where it fits: at Q4_K_M, (card − 1.5 GB reserve − 19.76 GB file) ÷ 262,144 bytes leaves about 10,400 tokens of FP16 cache on a 24 GB card and about 41,000 on a 32 GB card |
| Discrete card plus 96 GB or more of system memory | Capacity, with a slow second pool | A large mixture of experts with --cpu-moe, at a ceiling set by system memory |
| Any, with long contexts | Cache | The fewest KV bytes per token: fewer full-attention layers, sliding windows, hybrids |
Capacity, active work and attention state are separate axes
Section titled “Capacity, active work and attention state are separate axes”Imagine two models with similar active parameter counts. One is dense; the other routes tokens through a subset of experts. Similar active work does not imply similar memory residency, because the expert model may need a much larger collection of weights available for routing. Under concurrent requests, different tokens may select different experts, changing reuse and traffic.
A hybrid architecture adds another distinction: not every layer necessarily stores a conventional full-attention KV history. Use the architecture’s actual layer types and state shapes when calculating memory. Multiplying a dense transformer’s per-layer KV formula by the total layer count can misestimate a hybrid model.
Before choosing an engine, inspect the checkpoint configuration and the engine’s support for that architecture and representation. Loading a dense sibling from the same family does not establish support for its expert or recurrent variant. Your acceptance test should cover loading, representative long prompts, generation and the application feature you need. A successful short completion is evidence of basic execution, not evidence that every architecture-specific optimisation or long-context path is correct.
A dense model reads nearly its whole file for every token, while a mixture of experts routes each token through a scoring function (softmax in Qwen3 and gpt-oss, sigmoid plus a selection bias in Nemotron 3 Nano), a top-k choice and renormalised gates to a few experts, keeps all of them resident, and so separates the bytes it holds from the bytes it reads. A hybrid’s recurrent layers keep a fixed-size state instead of a growing cache, at the price of exact recall, which is why it keeps a few attention layers. Prefer the architecture that relieves what your machine lacks, capacity or bandwidth. Part 11 returns to experts for training memory and Part 18 splits them across machines.
Check your understanding
Sources for this lesson
29 verified · checked 2026-09-12
- 01Switch Transformers: Scaling to Trillion Parameter Models with Simple and Efficient Sparsityarxiv.org/abs/2101.039612026-09-12
- 02DeepSeek-V3 Technical Report§ 2.1.2 DeepSeekMoE with Auxiliary-Loss-Free Load Balancingarxiv.org/html/2412.19437v22026-09-12
- 03Mamba: Linear-Time Sequence Modeling with Selective State Spacesarxiv.org/abs/2312.007522026-09-08
- 04Gated Delta Networks: Improving Mamba2 with Delta Rulearxiv.org/abs/2412.064642026-09-12
- 05Mixture of Experts Explained — Hugging Face bloghuggingface.co/blog/moe2026-09-12
- 06Qwen3-30B-A3B model cardhuggingface.co/Qwen/Qwen3-30B-A3B2026-09-12
- 07Qwen3-30B-A3B config.jsonhuggingface.co/Qwen/Qwen3-30B-A3B/raw/main/config.json2026-09-12
- 08Qwen3-235B-A22B config.jsonhuggingface.co/Qwen/Qwen3-235B-A22B/raw/main/config.json2026-09-12
- 09Qwen3-32B model cardhuggingface.co/Qwen/Qwen3-32B2026-09-12
- 10Qwen3-32B config.jsonhuggingface.co/Qwen/Qwen3-32B/raw/main/config.json2026-09-12
- 11Qwen3-Next-80B-A3B-Instruct model cardhuggingface.co/Qwen/Qwen3-Next-80B-A3B-Instruct2026-09-12
- 12Qwen3-Next-80B-A3B-Instruct config.jsonhuggingface.co/Qwen/Qwen3-Next-80B-A3B-Instruct/raw/main/config.json2026-09-12
- 13gpt-oss-120b model cardhuggingface.co/openai/gpt-oss-120b2026-09-12
- 14gpt-oss-120b config.jsonhuggingface.co/openai/gpt-oss-120b/raw/main/config.json2026-09-12
- 15NVIDIA-Nemotron-3-Nano-30B-A3B-BF16 model cardhuggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF162026-09-12
- 16NVIDIA-Nemotron-3-Nano-30B-A3B-BF16 config.jsonhuggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16/raw/main/config.json2026-09-12
- 17unsloth/Qwen3-30B-A3B-GGUF file listing (tensor table read from the Q4_K_M file header)huggingface.co/api/models/unsloth/Qwen3-30B-A3B-GGUF/tree/main2026-09-12
- 18unsloth/Qwen3-32B-GGUF file listing (tensor table read from the Q4_K_M file header)huggingface.co/api/models/unsloth/Qwen3-32B-GGUF/tree/main2026-09-12
- 19ggml-org/gpt-oss-120b-GGUF file listing (tensor table read from the MXFP4 file header)huggingface.co/api/models/ggml-org/gpt-oss-120b-GGUF/tree/main2026-09-12
- 20Transformers v5.16.1 — modeling_qwen3_moe.py (Qwen3MoeTopKRouter, load_balancing_loss_func)github.com/huggingface/transformers/blob/v5.16.1/src/transformers/models/qwen3_moe/modeling_qwen3_moe.py2026-09-12
- 21Transformers v5.16.1 — modeling_gpt_oss.py (GptOssTopKRouter)github.com/huggingface/transformers/blob/v5.16.1/src/transformers/models/gpt_oss/modeling_gpt_oss.py2026-09-12
- 22Transformers v5.16.1 — modeling_qwen3_next.py (Qwen3NextSparseMoeBlock, Qwen3NextGatedDeltaNet, torch_recurrent_gated_delta_rule)github.com/huggingface/transformers/blob/v5.16.1/src/transformers/models/qwen3_next/modeling_qwen3_next.py2026-09-12
- 23Transformers v5.16.1 — modeling_nemotron_h.py (NemotronHTopkRouter, Mamba-2 mixer and state shapes)github.com/huggingface/transformers/blob/v5.16.1/src/transformers/models/nemotron_h/modeling_nemotron_h.py2026-09-12
- 24llama.cpp v0.4.0 — llama-server README§ --swa-full, --cpu-moe, --n-cpu-moe, --ctx-checkpointsgithub.com/ggml-org/llama.cpp/blob/v0.4.0/tools/server/README.md2026-09-12
- 25llama.cpp pull request 15293 — server, add SWA checkpointsgithub.com/ggml-org/llama.cpp/pull/152932026-09-12
- 26llama.cpp v0.4.0 — src/llama-quant.cpp (tensor_allows_quantization)github.com/ggml-org/llama.cpp/blob/v0.4.0/src/llama-quant.cpp2026-09-12
- 27llama.cpp v0.4.0 — tools/imatrix/imatrix.cpp (partial-data warning)github.com/ggml-org/llama.cpp/blob/v0.4.0/tools/imatrix/imatrix.cpp2026-09-12
- 28llama.cpp v0.4.0 — ggml/src/ggml-common.h (block sizes) and ggml/include/ggml.h (ggml_type ids)github.com/ggml-org/llama.cpp/blob/v0.4.0/ggml/src/ggml-common.h2026-09-12
- 29curl man page (--range, --location, --fail, --create-dirs, --output)curl.se/docs/manpage.html2026-09-12
Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.