[Bugfix][Gemma] Conditionally create KV projections/norms on KV-shared layers (#54917)

Signed-off-by: Josephasafg <ajgard7@gmail.com> Co-authored-by: minyichen <96753147+cyc00518@users.noreply.github.com> Co-authored-by: aoshen02 <aoshen@inferact.ai>

Asaf Gardin committed Sep 7, 2026 at 17:04 UTC f2d45f26bd6a2c841ffbd0030ebaefe87c274e58
3 files changed +222 -99
tests/models/language/generation/test_gemma.py
+66
@@ -10,6 +10,11 @@ import torch
10 from vllm.config import VllmConfig
11 from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
12 from vllm.model_executor.models import gemma
13 +from vllm.model_executor.models.gemma3n import (
14 + Gemma3nTextModel,
15 + _kv_sharing_weights_mapper,
16 +)
17 +from vllm.model_executor.models.gemma4 import Gemma4Model
18
19 MODELS = ["google/gemma-2b", "google/gemma-2-2b", "google/gemma-3-4b-it"]
20
@@ -51,6 +56,67 @@ def test_checkpoint_lm_head_can_override_tied_config(monkeypatch) -> None:
56 assert torch.equal(model.lm_head.weight[:4], lm_head_weight)
57
58
59 +@pytest.mark.cpu_test
60 +def test_gemma4_kv_shared_layer_loads_plain_q_proj() -> None:
61 + """KV-shared layers have q_proj instead of a packed qkv_proj; their
62 + redundant K/V tensors in original checkpoints have no parameter and are
63 + skipped rather than failing the load."""
64 + model = torch.nn.Module()
65 + model.config = SimpleNamespace(num_experts=0)
66 + model.start_layer, model.end_layer = 0, 2
67 + model.layers = torch.nn.ModuleList([torch.nn.Module(), torch.nn.Module()])
68 + for layer in model.layers:
69 + layer.self_attn = torch.nn.Module()
70 + model.layers[0].self_attn.qkv_proj = torch.nn.Linear(2, 6, bias=False)
71 + model.layers[1].self_attn.q_proj = torch.nn.Linear(2, 2, bias=False)
72 + shards: list[str] = []
73 + model.layers[0].self_attn.qkv_proj.weight.weight_loader = (
74 + lambda param, weight, shard_id: shards.append(shard_id)
75 + )
76 +
77 + q_weight = torch.full((2, 2), 2.0)
78 + weights = [
79 + (f"layers.{i}.self_attn.{tensor}.weight", q_weight)
80 + for i in (0, 1)
81 + for tensor in ("q_proj", "k_proj", "v_proj")
82 + ] + [("layers.1.self_attn.k_norm.weight", torch.ones(2))]
83 + loaded = Gemma4Model.load_weights(cast(Gemma4Model, model), weights)
84 +
85 + assert shards == ["q", "k", "v"]
86 + assert "layers.1.self_attn.q_proj.weight" in loaded
87 + assert not any(name.startswith("layers.1.self_attn.k") for name in loaded)
88 + assert not any(name.startswith("layers.1.self_attn.v") for name in loaded)
89 + assert torch.equal(model.layers[1].self_attn.q_proj.weight, q_weight)
90 +
91 +
92 +@pytest.mark.cpu_test
93 +def test_gemma3n_kv_shared_layer_mapper() -> None:
94 + """Only non-shared layers pack q/k/v into qkv_proj; KV-shared layers keep
95 + q_proj and drop the redundant K/V tensors original checkpoints ship."""
96 + config = SimpleNamespace(num_hidden_layers=4, num_kv_shared_layers=2)
97 + mapper = Gemma3nTextModel.hf_to_vllm_mapper | _kv_sharing_weights_mapper(config)
98 + weights = [
99 + (f"layers.{i}.self_attn.{tensor}.weight", torch.empty(0))
100 + for i in (1, 3)
101 + for tensor in ("q_proj", "k_proj", "v_proj", "k_norm", "o_proj")
102 + ]
103 +
104 + mapped = [
105 + (name, getattr(weight, "shard_id", None))
106 + for name, weight in mapper.apply(weights)
107 + ]
108 +
109 + assert mapped == [
110 + ("layers.1.self_attn.qkv_proj.weight", "q"),
111 + ("layers.1.self_attn.qkv_proj.weight", "k"),
112 + ("layers.1.self_attn.qkv_proj.weight", "v"),
113 + ("layers.1.self_attn.k_norm.weight", None),
114 + ("layers.1.self_attn.o_proj.weight", None),
115 + ("layers.3.self_attn.q_proj.weight", None),
116 + ("layers.3.self_attn.o_proj.weight", None),
117 + ]
118 +
119 +
120 @pytest.mark.parametrize("model", MODELS)
121 def test_dummy_loader(vllm_runner, monkeypatch, model: str) -> None:
122 with monkeypatch.context() as m:
vllm/model_executor/models/gemma3n.py
+79 -37
@@ -307,15 +307,36 @@ class Gemma3nAttention(nn.Module):
307 self.q_size = self.num_heads * self.head_dim
308 self.kv_size = self.num_kv_heads * self.head_dim
309
310 - self.qkv_proj = QKVParallelLinear(
311 - hidden_size,
312 - self.head_dim,
313 - self.total_num_heads,
314 - self.total_num_kv_heads,
315 - bias=config.attention_bias,
316 - quant_config=quant_config,
317 - prefix=f"{prefix}.qkv_proj",
310 + layer_idx = extract_layer_index(prefix)
311 + first_kv_shared_layer_idx = (
312 + config.num_hidden_layers - config.num_kv_shared_layers
313 )
314 + self.is_kv_shared_layer = layer_idx >= first_kv_shared_layer_idx
315 +
316 + if self.is_kv_shared_layer:
317 + # K/V come from the target layer's cache, so like HF this layer
318 + # only has q_proj (and no k_norm/v_norm).
319 + self.q_proj = ColumnParallelLinear(
320 + hidden_size,
321 + self.total_num_heads * self.head_dim,
322 + bias=config.attention_bias,
323 + quant_config=quant_config,
324 + prefix=f"{prefix}.q_proj",
325 + )
326 + else:
327 + self.qkv_proj = QKVParallelLinear(
328 + hidden_size,
329 + self.head_dim,
330 + self.total_num_heads,
331 + self.total_num_kv_heads,
332 + bias=config.attention_bias,
333 + quant_config=quant_config,
334 + prefix=f"{prefix}.qkv_proj",
335 + )
336 + self.k_norm = RMSNorm(hidden_size=self.head_dim, eps=config.rms_norm_eps)
337 + self.v_norm = RMSNorm(
338 + hidden_size=self.head_dim, eps=config.rms_norm_eps, has_weight=False
339 + )
340 self.o_proj = RowParallelLinear(
341 self.total_num_heads * self.head_dim,
342 hidden_size,
@@ -324,12 +345,7 @@ class Gemma3nAttention(nn.Module):
345 prefix=f"{prefix}.o_proj",
346 )
347 self.q_norm = RMSNorm(hidden_size=self.head_dim, eps=config.rms_norm_eps)
327 - self.k_norm = RMSNorm(hidden_size=self.head_dim, eps=config.rms_norm_eps)
328 - self.v_norm = RMSNorm(
329 - hidden_size=self.head_dim, eps=config.rms_norm_eps, has_weight=False
330 - )
348
332 - layer_idx = extract_layer_index(prefix)
349 layer_type = config.layer_types[layer_idx]
350 is_sliding = layer_type == "sliding_attention"
351 self.sliding_window = config.sliding_window if is_sliding else None
@@ -346,13 +362,8 @@ class Gemma3nAttention(nn.Module):
362 if is_sliding:
363 rope_parameters["rope_theta"] = config.rope_local_base_freq
364
349 - first_kv_shared_layer_idx = (
350 - config.num_hidden_layers - config.num_kv_shared_layers
351 - )
352 - self.is_kv_shared = layer_idx >= first_kv_shared_layer_idx
353 -
365 kv_sharing_target_layer_name = None
355 - if self.is_kv_shared:
366 + if self.is_kv_shared_layer:
367 # Last full attention layer is 1 before sharing
368 # Last sliding attention layer is 2 before sharing
369 offset = 2 if self.sliding_window is not None else 1
@@ -405,21 +416,30 @@ class Gemma3nAttention(nn.Module):
416 hidden_states: torch.Tensor,
417 **kwargs,
418 ) -> torch.Tensor:
408 - qkv, _ = self.qkv_proj(hidden_states)
409 - q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
410 -
411 - q = q.unflatten(-1, (self.num_heads, self.head_dim))
412 - q = self.q_norm(q)
413 - q = q.flatten(-2, -1)
414 - k = k.unflatten(-1, (self.num_kv_heads, self.head_dim))
415 - k = self.k_norm(k)
416 - k = k.flatten(-2, -1)
417 - v = v.unflatten(-1, (self.num_kv_heads, self.head_dim))
418 - v = self.v_norm(v)
419 - v = v.flatten(-2, -1)
420 -
421 - q, k = self.rotary_emb(positions, q, k)
422 - attn_output = self.attn(q, k, v)
419 + if self.is_kv_shared_layer:
420 + # Shared KV: only Q is projected; K/V come from the target layer.
421 + q, _ = self.q_proj(hidden_states)
422 + q = q.unflatten(-1, (self.num_heads, self.head_dim))
423 + q = self.q_norm(q)
424 + q = q.flatten(-2, -1)
425 + q, _ = self.rotary_emb(positions, q, None)
426 + attn_output = self.attn(q, None, None)
427 + else:
428 + qkv, _ = self.qkv_proj(hidden_states)
429 + q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
430 +
431 + q = q.unflatten(-1, (self.num_heads, self.head_dim))
432 + q = self.q_norm(q)
433 + q = q.flatten(-2, -1)
434 + k = k.unflatten(-1, (self.num_kv_heads, self.head_dim))
435 + k = self.k_norm(k)
436 + k = k.flatten(-2, -1)
437 + v = v.unflatten(-1, (self.num_kv_heads, self.head_dim))
438 + v = self.v_norm(v)
439 + v = v.flatten(-2, -1)
440 +
441 + q, k = self.rotary_emb(positions, q, k)
442 + attn_output = self.attn(q, k, v)
443
444 output, _ = self.o_proj(attn_output)
445 return output
@@ -795,6 +815,28 @@ class Gemma3nCrossDecoder(nn.Module):
815 return hidden_states
816
817
818 +def _kv_sharing_weights_mapper(config: Gemma3nTextConfig) -> WeightsMapper:
819 + """KV-shared layers only have q_proj, so qkv_proj packing applies to the
820 + other layers. Original checkpoints still ship K/V tensors for the shared
821 + layers (fine-tuned ones omit them); those are dropped."""
822 + first_kv_shared_layer_idx = config.num_hidden_layers - config.num_kv_shared_layers
823 + return WeightsMapper(
824 + orig_to_new_substr={
825 + f"layers.{i}.self_attn.{name}.": None
826 + for i in range(first_kv_shared_layer_idx, config.num_hidden_layers)
827 + for name in ("k_proj", "v_proj", "k_norm")
828 + },
829 + orig_to_new_stacked={
830 + f"layers.{i}.self_attn.{shard}_proj.": (
831 + f"layers.{i}.self_attn.qkv_proj.",
832 + shard,
833 + )
834 + for i in range(first_kv_shared_layer_idx)
835 + for shard in ("q", "k", "v")
836 + },
837 + )
838 +
839 +
840 # This disables torch.compile if --kv-sharing-fast-prefill passed
841 @support_torch_compile(
842 enable_if=lambda vllm_config: not vllm_config.cache_config.kv_sharing_fast_prefill
@@ -811,9 +853,6 @@ class Gemma3nTextModel(nn.Module, SupportsQuant):
853 "altup_projections.": "self_decoder.altup_projections.",
854 },
855 orig_to_new_stacked={
814 - ".q_proj": (".qkv_proj", "q"),
815 - ".k_proj": (".qkv_proj", "k"),
816 - ".v_proj": (".qkv_proj", "v"),
856 ".gate_proj": (".gate_up_proj", 0),
857 ".up_proj": (".gate_up_proj", 1),
858 },
@@ -826,6 +865,9 @@ class Gemma3nTextModel(nn.Module, SupportsQuant):
865 quant_config = vllm_config.quant_config
866 self.config = config
867 self.quant_config = quant_config
868 + self.hf_to_vllm_mapper = self.hf_to_vllm_mapper | _kv_sharing_weights_mapper(
869 + config
870 + )
871
872 self.altup_unembed_projections = nn.ModuleList(
873 [
vllm/model_executor/models/gemma4.py
+77 -62
@@ -413,18 +413,41 @@ class Gemma4Attention(nn.Module):
413 # Q/K norms with learnable weights handle scaling implicitly.
414 self.scaling = 1.0
415
416 - # QKVParallelLinear handles GQA correctly for all layer types.
417 - # k_eq_v layers load K weights into both K and V slots via
418 - # _weight_iterator remapping — no structural difference needed.
419 - self.qkv_proj = QKVParallelLinear(
420 - hidden_size,
421 - self.head_dim,
422 - self.total_num_heads,
423 - self.total_num_kv_heads,
424 - bias=config.attention_bias,
425 - quant_config=quant_config,
426 - prefix=f"{prefix}.qkv_proj",
416 + layer_idx = extract_layer_index(prefix)
417 + num_kv_shared_layers = getattr(config, "num_kv_shared_layers", 0)
418 + first_kv_shared_layer_idx = config.num_hidden_layers - num_kv_shared_layers
419 + self.is_kv_shared_layer = (
420 + num_kv_shared_layers > 0 and layer_idx >= first_kv_shared_layer_idx
421 )
422 +
423 + if self.is_kv_shared_layer:
424 + # K/V come from the target layer's cache, so like HF this layer
425 + # only has q_proj (and no k_norm/v_norm).
426 + self.q_proj = ColumnParallelLinear(
427 + hidden_size,
428 + self.total_num_heads * self.head_dim,
429 + bias=config.attention_bias,
430 + quant_config=quant_config,
431 + prefix=f"{prefix}.q_proj",
432 + )
433 + else:
434 + # QKVParallelLinear handles GQA correctly for all layer types.
435 + # k_eq_v layers load K weights into both K and V slots via
436 + # _weight_iterator remapping — no structural difference needed.
437 + self.qkv_proj = QKVParallelLinear(
438 + hidden_size,
439 + self.head_dim,
440 + self.total_num_heads,
441 + self.total_num_kv_heads,
442 + bias=config.attention_bias,
443 + quant_config=quant_config,
444 + prefix=f"{prefix}.qkv_proj",
445 + )
446 + self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
447 + # V norm: no learnable scale (pure normalization only)
448 + self.v_norm = RMSNorm(
449 + self.head_dim, eps=config.rms_norm_eps, has_weight=False
450 + )
451 self.o_proj = RowParallelLinear(
452 self.total_num_heads * self.head_dim,
453 hidden_size,
@@ -433,14 +456,10 @@ class Gemma4Attention(nn.Module):
456 prefix=f"{prefix}.o_proj",
457 )
458
436 - # Q/K norms: output = norm(x) * weight (learnable per-head scale)
459 + # Q norm: output = norm(x) * weight (learnable per-head scale)
460 self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
438 - self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
439 - # V norm: no learnable scale (pure normalization only)
440 - self.v_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps, has_weight=False)
461
462 # Determine layer type and sliding window
443 - layer_idx = extract_layer_index(prefix)
463 layer_type = config.layer_types[layer_idx]
464 self.is_sliding = layer_type == "sliding_attention"
465 sliding_window = config.sliding_window if self.is_sliding else None
@@ -466,30 +485,25 @@ class Gemma4Attention(nn.Module):
485 # KV sharing: layers in the last `num_kv_shared_layers` share KV
486 # cache with earlier layers of the same type.
487 kv_sharing_target_layer_name = None
469 - self.is_kv_shared_layer = False
470 - num_kv_shared_layers = getattr(config, "num_kv_shared_layers", 0)
471 - if num_kv_shared_layers > 0:
472 - first_kv_shared_layer_idx = config.num_hidden_layers - num_kv_shared_layers
473 - if layer_idx >= first_kv_shared_layer_idx:
474 - self.is_kv_shared_layer = True
475 - # Find the last non-shared layer of the same attention type
476 - prev_layers = config.layer_types[:first_kv_shared_layer_idx]
477 - current_layer_type = config.layer_types[layer_idx]
478 - kv_shared_layer_index = (
479 - len(prev_layers) - 1 - prev_layers[::-1].index(current_layer_type)
480 - )
481 - if kv_shared_layer_index >= 0:
482 - if ".layers." in prefix:
483 - param_name_before_layers = prefix.split(".layers.")[0]
484 - else:
485 - raise ValueError(
486 - "Unexpected prefix format for Gemma4Attention: "
487 - f"'{prefix}'. Expected to contain '.layers.'."
488 - )
489 - kv_sharing_target_layer_name = (
490 - f"{param_name_before_layers}.layers."
491 - f"{kv_shared_layer_index}.self_attn.attn"
488 + if self.is_kv_shared_layer:
489 + # Find the last non-shared layer of the same attention type
490 + prev_layers = config.layer_types[:first_kv_shared_layer_idx]
491 + current_layer_type = config.layer_types[layer_idx]
492 + kv_shared_layer_index = (
493 + len(prev_layers) - 1 - prev_layers[::-1].index(current_layer_type)
494 + )
495 + if kv_shared_layer_index >= 0:
496 + if ".layers." in prefix:
497 + param_name_before_layers = prefix.split(".layers.")[0]
498 + else:
499 + raise ValueError(
500 + "Unexpected prefix format for Gemma4Attention: "
501 + f"'{prefix}'. Expected to contain '.layers.'."
502 )
503 + kv_sharing_target_layer_name = (
504 + f"{param_name_before_layers}.layers."
505 + f"{kv_shared_layer_index}.self_attn.attn"
506 + )
507
508 self.rotary_emb = get_rope(
509 self.head_dim,
@@ -524,19 +538,24 @@ class Gemma4Attention(nn.Module):
538 hidden_states: torch.Tensor,
539 **kwargs,
540 ) -> torch.Tensor:
527 - # Unified QKV path (works for both k_eq_v and standard layers).
528 - # For k_eq_v, K weights are loaded into both K and V slots of
529 - # qkv_proj, so V == K automatically.
530 - qkv, _ = self.qkv_proj(hidden_states)
531 - q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
532 -
533 - # Q norm (always applied)
534 - q = q.unflatten(-1, (self.num_heads, self.head_dim))
535 - q = self.q_norm(q)
536 - q = q.flatten(-2, -1)
537 -
538 - if not self.is_kv_shared_layer:
539 - # Non-shared: apply K norm + RoPE, V norm
541 + if self.is_kv_shared_layer:
542 + # Shared KV: only Q is projected; K/V come from the target layer.
543 + q, _ = self.q_proj(hidden_states)
544 + q = q.unflatten(-1, (self.num_heads, self.head_dim))
545 + q = self.q_norm(q)
546 + q = q.flatten(-2, -1)
547 + q, _ = self.rotary_emb(positions, q, None)
548 + attn_output = self.attn(q, None, None)
549 + else:
550 + # For k_eq_v, K weights are loaded into both K and V slots of
551 + # qkv_proj, so V == K automatically.
552 + qkv, _ = self.qkv_proj(hidden_states)
553 + q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
554 +
555 + q = q.unflatten(-1, (self.num_heads, self.head_dim))
556 + q = self.q_norm(q)
557 + q = q.flatten(-2, -1)
558 +
559 k = k.unflatten(-1, (self.num_kv_heads, self.head_dim))
560 k = self.k_norm(k)
561 k = k.flatten(-2, -1)
@@ -545,11 +564,8 @@ class Gemma4Attention(nn.Module):
564 v = v.unflatten(-1, (self.num_kv_heads, self.head_dim))
565 v = self.v_norm(v)
566 v = v.flatten(-2, -1)
548 - else:
549 - # Shared: only apply RoPE to Q
550 - q = self.rotary_emb(positions, q, k)[0]
567 + attn_output = self.attn(q, k, v)
568
552 - attn_output = self.attn(q, k, v)
569 output, _ = self.o_proj(attn_output)
570
571 return output
@@ -1438,9 +1454,9 @@ class Gemma4Model(nn.Module, EagleModelMixin):
1454 if shard_name not in name:
1455 continue
1456 stacked_name = name.replace(shard_name, param_name)
1441 - # k_eq_v layers use separate q_proj/k_proj instead of
1442 - # packed qkv_proj. If the stacked param doesn't exist,
1443 - # skip this mapping and fall through to direct load.
1457 + # KV-shared layers have a plain q_proj instead of a packed
1458 + # qkv_proj: fall through to the direct load, which also
1459 + # skips the redundant K/V tensors original checkpoints ship.
1460 if stacked_name not in params_dict:
1461 continue
1462 if is_pp_missing_parameter(stacked_name, self):
@@ -1534,9 +1550,8 @@ class Gemma4ForCausalLM(
1550 ".moe.experts.down_proj": ".moe.down_proj",
1551 },
1552 )
1537 - # Note: qkv_proj packing applies to non-k_eq_v layers (sliding
1538 - # attention and full attention without k_eq_v). k_eq_v layers use
1539 - # separate q_proj + k_proj without packing.
1553 + # KV-shared layers only have q_proj, so qkv_proj packing applies to
1554 + # the other layers.
1555 packed_modules_mapping = {
1556 "qkv_proj": [
1557 "q_proj",