[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: commands:
- | - |
timeout 15m bash -c " 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 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_token"].shape == (1, 2)
assert call["prompt_feat"].shape == (1, 4, 80) assert call["prompt_feat"].shape == (1, 4, 80)
assert output.multimodal_outputs 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 prefer_model_sampler = True
_sampling_eps = 1e-5 _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 @property
def logits_vocab_size(self) -> int | None: def logits_vocab_size(self) -> int | None:
"""Width of the logits ``compute_logits`` returns (the speech head). """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 - 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): def __init__(self, config: CosyVoice3Config):
super().__init__() super().__init__()
self.config = config self.config = config
@@ -270,6 +270,11 @@ class MiniCPMO45Code2Wav(nn.Module):
has_preprocess = False has_preprocess = False
has_postprocess = 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__( def __init__(
self, self,
*, *,
+4
View File
@@ -182,6 +182,10 @@ def _snapshot_tensor_payload_to_cpu_async(
with torch.cuda.stream(copy_stream): with torch.cuda.stream(copy_stream):
copy_stream.wait_stream(source_stream) copy_stream.wait_stream(source_stream)
cpu_payload = _copy_tensor_payload_to_cpu(cloned, pin_memory) 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) ready_event.record(copy_stream)
return _AsyncCPUPayloadSnapshot(cpu_payload, ready_event, cuda_sources) return _AsyncCPUPayloadSnapshot(cpu_payload, ready_event, cuda_sources)
+103 -10
View File
@@ -13,7 +13,7 @@ import gc
import logging import logging
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import replace from dataclasses import replace
from typing import cast from typing import Any, cast
import numpy as np import numpy as np
import torch 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.outputs import OmniModelRunnerOutput
from vllm_omni.utils.mm_outputs import partition_payload_list 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.gpu_model_runner import OmniGPUModelRunner
from vllm_omni.worker.mixins import maybe_unpad_input_ids from vllm_omni.worker.mixins import maybe_unpad_input_ids
from vllm_omni.worker.omni_connector_model_runner_mixin import ( 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__) 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: class _HostCopyBatch:
"""Device-to-host copies of one step's outputs behind a single host sync. """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). # Build per-request multimodal_outputs list (dedicated channel).
# pooler_output is no longer used for multimodal data. # pooler_output is no longer used for multimodal data.
per_req_payloads: list[dict[str, object]] = [] 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. # 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): if isinstance(multimodal_outputs_raw, torch.Tensor):
# One row per request. The old asserts (`shape[0] == 1` AND # One row per request. The old asserts (`shape[0] == 1` AND
# `shape[0] == num_reqs`) jointly forced num_reqs == 1, silently # `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)." "to return multiple tensors per request, use a dict)."
) )
for i in range(num_reqs): 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): elif isinstance(multimodal_outputs_raw, list):
# One entry per request. The old `len == 1` assert did not check # 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 # 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)." "to return multiple lists per request, use a dict)."
) )
for out in multimodal_outputs_raw: 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): elif isinstance(multimodal_outputs_raw, Mapping):
num_reqs = self.input_batch.num_reqs num_reqs = self.input_batch.num_reqs
for i in range(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"Multimodal output list for key '{key}' has length {len(out)} "
f"but expected {num_reqs} (one entry per request)." 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): elif isinstance(out, torch.Tensor):
mm_payload[key] = to_host.copy(out) mm_payload[key] = copy_tensor(out)
else: else:
logger.warning(f"Unsupported multimodal output type for key '{key}': {type(out)}") logger.warning(f"Unsupported multimodal output type for key '{key}': {type(out)}")
per_req_payloads.append(_ensure_tensor_values(mm_payload)) per_req_payloads.append(_ensure_tensor_values(mm_payload))
else: else:
raise RuntimeError("Unsupported diffusion output type") raise RuntimeError("Unsupported diffusion output type")
# Sync mode hands these payloads out as finished host tensors. snapshot = None
to_host.wait() 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 inter_stage_outputs: list[dict[str, object] | None] | None
multimodal_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. # [Omni] Copy req_id mappings to avoid async scheduling mutation.
req_ids_output_copy = self.input_batch.req_ids.copy() req_ids_output_copy = self.input_batch.req_ids.copy()
req_id_to_index_output_copy = self.input_batch.req_id_to_index.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): for i, rid in enumerate(req_ids_output_copy):
req_state = self.requests.get(rid) req_state = self.requests.get(rid)
if req_state is not None and inter_stage_outputs[i]: 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() output.omni_connector_output = self.get_omni_connector_output()
if snapshot is not None:
return _AsyncGenerationOutput(output, snapshot)
if not self.use_async_scheduling: if not self.use_async_scheduling:
return output return output