[AR-Diffusion] Page in kernel-legal blocks so the default resolution can run (#6481)

Signed-off-by: linzhenpl07 <linzhenpl07@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Zhou Taichang <tzhouam@connect.ust.hk>
This commit is contained in:
authored and GitHub committed 2026-10-02 20:05:04 +08:00
1 parent e84014dffb
commit b10270a01c
8 files changed
+1229 -50

No files matched your search

@@ -376,10 +376,17 @@ def test_lru_eviction_releases_blocks_and_notifies_pipeline():
def test_budget_reduced_capacity_drives_runner_lru():
pipeline = CapablePipeline(tiny_spec(capacity=2))
# One tiny LingBot-like session requires 128 bytes; two require 192.
# One tiny LingBot-like session requires 1,808 bytes; two require 2,592.
#
# These read 128 and 192 while the paging unit was the frame. tiny_spec
# declares one token per frame, so it used to page a single token at a
# time -- a block size no attention kernel accepts. Paging at 16 makes
# each page sixteen times larger. The block *counts* are unchanged, and
# so is what this test is about: a budget that fits fewer sessions than
# the pipeline asked for must drive the runner's LRU.
runner = make_runner(
pipeline,
available_bytes=128,
available_bytes=2048,
gpu_memory_fraction=1.0,
)
kv = runner.kv_cache
@@ -665,3 +672,106 @@ def test_pipeline_without_warmup_provider_is_safely_skipped(monkeypatch):
monkeypatch.setattr(runner, "execute_model", fail_if_called)
runner._warmup_ar_rollout()
assert execute.called is False
# ── paging unit vs eviction unit ────────────────────────────────────────────
def test_a_frame_that_is_already_a_legal_block_keeps_its_paging():
"""Every resolution that runs today must page exactly as it does today."""
from vllm_omni.experimental.ar_diffusion.runner import paging_block_size
for tokens_per_frame in (16, 320, 1440, 4096):
assert tokens_per_frame % 16 == 0
assert paging_block_size(tokens_per_frame) == tokens_per_frame
def test_a_frame_the_kernel_would_reject_pages_at_the_finest_legal_unit():
"""832x480 is the checkpoint's own default and the kernel rejects it.
(480/16) x (832/16) = 1560 tokens per frame, and 1560 % 16 == 8, so a
frame-sized block is not something FlashAttention's paged kernel accepts.
"""
from vllm_omni.experimental.ar_diffusion.runner import paging_block_size
assert 1560 % 16 == 8
assert paging_block_size(1560) == 16
# And whatever it returns is always something the kernel takes.
for tokens_per_frame in range(1, 200):
assert paging_block_size(tokens_per_frame) % 16 == 0
# ── the kernel's constraint is data, not a constant ─────────────────────────
def _multiple_of(base):
from vllm.v1.attention.backend import MultipleOf
return MultipleOf(base)
def test_the_dispatching_module_answers_in_vllms_own_vocabulary():
"""AR-Diffusion calls flash_attn itself, so no backend can be asked.
The answer has to come from the module that picks the kernel, and it has to
have the shape vLLM uses, so the policy that consumes it does not care
where it came from.
"""
from vllm.v1.attention.backend import MultipleOf
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import supported_kernel_block_sizes
advertised = supported_kernel_block_sizes()
assert advertised, "a kernel that accepts nothing cannot be paged for"
for entry in advertised:
assert isinstance(entry, int | MultipleOf)
def test_a_kernel_that_takes_one_fixed_size_gets_that_size():
"""hpc_attn advertises [64] -- not a multiple, a single legal value.
A frame of 1560 tokens is not 64, and 1560 % 64 == 24, so the frame cannot
be the block and the only thing left to page at is 64. Assuming multiples
of 16 here would hand the kernel a block size it rejects.
"""
from vllm_omni.experimental.ar_diffusion.runner import paging_block_size
assert 1560 % 64 == 24
assert paging_block_size(1560, [64]) == 64
assert paging_block_size(64, [64]) == 64
assert paging_block_size(128, [64]) == 64 # a legal multiple is still not the advertised size
def test_a_kernel_with_large_pages_still_keeps_a_whole_frame_when_it_fits():
"""FlashInfer offers 128-and-up pages on Blackwell and not on Hopper.
The same checkpoint on the two cards must therefore page differently, which
is exactly what a constant cannot express.
"""
from vllm_omni.experimental.ar_diffusion.runner import paging_block_size
hopper = [_multiple_of(16)]
blackwell = [_multiple_of(16), 128, 256]
assert paging_block_size(1440, hopper) == 1440 # a legal multiple of 16
assert paging_block_size(1440, blackwell) == 1440
assert paging_block_size(1560, hopper) == 16
assert paging_block_size(1560, blackwell) == 16 # finest legal unit, not the largest
def test_whatever_comes_back_is_always_something_the_kernel_accepts():
from vllm_omni.experimental.ar_diffusion.runner import paging_block_size
for advertised, check in (
([_multiple_of(16)], lambda n: n % 16 == 0),
([64], lambda n: n == 64),
([_multiple_of(32), 48], lambda n: n % 32 == 0 or n == 48),
):
for tokens_per_frame in range(1, 300):
assert check(paging_block_size(tokens_per_frame, advertised))
def test_a_kernel_that_advertises_nothing_is_an_error_not_a_guess():
from vllm_omni.experimental.ar_diffusion.runner import paging_block_size
with pytest.raises(ValueError, match="no legal block size"):
paging_block_size(1560, [])
@@ -625,6 +625,74 @@ def test_non_contiguous_branch_indices_rejected():
)
# --- eviction against the block table ---------------------------------------
class _RecordingManager(ChunkWindowManager):
"""Capture the block range eviction asks for, without a real block pool.
``remove_skipped_blocks`` reads three attributes and calls one method. A
pool would add nothing: the bug under test is the conversion from the
spec's units into the block table's, and that is entirely visible here.
"""
def __init__(self, spec):
self.kv_cache_spec = spec
self.sliding_window = spec.sliding_window
self.block_size = spec.block_size
self.freed: tuple[int, int] | None = None
def _remove_blocks_in_range(self, request_id, first_block, last_block):
self.freed = (first_block, last_block)
def test_sink_is_preserved_when_a_frame_spans_many_blocks():
# The shipped geometry: 832x480 is 1560 tokens per frame, 1560 % 16 == 8 is
# not a legal block size, so paging falls back to 16 and a frame spans 98
# blocks. Every other eviction test uses chunk_size == BLOCK, where a frame
# is a block and a sink counted in frames indexes the block table correctly
# by accident -- which is why this went unnoticed.
chunk_size, sink_chunks, window_chunks = 1560, 9, 9
spec = ChunkWindowSpec(
block_size=16,
num_kv_heads=4,
head_size=64,
dtype=torch.float16,
sliding_window=window_chunks * chunk_size,
chunk_size=chunk_size,
window_chunks=window_chunks,
sink_chunks=sink_chunks,
)
manager = _RecordingManager(spec)
# Far enough past sink + window that eviction has definitely started.
manager.remove_skipped_blocks("req", total_computed_tokens=30 * chunk_size)
assert manager.freed is not None
first_freed, last_freed = manager.freed
# The assertion is on the token boundary rather than a block count, so it
# stays meaningful if the paging unit changes again.
sink_tokens = sink_chunks * chunk_size
assert first_freed * spec.block_size >= sink_tokens, (
f"eviction starts at token {first_freed * spec.block_size}, inside the {sink_tokens}-token sink"
)
# And it must not over-protect: at most one straddling block beyond the sink.
assert (first_freed - 1) * spec.block_size < sink_tokens
# Only wholly-skipped blocks are released.
skipped = manager.get_num_skipped_tokens(30 * chunk_size)
assert last_freed * spec.block_size <= sink_tokens + skipped
def test_sink_conversion_is_identity_when_a_frame_is_a_block():
# Every geometry that paged one frame per block must be untouched.
spec = make_spec(chunk_size=BLOCK, window_chunks=2, sink_chunks=1)
manager = _RecordingManager(spec)
manager.remove_skipped_blocks("req", total_computed_tokens=8 * BLOCK)
assert manager.freed is not None
first_freed, _ = manager.freed
assert first_freed == spec.sink_chunks
def test_staged_window_reuse_matches_a_full_gather():
"""Reusing the staged history must be byte-identical to re-gathering the whole window.
@@ -23,6 +23,7 @@ from vllm_omni.experimental.ar_diffusion.kv_cache import (
)
from vllm_omni.experimental.ar_diffusion.kv_cache import paged_attention as paged_attention_module
from vllm_omni.experimental.ar_diffusion.kv_cache.config import KV_GATHER_ENV
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import ChunkWindowManager
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@@ -36,10 +37,26 @@ NEG = "negative"
def make_state(
*, num_layers=1, window_chunks=2, dtype=torch.float32, device=torch.device("cpu"), reuse_history_staging=False
*,
num_layers=1,
window_chunks=2,
chunk_size=BLOCK,
sink_chunks=0,
reset_at_boundary=False,
dtype=torch.float32,
device=torch.device("cpu"),
reuse_history_staging=False,
):
"""Build a cache. ``chunk_size`` defaults to the block size, but the two are
independent -- the shipped 832x480 gives 1560 tokens per frame against
16-token blocks, so a frame is 97.5 blocks."""
cfg = ARDiffusionKVConfig(
enable=True, chunk_size=BLOCK, window_chunks=window_chunks, reuse_history_staging=reuse_history_staging
enable=True,
chunk_size=chunk_size,
window_chunks=window_chunks,
sink_chunks=sink_chunks,
reset_at_boundary=reset_at_boundary,
reuse_history_staging=reuse_history_staging,
)
kv = ARDiffusionKVCache(
cfg,
@@ -109,11 +126,13 @@ def _commit_video_span(
n_chunks: int,
dtype: torch.dtype,
device: torch.device,
chunk_size: int = BLOCK,
) -> tuple[torch.Tensor, torch.Tensor]:
ctx = st.get_kv_caches(kv_branch, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx
span = n_chunks * chunk_size
ctx = st.get_kv_caches(kv_branch, seq_len=span, commit_current=True)[0].forward_ctx
ctx.ensure_video_slots(device)
k = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
v = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
k = torch.randn(1, span, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
v = torch.randn(1, span, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
kv._k_pools[0][ctx.current_video_slot_mapping] = k[0]
kv._v_pools[0][ctx.current_video_slot_mapping] = v[0]
st.commit_paged_context(kv_branch)
@@ -547,6 +566,644 @@ def test_custom_op_compiles_fullgraph_without_recompile_on_value_change(monkeypa
torch._dynamo.reset()
# ── a frame that is not a whole number of blocks ────────────────────────────
# 24 tokens per chunk against 16-token blocks leaves 8 over, the same remainder
# the shipped 832x480 produces (1560 % 16 == 8). Every fixture above holds
# chunk_size == BLOCK, so nothing there can reach this path.
RAGGED_CHUNK = 24
@pytest.mark.parametrize("commit_current", [False, True])
@pytest.mark.parametrize("action_len", [0, 3])
def test_a_ragged_chunk_still_matches_the_dense_reference(commit_current, action_len):
"""Attention reads a sequence, not a set of blocks.
With one committed chunk of 24 tokens the history stops 8 slots into its
last block. The committing path writes straight after it and is fine. The
scratch path used to restart at slot zero of a fresh region, which left
those 8 slots unwritten -- and since the kernel consumes the block table as
one contiguous run, it read them as if they were tokens and shifted every
token after them by 8. Shapes stayed correct throughout, so only the values
showed it.
"""
torch.manual_seed(0)
device = torch.device("cpu")
dtype = torch.float32
# window_chunks=2 with one chunk of history keeps the whole sequence
# resident, so the window does not also round up here; that case is
# test_a_ragged_window_keeps_the_block_table_a_fixed_shape below.
kv, st = make_state(dtype=dtype, device=device, window_chunks=2, chunk_size=RAGGED_CHUNK)
history_k, history_v = _commit_video_span(
kv, st, kv_branch=POS, n_chunks=1, dtype=dtype, device=device, chunk_size=RAGGED_CHUNK
)
ctx = st.get_kv_caches(POS, seq_len=RAGGED_CHUNK, commit_current=commit_current)[0].forward_ctx
ctx.ensure_video_slots(device)
assert ctx.start_offset == RAGGED_CHUNK % BLOCK == 8
current_k = torch.randn(1, RAGGED_CHUNK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
current_v = torch.randn(1, RAGGED_CHUNK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
kv._k_pools[0][ctx.current_video_slot_mapping] = current_k[0]
kv._v_pools[0][ctx.current_video_slot_mapping] = current_v[0]
action_k = action_v = None
if action_len:
ctx.ensure_action_slots(action_len, device)
action_k = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
action_v = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
kv._k_pools[0][ctx.action_slot_mapping] = action_k[0]
kv._v_pools[0][ctx.action_slot_mapping] = action_v[0]
query = torch.randn(1, RAGGED_CHUNK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
block_table, query_start_loc, seq_lens, max_query_len, max_seq_len = ctx.build_block_table(
action_len=action_len, query_len=query.shape[1], device=device
)
paged = ar_diffusion_paged_attention(
query,
kv.key_cache(0),
kv.value_cache(0),
block_table=block_table,
query_start_loc=query_start_loc,
seq_lens=seq_lens,
max_query_len=max_query_len,
max_seq_len=max_seq_len,
softmax_scale=HEAD_DIM**-0.5,
causal=False,
)
new_k = torch.cat([history_k, current_k], dim=1)
new_v = torch.cat([history_v, current_v], dim=1)
if action_len:
new_k = torch.cat([new_k, action_k], dim=1)
new_v = torch.cat([new_v, action_v], dim=1)
ref = _dense_attention(query, new_k, new_v)
torch.testing.assert_close(paged, ref, rtol=1e-5, atol=1e-5)
def test_a_ragged_scratch_chunk_is_physically_continuous_with_its_history():
"""The write targets themselves must leave no hole.
This is the property the values above depend on, asserted directly so a
regression names the cause rather than showing a numeric mismatch.
"""
device = torch.device("cpu")
kv, st = make_state(device=device, window_chunks=2, chunk_size=RAGGED_CHUNK)
_commit_video_span(kv, st, kv_branch=POS, n_chunks=1, dtype=torch.float32, device=device, chunk_size=RAGGED_CHUNK)
ctx = st.get_kv_caches(POS, seq_len=RAGGED_CHUNK, commit_current=False)[0].forward_ctx
ctx.ensure_video_slots(device)
history_tail_block = ctx.history_block_ids[-1]
# The chunk starts inside the history's own last block, not at a fresh one.
assert ctx.current_video_block_ids[0] == history_tail_block
first_slot = int(ctx.current_video_slot_mapping[0])
assert first_slot == history_tail_block * BLOCK + 8
# And the action region must not be charged for that managed block.
ctx.ensure_action_slots(3, device)
assert ctx._scratch_blocks_used == len(ctx.current_video_block_ids) - 1
assert ctx.action_scratch_block_ids[0] not in ctx.current_video_block_ids
def test_a_ragged_window_keeps_the_block_table_a_fixed_shape():
"""The table's shape must not track how full the window is.
With a sink the visible window is 2*24 + 24 = 72 tokens, which is 4.5
blocks. Deriving the width by flooring that would understate the capacity,
the max() against the live block count would take over, and the shape would
change as the window filled -- recompiling the graph. The same floor would
also let max_seq_len come out under the kv_len actually passed.
"""
device = torch.device("cpu")
kv, st = make_state(device=device, window_chunks=2, sink_chunks=1, chunk_size=RAGGED_CHUNK)
# The window is deliberately not a whole number of blocks.
max_video_tokens = kv.spec.sliding_window + kv.spec.sink_chunks * kv.spec.chunk_size
assert max_video_tokens % BLOCK != 0
widths: set[int] = set()
kv_lens: list[int] = []
for _ in range(6):
ctx = st.get_kv_caches(POS, seq_len=RAGGED_CHUNK, commit_current=True)[0].forward_ctx
ctx.ensure_video_slots(device)
block_table, _, seq_lens, _, max_seq_len = ctx.build_block_table(
action_len=0, query_len=RAGGED_CHUNK, device=device
)
widths.add(int(block_table.shape[1]))
# A bound handed to the kernel has to actually bound the sequence.
assert int(seq_lens[0]) <= max_seq_len
kv_lens.append(int(seq_lens[0]))
st.commit_paged_context(POS)
assert len(widths) == 1, f"block table width varied across ticks: {sorted(widths)}"
# The window has to have actually filled, or none of the above was tested.
assert max(kv_lens) > min(kv_lens)
assert max(kv_lens) <= ctx.max_video_blocks * BLOCK
# Whole blocks are read, so each of the two boundaries -- the sink's end and
# the recent window's start -- can contribute up to BLOCK - 1 tokens the
# window no longer needs.
assert max(kv_lens) <= max_video_tokens + 2 * (BLOCK - 1)
def _poison_pools(kv: ARDiffusionKVCache) -> None:
for pool in (*kv._k_pools, *kv._v_pools):
pool.fill_(float("nan"))
def _write_prepared_chunk(
kv: ARDiffusionKVCache, ctx, *, dtype: torch.dtype, device: torch.device
) -> tuple[torch.Tensor, torch.Tensor]:
"""Write fresh K/V for a prepared chunk, poisoning the rest of its blocks first.
Blocks are handed out whole and a reused block keeps whatever it held, so a
slot read without having been written would not look unusual. Every slot of
the chunk's blocks that this chunk does not write is set to NaN first --
except the leading slots of a block the history still occupies -- so reading
one turns the attention output NaN.
"""
mapping = ctx.current_video_slot_mapping
first_slot = int(mapping[0])
for index, block in enumerate(ctx.current_video_block_ids):
start = first_slot if index == 0 else block * BLOCK
kv._k_pools[0][start : (block + 1) * BLOCK] = float("nan")
kv._v_pools[0][start : (block + 1) * BLOCK] = float("nan")
k = torch.randn(ctx.seq_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
v = torch.randn(ctx.seq_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
kv._k_pools[0][mapping] = k
kv._v_pools[0][mapping] = v
return k, v
def _window_positions(end: int, *, sink: int, window: int, written, history: int, resident: set[int]) -> list[int]:
"""Token positions attention must read at ``end``.
Every token of the current chunk, plus every written history token in a
block that is still resident and holds a sink token or one of the window's
most recent tokens.
"""
start = end - window
blocks = {p // BLOCK for p in range(min(sink, end))} | {p // BLOCK for p in range(max(start, 0), end)}
return [p for p in sorted(written) if p >= history or (p // BLOCK in blocks and p // BLOCK in resident)]
def _model_block_indices(kv, adapter) -> dict[int, int]:
"""Each resident block's index in model positions, by block id.
Compaction removes the evicted gap after the sink's blocks and shifts the
rest of the table down by it, so a table index past the sink is
``compacted_tokens`` behind the position it holds.
"""
block_size = kv.block_size
sink_blocks = -(-(kv.spec.sink_chunks * kv.spec.chunk_size) // block_size)
shift = adapter.compacted_tokens // block_size
return {
int(block): index if index < sink_blocks else index + shift
for index, block in enumerate(kv.block_table(adapter))
if block != kv.null_block_id
}
@pytest.mark.parametrize(
("sink_chunks", "window_chunks", "reset_at_boundary"),
[(1, 2, False), (0, 2, False), (1, 3, False), (2, 2, True), (0, 2, True)],
)
def test_attention_after_eviction_reads_every_kept_token_and_nothing_unwritten(
sink_chunks, window_chunks, reset_at_boundary
):
"""Once the window slides, the table must still cover the window exactly.
The sink and the recent window are separate token ranges, and each can
straddle a block edge on its own: 24-token chunks against 16-token blocks
put a boundary 8 tokens past an edge on alternating chunks. Counting blocks
off the ends of the resident list dropped the block holding the window's
first tokens whenever both boundaries straddled, and the returned length
ran past the last written slot. Neither changes a shape, so this compares
values against dense attention over the tokens the window keeps -- after
eviction, with every unwritten slot poisoned, on both the scratch and the
committing forward of every chunk.
The reset case keeps only the sink across a boundary, so the recent window
reaches back over blocks that are no longer resident. Those must be skipped
by position rather than read.
Eight chunks are enough for compaction to drop the evicted gap from the
table, after which storage positions run behind model positions. Removing
a gap that is not a whole number of chunks moves where eviction snaps, and
in the reset case the window then keeps tokens a boundary should have
dropped.
"""
torch.manual_seed(0)
device, dtype = torch.device("cpu"), torch.float32
kv, st = make_state(
device=device,
window_chunks=window_chunks,
sink_chunks=sink_chunks,
reset_at_boundary=reset_at_boundary,
chunk_size=RAGGED_CHUNK,
)
_poison_pools(kv)
sink, window = sink_chunks * RAGGED_CHUNK, int(kv.spec.sliding_window)
committed: dict[int, tuple[torch.Tensor, torch.Tensor]] = {}
evicted = compacted = False
for chunk in range(8):
history = int(st.adapter(POS).absolute_num_computed_tokens)
end = history + RAGGED_CHUNK
for commit_current in (False, True):
ctx = st.get_kv_caches(POS, seq_len=RAGGED_CHUNK, commit_current=commit_current)[0].forward_ctx
ctx.ensure_video_slots(device)
k, v = _write_prepared_chunk(kv, ctx, dtype=dtype, device=device)
written = dict(committed)
written.update({history + i: (k[i], v[i]) for i in range(RAGGED_CHUNK)})
query = torch.randn(1, RAGGED_CHUNK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
block_table, query_start_loc, seq_lens, max_query_len, max_seq_len = ctx.build_block_table(
action_len=0, query_len=RAGGED_CHUNK, device=device
)
resident = set(_model_block_indices(kv, st.adapter(POS)).values())
positions = _window_positions(
end, sink=sink, window=window, written=written, history=history, resident=resident
)
label = f"chunk {chunk}, commit_current={commit_current}"
assert int(seq_lens[0]) == len(positions), f"{label}: kv_len does not match the written slots it covers"
paged = ar_diffusion_paged_attention(
query,
kv.key_cache(0),
kv.value_cache(0),
block_table=block_table,
query_start_loc=query_start_loc,
seq_lens=seq_lens,
max_query_len=max_query_len,
max_seq_len=max_seq_len,
softmax_scale=HEAD_DIM**-0.5,
causal=False,
)
ref = _dense_attention(
query,
torch.stack([written[p][0] for p in positions]).unsqueeze(0),
torch.stack([written[p][1] for p in positions]).unsqueeze(0),
)
torch.testing.assert_close(paged, ref, rtol=1e-5, atol=1e-5, msg=lambda m, label=label: f"{label}: {m}")
committed = written
st.commit_paged_context(POS)
compacted = compacted or st.adapter(POS).compacted_tokens > 0
evicted = evicted or compacted or kv.null_block_id in kv.block_table(st.adapter(POS))
assert evicted, "the window never slid, so nothing after eviction was tested"
assert compacted, "the table was never compacted, so storage and model positions never diverged"
@pytest.mark.parametrize(
("sink_chunks", "window_chunks", "reset_at_boundary"),
[(1, 2, False), (0, 2, False), (1, 3, False), (2, 2, True), (0, 2, True)],
)
def test_compaction_does_not_change_which_tokens_stay_resident(
monkeypatch, sink_chunks, window_chunks, reset_at_boundary
):
"""Compaction renumbers the block table; it must not change what eviction keeps.
Storage positions run ``compacted_tokens`` behind model positions, and
eviction snaps to chunk boundaries counted in storage positions. Removing a
gap that is not a whole number of chunks moves every later snap, so eviction
keeps a different set of tokens -- in the reset case, tokens a boundary
should have dropped, which the window then reads. Attention checked against
what is resident cannot see that, so the oracle here is the same rollout
with compaction switched off.
"""
def resident_blocks_per_tick(compact: bool) -> tuple[list[set[int]], int]:
with monkeypatch.context() as patch:
if not compact:
patch.setattr(ChunkWindowManager, "compact_block_table", lambda self, request_id: 0)
kv, st = make_state(
window_chunks=window_chunks,
sink_chunks=sink_chunks,
reset_at_boundary=reset_at_boundary,
chunk_size=RAGGED_CHUNK,
)
ticks = []
for _ in range(12):
ctx = st.get_kv_caches(POS, seq_len=RAGGED_CHUNK, commit_current=True)[0].forward_ctx
ctx.ensure_video_slots(torch.device("cpu"))
ticks.append(set(_model_block_indices(kv, st.adapter(POS)).values()))
st.commit_paged_context(POS)
return ticks, st.adapter(POS).compacted_tokens
expected, _ = resident_blocks_per_tick(compact=False)
actual, compacted_tokens = resident_blocks_per_tick(compact=True)
assert compacted_tokens > 0, "the table was never compacted, so nothing was tested"
for tick, (want, got) in enumerate(zip(expected, actual)):
assert got == want, f"tick {tick}: compaction kept blocks {sorted(got - want)} and dropped {sorted(want - got)}"
def test_history_staging_holds_a_ragged_window_and_restages_it_whole(monkeypatch):
"""The staging buffers must fit the table width this module pads to.
A sink or window that is not a whole number of blocks spans one more block
than its token count suggests, so sizing staging from tokens left it short
and the first prepared forward refused to run. And because such a chunk
shares its first block with the history's tail, the history a probe sees is
not left untouched by the next probe, so it is restaged rather than reused.
"""
monkeypatch.setenv(KV_GATHER_ENV, "1")
device = torch.device("cpu")
kv, st = make_state(
device=device, window_chunks=2, sink_chunks=1, chunk_size=RAGGED_CHUNK, reuse_history_staging=True
)
capacity = int(kv.history_staging[0][0].shape[0])
for chunk in range(6):
for probe in range(2):
ctx = st.get_kv_caches(POS, seq_len=RAGGED_CHUNK, commit_current=False)[0].forward_ctx
ctx.prepare(device=device, action_len=0, query_len=RAGGED_CHUNK)
assert int(ctx.max_seq_len) <= capacity
assert not ctx.reuse_history, f"chunk {chunk}, probe {probe}: a ragged chunk reused the staged history"
ctx = st.get_kv_caches(POS, seq_len=RAGGED_CHUNK, commit_current=True)[0].forward_ctx
ctx.prepare(device=device, action_len=0, query_len=RAGGED_CHUNK)
st.commit_paged_context(POS)
@pytest.mark.parametrize("commit_current", [False, True])
def test_action_tokens_after_a_partly_written_video_block_are_refused(commit_current):
"""Action K/V follows the video blocks in the table, and the kernel reads it as one run.
A 24-token chunk from an empty history ends 8 slots into its second 16-token
block, so the run would read those 8 unwritten slots as the first action
tokens and never reach the last ones. Every shape stays right, which is why
this is refused rather than computed.
"""
_, st = make_state(window_chunks=2, chunk_size=RAGGED_CHUNK)
ctx = st.get_kv_caches(POS, seq_len=RAGGED_CHUNK, commit_current=commit_current)[0].forward_ctx
with pytest.raises(ValueError, match="partly written video block"):
ctx.build_block_table(action_len=3, query_len=RAGGED_CHUNK + 3, device=torch.device("cpu"))
def test_the_shipped_geometry_reads_exactly_the_window_on_every_tick():
"""832x480 with the checkpoint's own sink and window, slot by slot.
A 9-frame sink and a 9-frame window are each 8 tokens past a 16-token edge
but 28080 tokens together -- a whole number of blocks -- so a width taken
from their sum was one block short on every tick once the window had
filled. Twelve ticks of three frames, checking every slot the kernel reads
against the token that slot is supposed to hold. No attention is computed:
at this size the bookkeeping is the whole question.
"""
from vllm_omni.experimental.ar_diffusion.runner import paging_block_size
frame, frames_per_tick = (480 // 16) * (832 // 16), 3
block = paging_block_size(frame)
seq_len = frames_per_tick * frame
cfg = ARDiffusionKVConfig(enable=True, chunk_size=frame, window_chunks=9, sink_chunks=9)
kv = ARDiffusionKVCache(
cfg,
num_layers=1,
num_kv_heads=1,
head_size=8,
dtype=torch.float16,
block_size=block,
max_model_len=1 << 20,
available_bytes=1 << 27,
kv_branches=(ARDiffusionKVBranchSpec(POS, 0),),
session_capacity=1,
frames_per_block=frames_per_tick,
max_scratch_tokens_per_branch=seq_len,
device=torch.device("cpu"),
)
st = ARDiffusionKVState(kv, "s1", {POS: kv.begin_request("r-pos")}, num_layers=1)
sink, window = 9 * frame, int(kv.spec.sliding_window)
holds: dict[int, int] = {} # slot -> token position last written there
widths: set[int] = set()
filled = compacted = False
for tick in range(12):
history = int(st.adapter(POS).absolute_num_computed_tokens)
end = history + seq_len
ctx = st.get_kv_caches(POS, seq_len=seq_len, commit_current=True)[0].forward_ctx
ctx.ensure_video_slots(torch.device("cpu"))
for offset, slot in enumerate(ctx.current_video_slot_mapping.tolist()):
holds[slot] = history + offset
table, _, seq_lens, _, _ = ctx.build_block_table(action_len=0, query_len=seq_len, device=torch.device("cpu"))
widths.add(int(table.shape[1]))
kv_len = int(seq_lens[0])
position_of_block = {b: i * block for b, i in _model_block_indices(kv, st.adapter(POS)).items()}
read = []
for b in table[0].tolist()[: -(-kv_len // block)]:
assert b in position_of_block, f"tick {tick}: read block {b}, which holds no token of this session"
read.extend((b * block + o, position_of_block[b] + o) for o in range(block))
read = read[:kv_len]
wrong = [(slot, want) for slot, want in read if holds.get(slot) != want]
assert not wrong, f"tick {tick}: {len(wrong)} slots read hold a token other than the one their position implies"
start = end - window
kept = set(range(min(sink, end))) | set(range(max(start, 0), end))
missed = kept - {want for _, want in read}
assert not missed, f"tick {tick}: {len(missed)} tokens the window keeps were not read"
filled = filled or end > sink + window
st.commit_paged_context(POS)
compacted = compacted or st.adapter(POS).compacted_tokens > 0
assert filled, "the window never filled, so the case that fails was never reached"
assert compacted, "the table was never compacted, so storage and model positions never diverged"
assert len(widths) == 1, f"block table width varied across ticks: {sorted(widths)}"
class _CountedBlockIds:
"""Wraps a vLLM block manager's ``get_blocks`` and counts the block ids read.
Every path to a request's block ids goes through ``get_blocks``, so a copy of
the whole table is counted in full even if only a few entries are indexed.
"""
def __init__(self, manager):
self.reads = 0
self._get_blocks = manager.get_blocks
def __call__(self, request_id):
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
blocks = self._get_blocks(request_id)
return KVCacheBlocks(tuple([_CountedBlock(block, self) for block in group] for group in blocks.blocks))
class _CountedBlock:
def __init__(self, block, counter: _CountedBlockIds):
self._block = block
self._counter = counter
@property
def block_id(self) -> int:
self._counter.reads += 1
return self._block.block_id
def test_reading_the_window_touches_only_the_blocks_it_can_keep(monkeypatch):
"""However long the block table is, reading the window must not cost more.
Evicted positions stay in the table as null entries until compaction drops
them a whole number of chunks at a time -- up to two frames' worth of blocks
at 832x480 -- so the table can still be far longer than the window. Only the
sink's blocks and the recent window's blocks can hold a kept token, so those
are all the read may touch. Compaction is switched off here so the table
grows to many times the window, and reads are counted where the ids leave
vLLM's block manager.
"""
monkeypatch.setattr(ChunkWindowManager, "compact_block_table", lambda self, request_id: 0)
device = torch.device("cpu")
kv, st = make_state(device=device, window_chunks=2, sink_chunks=1, chunk_size=RAGGED_CHUNK)
reads: list[int] = []
table_lengths: list[int] = []
for _ in range(60):
ctx = st.get_kv_caches(POS, seq_len=RAGGED_CHUNK, commit_current=True)[0].forward_ctx
ctx.ensure_video_slots(device)
counted = _CountedBlockIds(kv.manager)
kv.manager.get_blocks = counted
try:
ctx.build_block_table(action_len=0, query_len=RAGGED_CHUNK, device=device)
finally:
del kv.manager.get_blocks
reads.append(counted.reads)
table_lengths.append(len(kv.block_table(st.adapter(POS))))
st.commit_paged_context(POS)
assert table_lengths[-1] > 10 * ctx.max_video_blocks, "the table never outgrew the window, so nothing was tested"
assert max(reads) <= ctx.max_video_blocks, (
f"read {max(reads)} table entries for a {ctx.max_video_blocks}-block window"
)
def test_the_checkpoints_own_default_resolution_can_build_a_cache():
"""832x480 is what LingBot World v2 ships as its default, and it could not run.
A frame-sized block made the resolution a kernel-compatibility question:
1560 tokens per frame is not a multiple of 16, so FlashAttention's paged
kernel rejected it and the default resolution had no realtime path at all.
"""
from vllm_omni.experimental.ar_diffusion.runner import paging_block_size
tokens_per_frame = (480 // 16) * (832 // 16)
assert tokens_per_frame == 1560
assert tokens_per_frame % 16 == 8, "the whole point is that a frame is not a legal block"
block_size = paging_block_size(tokens_per_frame)
cfg = ARDiffusionKVConfig(enable=True, chunk_size=tokens_per_frame, window_chunks=2)
kv = ARDiffusionKVCache(
cfg,
num_layers=1,
num_kv_heads=N_HEADS,
head_size=HEAD_DIM,
dtype=torch.float32,
block_size=block_size,
max_model_len=1 << 16,
available_bytes=1 << 28,
kv_branches=(ARDiffusionKVBranchSpec(POS, 0),),
session_capacity=1,
frames_per_block=2,
max_scratch_tokens_per_branch=block_size,
device=torch.device("cpu"),
)
assert kv.block_size % 16 == 0
# The eviction unit is still the frame; only the paging unit changed.
assert kv.spec.chunk_size == tokens_per_frame
assert kv.blocks_per_frame == -(-tokens_per_frame // block_size) == 98
def test_the_shipped_resolution_geometry_matches_dense_attention():
"""The real number, not a scaled-down stand-in: 1560 tokens per frame.
This is the case the issue reported failing at ~4e-02 on the scratch path.
It runs on CPU because the reference is dense attention, not a kernel.
"""
torch.manual_seed(0)
device = torch.device("cpu")
dtype = torch.float32
tokens_per_frame = (480 // 16) * (832 // 16)
assert tokens_per_frame == 1560
kv, st = make_state(dtype=dtype, device=device, window_chunks=2, chunk_size=tokens_per_frame)
history_k, history_v = _commit_video_span(
kv, st, kv_branch=POS, n_chunks=1, dtype=dtype, device=device, chunk_size=tokens_per_frame
)
# The non-committing path is the one that was wrong: four of the five
# forwards per generated block take it.
ctx = st.get_kv_caches(POS, seq_len=tokens_per_frame, commit_current=False)[0].forward_ctx
ctx.ensure_video_slots(device)
assert ctx.start_offset == 8, "1560 % 16 == 8 is the whole reason this case exists"
current_k = torch.randn(1, tokens_per_frame, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
current_v = torch.randn(1, tokens_per_frame, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
kv._k_pools[0][ctx.current_video_slot_mapping] = current_k[0]
kv._v_pools[0][ctx.current_video_slot_mapping] = current_v[0]
query = torch.randn(1, tokens_per_frame, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
block_table, query_start_loc, seq_lens, max_query_len, max_seq_len = ctx.build_block_table(
action_len=0, query_len=tokens_per_frame, device=device
)
paged = ar_diffusion_paged_attention(
query,
kv.key_cache(0),
kv.value_cache(0),
block_table=block_table,
query_start_loc=query_start_loc,
seq_lens=seq_lens,
max_query_len=max_query_len,
max_seq_len=max_seq_len,
softmax_scale=HEAD_DIM**-0.5,
causal=False,
)
ref = _dense_attention(
query,
torch.cat([history_k, current_k], dim=1),
torch.cat([history_v, current_v], dim=1),
)
torch.testing.assert_close(paged, ref, rtol=1e-5, atol=1e-5)
@pytest.mark.skipif(not _gpu_flash_attn_usable(), reason="usable GPU FlashAttention is required")
@pytest.mark.parametrize("commit_current", [False, True])
def test_the_shipped_resolution_geometry_on_the_real_kernel(commit_current):
"""The shipped 832x480 geometry through FlashAttention's paged kernel.
The CPU test above proves the addressing is right against a dense
reference. This one proves the real kernel agrees, at the real 1560
tokens per frame, through the production path -- host prep followed by
the fused write+attend op, which is what a forward actually calls.
"""
torch.manual_seed(0)
device = torch.device("cuda")
dtype = torch.float16
tokens_per_frame = (480 // 16) * (832 // 16)
assert tokens_per_frame == 1560
kv, st = make_state(dtype=dtype, device=device, window_chunks=2, chunk_size=tokens_per_frame)
history_k, history_v = _commit_video_span(
kv, st, kv_branch=POS, n_chunks=1, dtype=dtype, device=device, chunk_size=tokens_per_frame
)
layer_ctx = st.get_kv_caches(POS, seq_len=tokens_per_frame, commit_current=commit_current)[0]
ctx = layer_ctx.forward_ctx
current_k = torch.randn(1, tokens_per_frame, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
current_v = torch.randn(1, tokens_per_frame, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
query = torch.randn(1, tokens_per_frame, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
ctx.prepare(device=device, action_len=0, query_len=tokens_per_frame)
assert ctx.start_offset == 8, "1560 % 16 == 8 is the whole reason this case exists"
assert kv.block_size == 16
inputs = layer_ctx.to_layer_inputs()
paged = paged_write_attn(inputs, query[0], current_k[0], current_v[0], None, None, HEAD_DIM**-0.5).unsqueeze(0)
new_k = torch.cat([history_k, current_k], dim=1)[:, -kv.spec.sliding_window :]
new_v = torch.cat([history_v, current_v], dim=1)[:, -kv.spec.sliding_window :]
ref = _dense_attention(query, new_k, new_v)
torch.testing.assert_close(paged, ref, rtol=2e-2, atol=2e-2)
@pytest.mark.skipif(not _gpu_flash_attn_usable(), reason="usable GPU FlashAttention is required")
@pytest.mark.parametrize("history_chunks", [0, 1, 3])
@pytest.mark.parametrize("action_len", [0, 3])
@@ -42,6 +42,7 @@ from vllm_omni.experimental.ar_diffusion.kv_cache.paged import (
chunk_slot_mapping,
pool_write_chunk,
resident_block_ids,
visible_window_blocks,
)
_log = init_logger(__name__)
@@ -282,7 +283,12 @@ class ARDiffusionKVCache:
# forward needs one block per current frame plus space for any
# model-declared action/state tokens that coexist with video KV.
declared_scratch_blocks = (max_scratch_tokens_per_branch + block_size - 1) // block_size
minimum_scratch_blocks = self.frames_per_block + declared_scratch_blocks
# frames_per_block counts *frames*; convert to blocks rather than
# assuming one block per frame. A frame spans ceil(chunk_size /
# block_size) blocks once the two sizes are allowed to differ.
blocks_per_frame = -(-config.chunk_size // block_size)
self.blocks_per_frame = blocks_per_frame
minimum_scratch_blocks = self.frames_per_block * blocks_per_frame + declared_scratch_blocks
override = os.environ.get("AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH")
override_blocks = int(override) if override is not None else 0
if override_blocks < 0:
@@ -313,8 +319,23 @@ class ARDiffusionKVCache:
)
def _required_managed_blocks(capacity: int) -> int:
resident_per_session = config.sink_chunks + config.window_chunks
return self.num_local_kv_branches * (capacity * resident_per_session + self.frames_per_block) + 2
"""Managed blocks needed to hold ``capacity`` resident sessions.
Every term here counts *frames*, so each is converted to blocks.
Before block size and frame size were allowed to differ this
conversion was the identity and the frame counts could be used
directly; they cannot once a frame spans several blocks.
Converting rather than rewriting keeps the budget the same number
of *tokens* it always was, so a finer block size does not inflate
the requirement -- it only stops the final block of each term from
rounding up a whole frame's worth of memory.
"""
resident_frames = config.sink_chunks + config.window_chunks
per_session_blocks = resident_frames * self.blocks_per_frame
in_flight_blocks = self.frames_per_block * self.blocks_per_frame
headroom_blocks = 2 * self.blocks_per_frame
return self.num_local_kv_branches * (capacity * per_session_blocks + in_flight_blocks) + headroom_blocks
# reuse_history_staging keeps one contiguous K and V buffer per layer,
# sized to the padded visible window (sink + window plus the
@@ -327,9 +348,15 @@ class ARDiffusionKVCache:
self.history_staging_tokens = 0
if config.reuse_history_staging:
if contiguous_kv_gather_enabled():
self.history_staging_tokens = (
self.spec.sliding_window + config.sink_chunks * config.chunk_size + block_size
# The same width build_block_table pads to: a sink or window
# that is not a whole number of blocks spans one more block.
window_blocks = visible_window_blocks(
chunk_size=config.chunk_size,
block_size=block_size,
sink_tokens=config.sink_chunks * config.chunk_size,
window_tokens=self.spec.sliding_window,
)
self.history_staging_tokens = (window_blocks + 1) * block_size
self.history_staging_reserved_bytes = int(
2 * num_layers * self.history_staging_tokens * num_kv_heads * head_size * dtype.itemsize
)
@@ -649,6 +676,16 @@ class ARDiffusionKVCache:
def block_table(self, adapter: ARDiffusionRequestAdapter) -> list[int]:
return list(self.manager.get_block_ids(adapter.request_id)[0])
def block_ids_at(self, adapter: ARDiffusionRequestAdapter, indices: Sequence[int]) -> list[int]:
"""Block ids at ``indices`` of the request's block table, without copying it.
The table keeps a null entry for every evicted position, so it grows with
the session. :meth:`block_table` reads every entry; this reads only the
ones asked for, straight from the manager's own block list.
"""
blocks = self.manager.get_blocks(adapter.request_id).blocks[0]
return [blocks[index].block_id for index in indices]
def chunk_write_slots(self, adapter: ARDiffusionRequestAdapter) -> torch.Tensor:
"""Slot mapping for the in-flight chunk — the K/V write target."""
return chunk_slot_mapping(
@@ -17,6 +17,7 @@ Cosmos port) reuses unchanged. Three concerns live here:
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass
@@ -196,6 +197,19 @@ def chunk_window_skipped_tokens(
return (skipped // chunk_size) * chunk_size
def visible_window_blocks(*, chunk_size: int, block_size: int, sink_tokens: int, window_tokens: int) -> int:
"""Most blocks a sink plus a recent window can span; see ``max_video_blocks``.
Each range is converted on its own: the sink ends at a fixed position, and
the recent range starts on a chunk boundary, so at most
``block_size - gcd(chunk_size, block_size)`` tokens past a block edge.
"""
worst_start_offset = block_size - math.gcd(chunk_size, block_size)
sink_blocks = -(-sink_tokens // block_size)
recent_blocks = -(-(window_tokens + worst_start_offset) // block_size)
return sink_blocks + recent_blocks
class ChunkWindowManager(SlidingWindowManager):
"""``SlidingWindowManager`` that evicts at chunk boundaries.
@@ -219,14 +233,26 @@ class ChunkWindowManager(SlidingWindowManager):
Call only after commit/eviction, then shift the request's storage
position by the returned amount. Physical pages and model positions
are unchanged, including any allocated but not yet committed tail.
The gap starts where :meth:`remove_skipped_blocks` starts freeing: after
the sink's blocks, which are ``sink_chunks`` only while a frame is a
block. Its removal is also rounded down to a whole number of chunks.
Eviction snaps to chunk boundaries and reads its position in storage
coordinates, and paged attention reads each block's offset there too;
shifting storage by whole chunks *and* whole blocks leaves both exactly
as they were in model positions. When a frame is a block, every gap
already is one.
"""
if self.enable_caching:
raise RuntimeError("AR block-table compaction requires prefix caching to be disabled")
blocks = self.req_to_blocks.get(request_id, [])
start = self.kv_cache_spec.sink_chunks
spec = self.kv_cache_spec
start = -(-(spec.sink_chunks * spec.chunk_size) // self.block_size)
end = start
while end < len(blocks) and blocks[end] == self._null_block:
end += 1
blocks_per_step = spec.chunk_size // math.gcd(spec.chunk_size, self.block_size)
end = start + (end - start) // blocks_per_step * blocks_per_step
if end == start:
return 0
del blocks[start:end]
@@ -241,17 +267,32 @@ class ChunkWindowManager(SlidingWindowManager):
total_computed_tokens: int,
num_prompt_tokens: int | None = None,
) -> None:
"""Free the middle gap while preserving the leading attention sink."""
"""Free the middle gap while preserving the leading attention sink.
``sink_chunks`` counts frames while the block table is indexed in
blocks, so the sink is converted before it is used as an index. The two
were interchangeable while a frame was a block; this commit is what
separates them.
Neither boundary is guaranteed to land on a block edge -- 832x480 is
1560 tokens per frame against a 16-token block -- so a block can hold
the end of one frame and the start of the next. The sink end rounds up
so a straddling block stays protected, and the freed range ends on a
rounded-down boundary so only wholly-skipped blocks are released. Both
are exact when the sizes divide, which is every geometry that paged one
frame per block before.
"""
del num_prompt_tokens
num_skipped_tokens = self.get_num_skipped_tokens(total_computed_tokens)
if num_skipped_tokens <= 0:
return
sink_blocks = self.kv_cache_spec.sink_chunks
num_skipped_blocks = num_skipped_tokens // self.block_size
sink_tokens = self.kv_cache_spec.sink_chunks * self.kv_cache_spec.chunk_size
first_live_block = -(-sink_tokens // self.block_size)
last_freed_block = (sink_tokens + num_skipped_tokens) // self.block_size
self._remove_blocks_in_range(
request_id,
sink_blocks,
sink_blocks + num_skipped_blocks,
first_live_block,
last_freed_block,
)
@@ -6,12 +6,15 @@ from __future__ import annotations
import weakref
from dataclasses import dataclass, field
from typing import Any, ClassVar, NamedTuple
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
import torch
from vllm_omni.experimental.ar_diffusion.kv_cache.config import contiguous_kv_gather_enabled
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import compute_slot_mapping
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import compute_slot_mapping, visible_window_blocks
if TYPE_CHECKING:
from vllm.v1.attention.backend import MultipleOf
_LAYER_IDX_TENSORS: dict[int, torch.Tensor] = {}
@@ -91,6 +94,21 @@ class ARDiffusionPagedForwardContext:
action_slot_mapping: torch.Tensor | None = None
query_len: int = 0
kv_len: int = 0
# Tokens already committed when this forward began. Recorded rather than
# derived from the block count: the last block of the history is only
# partly written when a chunk is not a whole number of blocks, so
# blocks * block_size overstates it and would make attention read past
# what was written.
_history_tokens: int = 0
# Scratch blocks the current video tokens occupy. Not the same as
# len(current_video_block_ids): when the history ends mid-block, the first
# entry there is the history's own tail block, which is managed, not
# scratch. Action K/V is placed after this, so it must not count it.
_scratch_blocks_used: int = 0
# Slot offset of this chunk's first token inside its first block. The
# chunk's blocks then hold positions ``history - offset + j * block_size``,
# which is how the read path places them among the history's blocks.
_current_offset: int = 0
_allocated_video: bool = False
_committed: bool = False
_action_len: int = 0
@@ -106,14 +124,77 @@ class ARDiffusionPagedForwardContext:
def block_size(self) -> int:
return int(self.kv_cache.block_size)
@property
def chunk_size(self) -> int:
"""Tokens in one chunk -- one latent frame. The eviction unit."""
return int(self.kv_cache.spec.chunk_size)
@property
def start_offset(self) -> int:
"""Where in its first block this forward's tokens begin.
Non-zero exactly when the committed history does not end on a block
boundary, which is the normal case once a frame is not a whole number
of blocks: 1560 tokens per frame against 16-token blocks leaves the
history 8 slots into its last block on every odd-numbered chunk.
"""
return int(self.adapter.num_computed_tokens) % self.block_size
@property
def sink_tokens(self) -> int:
"""Leading tokens the window keeps however far it has slid."""
return int(self.kv_cache.spec.sink_chunks) * self.chunk_size
@property
def window_tokens(self) -> int:
"""Most recent tokens the window keeps, this forward's own included."""
return self.max_video_tokens - self.sink_tokens
@property
def max_video_blocks(self) -> int:
"""Most blocks the visible window can span, which fixes the table width.
The window is two token ranges, the sink and the most recent tokens, and
each is converted to blocks on its own because each can straddle a block
edge on its own. Rounding the two ranges' *sum* up once was a block short
whenever both straddled: at 832x480 a 9-frame sink and a 9-frame window
are each 8 tokens past a 16-token edge but 28080 tokens together, a whole
number of blocks, so it was short on every tick once the window had
filled.
The sink ends at a fixed position, so its blocks are just its tokens
rounded up. The recent range starts at a whole number of chunks, so its
start sits at most ``block_size - gcd(chunk_size, block_size)`` tokens
past a block edge, and it can need that much more room. A chunk that is
a whole number of blocks never straddles, so every geometry that paged
one frame per block keeps exactly the width it had.
The width is a constant of the configuration, which is what keeps the
block table a fixed shape as the window fills.
"""
return visible_window_blocks(
chunk_size=self.chunk_size,
block_size=self.block_size,
sink_tokens=self.sink_tokens,
window_tokens=self.window_tokens,
)
@property
def num_current_video_blocks(self) -> int:
if self.seq_len % self.block_size != 0:
raise AssertionError(
"AR-Diffusion paged attention expects frame-aligned seq_len "
f"(multiple of block_size={self.block_size}), got {self.seq_len}"
)
return self.seq_len // self.block_size
"""Blocks this forward's tokens occupy, counted from where they start.
A chunk need not be a whole number of blocks, so the count depends on
the offset it begins at, not only on its length: thirty tokens
starting at position thirty span three sixteen-token blocks, not two.
Rounding up leaves the tail of the last block unwritten, which is only
safe because nothing reads past ``kv_len`` -- see
:meth:`video_block_table`.
Both paths count the same way. The committing path writes straight
after the history; the scratch path is made to line up with it by
:meth:`ensure_video_slots`.
"""
return -(-(self.start_offset + self.seq_len) // self.block_size)
def ensure_video_slots(self, device: torch.device) -> None:
"""Allocate/write targets for the current video tokens, once per KV branch."""
@@ -121,8 +202,10 @@ class ARDiffusionPagedForwardContext:
return
n_blocks = self.num_current_video_blocks
self._history_tokens = int(self.adapter.num_computed_tokens)
if self.commit_current:
start = int(self.adapter.num_computed_tokens)
self._current_offset = start % self.block_size
self.kv_cache.allocate_token_slots(self.adapter, self.seq_len)
table = self.kv_cache.block_table(self.adapter)
start_block = start // self.block_size
@@ -132,8 +215,27 @@ class ARDiffusionPagedForwardContext:
compute_slot_mapping(table, positions, self.block_size), device
)
else:
self.current_video_block_ids = self.kv_cache.scratch_block_ids(self.kv_branch, 0, n_blocks)
positions = torch.arange(self.seq_len, dtype=torch.long)
# The scratch region cannot simply start at slot zero. The kernel
# reads the history and this chunk as one contiguous run, so if the
# history stopped mid-block, starting here at zero would leave the
# rest of that block unwritten and the kernel would read those dead
# slots as if they were tokens, shifting the whole sequence.
#
# Instead the chunk begins where the history left off, spilling its
# first few tokens into the free tail of the history's own last
# block. That block is already this session's -- blocks are
# allocated whole -- and those slots hold nothing yet; the
# committing forward overwrites the same slots with the clean K/V
# later, so the committed state is unchanged either way.
# A history whose tail block is no longer resident has nothing to
# spill into; the window then starts at this chunk and zero is right.
offset = self.start_offset if self.history_block_ids else 0
self._current_offset = offset
self._scratch_blocks_used = n_blocks - (1 if offset else 0)
scratch_ids = self.kv_cache.scratch_block_ids(self.kv_branch, 0, self._scratch_blocks_used)
tail_block = [self.history_block_ids[-1]] if offset else []
self.current_video_block_ids = tail_block + scratch_ids
positions = torch.arange(offset, offset + self.seq_len, dtype=torch.long)
self.current_video_slot_mapping = _to_device_async(
compute_slot_mapping(self.current_video_block_ids, positions, self.block_size), device
)
@@ -152,7 +254,9 @@ class ARDiffusionPagedForwardContext:
return
action_blocks = (action_len + self.block_size - 1) // self.block_size
scratch_offset = 0 if self.commit_current else len(self.current_video_block_ids)
# Count scratch blocks, not entries: the video list may lead with the
# history's tail block, which lives in the managed pool.
scratch_offset = 0 if self.commit_current else self._scratch_blocks_used
self.action_scratch_block_ids = self.kv_cache.scratch_block_ids(
self.kv_branch,
scratch_offset,
@@ -165,23 +269,75 @@ class ARDiffusionPagedForwardContext:
self._action_len = action_len
def video_block_table(self, device: torch.device) -> tuple[list[int], int]:
"""Blocks the attention reads, and how many of their slots it reads.
The kernel reads the listed blocks as one contiguous run and stops after
the returned length, so two things must hold: every block holding a
token the window keeps is listed, in token order, and only the last
listed block may be partly written.
Blocks are chosen by the token positions they hold rather than counted
off the ends of the resident list. The window is the sink ``[0, S)``
plus the most recent tokens, and each of those can straddle a block
edge on its own. Taking a fixed number of blocks off the tail dropped
the block holding the window's first tokens exactly when both
straddled, and a length capped by a block count rather than counted
from what was written then ran past the last written slot.
Whole blocks are read, so each boundary block contributes up to
``block_size - 1`` tokens the window has already let go of. They are
real K/V at their own positions; a slightly wider window is a far
smaller error than a truncated one.
"""
self.ensure_video_slots(device)
if self.max_video_tokens % self.block_size != 0:
raise AssertionError(
"AR-Diffusion paged attention requires max_video_tokens to be block-aligned, "
f"got max_video_tokens={self.max_video_tokens}, block_size={self.block_size}"
)
all_video_blocks = self.history_block_ids + self.current_video_block_ids
max_video_blocks = self.max_video_tokens // self.block_size
sink_blocks = int(self.kv_cache.spec.sink_chunks)
if len(all_video_blocks) <= max_video_blocks:
visible_video_blocks = all_video_blocks
else:
tail_blocks = max_video_blocks - sink_blocks
visible_video_blocks = all_video_blocks[:sink_blocks]
if tail_blocks:
visible_video_blocks += all_video_blocks[-tail_blocks:]
video_len = len(visible_video_blocks) * self.block_size
block_size = self.block_size
history = self._history_tokens
end = history + self.seq_len
sink_end = self.sink_tokens
recent_start = end - self.window_tokens
# Position of each block's first slot. History comes from the block
# table, where an evicted block is a null placeholder, so a block's index
# is its position -- in storage positions, which is what
# num_computed_tokens counts once compaction has dropped an evicted gap.
# Compaction shifts storage by whole chunks and whole blocks, so the sink,
# the window and every block offset sit exactly where they would in model
# positions. Reading the table after ensure_video_slots means a block
# this chunk's own allocation evicted is never listed. The current
# chunk's blocks follow from where its first token was placed; its first
# block is the history's own tail block whenever the history stopped
# part way through one, and keeps the history's position.
first_position: dict[int, int] = {}
#
# Only two index ranges can hold a token the window keeps: the sink's
# blocks, and the blocks from the recent window's start onward. Evicted
# positions stay in the table as null entries until compaction drops
# them a whole number of chunks at a time, so the table can still be
# much longer than the window; reading just these two ranges, without
# copying the table, keeps this at the window's size.
history_blocks = -(-history // block_size)
sink_blocks = min(-(-sink_end // block_size), history_blocks)
recent_first_block = max(max(recent_start, 0) // block_size, sink_blocks)
indices = [*range(sink_blocks), *range(recent_first_block, history_blocks)]
for index, block in zip(indices, self.kv_cache.block_ids_at(self.adapter, indices)):
if block != self.kv_cache.null_block_id:
first_position.setdefault(block, index * block_size)
current_start = history - self._current_offset
for index, block in enumerate(self.current_video_block_ids):
first_position.setdefault(int(block), current_start + index * block_size)
visible_video_blocks: list[int] = []
video_len = 0
for block, first in sorted(first_position.items(), key=lambda item: item[1]):
if first >= sink_end and first + block_size <= recent_start:
continue # wholly between the sink and the recent window
if video_len % block_size:
raise RuntimeError(
"AR-Diffusion paged attention would read past a partly written block: "
f"block {visible_video_blocks[-1]} is followed by block {block}."
)
visible_video_blocks.append(block)
video_len += min(block_size, end - first)
return visible_video_blocks, video_len
def build_block_table(
@@ -200,6 +356,21 @@ class ARDiffusionPagedForwardContext:
``ceil(seq_lens/block_size)`` entries, so padding is never read.
"""
video_blocks, video_len = self.video_block_table(device)
# Action K/V is listed after the video blocks and the kernel reads the
# table as one run, so the video run has to end on a block edge. If it
# stops part way through its last block, the run reads that block's
# unwritten slots as the first action tokens and never reaches the last
# ones -- a wrong attention with every shape intact. A frame that is a
# whole number of blocks always ends on an edge, so this refuses only
# geometries that could not page at all before the paging unit was
# separated from the frame.
if action_len > 0 and video_len % self.block_size:
raise ValueError(
"AR-Diffusion paged attention cannot place action tokens after a partly written video block: "
f"the video run ends {video_len % self.block_size} slots into block {video_blocks[-1]}, so the "
"kernel would read its unwritten slots as action tokens. Action tokens need tokens_per_frame "
f"to be a whole number of blocks (block_size={self.block_size})."
)
self.ensure_action_slots(action_len, device)
action_blocks = self.action_scratch_block_ids if action_len > 0 else []
block_ids = video_blocks + action_blocks
@@ -208,12 +379,19 @@ class ARDiffusionPagedForwardContext:
# Fixed capacity: full visible video window + one action-capacity block.
action_capacity_blocks = max(1, (action_len + self.block_size - 1) // self.block_size)
width = max(self.max_video_tokens // self.block_size + action_capacity_blocks, len(block_ids))
# Both of these count in blocks. Deriving them from the token count by
# flooring would understate the capacity whenever the window is not a
# whole number of blocks: the width would fall back on len(block_ids)
# and start tracking how full the window is -- the exact shape churn
# the fixed width exists to prevent -- and max_seq_len could come out
# below the kv_len actually being passed, by up to block_size - 1.
capacity_blocks = self.max_video_blocks + action_capacity_blocks
width = max(capacity_blocks, len(block_ids))
padded = block_ids + [0] * (width - len(block_ids))
self.query_len = int(query_len)
self.kv_len = int(video_len + action_len)
max_seq_len = int(self.max_video_tokens + action_capacity_blocks * self.block_size)
max_seq_len = int(capacity_blocks * self.block_size)
# Built on the host once per AR block; the copies go through the same
# pinned, non-blocking path as the slot mappings above so the CPU does
# not wait for the stream to drain three times before the first layer.
@@ -281,7 +459,11 @@ class ARDiffusionPagedForwardContext:
int(self.seq_len),
)
same_session = state.adapter is not None and state.adapter() is self.adapter
reuse = same_session and state.signature == signature
# A chunk that is not a whole number of blocks shares its first block with the history's tail (see
# ensure_video_slots), and the attention op restages the current chunk in whole blocks of its own,
# so such a forward restages the whole window instead.
block_aligned = self.start_offset == 0 and self.seq_len % self.block_size == 0
reuse = same_session and state.signature == signature and block_aligned
state.adapter = weakref.ref(self.adapter)
state.signature = signature
self.staging_enabled = True
@@ -461,6 +643,36 @@ def _resolve_fa_version(head_size: int) -> int:
return version
def supported_kernel_block_sizes() -> list[int | MultipleOf]:
"""Block sizes the kernel this module dispatches to will accept.
Same shape as vLLM's ``AttentionBackend.get_supported_kernel_block_sizes``
-- a plain int is that exact size, ``MultipleOf(b)`` is any positive
multiple of ``b`` -- but answered here rather than read off a backend,
because AR-Diffusion does not go through backend selection. It calls
``flash_attn_varlen_func`` itself, choosing between vLLM's CUDA build and
ROCm's AITER a few lines below, and only this module knows which.
It lives next to that choice so there is one place to change. The
constraint is a property of the kernel, not of the card, and the kernels
reachable from here agree on 16 today: vLLM's CUDA FlashAttention, ROCm
AITER, and upstream ``flash_attn`` all advertise ``MultipleOf(16)``. Other
backends in the same tree do not -- ``hpc_attn`` accepts only 64, and
FlashInfer advertises pages of 128 or more solely on Blackwell -- so a
caller must treat this as data to be queried, never as the number 16.
vLLM's FlashAttention backend does advertise a single 128-token page when FA4
runs its dedicated head-size-256 kernel on SM100/SM110. That kernel is not
reachable from here: ``get_flash_attn_version`` selects it only when the
caller passes ``supports_fa4_hd256=True``, and :func:`_resolve_fa_version`
does not, so a head size of 256 falls back to FA2, which pages at any
multiple of 16. Passing that flag later has to change this answer as well.
"""
from vllm.v1.attention.backend import MultipleOf
return [MultipleOf(16)]
def _rocm_flash_attn_varlen_func():
"""Resolve a ROCm varlen kernel, preferring AITER when available.
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Model-facing session state backed by paged AR-Diffusion KV storage."""
from __future__ import annotations
+55 -2
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import dataclasses
import time
from collections import OrderedDict
from collections.abc import Iterator
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from typing import TYPE_CHECKING
import torch
from vllm.logger import init_logger
@@ -29,9 +30,61 @@ from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVStat
from vllm_omni.experimental.ar_diffusion.tick_protocol import ARDiffusionTickRequest
from vllm_omni.platforms import current_omni_platform
if TYPE_CHECKING:
from vllm.v1.attention.backend import MultipleOf
logger = init_logger(__name__)
def paging_block_size(tokens_per_frame: int, supported: Sequence[int | MultipleOf] | None = None) -> int:
"""Choose the paging unit, which is not the eviction unit.
AR-Diffusion evicts by frame; the attention kernel tiles by a fixed number
of tokens. Using the frame as the block binds every output resolution to
that kernel constraint, so the resolutions that run today do so by
arithmetic accident -- and the LingBot World v2 checkpoint's own default
832x480 is not one of them:
(480/16) x (832/16) = 30 x 52 = 1560 tokens per frame, 1560 % 16 = 8
A frame that is already a legal block stays one, so every resolution that
works today keeps precisely the paging it has now. Anything else pages at
the finest legal unit, which costs a longer block table and gives back the
rounding waste of a block one whole frame wide -- at 832x480 that block is
over a gigabyte.
What counts as legal is asked, not assumed. ``supported`` carries vLLM's
own vocabulary: a plain int is that exact size, ``MultipleOf(b)`` is any
positive multiple of ``b``. The kernels AR-Diffusion reaches agree on
multiples of 16, but others in the same tree do not -- ``hpc_attn`` takes
only 64, and FlashInfer offers pages of 128 and up on Blackwell alone. A
constant here would be a silently wrong block size on the first machine
whose kernel differs, and a wrong block size is not an error: it is a
kernel reading tokens that were never written.
"""
if supported is None:
# Imported here, not at module scope: paged_attention registers a
# custom op on import, and the tests that pop it from sys.modules to
# re-import it depend on nothing else holding it first.
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import supported_kernel_block_sizes
supported = supported_kernel_block_sizes()
if not supported:
raise ValueError("The attention kernel advertises no legal block size.")
def legal(size: int) -> bool:
return size > 0 and any(
size == entry if isinstance(entry, int) else size % entry.base == 0 for entry in supported
)
if legal(tokens_per_frame):
return tokens_per_frame
finest = min(entry if isinstance(entry, int) else entry.base for entry in supported)
if not legal(finest):
raise ValueError(f"No legal paging unit among {supported!r}.")
return finest
def resolve_ar_diffusion_kv_config(od_config: OmniDiffusionConfig) -> ARDiffusionKVConfig:
"""Resolve optional deployment overrides and enable AR-Diffusion KV."""
raw = getattr(od_config, "ar_diffusion_kv_config", None)
@@ -139,7 +192,7 @@ class ARDiffusionModelRunner(DiffusionModelRunner):
num_kv_heads=spec.num_kv_heads,
head_size=spec.head_size,
dtype=self.od_config.dtype,
block_size=spec.tokens_per_frame,
block_size=paging_block_size(spec.tokens_per_frame),
max_model_len=spec.max_model_len,
available_bytes=self._available_memory_bytes() if available_bytes is None else available_bytes,
kv_branches=spec.kv_branches,