diff --git a/docs/.nav.yml b/docs/.nav.yml index beb15bf47..da55af813 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -161,6 +161,7 @@ nav: - Cache-DiT: design/feature/cache_dit.md - TeaCache: design/feature/teacache.md - Diffusion Continuous Batching: design/feature/diffusion_continuous_batching.md + - Mooncake KV Transfer: design/feature/mooncake-kv-transfer.md - Infrastructure and Performance: - Prometheus Metrics: design/metrics.md - Speech Generation Performance Optimizations: design/qwen3_omni_tts_performance_optimization.md diff --git a/docs/design/feature/mooncake-kv-transfer.md b/docs/design/feature/mooncake-kv-transfer.md new file mode 100644 index 000000000..ed83d562f --- /dev/null +++ b/docs/design/feature/mooncake-kv-transfer.md @@ -0,0 +1,89 @@ +# Mooncake KV Transfer + +## PR summary + +This change adds an opt-in AR-to-DiT KV handoff using vLLM's native +`KVTransferConfig`, Scheduler-owned diffusion KV pages, and +`MooncakeConnector`. The existing `OmniKVTransferManager` path remains the +fallback when `kv_transfer_config` is absent. + +The orchestrator creates one transfer ticket per request and resolves the +bound AR replica's Mooncake endpoint after generation. DiT allocates the +destination pages, waits for every worker rank to finish loading, and releases +the source and destination pages only after the connector lifecycle completes. +Request registration failures are rolled back before Worker dispatch. Receive +timeouts and cancellation terminate the request without recycling in-flight +pages: the scheduler retains them until every CFG row has completed on every +rank. Late completion is polled only while such requests remain. If a peer +never completes, its pages remain reserved until shutdown; other requests can +continue using the remaining capacity. Invalid pages, missing Worker ranks, +and failures without a proven rollback still stop the replica before teardown. +The example sets `transfer_timeout: 60.0` seconds explicitly. Cancellation +queued during a synchronous receive is processed when that receive completes +or times out; it does not interrupt the remote write. + +## Reference configuration + +| Stage | Replicas | Devices | Parallelism | KV cache | +| --- | ---: | --- | --- | ---: | +| AR | 2 | 0-3 | TP=2 per replica | 2 GiB per worker | +| DiT | 2 | 4-7 | TP=2 per replica, EP enabled | 1 GiB per worker | + +The acceptance workload uses 512x512 images, two denoising steps, guidance +scale 2.0, and `max_num_seqs=1`. + +For a four-GPU debug run, set both stages to one replica and assign AR TP=2 to +devices `0,1` and DiT TP=2 to devices `2,3`. + +## Experiment + +The final comparison used one node with eight NVIDIA RTX PRO 6000 Blackwell +Server Edition GPUs, TCP Mooncake transport, the same 50-prompt/seed T2I +dataset, concurrency 8, and two independent 100-request runs per +implementation. Each side completed 200/200 requests without an OOM or request +failure. + +| Implementation | Run 1 (req/s) | Run 2 (req/s) | Mean (req/s) | +| --- | ---: | ---: | ---: | +| `OmniKVTransferManager` | 4.4103 | 4.4497 | 4.4300 | +| Native `MooncakeConnector` | 4.4457 | 4.4699 | 4.4578 | + +Native throughput is **0.63% higher** than the legacy mean. The absolute +performance difference is below the 1% acceptance threshold. + +For accuracy, a non-zero transferred prefix and the resulting attention output +were compared bit-for-bit with the legacy manager path: + +```text +prefix_bits_equal True +attention_bits_equal True +max_abs_diff 0.0 +``` + +The one-AR/one-DiT TP=2 debug topology also completed 20/20 sequential +requests. A separate real-TCP transport check transferred both CFG rows from a +TP=1 producer to a strict-Ulysses SP=2 consumer with exact values on both +target ranks. + +## Scope and limitations + +- The fully validated end-to-end topology is 2x AR TP=2 plus 2x DiT TP=2. +- Heterogeneous TP=1 to SP=2 reshaping is transport-tested, but arbitrary mixed + TP/CFG/SP end-to-end combinations have not been exhaustively validated. +- CFG-parallel end-to-end deployment is not part of this acceptance run. +- Native AR-to-DiT transfer currently requires `async_chunk: false`. +- TCP is covered by the acceptance run. The supplementary single-node RDMA + measurements use a separate short workload; they do not establish RDMA + support for multi-node deployment, which remains out of scope. +- The native path currently validates the two-stage `AR(0) -> DiT(1)` graph. Other stage graphs are rejected during startup until + their producer/consumer edge mapping is implemented. +- Sleep mode is disabled for the native paged path because registered KV pages + must remain mapped for the connector lifetime. + +- Ascend NPU adaptation and its performance validation are maintained in a + separate follow-up PR; the GPU acceptance results above do not validate NPU. +- The homogeneous piecewise CUDA batching optimization is retained. A native + paged-attention CUDA graph capture/replay regression covers two CFG rows, + strided QKV, mixed causal/image spans, changed inputs, and output stability. + It must be run on CUDA hardware; CPU unit tests do not establish graph or + end-to-end performance acceptance. diff --git a/tests/config/test_omni_config.py b/tests/config/test_omni_config.py index 76bf3c319..cdb303316 100644 --- a/tests/config/test_omni_config.py +++ b/tests/config/test_omni_config.py @@ -62,6 +62,24 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu] _DEPLOY_DIR = Path(__file__).parents[2] / "vllm_omni" / "deploy" +@pytest.mark.parametrize("async_chunk", [False, True]) +def test_native_kv_transfer_requires_completed_ar_stage(async_chunk): + from types import SimpleNamespace + + pipeline = SimpleNamespace(stages=(), model_type="test") + deploy = DeployConfig( + async_chunk=async_chunk, + stages=[ + StageDeployConfig(stage_id=0, engine_extras={"kv_transfer_config": {"kv_connector": "MooncakeConnector"}}) + ], + ) + if async_chunk: + with pytest.raises(ValueError, match="requires async_chunk=False"): + omni_config_module._validate_async_chunk_support(pipeline, deploy) + else: + omni_config_module._validate_async_chunk_support(pipeline, deploy) + + @pytest.fixture(autouse=True) def _stable_test_platform(monkeypatch): from vllm_omni import platforms @@ -921,6 +939,7 @@ def test_sub_config_fields_match_structured_scopes(): assert {f.name for f in fields(OmniStageConnectorConfig)} == { "async_chunk", "omni_kv_config", + "kv_transfer_config", "stage_connector", "output_connectors", "input_connectors", diff --git a/tests/core/sched/test_omni_ar_scheduler_free_request_cleanup.py b/tests/core/sched/test_omni_ar_scheduler_free_request_cleanup.py index 73c71fd63..8bc63795f 100644 --- a/tests/core/sched/test_omni_ar_scheduler_free_request_cleanup.py +++ b/tests/core/sched/test_omni_ar_scheduler_free_request_cleanup.py @@ -7,6 +7,7 @@ window fills with stale entries after K completions. from __future__ import annotations +from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -15,6 +16,7 @@ import pytest # Request / RequestStatus are bound in this module. Ruff isort would reorder them. # isort: off import vllm_omni # noqa: F401 - import for side effects (patch vLLM) +from vllm.v1.request import RequestStatus from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler # isort: on @@ -72,3 +74,85 @@ def test_free_request_is_safe_without_a_chunk_transfer_adapter(): sched._free_request(request) # must not raise sched._free_input_coordinator_request.assert_called_once_with("req-1") + + +def test_native_mooncake_finish_uses_confirmed_token_boundary() -> None: + sched = _make_scheduler(chunk_transfer_adapter=None) + sched.vllm_config = SimpleNamespace(kv_transfer_config=SimpleNamespace(kv_connector="MooncakeConnector")) + observed = {} + + def connector_finished(request): + observed["status"] = request.status + observed["num_computed_tokens"] = request.num_computed_tokens + return True, None + + sched._connector_finished = connector_finished + request = _make_finished_request("req-native") + request.client_index = 0 + request.status = RequestStatus.FINISHED_STOPPED + request.kv_transfer_params = { + "do_remote_decode": True, + "transfer_id": "xfer-req-native", + } + request.num_computed_tokens = 13 + request.num_output_placeholders = 1 + request.num_prompt_tokens = 10 + + kv_params, _ = sched._free_request(request) + + assert observed == { + "status": RequestStatus.FINISHED_LENGTH_CAPPED, + "num_computed_tokens": 12, + } + assert request.status == RequestStatus.FINISHED_STOPPED + assert request.num_computed_tokens == 13 + assert kv_params == { + "transfer_id": "xfer-req-native", + "num_transfer_tokens": 12, + } + + +def test_native_mooncake_recycles_ar_pages_in_physical_order() -> None: + sched = OmniARScheduler.__new__(OmniARScheduler) + sched.vllm_config = SimpleNamespace(kv_transfer_config=SimpleNamespace(kv_connector="MooncakeConnector")) + blocks = [SimpleNamespace(block_id=7), SimpleNamespace(block_id=2), SimpleNamespace(block_id=4)] + sched.kv_cache_manager = SimpleNamespace( + enable_caching=False, + pop_blocks_for_free=MagicMock(return_value=blocks), + block_pool=SimpleNamespace(free_blocks=MagicMock()), + ) + sched.defer_block_free = False + sched.processed_step_seq = 3 + request = SimpleNamespace(last_sched_seq=3) + + sched._free_request_blocks(request) + + sched.kv_cache_manager.block_pool.free_blocks.assert_called_once() + freed = sched.kv_cache_manager.block_pool.free_blocks.call_args.args[0] + assert [block.block_id for block in freed] == [2, 4, 7] + + +def test_native_mooncake_deferred_ar_pages_drain_in_physical_order() -> None: + from collections import deque + + sched = OmniARScheduler.__new__(OmniARScheduler) + sched.vllm_config = SimpleNamespace(kv_transfer_config=SimpleNamespace(kv_connector="MooncakeConnector")) + blocks = [SimpleNamespace(block_id=7), SimpleNamespace(block_id=2), SimpleNamespace(block_id=4)] + sched.kv_cache_manager = SimpleNamespace( + enable_caching=False, + pop_blocks_for_free=MagicMock(return_value=blocks), + block_pool=SimpleNamespace(free_blocks=MagicMock()), + ) + sched.defer_block_free = True + sched.processed_step_seq = 2 + sched.sched_step_seq = 3 + sched.deferred_frees = deque() + request = SimpleNamespace(last_sched_seq=3) + + sched._free_request_blocks(request) + assert [block.block_id for block in sched.deferred_frees[0][1]] == [7, 4, 2] + + sched.processed_step_seq = 3 + sched._drain_deferred_frees() + freed = list(sched.kv_cache_manager.block_pool.free_blocks.call_args.args[0]) + assert [block.block_id for block in freed] == [2, 4, 7] diff --git a/tests/diffusion/diffusion_kv/test_block_tables.py b/tests/diffusion/diffusion_kv/test_block_tables.py index aa8e07307..dbca89735 100644 --- a/tests/diffusion/diffusion_kv/test_block_tables.py +++ b/tests/diffusion/diffusion_kv/test_block_tables.py @@ -325,7 +325,7 @@ def _runner( model_config=SimpleNamespace(max_model_len=max_len), scheduler_config=SimpleNamespace( max_num_batched_tokens=max_num_batched_tokens, - max_num_seqs=max_num_seqs, + max_num_seqs=max_num_seqs * max_rows_per_request, ), parallel_config=SimpleNamespace( decode_context_parallel_size=cp_size, @@ -403,7 +403,7 @@ def test_initialize_failure_does_not_publish_partial_cache_state( successful_init_cache = model_runner_backend_module.init_kv_cache vllm_config = SimpleNamespace( model_config=SimpleNamespace(max_model_len=16), - scheduler_config=SimpleNamespace(max_num_batched_tokens=32, max_num_seqs=1), + scheduler_config=SimpleNamespace(max_num_batched_tokens=32, max_num_seqs=2), parallel_config=SimpleNamespace( decode_context_parallel_size=1, cp_kv_cache_interleave_size=1, @@ -445,7 +445,7 @@ def test_initialize_failure_does_not_publish_partial_cache_state( assert backend.block_tables is None assert backend.paged_attention_adapter is None assert backend._kv_cache_layer_adapters["layer-0"].kv_cache == "placeholder-0" - assert backend.vllm_config.scheduler_config.max_num_seqs == 1 + assert backend.vllm_config.scheduler_config.max_num_seqs == 2 monkeypatch.setattr(model_runner_backend_module, "init_kv_cache", successful_init_cache) backend.initialize_kv_cache(config) diff --git a/tests/diffusion/diffusion_kv/test_initialization.py b/tests/diffusion/diffusion_kv/test_initialization.py index b5f914b11..b9161ba92 100644 --- a/tests/diffusion/diffusion_kv/test_initialization.py +++ b/tests/diffusion/diffusion_kv/test_initialization.py @@ -58,6 +58,7 @@ def _od_config(**overrides): gpu_memory_utilization=0.9, kv_cache_memory_bytes=None, max_num_seqs=1, + diffusion_kv_max_rows_per_request=1, max_num_batched_tokens=64, num_gpus=1, ) diff --git a/tests/diffusion/diffusion_kv/test_kv_connector.py b/tests/diffusion/diffusion_kv/test_kv_connector.py index 19847b3d5..7528a201e 100644 --- a/tests/diffusion/diffusion_kv/test_kv_connector.py +++ b/tests/diffusion/diffusion_kv/test_kv_connector.py @@ -1,16 +1,23 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +from contextlib import nullcontext +from types import SimpleNamespace from unittest import mock import pytest import torch from vllm.config import KVTransferConfig +from vllm.v1.core.kv_cache_manager import KVCacheBlocks +from vllm.v1.outputs import KVConnectorOutput from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.diffusion_kv.kv_connector import ( + KVTransferRegistrationError, + commit_kv_load, parse_kv_transfer_config, shutdown_kv_connector, + wait_for_kv_load, ) from vllm_omni.diffusion.sched.base_scheduler import BaseScheduler from vllm_omni.diffusion.vllm_config import create_diffusion_vllm_config @@ -138,3 +145,205 @@ def test_shutdown_kv_connector_is_idempotent() -> None: assert ensure_shutdown.call_count == 2 scheduler_connector.shutdown.assert_called_once() + + +def test_commit_aligns_cfg_transport_pages_without_extending_computed_prefix() -> None: + requests = tuple( + SimpleNamespace( + request_id=f"req/{sequence_id}", + kv_transfer_params={"num_transfer_tokens": 9}, + num_tokens=16, + num_computed_tokens=0, + ) + for sequence_id in range(2) + ) + manager = SimpleNamespace( + get_blocks=lambda _request_id: KVCacheBlocks(([0, 1, 2, 3],)), + kv_cache_config=SimpleNamespace(kv_cache_groups=[SimpleNamespace(kv_cache_spec=SimpleNamespace(block_size=4))]), + ) + connector = mock.Mock() + + expected = commit_kv_load(connector, manager, requests, [7, 5]) + + assert expected == {"req/0", "req/1"} + calls = connector.update_state_after_alloc.call_args_list + assert [len(call.args[1].blocks[0]) for call in calls] == [3, 3] + assert [call.args[2] for call in calls] == [7, 5] + assert [request.num_computed_tokens for request in requests] == [7, 5] + + +def test_wait_without_pending_load_uses_vllm_post_forward_once() -> None: + connector = mock.Mock() + output = mock.Mock(invalid_block_ids=set()) + active_connector = mock.Mock(kv_connector=connector) + active_connector.post_forward.return_value = output + scheduler_output = SimpleNamespace( + kv_transfer_request_ids=set(), + finished_req_ids={"finished"}, + kv_finished_request_ids={"finished/diffusion-kv/0", "finished/diffusion-kv/1"}, + ) + + result = wait_for_kv_load(active_connector, scheduler_output, timeout=1.0) + + assert result is output + active_connector.pre_forward.assert_called_once_with(scheduler_output) + active_connector.post_forward.assert_called_once_with({"finished/diffusion-kv/0", "finished/diffusion-kv/1"}) + connector.get_finished.assert_not_called() + + +def test_timeout_returns_partial_completion_without_releasing_pages(): + active = mock.Mock() + active.kv_connector.get_finished.return_value = (set(), {"cfg0"}) + active.kv_connector.get_block_ids_with_load_errors.return_value = set() + active.post_forward.return_value = KVConnectorOutput() + scheduled = SimpleNamespace(kv_transfer_request_ids={"cfg0", "cfg1"}, kv_finished_request_ids=set()) + output = wait_for_kv_load(active, scheduled, timeout=0) + assert output.finished_recving == {"cfg0"} + active.kv_connector.request_finished.assert_not_called() + active.post_forward.assert_called_once_with(set()) + + +@pytest.fixture +def cfg_registration(): + requests = tuple( + SimpleNamespace( + request_id=f"cfg{i}", + status=None, + num_tokens=8, + kv_transfer_params={"num_transfer_tokens": 4, "transfer_id": "ticket", "do_remote_prefill": True}, + ) + for i in range(2) + ) + manager = SimpleNamespace( + get_blocks=lambda _: KVCacheBlocks(([0, 1],)), + kv_cache_config=SimpleNamespace(kv_cache_groups=[SimpleNamespace(kv_cache_spec=SimpleNamespace(block_size=4))]), + ) + return requests, manager + + +def test_cfg_validation_is_atomic_before_connector_registration(cfg_registration): + requests, manager = cfg_registration + requests[1].kv_transfer_params["num_transfer_tokens"] = 9 + connector = mock.Mock() + with pytest.raises(KVTransferRegistrationError, match="boundary"): + commit_kv_load(connector, manager, requests, [4, 4]) + connector.update_state_after_alloc.assert_not_called() + + +@pytest.mark.parametrize("fail_init", [False, True]) +def test_sp_connector_init_snapshots_topology_and_restores_model_tp(monkeypatch, fail_init): + import vllm.distributed.parallel_state as parallel_state + + import vllm_omni.diffusion.diffusion_kv.kv_connector as module + import vllm_omni.diffusion.distributed.parallel_state as omni_parallel + + tp = SimpleNamespace(world_size=1, rank_in_group=0) + sp = SimpleNamespace(world_size=2, rank_in_group=1) + monkeypatch.setattr(parallel_state, "_TP", tp) + monkeypatch.setattr(omni_parallel, "get_sp_group", lambda: sp) + snapshot: dict[str, int] = {} + + def initialize(*args): + snapshot.update( + rank=parallel_state.get_tensor_model_parallel_rank(), + size=parallel_state.get_tensor_model_parallel_world_size(), + ) + if fail_init: + raise RuntimeError("initialization failed") + + monkeypatch.setattr(module, "ensure_kv_transfer_initialized", initialize) + config = SimpleNamespace( + kv_transfer_config=SimpleNamespace(kv_connector="MooncakeConnector", engine_id="dit"), + parallel_config=SimpleNamespace(prefill_context_parallel_size=2), + ) + with pytest.raises(RuntimeError, match="initialization failed") if fail_init else nullcontext(): + module.init_worker_kv_connector(config, object()) + assert parallel_state._TP is tp + assert snapshot == {"rank": 1, "size": 2} + + +def test_partial_mooncake_registration_is_replaced_by_empty_notifications(cfg_registration): + from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector import ( + MooncakeConnector, + MooncakeConnectorScheduler, + ) + + scheduler = object.__new__(MooncakeConnectorScheduler) + scheduler.is_kv_producer = False + scheduler.is_kv_consumer = True + scheduler._reqs_need_recv = {} + connector = object.__new__(MooncakeConnector) + connector.connector_scheduler = scheduler + requests, manager = cfg_registration + for request in requests: + request.kv_transfer_params.update(remote_engine_id="ar", remote_bootstrap_addr="http://localhost:8998") + + def register(request, blocks, count): + scheduler._reqs_need_recv[request.request_id] = (request, blocks) + request.kv_transfer_params["do_remote_prefill"] = False + if request.request_id == "cfg1": + raise RuntimeError("partial registration") + + connector.update_state_after_alloc = register + with pytest.raises(KVTransferRegistrationError, match="partial registration"): + commit_kv_load(connector, manager, requests, [4, 4]) + assert set(scheduler._reqs_need_recv) == {"cfg0", "cfg1"} + assert all(blocks == [] for _, blocks in scheduler._reqs_need_recv.values()) + metadata = scheduler.build_connector_meta(None) + # Even partial registration failure sends every row in one per-rank batch, + # preserving the producer's complete fan-out count for the shared ticket. + assert set(metadata.reqs_to_recv["ar"]) == {"cfg0", "cfg1"} + assert all( + meta.transfer_id == "ticket" and meta.local_block_ids == [] for meta in metadata.reqs_to_recv["ar"].values() + ) + + +@pytest.mark.parametrize("timeout", [0, -1, float("inf"), "60"]) +def test_native_transfer_timeout_must_be_positive_finite(timeout): + with pytest.raises(ValueError, match="transfer_timeout"): + parse_kv_transfer_config( + { + **KV_TRANSFER_CONFIG, + "kv_connector_extra_config": {"transfer_timeout": timeout}, + } + ) + + +def test_receive_completion_requires_all_ranks_across_polls(): + from vllm_omni.diffusion.executor.abstract import DiffusionExecutor + from vllm_omni.diffusion.sched.interface import CachedRequestData, DiffusionSchedulerOutput + + executor = SimpleNamespace(od_config=SimpleNamespace(num_gpus=2)) + executor.collective_rpc = mock.Mock( + side_effect=[ + [KVConnectorOutput(finished_recving={"cfg0", "cfg1"}), KVConnectorOutput(finished_recving={"cfg0"})], + [KVConnectorOutput(), KVConnectorOutput(finished_recving={"cfg1"})], + ] + ) + scheduled = DiffusionSchedulerOutput( + step_id=0, + scheduled_new_reqs=[], + scheduled_cached_reqs=CachedRequestData.make_empty(), + finished_req_ids=set(), + num_running_reqs=0, + num_waiting_reqs=0, + kv_connector_metadata=object(), + kv_transfer_request_ids={"cfg0", "cfg1"}, + ) + result = DiffusionExecutor.prepare_kv_for_forward(executor, scheduled) + assert result.finished_recving == {"cfg0"} + scheduled.kv_transfer_request_ids = set() + result = DiffusionExecutor.prepare_kv_for_forward(executor, scheduled) + assert result.finished_recving == {"cfg1"} + assert not executor._kv_receive_completed_ranks + + +def test_native_transfer_rejects_sleep_without_changing_legacy_sleep(): + with pytest.raises(ValueError, match="registered pages must remain mapped"): + OmniDiffusionConfig.from_kwargs( + diffusion_kv_mode="paged_scheduler", + diffusion_kv_max_rows_per_request=2, + kv_transfer_config=dict(KV_TRANSFER_CONFIG), + enable_sleep_mode=True, + ) + assert OmniDiffusionConfig.from_kwargs(enable_sleep_mode=True).enable_sleep_mode diff --git a/tests/diffusion/diffusion_kv/test_metadata.py b/tests/diffusion/diffusion_kv/test_metadata.py index 07e3eeb70..da9e7b331 100644 --- a/tests/diffusion/diffusion_kv/test_metadata.py +++ b/tests/diffusion/diffusion_kv/test_metadata.py @@ -61,13 +61,14 @@ class _MetadataScheduler(BaseScheduler): def test_scheduler_copies_opaque_params_to_sequence_requests() -> None: - params = {"transfer_id": "xfer-req-0", "do_remote_prefill": True} + params = {"transfer_id": "xfer-req-0", "do_remote_prefill": True, "num_transfer_tokens": 3} sequence = DiffusionKVRequest( "req-0/diffusion-kv/0", sequence_id=0, prefix_len=4, target_len=2, seq_len=6, + prompt_token_ids=[10, 11, 12, 13], ) request = OmniDiffusionRequest( prompt="prompt", @@ -83,6 +84,9 @@ def test_scheduler_copies_opaque_params_to_sequence_requests() -> None: assert state.diffusion_kv_requests[0].kv_transfer_params == params assert state.diffusion_kv_requests[0].kv_transfer_params is not params + assert sequence.prompt_token_ids == [10, 11, 12] + assert sequence.num_prompt_tokens == 3 + assert sequence.seq_len == 6 def test_diffusion_kv_metadata_uses_native_cache_group_block_ids() -> None: diff --git a/tests/diffusion/diffusion_kv/test_mooncake_cfg_fanout.py b/tests/diffusion/diffusion_kv/test_mooncake_cfg_fanout.py new file mode 100644 index 000000000..d9e66ddea --- /dev/null +++ b/tests/diffusion/diffusion_kv/test_mooncake_cfg_fanout.py @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Exercise upstream Mooncake completion accounting without device/network I/O.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import msgspec +import pytest +from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import mooncake_connector as mc +from vllm.v1.core.sched.scheduler import Scheduler +from vllm.v1.outputs import KVConnectorOutput + +from vllm_omni.diffusion.diffusion_kv.kv_connector import install_mooncake_cfg_fanout +from vllm_omni.worker.mixins import OmniWorkerMixin + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +@pytest.fixture +def producer(monkeypatch): + worker = object.__new__(mc.MooncakeConnectorWorker) + worker.shutdown = Mock() # No hardware constructor, sockets or listener thread. + worker.is_kv_consumer = False + worker.tp_rank, worker.tp_size = 0, 1 + worker.transfer_topo = SimpleNamespace(handshake_target_ranks=lambda size: list(range(size))) + worker.reqs_need_send, worker.finished_sending_reqs = {}, set() + for name in ( + "kv_caches_base_addr", + "block_len_per_layer", + "kv_block_len_per_layer", + "registered_layer_names", + "registered_layer_indices", + "registered_group_indices", + ): + setattr(worker, name, []) + worker._get_transfer_regions = Mock(return_value=[]) + worker._producer_cache_is_replicated = Mock(return_value=False) + worker._encoder = msgspec.msgpack.Encoder() + worker._sender_executor = None + worker._send_blocks = Mock(return_value=0) + worker.xfer_stats = Mock() + worker._build_transfer_params = AsyncMock(return_value=([1], [2], [16], [], None)) + monkeypatch.setattr(mc, "_align_transfer_regions", lambda *_: ([], [], None)) + monkeypatch.setattr(mc, "_validate_asymmetric_region_lengths", lambda **_: None) + monkeypatch.setattr(mc.envs, "VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT", 0.05) + connector = object.__new__(mc.MooncakeConnector) + connector.connector_worker = worker + return connector + + +async def register(worker, ticket): + worker.sender_loop = asyncio.get_running_loop() + for blocks in ([], [[10]]): + meta = mc.MooncakeConnectorMetadata() + meta.add_new_req(ticket, blocks, {"transfer_id": ticket}, load_remote_cache=False) + await worker.record_send_reqs(meta) + return worker.reqs_need_send[ticket] + + +def packet(rank, ranks, tickets, empty=False): + return mc.MooncakeXferMetadata( + remote_hostname="localhost", + remote_port=1234 + rank, + remote_tp_size=ranks, + remote_tp_rank=rank, + req_blocks={ + f"{ticket}/{row}": (ticket, [] if empty else [[100 + rank * 10 + row]]) + for ticket, rows in tickets.items() + for row in range(rows) + }, + kv_caches_base_addr=[], + block_lens=[], + kv_block_lens=[], + ) + + +async def send(worker, rank, ranks, tickets, empty=False): + sock = SimpleNamespace(send_multipart=AsyncMock()) + await worker.send_kv_to_decode(str(rank).encode(), sock, packet(rank, ranks, tickets, empty)) + return [ + msgspec.msgpack.decode(c.args[0][1], type=mc.MooncakeXferResponse) for c in sock.send_multipart.call_args_list + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("rows", [1, 2]) +@pytest.mark.parametrize("ranks", [2, 4]) +@pytest.mark.parametrize("in_flight", [False, True]) +async def test_source_pages_wait_for_every_rank_and_row(producer, rows, ranks, in_flight): + worker = producer.connector_worker + entered, release = asyncio.Event(), asyncio.Event() + original = worker._build_transfer_params + + async def plan(ready, meta, *regions): + if in_flight and meta.remote_tp_rank == ranks - 1: + entered.set() + await release.wait() + return await original(ready, meta, *regions) + + worker._build_transfer_params = plan + install_mooncake_cfg_fanout(producer) + adapted = worker._build_transfer_params + install_mooncake_cfg_fanout(producer) + assert worker._build_transfer_params is adapted # Do not multiply twice. + ticket = await register(worker, "request") + pending = asyncio.create_task(send(worker, ranks - 1, ranks, {"request": rows})) if in_flight else None + try: + if pending is not None: + await asyncio.wait_for(entered.wait(), 2) + for rank in range(ranks - 1): + await send(worker, rank, ranks, {"request": rows}) + assert ticket.need_send == ranks * rows + assert ticket.sent == (rank + 1) * rows + assert worker.reqs_need_send["request"] is ticket + finished = await worker.fetch_finished_sending_reqs() + scheduler = SimpleNamespace(connector=None, requests={"request": object()}, _free_blocks=Mock()) + Scheduler._update_from_kv_xfer_finished(scheduler, KVConnectorOutput(finished_sending=finished)) + scheduler._free_blocks.assert_not_called() + release.set() + responses = ( + await asyncio.wait_for(pending, 2) if pending else await send(worker, ranks - 1, ranks, {"request": rows}) + ) + assert sum(len(response.ok_reqs or []) for response in responses) == rows + assert all(not response.err_reqs for response in responses) + assert "request" not in worker.reqs_need_send + finished = await worker.fetch_finished_sending_reqs() + assert finished == {"request"} + Scheduler._update_from_kv_xfer_finished(scheduler, KVConnectorOutput(finished_sending=finished)) + scheduler._free_blocks.assert_called_once() + assert not await worker.fetch_finished_sending_reqs() + finally: + release.set() + if pending is not None: + await asyncio.gather(pending, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("empty", [False, True]) +@pytest.mark.parametrize("ranks", [1, 2]) +async def test_mixed_tickets_and_empty_block_notifications(producer, empty, ranks): + worker = producer.connector_worker + if empty: + worker._build_transfer_params.return_value = ([], [], [], [], None) + install_mooncake_cfg_fanout(producer) + single, cfg = await register(worker, "single"), await register(worker, "cfg") + await asyncio.gather(*(send(worker, rank, ranks, {"single": 1, "cfg": 2}, empty) for rank in range(ranks))) + assert (single.need_send, single.sent) == (ranks, ranks) + assert (cfg.need_send, cfg.sent) == (2 * ranks, 2 * ranks) + assert await worker.fetch_finished_sending_reqs() == {"single", "cfg"} + assert not worker.reqs_need_send + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["row_count", "transport"]) +async def test_failed_rank_does_not_report_source_complete(producer, failure): + worker = producer.connector_worker + install_mooncake_cfg_fanout(producer) + ticket = await register(worker, "request") + await send(worker, 0, 2, {"request": 2}) + if failure == "transport": + worker._send_blocks.return_value = -1 + responses = await send(worker, 1, 2, {"request": 1 if failure == "row_count" else 2}) + assert any(response.err_reqs for response in responses) + assert ticket.sent == 2 and ticket.need_send == 4 and ticket.sending == 0 + assert worker.reqs_need_send["request"] is ticket + assert not await worker.fetch_finished_sending_reqs() + # Preserve upstream timeout cleanup after all in-flight writes have stopped. + ticket.expire_time = 0 + assert await worker.fetch_finished_sending_reqs() == {"request"} + assert not worker.reqs_need_send + + +def test_worker_installs_adapter_after_native_initialization(producer, monkeypatch): + import vllm.distributed.kv_transfer as kv_transfer + + worker = producer.connector_worker + original = worker._build_transfer_params + + class UpstreamWorker: + def initialize_from_config(self, config): + assert config == "config" + assert worker._build_transfer_params is original + + class OmniWorker(OmniWorkerMixin, UpstreamWorker): + pass + + monkeypatch.setattr(kv_transfer, "has_kv_transfer_group", lambda: True) + monkeypatch.setattr(kv_transfer, "get_kv_transfer_group", lambda: producer) + object.__new__(OmniWorker).initialize_from_config("config") + assert worker._build_transfer_params is not original + + +@pytest.mark.parametrize("role", ["other_connector", "scheduler", "consumer"]) +def test_adapter_leaves_other_roles_untouched(producer, role): + worker = producer.connector_worker + original = worker._build_transfer_params + if role == "scheduler": + producer.connector_worker = None + elif role == "consumer": + worker.is_kv_consumer = True + install_mooncake_cfg_fanout(object() if role == "other_connector" else producer) + assert worker._build_transfer_params is original diff --git a/tests/diffusion/diffusion_kv/test_paged_attention_adapter.py b/tests/diffusion/diffusion_kv/test_paged_attention_adapter.py index 535c1b411..271f55ee8 100644 --- a/tests/diffusion/diffusion_kv/test_paged_attention_adapter.py +++ b/tests/diffusion/diffusion_kv/test_paged_attention_adapter.py @@ -94,7 +94,7 @@ class _FakeAttentionGroup: return self.builder -@dataclass(frozen=True, slots=True) +@dataclass(slots=True) class _FakeNativeMetadata: build_id: int causal: bool @@ -102,6 +102,7 @@ class _FakeNativeMetadata: query_start_loc_cpu: torch.Tensor positions: torch.Tensor slot_mappings: torch.Tensor + max_num_splits: int = 0 class _FakeLayer: @@ -288,6 +289,8 @@ def test_omni_paged_backend_consumes_context_and_restores_diffusion_shape( assert output.shape == query.shape assert torch.equal(output, query) assert layer.calls[0][0].shape == (5, 2, 4) + assert layer.calls[0][1] is None + assert layer.calls[0][2] is None assert layer.native_events == ["update", "forward"] assert layer.calls[0][3] is events[0][2] @@ -440,9 +443,7 @@ def test_platform_prewrite_updates_once_then_piecewise_reads_cache(monkeypatch: def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.MonkeyPatch) -> None: adapter, _, layer, events = _make_adapter(monkeypatch) - # Exercise the Ascend-only homogeneous batch path. CUDA keeps its native - # output-buffer contract and is covered by the GPU adapter tests. - monkeypatch.setattr(adapter_module.current_omni_platform, "is_npu", lambda: True) + layer.impl.vllm_flash_attn_version = 2 batch = adapter.prepare_batch( [ DiffusionPagedAttentionRow( @@ -499,10 +500,10 @@ def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest. assert torch.equal(layer.updates[0][0], key.reshape(12, 2, 4)) assert torch.equal(layer.updates[0][1], value.reshape(12, 2, 4)) assert [call[0].shape[0] for call in layer.calls] == [4, 6, 2] - assert [call[1].shape[0] for call in layer.calls] == [4, 6, 2] - assert [call[2].shape[0] for call in layer.calls] == [4, 6, 2] + assert all(call[1] is None and call[2] is None for call in layer.calls) assert all(call[3] is event[2] for call, event in zip(layer.calls, events[1:], strict=True)) assert [metadata.build_id for metadata in context.piecewise_native_metadata] == [1, 2, 3] + assert [metadata.max_num_splits for metadata in context.piecewise_native_metadata] == [1, 1, 1] # The first metadata build is the normal whole-query path. Piecewise calls # then use causal [3, 5), full [5, 8), and causal [8, 9) segments. @@ -539,6 +540,51 @@ def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest. ] +def test_homogeneous_piecewise_packs_strided_query_only_per_segment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter, _, _, _ = _make_adapter(monkeypatch) + batch = adapter.prepare_batch( + [ + DiffusionPagedAttentionRow( + request_id="req-0", + sequence_id=0, + query_len=6, + seq_len=9, + kv_start_pos=3, + ), + DiffusionPagedAttentionRow( + request_id="req-1", + sequence_id=1, + query_len=6, + seq_len=9, + kv_start_pos=3, + ), + ] + ) + packed_qkv = torch.randn(2, 6, 3, 2, 4) + query, key, value = packed_qkv.unbind(dim=2) + assert not query.is_contiguous() + metadata = SimpleNamespace( + attn_mask=None, + full_attn_spans=[[(5, 8)], [(5, 8)]], + query_ranges=None, + extra={}, + ) + + with adapter.activate(batch): + context = adapter.prepare_layer_context( + "layer-0", + query, + key, + value, + omni_attn_metadata=metadata, + ) + + assert not context.query.is_contiguous() + assert context.query.data_ptr() == query.data_ptr() + + def test_piecewise_metadata_snapshots_reused_native_scheduler_buffer(monkeypatch: pytest.MonkeyPatch) -> None: adapter, _, _, _ = _make_adapter(monkeypatch) shared_scheduler_metadata = torch.zeros(1, dtype=torch.int32) @@ -1232,7 +1278,7 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads ulysses_degree=2, ) - assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {}, 2)] + assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {}, 1)] assert specialized_backends == [(native_backend, 2)] assert config.attention_config.backend is None assert config.attention_config.backend_per_kind is original_backend_per_kind diff --git a/tests/diffusion/diffusion_kv/test_paged_attention_adapter_gpu.py b/tests/diffusion/diffusion_kv/test_paged_attention_adapter_gpu.py index d890dcc7b..285fb2c96 100644 --- a/tests/diffusion/diffusion_kv/test_paged_attention_adapter_gpu.py +++ b/tests/diffusion/diffusion_kv/test_paged_attention_adapter_gpu.py @@ -43,8 +43,7 @@ class _SmokeDiffusionAttention(nn.Module): self.softmax_scale = _HEAD_SIZE**-0.5 -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for native paged attention") -def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> None: +def _native_adapter(num_rows: int = 1): device = torch.device("cuda", torch.accelerator.current_device_index()) vllm_config = VllmConfig( cache_config=CacheConfig( @@ -89,8 +88,8 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N canonical_spec = native_layer.spec kv_cache_config = KVCacheConfig( - num_blocks=4, - kv_cache_tensors=[build_kv_cache_tensor(canonical_spec, 4, [_LAYER_NAME])], + num_blocks=4 * num_rows, + kv_cache_tensors=[build_kv_cache_tensor(canonical_spec, 4 * num_rows, [_LAYER_NAME])], kv_cache_groups=[ KVCacheGroupSpec( layer_names=[_LAYER_NAME], @@ -120,14 +119,15 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N ) block_tables = BlockTables( block_sizes=[_BLOCK_SIZE], - max_num_reqs=1, - max_num_batched_tokens=32, + max_num_reqs=num_rows, + max_num_batched_tokens=32 * num_rows, max_num_blocks_per_group=[2], device=device, kernel_block_sizes=kernel_block_sizes, ) - block_tables.append_block_ids(0, ([3, 1],), overwrite=True) + for row in range(num_rows): + block_tables.append_block_ids(row, ([4 * row + 3, 4 * row + 1],), overwrite=True) block_tables.apply_staged_writes() adapter = DiffusionPagedAttentionAdapter( vllm_config=vllm_config, @@ -136,11 +136,17 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N block_tables=block_tables, attn_groups=attn_groups, layers={_LAYER_NAME: native_layer}, - resolve_row=lambda _request_id, _sequence_id, _context_id: DiffusionPagedAttentionRowBinding( - row_index=0, + resolve_row=lambda _request_id, sequence_id, _context_id: DiffusionPagedAttentionRowBinding( + row_index=sequence_id, max_seq_len=19, ), ) + return adapter, diffusion_layer, device + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for native paged attention") +def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> None: + adapter, diffusion_layer, device = _native_adapter() prefix_batch = adapter.prepare_batch( [ DiffusionPagedAttentionRow( @@ -258,3 +264,48 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N torch.testing.assert_close(prefix_output, prefix_reference, rtol=2e-2, atol=2e-2) torch.testing.assert_close(suffix_output, suffix_reference, rtol=2e-2, atol=2e-2) torch.testing.assert_close(piecewise_output, piecewise_reference, rtol=2e-2, atol=2e-2) + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for native paged attention") +def test_hunyuan_homogeneous_cfg_paged_attention_graph_replay() -> None: + """Real paged kernels, strided QKV, mixed image spans and changed replay inputs.""" + adapter, layer, device = _native_adapter(num_rows=2) + packed = torch.randn(2, 19, 3, _NUM_HEADS, _HEAD_SIZE, device=device, dtype=torch.float16) + query, key, value = packed.unbind(dim=2) + backend = FlashAttentionImpl( + num_heads=_NUM_HEADS, head_size=_HEAD_SIZE, softmax_scale=layer.softmax_scale, num_kv_heads=_NUM_HEADS + ) + batch = adapter.prepare_batch( + [DiffusionPagedAttentionRow(request_id="cfg", sequence_id=i, query_len=19, seq_len=19) for i in range(2)] + ) + metadata = AttentionMetadata(full_attn_spans=[[(5, 10)], [(5, 10)]]) + with adapter.activate(batch): + context = adapter.prepare_layer_context(_LAYER_NAME, query, key, value, omni_attn_metadata=metadata) + assert context.piecewise_plan.homogeneous_batch_shape == (2, 19) + warmup = torch.cuda.Stream() + warmup.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(warmup): + for _ in range(3): + backend.forward_paged(context) + torch.cuda.current_stream().wait_stream(warmup) + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph): + captured = backend.forward_paged(context) + output_ptr = captured.data_ptr() + mask = torch.ones(19, 19, device=device, dtype=torch.bool).tril() + mask[5:10, 5:10] = True + for _ in range(2): + packed.copy_(torch.randn_like(packed)) + graph.replay() + actual = captured.clone() + eager = backend.forward_paged(context) + reference = F.scaled_dot_product_attention( + query.transpose(1, 2), + key.transpose(1, 2), + value.transpose(1, 2), + attn_mask=mask, + scale=layer.softmax_scale, + ).transpose(1, 2) + torch.testing.assert_close(actual.reshape_as(reference), reference, rtol=2e-2, atol=2e-2) + torch.testing.assert_close(actual, eager, rtol=2e-2, atol=2e-2) + assert captured.data_ptr() == output_ptr diff --git a/tests/diffusion/diffusion_kv/test_spec_discovery.py b/tests/diffusion/diffusion_kv/test_spec_discovery.py index 1ccef7a9f..f3db266a3 100644 --- a/tests/diffusion/diffusion_kv/test_spec_discovery.py +++ b/tests/diffusion/diffusion_kv/test_spec_discovery.py @@ -176,10 +176,11 @@ def test_worker_honors_explicit_kv_memory_budget(monkeypatch) -> None: profile_request = object() assert worker.determine_available_kv_memory(profile_request) == [4096] - worker.model_runner.profile_run.assert_called_once_with(profile_request) + worker.model_runner.profile_run.assert_not_called() def test_worker_treats_zero_kv_memory_budget_as_profiled_auto_sizing(monkeypatch) -> None: + monkeypatch.setattr(diffusion_worker_module, "current_omni_platform", SimpleNamespace(empty_cache=Mock())) worker = object.__new__(DiffusionWorker) worker.rank = 0 worker.init_snapshot = SimpleNamespace(free_memory=900) @@ -209,6 +210,7 @@ def test_worker_treats_zero_kv_memory_budget_as_profiled_auto_sizing(monkeypatch @pytest.mark.parametrize("non_kv_cache_memory", [750, 800]) def test_worker_rejects_non_positive_profiled_kv_memory(monkeypatch, non_kv_cache_memory: int) -> None: + monkeypatch.setattr(diffusion_worker_module, "current_omni_platform", SimpleNamespace(empty_cache=Mock())) worker = object.__new__(DiffusionWorker) worker.rank = 0 worker.init_snapshot = SimpleNamespace(free_memory=900) @@ -245,6 +247,7 @@ def test_worker_rejects_non_positive_profiled_kv_memory(monkeypatch, non_kv_cach def test_worker_profiles_activation_headroom_instead_of_current_residency(monkeypatch) -> None: + monkeypatch.setattr(diffusion_worker_module, "current_omni_platform", SimpleNamespace(empty_cache=Mock())) worker = object.__new__(DiffusionWorker) worker.rank = 0 worker.init_snapshot = SimpleNamespace(free_memory=1200) @@ -310,3 +313,31 @@ def test_rank_probe_gathers_local_failure_before_raising(monkeypatch) -> None: ) assert gathered == [(False, "ValueError: local failure")] + + +def test_hunyuan_native_layer_identity_matches_ar_spec(monkeypatch): + import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer as hy3 + + monkeypatch.setattr(hy3, "get_tensor_model_parallel_world_size", lambda: 1) + monkeypatch.setattr(hy3, "get_sequence_parallel_world_size", lambda: 1) + monkeypatch.setattr(hy3, "get_allgather_parallel_world_size", lambda: 1) + monkeypatch.setattr(hy3, "get_sequence_parallel_rank", lambda: 0) + monkeypatch.setattr(hy3, "QKVParallelLinear", lambda **_: nn.Identity()) + monkeypatch.setattr(hy3, "RowParallelLinear", lambda **_: nn.Identity()) + monkeypatch.setattr(hy3, "get_rope", lambda **_: nn.Identity()) + + # Only backend construction is fake; execute both HY3 constructors and the + # real runner spec-discovery path, including the actual Attention prefix. + def attention(**kwargs): + return _attention(enabled=kwargs.get("paged_kv_cache_role") is not None, prefix=kwargs["prefix"]) + + monkeypatch.setattr(hy3, "Attention", attention) + model = hy3.HunYuanAttention( + config=SimpleNamespace(num_key_value_heads=2, attention_head_dim=8), + hidden_size=16, + num_heads=2, + num_kv_heads=2, + prefix="layers.3.self_attn", + ) + runner = _runner(model.image_attn.attn) + assert set(runner.get_kv_cache_spec()) == {"model.layers.3.self_attn.attn"} diff --git a/tests/diffusion/models/hunyuan_image3/test_diffusion_kv_request.py b/tests/diffusion/models/hunyuan_image3/test_diffusion_kv_request.py index 2a17cd691..af9a41dfc 100644 --- a/tests/diffusion/models/hunyuan_image3/test_diffusion_kv_request.py +++ b/tests/diffusion/models/hunyuan_image3/test_diffusion_kv_request.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from types import SimpleNamespace @@ -362,6 +362,20 @@ def test_prepared_model_inputs_match_local_tokenization( local_inputs = pipeline.prepare_model_inputs(**common_kwargs) prepared_inputs = pipeline.prepare_model_inputs(**common_kwargs, prepared_layout=prepared_layout) + if with_reference_image: + # Preparation runs without a forward context. Coverage must come from + # the scheduler metadata copied onto this request, including every CFG row. + reused = pipeline.prepare_model_inputs( + **common_kwargs, prepared_layout=prepared_layout, kv_computed_tokens=(5,) * len(prefix_lens) + ) + assert reused["cond_vae_images"] is None and reused["cond_vit_images"] is None + partial = pipeline.prepare_model_inputs( + **common_kwargs, + prepared_layout=prepared_layout, + kv_computed_tokens=(5,) * (len(prefix_lens) - 1) + (4,), + ) + assert partial["cond_vae_images"] is not None and partial["cond_vit_images"] is not None + comparable_fields = ( "input_ids", "position_ids", @@ -474,3 +488,33 @@ def test_paged_preprocess_attaches_layout_and_scheduler_kv_requests(monkeypatch) (12, 17, 32) ] assert len(tokenizer.calls) == 1 + + +@pytest.mark.parametrize("transfer_tokens, expected", [(9, [7, 5]), (4, [4, 4])]) +def test_cfg_rows_preserve_reusable_token_ids_and_native_transfer_boundary(transfer_tokens, expected): + from vllm_omni.diffusion.diffusion_kv.kv_connector import prepare_kv_requests + + tokenizer, image_processor = _components([12, 14]) + request = _request(guidance_scale=5.0) + layout = _prepare(request, tokenizer, image_processor) + layout.tokenizer_output.think_recaption_end_pos = [[7], [5]] + layout.tokenizer_output.uncond_cfg_start_pos = [[None], [5]] + rows = build_hunyuan_diffusion_kv_requests(request, layout) + assert [row.prompt_token_ids for row in rows] == [ + layout.tokenizer_output.tokens[0, :7].tolist(), + layout.tokenizer_output.tokens[1, :5].tolist(), + ] + prepare_kv_requests(rows, {"num_transfer_tokens": transfer_tokens}) + assert [row.num_prompt_tokens for row in rows] == expected + assert all(row.kv_transfer_params["num_transfer_tokens"] == transfer_tokens for row in rows) + layout.tokenizer_output.uncond_cfg_start_pos = [[5]] + with pytest.raises(ValueError, match="one boundary per CFG row"): + build_hunyuan_diffusion_kv_requests(request, layout) + + +@pytest.mark.parametrize("computed, expected", [((8, 8), True), ((8, 5), False), ((0, 0), False), ((), False)]) +def test_native_image_reuse_requires_coverage_in_every_cfg_row(computed, expected): + from vllm_omni.diffusion.models.hunyuan_image3.request_layout import native_kv_covers_cond_images + + output = TokenizerEncodeOutput(joint_image_slices=[[slice(2, 8)], [slice(2, 6)]]) + assert native_kv_covers_cond_images(output, computed) is expected diff --git a/tests/diffusion/models/hunyuan_image3/test_image_kv_cache_manager.py b/tests/diffusion/models/hunyuan_image3/test_image_kv_cache_manager.py index 65fc0e67b..db48175c3 100644 --- a/tests/diffusion/models/hunyuan_image3/test_image_kv_cache_manager.py +++ b/tests/diffusion/models/hunyuan_image3/test_image_kv_cache_manager.py @@ -9,6 +9,7 @@ from __future__ import annotations import math from contextlib import ExitStack, contextmanager +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -143,6 +144,104 @@ def test_cache_manager_registers_attention_without_adding_dense_state() -> None: assert mgr.state_dict() == {} +def test_cache_manager_initializes_with_quantization_skip_layers(monkeypatch): + import vllm_omni.diffusion.attention.layer as layer + from vllm_omni.diffusion.config import set_current_diffusion_config + from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import ImageKVCacheManager + + config = SimpleNamespace( + diffusion_attention_config=None, + parallel_config=SimpleNamespace(ring_degree=1), + diffusion_kv_cache_dtype="auto", + diffusion_kv_cache_skip_layer_indices={3}, + ) + monkeypatch.setattr(layer, "get_attn_backend_for_role", lambda **_: (layer.SDPABackend, None)) + monkeypatch.setattr(layer, "build_parallel_attention_strategy", lambda **_: object()) + with patched_mgr_env(), set_current_diffusion_config(config): + # Keep the real Attention constructor and its skip-layer validation. + with patch(f"{_TRANSFORMER_MODULE}.Attention", layer.Attention): + mgr = ImageKVCacheManager( + num_heads=NUM_HEADS, + num_kv_heads=NUM_KV_HEADS, + head_dim=HEAD_DIM, + scaling=SCALING, + prefix="model.layers.3.self_attn", + ) + assert mgr.attn.layer_idx == 3 + assert not mgr.attn._should_apply_kv_cache_quant() + + +@pytest.mark.parametrize("first_step", [True, False]) +def test_native_connector_keeps_imported_prefix_in_paged_attention(first_step) -> None: + from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context + + mgr = _make_cache_mgr() + mgr.attn.paged_kv_active = True + query_len, prefix_len = IMAGE_TOKEN_LEN, 19 + key, value = _make_known_kv(2 * query_len) + spans = [[(prefix_len, prefix_len + query_len)]] * 2 + with ( + set_forward_context(), + override_paged_kv_adapter(object()), + patch.object(mgr, "_forward_dense_legacy", side_effect=AssertionError("dense KV path")), + patch(f"{_TRANSFORMER_MODULE}.repeat_kv", side_effect=AssertionError("KV head expansion")), + patch(f"{_TRANSFORMER_MODULE}.get_paged_kv_computed_tokens", return_value=(prefix_len, prefix_len)), + ): + _call_mgr( + mgr, + 2, + query_len, + prefix_len + query_len, + key, + value, + first_step=first_step, + full_attn_spans=spans, + ) + assert len(mgr.attn.paged_calls) == 1 + assert mgr.attn.paged_calls[0][1].shape == (2, query_len, NUM_KV_HEADS, HEAD_DIM) + assert mgr.attn.paged_metadata[0].attn_mask is None + assert mgr.attn.paged_metadata[0].full_attn_spans == spans + assert mgr.image_kv_cache_map is None + assert not hasattr(mgr, "_materialize_paged_prefix") + + +def test_legacy_tensor_prefix_still_uses_dense_cache_and_reuse() -> None: + mgr = _make_cache_mgr() + prefix_len, prompt_len = 3, 2 + imported = _make_known_kv(prefix_len, base=10.0) + mgr._injected_ar_kv = [imported] + first_len = prompt_len + IMAGE_TOKEN_LEN + key, value = _make_known_kv(first_len, base=20.0) + with ( + patch.object(mgr, "_forward_paged", side_effect=AssertionError("legacy entered paged path")), + ): + _call_mgr( + mgr, + 1, + first_len, + prefix_len + first_len, + key, + value, + first_step=True, + gen_timestep_scatter_index=_gen_timestep_index(1, prompt_len), + ) + assert mgr.image_kv_cache_map is not None + assert mgr.image_kv_cache_map[0].shape[1] == prefix_len + prompt_len + next_key, next_value = _make_known_kv(IMAGE_TOKEN_LEN, base=30.0) + _call_mgr( + mgr, + 1, + IMAGE_TOKEN_LEN, + prefix_len + first_len, + next_key, + next_value, + position_ids=torch.arange(prefix_len + prompt_len, prefix_len + first_len).unsqueeze(0), + ) + assert len(mgr.attn.calls) == 2 + assert not mgr.attn.paged_calls + assert mgr.attn.calls[-1][1].shape == (1, prefix_len + first_len, NUM_HEADS, HEAD_DIM) + + def test_scheduler_paged_kv_runs_piecewise_for_first_and_later_steps() -> None: from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context diff --git a/tests/diffusion/test_diffusion_engine_rpc_routing.py b/tests/diffusion/test_diffusion_engine_rpc_routing.py index 119939ae2..bb64e5f01 100644 --- a/tests/diffusion/test_diffusion_engine_rpc_routing.py +++ b/tests/diffusion/test_diffusion_engine_rpc_routing.py @@ -474,12 +474,13 @@ async def test_cancelled_future_does_not_kill_busy_loop(): @pytest.mark.asyncio -async def test_native_kv_reservation_error_wakes_stream_without_killing_busy_loop(): +@pytest.mark.parametrize("error_type", [ValueError, RuntimeError]) +async def test_native_kv_reservation_error_wakes_stream_without_killing_busy_loop(error_type): loop = asyncio.get_running_loop() engine = _make_engine_with_loop(loop) def fail_reservation(*args, **kwargs): - raise RuntimeError("native allocation bug") + raise error_type("native allocation bug") engine.scheduler._diffusion_kv_manager = SimpleNamespace( has_request=lambda request_id: False, diff --git a/tests/diffusion/test_diffusion_model_runner.py b/tests/diffusion/test_diffusion_model_runner.py index 69a9e4395..f5e4aaa2c 100644 --- a/tests/diffusion/test_diffusion_model_runner.py +++ b/tests/diffusion/test_diffusion_model_runner.py @@ -431,7 +431,13 @@ def test_update_states_carries_prepared_layout() -> None: request.prepared_layout = prepared_layout scheduler_output = SimpleNamespace( finished_req_ids=set(), - scheduled_new_reqs=[SimpleNamespace(request_id=request.request_id, req=request)], + scheduled_new_reqs=[ + SimpleNamespace( + request_id=request.request_id, + req=request, + diffusion_kv_metadata=None, + ) + ], scheduled_cached_reqs=SimpleNamespace(request_ids=[]), ) diff --git a/tests/diffusion/test_diffusion_scheduler.py b/tests/diffusion/test_diffusion_scheduler.py index 20a2f0384..38da458b0 100644 --- a/tests/diffusion/test_diffusion_scheduler.py +++ b/tests/diffusion/test_diffusion_scheduler.py @@ -11,6 +11,7 @@ import torch import vllm.v1.core.single_type_kv_cache_manager as native_kv_managers from pytest_mock import MockerFixture from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec +from vllm.v1.outputs import KVConnectorOutput from tests.helpers.kv_layout import build_kv_cache_tensor from vllm_omni.diffusion.data import DiffusionOutput, DiffusionRequestAbortedError @@ -136,6 +137,55 @@ def _attach_diffusion_kv(request: OmniDiffusionRequest, *, seq_len: int = 8) -> ) +@pytest.mark.asyncio +@pytest.mark.parametrize("scheduler_cls", [RequestScheduler, StepScheduler]) +@pytest.mark.parametrize("failure", ["timeout", "registration"]) +async def test_single_native_kv_failure_reaches_output_stream(mocker, scheduler_cls, failure): + from vllm_omni.diffusion.diffusion_kv.kv_connector import KVTransferRegistrationError + + scheduler = scheduler_cls() + _initialize_paged_scheduler(scheduler, max_num_seqs=1) + connector = mocker.Mock() + connector.get_num_new_matched_tokens.return_value = (4, True) + connector.request_finished.return_value = (False, None) + scheduler._kv_connector = connector + request = _make_request("failed") + _attach_diffusion_kv(request) + request.diffusion_kv_requests[0].prompt_token_ids = [1, 2, 3, 4] + request.kv_transfer_params = {"num_transfer_tokens": 4, "do_remote_prefill": True} + scheduler.add_request(request) + if failure == "registration": + mocker.patch( + "vllm_omni.diffusion.sched.base_scheduler.commit_kv_load", + side_effect=KVTransferRegistrationError("registration failed"), + ) + engine = object.__new__(DiffusionEngine) + engine.scheduler = scheduler + engine.od_config = SimpleNamespace(diffusion_kv_mode=DiffusionKVCacheMode.PAGED_SCHEDULER) + engine.execution_mode = ( + DiffusionExecutionMode.STEP_BATCH if scheduler_cls is StepScheduler else DiffusionExecutionMode.REQUEST_BATCH + ) + engine.abort_queue = queue.Queue() + engine._cv = threading.Condition() + engine.main_loop = asyncio.get_running_loop() + stream = asyncio.Queue() + engine._out_streams = {"failed": stream} + engine.executor = mocker.Mock() + engine.executor.prepare_kv_for_forward.return_value = KVConnectorOutput() + scheduled = scheduler.schedule() + engine._prepare_kv_for_forward(scheduled) + assert scheduled.scheduled_request_ids == [] + output = BatchRunnerOutput.from_list([]) + finished = scheduler.update_from_output(scheduled, output) + engine._emit_outputs(finished, scheduled.scheduled_request_ids, output) + terminal = await asyncio.wait_for(stream.get(), timeout=1) + assert terminal.finished + assert terminal.error == ("Timed out receiving diffusion KV" if failure == "timeout" else "registration failed") + assert scheduler.get_request_state("failed") is None + # Reporting failure must not recycle pages that the sender can still write. + assert scheduler._diffusion_kv_manager.has_request("failed") == (failure == "timeout") + + class _StubScheduler: def __init__(self, request: OmniDiffusionRequest, output) -> None: self._request = request @@ -602,16 +652,22 @@ class TestRequestScheduler: assert sched_output.finished_req_ids == {"impossible"} assert _new_ids(sched_output) == ["schedulable"] - def test_diffusion_kv_internal_allocation_error_finishes_request(self, monkeypatch) -> None: + def test_diffusion_kv_internal_allocation_error_is_request_scoped(self, monkeypatch) -> None: _initialize_paged_scheduler(self.scheduler) request = _make_request("native-error") _attach_diffusion_kv(request) self.scheduler.add_request(request) manager = self.scheduler._diffusion_kv_manager assert manager is not None + reserve_request = manager.reserve_request + next_request = _make_request("after-error") + _attach_diffusion_kv(next_request) + self.scheduler.add_request(next_request) def raise_native_error(*args, **kwargs): - raise ValueError("native allocation bug") + if args[0] == "native-error": + raise ValueError("native allocation bug") + return reserve_request(*args, **kwargs) monkeypatch.setattr( manager, @@ -619,14 +675,150 @@ class TestRequestScheduler: raise_native_error, ) - sched_output = self.scheduler.schedule() - + output = self.scheduler.schedule() state = self.scheduler.get_request_state("native-error") - assert state is not None assert state.status == DiffusionRequestStatus.FINISHED_ERROR assert state.error == "native allocation bug" - assert sched_output.finished_req_ids == {"native-error"} - assert sched_output.scheduled_request_ids == [] + assert output.finished_req_ids == {"native-error"} + assert _new_ids(output) == ["after-error"] + assert not manager.has_request("native-error") + + @pytest.mark.parametrize("action", ["abort", "timeout", "preempt"]) + def test_diffusion_kv_loading_defers_free_and_blocks_preempt(self, mocker: MockerFixture, action: str) -> None: + _initialize_paged_scheduler(self.scheduler, num_blocks=5) + connector = mocker.Mock() + connector.get_num_new_matched_tokens.return_value = (4, True) + connector.request_finished.return_value = (False, None) + self.scheduler._kv_connector = connector + request = _make_request("loading") + request.diffusion_kv_requests = tuple( + DiffusionKVRequest( + f"loading/diffusion-kv/{i}", + sequence_id=i, + prefix_len=4, + target_len=4, + seq_len=8, + prompt_token_ids=[1, 2, 3, 4], + ) + for i in range(2) + ) + request.kv_transfer_params = {"num_transfer_tokens": 4, "do_remote_prefill": True} + manager = self.scheduler._diffusion_kv_manager + assert manager is not None + pool = manager.native_manager.block_pool + initial_free_blocks = pool.get_num_free_blocks() + self.scheduler.add_request(request) + scheduled = self.scheduler.schedule() + internal_ids = {f"loading/diffusion-kv/{i}" for i in range(2)} + assert scheduled.kv_transfer_request_ids == internal_ids + assert pool.get_num_free_blocks() == initial_free_blocks - 4 + metadata = manager.get_metadata("loading") + state = self.scheduler.get_request_state("loading") + free_request = mocker.spy(manager, "free_request") + + if action == "abort": + self.scheduler.finish_requests("loading", DiffusionRequestStatus.FINISHED_ABORTED) + elif action == "timeout": + assert self.scheduler.fail_incomplete_kv_loads(internal_ids) == {"loading"} + assert state.error == "Timed out receiving diffusion KV" + if action != "preempt": + assert state.is_finished() + assert self.scheduler.pop_request_state("loading") is state + with pytest.raises(ValueError, match="already active"): + self.scheduler.add_request(request) + assert self.scheduler.get_diffusion_kv_cleanup_targets(["loading"]) == [] + + # Empty, unrelated, and partial completion retain both CFG rows even + # after the frontend has consumed and popped the terminal request. + for finished in (set(), {"other/diffusion-kv/0"}, {"loading/diffusion-kv/0"}): + self.scheduler.update_kv_connector_output(KVConnectorOutput(finished_recving=finished)) + assert "loading" in self.scheduler._kv_loading_request_ids + assert self.scheduler.preempt_request("loading") is False + assert not self.scheduler.completed_kv_drains() + assert manager.has_request("loading") + assert manager.get_metadata("loading") == metadata + assert pool.get_num_free_blocks() == initial_free_blocks - 4 + free_request.assert_not_called() + connector.request_finished.assert_not_called() + + # CFG completion may arrive in different polls, after rank aggregation. + self.scheduler.update_kv_connector_output(KVConnectorOutput(finished_recving={"loading/diffusion-kv/1"})) + assert "loading" not in self.scheduler._kv_loading_request_ids + if action == "preempt": + assert self.scheduler.preempt_request("loading") is True + assert state.status == DiffusionRequestStatus.PREEMPTED + assert manager.get_metadata("loading") == metadata + self.scheduler.finish_requests("loading", DiffusionRequestStatus.FINISHED_ABORTED) + else: + assert self.scheduler.completed_kv_drains() == {"loading"} + assert self.scheduler.get_diffusion_kv_cleanup_targets(["loading"]) == [ + ("loading", metadata.allocation_generation) + ] + self.scheduler.release_kv_drains({"loading"}) + assert not self.scheduler._running and not self.scheduler._waiting + assert self.scheduler._kv_finished_request_ids == internal_ids + assert connector.request_finished.call_count == 2 + free_request.assert_called_once_with("loading") + assert not manager.has_request("loading") + assert pool.get_num_free_blocks() == initial_free_blocks + + @pytest.mark.parametrize("cancel", [False, True]) + def test_engine_timeout_keeps_serving_and_reclaims_late_receive(self, mocker, cancel): + _initialize_paged_scheduler(self.scheduler, num_blocks=16, max_num_seqs=2) + connector = mocker.Mock() + connector.get_num_new_matched_tokens.return_value = (4, True) + connector.request_finished.return_value = (False, None) + self.scheduler._kv_connector = connector + for rid in ("slow", "ready"): + req = _make_request(rid) + _attach_diffusion_kv(req) + req.diffusion_kv_requests[0].prompt_token_ids = [1, 2, 3, 4] + req.kv_transfer_params = {"num_transfer_tokens": 4, "do_remote_prefill": True} + self.scheduler.add_request(req) + scheduled = self.scheduler.schedule() + engine = object.__new__(DiffusionEngine) + engine.scheduler = self.scheduler + engine.od_config = SimpleNamespace(diffusion_kv_mode=DiffusionKVCacheMode.PAGED_SCHEDULER) + engine.abort_queue = queue.Queue() + engine._cv = threading.Condition() + if cancel: + engine.abort_queue.put("slow") + engine.executor = mocker.Mock() + engine.executor.prepare_kv_for_forward.side_effect = [ + KVConnectorOutput(finished_recving={"ready/diffusion-kv/0"}), + KVConnectorOutput(finished_recving={"slow/diffusion-kv/0"}), + ] + fail_engine = mocker.patch.object(engine, "_fail_engine") + engine._prepare_kv_for_forward(scheduled) + assert scheduled.scheduled_request_ids == ["ready"] + assert scheduled.finished_req_ids == {"slow"} + expected_status = DiffusionRequestStatus.FINISHED_ABORTED if cancel else DiffusionRequestStatus.FINISHED_ERROR + assert self.scheduler.get_request_state("slow").status == expected_status + assert self.scheduler._diffusion_kv_manager.has_request("slow") + engine._remove_diffusion_kv_requests(["slow"]) + engine.executor.remove_diffusion_kv_requests.assert_not_called() + self.scheduler.pop_request_state("slow") + generation = self.scheduler._kv_request_generations["slow"] + engine._prepare_kv_for_forward(self.scheduler.schedule()) + engine.executor.remove_diffusion_kv_requests.assert_called_once_with([("slow", generation)]) + assert not self.scheduler._diffusion_kv_manager.has_request("slow") + assert self.scheduler._diffusion_kv_manager.has_request("ready") + fail_engine.assert_not_called() + + def test_slow_kv_match_does_not_block_later_request(self, mocker): + _initialize_paged_scheduler(self.scheduler, max_num_seqs=2) + connector = mocker.Mock() + connector.get_num_new_matched_tokens.side_effect = lambda req, _: ( + (None, False) if req.request_id.startswith("slow/") else (0, False) + ) + self.scheduler._kv_connector = connector + for rid in ("slow", "ready"): + request = _make_request(rid) + _attach_diffusion_kv(request) + self.scheduler.add_request(request) + output = self.scheduler.schedule() + assert _new_ids(output) == ["ready"] + assert list(self.scheduler._waiting) == ["slow"] def test_diffusion_kv_preemption_retains_allocation(self) -> None: _initialize_paged_scheduler(self.scheduler, num_blocks=3) diff --git a/tests/diffusion/test_multiproc_engine_concurrency.py b/tests/diffusion/test_multiproc_engine_concurrency.py index ade8d8fa7..50fca3ea8 100644 --- a/tests/diffusion/test_multiproc_engine_concurrency.py +++ b/tests/diffusion/test_multiproc_engine_concurrency.py @@ -835,11 +835,18 @@ class TestWorkerProcRpcRankStatus: def test_execute_rpc_returns_rank_status_envelope(self, monkeypatch): proc = self._make_worker_proc() + cpu_group = object() monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True) monkeypatch.setattr(torch.distributed, "get_world_size", lambda: 2) + monkeypatch.setattr( + diffusion_worker_module, + "get_world_group", + lambda: SimpleNamespace(cpu_group=cpu_group), + ) - def _all_gather_object(out, local): + def _all_gather_object(out, local, *, group): + assert group is cpu_group out[0] = local out[1] = { "rank": 1, diff --git a/tests/engine/test_async_omni_engine_stage_init.py b/tests/engine/test_async_omni_engine_stage_init.py index 75301ee0a..ec897bd7d 100644 --- a/tests/engine/test_async_omni_engine_stage_init.py +++ b/tests/engine/test_async_omni_engine_stage_init.py @@ -2395,13 +2395,20 @@ def test_dist_stage_runtime_applies_local_dp_to_stage_config(typed): @pytest.mark.parametrize("num_replicas", [1, 2, 3]) -def test_typed_diffusion_replicas_share_one_config_between_planning_and_launch(mocker, num_replicas): +@pytest.mark.parametrize("kv_owner", [None, "connector_config", "diffusion_config"]) +def test_typed_diffusion_replicas_share_one_config_between_planning_and_launch(mocker, num_replicas, kv_owner): from vllm_omni.config.config_factory import StageConfigFactory from vllm_omni.engine import stage_runtime as runtime_module stage = StageConfigFactory.create_typed_default_diffusion( "generic-diffusion", {"model_class_name": "QwenImagePipeline"} ).stage_configs[0] + if kv_owner is not None: + from vllm.config import KVTransferConfig + + getattr(stage, kv_owner).kv_transfer_config = KVTransferConfig( + kv_connector="MooncakeConnector", kv_role="kv_consumer", engine_id="dit" + ) devices = ",".join(str(i) for i in range(num_replicas)) stage.runtime_config.devices = devices stage.runtime_config.num_replicas = num_replicas @@ -2432,11 +2439,114 @@ def test_typed_diffusion_replicas_share_one_config_between_planning_and_launch(m for i, plan in enumerate(replicas): assert plan.metadata.runtime_cfg is plan.stage_cfg.runtime_config assert plan.stage_cfg.runtime_config.devices == str(i) - if num_replicas > 1: + if num_replicas > 1 or kv_owner is not None: assert plan.stage_cfg is not stage + if kv_owner is not None: + from vllm_omni.engine.stage_init_utils import build_engine_args_dict_from_omni_stage_config + + assert getattr(stage, kv_owner).kv_transfer_config.engine_id == "dit" + engine_args = build_engine_args_dict_from_omni_stage_config(plan.stage_cfg, "dummy-model") + assert engine_args["kv_transfer_config"].engine_id == f"dit-s0-r{i}" assert runtime._initialize_local_diffusion_replica(plan, stage_init_timeout=1) is client assert launch.call_args.kwargs["stage_config"] is plan.stage_cfg assert launch.call_args.kwargs["metadata"] is plan.metadata assert launch.call_args.kwargs["use_inline"] is (num_replicas == 1) assert launch.call_count == 2 * num_replicas + + +@pytest.mark.parametrize("typed", [False, True], ids=["legacy", "typed"]) +@pytest.mark.parametrize("num_replicas", [1, 2]) +def test_native_kv_producer_replica_identity_and_bootstrap_are_isolated(mocker, typed, num_replicas): + from vllm.config import KVTransferConfig + + from vllm_omni.config.omni_config import VllmOmniARStageConfig + from vllm_omni.config.stage_config import StagePipelineConfig + from vllm_omni.engine import stage_runtime as runtime_module + + kv_config = KVTransferConfig( + kv_connector="MooncakeConnector", + kv_role="kv_producer", + engine_id="ar", + kv_ip="127.0.0.1", + kv_connector_extra_config={"bootstrap_port": 9100}, + ) + if typed: + stage = VllmOmniARStageConfig(stage_pipeline_config=StagePipelineConfig(stage_id=0, model_stage="ar")) + stage.connector_config.kv_transfer_config = kv_config + runtime_cfg = stage.runtime_config + else: + runtime_cfg = OmegaConf.create({"devices": "0,1", "env": {"KEEP": "value"}}) + stage = types.SimpleNamespace(stage_id=0, engine_args={"kv_transfer_config": kv_config}, runtime=runtime_cfg) + metadata = _make_llm_metadata(0) + mocker.patch.object( + runtime_module, + "extract_legacy_stage_metadata", + side_effect=lambda cfg: types.SimpleNamespace(**{**metadata.__dict__, "runtime_cfg": cfg.runtime}), + ) + runtime_cfg.devices = "0,1" + runtime_cfg.env = {"KEEP": "value"} + runtime = StageRuntime( + stage_configs=[stage], + model="dummy-model", + config_path="dummy-config", + stage_init_timeout=1, + async_chunk=False, + ) + mocker.patch.object(runtime_module, "get_stage_connector_spec", return_value={}) + mocker.patch.object(runtime_module, "resolve_omni_kv_config_for_stage", return_value=(None, None, None)) + for builder in ("build_engine_args_dict", "build_engine_args_dict_from_omni_stage_config"): + mocker.patch.object(runtime_module, builder, return_value={}) + mocker.patch.object( + runtime_module, + "build_vllm_config", + return_value=(types.SimpleNamespace(kv_transfer_config=kv_config), object), + ) + + for _ in range(2): + plans = runtime._build_logical_stage_init_plans(None, [num_replicas], {}) + for i, replica in enumerate(plans[0].replicas): + config = replica.stage_vllm_config.kv_transfer_config + assert config.engine_id == f"ar-s0-r{i}" + assert config.kv_connector_extra_config["bootstrap_addr"] == f"http://127.0.0.1:{9100 + i}" + assert dict(replica.metadata.runtime_cfg.env) == { + "KEEP": "value", + "VLLM_MOONCAKE_BOOTSTRAP_PORT": str(9100 + i), + } + if typed: + assert replica.metadata.runtime_cfg is replica.stage_cfg.runtime_config + assert kv_config.engine_id == "ar" + assert kv_config.kv_connector_extra_config == {"bootstrap_port": 9100} + assert dict(runtime_cfg.env) == {"KEEP": "value"} + + +@pytest.mark.parametrize( + "roles, sources, async_chunk, valid", + [ + (["kv_producer", "kv_consumer"], [0], False, True), + (["kv_producer", "kv_consumer"], [0], True, False), + ([None, "kv_consumer"], [0], False, False), + (["kv_producer", None], [0], False, False), + ([None, "kv_producer", "kv_consumer"], [1], False, False), + (["kv_producer", "kv_consumer"], [1], False, False), + ([None, None], [0], False, True), + ], +) +def test_native_kv_topology_rejects_silent_legacy_fallback(roles, sources, async_chunk, valid): + plans = [] + for stage_id, role in enumerate(roles): + diffusion = stage_id == len(roles) - 1 + config = types.SimpleNamespace(kv_role=role) if role else None + replica = types.SimpleNamespace( + metadata=types.SimpleNamespace(stage_type="diffusion" if diffusion else "llm", engine_input_source=sources), + stage_vllm_config=None if diffusion else types.SimpleNamespace(kv_transfer_config=config), + stage_cfg=types.SimpleNamespace(engine_args={"kv_transfer_config": config}), + ) + plans.append(types.SimpleNamespace(stage_id=stage_id, replicas=[replica])) + runtime = object.__new__(StageRuntime) + runtime._async_chunk = async_chunk + if valid: + runtime._validate_native_kv_topology(plans) + else: + with pytest.raises(ValueError, match="two-stage"): + runtime._validate_native_kv_topology(plans) diff --git a/tests/engine/test_orchestrator.py b/tests/engine/test_orchestrator.py index 3b6ee7e7b..775eeb86b 100644 --- a/tests/engine/test_orchestrator.py +++ b/tests/engine/test_orchestrator.py @@ -22,6 +22,7 @@ from vllm.v1.engine.exceptions import EngineDeadError from vllm.v1.metrics.stats import IterationStats from vllm_omni.engine import OmniEngineCoreOutput +from vllm_omni.engine.errors import NativeKVHandoffError from vllm_omni.engine.messages import ( AbortRequestMessage, AbortResultMessage, @@ -1414,6 +1415,105 @@ async def test_handle_streaming_update_unknown_request_is_dropped() -> None: assert "req-unknown" not in orchestrator.request_states +@pytest.mark.asyncio +@pytest.mark.parametrize("native_kv", [False, True]) +async def test_add_request_attaches_native_kv_ticket_before_dispatch(mocker, native_kv) -> None: + sampling = SamplingParams(extra_args={"keep": "sampling"}) + prompt = SimpleNamespace(sampling_params=SamplingParams(extra_args={"keep": "prompt"})) + source = StagePool( + 0, + FakeStageClient(), + stage_vllm_config=SimpleNamespace( + kv_transfer_config=SimpleNamespace(kv_role="kv_producer") if native_kv else None + ), + ) + target = StagePool(1, FakeStageClient(stage_type="diffusion", final_output=True)) + orchestrator = Orchestrator( + request_async_queue=asyncio.Queue(), + output_async_queue=asyncio.Queue(), + rpc_async_queue=asyncio.Queue(), + stage_pools=[source, target], + ) + + async def check_dispatch(request_id, req_state, request, **kwargs): + assert req_state.native_kv_transfer_id == ("xfer-req" if native_kv else None) + for params, preserved in ((sampling, "sampling"), (request.sampling_params, "prompt")): + assert params.extra_args["keep"] == preserved + if native_kv: + assert params.extra_args["kv_transfer_params"] == { + "transfer_id": "xfer-req", + "do_remote_decode": True, + "do_remote_prefill": False, + } + else: + assert "kv_transfer_params" not in params.extra_args + return 0 + + dispatch = mocker.patch.object(source, "submit_initial", side_effect=check_dispatch) + await orchestrator._handle_add_request( + StageSubmissionMessage( + type="add_request", + request_id="req", + prompt=prompt, + original_prompt={}, + output_prompt_text=None, + sampling_params_list=[sampling, OmniDiffusionSamplingParams()], + final_stage_id=1, + preprocess_ms=0, + request_timestamp=0, + enqueue_ts=0, + ) + ) + dispatch.assert_awaited_once() + + +def test_native_handoff_uses_bound_replica_and_reports_missing_binding(mocker) -> None: + orchestrator = object.__new__(Orchestrator) + orchestrator.stage_pools = [mocker.Mock()] + orchestrator.stage_pools[0].get_bound_client.return_value = None + req_state = SimpleNamespace(native_kv_transfer_id="xfer-req") + output = SimpleNamespace(kv_transfer_params={"num_transfer_tokens": 4}) + + with pytest.raises(NativeKVHandoffError, match="bound AR replica"): + orchestrator._diffusion_submit_kwargs("req", 0, SimpleNamespace(engine_input_source=[0]), req_state, output) + orchestrator.stage_pools[0].get_bound_client.assert_called_once_with("req") + # The pool default points at replica 0; this request actually used replica 1. + orchestrator.stage_pools[0].stage_vllm_config = SimpleNamespace( + kv_transfer_config=SimpleNamespace(engine_id="ar-0") + ) + config = SimpleNamespace(engine_id="ar-1", kv_connector_extra_config={"bootstrap_addr": "http://host:8999"}) + orchestrator.stage_pools[0].get_bound_client.return_value = SimpleNamespace( + vllm_config=SimpleNamespace(kv_transfer_config=config) + ) + params = orchestrator._diffusion_submit_kwargs( + "req", 0, SimpleNamespace(engine_input_source=[0]), req_state, output + ) + assert params["kv_transfer_params"]["remote_engine_id"] == "ar-1" + assert params["kv_transfer_params"]["remote_bootstrap_addr"] == "http://host:8999" + assert "remote_engine_id" not in output.kv_transfer_params + + +@pytest.mark.asyncio +async def test_native_handoff_failure_is_request_scoped(mocker): + orchestrator = object.__new__(Orchestrator) + fail = mocker.patch.object(orchestrator, "_fail_request_client_error", new_callable=mocker.AsyncMock) + + def lost_binding(): + raise NativeKVHandoffError("bound AR replica is unavailable") + + assert not await orchestrator._dispatch_or_fail_request( + lost_binding, req_id="req", stage_id=1, operation="inter-stage forward" + ) + fail.assert_awaited_once_with( + "req", + 1, + "bound AR replica is unavailable", + status_code=502, + error_type="NativeKVHandoffError", + release_owners=True, + ) + + async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> None: class RecordingPool: def __init__(self) -> None: diff --git a/tests/entrypoints/test_async_omni_diffusion_config.py b/tests/entrypoints/test_async_omni_diffusion_config.py index ed886d026..1d106932d 100644 --- a/tests/entrypoints/test_async_omni_diffusion_config.py +++ b/tests/entrypoints/test_async_omni_diffusion_config.py @@ -190,7 +190,7 @@ def test_legacy_default_stage_build_accepts_engine_adapter_metadata(monkeypatch) )[0] assert "api_key" not in stage_dict["engine_args"] stage_cfg = create_config(stage_dict) - metadata = SimpleNamespace(stage_id=0, cfg_kv_collect_func=None) + metadata = SimpleNamespace(stage_id=0, cfg_kv_collect_func=None, default_sampling_params=None) monkeypatch.setattr(stage_init_utils.current_omni_platform, "get_device_count", lambda: 1) config = stage_init_utils.build_diffusion_config("unused", stage_cfg, metadata) diff --git a/vllm_omni/config/omni_config.py b/vllm_omni/config/omni_config.py index 8f7319b13..ef48603cf 100644 --- a/vllm_omni/config/omni_config.py +++ b/vllm_omni/config/omni_config.py @@ -271,6 +271,7 @@ class _ParallelEngineOverrides(_ParallelConfigEngineOverrides, total=False): class _ConnectorEngineOverrides(TypedDict, total=False): omni_kv_config: dict[str, Any] + kv_transfer_config: KVTransferConfig | dict[str, Any] @dataclass(frozen=True) @@ -332,6 +333,8 @@ def _first_defined(*values: Any) -> Any: def _validate_async_chunk_support(pipeline: PipelineConfig, deploy: DeployConfig) -> None: has_inter_stage_edges = any(stage.input_sources for stage in pipeline.stages) + if deploy.async_chunk and any(stage.engine_extras.get("kv_transfer_config") for stage in deploy.stages): + raise ValueError("Native AR-to-DiT KV transfer requires async_chunk=False.") if ( deploy.async_chunk and has_inter_stage_edges @@ -605,6 +608,7 @@ class OmniStageConnectorConfig: async_chunk: bool = False omni_kv_config: dict[str, Any] | None = None + kv_transfer_config: KVTransferConfig | None = None stage_connector: dict[str, Any] = field( default_factory=lambda: { "name": "SharedMemoryConnector", @@ -2078,6 +2082,7 @@ def _build_connector_config( return cast(Any, OmniStageConnectorConfig)( async_chunk=resolve_stage_async_chunk(deploy, stage_deploy), omni_kv_config=_copy_value(engine.get("omni_kv_config")), + kv_transfer_config=_copy_value(engine.get("kv_transfer_config")), output_connectors=_copy_value(output_connectors) if output_connectors else None, input_connectors=_copy_value(input_connectors) if input_connectors else None, ) diff --git a/vllm_omni/core/sched/omni_ar_scheduler.py b/vllm_omni/core/sched/omni_ar_scheduler.py index 3d866fda7..385e180af 100644 --- a/vllm_omni/core/sched/omni_ar_scheduler.py +++ b/vllm_omni/core/sched/omni_ar_scheduler.py @@ -23,7 +23,10 @@ from vllm.v1.request import Request, RequestStatus, StreamingUpdate from vllm.v1.spec_decode.metrics import SpecDecodingStats from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin -from vllm_omni.core.sched.utils import omni_routed_experts_for_request +from vllm_omni.core.sched.utils import ( + free_kv_blocks_in_physical_order, + omni_routed_experts_for_request, +) from vllm_omni.engine import OmniEngineCoreOutput from vllm_omni.engine.serialization import deserialize_additional_information @@ -127,6 +130,26 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): # Output placeholders are zero when async scheduling isn't used return request.num_computed_tokens - request.num_output_placeholders + def _uses_native_mooncake_connector(self) -> bool: + kv_config = getattr(self.vllm_config, "kv_transfer_config", None) + return getattr(kv_config, "kv_connector", None) == "MooncakeConnector" + + def _free_request_blocks(self, request: Request) -> None: + """Keep native Mooncake pages coalescible without changing vLLM APIs.""" + + if not self._uses_native_mooncake_connector() or self.kv_cache_manager.enable_caching: + super()._free_request_blocks(request) + return + if not self.defer_block_free or request.last_sched_seq <= self.processed_step_seq: + free_kv_blocks_in_physical_order(self.kv_cache_manager, request) + return + blocks = self.kv_cache_manager.pop_blocks_for_free(request) + if blocks: + # vLLM's deferred-free drain reverses this list before returning + # it to BlockPool, so store the inverse of the desired order. + blocks.sort(key=lambda block: block.block_id, reverse=True) + self.deferred_frees.append((self.sched_step_seq, blocks)) + def _resolve_kv_connector_type(self) -> str: """Connector backend name for the ``kv_wait_s`` label, or ``unknown``.""" connector_cfg = self._get_omni_kv_config_value("connector_config") @@ -755,9 +778,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): if req_id in self.waiting_for_transfer_free: req = self.requests.get(req_id) if req: - self.kv_cache_manager.free(req) - if req_id in self.requests: - del self.requests[req_id] + self._free_blocks(req) if req_id in self.transfer_triggered_requests: self.transfer_triggered_requests.remove(req_id) self.active_kv_transfers.discard(req_id) @@ -1041,7 +1062,36 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): getattr(self, "_inflight_prefills", set()).discard(request) # 1. Standard cleanup parts from base _free_request - connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request) + status = getattr(request, "status", None) + transfer_params = getattr(request, "kv_transfer_params", None) + native_transfer = ( + transfer_params + and transfer_params.get("do_remote_decode") + and getattr(getattr(self.vllm_config, "kv_transfer_config", None), "kv_connector", None) + == "MooncakeConnector" + ) + if native_transfer and status == RequestStatus.FINISHED_STOPPED: + request.status = RequestStatus.FINISHED_LENGTH_CAPPED + num_computed_tokens = None + if native_transfer: + # vLLM clips the block table with + # get_block_ids_for_computed_tokens(). Exclude Omni's optimistic + # async output placeholders from the physical transfer boundary. + num_computed_tokens = request.num_computed_tokens + request.num_computed_tokens = self._get_confirmed_num_computed_tokens(request) + try: + connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request) + finally: + if num_computed_tokens is not None: + request.num_computed_tokens = num_computed_tokens + if status is not None: + request.status = status + if native_transfer and connector_delay_free_blocks: + kv_xfer_params = { + **(kv_xfer_params or {}), + "transfer_id": transfer_params["transfer_id"], + "num_transfer_tokens": self._get_confirmed_num_computed_tokens(request), + } # EC Connector: mirror the KV hook (upstream v0.28 _free_request). # The contract requires firing before the encoder cache is freed so diff --git a/vllm_omni/core/sched/utils.py b/vllm_omni/core/sched/utils.py index 7e0c16bd4..12cadae73 100644 --- a/vllm_omni/core/sched/utils.py +++ b/vllm_omni/core/sched/utils.py @@ -3,7 +3,9 @@ """Shared utilities for omni schedulers.""" import numpy as np +from vllm.v1.core.kv_cache_manager import KVCacheManager from vllm.v1.outputs import RoutedExpertsLists +from vllm.v1.request import Request def omni_routed_experts_for_request(routed_experts: RoutedExpertsLists, request) -> np.ndarray | None: @@ -21,3 +23,19 @@ def omni_routed_experts_for_request(routed_experts: RoutedExpertsLists, request) mask = np.isin(routed_experts.slot_mapping, list(slot_set)) data = routed_experts.routing_data[mask] return data if data.size > 0 else None + + +def free_kv_blocks_in_physical_order(manager: KVCacheManager, request: Request) -> None: + """Return non-cached request blocks in increasing physical order. + + vLLM's block pool deliberately honors the eviction-priority order passed + to ``free_blocks``. For a non-caching P/D pool there is no LRU priority to + preserve, so physical order lets native connectors coalesce adjacent + source/destination pages on the next allocation. + """ + + if manager.enable_caching: + manager.free(request) + return + blocks = manager.pop_blocks_for_free(request) + manager.block_pool.free_blocks(sorted(blocks, key=lambda block: block.block_id)) diff --git a/vllm_omni/deploy/hunyuan-image-3-mooncakeconnector.yaml b/vllm_omni/deploy/hunyuan-image-3-mooncakeconnector.yaml new file mode 100644 index 000000000..ea088f916 --- /dev/null +++ b/vllm_omni/deploy/hunyuan-image-3-mooncakeconnector.yaml @@ -0,0 +1,82 @@ +pipeline: hunyuan_image_3_moe +async_chunk: false +trust_remote_code: true + +stages: + - stage_id: 0 + is_comprehension: true + # Return AR text (including CoT) alongside the final image. + final_output: true + final_output_type: text + num_replicas: 2 + devices: "0,1,2,3" + tensor_parallel_size: 2 + max_num_seqs: 1 + max_num_batched_tokens: 32768 + kv_cache_memory_bytes: 2147483648 + gpu_memory_utilization: 0.95 + enforce_eager: true + moe_backend: triton + env: + MC_TCP_ENABLE_CONNECTION_POOL: "1" + MC_TCP_SLICE_SIZE: "4194304" + VLLM_USE_FLASHINFER_SAMPLER: "0" + hf_overrides: + rope_parameters: + mrope_section: [0, 32, 32] + rope_type: default + kv_transfer_config: + kv_connector: MooncakeConnector + kv_role: kv_producer + engine_id: hunyuan-image3-ar + kv_ip: 127.0.0.1 + kv_connector_extra_config: + mooncake_protocol: tcp + bootstrap_port: 8998 + default_sampling_params: + temperature: 0.0 + max_tokens: 8192 + detokenize: true + skip_special_tokens: false + include_stop_str_in_output: true + + - stage_id: 1 + num_replicas: 2 + devices: "4,5,6,7" + max_num_seqs: 1 + max_num_batched_tokens: 16384 + max_model_len: 12288 + kv_cache_memory_bytes: 1073741824 + enforce_eager: true + # Registered Mooncake KV pages must remain mapped for the connector lifetime. + enable_sleep_mode: false + moe_backend: triton + env: + MC_TCP_ENABLE_CONNECTION_POOL: "1" + MC_TCP_SLICE_SIZE: "4194304" + PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True + distributed_executor_backend: mp + diffusion_kv_mode: paged_scheduler + diffusion_kv_max_rows_per_request: 2 + kv_transfer_config: + kv_connector: MooncakeConnector + kv_role: kv_consumer + engine_id: hunyuan-image3-dit + kv_connector_extra_config: + mooncake_protocol: tcp + # Seconds to wait before failing a request; in-flight pages remain reserved. + transfer_timeout: 60.0 + parallel_config: + tensor_parallel_size: 2 + enable_expert_parallel: true + default_sampling_params: + num_inference_steps: 2 + guidance_scale: 2.0 + height: 512 + width: 512 + +edges: + - from: 0 + to: 1 + window_size: -1 + max_inflight: 1 diff --git a/vllm_omni/diffusion/attention/backends/flash_attn.py b/vllm_omni/diffusion/attention/backends/flash_attn.py index 073fd3bd5..679bf4955 100644 --- a/vllm_omni/diffusion/attention/backends/flash_attn.py +++ b/vllm_omni/diffusion/attention/backends/flash_attn.py @@ -311,11 +311,17 @@ class FlashAttentionImpl(AttentionImpl[AttentionMetadata]): if native_impl is None: raise RuntimeError(f"Native attention implementation is not bound for diffusion layer {layer.layer_name!r}") prewrite_kv = current_omni_platform.requires_diffusion_paged_kv_prewrite() - read_kv_from_cache = prewrite_kv and layer.attn_backend.forward_includes_kv_cache_update + forward_updates_cache = layer.attn_backend.forward_includes_kv_cache_update + # Match vLLM's split update/attention contract. Once K/V has been + # written by do_kv_cache_update(), decoder attention consumes the + # paged cache and does not need segment-local K/V tensors. In + # particular, this avoids packing the same K/V projection view once + # for every piecewise segment. + read_kv_from_cache = not forward_updates_cache or prewrite_kv # The GPU/default path preserves native cache-update ownership. Ascend # prewrites through vLLM-Ascend's normal-layout writer so piecewise FIA # segments do not repeatedly scatter the same layer K/V. - if not layer.attn_backend.forward_includes_kv_cache_update or prewrite_kv: + if not forward_updates_cache or prewrite_kv: cache_update = getattr(native_impl, "do_kv_cache_update", None) if not callable(cache_update): raise RuntimeError( @@ -353,14 +359,11 @@ class FlashAttentionImpl(AttentionImpl[AttentionMetadata]): ) if paged_kv_context.piecewise_plan is not None: - # Ascend FIA can execute identical CFG rows as one batched call - # per piece. Keep that batch layout through the piecewise runner - # so it can concatenate row-major results instead of emitting an - # indexed ScatterUpdate for every segment. The CUDA path keeps - # its existing output-buffer contract (including graph capture). - use_homogeneous_batch = ( - current_omni_platform.is_npu() and paged_kv_context.piecewise_plan.homogeneous_batch_shape is not None - ) + # Identical CFG rows can execute as one batched call per piece. + # Keep that batch layout through the piecewise runner so Q/K/V use + # strided slices instead of indexed gathers and results can be + # concatenated in row-major order. + use_homogeneous_batch = paged_kv_context.piecewise_plan.homogeneous_batch_shape is not None output = None if not use_homogeneous_batch: output = torch.empty( diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index e19470fa5..9dd69502f 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -1225,6 +1225,8 @@ class OmniDiffusionConfig: if self.diffusion_kv_mode is not DiffusionKVCacheMode.PAGED_SCHEDULER: raise ValueError("native kv_transfer_config requires diffusion_kv_mode='paged_scheduler'") self.kv_transfer_config = parse_kv_transfer_config(self.kv_transfer_config) + if self.enable_sleep_mode: + raise ValueError("Native KV transfer does not support sleep mode: registered pages must remain mapped") self.master_port = self._resolve_master_port() self.request_batch_max_wait_ms = float(self.request_batch_max_wait_ms or 0.0) diff --git a/vllm_omni/diffusion/diffusion_engine.py b/vllm_omni/diffusion/diffusion_engine.py index 6e53b5909..5e3082f45 100644 --- a/vllm_omni/diffusion/diffusion_engine.py +++ b/vllm_omni/diffusion/diffusion_engine.py @@ -603,7 +603,11 @@ class DiffusionEngine: self._wait_for_admission_if_needed_locked() - sched_output = self.scheduler.schedule() + try: + sched_output = self.scheduler.schedule() + except Exception as exc: + self._fail_engine(exc) + return self._scheduler_num_waiting_reqs = max(int(sched_output.num_waiting_reqs), 0) self._emit_request_started_outputs(sched_output) @@ -613,8 +617,17 @@ class DiffusionEngine: continue try: - runner_output: BaseRunnerOutput = self.execute_fn(sched_output) # pyright: ignore[reportAssignmentType] + self._prepare_kv_for_forward(sched_output) + runner_output: BaseRunnerOutput = ( + self.execute_fn(sched_output) + if sched_output.scheduled_request_ids + else BatchRunnerOutput.from_list([]) + ) + worker_execution_completed = True except Exception as exc: + if self._closed: + return + worker_execution_completed = False logger.error( "Execution failed for diffusion requests %s", sched_output.scheduled_request_ids, exc_info=True ) @@ -632,8 +645,23 @@ class DiffusionEngine: self._process_aborts_queue() self._process_rpc_queue() - finished_req_ids = self.scheduler.update_from_output(sched_output, runner_output) - self._emit_outputs(finished_req_ids, sched_output.scheduled_request_ids, runner_output) + try: + finished_req_ids = self.scheduler.update_from_output(sched_output, runner_output) + self._emit_outputs( + finished_req_ids, + sched_output.scheduled_request_ids, + runner_output, + worker_execution_completed=worker_execution_completed, + ) + except Exception as exc: + self._fail_engine(exc) + return + + if not sched_output.scheduled_request_ids and self.scheduler._kv_draining_requests: + # Only exceptional in-flight receives need idle polling. New + # requests / aborts wake this wait immediately. + with self._cv: + self._cv.wait(timeout=0.01) # Engine is stopping: fail any RPCs still queued so callers don't hang. self._fail_pending_rpcs(RuntimeError("DiffusionEngine is shutting down.")) @@ -755,15 +783,80 @@ class DiffusionEngine: return unique_request_ids = list(dict.fromkeys(request_ids)) if unique_request_ids: - self.executor.remove_diffusion_kv_requests(unique_request_ids) + scheduler = getattr(self, "scheduler", None) + target_builder = getattr(scheduler, "get_diffusion_kv_cleanup_targets", None) + cleanup_targets = target_builder(unique_request_ids) if target_builder is not None else unique_request_ids + try: + if cleanup_targets: + self.executor.remove_diffusion_kv_requests(cleanup_targets) + except Exception as exc: + self._fail_engine(exc) + raise + + def _prepare_kv_for_forward(self, sched_output: DiffusionSchedulerOutput) -> None: + if getattr(sched_output, "kv_connector_metadata", None) is None: + return + try: + output = self.executor.prepare_kv_for_forward(sched_output) + assert output is not None + self.scheduler.update_kv_connector_output(output) + if not self.abort_queue.empty(): + self._process_aborts_queue() + incomplete = sched_output.kv_transfer_request_ids - (output.finished_recving or set()) + sched_output.finished_req_ids.update(self.scheduler.fail_incomplete_kv_loads(incomplete)) + # Timed-out/cancelled requests must never reach model execution. + terminal = { + rid + for rid in sched_output.scheduled_request_ids + if (state := self.scheduler.get_request_state(rid)) is not None and state.is_finished() + } + if terminal: + sched_output.finished_req_ids.update(terminal) + sched_output.scheduled_new_reqs = [ + req for req in sched_output.scheduled_new_reqs if req.request_id not in terminal + ] + sched_output.scheduled_cached_reqs.request_ids = [ + rid for rid in sched_output.scheduled_cached_reqs.request_ids if rid not in terminal + ] + # is_empty / started-output handling may have already cached it. + sched_output.__dict__.pop("scheduled_request_ids", None) + drained = self.scheduler.completed_kv_drains() + if drained: + self._remove_diffusion_kv_requests(drained) + self.scheduler.release_kv_drains(drained) + except Exception as exc: + self._fail_engine(exc) + raise + + def _fail_engine(self, exc: Exception) -> None: + if getattr(self, "_shutdown_complete", False): + return + logger.error("Diffusion engine failed; stopping workers before releasing KV pages", exc_info=exc) + with self._cv: + self._closed = True + if self.stop_event is not None: + self.stop_event.set() + streams = list(self._out_streams.values()) + self._cv.notify_all() + for stream in streams: + self._put_queue_output(stream, DiffusionOutput.from_exception(exc)) + self._fail_pending_rpcs(exc) + try: + self.executor.shutdown() + finally: + try: + self.scheduler.close() + finally: + self._shutdown_complete = True def _emit_finished_outputs( self, finished_ids: set[str], runner_output: BaseRunnerOutput | None = None, missing_result_error: str = "Diffusion execution finished without a final output", + worker_cleaned_ids: set[str] | None = None, ) -> None: - self._remove_diffusion_kv_requests(finished_ids) + self._remove_diffusion_kv_requests(finished_ids - (worker_cleaned_ids or set())) for rid in finished_ids: if runner_output is not None: _output = runner_output.get_request_output(rid) @@ -777,13 +870,21 @@ class DiffusionEngine: finished_ids: set[str], scheduled_request_ids: list[str], runner_output: BaseRunnerOutput, + *, + worker_execution_completed: bool = False, ) -> None: """Emit output chunks for every request through the unified output stream.""" + finished_ids = set(finished_ids) + worker_cleaned_ids = finished_ids.intersection(scheduled_request_ids) if worker_execution_completed else set() if self.execution_mode != DiffusionExecutionMode.STEP_BATCH: - self._emit_finished_outputs(finished_ids, runner_output) + self._emit_finished_outputs( + finished_ids, + runner_output, + worker_cleaned_ids=worker_cleaned_ids, + ) return - self._remove_diffusion_kv_requests(finished_ids) + self._remove_diffusion_kv_requests(finished_ids - worker_cleaned_ids) delivered_finished_req_ids: set[str] = set() @@ -975,7 +1076,11 @@ class DiffusionEngine: # keep scheduling and executing until the target request is finished while True: self._process_aborts_queue() - sched_output = self.scheduler.schedule() + try: + sched_output = self.scheduler.schedule() + except Exception as exc: + self._fail_engine(exc) + raise if sched_output.is_empty: if target_request_id in sched_output.finished_req_ids: self._remove_diffusion_kv_requests([target_request_id]) @@ -986,12 +1091,19 @@ class DiffusionEngine: # NOTE: add_req_and_wait_for_response() is synchronous, will be only called # within _dummy_run, only one request will be scheduled - request_id = sched_output.scheduled_request_ids[0] + request_id = target_request_id try: - runner_output: BaseRunnerOutput = self.execute_fn(sched_output) # pyright: ignore[reportAssignmentType] + self._prepare_kv_for_forward(sched_output) + runner_output: BaseRunnerOutput = ( + self.execute_fn(sched_output) + if sched_output.scheduled_request_ids + else BatchRunnerOutput.from_list([]) + ) except EngineDeadError: raise except Exception as exc: + if self._closed: + raise logger.error("Execution failed for diffusion request %s", request_id, exc_info=True) runner_output = RunnerOutput( request_id=request_id, @@ -1005,7 +1117,11 @@ class DiffusionEngine: finished_req_ids = self.scheduler.update_from_output(sched_output, runner_output) # sync func should receive one result - if not isinstance(runner_output, RunnerOutput) and not len(runner_output) == 1: + if ( + sched_output.scheduled_request_ids + and not isinstance(runner_output, RunnerOutput) + and len(runner_output) != 1 + ): raise ValueError("Sync func should receive one result at one time") if target_request_id in finished_req_ids: self._remove_diffusion_kv_requests([target_request_id]) @@ -1100,7 +1216,8 @@ class DiffusionEngine: executes one request from the collective wave. Hunyuan is currently the only model integrated with - ``paged_scheduler``; 1024x1024, enabled CFG, and the maximum advertised + ``paged_scheduler``. The stage's configured default resolution (or + 1024x1024 when absent), enabled CFG, and the maximum advertised reference-image count exercise its first-step activation peak. Admission compares each preprocessed request's CFG count and tokenized sequence/target shape with the resulting per-request profile envelope. @@ -1117,9 +1234,12 @@ class DiffusionEngine: model_class_name = self.od_config.model_class_name if model_class_name is None: raise RuntimeError("Diffusion KV profiling requires a resolved model_class_name") + additional_config = getattr(self.od_config, "additional_config", {}) or {} + profile_height = additional_config.get("diffusion_kv_profile_height", 1024) + profile_width = additional_config.get("diffusion_kv_profile_width", 1024) request = self._make_dummy_request( - height=1024, - width=1024, + height=profile_height, + width=profile_width, guidance_scale=5.0, num_image_inputs=get_dummy_run_num_image_inputs(model_class_name), ) @@ -1335,7 +1455,11 @@ class DiffusionEngine: def _process_aborts_queue(self) -> None: with self._cv: - self._drain_abort_queue() + try: + self._drain_abort_queue() + except Exception as exc: + self._fail_engine(exc) + raise def _drain_abort_queue(self) -> None: if self.abort_queue.empty(): @@ -1352,11 +1476,10 @@ class DiffusionEngine: request_ids = [request_ids] if isinstance(request_ids, str) else list(request_ids) request_ids = list(dict.fromkeys(request_ids)) - self._remove_diffusion_kv_requests(request_ids) - for request_id in request_ids: if self.scheduler.get_request_state(request_id) is not None: self.scheduler.finish_requests(request_id, DiffusionRequestStatus.FINISHED_ABORTED) + self._remove_diffusion_kv_requests(request_ids) def _finalize_finished_request( self, diff --git a/vllm_omni/diffusion/diffusion_kv/kv_connector.py b/vllm_omni/diffusion/diffusion_kv/kv_connector.py index 47c609860..eaa478300 100644 --- a/vllm_omni/diffusion/diffusion_kv/kv_connector.py +++ b/vllm_omni/diffusion/diffusion_kv/kv_connector.py @@ -1,35 +1,40 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project -"""Diffusion-side assembly facade built on the upstream Mooncake KV connector. - -The configured ``kv_connector`` (currently ``MooncakeConnector``) is created -through vLLM's native ``KVConnectorFactory``. PR0 only validates configuration -and creates the Scheduler/Worker connector objects; it does not subclass or -reimplement Mooncake's transfer runtime. Real page sizing, cache registration, -admission, and remote loading belong to the follow-up landing PR. -""" +"""Assemble native vLLM KV connectors for Scheduler-owned diffusion pages.""" from __future__ import annotations +import math +import time +from collections import Counter from collections.abc import Mapping from typing import TYPE_CHECKING, Any -import torch from vllm.config import KVTransferConfig, VllmConfig from vllm.distributed.kv_transfer.kv_connector.factory import KVConnectorFactory from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorRole from vllm.distributed.kv_transfer.kv_transfer_state import ensure_kv_transfer_initialized, ensure_kv_transfer_shutdown from vllm.logger import init_logger +from vllm.v1.core.kv_cache_manager import KVCacheBlocks from vllm.v1.kv_cache_interface import KVCacheConfig +from vllm.v1.outputs import KVConnectorOutput if TYPE_CHECKING: from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorBase_V1 + from vllm.v1.core.kv_cache_manager import KVCacheManager + from vllm.v1.worker.gpu.kv_connector import ActiveKVConnector from vllm_omni.diffusion.data import OmniDiffusionConfig + from vllm_omni.diffusion.diffusion_kv.request import DiffusionKVRequest + from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput logger = init_logger(__name__) +class KVTransferRegistrationError(ValueError): + """A failed handoff whose destination pages have not been dispatched.""" + + def mint_transfer_id(request_id: str) -> str: """Return the stable ticket shared by one AR -> Diffusion handoff.""" @@ -110,6 +115,8 @@ def parse_kv_transfer_config(value: object | None) -> KVTransferConfig | None: def create_scheduler_kv_connector( od_config: OmniDiffusionConfig, + kv_cache_config: KVCacheConfig | None = None, + vllm_config: VllmConfig | None = None, ) -> KVConnectorBase_V1 | None: """Create a Scheduler-role connector when native config is present.""" @@ -121,14 +128,15 @@ def create_scheduler_kv_connector( f"Diffusion native kv_transfer_config must be KVTransferConfig, got {type(kv_transfer_config)!r}" ) - vllm_config = _build_native_vllm_config(od_config) + if kv_cache_config is None or vllm_config is None: + raise ValueError("KV transfer requires paged_scheduler and a native cache configuration") connector = KVConnectorFactory.create_connector( config=vllm_config, role=KVConnectorRole.SCHEDULER, - kv_cache_config=_empty_kv_cache_config(), + kv_cache_config=kv_cache_config, ) logger.info( - "Created KV connector stub (SCHEDULER role): connector=%s engine_id=%s", + "Created KV connector (SCHEDULER role): connector=%s engine_id=%s", kv_transfer_config.kv_connector, kv_transfer_config.engine_id, ) @@ -140,9 +148,27 @@ def init_worker_kv_connector(vllm_config: VllmConfig, kv_cache_config: KVCacheCo if vllm_config.kv_transfer_config is None: return - ensure_kv_transfer_initialized(vllm_config, kv_cache_config) + import vllm.distributed.parallel_state as parallel_state + + from vllm_omni.diffusion.distributed.parallel_state import get_sp_group + + # vLLM 0.29 Mooncake snapshots tp_rank/tp_size in its Worker constructor; + # consumer receive/region mapping use those saved values, not get_tp_group. + # Restrict this compatibility bridge to that connector and restore the + # model's TP group even when construction fails. Never remap model TP for + # the connector's entire lifetime (it would change model collectives). + tp_group = parallel_state._TP + parallel_config = getattr(vllm_config, "parallel_config", None) + if getattr(parallel_config, "prefill_context_parallel_size", 1) > 1 and tp_group.world_size == 1: + if vllm_config.kv_transfer_config.kv_connector != "MooncakeConnector": + raise ValueError("Native SP KV transfer currently requires MooncakeConnector") + parallel_state._TP = get_sp_group() + try: + ensure_kv_transfer_initialized(vllm_config, kv_cache_config) + finally: + parallel_state._TP = tp_group logger.info( - "Initialized KV connector stub (WORKER role): connector=%s engine_id=%s", + "Initialized KV connector (WORKER role): connector=%s engine_id=%s", vllm_config.kv_transfer_config.kv_connector, vllm_config.kv_transfer_config.engine_id, ) @@ -156,26 +182,165 @@ def shutdown_kv_connector(*, scheduler_connector: KVConnectorBase_V1 | None = No ensure_kv_transfer_shutdown() -def _empty_kv_cache_config() -> KVCacheConfig: - """Empty cache config used only for PR0 connector assembly.""" - - return KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]) +def prepare_kv_requests(requests: tuple[DiffusionKVRequest, ...], params: Mapping[str, Any]) -> None: + for request in requests: + request.kv_transfer_params = dict(params) + num_tokens = min(len(request.prompt_token_ids), params["num_transfer_tokens"]) + request.prompt_token_ids = request.prompt_token_ids[:num_tokens] + request.num_prompt_tokens = num_tokens -def _build_native_vllm_config(od_config: OmniDiffusionConfig) -> VllmConfig: - from vllm_omni.diffusion.vllm_config import create_diffusion_vllm_config +def install_mooncake_cfg_fanout(connector: KVConnectorBase_V1) -> None: + """Account for multiple CFG destinations on one native Mooncake ticket. - kv_transfer_config = getattr(od_config, "kv_transfer_config", None) - assert isinstance(kv_transfer_config, KVTransferConfig) - vllm_config = create_diffusion_vllm_config(torch.device("cpu"), od_config) - vllm_config.kv_transfer_config = kv_transfer_config - return vllm_config + vLLM 0.29 initializes ``need_send`` to the paired consumer rank count, + but increments ``sent`` per consumer request. Adapt only this producer + instance, after KV initialization and before serving requests. Keep the + upstream address planning, transport, timeout and completion code intact. + """ + from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector import MooncakeConnector + + if not isinstance(connector, MooncakeConnector): + return + worker = connector.connector_worker + if worker is None or worker.is_kv_consumer or getattr(worker, "_omni_cfg_fanout_installed", False): + return + build_transfer_params = worker._build_transfer_params + + async def build_with_fanout(ready_reqs, agent_meta, local_regions, remote_regions): + # commit_kv_load registers all rows before build_connector_meta. Thus + # each rank's message contains the complete row set for each ticket, + # including empty-block notifications. Count the full message, not + # just the subset whose producer-ready events fired in this iteration. + rows_per_ticket = Counter(transfer_id for transfer_id, _ in agent_meta.req_blocks.values()) + tickets = {send_meta.transfer_id: send_meta for _, send_meta in ready_reqs} + for transfer_id, send_meta in tickets.items(): + rows = rows_per_ticket[transfer_id] + previous = getattr(send_meta, "_omni_cfg_rows", None) + if rows < 1 or (previous is not None and previous != rows): + # Use the upstream transfer-error path: it decrements sending + # without incrementing sent, so a malformed rank cannot free + # source pages still needed by another rank. + return [], [], [], [req_id for req_id, _ in ready_reqs], "Inconsistent Mooncake CFG receive count" + # No await between checking and updating: all handlers run on the + # sender loop. The marker belongs to the ticket, not to a request-global + # variable; it disappears with the upstream ticket on completion/expiry. + for transfer_id, send_meta in tickets.items(): + if getattr(send_meta, "_omni_cfg_rows", None) is None: + send_meta.need_send *= rows_per_ticket[transfer_id] + send_meta._omni_cfg_rows = rows_per_ticket[transfer_id] + return await build_transfer_params(ready_reqs, agent_meta, local_regions, remote_regions) + + worker._build_transfer_params = build_with_fanout # type: ignore[method-assign] + setattr(worker, "_omni_cfg_fanout_installed", True) + + +def commit_kv_load( + connector: KVConnectorBase_V1, + manager: KVCacheManager, + requests: tuple[DiffusionKVRequest, ...], + matched_tokens: list[int], +) -> set[str]: + expected = set() + allocations = [] + # All rows sharing a transfer_id must reach the same connector metadata: + # the producer uses this complete per-rank row set for fan-out accounting. + # Validate every CFG row before mutating any connector state. + for request, num_tokens in zip(requests, matched_tokens, strict=True): + blocks = manager.get_blocks(request.request_id) + # Mooncake's producer advertises complete physical blocks. Keep every + # CFG receiver on that same block boundary so the native connector can + # use its normal one-to-one page mapping even when the branches have + # different logical prefix lengths. Bytes beyond ``num_tokens`` are + # not marked computed and are overwritten by the DiT prefill. + transfer_tokens = num_tokens + if num_tokens > 0 and request.kv_transfer_params is not None: + transfer_tokens = request.kv_transfer_params["num_transfer_tokens"] + if type(transfer_tokens) is not int or not num_tokens <= transfer_tokens <= request.num_tokens: + raise KVTransferRegistrationError( + "Diffusion KV transfer boundary must cover the reusable prefix " + f"without exceeding the allocated sequence: reusable={num_tokens}, " + f"transfer={transfer_tokens!r}, allocated={request.num_tokens}" + ) + prefix_blocks = KVCacheBlocks( + tuple( + group[: (transfer_tokens + spec.kv_cache_spec.block_size - 1) // spec.kv_cache_spec.block_size] + for group, spec in zip(blocks.blocks, manager.kv_cache_config.kv_cache_groups, strict=True) + ) + ) + allocations.append((request, prefix_blocks, num_tokens)) + try: + for request, prefix_blocks, num_tokens in allocations: + connector.update_state_after_alloc(request, prefix_blocks, num_tokens) + request.num_computed_tokens = num_tokens + if request.kv_transfer_params is not None: + expected.add(request.request_id) + except Exception as exc: + from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector import MooncakeConnector + from vllm.v1.request import RequestStatus + + if not isinstance(connector, MooncakeConnector): + # Unknown connectors may have started I/O during registration. + raise + # Mooncake only queues metadata here; no Worker has seen addresses yet. + # Re-arm its pre-scheduling abort hook to replace even partially + # registered CFG receives with empty-block notifications to the producer. + for request in requests: + if request.kv_transfer_params is not None: + request.kv_transfer_params["do_remote_prefill"] = True + request.status = RequestStatus.FINISHED_ABORTED + delay_free, _ = connector.request_finished(request, []) + if delay_free: + raise RuntimeError("Connector could not cancel an undispatched KV load") from exc + raise KVTransferRegistrationError(f"Could not register diffusion KV receive: {exc}") from exc + return expected + + +def wait_for_kv_load( + active_connector: ActiveKVConnector, scheduler_output: DiffusionSchedulerOutput, timeout: float +) -> KVConnectorOutput: + connector = active_connector.kv_connector + active_connector.pre_forward(scheduler_output) + pending = set(scheduler_output.kv_transfer_request_ids) + finished_ids = scheduler_output.kv_finished_request_ids + if not pending: + output = active_connector.post_forward(finished_ids) + if output.invalid_block_ids: + raise RuntimeError("Diffusion KV connector reported invalid remote pages") + return output + + received, sent = set(), set() + deadline = time.monotonic() + timeout + while True: + finished_sending, finished_recving = connector.get_finished(finished_ids) + sent.update(finished_sending or ()) + received.update(finished_recving or ()) + pending.difference_update(received) + if connector.get_block_ids_with_load_errors(): + raise RuntimeError("Diffusion KV connector reported invalid remote pages") + if not pending: + break + if time.monotonic() >= deadline: + # A timeout is not a cancellation acknowledgement. Return partial + # completion; Scheduler quarantines the remaining destination pages. + break + time.sleep(0.001) + output = active_connector.post_forward(finished_ids) + output.finished_sending = sent | (output.finished_sending or set()) + output.finished_recving = received | (output.finished_recving or set()) + if output.invalid_block_ids: + raise RuntimeError("Diffusion KV connector reported invalid remote pages") + return output def _validate_kv_transfer_config(config: KVTransferConfig) -> None: engine_id = config.engine_id if not isinstance(engine_id, str) or not engine_id.strip(): raise ValueError("Diffusion native kv_transfer_config requires a non-empty engine_id") + extra_config = config.kv_connector_extra_config or {} + timeout = extra_config.get("transfer_timeout", 60.0) + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0: + raise ValueError("Diffusion transfer_timeout must be a positive finite number of seconds") if config.kv_connector is not None and config.kv_role is None: raise ValueError("Diffusion native kv_transfer_config requires kv_role when kv_connector is set") if config.kv_role not in (None, "kv_consumer", "kv_producer", "kv_both"): diff --git a/vllm_omni/diffusion/diffusion_kv/manager.py b/vllm_omni/diffusion/diffusion_kv/manager.py index eee51b864..3b2fb3b42 100644 --- a/vllm_omni/diffusion/diffusion_kv/manager.py +++ b/vllm_omni/diffusion/diffusion_kv/manager.py @@ -8,6 +8,7 @@ from collections.abc import Sequence from vllm.v1.core.kv_cache_manager import KVCacheManager from vllm.v1.kv_cache_interface import KVCacheConfig +from vllm_omni.core.sched.utils import free_kv_blocks_in_physical_order from vllm_omni.diffusion.diffusion_kv.metadata import ( DiffusionKVMetadata, DiffusionKVSequenceMetadata, @@ -179,7 +180,7 @@ class DiffusionKVCacheManager: requests = self._requests.pop(public_request_id, ()) self._metadata.pop(public_request_id, None) for request in reversed(requests): - self.native_manager.free(request) + free_kv_blocks_in_physical_order(self.native_manager, request) self._internal_request_ids.discard(request.request_id) def close(self) -> None: @@ -188,4 +189,4 @@ class DiffusionKVCacheManager: def _rollback(self, requests: Sequence[DiffusionKVRequest]) -> None: for request in reversed(requests): - self.native_manager.free(request) + free_kv_blocks_in_physical_order(self.native_manager, request) diff --git a/vllm_omni/diffusion/diffusion_kv/metadata.py b/vllm_omni/diffusion/diffusion_kv/metadata.py index db6cb54ec..19ecd1dc3 100644 --- a/vllm_omni/diffusion/diffusion_kv/metadata.py +++ b/vllm_omni/diffusion/diffusion_kv/metadata.py @@ -41,6 +41,7 @@ class DiffusionKVSequenceMetadata: seq_len: int block_ids: tuple[list[int], ...] context_ids: tuple[str, ...] = () + num_computed_tokens: int = 0 @dataclass diff --git a/vllm_omni/diffusion/diffusion_kv/model_runner_backend.py b/vllm_omni/diffusion/diffusion_kv/model_runner_backend.py index 20f4db165..578946cb0 100644 --- a/vllm_omni/diffusion/diffusion_kv/model_runner_backend.py +++ b/vllm_omni/diffusion/diffusion_kv/model_runner_backend.py @@ -190,6 +190,10 @@ class DiffusionKVModelRunnerBackend: ) return ulysses_degree + @property + def kv_caches_by_layer(self) -> dict[str, torch.Tensor]: + return {name: layer.kv_cache for name, layer in self._kv_cache_layer_adapters.items()} + def _get_max_rows_per_request(self) -> int: max_rows = getattr(self.od_config, "diffusion_kv_max_rows_per_request", None) if type(max_rows) is not int or max_rows <= 0: @@ -245,7 +249,9 @@ class DiffusionKVModelRunnerBackend: for capacity, block_size in zip(unaligned_max_num_blocks_per_group, block_sizes, strict=True) ] - max_num_reqs = max_num_seqs * max_rows_per_request + max_num_reqs = max_num_seqs + if max_num_reqs % max_rows_per_request: + raise ValueError("scheduler_config.max_num_seqs must be a multiple of diffusion_kv_max_rows_per_request") max_num_batched_tokens = getattr(scheduler_config, "max_num_batched_tokens", None) if type(max_num_batched_tokens) is not int or max_num_batched_tokens <= 0: raise ValueError("scheduler_config.max_num_batched_tokens must be a positive integer") @@ -254,9 +260,6 @@ class DiffusionKVModelRunnerBackend: cp_rank = get_dcp_group().rank_in_group if cp_size > 1 else 0 cp_interleave = parallel_config.cp_kv_cache_interleave_size - # Native metadata builders size per-row buffers from max_num_seqs. - # A diffusion public request can occupy several sequence/context rows. - scheduler_config.max_num_seqs = max_num_reqs kv_caches: list[torch.Tensor | list[torch.Tensor]] = [] previous_adapter_caches = { layer_name: adapter.kv_cache for layer_name, adapter in self._kv_cache_layer_adapters.items() @@ -303,7 +306,6 @@ class DiffusionKVModelRunnerBackend: self.vllm_config, ) except Exception: - scheduler_config.max_num_seqs = max_num_seqs for layer_name, previous_cache in previous_adapter_caches.items(): self._kv_cache_layer_adapters[layer_name].kv_cache = previous_cache raise @@ -591,7 +593,7 @@ class DiffusionKVModelRunnerBackend: ) raise RuntimeError(f"Diffusion KV request state is missing logical length for {identity!r}") - def remove_diffusion_kv_requests(self, request_ids: list[str]) -> int: + def remove_diffusion_kv_requests(self, request_ids: list[str | tuple[str, int]]) -> int: """Retire Worker rows without logically freeing Scheduler-owned blocks.""" if ( not is_scheduler_paged_kv_mode( @@ -600,11 +602,25 @@ class DiffusionKVModelRunnerBackend: or self.block_tables is None ): return 0 - request_id_set = set(request_ids) + request_generations = { + request_id: generation + for item in request_ids + if isinstance(item, tuple) + for request_id, generation in (item,) + if isinstance(request_id, str) and type(generation) is int + } + request_id_set = {item if isinstance(item, str) else item[0] for item in request_ids} identities_and_rows = [ (identity, row) for identity, row in self._diffusion_kv_identity_to_row.items() if identity[0] in request_id_set + and ( + identity[0] not in request_generations + or ( + (request_state := self._diffusion_kv_request_states.get(identity[0])) is not None + and request_state.generation == request_generations[identity[0]] + ) + ) ] rows = [row for _, row in identities_and_rows] if rows: @@ -620,9 +636,10 @@ class DiffusionKVModelRunnerBackend: ] self._apply_rows(rows, installs) + removed_request_ids = {identity[0] for identity, _ in identities_and_rows} for identity, _ in identities_and_rows: del self._diffusion_kv_identity_to_row[identity] - for request_id in request_id_set: + for request_id in removed_request_ids: self._diffusion_kv_request_states.pop(request_id, None) self._diffusion_kv_free_rows.extend(rows) self._diffusion_kv_free_rows.sort(reverse=True) diff --git a/vllm_omni/diffusion/diffusion_kv/paged_attention_adapter.py b/vllm_omni/diffusion/diffusion_kv/paged_attention_adapter.py index a76a5434c..697f0b968 100644 --- a/vllm_omni/diffusion/diffusion_kv/paged_attention_adapter.py +++ b/vllm_omni/diffusion/diffusion_kv/paged_attention_adapter.py @@ -84,12 +84,14 @@ class DiffusionPagedAttentionLayerAdapter(AttentionLayerBase): attention_config = vllm_config.attention_config previous_backend = attention_config.backend previous_backend_per_kind = attention_config.backend_per_kind + previous_pcp_size = vllm_config.parallel_config.prefill_context_parallel_size try: # This is a portable vLLM backend request. The active platform # resolves it to its native implementation, such as FlashAttention # on CUDA or AscendAttentionBackend on NPU. attention_config.backend = AttentionBackendEnum.FLASH_ATTN attention_config.backend_per_kind = {} + vllm_config.parallel_config.prefill_context_parallel_size = 1 with set_current_vllm_config(vllm_config): attn_backend = get_attn_backend( head_size=spec.head_size, @@ -101,6 +103,7 @@ class DiffusionPagedAttentionLayerAdapter(AttentionLayerBase): finally: attention_config.backend = previous_backend attention_config.backend_per_kind = previous_backend_per_kind + vllm_config.parallel_config.prefill_context_parallel_size = previous_pcp_size attn_backend = current_omni_platform.get_diffusion_paged_kv_attn_backend( attn_backend, ulysses_degree=ulysses_degree, @@ -758,6 +761,19 @@ class DiffusionPagedAttentionAdapter: slot_mappings=slot_mappings, causal=(row_segments[0].mode == "causal"), ) + # FA2's ``num_splits=0`` auto policy selects SplitKV for these + # already-small piecewise calls. Reuse vLLM's supported metadata + # control to select the single-pass kernel and avoid its extra + # partial-output/reduction work. Leave newer native backends and + # metadata without this control untouched. + for layer_name, native_metadata in segment_metadata.items(): + layer = self.layers.get(layer_name) + if ( + layer is not None + and getattr(layer.impl, "vllm_flash_attn_version", None) == 2 + and hasattr(native_metadata, "max_num_splits") + ): + native_metadata.max_num_splits = 1 # FA3's full-CUDA-graph metadata builder reuses one persistent # scheduler buffer across builds. Piecewise attention prepares all # segments before executing any of them, so each segment needs its @@ -901,9 +917,17 @@ class DiffusionPagedAttentionAdapter: raise KeyError( f"No piecewise native attention metadata was built for diffusion layer {layer_name!r}" ) from exc + # Identical rows are sliced and packed once per segment by the + # homogeneous piecewise runner. Keeping the projection view here + # avoids first copying the complete Q tensor only to copy each segment + # again immediately afterwards. Other native paths still require one + # packed query buffer. + query_input = query_flat + if piecewise_plan is None or piecewise_plan.homogeneous_batch_shape is None: + query_input = query_flat.contiguous() return DiffusionPagedAttentionContext( layer=layer, - query=query_flat.contiguous(), + query=query_input, key_write=key_flat, value_write=value_flat, slot_mapping=slot_mapping, diff --git a/vllm_omni/diffusion/diffusion_kv/request.py b/vllm_omni/diffusion/diffusion_kv/request.py index 83f9bffdb..694af587c 100644 --- a/vllm_omni/diffusion/diffusion_kv/request.py +++ b/vllm_omni/diffusion/diffusion_kv/request.py @@ -113,8 +113,6 @@ class DiffusionKVRequest: self.shared_prefix_boundary = 0 # Opaque request-scoped parameters consumed by an upstream KVConnector. self.kv_transfer_params = kv_transfer_params - # Mooncake derives the remote-load length from this list. PR0 carries - # the field without creating a placeholder or starting a load. self.prompt_token_ids = prompt_token_ids @property diff --git a/vllm_omni/diffusion/executor/abstract.py b/vllm_omni/diffusion/executor/abstract.py index f15db04bd..9b0f71e23 100644 --- a/vllm_omni/diffusion/executor/abstract.py +++ b/vllm_omni/diffusion/executor/abstract.py @@ -1,13 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable +from dataclasses import replace from typing import TYPE_CHECKING, Any from vllm.utils.import_utils import resolve_obj_by_qualname from vllm.v1.kv_cache_interface import KVCacheConfig, KVCacheSpec +from vllm.v1.outputs import KVConnectorOutput from vllm_omni.diffusion.data import OmniDiffusionConfig +from vllm_omni.diffusion.sched.interface import CachedRequestData if TYPE_CHECKING: from vllm_omni.diffusion.request import OmniDiffusionRequest @@ -159,7 +165,7 @@ class DiffusionExecutor(ABC): # ranks are not silently dropped. self.collective_rpc("set_kv_cache_configs", args=(kv_cache_configs, resolved_max_model_len)) - def remove_diffusion_kv_requests(self, request_ids: list[str]) -> None: + def remove_diffusion_kv_requests(self, request_ids: list[str | tuple[str, int]]) -> None: """Clear request rows on every Worker after Scheduler retirement.""" unique_request_ids = list(dict.fromkeys(request_ids)) @@ -170,6 +176,35 @@ class DiffusionExecutor(ABC): args=(unique_request_ids,), ) + def prepare_kv_for_forward(self, scheduler_output: DiffusionSchedulerOutput) -> KVConnectorOutput | None: + if scheduler_output.kv_connector_metadata is None: + return + transfer_output = replace( + scheduler_output, + scheduled_new_reqs=[], + scheduled_cached_reqs=CachedRequestData.make_empty(), + kv_prefetch_job=None, + ) + outputs: list[KVConnectorOutput] = self.collective_rpc( + "prepare_kv_for_forward", + args=(transfer_output,), + unique_reply_rank=0, + exec_all_ranks=True, + ) + if len(outputs) != self.od_config.num_gpus or any(output.invalid_block_ids for output in outputs): + # Missing ranks / invalid pages cannot establish safe ownership. + raise RuntimeError("Diffusion KV receive failed on one or more ranks") + completed = getattr(self, "_kv_receive_completed_ranks", {}) + for rank, output in enumerate(outputs): + for request_id in output.finished_recving or (): + completed.setdefault(request_id, set()).add(rank) + finished = {request_id for request_id, ranks in completed.items() if len(ranks) == len(outputs)} + for request_id in finished: + del completed[request_id] + self._kv_receive_completed_ranks = completed + outputs[0].finished_recving = finished + return outputs[0] + @abstractmethod def shutdown(self) -> None: """Shutdown the executor and release resources.""" diff --git a/vllm_omni/diffusion/forward_context.py b/vllm_omni/diffusion/forward_context.py index 1459da495..496f384d7 100644 --- a/vllm_omni/diffusion/forward_context.py +++ b/vllm_omni/diffusion/forward_context.py @@ -282,6 +282,47 @@ def set_forward_context_denoise_step_idx(step_idx: int | None) -> None: ensure_active(step_idx) +def get_paged_kv_computed_tokens() -> tuple[int, ...]: + runtime = _forward_context.paged_kv_runtime if _forward_context is not None else None + if runtime is None: + return () + return tuple(row.kv_start_pos for row in runtime.metadata.prefill_rows) + + +@contextmanager +def paged_kv_prefill(sequence_id: int, num_tokens: int): + from dataclasses import replace + + runtime = get_forward_context().paged_kv_runtime + if runtime is None: + raise RuntimeError("Paged KV prefill requires an active runtime") + rows = runtime.metadata.prefill_rows + matching_rows = [row for row in rows if row.sequence_id == sequence_id] + if len(matching_rows) != 1: + raise ValueError( + f"Paged KV prefill requires exactly one active row for sequence {sequence_id}; found {len(matching_rows)}" + ) + row = matching_rows[0] + if type(num_tokens) is not int or not row.kv_start_pos < num_tokens <= row.seq_len: + raise ValueError( + "Paged KV prefill target must extend the active prefix without exceeding its allocation: " + f"start={row.kv_start_pos}, target={num_tokens!r}, allocated={row.seq_len}" + ) + prefill = replace(row, query_len=num_tokens - row.kv_start_pos, seq_len=num_tokens) + batch = runtime.adapter.prepare_batch((prefill,)) + with runtime.adapter.activate(batch): + yield + runtime.metadata = replace( + runtime.metadata, + prefill_rows=tuple( + replace(item, kv_start_pos=num_tokens, query_len=item.seq_len - num_tokens) + if item.sequence_id == sequence_id + else item + for item in rows + ), + ) + + def set_forward_context_denoise_timestep(timestep: float | None) -> None: """Set the normalized (descending, 1 -> 0) denoise timestep. diff --git a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py index 1e91f87c1..743b8af91 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py @@ -75,7 +75,11 @@ from vllm_omni.diffusion.distributed.sp_plan import ( SequenceParallelOutput, ) from vllm_omni.diffusion.distributed.utils import get_local_device -from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx +from vllm_omni.diffusion.forward_context import ( + get_paged_kv_computed_tokens, + paged_kv_prefill, + set_forward_context_denoise_step_idx, +) from vllm_omni.diffusion.layers.fused_moe import FusedMoE from vllm_omni.diffusion.layers.norm import RMSNorm from vllm_omni.diffusion.layers.rope import RotaryEmbedding @@ -1097,8 +1101,6 @@ class ImageKVCacheManager(nn.Module): layer resolves the actual CUDA or Ascend paged kernel. """ - if uncond_cfg_prefill: - raise RuntimeError("Hunyuan negative-CFG prefill must run before paged row activation") if self._injected_ar_kv is not None: raise NotImplementedError("Hunyuan Scheduler-paged KV does not support imported AR KV") if not query_lens or len(seq_lens) != len(query_lens): @@ -1109,7 +1111,6 @@ class ImageKVCacheManager(nn.Module): self.clear_legacy_prompt_kv_cache() bs = len(query_lens) q_len = query_lens[0] - seq_len = seq_lens[0] assert query.shape[0] == bs * q_len, f"{query.shape[0]} != {bs * q_len}" head_num_per_rank = query.shape[1] @@ -1121,22 +1122,19 @@ class ImageKVCacheManager(nn.Module): value = value.reshape(bs, q_len, kv_head_num_per_rank, head_dim) joint_text_query = joint_text_key = joint_text_value = None - if self.sp_size > 1 and first_step: + if self.sp_size > 1 and uncond_cfg_prefill: + joint_text_query, joint_text_key, joint_text_value = query, key, value + query, key, value = query[:, :0], key[:, :0], value[:, :0] + elif self.sp_size > 1 and first_step: if shard_image_size is None or shard_image_size <= 0: raise ValueError("Hunyuan paged Ulysses requires a positive local image shard size") - local_prompt_len = seq_len - shard_image_size joint_query_len = query.shape[1] - shard_image_size - if local_prompt_len != joint_query_len: - raise ValueError( - "Hunyuan paged Ulysses prompt layout mismatch: " - f"key_prompt={local_prompt_len}, query_prompt={joint_query_len}" - ) joint_text_query = query[:, :joint_query_len] - joint_text_key = key[:, :local_prompt_len] - joint_text_value = value[:, :local_prompt_len] + joint_text_key = key[:, :joint_query_len] + joint_text_value = value[:, :joint_query_len] query = query[:, joint_query_len:] - key = key[:, local_prompt_len:] - value = value[:, local_prompt_len:] + key = key[:, joint_query_len:] + value = value[:, joint_query_len:] if joint_text_query is None: attn_metadata = AttentionMetadata(full_attn_spans=full_attn_spans) @@ -1254,6 +1252,8 @@ class ImageKVCacheManager(nn.Module): ) -> torch.Tensor: self.image_token_len = kwargs.get("num_image_tokens") if self.attn.is_paged_kv_active(): + # A native connector fills the same Scheduler-owned pages as + # local prefill. Imported prefixes need no dense reconstruction. full_attn_spans = kwargs.get("full_attn_spans") if full_attn_spans is None: raise ValueError("Hunyuan Scheduler-paged KV requires full_attn_spans metadata") @@ -1837,7 +1837,9 @@ class HunYuanAttention(nn.Module): num_kv_heads=self.num_kv_heads, scaling=self.scaling, image_token_len=4097, - prefix=f"{prefix}.image_attn", + # Native Mooncake pairs this canonical KV layer name with the AR + # HunYuanAttention: model.layers.N.self_attn.attn. Keep both in sync. + prefix=f"model.{prefix}", ) self.image_rope2d_emb = HunYuanRotary2DEmbedder( num_heads=self.num_heads, @@ -2964,7 +2966,10 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline): seq_lens=[prefill_seq_len], num_image_tokens=0, ar_kv_reuse_len=negative_reuse_len, - full_attn_spans=model_kwargs["full_attn_spans"][batch_slice] + full_attn_spans=[ + [(start, min(end, prefill_seq_len)) for start, end in spans if start < prefill_seq_len] + for spans in model_kwargs["full_attn_spans"][batch_slice] + ] if model_kwargs.get("full_attn_spans") else None, ) @@ -3019,6 +3024,28 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline): device, ): ar_kv_data = model_kwargs.pop("ar_kv_data", None) + computed_tokens = get_paged_kv_computed_tokens() + if computed_tokens and computed_tokens[0] > 0: + positive_reuse_len = computed_tokens[0] + if ( + len(computed_tokens) > 1 + and computed_tokens[1] < positive_reuse_len + and (not cfg_parallel_ready or cfg_rank == 1) + ): + prefill_inputs = self._build_negative_cfg_prefill_inputs( + input_ids, + model_kwargs, + batch_size, + computed_tokens[1], + positive_reuse_len, + cfg_parallel_ready, + ) + with ( + paged_kv_prefill(1, positive_reuse_len), + torch.autocast(device_type=device.type, dtype=torch.bfloat16), + ): + self.model.forward_call(**prefill_inputs) + return self._truncate_reused_prefix(input_ids, model_kwargs, positive_reuse_len), positive_reuse_len if ar_kv_data is None: logger.debug( "[AR KV Reuse] cfg_rank=%s: no AR KV received, fallback to full recompute (reuse_len=0)", diff --git a/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py b/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py index 441a9636e..4856fac75 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py @@ -367,6 +367,8 @@ class HunyuanImage3Pipeline( def __init__(self, od_config: OmniDiffusionConfig) -> None: self.hf_config = get_config(od_config.model, trust_remote_code=True) + self.hf_config.cfg_distilled = _config_flag(self.hf_config, "cfg_distilled") + self.hf_config.use_meanflow = _config_flag(self.hf_config, "use_meanflow") super().__init__(self.hf_config) # update diffusion config self.generation_config = GenerationConfig.from_pretrained(od_config.model) @@ -1282,7 +1284,9 @@ class HunyuanImage3Pipeline( # 4. Encode conditional images # Skip encoding if AR KV reuse is enabled - has_ar_kv = kwargs.get("ar_kv_data") + has_ar_kv = kwargs.get("ar_kv_data") or request_layout_utils.native_kv_covers_cond_images( + output, kwargs.get("kv_computed_tokens", ()) + ) if batch_cond_image_info is not None and len(batch_cond_image_info[0]) > 0 and not has_ar_kv: cond_vae_images, cond_timestep, cond_vit_images = self._encode_cond_image( batch_cond_image_info, cfg_factor[mode], generator=generator @@ -1894,6 +1898,7 @@ class HunyuanImage3Pipeline( pipe._guidance_scale = guidance_scale pipe._guidance_rescale = getattr(sampling, "guidance_rescale", 0.0) + ar_kv_kwargs = self._extract_ar_kv_from_sampling(sampling) model_kwargs = self.prepare_model_inputs( prompt=prompt, cot_text=cot_text, @@ -1906,8 +1911,10 @@ class HunyuanImage3Pipeline( batch_cond_image_info=batch_cond_image_info, bot_task=tokenizer_bot_task, prepared_layout=request_layout_utils.get_hunyuan_prepared_layout(state), + kv_computed_tokens=state.extra.get("kv_computed_tokens", ()), + **ar_kv_kwargs, ) - model_kwargs.update(self._extract_ar_kv_from_sampling(sampling)) + model_kwargs.update(ar_kv_kwargs) model_kwargs["use_cache"] = False input_ids = model_kwargs.pop("input_ids") @@ -2337,6 +2344,7 @@ class HunyuanImage3Pipeline( batch_cond_image_info=batch_cond_image_info, bot_task=tokenizer_bot_task, prepared_layout=request_layout_utils.get_hunyuan_prepared_layout(req.requests[0]), + kv_computed_tokens=getattr(req.requests[0], "kv_computed_tokens", ()), **ar_kv_kwargs, ) diff --git a/vllm_omni/diffusion/models/hunyuan_image3/request_layout.py b/vllm_omni/diffusion/models/hunyuan_image3/request_layout.py index 32353ed62..6eb873219 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/request_layout.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/request_layout.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations @@ -373,7 +373,32 @@ def build_hunyuan_diffusion_kv_requests( real_pos = tokenizer_output.real_pos assert prefix_positions is not None and real_pos is not None + def boundary_rows(field_name: str) -> list[int | None]: + positions = getattr(tokenizer_output, field_name) + if positions is None: + return [None] * cfg_factor + if not isinstance(positions, list) or not positions: + raise ValueError(f"Hunyuan {field_name} must be a non-empty boundary list") + # Batched tokenizer output contains a separate boundary for each CFG + # row. Never broadcast a conditional boundary into a negative row. + if cfg_factor == 1 and len(positions) == 1 and (positions[0] is None or type(positions[0]) is int): + return positions + if len(positions) == cfg_factor and all( + isinstance(row, list) and len(row) == 1 and (row[0] is None or type(row[0]) is int) for row in positions + ): + return [row[0] for row in positions] + raise ValueError(f"Hunyuan {field_name} must contain one boundary per CFG row") + + think_boundaries = boundary_rows("think_recaption_end_pos") + uncond_boundaries = boundary_rows("uncond_cfg_start_pos") + target_len = hunyuan_num_image_tokens(prepared_layout.generated_image_info) + reusable_lens = [0] * cfg_factor + if think_boundaries[0] is not None: + reusable_lens[0] = think_boundaries[0] or 0 + if any(boundary is not None for boundary in uncond_boundaries): + for sequence_id in range(1, cfg_factor): + reusable_lens[sequence_id] = min(reusable_lens[0], uncond_boundaries[sequence_id] or 0) return tuple( DiffusionKVRequest( f"{request.request_id}/diffusion-kv/{sequence_id}", @@ -383,6 +408,7 @@ def build_hunyuan_diffusion_kv_requests( prefix_len=int(prefix_row[-1].item()), target_len=target_len, seq_len=int(valid_row[-1].item()), + prompt_token_ids=tokenizer_output.tokens[sequence_id, : reusable_lens[sequence_id]].tolist(), # Prompt and reference-image tokens are already embedded in this # row's primary self-attention sequence. Hunyuan therefore has no # independently projected cross/joint-attention KV context. @@ -401,3 +427,14 @@ def get_hunyuan_prepared_layout(source: Any) -> HunyuanPreparedLayout | None: f"HunyuanImage3 expected prepared_layout to be HunyuanPreparedLayout, got {type(prepared_layout).__name__}" ) return prepared_layout + + +def native_kv_covers_cond_images(output: TokenizerEncodeOutput, computed_tokens: tuple[int, ...]) -> bool: + """Skip image encoding only when every CFG row already contains its image KV.""" + image_slices = output.joint_image_slices + if not image_slices or len(image_slices) != len(computed_tokens): + return False + return all( + slices and all(image_slice.stop <= computed for image_slice in slices) + for slices, computed in zip(image_slices, computed_tokens, strict=True) + ) diff --git a/vllm_omni/diffusion/request.py b/vllm_omni/diffusion/request.py index 9e9a4bfbb..b5be82567 100644 --- a/vllm_omni/diffusion/request.py +++ b/vllm_omni/diffusion/request.py @@ -70,6 +70,8 @@ class OmniDiffusionRequest: # Opaque native vLLM connector parameters. The Orchestrator and Scheduler # transport this bag without interpreting local pages or block IDs. kv_transfer_params: dict[str, Any] | None = None + # Worker-populated scheduler reuse boundaries, available before encoding. + kv_computed_tokens: tuple[int, ...] = () # A model preprocessor may keep selected requests on the legacy full-forward # path even when the engine is globally configured for step execution. use_step_execution: bool = True diff --git a/vllm_omni/diffusion/sched/base_scheduler.py b/vllm_omni/diffusion/sched/base_scheduler.py index d9bb78ea4..4fd695dc5 100644 --- a/vllm_omni/diffusion/sched/base_scheduler.py +++ b/vllm_omni/diffusion/sched/base_scheduler.py @@ -11,10 +11,17 @@ from dataclasses import fields from vllm.config import VllmConfig from vllm.logger import init_logger from vllm.v1.kv_cache_interface import KVCacheConfig +from vllm.v1.outputs import KVConnectorOutput +from vllm.v1.request import RequestStatus from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode -from vllm_omni.diffusion.diffusion_kv.manager import DiffusionKVAdmissionError, DiffusionKVCacheManager +from vllm_omni.diffusion.diffusion_kv.kv_connector import ( + KVTransferRegistrationError, + commit_kv_load, + prepare_kv_requests, +) +from vllm_omni.diffusion.diffusion_kv.manager import DiffusionKVCacheManager from vllm_omni.diffusion.diffusion_kv.metadata import DiffusionKVMetadata from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.sched.interface import ( @@ -58,6 +65,12 @@ class BaseScheduler(ABC): self._prefetch_enabled: bool = False self._diffusion_kv_manager: DiffusionKVCacheManager | None = None self._kv_connector = None + self._kv_transfer_request_ids: set[str] = set() + self._kv_finished_request_ids: set[str] = set() + self._kv_loading_request_ids: set[str] = set() + self._kv_request_generations: dict[str, int] = {} + self._kv_draining_requests: dict[str, SchedulerRequestState] = {} + self._kv_received_request_ids: set[str] = set() def initialize( self, @@ -75,6 +88,7 @@ class BaseScheduler(ABC): self._running.clear() self._running_sampling_params_key = None self._finished_req_ids.clear() + self._reset_kv_transfer_state() max_num_seqs = getattr(od_config, "max_num_seqs", 1) try: self.max_num_running_reqs = max(1, int(max_num_seqs)) @@ -114,7 +128,7 @@ class BaseScheduler(ABC): self._diffusion_kv_manager = None from vllm_omni.diffusion.diffusion_kv.kv_connector import create_scheduler_kv_connector - self._kv_connector = create_scheduler_kv_connector(od_config) + self._kv_connector = create_scheduler_kv_connector(od_config, kv_cache_config, kv_vllm_config) self._reset_scheduler_state() @property @@ -127,7 +141,7 @@ class BaseScheduler(ABC): return self._add_request_with_request_id(request.request_id, request) def _add_request_with_request_id(self, request_id: str, request: OmniDiffusionRequest) -> str: - if request_id in self._request_states: + if request_id in self._request_states or request_id in self._kv_draining_requests: raise ValueError(f"request_id {request_id!r} is already active.") state = self._make_request_state(request_id, request) state.queued_at = time.perf_counter() @@ -147,7 +161,10 @@ class BaseScheduler(ABC): scheduled_cached_request_ids.append(request_id) # Second, schedule WAITING requests while capacity remains. - while self._waiting and len(self._running) < self.max_num_running_reqs: + deferred: list[str] = [] + waiting_budget = len(self._waiting) + while self._waiting and waiting_budget and len(self._running) < self.max_num_running_reqs: + waiting_budget -= 1 request_id = self._waiting[0] state = self._request_states.get(request_id) if state is None: @@ -161,21 +178,24 @@ class BaseScheduler(ABC): if self._diffusion_kv_manager.has_request(request_id): diffusion_kv_metadata = self._diffusion_kv_manager.get_metadata(request_id) else: + matched_tokens: list[int] = [] + if self._kv_connector is not None: + for request in state.diffusion_kv_requests: + num_tokens, _ = self._kv_connector.get_num_new_matched_tokens(request, 0) + if num_tokens is None: + break + matched_tokens.append(num_tokens) + if len(matched_tokens) != len(state.diffusion_kv_requests): + deferred.append(self._waiting.popleft()) + continue try: allocation = self._diffusion_kv_manager.reserve_request( request_id, state.diffusion_kv_requests, ) except Exception as exc: - # schedule() runs outside the Engine execution-error - # wrapper. Convert both expected admission failures and - # unexpected native allocator failures into a terminal - # request error so the busy loop can wake its stream. - if not isinstance(exc, DiffusionKVAdmissionError): - logger.exception( - "Unexpected Diffusion KV allocation failure for request %s", - request_id, - ) + # Reservation rolls back on failure and has not yet + # handed any destination pages to the connector. self._finish_requests( {request_id: DiffusionRequestStatus.FINISHED_ERROR}, {request_id: str(exc)}, @@ -184,6 +204,27 @@ class BaseScheduler(ABC): if allocation is None: break diffusion_kv_metadata = allocation + self._kv_request_generations[request_id] = allocation.allocation_generation + if self._kv_connector is not None: + try: + transfer_ids = commit_kv_load( + self._kv_connector, + self._diffusion_kv_manager.native_manager, + state.diffusion_kv_requests, + matched_tokens, + ) + except KVTransferRegistrationError as exc: + self._finish_requests( + {request_id: DiffusionRequestStatus.FINISHED_ERROR}, {request_id: str(exc)} + ) + continue + # Other exceptions have no proven rollback. Keep pages + # allocated until the Engine shuts down the Workers. + self._kv_transfer_request_ids.update(transfer_ids) + if transfer_ids: + self._kv_loading_request_ids.add(request_id) + for sequence, request in zip(allocation.sequences, state.diffusion_kv_requests, strict=True): + sequence.num_computed_tokens = request.num_computed_tokens self._waiting.popleft() was_new_request = state.status == DiffusionRequestStatus.WAITING @@ -202,6 +243,8 @@ class BaseScheduler(ABC): else: scheduled_cached_request_ids.append(request_id) + self._waiting.extendleft(reversed(deferred)) + # Expose the next waiting request (serial mode) so the runner can # prefetch its KV during this forward. Skip a request without # kv_sender_info (would target the wrong sender under multi-replica) or @@ -226,11 +269,19 @@ class BaseScheduler(ABC): num_running_reqs=len(self._running), num_waiting_reqs=len(self._waiting), kv_prefetch_job=kv_prefetch_job, + kv_finished_request_ids=set(self._kv_finished_request_ids), ) + if self._kv_connector is not None and ( + self._kv_transfer_request_ids or self._kv_finished_request_ids or self._kv_draining_requests + ): + scheduler_output.kv_connector_metadata = self._kv_connector.build_connector_meta(scheduler_output) + scheduler_output.kv_transfer_request_ids = self._kv_transfer_request_ids + self._kv_transfer_request_ids = set() # update after schedule self._step_id += 1 self._finished_req_ids.clear() + self._kv_finished_request_ids.clear() return scheduler_output @abstractmethod @@ -238,7 +289,53 @@ class BaseScheduler(ABC): pass def has_requests(self) -> bool: - return bool(self._waiting or self._running) + return bool( + self._waiting + or self._running + or self._kv_transfer_request_ids + or self._kv_finished_request_ids + or self._kv_draining_requests + ) + + def update_kv_connector_output(self, output: KVConnectorOutput | None) -> None: + if output is None: + return + assert self._kv_connector is not None + self._kv_connector.update_connector_output(output) + finished_ids = output.finished_recving or set() + if not finished_ids: + return + for request_id in tuple(self._kv_loading_request_ids): + state = self._request_states.get(request_id) or self._kv_draining_requests.get(request_id) + if state is None: + continue + internal_ids = {request.request_id for request in state.diffusion_kv_requests} + self._kv_received_request_ids.update(internal_ids.intersection(finished_ids)) + if internal_ids and internal_ids.issubset(self._kv_received_request_ids): + self._kv_loading_request_ids.discard(request_id) + self._kv_received_request_ids.difference_update(internal_ids) + + def fail_incomplete_kv_loads(self, transfer_ids: set[str]) -> set[str]: + statuses = {} + for request_id in self._kv_loading_request_ids: + state = self._request_states.get(request_id) + if state is not None and any(req.request_id in transfer_ids for req in state.diffusion_kv_requests): + statuses[request_id] = DiffusionRequestStatus.FINISHED_ERROR + return self._finish_requests(statuses, {rid: "Timed out receiving diffusion KV" for rid in statuses}) + + def completed_kv_drains(self) -> set[str]: + return self._kv_draining_requests.keys() - self._kv_loading_request_ids + + def release_kv_drains(self, request_ids: set[str]) -> None: + """Called only after all ranks completed and Worker row cleanup succeeded.""" + for request_id in request_ids: + if request_id in self._kv_loading_request_ids: + raise RuntimeError("Cannot release diffusion pages before KV receive completes") + state = self._kv_draining_requests[request_id] + self._release_request_kv(state, state.status) + del self._kv_draining_requests[request_id] + if request_id not in self._request_states: + self._kv_request_generations.pop(request_id, None) def num_waiting_requests(self) -> int: return len(self._waiting) @@ -270,13 +367,27 @@ class BaseScheduler(ABC): def get_request_state(self, request_id: str) -> SchedulerRequestState | None: return self._request_states.get(request_id) + def get_diffusion_kv_cleanup_targets(self, request_ids: list[str]) -> list[str | tuple[str, int]]: + targets: list[str | tuple[str, int]] = [] + for request_id in request_ids: + if request_id in self._kv_loading_request_ids: + continue + generation = self._kv_request_generations.get(request_id) + if generation is not None: + targets.append((request_id, generation)) + else: + targets.append(request_id) + return targets + def pop_request_state(self, request_id: str) -> SchedulerRequestState | None: - if self._diffusion_kv_manager is not None: - self._diffusion_kv_manager.free_request(request_id) self._pop_extra_request_state(request_id) + if request_id not in self._kv_draining_requests: + self._kv_request_generations.pop(request_id, None) return self._request_states.pop(request_id, None) def preempt_request(self, request_id: str) -> bool: + if request_id in self._kv_loading_request_ids: + return False if request_id not in self._request_states: return False if request_id in self._running: @@ -295,13 +406,7 @@ class BaseScheduler(ABC): self._finish_requests({request_id: status for request_id in request_ids}) def close(self) -> None: - if self._diffusion_kv_manager is not None: - self._diffusion_kv_manager.close() - self._diffusion_kv_manager = None - from vllm_omni.diffusion.diffusion_kv.kv_connector import shutdown_kv_connector - - shutdown_kv_connector(scheduler_connector=self._kv_connector) - self._kv_connector = None + self._shutdown_diffusion_kv() self._request_states.clear() self._waiting.clear() self._running.clear() @@ -309,6 +414,26 @@ class BaseScheduler(ABC): self._finished_req_ids.clear() self._reset_scheduler_state() + def _reset_kv_transfer_state(self) -> None: + """Clear request bookkeeping for native KV transfers.""" + self._kv_transfer_request_ids.clear() + self._kv_finished_request_ids.clear() + self._kv_loading_request_ids.clear() + self._kv_draining_requests.clear() + self._kv_received_request_ids.clear() + self._kv_request_generations.clear() + + def _shutdown_diffusion_kv(self) -> None: + """Stop transfers before releasing their cache and request state.""" + from vllm_omni.diffusion.diffusion_kv.kv_connector import shutdown_kv_connector + + shutdown_kv_connector(scheduler_connector=self._kv_connector) + self._kv_connector = None + if self._diffusion_kv_manager is not None: + self._diffusion_kv_manager.close() + self._diffusion_kv_manager = None + self._reset_kv_transfer_state() + def _finish_requests( self, statuses: dict[str, DiffusionRequestStatus], @@ -326,7 +451,6 @@ class BaseScheduler(ABC): state = self._request_states.get(request_id) if state is None or state.is_finished(): continue - finished_req_ids.add(request_id) if request_id in self._running: running_to_remove.add(request_id) @@ -341,10 +465,12 @@ class BaseScheduler(ABC): self._waiting = deque(request_id for request_id in self._waiting if request_id not in waiting_to_remove) for request_id in finished_req_ids: - if self._diffusion_kv_manager is not None: - self._diffusion_kv_manager.free_request(request_id) state = self._request_states[request_id] status = statuses[request_id] + if request_id in self._kv_loading_request_ids: + self._kv_draining_requests[request_id] = state + else: + self._release_request_kv(state, status) state.status = status if status == DiffusionRequestStatus.FINISHED_ERROR: state.error = None if errors is None else errors.get(request_id) @@ -354,6 +480,27 @@ class BaseScheduler(ABC): self._finished_req_ids |= finished_req_ids return finished_req_ids + def _release_request_kv(self, state: SchedulerRequestState, status: DiffusionRequestStatus) -> None: + request_id = state.request_id + if self._kv_connector is not None: + for request in state.diffusion_kv_requests: + request.status = ( + RequestStatus.FINISHED_ABORTED + if status == DiffusionRequestStatus.FINISHED_ABORTED + else RequestStatus.FINISHED_STOPPED + ) + if request.kv_transfer_params: + self._kv_finished_request_ids.add(request.request_id) + block_ids = [] + if self._diffusion_kv_manager.has_request(request_id): + blocks = self._diffusion_kv_manager.native_manager.get_blocks(request.request_id) + block_ids = blocks.get_block_ids()[0] + delay_free, _ = self._kv_connector.request_finished(request, block_ids) + if delay_free: + raise RuntimeError("Diffusion consumer cannot release pages while KV transfer is active") + if self._diffusion_kv_manager is not None: + self._diffusion_kv_manager.free_request(request_id) + def _finalize_update_from_output( self, sched_output: DiffusionSchedulerOutput, @@ -391,13 +538,9 @@ class BaseScheduler(ABC): elif kv_requests: raise ValueError("dense_legacy request unexpectedly contains Scheduler Diffusion KV requests") - # Keep the request-scoped native connector bag on each Scheduler-owned - # sequence without interpreting its contents or creating page state. kv_transfer_params = getattr(request, "kv_transfer_params", None) if kv_transfer_params is not None: - for kv_request in kv_requests: - if kv_request.kv_transfer_params is None: - kv_request.kv_transfer_params = dict(kv_transfer_params) + prepare_kv_requests(kv_requests, kv_transfer_params) # DiffusionKVRequest objects are mutable Scheduler/native-KVCacheManager # state and must never ride the normal request payload to a Worker. diff --git a/vllm_omni/diffusion/sched/interface.py b/vllm_omni/diffusion/sched/interface.py index 122a97ec2..fd2b47c63 100644 --- a/vllm_omni/diffusion/sched/interface.py +++ b/vllm_omni/diffusion/sched/interface.py @@ -4,7 +4,7 @@ from __future__ import annotations import enum -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import cached_property from typing import TYPE_CHECKING, Any, TypedDict @@ -254,9 +254,10 @@ class DiffusionSchedulerOutput: num_waiting_reqs: int # next request to background-prefetch KV kv_prefetch_job: KVPrefetchJob | None = None - # Opaque metadata emitted by a future Scheduler-role connector. PR0 keeps - # the input port but does not build or consume it. kv_connector_metadata: KVConnectorMetadata | None = None + kv_transfer_request_ids: set[str] = field(default_factory=set) + # Connector lifecycle uses per-sequence IDs, not public request IDs. + kv_finished_request_ids: set[str] = field(default_factory=set) @cached_property def scheduled_request_ids(self) -> list[str]: @@ -274,4 +275,4 @@ class DiffusionSchedulerOutput: @property def is_empty(self) -> bool: - return self.num_scheduled_reqs == 0 + return self.num_scheduled_reqs == 0 and self.kv_connector_metadata is None diff --git a/vllm_omni/diffusion/sched/request_scheduler.py b/vllm_omni/diffusion/sched/request_scheduler.py index 4168ae974..826a63c67 100644 --- a/vllm_omni/diffusion/sched/request_scheduler.py +++ b/vllm_omni/diffusion/sched/request_scheduler.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations @@ -100,7 +100,7 @@ class RequestScheduler(BaseScheduler): def update_from_output(self, sched_output: DiffusionSchedulerOutput, output: RunnerOutput) -> set[str]: scheduled_request_ids = sched_output.scheduled_request_ids - if not scheduled_request_ids: + if not scheduled_request_ids and not sched_output.finished_req_ids: return set() terminal_statuses: dict[str, DiffusionRequestStatus] = {} diff --git a/vllm_omni/diffusion/sched/step_scheduler.py b/vllm_omni/diffusion/sched/step_scheduler.py index a5a7a04ae..636c71ba2 100644 --- a/vllm_omni/diffusion/sched/step_scheduler.py +++ b/vllm_omni/diffusion/sched/step_scheduler.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations @@ -64,7 +64,7 @@ class StepScheduler(BaseScheduler): def update_from_output(self, sched_output: DiffusionSchedulerOutput, output: RunnerOutput) -> set[str]: scheduled_request_ids = sched_output.scheduled_request_ids - if not scheduled_request_ids: + if not scheduled_request_ids and not sched_output.finished_req_ids: return set() terminal_statuses: dict[str, DiffusionRequestStatus] = {} diff --git a/vllm_omni/diffusion/vllm_config.py b/vllm_omni/diffusion/vllm_config.py index c90671d60..12ae1bdb9 100644 --- a/vllm_omni/diffusion/vllm_config.py +++ b/vllm_omni/diffusion/vllm_config.py @@ -151,6 +151,9 @@ class _DiffusionVllmModelConfig: def get_head_size(self) -> int: return self._require_attention_geometry()[2] + def get_total_num_kv_heads(self) -> int: + return self.hf_text_config.num_key_value_heads + @property def is_diffusion(self) -> bool: # This config is also installed as the current VllmConfig for the @@ -224,7 +227,10 @@ def configure_diffusion_vllm_config(vllm_config: VllmConfig, od_config: OmniDiff vllm_config.cache_config.kv_cache_memory_bytes = getattr(od_config, "kv_cache_memory_bytes", None) # Prefix hashes/publication are intentionally deferred to the next PR. vllm_config.cache_config.enable_prefix_caching = False - vllm_config.scheduler_config.max_num_seqs = int(od_config.max_num_seqs) + max_rows_per_request = getattr(od_config, "diffusion_kv_max_rows_per_request", None) + if type(max_rows_per_request) is not int or max_rows_per_request <= 0: + raise ValueError("paged_scheduler requires a positive diffusion_kv_max_rows_per_request") + vllm_config.scheduler_config.max_num_seqs = int(od_config.max_num_seqs) * max_rows_per_request max_num_batched_tokens = getattr(od_config, "max_num_batched_tokens", None) if max_num_batched_tokens is not None: vllm_config.scheduler_config.max_num_batched_tokens = int(max_num_batched_tokens) diff --git a/vllm_omni/diffusion/worker/diffusion_model_runner.py b/vllm_omni/diffusion/worker/diffusion_model_runner.py index 5fb124a18..6a5b81c58 100644 --- a/vllm_omni/diffusion/worker/diffusion_model_runner.py +++ b/vllm_omni/diffusion/worker/diffusion_model_runner.py @@ -193,14 +193,20 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): self.state_cache: dict[str, StepRequestState] = {} # Initialize KV cache manager for connector management. - self.kv_transfer_manager = OmniKVTransferManager.from_od_config(od_config) + self.kv_transfer_manager = ( + OmniKVTransferManager.from_od_config(od_config) + if getattr(od_config, "kv_transfer_config", None) is None + else None + ) + self._kv_connector = None # Prefetch covers TP / SP / CFG-Parallel / HSDP. Disabled when a CFG # companion KV collector is set (that KV is not backgrounded). has_cfg_companion_kv = getattr(od_config, "cfg_kv_collect_func", None) is not None self._kv_prefetch_enabled = ( - bool(self.kv_transfer_manager.config.enable_kv_async_prefetch) + self.kv_transfer_manager is not None + and bool(self.kv_transfer_manager.config.enable_kv_async_prefetch) and not has_cfg_companion_kv and self.kv_transfer_manager.config.need_recv_cache ) @@ -471,6 +477,16 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): self.diffusion_kv_backend.initialize_kv_cache(kv_cache_config) self.kv_cache_config = self.diffusion_kv_backend.kv_cache_config + if getattr(self.od_config, "kv_transfer_config", None) is not None: + from vllm.v1.worker.gpu.kv_connector import ActiveKVConnector + + self._kv_connector = ActiveKVConnector(self.vllm_config, self.diffusion_kv_backend.kv_caches_by_layer) + + def prepare_kv_for_forward(self, scheduler_output: DiffusionSchedulerOutput): + from vllm_omni.diffusion.diffusion_kv.kv_connector import wait_for_kv_load + + timeout = self.od_config.kv_transfer_config.kv_connector_extra_config.get("transfer_timeout", 60.0) + return wait_for_kv_load(self._kv_connector, scheduler_output, timeout) def install_diffusion_kv_metadata(self, metadata: DiffusionKVMetadata) -> bool: return self.diffusion_kv_backend.install_diffusion_kv_metadata(metadata) @@ -483,7 +499,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): ) -> int: return self.diffusion_kv_backend.get_diffusion_kv_row(request_id, sequence_id, context_id) - def remove_diffusion_kv_requests(self, request_ids: list[str]) -> int: + def remove_diffusion_kv_requests(self, request_ids: list[str | tuple[str, int]]) -> int: return self.diffusion_kv_backend.remove_diffusion_kv_requests(request_ids) def refresh_diffusion_kv_block_table_layout(self) -> None: @@ -520,8 +536,9 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): DiffusionPagedAttentionRow( request_id=request_metadata.request_id, sequence_id=sequence.sequence_id, - query_len=sequence.seq_len, + query_len=sequence.seq_len - sequence.num_computed_tokens, seq_len=sequence.seq_len, + kv_start_pos=sequence.num_computed_tokens, ) ) denoise_rows.append( @@ -606,6 +623,9 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): kv_prefetch_job: KVPrefetchJob | None = None, use_prefetch: bool = False, ) -> None: + if self.kv_transfer_manager is None: + self._initialize_generator(req.sampling_params) + return # Receive AR KV. Single-request execution can use the prefetch path: # consume prior-forward payload, sync-fallback on miss; request-batch # execution keeps the synchronous per-request receive path. @@ -770,6 +790,8 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): "Diffusion KV metadata count must match the request batch: " f"metadata={len(diffusion_kv_metadata)}, requests={len(reqs)}" ) + for req, metadata in zip(reqs, diffusion_kv_metadata, strict=True): + req.kv_computed_tokens = tuple(seq.num_computed_tokens for seq in metadata.sequences) paged_metadata = self._build_paged_attention_metadata(diffusion_kv_metadata) paged_kv_runtime, paged_kv_context = self.diffusion_kv_backend.activate_paged_attention_metadata( paged_metadata @@ -1018,13 +1040,18 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): kv_sender_info=sched_new_req.req.kv_sender_info, prepared_layout=getattr(sched_new_req.req, "prepared_layout", None), ) + if sched_new_req.diffusion_kv_metadata is not None: + new_state.extra["kv_computed_tokens"] = tuple( + seq.num_computed_tokens for seq in sched_new_req.diffusion_kv_metadata.sequences + ) state_req = copy.copy(sched_new_req.req) state_req.sampling_params = new_state.sampling - self.kv_transfer_manager.receive_multi_kv_cache_distributed( - state_req, - cfg_kv_collect_func=getattr(self.od_config, "cfg_kv_collect_func", None), - target_device=self._target_device, - ) + if self.kv_transfer_manager is not None: + self.kv_transfer_manager.receive_multi_kv_cache_distributed( + state_req, + cfg_kv_collect_func=getattr(self.od_config, "cfg_kv_collect_func", None), + target_device=self._target_device, + ) self.state_cache[request_id] = new_state resolved.append(new_state) diff --git a/vllm_omni/diffusion/worker/diffusion_worker.py b/vllm_omni/diffusion/worker/diffusion_worker.py index f934646dc..4eadf33db 100644 --- a/vllm_omni/diffusion/worker/diffusion_worker.py +++ b/vllm_omni/diffusion/worker/diffusion_worker.py @@ -59,6 +59,7 @@ from vllm_omni.diffusion.distributed.parallel_state import ( get_hsdp_replicate_group, get_pp_group, get_sp_group, + get_world_group, init_distributed_environment, initialize_model_parallel, model_parallel_is_initialized, @@ -116,7 +117,11 @@ def _all_gather_rank_values(value: Any) -> list[Any]: if not dist.is_available() or not dist.is_initialized(): return [value] values: list[Any] = [None] * dist.get_world_size() - dist.all_gather_object(values, value) + # Object collectives are control-plane traffic. Using the default NCCL + # group serializes them through temporary CUDA tensors, adding pointless + # H2D/DtoH copies to every rank-wide status check. Reuse the world + # coordinator's Gloo group, as vLLM does for CPU metadata collectives. + dist.all_gather_object(values, value, group=get_world_group().cpu_group) return values @@ -446,15 +451,13 @@ class DiffusionWorker: raise RuntimeError("Diffusion KV memory snapshot was not captured before model loading") override = self.vllm_config.cache_config.kv_cache_memory_bytes if override: - # Match native vLLM: an explicit cache budget skips automatic - # capacity derivation, but still runs the maximum-shape model - # request so lazy kernels and communication buffers initialize. - self.model_runner.profile_run(profile_requests) + # Post-allocation warmup avoids retaining a second activation arena. logger.info( "Worker %d: Initial free memory %s GiB, reserved %s GiB memory for " "Diffusion KV Cache as specified by kv_cache_memory_bytes config and " "skipped automatic memory profiling. This does not respect the " - "gpu_memory_utilization config. A profile warmup was still executed.", + "gpu_memory_utilization config. Model kernels will be initialized by " + "the post-allocation startup warmup.", self.rank, format_gib(self.init_snapshot.free_memory), format_gib(int(override)), @@ -467,6 +470,8 @@ class DiffusionWorker: ) as profile_result: self.model_runner.profile_run(profile_requests) + current_omni_platform.empty_cache() + available_memory = self.requested_memory - profile_result.non_kv_cache_memory if available_memory <= 0: raise RuntimeError( @@ -534,12 +539,18 @@ class DiffusionWorker: with self._maybe_get_memory_pool_context("kv_cache"): self.model_runner.set_kv_cache_config(kv_cache_config) - def remove_diffusion_kv_requests(self, request_ids: list[str]) -> int: + def remove_diffusion_kv_requests(self, request_ids: list[str | tuple[str, int]]) -> int: """Clear Worker-local rows without freeing Scheduler-owned blocks.""" assert self.model_runner is not None, "Model runner not initialized" return self.model_runner.remove_diffusion_kv_requests(request_ids) + def prepare_kv_for_forward(self, scheduler_output: DiffusionSchedulerOutput): + return _run_and_gather_rank_values( + "Diffusion KV receive", + lambda: self.model_runner.prepare_kv_for_forward(scheduler_output), + ) + def init_lora_manager(self) -> None: """Initialize the LoRA manager for this worker.""" pipeline = self.model_runner.pipeline @@ -790,6 +801,15 @@ class DiffusionWorker: Args: level: Sleep level. Level 1 offloads weights, level 2 also saves buffers. """ + # The config validator rejects sleep for the native paged path. Keep + # this worker-side guard precise as well: test doubles and legacy + # configs may expose arbitrary attributes through Mock/getattr. + if ( + getattr(self.od_config, "diffusion_kv_mode", DiffusionKVCacheMode.DENSE_LEGACY) + is DiffusionKVCacheMode.PAGED_SCHEDULER + and getattr(self.od_config, "kv_transfer_config", None) is not None + ): + raise ValueError("Cannot sleep while native KV connector memory is registered") CuMemAllocator = _get_cumem_allocator_class() allocator = CuMemAllocator.get_instance() @@ -1254,7 +1274,11 @@ class WorkerProc: world_size = torch.distributed.get_world_size() statuses: list[dict[str, Any] | None] = [None] * world_size - torch.distributed.all_gather_object(statuses, status) + torch.distributed.all_gather_object( + statuses, + status, + group=get_world_group().cpu_group, + ) missing_ranks = [rank for rank, rank_status in enumerate(statuses) if rank_status is None] if missing_ranks: logger.warning("RPC rank status gather returned missing entries for ranks: %s", missing_ranks) diff --git a/vllm_omni/engine/errors.py b/vllm_omni/engine/errors.py new file mode 100644 index 000000000..0128176d6 --- /dev/null +++ b/vllm_omni/engine/errors.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Engine runtime error types.""" + + +class NativeKVHandoffError(RuntimeError): + """One request lost its producer binding or transfer metadata.""" diff --git a/vllm_omni/engine/orchestrator.py b/vllm_omni/engine/orchestrator.py index 411c8b5c2..277a48119 100644 --- a/vllm_omni/engine/orchestrator.py +++ b/vllm_omni/engine/orchestrator.py @@ -38,6 +38,7 @@ from vllm_omni.diffusion.data import is_diffusion_request_started_output from vllm_omni.distributed.omni_connectors.utils.config import stage_receives_chunks from vllm_omni.engine import OmniEngineCoreRequest from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker +from vllm_omni.engine.errors import NativeKVHandoffError from vllm_omni.engine.membership_controller import MembershipController from vllm_omni.engine.messages import ( AbortRequestMessage, @@ -211,6 +212,7 @@ class OrchestratorRequestState: session_owned: bool = False running_counter_registered: bool = False request_artifact_dirs: set[str] = field(default_factory=set) + native_kv_transfer_id: str | None = None @dataclass @@ -1363,6 +1365,16 @@ class OrchestratorBase: try: await dispatch() return True + except NativeKVHandoffError as e: + await self._fail_request_client_error( + req_id, + stage_id, + str(e), + status_code=HTTPStatus.BAD_GATEWAY.value, + error_type="NativeKVHandoffError", + release_owners=True, + ) + return False except StageUnavailableError as e: # No specific replica to evict: the stage already has no live # replica or the chosen slot was evicted. Fail just this request. @@ -2193,12 +2205,13 @@ class OrchestratorBase: req_id, req_state, diffusion_prompt, - submit_kwargs={ - "kv_sender_info": self._build_kv_sender_info( - list(getattr(next_client, "engine_input_source", None) or [src_stage_id]), - request_id=req_id, - ) - }, + submit_kwargs=self._diffusion_submit_kwargs( + req_id, + src_stage_id, + next_client, + req_state, + output, + ), params_override=self._maybe_clone_diffusion_params_for_cfg(req_id, params), ) self._on_stage_submitted( @@ -2452,12 +2465,12 @@ class OrchestratorBase: _t_submit_start = _time.perf_counter() if next_pool.stage_type == "diffusion": - submit_kwargs = { - "kv_sender_info": self._build_kv_sender_info( - list(getattr(next_pool.stage_client, "engine_input_source", None) or [next_stage_id - 1]), - request_id=request_id, - ) - } + submit_kwargs = self._diffusion_submit_kwargs( + request_id, + next_stage_id - 1, + next_pool.stage_client, + req_state, + ) submitted = await self._dispatch_or_fail_request( lambda: next_pool.submit_initial( request_id, @@ -2554,6 +2567,67 @@ class OrchestratorBase: return True + def _maybe_attach_native_kv_transfer_params( + self, + req_state: OrchestratorRequestState, + prompt: Any, + ) -> None: + source_stage_id = 0 + if source_stage_id + 1 > req_state.final_stage_id: + return + config = getattr(self.stage_pools[source_stage_id].stage_vllm_config, "kv_transfer_config", None) + if getattr(config, "kv_role", None) != "kv_producer": + return + if self.stage_pools[source_stage_id + 1].stage_type != "diffusion": + return + + from vllm_omni.diffusion.diffusion_kv.kv_connector import build_source_kv_transfer_params, mint_transfer_id + + transfer_id = mint_transfer_id(req_state.request_id) + req_state.native_kv_transfer_id = transfer_id + params = build_source_kv_transfer_params( + transfer_id=transfer_id, + remote_engine_id=None, + remote_bootstrap_addr=None, + ) + for sampling in (req_state.sampling_params_list[source_stage_id], prompt.sampling_params): + sampling.extra_args = {**(sampling.extra_args or {}), "kv_transfer_params": params} + + def _diffusion_submit_kwargs( + self, + request_id: str, + source_stage_id: int, + next_client: Any, + req_state: OrchestratorRequestState, + output: Any = None, + ) -> dict[str, Any]: + source_stage_ids = list(getattr(next_client, "engine_input_source", None) or [source_stage_id]) + if req_state.native_kv_transfer_id is None: + return {"kv_sender_info": self._build_kv_sender_info(source_stage_ids, request_id=request_id)} + if output is None: + raise NativeKVHandoffError("Native KV handoff requires one completed AR source") + from vllm_omni.diffusion.diffusion_kv.kv_connector import ( + bootstrap_addr_from_kv_transfer_config, + build_target_kv_transfer_params, + ) + + source = self.stage_pools[source_stage_id].get_bound_client(request_id) + config = getattr(getattr(source, "vllm_config", None), "kv_transfer_config", None) + if source is None or config is None: + raise NativeKVHandoffError( + f"Native KV handoff for {request_id}: bound AR replica or its KV configuration is unavailable" + ) + params = getattr(output, "kv_transfer_params", None) + if not params or "num_transfer_tokens" not in params: + raise NativeKVHandoffError("AR source completed without native KV transfer metadata") + return { + "kv_transfer_params": build_target_kv_transfer_params( + source_params=params, + remote_engine_id=config.engine_id, + remote_bootstrap_addr=bootstrap_addr_from_kv_transfer_config(config), + ) + } + def _build_kv_sender_info( self, sender_stage_ids: list[int], @@ -2677,6 +2751,7 @@ class Orchestrator(OrchestratorBase): request_artifact_dirs=set(msg.request_artifact_dirs or ()), ) self.request_states[request_id] = req_state + self._maybe_attach_native_kv_transfer_params(req_state, prompt) self._register_running_request(req_state) req_state.streaming.enabled = bool(getattr(prompt, "resumable", False)) req_state.stage_submit_ts[stage_id] = _time.time() diff --git a/vllm_omni/engine/stage_init_utils.py b/vllm_omni/engine/stage_init_utils.py index f15e1238f..faaf6b707 100644 --- a/vllm_omni/engine/stage_init_utils.py +++ b/vllm_omni/engine/stage_init_utils.py @@ -1189,6 +1189,8 @@ def _project_omni_stage_engine_args( engine_args["async_chunk"] = connector_config.async_chunk if connector_config.omni_kv_config is not None: engine_args["omni_kv_config"] = copy.deepcopy(connector_config.omni_kv_config) + if connector_config.kv_transfer_config is not None: + engine_args["kv_transfer_config"] = copy.deepcopy(connector_config.kv_transfer_config) if is_diffusion: engine_args["parallel_config"] = _project_omni_config_fields( @@ -1980,6 +1982,11 @@ def build_diffusion_config( ) od_config = OmniDiffusionConfig(**{name: value for name, value in diffusion_kwargs.items() if value is not None}) + for dimension in ("height", "width"): + value = getattr(metadata.default_sampling_params, dimension, None) + if isinstance(value, int) and value > 0: + od_config.additional_config.setdefault(f"diffusion_kv_profile_{dimension}", value) + num_devices_per_stage = od_config.parallel_config.world_size device_control_env = current_omni_platform.device_control_env_var visible_devices_str = os.environ.get(device_control_env) if device_control_env else None diff --git a/vllm_omni/engine/stage_runtime.py b/vllm_omni/engine/stage_runtime.py index ce958ba6d..ed22bfa87 100644 --- a/vllm_omni/engine/stage_runtime.py +++ b/vllm_omni/engine/stage_runtime.py @@ -19,7 +19,7 @@ from typing import Any, TypedDict, cast import janus from vllm.logger import init_logger -from vllm_omni.config.omni_config import BaseVllmOmniStageConfig +from vllm_omni.config.omni_config import BaseVllmOmniStageConfig, VllmOmniDiffusionStageConfig from vllm_omni.distributed.omni_connectors.utils.initialization import ( resolve_omni_kv_config_for_stage, ) @@ -47,6 +47,7 @@ from vllm_omni.engine.stage_engine_startup import ( from vllm_omni.engine.stage_init_utils import ( LogicalStageInitPlan, ReplicaInitPlan, + StageMetadata, _inject_inferred_kv_tp_topology, acquire_device_locks, build_engine_args_dict, @@ -537,6 +538,7 @@ class StageRuntime: replicas_per_stage, replica_devices_map, ) + self._validate_native_kv_topology(stage_plans) return stage_plans def _finalize_initialized_stages( @@ -585,6 +587,113 @@ class StageRuntime: # ---- Internal methods ---- + def _validate_native_kv_topology(self, plans: list[LogicalStageInitPlan]) -> None: + """Reject unsupported native AR->DiT graphs before launching any Worker.""" + if not any(plan.replicas[0].metadata.stage_type == "diffusion" for plan in plans): + return # Native LLM prefill/decode is outside this path. + roles = {} + for plan in plans: + replica = plan.replicas[0] + if replica.stage_vllm_config is not None: + config = getattr(replica.stage_vllm_config, "kv_transfer_config", None) + elif isinstance(replica.stage_cfg, VllmOmniDiffusionStageConfig): + config = ( + replica.stage_cfg.connector_config.kv_transfer_config + or replica.stage_cfg.diffusion_config.kv_transfer_config + ) + else: + args = getattr(replica.stage_cfg, "engine_args", {}) + config = ( + args.get("kv_transfer_config") + if isinstance(args, Mapping) + else getattr(args, "kv_transfer_config", None) + ) + if config is not None: + roles[plan.stage_id] = config.get("kv_role") if isinstance(config, Mapping) else config.kv_role + if not roles: + return + if ( + self._async_chunk + or len(plans) != 2 + or roles != {0: "kv_producer", 1: "kv_consumer"} + or plans[0].replicas[0].metadata.stage_type != "llm" + or plans[1].replicas[0].metadata.stage_type != "diffusion" + or list(plans[1].replicas[0].metadata.engine_input_source or []) != [0] + ): + raise ValueError( + "Native AR-to-DiT KV transfer currently supports only a two-stage " + "0 (kv_producer) -> 1 (diffusion kv_consumer, engine_input_source=[0]) " + "pipeline with async_chunk=False" + ) + + @staticmethod + def _prepare_replica_stage_config( + stage_cfg: Any, + *, + stage_id: int, + stage_type: str, + replica_id: int, + num_replicas: int, + ) -> tuple[Any, bool]: + """Isolate replica config and assign the native DiT KV identity.""" + if isinstance(stage_cfg, BaseVllmOmniStageConfig): + native_kv = stage_cfg.connector_config.kv_transfer_config + if native_kv is None and isinstance(stage_cfg, VllmOmniDiffusionStageConfig): + native_kv = stage_cfg.diffusion_config.kv_transfer_config + else: + native_kv = ( + stage_cfg.engine_args.get("kv_transfer_config") + if isinstance(stage_cfg.engine_args, Mapping) + else getattr(stage_cfg.engine_args, "kv_transfer_config", None) + ) + # Keep the logical stage's device pool and native KV identity + # intact; each replica owns its config throughout startup. + replica_cfg = copy.deepcopy(stage_cfg) if num_replicas > 1 or native_kv else stage_cfg + if native_kv and stage_type == "diffusion": + if isinstance(replica_cfg, VllmOmniDiffusionStageConfig): + kv_config = ( + replica_cfg.connector_config.kv_transfer_config or replica_cfg.diffusion_config.kv_transfer_config + ) + assert kv_config is not None + kv_config.engine_id = f"{kv_config.engine_id}-s{stage_id}-r{replica_id}" + else: + kv_config = replica_cfg.engine_args["kv_transfer_config"] + kv_config["engine_id"] = f"{kv_config['engine_id']}-s{stage_id}-r{replica_id}" + return replica_cfg, bool(native_kv) + + @staticmethod + def _prepare_replica_vllm_config( + stage_vllm_config: Any, + replica_cfg: Any, + replica_metadata: StageMetadata, + *, + native_kv: bool, + ) -> Any: + """Assign native AR KV identity and the producer bootstrap endpoint.""" + if not native_kv or stage_vllm_config is None: + return stage_vllm_config + replica_vllm_config = copy.deepcopy(stage_vllm_config) + kv_config = replica_vllm_config.kv_transfer_config + kv_config.engine_id = f"{kv_config.engine_id}-s{replica_metadata.stage_id}-r{replica_metadata.replica_id}" + if kv_config.kv_connector == "MooncakeConnector" and kv_config.kv_role == "kv_producer": + extra = kv_config.kv_connector_extra_config + port = int(extra.get("bootstrap_port", 8998)) + replica_metadata.replica_id + extra["bootstrap_addr"] = f"http://{kv_config.kv_ip}:{port}" + if isinstance(replica_cfg, BaseVllmOmniStageConfig): + runtime_cfg = replica_cfg.runtime_config + runtime_cfg.env = { + **(runtime_cfg.env or {}), + "VLLM_MOONCAKE_BOOTSTRAP_PORT": str(port), + } + else: + runtime_cfg = copy.deepcopy(replica_metadata.runtime_cfg or {}) + runtime_cfg["env"] = { + **(runtime_cfg.get("env") or {}), + "VLLM_MOONCAKE_BOOTSTRAP_PORT": str(port), + } + replica_metadata.runtime_cfg = runtime_cfg + return replica_vllm_config + def _build_logical_stage_init_plans( self, omni_transfer_config: Any, @@ -658,9 +767,13 @@ class StageRuntime: ) for replica_id in range(num_replicas): - # Keep the logical stage's device pool intact; each replica owns - # the same config throughout planning, metadata and launch. - replica_cfg = copy.deepcopy(stage_cfg) if num_replicas > 1 else stage_cfg + replica_cfg, native_kv = self._prepare_replica_stage_config( + stage_cfg, + stage_id=stage_id, + stage_type=base_metadata.stage_type, + replica_id=replica_id, + num_replicas=num_replicas, + ) if stage_idx in replica_devices_map: devices = replica_devices_map[stage_idx][replica_id] runtime_cfg = getattr(replica_cfg, "runtime_config", getattr(replica_cfg, "runtime", None)) @@ -673,6 +786,12 @@ class StageRuntime: else extract_legacy_stage_metadata(replica_cfg) ) replica_metadata.replica_id = replica_id + replica_vllm_config = self._prepare_replica_vllm_config( + stage_vllm_config, + replica_cfg, + replica_metadata, + native_kv=native_kv, + ) if launch_mode == "remote" and replica_metadata.stage_type != "diffusion": replica_metadata.runtime_cfg = None replicas.append( @@ -684,7 +803,7 @@ class StageRuntime: metadata=replica_metadata, stage_connector_spec=stage_connector_spec, omni_kv_connector=omni_kv_connector, - stage_vllm_config=stage_vllm_config, + stage_vllm_config=replica_vllm_config, executor_class=executor_class, engine_args_dict=copy.deepcopy(engine_args_dict) if engine_args_dict is not None else None, ) diff --git a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py index 31225d7ad..0e48412d5 100755 --- a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py +++ b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py @@ -1536,7 +1536,10 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo # Used to embed timestep information into the input sequence. self.timestep_emb = TimestepEmbedder(hidden_size=config.hidden_size) - tokenizer = get_tokenizer(vllm_config.model_config.tokenizer) + tokenizer = get_tokenizer( + vllm_config.model_config.tokenizer, + trust_remote_code=vllm_config.model_config.trust_remote_code, + ) self._mrope_img_token_id = tokenizer.convert_tokens_to_ids("") self._mrope_boi_token_id = tokenizer.convert_tokens_to_ids("") self._mrope_eoi_token_id = tokenizer.convert_tokens_to_ids("") diff --git a/vllm_omni/worker/mixins.py b/vllm_omni/worker/mixins.py index 5f0fd8300..724cc87be 100644 --- a/vllm_omni/worker/mixins.py +++ b/vllm_omni/worker/mixins.py @@ -7,7 +7,7 @@ from typing import Any class OmniWorkerMixin: - """Mixin to ensure Omni plugins are loaded in worker processes.""" + """Shared Omni plugin and native KV connector setup for workers.""" def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -15,6 +15,16 @@ class OmniWorkerMixin: load_omni_general_plugins() + def initialize_from_config(self, kv_cache_config) -> None: + # The concrete GPU/NPU/XPU worker supplies this method through the MRO. + super().initialize_from_config(kv_cache_config) # type: ignore[misc] + from vllm.distributed.kv_transfer import get_kv_transfer_group, has_kv_transfer_group + + if has_kv_transfer_group(): + from vllm_omni.diffusion.diffusion_kv.kv_connector import install_mooncake_cfg_fanout + + install_mooncake_cfg_fanout(get_kv_transfer_group()) + def maybe_unpad_input_ids(model, input_ids, num_tokens_unpadded): """Trim cudagraph-bucket padding for models that split a flat ``input_ids``