[Perf][Bugfix][OmniVoice] Restore float16 serving, and fuse the generator hot loop (#6317)
Signed-off-by: MrlixiangWE <mrdanaer@gmail.com>
This commit is contained in:
1 parent
b9a88bf51e
commit
ca6b4e2112
8 files changed
+1039
-160
No files matched your search
@@ -31,6 +31,7 @@ NUM_CB = 8
|
||||
VOCAB = 1025
|
||||
HIDDEN = 64
|
||||
CAPTURE_SIZES = [32, 64, 128]
|
||||
HEAD_DIM = 64
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -53,22 +54,23 @@ class _SyntheticGenerator:
|
||||
self.config = self._Cfg()
|
||||
self.text_embedding = nn.Embedding(1000, HIDDEN).to(device).eval()
|
||||
self._linear = nn.Linear(HIDDEN, NUM_CB * VOCAB, bias=False).to(device).eval()
|
||||
self._rope_cos: torch.Tensor | None = None
|
||||
self._rope_sin: torch.Tensor | None = None
|
||||
self._rope_table: torch.Tensor | None = None
|
||||
|
||||
def _ensure_rope(self, seq_len: int, device: torch.device) -> None:
|
||||
if self._rope_cos is None or self._rope_cos.shape[0] < seq_len:
|
||||
max_len = max(seq_len, 512)
|
||||
self._rope_cos = torch.zeros(max_len, 64, device=device)
|
||||
self._rope_sin = torch.zeros(max_len, 64, device=device)
|
||||
@property
|
||||
def model_dtype(self) -> torch.dtype:
|
||||
"""Part of the interface _OmniVoiceCUDAGraphForward expects of a generator."""
|
||||
return self.text_embedding.weight.dtype
|
||||
|
||||
def _rope_table_for(self, seq_len: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
|
||||
"""Part of the interface _OmniVoiceCUDAGraphForward expects of a generator."""
|
||||
return torch.zeros(seq_len, HEAD_DIM, device=device, dtype=dtype)
|
||||
|
||||
def _step_forward(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
audio_mask: torch.Tensor,
|
||||
attention_mask: torch.Tensor | None,
|
||||
cos: torch.Tensor,
|
||||
sin: torch.Tensor,
|
||||
rope_table: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
two_b, _, S = input_ids.shape
|
||||
x = self.text_embedding(input_ids[:, 0, :].clamp(0, 999)) # [two_b, S, H]
|
||||
@@ -82,11 +84,9 @@ class _SyntheticGenerator:
|
||||
|
||||
|
||||
def _eager(gen: _SyntheticGenerator, ids: torch.Tensor, mask: torch.Tensor, attn: torch.Tensor) -> torch.Tensor:
|
||||
"""Run _step_forward with zero RoPE tensors (synthetic gen ignores them)."""
|
||||
S = ids.shape[-1]
|
||||
cos = torch.zeros(S, 64, device=ids.device)
|
||||
sin = torch.zeros(S, 64, device=ids.device)
|
||||
return gen._step_forward(ids, mask, attn, cos, sin)
|
||||
"""Run _step_forward with a zero RoPE table (synthetic gen ignores it)."""
|
||||
two_b, _, S = ids.shape
|
||||
return gen._step_forward(ids, mask, attn, gen._rope_table_for(S, ids.device, torch.float32))
|
||||
|
||||
|
||||
def _make_inputs(seq_len: int, device: torch.device = DEVICE):
|
||||
@@ -251,13 +251,10 @@ def test_cuda_graph_disabled_matches_eager_generator():
|
||||
mask = torch.ones(2, seq_len, dtype=torch.bool, device=DEVICE)
|
||||
attn = torch.ones(2, 1, seq_len, seq_len, dtype=torch.bool, device=DEVICE)
|
||||
|
||||
gen_eager._ensure_rope(seq_len, DEVICE)
|
||||
dtype = gen_eager.text_embedding.weight.dtype
|
||||
cos = gen_eager._rope_cos[:seq_len].to(device=DEVICE, dtype=dtype)
|
||||
sin = gen_eager._rope_sin[:seq_len].to(device=DEVICE, dtype=dtype)
|
||||
rope_table = gen_eager._rope_table_for(seq_len, DEVICE, gen_eager.model_dtype)
|
||||
|
||||
with torch.no_grad():
|
||||
eager_logits = gen_eager._step_forward(ids, mask, attn, cos, sin)
|
||||
eager_logits = gen_eager._step_forward(ids, mask, attn, rope_table)
|
||||
graph_logits = gen_graph._cuda_graph_fwd(ids, mask, attn)
|
||||
|
||||
torch.testing.assert_close(graph_logits, eager_logits, atol=0, rtol=0)
|
||||
@@ -0,0 +1,183 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""The OmniVoice generator packs q/k/v and gate/up into fused projections.
|
||||
|
||||
The HF checkpoint stores those five tensors separately, so packing them is a
|
||||
load-time step -- and the failure mode it introduces is silent. The generic
|
||||
name-based loader looks up ``q_proj``/``gate_proj`` as modules; once they no
|
||||
longer exist it finds nothing, logs a warning, and leaves the fused parameters
|
||||
at their random initialization. The model then serves requests and produces
|
||||
noise. These tests assert the property that failure mode violates: after a
|
||||
load, every fused parameter is fully written from the checkpoint, and anything
|
||||
that would leave one partly written raises instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import (
|
||||
_FUSED_PROJECTIONS,
|
||||
OmniVoiceGenerator,
|
||||
)
|
||||
from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
NUM_LAYERS = 2
|
||||
HIDDEN = 32
|
||||
HEAD_DIM = 8
|
||||
NUM_HEADS = 4
|
||||
NUM_KV_HEADS = 2
|
||||
INTERMEDIATE = 64
|
||||
|
||||
|
||||
def _config() -> OmniVoiceConfig:
|
||||
return OmniVoiceConfig(
|
||||
llm_config={
|
||||
"hidden_size": HIDDEN,
|
||||
"num_hidden_layers": NUM_LAYERS,
|
||||
"num_attention_heads": NUM_HEADS,
|
||||
"num_key_value_heads": NUM_KV_HEADS,
|
||||
"head_dim": HEAD_DIM,
|
||||
"intermediate_size": INTERMEDIATE,
|
||||
"vocab_size": 64,
|
||||
"max_position_embeddings": 128,
|
||||
},
|
||||
enable_cuda_graph=False,
|
||||
)
|
||||
|
||||
|
||||
def _checkpoint_shards() -> dict[str, torch.Tensor]:
|
||||
"""The five per-layer tensors an OmniVoice checkpoint actually stores."""
|
||||
torch.manual_seed(0)
|
||||
shards: dict[str, torch.Tensor] = {}
|
||||
for layer in range(NUM_LAYERS):
|
||||
prefix = f"llm.layers.{layer}"
|
||||
shards[f"{prefix}.self_attn.q_proj.weight"] = torch.randn(NUM_HEADS * HEAD_DIM, HIDDEN)
|
||||
shards[f"{prefix}.self_attn.k_proj.weight"] = torch.randn(NUM_KV_HEADS * HEAD_DIM, HIDDEN)
|
||||
shards[f"{prefix}.self_attn.v_proj.weight"] = torch.randn(NUM_KV_HEADS * HEAD_DIM, HIDDEN)
|
||||
shards[f"{prefix}.mlp.gate_proj.weight"] = torch.randn(INTERMEDIATE, HIDDEN)
|
||||
shards[f"{prefix}.mlp.up_proj.weight"] = torch.randn(INTERMEDIATE, HIDDEN)
|
||||
return shards
|
||||
|
||||
|
||||
def test_every_fused_parameter_is_written() -> None:
|
||||
generator = OmniVoiceGenerator(_config())
|
||||
shards = _checkpoint_shards()
|
||||
|
||||
loaded = generator._load_fused_projections(shards)
|
||||
|
||||
assert loaded == set(shards), "some checkpoint shards were not consumed"
|
||||
shards_per_layer = sum(len(paths) for paths in _FUSED_PROJECTIONS.values())
|
||||
assert shards_per_layer == 5, "q,k,v and gate,up"
|
||||
assert len(loaded) == NUM_LAYERS * shards_per_layer
|
||||
|
||||
|
||||
def test_packed_weight_matches_the_shards_it_came_from() -> None:
|
||||
"""Packing order is q,k,v and gate,up -- the split in forward() assumes it."""
|
||||
generator = OmniVoiceGenerator(_config())
|
||||
shards = _checkpoint_shards()
|
||||
generator._load_fused_projections(shards)
|
||||
|
||||
for layer in range(NUM_LAYERS):
|
||||
prefix = f"llm.layers.{layer}"
|
||||
qkv = torch.cat(
|
||||
[
|
||||
shards[f"{prefix}.self_attn.q_proj.weight"],
|
||||
shards[f"{prefix}.self_attn.k_proj.weight"],
|
||||
shards[f"{prefix}.self_attn.v_proj.weight"],
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
gate_up = torch.cat(
|
||||
[shards[f"{prefix}.mlp.gate_proj.weight"], shards[f"{prefix}.mlp.up_proj.weight"]],
|
||||
dim=0,
|
||||
)
|
||||
torch.testing.assert_close(generator.layers[layer].self_attn.qkv_proj.weight, qkv)
|
||||
torch.testing.assert_close(generator.layers[layer].mlp.gate_up_proj.weight, gate_up)
|
||||
|
||||
|
||||
def test_no_fused_parameter_keeps_its_random_init() -> None:
|
||||
"""The corruption this guards against: a fused param never written at all."""
|
||||
generator = OmniVoiceGenerator(_config())
|
||||
before = {
|
||||
name: param.detach().clone()
|
||||
for name, param in generator.named_parameters()
|
||||
if name.endswith(("qkv_proj.weight", "gate_up_proj.weight"))
|
||||
}
|
||||
assert len(before) == NUM_LAYERS * len(_FUSED_PROJECTIONS)
|
||||
|
||||
generator._load_fused_projections(_checkpoint_shards())
|
||||
|
||||
for name, initial in before.items():
|
||||
current = dict(generator.named_parameters())[name]
|
||||
assert not torch.equal(current, initial), f"{name} was left at its initialization"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dropped",
|
||||
["self_attn.k_proj", "self_attn.v_proj", "self_attn.q_proj", "mlp.gate_proj", "mlp.up_proj"],
|
||||
)
|
||||
def test_a_missing_shard_raises_instead_of_loading_partially(dropped: str) -> None:
|
||||
generator = OmniVoiceGenerator(_config())
|
||||
shards = _checkpoint_shards()
|
||||
del shards[f"llm.layers.0.{dropped}.weight"]
|
||||
|
||||
with pytest.raises(ValueError, match="missing"):
|
||||
generator._load_fused_projections(shards)
|
||||
|
||||
|
||||
def test_a_wrong_shaped_shard_raises() -> None:
|
||||
generator = OmniVoiceGenerator(_config())
|
||||
shards = _checkpoint_shards()
|
||||
shards["llm.layers.0.self_attn.q_proj.weight"] = torch.randn(NUM_HEADS * HEAD_DIM + 1, HIDDEN)
|
||||
|
||||
with pytest.raises(ValueError, match="pack to"):
|
||||
generator._load_fused_projections(shards)
|
||||
|
||||
|
||||
def test_a_checkpoint_missing_a_whole_layer_raises() -> None:
|
||||
"""Half-loaded is the dangerous state: it neither errors nor works."""
|
||||
generator = OmniVoiceGenerator(_config())
|
||||
shards = {k: v for k, v in _checkpoint_shards().items() if not k.startswith("llm.layers.1.")}
|
||||
|
||||
with pytest.raises(ValueError, match="fused projections"):
|
||||
generator._load_fused_projections(shards)
|
||||
|
||||
|
||||
def test_a_checkpoint_with_no_fused_shards_is_left_alone() -> None:
|
||||
"""An unrelated state_dict must not trip the completeness check."""
|
||||
generator = OmniVoiceGenerator(_config())
|
||||
assert generator._load_fused_projections({"audio_heads.weight": torch.randn(4, 4)}) == set()
|
||||
|
||||
|
||||
def test_load_weights_wires_the_packing_in(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The packing has to be reached from load_weights, not merely available.
|
||||
|
||||
Every test above calls _load_fused_projections directly, so deleting its call
|
||||
site from load_weights would leave them all green while the model loaded
|
||||
corrupted. This goes through the public entry point instead.
|
||||
"""
|
||||
import safetensors.torch
|
||||
|
||||
generator = OmniVoiceGenerator(_config())
|
||||
shards = _checkpoint_shards()
|
||||
before = generator.layers[0].self_attn.qkv_proj.weight.detach().clone()
|
||||
|
||||
(tmp_path / "model.safetensors").write_bytes(b"") # only its existence is checked
|
||||
monkeypatch.setattr(safetensors.torch, "load_file", lambda *args, **kwargs: shards)
|
||||
generator.load_weights(str(tmp_path), torch.device("cpu"))
|
||||
|
||||
expected = torch.cat(
|
||||
[
|
||||
shards["llm.layers.0.self_attn.q_proj.weight"],
|
||||
shards["llm.layers.0.self_attn.k_proj.weight"],
|
||||
shards["llm.layers.0.self_attn.v_proj.weight"],
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
written = generator.layers[0].self_attn.qkv_proj.weight
|
||||
assert not torch.equal(written, before), "load_weights left the fused projection at its init"
|
||||
torch.testing.assert_close(written, expected)
|
||||
@@ -0,0 +1,166 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""The fused attention prologue must equal the six steps it replaces.
|
||||
|
||||
``fused_qkv_norm_rope`` collapses split, per-head RMSNorm of Q and K, RoPE on
|
||||
both, the GQA broadcast of K and V, and the transpose into SDPA's layout into
|
||||
one Triton pass. That is a lot of behaviour to fold into one kernel, so these
|
||||
check it against the unfused reference across the geometries and dtypes it
|
||||
claims to support, and check that unsupported geometries fall back rather than
|
||||
producing something wrong.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.triton_utils import HAS_TRITON
|
||||
|
||||
from vllm_omni.model_executor.models.omnivoice.fused_qkv_rope import (
|
||||
_eager_qkv_norm_rope,
|
||||
fused_cuda_supported,
|
||||
fused_qkv_norm_rope,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cuda]
|
||||
|
||||
cuda_only = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
triton_only = pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
|
||||
EPS = 1e-6
|
||||
TOL = {
|
||||
torch.float32: dict(atol=2e-5, rtol=2e-5),
|
||||
torch.float16: dict(atol=4e-3, rtol=4e-3),
|
||||
torch.bfloat16: dict(atol=3e-2, rtol=3e-2),
|
||||
}
|
||||
|
||||
|
||||
def _inputs(batch, seq_len, num_heads, num_kv_heads, head_dim, dtype, device="cuda"):
|
||||
torch.manual_seed(11)
|
||||
total = num_heads + 2 * num_kv_heads
|
||||
qkv = torch.randn(batch, seq_len, total, head_dim, device=device, dtype=dtype)
|
||||
q_weight = torch.randn(head_dim, device=device, dtype=dtype)
|
||||
k_weight = torch.randn(head_dim, device=device, dtype=dtype)
|
||||
freqs = torch.randn(seq_len, head_dim // 2, device=device)
|
||||
rope_table = torch.cat([freqs.cos(), freqs.sin()], dim=-1).to(dtype)
|
||||
return qkv, q_weight, k_weight, rope_table
|
||||
|
||||
|
||||
@cuda_only
|
||||
@triton_only
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize(
|
||||
"batch,seq_len,num_heads,num_kv_heads,head_dim",
|
||||
[
|
||||
(2, 44, 16, 8, 128), # OmniVoice: the CFG pair, 2:1 GQA
|
||||
(2, 130, 16, 8, 128),
|
||||
(1, 7, 8, 8, 128), # no GQA broadcast
|
||||
(3, 16, 16, 8, 64),
|
||||
(2, 33, 32, 8, 128), # 4:1 GQA
|
||||
],
|
||||
)
|
||||
def test_matches_the_unfused_reference(dtype, batch, seq_len, num_heads, num_kv_heads, head_dim):
|
||||
qkv, q_weight, k_weight, rope_table = _inputs(batch, seq_len, num_heads, num_kv_heads, head_dim, dtype)
|
||||
assert fused_cuda_supported(qkv, num_heads, num_kv_heads)
|
||||
|
||||
got = fused_qkv_norm_rope(qkv, q_weight, k_weight, rope_table, EPS, num_heads, num_kv_heads)
|
||||
expected = _eager_qkv_norm_rope(qkv, q_weight, k_weight, rope_table, EPS, num_heads, num_kv_heads)
|
||||
|
||||
for name, a, b in zip(("q", "k", "v"), got, expected):
|
||||
assert a.shape == (batch, num_heads, seq_len, head_dim), name
|
||||
assert a.is_contiguous(), name
|
||||
torch.testing.assert_close(a, b, msg=lambda m, n=name: f"{n}: {m}", **TOL[dtype])
|
||||
|
||||
|
||||
@cuda_only
|
||||
@triton_only
|
||||
def test_v_is_the_untouched_projection_broadcast_into_place():
|
||||
"""V gets no norm and no rotation, only the GQA broadcast and the transpose."""
|
||||
batch, seq_len, num_heads, num_kv_heads, head_dim = 2, 44, 16, 8, 128
|
||||
qkv, q_weight, k_weight, rope_table = _inputs(batch, seq_len, num_heads, num_kv_heads, head_dim, torch.float32)
|
||||
|
||||
_, _, v = fused_qkv_norm_rope(qkv, q_weight, k_weight, rope_table, EPS, num_heads, num_kv_heads)
|
||||
|
||||
raw_v = qkv[:, :, num_heads + num_kv_heads :]
|
||||
expected = raw_v.repeat_interleave(num_heads // num_kv_heads, dim=2).permute(0, 2, 1, 3)
|
||||
torch.testing.assert_close(v, expected.contiguous(), atol=0, rtol=0)
|
||||
|
||||
|
||||
@cuda_only
|
||||
@triton_only
|
||||
def test_kv_heads_are_broadcast_not_reordered():
|
||||
"""Query group g must see KV head g // repeat, not some transposed pairing."""
|
||||
batch, seq_len, num_heads, num_kv_heads, head_dim = 1, 4, 16, 8, 128
|
||||
qkv, q_weight, k_weight, rope_table = _inputs(batch, seq_len, num_heads, num_kv_heads, head_dim, torch.float32)
|
||||
|
||||
_, k, _ = fused_qkv_norm_rope(qkv, q_weight, k_weight, rope_table, EPS, num_heads, num_kv_heads)
|
||||
|
||||
repeat = num_heads // num_kv_heads
|
||||
for head in range(num_heads):
|
||||
torch.testing.assert_close(k[:, head], k[:, (head // repeat) * repeat], atol=0, rtol=0)
|
||||
|
||||
|
||||
@cuda_only
|
||||
@triton_only
|
||||
def test_position_zero_is_a_pure_rmsnorm():
|
||||
"""A rope table row of cos=1, sin=0 must leave the normalized value alone."""
|
||||
batch, seq_len, num_heads, num_kv_heads, head_dim = 1, 4, 8, 8, 128
|
||||
qkv, q_weight, k_weight, _ = _inputs(batch, seq_len, num_heads, num_kv_heads, head_dim, torch.float32)
|
||||
half = head_dim // 2
|
||||
rope_table = torch.cat(
|
||||
[torch.ones(seq_len, half, device="cuda"), torch.zeros(seq_len, half, device="cuda")], dim=-1
|
||||
)
|
||||
|
||||
q, _, _ = fused_qkv_norm_rope(qkv, q_weight, k_weight, rope_table, EPS, num_heads, num_kv_heads)
|
||||
|
||||
raw_q = qkv[:, :, :num_heads].to(torch.float32)
|
||||
variance = raw_q.pow(2).mean(-1, keepdim=True)
|
||||
expected = (raw_q * torch.rsqrt(variance + EPS) * q_weight).permute(0, 2, 1, 3)
|
||||
torch.testing.assert_close(q, expected.contiguous(), **TOL[torch.float32])
|
||||
|
||||
|
||||
@cuda_only
|
||||
@pytest.mark.parametrize(
|
||||
"num_heads,num_kv_heads,head_dim",
|
||||
[(12, 4, 128), (16, 8, 96), (16, 6, 128)],
|
||||
)
|
||||
def test_unsupported_geometry_falls_back_and_still_matches(num_heads, num_kv_heads, head_dim):
|
||||
"""Head counts the tiling cannot split, or a head_dim that is not a power of two."""
|
||||
batch, seq_len = 2, 8
|
||||
qkv, q_weight, k_weight, rope_table = _inputs(batch, seq_len, num_heads, num_kv_heads, head_dim, torch.float32)
|
||||
assert not fused_cuda_supported(qkv, num_heads, num_kv_heads)
|
||||
|
||||
got = fused_qkv_norm_rope(qkv, q_weight, k_weight, rope_table, EPS, num_heads, num_kv_heads)
|
||||
expected = _eager_qkv_norm_rope(qkv, q_weight, k_weight, rope_table, EPS, num_heads, num_kv_heads)
|
||||
for a, b in zip(got, expected):
|
||||
torch.testing.assert_close(a, b, atol=0, rtol=0)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_cpu_tensors_take_the_eager_path():
|
||||
batch, seq_len, num_heads, num_kv_heads, head_dim = 1, 5, 16, 8, 128
|
||||
qkv, q_weight, k_weight, rope_table = _inputs(
|
||||
batch, seq_len, num_heads, num_kv_heads, head_dim, torch.float32, device="cpu"
|
||||
)
|
||||
assert not fused_cuda_supported(qkv, num_heads, num_kv_heads)
|
||||
|
||||
q, k, v = fused_qkv_norm_rope(qkv, q_weight, k_weight, rope_table, EPS, num_heads, num_kv_heads)
|
||||
assert q.shape == k.shape == v.shape == (batch, num_heads, seq_len, head_dim)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_a_mismatched_head_count_raises():
|
||||
qkv = torch.randn(1, 4, 30, 128) # 16 + 2*8 would be 32
|
||||
w = torch.randn(128)
|
||||
table = torch.zeros(4, 128)
|
||||
with pytest.raises(ValueError, match="packed QKV"):
|
||||
fused_qkv_norm_rope(qkv, w, w, table, EPS, 16, 8)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_a_short_rope_table_raises():
|
||||
qkv = torch.randn(1, 10, 32, 128)
|
||||
w = torch.randn(128)
|
||||
table = torch.zeros(4, 128)
|
||||
with pytest.raises(ValueError, match="rope_table"):
|
||||
fused_qkv_norm_rope(qkv, w, w, table, EPS, 16, 8)
|
||||
@@ -0,0 +1,183 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""OmniVoice attention masks must follow the model dtype, not a hardcoded float32.
|
||||
|
||||
SDPA requires the additive attention mask to carry the same dtype as the query.
|
||||
The generator used to materialize that mask as float32 unconditionally, in three
|
||||
places (the shared per-forward mask, the CUDA-graph capture buffer, and the
|
||||
replay-time normalization), so every half-precision deployment died on the first
|
||||
attention call with::
|
||||
|
||||
RuntimeError: invalid dtype for bias - should match query's dtype
|
||||
|
||||
(the exact wording depends on which SDPA backend is selected for the shape; the
|
||||
math backend words the same rejection as ``attn_mask.dtype``.)
|
||||
|
||||
That left OmniVoice servable only in float32, even though the upstream k2-fsa
|
||||
implementation runs it in float16.
|
||||
|
||||
Note the split between the CPU and CUDA tests below: the CPU SDPA backend
|
||||
silently accepts the mismatched mask, so only the CUDA tests can pin the actual
|
||||
crash. The CPU tests cover the mask contract itself, which is what the fix
|
||||
changes and what a future regression would break first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import (
|
||||
OmniVoiceAttention,
|
||||
OmniVoiceGenerator,
|
||||
_additive_float_mask,
|
||||
)
|
||||
from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig
|
||||
|
||||
HALF_DTYPES = [torch.float16, torch.bfloat16]
|
||||
ALL_DTYPES = HALF_DTYPES + [torch.float32]
|
||||
|
||||
cpu_test = pytest.mark.core_model
|
||||
cuda_test = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
|
||||
|
||||
def _tiny_config() -> OmniVoiceConfig:
|
||||
"""A real OmniVoiceConfig sized so the module fits in a unit test."""
|
||||
return OmniVoiceConfig(
|
||||
llm_config={
|
||||
"hidden_size": 32,
|
||||
"num_hidden_layers": 2,
|
||||
"num_attention_heads": 4,
|
||||
"num_key_value_heads": 2,
|
||||
"head_dim": 8,
|
||||
"intermediate_size": 64,
|
||||
"vocab_size": 64,
|
||||
"max_position_embeddings": 128,
|
||||
},
|
||||
enable_cuda_graph=False,
|
||||
)
|
||||
|
||||
|
||||
def _inputs(dtype: torch.dtype, device: torch.device) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
hidden = torch.randn(2, 6, 32, device=device, dtype=dtype)
|
||||
bool_mask = torch.ones(2, 1, 6, 6, dtype=torch.bool, device=device)
|
||||
bool_mask[:, :, :, 4:] = False
|
||||
rope_table = torch.zeros(6, 8, device=device, dtype=dtype)
|
||||
rope_table[:, :4] = 1.0 # cos = 1, sin = 0: no rotation, so the test is about the mask
|
||||
return hidden, bool_mask, rope_table
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The mask contract (CPU)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cpu_test
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("dtype", ALL_DTYPES)
|
||||
def test_additive_float_mask_uses_requested_dtype(dtype: torch.dtype) -> None:
|
||||
out = _additive_float_mask(torch.tensor([[True, False]]), dtype)
|
||||
assert out.dtype == dtype
|
||||
|
||||
|
||||
@cpu_test
|
||||
@pytest.mark.cpu
|
||||
def test_additive_float_mask_keeps_mask_semantics() -> None:
|
||||
"""Attend positions stay at 0.0 and masked ones at -inf, in half precision too."""
|
||||
out = _additive_float_mask(torch.tensor([[True, False]]), torch.float16)
|
||||
assert out[0, 0].item() == 0.0
|
||||
assert out[0, 1].item() == float("-inf")
|
||||
|
||||
|
||||
@cpu_test
|
||||
@pytest.mark.cpu
|
||||
def test_additive_float_mask_requires_an_explicit_dtype() -> None:
|
||||
"""The float32 default is the bug; keeping the parameter required is the fix."""
|
||||
with pytest.raises(TypeError):
|
||||
_additive_float_mask(torch.tensor([[True]])) # type: ignore[call-arg]
|
||||
|
||||
|
||||
@cpu_test
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("dtype", ALL_DTYPES)
|
||||
def test_generator_model_dtype_tracks_its_weights(dtype: torch.dtype) -> None:
|
||||
"""The single source of truth the three former float32 literals now defer to."""
|
||||
assert OmniVoiceGenerator(_tiny_config()).to(dtype).model_dtype == dtype
|
||||
|
||||
|
||||
@cpu_test
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("dtype", HALF_DTYPES)
|
||||
def test_attention_accepts_a_model_dtype_mask(dtype: torch.dtype) -> None:
|
||||
attn = OmniVoiceAttention(_tiny_config()).to(dtype).eval()
|
||||
hidden, bool_mask, rope_table = _inputs(dtype, torch.device("cpu"))
|
||||
|
||||
with torch.inference_mode():
|
||||
out = attn(hidden, rope_table, attention_mask=_additive_float_mask(bool_mask, dtype))
|
||||
|
||||
assert out.shape == hidden.shape
|
||||
assert out.dtype == dtype
|
||||
assert torch.isfinite(out).all()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The crash itself (CUDA only — the CPU backend does not reject the mismatch)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cuda_test
|
||||
@pytest.mark.parametrize("dtype", HALF_DTYPES)
|
||||
def test_float32_mask_is_what_broke_half_precision(dtype: torch.dtype) -> None:
|
||||
"""Pin the original failure so a float32 default cannot come back unnoticed."""
|
||||
attn = OmniVoiceAttention(_tiny_config()).to(device="cuda:0", dtype=dtype).eval()
|
||||
hidden, bool_mask, rope_table = _inputs(dtype, torch.device("cuda:0"))
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"(invalid dtype for bias|attn_mask)"), torch.inference_mode():
|
||||
attn(hidden, rope_table, attention_mask=_additive_float_mask(bool_mask, torch.float32))
|
||||
|
||||
|
||||
@cuda_test
|
||||
@pytest.mark.parametrize("dtype", HALF_DTYPES)
|
||||
def test_real_path_forward_runs_in_half_precision(dtype: torch.dtype) -> None:
|
||||
"""The regression, on the production path with the Triton norm kernels live."""
|
||||
attn = OmniVoiceAttention(_tiny_config()).to(device="cuda:0", dtype=dtype).eval()
|
||||
hidden, bool_mask, rope_table = _inputs(dtype, torch.device("cuda:0"))
|
||||
|
||||
with torch.inference_mode():
|
||||
out = attn(hidden, rope_table, attention_mask=_additive_float_mask(bool_mask, dtype))
|
||||
|
||||
assert out.dtype == dtype
|
||||
assert torch.isfinite(out).all()
|
||||
|
||||
|
||||
@cuda_test
|
||||
@pytest.mark.parametrize("dtype", HALF_DTYPES)
|
||||
def test_generator_forward_runs_in_half_precision(dtype: torch.dtype) -> None:
|
||||
"""End-to-end over the real iterative loop, which is where the float32 mask was built.
|
||||
|
||||
This is the test that fails on an unfixed tree: ``forward`` materialized the
|
||||
shared SDPA mask as float32 regardless of the weights' dtype.
|
||||
"""
|
||||
device = torch.device("cuda:0")
|
||||
config = _tiny_config()
|
||||
generator = OmniVoiceGenerator(config).to(device=device, dtype=dtype).eval()
|
||||
|
||||
seq_len, target_len = 12, 4
|
||||
input_ids = torch.zeros(2, config.num_audio_codebook, seq_len, dtype=torch.long, device=device)
|
||||
input_ids[:, 1:, :] = config.audio_mask_id
|
||||
audio_mask = torch.zeros(2, seq_len, dtype=torch.bool, device=device)
|
||||
audio_mask[:, seq_len - target_len :] = True
|
||||
attention_mask = torch.ones(2, 1, seq_len, seq_len, dtype=torch.bool, device=device)
|
||||
|
||||
with torch.inference_mode():
|
||||
tokens = generator(
|
||||
input_ids,
|
||||
audio_mask,
|
||||
attention_mask,
|
||||
target_lens=[target_len],
|
||||
seed=0,
|
||||
num_step=2,
|
||||
)
|
||||
|
||||
assert tokens.shape == (1, config.num_audio_codebook, target_len)
|
||||
assert tokens.dtype == torch.long
|
||||
@@ -15,6 +15,7 @@ import torch
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required"),
|
||||
]
|
||||
|
||||
@@ -48,7 +49,9 @@ def _ref_rms_norm(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Te
|
||||
|
||||
|
||||
def _ref_swiglu(gate: torch.Tensor, up: torch.Tensor) -> torch.Tensor:
|
||||
# Kernel: gate → float32, silu, cast to up.dtype, mul up
|
||||
# Kernel: gate → float32, silu, cast to up.dtype, mul up.
|
||||
# It now reads both halves out of one packed [..., 2 * cols] activation, so
|
||||
# the tests concatenate what they used to pass as two tensors.
|
||||
gate_f32 = gate.float()
|
||||
silu = gate_f32 * torch.sigmoid(gate_f32)
|
||||
return silu.to(up.dtype) * up
|
||||
@@ -124,7 +127,7 @@ def test_swiglu_float32(rows, cols):
|
||||
up = torch.randn(rows, cols, device=DEVICE)
|
||||
|
||||
ref = _ref_swiglu(gate, up)
|
||||
out = triton_swiglu(gate, up)
|
||||
out = triton_swiglu(torch.cat([gate, up], dim=-1))
|
||||
|
||||
torch.testing.assert_close(out, ref, atol=1e-5, rtol=1e-5)
|
||||
|
||||
@@ -136,7 +139,7 @@ def test_swiglu_negative_gate():
|
||||
up = torch.ones(4, 64, device=DEVICE)
|
||||
|
||||
ref = _ref_swiglu(gate, up)
|
||||
out = triton_swiglu(gate, up)
|
||||
out = triton_swiglu(torch.cat([gate, up], dim=-1))
|
||||
|
||||
torch.testing.assert_close(out, ref, atol=1e-5, rtol=1e-5)
|
||||
assert not torch.isnan(out).any()
|
||||
@@ -146,7 +149,7 @@ def test_swiglu_negative_gate():
|
||||
def test_swiglu_output_shape_preserved():
|
||||
gate = torch.randn(3, 8, 512, device=DEVICE)
|
||||
up = torch.randn(3, 8, 512, device=DEVICE)
|
||||
out = triton_swiglu(gate, up)
|
||||
out = triton_swiglu(torch.cat([gate, up], dim=-1))
|
||||
assert out.shape == gate.shape
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user