[Bugfix][Core] Preserve static KV staging inputs in AR-Diffusion (#7921)

Signed-off-by: MichaelCaoo <139663530+MichaelCao0@users.noreply.github.com>
This commit is contained in:
MichaelCaoo authored and GitHub committed 2026-09-23 23:17:45 +08:00
1 parent 81bc7116c1
commit 9a1a05facd
3 files changed
+196 -22

No files matched your search

@@ -0,0 +1,144 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CUDA regressions for contiguous AR-Diffusion history staging."""
import pytest
import torch
from tests.diffusion.ar_diffusion.test_paged_attention import (
BLOCK,
HEAD_DIM,
N_HEADS,
POS,
_commit_video_span,
_gpu_flash_attn_usable,
make_state,
)
from tests.helpers.mark import hardware_test
from vllm_omni.experimental.ar_diffusion.kv_cache import paged_write_attn
from vllm_omni.experimental.ar_diffusion.kv_cache.config import KV_GATHER_ENV
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
@pytest.mark.skipif(
torch.version.hip is not None or not _gpu_flash_attn_usable(), reason="usable CUDA FlashAttention is required"
)
@hardware_test(res={"cuda": ["L4", "H100"]}, num_cards=1)
@pytest.mark.parametrize("window_chunks", [2, 4])
@torch.inference_mode()
def test_staged_attention_updates_only_the_active_window_gpu(monkeypatch, window_chunks):
"""Full and reused gathers match fresh attention without touching spare capacity or stale history."""
monkeypatch.setenv(KV_GATHER_ENV, "1")
device, dtype = torch.device("cuda"), torch.bfloat16
kv, st = make_state(device=device, dtype=dtype, window_chunks=window_chunks, reuse_history_staging=True)
_commit_video_span(kv, st, kv_branch=POS, n_chunks=1, dtype=dtype, device=device)
stage_key, stage_value = kv.history_staging[0]
stage_key.fill_(-7)
stage_value.fill_(-7)
query = torch.randn(BLOCK, N_HEADS, HEAD_DIM, device=device, dtype=dtype)
for step in range(2):
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx
ctx.max_video_tokens = 2 * BLOCK
ctx.prepare(device, action_len=0, query_len=BLOCK)
inputs = ctx.layer_inputs(0)
assert inputs.reuse_history is (step > 0)
key = torch.randn_like(query)
value = torch.randn_like(query)
actual = paged_write_attn(inputs, query, key, value, None, None, HEAD_DIM**-0.5)
expected = paged_write_attn(
inputs._replace(stage_key=None, stage_value=None, reuse_history=False),
query,
key,
value,
None,
None,
HEAD_DIM**-0.5,
)
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
ids = inputs.block_table[0].long()
for staged, cache in ((stage_key, kv.key_cache(0)), (stage_value, kv.value_cache(0))):
torch.testing.assert_close(
staged[: inputs.max_seq_len], cache.index_select(0, ids).flatten(0, 1), rtol=0, atol=0
)
assert (staged[inputs.max_seq_len :] == -7).all()
@pytest.mark.skipif(
torch.version.hip is not None or not _gpu_flash_attn_usable(), reason="usable CUDA FlashAttention is required"
)
@hardware_test(res={"cuda": ["L4", "H100"]}, num_cards=1)
@pytest.mark.parametrize("window_chunks", [2, 4])
@pytest.mark.parametrize("reuse_history", [False, True])
@torch.inference_mode()
def test_staged_attention_replays_inductor_cudagraph(monkeypatch, window_chunks, reuse_history):
"""Inductor must capture the real mutable op and replay with fresh current K/V."""
monkeypatch.setenv(KV_GATHER_ENV, "1")
device, dtype = torch.device("cuda"), torch.bfloat16
kv, st = make_state(device=device, dtype=dtype, window_chunks=window_chunks, reuse_history_staging=True)
_commit_video_span(kv, st, kv_branch=POS, n_chunks=1, dtype=dtype, device=device)
stage_key, stage_value = kv.history_staging[0]
stage_key.fill_(-7)
stage_value.fill_(-7)
query = torch.randn(BLOCK, N_HEADS, HEAD_DIM, device=device, dtype=dtype)
key, value = torch.randn_like(query), torch.randn_like(query)
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx
ctx.max_video_tokens = 2 * BLOCK
ctx.prepare(device, action_len=0, query_len=BLOCK)
inputs = ctx.layer_inputs(0)
# Populate history before testing the reuse variant, as the first denoising probe does.
paged_write_attn(inputs, query, key, value, None, None, HEAD_DIM**-0.5)
if reuse_history:
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx
ctx.max_video_tokens = 2 * BLOCK
ctx.prepare(device, action_len=0, query_len=BLOCK)
inputs = ctx.layer_inputs(0)
assert inputs.reuse_history is reuse_history
torch._dynamo.reset()
try:
compiled = torch.compile(paged_write_attn, mode="reduce-overhead", fullgraph=True)
for _ in range(3):
torch.compiler.cudagraph_mark_step_begin()
compiled(inputs, query, key, value, None, None, HEAD_DIM**-0.5)
current_omni_platform.synchronize()
previous = None
for step in range(2):
key.normal_()
# Distinct value ranges ensure a stale replay cannot match the reference.
value.fill_(2.0 if step == 0 else -2.0)
torch.compiler.cudagraph_mark_step_begin()
with torch.profiler.profile(
activities=[torch.profiler.ProfilerActivity.CPU, torch.profiler.ProfilerActivity.CUDA]
) as prof:
actual = compiled(inputs, query, key, value, None, None, HEAD_DIM**-0.5)
current_omni_platform.synchronize()
assert any("cudaGraphLaunch" in event.key for event in prof.key_averages()), (
"Inductor did not replay a CUDA graph; mutable staging inputs may have lost their static annotation"
)
expected = paged_write_attn(
inputs._replace(stage_key=None, stage_value=None, reuse_history=False),
query,
key,
value,
None,
None,
HEAD_DIM**-0.5,
)
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
if previous is not None:
assert not torch.equal(actual, previous)
# Copy outside capture: graph-owned output storage is reused on the next step.
previous = actual.clone()
del actual
ids = inputs.block_table[0].long()
for staged, cache in ((stage_key, kv.key_cache(0)), (stage_value, kv.value_cache(0))):
torch.testing.assert_close(
staged[: inputs.max_seq_len], cache.index_select(0, ids).flatten(0, 1), rtol=0, atol=0
)
assert (staged[inputs.max_seq_len :] == -7).all()
finally:
torch._dynamo.reset()
@@ -244,7 +244,8 @@ def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len,
@pytest.mark.parametrize("history_chunks", [0, 1, 2, 3])
def test_staged_reuse_refreshes_the_current_blocks_at_their_live_offset(monkeypatch, history_chunks):
@pytest.mark.parametrize("window_chunks", [2, 4])
def test_staged_reuse_refreshes_the_current_blocks_at_their_live_offset(monkeypatch, history_chunks, window_chunks):
"""A second probe of the same AR block restages its current K/V where the table actually holds it.
The padded table always ends in at least one action-capacity block, and while the window is still
@@ -254,11 +255,14 @@ def test_staged_reuse_refreshes_the_current_blocks_at_their_live_offset(monkeypa
monkeypatch.setenv(KV_GATHER_ENV, "1")
device = torch.device("cpu")
dtype = torch.float32
kv, st = make_state(dtype=dtype, device=device, window_chunks=2, reuse_history_staging=True)
kv, st = make_state(dtype=dtype, device=device, window_chunks=window_chunks, reuse_history_staging=True)
for buffer in kv.history_staging[0]:
buffer.fill_(-7)
if history_chunks:
_commit_video_span(kv, st, kv_branch=POS, n_chunks=history_chunks, dtype=dtype, device=device)
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx
ctx.max_video_tokens = 2 * BLOCK
ctx.ensure_video_slots(device)
key_cache, value_cache = kv.key_cache(0), kv.value_cache(0)
@@ -275,6 +279,8 @@ def test_staged_reuse_refreshes_the_current_blocks_at_their_live_offset(monkeypa
n_blocks = max_seq_len // BLOCK
block_ids = block_table[0, :n_blocks].to(torch.long)
stage_k, stage_v = ctx.history_staging(0)
# The custom op narrows the manager-owned buffers before calling this helper.
stage_k, stage_v = stage_k[:max_seq_len], stage_v[:max_seq_len]
paged_attention_module._stage_window(
stage_k,
stage_v,
@@ -287,6 +293,8 @@ def test_staged_reuse_refreshes_the_current_blocks_at_their_live_offset(monkeypa
)
full_k = key_cache.index_select(0, block_ids).reshape(n_blocks * BLOCK, N_HEADS, HEAD_DIM)
full_v = value_cache.index_select(0, block_ids).reshape(n_blocks * BLOCK, N_HEADS, HEAD_DIM)
for buffer in kv.history_staging[0]:
assert (buffer[max_seq_len:] == -7).all()
return stage_k, stage_v, full_k, full_v, n_blocks
stage_k, stage_v, full_k, full_v, n_blocks = probe(1)
@@ -323,6 +331,24 @@ def test_staging_is_only_allocated_for_the_gather_path(monkeypatch, gather_enabl
assert (stage_k is not None) is gather_enabled
@pytest.mark.parametrize("window_chunks", [2, 4])
def test_layer_inputs_preserve_static_staging_tensors(monkeypatch, window_chunks):
"""Compiled inputs must retain the manager's static-address annotations, including with spare capacity."""
monkeypatch.setenv(KV_GATHER_ENV, "1")
device = torch.device("cpu")
kv, st = make_state(device=device, window_chunks=window_chunks, reuse_history_staging=True)
stage_key, stage_value = kv.history_staging[0]
for _ in range(2):
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx
ctx.max_video_tokens = 2 * BLOCK
ctx.prepare(device, action_len=0, query_len=BLOCK)
inputs = ctx.layer_inputs(0)
assert inputs.stage_key is stage_key
assert inputs.stage_value is stage_value
assert inputs.max_seq_len == 3 * BLOCK
assert stage_key.shape[0] == (window_chunks + 1) * BLOCK
@pytest.mark.skipif(not _gpu_flash_attn_usable(), reason="usable GPU FlashAttention is required")
@pytest.mark.parametrize("history_chunks", [1, 3])
@pytest.mark.parametrize("action_len", [0, 3])
@@ -363,8 +389,8 @@ def test_paged_attention_matches_dense_reference_gpu(history_chunks, action_len,
query[0],
current_k[0],
current_v[0],
action_k[0] if action_len else None,
action_v[0] if action_len else None,
action_k[0] if action_k is not None else None,
action_v[0] if action_v is not None else None,
HEAD_DIM**-0.5,
).unsqueeze(0)
@@ -556,8 +582,8 @@ def test_contiguous_kv_gather_path_matches_paged_path_gpu(monkeypatch, history_c
query[0],
current_k[0],
current_v[0],
action_k[0] if action_len else None,
action_v[0] if action_len else None,
action_k[0] if action_k is not None else None,
action_v[0] if action_v is not None else None,
HEAD_DIM**-0.5,
).unsqueeze(0)
@@ -264,6 +264,7 @@ class ARDiffusionPagedForwardContext:
# gather path -- their only consumer -- is switched on.
if not self.kv_cache.history_staging:
return
assert self.block_table is not None
if action_len or self.block_table.shape[0] != 1:
# Action tokens live in scratch blocks outside the video window; staging them is not modelled.
return
@@ -291,12 +292,14 @@ class ARDiffusionPagedForwardContext:
self.stage_first_block = self.kv_len // self.block_size - self.num_current_video_blocks
def history_staging(self, layer_idx: int) -> tuple[torch.Tensor | None, torch.Tensor | None]:
"""This layer's staging pair, narrowed to this forward's staged window (a prefix of the manager's buffer)."""
"""Return the manager-owned tensors marked static for CUDA Graph input mutation.
Fresh prefix views lose the bases' static-address annotation. Narrow the
window inside the attention custom op instead of at the compiled boundary.
"""
if not self.staging_enabled:
return None, None
key, value = self.kv_cache.history_staging[layer_idx]
tokens = int(self.max_seq_len)
return key[:tokens], value[:tokens]
return self.kv_cache.history_staging[layer_idx]
def layer_inputs(self, layer_idx: int) -> ARDiffusionPagedLayerInputs:
if not getattr(self, "_prepared", False):
@@ -435,12 +438,11 @@ def _stage_window(stage_key, stage_value, key_cache, value_cache, block_ids, n_b
earlier forward of the same AR block and cannot have changed since.
"""
ids = block_ids[first_block:]
blocks = n_blocks - first_block
stage_key.view(n_blocks, block_size, *key_cache.shape[2:])[first_block:].copy_(
key_cache.index_select(0, ids).view(blocks, block_size, *key_cache.shape[2:])
)
stage_value.view(n_blocks, block_size, *value_cache.shape[2:])[first_block:].copy_(
value_cache.index_select(0, ids).view(blocks, block_size, *value_cache.shape[2:])
# Write directly into the caller-owned window, avoiding an intermediate
# gathered tensor and its copy on every layer and denoising step.
torch.index_select(key_cache, 0, ids, out=stage_key.view(n_blocks, block_size, *key_cache.shape[2:])[first_block:])
torch.index_select(
value_cache, 0, ids, out=stage_value.view(n_blocks, block_size, *value_cache.shape[2:])[first_block:]
)
@@ -522,7 +524,7 @@ def ar_diffusion_paged_attention(
# vLLM's seqused_k/fa_version API. The ROCm flash-attn paged kernel also
# requires 128-token blocks, while AR-Diffusion uses frame-aligned
# 16-token blocks, so gather the visible blocks on-device first.
flash_attn_varlen_func = _rocm_flash_attn_varlen_func()
rocm_flash_attn_varlen_func = _rocm_flash_attn_varlen_func()
cu_seqlens_k = torch.cat([seq_lens.new_zeros(1), torch.cumsum(seq_lens, dim=0, dtype=torch.int32)])
positions = torch.arange(int(max_seq_len), device=query_flat.device)
logical_blocks = torch.div(positions, key_cache.shape[1], rounding_mode="floor")
@@ -533,7 +535,7 @@ def ar_diffusion_paged_attention(
valid = positions.unsqueeze(0) < seq_lens.unsqueeze(1)
packed_k = gathered_k[valid]
packed_v = gathered_v[valid]
out = flash_attn_varlen_func(
out = rocm_flash_attn_varlen_func(
q=query_flat,
k=packed_k,
v=packed_v,
@@ -579,8 +581,10 @@ def ar_diffusion_paged_attention(
# byte-for-byte what a full gather would produce. When the history did move -- new block,
# slid window, different session -- reuse_history is 0 and the whole window is re-gathered,
# which is the same work the unstaged path always does.
k_flat = stage_key
v_flat = stage_value
# Keep the marked base tensors as graph inputs, but only stage the
# active prefix when the manager allocated a larger capacity.
k_flat = stage_key[:max_seq_len]
v_flat = stage_value[:max_seq_len]
if reuse_history:
# The caller's metadata must describe a block-aligned current chunk inside the staged
# window; anything else is a wrong offset, not a reason to quietly restage everything.
@@ -621,12 +625,12 @@ def ar_diffusion_paged_attention(
fa_version=fa_version,
)
else:
from vllm.vllm_flash_attn import flash_attn_varlen_func
from vllm.vllm_flash_attn import flash_attn_varlen_func as paged_flash_attn_varlen_func
fa_version = _resolve_fa_version(query_flat.shape[-1])
out = torch.empty_like(query_flat)
flash_attn_varlen_func(
paged_flash_attn_varlen_func(
q=query_flat,
k=key_cache,
v=value_cache,