483 lines
18 KiB
Python
483 lines
18 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Unit tests for the Transformers modeling backend's linear fusers."""
|
|
|
|
import inspect
|
|
from types import MethodType, SimpleNamespace
|
|
|
|
import pytest
|
|
import torch
|
|
import torch.nn as nn
|
|
import torch.nn.functional as F
|
|
|
|
from vllm.model_executor.models.transformers.fuser import get_fuser
|
|
from vllm.model_executor.models.transformers.fusers import GLUFuser, QKVFuser
|
|
|
|
|
|
class SiluAndMulStub(nn.Module):
|
|
"""Stand-in for vLLM's `SiluAndMul` (no vLLM config required)."""
|
|
|
|
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
d = x.shape[-1] // 2
|
|
return F.silu(x[..., :d]) * x[..., d:]
|
|
|
|
|
|
class NoDownGLU(nn.Module):
|
|
"""`act(gate(x)) * up(x)` with no output projection -> `down_name` is None."""
|
|
|
|
def __init__(self, hidden: int = 16, inter: int = 32, bias: bool = False):
|
|
super().__init__()
|
|
self.gate_proj = nn.Linear(hidden, inter, bias=bias)
|
|
self.up_proj = nn.Linear(hidden, inter, bias=bias)
|
|
self.act_fn = nn.SiLU()
|
|
|
|
def forward(self, x):
|
|
return self.act_fn(self.gate_proj(x)) * self.up_proj(x)
|
|
|
|
|
|
class GLUMLP(NoDownGLU):
|
|
"""`down(act(gate(x)) * up(x))` — the canonical HF GLU MLP."""
|
|
|
|
def __init__(self, hidden: int = 16, inter: int = 32, bias: bool = False):
|
|
super().__init__(hidden, inter, bias)
|
|
self.down_proj = nn.Linear(inter, hidden, bias=bias)
|
|
|
|
def forward(self, x):
|
|
return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
|
|
|
|
|
|
class ReversedGLUMLP(GLUMLP):
|
|
"""`up(x) * act(gate(x))` — operands swapped (multiply is commutative)."""
|
|
|
|
def forward(self, x):
|
|
return self.down_proj(self.up_proj(x) * self.act_fn(self.gate_proj(x)))
|
|
|
|
|
|
class NotAnMLP(nn.Module):
|
|
"""Two linears but no activation*linear multiply -> must not match."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.fc1 = nn.Linear(8, 8)
|
|
self.fc2 = nn.Linear(8, 8)
|
|
|
|
def forward(self, x):
|
|
return self.fc2(self.fc1(x))
|
|
|
|
|
|
class NotAnActGLUMLP(GLUMLP):
|
|
"""GLU-shaped, but the "activation" is not a known activation module."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.act_fn = nn.Dropout()
|
|
|
|
|
|
class UntraceableMLP(GLUMLP):
|
|
"""Data-dependent control flow *before* the GLU -> no match."""
|
|
|
|
def forward(self, x):
|
|
if x.sum() > 0: # noqa: SIM108 - intentionally untraceable
|
|
return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
|
|
return x
|
|
|
|
|
|
class UntraceableTailGLUMLP(GLUMLP):
|
|
"""Data-dependent control flow *after* the GLU -> still fusable."""
|
|
|
|
def forward(self, x):
|
|
y = self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
|
|
if y.sum() > torch.inf: # intentionally untraceable
|
|
y = y * 0
|
|
return y
|
|
|
|
|
|
class FakeAttention(nn.Module):
|
|
"""HF v5-style attention: shape unpacking, dead KV branch, kwargs interface."""
|
|
|
|
is_causal = True
|
|
|
|
def __init__(
|
|
self,
|
|
hidden: int = 32,
|
|
head_dim: int = 8,
|
|
heads: int = 4,
|
|
kv_heads: int = 4,
|
|
bias: bool = False,
|
|
layer_idx: int = 0,
|
|
):
|
|
super().__init__()
|
|
self.config = SimpleNamespace(_attn_implementation="vllm")
|
|
self.layer_idx = layer_idx
|
|
self.head_dim = head_dim
|
|
self.scaling = head_dim**-0.5
|
|
self.q_proj = nn.Linear(hidden, heads * head_dim, bias=bias)
|
|
self.k_proj = nn.Linear(hidden, kv_heads * head_dim, bias=bias)
|
|
self.v_proj = nn.Linear(hidden, kv_heads * head_dim, bias=bias)
|
|
self.o_proj = nn.Linear(heads * head_dim, hidden, bias=bias)
|
|
|
|
def forward(
|
|
self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
|
|
):
|
|
from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
|
|
|
|
input_shape = hidden_states.shape[:-1]
|
|
hidden_shape = (*input_shape, -1, self.head_dim)
|
|
q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
|
|
k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
|
|
v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
|
|
if past_key_values is not None:
|
|
k, v = past_key_values.update(k, v, self.layer_idx)
|
|
attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
|
|
self.config._attn_implementation, None
|
|
)
|
|
attn_output, attn_weights = attention_interface(
|
|
self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
|
|
)
|
|
attn_output = attn_output.reshape(*input_shape, -1).contiguous()
|
|
return self.o_proj(attn_output), attn_weights
|
|
|
|
|
|
class ReversedFakeAttention(FakeAttention):
|
|
"""Projections computed in (v, k, q) order — q must still be identified."""
|
|
|
|
def forward(
|
|
self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
|
|
):
|
|
from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
|
|
|
|
input_shape = hidden_states.shape[:-1]
|
|
hidden_shape = (*input_shape, -1, self.head_dim)
|
|
v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
|
|
k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
|
|
q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
|
|
attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
|
|
self.config._attn_implementation, None
|
|
)
|
|
attn_output, _ = attention_interface(
|
|
self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
|
|
)
|
|
return self.o_proj(attn_output.reshape(*input_shape, -1)), None
|
|
|
|
|
|
class ExtraProjAttention(FakeAttention):
|
|
"""A second non-qkv linear of a different width -> `o_proj` still found."""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.sink_proj = nn.Linear(self.head_dim, self.head_dim, bias=False)
|
|
|
|
|
|
class QKNormAttention(FakeAttention):
|
|
"""OLMoE-style: a full-dim norm applied to the whole q/k projection output."""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.q_norm = nn.RMSNorm(self.q_proj.out_features)
|
|
self.k_norm = nn.RMSNorm(self.k_proj.out_features)
|
|
|
|
def forward(
|
|
self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
|
|
):
|
|
q = self.q_norm(self.q_proj(hidden_states))
|
|
k = self.k_norm(self.k_proj(hidden_states))
|
|
v = self.v_proj(hidden_states)
|
|
return self.o_proj(q + k + v), None
|
|
|
|
|
|
class PerHeadQKNormAttention(FakeAttention):
|
|
"""Qwen3-style: a per-head norm (`head_dim`) applied after the head reshape."""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.q_norm = nn.RMSNorm(self.head_dim)
|
|
self.k_norm = nn.RMSNorm(self.head_dim)
|
|
|
|
def forward(
|
|
self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
|
|
):
|
|
shape = (*hidden_states.shape[:-1], -1, self.head_dim)
|
|
q = self.q_norm(self.q_proj(hidden_states).view(shape))
|
|
k = self.k_norm(self.k_proj(hidden_states).view(shape))
|
|
v = self.v_proj(hidden_states).view(shape)
|
|
return self.o_proj((q + k + v).flatten(-2)), None
|
|
|
|
|
|
class FakeSelfAttn(nn.Module):
|
|
"""Stand-in for the vLLM `Attention` looked up in `attention_instances`."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.impl = SimpleNamespace(scale=None)
|
|
|
|
def forward(self, q, k, v):
|
|
# MHA-shaped stub: any deterministic combination of q/k/v will do
|
|
return q + 2 * k + 3 * v
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_fuser_cache():
|
|
get_fuser.cache_clear()
|
|
yield
|
|
get_fuser.cache_clear()
|
|
|
|
|
|
def _apply_glu_fuser_with_stubs(module: nn.Module, fuser: GLUFuser):
|
|
"""Apply a fuser using plain stand-ins (merged `nn.Linear` + silu AndMul)."""
|
|
gate = module.get_submodule(fuser.gate_name)
|
|
up = module.get_submodule(fuser.up_name)
|
|
merged = nn.Linear(
|
|
gate.in_features,
|
|
gate.out_features + up.out_features,
|
|
bias=gate.bias is not None,
|
|
)
|
|
with torch.no_grad():
|
|
merged.weight.copy_(torch.cat([gate.weight, up.weight], dim=0))
|
|
if gate.bias is not None:
|
|
merged.bias.copy_(torch.cat([gate.bias, up.bias], dim=0))
|
|
setattr(module, fuser.merged_name, merged)
|
|
setattr(module, fuser.act_name, SiluAndMulStub())
|
|
delattr(module, fuser.gate_name)
|
|
delattr(module, fuser.up_name)
|
|
module.forward = MethodType(fuser.fused_forward, module)
|
|
return module
|
|
|
|
|
|
def _apply_qkv_fuser_with_stubs(module: nn.Module, fuser: QKVFuser):
|
|
"""Apply a fuser using a plain merged `nn.Linear` (no TP sharding)."""
|
|
q, k, v = (
|
|
module.get_submodule(name)
|
|
for name in (fuser.q_name, fuser.k_name, fuser.v_name)
|
|
)
|
|
merged = nn.Linear(
|
|
q.in_features,
|
|
q.out_features + k.out_features + v.out_features,
|
|
bias=q.bias is not None,
|
|
)
|
|
with torch.no_grad():
|
|
merged.weight.copy_(torch.cat([q.weight, k.weight, v.weight], dim=0))
|
|
if q.bias is not None:
|
|
merged.bias.copy_(torch.cat([q.bias, k.bias, v.bias], dim=0))
|
|
merged.output_sizes = [q.out_features, k.out_features, v.out_features]
|
|
merged.tp_size = 1
|
|
setattr(module, fuser.merged_name, merged)
|
|
for name in (fuser.q_name, fuser.k_name, fuser.v_name):
|
|
delattr(module, name)
|
|
module.forward = MethodType(fuser.fused_forward, module)
|
|
return module
|
|
|
|
|
|
@pytest.mark.parametrize("mlp_cls", [GLUMLP, ReversedGLUMLP])
|
|
@pytest.mark.parametrize("bias", [False, True])
|
|
def test_detects_and_rewrites_glu(mlp_cls, bias):
|
|
with torch.device("meta"):
|
|
meta = mlp_cls(bias=bias)
|
|
fuser = get_fuser(meta)
|
|
assert isinstance(fuser, GLUFuser)
|
|
assert (
|
|
fuser.gate_name,
|
|
fuser.up_name,
|
|
fuser.act_name,
|
|
fuser.down_name,
|
|
) == ("gate_proj", "up_proj", "act_fn", "down_proj")
|
|
|
|
# The rewritten forward references the merged projection instead of the
|
|
# sources; the rest of the forward is untouched.
|
|
names = fuser.fused_forward.__code__.co_names
|
|
assert "gate_up_proj" in names and "act_fn" in names and "down_proj" in names
|
|
assert not {"gate_proj", "up_proj"} & set(names)
|
|
|
|
# Numerics: the fused forward must match the original on a real instance.
|
|
real = mlp_cls(bias=bias)
|
|
for p in real.parameters():
|
|
nn.init.normal_(p, std=0.05)
|
|
x = torch.randn(4, 16)
|
|
expected = real(x)
|
|
fused = _apply_glu_fuser_with_stubs(real, fuser)
|
|
|
|
# Fusion is in place: the module keeps its class and other attributes
|
|
assert fused is real and type(fused) is mlp_cls
|
|
torch.testing.assert_close(fused(x), expected, atol=1e-5, rtol=1e-5)
|
|
|
|
|
|
def test_glu_identifies_down_projection():
|
|
"""The row projection consuming `act(gate(x)) * up(x)` is identified.
|
|
|
|
It is forced to `RowParallelLinear` in `update_attrs` so its sharded input
|
|
matches the column-parallel merged gate/up; `None` when there is no such
|
|
projection to force (fusion of gate/up still applies)."""
|
|
with torch.device("meta"):
|
|
assert get_fuser(GLUMLP()).down_name == "down_proj"
|
|
assert get_fuser(ReversedGLUMLP()).down_name == "down_proj"
|
|
assert get_fuser(NoDownGLU()).down_name is None
|
|
|
|
|
|
@pytest.mark.parametrize("attn_cls", [FakeAttention, ReversedFakeAttention])
|
|
@pytest.mark.parametrize("kv_heads", [4, 2])
|
|
def test_detects_and_rewrites_qkv(attn_cls, kv_heads):
|
|
if attn_cls is ReversedFakeAttention and kv_heads == 4:
|
|
pytest.skip("MHA q/k/v assignment is order-based by design")
|
|
with torch.device("meta"):
|
|
meta = attn_cls(kv_heads=kv_heads)
|
|
fuser = get_fuser(meta)
|
|
assert isinstance(fuser, QKVFuser)
|
|
# q (sharded differently under TP) must be identified exactly; k/v may be
|
|
# swapped for non-canonical compute order, which is numerically consistent
|
|
# because the weight mapping and the split indices follow the same
|
|
# assignment.
|
|
assert fuser.q_name == "q_proj"
|
|
assert {fuser.k_name, fuser.v_name} == {"k_proj", "v_proj"}
|
|
assert fuser.o_name == "o_proj"
|
|
|
|
# The projections are merged; everything else stays live Python with its
|
|
# original semantics (branches, kwargs, attribute reads)
|
|
code = fuser.fused_forward.__code__
|
|
names = code.co_names
|
|
assert "qkv_proj" in names and "output_sizes" in names and "o_proj" in names
|
|
assert "tp_size" in names
|
|
assert not {"q_proj", "k_proj", "v_proj"} & set(names)
|
|
if attn_cls is FakeAttention:
|
|
assert "update" in names # the cache branch survives
|
|
assert code.co_flags & inspect.CO_VARKEYWORDS # **kwargs survives
|
|
|
|
# Numerics: the fused forward must match the original on a real instance,
|
|
# with a different layer_idx than the traced instance (kv_heads == heads so
|
|
# the q/k/v stub combination is shape-compatible).
|
|
real = attn_cls(kv_heads=4, layer_idx=3)
|
|
for p in real.parameters():
|
|
nn.init.normal_(p, std=0.05)
|
|
x = torch.randn(1, 5, 32)
|
|
attention_instances = {3: FakeSelfAttn()}
|
|
expected, _ = real(x, attention_instances=attention_instances)
|
|
fused = _apply_qkv_fuser_with_stubs(real, fuser)
|
|
|
|
# Fusion is in place: the module keeps its class and other attributes
|
|
assert fused is real and type(fused) is attn_cls
|
|
assert fused.layer_idx == 3 and fused.is_causal and fused.config is not None
|
|
out, _ = fused(x, attention_instances=attention_instances)
|
|
torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5)
|
|
|
|
|
|
def test_qkv_identifies_output_projection():
|
|
with torch.device("meta"):
|
|
assert get_fuser(FakeAttention()).o_name == "o_proj"
|
|
assert get_fuser(ReversedFakeAttention()).o_name == "o_proj"
|
|
assert get_fuser(ExtraProjAttention()).o_name == "o_proj"
|
|
# Norm children (q_norm/k_norm) must not disturb o_proj identification.
|
|
assert get_fuser(QKNormAttention()).o_name == "o_proj"
|
|
assert get_fuser(PerHeadQKNormAttention()).o_name == "o_proj"
|
|
|
|
|
|
def test_fuser_is_cached_per_class():
|
|
with torch.device("meta"):
|
|
fuser_a = get_fuser(GLUMLP())
|
|
fuser_b = get_fuser(GLUMLP())
|
|
assert fuser_a is fuser_b
|
|
assert GLUMLP in get_fuser.cache
|
|
|
|
|
|
@pytest.mark.parametrize("cls", [NotAnMLP, UntraceableMLP])
|
|
def test_non_matching_modules_return_none(cls):
|
|
with torch.device("meta"):
|
|
module = cls()
|
|
assert get_fuser(module) is None
|
|
|
|
|
|
def test_untraceable_tail_still_fuses():
|
|
with torch.device("meta"):
|
|
meta = UntraceableTailGLUMLP()
|
|
fuser = get_fuser(meta)
|
|
assert isinstance(fuser, GLUFuser)
|
|
|
|
# Numerics: the live tail must survive the rewrite
|
|
real = UntraceableTailGLUMLP()
|
|
for p in real.parameters():
|
|
nn.init.normal_(p, std=0.05)
|
|
x = torch.randn(4, 16)
|
|
expected = real(x)
|
|
fused = _apply_glu_fuser_with_stubs(real, fuser)
|
|
torch.testing.assert_close(fused(x), expected, atol=1e-5, rtol=1e-5)
|
|
|
|
|
|
def test_weight_mappings_are_scoped_to_fused_prefixes():
|
|
from vllm.model_executor.models.utils import WeightsMapper
|
|
|
|
with torch.device("meta"):
|
|
glu_fuser = get_fuser(GLUMLP())
|
|
qkv_fuser = get_fuser(FakeAttention())
|
|
|
|
mapper = WeightsMapper()
|
|
for prefix in ("model.layers.0.mlp", "model.layers.1.mlp"):
|
|
mapper.orig_to_new_stacked.update(glu_fuser.orig_to_new_stacked(prefix))
|
|
mapper.orig_to_new_stacked.update(
|
|
qkv_fuser.orig_to_new_stacked("model.layers.0.self_attn")
|
|
)
|
|
|
|
names = [
|
|
"model.layers.0.mlp.gate_proj.weight",
|
|
"model.layers.0.mlp.up_proj.weight",
|
|
"model.layers.1.mlp.gate_proj.weight",
|
|
"model.layers.0.self_attn.q_proj.weight",
|
|
"model.layers.0.self_attn.k_proj.weight",
|
|
"model.layers.0.self_attn.v_proj.weight",
|
|
# Unfused modules at other prefixes must be left untouched.
|
|
"model.layers.2.mlp.experts.0.gate_proj.weight",
|
|
"model.layers.1.self_attn.q_proj.weight",
|
|
]
|
|
# `apply` rewrites the name and stamps the shard id onto each tensor.
|
|
weights = [(name, torch.empty(0)) for name in names]
|
|
mapped = list(mapper.apply(weights))
|
|
mapped_names = [name for name, _ in mapped]
|
|
shard_ids = [getattr(data, "shard_id", None) for _, data in mapped]
|
|
|
|
assert mapped_names == [
|
|
"model.layers.0.mlp.gate_up_proj.weight",
|
|
"model.layers.0.mlp.gate_up_proj.weight",
|
|
"model.layers.1.mlp.gate_up_proj.weight",
|
|
"model.layers.0.self_attn.qkv_proj.weight",
|
|
"model.layers.0.self_attn.qkv_proj.weight",
|
|
"model.layers.0.self_attn.qkv_proj.weight",
|
|
# Only the exact fused layers are remapped; everything else is untouched.
|
|
"model.layers.2.mlp.experts.0.gate_proj.weight",
|
|
"model.layers.1.self_attn.q_proj.weight",
|
|
]
|
|
assert shard_ids == [0, 1, 0, "q", "k", "v", None, None]
|
|
|
|
# The fused layers are exposed to the quantization machinery via their
|
|
# original constituent projection names (what the checkpoint stores).
|
|
assert glu_fuser.packed_modules_mapping == {
|
|
"gate_up_proj": ["gate_proj", "up_proj"],
|
|
}
|
|
assert qkv_fuser.packed_modules_mapping == {
|
|
"qkv_proj": ["q_proj", "k_proj", "v_proj"],
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("cls", [NotAnMLP, NotAnActGLUMLP])
|
|
def test_unfusable_modules_are_not_fused(cls, default_vllm_config):
|
|
with torch.device("meta"):
|
|
module = cls()
|
|
fuser = get_fuser(module)
|
|
# Either no pattern matches the class, or this instance fails validation
|
|
# (`recursive_replace` gates fusion and its weight mappings on `validate`)
|
|
model_config = default_vllm_config.model_config
|
|
assert fuser is None or not fuser.validate(module, model_config)
|
|
|
|
|
|
def test_act_and_mul_derived_from_module(default_vllm_config):
|
|
from transformers.activations import GELUTanh, SiLUActivation
|
|
|
|
from vllm.model_executor.layers.activation import GeluAndMul, SiluAndMul
|
|
|
|
assert isinstance(GLUFuser._get_act_and_mul(nn.SiLU()), SiluAndMul)
|
|
assert isinstance(GLUFuser._get_act_and_mul(SiLUActivation()), SiluAndMul)
|
|
gelu_tanh = GLUFuser._get_act_and_mul(GELUTanh())
|
|
assert isinstance(gelu_tanh, GeluAndMul) and gelu_tanh.approximate == "tanh"
|
|
gelu = GLUFuser._get_act_and_mul(nn.GELU())
|
|
assert isinstance(gelu, GeluAndMul) and gelu.approximate == "none"
|
|
# Not activations at all -> no fusion
|
|
assert GLUFuser._get_act_and_mul_name(nn.Dropout()) is None
|
|
assert GLUFuser._get_act_and_mul_name(nn.LayerNorm(8)) is None
|
|
with pytest.raises(ValueError, match="No AndMul equivalent"):
|
|
GLUFuser._get_act_and_mul(nn.Dropout())
|