- On Gemma 4,
v_projdoes not exist on 5 of the 30 layers β and your LoRA config does not know it
On Gemma 4, v_proj does not exist on 5 of the 30 layers β and your LoRA config does not know it
This is a report created by Claude after hours and days spent training my LoRas for the Goetia merge. You might find this text useful when training your LoRas for the Gemma 4 MoE family.
If you fine-tune google/gemma-4-26B-A4B with a target_modules list containing
the string "v_proj", that adapter attaches to 25 layers, not 30. PEFT does not
warn you, because it only raises when nothing matched. Your adapter is smaller than
you think and asymmetric across depth, and the only visible sign is a
trainable-parameter count you probably did not hand-verify.
That is the short version. The long version is more interesting, because on the five
layers where v_proj is absent, k_proj is the value matrix β which means an
adapter you believe is "queries and keys only" is editing the value path, and an
adapter you believe is "values and output only" cannot reach values there at all.
I found this the hard way, by running a controlled experiment that turned out to be measuring something other than what I designed it to measure. Details at the end.
What is already known
The mechanism itself is not my discovery, and I want to be precise about that before adding anything.
The Gemma 4 technical report states it in one sentence, under long-context efficiency: "We improve memory efficiency by re-using keys as values in the global attention layers (except in E2B and E4B), i.e., values=keys." The official model card puts it as: "To optimize memory for long contexts, global layers feature unified Keys and Values, and apply Proportional RoPE (p-RoPE)."
Devansh has gone furthest in public, and states the consequence explicitly: "Gemma 4 eliminates the V projection in global layers. The key projection is computed, then reused directly as the value, with only RMSNorm applied on the value side as a differentiator in the forward pass." Maarten Grootendorst's visual guide covers K=V as a KV-cache trick. idlemachines noted the normalization asymmetry: "values get normalised too, but magnitude-only, with no learned scale."
And the flag is documented in transformers. Gemma4TextConfig carries a docstring
for attention_k_eq_v: "Whether keys and values share the same projection weights.
When True, the key projection output is reused as the value projection." One line,
in the API reference, with no consequences drawn.
One clarification worth making, because the public write-ups blur it: Gemma 4 has
two independent KV-saving mechanisms, and only one of them removes v_proj.
num_kv_shared_layersβ cross-layer sharing. Later layers reuse KV tensors from an earlier non-shared layer. This is the one the official HF launch post and Sebastian Raschka describe. In26B-A4Bit is set to 0, i.e. off.attention_k_eq_vβ within-layer sharing. On non-sliding layers, values are the key projection. This is the one that setsv_projtoNone. In26B-A4Bit is true.
If you read about Gemma 4 KV sharing and concluded it does not affect your adapter, you may have read about the wrong mechanism.
The part nobody seems to have written down
What I could not find anywhere is what this does to a LoRA config. Three consequences, and they compound.
The layout
"attention_k_eq_v": true,
"layer_types": ["sliding_attention", ..., "full_attention", ...]
layer_types puts full_attention at exactly indices 5, 11, 17, 23, 29 β every
sixth layer, and always the last. The other 25 are sliding_attention with a
1024-token window. Print the loaded model and the attention blocks are not uniform:
| layers 0β4, 6β10, 12β16, 18β22, 24β28 | layers 5, 11, 17, 23, 29 | |
|---|---|---|
q_proj |
2816 β 4096 | 2816 β 8192 |
k_proj |
2816 β 2048 | 2816 β 1024 |
v_proj |
2816 β 2048 | absent |
o_proj |
4096 β 2816 | 8192 β 2816 |
That is 115 attention projections, not 4 Γ 30 = 120. The shapes follow from
head_dim: 256 / num_key_value_heads: 8 on sliding layers versus
global_head_dim: 512 / num_global_key_value_heads: 2 on global ones.
This is not a broken checkpoint. model.safetensors.index.json of
google/gemma-4-26B-A4B itself has no self_attn.v_proj.weight key for those five
layers. From modeling_gemma4.py:
self.use_alternative_attention = config.attention_k_eq_v and not self.is_sliding
self.v_proj = (
nn.Linear(config.hidden_size, num_key_value_heads * self.head_dim, bias=config.attention_bias)
if not self.use_alternative_attention
else None
)
Note and not self.is_sliding. The flag is global, its effect is not.
Consequence 1: silent partial match
PEFT matches target_modules strings by module-name suffix. There is no
...layers.5.self_attn.v_proj to match, so nothing matches, and nothing is reported β
PEFT raises only when the whole list found nothing. So the popular seven-name list
gives you v_proj on 25 layers and q/k/o_proj on 30.
This is not hypothetical. Current Gemma 4 fine-tuning guides recommend exactly
["q_proj", "o_proj", "k_proj", "v_proj", "gate_proj", "up_proj", "down_proj"]
with no caveat about layer coverage. Community adapters use regexes like
(mlp|self_attn)\.(up|down|gate|q|k|v|o)_proj that treat v uniformly across depth.
Unsloth's guide uses target_modules="all-linear", which sidesteps the problem by
accident β it enumerates what exists rather than what you named β but does not
explain it. (Per oxen.ai, recent PEFT ships default Gemma 4 target modules scoped to
the language model via regex; that fixes vision-tower leakage, not the v_proj count.)
Consequence 2: on those layers, k_proj is the value matrix
Here is the exact order of operations in forward:
key_states = self.k_proj(hidden_states).view(hidden_shape)
value_states = self.v_proj(hidden_states).view(hidden_shape) if self.v_proj is not None else key_states
key_states = self.k_norm(key_states)
key_states = apply_rotary_pos_emb(key_states, cos, sin, unsqueeze_dim=2)
key_states = key_states.transpose(1, 2)
value_states = self.v_norm(value_states)
value_states = value_states.transpose(1, 2)
Look at where the fallback happens. value_states takes the raw output of
k_proj, before k_norm and before RoPE. Then it goes through v_norm, an RMSNorm
with with_scale=False. So one projection feeds two paths, normalized differently,
and positional information is applied to the key path only.
For anyone trying to reason about attention in terms of separable circuits β where the query-key product decides where to attend and the value-output product decides what gets written into the residual stream β this matters:
- An adapter on
q_proj + k_projis not query-key only. On those five layers it edits values. - An adapter on
v_proj + o_projhas no access to values there. Onlyo_proj. - On those five layers the two paths cannot be separated at all. They share one matrix.
And the five layers are the only ones that see the whole context; the other 25 are windowed at 1024 tokens. So anything you care about that involves long context β instruction following deep into a chat, re-reading a system prompt every turn, recalling something from 20k tokens back β lives precisely where the separation you are testing does not exist.
Consequence 3: QK-norm changes what a LoRA delta can even do
q_norm and k_norm are RMSNorm over head_dim, applied after the projection
and before RoPE:
query_states = self.q_proj(hidden_states).view(hidden_shape)
query_states = self.q_norm(query_states)
query_states = apply_rotary_pos_emb(query_states, cos, sin, unsqueeze_dim=2)
RMSNorm rescales each head's vector to unit RMS. So a LoRA delta on q_proj or
k_proj can change the direction of queries and keys but not their magnitude β
the normalization discards it. o_proj has no equivalent per-head constraint.
If you are comparing "adapt q/k" against "adapt v/o" at equal parameter budget on any QK-norm architecture β Gemma 3 and 4, Qwen3, OLMo 2/3 β this asymmetry is part of your result whether you account for it or not. I could not find any discussion of QK-norm interacting with LoRA. The closest published work is on controlling attention logits during pretraining (Anson & Aitchison 2025; Zhai et al., ΟReparam, ICML 2023), which treats the coupled magnitudes of Q and K as the thing to control β but says nothing about adapters.
Two smaller landmines in the same area
Both are mine as far as I can tell, and both are cheap to avoid:
Gemma4TextRouter.proj(2816 β 128) is a realnn.Linear, so a loose regex like.*proj$will catch the MoE router. Adapting expert routing is a far less predictable edit than adjusting attention. Exclude it explicitly.gate_proj/up_proj/down_projintarget_modulesland on the denseGemma4TextMLP(2816 β 2112) sitting next to the experts β that is the single shared expert β and not on the 128 routed ones. The names are absorbed by the wrong module, which is why the trainable-parameter count comes out plausible-looking but wrong.
For completeness, the neighbouring traps that are already well documented, so you
do not have to rediscover them: Gemma4TextExperts stores weights as stacked
nn.Parameter, so bitsandbytes cannot quantize them (Axolotl's expert-quantization
docs; bitsandbytes #1849) and PEFT needs target_parameters rather than
target_modules to reach them (PEFT docs; unsloth #4907 for the
"abnormally low trainable parameter count" symptom). The vision and audio towers reuse
the same leaf names, so an unanchored list leaks the adapter into them (oxen.ai;
Axolotl multimodal docs). On my first run part of the adapter landed on the vision
encoder and the loss flattened almost immediately.
Building the target list so it does what you wrote
Stop passing projection-name strings. Read the module paths off the live model and assert your assumptions:
import re
PROJ = ("q_proj", "k_proj", "v_proj", "o_proj")
LAYER_RE = re.compile(r"language_model\.layers\.(\d+)\.")
layer_mods = {}
for name, _ in model.named_modules():
if "language_model.layers." not in name: # anchor: excludes vision/audio towers
continue
if not name.endswith(PROJ):
continue
li = int(LAYER_RE.search(name).group(1))
layer_mods.setdefault(li, {})[name.rsplit(".", 1)[-1]] = name
global_layers = sorted(i for i, v in layer_mods.items() if "v_proj" not in v)
assert global_layers == [5, 11, 17, 23, 29], f"layer plan changed: {global_layers}"
assert sum(len(v) for v in layer_mods.values()) == 115
# A genuinely query-key-only arm: skip k_proj on global layers,
# where k_proj is also the value matrix.
targets = [
layer_mods[li][p]
for li in sorted(layer_mods)
for p in ("q_proj", "k_proj")
if p in layer_mods[li] and not (p == "k_proj" and li in global_layers)
]
FORBIDDEN = ("vision", "audio", "router", "experts", "embed", "lm_head",
"gate_proj", "up_proj", "down_proj")
assert not [t for t in targets if any(b in t.lower() for b in FORBIDDEN)]
Then re-audit after get_peft_model, because that is where a config can still
surprise you: check that the number of trainable tensors is exactly twice the number
of targets, and that the set of touched layers and projection types matches your plan.
Make both assert, not print. A printed warning scrolls off screen, and an hour of
A100 time goes with it. PEFT also ships get_model_status() / get_layer_status(),
which is the supported way to see what actually got wrapped.
How I ran into this, and why my own numbers do not settle anything
I wanted to know which half of attention carries writing style and which half is
responsible for a fine-tune losing its grip on output format. So: two adapters, same
data (1415 train / 74 eval), same seed, r=32, alpha=64, lr 2e-5, 2 epochs, 354 steps,
QLoRA 4-bit, one A100 80GB. One on v_proj + o_proj, one on q_proj + k_proj.
A: v_proj + o_proj |
B: q_proj + k_proj |
|
|---|---|---|
| targets | 55 | 60 |
| trainable params | 11,182,080 | 11,796,480 |
| final eval loss | 1.9703 | 2.2056 |
| mean token accuracy | 54.96 % | 51.38 % |
A is below B at every eval checkpoint, monotonically; both plateau; B has 5.5 % more trainable parameters and still loses.
I am not asking you to believe that means anything, for five reasons.
It reproduces a 2021 result. "The value/output side beats the query/key side at equal budget" is Table 5 of the original LoRA paper: on WikiSQL, Wq 70.4 / Wk 70.0 versus Wv 73.0 / Wo 73.2, and Wq+Wk 71.4 versus Wq+Wv 73.7. Yao et al. (IJCAI 2025) added the mechanism: the gradient with respect to W_K contains W_Q, which is near zero early in training, so Q and K are multiplicatively suppressed while V is not.
The split is not the split I thought it was. That is this whole article. Arm B edited values on five layers; arm A never reached values there.
The gap is over-determined. Beyond that, QK-norm handicaps arm B structurally, and
my base started at loss 7.43 on this data β very far off. When the base is that far
from the target distribution, the run mostly measures which arm can move the output
distribution fastest, and that favours o_proj, which writes straight into the
residual stream, over q/k, which only reshape a softmax.
My base was not clean, and the contamination is exactly on the seam I was testing.
The base is a 15-way MoE merge followed by abliteration. I went back and checked what
the abliteration tool actually modifies: attn.o_proj only, on layers 14β26, by
unconstrained L-BFGS optimization of the matrix rather than a rank-1 projection. So
o_proj had been surgically rewritten in 13 of 30 layers before I started, while
q/k/v were untouched. Arm A trains on top of rewritten matrices, arm B on top of
pristine ones. I cannot predict the direction of that bias β a rewritten o_proj
could be easier or harder to adapt further β but a comparison with a systematic
asymmetry like that is not a fair one. Worth stating plainly: if you benchmark
anything about attention on an abliterated model, find out which matrices were
abliterated first.
(On the other hand, the merge did not lose tensors: the merged checkpoint and
google/gemma-4-26B-A4B have byte-identical total_size β 51,611,872,412 β and the
shard files differ by 584 bytes, which is the size difference of the safetensors JSON
headers. The missing v_proj really is architectural.)
And my behavioural hypothesis was wrong. I expected the value/output half to carry style and the query/key half to be responsible for breaking format. What I saw was the opposite arrangement: the value/output arm carries the style and breaks structured output sooner, while the query/key arm holds formatting but barely transfers style β it describes a character's voice instead of speaking in it. I report that as a negative result rather than dropping it, with the caveat it deserves: those behavioural observations are single generations per setting at one context depth, judged by me, compared at equal adapter weight rather than equal effect size. Equal weight is the wrong normalization when one arm is simply a stronger intervention per unit of weight. That is an anecdote, not a measurement.
What I would actually like to know
- Does the loss gap survive on a clean
google/gemma-4-26B-A4B, with three seeds, and with the arms rebuilt so thatk_projon global layers goes to neither side? - How much of a LoRA delta on
q_proj/k_projsurvivesq_norm/k_norm? If the answer is "not much", then a chunk of the folklore about which projections matter is really a statement about where the normalization sits β and that folklore predates QK-norm becoming standard. - Those five global layers, where W_V is literally W_K: good place to adapt, or bad? It is the only spot in the model where the two paths are physically tied, and I have no intuition for what a low-rank edit there does.
If you have hit consequence 1 without noticing, or if you have run any of this on a clean base, I would like to hear about it.
References
Architecture and code:
- Gemma 4 Technical Report β https://arxiv.org/html/2607.02770v1
- Gemma 4 model card (Google) β https://ai.google.dev/gemma/docs/core/model_card_4
google/gemma-4-26B-A4Bconfig β https://huggingface.co/google/gemma-4-26B-A4B/blob/main/config.jsonmodeling_gemma4.pyβ https://github.com/huggingface/transformers/blob/main/src/transformers/models/gemma4/modeling_gemma4.pyconfiguration_gemma4.py(attention_k_eq_vdocstring) β https://github.com/huggingface/transformers/blob/main/src/transformers/models/gemma4/configuration_gemma4.py- Gemma4 model docs β https://huggingface.co/docs/transformers/model_doc/gemma4
Prior public description of K=V:
- Devansh, "Google's Gemma 4 is Weirder than you Realize" β https://machine-learning-made-simple.medium.com/googles-gemma-4-is-weirder-than-you-realize-17d00d95b0d5
- Maarten Grootendorst, "A Visual Guide to Gemma 4" β https://newsletter.maartengrootendorst.com/p/a-visual-guide-to-gemma-4
- idlemachines, "Gemma 4 is not your standard transformer" β https://idlemachines.co.uk/essays/gemma4-architecture
LoRA target-module selection:
- Hu et al., LoRA, 2021, Table 5 / Β§7.1 β https://arxiv.org/abs/2106.09685
- Yao et al., IJCAI 2025, unequal importance of attention matrices β https://arxiv.org/abs/2410.02247
- Elhage et al., 2021, query-key and output-value circuits β https://transformer-circuits.pub/2021/framework/index.html
- PEFT LoRA developer guide β https://huggingface.co/docs/peft/developer_guides/lora
Already-documented neighbouring traps:
- Axolotl, MoE expert quantization β https://docs.axolotl.ai/docs/expert_quantization.html
- bitsandbytes #1849, fused MoE weights β https://github.com/bitsandbytes-foundation/bitsandbytes/issues/1849
- unsloth #4907, low trainable-parameter count on Gemma 4 MoE β https://github.com/unslothai/unsloth/issues/4907
- oxen.ai, Gemma 4 fine-tuning pipeline β https://ghost.oxen.ai/writing-a-fine-tuning-and-deployment-pipeline-isnt-as-easy-as-it-looks-gemma-4-version/
- Axolotl multimodal docs β https://docs.axolotl.ai/docs/multimodal.html
Attention-logit control (background for consequence 3):
- Anson & Aitchison, "Controlling changes to attention logits", 2025 β https://arxiv.org/abs/2511.21377
- Zhai et al., ΟReparam, ICML 2023 β https://proceedings.mlr.press/v202/zhai23a/zhai23a.pdf
Code excerpts are from huggingface/transformers, Apache License 2.0. Gemma is
provided under and subject to the Gemma Terms of Use found at ai.google.dev/gemma/terms.
"Gemma 4" is used descriptively; this article is not affiliated with or endorsed by Google.