344 lines
13 KiB
Python
344 lines
13 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
|
|
"""Geometry contract RAINFUSION_ATTN enforces before handing a forward to rf_v2.
|
|
|
|
The grids here are the ones MiniMax-H3 FL2VA actually produces for an 8.7s clip:
|
|
1280x768 gives a 62x24x40 latent grid whose 59520 video rows land on the 128-row
|
|
kernel block, and 1344x768 gives 62x24x42 whose 62496 rows do not. Both are
|
|
handed to MindIE-SD: its rf_v2 preprocessing promotes an irregular video tail
|
|
to the always-kept prefix segment before generating the block mask.
|
|
"""
|
|
|
|
import dataclasses
|
|
import sys
|
|
import types
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm_omni.diffusion.attention.backends import rainfusion_attn
|
|
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata, VideoTokenLayout, VideoTokenSpan
|
|
from vllm_omni.diffusion.attention.backends.rainfusion_attn import (
|
|
_BLOCK_SIZE,
|
|
RainFusionAttentionBackend,
|
|
RainFusionAttentionImpl,
|
|
RainFusionPlan,
|
|
)
|
|
from vllm_omni.platforms import current_omni_platform
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.npu]
|
|
|
|
PREFIX_ROWS = 710 # 14 text rows + 696 audio rows
|
|
ALIGNED_GRID = (62, 24, 40) # 1280x768 -> 59520 video rows, 465 blocks
|
|
MISALIGNED_GRID = (62, 24, 42) # 1344x768 -> 62496 video rows, 488 blocks + 32 rows
|
|
|
|
|
|
def make_impl(**backend_kwargs):
|
|
return RainFusionAttentionImpl(
|
|
num_heads=8,
|
|
head_size=128,
|
|
softmax_scale=128**-0.5,
|
|
prefix="transformer_blocks.0.attn",
|
|
qkv_layout="BSND",
|
|
backend_kwargs={"sparsity": 0.8, **backend_kwargs},
|
|
)
|
|
|
|
|
|
def make_metadata(grid, prefix_len=PREFIX_ROWS, max_seqlen_q=None):
|
|
video_rows = grid[0] * grid[1] * grid[2]
|
|
return AttentionMetadata(
|
|
extra={"max_seqlen_q": prefix_len + video_rows if max_seqlen_q is None else max_seqlen_q},
|
|
video_layout=VideoTokenLayout(prefix_len=prefix_len, latent_grid=grid),
|
|
)
|
|
|
|
|
|
def test_block_aligned_video_segment_runs_sparse():
|
|
plan = make_impl()._resolve_plan(make_metadata(ALIGNED_GRID))
|
|
|
|
assert plan is not None
|
|
assert plan.prefix_len == PREFIX_ROWS
|
|
assert plan.used_len == PREFIX_ROWS + 59520
|
|
assert plan.latent_shape == list(ALIGNED_GRID)
|
|
|
|
|
|
def test_misaligned_video_segment_runs_sparse_with_updated_mindiesd():
|
|
plan = make_impl()._resolve_plan(make_metadata(MISALIGNED_GRID))
|
|
|
|
assert plan is not None
|
|
assert plan.prefix_len == PREFIX_ROWS
|
|
assert plan.used_len == PREFIX_ROWS + 62496
|
|
assert plan.latent_shape == list(MISALIGNED_GRID)
|
|
|
|
|
|
@pytest.mark.parametrize("prefix_len", [1, 127, _BLOCK_SIZE, 710, 900])
|
|
def test_prefix_length_does_not_affect_alignment(prefix_len):
|
|
# Only the video segment has to land on a block boundary; rf_v2 pools the
|
|
# prefix separately and keeps every one of its blocks.
|
|
plan = make_impl()._resolve_plan(make_metadata(ALIGNED_GRID, prefix_len=prefix_len))
|
|
|
|
assert plan is not None
|
|
assert plan.prefix_len == prefix_len
|
|
|
|
|
|
def test_sparsity_zero_never_resolves_a_plan():
|
|
assert make_impl(sparsity=0.0)._resolve_plan(make_metadata(ALIGNED_GRID)) is None
|
|
|
|
|
|
def test_missing_video_layout_falls_back_to_dense():
|
|
assert make_impl()._resolve_plan(AttentionMetadata(extra={"max_seqlen_q": 60230})) is None
|
|
|
|
|
|
def test_missing_max_seqlen_falls_back_to_dense():
|
|
metadata = AttentionMetadata(
|
|
video_layout=VideoTokenLayout(prefix_len=PREFIX_ROWS, latent_grid=ALIGNED_GRID),
|
|
)
|
|
|
|
assert make_impl()._resolve_plan(metadata) is None
|
|
|
|
|
|
def test_video_segment_must_be_the_tail_of_packed_document_zero():
|
|
metadata = make_metadata(ALIGNED_GRID, max_seqlen_q=PREFIX_ROWS + 59520 + 128)
|
|
|
|
assert make_impl()._resolve_plan(metadata) is None
|
|
|
|
|
|
def test_ref2va_multi_video_spans_resolve():
|
|
layout = VideoTokenLayout(
|
|
used_len=12000,
|
|
video_spans=(
|
|
VideoTokenSpan(start=128, latent_grid=(4, 16, 64), role="reference"),
|
|
VideoTokenSpan(start=5000, latent_grid=(4, 16, 64), role="target"),
|
|
),
|
|
)
|
|
plan = make_impl()._resolve_plan(AttentionMetadata(extra={"max_seqlen_q": 12000}, video_layout=layout))
|
|
|
|
assert plan is not None
|
|
assert plan.used_len == 12000
|
|
assert plan.video_spans == [
|
|
{"start": 128, "latent_shape": [4, 16, 64]},
|
|
{"start": 5000, "latent_shape": [4, 16, 64]},
|
|
]
|
|
|
|
|
|
def test_invalid_ref2va_video_spans_fall_back_to_dense():
|
|
layout = VideoTokenLayout(
|
|
used_len=12000,
|
|
video_spans=(
|
|
VideoTokenSpan(start=128, latent_grid=(4, 16, 64), role="reference"),
|
|
VideoTokenSpan(start=4000, latent_grid=(4, 16, 64), role="target"),
|
|
),
|
|
)
|
|
assert make_impl()._resolve_plan(AttentionMetadata(extra={"max_seqlen_q": 12000}, video_layout=layout)) is None
|
|
|
|
|
|
def test_validate_available_accepts_single_video_mindiesd(monkeypatch):
|
|
mindiesd = types.ModuleType("mindiesd")
|
|
|
|
def sparse_attention(query, key, value, **kwargs):
|
|
return query
|
|
|
|
monkeypatch.setattr(mindiesd, "sparse_attention", sparse_attention, raising=False)
|
|
monkeypatch.setitem(sys.modules, "mindiesd", mindiesd)
|
|
monkeypatch.setattr("importlib.util.find_spec", lambda _: object())
|
|
|
|
RainFusionAttentionBackend.validate_available()
|
|
|
|
|
|
def test_validate_available_accepts_new_mindiesd(monkeypatch):
|
|
mindiesd = types.ModuleType("mindiesd")
|
|
|
|
def sparse_attention(query, key, value, *, video_spans=None, **kwargs):
|
|
return query
|
|
|
|
monkeypatch.setattr(mindiesd, "sparse_attention", sparse_attention, raising=False)
|
|
monkeypatch.setitem(sys.modules, "mindiesd", mindiesd)
|
|
monkeypatch.setattr("importlib.util.find_spec", lambda _: object())
|
|
|
|
RainFusionAttentionBackend.validate_available()
|
|
|
|
|
|
def test_prefix_kv_slicing_contract_is_pinned():
|
|
# Regression pin for the #5543 packed-padding crash: reverting the flag
|
|
# would pass CI while reintroducing the ValueError for non-64-aligned
|
|
# requests. Both halves of the contract must hold together — the impl
|
|
# trims [real, pad] packed tensors to the valid prefix itself (so it may
|
|
# advertise prefix slicing) and it never reads attn_mask (so it must keep
|
|
# refusing one), which is what lets the model skip materializing the
|
|
# padding mask that _assert_metadata_compatible would reject.
|
|
assert RainFusionAttentionBackend.supports_prefix_kv_slicing is True
|
|
assert RainFusionAttentionBackend.supports_attention_mask() is False
|
|
|
|
|
|
@pytest.mark.parametrize("grid", [(4, 24, 40), (1, 24, 40)])
|
|
def test_short_video_stays_dense(grid):
|
|
assert make_impl()._resolve_plan(make_metadata(grid)) is None
|
|
|
|
|
|
@pytest.mark.parametrize("qkv_layout", ["BNSD", "BSH"])
|
|
def test_explicitly_wrong_layout_is_rejected(qkv_layout):
|
|
with pytest.raises(ValueError, match="BSND"):
|
|
RainFusionAttentionImpl(
|
|
num_heads=8,
|
|
head_size=128,
|
|
softmax_scale=128**-0.5,
|
|
qkv_layout=qkv_layout,
|
|
backend_kwargs={"sparsity": 0.8},
|
|
)
|
|
|
|
|
|
def test_undeclared_layout_stays_dense():
|
|
# An undeclared layout is not an error -- the layer keeps working, just
|
|
# densely, and the fallback sees the same absent layout plain FLASH_ATTN would.
|
|
impl = RainFusionAttentionImpl(
|
|
num_heads=8,
|
|
head_size=128,
|
|
softmax_scale=128**-0.5,
|
|
prefix="transformer_blocks.0.attn",
|
|
backend_kwargs={"sparsity": 0.8},
|
|
)
|
|
|
|
assert impl._resolve_plan(make_metadata(ALIGNED_GRID)) is None
|
|
assert impl.dense_fallback.qkv_layout is None
|
|
|
|
|
|
@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="rf_v2 runs on Ascend NPU only.")
|
|
def test_fully_populated_mask_reproduces_dense_attention():
|
|
"""At sparsity=0 every key block is kept, so rf_v2 must match dense attention.
|
|
|
|
This is the guard on the geometry the backend hands the kernel: a wrong
|
|
prefix length or latent grid still produces plausible output under sparse
|
|
selection, but shows up here as a mismatch against SDPA.
|
|
"""
|
|
torch.manual_seed(0)
|
|
prefix_len, grid = 384, (4, 16, 64) # 4096 video rows = 32 blocks
|
|
video_len = grid[0] * grid[1] * grid[2]
|
|
used = prefix_len + video_len
|
|
heads, head_dim = 4, 128
|
|
q, k, v = (torch.randn(1, used, heads, head_dim, dtype=torch.bfloat16, device="npu") for _ in range(3))
|
|
|
|
impl = make_impl()
|
|
impl.rainfusion = dataclasses.replace(impl.rainfusion, sparsity=0.0)
|
|
plan = RainFusionPlan(prefix_len=prefix_len, used_len=used, latent_shape=list(grid))
|
|
out = impl._forward_sparse_npu(q, k, v, plan)
|
|
|
|
reference = torch.nn.functional.scaled_dot_product_attention(
|
|
q.transpose(1, 2),
|
|
k.transpose(1, 2),
|
|
v.transpose(1, 2),
|
|
scale=impl.softmax_scale,
|
|
).transpose(1, 2)
|
|
error = (out.float() - reference.float()).abs().mean() / reference.float().abs().mean()
|
|
assert error < 2e-3, f"mean relative error {error:.4%} against dense attention"
|
|
|
|
|
|
@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="rf_v2 runs on Ascend NPU only.")
|
|
def test_irregular_video_tail_reproduces_dense_attention_with_updated_mindiesd():
|
|
"""vLLM forwards the full irregular grid; MindIE-SD owns its tail handling."""
|
|
torch.manual_seed(1)
|
|
prefix_len, grid = 384, (4, 16, 10)
|
|
video_len = grid[0] * grid[1] * grid[2]
|
|
used = prefix_len + video_len
|
|
heads, head_dim = 4, 128
|
|
q, k, v = (torch.randn(1, used, heads, head_dim, dtype=torch.bfloat16, device="npu") for _ in range(3))
|
|
|
|
# Explicit bf16: the dense comparison exercises the plain sparse kernel,
|
|
# and older MindIE-SD releases do not accept precision= (gate would raise).
|
|
impl = make_impl(precision="bf16")
|
|
impl.rainfusion = dataclasses.replace(impl.rainfusion, sparsity=0.0)
|
|
plan = RainFusionPlan(prefix_len=prefix_len, used_len=used, latent_shape=list(grid))
|
|
out = impl._forward_sparse_npu(q, k, v, plan)
|
|
|
|
reference = torch.nn.functional.scaled_dot_product_attention(
|
|
q.transpose(1, 2),
|
|
k.transpose(1, 2),
|
|
v.transpose(1, 2),
|
|
scale=impl.softmax_scale,
|
|
).transpose(1, 2)
|
|
error = (out.float() - reference.float()).abs().mean() / reference.float().abs().mean()
|
|
assert error < 2e-3, f"mean relative error {error:.4%} against dense attention"
|
|
|
|
|
|
# end_step tail fallback: the last ``end_step`` denoise steps must stay dense.
|
|
|
|
|
|
def test_end_step_tail_window_stays_dense():
|
|
"""step_idx inside the last end_step window must resolve no sparse plan."""
|
|
impl = make_impl(end_step=3)
|
|
fc = mock.Mock()
|
|
fc.denoise_step_idx = 47
|
|
fc.total_denoise_steps = 50
|
|
with (
|
|
mock.patch.object(rainfusion_attn, "is_forward_context_available", return_value=True),
|
|
mock.patch.object(rainfusion_attn, "get_forward_context", return_value=fc),
|
|
):
|
|
assert impl._resolve_plan(make_metadata(ALIGNED_GRID)) is None
|
|
|
|
|
|
def test_end_step_before_tail_window_still_sparse():
|
|
"""step_idx before the tail window must still resolve a sparse plan."""
|
|
impl = make_impl(end_step=3)
|
|
fc = mock.Mock()
|
|
fc.denoise_step_idx = 46
|
|
fc.total_denoise_steps = 50
|
|
with (
|
|
mock.patch.object(rainfusion_attn, "is_forward_context_available", return_value=True),
|
|
mock.patch.object(rainfusion_attn, "get_forward_context", return_value=fc),
|
|
):
|
|
assert impl._resolve_plan(make_metadata(ALIGNED_GRID)) is not None
|
|
|
|
|
|
def test_end_step_zero_never_triggers_tail_fallback():
|
|
"""end_step=0 must not fall back even on the final denoise step."""
|
|
impl = make_impl(end_step=0)
|
|
fc = mock.Mock()
|
|
fc.denoise_step_idx = 49
|
|
fc.total_denoise_steps = 50
|
|
with (
|
|
mock.patch.object(rainfusion_attn, "is_forward_context_available", return_value=True),
|
|
mock.patch.object(rainfusion_attn, "get_forward_context", return_value=fc),
|
|
):
|
|
assert impl._resolve_plan(make_metadata(ALIGNED_GRID)) is not None
|
|
|
|
|
|
# precision capability gate: a mindiesd without sparse_attention(precision=)
|
|
# must fail fast instead of silently running the BF16 path.
|
|
|
|
|
|
def _fake_mindiesd_module():
|
|
"""Install a minimal stand-in so the import inside _forward_sparse_npu succeeds."""
|
|
import types
|
|
|
|
fake = types.ModuleType("mindiesd")
|
|
setattr(fake, "sparse_attention", lambda *args, **kwargs: None)
|
|
return fake
|
|
|
|
|
|
def test_precision_non_bf16_requires_mindiesd_support():
|
|
"""Unsupported sparse precision must fail before any operator executes."""
|
|
import sys
|
|
|
|
impl = make_impl(precision="mix")
|
|
with mock.patch.dict(sys.modules, {"mindiesd": _fake_mindiesd_module()}):
|
|
with mock.patch.object(rainfusion_attn, "_mindiesd_supports_precision", return_value=False):
|
|
with pytest.raises(ValueError, match="explicitly support precision"):
|
|
impl._forward_sparse_npu(
|
|
None, None, None, RainFusionPlan(prefix_len=0, used_len=8, latent_shape=[2, 2, 2])
|
|
)
|
|
|
|
|
|
def test_precision_non_bf16_passes_gate_when_supported():
|
|
"""precision != bf16 with a capable mindiesd must not raise from the gate."""
|
|
import sys
|
|
|
|
impl = make_impl(precision="mix")
|
|
with mock.patch.dict(sys.modules, {"mindiesd": _fake_mindiesd_module()}):
|
|
with mock.patch.object(rainfusion_attn, "_mindiesd_supports_precision", return_value=True):
|
|
# q/k/v shapes: [B, S, N, D]; plan geometry must match S.
|
|
q = torch.randn(1, 8, 4, 128)
|
|
plan = RainFusionPlan(prefix_len=0, used_len=8, latent_shape=[2, 2, 2])
|
|
# The gate must pass; the fake mindiesd returns None so no crash.
|
|
out = impl._forward_sparse_npu(q, q, q, plan)
|
|
assert out is None
|