[Model] Support SenseNova-U1.5-8B-MoT and its distilled 8-step LoRA (#6516)

Signed-off-by: MrlixiangWE <mrdanaer@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
DanaerLeeandHongsheng Liu authored and GitHub committed 2026-09-01 22:53:53 +08:00
1 parent 477083a103
commit 0288c3f56e
19 files changed
+2264 -66

No files matched your search

@@ -0,0 +1,195 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CPU tests for SenseNova-U1 attention head handling.
The model used to expand K/V from 8 heads to 32 with ``repeat_interleave``
before calling the attention backend, so the backend's own GQA dispatch never
saw a GQA shape, and it passed an all-zeros mask during single-token decode,
which only served to keep SDPA off its fused kernels. These pin both contracts:
K/V must reach the backend uncompressed, decode must carry no mask, and the
compressed path must compute the same thing the expanded one did.
"""
import pytest
import torch
from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl
from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import (
SenseNovaU1Attention,
SenseNovaU1Model,
)
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
N_HEADS, N_KV_HEADS, HEAD_DIM, SEQ = 32, 8, 8, 6
GROUPS = N_HEADS // N_KV_HEADS
class _Recorder:
"""Stands in for the Attention module and records what it was handed."""
def __init__(self):
self.calls: list[tuple[torch.Size, torch.Size, torch.Tensor | None]] = []
def __call__(self, query, key, value, attn_metadata):
mask = None if attn_metadata is None else attn_metadata.attn_mask
self.calls.append((query.shape, key.shape, mask))
return query
class _AttnHost:
"""Carries only the attributes ``_run_attn``/``_run_attn_bshd`` touch."""
num_kv_groups = GROUPS
_align_mask_dtype = staticmethod(SenseNovaU1Attention._align_mask_dtype)
_run_attn = SenseNovaU1Attention._run_attn
_run_attn_bshd = SenseNovaU1Attention._run_attn_bshd
def __init__(self):
self.attn = _Recorder()
def test_bhsd_path_hands_the_backend_compressed_kv():
host = _AttnHost()
q = torch.randn(1, N_HEADS, SEQ, HEAD_DIM)
k = torch.randn(1, N_KV_HEADS, SEQ, HEAD_DIM)
host._run_attn(q, k, k.clone(), None)
_, key_shape, _ = host.attn.calls[-1]
# [B, S, H, D] after the transpose, so heads sit on dim 2.
assert key_shape[2] == N_KV_HEADS, f"K reached the backend with {key_shape[2]} heads"
def test_bshd_path_hands_the_backend_compressed_kv():
host = _AttnHost()
q = torch.randn(1, SEQ, N_HEADS, HEAD_DIM)
k = torch.randn(1, SEQ, N_KV_HEADS, HEAD_DIM)
host._run_attn_bshd(q, k, k.clone(), None)
_, key_shape, _ = host.attn.calls[-1]
assert key_shape[2] == N_KV_HEADS, f"K reached the backend with {key_shape[2]} heads"
def test_compressed_kv_computes_what_expansion_computed():
"""The backend must return the same tensor either way, or this is a bugfix
dressed up as a perf change."""
torch.manual_seed(0)
q = torch.randn(1, SEQ, N_HEADS, HEAD_DIM)
k = torch.randn(1, SEQ, N_KV_HEADS, HEAD_DIM)
v = torch.randn(1, SEQ, N_KV_HEADS, HEAD_DIM)
impl = SDPAImpl(
num_heads=N_HEADS,
head_size=HEAD_DIM,
softmax_scale=HEAD_DIM**-0.5,
num_kv_heads=N_KV_HEADS,
)
compressed = impl._forward_impl(q, k, v)
expanded = impl._forward_impl(q, k.repeat_interleave(GROUPS, dim=2), v.repeat_interleave(GROUPS, dim=2))
torch.testing.assert_close(compressed, expanded, atol=1e-6, rtol=1e-6)
class _MaskProbe(torch.nn.Module):
def __init__(self):
super().__init__()
self.masks: list[torch.Tensor | None] = []
def forward(self, hidden_states, **kwargs):
self.masks.append(kwargs["attention_mask"]["full_attention"])
return hidden_states
def _fake_rope(hidden_states, position_ids):
"""The forward builds the three RoPE tables before the layer loop; the probe
ignores them, so any 2-tuple will do."""
return hidden_states, hidden_states
class _ModelHost:
"""Carries only the attributes ``SenseNovaU1Model.forward`` touches."""
forward = SenseNovaU1Model.forward
def __init__(self, probe):
self.layers = [probe]
self.norm = torch.nn.Identity()
self.norm_mot_gen = torch.nn.Identity()
self.rotary_emb = _fake_rope
self.rotary_emb_hw = _fake_rope
def _run_model(seq_len: int):
probe = _MaskProbe()
_ModelHost(probe).forward(
inputs_embeds=torch.zeros(1, seq_len, 4),
indexes=torch.zeros(3, seq_len, dtype=torch.long),
)
return probe.masks[-1]
def test_single_token_decode_carries_no_mask():
assert _run_model(1) is None, "decode still builds the all-zeros mask"
def test_prefill_still_gets_a_causal_mask():
mask = _run_model(3)
assert mask is not None and mask.shape == (1, 1, 3, 3)
assert mask[0, 0, 0, 0] == 0.0
assert torch.isinf(mask[0, 0, 0, 1]) and mask[0, 0, 0, 1] < 0
assert mask[0, 0, 2, 1] == 0.0
# ---------------------------------------------------------------------------
# The decode path changes which SDPA kernel runs, so it is not bit-identical to
# what it replaced. "Different" would be a regression if the new result were
# worse, so pin the direction against an independent float64 reference. This
# needs a fused GQA kernel, which only exists on CUDA.
# ---------------------------------------------------------------------------
def _float64_attention(q, k, v):
q64 = q.double()
k64 = k.double().repeat_interleave(GROUPS, dim=1)
v64 = v.double().repeat_interleave(GROUPS, dim=1)
scores = (q64 @ k64.transpose(-1, -2)) * (q.shape[-1] ** -0.5)
return torch.softmax(scores, dim=-1) @ v64
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for fused GQA")
@pytest.mark.cuda
@pytest.mark.L4
@pytest.mark.parametrize("kv_len", [271, 512, 2048])
def test_decode_without_the_no_op_mask_is_closer_to_float64(kv_len):
"""The old decode path expanded K/V and passed an all-zeros mask, which kept
SDPA off its fused kernels. Dropping the mask is only defensible if the
kernel it unlocks is at least as accurate."""
dev, dtype = "cuda", torch.bfloat16
heads, dim = 32, 128
new_errs: list[float] = []
old_errs: list[float] = []
trials = 8
for t in range(trials):
torch.manual_seed(2000 + t)
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dtype)
k = torch.randn(1, heads // GROUPS, kv_len, dim, device=dev, dtype=dtype)
v = torch.randn(1, heads // GROUPS, kv_len, dim, device=dev, dtype=dtype)
ref = _float64_attention(q, k, v)
zeros_mask = torch.zeros(1, 1, 1, kv_len, device=dev, dtype=dtype)
old = torch.nn.functional.scaled_dot_product_attention(
q,
k.repeat_interleave(GROUPS, dim=1),
v.repeat_interleave(GROUPS, dim=1),
attn_mask=zeros_mask,
)
new = torch.nn.functional.scaled_dot_product_attention(q, k, v, enable_gqa=True)
new_errs.append((new.double() - ref).abs().mean().item())
old_errs.append((old.double() - ref).abs().mean().item())
# A single trial can tie either way depending on the GPU and the CUDA build,
# so gate on the aggregate: the maskless path must not be worse on average.
new_mean = sum(new_errs) / trials
old_mean = sum(old_errs) / trials
wins = sum(n <= o for n, o in zip(new_errs, old_errs))
assert new_mean <= old_mean, (
f"kv_len={kv_len}: maskless mean error {new_mean:.4e} is worse than masked {old_mean:.4e} "
f"({wins}/{trials} per-trial wins)"
)
@@ -0,0 +1,196 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CPU tests for SenseNova-U1 distilled-LoRA fusion.
The checkpoints ship unfused ``q_proj_mot_gen`` / ``gate_proj`` keys while the
pipeline runs fused layers, so each delta lands in its own row slice.
"""
import pytest
import torch
import torch.nn as nn
from safetensors.torch import save_file
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import SenseNovaU1Pipeline
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
HIDDEN, KV, INTER, RANK, ALPHA = 8, 4, 16, 2, 4.0
class _Layer(nn.Module):
def __init__(self):
super().__init__()
self.self_attn = nn.Module()
self.self_attn.qkv_proj_mot_gen = nn.Linear(HIDDEN, HIDDEN + 2 * KV, bias=False)
self.self_attn.o_proj_mot_gen = nn.Linear(HIDDEN, HIDDEN, bias=False)
self.mlp_mot_gen = nn.Module()
self.mlp_mot_gen.gate_up_proj = nn.Linear(HIDDEN, 2 * INTER, bias=False)
self.mlp_mot_gen.down_proj = nn.Linear(INTER, HIDDEN, bias=False)
class _Stub(SenseNovaU1Pipeline):
"""Only the parameter tree matters here, so skip the real __init__."""
def __init__(self):
nn.Module.__init__(self)
self.language_model = nn.Module()
self.language_model.model = nn.Module()
self.language_model.model.layers = nn.ModuleList([_Layer()])
def _triple(out: int, in_: int, fill: float) -> dict[str, torch.Tensor]:
return {
"lora_down.weight": torch.full((RANK, in_), fill, dtype=torch.float32),
"lora_up.weight": torch.full((out, RANK), 1.0, dtype=torch.float32),
"alpha": torch.tensor(ALPHA),
}
def _write(tmp_path, entries: dict[str, dict[str, torch.Tensor]]):
flat = {f"{mod}.{leaf}": t for mod, parts in entries.items() for leaf, t in parts.items()}
path = tmp_path / "lora.safetensors"
save_file(flat, str(path))
return str(path)
def _prefix(i: int = 0) -> str:
return f"language_model.model.layers.{i}"
def test_qkv_shards_land_in_their_own_row_slices(tmp_path):
pipe = _Stub()
qkv = pipe.language_model.model.layers[0].self_attn.qkv_proj_mot_gen.weight
with torch.no_grad():
qkv.zero_()
entries = {
f"{_prefix()}.self_attn.q_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0),
f"{_prefix()}.self_attn.k_proj_mot_gen": _triple(KV, HIDDEN, 2.0),
f"{_prefix()}.self_attn.v_proj_mot_gen": _triple(KV, HIDDEN, 3.0),
}
pipe.load_lora_weights(_write(tmp_path, entries))
scale = ALPHA / RANK
assert torch.allclose(qkv[:HIDDEN], torch.full((HIDDEN, HIDDEN), scale * RANK * 1.0))
assert torch.allclose(qkv[HIDDEN : HIDDEN + KV], torch.full((KV, HIDDEN), scale * RANK * 2.0))
assert torch.allclose(qkv[HIDDEN + KV :], torch.full((KV, HIDDEN), scale * RANK * 3.0))
def test_gate_and_up_land_in_their_own_halves(tmp_path):
pipe = _Stub()
gu = pipe.language_model.model.layers[0].mlp_mot_gen.gate_up_proj.weight
with torch.no_grad():
gu.zero_()
entries = {
f"{_prefix()}.mlp_mot_gen.gate_proj": _triple(INTER, HIDDEN, 1.0),
f"{_prefix()}.mlp_mot_gen.up_proj": _triple(INTER, HIDDEN, 5.0),
}
pipe.load_lora_weights(_write(tmp_path, entries))
scale = ALPHA / RANK
assert torch.allclose(gu[:INTER], torch.full((INTER, HIDDEN), scale * RANK * 1.0))
assert torch.allclose(gu[INTER:], torch.full((INTER, HIDDEN), scale * RANK * 5.0))
def test_unfused_targets_are_added_whole(tmp_path):
pipe = _Stub()
o = pipe.language_model.model.layers[0].self_attn.o_proj_mot_gen.weight
before = o.detach().clone()
entries = {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0)}
pipe.load_lora_weights(_write(tmp_path, entries))
assert torch.allclose(o - before, torch.full((HIDDEN, HIDDEN), (ALPHA / RANK) * RANK))
def test_lora_that_matches_nothing_raises_instead_of_no_op(tmp_path):
"""Without a loader the worker only warned and generated without the
adapter, so a wrong path produced a plausible image and no error."""
pipe = _Stub()
entries = {f"{_prefix()}.self_attn.does_not_exist": _triple(HIDDEN, HIDDEN, 1.0)}
with pytest.raises(ValueError, match="matched no parameter"):
pipe.load_lora_weights(_write(tmp_path, entries))
def test_shape_mismatch_raises(tmp_path):
pipe = _Stub()
entries = {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN + 1, HIDDEN, 1.0)}
with pytest.raises(RuntimeError, match="must match the size"):
pipe.load_lora_weights(_write(tmp_path, entries))
def test_multiple_files_rejected(tmp_path):
pipe = _Stub()
path = _write(tmp_path, {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0)})
with pytest.raises(ValueError, match="exactly one"):
pipe.load_lora_weights([path, path])
class _ShardedLayer(nn.Module):
"""One rank of a two-way sharded fused QKV, with a vLLM-style weight loader.
The parameter holds half of each of q, k and v. The loader is handed the whole
layer's tensor and keeps only this rank's slice, which is what vLLM does.
"""
def __init__(self):
super().__init__()
self.weight = nn.Parameter(torch.zeros(HIDDEN // 2 + KV, HIDDEN))
self.seen: list[tuple[str, tuple[int, ...]]] = []
self.weight.weight_loader = self._weight_loader
def _weight_loader(self, param, loaded_weight, shard_id):
self.seen.append((shard_id, tuple(loaded_weight.shape)))
sizes = {"q": HIDDEN // 2, "k": KV // 2, "v": KV // 2}
offsets = {"q": 0, "k": HIDDEN // 2, "v": HIDDEN // 2 + KV // 2}
rows = sizes[shard_id]
param.data[offsets[shard_id] : offsets[shard_id] + rows] = loaded_weight[:rows]
class _ShardedStub(SenseNovaU1Pipeline):
def __init__(self):
nn.Module.__init__(self)
self.language_model = nn.Module()
self.language_model.model = nn.Module()
layer = nn.Module()
layer.self_attn = nn.Module()
layer.self_attn.qkv_proj_mot_gen = _ShardedLayer()
self.language_model.model.layers = nn.ModuleList([layer])
def test_fused_delta_is_sharded_through_the_weight_loader(tmp_path):
"""Under tensor parallelism the parameter is a rank-local shard while the
checkpoint delta spans the whole layer, so the delta has to go through the
layer's weight loader. Adding it directly raises a shape error."""
pipe = _ShardedStub()
layer = pipe.language_model.model.layers[0].self_attn.qkv_proj_mot_gen
entries = {
f"{_prefix()}.self_attn.q_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0),
f"{_prefix()}.self_attn.k_proj_mot_gen": _triple(KV, HIDDEN, 2.0),
f"{_prefix()}.self_attn.v_proj_mot_gen": _triple(KV, HIDDEN, 3.0),
}
pipe.load_lora_weights(_write(tmp_path, entries))
# the loader saw the whole layer's tensors, not a pre-sharded slice
assert layer.seen == [("q", (HIDDEN, HIDDEN)), ("k", (KV, HIDDEN)), ("v", (KV, HIDDEN))]
scale = ALPHA / RANK * RANK
got = layer.weight
assert torch.allclose(got[: HIDDEN // 2], torch.full((HIDDEN // 2, HIDDEN), scale * 1.0))
assert torch.allclose(got[HIDDEN // 2 : HIDDEN // 2 + KV // 2], torch.full((KV // 2, HIDDEN), scale * 2.0))
assert torch.allclose(got[HIDDEN // 2 + KV // 2 :], torch.full((KV // 2, HIDDEN), scale * 3.0))
def test_fusion_does_not_retain_the_adapter_tensors(tmp_path):
"""Fusion is one-way and this pipeline has no unload path, so holding the
fp32 state dict would keep about 1.5 GiB alive for the shipped checkpoint."""
pipe = _Stub()
path = _write(tmp_path, {f"{_prefix()}.self_attn.o_proj_mot_gen": _triple(HIDDEN, HIDDEN, 1.0)})
pipe.load_lora_weights(path, adapter_name="distill")
kept = pipe.lora_loaded["distill"]
assert not isinstance(kept, dict)
assert not isinstance(kept, torch.Tensor)
# the sentinel still makes a second load a no-op
before = pipe.language_model.model.layers[0].self_attn.o_proj_mot_gen.weight.detach().clone()
pipe.load_lora_weights(path, adapter_name="distill")
torch.testing.assert_close(pipe.language_model.model.layers[0].self_attn.o_proj_mot_gen.weight, before)
@@ -0,0 +1,800 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for the paged decode cache and its CUDA graph.
The cache exists so a decode step has static shapes and can be captured. That
puts the burden on two things a normal test would not look at: the prefix must
survive the hand-off from the ordinary cache, and every quantity that varies
between steps must live in a device tensor. A Python-level index is correct
eagerly and silently wrong once captured, because capture bakes it in -- which
is exactly what the last test here pins.
"""
from types import SimpleNamespace
import pytest
import torch
from vllm.lora.layers import BaseLayerWithLoRA
import vllm_omni.diffusion.models.sensenova_u1.paged_decode as paged_decode
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import (
BLOCK_SIZE,
BUCKETS,
TAIL_STEP,
PagedDecodeCache,
_bucket_for,
)
from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import (
SenseNovaU1Attention,
)
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
LAYERS, KV_HEADS, HEAD_DIM = 2, 4, 8
N_HEADS = KV_HEADS * 2
class _Layer:
def __init__(self, keys=None, values=None):
self.keys, self.values = keys, values
class _Cache:
"""Stands in for the transformers cache: layers with [B, H, S, D] tensors."""
def __init__(self, layers):
self.layers = layers
def _dyn_cache(prefix, device=None):
torch.manual_seed(0)
return _Cache(
[
_Layer(
torch.randn(1, KV_HEADS, prefix, HEAD_DIM, device=device),
torch.randn(1, KV_HEADS, prefix, HEAD_DIM, device=device),
)
for _ in range(LAYERS)
]
)
def test_bucket_rounds_up_and_is_block_aligned():
assert _bucket_for(1) == 512
assert _bucket_for(512) == 512
assert _bucket_for(513) == 1024
assert _bucket_for(100_000) % BLOCK_SIZE == 0
def test_past_the_last_bucket_the_schedule_grows_in_steps():
"""Every bucket change reallocates the cache and re-captures the graph, so
the tail of the schedule must not step by BLOCK_SIZE. A think edit with two
2048x2048 inputs runs to about 9,100 tokens, so this regime is reachable,
and a request decodes at most 1024 steps.
"""
last = BUCKETS[-1]
assert _bucket_for(last + 1) == last + TAIL_STEP
assert _bucket_for(100_000) % BLOCK_SIZE == 0
for start in (last + 1, last + TAIL_STEP - 1, last + TAIL_STEP + 7):
bucket, grows = _bucket_for(start), 0
for n in range(start, start + 1025):
if _bucket_for(n) != bucket:
bucket = _bucket_for(n)
grows += 1
assert grows <= 1, f"start {start}: {grows} reallocations over 1024 decode steps"
def test_prefix_survives_the_hand_off():
dyn = _dyn_cache(37)
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
assert paged.length == 37
for i in range(LAYERS):
stored = paged.k[i].view(-1, KV_HEADS, HEAD_DIM)[:37]
torch.testing.assert_close(stored, dyn.layers[i].keys[0].transpose(0, 1))
def test_write_back_restores_what_was_handed_over():
"""Decode is paged but the stage after it reads the ordinary cache."""
dyn = _dyn_cache(21)
original = [layer.keys.clone() for layer in dyn.layers]
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
paged.to_dynamic_cache(dyn)
for before, layer in zip(original, dyn.layers):
torch.testing.assert_close(layer.keys, before)
def test_growing_keeps_the_tokens_already_stored():
dyn = _dyn_cache(500)
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
assert paged.bucket == 512
before = [k.view(-1, KV_HEADS, HEAD_DIM)[:500].clone() for k in paged.k]
gen = paged.generation
assert paged.grow(513) is True
assert paged.bucket == 1024
assert paged.generation > gen, "a reallocation must invalidate captured graphs"
for old, new in zip(before, paged.k):
torch.testing.assert_close(new.view(-1, KV_HEADS, HEAD_DIM)[:500], old)
def test_a_failed_grow_leaves_the_cache_on_its_old_buffers(monkeypatch):
"""A half-applied resize would strand a capture on freed storage.
``generation`` is half of the key a captured graph is stored under, so
publishing new K without publishing V, the block table, the bucket and the
generation leaves the old key selecting a graph whose recorded addresses
have just been freed. A later request replays it. Injecting a failure into
the last allocation is the cheapest way to reach that window.
"""
dyn = _dyn_cache(500)
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
before_k, before_v = list(paged.k), list(paged.v)
before_table, before_bucket = paged.block_table, paged.bucket
before_generation, before_length = paged.generation, paged.length
real_zeros = torch.zeros
calls = []
def flaky(*args, **kwargs):
calls.append(1)
if len(calls) == 2 * LAYERS: # the last of the two allocations per layer
raise RuntimeError("injected allocation failure")
return real_zeros(*args, **kwargs)
monkeypatch.setattr(torch, "zeros", flaky)
with pytest.raises(RuntimeError, match="injected"):
paged.grow(513)
monkeypatch.undo()
assert [id(t) for t in paged.k] == [id(t) for t in before_k], "K was published on its own"
assert [id(t) for t in paged.v] == [id(t) for t in before_v], "V was published on its own"
assert paged.block_table is before_table
assert paged.bucket == before_bucket
assert paged.generation == before_generation, "the graph key moved without new buffers"
assert paged.length == before_length
def test_length_lives_in_device_tensors():
"""Both the attended length and the write slot must be tensors.
A captured graph reads them at replay; a Python int would be frozen at
capture time and every replay would attend the wrong span and overwrite the
same slot.
"""
dyn = _dyn_cache(10)
paged = PagedDecodeCache.from_dynamic_cache(dyn, LAYERS, torch.device("cpu"), torch.float32)
paged.set_length(15)
assert torch.is_tensor(paged.seqused) and int(paged.seqused) == 15
assert torch.is_tensor(paged.pos) and int(paged.pos) == 14
# ---------------------------------------------------------------------------
# The capture contract. These need CUDA and the bundled flash-attention kernel.
# ---------------------------------------------------------------------------
cuda_only = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_paged_attention_matches_sdpa_over_the_same_buffer():
import torch.nn.functional as F
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
heads, kv_heads, dim, n = 8, 2, 64, 100
dev, dt = torch.device("cuda"), torch.bfloat16
torch.manual_seed(0)
dyn = _Cache(
[
_Layer(
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
)
]
)
paged = PagedDecodeCache.from_dynamic_cache(dyn, 1, dev, dt)
paged.set_length(n)
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
k = torch.zeros(1, kv_heads, 1, dim, device=dev, dtype=dt)
scale = dim**-0.5
# write the last slot back as itself so attend() is a pure read
k[0, :, 0] = dyn.layers[0].keys[0, :, n - 1]
v = torch.zeros_like(k)
v[0, :, 0] = dyn.layers[0].values[0, :, n - 1]
got = paged.attend(0, q, k, v, scale)
flat_k = paged.k[0].view(-1, kv_heads, dim)[:n]
flat_v = paged.v[0].view(-1, kv_heads, dim)[:n]
want = F.scaled_dot_product_attention(
q,
flat_k.unsqueeze(0).transpose(1, 2).contiguous(),
flat_v.unsqueeze(0).transpose(1, 2).contiguous(),
enable_gqa=True,
scale=scale,
).transpose(1, 2)
torch.testing.assert_close(got.float(), want.float(), atol=2e-3, rtol=2e-3)
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_a_captured_graph_follows_a_later_length():
"""One capture has to serve every length in the bucket.
If the attended span were a Python value the graph would keep attending the
capture-time length, and the run would still look fast.
"""
import torch.nn.functional as F
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
heads, kv_heads, dim, n = 8, 2, 64, 200
dev, dt = torch.device("cuda"), torch.bfloat16
torch.manual_seed(0)
dyn = _Cache(
[
_Layer(
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
)
]
)
paged = PagedDecodeCache.from_dynamic_cache(dyn, 1, dev, dt)
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
k = torch.zeros(1, kv_heads, 1, dim, device=dev, dtype=dt)
v = torch.zeros_like(k)
scale = dim**-0.5
paged.set_length(n)
k[0, :, 0] = dyn.layers[0].keys[0, :, n - 1]
v[0, :, 0] = dyn.layers[0].values[0, :, n - 1]
side = torch.cuda.Stream()
side.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(side):
for _ in range(3):
paged.attend(0, q, k, v, scale)
torch.cuda.current_stream().wait_stream(side)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
out = paged.attend(0, q, k, v, scale)
shorter = n - 43
paged.set_length(shorter)
graph.replay()
torch.accelerator.synchronize()
flat_k = paged.k[0].view(-1, kv_heads, dim)[:shorter]
flat_v = paged.v[0].view(-1, kv_heads, dim)[:shorter]
want = F.scaled_dot_product_attention(
q,
flat_k.unsqueeze(0).transpose(1, 2).contiguous(),
flat_v.unsqueeze(0).transpose(1, 2).contiguous(),
enable_gqa=True,
scale=scale,
).transpose(1, 2)
torch.testing.assert_close(out.float(), want.float(), atol=2e-3, rtol=2e-3)
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_a_captured_graph_writes_each_step_to_its_own_slot():
"""The write slot has to be a device tensor, not a Python index.
``test_a_captured_graph_follows_a_later_length`` pins the attended span.
The slot written is the other half: a Python index is frozen at capture, so
every replay would overwrite the capture-time slot and the cache would stop
growing -- correct eagerly, silently wrong once captured.
"""
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
heads, kv_heads, dim, n = 8, 2, 64, 100
dev, dt = torch.device("cuda"), torch.bfloat16
torch.manual_seed(0)
dyn = _Cache(
[
_Layer(
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
torch.randn(1, kv_heads, n, dim, device=dev, dtype=dt),
)
]
)
paged = PagedDecodeCache.from_dynamic_cache(dyn, 1, dev, dt)
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
k = torch.zeros(1, kv_heads, 1, dim, device=dev, dtype=dt)
v = torch.zeros_like(k)
scale = dim**-0.5
# Capture on the step that writes slot n-1, restoring what is already there
# so the capture itself changes nothing.
paged.set_length(n)
k[0, :, 0] = dyn.layers[0].keys[0, :, n - 1]
v[0, :, 0] = dyn.layers[0].values[0, :, n - 1]
side = torch.cuda.Stream()
side.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(side):
for _ in range(3):
paged.attend(0, q, k, v, scale)
torch.cuda.current_stream().wait_stream(side)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
paged.attend(0, q, k, v, scale)
kept = paged.k[0].view(-1, kv_heads, dim)[n - 1].clone()
# The next decode step: one token further, different K/V.
paged.set_length(n + 1)
k[0, :, 0] = 1.5
v[0, :, 0] = -2.5
graph.replay()
current_omni_platform.synchronize()
flat_k = paged.k[0].view(-1, kv_heads, dim)
flat_v = paged.v[0].view(-1, kv_heads, dim)
torch.testing.assert_close(flat_k[n].float(), torch.full_like(flat_k[n], 1.5).float())
torch.testing.assert_close(flat_v[n].float(), torch.full_like(flat_v[n], -2.5).float())
torch.testing.assert_close(flat_k[n - 1].float(), kept.float())
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_a_whole_decode_loop_matches_the_unpaged_path():
"""Per-step kernel equivalence is not loop equivalence.
With the switch off a single-token step attends over a cache grown by
``torch.cat`` and no mask; with it on the same step reads a bucket-sized
buffer through ``seqused_k``. Run the same sequence of steps through both,
crossing a bucket boundary on the way, and require the same argmax at every
step off a fixed projection -- which is the token id the pipeline would emit.
"""
import torch.nn.functional as F
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import PagedDecodeCache
heads, kv_heads, dim = 8, 2, 64
prefill, steps = 500, 40 # crosses the 512 bucket at step 13
dev, dt = torch.device("cuda"), torch.bfloat16
scale = dim**-0.5
torch.manual_seed(7)
keys = torch.randn(1, kv_heads, prefill, dim, device=dev, dtype=dt)
values = torch.randn(1, kv_heads, prefill, dim, device=dev, dtype=dt)
paged = PagedDecodeCache.from_dynamic_cache(_Cache([_Layer(keys, values)]), 1, dev, dt)
ref_k, ref_v = keys.clone(), values.clone()
proj = torch.randn(dim * heads, 128, device=dev, dtype=torch.float32)
paged_ids, ref_ids, worst = [], [], 0.0
for step in range(steps):
q = torch.randn(1, heads, 1, dim, device=dev, dtype=dt)
k = torch.randn(1, kv_heads, 1, dim, device=dev, dtype=dt)
v = torch.randn(1, kv_heads, 1, dim, device=dev, dtype=dt)
if paged.length + 1 > paged.bucket:
paged.grow(paged.length + 1)
paged.set_length(paged.length + 1)
got = paged.attend(0, q, k, v, scale)
ref_k = torch.cat([ref_k, k], dim=2)
ref_v = torch.cat([ref_v, v], dim=2)
want = F.scaled_dot_product_attention(q, ref_k, ref_v, enable_gqa=True, scale=scale).transpose(1, 2)
worst = max(worst, (got.float() - want.float()).abs().max().item())
paged_ids.append(int((got.float().reshape(1, -1) @ proj).argmax()))
ref_ids.append(int((want.float().reshape(1, -1) @ proj).argmax()))
assert paged.bucket == 1024, "the loop never crossed a bucket boundary"
assert paged_ids == ref_ids, (
f"argmax diverges at step {next(i for i, (a, b) in enumerate(zip(paged_ids, ref_ids)) if a != b)}"
)
assert worst < 5e-2, f"largest per-step deviation {worst:.3e}"
class _StubLM(torch.nn.Module):
"""Just enough CUDA work inside the capture for a pool to be allocated."""
def __init__(self, device):
super().__init__()
self.w = torch.randn(16, 64, device=device)
def forward(self, input_ids, indexes, past_key_values=None, use_cache=False, paged_cache=None):
x = input_ids.float().expand(1, 16) @ self.w
return SimpleNamespace(logits=x.unsqueeze(0))
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_the_decode_graph_captures_into_the_shared_platform_pool(monkeypatch):
"""``_decode_context`` builds a runner per request, so a private capture pool
hands every request its own arena and never gives it back. Measured on a
real model that was about 40 MiB of device memory per request, rising
linearly over twelve sequential think requests while the same twelve with
the switch off held flat.
"""
from vllm.platforms import current_platform
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import DecodeGraphRunner
pools = []
real_graph = torch.cuda.graph
def spy(graph, pool=None, **kwargs):
pools.append(pool)
return real_graph(graph, pool=pool, **kwargs)
monkeypatch.setattr(torch.cuda, "graph", spy)
dev = torch.device("cuda")
cache = PagedDecodeCache.from_dynamic_cache(_dyn_cache(8, dev), LAYERS, dev, torch.float32)
runner = DecodeGraphRunner(_StubLM(dev), cache, dev)
runner.step(0, 0)
assert pools, "no graph was captured"
assert pools[0] is not None, "captured into a fresh private pool"
assert pools[0] == current_platform.get_global_graph_pool()
assert not hasattr(runner, "_pool"), "the runner still keeps a pool of its own"
# ---------------------------------------------------------------------------
# The cache is only worth anything if production actually reaches it, and the
# kwargs it needs are not in every wheel that exports the kernel. The tests
# above would all still pass with the `forward_und` branch deleted, so these
# drive the real dispatch instead of the cache object.
# ---------------------------------------------------------------------------
class _PagedProbe:
def __init__(self, out):
self.out = out
self.calls: list[tuple[int, torch.Size, float]] = []
def attend(self, layer_idx, query, key, value, scaling):
self.calls.append((layer_idx, query.shape, scaling))
return self.out
class _AttnHost:
"""Carries only what ``forward_und`` touches on the way to the branch."""
forward_und = SenseNovaU1Attention.forward_und
qkv_proj = q_norm = k_norm = q_norm_hw = k_norm_hw = None
layer_idx = 3
scaling = HEAD_DIM**-0.5
def __init__(self, seq, paged_out):
self._qkv = (
torch.randn(1, N_HEADS, seq, HEAD_DIM),
torch.randn(1, KV_HEADS, seq, HEAD_DIM),
torch.randn(1, KV_HEADS, seq, HEAD_DIM),
)
self.paged = _PagedProbe(paged_out)
self.sdpa_calls: list[torch.Tensor | None] = []
def _project_and_rope(self, *args, **kwargs):
return self._qkv
def _run_attn(self, query, key, value, mask):
self.sdpa_calls.append(mask)
return torch.zeros(1, N_HEADS, query.shape[2], HEAD_DIM)
def o_proj(self, hidden_states):
return hidden_states * 2, None
def test_forward_und_sends_a_single_token_decode_to_the_paged_cache():
"""Goes through the real dispatch, so deleting the branch turns this red."""
out = torch.randn(1, N_HEADS, 1, HEAD_DIM)
host = _AttnHost(seq=1, paged_out=out)
got = host.forward_und(torch.zeros(1, 1, 16), None, None, position_embeddings=None, paged_cache=host.paged)
assert len(host.paged.calls) == 1, "the paged hook was never reached"
assert host.sdpa_calls == [], "the same step also ran the SDPA path"
assert host.paged.calls[0][0] == host.layer_idx
torch.testing.assert_close(got, out.reshape(1, 1, -1).contiguous() * 2)
@pytest.mark.parametrize(
"seq,mask",
[(2, None), (1, torch.zeros(1, 1, 1, 1))],
ids=["multi_token", "masked"],
)
def test_only_an_unmasked_single_token_step_takes_the_paged_path(seq, mask):
"""Both other shapes are uncapturable, and the cache holds one row per step."""
host = _AttnHost(seq=seq, paged_out=torch.zeros(1, N_HEADS, seq, HEAD_DIM))
host.forward_und(torch.zeros(1, seq, 16), None, mask, position_embeddings=None, paged_cache=host.paged)
assert host.paged.calls == [], "a step that cannot be captured reached the paged cache"
assert len(host.sdpa_calls) == 1
def test_a_kernel_missing_any_kwarg_we_pass_is_not_supported(monkeypatch):
"""The call names every argument, so the probe has to cover every name.
An older wheel exports ``flash_attn_varlen_func`` without the paged kwargs,
and a future one may rename the standard ones. Either way the probe is what
keeps that install on SDPA instead of raising TypeError at the first decode
step, with the feature on by default.
"""
def no_paged_kwargs(q, k, v, max_seqlen_q, cu_seqlens_q, max_seqlen_k, cu_seqlens_k, causal=False):
raise AssertionError("must not be called") # pragma: no cover
def renamed_standard_args(q, k, v, max_q, cu_q, max_k, seqused_k=None, block_table=None, causal=False):
raise AssertionError("must not be called") # pragma: no cover
for kernel in (no_paged_kwargs, renamed_standard_args):
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda kernel=kernel: kernel)
assert paged_decode.paged_decode_supported(torch.device("cuda"), HEAD_DIM) is False
def current_kernel(
q,
k,
v,
max_seqlen_q,
cu_seqlens_q,
max_seqlen_k,
seqused_k=None,
block_table=None,
softmax_scale=None,
causal=False,
):
raise AssertionError("must not be called") # pragma: no cover
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda: current_kernel)
assert paged_decode.paged_decode_supported(torch.device("cuda"), HEAD_DIM) is True
def test_only_q_k_and_v_reach_the_kernel_positionally(monkeypatch):
"""A positional standard argument binds to whatever a future wheel puts in
that slot, and the probe above only checks that the names exist."""
seen = {}
def recorder(*args, **kwargs):
seen["args"], seen["kwargs"] = args, kwargs
return torch.zeros(1, N_HEADS, HEAD_DIM)
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda: recorder)
cache = PagedDecodeCache(1, KV_HEADS, HEAD_DIM, 8, torch.device("cpu"), torch.float32)
cache.set_length(4)
cache.attend(
0,
torch.zeros(1, N_HEADS, 1, HEAD_DIM),
torch.zeros(1, KV_HEADS, 1, HEAD_DIM),
torch.zeros(1, KV_HEADS, 1, HEAD_DIM),
1.0,
)
assert len(seen["args"]) == 3, "only q, k and v may be positional"
assert set(seen["kwargs"]) == {
"max_seqlen_q",
"cu_seqlens_q",
"max_seqlen_k",
"seqused_k",
"block_table",
"softmax_scale",
"causal",
}
def test_the_kill_switch_returns_no_cache(monkeypatch):
"""The recipe documents `VLLM_OMNI_SENSENOVA_PAGED_DECODE=0` as the way back
to the ordinary cache, so the switch needs a test rather than only prose."""
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import (
SenseNovaU1Pipeline,
)
monkeypatch.setenv("VLLM_OMNI_SENSENOVA_PAGED_DECODE", "0")
# The env check returns before anything else on the pipeline is touched.
host = object.__new__(SenseNovaU1Pipeline)
assert SenseNovaU1Pipeline._decode_context(host, object()) is None
def _pipeline_host(monkeypatch, device="cpu", language_model=None):
"""A bare pipeline carrying only what ``_decode_context`` touches."""
from vllm_omni.diffusion.models.sensenova_u1 import pipeline_sensenova_u1 as pipe_mod
monkeypatch.setenv("VLLM_OMNI_SENSENOVA_PAGED_DECODE", "1")
monkeypatch.setattr(pipe_mod, "paged_decode_supported", lambda dev, head_dim: True)
layer = SimpleNamespace(self_attn=SimpleNamespace(head_dim=HEAD_DIM))
host = object.__new__(pipe_mod.SenseNovaU1Pipeline)
# The pipeline is an nn.Module, and the language model assigned below is one
# too, so the bookkeeping has to exist before the assignment.
torch.nn.Module.__init__(host)
host.device = device
lm = language_model if language_model is not None else torch.nn.Module()
lm.model = SimpleNamespace(layers=[layer] * LAYERS)
host.language_model = lm
return pipe_mod, host
def test_the_decode_context_is_reused_across_requests(monkeypatch):
"""A capture binds the addresses it recorded, so a cache built per request
forces a capture per request. Two requests whose prefill fits the same
bucket have to come back with the same cache and the same runner, and with
the generation unchanged so the captures stay valid.
"""
pipe_mod, host = _pipeline_host(monkeypatch)
monkeypatch.setattr(pipe_mod, "DecodeGraphRunner", lambda lm, cache, dev: SimpleNamespace(cache=cache))
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
first = ctx(host, _dyn_cache(10))
assert first is not None
second_input = _dyn_cache(20)
second = ctx(host, second_input)
assert second[0] is first[0], "the cache was rebuilt for a request that fits it"
assert second[1] is first[1], "the runner was rebuilt, so its captures are gone"
assert second[0].generation == first[0].generation, "reuse must not invalidate a capture"
assert second[0].length == 20
# The reused buffers must carry the new request's prefill, not the old one.
flat = second[0].k[0].view(-1, KV_HEADS, HEAD_DIM)[:20]
torch.testing.assert_close(flat, second_input.layers[0].keys[0].transpose(0, 1))
# A prefill past the bucket cannot share the buffers.
bigger = ctx(host, _dyn_cache(BUCKETS[0] + 1))
assert bigger[0] is not first[0], "a prefill past the bucket must get new buffers"
def test_releasing_the_captures_forces_a_rebuild(monkeypatch):
"""Sleep level 2 discards the memory a capture recorded, so what the
pipeline reuses across requests has to be droppable."""
pipe_mod, host = _pipeline_host(monkeypatch)
monkeypatch.setattr(pipe_mod, "DecodeGraphRunner", lambda lm, cache, dev: SimpleNamespace(cache=cache))
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
first = ctx(host, _dyn_cache(10))
assert ctx(host, _dyn_cache(10))[0] is first[0], "the fixture is not reusing to begin with"
pipe_mod.SenseNovaU1Pipeline.release_captured_graphs(host)
after = ctx(host, _dyn_cache(10))
assert after[0] is not first[0], "the cache survived the release"
assert after[1] is not first[1], "the runner survived the release"
class _FakeLoRAWrapper(BaseLayerWithLoRA):
"""Stands in for what ``replace_submodule`` leaves in the module tree."""
def __init__(self):
super().__init__()
def test_no_capture_crosses_a_request_once_lora_wrappers_are_installed(monkeypatch):
"""Base -> LoRA -> base. The reuse test cannot see an adapter change.
``DiffusionLoRAManager`` replaces the decode path's linear layers with
``BaseLayerWithLoRA`` and binds, rescales or resets slot 0 per request, and
the wrappers stay in the tree afterwards. A graph captured before an adapter
was bound would replay without its matmuls; one captured with it bound would
survive its removal. Neither moves the shape, dtype or bucket that decide
reuse, so the wrappers themselves have to end it.
"""
pipe_mod, host = _pipeline_host(monkeypatch)
monkeypatch.setattr(pipe_mod, "DecodeGraphRunner", lambda lm, cache, dev: SimpleNamespace(cache=cache))
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
base = ctx(host, _dyn_cache(10))
assert ctx(host, _dyn_cache(10))[0] is base[0], "the fixture is not reusing to begin with"
host.language_model.add_module("q_proj", _FakeLoRAWrapper())
adapted = ctx(host, _dyn_cache(10))
assert adapted[0] is not base[0], "an adapted request reused the base capture"
assert adapted[1] is not base[1], "an adapted request reused the base runner"
back = ctx(host, _dyn_cache(10))
assert back[0] is not adapted[0], "a later request reused the adapted capture"
assert back[1] is not adapted[1], "a later request reused the adapted runner"
assert getattr(host, "_paged_decode", None) is None, "an adapted capture was left on the pipeline"
@cuda_only
@pytest.mark.cuda
@pytest.mark.L4
def test_a_second_request_replays_the_first_capture(monkeypatch):
"""Reuse is only worth anything if the capture survives it."""
dev = torch.device("cuda")
pipe_mod, host = _pipeline_host(monkeypatch, device="cuda", language_model=_StubLM(dev))
ctx = pipe_mod.SenseNovaU1Pipeline._decode_context
cache, runner = ctx(host, _dyn_cache(10, dev))
cache.set_length(cache.length + 1)
runner.step(0, 0)
assert runner.captures == 1
cache2, runner2 = ctx(host, _dyn_cache(12, dev))
assert cache2 is cache and runner2 is runner
cache2.set_length(cache2.length + 1)
runner2.step(0, 0)
assert runner.captures == 1, "the second request captured the graph again"
class _Tok:
"""The two tokenizer calls ``_generate_text`` makes."""
EOS = 7
def convert_tokens_to_ids(self, token):
return self.EOS
def decode(self, ids, skip_special_tokens=False):
return " ".join(str(i) for i in ids)
class _Out:
def __init__(self, logits, past_key_values):
self.logits = logits
self.past_key_values = past_key_values
class _TextHost:
"""Carries only what ``_generate_text`` touches."""
def __init__(self, context):
self._context = context
self.seen: list[object] = []
self.tokenizer = _Tok()
def _decode_context(self, past_key_values):
return self._context
def _ar_step(self, next_token, t_idx, past_key_values, decode=None):
self.seen.append(decode)
# Stop the loop: argmax of this row is the EOS id.
logits = torch.full((1, 1, 8), -1.0)
logits[0, 0, _Tok.EOS] = 1.0
return _Out(logits, past_key_values)
def test_text_decoding_uses_the_paged_context_too():
"""Image-to-text and text-to-text run their own decode loop. It used to call
``_ar_step`` without the context, so the paged path was documented but never
reached outside think."""
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import (
SenseNovaU1Pipeline,
)
context = object()
host = _TextHost(context)
prefix = torch.full((1, 1, 8), -1.0)
prefix[0, 0, 5] = 1.0 # not EOS, so one step runs
SenseNovaU1Pipeline._generate_text(host, prefix, past_key_values=None, t_idx=0)
assert host.seen == [context], f"text decoding ran with decode={host.seen}"
class _WarmupReq:
"""The engine's dummy request, which carries the reserved id."""
def __init__(self, request_id):
self.request_id = request_id
self.prompts = [{"prompt": "", "modalities": ["image"]}]
def test_the_dummy_warmup_request_drives_one_decode_step(monkeypatch):
"""The dummy request is a text-to-image run with think off, so it exercises
the prefill shape and never the decode one. Without this hook whatever the
deploy config compiles for decode lands on the first real request."""
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import (
SenseNovaU1Pipeline,
)
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID
calls: list[str] = []
host = object.__new__(SenseNovaU1Pipeline)
monkeypatch.setattr(host, "_warm_ar_decode", lambda: calls.append("warm"), raising=False)
monkeypatch.setattr(
SenseNovaU1Pipeline,
"_parse_request",
lambda self, req: (_ for _ in ()).throw(_StopForwardError()),
raising=False,
)
with pytest.raises(_StopForwardError):
SenseNovaU1Pipeline.forward(host, _WarmupReq(DUMMY_DIFFUSION_REQUEST_ID))
assert calls == ["warm"], "the dummy warmup request did not drive a decode step"
calls.clear()
with pytest.raises(_StopForwardError):
SenseNovaU1Pipeline.forward(host, _WarmupReq("a-real-request"))
assert calls == [], "a real request paid for the warmup"
class _StopForwardError(Exception):
"""Stops ``forward`` right after the warmup hook, so the test needs no model."""
@@ -0,0 +1,107 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CPU tests for SenseNova-U1 RMSNorm and the U1.5 config surface.
``Qwen3RMSNorm`` was a hand-rolled fp32 up/down-cast chain; it now calls the fused
``F.rms_norm``. These pin the mathematics against an independent reference so a
future kernel swap cannot silently change what the layer computes, and pin the two
config fields that distinguish U1.5 from U1.
"""
import pytest
import torch
from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import Qwen3RMSNorm
from vllm_omni.transformers_utils.configs.sensenova_u1 import SenseNovaU1Config
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
def _reference(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
"""RMSNorm written out in float64 so it is independent of the implementation."""
x64 = x.to(torch.float64)
var = x64.pow(2).mean(-1, keepdim=True)
return (weight.to(torch.float64) * (x64 * torch.rsqrt(var + eps))).to(x.dtype)
@pytest.mark.parametrize("hidden", [8, 128, 4096])
@pytest.mark.parametrize(
"dtype,atol",
[(torch.float32, 1e-5), (torch.bfloat16, 3e-2), (torch.float16, 4e-3)],
)
def test_rmsnorm_matches_float64_reference(hidden: int, dtype: torch.dtype, atol: float):
torch.manual_seed(0)
norm = Qwen3RMSNorm(hidden, eps=1e-6).to(dtype)
with torch.no_grad():
norm.weight.copy_(torch.rand(hidden, dtype=dtype) + 0.5)
x = torch.randn(7, hidden, dtype=dtype)
got = norm(x)
want = _reference(x, norm.weight, norm.variance_epsilon)
assert got.dtype == dtype
assert got.shape == x.shape
torch.testing.assert_close(got.to(torch.float32), want.to(torch.float32), atol=atol, rtol=atol)
def _cast_chain(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
"""The implementation this PR replaced, kept so its accuracy can be compared."""
var = x.to(torch.float32).pow(2).mean(-1, keepdim=True)
return weight * (x.to(torch.float32) * torch.rsqrt(var + eps)).to(weight.dtype)
@pytest.mark.parametrize("hidden", [512, 4096])
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
def test_rmsnorm_is_closer_to_float64_than_the_cast_chain(hidden: int, dtype: torch.dtype):
"""The cast chain rounds to ``dtype`` before the weight multiply and so rounds
twice, while ``F.rms_norm`` rounds once. Restoring the cast chain fails here."""
torch.manual_seed(0)
norm = Qwen3RMSNorm(hidden, eps=1e-6).to(dtype)
with torch.no_grad():
norm.weight.copy_(torch.rand(hidden, dtype=dtype) + 0.5)
x = torch.randn(64, hidden, dtype=dtype)
reference = _reference(x, norm.weight, norm.variance_epsilon).to(torch.float64)
scale = reference.abs().mean()
def relative_error(out: torch.Tensor) -> float:
return ((out.to(torch.float64) - reference).abs().mean() / scale).item()
fused = relative_error(norm(x))
legacy = relative_error(_cast_chain(x, norm.weight, norm.variance_epsilon))
assert fused < legacy, f"{dtype} hidden={hidden}: fused {fused:.3e} not better than {legacy:.3e}"
def test_rmsnorm_is_scale_equivariant():
"""RMSNorm divides by the RMS, so scaling the input must not change the output."""
torch.manual_seed(0)
norm = Qwen3RMSNorm(64, eps=1e-12)
x = torch.randn(4, 64)
torch.testing.assert_close(norm(x), norm(x * 7.5), atol=1e-4, rtol=1e-4)
def test_rmsnorm_preserves_leading_dims():
norm = Qwen3RMSNorm(16, eps=1e-6)
for shape in [(16,), (3, 16), (2, 3, 16)]:
assert norm(torch.randn(*shape)).shape == shape
def test_rmsnorm_weight_is_applied():
norm = Qwen3RMSNorm(4, eps=1e-6)
x = torch.randn(2, 4)
base = norm(x)
with torch.no_grad():
norm.weight.mul_(3.0)
torch.testing.assert_close(norm(x), base * 3.0, atol=1e-5, rtol=1e-5)
def test_u15_config_fields_default_to_u1_values():
"""U1.5 flips exactly these two; the defaults must stay U1's so old checkpoints
keep their behaviour when the keys are absent."""
cfg = SenseNovaU1Config()
assert cfg.use_pixel_head is False
assert cfg.noise_scale_max_value == 10.0
def test_u15_config_fields_are_read_from_kwargs():
cfg = SenseNovaU1Config(use_pixel_head=True, noise_scale_max_value=16.0)
assert cfg.use_pixel_head is True
assert cfg.noise_scale_max_value == 16.0