[Transformers backend] Find attention with a fuser and attach vLLM's layer to it (#54941)
Signed-off-by: Thomas Ortner <boh@zurich.ibm.com> Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com> Co-authored-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>
Thomas Ortner committed
Sep 4, 2026 at 18:26 UTC
8ad2076c4a0e2a7e5ccf5b11455f737430ad12db
14 files changed
+522
-151
docs/models/supported_models.md
+33
-16
@@ -72,19 +72,26 @@ This means that, with the Transformers modeling backend for vLLM, new models can
72
73
This section details the necessary modifications to make to a Transformers compatible custom model that make it compatible with the Transformers modeling backend for vLLM. (We assume that a Transformers compatible custom model has already been created, see [Transformers - Customizing models](https://huggingface.co/docs/transformers/en/custom_models)).
74
75
-To make your model compatible with the Transformers modeling backend, it needs:
76
-
77
-1. `kwargs` passed down through all modules from `MyModel` to `MyAttention`.
78
- - If your model is encoder-only:
79
- 1. Add `is_causal = False` to `MyAttention`.
80
- - If your model is mixture-of-experts (MoE):
81
- 1. Your sparse MoE block must have an attribute called `experts`.
82
- 2. The class of `experts` (`MyExperts`) must either:
83
- - Inherit from `nn.ModuleList` (naive).
84
- - Or contain all 3D `nn.Parameters` (packed).
85
- 3. `MyExperts.forward` must accept `hidden_states`, `top_k_index`, `top_k_weights`.
86
-2. `MyAttention` must use `ALL_ATTENTION_FUNCTIONS` to call attention.
87
-3. `MyModel` must contain `_supports_attention_backend = True`.
75
+To make your model compatible with the Transformers modeling backend:
76
+
77
+1. `MyAttention` must use `ALL_ATTENTION_FUNCTIONS` to call attention.
78
+ - It must make exactly one such call. vLLM attaches one `Attention` layer per `MyAttention` module.
79
+ - `MyAttention` must contain a unique `layer_idx`. vLLM keys its KV cache using this index.
80
+ - Pass `scaling=` to the interface if your scale is not `head_size**-0.5`. vLLM reads it from the call to the attention interface.
81
+2. If your model is encoder-only:
82
+ 1. Add `is_causal = False` to `MyAttention`.
83
+3. If your model is mixture-of-experts (MoE):
84
+ 1. Your sparse MoE block must have an attribute called `experts`.
85
+ 2. The class of `experts` (`MyExperts`) must either:
86
+ - Inherit from `nn.ModuleList` (naive).
87
+ - Or contain all 3D `nn.Parameters` (packed).
88
+ 3. `MyExperts.forward` must accept `hidden_states`, `top_k_index`, `top_k_weights`.
89
+
90
+!!! note
91
+ `MyModel` no longer needs `_supports_attention_backend = True`, and `kwargs` no
92
+ longer need to be passed down through every module from `MyModel` to
93
+ `MyAttention`. vLLM reaches its attention layer through `MyAttention` itself, so
94
+ all it asks is that `MyAttention` dispatches through the interface.
95
96
<details class="code">
97
<summary>modeling_my_model.py</summary>
@@ -97,14 +104,24 @@ from torch import nn
104
class MyAttention(nn.Module):
105
is_causal = False # Only do this for encoder-only models
106
107
+ def __init__(self, config, layer_idx):
108
+ ...
109
+ self.config = config
110
+ self.layer_idx = layer_idx
111
+ self.scaling = self.head_dim**-0.5
112
+ ...
113
+
114
def forward(self, hidden_states, **kwargs):
115
...
102
- attention_interface = ALL_ATTENTION_FUNCTIONS[self.config._attn_implementation]
116
+ attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
117
+ self.config._attn_implementation, eager_attention_forward
118
+ )
119
attn_output, attn_weights = attention_interface(
120
self,
121
query_states,
122
key_states,
123
value_states,
124
+ scaling=self.scaling,
125
**kwargs,
126
)
127
...
@@ -127,7 +144,7 @@ class MySparseMoEBlock(nn.Module):
144
...
145
146
class MyModel(PreTrainedModel):
130
- _supports_attention_backend = True
147
+ ...
148
```
149
150
</details>
@@ -135,7 +152,7 @@ class MyModel(PreTrainedModel):
152
Here is what happens in the background when this model is loaded:
153
154
1. The config is loaded.
138
-2. `MyModel` Python class is loaded from the `auto_map` in config, and we check that the model `is_backend_compatible()`.
155
+2. `MyModel` Python class is loaded from the `auto_map` in config, and we check that the model `_can_set_attn_implementation()`.
156
3. `MyModel` is loaded into one of the Transformers modeling backend classes in [vllm/model_executor/models/transformers](../../vllm/model_executor/models/transformers) which sets `self.config._attn_implementation = "vllm"` so that vLLM's attention layer is used.
157
158
That's it!
tests/models/transformers/fusers/test_linear.py
+41
-35
@@ -10,7 +10,7 @@ import torch
10
import torch.nn as nn
11
import torch.nn.functional as F
12
13
-from vllm.model_executor.models.transformers.fuser import get_fuser
13
+from vllm.model_executor.models.transformers.fuser import get_fuser, get_fusers
14
from vllm.model_executor.models.transformers.fusers import (
15
GLUFuser,
16
PackedQKVFuser,
@@ -307,7 +307,7 @@ class PerHeadSplitAttention(nn.Module):
307
308
309
class FakeSelfAttn(nn.Module):
310
- """Stand-in for the vLLM `Attention` looked up in `attention_instances`."""
310
+ """Stand-in for the vLLM `Attention` attached to the dispatching module."""
311
312
def __init__(self):
313
super().__init__()
@@ -328,9 +328,9 @@ class FakeMQASelfAttn(FakeSelfAttn):
328
329
@pytest.fixture(autouse=True)
330
def _clear_fuser_cache():
331
- get_fuser.cache_clear()
331
+ get_fusers.cache_clear()
332
yield
333
- get_fuser.cache_clear()
333
+ get_fusers.cache_clear()
334
335
336
def _apply_glu_fuser_with_stubs(module: nn.Module, fuser: GLUFuser):
@@ -392,7 +392,7 @@ def _apply_packed_qkv_fuser_with_stubs(module: nn.Module, fuser: PackedQKVFuser)
392
def test_detects_and_rewrites_glu(mlp_cls, bias):
393
with torch.device("meta"):
394
meta = mlp_cls(bias=bias)
395
- fuser = get_fuser(meta)
395
+ fuser = get_fuser(meta, GLUFuser)
396
assert isinstance(fuser, GLUFuser)
397
assert (
398
fuser.gate_name,
@@ -427,9 +427,9 @@ def test_glu_identifies_down_projection():
427
matches the column-parallel merged gate/up; `None` when there is no such
428
projection to force (fusion of gate/up still applies)."""
429
with torch.device("meta"):
430
- assert get_fuser(GLUMLP()).down_name == "down_proj"
431
- assert get_fuser(ReversedGLUMLP()).down_name == "down_proj"
432
- assert get_fuser(NoDownGLU()).down_name is None
430
+ assert get_fuser(GLUMLP(), GLUFuser).down_name == "down_proj"
431
+ assert get_fuser(ReversedGLUMLP(), GLUFuser).down_name == "down_proj"
432
+ assert get_fuser(NoDownGLU(), GLUFuser).down_name is None
433
434
435
@pytest.mark.parametrize("attn_cls", [FakeAttention, ReversedFakeAttention])
@@ -439,7 +439,7 @@ def test_detects_and_rewrites_qkv(attn_cls, kv_heads):
439
pytest.skip("MHA q/k/v assignment is order-based by design")
440
with torch.device("meta"):
441
meta = attn_cls(kv_heads=kv_heads)
442
- fuser = get_fuser(meta)
442
+ fuser = get_fuser(meta, QKVFuser)
443
assert isinstance(fuser, QKVFuser)
444
# q (sharded differently under TP) must be identified exactly; k/v may be
445
# swapped for non-canonical compute order, which is numerically consistent
@@ -467,27 +467,27 @@ def test_detects_and_rewrites_qkv(attn_cls, kv_heads):
467
for p in real.parameters():
468
nn.init.normal_(p, std=0.05)
469
x = torch.randn(1, 5, 32)
470
- attention_instances = {3: FakeSelfAttn()}
471
- expected, _ = real(x, attention_instances=attention_instances)
470
+ real.attn = FakeSelfAttn()
471
+ expected, _ = real(x)
472
fused = _apply_qkv_fuser_with_stubs(real, fuser)
473
474
# Fusion is in place: the module keeps its class and other attributes
475
assert fused is real and type(fused) is attn_cls
476
assert fused.layer_idx == 3 and fused.is_causal and fused.config is not None
477
- out, _ = fused(x, attention_instances=attention_instances)
477
+ out, _ = fused(x)
478
torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5)
479
480
481
def test_qkv_identifies_output_projection():
482
with torch.device("meta"):
483
- assert get_fuser(FakeAttention()).o_name == "o_proj"
484
- assert get_fuser(ReversedFakeAttention()).o_name == "o_proj"
485
- assert get_fuser(ExtraProjAttention()).o_name == "o_proj"
483
+ assert get_fuser(FakeAttention(), QKVFuser).o_name == "o_proj"
484
+ assert get_fuser(ReversedFakeAttention(), QKVFuser).o_name == "o_proj"
485
+ assert get_fuser(ExtraProjAttention(), QKVFuser).o_name == "o_proj"
486
# Norm children (q_norm/k_norm) must not disturb o_proj identification.
487
- assert get_fuser(QKNormAttention()).o_name == "o_proj"
488
- assert get_fuser(PerHeadQKNormAttention()).o_name == "o_proj"
487
+ assert get_fuser(QKNormAttention(), QKVFuser).o_name == "o_proj"
488
+ assert get_fuser(PerHeadQKNormAttention(), QKVFuser).o_name == "o_proj"
489
# A module between o_proj and the return is transparent.
490
- assert get_fuser(ResidDropoutAttention()).o_name == "o_proj"
490
+ assert get_fuser(ResidDropoutAttention(), QKVFuser).o_name == "o_proj"
491
492
493
@pytest.mark.parametrize("kv_heads", [1, 2])
@@ -498,7 +498,7 @@ def test_detects_and_rewrites_packed_qkv(kv_heads):
498
checkpoint weight as-is, and shards q by heads while replicating k/v."""
499
with torch.device("meta"):
500
meta = PackedQKVAttention(kv_heads=kv_heads)
501
- fuser = get_fuser(meta)
501
+ fuser = get_fuser(meta, PackedQKVFuser)
502
assert isinstance(fuser, PackedQKVFuser)
503
assert (fuser.qkv_name, fuser.o_name) == ("c_attn", "c_proj")
504
assert (fuser.q_size, fuser.kv_size) == (32, 8 * kv_heads)
@@ -513,14 +513,14 @@ def test_detects_and_rewrites_packed_qkv(kv_heads):
513
for p in real.parameters():
514
nn.init.normal_(p, std=0.05)
515
x = torch.randn(1, 5, 32)
516
- attention_instances = {3: FakeMQASelfAttn()}
517
- expected, _ = real(x, attention_instances=attention_instances)
516
+ real.attn = FakeMQASelfAttn()
517
+ expected, _ = real(x)
518
fused = _apply_packed_qkv_fuser_with_stubs(real, fuser)
519
520
# Fusion is in place: the module keeps its class and other attributes
521
assert fused is real and type(fused) is PackedQKVAttention
522
assert fused.layer_idx == 3 and fused.is_causal
523
- out, _ = fused(x, attention_instances=attention_instances)
523
+ out, _ = fused(x)
524
torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5)
525
526
@@ -528,28 +528,28 @@ def test_per_head_split_is_not_packed_qkv():
528
"""The split must consume the whole projection, else its sizes are head
529
widths and re-sharding by them would be wrong."""
530
with torch.device("meta"):
531
- assert get_fuser(PerHeadSplitAttention()) is None
531
+ assert get_fuser(PerHeadSplitAttention(), PackedQKVFuser) is None
532
533
534
def test_fuser_is_cached_per_class_and_structure():
535
with torch.device("meta"):
536
- fuser_a = get_fuser(GLUMLP())
537
- fuser_b = get_fuser(GLUMLP())
536
+ fuser_a = get_fuser(GLUMLP(), GLUFuser)
537
+ fuser_b = get_fuser(GLUMLP(), GLUFuser)
538
assert fuser_a is fuser_b
539
- assert any(key[0] is GLUMLP for key in get_fuser.cache)
539
+ assert any(key[0] is GLUMLP for key in get_fusers.cache)
540
541
542
@pytest.mark.parametrize("cls", [NotAnMLP, UntraceableMLP])
543
def test_non_matching_modules_return_none(cls):
544
with torch.device("meta"):
545
module = cls()
546
- assert get_fuser(module) is None
546
+ assert get_fuser(module, GLUFuser) is None
547
548
549
def test_untraceable_tail_still_fuses():
550
with torch.device("meta"):
551
meta = UntraceableTailGLUMLP()
552
- fuser = get_fuser(meta)
552
+ fuser = get_fuser(meta, GLUFuser)
553
assert isinstance(fuser, GLUFuser)
554
555
# Numerics: the live tail must survive the rewrite
@@ -566,8 +566,8 @@ def test_weight_mappings_are_scoped_to_fused_prefixes():
566
from vllm.model_executor.models.utils import WeightsMapper
567
568
with torch.device("meta"):
569
- glu_fuser = get_fuser(GLUMLP())
570
- qkv_fuser = get_fuser(FakeAttention())
569
+ glu_fuser = get_fuser(GLUMLP(), GLUFuser)
570
+ qkv_fuser = get_fuser(FakeAttention(), QKVFuser)
571
572
mapper = WeightsMapper()
573
for prefix in ("model.layers.0.mlp", "model.layers.1.mlp"):
@@ -620,7 +620,7 @@ def test_weight_mappings_are_scoped_to_fused_prefixes():
620
def test_unfusable_modules_are_not_fused(cls, default_vllm_config):
621
with torch.device("meta"):
622
module = cls()
623
- fuser = get_fuser(module)
623
+ fuser = get_fuser(module, GLUFuser)
624
# Either no pattern matches the class, or this instance fails validation
625
# (`recursive_replace` gates fusion and its weight mappings on `validate`)
626
assert fuser is None or not fuser.validate(module, default_vllm_config)
@@ -654,9 +654,15 @@ def _wider_model_config(head_dim: int) -> SimpleNamespace:
654
655
656
@pytest.mark.parametrize(
657
- "cls, fuser_module", [(FakeAttention, qkv), (PackedQKVAttention, packed_qkv)]
657
+ "cls, fuser_module, fuser_cls",
658
+ [
659
+ (FakeAttention, qkv, QKVFuser),
660
+ (PackedQKVAttention, packed_qkv, PackedQKVFuser),
661
+ ],
662
)
659
-def test_head_counts_come_from_the_module_not_the_model(cls, fuser_module, monkeypatch):
663
+def test_head_counts_come_from_the_module_not_the_model(
664
+ cls, fuser_module, fuser_cls, monkeypatch
665
+):
666
"""A layer narrower than the model-wide head size must not be miscounted.
667
668
On a heterogeneous checkpoint (Gemma 4) the model-wide head size is the
@@ -680,7 +686,7 @@ def test_head_counts_come_from_the_module_not_the_model(cls, fuser_module, monke
686
monkeypatch.setattr(
687
fuser_module, "replace_linear_class", lambda *a, **kw: nn.Identity()
688
)
683
- fuser = get_fuser(module)
689
+ fuser = get_fuser(module, fuser_cls)
690
assert fuser is not None and fuser.validate(module, vllm_config)
691
fuser.update_attrs(module, "model.layers.0.self_attn", vllm_config)
692
@@ -700,5 +706,5 @@ def test_validate_accepts_a_layer_the_model_wide_head_size_would_reject():
706
707
# kv width is 24, not a multiple of the model-wide 16, but is of this
708
# layer's 8.
703
- fuser = get_fuser(module)
709
+ fuser = get_fuser(module, QKVFuser)
710
assert fuser is not None and fuser.validate(module, vllm_config)
tests/models/transformers/fusers/test_mla.py
+1
-1
@@ -158,4 +158,4 @@ class GLU(nn.Module):
158
159
def test_non_mla_is_not_matched():
160
with torch.device("meta"):
161
- assert not isinstance(get_fuser(GLU()), MLAFuser)
161
+ assert get_fuser(GLU(), MLAFuser) is None
tests/models/transformers/fusers/test_rms_norm.py
+11
-10
@@ -162,7 +162,7 @@ class UntraceableGatedRMSNorm(RMSNorm):
162
)
163
def test_detects_rms_norm_variants(cls, eps, zero_centered):
164
with torch.device("meta"):
165
- fuser = get_fuser(cls(16, eps=eps))
165
+ fuser = get_fuser(cls(16, eps=eps), RMSNormFuser)
166
assert isinstance(fuser, RMSNormFuser)
167
assert fuser.zero_centered == zero_centered
168
@@ -171,7 +171,7 @@ def test_detects_rms_norm_variants(cls, eps, zero_centered):
171
def test_non_rms_norms_are_not_matched(cls):
172
with torch.device("meta"):
173
module = cls(16) if cls is nn.LayerNorm else cls()
174
- assert not isinstance(get_fuser(module), RMSNormFuser)
174
+ assert get_fuser(module, RMSNormFuser) is None
175
176
177
@pytest.mark.parametrize(
@@ -179,7 +179,7 @@ def test_non_rms_norms_are_not_matched(cls):
179
)
180
def test_gated_rms_norm_is_not_fused(cls):
181
with torch.device("meta"):
182
- assert not isinstance(get_fuser(cls()), RMSNormFuser)
182
+ assert get_fuser(cls(), RMSNormFuser) is None
183
184
185
@pytest.mark.parametrize(
@@ -196,7 +196,7 @@ def test_rms_norm_builds_vllm_class(cls, expected, zero_centered, default_vllm_c
196
197
with torch.device("meta"):
198
module = cls()
199
- fuser = get_fuser(module)
199
+ fuser = get_fuser(module, RMSNormFuser)
200
built = fuser.fuse(module, "norm", default_vllm_config)
201
from vllm.model_executor.models.transformers.fusers.rms_norm import (
202
TPAwareNormMixin,
@@ -221,7 +221,7 @@ def test_weightless_norm_has_no_hidden_size(default_vllm_config):
221
matching the unfused norm and vLLM's native `RMSNorm(hidden_size=head_dim)`.
222
"""
223
module = WeightlessRMSNorm(72)
224
- built = get_fuser(module).fuse(module, "norm", default_vllm_config)
224
+ built = get_fuser(module, RMSNormFuser).fuse(module, "norm", default_vllm_config)
225
assert built.hidden_size == 0
226
227
built.tp_size = 2 # emulate TP=2 without a real process group
@@ -236,7 +236,7 @@ def test_fused_rms_norm_op_default_eps(default_vllm_config):
236
237
with torch.device("meta"):
238
module = torch.nn.RMSNorm(16) # forward is a single `F.rms_norm` call
239
- fuser = get_fuser(module)
239
+ fuser = get_fuser(module, RMSNormFuser)
240
assert isinstance(fuser, RMSNormFuser)
241
assert not fuser.zero_centered
242
vllm_config = SimpleNamespace(model_config=SimpleNamespace(dtype=torch.float32))
@@ -251,7 +251,8 @@ def test_eps_is_derived_per_instance(default_vllm_config):
251
with torch.device("meta"):
252
for eps in (1e-5, 1e-6):
253
module = RMSNorm(16, eps=eps)
254
- built = get_fuser(module).fuse(module, "norm", default_vllm_config)
254
+ fuser = get_fuser(module, RMSNormFuser)
255
+ built = fuser.fuse(module, "norm", default_vllm_config)
256
assert built.variance_epsilon == eps
257
258
@@ -262,7 +263,7 @@ def test_eps_attr_is_found_by_value_not_name(cls, default_vllm_config):
263
with torch.device("meta"):
264
for eps in (1e-5, 1e-6):
265
module = cls(16, eps=eps)
265
- fuser = get_fuser(module)
266
+ fuser = get_fuser(module, RMSNormFuser)
267
assert fuser.eps_attr == cls.attr
268
built = fuser.fuse(module, "norm", default_vllm_config)
269
assert built.variance_epsilon == eps
@@ -274,7 +275,7 @@ def test_literal_eps_is_not_mistaken_for_an_attribute(default_vllm_config, caplo
275
logger = "vllm.model_executor.models.transformers.fusers.rms_norm"
276
with caplog.at_level("DEBUG", logger=logger), torch.device("meta"):
277
module = LiteralEpsRMSNorm()
277
- fuser = get_fuser(module)
278
+ fuser = get_fuser(module, RMSNormFuser)
279
built = fuser.fuse(module, "norm", default_vllm_config)
280
assert fuser.eps_attr is None
281
assert built.variance_epsilon == 1e-4
@@ -287,7 +288,7 @@ def test_ambiguous_eps_attrs_are_disambiguated(default_vllm_config):
288
with torch.device("meta"):
289
module = AmbiguousEpsRMSNorm(16, eps=1e-6)
290
before = dict(vars(module))
290
- fuser = get_fuser(module)
291
+ fuser = get_fuser(module, RMSNormFuser)
292
assert fuser.eps_attr == "variance_epsilon"
293
assert vars(module) == before
294
tests/models/transformers/test_backend.py
+142
@@ -2,9 +2,11 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Test the functionality of the Transformers modeling backend."""
4
5
+import ast
6
import contextlib
7
import os
8
import tempfile
9
+from types import SimpleNamespace
10
from typing import Any
11
12
import pytest
@@ -15,6 +17,8 @@ from transformers import AutoConfig, AutoModel, PretrainedConfig
17
from vllm.config import ModelConfig, VllmConfig
18
from vllm.model_executor.models.interfaces import SupportsMultiModal
19
from vllm.model_executor.models.transformers.base import Base
20
+from vllm.model_executor.models.transformers.fusers import AttentionFuser
21
+from vllm.model_executor.models.transformers.fusers.attention import VLLM_ATTN_IMPL
22
from vllm.model_executor.models.transformers.multimodal import MultiModalMixin
23
from vllm.model_executor.models.utils import StageMissingLayer
24
@@ -583,3 +587,141 @@ def test_marking_skipped_without_tokenizer():
587
"""
588
vision_tower = build_marked_model(0, skip_tokenizer_init=True).vision_tower
589
assert not isinstance(vision_tower, StageMissingLayer)
590
+
591
+
592
+NUM_LAYERS = 4
593
+
594
+
595
+def build_model(model_type: str, **overrides) -> nn.Module:
596
+ """A tiny HF model of `NUM_LAYERS` layers, built on the meta device."""
597
+ try:
598
+ config = AutoConfig.for_model(
599
+ model_type,
600
+ num_hidden_layers=NUM_LAYERS,
601
+ hidden_size=64,
602
+ intermediate_size=128,
603
+ num_attention_heads=4,
604
+ num_key_value_heads=2,
605
+ head_dim=16,
606
+ vocab_size=VOCAB_SIZE,
607
+ **overrides,
608
+ )
609
+ except ValueError:
610
+ pytest.skip(f"The installed transformers has no {model_type!r} model")
611
+ with torch.device("meta"):
612
+ return AutoModel.from_config(config)
613
+
614
+
615
+ATTENTION_MODEL_TYPES = [
616
+ # The default `head_size**-0.5` happens to be right here
617
+ "llama",
618
+ # Scales the query by a learnable per-dim weight, so it declares 1.0
619
+ "gemma4_text",
620
+ # MLA, and declares yarn's `mscale`
621
+ "deepseek_v3",
622
+]
623
+
624
+
625
+@pytest.mark.parametrize("model_type", ATTENTION_MODEL_TYPES)
626
+def test_attention_dispatch_is_matched(model_type: str):
627
+ """Exactly the decoder layers' attention modules match an `AttentionFuser`.
628
+
629
+ That match is what `recursive_replace` records, and so what
630
+ `create_attention_instances` attaches vLLM's attention layer to.
631
+ """
632
+ model = build_model(model_type)
633
+ matched = {
634
+ name
635
+ for name, module in model.named_modules()
636
+ if AttentionFuser.match(None, module) is not None
637
+ }
638
+ assert matched == {f"layers.{i}.self_attn" for i in range(NUM_LAYERS)}
639
+
640
+
641
+def test_attention_layer_index_is_the_modules_own():
642
+ """The layer served comes from the module, not from its position in the stack.
643
+
644
+ LongCat Flash gives each decoder layer two attention sublayers numbered
645
+ `2i` and `2i + 1`, so `num_hidden_layers` is twice the length of the stack
646
+ and the enclosing layer's position is not the index the KV cache is keyed by.
647
+ A vision tower is excluded separately, by `validate`: only the text config is
648
+ patched to dispatch to vLLM, so a tower's attention is left to Transformers.
649
+ """
650
+ model = build_model("longcat_flash", num_layers=2)
651
+ text_config = model.config.get_text_config()
652
+ assert len(model.layers) * 2 == text_config.num_hidden_layers
653
+
654
+ attentions = [
655
+ model.get_submodule(f"layers.{i}.self_attn.{j}") for i in (0, 1) for j in (0, 1)
656
+ ]
657
+ fusers = [AttentionFuser.match(None, attn) for attn in attentions]
658
+ assert all(fuser is not None for fuser in fusers)
659
+ assert [f.layer_index(a) for f, a in zip(fusers, attentions)] == [0, 1, 2, 3]
660
+
661
+ # Not dispatching to vLLM yet, as a vision tower never would be. The vLLM
662
+ # config is not consulted, only the one the module was built with.
663
+ assert not any(f.validate(a, None) for f, a in zip(fusers, attentions))
664
+ text_config._attn_implementation = VLLM_ATTN_IMPL
665
+ assert all(f.validate(a, None) for f, a in zip(fusers, attentions))
666
+
667
+
668
+def test_attention_dispatch_is_required(monkeypatch: pytest.MonkeyPatch):
669
+ """Models selected by the permissive registry fail clearly without dispatch."""
670
+ monkeypatch.setattr(
671
+ "vllm.model_executor.models.transformers.base.get_pp_indices",
672
+ lambda *_: (0, 1),
673
+ )
674
+ model = SimpleNamespace(
675
+ text_config=SimpleNamespace(num_hidden_layers=1),
676
+ pp_group=SimpleNamespace(rank_in_group=0, world_size=1),
677
+ attention_fusers={},
678
+ _get_attn_cls=lambda: None,
679
+ )
680
+
681
+ with pytest.raises(
682
+ ValueError,
683
+ match="Layer 0 does not dispatch through the Transformers attention interface",
684
+ ):
685
+ Base.create_attention_instances(model)
686
+
687
+
688
+@pytest.mark.parametrize("model_type", ATTENTION_MODEL_TYPES)
689
+def test_attention_scale_is_the_declared_one(model_type: str):
690
+ """The scale comes off the module, not from the `head_size**-0.5` default.
691
+
692
+ Two of the three model types declare something the default would silently
693
+ replace, so they are what makes this more than a tautology.
694
+ """
695
+ attention = build_model(model_type).layers[0].self_attn
696
+ fuser = AttentionFuser.match(None, attention)
697
+ assert fuser is not None
698
+ assert fuser.scale(attention) == attention.scaling
699
+
700
+
701
+def test_attention_scale_is_the_argument_not_the_attribute():
702
+ """OPT applies the scale to the query itself, then declares a literal 1.0.
703
+
704
+ Its `self.scaling` is the `head_size**-0.5` it has already applied, so
705
+ reading the attribute rather than the argument the module hands the
706
+ interface would scale twice.
707
+ """
708
+ model = build_model("opt", ffn_dim=128, word_embed_proj_dim=64)
709
+ attention = model.get_submodule("decoder.layers.0.self_attn")
710
+ fuser = AttentionFuser.match(None, attention)
711
+ assert fuser is not None
712
+ assert fuser.scale(attention) == 1.0 != attention.scaling
713
+
714
+
715
+def test_attention_scale_rejects_unresolvable_expression():
716
+ """Unresolvable declared scales must not silently use the Llama default."""
717
+
718
+ class Attention(nn.Module):
719
+ head_dim = 16
720
+
721
+ fuser = AttentionFuser(
722
+ source_cls="Attention",
723
+ scale_expr=ast.parse("self.head_dim**-0.5 * self.factor", mode="eval").body,
724
+ )
725
+
726
+ with pytest.raises(ValueError, match="Cannot resolve attention scaling expression"):
727
+ fuser.scale(Attention())
vllm/model_executor/models/registry.py
+4
-1
@@ -1253,7 +1253,10 @@ class _ModelRegistry:
1253
"'auto_map' (relevant if the model is custom)."
1254
)
1255
1256
- if not model_module.is_backend_compatible():
1256
+ if not (
1257
+ model_module.is_backend_compatible()
1258
+ or model_module._can_set_attn_implementation()
1259
+ ):
1260
if model_config.model_impl != "transformers":
1261
return None
1262
vllm/model_executor/models/transformers/__init__.py
+3
-15
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING
21
import torch.nn.functional as F
22
from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
23
24
-from vllm.model_executor.models.transformers.base import Base
24
+from vllm.model_executor.models.transformers.base import VLLM_ATTN_ATTR, Base
25
from vllm.model_executor.models.transformers.causal import CausalMixin
26
from vllm.model_executor.models.transformers.legacy import LegacyMixin
27
from vllm.model_executor.models.transformers.moe import MoEMixin
@@ -40,8 +40,6 @@ from vllm.multimodal import MULTIMODAL_REGISTRY
40
if TYPE_CHECKING:
41
import torch
42
43
- from vllm.model_executor.layers.attention import Attention, MLAAttention
44
-
43
44
def vllm_attention_forward(
45
# Transformers args
@@ -50,15 +48,9 @@ def vllm_attention_forward(
48
key: "torch.Tensor",
49
value: "torch.Tensor",
50
attention_mask: "torch.Tensor",
53
- # Transformers kwargs
54
- scaling: float | None = None,
55
- # vLLM kwargs
56
- attention_instances: "dict[int, Attention] | None" = None,
51
**kwargs,
52
):
59
- self_attn = attention_instances[module.layer_idx]
60
- if scaling is not None:
61
- self_attn.impl.scale = float(scaling)
53
+ self_attn = getattr(module, VLLM_ATTN_ATTR)
54
hidden = query.shape[-2]
55
head_dim_qk = query.shape[-1]
56
head_dim_v = value.shape[-1]
@@ -85,13 +77,9 @@ def vllm_mla_attention_forward(
77
kv_c_normed: "torch.Tensor",
78
k_pe: "torch.Tensor",
79
attention_mask: "torch.Tensor",
88
- # Transformers kwargs
89
- scaling: float | None = None,
90
- # vLLM kwargs
91
- attention_instances: "dict[int, MLAAttention] | None" = None,
80
**kwargs,
81
):
94
- self_attn = attention_instances[module.layer_idx]
82
+ self_attn = getattr(module, VLLM_ATTN_ATTR)
83
# [batch=1, heads, num_tokens, qk_head_dim] -> [num_tokens, heads, qk_head_dim]
84
query = query.transpose(1, 2).flatten(0, 1)
85
num_tokens, num_heads = query.shape[:2]
vllm/model_executor/models/transformers/base.py
+78
-34
@@ -57,7 +57,11 @@ from vllm.model_executor.models.interfaces import (
57
)
58
from vllm.model_executor.models.interfaces_base import VllmModel
59
from vllm.model_executor.models.transformers.fuser import BaseFuser, Fusers
60
-from vllm.model_executor.models.transformers.fusers import MLAFuser
60
+from vllm.model_executor.models.transformers.fusers import AttentionFuser, MLAFuser
61
+from vllm.model_executor.models.transformers.fusers.attention import (
62
+ VLLM_ATTN_IMPL,
63
+ VLLM_MLA_ATTN_IMPL,
64
+)
65
from vllm.model_executor.models.transformers.utils import (
66
attrsetter,
67
can_enable_torch_compile,
@@ -73,7 +77,6 @@ from vllm.model_executor.models.utils import (
77
AutoWeightsLoader,
78
PPMissingLayer,
79
WeightsMapper,
76
- extract_layer_index,
80
make_empty_intermediate_tensors_factory,
81
maybe_prefix,
82
)
@@ -86,6 +89,11 @@ if TYPE_CHECKING:
89
90
logger = init_logger(__name__)
91
92
+VLLM_ATTN_ATTR = "attn"
93
+"""Attribute in which to store the vLLM `Attention` instance that serves a Transformers
94
+attention module. `"attn"` is chosen deliberately to match the mappings generated by
95
+`maybe_remap_kv_scale_name`."""
96
+
97
98
class PreTrainedModelClasses(NamedTuple):
99
decoder: type["PreTrainedModel"]
@@ -129,9 +137,13 @@ class Base(
137
self.packed_modules_mapping: dict[str, list[str]] = {}
138
"""Fused module -> constituent projections, populated by `recursive_replace`
139
for the quantization machinery and loaders (e.g. bitsandbytes)."""
132
- self.fusers: dict[str, BaseFuser] = {}
133
- """Module qualname -> the fuser applied to it, populated
140
+ self.fusers: dict[str, list[BaseFuser]] = {}
141
+ """Module qualname -> the fusers applied to it, populated
142
by `recursive_replace` for `create_attention_instances`."""
143
+ self.attention_fusers: dict[int, tuple[str, AttentionFuser]] = {}
144
+ """`layer_idx` -> the qualname and fuser of the module computing that
145
+ layer's attention, populated by `recursive_replace` for
146
+ `create_attention_instances`."""
147
148
# Attrs for Eagle3 (see self.set_aux_hidden_state_layers)
149
self._target_class: type[nn.Module] = nn.Module
@@ -189,7 +201,7 @@ class Base(
201
- Sets the dtype to the default torch dtype set by vLLM because Transformers
202
uses the config dtype when creating the model
203
"""
192
- self.text_config._attn_implementation = "vllm"
204
+ self.text_config._attn_implementation = VLLM_ATTN_IMPL
205
self.config.dtype = torch.get_default_dtype()
206
207
@contextmanager
@@ -475,9 +487,22 @@ class Base(
487
488
vocab_embeddings = self._vocab_embeddings()
489
478
- def register_fusion(fuser: BaseFuser, prefix: str):
490
+ def register_fusion(fuser: BaseFuser, prefix: str, module: nn.Module):
491
"""Register a fused layer's mappings just before it is built."""
480
- self.fusers[prefix] = fuser
492
+ self.fusers.setdefault(prefix, []).append(fuser)
493
+
494
+ if (
495
+ isinstance(fuser, AttentionFuser)
496
+ and (index := fuser.layer_index(module)) is not None
497
+ ):
498
+ if index in self.attention_fusers:
499
+ other, _ = self.attention_fusers[index]
500
+ raise ValueError(
501
+ f"Layer {index} dispatches attention from more than one "
502
+ f"module ({other} and {prefix}), which one vLLM attention "
503
+ "layer cannot serve."
504
+ )
505
+ self.attention_fusers[index] = (prefix, fuser)
506
507
orig_to_new_stacked = fuser.orig_to_new_stacked(prefix)
508
self.hf_to_vllm_mapper.orig_to_new_stacked.update(orig_to_new_stacked)
@@ -528,10 +553,11 @@ class Base(
553
new_module = replace_embedding_class(
554
child_module, self.quant_config, prefix=qual_name
555
)
531
- elif (fuser := fusers[child_module]) is not None:
532
- register_fusion(fuser, qual_name)
533
- new_module = fuser.fuse(child_module, qual_name, self.vllm_config)
534
- logger.info_once(fuser.info(child_name))
556
+ elif child_module_fusers := fusers[child_module]:
557
+ for fuser in child_module_fusers:
558
+ register_fusion(fuser, qual_name, child_module)
559
+ new_module = fuser.fuse(new_module, qual_name, self.vllm_config)
560
+ logger.info_once(fuser.info(child_name))
561
_recursive_replace(new_module, prefix=qual_name)
562
elif not isinstance(child_module, MoERunner):
563
# MoERunner can contain aliases of shared experts and gates,
@@ -548,20 +574,14 @@ class Base(
574
"""
575
Create `Attention` instances to inform KV cache allocation.
576
"""
551
- mla_fusers = {}
577
attention_instances = {}
578
text_config = self.text_config
579
attn_cls = self._get_attn_cls()
580
581
# kv_lora_rank indicates that this is an MLA model
582
if getattr(text_config, "kv_lora_rank", None) is not None:
558
- mla_fusers = {
559
- extract_layer_index(prefix): (prefix, fuser)
560
- for prefix, fuser in self.fusers.items()
561
- if isinstance(fuser, MLAFuser)
562
- }
583
if attn_cls is MLAAttention:
564
- text_config._attn_implementation = "vllm_mla"
584
+ text_config._attn_implementation = VLLM_MLA_ATTN_IMPL
585
else:
586
# MLA model not using MLAAttention: recompute head_size for full attn
587
qk_nope_head_dim = getattr(text_config, "qk_nope_head_dim", 0)
@@ -570,12 +590,23 @@ class Base(
590
self.model_config.model_arch_config.head_size = qk_head_dim
591
592
logits_soft_cap = getattr(text_config, "attn_logit_softcapping", None)
593
+ layer_types = getattr(text_config, "layer_types", None)
594
595
pp_rank = self.pp_group.rank_in_group
596
pp_size = self.pp_group.world_size
597
start, end = get_pp_indices(text_config.num_hidden_layers, pp_rank, pp_size)
598
599
for i in range(start, end):
600
+ if i not in self.attention_fusers:
601
+ in_range = layer_types and i < len(layer_types)
602
+ layer = f"{i} ({layer_types[i]})" if in_range else str(i)
603
+ raise ValueError(
604
+ f"Layer {layer} does not dispatch through the Transformers "
605
+ "attention interface and vLLM has no other way to handle it."
606
+ )
607
+ prefix, attn_fuser = self.attention_fusers[i]
608
+ attn_module = self.get_submodule(prefix)
609
+
610
# `[i]` is the whole-model config unless the checkpoint is
611
# heterogeneous, in which case it is this layer's own geometry.
612
arch_config = self.model_config.model_arch_config[i]
@@ -583,8 +614,10 @@ class Base(
614
self.parallel_config, arch_config
615
)
616
head_size = arch_config.head_size
586
- # Default to Llama scale, maybe updated in vllm_attention_forward
587
- scale = head_size**-0.5
617
+ scale = attn_fuser.scale(attn_module)
618
+ # Default to Llama scale if AttentionFuser couldn't identify it
619
+ if scale is None:
620
+ scale = head_size**-0.5
621
num_kv_heads = self.model_config.get_num_kv_heads(
622
self.parallel_config, arch_config
623
)
@@ -598,17 +631,22 @@ class Base(
631
)
632
633
if attn_cls is MLAAttention:
601
- prefix, fuser = mla_fusers[i]
602
- mla_module = self.get_submodule(prefix)
634
+ mla_fuser = next(
635
+ (f for f in self.fusers[prefix] if isinstance(f, MLAFuser)), None
636
+ )
637
+ if mla_fuser is None:
638
+ raise ValueError(
639
+ f"Layer {i} ({prefix}) did not fuse as MLA, but the rest of "
640
+ "the model did, so its latent KV cache cannot be allocated."
641
+ )
642
dims = get_mla_dims(self.model_config)
643
kwargs.update(
605
- scale=mla_module.scaling,
644
qk_nope_head_dim=dims.qk_nope_head_dim,
645
qk_rope_head_dim=dims.qk_rope_head_dim,
646
v_head_dim=dims.v_head_dim,
647
q_lora_rank=dims.q_lora_rank,
648
kv_lora_rank=dims.kv_lora_rank,
611
- kv_b_proj=mla_module.get_submodule(fuser.kv_b_proj_name),
649
+ kv_b_proj=attn_module.get_submodule(mla_fuser.kv_b_proj_name),
650
)
651
else:
652
kwargs.update(
@@ -618,17 +656,20 @@ class Base(
656
)
657
658
# Handle interleaved sliding window attention
621
- if (
622
- hasattr(text_config, "layer_types")
623
- and text_config.layer_types[i] == "sliding_attention"
624
- ):
659
+ if layer_types and layer_types[i] == "sliding_attention":
660
kwargs["per_layer_sliding_window"] = text_config.sliding_window
661
662
attn_instance = attn_cls(**kwargs)
628
- if attn_cls is MLAAttention:
629
- # Attach MLA attn_instance to mla_module so it appears in
630
- # model.named_modules() and runs its process_weights_after_loading
631
- mla_module._vllm_mla_attn = attn_instance
663
+
664
+ # Attach the instance to the module it serves, which is the only way it
665
+ # is in the module tree at all: a plain dict is invisible to
666
+ # `nn.Module.__setattr__`. Being in the tree runs its
667
+ # `process_weights_after_loading` and moves its KV-scale buffers with
668
+ # `model.to(device)`, and lets the attention interface read it off the
669
+ # module rather than index by `module.layer_idx`, an `int` that bakes
670
+ # layer identity into the traced graph and so costs one compiled
671
+ # artifact per layer.
672
+ setattr(attn_module, VLLM_ATTN_ATTR, attn_instance)
673
attention_instances[i] = attn_instance
674
return attention_instances
675
@@ -645,7 +686,11 @@ class Base(
686
return EncoderOnlyAttention
687
if self.model_config.use_mla:
688
self.check_version("5.15.0.dev0", "optimized MLA support")
648
- if any(isinstance(fuser, MLAFuser) for fuser in self.fusers.values()):
689
+ if any(
690
+ isinstance(fuser, MLAFuser)
691
+ for fusers in self.fusers.values()
692
+ for fuser in fusers
693
+ ):
694
return MLAAttention
695
logger.warning_once(
696
"This model uses MLA but `MLAFuser` failed to match and/or fuse any "
@@ -731,7 +776,6 @@ class Base(
776
inputs_embeds=inputs_embeds,
777
use_cache=False,
778
position_ids=positions,
734
- attention_instances=self.attention_instances,
779
return_dict=False,
780
**self._output_aux_hidden_states_kwargs,
781
**kwargs,
vllm/model_executor/models/transformers/fuser.py
+69
-35
@@ -2,20 +2,22 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Fuser detection for the Transformers modeling backend.
4
5
-`get_fuser` traces a module class once (see `fx_utils`) and matches it against
6
-each concrete fuser in `fusers`; `Fusers` caches the result per class for a
7
-whole model. `base.recursive_replace` then applies the matched fuser per
5
+`get_fusers` traces a module class once (see `fx_utils`) and matches it against each
6
+concrete fuser in `fusers`; `Fusers` caches the result per class for a whole model.
7
+At most one match may give the module a different forward, and any number of fusers that
8
+leave the forward alone apply alongside. `base.recursive_replace` then applies them per
9
instance. RMSNorm-shaped modules the tracer cannot match are warned about.
10
"""
11
12
from collections import UserDict
12
-from typing import TYPE_CHECKING
13
+from typing import TYPE_CHECKING, TypeVar
14
15
from cachetools import cached
16
from torch import nn
17
18
from vllm.logger import init_logger
19
from vllm.model_executor.models.transformers.fusers import (
20
+ AttentionFuser,
21
BaseFuser,
22
GLUFuser,
23
MLAFuser,
@@ -31,58 +33,90 @@ if TYPE_CHECKING:
33
34
logger = init_logger(__name__)
35
36
+F = TypeVar("F", bound=BaseFuser)
37
+
38
39
def key(module: nn.Module) -> tuple:
36
- """Cache key for `get_fuser`. Considers module type and its immediate children."""
40
+ """Cache key for `get_fusers`. Considers module type and its immediate children."""
41
return (type(module), tuple(name for name, _ in module.named_children()))
42
43
44
+FUSERS: tuple[type[BaseFuser], ...] = (
45
+ MLAFuser,
46
+ GLUFuser,
47
+ QKVFuser,
48
+ PackedQKVFuser,
49
+ RMSNormFuser,
50
+ AttentionFuser,
51
+)
52
+"""Every fuser, in match order: those that redefine the forward first, then those
53
+that leave it alone. A new fuser is added here."""
54
+
55
+
56
@cached(cache={}, key=key)
41
-def get_fuser(module: nn.Module) -> BaseFuser | None:
42
- """The fuser for `module`'s class and shape (cached), or `None` if no match."""
57
+def get_fusers(module: nn.Module) -> list[BaseFuser]:
58
+ """Every fuser that applies to `module`'s class and shape (cached).
59
+
60
+ The one that redefines the forward comes first, so applying them in order
61
+ fuses before the rest read or adjust the result."""
62
# Projection fusions need >=2 sibling linears; the RMSNorm fusion needs a
63
# leaf module (raw tensor math, no submodules). Nothing else can match, and
64
# tracing is skipped for it.
65
n_linear = sum(isinstance(c, nn.Linear) for c in module.children())
66
is_leaf = next(module.children(), None) is None
48
- if n_linear < 2 and not is_leaf:
49
- return None
50
- if (graph := trace(module)) is None:
51
- return None
52
- for fuser_cls in (MLAFuser, GLUFuser, QKVFuser, PackedQKVFuser, RMSNormFuser):
53
- if (fuser := fuser_cls.match(graph, module)) is not None:
54
- if isinstance(fuser, RewriteFuser):
55
- try:
56
- fuser.update_forward(module)
57
- except Exception as exc:
58
- logger.debug(
59
- "Attempted to fuse %s using %s but failed "
60
- "to update its forward method: %s",
61
- type(module),
62
- fuser_cls.__name__,
63
- exc,
64
- )
65
- continue
66
- return fuser
67
+ graph = trace(module) if n_linear >= 2 or is_leaf else None
68
+
69
+ fusers: list[BaseFuser] = []
70
+ for fuser_cls in FUSERS:
71
+ if fuser_cls.redefines_forward and (
72
+ # A forward can only be rewritten from a trace of it, and only once,
73
+ # because every rewrite starts from the original source.
74
+ graph is None or any(fuser.redefines_forward for fuser in fusers)
75
+ ):
76
+ continue
77
+ if (fuser := fuser_cls.match(graph, module)) is None:
78
+ continue
79
+ if isinstance(fuser, RewriteFuser):
80
+ try:
81
+ fuser.update_forward(module)
82
+ except Exception as exc:
83
+ logger.debug(
84
+ "Attempted to fuse %s using %s but failed "
85
+ "to update its forward method: %s",
86
+ type(module),
87
+ fuser_cls.__name__,
88
+ exc,
89
+ )
90
+ continue
91
+ fusers.append(fuser)
92
+
93
# A norm we could not match structurally is left unfused; flag likely misses.
68
- if module.__class__.__name__.endswith("RMSNorm"):
94
+ if (
95
+ module.__class__.__name__.endswith("RMSNorm")
96
+ and graph is not None
97
+ and not any(isinstance(fuser, RMSNormFuser) for fuser in fusers)
98
+ ):
99
logger.warning_once(
100
"%s looks like an RMSNorm but its computation did not match the "
101
"expected pattern, so it was left unfused.",
102
module.__class__.__name__,
103
)
74
- return None
104
+ return fusers
105
+
106
+
107
+def get_fuser(module: nn.Module, fuser_cls: type[F]) -> "F | None":
108
+ """The first `fuser_cls` that applies to `module`, if one does."""
109
+ return next((f for f in get_fusers(module) if isinstance(f, fuser_cls)), None)
110
111
112
class Fusers(UserDict):
78
- """Mapping from module class and shape to fuser, for all fusable modules."""
113
+ """Mapping from module class and shape to fusers, for all fusable modules."""
114
115
def __init__(self, model: nn.Module, vllm_config: "VllmConfig"):
116
self.vllm_config = vllm_config
82
- super().__init__({key(m): get_fuser(m) for m in model.modules()})
117
+ super().__init__({key(m): get_fusers(m) for m in model.modules()})
118
84
- def __getitem__(self, m: nn.Module) -> BaseFuser | None:
85
- fuser = self.data.get(key(m))
86
- if fuser is not None and fuser.validate(m, self.vllm_config):
87
- return fuser
88
- return None
119
+ def __getitem__(self, m: nn.Module) -> list[BaseFuser]:
120
+ """The fusers this instance can take, in the order to apply them."""
121
+ fusers = self.data.get(key(m), ())
122
+ return [fuser for fuser in fusers if fuser.validate(m, self.vllm_config)]
vllm/model_executor/models/transformers/fusers/__init__.py
+2
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Concrete fusers for the Transformers modeling backend."""
4
5
+from vllm.model_executor.models.transformers.fusers.attention import AttentionFuser
6
from vllm.model_executor.models.transformers.fusers.base import (
7
BaseFuser,
8
RewriteFuser,
@@ -15,6 +16,7 @@ from vllm.model_executor.models.transformers.fusers.qkv import QKVFuser
16
from vllm.model_executor.models.transformers.fusers.rms_norm import RMSNormFuser
17
18
__all__ = [
19
+ "AttentionFuser",
20
"BaseFuser",
21
"RewriteFuser",
22
"StackedFuser",
vllm/model_executor/models/transformers/fusers/attention.py
new
+129
@@ -0,0 +1,129 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Attention fuser: the module that dispatches to the attention interface."""
4
+
5
+import ast
6
+import inspect
7
+import textwrap
8
+from dataclasses import dataclass
9
+from functools import cache
10
+from typing import TYPE_CHECKING, ClassVar
11
+
12
+from torch import fx, nn
13
+
14
+from vllm.model_executor.models.transformers.fusers.base import BaseFuser
15
+
16
+if TYPE_CHECKING:
17
+ from vllm.config import VllmConfig
18
+
19
+VLLM_ATTN_IMPL = "vllm"
20
+VLLM_MLA_ATTN_IMPL = "vllm_mla"
21
+
22
+
23
+def _is_interface_lookup(node: ast.expr | None) -> bool:
24
+ """Whether `node` reads an entry out of `ALL_ATTENTION_FUNCTIONS`."""
25
+ # ALL_ATTENTION_FUNCTIONS.get_interface(...) or ALL_ATTENTION_FUNCTIONS[...]
26
+ if isinstance(node, ast.Call):
27
+ node = node.func
28
+ if not isinstance(node, (ast.Attribute, ast.Subscript)):
29
+ return False
30
+ return (
31
+ isinstance(node.value, ast.Name) and node.value.id == "ALL_ATTENTION_FUNCTIONS"
32
+ )
33
+
34
+
35
+@cache
36
+def interface_call(cls: type[nn.Module]) -> ast.Call | None:
37
+ """The attention interface call in `cls.forward`, if it makes exactly one."""
38
+ try:
39
+ source = inspect.getsource(inspect.unwrap(cls.forward))
40
+ tree = ast.parse(textwrap.dedent(source))
41
+ except (AttributeError, OSError, SyntaxError, TypeError):
42
+ return None
43
+
44
+ # Find the names of the local variables that read from the interface lookup
45
+ names = set()
46
+ for node in ast.walk(tree):
47
+ if not isinstance(node, (ast.Assign, ast.AnnAssign)):
48
+ continue
49
+ if not _is_interface_lookup(node.value):
50
+ continue
51
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
52
+ names.update(t.id for t in targets if isinstance(t, ast.Name))
53
+ # Find the calls to those names, and return the one if there is exactly one
54
+ calls = [
55
+ node
56
+ for node in ast.walk(tree)
57
+ if isinstance(node, ast.Call)
58
+ and isinstance(node.func, ast.Name)
59
+ and node.func.id in names
60
+ ]
61
+ return calls[0] if len(calls) == 1 else None
62
+
63
+
64
+def _resolve(node: ast.expr, module: nn.Module) -> object:
65
+ """The value of `node` on `module`, for literals and `self.<attr>`."""
66
+ if isinstance(node, ast.Constant):
67
+ return node.value
68
+ if (
69
+ isinstance(node, ast.Attribute)
70
+ and isinstance(node.value, ast.Name)
71
+ and node.value.id == "self"
72
+ ):
73
+ return getattr(module, node.attr, None)
74
+ return None
75
+
76
+
77
+@dataclass
78
+class AttentionFuser(BaseFuser):
79
+ """A module that dispatches through the Transformers attention interface."""
80
+
81
+ redefines_forward: ClassVar[bool] = False
82
+
83
+ source_cls: str
84
+ """Class of the HF module that dispatches (for logging)."""
85
+ scale_expr: ast.expr | None
86
+ """Source of the `scaling=` the module hands the interface, if it hands one."""
87
+
88
+ def info(self, name: str) -> str:
89
+ return f"Found: {name} ({self.source_cls}) -> attention interface"
90
+
91
+ @classmethod
92
+ def match(
93
+ cls, graph: fx.Graph | None, module: nn.Module
94
+ ) -> "AttentionFuser | None":
95
+ if (call := interface_call(type(module))) is None:
96
+ return None
97
+ scaling = [kw.value for kw in call.keywords if kw.arg == "scaling"]
98
+ scale_expr = scaling[0] if len(scaling) == 1 else None
99
+ return cls(source_cls=type(module).__name__, scale_expr=scale_expr)
100
+
101
+ def validate(self, module: nn.Module, vllm_config: "VllmConfig") -> bool:
102
+ """Whether `module` will actually dispatch to vLLM."""
103
+ config = getattr(module, "config", None)
104
+ # Only patched in the text config, this excludes attention based mm encoders
105
+ vllm_attn_impls = {VLLM_ATTN_IMPL, VLLM_MLA_ATTN_IMPL}
106
+ return getattr(config, "_attn_implementation", None) in vllm_attn_impls
107
+
108
+ def fuse(
109
+ self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
110
+ ) -> nn.Module:
111
+ return module
112
+
113
+ def layer_index(self, module: nn.Module) -> int | None:
114
+ """The layer `module` computes attention for, if it declares one."""
115
+ layer_idx = getattr(module, "layer_idx", None)
116
+ return layer_idx if isinstance(layer_idx, int) else None
117
+
118
+ def scale(self, module: nn.Module) -> float | None:
119
+ """The softmax scale `module` passes to the interface, or `None`."""
120
+ if self.scale_expr is None:
121
+ return None
122
+ scale = _resolve(self.scale_expr, module)
123
+ if not isinstance(scale, (int, float)) or isinstance(scale, bool):
124
+ expression = ast.unparse(self.scale_expr)
125
+ raise ValueError(
126
+ f"Cannot resolve attention scaling expression {expression!r} in "
127
+ f"{type(module).__name__}."
128
+ )
129
+ return float(scale)
vllm/model_executor/models/transformers/fusers/base.py
+5
-1
@@ -20,11 +20,15 @@ if TYPE_CHECKING:
20
class BaseFuser(ABC):
21
"""A detected fusion and how to apply it.
22
23
- `match` analyses the module *class* once (cached, see `get_fuser`); `fuse`
23
+ `match` analyses the module *class* once (cached, see `get_fusers`); `fuse`
24
then applies the fusion to an instance in `recursive_replace`, returning the
25
module to install in its place.
26
"""
27
28
+ redefines_forward: ClassVar[bool] = True
29
+ """Whether `fuse` gives the module a different forward,
30
+ by rewriting its source or by returning a different module in its place."""
31
+
32
@abstractmethod
33
def info(self, name: str) -> str:
34
"""A human-readable description of the fusion at `name`, for logging."""
vllm/model_executor/models/transformers/fusers/mla.py
+1
-1
@@ -59,7 +59,7 @@ def _is_rms_norm(module: nn.Module) -> bool:
59
# Go via `get_fuser` because it caches
60
from vllm.model_executor.models.transformers.fuser import get_fuser
61
62
- return isinstance(get_fuser(module), RMSNormFuser)
62
+ return get_fuser(module, RMSNormFuser) is not None
63
64
65
def _top_level_index(funcdef: ast.FunctionDef, node: ast.AST) -> int:
vllm/model_executor/models/transformers/moe.py
+3
-2
@@ -36,6 +36,7 @@ from vllm.model_executor.layers.fused_moe import (
36
)
37
from vllm.model_executor.models.interfaces import MixtureOfExperts
38
from vllm.model_executor.models.transformers.fuser import get_fuser
39
+from vllm.model_executor.models.transformers.fusers.glu import GLUFuser
40
from vllm.model_executor.models.transformers.fusers.moe import MoEBlockFuser
41
from vllm.model_executor.models.utils import extract_layer_index, maybe_prefix
42
from vllm.utils.torch_utils import STR_DTYPE_TO_TORCH_DTYPE, direct_register_custom_op
@@ -319,8 +320,8 @@ class MoEMixin(MixtureOfExperts):
320
# Store shared experts for later down projection adjustment
321
if shared_experts is not None:
322
hf_shared = shared_experts.shared_experts
322
- glu_fuser = get_fuser(hf_shared)
323
- down_name = getattr(glu_fuser, "down_name", None)
323
+ glu_fuser = get_fuser(hf_shared, GLUFuser)
324
+ down_name = glu_fuser and glu_fuser.down_name
325
if down_name is not None:
326
shared_down_projs.append((hf_shared, down_name))
327
# Prefer config, otherwise read it from fuser.