Views
No views yet
v_proj does not exist on 5 of the 30 layers — and your LoRA config does not know itThis 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.
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.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.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.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.1"attention_k_eq_v": true,
2"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 |
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.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:1self.use_alternative_attention = config.attention_k_eq_v and not self.is_sliding
2self.v_proj = (
3 nn.Linear(config.hidden_size, num_key_value_heads * self.head_dim, bias=config.attention_bias)
4 if not self.use_alternative_attention
5 else None
6)and not self.is_sliding. The flag is global, its effect is not.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.["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.)k_proj is the value matrixforward:1key_states = self.k_proj(hidden_states).view(hidden_shape)
2value_states = self.v_proj(hidden_states).view(hidden_shape) if self.v_proj is not None else key_states
3
4key_states = self.k_norm(key_states)
5key_states = apply_rotary_pos_emb(key_states, cos, sin, unsqueeze_dim=2)
6key_states = key_states.transpose(1, 2)
7
8value_states = self.v_norm(value_states)
9value_states = value_states.transpose(1, 2)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.q_proj + k_proj is not query-key only. On those five layers it
edits values.v_proj + o_proj has no access to values there. Only o_proj.q_norm and k_norm are RMSNorm over head_dim, applied after the projection
and before RoPE:1query_states = self.q_proj(hidden_states).view(hidden_shape)
2query_states = self.q_norm(query_states)
3query_states = apply_rotary_pos_emb(query_states, cos, sin, unsqueeze_dim=2)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.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.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.1import re
2
3PROJ = ("q_proj", "k_proj", "v_proj", "o_proj")
4LAYER_RE = re.compile(r"language_model\.layers\.(\d+)\.")
5
6layer_mods = {}
7for name, _ in model.named_modules():
8 if "language_model.layers." not in name: # anchor: excludes vision/audio towers
9 continue
10 if not name.endswith(PROJ):
11 continue
12 li = int(LAYER_RE.search(name).group(1))
13 layer_mods.setdefault(li, {})[name.rsplit(".", 1)[-1]] = name
14
15global_layers = sorted(i for i, v in layer_mods.items() if "v_proj" not in v)
16assert global_layers == [5, 11, 17, 23, 29], f"layer plan changed: {global_layers}"
17assert sum(len(v) for v in layer_mods.values()) == 115
18
19# A genuinely query-key-only arm: skip k_proj on global layers,
20# where k_proj is also the value matrix.
21targets = [
22 layer_mods[li][p]
23 for li in sorted(layer_mods)
24 for p in ("q_proj", "k_proj")
25 if p in layer_mods[li] and not (p == "k_proj" and li in global_layers)
26]
27
28FORBIDDEN = ("vision", "audio", "router", "experts", "embed", "lm_head",
29 "gate_proj", "up_proj", "down_proj")
30assert not [t for t in targets if any(b in t.lower() for b in FORBIDDEN)]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.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 % |
o_proj, which writes straight into the
residual stream, over q/k, which only reshape a softmax.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.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.)google/gemma-4-26B-A4B, with three seeds, and
with the arms rebuilt so that k_proj on global layers goes to neither side?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.google/gemma-4-26B-A4B config — 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_v docstring) — https://github.com/huggingface/transformers/blob/main/src/transformers/models/gemma4/configuration_gemma4.pyhuggingface/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.