[LingBot World] Eleven bit-identical removals of repeated work in the realtime path (#7648)

Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Zhou TaichangandClaude Fable 5.1 authored and GitHub committed 2026-09-21 06:12:45 +00:00
1 parent a9e2117f5e
commit 86fcfe95f1
23 files changed
+1957 -200

No files matched your search

@@ -70,6 +70,7 @@ The lifecycle labels used below are:
| `OMNI_DIFFUSION_PROMPT_EMBED_CACHE_SIZE` | Positive integer; default `32` entries | Each diffusion runner; resolved during model setup | A valid environment value overrides the explicit cache size. A non-integer logs a warning; a non-positive value is ignored. | Experimental |
| `OMNI_DIFFUSION_SESSION_STATE_MANAGER` | Boolean with the same accepted spellings as the prompt cache; default disabled | Experimental diffusion session manager; model setup | A recognized environment value overrides the explicit enable setting. An unrecognized value is ignored. | Experimental |
| `OMNI_DIFFUSION_SESSION_STATE_MANAGER_MAX_SESSIONS` | Positive integer; default `64` | Experimental diffusion session manager; model setup | A valid environment value overrides the explicit maximum. A non-integer logs a warning; a non-positive value is ignored. | Experimental |
| `VLLM_OMNI_AR_DIFFUSION_KV_GATHER` | `1` enables; default `0` (off) | AR-diffusion KV manager at allocation and attention at dispatch; set before worker startup | Only the exact value `1` enables contiguous KV gathering. History staging additionally requires `ARDiffusionKVConfig.reuse_history_staging`. | Experimental |
Backend names for `DIFFUSION_ATTENTION_BACKEND` are the members of
`DiffusionAttentionBackendEnum`, such as `FLASH_ATTN`, `TORCH_SDPA`,
+1 -1
View File
@@ -164,7 +164,7 @@ def test_inventory_matches_reviewed_snapshot_counts():
"""Make an inventory expansion an explicit review decision."""
category_counts = Counter(item.category for item in ENVIRONMENT_VARIABLE_INVENTORY.values())
assert category_counts == {
EnvironmentVariableCategory.PUBLIC_OMNI: 28,
EnvironmentVariableCategory.PUBLIC_OMNI: 29,
EnvironmentVariableCategory.INHERITED_VLLM: 20,
EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27,
EnvironmentVariableCategory.MODEL_SPECIFIC: 57,
+103 -1
View File
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for the AR-Diffusion KV cache helpers (Phase 1, PR-2).
Covers the request adapter, the chunk-window spec/manager (registration + the
@@ -269,9 +270,14 @@ def test_compiled_paged_write_does_not_clone_the_pool():
# drives the reinplace pass, so a drift here would silently test nothing.
real_schema = torch.ops.vllm_omni.ar_diffusion_paged_write_attn.default._schema
real_mutated = [arg.name for arg in real_schema.arguments if arg.alias_info and arg.alias_info.is_write]
assert real_mutated == ["key_pool", "value_pool"], (
# The staging buffers are mutated too when reuse_history_staging is on, but they are separate
# allocations and not what this test is about; the pools are what must not be cloned.
assert real_mutated[:2] == ["key_pool", "value_pool"], (
f"the real paged-write op now mutates {real_mutated}; this test still models two pools"
)
assert set(real_mutated) - {"key_pool", "value_pool"} <= {"stage_key", "stage_value"}, (
f"unexpected mutated argument in the real paged-write op: {real_mutated}"
)
num_blocks, heads, dim = 8, 4, 16
cache_shape = (num_blocks, BLOCK, heads, dim)
@@ -357,6 +363,7 @@ def _make_tiny_capacity_kv(
available_bytes: int,
gpu_memory_fraction: float = 1.0,
model_owned_state_bytes_per_session: int = 0,
reuse_history_staging: bool = False,
) -> ARDiffusionKVCache:
return ARDiffusionKVCache(
ARDiffusionKVConfig(
@@ -365,6 +372,7 @@ def _make_tiny_capacity_kv(
window_chunks=3,
sink_chunks=3,
gpu_memory_fraction=gpu_memory_fraction,
reuse_history_staging=reuse_history_staging,
),
num_layers=1,
num_kv_heads=1,
@@ -382,6 +390,33 @@ def _make_tiny_capacity_kv(
)
def test_history_staging_is_reserved_in_the_kv_budget(monkeypatch):
"""The per-layer staging pair is worker-wide memory allocated after admission; the budget must hold it."""
monkeypatch.setenv("VLLM_OMNI_AR_DIFFUSION_KV_GATHER", "1")
# Same geometry as the capacity-two test: 192 bytes fits two sessions exactly without staging.
plain = _make_tiny_capacity_kv(requested_capacity=2, available_bytes=192)
assert plain.session_capacity == 2 and plain.history_staging_reserved_bytes == 0
staged = _make_tiny_capacity_kv(requested_capacity=2, available_bytes=192, reuse_history_staging=True)
# 2 (K, V) x 1 layer x (sink 3 + window 3 + one action-capacity block) tokens x 1 head x 1 dim x 4 bytes.
assert staged.history_staging_reserved_bytes == 2 * 1 * 7 * 1 * 1 * 4
# It comes out of the same budget, so the second session no longer fits.
assert staged.session_capacity == 1
assert staged.num_blocks_total * 8 + staged.history_staging_reserved_bytes <= 192
# Boundary: one session fits at 128 bytes without staging; with staging the same budget is rejected
# up front instead of failing on the first forward.
_make_tiny_capacity_kv(requested_capacity=1, available_bytes=128)
with pytest.raises(ValueError, match="cannot fit one session"):
_make_tiny_capacity_kv(requested_capacity=1, available_bytes=128, reuse_history_staging=True)
def test_history_staging_is_not_reserved_when_the_gather_path_is_off(monkeypatch):
monkeypatch.delenv("VLLM_OMNI_AR_DIFFUSION_KV_GATHER", raising=False)
staged = _make_tiny_capacity_kv(requested_capacity=2, available_bytes=192, reuse_history_staging=True)
assert staged.history_staging_reserved_bytes == 0 and staged.session_capacity == 2
def test_capacity_two_retains_both_windows_and_allocates_next_block():
# LingBot-like single-branch geometry: page=8 bytes; capacity=2 requires
# 17 managed + 3 scratch blocks, plus two 16-byte cross-attention
@@ -588,3 +623,70 @@ def test_non_contiguous_branch_indices_rejected():
kv_branches=(ARDiffusionKVBranchSpec("main", 1),),
session_capacity=1,
)
def test_staged_window_reuse_matches_a_full_gather():
"""Reusing the staged history must be byte-identical to re-gathering the whole window.
This is the property the optimisation rests on: the leading blocks were staged by an earlier forward of
the same AR block, so skipping them can only be correct if what is already there equals what a fresh
gather would write. The test stages once, mutates only the current chunk's blocks in the pool (what a
later probe of the same block does), restages the tail alone, and compares against a full gather.
"""
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import _stage_window
torch.manual_seed(0)
num_blocks, block_size, heads, dim = 6, 4, 2, 8
current_blocks = 2
key_cache = torch.randn(num_blocks, block_size, heads, dim)
value_cache = torch.randn(num_blocks, block_size, heads, dim)
block_ids = torch.tensor([5, 4, 3, 2, 1, 0])
stage_key = torch.zeros(num_blocks * block_size, heads, dim)
stage_value = torch.zeros(num_blocks * block_size, heads, dim)
_stage_window(stage_key, stage_value, key_cache, value_cache, block_ids, num_blocks, block_size, first_block=0)
# A later probe rewrites only the current chunk's blocks in the pool.
for slot in block_ids[num_blocks - current_blocks :]:
key_cache[slot] = torch.randn(block_size, heads, dim)
value_cache[slot] = torch.randn(block_size, heads, dim)
_stage_window(
stage_key,
stage_value,
key_cache,
value_cache,
block_ids,
num_blocks,
block_size,
first_block=num_blocks - current_blocks,
)
full_key = torch.zeros_like(stage_key)
full_value = torch.zeros_like(stage_value)
_stage_window(full_key, full_value, key_cache, value_cache, block_ids, num_blocks, block_size, first_block=0)
torch.testing.assert_close(stage_key, full_key, rtol=0, atol=0)
torch.testing.assert_close(stage_value, full_value, rtol=0, atol=0)
def test_staged_window_tail_refresh_leaves_history_untouched():
"""The tail restage must not write the history rows -- that is what makes it cheaper."""
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import _stage_window
torch.manual_seed(1)
num_blocks, block_size, heads, dim = 5, 4, 1, 4
key_cache = torch.randn(num_blocks, block_size, heads, dim)
value_cache = torch.randn(num_blocks, block_size, heads, dim)
block_ids = torch.arange(num_blocks)
stage_key = torch.full((num_blocks * block_size, heads, dim), -1.0)
stage_value = torch.full((num_blocks * block_size, heads, dim), -1.0)
_stage_window(
stage_key, stage_value, key_cache, value_cache, block_ids, num_blocks, block_size, first_block=num_blocks - 1
)
history_rows = (num_blocks - 1) * block_size
assert (stage_key[:history_rows] == -1.0).all(), "history rows were rewritten by a tail restage"
assert (stage_value[:history_rows] == -1.0).all()
torch.testing.assert_close(stage_key[history_rows:], key_cache[-1].reshape(block_size, heads, dim), rtol=0, atol=0)
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for AR-Diffusion paged self-attention contexts."""
from __future__ import annotations
@@ -20,6 +21,8 @@ from vllm_omni.experimental.ar_diffusion.kv_cache import (
ar_diffusion_paged_attention,
paged_write_attn,
)
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.state import ARDiffusionKVState
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@@ -32,8 +35,12 @@ POS = "positive"
NEG = "negative"
def make_state(*, num_layers=1, window_chunks=2, dtype=torch.float32, device=torch.device("cpu")):
cfg = ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks)
def make_state(
*, num_layers=1, window_chunks=2, dtype=torch.float32, device=torch.device("cpu"), reuse_history_staging=False
):
cfg = ARDiffusionKVConfig(
enable=True, chunk_size=BLOCK, window_chunks=window_chunks, reuse_history_staging=reuse_history_staging
)
kv = ARDiffusionKVCache(
cfg,
num_layers=num_layers,
@@ -236,6 +243,86 @@ def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len,
assert st.adapter(POS).completed_chunks == before + (1 if commit_current else 0)
@pytest.mark.parametrize("history_chunks", [0, 1, 2, 3])
def test_staged_reuse_refreshes_the_current_blocks_at_their_live_offset(monkeypatch, history_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
growing in unused window capacity too, so "the last current_blocks entries" is padding. The refresh
has to start right after the visible history: empty (window growing), partial, full, and after a slide.
"""
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)
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.ensure_video_slots(device)
key_cache, value_cache = kv.key_cache(0), kv.value_cache(0)
def probe(step: int):
# Each probe of the block writes new current K/V, then stages.
kv._k_pools[0][ctx.current_video_slot_mapping] = torch.full((BLOCK, N_HEADS, HEAD_DIM), float(step))
kv._v_pools[0][ctx.current_video_slot_mapping] = torch.full((BLOCK, N_HEADS, HEAD_DIM), -float(step))
# Store the metadata on the context the way prepare() does.
(ctx.block_table, ctx.query_start_loc, ctx.seq_lens, ctx.max_query_len, ctx.max_seq_len) = (
ctx.build_block_table(action_len=0, query_len=BLOCK, device=device)
)
block_table, max_seq_len = ctx.block_table, ctx.max_seq_len
ctx._prepare_history_staging(0)
n_blocks = max_seq_len // BLOCK
block_ids = block_table[0, :n_blocks].to(torch.long)
stage_k, stage_v = ctx.history_staging(0)
paged_attention_module._stage_window(
stage_k,
stage_v,
key_cache,
value_cache,
block_ids,
n_blocks,
BLOCK,
first_block=ctx.stage_first_block if ctx.reuse_history else 0,
)
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)
return stage_k, stage_v, full_k, full_v, n_blocks
stage_k, stage_v, full_k, full_v, n_blocks = probe(1)
assert ctx.reuse_history is False
assert torch.equal(stage_k, full_k) and torch.equal(stage_v, full_v)
stage_k, stage_v, full_k, full_v, n_blocks = probe(2)
assert ctx.reuse_history is True
visible_history_blocks = min(history_chunks, 2 - 1) # window of 2 blocks minus the current one
assert ctx.stage_first_block == visible_history_blocks
# The padded table is wider than the live window, so the end-of-table guess is not the offset.
assert n_blocks - 1 != ctx.stage_first_block
assert torch.equal(stage_k, full_k) and torch.equal(stage_v, full_v)
@pytest.mark.parametrize("gather_enabled", [False, True])
def test_staging_is_only_allocated_for_the_gather_path(monkeypatch, gather_enabled):
if gather_enabled:
monkeypatch.setenv(KV_GATHER_ENV, "1")
else:
monkeypatch.delenv(KV_GATHER_ENV, raising=False)
device = torch.device("cpu")
kv, st = make_state(device=device, reuse_history_staging=True)
# The manager owns the pairs and allocates them only when their consumer is on.
assert bool(kv.history_staging) is gather_enabled
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx
ctx.ensure_video_slots(device)
(ctx.block_table, ctx.query_start_loc, ctx.seq_lens, ctx.max_query_len, ctx.max_seq_len) = ctx.build_block_table(
action_len=0, query_len=BLOCK, device=device
)
ctx._prepare_history_staging(0)
assert ctx.staging_enabled is gather_enabled
stage_k, _ = ctx.history_staging(0)
assert (stage_k is not None) is gather_enabled
@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])
@@ -376,13 +463,27 @@ def test_custom_op_registration_idempotent():
assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn")
def test_custom_op_compiles_fullgraph_without_recompile_on_value_change():
def test_custom_op_mutable_arguments_cannot_be_elided_as_defaults():
# Older PyTorch ADInplaceOrView handlers index positional mutable inputs
# directly. Default-valued trailing inputs can disappear before that handler.
schema = torch.ops.vllm_omni.ar_diffusion_paged_write_attn.default._schema
mutable = [arg for arg in schema.arguments if arg.alias_info is not None and arg.alias_info.is_write]
assert {arg.name for arg in mutable} == {"key_pool", "value_pool", "stage_key", "stage_value"}
assert all(not arg.has_default_value() for arg in mutable)
@pytest.mark.parametrize("reuse_history_staging", [False, True])
def test_custom_op_compiles_fullgraph_without_recompile_on_value_change(monkeypatch, reuse_history_staging):
"""The op must trace as one opaque node: fullgraph OK, and changed tensor
VALUES (new slots / block ids) must not trigger recompilation."""
import torch._dynamo
device = torch.device("cpu")
kv, st = make_state(num_layers=2, window_chunks=2)
monkeypatch.setenv(KV_GATHER_ENV, "1")
kv, st = make_state(num_layers=2, window_chunks=2, reuse_history_staging=reuse_history_staging)
if reuse_history_staging:
# Hold the host-side staging offset fixed while table/slot values change.
_commit_video_span(kv, st, kv_branch=POS, n_chunks=2, dtype=torch.float32, device=device)
def run_one_forward(commit):
contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=commit)
@@ -391,9 +492,8 @@ def test_custom_op_compiles_fullgraph_without_recompile_on_value_change():
q = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
k = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
v = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
# Both layers through ONE compiled fn: layer_idx is a tensor, so a
# different layer must NOT recompile (all 40 DiT blocks share the
# block-forward code object in production).
# Both layers use one compiled function; the tensor-valued layer index
# must not specialize it.
for layer_ctx in contexts:
out = compiled(layer_ctx.to_layer_inputs(), q, k, v)
st.commit_paged_context(POS)
@@ -419,3 +519,52 @@ def test_custom_op_compiles_fullgraph_without_recompile_on_value_change():
# Leave a clean dynamo state for later suites in the same pytest process
# (e.g. model_executor transformers models).
torch._dynamo.reset()
@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])
def test_contiguous_kv_gather_path_matches_paged_path_gpu(monkeypatch, history_chunks, action_len):
"""VLLM_OMNI_AR_DIFFUSION_KV_GATHER=1 gathers the visible blocks and runs varlen FA3 without a block table.
Covers an empty, partial and full window plus a partially filled action block:
the gather reads the tail-padding null block, so its (zeroed) rows must be masked.
"""
torch.manual_seed(0)
device = torch.device("cuda")
dtype = torch.float16
kv, st = make_state(dtype=dtype, device=device, window_chunks=2)
if history_chunks:
_commit_video_span(kv, st, kv_branch=POS, n_chunks=history_chunks, dtype=dtype, device=device)
layer_ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0]
ctx = layer_ctx.forward_ctx
current_k = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
current_v = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
action_k = action_v = None
if action_len:
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)
query = torch.randn(1, BLOCK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
ctx.prepare(device=device, action_len=action_len, query_len=query.shape[1])
inputs = layer_ctx.to_layer_inputs()
def run() -> torch.Tensor:
return paged_write_attn(
inputs,
query[0],
current_k[0],
current_v[0],
action_k[0] if action_len else None,
action_v[0] if action_len else None,
HEAD_DIM**-0.5,
).unsqueeze(0)
monkeypatch.delenv(KV_GATHER_ENV, raising=False)
paged = run()
monkeypatch.setenv(KV_GATHER_ENV, "1")
gathered = run()
assert torch.isfinite(gathered).all()
# Same kernel family on the same K/V: only accumulation order differs.
torch.testing.assert_close(gathered, paged, rtol=2e-3, atol=2e-3)
+43
View File
@@ -461,3 +461,46 @@ def test_ring_p2p_parity(world_size: int, dtype: torch.dtype):
device_kind="cuda",
master_port=29501,
)
def _run_fused_qkv_matches_three_4d(
local_rank: int,
world_size: int,
device_kind: DeviceKind,
master_port: int,
) -> None:
"""LingBot's fused (B, S/N, 3, H, D) exchange must equal three per-tensor 4D exchanges."""
from vllm_omni.diffusion.distributed.comm import all_to_all_5D
device = _worker_device(local_rank, device_kind)
if device_kind == "cuda":
current_omni_platform.set_device(device)
_init_worker(local_rank, world_size, master_port, device_kind)
initialize_model_parallel(ulysses_degree=world_size)
sp_group = get_sp_group().ulysses_group
try:
batch, seq_per_rank, heads, head_size = 1, 6, 4 * world_size, 8
# Distinguishable values: rank, q/k/v index, position, head, feature.
base = torch.arange(seq_per_rank * heads * head_size, dtype=torch.float32, device=device).reshape(
batch, seq_per_rank, heads, head_size
)
tensors = [base + 1000.0 * local_rank + 100000.0 * which for which in range(3)]
separate = [SeqAllToAll4D.apply(sp_group, t, 2, 1, False) for t in tensors]
fused = all_to_all_5D(torch.stack(tensors, dim=2), scatter_idx=3, gather_idx=1, group=sp_group)
assert fused.shape == (batch, seq_per_rank * world_size, 3, heads // world_size, head_size)
for which, expected in enumerate(separate):
assert torch.equal(fused.unbind(2)[which], expected), f"q/k/v index {which} differs on rank {local_rank}"
finally:
destroy_distributed_env()
@pytest.mark.core_model
@pytest.mark.diffusion
@pytest.mark.cpu
@pytest.mark.parametrize("world_size", [2, 4])
def test_fused_qkv_all_to_all_matches_three_4d_exchanges_cpu(world_size: int) -> None:
torch.multiprocessing.spawn(
_run_fused_qkv_matches_three_4d,
args=(world_size, "cpu", 29613),
nprocs=world_size,
)
@@ -356,6 +356,94 @@ def test_attention_wrapper_rejects_spatial_extent_change(monkeypatch: pytest.Mon
wan_spatial_shard._SPATIAL_SHARD_CONTEXT.reset(token)
def _reference_conv_input(conv, x, cache_x):
"""The conv input the previous implementation built: cat, F.pad, then halo_exchange."""
padding = list(conv._padding)
if cache_x is not None and padding[4] > 0:
x = torch.cat([cache_x, x], dim=2)
padding[4] -= cache_x.shape[2]
x = torch.nn.functional.pad(x, padding)
x_padded, _, _ = wan_spatial_shard.halo_exchange(
x, group=object(), halo_size=conv.halo_size, split_dim=conv.split_dim
)
return x_padded, x.shape[conv.split_tensor_dim]
def _dist_conv(split_dim, kernel=(3, 3, 3), padding=(1, 1, 1)):
from diffusers.models.autoencoders.autoencoder_kl_wan import WanCausalConv3d
torch.manual_seed(0)
source = WanCausalConv3d(3, 4, kernel, padding=padding)
return source, wan_spatial_shard.WanDistCausalConv3d(source, object(), split_dim=split_dim)
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize("split_dim", ["width", "height"])
@pytest.mark.parametrize("with_cache", [False, True])
def test_single_pass_conv_input_matches_cat_pad_halo_on_one_rank(monkeypatch, split_dim, with_cache):
"""One rank, halo-bearing kernel: same bytes as the previous cat -> F.pad -> halo_exchange path."""
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, 1))
_, conv = _dist_conv(split_dim)
x = torch.randn(1, 3, 2, 6, 8)
cache_x = torch.randn(1, 3, 2, 6, 8) if with_cache else None
expected, expected_extent = _reference_conv_input(conv, x, cache_x)
got, got_extent = conv._assemble_input(x, cache_x)
assert got.shape == expected.shape and got_extent == expected_extent
assert torch.equal(got, expected)
@pytest.mark.core_model
@pytest.mark.cpu
def test_single_pass_conv_rejects_cache_longer_than_causal_padding():
_, conv = _dist_conv("width")
with pytest.raises(AssertionError, match="temporal cache exceeds"):
conv._assemble_input(torch.randn(1, 3, 2, 6, 8), torch.randn(1, 3, 3, 6, 8))
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize("split_dim", ["width", "height"])
def test_single_pass_conv_without_a_halo_matches_the_stock_causal_conv(monkeypatch, split_dim):
"""A kernel with no spatial extent along the split (the temporal conv) needs no halo and equals the stock conv."""
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, 1))
source, conv = _dist_conv(split_dim, kernel=(3, 1, 1), padding=(1, 0, 0))
x = torch.randn(1, 3, 2, 6, 8)
cache_x = torch.randn(1, 3, 2, 6, 8)
assert torch.equal(conv(x, cache_x), source(x, cache_x))
assert torch.equal(conv(x, None), source(x, None))
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize("split_dim", ["width", "height"])
@pytest.mark.parametrize("rank", [0, 1])
@pytest.mark.parametrize("kernel,padding", [((3, 3, 3), (1, 1, 1)), ((3, 1, 1), (0, 0, 0))])
def test_single_pass_conv_input_matches_cat_pad_halo_on_two_ranks(monkeypatch, split_dim, rank, kernel, padding):
"""Same bytes as cat -> F.pad -> halo_exchange, halos included, on either rank of a pair."""
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (rank, 2))
def fake_p2p(*, rank, world_size, group, top_row_ref, bottom_row_ref, recv_top_buf, recv_bottom_buf):
# A neighbour's halo is whatever it sent; stand in with a marker derived from our own edge so
# both the reference and the single-pass path see the same exchange. Global edges stay zero.
recv_top_buf.copy_(top_row_ref * 0 + 7.0) if rank > 0 else recv_top_buf.zero_()
recv_bottom_buf.copy_(bottom_row_ref * 0 + 9.0) if rank < world_size - 1 else recv_bottom_buf.zero_()
monkeypatch.setattr(wan_spatial_shard, "_halo_exchange_p2p", fake_p2p)
_, conv = _dist_conv(split_dim, kernel=kernel, padding=padding)
x = torch.randn(1, 3, 1, 6, 8)
cache_x = torch.randn(1, 3, 2, 6, 8)
expected, expected_extent = _reference_conv_input(conv, x, cache_x)
got, got_extent = conv._assemble_input(x, cache_x)
assert got.shape == expected.shape and got_extent == expected_extent
assert torch.equal(got, expected)
@pytest.mark.core_model
@pytest.mark.cpu
def test_halo_exchange_single_rank_noop(monkeypatch: pytest.MonkeyPatch):
@@ -74,6 +74,17 @@ def _install_vllm_stubs() -> None:
_SeqAllToAll4D,
)
def _all_to_all_5D(value, scatter_idx=3, gather_idx=1, group=None, use_sync=False):
# Single-rank stub: the fused q/k/v exchange is the identity when N == 1.
del scatter_idx, gather_idx, group, use_sync
return value
setattr(
sys.modules["vllm_omni.diffusion.distributed.comm"],
"all_to_all_5D",
_all_to_all_5D,
)
def get_sp_group():
return SimpleNamespace(
ulysses_world_size=1,
@@ -588,26 +599,7 @@ def test_tp_rmsnorm_weight_loader_selects_rank_shard(monkeypatch: pytest.MonkeyP
torch.testing.assert_close(norm.weight, torch.tensor([30.0, 40.0]))
def test_single_token_text_kv_shard_owns_compact_storage(monkeypatch):
module = _load_module()
monkeypatch.setattr(
module,
"get_sp_group",
lambda: SimpleNamespace(
ulysses_world_size=2,
ulysses_rank=1,
ulysses_group=None,
),
)
attention = module.LingBotCrossAttention(dim=8, num_heads=4)
full = torch.arange(8, dtype=torch.float32).reshape(1, 1, 4, 2)
shard = attention.shard_kv_heads(full)
torch.testing.assert_close(shard, full[:, :, 2:], rtol=0, atol=0)
assert shard.is_contiguous()
assert shard.untyped_storage().nbytes() == shard.numel() * shard.element_size()
@pytest.mark.parametrize("attention_class", ["LingBotSelfAttention", "LingBotCrossAttention"])
@pytest.mark.parametrize("attention_class", ["LingBotSelfAttention"])
def test_ulysses_rejects_non_divisible_head_count(monkeypatch, attention_class):
module = _load_module()
monkeypatch.setattr(
@@ -621,3 +613,35 @@ def test_ulysses_rejects_non_divisible_head_count(monkeypatch, attention_class):
)
with pytest.raises(ValueError, match="heads must be divisible"):
getattr(module, attention_class)(dim=8, num_heads=4)
def test_supplied_camera_modulation_matches_computing_it_inline() -> None:
"""Passing a prebuilt modulation must be arithmetically identical to letting the block build its own."""
module = _load_module()
torch.manual_seed(0)
block = module.LingBotAttentionBlock(dim=4, num_heads=2, prefix="blocks.0")
camera = torch.randn(1, 3, 4)
scale, shift = block.camera_modulation(camera)
inline_scale, inline_shift = block.camera_modulation(camera)
torch.testing.assert_close(scale, inline_scale, rtol=0, atol=0)
torch.testing.assert_close(shift, inline_shift, rtol=0, atol=0)
def test_the_camera_modulation_reads_only_the_camera_tokens() -> None:
"""The reuse window is only sound because the modulation depends on nothing else that varies per forward."""
module = _load_module()
torch.manual_seed(0)
block = module.LingBotAttentionBlock(dim=4, num_heads=2, prefix="blocks.0")
camera = torch.randn(1, 3, 4)
first = block.camera_modulation(camera)
# Same camera, different everything else a forward would carry: the modulation cannot move.
second = block.camera_modulation(camera.clone())
torch.testing.assert_close(first[0], second[0], rtol=0, atol=0)
torch.testing.assert_close(first[1], second[1], rtol=0, atol=0)
# A different camera must move it, or the cache key would be hiding a real dependency.
other = block.camera_modulation(camera + 1)
assert not torch.equal(first[0], other[0])
@@ -837,6 +837,9 @@ def _rollout(model, mode, dtype, batch):
frames_per_block=_FRAMES,
max_scratch_tokens_per_branch=_FRAMES * _TOKENS_PER_FRAME,
cross_attention_lengths={"text": 5},
# Cross-attention keeps every local head on every SP rank, so its pool is wider than the
# self-attention share above.
cross_attention_kv_heads={"text": model.blocks[0].cross_attn.num_local_heads},
device=device,
)
state = ARDiffusionKVState(kv, "numeric", {"main": kv.begin_request("numeric")}, num_layers=_LAYERS)
@@ -896,7 +899,6 @@ def _worker(rank, world_size, sp_size, tp_size, mode, dtype, batch, rendezvous):
from vllm_omni.diffusion.distributed.parallel_state import (
destroy_distributed_env,
destroy_model_parallel,
get_sp_group,
init_distributed_environment,
initialize_model_parallel,
)
@@ -948,11 +950,10 @@ def _worker(rank, world_size, sp_size, tp_size, mode, dtype, batch, rendezvous):
bound = 1e-5 if dtype == torch.float32 else 1e-2
error = (result - expected).double().norm() / expected.double().norm()
assert error <= bound, f"rank={rank}, TP{tp} SP{sp}: relative L2 {error.item():.3g} > {bound}"
local_heads = _HEADS // tp // sp
first = (
get_tensor_model_parallel_rank() * (_HEADS // tp)
+ get_sp_group().ulysses_rank * local_heads
)
# Cross-attention K/V is replicated across the Ulysses group, not split over it, so
# every SP rank holds its whole TP shard of heads. Only TP slices the cache now.
local_heads = _HEADS // tp
first = get_tensor_model_parallel_rank() * local_heads
for (k, v), (ek, ev) in zip(cross, expected_cross, strict=True):
tolerance = 1e-5 if dtype == torch.float32 else 2e-2
torch.testing.assert_close(
@@ -1033,3 +1034,56 @@ def test_temporal_rope_extension_keeps_spatial_limit():
model._rotary_embedding(
frames=3, height=17, width=1, start_frame=16, dtype=torch.float32, device=torch.device("cpu")
)
@pytest.mark.cpu
def test_the_camera_injector_runs_once_per_held_cache_and_gives_the_same_output() -> None:
"""Two forwards sharing one caller-held cache: the injector runs once per block, outputs unchanged."""
module = attention_tests._load_module()
model = _tiny_model(module, num_layers=2).eval()
hidden_states = torch.randn(1, 36, 1, 4, 4)
timestep = torch.tensor([1.0])
encoder_hidden_states = torch.randn(1, 3, 6)
camera_hidden_states = torch.randn(1, 6 * 8 * 8, 1, 4, 4)
def run(cache, camera_cache=None):
return model(
hidden_states,
timestep,
encoder_hidden_states,
camera_hidden_states,
cache=cache,
start_frame=0,
update_cache=False,
camera_modulation_cache=camera_cache,
)
# Baseline: no cache, so every forward rebuilds the injector in every block.
uncached_first = run(_cache(module, model))
uncached_second = run(_cache(module, model))
injector_calls = [attention_tests._record_outputs(block.cam_injector_layer1) for block in model.blocks]
held = module.CameraModulationCache()
cached_first = run(_cache(module, model), held)
cached_second = run(_cache(module, model), held)
# One build per block for both forwards, instead of one per block per forward.
assert [len(calls) for calls in injector_calls] == [1, 1]
torch.testing.assert_close(cached_first, uncached_first, rtol=0, atol=0)
torch.testing.assert_close(cached_second, uncached_second, rtol=0, atol=0)
@pytest.mark.cpu
def test_timestep_projection_is_staged_into_one_static_buffer_per_shape() -> None:
"""Two forwards' projections land in the same buffer; the blocks never see the fresh per-forward tensor."""
module = attention_tests._load_module()
model = _tiny_model(module, num_layers=1).eval()
first = torch.randn(1, 8, 6, model.dim)
second = torch.randn(1, 8, 6, model.dim)
staged_first = model._stage_timestep_projection(first)
assert staged_first is not first and torch.equal(staged_first, first)
staged_second = model._stage_timestep_projection(second)
assert staged_second is staged_first and torch.equal(staged_second, second)
# A different shape gets its own buffer; the first one is kept.
other = model._stage_timestep_projection(torch.randn(1, 4, 6, model.dim))
assert other is not staged_first and len(model._timestep_projection_buffers) == 2
@@ -4,11 +4,13 @@
from __future__ import annotations
import gc
import os
import weakref
from pathlib import Path
from threading import Lock
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from diffusers.utils.torch_utils import randn_tensor as _diffusers_randn_tensor
@@ -29,6 +31,7 @@ from vllm_omni.diffusion.models.lingbot_world.camera import (
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.utils import StepRequestState
from vllm_omni.experimental.ar_diffusion.capability import supports_chunk_step_grouping
from vllm_omni.experimental.ar_diffusion.tick_protocol import (
ARDiffusionControlInput,
ARDiffusionTickRequest,
@@ -127,6 +130,10 @@ class _RecordingTransformer(nn.Module):
sink_size=3,
)
self.blocks = nn.ModuleList([nn.Identity(), nn.Identity()])
for block in self.blocks:
# The spec reads head geometry from both attentions; cross-attention keeps every local head.
block.self_attn = SimpleNamespace(num_sp_heads=2)
block.cross_attn = SimpleNamespace(num_local_heads=2)
self.calls: list[dict] = []
self.cache_allocations: list[dict] = []
self.raise_on_call = raise_on_call
@@ -146,6 +153,7 @@ class _RecordingTransformer(nn.Module):
"cache_id": id(kwargs["cache"]),
"start_frame": kwargs["start_frame"],
"update_cache": kwargs["update_cache"],
"camera_cache": kwargs.get("camera_modulation_cache"),
}
self.calls.append(call)
if self.raise_on_call == len(self.calls):
@@ -534,14 +542,15 @@ def test_ar_diffusion_capability_uses_transformer_local_head_geometry() -> None:
assert spec.sink_frames == 3
assert [(branch.name, branch.local_index) for branch in spec.kv_branches] == [("main", 0)]
assert spec.cross_attention_lengths == {"text": 512}
# One condition block, committed + pending encoder caches, and decoder state.
# One condition block, committed + pending encoder caches, the session's
# cached prompt embedding (512 tokens x text_dim 8 x fp32), and decoder state.
# The stub encoder retains 2 RGB frames at 16x16 and 2 feature frames at 2x2.
encoder_bytes = (2 * 3 * 16 * 16 + 2 * 16 * 2 * 2) * 4
assert spec.model_owned_state_bytes_per_session == 960 + 2 * encoder_bytes + 2_421_248
assert spec.model_owned_state_bytes_per_session == 960 + 384 + 2 * encoder_bytes + 2_421_248
def test_session_admission_accounts_for_the_streaming_decoder_cache() -> None:
"""Admission includes encoder state even when the decoder cannot stream."""
"""Admission includes encoder state and the prompt embedding even when the decoder cannot stream."""
module = _load_pipeline_module()
def spec_pipeline():
@@ -560,13 +569,29 @@ def test_session_admission_accounts_for_the_streaming_decoder_cache() -> None:
# than the image condition alone could account for.
assert widened - small > 2_000_000
# Removing decoder support leaves the condition and both encoder histories.
# Removing decoder support leaves the condition, both encoder histories and
# the prompt-embedding cache.
bare = spec_pipeline()
for attribute in ("decoder", "post_quant_conv"):
if hasattr(bare.vae, attribute):
delattr(bare.vae, attribute)
bare.vae._cached_conv_counts.pop("decoder")
assert bare.ar_diffusion_kv_cache_spec().model_owned_state_bytes_per_session == 960 + 2 * 6_656
assert bare.ar_diffusion_kv_cache_spec().model_owned_state_bytes_per_session == 960 + 384 + 2 * 6_656
@pytest.mark.parametrize("sp_size", [1, 4])
def test_session_admission_budgets_camera_modulation_per_rank(sp_size: int) -> None:
pipeline = _pipeline(_load_pipeline_module())
pipeline._ar_height, pipeline._ar_width = 480, 832
pipeline.od_config.parallel_config.ulysses_degree = sp_size
pipeline.transformer.config.num_layers = 40
pipeline.transformer.config.num_attention_heads = 40
pipeline.transformer.config.attention_head_dim = 128
pipeline.transformer._dtype = torch.bfloat16
forty_layers = pipeline.ar_diffusion_kv_cache_spec().model_owned_state_bytes_per_session
pipeline.transformer.config.num_layers = 1
one_layer = pipeline.ar_diffusion_kv_cache_spec().model_owned_state_bytes_per_session
assert forty_layers - one_layer == 2 * 39 * (4680 // sp_size) * 5120 * 2
def test_preprocess_materializes_external_inputs_before_worker_execution(tmp_path: Path) -> None:
@@ -1672,7 +1697,6 @@ def test_typed_ticks_generate_one_global_block_and_return_standard_metadata(
"applied_event_ids": [1],
}
assert pipeline._ar_sessions["world-1"].next_chunk_index == 2
assert pipeline._ar_sessions["world-1"].prompt == "enter the snowy valley"
assert pipeline._ar_sessions["world-1"].camera_tail is not None
assert pipeline._ar_sessions["world-1"].camera_tail.poses.shape == (1, 4, 4)
expected_generator = torch.Generator(device="cpu").manual_seed(17)
@@ -1846,8 +1870,11 @@ def test_typed_ticks_keep_bounded_encoder_state_beyond_ten_chunks(
pipeline(_request(sampling=sampling))
assert pipeline.vae.encode_inputs == []
assert [video.shape[2] for video in pipeline.vae.encoder.inputs] == [1] + [4] * 32
# The stub encoder's history settles after its second block; the nine
# blocks after that reuse the settled condition without an encode.
assert [video.shape[2] for video in pipeline.vae.encoder.inputs] == [1] + [4] * 5
assert all(torch.count_nonzero(video) == 0 for video in pipeline.vae.encoder.inputs[1:])
assert pipeline._ar_sessions["world-1"].condition_fixed_point is not None
encoder_cache = pipeline._ar_sessions["world-1"].encoder_cache
assert sum(t.numel() * t.element_size() for t in encoder_cache) == pipeline._condition_encoder_cache_bytes()
assert pipeline.vae._enc_feat_map == ["module-owned"]
@@ -1943,6 +1970,76 @@ def test_condition_continues_real_causal_vae_beyond_initial_horizon(
assert pipeline._ar_sessions == {}
def test_condition_encoder_stops_at_its_fixed_point_and_restarts_with_the_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Once a block returns the history it was given, later blocks reuse the condition without encoding.
The whole-clip encode is the reference for every block, so reusing the
stored condition is only allowed to be a speedup, never a change.
"""
pipeline = _tiny_condition_pipeline(monkeypatch)
pipeline._ar_diffusion_kv_state = object()
blocks = 12
inputs = pipeline._parse_request(_request(sampling=_SamplingParams(num_frames=(blocks * 3 - 1) * 4 + 1)))
with torch.inference_mode():
expected = pipeline._prepare_condition(inputs, dtype=torch.float32)
encoder_calls = []
original_encoder = pipeline.vae.encoder.forward
def encoder(*args, **kwargs):
encoder_calls.append(len(seen))
return original_encoder(*args, **kwargs)
seen: list[torch.Tensor] = []
def generate_block(**kwargs):
index = len(seen) % blocks
torch.testing.assert_close(
kwargs["condition"], expected[:, :, index * 3 : (index + 1) * 3], rtol=1e-6, atol=1e-6
)
seen.append(kwargs["condition"])
return torch.zeros_like(kwargs["condition"][:, :16])
monkeypatch.setattr(pipeline.vae.encoder, "forward", encoder)
monkeypatch.setattr(pipeline, "_generate_block", generate_block)
def tick(index):
pipeline(_request(sampling=_SamplingParams(extra_args=_tick_extra_args(chunk_index=index))))
settled_at = None
for index in range(blocks):
tick(index)
state = pipeline._ar_sessions["world-1"]
if settled_at is None and state.condition_fixed_point is not None:
settled_at = index
assert state.pending_encoder_cache is None and state.encoder_cache is not None
assert settled_at is not None and 0 < settled_at < blocks - 1, settled_at
# Three encoder passes per encoded block, none once the fixed point is stored.
assert encoder_calls == [index for index in range(settled_at + 1) for _ in range(3)]
# The stored condition is the session's own, not shared with the callers.
assert all(seen[settled_at] is not later for later in seen[settled_at + 1 :])
# A session restarted from chunk 0 encodes again from the source image.
pipeline.reset_ar_diffusion_session("world-1")
tick(0)
state = pipeline._ar_sessions["world-1"]
assert state.condition_fixed_point is None
assert encoder_calls[-3:] == [blocks] * 3
def test_condition_encoder_fixed_point_is_released_with_the_session(monkeypatch: pytest.MonkeyPatch) -> None:
pipeline = _tiny_condition_pipeline(monkeypatch)
pipeline._ar_diffusion_kv_state = object()
monkeypatch.setattr(pipeline, "_generate_block", lambda **kwargs: torch.zeros_like(kwargs["condition"][:, :16]))
for index in range(8):
pipeline(_request(sampling=_SamplingParams(extra_args=_tick_extra_args(chunk_index=index))))
state = pipeline._ar_sessions["world-1"]
assert state.condition_fixed_point is not None
pipeline.close_ar_diffusion_session("world-1")
assert state.condition_fixed_point is None and state.encoder_cache is None
def test_condition_encoder_interleaving_retry_and_reset(monkeypatch: pytest.MonkeyPatch) -> None:
pipeline = _tiny_condition_pipeline(monkeypatch)
pipeline._ar_diffusion_kv_state = object()
@@ -2209,6 +2306,40 @@ def _stepwise_chunks(pipeline, state, ar_state):
yield output
def test_stepwise_block_shares_one_camera_cache_across_its_five_forwards() -> None:
"""The four probes and the commit of one stepwise block reuse one camera cache; the next block gets its own.
Request mode creates one cache per loop; the stepwise path's forwards are
separate scheduler steps, so the pipeline holds the block's cache and
passes it to each one.
"""
module = _load_pipeline_module()
transformer = _RecordingTransformer()
pipeline = _pipeline(module, transformer=transformer)
pipeline._ar_height = 16
pipeline._ar_width = 16
state = _stepwise_state(num_frames=21)
state.sampling.output_type = "latent"
with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)):
outputs = _run_stepwise(pipeline, state)
assert len(outputs) == 2 and len(transformer.calls) == 10
caches = [call["camera_cache"] for call in transformer.calls]
assert all(c is not None for c in caches)
assert len({id(c) for c in caches[:5]}) == 1 and len({id(c) for c in caches[5:]}) == 1
assert caches[0] is not caches[5]
# The block's cache does not outlive its commit.
assert "camera_cache" not in state.extra
def test_pipeline_declares_chunk_step_grouping() -> None:
"""A realtime block's probes and commit may run without a scheduler cycle between them."""
module = _load_pipeline_module()
assert module.LingBotWorldCausalDMDPipeline.supports_chunk_step_grouping is True
assert supports_chunk_step_grouping(_pipeline(module))
def test_pipeline_declares_step_execution_support() -> None:
module = _load_pipeline_module()
pipeline = _pipeline(module)
@@ -2337,7 +2468,10 @@ def test_stepwise_matches_tick_transformer_trace_and_latents(num_chunks) -> None
torch.testing.assert_close(step_call["camera_hidden_states"], tick_call["camera_hidden_states"])
assert stepwise_state.extra["condition"].shape[2] == 3
assert pipeline.vae.encode_inputs == []
assert [video.shape[2] for video in pipeline.vae.encoder.inputs] == ([1] + [4] * (num_chunks * 3 - 1)) * 2
# The stub encoder's history settles after its second block, so both
# modes encode two blocks and reuse the settled condition after that.
encoded_blocks = min(num_chunks, 2)
assert [video.shape[2] for video in pipeline.vae.encoder.inputs] == ([1] + [4] * (encoded_blocks * 3 - 1)) * 2
assert stepwise_fake.commits == ["main"] * num_chunks
assert len(stepwise_outputs) == num_chunks and stepwise_outputs[-1].finished
assert pipeline.vae.decode_inputs == []
@@ -2406,21 +2540,16 @@ def test_stepwise_emits_latents_without_touching_the_vae() -> None:
assert pipeline.vae.decode_inputs == []
def _capture_video_processor(monkeypatch) -> list[tuple[tuple[int, ...], str]]:
"""Record what reaches postprocess_video, and name each chunk's output."""
import diffusers.video_processor as video_processor_module
def _capture_pixel_conversion(monkeypatch) -> list[tuple[int, ...]]:
"""Record each decoded block reaching the uint8 conversion, and name its output."""
module = _load_pipeline_module()
processed: list[tuple[int, ...]] = []
processed: list[tuple[tuple[int, ...], str]] = []
def uint8_frames(video):
processed.append(tuple(video.shape))
return f"frames-{len(processed)}"
class VideoProcessor:
def __init__(self, *, vae_scale_factor):
assert vae_scale_factor == 8
def postprocess_video(self, video, *, output_type):
processed.append((tuple(video.shape), output_type))
return f"frames-{len(processed)}"
monkeypatch.setattr(video_processor_module, "VideoProcessor", VideoProcessor)
monkeypatch.setattr(module, "_uint8_frames", uint8_frames)
return processed
@@ -2434,7 +2563,7 @@ def _streaming_pipeline(module):
def test_stepwise_decodes_each_chunk_for_streaming_consumers(monkeypatch) -> None:
"""A non-latent request must stream pixels under the primary "video" key."""
module = _load_pipeline_module()
processed = _capture_video_processor(monkeypatch)
processed = _capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
state = _stepwise_state(num_frames=21)
state.sampling.output_type = "np"
@@ -2443,7 +2572,7 @@ def test_stepwise_decodes_each_chunk_for_streaming_consumers(monkeypatch) -> Non
with pipeline.bind_ar_diffusion_state(state.request_id, fake):
outputs = _run_stepwise(pipeline, state)
assert [output_type for _, output_type in processed] == ["np", "np"]
assert processed == [(1, 3, 9, 16, 16), (1, 3, 12, 16, 16)]
assert [output.output["payload"] for output in outputs] == [{"video": "frames-1"}, {"video": "frames-2"}]
# Identity metadata survives the switch to pixel output.
assert [output.output["metadata"]["ar_diffusion"]["chunk_index"] for output in outputs] == [0, 1]
@@ -2457,7 +2586,7 @@ def test_stepwise_chunks_continue_one_sessions_temporal_decode(monkeypatch) -> N
the same latents would: 9 frames, then 12.
"""
module = _load_pipeline_module()
processed = _capture_video_processor(monkeypatch)
processed = _capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
state = _stepwise_state(num_frames=21)
state.sampling.output_type = "np"
@@ -2466,7 +2595,7 @@ def test_stepwise_chunks_continue_one_sessions_temporal_decode(monkeypatch) -> N
with pipeline.bind_ar_diffusion_state(state.request_id, fake):
_run_stepwise(pipeline, state)
assert [shape for shape, _ in processed] == [(1, 3, 9, 16, 16), (1, 3, 12, 16, 16)]
assert processed == [(1, 3, 9, 16, 16), (1, 3, 12, 16, 16)]
# One opening frame for the session, not one per chunk.
assert pipeline.vae.decoder.first_chunk_flags == [True] + [False] * 5
# The whole-clip decode path is not involved, and the module-owned cache
@@ -2511,7 +2640,7 @@ def test_prepare_next_chunk_rejects_live_camera_with_scripted_paths() -> None:
def test_peek_chunk_media_matches_streaming_decoder_frame_counts(monkeypatch) -> None:
"""Camera timelines must track the streaming decoder's 9-then-12 media counts."""
module = _load_pipeline_module()
_capture_video_processor(monkeypatch)
_capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
state = _stepwise_state(num_frames=21)
state.sampling.output_type = "np"
@@ -2536,7 +2665,7 @@ def test_peek_chunk_media_matches_streaming_decoder_frame_counts(monkeypatch) ->
def test_streaming_decode_state_is_owned_by_the_session(monkeypatch) -> None:
"""Decoder state is keyed by request id and released with the AR session."""
module = _load_pipeline_module()
_capture_video_processor(monkeypatch)
_capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
state = _stepwise_state(request_id="req-stream", num_frames=21)
state.sampling.output_type = "np"
@@ -2560,7 +2689,7 @@ def test_streaming_decode_state_is_owned_by_the_session(monkeypatch) -> None:
def test_streaming_decode_state_is_dropped_on_session_reset(monkeypatch) -> None:
"""A reset session must not resume the temporal context it just abandoned."""
module = _load_pipeline_module()
_capture_video_processor(monkeypatch)
_capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
state = _stepwise_state(request_id="req-reset", num_frames=21)
state.sampling.output_type = "np"
@@ -2584,7 +2713,7 @@ def test_streaming_decode_keeps_interleaved_sessions_isolated(monkeypatch) -> No
interleaving the scheduler actually produces.
"""
module = _load_pipeline_module()
processed = _capture_video_processor(monkeypatch)
processed = _capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
states = []
for request_id in ("req-a", "req-b"):
@@ -2617,7 +2746,7 @@ def test_streaming_decode_keeps_interleaved_sessions_isolated(monkeypatch) -> No
assert pipeline.vae.decoder.first_chunk_flags == (
[True, False, False] + [True, False, False] + [False] * 3 + [False] * 3
)
assert [shape for shape, _ in processed] == [
assert processed == [
(1, 3, 9, 16, 16),
(1, 3, 9, 16, 16),
(1, 3, 12, 16, 16),
@@ -2628,7 +2757,7 @@ def test_streaming_decode_keeps_interleaved_sessions_isolated(monkeypatch) -> No
def test_streaming_decode_failure_drops_the_half_advanced_cache(monkeypatch) -> None:
"""A failed chunk must not leave a cache a later chunk would continue from."""
module = _load_pipeline_module()
_capture_video_processor(monkeypatch)
_capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
state = _stepwise_state(request_id="req-boom", num_frames=21)
state.sampling.output_type = "np"
@@ -2662,7 +2791,7 @@ def test_streaming_decode_receives_rescaled_latents(monkeypatch) -> None:
never reaches, so this asserts on what ``post_quant_conv`` received.
"""
module = _load_pipeline_module()
_capture_video_processor(monkeypatch)
_capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
state = _stepwise_state(num_frames=21)
state.sampling.output_type = "np"
@@ -2695,7 +2824,7 @@ def test_streaming_decode_is_reported_to_the_profiler(monkeypatch) -> None:
perf regression in the streaming decoder would be too.
"""
module = _load_pipeline_module()
_capture_video_processor(monkeypatch)
_capture_pixel_conversion(monkeypatch)
pipeline = _pipeline(
module,
transformer=_RecordingTransformer(),
@@ -2723,21 +2852,18 @@ def test_streaming_decode_is_reported_to_the_profiler(monkeypatch) -> None:
def test_streaming_decode_falls_back_when_the_vae_would_tile(monkeypatch) -> None:
"""Tiled decode drives its own cache, so a tiling shape keeps per-chunk decode."""
module = _load_pipeline_module()
processed = _capture_video_processor(monkeypatch)
processed = _capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
# A latent of 2x2 exceeds a one-latent-cell tile, so vae.decode would tile.
_enable_vae_tiling(pipeline, tile_sample_min=8)
outputs = [
pipeline._decode_chunk_to_pixels(torch.zeros(1, 16, 3, 2, 2), output_type="np", session_id="tiled")
for _ in range(2)
]
outputs = [pipeline._decode_chunk_to_pixels(torch.zeros(1, 16, 3, 2, 2), session_id="tiled") for _ in range(2)]
# One whole-clip decode per AR block, each seeing only that block's frames.
assert [tuple(latents.shape) for latents in pipeline.vae.decode_inputs] == [(1, 16, 3, 2, 2)] * 2
assert pipeline.vae.decoder.first_chunk_flags == []
assert pipeline._streaming_decode_states == {}
# Nine frames per chunk instead of 9 then 12: the frame loss being removed.
assert [shape for shape, _ in processed] == [(1, 3, 9, 16, 16), (1, 3, 9, 16, 16)]
assert processed == [(1, 3, 9, 16, 16), (1, 3, 9, 16, 16)]
assert outputs == ["frames-1", "frames-2"]
@@ -2749,7 +2875,7 @@ def test_streaming_decode_survives_tiling_enabled_below_the_tile_threshold(monke
on a configuration that would never have tiled.
"""
module = _load_pipeline_module()
processed = _capture_video_processor(monkeypatch)
processed = _capture_pixel_conversion(monkeypatch)
pipeline = _streaming_pipeline(module)
# A 2x2 latent is within a tile this size, so vae.decode would not tile.
_enable_vae_tiling(pipeline, tile_sample_min=256)
@@ -2761,7 +2887,7 @@ def test_streaming_decode_survives_tiling_enabled_below_the_tile_threshold(monke
assert pipeline.vae.decode_inputs == []
assert pipeline.vae.decoder.first_chunk_flags == [True] + [False] * 5
assert [shape for shape, _ in processed] == [(1, 3, 9, 16, 16), (1, 3, 12, 16, 16)]
assert processed == [(1, 3, 9, 16, 16), (1, 3, 12, 16, 16)]
def test_registry_and_model_exports_resolve_official_pipeline_class_name() -> None:
@@ -2904,3 +3030,193 @@ def test_stepwise_requests_with_the_same_prompt_share_one_encode() -> None:
assert encoded == ["move through the room"]
assert states[0].prompt_embeds is states[1].prompt_embeds
def test_session_text_caches_publish_key_only_after_a_successful_build() -> None:
module = _load_pipeline_module()
pipeline = _pipeline(module)
state = module._LingBotARSessionState()
invalidations: list[bool] = []
fail_next = {"raise": False}
def fake_text_caches(prompt_embeds, *, invalidate):
invalidations.append(invalidate)
if fail_next["raise"]:
fail_next["raise"] = False
raise RuntimeError("injected failure inside _ar_text_caches")
return ["caches"]
pipeline._ar_text_caches = fake_text_caches
embeds = torch.ones(1, 512, 8)
caches = lambda prompt, length=512, e=embeds: pipeline._session_text_caches( # noqa: E731
e, prompt=prompt, max_sequence_length=length, session_state=state
)
# First build of a session: runner state is empty -> no invalidation, key published.
caches("A")
assert invalidations == [False] and state.text_cache_key == ("A", 512, str(embeds.dtype))
assert state.text_cache_dirty is False
# Same inputs: reuse without invalidation.
caches("A")
assert invalidations == [False, False]
# Regression (Codex review): B's build fails after invalidating -> no key is
# left behind, so retrying A must invalidate again instead of consuming
# partial/mixed K/V.
fail_next["raise"] = True
with pytest.raises(RuntimeError, match="injected failure"):
caches("B")
assert state.text_cache_key is None and state.text_cache_dirty is True
caches("A")
assert invalidations[-2:] == [True, True] and state.text_cache_key == ("A", 512, str(embeds.dtype))
assert state.text_cache_dirty is False
# The key covers every embedding-defining input: same text, other length or dtype -> rebuild.
caches("A", length=256)
caches("A", e=embeds.to(torch.bfloat16))
assert invalidations[-2:] == [True, True]
# ---------------------------------------------------------------------------
# a source image is decoded once while the file behind it is unchanged
# ---------------------------------------------------------------------------
def _write_png(path, colour=(10, 20, 30)):
Image.new("RGB", (64, 64), colour).save(path)
return path
def _counting_decode(module):
"""Replace the file decode with a counter, so the tests measure decodes rather than pixels."""
decoded: list[str] = []
original = module._decode_source_image_file
def decode(path):
decoded.append(os.fspath(path))
return Image.new("RGB", (64, 64), (1, 2, 3))
module._decode_source_image_file = decode
return decoded, original
def test_the_same_source_image_is_decoded_once(tmp_path: Path) -> None:
module = _load_pipeline_module()
module._decode_source_image_fingerprinted.cache_clear()
path = _write_png(tmp_path / "frame.png")
decoded, original = _counting_decode(module)
try:
first = module._load_source_image(path)
second = module._load_source_image(path)
assert decoded == [os.fspath(os.path.realpath(path))] or len(decoded) == 1
# Every caller owns its copy: mutating one must not reach the next caller or the cache.
assert first is not second
first.putpixel((0, 0), (255, 255, 255))
assert module._load_source_image(path).getpixel((0, 0)) == (1, 2, 3)
assert len(decoded) == 1
finally:
module._decode_source_image_file = original
module._decode_source_image_fingerprinted.cache_clear()
def test_a_rewritten_source_image_is_decoded_again(tmp_path: Path) -> None:
module = _load_pipeline_module()
module._decode_source_image_fingerprinted.cache_clear()
path = _write_png(tmp_path / "frame.png")
decoded, original = _counting_decode(module)
try:
module._load_source_image(path)
module._load_source_image(path)
assert len(decoded) == 1
# Same path, different bytes: the mtime/size in the key must force a fresh decode.
os.utime(path, ns=(0, 0))
_write_png(path, colour=(200, 100, 50))
module._load_source_image(path)
assert len(decoded) == 2
finally:
module._decode_source_image_file = original
module._decode_source_image_fingerprinted.cache_clear()
def test_an_image_rewritten_during_the_decode_is_never_cached(tmp_path: Path) -> None:
module = _load_pipeline_module()
module._decode_source_image_fingerprinted.cache_clear()
path = _write_png(tmp_path / "frame.png")
decoded: list[str] = []
original = module._decode_source_image_file
def decode_then_rewrite(p):
decoded.append(os.fspath(p))
_write_png(path, colour=(len(decoded), 0, 0))
os.utime(path, ns=(len(decoded) * 1_000_000, len(decoded) * 1_000_000))
return Image.new("RGB", (64, 64), (1, 2, 3))
module._decode_source_image_file = decode_then_rewrite
try:
module._load_source_image(path)
# The entry is keyed by the fingerprint taken before the decode, which the rewrite invalidated: the
# next call must decode again rather than serve the image that was decoded mid-rewrite.
module._load_source_image(path)
assert len(decoded) == 2
finally:
module._decode_source_image_file = original
module._decode_source_image_fingerprinted.cache_clear()
def test_an_unstattable_path_keeps_upstream_validation(tmp_path: Path) -> None:
module = _load_pipeline_module()
module._decode_source_image_fingerprinted.cache_clear()
missing = tmp_path / "does-not-exist.png"
# The uncached path owns the error contract; caching must not change which exception a caller sees.
with pytest.raises(ValueError):
module._load_source_image(missing)
assert module._decode_source_image_fingerprinted.cache_info().currsize == 0
def test_the_text_cache_is_sized_by_cross_attention_heads_not_the_self_attention_share() -> None:
"""Cross-attention replicates heads per rank, so its pool must not inherit the self-attention head count."""
module = _load_pipeline_module()
pipeline = _pipeline(module)
pipeline.transformer.blocks[0].self_attn = SimpleNamespace(num_sp_heads=1)
pipeline.transformer.blocks[0].cross_attn = SimpleNamespace(num_local_heads=4)
spec = pipeline.ar_diffusion_kv_cache_spec()
assert spec.num_kv_heads == 1
assert spec.cross_attention_kv_heads == {"text": 4}
assert spec.cross_attention_lengths == {"text": 512}
@pytest.mark.parametrize("output_type", ["np", "pil"])
def test_streamed_chunk_is_uint8_frames_whatever_pixel_output_type_was_asked(monkeypatch, output_type) -> None:
"""A realtime tick delivers (F, H, W, 3) uint8 for every pixel output type; only latent differs."""
module = _load_pipeline_module()
pipeline = _streaming_pipeline(module)
state = _stepwise_state(num_frames=21)
state.sampling.output_type = output_type
with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)):
outputs = _run_stepwise(pipeline, state)
videos = [output.output["payload"]["video"] for output in outputs]
assert [type(video) for video in videos] == [np.ndarray, np.ndarray]
assert [video.dtype for video in videos] == [np.uint8, np.uint8]
assert [video.shape for video in videos] == [(9, 16, 16, 3), (12, 16, 16, 3)]
def test_uint8_frames_match_the_pil_conversion_byte_for_byte() -> None:
"""The device-side conversion is the PIL path's arithmetic, so the bytes are the same."""
from diffusers.video_processor import VideoProcessor
module = _load_pipeline_module()
generator = torch.Generator().manual_seed(7)
# Cover the clamp on both sides and values that sit exactly on rounding edges.
video = torch.rand(1, 3, 5, 8, 8, generator=generator) * 2.4 - 1.2
video[0, :, 0, 0, :4] = torch.tensor([-1.0, 1.0, 0.0, 1 / 255 - 1.0])
reference = VideoProcessor(vae_scale_factor=8).postprocess_video(video, output_type="pil")[0]
expected = np.stack([np.asarray(frame) for frame in reference])
frames = module._uint8_frames(video)
assert frames.dtype == np.uint8 and frames.shape == (5, 8, 8, 3)
assert frames.flags["C_CONTIGUOUS"]
np.testing.assert_array_equal(frames, expected)
+245 -1
View File
@@ -103,6 +103,78 @@ class _StepPipeline:
return DiffusionOutput(output=torch.tensor([state.step_index], dtype=torch.float32))
class _ChunkedStepPipeline(_StepPipeline):
"""Streaming stub: two chunks of two denoise steps each, one decode per chunk."""
supports_chunk_step_grouping = False
def prepare_encode(self, state, **kwargs):
del kwargs
self.prepare_calls += 1
state.timesteps = [torch.tensor(10), torch.tensor(5), torch.tensor(10), torch.tensor(5)]
state.latents = torch.tensor([0.0])
state.prompt_embeds = torch.tensor([[0.0, 0.0], [1.0, 1.0]])
state.chunk_num_steps = 2
state.total_chunks = 2
state.chunk_index = 0
state.step_in_chunk = 0
return state
def step_scheduler(self, state, noise_pred, **kwargs):
del noise_pred, kwargs
self.scheduler_calls += 1
state.step_in_chunk += 1
state.step_index = state.step_in_chunk
def post_decode(self, state, **kwargs):
del kwargs
self.decode_calls += 1
result = DiffusionOutput(output=torch.tensor([float(state.chunk_index)]))
state.chunk_index += 1
state.step_in_chunk = 0
return result
class _BatchDependentChunkPipeline(_ChunkedStepPipeline):
"""Streaming stub whose noise depends on the batch's latents and timesteps, so a stale batch shows."""
def __init__(self):
super().__init__()
self.seen: list[tuple[float, float]] = []
def prepare_encode(self, state, **kwargs):
super().prepare_encode(state, **kwargs)
state.latents = torch.tensor([1.0])
return state
def denoise_step(self, input_batch, **kwargs):
self.denoise_calls += 1
latent = float(input_batch.latents.reshape(-1)[0])
timestep = float(input_batch.timesteps.reshape(-1)[0])
self.seen.append((latent, timestep))
return torch.full((input_batch.latents.shape[0], 1), latent + timestep)
def step_scheduler(self, state, noise_pred, **kwargs):
del kwargs
self.scheduler_calls += 1
state.latents = state.latents + noise_pred.reshape(-1)[0]
state.step_in_chunk += 1
state.step_index = state.step_in_chunk
def post_decode(self, state, **kwargs):
self.final_latent = state.latents.reshape(-1)[0].clone()
return super().post_decode(state, **kwargs)
class _FailsMidChunkPipeline(_ChunkedStepPipeline):
"""Streaming stub whose second denoise step of the first chunk raises."""
def step_scheduler(self, state, noise_pred, **kwargs):
super().step_scheduler(state, noise_pred, **kwargs)
if state.chunk_index == 0 and state.step_in_chunk == 2:
raise RuntimeError("boom mid chunk")
class _PerRequestErrorStepPipeline(_StepPipeline):
def prepare_encode(self, state, **kwargs):
if state.prompt == "fail-prepare":
@@ -327,8 +399,9 @@ def _make_vllm_config():
def _make_runner(
diffusion_kv_mode: DiffusionKVCacheMode = DiffusionKVCacheMode.DENSE_LEGACY,
runner_cls: type[DiffusionModelRunner] = DiffusionModelRunner,
):
runner = object.__new__(DiffusionModelRunner)
runner = object.__new__(runner_cls)
runner.vllm_config = _make_vllm_config()
runner.od_config = SimpleNamespace(
cache_backend=None,
@@ -511,6 +584,37 @@ def test_input_batch_cached_repack_keeps_static_prompt_fields_for_same_compositi
torch.testing.assert_close(repacked.prompt_embeds, torch.ones(1, 2, 3))
@pytest.mark.cpu
def test_make_batch_identity_mapping_never_reads_the_device_tensor_back(monkeypatch):
"""The default index mapping is built on the host; reading it back from the device would sync every step."""
def forbidden(self, *args, **kwargs):
raise AssertionError("make_batch read a tensor back to the host")
monkeypatch.setattr(torch.Tensor, "tolist", forbidden)
monkeypatch.setattr(torch.Tensor, "cpu", forbidden)
states = [_make_input_batch_state("req-1", 1.0), _make_input_batch_state("req-2", 2.0)]
batch = InputBatch.make_batch(states)
repacked = InputBatch.make_batch(states, cached_batch=batch)
assert repacked is batch
assert list(batch.idx_mapping_np) == [0, 1]
assert batch.idx_mapping.dtype is torch.int32 and batch.idx_mapping.shape == (2,)
assert batch.request_ids == ["req-1", "req-2"]
@pytest.mark.cpu
def test_make_batch_explicit_mapping_still_selects_and_orders_states():
states = [_make_input_batch_state("req-1", 1.0), _make_input_batch_state("req-2", 2.0)]
batch = InputBatch.make_batch(states, idx_mapping=torch.tensor([1, 0]))
assert batch.request_ids == ["req-2", "req-1"]
assert list(batch.idx_mapping_np) == [1, 0]
torch.testing.assert_close(batch.latents, torch.tensor([[2.0], [1.0]]))
@pytest.mark.cpu
def test_step_profiler_reports_denoise_step_as_diffuse():
pipeline = _AutoDenoiseProfilerPipeline()
@@ -628,6 +732,146 @@ class TestRunner:
assert runner.pipeline.scheduler_calls == 2
assert runner.pipeline.decode_calls == 1
@staticmethod
def _make_grouping_runner(pipeline, *, grouping: bool):
"""An AR runner without KV (so it inherits the stepwise entry) over a streaming stub pipeline."""
from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner
runner = _make_runner(runner_cls=ARDiffusionModelRunner)
runner.od_config.streaming_output = True
runner.kv_cache = None
runner.pipeline = pipeline
runner.pipeline.supports_chunk_step_grouping = grouping
return runner
@staticmethod
def _drive_to_completion(runner, num_steps=4):
outputs = [runner.execute_stepwise(_make_scheduler_output(_make_step_request(num_steps)))]
step = 1
while not outputs[-1].get_request_output("req-1").finished:
outputs.append(runner.execute_stepwise(_make_cached_scheduler_output(step_id=step)))
step += 1
return [o.get_request_output("req-1") for o in outputs]
@pytest.mark.parametrize("grouping", [False, True])
def test_ar_runner_runs_every_step_of_a_chunk_in_one_call_when_the_pipeline_declares_it(
self, monkeypatch, grouping
):
"""With the capability declared, one AR runner call drives a whole chunk; the outputs are the same either way."""
runner = self._make_grouping_runner(_ChunkedStepPipeline(), grouping=grouping)
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
denoise_calls_after_each_call = []
original = runner.execute_stepwise
def counting(scheduler_output):
output = original(scheduler_output)
denoise_calls_after_each_call.append(runner.pipeline.denoise_calls)
return output
runner.execute_stepwise = counting
per_call = self._drive_to_completion(runner)
# Chunk outputs, and the steps they were emitted at, are identical.
emitted = [(o.step_index, float(o.result.output[0])) for o in per_call if o.result is not None]
assert emitted == [(2, 0.0), (2, 1.0)]
assert per_call[-1].finished is True
assert runner.pipeline.denoise_calls == 4 and runner.pipeline.scheduler_calls == 4
assert runner.pipeline.decode_calls == 2
assert runner.pipeline.prepare_calls == 1
# One call per chunk with the capability, one per step without; a
# grouped call never crosses into the next chunk.
assert denoise_calls_after_each_call == ([2, 4] if grouping else [1, 2, 3, 4])
assert "req-1" not in runner.state_cache
def test_ar_runner_continues_a_grouped_chunk_as_a_cached_request(self, monkeypatch):
"""The first step admits the new request; every further step of the chunk is a cached-request continuation."""
runner = self._make_grouping_runner(_ChunkedStepPipeline(), grouping=True)
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
seen = []
original_core = DiffusionModelRunner._execute_stepwise_core
def recording_core(self_, scheduler_output, **kwargs):
seen.append(scheduler_output)
return original_core(self_, scheduler_output, **kwargs)
monkeypatch.setattr(DiffusionModelRunner, "_execute_stepwise_core", recording_core)
first = runner.execute_stepwise(_make_scheduler_output(_make_step_request(4), finished_req_ids={"old"}))
assert first.get_request_output("req-1").result is not None
assert runner.pipeline.prepare_calls == 1
assert len(seen) == 2
assert [n.request_id for n in seen[0].scheduled_new_reqs] == ["req-1"]
continuation = seen[1]
assert continuation.scheduled_new_reqs == []
assert continuation.scheduled_cached_reqs.request_ids == ["req-1"]
assert continuation.finished_req_ids == set()
assert continuation.kv_prefetch_job is None and continuation.kv_connector_metadata is None
assert continuation.step_id == seen[0].step_id
def test_ar_runner_grouped_steps_see_the_same_inputs_as_per_step_calls(self, monkeypatch):
"""Every grouped step sees the latents and timestep step_scheduler just advanced, exactly as per-step calls do."""
finals = {}
seen = {}
for grouping in (False, True):
runner = self._make_grouping_runner(_BatchDependentChunkPipeline(), grouping=grouping)
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
self._drive_to_completion(runner)
finals[grouping] = float(runner.pipeline.final_latent)
seen[grouping] = runner.pipeline.seen
# 1 -> +(1+10)=12 -> +(12+5)=29 -> +(29+10)=68 -> +(68+5)=141 in both modes.
assert seen[False] == [(1.0, 10.0), (12.0, 5.0), (29.0, 10.0), (68.0, 5.0)]
assert seen[True] == seen[False]
assert finals[True] == finals[False] == 141.0
def test_ar_runner_grouped_chunk_stops_at_a_failing_step_and_drops_the_state(self, monkeypatch):
"""A failure mid-chunk ends the grouped call with that step's error; nothing runs after it."""
runner = self._make_grouping_runner(_FailsMidChunkPipeline(), grouping=True)
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
output = runner.execute_stepwise(_make_scheduler_output(_make_step_request(4)))
failed = output.get_request_output("req-1")
assert failed.finished is True
assert failed.result is not None and "boom mid chunk" in failed.result.error
assert runner.pipeline.denoise_calls == 2 and runner.pipeline.decode_calls == 0
assert "req-1" not in runner.state_cache
def test_ar_runner_fails_a_chunk_that_never_advances(self, monkeypatch):
pipeline = _ChunkedStepPipeline()
monkeypatch.setattr(pipeline, "step_scheduler", lambda *args, **kwargs: None)
runner = self._make_grouping_runner(pipeline, grouping=True)
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
output = runner.execute_stepwise(_make_scheduler_output(_make_step_request(4)))
failed = output.get_request_output("req-1")
assert failed.finished
assert "within 2 denoise steps" in failed.result.error
assert pipeline.denoise_calls == 2
assert "req-1" not in runner.state_cache
def test_base_runner_never_groups_and_the_ar_policy_needs_capability_streaming_and_one_request(self, monkeypatch):
"""The shared runner is one step per call; the AR runner groups only a lone streaming request that declares it."""
base = _make_runner()
base.od_config.streaming_output = True
base.pipeline = _ChunkedStepPipeline()
base.pipeline.supports_chunk_step_grouping = True
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
first = DiffusionModelRunner.execute_stepwise(base, _make_scheduler_output(_make_step_request(4)))
assert first.get_request_output("req-1").result is None and base.pipeline.denoise_calls == 1
ar = self._make_grouping_runner(_ChunkedStepPipeline(), grouping=False)
one = _make_cached_scheduler_output()
two = _make_batch_scheduler_output([_make_step_request(4), _make_step_request(4)])
two.scheduled_new_reqs[1].request_id = two.scheduled_new_reqs[1].req.request_id = "req-2"
assert ar._groups_chunk_steps(one) is False # no capability declared
ar.pipeline.supports_chunk_step_grouping = True
assert ar._groups_chunk_steps(one) is True
assert ar._groups_chunk_steps(two) is False # not for a batch
ar.od_config.streaming_output = False
assert ar._groups_chunk_steps(one) is False # not without streaming output
def test_stepwise_output_includes_stage_and_peak_metrics(self, monkeypatch):
runner = _make_runner()
runner.pipeline = _ProfilingStepPipeline()
@@ -64,6 +64,7 @@ _PUBLIC_OMNI = (
"SPEAKER_MAX_UPLOADED",
"SPEAKER_SAMPLES_DIR",
"VLLM_OMNI_ABORT_TIMEOUT",
"VLLM_OMNI_AR_DIFFUSION_KV_GATHER",
"VLLM_OMNI_ASYNC_OUTPUT_TIMEOUT",
"VLLM_OMNI_EVENT_DRIVEN_ORCH",
"VLLM_OMNI_FUSED_QK_NORM_ROPE_MIN_TOKENS",
@@ -505,22 +505,8 @@ class WanDistCausalConv3d(nn.Conv3d):
self._trim_cache: dict[int, tuple[int, int, int]] = {}
def forward(self, x: torch.Tensor, cache_x: torch.Tensor | None = None) -> torch.Tensor:
padding = list(self._padding)
if cache_x is not None and padding[4] > 0:
cache_x = cache_x.to(x.device)
x = torch.cat([cache_x, x], dim=2)
padding[4] -= cache_x.shape[2]
x = F.pad(x, padding)
x_padded, self._halo_recv_top_buf, self._halo_recv_bottom_buf = halo_exchange(
x,
group=self.group,
halo_size=self.halo_size,
split_dim=self.split_dim,
recv_top_buf=self._halo_recv_top_buf,
recv_bottom_buf=self._halo_recv_bottom_buf,
)
shift, start, upper_bound = self._get_trim_params(x.shape[self.split_tensor_dim])
x_padded, local_extent = self._assemble_input(x, cache_x)
shift, start, upper_bound = self._get_trim_params(local_extent)
if shift:
x_padded = _narrow_along_dim(
x_padded,
@@ -532,6 +518,83 @@ class WanDistCausalConv3d(nn.Conv3d):
out = _trim_local_conv_output(out, self.halo_size, start, upper_bound, split_dim=self.split_dim)
return _zero_invalid_extent(out, split_dim=self.split_dim)
def _assemble_input(self, x: torch.Tensor, cache_x: torch.Tensor | None) -> tuple[torch.Tensor, int]:
"""Build the conv input -- cached frames, causal/spatial zero padding and the halo columns -- in one pass.
The straightforward version wrote the activation three times per call: ``torch.cat`` of the cached
frames, ``F.pad`` for the causal and spatial zeros, then another ``torch.cat`` to attach the halos the
neighbours sent. Every causal conv of the decoder runs this on every latent frame, so at 480x832 that
was the largest single source of memory traffic in the decode. Here the final tensor is allocated once
and the cached frames and the new frames are copied straight into their slots; only the padding slices
are zeroed. The halo rows are exchanged through contiguous send and receive buffers (a P2P op needs a
contiguous tensor and the halo slot of a 5D tensor is a strided view) and copied into their slots. The
conv sees the same tensor as before, byte for byte.
Returns the assembled tensor and the extent along the split dimension the trim parameters are keyed by
(the extent before halos, as ``halo_exchange`` saw it).
"""
pad_w0, pad_w1, pad_h0, pad_h1, pad_t0, pad_t1 = self._padding
cache_frames = 0
if cache_x is not None and pad_t0 > 0:
cache_x = cache_x.to(x.device)
cache_frames = int(cache_x.shape[2])
pad_t0 -= cache_frames
assert pad_t0 >= 0, "Wan temporal cache exceeds the causal padding"
rank, world_size = _rank_world(self.group)
halo = self.halo_size if world_size > 1 else 0
halo_h = halo if self.split_dim == "height" else 0
halo_w = halo if self.split_dim == "width" else 0
batch, channels, frames, height, width = x.shape
top, left = pad_h0 + halo_h, pad_w0 + halo_w
total_frames = pad_t0 + cache_frames + frames + pad_t1
total_height = top + height + pad_h1 + halo_h
total_width = left + width + pad_w1 + halo_w
buf = torch.empty((batch, channels, total_frames, total_height, total_width), dtype=x.dtype, device=x.device)
# Zero only what the conv must read as zeros: the causal frames in
# front (and any trailing ones) and the spatial padding rows/columns.
# The halo columns are filled below.
if pad_t0:
buf[:, :, :pad_t0].zero_()
if pad_t1:
buf[:, :, total_frames - pad_t1 :].zero_()
if pad_h0:
buf[:, :, :, halo_h:top].zero_()
if pad_h1:
buf[:, :, :, total_height - halo_h - pad_h1 : total_height - halo_h].zero_()
if pad_w0:
buf[:, :, :, :, halo_w:left].zero_()
if pad_w1:
buf[:, :, :, :, total_width - halo_w - pad_w1 : total_width - halo_w].zero_()
interior = buf[:, :, pad_t0 : pad_t0 + cache_frames + frames, top : top + height, left : left + width]
if cache_frames:
interior[:, :, :cache_frames].copy_(cache_x)
interior[:, :, cache_frames:].copy_(x)
else:
interior.copy_(x)
dim = self.split_tensor_dim
local_extent = buf.shape[dim] - 2 * halo
if halo:
# What halo_exchange sends: the padded tensor's first and last
# ``halo`` rows along the split dimension, padding included.
top_row_ref = _narrow_along_dim(buf, dim, halo, halo)
bottom_row_ref = _narrow_along_dim(buf, dim, buf.shape[dim] - 2 * halo, halo)
self._halo_recv_top_buf = _ensure_recv_buf(self._halo_recv_top_buf, top_row_ref)
self._halo_recv_bottom_buf = _ensure_recv_buf(self._halo_recv_bottom_buf, bottom_row_ref)
_halo_exchange_p2p(
rank=rank,
world_size=world_size,
group=self.group,
top_row_ref=top_row_ref,
bottom_row_ref=bottom_row_ref,
recv_top_buf=self._halo_recv_top_buf,
recv_bottom_buf=self._halo_recv_bottom_buf,
)
_narrow_along_dim(buf, dim, 0, halo).copy_(self._halo_recv_top_buf)
_narrow_along_dim(buf, dim, buf.shape[dim] - halo, halo).copy_(self._halo_recv_bottom_buf)
return buf, local_extent
def _get_trim_params(self, local_extent: int) -> tuple[int, int, int]:
trim_params = self._trim_cache.get(local_extent)
if trim_params is None:
+7
View File
@@ -92,6 +92,13 @@ class SupportsStepExecution(Protocol):
``prepare_encode()`` (one-time request setup), ``denoise_step()``
(one denoise forward), ``step_scheduler()`` (one scheduler update),
and ``post_decode()`` (final decode).
A pipeline may additionally set the optional class attribute
``supports_chunk_step_grouping = True`` to declare that its request state
may advance through every denoise step of its current chunk without a
serving-scheduler cycle in between (a capability, not a policy: the runner
that owns the scheduling decision chooses whether to group steps). It is
optional so it does not become part of the runtime protocol check.
"""
supports_step_execution: ClassVar[bool] = True
@@ -22,6 +22,7 @@ from diffusers.utils.torch_utils import randn_tensor
from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx
from vllm_omni.diffusion.models.lingbot_world.transformer import (
CameraModulationCache,
LingBotAttentionCache,
LingBotTransformerCache,
)
@@ -109,11 +110,16 @@ class LingBotDMDBlockRunner:
start_frame: int,
timestep_value: float,
step_index: int,
camera_cache: CameraModulationCache | None = None,
) -> torch.Tensor:
"""Predict flow for one denoise step.
A probe never writes KV: only the clean x0 of a finished block may
enter the cache, which is ``commit_block_kv``'s job.
``camera_cache`` is the block's camera-modulation cache when the caller
holds it across separate calls (the stepwise path); ``generate_block``
opens the window itself instead.
"""
if not self.enforce_eager:
torch.compiler.cudagraph_mark_step_begin()
@@ -130,6 +136,7 @@ class LingBotDMDBlockRunner:
cache=self._block_cache(condition=condition, cache=cache, ar=ar, commit_current=False),
start_frame=start_frame,
update_cache=False,
camera_modulation_cache=camera_cache,
)
if flow_prediction.shape != current_latents.shape:
raise RuntimeError(
@@ -172,11 +179,13 @@ class LingBotDMDBlockRunner:
cache: LingBotTransformerCache | None,
ar: ARBlockContext | None,
start_frame: int,
camera_cache: CameraModulationCache | None = None,
) -> None:
"""Write the finished block's clean x0 into KV and commit its pages.
The fifth transformer call of a block, deliberately not a denoise step:
on the stepwise path it belongs to ``post_decode()``.
on the stepwise path it belongs to ``post_decode()``. ``camera_cache``
is as in ``probe_step``.
"""
# Commit K/V only for the final clean block, never for noisy probes.
cache_input = torch.cat((latents.to(dtype=condition.dtype), condition), dim=1)
@@ -190,6 +199,7 @@ class LingBotDMDBlockRunner:
cache=self._block_cache(condition=condition, cache=cache, ar=ar, commit_current=True),
start_frame=start_frame,
update_cache=True,
camera_modulation_cache=camera_cache,
)
if ar is not None:
ar.state.commit_paged_context(ar.branch)
@@ -210,6 +220,10 @@ class LingBotDMDBlockRunner:
"""Request mode: all probes of one block, then its commit."""
block_shape = (1, self.transformer.config.out_channels, *condition.shape[2:5])
current_latents = randn_tensor(block_shape, generator=generator, device=self.device, dtype=torch.float32)
# Every forward below -- each probe and the commit -- reads the same camera trajectory, so the camera
# injector is built once per block: one cache for the whole loop, exactly as the stepwise path holds
# one per block across its separate calls.
camera_cache = CameraModulationCache()
for step_index, (timestep_value, sigma) in enumerate(schedule):
flow_prediction = self.probe_step(
current_latents=current_latents,
@@ -221,6 +235,7 @@ class LingBotDMDBlockRunner:
start_frame=start_frame,
timestep_value=timestep_value,
step_index=step_index,
camera_cache=camera_cache,
)
next_sigma = schedule[step_index + 1][1] if step_index + 1 < len(schedule) else None
current_latents = self.apply_transition(
@@ -235,5 +250,6 @@ class LingBotDMDBlockRunner:
cache=cache,
ar=ar,
start_frame=start_frame,
camera_cache=camera_cache,
)
return current_latents
@@ -4,6 +4,7 @@
from __future__ import annotations
import functools
import math
import os
from collections import OrderedDict
@@ -54,6 +55,7 @@ from vllm_omni.diffusion.models.lingbot_world.camera import (
)
from vllm_omni.diffusion.models.lingbot_world.dmd_block import ARBlockContext, LingBotDMDBlockRunner
from vllm_omni.diffusion.models.lingbot_world.transformer import (
CameraModulationCache,
CausalLingBotWorldTransformer3DModel,
LingBotAttentionCache,
LingBotTransformerCache,
@@ -91,7 +93,6 @@ logger = init_logger(__name__)
_STREAMING_DECODE_BYTES_PER_PIXEL_FP32 = 37832 * 1024 / (64 * 64)
if TYPE_CHECKING:
from diffusers.video_processor import VideoProcessor
from tqdm.std import tqdm as TqdmProgressBar
from vllm_omni.diffusion.worker.input_batch import InputBatch
@@ -152,6 +153,9 @@ class _LingBotRequestInputs:
width: int
num_frames: int
num_latent_frames: int
# Request mode returns pixels through VideoProcessor in this type. Step execution (realtime sessions)
# returns every pixel type as ``(F, H, W, 3)`` uint8 frames, the layout the streaming endpoint muxes; only
# ``"latent"`` differs there. See ``_decode_chunk_to_pixels``.
output_type: str
max_sequence_length: int
flow_shift: float
@@ -163,10 +167,20 @@ class _LingBotARSessionState:
"""Model-owned state; attention tensors remain runner-owned."""
next_chunk_index: int = 0
prompt: str | None = None
# The umT5 embedding itself is reused by ``encode_prompt``'s own cache.
# ``text_cache_key`` does the same job for the runner-owned cross-attention
# K/V built from that embedding: it holds the (prompt, max_sequence_length,
# dtype) the K/V was built from and is only published after a successful
# build; ``text_cache_dirty`` is raised for the duration of a build, so a
# build that fails forces the next tick to invalidate whatever its prompt is.
text_cache_key: tuple[str, int, str] | None = None
text_cache_dirty: bool = False
generator_state: torch.Tensor | None = None
encoder_cache: list[torch.Tensor | None] | None = None
pending_encoder_cache: list[torch.Tensor | None] | None = None
# The condition the encoder settles on once its history has stopped
# changing; see ``_next_condition_chunk``.
condition_fixed_point: torch.Tensor | None = None
camera_tail: CameraTrajectory | None = None
camera_pitch: float = 0.0
@@ -284,7 +298,70 @@ def _decode_source_image(image: PIL.Image.Image) -> PIL.Image.Image:
raise ValueError(_SOURCE_IMAGE_ERROR) from None
def _source_image_fingerprint(path: str | os.PathLike[str]) -> tuple[str, int, int, int, int, int]:
"""Identify the bytes behind ``path``: the resolved path plus the stat fields that change when it is rewritten."""
resolved = os.path.realpath(os.fspath(path))
stat = os.stat(resolved)
return resolved, stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns
def _encoder_caches_identical(
before: list[torch.Tensor | None] | None, after: list[torch.Tensor | None] | None
) -> bool:
"""Whether two encoder histories hold the same tensors bit for bit, slot by slot."""
if before is None or after is None or len(before) != len(after):
return False
for x, y in zip(before, after, strict=True):
if x is None or y is None:
if x is not y:
return False
elif x.shape != y.shape or x.dtype != y.dtype or not torch.equal(x, y):
return False
return True
def _uint8_frames(video: torch.Tensor) -> np.ndarray:
"""Turn one decoded block ``(1, 3, F, H, W)`` in ``[-1, 1]`` into ``(F, H, W, 3)`` uint8 on the host.
This is the arithmetic ``VideoProcessor.postprocess_video`` applies for
``"np"`` and ``"pil"`` -- denormalise, widen to float32, scale by 255 and
round -- done on the device before the copy instead of on the host after
it. The bytes are identical to the PIL path; what changes is that the
frames cross to the host once, already in the layout and dtype the
streaming endpoint muxes, instead of as a float32 copy that is then
converted per frame while the next block waits.
"""
# Same order of operations as VideoProcessor: denormalise and clamp in
# the decoder dtype, then widen, scale and round.
frames = (video[0] / 2 + 0.5).clamp(0, 1).permute(1, 2, 3, 0)
frames = frames.float().mul_(255).round_().to(torch.uint8)
return np.ascontiguousarray(frames.cpu().numpy())
@functools.lru_cache(maxsize=1)
def _decode_source_image_fingerprinted(fingerprint: tuple[str, int, int, int, int, int]) -> PIL.Image.Image:
"""The decoded image behind ``fingerprint`` (resolved path plus the stat fields that change on rewrite).
One entry: a request-mode caller that replays the same source path decodes it once. The realtime tick
carries its image inline and never comes through here.
"""
return _decode_source_image_file(fingerprint[0])
def _load_source_image(path: str | os.PathLike[str]) -> PIL.Image.Image:
"""Decode the source image at ``path``, reusing the last decode while the file is unchanged.
Every caller gets its own copy, so a request that mutates its image cannot reach the next one. A path
that cannot be stat'ed goes straight to the uncached decode, which owns the validation error contract.
"""
try:
fingerprint = _source_image_fingerprint(path)
except (OSError, TypeError, ValueError):
return _decode_source_image_file(path)
return _decode_source_image_fingerprinted(fingerprint).copy()
def _decode_source_image_file(path: str | os.PathLike[str]) -> PIL.Image.Image:
try:
source_image = PIL.Image.open(path)
except (OSError, SyntaxError, ValueError, PIL.Image.DecompressionBombError):
@@ -483,6 +560,9 @@ class LingBotWorldCausalDMDPipeline(
"""LingBot-World v2 I2V generation with a request-local causal cache."""
supports_step_execution: ClassVar[bool] = True
# A realtime block is four probes and a commit over one prepared chunk
# state; nothing between those steps needs the serving scheduler.
supports_chunk_step_grouping: ClassVar[bool] = True
_dit_modules: ClassVar[list[str]] = ["transformer"]
_encoder_modules: ClassVar[list[str]] = ["text_encoder"]
_vae_modules: ClassVar[list[str]] = ["vae"]
@@ -637,12 +717,21 @@ class LingBotWorldCausalDMDPipeline(
raise ValueError("LingBot AR-Diffusion cache needs a positive recent window after reserving sink frames.")
condition_latent_frames = int(self.transformer.config.num_frames_per_block)
condition_channels = self.vae_scale_factor_temporal + int(self.transformer.config.out_channels)
element_size = torch.empty((), dtype=self.transformer.dtype).element_size()
condition_bytes_per_session = (
condition_channels
* condition_latent_frames
* latent_height
* latent_width
* torch.empty((), dtype=self.transformer.dtype).element_size()
condition_channels * condition_latent_frames * latent_height * latent_width * element_size
)
# Each layer retains scale and shift for the rank-local camera tokens
# across all forwards of a block, including between scheduler calls.
camera_tokens = tokens_per_frame * condition_latent_frames
sp_size = int(self.od_config.parallel_config.ulysses_degree)
camera_bytes_per_session = (
2
* int(self.transformer.config.num_layers)
* ((camera_tokens + sp_size - 1) // sp_size)
* int(self.transformer.config.num_attention_heads)
* int(self.transformer.config.attention_head_dim)
* element_size
)
return ARDiffusionKVCacheSpec(
num_layers=int(self.transformer.config.num_layers),
@@ -658,10 +747,14 @@ class LingBotWorldCausalDMDPipeline(
ARDiffusionCrossAttentionKVSpec(
self._AR_TEXT_CACHE,
_MAX_SEQUENCE_LENGTH,
# Cross-attention keeps every local head on every rank rather than sharding heads across
# the Ulysses group, so the text pool holds that many heads, not the self-attention share.
num_kv_heads=int(self.transformer.blocks[0].cross_attn.num_local_heads),
),
),
model_owned_state_bytes_per_session=(
condition_bytes_per_session
+ camera_bytes_per_session
+ 2 * self._condition_encoder_cache_bytes() # Committed and in-flight histories.
+ self._streaming_decode_bytes_per_session()
),
@@ -733,6 +826,7 @@ class LingBotWorldCausalDMDPipeline(
if state is not None:
state.encoder_cache = None
state.pending_encoder_cache = None
state.condition_fixed_point = None
def reset_ar_diffusion_session(self, session_id: str) -> None:
self._release_condition_encoder_state(session_id)
@@ -988,6 +1082,50 @@ class LingBotWorldCausalDMDPipeline(
)
return condition
def _next_condition_chunk(
self,
inputs: _LingBotRequestInputs,
*,
start_frame: int,
session_state: _LingBotARSessionState,
dtype: torch.dtype,
) -> tuple[torch.Tensor, list[torch.Tensor | None]]:
"""The session's condition for the block at ``start_frame``, encoding only while it still changes.
After a session's opening block the encoder is fed the same all-zero
pixel frames on every block, so the only thing that can make one
block's condition differ from the last is the encoder's own history.
The history is a causal cache with finite temporal reach, and once the
zero frames have flushed the source image out of it the encoder is at
a fixed point: identical input and identical state produce the same
output and the same state on every later block. That fixed point is
detected, not assumed after some number of blocks: a block whose
returned history is bit-identical to the history it was given is the
proof, and from then on the stored condition is returned and the
encoder is not run. Measured at 480x832 the encode is 178 ms of a
block and the fixed point arrives by the eleventh; the served
rollouts this was measured on (40 blocks) are then byte-identical.
The opening block resets the fixed point, so a session that restarts
from a new source image encodes again until it settles.
"""
if start_frame == 0:
session_state.condition_fixed_point = None
elif session_state.condition_fixed_point is not None:
assert session_state.encoder_cache is not None
# The history is unchanged by construction; the pending copy is
# what post_decode commits, exactly as an encoded block's would be.
return session_state.condition_fixed_point.clone(), list(session_state.encoder_cache)
condition, pending_cache = self._prepare_condition_chunk(
inputs,
start_frame=start_frame,
encoder_cache=session_state.encoder_cache,
dtype=dtype,
)
if start_frame > 0 and _encoder_caches_identical(session_state.encoder_cache, pending_cache):
session_state.condition_fixed_point = condition.clone()
return condition, pending_cache
@torch.no_grad()
def _prepare_condition_chunk(
self,
@@ -1160,10 +1298,8 @@ class LingBotWorldCausalDMDPipeline(
for block in self.transformer.blocks:
cross_attention = block.cross_attn
shape = (cross_attention.num_local_heads, cross_attention.head_dim)
key = cross_attention.shard_kv_heads(
cross_attention.norm_k(cross_attention.k(projected_text)).unflatten(2, shape)
)
value = cross_attention.shard_kv_heads(cross_attention.v(projected_text).unflatten(2, shape))
key = cross_attention.norm_k(cross_attention.k(projected_text)).unflatten(2, shape)
value = cross_attention.v(projected_text).unflatten(2, shape)
yield key, value
state.populate_cross_attention(
@@ -1243,6 +1379,34 @@ class LingBotWorldCausalDMDPipeline(
progress_bar=progress_bar,
)
def _session_text_caches(
self,
prompt_embeds: torch.Tensor,
*,
prompt: str,
max_sequence_length: int,
session_state: _LingBotARSessionState,
) -> list[LingBotAttentionCache]:
"""Runner-owned cross-attention K/V for this tick, rebuilt when its inputs change.
The K/V is keyed by everything that defines the prompt embedding
(text, sequence length, dtype). The first build of a session needs no
invalidation (the runner state is empty); a key change does. The dirty
flag is raised for the duration of the build and cleared only after
``_ar_text_caches`` returns, so a build that fails after invalidating
or partially writing the caches makes the next tick, whatever its
prompt, invalidate again instead of consuming partial K/V.
"""
text_key = (prompt, int(max_sequence_length), str(prompt_embeds.dtype))
previous = session_state.text_cache_key
invalidate = session_state.text_cache_dirty or (previous is not None and previous != text_key)
session_state.text_cache_dirty = True
session_state.text_cache_key = None
caches = self._ar_text_caches(prompt_embeds, invalidate=invalidate)
session_state.text_cache_key = text_key
session_state.text_cache_dirty = False
return caches
def encode_prompt(
self,
prompt: str,
@@ -1345,6 +1509,9 @@ class LingBotWorldCausalDMDPipeline(
)
dtype = self.transformer.dtype
# Phase 1: turn all three user inputs into DiT-ready conditions.
# ``encode_prompt`` reuses an already-encoded prompt itself; the
# runner-owned cross-attention K/V is keyed the same way in
# ``_session_text_caches`` below.
prompt_embeds = self.encode_prompt(
inputs.prompt,
max_sequence_length=inputs.max_sequence_length,
@@ -1354,10 +1521,10 @@ class LingBotWorldCausalDMDPipeline(
condition = self._prepare_condition(inputs, dtype=dtype)
else:
assert session_state is not None
condition, pending_encoder_cache = self._prepare_condition_chunk(
condition, pending_encoder_cache = self._next_condition_chunk(
inputs,
start_frame=tick.chunk_index * block_frames,
encoder_cache=session_state.encoder_cache,
session_state=session_state,
dtype=dtype,
)
camera_pitch: float | None = None
@@ -1396,12 +1563,13 @@ class LingBotWorldCausalDMDPipeline(
)
else:
assert session_state is not None
prompt_changed = session_state.prompt is not None and session_state.prompt != inputs.prompt
if session_state.generator_state is not None:
inputs.generator.set_state(session_state.generator_state)
ar_cross_attention = self._ar_text_caches(
ar_cross_attention = self._session_text_caches(
prompt_embeds,
invalidate=prompt_changed,
prompt=inputs.prompt,
max_sequence_length=inputs.max_sequence_length,
session_state=session_state,
)
generated_blocks: list[torch.Tensor] = []
total_steps = (inputs.num_latent_frames // block_frames) * len(LINGBOT_DMD_TIMESTEPS)
@@ -1437,7 +1605,6 @@ class LingBotWorldCausalDMDPipeline(
cache = None
if tick is not None:
assert session_state is not None
session_state.prompt = inputs.prompt
session_state.generator_state = inputs.generator.get_state()
session_state.encoder_cache = pending_encoder_cache
session_state.camera_tail = camera_tail
@@ -1536,11 +1703,15 @@ class LingBotWorldCausalDMDPipeline(
if state is not None:
state.release()
def _decode_chunk_to_pixels(
self, latents: torch.Tensor, *, output_type: str, session_id: str | None = None
) -> torch.Tensor | np.ndarray | list[list[PIL.Image.Image]]:
def _decode_chunk_to_pixels(self, latents: torch.Tensor, *, session_id: str | None = None) -> np.ndarray:
"""Decode one AR block so streaming consumers receive pixels, not latents.
The block comes back as ``(F, H, W, 3)`` uint8 whatever pixel output
type the request named: the realtime endpoint muxes uint8 frames, so
``"pil"`` and ``"np"`` only ever differed in how much work stood
between the decoder and those bytes. Only ``"latent"`` means
something else, and the caller keeps latents before reaching here.
With a ``session_id`` the block is decoded through that session's own
temporal cache, so chunk *N + 1* continues chunk *N*: the causal decoder
expands the session's opening latent frame to one raw frame and every
@@ -1566,7 +1737,7 @@ class LingBotWorldCausalDMDPipeline(
video = self.vae.decode(vae_latents, return_dict=False)[0]
else:
video = self._streaming_decode_chunk(decoder, vae_latents, cast(str, session_id))
return self._video_processor().postprocess_video(video, output_type=output_type)
return _uint8_frames(video)
def _streaming_decode_chunk(
self, decoder: WanStreamingDecoder, vae_latents: torch.Tensor, session_id: str
@@ -1587,15 +1758,6 @@ class LingBotWorldCausalDMDPipeline(
self._release_streaming_decode_state(session_id)
raise
def _video_processor(self) -> VideoProcessor:
processor = getattr(self, "_cached_video_processor", None)
if processor is None:
from diffusers.video_processor import VideoProcessor
processor = VideoProcessor(vae_scale_factor=self.vae_scale_factor_spatial)
self._cached_video_processor = processor
return processor
def _require_bound_ar_state(self) -> None:
if self._ar_diffusion_kv_state is None:
raise RuntimeError("LingBot step execution requires AR-Diffusion session binding.")
@@ -1683,10 +1845,10 @@ class LingBotWorldCausalDMDPipeline(
session_state = self._ar_sessions[state.request_id]
if session_state.next_chunk_index != state.chunk_index:
raise ValueError("LingBot condition chunks must be contiguous within the session.")
condition, pending_encoder_cache = self._prepare_condition_chunk(
condition, pending_encoder_cache = self._next_condition_chunk(
inputs,
start_frame=start_frame,
encoder_cache=session_state.encoder_cache,
session_state=session_state,
dtype=extra["dtype"],
)
previous = extra.get("camera_tail")
@@ -1780,6 +1942,12 @@ class LingBotWorldCausalDMDPipeline(
raise ValueError("LingBot World requires the runner-provided torch.Generator.")
extra["condition"] = condition
extra["camera"] = camera
# One camera-modulation cache per block: the four probes and the
# commit below are separate scheduler steps, so the block runner
# cannot open the reuse window around them the way generate_block
# does around its own loop. Holding the cache here makes the window
# span exactly this block, per request.
extra["camera_cache"] = CameraModulationCache()
extra["camera_tail"] = camera_tail
extra["start_frame"] = start_frame
extra["ar_cross_attention"] = self._ar_text_caches(
@@ -1838,6 +2006,7 @@ class LingBotWorldCausalDMDPipeline(
start_frame=int(extra["start_frame"]),
timestep_value=float(schedule[step_in_chunk][0]),
step_index=step_in_chunk,
camera_cache=extra["camera_cache"],
)
def step_scheduler(self, state: StepRequestState, noise_pred: torch.Tensor, **kwargs: Any) -> None:
@@ -1880,6 +2049,7 @@ class LingBotWorldCausalDMDPipeline(
cache=None,
ar=self._ar_block_context(extra["ar_cross_attention"]),
start_frame=int(extra["start_frame"]),
camera_cache=extra.pop("camera_cache"),
)
completed_chunk_index = state.chunk_index
inputs: _LingBotRequestInputs = extra["inputs"]
@@ -1889,13 +2059,7 @@ class LingBotWorldCausalDMDPipeline(
if inputs.output_type == "latent":
payload: dict[str, Any] = {"latents": latents}
else:
payload = {
"video": self._decode_chunk_to_pixels(
latents,
output_type=inputs.output_type,
session_id=state.request_id,
)
}
payload = {"video": self._decode_chunk_to_pixels(latents, session_id=state.request_id)}
output = {
"payload": payload,
"metadata": {
@@ -24,7 +24,7 @@ from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm.model_executor.utils import set_weight_attrs
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.distributed.comm import SeqAllToAll4D
from vllm_omni.diffusion.distributed.comm import SeqAllToAll4D, all_to_all_5D
from vllm_omni.diffusion.distributed.parallel_state import get_sp_group
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelInput, SequenceParallelOutput
from vllm_omni.diffusion.layers.norm import LayerNorm
@@ -329,9 +329,18 @@ class LingBotSelfAttention(nn.Module):
key = self.rotary_embedding(key, cos, sin)
if self.ulysses_world_size > 1:
query = SeqAllToAll4D.apply(self.ulysses_group, query, 2, 1, False)
key = SeqAllToAll4D.apply(self.ulysses_group, key, 2, 1, False)
value = SeqAllToAll4D.apply(self.ulysses_group, value, 2, 1, False)
# One fused sequence->head all-to-all for q/k/v instead of three:
# (B, S/N, 3, H, D) -> (B, S, 3, H/N, D). Same head-group assignment
# as the per-tensor 4D exchange (rank r receives head group r), so
# the result is a pure permutation of the three-call version, with a
# 3x larger message and one third of the latency-bound collectives.
qkv = all_to_all_5D(
torch.stack((query, key, value), dim=2),
scatter_idx=3,
gather_idx=1,
group=self.ulysses_group,
)
query, key, value = qkv.unbind(2)
if isinstance(cache, ARDiffusionPagedLayerInputs):
if query.shape[0] != 1:
@@ -421,13 +430,11 @@ class LingBotCrossAttention(nn.Module):
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.num_local_heads = num_heads // tp_size
self.ulysses_world_size, self.ulysses_rank, self.ulysses_group = _ulysses_state()
if self.num_local_heads % self.ulysses_world_size:
raise ValueError(
"LingBot local cross-attention heads must be divisible by the Ulysses degree: "
f"heads={self.num_local_heads}, ulysses={self.ulysses_world_size}."
)
self.num_sp_heads = self.num_local_heads // self.ulysses_world_size
# Cross-attention keeps every local head on every Ulysses rank and attends the rank's own sequence
# shard. Each query token attends the whole text sequence independently of the others, so there is
# nothing to exchange: the sequence split Ulysses already gives us is the only split needed, and both
# all-to-all collectives -- one for the query, one for the output -- disappear. The cost is that the text K/V is
# replicated per rank instead of head-sharded; ar_diffusion_kv_cache_spec sizes the pool for it.
self.tp_inner_dim = self.num_local_heads * self.head_dim
self.q = ColumnParallelLinear(
@@ -465,9 +472,9 @@ class LingBotCrossAttention(nn.Module):
self.norm_q = _LingBotRMSNorm(self.tp_inner_dim, eps)
self.norm_k = _LingBotRMSNorm(self.tp_inner_dim, eps)
self.attn = Attention(
num_heads=self.num_sp_heads,
num_heads=self.num_local_heads,
head_size=self.head_dim,
num_kv_heads=self.num_sp_heads,
num_kv_heads=self.num_local_heads,
softmax_scale=self.head_dim**-0.5,
causal=False,
role="cross",
@@ -477,14 +484,6 @@ class LingBotCrossAttention(nn.Module):
disable_kv_quant=True,
)
def shard_kv_heads(self, value: torch.Tensor) -> torch.Tensor:
"""Select this Ulysses rank's projected K/V heads."""
if self.ulysses_world_size == 1:
return value
start = self.ulysses_rank * self.num_sp_heads
return value[:, :, start : start + self.num_sp_heads].clone(memory_format=torch.contiguous_format)
def forward(
self,
hidden_states: torch.Tensor,
@@ -494,8 +493,6 @@ class LingBotCrossAttention(nn.Module):
) -> tuple[torch.Tensor, LingBotAttentionCache]:
query = self.norm_q(self.q(hidden_states))
query = query.unflatten(2, (self.num_local_heads, self.head_dim))
if self.ulysses_world_size > 1:
query = SeqAllToAll4D.apply(self.ulysses_group, query, 2, 1, False)
# Text K/V is constant within a request and is projected once per layer.
if cache is None:
@@ -503,8 +500,8 @@ class LingBotCrossAttention(nn.Module):
raise ValueError("encoder_hidden_states are required when the cross-attention cache is empty.")
key = self.norm_k(self.k(encoder_hidden_states))
value = self.v(encoder_hidden_states)
key = self.shard_kv_heads(key.unflatten(2, (self.num_local_heads, self.head_dim)))
value = self.shard_kv_heads(value.unflatten(2, (self.num_local_heads, self.head_dim)))
key = key.unflatten(2, (self.num_local_heads, self.head_dim))
value = value.unflatten(2, (self.num_local_heads, self.head_dim))
cache = LingBotAttentionCache(
key=key,
value=value,
@@ -517,8 +514,6 @@ class LingBotCrossAttention(nn.Module):
value = cache.value[:, : cache.end]
output = self.attn(query, key, value)
if self.ulysses_world_size > 1:
output = SeqAllToAll4D.apply(self.ulysses_group, output, 1, 2, False)
return self.o(output.flatten(2, 3)), cache
@@ -592,6 +587,16 @@ class LingBotAttentionBlock(nn.Module):
self.cam_scale_layer = nn.Linear(dim, dim)
self.cam_shift_layer = nn.Linear(dim, dim)
def camera_modulation(self, camera_hidden_states: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
"""Build this block's camera scale and shift from the patchified camera tokens.
Depends only on ``camera_hidden_states``, so a caller holding the camera trajectory fixed -- every
denoise forward of one AR chunk does -- can build this once and pass it back through ``forward``.
"""
camera_features = self.cam_injector_layer2(F.silu(self.cam_injector_layer1(camera_hidden_states)))
camera_features = camera_features + camera_hidden_states
return self.cam_scale_layer(camera_features), self.cam_shift_layer(camera_features)
def forward(
self,
hidden_states: torch.Tensor,
@@ -605,6 +610,7 @@ class LingBotAttentionBlock(nn.Module):
sink_tokens: int,
update_cache: bool,
rotary_emb: tuple[torch.Tensor, torch.Tensor],
camera_modulation: tuple[torch.Tensor, torch.Tensor] | None = None,
) -> tuple[torch.Tensor, LingBotAttentionCache]:
batch_size, token_count, dim = hidden_states.shape
if (
@@ -638,10 +644,9 @@ class LingBotAttentionBlock(nn.Module):
hidden_grid = hidden_grid + attention_output.unflatten(1, (num_frames, tokens_per_frame)) * gate_msa
hidden_states = hidden_grid.flatten(1, 2).to(hidden_states.dtype)
camera_features = self.cam_injector_layer2(F.silu(self.cam_injector_layer1(camera_hidden_states)))
camera_features = camera_features + camera_hidden_states
camera_scale = self.cam_scale_layer(camera_features)
camera_shift = self.cam_shift_layer(camera_features)
camera_scale, camera_shift = (
self.camera_modulation(camera_hidden_states) if camera_modulation is None else camera_modulation
)
hidden_states = ((1 + camera_scale) * hidden_states + camera_shift).to(hidden_states.dtype)
attention_output, cross_cache = self.cross_attn(
@@ -763,6 +768,22 @@ def _rope_axis(max_seq_len: int, dim: int, *, start: int = 0) -> tuple[torch.Ten
return phase.cos().float(), phase.sin().float()
class CameraModulationCache:
"""Per-block camera scale and shift, held by the caller across the forwards of one AR block.
The camera injector is four projections and a SiLU per block and reads only the camera tokens, which do
not change between the denoise steps and the clean commit of one block. The caller that owns that
invariant creates one of these per block and passes it to every forward of the block; ``forward`` fills a
block's entry on first use and reads it after. Its lifetime is the caller's: the AR runner keeps one per
block, ``generate_block`` one per loop, and nothing here checks the camera tensor for change.
"""
__slots__ = ("entries",)
def __init__(self) -> None:
self.entries: dict[int, tuple[torch.Tensor, torch.Tensor]] = {}
class CausalLingBotWorldTransformer3DModel(nn.Module):
"""Checkpoint-compatible causal LingBot World video transformer."""
@@ -922,6 +943,27 @@ class CausalLingBotWorldTransformer3DModel(nn.Module):
cosine, sine = _rope_axis(rope_max_seq_len, axis_dim)
self.register_buffer(f"_rope_{axis}_cosine", cosine, persistent=False)
self.register_buffer(f"_rope_{axis}_sine", sine, persistent=False)
# One static-address buffer per shape for the per-token timestep
# projection; see _stage_timestep_projection.
self._timestep_projection_buffers: dict[tuple[tuple[int, ...], torch.dtype, torch.device], torch.Tensor] = {}
def _stage_timestep_projection(self, projection: torch.Tensor) -> torch.Tensor:
"""Hand the blocks the timestep projection from a buffer whose address never changes.
Every regional block graph takes the projection as an input. Under CUDA-graph replay an input that
arrives in a fresh tensor each forward is copied into the graph's placeholder on every replay: at
480x832 with four Ulysses ranks that is a 72 MB copy per block, forty times per forward, two hundred
times per AR chunk. Copying the projection once per forward into a buffer marked static lets every
replay read it in place. The values the blocks see are the same; only where they live changes.
"""
key = (tuple(projection.shape), projection.dtype, projection.device)
buffer = self._timestep_projection_buffers.get(key)
if buffer is None:
buffer = torch.empty(projection.shape, dtype=projection.dtype, device=projection.device)
torch._dynamo.mark_static_address(buffer)
self._timestep_projection_buffers[key] = buffer
buffer.copy_(projection)
return buffer
@property
def dtype(self) -> torch.dtype:
@@ -1178,6 +1220,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module):
cache: LingBotTransformerCache,
start_frame: int,
update_cache: bool,
camera_modulation_cache: CameraModulationCache | None = None,
) -> torch.Tensor:
if not isinstance(update_cache, bool):
raise ValueError("update_cache must be a boolean.")
@@ -1272,16 +1315,25 @@ class CausalLingBotWorldTransformer3DModel(nn.Module):
"LingBot SP input hooks did not shard all conditioning tensors consistently; "
f"expected {local_tokens} tokens per rank. Check SP hook registration and input dimensions."
)
timestep_projection = self._stage_timestep_projection(timestep_projection)
rotary_emb = (cosine, sine)
# Phase 3: each layer receives its own cache entry. Text K/V is passed
# only when absent; the returned cache is stored for subsequent DMD
# steps and causal blocks in this request.
# steps and causal blocks in this request. A caller-held camera cache
# gives every block its modulation once per AR block.
for index, block in enumerate(self.blocks):
camera_modulation = None
if camera_modulation_cache is not None:
camera_modulation = camera_modulation_cache.entries.get(index)
if camera_modulation is None:
camera_modulation = block.camera_modulation(camera_hidden_states)
camera_modulation_cache.entries[index] = camera_modulation
hidden_states, cross_cache = block(
hidden_states,
projected_text if cache.cross_attention[index] is None else None,
timestep_projection,
camera_hidden_states,
camera_modulation=camera_modulation,
self_cache=cache.self_attention[index],
cross_cache=cache.cross_attention[index],
current_start=current_start,
+10 -3
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Diffusion input-batch structures following the MRV2-style vLLM layout.
Request states remain the only persistent source of truth. Static tensors are
@@ -67,19 +67,26 @@ def _select_states(
raise ValueError("Cannot build InputBatch from empty states.")
if idx_mapping is None:
# The identity mapping is known on the host; build the device copy as
# a launch and the host copy from NumPy rather than reading the device
# tensor back. This runs on every step of every stepwise request, and
# a device-to-host read here would stall the host until the queued
# denoise work drained, so it must not be a synchronising operation.
device = states[0].latents.device if states[0].latents is not None else None
idx_mapping = torch.arange(len(states), dtype=torch.int32, device=device)
idx_mapping_np = np.arange(len(states), dtype=np.int32)
else:
if idx_mapping.ndim != 1:
raise ValueError("idx_mapping must be a 1D tensor.")
idx_mapping = idx_mapping.to(dtype=torch.int32)
idx_mapping_np = idx_mapping.detach().cpu().numpy()
selected_states: list[StepRequestState] = []
for batch_idx, state_idx in enumerate(idx_mapping.tolist()):
for batch_idx, state_idx in enumerate(idx_mapping_np.tolist()):
if state_idx < 0 or state_idx >= len(states):
raise ValueError(f"idx_mapping[{batch_idx}]={state_idx} is out of range for states.")
selected_states.append(states[state_idx])
return selected_states, idx_mapping, idx_mapping.detach().cpu().numpy()
return selected_states, idx_mapping, idx_mapping_np
def _prepare_request_ids(states: Sequence[StepRequestState]) -> list[str]:
@@ -40,12 +40,18 @@ class ARDiffusionCrossAttentionKVSpec:
name: str
num_tokens: int
# Head count of this cross-attention cache. Cross-attention need not be sharded the same way as
# self-attention: a model whose cross-attention keeps every local head on every rank stores that many heads
# here instead of its self-attention share. None means "same as self-attention", the existing behaviour.
num_kv_heads: int | None = None
def __post_init__(self) -> None:
if not self.name:
raise ValueError("AR-Diffusion cross-attention cache names must be non-empty")
if self.num_tokens <= 0:
raise ValueError(f"AR-Diffusion cross-attention num_tokens must be positive, got {self.num_tokens}")
if self.num_kv_heads is not None and self.num_kv_heads <= 0:
raise ValueError(f"AR-Diffusion cross-attention num_kv_heads must be positive, got {self.num_kv_heads}")
@dataclass(frozen=True)
@@ -135,6 +141,11 @@ class ARDiffusionKVCacheSpec:
def cross_attention_lengths(self) -> dict[str, int]:
return {cache.name: cache.num_tokens for cache in self.cross_attention}
@property
def cross_attention_kv_heads(self) -> dict[str, int]:
"""Per-cache head counts, for the caches that do not use the self-attention head count."""
return {cache.name: cache.num_kv_heads for cache in self.cross_attention if cache.num_kv_heads is not None}
@runtime_checkable
class SupportsARDiffusionPipeline(Protocol):
@@ -181,3 +192,12 @@ class SupportsARDiffusionWarmup(Protocol):
def ar_diffusion_warmup_requests(self, session_id: str) -> Iterable[OmniDiffusionRequest]:
"""Yield requests for compiled shapes, each carrying ``session_id``."""
...
def supports_chunk_step_grouping(pipeline: object) -> bool:
"""Whether a step-execution ``pipeline`` declares that one chunk's steps may run without scheduler cycles.
An AR-runner policy input, consulted only after the pipeline has already been admitted to step
execution; LingBot-World declares it as a class attribute.
"""
return bool(getattr(pipeline, "supports_chunk_step_grouping", False))
@@ -1,10 +1,24 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Configuration for AR-Diffusion engine-level KV cache management."""
from __future__ import annotations
import os
from dataclasses import dataclass
KV_GATHER_ENV = "VLLM_OMNI_AR_DIFFUSION_KV_GATHER"
def contiguous_kv_gather_enabled() -> bool:
"""Whether the experimental contiguous-K/V gather attention path is on (``VLLM_OMNI_AR_DIFFUSION_KV_GATHER=1``).
The one place the switch is read: the KV manager consults it when it budgets and allocates the history
staging buffers, and the attention dispatch when it picks the path, so the two cannot disagree. It is the
only consumer of ``ARDiffusionKVConfig.reuse_history_staging``.
"""
return os.environ.get(KV_GATHER_ENV, "0") == "1"
@dataclass
class ARDiffusionKVConfig:
@@ -30,6 +44,12 @@ class ARDiffusionKVConfig:
# DiT graphs for every window-fill shape at load time via a synthetic rollout,
# so the serving run is fast from the first chunk. No effect when eager.
warmup_cudagraph: bool = True
# Keep one contiguous K/V staging buffer per layer and refresh only the tokens that changed.
# Within one AR block every forward attends the same history and differs only in the current
# chunk, so re-gathering the whole visible window per forward re-copies bytes that did not move.
# Off by default: it costs two max-sequence buffers per layer, per rank. See
# ARDiffusionPagedForwardContext.history_staging.
reuse_history_staging: bool = False
# Also capture the post-window-boundary (reset-cycle) forward during warm-up.
warmup_capture_reset: bool = False
@@ -15,7 +15,8 @@ from __future__ import annotations
import inspect
import os
from collections.abc import Collection, Iterable, Sequence
from typing import cast
from dataclasses import dataclass
from typing import Any, cast
import torch
from vllm.logger import init_logger
@@ -29,7 +30,11 @@ from vllm.v1.request import RequestStatus
from vllm_omni.diffusion.diffusion_kv.layout import build_kv_cache_tensor
from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec
from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVConfig
from vllm_omni.experimental.ar_diffusion.kv_cache.config import (
KV_GATHER_ENV,
ARDiffusionKVConfig,
contiguous_kv_gather_enabled,
)
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import (
ChunkWindowManager,
ChunkWindowSpec,
@@ -158,6 +163,18 @@ def build_kv_manager(
return KVCacheManager(config, **kwargs)
@dataclass
class HistoryStagingState:
"""What the staged history window currently holds: whose session and which visible blocks.
Owned by the cache next to the buffers themselves; ``ARDiffusionPagedForwardContext`` reads it to decide
whether a forward may keep the staged history and writes back what it staged.
"""
adapter: Any | None = None # weakref to the session adapter the window was staged for
signature: tuple[Any, ...] | None = None
class ARDiffusionKVCache:
"""Own the paged KV pool and KV-branch-local storage for one model.
@@ -180,6 +197,7 @@ class ARDiffusionKVCache:
kv_branches: tuple[ARDiffusionKVBranchSpec, ...],
session_capacity: int,
cross_attention_lengths: dict[str, int] | None = None,
cross_attention_kv_heads: dict[str, int] | None = None,
device: torch.device | None = None,
frames_per_block: int = 1,
max_scratch_tokens_per_branch: int = 0,
@@ -225,6 +243,18 @@ class ARDiffusionKVCache:
self.head_size = head_size
self.dtype = dtype
self.cross_attention_lengths = dict(cross_attention_lengths or {})
# A cross-attention cache may hold more heads than the self-attention share: a model that keeps every
# local head on every rank, rather than sharding heads across the sequence-parallel group, stores the
# full local set here. Absent means the self-attention head count, which is what every caller did before.
self.cross_attention_kv_heads = {
name: int((cross_attention_kv_heads or {}).get(name, num_kv_heads)) for name in self.cross_attention_lengths
}
invalid_heads = {n: h for n, h in self.cross_attention_kv_heads.items() if h <= 0}
if invalid_heads:
raise ValueError(f"cross_attention_kv_heads must be positive, got {invalid_heads}")
unknown_heads = set(cross_attention_kv_heads or {}) - set(self.cross_attention_lengths)
if unknown_heads:
raise ValueError(f"cross_attention_kv_heads names unknown caches: {sorted(unknown_heads)}")
invalid_cross = {name: length for name, length in self.cross_attention_lengths.items() if length <= 0}
if invalid_cross:
raise ValueError(f"cross_attention_lengths must be positive, got {invalid_cross}")
@@ -274,20 +304,46 @@ class ARDiffusionKVCache:
self.scratch_reserved_bytes = self.scratch_num_blocks * page_size_bytes
self.model_owned_state_bytes_per_session = model_owned_state_bytes_per_session
def _cross_pool_bytes(length: int) -> int:
return int(2 * len(self.kv_branches) * length * num_kv_heads * head_size * dtype.itemsize * num_layers)
def _cross_pool_bytes(length: int, heads: int) -> int:
return int(2 * len(self.kv_branches) * length * heads * head_size * dtype.itemsize * num_layers)
self.cross_attention_bytes_per_session = sum(
_cross_pool_bytes(length) for length in self.cross_attention_lengths.values()
_cross_pool_bytes(length, self.cross_attention_kv_heads[name])
for name, length in self.cross_attention_lengths.items()
)
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
# reuse_history_staging keeps one contiguous K and V buffer per layer,
# sized to the padded visible window (sink + window plus the
# action-capacity block build_block_table always reserves), for the
# whole worker. It is allocated lazily on the first prepared forward,
# which is after admission, so it has to be reserved here or admission
# can succeed and that first forward run out of memory. Only the
# contiguous-gather attention path consumes it.
self.history_staging_reserved_bytes = 0
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
)
self.history_staging_reserved_bytes = int(
2 * num_layers * self.history_staging_tokens * num_kv_heads * head_size * dtype.itemsize
)
else:
_log.warning(
"reuse_history_staging is set but the contiguous K/V gather path (%s=1) is off: "
"staging has no consumer and is neither budgeted nor allocated.",
KV_GATHER_ENV,
)
def _required_bytes(capacity: int) -> int:
return (
self.scratch_reserved_bytes
+ self.history_staging_reserved_bytes
+ capacity * self.cross_attention_bytes_per_session
+ capacity * self.model_owned_state_bytes_per_session
+ _required_managed_blocks(capacity) * page_size_bytes
@@ -298,7 +354,7 @@ class ARDiffusionKVCache:
raise ValueError(
"AR-Diffusion available device memory cannot fit one session: "
f"available={available_bytes} bytes, required={one_session_bytes} bytes "
"(managed self-attention + cross-attention + scratch + model-owned state)."
"(managed self-attention + cross-attention + scratch + model-owned state + K/V staging)."
)
self.memory_budget_bytes = max(self.configured_memory_budget_bytes, one_session_bytes)
if self.memory_budget_bytes > self.configured_memory_budget_bytes:
@@ -325,6 +381,7 @@ class ARDiffusionKVCache:
self_attn_budget_bytes = (
self.memory_budget_bytes
- self.scratch_reserved_bytes
- self.history_staging_reserved_bytes
- self.cross_attention_reserved_bytes
- self.model_owned_state_reserved_bytes
)
@@ -374,6 +431,20 @@ class ARDiffusionKVCache:
dtype,
device,
)
# reuse_history_staging: one contiguous (K, V) pair per layer, sized to the padded visible window this
# cache admits and budgeted above, owned here next to the pools it stages from. Allocated outside any
# capture and marked static, so compiled regions treat it as a stable input rather than a new tensor.
self.history_staging: list[tuple[torch.Tensor, torch.Tensor]] = []
self.history_staging_state = HistoryStagingState()
if self.history_staging_tokens and device is not None:
for _ in range(num_layers):
pair = (
torch.empty((self.history_staging_tokens, num_kv_heads, head_size), device=device, dtype=dtype),
torch.empty((self.history_staging_tokens, num_kv_heads, head_size), device=device, dtype=dtype),
)
for buffer in pair:
torch._dynamo.mark_static_address(buffer)
self.history_staging.append(pair)
# -- cross-attention pool access -------------------------------------------
# Cross-attn KV is static once populated — write once (from text encoder),
@@ -455,7 +526,7 @@ class ARDiffusionKVCache:
if not self._allocate_tensors:
raise RuntimeError("AR-Diffusion cross-attention tensors require a configured pool device")
shape = (length, self.num_kv_heads, self.head_size)
shape = (length, self.cross_attention_kv_heads[cache_name], self.head_size)
expected_input_shape = (1, *shape)
k_pool = [torch.empty(shape, dtype=self.dtype, device=self.device) for _ in range(self.num_layers)]
v_pool = [torch.empty(shape, dtype=self.dtype, device=self.device) for _ in range(self.num_layers)]
@@ -115,11 +115,27 @@ def allocate_kv_pool_with_views(
cache_shape = (num_blocks, block_size, num_kv_heads, head_dim)
flat_shape = (num_blocks * block_size, num_kv_heads, head_dim)
for _ in range(num_layers):
k = torch.empty(cache_shape, dtype=dtype, device=device)
v = torch.empty(cache_shape, dtype=dtype, device=device)
kv_pools.append([k, v])
k_pools.append(k.reshape(flat_shape))
v_pools.append(v.reshape(flat_shape))
# The flat slot views are what the paged-write custom op mutates inside the
# compiled block graphs, so they must be the *base* allocations: dynamo only
# honours mark_static_address on the tensor object it sees as a graph input,
# and a view of a static base is still classified as a mutated input, which
# makes inductor skip CUDA graphs for every block ("skipping cudagraphs due
# to mutated inputs"). The block-table layout is therefore the view here.
k = torch.empty(flat_shape, dtype=dtype, device=device)
v = torch.empty(flat_shape, dtype=dtype, device=device)
for pool in (k, v):
# Block 0 is the manager's null block: block tables are tail-padded with
# it and it is never written. The contiguous-K/V gather path may read
# (masked) rows from it, so it must hold finite values; every other
# block is fully written before it is read.
pool[:block_size].zero_()
# Session-lifetime storage: tell dynamo/inductor the address is static so
# CUDA-graph trees (mode="reduce-overhead") mutate it in place instead of
# copying ~300 MB per layer per replay (or skipping the graph).
torch._dynamo.mark_static_address(pool)
kv_pools.append([k.view(cache_shape), v.view(cache_shape)])
k_pools.append(k)
v_pools.append(v)
return kv_pools, k_pools, v_pools
@@ -1,13 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Paged self-attention helpers for AR-Diffusion KV reuse."""
from __future__ import annotations
import weakref
from dataclasses import dataclass, field
from typing import 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
_LAYER_IDX_TENSORS: dict[int, torch.Tensor] = {}
@@ -21,6 +24,19 @@ def _layer_idx_tensor(layer_idx: int) -> torch.Tensor:
return t
def _to_device_async(t: torch.Tensor, device: torch.device) -> torch.Tensor:
"""Host->device copy that does not stall the CPU.
``tensor.to(device)`` from pageable memory is synchronous: the CPU blocks until
every kernel already queued on the stream has finished. Pinned + non_blocking
keeps the CPU running ahead (the caching host allocator keeps the pinned
source alive until the copy's stream event completes).
"""
if torch.device(device).type != "cuda" or t.device.type != "cpu":
return t.to(device=device)
return t.pin_memory().to(device=device, non_blocking=True)
class ARDiffusionPagedLayerInputs(NamedTuple):
"""Compiled-region payload for one layer's paged self-attention.
@@ -48,6 +64,14 @@ class ARDiffusionPagedLayerInputs(NamedTuple):
seq_lens: torch.Tensor
max_query_len: int
max_seq_len: int
# Contiguous K/V staging for this layer, when reuse_history_staging is on. ``reuse_history`` is a host
# bool decided once per forward (``_prepare_history_staging``): it selects one of two compiled variants.
stage_key: torch.Tensor | None = None
stage_value: torch.Tensor | None = None
reuse_history: bool = False
# Index of the current chunk's first block within the staged window: right after the visible history,
# which is not the end of the padded block table (that carries at least one action-capacity block).
stage_first_block: int = 0
@dataclass
@@ -104,15 +128,15 @@ class ARDiffusionPagedForwardContext:
start_block = start // self.block_size
self.current_video_block_ids = [int(b) for b in table[start_block : start_block + n_blocks]]
positions = torch.arange(start, start + self.seq_len, dtype=torch.long)
self.current_video_slot_mapping = compute_slot_mapping(table, positions, self.block_size).to(device=device)
self.current_video_slot_mapping = _to_device_async(
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)
self.current_video_slot_mapping = compute_slot_mapping(
self.current_video_block_ids,
positions,
self.block_size,
).to(device=device)
self.current_video_slot_mapping = _to_device_async(
compute_slot_mapping(self.current_video_block_ids, positions, self.block_size), device
)
self._allocated_video = True
def ensure_action_slots(self, action_len: int, device: torch.device) -> None:
@@ -135,11 +159,9 @@ class ARDiffusionPagedForwardContext:
action_blocks,
)
positions = torch.arange(action_len, dtype=torch.long)
self.action_slot_mapping = compute_slot_mapping(
self.action_scratch_block_ids,
positions,
self.block_size,
).to(device=device)
self.action_slot_mapping = _to_device_async(
compute_slot_mapping(self.action_scratch_block_ids, positions, self.block_size), device
)
self._action_len = action_len
def video_block_table(self, device: torch.device) -> tuple[list[int], int]:
@@ -192,9 +214,12 @@ class ARDiffusionPagedForwardContext:
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)
block_table = torch.tensor([padded], dtype=torch.int32, device=device)
query_start_loc = torch.tensor([0, self.query_len], dtype=torch.int32, device=device)
seq_lens = torch.tensor([self.kv_len], dtype=torch.int32, device=device)
# 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.
block_table = _to_device_async(torch.tensor([padded], dtype=torch.int32), device)
query_start_loc = _to_device_async(torch.tensor([0, self.query_len], dtype=torch.int32), device)
seq_lens = _to_device_async(torch.tensor([self.kv_len], dtype=torch.int32), device)
return block_table, query_start_loc, seq_lens, self.query_len, max_seq_len
def prepare(self, device: torch.device, action_len: int, query_len: int) -> None:
@@ -218,15 +243,71 @@ class ARDiffusionPagedForwardContext:
) = self.build_block_table(action_len=action_len, query_len=query_len, device=device)
if self.action_slot_mapping is None:
self.action_slot_mapping = torch.empty(0, dtype=torch.long, device=device)
self._prepare_history_staging(action_len)
self._prepared = True
def _prepare_history_staging(self, action_len: int) -> None:
"""Decide whether this forward can keep the history already staged, and from where.
The staged window is only reusable when nothing about it moved: the same session adapter, the same
visible history blocks, the same sequence bounds. Any difference -- a new AR block, a slid window, a
different request -- and the buffer is rebuilt from the pools.
The decision is a host bool, deliberately. It has to be, for the gather to actually be skipped: a
device flag could only select between two results already computed. It is the same value for all
layers of a forward, so it costs two compiled variants in total rather than one per layer.
"""
self.staging_enabled = False
self.reuse_history = False
self.stage_first_block = 0
# The manager allocates the staging pairs only when reuse_history_staging is on and the contiguous
# gather path -- their only consumer -- is switched on.
if not self.kv_cache.history_staging:
return
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
capacity = int(self.kv_cache.history_staging[0][0].shape[0])
if int(self.max_seq_len) > capacity:
raise RuntimeError(
f"history staging buffers hold {capacity} tokens but this forward stages {int(self.max_seq_len)}"
)
state = self.kv_cache.history_staging_state
signature = (
int(self.adapter.num_computed_tokens),
tuple(self.history_block_ids),
int(self.max_seq_len),
int(self.seq_len),
)
same_session = state.adapter is not None and state.adapter() is self.adapter
reuse = same_session and state.signature == signature
state.adapter = weakref.ref(self.adapter)
state.signature = signature
self.staging_enabled = True
self.reuse_history = bool(reuse)
# ``kv_len`` is the visible video window here (no action tokens on this path): the current chunk's
# blocks are its last ``num_current_video_blocks`` entries, and the table's trailing padding blocks
# come after them.
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)."""
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]
def layer_inputs(self, layer_idx: int) -> ARDiffusionPagedLayerInputs:
if not getattr(self, "_prepared", False):
raise RuntimeError("ARDiffusionPagedForwardContext.layer_inputs() before prepare()")
key_pool = self.kv_cache._k_pools[layer_idx]
value_pool = self.kv_cache._v_pools[layer_idx]
stage_key, stage_value = self.history_staging(layer_idx)
return ARDiffusionPagedLayerInputs(
layer_idx=_layer_idx_tensor(layer_idx),
key_pool=self.kv_cache._k_pools[layer_idx],
value_pool=self.kv_cache._v_pools[layer_idx],
key_pool=key_pool,
value_pool=value_pool,
block_size=int(self.kv_cache.block_size),
seq_len=int(self.seq_len),
video_slots=self.current_video_slot_mapping,
@@ -236,6 +317,10 @@ class ARDiffusionPagedForwardContext:
seq_lens=self.seq_lens,
max_query_len=int(self.max_query_len),
max_seq_len=int(self.max_seq_len),
stage_key=stage_key,
stage_value=stage_value,
reuse_history=self.reuse_history,
stage_first_block=int(self.stage_first_block),
)
def mark_committed(self) -> None:
@@ -342,6 +427,23 @@ def _reference_paged_attention(
_FA_VERSION_BY_HEAD_SIZE: dict[int, int] = {}
def _stage_window(stage_key, stage_value, key_cache, value_cache, block_ids, n_blocks, block_size, *, first_block):
"""Gather visible blocks from ``first_block`` onward into the staging buffers.
``first_block`` is 0 for a full restage and the index of the current chunk's first block when the
history is already staged. Skipping the leading blocks is the whole point: they were gathered by an
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:])
)
def _resolve_fa_version(head_size: int) -> int:
# get_flash_attn_version -> current_platform.get_device_capability() is not
# dynamo-traceable, and the answer is fixed per head size for the process.
@@ -385,6 +487,11 @@ def ar_diffusion_paged_attention(
max_seq_len: int,
softmax_scale: float,
causal: bool = False,
stage_key: torch.Tensor | None = None,
stage_value: torch.Tensor | None = None,
reuse_history: bool = False,
current_tokens: int = 0,
stage_first_block: int = 0,
) -> torch.Tensor:
"""Run non-causal paged attention over a vLLM block table.
@@ -437,6 +544,82 @@ def ar_diffusion_paged_attention(
softmax_scale=float(softmax_scale),
causal=causal,
)
elif contiguous_kv_gather_enabled() and block_table.shape[0] == 1:
# Experimental (VLLM_OMNI_AR_DIFFUSION_KV_GATHER=1): FA3's paged-KV path with the
# frame-sized block (1560 tokens, not a multiple of the FA3 K/V tile)
# runs ~15-17% slower than the same kernel on contiguous K/V. Gather the
# visible blocks into a contiguous buffer once per layer and attend
# without a block table; ``seqused_k`` masks the tail-padding block.
from vllm.vllm_flash_attn import flash_attn_varlen_func
fa_version = _resolve_fa_version(query_flat.shape[-1])
block_size = key_cache.shape[1]
n_blocks = int(max_seq_len) // block_size
if n_blocks * block_size != int(max_seq_len):
raise ValueError("the contiguous K/V gather path requires max_seq_len to be block-aligned")
block_ids = block_table[0, :n_blocks].to(torch.long)
if stage_key is None or stage_value is None:
# Fresh allocations on purpose: a module-level cached buffer that is first
# allocated inside a CUDA-graph-trees warm-up run lives in the graph pool
# untracked ("tensor(s) in the cudagraph pool not tracked as outputs").
k_buf = key_cache.index_select(0, block_ids)
v_buf = value_cache.index_select(0, block_ids)
k_flat = k_buf.view(n_blocks * block_size, *key_cache.shape[2:])
v_flat = v_buf.view(n_blocks * block_size, *value_cache.shape[2:])
else:
# Caller-owned staging, allocated outside any capture and marked static, so the buffer this
# writes into is the same address every forward and the graph pool never owns it.
#
# Only the current chunk moved. The block table is the visible history followed by the
# current chunk's blocks and then padding (at least the action-capacity block, plus unused
# window capacity while the window is still growing), so the rows this forward changed start
# at ``stage_first_block`` -- the caller's count of visible history blocks -- and NOT at
# ``n_blocks - current_blocks``, which would refresh padding and leave the current K/V stale.
# Everything before them was staged by an earlier forward of the same AR block and is still
# 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
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.
current_blocks = current_tokens // block_size
assert current_tokens > 0 and current_blocks * block_size == current_tokens, (
f"staged reuse needs a block-aligned current chunk, got {current_tokens} tokens"
)
assert 0 <= stage_first_block and stage_first_block + current_blocks <= n_blocks, (
f"stage_first_block={stage_first_block} + {current_blocks} blocks "
f"exceeds the {n_blocks}-block window"
)
_stage_window(
k_flat,
v_flat,
key_cache,
value_cache,
block_ids,
n_blocks,
block_size,
first_block=stage_first_block if reuse_history else 0,
)
out = torch.empty_like(query_flat)
# Varlen K/V: cu_seqlens_k = [0, kv_len] built on device from seq_lens
# (no host sync). Rows past kv_len (the tail-padding block) are never
# read, exactly like the ROCm gather path above.
cu_seqlens_k = torch.cat((seq_lens.new_zeros(1), seq_lens.to(torch.int32)))
flash_attn_varlen_func(
q=query_flat,
k=k_flat,
v=v_flat,
out=out,
cu_seqlens_q=query_start_loc,
max_seqlen_q=int(max_query_len),
cu_seqlens_k=cu_seqlens_k,
max_seqlen_k=int(max_seq_len),
softmax_scale=float(softmax_scale),
causal=causal,
fa_version=fa_version,
)
else:
from vllm.vllm_flash_attn import flash_attn_varlen_func
@@ -488,6 +671,10 @@ def _paged_write_attn_impl(
max_query_len: int,
max_seq_len: int,
softmax_scale: float,
stage_key: torch.Tensor | None = None,
stage_value: torch.Tensor | None = None,
reuse_history: bool = False,
stage_first_block: int = 0,
) -> torch.Tensor:
key_pool[video_slots] = k_curr.to(key_pool.dtype)
value_pool[video_slots] = v_curr.to(value_pool.dtype)
@@ -507,16 +694,23 @@ def _paged_write_attn_impl(
max_seq_len=max_seq_len,
softmax_scale=softmax_scale,
causal=False,
stage_key=stage_key,
stage_value=stage_value,
reuse_history=reuse_history,
current_tokens=int(k_curr.shape[0]),
stage_first_block=stage_first_block,
)
# hasattr guard keeps registration idempotent across test re-imports that pop
# the module from sys.modules (same as sage_attn3.py).
if not hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn"):
# Keep staging arguments required even when their value is None. The dispatcher
# strips trailing defaults, and older PyTorch mutation handlers index the
# positional arguments without restoring them before bumping version counters.
@torch.library.custom_op(
"vllm_omni::ar_diffusion_paged_write_attn",
mutates_args=("key_pool", "value_pool"),
mutates_args=("key_pool", "value_pool", "stage_key", "stage_value"),
)
def _paged_write_attn_op(
query: torch.Tensor,
@@ -535,6 +729,10 @@ if not hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn"):
max_query_len: int,
max_seq_len: int,
softmax_scale: float,
stage_key: torch.Tensor | None,
stage_value: torch.Tensor | None,
reuse_history: bool,
stage_first_block: int,
) -> torch.Tensor:
return _paged_write_attn_impl(
query,
@@ -553,6 +751,10 @@ if not hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn"):
max_query_len,
max_seq_len,
softmax_scale,
stage_key,
stage_value,
reuse_history,
stage_first_block,
)
@_paged_write_attn_op.register_fake
@@ -573,6 +775,10 @@ if not hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn"):
max_query_len,
max_seq_len,
softmax_scale,
stage_key=None,
stage_value=None,
reuse_history=False,
stage_first_block=0,
):
return torch.empty_like(query)
@@ -598,4 +804,8 @@ def paged_write_attn(
inputs.max_query_len,
inputs.max_seq_len,
softmax_scale,
inputs.stage_key,
inputs.stage_value,
inputs.reuse_history,
inputs.stage_first_block,
)
+91 -2
View File
@@ -14,13 +14,14 @@ from vllm.logger import init_logger
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.models.interface import supports_step_execution
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput, KVPrefetchJob
from vllm_omni.diffusion.sched.interface import CachedRequestData, DiffusionSchedulerOutput, KVPrefetchJob
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput
from vllm_omni.experimental.ar_diffusion.capability import (
ARDiffusionKVCacheSpec,
SupportsARDiffusionPipeline,
SupportsARDiffusionWarmup,
supports_chunk_step_grouping,
)
from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVConfig
from vllm_omni.experimental.ar_diffusion.kv_cache.manager import ARDiffusionKVCache
@@ -144,6 +145,7 @@ class ARDiffusionModelRunner(DiffusionModelRunner):
kv_branches=spec.kv_branches,
session_capacity=spec.session_capacity,
cross_attention_lengths=spec.cross_attention_lengths,
cross_attention_kv_heads=spec.cross_attention_kv_heads,
frames_per_block=spec.frames_per_block,
max_scratch_tokens_per_branch=spec.max_scratch_tokens_per_branch,
model_owned_state_bytes_per_session=spec.model_owned_state_bytes_per_session,
@@ -163,7 +165,10 @@ class ARDiffusionModelRunner(DiffusionModelRunner):
config.window_chunks,
config.sink_chunks,
[(kv_branch.name, kv_branch.local_index) for kv_branch in spec.kv_branches],
spec.cross_attention_lengths,
{
name: (length, spec.cross_attention_kv_heads.get(name, spec.num_kv_heads))
for name, length in spec.cross_attention_lengths.items()
},
self._session_capacity,
spec.session_capacity,
)
@@ -338,6 +343,90 @@ class ARDiffusionModelRunner(DiffusionModelRunner):
"ARDiffusionModelRunner does not support request-batch execution; use request mode with max_num_seqs=1."
)
def _groups_chunk_steps(self, scheduler_output: DiffusionSchedulerOutput) -> bool:
"""Policy: does this stepwise call run the scheduled request's chunk to its boundary?
Only on the realtime AR path, for a lone streaming request of a
pipeline that declares ``supports_chunk_step_grouping``. The session's
KV is bound for the whole call and the scheduler regains control at
every chunk boundary, which is where this path's interactions are
applied anyway; what it gives up is the chance to act between two
steps of one chunk.
"""
return (
bool(self.od_config.streaming_output)
and len(scheduler_output.scheduled_request_ids) == 1
and supports_chunk_step_grouping(self.pipeline)
)
def _execute_stepwise_core(
self,
scheduler_output: DiffusionSchedulerOutput,
*,
record_output_peak_memory: bool,
in_diffusion_kv_memory_profile: bool = False,
) -> BatchRunnerOutput:
"""Run the shared single-step core once, or repeatedly until the chunk boundary.
The shared runner keeps its one-step-per-call semantics. When the
grouping policy applies, the remaining steps of the request's current
chunk are driven from here with a continuation of the scheduler
output: the request is presented as a cached request, with nothing
new to admit, nothing to retire and no connector work, so every
further step is exactly what the next scheduler cycle would have
asked for. Grouping the probes and the commit of one block this way
drops the scheduler/executor round trips between them.
The loop stops at the first call that emits a result (a chunk or an
error), finishes the request, or leaves it without state; the base
core already handles a mid-chunk failure or interrupt by finishing
the request with an error and dropping its state.
"""
output = super()._execute_stepwise_core(
scheduler_output,
record_output_peak_memory=record_output_peak_memory,
in_diffusion_kv_memory_profile=in_diffusion_kv_memory_profile,
)
if not self._groups_chunk_steps(scheduler_output):
return output
request_id = scheduler_output.scheduled_request_ids[0]
continuation = dataclasses.replace(
scheduler_output,
scheduled_new_reqs=[],
scheduled_cached_reqs=CachedRequestData(request_ids=[request_id]),
finished_req_ids=set(),
kv_prefetch_job=None,
kv_connector_metadata=None,
)
if not self._chunk_step_pending(output, request_id):
return output
max_steps = self.state_cache[request_id].chunk_num_steps
if max_steps is None:
return output
steps = 1
while self._chunk_step_pending(output, request_id):
if steps >= max_steps:
self.state_cache.pop(request_id)
runner_output = output.get_request_output(request_id)
assert runner_output is not None
runner_output.finished = True
runner_output.result = DiffusionOutput(error=f"Chunk did not complete within {max_steps} denoise steps")
break
steps += 1
output = super()._execute_stepwise_core(
continuation,
record_output_peak_memory=record_output_peak_memory,
in_diffusion_kv_memory_profile=in_diffusion_kv_memory_profile,
)
return output
def _chunk_step_pending(self, output: BatchRunnerOutput, request_id: str) -> bool:
"""More steps of the request's current chunk remain after ``output``."""
runner_output = output.get_request_output(request_id)
if runner_output is None or runner_output.finished or runner_output.result is not None:
return False
return request_id in self.state_cache
def execute_stepwise(self, scheduler_output: DiffusionSchedulerOutput) -> BatchRunnerOutput:
"""Bind runner-owned KV for one stepwise invocation, then inherit the step loop."""
pipeline = getattr(self, "pipeline", None)