File size: 19,142 Bytes
a6c24d8
 
de24e6f
 
a6c24d8
bfda6f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de24e6f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
---
license: apache-2.0
base_model:
- google/gemma-4-26B-A4B-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. In `26B-A4B` it 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 sets `v_proj` to `None`. In `26B-A4B` it 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

```json
"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`:

```python
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`:

```python
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_proj` is **not** query-key only. On those five layers it
  edits values.
- An adapter on `v_proj + o_proj` has **no** access to values there. Only `o_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:

```python
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 real `nn.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_proj` in `target_modules` land on the *dense*
  `Gemma4TextMLP` (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:

```python
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 that `k_proj` on global layers goes to neither side?
- How much of a LoRA delta on `q_proj` / `k_proj` survives `q_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-A4B` config β€” https://huggingface.co/google/gemma-4-26B-A4B/blob/main/config.json
- `modeling_gemma4.py` β€” https://github.com/huggingface/transformers/blob/main/src/transformers/models/gemma4/modeling_gemma4.py
- `configuration_gemma4.py` (`attention_k_eq_v` docstring) β€” 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.