[Model][Core] Avoid unused CosyVoice3 payloads and redundant AR output work (#8473)

Signed-off-by: Sorbett <sorbet111@163.com>
This commit is contained in:
Scott Ye authored and GitHub committed 2026-10-05 04:52:19 +00:00
1 parent 5ca23106fd
commit 1f47fa2f29
9 files changed
+525 -73

No files matched your search

+23
View File
@@ -47,6 +47,29 @@ adapter, not injected through unrelated scheduler state.
**Rule:** Workers and model runners MUST NOT implement cross-stage routing.
## Token-only output payloads
An AR stage whose downstream decoder consumes sampled tokens rather than
hidden states can set `omni_pooler_payload_include_hidden = False`. Hidden
states remain available for logits and sampling; prefill conditioning and
other required multimodal outputs still cross the stage boundary.
A chunk processor that needs IDs on steps without a tensor payload declares
`requires_token_updates = True`. The scheduler then enqueues nonempty sampled
IDs while preserving final-stage exclusions and the processor's EOF handling.
Processors without this declaration retain their tensor-payload trigger.
When hidden and multimodal payloads are absent, the shared runner skips empty
per-request payload construction. Prefix-cache merges and pending model
postprocess retain the normal path. This policy is independent of whether
output materialization runs inline or in the background.
Inline construction consumes query offsets, scheduled-token counts and
scheduler metadata before the next execution can reuse them. Background
construction snapshots those inputs. Both paths reuse the request IDs already
copied by upstream bookkeeping; connector collection stays on the main thread
because it can run a tensor-parallel collective.
## Safe-change guide
Test request lifecycle, abort, cache state, and every affected worker execution
@@ -23,6 +23,7 @@ from vllm.v1.engine import FinishReason
from vllm.v1.core.sched.request_queue import SchedulingPolicy, create_request_queue
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler, OmniARScheduler
from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import talker2code2wav_async_chunk
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
OmniChunkTransferAdapter,
)
@@ -287,7 +288,12 @@ def test_resumable_segment_boundary_keeps_pre_transition_send_watermark() -> Non
)
def test_running_decode_step_without_inter_stage_payload_does_not_raise() -> None:
@pytest.mark.parametrize("processor", [None, talker2code2wav_async_chunk], ids=["no_opt_in", "cosyvoice3"])
@pytest.mark.parametrize("new_token_ids", [[], [42]], ids=["no_tokens", "sampled_token"])
@pytest.mark.parametrize("omit_chunk_transfer", [False, True], ids=["downstream", "stage0_final"])
def test_running_decode_step_without_inter_stage_payload_does_not_raise(
processor, new_token_ids, omit_chunk_transfer, mocker
) -> None:
"""A decode step that neither stops nor carries an inter-stage payload.
``finished`` is only assigned when the request stops, yet the async-chunk
@@ -296,14 +302,16 @@ def test_running_decode_step_without_inter_stage_payload_does_not_raise() -> Non
"""
session = _make_request()
session.status = RequestStatus.RUNNING
session.additional_information = {"omni_final_stage_id": 0} if omit_chunk_transfer else None
sched = MagicMock()
sched = mocker.MagicMock()
sched.requests = {session.request_id: session}
sched.perf_metrics = None
sched.structured_output_manager.accept_tokens.return_value = True
sched._update_request_with_output.return_value = ([42], False)
sched._update_request_with_output.return_value = (new_token_ids, False)
sched._process_kv_transfer_trigger.return_value = False
sched.chunk_transfer_adapter = MagicMock()
sched.chunk_transfer_adapter = mocker.MagicMock()
sched.chunk_transfer_adapter.custom_process_next_stage_input_func = processor
sched.running = [session]
sched.waiting_for_transfer_free = set()
sched.transfer_triggered_requests = set()
@@ -316,13 +324,13 @@ def test_running_decode_step_without_inter_stage_payload_does_not_raise() -> Non
sched.make_stats.return_value = None
bind_omits_transfer_helpers(sched)
scheduler_output = MagicMock(spec=SchedulerOutput)
scheduler_output = mocker.MagicMock(spec=SchedulerOutput)
scheduler_output.num_scheduled_tokens = {session.request_id: 1}
scheduler_output.scheduled_spec_decode_tokens = {}
scheduler_output.num_invalid_spec_tokens = 0
model_runner_output = MagicMock(spec=ModelRunnerOutput)
model_runner_output.sampled_token_ids = [[42]]
model_runner_output = mocker.MagicMock(spec=ModelRunnerOutput)
model_runner_output.sampled_token_ids = [new_token_ids]
model_runner_output.logprobs = None
model_runner_output.prompt_logprobs_dict = {}
model_runner_output.pooler_output = None
@@ -335,8 +343,16 @@ def test_running_decode_step_without_inter_stage_payload_does_not_raise() -> Non
OmniARScheduler.update_from_output(sched, scheduler_output, model_runner_output)
# Nothing to hand downstream: no payload, no segment boundary, not finished.
sched.chunk_transfer_adapter.save_async.assert_not_called()
if omit_chunk_transfer or processor is None or not new_token_ids:
sched.chunk_transfer_adapter.save_async.assert_not_called()
else:
sched.chunk_transfer_adapter.save_async.assert_called_once_with(
None,
session,
False,
new_token_ids=[42],
confirmed_num_computed_tokens=None,
)
def test_queued_streaming_update_on_async_stop_fences_in_flight_once() -> None:
@@ -3,32 +3,32 @@
from __future__ import annotations
import functools
from collections import OrderedDict
from threading import Lock
from types import SimpleNamespace
from typing import TYPE_CHECKING
import pytest
import torch
import torch.nn as nn
from vllm.config import VllmConfig
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.outputs import SamplerOutput
from vllm.v1.sample.logits_processor.state import LogitsProcessors
from vllm.v1.sample.metadata import SamplingMetadata
from vllm.v1.worker.gpu_input_batch import InputBatch
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
from vllm_omni.config.model import OmniModelConfig
from vllm_omni.data_entry_keys import to_struct
from vllm_omni.model_executor.models.cosyvoice3 import cosyvoice3, cosyvoice3_talker
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import CosyVoice3Model, _normalize_request_conditioning
from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config
from vllm_omni.worker import gpu_ar_model_runner
from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
if TYPE_CHECKING:
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import CosyVoice3Model
@functools.lru_cache(maxsize=1)
def _cosyvoice3_model_and_runner():
"""Defer heavy Omni/vLLM imports until a test runs (avoids duplicate CustomOp init)."""
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import CosyVoice3Model
from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner
return CosyVoice3Model, GPUARModelRunner
class _DummyCode2Wav:
def __init__(
@@ -89,7 +89,6 @@ def _make_code2wav_model(
num_samples: int = 32,
outputs: list[tuple[torch.Tensor, dict[str, object] | None]] | None = None,
) -> CosyVoice3Model:
CosyVoice3Model, _ = _cosyvoice3_model_and_runner()
model = object.__new__(CosyVoice3Model)
nn.Module.__init__(model)
model.model_stage = "cosyvoice3_code2wav"
@@ -115,7 +114,6 @@ def _make_code2wav_model(
def _make_talker_model() -> CosyVoice3Model:
CosyVoice3Model, _ = _cosyvoice3_model_and_runner()
model = object.__new__(CosyVoice3Model)
nn.Module.__init__(model)
model.model_stage = "cosyvoice3_talker"
@@ -160,6 +158,162 @@ def _make_sampling_metadata(
)
@pytest.fixture
def output_payload_vllm_config(tmp_path, request):
hf_config = CosyVoice3Config()
hf_config.llm.update(llm_input_size=16, llm_output_size=16, speech_token_size=32)
# Exercise the real config fields without model downloads or engine setup.
model_config = object.__new__(OmniModelConfig)
model_config.hf_config = hf_config
model_config.model_stage = "cosyvoice3_talker"
model_config.model = str(tmp_path)
model_config.async_chunk = getattr(request, "param", True)
model_config.enable_return_routed_experts = False
model_config.engine_output_type = "latent"
model_config.stage_connector_config = {"name": "SharedMemoryConnector", "extra": {"role": "sender"}}
vllm_config = object.__new__(VllmConfig)
vllm_config.model_config = model_config
return vllm_config
@pytest.fixture
def output_payload_talker(monkeypatch, output_payload_vllm_config):
class DummyEncoder(nn.Module):
def __init__(self, **kwargs):
super().__init__()
def forward(self, inputs_embeds, positions):
return inputs_embeds
class DummyTalker(nn.Module):
def __init__(self, *, llm: nn.Module, **kwargs):
super().__init__()
self.llm = llm
monkeypatch.setattr(cosyvoice3_talker, "VLLMQwen2Encoder", DummyEncoder)
monkeypatch.setattr(cosyvoice3_talker, "CosyVoice3LM", DummyTalker)
monkeypatch.setattr(CosyVoice3Model, "_create_llm_vllm_config", lambda self, config: config)
return CosyVoice3Model(vllm_config=output_payload_vllm_config)
@pytest.mark.parametrize(
"output_payload_vllm_config", [True, False], indirect=True, ids=["async_chunks", "sync_chunks"]
)
@pytest.mark.parametrize("async_scheduling", [True, False], ids=["async_scheduling", "sync_scheduling"])
def test_talker_skips_unused_hidden_payload_without_async_materialization(
output_payload_talker, output_payload_vllm_config, async_scheduling
):
runner = object.__new__(GPUARModelRunner)
runner.use_async_scheduling = async_scheduling
runner.omni_prefix_cache = None
runner.speculative_config = None
runner.model_config = output_payload_vllm_config.model_config
runner.model = output_payload_talker
# load_model caches this policy before the output path runs.
runner._pooler_payload_include_hidden_flag = bool(getattr(runner.model, "omni_pooler_payload_include_hidden", True))
assert runner._model_omni_pooler_payload_include_hidden() is (not runner.model_config.async_chunk)
assert runner._should_use_async_omni_output() is False
@pytest.mark.parametrize("is_prefill", [True, False], ids=["prefill", "decode"])
@pytest.mark.parametrize(
"output_payload_vllm_config", [True, False], indirect=True, ids=["async_chunks", "sync_chunks"]
)
def test_talker_inline_output_preserves_conditioning_and_tokens(
output_payload_talker, output_payload_vllm_config, monkeypatch, mocker, is_prefill
):
"""Token-only output retains prefill conditioning and sampled codec IDs."""
runner = object.__new__(GPUARModelRunner)
runner.model = output_payload_talker
runner._pooler_payload_include_hidden_flag = bool(getattr(runner.model, "omni_pooler_payload_include_hidden", True))
runner.vllm_config = output_payload_vllm_config
runner.model_config = output_payload_vllm_config.model_config
runner._async_chunk = runner.model_config.async_chunk
runner.omni_prefix_cache = None
runner.supports_mm_inputs = False
runner.routed_experts_initialized = False
runner.model_intermediate_buffer = {}
runner.input_batch = object.__new__(InputBatch)
runner.input_batch._req_ids = ["r1", "r2"]
runner.input_batch.req_id_to_index = {"r1": 0, "r2": 1}
monkeypatch.setattr(GPUARModelRunner, "_resolve_pooler_payload_req_ids", lambda self, req_ids: ("latent", req_ids))
monkeypatch.setattr(GPUARModelRunner, "get_omni_connector_output", lambda self: None)
conditioning = {
"speech_token": torch.tensor([[11, 12], [21, 0]], dtype=torch.long),
"speech_token_len": torch.tensor([2, 1], dtype=torch.long),
"speech_feat": torch.arange(16, dtype=torch.float32).reshape(2, 4, 2),
"embedding": torch.tensor([[0.1, 0.2], [0.3, 0.4]]),
}
expected = {key: value.clone() for key, value in conditioning.items()}
hidden_states = output_payload_talker.forward(
input_ids=torch.tensor([1, 2, 3]),
positions=torch.arange(3),
inputs_embeds=torch.ones(3, 16),
**(conditioning if is_prefill else {}),
)
model_output = output_payload_talker.make_omni_output(hidden_states, **(conditioning if is_prefill else {}))
cpu_materialization = mocker.spy(gpu_ar_model_runner, "_to_cpu_contiguous")
scheduler_output = object.__new__(SchedulerOutput)
scheduler_output.total_num_scheduled_tokens = 3
scheduler_output.num_scheduled_tokens = {"r1": 2, "r2": 1}
output = runner._build_omni_model_runner_output_from_snapshot(
scheduler_output=scheduler_output,
hidden_states=model_output.text_hidden_states,
staged_hidden_states_cpu=None,
multimodal_outputs=model_output.multimodal_outputs,
req_ids_output_copy=["r1", "r2"],
req_id_to_index_output_copy={"r1": 0, "r2": 1},
valid_sampled_token_ids=[[101], [102]],
logprobs_lists=None,
prompt_logprobs_dict={},
num_nans_in_logits=None,
kv_connector_output=None,
ec_connector_output=None,
cudagraph_stats=None,
kv_extracted_req_ids=None,
num_scheduled_tokens_np=torch.tensor([2, 1], dtype=torch.int32).numpy(),
query_start_loc_cpu=torch.tensor([0, 2], dtype=torch.long),
)
assert output.req_ids == ["r1", "r2"]
assert output.sampled_token_ids == [[101], [102]]
hidden_storage = model_output.text_hidden_states.untyped_storage().data_ptr()
hidden_copied = any(
call.args[0].untyped_storage().data_ptr() == hidden_storage for call in cpu_materialization.call_args_list
)
assert hidden_copied is (not runner._async_chunk)
if runner._async_chunk:
assert output.multimodal_outputs is None
if not is_prefill and runner._async_chunk:
assert not output.inter_stage_outputs or all(not item for item in output.inter_stage_outputs)
return
assert len(output.inter_stage_outputs) == 2
for idx, prompt_len in enumerate([2, 1]):
item = output.inter_stage_outputs[idx]
assert ("hidden" not in item) is runner._async_chunk
if not is_prefill:
continue
# The downstream processor removes padding using speech_token_len.
torch.testing.assert_close(item["embed.speech_token"], expected["speech_token"][idx : idx + 1])
torch.testing.assert_close(item["embed.speech_feat"], expected["speech_feat"][idx : idx + 1])
assert item["embed.speech_token_len"].item() == prompt_len
torch.testing.assert_close(item["embed.embedding"], expected["embedding"][idx : idx + 1])
def test_talker_preserves_packed_payload_policy(output_payload_talker, output_payload_vllm_config, monkeypatch):
output_payload_vllm_config.model_config.async_chunk = False
monkeypatch.setattr(cosyvoice3, "cosyvoice3_packed_inference_enabled", lambda: True)
model = CosyVoice3Model(vllm_config=output_payload_vllm_config)
assert model.omni_pooler_payload_include_hidden is False
assert not getattr(model, "use_async_omni_output", False)
def test_forward_prefers_token_offset_when_present():
model = _make_code2wav_model()
@@ -561,7 +715,6 @@ def test_gpu_ar_model_runner_prefers_model_sampler_when_opted_in():
# runs for all sampling paths
self.updated = True
_, GPUARModelRunner = _cosyvoice3_model_and_runner()
runner = object.__new__(GPUARModelRunner)
runner.input_batch = _DummyInputBatch()
@@ -602,7 +755,6 @@ def test_gpu_ar_model_runner_supplies_req_output_history_to_model_sampler():
# runs for all sampling paths
self.update_async_called = True
_, GPUARModelRunner = _cosyvoice3_model_and_runner()
runner = object.__new__(GPUARModelRunner)
runner.input_batch = _DummyInputBatch()
@@ -649,7 +801,6 @@ def test_gpu_ar_model_runner_repairs_async_placeholders_for_model_sampler():
# runs for all sampling paths (model sampler + fallback sampler)
self.update_async_called = True
_, GPUARModelRunner = _cosyvoice3_model_and_runner()
runner = object.__new__(GPUARModelRunner)
runner.input_batch = _DummyInputBatch()
@@ -722,8 +873,6 @@ def test_embed_input_ids_preserves_interleaved_request_boundaries(order):
def test_mrv2_conditioning_follows_encoded_prompts_to_batch_rows():
from collections import OrderedDict
model = _make_talker_model()
speech = nn.Embedding.from_pretrained(torch.arange(100 * 4, dtype=torch.float32).reshape(100, 4))
text = nn.Embedding.from_pretrained(torch.arange(100 * 4, dtype=torch.float32).reshape(100, 4) + 1000)
@@ -791,10 +940,8 @@ def test_embed_input_ids_rejects_cross_request_placeholder_block():
def test_full_response_singleton_uses_the_optimized_batch_path(monkeypatch):
import vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 as module
monkeypatch.setenv("COSYVOICE3_BATCH_FLOW", "1")
monkeypatch.setattr(module, "cosyvoice3_packed_inference_enabled", lambda: True)
monkeypatch.setattr(cosyvoice3, "cosyvoice3_packed_inference_enabled", lambda: True)
model = _make_code2wav_model()
model.forward(
input_ids=torch.tensor([0, 1, 2]),
@@ -816,10 +963,6 @@ def test_full_response_singleton_uses_the_optimized_batch_path(monkeypatch):
def test_full_response_adapter_uses_live_prefill_then_empty_decode(monkeypatch):
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
model = _make_talker_model()
hidden = torch.ones(2, 4)
runner = object.__new__(OmniGPUModelRunner)
@@ -840,13 +983,11 @@ def test_full_response_adapter_uses_live_prefill_then_empty_decode(monkeypatch):
def test_packed_flow_preserves_torch_estimator_when_speaker_trt_is_enabled(monkeypatch):
import vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 as module
model = _make_code2wav_model()
model._code2wav_trt_done = False
monkeypatch.setattr(module.torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(module, "cosyvoice3_packed_inference_enabled", lambda: True)
monkeypatch.setattr(module, "_cosyvoice3_trt_enabled", lambda: True)
monkeypatch.setattr(cosyvoice3.torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(cosyvoice3, "cosyvoice3_packed_inference_enabled", lambda: True)
monkeypatch.setattr(cosyvoice3, "_cosyvoice3_trt_enabled", lambda: True)
def unexpected_estimator_resolution():
raise AssertionError("Packed Flow must not resolve or replace its torch estimator")
@@ -904,21 +1045,18 @@ def test_sampling_mode_preserves_or_merges_control_logits(mode):
def test_cancelled_stream_releases_only_its_vocoder_state():
model_cls, _ = _cosyvoice3_model_and_runner()
model = SimpleNamespace(
_stream_audio_cache_lock=Lock(),
_stream_vocoder_cache_by_req={"cancelled": {"mel": torch.ones(2)}, "live": {"mel": torch.zeros(2)}},
)
model_cls.on_requests_finished(model, {"cancelled", "unknown"})
model_cls.on_requests_finished(model, {"cancelled"})
CosyVoice3Model.on_requests_finished(model, {"cancelled", "unknown"})
CosyVoice3Model.on_requests_finished(model, {"cancelled"})
assert set(model._stream_vocoder_cache_by_req) == {"live"}
@pytest.mark.parametrize("mrv2,packed", [(False, False), (False, True), (True, False)])
def test_talker_output_contract(monkeypatch, mrv2, packed):
import vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 as mod
monkeypatch.setattr(mod, "cosyvoice3_packed_inference_enabled", lambda: packed)
monkeypatch.setattr(cosyvoice3, "cosyvoice3_packed_inference_enabled", lambda: packed)
model = _make_talker_model()
hidden = torch.zeros(2, 4)
model.model = SimpleNamespace(llm=lambda embeddings, positions: embeddings)
@@ -932,9 +1070,6 @@ def test_talker_output_contract(monkeypatch, mrv2, packed):
def test_request_conditioning_normalizes_singletons_without_changing_schema():
from vllm_omni.data_entry_keys import to_struct
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import _normalize_request_conditioning
token = torch.tensor([[7, 8]], dtype=torch.int32)
feat = torch.randn(1, 4, 80)
speaker = torch.randn(1, 192)
@@ -949,8 +1084,6 @@ def test_request_conditioning_normalizes_singletons_without_changing_schema():
def test_request_conditioning_rejects_unsplit_voices():
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3 import _normalize_request_conditioning
with pytest.raises(ValueError, match="unsplit batch"):
_normalize_request_conditioning({"embed": {"embedding": [torch.ones(1, 192), torch.zeros(1, 192)]}})
@@ -6,6 +6,7 @@ from types import SimpleNamespace
import pytest
import torch
from vllm.sampling_params import SamplingParams
from vllm_omni.data_entry_keys import serialize_payload
from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import (
@@ -14,6 +15,7 @@ from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import (
text2flow_token_only,
)
from vllm_omni.outputs.mm_outputs import MultimodalPayload
from vllm_omni.request import OmniRequest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -376,6 +378,61 @@ def test_talker2code2wav_async_chunk_emits_terminal_eof_without_duplicate_audio(
assert payload_final.codes.audio.tolist() == []
def test_interleaved_token_only_requests_keep_conditioning_and_eof_separate():
transfer_manager = _transfer_manager()
requests = [
OmniRequest(
request_id=f"token-only-{index}",
external_req_id=f"token-only-{index}",
prompt_token_ids=[1],
sampling_params=SamplingParams(max_tokens=16),
pooling_params=None,
arrival_time=0.0,
block_hasher=None,
)
for index in range(2)
]
conditioning = [
{
"embed": {
"speech_token": torch.tensor([[101 + index, 201 + index]]),
"speech_feat": torch.arange(8, dtype=torch.float32).reshape(1, 4, 2) + index,
"embedding": torch.tensor([[0.1 + index, 0.2 + index]]),
}
}
for index in range(2)
]
requests[0].append_output_token_ids([8, 9])
requests[1].append_output_token_ids([18, 19])
first_chunks = [
talker2code2wav_async_chunk(transfer_manager, payload, request)
for request, payload in zip(requests, conditioning)
]
for index, chunk in enumerate(first_chunks):
assert chunk is not None
assert chunk.codes.audio.tolist() == ([8, 9] if index == 0 else [18, 19])
torch.testing.assert_close(chunk.embed.embedding, conditioning[index]["embed"]["embedding"])
requests[0].append_output_token_ids([10, 11])
final_a = talker2code2wav_async_chunk(transfer_manager, None, requests[0], is_finished=True)
requests[1].append_output_token_ids([20])
pending_b = talker2code2wav_async_chunk(transfer_manager, None, requests[1])
final_b = talker2code2wav_async_chunk(transfer_manager, None, requests[1], is_finished=True)
assert final_a.codes.audio.tolist() == [8, 9, 10, 11]
assert final_a.meta.left_context_size == 2
assert final_a.meta.finished.item() is True
assert final_a.embed is None
assert pending_b is None
assert final_b.codes.audio.tolist() == [18, 19, 20]
assert final_b.meta.left_context_size == 2
assert final_b.meta.finished.item() is True
assert final_b.embed is None
for request in requests:
assert talker2code2wav_async_chunk(transfer_manager, None, request, is_finished=True) is None
def test_full_payload_retains_true_length_for_padded_conditioning():
from vllm_omni.data_entry_keys import to_struct
+184 -2
View File
@@ -15,6 +15,8 @@ import numpy as np
import pytest
import torch
from vllm.sampling_params import SamplingParams
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.outputs import SamplerOutput
from vllm.v1.sample.metadata import SamplingMetadata
from vllm.v1.utils import CpuGpuBuffer
from vllm.v1.worker import gpu_input_batch
@@ -25,7 +27,7 @@ from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechReques
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.tts_adapters.base import PreparedRequest
from vllm_omni.outputs import OmniModelRunnerOutput
from vllm_omni.worker import sparse_audio
from vllm_omni.worker import gpu_ar_model_runner, sparse_audio
from vllm_omni.worker.gpu_ar_model_runner import (
ExecuteModelState,
GPUARModelRunner,
@@ -614,8 +616,10 @@ def test_build_omni_output_copies_hidden_for_partial_downstream_batch(monkeypatc
assert output.multimodal_outputs is None
def test_process_additional_information_uses_snapshot_request_order(monkeypatch):
@pytest.mark.parametrize("include_hidden", [True, False])
def test_process_additional_information_uses_snapshot_request_order(monkeypatch, include_hidden):
runner = _make_async_output_runner()
runner._pooler_payload_include_hidden_flag = include_hidden
seen = []
class PostprocessModel:
@@ -958,6 +962,11 @@ def test_sample_tokens_tail_only_prefix_cache_uses_staged_cpu_hidden_states(monk
monkeypatch.setattr(GPUARModelRunner, "_should_accumulate_full_payload_output", lambda self: False)
monkeypatch.setattr(GPUARModelRunner, "get_omni_connector_output", lambda self: None)
monkeypatch.setattr(
GPUARModelRunner,
"_snapshot_scheduler_output_for_async_omni_output",
lambda *args: pytest.fail("inline output must not snapshot scheduler metadata"),
)
output = GPUARModelRunner.sample_tokens(runner, grammar_output=None)
# Non-async-chunk now ships the full payload to the next stage, so
@@ -1946,3 +1955,176 @@ def test_prepare_hook_keeps_legacy_signature_and_orders_opt_in_metadata(accepts_
assert seen["discard"] == [True, False]
else:
assert set(seen) == {"req_ids"}
@pytest.fixture
def token_only_builder(monkeypatch):
runner = _make_async_output_runner()
runner._pooler_payload_include_hidden_flag = False
runner.supports_mm_inputs = True
monkeypatch.setattr(GPUARModelRunner, "_resolve_pooler_payload_req_ids", lambda self, req_ids: ("latent", req_ids))
scheduler = SchedulerOutput.make_empty()
scheduler.total_num_scheduled_tokens = 2
scheduler.num_scheduled_tokens = {"r1": 1, "r2": 1}
kwargs = dict(
scheduler_output=scheduler,
hidden_states=torch.tensor([[1.0], [2.0]]),
staged_hidden_states_cpu=None,
multimodal_outputs=None,
req_ids_output_copy=["r1", "r2"],
req_id_to_index_output_copy={"r1": 0, "r2": 1},
valid_sampled_token_ids=[[11], [22]],
logprobs_lists=object(),
prompt_logprobs_dict={"r1": object()},
num_nans_in_logits={"r1": 0},
kv_connector_output=object(),
ec_connector_output=object(),
cudagraph_stats=object(),
kv_extracted_req_ids=["r2"],
num_scheduled_tokens_np=np.array([1, 1], dtype=np.int32),
query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.int32),
)
return runner, kwargs
@pytest.mark.parametrize("empty_mm", [None, {}])
def test_token_only_output_preserves_sampling_and_connector_metadata(token_only_builder, mocker, empty_mm):
runner, kwargs = token_only_builder
kwargs["multimodal_outputs"] = empty_mm
payload_builder = mocker.spy(runner, "_build_omni_pooler_payload")
output = runner._build_omni_model_runner_output_from_snapshot(**kwargs)
assert output.req_ids == ["r1", "r2"]
assert output.req_id_to_index == {"r1": 0, "r2": 1}
assert output.sampled_token_ids == [[11], [22]]
assert output.inter_stage_outputs is None
assert output.multimodal_outputs is None
assert output.pooler_output is None
assert output.logprobs is kwargs["logprobs_lists"]
for name in (
"prompt_logprobs_dict",
"num_nans_in_logits",
"kv_connector_output",
"ec_connector_output",
"cudagraph_stats",
"kv_extracted_req_ids",
):
assert getattr(output, name) is kwargs[name]
payload_builder.assert_not_called()
def test_token_only_output_keeps_prefix_cache_merge(token_only_builder, mocker):
runner, kwargs = token_only_builder
runner.omni_prefix_cache = object()
cached = {"codes.audio": {"r1": torch.tensor([31]), "r2": torch.tensor([32])}}
merge = mocker.patch.object(
runner, "_prepare_prefix_cache_pooler_payload_sources", return_value=(None, None, cached)
)
output = runner._build_omni_model_runner_output_from_snapshot(**kwargs)
merge.assert_called_once()
assert output.inter_stage_outputs[0]["codes.audio"].tolist() == [31]
assert output.inter_stage_outputs[1]["codes.audio"].tolist() == [32]
def test_token_only_output_keeps_routed_experts(token_only_builder, mocker):
runner, kwargs = token_only_builder
runner.model_config.enable_return_routed_experts = True
runner.routed_experts_initialized = True
routes = np.array([[[1, 2]], [[3, 4]]], dtype=np.int32)
extract = mocker.patch.object(runner, "_omni_extract_routed_experts", return_value=routes)
output = runner._build_omni_model_runner_output_from_snapshot(**kwargs)
extract.assert_called_once_with(kwargs["scheduler_output"])
assert output.routed_experts is routes
@pytest.mark.parametrize("snapshot", [True, False], ids=["background", "inline"])
def test_query_metadata_lifetime_matches_materialization_mode(snapshot):
runner = object.__new__(GPUARModelRunner)
runner.query_start_loc = object.__new__(CpuGpuBuffer)
runner.query_start_loc.cpu = torch.tensor([0, 2, 3], dtype=torch.int32)
offsets = runner._get_query_start_loc_cpu(snapshot=snapshot)
runner.query_start_loc.cpu[1] = 9
if snapshot:
assert offsets.tolist() == [0, 2, 3]
else:
assert offsets is runner.query_start_loc.cpu
assert offsets.tolist() == [0, 9, 3]
def test_background_output_survives_step_metadata_reuse(monkeypatch, mocker):
"""Delay the real builder until offsets, batch IDs and counts are reused."""
runner = _make_async_output_runner()
runner.use_async_scheduling = True
runner.speculative_config = None
runner.model.use_async_omni_output = True
runner.broadcast_pp_output = False
runner.kv_connector_output = None
runner.device = torch.device("cpu")
runner.async_output_copy_stream = None
runner.input_batch = mocker.Mock(
spec=InputBatch, req_ids=["r1", "r2"], req_id_to_index={"r1": 0, "r2": 1}, vocab_size=10
)
runner.query_start_loc = object.__new__(CpuGpuBuffer)
runner.query_start_loc.cpu = torch.tensor([0, 1, 3], dtype=torch.int32)
runner._omni_num_scheduled_tokens_np = np.array([1, 2], dtype=np.int32)
scheduler = SchedulerOutput.make_empty()
scheduler.num_scheduled_tokens = {"r1": 1, "r2": 2}
scheduler.total_num_scheduled_tokens = 3
hidden = torch.tensor([[1.0], [2.0], [3.0]])
runner.execute_model_state = ExecuteModelState(
scheduler, None, None, None, hidden, None, None, None, None, None, {}, None
)
sampler = SamplerOutput(sampled_token_ids=torch.tensor([[7], [8]]), logprobs_tensors=None)
mocker.patch.object(runner, "_sample", return_value=sampler)
mocker.patch.object(runner, "_update_states_after_model_execute")
# Match upstream bookkeeping's ownership contract: these are already copies.
mocker.patch.object(
runner,
"_bookkeeping_sync",
return_value=(
{},
None,
None,
[],
{},
runner.input_batch.req_ids.copy(),
runner.input_batch.req_id_to_index.copy(),
[],
),
)
mocker.patch.object(runner, "_run_post_sample_talker_mtp", return_value={})
mocker.patch.object(runner, "eplb_step")
mocker.patch.object(runner, "get_omni_connector_output", return_value=None)
monkeypatch.setattr(gpu_ar_model_runner, "get_pp_group", lambda: mocker.Mock(world_size=1))
mocker.patch.object(runner, "_resolve_pooler_payload_req_ids", side_effect=lambda ids: ("audio", ids))
# Device copying is covered separately; this test isolates metadata lifetime.
mocker.patch.object(
runner,
"_snapshot_omni_output_tensors_for_async_output",
return_value=gpu_ar_model_runner._OmniOutputTensorSnapshot(
hidden_states=hidden, staged_hidden_states_cpu=None, multimodal_outputs={}
),
)
async_output = mocker.Mock(
spec=OmniAsyncGPUModelRunnerOutput,
sampled_token_ids_cpu=sampler.sampled_token_ids,
async_copy_ready_event=object(),
)
constructor = mocker.patch.object(gpu_ar_model_runner, "OmniAsyncGPUModelRunnerOutput", return_value=async_output)
assert runner.sample_tokens(None) is async_output
runner.query_start_loc.cpu.fill_(99)
runner._omni_num_scheduled_tokens_np.fill(99)
scheduler.num_scheduled_tokens.clear()
runner.input_batch.req_ids[:] = ["next-step"]
runner.input_batch.req_id_to_index.clear()
output = constructor.call_args.kwargs["model_runner_output_builder"]()
assert output.req_ids == ["r1", "r2"]
assert output.req_id_to_index == {"r1": 0, "r2": 1}
torch.testing.assert_close(output.inter_stage_outputs[0]["hidden"], hidden[:1])
torch.testing.assert_close(output.inter_stage_outputs[1]["hidden"], hidden[1:])
+12 -2
View File
@@ -432,6 +432,9 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
pooler_outputs = model_runner_output.pooler_output
mm_outputs = getattr(model_runner_output, "multimodal_outputs", None)
inter_stage_outputs = getattr(model_runner_output, "inter_stage_outputs", None)
# Token-only processors need sampled IDs even without a tensor payload.
processor = getattr(getattr(self, "chunk_transfer_adapter", None), "custom_process_next_stage_input_func", None)
requires_token_updates = getattr(processor, "requires_token_updates", False) is True
num_nans_in_logits = model_runner_output.num_nans_in_logits
kv_connector_output = model_runner_output.kv_connector_output
ec_connector_output = getattr(model_runner_output, "ec_connector_output", None)
@@ -689,7 +692,9 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
# Only when this step might save. Read additional_information
# before _free_request rewrites it.
omits_chunk_transfer = False
if self.chunk_transfer_adapter is not None and (inter_stage_output is not None or stopped):
if self.chunk_transfer_adapter is not None and (
inter_stage_output is not None or (new_token_ids and requires_token_updates) or stopped
):
omits_chunk_transfer = self._request_omits_chunk_transfer_to_next_stage(request)
# Capture before resumable stop handling can clear token history.
output_token_ids: Any = getattr(request, "output_token_ids", None)
@@ -818,7 +823,12 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
if (
self.chunk_transfer_adapter is not None
and not omits_chunk_transfer
and (inter_stage_output is not None or is_segment_finished or finished)
and (
inter_stage_output is not None
or (new_token_ids and requires_token_updates)
or is_segment_finished
or finished
)
):
save_kwargs = {
"new_token_ids": new_token_ids,
@@ -773,6 +773,10 @@ class CosyVoice3Model(
self.model_dir = model_dir
self.model = None
if self.model_stage == "cosyvoice3_talker":
# Code2Wav consumes sampled tokens and prompt conditioning, not
# hidden states. The processor opts into token-only chunk updates.
if getattr(vllm_config.model_config, "async_chunk", False):
self.omni_pooler_payload_include_hidden = False
# Initialize talker stage (text to speech tokens)
from vllm_omni.model_executor.models.cosyvoice3.cosyvoice3_talker import CosyVoice3LM, VLLMQwen2Encoder
@@ -297,6 +297,10 @@ _FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset(
)
# This processor consumes sampled codec IDs even when no tensor payload is emitted.
talker2code2wav_async_chunk.requires_token_updates = True # type: ignore[attr-defined]
def text2flow_token_only(
source_outputs: list,
prompt: OmniTokensPrompt | TextPrompt = None,
+39 -16
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import gc
import threading
from collections.abc import Callable, Sequence
from collections.abc import Callable, Mapping, Sequence
from contextlib import nullcontext
from copy import copy
from dataclasses import replace
@@ -1596,10 +1596,12 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
return None
return wire_payloads
def _snapshot_query_start_loc_cpu(self) -> Any:
def _get_query_start_loc_cpu(self, *, snapshot: bool) -> Any:
query_start_loc_cpu = self.query_start_loc.cpu
if callable(query_start_loc_cpu):
query_start_loc_cpu = query_start_loc_cpu()
if not snapshot:
return query_start_loc_cpu
if isinstance(query_start_loc_cpu, torch.Tensor):
return query_start_loc_cpu.detach().cpu().clone()
if isinstance(query_start_loc_cpu, np.ndarray):
@@ -1888,7 +1890,18 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
# OmniModelRunnerOutput.pooler_output (which is set to None below).
# The actual multimodal wire transport uses multimodal_outputs instead.
pooler_output: list[dict[str, object]] | None = None
if needs_pooler_payload:
# Token-only steps have no pooler payload to construct. Keep the full
# path for cached payloads and model postprocess, even without MM input.
has_mm_payload = multimodal_outputs is not None and (
not isinstance(multimodal_outputs, Mapping) or len(multimodal_outputs) > 0
)
needs_payload_work = (
include_hidden_payload
or has_mm_payload
or self.omni_prefix_cache is not None
or (not postprocess_already_applied and self._runner_model_omni_flag("has_postprocess"))
)
if needs_pooler_payload and needs_payload_work:
hidden_seq_len = int(hidden_states.shape[0])
scheduled_seq_len = int(scheduler_output.total_num_scheduled_tokens)
mm_cpu = None
@@ -2186,6 +2199,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
kv_connector_output = self.kv_connector_output
self.kv_connector_output = None
use_async_omni_output = self._should_use_async_omni_output()
num_scheduled_tokens_np = getattr(self, "_omni_num_scheduled_tokens_np", None)
if num_scheduled_tokens_np is None:
num_scheduled_tokens_np = np.array(
@@ -2193,20 +2207,29 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
dtype=np.int32,
)
else:
num_scheduled_tokens_np = np.asarray(num_scheduled_tokens_np, dtype=np.int32).copy()
num_scheduled_tokens_np = np.asarray(num_scheduled_tokens_np, dtype=np.int32)
if use_async_omni_output:
num_scheduled_tokens_np = num_scheduled_tokens_np.copy()
query_start_loc_cpu = self._snapshot_query_start_loc_cpu()
scheduler_output_snapshot = self._snapshot_scheduler_output_for_async_omni_output(scheduler_output)
req_ids_output_snapshot = list(req_ids_output_copy)
req_id_to_index_output_snapshot = dict(req_id_to_index_output_copy)
valid_sampled_token_ids_snapshot = [list(token_ids) for token_ids in valid_sampled_token_ids]
logprobs_lists_snapshot = copy(logprobs_lists) if logprobs_lists is not None else None
prompt_logprobs_dict_snapshot = dict(prompt_logprobs_dict) if prompt_logprobs_dict is not None else {}
num_nans_in_logits_snapshot = (
dict(num_nans_in_logits) if isinstance(num_nans_in_logits, dict) else num_nans_in_logits
)
use_async_omni_output = self._should_use_async_omni_output()
# Inline materialization finishes before the next execute_model can
# mutate step metadata. Only the background builder needs snapshots.
query_start_loc_cpu = self._get_query_start_loc_cpu(snapshot=use_async_omni_output)
scheduler_output_snapshot = scheduler_output
valid_sampled_token_ids_snapshot = valid_sampled_token_ids
logprobs_lists_snapshot = logprobs_lists
prompt_logprobs_dict_snapshot = prompt_logprobs_dict
num_nans_in_logits_snapshot = num_nans_in_logits
if use_async_omni_output:
scheduler_output_snapshot = self._snapshot_scheduler_output_for_async_omni_output(scheduler_output)
valid_sampled_token_ids_snapshot = [list(token_ids) for token_ids in valid_sampled_token_ids]
logprobs_lists_snapshot = copy(logprobs_lists) if logprobs_lists is not None else None
prompt_logprobs_dict_snapshot = dict(prompt_logprobs_dict) if prompt_logprobs_dict is not None else {}
num_nans_in_logits_snapshot = (
dict(num_nans_in_logits) if isinstance(num_nans_in_logits, dict) else num_nans_in_logits
)
# _bookkeeping_sync already detached these from the mutable input batch.
req_ids_output_snapshot = req_ids_output_copy
req_id_to_index_output_snapshot = req_id_to_index_output_copy
omni_postprocess_already_applied = False
if use_async_omni_output:
omni_postprocess_already_applied = self._maybe_run_eager_omni_postprocess_before_async_output(