Signed-off-by: root <jeffaw99@hotmail.com> Signed-off-by: big-yellow-duck <jeffaw99@hotmail.com> Co-authored-by: Tan Pin Siang <1716735+tanpinsiang@users.noreply.github.com> Co-authored-by: vllmellm <190700713+vllmellm@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
1409 lines
49 KiB
Python
1409 lines
49 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
"""Segmented attention accuracy, cache semantics, and startup tuning contracts."""
|
|
|
|
import math
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm.platforms import current_platform
|
|
from vllm.triton_utils import triton
|
|
|
|
if current_platform.is_rocm():
|
|
from vllm.platforms.rocm import on_gfx1x
|
|
|
|
pytestmark = [
|
|
pytest.mark.skip_global_cleanup,
|
|
pytest.mark.skipif(
|
|
not (current_platform.is_rocm() and on_gfx1x()),
|
|
reason="Segmented attention requires RDNA GPUs",
|
|
),
|
|
]
|
|
|
|
|
|
def _make_paged_attention_case(
|
|
query_lens: list[int],
|
|
context_lens: list[int],
|
|
*,
|
|
num_heads: int,
|
|
num_kv_heads: int,
|
|
head_size: int,
|
|
block_size: int,
|
|
fp8: bool,
|
|
causal: bool = True,
|
|
sliding_window: int = 0,
|
|
softcap: float = 0.0,
|
|
sinks: torch.Tensor | None = None,
|
|
):
|
|
device = torch.device("cuda:0")
|
|
generator = torch.Generator(device=device).manual_seed(419)
|
|
total_queries = sum(query_lens)
|
|
qkv = (
|
|
torch.randn(
|
|
total_queries,
|
|
num_heads + 2 * num_kv_heads,
|
|
head_size,
|
|
device=device,
|
|
dtype=torch.bfloat16,
|
|
generator=generator,
|
|
)
|
|
* 0.25
|
|
)
|
|
query, key, value = qkv.split((num_heads, num_kv_heads, num_kv_heads), dim=1)
|
|
cache_dtype = current_platform.fp8_dtype() if fp8 else torch.bfloat16
|
|
k_scale = torch.tensor(0.125 if fp8 else 1.0, device=device)
|
|
v_scale = torch.tensor(0.25 if fp8 else 1.0, device=device)
|
|
max_seq_len = max(q + c for q, c in zip(query_lens, context_lens))
|
|
blocks_per_seq = triton.cdiv(max_seq_len, block_size)
|
|
num_blocks = len(query_lens) * blocks_per_seq
|
|
key_cache = (
|
|
torch.randn(
|
|
num_blocks,
|
|
block_size,
|
|
num_kv_heads,
|
|
head_size,
|
|
device=device,
|
|
dtype=torch.float32,
|
|
generator=generator,
|
|
)
|
|
* 0.25
|
|
/ k_scale
|
|
).to(cache_dtype)
|
|
value_cache = (
|
|
torch.randn(
|
|
num_blocks,
|
|
block_size,
|
|
num_kv_heads,
|
|
head_size,
|
|
device=device,
|
|
dtype=torch.float32,
|
|
generator=generator,
|
|
)
|
|
* 0.25
|
|
/ v_scale
|
|
).to(cache_dtype)
|
|
block_table = torch.arange(num_blocks, device=device, dtype=torch.int32).view(
|
|
len(query_lens), blocks_per_seq
|
|
)
|
|
starts = torch.tensor(
|
|
[0, *torch.tensor(query_lens).cumsum(0).tolist()],
|
|
device=device,
|
|
dtype=torch.int32,
|
|
)
|
|
seq_lens = torch.tensor(
|
|
[q + c for q, c in zip(query_lens, context_lens)],
|
|
device=device,
|
|
dtype=torch.int32,
|
|
)
|
|
|
|
first = 0
|
|
for seq, (query_len, context_len) in enumerate(zip(query_lens, context_lens)):
|
|
for query_pos in range(query_len):
|
|
cache_pos = context_len + query_pos
|
|
block = block_table[seq, cache_pos // block_size]
|
|
offset = cache_pos % block_size
|
|
key_cache[block, offset] = (key[first + query_pos].float() / k_scale).to(
|
|
cache_dtype
|
|
)
|
|
value_cache[block, offset] = (
|
|
value[first + query_pos].float() / v_scale
|
|
).to(cache_dtype)
|
|
first += query_len
|
|
|
|
references = []
|
|
first = 0
|
|
for seq, (query_len, context_len) in enumerate(zip(query_lens, context_lens)):
|
|
physical = block_table[seq].long()
|
|
seq_len = context_len + query_len
|
|
full_key = (
|
|
key_cache[physical].flatten(0, 1)[:seq_len].float() * k_scale
|
|
).repeat_interleave(num_heads // num_kv_heads, dim=1)
|
|
full_value = (
|
|
value_cache[physical].flatten(0, 1)[:seq_len].float() * v_scale
|
|
).repeat_interleave(num_heads // num_kv_heads, dim=1)
|
|
scores = torch.einsum(
|
|
"qhd,khd->hqk", query[first : first + query_len].float(), full_key
|
|
) / math.sqrt(head_size)
|
|
if softcap > 0:
|
|
scores = softcap * torch.tanh(scores / softcap)
|
|
query_positions = context_len + torch.arange(query_len, device=device)
|
|
key_positions = torch.arange(seq_len, device=device)
|
|
causal_mask = key_positions[None, :] > query_positions[:, None]
|
|
if causal:
|
|
scores.masked_fill_(causal_mask[None], -float("inf"))
|
|
if sliding_window > 0:
|
|
window_mask = key_positions[None, :] < (
|
|
query_positions[:, None] - sliding_window + 1
|
|
)
|
|
scores.masked_fill_(window_mask[None], -float("inf"))
|
|
if sinks is not None:
|
|
sink_scores = sinks.float()[:, None, None].expand(-1, query_len, 1)
|
|
probabilities = torch.cat((scores, sink_scores), dim=-1).softmax(-1)
|
|
probabilities = probabilities[..., :-1]
|
|
else:
|
|
probabilities = scores.softmax(-1)
|
|
references.append(torch.einsum("hqk,khd->qhd", probabilities, full_value))
|
|
first += query_len
|
|
|
|
return {
|
|
"query": query,
|
|
"key": key,
|
|
"value": value,
|
|
"key_cache": key_cache,
|
|
"value_cache": value_cache,
|
|
"block_table": block_table,
|
|
"starts": starts,
|
|
"seq_lens": seq_lens,
|
|
"k_scale": k_scale,
|
|
"v_scale": v_scale,
|
|
"reference": torch.cat(references),
|
|
"max_seq_len": max_seq_len,
|
|
}
|
|
|
|
|
|
def _run_segmented_case(case, output, max_query_len, *, force_splits=None, **kwargs):
|
|
from vllm.v1.attention.ops.segmented_attention import (
|
|
run_segmented_attention,
|
|
select_segmented_config,
|
|
)
|
|
|
|
config = None
|
|
heads, dim = case["query"].shape[1:]
|
|
if force_splits is not None:
|
|
config = dict(
|
|
select_segmented_config(
|
|
case["seq_lens"].numel(),
|
|
max_query_len,
|
|
case["max_seq_len"],
|
|
heads,
|
|
case["key_cache"].shape[2],
|
|
dim,
|
|
case["key_cache"].element_size() == 1,
|
|
),
|
|
splits=force_splits,
|
|
)
|
|
run_segmented_attention(
|
|
case["query"],
|
|
output,
|
|
case["key_cache"],
|
|
case["value_cache"],
|
|
case["block_table"],
|
|
case["starts"],
|
|
case["seq_lens"],
|
|
max_query_len,
|
|
case["max_seq_len"],
|
|
case["k_scale"],
|
|
case["v_scale"],
|
|
dim**-0.5,
|
|
config=config,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def test_segmented_tuning_candidates_preserve_workspace_bound():
|
|
"""D128/D256 candidates retain their existing split scratch bound."""
|
|
from vllm.model_executor.warmup import (
|
|
rocm_segmented_attn_autotune_warmup as tuning,
|
|
)
|
|
from vllm.v1.attention.ops import segmented_attention as segmented
|
|
|
|
for batch, query_len, seq_len, heads, kv_heads, dim, fp8 in (
|
|
(1, 1, 128, 8, 8, 128, True),
|
|
(32, 1, 262144, 16, 1, 128, True),
|
|
(4, 1024, 8192, 16, 4, 256, False),
|
|
(1, 1024, 131072, 6, 1, 256, True),
|
|
):
|
|
default = segmented.select_segmented_config(
|
|
batch, query_len, seq_len, heads, kv_heads, dim, fp8
|
|
)
|
|
candidates = tuning._candidate_configs(default, batch, query_len, heads, dim)
|
|
assert candidates
|
|
assert (
|
|
tuning._normalized_config(default, batch, query_len, heads, dim)
|
|
in candidates
|
|
)
|
|
assert all(config["splits"] <= default["splits"] for config in candidates)
|
|
assert all(dim % config["bk"] == 0 for config in candidates)
|
|
|
|
|
|
@pytest.mark.parametrize("dim", (128, 256))
|
|
def test_segmented_fp8_long_extend_split_workspace_bound(dim):
|
|
from vllm.v1.attention.ops.segmented_attention import (
|
|
MAX_LONG_EXTEND_WORKSPACE_BYTES,
|
|
segmented_query_capacity,
|
|
select_segmented_config,
|
|
)
|
|
|
|
for batch, query_len, seq_len, heads in (
|
|
(1, 256, 8192, 6),
|
|
(1, 256, 32768, 6),
|
|
(1, 256, 131072, 6),
|
|
(1, 1024, 32768, 1),
|
|
(1, 1024, 131072, 1),
|
|
(1, 1024, 131072, 6),
|
|
(1, 4096, 131072, 1),
|
|
(4, 1024, 131072, 6),
|
|
(5, 257, 131072, 6),
|
|
(21, 256, 131072, 6),
|
|
):
|
|
config = select_segmented_config(batch, query_len, seq_len, heads, 1, dim, True)
|
|
if seq_len >= 32768:
|
|
scratch = (
|
|
batch
|
|
* segmented_query_capacity(query_len)
|
|
* heads
|
|
* config["splits"]
|
|
* (dim + 1)
|
|
* 4
|
|
)
|
|
assert scratch <= MAX_LONG_EXTEND_WORKSPACE_BYTES * dim // 128
|
|
|
|
for batch in (1, 4, 16):
|
|
for query_len in (257, 1025, 4095, 8192):
|
|
for ratio in (1, 6, 16):
|
|
for kv_heads in (1, 4):
|
|
for seq_len in (32768, 131072):
|
|
heads = ratio * kv_heads
|
|
config = select_segmented_config(
|
|
batch, query_len, seq_len, heads, kv_heads, dim, True
|
|
)
|
|
if config["splits"] == 1:
|
|
continue
|
|
scratch = (
|
|
batch
|
|
* segmented_query_capacity(query_len)
|
|
* heads
|
|
* config["splits"]
|
|
* (dim + 1)
|
|
* 4
|
|
)
|
|
assert scratch <= MAX_LONG_EXTEND_WORKSPACE_BYTES * dim // 128
|
|
|
|
|
|
@torch.inference_mode()
|
|
def test_segmented_bf16_d256_long_extend_matches_dense_reference():
|
|
from vllm.platforms.rocm import on_gfx1x
|
|
from vllm.v1.attention.ops.segmented_attention import run_segmented_attention
|
|
|
|
if not current_platform.is_rocm() or not on_gfx1x():
|
|
pytest.skip("gfx1x segmented prefill")
|
|
|
|
case = _make_paged_attention_case(
|
|
[1024],
|
|
[7168],
|
|
num_heads=6,
|
|
num_kv_heads=1,
|
|
head_size=256,
|
|
block_size=1568,
|
|
fp8=False,
|
|
)
|
|
output = torch.empty_like(case["query"])
|
|
run_segmented_attention(
|
|
case["query"],
|
|
output,
|
|
case["key_cache"],
|
|
case["value_cache"],
|
|
case["block_table"],
|
|
case["starts"],
|
|
case["seq_lens"],
|
|
1024,
|
|
case["max_seq_len"],
|
|
case["k_scale"],
|
|
case["v_scale"],
|
|
256**-0.5,
|
|
)
|
|
relative = (output.float() - case["reference"]).norm(dim=-1) / case[
|
|
"reference"
|
|
].norm(dim=-1).clamp_min(1e-6)
|
|
assert torch.isfinite(output).all() and relative.max().item() < 0.01
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"query_len,heads,qk_amplitude,dim",
|
|
[
|
|
(256, 1, 1, 256),
|
|
(256, 6, 1, 256),
|
|
(512, 6, 1, 256),
|
|
(256, 6, 4, 256),
|
|
(256, 6, 16, 256),
|
|
(256, 6, 1, 128),
|
|
(512, 6, 4, 128),
|
|
(256, 6, 16, 128),
|
|
(8192, 1, 1, 128),
|
|
(8192, 1, 1, 256),
|
|
],
|
|
)
|
|
@torch.inference_mode()
|
|
def test_segmented_fp8_long_extend_matches_dense_reference(
|
|
query_len, heads, qk_amplitude, dim
|
|
):
|
|
from vllm.platforms.rocm import on_gfx12x
|
|
from vllm.v1.attention.ops.segmented_attention import run_segmented_attention
|
|
|
|
if not current_platform.is_rocm() or not on_gfx12x():
|
|
pytest.skip("FP8 KV requires gfx12")
|
|
case = _make_paged_attention_case(
|
|
[query_len],
|
|
[8192 - query_len],
|
|
num_heads=heads,
|
|
num_kv_heads=1,
|
|
head_size=dim,
|
|
block_size=1568,
|
|
fp8=True,
|
|
)
|
|
if qk_amplitude != 1:
|
|
# Check peaked logits too: one-term FP8 Q quantization passed only the
|
|
# nearly uniform fixture and deviated by 4-70% at these amplitudes.
|
|
case["query"].mul_(qk_amplitude)
|
|
case["k_scale"].mul_(qk_amplitude)
|
|
full_key = (
|
|
case["key_cache"][case["block_table"][0].long()]
|
|
.flatten(0, 1)[:8192]
|
|
.float()
|
|
* case["k_scale"]
|
|
).repeat_interleave(heads, dim=1)
|
|
full_value = (
|
|
case["value_cache"][case["block_table"][0].long()]
|
|
.flatten(0, 1)[:8192]
|
|
.float()
|
|
* case["v_scale"]
|
|
).repeat_interleave(heads, dim=1)
|
|
scores = torch.einsum(
|
|
"qhd,khd->hqk", case["query"].float(), full_key
|
|
) / math.sqrt(dim)
|
|
future = torch.arange(8192, device=case["query"].device)[None, :] > (
|
|
8192
|
|
- query_len
|
|
+ torch.arange(query_len, device=case["query"].device)[:, None]
|
|
)
|
|
scores.masked_fill_(future[None], -float("inf"))
|
|
case["reference"] = torch.einsum("hqk,khd->qhd", scores.softmax(-1), full_value)
|
|
output = torch.empty_like(case["query"])
|
|
run_segmented_attention(
|
|
case["query"],
|
|
output,
|
|
case["key_cache"],
|
|
case["value_cache"],
|
|
case["block_table"],
|
|
case["starts"],
|
|
case["seq_lens"],
|
|
query_len,
|
|
case["max_seq_len"],
|
|
case["k_scale"],
|
|
case["v_scale"],
|
|
dim**-0.5,
|
|
)
|
|
relative = (output.float() - case["reference"]).norm(dim=-1) / case[
|
|
"reference"
|
|
].norm(dim=-1).clamp_min(1e-6)
|
|
assert torch.isfinite(output).all() and relative.max().item() < 0.01
|
|
|
|
|
|
def test_segmented_tuning_protects_static_incumbent():
|
|
"""Noise-sized gains must not replace the static configuration."""
|
|
from vllm.model_executor.warmup import (
|
|
rocm_segmented_attn_autotune_warmup as tuning,
|
|
)
|
|
|
|
incumbent = {"bm": 16}
|
|
challenger = {"bm": 32}
|
|
samples = {
|
|
tuning._config_key(incumbent): [100.0, 101.0, 99.0, 100.0, 100.0],
|
|
tuning._config_key(challenger): [99.0, 100.0, 98.0, 99.0, 99.0],
|
|
}
|
|
winner, comparisons = tuning._select_tuned_winner(
|
|
incumbent, [incumbent, challenger], samples
|
|
)
|
|
assert winner["config"] == incumbent
|
|
assert comparisons[1]["paired_speedup_vs_default"] < 1.02
|
|
|
|
|
|
def test_segmented_tuning_promotes_verified_challenger():
|
|
"""A finalist with a stable material gain should replace the incumbent."""
|
|
from vllm.model_executor.warmup import (
|
|
rocm_segmented_attn_autotune_warmup as tuning,
|
|
)
|
|
|
|
incumbent = {"bm": 16}
|
|
challenger = {"bm": 32}
|
|
samples = {
|
|
tuning._config_key(incumbent): [100.0, 102.0, 98.0, 101.0, 99.0],
|
|
tuning._config_key(challenger): [94.0, 96.0, 92.0, 95.0, 93.0],
|
|
}
|
|
winner, _ = tuning._select_tuned_winner(incumbent, [incumbent, challenger], samples)
|
|
assert winner["config"] == challenger
|
|
assert winner["paired_speedup_vs_default"] > 1.02
|
|
|
|
|
|
def test_segmented_tuning_balances_tp_workloads_without_overlap():
|
|
"""Every missing bucket belongs to exactly one reasonably balanced rank."""
|
|
from vllm.model_executor.warmup import (
|
|
rocm_segmented_attn_autotune_warmup as tuning,
|
|
)
|
|
|
|
workloads = list(tuning._workloads(8192, 262144, 32))
|
|
shards = tuning._shard_workloads(
|
|
workloads,
|
|
4,
|
|
8192,
|
|
12,
|
|
2,
|
|
256,
|
|
True,
|
|
)
|
|
flattened = [workload for shard in shards for workload in shard]
|
|
assert sorted(flattened) == sorted(workloads)
|
|
assert len(flattened) == len(set(flattened))
|
|
owners = {
|
|
workload[:2]: rank for rank, shard in enumerate(shards) for workload in shard
|
|
}
|
|
assert all(
|
|
owners[workload[:2]] == rank
|
|
for rank, shard in enumerate(shards)
|
|
for workload in shard
|
|
)
|
|
|
|
weights = [
|
|
sum(
|
|
tuning._workload_weight(workload, 8192, 12, 2, 256, True)
|
|
for workload in shard
|
|
)
|
|
for shard in shards
|
|
]
|
|
largest_group = max(
|
|
sum(
|
|
tuning._workload_weight(workload, 8192, 12, 2, 256, True)
|
|
for workload in workloads
|
|
if workload[:2] == group
|
|
)
|
|
for group in {workload[:2] for workload in workloads}
|
|
)
|
|
assert max(weights) - min(weights) <= largest_group
|
|
|
|
|
|
def test_segmented_tuning_prunes_scheduler_and_kv_limits():
|
|
"""Generated buckets must be reachable under scheduler and cache limits."""
|
|
from vllm.model_executor.warmup import (
|
|
rocm_segmented_attn_autotune_warmup as tuning,
|
|
)
|
|
|
|
limits = (8192, 262144, 32)
|
|
raw = set(
|
|
tuning._workloads(
|
|
*limits,
|
|
dtype=torch.bfloat16,
|
|
kv_dtype=torch.float8_e4m3fn,
|
|
heads=16,
|
|
kv_heads=1,
|
|
dim=128,
|
|
page=16,
|
|
)
|
|
)
|
|
assert {batch for batch, _, _ in raw} == {1, 2, 4, 8, 16, 32}
|
|
assert {query for _, query, _ in raw} == set(tuning._QUERY_BUCKETS)
|
|
assert (32, 1, 262144) in raw
|
|
assert (1, 8192, 262144) in raw
|
|
assert (1, 8192, 131072) in raw
|
|
assert (1, 8, 131072) in raw
|
|
assert all(
|
|
sum(tuning._query_lengths(batch, query, limits[0])) <= limits[0]
|
|
and batch <= limits[2]
|
|
and query <= min(limits[0], tuning.MAX_QUERY_LEN)
|
|
and query <= seq_len <= limits[1]
|
|
for batch, query, seq_len in raw
|
|
)
|
|
|
|
layouts = ((16, 16 * 128 * 2),)
|
|
bounded = set(
|
|
tuning._workloads(
|
|
*limits,
|
|
memory_budget_bytes=2**50,
|
|
dtype=torch.bfloat16,
|
|
kv_dtype=torch.float8_e4m3fn,
|
|
heads=16,
|
|
kv_heads=1,
|
|
dim=128,
|
|
page=16,
|
|
cache_layouts=layouts,
|
|
cache_budget_bytes=2**20,
|
|
)
|
|
)
|
|
assert bounded < raw
|
|
assert all(
|
|
batch * sum(((seq_len + block - 1) // block) * size for block, size in layouts)
|
|
<= 2**20
|
|
for batch, _, seq_len in bounded
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def segmented_tuner(tmp_path, monkeypatch):
|
|
"""Exercise real cache files and lookup while replacing GPU timing only."""
|
|
from types import SimpleNamespace
|
|
|
|
from vllm.model_executor.warmup import (
|
|
rocm_segmented_attn_autotune_warmup as tuning,
|
|
)
|
|
from vllm.v1.attention.ops import segmented_attention as segmented
|
|
|
|
monkeypatch.setenv("VLLM_CACHE_ROOT", str(tmp_path))
|
|
monkeypatch.setattr(
|
|
torch.cuda,
|
|
"get_device_properties",
|
|
lambda _: SimpleNamespace(
|
|
name="test", gcnArchName="gfx1201", multi_processor_count=32
|
|
),
|
|
)
|
|
monkeypatch.setattr(tuning, "_TABLES", {})
|
|
calls = []
|
|
|
|
def tune(*args, **kwargs):
|
|
heads, kv_heads, dim = args[3:6]
|
|
batch, query_len, seq_len = args[-1]
|
|
default = segmented.select_segmented_config(
|
|
batch, query_len, seq_len, heads, kv_heads, dim, False
|
|
)
|
|
best = tuning._candidate_configs(default, batch, query_len, heads, dim)[0]
|
|
calls.append(args[-1])
|
|
return {
|
|
"workload": list(args[-1]),
|
|
"query_lengths": tuning._query_lengths(batch, query_len, args[-2]),
|
|
"default": default,
|
|
"best": best,
|
|
"results": [],
|
|
}
|
|
|
|
monkeypatch.setattr(tuning, "_tune_workload", tune)
|
|
geometry = (torch.device("cuda:0"), torch.bfloat16, 4, 2, 128, 16, 128**-0.5)
|
|
return SimpleNamespace(
|
|
module=tuning,
|
|
warmup_args=(*geometry, 4, 8, 2),
|
|
lookup_args=(*geometry[:2], torch.bfloat16, *geometry[2:], 1, 1, 8),
|
|
calls=calls,
|
|
cache_dir=tmp_path,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("fail", [False, True])
|
|
@pytest.mark.parametrize("device", ["cuda", "cuda:0"])
|
|
def test_segmented_tuning_releases_only_new_kernel_modules(monkeypatch, fail, device):
|
|
"""Tuning releases candidate modules even on failure without invalidating graphs."""
|
|
import weakref
|
|
from types import SimpleNamespace
|
|
|
|
from vllm.model_executor.warmup import (
|
|
rocm_segmented_attn_autotune_warmup as tuning,
|
|
)
|
|
|
|
class Module:
|
|
pass
|
|
|
|
existing = Module()
|
|
other_device = Module()
|
|
caches = [{"existing": existing} for _ in range(2)]
|
|
for name, cache in zip(
|
|
("_segmented_attention_stage", "_segmented_attention_reduce"), caches
|
|
):
|
|
monkeypatch.setattr(
|
|
tuning,
|
|
name,
|
|
SimpleNamespace(device_caches={0: (cache,), 1: ({"other": other_device},)}),
|
|
)
|
|
monkeypatch.setattr(torch.accelerator, "synchronize", lambda _: None)
|
|
monkeypatch.setattr(torch.accelerator, "empty_cache", lambda: None)
|
|
monkeypatch.setattr(torch.accelerator, "current_device_index", lambda: 0)
|
|
references = []
|
|
try:
|
|
with tuning._temporary_tuning_kernels(torch.device(device)):
|
|
for cache in caches:
|
|
cache["candidate"] = Module()
|
|
references.append(weakref.ref(cache["candidate"]))
|
|
if fail:
|
|
raise RuntimeError("tuning failed")
|
|
except RuntimeError:
|
|
assert fail
|
|
assert all(reference() is None for reference in references)
|
|
assert all(cache == {"existing": existing} for cache in caches)
|
|
for kernel in (
|
|
tuning._segmented_attention_stage,
|
|
tuning._segmented_attention_reduce,
|
|
):
|
|
assert kernel.device_caches[1][0] == {"other": other_device}
|
|
|
|
|
|
def test_segmented_tuning_cleanup_preserves_graphs_and_reloads_kernels():
|
|
"""Existing graphs survive cleanup, and released variants still run correctly."""
|
|
import weakref
|
|
|
|
from vllm.model_executor.warmup import (
|
|
rocm_segmented_attn_autotune_warmup as tuning,
|
|
)
|
|
|
|
case = _make_paged_attention_case(
|
|
[3],
|
|
[11],
|
|
num_heads=10,
|
|
num_kv_heads=2,
|
|
head_size=128,
|
|
fp8=False,
|
|
block_size=16,
|
|
)
|
|
output = torch.empty_like(case["query"])
|
|
_run_segmented_case(case, output, 3, force_splits=1)
|
|
graph = torch.cuda.CUDAGraph()
|
|
with torch.cuda.graph(graph):
|
|
_run_segmented_case(case, output, 3, force_splits=1)
|
|
caches = [
|
|
kernel.device_caches[0][0]
|
|
for kernel in (
|
|
tuning._segmented_attention_stage,
|
|
tuning._segmented_attention_reduce,
|
|
)
|
|
]
|
|
keys = [set(cache) for cache in caches]
|
|
with tuning._temporary_tuning_kernels(torch.device("cuda:0")):
|
|
_run_segmented_case(case, output, 3, force_splits=2)
|
|
references = [
|
|
weakref.ref(cache[key])
|
|
for cache, existing in zip(caches, keys)
|
|
for key in cache.keys() - existing
|
|
]
|
|
assert references
|
|
assert all(reference() is None for reference in references)
|
|
output.fill_(float("nan"))
|
|
graph.replay()
|
|
torch.testing.assert_close(output.float(), case["reference"], atol=0.01, rtol=0.01)
|
|
output.fill_(float("nan"))
|
|
_run_segmented_case(case, output, 3, force_splits=2)
|
|
torch.testing.assert_close(output.float(), case["reference"], atol=0.01, rtol=0.01)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mode",
|
|
[
|
|
pytest.param({}, id="causal"),
|
|
pytest.param({"causal": False}, id="noncausal"),
|
|
pytest.param({"sliding_window": 7}, id="sliding-window"),
|
|
pytest.param({"sliding_window": 7, "causal": False}, id="draft-window"),
|
|
pytest.param({"has_sinks": True}, id="sinks"),
|
|
],
|
|
)
|
|
def test_segmented_tuning_persists_without_retuning(segmented_tuner, monkeypatch, mode):
|
|
"""A fresh in-memory table reloads the same winner without timing or writing."""
|
|
state = segmented_tuner
|
|
tuning = state.module
|
|
tuning.warmup_segmented_attention(
|
|
*state.warmup_args, memory_budget_bytes=2**50, **mode
|
|
)
|
|
assert state.calls
|
|
expected = tuning.get_segmented_config(*state.lookup_args, **mode)
|
|
assert expected is not None
|
|
(cache,) = state.cache_dir.rglob("*.json")
|
|
saved = cache.read_bytes(), cache.stat().st_mtime_ns
|
|
tuning._TABLES.clear()
|
|
|
|
def forbidden(*args, **kwargs):
|
|
pytest.fail("Persistent segmented startup cache attempted to retune")
|
|
|
|
monkeypatch.setattr(tuning, "_tune_workload", forbidden)
|
|
tuning.warmup_segmented_attention(
|
|
*state.warmup_args, memory_budget_bytes=2**50, **mode
|
|
)
|
|
assert tuning.get_segmented_config(*state.lookup_args, **mode) == expected
|
|
assert (cache.read_bytes(), cache.stat().st_mtime_ns) == saved
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"max_tokens,mode",
|
|
[
|
|
pytest.param(3, {}, id="scheduler-token-budget"),
|
|
pytest.param(4, {"causal": False}, id="noncausal"),
|
|
pytest.param(4, {"sliding_window": 7}, id="sliding-window"),
|
|
pytest.param(4, {"sliding_window": 7, "causal": False}, id="draft-window"),
|
|
pytest.param(4, {"has_sinks": True}, id="sinks"),
|
|
],
|
|
)
|
|
def test_segmented_tuning_does_not_reuse_incompatible_winners(
|
|
segmented_tuner, max_tokens, mode
|
|
):
|
|
"""Changed attention semantics or query mix require their own measurements."""
|
|
state = segmented_tuner
|
|
tuning = state.module
|
|
tuning.warmup_segmented_attention(*state.warmup_args, memory_budget_bytes=2**50)
|
|
previous_calls = len(state.calls)
|
|
if mode:
|
|
assert tuning.get_segmented_config(*state.lookup_args, **mode) is None
|
|
args = (*state.warmup_args[:7], max_tokens, *state.warmup_args[8:])
|
|
tuning.warmup_segmented_attention(*args, memory_budget_bytes=2**50, **mode)
|
|
assert len(state.calls) > previous_calls
|
|
assert len(list(state.cache_dir.rglob("*.json"))) == 2
|
|
assert tuning.get_segmented_config(*state.lookup_args, **mode) is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"dim,page,hq,hk,dtype,kv_dtype,byte_cache,force_splits",
|
|
[
|
|
(128, 16, 4, 4, torch.bfloat16, torch.bfloat16, False, None),
|
|
(128, 32, 8, 2, torch.float16, torch.float16, False, None),
|
|
(256, 784, 12, 2, torch.bfloat16, torch.bfloat16, False, None),
|
|
(256, 1568, 12, 2, torch.bfloat16, torch.float8_e4m3fn, False, None),
|
|
(128, 32, 16, 1, torch.bfloat16, torch.float8_e4m3fn, True, None),
|
|
(256, 32, 4, 2, torch.float16, torch.float8_e4m3fn, False, None),
|
|
(128, 32, 8, 2, torch.bfloat16, torch.bfloat16, False, 32),
|
|
],
|
|
)
|
|
@torch.inference_mode()
|
|
def test_segmented_attention_ragged_unified_graph_replay(
|
|
monkeypatch, dim, page, hq, hk, dtype, kv_dtype, byte_cache, force_splits
|
|
):
|
|
"""Unified-cache segmented prefill follows metadata on graph replay."""
|
|
from vllm.platforms.rocm import on_gfx1x, on_gfx12x
|
|
from vllm.v1.attention.ops import segmented_attention as dispatcher
|
|
|
|
if not current_platform.is_rocm() or not (
|
|
on_gfx12x() if kv_dtype.itemsize == 1 else on_gfx1x()
|
|
):
|
|
pytest.skip("gfx1x segmented prefill (FP8 requires gfx12)")
|
|
if force_splits is not None:
|
|
original = dispatcher.select_segmented_config
|
|
monkeypatch.setattr(
|
|
dispatcher,
|
|
"select_segmented_config",
|
|
lambda *args: dict(original(*args), splits=force_splits),
|
|
)
|
|
query_lengths = [0, 1, 2, 7, 33, 129]
|
|
contexts = [0, 67, 0, page - 1, page + 1, 33]
|
|
device = torch.device("cuda:0")
|
|
total = sum(query_lengths)
|
|
q = torch.randn(total, hq + 1, dim, device=device, dtype=dtype)[:, :hq]
|
|
k = torch.randn(total, hk + 1, dim, device=device, dtype=dtype)[:, :hk]
|
|
v = torch.randn_like(k)
|
|
blocks_per_seq = triton.cdiv(max(map(sum, zip(query_lengths, contexts))), page)
|
|
num_blocks = len(contexts) * blocks_per_seq
|
|
page_elements = page * hk * dim
|
|
backing = torch.empty(num_blocks * 2 * page_elements, device=device, dtype=kv_dtype)
|
|
cache_shape = (num_blocks, page, hk, dim)
|
|
strides = (2 * page_elements, hk * dim, dim, 1)
|
|
kc = torch.as_strided(backing, cache_shape, strides)
|
|
vc = torch.as_strided(backing, cache_shape, strides, page_elements)
|
|
ks = torch.tensor(0.13 if kv_dtype.itemsize == 1 else 1.0, device=device)
|
|
vs = torch.tensor(0.27 if kv_dtype.itemsize == 1 else 1.0, device=device)
|
|
kc.copy_((torch.randn(cache_shape, device=device).float() * 0.25 / ks).to(kv_dtype))
|
|
vc.copy_((torch.randn(cache_shape, device=device).float() * 0.25 / vs).to(kv_dtype))
|
|
table = torch.randperm(num_blocks, device=device, dtype=torch.int32).view(
|
|
len(contexts), blocks_per_seq
|
|
)
|
|
host_table = table.cpu().tolist()
|
|
starts = torch.empty(len(contexts) + 1, device=device, dtype=torch.int32)
|
|
lengths = torch.empty(len(contexts), device=device, dtype=torch.int32)
|
|
output = torch.full((total, hq + 1, dim), 17.0, device=device, dtype=dtype)[:, :hq]
|
|
|
|
def metadata(qlens):
|
|
starts.copy_(
|
|
torch.tensor(
|
|
[0, *torch.tensor(qlens).cumsum(0).tolist()],
|
|
device=device,
|
|
dtype=torch.int32,
|
|
)
|
|
)
|
|
lengths.copy_(
|
|
torch.tensor(
|
|
[c + n for c, n in zip(contexts, qlens)],
|
|
device=device,
|
|
dtype=torch.int32,
|
|
)
|
|
)
|
|
first = 0
|
|
for seq, (nq, context) in enumerate(zip(qlens, contexts)):
|
|
for local in range(nq):
|
|
position = context + local
|
|
physical = host_table[seq][position // page]
|
|
offset = position % page
|
|
kc[physical, offset].copy_((k[first + local].float() / ks).to(kv_dtype))
|
|
vc[physical, offset].copy_((v[first + local].float() / vs).to(kv_dtype))
|
|
first += nq
|
|
|
|
def run():
|
|
dispatcher.segmented_attention(
|
|
query=q,
|
|
key=k,
|
|
value=v,
|
|
output=output,
|
|
kv_cache_dtype="fp8" if kv_dtype.itemsize == 1 else "auto",
|
|
key_cache=kc.view(torch.uint8) if byte_cache else kc,
|
|
value_cache=vc.view(torch.uint8) if byte_cache else vc,
|
|
block_table=table,
|
|
query_start_loc=starts,
|
|
seq_lens=lengths,
|
|
max_seq_len=max(contexts) + 129,
|
|
max_query_len=129,
|
|
k_scale=ks,
|
|
v_scale=vs,
|
|
sm_scale=dim**-0.5,
|
|
)
|
|
|
|
def check(qlens):
|
|
first = 0
|
|
for seq, (nq, context) in enumerate(zip(qlens, contexts)):
|
|
if nq == 0:
|
|
first += nq
|
|
continue
|
|
physical = table[seq].long()
|
|
full_k = (
|
|
kc[physical].reshape(-1, hk, dim)[: context + nq].float() * ks
|
|
).repeat_interleave(hq // hk, 1)
|
|
full_v = (
|
|
vc[physical].reshape(-1, hk, dim)[: context + nq].float() * vs
|
|
).repeat_interleave(hq // hk, 1)
|
|
logits = torch.einsum(
|
|
"qhd,khd->hqk", q[first : first + nq].float(), full_k
|
|
) / math.sqrt(dim)
|
|
mask = (
|
|
torch.arange(context + nq, device=device)[None, :]
|
|
> context + torch.arange(nq, device=device)[:, None]
|
|
)
|
|
logits.masked_fill_(mask[None], -float("inf"))
|
|
ref = torch.einsum("hqk,khd->qhd", logits.softmax(-1), full_v)
|
|
actual = output[first : first + nq].float()
|
|
assert torch.isfinite(actual).all()
|
|
relative = (actual - ref).norm(dim=-1) / ref.norm(dim=-1).clamp_min(1e-6)
|
|
assert relative.max().item() < 0.01
|
|
first += nq
|
|
|
|
metadata(query_lengths)
|
|
run()
|
|
torch.accelerator.synchronize()
|
|
check(query_lengths)
|
|
graph = torch.cuda.CUDAGraph()
|
|
with torch.cuda.graph(graph):
|
|
run()
|
|
replay_lengths = [1, 0, 7, 2, 34, 128]
|
|
q.mul_(0.75)
|
|
k.add_(0.25)
|
|
v.add_(0.5)
|
|
metadata(replay_lengths)
|
|
output.fill_(17.0)
|
|
graph.replay()
|
|
torch.accelerator.synchronize()
|
|
check(replay_lengths)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fp8,force_splits",
|
|
[(False, None), (True, None), (False, 4), (True, 4)],
|
|
)
|
|
@torch.inference_mode()
|
|
def test_segmented_ragged_prefill_matches_dense_reference(fp8, force_splits):
|
|
from vllm.platforms.rocm import on_gfx1x, on_gfx12x
|
|
|
|
if not current_platform.is_rocm() or not (on_gfx12x() if fp8 else on_gfx1x()):
|
|
pytest.skip("gfx1x segmented prefill (FP8 requires gfx12)")
|
|
case = _make_paged_attention_case(
|
|
[1, 2, 7, 129],
|
|
[31, 64, 97, 128],
|
|
num_heads=12,
|
|
num_kv_heads=2,
|
|
head_size=256,
|
|
block_size=32,
|
|
fp8=fp8,
|
|
)
|
|
output = torch.empty_like(case["query"])
|
|
_run_segmented_case(case, output, max_query_len=129, force_splits=force_splits)
|
|
|
|
relative = (output.float() - case["reference"]).norm(dim=-1) / case[
|
|
"reference"
|
|
].norm(dim=-1).clamp_min(1e-6)
|
|
assert torch.isfinite(output).all() and relative.max().item() < 0.01
|
|
|
|
|
|
@pytest.mark.parametrize("fp8", [False, True])
|
|
@torch.inference_mode()
|
|
def test_rocm_segmented_attn_layout_cache_update(fp8):
|
|
from types import SimpleNamespace
|
|
|
|
from vllm.platforms.rocm import on_gfx1x, on_gfx12x
|
|
from vllm.v1.attention.backend import AttentionType
|
|
from vllm.v1.attention.backends.rocm_segmented_attn import (
|
|
RocmSegmentedAttentionImpl,
|
|
)
|
|
|
|
if not current_platform.is_rocm() or not (on_gfx12x() if fp8 else on_gfx1x()):
|
|
pytest.skip("gfx1x segmented prefill (FP8 requires gfx12)")
|
|
cache_dtype = torch.uint8 if fp8 else torch.bfloat16
|
|
kv_cache_dtype = "fp8" if fp8 else "auto"
|
|
impl = RocmSegmentedAttentionImpl(
|
|
12,
|
|
256,
|
|
256**-0.5,
|
|
2,
|
|
None,
|
|
None,
|
|
kv_cache_dtype,
|
|
attn_type=AttentionType.DECODER,
|
|
)
|
|
cache = torch.zeros(3, 2, 32, 512, device="cuda", dtype=cache_dtype)
|
|
key = torch.randn(5, 2, 256, device="cuda", dtype=torch.bfloat16)
|
|
value = torch.randn_like(key)
|
|
slots = torch.tensor([0, 7, 32, 65, 95], device="cuda", dtype=torch.int64)
|
|
k_scale = torch.tensor(0.125 if fp8 else 1.0, device="cuda")
|
|
v_scale = torch.tensor(0.25 if fp8 else 1.0, device="cuda")
|
|
layer = SimpleNamespace(_k_scale=k_scale, _v_scale=v_scale)
|
|
|
|
impl.do_kv_cache_update(layer, key, value, cache, slots)
|
|
key_cache, value_cache = impl._split_kv_cache(cache)
|
|
if fp8:
|
|
key_cache = key_cache.view(impl.fp8_dtype)
|
|
value_cache = value_cache.view(impl.fp8_dtype)
|
|
cached_key = torch.stack(
|
|
[key_cache[int(slot) // 32, int(slot) % 32] for slot in slots]
|
|
)
|
|
cached_value = torch.stack(
|
|
[value_cache[int(slot) // 32, int(slot) % 32] for slot in slots]
|
|
)
|
|
expected_key = (key.float() / k_scale).to(key_cache.dtype)
|
|
expected_value = (value.float() / v_scale).to(value_cache.dtype)
|
|
torch.testing.assert_close(cached_key, expected_key, rtol=0, atol=0)
|
|
torch.testing.assert_close(cached_value, expected_value, rtol=0, atol=0)
|
|
|
|
|
|
@torch.inference_mode()
|
|
def test_segmented_mixed_causality_matches_dense_reference():
|
|
from vllm.platforms.rocm import on_gfx1x
|
|
from vllm.v1.attention.ops import segmented_attention as dispatcher
|
|
|
|
if not current_platform.is_rocm() or not on_gfx1x():
|
|
pytest.skip("gfx1x unified attention per-request causal fallback")
|
|
|
|
query_lens = [2, 3]
|
|
context_lens = [5, 6]
|
|
args = dict(num_heads=8, num_kv_heads=2, head_size=128, block_size=32)
|
|
case = _make_paged_attention_case(
|
|
query_lens, context_lens, fp8=False, causal=True, **args
|
|
)
|
|
noncausal_case = _make_paged_attention_case(
|
|
query_lens, context_lens, fp8=False, causal=False, **args
|
|
)
|
|
reference = torch.cat(
|
|
(
|
|
case["reference"][: query_lens[0]],
|
|
noncausal_case["reference"][query_lens[0] :],
|
|
)
|
|
)
|
|
causal = torch.tensor([True, False], device=case["query"].device)
|
|
output = torch.empty_like(case["query"])
|
|
dispatcher.segmented_attention(
|
|
query=case["query"],
|
|
key=case["key"],
|
|
value=case["value"],
|
|
output=output,
|
|
kv_cache_dtype="auto",
|
|
key_cache=case["key_cache"],
|
|
value_cache=case["value_cache"],
|
|
block_table=case["block_table"],
|
|
query_start_loc=case["starts"],
|
|
seq_lens=case["seq_lens"],
|
|
max_seq_len=case["max_seq_len"],
|
|
max_query_len=max(query_lens),
|
|
k_scale=case["k_scale"],
|
|
v_scale=case["v_scale"],
|
|
sm_scale=128**-0.5,
|
|
causal=causal,
|
|
)
|
|
|
|
relative = (output.float() - reference).norm(dim=-1) / reference.norm(
|
|
dim=-1
|
|
).clamp_min(1e-6)
|
|
assert torch.isfinite(output).all() and relative.max().item() < 0.01
|
|
|
|
|
|
@pytest.mark.parametrize("fp8", [False, True])
|
|
@pytest.mark.parametrize("causal", [False, True])
|
|
@pytest.mark.parametrize("window_size", [1, 2048])
|
|
@torch.inference_mode()
|
|
def test_segmented_sliding_window_matches_dense_reference(fp8, causal, window_size):
|
|
from vllm.platforms.rocm import on_gfx1x, on_gfx12x
|
|
|
|
if not current_platform.is_rocm() or not (on_gfx12x() if fp8 else on_gfx1x()):
|
|
pytest.skip("gfx1x segmented prefill (FP8 requires gfx12)")
|
|
case = _make_paged_attention_case(
|
|
[8],
|
|
[4096],
|
|
num_heads=32,
|
|
num_kv_heads=8,
|
|
head_size=128,
|
|
block_size=32,
|
|
fp8=fp8,
|
|
causal=causal,
|
|
sliding_window=window_size,
|
|
)
|
|
output = torch.empty_like(case["query"])
|
|
_run_segmented_case(
|
|
case, output, max_query_len=8, sliding_window=window_size - 1, causal=causal
|
|
)
|
|
|
|
relative = (output.float() - case["reference"]).norm(dim=-1) / case[
|
|
"reference"
|
|
].norm(dim=-1).clamp_min(1e-6)
|
|
assert torch.isfinite(output).all() and relative.max().item() < 0.01
|
|
|
|
|
|
@pytest.mark.parametrize("fp8", [False, True])
|
|
@pytest.mark.parametrize("window_size", [0, 128])
|
|
@pytest.mark.parametrize("head_size", [64, 128])
|
|
@torch.inference_mode()
|
|
def test_segmented_sinks_match_dense_reference(fp8, window_size, head_size):
|
|
from vllm.platforms.rocm import on_gfx1x, on_gfx12x
|
|
|
|
if not current_platform.is_rocm() or not (on_gfx12x() if fp8 else on_gfx1x()):
|
|
pytest.skip("gfx1x segmented prefill (FP8 requires gfx12)")
|
|
sinks = torch.linspace(
|
|
-1.0,
|
|
8.0,
|
|
16,
|
|
device="cuda:0",
|
|
dtype=torch.bfloat16 if head_size == 64 else torch.float32,
|
|
)
|
|
case = _make_paged_attention_case(
|
|
[1, 7],
|
|
[127, 130],
|
|
num_heads=16,
|
|
num_kv_heads=2,
|
|
head_size=head_size,
|
|
block_size=32,
|
|
fp8=fp8,
|
|
sliding_window=window_size,
|
|
sinks=sinks,
|
|
)
|
|
output = torch.empty_like(case["query"])
|
|
_run_segmented_case(
|
|
case,
|
|
output,
|
|
max_query_len=7,
|
|
sliding_window=window_size - 1 if window_size else -1,
|
|
sinks=sinks,
|
|
)
|
|
relative = (output.float() - case["reference"]).norm(dim=-1) / case[
|
|
"reference"
|
|
].norm(dim=-1).clamp_min(1e-6)
|
|
assert torch.isfinite(output).all() and relative.max().item() < 0.01
|
|
|
|
|
|
@pytest.mark.parametrize("fp8", [False, True])
|
|
@pytest.mark.parametrize("window_size", [0, 128])
|
|
@pytest.mark.parametrize("query_len", [1, 8])
|
|
@torch.inference_mode()
|
|
def test_segmented_sink_split_reduce_matches_reference(fp8, window_size, query_len):
|
|
from vllm.platforms.rocm import on_gfx1x, on_gfx12x
|
|
from vllm.v1.attention.ops.segmented_attention import (
|
|
run_segmented_attention,
|
|
select_segmented_config,
|
|
)
|
|
|
|
if not current_platform.is_rocm() or not (on_gfx12x() if fp8 else on_gfx1x()):
|
|
pytest.skip("gfx1x segmented prefill (FP8 requires gfx12)")
|
|
sinks = torch.linspace(-1.0, 8.0, 16, device="cuda:0")
|
|
case = _make_paged_attention_case(
|
|
[query_len],
|
|
[8192 - query_len],
|
|
num_heads=16,
|
|
num_kv_heads=2,
|
|
head_size=64,
|
|
block_size=64,
|
|
fp8=fp8,
|
|
sliding_window=window_size,
|
|
sinks=sinks,
|
|
)
|
|
attention_span = min(8192, window_size + query_len) if window_size else 8192
|
|
config = dict(
|
|
select_segmented_config(1, query_len, attention_span, 16, 2, 64, fp8),
|
|
splits=4,
|
|
)
|
|
output = torch.empty_like(case["query"])
|
|
run_segmented_attention(
|
|
case["query"],
|
|
output,
|
|
case["key_cache"],
|
|
case["value_cache"],
|
|
case["block_table"],
|
|
case["starts"],
|
|
case["seq_lens"],
|
|
query_len,
|
|
8192,
|
|
case["k_scale"],
|
|
case["v_scale"],
|
|
64**-0.5,
|
|
sliding_window=window_size - 1 if window_size else -1,
|
|
sinks=sinks,
|
|
config=config,
|
|
)
|
|
relative = (output.float() - case["reference"]).norm(dim=-1) / case[
|
|
"reference"
|
|
].norm(dim=-1).clamp_min(1e-6)
|
|
assert torch.isfinite(output).all() and relative.max().item() < 0.01
|
|
|
|
|
|
@pytest.mark.parametrize("query_len,context_len", [(1, 8191), (8, 32760), (1, 131071)])
|
|
@torch.inference_mode()
|
|
def test_segmented_long_context_fp8_sinks_match_dense_reference(query_len, context_len):
|
|
from vllm.platforms.rocm import on_gfx12x
|
|
|
|
if not current_platform.is_rocm() or not on_gfx12x():
|
|
pytest.skip("FP8 KV requires gfx12")
|
|
sinks = torch.linspace(-1.0, 1.0, 16, device="cuda:0", dtype=torch.bfloat16)
|
|
case = _make_paged_attention_case(
|
|
[query_len],
|
|
[context_len],
|
|
num_heads=16,
|
|
num_kv_heads=2,
|
|
head_size=64,
|
|
block_size=64,
|
|
fp8=True,
|
|
sinks=sinks,
|
|
)
|
|
output = torch.empty_like(case["query"])
|
|
_run_segmented_case(case, output, max_query_len=query_len, sinks=sinks)
|
|
relative = (output.float() - case["reference"]).norm(dim=-1) / case[
|
|
"reference"
|
|
].norm(dim=-1).clamp_min(1e-6)
|
|
assert torch.isfinite(output).all() and relative.max().item() < 0.01
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"feature",
|
|
[
|
|
"softcap",
|
|
"sliding_softcap",
|
|
"output_scale",
|
|
],
|
|
)
|
|
@torch.inference_mode()
|
|
def test_segmented_softcap_and_output_scaling_match_dense_reference(feature):
|
|
from vllm.platforms.rocm import on_gfx1x
|
|
from vllm.v1.attention.ops import segmented_attention as dispatcher
|
|
|
|
if not current_platform.is_rocm() or not on_gfx1x():
|
|
pytest.skip("gfx1x unified attention feature fallback")
|
|
|
|
num_heads = 8
|
|
configured_sliding_window = 16 if feature.startswith("sliding") else 0
|
|
sliding_window = configured_sliding_window - 1 if configured_sliding_window else -1
|
|
softcap = 5.0 if "softcap" in feature else 0.0
|
|
case = _make_paged_attention_case(
|
|
[7, 2],
|
|
[40, 35],
|
|
num_heads=num_heads,
|
|
num_kv_heads=2,
|
|
head_size=128,
|
|
block_size=32,
|
|
fp8=False,
|
|
sliding_window=configured_sliding_window,
|
|
softcap=softcap,
|
|
sinks=None,
|
|
)
|
|
output_scale = None
|
|
if feature == "output_scale":
|
|
output_scale = torch.tensor(0.5, device="cuda:0")
|
|
output = torch.empty_like(case["query"], dtype=current_platform.fp8_dtype())
|
|
else:
|
|
output = torch.empty_like(case["query"])
|
|
|
|
dispatcher.segmented_attention(
|
|
query=case["query"],
|
|
key=case["key"],
|
|
value=case["value"],
|
|
output=output,
|
|
kv_cache_dtype="auto",
|
|
key_cache=case["key_cache"],
|
|
value_cache=case["value_cache"],
|
|
block_table=case["block_table"],
|
|
query_start_loc=case["starts"],
|
|
seq_lens=case["seq_lens"],
|
|
max_seq_len=case["max_seq_len"],
|
|
max_query_len=7,
|
|
k_scale=case["k_scale"],
|
|
v_scale=case["v_scale"],
|
|
sm_scale=128**-0.5,
|
|
sliding_window=sliding_window,
|
|
softcap=softcap,
|
|
output_scale=output_scale,
|
|
sinks=None,
|
|
)
|
|
|
|
actual = output.float()
|
|
if output_scale is not None:
|
|
actual *= output_scale
|
|
torch.testing.assert_close(actual, case["reference"], atol=0.2, rtol=0.2)
|
|
else:
|
|
relative = (actual - case["reference"]).norm(dim=-1) / case["reference"].norm(
|
|
dim=-1
|
|
).clamp_min(1e-6)
|
|
assert torch.isfinite(output).all() and relative.max().item() < 0.01
|
|
|
|
|
|
@torch.inference_mode()
|
|
def test_segmented_attention_cache_offsets_cross_int32_boundary():
|
|
"""A small logical prefix can live beyond 2**31 elements in a strided cache."""
|
|
from vllm.platforms.rocm import on_gfx1x
|
|
from vllm.v1.attention.ops.segmented_attention import run_segmented_attention
|
|
|
|
if not current_platform.is_rocm() or not on_gfx1x():
|
|
pytest.skip("gfx1x segmented prefill")
|
|
torch.accelerator.empty_cache()
|
|
free, _ = torch.accelerator.get_memory_info()
|
|
if free < 10 * 2**30:
|
|
pytest.skip("The address-boundary fixture needs 10 GiB free VRAM")
|
|
device = torch.device("cuda:0")
|
|
stride = 2**30 + 4096
|
|
kc = torch.empty_strided(
|
|
(3, 32, 1, 128),
|
|
(stride, 128, 128, 1),
|
|
device=device,
|
|
dtype=torch.bfloat16,
|
|
)
|
|
vc = torch.empty_strided(
|
|
(3, 32, 1, 128),
|
|
(stride, 128, 128, 1),
|
|
device=device,
|
|
dtype=torch.bfloat16,
|
|
)
|
|
dense_k = torch.randn(2, 32, 1, 128, device=device, dtype=torch.bfloat16)
|
|
dense_v = torch.randn_like(dense_k)
|
|
for logical, physical in enumerate((2, 1)):
|
|
kc[physical].copy_(dense_k[logical])
|
|
vc[physical].copy_(dense_v[logical])
|
|
q = torch.randn(2, 4, 128, device=device, dtype=torch.bfloat16)
|
|
out = torch.empty_like(q)
|
|
table = torch.tensor([[2, 1]], device=device, dtype=torch.int32)
|
|
starts = torch.tensor([0, 2], device=device, dtype=torch.int32)
|
|
lengths = torch.tensor([35], device=device, dtype=torch.int32)
|
|
one = torch.ones((), device=device)
|
|
run_segmented_attention(
|
|
q, out, kc, vc, table, starts, lengths, 2, 35, one, one, 128**-0.5
|
|
)
|
|
full_k = dense_k.flatten(0, 1)[:35].float().repeat_interleave(4, 1)
|
|
full_v = dense_v.flatten(0, 1)[:35].float().repeat_interleave(4, 1)
|
|
scores = torch.einsum("qhd,khd->hqk", q.float(), full_k) / math.sqrt(128)
|
|
scores[:, 0, -1] = -float("inf")
|
|
expected = torch.einsum("hqk,khd->qhd", scores.softmax(-1), full_v)
|
|
error = ((out.float() - expected).norm(dim=-1) / expected.norm(dim=-1)).max()
|
|
assert torch.isfinite(out).all() and error < 0.01
|
|
|
|
|
|
@pytest.mark.parametrize("dim,hq,hk", [(128, 16, 1), (256, 12, 2)])
|
|
@pytest.mark.parametrize("fp8", [False, True])
|
|
def test_segmented_attention_reservation_covers_ragged_query_caps(
|
|
monkeypatch, dim, hq, hk, fp8
|
|
):
|
|
"""Every supported query bucket fits the startup buffer after locking."""
|
|
from vllm.v1.attention.ops import segmented_attention as segmented
|
|
|
|
sizes = segmented.segmented_attention_workspace_size(
|
|
32,
|
|
hq,
|
|
hk,
|
|
dim,
|
|
65536,
|
|
fp8=fp8,
|
|
)
|
|
workspace = tuple(torch.empty(size) for size in sizes)
|
|
query_lengths = {
|
|
query_len
|
|
for capacity in segmented._query_capacity_buckets()
|
|
for query_len in (max(1, capacity // 2 + 1), capacity)
|
|
}
|
|
for batch in range(1, 33):
|
|
for query_len in query_lengths:
|
|
qcap = segmented.segmented_query_capacity(query_len)
|
|
cfg = segmented.select_segmented_config(
|
|
batch, query_len, 65536, hq, hk, dim, fp8
|
|
)
|
|
shapes = segmented.segmented_workspace_shapes(
|
|
batch, qcap, hq, hk, dim, cfg["splits"]
|
|
)
|
|
if shapes is not None:
|
|
assert math.prod(shapes[0]) <= workspace[0].numel()
|
|
assert math.prod(shapes[1]) <= workspace[1].numel()
|
|
|
|
|
|
def test_segmented_attention_reservation_respects_scheduler_token_limit(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""Workspace planning excludes batch/query pairs the scheduler cannot form."""
|
|
from vllm.v1.attention.ops import segmented_attention as segmented
|
|
|
|
calls = []
|
|
|
|
def record_config(batch, query_len, *_args):
|
|
calls.append((batch, query_len))
|
|
return {"splits": 1}
|
|
|
|
monkeypatch.setattr(segmented, "select_segmented_config", record_config)
|
|
segmented.segmented_attention_workspace_size.cache_clear()
|
|
segmented.segmented_attention_workspace_size(
|
|
32,
|
|
16,
|
|
2,
|
|
128,
|
|
65536,
|
|
max_tokens=8,
|
|
)
|
|
|
|
assert calls
|
|
assert all(batch + query_len - 1 <= 8 for batch, query_len in calls)
|
|
assert (8, 1) in calls
|
|
assert (1, 8) in calls
|
|
|
|
|
|
def test_segmented_attention_query_capacity_buckets() -> None:
|
|
from vllm.v1.attention.ops.segmented_attention import (
|
|
MAX_QUERY_LEN,
|
|
segmented_query_capacity,
|
|
)
|
|
|
|
assert [
|
|
segmented_query_capacity(query_len)
|
|
for query_len in (1, 2, 3, 33, 129, 1025, 2049, MAX_QUERY_LEN)
|
|
] == [1, 2, 4, 64, 256, 2048, 4096, 8192]
|
|
with pytest.raises(ValueError, match="Query length"):
|
|
segmented_query_capacity(MAX_QUERY_LEN + 1)
|
|
|
|
|
|
def test_segmented_scratch_is_stable_and_isolated_across_runtime_lanes(monkeypatch):
|
|
"""Draft/DBO scratch never aliases target scratch or generic workspace."""
|
|
import vllm.v1.worker.workspace as workspace
|
|
from vllm.v1.attention.ops import segmented_attention as segmented
|
|
|
|
active_ubatch = [0]
|
|
monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: active_ubatch[0])
|
|
manager = workspace.WorkspaceManager(
|
|
torch.device("cpu"), num_ubatches=2, num_lanes=2
|
|
)
|
|
monkeypatch.setattr(segmented, "is_workspace_manager_initialized", lambda: True)
|
|
monkeypatch.setattr(segmented, "current_workspace_manager", lambda: manager)
|
|
buffers = {}
|
|
for ubatch in range(2):
|
|
active_ubatch[0] = ubatch
|
|
for lane in range(2):
|
|
with workspace.use_workspace_lane(lane):
|
|
buffers[ubatch, lane] = segmented.get_segmented_attention_workspace(
|
|
torch.device("cpu"), (1024, 16)
|
|
)
|
|
manager.get_simultaneous(((2048,), torch.float32))
|
|
manager.lock()
|
|
pointers: set[int] = set()
|
|
for (ubatch, lane), expected in buffers.items():
|
|
active_ubatch[0] = ubatch
|
|
with workspace.use_workspace_lane(lane):
|
|
actual = segmented.get_segmented_attention_workspace(
|
|
torch.device("cpu"), (1024, 16)
|
|
)
|
|
assert actual is expected
|
|
pointers.update(t.data_ptr() for t in actual)
|
|
assert (
|
|
manager.get_simultaneous(((1,), torch.float32))[0].data_ptr()
|
|
not in pointers
|
|
)
|
|
assert len(pointers) == 8
|