[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

+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()