~/satyajit

MoVA: routing the value vectors, and why the KV cache does not notice

mdjsonmcp

2026-09-22 · 15 min · llm · attention · mixture-of-experts · kv-cache · inference-optimization · explainer

Attention has been picked apart from every direction except one. Queries got grouped (GQA), keys and values got compressed into a shared latent (MLA), keys got reconstructed from values so the key stream could be dropped, whole layers got replaced with sliding windows or linear recurrences. The feed-forward network, meanwhile, got sparsified twenty different ways and every open frontier model now ships a mixture of experts.

What nobody had shipped is MoE sparsity inside attention. K2-Horizon-MoVA-36B-A4B, from IFM, does exactly that: it deletes v_proj and puts a router and sixty-four value experts in its place.

IFM/K2-Horizon-MoVA-36B-A4B@7730b92 · snapshot 2026-09-22
parameters
37.44B
repo size
5.17 TB
architecture
K2HorizonForCausalLM
task
text-generation
library
transformers
license
apache-2.0
safetensors
48 shards
largest file
4.16 GB
files
63
downloads
16.3K
likes
363
languages
en
parameters by dtype
BF1637.44B
k2-horizon36bmovamoeopen-weightsifm

repo last modified 2026-09-21

The card above reports 37.44B parameters — my own count from the headers agrees to the parameter — and 5.17 TB of repository storage, which is not a typo. The repository carries every intermediate checkpoint of every training stage as a git branch. More on that at the end; it is the best thing about this release.

The architectural claim is a short list, and the third item is the one that should make you stop:

That reads wrong on its face. If every token selects a different mixture of value experts, then every token has a different value vector, and the cache has to store something per token. So either the cache grows, or the routing is not really per-token, or it is doing something clever. The repository ships modeling_k2_horizon.py, so the question is answerable in an afternoon rather than arguable.

It is doing something clever, and the clever thing is an ordering.

Where the routing happens

Here is K2HorizonMoVAAttention.forward, reduced to the four lines that matter:

# modeling_k2_horizon.py
router_logits = F.linear(flat_hidden_states, self.v_router.weight)   # 2560 -> 64
routing_weights, selected = calc_router_weights(
    router_logits, self.v_router.bias, "sigmoid", top_k=4, scaling_factor=2.5)
 
mixed_value_states = combine_routed_experts(                          # Σ wₖ·SiLU(Vₖx)
    flat_hidden_states, routing_weights, selected, self.v_experts, activation=F.silu)
 
value_states = mixed_value_states.view(*input_shape, -1, 128).transpose(1, 2)
# ... rope on q and k ...
key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx)

self.v_experts is a ModuleList of 64 nn.Linear(2560, 8 * 128) — sixty-four complete GQA value projections. The router picks four, runs them, applies SiLU to each, and weights them by the renormalised sigmoid scores scaled by 2.5. The result, mixed_value_states, is (tokens, 1024). Reshaped, it is (batch, 8 heads, tokens, 128).

Which is exactly the shape a plain GQA v_proj would have produced.

K2HorizonMoVAAttention.forward · one layer, one token
KV cachex2560q_proj32 × 128k_proj8 × 128K8 × 128v_router2560 → 64sigmoid · top-4V0 · SiLUV1 · SiLUV2 · SiLUV3 · SiLU4 of 64 experts, each 2560 → 1024V8 × 128Σ wₖ · SiLU(Vₖx)FlashAttnunmodified× softplusgate_projo_proj
The routed mixture is collapsed to one 8 × 128 tensor beforepast_key_values.update is called, so the cache stores exactly the GQA shapes and the attention kernel never learns that routing happened. Transcribed from modeling_k2_horizon.py.

That is the whole answer. The mixture is collapsed before the cache is touched. past_key_values.update receives one 8 × 128 value tensor per token, indistinguishable in shape, dtype and layout from what an ordinary grouped-query model writes. The router's decision is a property of how the value was computed, not of what is stored — it is baked into the numbers and then thrown away.

Everything downstream follows from that. The attention kernel is called through ALL_ATTENTION_FUNCTIONS[self.config._attn_implementation] with no special casing at all, so FlashAttention, SDPA, a paged kernel, or a sparse-attention backend all work unmodified — they are being handed a GQA tensor. The num_key_value_groups = 32 / 8 = 4 repeat is the standard one. The sliding_window argument is passed straight through. All three compatibility claims are true, and they are true for the same single reason.

So what does it cost?

Not cache. Weights.

K2-Horizon-MoVA-36B-A4B · summed from the safetensors headers
where the parameters arestoredactive / token
MoE feed-forward experts
top-8 of 100 · 45 layers
26.54B2.12B
MoVA value experts
top-4 of 64 · 45 layers
7.55B472M
attention q / k / o
dense, all 48 layers
1.13B1.13B
attention output gate
softplus, per head
503M503M
shared expert
265M265M
embedding + output head
1.28B1.28B
dense FFN, layers 0-2
142M142M
routers, norms, dense v_proj
27M27M
total37.44B5.95B
value projection: 64× the weights of a plain GQA v_proj, 4× the decode multiply-accumulates · 0× the cache
Active counts assume every routed expert is a distinct weight read, which is the worst case for a single token and the right case for a batch. The card names 4B active and 36B stored; counting the output head but not the embedding lookup gives 5.31B, and counting neither gives 4.66B.

Forty-five of the forty-eight layers are MoVA layers (mlp_only_layers: [0, 1, 2] keeps the first three dense, with an ordinary v_proj of 2.62M parameters each). Each MoVA layer stores 64 × 2560 × 1024 = 167.8M parameters of value experts where a GQA layer would store 2.6M. Across 45 layers that is 7,549,747,200 parameters — 20.2% of the entire model — sitting in the value projection alone.

Per token it runs four of them: 10.49M multiply-accumulates against a plain GQA v_proj's 2.62M, plus a 164K-parameter router. So the trade is:

weightsdecode FLOPsKV cache
MoVA vs plain GQA v_proj64×

That is the honest statement of the design, and it is a good one. Parameters are cheap to store and expensive only when you read them; cache is expensive at every context length and cannot be amortised across a batch. Moving cost from the second column to the first is the same bet the whole MoE field made, applied where nobody had applied it.

What it is not is free. A 4× multiplier on the value projection at decode time is real work in the regime where decode is memory-bound, and four experts is four scattered weight reads instead of one contiguous one. combine_routed_experts in the released code is the naive loop — one index_add_ per touched expert — which is fine for correctness and is not what you would serve; the SGLang recipe passes xllm_source_router_gemm_partitions to get a fused path.

"No additional cache cost" and "a small cache" are different sentences

The claim is exact. It is also worth seeing next to the absolute number, because K2-Horizon spent its architecture budget on the opposite axis from everyone else this month.

use_sliding_window is false, sliding_window is null. All 48 layers are full attention. 8 KV heads × 128 dims × 2 tensors × 48 layers × 2 bytes = 196,608 bytes = 192 KiB per token.

K2-Horizon-MoVA-36B-A4B96.0 GiB
48 full-attention layers · 8 KV heads × 128
matched plain GQA96.0 GiB
same 48 layers, same 8 × 128, ordinary v_proj
MiMo-V2.6-Pro25.0 GiB
10 global + 60 sliding-window at 128 · 8 KV heads × 160
192 KiB per token · identical to the matched GQA row, which is the claim · and 3.8× the hybrid at this length
Per sequence, one batch element, from each model’s ownconfig.json. The MiMo row caches a 160-wide entry per KV head because its query/key head dim is 192 and its value head dim is 128; the exact number does not change the shape of the comparison, which is 60 layers that stop growing at 128 tokens.

At the model's native 512K context that is 96 GiB of KV cache for a single sequence in BF16, 48 GiB in FP8 — several times the 72 GB of weights. MoVA does not make that worse than GQA, which is the claim and it holds. But three weeks later MiMo-V2.6 shipped 60 of its 70 layers as sliding-window attention with a 128-token window, so 6/7 of its layers stop growing past 128 tokens.

Two releases, three weeks apart, making opposite bets about where attention should be cheap: IFM buys representational capacity in the value path and pays full freight on the cache; Xiaomi buys cache and pays with locality. Neither is wrong. But a reader seeing "no additional KV cache cost" should not come away thinking this is a long-context-cheap model. It is a long-context-capable model with a 2020-shaped cache.

The paired comparison that would settle it

The one experiment I want and cannot find is the obvious one: K2-Horizon-36B with MoVA against K2-Horizon-36B with a plain v_proj, matched on tokens and everything else. Without it, MoVA's contribution is confounded with 22.9T pretraining tokens, four midtraining stages and two SFT phases.

The family gives a partial substitute, because IFM shipped K2-Horizon-32B, K2-Horizon-7B, K2-Horizon-3.7B and K2-Horizon-0.9B alongside it. On Artificial Analysis' Intelligence Index:

Artificial Analysis Intelligence Index v4.3 · the K2-Horizon family and its named comparators
K2-Horizon-375B-A23B
30.5
K2-Horizon-MoVA-36B-A4B
25.3
Nemotron 3 Ultra 550B-A55B
22.93
G9v3-39A5B
21.83
K2-Horizon-7B (dense)
20.6
Gemma 4 31B-it
18.98
Muse Glimmer 30B
17.48
K2-Horizon-3.7B (dense)
15.61
Qwen3.6-35B-A3B
15.23
Nemotron 3 Super 120B-A12B
12.83
010203040

The 25.30 is confirmed on AA's own board, and the interesting row is the top one: the 375B sibling, more than ten times the stored parameters and nearly five times the active ones, scores 30.50. The 36B recovers 83% of its family's flagship index at a tenth of the size. That is the most controlled comparison available and it is a strong result — stronger, I think, than beating another lab's 550B model, because at least the data and the recipe are held roughly fixed.

The size comparison, checked

The card's own wording is "outscores open weight dense (approximately 30B model size) and MoE models up to 15× its size." The largest model in its comparison table and figure is Nemotron 3 Ultra 550B-A55B, and 550 / 36 = 15.3. So the 15× is exactly right and names a real model.

I was asked about a claim of "over 20× the total parameters." Nothing in the model card, the benchmark figure or the GGUF card says 20×, and no comparator in either the table or the figure is above 15.3× — the list is Nemotron 3 Ultra 550B-A55B, Nemotron 3 Super 120B-A12B, G9v3-39A5B, Qwen3.6-35B-A3B, Muse Glimmer 30B, Gemma 4 31B-it, and GPT 5.6 luna (medium) as the closed baseline. If 20× is circulating, it did not come from the artifact.

The other thing to say about that table is that K2-Horizon leads two of its nine rows.

Six grouped bar charts comparing K2-Horizon-MoVA-36B-A4B against Nemotron 3 Ultra 550B-A55B, Nemotron 3 Super 120B-A12B, G9v3-39A5B, Qwen3.6-35B-A3B, Muse Glimmer 30B, Gemma 4 31B-it and GPT 5.6 luna. K2-Horizon leads tau3-Banking at 26.8 and Terminal-Bench 2.1 at 58.6, and trails on Humanity's Last Exam at 25.2, AA-LCR at 66.3, SciCode at 38.9 and CritPt at 2.1.
The card's own figure. Two clear wins on the agentic panels, four panels where the 36B is mid-pack or last; the card's markdown table adds GPQA Diamond (80.8 against Nemotron Ultra's 86.7) and two AA-Omniscience rows, all losses (model card, benchmark figure).

That is not damning — an aggregate index can be won by a model that loses most individual rows, because the index averages across ten evaluations and these are six of them. But "outscores models up to 15× its size" is doing a lot of work for a table where the 15× model wins seven of nine printed comparisons. The precise version is: the 36B wins the index, and it wins it on the agentic evaluations while losing the knowledge and science ones, which is exactly the profile you would predict for a model with 4B active parameters and a 512K context.

The zero nobody has mentioned

Artificial Analysis' record for this model carries "terminalBench40": 0 — a literal zero on Terminal-Bench 4.0, one of the ten evaluations that make up the 25.30 index. The card reports 58.6 on Terminal-Bench 2.1, which is a strong score, and does not report 4.0 at all.

A zero on an agentic benchmark is almost never a capability result; it is a harness result — a tool-call format the scaffold cannot parse, a container that never starts, a reasoning-effort setting that runs out of budget. The card is unusually specific about exactly the things that would cause it: always pass reasoning_effort: "high", enable the k2_horizon reasoning parser, enable the k2_horizon tool-call parser, and note that the model supports json, xml and xml_typed tool-call formats with xml as the default. Miss any of those and a terminal agent produces text that no harness will execute.

Two things make this worth stating rather than dismissing. First, the headline index of 25.30 is achieved with a zero in it — whatever is broken, fixing it can only move the number up. Second, Nemotron 3 Ultra, the 550B comparator, scores 1/198 on the same benchmark, so this is not a K2-Horizon peculiarity; it is a harness compatibility problem that eats open-weight models on Terminal-Bench 4.0 specifically, which is the sort of thing a harness comparison exists to warn about.

The release around the model

I have been hard on the benchmark framing, so let me be equally direct that the release is the most open one I have read this year, and by a distance.

Five training-loss panels for K2-Horizon-36B. Pretraining falls from 3.6 to about 1.1 over 22.9 trillion tokens. Midtraining stage 1 falls from 0.815 to 0.73 over 1.1 trillion tokens at 32K sequence length. Stage 2 falls from 0.566 to 0.517 over 498 billion tokens. Stage 3 is flat around 0.50 over 110 billion tokens and visibly noisier. Stage 4 falls from 0.479 to 0.438 over 199 billion tokens.
Training loss by stage, reconstructed from the public W&B run. The discontinuities between panels are the context extensions and data-mix changes, not instability; stage 3 at 512K is the noisiest because the batch holds far fewer sequences (model card, assets/k2-horizon-36b-training-loss-vs-tokens.png).

Publishing that figure at all is a choice. The stage-3 panel is visibly flat and noisy — 110B tokens at 512K sequence length that barely move the loss — and most labs would have cropped it.

The take

MoVA is a real idea, cleanly implemented, and the compatibility claims survive reading the code because they all reduce to one design decision: collapse the mixture before the cache, and everything downstream stays ordinary. That is worth stealing. It is also the answer to the objection in the title — the cache stores the mixture's output, one vector per token, exactly as GQA does, and the routing information is not retained because nothing downstream needs it.

The cost is 20% of the model's parameters in a projection that used to be 0.3% of it, and a 4× multiplier on that projection's decode work. Whether that buys more than spending the same parameters on eight more FFN experts is the experiment IFM has not run, or at least has not published, and it is the only experiment that would settle whether MoVA is a contribution or a decoration.

What I would want next, in order: the ablation; a fused decode kernel measured against a matched GQA baseline on the same hardware; and someone to find out why this model scores zero on Terminal-Bench 4.0, because a 4B-active model that does 58.6 on 2.1 should not be doing that, and the index it is already winning has that zero inside it.

What would change my mind

5 claims above, and what would falsify each

  1. MoVA's routed value mixture is collapsed before the KV cache is written, so the cache is shape-identical to GQA.

    Read K2HorizonMoVAAttention.forward in modeling_k2_horizon.py: mixed_value_states is reshaped and passed to past_key_values.update as a single (batch, 8, tokens, 128) tensor. If a production serving path caches the router indices and the per-expert outputs instead — to recompute or to re-mix on a later pass — then the cache does grow in that path and the claim holds only for the reference implementation.

  2. K2-Horizon-MoVA-36B-A4B stores 37.44B parameters and activates 5.95B per token, of which the value experts are 7.55B stored and 0.47B active.

    Summed from the safetensors headers of all 48 shards. Load the model and print sum(p.numel() for p in model.parameters()). The card says 36B and 4B; those are round numbers under some convention — the closest I can construct is 4.66B, excluding both the embedding lookup and the output head. If someone shows a convention that lands on 4.0B, my accounting is missing a term.

  3. No comparator in the release is more than 15.3× K2-Horizon's total parameters.

    The full comparator list is in the card's table and its benchmark figure: 550B, 120B, 39B, 35B, 30B, 31B, plus one closed model. If IFM publishes a comparison against a 750B-plus model, or the forthcoming technical report adds one, the 20× framing has a source and this section is out of date.

  4. Artificial Analysis records K2-Horizon-MoVA-36B-A4B at 0 on Terminal-Bench 4.0.

    Read from the embedded terminalBench40 field on AA's model page on 2026-09-22, where it is the number 0 rather than null — other models on the same page carry null where the evaluation was not run, which is why I read it as measured. If AA confirms it means "not evaluated", the section is wrong and the index of 25.30 is computed over nine evaluations rather than ten with a zero.

  5. MoVA's benefit cannot be separated from the rest of the recipe without an ablation.

    Falsified the moment IFM publishes a matched mova_num_experts: 0 run at the same scale and token budget — the config supports it, the class falls back to K2HorizonAttention with a plain v_proj, and the intermediate checkpoints make a shorter matched run cheap. If such a run exists in the forthcoming technical report, this is the section to delete.


The mechanism is Measured from modeling_k2_horizon.py and config.json in IFM/K2-Horizon-MoVA-36B-A4B, read on 2026-09-22. Parameter counts are Measured: summed from the safetensors headers of all 48 shards, fetched by HTTP range request. KV-cache figures are arithmetic on the two models' configs. Benchmark figures are Reported — from the model card's own table and figure, and from Artificial Analysis for the index and the comparator scores. No model was run; there is no GPU here. Both figures are IFM's; the diagram, the parameter table and the cache ledger are mine. Related: Grouped Value Attention for the other 2026 attempt to change what the value stream is for, Mixture of Experts from scratch for the routing machinery MoVA borrows, and MiMo-V2.6 for the release three weeks later that made the opposite bet.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "MoVA: routing the value vectors, and why the KV cache does not notice", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026k2horizonmova,
  author = {Satyajit Ghana},
  title  = {MoVA: routing the value vectors, and why the KV cache does not notice},
  url    = {https://ai.thesatyajit.com/articles/k2-horizon-mova},
  year   = {2026}
}
share