Fix Gemma 4 for upcoming Transformers version (#49797)
Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>
Harry Mellor committed
Aug 10, 2026 at 08:48 UTC
70b84f0bcbb6d0a35b74b1035673a1c934089dbb
15 files changed
+570
-106
tests/config/test_model_arch_config.py
+219
@@ -3,15 +3,20 @@
3
"""Tests for ModelArchitectureConfig and its integration with ModelConfig."""
4
5
import json
6
+from copy import copy
7
from pathlib import Path
8
from types import SimpleNamespace
9
from typing import cast
10
11
import pytest
12
from transformers import PretrainedConfig
13
+from transformers.models.gemma4.configuration_gemma4 import Gemma4TextConfig
14
15
from vllm.config import ModelConfig, ParallelConfig, SpeculativeConfig
16
+from vllm.config.model_arch import ModelArchitectureConfig
17
+from vllm.transformers_utils.configs.gemma4 import gemma4_layer_config
18
from vllm.transformers_utils.model_arch_config_convertor import (
19
+ Gemma4ModelArchConfigConvertor,
20
ModelArchConfigConvertorBase,
21
)
22
@@ -182,6 +187,220 @@ def test_legacy_modelopt_config_without_producer_is_normalized():
187
assert convertor.get_quantization_config()["quant_method"] == "modelopt_fp4"
188
189
190
+def _layer(**overrides) -> ModelArchitectureConfig:
191
+ fields = dict(
192
+ architectures=["X"],
193
+ model_type="x",
194
+ text_model_type=None,
195
+ hidden_size=64,
196
+ total_num_hidden_layers=3,
197
+ total_num_attention_heads=8,
198
+ head_size=16,
199
+ vocab_size=32,
200
+ total_num_kv_heads=4,
201
+ num_experts=0,
202
+ num_experts_per_token=0,
203
+ quantization_config=None,
204
+ is_deepseek_mla=False,
205
+ is_mm_prefix_lm=False,
206
+ rswa_window=None,
207
+ derived_max_model_len_and_key=(1.0, None),
208
+ )
209
+ return ModelArchitectureConfig(**(fields | overrides))
210
+
211
+
212
+def test_from_layers_collapses_to_the_largest_layer():
213
+ """The whole-model config must size buffers for every layer."""
214
+ arch = ModelArchitectureConfig.from_layers(
215
+ [_layer(), _layer(), _layer(head_size=32, total_num_kv_heads=8)]
216
+ )
217
+
218
+ assert (arch.head_size, arch.total_num_kv_heads) == (32, 8)
219
+ assert [arch[i].head_size for i in range(3)] == [16, 16, 32]
220
+ assert [arch[i].total_num_kv_heads for i in range(3)] == [4, 4, 8]
221
+ # A layer view is itself homogeneous, so reading from it cannot recurse.
222
+ assert arch[0].per_layer_overrides is None
223
+
224
+
225
+def test_layer_view_follows_later_edits_to_the_whole_model_config():
226
+ """`model_arch_config` is edited after construction in a few places."""
227
+ arch = ModelArchitectureConfig.from_layers(
228
+ [_layer(), _layer(), _layer(head_size=32)]
229
+ )
230
+
231
+ arch.is_mm_prefix_lm = True
232
+
233
+ assert all(arch[i].is_mm_prefix_lm for i in range(3))
234
+ assert [arch[i].head_size for i in range(3)] == [16, 16, 32]
235
+
236
+
237
+def test_uniform_layers_stay_homogeneous():
238
+ """A checkpoint can vary attributes vLLM never reads."""
239
+ arch = ModelArchitectureConfig.from_layers([_layer()] * 3)
240
+
241
+ assert arch.per_layer_overrides is None
242
+ assert arch[1] is arch
243
+
244
+
245
+def test_getitem_rejects_negative_indices():
246
+ """Wrapping would only misbehave on heterogeneous models, so reject both."""
247
+ homogeneous = _layer()
248
+ heterogeneous = ModelArchitectureConfig.from_layers(
249
+ [_layer(), _layer(), _layer(head_size=32)]
250
+ )
251
+
252
+ for arch in (homogeneous, heterogeneous):
253
+ with pytest.raises(IndexError):
254
+ arch[-1]
255
+
256
+
257
+@pytest.mark.parametrize(
258
+ "varying",
259
+ [
260
+ # `bool` is an `int`; collapsing this one with `max` would make `use_mla`
261
+ # true model wide and silently discard every per-layer KV head count.
262
+ {"is_deepseek_mla": True},
263
+ {"quantization_config": {"quant_method": "fp8"}},
264
+ {"rswa_window": 512},
265
+ ],
266
+)
267
+def test_from_layers_rejects_fields_with_no_whole_model_value(varying: dict):
268
+ with pytest.raises(ValueError, match="varies across layers"):
269
+ ModelArchitectureConfig.from_layers([_layer(), _layer(), _layer(**varying)])
270
+
271
+
272
+def test_from_layers_rejects_a_layer_count_mismatch():
273
+ """Draft configs can inherit a target's per-layer spec."""
274
+ with pytest.raises(ValueError, match="per-layer configs"):
275
+ ModelArchitectureConfig.from_layers([_layer(), _layer(head_size=32)])
276
+
277
+
278
+def _gemma4_text_config(**overrides) -> Gemma4TextConfig:
279
+ """A six layer Gemma4 whose last layer is wider than the rest."""
280
+ fields = dict(
281
+ num_hidden_layers=6,
282
+ hidden_size=64,
283
+ num_attention_heads=8,
284
+ num_key_value_heads=4,
285
+ head_dim=16,
286
+ global_head_dim=32,
287
+ # Transformers forces the last layer to full attention.
288
+ layer_types=["sliding_attention"] * 5 + ["full_attention"],
289
+ )
290
+ return Gemma4TextConfig(**(fields | overrides))
291
+
292
+
293
+def test_gemma4_head_dims_vary_by_layer_type():
294
+ """Gemma4's full attention layers are wider than its sliding ones.
295
+
296
+ Transformers >= 5.15.0 says so in the config; this exercises the convertor
297
+ building the same per-layer view from the flat attributes used before that.
298
+ """
299
+ text_config = _gemma4_text_config(
300
+ num_global_key_value_heads=8, attention_k_eq_v=True
301
+ )
302
+
303
+ arch = Gemma4ModelArchConfigConvertor(text_config, text_config).convert()
304
+
305
+ assert (arch.head_size, arch.total_num_kv_heads) == (32, 8)
306
+ assert [arch[i].head_size for i in range(6)] == [16] * 5 + [32]
307
+ assert [arch[i].total_num_kv_heads for i in range(6)] == [4] * 5 + [8]
308
+ # The model files resolve each layer through the same helper, so the KV cache
309
+ # vLLM allocates and the projections the model builds cannot disagree.
310
+ assert [gemma4_layer_config(text_config, i).head_dim for i in range(6)] == [
311
+ arch[i].head_size for i in range(6)
312
+ ]
313
+
314
+
315
+def test_gemma4_without_global_kv_heads():
316
+ """`num_global_key_value_heads` defaults to `None`, not to a head count."""
317
+ text_config = _gemma4_text_config()
318
+
319
+ arch = Gemma4ModelArchConfigConvertor(text_config, text_config).convert()
320
+
321
+ assert arch.total_num_kv_heads == 4
322
+ assert [arch[i].head_size for i in range(6)] == [16] * 5 + [32]
323
+
324
+
325
+def test_gemma4_layer_count_comes_from_num_hidden_layers():
326
+ """`dummy_hf_overrides` shrinks the stack but leaves `layer_types` long."""
327
+ text_config = _gemma4_text_config()
328
+ text_config.num_hidden_layers = 3
329
+
330
+ arch = Gemma4ModelArchConfigConvertor(text_config, text_config).convert()
331
+
332
+ assert arch.total_num_hidden_layers == 3
333
+ # Only sliding layers survive the truncation, so nothing varies.
334
+ assert arch.per_layer_overrides is None
335
+
336
+
337
+def test_gemma4_uniform_head_dims_are_homogeneous():
338
+ text_config = _gemma4_text_config(global_head_dim=16)
339
+
340
+ arch = Gemma4ModelArchConfigConvertor(text_config, text_config).convert()
341
+
342
+ assert arch.per_layer_overrides is None
343
+ assert arch[3] is arch
344
+
345
+
346
+class _HeterogeneousConfig(PretrainedConfig):
347
+ """A stand-in for the Transformers >= 5.15.0 heterogeneous config API.
348
+
349
+ No released Transformers has it, so the default per-layer seam has no other
350
+ way to be exercised. Mirrors the parts vLLM uses: per-layer configs are
351
+ shallow copies with the varying attributes applied and heterogeneity
352
+ stripped, so they do not recurse.
353
+ """
354
+
355
+ is_heterogeneous = True
356
+
357
+ def __init__(self, per_layer: dict[str, list], **kwargs):
358
+ super().__init__(**kwargs)
359
+ self._per_layer = per_layer
360
+
361
+ @property
362
+ def per_layer_config(self) -> list[PretrainedConfig]:
363
+ layers = []
364
+ for i in range(self.num_hidden_layers):
365
+ layer = copy(self)
366
+ layer.is_heterogeneous = False
367
+ for name, values in self._per_layer.items():
368
+ setattr(layer, name, values[i])
369
+ layers.append(layer)
370
+ return layers
371
+
372
+
373
+def test_transformers_heterogeneous_config_is_resolved_per_layer():
374
+ hf_config = _HeterogeneousConfig(
375
+ per_layer={"head_dim": [16, 16, 32], "num_key_value_heads": [4, 4, 8]},
376
+ num_hidden_layers=3,
377
+ hidden_size=64,
378
+ num_attention_heads=8,
379
+ )
380
+
381
+ arch = ModelArchConfigConvertorBase(hf_config, hf_config).convert()
382
+
383
+ assert (arch.head_size, arch.total_num_kv_heads) == (32, 8)
384
+ assert [arch[i].head_size for i in range(3)] == [16, 16, 32]
385
+ assert [arch[i].total_num_kv_heads for i in range(3)] == [4, 4, 8]
386
+
387
+
388
+def test_heterogeneous_config_varying_nothing_vllm_reads():
389
+ """Transformers prunes nothing for us: the diff has to notice."""
390
+ hf_config = _HeterogeneousConfig(
391
+ per_layer={"bos_token_id": [1, 2, 3]},
392
+ num_hidden_layers=3,
393
+ hidden_size=64,
394
+ num_attention_heads=8,
395
+ head_dim=16,
396
+ )
397
+
398
+ arch = ModelArchConfigConvertorBase(hf_config, hf_config).convert()
399
+
400
+ assert arch.per_layer_overrides is None
401
+ assert arch[2] is arch
402
+
403
+
404
@pytest.mark.parametrize("model", BASE_MODELS_TO_TEST)
405
def test_base_model_arch_config(model: str):
406
"""Test model architecture config for base models."""
tests/models/transformers/fusers/test_linear.py
+62
@@ -15,6 +15,8 @@ from vllm.model_executor.models.transformers.fusers import (
15
GLUFuser,
16
PackedQKVFuser,
17
QKVFuser,
18
+ packed_qkv,
19
+ qkv,
20
)
21
22
@@ -640,3 +642,63 @@ def test_act_and_mul_derived_from_module(default_vllm_config):
642
assert GLUFuser._get_act_and_mul_name(nn.LayerNorm(8)) is None
643
with pytest.raises(ValueError, match="No AndMul equivalent"):
644
GLUFuser._get_act_and_mul(nn.Dropout())
645
+
646
+
647
+def _wider_model_config(head_dim: int) -> SimpleNamespace:
648
+ """A model whose global head size is twice `head_dim`, as a wider layer
649
+ elsewhere in a heterogeneous checkpoint would make it."""
650
+ return SimpleNamespace(
651
+ model_config=SimpleNamespace(get_head_size=lambda: 2 * head_dim),
652
+ quant_config=None,
653
+ )
654
+
655
+
656
+@pytest.mark.parametrize(
657
+ "cls, fuser_module", [(FakeAttention, qkv), (PackedQKVAttention, packed_qkv)]
658
+)
659
+def test_head_counts_come_from_the_module_not_the_model(cls, fuser_module, monkeypatch):
660
+ """A layer narrower than the model-wide head size must not be miscounted.
661
+
662
+ On a heterogeneous checkpoint (Gemma 4) the model-wide head size is the
663
+ largest across layers, so deriving `total_num_heads = out_features //
664
+ head_size` from it undercounts heads on a narrower layer. The widths still
665
+ add up, so nothing raises below TP=4: the layer is just sharded wrong.
666
+ """
667
+ head_dim, heads, kv_heads = 8, 8, 4
668
+ vllm_config = _wider_model_config(head_dim)
669
+ with torch.device("meta"):
670
+ module = cls(hidden=32, head_dim=head_dim, heads=heads, kv_heads=kv_heads)
671
+
672
+ # Both replacements shard, so they need a TP group; only the head counts
673
+ # the fuser derives are under test here.
674
+ captured = {}
675
+ monkeypatch.setattr(
676
+ fuser_module,
677
+ "QKVParallelLinear",
678
+ lambda **kwargs: captured.update(kwargs) or nn.Identity(),
679
+ )
680
+ monkeypatch.setattr(
681
+ fuser_module, "replace_linear_class", lambda *a, **kw: nn.Identity()
682
+ )
683
+ fuser = get_fuser(module)
684
+ assert fuser is not None and fuser.validate(module, vllm_config)
685
+ fuser.update_attrs(module, "model.layers.0.self_attn", vllm_config)
686
+
687
+ assert captured["head_size"] == head_dim
688
+ assert captured["total_num_heads"] == heads
689
+ assert captured["total_num_kv_heads"] == kv_heads
690
+
691
+
692
+def test_validate_accepts_a_layer_the_model_wide_head_size_would_reject():
693
+ """`validate` gates fusion, so a wrong head size silently disables it."""
694
+ head_dim, heads, kv_heads = 8, 8, 3
695
+ vllm_config = _wider_model_config(head_dim)
696
+ with torch.device("meta"):
697
+ module = FakeAttention(
698
+ hidden=32, head_dim=head_dim, heads=heads, kv_heads=kv_heads
699
+ )
700
+
701
+ # kv width is 24, not a multiple of the model-wide 16, but is of this
702
+ # layer's 8.
703
+ fuser = get_fuser(module)
704
+ assert fuser is not None and fuser.validate(module, vllm_config)
vllm/config/model.py
+27
-10
@@ -886,9 +886,7 @@ class ModelConfig:
886
)
887
return supports_mm
888
889
- def get_model_arch_config(
890
- self,
891
- ) -> ModelArchitectureConfig:
889
+ def get_model_arch_config(self) -> ModelArchitectureConfig:
890
convertor_cls = MODEL_ARCH_CONFIG_CONVERTORS.get(
891
self.hf_config.model_type, ModelArchConfigConvertorBase
892
)
@@ -982,8 +980,8 @@ class ModelConfig:
980
"""Determine which Transformers modeling backend class will be used if
981
`model_impl` is set to `transformers` or `auto`."""
982
cls = "Transformers"
985
- # If 'hf_config != hf_text_config' it's a nested config, i.e. multimodal
986
- cls += "MultiModal" if self.hf_config != self.hf_text_config else ""
983
+ # If 'hf_config is not hf_text_config' it's a nested config, i.e. multimodal
984
+ cls += "MultiModal" if self.hf_config is not self.hf_text_config else ""
985
cls += "MoE" if self.is_moe else ""
986
# Check if the architecture we're wrapping has defaults
987
runner = None
@@ -1473,21 +1471,40 @@ class ModelConfig:
1471
"""Returns the total number of KV heads."""
1472
return self.model_arch_config.total_num_kv_heads
1473
1476
- def get_num_kv_heads(self, parallel_config: ParallelConfig) -> int:
1477
- """Returns the number of KV heads per GPU."""
1474
+ def get_num_kv_heads(
1475
+ self,
1476
+ parallel_config: ParallelConfig,
1477
+ arch_config: ModelArchitectureConfig | None = None,
1478
+ ) -> int:
1479
+ """Returns the number of KV heads per GPU.
1480
+
1481
+ Pass ``arch_config`` (from ``model_arch_config[layer_idx]``) to size a
1482
+ single layer of a heterogeneous model rather than the model as a whole.
1483
+ """
1484
if self.use_mla:
1485
# When using MLA during decode it becomes MQA
1486
return 1
1487
1482
- total_num_kv_heads = self.get_total_num_kv_heads()
1488
+ arch_config = arch_config or self.model_arch_config
1489
+ total_num_kv_heads = arch_config.total_num_kv_heads
1490
# If tensor parallelism is used, we divide the number of KV heads by
1491
# the tensor parallel size. We will replicate the KV heads in the
1492
# case where the number of KV heads is smaller than the tensor
1493
# parallel size so each GPU has at least one KV head.
1494
return max(1, total_num_kv_heads // parallel_config.tensor_parallel_size)
1495
1489
- def get_num_attention_heads(self, parallel_config: ParallelConfig) -> int:
1490
- num_heads = self.model_arch_config.total_num_attention_heads
1496
+ def get_num_attention_heads(
1497
+ self,
1498
+ parallel_config: ParallelConfig,
1499
+ arch_config: ModelArchitectureConfig | None = None,
1500
+ ) -> int:
1501
+ """Returns the number of attention heads per GPU.
1502
+
1503
+ Pass ``arch_config`` (from ``model_arch_config[layer_idx]``) to size a
1504
+ single layer of a heterogeneous model rather than the model as a whole.
1505
+ """
1506
+ arch_config = arch_config or self.model_arch_config
1507
+ num_heads = arch_config.total_num_attention_heads
1508
return num_heads // parallel_config.tensor_parallel_size
1509
1510
def get_num_experts(self) -> int:
vllm/config/model_arch.py
+77
@@ -1,5 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from copy import copy
4
+from dataclasses import fields as dataclass_fields
5
from typing import Any
6
7
from pydantic import ConfigDict
@@ -64,3 +66,78 @@ class ModelArchitectureConfig:
66
67
derived_max_model_len_and_key: tuple[float, str | None]
68
"""Derived maximum model length and key from the hf config."""
69
+
70
+ per_layer_overrides: "list[dict[str, Any]] | None" = None
71
+ """Per-layer values for the fields that vary, `None` unless some field does.
72
+
73
+ One dict per layer, holding only the fields whose value differs from the
74
+ whole-model value above. Everything else is read from the whole-model config,
75
+ so later edits to it are visible through `self[layer_idx]`."""
76
+
77
+ def __getitem__(self, layer_idx: int) -> "ModelArchitectureConfig":
78
+ """ModelArchitectureConfig for a specific layer.
79
+
80
+ Returns `self` when no field varies by layer, so callers never need to
81
+ branch on heterogeneity. Mirrors `PreTrainedConfig.per_layer_config[i]`.
82
+ """
83
+ if layer_idx < 0:
84
+ # Homogeneous configs would ignore a negative index and heterogeneous
85
+ # ones would wrap, so an off-by-one would only show up on the models
86
+ # this exists for. Reject it for both.
87
+ raise IndexError(f"layer index must not be negative, got {layer_idx}")
88
+ if self.per_layer_overrides is None:
89
+ return self
90
+ layer = copy(self)
91
+ object.__setattr__(layer, "per_layer_overrides", None)
92
+ for name, value in self.per_layer_overrides[layer_idx].items():
93
+ object.__setattr__(layer, name, value)
94
+ return layer
95
+
96
+ @classmethod
97
+ def from_layers(
98
+ cls, layers: "list[ModelArchitectureConfig]"
99
+ ) -> "ModelArchitectureConfig":
100
+ """Whole-model config for a checkpoint whose layers differ.
101
+
102
+ Fields that agree across layers are taken as they are. Fields that differ
103
+ are collapsed with `max`, so buffers are sized for the largest layer, and
104
+ the differing values are kept per layer. No field is named here: which
105
+ ones vary is whatever the checkpoint says.
106
+ """
107
+ if not layers:
108
+ raise ValueError("a model must have at least one layer")
109
+
110
+ merged: dict[str, Any] = {}
111
+ overrides: list[dict[str, Any]] = [{} for _ in layers]
112
+ for f in dataclass_fields(cls):
113
+ if f.name == "per_layer_overrides":
114
+ continue
115
+ values = [getattr(layer, f.name) for layer in layers]
116
+ if all(value == values[0] for value in values):
117
+ merged[f.name] = values[0]
118
+ continue
119
+ # `bool` is an `int`, so an exact type check is what keeps a varying
120
+ # flag from collapsing to `any`. `is_deepseek_mla` doing that would
121
+ # make `use_mla` true model wide, and `get_num_kv_heads` then returns
122
+ # 1 for every layer, discarding the overrides built here.
123
+ if not all(type(value) in (int, float) for value in values):
124
+ raise ValueError(
125
+ f"{f.name!r} varies across layers and has no whole-model "
126
+ f"value: {sorted(set(map(repr, values)))}. Only numeric "
127
+ f"fields collapse (with `max`, to size buffers for the "
128
+ f"largest layer); give this one an explicit rule in "
129
+ f"ModelArchitectureConfig.from_layers."
130
+ )
131
+ merged[f.name] = max(values)
132
+ for override, value in zip(overrides, values):
133
+ if value != merged[f.name]:
134
+ override[f.name] = value
135
+
136
+ if len(layers) != merged["total_num_hidden_layers"]:
137
+ raise ValueError(
138
+ f"got {len(layers)} per-layer configs for a model with "
139
+ f"{merged['total_num_hidden_layers']} layers"
140
+ )
141
+ # A checkpoint can be heterogeneous over attributes vLLM never reads, in
142
+ # which case there is nothing to keep the layers apart for.
143
+ return cls(**merged, per_layer_overrides=overrides if any(overrides) else None)
vllm/model_executor/model_loader/weight_utils.py
+2
-6
@@ -260,12 +260,8 @@ def get_quant_config(
260
and hf_quant_config.get("quant_method") == "compressed-tensors"
261
and "config_groups" in hf_quant_config
262
):
263
- if hf_text_config is not None:
264
- n_heads = getattr(hf_text_config, "num_attention_heads", None)
265
- n_kv_heads = getattr(hf_text_config, "num_key_value_heads", None)
266
- else:
267
- n_heads = getattr(model_config.hf_config, "num_attention_heads", None)
268
- n_kv_heads = getattr(model_config.hf_config, "num_key_value_heads", None)
263
+ n_heads = model_config.model_arch_config.total_num_attention_heads
264
+ n_kv_heads = model_config.model_arch_config.total_num_kv_heads
265
266
hf_quant_config["total_num_heads"] = n_heads
267
hf_quant_config["total_num_kv_heads"] = (
vllm/model_executor/models/config.py
+20
-21
@@ -200,26 +200,29 @@ class Gemma4Config(VerifyAndUpdateConfig):
200
def verify_and_update_config(vllm_config: "VllmConfig") -> None:
201
"""Configure attention for heterogeneous head dimensions.
202
203
- Gemma4 uses different head dimensions for sliding window
204
- (head_dim) vs full attention (global_head_dim) layers. The
205
- default FA3 on Hopper cannot handle head_dim > 256, which
206
- causes mixed backend selection and numerical divergence.
207
-
208
- When FA4 is available we force it for ALL layers, giving a
209
- uniform kernel path and avoiding the mixed FA3+FA4 penalty.
210
- When FA4 is not available we fall back to Triton.
203
+ Gemma4 uses different head dimensions for sliding window vs full attention
204
+ layers. The default FA3 on Hopper cannot handle head_dim > 256, which causes
205
+ mixed backend selection and numerical divergence.
206
+
207
+ When FA4 is available we force it for ALL layers, giving a uniform kernel path
208
+ and avoiding the mixed FA3+FA4 penalty. When FA4 is not available we fall back
209
+ to Triton.
210
"""
212
- hf_text_config = vllm_config.model_config.hf_text_config
213
- head_dim = getattr(hf_text_config, "head_dim", None)
214
- global_head_dim = getattr(hf_text_config, "global_head_dim", None)
211
+ model_config = vllm_config.model_config
212
+ arch_config = model_config.model_arch_config
213
+ layer_types = getattr(model_config.hf_text_config, "layer_types", None) or []
214
+ head_dims = {
215
+ layer_types[i]: arch_config[i].head_size
216
+ for i in range(min(arch_config.total_num_hidden_layers, len(layer_types)))
217
+ }
218
216
- if head_dim is None or global_head_dim is None or head_dim == global_head_dim:
219
+ if len(set(head_dims.values())) <= 1:
220
return
221
222
from vllm.v1.attention.backends.fa_utils import is_fa_version_supported
223
from vllm.v1.attention.backends.registry import AttentionBackendEnum
224
222
- max_head_dim = max(head_dim, global_head_dim)
225
+ max_head_dim = max(head_dims.values())
226
227
if is_fa_version_supported(4) and max_head_dim <= 512:
228
if (
@@ -229,20 +232,16 @@ class Gemma4Config(VerifyAndUpdateConfig):
232
):
233
vllm_config.attention_config.flash_attn_version = 4
234
logger.info(
232
- "Gemma4 model has heterogeneous head dimensions "
233
- "(head_dim=%d, global_head_dim=%d). Using FA4 for "
235
+ "Gemma4 model has heterogeneous head dimensions %s. Using FA4 for "
236
"all layers to avoid mixed FA3/FA4 penalty.",
235
- head_dim,
236
- global_head_dim,
237
+ head_dims,
238
)
239
elif vllm_config.attention_config.backend is None:
240
vllm_config.attention_config.backend = AttentionBackendEnum.TRITON_ATTN
241
logger.info(
242
"Gemma4 model has heterogeneous head dimensions "
242
- "(head_dim=%d, global_head_dim=%d). FA4 not available, "
243
- "forcing TRITON_ATTN backend.",
244
- head_dim,
245
- global_head_dim,
243
+ "%s. FA4 not available, forcing TRITON_ATTN backend.",
244
+ head_dims,
245
)
246
247
vllm/model_executor/models/gemma4.py
+4
-24
@@ -63,6 +63,7 @@ from vllm.model_executor.model_loader.weight_utils import (
63
)
64
from vllm.platforms import current_platform
65
from vllm.sequence import IntermediateTensors
66
+from vllm.transformers_utils.configs.gemma4 import gemma4_layer_config
67
from vllm.triton_utils import tl, triton
68
from vllm.v1.attention.backends.utils import KVSharingFastPrefillMetadata
69
@@ -380,7 +381,6 @@ class Gemma4Attention(nn.Module):
381
num_kv_heads: int,
382
head_dim: int,
383
max_position_embeddings: int,
383
- use_k_eq_v: bool = False,
384
cache_config: CacheConfig | None = None,
385
quant_config: QuantizationConfig | None = None,
386
attn_logits_soft_cap: float | None = None,
@@ -389,7 +389,6 @@ class Gemma4Attention(nn.Module):
389
super().__init__()
390
self.config = config
391
self.hidden_size = hidden_size
392
- self.use_k_eq_v = use_k_eq_v
392
393
tp_size = get_tensor_model_parallel_world_size()
394
self.tp_rank = get_tensor_model_parallel_rank()
@@ -570,27 +569,9 @@ class Gemma4DecoderLayer(nn.Module):
569
self.layer_idx = layer_idx
570
571
# Gemma4 uses different head dimensions for sliding vs full attention
573
- layer_type = config.layer_types[layer_idx]
574
- self.is_full_attention = layer_type == "full_attention"
575
- if self.is_full_attention:
576
- head_dim = getattr(config, "global_head_dim", config.head_dim)
577
- else:
578
- head_dim = config.head_dim
579
-
580
- # Determine if this full-attention layer uses k_eq_v
581
- # (laptop variant: no v_proj, K reused as V on full attention layers)
582
- use_k_eq_v = self.is_full_attention and getattr(
583
- config, "attention_k_eq_v", False
584
- )
585
-
586
- # For k_eq_v full-attention layers, use num_global_key_value_heads
587
- # as the KV head count when k_eq_v is enabled.
588
- if use_k_eq_v:
589
- num_kv_heads = getattr(
590
- config, "num_global_key_value_heads", config.num_key_value_heads
591
- )
592
- else:
593
- num_kv_heads = config.num_key_value_heads
572
+ layer_config = gemma4_layer_config(config, layer_idx)
573
+ head_dim = layer_config.head_dim
574
+ num_kv_heads = layer_config.num_key_value_heads
575
576
self.self_attn = Gemma4Attention(
577
config=config,
@@ -599,7 +580,6 @@ class Gemma4DecoderLayer(nn.Module):
580
num_kv_heads=num_kv_heads,
581
head_dim=head_dim,
582
max_position_embeddings=config.max_position_embeddings,
602
- use_k_eq_v=use_k_eq_v,
583
cache_config=cache_config,
584
quant_config=quant_config,
585
attn_logits_soft_cap=getattr(config, "attn_logit_softcapping", None),
vllm/model_executor/models/gemma4_dspark.py
+9
-13
@@ -20,6 +20,7 @@ from vllm.model_executor.layers.vocab_parallel_embedding import (
20
VocabParallelEmbedding,
21
)
22
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
23
+from vllm.transformers_utils.configs.gemma4 import gemma4_layer_config
24
25
from .gemma4_mtp import Gemma4MTPAttention, Gemma4MTPDecoderLayer
26
from .qwen3_dflash import DFlashQwen3Model, _dflash_layer_causal
@@ -38,18 +39,14 @@ class Gemma4DSparkAttention(Gemma4MTPAttention):
39
prefix: str,
40
) -> None:
41
layer_idx = extract_layer_index(prefix)
41
- is_full = config.layer_types[layer_idx] == "full_attention"
42
- head_dim = (
43
- getattr(config, "global_head_dim", config.head_dim)
44
- if is_full
45
- else config.head_dim
46
- )
47
- use_k_eq_v = is_full and getattr(config, "attention_k_eq_v", False)
48
- num_kv_heads = (
49
- getattr(config, "num_global_key_value_heads", config.num_key_value_heads)
50
- if use_k_eq_v
51
- else config.num_key_value_heads
42
+ layer_type = config.layer_types[layer_idx]
43
+ self.use_k_eq_v = layer_type == "full_attention" and getattr(
44
+ config, "attention_k_eq_v", False
45
)
46
+
47
+ layer_config = gemma4_layer_config(config, layer_idx)
48
+ head_dim = layer_config.head_dim
49
+ num_kv_heads = layer_config.num_key_value_heads
50
super().__init__(
51
config=config,
52
hidden_size=config.hidden_size,
@@ -64,7 +61,6 @@ class Gemma4DSparkAttention(Gemma4MTPAttention):
61
)
62
self.is_kv_shared_layer = False
63
self.causal = _dflash_layer_causal(config, layer_idx)
67
- self.use_k_eq_v = use_k_eq_v
64
self.kv_size = self.num_kv_heads * self.head_dim
65
attn_bias = getattr(config, "attention_bias", False)
66
self.k_proj = ColumnParallelLinear(
@@ -76,7 +72,7 @@ class Gemma4DSparkAttention(Gemma4MTPAttention):
72
)
73
self.v_proj = (
74
None
79
- if use_k_eq_v
75
+ if self.use_k_eq_v
76
else ColumnParallelLinear(
77
config.hidden_size,
78
self.total_num_kv_heads * self.head_dim,
vllm/model_executor/models/gemma4_mtp.py
+4
-15
@@ -45,6 +45,7 @@ from vllm.model_executor.layers.vocab_parallel_embedding import (
45
VocabParallelEmbedding,
46
)
47
from vllm.sequence import IntermediateTensors
48
+from vllm.transformers_utils.configs.gemma4 import gemma4_layer_config
49
50
from .gemma4 import Gemma4MLP, _get_text_config
51
from .utils import (
@@ -271,21 +272,9 @@ class Gemma4MTPDecoderLayer(nn.Module):
272
self.hidden_size = config.hidden_size
273
274
layer_idx = extract_layer_index(prefix)
274
- layer_type = config.layer_types[layer_idx]
275
- is_full_attention = layer_type == "full_attention"
276
- head_dim = (
277
- getattr(config, "global_head_dim", config.head_dim)
278
- if is_full_attention
279
- else config.head_dim
280
- )
281
-
282
- use_k_eq_v = is_full_attention and getattr(config, "attention_k_eq_v", False)
283
- if use_k_eq_v:
284
- num_kv_heads = getattr(
285
- config, "num_global_key_value_heads", config.num_key_value_heads
286
- )
287
- else:
288
- num_kv_heads = config.num_key_value_heads
275
+ layer_config = gemma4_layer_config(config, layer_idx)
276
+ head_dim = layer_config.head_dim
277
+ num_kv_heads = layer_config.num_key_value_heads
278
279
self.self_attn = Gemma4MTPAttention(
280
config=config,
vllm/model_executor/models/transformers/base.py
+14
-6
@@ -522,9 +522,6 @@ class Base(
522
if qk_head_dim := qk_nope_head_dim + qk_rope_head_dim:
523
self.model_config.model_arch_config.head_size = qk_head_dim
524
525
- num_heads = self.model_config.get_num_attention_heads(self.parallel_config)
526
- head_size = self.model_config.get_head_size()
527
- num_kv_heads = self.model_config.get_num_kv_heads(self.parallel_config)
525
logits_soft_cap = getattr(text_config, "attn_logit_softcapping", None)
526
527
pp_rank = self.pp_group.rank_in_group
@@ -532,11 +529,22 @@ class Base(
529
start, end = get_pp_indices(text_config.num_hidden_layers, pp_rank, pp_size)
530
531
for i in range(start, end):
532
+ # `[i]` is the whole-model config unless the checkpoint is
533
+ # heterogeneous, in which case it is this layer's own geometry.
534
+ arch_config = self.model_config.model_arch_config[i]
535
+ num_heads = self.model_config.get_num_attention_heads(
536
+ self.parallel_config, arch_config
537
+ )
538
+ head_size = arch_config.head_size
539
+ # Default to Llama scale, maybe updated in vllm_attention_forward
540
+ scale = head_size**-0.5
541
+ num_kv_heads = self.model_config.get_num_kv_heads(
542
+ self.parallel_config, arch_config
543
+ )
544
+
545
kwargs = dict(
546
num_heads=num_heads,
537
- # NOTE: We use Llama scale as default, if it's set by
538
- # Transformers, it's updated in vllm_attention_forward
539
- scale=head_size**-0.5,
547
+ scale=scale,
548
cache_config=self.cache_config,
549
quant_config=self.quant_config,
550
prefix=f"{i}.attn",
vllm/model_executor/models/transformers/fusers/base.py
+11
@@ -57,6 +57,17 @@ class BaseFuser(ABC):
57
return {}
58
59
60
+def fused_head_size(module: nn.Module, vllm_config: "VllmConfig") -> int:
61
+ """The head size of `module`, which head counts are derived from.
62
+
63
+ Prefer the module's own `head_dim`, which Transformers sets per instance:
64
+ the model-wide value is the largest head size across layers, so on a
65
+ heterogeneous checkpoint it would divide out the wrong number of heads, and
66
+ it is the text head size, so it does not describe a vision tower at all.
67
+ """
68
+ return getattr(module, "head_dim", None) or vllm_config.model_config.get_head_size()
69
+
70
+
71
def local_output_sizes(merged_name: str) -> str:
72
"""Source for the per-rank widths of the merged linear `self.<merged_name>`."""
73
merged = f"self.{merged_name}"
vllm/model_executor/models/transformers/fusers/packed_qkv.py
+3
-2
@@ -12,6 +12,7 @@ from vllm.logger import init_logger
12
from vllm.model_executor.layers.linear import QKVParallelLinear
13
from vllm.model_executor.models.transformers.fusers.base import (
14
RewriteFuser,
15
+ fused_head_size,
16
local_output_sizes,
17
)
18
from vllm.model_executor.models.transformers.fx_utils import (
@@ -122,7 +123,7 @@ class PackedQKVFuser(RewriteFuser):
123
124
def validate(self, module: nn.Module, vllm_config: "VllmConfig") -> bool:
125
"""Shapes must be compatible with a head-sharded packed GEMM."""
125
- head_size = vllm_config.model_config.get_head_size()
126
+ head_size = fused_head_size(module, vllm_config)
127
qkv = module.get_submodule(self.qkv_name)
128
compatible = (
129
self.q_size % head_size == 0
@@ -137,7 +138,7 @@ class PackedQKVFuser(RewriteFuser):
138
self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
139
) -> None:
140
quant_config = vllm_config.quant_config
140
- head_size = vllm_config.model_config.get_head_size()
141
+ head_size = fused_head_size(module, vllm_config)
142
qkv_prefix = maybe_prefix(prefix, self.qkv_name)
143
qkv = module.get_submodule(self.qkv_name)
144
merged = QKVParallelLinear(
vllm/model_executor/models/transformers/fusers/qkv.py
+3
-2
@@ -12,6 +12,7 @@ from vllm.logger import init_logger
12
from vllm.model_executor.layers.linear import QKVParallelLinear
13
from vllm.model_executor.models.transformers.fusers.base import (
14
StackedFuser,
15
+ fused_head_size,
16
local_output_sizes,
17
)
18
from vllm.model_executor.models.transformers.fx_utils import (
@@ -158,7 +159,7 @@ class QKVFuser(StackedFuser):
159
q = module.get_submodule(self.q_name)
160
k = module.get_submodule(self.k_name)
161
v = module.get_submodule(self.v_name)
161
- head_size = vllm_config.model_config.get_head_size()
162
+ head_size = fused_head_size(module, vllm_config)
163
compatible = (
164
q.in_features == k.in_features == v.in_features
165
and len({proj.bias is None for proj in (q, k, v)}) == 1
@@ -174,7 +175,7 @@ class QKVFuser(StackedFuser):
175
self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
176
) -> None:
177
quant_config = vllm_config.quant_config
177
- head_size = vllm_config.model_config.get_head_size()
178
+ head_size = fused_head_size(module, vllm_config)
179
q = module.get_submodule(self.q_name)
180
k = module.get_submodule(self.k_name)
181
merged = QKVParallelLinear(
vllm/transformers_utils/configs/gemma4.py
new
+34
@@ -0,0 +1,34 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Per-layer config resolution for Gemma4 and its variants."""
4
+
5
+from copy import copy
6
+
7
+from transformers import PretrainedConfig
8
+
9
+
10
+def gemma4_layer_config(
11
+ text_config: PretrainedConfig, layer_idx: int
12
+) -> PretrainedConfig:
13
+ """The Gemma4 text config as it applies to one layer.
14
+
15
+ Gemma4 uses a larger head dimension on its full attention layers than on its
16
+ sliding ones, and with `attention_k_eq_v` it uses more KV heads there too.
17
+ Transformers >= 5.15.0 says so in the config itself; before that the values
18
+ are flat attributes that `layer_types` picks between, so resolve them here.
19
+ The result is homogeneous either way, so callers read `head_dim` and
20
+ `num_key_value_heads` off it without caring which version they are on.
21
+ """
22
+ if getattr(text_config, "is_heterogeneous", False):
23
+ return text_config.per_layer_config[layer_idx]
24
+
25
+ layer = copy(text_config)
26
+ if text_config.layer_types[layer_idx] == "full_attention":
27
+ global_head_dim = getattr(text_config, "global_head_dim", None)
28
+ layer.head_dim = global_head_dim or text_config.head_dim
29
+ if getattr(text_config, "attention_k_eq_v", False):
30
+ global_kv_heads = getattr(text_config, "num_global_key_value_heads", None)
31
+ layer.num_key_value_heads = (
32
+ global_kv_heads or text_config.num_key_value_heads
33
+ )
34
+ return layer
vllm/transformers_utils/model_arch_config_convertor.py
+81
-7
@@ -17,6 +17,7 @@ from vllm.transformers_utils.config import (
17
ConfigFormat,
18
get_safetensors_params_metadata,
19
)
20
+from vllm.transformers_utils.configs.gemma4 import gemma4_layer_config
21
from vllm.utils.torch_utils import common_broadcastable_dtype
22
23
logger = init_logger(__name__)
@@ -27,6 +28,37 @@ class ModelArchConfigConvertorBase:
28
self.hf_config = hf_config
29
self.hf_text_config = hf_text_config
30
31
+ def get_per_layer_hf_configs(
32
+ self,
33
+ ) -> list[tuple[PretrainedConfig, PretrainedConfig]] | None:
34
+ """`(hf_config, hf_text_config)` per layer, or `None` if homogeneous.
35
+
36
+ This is the only place that decides whether a checkpoint is heterogeneous
37
+ and how many layers it has, so a convertor can declare variation that
38
+ Transformers does not express (see `Gemma4ModelArchConfigConvertor`).
39
+
40
+ Each pair is converted independently and the results are diffed, so a
41
+ convertor that overrides this must not also collapse a varying field in
42
+ its getters: the collapse would then be applied per layer and every layer
43
+ would report the same value.
44
+ """
45
+ config_het = getattr(self.hf_config, "is_heterogeneous", False)
46
+ text_het = getattr(self.hf_text_config, "is_heterogeneous", False)
47
+ if not (config_het or text_het):
48
+ return None
49
+ # The text config decides the layer count when it is the heterogeneous one:
50
+ # its stack is what vLLM builds attention layers for.
51
+ source = self.hf_text_config if text_het else self.hf_config
52
+ return [
53
+ (
54
+ self.hf_config.per_layer_config[i] if config_het else self.hf_config,
55
+ self.hf_text_config.per_layer_config[i]
56
+ if text_het
57
+ else self.hf_text_config,
58
+ )
59
+ for i in range(len(source.per_layer_config))
60
+ ]
61
+
62
def get_architectures(self) -> list[str]:
63
# Sometimes we get here from `vllm_config.with_hf_config(text_config)` where
64
# `text_config` is a sub-config from a multi-modal model. If this is the case,
@@ -382,6 +414,35 @@ class ModelArchConfigConvertorBase:
414
return derived_max_model_len, max_len_key
415
416
def convert(self, supports_multimodal: bool = True) -> ModelArchitectureConfig:
417
+ if (per_layer := self.get_per_layer_hf_configs()) is None:
418
+ return self.convert_layer(supports_multimodal)
419
+
420
+ if self.is_deepseek_mla():
421
+ raise NotImplementedError(
422
+ "Heterogeneous MLA models are not supported: `get_head_size` "
423
+ "patches the config in place for them, which a per-layer "
424
+ "conversion would apply to a throwaway copy."
425
+ )
426
+ # Convert each layer and let the result work out which fields differ.
427
+ # Reading a varying attribute off the global config would raise, so the
428
+ # whole-model config has to be built up from the layers.
429
+ return ModelArchitectureConfig.from_layers(
430
+ [
431
+ type(self)(*configs).convert_layer(supports_multimodal)
432
+ for configs in per_layer
433
+ ]
434
+ )
435
+
436
+ def convert_layer(
437
+ self, supports_multimodal: bool = True
438
+ ) -> ModelArchitectureConfig:
439
+ """Convert one homogeneous config, without resolving per-layer values.
440
+
441
+ `convert` calls this once per layer, so it must not recurse back into
442
+ per-layer resolution: the configs it receives are already layer-specific,
443
+ and they can still carry the attributes that made the model look
444
+ heterogeneous in the first place.
445
+ """
446
model_arch_config = ModelArchitectureConfig(
447
architectures=self.get_architectures(),
448
model_type=self.hf_config.model_type,
@@ -622,13 +683,26 @@ class Gemma4ModelArchConfigConvertor(ModelArchConfigConvertorBase):
683
== "vision"
684
)
685
625
- def get_head_size(self) -> int:
626
- # Gemma4 uses dual head dimensions: head_dim (sliding attention)
627
- # and global_head_dim (full attention). Return the largest so
628
- # that attention backends allocate buffers large enough for both.
629
- head_dim = getattr(self.hf_text_config, "head_dim", 0)
630
- global_head_dim = getattr(self.hf_text_config, "global_head_dim", 0)
631
- return max(head_dim, global_head_dim) or super().get_head_size()
686
+ def get_per_layer_hf_configs(
687
+ self,
688
+ ) -> list[tuple[PretrainedConfig, PretrainedConfig]] | None:
689
+ # Gemma4 uses a larger head dimension, and sometimes more KV heads, on its
690
+ # full attention layers than on its sliding ones. Transformers >= 5.15.0
691
+ # says so in the config; before that the values are flat attributes picked
692
+ # apart by `layer_types`, so build the per-layer configs here instead. Both
693
+ # then reach the base convertor as ordinary homogeneous configs.
694
+ if getattr(self.hf_text_config, "is_heterogeneous", False):
695
+ return super().get_per_layer_hf_configs()
696
+ text_config = self.hf_text_config
697
+ if not getattr(text_config, "layer_types", None):
698
+ return None
699
+ # `num_hidden_layers` is what the stack is built from, and it is what
700
+ # Transformers sizes `per_layer_config` by. `layer_types` can be longer,
701
+ # e.g. when a test truncates the layer count to build a small model.
702
+ return [
703
+ (self.hf_config, gemma4_layer_config(text_config, layer_idx))
704
+ for layer_idx in range(self.get_num_hidden_layers())
705
+ ]
706
707
708
class MossAudioModelArchConfigConvertor(ModelArchConfigConvertorBase):