[Core] Defer generation payload host waits to output consumption (#8540)

Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
amy-why-3459 authored and GitHub committed 2026-10-07 17:20:54 +08:00
1 parent d5a3380103
commit de253412b0
8 files changed
+350 -11

No files matched your search

+1 -1
View File
@@ -122,7 +122,7 @@ steps:
commands:
- |
timeout 15m bash -c "
pytest -sv tests/engine/ tests/model_executor/ tests/worker/test_batched_omni_output.py tests/worker_v2/ -m 'core_model and cuda' --run-level 'core_model'
pytest -sv tests/engine/ tests/model_executor/ tests/worker/test_batched_omni_output.py tests/worker/test_gpu_generation_async_output.py tests/worker_v2/ -m 'core_model and cuda' --run-level 'core_model'
"
mirror_hardwares: l4_1
@@ -1109,3 +1109,14 @@ def test_request_conditioning_reaches_code2wav_with_true_prompt_length():
assert call["prompt_token"].shape == (1, 2)
assert call["prompt_feat"].shape == (1, 4, 80)
assert output.multimodal_outputs
@pytest.mark.parametrize("stage", ["cosyvoice3_talker", "cosyvoice3_code2wav"])
@pytest.mark.parametrize("owned", [False, True])
def test_generation_output_ownership_is_exposed_only_for_owned_codec(stage, owned):
model = object.__new__(CosyVoice3Model)
nn.Module.__init__(model)
model.model_stage = stage
if stage == "cosyvoice3_code2wav":
model.code2wav = SimpleNamespace(owns_generation_output_storage=owned)
assert model.owns_generation_output_storage is (stage == "cosyvoice3_code2wav" and owned)
@@ -0,0 +1,214 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import torch
from tests.worker.test_gpu_generation_model_runner import _make_runner
from vllm_omni.worker.gpu_generation_model_runner import _AsyncGenerationOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cuda]
@pytest.mark.parametrize("kind", ["tensor", "list", "mapping"])
def test_async_generation_owns_output_and_request_mapping(kind):
if not torch.cuda.is_available():
pytest.skip("CUDA required")
source = torch.arange(64, device="cuda", dtype=torch.float32).reshape(2, 32)
expected = source.cpu()
raw = source if kind == "tensor" else list(source.unbind())
if kind == "mapping":
raw = {"audio": list(source.unbind())}
runner = _make_runner(raw, num_reqs=2)
runner.device = torch.device("cuda")
runner.use_async_scheduling = True
runner.async_output_copy_stream = torch.cuda.Stream()
# Publication must not wait for GPU work on the calling thread.
with (
patch.object(torch.cuda.Event, "synchronize", side_effect=AssertionError("host wait")),
patch.object(torch.cuda.Stream, "synchronize", side_effect=AssertionError("host wait")),
):
output = runner.sample_tokens()
assert isinstance(output, _AsyncGenerationOutput)
source.fill_(-1)
runner.input_batch.req_ids.clear()
runner.input_batch.req_id_to_index.clear()
resolved = output.get_output()
assert resolved.req_ids == ["req-1", "req-2"]
assert resolved.req_id_to_index == {"req-1": 0, "req-2": 1}
key = "audio" if kind == "mapping" else "model_outputs"
for i, payload in enumerate(resolved.multimodal_outputs):
torch.testing.assert_close(payload[key], expected[i])
assert output.get_output() is resolved
def test_async_generation_preserves_none_rows():
if not torch.cuda.is_available():
pytest.skip("CUDA required")
runner = _make_runner([None, torch.ones(8, device="cuda")], num_reqs=2)
runner.device = torch.device("cuda")
runner.use_async_scheduling = True
runner.async_output_copy_stream = torch.cuda.Stream()
output = runner.sample_tokens().get_output()
assert output.multimodal_outputs[0]["model_outputs"] is None
torch.testing.assert_close(output.multimodal_outputs[1]["model_outputs"], torch.ones(8))
@pytest.mark.parametrize("owns_storage", [False, True])
def test_full_payload_accumulation_keeps_synchronous_host_copy(owns_storage):
if not torch.cuda.is_available():
pytest.skip("CUDA required")
runner = _make_runner(torch.ones((1, 8), device="cuda"))
runner.device = torch.device("cuda")
runner.use_async_scheduling = True
runner.async_output_copy_stream = torch.cuda.Stream()
runner.model = SimpleNamespace(owns_generation_output_storage=owns_storage)
runner.requests = {"req-1": object()}
seen = []
runner._should_accumulate_full_payload_output = lambda: True
runner.accumulate_full_payload_output = lambda rid, payload, state: seen.append(payload["model_outputs"].clone())
with (
patch("vllm_omni.worker.gpu_generation_model_runner.AsyncGPUModelRunnerOutput") as wrapper,
patch(
"vllm_omni.worker.gpu_generation_model_runner._snapshot_tensor_payload_to_cpu_async",
side_effect=AssertionError("unexpected deferred copy"),
),
):
runner.sample_tokens()
wrapper.assert_called_once()
torch.testing.assert_close(seen[0], torch.ones(8))
@pytest.mark.parametrize("kind", ["tensor", "list", "mapping"])
def test_owned_generation_output_skips_device_snapshot_and_retains_sources(kind):
if not torch.cuda.is_available():
pytest.skip("CUDA required")
source = torch.empty((2, 32), device="cuda")
# Production must be ordered before copying, even if it is still pending.
torch.cuda._sleep(5_000_000)
source.fill_(7)
raw = source if kind == "tensor" else list(source.unbind())
if kind == "mapping":
raw = {"audio": list(source.unbind()), "sr": [torch.tensor(24000), torch.tensor(24000)]}
runner = _make_runner(raw, num_reqs=2)
runner.model = SimpleNamespace(owns_generation_output_storage=True)
runner.model_config = SimpleNamespace(enforce_eager=True)
runner.device = torch.device("cuda")
runner.use_async_scheduling = True
runner.async_output_copy_stream = torch.cuda.Stream()
with (
patch(
"vllm_omni.worker.gpu_generation_model_runner._snapshot_tensor_payload_to_cpu_async",
side_effect=AssertionError("unexpected device snapshot"),
),
patch.object(torch.cuda.Event, "synchronize", side_effect=AssertionError("host wait")),
patch.object(torch.cuda.Stream, "synchronize", side_effect=AssertionError("host wait")),
):
output = runner.sample_tokens()
assert isinstance(output, _AsyncGenerationOutput)
expected_ptrs = [source.data_ptr()] if kind == "tensor" else [source[i].data_ptr() for i in range(2)]
assert [t.data_ptr() for t in output._snapshot._cuda_sources] == expected_ptrs
if kind == "mapping":
for rate in raw["sr"]:
rate.fill_(-1)
del raw, source, runner
resolved = output.get_output()
assert not output._snapshot._cuda_sources
key = "audio" if kind == "mapping" else "model_outputs"
for payload in resolved.multimodal_outputs:
torch.testing.assert_close(payload[key], torch.full((32,), 7.0))
if kind == "mapping":
assert payload["sr"].item() == 24000
assert resolved.req_ids == ["req-1", "req-2"]
assert output.get_output() is resolved
def test_owned_generation_output_preserves_none_rows():
if not torch.cuda.is_available():
pytest.skip("CUDA required")
runner = _make_runner([None, torch.ones(8, device="cuda")], num_reqs=2)
runner.model = SimpleNamespace(owns_generation_output_storage=True)
runner.model_config = SimpleNamespace(enforce_eager=True)
runner.device = torch.device("cuda")
runner.use_async_scheduling = True
runner.async_output_copy_stream = torch.cuda.Stream()
output = runner.sample_tokens().get_output()
assert output.multimodal_outputs[0]["model_outputs"] is None
torch.testing.assert_close(output.multimodal_outputs[1]["model_outputs"], torch.ones(8))
def test_outer_graph_execution_keeps_snapshot_even_for_owned_model():
if not torch.cuda.is_available():
pytest.skip("CUDA required")
source = torch.ones((1, 8), device="cuda")
runner = _make_runner(source)
runner.model = SimpleNamespace(owns_generation_output_storage=True)
runner.model_config = SimpleNamespace(enforce_eager=False)
runner.device = torch.device("cuda")
runner.use_async_scheduling = True
runner.async_output_copy_stream = torch.cuda.Stream()
with patch(
"vllm_omni.worker.gpu_generation_model_runner._copy_owned_generation_payload_to_cpu_async",
side_effect=AssertionError("unsafe borrowed graph output"),
):
output = runner.sample_tokens()
source.fill_(-1)
resolved = output.get_output()
torch.testing.assert_close(resolved.multimodal_outputs[0]["model_outputs"], torch.ones(8))
@pytest.mark.parametrize("owned", [False, True])
def test_discarded_output_records_copy_stream(owned):
if not torch.cuda.is_available():
pytest.skip("CUDA required")
source = torch.ones((2, 32), device="cuda")
runner = _make_runner(source, num_reqs=2)
runner.model = SimpleNamespace(owns_generation_output_storage=owned)
runner.model_config = SimpleNamespace(enforce_eager=True)
runner.device = source.device
runner.use_async_scheduling = True
runner.async_output_copy_stream = torch.cuda.Stream()
calls = []
original = torch.Tensor.record_stream
def record(tensor, stream):
calls.append((tensor.data_ptr(), stream))
return original(tensor, stream)
with patch.object(torch.Tensor, "record_stream", record):
output = runner.sample_tokens()
assert calls == [(output._snapshot._cuda_sources[0].data_ptr(), runner.async_output_copy_stream)]
# Drop the GPU snapshot while the copy can still be in flight. CPU storage
# remains readable after the copy stream finishes, without calling get_output.
host = output._output.multimodal_outputs
del output
runner.async_output_copy_stream.synchronize()
for row in host:
torch.testing.assert_close(row["model_outputs"], torch.ones(32))
@pytest.mark.parametrize("owned", [False, True])
@pytest.mark.parametrize("strided", [False, True])
def test_dense_waveforms_share_one_host_allocation(owned, strided):
if not torch.cuda.is_available():
pytest.skip("CUDA required")
source = torch.arange(128, device="cuda", dtype=torch.float32).reshape(4, 32)
if strided:
source = source[:, ::2]
expected = source.cpu()
runner = _make_runner(source, num_reqs=4)
runner.model = SimpleNamespace(owns_generation_output_storage=owned)
runner.model_config = SimpleNamespace(enforce_eager=True)
runner.device = source.device
runner.use_async_scheduling = True
runner.async_output_copy_stream = torch.cuda.Stream()
output = runner.sample_tokens().get_output()
rows = [payload["model_outputs"] for payload in output.multimodal_outputs]
expected_allocations = len(rows) if strided else 1
assert len({row.untyped_storage().data_ptr() for row in rows}) == expected_allocations
assert all(row.is_contiguous() for row in rows)
for i, row in enumerate(rows):
torch.testing.assert_close(row, expected[i])
@@ -744,6 +744,13 @@ class CosyVoice3Model(
prefer_model_sampler = True
_sampling_eps = 1e-5
@property
def owns_generation_output_storage(self) -> bool:
"""Expose the codec's ownership contract on the model seen by the runner."""
return self.model_stage == "cosyvoice3_code2wav" and (
getattr(self.code2wav, "owns_generation_output_storage", False) is True
)
@property
def logits_vocab_size(self) -> int | None:
"""Width of the logits ``compute_logits`` returns (the speech head).
@@ -92,6 +92,11 @@ class CosyVoice3Code2Wav(nn.Module):
- HiFiGAN vocoder for mel-to-waveform conversion
"""
# Returned CUDA payloads own their storage (including HiFT graph results).
# Subsequent forwards/state updates never mutate it; the runner can retain
# the tensors and copy to host without taking another device snapshot.
owns_generation_output_storage = True
def __init__(self, config: CosyVoice3Config):
super().__init__()
self.config = config
@@ -270,6 +270,11 @@ class MiniCPMO45Code2Wav(nn.Module):
has_preprocess = False
has_postprocess = False
# Returned CUDA payloads own their storage (including HiFT graph results).
# Subsequent forwards/state updates never mutate it; the runner can retain
# the tensors and copy to host without taking another device snapshot.
owns_generation_output_storage = True
def __init__(
self,
*,
+4
View File
@@ -182,6 +182,10 @@ def _snapshot_tensor_payload_to_cpu_async(
with torch.cuda.stream(copy_stream):
copy_stream.wait_stream(source_stream)
cpu_payload = _copy_tensor_payload_to_cpu(cloned, pin_memory)
# An executor may discard an unconsumed output (e.g. cancellation).
# Keep the allocator from reusing its source storage before D2H ends.
for source in cuda_sources:
source.record_stream(copy_stream)
ready_event.record(copy_stream)
return _AsyncCPUPayloadSnapshot(cpu_payload, ready_event, cuda_sources)
+103 -10
View File
@@ -13,7 +13,7 @@ import gc
import logging
from collections.abc import Mapping
from dataclasses import replace
from typing import cast
from typing import Any, cast
import numpy as np
import torch
@@ -42,7 +42,13 @@ from vllm.v1.worker.utils import sanity_check_mm_encoder_outputs
from vllm_omni.outputs import OmniModelRunnerOutput
from vllm_omni.utils.mm_outputs import partition_payload_list
from vllm_omni.worker.gpu_ar_model_runner import ExecuteModelState, _ensure_tensor_values
from vllm_omni.worker.gpu_ar_model_runner import (
ExecuteModelState,
_AsyncCPUPayloadSnapshot,
_copy_tensor_payload_to_cpu,
_ensure_tensor_values,
_snapshot_tensor_payload_to_cpu_async,
)
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
from vllm_omni.worker.mixins import maybe_unpad_input_ids
from vllm_omni.worker.omni_connector_model_runner_mixin import (
@@ -53,6 +59,57 @@ from vllm_omni.worker.omni_connector_model_runner_mixin import (
logger = logging.getLogger(__name__)
class _AsyncGenerationOutput(AsyncModelRunnerOutput):
"""Generation payloads become readable when their copy-stream event completes."""
def __init__(self, output: OmniModelRunnerOutput, snapshot: _AsyncCPUPayloadSnapshot) -> None:
self._output = output
self._snapshot = snapshot
def get_output(self) -> OmniModelRunnerOutput:
self._snapshot.wait()
return self._output
def _copy_owned_generation_payload_to_cpu_async(
payload: Any, *, copy_stream: torch.cuda.Stream, pin_memory: bool
) -> _AsyncCPUPayloadSnapshot:
"""Copy immutable model-owned outputs, retaining their storage until D2H finishes.
The model must guarantee no subsequent forward or state update mutates any
returned CUDA payload storage. CPU values still need a synchronous snapshot.
record_stream also protects allocations if an unconsumed output is dropped.
"""
sources: list[torch.Tensor] = []
def retain(value: Any) -> Any:
if isinstance(value, torch.Tensor):
if value.device.type == "cuda":
tensor = value.detach()
sources.append(tensor)
return tensor
return value.detach().clone()
if isinstance(value, dict):
return {key: retain(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
items = [retain(item) for item in value]
return tuple(items) if isinstance(value, tuple) else items
return value
retained = retain(payload)
if not sources:
return _AsyncCPUPayloadSnapshot(retained, None, sources)
producer = torch.cuda.current_stream()
ready = torch.cuda.Event()
with torch.cuda.stream(copy_stream):
copy_stream.wait_stream(producer)
host_payload = _copy_tensor_payload_to_cpu(retained, pin_memory)
for source in sources:
source.record_stream(copy_stream)
ready.record(copy_stream)
return _AsyncCPUPayloadSnapshot(host_payload, ready, sources)
class _HostCopyBatch:
"""Device-to-host copies of one step's outputs behind a single host sync.
@@ -442,8 +499,15 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
# Build per-request multimodal_outputs list (dedicated channel).
# pooler_output is no longer used for multimodal data.
per_req_payloads: list[dict[str, object]] = []
pin_memory = is_pin_memory_available()
accumulate_full_payload = self._should_accumulate_full_payload_output()
use_async_output = (
self.use_async_scheduling and self.device.type == "cuda" and pin_memory and not accumulate_full_payload
)
# One host sync for the whole step instead of one per request tensor.
to_host = _HostCopyBatch(is_pin_memory_available())
to_host = _HostCopyBatch(pin_memory)
# Validate and split the payload before launching any asynchronous copies.
copy_tensor = torch.Tensor.detach if use_async_output else to_host.copy
if isinstance(multimodal_outputs_raw, torch.Tensor):
# One row per request. The old asserts (`shape[0] == 1` AND
# `shape[0] == num_reqs`) jointly forced num_reqs == 1, silently
@@ -456,7 +520,7 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
"to return multiple tensors per request, use a dict)."
)
for i in range(num_reqs):
per_req_payloads.append({"model_outputs": to_host.copy(multimodal_outputs_raw[i])})
per_req_payloads.append({"model_outputs": copy_tensor(multimodal_outputs_raw[i])})
elif isinstance(multimodal_outputs_raw, list):
# One entry per request. The old `len == 1` assert did not check
# num_reqs, so a batched step built a length-1 payload list that
@@ -469,7 +533,7 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
"to return multiple lists per request, use a dict)."
)
for out in multimodal_outputs_raw:
per_req_payloads.append({"model_outputs": to_host.copy(out) if out is not None else None})
per_req_payloads.append({"model_outputs": copy_tensor(out) if out is not None else None})
elif isinstance(multimodal_outputs_raw, Mapping):
num_reqs = self.input_batch.num_reqs
for i in range(num_reqs):
@@ -481,16 +545,42 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
f"Multimodal output list for key '{key}' has length {len(out)} "
f"but expected {num_reqs} (one entry per request)."
)
mm_payload[key] = to_host.copy(out[i])
mm_payload[key] = copy_tensor(out[i])
elif isinstance(out, torch.Tensor):
mm_payload[key] = to_host.copy(out)
mm_payload[key] = copy_tensor(out)
else:
logger.warning(f"Unsupported multimodal output type for key '{key}': {type(out)}")
per_req_payloads.append(_ensure_tensor_values(mm_payload))
else:
raise RuntimeError("Unsupported diffusion output type")
# Sync mode hands these payloads out as finished host tensors.
to_host.wait()
snapshot = None
if use_async_output:
# Models may opt in only when all CUDA payload storage is immutable
# across subsequent forwards. Outer model graph capture can still reuse
# these allocations, so require eager outer execution (internal graphs
# that return owned copies are safe). Otherwise keep the snapshot.
copy_payload = (
_copy_owned_generation_payload_to_cpu_async
if (
getattr(getattr(self, "model", None), "owns_generation_output_storage", False) is True
and getattr(getattr(self, "model_config", None), "enforce_eager", False) is True
)
else _snapshot_tensor_payload_to_cpu_async
)
# Dense waveforms can use one host allocation and one D2H transfer.
# Split only after copying; the CPU rows retain the batch storage.
if isinstance(multimodal_outputs_raw, torch.Tensor) and multimodal_outputs_raw.is_contiguous():
snapshot = copy_payload(
multimodal_outputs_raw, copy_stream=self.async_output_copy_stream, pin_memory=pin_memory
)
per_req_payloads = [{"model_outputs": row} for row in snapshot.payload.unbind(0)]
else:
snapshot = copy_payload(
per_req_payloads, copy_stream=self.async_output_copy_stream, pin_memory=pin_memory
)
per_req_payloads = snapshot.payload
else:
to_host.wait()
inter_stage_outputs: list[dict[str, object] | None] | None
multimodal_outputs: list[dict[str, object] | None] | None
@@ -505,7 +595,7 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
# [Omni] Copy req_id mappings to avoid async scheduling mutation.
req_ids_output_copy = self.input_batch.req_ids.copy()
req_id_to_index_output_copy = self.input_batch.req_id_to_index.copy()
if inter_stage_outputs and self._should_accumulate_full_payload_output():
if inter_stage_outputs and accumulate_full_payload:
for i, rid in enumerate(req_ids_output_copy):
req_state = self.requests.get(rid)
if req_state is not None and inter_stage_outputs[i]:
@@ -528,6 +618,9 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
)
output.omni_connector_output = self.get_omni_connector_output()
if snapshot is not None:
return _AsyncGenerationOutput(output, snapshot)
if not self.use_async_scheduling:
return output