Files
vllm-omni/tests/diffusion/attention/test_piecewise_attn.py
T
2026-08-22 21:45:45 +08:00

285 lines
8.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""End-to-end test for ``piecewise_attn`` (CPU).
Verify that running attention in segments (causal outside full-attn spans,
bidirectional inside full-attn spans) matches running a single full SDPA call
with the equivalent 2D attention mask.
Covers:
* batch size = 1 and batch size > 1 (homogeneous CFG-like batch)
* query length == key length (full prefill)
* query length < key length (decode-like tail slice)
* various full-attn-span layouts (none / start / middle / end / multi)
"""
from __future__ import annotations
import pytest
import torch
import torch.nn.functional as F
import vllm_omni.diffusion.attention.backends.utils.piecewise_attn as piecewise_module
from vllm_omni.diffusion.attention.backends.abstract import QueryRange
from vllm_omni.diffusion.attention.backends.utils.piecewise_attn import (
Segment,
build_paged_piecewise_plan,
build_segments,
piecewise_attn,
run_paged_piecewise_plan,
)
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
DEVICE = torch.device("cpu")
def _sdpa_attn_func(q, k, v, causal, softmax_scale):
q_ = q.transpose(1, 2)
k_ = k.transpose(1, 2)
v_ = v.transpose(1, 2)
attn_mask = None
if causal:
Sq, Sk = q_.shape[-2], k_.shape[-2]
i = torch.arange(Sq, device=q.device).unsqueeze(1)
j = torch.arange(Sk, device=q.device).unsqueeze(0)
attn_mask = j <= (i + (Sk - Sq))
out = F.scaled_dot_product_attention(q_, k_, v_, attn_mask=attn_mask, scale=softmax_scale)
return out.transpose(1, 2).contiguous()
def _full_reference(query, key, value, global_spans, q_start, q_end, softmax_scale):
"""Build a full 2D mask with global spans and compute reference output."""
Sk = key.shape[1]
mask = torch.tril(torch.ones(Sk, Sk, dtype=torch.bool, device=key.device))
for a, e in global_spans:
mask[a:e, :e] = True
mask_q = mask[q_start:q_end, :]
q_ = query.transpose(1, 2)
k_ = key.transpose(1, 2)
v_ = value.transpose(1, 2)
out = F.scaled_dot_product_attention(q_, k_, v_, attn_mask=mask_q, scale=softmax_scale)
return out.transpose(1, 2).contiguous()
SPAN_CASES = [
pytest.param([], id="no-spans"),
pytest.param([(0, 10)], id="span-at-start"),
pytest.param([(10, 30), (54, 64)], id="multi-spans"),
]
Q_RANGE_CASES = [
pytest.param((0, 64), id="q_eq_k"), # Sq == Sk (prefill)
pytest.param((53, 64), id="q_lt_k"), # Sq < Sk (decode-like)
]
BATCH_CASES = [
pytest.param(1, id="B1"),
pytest.param(2, id="B2"),
]
def test_build_segments_full_span_retains_global_kv_end():
segments = build_segments([(2, 7)], query_offset=4, query_len=2)
assert segments == [Segment(q_start=4, q_end=6, kv_end=7, mode="full")]
def test_paged_piecewise_runner_uses_packed_indices_and_kv_endpoints():
full_attn_spans = [[(2, 5)], [(4, 7)]]
plan = build_paged_piecewise_plan(
full_attn_spans,
query_offsets=[0, 2],
query_lens=[6, 6],
seq_lens=[6, 8],
device=DEVICE,
)
query = torch.arange(12 * 2 * 4, dtype=torch.float32).reshape(12, 2, 4)
key = query + 100
value = query + 200
calls = []
def run_segment(segment_query, segment_key, segment_value, segment_batch):
calls.append(
(
[segment.mode for segment in segment_batch.row_segments],
[segment.kv_end for segment in segment_batch.row_segments],
segment_batch.query_indices.tolist(),
)
)
torch.testing.assert_close(segment_key, segment_query + 100)
torch.testing.assert_close(segment_value, segment_query + 200)
return segment_query
output = run_paged_piecewise_plan(
query,
key,
value,
plan,
plan.segments,
run_segment,
)
torch.testing.assert_close(output, query)
assert calls == [
(["causal", "causal"], [2, 4], [0, 1, 6, 7]),
(["full", "full"], [5, 7], [2, 3, 4, 8, 9, 10]),
(["causal", "causal"], [6, 8], [5, 11]),
]
def test_paged_piecewise_runner_uses_runner_output_shape():
plan = build_paged_piecewise_plan(
[[(2, 5)]],
query_offsets=[0],
query_lens=[6],
seq_lens=[6],
device=DEVICE,
)
query = torch.arange(6 * 2 * 4, dtype=torch.float32).reshape(6, 2, 4)
output = run_paged_piecewise_plan(
query,
query,
query,
plan,
plan.segments,
lambda segment_query, *_args: segment_query[..., :3],
)
assert output.shape == (6, 2, 3)
torch.testing.assert_close(output, query[..., :3])
@pytest.mark.parametrize("global_spans", SPAN_CASES)
@pytest.mark.parametrize("q_range", Q_RANGE_CASES)
@pytest.mark.parametrize("batch_size", BATCH_CASES)
def test_piecewise_matches_full(global_spans, q_range, batch_size):
torch.manual_seed(0)
H, D, Sk = 2, 16, 64
q_start, q_end = q_range
Sq = q_end - q_start
key = torch.randn(batch_size, Sk, H, D, device=DEVICE)
value = torch.randn(batch_size, Sk, H, D, device=DEVICE)
query = torch.randn(batch_size, Sq, H, D, device=DEVICE)
full_attn_spans = [list(global_spans) for _ in range(batch_size)]
softmax_scale = 1.0 / (D**0.5)
got = piecewise_attn(
query,
key,
value,
full_attn_spans=full_attn_spans,
softmax_scale=softmax_scale,
attn_func=_sdpa_attn_func,
)
expected = _full_reference(query, key, value, global_spans, q_start, q_end, softmax_scale)
torch.testing.assert_close(got, expected, atol=1e-5, rtol=1e-5)
def test_piecewise_span_fully_before_qstart():
"""Spans entirely before query region produce pure causal attention."""
torch.manual_seed(0)
B, H, D, Sk = 1, 2, 16, 30
q_start, q_end = 15, 30
Sq = q_end - q_start
key = torch.randn(B, Sk, H, D, device=DEVICE)
value = torch.randn(B, Sk, H, D, device=DEVICE)
query = torch.randn(B, Sq, H, D, device=DEVICE)
global_spans = [(5, 10)]
full_attn_spans = [list(global_spans) for _ in range(B)]
softmax_scale = 1.0 / (D**0.5)
got = piecewise_attn(
query,
key,
value,
full_attn_spans=full_attn_spans,
softmax_scale=softmax_scale,
attn_func=_sdpa_attn_func,
)
expected = _full_reference(query, key, value, global_spans, q_start, q_end, softmax_scale)
torch.testing.assert_close(got, expected, atol=1e-5, rtol=1e-5)
def test_piecewise_noncontiguous_query_ranges_match_full_mask():
torch.manual_seed(0)
B, H, D, Sk = 1, 2, 8, 8
key = torch.randn(B, Sk, H, D, device=DEVICE)
value = torch.randn(B, Sk, H, D, device=DEVICE)
query = torch.randn(B, 4, H, D, device=DEVICE)
spans = [(2, 7)]
query_ranges = (
QueryRange(0, 1, 0),
QueryRange(1, 4, 4),
)
softmax_scale = D**-0.5
got = piecewise_attn(
query,
key,
value,
full_attn_spans=[spans],
softmax_scale=softmax_scale,
attn_func=_sdpa_attn_func,
query_ranges=query_ranges,
)
expected = torch.cat(
[
_full_reference(query[:, :1], key, value, spans, 0, 1, softmax_scale),
_full_reference(query[:, 1:], key, value, spans, 4, 7, softmax_scale),
],
dim=1,
)
torch.testing.assert_close(got, expected, atol=1e-5, rtol=1e-5)
def test_piecewise_rejects_incomplete_query_ranges():
query = torch.zeros(1, 3, 1, 2)
key = torch.zeros(1, 3, 1, 2)
value = torch.zeros_like(key)
with pytest.raises(ValueError, match="cover local query contiguously"):
piecewise_attn(
query,
key,
value,
full_attn_spans=[[]],
softmax_scale=1.0,
attn_func=_sdpa_attn_func,
query_ranges=(QueryRange(1, 3, 1),),
)
def test_piecewise_combines_noncontiguous_query_ranges_with_one_cat(monkeypatch):
query = torch.randn(1, 4, 2, 8)
key = torch.randn(1, 8, 2, 8)
value = torch.randn_like(key)
cat_calls = 0
original_cat = torch.cat
def counted_cat(tensors, dim=0):
nonlocal cat_calls
cat_calls += 1
return original_cat(tensors, dim=dim)
monkeypatch.setattr(piecewise_module.torch, "cat", counted_cat)
output = piecewise_attn(
query,
key,
value,
full_attn_spans=[[(0, 8)]],
softmax_scale=1.0,
attn_func=lambda q, k, v, **kwargs: q,
query_ranges=(QueryRange(0, 1, 0), QueryRange(1, 4, 4)),
)
assert cat_calls == 1
torch.testing.assert_close(output, query)