Compare commits

...
Author SHA1 Message Date
NumberWan b9e9d236c3 [BugFix] Restore vllm_c IR op priority and torch.nn.RMSNorm for Qwen-Image (Same As #5009) (#5017)
Signed-off-by: NumberWan <wantszkin2003@gmail.com>
2026-07-11 01:00:39 +08:00
herotai214 d4a869fe5e [Bugfix] Fix tokenizer config for higgs-audio-v3 test error (#4918)
Signed-off-by: herotai214 <herotai214@gmail.com>
2026-07-06 10:53:24 +00:00
amy-why-3459 9ceca4ec85 [Bugfix] Fix full-payload mm splitting for dual hidden/scheduled batch axes (#4870 follow-up) (#4910)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-07-06 15:08:05 +08:00
Vensen ba7e9e6496 [CI][sleep mode] fix sleep pytest CI error (#4912)
Signed-off-by: vensen <vensenmu@gmail.com>
2026-07-06 15:07:01 +08:00
汪志鹏 14fad9835d [BugFix]: fix #3236 bug (#4877)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
2026-07-05 17:26:09 +00:00
汪志鹏 862e0a2b27 [BugFix] Preserve diffusion extra_body params for out-of-process CFG Parallel (#4900)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
2026-07-05 17:06:30 +00:00
TaffyOfficialandzuiho 66ec3a2aff [Bugfix] Fix HunyuanImage3 MoE groups for vLLM 0.24 (#4893)
Signed-off-by: zuiho <2324465096@qq.com>
Co-authored-by: zuiho <2324465096@qq.com>
2026-07-05 15:37:42 +00:00
Vensen ddba6de2ab [Bugfix][sleep mode]: guard generation on partial wake and ensure wake idempotency (#4834)
Signed-off-by: vensen <vensenmu@gmail.com>
2026-07-05 14:50:08 +00:00
Yueqian Lin d7b365df64 [Bugfix][Qwen3-TTS] Keep seeded residual MTP sampling batched (#4889)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
2026-07-05 21:47:06 +08:00
Renzheng Wang 02d654760f [BugFix] Drop removed get_cache_scale call in HunyuanImage3 diffusion loader (#4892)
Signed-off-by: wangrzneu <wangrzneu@gmail.com>
2026-07-05 09:50:43 +00:00
Zhou Taichang 86bdcaf3d5 [BDE] Phase 1: engine-level KV cache management for DreamZero (RFC #4366) (#4534) 2026-07-05 07:17:58 +08:00
Sy03 aa21412c01 [Fix][VoxCPM2] Follow up unified graph config gate (#4878)
Signed-off-by: Sy03 <1370724210@qq.com>
2026-07-04 19:58:21 +00:00
Sy03andYueqian Lin 1b31f96240 [Bugfix] Fix Qwen3-TTS no-async-chunk payload splitting (#4870)
Signed-off-by: Sy03 <1370724210@qq.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
2026-07-04 19:57:55 +00:00
Sy03andYueqian Lin 0798b0bf03 [Bugfix][Qwen3-TTS] Seed residual MTP sampling (#4869)
Signed-off-by: Sy03 <1370724210@qq.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
2026-07-04 17:47:11 +00:00
59 changed files with 4792 additions and 425 deletions

No files matched your search

+1
View File
@@ -208,6 +208,7 @@ cache/
.cache/ .cache/
diffusion_cache/ diffusion_cache/
kv_cache/ kv_cache/
!vllm_omni/experimental/ar_diffusion/kv_cache/
# Logs # Logs
logs/ logs/
+1
View File
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
+136
View File
@@ -0,0 +1,136 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for the generic engine-backend dispatcher + AR-Diffusion runner wiring.
``DiffusionEngine.resolve_engine_class`` is a generic dispatcher (``"default"`` / a
``DiffusionEngine`` subclass / an import-path string). DreamZero selects the
AR-Diffusion engine via its deploy config's ``engine_backend`` qualname — no
DreamZero/ar_diffusion knowledge lives in the public base, so the routing check
here is simply that the ``ARDiffusionEngine`` qualname resolves correctly.
"""
from dataclasses import fields
from types import SimpleNamespace
import pytest
from vllm.utils.import_utils import resolve_obj_by_qualname
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.experimental.ar_diffusion.engine import (
AR_DIFFUSION_MODEL_RUNNER_CLS,
ARDiffusionEngine,
)
def _cfg(backend):
return SimpleNamespace(engine_backend=backend)
def test_default_resolves_to_diffusion_engine():
assert DiffusionEngine.resolve_engine_class(_cfg("default")) is DiffusionEngine
def test_subclass_type_is_returned():
assert DiffusionEngine.resolve_engine_class(_cfg(ARDiffusionEngine)) is ARDiffusionEngine
def test_ar_diffusion_qualname_resolves():
# How DreamZero's deploy config selects the engine: a full import-path string.
cls = DiffusionEngine.resolve_engine_class(_cfg("vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine"))
assert cls is ARDiffusionEngine
def test_non_engine_type_raises():
with pytest.raises(TypeError):
DiffusionEngine.resolve_engine_class(_cfg(dict))
def test_qualname_not_an_engine_raises():
with pytest.raises(TypeError):
DiffusionEngine.resolve_engine_class(_cfg("builtins.dict"))
def test_bad_qualname_raises():
with pytest.raises(ValueError):
DiffusionEngine.resolve_engine_class(_cfg("not.a.real.module.NoSuchEngine"))
def test_ar_diffusion_engine_is_diffusion_engine_subclass():
assert issubclass(ARDiffusionEngine, DiffusionEngine)
def test_config_engine_backend_field_defaults_to_default():
field = {f.name: f for f in fields(OmniDiffusionConfig)}["engine_backend"]
assert field.default == "default"
# --- AR-Diffusion worker/runner wiring -----------------------------------------------
def test_ar_diffusion_model_runner_cls_resolves():
cls = resolve_obj_by_qualname(AR_DIFFUSION_MODEL_RUNNER_CLS)
assert cls.__name__ == "ARDiffusionModelRunner"
def test_engine_class_declares_its_runner():
"""Routing lives on the engine class (review: hsliuustc0106) — engines
never mutate od_config; the worker resolves the runner off the class."""
assert ARDiffusionEngine.default_diffusion_model_runner_cls == AR_DIFFUSION_MODEL_RUNNER_CLS
assert DiffusionEngine.default_diffusion_model_runner_cls is None
def _select_runner(od_config, platform_default="PLATFORM"):
"""Mirror of the worker's runner-selection chain (override > engine > platform)."""
runner_override = getattr(od_config, "diffusion_model_runner_cls", None)
if isinstance(runner_override, str) and runner_override:
return runner_override
engine_cls = DiffusionEngine.resolve_engine_class(od_config)
engine_runner = getattr(engine_cls, "default_diffusion_model_runner_cls", None)
if isinstance(engine_runner, str) and engine_runner:
return engine_runner
return platform_default
def test_runner_selection_chain():
ar = "vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine"
# engine-declared runner wins over platform default
assert (
_select_runner(SimpleNamespace(diffusion_model_runner_cls=None, engine_backend=ar))
== AR_DIFFUSION_MODEL_RUNNER_CLS
)
# explicit override wins over the engine's declaration
assert (
_select_runner(SimpleNamespace(diffusion_model_runner_cls="my.custom.Runner", engine_backend=ar))
== "my.custom.Runner"
)
# default engine -> platform default
assert _select_runner(SimpleNamespace(diffusion_model_runner_cls=None, engine_backend="default")) == "PLATFORM"
def test_dreamzero_pipeline_rejects_non_ar_diffusion_engine():
"""Review (hsliuustc0106): a stale config with engine_backend='default'
must fail at init, not mid-forward on the first KV access."""
from types import SimpleNamespace
import pytest
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
with pytest.raises(ValueError, match="requires the AR-Diffusion engine"):
DreamZeroPipeline(od_config=SimpleNamespace(engine_backend="default"))
with pytest.raises(ValueError, match="requires the AR-Diffusion engine"):
DreamZeroPipeline(od_config=SimpleNamespace())
def test_runner_rejects_multi_sequence_config():
"""Review (hsliuustc0106): the paged path writes batch index 0 only, so
max_num_seqs > 1 must be rejected at init."""
from types import SimpleNamespace
import pytest
from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner
fake = SimpleNamespace(od_config=SimpleNamespace(max_num_seqs=4))
with pytest.raises(ValueError, match="max_num_seqs=1"):
ARDiffusionModelRunner._preallocate_kv_cache(fake)
+262
View File
@@ -0,0 +1,262 @@
# SPDX-License-Identifier: Apache-2.0
"""Unit tests for the AR-Diffusion KV cache helpers (Phase 1, PR-2).
Covers the request adapter, the chunk-window spec/manager (registration + the
eviction policy), and the pool builder — exercised against the installed vLLM
KV stack on CPU (block bookkeeping only, no GPU tensors).
"""
import pytest
import torch
from vllm.v1.kv_cache_interface import KVCacheSpecKind, get_kv_cache_spec_kind
from vllm.v1.request import RequestStatus
try:
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
except ModuleNotFoundError:
KVCacheSpecRegistry = None
from vllm.v1.core.single_type_kv_cache_manager import spec_manager_map
from vllm_omni.experimental.ar_diffusion.kv_cache import (
ARDiffusionKVCache,
ARDiffusionKVConfig,
ARDiffusionRequestAdapter,
ChunkWindowManager,
ChunkWindowSpec,
allocate_kv_pool_with_views,
build_kv_manager,
compute_num_blocks,
)
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import chunk_window_skipped_tokens
BLOCK = 16
def make_spec(*, chunk_size=BLOCK, window_chunks=2, sink_chunks=0, reset_at_boundary=False):
return ChunkWindowSpec(
block_size=BLOCK,
num_kv_heads=4,
head_size=64,
dtype=torch.float16,
sliding_window=window_chunks * chunk_size,
chunk_size=chunk_size,
window_chunks=window_chunks,
sink_chunks=sink_chunks,
reset_at_boundary=reset_at_boundary,
)
# --- ChunkWindowSpec registration -------------------------------------------
def test_spec_registration_resolves_to_chunk_window_manager():
# Without explicit registration the MRO walk would fall back to the parent
# SlidingWindowManager; assert the subclass manager wins.
spec = make_spec()
if KVCacheSpecRegistry is not None:
assert KVCacheSpecRegistry.get_manager_class(spec) is ChunkWindowManager
else:
assert spec_manager_map[type(spec)] is ChunkWindowManager
def test_spec_kind_is_sliding_window():
assert get_kv_cache_spec_kind(make_spec()) == KVCacheSpecKind.SLIDING_WINDOW
def test_spec_rejects_inconsistent_window():
with pytest.raises(ValueError):
ChunkWindowSpec(
block_size=BLOCK,
num_kv_heads=4,
head_size=64,
dtype=torch.float16,
sliding_window=99, # != window_chunks * chunk_size
chunk_size=BLOCK,
window_chunks=2,
)
# --- eviction policy (pure) -------------------------------------------------
def test_sliding_replace_keeps_window():
# window = 2 chunks * 16 = 32. Base sliding formula keeps `window` tokens;
# the snap is to chunk boundaries.
def skip(n):
return chunk_window_skipped_tokens(n, chunk_size=16, sliding_window=32, sink_chunks=0, reset_at_boundary=False)
assert skip(32) == 0 # nothing past the window yet
assert skip(48) == 16 # one chunk fell out of the window
assert skip(64) == 32
def test_sliding_replace_snaps_to_chunk_boundary():
# A non-chunk-aligned overflow must snap down so a chunk is never half-evicted.
skip = chunk_window_skipped_tokens(50, chunk_size=16, sliding_window=32, sink_chunks=0, reset_at_boundary=False)
assert skip % 16 == 0 and skip == 16
def test_sink_chunks_protected():
# sink = 1 chunk (16 tokens) is never skipped.
skip = chunk_window_skipped_tokens(80, chunk_size=16, sliding_window=32, sink_chunks=1, reset_at_boundary=False)
# raw overflow = 80 - 32 - 16 = 32 -> snapped 32
assert skip == 32
def test_reset_at_boundary_drops_completed_past_sink():
skip = chunk_window_skipped_tokens(48, chunk_size=16, sliding_window=32, sink_chunks=1, reset_at_boundary=True)
# completed = 48; sink = 16 -> drop 32
assert skip == 32
# --- ARDiffusionKVConfig ------------------------------------------------------------
def test_kv_config_sliding_window_property():
assert ARDiffusionKVConfig(chunk_size=16, window_chunks=3).sliding_window == 48
assert ARDiffusionKVConfig(chunk_size=16, window_chunks=None).sliding_window is None
# --- ARDiffusionRequestAdapter ------------------------------------------------------
def test_adapter_advances_per_chunk_not_per_step():
a = ARDiffusionRequestAdapter("r0", chunk_size=16)
assert a.num_computed_tokens == 0
assert a.num_tokens == 16 # in-flight chunk
a.on_chunk_committed()
assert a.num_computed_tokens == 16
assert a.num_tokens == 32
def test_adapter_accounts_for_prefill_prefix():
a = ARDiffusionRequestAdapter("r0", chunk_size=16, prefill_prefix_tokens=4)
assert a.num_computed_tokens == 4
assert a.num_prompt_tokens == 4
assert a.num_tokens == 20
def test_adapter_status_is_vllm_enum():
assert isinstance(ARDiffusionRequestAdapter("r0", chunk_size=16).status, RequestStatus)
# --- pool / manager ---------------------------------------------------------
def test_compute_num_blocks():
# 1 MiB budget at fraction 0.5 with 16 KiB pages -> 32 blocks.
assert compute_num_blocks(1 << 20, 0.5, 16 << 10) == 32
def test_paged_pool_layout_exposes_flat_slot_views():
kv_pools, k_pools, v_pools = allocate_kv_pool_with_views(
num_blocks=4,
block_size=BLOCK,
num_layers=1,
num_kv_heads=4,
head_dim=64,
dtype=torch.float32,
device=torch.device("cpu"),
)
assert kv_pools[0].shape == (2, 4, BLOCK, 4, 64)
assert k_pools[0].shape == (4 * BLOCK, 4, 64)
assert v_pools[0].shape == (4 * BLOCK, 4, 64)
k_pools[0][BLOCK + 3].fill_(7)
v_pools[0][2 * BLOCK + 5].fill_(11)
assert torch.equal(kv_pools[0][0, 1, 3], k_pools[0][BLOCK + 3])
assert torch.equal(kv_pools[0][1, 2, 5], v_pools[0][2 * BLOCK + 5])
def test_build_manager_allocate_free_roundtrip():
"""End-to-end: a ARDiffusionRequestAdapter drives a real KVCacheManager.
This is the adapter conformance check — if the adapter were missing an
attribute the manager reads, allocate_slots/free would raise here.
"""
spec = make_spec()
mgr = build_kv_manager(spec, ["layer0"], num_blocks=16, max_model_len=1024)
free_before = mgr.block_pool.get_num_free_blocks()
adapter = ARDiffusionRequestAdapter("req-0", chunk_size=BLOCK)
blocks = mgr.allocate_slots(adapter, num_new_tokens=BLOCK, full_sequence_must_fit=True)
assert blocks is not None
assert mgr.block_pool.get_num_free_blocks() < free_before
mgr.free(adapter)
assert mgr.block_pool.get_num_free_blocks() == free_before
def test_cross_attn_pool_deducted_from_self_attn_budget():
"""The cross-attn pool is allocated directly; its bytes are subtracted from
the self-attn paged-pool budget so the two together stay within the free
memory budget (review: zwhzzz0821)."""
L = 512
avail = 1 << 30 # 1 GiB
kv = ARDiffusionKVCache(
ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=2, gpu_memory_fraction=0.5),
num_layers=2,
num_kv_heads=4,
head_size=64,
dtype=torch.float16,
block_size=BLOCK,
max_model_len=4096,
available_bytes=avail,
cross_attn_length=L,
device=torch.device("cpu"),
)
cross_bytes = 2 * 2 * L * 4 * 64 * torch.float16.itemsize * 2 # K+V, pos+neg, layers
expected = compute_num_blocks(avail - cross_bytes, 0.5, kv.spec.page_size_bytes * 2)
assert kv.num_blocks == expected
assert expected > 2 * (2 + 1) + 2 # above the min-blocks floor, so the deduction is what's tested
def _make_kv(*, local_branches, num_frame_per_block=2, window_chunks=9):
return ARDiffusionKVCache(
ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks),
num_layers=1,
num_kv_heads=4,
head_size=64,
dtype=torch.float32,
block_size=BLOCK,
max_model_len=4096,
available_bytes=1 << 16, # tiny -> the floor binds
device=torch.device("cpu"),
local_branches=local_branches,
num_frame_per_block=num_frame_per_block,
)
def test_pool_floor_is_branch_aware():
"""CFG-parallel rank (one local branch) sizes for one window + in-flight chunk;
a single-process run (both branches) sizes for two. Scratch scales the same way."""
one = _make_kv(local_branches=1)
two = _make_kv(local_branches=2)
assert one.managed_num_blocks == 1 * (9 + 2) + 2 # 13
assert two.managed_num_blocks == 2 * (9 + 2) + 2 # 24
assert one.scratch_num_blocks == one.scratch_blocks_per_branch
assert two.scratch_num_blocks == 2 * two.scratch_blocks_per_branch
assert one.num_blocks_total == 13 + one.scratch_blocks_per_branch
def test_scratch_maps_to_slot_zero_with_one_local_branch():
"""A CFG-parallel rank runs exactly one branch: whichever CFG side it is,
its scratch lands in the rank's single slot (no dead second slot)."""
one = _make_kv(local_branches=1)
assert one.scratch_block_ids(True, 0, 2) == one.scratch_block_ids(False, 0, 2)
two = _make_kv(local_branches=2)
assert two.scratch_block_ids(True, 0, 2) != two.scratch_block_ids(False, 0, 2)
def test_scratch_exhaustion_still_raises():
one = _make_kv(local_branches=1)
cap = one.scratch_blocks_per_branch
with pytest.raises(RuntimeError, match="scratch blocks exhausted"):
one.scratch_block_ids(False, 0, cap + 1)
def test_invalid_local_branches_rejected():
with pytest.raises(ValueError, match="local_branches"):
_make_kv(local_branches=3)
@@ -0,0 +1,93 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for ARDiffusionKVCache — the engine-level KV orchestration body (Phase 1)."""
import pytest
import torch
from vllm_omni.experimental.ar_diffusion.kv_cache import ARDiffusionKVCache, ARDiffusionKVConfig
DIMS = dict(num_layers=2, num_kv_heads=4, head_size=64, dtype=torch.float16, block_size=16)
def make_cache(*, chunk_size=16, window_chunks=2, available_bytes=1 << 24):
cfg = ARDiffusionKVConfig(enable=True, chunk_size=chunk_size, window_chunks=window_chunks)
return ARDiffusionKVCache(cfg, max_model_len=4096, available_bytes=available_bytes, **DIMS)
def test_requires_enabled_config():
with pytest.raises(ValueError):
ARDiffusionKVCache(ARDiffusionKVConfig(enable=False), max_model_len=256, available_bytes=1 << 20, **DIMS)
def test_requires_bounded_window():
cfg = ARDiffusionKVConfig(enable=True, chunk_size=16, window_chunks=None)
with pytest.raises(ValueError):
ARDiffusionKVCache(cfg, max_model_len=256, available_bytes=1 << 20, **DIMS)
def test_full_request_lifecycle_and_eviction():
"""begin -> per-chunk allocate/slots/commit over a long rollout -> free.
Exercises the orchestrator end-to-end and asserts the chunk window bounds
memory (pool plateaus) and frees cleanly.
"""
kv = make_cache(chunk_size=16, window_chunks=2)
free_total = kv.manager.block_pool.get_num_free_blocks()
adapter = kv.begin_request("req-0")
free_after = []
for k in range(10):
block_table = kv.allocate_chunk(adapter)
slots = kv.chunk_write_slots(adapter)
# Slots target real blocks in this chunk's table; length == chunk_size.
assert len(slots) == kv.spec.chunk_size
used = {int(s) // kv.block_size for s in slots}
assert kv.null_block_id not in used
assert used <= set(block_table)
# Resident window stays bounded by window + the in-flight chunk.
assert len(kv.window_block_ids(adapter)) <= kv.spec.window_chunks + 1
free_after.append(kv.manager.block_pool.get_num_free_blocks())
kv.commit_chunk(adapter)
# Pool memory plateaus once the window is full (eviction recycles blocks).
assert free_after[-1] == free_after[kv.spec.window_chunks]
kv.end_request(adapter)
assert kv.manager.block_pool.get_num_free_blocks() == free_total
def test_num_computed_advances_per_chunk():
kv = make_cache(chunk_size=16, window_chunks=2)
a = kv.begin_request("r")
assert a.num_computed_tokens == 0
kv.allocate_chunk(a)
kv.commit_chunk(a)
assert a.num_computed_tokens == 16
kv.end_request(a)
def test_state_close_frees_both_branch_blocks():
"""ARDiffusionKVState.close() returns both CFG branches' pool blocks to the pool.
This is the primitive the runner's LRU eviction relies on: when a session is
evicted, close() must free the blocks both adapters hold, or session churn
leaks pool ownership (review P1).
"""
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
kv = make_cache(chunk_size=16, window_chunks=2)
free_total = kv.manager.block_pool.get_num_free_blocks()
pos = kv.begin_request("bde__s")
neg = kv.begin_request("bde__s__neg")
state = ARDiffusionKVState(kv, pos, neg, num_layers=kv.num_layers)
for _ in range(3):
for adapter in (pos, neg):
kv.allocate_chunk(adapter)
kv.commit_chunk(adapter)
# Both branches hold resident blocks now.
assert kv.manager.block_pool.get_num_free_blocks() < free_total
state.close()
# All blocks returned — no leak across an evicted session.
assert kv.manager.block_pool.get_num_free_blocks() == free_total
+394
View File
@@ -0,0 +1,394 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for AR-Diffusion paged self-attention contexts."""
from __future__ import annotations
import subprocess
from importlib.util import find_spec
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import torch
from vllm_omni.experimental.ar_diffusion.kv_cache import (
ARDiffusionKVCache,
ARDiffusionKVConfig,
ARDiffusionPagedLayerContext,
ARDiffusionPagedLayerInputs,
ar_diffusion_paged_attention,
paged_write_attn,
)
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
BLOCK = 16
N_HEADS = 4
HEAD_DIM = 64
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)
kv = ARDiffusionKVCache(
cfg,
num_layers=num_layers,
num_kv_heads=N_HEADS,
head_size=HEAD_DIM,
dtype=dtype,
block_size=BLOCK,
max_model_len=4096,
available_bytes=1 << 26,
device=device,
)
pos = kv.begin_request("r-pos")
neg = kv.begin_request("r-neg")
return kv, ARDiffusionKVState(kv, pos, neg, num_layers=num_layers)
def _dense_attention(query: torch.Tensor, key: torch.Tensor, value: torch.Tensor) -> torch.Tensor:
scores = torch.einsum("bqhd,bkhd->bhqk", query.float(), key.float()) * (HEAD_DIM**-0.5)
probs = torch.softmax(scores, dim=-1).to(value.dtype)
return torch.einsum("bhqk,bkhd->bqhd", probs, value)
def _cuda_flash_attn_usable() -> bool:
if not torch.cuda.is_available():
return False
try:
spec = find_spec("vllm.vllm_flash_attn")
if spec is None or spec.origin is None:
return True
fa2_so = Path(spec.origin).parent / "_vllm_fa2_C.abi3.so"
linked = subprocess.check_output(["ldd", str(fa2_so)], text=True, timeout=5)
except Exception:
return True
if "libcudart.so.13" not in linked:
return True
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
text=True,
timeout=5,
)
driver_major = int(out.splitlines()[0].split(".")[0])
except Exception:
return True
return driver_major >= 580
def _commit_video_span(
kv: ARDiffusionKVCache,
st: ARDiffusionKVState,
*,
is_negative: bool,
n_chunks: int,
dtype: torch.dtype,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
ctx = st.get_kv_caches(is_negative, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx
ctx.ensure_video_slots(device)
k = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
v = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
kv._k_pools[0][ctx.current_video_slot_mapping] = k[0]
kv._v_pools[0][ctx.current_video_slot_mapping] = v[0]
st.commit_paged_context(is_negative)
return k, v
def test_paged_context_allocates_lazily_and_commits_after_forward():
_, st = make_state()
contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)
ctx = contexts[0].forward_ctx
assert isinstance(contexts[0], ARDiffusionPagedLayerContext)
assert st.pos.completed_chunks == 0
assert ctx.current_video_slot_mapping is None
ctx.ensure_video_slots(torch.device("cpu"))
assert st.pos.completed_chunks == 0
assert len(ctx.current_video_block_ids) == 1
st.commit_paged_context(False)
assert st.pos.completed_chunks == 1
assert st._committed[False] == BLOCK
def test_scratch_video_and_action_blocks_do_not_commit():
kv, st = make_state()
ctx = st.get_kv_caches(False, seq_len=2 * BLOCK, commit_current=False)[0].forward_ctx
ctx.ensure_video_slots(torch.device("cpu"))
ctx.ensure_action_slots(3, torch.device("cpu"))
assert ctx.current_video_block_ids == kv.scratch_block_ids(False, 0, 2)
assert ctx.action_scratch_block_ids == kv.scratch_block_ids(False, 2, 1)
st.commit_paged_context(False)
assert st.pos.completed_chunks == 0
assert st._committed[False] == 0
def test_pipeline_kv_get_paged_path_has_no_gather_backend():
kv, st = make_state()
assert not hasattr(kv, "gather_window_all_layers")
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
pipeline = DreamZeroPipeline.__new__(DreamZeroPipeline)
pipeline._ar_diffusion_kv_state = st
contexts = pipeline._kv_get(MagicMock(), False, seq_len=BLOCK, update_kv_cache=False)
assert len(contexts) == 1
assert isinstance(contexts[0], ARDiffusionPagedLayerContext)
@pytest.mark.parametrize("history_chunks", [0, 1, 3])
@pytest.mark.parametrize("action_len", [0, 3])
@pytest.mark.parametrize("commit_current", [False, True])
def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len, commit_current):
torch.manual_seed(0)
device = torch.device("cpu")
dtype = torch.float32
kv, st = make_state(dtype=dtype, device=device, window_chunks=2)
history_k_parts: list[torch.Tensor] = []
history_v_parts: list[torch.Tensor] = []
if history_chunks:
k, v = _commit_video_span(
kv,
st,
is_negative=False,
n_chunks=history_chunks,
dtype=dtype,
device=device,
)
history_k_parts.append(k)
history_v_parts.append(v)
ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=commit_current)[0].forward_ctx
ctx.ensure_video_slots(device)
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)
kv._k_pools[0][ctx.current_video_slot_mapping] = current_k[0]
kv._v_pools[0][ctx.current_video_slot_mapping] = current_v[0]
action_k = action_v = None
if action_len:
ctx.ensure_action_slots(action_len, device)
action_k = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
action_v = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
kv._k_pools[0][ctx.action_slot_mapping] = action_k[0]
kv._v_pools[0][ctx.action_slot_mapping] = action_v[0]
query = torch.randn(1, BLOCK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
block_table, query_start_loc, seq_lens, max_query_len, max_seq_len = ctx.build_block_table(
action_len=action_len,
query_len=query.shape[1],
device=device,
)
paged = ar_diffusion_paged_attention(
query,
kv.key_cache(0),
kv.value_cache(0),
block_table=block_table,
query_start_loc=query_start_loc,
seq_lens=seq_lens,
max_query_len=max_query_len,
max_seq_len=max_seq_len,
softmax_scale=HEAD_DIM**-0.5,
causal=False,
)
if history_k_parts:
history_k = torch.cat(history_k_parts, dim=1)
history_v = torch.cat(history_v_parts, dim=1)
else:
history_k = torch.empty(1, 0, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
history_v = torch.empty(1, 0, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
new_k = torch.cat([history_k, current_k], dim=1)[:, -kv.spec.sliding_window :]
new_v = torch.cat([history_v, current_v], dim=1)[:, -kv.spec.sliding_window :]
if action_len:
new_k = torch.cat([new_k, action_k], dim=1)
new_v = torch.cat([new_v, action_v], dim=1)
ref = _dense_attention(query, new_k, new_v)
torch.testing.assert_close(paged, ref, rtol=1e-5, atol=1e-5)
before = st.pos.completed_chunks
st.commit_paged_context(False)
assert st.pos.completed_chunks == before + (1 if commit_current else 0)
@pytest.mark.skipif(not _cuda_flash_attn_usable(), reason="usable CUDA FlashAttention is required")
@pytest.mark.parametrize("history_chunks", [1, 3])
@pytest.mark.parametrize("action_len", [0, 3])
@pytest.mark.parametrize("commit_current", [False, True])
def test_paged_attention_matches_dense_reference_gpu(history_chunks, action_len, commit_current):
pytest.importorskip("vllm.vllm_flash_attn")
torch.manual_seed(0)
device = torch.device("cuda")
dtype = torch.float16
kv, st = make_state(dtype=dtype, device=device, window_chunks=2)
history_k, history_v = _commit_video_span(
kv,
st,
is_negative=False,
n_chunks=history_chunks,
dtype=dtype,
device=device,
)
layer_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=commit_current)[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)
# The production path: once-per-forward host prep, then the fused
# write+attend custom op consuming the NamedTuple payload.
ctx.prepare(device=device, action_len=action_len, query_len=query.shape[1])
inputs = layer_ctx.to_layer_inputs()
assert isinstance(inputs, ARDiffusionPagedLayerInputs)
paged = 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)
# Direct python-fn call on the same (already written) pools must be
# bit-exact: identical kernel, identical inputs.
direct = ar_diffusion_paged_attention(
query,
kv.key_cache(0),
kv.value_cache(0),
block_table=ctx.block_table,
query_start_loc=ctx.query_start_loc,
seq_lens=ctx.seq_lens,
max_query_len=ctx.max_query_len,
max_seq_len=ctx.max_seq_len,
softmax_scale=HEAD_DIM**-0.5,
causal=False,
)
assert torch.equal(paged, direct)
new_k = torch.cat([history_k, current_k], dim=1)[:, -kv.spec.sliding_window :]
new_v = torch.cat([history_v, current_v], dim=1)[:, -kv.spec.sliding_window :]
if action_len:
new_k = torch.cat([new_k, action_k], dim=1)
new_v = torch.cat([new_v, action_v], dim=1)
ref = _dense_attention(query, new_k, new_v)
torch.testing.assert_close(paged, ref, rtol=2e-2, atol=2e-2)
def test_block_table_padded_to_fixed_width():
"""Shapes must be constant across window growth: only values change."""
device = torch.device("cpu")
kv, st = make_state(window_chunks=2)
ctx1 = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx
ctx1.prepare(device=device, action_len=0, query_len=BLOCK)
st.commit_paged_context(False)
ctx2 = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx
ctx2.prepare(device=device, action_len=0, query_len=BLOCK)
st.commit_paged_context(False)
# 1-block vs 2-block visible history: same table width, same max_seq_len.
assert ctx1.block_table.shape == ctx2.block_table.shape
assert ctx1.max_seq_len == ctx2.max_seq_len
expected_width = kv.spec.sliding_window // kv.block_size + 1
assert ctx1.block_table.shape == (1, expected_width)
# Real lengths live in seq_lens, not the padded table.
assert int(ctx1.seq_lens[0]) == BLOCK
assert int(ctx2.seq_lens[0]) == 2 * BLOCK
def test_prepare_is_idempotent_and_layers_share_metadata():
device = torch.device("cpu")
_, st = make_state(num_layers=2)
contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False)
fctx = contexts[0].forward_ctx
fctx.prepare(device=device, action_len=0, query_len=BLOCK)
table = fctx.block_table
fctx.prepare(device=device, action_len=0, query_len=BLOCK)
assert fctx.block_table is table # memoized, not rebuilt
i0, i1 = contexts[0].to_layer_inputs(), contexts[1].to_layer_inputs()
# 0-dim tensors (NOT python ints) so dynamo doesn't install per-layer
# value guards on the shared block code object.
assert isinstance(i0.layer_idx, torch.Tensor) and int(i0.layer_idx) == 0
assert isinstance(i1.layer_idx, torch.Tensor) and int(i1.layer_idx) == 1
# All layers share the same metadata tensor objects.
assert i0.block_table is i1.block_table
assert i0.seq_lens is i1.seq_lens
assert i0.video_slots is i1.video_slots
def test_layer_inputs_before_prepare_raises():
_, st = make_state()
layer_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False)[0]
with pytest.raises(RuntimeError, match="before prepare"):
layer_ctx.to_layer_inputs()
def test_custom_op_registration_idempotent():
import importlib
import sys
assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn")
mod = "vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention"
saved = sys.modules.pop(mod)
try:
importlib.import_module(mod) # re-registration must not raise
finally:
sys.modules[mod] = saved
assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn")
def test_custom_op_compiles_fullgraph_without_recompile_on_value_change():
"""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)
def run_one_forward(commit):
contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=commit)
fctx = contexts[0].forward_ctx
fctx.prepare(device=device, action_len=0, query_len=BLOCK)
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).
for layer_ctx in contexts:
out = compiled(layer_ctx.to_layer_inputs(), q, k, v)
st.commit_paged_context(False)
return out
torch._dynamo.reset()
from torch._dynamo.testing import CompileCounter
counter = CompileCounter()
def fn(inputs, q, k, v):
return paged_write_attn(inputs, q, k, v, None, None, HEAD_DIM**-0.5) * 1.0
compiled = torch.compile(fn, backend=counter, fullgraph=True)
run_one_forward(commit=True) # history grows between calls ->
run_one_forward(commit=True) # block-table VALUES change, shapes don't
run_one_forward(commit=False)
assert counter.frame_count == 1, f"recompiled: frame_count={counter.frame_count}"
+222
View File
@@ -0,0 +1,222 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for the ARDiffusionKVState paged-attention pipeline bridge."""
import pytest
import torch
from vllm_omni.experimental.ar_diffusion.kv_cache import (
ARDiffusionKVCache,
ARDiffusionKVConfig,
ARDiffusionPagedLayerContext,
)
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
BLOCK = 16
N_HEADS = 4
HEAD_DIM = 64
def make_state(num_layers=1, window_chunks=4, cross_attn_length=0):
cfg = ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks)
kv = ARDiffusionKVCache(
cfg,
num_layers=num_layers,
num_kv_heads=N_HEADS,
head_size=HEAD_DIM,
dtype=torch.float32,
block_size=BLOCK,
max_model_len=4096,
available_bytes=1 << 24,
cross_attn_length=cross_attn_length,
device=torch.device("cpu"),
)
pos = kv.begin_request("r-pos")
neg = kv.begin_request("r-neg")
return kv, ARDiffusionKVState(kv, pos, neg, num_layers=num_layers)
def _prepare_and_commit(st: ARDiffusionKVState, is_negative: bool, n_chunks: int) -> None:
ctx = st.get_kv_caches(is_negative, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx
ctx.ensure_video_slots(torch.device("cpu"))
st.commit_paged_context(is_negative)
def test_get_returns_paged_layer_contexts_when_nothing_committed():
_, st = make_state(num_layers=2)
for neg in (False, True):
contexts = st.get_kv_caches(neg, seq_len=BLOCK, commit_current=False)
assert len(contexts) == 2
assert all(isinstance(ctx, ARDiffusionPagedLayerContext) for ctx in contexts)
assert contexts[0].history_block_ids == []
def test_managed_paged_context_commits_only_after_forward():
kv, st = make_state(num_layers=1)
contexts = st.get_kv_caches(False, seq_len=2 * BLOCK, commit_current=True)
ctx = contexts[0].forward_ctx
assert st.pos.completed_chunks == 0
assert kv.window_block_ids(st.pos) == []
ctx.ensure_video_slots(torch.device("cpu"))
assert st.pos.completed_chunks == 0
assert len(ctx.current_video_block_ids) == 2
st.commit_paged_context(False)
assert st.pos.completed_chunks == 2
assert st._committed[False] == 2 * BLOCK
assert len(kv.window_block_ids(st.pos)) == 2
def test_scratch_paged_context_does_not_commit():
kv, st = make_state(num_layers=1)
contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False)
ctx = contexts[0].forward_ctx
ctx.ensure_video_slots(torch.device("cpu"))
assert ctx.current_video_block_ids == kv.scratch_block_ids(False, 0, 1)
st.commit_paged_context(False)
assert st.pos.completed_chunks == 0
assert st._committed[False] == 0
assert kv.window_block_ids(st.pos) == []
def test_eviction_bounds_resident_window():
kv, st = make_state(num_layers=1, window_chunks=3)
_prepare_and_commit(st, False, 3)
assert len(kv.window_block_ids(st.pos)) == 3
_prepare_and_commit(st, False, 2)
ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False)[0].forward_ctx
visible_blocks, video_len = ctx.video_block_table(torch.device("cpu"))
assert len(visible_blocks) == 3
assert video_len == 3 * BLOCK
def test_branches_are_independent():
kv, st = make_state()
_prepare_and_commit(st, False, 1)
assert len(kv.window_block_ids(st.pos)) == 1
assert kv.window_block_ids(st.neg) == []
def test_reset_clears_session_window():
kv, st = make_state(num_layers=1, window_chunks=4)
_prepare_and_commit(st, False, 2)
_prepare_and_commit(st, True, 1)
assert st._committed[False] == 2 * BLOCK and st._committed[True] == BLOCK
free_before = kv.manager.block_pool.get_num_free_blocks()
st.reset()
assert st._committed == {False: 0, True: 0}
assert st._paged_pending == {False: None, True: None}
assert kv.window_block_ids(st.pos) == []
assert kv.window_block_ids(st.neg) == []
assert kv.manager.block_pool.get_num_free_blocks() > free_before
_prepare_and_commit(st, False, 1)
assert len(kv.window_block_ids(st.pos)) == 1
def test_get_cross_kv_caches_raises_before_populate():
"""Engine ownership guard: a cross-attn read before _kv_populate_cross must fail loud."""
_, st = make_state() # cross_attn_length == 0, nothing populated
for neg in (False, True):
with pytest.raises(RuntimeError, match="cross-attn read before"):
st.get_cross_kv_caches(neg)
# Guards the full AND: the populated flag alone is not enough without a pool.
st._cross_text_populated[False] = True
with pytest.raises(RuntimeError, match="cross-attn read before"):
st.get_cross_kv_caches(False)
def test_get_cross_kv_caches_returns_pool_dicts_when_populated():
L = 8
kv, st = make_state(num_layers=2, cross_attn_length=L)
written = []
for i in range(2):
k = torch.randn(1, L, N_HEADS, HEAD_DIM)
v = torch.randn(1, L, N_HEADS, HEAD_DIM)
kv.write_cross_kv(i, False, k, v)
written.append((k, v))
st._cross_text_populated[False] = True
out = st.get_cross_kv_caches(False)
assert len(out) == 2
for i, (k, v) in enumerate(written):
assert out[i]["is_init"] is True
assert out[i]["k"].shape == (1, L, N_HEADS, HEAD_DIM)
assert torch.equal(out[i]["k"], k)
assert torch.equal(out[i]["v"], v)
def test_kv_create_is_noop_engine_owns_allocation():
from unittest.mock import MagicMock
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
p = DreamZeroPipeline.__new__(DreamZeroPipeline)
p._ar_diffusion_kv_state = object()
state = MagicMock()
p._kv_create(state, 1, "float32", "cpu", 24, 4, 64)
state.create_kv_caches.assert_not_called()
def test_get_kv_cache_requires_frame_aligned_seqlen():
_, st = make_state()
with pytest.raises(AssertionError, match="frame-aligned"):
st.get_kv_caches(False, seq_len=BLOCK + 1, commit_current=True)
def test_prepare_one_branch_allocates_nothing_for_the_other():
"""CFG-parallel laziness: a rank prepares only the branch it runs."""
kv, st = make_state(num_layers=1, window_chunks=4)
pos_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx
neg_ctx = st.get_kv_caches(True, seq_len=BLOCK, commit_current=True)[0].forward_ctx
pos_ctx.prepare(device=torch.device("cpu"), action_len=0, query_len=BLOCK)
assert pos_ctx._allocated_video
assert not neg_ctx._allocated_video
assert kv.window_block_ids(st.neg) == []
def test_failed_forward_tears_down_session(monkeypatch):
"""Review follow-up (hsliuustc0106): a forward that dies partway must not
leave allocated-but-uncommitted KV behind — the session is torn down (pool
blocks freed, model-local state dropped) and the error propagates."""
from types import SimpleNamespace
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner
kv, st = make_state(num_layers=1, window_chunks=4)
_prepare_and_commit(st, False, 2) # session owns pool blocks
free_before_failure = kv.manager.block_pool.get_num_free_blocks()
runner = object.__new__(ARDiffusionModelRunner)
runner.kv_cache = kv
runner._ar_diffusion_states = __import__("collections").OrderedDict({"s1": st})
runner._max_ar_diffusion_states = 4
runner.pipeline = SimpleNamespace(_states={"s1": object()}, _ar_diffusion_kv_state=None)
runner.device = None
runner._perf_e2e_times = []
def boom(self, req):
raise RuntimeError("layer 17 exploded")
monkeypatch.setattr(DiffusionModelRunner, "execute_model", boom)
req = SimpleNamespace(
request_id="r0",
sampling_params=SimpleNamespace(extra_args={"session_id": "s1"}),
)
with pytest.raises(RuntimeError, match="layer 17 exploded"):
ARDiffusionModelRunner.execute_model(runner, req)
assert "s1" not in runner._ar_diffusion_states
assert "s1" not in runner.pipeline._states
assert runner.pipeline._ar_diffusion_kv_state is None
# close() freed the session's resident blocks.
assert kv.manager.block_pool.get_num_free_blocks() > free_before_failure
+76
View File
@@ -0,0 +1,76 @@
# SPDX-License-Identifier: Apache-2.0
"""Slot-mapping / block-table tests for the AR-Diffusion engine (Phase 1, Step 3)."""
import pytest
import torch
from vllm_omni.experimental.ar_diffusion.kv_cache import (
ARDiffusionRequestAdapter,
ChunkWindowSpec,
build_kv_manager,
chunk_slot_mapping,
compute_slot_mapping,
resident_block_ids,
)
BLOCK = 16
def make_spec(*, window_chunks=2):
return ChunkWindowSpec(
block_size=BLOCK,
num_kv_heads=4,
head_size=64,
dtype=torch.float16,
sliding_window=window_chunks * BLOCK,
chunk_size=BLOCK,
window_chunks=window_chunks,
)
def test_slot_mapping_matches_block_offsets():
# block table [5, 2, 9], block_size 4. slot = block*4 + (pos % 4).
slots = compute_slot_mapping([5, 2, 9], [0, 1, 4, 5, 8, 9], block_size=4)
assert slots.tolist() == [20, 21, 8, 9, 36, 37]
def test_slot_mapping_rejects_bad_block_size():
with pytest.raises(ValueError):
compute_slot_mapping([1], [0], block_size=0)
def test_chunk_slot_mapping_targets_current_chunk():
# After 2 committed chunks (num_computed=32), the in-flight chunk maps to the
# block covering positions 32..47 — i.e. block table index 2.
block_ids = [7, 4, 3] # block index 2 -> physical block 3
slots = chunk_slot_mapping(block_ids, num_computed_tokens=32, chunk_size=4, block_size=16)
# positions 32..35 -> block_index 2 -> physical 3 -> slots 48..51
assert slots.tolist() == [48, 49, 50, 51]
def test_resident_block_ids_excludes_null():
assert resident_block_ids([0, 1, 0, 2, 3], null_block_id=0) == [1, 2, 3]
def test_blocktable_build_single_request():
"""Integration: a real KVCacheManager block table -> a valid slot mapping.
Every slot for the in-flight chunk must land inside a real (non-null) block
that the manager actually allocated for the request.
"""
spec = make_spec()
mgr = build_kv_manager(spec, ["l0"], num_blocks=16, max_model_len=1024)
null_id = mgr.block_pool.null_block.block_id
adapter = ARDiffusionRequestAdapter("req", chunk_size=BLOCK)
mgr.allocate_slots(adapter, num_new_tokens=BLOCK)
block_ids = mgr.get_block_ids(adapter.request_id)[0]
real = set(resident_block_ids(block_ids, null_id))
assert real, "expected at least one real block after allocation"
slots = chunk_slot_mapping(block_ids, adapter.num_computed_tokens, spec.chunk_size, spec.block_size)
# Each slot resolves to a real allocated block, none to null_block.
blocks_used = {int(s) // spec.block_size for s in slots}
assert blocks_used <= real
assert null_id not in blocks_used
assert len(slots) == spec.chunk_size
@@ -6,6 +6,7 @@ import pytest
from vllm.v1.request import RequestStatus from vllm.v1.request import RequestStatus
import vllm_omni.core.sched.omni_ar_scheduler as scheduler_mod import vllm_omni.core.sched.omni_ar_scheduler as scheduler_mod
import vllm_omni.model_executor.models.voxcpm2.scheduler as voxcpm2_scheduler_mod
from vllm_omni.model_executor.models.voxcpm2.scheduler import VoxCPM2OmniARAsyncScheduler from vllm_omni.model_executor.models.voxcpm2.scheduler import VoxCPM2OmniARAsyncScheduler
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -51,14 +52,30 @@ class _MockRequest:
return RequestStatus.is_finished(self.status) return RequestStatus.is_finished(self.status)
def _make_scheduler(*, enable_unified_decode_graph: bool = True) -> VoxCPM2OmniARAsyncScheduler: @pytest.fixture(autouse=True)
def _mock_cuda_graph_platform(monkeypatch) -> None:
monkeypatch.setattr(voxcpm2_scheduler_mod.current_omni_platform, "is_cuda", lambda: True)
def _make_scheduler(
*,
enable_unified_decode_graph: bool | None = True,
deterministic_cfm_noise: bool = False,
runtime_config_on_model_config: bool = False,
) -> VoxCPM2OmniARAsyncScheduler:
sched = VoxCPM2OmniARAsyncScheduler.__new__(VoxCPM2OmniARAsyncScheduler) sched = VoxCPM2OmniARAsyncScheduler.__new__(VoxCPM2OmniARAsyncScheduler)
runtime_config = SimpleNamespace(enable_unified_decode_graph=enable_unified_decode_graph) hf_config = SimpleNamespace()
sched.vllm_config = SimpleNamespace( model_config = SimpleNamespace(hf_config=hf_config)
model_config=SimpleNamespace( if enable_unified_decode_graph is not None or deterministic_cfm_noise:
hf_config=SimpleNamespace(voxcpm2_runtime_config=runtime_config), runtime_config = SimpleNamespace(
enable_unified_decode_graph=True if enable_unified_decode_graph is None else enable_unified_decode_graph,
deterministic_cfm_noise=deterministic_cfm_noise,
) )
) if runtime_config_on_model_config:
model_config.voxcpm2_runtime_config = runtime_config
else:
hf_config.voxcpm2_runtime_config = runtime_config
sched.vllm_config = SimpleNamespace(model_config=model_config)
return sched return sched
@@ -86,6 +103,39 @@ def test_voxcpm2_unified_decode_graph_does_not_defer_when_disabled() -> None:
assert not scheduler._should_defer_waiting_for_unified_decode_graph() assert not scheduler._should_defer_waiting_for_unified_decode_graph()
def test_voxcpm2_unified_decode_graph_uses_model_runtime_defaults() -> None:
scheduler = _make_scheduler(enable_unified_decode_graph=None)
scheduler.running = [_MockRequest("decode")]
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
assert scheduler._should_defer_waiting_for_unified_decode_graph()
def test_voxcpm2_unified_decode_graph_reads_model_config_runtime_config() -> None:
scheduler = _make_scheduler(enable_unified_decode_graph=True, runtime_config_on_model_config=True)
scheduler.running = [_MockRequest("decode")]
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
assert scheduler._should_defer_waiting_for_unified_decode_graph()
def test_voxcpm2_unified_decode_graph_does_not_defer_with_deterministic_noise() -> None:
scheduler = _make_scheduler(deterministic_cfm_noise=True)
scheduler.running = [_MockRequest("decode")]
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
assert not scheduler._should_defer_waiting_for_unified_decode_graph()
def test_voxcpm2_unified_decode_graph_does_not_defer_without_cuda_graph(monkeypatch) -> None:
monkeypatch.setattr(voxcpm2_scheduler_mod.current_omni_platform, "is_cuda", lambda: False)
scheduler = _make_scheduler()
scheduler.running = [_MockRequest("decode")]
scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)])
assert not scheduler._should_defer_waiting_for_unified_decode_graph()
def test_unified_decode_graph_deferral_restores_waiting_queue(monkeypatch) -> None: def test_unified_decode_graph_deferral_restores_waiting_queue(monkeypatch) -> None:
scheduler = _make_scheduler() scheduler = _make_scheduler()
scheduler.running = [_MockRequest("decode")] scheduler.running = [_MockRequest("decode")]
@@ -19,6 +19,10 @@ class _FakeGroup:
self.group_ranks = group_ranks self.group_ranks = group_ranks
self.parallel_mode = parallel_mode self.parallel_mode = parallel_mode
self.device_group = object() self.device_group = object()
self.device_communicator = kwargs.get("device_communicator")
reduce_scatter = kwargs.get("reduce_scatter")
if reduce_scatter is not None:
self.reduce_scatter = reduce_scatter
self.ulysses_group = kwargs.get("ulysses_group") self.ulysses_group = kwargs.get("ulysses_group")
self.ring_group = kwargs.get("ring_group") self.ring_group = kwargs.get("ring_group")
self.local_group = next(group for group in group_ranks if local_rank in group) self.local_group = next(group for group in group_ranks if local_rank in group)
@@ -55,6 +59,23 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch):
created_groups.append(group) created_groups.append(group)
return group return group
def fake_init_vllm_model_parallel_group(
group_ranks,
local_rank,
backend,
group_name,
):
del backend
group = _FakeGroup(
[list(ranks) for ranks in group_ranks],
local_rank,
f"vllm_{group_name}",
device_communicator=object(),
reduce_scatter=lambda tensor, **kwargs: tensor,
)
created_groups.append(group)
return group
fake_world_group = SimpleNamespace( fake_world_group = SimpleNamespace(
rank_in_group=local_rank, rank_in_group=local_rank,
local_rank=local_rank, local_rank=local_rank,
@@ -68,6 +89,7 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch):
monkeypatch.setattr(parallel_state, "get_world_group", lambda: fake_world_group) monkeypatch.setattr(parallel_state, "get_world_group", lambda: fake_world_group)
monkeypatch.setattr(parallel_state, "get_forward_context", lambda: fake_forward_context) monkeypatch.setattr(parallel_state, "get_forward_context", lambda: fake_forward_context)
monkeypatch.setattr(parallel_state, "init_model_parallel_group", fake_init_model_parallel_group) monkeypatch.setattr(parallel_state, "init_model_parallel_group", fake_init_model_parallel_group)
monkeypatch.setattr(parallel_state, "init_vllm_model_parallel_group", fake_init_vllm_model_parallel_group)
monkeypatch.setattr(parallel_state, "init_dit_group", lambda *_args, **_kwargs: None) monkeypatch.setattr(parallel_state, "init_dit_group", lambda *_args, **_kwargs: None)
for name in ("_DP", "_CFG", "_SP", "_PP", "_FS", "_EXPERT_PARALLEL_GROUP_RANKS"): for name in ("_DP", "_CFG", "_SP", "_PP", "_FS", "_EXPERT_PARALLEL_GROUP_RANKS"):
@@ -87,7 +109,7 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch):
backend="gloo", backend="gloo",
) )
assert parallel_state.vllm_parallel_state._PCP is parallel_state._SP assert parallel_state.vllm_parallel_state._PCP is not parallel_state._SP
assert parallel_state.vllm_parallel_state._PCP.world_size == 2 assert parallel_state.vllm_parallel_state._PCP.world_size == 2
assert parallel_state._DP.world_size == 2 assert parallel_state._DP.world_size == 2
assert parallel_state.vllm_parallel_state._DP is not parallel_state._DP assert parallel_state.vllm_parallel_state._DP is not parallel_state._DP
@@ -96,6 +118,13 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch):
assert parallel_state.vllm_parallel_state._TP.world_size == 2 assert parallel_state.vllm_parallel_state._TP.world_size == 2
assert parallel_state._PP.world_size == 2 assert parallel_state._PP.world_size == 2
assert parallel_state.vllm_parallel_state._PCP.device_communicator is not None
assert parallel_state.vllm_parallel_state._DP.device_communicator is not None
assert parallel_state.vllm_parallel_state._EP.device_communicator is not None
assert hasattr(parallel_state.vllm_parallel_state._PCP, "reduce_scatter")
assert hasattr(parallel_state.vllm_parallel_state._DP, "reduce_scatter")
assert hasattr(parallel_state.vllm_parallel_state._EP, "reduce_scatter")
assert parallel_state.vllm_parallel_state._PCP.local_group == [0, 2] assert parallel_state.vllm_parallel_state._PCP.local_group == [0, 2]
assert parallel_state.vllm_parallel_state._DP.local_group == [0, 8, 16, 24] assert parallel_state.vllm_parallel_state._DP.local_group == [0, 8, 16, 24]
assert parallel_state.vllm_parallel_state._EP.local_group == [ assert parallel_state.vllm_parallel_state._EP.local_group == [
@@ -155,7 +184,9 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch):
], ],
] ]
ep_groups = [group.local_group for group in created_groups if group.parallel_mode == "expert"] vllm_group_names = [group.parallel_mode for group in created_groups if group.parallel_mode.startswith("vllm_")]
assert vllm_group_names == ["vllm_pcp", "vllm_tp", "vllm_dp", "vllm_ep"]
ep_groups = [group.local_group for group in created_groups if group.parallel_mode == "vllm_ep"]
assert ep_groups == [parallel_state.vllm_parallel_state._EP.local_group] assert ep_groups == [parallel_state.vllm_parallel_state._EP.local_group]
@@ -50,6 +50,48 @@ class TestSetForwardContextNumTokens:
mock_get.assert_not_called() mock_get.assert_not_called()
class TestSetForwardContextDPMetadata:
"""Test diffusion MoE DP metadata setup for vLLM 0.24 all2all dispatch."""
def test_sets_dp_metadata_from_vllm_dp_group(self, mocker):
import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
mock_ctx = mocker.MagicMock()
mock_ctx.dp_metadata = None
fake_group = mocker.MagicMock()
fake_group.world_size = 2
fake_group.cpu_group = object()
def _all_gather_object(output, obj, group):
assert group is fake_group.cpu_group
output[:] = [obj, obj + 1]
mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=True)
mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context", return_value=mock_ctx)
mocker.patch.object(hunyuan_moe._vllm_ps, "_DP", fake_group, create=True)
mocker.patch.object(hunyuan_moe.torch.distributed, "all_gather_object", side_effect=_all_gather_object)
hunyuan_moe._set_forward_context_dp_metadata(1024)
assert mock_ctx.dp_metadata is not None
assert mock_ctx.dp_metadata.num_tokens_across_dp_cpu.tolist() == [1024, 1025]
def test_keeps_existing_dp_metadata(self, mocker):
import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
existing_metadata = object()
mock_ctx = mocker.MagicMock()
mock_ctx.dp_metadata = existing_metadata
mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=True)
mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context", return_value=mock_ctx)
mock_all_gather = mocker.patch.object(hunyuan_moe.torch.distributed, "all_gather_object")
hunyuan_moe._set_forward_context_dp_metadata(1024)
assert mock_ctx.dp_metadata is existing_metadata
mock_all_gather.assert_not_called()
class TestHunyuanFusedMoEPlatformDispatch: class TestHunyuanFusedMoEPlatformDispatch:
"""Test platform dispatch via platform qualname hooks.""" """Test platform dispatch via platform qualname hooks."""
+6 -10
View File
@@ -4,7 +4,6 @@
from collections import OrderedDict from collections import OrderedDict
import pytest import pytest
import torch
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
from vllm_omni.diffusion.models.dreamzero.state_dreamzero import DreamZeroState from vllm_omni.diffusion.models.dreamzero.state_dreamzero import DreamZeroState
@@ -46,14 +45,11 @@ def test_dreamzero_pipeline_state_lru_caps_retained_sessions() -> None:
assert "session-b" not in pipeline._states assert "session-b" not in pipeline._states
def test_dreamzero_state_cache_access_requires_initialization() -> None: def test_dreamzero_state_owns_no_kv_caches() -> None:
"""KV (self- and cross-attention) is engine-owned since the AR-Diffusion
paged backend: the model-local cache accessors must be gone so nothing can
silently bypass the engine pool."""
state = DreamZeroState() state = DreamZeroState()
with pytest.raises(RuntimeError, match="KV caches not initialized"): for removed in ("get_kv_caches", "create_kv_caches", "update_kv_cache", "get_crossattn_caches"):
state.get_kv_caches() assert not hasattr(state, removed)
with pytest.raises(RuntimeError, match="Cross-attn caches not initialized"):
state.get_crossattn_caches()
with pytest.raises(RuntimeError, match="create_kv_caches first"):
state.update_kv_cache(0, torch.empty(0))
@@ -22,6 +22,7 @@ _OMNI_RUNNER_PARAM = (
{ {
"init_timeout": 1200, "init_timeout": 1200,
"tensor_parallel_size": 2, "tensor_parallel_size": 2,
"vae_use_tiling": True,
}, },
) )
@@ -32,6 +33,7 @@ _OMNI_RUNNER_PARAM_OFFLOAD = (
"init_timeout": 1200, "init_timeout": 1200,
"tensor_parallel_size": 2, "tensor_parallel_size": 2,
"enable_cpu_offload": True, "enable_cpu_offload": True,
"vae_use_tiling": True,
}, },
) )
@@ -45,10 +47,6 @@ pytestmark = [
), ),
] ]
_SKIP_ISSUE_3236 = pytest.mark.skip(
reason="https://github.com/vllm-project/vllm-omni/issues/3236",
)
def _validate_mp4(video_bytes: bytes, min_frames: int = 10) -> None: def _validate_mp4(video_bytes: bytes, min_frames: int = 10) -> None:
"""Validate that the MP4 contains meaningful video and audio tracks.""" """Validate that the MP4 contains meaningful video and audio tracks."""
@@ -74,7 +72,6 @@ def _validate_mp4(video_bytes: bytes, min_frames: int = 10) -> None:
container.close() container.close()
@_SKIP_ISSUE_3236
@hardware_test(res={"cuda": "H100"}, num_cards=2) @hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_magi_human_e2e(omni_runner: OmniRunner): def test_magi_human_e2e(omni_runner: OmniRunner):
"""End-to-end test for MagiHuman generating video and audio.""" """End-to-end test for MagiHuman generating video and audio."""
@@ -127,7 +127,7 @@ class TestHiggsAudioV3OnlineHappyPath:
windows stitch into a coherent PCM stream. The byte-count gate is the same as windows stitch into a coherent PCM stream. The byte-count gate is the same as
the sync paths; per-chunk audio content is verified offline against Whisper. the sync paths; per-chunk audio content is verified offline against Whisper.
NOTE: ``min_hnr_db=0.0`` sits below the typical speech-noise floor so the NOTE: ``min_hnr_db=0.0`` sit below the typical speech-noise floor so the
check still catches catastrophic codec failure (silence, white noise, sample check still catches catastrophic codec failure (silence, white noise, sample
scramble all give HNR << 0) while allowing for the sliding-window codec's scramble all give HNR << 0) while allowing for the sliding-window codec's
slightly-noisier-than-sync output. The default 1.0 dB threshold has only slightly-noisier-than-sync output. The default 1.0 dB threshold has only
@@ -3344,6 +3344,27 @@ class TestTTSAsyncOffloading:
qwen3_tts_server._build_tts_params.assert_called_once() qwen3_tts_server._build_tts_params.assert_called_once()
qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once() qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once()
def test_prepare_speech_generation_qwen3_default_seed_sets_tts_local_seed(
self, qwen3_tts_server, mocker: MockerFixture
):
"""Deploy default seed should seed Qwen3 TTS residual MTP sampling."""
qwen3_tts_server.engine_client.default_sampling_params_list = [
SimpleNamespace(max_tokens=2048, seed=42, extra_args=None)
]
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
request = OpenAICreateSpeechRequest(input="hello")
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
stage0_params = qwen3_tts_server.engine_client.generate.call_args.kwargs["sampling_params_list"][0]
assert stage0_params.seed == 42
assert stage0_params.extra_args["tts_local_seed"] == 42
assert qwen3_tts_server.engine_client.default_sampling_params_list[0].extra_args is None
def test_prepare_speech_generation_uses_adapter_model_type_label( def test_prepare_speech_generation_uses_adapter_model_type_label(
self, self,
voxtral_server, voxtral_server,
@@ -3429,6 +3450,48 @@ class TestTTSAsyncOffloading:
mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True) mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True)
def test_prepare_speech_generation_no_async_chunk_stream_uses_final_only(
self, qwen3_tts_server, mocker: MockerFixture
):
"""Full-payload TTS streaming should not request delta multimodal outputs."""
qwen3_tts_server.engine_client.model_config.async_chunk = False
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
)
request = OpenAICreateSpeechRequest(input="hello", stream_format="audio", response_format="pcm")
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, False)
def test_prepare_speech_generation_no_async_chunk_stream_keeps_delta_for_non_qwen3(
self, voxtral_server, mocker: MockerFixture
):
"""FINAL_ONLY streaming for async_chunk=False is scoped to qwen3_tts only."""
voxtral_server.engine_client.model_config.async_chunk = False
mocker.patch.object(voxtral_server._get_tts_adapter(), "validate", return_value=None)
voxtral_server._build_voxtral_prompt_async = mocker.AsyncMock(
return_value={
"prompt_token_ids": [1, 2, 3],
"additional_information": {"voice": ["test"]},
}
)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=voxtral_server.engine_client.default_sampling_params_list,
)
request = OpenAICreateSpeechRequest(input="hello", voice="test", stream_format="audio", response_format="pcm")
asyncio.run(voxtral_server._prepare_speech_generation(request))
mock_coerce.assert_called_once_with(voxtral_server.engine_client.default_sampling_params_list, True)
def test_prepare_speech_generation_qwen3_voicedesign_non_streaming_mode_false( def test_prepare_speech_generation_qwen3_voicedesign_non_streaming_mode_false(
self, qwen3_tts_server, mocker: MockerFixture self, qwen3_tts_server, mocker: MockerFixture
): ):
+49 -8
View File
@@ -179,7 +179,7 @@ class TestOmniSleepMode:
try: try:
device_id = 0 device_id = 0
used_before = get_device_global_memory_used_gib(device_id) used_before = get_device_global_memory_used_gib(device_id)
acks = await llm_engine.sleep(stage_ids=[0], level=2) acks = await llm_engine.sleep(stage_ids=[0], level=1)
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
used_after = get_device_global_memory_used_gib(device_id) used_after = get_device_global_memory_used_gib(device_id)
drop_gib = used_before - used_after drop_gib = used_before - used_after
@@ -209,7 +209,7 @@ class TestOmniSleepMode:
"""Diffusion Worker stage signal loop""" """Diffusion Worker stage signal loop"""
try: try:
logger.info("Starting Diffusion Worker Handshake Test") logger.info("Starting Diffusion Worker Handshake Test")
acks = await diffusion_engine.sleep(stage_ids=[0], level=2) acks = await diffusion_engine.sleep(stage_ids=[0], level=1)
def _get_status(ack): def _get_status(ack):
return ack.status if hasattr(ack, "status") else ack.get("status") return ack.status if hasattr(ack, "status") else ack.get("status")
@@ -264,9 +264,9 @@ class TestOmniSleepMode:
base_output = output base_output = output
assert base_output is not None and len(base_output.images) > 0 assert base_output is not None and len(base_output.images) > 0
logger.info("Baseline Generation successful.") logger.info("Baseline Generation successful.")
# Sleep Level 2 # Sleep Level 1
logger.info("Entering Deep Sleep (VRAM Scavenging)...") logger.info("Entering Deep Sleep (VRAM Scavenging)...")
await diffusion_engine.sleep(stage_ids=[0], level=2) await diffusion_engine.sleep(stage_ids=[0], level=1)
# Wake-up # Wake-up
logger.info("Waking up (Reloading Weights)...") logger.info("Waking up (Reloading Weights)...")
await diffusion_engine.wake_up(stage_ids=[0]) await diffusion_engine.wake_up(stage_ids=[0])
@@ -353,8 +353,8 @@ class TestOmniSleepMode:
logger.info(f"Diffusion Initial VRAM: {vram_initial:.2f} GiB") logger.info(f"Diffusion Initial VRAM: {vram_initial:.2f} GiB")
# Sleep # Sleep
logger.info("Triggering Level 2 Deep Sleep (Full Weight Offloading)...") logger.info("Triggering Level 1 Deep Sleep (Partial Weight Offloading)...")
acks = await diffusion_engine.sleep(stage_ids=[0], level=2) acks = await diffusion_engine.sleep(stage_ids=[0], level=1)
reported_freed_bytes = sum(getattr(ack, "freed_bytes", 0) for ack in acks) reported_freed_bytes = sum(getattr(ack, "freed_bytes", 0) for ack in acks)
reported_freed_gib = reported_freed_bytes / 1024**3 reported_freed_gib = reported_freed_bytes / 1024**3
@@ -407,6 +407,47 @@ class TestOmniSleepMode:
diffusion_engine.shutdown() diffusion_engine.shutdown()
await asyncio.sleep(1) await asyncio.sleep(1)
@pytest.mark.asyncio
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_level2_sleep_wake_raises(self, llm_engine: AsyncOmni):
"""Regression for #4473 Repro A: wake_up() after sleep(level=2) must raise
NotImplementedError instead of silently producing corrupted output."""
try:
await llm_engine.sleep(stage_ids=[0], level=2)
with pytest.raises(NotImplementedError, match="sleep\\(level=2\\)"):
await llm_engine.wake_up(stage_ids=[0])
finally:
llm_engine.shutdown()
@pytest.mark.asyncio
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_partial_wake_blocks_generate(self, llm_engine: AsyncOmni):
"""Regression for #4473 Repro B: generate() must be rejected if kv_cache
is still asleep after wake_up(tags=["weights"]), instead of crashing with
CUDA illegal memory access."""
try:
await llm_engine.sleep(stage_ids=[0], level=1)
await llm_engine.wake_up(stage_ids=[0], tags=["weights"])
with pytest.raises(RuntimeError, match="partially or fully asleep"):
async for _ in llm_engine.generate("test", sampling_params=SamplingParams(max_tokens=4)):
pass
finally:
llm_engine.shutdown()
@pytest.mark.asyncio
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_duplicate_wake_is_idempotent(self, llm_engine: AsyncOmni):
"""Regression for #4473 Repro C: duplicate wake_up(tags=None) must be a
safe no-op instead of raising a cumem CUDA invalid argument error."""
try:
await llm_engine.sleep(stage_ids=[0], level=1)
first_acks = await llm_engine.wake_up(stage_ids=[0])
assert len(first_acks) > 0, "First wake_up() should return ACKs"
second_acks = await llm_engine.wake_up(stage_ids=[0])
assert second_acks == [], f"Duplicate wake_up() should return [] but got {second_acks}"
finally:
llm_engine.shutdown()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# H100 (or MI325) e2e: BAGEL / pure diffusion, multi-TP # H100 (or MI325) e2e: BAGEL / pure diffusion, multi-TP
@@ -444,7 +485,7 @@ async def test_diffusion_model_sleep_tp(tp_size: int):
async for _ in engine.generate("test", sampling_params_list=[llm_sp, diff_sp]): async for _ in engine.generate("test", sampling_params_list=[llm_sp, diff_sp]):
pass pass
acks = await engine.sleep(level=2) acks = await engine.sleep(level=1)
statuses = [get_ack_info(ack, "status") for ack in acks] statuses = [get_ack_info(ack, "status") for ack in acks]
assert all(s == "SUCCESS" for s in statuses), f"Sleep failed. Statuses: {statuses}" assert all(s == "SUCCESS" for s in statuses), f"Sleep failed. Statuses: {statuses}"
@@ -500,7 +541,7 @@ async def test_multistage_sleep_h100(tp_size: int):
async for _ in engine.generate("warmup", sampling_params_list=[SamplingParams(), sp]): async for _ in engine.generate("warmup", sampling_params_list=[SamplingParams(), sp]):
pass pass
acks = await engine.sleep(stage_ids=[0, 1], level=2) acks = await engine.sleep(stage_ids=[0, 1], level=1)
assert len(acks) == 2 assert len(acks) == 2
await engine.wake_up(stage_ids=[0, 1]) await engine.wake_up(stage_ids=[0, 1])
@@ -339,6 +339,91 @@ class TestCodePredictorDtypeAlignment:
assert not torch.equal(first[:, 1:], different[:, 1:]) assert not torch.equal(first[:, 1:], different[:, 1:])
class TestCodePredictorPerRowGenerators:
"""Seeded requests must stay deterministic inside a multi-row batch (#4883)."""
def _make_predictor(self, mocker: MockerFixture, loaded_target_classes):
_, _, code_predictor_wrapper, _, _ = loaded_target_classes
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
mocker.patch.object(common_mod.current_omni_platform, "is_npu", return_value=False)
cp_config, talker_config = _make_tiny_config(loaded_target_classes)
vllm_config = _make_vllm_config(mocker, max_num_seqs=4)
predictor = code_predictor_wrapper(
vllm_config=vllm_config,
config=cp_config,
talker_config=talker_config,
)
predictor._wrapper_config.use_cuda_graphs = False
return predictor, talker_config
def test_multinomial_per_row_matches_single_row_draws(self, mocker: MockerFixture, loaded_target_classes) -> None:
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
multinomial = common_mod.CodePredictorWrapper._multinomial
torch.manual_seed(7)
probs = torch.softmax(torch.randn(3, 64), dim=-1)
def seeded(seed: int) -> torch.Generator:
generator = torch.Generator(device=probs.device)
generator.manual_seed(seed)
return generator
batched = multinomial(probs, None, [seeded(11), None, seeded(22)])
assert batched.shape == (3, 1)
# Each seeded row must reproduce a standalone draw from the same seed,
# independent of what the other rows in the batch consume.
assert torch.equal(batched[0:1], torch.multinomial(probs[0:1], num_samples=1, generator=seeded(11)))
assert torch.equal(batched[2:3], torch.multinomial(probs[2:3], num_samples=1, generator=seeded(22)))
def test_forward_per_row_generators_are_row_independent(self, mocker: MockerFixture, loaded_target_classes) -> None:
predictor, talker_config = self._make_predictor(mocker, loaded_target_classes)
hidden = talker_config.hidden_size
torch.manual_seed(123)
row_embed = torch.randn(1, hidden)
row_hidden = torch.randn(1, hidden)
# Three identical rows: two share a seed, one differs.
layer0_code = torch.zeros(3, dtype=torch.long)
layer0_embed = row_embed.expand(3, hidden).contiguous()
last_talker_hidden = row_hidden.expand(3, hidden).contiguous()
def seeded(seed: int) -> torch.Generator:
generator = torch.Generator(device=layer0_code.device)
generator.manual_seed(seed)
return generator
def run(generators):
return predictor(
layer0_code=layer0_code,
layer0_embed=layer0_embed,
last_talker_hidden=last_talker_hidden,
do_sample=True,
temperature=0.9,
top_k=50,
top_p=1.0,
generators=generators,
)
first = run([seeded(1234), seeded(1234), seeded(4321)])
second = run([seeded(1234), seeded(1234), seeded(4321)])
# Same per-row seeds -> the whole batch reproduces across calls.
assert torch.equal(first, second)
# Identical inputs + identical seeds -> identical rows within a call.
assert torch.equal(first[0], first[1])
# A different seed on the same inputs must diverge in the residual layers.
assert not torch.equal(first[0, 1:], first[2, 1:])
def test_forward_rejects_mismatched_generators_length(self, mocker: MockerFixture, loaded_target_classes) -> None:
predictor, talker_config = self._make_predictor(mocker, loaded_target_classes)
hidden = talker_config.hidden_size
with pytest.raises(ValueError, match="one entry per row"):
predictor(
layer0_code=torch.zeros(2, dtype=torch.long),
layer0_embed=torch.randn(2, hidden),
last_talker_hidden=torch.randn(2, hidden),
generators=[torch.Generator()],
)
class TestCodePredictorModelDtype: class TestCodePredictorModelDtype:
"""Test the inner model forward with different dtypes.""" """Test the inner model forward with different dtypes."""
@@ -12,6 +12,7 @@ REPO_ROOT = Path(__file__).resolve().parents[4]
RUNNER = REPO_ROOT / "vllm_omni" / "worker" / "gpu_ar_model_runner.py" RUNNER = REPO_ROOT / "vllm_omni" / "worker" / "gpu_ar_model_runner.py"
RUNNER_ASSISTED_METADATA = REPO_ROOT / "vllm_omni" / "worker" / "runner_assisted_metadata.py" RUNNER_ASSISTED_METADATA = REPO_ROOT / "vllm_omni" / "worker" / "runner_assisted_metadata.py"
TALKER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "voxcpm2_talker.py" TALKER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "voxcpm2_talker.py"
VOXCPM2_RUNTIME_CONFIG = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "runtime_config.py"
VOXCPM2_PIPELINE = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "pipeline.py" VOXCPM2_PIPELINE = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "pipeline.py"
VOXCPM2_SCHEDULER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "scheduler.py" VOXCPM2_SCHEDULER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "scheduler.py"
OMNI_AR_SCHEDULER = REPO_ROOT / "vllm_omni" / "core" / "sched" / "omni_ar_scheduler.py" OMNI_AR_SCHEDULER = REPO_ROOT / "vllm_omni" / "core" / "sched" / "omni_ar_scheduler.py"
@@ -74,6 +75,7 @@ def test_ar_runner_without_model_hook_stays_on_normal_path():
def test_voxcpm2_graph_paths_fail_closed_and_preserve_deterministic_noise(): def test_voxcpm2_graph_paths_fail_closed_and_preserve_deterministic_noise():
source = TALKER.read_text() source = TALKER.read_text()
runtime_config_source = VOXCPM2_RUNTIME_CONFIG.read_text()
compact_source = "".join(source.split()) compact_source = "".join(source.split())
assert "_voxcpm2_compile_without_inductor_cudagraphs" in source assert "_voxcpm2_compile_without_inductor_cudagraphs" in source
@@ -86,8 +88,8 @@ def test_voxcpm2_graph_paths_fail_closed_and_preserve_deterministic_noise():
assert '"triton.cudagraphs": False' in compile_source assert '"triton.cudagraphs": False' in compile_source
assert '"triton.cudagraph_trees": False' in compile_source assert '"triton.cudagraph_trees": False' in compile_source
assert "self._enable_unified_decode_graph=(use_cuda_graph" in compact_source assert "self._enable_unified_decode_graph=self._runtime_config.unified_decode_graph_available(" in compact_source
assert "andnotself._deterministic_cfm_noise" in compact_source assert "andnotself.deterministic_cfm_noise" in "".join(runtime_config_source.split())
assert "decode_tail_graph" not in source assert "decode_tail_graph" not in source
unified_source = source[source.index("def _forward_unified_decode") :] unified_source = source[source.index("def _forward_unified_decode") :]
@@ -183,7 +185,9 @@ def test_voxcpm2_scheduler_policy_stays_model_local():
assert "def _should_defer_waiting_admission(self) -> bool:" in common_source assert "def _should_defer_waiting_admission(self) -> bool:" in common_source
assert "class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler)" in voxcpm2_scheduler_source assert "class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler)" in voxcpm2_scheduler_source
assert "voxcpm2_runtime_config" in voxcpm2_scheduler_source assert "from .runtime_config import _VoxCPM2RuntimeConfig" in voxcpm2_scheduler_source
assert "_VoxCPM2RuntimeConfig.from_vllm_config(self.vllm_config)" in voxcpm2_scheduler_source
assert "unified_decode_graph_available(use_cuda_graph=current_omni_platform.is_cuda())" in voxcpm2_scheduler_source
assert "_should_defer_waiting_for_unified_decode_graph" in voxcpm2_scheduler_source assert "_should_defer_waiting_for_unified_decode_graph" in voxcpm2_scheduler_source
assert "def schedule(" not in voxcpm2_scheduler_source assert "def schedule(" not in voxcpm2_scheduler_source
assert "create_request_queue" not in voxcpm2_scheduler_source assert "create_request_queue" not in voxcpm2_scheduler_source
@@ -0,0 +1,38 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm.config import CompilationConfig, CompilationMode, DeviceConfig, VllmConfig
from vllm_omni.platforms.cuda.platform import CudaOmniPlatform
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _vllm_config(*, backend: str = "inductor", mode: CompilationMode) -> VllmConfig:
return VllmConfig(
device_config=DeviceConfig(device="cpu"),
compilation_config=CompilationConfig(backend=backend, mode=mode),
)
@pytest.mark.parametrize(
"mode",
[CompilationMode.NONE, CompilationMode.VLLM_COMPILE, CompilationMode.STOCK_TORCH_COMPILE],
)
def test_cuda_default_ir_op_priority_prefers_vllm_c_when_inductor_backend(mode: CompilationMode) -> None:
"""Regression for #4964: inductor-active configs must not switch to native-only."""
priority = CudaOmniPlatform.get_default_ir_op_priority(_vllm_config(mode=mode))
assert priority.rms_norm == ["vllm_c", "native"]
assert priority.fused_add_rms_norm == ["vllm_c", "native"]
def test_cuda_default_ir_op_priority_with_oink(monkeypatch: pytest.MonkeyPatch) -> None:
import vllm.envs as envs
monkeypatch.setattr(envs, "VLLM_USE_OINK_OPS", True)
priority = CudaOmniPlatform.get_default_ir_op_priority(
_vllm_config(mode=CompilationMode.VLLM_COMPILE),
)
assert priority.rms_norm == ["oink", "vllm_c", "native"]
assert priority.fused_add_rms_norm == ["oink", "vllm_c", "native"]
+31 -1
View File
@@ -7,6 +7,7 @@ without requiring the actual checkpoint or GPU.
""" """
import asyncio import asyncio
import json
import time import time
from collections import OrderedDict, defaultdict from collections import OrderedDict, defaultdict
from types import SimpleNamespace from types import SimpleNamespace
@@ -1309,7 +1310,10 @@ class TestVoiceCloneReferenceCache:
parent = tmp_path / "OmniVoice" parent = tmp_path / "OmniVoice"
subdir = parent / "audio_tokenizer" subdir = parent / "audio_tokenizer"
subdir.mkdir(parents=True) subdir.mkdir(parents=True)
(subdir / "config.json").write_text("{}", encoding="utf-8") (subdir / "config.json").write_text(
json.dumps({"model_type": "higgs_audio_v2_tokenizer"}),
encoding="utf-8",
)
monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(parent)) monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(parent))
assert tok._resolve_audio_tokenizer_dir() == str(subdir) assert tok._resolve_audio_tokenizer_dir() == str(subdir)
@@ -1317,6 +1321,32 @@ class TestVoiceCloneReferenceCache:
monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(subdir)) monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(subdir))
assert tok._resolve_audio_tokenizer_dir() == str(subdir) assert tok._resolve_audio_tokenizer_dir() == str(subdir)
def test_audio_tokenizer_dir_rejects_omnivoice_config(self, tmp_path, monkeypatch):
import huggingface_hub
import huggingface_hub.constants as hub_constants
from vllm_omni.model_executor.models.higgs_audio_v2 import higgs_audio_v2_tokenizer as tok
parent = tmp_path / "OmniVoice"
subdir = parent / "audio_tokenizer"
subdir.mkdir(parents=True)
(subdir / "config.json").write_text(
json.dumps({"model_type": "omnivoice"}),
encoding="utf-8",
)
monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(parent))
monkeypatch.setenv("HIGGS_AUDIO_V2_TOKENIZER_PATH", "")
monkeypatch.setattr(
huggingface_hub,
"try_to_load_from_cache",
lambda **_: str(subdir / "config.json"),
)
monkeypatch.setattr(hub_constants, "HF_HUB_CACHE", str(tmp_path / "empty_cache"))
assert tok._normalize_audio_tokenizer_dir(str(parent)) is None
assert tok._resolve_audio_tokenizer_dir() is None
def test_higgs_v3_ref_code_cache_returns_clone(self): def test_higgs_v3_ref_code_cache_returns_clone(self):
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
+91 -5
View File
@@ -266,7 +266,6 @@ def test_build_omni_output_uses_snapshots_and_connector_after_accumulation(monke
ec_connector_output=None, ec_connector_output=None,
cudagraph_stats=None, cudagraph_stats=None,
kv_extracted_req_ids=["r2"], kv_extracted_req_ids=["r2"],
seq_len=3,
num_scheduled_tokens_np=torch.tensor([1, 2], dtype=torch.int32).numpy(), num_scheduled_tokens_np=torch.tensor([1, 2], dtype=torch.int32).numpy(),
query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long), query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long),
) )
@@ -312,7 +311,6 @@ def test_build_omni_output_copies_hidden_for_partial_downstream_batch(monkeypatc
ec_connector_output=None, ec_connector_output=None,
cudagraph_stats=None, cudagraph_stats=None,
kv_extracted_req_ids=None, kv_extracted_req_ids=None,
seq_len=6,
num_scheduled_tokens_np=np.array([1, 2, 3], dtype=np.int32), num_scheduled_tokens_np=np.array([1, 2, 3], dtype=np.int32),
query_start_loc_cpu=torch.tensor([0, 1, 3], dtype=torch.long), query_start_loc_cpu=torch.tensor([0, 1, 3], dtype=torch.long),
) )
@@ -366,7 +364,6 @@ def test_process_additional_information_uses_snapshot_request_order(monkeypatch)
ec_connector_output=None, ec_connector_output=None,
cudagraph_stats=None, cudagraph_stats=None,
kv_extracted_req_ids=None, kv_extracted_req_ids=None,
seq_len=3,
num_scheduled_tokens_np=torch.tensor([1, 2], dtype=torch.int32).numpy(), num_scheduled_tokens_np=torch.tensor([1, 2], dtype=torch.int32).numpy(),
query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long), query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long),
) )
@@ -427,7 +424,6 @@ def test_build_omni_output_skips_hidden_when_model_opts_out(monkeypatch):
ec_connector_output=None, ec_connector_output=None,
cudagraph_stats=None, cudagraph_stats=None,
kv_extracted_req_ids=None, kv_extracted_req_ids=None,
seq_len=2,
num_scheduled_tokens_np=np.array([2], dtype=np.int32), num_scheduled_tokens_np=np.array([2], dtype=np.int32),
query_start_loc_cpu=torch.tensor([0], dtype=torch.long), query_start_loc_cpu=torch.tensor([0], dtype=torch.long),
) )
@@ -439,6 +435,97 @@ def test_build_omni_output_skips_hidden_when_model_opts_out(monkeypatch):
assert output.multimodal_outputs is None assert output.multimodal_outputs is None
def test_build_omni_output_splits_mm_by_scheduled_tokens_when_hidden_is_tail_only(monkeypatch):
runner = _make_async_output_runner(engine_output_type="latent")
runner.model.omni_pooler_payload_include_hidden = False
runner._async_chunk = False
runner.requests = {"r1": object(), "r2": object(), "r3": object()}
monkeypatch.setattr(
GPUARModelRunner,
"_resolve_pooler_payload_req_ids",
lambda self, req_ids: ("latent", req_ids),
)
monkeypatch.setattr(GPUARModelRunner, "_should_accumulate_full_payload_output", lambda self: False)
monkeypatch.setattr(GPUARModelRunner, "get_omni_connector_output", lambda self: None)
monkeypatch.setattr(GPUARModelRunner, "_process_additional_information_updates", lambda *args, **kwargs: None)
codes = torch.arange(48, dtype=torch.long).reshape(3, 16)
output = GPUARModelRunner._build_omni_model_runner_output_from_snapshot(
runner,
scheduler_output=SimpleNamespace(
total_num_scheduled_tokens=3,
num_scheduled_tokens={"r1": 1, "r2": 1, "r3": 1},
),
hidden_states=torch.tensor([[1.0]]),
staged_hidden_states_cpu=None,
multimodal_outputs={"codes": {"audio": codes}},
req_ids_output_copy=["r1", "r2", "r3"],
req_id_to_index_output_copy={"r1": 0, "r2": 1, "r3": 2},
valid_sampled_token_ids=[[101], [102], [103]],
logprobs_lists=None,
prompt_logprobs_dict={},
num_nans_in_logits=None,
kv_connector_output=None,
ec_connector_output=None,
cudagraph_stats=None,
kv_extracted_req_ids=None,
num_scheduled_tokens_np=np.array([1, 1, 1], dtype=np.int32),
query_start_loc_cpu=torch.tensor([0, 1, 2], dtype=torch.long),
)
assert output.inter_stage_outputs is not None
assert torch.equal(output.inter_stage_outputs[0]["codes.audio"], codes[0:1])
assert torch.equal(output.inter_stage_outputs[1]["codes.audio"], codes[1:2])
assert torch.equal(output.inter_stage_outputs[2]["codes.audio"], codes[2:3])
def test_build_omni_output_splits_mm_by_hidden_len_when_scheduled_is_padded(monkeypatch):
"""Thinker mm rows align to hidden_states.shape[0], not padded scheduled count."""
runner = _make_async_output_runner(engine_output_type="latent")
runner.model.omni_pooler_payload_include_hidden = False
runner._async_chunk = False
runner.requests = {"r1": object(), "r2": object(), "r3": object()}
monkeypatch.setattr(
GPUARModelRunner,
"_resolve_pooler_payload_req_ids",
lambda self, req_ids: ("latent", req_ids),
)
monkeypatch.setattr(GPUARModelRunner, "_should_accumulate_full_payload_output", lambda self: False)
monkeypatch.setattr(GPUARModelRunner, "get_omni_connector_output", lambda self: None)
monkeypatch.setattr(GPUARModelRunner, "_process_additional_information_updates", lambda *args, **kwargs: None)
layers = torch.arange(96, dtype=torch.long).reshape(3, 32)
output = GPUARModelRunner._build_omni_model_runner_output_from_snapshot(
runner,
scheduler_output=SimpleNamespace(
total_num_scheduled_tokens=5,
num_scheduled_tokens={"r1": 1, "r2": 1, "r3": 1},
),
hidden_states=torch.randn(3, 4),
staged_hidden_states_cpu=None,
multimodal_outputs={"hidden_states": {"layers": {0: layers}}},
req_ids_output_copy=["r1", "r2", "r3"],
req_id_to_index_output_copy={"r1": 0, "r2": 1, "r3": 2},
valid_sampled_token_ids=[[101], [102], [103]],
logprobs_lists=None,
prompt_logprobs_dict={},
num_nans_in_logits=None,
kv_connector_output=None,
ec_connector_output=None,
cudagraph_stats=None,
kv_extracted_req_ids=None,
num_scheduled_tokens_np=np.array([1, 1, 1], dtype=np.int32),
query_start_loc_cpu=torch.tensor([0, 1, 2], dtype=torch.long),
)
assert output.inter_stage_outputs is not None
assert torch.equal(output.inter_stage_outputs[0]["hidden_states.layer_0"], layers[0:1])
assert torch.equal(output.inter_stage_outputs[1]["hidden_states.layer_0"], layers[1:2])
assert torch.equal(output.inter_stage_outputs[2]["hidden_states.layer_0"], layers[2:3])
def test_async_snapshot_payload_omits_hidden_when_model_opts_out(): def test_async_snapshot_payload_omits_hidden_when_model_opts_out():
runner = _make_async_output_runner() runner = _make_async_output_runner()
runner.model.omni_pooler_payload_include_hidden = False runner.model.omni_pooler_payload_include_hidden = False
@@ -627,7 +714,6 @@ def test_build_omni_output_falls_back_to_mm_cpu_without_prefix_merge(monkeypatch
ec_connector_output=None, ec_connector_output=None,
cudagraph_stats=None, cudagraph_stats=None,
kv_extracted_req_ids=None, kv_extracted_req_ids=None,
seq_len=2,
num_scheduled_tokens_np=np.array([1, 1], dtype=np.int32), num_scheduled_tokens_np=np.array([1, 1], dtype=np.int32),
query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long), query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long),
) )
@@ -81,6 +81,7 @@ class CaptureTalkerMTP(torch.nn.Module):
top_k=None, top_k=None,
top_p=None, top_p=None,
generator=None, generator=None,
generators=None,
): ):
self.calls.append( self.calls.append(
{ {
@@ -90,6 +91,7 @@ class CaptureTalkerMTP(torch.nn.Module):
"top_k": top_k, "top_k": top_k,
"top_p": top_p, "top_p": top_p,
"generator": generator, "generator": generator,
"generators": generators,
} }
) )
codes = torch.zeros((req_embeds.shape[0], 1), dtype=torch.int64) codes = torch.zeros((req_embeds.shape[0], 1), dtype=torch.int64)
@@ -306,6 +308,7 @@ def test_talker_mtp_forward_passes_qwen3_tts_subtalker_sampling_params_to_talker
"top_k": 9, "top_k": 9,
"top_p": 0.55, "top_p": 0.55,
"generator": runner.talker_mtp.calls[0]["generator"], "generator": runner.talker_mtp.calls[0]["generator"],
"generators": None,
} }
] ]
assert runner.talker_mtp.calls[0]["generator"] is not None assert runner.talker_mtp.calls[0]["generator"] is not None
@@ -350,6 +353,57 @@ def test_talker_mtp_forward_keeps_explicit_seeded_requests_scalar(monkeypatch):
assert torch.equal(runner.talker_mtp_inputs_embeds.gpu, saved_embeds) assert torch.equal(runner.talker_mtp_inputs_embeds.gpu, saved_embeds)
def test_talker_mtp_forward_batches_seeded_requests_for_opted_in_models(monkeypatch):
"""Models with talker_mtp_accepts_per_row_generators get one batched call (#4883)."""
import vllm_omni.worker.gpu_model_runner as mod
monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context)
runner = _make_runner(req_ids=("r1", "r2"), hidden_size=4)
runner.requests["r1"].sampling_params = SimpleNamespace(
seed=11,
extra_args={"tts_local_seed": 11},
)
runner.requests["r2"].sampling_params = SimpleNamespace(
seed=22,
extra_args={"tts_local_seed": 22},
)
runner.talker_mtp = CaptureTalkerMTP()
runner.model = SimpleNamespace(
talker_mtp_output_key=("codes", "audio"),
talker_mtp_accepts_per_row_generators=True,
)
runner.vllm_config = SimpleNamespace(model_config=SimpleNamespace(subtalker_sampling_params={}))
def fake_determine(self, num_tokens, num_reqs, num_scheduled_tokens_np, max_num_scheduled_tokens, use_cascade_attn):
batch_desc = SimpleNamespace(num_tokens=int(num_tokens))
return (False, batch_desc, None, None, None)
monkeypatch.setattr(runner, "_determine_batch_execution_and_padding", fake_determine.__get__(runner, type(runner)))
inputs_embeds = torch.zeros((6, 4), dtype=torch.float32)
OmniGPUModelRunner._talker_mtp_forward(runner, ["r1", "r2"], inputs_embeds)
# One batched call with distinct per-row generators, not two scalar calls.
assert [call["batch_size"] for call in runner.talker_mtp.calls] == [2]
row_generators = runner.talker_mtp.calls[0]["generators"]
assert runner.talker_mtp.calls[0]["generator"] is None
assert len(row_generators) == 2
assert all(generator is not None for generator in row_generators)
assert row_generators[0] is not row_generators[1]
# The per-request generator stream persists across steps...
OmniGPUModelRunner._talker_mtp_forward(runner, ["r1", "r2"], inputs_embeds)
assert runner.talker_mtp.calls[1]["generators"][0] is row_generators[0]
assert runner.talker_mtp.calls[1]["generators"][1] is row_generators[1]
# ...and is evicted once its request finishes.
del runner.requests["r2"]
OmniGPUModelRunner._talker_mtp_forward(runner, ["r1"], inputs_embeds)
assert set(runner._talker_mtp_generators) == {"r1"}
assert runner.talker_mtp.calls[2]["generator"] is row_generators[0]
def test_update_intermediate_buffer_writes_to_buffer_and_setattr(monkeypatch): def test_update_intermediate_buffer_writes_to_buffer_and_setattr(monkeypatch):
"""Validate that _update_intermediate_buffer writes to model_intermediate_buffer """Validate that _update_intermediate_buffer writes to model_intermediate_buffer
(forward path) and mirrors to additional_information_cpu setattr (backward compat).""" (forward path) and mirrors to additional_information_cpu setattr (backward compat)."""
+2
View File
@@ -14,6 +14,8 @@ stages:
max_num_seqs: 1 max_num_seqs: 1
enforce_eager: false enforce_eager: false
model_class_name: DreamZeroPipeline model_class_name: DreamZeroPipeline
# DreamZero runs on the AR-Diffusion engine (engine-level paged KV cache).
engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine
model_config: model_config:
default_robot_embodiment: roboarena default_robot_embodiment: roboarena
policy_server_config: policy_server_config:
@@ -60,7 +60,6 @@ stages:
temperature: 0.9 temperature: 0.9
top_k: 50 top_k: 50
max_tokens: 4096 max_tokens: 4096
seed: 42
repetition_penalty: 1.05 repetition_penalty: 1.05
subtalker_sampling_params: subtalker_sampling_params:
do_sample: true do_sample: true
@@ -85,7 +84,6 @@ stages:
top_p: 1.0 top_p: 1.0
top_k: -1 top_k: -1
max_tokens: 65536 max_tokens: 65536
seed: 42
repetition_penalty: 1.0 repetition_penalty: 1.0
platforms: platforms:
+15 -14
View File
@@ -28,20 +28,21 @@ stages:
max_num_batched_tokens: 4096 max_num_batched_tokens: 4096
max_model_len: 4096 max_model_len: 4096
devices: "0" devices: "0"
hf_overrides: engine_extras:
voxcpm2_runtime_config: hf_overrides:
audio_emit_every: 1 voxcpm2_runtime_config:
vae_decode_every: 3 audio_emit_every: 1
enable_batched_cfm: true vae_decode_every: 3
enable_batched_vae_decode: true enable_batched_cfm: true
coalesce_audio_d2h: true enable_batched_vae_decode: true
enable_loc_dit_fused_qkv: true coalesce_audio_d2h: true
enable_loc_dit_fused_mlp: true enable_loc_dit_fused_qkv: true
enable_loc_dit_zero_dt_cache: true enable_loc_dit_fused_mlp: true
enable_loc_dit_skip_qkv_contig: true enable_loc_dit_zero_dt_cache: true
enable_loc_dit_reduce_overhead_no_cg: true enable_loc_dit_skip_qkv_contig: true
enable_unified_decode_graph: true enable_loc_dit_reduce_overhead_no_cg: true
unified_decode_graph_max_batch_size: 8 enable_unified_decode_graph: true
unified_decode_graph_max_batch_size: 8
default_sampling_params: default_sampling_params:
temperature: 0.0 temperature: 0.0
top_p: 1.0 top_p: 1.0
+24 -1
View File
@@ -6,6 +6,7 @@ import os
import random import random
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import dataclass, field, fields from dataclasses import dataclass, field, fields
from enum import Enum
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import diffusers import diffusers
@@ -613,6 +614,20 @@ class OmniDiffusionConfig:
distributed_executor_backend: str = "mp" distributed_executor_backend: str = "mp"
nccl_port: int | None = None nccl_port: int | None = None
# Engine backend selection, resolved by ``DiffusionEngine.resolve_engine_class``
# (mirrors ``DiffusionExecutor.get_class``). Config files use a string:
# "default" -> DiffusionEngine, or an import path (set e.g. by a deploy
# config). Programmatic callers may pass a DiffusionEngine subclass
# directly — hence ``str | type`` (structured-config mirrors should expose
# the string form only).
engine_backend: str | type = "default"
# Optional override for the diffusion model runner class (import path).
# Precedence in the worker: this override > the runner declared by the
# selected engine class (``default_diffusion_model_runner_cls``) > the
# platform default. Never mutated by engines.
diffusion_model_runner_cls: str | None = None
# HuggingFace specific parameters # HuggingFace specific parameters
trust_remote_code: bool = False trust_remote_code: bool = False
revision: str | None = None revision: str | None = None
@@ -1173,7 +1188,8 @@ class OmniDiffusionConfig:
valid_fields = {f.name for f in fields(cls)} valid_fields = {f.name for f in fields(cls)}
filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_fields} filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_fields}
return cls(**filtered_kwargs) instance = cls(**filtered_kwargs)
return instance
@dataclass @dataclass
@@ -1473,5 +1489,12 @@ class OmniWakeTask:
tags: list[str] | None = None tags: list[str] | None = None
class CuMemTag(str, Enum):
"""Tags representing specific CuMem allocations for sleep/wake state tracking."""
WEIGHTS = "weights"
KV_CACHE = "kv_cache"
# Special message broadcast via scheduler queues to signal worker shutdown. # Special message broadcast via scheduler queues to signal worker shutdown.
SHUTDOWN_MESSAGE = {"type": "shutdown"} SHUTDOWN_MESSAGE = {"type": "shutdown"}
+51 -7
View File
@@ -134,6 +134,14 @@ class _RpcTask:
class DiffusionEngine: class DiffusionEngine:
"""The diffusion engine for vLLM-Omni diffusion models.""" """The diffusion engine for vLLM-Omni diffusion models."""
#: Import path of the model runner this engine's workers should build, or
#: ``None`` for the platform default. Subclasses declare their runner here
#: (e.g. the AR-Diffusion engine), the worker resolves it through
#: :meth:`resolve_engine_class` — so engine->runner routing lives on the
#: engine class itself, and ``od_config.diffusion_model_runner_cls``
#: remains a pure explicit user override (never mutated by engines).
default_diffusion_model_runner_cls: str | None = None
def __init__( def __init__(
self, self,
od_config: OmniDiffusionConfig, od_config: OmniDiffusionConfig,
@@ -609,19 +617,55 @@ class DiffusionEngine:
self._put_streaming_output_with_cv(request_id, out) self._put_streaming_output_with_cv(request_id, out)
@staticmethod @staticmethod
def make_engine( def resolve_engine_class(config: OmniDiffusionConfig) -> type[DiffusionEngine]:
config: OmniDiffusionConfig, """Resolve the engine class selected by ``config.engine_backend``.
scheduler: SchedulerInterface | None = None,
) -> DiffusionEngine: Mirrors ``DiffusionExecutor.get_class``: accepts ``"default"``, a
"""Factory method to create a DiffusionEngine instance. ``DiffusionEngine`` subclass, or an import-path string (e.g. a deploy
config's ``engine_backend``). Kept separate from :meth:`make_engine` so the
selection is testable without constructing an engine (which runs a dummy forward).
Args: Args:
config: The configuration for the diffusion engine. config: The configuration for the diffusion engine.
Returns: Returns:
An instance of DiffusionEngine. The ``DiffusionEngine`` (sub)class to instantiate.
""" """
return DiffusionEngine(config, scheduler=scheduler) backend = getattr(config, "engine_backend", "default") or "default"
if isinstance(backend, type):
if not issubclass(backend, DiffusionEngine):
raise TypeError(f"engine_backend must be a DiffusionEngine subclass. Got {backend}.")
return backend
if backend == "default":
return DiffusionEngine
if isinstance(backend, str):
try:
engine_class = resolve_obj_by_qualname(backend)
except (ImportError, ValueError) as e:
raise ValueError(
f"Failed to load engine_backend '{backend}'. Ensure it is a valid python path. Error: {e}"
) from e
if not issubclass(engine_class, DiffusionEngine):
raise TypeError(f"engine_backend must resolve to a DiffusionEngine subclass. Got {engine_class}.")
return engine_class
raise ValueError(f"Unknown engine_backend: {backend!r}")
@staticmethod
def make_engine(
config: OmniDiffusionConfig,
scheduler: SchedulerInterface | None = None,
) -> DiffusionEngine:
"""Factory method to create the engine selected by ``config.engine_backend``.
Args:
config: The configuration for the diffusion engine.
Returns:
An instance of the resolved ``DiffusionEngine`` (sub)class.
"""
engine_class = DiffusionEngine.resolve_engine_class(config)
return engine_class(config, scheduler=scheduler)
def add_request(self, request: OmniDiffusionRequest) -> str: def add_request(self, request: OmniDiffusionRequest) -> str:
with self._cv: with self._cv:
@@ -512,6 +512,21 @@ def init_model_parallel_group(
) )
def init_vllm_model_parallel_group(
group_ranks: list[list[int]],
local_rank: int,
backend: str,
group_name: str,
) -> vllm_parallel_state.GroupCoordinator:
return vllm_parallel_state.init_model_parallel_group(
group_ranks=group_ranks,
local_rank=local_rank,
backend=backend,
group_name=group_name,
use_device_communicator=True,
)
def init_dit_group( def init_dit_group(
dit_parallel_size: int, dit_parallel_size: int,
backend: str, backend: str,
@@ -838,23 +853,40 @@ def initialize_model_parallel(
if use_moe_parallel_mapping: if use_moe_parallel_mapping:
# Diffusion normally uses its own SP group. Map it to vLLM PCP only for # Diffusion normally uses its own SP group. Map it to vLLM PCP only for
# expert-parallel runtimes that rely on vLLM FusedMoE group semantics. # expert-parallel runtimes that rely on vLLM FusedMoE group semantics.
vllm_parallel_state._PCP = _SP # vLLM 0.24 MoE kernels require GroupCoordinator.device_communicator
# and reduce_scatter(), which the diffusion SP coordinator intentionally
# does not own. Keep the rank layout but build a vLLM coordinator.
vllm_parallel_state._PCP = init_vllm_model_parallel_group(
group_ranks=sp_group_ranks,
local_rank=get_world_group().local_rank,
backend=backend,
group_name="pcp",
)
assert vllm_parallel_state._TP is None, "Tensor parallel group is already initialized" assert vllm_parallel_state._TP is None, "Tensor parallel group is already initialized"
vllm_parallel_state._TP = init_model_parallel_group( tp_group_ranks = rank_generator.get_ranks("tp")
group_ranks=rank_generator.get_ranks("tp"), if use_moe_parallel_mapping:
local_rank=get_world_group().local_rank, vllm_parallel_state._TP = init_vllm_model_parallel_group(
backend=backend, group_ranks=tp_group_ranks,
parallel_mode="tensor", local_rank=get_world_group().local_rank,
) backend=backend,
if use_moe_parallel_mapping and cfg_parallel_size > 1: group_name="tp",
)
else:
vllm_parallel_state._TP = init_model_parallel_group(
group_ranks=tp_group_ranks,
local_rank=get_world_group().local_rank,
backend=backend,
parallel_mode="tensor",
)
if use_moe_parallel_mapping:
# CFG is a diffusion-specific replica dimension. Fold it into vLLM DP # CFG is a diffusion-specific replica dimension. Fold it into vLLM DP
# only when constructing the vLLM EP layout for expert-parallel paths. # only when constructing the vLLM EP layout for expert-parallel paths.
vllm_parallel_state._DP = init_model_parallel_group( vllm_parallel_state._DP = init_vllm_model_parallel_group(
group_ranks=rank_generator.get_ranks("cfg-dp"), group_ranks=rank_generator.get_ranks("cfg-dp"),
local_rank=get_world_group().local_rank, local_rank=get_world_group().local_rank,
backend=backend, backend=backend,
parallel_mode="data", group_name="dp",
) )
global _FS, _EXPERT_PARALLEL_GROUP_RANKS global _FS, _EXPERT_PARALLEL_GROUP_RANKS
@@ -869,11 +901,11 @@ def initialize_model_parallel(
_EXPERT_PARALLEL_GROUP_RANKS = None _EXPERT_PARALLEL_GROUP_RANKS = None
if use_moe_parallel_mapping: if use_moe_parallel_mapping:
ep_group_ranks = rank_generator.get_ranks("tp-sp-cfg-dp") ep_group_ranks = rank_generator.get_ranks("tp-sp-cfg-dp")
vllm_parallel_state._EP = init_model_parallel_group( vllm_parallel_state._EP = init_vllm_model_parallel_group(
group_ranks=ep_group_ranks, group_ranks=ep_group_ranks,
local_rank=get_world_group().local_rank, local_rank=get_world_group().local_rank,
backend=backend, backend=backend,
parallel_mode="expert", group_name="ep",
) )
_EXPERT_PARALLEL_GROUP_RANKS = ep_group_ranks _EXPERT_PARALLEL_GROUP_RANKS = ep_group_ranks
@@ -37,6 +37,20 @@ from vllm_omni.diffusion.models.dreamzero.action_encoder import (
MultiEmbodimentActionEncoder, MultiEmbodimentActionEncoder,
) )
# AR-Diffusion paged self-attention (in-tree experimental engine). Import at
# module level so the isinstance check + custom-op call trace cleanly inside
# the fullgraph-compiled DiT block (an import inside the traced region would
# graph-break). The model still works without the engine: the payload type is
# only ever constructed by the AR-Diffusion runner.
try:
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import (
ARDiffusionPagedLayerInputs,
paged_write_attn,
)
except ImportError: # pragma: no cover - experimental package always ships in-tree
ARDiffusionPagedLayerInputs = None
paged_write_attn = None
# ── RoPE utilities ────────────────────────────────────────────────── # ── RoPE utilities ──────────────────────────────────────────────────
@@ -510,7 +524,7 @@ class CausalWanSelfAttention(nn.Module):
freqs_action: torch.Tensor, freqs_action: torch.Tensor,
freqs_state: torch.Tensor, freqs_state: torch.Tensor,
action_register_length: int | None, action_register_length: int | None,
kv_cache: torch.Tensor | None = None, kv_cache: torch.Tensor | Any | None = None,
current_start_frame: int = 0, current_start_frame: int = 0,
is_tf: bool = True, is_tf: bool = True,
) -> tuple[torch.Tensor, torch.Tensor | None]: ) -> tuple[torch.Tensor, torch.Tensor | None]:
@@ -567,24 +581,44 @@ class CausalWanSelfAttention(nn.Module):
action_v = v[:, -action_register_length:] action_v = v[:, -action_register_length:]
v = v[:, :-action_register_length] v = v[:, :-action_register_length]
updated_k = kv_cache[0] if ARDiffusionPagedLayerInputs is not None and isinstance(kv_cache, ARDiffusionPagedLayerInputs):
updated_v = kv_cache[1] # Fused write+attend custom op: one opaque node in the compiled
new_k = torch.cat([updated_k, roped_key], dim=1) # graph (slot writes + FlashAttention block-table kernel inside).
new_v = torch.cat([updated_v, v], dim=1) # Metadata tensors were prepared once per forward in _forward_blocks.
new_k = new_k[:, -self.max_attention_size :] if action_register_length is not None:
new_v = new_v[:, -self.max_attention_size :] q_cat = torch.cat([roped_query, roped_action_query], dim=1)
k_act, v_act = roped_action_key[0], action_v[0]
if action_register_length is not None: else:
q_cat = torch.cat([roped_query, roped_action_query], dim=1) q_cat = roped_query
k_cat = torch.cat([new_k, roped_action_key], dim=1) k_act = v_act = None
v_cat = torch.cat([new_v, action_v], dim=1) x = paged_write_attn(
kv_cache,
q_cat[0],
roped_key[0],
v[0],
k_act,
v_act,
self.head_dim**-0.5,
).unsqueeze(0)
else: else:
q_cat = roped_query updated_k = kv_cache[0]
k_cat = new_k updated_v = kv_cache[1]
v_cat = new_v new_k = torch.cat([updated_k, roped_key], dim=1)
new_v = torch.cat([updated_v, v], dim=1)
new_k = new_k[:, -self.max_attention_size :]
new_v = new_v[:, -self.max_attention_size :]
x = self.attn(q_cat, k_cat, v_cat) if action_register_length is not None:
updated_kv_cache = torch.stack([new_k, new_v], dim=0) q_cat = torch.cat([roped_query, roped_action_query], dim=1)
k_cat = torch.cat([new_k, roped_action_key], dim=1)
v_cat = torch.cat([new_v, action_v], dim=1)
else:
q_cat = roped_query
k_cat = new_k
v_cat = new_v
x = self.attn(q_cat, k_cat, v_cat)
updated_kv_cache = torch.stack([new_k, new_v], dim=0)
x = x.flatten(2) x = x.flatten(2)
x = self.o(x) x = self.o(x)
@@ -646,7 +680,7 @@ class CausalWanAttentionBlock(nn.Module):
freqs_state: torch.Tensor, freqs_state: torch.Tensor,
context: torch.Tensor, context: torch.Tensor,
action_register_length: int | None = None, action_register_length: int | None = None,
kv_cache: torch.Tensor | None = None, kv_cache: torch.Tensor | Any | None = None,
crossattn_cache: dict | None = None, crossattn_cache: dict | None = None,
current_start_frame: int = 0, current_start_frame: int = 0,
is_tf: bool = True, is_tf: bool = True,
@@ -925,10 +959,10 @@ class CausalWanModel(nn.Module):
action: torch.Tensor | None, action: torch.Tensor | None,
timestep_action: torch.Tensor | None, timestep_action: torch.Tensor | None,
state: torch.Tensor | None, state: torch.Tensor | None,
kv_cache: list[torch.Tensor], kv_cache: list[Any],
crossattn_cache: list[dict] | None,
current_start_frame: int, current_start_frame: int,
) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor]]: crossattn_cache: list[dict] | None = None,
) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor | None]]:
x = x.flatten(start_dim=2).transpose(1, 2) x = x.flatten(start_dim=2).transpose(1, 2)
B = x.shape[0] B = x.shape[0]
F_t = timestep.shape[1] F_t = timestep.shape[1]
@@ -966,7 +1000,28 @@ class CausalWanModel(nn.Module):
clip_embedding = self.img_emb(clip_feature) clip_embedding = self.img_emb(clip_feature)
context = torch.cat([clip_embedding, context], dim=1) context = torch.cat([clip_embedding, context], dim=1)
updated_kv_caches: list[torch.Tensor] = [] # AR-Diffusion paged path: host-side prep ONCE per branch forward, outside
# the compiled blocks — allocate current video/action slots, build the
# padded block-table metadata all 40 layers share, and hand the compiled
# region plain tensors (ARDiffusionPagedLayerInputs) instead of a Python
# context object. Lazy-allocation contract preserved: only the branch this
# CFG-parallel rank executes reaches its _forward_blocks.
if kv_cache and getattr(kv_cache[0], "is_ar_diffusion_paged_context", False):
if B != 1:
raise RuntimeError("AR-Diffusion paged self-attention currently expects batch_size=1")
fctx = kv_cache[0].forward_ctx
if seq_len != fctx.seq_len:
raise RuntimeError(
f"AR-Diffusion paged context seq_len={fctx.seq_len} but current video KV has {seq_len} tokens"
)
fctx.prepare(
device=x.device,
action_len=int(action_register_length or 0),
query_len=int(x.shape[1]),
)
kv_cache = [c.to_layer_inputs() for c in kv_cache]
updated_kv_caches: list[torch.Tensor | None] = []
for block_index, block in enumerate(self.blocks): for block_index, block in enumerate(self.blocks):
x, updated_kv_cache = block( x, updated_kv_cache = block(
x=x, x=x,
@@ -1000,9 +1055,9 @@ class CausalWanModel(nn.Module):
timestep: torch.Tensor, timestep: torch.Tensor,
context: torch.Tensor, context: torch.Tensor,
seq_len: int, seq_len: int,
kv_cache: list[torch.Tensor], kv_cache: list[Any],
crossattn_cache: list[torch.Tensor],
current_start_frame: int, current_start_frame: int,
crossattn_cache: list[dict] | None = None,
y: torch.Tensor | None = None, y: torch.Tensor | None = None,
clip_feature: torch.Tensor | None = None, clip_feature: torch.Tensor | None = None,
action: torch.Tensor | None = None, action: torch.Tensor | None = None,
@@ -3,7 +3,7 @@
"""DreamZero pipeline for vllm-omni. """DreamZero pipeline for vllm-omni.
Entry point for DiffusionEngine.step() → pipeline.forward(req) Entry point for DiffusionEngine.step() -> pipeline.forward(req)
""" """
from __future__ import annotations from __future__ import annotations
@@ -93,17 +93,102 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
Multi-output: predict_noise() returns (video_pred, action_pred). Multi-output: predict_noise() returns (video_pred, action_pred).
CFG: video gets standard CFG, action takes positive branch only. CFG: video gets standard CFG, action takes positive branch only.
State: DreamZeroState manages KV cache + frame buffer across forward() calls.
KV is managed by the AR-Diffusion engine: ``self._ar_diffusion_kv_state`` is set by the
runner before ``forward()`` and the pipeline routes all KV access (get / update /
commit / reset) through the pool-backed state. Purely duck-typed (no engine import).
""" """
_ar_diffusion_kv_state = None # set by the runner before each forward
def _kv_get(self, state, is_negative, seq_len=None, update_kv_cache=False):
return self._ar_diffusion_kv_state.get_kv_caches(
is_negative,
seq_len=seq_len,
commit_current=update_kv_cache,
)
def _kv_create(self, state, batch_size, dtype, device, num_layers, num_heads, head_dim):
# The engine owns all KV allocation: self-attn is allocated lazily from
# paged contexts, and cross-attn is populated eagerly in _kv_populate_cross.
# Nothing is created model-side.
return
def _kv_commit(self, is_negative: bool):
self._ar_diffusion_kv_state.commit_paged_context(is_negative)
def _kv_get_cross(self, state, is_negative):
"""Cross-attn cache from the engine pool (text k/v + I2V image k_img/v_img)."""
return self._ar_diffusion_kv_state.get_cross_kv_caches(is_negative)
def _kv_populate_cross(self, context: torch.Tensor, clip_feature, is_negative: bool) -> None:
"""Eagerly project cross-attn K/V for all layers into the AR-Diffusion pool.
Caches the session-invariant cross-attn projections once, per half: the text
``k``/``v`` from ``text_embedding(context)`` survive window-boundary resets
(prompt unchanged within a session — only session resets clear them), while
the I2V image-token ``k_img``/``v_img`` from ``img_emb(clip_feature)`` (the
257 image tokens the forward splits off, cached model-side by #4154) are
re-projected on every window restart from the fresh CLIP features. Must run
after the image is encoded so ``clip_feature`` is available.
"""
s = self._ar_diffusion_kv_state
need_text = not s._cross_text_populated.get(is_negative, False)
need_img = (
clip_feature is not None
and getattr(self.transformer, "model_type", "t2v") == "i2v"
and not s._cross_img_populated.get(is_negative, False)
)
if not need_text and not need_img:
return
projected = self.transformer.text_embedding(context) if need_text else None
img_ctx = self.transformer.img_emb(clip_feature) if need_img else None
for i, block in enumerate(self.transformer.blocks):
ca = block.cross_attn
n, d = ca.tp_num_heads, ca.head_dim
k = v = None
if projected is not None:
k = ca.norm_k(ca.k(projected)).unflatten(2, (n, d))
v = ca.v(projected).unflatten(2, (n, d))
k_img = v_img = None
if img_ctx is not None:
k_img = ca.norm_k_img(ca.k_img(img_ctx)).unflatten(2, (n, d))
v_img = ca.v_img(img_ctx).unflatten(2, (n, d))
s.kv_cache.write_cross_kv(i, is_negative, k, v, k_img, v_img)
if need_text:
s._cross_text_populated[is_negative] = True
if need_img:
s._cross_img_populated[is_negative] = True
logger.info(
"AR-Diffusion CROSS POPULATE [%s]: %d layers, text=%s img=%s",
"neg" if is_negative else "pos",
len(self.transformer.blocks),
"kept" if projected is None else tuple(context.shape),
None if img_ctx is None else tuple(img_ctx.shape),
)
def _kv_reset(self, state, *, clear_video_latents: bool = True):
"""Reset the engine's pooled session window plus the model's non-KV state.
DreamZero resets at the attention-window boundary; the engine pool drops the
same window so the next forward starts fresh. ``clear_video_latents=False``
keeps the accumulated video latents for export.
``clear_video_latents=False`` also marks a window ("inference") reset: the
prompt is unchanged, so the pool keeps the text cross-attn K/V and only the
image half repopulates on the restart forward.
"""
state.reset(clear_video_latents=clear_video_latents)
self._ar_diffusion_kv_state.reset(keep_cross_text=not clear_video_latents)
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = "") -> None: def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = "") -> None:
"""Initialize pipeline components. """Initialize pipeline components.
DreamZero root checkpoint layout (GEAR-Dreams/DreamZero-DROID): DreamZero root checkpoint layout (GEAR-Dreams/DreamZero-DROID):
config.json — root config (action_head_cfg, architectures, etc.) config.json -- root config (action_head_cfg, architectures, etc.)
model-*.safetensors — all learned weights (action_head.{model,text_encoder,image_encoder,vae}.*) model-*.safetensors -- all learned weights (action_head.{model,text_encoder,image_encoder,vae}.*)
experiment_cfg/metadata.json — per-embodiment action normalization stats experiment_cfg/metadata.json -- per-embodiment action normalization stats
vae/ — symlink to Wan2.1 VAE (diffusers-compatible) vae/ -- symlink to Wan2.1 VAE (diffusers-compatible)
Components are instantiated from config (not from_pretrained), then filled Components are instantiated from config (not from_pretrained), then filled
by load_weights() which reads root safetensors and remaps key prefixes. by load_weights() which reads root safetensors and remaps key prefixes.
@@ -118,6 +203,18 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
""" """
super().__init__() super().__init__()
# DreamZero is engine-only: every KV access in forward() routes through
# the AR-Diffusion engine's pool-backed state. Fail fast here — a stale
# or programmatic config that leaves engine_backend="default" would
# otherwise only crash mid-forward on the first KV access.
engine_backend = str(getattr(od_config, "engine_backend", "") or "")
if "ar_diffusion" not in engine_backend.lower().replace("-", "_"):
raise ValueError(
"DreamZeroPipeline requires the AR-Diffusion engine; set "
"engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine "
f"in the deploy config (got engine_backend={engine_backend!r})."
)
model_path = od_config.model model_path = od_config.model
model_config = od_config.model_config model_config = od_config.model_config
local_files_only = os.path.exists(model_path) local_files_only = os.path.exists(model_path)
@@ -228,8 +325,11 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
self.max_action_dim: int = ah_config["max_action_dim"] self.max_action_dim: int = ah_config["max_action_dim"]
self.negative_prompt: str = model_config.get("negative_prompt", DEFAULT_NEGATIVE_PROMPT) self.negative_prompt: str = model_config.get("negative_prompt", DEFAULT_NEGATIVE_PROMPT)
# The negative prompt is a model constant: encode it once, lazily, and
# reuse across every forward/session (UMT5 encode is deterministic).
self._negative_prompt_embeds_cache: torch.Tensor | None = None
# Embodiment name → numeric ID mapping (model knowledge) # Embodiment name -> numeric ID mapping (model knowledge)
self.embodiment_name_to_id: dict[str, int] = model_config.get( self.embodiment_name_to_id: dict[str, int] = model_config.get(
"embodiment_name_to_id", "embodiment_name_to_id",
DEFAULT_EMBODIMENT_NAME_TO_ID, DEFAULT_EMBODIMENT_NAME_TO_ID,
@@ -344,11 +444,17 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
state=kwargs.get("state_features"), state=kwargs.get("state_features"),
embodiment_id=kwargs.get("embodiment_id"), embodiment_id=kwargs.get("embodiment_id"),
) )
if kwargs.get("update_kv_cache", False) and updated_kv_caches: if kwargs.get("update_kv_cache", False):
state = kwargs.get("dreamzero_state", self.state)
is_neg = kwargs.get("is_negative", False) is_neg = kwargs.get("is_negative", False)
for i, kv in enumerate(updated_kv_caches): logger.debug(
state.update_kv_cache(i, kv, is_negative=is_neg) "AR-Diffusion pipeline predict_noise -> commit paged context: "
"is_neg=%s seq_len=%s current_start_frame=%s layers=%d",
is_neg,
kwargs.get("seq_len"),
kwargs.get("current_start_frame"),
len(updated_kv_caches),
)
self._kv_commit(is_neg)
return video_pred, action_pred return video_pred, action_pred
@@ -362,6 +468,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
Paper D.2 uses ``mode=reduce-overhead``, ``fullgraph=True``, ``dynamic=False`` Paper D.2 uses ``mode=reduce-overhead``, ``fullgraph=True``, ``dynamic=False``
on text/image/VAE and DiT. VAE decode uses a tensor feat_cache patch and on text/image/VAE and DiT. VAE decode uses a tensor feat_cache patch and
compiles ``decoder.forward`` (not ``_decode``, which has a Python frame loop). compiles ``decoder.forward`` (not ``_decode``, which has a Python frame loop).
Incremental VAE encode (``_vae_encode_encoder_chunk``) stays eager because
Wan ``feat_cache`` mutation is incompatible with CUDAGraph capture.
DiT blocks use per-block ``fullgraph=True``. DiT blocks use per-block ``fullgraph=True``.
""" """
if not torch.cuda.is_available(): if not torch.cuda.is_available():
@@ -376,6 +484,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
compile_ro = {"mode": "reduce-overhead", "fullgraph": True, "dynamic": False} compile_ro = {"mode": "reduce-overhead", "fullgraph": True, "dynamic": False}
# DiT blocks: default avoids CUDAGraph overwrite on modulation tensors; encoders use reduce-overhead. # DiT blocks: default avoids CUDAGraph overwrite on modulation tensors; encoders use reduce-overhead.
# The AR-Diffusion paged self-attention is a registered custom op, so the
# block stays fullgraph even on that path.
dit_compile = {"mode": "default", "fullgraph": True, "dynamic": False} dit_compile = {"mode": "default", "fullgraph": True, "dynamic": False}
logger.info( logger.info(
@@ -396,9 +506,9 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
logger.warning("DreamZero: image_encoder compile failed (%s); skipping.", exc) logger.warning("DreamZero: image_encoder compile failed (%s); skipping.", exc)
try: try:
self.vae.encode = torch.compile(self.vae.encode, **compile_ro) self.vae._encode = torch.compile(self.vae._encode, **compile_ro)
except Exception as exc: except Exception as exc:
logger.warning("DreamZero: vae.encode compile failed (%s); skipping.", exc) logger.warning("DreamZero: vae._encode compile failed (%s); skipping.", exc)
compiled_blocks = 0 compiled_blocks = 0
for block in self.transformer.blocks: for block in self.transformer.blocks:
@@ -437,10 +547,24 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
try: try:
image = torch.zeros(1, 1, 3, 180, 320, dtype=torch.bfloat16, device=device) image = torch.zeros(1, 1, 3, 180, 320, dtype=torch.bfloat16, device=device)
self._encode_image(image, self.num_frames, 180, 320) self._encode_image(image, self.num_frames, 180, 320, state=self.state)
except Exception as exc: except Exception as exc:
logger.warning("DreamZero compile warmup (image_encoder) skipped: %s", exc) logger.warning("DreamZero compile warmup (image_encoder) skipped: %s", exc)
try:
self.state.reset_vae_encoder_stream()
dummy_video = torch.zeros(1, 3, 1, 180, 320, dtype=torch.bfloat16, device=device)
self._vae_stream_seed(self.state, dummy_video[:, :, :1])
for _ in range(4):
self._vae_stream_append_frame(self.state, dummy_video[:, :, :1])
self._vae_stream_get_observation_latents(
self.state,
self.num_frame_per_block,
dtype=torch.bfloat16,
)
except Exception as exc:
logger.warning("DreamZero compile warmup (vae encode stream) skipped: %s", exc)
try: try:
latent_h, latent_w = 180 // 8, 320 // 8 latent_h, latent_w = 180 // 8, 320 // 8
dummy_latent = torch.zeros( dummy_latent = torch.zeros(
@@ -499,7 +623,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
def _preprocess_video(self, videos: torch.Tensor) -> torch.Tensor: def _preprocess_video(self, videos: torch.Tensor) -> torch.Tensor:
"""uint8 [B,T,H,W,C] → bfloat16 [B,C,T,H,W] normalized to [-1,1].""" """uint8 [B,T,H,W,C] -> bfloat16 [B,C,T,H,W] normalized to [-1,1]."""
videos = videos.permute(0, 4, 1, 2, 3) videos = videos.permute(0, 4, 1, 2, 3)
if videos.dtype == torch.uint8: if videos.dtype == torch.uint8:
videos = videos.float() / 255.0 videos = videos.float() / 255.0
@@ -546,6 +670,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
num_frames: int, num_frames: int,
height: int, height: int,
width: int, width: int,
*,
state: DreamZeroState | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Encode first frame via CLIP + VAE. """Encode first frame via CLIP + VAE.
Returns: (clip_feas, ys, image_latent) Returns: (clip_feas, ys, image_latent)
@@ -581,11 +707,147 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
new_image = y[:, :, 0:1] new_image = y[:, :, 0:1]
y = torch.concat([msk, y], dim=1) y = torch.concat([msk, y], dim=1)
if state is not None:
if not state.vae_stream_initialized:
# Seed the AR streaming encoder after the compiled full encode above;
# ``cudagraph_mark_step_begin`` isolates eager feat_cache work from CUDAGraph.
self._vae_stream_seed(state, image_input[:, :, :1])
else:
# Window ("inference") restart with a live stream: keep the real
# frame history in the Wan feat_cache and append the restart
# observation instead of reseeding from scratch.
self._cudagraph_mark_step_begin()
self._vae_stream_append_frame(state, image_input[:, :, :1])
return clip_context, y, new_image return clip_context, y, new_image
def _vae_patchify(self, videos: torch.Tensor) -> torch.Tensor:
if self.vae.config.patch_size is not None:
from diffusers.models.autoencoders.autoencoder_kl_wan import patchify
return patchify(videos, patch_size=self.vae.config.patch_size)
return videos
@staticmethod
def _vae_clone_feat_map(feat_map: list[torch.Tensor | None]) -> list[torch.Tensor | None]:
return [entry.clone() if isinstance(entry, torch.Tensor) else entry for entry in feat_map]
def _vae_init_enc_feat_map(self) -> list[torch.Tensor | None]:
self.vae.clear_cache()
return self._vae_clone_feat_map(self.vae._enc_feat_map)
def _vae_encode_encoder_chunk(
self,
chunk: torch.Tensor,
feat_map: list[torch.Tensor | None],
) -> tuple[torch.Tensor, list[torch.Tensor | None]]:
"""Run one Wan encoder chunk while mutating ``feat_map`` in place.
Must stay eager (not ``torch.compile``): Wan causal ``feat_cache`` updates
conflict with ``reduce-overhead`` CUDAGraph capture.
"""
self.vae._enc_feat_map = feat_map
self.vae._enc_conv_idx = [0]
out = self.vae.encoder(
chunk,
feat_cache=self.vae._enc_feat_map,
feat_idx=self.vae._enc_conv_idx,
)
return out, self._vae_clone_feat_map(self.vae._enc_feat_map)
def _vae_quantize_encoder_out(self, encoder_out: torch.Tensor) -> torch.Tensor:
enc = self.vae.quant_conv(encoder_out)
mu, _ = enc.chunk(2, dim=1)
mean = self.vae_latents_mean.to(device=mu.device, dtype=mu.dtype)
inv_std = self.vae_latents_inv_std.to(device=mu.device, dtype=mu.dtype)
return (mu - mean) * inv_std
def _vae_stream_seed(self, state: DreamZeroState, first_frame: torch.Tensor) -> None:
"""Seed incremental VAE encode with the first observation frame."""
state.reset_vae_encoder_stream()
feat_map = self._vae_init_enc_feat_map()
chunk = self._vae_patchify(first_frame.to(dtype=self.vae.dtype))
self._cudagraph_mark_step_begin()
encoder_out, feat_map = self._vae_encode_encoder_chunk(chunk[:, :, :1], feat_map)
state.vae_enc_feat_map = feat_map
state.vae_encoder_out = encoder_out
state.vae_pending_body_frames = None
state.vae_stream_initialized = True
def _vae_stream_append_frame(self, state: DreamZeroState, new_frame: torch.Tensor) -> None:
"""Append one pixel frame and encode a 4-frame body chunk when ready."""
if not state.vae_stream_initialized or state.vae_enc_feat_map is None or state.vae_encoder_out is None:
raise RuntimeError("VAE encoder stream is not initialized.")
frame = new_frame.to(dtype=self.vae.dtype)
if state.vae_pending_body_frames is None:
state.vae_pending_body_frames = frame
else:
state.vae_pending_body_frames = torch.cat([state.vae_pending_body_frames, frame], dim=2)
while state.vae_pending_body_frames is not None and state.vae_pending_body_frames.shape[2] >= 4:
body = state.vae_pending_body_frames[:, :, :4]
if state.vae_pending_body_frames.shape[2] > 4:
state.vae_pending_body_frames = state.vae_pending_body_frames[:, :, 4:]
else:
state.vae_pending_body_frames = None
chunk = self._vae_patchify(body)
encoder_chunk, feat_map = self._vae_encode_encoder_chunk(chunk, state.vae_enc_feat_map)
state.vae_encoder_out = torch.cat([state.vae_encoder_out, encoder_chunk], dim=2)
state.vae_enc_feat_map = feat_map
def _vae_stream_get_observation_latents(
self,
state: DreamZeroState,
num_latent_frames: int,
*,
dtype: torch.dtype,
) -> torch.Tensor:
if state.vae_encoder_out is None:
raise RuntimeError("VAE encoder stream has no accumulated encoder output.")
latents = self._vae_quantize_encoder_out(state.vae_encoder_out).to(dtype=dtype)
if latents.shape[2] >= num_latent_frames:
return latents[:, :, -num_latent_frames:]
pad_count = num_latent_frames - latents.shape[2]
pad = latents[:, :, -1:].expand(-1, -1, pad_count, -1, -1)
return torch.cat([pad, latents], dim=2)
def _preprocess_vae_observation_window(self, videos: torch.Tensor) -> torch.Tensor:
_, _, num_frames_raw, _, _ = videos.shape
if (num_frames_raw - 1) // 4 == self.num_frame_per_block:
return videos
if num_frames_raw // 4 != self.num_frame_per_block:
repeat_factor = self.num_frame_per_block // (num_frames_raw // 4)
videos = torch.repeat_interleave(videos, repeat_factor, dim=2)
first_frame = videos[:, :, 0:1]
return torch.cat([first_frame, videos], dim=2)
first_frame = videos[:, :, 0:1]
return torch.cat([first_frame, videos], dim=2)
def _encode_observation_latents(
self,
state: DreamZeroState,
videos: torch.Tensor,
*,
latent_dtype: torch.dtype,
) -> torch.Tensor:
"""Encode current robot observation into normalized VAE latents."""
if state.vae_stream_initialized:
self._cudagraph_mark_step_begin()
self._vae_stream_append_frame(state, videos[:, :, -1:])
return self._vae_stream_get_observation_latents(
state,
self.num_frame_per_block,
dtype=latent_dtype,
)
videos = self._preprocess_vae_observation_window(videos)
return self._encode_vae_latents(videos).to(dtype=latent_dtype)
def _encode_vae_latents(self, videos: torch.Tensor) -> torch.Tensor: def _encode_vae_latents(self, videos: torch.Tensor) -> torch.Tensor:
"""Encode videos into normalized VAE latents.""" """Encode videos into normalized VAE latents."""
input_dtype = videos.dtype input_dtype = videos.dtype
self._cudagraph_mark_step_begin()
hidden = self.vae._encode(videos.to(dtype=self.vae.dtype)) hidden = self.vae._encode(videos.to(dtype=self.vae.dtype))
mu, _ = hidden.chunk(2, dim=1) mu, _ = hidden.chunk(2, dim=1)
mean = self.vae_latents_mean.to(device=mu.device, dtype=mu.dtype) mean = self.vae_latents_mean.to(device=mu.device, dtype=mu.dtype)
@@ -635,7 +897,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
) -> None: ) -> None:
"""Prefill KV cache with first frame and/or current observation. """Prefill KV cache with first frame and/or current observation.
Uses predict_noise_maybe_with_cfg() for CFG parallel — same path as Uses predict_noise_maybe_with_cfg() for CFG parallel -- same path as
the denoise loop. The mixin handles rank dispatch automatically. the denoise loop. The mixin handles rank dispatch automatically.
KV cache update happens as a side effect inside predict_noise(). KV cache update happens as a side effect inside predict_noise().
""" """
@@ -646,7 +908,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
head_dim = self.transformer.dim // self.transformer.num_heads head_dim = self.transformer.dim // self.transformer.num_heads
if state.current_start_frame == 0: if state.current_start_frame == 0:
state.create_kv_caches( self._kv_create(
state,
batch_size, batch_size,
dtype, dtype,
device, device,
@@ -671,16 +934,16 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
) )
positive_kwargs = dict( positive_kwargs = dict(
encoder_hidden_states=prompt_embeds, encoder_hidden_states=prompt_embeds,
kv_cache=state.get_kv_caches(False), kv_cache=self._kv_get(state, False, seq_len=frame_seqlen, update_kv_cache=True),
crossattn_cache=state.get_crossattn_caches(False), crossattn_cache=self._kv_get_cross(state, False),
is_negative=False, is_negative=False,
**common, **common,
) )
negative_kwargs = ( negative_kwargs = (
dict( dict(
encoder_hidden_states=negative_prompt_embeds, encoder_hidden_states=negative_prompt_embeds,
kv_cache=state.get_kv_caches(True), kv_cache=self._kv_get(state, True, seq_len=frame_seqlen, update_kv_cache=True),
crossattn_cache=state.get_crossattn_caches(True), crossattn_cache=self._kv_get_cross(state, True),
is_negative=True, is_negative=True,
**common, **common,
) )
@@ -721,16 +984,16 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
) )
positive_kwargs = dict( positive_kwargs = dict(
encoder_hidden_states=prompt_embeds, encoder_hidden_states=prompt_embeds,
kv_cache=state.get_kv_caches(False), kv_cache=self._kv_get(state, False, seq_len=seq_len, update_kv_cache=True),
crossattn_cache=state.get_crossattn_caches(False), crossattn_cache=self._kv_get_cross(state, False),
is_negative=False, is_negative=False,
**common, **common,
) )
negative_kwargs = ( negative_kwargs = (
dict( dict(
encoder_hidden_states=negative_prompt_embeds, encoder_hidden_states=negative_prompt_embeds,
kv_cache=state.get_kv_caches(True), kv_cache=self._kv_get(state, True, seq_len=seq_len, update_kv_cache=True),
crossattn_cache=state.get_crossattn_caches(True), crossattn_cache=self._kv_get_cross(state, True),
is_negative=True, is_negative=True,
**common, **common,
) )
@@ -763,8 +1026,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
For each timestep: For each timestep:
1. Build positive_kwargs / negative_kwargs 1. Build positive_kwargs / negative_kwargs
2. predict_noise_maybe_with_cfg() → (video_pred, action_pred) 2. predict_noise_maybe_with_cfg() -> (video_pred, action_pred)
3. scheduler_step_maybe_with_cfg() → VideoActionScheduler 3. scheduler_step_maybe_with_cfg() -> VideoActionScheduler
4. _synchronize_cfg_parallel_step_output() 4. _synchronize_cfg_parallel_step_output()
""" """
seq_len = kwargs["seq_len"] seq_len = kwargs["seq_len"]
@@ -823,8 +1086,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
hidden_states=noisy_input.transpose(1, 2), hidden_states=noisy_input.transpose(1, 2),
timestep_video=timestep, timestep_video=timestep,
encoder_hidden_states=prompt_embeds, encoder_hidden_states=prompt_embeds,
kv_cache=state.get_kv_caches(False), kv_cache=self._kv_get(state, False, seq_len=seq_len, update_kv_cache=False),
crossattn_cache=state.get_crossattn_caches(False), crossattn_cache=self._kv_get_cross(state, False),
y=y, y=y,
clip_feature=state.clip_feas, clip_feature=state.clip_feas,
action=noisy_input_action, action=noisy_input_action,
@@ -838,8 +1101,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
hidden_states=noisy_input.transpose(1, 2), hidden_states=noisy_input.transpose(1, 2),
timestep_video=timestep, timestep_video=timestep,
encoder_hidden_states=negative_prompt_embeds, encoder_hidden_states=negative_prompt_embeds,
kv_cache=state.get_kv_caches(True), kv_cache=self._kv_get(state, True, seq_len=seq_len, update_kv_cache=False),
crossattn_cache=state.get_crossattn_caches(True), crossattn_cache=self._kv_get_cross(state, True),
y=y, y=y,
clip_feature=state.clip_feas, clip_feature=state.clip_feas,
action=noisy_input_action, action=noisy_input_action,
@@ -936,7 +1199,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
device=device, device=device,
) )
# State: raw from transform → pad to (B, state_horizon=1, max_state_dim) # State: raw from transform -> pad to (B, state_horizon=1, max_state_dim)
raw_state = unified_obs["state"] raw_state = unified_obs["state"]
state_for_postprocess = None state_for_postprocess = None
if raw_state is not None: if raw_state is not None:
@@ -976,38 +1239,66 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
# Explicit reset from OpenPI serving is carried by `extra_args["reset"]` # Explicit reset from OpenPI serving is carried by `extra_args["reset"]`
# on the next inference request after websocket reset/session switch. # on the next inference request after websocket reset/session switch.
if extra_args.get("reset", False): if extra_args.get("reset", False):
state.reset() self._kv_reset(state)
else: else:
# Auto-reset based on model state (before accumulation). Both a "session"
# reset and an "inference" (window-boundary) reset clear the model-local
# KV window, so route both through _kv_reset to drop the AR-Diffusion pool window
# too and stay parity-exact; "inference" keeps the accumulated video
# latents (reset_inference_state == reset(clear_video_latents=False)).
reset_reason = state.reset_reason(text_tokens, 0, self.transformer.local_attn_size) reset_reason = state.reset_reason(text_tokens, 0, self.transformer.local_attn_size)
if reset_reason == "session": if reset_reason == "session":
state.reset() self._kv_reset(state)
elif reset_reason == "inference": elif reset_reason == "inference":
state.reset_inference_state() self._kv_reset(state, clear_video_latents=False)
state.language = text_tokens state.language = text_tokens
# Frame accumulation: stitched single frame → multi-frame video # Frame accumulation: stitched single frame -> multi-frame video
video_frames = state.accumulate_frames(stitched) # (T, H, W, C) video_frames = state.accumulate_frames(stitched) # (T, H, W, C)
videos = torch.from_numpy(video_frames).unsqueeze(0).to(device) # (B=1, T, H, W, C) videos = torch.from_numpy(video_frames).unsqueeze(0).to(device) # (B=1, T, H, W, C)
videos = self._preprocess_video(videos) # → [B,C,T,H,W] bf16 videos = self._preprocess_video(videos) # -> [B,C,T,H,W] bf16
_, _, num_frames_raw, height, width = videos.shape _, _, num_frames_raw, height, width = videos.shape
prompt_embeds = self._encode_text(text_tokens, attention_mask) # Optional phase timing (DZ_PHASE_TIMING=1): logs per-forward stage costs
# (text encode / obs VAE encode / KV prefill / denoise) at INFO. Each mark
# synchronizes CUDA, so leave it off for timed benchmark runs.
_pt = None
if os.environ.get("DZ_PHASE_TIMING"):
import time as _time
torch.accelerator.synchronize()
_pt = {"time": _time, "t0": _time.perf_counter(), "marks": []}
def _pt_mark(name: str) -> None:
if _pt is not None:
torch.accelerator.synchronize()
_pt["marks"].append((name, _pt["time"].perf_counter()))
# Prompt embeds are constant within a session (a prompt change triggers a
# "session" reset above, which clears this cache alongside state.language).
if state.prompt_embeds is None:
state.prompt_embeds = self._encode_text(text_tokens, attention_mask)
prompt_embeds = state.prompt_embeds
# Negative prompt for CFG uncond branch (model constant) # Negative prompt for CFG uncond branch (model constant)
negative_prompt_embeds = None negative_prompt_embeds = None
if self.cfg_scale > 1.0: if self.cfg_scale > 1.0:
neg_inputs = self.tokenizer( if self._negative_prompt_embeds_cache is None:
self.negative_prompt, neg_inputs = self.tokenizer(
max_length=512, self.negative_prompt,
padding="max_length", max_length=512,
truncation=True, padding="max_length",
return_tensors="pt", truncation=True,
add_special_tokens=True, return_tensors="pt",
) add_special_tokens=True,
negative_prompt_embeds = self._encode_text( )
neg_inputs["input_ids"].to(device), self._negative_prompt_embeds_cache = self._encode_text(
neg_inputs["attention_mask"].to(device), neg_inputs["input_ids"].to(device),
) neg_inputs["attention_mask"].to(device),
)
negative_prompt_embeds = self._negative_prompt_embeds_cache
_pt_mark("text_encode")
# Extract first/last frame for CLIP + VAE encoding # Extract first/last frame for CLIP + VAE encoding
if num_frames_raw == 4 or num_frames_raw == 9: if num_frames_raw == 4 or num_frames_raw == 9:
@@ -1021,27 +1312,24 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
self.num_frames, self.num_frames,
height, height,
width, width,
state=state,
) )
state.clip_feas = clip_feas.to(dtype=image.dtype) state.clip_feas = clip_feas.to(dtype=image.dtype)
state.ys = ys.to(dtype=image.dtype) state.ys = ys.to(dtype=image.dtype)
if state.current_start_frame != 0: # Eager cross-attn population (AR-Diffusion only): cache text + image-token K/V
# Subsequent calls: encode current observation via VAE # into the pool now that the image is encoded (clip_feas available).
if (num_frames_raw - 1) // 4 == self.num_frame_per_block: # Runs on the first forward of a session and after each window-boundary
pass # reset (current_start_frame returns to 0); the populate guards (text/img halves) gate re-entry.
elif num_frames_raw // 4 != self.num_frame_per_block: self._kv_populate_cross(prompt_embeds, state.clip_feas, is_negative=False)
repeat_factor = self.num_frame_per_block // (num_frames_raw // 4) if negative_prompt_embeds is not None:
videos = torch.repeat_interleave(videos, repeat_factor, dim=2) self._kv_populate_cross(negative_prompt_embeds, state.clip_feas, is_negative=True)
first_frame = videos[:, :, 0:1]
videos = torch.cat([first_frame, videos], dim=2)
else:
first_frame = videos[:, :, 0:1]
videos = torch.cat([first_frame, videos], dim=2)
if state.current_start_frame != 0:
latent_dtype = videos.dtype latent_dtype = videos.dtype
with torch.no_grad(): with torch.no_grad():
image = self._encode_vae_latents(videos) image = self._encode_observation_latents(state, videos, latent_dtype=latent_dtype)
image = image.to(dtype=latent_dtype) _pt_mark("obs_vae_encode")
batch_size = image.shape[0] batch_size = image.shape[0]
generator = torch.Generator(device=device).manual_seed(self.seed) generator = torch.Generator(device=device).manual_seed(self.seed)
@@ -1082,6 +1370,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
do_true_cfg, do_true_cfg,
state, state,
) )
_pt_mark("prefill_kv")
sample_scheduler = copy.deepcopy(self.scheduler) sample_scheduler = copy.deepcopy(self.scheduler)
sample_scheduler_action = copy.deepcopy(self.scheduler) sample_scheduler_action = copy.deepcopy(self.scheduler)
@@ -1123,6 +1412,19 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
state_features=state_features, state_features=state_features,
embodiment_id=embodiment_id, embodiment_id=embodiment_id,
) )
if _pt is not None:
_pt_mark("diffuse")
prev_t = _pt["t0"]
parts = []
for name, t in _pt["marks"]:
parts.append(f"{name}={1000 * (t - prev_t):.1f}ms")
prev_t = t
logger.info(
"DZ_PHASE_TIMING csf=%s total=%.1fms %s",
state.current_start_frame,
1000 * (prev_t - _pt["t0"]),
" ".join(parts),
)
if state.current_start_frame == 1: if state.current_start_frame == 1:
video_out = torch.cat([image, video_out], dim=1) video_out = torch.cat([image, video_out], dim=1)
@@ -1133,7 +1435,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
# q99 denorm: [-1,1] → real values # q99 denorm: [-1,1] → real values
action_out = self._denormalize_action(action_out.float(), embodiment_name) action_out = self._denormalize_action(action_out.float(), embodiment_name)
# Relative → absolute: only for relative_action_keys (joint_position only) # Relative -> absolute: only for relative_action_keys (joint_position only)
# gripper_position is NOT relative, so don't add state back to it # gripper_position is NOT relative, so don't add state back to it
if self.relative_action and state_for_postprocess is not None: if self.relative_action and state_for_postprocess is not None:
n_relative = self.relative_action_dim # 7 for DROID (joint only) n_relative = self.relative_action_dim # 7 for DROID (joint only)
@@ -1145,7 +1447,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
action_out[..., :n_relative] + last_state.unsqueeze(1) # broadcast over horizon action_out[..., :n_relative] + last_state.unsqueeze(1) # broadcast over horizon
) )
# Squeeze batch dim for output: (B, horizon, dim) → (horizon, dim) # Squeeze batch dim for output: (B, horizon, dim) -> (horizon, dim)
actions_np = action_out.squeeze(0).float().cpu().numpy() # (horizon, max_action_dim) actions_np = action_out.squeeze(0).float().cpu().numpy() # (horizon, max_action_dim)
actions_np = transform.transform_action_output(actions_np) actions_np = transform.transform_action_output(actions_np)
@@ -1245,7 +1547,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin):
q01 = stats["q01"].to(device=action.device, dtype=action.dtype) q01 = stats["q01"].to(device=action.device, dtype=action.dtype)
q99 = stats["q99"].to(device=action.device, dtype=action.dtype) q99 = stats["q99"].to(device=action.device, dtype=action.dtype)
# action shape: (B, horizon, action_dim) or (B, horizon, max_action_dim) # action shape: (B, horizon, action_dim) or (B, horizon, max_action_dim)
# q01/q99 shape: (actual_action_dim,) — only denorm actual dims # q01/q99 shape: (actual_action_dim,) -- only denorm actual dims
actual_dim = q01.shape[0] actual_dim = q01.shape[0]
action_real = action.clone() action_real = action.clone()
action_real[..., :actual_dim] = (action[..., :actual_dim] + 1) / 2 * (q99 - q01) + q01 action_real[..., :actual_dim] = (action[..., :actual_dim] + 1) / 2 * (q99 - q01) + q01
@@ -115,17 +115,24 @@ class DreamZeroState:
# Concatenated along T before decode (matches upstream video_across_time). # Concatenated along T before decode (matches upstream video_across_time).
self.video_latents_across_time = saved_video_latents self.video_latents_across_time = saved_video_latents
# KV cache once robot-policy diffusion supports that integration.
self.kv_cache = None
self.kv_cache_neg = None
self.crossattn_cache = None
self.crossattn_cache_neg = None
self.current_start_frame = 0 self.current_start_frame = 0
self.clip_feas = None self.clip_feas = None
self.ys = None self.ys = None
if clear_video_latents: if clear_video_latents:
# Session reset: drop the prompt-embed cache and VAE encoder stream.
self.language = None self.language = None
self.prompt_embeds = None
self.reset_vae_encoder_stream()
# Window ("inference") resets keep both: the prompt is unchanged, and the
# Wan feat_cache history is independent of the DiT attention window.
def reset_vae_encoder_stream(self) -> None:
"""Clear incremental Wan VAE encoder state used across AR steps."""
self.vae_stream_initialized = False
self.vae_enc_feat_map: list[torch.Tensor | None] | None = None
self.vae_encoder_out: torch.Tensor | None = None
self.vae_pending_body_frames: torch.Tensor | None = None
def reset_inference_state(self) -> None: def reset_inference_state(self) -> None:
"""Reset KV/frame state after local attention rolls without dropping video latents.""" """Reset KV/frame state after local attention rolls without dropping video latents."""
@@ -166,57 +173,3 @@ class DreamZeroState:
def should_reset(self, text_tokens: torch.Tensor | None, num_video_frames: int, local_attn_size: int) -> bool: def should_reset(self, text_tokens: torch.Tensor | None, num_video_frames: int, local_attn_size: int) -> bool:
"""Determine if state should be reset before this forward().""" """Determine if state should be reset before this forward()."""
return self.reset_reason(text_tokens, num_video_frames, local_attn_size) is not None return self.reset_reason(text_tokens, num_video_frames, local_attn_size) is not None
# ------------------------------------------------------------------
# KV cache management
# ------------------------------------------------------------------
def create_kv_caches(
self,
batch_size: int,
dtype: torch.dtype,
device: torch.device,
num_layers: int,
num_heads: int,
head_dim: int,
) -> None:
"""Initialize empty KV caches and cross-attention caches."""
self.kv_cache = [
torch.zeros(2, batch_size, 0, num_heads, head_dim, dtype=dtype, device=device) for _ in range(num_layers)
]
self.kv_cache_neg = [
torch.zeros(2, batch_size, 0, num_heads, head_dim, dtype=dtype, device=device) for _ in range(num_layers)
]
self.crossattn_cache = [
{"is_init": False, "k": None, "v": None, "k_img": None, "v_img": None} for _ in range(num_layers)
]
self.crossattn_cache_neg = [
{"is_init": False, "k": None, "v": None, "k_img": None, "v_img": None} for _ in range(num_layers)
]
def update_kv_cache(
self,
layer_index: int,
updated_kv: torch.Tensor,
is_negative: bool = False,
) -> None:
"""Update a single layer's KV cache after prefill."""
cache = self.kv_cache_neg if is_negative else self.kv_cache
if cache is None:
raise RuntimeError("KV caches not initialized, call create_kv_caches first.")
cache[layer_index] = updated_kv.clone()
def get_kv_caches(self, is_negative: bool = False) -> list[torch.Tensor]:
"""Get KV caches for the specified branch."""
cache = self.kv_cache_neg if is_negative else self.kv_cache
if cache is None:
raise RuntimeError("KV caches not initialized.")
return cache
def get_crossattn_caches(self, is_negative: bool = False) -> list[dict[str, bool | torch.Tensor | None]]:
"""Get cross-attention caches for the specified branch."""
cache = self.crossattn_cache_neg if is_negative else self.crossattn_cache
if cache is None:
raise RuntimeError("Cross-attn caches not initialized.")
return cache
@@ -9,6 +9,35 @@ import torch
class DreamZeroVideoExportWorkerExtension: class DreamZeroVideoExportWorkerExtension:
"""DreamZero worker RPCs used by offline example video export.""" """DreamZero worker RPCs used by offline example video export."""
def gpu_mem_stats(self) -> dict:
"""Peak GPU memory (GiB) for this worker's device, for profiling.
``max_memory_reserved`` is the caching-allocator high-water mark (the best
in-process proxy for "peak VRAM"); ``max_memory_allocated`` is the live
tensor high-water mark. Both are monotonic from process start, so the
end-of-run values capture the whole rollout's peak.
"""
dev = torch.accelerator.current_device_index()
return {
"device": int(dev),
"peak_reserved_gib": torch.accelerator.max_memory_reserved(dev) / (1024**3),
"peak_allocated_gib": torch.accelerator.max_memory_allocated(dev) / (1024**3),
}
def ar_diffusion_perf_stats(self, reset: bool = True) -> dict:
"""Per-request server-side forward E2E times (s) recorded by the AR-Diffusion runner.
The worker-side compute time per ``execute_model`` — the analog of the
upstream server's per-request E2E — which excludes the engine<->worker IPC
that the client's ``omni.generate`` wall time includes. Returns ``[]`` when
the runner is the base (KV-disabled) runner that does not record timings.
"""
runner = self.model_runner
times = list(getattr(runner, "_perf_e2e_times", []) or [])
if reset and hasattr(runner, "_perf_e2e_times"):
runner._perf_e2e_times = []
return {"server_e2e_s": times}
@staticmethod @staticmethod
def _latents_to_uint8_frames(decoded: torch.Tensor) -> torch.Tensor: def _latents_to_uint8_frames(decoded: torch.Tensor) -> torch.Tensor:
decoded = decoded.squeeze(0).permute(1, 2, 3, 0).contiguous() decoded = decoded.squeeze(0).permute(1, 2, 3, 0).contiguous()
@@ -3,6 +3,8 @@
from typing import Any from typing import Any
import torch
import vllm.distributed.parallel_state as _vllm_ps
import vllm.forward_context as _vllm_fc import vllm.forward_context as _vllm_fc
from vllm.utils.import_utils import resolve_obj_by_qualname from vllm.utils.import_utils import resolve_obj_by_qualname
@@ -24,6 +26,30 @@ def _set_forward_context_num_tokens(num_tokens: int) -> None:
forward_context.in_profile_run = False forward_context.in_profile_run = False
def _set_forward_context_dp_metadata(num_tokens: int) -> None:
"""Populate vLLM MoE DP token metadata for diffusion-only DP mappings."""
if not _vllm_fc.is_forward_context_available():
return
forward_context = _vllm_fc.get_forward_context()
if getattr(forward_context, "dp_metadata", None) is not None:
return
dp_group = getattr(_vllm_ps, "_DP", None)
if dp_group is None or getattr(dp_group, "world_size", 1) <= 1:
return
gathered_num_tokens: list[int | None] = [None] * dp_group.world_size
torch.distributed.all_gather_object(
gathered_num_tokens,
int(num_tokens),
group=dp_group.cpu_group,
)
if any(count is None for count in gathered_num_tokens):
raise RuntimeError(f"Failed to gather MoE DP token counts: {gathered_num_tokens}")
forward_context.dp_metadata = _vllm_fc.DPMetadata(torch.tensor(gathered_num_tokens, dtype=torch.int64))
class HunyuanFusedMoEDefault: class HunyuanFusedMoEDefault:
"""Adapter that configures the upstream FusedMoE ``MoERunner`` for HunyuanImage3. """Adapter that configures the upstream FusedMoE ``MoERunner`` for HunyuanImage3.
@@ -62,6 +88,7 @@ class HunyuanFusedMoEDefault:
hidden_states = args[0] hidden_states = args[0]
if hidden_states is not None: if hidden_states is not None:
_set_forward_context_num_tokens(hidden_states.shape[0]) _set_forward_context_num_tokens(hidden_states.shape[0])
_set_forward_context_dp_metadata(hidden_states.shape[0])
moe_runner.register_forward_pre_hook(_num_tokens_pre_hook, with_kwargs=True) moe_runner.register_forward_pre_hook(_num_tokens_pre_hook, with_kwargs=True)
@@ -2235,13 +2235,8 @@ class HunyuanImage3Model(nn.Module):
# processed with quantization, LoRA, fine-tuning, etc. # processed with quantization, LoRA, fine-tuning, etc.
if self.config.tie_word_embeddings and "lm_head.weight" in name: if self.config.tie_word_embeddings and "lm_head.weight" in name:
continue continue
if self.quant_config is not None and (scale_name := self.quant_config.get_cache_scale(name)): # KV-cache scales are renamed via maybe_remap_kv_scale_name below;
# Loading kv cache scales for compressed-tensors quantization # quant_config.get_cache_scale was removed in vLLM v0.23.0 (see #4810).
param = params_dict[scale_name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
loaded_weight = loaded_weight[0]
weight_loader(param, loaded_weight)
continue
is_found = False is_found = False
for param_name, weight_name, shard_id in stacked_params_mapping: for param_name, weight_name, shard_id in stacked_params_mapping:
@@ -17,7 +17,6 @@ from diffusers.models.embeddings import TimestepEmbedding, Timesteps
from diffusers.models.modeling_outputs import Transformer2DModelOutput from diffusers.models.modeling_outputs import Transformer2DModelOutput
from diffusers.models.normalization import AdaLayerNormContinuous from diffusers.models.normalization import AdaLayerNormContinuous
from vllm.logger import init_logger from vllm.logger import init_logger
from vllm.model_executor.layers.layernorm import RMSNorm
from vllm.model_executor.layers.linear import ( from vllm.model_executor.layers.linear import (
ColumnParallelLinear, ColumnParallelLinear,
QKVParallelLinear, QKVParallelLinear,
@@ -555,8 +554,8 @@ class QwenImageCrossAttention(nn.Module):
self.query_num_heads = self.to_qkv.num_heads self.query_num_heads = self.to_qkv.num_heads
self.kv_num_heads = self.to_qkv.num_kv_heads self.kv_num_heads = self.to_qkv.num_kv_heads
self.norm_q = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity() self.norm_q = nn.RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity()
self.norm_k = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity() self.norm_k = nn.RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity()
self.inner_dim = out_dim if out_dim is not None else head_dim * self.total_num_heads self.inner_dim = out_dim if out_dim is not None else head_dim * self.total_num_heads
@@ -593,8 +592,8 @@ class QwenImageCrossAttention(nn.Module):
prefix=f"{prefix}.to_out.0", prefix=f"{prefix}.to_out.0",
) )
self.norm_added_q = RMSNorm(head_dim, eps=eps) self.norm_added_q = nn.RMSNorm(head_dim, eps=eps)
self.norm_added_k = RMSNorm(head_dim, eps=eps) self.norm_added_k = nn.RMSNorm(head_dim, eps=eps)
self.attn = Attention( self.attn = Attention(
num_heads=self.query_num_heads, num_heads=self.query_num_heads,
@@ -1007,7 +1006,7 @@ class QwenImageTransformer2DModel(CachedTransformer):
quant_config=quant_config, quant_config=quant_config,
) )
self.txt_norm = RMSNorm(joint_attention_dim, eps=1e-6) self.txt_norm = nn.RMSNorm(joint_attention_dim, eps=1e-6)
# Entry projections (image/text) are kept full precision — # Entry projections (image/text) are kept full precision —
# small sensitive layers at the network boundary (see #2728). # small sensitive layers at the network boundary (see #2728).
+26 -2
View File
@@ -213,8 +213,32 @@ class DiffusionWorker:
self._step_lora_state: dict[str, tuple[LoRARequest | None, float]] = {} self._step_lora_state: dict[str, tuple[LoRARequest | None, float]] = {}
self.stage_id = getattr(od_config, "stage_id", 0) self.stage_id = getattr(od_config, "stage_id", 0)
self.init_device() self.init_device()
# Create model runner using the platform-specified class # Create model runner — one decision chain, in precedence order:
model_runner_cls_path = current_omni_platform.get_diffusion_model_runner_cls() # 1. explicit od_config.diffusion_model_runner_cls (user override),
# 2. the runner declared by the engine class that engine_backend
# selects (e.g. ARDiffusionEngine -> ARDiffusionModelRunner),
# 3. the platform default.
# Routing policy therefore lives on the engine class / config surface;
# engines never mutate od_config. Overrides must be import-path
# strings — guard with isinstance so a non-string (e.g. a Mock
# od_config in tests) doesn't shadow the platform hook.
runner_override = getattr(self.od_config, "diffusion_model_runner_cls", None)
engine_runner = None
if not (isinstance(runner_override, str) and runner_override):
try:
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
engine_cls = DiffusionEngine.resolve_engine_class(self.od_config)
engine_runner = getattr(engine_cls, "default_diffusion_model_runner_cls", None)
except Exception:
logger.warning("Worker %s: engine_backend resolution failed; using platform runner", self.rank)
engine_runner = None
if isinstance(runner_override, str) and runner_override:
model_runner_cls_path = runner_override
elif isinstance(engine_runner, str) and engine_runner:
model_runner_cls_path = engine_runner
else:
model_runner_cls_path = current_omni_platform.get_diffusion_model_runner_cls()
model_runner_cls = resolve_obj_by_qualname(model_runner_cls_path) model_runner_cls = resolve_obj_by_qualname(model_runner_cls_path)
self.model_runner = model_runner_cls( self.model_runner = model_runner_cls(
vllm_config=self.vllm_config, vllm_config=self.vllm_config,
+51 -6
View File
@@ -26,7 +26,7 @@ from vllm.tasks import SupportedTask
from vllm.utils import random_uuid from vllm.utils import random_uuid
from vllm.v1.engine.exceptions import EngineDeadError from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.data import OmniACK, OmniSleepTask, OmniWakeTask from vllm_omni.diffusion.data import CuMemTag, OmniACK, OmniSleepTask, OmniWakeTask
from vllm_omni.engine.messages import ErrorMessage, OutputMessage from vllm_omni.engine.messages import ErrorMessage, OutputMessage
from vllm_omni.entrypoints.client_request_state import ClientRequestState from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.entrypoints.omni_base import ( from vllm_omni.entrypoints.omni_base import (
@@ -141,7 +141,8 @@ class AsyncOmni(EngineClient, OmniBase):
OmniBase.__init__(self, model=model, **kwargs) OmniBase.__init__(self, model=model, **kwargs)
self._pause_cond: asyncio.Condition = asyncio.Condition() self._pause_cond: asyncio.Condition = asyncio.Condition()
self._paused: bool = False self._paused: bool = False
self._is_sleeping: bool = False self._sleeping_tags: set[str] = set()
self._level2_sleeping: bool = False
self.final_output_task: asyncio.Task | None = None self.final_output_task: asyncio.Task | None = None
self.event_resolver = AsyncEventResolver(orchestrator=self) self.event_resolver = AsyncEventResolver(orchestrator=self)
self.config_path = self.engine.config_path self.config_path = self.engine.config_path
@@ -208,10 +209,13 @@ class AsyncOmni(EngineClient, OmniBase):
def get_diffusion_od_config(self) -> Any | None: def get_diffusion_od_config(self) -> Any | None:
"""Return the diffusion-stage config when the pipeline has one.""" """Return the diffusion-stage config when the pipeline has one."""
saw_diffusion_stage = False
for stage_client in self.engine.stage_clients: for stage_client in self.engine.stage_clients:
if getattr(stage_client, "stage_type", None) != "diffusion": if getattr(stage_client, "stage_type", None) != "diffusion":
continue continue
saw_diffusion_stage = True
od_config = getattr(stage_client, "od_config", None) od_config = getattr(stage_client, "od_config", None)
if od_config is not None: if od_config is not None:
return od_config return od_config
@@ -221,6 +225,11 @@ class AsyncOmni(EngineClient, OmniBase):
if od_config is not None: if od_config is not None:
return od_config return od_config
# Out-of-process diffusion clients don't carry od_config (it lives in the
# worker); fall back to the engine's model_class_name resolution.
if saw_diffusion_stage:
return self.engine.get_diffusion_od_config()
return None return None
@property @property
@@ -306,6 +315,14 @@ class AsyncOmni(EngineClient, OmniBase):
logger.debug(f"[AsyncOmni] generate() called for request {external_request_id}") logger.debug(f"[AsyncOmni] generate() called for request {external_request_id}")
_sleeping_tags = getattr(self, "_sleeping_tags", None)
if _sleeping_tags:
raise RuntimeError(
f"Generation rejected: Engine is partially or fully asleep. "
f"Currently sleeping tags: {list(_sleeping_tags)}. "
f"Please perform a full wake_up before generating."
)
# Reject diffusion list-prompt early with a clear API error. # Reject diffusion list-prompt early with a clear API error.
if isinstance(prompt, list) and any( if isinstance(prompt, list) and any(
getattr(client, "stage_type", "") == "diffusion" for client in getattr(self.engine, "stage_clients", []) getattr(client, "stage_type", "") == "diffusion" for client in getattr(self.engine, "stage_clients", [])
@@ -922,11 +939,32 @@ class AsyncOmni(EngineClient, OmniBase):
if ack is not None: if ack is not None:
await self.event_resolver.resolve(ack) await self.event_resolver.resolve(ack)
final_acks.append(ack) final_acks.append(ack)
self._is_sleeping = True if not hasattr(self, "_sleeping_tags"):
self._sleeping_tags = set()
self._sleeping_tags.update([CuMemTag.WEIGHTS.value, CuMemTag.KV_CACHE.value])
if level == 2:
self._level2_sleeping = True
return final_acks return final_acks
async def wake_up(self, stage_ids: list[int] | None = None, tags: list[str] | None = None) -> list[OmniACK]: async def wake_up(self, stage_ids: list[int] | None = None, tags: list[str] | None = None) -> list[OmniACK]:
self._final_output_handler() self._final_output_handler()
if getattr(self, "_level2_sleeping", False):
raise NotImplementedError(
"wake_up() after sleep(level=2) is not yet implemented: weights were "
"discarded from GPU and reloading from disk is not yet supported. "
"Use sleep(level=1) instead, which offloads weights to CPU RAM "
"and supports fast DMA restore."
)
_current_tags = getattr(self, "_sleeping_tags", set())
if tags is None:
requested_tags = list(_current_tags)
else:
requested_tags = [t for t in tags if t in _current_tags]
if not requested_tags:
logger.info(f"[{self._name}] Requested tags {tags} are already warm. Skipping wake_up.")
return []
if stage_ids is None: if stage_ids is None:
stage_ids = list(range(len(self.engine.stage_clients))) stage_ids = list(range(len(self.engine.stage_clients)))
total_workers = 0 total_workers = 0
@@ -940,7 +978,7 @@ class AsyncOmni(EngineClient, OmniBase):
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
self.event_resolver.watch_task(task_id, expected_count=total_workers) self.event_resolver.watch_task(task_id, expected_count=total_workers)
logger.info(f"[{self._name}] Wake-up initiated (Task: {task_id}). Awaiting {total_workers} ACKs...") logger.info(f"[{self._name}] Wake-up initiated (Task: {task_id}). Awaiting {total_workers} ACKs...")
task = OmniWakeTask(tags=tags, task_id=task_id) task = OmniWakeTask(tags=requested_tags, task_id=task_id)
rpc_results = await self.collective_rpc(method="handle_wake_task", args=(task,), stage_ids=stage_ids) rpc_results = await self.collective_rpc(method="handle_wake_task", args=(task,), stage_ids=stage_ids)
final_acks = [] final_acks = []
for stage_res in rpc_results: for stage_res in rpc_results:
@@ -951,7 +989,14 @@ class AsyncOmni(EngineClient, OmniBase):
final_acks.append(ack) final_acks.append(ack)
current_omni_platform.synchronize() current_omni_platform.synchronize()
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
self._is_sleeping = False
for t in requested_tags:
if hasattr(self, "_sleeping_tags"):
self._sleeping_tags.discard(t)
# Only clear the level-2 flag once all tags are warm, in case partial
# wake support (e.g. tags=["kv_cache"] only) is added in the future.
if not getattr(self, "_sleeping_tags", None):
self._level2_sleeping = False
logger.info(f"[{self._name}] All {len(final_acks)}/{total_workers} workers reported WARM for task {task_id}.") logger.info(f"[{self._name}] All {len(final_acks)}/{total_workers} workers reported WARM for task {task_id}.")
return final_acks return final_acks
@@ -961,7 +1006,7 @@ class AsyncOmni(EngineClient, OmniBase):
TODO(AsyncOmni): query the orchestrator once all stage backends expose TODO(AsyncOmni): query the orchestrator once all stage backends expose
a real sleeping-state RPC. For now we track the requested state locally. a real sleeping-state RPC. For now we track the requested state locally.
""" """
return self._is_sleeping return bool(getattr(self, "_sleeping_tags", None))
async def add_lora(self, lora_request: LoRARequest) -> bool: async def add_lora(self, lora_request: LoRARequest) -> bool:
"""Load a new LoRA adapter into all stages. """Load a new LoRA adapter into all stages.
+22 -4
View File
@@ -3495,12 +3495,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
request_id = request_id or f"speech-{random_uuid()}" request_id = request_id or f"speech-{random_uuid()}"
qwen3_ref_audio_warmup_artifact_key: str | None = None qwen3_ref_audio_warmup_artifact_key: str | None = None
# If this is a streaming request, we need to coerce # If this is a streaming request with real async chunks, we need to
# cumulative outputs to delta outputs; this ensures # coerce cumulative outputs to delta outputs; this ensures we don't
# we don't emit redundant MM data & drain after emitting. # emit redundant MM data & drain after emitting. Qwen3-TTS full-payload
# (async_chunk=False) has no incremental audio chunks, so keep
# FINAL_ONLY semantics and let the streaming response send the final
# waveform once. Scoped to qwen3_tts: other async_chunk=False models
# keep the DELTA coercion they stream with today.
# list() makes a copy to avoid mutating the params. # list() makes a copy to avoid mutating the params.
sampling_params_list = list(self.engine_client.default_sampling_params_list) sampling_params_list = list(self.engine_client.default_sampling_params_list)
is_streaming_request = request.is_streaming() async_chunk = getattr(self.model_config, "async_chunk", True)
qwen3_full_payload = self._tts_model_type == "qwen3_tts" and not bool(async_chunk)
is_streaming_request = request.is_streaming() and not qwen3_full_payload
sampling_params_list = coerce_param_message_types(sampling_params_list, is_streaming_request) sampling_params_list = coerce_param_message_types(sampling_params_list, is_streaming_request)
# Build prompt + tts_params via the per-model adapter (RFC #4327). Every # Build prompt + tts_params via the per-model adapter (RFC #4327). Every
@@ -3687,6 +3693,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
stage0_params.extra_args = {} stage0_params.extra_args = {}
stage0_params.extra_args["tts_local_seed"] = request.seed stage0_params.extra_args["tts_local_seed"] = request.seed
if self._tts_model_type == "qwen3_tts" and sampling_params_list:
stage0_params = sampling_params_list[0]
default_seed = getattr(stage0_params, "seed", None)
if default_seed is not None:
import copy
sampling_params_list = copy.deepcopy(sampling_params_list)
stage0_params = sampling_params_list[0]
if stage0_params.extra_args is None:
stage0_params.extra_args = {}
stage0_params.extra_args.setdefault("tts_local_seed", int(default_seed))
generator = self.engine_client.generate( generator = self.engine_client.generate(
prompt=prompt, prompt=prompt,
request_id=request_id, request_id=request_id,
+7 -2
View File
@@ -1,3 +1,8 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Experimental vLLM-Omni subsystems.
"""Experimental vLLM-Omni features (APIs may change without notice)."""
Modules under ``vllm_omni.experimental`` are in active development and not yet
covered by the stability guarantees of the rest of the package — their APIs,
config surfaces, and defaults may change without notice. The AR-Diffusion
Engine (:mod:`vllm_omni.experimental.ar_diffusion`) lives here.
"""
@@ -0,0 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
"""AR-Diffusion Engine (AR-Diffusion).
The AR-Diffusion engine: a ``DiffusionEngine`` subclass that adds engine-level KV
cache management for autoregressive / chunked "world-model" diffusion models
(DreamZero and the AR-DiT family). Selected via
``OmniDiffusionConfig.engine_backend = "ar_diffusion"``.
"""
from vllm_omni.experimental.ar_diffusion.engine import ARDiffusionEngine
__all__ = ["ARDiffusionEngine"]
@@ -0,0 +1,41 @@
# SPDX-License-Identifier: Apache-2.0
"""The AR-Diffusion Engine (AR-Diffusion)."""
from __future__ import annotations
from vllm.logger import init_logger
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
logger = init_logger(__name__)
#: Import path of the runner the AR-Diffusion engine routes its workers to.
AR_DIFFUSION_MODEL_RUNNER_CLS = "vllm_omni.experimental.ar_diffusion.runner.ARDiffusionModelRunner"
class ARDiffusionEngine(DiffusionEngine):
"""AR-Diffusion engine with engine-level KV cache management.
AR-Diffusion serves autoregressive / chunked blockwise-causal diffusion models
(world models, AR-DiT) that materialize persistent attention KV. It reuses
vLLM's paged KV stack (``KVCacheManager`` / ``BlockPool`` / ``BlockTables``)
as a library, driven from the engine rather than hand-rolled inside each
model. See ``BDE_doc/diffusion_kv_cache_management.md`` for the design and
``BDE_doc/dreamzero_kv_phase1_plan.md`` for the rollout.
It is selected per model via ``OmniDiffusionConfig.engine_backend = "ar_diffusion"``
(resolved by :meth:`DiffusionEngine.make_engine`), so models that do not opt
in keep using the base ``DiffusionEngine`` unchanged.
Architecture note: in the multiproc setup the KV cache lives in the worker /
runner process (GPU side), co-located with the model and KV tensors — so the
actual KV *body* is :class:`~vllm_omni.experimental.ar_diffusion.kv_cache.manager.ARDiffusionKVCache`, owned
by :class:`~vllm_omni.experimental.ar_diffusion.runner.ARDiffusionModelRunner`. ``ARDiffusionEngine`` itself is the
thin selection / injection seam; it wires the AR-Diffusion executor → worker → runner
so DreamZero's rollout runs against the runner-owned KV cache.
"""
#: Workers of this engine build the AR-Diffusion runner (unless the config
#: sets an explicit ``diffusion_model_runner_cls`` override). Declared on
#: the class so routing needs no od_config mutation at engine init.
default_diffusion_model_runner_cls = AR_DIFFUSION_MODEL_RUNNER_CLS
@@ -0,0 +1,57 @@
# SPDX-License-Identifier: Apache-2.0
"""AR-Diffusion engine-level KV cache helpers.
Thin glue over vLLM's paged KV stack (``KVCacheManager`` / ``BlockPool`` /
``SlidingWindowManager``), used by the AR-Diffusion Engine to manage KV for
AR-diffusion models. See ``BDE_doc/dreamzero_kv_phase1_plan.md``.
Layout: ``config`` (public knob) · ``paged`` (engine-generic paging mechanics +
chunk-window eviction spec) · ``manager`` (the ARDiffusionKVCache orchestrator + its
request adapter / pool builders) · ``state`` (the model-facing ARDiffusionKVState bridge).
"""
from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVConfig
from vllm_omni.experimental.ar_diffusion.kv_cache.manager import (
ARDiffusionKVCache,
ARDiffusionRequestAdapter,
build_kv_manager,
compute_num_blocks,
)
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import (
ChunkWindowManager,
ChunkWindowSpec,
allocate_kv_pool_with_views,
chunk_slot_mapping,
compute_slot_mapping,
pool_write_chunk,
resident_block_ids,
)
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import (
ARDiffusionPagedForwardContext,
ARDiffusionPagedLayerContext,
ARDiffusionPagedLayerInputs,
ar_diffusion_paged_attention,
paged_write_attn,
set_current_paged_kv_cache,
)
__all__ = [
"ARDiffusionKVCache",
"ARDiffusionKVConfig",
"ARDiffusionPagedForwardContext",
"ARDiffusionPagedLayerContext",
"ARDiffusionPagedLayerInputs",
"ARDiffusionRequestAdapter",
"ChunkWindowManager",
"ChunkWindowSpec",
"allocate_kv_pool_with_views",
"ar_diffusion_paged_attention",
"build_kv_manager",
"chunk_slot_mapping",
"compute_num_blocks",
"compute_slot_mapping",
"paged_write_attn",
"pool_write_chunk",
"resident_block_ids",
"set_current_paged_kv_cache",
]
@@ -0,0 +1,41 @@
# SPDX-License-Identifier: Apache-2.0
"""Configuration for AR-Diffusion engine-level KV cache management."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ARDiffusionKVConfig:
"""Settings for the AR-Diffusion paged KV cache.
Disabled by default: when ``enable`` is False the AR-Diffusion engine behaves exactly
like the base ``DiffusionEngine`` (no pool, no paged KV).
"""
enable: bool = False
# Persistent KV tokens materialized per AR chunk. For DreamZero this is
# ``num_frame_per_block * frame_seqlen``.
chunk_size: int = 0
# Resident window in chunks. ``None`` means full attention (no eviction).
window_chunks: int | None = None
# Protected leading chunks (attention sink); never evicted.
sink_chunks: int = 0
# DreamZero-style window reset vs. VGGT-style sliding replace.
reset_at_boundary: bool = False
# Fraction of free device memory budgeted for the AR-Diffusion KV pool.
gpu_memory_fraction: float = 0.1
# When CUDA graph / torch.compile is on (not enforce_eager), pre-capture the
# 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
# Also capture the post-window-boundary (reset-cycle) forward during warm-up.
warmup_capture_reset: bool = False
@property
def sliding_window(self) -> int | None:
"""Window size in tokens, or ``None`` for full attention."""
if self.window_chunks is None:
return None
return self.window_chunks * self.chunk_size
@@ -0,0 +1,497 @@
# SPDX-License-Identifier: Apache-2.0
"""ARDiffusionKVCache — the engine-level KV cache orchestrator for one AR-Diffusion model.
This is the *body* of AR-Diffusion's KV management: it owns a vLLM ``KVCacheManager`` (a
single chunk-window group) and the per-request adapter lifecycle, and exposes the
per-chunk operations a rollout needs — allocate, slot mapping, commit, window
lookup, free. It lives in the model runner (worker / GPU side), co-located with
the model and the KV tensors; the DreamZero pipeline calls these methods during a
rollout. The main-process ``ARDiffusionEngine`` only selects the engine and is otherwise
thin.
"""
from __future__ import annotations
import inspect
import os
from collections.abc import Sequence
import torch
from vllm.logger import init_logger
from vllm.v1.core.kv_cache_manager import KVCacheManager
from vllm.v1.kv_cache_interface import (
KVCacheConfig,
KVCacheGroupSpec,
KVCacheSpec,
KVCacheTensor,
)
from vllm.v1.request import RequestStatus
from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVConfig
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import (
ChunkWindowSpec,
allocate_kv_pool_with_views,
chunk_slot_mapping,
pool_write_chunk,
resident_block_ids,
)
_log = init_logger(__name__)
class ARDiffusionRequestAdapter:
"""Duck-types the subset of ``vllm.v1.request.Request`` that the
``KVCacheManager`` reads (``allocate_slots`` / ``get_computed_blocks`` /
``free`` and the coordinator they call into).
It is intentionally NOT a full ``Request``. The conformance test exercises a
real ``KVCacheManager`` against this adapter so the surface cannot silently
drift across vLLM versions.
A AR-Diffusion request advances one *chunk* at a time: ``allocate_slots`` is called
once per chunk and ``num_computed_tokens`` advances only when a chunk is
committed (:meth:`on_chunk_committed`), so the ``T`` denoise steps of a chunk
reuse the same slots.
"""
def __init__(
self,
request_id: str,
*,
chunk_size: int,
prefill_prefix_tokens: int = 0,
) -> None:
self.request_id = request_id
self._chunk_size = chunk_size
self._prefill = prefill_prefix_tokens
self._completed_chunks = 0
# Filled only when cross-request prefix reuse is enabled (Phase 3).
self.block_hashes: list = []
self.skip_reading_prefix_cache = True
self.num_preemptions = 0
# vLLM watermark gate reads this; map the request lifecycle onto it.
self.status = RequestStatus.WAITING
@property
def num_computed_tokens(self) -> int:
"""Persistent KV already materialized (committed chunks + prefill)."""
return self._prefill + self._completed_chunks * self._chunk_size
@property
def num_tokens(self) -> int:
"""Total tokens once the in-flight chunk is committed."""
return self._prefill + (self._completed_chunks + 1) * self._chunk_size
@property
def num_prompt_tokens(self) -> int:
"""The prefill prefix length (read by ``cache_blocks`` when caching)."""
return self._prefill
@property
def completed_chunks(self) -> int:
return self._completed_chunks
def on_chunk_committed(self) -> None:
"""Advance by one chunk. Call once per chunk, not per denoise step."""
self._completed_chunks += 1
def compute_num_blocks(
available_bytes: int,
gpu_memory_fraction: float,
page_size_bytes: int,
) -> int:
"""Number of KV blocks that fit in ``fraction`` of the memory budget."""
if page_size_bytes <= 0:
raise ValueError(f"page_size_bytes must be positive, got {page_size_bytes}")
if not 0.0 < gpu_memory_fraction <= 1.0:
raise ValueError(f"gpu_memory_fraction must be in (0, 1], got {gpu_memory_fraction}")
budget = int(available_bytes * gpu_memory_fraction)
return max(0, budget // page_size_bytes)
def build_kv_manager(
spec: KVCacheSpec,
layer_names: Sequence[str],
num_blocks: int,
max_model_len: int,
*,
enable_caching: bool = False,
) -> KVCacheManager:
"""Build a ``KVCacheManager`` with a single KV cache group for ``spec``.
Args:
spec: The KV cache spec for the group (e.g. a ``ChunkWindowSpec``).
layer_names: Attention layers sharing this group's block table.
num_blocks: Total physical blocks in the pool.
max_model_len: Upper bound on a request's sequence length.
enable_caching: Cross-request prefix caching (Phase 3); off in Phase 1.
"""
layer_names = list(layer_names)
group = KVCacheGroupSpec(layer_names=layer_names, kv_cache_spec=spec)
tensors = [KVCacheTensor(size=spec.page_size_bytes * num_blocks, shared_by=layer_names)]
config = KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=tensors,
kv_cache_groups=[group],
)
kwargs = dict(max_model_len=max_model_len, hash_block_size=spec.block_size, enable_caching=enable_caching)
params = inspect.signature(KVCacheManager).parameters
if "scheduler_block_size" in params:
kwargs["scheduler_block_size"] = spec.block_size
if "max_num_batched_tokens" in params:
kwargs["max_num_batched_tokens"] = max_model_len
return KVCacheManager(config, **kwargs)
class ARDiffusionKVCache:
"""Owns the paged KV pool + per-request lifecycle for a AR-Diffusion model.
Build once per loaded model (dimensions known); then per request:
``begin_request`` → per chunk (``allocate_chunk`` → ``chunk_write_slots`` →
[model writes K/V] → ``commit_chunk``) → ``end_request``.
"""
def __init__(
self,
config: ARDiffusionKVConfig,
*,
num_layers: int,
num_kv_heads: int,
head_size: int,
dtype: torch.dtype,
block_size: int,
max_model_len: int,
available_bytes: int,
cross_attn_length: int = 0,
cross_attn_img_length: int = 0,
device: torch.device | None = None,
local_branches: int = 2,
num_frame_per_block: int = 1,
) -> None:
if not config.enable:
raise ValueError("ARDiffusionKVCache built with a disabled ARDiffusionKVConfig")
if config.window_chunks is None:
raise ValueError("Phase 1 requires a bounded window (window_chunks)")
if config.chunk_size <= 0:
raise ValueError("ARDiffusionKVConfig.chunk_size must be set (> 0)")
if local_branches not in (1, 2):
raise ValueError(f"local_branches must be 1 or 2, got {local_branches}")
self.config = config
# How many CFG branches allocate from THIS rank's pool. Under
# CFG-parallel x2 each rank executes exactly one branch (rank0 pos,
# rank1 neg) and the other branch's lazy contexts never allocate, so
# sizing for both would leave ~half the pool as idle capacity. A
# single-process run (TP1 / offline) executes both branches -> 2.
self.local_branches = local_branches
self.num_frame_per_block = max(1, int(num_frame_per_block))
self.block_size = block_size
self.num_layers = num_layers
self.num_kv_heads = num_kv_heads
self.head_size = head_size
self.dtype = dtype
self.cross_attn_length = cross_attn_length
self.device = device or torch.device("cpu")
self._adapters: dict[str, ARDiffusionRequestAdapter] = {}
# -- cross-attention pool (static, one-time-fill) -----------------------
# Cross-attn KV is computed once from the text encoder and never changes.
# Allocate a per-layer contiguous tensor for each branch: (2, text_len,
# kv_heads, head_dim) where dim-0 = [pos, neg]. Allocated separately
# from the self-attn block pool (static, small — ~400 MiB for
# DreamZero I2V). Both pools draw from the same GPU free memory but
# the cross-attn pool is sized directly rather than via the block pool
# budget (no eviction/chunk lifecycle needed).
self.cross_attn_img_length = cross_attn_img_length
self._cross_k: list[torch.Tensor] = []
self._cross_v: list[torch.Tensor] = []
# I2V image-token cross-attn pool. Like the text k/v, the image-token
# k_img/v_img are session-invariant (the conditioning image doesn't change),
# so they are cached once and read every denoise step — see
# WanI2VCrossAttention (#4154 caches these model-side too). Empty for T2V.
self._cross_k_img: list[torch.Tensor] = []
self._cross_v_img: list[torch.Tensor] = []
# Bytes the cross-attn pools consume directly (K+V, pos+neg, all layers).
# Deducted from the self-attn paged-pool budget below so the two
# allocations together stay within the GPU free-memory budget.
def _cross_pool_bytes(length: int) -> int:
return 2 * 2 * length * num_kv_heads * head_size * dtype.itemsize * num_layers
cross_total_bytes = 0
if device is not None and cross_attn_length > 0:
cross_shape = (2, cross_attn_length, num_kv_heads, head_size)
cross_bytes = _cross_pool_bytes(cross_attn_length)
cross_total_bytes += cross_bytes
for _ in range(num_layers):
self._cross_k.append(torch.empty(cross_shape, dtype=dtype, device=device))
self._cross_v.append(torch.empty(cross_shape, dtype=dtype, device=device))
_log.info(
"AR-Diffusion cross-attn pool: %d layers × (%d tok × %d heads × %d) = %.1f MiB",
num_layers,
cross_attn_length,
num_kv_heads,
head_size,
cross_bytes / (1024 * 1024),
)
if cross_attn_img_length > 0:
img_shape = (2, cross_attn_img_length, num_kv_heads, head_size)
cross_total_bytes += _cross_pool_bytes(cross_attn_img_length)
for _ in range(num_layers):
self._cross_k_img.append(torch.empty(img_shape, dtype=dtype, device=device))
self._cross_v_img.append(torch.empty(img_shape, dtype=dtype, device=device))
_log.info(
"AR-Diffusion cross-attn IMG pool: %d layers × %d img-tok (I2V)",
num_layers,
cross_attn_img_length,
)
self.spec = ChunkWindowSpec(
block_size=block_size,
num_kv_heads=num_kv_heads,
head_size=head_size,
dtype=dtype,
sliding_window=config.window_chunks * config.chunk_size,
chunk_size=config.chunk_size,
window_chunks=config.window_chunks,
sink_chunks=config.sink_chunks,
reset_at_boundary=config.reset_at_boundary,
)
# Each pool block spans all layers' K/V, so size against the per-layer
# page size times the layer count.
# Size the self-attn pool against the memory left after the cross-attn
# pools (allocated above), so cross + self-attn stays within the budget.
num_blocks = compute_num_blocks(
max(0, available_bytes - cross_total_bytes),
config.gpu_memory_fraction,
self.spec.page_size_bytes * num_layers,
)
# Floor: one forward needs the resident window plus the in-flight chunk
# (num_frame_per_block frame-blocks) for every branch THIS rank runs,
# with a little eviction-transient headroom. The memory-fraction
# heuristic can under-size this once block_size grows — e.g. frame-granular
# paging at the true frame_seqlen makes each block larger and the pool
# fewer-blocks — so guarantee the minimum the rollout cannot run without,
# otherwise allocate_chunk hits an exhausted pool mid-forward.
min_blocks = self.local_branches * (config.window_chunks + self.num_frame_per_block) + 2
if num_blocks < min_blocks:
_log.warning(
"AR-Diffusion KV pool: memory-fraction sizing gave %d blocks; raising to the %d-block "
"floor (%d local CFG branch(es) x (window_chunks=%d + num_frame_per_block=%d) + 2 headroom)",
num_blocks,
min_blocks,
self.local_branches,
config.window_chunks,
self.num_frame_per_block,
)
num_blocks = min_blocks
layer_names = [f"ar_diffusion.layer.{i}" for i in range(num_layers)]
self.manager = build_kv_manager(self.spec, layer_names, num_blocks, max_model_len)
self.managed_num_blocks = num_blocks
self.num_blocks = num_blocks
# Scratch blocks are outside KVCacheManager ownership. They hold current
# denoise-step video KV when update_kv_cache=False and action/state KV in
# both modes; they are reused every forward and never committed.
# Default 4: measured high-water is num_frame_per_block video blocks (2)
# + 1 action block per branch per forward, +1 margin.
scratch_per_branch = int(os.environ.get("AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH", "4"))
if scratch_per_branch <= 0:
raise ValueError("AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH must be positive")
self.scratch_blocks_per_branch = scratch_per_branch
self.scratch_num_blocks = self.local_branches * scratch_per_branch
self.num_blocks_total = self.managed_num_blocks + self.scratch_num_blocks
self.null_block_id = self.manager.block_pool.null_block.block_id
# Allocate the per-layer paged K/V pools on the given device.
self._kv_pools: list[torch.Tensor] = []
self._k_pools: list[torch.Tensor] = []
self._v_pools: list[torch.Tensor] = []
if device is not None:
self._kv_pools, self._k_pools, self._v_pools = allocate_kv_pool_with_views(
self.num_blocks_total,
block_size,
num_layers,
num_kv_heads,
head_size,
dtype,
device,
)
# -- cross-attention pool access -------------------------------------------
# Cross-attn KV is static once populated — write once (from text encoder),
# read many (every denoising step). Not managed through the paged block pool.
def write_cross_kv(
self,
layer_idx: int,
is_negative: bool,
k: torch.Tensor | None,
v: torch.Tensor | None,
k_img: torch.Tensor | None = None,
v_img: torch.Tensor | None = None,
) -> None:
"""Write one layer's cross-attn K/V into the pool.
``k`` / ``v``: ``(B, text_len, tp_num_heads, head_dim)``; only batch-0
is copied (B=1 for inference). ``None`` skips the text half (window
restarts keep the still-valid text K/V and rewrite only the image half).
The ``is_negative`` flag selects the correct CFG branch slot. ``k_img`` /
``v_img`` (I2V image tokens, ``(B, 257, ...)``) are written when the
image pool is allocated.
"""
branch = 1 if is_negative else 0
if k is not None:
self._cross_k[layer_idx][branch].copy_(k[0])
self._cross_v[layer_idx][branch].copy_(v[0])
if k_img is not None and self._cross_k_img:
self._cross_k_img[layer_idx][branch].copy_(k_img[0])
self._cross_v_img[layer_idx][branch].copy_(v_img[0])
def read_cross_kv(self, layer_idx: int, is_negative: bool) -> dict:
"""Return a pool-backed cross-attn cache dict for one layer.
The dict matches the ``{"is_init": True, "k": Tensor, "v": Tensor}``
convention the cross-attention module expects — it reads from the pool
slice rather than from the lazy-initialised model-local dict. For I2V,
``k_img`` / ``v_img`` are added so the image-token cache (added by #4154)
reads from the pool too.
"""
branch = 1 if is_negative else 0
k = self._cross_k[layer_idx][branch].unsqueeze(0) # (1, L, heads, dim)
v = self._cross_v[layer_idx][branch].unsqueeze(0)
cache = {"is_init": True, "k": k, "v": v}
if self._cross_k_img:
cache["k_img"] = self._cross_k_img[layer_idx][branch].unsqueeze(0)
cache["v_img"] = self._cross_v_img[layer_idx][branch].unsqueeze(0)
return cache
# -- request lifecycle ---------------------------------------------------
def begin_request(self, request_id: str, *, prefill_prefix_tokens: int = 0) -> ARDiffusionRequestAdapter:
adapter = ARDiffusionRequestAdapter(
request_id,
chunk_size=self.spec.chunk_size,
prefill_prefix_tokens=prefill_prefix_tokens,
)
self._adapters[request_id] = adapter
_log.debug("AR-Diffusion begin_request: req=%s prefill=%d", request_id, prefill_prefix_tokens)
return adapter
def end_request(self, adapter: ARDiffusionRequestAdapter) -> None:
_log.debug(
"AR-Diffusion end_request: req=%s chunks=%d free=%d",
adapter.request_id,
adapter.completed_chunks,
self.manager.block_pool.get_num_free_blocks(),
)
self.manager.free(adapter)
self._adapters.pop(adapter.request_id, None)
# -- per-chunk operations ------------------------------------------------
def allocate_chunk(self, adapter: ARDiffusionRequestAdapter) -> list[int]:
"""Allocate a chunk's blocks (evicting out-of-window blocks first).
Returns the request's full block table (incl. null_block placeholders).
"""
blocks = self.manager.allocate_slots(adapter, num_new_tokens=self.spec.chunk_size)
if blocks is None:
raise RuntimeError("AR-Diffusion KV pool exhausted while allocating a chunk")
table = self.block_table(adapter)
resident = resident_block_ids(table, self.null_block_id)
_log.debug(
"AR-Diffusion allocate_chunk: req=%s chunk=%d table_len=%d resident=%d free=%d",
adapter.request_id,
adapter.completed_chunks,
len(table),
len(resident),
self.manager.block_pool.get_num_free_blocks(),
)
return table
def allocate_token_slots(self, adapter: ARDiffusionRequestAdapter, num_tokens: int) -> list[int]:
"""Allocate managed blocks for an in-flight video span without committing it."""
if num_tokens <= 0:
raise ValueError(f"num_tokens must be positive, got {num_tokens}")
blocks = self.manager.allocate_slots(adapter, num_new_tokens=num_tokens)
if blocks is None:
raise RuntimeError("AR-Diffusion KV pool exhausted while allocating paged attention slots")
return self.block_table(adapter)
def block_table(self, adapter: ARDiffusionRequestAdapter) -> list[int]:
return list(self.manager.get_block_ids(adapter.request_id)[0])
def chunk_write_slots(self, adapter: ARDiffusionRequestAdapter) -> torch.Tensor:
"""Slot mapping for the in-flight chunk — the K/V write target."""
return chunk_slot_mapping(
self.block_table(adapter),
adapter.num_computed_tokens,
self.spec.chunk_size,
self.block_size,
)
def scratch_block_ids(self, is_negative: bool, start: int, count: int) -> list[int]:
"""Return branch-local scratch block ids outside manager ownership."""
if count < 0 or start < 0:
raise ValueError(f"scratch start/count must be non-negative, got start={start}, count={count}")
if start + count > self.scratch_blocks_per_branch:
raise RuntimeError(
"AR-Diffusion paged attention scratch blocks exhausted: "
f"need [{start}, {start + count}) of {self.scratch_blocks_per_branch}. "
"Increase AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH."
)
# With one local branch (CFG-parallel x2) the rank's only branch maps to
# slot 0 whichever CFG side it is; the other branch never allocates here.
branch_offset = self.scratch_blocks_per_branch if (is_negative and self.local_branches == 2) else 0
base = self.managed_num_blocks + branch_offset + start
return list(range(base, base + count))
def key_cache(self, layer_idx: int) -> torch.Tensor:
return self._kv_pools[layer_idx][0]
def value_cache(self, layer_idx: int) -> torch.Tensor:
return self._kv_pools[layer_idx][1]
def window_block_ids(self, adapter: ARDiffusionRequestAdapter) -> list[int]:
"""Resident (non-null) managed blocks visible to paged attention."""
return resident_block_ids(self.block_table(adapter), self.null_block_id)
def commit_chunk(self, adapter: ARDiffusionRequestAdapter) -> None:
"""Advance the adapter by one chunk after its K/V is written.
This standalone primitive is used by low-level manager tests. In the
DreamZero paged-attention path, :meth:`ARDiffusionKVState.commit_paged_context`
advances the adapter only after the forward succeeds. Call once per
committed chunk, not per denoise step.
"""
_log.debug("AR-Diffusion commit: req=%s before=%d", adapter.request_id, adapter.completed_chunks)
adapter.on_chunk_committed()
_log.debug("AR-Diffusion commit: req=%s after=%d", adapter.request_id, adapter.completed_chunks)
# -- pool-backed K/V access --------------------------------------------
def write_chunk_kv(
self,
layer_index: int,
new_k: torch.Tensor,
new_v: torch.Tensor,
adapter: ARDiffusionRequestAdapter,
) -> None:
"""Write one layer's committed-chunk K/V into the pool."""
slots = self.chunk_write_slots(adapter)
_log.debug(
"AR-Diffusion write: req=%s layer=%d chunk=%d shapes=%s dev=%s",
adapter.request_id,
layer_index,
adapter.completed_chunks,
(tuple(new_k.shape), tuple(new_v.shape)),
slots.device,
)
pool_write_chunk(
self._k_pools[layer_index],
self._v_pools[layer_index],
new_k,
new_v,
slots,
)
@@ -0,0 +1,223 @@
# SPDX-License-Identifier: Apache-2.0
"""Generic paging mechanics + chunk-window eviction spec for the AR-Diffusion engine.
Engine-generic, model-agnostic primitives — the layer a second model (e.g. the
Cosmos port) reuses unchanged. Three concerns live here:
* **Slot mapping** — absolute token positions → physical KV-cache slots, the
standard PagedAttention layout ``slot(pos) = block_id(pos) * block_size +
(pos % block_size)``.
* **Pool I/O** — allocate per-layer FlashAttention-compatible paged K/V pools
plus flat compatibility views for direct slot writes.
* **Chunk-window eviction** — a ``SlidingWindowSpec`` subclass whose unit is a
*chunk* (``sliding_window = window_chunks * chunk_size``) plus a manager that
evicts at chunk boundaries. Memory policy / refcounting / ``null_block``
replacement stay in vLLM's ``BlockPool``; only the token-skip math is here.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import torch
from vllm.v1.core.single_type_kv_cache_manager import SlidingWindowManager
from vllm.v1.kv_cache_interface import SlidingWindowSpec
try:
from vllm.v1.kv_cache_spec_registry import register_kv_cache_spec
except ModuleNotFoundError:
def register_kv_cache_spec(*, manager_class, uniform_type_base_spec=None):
"""Compatibility shim for vLLM versions without kv_cache_spec_registry."""
def decorator(spec_class):
from vllm.v1.core.single_type_kv_cache_manager import spec_manager_map
spec_manager_map[spec_class] = manager_class
return spec_class
return decorator
# ── Slot mapping ────────────────────────────────────────────────────────────
def compute_slot_mapping(
block_ids: Sequence[int],
positions: torch.Tensor | Sequence[int],
block_size: int,
) -> torch.Tensor:
"""Map absolute token positions to physical KV-cache slots.
Args:
block_ids: physical block id per block index (the request's block table).
positions: absolute token positions to map (1-D).
block_size: tokens per block.
Returns:
``LongTensor`` of physical slots, one per position.
"""
if block_size <= 0:
raise ValueError(f"block_size must be positive, got {block_size}")
table = torch.as_tensor(block_ids, dtype=torch.long)
pos = torch.as_tensor(positions, dtype=torch.long)
block_index = torch.div(pos, block_size, rounding_mode="floor")
offset = pos % block_size
return table[block_index] * block_size + offset
def chunk_slot_mapping(
block_ids: Sequence[int],
num_computed_tokens: int,
chunk_size: int,
block_size: int,
) -> torch.Tensor:
"""Slot mapping for the in-flight chunk's tokens (the commit write target).
The chunk occupies absolute positions
``[num_computed_tokens, num_computed_tokens + chunk_size)``.
"""
positions = torch.arange(
num_computed_tokens,
num_computed_tokens + chunk_size,
dtype=torch.long,
)
return compute_slot_mapping(block_ids, positions, block_size)
def resident_block_ids(block_ids: Sequence[int], null_block_id: int) -> list[int]:
"""Real (non-null) blocks currently resident, in table order.
These are the blocks the read path gathers the attention window from;
out-of-window positions are the shared ``null_block`` and are excluded.
"""
return [int(b) for b in block_ids if int(b) != null_block_id]
# ── Pool I/O (allocate / write) ─────────────────────────────────────────────
def allocate_kv_pool_with_views(
num_blocks: int,
block_size: int,
num_layers: int,
num_kv_heads: int,
head_dim: int,
dtype: torch.dtype,
device: torch.device,
) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]:
"""Allocate vLLM-style paged KV pools plus flat slot-write views.
The owning tensor follows FlashAttention's block-table cache layout:
``(2, num_blocks, block_size, num_kv_heads, head_dim)``, where dim-0 is
``[K, V]``. The flat K/V views keep ``slot = block_id * block_size +
offset`` writes simple without changing the kernel-facing cache layout.
"""
kv_pools: list[torch.Tensor] = []
k_pools: list[torch.Tensor] = []
v_pools: list[torch.Tensor] = []
for _ in range(num_layers):
kv = torch.empty(2, num_blocks, block_size, num_kv_heads, head_dim, dtype=dtype, device=device)
kv_pools.append(kv)
k_pools.append(kv[0].reshape(num_blocks * block_size, num_kv_heads, head_dim))
v_pools.append(kv[1].reshape(num_blocks * block_size, num_kv_heads, head_dim))
return kv_pools, k_pools, v_pools
def pool_write_chunk(
k_pool: torch.Tensor,
v_pool: torch.Tensor,
new_k: torch.Tensor,
new_v: torch.Tensor,
slot_mapping: torch.Tensor,
) -> None:
"""Write the committed chunk's K/V into pool slots (in place).
``new_k`` / ``new_v`` shape: ``(batch, chunk_size, num_kv_heads, head_dim)``.
``slot_mapping`` shape: ``(chunk_size,)`` — one physical slot per token position.
The batch dim is written to the same slots (multi-batch writes the same
positions; the caller is responsible for per-sequence bookkeeping).
This is the *commit* write — called once per chunk, after the last denoise step.
"""
k_pool[slot_mapping] = new_k[0] # batch index 0
v_pool[slot_mapping] = new_v[0]
def _drop_batch(t: torch.Tensor) -> torch.Tensor:
"""``(1, L, n_heads, head_dim)`` -> ``(L, n_heads, head_dim)``; pass 3-D through."""
if t.dim() == 4 and t.shape[0] == 1:
return t[0]
return t
# ── Chunk-window eviction (spec + manager) ──────────────────────────────────
def chunk_window_skipped_tokens(
num_computed_tokens: int,
*,
chunk_size: int,
sliding_window: int,
sink_chunks: int,
reset_at_boundary: bool,
) -> int:
"""Tokens outside the resident chunk window, snapped to a chunk boundary.
Pure function so the eviction policy is unit-testable without constructing a
manager. Two strategies:
- ``reset_at_boundary`` (DreamZero): at each chunk boundary everything past
the sink is dropped.
- otherwise (VGGT-style sliding replace): keep the last ``window`` tokens
(plus the sink); the skip count snaps down to a chunk boundary so a chunk
is never half-evicted.
"""
sink = sink_chunks * chunk_size
if reset_at_boundary:
completed = (num_computed_tokens // chunk_size) * chunk_size
return max(0, completed - sink)
skipped = max(0, num_computed_tokens - sliding_window - sink)
return (skipped // chunk_size) * chunk_size
class ChunkWindowManager(SlidingWindowManager):
"""``SlidingWindowManager`` that evicts at chunk boundaries.
``self.sliding_window`` is set by the base ``__init__``; the chunk fields are
read from ``self.kv_cache_spec`` (a :class:`ChunkWindowSpec`).
"""
def get_num_skipped_tokens(self, num_computed_tokens: int) -> int:
spec = self.kv_cache_spec
return chunk_window_skipped_tokens(
num_computed_tokens,
chunk_size=spec.chunk_size,
sliding_window=self.sliding_window,
sink_chunks=spec.sink_chunks,
reset_at_boundary=spec.reset_at_boundary,
)
# Register so KVCacheManager resolves ChunkWindowSpec to ChunkWindowManager.
# Dispatch walks the spec's MRO, so without explicit registration the subclass
# would silently fall back to the parent SlidingWindowManager (override ignored).
# uniform_type_base_spec=None => its own KV cache group.
@register_kv_cache_spec(manager_class=ChunkWindowManager, uniform_type_base_spec=None)
@dataclass(frozen=True, kw_only=True)
class ChunkWindowSpec(SlidingWindowSpec):
# sliding_window (inherited) MUST equal window_chunks * chunk_size.
chunk_size: int
window_chunks: int
sink_chunks: int = 0
reset_at_boundary: bool = False
def __post_init__(self):
super().__post_init__()
if self.sliding_window != self.window_chunks * self.chunk_size:
raise ValueError(
"ChunkWindowSpec.sliding_window must equal "
f"window_chunks * chunk_size ({self.window_chunks} * "
f"{self.chunk_size}), got {self.sliding_window}"
)
@@ -0,0 +1,550 @@
# SPDX-License-Identifier: Apache-2.0
"""Paged self-attention helpers for AR-Diffusion DreamZero KV reuse."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, ClassVar, NamedTuple
import torch
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import compute_slot_mapping
# Set by ARDiffusionPagedForwardContext.prepare() before each branch forward and
# read by the fused write+attend custom op below. The pools are process-lifetime
# allocations owned by one ARDiffusionKVCache per worker process (CFG-parallel /
# TP ranks are separate processes), mirroring vLLM's forward-context pattern for
# keeping multi-GiB cache tensors out of the compiled graph's inputs.
_CURRENT_PAGED_KV_CACHE: Any = None
def set_current_paged_kv_cache(kv_cache: Any) -> None:
global _CURRENT_PAGED_KV_CACHE
_CURRENT_PAGED_KV_CACHE = kv_cache
_LAYER_IDX_TENSORS: dict[int, torch.Tensor] = {}
def _layer_idx_tensor(layer_idx: int) -> torch.Tensor:
t = _LAYER_IDX_TENSORS.get(layer_idx)
if t is None:
t = torch.tensor(layer_idx, dtype=torch.int64)
_LAYER_IDX_TENSORS[layer_idx] = t
return t
class ARDiffusionPagedLayerInputs(NamedTuple):
"""Compiled-region payload for one layer's paged self-attention.
A NamedTuple of plain tensors + ints so ``torch.compile`` treats every field
as a pytree graph input (no object-attribute guards, no recompiles when only
tensor *values* change). All layers of one branch forward share the same
metadata tensor objects, built once by ``prepare()``.
``layer_idx`` is a 0-dim CPU tensor, NOT a python int: all 40 DiT blocks
share one compiled code object, and an int here becomes a per-layer dynamo
value guard (``layer_idx == k``) — 40 cache variants that blow the
recompile limit. A tensor input guards on shape/dtype only, so one graph
serves every layer.
"""
layer_idx: torch.Tensor
seq_len: int
video_slots: torch.Tensor
action_slots: torch.Tensor
block_table: torch.Tensor
query_start_loc: torch.Tensor
seq_lens: torch.Tensor
max_query_len: int
max_seq_len: int
@dataclass
class ARDiffusionPagedForwardContext:
"""Mutable branch-level state shared by all layer contexts in one forward."""
kv_cache: Any
adapter: Any
is_negative: bool
history_block_ids: list[int]
seq_len: int
commit_current: bool
max_video_tokens: int
current_video_block_ids: list[int] = field(default_factory=list)
current_video_slot_mapping: torch.Tensor | None = None
action_scratch_block_ids: list[int] = field(default_factory=list)
action_slot_mapping: torch.Tensor | None = None
query_len: int = 0
kv_len: int = 0
_allocated_video: bool = False
_committed: bool = False
_action_len: int = 0
# Set once by prepare(); shared by all layers of the branch forward.
block_table: torch.Tensor | None = None
query_start_loc: torch.Tensor | None = None
seq_lens: torch.Tensor | None = None
max_query_len: int = 0
max_seq_len: int = 0
_prepared: bool = False
@property
def block_size(self) -> int:
return int(self.kv_cache.block_size)
@property
def num_current_video_blocks(self) -> int:
if self.seq_len % self.block_size != 0:
raise AssertionError(
"AR-Diffusion paged attention expects frame-aligned seq_len "
f"(multiple of block_size={self.block_size}), got {self.seq_len}"
)
return self.seq_len // self.block_size
def ensure_video_slots(self, device: torch.device) -> None:
"""Allocate/write targets for the current video tokens, once per branch."""
if self._allocated_video:
return
n_blocks = self.num_current_video_blocks
if self.commit_current:
start = int(self.adapter.num_computed_tokens)
self.kv_cache.allocate_token_slots(self.adapter, self.seq_len)
table = self.kv_cache.block_table(self.adapter)
start_block = start // self.block_size
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)
else:
self.current_video_block_ids = self.kv_cache.scratch_block_ids(self.is_negative, 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._allocated_video = True
def ensure_action_slots(self, action_len: int, device: torch.device) -> None:
"""Reserve scratch slots for action/state K/V, if present."""
if action_len <= 0:
self.action_scratch_block_ids = []
self.action_slot_mapping = torch.empty(0, dtype=torch.long, device=device)
self._action_len = 0
return
self.ensure_video_slots(device)
if self.action_slot_mapping is not None and self._action_len == action_len:
return
action_blocks = (action_len + self.block_size - 1) // self.block_size
scratch_offset = 0 if self.commit_current else len(self.current_video_block_ids)
self.action_scratch_block_ids = self.kv_cache.scratch_block_ids(
self.is_negative,
scratch_offset,
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_len = action_len
def video_block_table(self, device: torch.device) -> tuple[list[int], int]:
self.ensure_video_slots(device)
if self.max_video_tokens % self.block_size != 0:
raise AssertionError(
"AR-Diffusion paged attention requires max_video_tokens to be block-aligned, "
f"got max_video_tokens={self.max_video_tokens}, block_size={self.block_size}"
)
all_video_blocks = self.history_block_ids + self.current_video_block_ids
max_video_blocks = self.max_video_tokens // self.block_size
visible_video_blocks = all_video_blocks[-max_video_blocks:] if max_video_blocks else []
video_len = min(len(all_video_blocks) * self.block_size, self.max_video_tokens)
return visible_video_blocks, video_len
def build_block_table(
self,
*,
action_len: int,
query_len: int,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, int]:
"""Build FlashAttention block-table metadata for one self-attn call.
The block table is tail-padded to a fixed width and ``max_seq_len`` is a
constant upper bound, so across window growth only tensor *values* change
— tensor shapes and the int consts stay stable for ``torch.compile``
(and, later, CUDA-graph capture). The kernel only dereferences the first
``ceil(seq_lens/block_size)`` entries, so padding is never read.
"""
video_blocks, video_len = self.video_block_table(device)
self.ensure_action_slots(action_len, device)
action_blocks = self.action_scratch_block_ids if action_len > 0 else []
block_ids = video_blocks + action_blocks
if not block_ids:
raise RuntimeError("AR-Diffusion paged attention needs at least current video KV blocks")
# Fixed capacity: full visible video window + one action-capacity block.
action_capacity_blocks = max(1, (action_len + self.block_size - 1) // self.block_size)
width = max(self.max_video_tokens // self.block_size + action_capacity_blocks, len(block_ids))
padded = block_ids + [0] * (width - len(block_ids))
self.query_len = int(query_len)
self.kv_len = int(video_len + action_len)
max_seq_len = int(self.max_video_tokens + action_capacity_blocks * self.block_size)
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)
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:
"""Host-side, once-per-branch-forward setup (called OUTSIDE torch.compile).
Allocates the current video/action slots (still lazy: only the branch a
CFG-parallel rank actually runs reaches its ``_forward_blocks``), builds
the padded block-table metadata ONCE for all layers, and publishes the
pool registry for the fused custom op. The compiled per-layer code then
only consumes prebuilt tensors via ``ARDiffusionPagedLayerInputs``.
"""
if getattr(self, "_prepared", False):
return
self.ensure_video_slots(device)
(
self.block_table,
self.query_start_loc,
self.seq_lens,
self.max_query_len,
self.max_seq_len,
) = 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)
set_current_paged_kv_cache(self.kv_cache)
self._prepared = True
def layer_inputs(self, layer_idx: int) -> ARDiffusionPagedLayerInputs:
if not getattr(self, "_prepared", False):
raise RuntimeError("ARDiffusionPagedForwardContext.layer_inputs() before prepare()")
return ARDiffusionPagedLayerInputs(
layer_idx=_layer_idx_tensor(layer_idx),
seq_len=int(self.seq_len),
video_slots=self.current_video_slot_mapping,
action_slots=self.action_slot_mapping,
block_table=self.block_table,
query_start_loc=self.query_start_loc,
seq_lens=self.seq_lens,
max_query_len=int(self.max_query_len),
max_seq_len=int(self.max_seq_len),
)
def mark_committed(self) -> None:
self._committed = True
@dataclass
class ARDiffusionPagedLayerContext:
"""Layer-specific handle passed through DreamZero's existing ``kv_cache`` slot."""
is_ar_diffusion_paged_context: ClassVar[bool] = True
layer_idx: int
forward_ctx: ARDiffusionPagedForwardContext
@property
def kv_cache(self):
return self.forward_ctx.kv_cache
@property
def adapter(self):
return self.forward_ctx.adapter
@property
def is_negative(self) -> bool:
return self.forward_ctx.is_negative
@property
def history_block_ids(self) -> list[int]:
return self.forward_ctx.history_block_ids
@property
def current_video_block_ids(self) -> list[int]:
return self.forward_ctx.current_video_block_ids
@property
def current_video_slot_mapping(self) -> torch.Tensor | None:
return self.forward_ctx.current_video_slot_mapping
@property
def action_scratch_block_ids(self) -> list[int]:
return self.forward_ctx.action_scratch_block_ids
@property
def action_slot_mapping(self) -> torch.Tensor | None:
return self.forward_ctx.action_slot_mapping
@property
def seq_len(self) -> int:
return self.forward_ctx.seq_len
@property
def query_len(self) -> int:
return self.forward_ctx.query_len
@property
def kv_len(self) -> int:
return self.forward_ctx.kv_len
@property
def commit_current(self) -> bool:
return self.forward_ctx.commit_current
def to_layer_inputs(self) -> ARDiffusionPagedLayerInputs:
"""Compiled-region payload; requires ``forward_ctx.prepare()`` first."""
return self.forward_ctx.layer_inputs(self.layer_idx)
def is_ar_diffusion_paged_context(value: object) -> bool:
return isinstance(value, ARDiffusionPagedLayerContext)
def _reference_paged_attention(
query: torch.Tensor,
key_cache: torch.Tensor,
value_cache: torch.Tensor,
block_table: torch.Tensor,
query_start_loc: torch.Tensor,
seq_lens: torch.Tensor,
softmax_scale: float,
*,
causal: bool,
) -> torch.Tensor:
if causal:
raise NotImplementedError("AR-Diffusion paged self-attention uses causal=False")
outs: list[torch.Tensor] = []
block_size = key_cache.shape[1]
for i in range(seq_lens.shape[0]):
q_start = int(query_start_loc[i].item())
q_end = int(query_start_loc[i + 1].item())
kv_len = int(seq_lens[i].item())
q = query[q_start:q_end]
positions = torch.arange(kv_len, device=query.device)
logical_blocks = torch.div(positions, block_size, rounding_mode="floor")
offsets = positions % block_size
physical_blocks = block_table[i, logical_blocks].long()
k = key_cache[physical_blocks, offsets]
v = value_cache[physical_blocks, offsets]
scores = torch.einsum("qhd,khd->hqk", q.float(), k.float()) * float(softmax_scale)
probs = torch.softmax(scores, dim=-1).to(v.dtype)
outs.append(torch.einsum("hqk,khd->qhd", probs, v))
return torch.cat(outs, dim=0)
_FA_VERSION_BY_HEAD_SIZE: dict[int, int] = {}
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.
version = _FA_VERSION_BY_HEAD_SIZE.get(head_size)
if version is None:
try:
from vllm.v1.attention.backends.fa_utils import get_flash_attn_version
version = int(get_flash_attn_version(requires_alibi=False, head_size=head_size) or 2)
except Exception:
version = 2
_FA_VERSION_BY_HEAD_SIZE[head_size] = version
return version
def ar_diffusion_paged_attention(
query: torch.Tensor,
key_cache: torch.Tensor,
value_cache: torch.Tensor,
*,
block_table: torch.Tensor,
query_start_loc: torch.Tensor,
seq_lens: torch.Tensor,
max_query_len: int,
max_seq_len: int,
softmax_scale: float,
causal: bool = False,
) -> torch.Tensor:
"""Run non-causal paged attention over a vLLM block table.
``query`` may be ``(B, L, H, D)`` or already flattened as ``(T, H, D)``.
``key_cache`` / ``value_cache`` are ``(num_blocks, block_size, H, D)``.
"""
batched = query.dim() == 4
if batched:
batch, q_len = query.shape[:2]
query_flat = query.reshape(batch * q_len, *query.shape[2:])
else:
query_flat = query
if not query_flat.is_cuda:
out = _reference_paged_attention(
query_flat,
key_cache,
value_cache,
block_table,
query_start_loc,
seq_lens,
softmax_scale,
causal=causal,
)
else:
from vllm.vllm_flash_attn import flash_attn_varlen_func
fa_version = _resolve_fa_version(query_flat.shape[-1])
out = torch.empty_like(query_flat)
flash_attn_varlen_func(
q=query_flat,
k=key_cache,
v=value_cache,
out=out,
cu_seqlens_q=query_start_loc,
max_seqlen_q=int(max_query_len),
seqused_k=seq_lens,
max_seqlen_k=int(max_seq_len),
softmax_scale=float(softmax_scale),
causal=causal,
block_table=block_table,
fa_version=fa_version,
)
if batched:
return out.reshape(query.shape)
return out
# ── Fused write+attend custom op (torch.compile-safe) ──────────────────────
#
# One opaque op per layer keeps the compiled DiT block fullgraph: dynamo treats
# it as a single graph node (no eager island, no graph breaks), and the K/V slot
# writes happen inside the op so write→read ordering with the block-table kernel
# is internal — no hidden-mutation ordering hazards against a separate reader op
# and no multi-GiB pool tensors as graph inputs (the flat and paged pool views
# alias the same storage, which AOTAutograd handles poorly as inputs). Pattern
# follows sage_attn3.py in-repo and vLLM's own unified attention ops.
def _paged_write_attn_impl(
query: torch.Tensor,
k_curr: torch.Tensor,
v_curr: torch.Tensor,
k_act: torch.Tensor | None,
v_act: torch.Tensor | None,
layer_idx: torch.Tensor,
video_slots: torch.Tensor,
action_slots: torch.Tensor,
block_table: torch.Tensor,
query_start_loc: torch.Tensor,
seq_lens: torch.Tensor,
max_query_len: int,
max_seq_len: int,
softmax_scale: float,
) -> torch.Tensor:
kv = _CURRENT_PAGED_KV_CACHE
if kv is None:
raise RuntimeError("ar_diffusion_paged_write_attn called before prepare() set the KV pool registry")
layer_idx = int(layer_idx)
k_pool = kv._k_pools[layer_idx]
v_pool = kv._v_pools[layer_idx]
k_pool[video_slots] = k_curr.to(k_pool.dtype)
v_pool[video_slots] = v_curr.to(v_pool.dtype)
if k_act is not None and v_act is not None and k_act.shape[0] > 0:
k_pool[action_slots] = k_act.to(k_pool.dtype)
v_pool[action_slots] = v_act.to(v_pool.dtype)
return ar_diffusion_paged_attention(
query,
kv.key_cache(layer_idx),
kv.value_cache(layer_idx),
block_table=block_table,
query_start_loc=query_start_loc,
seq_lens=seq_lens,
max_query_len=max_query_len,
max_seq_len=max_seq_len,
softmax_scale=softmax_scale,
causal=False,
)
# 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"):
@torch.library.custom_op("vllm_omni::ar_diffusion_paged_write_attn", mutates_args=())
def _paged_write_attn_op(
query: torch.Tensor,
k_curr: torch.Tensor,
v_curr: torch.Tensor,
k_act: torch.Tensor | None,
v_act: torch.Tensor | None,
layer_idx: torch.Tensor,
video_slots: torch.Tensor,
action_slots: torch.Tensor,
block_table: torch.Tensor,
query_start_loc: torch.Tensor,
seq_lens: torch.Tensor,
max_query_len: int,
max_seq_len: int,
softmax_scale: float,
) -> torch.Tensor:
return _paged_write_attn_impl(
query,
k_curr,
v_curr,
k_act,
v_act,
layer_idx,
video_slots,
action_slots,
block_table,
query_start_loc,
seq_lens,
max_query_len,
max_seq_len,
softmax_scale,
)
@_paged_write_attn_op.register_fake
def _(
query,
k_curr,
v_curr,
k_act,
v_act,
layer_idx,
video_slots,
action_slots,
block_table,
query_start_loc,
seq_lens,
max_query_len,
max_seq_len,
softmax_scale,
):
return torch.empty_like(query)
def paged_write_attn(
inputs: ARDiffusionPagedLayerInputs, query, k_curr, v_curr, k_act, v_act, softmax_scale: float
) -> torch.Tensor:
"""Model-facing entry: routes through the custom op (traceable in fullgraph)."""
return torch.ops.vllm_omni.ar_diffusion_paged_write_attn(
query,
k_curr,
v_curr,
k_act,
v_act,
inputs.layer_idx,
inputs.video_slots,
inputs.action_slots,
inputs.block_table,
inputs.query_start_loc,
inputs.seq_lens,
inputs.max_query_len,
inputs.max_seq_len,
softmax_scale,
)
@@ -0,0 +1,183 @@
# SPDX-License-Identifier: Apache-2.0
"""DreamZero-facing AR-Diffusion KV bridge backed by paged self-attention."""
from __future__ import annotations
from vllm.logger import init_logger
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import (
ARDiffusionPagedForwardContext,
ARDiffusionPagedLayerContext,
)
_log = init_logger(__name__)
class ARDiffusionKVState:
"""Per-request bridge between a DreamZero rollout and the AR-Diffusion KV pool.
The self-attention path is paged-only: ``get_kv_caches`` returns lightweight
per-layer contexts, and each attention layer writes current K/V into the
pool before calling the FlashAttention block-table kernel. No resident
window is gathered into a contiguous tensor.
"""
def __init__(self, kv_cache, pos_adapter, neg_adapter, num_layers: int) -> None:
"""One state per DreamZero session, holding BOTH CFG branches' adapters.
``pos``/``neg`` are the classifier-free-guidance branches: ``pos`` is
the conditional pass (real prompt), ``neg`` the unconditional pass
(negative prompt). Each owns an independent request adapter — and
therefore independent pool blocks — because the two branches produce
different K/V for the same frames. Every ``is_negative`` flag in this
module selects between them (``False`` -> ``pos``, ``True`` -> ``neg``).
"""
self.kv_cache = kv_cache
self.pos = pos_adapter
self.neg = neg_adapter
self.num_layers = num_layers
self._committed: dict[bool, int] = {False: 0, True: 0}
self._paged_pending: dict[bool, ARDiffusionPagedForwardContext | None] = {False: None, True: None}
# Cross-attn KV is populated once after text encoding (eager), then
# read every denoising step. Tracked per half: the text K/V depend only
# on the session prompt (survive window-boundary resets), while the I2V
# image-token K/V come from the current observation's CLIP features and
# must be re-projected on every window restart.
self._cross_text_populated: dict[bool, bool] = {False: False, True: False}
self._cross_img_populated: dict[bool, bool] = {False: False, True: False}
def _adapter(self, is_negative: bool):
return self.neg if is_negative else self.pos
def get_kv_caches(
self,
is_negative: bool,
seq_len: int | None = None,
commit_current: bool = False,
) -> list[ARDiffusionPagedLayerContext]:
if seq_len is None:
raise ValueError("AR-Diffusion paged self-attention requires seq_len in get_kv_caches()")
return self.prepare_paged_context(is_negative, seq_len, commit_current)
def prepare_paged_context(
self,
is_negative: bool,
seq_len: int,
commit_current: bool,
) -> list[ARDiffusionPagedLayerContext]:
"""Return per-layer paged attention contexts for one branch forward.
Allocation is lazy: constructing both CFG branches' kwargs must not
allocate manager blocks for a branch that a CFG-parallel rank will not run.
The first self-attn layer that consumes the context allocates the current
video slots, then all layers share that branch-level state.
"""
cs = self.kv_cache.spec.chunk_size
if int(seq_len) % cs != 0:
raise AssertionError(
f"AR-Diffusion expects frame-aligned seq_len (multiple of chunk_size={cs}), got {seq_len}"
)
pending = self._paged_pending.get(is_negative)
if pending is not None and pending.commit_current and pending._allocated_video and not pending._committed:
raise RuntimeError("AR-Diffusion paged context replaced before its managed current chunk was committed")
adapter = self._adapter(is_negative)
branch = "neg" if is_negative else "pos"
forward_ctx = ARDiffusionPagedForwardContext(
kv_cache=self.kv_cache,
adapter=adapter,
is_negative=is_negative,
history_block_ids=self.kv_cache.window_block_ids(adapter),
seq_len=int(seq_len),
commit_current=bool(commit_current),
max_video_tokens=int(self.kv_cache.spec.sliding_window),
)
self._paged_pending[is_negative] = forward_ctx
_log.debug(
"AR-Diffusion GET [%s] source=paged-attn layers=%d history_blocks=%d seq_len=%d commit_current=%s",
branch,
self.num_layers,
len(forward_ctx.history_block_ids),
int(seq_len),
bool(commit_current),
)
return [ARDiffusionPagedLayerContext(layer_idx=i, forward_ctx=forward_ctx) for i in range(self.num_layers)]
def commit_paged_context(self, is_negative: bool) -> None:
"""Commit the managed current video blocks after a successful forward."""
ctx = self._paged_pending.get(is_negative)
if ctx is None:
return
branch = "neg" if is_negative else "pos"
if ctx.commit_current and ctx._allocated_video:
n_chunks = ctx.seq_len // self.kv_cache.spec.chunk_size
for _ in range(n_chunks):
ctx.adapter.on_chunk_committed()
self._committed[is_negative] += ctx.seq_len
_log.debug(
"AR-Diffusion COMMIT [%s] paged-attn new_tokens=%d chunks=%d resident=%d/%d",
branch,
ctx.seq_len,
n_chunks,
len(self.kv_cache.window_block_ids(ctx.adapter)),
self.kv_cache.spec.window_chunks,
)
ctx.mark_committed()
self._paged_pending[is_negative] = None
def get_cross_kv_caches(self, is_negative: bool) -> list[dict]:
"""Return pool-backed cross-attn cache dicts.
Under AR-Diffusion the cross-attn pool is always populated by ``_kv_populate_cross``
before the first read, so this must never be reached unpopulated — if it
were, the model would lazily project and write cross KV itself, violating
engine ownership. Fail loud rather than fall back to the model.
"""
img_ok = not self.kv_cache._cross_k_img or self._cross_img_populated[is_negative]
if not (self._cross_text_populated[is_negative] and img_ok and self.kv_cache.cross_attn_length > 0):
raise RuntimeError(
f"AR-Diffusion cross-attn read before _kv_populate_cross (neg={is_negative}, "
f"cross_attn_length={self.kv_cache.cross_attn_length}, "
f"text={self._cross_text_populated[is_negative]} "
f"img={self._cross_img_populated[is_negative]}) — the engine must own all cross KV"
)
return [self.kv_cache.read_cross_kv(i, is_negative) for i in range(self.num_layers)]
def close(self) -> None:
"""Final teardown: free both branches' resident pool blocks."""
self.kv_cache.end_request(self.pos)
self.kv_cache.end_request(self.neg)
self._paged_pending = {False: None, True: None}
def reset(self, *, keep_cross_text: bool = False) -> None:
"""Drop this session's KV — mirrors the model-local ``state.reset()``.
DreamZero resets at the attention-window boundary (``should_reset``); the
model starts a fresh sliding window, so AR-Diffusion must free the resident pool
blocks and start a fresh adapter for each branch. The ``ARDiffusionKVState``
object (and thus ``pipeline._ar_diffusion_kv_state``) is preserved across the reset
so the runner's session mapping stays valid — only the pool-backed state
is recycled.
Args:
keep_cross_text: On window ("inference") resets the session prompt is
unchanged, so the text cross-attn K/V in the static pool buffers are
still valid — keep them and only force the image half to repopulate.
Session resets (prompt change / explicit reset) pass ``False``.
"""
pos_id, neg_id = self.pos.request_id, self.neg.request_id
self.close()
self.pos = self.kv_cache.begin_request(pos_id)
self.neg = self.kv_cache.begin_request(neg_id)
self._committed = {False: 0, True: 0}
self._paged_pending = {False: None, True: None}
if not keep_cross_text:
self._cross_text_populated = {False: False, True: False}
self._cross_img_populated = {False: False, True: False}
_log.info(
"AR-Diffusion RESET [%s/%s] session KV cleared (window boundary; cross text %s)",
pos_id,
neg_id,
"kept" if keep_cross_text else "cleared",
)
@@ -0,0 +1,409 @@
# SPDX-License-Identifier: Apache-2.0
"""ARDiffusionModelRunner — the diffusion model runner for the AR-Diffusion engine.
Subclasses ``DiffusionModelRunner`` and owns the engine-level KV cache
(:class:`ARDiffusionKVCache`). It brackets a request's rollout with the KV lifecycle
(``begin_request`` / ``end_request``) and exposes the live ``ARDiffusionKVCache`` so the
model pipeline (DreamZero) can drive the per-chunk allocate / slot-mapping /
gather / commit operations during ``pipeline.forward``.
Selecting the AR-Diffusion engine enables KV management (no env gate); optional KV
parameters come from the deploy yaml's ``model_config["ar_diffusion_kv_config"]``.
"""
from __future__ import annotations
import dataclasses
import math
import time
from collections import OrderedDict
import numpy as np
import torch
from vllm.logger import init_logger
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import MAX_DREAMZERO_SESSIONS
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVConfig
from vllm_omni.experimental.ar_diffusion.kv_cache.manager import ARDiffusionKVCache
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
logger = init_logger(__name__)
def resolve_ar_diffusion_kv_config(od_config: OmniDiffusionConfig) -> ARDiffusionKVConfig:
"""Resolve the AR-Diffusion KV config from the deploy/command arg.
Selecting the AR-Diffusion engine implies KV management, so the result is always
enabled. Optional overrides (``window_chunks`` / ``gpu_memory_fraction`` /
``sink_chunks`` / ``reset_at_boundary``) may be supplied via the deploy yaml as
``model_config["ar_diffusion_kv_config"]``. ``chunk_size`` / ``window_chunks`` are
finalized from the model geometry at load (see ``_preallocate_kv_cache``).
"""
raw = getattr(od_config, "ar_diffusion_kv_config", None)
if raw is None:
model_config = getattr(od_config, "model_config", None)
if isinstance(model_config, dict):
raw = model_config.get("ar_diffusion_kv_config")
if isinstance(raw, ARDiffusionKVConfig):
return dataclasses.replace(raw, enable=True)
if isinstance(raw, dict):
return ARDiffusionKVConfig(**{**raw, "enable": True})
return ARDiffusionKVConfig(enable=True)
class ARDiffusionModelRunner(DiffusionModelRunner):
def __init__(self, vllm_config, od_config: OmniDiffusionConfig, device) -> None:
super().__init__(vllm_config, od_config, device)
self.ar_diffusion_kv_config = resolve_ar_diffusion_kv_config(od_config)
# Built after the model is loaded (dimensions known); stays None while KV
# management is disabled.
self.kv_cache: ARDiffusionKVCache | None = None
# DreamZero KV is session-scoped (persists across a session's forwards),
# so AR-Diffusion KV state is keyed by session_id and reused, not created per request.
# Bounded by the same LRU cap as DreamZeroPipeline._states: an evicted
# session's ARDiffusionKVState owns pool blocks (two CFG adapters), so without a
# bound, session-id churn in a long-running server would leak pool ownership
# until the KV pool is exhausted. Evicting frees those blocks (state.close()).
self._ar_diffusion_states: OrderedDict[str, ARDiffusionKVState] = OrderedDict()
self._max_ar_diffusion_states = MAX_DREAMZERO_SESSIONS
# Per-request server-side forward E2E times (seconds), recorded in
# execute_model. This is the worker-side compute time — the analog of the
# upstream server's per-request E2E — and excludes the engine<->worker IPC
# that the client's omni.generate wall time includes. Read (and cleared)
# by the ar_diffusion_perf_stats worker RPC for the perf-compare summary.
self._perf_e2e_times: list[float] = []
def build_kv_cache(
self,
*,
num_layers: int,
num_kv_heads: int,
head_size: int,
dtype,
block_size: int,
max_model_len: int,
available_bytes: int,
cross_attn_length: int = 0,
cross_attn_img_length: int = 0,
local_branches: int = 2,
num_frame_per_block: int = 1,
) -> None:
"""Construct the ARDiffusionKVCache (preallocating GPU pools on ``self.device``).
No-op when KV management is disabled.
"""
if not self.ar_diffusion_kv_config.enable:
return
self.kv_cache = ARDiffusionKVCache(
self.ar_diffusion_kv_config,
num_layers=num_layers,
num_kv_heads=num_kv_heads,
head_size=head_size,
dtype=dtype,
block_size=block_size,
max_model_len=max_model_len,
available_bytes=available_bytes,
cross_attn_length=cross_attn_length,
cross_attn_img_length=cross_attn_img_length,
local_branches=local_branches,
num_frame_per_block=num_frame_per_block,
device=self.device,
)
logger.info(
"AR-Diffusion KV cache enabled: %d blocks, chunk_size=%d, window_chunks=%s, "
"layers=%d kv_heads=%d head_dim=%d block_size=%d cross_attn_len=%d device=%s",
self.kv_cache.num_blocks,
self.ar_diffusion_kv_config.chunk_size,
self.ar_diffusion_kv_config.window_chunks,
num_layers,
num_kv_heads,
head_size,
block_size,
cross_attn_length,
self.device,
)
# -- preallocation at load -------------------------------------------------
def load_model(self, *args, **kwargs):
super().load_model(*args, **kwargs)
if self.ar_diffusion_kv_config.enable and self.pipeline is not None:
self._preallocate_kv_cache()
# Pre-capture the CUDA graphs for every window-fill shape at load time
# (only when compiling/cuda-graph is on) so serving is fast from chunk 0.
if not self.od_config.enforce_eager and self.ar_diffusion_kv_config.warmup_cudagraph:
self._warmup_ar_rollout()
def _infer_frame_seqlen(self) -> int:
"""frame_seqlen = (H//8)*(W//8)//4 from the configured image_resolution."""
mc = getattr(self.od_config, "model_config", None) or {}
psc = (mc.get("policy_server_config") if isinstance(mc, dict) else None) or {}
res = psc.get("image_resolution", [180, 320])
h, w = int(res[0]), int(res[1])
return (h // 8) * (w // 8) // 4
def _preallocate_kv_cache(self) -> None:
"""Build the KV pool once, from the loaded DreamZero transformer geometry."""
# The paged self-attention path carries one rollout per request: slot
# mappings/adapters are single-sequence and K/V writes address batch
# index 0 only. Reject a multi-sequence config at init instead of
# failing (or silently dropping sequences) mid-forward.
max_num_seqs = int(getattr(self.od_config, "max_num_seqs", 1) or 1)
if max_num_seqs > 1:
raise ValueError(
"AR-Diffusion paged KV supports max_num_seqs=1 (single-sequence "
f"rollouts); got max_num_seqs={max_num_seqs}. Per-sequence "
"adapters/slot maps are not implemented."
)
t = self.pipeline.transformer
num_layers = int(t.num_layers)
num_kv_heads = int(getattr(t.blocks[0].self_attn, "tp_num_heads", t.num_heads))
head_size = int(t.dim // t.num_heads)
num_frame_per_block = int(t.num_frame_per_block)
local_attn_size = int(t.local_attn_size)
# The loaded transformer is the source of truth for frame_seqlen (it derives
# max_attention_size from it). The deploy-config inference is only a
# cross-check — warn on drift rather than silently sizing chunks wrong.
frame_seqlen = int(getattr(t, "frame_seqlen", 0)) or self._infer_frame_seqlen()
inferred = self._infer_frame_seqlen()
if frame_seqlen != inferred:
logger.warning(
"AR-Diffusion frame_seqlen mismatch: transformer=%d deploy-config=%d; using transformer",
frame_seqlen,
inferred,
)
# The model's own attention window, in tokens (= the [-max_attention_size:]
# slice it applies). Read it directly rather than recomputing.
max_attention_size = int(t.blocks[0].self_attn.max_attention_size)
# Cross-attn text length — static pool for the text-encoder output.
# The cached k/v spans the full text sequence (the image tokens
# prepended in _forward_blocks are stripped inside the cross-attn
# forward but are not part of the cached k/v).
cross_attn_length = int(getattr(t, "text_len", 0))
# I2V image-token cross-attn length: the model splits context[:, :257] as the
# image tokens (the img_emb(clip_feature) output), caching k_img/v_img. T2V: 0.
cross_attn_img_length = 257 if getattr(t, "model_type", "t2v") == "i2v" else 0
# Frame-granular paging: 1 block = 1 frame = frame_seqlen tokens, so the
# resident window matches max_attention_size exactly (it need not be a whole
# number of num_frame_per_block causal blocks).
chunk_size = frame_seqlen
window_chunks = self.ar_diffusion_kv_config.window_chunks or (max_attention_size // frame_seqlen)
self.ar_diffusion_kv_config = dataclasses.replace(
self.ar_diffusion_kv_config, chunk_size=chunk_size, window_chunks=window_chunks
)
free_bytes = torch.cuda.mem_get_info(self.device)[0]
# Under CFG-parallel each rank executes exactly ONE branch (rank0 pos,
# rank1 neg; the other branch's lazy contexts never allocate on this
# rank), so its pool only needs one branch's capacity. A single-process
# run (cfg world 1) executes both branches from one pool.
try:
from vllm_omni.diffusion.distributed.parallel_state import (
get_classifier_free_guidance_world_size,
)
cfg_world = int(get_classifier_free_guidance_world_size())
except Exception:
cfg_world = 1
local_branches = 1 if cfg_world >= 2 else 2
logger.info(
"AR-Diffusion preallocating (paged): frame_seqlen=%d num_frame_per_block=%d "
"local_attn_size=%d -> chunk_size=%d window_chunks=%d (window=%d tokens)",
frame_seqlen,
num_frame_per_block,
local_attn_size,
chunk_size,
window_chunks,
window_chunks * chunk_size,
)
self.build_kv_cache(
num_layers=num_layers,
num_kv_heads=num_kv_heads,
head_size=head_size,
dtype=self.od_config.dtype,
block_size=chunk_size, # one pool block per chunk
max_model_len=1 << 20,
available_bytes=free_bytes,
cross_attn_length=cross_attn_length,
cross_attn_img_length=cross_attn_img_length,
local_branches=local_branches,
num_frame_per_block=num_frame_per_block,
)
def execute_model(self, req: OmniDiffusionRequest) -> DiffusionOutput:
# KV disabled -> base behavior, unchanged.
if self.kv_cache is None:
return super().execute_model(req)
kv = self.kv_cache
# DreamZero KV is session-scoped (the model state persists across a
# session's forwards), so the AR-Diffusion KV state is keyed by session_id and
# reused — matching how pipeline.forward resolves the model-local state.
extra_args = req.sampling_params.extra_args or {}
session_id = str(extra_args.get("session_id") or "default")
state = self._ar_diffusion_states.get(session_id)
if state is None:
pos = kv.begin_request(f"bde__{session_id}")
neg = kv.begin_request(f"bde__{session_id}__neg")
state = ARDiffusionKVState(kv, pos, neg, num_layers=kv.num_layers)
self._ar_diffusion_states[session_id] = state
# Evict the least-recently-used session(s) past the cap, freeing their
# pool blocks — mirrors DreamZeroPipeline._states so the AR-Diffusion pool can't
# outlive the model-local state map it shadows.
while len(self._ar_diffusion_states) > self._max_ar_diffusion_states:
old_id, old_state = self._ar_diffusion_states.popitem(last=False)
old_state.close()
logger.debug("AR-Diffusion evicted session=%s (LRU); freed pool blocks", old_id)
# Track recency on every access (hit or miss) so the LRU order is correct.
self._ar_diffusion_states.move_to_end(session_id)
logger.debug(
"AR-Diffusion execute_model: req=%s session=%s chunk_size=%d window_chunks=%s num_blocks=%d",
req.request_id,
session_id,
kv.spec.chunk_size,
kv.config.window_chunks,
kv.num_blocks,
)
self.pipeline._ar_diffusion_kv_state = state
# Time the worker-side forward (true per-request compute E2E). The CUDA
# sync below makes the stop reflect completed GPU work, not just dispatch.
_e2e_t0 = time.perf_counter()
try:
out = super().execute_model(req)
except Exception:
# Transactional containment: a forward that died partway may have
# written some layers' K/V into allocated-but-uncommitted blocks
# and advanced model-local state. Tear the whole session down
# (engine pool blocks freed via close(); model-local state dropped)
# so the next request with this session id starts clean instead of
# tripping the pending-commit guard or reading half-written KV.
broken = self._ar_diffusion_states.pop(session_id, None)
if broken is not None:
broken.close()
states = getattr(self.pipeline, "_states", None)
if states is not None:
states.pop(session_id, None)
logger.warning(
"AR-Diffusion execute_model failed for session=%s; session state torn down "
"(pool blocks freed) — the next request starts a fresh session",
session_id,
)
raise
finally:
self.pipeline._ar_diffusion_kv_state = None
if self.device is not None and torch.cuda.is_available():
torch.accelerator.synchronize(self.device)
self._perf_e2e_times.append(time.perf_counter() - _e2e_t0)
return out
# -- cuda-graph warm-up ----------------------------------------------------
#: Dedicated session id for the warm-up rollout; cleaned from both state maps.
_WARMUP_SID = "__ardiffusion_warmup__"
def _synth_robot_obs(self, h: int, w: int, n_frames: int) -> dict:
"""Zero-filled roboarena observation matching the client's obs layout.
Mirrors ``examples/.../client_schedule.make_obs_from_video`` (2 ext + wrist
cameras, 7/6/1 state vectors) so it survives ``_transform_robot_obs`` exactly
like a real request. One frame -> ``(H,W,3)``; chunk -> ``(n_frames,H,W,3)``.
"""
img = np.zeros((h, w, 3), dtype=np.uint8) if n_frames == 1 else np.zeros((n_frames, h, w, 3), dtype=np.uint8)
return {
"observation/exterior_image_0_left": img,
"observation/exterior_image_1_left": img,
"observation/wrist_image_left": img,
"observation/joint_position": np.zeros(7, dtype=np.float32),
"observation/cartesian_position": np.zeros(6, dtype=np.float32),
"observation/gripper_position": np.zeros(1, dtype=np.float32),
"prompt": "warmup",
"session_id": self._WARMUP_SID,
}
def _warmup_ar_rollout(self) -> None:
"""Drive a synthetic rollout through one window-fill so every DiT graph is
captured at load (off the serving hot path). Frees the synthetic session and
leaves the KV pool exactly as found. Never raises — falls back to lazy capture.
"""
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
kv = self.kv_cache
if kv is None:
return
sid = self._WARMUP_SID
t = self.pipeline.transformer
nfpb = int(t.num_frame_per_block)
window_chunks = int(kv.config.window_chunks or 0)
if window_chunks <= 0:
return
# Distinct DiT window shapes are walked by one fill: prefill + ceil((W-1)/nfpb)
# chunk forwards. +1 optionally to also capture the reset-cycle forward.
n_forwards = 1 + math.ceil(max(0, window_chunks - 1) / nfpb)
if self.ar_diffusion_kv_config.warmup_capture_reset:
n_forwards += 1
res = self._image_resolution()
h, w = int(res[0]), int(res[1])
free_before = kv.manager.block_pool.get_num_free_blocks()
# The step cache is left active: the DiT input shape is per-chunk (the resident
# window length), and the first denoise step of every chunk always computes, so
# every window shape is captured regardless of how many later steps are skipped.
logger.info(
"AR-Diffusion cudagraph warm-up: up to %d synthetic forwards (window_chunks=%d, nfpb=%d)",
n_forwards,
window_chunks,
nfpb,
)
try:
for i in range(n_forwards):
n_frames = 1 if i == 0 else 4 # client convention: 1-frame prefill, 4-frame chunks
obs = self._synth_robot_obs(h, w, n_frames)
sp = OmniDiffusionSamplingParams(extra_args={"reset": i == 0, "session_id": sid, "robot_obs": obs})
req = OmniDiffusionRequest(prompts=["warmup"], sampling_params=sp, request_id=f"ardiffusion-warmup-{i}")
self.execute_model(req)
# Stop once the resident window is full — the remaining shapes are
# already captured (the window caps/resets through the same set).
ar_state = self._ar_diffusion_states.get(sid)
if (
not self.ar_diffusion_kv_config.warmup_capture_reset
and ar_state is not None
and len(kv.window_block_ids(ar_state.pos)) >= window_chunks
):
break
except Exception as e: # noqa: BLE001 — warm-up must never break model load
logger.warning("AR-Diffusion cudagraph warm-up failed (%s); using lazy capture.", e)
finally:
# Free the synthetic session from BOTH maps (KV pool + pipeline state).
ar_state = self._ar_diffusion_states.pop(sid, None)
if ar_state is not None:
try:
ar_state.close() # return KV blocks to the BlockPool
except Exception as e: # noqa: BLE001
logger.warning("AR-Diffusion warm-up: state.close() failed (%s)", e)
try:
self.pipeline._states.pop(sid, None)
except Exception: # noqa: BLE001
pass
# Drop any per-forward timings the warm-up recorded so serving stats start clean.
self._perf_e2e_times.clear()
free_after = kv.manager.block_pool.get_num_free_blocks()
if free_after != free_before:
logger.warning(
"AR-Diffusion warm-up: KV pool not restored (free %d -> %d) — possible leak",
free_before,
free_after,
)
else:
logger.info(
"AR-Diffusion cudagraph warm-up complete; KV pool restored (%d free blocks)",
free_after,
)
def _image_resolution(self) -> list[int]:
mc = getattr(self.od_config, "model_config", None) or {}
psc = (mc.get("policy_server_config") if isinstance(mc, dict) else None) or {}
return psc.get("image_resolution", [180, 320])
@@ -14,7 +14,7 @@ Shared by Qwen3-Omni and Qwen3-TTS talker models.
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
from collections.abc import Iterable from collections.abc import Iterable, Sequence
import torch import torch
import torch.nn as nn import torch.nn as nn
@@ -742,6 +742,27 @@ class CodePredictorWrapper(nn.Module):
# Forward -- re-prefill + inline sampling # Forward -- re-prefill + inline sampling
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod
def _multinomial(
probs: torch.Tensor,
generator: torch.Generator | None,
generators: Sequence[torch.Generator | None] | None,
) -> torch.Tensor:
"""Sample one code per row, optionally with per-row generators.
Per-row generators keep explicitly-seeded requests deterministic in a
multi-row batch: each row consumes draws only from its own generator,
so the transformer forward can stay batched (#4883).
"""
if generators is None:
return torch.multinomial(probs, num_samples=1, generator=generator)
return torch.cat(
[
torch.multinomial(probs[row : row + 1], num_samples=1, generator=row_generator)
for row, row_generator in enumerate(generators)
]
)
@torch.inference_mode() @torch.inference_mode()
def forward( def forward(
self, self,
@@ -753,9 +774,12 @@ class CodePredictorWrapper(nn.Module):
top_k: int = 50, top_k: int = 50,
top_p: float = 1.0, top_p: float = 1.0,
generator: torch.Generator | None = None, generator: torch.Generator | None = None,
generators: Sequence[torch.Generator | None] | None = None,
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
"""Predict residual codebooks 1..G-1 autoregressively via re-prefill.""" """Predict residual codebooks 1..G-1 autoregressively via re-prefill."""
bsz = int(layer0_code.shape[0]) bsz = int(layer0_code.shape[0])
if generators is not None and len(generators) != bsz:
raise ValueError(f"generators must have one entry per row: got {len(generators)} for batch {bsz}")
num_groups = self._num_groups num_groups = self._num_groups
device = layer0_code.device device = layer0_code.device
@@ -846,7 +870,7 @@ class CodePredictorWrapper(nn.Module):
sorted_logits[remove_mask] = float("-inf") sorted_logits[remove_mask] = float("-inf")
logits = sorted_logits.scatter(1, sorted_idx, sorted_logits) logits = sorted_logits.scatter(1, sorted_idx, sorted_logits)
probs = F.softmax(logits, dim=-1, dtype=torch.float32) probs = F.softmax(logits, dim=-1, dtype=torch.float32)
code = torch.multinomial(probs, num_samples=1, generator=generator) code = self._multinomial(probs, generator, generators)
else: else:
# "per_call" mode: temperature-scaled + top-k # "per_call" mode: temperature-scaled + top-k
if use_sampling: if use_sampling:
@@ -855,7 +879,7 @@ class CodePredictorWrapper(nn.Module):
topk_vals, _ = scaled.topk(top_k, dim=-1) topk_vals, _ = scaled.topk(top_k, dim=-1)
scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf")) scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf"))
probs = F.softmax(scaled, dim=-1, dtype=torch.float32) probs = F.softmax(scaled, dim=-1, dtype=torch.float32)
code = torch.multinomial(probs, num_samples=1, generator=generator) code = self._multinomial(probs, generator, generators)
else: else:
code = logits.argmax(dim=-1, keepdim=True) code = logits.argmax(dim=-1, keepdim=True)
+20 -4
View File
@@ -22,6 +22,7 @@ generation.
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re import re
from typing import Any from typing import Any
@@ -170,14 +171,25 @@ _AUDIO_TOKENIZER_PATH_ENVS = (
) )
def _is_higgs_audio_tokenizer_config(config_path: str) -> bool:
try:
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
except (OSError, json.JSONDecodeError):
return False
return config.get("model_type") == "higgs_audio_v2_tokenizer"
def _normalize_audio_tokenizer_dir(path: str) -> str | None: def _normalize_audio_tokenizer_dir(path: str) -> str | None:
if not path: if not path:
return None return None
expanded = os.path.abspath(os.path.expanduser(path)) expanded = os.path.abspath(os.path.expanduser(path))
if os.path.isfile(os.path.join(expanded, "config.json")): config_path = os.path.join(expanded, "config.json")
if os.path.isfile(config_path) and _is_higgs_audio_tokenizer_config(config_path):
return expanded return expanded
nested = os.path.join(expanded, _K2_OMNIVOICE_SUBDIR) nested = os.path.join(expanded, _K2_OMNIVOICE_SUBDIR)
if os.path.isfile(os.path.join(nested, "config.json")): nested_config_path = os.path.join(nested, "config.json")
if os.path.isfile(nested_config_path) and _is_higgs_audio_tokenizer_config(nested_config_path):
return nested return nested
return None return None
@@ -201,7 +213,9 @@ def _resolve_audio_tokenizer_dir() -> str | None:
filename=f"{_K2_OMNIVOICE_SUBDIR}/config.json", filename=f"{_K2_OMNIVOICE_SUBDIR}/config.json",
) )
if isinstance(cached_config, str) and os.path.isfile(cached_config): if isinstance(cached_config, str) and os.path.isfile(cached_config):
return os.path.dirname(cached_config) candidate = _normalize_audio_tokenizer_dir(os.path.dirname(cached_config))
if candidate is not None:
return candidate
from huggingface_hub.constants import HF_HUB_CACHE from huggingface_hub.constants import HF_HUB_CACHE
@@ -240,7 +254,9 @@ def _load_audio_tokenizer():
_K2_OMNIVOICE_REPO, _K2_OMNIVOICE_REPO,
allow_patterns=[f"{_K2_OMNIVOICE_SUBDIR}/*"], allow_patterns=[f"{_K2_OMNIVOICE_SUBDIR}/*"],
) )
audio_tokenizer_dir = os.path.join(repo_path, _K2_OMNIVOICE_SUBDIR) audio_tokenizer_dir = _normalize_audio_tokenizer_dir(repo_path)
if audio_tokenizer_dir is None:
raise RuntimeError(f"Downloaded {_K2_OMNIVOICE_REPO} does not contain a valid Higgs audio tokenizer")
device = current_omni_platform.get_torch_device() device = current_omni_platform.get_torch_device()
model = HiggsAudioV2TokenizerModel.from_pretrained(audio_tokenizer_dir).to(device) model = HiggsAudioV2TokenizerModel.from_pretrained(audio_tokenizer_dir).to(device)
return model.eval() return model.eval()
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping, Sequence
from typing import Any from typing import Any
import numpy as np import numpy as np
@@ -318,6 +318,12 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
# OmniGPUModelRunner will store talker_mtp output under this key in # OmniGPUModelRunner will store talker_mtp output under this key in
# per-request additional_information. # per-request additional_information.
self.talker_mtp_output_key = ("codes", "audio") self.talker_mtp_output_key = ("codes", "audio")
# talker_mtp samples with per-row generators, so explicitly-seeded
# requests stay batched instead of one scalar forward per row (#4883).
# Only valid while talker_mtp receives the unpadded active batch (this
# talker is not graph-wrapped); a padded batch would need the runner to
# pad the generators list as well.
self.talker_mtp_accepts_per_row_generators = True
self.model = Qwen3Model(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")) self.model = Qwen3Model(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model"))
@@ -1056,6 +1062,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
top_k: int | None = None, top_k: int | None = None,
top_p: float | None = None, top_p: float | None = None,
generator: torch.Generator | None = None, generator: torch.Generator | None = None,
generators: Sequence[torch.Generator | None] | None = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[torch.Tensor, torch.Tensor]: ) -> tuple[torch.Tensor, torch.Tensor]:
"""GPU fast-path used by OmniGPUModelRunner to predict residual codebooks (1..Q-1). """GPU fast-path used by OmniGPUModelRunner to predict residual codebooks (1..Q-1).
@@ -1095,6 +1102,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
top_k=top_k, top_k=top_k,
top_p=top_p, top_p=top_p,
generator=generator, generator=generator,
generators=generators,
) # [B, Q] ) # [B, Q]
# Map invalid layer-0 ids (e.g. EOS) to PAD=0 so SpeechTokenizer sees only real codes. # Map invalid layer-0 ids (e.g. EOS) to PAD=0 so SpeechTokenizer sees only real codes.
@@ -0,0 +1,123 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import dataclasses
from typing import Any
from vllm.logger import init_logger
logger = init_logger(__name__)
@dataclasses.dataclass(frozen=True)
class _VoxCPM2RuntimeConfig:
enable_profiling: bool = False
enable_nvtx_profile: bool = False
enable_loc_dit_layer_nvtx: bool = False
enable_loc_dit_fused_qkv: bool = True
enable_loc_dit_fused_mlp: bool = True
enable_loc_dit_zero_dt_cache: bool = True
enable_loc_dit_fast_rope: bool = False
enable_loc_dit_skip_qkv_contig: bool = True
enable_loc_dit_reduce_overhead_no_cg: bool = False
enable_loc_dit_fullgraph_no_cg: bool = False
cfg_cutoff_ratio: float = 1.0
decode_graph_capture_policy: str = "all"
enable_vae_cuda_graph: bool = False
enable_cfm_cuda_graph: bool = False
enable_cfm_prealloc_output: bool = False
enable_batched_cfm: bool = True
deterministic_cfm_noise: bool = False
deterministic_cfm_seed: int = 20260601
audio_emit_every: int = 1
vae_decode_every: int = 3
enable_delayed_audio_copy: bool = False
delayed_audio_copy_use_events: bool = False
coalesce_audio_d2h: bool = True
enable_batched_vae_decode: bool = True
enable_batched_fsq_fusion: bool = False
batched_fsq_fusion_max_batch: int = 32
enable_batched_prefill_tail: bool = False
enable_unified_decode_graph: bool = True
unified_decode_graph_max_batch_size: int = 64
@classmethod
def from_vllm_config(cls, vllm_config: Any) -> _VoxCPM2RuntimeConfig:
model_config = vllm_config.model_config
raw = getattr(model_config, "voxcpm2_runtime_config", None)
if raw is None:
raw = getattr(getattr(model_config, "hf_config", None), "voxcpm2_runtime_config", None)
if raw is None:
return cls()
if hasattr(raw, "to_dict"):
raw = raw.to_dict()
elif not isinstance(raw, dict) and hasattr(raw, "__dict__"):
raw = vars(raw)
if not isinstance(raw, dict):
logger.warning("Ignoring invalid voxcpm2_runtime_config=%r; expected a dict.", raw)
return cls()
fields = {field.name: field for field in dataclasses.fields(cls)}
values: dict[str, Any] = {}
for key, value in raw.items():
if key not in fields:
logger.warning("Ignoring unknown VoxCPM2 runtime config key: %s", key)
continue
default = getattr(cls(), key)
values[key] = cls._coerce_value(key, value, default)
return cls(**values)._normalized()
def _normalized(self) -> _VoxCPM2RuntimeConfig:
cfg = self
if cfg.enable_batched_cfm and cfg.enable_cfm_cuda_graph:
logger.warning(
"VoxCPM2 batched CFM and CFM CUDA Graph are mutually exclusive; "
"disabling CFM CUDA Graph and keeping batched CFM."
)
cfg = dataclasses.replace(cfg, enable_cfm_cuda_graph=False)
if cfg.enable_cfm_prealloc_output and not cfg.enable_cfm_cuda_graph:
logger.warning("VoxCPM2 CFM preallocated output requires CFM CUDA Graph; disabling it.")
cfg = dataclasses.replace(cfg, enable_cfm_prealloc_output=False)
if cfg.delayed_audio_copy_use_events and not cfg.enable_delayed_audio_copy:
logger.warning("VoxCPM2 delayed audio copy events require delayed audio copy; disabling event polling.")
cfg = dataclasses.replace(cfg, delayed_audio_copy_use_events=False)
if cfg.enable_batched_vae_decode and (
cfg.enable_delayed_audio_copy or cfg.audio_emit_every > 1 or cfg.enable_vae_cuda_graph
):
logger.warning(
"VoxCPM2 batched VAE decode is incompatible with delayed audio copy, "
"audio_emit_every > 1, and VAE CUDA Graph; disabling batched VAE decode."
)
cfg = dataclasses.replace(cfg, enable_batched_vae_decode=False)
if cfg.enable_unified_decode_graph:
if cfg.enable_cfm_cuda_graph:
logger.info("VoxCPM2 unified decode graph supersedes independent CFM CUDA Graph; disabling it.")
cfg = dataclasses.replace(cfg, enable_cfm_cuda_graph=False, enable_cfm_prealloc_output=False)
if not cfg.enable_batched_cfm:
logger.info("VoxCPM2 unified decode graph requires batched CFM; enabling it.")
cfg = dataclasses.replace(cfg, enable_batched_cfm=True)
return cfg
def unified_decode_graph_available(self, *, use_cuda_graph: bool) -> bool:
return bool(use_cuda_graph and self.enable_unified_decode_graph and not self.deterministic_cfm_noise)
@staticmethod
def _coerce_value(key: str, value: Any, default: Any) -> Any:
if isinstance(default, bool):
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
if isinstance(default, int) and not isinstance(default, bool):
value = int(value)
if key in {"audio_emit_every", "vae_decode_every", "batched_fsq_fusion_max_batch"}:
return max(1, value)
return value
if isinstance(default, float):
if key == "cfg_cutoff_ratio":
return min(1.0, max(0.0, float(value)))
return float(value)
if isinstance(default, str):
return str(value)
return value
@@ -6,6 +6,9 @@ from __future__ import annotations
from vllm.v1.request import RequestStatus from vllm.v1.request import RequestStatus
from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler
from vllm_omni.platforms import current_omni_platform
from .runtime_config import _VoxCPM2RuntimeConfig
class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler): class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler):
@@ -19,11 +22,8 @@ class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler):
""" """
def _unified_decode_graph_enabled(self) -> bool: def _unified_decode_graph_enabled(self) -> bool:
hf_config = getattr(self.vllm_config.model_config, "hf_config", None) runtime_config = _VoxCPM2RuntimeConfig.from_vllm_config(self.vllm_config)
runtime_config = getattr(hf_config, "voxcpm2_runtime_config", None) return runtime_config.unified_decode_graph_available(use_cuda_graph=current_omni_platform.is_cuda())
if isinstance(runtime_config, dict):
return bool(runtime_config.get("enable_unified_decode_graph", False))
return bool(getattr(runtime_config, "enable_unified_decode_graph", False))
def _should_defer_waiting_for_unified_decode_graph(self) -> bool: def _should_defer_waiting_for_unified_decode_graph(self) -> bool:
if not self._unified_decode_graph_enabled(): if not self._unified_decode_graph_enabled():
@@ -46,6 +46,7 @@ from vllm_omni.utils.speaker_cache import (
from vllm_omni.worker.runner_assisted_metadata import RunnerAssistedFullAttentionMetadataRequest from vllm_omni.worker.runner_assisted_metadata import RunnerAssistedFullAttentionMetadataRequest
from .minicpm4_paged import MiniCPM4PagedForVoxCPM2, MiniCPM4PagedResidualLM from .minicpm4_paged import MiniCPM4PagedForVoxCPM2, MiniCPM4PagedResidualLM
from .runtime_config import _VoxCPM2RuntimeConfig
from .voxcpm2_import_utils import import_voxcpm2_core from .voxcpm2_import_utils import import_voxcpm2_core
logger = init_logger(__name__) logger = init_logger(__name__)
@@ -104,115 +105,6 @@ class _DecodeResidualMeta(NamedTuple):
new_lm_hidden: torch.Tensor new_lm_hidden: torch.Tensor
@dataclasses.dataclass(frozen=True)
class _VoxCPM2RuntimeConfig:
enable_profiling: bool = False
enable_nvtx_profile: bool = False
enable_loc_dit_layer_nvtx: bool = False
enable_loc_dit_fused_qkv: bool = True
enable_loc_dit_fused_mlp: bool = True
enable_loc_dit_zero_dt_cache: bool = True
enable_loc_dit_fast_rope: bool = False
enable_loc_dit_skip_qkv_contig: bool = True
enable_loc_dit_reduce_overhead_no_cg: bool = False
enable_loc_dit_fullgraph_no_cg: bool = False
cfg_cutoff_ratio: float = 1.0
decode_graph_capture_policy: str = "all"
enable_vae_cuda_graph: bool = False
enable_cfm_cuda_graph: bool = False
enable_cfm_prealloc_output: bool = False
enable_batched_cfm: bool = True
deterministic_cfm_noise: bool = False
deterministic_cfm_seed: int = 20260601
audio_emit_every: int = 1
vae_decode_every: int = 3
enable_delayed_audio_copy: bool = False
delayed_audio_copy_use_events: bool = False
coalesce_audio_d2h: bool = True
enable_batched_vae_decode: bool = True
enable_batched_fsq_fusion: bool = False
batched_fsq_fusion_max_batch: int = 32
enable_batched_prefill_tail: bool = False
enable_unified_decode_graph: bool = True
unified_decode_graph_max_batch_size: int = 64
@classmethod
def from_vllm_config(cls, vllm_config: VllmConfig) -> _VoxCPM2RuntimeConfig:
model_config = vllm_config.model_config
raw = getattr(model_config, "voxcpm2_runtime_config", None)
if raw is None:
raw = getattr(getattr(model_config, "hf_config", None), "voxcpm2_runtime_config", None)
if raw is None:
return cls()
if hasattr(raw, "to_dict"):
raw = raw.to_dict()
elif not isinstance(raw, dict) and hasattr(raw, "__dict__"):
raw = vars(raw)
if not isinstance(raw, dict):
logger.warning("Ignoring invalid voxcpm2_runtime_config=%r; expected a dict.", raw)
return cls()
fields = {field.name: field for field in dataclasses.fields(cls)}
values: dict[str, Any] = {}
for key, value in raw.items():
if key not in fields:
logger.warning("Ignoring unknown VoxCPM2 runtime config key: %s", key)
continue
default = getattr(cls(), key)
values[key] = cls._coerce_value(key, value, default)
return cls(**values)._normalized()
def _normalized(self) -> _VoxCPM2RuntimeConfig:
cfg = self
if cfg.enable_batched_cfm and cfg.enable_cfm_cuda_graph:
logger.warning(
"VoxCPM2 batched CFM and CFM CUDA Graph are mutually exclusive; "
"disabling CFM CUDA Graph and keeping batched CFM."
)
cfg = dataclasses.replace(cfg, enable_cfm_cuda_graph=False)
if cfg.enable_cfm_prealloc_output and not cfg.enable_cfm_cuda_graph:
logger.warning("VoxCPM2 CFM preallocated output requires CFM CUDA Graph; disabling it.")
cfg = dataclasses.replace(cfg, enable_cfm_prealloc_output=False)
if cfg.delayed_audio_copy_use_events and not cfg.enable_delayed_audio_copy:
logger.warning("VoxCPM2 delayed audio copy events require delayed audio copy; disabling event polling.")
cfg = dataclasses.replace(cfg, delayed_audio_copy_use_events=False)
if cfg.enable_batched_vae_decode and (
cfg.enable_delayed_audio_copy or cfg.audio_emit_every > 1 or cfg.enable_vae_cuda_graph
):
logger.warning(
"VoxCPM2 batched VAE decode is incompatible with delayed audio copy, "
"audio_emit_every > 1, and VAE CUDA Graph; disabling batched VAE decode."
)
cfg = dataclasses.replace(cfg, enable_batched_vae_decode=False)
if cfg.enable_unified_decode_graph:
if cfg.enable_cfm_cuda_graph:
logger.info("VoxCPM2 unified decode graph supersedes independent CFM CUDA Graph; disabling it.")
cfg = dataclasses.replace(cfg, enable_cfm_cuda_graph=False, enable_cfm_prealloc_output=False)
if not cfg.enable_batched_cfm:
logger.info("VoxCPM2 unified decode graph requires batched CFM; enabling it.")
cfg = dataclasses.replace(cfg, enable_batched_cfm=True)
return cfg
@staticmethod
def _coerce_value(key: str, value: Any, default: Any) -> Any:
if isinstance(default, bool):
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
if isinstance(default, int) and not isinstance(default, bool):
value = int(value)
if key in {"audio_emit_every", "vae_decode_every", "batched_fsq_fusion_max_batch"}:
return max(1, value)
return value
if isinstance(default, float):
if key == "cfg_cutoff_ratio":
return min(1.0, max(0.0, float(value)))
return float(value)
if isinstance(default, str):
return str(value)
return value
def is_cjk_char(c: str) -> bool: def is_cjk_char(c: str) -> bool:
"""Check if a character is a CJK ideograph.""" """Check if a character is a CJK ideograph."""
cp = ord(c) cp = ord(c)
@@ -1028,8 +920,8 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
self._cuda_graph_warmup_steps = 0 self._cuda_graph_warmup_steps = 0
self._cuda_graph_warmup_threshold = 3 self._cuda_graph_warmup_threshold = 3
self._unified_graphs: dict[int, _CapturedUnifiedDecodeGraph] = {} self._unified_graphs: dict[int, _CapturedUnifiedDecodeGraph] = {}
self._enable_unified_decode_graph = ( self._enable_unified_decode_graph = self._runtime_config.unified_decode_graph_available(
use_cuda_graph and self._runtime_config.enable_unified_decode_graph and not self._deterministic_cfm_noise use_cuda_graph=use_cuda_graph
) )
self._unified_decode_graph_max_batch_size = self._runtime_config.unified_decode_graph_max_batch_size self._unified_decode_graph_max_batch_size = self._runtime_config.unified_decode_graph_max_batch_size
self._unified_graph_bucket_sizes: frozenset[int] = self._build_unified_graph_bucket_sizes( self._unified_graph_bucket_sizes: frozenset[int] = self._build_unified_graph_bucket_sizes(
+2 -10
View File
@@ -224,16 +224,8 @@ class CudaOmniPlatform(OmniPlatform, CudaPlatformBase):
@classmethod @classmethod
def get_default_ir_op_priority(cls, vllm_config: VllmConfig) -> IrOpPriorityConfig: def get_default_ir_op_priority(cls, vllm_config: VllmConfig) -> IrOpPriorityConfig:
"""Copied from upstream CudaPlatformBase with inductor-aware logic. """Prefer ``vllm_c`` CUDA kernels over ``native`` for diffusion IR ops."""
default = ["vllm_c", "native"]
When inductor is active (compiling) use native as the default;
otherwise prefer vllm_c kernels where available.
"""
from vllm.config.compilation import CompilationMode
cc = vllm_config.compilation_config
using_inductor = cc.backend == "inductor" and cc.mode != CompilationMode.NONE
default = ["native"] if using_inductor else ["vllm_c", "native"]
# Use oink if enabled for rms_norm # Use oink if enabled for rms_norm
# TODO(Laurawly/luka): remove this env var, # TODO(Laurawly/luka): remove this env var,
@@ -902,7 +902,8 @@ class NPUARModelRunner(OmniNPUModelRunner):
) = self.execute_model_state ) = self.execute_model_state
# Clear ephemeral state. # Clear ephemeral state.
self.execute_model_state = None self.execute_model_state = None
seq_len = hidden_states.shape[0] hidden_seq_len = int(hidden_states.shape[0])
scheduled_seq_len = int(scheduler_output.total_num_scheduled_tokens)
# Apply structured output bitmasks if present. # Apply structured output bitmasks if present.
if grammar_output is not None: if grammar_output is not None:
@@ -1141,7 +1142,8 @@ class NPUARModelRunner(OmniNPUModelRunner):
start=start, start=start,
end=end, end=end,
pass_lists_through=False, pass_lists_through=False,
seq_len=seq_len, seq_len=hidden_seq_len,
scheduled_seq_len=scheduled_seq_len,
) )
payload.update(mm_payload) payload.update(mm_payload)
pooler_output.append(flatten_payload(payload)) pooler_output.append(flatten_payload(payload))
+34 -10
View File
@@ -106,7 +106,13 @@ def _to_cpu(value):
def to_payload_element( def to_payload_element(
element: object, idx: int, start: int, end: int, pass_lists_through: bool = False, seq_len: int | None = None element: object,
idx: int,
start: int,
end: int,
pass_lists_through: bool = False,
seq_len: int | None = None,
scheduled_seq_len: int | None = None,
): ):
"""Build an mm payload element corresponding to one request index """Build an mm payload element corresponding to one request index
from an element containing 0 or more CPU tensors. from an element containing 0 or more CPU tensors.
@@ -120,21 +126,39 @@ def to_payload_element(
passthrough data; this should be False in normal cases, but True passthrough data; this should be False in normal cases, but True
if we need to avoid splitting nonempty lists prior to calling if we need to avoid splitting nonempty lists prior to calling
postprocess, which is the case for prefix cache. postprocess, which is the case for prefix cache.
seq_len: Optional sequence length (i.e., dim 0 of hidden states). seq_len: Optional hidden-aligned batch length (``hidden_states.shape[0]``).
When set, a tensor whose first dimension equals seq_len is When a tensor's first dimension equals this value, it is sliced
sliced per request. The prefix cache passthrough also passes per request using ``start:end``.
the total scheduled token count here so 1D (seq_len,) metadata scheduled_seq_len: Optional scheduler-aligned batch length
that is intentionally not cached is still split per request. (``scheduler_output.total_num_scheduled_tokens``). Some full-payload
mm tensors (e.g. batched ``codes.audio`` with tail-only hidden states)
are laid out by scheduled tokens instead of the hidden tail shape.
When omitted, ``seq_len`` is reused for backward compatibility.
""" """
# Cached per-token tensors are merged elsewhere; here a first dim if scheduled_seq_len is None:
# equal to seq_len means a per-request slice is required. scheduled_seq_len = seq_len
if seq_len is not None and isinstance(element, torch.Tensor) and element.shape[0] == seq_len:
# Cached per-token tensors are merged elsewhere; here a first dim equal to
# either the hidden-aligned or scheduler-aligned batch length means a
# per-request slice is required.
if isinstance(element, torch.Tensor) and (
(seq_len is not None and element.shape[0] == seq_len)
or (scheduled_seq_len is not None and element.shape[0] == scheduled_seq_len)
):
return element[start:end].contiguous() return element[start:end].contiguous()
# Every other case is shared between prefix cache (passthrough data) # Every other case is shared between prefix cache (passthrough data)
# and running a model without prefix caching. # and running a model without prefix caching.
elif isinstance(element, dict): elif isinstance(element, dict):
return { return {
sk: to_payload_element(sv, idx, start, end, pass_lists_through=pass_lists_through, seq_len=seq_len) sk: to_payload_element(
sv,
idx,
start,
end,
pass_lists_through=pass_lists_through,
seq_len=seq_len,
scheduled_seq_len=scheduled_seq_len,
)
for sk, sv in element.items() for sk, sv in element.items()
} }
elif isinstance(element, list): elif isinstance(element, list):
+12 -8
View File
@@ -839,7 +839,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
end: int, end: int,
audio_sparse_output: bool, audio_sparse_output: bool,
sparse_mm_index: dict[str, int], sparse_mm_index: dict[str, int],
seq_len: int, hidden_seq_len: int,
scheduled_seq_len: int,
) -> dict[str, object]: ) -> dict[str, object]:
if combined_multimodal_outputs: if combined_multimodal_outputs:
return self._build_combined_prefix_cache_mm_payload( return self._build_combined_prefix_cache_mm_payload(
@@ -876,7 +877,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
start=start, start=start,
end=end, end=end,
pass_lists_through=False, pass_lists_through=False,
seq_len=seq_len, seq_len=hidden_seq_len,
scheduled_seq_len=scheduled_seq_len,
) )
return mm_payload return mm_payload
@@ -894,7 +896,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
mm_cpu: dict[str, object] | None, mm_cpu: dict[str, object] | None,
audio_sparse_output: bool, audio_sparse_output: bool,
sparse_mm_index: dict[str, int], sparse_mm_index: dict[str, int],
seq_len: int, hidden_seq_len: int,
scheduled_seq_len: int,
) -> dict[str, object]: ) -> dict[str, object]:
payload: dict[str, object] = {} payload: dict[str, object] = {}
if not audio_sparse_output: if not audio_sparse_output:
@@ -920,7 +923,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
end=end, end=end,
audio_sparse_output=audio_sparse_output, audio_sparse_output=audio_sparse_output,
sparse_mm_index=sparse_mm_index, sparse_mm_index=sparse_mm_index,
seq_len=seq_len, hidden_seq_len=hidden_seq_len,
scheduled_seq_len=scheduled_seq_len,
) )
payload.update(mm_payload) payload.update(mm_payload)
return payload return payload
@@ -1676,7 +1680,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
ec_connector_output: Any, ec_connector_output: Any,
cudagraph_stats: Any, cudagraph_stats: Any,
kv_extracted_req_ids: list[str] | None, kv_extracted_req_ids: list[str] | None,
seq_len: int,
num_scheduled_tokens_np: np.ndarray, num_scheduled_tokens_np: np.ndarray,
query_start_loc_cpu: Any, query_start_loc_cpu: Any,
postprocess_already_applied: bool = False, postprocess_already_applied: bool = False,
@@ -1732,6 +1735,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
# The actual multimodal wire transport uses multimodal_outputs instead. # The actual multimodal wire transport uses multimodal_outputs instead.
pooler_output: list[dict[str, object]] | None = None pooler_output: list[dict[str, object]] | None = None
if needs_pooler_payload: if needs_pooler_payload:
hidden_seq_len = int(hidden_states.shape[0])
scheduled_seq_len = int(scheduler_output.total_num_scheduled_tokens)
mm_cpu = None mm_cpu = None
if self.omni_prefix_cache is not None: if self.omni_prefix_cache is not None:
( (
@@ -1787,7 +1792,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
mm_cpu=mm_cpu, mm_cpu=mm_cpu,
audio_sparse_output=audio_sparse_output, audio_sparse_output=audio_sparse_output,
sparse_mm_index=sparse_mm_index, sparse_mm_index=sparse_mm_index,
seq_len=seq_len, hidden_seq_len=hidden_seq_len,
scheduled_seq_len=scheduled_seq_len,
) )
pooler_output.append(flatten_payload(payload)) pooler_output.append(flatten_payload(payload))
@@ -1873,7 +1879,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
slot_mappings, # OMNI: unpack slot_mappings for drafter slot_mappings, # OMNI: unpack slot_mappings for drafter
) = self.execute_model_state ) = self.execute_model_state
self.execute_model_state = None self.execute_model_state = None
seq_len = hidden_states.shape[0]
# Apply structured output bitmasks if present. # Apply structured output bitmasks if present.
if grammar_output is not None: if grammar_output is not None:
@@ -2040,7 +2045,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
ec_connector_output=ec_connector_output, ec_connector_output=ec_connector_output,
cudagraph_stats=cudagraph_stats, cudagraph_stats=cudagraph_stats,
kv_extracted_req_ids=kv_extracted_req_ids, kv_extracted_req_ids=kv_extracted_req_ids,
seq_len=seq_len,
num_scheduled_tokens_np=num_scheduled_tokens_np, num_scheduled_tokens_np=num_scheduled_tokens_np,
query_start_loc_cpu=query_start_loc_cpu, query_start_loc_cpu=query_start_loc_cpu,
postprocess_already_applied=omni_postprocess_already_applied, postprocess_already_applied=omni_postprocess_already_applied,
+32 -17
View File
@@ -1826,7 +1826,33 @@ class OmniGPUModelRunner(GPUModelRunner):
seed = extra_args.get("tts_local_seed") seed = extra_args.get("tts_local_seed")
return int(seed) if seed is not None else None return int(seed) if seed is not None else None
if decode_batch_size > 1 and any(_explicit_talker_seed(req_id) is not None for req_id in decode_req_ids): def _row_generator(req_id: str) -> torch.Generator | None:
seed = _explicit_talker_seed(req_id)
if seed is None:
return None
cache = getattr(self, "_talker_mtp_generators", None)
if cache is None:
cache = {}
self._talker_mtp_generators = cache
generator = cache.get(req_id)
if generator is None or generator.device != req_input_ids.device:
generator = torch.Generator(device=req_input_ids.device)
generator.manual_seed(seed)
cache[req_id] = generator
return generator
row_generators = [_row_generator(req_id) for req_id in decode_req_ids]
cache = getattr(self, "_talker_mtp_generators", None)
if cache:
# Generators live as long as their request; drop finished ones.
for stale_id in [rid for rid in cache if rid not in self.requests]:
del cache[stale_id]
if (
decode_batch_size > 1
and any(generator is not None for generator in row_generators)
and not getattr(self.model, "talker_mtp_accepts_per_row_generators", False)
):
# A torch.Generator is a single stream. Using one generator for a # A torch.Generator is a single stream. Using one generator for a
# multi-row batch would make explicitly-seeded requests depend on # multi-row batch would make explicitly-seeded requests depend on
# other rows in the same scheduler step, so keep that path scalar. # other rows in the same scheduler step, so keep that path scalar.
@@ -1849,28 +1875,17 @@ class OmniGPUModelRunner(GPUModelRunner):
self.text_step.gpu[:decode_batch_size].copy_(saved_text) self.text_step.gpu[:decode_batch_size].copy_(saved_text)
return return
generator = None
if decode_req_ids:
first_req_id = decode_req_ids[0]
seed = _explicit_talker_seed(first_req_id)
if seed is not None:
generators = getattr(self, "_talker_mtp_generators", None)
if generators is None:
generators = {}
self._talker_mtp_generators = generators
generator = generators.get(first_req_id)
if generator is None or generator.device != req_input_ids.device:
generator = torch.Generator(device=req_input_ids.device)
generator.manual_seed(int(seed))
generators[first_req_id] = generator
talker_kwargs = { talker_kwargs = {
"do_sample": subtalker_params.get("do_sample"), "do_sample": subtalker_params.get("do_sample"),
"temperature": subtalker_params.get("temperature"), "temperature": subtalker_params.get("temperature"),
"top_k": subtalker_params.get("top_k"), "top_k": subtalker_params.get("top_k"),
"top_p": subtalker_params.get("top_p"), "top_p": subtalker_params.get("top_p"),
} }
if generator is not None: if decode_batch_size == 1:
talker_kwargs["generator"] = generator if row_generators[0] is not None:
talker_kwargs["generator"] = row_generators[0]
elif any(generator is not None for generator in row_generators):
talker_kwargs["generators"] = row_generators
if getattr(self.model, "talker_mtp_accepts_req_infos", False): if getattr(self.model, "talker_mtp_accepts_req_infos", False):
talker_kwargs["req_ids"] = decode_req_ids talker_kwargs["req_ids"] = decode_req_ids
talker_kwargs["req_infos"] = [ talker_kwargs["req_infos"] = [