|
Download README.md from SubMaroon/gemma4-lora-traps: direct link, hf CLI and curl.
- Browser
- Download file 19.1 kB
-
https://huggingface.co/SubMaroon/gemma4-lora-traps/resolve/main/README.md
- Command line
-
hf download hf://SubMaroon/gemma4-lora-traps/README.md
-
curl -L -o README.md https://huggingface.co/SubMaroon/gemma4-lora-traps/resolve/main/README.md
19.1 kB
| 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. |