[Core][Model] Enable Qwen3-TTS MRV2 and optimize the TTS pipeline (#7781)
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
103 files changed
+10232
-661
No files matched your search
@@ -23,6 +23,16 @@ x-qwen3-tts: &qwen3_tts
|
||||
- vllm_omni/entrypoints/openai/serving_speech.py
|
||||
- vllm_omni/entrypoints/openai/tts_adapters/
|
||||
- vllm_omni/engine/stage_pool.py
|
||||
- vllm_omni/worker_v2/
|
||||
- vllm_omni/core/sched/
|
||||
- vllm_omni/distributed/omni_connectors/
|
||||
- vllm_omni/config/stage_config.py
|
||||
- vllm_omni/config/omni_config.py
|
||||
- vllm_omni/engine/orchestrator.py
|
||||
- vllm_omni/engine/stage_engine_core_proc.py
|
||||
- vllm_omni/deploy/qwen3_tts_mrv2.yaml
|
||||
- vllm_omni/deploy/qwen3_tts_high_concurrency_mrv2.yaml
|
||||
- vllm_omni/deploy/qwen3_tts_high_concurrency_mrv2_b4.yaml
|
||||
|
||||
x-minicpmo-4-5: &minicpmo_4_5
|
||||
- vllm_omni/engine/async_omni_engine.py
|
||||
|
||||
@@ -102,7 +102,7 @@ steps:
|
||||
commands:
|
||||
- |
|
||||
timeout 15m bash -c "
|
||||
pytest -sv tests/engine/ tests/model_executor/ tests/worker/test_batched_omni_output.py -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_v2/ -m 'core_model and cuda' --run-level 'core_model'
|
||||
"
|
||||
mirror_hardwares: l4_1
|
||||
|
||||
|
||||
@@ -290,3 +290,90 @@ vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 --deploy-config /path/to/depl
|
||||
|
||||
!!! important
|
||||
We are actively iterating on the definition of deployment configurations, and we welcome feedback from users and developers.
|
||||
|
||||
## Qwen3-TTS with Model Runner V2
|
||||
|
||||
Qwen3-TTS can opt into the native CUDA Model Runner V2 pipeline on vLLM
|
||||
0.29.0. Select one of these deployment profiles:
|
||||
|
||||
| Profile | Runner | Code2Wav graph batches | Intended use |
|
||||
| --- | --- | --- | --- |
|
||||
| `qwen3_tts.yaml` | V1 | Existing defaults | Existing deployment / regression control |
|
||||
| `qwen3_tts_mrv2.yaml` | V2 | B1 | Native runner validation |
|
||||
| `qwen3_tts_high_concurrency_mrv2.yaml` | V2 | B1, B2 | Opt-in throughput tuning |
|
||||
| `qwen3_tts_high_concurrency_mrv2_b4.yaml` | V2 | B1, B2, B3, B4 | Experimental throughput / buffered playback |
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base --omni \
|
||||
--deploy-config vllm_omni/deploy/qwen3_tts_mrv2.yaml
|
||||
```
|
||||
|
||||
The V2 profiles bound Talker prefill to 512 tokens per step and select the
|
||||
Talker AR runner and the Code2Wav generation
|
||||
runner together. Native inter-stage delivery carries codec payloads directly;
|
||||
request-owned snapshots preserve buffers through asynchronous completion and
|
||||
CUDA graph reuse. Terminal completion waits for upstream stage metrics before
|
||||
releasing request state. Platform sections retain V1 on NPU, XPU, ROCm and MUSA;
|
||||
this change does not qualify MRV2 on those backends or enable other model families.
|
||||
Selecting V2 for a stage without `supports_native_mrv2_data_plane` emits a warning
|
||||
when pipeline and deployment settings are merged. That stage retains the legacy
|
||||
transport path; the warning does not establish support for that combination.
|
||||
|
||||
MRV2 model hooks use capability declarations rather than architecture or stage
|
||||
names. Omni lifecycle flags select the model state, and `_returns_tuple` declares
|
||||
the capture output contract. The optional batched predictor hook is `mtp`, with
|
||||
an explicit `mtp_output_key` (a string or two-part payload key), optional
|
||||
`mtp_validity_key`, and `mtp_graph_safe`/`mtp_disable_graph` capture controls.
|
||||
Its inputs are token IDs, embeddings, previous hidden states and per-row
|
||||
conditioning; it returns updated embeddings and prediction codes. Models may
|
||||
supply `mtp_sampling_params` and `get_mtp_seed(sampling_params)` for model-local explicit seeds, and declare
|
||||
`mtp_accepts_per_row_generators`, `mtp_accepts_req_infos`, or `mtp_sample_uniforms`
|
||||
(with `mtp_sample_steps` and `mtp_sample_vocab_size`) as needed. Qwen3-TTS retains
|
||||
its existing `talker_mtp` entry point for V1.
|
||||
|
||||
### Included performance work
|
||||
|
||||
- Request snapshots have a fast path for immutable scalar leaves, including
|
||||
waveform lists. Mutable containers, aliases and cycles keep deep-copy
|
||||
semantics. This is not the historical shallow `dict(prompt)` experiment.
|
||||
- The API reference cache holds owned float32 arrays with a default capacity
|
||||
of 1024 entries and a 512 MiB waveform-payload budget. Cache hits return
|
||||
independent lists. Qwen3-TTS model artifact caches also default to 1024
|
||||
entries. These caches have different owners and eviction policies; equal
|
||||
entry limits do not make them a single coherent cache.
|
||||
- Talker state stays on GPU where its lifecycle allows it. BOS/EOS projections
|
||||
are cached in their projection dtype and invalidated on weight loading.
|
||||
- Code2Wav packs CPU codec inputs before device transfer. Optional B2/B4 graph
|
||||
buckets batch compatible requests without sharing their per-request state.
|
||||
- Native output materialization runs in a bounded worker and drains before
|
||||
closing the data plane. The existing control-only shortcut avoids launching
|
||||
model work for a step that has only lifecycle events.
|
||||
|
||||
MTP prefix re-prefill remains disabled in the high-concurrency V2 profile.
|
||||
Adding more graph shapes increases compilation cost and has not established a
|
||||
stable end-to-end gain for this extracted version.
|
||||
|
||||
### Decoder batches and playback
|
||||
|
||||
Changing `decode_cudagraph_batch_sizes` selects the captured batch buckets.
|
||||
Keep `decode_batch_max_size` consistent with the intended maximum too; the
|
||||
stateful graph path currently groups according to the captured buckets, while
|
||||
the stateless path also uses the explicit maximum.
|
||||
|
||||
B4 remains experimental. First-packet latency alone does not establish
|
||||
uninterrupted playback. Validate inter-chunk arrival times, buffering, WER and
|
||||
speaker similarity before adopting either batching preset for a production
|
||||
workload. Floating-point decoder outputs can differ across batch sizes; this PR
|
||||
does not claim bitwise or quality equivalence.
|
||||
|
||||
### Optional MPS deployment
|
||||
|
||||
NVIDIA MPS is an optional operator setting for colocated CUDA processes, not a
|
||||
YAML option or a library default. This PR does not establish a throughput or
|
||||
first-packet latency benefit from MPS. Measure the exact deployment with and
|
||||
without MPS before enabling it.
|
||||
|
||||
Use only assigned GPUs and an independent MPS pipe directory. A private MPS
|
||||
server does not provide exclusive GPU ownership or MIG isolation. For a
|
||||
single-GPU deployment, explicitly place both stages on that GPU; the supplied
|
||||
high-concurrency profile places its two stages on different GPUs by default.
|
||||
@@ -1429,6 +1429,9 @@ stages:
|
||||
assert deploy.async_chunk is True
|
||||
assert deploy.connectors is not None
|
||||
assert deploy.platforms is not None
|
||||
connector = deploy.connectors["connector_of_shared_memory"]
|
||||
assert "async_chunk_batch_min_size" not in connector["extra"]
|
||||
assert "async_chunk_batch_max_wait_ms" not in connector["extra"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hf_config", "model"),
|
||||
@@ -2704,6 +2707,56 @@ class TestPlatformOverrides:
|
||||
|
||||
assert deploy.stages[0].engine_extras["block_size"] == 128
|
||||
|
||||
@pytest.mark.parametrize("platform", ["cuda", "npu"])
|
||||
def test_recommended_native_runner_platform_defaults(self, platform):
|
||||
filename, pipeline_key = "qwen3_tts_mrv2.yaml", "qwen3_tts"
|
||||
deploy = load_deploy_config(Path(get_deploy_config_path(filename)))
|
||||
deploy = _apply_platform_overrides(deploy, platform=platform)
|
||||
expect_v2 = platform == "cuda"
|
||||
assert deploy.model_runner == ("v2" if expect_v2 else "v1")
|
||||
|
||||
# The selection must reach the live worker-dispatch consumer, not just
|
||||
# the transport-level DeployConfig field.
|
||||
pipeline = resolve_pipeline_config(pipeline_key)
|
||||
stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
assert [stage.yaml_engine_args["use_v2_model_runner"] for stage in stages] == [expect_v2] * len(stages)
|
||||
|
||||
if expect_v2 and pipeline.stages and deploy.async_chunk:
|
||||
# v2 only engages the native plane on stages declaring support.
|
||||
assert all(ps.supports_native_mrv2_data_plane for ps in pipeline.stages)
|
||||
|
||||
@pytest.mark.parametrize("runner,native", [("v1", False), ("v2", False), ("v2", True)])
|
||||
def test_mrv2_undeclared_transport_warns(self, monkeypatch, runner, native):
|
||||
from unittest.mock import Mock
|
||||
|
||||
warning = Mock()
|
||||
monkeypatch.setattr("vllm_omni.config.stage_config.logger.warning", warning)
|
||||
pipeline = PipelineConfig(
|
||||
model_type="custom_pipeline",
|
||||
stages=[
|
||||
StagePipelineConfig(
|
||||
stage_id=0, model_stage="custom_ar", final_output=True, supports_native_mrv2_data_plane=native
|
||||
)
|
||||
],
|
||||
)
|
||||
merge_pipeline_deploy(pipeline, DeployConfig(model_runner=runner))
|
||||
assert warning.call_count == int(runner == "v2" and not native)
|
||||
if warning.called:
|
||||
assert "legacy transport path" in warning.call_args.args[0]
|
||||
|
||||
def test_runner_selection_rejects_engine_extras_override(self):
|
||||
pipeline = PipelineConfig(
|
||||
model_type="runner_selection_reserved",
|
||||
stages=SINGLE_STAGE_PIPE_CFG,
|
||||
)
|
||||
for reserved in ("use_v2_model_runner", "supports_native_mrv2_data_plane"):
|
||||
deploy = DeployConfig(
|
||||
model_runner="v2",
|
||||
stages=[StageDeployConfig(stage_id=0, engine_extras={reserved: False})],
|
||||
)
|
||||
with pytest.raises(ValueError, match=f"{reserved!r} must not be set"):
|
||||
merge_pipeline_deploy(pipeline, deploy)
|
||||
|
||||
def test_npu_overrides(self):
|
||||
deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
|
||||
if not deploy_path.exists():
|
||||
|
||||
@@ -50,8 +50,10 @@ from vllm_omni.config.stage_config import (
|
||||
StageDeployConfig,
|
||||
StageExecutionType,
|
||||
StagePipelineConfig,
|
||||
_apply_platform_overrides,
|
||||
load_deploy_config,
|
||||
merge_pipeline_deploy,
|
||||
resolve_deploy_yaml,
|
||||
)
|
||||
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
|
||||
from vllm_omni.engine.stage_engine_startup import _serialize_stage_config
|
||||
@@ -783,6 +785,67 @@ def test_joyai_code2wav_waits_for_full_payload():
|
||||
assert code2wav.model_config.requires_full_payload_input is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_runner", ["v1", "v2"])
|
||||
def test_deploy_model_runner_selection_propagates_to_every_stage(tmp_path: Path, model_runner: str):
|
||||
deploy_path = tmp_path / "qwen3_tts_runner.yaml"
|
||||
deploy_path.write_text(
|
||||
f"""\
|
||||
pipeline: qwen3_tts
|
||||
model_runner: {model_runner}
|
||||
async_chunk: true
|
||||
stages:
|
||||
- stage_id: 0
|
||||
- stage_id: 1
|
||||
"""
|
||||
)
|
||||
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
pipeline = _resolve_pipeline_or_skip("qwen3_tts")
|
||||
legacy_stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
structured = _from_pipeline_key("qwen3_tts", deploy_config_path=str(deploy_path))
|
||||
expect_v2 = model_runner == "v2"
|
||||
|
||||
assert deploy.model_runner == model_runner
|
||||
assert all(stage.yaml_engine_args["use_v2_model_runner"] is expect_v2 for stage in legacy_stages)
|
||||
assert all(stage.model_config.use_v2_model_runner is expect_v2 for stage in structured.stage_configs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", ["npu", "xpu"])
|
||||
def test_mrv2_fails_fast_on_platforms_without_native_workers(platform: str):
|
||||
with pytest.raises(NotImplementedError, match="Model Runner V2"):
|
||||
_apply_platform_overrides(DeployConfig(model_runner="v2"), platform=platform)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("default_name", "mrv2_name"),
|
||||
[
|
||||
("qwen3_tts.yaml", "qwen3_tts_mrv2.yaml"),
|
||||
(
|
||||
"qwen3_tts_high_concurrency.yaml",
|
||||
"qwen3_tts_high_concurrency_mrv2.yaml",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_qwen3_mrv2_profiles_are_explicit_opt_in(default_name: str, mrv2_name: str):
|
||||
assert load_deploy_config(_DEPLOY_DIR / default_name).model_runner == "v1"
|
||||
assert load_deploy_config(_DEPLOY_DIR / mrv2_name).model_runner == "v2"
|
||||
|
||||
|
||||
def test_qwen3_tts_mrv2_retunes_do_not_change_default_mrv1_profile():
|
||||
default = resolve_deploy_yaml(_DEPLOY_DIR / "qwen3_tts_high_concurrency.yaml")
|
||||
mrv2 = resolve_deploy_yaml(_DEPLOY_DIR / "qwen3_tts_high_concurrency_mrv2.yaml")
|
||||
default_extra = default["connectors"]["connector_of_shared_memory"]["extra"]
|
||||
mrv2_extra = mrv2["connectors"]["connector_of_shared_memory"]["extra"]
|
||||
|
||||
# B2 batch stays consistent with the graph buckets; V1 profile untouched.
|
||||
assert default_extra["decode_batch_max_size"] == 1
|
||||
assert mrv2_extra["decode_batch_max_size"] == 2
|
||||
assert mrv2_extra["decode_cudagraph_batch_sizes"] == [1, 2]
|
||||
assert mrv2_extra["code_predictor_prefix_graphs"] is False
|
||||
assert mrv2["stages"][1]["enforce_eager"] is False
|
||||
assert default["stages"][1]["enforce_eager"] is True
|
||||
|
||||
|
||||
def test_vllm_omni_stage_config_public_fields_use_typed_stage_realizations():
|
||||
assert not hasattr(BaseVllmOmniStageConfig, "from_stage_config")
|
||||
assert not hasattr(BaseVllmOmniStageConfig, "to_legacy_stage_config")
|
||||
@@ -879,9 +942,11 @@ def test_sub_config_fields_match_structured_scopes():
|
||||
"limit_mm_per_prompt",
|
||||
"interleave_mm_strings",
|
||||
"media_io_kwargs",
|
||||
"final_output",
|
||||
"active_stream_window",
|
||||
"session_mode",
|
||||
"duplex_max_sessions",
|
||||
"use_v2_model_runner",
|
||||
"enable_sleep_mode",
|
||||
"default_sampling_params",
|
||||
"subtalker_sampling_params",
|
||||
@@ -890,6 +955,7 @@ def test_sub_config_fields_match_structured_scopes():
|
||||
"custom_voice_dir",
|
||||
"task_type",
|
||||
"codec_frame_rate_hz",
|
||||
"supports_native_mrv2_data_plane",
|
||||
"enforce_eager",
|
||||
"max_cudagraph_capture_size",
|
||||
"enable_flashinfer_autotune",
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
"""Test that OmniGenerationScheduler restores chunk-waiting requests
|
||||
even when the OmniNewRequestData rewrapping fails.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
Regression test: if process_pending_chunks() moves requests into
|
||||
internal deques but restore_queues() is not called due to an exception,
|
||||
those requests are permanently orphaned.
|
||||
"""
|
||||
"""Chunk-lifecycle coverage for OmniGenerationScheduler: restore on error,
|
||||
native terminal state, in-flight no-resubmission, slot release on finish,
|
||||
and single scheduling of a terminal empty-prompt chunk."""
|
||||
|
||||
from collections import deque
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm import SamplingParams
|
||||
from vllm.v1.core.sched.interface import PauseState
|
||||
from vllm.v1.core.sched.request_queue import SchedulingPolicy, create_request_queue
|
||||
from vllm.v1.request import Request, RequestStatus
|
||||
|
||||
from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler, _has_async_chunk_payload_to_run
|
||||
from vllm_omni.engine.serialization import serialize_additional_information
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -20,16 +28,20 @@ class FakeAdapter:
|
||||
self.waiting_for_chunk_waiting_requests = deque()
|
||||
self.waiting_for_chunk_running_requests = deque()
|
||||
self.restore_called = False
|
||||
self.done_request_ids = set()
|
||||
|
||||
def process_pending_chunks(self, waiting, running):
|
||||
"""Simulate moving requests out of the scheduler queues."""
|
||||
# Move one request from running into internal deque
|
||||
def process_pending_chunks(self, waiting, running, scheduler_requests=None):
|
||||
if running:
|
||||
req = running.pop()
|
||||
self.waiting_for_chunk_running_requests.append(req)
|
||||
|
||||
def is_done_receiving_chunks(self, request_id):
|
||||
return request_id in self.done_request_ids
|
||||
|
||||
def collect_failed_send_request_ids(self):
|
||||
return {}
|
||||
|
||||
def restore_queues(self, waiting, running, scheduler_requests=None):
|
||||
"""Put requests back."""
|
||||
self.restore_called = True
|
||||
running.extend(self.waiting_for_chunk_running_requests)
|
||||
self.waiting_for_chunk_running_requests = deque()
|
||||
@@ -38,6 +50,131 @@ class FakeAdapter:
|
||||
pass
|
||||
|
||||
|
||||
def _make_generation_scheduler(waiting_request, *, use_v2_model_runner=False):
|
||||
scheduler = OmniGenerationScheduler.__new__(OmniGenerationScheduler)
|
||||
scheduler.max_num_scheduled_tokens = 8
|
||||
scheduler.max_num_running_reqs = 1
|
||||
scheduler._pause_state = PauseState.UNPAUSED
|
||||
scheduler.running = []
|
||||
scheduler.waiting = create_request_queue(SchedulingPolicy.FCFS)
|
||||
scheduler.waiting.add_request(waiting_request)
|
||||
scheduler.skipped_waiting = create_request_queue(SchedulingPolicy.FCFS)
|
||||
scheduler.requests = {waiting_request.request_id: waiting_request}
|
||||
scheduler.policy = SchedulingPolicy.FCFS
|
||||
scheduler.chunk_transfer_adapter = FakeAdapter()
|
||||
scheduler.input_coordinator = None
|
||||
scheduler.log_stats = False
|
||||
scheduler.scheduler_config = SimpleNamespace(enable_chunked_prefill=True)
|
||||
scheduler.num_lookahead_tokens = 0
|
||||
scheduler.kv_cache_manager = SimpleNamespace(
|
||||
new_step_starts=lambda: None,
|
||||
allocate_slots=lambda *args, **kwargs: SimpleNamespace(get_block_ids=lambda: ([1],)),
|
||||
get_num_common_prefix_blocks=lambda request_id: [0],
|
||||
take_new_block_ids=lambda: None,
|
||||
)
|
||||
scheduler.kv_cache_config = SimpleNamespace(kv_cache_groups=[object()])
|
||||
scheduler.use_v2_model_runner = use_v2_model_runner
|
||||
scheduler._retains_state_across_chunks = False
|
||||
scheduler.needs_kv_cache_zeroing = False
|
||||
scheduler.finished_req_ids = set()
|
||||
scheduler.encoder_cache_manager = SimpleNamespace(get_freed_mm_hashes=lambda: [])
|
||||
scheduler.connector = None
|
||||
scheduler.ec_connector = None
|
||||
scheduler.prev_step_scheduled_req_ids = set()
|
||||
scheduler._pending_finish_reqs = []
|
||||
scheduler._consume_pending_connector_output = lambda model_mode: None
|
||||
scheduler._process_pending_input_timeouts = lambda: None
|
||||
scheduler._make_cached_request_data = lambda **kwargs: SimpleNamespace(
|
||||
req_ids=[],
|
||||
resumed_req_ids=[],
|
||||
new_token_ids=[],
|
||||
all_token_ids=[],
|
||||
new_block_ids=[],
|
||||
num_computed_tokens=[],
|
||||
num_output_tokens=[],
|
||||
)
|
||||
scheduler._update_after_schedule = lambda output: None
|
||||
scheduler._wrap_omni_scheduler_output = lambda output: output
|
||||
return scheduler
|
||||
|
||||
|
||||
def _chunk_request(request_id, **kwargs):
|
||||
defaults = dict(
|
||||
request_id=request_id,
|
||||
prompt_token_ids=[1],
|
||||
num_computed_tokens=0,
|
||||
num_in_flight_tokens=0,
|
||||
status=None,
|
||||
sampling_params=None,
|
||||
pooling_params=None,
|
||||
mm_features=None,
|
||||
lora_request=None,
|
||||
prompt_is_token_ids=True,
|
||||
additional_information=None,
|
||||
external_req_id=request_id,
|
||||
prefill_stats=None,
|
||||
record_event=lambda *args, **kwargs: None,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def test_chunk_lifecycle_no_resubmit_and_state_survives_requeue(monkeypatch) -> None:
|
||||
# Native plane: a completed payload without a new chunk is never executed
|
||||
# again; requeued requests keep WAITING_FOR_CHUNK and their token counts.
|
||||
scheduler = _make_generation_scheduler(_chunk_request("unused"))
|
||||
monkeypatch.setattr("vllm_omni.core.sched.omni_generation_scheduler.create_request_queue", create_request_queue)
|
||||
scheduler._native_data_plane = True
|
||||
scheduler.chunk_transfer_adapter = None
|
||||
scheduler.input_coordinator = SimpleNamespace(finished_requests=set(), restore_queues=lambda *args, **kwargs: None)
|
||||
scheduler.running = []
|
||||
scheduler.waiting = create_request_queue(scheduler.policy)
|
||||
scheduler.skipped_waiting = create_request_queue(scheduler.policy)
|
||||
|
||||
completed = Request("completed", [1, 2], SamplingParams(max_tokens=4), pooling_params=None)
|
||||
completed.status = RequestStatus.RUNNING
|
||||
completed.num_computed_tokens = 2
|
||||
scheduler.running = [completed]
|
||||
scheduler.requests = {"completed": completed}
|
||||
scheduler.input_coordinator.finished_requests.add("completed") # terminal payload, no new chunk
|
||||
|
||||
assert scheduler._is_done_receiving_chunks("completed")
|
||||
assert not scheduler._is_done_receiving_chunks("unknown")
|
||||
output = scheduler.schedule()
|
||||
assert not output.num_scheduled_tokens
|
||||
assert scheduler._pending_finish_reqs == [completed]
|
||||
|
||||
completed.status = RequestStatus.WAITING_FOR_CHUNK
|
||||
scheduler._pending_finish_reqs = []
|
||||
scheduler._requeue_completed_native_chunks()
|
||||
assert completed.status == RequestStatus.WAITING_FOR_CHUNK
|
||||
assert completed.num_computed_tokens == 2
|
||||
assert not scheduler.running
|
||||
|
||||
|
||||
def test_generation_scheduler_schedules_terminal_empty_prompt_chunk_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
waiting = _chunk_request(
|
||||
"terminal",
|
||||
prompt_token_ids=[],
|
||||
num_prompt_tokens=0,
|
||||
additional_information=serialize_additional_information(
|
||||
{"codes": {"audio": torch.tensor([[1, 2, 3]], dtype=torch.long)}}
|
||||
),
|
||||
)
|
||||
scheduler = _make_generation_scheduler(waiting)
|
||||
scheduler.chunk_transfer_adapter.done_request_ids.add("terminal")
|
||||
scheduler.requests = {"terminal": waiting}
|
||||
assert _has_async_chunk_payload_to_run(waiting)
|
||||
# A terminal codec request with an empty prompt is still scheduled exactly once.
|
||||
monkeypatch.setattr("vllm_omni.core.sched.omni_generation_scheduler.create_request_queue", create_request_queue)
|
||||
|
||||
output = OmniGenerationScheduler.schedule(scheduler)
|
||||
|
||||
assert output.num_scheduled_tokens == {"terminal": 1}
|
||||
assert output.scheduled_new_reqs[0].req_id == "terminal"
|
||||
assert scheduler._pending_finish_reqs == []
|
||||
|
||||
|
||||
class TestRestoreQueuesOnError:
|
||||
"""Verify that restore_queues is called even when rewrapping raises."""
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Tests for ``OmniARScheduler._drop_aborted_queued_requests``.
|
||||
|
||||
``schedule()`` sweeps ``FINISHED_ABORTED`` requests out of the queues before
|
||||
@@ -68,6 +71,7 @@ def test_schedule_sweeps_skipped_waiting_before_upstream_selection(
|
||||
aborted = _StubRequest("req-aborted", RequestStatus.FINISHED_ABORTED)
|
||||
scheduler = _make_scheduler(policy, skipped_waiting=[aborted])
|
||||
scheduler.chunk_transfer_adapter = None
|
||||
scheduler.max_num_running_reqs = 8
|
||||
scheduler.input_coordinator = None
|
||||
scheduler.num_waiting_for_streaming_input = 0
|
||||
scheduler._process_pending_omni_inputs = lambda model_mode: None
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Tests for the AR sampled-token logprob contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -93,6 +96,7 @@ class _Request:
|
||||
self.num_computed_tokens = 0
|
||||
self.num_in_flight_tokens = 0
|
||||
self.num_output_placeholders = 0
|
||||
self.output_token_ids: list[int] = []
|
||||
# vLLM 0.27 (a0c092ee72): Request gained num_stale_output_tokens to
|
||||
# track in-flight outputs discarded at preemption/streaming-stop.
|
||||
self.num_stale_output_tokens = 0
|
||||
@@ -145,6 +149,14 @@ def _make_scheduler_stub(requests: list[_Request]) -> SimpleNamespace:
|
||||
kv_cache_manager=SimpleNamespace(take_events=lambda: None),
|
||||
kv_event_publisher=SimpleNamespace(publish=lambda _events: None),
|
||||
recompute_kv_load_failures=False,
|
||||
_native_data_plane=False,
|
||||
vllm_config=SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
use_v2_model_runner=False,
|
||||
async_chunk=False,
|
||||
final_output=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
for name in _MIXIN_UPDATE_HELPERS:
|
||||
setattr(scheduler, name, MethodType(getattr(OmniSchedulerMixin, name), scheduler))
|
||||
@@ -167,10 +179,15 @@ def _bind_request_lifecycle(
|
||||
scheduler.requests.pop(request.request_id, None)
|
||||
scheduler.finished_req_ids.add(request.request_id)
|
||||
scheduler.finished_req_ids_dict[request.client_index].add(request.request_id)
|
||||
# vLLM 0.26 contract: (kv_xfer_params, ec_xfer_params)
|
||||
# Current vLLM contract: (kv_xfer_params, ec_xfer_params)
|
||||
return None, None
|
||||
|
||||
scheduler._update_request_with_output = update_request
|
||||
def update_with_history(request, token_ids):
|
||||
accepted, stopped = update_request(request, token_ids)
|
||||
request.output_token_ids.extend(accepted)
|
||||
return accepted, stopped
|
||||
|
||||
scheduler._update_request_with_output = update_with_history
|
||||
scheduler._process_kv_transfer_trigger = lambda _request, _tokens: False
|
||||
scheduler._handle_stopped_request = handle_stopped or (lambda _request: True)
|
||||
scheduler._free_request = free_request
|
||||
@@ -219,6 +236,7 @@ def test_mid_step_stop_trims_logprob_rows_with_token_ids() -> None:
|
||||
(output,) = outputs[0].outputs
|
||||
|
||||
assert output.new_token_ids == [7]
|
||||
assert output.num_generation_tokens == 1
|
||||
assert output.finish_reason is FinishReason.STOP
|
||||
token_rows = np.asarray(output.new_logprobs.logprob_token_ids)
|
||||
value_rows = np.asarray(output.new_logprobs.logprobs)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.core.sched.omni_ar_scheduler import _should_emit_engine_output
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("async_chunk", "final_output", "use_v2", "stopped", "has_control", "expected"),
|
||||
[
|
||||
(True, False, True, False, False, False), # async+v2 suppresses data
|
||||
(True, False, False, False, False, True), # v1 continues to emit
|
||||
(True, False, True, True, False, True), # stopped always emits
|
||||
(True, False, True, False, True, True), # control-only still emits
|
||||
],
|
||||
)
|
||||
def test_async_chunk_intermediate_stage_emits_only_control_outputs(
|
||||
async_chunk: bool,
|
||||
final_output: bool,
|
||||
use_v2: bool,
|
||||
stopped: bool,
|
||||
has_control: bool,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
model_config = SimpleNamespace(
|
||||
async_chunk=async_chunk,
|
||||
final_output=final_output,
|
||||
use_v2_model_runner=use_v2,
|
||||
)
|
||||
|
||||
assert (
|
||||
_should_emit_engine_output(
|
||||
model_config,
|
||||
stopped=stopped,
|
||||
has_control=has_control,
|
||||
)
|
||||
is expected
|
||||
)
|
||||
@@ -17,6 +17,7 @@ import pytest
|
||||
import vllm_omni # noqa: F401 - import for side effects (patch vLLM)
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler
|
||||
from vllm.v1.outputs import ModelRunnerOutput
|
||||
from vllm.v1.engine import FinishReason
|
||||
from vllm.v1.core.sched.request_queue import SchedulingPolicy, create_request_queue
|
||||
@@ -1261,3 +1262,47 @@ def test_context_overflow_emits_an_error_output_with_the_reason() -> None:
|
||||
|
||||
sched._emit_streaming_context_overflow_outputs(outputs)
|
||||
assert len(outputs[2]) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("native", [True, False])
|
||||
def test_async_chunk_reserves_parked_slots_during_ar_admission(monkeypatch, native) -> None:
|
||||
sched = _make_scheduler(stage_id=1)
|
||||
parked = SimpleNamespace(request_id="parked")
|
||||
sched.requests = {"parked": parked}
|
||||
sched.waiting = []
|
||||
sched.running = []
|
||||
sched._native_data_plane = native
|
||||
sched.use_v2_model_runner = native
|
||||
sched.max_num_running_reqs = 8
|
||||
sched.input_coordinator = (
|
||||
SimpleNamespace(_waiting_for_chunk_running=[parked], restore_queues=lambda _w, _r: None) if native else None
|
||||
)
|
||||
sched.chunk_transfer_adapter = (
|
||||
None
|
||||
if native
|
||||
else SimpleNamespace(
|
||||
waiting_for_chunk_running_requests=[parked],
|
||||
_held_non_active=[],
|
||||
process_pending_chunks=lambda *_a, **_kw: None,
|
||||
collect_failed_send_request_ids=lambda: {},
|
||||
restore_queues=lambda *_a, **_kw: None,
|
||||
postprocess_scheduler_output=lambda *_a, **_kw: None,
|
||||
)
|
||||
)
|
||||
sched._consume_pending_connector_output = lambda model_mode: None
|
||||
sched._process_pending_input_timeouts = lambda: None
|
||||
sched._should_defer_waiting_admission = lambda: False
|
||||
sched.get_finished_requests_needing_kv_transfer = lambda: {}
|
||||
sched._wrap_omni_scheduler_output = lambda output, **_kwargs: output
|
||||
observed_limits: list[int] = []
|
||||
|
||||
def fake_schedule(self, _throttle_prefills=False):
|
||||
observed_limits.append(self.max_num_running_reqs)
|
||||
return SimpleNamespace(scheduled_new_reqs=[])
|
||||
|
||||
monkeypatch.setattr(VLLMScheduler, "schedule", fake_schedule)
|
||||
|
||||
sched.schedule()
|
||||
|
||||
assert observed_limits == [7 if native else 8]
|
||||
assert sched.max_num_running_reqs == 8
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
@@ -141,6 +144,7 @@ def test_voxcpm2_unified_decode_graph_does_not_defer_without_cuda_graph(monkeypa
|
||||
|
||||
def test_unified_decode_graph_deferral_restores_waiting_queue(monkeypatch) -> None:
|
||||
scheduler = _make_scheduler()
|
||||
scheduler.max_num_running_reqs = 8
|
||||
scheduler.running = [_MockRequest("decode")]
|
||||
original_waiting_req = _MockRequest("waiting", status=RequestStatus.WAITING)
|
||||
deferred_by_upstream = _MockRequest("deferred-by-upstream", status=RequestStatus.WAITING)
|
||||
|
||||
@@ -123,6 +123,9 @@ def _make_fast_path_sched(*, defer_block_free: bool) -> tuple[MagicMock, Request
|
||||
|
||||
sched = MagicMock()
|
||||
sched.max_num_scheduled_tokens = 8
|
||||
sched.max_num_running_reqs = 8
|
||||
sched._native_data_plane = False
|
||||
sched._retains_state_across_chunks = False
|
||||
sched._pause_state = PauseState.UNPAUSED
|
||||
sched.requests = {request.request_id: request}
|
||||
sched.running = [request]
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from collections import defaultdict
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from vllm.v1.engine import FinishReason
|
||||
@@ -87,7 +91,7 @@ def test_full_payload_coordinator_matches_legacy_gate(monkeypatch, stage_id, asy
|
||||
|
||||
|
||||
def test_schedule_lifecycle_helpers_process_and_restore_both_input_paths():
|
||||
calls = []
|
||||
calls: list[tuple[Any, ...]] = []
|
||||
scheduler = _Scheduler()
|
||||
scheduler.waiting = ["waiting"]
|
||||
scheduler.running = ["running"]
|
||||
@@ -115,7 +119,7 @@ def test_schedule_lifecycle_helpers_process_and_restore_both_input_paths():
|
||||
collect_failed_send_request_ids=_collect_failed_sends,
|
||||
)
|
||||
scheduler.input_coordinator = SimpleNamespace(
|
||||
restore_queues=lambda waiting: calls.append(("restore-full", waiting))
|
||||
restore_queues=lambda waiting, running: calls.append(("restore-full", waiting, running))
|
||||
)
|
||||
|
||||
scheduler._process_pending_omni_inputs("ar")
|
||||
@@ -130,7 +134,7 @@ def test_schedule_lifecycle_helpers_process_and_restore_both_input_paths():
|
||||
("chunk-timeouts", omni_scheduler_mixin.DEFAULT_INPUT_WAIT_TIMEOUT_S),
|
||||
("failed-sends",),
|
||||
("restore-chunks", scheduler.waiting, scheduler.running, scheduler.requests),
|
||||
("restore-full", scheduler.waiting),
|
||||
("restore-full", scheduler.waiting, scheduler.running),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Drain ready events once, merging live requests and filtering cancellations."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
|
||||
from vllm_omni.outputs import OmniConnectorOutput
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_ready_inbox_coalesces_live_events_and_drops_cancelled(mocker):
|
||||
scheduler = OmniSchedulerMixin()
|
||||
scheduler.requests, scheduler.waiting, scheduler.running = {"r": object()}, [], []
|
||||
coordinator = SimpleNamespace(
|
||||
_async_chunk=True, update_request_metadata=mocker.Mock(), process_pending_chunks=mocker.Mock()
|
||||
)
|
||||
scheduler.input_coordinator = coordinator
|
||||
scheduler._init_omni_connector_output_inbox()
|
||||
scheduler.enqueue_omni_connector_output(OmniConnectorOutput(chunk_ready_req_ids={"r"}))
|
||||
scheduler.enqueue_omni_connector_output(
|
||||
OmniConnectorOutput(
|
||||
chunk_ready_req_ids={"r", "aborted"},
|
||||
chunk_finished_req_ids={"r", "aborted"},
|
||||
request_metadata={"r": {"decode_token_end": 2}, "aborted": {"decode_token_end": 99}},
|
||||
)
|
||||
)
|
||||
scheduler._consume_pending_connector_output(model_mode="ar")
|
||||
coordinator.update_request_metadata.assert_called_once_with(
|
||||
scheduler.requests, {"r": {"decode_token_end": 2}}, model_mode="ar"
|
||||
)
|
||||
coordinator.process_pending_chunks.assert_called_once_with([], [], {"r"}, {"r"})
|
||||
scheduler._consume_pending_connector_output(model_mode="ar")
|
||||
assert coordinator.update_request_metadata.call_count == 1
|
||||
coordinator.process_pending_chunks.assert_called_with([], [], set(), set())
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Tests for the ``num_waiting_for_streaming_input`` resync placed in
|
||||
``OmniARScheduler.finish_requests`` / ``OmniGenerationScheduler.finish_requests``
|
||||
and in ``OmniARScheduler.schedule``.
|
||||
@@ -128,6 +131,7 @@ def test_ar_schedule_resyncs_before_delegating_upstream(monkeypatch: pytest.Monk
|
||||
scheduler.skipped_waiting = []
|
||||
scheduler.num_waiting_for_streaming_input = 1 # leaked by the status rewrite
|
||||
scheduler.policy = "fcfs"
|
||||
scheduler.max_num_running_reqs = 8
|
||||
|
||||
seen: dict[str, int] = {}
|
||||
|
||||
|
||||
@@ -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
|
||||
"""Unit tests for OmniSchedulingCoordinator.
|
||||
|
||||
These tests use mock request objects and mock queues. They do not require
|
||||
@@ -20,7 +20,9 @@ import torch
|
||||
import vllm_omni.core.sched.omni_scheduling_coordinator as coord_mod
|
||||
from vllm_omni.core.sched.omni_scheduling_coordinator import (
|
||||
OmniSchedulingCoordinator,
|
||||
uses_native_mrv2_data_plane,
|
||||
)
|
||||
from vllm_omni.core.sched.output import OmniChunkRecvHandle
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -56,6 +58,7 @@ def _make_request(req_id: str, status: str = "waiting") -> SimpleNamespace:
|
||||
prompt_token_ids=[],
|
||||
num_prompt_tokens=0,
|
||||
num_computed_tokens=0,
|
||||
num_output_placeholders=0,
|
||||
_all_token_ids=[],
|
||||
_output_token_ids=[],
|
||||
payload_sender_info=None,
|
||||
@@ -83,7 +86,7 @@ class MockQueue:
|
||||
def prepend_requests(self, requests):
|
||||
self._items = list(requests) + self._items
|
||||
|
||||
def remove(self, request):
|
||||
def remove_request(self, request):
|
||||
self._items.remove(request)
|
||||
|
||||
def remove_requests(self, requests):
|
||||
@@ -96,7 +99,47 @@ class MockQueue:
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
class TestCoordinatorUpdateRequestMetadata(unittest.TestCase):
|
||||
class TestNativeMRV2DataPlaneSelection(unittest.TestCase):
|
||||
def test_native_plane_requires_v2_and_async_chunk_capability(self):
|
||||
native = SimpleNamespace(async_chunk=True, supports_native_mrv2_data_plane=True)
|
||||
|
||||
self.assertTrue(uses_native_mrv2_data_plane(native, use_v2_model_runner=True))
|
||||
self.assertFalse(uses_native_mrv2_data_plane(native, use_v2_model_runner=False))
|
||||
self.assertFalse(uses_native_mrv2_data_plane(SimpleNamespace(async_chunk=False), use_v2_model_runner=True))
|
||||
|
||||
|
||||
def test_chunk_registration_ready_and_terminal_lifecycle():
|
||||
coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True)
|
||||
req = _make_request("internal", status=RequestStatus.WAITING)
|
||||
req.external_req_id = "external"
|
||||
waiting = MockQueue([req])
|
||||
coord.process_pending_chunks(waiting, [], set(), set())
|
||||
assert req.status == RequestStatus.WAITING_FOR_CHUNK
|
||||
[handle] = coord.pending_chunk_registrations
|
||||
assert isinstance(handle, OmniChunkRecvHandle)
|
||||
assert (handle.request_id, handle.external_req_id) == ("internal", "external")
|
||||
coord.restore_queues(waiting, [])
|
||||
coord.process_pending_chunks(waiting, [], {"internal"}, set())
|
||||
assert req.status == RequestStatus.WAITING
|
||||
assert "internal" in coord.requests_with_ready_chunks
|
||||
req.status = RequestStatus.WAITING_FOR_CHUNK
|
||||
coord.process_pending_chunks(waiting, [], set(), {"internal"})
|
||||
assert "internal" in coord.finished_requests
|
||||
|
||||
|
||||
def test_chunk_waiting_removes_request_from_running_list():
|
||||
coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1, async_chunk=True)
|
||||
req = _make_request("running", status=RequestStatus.RUNNING)
|
||||
running = [req]
|
||||
|
||||
coord.process_pending_chunks(MockQueue(), running, set(), set())
|
||||
|
||||
assert running == []
|
||||
assert req.status == RequestStatus.WAITING_FOR_CHUNK
|
||||
assert list(coord._waiting_for_chunk_running) == [req]
|
||||
|
||||
|
||||
class TestChunkCoordinatorUpdateRequestMetadata(unittest.TestCase):
|
||||
"""Test update_request_metadata applies scheduling metadata to requests."""
|
||||
|
||||
def test_ar_mode_no_longer_sets_additional_information(self):
|
||||
@@ -382,17 +425,18 @@ class TestTimeoutDetection(unittest.TestCase):
|
||||
self.assertEqual(result, {"r1"})
|
||||
self.assertEqual(len(coord._waiting_for_input), 0)
|
||||
|
||||
def test_free_finished_request_clears_waiting_since(self):
|
||||
"""free_finished_request clears coordinator lifecycle markers."""
|
||||
coord = OmniSchedulingCoordinator(stage_id=1)
|
||||
coord._waiting_since["r1"] = 0.0
|
||||
coord._full_payload_input_received.add("r1")
|
||||
def test_free_finished_request_clears_all_lifecycle_state(self):
|
||||
"""free_finished_request makes stale connector events harmless."""
|
||||
coord = OmniSchedulingCoordinator(scheduler_max_num_seqs=10, stage_id=1)
|
||||
r1, r2 = _make_request("r1"), _make_request("r2")
|
||||
coord.finished_requests.add("r1")
|
||||
coord.requests_with_ready_chunks.add("r1")
|
||||
coord._waiting_for_chunk_running.extend([r1, r2])
|
||||
coord.pending_chunk_registrations = [OmniChunkRecvHandle(request_id="r1"), OmniChunkRecvHandle(request_id="r2")]
|
||||
|
||||
coord.free_finished_request("r1")
|
||||
self.assertNotIn("r1", coord._waiting_since)
|
||||
self.assertNotIn("r1", coord._full_payload_input_received)
|
||||
|
||||
self.assertNotIn("r1", coord.finished_requests)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
self.assertNotIn("r1", coord.requests_with_ready_chunks)
|
||||
self.assertEqual([r.request_id for r in coord._waiting_for_chunk_running], ["r2"])
|
||||
self.assertEqual([h.request_id for h in coord.pending_chunk_registrations], ["r2"])
|
||||
@@ -13,6 +13,7 @@ import os
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
import pytest
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import get_asset_path
|
||||
@@ -57,7 +58,12 @@ tts_server_params = [
|
||||
pytest.param(
|
||||
(MODEL, get_cuda_graph_config()),
|
||||
id="no_cuda_graph",
|
||||
)
|
||||
),
|
||||
pytest.param(
|
||||
(MODEL, get_deploy_config_path("qwen3_tts_mrv2.yaml")),
|
||||
id="async_chunk_mrv2",
|
||||
marks=pytest.mark.skipif(not current_platform.is_cuda(), reason="MRV2 validation is CUDA-only"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
import pytest
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import get_asset_path
|
||||
@@ -50,7 +51,16 @@ tts_server_params = [
|
||||
server_args=["--trust-remote-code"],
|
||||
),
|
||||
id="async_chunk",
|
||||
)
|
||||
),
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=get_deploy_config_path("qwen3_tts_mrv2.yaml"),
|
||||
server_args=["--trust-remote-code"],
|
||||
),
|
||||
id="async_chunk_mrv2",
|
||||
marks=pytest.mark.skipif(not current_platform.is_cuda(), reason="MRV2 validation is CUDA-only"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ def _engine_core_outputs(tag: str, timestamp: float) -> SimpleNamespace:
|
||||
return SimpleNamespace(outputs=[tag], timestamp=timestamp, scheduler_stats=None, finished_requests=None)
|
||||
|
||||
|
||||
def _terminal_engine_core_outputs(request_id: str) -> EngineCoreOutputs:
|
||||
def _terminal_engine_core_outputs(request_id: str, timestamp: float = 1.0) -> EngineCoreOutputs:
|
||||
return EngineCoreOutputs(
|
||||
outputs=[
|
||||
EngineCoreOutput(
|
||||
@@ -320,7 +320,7 @@ def _terminal_engine_core_outputs(request_id: str) -> EngineCoreOutputs:
|
||||
finish_reason=FinishReason.STOP,
|
||||
)
|
||||
],
|
||||
timestamp=1.0,
|
||||
timestamp=timestamp,
|
||||
finished_requests={request_id},
|
||||
)
|
||||
|
||||
@@ -520,6 +520,7 @@ async def _enqueue_add_request(
|
||||
original_prompt,
|
||||
sampling_params_list,
|
||||
final_stage_id: int,
|
||||
final_output_stage_ids: list[int] | None = None,
|
||||
) -> None:
|
||||
orchestrator_fixture.request_sync_q.put_nowait(
|
||||
StageSubmissionMessage(
|
||||
@@ -530,6 +531,7 @@ async def _enqueue_add_request(
|
||||
output_prompt_text=None,
|
||||
sampling_params_list=sampling_params_list,
|
||||
final_stage_id=final_stage_id,
|
||||
final_output_stage_ids=final_output_stage_ids,
|
||||
preprocess_ms=0.0,
|
||||
request_timestamp=time.time(),
|
||||
enqueue_ts=time.perf_counter(),
|
||||
@@ -789,6 +791,10 @@ async def test_run_async_chunk(orchestrator_factory) -> None:
|
||||
|
||||
stage1.push_engine_core_outputs(_engine_core_outputs("stage1-final", 3.0))
|
||||
|
||||
await _wait_for(
|
||||
lambda: orchestrator_fixture.orchestrator.request_states["req-async"].pending_final_output is not None
|
||||
)
|
||||
stage0.push_engine_core_outputs(_engine_core_outputs("stage0-final", 3.1))
|
||||
output_msg = await _get_output_message(orchestrator_fixture)
|
||||
|
||||
assert output_msg.request_id == "req-async"
|
||||
|
||||
@@ -259,6 +259,17 @@ def test_native_text_tpot_only_accepts_positive_finished_metric(
|
||||
assert processor.pop_native_text_metrics("r")["vllm_tpot_ms"] == expected_tpot_ms
|
||||
|
||||
|
||||
def test_suppressed_native_tokens_use_authoritative_count_even_without_stats(monkeypatch):
|
||||
monkeypatch.setattr(VLLMOutputProcessor, "_update_stats_from_output", lambda *args, **kwargs: None)
|
||||
processor = object.__new__(MultimodalOutputProcessor)
|
||||
processor._native_text_metrics_by_request = {}
|
||||
state = _make_state(RequestOutputKind.FINAL_ONLY)
|
||||
processor._update_stats_from_output(
|
||||
state, SimpleNamespace(new_token_ids=[2150], num_generation_tokens=2048), None, None
|
||||
)
|
||||
assert processor.pop_native_text_metrics("r")["num_generation_tokens"] == 2048
|
||||
|
||||
|
||||
def test_delta_drains_output_modality_per_step():
|
||||
"""DELTA drains the mm_type key (output modality) but preserves hidden-state keys."""
|
||||
s = _make_state(RequestOutputKind.DELTA)
|
||||
@@ -616,6 +627,7 @@ def test_no_detokenizer_process_outputs_returns_nonterminal_audio_chunk(monkeypa
|
||||
finish_reason=None,
|
||||
stop_reason=None,
|
||||
kv_transfer_params=None,
|
||||
ec_transfer_params=None,
|
||||
routed_experts=None,
|
||||
num_cached_tokens=0,
|
||||
)
|
||||
@@ -631,6 +643,7 @@ def _make_mm_only_output_processor(monkeypatch):
|
||||
processor = object.__new__(MultimodalOutputProcessor)
|
||||
processor.output_modality = OutputModality.AUDIO
|
||||
processor.request_states = {"r": _make_no_detok_state(RequestOutputKind.DELTA)}
|
||||
processor.tracing_enabled = False
|
||||
monkeypatch.setattr(
|
||||
VLLMOutputProcessor,
|
||||
"process_outputs",
|
||||
@@ -660,6 +673,7 @@ def _audio_engine_output(*, is_segment_finished: bool, is_last_chunk: bool):
|
||||
finish_reason=FinishReason.STOP,
|
||||
stop_reason=None,
|
||||
kv_transfer_params=None,
|
||||
ec_transfer_params=None,
|
||||
routed_experts=None,
|
||||
num_cached_tokens=0,
|
||||
is_segment_finished=is_segment_finished,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Regression tests for MRv2 Omni AR text detach and generation payload outputs."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.sampling_params import RequestOutputKind
|
||||
from vllm.v1.engine import FinishReason
|
||||
|
||||
from vllm_omni.engine import OmniEngineCoreOutput
|
||||
from vllm_omni.engine.output_modality import OutputModality
|
||||
from vllm_omni.engine.output_processor import (
|
||||
MultimodalOutputProcessor,
|
||||
OmniRequestState,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _Detokenizer:
|
||||
def __init__(self):
|
||||
self.output_token_ids = []
|
||||
self.output_text = ""
|
||||
self.update_calls = []
|
||||
|
||||
def update(self, token_ids, stop):
|
||||
self.update_calls.append((list(token_ids), stop))
|
||||
self.output_token_ids.extend(token_ids)
|
||||
self.output_text += "X" * len(token_ids)
|
||||
return None
|
||||
|
||||
def get_next_output_text(self, finished, delta):
|
||||
return self.output_text
|
||||
|
||||
def num_output_tokens(self):
|
||||
return len(self.output_token_ids)
|
||||
|
||||
|
||||
def _make_processor(output_modality, detokenizer=None):
|
||||
state = OmniRequestState(
|
||||
request_id="r",
|
||||
external_req_id="r",
|
||||
parent_req=None,
|
||||
request_index=0,
|
||||
lora_request=None,
|
||||
output_kind=RequestOutputKind.FINAL_ONLY,
|
||||
prompt="prompt" if detokenizer else None,
|
||||
prompt_token_ids=[1] if detokenizer else [],
|
||||
prompt_embeds=None,
|
||||
logprobs_processor=MagicMock(logprobs=None, cumulative_logprob=None, prompt_logprobs=None)
|
||||
if detokenizer
|
||||
else None,
|
||||
detokenizer=detokenizer,
|
||||
max_tokens_param=None,
|
||||
arrival_time=0.0,
|
||||
queue=None,
|
||||
log_stats=False,
|
||||
stream_interval=1,
|
||||
)
|
||||
processor = MultimodalOutputProcessor(tokenizer=None, log_stats=False, output_modality=output_modality)
|
||||
processor.request_states["r"] = state
|
||||
processor.external_req_ids["r"].append("r")
|
||||
return processor, state
|
||||
|
||||
|
||||
def _rehydrated_output(*, pooling_output=None, finish_reason=FinishReason.STOP):
|
||||
"""Build the post-StagePool shape without violating vLLM's wire schema."""
|
||||
return SimpleNamespace(
|
||||
request_id="r",
|
||||
new_token_ids=[42],
|
||||
pooling_output=pooling_output,
|
||||
finish_reason=finish_reason,
|
||||
stop_reason=None,
|
||||
kv_transfer_params=None,
|
||||
ec_transfer_params=None,
|
||||
routed_experts=None,
|
||||
trace_headers=None,
|
||||
prefill_stats=None,
|
||||
spec_decode_metrics=None,
|
||||
new_sampling_mask=None,
|
||||
num_nans_in_logits=0,
|
||||
multimodal_output=None,
|
||||
is_segment_finished=False,
|
||||
is_non_final_audio_chunk=False,
|
||||
output_type=None,
|
||||
num_generation_tokens=None,
|
||||
finished=True,
|
||||
)
|
||||
|
||||
|
||||
def test_text_tokens_are_detokenized_when_mrv2_ar_output_has_pooling_payload():
|
||||
detokenizer = _Detokenizer()
|
||||
processor, state = _make_processor(OutputModality.LATENT, detokenizer)
|
||||
|
||||
# StagePool has already rehydrated the MRv2 carrier. Use a structural
|
||||
# output here because upstream EngineCoreOutput strictly validates
|
||||
# pooling_output as torch.Tensor | None on construction.
|
||||
output = _rehydrated_output(pooling_output={"hidden": torch.ones(1, 4)})
|
||||
|
||||
processed = processor.process_outputs([output])
|
||||
|
||||
assert detokenizer.update_calls == [([42], True)]
|
||||
completion = processed.request_outputs[0].outputs[0]
|
||||
assert list(completion.token_ids) == [42]
|
||||
assert completion.text == "X"
|
||||
assert not state.mm_accumulated.is_empty
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
def test_generation_stage_mm_delivered_only_with_terminal(streaming):
|
||||
# Non-terminal chunks accumulate; the terminal output carries the audio.
|
||||
# When the first output is already terminal it is delivered directly.
|
||||
processor, _ = _make_processor(OutputModality.AUDIO)
|
||||
audio = torch.ones(1, 320)
|
||||
sr = torch.tensor(24000, dtype=torch.int32)
|
||||
mm_output = OmniEngineCoreOutput(
|
||||
request_id="r",
|
||||
new_token_ids=[],
|
||||
multimodal_output={"model_outputs": audio, "sr": sr},
|
||||
finish_reason=None if streaming else FinishReason.STOP,
|
||||
)
|
||||
|
||||
processed = processor.process_outputs([mm_output])
|
||||
if streaming:
|
||||
assert processed.request_outputs == []
|
||||
processed = processor.process_outputs(
|
||||
[OmniEngineCoreOutput(request_id="r", new_token_ids=[], finish_reason=FinishReason.STOP)]
|
||||
)
|
||||
|
||||
assert len(processed.request_outputs) == 1
|
||||
completion = processed.request_outputs[0].outputs[0]
|
||||
assert completion.text == ""
|
||||
assert torch.equal(completion.multimodal_output["audio"], audio)
|
||||
assert completion.multimodal_output["sr"].item() == 24000
|
||||
@@ -0,0 +1,21 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.engine.request_snapshot import copy_request_snapshot
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_full_snapshot_preserves_fields_aliases_and_isolation():
|
||||
samples = [0.25, 0.5]
|
||||
source = {"prompt_token_ids": [1, 2], "additional_information": {"samples": samples}, "alias": samples}
|
||||
result = copy_request_snapshot(source)
|
||||
assert result == copy.deepcopy(source)
|
||||
assert result["alias"] is result["additional_information"]["samples"]
|
||||
result["alias"].append(0.75)
|
||||
result["prompt_token_ids"][0] = 9
|
||||
assert samples == [0.25, 0.5]
|
||||
assert source["prompt_token_ids"] == [1, 2]
|
||||
@@ -1393,7 +1393,7 @@ class TestConnectRemoteEngineCoresCoordinator:
|
||||
yield mocker.Mock()
|
||||
|
||||
mocker.patch("vllm_omni.engine.stage_engine_startup.zmq_socket_ctx", return_value=fake_socket_ctx())
|
||||
mock_wait = mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup")
|
||||
mock_wait = mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup", autospec=True)
|
||||
with connect_remote_engine_cores(
|
||||
vllm_config=vllm_config,
|
||||
omni_master_server=omni_master_server,
|
||||
@@ -1428,7 +1428,7 @@ class TestConnectRemoteEngineCoresCoordinator:
|
||||
yield mocker.Mock()
|
||||
|
||||
mocker.patch("vllm_omni.engine.stage_engine_startup.zmq_socket_ctx", return_value=fake_socket_ctx())
|
||||
mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup")
|
||||
mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup", autospec=True)
|
||||
with connect_remote_engine_cores(
|
||||
vllm_config=vllm_config,
|
||||
omni_master_server=omni_master_server,
|
||||
@@ -1454,7 +1454,7 @@ class TestConnectRemoteEngineCoresCoordinator:
|
||||
yield mocker.Mock()
|
||||
|
||||
mocker.patch("vllm_omni.engine.stage_engine_startup.zmq_socket_ctx", return_value=fake_socket_ctx())
|
||||
mock_wait = mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup")
|
||||
mock_wait = mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup", autospec=True)
|
||||
|
||||
with connect_remote_diffusion_proc(
|
||||
omni_master_server=omni_master_server,
|
||||
@@ -1466,7 +1466,8 @@ class TestConnectRemoteEngineCoresCoordinator:
|
||||
omni_master_server.get_zmq_addresses.assert_called_once_with(7, replica_id=2)
|
||||
omni_master_server.get_allocation.assert_called_once_with(7, replica_id=2)
|
||||
mock_wait.assert_called_once()
|
||||
_, core_engines, parallel_config, *_ = mock_wait.call_args.args
|
||||
_, core_engines, parallel_config, _, _, launch = mock_wait.call_args.args
|
||||
assert launch.addresses is omni_master_server.get_zmq_addresses.return_value
|
||||
assert core_engines[0].local is False
|
||||
assert parallel_config.data_parallel_size_local == 0
|
||||
|
||||
@@ -1507,7 +1508,7 @@ class TestLaunchOmniCoreEngines:
|
||||
"vllm_omni.engine.stage_engine_startup.CoreEngineProcManager",
|
||||
return_value=local_engine_manager,
|
||||
)
|
||||
mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup")
|
||||
mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup", autospec=True)
|
||||
with _launch_omni_core_engines(
|
||||
vllm_config=vllm_config,
|
||||
executor_class=mocker.Mock(),
|
||||
@@ -1584,7 +1585,7 @@ class TestLaunchOmniCoreEngines:
|
||||
"vllm_omni.engine.stage_engine_startup.CoreEngineProcManager",
|
||||
return_value=mocker.Mock(),
|
||||
)
|
||||
mock_wait = mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup")
|
||||
mock_wait = mocker.patch("vllm_omni.engine.stage_engine_startup.wait_for_engine_startup", autospec=True)
|
||||
with _launch_omni_core_engines(
|
||||
vllm_config=vllm_config,
|
||||
executor_class=mocker.Mock(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import sys
|
||||
import types
|
||||
from dataclasses import fields, replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic.fields import FieldInfo
|
||||
@@ -61,9 +62,14 @@ _DEPLOY_DIR = Path(__file__).parents[2] / "vllm_omni" / "deploy"
|
||||
def _effective_backend_values(config_cls: type, engine_args: dict) -> dict[str, object]:
|
||||
backend_fields = fields(config_cls)
|
||||
backend_field_names = {backend_field.name for backend_field in backend_fields}
|
||||
backend_config = config_cls(
|
||||
**{name: copy.deepcopy(value) for name, value in engine_args.items() if name in backend_field_names}
|
||||
)
|
||||
# This is a schema/normalization comparison, not a Hub download test.
|
||||
with (
|
||||
patch("vllm.engine.arg_utils.get_model_path", side_effect=lambda model, *_: model),
|
||||
patch("vllm_omni.diffusion.data.get_model_path", side_effect=lambda model, *_: model),
|
||||
):
|
||||
backend_config = config_cls(
|
||||
**{name: copy.deepcopy(value) for name, value in engine_args.items() if name in backend_field_names}
|
||||
)
|
||||
effective_values: dict[str, object] = {}
|
||||
for backend_field in backend_fields:
|
||||
value = getattr(backend_config, backend_field.name)
|
||||
@@ -78,6 +84,9 @@ _DIFFUSION_BACKEND_FIELDS = frozenset(field.name for field in fields(OmniDiffusi
|
||||
_TOPOLOGY_ONLY_ENGINE_ARGS = frozenset({"inline_diffusion"})
|
||||
_OMNI_ONLY_LLM_STAGE_ENGINE_FIELDS = frozenset(
|
||||
{
|
||||
"final_output",
|
||||
"use_v2_model_runner",
|
||||
"supports_native_mrv2_data_plane",
|
||||
"active_stream_window",
|
||||
"codec_frame_rate_hz",
|
||||
"custom_voice_dir",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Terminal audio must not retire a request before upstream terminal metrics."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def build():
|
||||
o = Orchestrator.__new__(Orchestrator)
|
||||
o.stage_pools = [
|
||||
SimpleNamespace(
|
||||
final_output=False, build_stage_metrics=lambda *a, **k: SimpleNamespace(stage_id=0, num_tokens_out=42)
|
||||
),
|
||||
SimpleNamespace(
|
||||
final_output=True, build_stage_metrics=lambda *a, **k: SimpleNamespace(stage_id=1, num_tokens_out=0)
|
||||
),
|
||||
]
|
||||
state = OrchestratorRequestState(
|
||||
request_id="r",
|
||||
final_stage_id=1,
|
||||
final_output_stage_ids={1},
|
||||
stage_submit_ts={0: 0, 1: 0},
|
||||
sampling_params_list=[None, None],
|
||||
)
|
||||
o.request_states = {"r": state}
|
||||
o.output_async_queue = asyncio.Queue()
|
||||
o.async_chunk = True
|
||||
o._pd_pair = None
|
||||
o._cfg_tracker = MagicMock()
|
||||
o._cfg_tracker.is_companion.return_value = False
|
||||
o._cfg_tracker.cleanup_parent.return_value = []
|
||||
o._is_duplex_session_request = lambda s: False
|
||||
o._duplex_output_decision = lambda *a: None
|
||||
o._stage_receives_async_chunks = lambda s: True
|
||||
cleaned = []
|
||||
|
||||
async def cleanup(ids):
|
||||
cleaned.extend(ids)
|
||||
for r in ids:
|
||||
o.request_states.pop(r, None)
|
||||
|
||||
o._cleanup_request_ids = cleanup
|
||||
return o, state, cleaned
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("order", [(0, 1), (1, 0)])
|
||||
async def test_terminal_metrics_precede_final_audio_for_either_arrival_order(order):
|
||||
o, state, cleaned = build()
|
||||
for stage in order:
|
||||
await o._handle_processed_outputs(stage, 0, [SimpleNamespace(request_id="r", finished=True, error=None)])
|
||||
if stage == 1 and not state.finished_stage_ids.issuperset({0, 1}):
|
||||
assert o.output_async_queue.empty()
|
||||
assert "r" in o.request_states
|
||||
assert cleaned == []
|
||||
metrics = o.output_async_queue.get_nowait()
|
||||
final = o.output_async_queue.get_nowait()
|
||||
assert type(metrics).__name__ == "StageMetricsMessage"
|
||||
assert metrics.stage_id == 0 and metrics.metrics.num_tokens_out == 42
|
||||
assert type(final).__name__ == "OutputMessage" and final.finished
|
||||
assert o.output_async_queue.empty()
|
||||
assert cleaned == ["r"]
|
||||
assert state.pending_final_output is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_pending_output_is_not_published_by_late_upstream():
|
||||
o, state, cleaned = build()
|
||||
await o._handle_processed_outputs(1, 0, [SimpleNamespace(request_id="r", finished=True, error=None)])
|
||||
assert state.pending_final_output is not None
|
||||
await o._cleanup_request_ids(["r"])
|
||||
await o._handle_processed_outputs(0, 0, [SimpleNamespace(request_id="r", finished=True, error=None)])
|
||||
assert o.output_async_queue.empty()
|
||||
assert cleaned == ["r"]
|
||||
@@ -0,0 +1,95 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.entrypoints.openai.serving_speech import (
|
||||
_REF_AUDIO_RESOLVE_CACHE_MAX_BYTES,
|
||||
_REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES,
|
||||
OmniOpenAIServingSpeech,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
server = object.__new__(OmniOpenAIServingSpeech)
|
||||
server._diffusion_mode = False
|
||||
server.model_config = None
|
||||
server._ref_audio_resolve_cache = OrderedDict()
|
||||
server._ref_audio_resolve_cache_bytes = 0
|
||||
server._ref_audio_resolve_cache_max_entries = _REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES
|
||||
server._ref_audio_resolve_cache_max_bytes = _REF_AUDIO_RESOLVE_CACHE_MAX_BYTES
|
||||
server._ref_audio_model_artifact_ready = set()
|
||||
server._request_ref_audio_artifact_keys = {}
|
||||
return server
|
||||
|
||||
|
||||
def _put(cache, key, samples, artifact=None):
|
||||
cache._put_resolved_ref_audio(key, samples, 24000, artifact or key)
|
||||
|
||||
|
||||
def test_compact_storage_and_default_capacity(cache):
|
||||
assert cache._ref_audio_resolve_cache_max_entries == 1024
|
||||
assert cache._ref_audio_resolve_cache_max_bytes == 512 * 1024 * 1024
|
||||
_put(cache, "a", [0.0, 0.25, -0.5, 1.0])
|
||||
value = cache._ref_audio_resolve_cache["a"]
|
||||
assert value[0].dtype == np.float32
|
||||
assert value[2] == cache._ref_audio_resolve_cache_bytes == 16
|
||||
|
||||
|
||||
def test_cached_float32_roundtrip_and_mutation_isolation(cache):
|
||||
source = "data:audio/wav;base64,already-cached"
|
||||
key = hashlib.sha1(source.encode()).hexdigest()
|
||||
values = np.random.default_rng(42).standard_normal(48000).astype(np.float32).tolist()
|
||||
expected = values.copy()
|
||||
_put(cache, key, values)
|
||||
values[0] = 99.0
|
||||
first, sample_rate, _ = asyncio.run(cache._resolve_ref_audio(source))
|
||||
assert first == expected
|
||||
first[0] = 100.0
|
||||
second, _, _ = asyncio.run(cache._resolve_ref_audio(source))
|
||||
assert sample_rate == 24000
|
||||
assert second == expected
|
||||
|
||||
|
||||
def test_entry_eviction_invalidates_artifact_readiness(cache):
|
||||
cache._ref_audio_resolve_cache_max_entries = 2
|
||||
_put(cache, "a", [0.0])
|
||||
cache._ref_audio_model_artifact_ready.add(("a", False))
|
||||
_put(cache, "b", [0.0])
|
||||
_put(cache, "c", [0.0])
|
||||
assert list(cache._ref_audio_resolve_cache) == ["b", "c"]
|
||||
assert ("a", False) not in cache._ref_audio_model_artifact_ready
|
||||
|
||||
|
||||
def test_byte_budget_evicts_and_rejects_oversized_entries(cache):
|
||||
cache._ref_audio_resolve_cache_max_bytes = 16
|
||||
_put(cache, "a", [0.0] * 3)
|
||||
_put(cache, "b", [0.0] * 3)
|
||||
assert list(cache._ref_audio_resolve_cache) == ["b"]
|
||||
assert cache._ref_audio_resolve_cache_bytes == 12
|
||||
_put(cache, "too-large", [0.0] * 5)
|
||||
assert "too-large" not in cache._ref_audio_resolve_cache
|
||||
|
||||
|
||||
def test_replacement_accounts_bytes_and_preserves_referenced_artifact(cache):
|
||||
_put(cache, "a", [0.0] * 3, "same")
|
||||
_put(cache, "alias", [0.0] * 2, "same")
|
||||
cache._ref_audio_model_artifact_ready.add(("same", False))
|
||||
_put(cache, "a", [1.0] * 4, "new")
|
||||
assert cache._ref_audio_resolve_cache_bytes == 24
|
||||
assert ("same", False) in cache._ref_audio_model_artifact_ready
|
||||
|
||||
|
||||
def test_pool_accepts_666_references(cache):
|
||||
for index in range(666):
|
||||
_put(cache, str(index), [0.0] * 8)
|
||||
assert len(cache._ref_audio_resolve_cache) == 666
|
||||
assert cache._ref_audio_resolve_cache_bytes == 666 * 8 * 4
|
||||
@@ -682,6 +682,7 @@ class TestCodePredictorGraphReplay:
|
||||
return_proj_buf=False,
|
||||
)
|
||||
predictor._prefix_graphs_enabled = False
|
||||
predictor._prefix_reprefill_enabled = False
|
||||
predictor._bucket_pos_ids = {1: torch.arange(4).unsqueeze(0)}
|
||||
graph = mocker.Mock()
|
||||
graph_output = torch.zeros(1, 4, 4)
|
||||
@@ -1362,3 +1363,38 @@ class TestCodePredictorFusedProjections:
|
||||
|
||||
with pytest.raises(RuntimeError, match="missing fused parameters"):
|
||||
model.load_weights(weights)
|
||||
|
||||
|
||||
class TestMTPExecutionBuckets:
|
||||
"""Outer MRv2 MTP graph buckets must align with the predictor bucket set."""
|
||||
|
||||
def test_configure_mtp_execution_buckets(self, mocker: MockerFixture, loaded_target_classes) -> None:
|
||||
_ = loaded_target_classes
|
||||
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
|
||||
mocker.patch.object(common_mod.current_omni_platform, "is_npu", return_value=False)
|
||||
|
||||
cp_config, _ = _make_tiny_config(loaded_target_classes)
|
||||
vllm_config = _make_vllm_config(mocker, max_num_seqs=4)
|
||||
wrapper = common_mod.CodePredictorWrapper(
|
||||
vllm_config=vllm_config,
|
||||
cp_config=cp_config,
|
||||
wrapper_config=common_mod.CodePredictorWrapperConfig(use_cuda_graphs=False),
|
||||
talker_hidden_size=cp_config.hidden_size,
|
||||
)
|
||||
|
||||
# Default keeps the legacy power-of-two derivation.
|
||||
assert wrapper._execution_batch_buckets is None
|
||||
assert wrapper._batch_bucket_sizes() == [1, 2, 4]
|
||||
|
||||
wrapper.configure_mtp_execution_buckets([3, 1, 3, 0, 9])
|
||||
assert wrapper._batch_bucket_sizes() == [1, 3, 4] # deduplicate/filter declaration and retain max
|
||||
|
||||
with pytest.raises(RuntimeError, match="before the first warmup"):
|
||||
wrapper2 = common_mod.CodePredictorWrapper(
|
||||
vllm_config=vllm_config,
|
||||
cp_config=cp_config,
|
||||
wrapper_config=common_mod.CodePredictorWrapperConfig(use_cuda_graphs=False),
|
||||
talker_hidden_size=cp_config.hidden_size,
|
||||
)
|
||||
wrapper2._bucket_sizes = [1, 2, 4] # stand-in for warmed-up state
|
||||
wrapper2.configure_mtp_execution_buckets([3])
|
||||
@@ -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
|
||||
"""
|
||||
Tests for CUDA Graph decoder wrapper numerical equivalence.
|
||||
|
||||
@@ -142,6 +142,28 @@ def test_padded_output_shape_and_length(decoder, wrapper, seq_len):
|
||||
assert graph_out.shape[-1] == expected_len
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_padded_batch_size_uses_larger_capture_bucket(decoder):
|
||||
"""Non-exact batch sizes should replay the next captured batch bucket."""
|
||||
w = CUDAGraphDecoderWrapper(
|
||||
decoder=decoder,
|
||||
capture_sizes=[25],
|
||||
capture_batch_sizes=[1, 4],
|
||||
num_quantizers=NUM_QUANTIZERS,
|
||||
enabled=True,
|
||||
)
|
||||
w.warmup(DEVICE)
|
||||
codes = _random_codes(25, batch_size=3)
|
||||
|
||||
with torch.no_grad():
|
||||
eager_out = decoder(codes)
|
||||
graph_out = w.decode(codes)
|
||||
|
||||
assert (4, 25) in w.graphs
|
||||
assert graph_out.shape == eager_out.shape
|
||||
torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("seq_len", [10, 30, 47, 73, 99])
|
||||
def test_padded_interior_positions_close(decoder, wrapper, seq_len):
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Output metadata must not wait for the preceding model forward on CUDA."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
|
||||
Qwen3TTSTalkerForConditionalGeneration,
|
||||
)
|
||||
from vllm_omni.worker.gpu_ar_model_runner import _snapshot_tensor_payload_to_cpu_async
|
||||
|
||||
pytestmark = [pytest.mark.core_model]
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("validity_device", ["cpu", "cuda"])
|
||||
@torch.inference_mode()
|
||||
def test_output_validity_preserves_values_without_waiting_for_forward(validity_device):
|
||||
talker = Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
|
||||
talker.vllm_config = SimpleNamespace(model_config=SimpleNamespace(async_chunk=True))
|
||||
hidden = torch.zeros((3, 8), device="cuda")
|
||||
infos = [
|
||||
{
|
||||
"codes": {"audio": torch.zeros((frames, 16), device="cuda", dtype=torch.long)},
|
||||
"meta": {"codec_frame_valid": torch.tensor(valid, device=validity_device)},
|
||||
}
|
||||
for frames, valid in [(2, True), (1, False)]
|
||||
]
|
||||
# Warm allocation paths before checking the stream-ordering contract.
|
||||
talker.make_omni_output(hidden, model_intermediate_buffer=infos)
|
||||
copy_stream = torch.cuda.Stream()
|
||||
torch.accelerator.synchronize()
|
||||
|
||||
preceding_forward = torch.cuda.Event()
|
||||
torch.cuda._sleep(500_000_000)
|
||||
preceding_forward.record()
|
||||
output = talker.make_omni_output(hidden, model_intermediate_buffer=infos)
|
||||
waited_for_forward = preceding_forward.query()
|
||||
validity = output.multimodal_outputs["meta"]["codec_frame_valid"]
|
||||
snapshot = _snapshot_tensor_payload_to_cpu_async(
|
||||
output.multimodal_outputs, copy_stream=copy_stream, pin_memory=True
|
||||
)
|
||||
snapshot.wait()
|
||||
|
||||
assert not waited_for_forward, "output construction synchronized the preceding forward"
|
||||
assert validity.device.type == "cuda"
|
||||
assert validity.dtype == torch.int8
|
||||
cpu_validity = snapshot.payload["meta"]["codec_frame_valid"]
|
||||
assert cpu_validity.device.type == "cpu"
|
||||
assert cpu_validity.tolist() == [1, 1, 0]
|
||||
assert all(info["meta"]["codec_frame_valid"].device.type == validity_device for info in infos)
|
||||
@@ -0,0 +1,100 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Exercise actual stateless decoder capture, not a mocked capture indicator."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.configuration_qwen3_tts_tokenizer_v2 import (
|
||||
Qwen3TTSTokenizerV2DecoderConfig,
|
||||
)
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import (
|
||||
Qwen3TTSTokenizerV2DecoderTransformerModel,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, *hardware_marks(res={"cuda": "L4"}, num_cards=1)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [1, 2])
|
||||
@pytest.mark.parametrize("input_mode", ["implicit", "positions"])
|
||||
@torch.inference_mode()
|
||||
def test_stateless_decoder_capture_replays_new_inputs(batch_size, input_mode):
|
||||
sequence_length = 8
|
||||
torch.manual_seed(42)
|
||||
config = Qwen3TTSTokenizerV2DecoderConfig(
|
||||
hidden_size=32,
|
||||
latent_dim=16,
|
||||
max_position_embeddings=512,
|
||||
num_attention_heads=4,
|
||||
num_key_value_heads=4,
|
||||
intermediate_size=64,
|
||||
num_hidden_layers=1,
|
||||
num_quantizers=2,
|
||||
sliding_window=16,
|
||||
decoder_dim=32,
|
||||
upsample_rates=(2,),
|
||||
upsampling_ratios=(2,),
|
||||
)
|
||||
config._attn_implementation = "sdpa"
|
||||
model = Qwen3TTSTokenizerV2DecoderTransformerModel._from_config(config).cuda().eval()
|
||||
inputs = torch.randn(batch_size, sequence_length, config.latent_dim, device="cuda")
|
||||
positions = {}
|
||||
if input_mode == "positions":
|
||||
cache_position = torch.arange(sequence_length, device="cuda")
|
||||
positions = {"cache_position": cache_position, "position_ids": cache_position.unsqueeze(0)}
|
||||
stream = torch.cuda.Stream()
|
||||
stream.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(stream):
|
||||
for _ in range(3):
|
||||
model(inputs_embeds=inputs, use_cache=False, **positions)
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
torch.accelerator.synchronize()
|
||||
|
||||
# No wrapper or eager fallback: an illegal capture must fail this test.
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
captured_output = model(inputs_embeds=inputs, use_cache=False, **positions).last_hidden_state
|
||||
|
||||
outputs = []
|
||||
for _ in range(3):
|
||||
new_inputs = torch.randn_like(inputs)
|
||||
expected = model(inputs_embeds=new_inputs, use_cache=False, **positions).last_hidden_state
|
||||
if input_mode == "implicit":
|
||||
# Compare the cached mask against the independent live-mask path,
|
||||
# not only against eager execution using the same cached mask.
|
||||
reference = model(
|
||||
inputs_embeds=new_inputs,
|
||||
use_cache=False,
|
||||
position_ids=torch.arange(sequence_length, device="cuda").unsqueeze(0),
|
||||
).last_hidden_state
|
||||
torch.testing.assert_close(expected, reference, atol=1e-5, rtol=1e-4)
|
||||
inputs.copy_(new_inputs)
|
||||
graph.replay()
|
||||
torch.accelerator.synchronize()
|
||||
torch.testing.assert_close(captured_output, expected, atol=1e-5, rtol=1e-4)
|
||||
outputs.append(captured_output.clone())
|
||||
assert not torch.equal(outputs[0], outputs[1]), "replay must consume the new input"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [1, 2])
|
||||
@torch.inference_mode()
|
||||
def test_stateless_codec_wrapper_captures_and_replays_waveform(batch_size):
|
||||
from tests.model_executor.models.qwen3_tts.test_qwen3_tts_incremental_decode import _make_small_decoder
|
||||
from vllm_omni.model_executor.models.qwen3_tts.segmented_graph_wrapper import CUDAGraphDecoderWrapper
|
||||
|
||||
torch.manual_seed(42)
|
||||
decoder = _make_small_decoder().cuda().eval()
|
||||
wrapper = CUDAGraphDecoderWrapper(decoder, num_quantizers=2, async_chunk=False)
|
||||
# Exercise the production capture method, including quantization and
|
||||
# waveform convolutions, without accepting warmup's eager fallback.
|
||||
wrapper._capture_stateless(batch_size, 8, torch.device("cuda"), torch.long)
|
||||
state = wrapper.stateless_states[(batch_size, 8)]
|
||||
for _ in range(3):
|
||||
codes = torch.randint(0, 32, (batch_size, 2, 8), device="cuda")
|
||||
expected = decoder._forward_exact(codes)
|
||||
state["input"]["codes"].copy_(codes)
|
||||
state["graph"].replay()
|
||||
torch.accelerator.synchronize()
|
||||
torch.testing.assert_close(state["output"], expected, atol=1e-5, rtol=1e-4)
|
||||
assert torch.isfinite(state["output"]).all()
|
||||
@@ -1,5 +1,9 @@
|
||||
from collections import OrderedDict
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from collections import OrderedDict, UserDict
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -36,6 +40,7 @@ def _make_minimal_talker(
|
||||
"""
|
||||
model = Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
|
||||
model.talker_config = SimpleNamespace(codec_pad_id=7, num_code_groups=16)
|
||||
model._codebook_vocab_size = 2048
|
||||
model._embedding_dtype = torch.bfloat16
|
||||
if tts_pad_embed is None:
|
||||
tts_pad_embed = torch.zeros((1, 4), dtype=torch.bfloat16)
|
||||
@@ -51,6 +56,22 @@ def _make_minimal_talker(
|
||||
return model
|
||||
|
||||
|
||||
def test_postprocess_batch_gathers_each_request_tail():
|
||||
from vllm_omni.model_executor.models.output_templates import OwnedBatchTensor
|
||||
|
||||
model = _make_minimal_talker()
|
||||
hidden = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]])
|
||||
|
||||
key, values = model.postprocess_batch_mrv2(
|
||||
hidden_states=hidden,
|
||||
last_token_indices=torch.tensor([1, 3]),
|
||||
)
|
||||
|
||||
assert key == ("hidden_states", "last")
|
||||
assert isinstance(values, OwnedBatchTensor) # freshly allocated rows transfer ownership
|
||||
assert torch.equal(values.tensor, torch.tensor([[3.0, 4.0], [7.0, 8.0]]))
|
||||
|
||||
|
||||
def _make_minimal_builder(
|
||||
*,
|
||||
config: SimpleNamespace | None = None,
|
||||
@@ -95,6 +116,7 @@ def _make_minimal_builder(
|
||||
)
|
||||
builder._speaker_cache = None
|
||||
builder._text_tokenizer = None
|
||||
builder._projected_token_cache = {}
|
||||
builder._embedding_dtype = torch.bfloat16
|
||||
builder._ref_audio_artifact_cache_max_entries = 256
|
||||
builder._ref_audio_artifact_cache = OrderedDict()
|
||||
@@ -259,6 +281,30 @@ def test_decode_compacts_long_trailing_text_after_large_offset():
|
||||
assert torch.equal(update["hidden_states"]["trailing_text"], trailing_text[65:])
|
||||
|
||||
|
||||
def test_decode_marks_codec_frame_validity_from_processed_input_token():
|
||||
model = _make_minimal_talker()
|
||||
model.embed_input_ids = lambda input_ids: input_ids.to(torch.float32).reshape(1, 1, 1).expand(1, 1, 4)
|
||||
common = {
|
||||
"input_embeds": None,
|
||||
"text": ["hello"],
|
||||
"task_type": ["CustomVoice"],
|
||||
"hidden_states": {
|
||||
"trailing_text": torch.ones((2, 4)),
|
||||
"last": torch.ones(4),
|
||||
},
|
||||
"meta": {"talker_text_offset": 0},
|
||||
"_omni_is_prefill": False,
|
||||
"_omni_num_computed_tokens": 2,
|
||||
"_omni_prompt_len": 2,
|
||||
}
|
||||
|
||||
_, _, valid_update = model.preprocess(input_ids=torch.tensor([123]), **common)
|
||||
_, _, eos_update = model.preprocess(input_ids=torch.tensor([4198]), **common)
|
||||
|
||||
assert valid_update["meta"]["codec_frame_valid"].item() is True
|
||||
assert eos_update["meta"]["codec_frame_valid"].item() is False
|
||||
|
||||
|
||||
def test_decode_replay_span_embeds_all_tokens_without_mutating_decode_state():
|
||||
model = _make_minimal_talker()
|
||||
|
||||
@@ -286,14 +332,17 @@ def test_decode_replay_span_embeds_all_tokens_without_mutating_decode_state():
|
||||
out_embeds.cpu(),
|
||||
torch.tensor([[101.0] * 4, [202.0] * 4, [303.0] * 4], dtype=torch.bfloat16),
|
||||
)
|
||||
assert update == {"meta": {"codec_streaming": True}}
|
||||
assert update["meta"]["codec_streaming"] is True
|
||||
assert update["meta"]["codec_frame_valid"].item() is False
|
||||
|
||||
|
||||
def test_decode_batch_preprocess_matches_decode_state_updates():
|
||||
@pytest.mark.parametrize("mrv2", [False, True])
|
||||
def test_decode_batch_preprocess_matches_decode_state_updates(mrv2):
|
||||
tts_pad = torch.full((1, 4), -1.0, dtype=torch.bfloat16)
|
||||
model = _make_minimal_talker(tts_pad_embed=tts_pad)
|
||||
|
||||
def fake_embed_input_ids(input_ids):
|
||||
assert not mrv2, "MRV2 must reuse the prepared token embeddings"
|
||||
return input_ids.to(torch.float32).reshape(-1, 1, 1).expand(-1, 1, 4)
|
||||
|
||||
model.embed_input_ids = fake_embed_input_ids
|
||||
@@ -302,7 +351,10 @@ def test_decode_batch_preprocess_matches_decode_state_updates():
|
||||
last_a = torch.full((4,), 2.0, dtype=torch.float32)
|
||||
last_b = torch.full((4,), 3.0, dtype=torch.float32)
|
||||
|
||||
out_ids, out_embeds, past_hidden, text_step, updates = model.preprocess_decode_batch(
|
||||
preprocess = model.preprocess_decode_batch_mrv2 if mrv2 else model.preprocess_decode_batch
|
||||
prepared = torch.tensor([[101.0] * 4, [202.0] * 4], dtype=torch.bfloat16)
|
||||
out_ids, out_embeds, past_hidden, text_step, updates = preprocess(
|
||||
**({"input_embeds": prepared} if mrv2 else {}),
|
||||
input_ids=torch.tensor([101, 202], dtype=torch.long),
|
||||
req_infos=[
|
||||
{
|
||||
@@ -321,15 +373,19 @@ def test_decode_batch_preprocess_matches_decode_state_updates():
|
||||
)
|
||||
|
||||
assert out_ids.tolist() == [101, 202]
|
||||
if mrv2:
|
||||
assert out_embeds.data_ptr() == prepared.data_ptr()
|
||||
assert torch.equal(out_embeds.cpu(), torch.tensor([[101.0] * 4, [202.0] * 4], dtype=torch.bfloat16))
|
||||
assert torch.equal(past_hidden.cpu(), torch.stack([last_a, last_b]).to(torch.bfloat16))
|
||||
assert torch.equal(text_step[0].cpu(), trailing_a[1].to(torch.bfloat16))
|
||||
assert torch.equal(text_step[1].cpu(), tts_pad.reshape(-1).to(torch.bfloat16))
|
||||
assert updates[0]["meta"]["talker_text_offset"] == 2
|
||||
assert updates[0]["meta"]["codec_streaming"] is True
|
||||
assert updates[0]["meta"]["codec_frame_valid"].item() is True
|
||||
assert "hidden_states" not in updates[0]
|
||||
assert updates[1]["meta"]["talker_text_offset"] == 0
|
||||
assert updates[1]["meta"]["codec_streaming"] is False
|
||||
assert updates[1]["meta"]["codec_frame_valid"].item() is True
|
||||
assert updates[1]["hidden_states"]["trailing_text"].numel() == 0
|
||||
|
||||
|
||||
@@ -449,11 +505,11 @@ def test_base_voice_clone_batch_preprocess_encodes_ref_code_by_sample_rate():
|
||||
|
||||
def __call__(self, texts, *, padding=False):
|
||||
self.calls.append((texts, padding))
|
||||
return {"input_ids": [[idx + 1, idx + 2, idx + 3] for idx, _ in enumerate(texts)]}
|
||||
return UserDict({"input_ids": [[1, 2, 3], [2, 3, 4, 5]]})
|
||||
|
||||
text_tok = FakeTextTokenizer()
|
||||
builder._text_tokenizer = text_tok
|
||||
buf = {
|
||||
buf: dict[str, dict[str, Any]] = {
|
||||
"r1": {
|
||||
"task_type": ["Base"],
|
||||
"text": ["one"],
|
||||
@@ -488,9 +544,17 @@ def test_base_voice_clone_batch_preprocess_encodes_ref_code_by_sample_rate():
|
||||
assert buf["r2"][NORMALIZED_REF_AUDIO_KEY][0] is wav2
|
||||
assert len(text_tok.calls) == 2
|
||||
assert torch.equal(buf["r1"][PRECOMPUTED_TEXT_IDS_KEY], torch.tensor([1, 2, 3]))
|
||||
assert torch.equal(buf["r2"][PRECOMPUTED_TEXT_IDS_KEY], torch.tensor([2, 3, 4]))
|
||||
assert torch.equal(buf["r2"][PRECOMPUTED_TEXT_IDS_KEY], torch.tensor([2, 3, 4, 5]))
|
||||
assert torch.equal(buf["r1"][PRECOMPUTED_REF_IDS_KEY], torch.tensor([1, 2, 3]))
|
||||
assert torch.equal(buf["r2"][PRECOMPUTED_REF_IDS_KEY], torch.tensor([2, 3, 4]))
|
||||
assert torch.equal(buf["r2"][PRECOMPUTED_REF_IDS_KEY], torch.tensor([2, 3, 4, 5]))
|
||||
assert (
|
||||
buf["r1"][PRECOMPUTED_TEXT_IDS_KEY].untyped_storage().data_ptr()
|
||||
== buf["r2"][PRECOMPUTED_TEXT_IDS_KEY].untyped_storage().data_ptr()
|
||||
)
|
||||
assert (
|
||||
buf["r1"][PRECOMPUTED_REF_IDS_KEY].untyped_storage().data_ptr()
|
||||
== buf["r2"][PRECOMPUTED_REF_IDS_KEY].untyped_storage().data_ptr()
|
||||
)
|
||||
|
||||
|
||||
def test_base_voice_clone_batch_preprocess_reuses_singleton_normalized_audio_without_speech_tokenizer():
|
||||
@@ -506,7 +570,7 @@ def test_base_voice_clone_batch_preprocess_reuses_singleton_normalized_audio_wit
|
||||
return {"input_ids": [[7, 8, 9] for _ in texts]}
|
||||
|
||||
builder._text_tokenizer = FakeTextTokenizer()
|
||||
buf = {
|
||||
buf: dict[str, dict[str, Any]] = {
|
||||
"r1": {
|
||||
"task_type": ["Base"],
|
||||
"text": ["one"],
|
||||
@@ -539,7 +603,7 @@ def test_base_voice_clone_batch_preprocess_skips_after_initial_prefill_state_exi
|
||||
builder._encode_ref_audio_batch_fn = lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("speech tokenizer not expected")
|
||||
)
|
||||
buf = {
|
||||
buf: dict[str, dict[str, Any]] = {
|
||||
"r1": {
|
||||
"task_type": ["Base"],
|
||||
"text": ["one"],
|
||||
@@ -574,7 +638,7 @@ def test_base_voice_clone_batch_preprocess_uses_serving_artifact_cache_key_witho
|
||||
return {"input_ids": [[7, 8, 9] for _ in texts]}
|
||||
|
||||
builder._text_tokenizer = FakeTextTokenizer()
|
||||
buf = {
|
||||
buf: dict[str, dict[str, Any]] = {
|
||||
"r1": {
|
||||
"task_type": ["Base"],
|
||||
"text": ["one"],
|
||||
|
||||
@@ -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
|
||||
"""Regression tests for cross-request ``codes.ref`` leakage (#4370).
|
||||
|
||||
``make_omni_output`` used to collapse every request's reference codec
|
||||
@@ -11,11 +11,14 @@ vocoder chunks then decoded with another request's reference voice as
|
||||
context (audible onset timbre deformation at any concurrency > 1).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
|
||||
Qwen3TTSTalkerForConditionalGeneration,
|
||||
_qwen3_tts_gpu_resident_buffer_keys,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
@@ -23,6 +26,18 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
_NUM_CODE_GROUPS = 16
|
||||
|
||||
|
||||
def test_large_tts_prefill_artifacts_stay_gpu_resident_only_on_mrv2() -> None:
|
||||
v1_keys = _qwen3_tts_gpu_resident_buffer_keys(False)
|
||||
v2_keys = _qwen3_tts_gpu_resident_buffer_keys(True)
|
||||
|
||||
assert ("embed", "prefill") not in v1_keys
|
||||
assert ("codes", "ref") not in v1_keys
|
||||
assert ("meta", "codec_frame_valid") not in v1_keys
|
||||
assert ("embed", "prefill") in v2_keys
|
||||
assert ("codes", "ref") in v2_keys
|
||||
assert ("meta", "codec_frame_valid") in v2_keys
|
||||
|
||||
|
||||
def _make_talker() -> Qwen3TTSTalkerForConditionalGeneration:
|
||||
# make_omni_output reads no instance state, so skip __init__ (no real
|
||||
# config / checkpoint needed), same as the preprocess tests.
|
||||
@@ -89,3 +104,42 @@ def test_make_omni_output_keeps_hidden_states_for_replay_spans_without_audio_cod
|
||||
|
||||
assert torch.equal(out.text_hidden_states, hidden)
|
||||
assert out.multimodal_outputs["codes"]["audio"].shape == (1, _NUM_CODE_GROUPS)
|
||||
|
||||
|
||||
def test_make_omni_output_preserves_per_request_codec_frame_validity() -> None:
|
||||
talker = _make_talker()
|
||||
talker.vllm_config = SimpleNamespace(model_config=SimpleNamespace(async_chunk=True))
|
||||
valid = _info(1, None)
|
||||
invalid = _info(1, None)
|
||||
valid["meta"]["codec_frame_valid"] = True
|
||||
invalid["meta"]["codec_frame_valid"] = False
|
||||
|
||||
out = talker.make_omni_output(
|
||||
torch.zeros((2, 8)),
|
||||
model_intermediate_buffer=[valid, invalid],
|
||||
)
|
||||
|
||||
assert out.multimodal_outputs["meta"]["codec_frame_valid"].tolist() == [1, 0]
|
||||
|
||||
|
||||
def test_make_omni_output_publishes_ref_once_for_kv_resumed_first_decode() -> None:
|
||||
talker = _make_talker()
|
||||
talker.vllm_config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(async_chunk=True),
|
||||
)
|
||||
ref_code = torch.ones((5, _NUM_CODE_GROUPS), dtype=torch.long)
|
||||
info = _info(1, ref_code)
|
||||
info["_omni_num_computed_tokens"] = 9
|
||||
info["_omni_prompt_len"] = 5
|
||||
|
||||
first = talker.make_omni_output(
|
||||
torch.zeros((1, 8)),
|
||||
model_intermediate_buffer=[info],
|
||||
)
|
||||
second = talker.make_omni_output(
|
||||
torch.zeros((1, 8)),
|
||||
model_intermediate_buffer=[info],
|
||||
)
|
||||
|
||||
assert first.multimodal_outputs["codes"]["ref"][0] is ref_code
|
||||
assert "ref" not in second.multimodal_outputs["codes"]
|
||||
@@ -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
|
||||
"""Unit tests for the Qwen3-TTS leading-silence ban (#4966, #5048).
|
||||
|
||||
``compute_logits`` suppresses the checkpoint-derived silence codec vocabulary
|
||||
@@ -59,6 +59,7 @@ def _make_talker(*, ban_frames: int, logits: torch.Tensor, modes=None):
|
||||
object.__setattr__(talker, "_silence_mask", silence_mask)
|
||||
object.__setattr__(talker, "_codec_disallowed_mask", torch.zeros((_VOCAB,), dtype=torch.bool))
|
||||
object.__setattr__(talker, "_batch_req_ids", req_ids)
|
||||
object.__setattr__(talker, "_mrv2_silence_ban_mask", None)
|
||||
object.__setattr__(
|
||||
talker,
|
||||
"_req_x_vector_only",
|
||||
@@ -78,7 +79,26 @@ def _banned(row: torch.Tensor) -> set[int]:
|
||||
return {int(i) for i in torch.nonzero(torch.isinf(row) & (row < 0)).flatten()}
|
||||
|
||||
|
||||
# -------------------- the step gate --------------------
|
||||
def test_mrv2_silence_ban_tracks_batch_order_scope_and_decode_window() -> None:
|
||||
talker = _make_talker(ban_frames=3, logits=torch.zeros((3, _VOCAB)), modes=[True, False, True])
|
||||
with talker.mrv2_sampling_context(
|
||||
req_ids=["req2", "req1", "req0"],
|
||||
num_output_tokens=torch.tensor([3, 0, 1]),
|
||||
):
|
||||
logits = talker.compute_logits(torch.zeros((3, 4)))
|
||||
|
||||
assert _banned(logits[0]) == set()
|
||||
assert _banned(logits[1]) == set()
|
||||
assert _banned(logits[2]) == set(_SILENCE_IDS)
|
||||
|
||||
# Prompt logprobs are computed after sampling and must remain unmasked.
|
||||
assert not torch.isneginf(talker.compute_logits(torch.zeros((3, 4)))).any()
|
||||
|
||||
with talker.mrv2_sampling_context(
|
||||
req_ids=["req2", "req1", "req0"],
|
||||
num_output_tokens=torch.tensor([4, 1, 3]),
|
||||
):
|
||||
assert not torch.isneginf(talker.compute_logits(torch.zeros((3, 4)))).any()
|
||||
|
||||
|
||||
def test_ban_disabled_leaves_logits_unchanged() -> None:
|
||||
|
||||
@@ -7,6 +7,9 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors import (
|
||||
qwen3_tts as qwen3_tts_processors,
|
||||
)
|
||||
from vllm_omni.model_executor.stage_input_processors.chunk_size_utils import (
|
||||
AdaptiveChunkController,
|
||||
compute_adaptive_emit,
|
||||
@@ -32,7 +35,52 @@ _FRAME = [1, 2, 3, 4]
|
||||
_Q = len(_FRAME)
|
||||
|
||||
|
||||
def _req(rid, *, finished, initial_codec_chunk_frames=None, non_streaming_mode=None, full_utterance_decode=None):
|
||||
@pytest.mark.parametrize("frames", [1, 26])
|
||||
def test_terminal_without_new_frames_does_not_replay_last_chunk(frames):
|
||||
tm = _tm(initial_chunk_frames=1)
|
||||
emitted = 0
|
||||
for count in range(1, frames + 1):
|
||||
payload = _call(tm, "r", n_frames=count)
|
||||
if payload is not None:
|
||||
emitted += payload.codes.audio.numel() // _Q
|
||||
tm.put_req_chunk["r"] += 1
|
||||
terminal = _call(tm, "r", n_frames=frames, finished=True)
|
||||
assert terminal is not None and terminal.meta.finished.item()
|
||||
assert terminal.codes.audio.numel() == 0
|
||||
assert emitted == frames
|
||||
|
||||
|
||||
def test_repeated_nonterminal_callback_does_not_republish_boundary():
|
||||
tm = _tm(initial_chunk_frames=1)
|
||||
first = _call(tm, "r", n_frames=1)
|
||||
assert first is not None
|
||||
tm.put_req_chunk["r"] += 1
|
||||
assert _call(tm, "r", n_frames=1) is None
|
||||
next_chunk = _call(tm, "r", n_frames=26)
|
||||
assert next_chunk.codes.audio.numel() // _Q == 25
|
||||
|
||||
|
||||
def test_terminal_flush_contains_only_frames_not_previously_emitted():
|
||||
tm = _tm(initial_chunk_frames=1)
|
||||
_call(tm, "r", n_frames=1)
|
||||
tm.put_req_chunk["r"] += 1
|
||||
_call(tm, "r", n_frames=26)
|
||||
tm.put_req_chunk["r"] += 1
|
||||
final = _call(tm, "r", n_frames=30, finished=True)
|
||||
assert final.codes.audio.numel() // _Q == 4
|
||||
tm.put_req_chunk["r"] += 1
|
||||
assert _call(tm, "r", n_frames=30, finished=True).codes.audio.numel() == 0
|
||||
|
||||
|
||||
def _req(
|
||||
rid,
|
||||
*,
|
||||
finished,
|
||||
initial_codec_chunk_frames=None,
|
||||
non_streaming_mode=None,
|
||||
full_utterance_decode=None,
|
||||
output_token_ids=None,
|
||||
):
|
||||
entries = {}
|
||||
if initial_codec_chunk_frames is not None:
|
||||
entries["initial_codec_chunk_frames"] = SimpleNamespace(list_data=[initial_codec_chunk_frames])
|
||||
@@ -40,11 +88,11 @@ def _req(rid, *, finished, initial_codec_chunk_frames=None, non_streaming_mode=N
|
||||
entries["non_streaming_mode"] = SimpleNamespace(list_data=[non_streaming_mode])
|
||||
if full_utterance_decode is not None:
|
||||
entries["full_utterance_decode"] = SimpleNamespace(list_data=[full_utterance_decode])
|
||||
ai = SimpleNamespace(entries=entries) if entries else None
|
||||
return SimpleNamespace(
|
||||
external_req_id=rid,
|
||||
is_finished=lambda: finished,
|
||||
additional_information=ai,
|
||||
additional_information=SimpleNamespace(entries=entries) if entries else None,
|
||||
output_token_ids=output_token_ids or [],
|
||||
)
|
||||
|
||||
|
||||
@@ -206,6 +254,112 @@ def test_voicedesign_prompt_mode_with_streaming_keeps_windowed_emit():
|
||||
assert len(p.codes.audio) == _Q * 25
|
||||
|
||||
|
||||
def test_async_chunk_skips_stop_token_zero_frame_without_gpu_value_check():
|
||||
tm = _tm()
|
||||
rid = "r-stop"
|
||||
tm.code_prompt_token_ids[rid] = [_FRAME[:]]
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
multimodal_output={"codes": {"audio": torch.zeros((1, _Q), dtype=torch.long)}},
|
||||
request=_req(rid, finished=True, initial_codec_chunk_frames=2, output_token_ids=[2150]),
|
||||
is_finished=True,
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload.codes.audio.tolist() == _FRAME
|
||||
assert len(tm.code_prompt_token_ids[rid]) == 1
|
||||
|
||||
|
||||
def test_async_chunk_uses_authoritative_frame_validity_without_token_history():
|
||||
tm = _tm()
|
||||
rid = "r-native-validity"
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
multimodal_output={
|
||||
"codes": {"audio": torch.tensor([[1, 2, 3, 4]])},
|
||||
"meta": {"codec_frame_valid": False},
|
||||
},
|
||||
request=_req(rid, finished=False, initial_codec_chunk_frames=1, output_token_ids=[]),
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
assert payload is None
|
||||
assert tm.code_prompt_token_ids[rid] == []
|
||||
|
||||
|
||||
def test_async_chunk_keeps_final_real_frame_when_eos_was_just_sampled():
|
||||
tm = _tm()
|
||||
rid = "r-final-real-frame"
|
||||
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
multimodal_output={
|
||||
"codes": {"audio": torch.tensor([[1, 2, 3, 4]])},
|
||||
"meta": {"codec_frame_valid": True},
|
||||
},
|
||||
request=_req(rid, finished=True, initial_codec_chunk_frames=1, output_token_ids=[4198]),
|
||||
is_finished=True,
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload.codes.audio.tolist() == [1, 2, 3, 4]
|
||||
|
||||
|
||||
def test_async_chunk_batch_matches_scalar_payloads_and_state():
|
||||
batch_builder = getattr(qwen3_tts_processors, "talker2code2wav_async_chunk_batch", None)
|
||||
assert callable(batch_builder), "Qwen3-TTS must provide the native MRv2 batch payload builder"
|
||||
|
||||
scalar_tm = _tm(chunk_frames=4, left_context=3, initial_chunk_frames=1)
|
||||
batch_tm = _tm(chunk_frames=4, left_context=3, initial_chunk_frames=1)
|
||||
requests = [
|
||||
_req("r-batch-0", finished=False, output_token_ids=[7]),
|
||||
_req("r-batch-1", finished=False, output_token_ids=[8]),
|
||||
]
|
||||
ref_code = torch.tensor([[9, 9, 9, 9], [8, 8, 8, 8]], dtype=torch.long)
|
||||
# Request 2 has a multi-token span: the last validity flag wins the chunk.
|
||||
outputs = [
|
||||
{"codes": {"audio": torch.tensor([[1, 2, 3, 4]]), "ref": ref_code}},
|
||||
{
|
||||
"codes": {"audio": torch.tensor([[9, 9, 9, 9], [5, 6, 7, 8]])},
|
||||
"meta": {"codec_frame_valid": torch.tensor([False, True])},
|
||||
},
|
||||
]
|
||||
|
||||
scalar_payloads = [
|
||||
talker2code2wav_async_chunk(
|
||||
transfer_manager=scalar_tm,
|
||||
multimodal_output=output,
|
||||
request=request,
|
||||
is_finished=False,
|
||||
)
|
||||
for request, output in zip(requests, outputs)
|
||||
]
|
||||
batch_payloads = batch_builder(
|
||||
transfer_manager=batch_tm,
|
||||
pooling_outputs=outputs,
|
||||
requests=requests,
|
||||
is_finished=[False, False],
|
||||
)
|
||||
|
||||
assert len(batch_payloads) == len(scalar_payloads)
|
||||
for scalar, batched in zip(scalar_payloads, batch_payloads):
|
||||
assert scalar is not None and batched is not None
|
||||
torch.testing.assert_close(batched.codes.audio, scalar.codes.audio)
|
||||
assert batched.meta.left_context_size == scalar.meta.left_context_size
|
||||
assert batched.meta.finished.item() == scalar.meta.finished.item()
|
||||
assert batched.speaker == scalar.speaker
|
||||
assert batched.language == scalar.language
|
||||
for request in requests:
|
||||
rid = request.external_req_id
|
||||
torch.testing.assert_close(
|
||||
torch.stack(batch_tm.code_prompt_token_ids[rid]),
|
||||
torch.stack(scalar_tm.code_prompt_token_ids[rid]),
|
||||
)
|
||||
torch.testing.assert_close(batch_tm.request_payload["r-batch-0"], scalar_tm.request_payload["r-batch-0"])
|
||||
|
||||
|
||||
_CASES = [
|
||||
# ── IC boundary rule ──────────────────────────────────────────────
|
||||
# initial_codec_chunk_frames only controls the first emitted chunk.
|
||||
@@ -703,35 +857,27 @@ class TestRampHelpers:
|
||||
def test_parse_ramp_mixed_list_returns_none(self):
|
||||
assert parse_chunk_ramp({"codec_chunk_ramp": [4, "x"]}) is None
|
||||
|
||||
def test_parse_ramp_warns_on_tail_mismatch(self, caplog):
|
||||
import logging
|
||||
def test_parse_ramp_warns_on_tail_mismatch(self, monkeypatch):
|
||||
warnings = []
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.model_executor.stage_input_processors.chunk_size_utils.logger.warning",
|
||||
lambda message, *args: warnings.append(message % args),
|
||||
)
|
||||
|
||||
target_logger = logging.getLogger("vllm_omni.model_executor.stage_input_processors.chunk_size_utils")
|
||||
target_logger.addHandler(caplog.handler)
|
||||
prev_level = target_logger.level
|
||||
target_logger.setLevel(logging.WARNING)
|
||||
try:
|
||||
result = parse_chunk_ramp({"codec_chunk_ramp": [4, 4, 8]}, steady=25)
|
||||
finally:
|
||||
target_logger.removeHandler(caplog.handler)
|
||||
target_logger.setLevel(prev_level)
|
||||
result = parse_chunk_ramp({"codec_chunk_ramp": [4, 4, 8]}, steady=25)
|
||||
assert result == [4, 4, 8]
|
||||
assert "reintroduces" in caplog.text
|
||||
assert any("reintroduces" in message for message in warnings)
|
||||
|
||||
def test_parse_ramp_no_warn_on_tail_match(self, caplog):
|
||||
import logging
|
||||
def test_parse_ramp_no_warn_on_tail_match(self, monkeypatch):
|
||||
warnings = []
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.model_executor.stage_input_processors.chunk_size_utils.logger.warning",
|
||||
lambda message, *args: warnings.append(message % args),
|
||||
)
|
||||
|
||||
target_logger = logging.getLogger("vllm_omni.model_executor.stage_input_processors.chunk_size_utils")
|
||||
target_logger.addHandler(caplog.handler)
|
||||
prev_level = target_logger.level
|
||||
target_logger.setLevel(logging.WARNING)
|
||||
try:
|
||||
result = parse_chunk_ramp({"codec_chunk_ramp": [4, 4, 8, 16, 25]}, steady=25)
|
||||
finally:
|
||||
target_logger.removeHandler(caplog.handler)
|
||||
target_logger.setLevel(prev_level)
|
||||
result = parse_chunk_ramp({"codec_chunk_ramp": [4, 4, 8, 16, 25]}, steady=25)
|
||||
assert result == [4, 4, 8, 16, 25]
|
||||
assert "reintroduces" not in caplog.text
|
||||
assert warnings == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,ramp,steady,expected",
|
||||
@@ -1028,6 +1174,7 @@ class TestChunkRampEmission:
|
||||
tm.put_req_chunk[rid] = 2
|
||||
|
||||
tm.code_prompt_token_ids[rid] = []
|
||||
tm._qwen3_tts_emitted_frames.pop(rid, None)
|
||||
tm.ramp_chunk_count.pop(rid, None)
|
||||
|
||||
p_seg2_0 = self._emit(tm, rid, 4)
|
||||
@@ -1051,6 +1198,7 @@ class TestChunkRampEmission:
|
||||
tm.put_req_chunk[rid] = 1
|
||||
|
||||
tm.code_prompt_token_ids[rid] = []
|
||||
tm._qwen3_tts_emitted_frames.pop(rid, None)
|
||||
tm.ramp_chunk_count.pop(rid, None)
|
||||
|
||||
p_seg2_0 = self._emit(tm, rid, 4)
|
||||
@@ -1429,6 +1577,7 @@ class TestAdaptiveEmission:
|
||||
tm.put_req_chunk[rid] = 1
|
||||
|
||||
tm.code_prompt_token_ids[rid] = []
|
||||
tm._qwen3_tts_emitted_frames.pop(rid, None)
|
||||
tm.ramp_chunk_count.pop(rid, None)
|
||||
tm._adaptive_states.pop(rid, None)
|
||||
|
||||
|
||||
@@ -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
|
||||
"""Guard against out-of-vocabulary stop ids in min-tokens masking (#4962).
|
||||
|
||||
vLLM's input processor folds the stage tokenizer's EOS id into
|
||||
@@ -19,7 +19,7 @@ from vllm.v1.sample.logits_processor import (
|
||||
MinTokensLogitsProcessor,
|
||||
)
|
||||
|
||||
from vllm_omni.worker.sampling_utils import sanitize_min_tokens_stop_ids
|
||||
from vllm_omni.worker.sampling_utils import sanitize_min_tokens_stop_ids, sanitize_sampling_params_min_tokens_stop_ids
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -28,6 +28,12 @@ CODEC_EOS = 2150
|
||||
TEXT_EOS = 151645
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_vllm_pin_memory_for_cpu(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Keep the module-level H2D helper in CPU-only mode."""
|
||||
monkeypatch.setattr("vllm.utils.torch_utils.PIN_MEMORY", False)
|
||||
|
||||
|
||||
def _make_min_tokens_proc(stop_token_ids: list[int], eos_token_id: int | None) -> MinTokensLogitsProcessor:
|
||||
"""Build a MinTokensLogitsProcessor with one active request, mirroring
|
||||
the production path (engine folds the tokenizer EOS via
|
||||
@@ -113,3 +119,14 @@ def test_guard_handles_multiple_requests():
|
||||
assert out[0, CODEC_EOS] == -float("inf")
|
||||
assert out[1, 7] == -float("inf")
|
||||
assert int((out == -float("inf")).sum()) == 2
|
||||
|
||||
|
||||
def test_sampling_params_guard_filters_only_unreachable_min_tokens_ids():
|
||||
params = SamplingParams(min_tokens=2, stop_token_ids=[CODEC_EOS])
|
||||
params.update_from_generation_config({}, TEXT_EOS)
|
||||
|
||||
sanitize_sampling_params_min_tokens_stop_ids(params, TALKER_VOCAB)
|
||||
|
||||
assert params.all_stop_token_ids == {CODEC_EOS}
|
||||
assert params.stop_token_ids == [CODEC_EOS]
|
||||
assert params.eos_token_id == TEXT_EOS
|
||||
@@ -1724,3 +1724,16 @@ class TestSendRetry(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fallback,external,expected", [("r", None, "mapped"), (None, "ext", "ext"), (None, None, None)]
|
||||
)
|
||||
def test_resolve_request_id_requires_internal_or_external_id(fallback, external, expected):
|
||||
host = SimpleNamespace(_request_ids_mapping={"r": "mapped"})
|
||||
request = SimpleNamespace(external_req_id=external)
|
||||
if expected is None:
|
||||
with pytest.raises(ValueError, match="request ID"):
|
||||
OmniConnectorModelRunnerMixin._resolve_external_req_id(host, request, fallback)
|
||||
else:
|
||||
assert OmniConnectorModelRunnerMixin._resolve_external_req_id(host, request, fallback) == expected
|
||||
@@ -770,8 +770,10 @@ def test_accumulate_full_payload_output_keeps_all_zero_qwen3_omni_prefill_placeh
|
||||
def test_full_payload_output_accumulation_hook_matrix():
|
||||
assert _make_full_payload_accumulation_runner(model_stage="thinker")._should_accumulate_full_payload_output()
|
||||
assert _make_full_payload_accumulation_runner(model_stage="talker")._should_accumulate_full_payload_output()
|
||||
assert not _make_full_payload_accumulation_runner(
|
||||
model_stage="code2wav", final_output=True
|
||||
# A stage may publish a user-visible output and still feed another stage
|
||||
# (Qwen3-Omni thinker publishes text while sending audio state to talker).
|
||||
assert _make_full_payload_accumulation_runner(
|
||||
model_stage="thinker", final_output=True
|
||||
)._should_accumulate_full_payload_output()
|
||||
assert not _make_full_payload_accumulation_runner(
|
||||
model_stage="token2audio",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from vllm.compilation.cuda_graph import CUDAGraphMode
|
||||
|
||||
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dp_size", [1, 2])
|
||||
def test_dispatch_eager_and_manager_paths(monkeypatch, dp_size):
|
||||
runner = object.__new__(OmniGPUModelRunner)
|
||||
runner.cudagraph_manager = None
|
||||
runner.dp_size = dp_size
|
||||
runner.dp_rank = 1
|
||||
|
||||
import vllm.v1.worker.gpu.dp_utils as dp_utils
|
||||
|
||||
expected_tokens = object()
|
||||
sync_calls = []
|
||||
|
||||
def sync_padding(*args, **kwargs):
|
||||
sync_calls.append((args, kwargs))
|
||||
return args[1], expected_tokens
|
||||
|
||||
monkeypatch.setattr(
|
||||
dp_utils,
|
||||
"sync_cudagraph_and_dp_padding",
|
||||
sync_padding,
|
||||
)
|
||||
|
||||
batch_desc, num_tokens_across_dp = runner._dispatch_batch_descriptor(
|
||||
num_reqs=1, num_toks=8, uniform_tok_count=8, num_active_loras=0, use_eager=True, max_query_len=8
|
||||
)
|
||||
assert batch_desc.cg_mode == CUDAGraphMode.NONE
|
||||
if dp_size == 1:
|
||||
assert num_tokens_across_dp is None
|
||||
assert sync_calls == []
|
||||
else:
|
||||
assert num_tokens_across_dp is expected_tokens
|
||||
|
||||
# Non-eager dispatch goes through the cudagraph manager.
|
||||
expected = SimpleNamespace(cg_mode=CUDAGraphMode.PIECEWISE, num_tokens=8, num_reqs=1)
|
||||
dispatch_calls = []
|
||||
|
||||
def dispatch(*args, **kwargs):
|
||||
dispatch_calls.append((args, kwargs))
|
||||
return expected
|
||||
|
||||
runner.cudagraph_manager = SimpleNamespace(dispatch=dispatch)
|
||||
runner.dp_size = 1
|
||||
batch_desc, _ = runner._dispatch_batch_descriptor(
|
||||
num_reqs=1, num_toks=8, uniform_tok_count=8, num_active_loras=0, use_eager=False, max_query_len=8
|
||||
)
|
||||
assert batch_desc is expected
|
||||
assert dispatch_calls == [((1, 8, 8), {"num_active_loras": 0, "max_query_len": 8})]
|
||||
@@ -0,0 +1,34 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.worker import sampling_utils
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_fixed_seed_contract_separates_stage_sampler_from_talker_mtp_rng() -> None:
|
||||
assert hasattr(sampling_utils, "apply_fixed_seed_to_sampling_params")
|
||||
assert hasattr(sampling_utils, "get_tts_local_seed")
|
||||
|
||||
stage_only = SimpleNamespace(seed=None, extra_args=None)
|
||||
sampling_utils.apply_fixed_seed_to_sampling_params(
|
||||
stage_only,
|
||||
17,
|
||||
seed_talker_mtp=False,
|
||||
)
|
||||
assert stage_only.seed == 17
|
||||
assert sampling_utils.get_tts_local_seed(stage_only) is None
|
||||
|
||||
talker = SimpleNamespace(seed=None, extra_args={"preserved": True})
|
||||
sampling_utils.apply_fixed_seed_to_sampling_params(
|
||||
talker,
|
||||
23,
|
||||
seed_talker_mtp=True,
|
||||
)
|
||||
assert talker.seed == 23
|
||||
assert talker.extra_args == {"preserved": True, "tts_local_seed": 23}
|
||||
assert sampling_utils.get_tts_local_seed(talker) == 23
|
||||
@@ -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
|
||||
"""Characterization tests for ``vllm_omni.worker.base.OmniGPUWorkerBase``.
|
||||
|
||||
Pins the CURRENT behaviour of ``determine_available_memory`` (device-level
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,96 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Unit tests for OmniIntermediateBuffer lifecycle.
|
||||
|
||||
Retained contracts: wire decode with malformed-payload rejection, slot
|
||||
lifecycle with index migration on reuse, gather in batch order, update merge
|
||||
semantics (cpu/gpu-resident keys), single owned snapshot for
|
||||
update_gpu_tensor_rows, and decode-span merge until ack.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.engine import (
|
||||
AdditionalInformationEntry,
|
||||
AdditionalInformationPayload,
|
||||
PromptEmbedsPayload,
|
||||
)
|
||||
from vllm_omni.worker_v2.model_states.intermediate_buffer import (
|
||||
OmniIntermediateBuffer,
|
||||
_resolve_additional_information,
|
||||
_resolve_prompt_embeds,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _make_new_req_data(req_id="r1", **kwargs):
|
||||
kwargs.setdefault("mm_features", [])
|
||||
return SimpleNamespace(req_id=req_id, **kwargs)
|
||||
|
||||
|
||||
def test_wire_decode_passthrough_and_malformed_rejection():
|
||||
tensor = torch.randn(3, 4)
|
||||
assert _resolve_prompt_embeds(tensor) is not None
|
||||
assert _resolve_prompt_embeds(None) is None
|
||||
with pytest.raises(ValueError, match="Failed to decode prompt_embeds payload"):
|
||||
_resolve_prompt_embeds(PromptEmbedsPayload(data=b"\x00", shape=[2, 2], dtype="float32"))
|
||||
|
||||
assert _resolve_additional_information({"key": "val"}) == {"key": "val"}
|
||||
assert _resolve_additional_information(None) == {}
|
||||
payload = AdditionalInformationPayload(
|
||||
entries={
|
||||
"embed.decode": AdditionalInformationEntry(tensor_data=b"\x00", tensor_shape=[2], tensor_dtype="float32")
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="Failed to decode additional_information payload"):
|
||||
_resolve_additional_information(payload)
|
||||
|
||||
|
||||
def test_slot_lifecycle_fields_reuse_and_remove_idempotent():
|
||||
buf = OmniIntermediateBuffer(max_num_reqs=4)
|
||||
embeds = torch.randn(2, 8)
|
||||
features = [torch.randn(3, 4)]
|
||||
params = SimpleNamespace(extra_args={"tts_local_seed": 1234})
|
||||
buf.add_request(0, _make_new_req_data("r0", prompt_embeds=embeds, mm_features=features, sampling_params=params))
|
||||
assert torch.equal(buf.buffers[0]["prompt_embeds_cpu"], embeds)
|
||||
assert buf.buffers[0]["mm_features"] is features
|
||||
assert buf.buffers[0]["sampling_params"] is params
|
||||
|
||||
buf.add_request(2, _make_new_req_data("r2"))
|
||||
buf.add_request(3, _make_new_req_data("r3"))
|
||||
batch = SimpleNamespace(idx_mapping_np=[2, 0, 3])
|
||||
assert [g["req_id"] for g in buf.gather(batch)] == ["r2", "r0", "r3"]
|
||||
|
||||
# Slot reuse: req_id mapping migrates; duplicate removal is a no-op.
|
||||
buf.add_request(0, _make_new_req_data("new"))
|
||||
assert buf.req_id_to_index["new"] == 0
|
||||
buf.remove_request(0)
|
||||
buf.remove_request(0)
|
||||
assert "new" not in buf.req_id_to_index
|
||||
assert buf.buffers[0] == {}
|
||||
|
||||
|
||||
def test_update_merge_semantics(monkeypatch):
|
||||
buf = OmniIntermediateBuffer(max_num_reqs=1)
|
||||
buf.add_request(0, _make_new_req_data("r0"))
|
||||
|
||||
buf.update(0, {"hidden": torch.randn(4)})
|
||||
assert buf.buffers[0]["hidden"].device == torch.device("cpu") # default migrates to cpu
|
||||
buf.update(0, {})
|
||||
buf.update(0, {"a": torch.tensor([1.0])})
|
||||
buf.update(0, {"b": torch.tensor([2.0])})
|
||||
assert "a" in buf.buffers[0] and "b" in buf.buffers[0]
|
||||
|
||||
# GPU-resident keys keep values (including list items) off the cpu path.
|
||||
tensor = torch.randn(2)
|
||||
monkeypatch.setattr(torch.Tensor, "cpu", lambda *_a, **_k: pytest.fail("GPU-resident value must not move to CPU"))
|
||||
buf.update(0, {"items": [tensor], "kv": torch.randn(4)}, gpu_resident_keys={"items", "kv"})
|
||||
stored = buf.buffers[0]["items"][0]
|
||||
assert stored is not tensor
|
||||
assert torch.equal(stored, tensor)
|
||||
assert "kv" in buf.buffers[0]
|
||||
@@ -0,0 +1,28 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CUDA-lane checks for GPU-resident intermediate buffer values."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.worker_v2.model_states.intermediate_buffer import OmniIntermediateBuffer
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cuda]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key,dtype", [(("embed", "prefill"), torch.float32), (("codes", "ref"), torch.long)])
|
||||
def test_gpu_resident_values_stay_on_device_after_update(key, dtype):
|
||||
# Declared gpu-resident keys must keep tensors on device (CLA snapshot real).
|
||||
buffer = OmniIntermediateBuffer(1)
|
||||
value = torch.ones(2, 3, device="cuda", dtype=dtype)
|
||||
buffer.add_request(0, SimpleNamespace(req_id="r1", mm_features=[]))
|
||||
|
||||
buffer.update(0, {key[0]: {key[1]: value}}, {key})
|
||||
|
||||
stored = buffer.buffers[0][key[0]][key[1]]
|
||||
assert stored.device.type == "cuda"
|
||||
assert stored.dtype == dtype
|
||||
assert torch.equal(stored.cpu(), value.cpu())
|
||||
@@ -0,0 +1,253 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Unit tests for OmniARModelRunner v2: async output staging, snapshot ownership, payload slicing."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.v1.outputs import RoutedExpertsTensors
|
||||
from vllm.v1.worker.gpu.sample.output import SamplerOutput, SamplingMaskTensors
|
||||
|
||||
import vllm_omni.worker_v2.omni_ar_model_runner as omni_ar_model_runner
|
||||
from vllm_omni.worker_v2.omni_ar_model_runner import OmniARModelRunner, OmniAsyncOutput
|
||||
from vllm_omni.worker_v2.output_snapshot import pack_output_snapshot
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
def wait_stream(self, _stream) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeEvent:
|
||||
def record(self, _stream) -> None:
|
||||
pass
|
||||
|
||||
def synchronize(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _async_output(req_ids=("req-0",), **overrides) -> OmniAsyncOutput:
|
||||
rid2idx = {rid: i for i, rid in enumerate(req_ids)}
|
||||
mro = omni_ar_model_runner.OmniModelRunnerOutput(list(req_ids), rid2idx, None, prompt_logprobs_dict={})
|
||||
sampler_output = SamplerOutput(torch.tensor([[123]]), None, None, None)
|
||||
kwargs = dict(model_runner_output=mro, sampler_output=sampler_output)
|
||||
kwargs.update(num_sampled_tokens=torch.tensor([1] * len(req_ids)), copy_event=_FakeEvent())
|
||||
kwargs.update(main_stream=_FakeStream(), copy_stream=_FakeStream())
|
||||
return OmniAsyncOutput(**(kwargs | overrides))
|
||||
|
||||
|
||||
def test_async_output_blocking_event_and_routing_masks(monkeypatch) -> None:
|
||||
event_kwargs = []
|
||||
monkeypatch.setattr(torch.cuda, "set_stream", lambda _stream: None)
|
||||
|
||||
def make_event(**kwargs):
|
||||
event_kwargs.append(kwargs)
|
||||
return _FakeEvent()
|
||||
|
||||
monkeypatch.setattr(torch.cuda, "Event", make_event)
|
||||
masks = SamplingMaskTensors(torch.tensor([[5], [0]], dtype=torch.uint8), torch.tensor([2, 0]), 4)
|
||||
sampler_output = SamplerOutput(torch.tensor([[2], [0]]), None, None, torch.tensor([1, 0]), None, masks)
|
||||
routed = RoutedExpertsTensors(torch.tensor([[[2, 3]], [[4, 5]]]), torch.tensor([7, 9]))
|
||||
|
||||
output = _async_output(
|
||||
req_ids=["decode", "prefill"],
|
||||
sampler_output=sampler_output,
|
||||
num_sampled_tokens=torch.tensor([1, 0]),
|
||||
copy_event=None, # None → constructor builds the default blocking event
|
||||
routed_experts=routed,
|
||||
).get_output()
|
||||
assert event_kwargs == [{"blocking": True}] # blocking event by default
|
||||
assert output.sampled_token_ids == [[2], []] and output.sampling_masks.cu_num_generated_tokens == [0, 1, 1]
|
||||
np.testing.assert_array_equal(output.routed_experts.routing_data, [[[2, 3]], [[4, 5]]])
|
||||
np.testing.assert_array_equal(output.routed_experts.slot_mapping, [7, 9])
|
||||
np.testing.assert_array_equal(output.sampling_masks.token_ids, [0, 2])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("needs_history", [False, True])
|
||||
def test_last_pp_rank_orchestration_and_kv_resolver(monkeypatch, needs_history) -> None:
|
||||
runner = OmniARModelRunner.__new__(OmniARModelRunner)
|
||||
input_batch = SimpleNamespace(req_ids=["req"], num_reqs=1, seq_lens=torch.tensor([3]))
|
||||
input_batch.idx_mapping, input_batch.query_start_loc = torch.tensor([0]), torch.tensor([0, 1])
|
||||
state = SimpleNamespace(input_batch=input_batch, hidden_states=torch.zeros(1, 2))
|
||||
state.finished_req_ids, state.ec_connector_output, state.routed_experts = {"finished"}, None, None
|
||||
runner.execute_model_state = state
|
||||
runner._kv_extracted_req_ids = runner._last_aux_output = runner._last_multimodal_outputs = None
|
||||
runner.is_last_pp_rank, runner.pp_handler, runner.check_ep_fault = True, None, False
|
||||
runner.model_config = SimpleNamespace(async_chunk=False)
|
||||
runner.vllm_config = SimpleNamespace(model_config=SimpleNamespace(engine_output_type="text"))
|
||||
runner.model_state = SimpleNamespace(postprocess_model_output=MagicMock(return_value=(torch.zeros(1, 2), None)))
|
||||
runner.model_state.intermediate_buffer = SimpleNamespace(buffers={0: {"global_request_id": "global-req"}})
|
||||
runner.req_states = SimpleNamespace(req_id_to_index={"req": 0})
|
||||
runner.req_states.all_token_ids = SimpleNamespace(gpu=torch.tensor([[1]]))
|
||||
runner.req_states.num_computed_tokens = SimpleNamespace(gpu=torch.tensor([0]))
|
||||
runner.req_states.prompt_len = SimpleNamespace(np=np.array([1]), gpu=torch.tensor([1]))
|
||||
runner.main_stream = runner.output_copy_stream = MagicMock()
|
||||
runner.eplb = runner._finalize_native_data_plane_output = runner._reserve_native_data_plane_outputs = MagicMock()
|
||||
sampler_out = (SimpleNamespace(sampled_token_ids=torch.tensor([[2]])), MagicMock(), MagicMock())
|
||||
sampling_active = False
|
||||
|
||||
@contextmanager
|
||||
def sampling_context(*, req_ids, num_output_tokens):
|
||||
nonlocal sampling_active
|
||||
assert needs_history and req_ids == ["req"]
|
||||
sampling_active = True
|
||||
yield
|
||||
sampling_active = False
|
||||
|
||||
runner.model = SimpleNamespace(compute_logits=None, logitsprocs_need_output_token_ids=needs_history)
|
||||
runner.model.mrv2_sampling_context = sampling_context
|
||||
runner.sample = MagicMock(return_value=sampler_out)
|
||||
runner.sample.side_effect = lambda *_: sampler_out if sampling_active is needs_history else pytest.fail()
|
||||
logprobs_mock = MagicMock(side_effect=lambda *_: pytest.fail("inside ctx") if sampling_active else {})
|
||||
runner.prompt_logprobs_worker = SimpleNamespace(compute_prompt_logprobs=logprobs_mock)
|
||||
runner.postprocess_sampled, connector_output = MagicMock(), object()
|
||||
|
||||
def post_forward(finished_req_ids):
|
||||
runner.postprocess_sampled.assert_called_once() # postprocess precedes kv_connector.post_forward
|
||||
assert finished_req_ids == {"finished"}
|
||||
return connector_output
|
||||
|
||||
runner.kv_connector = SimpleNamespace(post_forward=MagicMock(side_effect=post_forward))
|
||||
mock_out = SimpleNamespace(copy_event=None)
|
||||
monkeypatch.setattr(omni_ar_model_runner, "OmniAsyncOutput", MagicMock(return_value=mock_out))
|
||||
|
||||
assert runner.sample_tokens(None) is mock_out
|
||||
built = omni_ar_model_runner.OmniAsyncOutput.call_args.kwargs["model_runner_output"]
|
||||
assert built.kv_connector_output is connector_output
|
||||
assert runner._resolve_global_request_id("req") == "global-req" # from the intermediate buffer
|
||||
assert runner._resolve_global_request_id("unknown") == "unknown" # fallback to the local id
|
||||
|
||||
|
||||
def test_async_mm_snapshot_owns_output_until_copy_finishes() -> None:
|
||||
runner = OmniARModelRunner.__new__(OmniARModelRunner)
|
||||
runner.model_config = SimpleNamespace(async_chunk=True)
|
||||
runner._async_mm_snapshot_slots, runner._async_mm_snapshot_events = [{}], [None]
|
||||
runner._async_mm_snapshot_pending, runner._async_mm_snapshot_cursor = [False], 0
|
||||
waited: list[object] = []
|
||||
runner.main_stream = SimpleNamespace(wait_event=waited.append)
|
||||
source = torch.tensor([[7, 8]], dtype=torch.long)
|
||||
|
||||
snapshot = runner._retain_multimodal_outputs({"codes": {"audio": source}})
|
||||
source.fill_(99)
|
||||
|
||||
# Snapshot owns the data (graph replay cannot overwrite it)...
|
||||
snap = snapshot["codes"]["audio"]
|
||||
assert snap.tolist() == [[7, 8]] and snap.data_ptr() != source.data_ptr()
|
||||
assert runner._last_multimodal_snapshot_slot == 0
|
||||
# ...and slot reuse waits for the previous D2H copy event.
|
||||
runner._release_multimodal_snapshot(0, copy_event := object())
|
||||
runner._retain_multimodal_outputs({"codes": {"audio": torch.zeros(1, 2)}})
|
||||
assert waited == [copy_event]
|
||||
|
||||
|
||||
def test_snapshot_slots_bounded_by_shape_and_packed_grouping_isolation(monkeypatch) -> None:
|
||||
slot: dict[tuple[Any, ...], torch.Tensor] = {}
|
||||
omni_ar_model_runner._copy_mm_to_snapshot_slot(torch.ones(1, 2), slot)
|
||||
omni_ar_model_runner._copy_mm_to_snapshot_slot(torch.ones(4, 2), slot)
|
||||
assert len(slot) == 2 # separate shapes do not share a buffer
|
||||
monkeypatch.setattr(omni_ar_model_runner, "_ASYNC_MM_SNAPSHOT_MAX_BUCKETS_PER_SLOT", 1)
|
||||
bounded: dict[tuple[Any, ...], torch.Tensor] = {}
|
||||
kept = omni_ar_model_runner._copy_mm_to_snapshot_slot(torch.ones(2, 2), bounded)
|
||||
overflow = omni_ar_model_runner._copy_mm_to_snapshot_slot(torch.ones(5, 2), bounded)
|
||||
assert len(bounded) == 1 and overflow.data_ptr() != kept.data_ptr() # overflow clones without evicting
|
||||
|
||||
source = torch.arange(12, dtype=torch.int64).view(3, 4).t()
|
||||
payload = {"noncontiguous": source, "nested": [torch.tensor(7), (torch.tensor([1.5]),)], "meta": "ok"}
|
||||
pack_slot: dict[tuple[Any, ...], torch.Tensor] = {}
|
||||
snapshot = pack_output_snapshot(payload, pack_slot, max_buckets=4)
|
||||
source.fill_(99) # ownership: snapshot keeps pre-mutation values
|
||||
copies = []
|
||||
|
||||
def copy(tensor):
|
||||
copies.append(tensor.numel())
|
||||
return tensor.clone()
|
||||
|
||||
host = snapshot.copy_to_cpu(copy)
|
||||
assert len(copies) == 2 # grouped by dtype (int64, float32), not tensor count
|
||||
assert host["noncontiguous"].tolist() == torch.arange(12).view(3, 4).t().tolist()
|
||||
assert host["nested"][0].item() == 7 and isinstance(host["nested"][1], tuple) and host["meta"] == "ok"
|
||||
pack_output_snapshot(payload, pack_slot, max_buckets=4)
|
||||
assert host["noncontiguous"][0, 0].item() == 0 # repacking must not corrupt published host data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("need_pooler,async_chunk", [(False, False), (False, True), (True, False), (True, True)])
|
||||
def test_guard_graph_replay_for_pooler_copy(need_pooler, async_chunk) -> None:
|
||||
main_stream = MagicMock()
|
||||
omni_ar_model_runner._guard_graph_replay_for_pooler_copy(
|
||||
main_stream, object(), need_pooler=need_pooler, async_chunk=async_chunk
|
||||
)
|
||||
# Non-async pooler copies must gate the next graph replay on the copy event.
|
||||
assert main_stream.wait_event.call_count == (1 if need_pooler and not async_chunk else 0)
|
||||
|
||||
|
||||
def test_build_async_chunk_outputs_slices_padded_axis_and_splits_channels() -> None:
|
||||
# Graph-padded batch: padded_total_tokens > total_tokens; slice by the real token axis.
|
||||
padded_codes = torch.arange(16, dtype=torch.long).reshape(8, 2)
|
||||
build = OmniARModelRunner._build_async_chunk_outputs_from_mm
|
||||
inter_stage, client = build({"codes": {"audio": padded_codes}}, np.array([0, 1, 2]), np.array([1, 1]), 2, 2, 8)
|
||||
assert client is None
|
||||
assert torch.equal(inter_stage[0]["codes.audio"], padded_codes[0:1])
|
||||
assert torch.equal(inter_stage[1]["codes.audio"], padded_codes[1:2])
|
||||
|
||||
codes, audio = torch.arange(16, dtype=torch.long).reshape(4, 4), torch.randn(4, 8)
|
||||
req_codes = [torch.arange(16 * i, 16 * (i + 1), dtype=torch.long).reshape(1, 16) for i in range(2)]
|
||||
inter_stage, client = build(
|
||||
{"codes": {"audio": codes}, "audio": audio}, np.array([0, 2, 4]), np.array([2, 2]), 2, 4
|
||||
)
|
||||
assert "hidden" not in inter_stage[0] and torch.equal(client[1]["audio"], audio[2:]) # channel split
|
||||
assert torch.equal(inter_stage[1]["codes.audio"], codes[2:]) # inter-stage channel
|
||||
# Per-request code lists pass through unsliced.
|
||||
inter_stage, client = build({"codes": {"audio": req_codes}}, np.array([0, 1, 2]), np.array([1, 1]), 2, 2)
|
||||
assert client is None and torch.equal(inter_stage[0]["codes.audio"], req_codes[0])
|
||||
|
||||
|
||||
def test_async_chunk_output_stages_mm_on_copy_stream_before_get_output(monkeypatch) -> None:
|
||||
monkeypatch.setattr(torch.cuda, "set_stream", lambda _stream: None)
|
||||
calls = []
|
||||
|
||||
def copy_mm(mm_outputs, total_tokens, **ctx):
|
||||
calls.append((total_tokens, ctx))
|
||||
return {"codes": {"audio": mm_outputs["codes"]["audio"].clone()}}
|
||||
|
||||
monkeypatch.setattr(omni_ar_model_runner, "_async_copy_mm", copy_mm)
|
||||
source_codes = torch.tensor([[7, 8]], dtype=torch.long)
|
||||
input_batch = SimpleNamespace(query_start_loc_np=np.array([0, 1]), num_scheduled_tokens=[1], num_reqs=1)
|
||||
output = _async_output(
|
||||
multimodal_outputs={"codes": {"audio": source_codes}},
|
||||
input_batch=input_batch,
|
||||
copy_stream=(copy_stream := _FakeStream()),
|
||||
async_chunk=True,
|
||||
)
|
||||
# Staged once on the copy stream during construction, with one resolved pin-memory context for all D2H helpers.
|
||||
[(total_tokens, ctx)] = calls
|
||||
assert total_tokens == 1 and ctx["copy_stream"] is copy_stream and ctx["pin_memory"] is not None
|
||||
assert output._mm_snapshot["codes"]["audio"].device.type == "cpu"
|
||||
source_codes.fill_(99) # a later graph replay cannot leak into the snapshot
|
||||
assert torch.equal(output.get_output().inter_stage_outputs[0]["codes.audio"], torch.tensor([[7, 8]]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefill_first", [False, True])
|
||||
@pytest.mark.parametrize("padded", [False, True])
|
||||
def test_request_reference_codes_preserve_local_axis(prefill_first, padded):
|
||||
lengths = np.array([255, 1] if prefill_first else [1, 255])
|
||||
offsets = np.array([0, lengths[0], 256])
|
||||
size = 512 if padded else 256
|
||||
ref = torch.arange(size * 16).reshape(size, 16)
|
||||
refs = [ref, torch.empty(0)] if prefill_first else [torch.empty(0), ref]
|
||||
codes = torch.arange(size * 16).reshape(size, 16)
|
||||
outputs, _ = OmniARModelRunner._build_async_chunk_outputs_from_mm(
|
||||
{"codes": {"audio": codes, "ref": refs}}, offsets, lengths, 2, 256, size
|
||||
)
|
||||
index = 0 if prefill_first else 1
|
||||
assert torch.equal(outputs[index]["codes.ref"], ref)
|
||||
assert outputs[index]["codes.ref"].data_ptr() != ref.data_ptr()
|
||||
for i in range(2):
|
||||
assert torch.equal(outputs[i]["codes.audio"], codes[offsets[i] : offsets[i + 1]])
|
||||
@@ -0,0 +1,481 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Lifecycle contracts for OmniRunnerDataPlane/NativeOutputWorker."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.worker_v2.delivery import DeliveryCancelledError, DeliveryState, OmniDeliveryManager
|
||||
from vllm_omni.worker_v2.native_output_worker import NativeOutputWorker
|
||||
from vllm_omni.worker_v2.omni_data_plane import OmniRunnerDataPlane
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _complete(plane, payloads, token=21, req="internal"):
|
||||
return plane.complete_outputs(
|
||||
req_ids=[req], inter_stage_outputs=payloads, sampled_token_ids=[[token]] * len(payloads)
|
||||
)
|
||||
|
||||
|
||||
def _new_request(req_id="internal", external_req_id="external"):
|
||||
return SimpleNamespace(
|
||||
req_id=req_id,
|
||||
external_req_id=external_req_id,
|
||||
prompt_token_ids=[10, 11],
|
||||
num_computed_tokens=2,
|
||||
resumable=True,
|
||||
additional_information=SimpleNamespace(entries={}),
|
||||
sampling_params=SimpleNamespace(stop_token_ids=[2150]),
|
||||
)
|
||||
|
||||
|
||||
def _bare_plane(*, delivery_timeout_s=1.0, shutdown_timeout_s=1.0):
|
||||
plane = object.__new__(OmniRunnerDataPlane)
|
||||
plane.__dict__.update(
|
||||
_native_requests={},
|
||||
_native_output_lock=threading.RLock(),
|
||||
_native_send_lock=threading.Lock(),
|
||||
_native_output_error=None,
|
||||
_native_output_error_lock=threading.Lock(),
|
||||
_native_output_closed=False,
|
||||
_native_outputs_in_flight=defaultdict(int),
|
||||
_native_terminal_pending=set(),
|
||||
_put_req_chunk=defaultdict(int),
|
||||
_ramp_chunk_count=defaultdict(int),
|
||||
_delivery_manager=OmniDeliveryManager(
|
||||
delivery_timeout_s=delivery_timeout_s, shutdown_timeout_s=shutdown_timeout_s
|
||||
),
|
||||
)
|
||||
return plane
|
||||
|
||||
|
||||
def _make_plane(replace_send):
|
||||
p = _bare_plane()
|
||||
p.record = SimpleNamespace(batches=[], cleaned=[])
|
||||
if replace_send:
|
||||
p.send_chunks = lambda entries, **_kw: p.record.batches.append(entries) or len(entries)
|
||||
p.get_omni_connector_output, p.cleanup_finished_request = (lambda: None), p.record.cleaned.append
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raw_plane(): # keeps the real connector send path
|
||||
p = _make_plane(replace_send=False)
|
||||
yield p
|
||||
if getattr(p, "_native_output_worker", None) is not None:
|
||||
p._stop_output_worker()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plane():
|
||||
p = _make_plane(replace_send=True)
|
||||
yield p
|
||||
if getattr(p, "_native_output_worker", None) is not None:
|
||||
p._stop_output_worker()
|
||||
|
||||
|
||||
def _gated_connector():
|
||||
# put() blocks its first call until released (or forever without release).
|
||||
conn = SimpleNamespace(
|
||||
put_keys=[], put_started=threading.Event(), release=threading.Event(), close_called=threading.Event()
|
||||
)
|
||||
|
||||
def put(**kwargs):
|
||||
conn.put_keys.append(kwargs["put_key"])
|
||||
if len(conn.put_keys) == 1:
|
||||
conn.put_started.set()
|
||||
assert conn.release.wait(timeout=2)
|
||||
return True, 1, None
|
||||
|
||||
conn.put, conn.close = put, conn.close_called.set
|
||||
return conn
|
||||
|
||||
|
||||
def _start_save_thread(plane, connector):
|
||||
plane.__dict__.update(
|
||||
_omni_connector=connector,
|
||||
_stage_id=0,
|
||||
_next_stage_id=1,
|
||||
_lock=threading.Lock(),
|
||||
_pending_save_reqs={},
|
||||
_pending_save_counts=defaultdict(int),
|
||||
_deferred_send_cleanup=set(),
|
||||
_request_ids_mapping={},
|
||||
_cached_ic={},
|
||||
_send_side_request_payload={},
|
||||
_code_prompt_token_ids=defaultdict(list),
|
||||
_work_available=threading.Event(),
|
||||
_stop_event=threading.Event(),
|
||||
_MAX_SEND_RETRIES=0,
|
||||
_can_send=True,
|
||||
_recv_thread=None,
|
||||
_custom_process_batch_func=lambda **kwargs: kwargs["pooling_outputs"],
|
||||
is_data_transfer_rank=lambda: True,
|
||||
_connector_send_error_sink=plane._record_output_error,
|
||||
)
|
||||
plane._save_thread = threading.Thread(target=plane._save_loop, daemon=True)
|
||||
plane._save_thread.start()
|
||||
|
||||
|
||||
def _stop_save_thread(plane):
|
||||
plane._stop_event.set()
|
||||
plane._work_available.set()
|
||||
plane._save_thread.join(timeout=2)
|
||||
assert not plane._save_thread.is_alive()
|
||||
|
||||
|
||||
class _Output:
|
||||
copy_event = None
|
||||
|
||||
def __init__(self, rid="internal", gate=None, started=None, error=None, payload=None, token=1):
|
||||
self.rid, self.gate, self.started = rid, gate, started
|
||||
self.error, self.payload, self.token = error, payload, token
|
||||
|
||||
def get_output(self):
|
||||
if self.started is not None:
|
||||
self.started.set()
|
||||
if self.gate is not None:
|
||||
assert self.gate.wait(timeout=3)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return SimpleNamespace(
|
||||
req_ids=[self.rid],
|
||||
inter_stage_outputs=[self.payload] if self.payload is not None else None,
|
||||
sampled_token_ids=[[self.token]],
|
||||
)
|
||||
|
||||
|
||||
def test_emit_cohort_one_batch_and_trims_non_resumable_history(plane):
|
||||
plane.send_chunk = lambda *_a, **_kw: pytest.fail("must not fall back to per-request send_chunk")
|
||||
plane.register_request(_new_request("r0", "ext-0"))
|
||||
plane.register_request(_new_request("r1", "ext-1"))
|
||||
emitted = plane.emit_chunks(
|
||||
req_ids=["r0", "r1"],
|
||||
inter_stage_outputs=[{"codes.audio": "c0"}, {"codes.audio": "c1"}],
|
||||
sampled_token_ids=[[21]],
|
||||
terminal_req_ids={"r1"},
|
||||
)
|
||||
assert emitted == 2 and len(plane.record.batches) == 1 # the whole step cohort goes out in one batch
|
||||
(req0, _), (req1, _) = plane.record.batches[0]
|
||||
assert (
|
||||
req0.all_token_ids == [10, 11, 21]
|
||||
and not req0.is_finished()
|
||||
and req1.is_finished()
|
||||
and plane.record.cleaned == ["r1"]
|
||||
)
|
||||
# A non-resumable request omits token history after its first chunk.
|
||||
plane.register_request(_new_request("r2", "ext-2"))
|
||||
state = plane._native_requests["r2"]
|
||||
state.resumable, state.output_token_ids, plane._put_req_chunk["ext-2"] = False, list(range(1024)), 1
|
||||
plane.emit_chunks(
|
||||
req_ids=["r2"], inter_stage_outputs=[{"h": "d"}], sampled_token_ids=[[1024]], terminal_req_ids={"r2"}
|
||||
)
|
||||
request, _ = plane.record.batches[1][0]
|
||||
assert request.output_token_count == 1025 and request.all_token_ids == []
|
||||
|
||||
|
||||
def test_terminal_reserved_abort_and_cleanup_lifecycle(plane):
|
||||
batches, cleaned = plane.record.batches, plane.record.cleaned
|
||||
plane.register_request(_new_request())
|
||||
plane.reserve_outputs(["internal"])
|
||||
plane.reserve_outputs(["internal"])
|
||||
# The terminal defers while reservations are in flight, and is idempotent.
|
||||
assert plane.request_terminal({"internal"}) == 0
|
||||
assert plane.request_terminal({"internal"}) == 0 and batches == []
|
||||
assert _complete(plane, [{"codes.audio": "c0"}]) == 1 and not batches[0][0][0].is_finished()
|
||||
assert _complete(plane, [{"codes.audio": "c1"}], token=22) == 2 # data chunk, then the released terminal
|
||||
chunk, terminal = batches[1][0], batches[2][0]
|
||||
assert chunk[1] == {"codes": {"audio": "c1"}} and chunk[0].output_token_ids == [21, 22]
|
||||
assert terminal[1] is None and terminal[0].is_finished() and cleaned == ["internal"]
|
||||
# Stale terminal/complete after cleanup are no-ops; cleanup ran exactly once.
|
||||
assert plane.request_terminal({"internal"}) == 0 and _complete(plane, [{"codes.audio": "stale"}]) == 0
|
||||
# Abort emits exactly one terminal and silently drops the stale deferred output.
|
||||
plane.register_request(_new_request("ra"))
|
||||
plane.reserve_outputs(["ra"])
|
||||
plane.request_terminal({"ra"})
|
||||
assert plane.abort_requests({"ra"}) == 1
|
||||
request, payload = batches[-1][0]
|
||||
assert request.is_finished() and payload is None and cleaned == ["internal", "ra"]
|
||||
assert (
|
||||
plane.abort_requests({"ra"}) == 0 and _complete(plane, [{"s": 1}], req="ra") == 0
|
||||
) # duplicate abort, stale output
|
||||
assert sum(b[0][0].request_id == "ra" for b in batches) == 1 # terminal exactly once
|
||||
|
||||
|
||||
def test_abort_cannot_overtake_committed_output(plane):
|
||||
in_lock, release, sent = threading.Event(), threading.Event(), threading.Event()
|
||||
|
||||
def gated(entries, _orig=plane._send_chunk_entries, **kw):
|
||||
terminal = entries[0][0].is_finished()
|
||||
if not terminal:
|
||||
in_lock.set()
|
||||
assert release.wait(timeout=2) # hold the committed data send inside _native_send_lock
|
||||
result = _orig(entries, **kw)
|
||||
if terminal:
|
||||
sent.set()
|
||||
return result
|
||||
|
||||
plane._send_chunk_entries = gated
|
||||
plane.register_request(_new_request())
|
||||
plane.reserve_outputs(["internal"])
|
||||
threads = [
|
||||
threading.Thread(target=lambda: _complete(plane, [{"c": 0}])),
|
||||
threading.Thread(target=plane.abort_requests, args=({"internal"},)),
|
||||
]
|
||||
threads[0].start()
|
||||
try:
|
||||
assert in_lock.wait(timeout=2)
|
||||
threads[1].start()
|
||||
overtook = sent.wait(timeout=0.1) # the terminal must not pass the committed data send
|
||||
finally:
|
||||
release.set()
|
||||
for t in threads:
|
||||
if t.ident is not None:
|
||||
t.join(timeout=2)
|
||||
assert not t.is_alive()
|
||||
assert not overtook
|
||||
|
||||
|
||||
def test_output_worker_fifo_and_scheduler_not_blocked(plane):
|
||||
batches, send_started, release_send = [], threading.Event(), threading.Event()
|
||||
|
||||
def send_chunks(entries, **_kw):
|
||||
if not entries[0][0].is_finished():
|
||||
send_started.set()
|
||||
assert release_send.wait(timeout=2)
|
||||
batches.append(entries)
|
||||
return len(entries)
|
||||
|
||||
plane.send_chunks = send_chunks
|
||||
plane._start_output_worker(max_pending_batches=2)
|
||||
plane.register_request(_new_request())
|
||||
plane.reserve_outputs(["internal"])
|
||||
plane.enqueue_outputs(req_ids=["internal"], inter_stage_outputs=[{"c": 0}], sampled_token_ids=[[21]])
|
||||
assert send_started.wait(timeout=2)
|
||||
# A stuck deferred send must not block the scheduler path.
|
||||
assert plane.request_terminal({"internal"}) == 0 and batches == []
|
||||
release_send.set()
|
||||
plane.drain_outputs()
|
||||
assert [e[0].is_finished() for batch in batches for e in batch] == [False, True]
|
||||
|
||||
|
||||
def test_worker_failure_surfaces_and_close_drains_in_order(plane):
|
||||
events = []
|
||||
plane.send_chunks = lambda _e, **_kw: (_ for _ in ()).throw(RuntimeError("enqueue failed"))
|
||||
plane.shutdown_omni_connectors = lambda: events.append("shutdown")
|
||||
plane._start_output_worker(max_pending_batches=2)
|
||||
plane.register_request(_new_request())
|
||||
plane.enqueue_outputs(req_ids=["internal"], inter_stage_outputs=[{"c": 0}], sampled_token_ids=[[21]])
|
||||
with pytest.raises(RuntimeError, match="enqueue failed"):
|
||||
plane.drain_outputs()
|
||||
with pytest.raises(RuntimeError, match="enqueue failed"): # an errored worker rejects new submissions
|
||||
plane.enqueue_outputs(req_ids=["internal"], inter_stage_outputs=[{"c": 1}], sampled_token_ids=[[22]])
|
||||
# close() drains pending batches before shutting connectors down.
|
||||
plane._native_output_error = None
|
||||
|
||||
def send_chunks(entries, **_kw):
|
||||
events.append("send")
|
||||
return len(entries)
|
||||
|
||||
plane.send_chunks = send_chunks
|
||||
plane.enqueue_outputs(req_ids=["internal"], inter_stage_outputs=[{"c": 1}], sampled_token_ids=[[22]])
|
||||
plane.close()
|
||||
assert events == ["send", "shutdown"]
|
||||
|
||||
|
||||
def test_permanent_put_failure_quarantines_and_holds_state(raw_plane):
|
||||
puts = []
|
||||
|
||||
def put(**_kw):
|
||||
puts.append(1)
|
||||
return False, 0, None
|
||||
|
||||
connector = SimpleNamespace(put=put, close=lambda: None)
|
||||
_start_save_thread(raw_plane, connector)
|
||||
raw_plane.register_request(_new_request())
|
||||
raw_plane.reserve_outputs(["internal"])
|
||||
raw_plane.request_terminal({"internal"})
|
||||
with pytest.raises(RuntimeError, match="connector send failed"):
|
||||
raw_plane.complete_outputs(req_ids=["internal"], inter_stage_outputs=[{"c": 0}], sampled_token_ids=[[21]])
|
||||
assert len(puts) == 1 and raw_plane._native_output_error is not None # fail fast, no retry
|
||||
assert raw_plane._native_outputs_in_flight["internal"] == 1 and "internal" in raw_plane._native_terminal_pending
|
||||
with pytest.raises(RuntimeError, match="quarantined"): # the quarantine rejects new tickets
|
||||
raw_plane._delivery_manager.create_ticket(request_id="other", put_key="other_0_0")
|
||||
_stop_save_thread(raw_plane)
|
||||
|
||||
|
||||
def test_delivery_timeout_drops_queued_ticket(raw_plane):
|
||||
raw_plane._delivery_manager = OmniDeliveryManager(delivery_timeout_s=0.05, shutdown_timeout_s=0.5)
|
||||
connector = _gated_connector()
|
||||
_start_save_thread(raw_plane, connector)
|
||||
req = SimpleNamespace(request_id="external")
|
||||
_, first = raw_plane._enqueue_chunk_payload(req, {"c": 1}, wait_for_delivery=True)
|
||||
_, second = raw_plane._enqueue_chunk_payload(req, {"c": 2}, wait_for_delivery=True)
|
||||
assert connector.put_started.wait(timeout=1)
|
||||
with pytest.raises(TimeoutError, match="delivery timed out"):
|
||||
first.wait()
|
||||
assert raw_plane._delivery_manager.is_quarantined
|
||||
connector.release.set()
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline and "external" in raw_plane._pending_save_counts: # the save loop drops it
|
||||
time.sleep(0.001)
|
||||
# After quarantine the queued ticket is never put and is marked FAILED.
|
||||
assert connector.put_keys == [first.put_key] and second.state is DeliveryState.FAILED
|
||||
assert "external" not in raw_plane._pending_save_counts and "external" not in raw_plane._pending_save_reqs
|
||||
_stop_save_thread(raw_plane)
|
||||
|
||||
|
||||
def test_close_cancels_waiting_delivery_once_and_shutdown_stays_bounded():
|
||||
# A queued delivery waiter is cancelled exactly once on shutdown.
|
||||
manager = OmniDeliveryManager(delivery_timeout_s=30.0, shutdown_timeout_s=0.5)
|
||||
ticket = manager.create_ticket(request_id="r", put_key="k")
|
||||
manager.shutdown(RuntimeError("close"))
|
||||
manager.shutdown(RuntimeError("again")) # idempotent: cancelled at most once
|
||||
with pytest.raises(DeliveryCancelledError, match="cancelled"):
|
||||
ticket.wait()
|
||||
# A connector put() that cannot be cancelled must not hang shutdown.
|
||||
plane = _bare_plane(delivery_timeout_s=0.05, shutdown_timeout_s=0.2)
|
||||
connector = _gated_connector()
|
||||
_start_save_thread(plane, connector)
|
||||
assert plane._enqueue_chunk_payload(SimpleNamespace(request_id="external"), {"c": 0}, wait_for_delivery=True)[0]
|
||||
assert connector.put_started.wait(timeout=1)
|
||||
start = time.monotonic()
|
||||
with pytest.raises(RuntimeError, match="shutdown exceeded"):
|
||||
plane.shutdown_omni_connectors()
|
||||
assert time.monotonic() - start < 1.5 and connector.close_called.is_set()
|
||||
connector.release.set()
|
||||
_stop_save_thread(plane)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("abort", [False, True])
|
||||
def test_materialization_fences_terminal_and_abort(plane, abort):
|
||||
batches, cleaned, gate, started = plane.record.batches, plane.record.cleaned, threading.Event(), threading.Event()
|
||||
plane._start_output_worker(max_pending_batches=2)
|
||||
materializer = NativeOutputWorker(2)
|
||||
try:
|
||||
plane.register_request(_new_request())
|
||||
plane.reserve_outputs(["internal"])
|
||||
output = materializer.submit(
|
||||
_Output(gate=gate, started=started, payload={"codes.audio": "c0"}, token=21), plane
|
||||
)
|
||||
assert started.wait(timeout=3)
|
||||
assert plane.request_terminal({"internal"}) == 0 # fenced behind the in-flight materialization
|
||||
assert not abort or plane.abort_requests({"internal"}) == 1
|
||||
gate.set()
|
||||
output.get_output()
|
||||
materializer.close()
|
||||
plane.drain_outputs()
|
||||
assert cleaned == ["internal"] and len(batches) == (1 if abort else 2)
|
||||
assert abort or (batches[0][0][1] == {"codes": {"audio": "c0"}} and not batches[0][0][0].is_finished())
|
||||
assert batches[-1][0][0].is_finished()
|
||||
finally:
|
||||
gate.set()
|
||||
materializer.close()
|
||||
|
||||
|
||||
def test_native_worker_fifo_thread_affinity_and_signals():
|
||||
owner, gate = threading.get_ident(), threading.Event()
|
||||
calls: list[tuple[Any, ...]] = []
|
||||
|
||||
def get_signals():
|
||||
calls.append(("signals", threading.get_ident()))
|
||||
return "ready"
|
||||
|
||||
plane = SimpleNamespace(
|
||||
enqueue_outputs=lambda **kw: calls.append(("publish", kw["req_ids"], threading.get_ident())),
|
||||
get_omni_connector_output=get_signals,
|
||||
)
|
||||
worker = NativeOutputWorker(2)
|
||||
try:
|
||||
a = worker.submit(_Output("a", gate=gate, payload={"v": 1}), plane)
|
||||
b = worker.submit(_Output("b", payload={"v": 1}), plane)
|
||||
gate.set()
|
||||
b.get_output() # publication of b implies a already published (FIFO, single worker thread)
|
||||
publishes = [c for c in calls if c[0] == "publish"]
|
||||
assert [c[1] for c in publishes] == [["a"], ["b"]]
|
||||
assert all(c[2] != owner for c in publishes) # published on the worker thread
|
||||
result = a.get_output()
|
||||
assert a.get_output() is result and result.inter_stage_outputs is None # resolution is cached
|
||||
assert result.omni_connector_output == "ready"
|
||||
assert calls[-2:] == [("signals", owner)] * 2 # signals resolve lazily on the caller thread
|
||||
finally:
|
||||
gate.set()
|
||||
worker.close()
|
||||
|
||||
|
||||
def test_native_worker_capacity_error_cache_and_tp_gates():
|
||||
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||
|
||||
plane = SimpleNamespace(enqueue_outputs=lambda **kw: None, get_omni_connector_output=lambda: None)
|
||||
worker, gate = NativeOutputWorker(1), threading.Event()
|
||||
try:
|
||||
first = worker.submit(_Output("a", gate=gate), plane)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(worker.submit, _Output("b"), plane)
|
||||
time.sleep(0.05)
|
||||
assert not future.done() # capacity 1: pending materialization is bounded
|
||||
gate.set()
|
||||
second = future.result(3)
|
||||
assert first.get_output().req_ids == ["a"] and second.get_output().req_ids == ["b"]
|
||||
bad = worker.submit(_Output("x", error=ValueError("crash")), plane)
|
||||
finally:
|
||||
gate.set()
|
||||
worker.close()
|
||||
# A failed materialization is cached: repeated reads raise the same error.
|
||||
for _ in range(2):
|
||||
with pytest.raises(ValueError, match="crash"):
|
||||
bad.get_output()
|
||||
runner = SimpleNamespace(
|
||||
_omni_data_plane=object(), vllm_config=SimpleNamespace(parallel_config=SimpleNamespace(tensor_parallel_size=2))
|
||||
)
|
||||
assert OmniGPUModelRunner._uses_native_output_materializer(runner) is False # TP>1 keeps TP consumers
|
||||
runner.vllm_config.parallel_config.tensor_parallel_size = 1
|
||||
assert OmniGPUModelRunner._uses_native_output_materializer(runner) is True
|
||||
|
||||
|
||||
def test_abort_before_first_chunk_cleans_receiver_state():
|
||||
plane = _bare_plane()
|
||||
plane._stage_id, plane._lock, plane._work_available = 1, threading.Lock(), threading.Event()
|
||||
for name in (
|
||||
"_pending_full_payload_send",
|
||||
"_request_ids_mapping",
|
||||
"_pending_save_counts",
|
||||
"_send_side_request_payload",
|
||||
"_code_prompt_token_ids",
|
||||
"_cached_ic",
|
||||
"_adaptive_states",
|
||||
"_kv_pending_transfers",
|
||||
"_get_req_chunk",
|
||||
"_pending_load_reqs",
|
||||
"_local_stage_payload_cache",
|
||||
"_local_request_metadata",
|
||||
):
|
||||
setattr(plane, name, {})
|
||||
for name in (
|
||||
"_deferred_send_cleanup",
|
||||
"_kv_active_transfers",
|
||||
"_kv_completed_transfers",
|
||||
"_kv_triggered_requests",
|
||||
"_finished_load_reqs",
|
||||
"_chunk_ready_req_ids",
|
||||
"_chunk_finished_req_ids",
|
||||
"_chunk_stream_completed",
|
||||
"_stage_recv_req_ids",
|
||||
"_full_payload_pending_broadcast_req_ids",
|
||||
"_async_chunk_updated_req_ids",
|
||||
):
|
||||
setattr(plane, name, set())
|
||||
plane.register_receivers([SimpleNamespace(request_id="r", external_req_id="external")])
|
||||
plane._local_stage_payload_cache["r"] = {"codes": torch.ones(1)}
|
||||
assert "r" in plane._pending_load_reqs and plane._request_ids_mapping["r"] == "external"
|
||||
assert plane.abort_requests({"r"}) == 0
|
||||
assert plane.abort_requests({"r"}) == 0
|
||||
assert not plane._pending_load_reqs and not plane._request_ids_mapping and not plane._local_stage_payload_cache
|
||||
@@ -0,0 +1,184 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""OmniGenerationModelRunner contracts: empty-step lifecycle, output partition,
|
||||
CPU-sync vs CUDA-async dispatch, and async-chunk slot recycling."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _FakeStagedField:
|
||||
def __init__(self, data):
|
||||
self.np = data
|
||||
self._staged = []
|
||||
|
||||
def stage_write_elem(self, idx, value):
|
||||
self._staged.append((idx, value))
|
||||
|
||||
def apply_write(self):
|
||||
for idx, value in self._staged:
|
||||
self.np[idx] = value
|
||||
self._staged.clear()
|
||||
|
||||
|
||||
def _make_runner(model_output, num_reqs=1, prompt_len=10):
|
||||
runner = object.__new__(OmniGenerationModelRunner)
|
||||
runner.device = torch.device("cpu")
|
||||
runner.model_config = MagicMock(hf_text_config=None)
|
||||
del runner.model_config.eos_token_id
|
||||
runner.postprocess = lambda *a, **kw: None
|
||||
runner._gen_model_output = model_output
|
||||
runner._gen_input_batch = SimpleNamespace(
|
||||
num_reqs=num_reqs, req_ids=[f"req-{i}" for i in range(num_reqs)], idx_mapping_np=np.arange(num_reqs)
|
||||
)
|
||||
runner.execute_model_state = SimpleNamespace(finished_req_ids={"finished"}, ec_connector_output=None)
|
||||
runner.kv_connector = SimpleNamespace(post_forward=MagicMock(return_value=None))
|
||||
runner.check_ep_fault = False
|
||||
runner.req_states = MagicMock(
|
||||
prompt_len=SimpleNamespace(np=np.full(num_reqs, prompt_len, dtype=np.int32)),
|
||||
num_computed_tokens=_FakeStagedField(np.zeros(num_reqs, dtype=np.int32)),
|
||||
)
|
||||
return runner
|
||||
|
||||
|
||||
def test_control_only_step_keeps_lifecycle_and_skips_input_construction():
|
||||
runner = object.__new__(OmniGenerationModelRunner)
|
||||
order = []
|
||||
for name in (
|
||||
"_prepare_native_data_plane",
|
||||
"finish_requests",
|
||||
"free_states",
|
||||
"_apply_block_table_staged_writes_if_available",
|
||||
):
|
||||
setattr(runner, name, lambda *args, name=name: order.append(name))
|
||||
for name in ("_handle_async_chunk_updates", "add_requests", "update_requests", "_sync_native_data_plane_payloads"):
|
||||
setattr(runner, name, MagicMock(side_effect=AssertionError("control step built model inputs")))
|
||||
output = object()
|
||||
runner.kv_connector = SimpleNamespace(no_forward=lambda _s: output)
|
||||
runner._merge_ec_connector_no_forward = lambda _s, value: value
|
||||
runner._attach_native_data_plane_signals = lambda value: value
|
||||
scheduler_output = SimpleNamespace(
|
||||
total_num_scheduled_tokens=0, scheduled_new_reqs=[], scheduled_cached_reqs=SimpleNamespace(req_ids=[])
|
||||
)
|
||||
assert runner.execute_model(scheduler_output) is output
|
||||
assert order == [
|
||||
"_prepare_native_data_plane",
|
||||
"finish_requests",
|
||||
"free_states",
|
||||
"_apply_block_table_staged_writes_if_available",
|
||||
]
|
||||
|
||||
|
||||
def test_released_chunk_reuses_scheduler_output_and_slot_recycle_clears_state():
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
|
||||
from vllm_omni.core.sched.output import OmniCachedRequestData
|
||||
|
||||
runner = object.__new__(OmniGenerationModelRunner)
|
||||
runner.model = SimpleNamespace(logits_processor=None)
|
||||
runner._remove_request = MagicMock()
|
||||
req_id_to_index = MagicMock()
|
||||
req_id_to_index.get.return_value = None
|
||||
runner.req_states = SimpleNamespace(
|
||||
add_request=MagicMock(), req_id_to_index=req_id_to_index, apply_staged_writes=MagicMock()
|
||||
)
|
||||
runner.pooling_runner = None
|
||||
runner.encoder_cache = None
|
||||
runner.model_state = SimpleNamespace(
|
||||
add_request=MagicMock(),
|
||||
apply_staged_writes=MagicMock(),
|
||||
remove_request=MagicMock(),
|
||||
intermediate_buffer=SimpleNamespace(remove_request=MagicMock()),
|
||||
)
|
||||
runner.block_tables = SimpleNamespace(append_block_ids=MagicMock())
|
||||
runner.lora_state = SimpleNamespace(add_request=MagicMock())
|
||||
runner.is_last_pp_rank = False
|
||||
runner.adaptive_verification = None
|
||||
runner.sampler = None
|
||||
cached = OmniCachedRequestData(
|
||||
req_ids=["req"],
|
||||
resumed_req_ids=set(),
|
||||
new_token_ids=[[]],
|
||||
all_token_ids={"req": [1]},
|
||||
new_block_ids=[()],
|
||||
num_computed_tokens=[0],
|
||||
num_output_tokens=[0],
|
||||
prompt_token_ids={"req": [1]},
|
||||
additional_information={"req": None},
|
||||
)
|
||||
scheduler_output = SchedulerOutput(
|
||||
scheduled_new_reqs=[],
|
||||
scheduled_cached_reqs=cached,
|
||||
num_scheduled_tokens={"req": 1},
|
||||
total_num_scheduled_tokens=1,
|
||||
scheduled_spec_decode_tokens={},
|
||||
scheduled_encoder_inputs={},
|
||||
num_common_prefix_blocks=[0],
|
||||
finished_req_ids=set(),
|
||||
free_encoder_mm_hashes=[],
|
||||
)
|
||||
runner._handle_async_chunk_updates(scheduler_output)
|
||||
added = runner.model_state.add_request.call_args.args[1]
|
||||
assert (added.req_id, added.prompt_token_ids) == ("req", [1])
|
||||
|
||||
|
||||
def test_sample_tokens_cpu_sync_owns_outputs_and_runs_connector_last():
|
||||
waveform = torch.arange(4, dtype=torch.float32)
|
||||
output = OmniOutput(text_hidden_states=torch.empty(0), multimodal_outputs={"codes": {"audio": [waveform]}})
|
||||
runner = _make_runner(output, num_reqs=1)
|
||||
runner._reserve_native_data_plane_outputs = MagicMock()
|
||||
runner._finalize_native_data_plane_output = MagicMock(side_effect=lambda value: value)
|
||||
|
||||
def post_forward(_finished):
|
||||
assert runner.req_states.num_computed_tokens.np.tolist() == [10]
|
||||
|
||||
runner.kv_connector.post_forward.side_effect = post_forward
|
||||
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||
assert isinstance(result, OmniModelRunnerOutput)
|
||||
# Reserve precedes finalize; connector runs after token state is applied.
|
||||
runner._reserve_native_data_plane_outputs.assert_called_once_with(["req-0"])
|
||||
runner.kv_connector.post_forward.assert_called_once_with({"finished"})
|
||||
waveform.fill_(-1)
|
||||
assert torch.equal(result.multimodal_outputs[0]["codes.audio"], torch.arange(4, dtype=torch.float32))
|
||||
|
||||
|
||||
def test_sample_tokens_uses_async_output_for_cuda_and_snapshots_req_ids(monkeypatch):
|
||||
from vllm_omni.worker_v2 import omni_generation_model_runner as generation_runner
|
||||
|
||||
output = OmniOutput(text_hidden_states=torch.zeros(1), multimodal_outputs={"model_outputs": [torch.randn(4)]})
|
||||
runner = _make_runner(output, num_reqs=1)
|
||||
runner.device = SimpleNamespace(type="cuda")
|
||||
runner.main_stream = object()
|
||||
runner.output_copy_stream = object()
|
||||
runner.model_config.async_chunk = True
|
||||
runner._release_generation_slots = MagicMock()
|
||||
runner._finalize_native_data_plane_output = MagicMock()
|
||||
monkeypatch.setattr(generation_runner, "_contains_cuda_tensor", lambda _: True)
|
||||
captured = {}
|
||||
|
||||
class _FakeAsyncOutput:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(generation_runner, "OmniGenerationAsyncOutput", _FakeAsyncOutput)
|
||||
input_batch = runner._gen_input_batch
|
||||
result = generation_runner.OmniGenerationModelRunner.sample_tokens(runner)
|
||||
|
||||
assert isinstance(result, _FakeAsyncOutput)
|
||||
assert captured["finalize_output"] is runner._finalize_native_data_plane_output
|
||||
assert captured["model_runner_output"].sampled_token_ids == [[]]
|
||||
runner._release_generation_slots.assert_called_once()
|
||||
output_req_ids = captured["model_runner_output"].req_ids
|
||||
input_batch.req_ids[0] = "reused" # snapshot must insulate the published output
|
||||
assert output_req_ids == ["req-0"]
|
||||
@@ -0,0 +1,166 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""MRV2 admission, capture, dispatch and request lifecycle contracts."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm import SamplingParams
|
||||
from vllm.config.compilation import CUDAGraphMode
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.worker.gpu.model_runner import GPUModelRunner
|
||||
from vllm.v1.worker.gpu.sample.sampler import Sampler
|
||||
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
from vllm_omni.worker_v2.model_states import init_omni_model_state
|
||||
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState
|
||||
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _make_runner():
|
||||
"""Create an OmniGPUModelRunner without calling __init__."""
|
||||
runner = object.__new__(OmniGPUModelRunner)
|
||||
runner.model = MagicMock()
|
||||
runner.req_states = SimpleNamespace(req_id_to_index={"r1": 0, "r2": 1})
|
||||
runner.execute_model_state = None
|
||||
return runner
|
||||
|
||||
|
||||
def test_add_requests_empty_admission_and_stop_id_sanitization():
|
||||
runner = _make_runner()
|
||||
runner.sampler = Sampler.__new__(Sampler)
|
||||
with patch.object(GPUModelRunner, "add_requests", return_value=None) as parent:
|
||||
runner.add_requests(SchedulerOutput.make_empty())
|
||||
parent.assert_not_called()
|
||||
|
||||
# New requests always go upstream; narrow logits heads sanitize stop ids.
|
||||
sampling_params = SamplingParams(min_tokens=2, stop_token_ids=[2150])
|
||||
sampling_params.update_from_generation_config({}, 151645)
|
||||
runner.model = SimpleNamespace(logits_processor=SimpleNamespace(vocab_size=3072))
|
||||
output = SchedulerOutput.make_empty()
|
||||
output.scheduled_new_reqs = [SimpleNamespace(sampling_params=sampling_params)]
|
||||
runner.add_requests(output)
|
||||
parent.assert_called_once_with(output)
|
||||
assert sampling_params.all_stop_token_ids == {2150}
|
||||
assert sampling_params.eos_token_id == 151645
|
||||
|
||||
|
||||
def test_prepare_native_data_plane_terminal_abort_split_and_warmup_skip():
|
||||
runner = _make_runner()
|
||||
plane = SimpleNamespace(
|
||||
register_request=MagicMock(),
|
||||
register_receivers=MagicMock(),
|
||||
request_terminal=MagicMock(),
|
||||
abort_requests=MagicMock(),
|
||||
)
|
||||
runner._omni_data_plane = plane
|
||||
new_req = SimpleNamespace(req_id="r1")
|
||||
warmup_req = SimpleNamespace(req_id="_warmup_0_")
|
||||
handle = SimpleNamespace(request_id="r2", external_req_id="ext-r2")
|
||||
scheduler_output = SimpleNamespace(
|
||||
scheduled_new_reqs=[new_req, warmup_req],
|
||||
pending_input_registrations=[handle],
|
||||
data_plane_terminal_req_ids={"r0"},
|
||||
finished_req_ids={"r0", "aborted"},
|
||||
)
|
||||
|
||||
runner._prepare_native_data_plane(scheduler_output)
|
||||
|
||||
plane.register_request.assert_called_once_with(new_req) # warmup skipped
|
||||
plane.register_receivers.assert_called_once_with([handle])
|
||||
plane.request_terminal.assert_called_once_with({"r0"})
|
||||
plane.abort_requests.assert_called_once_with({"aborted"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("output_form", ["tuple", "omni"])
|
||||
def test_capture_model_unwraps_exclude_full_and_capture_mtp(output_form):
|
||||
runner = object.__new__(OmniGPUModelRunner)
|
||||
hidden = torch.ones(1, 2)
|
||||
|
||||
def original_forward():
|
||||
if output_form == "tuple":
|
||||
return hidden, {"layers": {}}
|
||||
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
|
||||
|
||||
runner.model = SimpleNamespace(forward=original_forward)
|
||||
runner._model_returns_tuple = True
|
||||
runner._exclude_full_graph = True
|
||||
runner.use_aux_hidden_state_outputs = False
|
||||
piecewise = SimpleNamespace(cg_mode=CUDAGraphMode.PIECEWISE)
|
||||
full = SimpleNamespace(cg_mode=CUDAGraphMode.FULL)
|
||||
runner.cudagraph_manager = SimpleNamespace(
|
||||
_capture_descs={CUDAGraphMode.PIECEWISE: [piecewise], CUDAGraphMode.FULL: [full]},
|
||||
_candidates={(1, 0): [piecewise, full]},
|
||||
)
|
||||
runner.model_state = SimpleNamespace(capture_mtp_graphs=MagicMock())
|
||||
runner._dispatch_mtp_batch_descriptor = MagicMock(return_value="desc")
|
||||
|
||||
def assert_unwrapped(_self):
|
||||
assert torch.equal(runner.model.forward(), hidden) # forward unwrapped during capture
|
||||
return 3
|
||||
|
||||
with patch.object(GPUModelRunner, "capture_model", assert_unwrapped):
|
||||
assert runner.capture_model() == 3
|
||||
|
||||
assert runner.model.forward is original_forward # restored after capture
|
||||
assert runner.cudagraph_manager._capture_descs == {CUDAGraphMode.PIECEWISE: [piecewise]}
|
||||
runner.model_state.capture_mtp_graphs.assert_called_once_with(runner._dispatch_mtp_batch_descriptor)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parallel_config,match",
|
||||
[
|
||||
(dict(pipeline_parallel_size=2, prefill_context_parallel_size=1), "pipeline parallel"),
|
||||
(dict(pipeline_parallel_size=1, prefill_context_parallel_size=2), "prefill context parallelism"),
|
||||
],
|
||||
)
|
||||
def test_mrv2_rejects_parallel_modes_at_startup(parallel_config, match):
|
||||
runner = object.__new__(OmniGPUModelRunner)
|
||||
runner.vllm_config = SimpleNamespace(parallel_config=SimpleNamespace(**parallel_config))
|
||||
with pytest.raises(NotImplementedError, match=match):
|
||||
runner._validate_parallel_support()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flag,expect_omni",
|
||||
[(None, False), ("has_preprocess", True), ("has_postprocess", True), ("have_multimodal_outputs", True)],
|
||||
)
|
||||
def test_init_model_state_factory_dispatches_omni_only(monkeypatch, flag, expect_omni):
|
||||
upstream = MagicMock(return_value=object())
|
||||
monkeypatch.setattr("vllm_omni.worker_v2.model_states._upstream_init_model_state", upstream)
|
||||
monkeypatch.setattr(OmniModelState, "__init__", lambda *args: None)
|
||||
cfg = SimpleNamespace(model_config=SimpleNamespace(architectures=["CustomModel"]))
|
||||
model = SimpleNamespace(**({flag: True} if flag else {}))
|
||||
state = init_omni_model_state(cfg, model, None, torch.device("cpu"))
|
||||
|
||||
if expect_omni:
|
||||
assert isinstance(state, OmniModelState)
|
||||
upstream.assert_not_called()
|
||||
else:
|
||||
upstream.assert_called_once()
|
||||
assert state is upstream.return_value
|
||||
|
||||
|
||||
def test_finish_requests_notifies_model_and_cleans_only_known_slots(monkeypatch):
|
||||
runner = _make_runner()
|
||||
calls = []
|
||||
runner.model = SimpleNamespace(on_requests_finished=lambda ids: calls.append(set(ids)))
|
||||
runner.model_state = MagicMock()
|
||||
monkeypatch.setattr(GPUModelRunner, "finish_requests", lambda *args: None)
|
||||
runner.finish_requests(SimpleNamespace(finished_req_ids={"released"}, preempted_req_ids={"r1"}))
|
||||
assert calls == [{"released"}]
|
||||
assert sorted(c.args[0] for c in runner.model_state.remove_request.call_args_list) == [0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage,declared", [("thinker", False), ("custom_ar", True)])
|
||||
def test_capture_contract_uses_model_declaration(stage, declared):
|
||||
runner = _make_runner()
|
||||
runner.model = SimpleNamespace(model_stage=stage, _returns_tuple=declared)
|
||||
runner._configure_cudagraph_output_contract()
|
||||
assert runner._model_returns_tuple is declared
|
||||
assert runner._exclude_full_graph is declared
|
||||
@@ -0,0 +1,361 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""OmniModelState: mixed-batch reorder, state isolation/slot reuse, MTP
|
||||
graph/eager paths with per-request seed independence, async snapshot ownership."""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.v1.worker.gpu.model_states.default import DefaultModelState
|
||||
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput, OwnedBatchTensor
|
||||
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState, _make_safe_get_rope
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _DummyInputBatch:
|
||||
input_ids: SimpleNamespace
|
||||
query_start_loc: torch.Tensor
|
||||
|
||||
def __init__(self, indices, *, num_computed_tokens_cpu=None):
|
||||
self.idx_mapping_np = indices
|
||||
self.num_reqs = len(indices)
|
||||
self.num_scheduled_tokens = [1] * len(indices)
|
||||
self.query_start_loc_np = list(range(len(indices)))
|
||||
if num_computed_tokens_cpu is not None:
|
||||
self.num_computed_tokens_cpu = np.array(num_computed_tokens_cpu, dtype=np.int32)
|
||||
|
||||
|
||||
def _make_state(max_num_reqs=4, has_preprocess=False, has_postprocess=False, have_multimodal_outputs=False):
|
||||
state = object.__new__(OmniModelState)
|
||||
model = MagicMock()
|
||||
model.has_preprocess = has_preprocess
|
||||
model.has_postprocess = has_postprocess
|
||||
model.have_multimodal_outputs = have_multimodal_outputs
|
||||
model.gpu_resident_buffer_keys = set()
|
||||
model.batched_gpu_staging_keys = set()
|
||||
model.mtp_accepts_per_row_generators = False
|
||||
model.mtp_accepts_req_infos = False
|
||||
model.mtp_output_key = ("codes", "audio")
|
||||
model.mtp_validity_key = None
|
||||
model.mtp_sampling_params = {}
|
||||
model.get_mtp_seed = lambda params: (getattr(params, "extra_args", None) or {}).get("test_seed")
|
||||
model.preprocess_batch_mrv2 = None
|
||||
model.preprocess_decode_batch_mrv2 = None
|
||||
model.preprocess_decode_batch = None
|
||||
model.postprocess_batch_mrv2 = None
|
||||
state.model = model
|
||||
state.scheduler_config = SimpleNamespace(max_num_seqs=max_num_reqs)
|
||||
state.vllm_config = SimpleNamespace(model_config=SimpleNamespace(subtalker_sampling_params={}))
|
||||
state.has_preprocess = has_preprocess
|
||||
state.has_postprocess = has_postprocess
|
||||
state.have_multimodal_outputs = have_multimodal_outputs
|
||||
|
||||
from vllm_omni.worker_v2.model_states.intermediate_buffer import OmniIntermediateBuffer
|
||||
|
||||
state.intermediate_buffer = OmniIntermediateBuffer(max_num_reqs)
|
||||
state._static_inputs_embeds = None
|
||||
state._mtp_generators = {}
|
||||
state._mtp_runner = None
|
||||
for name in ("_mtp_input_ids", "_mtp_input_embeds", "_mtp_hidden", "_mtp_text_step", "_mtp_offsets"):
|
||||
setattr(state, name, None)
|
||||
return state
|
||||
|
||||
|
||||
def _add(state, req_id="r1", idx=0, **req_kwargs):
|
||||
with patch.object(DefaultModelState, "add_request", return_value=None):
|
||||
state.add_request(idx, SimpleNamespace(req_id=req_id, mm_features=[], **req_kwargs))
|
||||
|
||||
|
||||
def _fill_buffers(state, *req_ids):
|
||||
for idx, req_id in enumerate(req_ids):
|
||||
state.intermediate_buffer.buffers[idx] = {"req_id": req_id}
|
||||
|
||||
|
||||
def _seeded(seed):
|
||||
return SimpleNamespace(extra_args={"test_seed": seed}, seed=None)
|
||||
|
||||
|
||||
def _init_static(state, bsz, dim=3):
|
||||
state._mtp_input_ids = torch.zeros(bsz, dtype=torch.long)
|
||||
state._mtp_input_embeds = torch.zeros((bsz, dim))
|
||||
state._mtp_hidden = torch.zeros((bsz, dim))
|
||||
state._mtp_text_step = torch.zeros((bsz, dim))
|
||||
state._mtp_offsets = torch.zeros(bsz, dtype=torch.long)
|
||||
|
||||
|
||||
def _mtp_batches(dim=3):
|
||||
return [(0, 0, (torch.ones(dim), torch.ones(dim) * 2)), (1, 1, (torch.ones(dim) * 3, torch.ones(dim) * 4))]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fwd_ctx():
|
||||
with patch("vllm.forward_context.set_forward_context", return_value=nullcontext()):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize("req_id,expect_validity", [("_warmup_0_", True), ("r1", False)])
|
||||
def test_add_request_declared_validity_only_for_warmup(req_id, expect_validity):
|
||||
# Real requests must not be masked with a fabricated validity key.
|
||||
state = _make_state()
|
||||
state.model.mtp_validity_key = ("meta", "codec_frame_valid")
|
||||
_add(state, req_id, 0)
|
||||
buf = state.intermediate_buffer.buffers[0]
|
||||
assert ("meta" in buf) is expect_validity
|
||||
if expect_validity:
|
||||
assert buf["meta"]["codec_frame_valid"].item() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["index", "req_id", "unknown"])
|
||||
def test_remove_request_state_isolation(mode):
|
||||
state = _make_state()
|
||||
_add(state, "r1", 0)
|
||||
state._mtp_generators["r1"] = torch.Generator(device="cpu")
|
||||
if mode == "unknown":
|
||||
state.remove_request("missing")
|
||||
assert state.intermediate_buffer.buffers[0]["req_id"] == "r1"
|
||||
assert "r1" in state._mtp_generators
|
||||
return
|
||||
state.remove_request(0 if mode == "index" else "r1")
|
||||
assert state.intermediate_buffer.buffers[0] == {}
|
||||
assert "r1" not in state._mtp_generators # recycled slot inherits no seed stream
|
||||
|
||||
|
||||
def test_output_spans_follow_reordered_mixed_batch():
|
||||
# batch=[2, 0]: prefill (3 tokens) reordered ahead of decode (1 token).
|
||||
state = _make_state(have_multimodal_outputs=True)
|
||||
batch = _DummyInputBatch([2, 0])
|
||||
batch.num_scheduled_tokens = [3, 1]
|
||||
batch.query_start_loc_np = [0, 3]
|
||||
state.intermediate_buffer.buffers[2] = {"req_id": "prefill"}
|
||||
state.intermediate_buffer.buffers[0] = {"req_id": "decode"}
|
||||
seen = {}
|
||||
state.model.make_omni_output = lambda hidden, **kwargs: (
|
||||
seen.update(kwargs) or OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
|
||||
)
|
||||
state.postprocess_model_output(torch.zeros(4, 2), batch, SimpleNamespace())
|
||||
assert seen["request_token_spans"] == [(0, 3), (3, 4)]
|
||||
assert [info["req_id"] for info in seen["model_intermediate_buffer"]] == ["prefill", "decode"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook_name", ["preprocess_decode_batch_mrv2", "preprocess_decode_batch", "legacy"])
|
||||
def test_decode_cohort_uses_batch_hook(hook_name):
|
||||
state = _make_state(max_num_reqs=2, has_preprocess=True)
|
||||
state.intermediate_buffer.buffers[0] = {"req_id": "r1", "meta": {"step": 1}}
|
||||
state.intermediate_buffer.buffers[1] = {"req_id": "r2", "meta": {"step": 2}}
|
||||
batch_calls = []
|
||||
|
||||
def preprocess_decode_batch(*, input_ids, input_embeds, req_infos):
|
||||
batch_calls.append(list(req_infos))
|
||||
return (
|
||||
input_ids + 10,
|
||||
input_embeds + 20,
|
||||
torch.tensor([[31.0, 32.0], [41.0, 42.0]]),
|
||||
torch.tensor([[51.0, 52.0], [61.0, 62.0]]),
|
||||
[{"meta": {"step": 11}}, {"meta": {"step": 12}}],
|
||||
)
|
||||
|
||||
if hook_name == "legacy":
|
||||
# Legacy V1 hook without input_embeds is wrapped by the adapter.
|
||||
state.model.preprocess_decode_batch = lambda *, input_ids, req_infos: preprocess_decode_batch(
|
||||
input_ids=input_ids, input_embeds=model_inputs["inputs_embeds"], req_infos=req_infos
|
||||
)
|
||||
else:
|
||||
setattr(state.model, hook_name, preprocess_decode_batch)
|
||||
if hook_name == "preprocess_decode_batch_mrv2":
|
||||
state.model.preprocess_decode_batch = MagicMock(
|
||||
side_effect=AssertionError("MRV2 hook must take precedence")
|
||||
)
|
||||
state.model.preprocess = MagicMock(side_effect=AssertionError("decode requests must use the batch hook"))
|
||||
seen_prepacked = []
|
||||
|
||||
def capture(batches, *_a, prepacked_mtp_inputs=None):
|
||||
seen_prepacked.append(prepacked_mtp_inputs)
|
||||
|
||||
state._run_batched_mtp = capture
|
||||
model_inputs = {
|
||||
"input_ids": torch.tensor([101, 202], dtype=torch.long),
|
||||
"inputs_embeds": torch.tensor([[1.0, 2.0], [3.0, 4.0]]),
|
||||
}
|
||||
state.run_preprocess(
|
||||
_DummyInputBatch([0, 1], num_computed_tokens_cpu=[1, 1]),
|
||||
model_inputs,
|
||||
SimpleNamespace(prompt_len=np.array([1, 1], dtype=np.int32)),
|
||||
)
|
||||
state.model.preprocess.assert_not_called()
|
||||
assert [info["req_id"] for info in batch_calls[0]] == ["r1", "r2"]
|
||||
assert torch.equal(model_inputs["input_ids"], torch.tensor([111, 212]))
|
||||
assert state.intermediate_buffer.buffers[0]["meta"]["step"] == 11
|
||||
assert torch.equal(seen_prepacked[0][0], torch.tensor([[31.0, 32.0], [41.0, 42.0]]))
|
||||
|
||||
|
||||
def test_static_decode_embeddings_refresh_from_input_ids():
|
||||
# FULL-graph replay reads the static buffer: it must hold fresh embeddings.
|
||||
state = _make_state(has_preprocess=True)
|
||||
static_embeds = torch.zeros(1, 4)
|
||||
state._static_inputs_embeds = static_embeds
|
||||
expected = torch.tensor([[1.0, 2.0, 3.0, 4.0]])
|
||||
state.model.embed_input_ids = lambda input_ids: expected.to(input_ids.device)
|
||||
state.model.preprocess = lambda input_ids, input_embeds, **_info: (input_ids, input_embeds, {})
|
||||
state.intermediate_buffer.buffers[0] = {"req_id": "r1"}
|
||||
state.run_preprocess(
|
||||
_DummyInputBatch([0]),
|
||||
{"input_ids": torch.tensor([1049], dtype=torch.long), "inputs_embeds": static_embeds[:1]},
|
||||
)
|
||||
assert torch.equal(static_embeds, expected)
|
||||
original = torch.tensor([[1.0, 2.0]])
|
||||
assert OmniModelState._preprocess_result_needs_writeback(original, original) is False
|
||||
assert OmniModelState._preprocess_result_needs_writeback(original, original.view_as(original)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("owned", [False, True])
|
||||
def test_batched_postprocess_gpu_snapshot_writeback(owned):
|
||||
# batch=[1, 0]: last-token indices follow the reordered batch, the scalar
|
||||
# hook is not called, and both rows share one snapshot storage so a later
|
||||
# graph replay cannot overwrite published rows. Owned producer batches
|
||||
# skip the extra snapshot while borrowed graph output still gets one.
|
||||
state = _make_state(max_num_reqs=2, has_postprocess=True)
|
||||
state.model.gpu_resident_buffer_keys = {("hidden_states", "last")}
|
||||
_fill_buffers(state, "r0", "r1")
|
||||
state.model.postprocess = MagicMock(side_effect=AssertionError("batch hook must replace scalar postprocess"))
|
||||
calls = []
|
||||
produced = []
|
||||
|
||||
def postprocess_batch(*, hidden_states, last_token_indices):
|
||||
calls.append(last_token_indices.clone())
|
||||
gathered = hidden_states.index_select(0, last_token_indices)
|
||||
produced.append(gathered)
|
||||
if owned:
|
||||
return (("hidden_states", "last"), OwnedBatchTensor(gathered))
|
||||
return (("hidden_states", "last"), gathered)
|
||||
|
||||
state.model.postprocess_batch_mrv2 = postprocess_batch
|
||||
batch = _DummyInputBatch([1, 0])
|
||||
batch.num_scheduled_tokens = [2, 2]
|
||||
batch.query_start_loc_np = [0, 2]
|
||||
batch.query_start_loc = torch.tensor([0, 2, 4], dtype=torch.int32)
|
||||
hidden = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]])
|
||||
state.run_postprocess(hidden, batch)
|
||||
|
||||
state.model.postprocess.assert_not_called()
|
||||
assert torch.equal(calls[0], torch.tensor([1, 3], dtype=torch.int32))
|
||||
first = state.intermediate_buffer.buffers[0]["hidden_states"]["last"]
|
||||
second = state.intermediate_buffer.buffers[1]["hidden_states"]["last"]
|
||||
assert torch.equal(first, hidden[3])
|
||||
assert torch.equal(second, hidden[1])
|
||||
assert first.untyped_storage().data_ptr() == second.untyped_storage().data_ptr()
|
||||
if owned:
|
||||
# Owned output: rows view into the producer batch, no extra snapshot.
|
||||
assert first.untyped_storage().data_ptr() == produced[0].untyped_storage().data_ptr()
|
||||
else:
|
||||
# Borrowed/graph output: the snapshot owns its storage, not the producer.
|
||||
assert first.untyped_storage().data_ptr() != produced[0].untyped_storage().data_ptr()
|
||||
|
||||
|
||||
def test_seed_independence_resolve_once_and_sampling_kwargs():
|
||||
state = _make_state(max_num_reqs=2)
|
||||
# vLLM sampling seed must not produce a talker generator.
|
||||
cpu = torch.device("cpu")
|
||||
assert state._get_mtp_generator("r1", SimpleNamespace(extra_args={}, seed=42), cpu) is None
|
||||
# Same model-local seed reproduces identical uniforms regardless of batch makeup.
|
||||
state._mtp_sample_uniforms = torch.empty((2, 2, 4))
|
||||
assert torch.equal(
|
||||
state._prepare_mtp_sample_uniforms([torch.Generator().manual_seed(11)], 1).clone(),
|
||||
state._prepare_mtp_sample_uniforms([torch.Generator().manual_seed(11)], 1),
|
||||
)
|
||||
# Per-request generators are resolved once per step and ride sampling kwargs.
|
||||
state.model.mtp_sampling_params = {"do_sample": True, "temperature": 0.7}
|
||||
state.model.mtp_accepts_per_row_generators = True
|
||||
_init_static(state, 2)
|
||||
state.intermediate_buffer.buffers[0] = {"req_id": "r0", "sampling_params": _seeded(11)}
|
||||
state.intermediate_buffer.buffers[1] = {"req_id": "r1", "sampling_params": _seeded(22)}
|
||||
resolver = MagicMock(wraps=state._get_mtp_generator)
|
||||
state._get_mtp_generator = resolver
|
||||
|
||||
def mtp(input_ids, input_embeds, last_hidden, text_step, **kwargs):
|
||||
assert (kwargs["do_sample"], kwargs["temperature"]) == (True, 0.7)
|
||||
assert all(isinstance(g, torch.Generator) for g in kwargs["generators"])
|
||||
return input_embeds + 10, torch.tensor([[1, 2, 3], [4, 5, 6]])
|
||||
|
||||
state.model.mtp = mtp
|
||||
state._run_batched_mtp(
|
||||
_mtp_batches(), torch.tensor([101, 202]), torch.zeros(2, 3), _DummyInputBatch([0, 1]), {("codes", "audio")}
|
||||
)
|
||||
assert resolver.call_count == 2
|
||||
assert torch.equal(state.intermediate_buffer.buffers[0]["codes"]["audio"], torch.tensor([[1, 2, 3]]))
|
||||
assert torch.equal(state.intermediate_buffer.buffers[1]["codes"]["audio"], torch.tensor([[4, 5, 6]]))
|
||||
|
||||
|
||||
def test_seeded_mtp_bypasses_outer_graph_runner():
|
||||
state = _make_state(max_num_reqs=2)
|
||||
raw_calls = []
|
||||
|
||||
def mtp(*args, **kwargs):
|
||||
raw_calls.append(kwargs)
|
||||
return args[1], args[0].reshape(-1, 1)
|
||||
|
||||
state.model.mtp = mtp
|
||||
state._mtp_runner = MagicMock(side_effect=AssertionError("seeded sampling must not replay the graph"))
|
||||
generators = [torch.Generator().manual_seed(11), torch.Generator().manual_seed(22)]
|
||||
state._call_mtp_runner(
|
||||
torch.tensor([101, 202]), torch.zeros(2, 3), torch.zeros(2, 3), torch.zeros(2, 3), generators=generators
|
||||
)
|
||||
assert raw_calls == [{"generators": generators}]
|
||||
state._mtp_runner.assert_not_called()
|
||||
|
||||
|
||||
def test_run_batched_mtp_uses_dispatched_graph_descriptor():
|
||||
state = _make_state(max_num_reqs=4)
|
||||
|
||||
class _FakeGraphRunner:
|
||||
def __call__(self, input_ids, input_embeds, last_hidden, text_step, **kwargs):
|
||||
assert input_ids.shape[0] == 4 # graph runs the padded descriptor size
|
||||
return input_embeds + 2, torch.arange(12, dtype=torch.long).reshape(4, 3)
|
||||
|
||||
state._mtp_runner = _FakeGraphRunner()
|
||||
_init_static(state, 4)
|
||||
_fill_buffers(state, "r0", "r1")
|
||||
graph_desc = SimpleNamespace(cg_mode="FULL", num_tokens=4)
|
||||
set_ctx = MagicMock(return_value=nullcontext())
|
||||
with (
|
||||
patch(
|
||||
"vllm_omni.worker_v2.model_states.omni_model_state.current_omni_platform.get_graph_wrapper_cls",
|
||||
return_value=_FakeGraphRunner,
|
||||
),
|
||||
patch("vllm.forward_context.set_forward_context", set_ctx),
|
||||
):
|
||||
state._run_batched_mtp(
|
||||
_mtp_batches(),
|
||||
torch.tensor([101, 202]),
|
||||
torch.zeros(2, 3),
|
||||
_DummyInputBatch([0, 1]),
|
||||
{("codes", "audio")},
|
||||
lambda bsz: graph_desc,
|
||||
)
|
||||
_, kwargs = set_ctx.call_args
|
||||
assert kwargs["batch_descriptor"] is graph_desc
|
||||
assert kwargs["num_tokens"] == 4
|
||||
assert torch.equal(state.intermediate_buffer.buffers[0]["codes"]["audio"], torch.tensor([[0, 1, 2]]))
|
||||
assert torch.equal(state.intermediate_buffer.buffers[1]["codes"]["audio"], torch.tensor([[3, 4, 5]]))
|
||||
|
||||
|
||||
def test_rope_shim_propagates_type_error():
|
||||
# vLLM rope API drift (TypeError) must not be swallowed by the shim.
|
||||
def broken_get_rope(*_args, **_kwargs):
|
||||
raise TypeError("vLLM rope API drift")
|
||||
|
||||
with pytest.raises(TypeError, match="rope API drift"):
|
||||
_make_safe_get_rope(broken_get_rope)(SimpleNamespace(uses_mrope=False), object())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("output_key", [None, ("only_one",)])
|
||||
def test_mtp_requires_model_declared_output_key(output_key):
|
||||
state = _make_state()
|
||||
model = SimpleNamespace(mtp=lambda: None, mtp_output_key=output_key)
|
||||
with pytest.raises(TypeError, match="must declare mtp_output_key"):
|
||||
state._init_mtp_runner(model)
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import MISSING, field
|
||||
from typing import Any
|
||||
|
||||
@@ -124,6 +127,8 @@ class OmniModelConfig(ModelConfig):
|
||||
async_chunk: bool = False
|
||||
session_mode: str = "turn"
|
||||
retains_state_across_chunks: bool = False
|
||||
use_v2_model_runner: bool = False
|
||||
supports_native_mrv2_data_plane: bool = False
|
||||
# Stage-1 active stream slots; 0 keeps legacy chunk-level round-robin.
|
||||
active_stream_window: int = 0
|
||||
duplex_max_sessions: int = 1
|
||||
@@ -134,6 +139,7 @@ class OmniModelConfig(ModelConfig):
|
||||
# Optional dotted path of a per-stage pooling-output decoder applied
|
||||
# worker-side before IPC. Read by the AR scheduler.
|
||||
pooling_output_decoder: str | None = None
|
||||
final_output: bool = False
|
||||
hf_config_name: str | None = None
|
||||
custom_process_next_stage_input_func: str | None = None
|
||||
stage_connector_config: dict[str, Any] = field(
|
||||
|
||||
@@ -170,7 +170,10 @@ class _ModelEngineOverrides(TypedDict, total=False):
|
||||
limit_mm_per_prompt: dict[str, Any]
|
||||
interleave_mm_strings: bool
|
||||
media_io_kwargs: dict[str, Any]
|
||||
final_output: bool
|
||||
active_stream_window: int
|
||||
use_v2_model_runner: bool
|
||||
supports_native_mrv2_data_plane: bool
|
||||
enable_sleep_mode: bool
|
||||
subtalker_sampling_params: dict[str, Any]
|
||||
silence_ban_frames: int
|
||||
@@ -493,9 +496,12 @@ class OmniStageModelConfig(_TrackExplicitConfigFields):
|
||||
# MiniCPM interleaved AV packing and media decode knobs (Daily-Omni).
|
||||
interleave_mm_strings: bool | None = None
|
||||
media_io_kwargs: dict[str, Any] | None = None
|
||||
final_output: bool = False
|
||||
active_stream_window: int = Field(default=0, ge=0)
|
||||
session_mode: str = "turn"
|
||||
duplex_max_sessions: int = Field(default=1, ge=1)
|
||||
use_v2_model_runner: bool = False
|
||||
supports_native_mrv2_data_plane: bool = False
|
||||
enable_sleep_mode: bool = False
|
||||
default_sampling_params: dict[str, Any] | None = None
|
||||
subtalker_sampling_params: dict[str, Any] | None = None
|
||||
@@ -1965,8 +1971,14 @@ def _build_model_config(
|
||||
kwargs["dtype"] = _copy_value(deploy.dtype)
|
||||
if "active_stream_window" not in kwargs:
|
||||
kwargs["active_stream_window"] = _copy_value(deploy.active_stream_window)
|
||||
kwargs["final_output"] = topology.final_output
|
||||
if "custom_voice_dir" not in kwargs and deploy.custom_voice_dir is not None:
|
||||
kwargs["custom_voice_dir"] = _copy_value(deploy.custom_voice_dir)
|
||||
kwargs.setdefault("use_v2_model_runner", deploy.model_runner == "v2")
|
||||
kwargs.setdefault(
|
||||
"supports_native_mrv2_data_plane",
|
||||
topology.supports_native_mrv2_data_plane,
|
||||
)
|
||||
if "has_sampling_extra_args" not in kwargs:
|
||||
kwargs["has_sampling_extra_args"] = bool((default_sampling_params or {}).get("extra_args"))
|
||||
if "model_subdir" not in kwargs and topology.model_subdir is not None:
|
||||
|
||||
@@ -11,7 +11,7 @@ from collections.abc import Callable, Mapping
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
from transformers import PretrainedConfig
|
||||
from vllm.logger import init_logger
|
||||
@@ -250,6 +250,7 @@ class StagePipelineConfig:
|
||||
# Alternates picked by ``merge_pipeline_deploy`` based on ``deploy.async_chunk``.
|
||||
async_chunk_process_next_stage_input_func: str | None = None
|
||||
sync_process_input_func: str | None = None
|
||||
supports_native_mrv2_data_plane: bool = False
|
||||
# Rewrites the Stage-0 view of a raw prompt before vLLM input processing.
|
||||
# The callable receives ``(prompt, sampling_params_list)``; downstream
|
||||
# stages continue to receive the original prompt.
|
||||
@@ -566,6 +567,7 @@ class DeployConfig:
|
||||
|
||||
async_chunk: bool = True
|
||||
session_mode: str = "turn"
|
||||
model_runner: Literal["v1", "v2"] = "v1"
|
||||
# Stage-1 active stream slots; 0 preserves legacy all-stream cycling.
|
||||
active_stream_window: int = 0
|
||||
duplex_session: DuplexSessionRuntimeConfig = field(default_factory=DuplexSessionRuntimeConfig)
|
||||
@@ -736,6 +738,16 @@ def _merge_platforms(
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_connectors(
|
||||
base: dict[str, Any] | None,
|
||||
overlay: dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Deep-merge named connector definitions from a deploy overlay."""
|
||||
if not base and not overlay:
|
||||
return None
|
||||
return _get_recursively_merged_dict(base or {}, overlay or {})
|
||||
|
||||
|
||||
def resolve_deploy_yaml(path: str | Path) -> dict[str, Any]:
|
||||
"""Load a deploy YAML with optional ``base_config`` inheritance."""
|
||||
raw_dict = to_dict(load_yaml_config(path))
|
||||
@@ -748,12 +760,15 @@ def resolve_deploy_yaml(path: str | Path) -> dict[str, Any]:
|
||||
base_path = Path(path).parent / base_path
|
||||
base_dict = resolve_deploy_yaml(base_path)
|
||||
|
||||
# Merge top-level scalars: overlay wins. ``stages:`` and ``platforms:``
|
||||
# are deep-merged below so an overlay can layer on top of the base.
|
||||
# Merge top-level scalars: overlay wins. Structured sections are merged
|
||||
# below so a thin overlay does not discard inherited runtime contracts.
|
||||
merged = {
|
||||
**base_dict,
|
||||
**{k: v for k, v in raw_dict.items() if k not in ("stages", "platforms")},
|
||||
**{k: v for k, v in raw_dict.items() if k not in ("connectors", "stages", "platforms")},
|
||||
}
|
||||
merged_connectors = _merge_connectors(base_dict.get("connectors"), raw_dict.get("connectors"))
|
||||
if merged_connectors is not None:
|
||||
merged["connectors"] = merged_connectors
|
||||
merged["stages"] = _merge_stage_lists(base_dict.get("stages"), raw_dict.get("stages"))
|
||||
merged_platforms = _merge_platforms(base_dict.get("platforms"), raw_dict.get("platforms"))
|
||||
if merged_platforms is not None:
|
||||
@@ -773,9 +788,14 @@ def load_deploy_config(path: str | Path) -> DeployConfig:
|
||||
|
||||
stages = [_parse_stage_deploy(s) for s in raw_dict.get("stages", [])]
|
||||
|
||||
model_runner = raw_dict.get("model_runner", "v1")
|
||||
if model_runner not in ("v1", "v2"):
|
||||
raise ValueError(f"model_runner must be one of ('v1', 'v2'), got {model_runner!r}")
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"async_chunk": raw_dict.get("async_chunk", True),
|
||||
"session_mode": raw_dict.get("session_mode", "turn"),
|
||||
"model_runner": model_runner,
|
||||
"active_stream_window": int(raw_dict.get("active_stream_window", 0) or 0),
|
||||
"duplex_session": DuplexSessionRuntimeConfig(**(raw_dict.get("duplex_session") or {})),
|
||||
"connectors": raw_dict.get("connectors", None),
|
||||
@@ -828,15 +848,25 @@ def _apply_platform_overrides(
|
||||
deploy: DeployConfig,
|
||||
platform: str | None = None,
|
||||
) -> DeployConfig:
|
||||
"""Merge platform-specific stage overrides into deploy config."""
|
||||
"""Merge platform-specific runner and stage overrides into deploy config."""
|
||||
if platform is None:
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
device_name = current_omni_platform.device_name
|
||||
platform = device_name.lower() if device_name is not None else None
|
||||
platform_section = (deploy.platforms or {}).get(platform) if platform is not None else None
|
||||
if platform_section is not None and "model_runner" in platform_section:
|
||||
model_runner = platform_section["model_runner"]
|
||||
if model_runner not in ("v1", "v2"):
|
||||
raise ValueError(f"platform model_runner must be one of ('v1', 'v2'), got {model_runner!r}")
|
||||
deploy.model_runner = model_runner
|
||||
if deploy.model_runner == "v2" and platform in {"npu", "xpu"}:
|
||||
raise NotImplementedError(
|
||||
f"Model Runner V2 is not supported on {platform.upper()}: "
|
||||
"the platform worker still uses the legacy chunk-transfer data plane."
|
||||
)
|
||||
if platform is None or deploy.platforms is None:
|
||||
return deploy
|
||||
platform_section = deploy.platforms.get(platform)
|
||||
if platform_section is None:
|
||||
return deploy
|
||||
|
||||
@@ -998,6 +1028,28 @@ def _build_engine_args(
|
||||
# model-owned streaming state. Propagate it to every stage instead of
|
||||
# making individual models duplicate the value in connector extras.
|
||||
engine_args["duplex_max_sessions"] = deploy.duplex_session.max_sessions
|
||||
# The runner selection is a deploy-topology decision owned by the
|
||||
# ``model_runner`` field; do not let an opaque ``engine_extras`` entry
|
||||
# silently veto or force it per stage.
|
||||
if ds is not None:
|
||||
for reserved in ("use_v2_model_runner", "supports_native_mrv2_data_plane"):
|
||||
if reserved in ds.engine_extras:
|
||||
raise ValueError(
|
||||
f"stage {ds.stage_id}: {reserved!r} must not be set via engine_extras; "
|
||||
"it is derived from the deploy-level `model_runner` field and the "
|
||||
"pipeline's `supports_native_mrv2_data_plane` declaration."
|
||||
)
|
||||
engine_args["use_v2_model_runner"] = deploy.model_runner == "v2"
|
||||
engine_args["supports_native_mrv2_data_plane"] = bool(ps.supports_native_mrv2_data_plane)
|
||||
if deploy.model_runner == "v2" and not ps.supports_native_mrv2_data_plane:
|
||||
logger.warning(
|
||||
"Stage %s (%s) selects model_runner=v2 without declaring "
|
||||
"supports_native_mrv2_data_plane. It will use the legacy transport path; "
|
||||
"MRV2 support for this pipeline has not been validated. Use model_runner=v1 "
|
||||
"unless you are validating a new MRV2 integration.",
|
||||
ps.stage_id,
|
||||
ps.model_arch or pipeline.model_arch or pipeline.model_type,
|
||||
)
|
||||
if ps.omni_kv_config:
|
||||
engine_args["omni_kv_config"] = dict(ps.omni_kv_config)
|
||||
engine_args["requires_full_payload_input"] = ps.requires_full_payload_input
|
||||
@@ -1196,6 +1248,9 @@ class StageConfig:
|
||||
else:
|
||||
engine_args[key] = value
|
||||
|
||||
# Terminal-stage ownership comes from topology, not engine overrides.
|
||||
engine_args["final_output"] = self.final_output
|
||||
|
||||
# Build runtime config from YAML defaults + CLI overrides
|
||||
runtime: dict[str, Any] = dict(self.yaml_runtime)
|
||||
runtime.setdefault("process", True)
|
||||
|
||||
@@ -33,6 +33,21 @@ from vllm_omni.engine.serialization import deserialize_additional_information
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _should_emit_engine_output(
|
||||
model_config: Any,
|
||||
*,
|
||||
stopped: bool,
|
||||
has_control: bool,
|
||||
) -> bool:
|
||||
if stopped or has_control:
|
||||
return True
|
||||
return not (
|
||||
bool(getattr(model_config, "use_v2_model_runner", False))
|
||||
and bool(getattr(model_config, "async_chunk", False))
|
||||
and not bool(getattr(model_config, "final_output", False))
|
||||
)
|
||||
|
||||
|
||||
class SampledLogprobContractError(RuntimeError):
|
||||
"""The model runner returned unusable sampled-token logprobs."""
|
||||
|
||||
@@ -84,6 +99,8 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
core scheduling logic.
|
||||
"""
|
||||
|
||||
max_num_running_reqs: int
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Track requests that need KV cache transfer when finished
|
||||
@@ -200,9 +217,10 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
finished_status: RequestStatus,
|
||||
) -> list[Request]:
|
||||
"""Finish requests and discard any incomplete KV-wait timing."""
|
||||
cleanup_ids: Iterable[str]
|
||||
if isinstance(request_ids, str):
|
||||
cleanup_ids = (request_ids,)
|
||||
finish_request_ids: str | tuple[str, ...] | None = request_ids
|
||||
finish_request_ids: str | Iterable[str] | None = request_ids
|
||||
elif request_ids is None:
|
||||
cleanup_ids = ()
|
||||
finish_request_ids = None
|
||||
@@ -329,15 +347,22 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
self._process_pending_omni_inputs(model_mode="ar")
|
||||
self._drop_aborted_queued_requests()
|
||||
self._resync_streaming_input_counter()
|
||||
|
||||
original_waiting = None
|
||||
if self._should_defer_waiting_admission():
|
||||
original_waiting = waiting
|
||||
self.waiting = create_request_queue(self.policy)
|
||||
|
||||
original_max_num_running_reqs = self.max_num_running_reqs
|
||||
async_chunk_transport = self._async_chunk_transport_enabled()
|
||||
reserved_running_slots = (
|
||||
self._get_async_chunk_reserved_running_slots() if async_chunk_transport and self.use_v2_model_runner else 0
|
||||
)
|
||||
if reserved_running_slots:
|
||||
self.max_num_running_reqs = max(0, original_max_num_running_reqs - reserved_running_slots)
|
||||
try:
|
||||
scheduler_output = super().schedule(throttle_prefills)
|
||||
finally:
|
||||
self.max_num_running_reqs = original_max_num_running_reqs
|
||||
if original_waiting is not None:
|
||||
deferred_waiting = list(self.waiting)
|
||||
if deferred_waiting:
|
||||
@@ -441,7 +466,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
assert num_tokens_scheduled > 0
|
||||
request = self.requests.get(req_id)
|
||||
if request is not None:
|
||||
# vLLM 0.26: settle the in-flight tokens counted in schedule().
|
||||
# Settle the in-flight tokens counted in schedule().
|
||||
# Must happen before the skips below — failed-KV-load and
|
||||
# already-finished requests were incremented too, and the two
|
||||
# readers (allocate_slots, _connector_finished) clamp with
|
||||
@@ -609,6 +634,11 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
confirmed_num_computed_tokens = None
|
||||
boundary_generation = None
|
||||
# Capture before resumable stop handling can clear token history.
|
||||
output_token_ids: Any = getattr(request, "output_token_ids", None)
|
||||
if output_token_ids is None:
|
||||
output_token_ids = getattr(request, "_output_token_ids", ())
|
||||
num_generation_tokens = len(output_token_ids)
|
||||
if stopped:
|
||||
if self.chunk_transfer_adapter is not None:
|
||||
confirmed_num_computed_tokens = self.chunk_transfer_adapter._confirmed_num_computed_tokens(request)
|
||||
@@ -627,6 +657,8 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
is_segment_finished = not finished
|
||||
if finished:
|
||||
request.resumable = False
|
||||
if self._native_data_plane:
|
||||
self._pending_data_plane_terminal_req_ids.add(req_id)
|
||||
if not finished:
|
||||
# for streaming input request only
|
||||
if self.chunk_transfer_adapter:
|
||||
@@ -683,7 +715,18 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
# Get prompt logprobs for this request.
|
||||
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
|
||||
if new_token_ids or mm_output is not None or pooler_output is not None or kv_transfer_params or stopped:
|
||||
has_stage_output = (
|
||||
bool(new_token_ids)
|
||||
or mm_output is not None
|
||||
or pooler_output is not None
|
||||
or kv_transfer_params
|
||||
or stopped
|
||||
)
|
||||
if has_stage_output and _should_emit_engine_output(
|
||||
self.vllm_config.model_config,
|
||||
stopped=stopped,
|
||||
has_control=kv_transfer_params is not None,
|
||||
):
|
||||
OmniSchedulerMixin._append_request_output(
|
||||
self,
|
||||
outputs,
|
||||
@@ -702,6 +745,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
num_nans_in_logits=request.num_nans_in_logits,
|
||||
is_segment_finished=is_segment_finished,
|
||||
new_prompt_len_snapshot=self._new_prompt_len_snapshot.get(req_id),
|
||||
num_generation_tokens=num_generation_tokens,
|
||||
)
|
||||
else:
|
||||
# Invariant: EngineCore returns no partial prefill outputs.
|
||||
@@ -877,7 +921,8 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
# This streaming update has already been dequeued. Report the
|
||||
# permanent contract failure so the next scheduling pass
|
||||
# finishes only this request instead of crashing EngineCore.
|
||||
self.chunk_transfer_adapter.record_receive_failure(req_id, str(exc))
|
||||
if self.chunk_transfer_adapter is not None:
|
||||
self.chunk_transfer_adapter.record_receive_failure(req_id, str(exc))
|
||||
return
|
||||
if replaced is not None:
|
||||
if replaced:
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from vllm.compilation.cuda_graph import CUDAGraphStat
|
||||
from vllm.logger import init_logger
|
||||
@@ -15,11 +15,7 @@ from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.core.sched.request_queue import create_request_queue
|
||||
from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler
|
||||
from vllm.v1.core.sched.utils import remove_all
|
||||
from vllm.v1.engine import (
|
||||
EngineCoreEventType,
|
||||
EngineCoreOutput,
|
||||
EngineCoreOutputs,
|
||||
)
|
||||
from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs
|
||||
from vllm.v1.metrics.perf import PerfStats
|
||||
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
|
||||
from vllm.v1.spec_decode.metrics import SpecDecodingStats
|
||||
@@ -27,12 +23,33 @@ from vllm.v1.spec_decode.metrics import SpecDecodingStats
|
||||
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
|
||||
from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestData
|
||||
from vllm_omni.core.sched.utils import omni_routed_experts_for_request
|
||||
from vllm_omni.engine.serialization import deserialize_additional_information
|
||||
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.v1.core.sched.request_queue import RequestQueue
|
||||
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _has_async_chunk_payload_to_run(request: Request) -> bool:
|
||||
additional_information = getattr(request, "additional_information", None)
|
||||
if not isinstance(additional_information, dict):
|
||||
additional_information = deserialize_additional_information(additional_information)
|
||||
|
||||
codes = additional_information.get("codes")
|
||||
audio = codes.get("audio") if isinstance(codes, dict) else additional_information.get("codes.audio")
|
||||
if audio is None:
|
||||
return False
|
||||
if hasattr(audio, "numel"):
|
||||
return bool(audio.numel())
|
||||
return bool(audio)
|
||||
|
||||
|
||||
class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
waiting: RequestQueue
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
model_config = self.vllm_config.model_config
|
||||
@@ -40,6 +57,25 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
self._retains_state_across_chunks = bool(getattr(model_config, "retains_state_across_chunks", False))
|
||||
self._pending_finish_reqs: list[Request] = []
|
||||
|
||||
def _is_done_receiving_chunks(self, request_id: str) -> bool:
|
||||
if self.chunk_transfer_adapter is not None:
|
||||
return self.chunk_transfer_adapter.is_done_receiving_chunks(request_id)
|
||||
coordinator = self.input_coordinator
|
||||
return bool(self._native_data_plane and coordinator is not None and request_id in coordinator.finished_requests)
|
||||
|
||||
def _input_execution_is_terminal(
|
||||
self,
|
||||
request_id: str,
|
||||
scheduler_output: SchedulerOutput,
|
||||
) -> bool:
|
||||
if self._native_data_plane:
|
||||
return request_id in getattr(
|
||||
scheduler_output,
|
||||
"input_terminal_req_ids",
|
||||
set(),
|
||||
)
|
||||
return self._is_done_receiving_chunks(request_id)
|
||||
|
||||
@staticmethod
|
||||
def _record_prefill_stats(request: Request) -> None:
|
||||
"""Mirror upstream first-prefill prompt accounting for generation stages.
|
||||
@@ -73,6 +109,29 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
return False
|
||||
return super()._handle_stopped_request(request)
|
||||
|
||||
def _requeue_completed_native_chunks(self) -> None:
|
||||
"""Return completed native generation chunks to the admission queue.
|
||||
|
||||
A generation batch slot belongs to one chunk, not the entire stream.
|
||||
Keep requests with outstanding output in ``running`` so their state
|
||||
cannot be reused before completion. Other live requests join the tail
|
||||
of ``waiting`` without freeing their decoder state or KV blocks.
|
||||
Preserve WAITING_FOR_CHUNK until the coordinator observes fresh input.
|
||||
Stateful codecs retain lifetime admission until their decoder state is
|
||||
released, even when no chunk is currently executing.
|
||||
"""
|
||||
if not getattr(self, "_native_data_plane", False) or self._retains_state_across_chunks:
|
||||
return
|
||||
in_flight: list[Request] = []
|
||||
for request in self.running:
|
||||
if request.num_in_flight_tokens > 0 or request.is_finished():
|
||||
in_flight.append(request)
|
||||
continue
|
||||
if request.status == RequestStatus.RUNNING:
|
||||
request.status = RequestStatus.WAITING
|
||||
self.waiting.add_request(request)
|
||||
self.running = in_flight
|
||||
|
||||
def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
|
||||
"""One-shot generation fast path:
|
||||
- Feed all input tokens of the request at once
|
||||
@@ -82,6 +141,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
"""
|
||||
|
||||
token_budget = self.max_num_scheduled_tokens
|
||||
execution_batch_size = self.max_num_running_reqs
|
||||
if self._pause_state == PauseState.PAUSED_ALL:
|
||||
token_budget = 0
|
||||
scheduled_timestamp = time.monotonic()
|
||||
@@ -102,14 +162,22 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
skipped_waiting_requests = create_request_queue(self.policy)
|
||||
req_index = 0
|
||||
self._drop_aborted_queued_requests()
|
||||
self._requeue_completed_native_chunks()
|
||||
self._process_pending_omni_inputs(model_mode="generation")
|
||||
self._drop_aborted_queued_requests()
|
||||
self._resync_streaming_input_counter()
|
||||
async_chunk_transport = self._async_chunk_transport_enabled()
|
||||
native_chunks = bool(getattr(self, "_native_data_plane", False))
|
||||
# Parking releases an execution slot, but stateful codecs retain their
|
||||
# request-owned state until completion or abort, in either runner.
|
||||
reserved_running_slots = (
|
||||
self._get_async_chunk_reserved_running_slots() if self._retains_state_across_chunks else 0
|
||||
)
|
||||
|
||||
# OMNI: Track requests that are already finished (e.g., marked by connector)
|
||||
# These should be removed from running and not scheduled
|
||||
already_finished_reqs: set[Request] = set()
|
||||
while req_index < len(self.running) and token_budget > 0:
|
||||
while req_index < len(self.running) and token_budget > 0 and len(num_scheduled_tokens) < execution_batch_size:
|
||||
request = self.running[req_index]
|
||||
# OMNI: Skip requests that are not in self.requests
|
||||
if request.request_id not in self.requests or (
|
||||
@@ -119,6 +187,12 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
req_index += 1
|
||||
continue
|
||||
|
||||
if native_chunks and request.num_in_flight_tokens > 0:
|
||||
# Overlap different streams, never two stateful chunks from
|
||||
# the same stream. Readiness remains pending for the next step.
|
||||
req_index += 1
|
||||
continue
|
||||
|
||||
num_computed_tokens = request.num_computed_tokens
|
||||
required_tokens = len(request.prompt_token_ids) - num_computed_tokens
|
||||
if not self.scheduler_config.enable_chunked_prefill and required_tokens > token_budget:
|
||||
@@ -127,12 +201,16 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
break
|
||||
# async_chunk: don't schedule placeholder tokens when no new chunk is available.
|
||||
if required_tokens <= 0:
|
||||
if self.chunk_transfer_adapter is not None and self.chunk_transfer_adapter.is_done_receiving_chunks(
|
||||
request.request_id
|
||||
):
|
||||
self._pending_finish_reqs.append(request)
|
||||
req_index += 1
|
||||
continue
|
||||
if async_chunk_transport and self._is_done_receiving_chunks(request.request_id):
|
||||
if len(request.prompt_token_ids) == 0 and _has_async_chunk_payload_to_run(request):
|
||||
required_tokens = 1
|
||||
else:
|
||||
self._pending_finish_reqs.append(request)
|
||||
req_index += 1
|
||||
continue
|
||||
else:
|
||||
req_index += 1
|
||||
continue
|
||||
num_new_tokens = min(required_tokens, token_budget)
|
||||
new_blocks = self.kv_cache_manager.allocate_slots(
|
||||
request,
|
||||
@@ -161,20 +239,27 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
if already_finished_reqs:
|
||||
self.running = remove_all(self.running, already_finished_reqs)
|
||||
|
||||
# Fast path selection and scheduling for one-shot generation requests,
|
||||
# independent of pooling_params.
|
||||
while self.waiting and token_budget > 0 and self._pause_state == PauseState.UNPAUSED:
|
||||
# Requests waiting for their next chunk are temporarily absent
|
||||
# from `running`, but stateful models still retain their model
|
||||
# runner slot. Mirror vLLM's treatment of
|
||||
# `num_waiting_for_streaming_input` when enforcing max_num_seqs.
|
||||
num_running = len(self.running)
|
||||
if self._retains_state_across_chunks and self.chunk_transfer_adapter is not None:
|
||||
num_running += self.chunk_transfer_adapter.num_running_waiting_for_chunk
|
||||
if num_running >= self.max_num_running_reqs:
|
||||
break
|
||||
|
||||
# Fast path selection and scheduling (treat all as diffusion requests,
|
||||
# independent of pooling_params)
|
||||
while (
|
||||
self.waiting
|
||||
and token_budget > 0
|
||||
and len(num_scheduled_tokens) < execution_batch_size
|
||||
and (
|
||||
len(num_scheduled_tokens)
|
||||
if native_chunks and not self._retains_state_across_chunks
|
||||
else len(self.running) + reserved_running_slots
|
||||
)
|
||||
< self.max_num_running_reqs
|
||||
and self._pause_state == PauseState.UNPAUSED
|
||||
):
|
||||
request = self.waiting.peek_request()
|
||||
if native_chunks and request.num_in_flight_tokens > 0:
|
||||
# A restored waiting entry can still own an outstanding batch.
|
||||
# Do not let it block other ready streams or execute it twice.
|
||||
self.waiting.pop_request()
|
||||
skipped_waiting_requests.add_request(request)
|
||||
continue
|
||||
# OMNI: Skip requests that are not in self.requests
|
||||
if request.request_id not in self.requests or (
|
||||
self.chunk_transfer_adapter is None and request.status == RequestStatus.FINISHED_STOPPED
|
||||
@@ -184,11 +269,12 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
continue
|
||||
|
||||
# async_chunk: wait for the first upstream chunk (don't start with placeholders).
|
||||
if self.chunk_transfer_adapter is not None and len(request.prompt_token_ids) == 0:
|
||||
if self.chunk_transfer_adapter.is_done_receiving_chunks(request.request_id):
|
||||
self.waiting.pop_request()
|
||||
self._pending_finish_reqs.append(request)
|
||||
continue
|
||||
if async_chunk_transport and len(request.prompt_token_ids) == 0:
|
||||
if self._is_done_receiving_chunks(request.request_id):
|
||||
if not _has_async_chunk_payload_to_run(request):
|
||||
self.waiting.pop_request()
|
||||
self._pending_finish_reqs.append(request)
|
||||
continue
|
||||
else:
|
||||
self.waiting.pop_request()
|
||||
skipped_waiting_requests.prepend_request(request)
|
||||
@@ -197,6 +283,22 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
# Allocate all input tokens for the request in one shot
|
||||
# (allocate 1 placeholder if zero)
|
||||
required_tokens = max(len(request.prompt_token_ids), 1)
|
||||
if native_chunks:
|
||||
required_tokens = len(request.prompt_token_ids) - request.num_computed_tokens
|
||||
if required_tokens <= 0:
|
||||
if (
|
||||
self._is_done_receiving_chunks(request.request_id)
|
||||
and not request.prompt_token_ids
|
||||
and _has_async_chunk_payload_to_run(request)
|
||||
):
|
||||
required_tokens = 1
|
||||
else:
|
||||
self.waiting.pop_request()
|
||||
if self._is_done_receiving_chunks(request.request_id):
|
||||
self._pending_finish_reqs.append(request)
|
||||
else:
|
||||
skipped_waiting_requests.add_request(request)
|
||||
continue
|
||||
num_new_tokens = min(required_tokens, token_budget)
|
||||
new_blocks = self.kv_cache_manager.allocate_slots(
|
||||
request,
|
||||
@@ -229,7 +331,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
# If fast path scheduled none, fall back to the original scheduling
|
||||
if not num_scheduled_tokens:
|
||||
if self.chunk_transfer_adapter:
|
||||
if async_chunk_transport:
|
||||
# Don't fall back: base scheduler doesn't handle async_chunk
|
||||
# requests with empty prompt_token_ids.
|
||||
self._restore_omni_wait_queues()
|
||||
@@ -245,7 +347,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
any_request = self.running[0]
|
||||
num_common_prefix_blocks = self.kv_cache_manager.get_num_common_prefix_blocks(any_request.request_id)
|
||||
|
||||
# Assemble SchedulerOutput (align with v0.14.0)
|
||||
# Assemble the current SchedulerOutput contract.
|
||||
if self.use_v2_model_runner:
|
||||
# No resumed reqs in fast path; pass prefill_token_ids for new reqs.
|
||||
new_reqs_data = [
|
||||
@@ -284,7 +386,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
total_num_scheduled_tokens = sum(num_scheduled_tokens.values())
|
||||
|
||||
# Record the request ids scheduled in this step (v0.14.0 behavior).
|
||||
# Record request ids scheduled in this step for the next update.
|
||||
self.prev_step_scheduled_req_ids.clear()
|
||||
self.prev_step_scheduled_req_ids.update(num_scheduled_tokens.keys())
|
||||
|
||||
@@ -404,7 +506,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
assert num_tokens_scheduled > 0
|
||||
request = self.requests.get(req_id)
|
||||
if request is not None:
|
||||
# vLLM 0.26: settle the in-flight tokens counted in schedule().
|
||||
# Settle the in-flight tokens counted in schedule().
|
||||
# Must happen before the skips below — failed-KV-load and
|
||||
# already-finished requests were incremented too, and the two
|
||||
# readers (allocate_slots, _connector_finished) clamp with
|
||||
@@ -496,10 +598,16 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
if (
|
||||
request.status == RequestStatus.FINISHED_ERROR
|
||||
or request.status == RequestStatus.FINISHED_STOPPED
|
||||
or (self.chunk_transfer_adapter is None and request.num_computed_tokens >= request.num_prompt_tokens)
|
||||
or (
|
||||
self.chunk_transfer_adapter is not None
|
||||
and self.chunk_transfer_adapter.is_done_receiving_chunks(request.request_id)
|
||||
not self._async_chunk_transport_enabled()
|
||||
and request.num_computed_tokens >= request.num_prompt_tokens
|
||||
)
|
||||
or (
|
||||
self._async_chunk_transport_enabled()
|
||||
and self._input_execution_is_terminal(
|
||||
request.request_id,
|
||||
scheduler_output,
|
||||
)
|
||||
and request.num_computed_tokens >= len(request.prompt_token_ids)
|
||||
)
|
||||
):
|
||||
@@ -526,6 +634,8 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
finish_reason = request.get_finished_reason()
|
||||
finished = self._handle_stopped_request(request)
|
||||
is_segment_finished = not finished
|
||||
if finished and self._native_data_plane:
|
||||
self._pending_data_plane_terminal_req_ids.add(req_id)
|
||||
if not finished:
|
||||
# for streaming input request only
|
||||
if self.chunk_transfer_adapter:
|
||||
|
||||
@@ -5,17 +5,20 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import time
|
||||
from collections.abc import Iterable, Iterator
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.compilation.cuda_graph import CUDAGraphStat
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.distributed.kv_events import KVEventBatch
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
|
||||
from vllm.logger import init_logger
|
||||
from vllm.utils.import_utils import resolve_obj_by_qualname
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.core.sched.request_queue import RequestQueue
|
||||
from vllm.v1.core.sched.utils import remove_all
|
||||
from vllm.v1.engine import (
|
||||
EngineCoreEventType,
|
||||
@@ -31,6 +34,7 @@ from vllm.v1.spec_decode.metrics import SpecDecodingStats
|
||||
from vllm_omni.core.sched.omni_scheduling_coordinator import (
|
||||
OmniSchedulingCoordinator,
|
||||
uses_full_payload_input_coordinator,
|
||||
uses_native_mrv2_data_plane,
|
||||
)
|
||||
from vllm_omni.core.sched.output import (
|
||||
OmniChunkRecvHandle,
|
||||
@@ -41,6 +45,8 @@ from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapt
|
||||
OmniChunkTransferAdapter,
|
||||
)
|
||||
from vllm_omni.engine import OmniEngineCoreOutput
|
||||
from vllm_omni.engine.serialization import serialize_additional_information
|
||||
from vllm_omni.outputs import OmniConnectorOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -101,6 +107,19 @@ elif DEFAULT_INPUT_WAIT_TIMEOUT_S == 0:
|
||||
class OmniSchedulerMixin:
|
||||
"""Shared scheduler helpers for omni-specific request handling."""
|
||||
|
||||
# Provided by the concrete vLLM scheduler.
|
||||
vllm_config: VllmConfig
|
||||
requests: dict[str, Request]
|
||||
waiting: RequestQueue
|
||||
running: list[Request]
|
||||
|
||||
def _init_omni_connector_output_inbox(self) -> None:
|
||||
self._omni_connector_output_inbox: queue.SimpleQueue[OmniConnectorOutput] = queue.SimpleQueue()
|
||||
|
||||
def enqueue_omni_connector_output(self, output: OmniConnectorOutput) -> None:
|
||||
"""Accept a lightweight readiness event from the local runner thread."""
|
||||
self._omni_connector_output_inbox.put(output)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Shared scheduler/output helpers (lift the AR / generation duplicates)
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -119,15 +138,30 @@ class OmniSchedulerMixin:
|
||||
self.use_v2_model_runner = False
|
||||
|
||||
model_config = self.vllm_config.model_config
|
||||
self.chunk_transfer_adapter = (
|
||||
OmniChunkTransferAdapter(self.vllm_config) if getattr(model_config, "async_chunk", False) else None
|
||||
self.use_v2_model_runner = bool(getattr(model_config, "use_v2_model_runner", False))
|
||||
self._native_data_plane = uses_native_mrv2_data_plane(
|
||||
model_config,
|
||||
use_v2_model_runner=self.use_v2_model_runner,
|
||||
)
|
||||
self.input_coordinator = (
|
||||
OmniSchedulingCoordinator(stage_id=getattr(model_config, "stage_id", 0))
|
||||
if uses_full_payload_input_coordinator(model_config)
|
||||
self.chunk_transfer_adapter = (
|
||||
OmniChunkTransferAdapter(self.vllm_config)
|
||||
if getattr(model_config, "async_chunk", False) and not self._native_data_plane
|
||||
else None
|
||||
)
|
||||
self._latest_omni_connector_output = None
|
||||
self.input_coordinator: OmniSchedulingCoordinator | None = None
|
||||
if self._native_data_plane:
|
||||
self.input_coordinator = OmniSchedulingCoordinator(
|
||||
scheduler_max_num_seqs=self.vllm_config.scheduler_config.max_num_seqs,
|
||||
stage_id=getattr(model_config, "stage_id", 0),
|
||||
async_chunk=True,
|
||||
)
|
||||
elif uses_full_payload_input_coordinator(model_config):
|
||||
self.input_coordinator = OmniSchedulingCoordinator(
|
||||
stage_id=getattr(model_config, "stage_id", 0),
|
||||
)
|
||||
self._latest_omni_connector_output: OmniConnectorOutput | None = None
|
||||
self._init_omni_connector_output_inbox()
|
||||
self._pending_data_plane_terminal_req_ids: set[str] = set()
|
||||
# Optional per-stage pooling-output decoder hook (dotted path in
|
||||
# model_config); applied worker-side before IPC.
|
||||
self._pooling_output_decoder = None
|
||||
@@ -214,6 +248,35 @@ class OmniSchedulerMixin:
|
||||
session.num_prompt_tokens = len(new_prompt)
|
||||
self._finish_streaming_session_update(session, update)
|
||||
|
||||
def _async_chunk_transport_enabled(self) -> bool:
|
||||
return getattr(self, "chunk_transfer_adapter", None) is not None or bool(
|
||||
getattr(self, "_native_data_plane", False)
|
||||
)
|
||||
|
||||
def _get_async_chunk_reserved_running_slots(self) -> int:
|
||||
adapter = getattr(self, "chunk_transfer_adapter", None)
|
||||
live_requests = self.requests
|
||||
reserved_ids: set[str] = set()
|
||||
if adapter is not None:
|
||||
parked = getattr(adapter, "waiting_for_chunk_running_requests", ())
|
||||
held = getattr(adapter, "_held_non_active", ())
|
||||
else:
|
||||
coordinator = getattr(self, "input_coordinator", None)
|
||||
parked = getattr(coordinator, "_waiting_for_chunk_running", ())
|
||||
held = ()
|
||||
for request in parked:
|
||||
req_id = getattr(request, "request_id", None)
|
||||
if req_id is not None and req_id in live_requests:
|
||||
reserved_ids.add(req_id)
|
||||
for request in held:
|
||||
req_id = getattr(request, "request_id", None)
|
||||
if req_id is not None and req_id in live_requests:
|
||||
reserved_ids.add(req_id)
|
||||
return len(reserved_ids)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Shared scheduler/output helpers (lift the AR / generation duplicates)
|
||||
# ------------------------------------------------------------------ #
|
||||
def _release_replaced_streaming_prompt_cache(self, session: Request) -> None:
|
||||
"""Discard cache state that belongs to a replaced prompt."""
|
||||
# A prompt replacement is not a normal streaming extension: none of
|
||||
@@ -259,19 +322,55 @@ class OmniSchedulerMixin:
|
||||
AR and generation schedulers except for the ``model_mode`` argument
|
||||
forwarded to ``update_request_metadata``.
|
||||
"""
|
||||
connector_outputs: list[OmniConnectorOutput] = []
|
||||
inbox = getattr(self, "_omni_connector_output_inbox", None)
|
||||
if inbox is not None:
|
||||
while True:
|
||||
try:
|
||||
connector_outputs.append(inbox.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
connector_output = getattr(self, "_latest_omni_connector_output", None)
|
||||
self._latest_omni_connector_output = None
|
||||
if connector_output is not None:
|
||||
connector_outputs.append(connector_output)
|
||||
input_coordinator = getattr(self, "input_coordinator", None)
|
||||
if input_coordinator is None:
|
||||
return
|
||||
if connector_output and connector_output.request_metadata:
|
||||
request_metadata: dict[str, dict[str, Any]] = {}
|
||||
chunk_ready_req_ids: set[str] = set()
|
||||
chunk_finished_req_ids: set[str] = set()
|
||||
stage_recv_req_ids: set[str] = set()
|
||||
for output in connector_outputs:
|
||||
request_metadata.update(output.request_metadata)
|
||||
chunk_ready_req_ids.update(output.chunk_ready_req_ids)
|
||||
chunk_finished_req_ids.update(output.chunk_finished_req_ids)
|
||||
stage_recv_req_ids.update(output.stage_recv_req_ids)
|
||||
live_request_ids = self.requests.keys()
|
||||
request_metadata = {
|
||||
req_id: metadata for req_id, metadata in request_metadata.items() if req_id in live_request_ids
|
||||
}
|
||||
chunk_ready_req_ids.intersection_update(live_request_ids)
|
||||
chunk_finished_req_ids.intersection_update(live_request_ids)
|
||||
stage_recv_req_ids.intersection_update(live_request_ids)
|
||||
if request_metadata:
|
||||
input_coordinator.update_request_metadata(
|
||||
self.requests, connector_output.request_metadata, model_mode=model_mode
|
||||
self.requests,
|
||||
request_metadata,
|
||||
model_mode=model_mode,
|
||||
)
|
||||
if input_coordinator._async_chunk:
|
||||
input_coordinator.process_pending_chunks(
|
||||
self.waiting,
|
||||
self.running,
|
||||
chunk_ready_req_ids,
|
||||
chunk_finished_req_ids,
|
||||
)
|
||||
else:
|
||||
input_coordinator.process_pending_full_payload_inputs(
|
||||
self.waiting,
|
||||
stage_recv_req_ids,
|
||||
)
|
||||
input_coordinator.process_pending_full_payload_inputs(
|
||||
self.waiting,
|
||||
connector_output.stage_recv_req_ids if connector_output else set(),
|
||||
)
|
||||
|
||||
def _process_pending_omni_inputs(self, model_mode: str) -> None:
|
||||
"""Apply pending connector inputs, timeouts, and async chunks."""
|
||||
@@ -316,7 +415,7 @@ class OmniSchedulerMixin:
|
||||
scheduler_requests=self.requests,
|
||||
)
|
||||
if self.input_coordinator:
|
||||
self.input_coordinator.restore_queues(self.waiting)
|
||||
self.input_coordinator.restore_queues(self.waiting, self.running)
|
||||
|
||||
def _process_pending_input_timeouts(self) -> None:
|
||||
"""Force-fail requests waiting on the full-payload coordinator too long.
|
||||
@@ -458,6 +557,8 @@ class OmniSchedulerMixin:
|
||||
*,
|
||||
finished_requests_needing_kv_transfer: dict | None = None,
|
||||
pending_input_registrations: list[OmniChunkRecvHandle] | None = None,
|
||||
data_plane_terminal_req_ids: set[str] | None = None,
|
||||
input_terminal_req_ids: set[str] | None = None,
|
||||
) -> OmniSchedulerOutput:
|
||||
"""Wrap a base ``SchedulerOutput`` in ``OmniSchedulerOutput``.
|
||||
|
||||
@@ -468,11 +569,31 @@ class OmniSchedulerMixin:
|
||||
base_data = {name: getattr(base, name) for name in SchedulerOutput.__dataclass_fields__}
|
||||
input_coordinator = getattr(self, "input_coordinator", None)
|
||||
if pending_input_registrations is None:
|
||||
pending_input_registrations = input_coordinator.pending_input_registrations if input_coordinator else []
|
||||
if input_coordinator is None:
|
||||
pending_input_registrations = []
|
||||
elif input_coordinator._async_chunk:
|
||||
pending_input_registrations = input_coordinator.pending_chunk_registrations
|
||||
else:
|
||||
pending_input_registrations = input_coordinator.pending_input_registrations
|
||||
if data_plane_terminal_req_ids is None:
|
||||
pending_terminal: set[str] = getattr(self, "_pending_data_plane_terminal_req_ids", set())
|
||||
data_plane_terminal_req_ids = set(pending_terminal)
|
||||
pending_terminal.clear()
|
||||
if input_coordinator is not None:
|
||||
scheduled_terminal_req_ids = input_coordinator.get_scheduled_input_terminal_req_ids(base)
|
||||
if input_terminal_req_ids is None:
|
||||
input_terminal_req_ids = scheduled_terminal_req_ids
|
||||
else:
|
||||
input_terminal_req_ids = set(input_terminal_req_ids).union(
|
||||
scheduled_terminal_req_ids,
|
||||
)
|
||||
input_coordinator.postprocess_scheduler_output(base)
|
||||
return OmniSchedulerOutput(
|
||||
**base_data,
|
||||
finished_requests_needing_kv_transfer=finished_requests_needing_kv_transfer or {},
|
||||
pending_input_registrations=pending_input_registrations,
|
||||
data_plane_terminal_req_ids=data_plane_terminal_req_ids,
|
||||
input_terminal_req_ids=input_terminal_req_ids or set(),
|
||||
)
|
||||
|
||||
def _rewrap_scheduled_new_reqs(self, scheduler_output: SchedulerOutput) -> None:
|
||||
@@ -520,8 +641,14 @@ class OmniSchedulerMixin:
|
||||
num_nans_in_logits: int = 0,
|
||||
is_segment_finished: bool | None = False,
|
||||
new_prompt_len_snapshot: int | None = None,
|
||||
num_generation_tokens: int | None = None,
|
||||
) -> OmniEngineCoreOutput:
|
||||
"""Build the common request-output envelope used by LLM schedulers."""
|
||||
pooling_output_payload = None
|
||||
if isinstance(pooling_output, dict):
|
||||
pooling_output_payload = serialize_additional_information(pooling_output)
|
||||
pooling_output = None
|
||||
|
||||
return OmniEngineCoreOutput(
|
||||
request_id=request.request_id,
|
||||
new_token_ids=new_token_ids,
|
||||
@@ -529,6 +656,7 @@ class OmniSchedulerMixin:
|
||||
new_logprobs=new_logprobs,
|
||||
new_prompt_logprobs_tensors=new_prompt_logprobs_tensors,
|
||||
pooling_output=pooling_output,
|
||||
pooling_output_payload=pooling_output_payload,
|
||||
multimodal_output=multimodal_output,
|
||||
stop_reason=stop_reason,
|
||||
events=request.take_events(),
|
||||
@@ -540,6 +668,7 @@ class OmniSchedulerMixin:
|
||||
num_nans_in_logits=num_nans_in_logits,
|
||||
is_segment_finished=is_segment_finished,
|
||||
new_prompt_len_snapshot=new_prompt_len_snapshot,
|
||||
num_generation_tokens=num_generation_tokens,
|
||||
)
|
||||
|
||||
def _append_request_output(
|
||||
@@ -953,10 +1082,10 @@ class OmniSchedulerMixin:
|
||||
self.running[:] = [req for req in self.running if keep_running(req)]
|
||||
|
||||
def _drop_aborted_queued_requests(self) -> None:
|
||||
for queue in (self.waiting, self.skipped_waiting):
|
||||
aborted = [req for req in queue if req.status == RequestStatus.FINISHED_ABORTED]
|
||||
for request_queue in (self.waiting, self.skipped_waiting):
|
||||
aborted = [req for req in request_queue if req.status == RequestStatus.FINISHED_ABORTED]
|
||||
if aborted:
|
||||
queue.remove_requests(aborted)
|
||||
request_queue.remove_requests(aborted)
|
||||
self.running[:] = [req for req in self.running if req.status != RequestStatus.FINISHED_ABORTED]
|
||||
|
||||
def _resync_streaming_input_counter(self) -> None:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Scheduling-side coordination for full_payload input waiting.
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Scheduling-side coordination for chunk and full-payload input waiting.
|
||||
|
||||
Manages WAITING_FOR_INPUT state transitions based on readiness signals
|
||||
from OmniConnectorOutput, without ever calling connector.put()/get().
|
||||
|
||||
Chunk waiting (WAITING_FOR_CHUNK) lives on OmniChunkTransferAdapter.
|
||||
Manages WAITING_FOR_CHUNK and WAITING_FOR_INPUT state transitions based on
|
||||
readiness signals from OmniConnectorOutput, without calling connector I/O.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +20,18 @@ from vllm_omni.core.sched.output import OmniChunkRecvHandle
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def uses_native_mrv2_data_plane(
|
||||
model_config: Any,
|
||||
*,
|
||||
use_v2_model_runner: bool,
|
||||
) -> bool:
|
||||
return bool(
|
||||
use_v2_model_runner
|
||||
and getattr(model_config, "async_chunk", False)
|
||||
and getattr(model_config, "supports_native_mrv2_data_plane", False)
|
||||
)
|
||||
|
||||
|
||||
def uses_full_payload_input_coordinator(model_config: Any) -> bool:
|
||||
"""Returns True if this stage parks pending requests in
|
||||
WAITING_FOR_INPUT awaiting a full_payload delivery on the worker connector.
|
||||
@@ -38,7 +48,7 @@ def uses_full_payload_input_coordinator(model_config: Any) -> bool:
|
||||
|
||||
|
||||
class OmniSchedulingCoordinator:
|
||||
"""Pure-scheduling coordinator for full_payload input waiting.
|
||||
"""Pure-scheduling coordinator for chunk and full-payload input waiting.
|
||||
|
||||
The Scheduler owns an instance of this class. It consumes readiness
|
||||
signals produced by the Model Runner's ``OmniConnectorModelRunnerMixin``
|
||||
@@ -46,12 +56,28 @@ class OmniSchedulingCoordinator:
|
||||
transitions accordingly.
|
||||
"""
|
||||
|
||||
def __init__(self, stage_id: int = 0):
|
||||
def __init__(
|
||||
self,
|
||||
scheduler_max_num_seqs: int = 0,
|
||||
stage_id: int = 0,
|
||||
async_chunk: bool = False,
|
||||
) -> None:
|
||||
self._stage_id = stage_id
|
||||
self._scheduler_max_num_seqs = scheduler_max_num_seqs
|
||||
self._async_chunk = async_chunk
|
||||
|
||||
self.finished_requests: set[str] = set()
|
||||
self.requests_with_ready_chunks: set[str] = set()
|
||||
self.input_terminal_req_ids: set[str] = set()
|
||||
self._full_payload_input_received: set[str] = set()
|
||||
|
||||
self._waiting_for_chunk_waiting: deque[Any] = deque()
|
||||
self._waiting_for_chunk_running: deque[Any] = deque()
|
||||
|
||||
# Request IDs that were newly registered for chunk recv this cycle.
|
||||
# The engine/Model Runner should call register_chunk_recv() for these
|
||||
# so the bg thread starts polling.
|
||||
self.pending_chunk_registrations: list[OmniChunkRecvHandle] = []
|
||||
# Requests waiting for full_payload stage input (WAITING_FOR_INPUT).
|
||||
self._waiting_for_input: deque[Any] = deque()
|
||||
# Per-cycle list of minimal handles to ship to the model runner so it
|
||||
@@ -69,6 +95,47 @@ class OmniSchedulingCoordinator:
|
||||
# Core scheduling methods
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def process_pending_chunks(
|
||||
self,
|
||||
waiting_queue: Any,
|
||||
running_queue: list[Request],
|
||||
chunk_ready_req_ids: set[str],
|
||||
chunk_finished_req_ids: set[str],
|
||||
) -> None:
|
||||
"""Transition requests whose chunks have arrived.
|
||||
|
||||
Args:
|
||||
waiting_queue: Scheduler's waiting request queue.
|
||||
running_queue: Scheduler's running request list.
|
||||
chunk_ready_req_ids: IDs with a newly arrived chunk this cycle.
|
||||
chunk_finished_req_ids: IDs whose final chunk has arrived.
|
||||
"""
|
||||
if self._stage_id == 0 or not self._async_chunk:
|
||||
return
|
||||
|
||||
self.finished_requests.update(chunk_finished_req_ids)
|
||||
self.pending_chunk_registrations = []
|
||||
|
||||
self._process_chunk_queue(
|
||||
waiting_queue,
|
||||
self._waiting_for_chunk_waiting,
|
||||
RequestStatus.WAITING,
|
||||
chunk_ready_req_ids,
|
||||
)
|
||||
self._process_chunk_queue(
|
||||
running_queue,
|
||||
self._waiting_for_chunk_running,
|
||||
RequestStatus.RUNNING,
|
||||
chunk_ready_req_ids,
|
||||
)
|
||||
while len(running_queue) > self._scheduler_max_num_seqs:
|
||||
request = running_queue.pop()
|
||||
# Must reset status to WAITING so the scheduler treats it as
|
||||
# schedulable work. KV blocks are NOT freed here (unlike a
|
||||
# real preemption), so PREEMPTED would be incorrect.
|
||||
request.status = RequestStatus.WAITING
|
||||
waiting_queue.prepend_requests([request])
|
||||
|
||||
def process_pending_full_payload_inputs(
|
||||
self,
|
||||
waiting_queue: Any,
|
||||
@@ -144,10 +211,29 @@ class OmniSchedulingCoordinator:
|
||||
waiting_queue.remove_requests(to_remove)
|
||||
|
||||
def free_finished_request(self, request_id: str) -> None:
|
||||
"""Prune internal tracking sets for a freed request to prevent unbounded growth."""
|
||||
"""Prune all coordinator state owned by a freed request."""
|
||||
self._full_payload_input_received.discard(request_id)
|
||||
self.finished_requests.discard(request_id)
|
||||
self.requests_with_ready_chunks.discard(request_id)
|
||||
self.input_terminal_req_ids.discard(request_id)
|
||||
self._waiting_since.pop(request_id, None)
|
||||
for queue_attr in (
|
||||
"_waiting_for_chunk_waiting",
|
||||
"_waiting_for_chunk_running",
|
||||
"_waiting_for_input",
|
||||
):
|
||||
queue = getattr(self, queue_attr)
|
||||
setattr(
|
||||
self,
|
||||
queue_attr,
|
||||
deque(request for request in queue if request.request_id != request_id),
|
||||
)
|
||||
self.pending_chunk_registrations = [
|
||||
handle for handle in self.pending_chunk_registrations if handle.request_id != request_id
|
||||
]
|
||||
self.pending_input_registrations = [
|
||||
handle for handle in self.pending_input_registrations if handle.request_id != request_id
|
||||
]
|
||||
|
||||
def collect_timed_out_request_ids(
|
||||
self,
|
||||
@@ -178,11 +264,14 @@ class OmniSchedulingCoordinator:
|
||||
|
||||
# Defensively remove from coordinator internal queues (may already
|
||||
# be empty if restore_queues() has run).
|
||||
remaining: deque[Any] = deque()
|
||||
for request in self._waiting_for_input:
|
||||
if request.request_id not in timed_out_ids:
|
||||
remaining.append(request)
|
||||
self._waiting_for_input = remaining
|
||||
for queue_attr in (
|
||||
"_waiting_for_chunk_waiting",
|
||||
"_waiting_for_chunk_running",
|
||||
"_waiting_for_input",
|
||||
):
|
||||
queue = getattr(self, queue_attr)
|
||||
remaining = deque(request for request in queue if request.request_id not in timed_out_ids)
|
||||
setattr(self, queue_attr, remaining)
|
||||
|
||||
for req_id in timed_out_ids:
|
||||
self._waiting_since.pop(req_id, None)
|
||||
@@ -198,8 +287,17 @@ class OmniSchedulingCoordinator:
|
||||
def restore_queues(
|
||||
self,
|
||||
waiting_queue: Any,
|
||||
running_queue: list[Request] | None = None,
|
||||
) -> None:
|
||||
"""Return waiting-for-input requests to the waiting queue."""
|
||||
"""Return waiting-for-chunk/input requests to scheduling queues."""
|
||||
for request in self._waiting_for_chunk_waiting:
|
||||
waiting_queue.add_request(request)
|
||||
self._waiting_for_chunk_waiting = deque()
|
||||
|
||||
if running_queue is not None and self._waiting_for_chunk_running:
|
||||
running_queue.extend(self._waiting_for_chunk_running)
|
||||
self._waiting_for_chunk_running = deque()
|
||||
|
||||
for request in self._waiting_for_input:
|
||||
waiting_queue.add_request(request)
|
||||
self._waiting_for_input = deque()
|
||||
@@ -247,6 +345,9 @@ class OmniSchedulingCoordinator:
|
||||
if request is None:
|
||||
continue
|
||||
|
||||
if metadata.get("input_terminal") is True:
|
||||
self.input_terminal_req_ids.add(req_id)
|
||||
|
||||
# Handle next_stage_prompt_len if present (for models like Qwen3-Omni).
|
||||
# Only apply when the request has not started decoding yet
|
||||
# (no output tokens). Resetting a mid-decode request would
|
||||
@@ -255,8 +356,7 @@ class OmniSchedulingCoordinator:
|
||||
next_len = metadata["next_stage_prompt_len"]
|
||||
if isinstance(next_len, int) and next_len > 0:
|
||||
output_token_ids = getattr(request, "_output_token_ids", None)
|
||||
has_decode_output = output_token_ids is not None and len(output_token_ids) > 0
|
||||
if has_decode_output:
|
||||
if output_token_ids is not None and len(output_token_ids) > 0:
|
||||
logger.debug(
|
||||
"[Coordinator stage-%s] Skipping prompt resize for req %s: "
|
||||
"request already has %s output tokens",
|
||||
@@ -284,6 +384,12 @@ class OmniSchedulingCoordinator:
|
||||
|
||||
if model_mode != "ar":
|
||||
new_ids = self._flatten_prompt_token_ids(metadata.get("code_predictor_codes"))
|
||||
runtime_seed = None
|
||||
if "left_context_size" in metadata:
|
||||
runtime_seed = {
|
||||
"meta": {"left_context_size": metadata["left_context_size"]},
|
||||
}
|
||||
request._omni_initial_model_buffer = runtime_seed
|
||||
if new_ids:
|
||||
request.prompt_token_ids = new_ids
|
||||
request.num_prompt_tokens = len(new_ids)
|
||||
@@ -291,3 +397,103 @@ class OmniSchedulingCoordinator:
|
||||
request._all_token_ids.extend(new_ids)
|
||||
request._output_token_ids.clear()
|
||||
request.num_computed_tokens = 0
|
||||
|
||||
def postprocess_scheduler_output(
|
||||
self,
|
||||
scheduler_output: Any,
|
||||
requests: dict[str, Request] | None = None,
|
||||
) -> None:
|
||||
"""Clear per-cycle ready state after scheduler output is materialized."""
|
||||
self._clear_chunk_ready(scheduler_output)
|
||||
self.input_terminal_req_ids.difference_update(
|
||||
self._scheduled_request_ids(scheduler_output),
|
||||
)
|
||||
|
||||
def get_scheduled_input_terminal_req_ids(
|
||||
self,
|
||||
scheduler_output: Any,
|
||||
) -> set[str]:
|
||||
"""Return terminal inputs executed by this immutable scheduler step."""
|
||||
return self.input_terminal_req_ids.intersection(
|
||||
self._scheduled_request_ids(scheduler_output),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _scheduled_request_ids(scheduler_output: Any) -> set[str]:
|
||||
num_scheduled_tokens = getattr(
|
||||
scheduler_output,
|
||||
"num_scheduled_tokens",
|
||||
None,
|
||||
)
|
||||
if num_scheduled_tokens is not None:
|
||||
return set(num_scheduled_tokens)
|
||||
|
||||
request_ids = {req.req_id for req in getattr(scheduler_output, "scheduled_new_reqs", ())}
|
||||
cached_reqs = getattr(scheduler_output, "scheduled_cached_reqs", None)
|
||||
request_ids.update(getattr(cached_reqs, "req_ids", ()))
|
||||
return request_ids
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _process_chunk_queue(
|
||||
self,
|
||||
queue: Any,
|
||||
waiting_for_chunk_list: deque[Any],
|
||||
target_status: RequestStatus,
|
||||
chunk_ready_req_ids: set[str],
|
||||
) -> None:
|
||||
queue_snapshot = list(queue)
|
||||
for request in queue_snapshot:
|
||||
request_can_run = self._request_can_run(request, chunk_ready_req_ids)
|
||||
if request.status != RequestStatus.WAITING_FOR_CHUNK:
|
||||
if request_can_run:
|
||||
if request.request_id in chunk_ready_req_ids:
|
||||
self.requests_with_ready_chunks.add(request.request_id)
|
||||
continue
|
||||
if request.status == RequestStatus.WAITING_FOR_INPUT:
|
||||
continue
|
||||
self.pending_chunk_registrations.append(
|
||||
OmniChunkRecvHandle(
|
||||
request_id=request.request_id,
|
||||
external_req_id=getattr(request, "external_req_id", None),
|
||||
)
|
||||
)
|
||||
request.status = RequestStatus.WAITING_FOR_CHUNK
|
||||
self._waiting_since.setdefault(request.request_id, time.monotonic())
|
||||
else:
|
||||
if request_can_run:
|
||||
request.status = target_status
|
||||
if request.request_id in chunk_ready_req_ids:
|
||||
self.requests_with_ready_chunks.add(request.request_id)
|
||||
self._waiting_since.pop(request.request_id, None)
|
||||
continue
|
||||
if isinstance(queue, list):
|
||||
queue.remove(request)
|
||||
else:
|
||||
queue.remove_request(request)
|
||||
waiting_for_chunk_list.append(request)
|
||||
|
||||
def _request_can_run(
|
||||
self,
|
||||
request: Request,
|
||||
chunk_ready_req_ids: set[str],
|
||||
) -> bool:
|
||||
req_id = request.request_id
|
||||
if req_id in self.finished_requests:
|
||||
# Admit the final payload or terminal marker for cleanup.
|
||||
return True
|
||||
|
||||
return req_id in chunk_ready_req_ids or req_id in self.requests_with_ready_chunks
|
||||
|
||||
def _clear_chunk_ready(self, scheduler_output: Any) -> None:
|
||||
if scheduler_output.scheduled_new_reqs:
|
||||
for req_data in scheduler_output.scheduled_new_reqs:
|
||||
req_id = getattr(req_data, "req_id", None)
|
||||
if req_id is not None:
|
||||
self.requests_with_ready_chunks.discard(req_id)
|
||||
|
||||
if scheduler_output.scheduled_cached_reqs:
|
||||
for req_id in scheduler_output.scheduled_cached_reqs.req_ids:
|
||||
self.requests_with_ready_chunks.discard(req_id)
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass, field, fields
|
||||
|
||||
from vllm.v1.core.sched.output import CachedRequestData, NewRequestData, SchedulerOutput
|
||||
@@ -18,14 +21,14 @@ class OmniNewRequestData(NewRequestData):
|
||||
|
||||
Args:
|
||||
external_req_id: Optional external request ID for tracking
|
||||
additional_information: Optional serialized additional information
|
||||
additional_information: Optional serialized or materialized additional information
|
||||
dictionary containing tensors or lists
|
||||
model_intermediate_buffer: Optional runner-owned payload for
|
||||
GPUModelRunner.model_intermediate_buffer
|
||||
"""
|
||||
|
||||
external_req_id: str | None = None
|
||||
additional_information: AdditionalInformationPayload | None = None
|
||||
additional_information: AdditionalInformationPayload | dict[str, object] | None = None
|
||||
model_intermediate_buffer: dict[str, object] | None = None
|
||||
|
||||
@classmethod
|
||||
@@ -114,3 +117,5 @@ class OmniSchedulerOutput(SchedulerOutput):
|
||||
|
||||
finished_requests_needing_kv_transfer: dict[str, dict] = field(default_factory=dict)
|
||||
pending_input_registrations: list[OmniChunkRecvHandle] = field(default_factory=list)
|
||||
data_plane_terminal_req_ids: set[str] = field(default_factory=set)
|
||||
input_terminal_req_ids: set[str] = field(default_factory=set)
|
||||
@@ -34,9 +34,14 @@ class HiddenStates(TypedDict, total=False):
|
||||
|
||||
|
||||
class Embeddings(TypedDict, total=False):
|
||||
prepared_prefill: torch.Tensor
|
||||
prefill: torch.Tensor
|
||||
decode: torch.Tensor
|
||||
decode_token_start: int
|
||||
decode_token_end: int
|
||||
cached_decode: torch.Tensor
|
||||
cached_decode_token_start: int
|
||||
cached_decode_token_end: int
|
||||
tts_bos: torch.Tensor
|
||||
tts_eos: torch.Tensor
|
||||
tts_pad: torch.Tensor
|
||||
@@ -54,6 +59,7 @@ class Codes(TypedDict, total=False):
|
||||
|
||||
|
||||
class Ids(TypedDict, total=False):
|
||||
prepared_prefill: torch.Tensor
|
||||
all: list[int]
|
||||
prompt: list[int]
|
||||
output: list[int]
|
||||
@@ -81,6 +87,7 @@ class OmniPayloadMeta(TypedDict, total=False):
|
||||
next_stage_prompt_len: int
|
||||
next_stage_generation_tokens: int
|
||||
replace_streaming_prompt: bool
|
||||
next_stage_prompt_ids: list[int]
|
||||
streaming_prompt_recompute: bool
|
||||
streaming_condition_seq: int
|
||||
replace_runtime_additional_information: bool
|
||||
@@ -94,6 +101,7 @@ class OmniPayloadMeta(TypedDict, total=False):
|
||||
width: int
|
||||
decode_flag: bool
|
||||
codec_streaming: bool
|
||||
codec_frame_valid: bool | torch.Tensor
|
||||
ref_code_len: int
|
||||
ref_context_size: int
|
||||
ref_context_request_id: str
|
||||
@@ -148,6 +156,8 @@ class EmbeddingsStruct(_StructBase):
|
||||
decode_token_start: int | None = None
|
||||
decode_token_end: int | None = None
|
||||
cached_decode: torch.Tensor | None = None
|
||||
cached_decode_token_start: int | None = None
|
||||
cached_decode_token_end: int | None = None
|
||||
tts_bos: torch.Tensor | None = None
|
||||
tts_eos: torch.Tensor | None = None
|
||||
tts_pad: torch.Tensor | None = None
|
||||
@@ -190,6 +200,7 @@ class MetaStruct(_StructBase):
|
||||
next_stage_prompt_len: int | None = None
|
||||
next_stage_generation_tokens: int | None = None
|
||||
replace_streaming_prompt: bool | None = None
|
||||
next_stage_prompt_ids: list[int] | None = None
|
||||
streaming_prompt_recompute: bool | None = None
|
||||
streaming_condition_seq: int | None = None
|
||||
replace_runtime_additional_information: bool | None = None
|
||||
@@ -203,6 +214,7 @@ class MetaStruct(_StructBase):
|
||||
width: int | None = None
|
||||
decode_flag: bool | None = None
|
||||
codec_streaming: bool | None = None
|
||||
codec_frame_valid: torch.Tensor | None = None
|
||||
ref_code_len: int | None = None
|
||||
# Expected FINAL length of a growing async-chunk sequence, when the
|
||||
# producer knows it up front (e.g. a frame-locked AR stage whose
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Opt-in CUDA Model Runner V2 profile. The base profile retains V1.
|
||||
base_config: qwen3_tts_high_concurrency.yaml
|
||||
model_runner: v2
|
||||
|
||||
platforms:
|
||||
npu:
|
||||
model_runner: v1
|
||||
xpu:
|
||||
model_runner: v1
|
||||
rocm:
|
||||
model_runner: v1
|
||||
musa:
|
||||
model_runner: v1
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
extra:
|
||||
# Keep unqualified prefix-graph optimizations disabled in this profile.
|
||||
code_predictor_prefix_graphs: false
|
||||
ref_audio_artifact_cache_max_entries: 1024
|
||||
# Opt-in B2 codec graphs. Validate quality for the deployment workload.
|
||||
decode_batch_max_size: 2
|
||||
decode_cudagraph_batch_sizes: [1, 2]
|
||||
|
||||
stages:
|
||||
- stage_id: 1
|
||||
# Enable the decoder's inner graphs; the generation runner stays eager.
|
||||
enforce_eager: false
|
||||
@@ -0,0 +1,8 @@
|
||||
# Experimental B4 profile. Early first audio can require startup buffering.
|
||||
# MPS is a separate operator setting and is not enabled by this file.
|
||||
base_config: qwen3_tts_high_concurrency_mrv2.yaml
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
extra:
|
||||
decode_batch_max_size: 4
|
||||
decode_cudagraph_batch_sizes: [1, 2, 3, 4]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Opt-in single-GPU throughput profile, measured on H200 at concurrency 128
|
||||
# with Qwen3-TTS-12Hz-1.7B-Base. Warm the full pipeline before benchmarking.
|
||||
# B8 uses more graph memory than B4; this is not a low-latency profile.
|
||||
# MPS is optional and is not enabled by this file.
|
||||
#
|
||||
# vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base --omni \
|
||||
# --deploy-config vllm_omni/deploy/qwen3_tts_high_concurrency_mrv2_single_gpu.yaml
|
||||
base_config: qwen3_tts_high_concurrency_mrv2.yaml
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
extra:
|
||||
decode_batch_max_size: 8
|
||||
decode_cudagraph_batch_sizes: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "0"
|
||||
max_num_seqs: 128
|
||||
- stage_id: 1
|
||||
devices: "0"
|
||||
# Admission candidates are grouped by codec phase before B8 execution.
|
||||
max_num_seqs: 32
|
||||
@@ -0,0 +1,18 @@
|
||||
# Opt-in CUDA Model Runner V2 profile. The base profile retains V1.
|
||||
base_config: qwen3_tts.yaml
|
||||
model_runner: v2
|
||||
|
||||
platforms:
|
||||
npu:
|
||||
model_runner: v1
|
||||
xpu:
|
||||
model_runner: v1
|
||||
rocm:
|
||||
model_runner: v1
|
||||
musa:
|
||||
model_runner: v1
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
# Bound per-step prefill work, matching the high-concurrency TTS profile.
|
||||
max_num_batched_tokens: 512
|
||||
@@ -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
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
|
||||
+341
-113
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
import importlib
|
||||
import inspect
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import torch
|
||||
@@ -65,7 +66,6 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
extracted: dict[str, Any] = {}
|
||||
meta = payload.get("meta") if isinstance(payload, dict) else None
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
|
||||
if "next_stage_prompt_len" in meta:
|
||||
extracted["next_stage_prompt_len"] = meta["next_stage_prompt_len"]
|
||||
else:
|
||||
@@ -85,6 +85,9 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
elif "left_context_size" in payload:
|
||||
logger.warning_once("legacy flat 'left_context_size' key in payload; expected 'meta.left_context_size'")
|
||||
|
||||
if cls._payload_finished(payload):
|
||||
extracted["input_terminal"] = True
|
||||
|
||||
return extracted
|
||||
|
||||
_NON_CONSUMABLE_PAYLOAD_KEYS: set[tuple[str, str]] = {
|
||||
@@ -331,67 +334,11 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
"""
|
||||
if not hasattr(self, "_lock"):
|
||||
return OmniConnectorOutput()
|
||||
|
||||
tp_group = self._get_local_tp_group()
|
||||
if self._async_chunk and tp_group is not None and getattr(tp_group, "world_size", 1) > 1:
|
||||
if self.is_data_transfer_rank():
|
||||
with self._lock:
|
||||
fanout_packet = self._collect_async_chunk_fanout_packet_locked()
|
||||
else:
|
||||
fanout_packet = None
|
||||
fanout_packet = self._broadcast_tp_payload_packet(fanout_packet)
|
||||
if fanout_packet is None:
|
||||
newly_finished = set()
|
||||
chunk_finished = set()
|
||||
request_metadata = {}
|
||||
else:
|
||||
if not self.is_data_transfer_rank():
|
||||
self._apply_async_chunk_fanout_packet(fanout_packet)
|
||||
newly_finished = set(fanout_packet["newly_finished"])
|
||||
chunk_finished = set(fanout_packet["chunk_finished"])
|
||||
request_metadata = dict(fanout_packet["request_metadata"])
|
||||
else:
|
||||
with self._lock:
|
||||
newly_finished = set(self._finished_load_reqs)
|
||||
self._finished_load_reqs.clear()
|
||||
chunk_finished = set(self._chunk_finished_req_ids)
|
||||
self._chunk_finished_req_ids.clear()
|
||||
request_metadata = dict(self._local_request_metadata)
|
||||
self._local_request_metadata.clear()
|
||||
# _send_side_request_payload is the async accumulation buffer for
|
||||
# future recv chunks. Clearing it on every consumable wake-up drops
|
||||
# intermediate
|
||||
# thinker decode spans before the model side can consume them.
|
||||
# Only terminal chunk_finished requests may release that buffer.
|
||||
for req_id in chunk_finished:
|
||||
if req_id not in self._local_stage_payload_cache:
|
||||
continue
|
||||
ext_req_id = self._request_ids_mapping.get(req_id, req_id)
|
||||
self._send_side_request_payload.pop(ext_req_id, None)
|
||||
if ext_req_id != req_id:
|
||||
self._send_side_request_payload.pop(req_id, None)
|
||||
self._chunk_ready_req_ids.update(newly_finished)
|
||||
|
||||
output = OmniConnectorOutput(
|
||||
chunk_ready_req_ids=set(self._chunk_ready_req_ids),
|
||||
chunk_finished_req_ids=chunk_finished,
|
||||
request_metadata=request_metadata,
|
||||
kv_sent_req_ids=list(self._kv_sent_req_ids),
|
||||
stage_recv_req_ids=set(self._stage_recv_req_ids),
|
||||
has_pending_kv_work=self.has_pending_kv_work(),
|
||||
)
|
||||
if output.stage_recv_req_ids or chunk_finished or newly_finished:
|
||||
logger.debug(
|
||||
"[Stage-%s] get_omni_connector_output: stage_recv=%s, chunk_finished=%s, chunk_ready=%s",
|
||||
self._stage_id,
|
||||
output.stage_recv_req_ids,
|
||||
chunk_finished,
|
||||
output.chunk_ready_req_ids,
|
||||
)
|
||||
self._chunk_ready_req_ids.clear()
|
||||
self._kv_sent_req_ids.clear()
|
||||
self._stage_recv_req_ids.clear()
|
||||
return output
|
||||
drain_lock = getattr(self, "_omni_connector_output_drain_lock", None)
|
||||
if drain_lock is None:
|
||||
return self._drain_omni_connector_output()
|
||||
with drain_lock:
|
||||
return self._drain_omni_connector_output()
|
||||
|
||||
@staticmethod
|
||||
def _connector_output_has_signals(output: OmniConnectorOutput) -> bool:
|
||||
@@ -854,6 +801,9 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
self,
|
||||
request: Any,
|
||||
pooling_output: Any | None = None,
|
||||
*,
|
||||
propagate_errors: bool = False,
|
||||
wait_for_delivery: bool = False,
|
||||
) -> bool:
|
||||
"""Derive and enqueue one chunk for async sending.
|
||||
|
||||
@@ -882,10 +832,11 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
request_id=request_id,
|
||||
request=request,
|
||||
pooling_output=pooling_output,
|
||||
propagate_errors=propagate_errors,
|
||||
)
|
||||
if payload_data is None:
|
||||
if chunk_id == 0:
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
"[Stage-%s] send_chunk: payload is None for req=%s chunk=%s (process_func=%s)",
|
||||
self._stage_id,
|
||||
request_id,
|
||||
@@ -894,31 +845,14 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
)
|
||||
return False
|
||||
|
||||
self._put_req_chunk[request_id] += 1
|
||||
self._ramp_chunk_count[request_id] += 1
|
||||
next_stage_id = self._next_stage_id
|
||||
connector_put_key = f"{request_id}_{self._stage_id}_{chunk_id}"
|
||||
|
||||
if chunk_id == 0:
|
||||
logger.debug(
|
||||
"[Stage-%s] send_chunk: first chunk enqueued, req=%s key=%s",
|
||||
self._stage_id,
|
||||
request_id,
|
||||
connector_put_key,
|
||||
)
|
||||
|
||||
task = {
|
||||
"stage_id": self._stage_id,
|
||||
"next_stage_id": next_stage_id,
|
||||
"put_key": connector_put_key,
|
||||
"data": payload_data,
|
||||
"request_id": request_id,
|
||||
}
|
||||
with self._lock:
|
||||
self._pending_save_reqs.setdefault(request_id, deque()).append(task)
|
||||
self._pending_save_counts[request_id] += 1
|
||||
self._work_available.set()
|
||||
return True
|
||||
enqueued, completion = self._enqueue_chunk_payload(
|
||||
request,
|
||||
payload_data,
|
||||
wait_for_delivery=wait_for_delivery,
|
||||
)
|
||||
if enqueued and completion is not None:
|
||||
completion.wait()
|
||||
return enqueued
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Background I/O threads
|
||||
@@ -946,14 +880,7 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
_recv_poll_count,
|
||||
)
|
||||
|
||||
made_progress = False
|
||||
for req_id in pending_ids:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
made_progress = self._poll_single_request(req_id) or made_progress
|
||||
except Exception:
|
||||
logger.warning("Error receiving data for %s", req_id, exc_info=True)
|
||||
made_progress = self._poll_pending_requests_once(pending_ids)
|
||||
|
||||
if not made_progress and not self._stop_event.is_set():
|
||||
self._work_available.wait(timeout=0.005)
|
||||
@@ -977,22 +904,29 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
|
||||
if task is not None:
|
||||
success = False
|
||||
send_error: BaseException | None = None
|
||||
try:
|
||||
success = self._send_single_request(task)
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
send_error = error
|
||||
logger.error(
|
||||
"Error saving data for %s",
|
||||
task.get("request_id"),
|
||||
exc_info=True,
|
||||
)
|
||||
if not success:
|
||||
self._requeue_or_drop_failed_send(task)
|
||||
self._requeue_or_drop_failed_send(task, error=send_error)
|
||||
continue
|
||||
|
||||
self._work_available.wait(timeout=0.01)
|
||||
self._work_available.clear()
|
||||
|
||||
def _requeue_or_drop_failed_send(self, task: dict) -> None:
|
||||
def _requeue_or_drop_failed_send(
|
||||
self,
|
||||
task: dict,
|
||||
*,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
"""Re-enqueue a failed send task or drop it after max retries."""
|
||||
retry_count = task.get("_retry_count", 0) + 1
|
||||
req_id = task.get("request_id")
|
||||
@@ -1002,6 +936,8 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
getattr(self, "_stage_id", "?"),
|
||||
)
|
||||
return
|
||||
if error is not None:
|
||||
task["_last_send_error"] = error
|
||||
if retry_count <= self._MAX_SEND_RETRIES:
|
||||
task["_retry_count"] = retry_count
|
||||
logger.warning(
|
||||
@@ -1015,19 +951,36 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
dq = self._pending_save_reqs.setdefault(req_id, deque())
|
||||
dq.appendleft(task)
|
||||
else:
|
||||
cause = error or task.get("_last_send_error")
|
||||
failure = RuntimeError(
|
||||
"connector send failed permanently for "
|
||||
f"request {req_id!r}, key {task.get('put_key')!r} "
|
||||
f"after {self._MAX_SEND_RETRIES + 1} attempts"
|
||||
)
|
||||
if cause is not None:
|
||||
failure.__cause__ = cause
|
||||
logger.error(
|
||||
"[Stage-%s] Giving up on send for %s after %d retries",
|
||||
getattr(self, "_stage_id", "?"),
|
||||
req_id,
|
||||
self._MAX_SEND_RETRIES,
|
||||
)
|
||||
sink = getattr(self, "_connector_send_error_sink", None)
|
||||
if sink is not None:
|
||||
try:
|
||||
sink(failure)
|
||||
except Exception:
|
||||
logger.exception("Connector send error sink failed for %s", req_id)
|
||||
completion = task.get("completion")
|
||||
if completion is not None:
|
||||
completion.set_error(failure)
|
||||
self._decrement_pending_save_count(req_id)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Chunk-level poll / send (ported from OmniChunkTransferAdapter)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _poll_single_request(self, req_id: str) -> bool:
|
||||
def _poll_single_request(self, req_id: str, *, publish_ready: bool = True) -> bool:
|
||||
"""Poll connector for one chunk of a request (non-blocking)."""
|
||||
connector = self._omni_connector
|
||||
if connector is None:
|
||||
@@ -1099,16 +1052,20 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
is_finished = self._payload_finished(payload_data)
|
||||
incoming_payload_consumable = self._payload_is_consumable(payload_data)
|
||||
|
||||
if self._model_mode == "ar":
|
||||
payload_data = self._accumulate_payload(external_req_id, payload_data)
|
||||
payload_consumable = incoming_payload_consumable
|
||||
else:
|
||||
new_ids = self._payload_audio_codes(payload_data) or []
|
||||
if not new_ids and not is_finished:
|
||||
if self._model_mode != "ar":
|
||||
new_ids = self._payload_audio_codes(payload_data)
|
||||
if not self._payload_value_has_content(new_ids) and not is_finished:
|
||||
return False
|
||||
payload_consumable = self._payload_is_consumable(payload_data)
|
||||
|
||||
with self._lock:
|
||||
if self._model_mode == "ar":
|
||||
# Accumulation, staging, and model-side consume/ack share
|
||||
# this lock. Keeping the transition atomic prevents the
|
||||
# model thread from clearing a just-merged decode span
|
||||
# before the recv thread publishes it to the local cache.
|
||||
payload_data = self._accumulate_payload(external_req_id, payload_data)
|
||||
payload_consumable = incoming_payload_consumable
|
||||
if is_finished:
|
||||
self._chunk_finished_req_ids.add(req_id)
|
||||
self._chunk_stream_completed.add(req_id)
|
||||
@@ -1167,6 +1124,8 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
type(engine_inputs).__name__,
|
||||
)
|
||||
|
||||
if publish_ready and self._async_chunk and (payload_consumable or is_finished):
|
||||
self.publish_omni_connector_output_to_sink()
|
||||
logger.debug("[Stage-%s] Received data for key %s", self._stage_id, connector_get_key)
|
||||
return True
|
||||
|
||||
@@ -1175,14 +1134,27 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
request_id: str | None,
|
||||
request: Any | None,
|
||||
pooling_output: Any | None,
|
||||
*,
|
||||
propagate_errors: bool = False,
|
||||
) -> Any | None:
|
||||
"""Run the custom process hook with a best-effort finished kwarg."""
|
||||
if self._custom_process_func is None:
|
||||
return None
|
||||
|
||||
payload_kwarg = getattr(self, "_custom_process_payload_kwarg", None)
|
||||
if payload_kwarg is None:
|
||||
payload_kwarg = self._connector_payload_kwarg(self._custom_process_func)
|
||||
self._custom_process_payload_kwarg = payload_kwarg
|
||||
if payload_kwarg is None:
|
||||
message = f"custom process hook {self._custom_process_func} has no connector payload argument"
|
||||
logger.error(message)
|
||||
if propagate_errors:
|
||||
raise RuntimeError(message)
|
||||
return None
|
||||
|
||||
kwargs = {
|
||||
"transfer_manager": self,
|
||||
"pooling_output": pooling_output,
|
||||
payload_kwarg: pooling_output,
|
||||
"request": request,
|
||||
}
|
||||
supports_is_finished = getattr(
|
||||
@@ -1197,21 +1169,29 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
kwargs["is_finished"] = bool(is_finished_fn())
|
||||
except Exception:
|
||||
logger.debug("request.is_finished() failed for %s", request_id, exc_info=True)
|
||||
if propagate_errors:
|
||||
raise
|
||||
|
||||
try:
|
||||
return self._custom_process_func(**kwargs)
|
||||
except TypeError as exc:
|
||||
if "is_finished" not in kwargs or not self._is_unexpected_is_finished_kwarg_error(exc):
|
||||
logger.exception("custom_process_stage_input_func failed for chunk %s", request_id)
|
||||
if propagate_errors:
|
||||
raise
|
||||
return None
|
||||
kwargs.pop("is_finished", None)
|
||||
try:
|
||||
return self._custom_process_func(**kwargs)
|
||||
except Exception:
|
||||
logger.exception("custom_process_stage_input_func failed for chunk %s", request_id)
|
||||
if propagate_errors:
|
||||
raise
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("custom_process_stage_input_func failed for chunk %s", request_id)
|
||||
if propagate_errors:
|
||||
raise
|
||||
return None
|
||||
|
||||
def _custom_process_supports_is_finished_kwarg(self) -> bool | None:
|
||||
@@ -1247,15 +1227,17 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
def _send_single_request(self, task: dict) -> bool:
|
||||
"""Send one queued task via connector.put().
|
||||
|
||||
Returns True on success. On failure (put() raises or returns
|
||||
``success=False``), returns False **without** decrementing
|
||||
``_pending_save_counts`` so the caller can retry or clean up.
|
||||
Returns True when the task is consumed, either by a successful send or
|
||||
because its delivery completion was already terminal. On transport
|
||||
failure (put() raises or returns ``success=False``), returns False
|
||||
**without** decrementing ``_pending_save_counts`` so the caller can
|
||||
retry or clean up.
|
||||
"""
|
||||
connector = self._omni_connector
|
||||
if connector is None:
|
||||
return True
|
||||
|
||||
request_id = task.get("request_id")
|
||||
request_id = task["request_id"]
|
||||
payload_data = task.get("data")
|
||||
if payload_data is None and task.get("request") is not None:
|
||||
payload_data = self._build_custom_process_payload(
|
||||
@@ -1264,6 +1246,15 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
pooling_output=task.get("pooling_output"),
|
||||
)
|
||||
put_key = task.get("put_key")
|
||||
completion = task.get("completion")
|
||||
if completion is not None and not completion.mark_in_flight():
|
||||
logger.debug(
|
||||
"[Stage-%s] Skipping terminal connector delivery: put_key=%s",
|
||||
task["stage_id"],
|
||||
put_key,
|
||||
)
|
||||
self._decrement_pending_save_count(request_id)
|
||||
return True
|
||||
|
||||
success, _size, _metadata = connector.put(
|
||||
from_stage=str(task["stage_id"]),
|
||||
@@ -1282,8 +1273,9 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
if not success:
|
||||
return False
|
||||
|
||||
if isinstance(request_id, str):
|
||||
self._decrement_pending_save_count(request_id)
|
||||
self._decrement_pending_save_count(request_id)
|
||||
if completion is not None:
|
||||
completion.set_result()
|
||||
return True
|
||||
|
||||
def _decrement_pending_save_count(self, request_id: str) -> None:
|
||||
@@ -1302,6 +1294,7 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
self._put_req_chunk.pop(cleanup_req_id, None)
|
||||
self._send_side_request_payload.pop(cleanup_req_id, None)
|
||||
self._code_prompt_token_ids.pop(cleanup_req_id, None)
|
||||
getattr(self, "_qwen3_tts_emitted_frames", {}).pop(cleanup_req_id, None)
|
||||
self._cached_ic.pop(cleanup_req_id, None)
|
||||
self._ramp_chunk_count.pop(cleanup_req_id, None)
|
||||
self._adaptive_states.pop(cleanup_req_id, None)
|
||||
@@ -1354,3 +1347,238 @@ class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin):
|
||||
|
||||
self._send_side_request_payload[req_id] = merged
|
||||
return cast(OmniPayload, dict(merged))
|
||||
|
||||
def _drain_omni_connector_output(self) -> OmniConnectorOutput:
|
||||
tp_group = self._get_local_tp_group()
|
||||
if self._async_chunk and tp_group is not None and getattr(tp_group, "world_size", 1) > 1:
|
||||
if self.is_data_transfer_rank():
|
||||
with self._lock:
|
||||
fanout_packet = self._collect_async_chunk_fanout_packet_locked()
|
||||
else:
|
||||
fanout_packet = None
|
||||
fanout_packet = self._broadcast_tp_payload_packet(fanout_packet)
|
||||
if fanout_packet is None:
|
||||
newly_finished = set()
|
||||
chunk_finished = set()
|
||||
request_metadata = {}
|
||||
else:
|
||||
if not self.is_data_transfer_rank():
|
||||
self._apply_async_chunk_fanout_packet(fanout_packet)
|
||||
newly_finished = set(fanout_packet["newly_finished"])
|
||||
chunk_finished = set(fanout_packet["chunk_finished"])
|
||||
request_metadata = dict(fanout_packet["request_metadata"])
|
||||
else:
|
||||
with self._lock:
|
||||
newly_finished = set(self._finished_load_reqs)
|
||||
self._finished_load_reqs.clear()
|
||||
chunk_finished = set(self._chunk_finished_req_ids)
|
||||
self._chunk_finished_req_ids.clear()
|
||||
request_metadata = dict(self._local_request_metadata)
|
||||
self._local_request_metadata.clear()
|
||||
# _send_side_request_payload is the async accumulation buffer for
|
||||
# future recv chunks. Clearing it on every consumable wake-up drops
|
||||
# intermediate
|
||||
# thinker decode spans before the model side can consume them.
|
||||
# Only terminal chunk_finished requests may release that buffer.
|
||||
for req_id in chunk_finished:
|
||||
if req_id not in self._local_stage_payload_cache:
|
||||
continue
|
||||
ext_req_id = self._request_ids_mapping.get(req_id, req_id)
|
||||
self._send_side_request_payload.pop(ext_req_id, None)
|
||||
if ext_req_id != req_id:
|
||||
self._send_side_request_payload.pop(req_id, None)
|
||||
self._chunk_ready_req_ids.update(newly_finished)
|
||||
|
||||
output = OmniConnectorOutput(
|
||||
chunk_ready_req_ids=set(self._chunk_ready_req_ids),
|
||||
chunk_finished_req_ids=chunk_finished,
|
||||
request_metadata=request_metadata,
|
||||
kv_sent_req_ids=list(self._kv_sent_req_ids),
|
||||
stage_recv_req_ids=set(self._stage_recv_req_ids),
|
||||
has_pending_kv_work=self.has_pending_kv_work(),
|
||||
)
|
||||
if output.stage_recv_req_ids or chunk_finished or newly_finished:
|
||||
logger.debug(
|
||||
"[Stage-%s] get_omni_connector_output: stage_recv=%s, chunk_finished=%s, chunk_ready=%s",
|
||||
self._stage_id,
|
||||
output.stage_recv_req_ids,
|
||||
chunk_finished,
|
||||
output.chunk_ready_req_ids,
|
||||
)
|
||||
self._chunk_ready_req_ids.clear()
|
||||
self._kv_sent_req_ids.clear()
|
||||
self._stage_recv_req_ids.clear()
|
||||
return output
|
||||
|
||||
def _enqueue_chunk_payload(
|
||||
self,
|
||||
request: Any,
|
||||
payload_data: Any,
|
||||
*,
|
||||
wait_for_delivery: bool = False,
|
||||
) -> tuple[bool, Any | None]:
|
||||
"""Enqueue an already-built payload under its per-request chunk key."""
|
||||
raw_req_id = getattr(request, "request_id", None) or getattr(request, "req_id", None)
|
||||
request_id = self._resolve_external_req_id(request, raw_req_id)
|
||||
if raw_req_id and raw_req_id != request_id:
|
||||
self._request_ids_mapping.setdefault(raw_req_id, request_id)
|
||||
chunk_id = self._put_req_chunk[request_id]
|
||||
self._put_req_chunk[request_id] += 1
|
||||
self._ramp_chunk_count[request_id] += 1
|
||||
next_stage_id = self._next_stage_id
|
||||
connector_put_key = f"{request_id}_{self._stage_id}_{chunk_id}"
|
||||
completion = (
|
||||
self._create_send_completion(
|
||||
request_id=request_id,
|
||||
put_key=connector_put_key,
|
||||
)
|
||||
if wait_for_delivery
|
||||
else None
|
||||
)
|
||||
|
||||
if chunk_id == 0:
|
||||
logger.debug(
|
||||
"[Stage-%s] send_chunk: first chunk enqueued, req=%s key=%s",
|
||||
self._stage_id,
|
||||
request_id,
|
||||
connector_put_key,
|
||||
)
|
||||
|
||||
task = {
|
||||
"stage_id": self._stage_id,
|
||||
"next_stage_id": next_stage_id,
|
||||
"put_key": connector_put_key,
|
||||
"data": payload_data,
|
||||
"request_id": request_id,
|
||||
"completion": completion,
|
||||
}
|
||||
with self._lock:
|
||||
self._pending_save_reqs.setdefault(request_id, deque()).append(task)
|
||||
self._pending_save_counts[request_id] += 1
|
||||
self._work_available.set()
|
||||
return True, completion
|
||||
|
||||
def _poll_pending_requests_once(self, pending_ids: list[str]) -> bool:
|
||||
"""Poll one receiver pass and publish its ready requests as a cohort."""
|
||||
made_progress = False
|
||||
for req_id in pending_ids:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
made_progress = self._poll_single_request(req_id, publish_ready=False) or made_progress
|
||||
except Exception:
|
||||
logger.warning("Error receiving data for %s", req_id, exc_info=True)
|
||||
if made_progress and self._async_chunk:
|
||||
self.publish_omni_connector_output_to_sink()
|
||||
return made_progress
|
||||
|
||||
def _publish_chunk_cohort(self, entries: list[tuple[Any, Any]], *, wait_for_delivery: bool) -> int:
|
||||
entries = [(request, payload) for request, payload in entries if payload is not None]
|
||||
if not entries:
|
||||
return 0
|
||||
emitted = 0
|
||||
completions: list[Any] = []
|
||||
for request, payload in entries:
|
||||
enqueued, completion = self._enqueue_chunk_payload(request, payload, wait_for_delivery=wait_for_delivery)
|
||||
if enqueued:
|
||||
emitted += 1
|
||||
if completion is not None:
|
||||
completions.append(completion)
|
||||
for completion in completions:
|
||||
completion.wait()
|
||||
return emitted
|
||||
|
||||
def publish_omni_connector_output_to_sink(self) -> bool:
|
||||
"""Drain pending readiness into the same-process scheduler inbox."""
|
||||
sink = getattr(self, "_omni_connector_output_sink", None)
|
||||
if sink is None:
|
||||
return False
|
||||
output = self.get_omni_connector_output()
|
||||
if not self._connector_output_has_signals(output):
|
||||
return False
|
||||
sink(output)
|
||||
return True
|
||||
|
||||
def send_chunks(
|
||||
self,
|
||||
entries: list[tuple[Any, Any | None]],
|
||||
*,
|
||||
propagate_errors: bool = False,
|
||||
wait_for_delivery: bool = False,
|
||||
) -> int:
|
||||
"""Build and enqueue all request payloads from one model step.
|
||||
|
||||
A model-specific batch builder may coalesce GPU post-processing and
|
||||
D2H copies across requests. Models without one retain the scalar
|
||||
builder contract.
|
||||
"""
|
||||
if not entries or self._omni_connector is None:
|
||||
return 0
|
||||
if not self.is_data_transfer_rank():
|
||||
return len(entries)
|
||||
|
||||
batch_func = getattr(self, "_custom_process_batch_func", None)
|
||||
if batch_func is None:
|
||||
payloads = []
|
||||
for request, pooling_output in entries:
|
||||
raw_req_id = getattr(request, "request_id", None) or getattr(request, "req_id", None)
|
||||
request_id = self._resolve_external_req_id(request, raw_req_id)
|
||||
payloads.append(
|
||||
self._build_custom_process_payload(
|
||||
request_id=request_id,
|
||||
request=request,
|
||||
pooling_output=pooling_output,
|
||||
propagate_errors=propagate_errors,
|
||||
)
|
||||
)
|
||||
return self._publish_chunk_cohort(
|
||||
[(request, payload) for (request, _), payload in zip(entries, payloads, strict=True)],
|
||||
wait_for_delivery=wait_for_delivery,
|
||||
)
|
||||
|
||||
requests = [request for request, _ in entries]
|
||||
pooling_outputs = [pooling_output for _, pooling_output in entries]
|
||||
is_finished: list[bool] = []
|
||||
for request in requests:
|
||||
finished_fn = getattr(request, "is_finished", None)
|
||||
is_finished.append(bool(finished_fn()) if callable(finished_fn) else False)
|
||||
|
||||
try:
|
||||
payloads = batch_func(
|
||||
transfer_manager=self,
|
||||
pooling_outputs=pooling_outputs,
|
||||
requests=requests,
|
||||
is_finished=is_finished,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"batch custom_process_stage_input_func failed for %s requests",
|
||||
len(entries),
|
||||
)
|
||||
if propagate_errors:
|
||||
raise
|
||||
return 0
|
||||
if not isinstance(payloads, list) or len(payloads) != len(entries):
|
||||
message = (
|
||||
"batch custom process hook "
|
||||
f"{batch_func} returned "
|
||||
f"{len(payloads) if isinstance(payloads, list) else type(payloads).__name__} "
|
||||
f"payloads for {len(entries)} requests"
|
||||
)
|
||||
logger.error(
|
||||
"batch custom process hook %s returned %s payloads for %s requests",
|
||||
batch_func,
|
||||
len(payloads) if isinstance(payloads, list) else type(payloads).__name__,
|
||||
len(entries),
|
||||
)
|
||||
if propagate_errors:
|
||||
raise RuntimeError(message)
|
||||
return 0
|
||||
|
||||
return self._publish_chunk_cohort(list(zip(requests, payloads)), wait_for_delivery=wait_for_delivery)
|
||||
|
||||
def set_omni_connector_output_sink(
|
||||
self,
|
||||
sink: Callable[[OmniConnectorOutput], None] | None,
|
||||
) -> None:
|
||||
self._omni_connector_output_sink = sink
|
||||
@@ -9,6 +9,7 @@ import inspect
|
||||
import os
|
||||
import threading
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
@@ -21,6 +22,7 @@ from vllm_omni.distributed.omni_connectors.utils.config import (
|
||||
)
|
||||
from vllm_omni.distributed.omni_connectors.utils.initialization import resolve_connector_spec
|
||||
from vllm_omni.distributed.omni_connectors.utils.kv_utils import get_local_tp_rank, get_omni_replica_id
|
||||
from vllm_omni.outputs import OmniConnectorOutput
|
||||
|
||||
logger = init_logger("vllm_omni.worker.omni_connector_model_runner_mixin")
|
||||
|
||||
@@ -69,14 +71,35 @@ def should_accumulate_full_payload_output(model_config, custom_process_func) ->
|
||||
return False
|
||||
if getattr(model_config, "async_chunk", False):
|
||||
return False
|
||||
if getattr(model_config, "final_output", False):
|
||||
return False
|
||||
next_stage_func = getattr(model_config, "custom_process_next_stage_input_func", None)
|
||||
if not isinstance(next_stage_func, str) or not next_stage_func:
|
||||
return False
|
||||
return getattr(model_config, "model_stage", None) is not None
|
||||
|
||||
|
||||
class _SendCompletion:
|
||||
"""One connector task's delivery acknowledgement."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._event = threading.Event()
|
||||
self._error: BaseException | None = None
|
||||
|
||||
def mark_in_flight(self) -> bool:
|
||||
return True
|
||||
|
||||
def set_result(self) -> None:
|
||||
self._event.set()
|
||||
|
||||
def set_error(self, error: BaseException) -> None:
|
||||
self._error = error
|
||||
self._event.set()
|
||||
|
||||
def wait(self) -> None:
|
||||
self._event.wait()
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
|
||||
class _OmniConnectorRuntimeMixin:
|
||||
"""Own connector lifecycle, shared state, and KV transfer delegation."""
|
||||
|
||||
@@ -166,6 +189,8 @@ class _OmniConnectorRuntimeMixin:
|
||||
self._stage_id: int = stage_id if isinstance(stage_id, int) else 0
|
||||
|
||||
self._custom_process_func_path, self._custom_process_func = self._load_custom_func(model_config)
|
||||
self._custom_process_batch_func = self._load_custom_batch_func(self._custom_process_func)
|
||||
self._custom_process_payload_kwarg = self._connector_payload_kwarg(self._custom_process_func)
|
||||
self._custom_process_supports_is_finished = self._custom_process_supports_is_finished_kwarg()
|
||||
logger.debug(
|
||||
"[Stage-%s] init_omni_connectors: async_chunk=%s, custom_process_func=%s, connector=%s, func_path=%s",
|
||||
@@ -229,6 +254,7 @@ class _OmniConnectorRuntimeMixin:
|
||||
self._pending_save_reqs: dict[str, deque] = {}
|
||||
self._pending_save_counts: dict[str, int] = defaultdict(int)
|
||||
self._deferred_send_cleanup: set[str] = set()
|
||||
self._connector_send_error_sink: Callable[[BaseException], None] | None = None
|
||||
# -- per-cycle output accumulator --
|
||||
self._chunk_ready_req_ids: set[str] = set()
|
||||
self._chunk_finished_req_ids: set[str] = set()
|
||||
@@ -247,6 +273,9 @@ class _OmniConnectorRuntimeMixin:
|
||||
self._local_stage_payload_cache: dict[str, dict[str, Any]] = {}
|
||||
# Lightweight scheduling metadata pending delivery to the Scheduler.
|
||||
self._local_request_metadata: dict[str, dict[str, Any]] = {}
|
||||
# Optional same-process control-plane fast path. Payload tensors remain
|
||||
# runner-owned; only OmniConnectorOutput readiness is published.
|
||||
self._omni_connector_output_sink: Callable[[OmniConnectorOutput], None] | None = None
|
||||
|
||||
# -- persistent set of request IDs whose chunk stream is complete --
|
||||
# Prevents re-registration after the finish sentinel has been received.
|
||||
@@ -270,6 +299,7 @@ class _OmniConnectorRuntimeMixin:
|
||||
self._kv_triggered_requests: set[str] = set()
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._omni_connector_output_drain_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._work_available = threading.Event()
|
||||
|
||||
@@ -364,6 +394,7 @@ class _OmniConnectorRuntimeMixin:
|
||||
self._put_req_chunk.pop(k, None)
|
||||
self._send_side_request_payload.pop(k, None)
|
||||
self._code_prompt_token_ids.pop(k, None)
|
||||
getattr(self, "_qwen3_tts_emitted_frames", {}).pop(k, None)
|
||||
self._cached_ic.pop(k, None)
|
||||
self._ramp_chunk_count.pop(k, None)
|
||||
self._adaptive_states.pop(k, None)
|
||||
@@ -384,6 +415,10 @@ class _OmniConnectorRuntimeMixin:
|
||||
self._cleanup_recv_delivery_state(req_id)
|
||||
|
||||
def _drop_send_side_payload_state(self, req_id: str, ext_id: str | None) -> None:
|
||||
watermark = getattr(self, "_qwen3_tts_emitted_frames", {})
|
||||
watermark.pop(req_id, None)
|
||||
if ext_id is not None:
|
||||
watermark.pop(ext_id, None)
|
||||
if ext_id is not None:
|
||||
self._send_side_request_payload.pop(ext_id, None)
|
||||
self._cached_ic.pop(ext_id, None)
|
||||
@@ -594,31 +629,12 @@ class _OmniConnectorRuntimeMixin:
|
||||
|
||||
return None, None
|
||||
|
||||
@staticmethod
|
||||
def _is_connector_payload_builder(func: Any) -> bool:
|
||||
@classmethod
|
||||
def _is_connector_payload_builder(cls, func: Any) -> bool:
|
||||
"""Whether *func* matches the mixin payload-builder contract."""
|
||||
try:
|
||||
signature = inspect.signature(func)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._connector_payload_kwarg(func) is not None
|
||||
|
||||
params = signature.parameters
|
||||
if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values()):
|
||||
return True
|
||||
|
||||
required = {"transfer_manager", "pooling_output", "request"}
|
||||
supported = {
|
||||
name
|
||||
for name, param in params.items()
|
||||
if param.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
)
|
||||
}
|
||||
return required.issubset(supported)
|
||||
|
||||
def _resolve_external_req_id(self, request: Any, fallback_req_id: str) -> str:
|
||||
def _resolve_external_req_id(self, request: Any, fallback_req_id: str | None) -> str:
|
||||
"""Resolve the external request ID consistently.
|
||||
|
||||
Checks ``_request_ids_mapping`` first (populated by
|
||||
@@ -626,7 +642,7 @@ class _OmniConnectorRuntimeMixin:
|
||||
``external_req_id`` attribute, and finally to the given
|
||||
``fallback_req_id``.
|
||||
"""
|
||||
mapped = self._request_ids_mapping.get(fallback_req_id)
|
||||
mapped = self._request_ids_mapping.get(fallback_req_id) if fallback_req_id is not None else None
|
||||
if mapped is not None:
|
||||
return mapped
|
||||
if request is not None:
|
||||
@@ -634,6 +650,8 @@ class _OmniConnectorRuntimeMixin:
|
||||
ext = getattr(request, "external_req_id", None)
|
||||
if ext is not None:
|
||||
return ext
|
||||
if fallback_req_id is None:
|
||||
raise ValueError("Connector request has neither an external nor an internal request ID")
|
||||
return fallback_req_id
|
||||
|
||||
def _resolve_next_stage_id(self, model_config: Any) -> int:
|
||||
@@ -1073,3 +1091,67 @@ class _OmniConnectorRuntimeMixin:
|
||||
) -> str:
|
||||
"""Build connector key that includes rank info for KV transfers."""
|
||||
return f"{req_id}_{from_stage}_{chunk_id}_{from_rank}_{to_rank}"
|
||||
|
||||
@staticmethod
|
||||
def _connector_payload_kwarg(func: Any) -> str | None:
|
||||
"""Resolve the model output argument used by a connector builder.
|
||||
|
||||
Full-payload builders historically name it ``pooling_output`` while
|
||||
async-chunk builders name it ``multimodal_output``. Both represent the
|
||||
runner-owned per-step inter-stage payload and share the same transport
|
||||
contract.
|
||||
"""
|
||||
if func is None:
|
||||
return None
|
||||
try:
|
||||
signature = inspect.signature(func)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
params = signature.parameters
|
||||
if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values()):
|
||||
return "pooling_output"
|
||||
|
||||
supported = {
|
||||
name
|
||||
for name, param in params.items()
|
||||
if param.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
)
|
||||
}
|
||||
if not {"transfer_manager", "request"}.issubset(supported):
|
||||
return None
|
||||
if "pooling_output" in supported:
|
||||
return "pooling_output"
|
||||
if "multimodal_output" in supported:
|
||||
return "multimodal_output"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _load_custom_batch_func(custom_process_func: Any | None) -> Any | None:
|
||||
"""Resolve an optional ``<scalar_builder>_batch`` peer."""
|
||||
if custom_process_func is None:
|
||||
return None
|
||||
module_name = getattr(custom_process_func, "__module__", None)
|
||||
func_name = getattr(custom_process_func, "__name__", None)
|
||||
if not module_name or not func_name:
|
||||
return None
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
batch_func = getattr(module, f"{func_name}_batch", None)
|
||||
except ImportError:
|
||||
logger.debug(
|
||||
"Could not import batch connector payload builder for %s.%s",
|
||||
module_name,
|
||||
func_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
return batch_func if callable(batch_func) else None
|
||||
|
||||
def _create_send_completion(self, *, request_id: str, put_key: str) -> Any:
|
||||
"""Create a delivery completion; MRv2 overrides this with a ticket."""
|
||||
del request_id, put_key
|
||||
return _SendCompletion()
|
||||
@@ -273,6 +273,8 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
frozen_ids = token_ids.copy()
|
||||
setattr(snapshot, private_name, frozen_ids)
|
||||
setattr(snapshot, public_name, ConstantList(frozen_ids))
|
||||
if public_name == "output_token_ids":
|
||||
snapshot.output_token_count = len(frozen_ids)
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
@@ -878,6 +880,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
|
||||
if is_segment_finished:
|
||||
self.code_prompt_token_ids.pop(external_req_id, None)
|
||||
getattr(self, "_qwen3_tts_emitted_frames", {}).pop(external_req_id, None)
|
||||
self.ramp_chunk_count.pop(external_req_id, None)
|
||||
self._adaptive_states.pop(external_req_id, None)
|
||||
cached_ic = getattr(self, "_cached_ic", None)
|
||||
@@ -992,6 +995,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
|
||||
self.put_req_chunk.pop(external_req_id, None)
|
||||
self.request_payload.pop(external_req_id, None)
|
||||
self.code_prompt_token_ids.pop(external_req_id, None)
|
||||
getattr(self, "_qwen3_tts_emitted_frames", {}).pop(external_req_id, None)
|
||||
self.requests_num_chunks_sent.pop(external_req_id, None)
|
||||
self._segment_generation.pop(external_req_id, None)
|
||||
self.ramp_chunk_count.pop(external_req_id, None)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Engine components for vLLM-Omni.
|
||||
"""
|
||||
@@ -134,10 +137,19 @@ class OmniEngineCoreOutput(EngineCoreOutput):
|
||||
# pooling_output is inherited from EngineCoreOutput as torch.Tensor | None
|
||||
# and retains its original vLLM semantics for pooling/embedding tasks.
|
||||
multimodal_output: dict[str, torch.Tensor] | None = None
|
||||
# Bytes-serialized carrier for a dict-shaped pooling_output (MR V2 cross-stage
|
||||
# hidden/codec handoff). vLLM strictly decodes ``pooling_output`` as a
|
||||
# ``torch.Tensor``, so a per-request dict payload cannot ride that field. The
|
||||
# producer (OmniARScheduler) sets ``pooling_output=None`` and serializes the
|
||||
# dict here; the consumer (StagePool._poll_stage_raw) rehydrates it back into
|
||||
# ``pooling_output`` after decode. Tensor pooling outputs are unaffected.
|
||||
pooling_output_payload: AdditionalInformationPayload | None = None
|
||||
# Finished flag for streaming input segment
|
||||
is_segment_finished: bool | None = False
|
||||
# Streaming update prompt length
|
||||
new_prompt_len_snapshot: int | None = None
|
||||
# Authoritative segment count when the native plane suppresses token IPC.
|
||||
num_generation_tokens: int | None = None
|
||||
|
||||
|
||||
class OmniEngineCoreOutputs(EngineCoreOutputs):
|
||||
|
||||
@@ -190,6 +190,7 @@ class OmniEngineArgs(EngineArgs):
|
||||
model_stage: str = "thinker"
|
||||
model_arch: str | None = None
|
||||
engine_output_type: str | None = None
|
||||
final_output: bool = False
|
||||
hf_config_name: str | None = None
|
||||
custom_process_next_stage_input_func: str | None = None
|
||||
requires_full_payload_input: bool = False
|
||||
@@ -199,6 +200,8 @@ class OmniEngineArgs(EngineArgs):
|
||||
async_chunk: bool = False
|
||||
session_mode: str = "turn"
|
||||
retains_state_across_chunks: bool = False
|
||||
use_v2_model_runner: bool = False
|
||||
supports_native_mrv2_data_plane: bool = False
|
||||
# WS-A: Stage-1 active stream slots. 0 = legacy preempt-everything.
|
||||
# Must be declared here so engine_args dict propagation does not silently
|
||||
# drop the value when constructing OmniEngineArgs from kwargs.
|
||||
@@ -423,6 +426,8 @@ class OmniEngineArgs(EngineArgs):
|
||||
async_chunk=self.async_chunk,
|
||||
session_mode=self.session_mode,
|
||||
retains_state_across_chunks=self.retains_state_across_chunks,
|
||||
use_v2_model_runner=self.use_v2_model_runner,
|
||||
supports_native_mrv2_data_plane=self.supports_native_mrv2_data_plane,
|
||||
active_stream_window=self.active_stream_window,
|
||||
duplex_max_sessions=self.duplex_max_sessions,
|
||||
model_stage=self.model_stage,
|
||||
@@ -430,6 +435,7 @@ class OmniEngineArgs(EngineArgs):
|
||||
worker_type=self.worker_type,
|
||||
pooling_output_decoder=self.pooling_output_decoder,
|
||||
engine_output_type=self.engine_output_type,
|
||||
final_output=self.final_output,
|
||||
hf_config_name=self.hf_config_name,
|
||||
custom_process_next_stage_input_func=self.custom_process_next_stage_input_func,
|
||||
requires_full_payload_input=self.requires_full_payload_input,
|
||||
|
||||
@@ -192,6 +192,8 @@ class OrchestratorRequestState:
|
||||
final_stage_id: int = -1
|
||||
final_output_stage_ids: set[int] = field(default_factory=set)
|
||||
finished_final_output_stage_ids: set[int] = field(default_factory=set)
|
||||
finished_stage_ids: set[int] = field(default_factory=set)
|
||||
pending_final_output: OutputMessage | None = None
|
||||
|
||||
# Wall-clock timestamp when the client-facing engine request was accepted.
|
||||
request_timestamp: float = 0.0
|
||||
@@ -763,20 +765,21 @@ class OrchestratorBase:
|
||||
float(kv_wait_s),
|
||||
)
|
||||
req_state = self.request_states.get(getattr(eco, "request_id", None))
|
||||
if req_state is None or not req_state.streaming.enabled:
|
||||
if req_state is None:
|
||||
continue
|
||||
segment_finished = bool(getattr(eco, "is_segment_finished", False))
|
||||
raw_mm = self._completion_multimodal_output(eco, None)
|
||||
req_state.streaming.segments[stage_id] = StreamingSegmentState(
|
||||
finished=segment_finished,
|
||||
token_ids=(self._coerce_int_list(getattr(eco, "new_token_ids", None)) if segment_finished else []),
|
||||
output_metadata=(dict(raw_mm) if segment_finished and isinstance(raw_mm, dict) else {}),
|
||||
)
|
||||
req_state.streaming.new_prompt_len_snapshot = getattr(
|
||||
eco,
|
||||
"new_prompt_len_snapshot",
|
||||
None,
|
||||
)
|
||||
if req_state.streaming.enabled:
|
||||
segment_finished = bool(getattr(eco, "is_segment_finished", False))
|
||||
raw_mm = self._completion_multimodal_output(eco, None)
|
||||
req_state.streaming.segments[stage_id] = StreamingSegmentState(
|
||||
finished=segment_finished,
|
||||
token_ids=(self._coerce_int_list(getattr(eco, "new_token_ids", None)) if segment_finished else []),
|
||||
output_metadata=(dict(raw_mm) if segment_finished and isinstance(raw_mm, dict) else {}),
|
||||
)
|
||||
req_state.streaming.new_prompt_len_snapshot = getattr(
|
||||
eco,
|
||||
"new_prompt_len_snapshot",
|
||||
None,
|
||||
)
|
||||
if await self._apply_raw_terminal_stage_finish(stage_id, eco, req_state):
|
||||
raw_terminal_request_ids.add(req_state.request_id)
|
||||
await self._report_duplex_session_request_error(stage_id, replica_id, eco, req_state)
|
||||
@@ -1506,18 +1509,19 @@ class OrchestratorBase:
|
||||
``is_segment_finished=False``, but vLLM's output processor may remove the
|
||||
request state before that EngineCoreOutput is processed.
|
||||
|
||||
Only update ``finished_final_output_stage_ids`` here. Request cleanup stays
|
||||
in ``_route_output`` so downstream async-chunk stages can still deliver
|
||||
outputs after stage-0 session end.
|
||||
Record raw stage completion here. Client completion is
|
||||
resolved after this raw batch passes through the output processor, so a
|
||||
real processed output wins over the swallowed-terminal fallback.
|
||||
"""
|
||||
if getattr(eco, "finish_reason", None) is None:
|
||||
return False
|
||||
if getattr(eco, "is_segment_finished", False):
|
||||
return False
|
||||
|
||||
req_state.finished_stage_ids.add(stage_id)
|
||||
final_output_stage_ids = req_state.final_output_stage_ids or {req_state.final_stage_id}
|
||||
if stage_id not in final_output_stage_ids:
|
||||
return False
|
||||
return True
|
||||
req_state.finished_final_output_stage_ids.add(stage_id)
|
||||
return True
|
||||
|
||||
@@ -1570,16 +1574,23 @@ class OrchestratorBase:
|
||||
replica_id: int,
|
||||
request_ids: set[str],
|
||||
) -> None:
|
||||
"""Finish streaming requests whose raw terminal had no processed output."""
|
||||
"""Finish requests whose raw terminal had no processed output."""
|
||||
pool = self.stage_pools[stage_id]
|
||||
if not pool.final_output:
|
||||
return
|
||||
|
||||
for request_id in request_ids:
|
||||
req_state = self.request_states.get(request_id)
|
||||
if req_state is None or req_state.session_owned:
|
||||
continue
|
||||
|
||||
pending = req_state.pending_final_output
|
||||
if pending is not None:
|
||||
if set(req_state.stage_submit_ts).issubset(req_state.finished_stage_ids):
|
||||
req_state.pending_final_output = None
|
||||
await self.output_async_queue.put(pending)
|
||||
await self._cleanup_request_ids([request_id, *self._cfg_tracker.cleanup_parent(request_id)])
|
||||
# A real terminal output is pending; do not replace it with
|
||||
# the swallowed-output fallback before upstream completion.
|
||||
continue
|
||||
if not pool.final_output:
|
||||
continue
|
||||
final_output_stage_ids = req_state.final_output_stage_ids or {req_state.final_stage_id}
|
||||
if not final_output_stage_ids.issubset(req_state.finished_final_output_stage_ids):
|
||||
continue
|
||||
@@ -1653,27 +1664,41 @@ class OrchestratorBase:
|
||||
await self._cleanup_request_ids([req_id])
|
||||
return
|
||||
|
||||
if finished and not segment_finished:
|
||||
req_state.finished_stage_ids.add(stage_id)
|
||||
|
||||
request_finished = False
|
||||
if finished and self.stage_pools[stage_id].final_output and not segment_finished:
|
||||
req_state.finished_final_output_stage_ids.add(stage_id)
|
||||
final_output_stage_ids = req_state.final_output_stage_ids or {req_state.final_stage_id}
|
||||
request_finished = final_output_stage_ids.issubset(req_state.finished_final_output_stage_ids)
|
||||
if req_state.session_owned:
|
||||
# Session-owned outputs are delivered to their session through
|
||||
# ``_intercept_stage_output`` below, never to a request queue.
|
||||
# Session-owned outputs are delivered through the session interceptor.
|
||||
pass
|
||||
elif self.stage_pools[stage_id].final_output:
|
||||
await self.output_async_queue.put(
|
||||
OutputMessage(
|
||||
request_id=req_id,
|
||||
stage_id=stage_id,
|
||||
replica_id=replica_id,
|
||||
engine_outputs=output,
|
||||
metrics=stage_metrics,
|
||||
finished=request_finished,
|
||||
stage_submit_ts=submit_ts,
|
||||
)
|
||||
message = OutputMessage(
|
||||
request_id=req_id,
|
||||
stage_id=stage_id,
|
||||
replica_id=replica_id,
|
||||
engine_outputs=output,
|
||||
metrics=stage_metrics,
|
||||
finished=request_finished,
|
||||
stage_submit_ts=submit_ts,
|
||||
)
|
||||
if (
|
||||
request_finished
|
||||
and self.async_chunk
|
||||
and not req_state.streaming.enabled
|
||||
and not set(req_state.stage_submit_ts).issubset(req_state.finished_stage_ids)
|
||||
):
|
||||
# Direct chunk delivery can finish the decoder before the
|
||||
# upstream engine's terminal output/metrics reaches this loop.
|
||||
# Keep request state until those messages are routed, otherwise
|
||||
# the frontend publishes incomplete usage and loses stop reasons.
|
||||
req_state.pending_final_output = message
|
||||
request_finished = False
|
||||
else:
|
||||
await self.output_async_queue.put(message)
|
||||
elif stage_metrics is not None:
|
||||
await self.output_async_queue.put(
|
||||
StageMetricsMessage(
|
||||
@@ -1741,6 +1766,12 @@ class OrchestratorBase:
|
||||
is_final_update=True,
|
||||
)
|
||||
|
||||
pending = req_state.pending_final_output
|
||||
if pending is not None and set(req_state.stage_submit_ts).issubset(req_state.finished_stage_ids):
|
||||
req_state.pending_final_output = None
|
||||
await self.output_async_queue.put(pending)
|
||||
request_finished = True
|
||||
|
||||
if request_finished and not req_state.session_owned:
|
||||
await self._cleanup_request_ids([req_id, *self._cfg_tracker.cleanup_parent(req_id)])
|
||||
|
||||
@@ -2483,8 +2514,6 @@ class OrchestratorBase:
|
||||
operation="async-chunk prewarm",
|
||||
)
|
||||
else:
|
||||
import copy
|
||||
|
||||
from vllm_omni.distributed.omni_connectors.adapter import compute_talker_prompt_ids_length
|
||||
|
||||
try:
|
||||
@@ -2507,7 +2536,9 @@ class OrchestratorBase:
|
||||
|
||||
original_prompt = req_state.prompt
|
||||
if isinstance(original_prompt, dict):
|
||||
base_input = copy.deepcopy(original_prompt)
|
||||
from vllm_omni.engine.request_snapshot import copy_request_snapshot
|
||||
|
||||
base_input = copy_request_snapshot(original_prompt)
|
||||
else:
|
||||
base_input = {}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Full request snapshots with a fast path for immutable scalar leaves."""
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
_ATOMIC_TYPES = frozenset((type(None), bool, int, float, complex, str, bytes))
|
||||
|
||||
|
||||
def copy_request_snapshot(value: Any, memo: dict[int, Any] | None = None) -> Any:
|
||||
"""Deep-copy all fields, preserving aliases and isolating mutable containers.
|
||||
|
||||
Plain numeric/text trees do not need generic object dispatch or an identity
|
||||
lookup for each immutable scalar. Subclasses and other objects retain their
|
||||
regular deepcopy contracts. No model fields are filtered or shared here.
|
||||
"""
|
||||
kind = type(value)
|
||||
if kind in _ATOMIC_TYPES:
|
||||
return value
|
||||
if memo is None:
|
||||
memo = {}
|
||||
key = id(value)
|
||||
if key in memo:
|
||||
return memo[key]
|
||||
if kind is list:
|
||||
atomic = all(type(item) in _ATOMIC_TYPES for item in value)
|
||||
result = value.copy() if atomic else []
|
||||
memo[key] = result
|
||||
if not atomic:
|
||||
result.extend(copy_request_snapshot(item, memo) for item in value)
|
||||
elif kind is dict:
|
||||
result = {}
|
||||
memo[key] = result
|
||||
for k, item in value.items():
|
||||
result[copy_request_snapshot(k, memo)] = copy_request_snapshot(item, memo)
|
||||
else:
|
||||
return copy.deepcopy(value, memo)
|
||||
# Keep sources alive for the duration of a copy, including custom deepcopy
|
||||
# implementations that mutate a source container while being visited.
|
||||
memo.setdefault(id(memo), []).append(value)
|
||||
return result
|
||||
@@ -19,7 +19,7 @@ import vllm.v1.engine as _vllm_engine_module
|
||||
import vllm.v1.engine.core_client as _vllm_core_client_module
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.engine import EngineCoreRequest
|
||||
from vllm.v1.engine.core_client import AsyncMPClient, DPLBAsyncMPClient
|
||||
from vllm.v1.engine.core_client import AsyncMPClient, DPLBAsyncMPClient, MPClient
|
||||
from vllm.v1.engine.exceptions import EngineDeadError
|
||||
|
||||
from vllm_omni.distributed.omni_connectors.utils.config import (
|
||||
@@ -232,6 +232,9 @@ class StageEngineCoreClientBase(StageClientBase):
|
||||
if self.resources.engine_dead:
|
||||
raise EngineDeadError(f"Stage-{self.stage_id} engine core is dead")
|
||||
|
||||
def _apply_ready_response(self, payload: bytes) -> None:
|
||||
MPClient._apply_ready_response(self, payload)
|
||||
|
||||
# ==================== Overrides ====================
|
||||
|
||||
async def add_request_async(self, request: EngineCoreRequest) -> None:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Stage Core Process for vLLM-Omni V1 architecture.
|
||||
|
||||
@@ -27,6 +30,7 @@ from vllm.v1.engine.utils import (
|
||||
EngineZmqAddresses,
|
||||
SignalCallback,
|
||||
)
|
||||
from vllm.v1.executor.uniproc_executor import UniProcExecutor
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator import create_stage_coord_client
|
||||
from vllm_omni.engine import OmniEngineCoreRequest
|
||||
@@ -75,6 +79,24 @@ def _signal_exit_code(signum: int) -> int:
|
||||
return _SIGNAL_EXIT_BASE + signum
|
||||
|
||||
|
||||
def _bind_native_data_plane_ready_sink(model_executor: Any, scheduler: Any) -> bool:
|
||||
"""Bind the TP1 in-process runner control plane directly to its scheduler."""
|
||||
if not isinstance(model_executor, UniProcExecutor):
|
||||
return False
|
||||
parallel_config = model_executor.vllm_config.parallel_config
|
||||
if parallel_config.tensor_parallel_size != 1 or parallel_config.pipeline_parallel_size != 1:
|
||||
return False
|
||||
driver_worker = getattr(model_executor, "driver_worker", None)
|
||||
worker = getattr(driver_worker, "worker", None)
|
||||
model_runner = getattr(worker, "model_runner", None)
|
||||
data_plane = getattr(model_runner, "_omni_data_plane", None)
|
||||
sink = getattr(scheduler, "enqueue_omni_connector_output", None)
|
||||
if data_plane is None or not callable(sink):
|
||||
return False
|
||||
data_plane.set_omni_connector_output_sink(sink)
|
||||
return True
|
||||
|
||||
|
||||
class StageEngineCoreProc(EngineCoreProc):
|
||||
"""Stage-specific engine core process for vLLM-Omni.
|
||||
|
||||
@@ -83,6 +105,11 @@ class StageEngineCoreProc(EngineCoreProc):
|
||||
``EngineCoreProc.run_engine_core()``.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if _bind_native_data_plane_ready_sink(self.model_executor, self.scheduler):
|
||||
logger.info("Bound native MRv2 connector readiness directly to the scheduler inbox.")
|
||||
|
||||
def preprocess_add_request(self, request: OmniEngineCoreRequest) -> tuple[Any, int]:
|
||||
"""Preserve omni payloads when vLLM builds its scheduler request."""
|
||||
scheduler_request, current_wave = super().preprocess_add_request(request)
|
||||
|
||||
@@ -15,6 +15,7 @@ from vllm.logger import init_logger
|
||||
from vllm.v1.engine import EngineCoreOutputs
|
||||
from vllm.v1.metrics.stats import IterationStats
|
||||
|
||||
from vllm_omni.data_entry_keys import flatten_payload
|
||||
from vllm_omni.distributed.omni_coordinator import (
|
||||
LoadBalancer,
|
||||
OmniCoordClientForHub,
|
||||
@@ -22,6 +23,7 @@ from vllm_omni.distributed.omni_coordinator import (
|
||||
ReplicaStatus,
|
||||
)
|
||||
from vllm_omni.distributed.omni_coordinator.load_balancer import Task
|
||||
from vllm_omni.engine.serialization import deserialize_additional_information
|
||||
from vllm_omni.engine.stage_client import (
|
||||
StagePoolClient,
|
||||
StagePoolDiffusionClient,
|
||||
@@ -1136,8 +1138,30 @@ class StagePool:
|
||||
# gauges for that interval.
|
||||
if not outputs.outputs and outputs.scheduler_stats is None and not outputs.finished_requests:
|
||||
return None
|
||||
self._rehydrate_pooling_output_payloads(outputs)
|
||||
return outputs
|
||||
|
||||
@staticmethod
|
||||
def _rehydrate_pooling_output_payloads(outputs: EngineCoreOutputs) -> None:
|
||||
"""Restore dict-shaped pooling_output from its bytes carrier (MR V2).
|
||||
|
||||
vLLM decodes EngineCoreOutput.pooling_output as a torch.Tensor, so MR
|
||||
V2 runners ship the per-request dict handoff via ``pooling_output_payload``
|
||||
with ``pooling_output=None``. Decode it back here so downstream stage-input
|
||||
processors see the same ``pooling_output`` shape as the legacy runner.
|
||||
"""
|
||||
for eco in outputs.outputs:
|
||||
payload = getattr(eco, "pooling_output_payload", None)
|
||||
if payload is None:
|
||||
continue
|
||||
if getattr(eco, "pooling_output", None) is None:
|
||||
# deserialize_* returns the nested OmniPayload form; the producer
|
||||
# serialized a flat (dotted-key) pooler dict, and downstream
|
||||
# consumers (e.g. talker2code2wav_full_payload) read flat keys like
|
||||
# "codes.audio". Re-flatten to restore the exact on-wire shape.
|
||||
eco.pooling_output = flatten_payload(deserialize_additional_information(payload))
|
||||
eco.pooling_output_payload = None
|
||||
|
||||
async def process_llm_raw_outputs(
|
||||
self,
|
||||
replica_id: int,
|
||||
|
||||
@@ -110,8 +110,8 @@ _REF_AUDIO_MIN_DURATION = 1.0 # seconds
|
||||
_REF_AUDIO_MAX_DURATION = 30.0 # seconds
|
||||
_REF_AUDIO_METADATA_FETCH_ATTEMPTS = 3
|
||||
_REMOTE_REF_AUDIO_SCHEMES = frozenset({"http", "https", "data"})
|
||||
_REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES = 256
|
||||
_REF_AUDIO_RESOLVE_CACHE_MAX_BYTES = 256 * 1024 * 1024
|
||||
_REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES = 1024
|
||||
_REF_AUDIO_RESOLVE_CACHE_MAX_BYTES = 512 * 1024 * 1024
|
||||
_TTS_MAX_INSTRUCTIONS_LENGTH = 500
|
||||
_DEFAULT_VOICE_NAME = "default"
|
||||
|
||||
@@ -1359,10 +1359,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
self._ref_audio_resolve_cache.move_to_end(cache_key)
|
||||
return cached[3]
|
||||
|
||||
def _put_resolved_ref_audio(self, cache_key: str, waveform: np.ndarray, sr: int, artifact_key: str) -> None:
|
||||
def _put_resolved_ref_audio(
|
||||
self, cache_key: str, waveform: np.ndarray | list[float], sr: int, artifact_key: str
|
||||
) -> None:
|
||||
if self._ref_audio_resolve_cache_max_entries <= 0 or self._ref_audio_resolve_cache_max_bytes <= 0:
|
||||
return
|
||||
size = waveform.nbytes
|
||||
# Keep the finalized ndarray's ownership and identity for the array
|
||||
# resolver. Legacy list callers are materialized into a compact array;
|
||||
# their Python list remains independent from cached storage.
|
||||
waveform = np.asarray(waveform, dtype=np.float32)
|
||||
if not waveform.flags.c_contiguous:
|
||||
waveform = np.ascontiguousarray(waveform)
|
||||
size = int(waveform.nbytes)
|
||||
if size > self._ref_audio_resolve_cache_max_bytes:
|
||||
return
|
||||
previous = self._ref_audio_resolve_cache.pop(cache_key, None)
|
||||
|
||||
@@ -682,14 +682,20 @@ class CodePredictorWrapper(nn.Module):
|
||||
self._lm_heads_list: list[nn.Module] | None = None
|
||||
self._codec_embeds_list: list[nn.Module] | None = None
|
||||
self._device_graphs: dict[int | tuple[int, int], tuple] = {} # (graph, static_output) per bucket
|
||||
# Outer-runner execution buckets when a model host declares them (MRv2);
|
||||
# ``None`` keeps the legacy power-of-two bucket derivation.
|
||||
self._execution_batch_buckets: list[int] | None = None
|
||||
prefix_graph_cfg = self._stage_connector_extra_config(vllm_config)
|
||||
prefix_graphs_requested = self._parse_bool_config(prefix_graph_cfg.get("code_predictor_prefix_graphs"))
|
||||
is_npu = current_omni_platform.is_npu()
|
||||
self._prefix_graphs_enabled = prefix_graphs_requested and wrapper_config.use_cuda_graphs and not is_npu
|
||||
if prefix_graphs_requested and not self._prefix_graphs_enabled:
|
||||
# MRv2 captures the whole Talker MTP call. It can therefore capture
|
||||
# shorter compiled re-prefill forwards without nesting predictor-owned
|
||||
# CUDA graphs inside the outer graph.
|
||||
self._prefix_reprefill_enabled = prefix_graphs_requested and not is_npu
|
||||
self._prefix_graphs_enabled = self._prefix_reprefill_enabled and wrapper_config.use_cuda_graphs
|
||||
if prefix_graphs_requested and not self._prefix_reprefill_enabled:
|
||||
logger.info_once(
|
||||
"code_predictor: prefix CUDA graphs requested but disabled because use_cuda_graphs=%s is_npu=%s",
|
||||
wrapper_config.use_cuda_graphs,
|
||||
"code_predictor: prefix re-prefill requested but disabled because is_npu=%s",
|
||||
is_npu,
|
||||
)
|
||||
self._prefix_graph_buckets = self._parse_positive_int_set(
|
||||
@@ -698,6 +704,7 @@ class CodePredictorWrapper(nn.Module):
|
||||
self._prefix_graph_seq_lens = self._parse_positive_int_set(
|
||||
prefix_graph_cfg.get("code_predictor_prefix_graph_seq_lens")
|
||||
)
|
||||
self._prefix_reprefill_seq_lens = tuple(self._prefix_seq_lens(self._num_groups + 1))
|
||||
|
||||
def get_input_embeddings(self) -> nn.ModuleList:
|
||||
return self.model.get_input_embeddings()
|
||||
@@ -761,7 +768,8 @@ class CodePredictorWrapper(nn.Module):
|
||||
dynamic=False,
|
||||
options={"epilogue_fusion": False},
|
||||
)
|
||||
self._warmup_buckets()
|
||||
with torch._dynamo.config.patch(cache_size_limit=self._compile_cache_size_limit()):
|
||||
self._warmup_buckets()
|
||||
|
||||
if self._wrapper_config.use_cuda_graphs:
|
||||
self._capture_cuda_graphs()
|
||||
@@ -770,7 +778,7 @@ class CodePredictorWrapper(nn.Module):
|
||||
logger.info("code_predictor: torch.compile (dynamic=False, no epilogue fusion)")
|
||||
|
||||
def _padded_bsz(self, bsz: int) -> int:
|
||||
"""Round batch size up to nearest power-of-2 bucket."""
|
||||
"""Round batch size up to the nearest warmed bucket."""
|
||||
for bucket in self._bucket_sizes:
|
||||
if bsz <= bucket:
|
||||
return bucket
|
||||
@@ -832,15 +840,28 @@ class CodePredictorWrapper(nn.Module):
|
||||
return row_generators
|
||||
|
||||
@classmethod
|
||||
def _sample_codes_gumbel(cls, logits: torch.Tensor, generator: _GeneratorLike = None) -> torch.Tensor:
|
||||
"""Sample ``logits`` via Gumbel-max with optional per-row generators."""
|
||||
row_generators = cls._normalize_generators(generator, int(logits.shape[0]))
|
||||
u = torch.empty_like(logits, dtype=torch.float32)
|
||||
if isinstance(row_generators, list):
|
||||
for row, row_generator in enumerate(row_generators):
|
||||
u[row : row + 1].uniform_(_UNIFORM_EPS, 1.0 - _UNIFORM_EPS, generator=row_generator)
|
||||
def _sample_codes_gumbel(
|
||||
cls,
|
||||
logits: torch.Tensor,
|
||||
generator: _GeneratorLike = None,
|
||||
uniforms: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Sample ``logits`` via Gumbel-max with optional precomputed noise."""
|
||||
if uniforms is None:
|
||||
row_generators = cls._normalize_generators(generator, int(logits.shape[0]))
|
||||
u = torch.empty_like(logits, dtype=torch.float32)
|
||||
if isinstance(row_generators, list):
|
||||
for row, row_generator in enumerate(row_generators):
|
||||
u[row : row + 1].uniform_(_UNIFORM_EPS, 1.0 - _UNIFORM_EPS, generator=row_generator)
|
||||
else:
|
||||
u.uniform_(_UNIFORM_EPS, 1.0 - _UNIFORM_EPS, generator=row_generators)
|
||||
else:
|
||||
u.uniform_(_UNIFORM_EPS, 1.0 - _UNIFORM_EPS, generator=row_generators)
|
||||
if uniforms.shape != logits.shape:
|
||||
raise ValueError(
|
||||
f"precomputed sampling uniforms must match logits: uniforms={tuple(uniforms.shape)} "
|
||||
f"logits={tuple(logits.shape)}"
|
||||
)
|
||||
u = uniforms
|
||||
return (logits.float() - torch.log(-torch.log(u))).argmax(dim=-1, keepdim=True)
|
||||
|
||||
def _prefix_seq_lens(self, max_seq: int) -> list[int]:
|
||||
@@ -850,13 +871,64 @@ class CodePredictorWrapper(nn.Module):
|
||||
allowed = set(all_seq_lens)
|
||||
return sorted(seq_len for seq_len in self._prefix_graph_seq_lens if seq_len in allowed)
|
||||
|
||||
def _warmup_buckets(self) -> None:
|
||||
"""Warmup power-of-2 batch-size buckets to front-load Inductor compilation."""
|
||||
@staticmethod
|
||||
def _synchronize_warmup(device: torch.device) -> None:
|
||||
if device.type != "cpu":
|
||||
current_omni_platform.synchronize()
|
||||
|
||||
def configure_mtp_execution_buckets(self, sizes: Iterable[int]) -> None:
|
||||
"""Declare the batch buckets the outer runner can actually reach.
|
||||
|
||||
Must be called before the first warmup; once buckets are captured the
|
||||
existing set is only acceptable when it already covers the declaration
|
||||
(never clear active graphs to rebuild them).
|
||||
"""
|
||||
max_bsz = int(self._vllm_config.scheduler_config.max_num_seqs)
|
||||
cleaned = sorted({int(size) for size in sizes if 0 < int(size) <= max_bsz} | {max_bsz})
|
||||
if self._bucket_sizes:
|
||||
missing = [size for size in cleaned if size not in self._bucket_sizes]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"mtp execution buckets must be configured before the first warmup; "
|
||||
f"missing={missing} existing={self._bucket_sizes}"
|
||||
)
|
||||
return
|
||||
self._execution_batch_buckets = cleaned
|
||||
|
||||
def _batch_bucket_sizes(self) -> list[int]:
|
||||
max_bsz = self._vllm_config.scheduler_config.max_num_seqs
|
||||
if self._execution_batch_buckets is not None:
|
||||
# Outer-reachable buckets plus prefix-graph capture buckets and the
|
||||
# full-size eager fallback row already merged at configure time.
|
||||
bucket_sizes = list(self._execution_batch_buckets)
|
||||
bucket_sizes.extend(bucket for bucket in self._prefix_graph_buckets if bucket <= max_bsz)
|
||||
return sorted(set(bucket_sizes))
|
||||
bucket_sizes = [1 << i for i in range(max_bsz.bit_length()) if (1 << i) <= max_bsz]
|
||||
if max_bsz not in bucket_sizes:
|
||||
bucket_sizes.append(max_bsz)
|
||||
self._bucket_sizes = sorted(bucket_sizes)
|
||||
bucket_sizes.extend(bucket for bucket in self._prefix_graph_buckets if bucket <= max_bsz)
|
||||
bucket_sizes.append(max_bsz)
|
||||
return sorted(set(bucket_sizes))
|
||||
|
||||
def _compile_cache_size_limit(self) -> int:
|
||||
bucket_sizes = self._batch_bucket_sizes()
|
||||
if not self._prefix_reprefill_enabled:
|
||||
required_entries = len(bucket_sizes)
|
||||
else:
|
||||
prefix_buckets = (
|
||||
self._prefix_graph_buckets.intersection(bucket_sizes)
|
||||
if self._prefix_graph_buckets
|
||||
else set(bucket_sizes)
|
||||
)
|
||||
prefix_seq_lens = self._prefix_reprefill_seq_lens
|
||||
needs_full_graph = set(prefix_seq_lens) != set(range(2, self._num_groups + 1))
|
||||
full_graph_entries = len(bucket_sizes) - len(prefix_buckets)
|
||||
if needs_full_graph:
|
||||
full_graph_entries += len(prefix_buckets)
|
||||
required_entries = full_graph_entries + len(prefix_buckets) * len(prefix_seq_lens)
|
||||
return max(torch._dynamo.config.cache_size_limit, required_entries)
|
||||
|
||||
def _warmup_buckets(self) -> None:
|
||||
"""Warm up batch-size buckets to front-load Inductor compilation."""
|
||||
self._bucket_sizes = self._batch_bucket_sizes()
|
||||
|
||||
max_seq = self._num_groups + 1
|
||||
device = next(self.model.parameters()).device
|
||||
@@ -866,8 +938,8 @@ class CodePredictorWrapper(nn.Module):
|
||||
self._ensure_buffers(device, self._model_dtype, max(self._bucket_sizes))
|
||||
proj_buf = self._proj_buf
|
||||
|
||||
if self._prefix_graphs_enabled:
|
||||
prefix_seq_lens = self._prefix_seq_lens(max_seq)
|
||||
if self._prefix_reprefill_enabled:
|
||||
prefix_seq_lens = self._prefix_reprefill_seq_lens
|
||||
needs_full_graph = set(prefix_seq_lens) != set(range(2, max_seq))
|
||||
for bsz in self._bucket_sizes:
|
||||
capture_prefixes = not self._prefix_graph_buckets or bsz in self._prefix_graph_buckets
|
||||
@@ -905,6 +977,10 @@ class CodePredictorWrapper(nn.Module):
|
||||
self._compiled_model_fwd(proj_buf[:bsz, :max_seq, :], pos_ids)
|
||||
logger.info("code_predictor: warmup done for buckets %s", self._bucket_sizes)
|
||||
|
||||
# Compiled attention warmup is asynchronous. Complete it before graph
|
||||
# capture and serving reuse the same static buffers.
|
||||
self._synchronize_warmup(device)
|
||||
|
||||
def _capture_cuda_graphs(self) -> None:
|
||||
"""Capture a CUDA graph per bucket using vLLM's global graph pool."""
|
||||
from vllm.platforms import current_platform
|
||||
@@ -914,7 +990,7 @@ class CodePredictorWrapper(nn.Module):
|
||||
proj_buf = self._proj_buf
|
||||
|
||||
if self._prefix_graphs_enabled:
|
||||
prefix_seq_lens = self._prefix_seq_lens(max_seq)
|
||||
prefix_seq_lens = self._prefix_reprefill_seq_lens
|
||||
needs_full_graph = set(prefix_seq_lens) != set(range(2, max_seq))
|
||||
for bsz in self._bucket_sizes:
|
||||
capture_prefixes = not self._prefix_graph_buckets or bsz in self._prefix_graph_buckets
|
||||
@@ -1013,13 +1089,20 @@ class CodePredictorWrapper(nn.Module):
|
||||
top_p: float = 1.0,
|
||||
generator: torch.Generator | None = None,
|
||||
generators: Sequence[torch.Generator | None] | None = None,
|
||||
sample_uniforms: torch.Tensor | None = None,
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Predict residual codebooks 1..G-1 autoregressively via re-prefill."""
|
||||
bsz = int(layer0_code.shape[0])
|
||||
num_groups = self._num_groups
|
||||
if generators is not None and len(generators) != bsz:
|
||||
raise ValueError(f"generators must have one entry per row: got {len(generators)} for batch {bsz}")
|
||||
if sample_uniforms is not None:
|
||||
expected_shape = (bsz, num_groups - 1, int(self.config.vocab_size))
|
||||
if tuple(sample_uniforms.shape) != expected_shape:
|
||||
raise ValueError(
|
||||
f"sample_uniforms must have shape {expected_shape}, got {tuple(sample_uniforms.shape)}"
|
||||
)
|
||||
sample_generator: _GeneratorLike = generators if generators is not None else generator
|
||||
num_groups = self._num_groups
|
||||
device = layer0_code.device
|
||||
|
||||
# _setup_compile caches _model_dtype on first call; use it for buffers
|
||||
@@ -1072,11 +1155,16 @@ class CodePredictorWrapper(nn.Module):
|
||||
for step in range(1, num_groups):
|
||||
graph_key: int | tuple[int, int] = padded_bsz
|
||||
seq_len = max_seq
|
||||
if self._prefix_graphs_enabled:
|
||||
prefix_key = (padded_bsz, step + 1)
|
||||
if prefix_key in self._device_graphs:
|
||||
graph_key = prefix_key
|
||||
seq_len = step + 1
|
||||
if self._prefix_reprefill_enabled:
|
||||
actual_seq_len = step + 1
|
||||
for prefix_seq_len in self._prefix_reprefill_seq_lens:
|
||||
if prefix_seq_len < actual_seq_len:
|
||||
continue
|
||||
prefix_key = (padded_bsz, prefix_seq_len)
|
||||
if prefix_key in self._bucket_pos_ids:
|
||||
graph_key = prefix_key
|
||||
seq_len = prefix_seq_len
|
||||
break
|
||||
pos_ids = self._bucket_pos_ids.get(graph_key)
|
||||
if pos_ids is None:
|
||||
pos_ids = (
|
||||
@@ -1123,7 +1211,8 @@ class CodePredictorWrapper(nn.Module):
|
||||
remove_mask = (cumulative_probs - sorted_probs) >= s_top_p
|
||||
sorted_logits[remove_mask] = float("-inf")
|
||||
logits = sorted_logits.scatter(1, sorted_idx, sorted_logits)
|
||||
code = self._sample_codes_gumbel(logits, generator=sample_generator)
|
||||
step_uniforms = sample_uniforms[:, step - 1, :] if sample_uniforms is not None else None
|
||||
code = self._sample_codes_gumbel(logits, generator=sample_generator, uniforms=step_uniforms)
|
||||
else:
|
||||
# "per_call" mode: temperature-scaled + top-k -> Gumbel-max
|
||||
if use_sampling:
|
||||
@@ -1131,7 +1220,8 @@ class CodePredictorWrapper(nn.Module):
|
||||
if top_k > 0:
|
||||
topk_vals, _ = scaled.topk(top_k, dim=-1)
|
||||
scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf"))
|
||||
code = self._sample_codes_gumbel(scaled, generator=sample_generator)
|
||||
step_uniforms = sample_uniforms[:, step - 1, :] if sample_uniforms is not None else None
|
||||
code = self._sample_codes_gumbel(scaled, generator=sample_generator, uniforms=step_uniforms)
|
||||
else:
|
||||
code = logits.argmax(dim=-1, keepdim=True)
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
import torch
|
||||
@@ -6,6 +9,18 @@ from vllm.sequence import IntermediateTensors
|
||||
from vllm_omni.data_entry_keys import OmniPayload
|
||||
|
||||
|
||||
class OwnedBatchTensor(NamedTuple):
|
||||
"""A batch tensor whose producer transfers ownership to the buffer.
|
||||
|
||||
The producer must have freshly allocated the tensor (e.g. an index_select
|
||||
result) and must not write it afterwards. The buffer keeps row views
|
||||
instead of taking a second snapshot. Tensors without this marker are
|
||||
treated as borrowed (graph output / shared scratch) and still get a clone.
|
||||
"""
|
||||
|
||||
tensor: torch.Tensor
|
||||
|
||||
|
||||
class OmniOutput(NamedTuple):
|
||||
"""Output from the merged Omni model containing both text and audio."""
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ class Qwen3OmniMoeForConditionalGeneration(
|
||||
# vLLM run with no talker stage downstream, so no one consumes captured
|
||||
# thinker layers and the forward must return what stock vLLM expects.
|
||||
self.is_staged_run = getattr(vllm_config.model_config, "model_stage", None) is not None
|
||||
self._returns_tuple = self.model_stage == "thinker" and self.is_staged_run
|
||||
|
||||
if self.model_stage == "thinker":
|
||||
self.use_async_omni_output = True
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
# Copyright 2026 The Qwen team, Alibaba Group and the HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -533,6 +536,10 @@ class Qwen3TTSConfig(PretrainedConfig):
|
||||
if any("Code2Wav" in str(a) for a in archs):
|
||||
if hasattr(config, "rope_parameters"):
|
||||
delattr(config, "rope_parameters")
|
||||
# Code2Wav doesn't use position embeddings, but vLLM validates
|
||||
# max_model_len <= max_position_embeddings. Set a large value
|
||||
# to allow the pipeline.yaml max_model_len (65536) to pass validation.
|
||||
config.max_position_embeddings = 131072
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Copyright 2026 The Alibaba Qwen team.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""
|
||||
CUDA Graph wrapper for Qwen3TTSTokenizerV2Decoder.
|
||||
|
||||
@@ -8,10 +9,8 @@ reducing kernel launch overhead during inference.
|
||||
"""
|
||||
|
||||
import bisect
|
||||
import os
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Callable, Collection, Sequence
|
||||
|
||||
import torch
|
||||
from torch.cuda import CUDAGraph
|
||||
@@ -187,22 +186,6 @@ class CUDAGraphDecoderWrapper:
|
||||
|
||||
self._warmed_up = False
|
||||
self._device = None
|
||||
self._stats_enabled = os.environ.get("VLLM_OMNI_QWEN3_CODE2WAV_CUDAGRAPH_STATS", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
self._stats_log_every = int(os.environ.get("VLLM_OMNI_QWEN3_CODE2WAV_CUDAGRAPH_STATS_LOG_EVERY", "0") or 0)
|
||||
self._stats_total = 0
|
||||
self._stats_hits = 0
|
||||
self._stats_compiled_hits = 0
|
||||
self._stats_fallbacks = 0
|
||||
self._stats_stream_capture_fallbacks = 0
|
||||
self._stats_requests: Counter[tuple[int, int]] = Counter()
|
||||
self._stats_hit_shapes: Counter[tuple[int, int, int]] = Counter()
|
||||
self._stats_compiled_shapes: Counter[tuple[int, int]] = Counter()
|
||||
self._stats_fallback_shapes: Counter[tuple[int, int, int]] = Counter()
|
||||
|
||||
@staticmethod
|
||||
def compute_capture_sizes(
|
||||
@@ -244,57 +227,19 @@ class CUDAGraphDecoderWrapper:
|
||||
shapes.update(self.extra_capture_shapes)
|
||||
return sorted(shapes)
|
||||
|
||||
def _record_decode_stats(
|
||||
self,
|
||||
*,
|
||||
hit: bool,
|
||||
batch_size: int,
|
||||
@staticmethod
|
||||
def _select_replay_shape(
|
||||
actual_batch_size: int,
|
||||
actual_size: int,
|
||||
padded_size: int | None,
|
||||
stream_capture: bool = False,
|
||||
compiled: bool = False,
|
||||
) -> None:
|
||||
if not self._stats_enabled:
|
||||
return
|
||||
|
||||
padded_key = int(padded_size) if padded_size is not None else -1
|
||||
self._stats_total += 1
|
||||
self._stats_requests[(batch_size, actual_size)] += 1
|
||||
if hit:
|
||||
self._stats_hits += 1
|
||||
if compiled:
|
||||
self._stats_compiled_hits += 1
|
||||
self._stats_compiled_shapes[(batch_size, actual_size)] += 1
|
||||
else:
|
||||
self._stats_hit_shapes[(batch_size, actual_size, padded_key)] += 1
|
||||
else:
|
||||
self._stats_fallbacks += 1
|
||||
self._stats_fallback_shapes[(batch_size, actual_size, padded_key)] += 1
|
||||
if stream_capture:
|
||||
self._stats_stream_capture_fallbacks += 1
|
||||
|
||||
if self._stats_log_every > 0 and self._stats_total % self._stats_log_every == 0:
|
||||
self.log_decode_stats()
|
||||
|
||||
def log_decode_stats(self) -> None:
|
||||
if not self._stats_enabled or self._stats_total == 0:
|
||||
return
|
||||
hit_rate = 100.0 * self._stats_hits / self._stats_total
|
||||
logger.info(
|
||||
"Code2Wav CUDA Graph stats: total=%d hits=%d fallbacks=%d "
|
||||
"compiled_hits=%d stream_capture_fallbacks=%d hit_rate=%.2f%% "
|
||||
"top_requests=%s top_compiled=%s top_hits=%s top_fallbacks=%s",
|
||||
self._stats_total,
|
||||
self._stats_hits,
|
||||
self._stats_fallbacks,
|
||||
self._stats_compiled_hits,
|
||||
self._stats_stream_capture_fallbacks,
|
||||
hit_rate,
|
||||
self._stats_requests.most_common(12),
|
||||
self._stats_compiled_shapes.most_common(12),
|
||||
self._stats_hit_shapes.most_common(12),
|
||||
self._stats_fallback_shapes.most_common(12),
|
||||
available_shapes: Collection[tuple[int, int]],
|
||||
) -> tuple[int, int] | None:
|
||||
"""Select the lowest-cost captured shape that contains the request."""
|
||||
candidates = (
|
||||
(batch_size, size)
|
||||
for batch_size, size in available_shapes
|
||||
if batch_size >= actual_batch_size and size >= actual_size
|
||||
)
|
||||
return min(candidates, key=lambda shape: (shape[0] * shape[1], shape[0], shape[1]), default=None)
|
||||
|
||||
def warmup(
|
||||
self,
|
||||
@@ -486,68 +431,42 @@ class CUDAGraphDecoderWrapper:
|
||||
# outside the startup capture window, so normal inference still hits
|
||||
# the graph fast path.
|
||||
if torch.cuda.is_current_stream_capturing():
|
||||
self._record_decode_stats(
|
||||
hit=False,
|
||||
batch_size=int(codes.shape[0]),
|
||||
actual_size=int(codes.shape[-1]),
|
||||
padded_size=None,
|
||||
stream_capture=True,
|
||||
)
|
||||
return self.decoder(codes)
|
||||
|
||||
batch_size = int(codes.shape[0])
|
||||
actual_size = int(codes.shape[-1])
|
||||
padded_size = self._get_padded_size(actual_size)
|
||||
compile_key = (batch_size, actual_size)
|
||||
if compile_key not in self._compiled_shapes and padded_size is not None:
|
||||
compile_key = (batch_size, padded_size)
|
||||
if compile_key in self._compiled_shapes:
|
||||
compile_key = self._select_replay_shape(batch_size, actual_size, self._compiled_shapes)
|
||||
if compile_key is not None:
|
||||
compiled_size = compile_key[1]
|
||||
self._record_decode_stats(
|
||||
hit=True,
|
||||
batch_size=batch_size,
|
||||
actual_size=actual_size,
|
||||
padded_size=compiled_size,
|
||||
compiled=True,
|
||||
)
|
||||
static_input = self._compiled_static_inputs[compile_key]
|
||||
if actual_size == compiled_size:
|
||||
if actual_size == compiled_size and batch_size == compile_key[0]:
|
||||
static_input.copy_(codes)
|
||||
else:
|
||||
static_input.zero_()
|
||||
static_input[:, :, :actual_size] = codes
|
||||
static_input[:batch_size, :, :actual_size] = codes
|
||||
self._compiled_graphs[compile_key].replay()
|
||||
output = self._trim_replay_output(self._compiled_static_outputs[compile_key], actual_size, compiled_size)
|
||||
output = output[:batch_size]
|
||||
if clone_graph_output:
|
||||
return output.clone()
|
||||
return output
|
||||
|
||||
graph_key = (batch_size, padded_size) if padded_size is not None else None
|
||||
graph_key = self._select_replay_shape(batch_size, actual_size, self.graphs)
|
||||
|
||||
if graph_key is None or graph_key not in self.graphs:
|
||||
self._record_decode_stats(
|
||||
hit=False,
|
||||
batch_size=batch_size,
|
||||
actual_size=actual_size,
|
||||
padded_size=padded_size,
|
||||
)
|
||||
if graph_key is None:
|
||||
return self.decoder(codes)
|
||||
|
||||
self._record_decode_stats(
|
||||
hit=True,
|
||||
batch_size=batch_size,
|
||||
actual_size=actual_size,
|
||||
padded_size=padded_size,
|
||||
)
|
||||
static_input = self.static_inputs[graph_key]
|
||||
if actual_size == padded_size:
|
||||
graph_size = graph_key[1]
|
||||
if actual_size == graph_size and batch_size == graph_key[0]:
|
||||
static_input.copy_(codes)
|
||||
else:
|
||||
static_input.zero_()
|
||||
static_input[:, :, :actual_size] = codes
|
||||
static_input[:batch_size, :, :actual_size] = codes
|
||||
self.graphs[graph_key].replay()
|
||||
|
||||
output = self._trim_replay_output(self.static_outputs[graph_key], actual_size, padded_size)
|
||||
output = self._trim_replay_output(self.static_outputs[graph_key], actual_size, graph_size)
|
||||
output = output[:batch_size]
|
||||
if clone_graph_output:
|
||||
return output.clone()
|
||||
return output
|
||||
|
||||
@@ -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
|
||||
"""Qwen3-TTS pipeline: Talker (text → RVQ codec) → Code2Wav (codec → audio).
|
||||
|
||||
Chunked vs end-to-end mode is dispatched from ``deploy.async_chunk``.
|
||||
@@ -41,6 +41,7 @@ QWEN3_TTS_PIPELINE = PipelineConfig(
|
||||
owns_tokenizer=True,
|
||||
engine_output_type="latent",
|
||||
async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2code2wav_async_chunk"),
|
||||
supports_native_mrv2_data_plane=True,
|
||||
custom_process_next_stage_input_func=f"{_PROC}.talker2code2wav_full_payload",
|
||||
sampling_constraints={
|
||||
"detokenize": False,
|
||||
@@ -56,6 +57,7 @@ QWEN3_TTS_PIPELINE = PipelineConfig(
|
||||
final_output_type="audio",
|
||||
engine_output_type="audio",
|
||||
model_arch="Qwen3TTSCode2Wav",
|
||||
supports_native_mrv2_data_plane=True,
|
||||
# ``sync_process_input_func`` is the only input-proc override for
|
||||
# this stage in sync (non-async-chunk) mode: a length-only
|
||||
# ``_token_only`` placeholder. The bulk codec payload itself
|
||||
|
||||
@@ -198,10 +198,6 @@ def mel_spectrogram(
|
||||
hann_window: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Mel spectrogram via torch STFT and a (cached) mel filterbank."""
|
||||
if torch.min(y) < -1.0:
|
||||
logger.warning("Min value of input waveform signal is %s", torch.min(y))
|
||||
if torch.max(y) > 1.0:
|
||||
logger.warning("Max value of input waveform signal is %s", torch.max(y))
|
||||
device = y.device
|
||||
if mel_basis is None:
|
||||
mel_basis = _cached_mel_filter_bank(sampling_rate, n_fft, num_mels, fmin, fmax).to(device)
|
||||
@@ -350,7 +346,7 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
tts_pad_embed: torch.Tensor,
|
||||
encode_ref_audio_batch: Callable[..., list[torch.Tensor]],
|
||||
speaker_cache: Any | None = None,
|
||||
ref_audio_artifact_cache_max_entries: int = 256,
|
||||
ref_audio_artifact_cache_max_entries: int = 1024,
|
||||
):
|
||||
self._config = config
|
||||
self._talker_config = talker_config
|
||||
@@ -366,9 +362,14 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
self._embedding_dtype = torch.bfloat16
|
||||
|
||||
self._text_tokenizer: Any | None = None
|
||||
# Projected special-token embeddings are immutable model constants.
|
||||
# Reusing them avoids launching the embedding/projection pair once per
|
||||
# request during prompt construction.
|
||||
self._projected_token_cache: dict[tuple[str, tuple[int, ...]], torch.Tensor] = {}
|
||||
|
||||
self._ref_audio_artifact_cache_max_entries = int(ref_audio_artifact_cache_max_entries)
|
||||
self._ref_audio_artifact_cache: OrderedDict[str, dict[str, torch.Tensor | bool]] = OrderedDict()
|
||||
self._long_tensor_cache: dict[tuple[str, tuple[int, ...]], torch.Tensor] = {}
|
||||
|
||||
# Bounded LRU; caller-supplied orig_sr can otherwise grow this without limit.
|
||||
self._resampler_cache: OrderedDict[tuple[int, int], AudioResampler] = OrderedDict()
|
||||
@@ -384,6 +385,29 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
"""Return the pad-token embedding on the requested device/dtype, shape ``[1, 1, H]``."""
|
||||
return self._tts_pad_embed_buffer.to(device=device, dtype=dtype).reshape(1, 1, -1)
|
||||
|
||||
def _long_tensor(self, values: Sequence[int], device: torch.device) -> torch.Tensor:
|
||||
device = torch.device(device)
|
||||
if device.type == "cuda" and device.index is None:
|
||||
device = torch.device("cuda", torch.accelerator.current_device_index())
|
||||
key = (str(device), tuple(int(v) for v in values))
|
||||
cache = getattr(self, "_long_tensor_cache", None)
|
||||
if cache is None:
|
||||
cache = self._long_tensor_cache = {}
|
||||
cached = cache.get(key)
|
||||
if cached is None or cached.device != device:
|
||||
cached = torch.tensor([list(key[1])], device=device, dtype=torch.long)
|
||||
cache[key] = cached
|
||||
return cached
|
||||
|
||||
def _projected_tokens(self, values: Sequence[int], device: torch.device) -> torch.Tensor:
|
||||
device = torch.device(device)
|
||||
key = (str(device), tuple(int(v) for v in values))
|
||||
cached = self._projected_token_cache.get(key)
|
||||
if cached is None:
|
||||
cached = self._text_projection(self._text_embedding(self._long_tensor(values, device)))
|
||||
self._projected_token_cache[key] = cached
|
||||
return cached
|
||||
|
||||
def _get_resampler(self, orig_sr: int, target_sr: int) -> AudioResampler:
|
||||
key = (int(orig_sr), int(target_sr))
|
||||
cache = self._resampler_cache
|
||||
@@ -631,9 +655,9 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
if entry is None:
|
||||
entry = {}
|
||||
if isinstance(ref_code, torch.Tensor):
|
||||
entry["ref_code"] = ref_code.detach().to("cpu", dtype=torch.long).contiguous()
|
||||
entry["ref_code"] = ref_code.detach().to(dtype=torch.long).contiguous()
|
||||
if isinstance(ref_spk_embedding, torch.Tensor):
|
||||
entry["ref_spk_embedding"] = ref_spk_embedding.detach().to("cpu", dtype=self._embedding_dtype).reshape(-1)
|
||||
entry["ref_spk_embedding"] = ref_spk_embedding.detach().to(dtype=self._embedding_dtype).reshape(-1)
|
||||
if not entry:
|
||||
return
|
||||
self._ref_audio_artifact_cache[cache_key] = entry
|
||||
@@ -659,7 +683,6 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
self._speaker_encoder.to(device=dev, dtype=dtype)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
# Resample to 24kHz for speaker encoder.
|
||||
target_sr = int(getattr(self._config.speaker_encoder_config, "sample_rate", 24000))
|
||||
if sr != target_sr:
|
||||
@@ -761,6 +784,23 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
req_ids: list[str],
|
||||
model_intermediate_buffer: dict[str, dict[str, Any]],
|
||||
device: torch.device,
|
||||
) -> None:
|
||||
"""MRv1 adapter for the request-info batch implementation."""
|
||||
self.preprocess_infos_batch(
|
||||
req_infos=[
|
||||
info_dict
|
||||
for req_id in req_ids
|
||||
if isinstance((info_dict := model_intermediate_buffer.get(req_id)), dict)
|
||||
],
|
||||
device=device,
|
||||
)
|
||||
|
||||
@torch.inference_mode()
|
||||
def preprocess_infos_batch(
|
||||
self,
|
||||
*,
|
||||
req_infos: list[dict[str, Any]],
|
||||
device: torch.device,
|
||||
) -> None:
|
||||
"""Batch Base voice-clone ref-audio codec extraction for current prefill requests.
|
||||
|
||||
@@ -775,11 +815,7 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
pending_text: list[tuple[dict[str, Any], str]] = []
|
||||
pending_ref_text: list[tuple[dict[str, Any], str]] = []
|
||||
groups: dict[int, list[tuple[dict[str, Any], np.ndarray, int]]] = {}
|
||||
for req_id in req_ids:
|
||||
info_dict = model_intermediate_buffer.get(req_id)
|
||||
if not isinstance(info_dict, dict):
|
||||
continue
|
||||
|
||||
for info_dict in req_infos:
|
||||
if (
|
||||
self._needs_initial_prompt_preprocess(info_dict)
|
||||
and first_value(info_dict.get("task_type"), "CustomVoice") == "Base"
|
||||
@@ -837,17 +873,31 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
except Exception as exc:
|
||||
logger.debug("Qwen3-TTS batched text tokenization failed; falling back to serial path: %s", exc)
|
||||
continue
|
||||
input_ids = tokenized.get("input_ids") if isinstance(tokenized, dict) else None
|
||||
input_ids = tokenized.get("input_ids") if isinstance(tokenized, Mapping) else None
|
||||
if not isinstance(input_ids, list) or len(input_ids) != len(items):
|
||||
continue
|
||||
valid_items: list[tuple[dict[str, Any], int]] = []
|
||||
flat_ids: list[int] = []
|
||||
for (info_dict, _), ids in zip(items, input_ids, strict=True):
|
||||
if isinstance(ids, list) and ids:
|
||||
info_dict[key] = torch.tensor(ids, dtype=torch.long)
|
||||
if not (
|
||||
isinstance(ids, list)
|
||||
and ids
|
||||
and all(isinstance(token_id, (int, np.integer)) for token_id in ids)
|
||||
):
|
||||
continue
|
||||
valid_items.append((info_dict, len(ids)))
|
||||
flat_ids.extend(int(token_id) for token_id in ids)
|
||||
if not valid_items:
|
||||
continue
|
||||
use_async_h2d = device.type == "cuda"
|
||||
packed_ids_cpu = torch.tensor(flat_ids, dtype=torch.long, pin_memory=use_async_h2d)
|
||||
packed_ids = packed_ids_cpu.to(device=device, non_blocking=use_async_h2d)
|
||||
offset = 0
|
||||
for info_dict, num_ids in valid_items:
|
||||
info_dict[key] = packed_ids.narrow(0, offset, num_ids)
|
||||
offset += num_ids
|
||||
|
||||
groups = {sr: items for sr, items in groups.items() if len(items) >= 2}
|
||||
if not groups:
|
||||
return
|
||||
|
||||
for sr, items in groups.items():
|
||||
wavs = [wav for _, wav, _ in items]
|
||||
try:
|
||||
@@ -892,9 +942,7 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
codec_embed_sum = torch.cat(codec_embed, dim=1).sum(1).unsqueeze(0) # [1,T,H]
|
||||
codec_embed_sum = torch.cat(
|
||||
[
|
||||
self._codec_embed(
|
||||
torch.tensor([[self._talker_config.codec_bos_id]], device=codec_embed_sum.device, dtype=torch.long)
|
||||
),
|
||||
self._codec_embed(self._long_tensor([self._talker_config.codec_bos_id], codec_embed_sum.device)),
|
||||
codec_embed_sum,
|
||||
],
|
||||
dim=1,
|
||||
@@ -990,12 +1038,8 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
# tts special token embeds (projected into talker hidden).
|
||||
# ``tts_pad_embed`` is precomputed (request-independent), so we only
|
||||
# need bos/eos here.
|
||||
tts_tokens = torch.tensor(
|
||||
[[config.tts_bos_token_id, config.tts_eos_token_id]],
|
||||
device=input_ids.device,
|
||||
dtype=input_ids.dtype,
|
||||
)
|
||||
tts_bos_embed, tts_eos_embed = text_projection(text_embedding(tts_tokens)).chunk(2, dim=1)
|
||||
projected_tts = self._projected_tokens([config.tts_bos_token_id, config.tts_eos_token_id], input_ids.device)
|
||||
tts_bos_embed, tts_eos_embed = projected_tts.chunk(2, dim=1)
|
||||
tts_pad_embed = self._pad_embed(input_ids.device, tts_bos_embed.dtype)
|
||||
|
||||
# Codec prefill tags.
|
||||
@@ -1032,9 +1076,9 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
]
|
||||
]
|
||||
|
||||
codec_input_0 = codec_embed(torch.tensor(codec_prefill_list, device=input_ids.device, dtype=torch.long))
|
||||
codec_input_0 = codec_embed(self._long_tensor(codec_prefill_list[0], input_ids.device))
|
||||
codec_input_1 = codec_embed(
|
||||
torch.tensor([[talker_config.codec_pad_id, talker_config.codec_bos_id]], device=input_ids.device)
|
||||
self._long_tensor([talker_config.codec_pad_id, talker_config.codec_bos_id], input_ids.device)
|
||||
)
|
||||
|
||||
# Speaker embedding/token (task-dependent)
|
||||
@@ -1339,7 +1383,7 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
talker_prompt,
|
||||
text_all + codec_embed(pad_ids),
|
||||
tts_pad_embed
|
||||
+ codec_embed(torch.tensor([[talker_config.codec_bos_id]], device=input_ids.device)),
|
||||
+ codec_embed(self._long_tensor([talker_config.codec_bos_id], input_ids.device)),
|
||||
],
|
||||
dim=1,
|
||||
)
|
||||
@@ -1392,8 +1436,7 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
[
|
||||
talker_prompt,
|
||||
text_all + codec_embed(pad_ids),
|
||||
tts_pad_embed
|
||||
+ codec_embed(torch.tensor([[talker_config.codec_bos_id]], device=input_ids.device)),
|
||||
tts_pad_embed + codec_embed(self._long_tensor([talker_config.codec_bos_id], input_ids.device)),
|
||||
],
|
||||
dim=1,
|
||||
)
|
||||
@@ -1431,8 +1474,7 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
[
|
||||
talker_prompt,
|
||||
text_all + codec_embed(pad_ids),
|
||||
tts_pad_embed
|
||||
+ codec_embed(torch.tensor([[talker_config.codec_bos_id]], device=input_ids.device)),
|
||||
tts_pad_embed + codec_embed(self._long_tensor([talker_config.codec_bos_id], input_ids.device)),
|
||||
],
|
||||
dim=1,
|
||||
)
|
||||
|
||||
@@ -46,9 +46,9 @@ def _codec_ids_from_payload_or_input(
|
||||
if isinstance(codes, dict):
|
||||
audio = codes.get("audio")
|
||||
if isinstance(audio, torch.Tensor) and audio.numel() > 0:
|
||||
return audio.reshape(-1).to(device=input_ids.device, dtype=torch.long)
|
||||
return audio.reshape(-1).to(dtype=torch.long)
|
||||
if isinstance(audio, (list, tuple)) and audio:
|
||||
return torch.as_tensor(audio, device=input_ids.device, dtype=torch.long).reshape(-1)
|
||||
return torch.as_tensor(audio, dtype=torch.long).reshape(-1)
|
||||
return input_ids.reshape(-1).to(dtype=torch.long)
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ class Qwen3TTSCode2Wav(nn.Module):
|
||||
# decoder caches must use the same IDs delivered by on_requests_finished;
|
||||
# payload metadata carries an external ID which may differ.
|
||||
requires_request_ids = True
|
||||
# A nonempty native codes.audio payload is authoritative; token IDs may
|
||||
# serve only as per-request control slots for the generation scheduler.
|
||||
supports_native_payload_input = True
|
||||
batched_gpu_staging_keys = {("codes", "audio")}
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
@@ -427,12 +431,9 @@ class Qwen3TTSCode2Wav(nn.Module):
|
||||
try:
|
||||
_, c = valid_codes_qf[0]
|
||||
logger.info(
|
||||
"Code2Wav codec: frames=%d q=%d uniq=%d range=[%d,%d] batch=%d",
|
||||
"Code2Wav codec: frames=%d q=%d batch=%d",
|
||||
c.shape[1],
|
||||
q,
|
||||
int(torch.unique(c).numel()),
|
||||
int(c.min().item()),
|
||||
int(c.max().item()),
|
||||
len(valid_codes_qf),
|
||||
)
|
||||
except Exception:
|
||||
@@ -454,9 +455,22 @@ class Qwen3TTSCode2Wav(nn.Module):
|
||||
|
||||
request_lengths = [int(codes_qf.shape[-1]) for _, codes_qf in valid_codes_qf]
|
||||
max_request_length = max(request_lengths)
|
||||
request_codes = valid_codes_qf[0][1].new_zeros((len(valid_codes_qf), q, max_request_length))
|
||||
for row, (_, codes_qf) in enumerate(valid_codes_qf):
|
||||
request_codes[row, :, : codes_qf.shape[-1]].copy_(codes_qf)
|
||||
request_codes_shape = (len(valid_codes_qf), q, max_request_length)
|
||||
target_device = ids.device
|
||||
if target_device.type == "cuda" and all(codes_qf.device.type == "cpu" for _, codes_qf in valid_codes_qf):
|
||||
staged_codes = torch.zeros(
|
||||
request_codes_shape,
|
||||
dtype=torch.long,
|
||||
device="cpu",
|
||||
pin_memory=True,
|
||||
)
|
||||
for row, (_, codes_qf) in enumerate(valid_codes_qf):
|
||||
staged_codes[row, :, : codes_qf.shape[-1]].copy_(codes_qf)
|
||||
request_codes = staged_codes.to(device=target_device, non_blocking=True)
|
||||
else:
|
||||
request_codes = torch.zeros(request_codes_shape, dtype=torch.long, device=target_device)
|
||||
for row, (_, codes_qf) in enumerate(valid_codes_qf):
|
||||
request_codes[row, :, : codes_qf.shape[-1]].copy_(codes_qf)
|
||||
|
||||
self._record_decode_batch_stats(
|
||||
group_size=len(valid_codes_qf),
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
@@ -25,13 +26,14 @@ from vllm.multimodal.audio import AudioResampler
|
||||
from vllm.sequence import IntermediateTensors
|
||||
|
||||
from vllm_omni.data_entry_keys import OmniPayload
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput, OwnedBatchTensor
|
||||
from vllm_omni.utils.speaker_cache import (
|
||||
get_speaker_cache,
|
||||
iter_custom_voice_profiles,
|
||||
load_validated_profile_tensors,
|
||||
validate_qwen3_tts_profile,
|
||||
)
|
||||
from vllm_omni.worker.sampling_utils import get_tts_local_seed
|
||||
|
||||
from .configuration_qwen3_tts import Qwen3TTSConfig, Qwen3TTSSpeakerEncoderConfig, Qwen3TTSTalkerConfig
|
||||
from .prompt_embeds_builder import PRECOMPUTED_TEXT_IDS_KEY, Qwen3TTSPromptEmbedsBuilder, resolve_x_vector_only
|
||||
@@ -42,6 +44,80 @@ from .tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import Qwen3TTSTokenizerV2E
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_TRAILING_TEXT_COMPACT_MIN_FRAMES = 64
|
||||
_ASYNC_REF_CODES_PUBLISHED_KEY = "_qwen3_tts_ref_codes_published"
|
||||
|
||||
|
||||
def _qwen3_tts_gpu_resident_buffer_keys(
|
||||
use_v2_model_runner: bool,
|
||||
) -> set[tuple[str, str]]:
|
||||
keys = {
|
||||
("codes", "audio"),
|
||||
("hidden_states", "last"),
|
||||
("hidden_states", "trailing_text"),
|
||||
}
|
||||
if use_v2_model_runner:
|
||||
keys.update(
|
||||
{
|
||||
("codes", "ref"),
|
||||
("embed", "prefill"),
|
||||
("meta", "codec_frame_valid"),
|
||||
}
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
def _materialize_span_scalars(
|
||||
values: Sequence[int],
|
||||
span_lengths: Sequence[int],
|
||||
*,
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor | None:
|
||||
"""Expand a uniform scalar cohort with one GPU allocation."""
|
||||
if len(values) != len(span_lengths):
|
||||
raise ValueError(
|
||||
f"values and span_lengths must have the same length, got {len(values)} and {len(span_lengths)}"
|
||||
)
|
||||
total = sum(int(length) for length in span_lengths)
|
||||
if not values:
|
||||
return torch.empty((0,), dtype=dtype, device=device)
|
||||
first = int(values[0])
|
||||
if not all(int(value) == first for value in values[1:]):
|
||||
return None
|
||||
return torch.full((total,), first, dtype=dtype, device=device)
|
||||
|
||||
|
||||
def _should_publish_async_ref_codes(info: dict[str, Any]) -> bool:
|
||||
"""Publish ref codes exactly once, including after a KV-resumed prefill."""
|
||||
meta = info.get("meta")
|
||||
return not (isinstance(meta, dict) and bool(meta.get(_ASYNC_REF_CODES_PUBLISHED_KEY)))
|
||||
|
||||
|
||||
def _mark_async_ref_codes_published(info: dict[str, Any]) -> None:
|
||||
meta = info.setdefault("meta", {})
|
||||
if not isinstance(meta, dict):
|
||||
raise TypeError("Qwen3-TTS payload meta must be a dict")
|
||||
meta[_ASYNC_REF_CODES_PUBLISHED_KEY] = True
|
||||
|
||||
|
||||
def _ref_audio_artifact_cache_capacity(vllm_config: VllmConfig) -> int:
|
||||
model_cfg = getattr(vllm_config, "model_config", None)
|
||||
connector_cfg = getattr(model_cfg, "stage_connector_config", None)
|
||||
connector_extra = (
|
||||
connector_cfg.get("extra", connector_cfg)
|
||||
if isinstance(connector_cfg, dict)
|
||||
else getattr(connector_cfg, "extra", None)
|
||||
)
|
||||
if not isinstance(connector_extra, dict):
|
||||
connector_extra = {}
|
||||
raw_capacity = connector_extra.get("ref_audio_artifact_cache_max_entries", 1024)
|
||||
try:
|
||||
capacity = int(raw_capacity)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid ref_audio_artifact_cache_max_entries={raw_capacity!r}") from exc
|
||||
if capacity < 0:
|
||||
raise ValueError(f"ref_audio_artifact_cache_max_entries must be non-negative, got {capacity}")
|
||||
return capacity
|
||||
|
||||
|
||||
def _has_tts_text_conditioning(info_dict: dict[str, Any], hidden_states: Any | None = None) -> bool:
|
||||
@@ -267,8 +343,7 @@ class Qwen3TTSSpeakerEncoder(torch.nn.Module):
|
||||
|
||||
|
||||
class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
"""vLLM-AR talker: step-wise layer-0 codec decoding.
|
||||
Predicts residual codebooks (1..Q-1) into `audio_codes` and streams text via `tailing_text_hidden`."""
|
||||
"""vLLM-AR talker with step-wise layer-0 codec decoding."""
|
||||
|
||||
hf_to_vllm_mapper = WeightsMapper(
|
||||
orig_to_new_prefix={
|
||||
@@ -329,29 +404,13 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
# per-request additional_information.
|
||||
self.talker_mtp_output_key = ("codes", "audio")
|
||||
self.talker_mtp_graph_safe = True
|
||||
# talker_mtp samples with per-row generators, so explicitly-seeded
|
||||
# requests stay batched instead of one scalar forward per row (#4883).
|
||||
# This is only valid while talker_mtp receives the *unpadded* active
|
||||
# batch, i.e. when it is NOT graph-wrapped. The runner wraps talker_mtp
|
||||
# (padded batches + a single captured RNG stream) whenever full
|
||||
# cudagraphs are enabled, so disable per-row generators in that case.
|
||||
# Consequence: under full cudagraphs, per-request ``tts_local_seed`` is
|
||||
# not reproducible (matching Qwen3-Omni, which has no per-row seeding);
|
||||
# eager mode keeps the per-row generator fast-path.
|
||||
# TODO(#4923): the model should not read ``cudagraph_mode`` — it is an
|
||||
# upstream (runner) concern, and the runner already re-derives the same
|
||||
# wrap decision in ``_init_talker_mtp``. Once all TTS models declare
|
||||
# ``talker_mtp_graph_safe`` we should move this gating into the model
|
||||
# runner (models only expose the static capability flag, and users can
|
||||
# then configure ``cudagraph_mode`` freely). This also means mtp seeding
|
||||
# is silently dropped when the decode batch > 1 under full cudagraphs;
|
||||
# the follow-up should refactor the per-row generator in the runner to
|
||||
# be cudagraph-safe so seeded batches stay reproducible.
|
||||
cudagraph_mode = getattr(vllm_config.compilation_config, "cudagraph_mode", None)
|
||||
talker_mtp_graph_wrapped = (
|
||||
self.talker_mtp_graph_safe and cudagraph_mode is not None and cudagraph_mode.has_full_cudagraphs()
|
||||
)
|
||||
self.talker_mtp_accepts_per_row_generators = not talker_mtp_graph_wrapped
|
||||
# The runners bypass only the outer whole-MTP graph when explicit
|
||||
# generators are present, so seeded requests can still share one raw
|
||||
# batched MTP call with independent per-row streams.
|
||||
self.talker_mtp_accepts_per_row_generators = True
|
||||
self.mtp_sample_uniforms = True
|
||||
self.mtp_sample_steps = max(0, int(self.talker_config.num_code_groups) - 1)
|
||||
self.mtp_sample_vocab_size = self._codebook_vocab_size
|
||||
self.use_async_omni_output = True
|
||||
self.eager_omni_postprocess_before_async_output = True
|
||||
self.omni_pooler_payload_include_hidden = False
|
||||
@@ -445,14 +504,24 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
# the same req_id ordering that indexes sampling_metadata.
|
||||
self._req_x_vector_only: dict[str, bool | None] = {}
|
||||
self._batch_req_ids: list[str] = []
|
||||
self._mrv2_silence_ban_mask: torch.Tensor | None = None
|
||||
|
||||
# Keys that should stay on GPU in model_intermediate_buffer to avoid
|
||||
# CPU-to-GPU round-trips on every decode step.
|
||||
self.gpu_resident_buffer_keys: set[tuple[str, str]] = {
|
||||
("codes", "audio"),
|
||||
("hidden_states", "last"),
|
||||
("hidden_states", "trailing_text"),
|
||||
}
|
||||
self._use_v2_model_runner = bool(getattr(vllm_config.model_config, "use_v2_model_runner", False))
|
||||
self.gpu_resident_buffer_keys = _qwen3_tts_gpu_resident_buffer_keys(self._use_v2_model_runner)
|
||||
|
||||
if self._use_v2_model_runner:
|
||||
# Align predictor warmup/capture buckets with the outer MRv2 MTP
|
||||
# graph buckets so the compile cache covers every reachable size.
|
||||
outer_max_bsz = int(vllm_config.scheduler_config.max_num_seqs)
|
||||
outer_buckets = {
|
||||
int(size)
|
||||
for size in vllm_config.compilation_config.cudagraph_capture_sizes
|
||||
if 0 < int(size) <= outer_max_bsz
|
||||
}
|
||||
outer_buckets.update({1, outer_max_bsz}) # eager fallback rows
|
||||
self.code_predictor.configure_mtp_execution_buckets(sorted(outer_buckets))
|
||||
|
||||
# ``text_proj(text_emb(tts_pad_token_id))`` is request-independent —
|
||||
# it depends only on frozen ``text_embedding`` / ``text_projection``
|
||||
@@ -492,6 +561,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
self._subtalker_sampling_params: dict[str, Any] = (
|
||||
dict(raw_subtalker_sampling) if isinstance(raw_subtalker_sampling, Mapping) else {}
|
||||
)
|
||||
self.mtp_sampling_params = self._subtalker_sampling_params
|
||||
|
||||
self._stacked_codec_embed: torch.Tensor | None = None
|
||||
|
||||
@@ -514,6 +584,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
tts_pad_embed=self._tts_pad_embed,
|
||||
encode_ref_audio_batch=self._encode_ref_audio_batch,
|
||||
speaker_cache=self._speaker_cache,
|
||||
ref_audio_artifact_cache_max_entries=_ref_audio_artifact_cache_capacity(vllm_config),
|
||||
)
|
||||
self._load_custom_voice_profiles()
|
||||
|
||||
@@ -592,6 +663,10 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
# Suppress silence-region tokens for the first N decode frames (#4966). The
|
||||
# per-request decode step is the length of its generated-token history.
|
||||
ban_n = self._silence_ban_frames
|
||||
if ban_n > 0 and sampling_metadata is None and self._mrv2_silence_ban_mask is not None:
|
||||
if self._mrv2_silence_ban_mask.shape[0] != logits.shape[0]:
|
||||
raise ValueError("MRV2 silence-ban batch must match the sampled logits rows")
|
||||
return logits.masked_fill(self._mrv2_silence_ban_mask & self._silence_mask, float("-inf"))
|
||||
if ban_n > 0 and sampling_metadata is not None:
|
||||
output_token_ids = getattr(sampling_metadata, "output_token_ids", None)
|
||||
if output_token_ids is None or len(output_token_ids) != logits.shape[0]:
|
||||
@@ -645,11 +720,17 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
info_dicts = kwargs.get("runtime_additional_information") or []
|
||||
if "runtime_additional_information" in kwargs and "model_intermediate_buffer" not in kwargs:
|
||||
logger.warning_once("runtime_additional_information is deprecated, use model_intermediate_buffer")
|
||||
model_config = getattr(getattr(self, "vllm_config", None), "model_config", None)
|
||||
async_chunk = bool(getattr(model_config, "async_chunk", False))
|
||||
audio_codes_list: list[torch.Tensor] = []
|
||||
ref_code_len_list: list[torch.Tensor] = []
|
||||
ref_code_len_segments: list[tuple[int | torch.Tensor, int, torch.device]] = []
|
||||
ref_code_list: list[torch.Tensor] = []
|
||||
has_ref_code = False
|
||||
codec_streaming_list: list[torch.Tensor] = []
|
||||
codec_streaming_values: list[int] = []
|
||||
codec_streaming_span_lengths: list[int] = []
|
||||
codec_streaming_device: torch.device | None = None
|
||||
codec_frame_valid_device: torch.device | None = None
|
||||
codec_frame_valid_parts: list[torch.Tensor] = []
|
||||
for info in info_dicts:
|
||||
if not isinstance(info, dict):
|
||||
ref_code_list.append(torch.empty(0, dtype=torch.long))
|
||||
@@ -659,17 +740,46 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
ac = codes.get("audio")
|
||||
if isinstance(ac, torch.Tensor):
|
||||
audio_codes_list.append(ac)
|
||||
cs = meta.get("codec_streaming")
|
||||
if isinstance(cs, bool):
|
||||
codec_streaming_list.append(
|
||||
torch.full((int(ac.shape[0]),), int(cs), dtype=torch.int8, device=ac.device)
|
||||
frame_valid = meta.get("codec_frame_valid")
|
||||
if isinstance(frame_valid, bool):
|
||||
codec_frame_valid_device = ac.device
|
||||
codec_frame_valid_parts.append(
|
||||
torch.full(
|
||||
(int(ac.shape[0]),),
|
||||
int(frame_valid),
|
||||
dtype=torch.int8,
|
||||
device=ac.device,
|
||||
)
|
||||
)
|
||||
elif isinstance(frame_valid, torch.Tensor):
|
||||
if frame_valid.numel() != 1:
|
||||
raise ValueError("codec_frame_valid must be scalar per request")
|
||||
codec_frame_valid_device = ac.device
|
||||
codec_frame_valid_parts.append(
|
||||
frame_valid.to(device=ac.device, dtype=torch.int8, non_blocking=True)
|
||||
.reshape(1)
|
||||
.expand(int(ac.shape[0]))
|
||||
)
|
||||
cs = meta.get("codec_streaming")
|
||||
if not async_chunk and isinstance(cs, bool):
|
||||
codec_streaming_values.append(int(cs))
|
||||
codec_streaming_span_lengths.append(int(ac.shape[0]))
|
||||
codec_streaming_device = ac.device
|
||||
ref_code = codes.get("ref")
|
||||
if isinstance(ref_code, torch.Tensor) and ref_code.numel() > 0:
|
||||
publish_ref_code = not async_chunk or _should_publish_async_ref_codes(info)
|
||||
if publish_ref_code and isinstance(ref_code, torch.Tensor) and ref_code.numel() > 0:
|
||||
ref_code_list.append(ref_code)
|
||||
has_ref_code = True
|
||||
if async_chunk:
|
||||
_mark_async_ref_codes_published(info)
|
||||
else:
|
||||
ref_code_list.append(torch.empty(0, dtype=torch.long))
|
||||
if async_chunk:
|
||||
# The streaming consumer uses codes.ref.shape[0] directly and
|
||||
# does not read ref_code_len or codec_streaming. Avoid creating
|
||||
# GPU control tensors that would only be snapshotted and copied
|
||||
# back to the CPU on every decode step.
|
||||
continue
|
||||
ref_len = meta.get("ref_code_len")
|
||||
if ref_len is None:
|
||||
continue
|
||||
@@ -680,7 +790,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
if ref_len.numel() == 0:
|
||||
raise ValueError("ref_code_len is an empty tensor")
|
||||
ref_len_tail = ref_len.reshape(-1)[-1:].to(dtype=torch.int32, device=ac.device)
|
||||
ref_code_len_list.append(ref_len_tail.expand(span_len).contiguous())
|
||||
ref_code_len_segments.append((ref_len_tail, span_len, ac.device))
|
||||
continue
|
||||
if isinstance(ref_len, list):
|
||||
if len(ref_len) != 1:
|
||||
@@ -688,7 +798,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
ref_len_val = int(ref_len[0])
|
||||
else:
|
||||
ref_len_val = int(ref_len)
|
||||
ref_code_len_list.append(torch.full((span_len,), ref_len_val, dtype=torch.int32, device=ac.device))
|
||||
ref_code_len_segments.append((ref_len_val, span_len, ac.device))
|
||||
|
||||
if not audio_codes_list:
|
||||
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
|
||||
@@ -696,15 +806,61 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
audio_codes = torch.cat(audio_codes_list, dim=0)
|
||||
span_len = int(audio_codes.shape[0])
|
||||
mm: OmniPayload = {"codes": {"audio": audio_codes}}
|
||||
if ref_code_len_list:
|
||||
mm.setdefault("meta", {})["ref_code_len"] = torch.cat(ref_code_len_list, dim=0)[:span_len]
|
||||
if ref_code_len_segments:
|
||||
if all(isinstance(value, int) for value, _length, _device in ref_code_len_segments):
|
||||
ref_code_len = _materialize_span_scalars(
|
||||
[int(value) for value, _length, _device in ref_code_len_segments],
|
||||
[length for _value, length, _device in ref_code_len_segments],
|
||||
dtype=torch.int32,
|
||||
device=ref_code_len_segments[0][2],
|
||||
)
|
||||
if ref_code_len is None:
|
||||
ref_code_len = torch.cat(
|
||||
[
|
||||
torch.full((length,), int(value), dtype=torch.int32, device=device)
|
||||
for value, length, device in ref_code_len_segments
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
else:
|
||||
ref_code_len_parts = [
|
||||
value.expand(length)
|
||||
if isinstance(value, torch.Tensor)
|
||||
else torch.full((length,), value, dtype=torch.int32, device=device)
|
||||
for value, length, device in ref_code_len_segments
|
||||
]
|
||||
ref_code_len = torch.cat(ref_code_len_parts, dim=0)
|
||||
mm.setdefault("meta", {})["ref_code_len"] = ref_code_len[:span_len]
|
||||
if has_ref_code:
|
||||
# Batch-aligned, one entry per request: ``to_payload_element``
|
||||
# indexes ``element[idx]`` per request, and a shorter list would
|
||||
# silently broadcast one request's reference codes to the others.
|
||||
mm.setdefault("codes", {})["ref"] = ref_code_list
|
||||
if codec_streaming_list:
|
||||
mm.setdefault("meta", {})["codec_streaming"] = torch.cat(codec_streaming_list, dim=0)[:span_len]
|
||||
if codec_streaming_values:
|
||||
assert codec_streaming_device is not None
|
||||
codec_streaming = _materialize_span_scalars(
|
||||
codec_streaming_values,
|
||||
codec_streaming_span_lengths,
|
||||
dtype=torch.int8,
|
||||
device=codec_streaming_device,
|
||||
)
|
||||
if codec_streaming is None:
|
||||
codec_streaming = torch.cat(
|
||||
[
|
||||
torch.full((length,), value, dtype=torch.int8, device=codec_streaming_device)
|
||||
for value, length in zip(
|
||||
codec_streaming_values,
|
||||
codec_streaming_span_lengths,
|
||||
strict=True,
|
||||
)
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
mm.setdefault("meta", {})["codec_streaming"] = codec_streaming[:span_len]
|
||||
if codec_frame_valid_parts:
|
||||
assert codec_frame_valid_device is not None
|
||||
codec_frame_valid = torch.cat(codec_frame_valid_parts, dim=0)
|
||||
mm.setdefault("meta", {})["codec_frame_valid"] = codec_frame_valid[:span_len]
|
||||
return OmniOutput(text_hidden_states=hidden, multimodal_outputs=mm)
|
||||
|
||||
# -------------------- preprocess / postprocess --------------------
|
||||
@@ -763,27 +919,32 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
|
||||
if is_prefill:
|
||||
# Prefill (prompt embeddings)
|
||||
prompt_embeds_cpu = embed.get("prefill")
|
||||
# First prefill round: prompt_embeds_cpu is not yet populated.
|
||||
# Subsequent prefill rounds (multi-chunk): prompt_embeds_cpu is a Tensor stored by the first round.
|
||||
is_first_prefill = not isinstance(prompt_embeds_cpu, torch.Tensor) or prompt_embeds_cpu.ndim != 2
|
||||
prompt_embeds_buffer = embed.get("prefill")
|
||||
# First prefill round: the retained prompt buffer is not populated.
|
||||
# Subsequent chunks slice the buffer stored by the first round.
|
||||
is_first_prefill = not isinstance(prompt_embeds_buffer, torch.Tensor) or prompt_embeds_buffer.ndim != 2
|
||||
if is_first_prefill:
|
||||
full_prompt_embeds, tailing_text_hidden, ref_code_len, ref_code = (
|
||||
self._prompt_builder.build_prompt_embeds(task_type=task_type, info_dict=info_dict)
|
||||
)
|
||||
# Store full prompt embeddings on CPU (large, prefill-only).
|
||||
# tailing_text_hidden stays on GPU (gpu_resident_buffer_keys).
|
||||
prompt_embeds_cpu = full_prompt_embeds.detach().to("cpu").contiguous()
|
||||
prompt_embeds_buffer = full_prompt_embeds.detach()
|
||||
if not bool(getattr(self, "_use_v2_model_runner", False)):
|
||||
prompt_embeds_buffer = prompt_embeds_buffer.to("cpu")
|
||||
prompt_embeds_buffer = prompt_embeds_buffer.contiguous()
|
||||
info_update: OmniPayload = {
|
||||
"embed": {"prefill": prompt_embeds_cpu},
|
||||
"embed": {"prefill": prompt_embeds_buffer},
|
||||
"hidden_states": {"trailing_text": tailing_text_hidden.detach()},
|
||||
"meta": {
|
||||
"talker_text_offset": 0,
|
||||
"codec_streaming": codec_streaming,
|
||||
"codec_frame_valid": torch.zeros((), dtype=torch.bool, device=input_ids.device),
|
||||
},
|
||||
}
|
||||
if isinstance(ref_code, torch.Tensor) and ref_code.numel() > 0:
|
||||
info_update.setdefault("codes", {})["ref"] = ref_code.detach().to("cpu").contiguous()
|
||||
ref_code_buffer = ref_code.detach()
|
||||
if not bool(getattr(self, "_use_v2_model_runner", False)):
|
||||
ref_code_buffer = ref_code_buffer.to("cpu")
|
||||
info_update.setdefault("codes", {})["ref"] = ref_code_buffer.contiguous()
|
||||
if ref_code_len is not None:
|
||||
info_update["meta"]["ref_code_len"] = int(ref_code_len)
|
||||
# First prefill: source the slice offset from `_omni_num_computed_tokens`
|
||||
@@ -794,16 +955,21 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
else:
|
||||
# Subsequent prefill chunk: slice from stored embeddings at running offset.
|
||||
offset = max(0, int(meta.get("talker_prefill_offset", 0) or 0))
|
||||
info_update = {"meta": {"codec_streaming": codec_streaming}}
|
||||
info_update = {
|
||||
"meta": {
|
||||
"codec_streaming": codec_streaming,
|
||||
"codec_frame_valid": torch.zeros((), dtype=torch.bool, device=input_ids.device),
|
||||
}
|
||||
}
|
||||
|
||||
# Always return a span_len slice; if the scheduled placeholder is longer than what
|
||||
# the prompt actually fills, pad with tts_pad_embed (preserves placeholder/embedding alignment).
|
||||
s = max(0, min(offset, int(prompt_embeds_cpu.shape[0])))
|
||||
e = max(0, min(offset + span_len, int(prompt_embeds_cpu.shape[0])))
|
||||
take = prompt_embeds_cpu[s:e]
|
||||
s = max(0, min(offset, int(prompt_embeds_buffer.shape[0])))
|
||||
e = max(0, min(offset + span_len, int(prompt_embeds_buffer.shape[0])))
|
||||
take = prompt_embeds_buffer[s:e]
|
||||
if int(take.shape[0]) < span_len:
|
||||
pad_n = int(span_len - int(take.shape[0]))
|
||||
pad_rows = tts_pad_embed.reshape(1, -1).to("cpu").expand(pad_n, -1)
|
||||
pad_rows = tts_pad_embed.to(device=take.device).reshape(1, -1).expand(pad_n, -1)
|
||||
take = torch.cat([take, pad_rows], dim=0)
|
||||
prompt_embeds = take.to(device=input_ids.device, dtype=dtype)
|
||||
info_update["meta"]["talker_prefill_offset"] = int(offset + span_len)
|
||||
@@ -826,7 +992,16 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
.to(device=input_ids.device, dtype=dtype)
|
||||
.reshape(span_len, -1)
|
||||
)
|
||||
return input_ids, inputs_embeds_out, {"meta": {"codec_streaming": codec_streaming}}
|
||||
return (
|
||||
input_ids,
|
||||
inputs_embeds_out,
|
||||
{
|
||||
"meta": {
|
||||
"codec_streaming": codec_streaming,
|
||||
"codec_frame_valid": torch.zeros((), dtype=torch.bool, device=input_ids.device),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Decode: span_len == 1
|
||||
# Pop one text-step vector from tailing_text_hidden queue.
|
||||
@@ -884,6 +1059,9 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
"meta": {
|
||||
"talker_text_offset": int(next_text_offset),
|
||||
"codec_streaming": codec_streaming,
|
||||
"codec_frame_valid": (
|
||||
(input_ids.reshape(-1)[-1] >= 0) & (input_ids.reshape(-1)[-1] < self._codebook_vocab_size)
|
||||
).reshape(()),
|
||||
},
|
||||
}
|
||||
if trailing_text_update is not None:
|
||||
@@ -895,11 +1073,13 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
*,
|
||||
input_ids: torch.Tensor,
|
||||
req_infos: list[dict[str, Any]],
|
||||
input_embeds: torch.Tensor | None = None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, list[dict[str, Any]]]:
|
||||
"""Batch the decode-only preprocess path for Qwen3-TTS.
|
||||
|
||||
This mirrors the scalar decode branch in ``preprocess()``, but performs
|
||||
the token embedding lookup once for the whole decode batch.
|
||||
the token embedding lookup once for the whole decode batch, unless
|
||||
the runner has already prepared embeddings for these input ids.
|
||||
"""
|
||||
input_ids_flat = input_ids.reshape(-1)
|
||||
if int(input_ids_flat.numel()) != len(req_infos):
|
||||
@@ -907,6 +1087,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
f"preprocess_decode_batch expected {len(req_infos)} input ids, got {int(input_ids_flat.numel())}"
|
||||
)
|
||||
|
||||
codec_frame_valid_batch = (input_ids_flat >= 0) & (input_ids_flat < self._codebook_vocab_size)
|
||||
device = input_ids_flat.device
|
||||
dtype = self._embedding_dtype
|
||||
# Request-independent constant (see :meth:`_init_runtime_buffers`) —
|
||||
@@ -979,17 +1160,16 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
"meta": {
|
||||
"talker_text_offset": int(next_text_offset),
|
||||
"codec_streaming": codec_streaming,
|
||||
"codec_frame_valid": codec_frame_valid_batch[len(updates)].reshape(()),
|
||||
},
|
||||
}
|
||||
if trailing_text_update is not None:
|
||||
info_update["hidden_states"] = {"trailing_text": trailing_text_update.detach()}
|
||||
updates.append(info_update)
|
||||
|
||||
inputs_embeds_out = self.embed_input_ids(input_ids_flat.reshape(-1, 1).to(torch.long)).to(
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
)
|
||||
inputs_embeds_out = inputs_embeds_out.reshape(len(req_infos), -1)
|
||||
if input_embeds is None:
|
||||
input_embeds = self.embed_input_ids(input_ids_flat.reshape(-1, 1).to(torch.long))
|
||||
inputs_embeds_out = input_embeds.to(device=device, dtype=dtype).reshape(len(req_infos), -1)
|
||||
return (
|
||||
input_ids_flat,
|
||||
inputs_embeds_out,
|
||||
@@ -998,6 +1178,16 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
updates,
|
||||
)
|
||||
|
||||
def preprocess_decode_batch_mrv2(
|
||||
self,
|
||||
*,
|
||||
input_ids: torch.Tensor,
|
||||
input_embeds: torch.Tensor,
|
||||
req_infos: list[dict[str, Any]],
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, list[dict[str, Any]]]:
|
||||
"""Expose the existing TTS decode batcher through the MRv2 contract."""
|
||||
return self.preprocess_decode_batch(input_ids=input_ids, req_infos=req_infos, input_embeds=input_embeds)
|
||||
|
||||
def postprocess(self, hidden_states: torch.Tensor, **_: Any) -> dict[str, Any]:
|
||||
# Keep the last token hidden for the next decode step's code predictor.
|
||||
# Stays on GPU - gpu_resident_buffer_keys avoids the CPU round-trip.
|
||||
@@ -1006,6 +1196,23 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
last = hidden_states[-1, :].detach()
|
||||
return {"hidden_states": {"last": last}}
|
||||
|
||||
def postprocess_batch_mrv2(
|
||||
self,
|
||||
*,
|
||||
hidden_states: torch.Tensor,
|
||||
last_token_indices: torch.Tensor,
|
||||
) -> tuple[tuple[str, str], OwnedBatchTensor]:
|
||||
"""Gather every request's Talker tail in one GPU operation.
|
||||
|
||||
``index_select`` freshly allocates the result, so the batch is marked
|
||||
as owned and the intermediate buffer stores row views without taking
|
||||
a second snapshot.
|
||||
"""
|
||||
return (
|
||||
("hidden_states", "last"),
|
||||
OwnedBatchTensor(hidden_states.index_select(0, last_token_indices)),
|
||||
)
|
||||
|
||||
@torch.inference_mode()
|
||||
def preprocess_batch(
|
||||
self,
|
||||
@@ -1026,6 +1233,39 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
device=device,
|
||||
)
|
||||
|
||||
@torch.inference_mode()
|
||||
def preprocess_batch_mrv2(
|
||||
self,
|
||||
*,
|
||||
req_infos: list[dict[str, Any]],
|
||||
device: torch.device,
|
||||
) -> None:
|
||||
"""Batch new MRv2 prefill slots without rebuilding a req-id map."""
|
||||
if self._silence_ban_frames > 0:
|
||||
for info in req_infos:
|
||||
self._req_x_vector_only[info["req_id"]] = resolve_x_vector_only(info)
|
||||
self._prompt_builder.preprocess_infos_batch(
|
||||
req_infos=req_infos,
|
||||
device=device,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def mrv2_sampling_context(self, *, req_ids: list[str], num_output_tokens: torch.Tensor) -> Iterator[None]:
|
||||
"""Scope the silence gate to sampling, never prompt-logprob computation."""
|
||||
self.set_batch_req_ids(req_ids)
|
||||
self._mrv2_silence_ban_mask = None
|
||||
if self._silence_ban_frames > 0:
|
||||
in_scope = torch.tensor(
|
||||
[self._req_x_vector_only.get(req_id) is True for req_id in req_ids],
|
||||
device=num_output_tokens.device,
|
||||
dtype=torch.bool,
|
||||
)
|
||||
self._mrv2_silence_ban_mask = (in_scope & (num_output_tokens < self._silence_ban_frames)).unsqueeze(1)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._mrv2_silence_ban_mask = None
|
||||
|
||||
def set_batch_req_ids(self, req_ids: Sequence[str]) -> None:
|
||||
"""Record the current batch's req_ids, in the order that indexes logits.
|
||||
|
||||
@@ -1051,10 +1291,9 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
sampling_rate=target_sr,
|
||||
return_tensors="pt",
|
||||
)
|
||||
inputs = inputs.to(device).to(torch.bfloat16)
|
||||
|
||||
input_values = inputs["input_values"].squeeze(1)
|
||||
padding_mask = inputs["padding_mask"].squeeze(1)
|
||||
input_values = inputs["input_values"].squeeze(1).to(device=device, dtype=torch.bfloat16)
|
||||
downsample = self._encoder_downsample_rate
|
||||
valid_code_lengths = [(int(wav.size) + downsample - 1) // downsample for wav in wavs]
|
||||
|
||||
with torch.inference_mode():
|
||||
encoded = self.encoder.encode(
|
||||
@@ -1063,13 +1302,13 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
)
|
||||
|
||||
audio_codes = encoded.audio_codes[:, : self._encoder_valid_num_quantizers]
|
||||
downsample = self._encoder_downsample_rate
|
||||
return [
|
||||
code[..., : -(-mask.sum() // downsample)].transpose(0, 1).to(device=device, dtype=torch.long)
|
||||
for code, mask in zip(audio_codes, padding_mask)
|
||||
code[:, : int(valid_len)].transpose(0, 1).to(dtype=torch.long).contiguous()
|
||||
for code, valid_len in zip(audio_codes, valid_code_lengths, strict=True)
|
||||
]
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
self._prompt_builder._projected_token_cache.clear()
|
||||
# Consume talker weights, and conditionally consume speaker encoder
|
||||
# weights only if they are present in the checkpoint.
|
||||
speaker_weights: list[tuple[str, torch.Tensor]] = []
|
||||
@@ -1256,6 +1495,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
top_p: float | None = None,
|
||||
generator: torch.Generator | None = None,
|
||||
generators: Sequence[torch.Generator | None] | None = None,
|
||||
sample_uniforms: torch.Tensor | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""GPU fast-path used by OmniGPUModelRunner to predict residual codebooks (1..Q-1).
|
||||
@@ -1296,6 +1536,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
top_p=top_p,
|
||||
generator=generator,
|
||||
generators=generators,
|
||||
sample_uniforms=sample_uniforms,
|
||||
) # [B, Q]
|
||||
|
||||
# Map invalid layer-0 ids (e.g. EOS) to PAD=0 so SpeechTokenizer sees only real codes.
|
||||
@@ -1313,3 +1554,20 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
summed = (last_id_hidden.squeeze(1) + gathered.sum(dim=1)).unsqueeze(1)
|
||||
inputs_embeds_out = (summed + text_step).reshape(bsz, -1)
|
||||
return inputs_embeds_out, audio_codes.to(dtype=torch.long)
|
||||
|
||||
# MRV2 capability names read the V1 canonical values so platform patches
|
||||
# such as the NPU 310P graph-safety override apply to both runners.
|
||||
mtp = talker_mtp
|
||||
get_mtp_seed = staticmethod(get_tts_local_seed)
|
||||
|
||||
@property
|
||||
def mtp_output_key(self) -> tuple[str, str]:
|
||||
return self.talker_mtp_output_key
|
||||
|
||||
@property
|
||||
def mtp_graph_safe(self) -> bool:
|
||||
return self.talker_mtp_graph_safe
|
||||
|
||||
@property
|
||||
def mtp_accepts_per_row_generators(self) -> bool:
|
||||
return self.talker_mtp_accepts_per_row_generators
|
||||
+82
-16
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
# Copyright 2026 The Qwen team, Alibaba Group and the HuggingFace Inc. team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -28,7 +31,7 @@ from transformers import MimiConfig, MimiModel
|
||||
from transformers.activations import ACT2FN
|
||||
from transformers.cache_utils import Cache, DynamicCache
|
||||
from transformers.integrations import use_kernel_forward_from_hub
|
||||
from transformers.masking_utils import create_causal_mask, create_sliding_window_causal_mask
|
||||
from transformers.masking_utils import create_sliding_window_causal_mask
|
||||
from transformers.modeling_flash_attention_utils import FlashAttentionKwargs
|
||||
from transformers.modeling_layers import GradientCheckpointingLayer
|
||||
from transformers.modeling_outputs import BaseModelOutputWithPast
|
||||
@@ -521,15 +524,63 @@ class Qwen3TTSTokenizerV2DecoderTransformerModel(Qwen3TTSTokenizerV2DecoderPreTr
|
||||
self.norm = Qwen3TTSTokenizerV2DecoderRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
self.rotary_emb = Qwen3TTSTokenizerV2DecoderRotatoryEmbedding(config=config)
|
||||
self.gradient_checkpointing = False
|
||||
self.has_sliding_layers = "sliding_attention" in self.config.layer_types
|
||||
self.window_size = config.sliding_window
|
||||
|
||||
self.input_proj = nn.Linear(config.latent_dim, config.hidden_size)
|
||||
self.output_proj = nn.Linear(config.hidden_size, config.latent_dim)
|
||||
# Stateless calls with implicit positions share one broadcastable mask
|
||||
# per warmup shape. Incremental or explicit-position calls build a live mask.
|
||||
self._sliding_attention_mask_cache: dict[tuple[int, torch.dtype, torch.device, str], torch.Tensor | None] = {}
|
||||
|
||||
# Initialize weights and apply final processing
|
||||
self.post_init()
|
||||
|
||||
def _apply(self, fn, recurse=True):
|
||||
# A placement or dtype change invalidates every cached mask.
|
||||
self._sliding_attention_mask_cache.clear()
|
||||
return super()._apply(fn, recurse=recurse)
|
||||
|
||||
def _get_sliding_attention_mask(
|
||||
self,
|
||||
inputs_embeds: torch.Tensor,
|
||||
) -> torch.Tensor | None:
|
||||
sequence_length = int(inputs_embeds.shape[1])
|
||||
max_position_embeddings = int(self.config.max_position_embeddings)
|
||||
if sequence_length > max_position_embeddings:
|
||||
raise ValueError(
|
||||
f"Input length {sequence_length} exceeds max_position_embeddings {max_position_embeddings}"
|
||||
)
|
||||
|
||||
cache_key = (
|
||||
sequence_length,
|
||||
inputs_embeds.dtype,
|
||||
inputs_embeds.device,
|
||||
str(self.config._attn_implementation),
|
||||
)
|
||||
if cache_key not in self._sliding_attention_mask_cache:
|
||||
mask_inputs = inputs_embeds[:1]
|
||||
mask_kwargs = {
|
||||
"config": self.config,
|
||||
"attention_mask": None,
|
||||
"past_key_values": None,
|
||||
"position_ids": None,
|
||||
}
|
||||
sig = inspect.signature(create_sliding_window_causal_mask)
|
||||
if "input_embeds" in sig.parameters:
|
||||
mask_kwargs["input_embeds"] = mask_inputs
|
||||
mask_kwargs["cache_position"] = torch.arange(
|
||||
sequence_length,
|
||||
device=inputs_embeds.device,
|
||||
)
|
||||
else:
|
||||
mask_kwargs["inputs_embeds"] = mask_inputs
|
||||
sliding_attention_mask = create_sliding_window_causal_mask(**mask_kwargs)
|
||||
if sliding_attention_mask is not None:
|
||||
sliding_attention_mask = sliding_attention_mask.contiguous()
|
||||
self._sliding_attention_mask_cache[cache_key] = sliding_attention_mask
|
||||
|
||||
return self._sliding_attention_mask_cache[cache_key]
|
||||
|
||||
# Note: @check_model_inputs decorator removed for vLLM compatibility
|
||||
# The decorator causes "unexpected keyword argument 'inputs_embeds'" error
|
||||
@auto_docstring
|
||||
@@ -558,6 +609,17 @@ class Qwen3TTSTokenizerV2DecoderTransformerModel(Qwen3TTSTokenizerV2DecoderPreTr
|
||||
|
||||
inputs_embeds = self.input_proj(inputs_embeds)
|
||||
|
||||
sequence_length = inputs_embeds.shape[1]
|
||||
# Only internally generated positions are known to be contiguous and
|
||||
# zero-based without reading CUDA tensor values back to the host.
|
||||
# Explicit positions use the live mask path, including during capture.
|
||||
uses_prepared_or_incremental_mask = (
|
||||
attention_mask is not None
|
||||
or bool(use_cache)
|
||||
or past_key_values is not None
|
||||
or cache_position is not None
|
||||
or position_ids is not None
|
||||
)
|
||||
if use_cache and past_key_values is None:
|
||||
past_key_values = DynamicCache(config=self.config)
|
||||
|
||||
@@ -565,7 +627,7 @@ class Qwen3TTSTokenizerV2DecoderTransformerModel(Qwen3TTSTokenizerV2DecoderPreTr
|
||||
past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0
|
||||
cache_position = torch.arange(
|
||||
past_seen_tokens,
|
||||
past_seen_tokens + inputs_embeds.shape[1],
|
||||
past_seen_tokens + sequence_length,
|
||||
device=inputs_embeds.device,
|
||||
)
|
||||
|
||||
@@ -574,22 +636,24 @@ class Qwen3TTSTokenizerV2DecoderTransformerModel(Qwen3TTSTokenizerV2DecoderPreTr
|
||||
|
||||
hidden_states = inputs_embeds
|
||||
|
||||
if not isinstance(causal_mask_mapping := attention_mask, dict):
|
||||
if isinstance(attention_mask, dict):
|
||||
sliding_attention_mask = attention_mask["sliding_attention"]
|
||||
elif uses_prepared_or_incremental_mask:
|
||||
mask_kwargs = {
|
||||
"config": self.config,
|
||||
"attention_mask": attention_mask,
|
||||
"past_key_values": past_key_values,
|
||||
"position_ids": position_ids,
|
||||
}
|
||||
sig = inspect.signature(create_causal_mask)
|
||||
sig = inspect.signature(create_sliding_window_causal_mask)
|
||||
if "input_embeds" in sig.parameters:
|
||||
mask_kwargs["input_embeds"] = inputs_embeds
|
||||
mask_kwargs["cache_position"] = cache_position
|
||||
else:
|
||||
mask_kwargs["inputs_embeds"] = inputs_embeds
|
||||
causal_mask_mapping = {"full_attention": create_causal_mask(**mask_kwargs)}
|
||||
if self.has_sliding_layers:
|
||||
causal_mask_mapping["sliding_attention"] = create_sliding_window_causal_mask(**mask_kwargs)
|
||||
sliding_attention_mask = create_sliding_window_causal_mask(**mask_kwargs)
|
||||
else:
|
||||
sliding_attention_mask = self._get_sliding_attention_mask(inputs_embeds)
|
||||
|
||||
# create position embeddings to be shared across the decoder layers
|
||||
position_embeddings = self.rotary_emb(hidden_states, position_ids)
|
||||
@@ -597,7 +661,7 @@ class Qwen3TTSTokenizerV2DecoderTransformerModel(Qwen3TTSTokenizerV2DecoderPreTr
|
||||
for decoder_layer in self.layers[: self.config.num_hidden_layers]:
|
||||
hidden_states = decoder_layer(
|
||||
hidden_states,
|
||||
attention_mask=causal_mask_mapping[decoder_layer.attention_type],
|
||||
attention_mask=sliding_attention_mask,
|
||||
position_ids=position_ids,
|
||||
past_key_values=past_key_values,
|
||||
use_cache=use_cache,
|
||||
@@ -1400,13 +1464,15 @@ class Qwen3TTSTokenizerV2Decoder(Qwen3TTSTokenizerV2DecoderPreTrainedModel):
|
||||
|
||||
@staticmethod
|
||||
def _slice_dynamic_cache(cache: DynamicCache, row: int) -> DynamicCache:
|
||||
request_cache = copy.deepcopy(cache)
|
||||
for layer in request_cache.layers:
|
||||
if layer.keys is not None:
|
||||
layer.keys = layer.keys[row : row + 1].clone()
|
||||
if layer.values is not None:
|
||||
layer.values = layer.values[row : row + 1].clone()
|
||||
return request_cache
|
||||
# Preserve cache metadata and independent ownership without cloning the
|
||||
# full batch for every request. Seed deepcopy with only the selected KV
|
||||
# rows; it will copy the remaining cache structure normally.
|
||||
memo: dict[int, Any] = {}
|
||||
for layer in cache.layers:
|
||||
for tensor in (layer.keys, layer.values):
|
||||
if tensor is not None and id(tensor) not in memo:
|
||||
memo[id(tensor)] = tensor[row : row + 1].clone()
|
||||
return copy.deepcopy(cache, memo)
|
||||
|
||||
@staticmethod
|
||||
def _cache_tensors_are_batchable(request_caches: list[dict[str, Any]], keys: tuple[str, ...]) -> bool:
|
||||
|
||||
@@ -70,7 +70,7 @@ def _extract_last_frame(multimodal_output: OmniPayload | dict[str, Any]) -> torc
|
||||
return None
|
||||
if audio_codes.ndim == 2:
|
||||
frame = audio_codes[-1]
|
||||
if frame.numel() == 0 or not bool(frame.any().item()):
|
||||
if frame.numel() == 0:
|
||||
return None
|
||||
return frame.to(torch.long).reshape(-1)
|
||||
if audio_codes.ndim == 1:
|
||||
@@ -78,6 +78,31 @@ def _extract_last_frame(multimodal_output: OmniPayload | dict[str, Any]) -> torc
|
||||
raise ValueError(f"Invalid audio_codes shape for Qwen3-TTS async_chunk: {tuple(audio_codes.shape)}")
|
||||
|
||||
|
||||
def _should_append_async_frame(
|
||||
multimodal_output: OmniPayload | dict[str, Any],
|
||||
frame: torch.Tensor,
|
||||
) -> bool:
|
||||
meta = multimodal_output.get("meta", {})
|
||||
frame_valid = meta.get("codec_frame_valid") if isinstance(meta, Mapping) else None
|
||||
if isinstance(frame_valid, torch.Tensor):
|
||||
if frame_valid.numel() == 0:
|
||||
raise ValueError("codec_frame_valid must not be empty")
|
||||
# A single request can carry a multi-token prefill span. The model
|
||||
# emits one validity entry per codec row, while _extract_last_frame()
|
||||
# selects the final row from that span.
|
||||
return bool(frame_valid.reshape(-1)[-1].item())
|
||||
if frame_valid is not None:
|
||||
return bool(frame_valid)
|
||||
# Compatibility for payload producers predating the explicit validity
|
||||
# contract. Real Qwen3-TTS outputs carry codec_frame_valid and avoid this
|
||||
# value-dependent synchronization.
|
||||
return bool(frame.any().item())
|
||||
|
||||
|
||||
def _append_tensor_frame(transfer_manager: Any, request_id: str, frame: torch.Tensor) -> None:
|
||||
transfer_manager.code_prompt_token_ids[request_id].append(frame.detach().to(torch.long).reshape(-1).contiguous())
|
||||
|
||||
|
||||
def talker2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
multimodal_output: OmniPayload | dict[str, Any] | None,
|
||||
@@ -93,12 +118,11 @@ def talker2code2wav_async_chunk(
|
||||
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
frame = _extract_last_frame(multimodal_output)
|
||||
if frame is not None:
|
||||
codec_codes = frame.cpu().tolist()
|
||||
transfer_manager.code_prompt_token_ids[request_id].append(codec_codes)
|
||||
if frame is not None and _should_append_async_frame(multimodal_output, frame):
|
||||
_append_tensor_frame(transfer_manager, request_id, frame)
|
||||
ref_code = multimodal_output.get("codes", {}).get("ref")
|
||||
if isinstance(ref_code, torch.Tensor) and ref_code.numel() > 0 and request_payload.get(request_id) is None:
|
||||
request_payload[request_id] = ref_code.to(torch.long).cpu().contiguous()
|
||||
request_payload[request_id] = ref_code.detach().to(torch.long).contiguous()
|
||||
elif not finished:
|
||||
return None
|
||||
|
||||
@@ -183,6 +207,17 @@ def talker2code2wav_async_chunk(
|
||||
)
|
||||
initial_chunk_size = chunk_size
|
||||
length = len(transfer_manager.code_prompt_token_ids[request_id])
|
||||
emitted_frames = getattr(transfer_manager, "_qwen3_tts_emitted_frames", None)
|
||||
if emitted_frames is None:
|
||||
emitted_frames = {}
|
||||
transfer_manager._qwen3_tts_emitted_frames = emitted_frames
|
||||
if request_id in emitted_frames and length <= emitted_frames[request_id]:
|
||||
if finished:
|
||||
return OmniPayloadStruct(
|
||||
codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)),
|
||||
meta=MetaStruct(request_id=request_id, left_context_size=0, finished=torch.tensor(True)),
|
||||
)
|
||||
return None
|
||||
|
||||
# Full-utterance Code2Wav is gated by ``full_utterance_decode`` (explicit
|
||||
# opt-in via additional_information). Do not reuse ``non_streaming_mode``:
|
||||
@@ -305,6 +340,8 @@ def talker2code2wav_async_chunk(
|
||||
|
||||
if finished and first_chunk:
|
||||
context_length = length
|
||||
if request_id in emitted_frames:
|
||||
context_length = min(context_length, length - emitted_frames[request_id])
|
||||
|
||||
if adaptive_enabled:
|
||||
ctrl = transfer_manager._adaptive_states.get(request_id)
|
||||
@@ -343,7 +380,7 @@ def talker2code2wav_async_chunk(
|
||||
if isinstance(ref_code, torch.Tensor) and ref_code.numel() > 0:
|
||||
ref_context = ref_code
|
||||
if ref_code_context_frames > 0 and int(ref_context.shape[0]) > ref_code_context_frames:
|
||||
logger.info_once(
|
||||
logger.debug(
|
||||
"Qwen3-TTS async chunk uses the last %d/%d ref_code frames as bounded Code2Wav context.",
|
||||
ref_code_context_frames,
|
||||
int(ref_context.shape[0]),
|
||||
@@ -375,6 +412,7 @@ def talker2code2wav_async_chunk(
|
||||
meta.ref_context_request_id = ref_context_request_id
|
||||
meta.ref_context_included = ref_context_included
|
||||
|
||||
emitted_frames[request_id] = length
|
||||
return OmniPayloadStruct(
|
||||
codes=CodesStruct(audio=code_predictor_codes),
|
||||
meta=meta,
|
||||
@@ -383,6 +421,91 @@ def talker2code2wav_async_chunk(
|
||||
)
|
||||
|
||||
|
||||
def _copy_async_chunk_tensors_to_cpu(
|
||||
tensors: list[torch.Tensor],
|
||||
) -> list[torch.Tensor]:
|
||||
"""Materialize one producer cohort with at most one D2H per device."""
|
||||
if not tensors:
|
||||
return []
|
||||
|
||||
outputs: list[torch.Tensor | None] = [None] * len(tensors)
|
||||
by_device: dict[torch.device, list[tuple[int, torch.Tensor]]] = {}
|
||||
for index, tensor in enumerate(tensors):
|
||||
value = tensor.detach().to(dtype=torch.long).reshape(-1).contiguous()
|
||||
if value.device.type == "cpu":
|
||||
outputs[index] = value
|
||||
else:
|
||||
by_device.setdefault(value.device, []).append((index, value))
|
||||
|
||||
for entries in by_device.values():
|
||||
lengths = [int(value.numel()) for _, value in entries]
|
||||
combined = torch.cat([value for _, value in entries], dim=0).to(device="cpu")
|
||||
offset = 0
|
||||
for (index, source), length in zip(entries, lengths):
|
||||
outputs[index] = combined[offset : offset + length].reshape(source.shape).contiguous()
|
||||
offset += length
|
||||
|
||||
return [value for value in outputs if value is not None]
|
||||
|
||||
|
||||
def talker2code2wav_async_chunk_batch(
|
||||
transfer_manager: Any,
|
||||
pooling_outputs: list[OmniPayload | dict[str, Any] | None],
|
||||
requests: list[Any],
|
||||
is_finished: list[bool],
|
||||
) -> list[OmniPayloadStruct | None]:
|
||||
"""Build one Talker model-step worth of Code2Wav payloads.
|
||||
|
||||
MRv2 invokes this hook from its ordered native output worker. Codec frames
|
||||
and first-chunk reference codes from the same model step are copied as a
|
||||
cohort, while chunk boundaries and request metadata remain delegated to the
|
||||
scalar builder below.
|
||||
"""
|
||||
if not (len(pooling_outputs) == len(requests) == len(is_finished)):
|
||||
raise ValueError("batch codec inputs must have identical lengths")
|
||||
|
||||
request_payload = getattr(transfer_manager, "request_payload", None)
|
||||
if request_payload is None:
|
||||
request_payload = {}
|
||||
transfer_manager.request_payload = request_payload
|
||||
|
||||
selected: list[torch.Tensor] = []
|
||||
destinations: list[tuple[str, str, tuple[int, ...]]] = []
|
||||
for pooling_output, request in zip(pooling_outputs, requests):
|
||||
if not isinstance(pooling_output, Mapping):
|
||||
continue
|
||||
request_id = request.external_req_id
|
||||
frame = _extract_last_frame(pooling_output)
|
||||
if frame is not None and _should_append_async_frame(pooling_output, frame):
|
||||
selected.append(frame)
|
||||
destinations.append(("frame", request_id, tuple(frame.shape)))
|
||||
|
||||
ref_code = pooling_output.get("codes", {}).get("ref")
|
||||
if isinstance(ref_code, torch.Tensor) and ref_code.numel() > 0 and request_payload.get(request_id) is None:
|
||||
selected.append(ref_code)
|
||||
destinations.append(("ref", request_id, tuple(ref_code.shape)))
|
||||
|
||||
copied = _copy_async_chunk_tensors_to_cpu(selected)
|
||||
for value, (kind, request_id, shape) in zip(copied, destinations):
|
||||
value = value.reshape(shape)
|
||||
if kind == "frame":
|
||||
_append_tensor_frame(transfer_manager, request_id, value)
|
||||
else:
|
||||
request_payload[request_id] = value.contiguous()
|
||||
|
||||
payloads: list[OmniPayloadStruct | None] = []
|
||||
for request, finished in zip(requests, is_finished):
|
||||
payloads.append(
|
||||
talker2code2wav_async_chunk(
|
||||
transfer_manager=transfer_manager,
|
||||
multimodal_output={},
|
||||
request=request,
|
||||
is_finished=finished,
|
||||
)
|
||||
)
|
||||
return payloads
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Worker-connector data plane (non-async-chunk path).
|
||||
# AR runner's `flatten_payload` converts the model emit
|
||||
|
||||
@@ -60,6 +60,10 @@ class OmniModelRunnerOutput(ModelRunnerOutput):
|
||||
# The Scheduler can safely free the block tables for these requests.
|
||||
kv_extracted_req_ids: list[str] | None = None
|
||||
omni_connector_output: OmniConnectorOutput | None = None
|
||||
# True when sampled_token_ids has already been materialized on the host.
|
||||
# MRv2 consumers must treat the value as immutable and must not perform
|
||||
# another device-to-host conversion or rebuild it from sampler tensors.
|
||||
sampled_token_ids_materialized: bool = False
|
||||
|
||||
@classmethod
|
||||
def with_kv_conn_output_only(cls, kv_connector_output: Any) -> "OmniModelRunnerOutput":
|
||||
|
||||
@@ -684,11 +684,19 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
|
||||
# Accumulate multimodal tensors regardless of path.
|
||||
if isinstance(req_state, OmniRequestState):
|
||||
mm_type = getattr(eco, "output_type", None) or default_mm_type
|
||||
|
||||
mm_output = getattr(eco, "multimodal_output", None)
|
||||
if mm_output is not None:
|
||||
mm_type = getattr(eco, "output_type", None) or default_mm_type
|
||||
req_state.add_multimodal_tensor(mm_output, mm_type)
|
||||
|
||||
# Omni AR stages carry model payloads alongside text tokens in
|
||||
# pooling_output. Preserve the payload, then clear the field so
|
||||
# upstream still runs the normal detokenization path.
|
||||
if eco.pooling_output is not None and req_state.detokenizer is not None:
|
||||
req_state.add_multimodal_tensor(eco.pooling_output, mm_type)
|
||||
eco.pooling_output = None
|
||||
|
||||
# Route: if no detokenizer and no pooling output, handle locally
|
||||
# to avoid upstream's assert on detokenizer.
|
||||
if req_state.detokenizer is None and eco.pooling_output is None:
|
||||
@@ -697,20 +705,22 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
upstream_outputs.append(eco)
|
||||
|
||||
# Handle multimodal-only outputs (generation stages) locally.
|
||||
mm_request_outputs = self._process_mm_only_outputs(
|
||||
mm_request_outputs, mm_reqs_to_abort = self._process_mm_only_outputs(
|
||||
mm_only_outputs,
|
||||
engine_core_timestamp=engine_core_timestamp,
|
||||
iteration_stats=iteration_stats,
|
||||
)
|
||||
|
||||
# Delegate text/pooling outputs to upstream.
|
||||
processed = super().process_outputs(
|
||||
upstream_processed = super().process_outputs(
|
||||
upstream_outputs,
|
||||
engine_core_timestamp=engine_core_timestamp,
|
||||
iteration_stats=iteration_stats,
|
||||
)
|
||||
processed.request_outputs.extend(mm_request_outputs)
|
||||
return processed
|
||||
return OutputProcessorOutput(
|
||||
request_outputs=mm_request_outputs + upstream_processed.request_outputs,
|
||||
reqs_to_abort=mm_reqs_to_abort + upstream_processed.reqs_to_abort,
|
||||
)
|
||||
|
||||
def _process_mm_only_outputs(
|
||||
self,
|
||||
@@ -718,30 +728,32 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
*,
|
||||
engine_core_timestamp: float | None = None,
|
||||
iteration_stats: IterationStats | None = None,
|
||||
) -> list[OmniRequestOutput | PoolingRequestOutput]:
|
||||
) -> tuple[list[OmniRequestOutput | PoolingRequestOutput], list[str]]:
|
||||
"""Handle outputs from generation stages that have no detokenizer.
|
||||
|
||||
These cannot go through upstream process_outputs because it asserts
|
||||
detokenizer is not None when pooling_output is None.
|
||||
"""
|
||||
request_outputs: list[OmniRequestOutput | PoolingRequestOutput] = []
|
||||
reqs_to_abort: list[str] = []
|
||||
for eco in engine_core_outputs:
|
||||
req_state = self.request_states.get(eco.request_id)
|
||||
if req_state is None or not isinstance(req_state, OmniRequestState):
|
||||
continue
|
||||
|
||||
new_token_ids = eco.new_token_ids
|
||||
finish_reason = eco.finish_reason
|
||||
stop_reason = eco.stop_reason
|
||||
kv_transfer_params = eco.kv_transfer_params
|
||||
ec_transfer_params = getattr(eco, "ec_transfer_params", None)
|
||||
routed_experts = eco.routed_experts
|
||||
self._update_stats_from_output(
|
||||
req_state,
|
||||
eco,
|
||||
engine_core_timestamp,
|
||||
iteration_stats,
|
||||
)
|
||||
|
||||
new_token_ids = eco.new_token_ids
|
||||
finish_reason = eco.finish_reason
|
||||
stop_reason = eco.stop_reason
|
||||
kv_transfer_params = eco.kv_transfer_params
|
||||
ec_transfer_params = eco.ec_transfer_params
|
||||
routed_experts = eco.routed_experts
|
||||
prefill_stats = getattr(eco, "prefill_stats", None)
|
||||
if prefill_stats is not None:
|
||||
req_state.num_cached_tokens = prefill_stats.num_cached_tokens
|
||||
@@ -770,12 +782,17 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
is_segment_finished = bool(getattr(eco, "is_segment_finished", False))
|
||||
if finish_reason is not None and not is_segment_finished and not is_non_final_audio_chunk:
|
||||
self._finish_request(req_state)
|
||||
if not getattr(eco, "finished", True):
|
||||
reqs_to_abort.append(eco.request_id)
|
||||
self._update_stats_from_finished(
|
||||
req_state,
|
||||
finish_reason,
|
||||
iteration_stats,
|
||||
)
|
||||
return request_outputs
|
||||
if self.tracing_enabled:
|
||||
self.do_tracing(eco, req_state, iteration_stats)
|
||||
|
||||
return request_outputs, reqs_to_abort
|
||||
|
||||
def _update_stats_from_output(
|
||||
self,
|
||||
@@ -804,6 +821,10 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
None,
|
||||
)
|
||||
|
||||
reported_count = getattr(engine_core_output, "num_generation_tokens", None)
|
||||
if isinstance(reported_count, int) and not isinstance(reported_count, bool) and reported_count >= 0:
|
||||
self._native_text_metric_record(req_state.external_req_id)["num_generation_tokens"] = reported_count
|
||||
|
||||
if iteration_stats is None or engine_core_timestamp is None or native_stats is None:
|
||||
return
|
||||
|
||||
@@ -816,7 +837,11 @@ class MultimodalOutputProcessor(VLLMOutputProcessor):
|
||||
req_state.lora_name,
|
||||
)
|
||||
record = self._native_text_metric_record(req_state.external_req_id)
|
||||
record["num_generation_tokens"] = int(native_stats.num_generation_tokens)
|
||||
record["num_generation_tokens"] = (
|
||||
reported_count
|
||||
if isinstance(reported_count, int) and not isinstance(reported_count, bool) and reported_count >= 0
|
||||
else int(native_stats.num_generation_tokens)
|
||||
)
|
||||
if was_prefilling:
|
||||
record["vllm_ttft_ms"] = max(float(native_stats.first_token_latency) * 1000.0, 0.0)
|
||||
return
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Base worker class for vLLM-Omni with device-level GPU memory profiling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import gc
|
||||
import os
|
||||
|
||||
@@ -75,7 +78,6 @@ class GPUARWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
||||
f"DP adjusted local rank {self.local_rank} is out of "
|
||||
f"bounds for {torch.accelerator.device_count()} devices."
|
||||
)
|
||||
|
||||
visible_device_index = current_platform.logical_device_id_to_visible_device_id(self.local_rank)
|
||||
self.device = current_omni_platform.get_torch_device(visible_device_index)
|
||||
torch.accelerator.set_device_index(self.device)
|
||||
@@ -113,13 +115,24 @@ class GPUARWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
||||
num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1
|
||||
init_workspace_manager(self.device, num_ubatches)
|
||||
|
||||
if self.use_v2_model_runner:
|
||||
# OMNI: v2 model runner does not yet include omni hooks.
|
||||
logger.warning("OMNI GPUARWorker forces v1 model runner for omni hooks.")
|
||||
self.use_v2_model_runner = False
|
||||
model_stage = getattr(self.model_config, "model_stage", None)
|
||||
stage_id = getattr(self.model_config, "stage_id", None)
|
||||
|
||||
# Construct the model runner
|
||||
self.model_runner = self.model_runner_cls(self.vllm_config, self.device)
|
||||
self.use_v2_model_runner = bool(getattr(self.model_config, "use_v2_model_runner", False))
|
||||
if self.use_v2_model_runner:
|
||||
from vllm_omni.worker_v2.omni_ar_model_runner import (
|
||||
OmniARModelRunner,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Using MR v2 OmniARModelRunner for omni AR stage (stage_id=%s model_stage=%s).",
|
||||
stage_id,
|
||||
model_stage,
|
||||
)
|
||||
self.model_runner = OmniARModelRunner(self.vllm_config, self.device)
|
||||
else:
|
||||
self.use_v2_model_runner = False
|
||||
self.model_runner = self.model_runner_cls(self.vllm_config, self.device)
|
||||
|
||||
if self.rank == 0:
|
||||
# If usage stat is enabled, collect relevant info.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import gc
|
||||
import os
|
||||
|
||||
@@ -8,7 +11,10 @@ from vllm.tracing import instrument
|
||||
from vllm.utils.mem_utils import MemorySnapshot, format_gib
|
||||
from vllm.utils.torch_utils import set_random_seed
|
||||
from vllm.v1.utils import report_usage_stats
|
||||
from vllm.v1.worker.gpu_worker import init_worker_distributed_environment
|
||||
from vllm.v1.worker.gpu_worker import (
|
||||
CompilationTimes,
|
||||
init_worker_distributed_environment,
|
||||
)
|
||||
from vllm.v1.worker.workspace import init_workspace_manager
|
||||
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
@@ -96,13 +102,28 @@ class GPUGenerationWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
||||
num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1
|
||||
init_workspace_manager(self.device, num_ubatches)
|
||||
|
||||
self.use_v2_model_runner = bool(getattr(self.model_config, "use_v2_model_runner", False))
|
||||
if self.use_v2_model_runner:
|
||||
# OMNI: v2 model runner does not yet include omni hooks.
|
||||
logger.warning("OMNI GPUGenerationWorker forces v1 model runner for omni hooks.")
|
||||
self.use_v2_model_runner = False
|
||||
from vllm_omni.worker_v2.omni_generation_model_runner import (
|
||||
OmniGenerationModelRunner,
|
||||
)
|
||||
|
||||
self.model_runner = self.model_runner_cls(self.vllm_config, self.device)
|
||||
logger.info("Using MR v2 OmniGenerationModelRunner for generation stage.")
|
||||
self.model_runner = OmniGenerationModelRunner(self.vllm_config, self.device)
|
||||
else:
|
||||
self.model_runner = self.model_runner_cls(self.vllm_config, self.device)
|
||||
|
||||
if self.rank == 0:
|
||||
# If usage stat is enabled, collect relevant info.
|
||||
report_usage_stats(self.vllm_config)
|
||||
|
||||
@instrument(span_name="Compile/warmup")
|
||||
def compile_or_warm_up_model(self) -> CompilationTimes:
|
||||
"""Generation stages have no KV cache or sampler — skip warmup_kernels."""
|
||||
if not self.use_v2_model_runner:
|
||||
return super().compile_or_warm_up_model()
|
||||
import time
|
||||
|
||||
start = time.perf_counter()
|
||||
self.model_runner.profile_run()
|
||||
return CompilationTimes(language_model=time.perf_counter() - start, encoder=0.0)
|
||||
@@ -2042,10 +2042,20 @@ class OmniGPUModelRunner(PrefixCacheRunnerMixin, GPUModelRunner):
|
||||
if req_state is not None:
|
||||
existing = self.model_intermediate_buffer.setdefault(req_id, {})
|
||||
existing.setdefault(out_key[0], {})[out_key[1]] = row
|
||||
validity_key = getattr(self.model, "talker_mtp_validity_key", None)
|
||||
if validity_key is not None:
|
||||
existing.setdefault(validity_key[0], {})[validity_key[1]] = torch.ones(
|
||||
(), dtype=torch.bool, device=code_predictor_codes.device
|
||||
)
|
||||
req_state.additional_information_cpu = existing
|
||||
else:
|
||||
for idx, req_id in enumerate(decode_req_ids):
|
||||
update_dict = {out_key[0]: {out_key[1]: code_predictor_codes[idx : idx + 1]}}
|
||||
validity_key = getattr(self.model, "talker_mtp_validity_key", None)
|
||||
if validity_key is not None:
|
||||
update_dict.setdefault(validity_key[0], {})[validity_key[1]] = torch.ones(
|
||||
(), dtype=torch.bool, device=code_predictor_codes.device
|
||||
)
|
||||
self._update_intermediate_buffer(req_id, update_dict)
|
||||
|
||||
def _model_forward(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Helpers for explicit thinker decode span metadata."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
TensorSpan = tuple[torch.Tensor, int, int]
|
||||
|
||||
|
||||
def get_tensor_span(payload: Mapping[str, Any], *, tensor_key: str, start_key: str, end_key: str) -> TensorSpan | None:
|
||||
tensor = payload.get(tensor_key)
|
||||
start = payload.get(start_key)
|
||||
end = payload.get(end_key)
|
||||
if not isinstance(tensor, torch.Tensor):
|
||||
return None
|
||||
if not isinstance(start, int) or not isinstance(end, int):
|
||||
return None
|
||||
if start < 0 or end < start or (end - start) != int(tensor.shape[0]):
|
||||
return None
|
||||
return tensor, start, end
|
||||
|
||||
|
||||
def merge_tensor_spans(existing_span: TensorSpan | None, incoming_span: TensorSpan | None) -> TensorSpan | None:
|
||||
if existing_span is None or incoming_span is None:
|
||||
return None
|
||||
|
||||
existing_tensor, existing_start, existing_end = existing_span
|
||||
incoming_tensor, incoming_start, incoming_end = incoming_span
|
||||
if incoming_tensor.device != existing_tensor.device or incoming_tensor.dtype != existing_tensor.dtype:
|
||||
incoming_tensor = incoming_tensor.to(device=existing_tensor.device, dtype=existing_tensor.dtype)
|
||||
if incoming_start == existing_end:
|
||||
if existing_start == existing_end:
|
||||
return incoming_tensor, incoming_start, incoming_end
|
||||
return torch.cat([existing_tensor, incoming_tensor], dim=0), existing_start, incoming_end
|
||||
if incoming_start < existing_end:
|
||||
overlap = existing_end - incoming_start
|
||||
if overlap >= int(incoming_tensor.shape[0]):
|
||||
return existing_tensor, existing_start, existing_end
|
||||
trimmed_tensor = incoming_tensor[overlap:]
|
||||
return (
|
||||
torch.cat([existing_tensor, trimmed_tensor], dim=0),
|
||||
existing_start,
|
||||
existing_end + int(trimmed_tensor.shape[0]),
|
||||
)
|
||||
return None
|
||||
@@ -6,18 +6,72 @@ from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.v1.sample.logits_processor import LogitsProcessors, MinTokensLogitsProcessor
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"build_model_sampler_extra_args",
|
||||
"call_model_sampler",
|
||||
"apply_fixed_seed_to_sampling_params",
|
||||
"get_tts_local_seed",
|
||||
"clamp_prompt_ids_to_penalty_padding",
|
||||
"sanitize_min_tokens_stop_ids",
|
||||
"sanitize_sampling_params_min_tokens_stop_ids",
|
||||
"build_model_sampler_extra_args",
|
||||
"call_model_sampler",
|
||||
]
|
||||
|
||||
|
||||
def get_tts_local_seed(sampling_params: Any) -> int | None:
|
||||
"""Return the explicit per-request Talker MTP seed, if configured."""
|
||||
extra_args = getattr(sampling_params, "extra_args", None) if sampling_params is not None else None
|
||||
seed = extra_args.get("tts_local_seed") if isinstance(extra_args, dict) else None
|
||||
return int(seed) if seed is not None else None
|
||||
|
||||
|
||||
def apply_fixed_seed_to_sampling_params(
|
||||
sampling_params: Any,
|
||||
seed: int,
|
||||
*,
|
||||
seed_talker_mtp: bool,
|
||||
) -> None:
|
||||
"""Apply one request seed to a stage sampler and, for Talkers, its MTP RNG."""
|
||||
seed = int(seed)
|
||||
sampling_params.seed = seed
|
||||
if not seed_talker_mtp:
|
||||
return
|
||||
extra_args = dict(getattr(sampling_params, "extra_args", None) or {})
|
||||
extra_args["tts_local_seed"] = seed
|
||||
sampling_params.extra_args = extra_args
|
||||
|
||||
|
||||
def sanitize_sampling_params_min_tokens_stop_ids(
|
||||
sampling_params: SamplingParams,
|
||||
logits_vocab: int,
|
||||
) -> None:
|
||||
"""Remove unreachable ids before MRv2 builds persistent min-token state.
|
||||
|
||||
MRv2 copies ``SamplingParams.all_stop_token_ids`` into GPU-resident
|
||||
``LogitBiasState`` when a request is registered. Narrow codec heads cannot
|
||||
safely carry the text tokenizer EOS in that state. The engine-facing EOS
|
||||
and explicit ``stop_token_ids`` fields remain unchanged; only the set used
|
||||
by the sampler's min-token mask is normalized.
|
||||
"""
|
||||
if sampling_params.min_tokens <= 0:
|
||||
return
|
||||
stop_token_ids = sampling_params.all_stop_token_ids
|
||||
unreachable = {token_id for token_id in stop_token_ids if token_id < 0 or token_id >= logits_vocab}
|
||||
if not unreachable:
|
||||
return
|
||||
stop_token_ids.difference_update(unreachable)
|
||||
logger.warning_once(
|
||||
"min_tokens: dropped stop token ids %s that exceed the logits vocabulary (%d); "
|
||||
"the model head cannot emit them.",
|
||||
str(sorted(unreachable)),
|
||||
logits_vocab,
|
||||
)
|
||||
|
||||
|
||||
def build_model_sampler_extra_args(input_batch: Any, requests: Any) -> list[dict | None]:
|
||||
"""Return per-request ``SamplingParams.extra_args`` in batch-row order."""
|
||||
request_states = requests or {}
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,218 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class DeliveryState(Enum):
|
||||
QUEUED = auto()
|
||||
IN_FLIGHT = auto()
|
||||
DELIVERED = auto()
|
||||
FAILED = auto()
|
||||
CANCELLED = auto()
|
||||
|
||||
|
||||
class DeliveryTimeoutError(TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
class DeliveryCancelledError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_TERMINAL_STATES = {
|
||||
DeliveryState.DELIVERED,
|
||||
DeliveryState.FAILED,
|
||||
DeliveryState.CANCELLED,
|
||||
}
|
||||
|
||||
|
||||
class DeliveryTicket:
|
||||
"""Exactly-once completion for one MRv2 connector delivery."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: OmniDeliveryManager,
|
||||
*,
|
||||
request_id: str,
|
||||
put_key: str,
|
||||
deadline: float,
|
||||
) -> None:
|
||||
self._manager = manager
|
||||
self.request_id = request_id
|
||||
self.put_key = put_key
|
||||
self.deadline = deadline
|
||||
self._state = DeliveryState.QUEUED
|
||||
self._error: BaseException | None = None
|
||||
self._event = threading.Event()
|
||||
|
||||
@property
|
||||
def state(self) -> DeliveryState:
|
||||
with self._manager._lock:
|
||||
return self._state
|
||||
|
||||
def mark_in_flight(self) -> bool:
|
||||
return self._manager._mark_in_flight(self)
|
||||
|
||||
def set_delivered(self) -> bool:
|
||||
return self._manager._set_delivered(self)
|
||||
|
||||
def set_failed(self, error: BaseException) -> bool:
|
||||
return self._manager._set_failed(self, error)
|
||||
|
||||
# Compatibility with the connector mixin's completion interface.
|
||||
def set_result(self) -> bool:
|
||||
return self.set_delivered()
|
||||
|
||||
def set_error(self, error: BaseException) -> bool:
|
||||
return self.set_failed(error)
|
||||
|
||||
def wait(self) -> None:
|
||||
while True:
|
||||
with self._manager._lock:
|
||||
state = self._state
|
||||
error = self._error
|
||||
remaining = self.deadline - time.monotonic()
|
||||
if state is DeliveryState.DELIVERED:
|
||||
return
|
||||
if state in (DeliveryState.FAILED, DeliveryState.CANCELLED):
|
||||
assert error is not None
|
||||
raise error
|
||||
if remaining <= 0:
|
||||
self._manager._timeout(self)
|
||||
continue
|
||||
self._event.wait(timeout=remaining)
|
||||
|
||||
|
||||
class OmniDeliveryManager:
|
||||
"""MRv2-only connector delivery state machine.
|
||||
|
||||
A timeout or permanent delivery failure quarantines the whole manager.
|
||||
This is a stage-fatal condition: no new payload may be accepted after the
|
||||
transport can no longer prove ordered, exactly-once completion.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
delivery_timeout_s: float,
|
||||
shutdown_timeout_s: float = 5.0,
|
||||
) -> None:
|
||||
if delivery_timeout_s <= 0:
|
||||
raise ValueError("delivery_timeout_s must be positive")
|
||||
if shutdown_timeout_s <= 0:
|
||||
raise ValueError("shutdown_timeout_s must be positive")
|
||||
self.delivery_timeout_s = float(delivery_timeout_s)
|
||||
self.shutdown_timeout_s = float(shutdown_timeout_s)
|
||||
self._lock = threading.Lock()
|
||||
self._tickets: set[DeliveryTicket] = set()
|
||||
self._quarantine_error: BaseException | None = None
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def is_quarantined(self) -> bool:
|
||||
with self._lock:
|
||||
return self._quarantine_error is not None
|
||||
|
||||
def create_ticket(self, *, request_id: str, put_key: str) -> DeliveryTicket:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("MRv2 delivery manager is closed")
|
||||
if self._quarantine_error is not None:
|
||||
raise RuntimeError("MRv2 delivery manager is quarantined") from self._quarantine_error
|
||||
ticket = DeliveryTicket(
|
||||
self,
|
||||
request_id=request_id,
|
||||
put_key=put_key,
|
||||
deadline=time.monotonic() + self.delivery_timeout_s,
|
||||
)
|
||||
self._tickets.add(ticket)
|
||||
return ticket
|
||||
|
||||
def quarantine(self, error: BaseException) -> None:
|
||||
with self._lock:
|
||||
self._quarantine_locked(error, primary_ticket=None)
|
||||
|
||||
def shutdown(self, cause: BaseException | None = None) -> None:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if self._quarantine_error is None:
|
||||
self._quarantine_error = cause or RuntimeError("MRv2 stage shutdown")
|
||||
for ticket in tuple(self._tickets):
|
||||
if ticket._state in _TERMINAL_STATES:
|
||||
continue
|
||||
message = f"connector delivery cancelled for request {ticket.request_id!r}, key {ticket.put_key!r}"
|
||||
if cause is not None:
|
||||
message += f": {cause}"
|
||||
self._finish_locked(
|
||||
ticket,
|
||||
DeliveryState.CANCELLED,
|
||||
DeliveryCancelledError(message),
|
||||
)
|
||||
|
||||
def _mark_in_flight(self, ticket: DeliveryTicket) -> bool:
|
||||
with self._lock:
|
||||
if ticket._state is not DeliveryState.QUEUED:
|
||||
return False
|
||||
ticket._state = DeliveryState.IN_FLIGHT
|
||||
return True
|
||||
|
||||
def _set_delivered(self, ticket: DeliveryTicket) -> bool:
|
||||
with self._lock:
|
||||
if ticket._state in _TERMINAL_STATES:
|
||||
return False
|
||||
return self._finish_locked(ticket, DeliveryState.DELIVERED, None)
|
||||
|
||||
def _set_failed(self, ticket: DeliveryTicket, error: BaseException) -> bool:
|
||||
with self._lock:
|
||||
if ticket._state in _TERMINAL_STATES:
|
||||
return False
|
||||
self._quarantine_locked(error, primary_ticket=ticket)
|
||||
return True
|
||||
|
||||
def _timeout(self, ticket: DeliveryTicket) -> bool:
|
||||
error = DeliveryTimeoutError(
|
||||
"connector delivery timed out after "
|
||||
f"{self.delivery_timeout_s:.3f}s for request "
|
||||
f"{ticket.request_id!r}, key {ticket.put_key!r}"
|
||||
)
|
||||
with self._lock:
|
||||
if ticket._state in _TERMINAL_STATES:
|
||||
return False
|
||||
self._quarantine_locked(error, primary_ticket=ticket)
|
||||
return True
|
||||
|
||||
def _quarantine_locked(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
primary_ticket: DeliveryTicket | None,
|
||||
) -> None:
|
||||
if self._quarantine_error is None:
|
||||
self._quarantine_error = error
|
||||
stage_error = self._quarantine_error
|
||||
for ticket in tuple(self._tickets):
|
||||
if ticket._state in _TERMINAL_STATES:
|
||||
continue
|
||||
ticket_error = error if ticket is primary_ticket else stage_error
|
||||
self._finish_locked(ticket, DeliveryState.FAILED, ticket_error)
|
||||
|
||||
def _finish_locked(
|
||||
self,
|
||||
ticket: DeliveryTicket,
|
||||
state: DeliveryState,
|
||||
error: BaseException | None,
|
||||
) -> bool:
|
||||
if ticket._state in _TERMINAL_STATES:
|
||||
return False
|
||||
ticket._state = state
|
||||
ticket._error = error
|
||||
self._tickets.discard(ticket)
|
||||
ticket._event.set()
|
||||
return True
|
||||
@@ -0,0 +1,43 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Omni-aware ``init_model_state`` factory.
|
||||
|
||||
Extends the upstream v2 factory with Omni architecture dispatch.
|
||||
Non-Omni architectures fall through to the upstream ``init_model_state``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
|
||||
from vllm.v1.worker.gpu.model_states import (
|
||||
init_model_state as _upstream_init_model_state,
|
||||
)
|
||||
from vllm.v1.worker.gpu.model_states.interface import ModelState
|
||||
|
||||
|
||||
def init_omni_model_state(
|
||||
vllm_config: VllmConfig,
|
||||
model: nn.Module,
|
||||
encoder_cache: EncoderCache | None,
|
||||
device: torch.device,
|
||||
) -> ModelState:
|
||||
"""Create the appropriate ``ModelState`` for *model*.
|
||||
|
||||
Returns an ``OmniModelState`` when the model declares Omni lifecycle
|
||||
capabilities; otherwise
|
||||
delegates to the upstream v2 factory.
|
||||
"""
|
||||
uses_omni_lifecycle = any(
|
||||
getattr(model, flag, False) is True for flag in ("has_preprocess", "has_postprocess", "have_multimodal_outputs")
|
||||
)
|
||||
if uses_omni_lifecycle:
|
||||
from vllm_omni.worker_v2.model_states.omni_model_state import (
|
||||
OmniModelState,
|
||||
)
|
||||
|
||||
return OmniModelState(vllm_config, model, encoder_cache, device)
|
||||
return _upstream_init_model_state(vllm_config, model, encoder_cache, device)
|
||||
@@ -0,0 +1,265 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""OmniIntermediateBuffer — per-request cross-stage state for Omni pipelines.
|
||||
|
||||
Uses ``req_index`` (not ``req_id``) for O(1) access, aligned with v2's
|
||||
``RequestState`` slot management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from vllm.v1.core.sched.output import NewRequestData
|
||||
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||
|
||||
from vllm_omni.worker.payload_span import get_tensor_span, merge_tensor_spans
|
||||
|
||||
|
||||
def _resolve_prompt_embeds(pe: Any) -> torch.Tensor | None:
|
||||
"""Convert a prompt_embeds payload to a contiguous CPU tensor."""
|
||||
if pe is None:
|
||||
return None
|
||||
if isinstance(pe, torch.Tensor):
|
||||
return pe.detach().cpu().contiguous()
|
||||
try:
|
||||
data = getattr(pe, "data", None)
|
||||
shape = getattr(pe, "shape", None)
|
||||
if data is not None and shape is not None:
|
||||
dt = np.dtype(getattr(pe, "dtype", "float32"))
|
||||
arr = np.frombuffer(data, dtype=dt).reshape(shape)
|
||||
return torch.from_numpy(arr.copy())
|
||||
except Exception as exc:
|
||||
raise ValueError("Failed to decode prompt_embeds payload") from exc
|
||||
raise TypeError(f"Unsupported prompt_embeds payload type: {type(pe).__name__}")
|
||||
|
||||
|
||||
def _resolve_additional_information(payload: Any) -> dict[str, Any]:
|
||||
"""Convert an additional_information payload to a plain dict."""
|
||||
if payload is None:
|
||||
return {}
|
||||
try:
|
||||
from vllm_omni.data_entry_keys import unflatten_payload
|
||||
from vllm_omni.engine.serialization import deserialize_additional_information
|
||||
|
||||
info = deserialize_additional_information(payload)
|
||||
if isinstance(info, dict) and any(isinstance(key, str) and "." in key for key in info):
|
||||
info = unflatten_payload(info)
|
||||
except Exception as exc:
|
||||
raise ValueError("Failed to decode additional_information payload") from exc
|
||||
if not isinstance(info, dict):
|
||||
raise TypeError(f"Decoded additional_information must be a dict, got {type(info).__name__}")
|
||||
return info
|
||||
|
||||
|
||||
class OmniIntermediateBuffer:
|
||||
"""Per-request intermediate state for multi-stage Omni pipelines.
|
||||
|
||||
Stores ``prompt_embeds``, ``additional_information``, ``mm_features``,
|
||||
``req_id`` and any runtime updates written back by model postprocess.
|
||||
"""
|
||||
|
||||
def __init__(self, max_num_reqs: int):
|
||||
self.buffers: list[dict[str, Any]] = [{} for _ in range(max_num_reqs)]
|
||||
self.req_id_to_index: dict[str, int] = {}
|
||||
|
||||
def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
|
||||
info: dict[str, Any] = {}
|
||||
|
||||
pe = getattr(new_req_data, "prompt_embeds", None)
|
||||
if pe is not None:
|
||||
pe_cpu = _resolve_prompt_embeds(pe)
|
||||
if pe_cpu is not None:
|
||||
info["prompt_embeds_cpu"] = pe_cpu
|
||||
|
||||
ai = getattr(new_req_data, "additional_information", None)
|
||||
if ai is not None:
|
||||
info.update(_resolve_additional_information(ai))
|
||||
|
||||
if new_req_data.mm_features:
|
||||
info["mm_features"] = new_req_data.mm_features
|
||||
|
||||
sampling_params = getattr(new_req_data, "sampling_params", None)
|
||||
if sampling_params is not None:
|
||||
info["sampling_params"] = sampling_params
|
||||
|
||||
info["req_id"] = new_req_data.req_id
|
||||
previous_id = self.buffers[req_index].get("req_id")
|
||||
if previous_id is not None and self.req_id_to_index.get(previous_id) == req_index:
|
||||
self.req_id_to_index.pop(previous_id, None)
|
||||
self.buffers[req_index] = info
|
||||
self.req_id_to_index[new_req_data.req_id] = req_index
|
||||
|
||||
def remove_request(self, req_index: int) -> None:
|
||||
req_id = self.buffers[req_index].get("req_id")
|
||||
if req_id is not None and self.req_id_to_index.get(req_id) == req_index:
|
||||
self.req_id_to_index.pop(req_id)
|
||||
self.buffers[req_index] = {}
|
||||
|
||||
def gather(self, input_batch: InputBatch) -> list[dict[str, Any]]:
|
||||
"""Return buffer dicts in current batch order (via ``idx_mapping_np``)."""
|
||||
return [self.buffers[idx] for idx in input_batch.idx_mapping_np]
|
||||
|
||||
@staticmethod
|
||||
def _split_gpu_keys(gpu_resident_keys: set[Any] | None) -> tuple[set[Any], dict[Any, set[Any]]]:
|
||||
top_level: set[Any] = set()
|
||||
nested: dict[Any, set[Any]] = {}
|
||||
for key in gpu_resident_keys or set():
|
||||
if isinstance(key, tuple) and len(key) == 2:
|
||||
nested.setdefault(key[0], set()).add(key[1])
|
||||
else:
|
||||
top_level.add(key)
|
||||
return top_level, nested
|
||||
|
||||
@staticmethod
|
||||
def _store_value(dest: dict[Any, Any], key: Any, value: Any, gpu_keys: set[Any]) -> None:
|
||||
if isinstance(value, torch.Tensor):
|
||||
if key in gpu_keys:
|
||||
dest[key] = value.detach().clone()
|
||||
else:
|
||||
dest[key] = value.detach().cpu().contiguous()
|
||||
elif isinstance(value, list):
|
||||
dest[key] = [
|
||||
(
|
||||
item.detach().clone()
|
||||
if key in gpu_keys and isinstance(item, torch.Tensor)
|
||||
else item.detach().cpu().contiguous()
|
||||
if isinstance(item, torch.Tensor)
|
||||
else item
|
||||
)
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
dest[key] = value
|
||||
|
||||
def update(
|
||||
self,
|
||||
req_index: int,
|
||||
updates: dict[Any, Any],
|
||||
gpu_resident_keys: set[Any] | None = None,
|
||||
) -> None:
|
||||
"""Merge *updates* into the buffer at *req_index*.
|
||||
|
||||
Tensors are detached; those whose key is **not** in
|
||||
*gpu_resident_keys* are moved to CPU.
|
||||
"""
|
||||
if not updates:
|
||||
return
|
||||
top_gpu_keys, nested_gpu_keys = self._split_gpu_keys(gpu_resident_keys)
|
||||
existing = self.buffers[req_index]
|
||||
for k, v in updates.items():
|
||||
if isinstance(k, tuple) and len(k) == 2:
|
||||
type_key, qualifier = k
|
||||
existing_sub = existing.setdefault(type_key, {})
|
||||
if not isinstance(existing_sub, dict):
|
||||
existing_sub = {}
|
||||
existing[type_key] = existing_sub
|
||||
self._store_value(existing_sub, qualifier, v, nested_gpu_keys.get(type_key, set()))
|
||||
elif isinstance(v, dict):
|
||||
existing_sub = existing.setdefault(k, {})
|
||||
if not isinstance(existing_sub, dict):
|
||||
existing_sub = {}
|
||||
existing[k] = existing_sub
|
||||
merged_qualifiers: set[str] = set()
|
||||
if k == "embed" and isinstance(v.get("decode"), torch.Tensor):
|
||||
span_keys = {"decode_token_start", "decode_token_end"}
|
||||
if span_keys.intersection(v):
|
||||
incoming_span = get_tensor_span(
|
||||
v,
|
||||
tensor_key="decode",
|
||||
start_key="decode_token_start",
|
||||
end_key="decode_token_end",
|
||||
)
|
||||
if incoming_span is None:
|
||||
raise RuntimeError("Incoming Thinker decode delta has an invalid absolute span")
|
||||
existing_span = get_tensor_span(
|
||||
existing_sub,
|
||||
tensor_key="decode",
|
||||
start_key="decode_token_start",
|
||||
end_key="decode_token_end",
|
||||
)
|
||||
merged_span: tuple[Any, int, int] | None = incoming_span
|
||||
if existing_span is not None:
|
||||
merged_span = merge_tensor_spans(existing_span, incoming_span)
|
||||
if merged_span is None:
|
||||
raise RuntimeError(
|
||||
"Non-contiguous pending Thinker decode spans in model buffer: "
|
||||
f"existing=[{existing_span[1]}, {existing_span[2]}) "
|
||||
f"incoming=[{incoming_span[1]}, {incoming_span[2]})"
|
||||
)
|
||||
assert merged_span is not None
|
||||
merged_tensor, merged_start, merged_end = merged_span
|
||||
self._store_value(
|
||||
existing_sub,
|
||||
"decode",
|
||||
merged_tensor,
|
||||
nested_gpu_keys.get(k, set()),
|
||||
)
|
||||
existing_sub["decode_token_start"] = merged_start
|
||||
existing_sub["decode_token_end"] = merged_end
|
||||
merged_qualifiers.update({"decode", "decode_token_start", "decode_token_end"})
|
||||
for qualifier, value in v.items():
|
||||
if qualifier in merged_qualifiers:
|
||||
continue
|
||||
self._store_value(existing_sub, qualifier, value, nested_gpu_keys.get(k, set()))
|
||||
else:
|
||||
self._store_value(existing, k, v, top_gpu_keys)
|
||||
|
||||
def update_gpu_tensor_rows(
|
||||
self,
|
||||
req_indices: list[int],
|
||||
key: Any,
|
||||
values: torch.Tensor,
|
||||
*,
|
||||
keepdim: bool = True,
|
||||
) -> None:
|
||||
"""Snapshot a batch-first tensor once and retain owned row views."""
|
||||
self._store_gpu_tensor_rows(req_indices, key, values.detach().clone(), keepdim=keepdim)
|
||||
|
||||
def update_owned_gpu_tensor_rows(
|
||||
self,
|
||||
req_indices: list[int],
|
||||
key: Any,
|
||||
owned_values: torch.Tensor,
|
||||
*,
|
||||
keepdim: bool = True,
|
||||
) -> None:
|
||||
"""Store row views of an already-owned batch tensor without cloning.
|
||||
|
||||
The caller must guarantee ``owned_values`` is not written after this
|
||||
call (the model output ownership contract). Row views keep the owned storage
|
||||
alive until each request row is replaced or the slot is freed.
|
||||
"""
|
||||
self._store_gpu_tensor_rows(req_indices, key, owned_values, keepdim=keepdim)
|
||||
|
||||
def _store_gpu_tensor_rows(
|
||||
self,
|
||||
req_indices: list[int],
|
||||
key: Any,
|
||||
owned: torch.Tensor,
|
||||
*,
|
||||
keepdim: bool,
|
||||
) -> None:
|
||||
num_rows = len(req_indices)
|
||||
if owned.ndim == 0 or owned.shape[0] != num_rows:
|
||||
raise ValueError(
|
||||
"Batched GPU state update changed the request axis: "
|
||||
f"key={key!r} expected={num_rows} "
|
||||
f"actual={owned.shape[0] if owned.ndim else 0}"
|
||||
)
|
||||
|
||||
for row, req_index in enumerate(req_indices):
|
||||
existing = self.buffers[req_index]
|
||||
row_value = owned[row : row + 1] if keepdim else owned[row]
|
||||
if isinstance(key, tuple) and len(key) == 2:
|
||||
type_key, qualifier = key
|
||||
existing_sub = existing.setdefault(type_key, {})
|
||||
if not isinstance(existing_sub, dict):
|
||||
existing_sub = {}
|
||||
existing[type_key] = existing_sub
|
||||
existing_sub[qualifier] = row_value
|
||||
else:
|
||||
existing[key] = row_value
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Materialize native producer output independently of scheduler consumption."""
|
||||
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from threading import BoundedSemaphore, Lock
|
||||
from typing import Any
|
||||
|
||||
from vllm.v1.outputs import AsyncModelRunnerOutput
|
||||
|
||||
|
||||
class NativeAsyncOutput(AsyncModelRunnerOutput):
|
||||
"""Consume an ordered publication on the engine's owner thread."""
|
||||
|
||||
def __init__(self, future: Future, plane: Any, copy_event: Any) -> None:
|
||||
self._future: Future | None = future
|
||||
self._plane = plane
|
||||
self.copy_event = copy_event
|
||||
self._resolved: Future = Future()
|
||||
|
||||
def get_output(self) -> Any:
|
||||
# Like the upstream lazy AsyncOutputFuture, consumption belongs to one
|
||||
# engine thread. Cache finalization and errors for repeated consumption.
|
||||
if not self._resolved.done():
|
||||
try:
|
||||
assert self._future is not None
|
||||
output = self._future.result()
|
||||
output.omni_connector_output = self._plane.get_omni_connector_output()
|
||||
self._resolved.set_result(output)
|
||||
except Exception as error:
|
||||
self._resolved.set_exception(error)
|
||||
finally:
|
||||
if self._resolved.done():
|
||||
# Do not retain raw snapshots or a callback bound to self.
|
||||
self._future = None
|
||||
self._plane = None
|
||||
return self._resolved.result()
|
||||
|
||||
|
||||
class NativeOutputWorker:
|
||||
"""Bounded FIFO CPU materialization and native-queue publication.
|
||||
|
||||
Input outputs must have no owner-thread finalizer installed. Reservations
|
||||
and metadata writes must be complete before submit. This worker never
|
||||
updates scheduler state or drains connector notifications.
|
||||
"""
|
||||
|
||||
def __init__(self, capacity: int, device: Any | None = None) -> None:
|
||||
if capacity < 1:
|
||||
raise ValueError("Native output capacity must be positive")
|
||||
self._slots = BoundedSemaphore(capacity)
|
||||
self._lock = Lock()
|
||||
self._closed = False
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
thread_name_prefix="omni-native-materialize",
|
||||
initializer=self._initialize_device,
|
||||
initargs=(device,),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _initialize_device(device: Any | None) -> None:
|
||||
if device is not None:
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
current_platform.set_device(device)
|
||||
|
||||
@staticmethod
|
||||
def _materialize(output: AsyncModelRunnerOutput, plane: Any) -> Any:
|
||||
result = output.get_output()
|
||||
plane.enqueue_outputs(
|
||||
req_ids=list(result.req_ids),
|
||||
inter_stage_outputs=getattr(result, "inter_stage_outputs", None),
|
||||
sampled_token_ids=getattr(result, "sampled_token_ids", None),
|
||||
)
|
||||
result.inter_stage_outputs = None
|
||||
return result
|
||||
|
||||
def submit(self, output: AsyncModelRunnerOutput, plane: Any) -> NativeAsyncOutput:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("Native output worker is closed")
|
||||
self._slots.acquire()
|
||||
try:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("Native output worker is closed")
|
||||
future = self._executor.submit(self._materialize, output, plane)
|
||||
except BaseException:
|
||||
self._slots.release()
|
||||
raise
|
||||
future.add_done_callback(lambda _: self._slots.release())
|
||||
return NativeAsyncOutput(future, plane, output.copy_event)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._closed = True
|
||||
# Drain submitted publications before the native data plane is closed.
|
||||
self._executor.shutdown(wait=True)
|
||||
@@ -0,0 +1,818 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""OmniARModelRunner — autoregressive stage runner on MR V2.
|
||||
|
||||
Extends ``OmniGPUModelRunner`` with:
|
||||
|
||||
* ``OmniOutput`` post-processing in ``sample_tokens``
|
||||
* Per-request ``pooler_output`` construction (hidden + multimodal slices)
|
||||
* Async D2H copy via ``OmniAsyncOutput`` for non-blocking output transfer
|
||||
* Cross-stage KV extraction before state cleanup
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from typing import Any, cast
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.fused_moe.all2all_utils import (
|
||||
get_ep_all2all_manager,
|
||||
)
|
||||
from vllm.utils.torch_utils import PIN_MEMORY
|
||||
from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput
|
||||
from vllm.v1.outputs import AsyncModelRunnerOutput, ModelRunnerOutput, RoutedExpertsTensors
|
||||
from vllm.v1.worker.gpu.eplb_utils import step_eplb_after
|
||||
|
||||
from vllm_omni.data_entry_keys import OmniPayload, flatten_payload
|
||||
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
|
||||
OmniKVTransferManager,
|
||||
)
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||
from vllm_omni.utils.mm_outputs import partition_flat_payload
|
||||
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||
from vllm_omni.worker_v2.output_snapshot import PackedOutputSnapshot, pack_output_snapshot
|
||||
|
||||
logger = init_logger(__name__)
|
||||
_ASYNC_MM_SNAPSHOT_MAX_BUCKETS_PER_SLOT = 64
|
||||
|
||||
|
||||
def _copy_mm_to_snapshot_slot(value: Any, slot: dict[tuple[Any, ...], torch.Tensor], path: tuple[Any, ...] = ()) -> Any:
|
||||
if not path and isinstance(value, dict):
|
||||
packed = pack_output_snapshot(value, slot, max_buckets=_ASYNC_MM_SNAPSHOT_MAX_BUCKETS_PER_SLOT)
|
||||
if packed is not None:
|
||||
return packed
|
||||
if isinstance(value, torch.Tensor):
|
||||
bucket_key = path + (tuple(value.shape), value.dtype, value.device)
|
||||
cached = slot.get(bucket_key)
|
||||
if cached is None:
|
||||
if len(slot) >= _ASYNC_MM_SNAPSHOT_MAX_BUCKETS_PER_SLOT:
|
||||
return value.detach().clone()
|
||||
cached = torch.empty_like(value)
|
||||
slot[bucket_key] = cached
|
||||
cached.copy_(value)
|
||||
return cached
|
||||
if isinstance(value, dict):
|
||||
return {key: _copy_mm_to_snapshot_slot(item, slot, path + (key,)) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_copy_mm_to_snapshot_slot(item, slot, path + (index,)) for index, item in enumerate(value)]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_copy_mm_to_snapshot_slot(item, slot, path + (index,)) for index, item in enumerate(value))
|
||||
return value
|
||||
|
||||
|
||||
def _has_cuda_tensor(value: Any) -> bool:
|
||||
if isinstance(value, torch.Tensor):
|
||||
return value.device.type == "cuda"
|
||||
if isinstance(value, dict):
|
||||
return any(_has_cuda_tensor(item) for item in value.values())
|
||||
if isinstance(value, (list, tuple)):
|
||||
return any(_has_cuda_tensor(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _guard_graph_replay_for_pooler_copy(
|
||||
main_stream: Any,
|
||||
copy_event: Any,
|
||||
*,
|
||||
need_pooler: bool,
|
||||
async_chunk: bool,
|
||||
) -> None:
|
||||
"""Keep static graph outputs alive until non-snapshotted D2H completes."""
|
||||
if need_pooler and not async_chunk:
|
||||
main_stream.wait_event(copy_event)
|
||||
|
||||
|
||||
class OmniARModelRunner(OmniGPUModelRunner):
|
||||
"""AR stage runner. Produces per-request hidden states + multimodal outputs."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.kv_transfer_manager: OmniKVTransferManager | None = None
|
||||
self._kv_extracted_req_ids: list[str] | None = None
|
||||
self._async_mm_snapshot_slots: list[dict[tuple[Any, ...], torch.Tensor]] = [{} for _ in range(4)]
|
||||
self._async_mm_snapshot_events: list[torch.cuda.Event | None] = [None] * 4
|
||||
self._async_mm_snapshot_pending = [False] * 4
|
||||
self._async_mm_snapshot_cursor = 0
|
||||
self._last_multimodal_snapshot_slot: int | None = None
|
||||
|
||||
def _ensure_kv_transfer_manager(self) -> OmniKVTransferManager:
|
||||
if self.kv_transfer_manager is None:
|
||||
self.kv_transfer_manager = OmniKVTransferManager.from_vllm_config(self.vllm_config, self.model_config)
|
||||
return self.kv_transfer_manager
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# execute_model: KV transfer pre-hook + delegate to super
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def execute_model(
|
||||
self,
|
||||
scheduler_output: SchedulerOutput,
|
||||
intermediate_tensors: Any | None = None,
|
||||
dummy_run: bool = False,
|
||||
skip_attn_for_dummy_run: bool = False,
|
||||
is_profile: bool = False,
|
||||
context_len: int = 0,
|
||||
) -> Any:
|
||||
if not dummy_run:
|
||||
self._handle_kv_transfer_pre(scheduler_output)
|
||||
return super().execute_model(
|
||||
scheduler_output,
|
||||
intermediate_tensors,
|
||||
dummy_run=dummy_run,
|
||||
skip_attn_for_dummy_run=skip_attn_for_dummy_run,
|
||||
is_profile=is_profile,
|
||||
context_len=context_len,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# sample_tokens: OmniOutput handling + pooler_output + async D2H
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@torch.inference_mode()
|
||||
@step_eplb_after()
|
||||
def sample_tokens(
|
||||
self, grammar_output: GrammarOutput | None
|
||||
) -> OmniAsyncOutput | OmniModelRunnerOutput | ModelRunnerOutput | None:
|
||||
kv_extracted = self._kv_extracted_req_ids
|
||||
self._kv_extracted_req_ids = None
|
||||
|
||||
if self.execute_model_state is None:
|
||||
return None
|
||||
|
||||
input_batch = self.execute_model_state.input_batch
|
||||
hidden_states = self.execute_model_state.hidden_states
|
||||
finished_req_ids = self.execute_model_state.finished_req_ids
|
||||
ec_connector_output = self.execute_model_state.ec_connector_output
|
||||
routed_experts = self.execute_model_state.routed_experts
|
||||
self.execute_model_state = None
|
||||
|
||||
# --- Omni: reconstruct raw model output and post-process ---
|
||||
aux = self._last_aux_output
|
||||
self._last_aux_output = None
|
||||
multimodal_outputs = self._last_multimodal_outputs
|
||||
self._last_multimodal_outputs = None
|
||||
snapshot_slot = None
|
||||
self._last_multimodal_snapshot_slot = None
|
||||
raw_output = self._reconstruct_raw_model_output(
|
||||
hidden_states=hidden_states,
|
||||
multimodal_outputs=multimodal_outputs,
|
||||
aux=aux,
|
||||
)
|
||||
text_hidden, multimodal_outputs = self.model_state.postprocess_model_output(
|
||||
raw_output, input_batch, self.req_states
|
||||
)
|
||||
if bool(getattr(self.model_config, "async_chunk", False)) and multimodal_outputs:
|
||||
multimodal_outputs = self._retain_multimodal_outputs(multimodal_outputs)
|
||||
snapshot_slot = self._last_multimodal_snapshot_slot
|
||||
self._last_multimodal_snapshot_slot = None
|
||||
|
||||
# --- Standard v2 sampling ---
|
||||
sampling_context = nullcontext()
|
||||
model_sampling_context = getattr(self.model, "mrv2_sampling_context", None)
|
||||
if callable(model_sampling_context) and getattr(self.model, "logitsprocs_need_output_token_ids", False):
|
||||
# seq_lens includes this forward's input, but not its sampled token:
|
||||
# final prefill has zero prior outputs; the first decode has one.
|
||||
sampling_context = model_sampling_context(
|
||||
req_ids=input_batch.req_ids,
|
||||
num_output_tokens=(
|
||||
input_batch.seq_lens[: input_batch.num_reqs]
|
||||
- self.req_states.prompt_len.gpu[input_batch.idx_mapping]
|
||||
),
|
||||
)
|
||||
with sampling_context:
|
||||
sampler_output, num_sampled, num_rejected = self.sample(
|
||||
text_hidden,
|
||||
input_batch,
|
||||
grammar_output,
|
||||
)
|
||||
if self.pp_handler is not None:
|
||||
self.pp_handler.broadcast(
|
||||
sampler_output.sampled_token_ids,
|
||||
num_sampled,
|
||||
num_rejected,
|
||||
input_batch,
|
||||
)
|
||||
|
||||
# --- Omni: prompt logprobs ---
|
||||
assert self.prompt_logprobs_worker is not None
|
||||
# Mirror the current parent GPUModelRunner call exactly.
|
||||
prompt_logprobs_dict = self.prompt_logprobs_worker.compute_prompt_logprobs(
|
||||
self.model.compute_logits,
|
||||
text_hidden,
|
||||
input_batch,
|
||||
self.req_states.all_token_ids.gpu,
|
||||
self.req_states.num_computed_tokens.gpu,
|
||||
self.req_states.prompt_len.np,
|
||||
)
|
||||
|
||||
# --- Omni: pooler_output ---
|
||||
engine_output_type = getattr(self.vllm_config.model_config, "engine_output_type", "text")
|
||||
need_pooler = engine_output_type != "text"
|
||||
|
||||
# --- Build base output ---
|
||||
model_runner_output = OmniModelRunnerOutput(
|
||||
req_ids=input_batch.req_ids,
|
||||
req_id_to_index={rid: i for i, rid in enumerate(input_batch.req_ids)},
|
||||
sampled_token_ids=None, # type: ignore[arg-type]
|
||||
prompt_logprobs_dict=prompt_logprobs_dict,
|
||||
kv_connector_output=None,
|
||||
)
|
||||
model_runner_output.kv_extracted_req_ids = kv_extracted
|
||||
model_runner_output._async_chunk = bool(getattr(self.model_config, "async_chunk", False))
|
||||
|
||||
# --- Async D2H via OmniAsyncOutput ---
|
||||
materialize_native = self._uses_native_output_materializer()
|
||||
async_output = OmniAsyncOutput(
|
||||
model_runner_output=model_runner_output,
|
||||
sampler_output=sampler_output,
|
||||
num_sampled_tokens=num_sampled,
|
||||
main_stream=self.main_stream,
|
||||
copy_stream=self.output_copy_stream,
|
||||
text_hidden=text_hidden if need_pooler else None,
|
||||
multimodal_outputs=multimodal_outputs if need_pooler else None,
|
||||
input_batch=input_batch if need_pooler else None,
|
||||
async_chunk=bool(getattr(self.model_config, "async_chunk", False)),
|
||||
finalize_output=(None if materialize_native else self._finalize_native_data_plane_output),
|
||||
check_ep_fault=self.check_ep_fault,
|
||||
routed_experts=routed_experts,
|
||||
)
|
||||
self._release_multimodal_snapshot(snapshot_slot, async_output.copy_event)
|
||||
_guard_graph_replay_for_pooler_copy(
|
||||
self.main_stream,
|
||||
async_output.copy_event,
|
||||
need_pooler=need_pooler,
|
||||
async_chunk=bool(getattr(self.model_config, "async_chunk", False)),
|
||||
)
|
||||
|
||||
# Postprocess AFTER creating async output (so copy_event is
|
||||
# recorded before postprocess, matching upstream pattern).
|
||||
self.postprocess_sampled(
|
||||
input_batch.idx_mapping,
|
||||
sampler_output.sampled_token_ids,
|
||||
num_sampled,
|
||||
num_rejected,
|
||||
input_batch.query_start_loc,
|
||||
)
|
||||
model_runner_output.kv_connector_output = self.kv_connector.post_forward(finished_req_ids)
|
||||
model_runner_output.ec_connector_output = ec_connector_output
|
||||
|
||||
self._reserve_native_data_plane_outputs(list(model_runner_output.req_ids))
|
||||
if materialize_native:
|
||||
return self._materialize_native_output(async_output)
|
||||
return async_output
|
||||
|
||||
def _retain_multimodal_outputs(self, outputs: dict[str, Any]) -> dict[str, Any]:
|
||||
if not bool(getattr(self.model_config, "async_chunk", False)) or not outputs:
|
||||
return outputs
|
||||
slot_index = self._async_mm_snapshot_cursor
|
||||
if self._async_mm_snapshot_pending[slot_index]:
|
||||
raise RuntimeError("Async multimodal snapshot ring exhausted before sample_tokens().")
|
||||
event = self._async_mm_snapshot_events[slot_index]
|
||||
if event is not None:
|
||||
self.main_stream.wait_event(event)
|
||||
if _has_cuda_tensor(outputs):
|
||||
with torch.cuda.stream(self.main_stream):
|
||||
snapshot = _copy_mm_to_snapshot_slot(outputs, self._async_mm_snapshot_slots[slot_index])
|
||||
else:
|
||||
snapshot = _copy_mm_to_snapshot_slot(outputs, self._async_mm_snapshot_slots[slot_index])
|
||||
self._async_mm_snapshot_pending[slot_index] = True
|
||||
self._last_multimodal_snapshot_slot = slot_index
|
||||
self._async_mm_snapshot_cursor = (slot_index + 1) % len(self._async_mm_snapshot_slots)
|
||||
return snapshot
|
||||
|
||||
def _release_multimodal_snapshot(self, slot_index: int | None, copy_event: torch.cuda.Event) -> None:
|
||||
if slot_index is None:
|
||||
return
|
||||
self._async_mm_snapshot_events[slot_index] = copy_event
|
||||
self._async_mm_snapshot_pending[slot_index] = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# pooler_output construction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _reconstruct_raw_model_output(
|
||||
*,
|
||||
hidden_states: torch.Tensor,
|
||||
multimodal_outputs: dict[str, Any] | None,
|
||||
aux: Any | None,
|
||||
) -> Any:
|
||||
if multimodal_outputs:
|
||||
return OmniOutput(
|
||||
text_hidden_states=hidden_states,
|
||||
multimodal_outputs=cast(OmniPayload, multimodal_outputs),
|
||||
)
|
||||
if aux is not None:
|
||||
return (hidden_states, aux)
|
||||
return hidden_states
|
||||
|
||||
@staticmethod
|
||||
def _build_pooler_output_from_cpu(
|
||||
hidden_cpu: torch.Tensor,
|
||||
mm_cpu: dict[str, Any],
|
||||
query_start_loc_np: np.ndarray,
|
||||
num_scheduled_tokens: np.ndarray,
|
||||
num_reqs: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build pooler_output from already-CPU tensors."""
|
||||
total = hidden_cpu.shape[0]
|
||||
pooler: list[dict[str, Any]] = []
|
||||
for i in range(num_reqs):
|
||||
start = int(query_start_loc_np[i])
|
||||
end = start + int(num_scheduled_tokens[i])
|
||||
payload: dict[str, Any] = {"hidden": hidden_cpu[start:end].clone()}
|
||||
for k, v in mm_cpu.items():
|
||||
payload[k] = _slice_pooler_value(
|
||||
v,
|
||||
req_index=i,
|
||||
start=start,
|
||||
end=end,
|
||||
total_tokens=total,
|
||||
)
|
||||
pooler.append(flatten_payload(payload))
|
||||
return pooler
|
||||
|
||||
@staticmethod
|
||||
def _build_async_chunk_outputs_from_mm(
|
||||
mm_outputs: dict[str, Any],
|
||||
query_start_loc_np: np.ndarray,
|
||||
num_scheduled_tokens: np.ndarray,
|
||||
num_reqs: int,
|
||||
total_tokens: int,
|
||||
padded_total_tokens: int | None = None,
|
||||
) -> tuple[list[dict[str, Any] | None] | None, list[dict[str, Any] | None] | None]:
|
||||
"""Build async-chunk payloads without materializing hidden on CPU."""
|
||||
if not mm_outputs:
|
||||
return None, None
|
||||
|
||||
inter_stage_list: list[dict[str, Any] | None] = []
|
||||
client_mm_list: list[dict[str, Any] | None] = []
|
||||
for i in range(num_reqs):
|
||||
start = int(query_start_loc_np[i])
|
||||
end = start + int(num_scheduled_tokens[i])
|
||||
payload = {
|
||||
key: _slice_pooler_value(
|
||||
value,
|
||||
req_index=i,
|
||||
start=start,
|
||||
end=end,
|
||||
total_tokens=total_tokens,
|
||||
padded_total_tokens=padded_total_tokens,
|
||||
)
|
||||
for key, value in mm_outputs.items()
|
||||
}
|
||||
inter_stage, client_mm = partition_flat_payload(flatten_payload(payload))
|
||||
inter_stage_list.append(inter_stage or None)
|
||||
client_mm_list.append(client_mm or None)
|
||||
|
||||
return (
|
||||
None if all(item is None for item in inter_stage_list) else inter_stage_list,
|
||||
None if all(item is None for item in client_mm_list) else client_mm_list,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# KV transfer
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_kv_transfer_pre(self, scheduler_output: SchedulerOutput) -> None:
|
||||
finished: dict = getattr(scheduler_output, "finished_requests_needing_kv_transfer", {})
|
||||
if not finished:
|
||||
return
|
||||
|
||||
kv_caches = getattr(self, "kv_caches", None)
|
||||
if kv_caches is None:
|
||||
return
|
||||
|
||||
if hasattr(self.model, "get_kv_transfer_metadata"):
|
||||
for req_id, data in finished.items():
|
||||
try:
|
||||
meta = self.model.get_kv_transfer_metadata(req_id)
|
||||
if meta:
|
||||
existing = data.get("custom_metadata") or {}
|
||||
existing.update(meta)
|
||||
data["custom_metadata"] = existing
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to get KV transfer metadata for %s",
|
||||
req_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
mgr = self._ensure_kv_transfer_manager()
|
||||
self._kv_extracted_req_ids = mgr.handle_finished_requests_kv_transfer(
|
||||
finished_reqs=finished,
|
||||
kv_caches=kv_caches,
|
||||
block_size=self.cache_config.block_size,
|
||||
cache_dtype=str(self.cache_config.cache_dtype),
|
||||
request_id_resolver=self._resolve_global_request_id,
|
||||
)
|
||||
|
||||
def _resolve_global_request_id(self, req_id: str) -> str:
|
||||
req_idx = self.req_states.req_id_to_index.get(req_id)
|
||||
if req_idx is None:
|
||||
return req_id
|
||||
info = self.model_state.intermediate_buffer.buffers[req_idx]
|
||||
global_id = info.get("global_request_id")
|
||||
if isinstance(global_id, list):
|
||||
global_id = global_id[0] if global_id else None
|
||||
if isinstance(global_id, bytes):
|
||||
return global_id.decode("utf-8")
|
||||
return str(global_id) if global_id else req_id
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# OmniAsyncOutput — async D2H for Omni AR outputs
|
||||
# ======================================================================
|
||||
|
||||
|
||||
def _async_copy_to_np(
|
||||
x: torch.Tensor,
|
||||
*,
|
||||
copy_stream: torch.cuda.Stream | None = None,
|
||||
pin_memory: bool | None = None,
|
||||
) -> np.ndarray:
|
||||
return _async_copy_tensor(
|
||||
x,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
).numpy()
|
||||
|
||||
|
||||
def _async_copy_tensor(
|
||||
x: torch.Tensor,
|
||||
*,
|
||||
copy_stream: torch.cuda.Stream | None = None,
|
||||
pin_memory: bool | None = None,
|
||||
) -> torch.Tensor:
|
||||
x = x.detach()
|
||||
if x.device.type == "cpu":
|
||||
return x.clone()
|
||||
if pin_memory is None:
|
||||
pin_memory = PIN_MEMORY
|
||||
cpu = torch.empty_like(x, device="cpu", pin_memory=pin_memory)
|
||||
cpu.copy_(x, non_blocking=pin_memory)
|
||||
if copy_stream is None:
|
||||
copy_stream = torch.cuda.current_stream()
|
||||
x.record_stream(copy_stream)
|
||||
return cpu
|
||||
|
||||
|
||||
def _async_copy_mm_value(
|
||||
value: Any,
|
||||
*,
|
||||
copy_stream: torch.cuda.Stream | None = None,
|
||||
pin_memory: bool | None = None,
|
||||
) -> Any:
|
||||
if isinstance(value, torch.Tensor):
|
||||
return _async_copy_tensor(
|
||||
value,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _async_copy_mm_value(
|
||||
val,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
for key, val in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
_async_copy_mm_value(
|
||||
val,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
for val in value
|
||||
]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(
|
||||
_async_copy_mm_value(
|
||||
val,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
for val in value
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _async_copy_mm(
|
||||
mm_outputs: dict | None,
|
||||
total_tokens: int,
|
||||
*,
|
||||
copy_stream: torch.cuda.Stream | None = None,
|
||||
pin_memory: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Non-blocking D2H copy of multimodal output tensors."""
|
||||
if not mm_outputs:
|
||||
return {}
|
||||
if isinstance(mm_outputs, PackedOutputSnapshot):
|
||||
return mm_outputs.copy_to_cpu(
|
||||
lambda tensor: _async_copy_tensor(tensor, copy_stream=copy_stream, pin_memory=pin_memory)
|
||||
)
|
||||
return {
|
||||
key: _async_copy_mm_value(
|
||||
value,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
for key, value in mm_outputs.items()
|
||||
}
|
||||
|
||||
|
||||
def _slice_pooler_value(
|
||||
value: Any,
|
||||
*,
|
||||
req_index: int,
|
||||
start: int,
|
||||
end: int,
|
||||
total_tokens: int,
|
||||
padded_total_tokens: int | None = None,
|
||||
request_scoped: bool = False,
|
||||
) -> Any:
|
||||
if isinstance(value, torch.Tensor):
|
||||
token_axis_sizes = {total_tokens}
|
||||
if padded_total_tokens is not None:
|
||||
token_axis_sizes.add(padded_total_tokens)
|
||||
if not request_scoped and value.dim() > 0 and value.shape[0] in token_axis_sizes:
|
||||
return value[start:end].contiguous()
|
||||
return value.clone()
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _slice_pooler_value(
|
||||
val,
|
||||
req_index=req_index,
|
||||
start=start,
|
||||
end=end,
|
||||
total_tokens=total_tokens,
|
||||
padded_total_tokens=padded_total_tokens,
|
||||
request_scoped=request_scoped,
|
||||
)
|
||||
for key, val in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
return []
|
||||
# Lists at the batch level contain request-owned payloads. Their
|
||||
# tensors use local axes even when a size coincides with batch tokens.
|
||||
values = value if request_scoped else [value[req_index] if req_index < len(value) else value[0]]
|
||||
owned = [
|
||||
_slice_pooler_value(
|
||||
item,
|
||||
req_index=req_index,
|
||||
start=start,
|
||||
end=end,
|
||||
total_tokens=total_tokens,
|
||||
request_scoped=True,
|
||||
)
|
||||
for item in values
|
||||
]
|
||||
return owned if request_scoped else owned[0]
|
||||
return value
|
||||
|
||||
|
||||
def _ensure_tensor_values(payload: dict[str, Any]) -> dict[str, torch.Tensor]:
|
||||
"""Convert a flattened payload to strictly ``dict[str, torch.Tensor]``.
|
||||
|
||||
Non-tensor scalars (int/float/bool) are wrapped with ``torch.tensor()``;
|
||||
values that cannot be safely converted are dropped. Enforces the tensor-only
|
||||
invariant required by ``OmniEngineCoreOutput.multimodal_output`` (the channel
|
||||
the async_chunk stage-input processor reads). Mirrors the V1 runner helper.
|
||||
"""
|
||||
result: dict[str, torch.Tensor] = {}
|
||||
for key, val in payload.items():
|
||||
if isinstance(val, torch.Tensor):
|
||||
result[key] = val
|
||||
elif isinstance(val, (int, float, bool)):
|
||||
result[key] = torch.tensor(val)
|
||||
elif isinstance(val, (list, tuple)):
|
||||
try:
|
||||
result[key] = torch.tensor(val)
|
||||
except (ValueError, TypeError, RuntimeError):
|
||||
logger.warning(
|
||||
"Dropping non-tensorizable multimodal output key '%s' (type=%s).",
|
||||
key,
|
||||
type(val).__name__,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Dropping non-tensor multimodal output key '%s' (type=%s).",
|
||||
key,
|
||||
type(val).__name__,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class OmniAsyncOutput(AsyncModelRunnerOutput):
|
||||
"""Async D2H copy for Omni AR model outputs.
|
||||
|
||||
Mirrors upstream ``AsyncOutput`` but additionally handles
|
||||
``pooler_output`` (hidden states + multimodal outputs) via
|
||||
non-blocking copies on the copy stream.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_runner_output: OmniModelRunnerOutput,
|
||||
sampler_output: Any,
|
||||
num_sampled_tokens: torch.Tensor,
|
||||
main_stream: torch.cuda.Stream,
|
||||
copy_stream: torch.cuda.Stream,
|
||||
copy_event: torch.cuda.Event | None = None,
|
||||
text_hidden: torch.Tensor | None = None,
|
||||
multimodal_outputs: dict | None = None,
|
||||
input_batch: Any | None = None,
|
||||
async_chunk: bool = False,
|
||||
finalize_output: Any | None = None,
|
||||
check_ep_fault: bool = False,
|
||||
routed_experts: RoutedExpertsTensors | None = None,
|
||||
):
|
||||
self.model_runner_output = model_runner_output
|
||||
self.sampler_output = sampler_output
|
||||
self.num_sampled_tokens = num_sampled_tokens
|
||||
self.routed_experts = routed_experts
|
||||
self.copy_event = copy_event if copy_event is not None else torch.cuda.Event(blocking=True)
|
||||
self._async_chunk = bool(async_chunk)
|
||||
self._finalize_output = finalize_output
|
||||
self._has_fault: torch.Tensor | None = None
|
||||
|
||||
# Snapshot input_batch metadata needed for pooler_output slicing
|
||||
self._need_pooler = text_hidden is not None or (self._async_chunk and bool(multimodal_outputs))
|
||||
self._query_start_loc_np: np.ndarray | None = None
|
||||
self._num_scheduled_tokens: np.ndarray | None = None
|
||||
self._num_reqs: int = 0
|
||||
self._total_tokens: int = 0
|
||||
self._padded_total_tokens: int = 0
|
||||
if self._need_pooler and input_batch is not None:
|
||||
self._query_start_loc_np = input_batch.query_start_loc_np.copy()
|
||||
self._num_scheduled_tokens = np.array(input_batch.num_scheduled_tokens, dtype=np.int32)
|
||||
self._num_reqs = input_batch.num_reqs
|
||||
if self._query_start_loc_np.shape[0] > self._num_reqs:
|
||||
self._total_tokens = int(self._query_start_loc_np[self._num_reqs])
|
||||
else:
|
||||
self._total_tokens = int(self._num_scheduled_tokens[: self._num_reqs].sum())
|
||||
self._padded_total_tokens = int(getattr(input_batch, "num_tokens_after_padding", self._total_tokens))
|
||||
|
||||
# Perform all D2H copies on the copy stream (non-blocking).
|
||||
import contextlib
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _stream(to_stream, from_stream):
|
||||
try:
|
||||
torch.cuda.set_stream(to_stream)
|
||||
yield
|
||||
finally:
|
||||
torch.cuda.set_stream(from_stream)
|
||||
|
||||
with _stream(copy_stream, main_stream):
|
||||
copy_stream.wait_stream(main_stream)
|
||||
pin_memory = PIN_MEMORY
|
||||
|
||||
# Sampled token ids
|
||||
self.sampled_token_ids_np = _async_copy_to_np(
|
||||
sampler_output.sampled_token_ids,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
self.num_sampled_tokens_np = _async_copy_to_np(
|
||||
num_sampled_tokens,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
self.sampling_mask_tensors = None
|
||||
if sampler_output.sampling_mask_tensors is not None:
|
||||
self.sampling_mask_tensors = sampler_output.sampling_mask_tensors.to_cpu_nonblocking()
|
||||
self.routed_experts_cpu = None
|
||||
if routed_experts is not None:
|
||||
self.routed_experts_cpu = routed_experts.to_cpu_nonblocking()
|
||||
|
||||
# Logprobs
|
||||
self.logprobs_tensors = None
|
||||
if sampler_output.logprobs_tensors is not None:
|
||||
self.logprobs_tensors = sampler_output.logprobs_tensors.to_cpu_nonblocking()
|
||||
self.num_nans: np.ndarray | None = None
|
||||
if sampler_output.num_nans is not None:
|
||||
self.num_nans = _async_copy_to_np(
|
||||
sampler_output.num_nans,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
|
||||
# Prompt logprobs
|
||||
self.prompt_logprobs_dict = {
|
||||
k: v.to_cpu_nonblocking() if v is not None else None
|
||||
for k, v in self.model_runner_output.prompt_logprobs_dict.items()
|
||||
}
|
||||
if check_ep_fault:
|
||||
has_fault = get_ep_all2all_manager().query_fault()
|
||||
self._has_fault = has_fault.to("cpu", non_blocking=True)
|
||||
|
||||
# Pooler output (hidden + multimodal) — async D2H
|
||||
self._hidden_cpu: torch.Tensor | None = None
|
||||
self._mm_cpu: dict[str, Any] = {}
|
||||
self._mm_snapshot: dict[str, Any] = {}
|
||||
if self._need_pooler and self._async_chunk:
|
||||
# CUDA graph replay reuses the model's output buffers. Take
|
||||
# ownership directly in pinned host memory on the output copy
|
||||
# stream so deferred finalization never performs a blocking
|
||||
# D2H copy on the runner thread.
|
||||
self._mm_snapshot = _async_copy_mm(
|
||||
multimodal_outputs,
|
||||
self._total_tokens,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
elif self._need_pooler and text_hidden is not None:
|
||||
self._hidden_cpu = _async_copy_tensor(
|
||||
text_hidden,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
total_tokens = text_hidden.shape[0]
|
||||
self._mm_cpu = _async_copy_mm(
|
||||
multimodal_outputs,
|
||||
total_tokens,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
|
||||
self.copy_event.record(copy_stream)
|
||||
|
||||
def get_output(self) -> OmniModelRunnerOutput:
|
||||
self.copy_event.synchronize()
|
||||
|
||||
# Sampled token ids
|
||||
sampled_token_ids: list[list[int]] = self.sampled_token_ids_np.tolist()
|
||||
num_sampled_tokens: list[int] = self.num_sampled_tokens_np.tolist()
|
||||
for token_ids, num_tokens in zip(sampled_token_ids, num_sampled_tokens):
|
||||
del token_ids[num_tokens:]
|
||||
self.model_runner_output.sampled_token_ids = sampled_token_ids
|
||||
if self.sampling_mask_tensors is not None:
|
||||
self.model_runner_output.sampling_masks = self.sampling_mask_tensors.tolists(self.num_sampled_tokens_np)
|
||||
if self.routed_experts_cpu is not None:
|
||||
self.model_runner_output.routed_experts = self.routed_experts_cpu.tolists()
|
||||
self.model_runner_output.sampled_token_ids_materialized = True
|
||||
|
||||
# Logprobs
|
||||
if self.num_nans is not None:
|
||||
self.model_runner_output.num_nans_in_logits = dict(
|
||||
zip(self.model_runner_output.req_ids, self.num_nans.tolist())
|
||||
)
|
||||
if self.logprobs_tensors is not None:
|
||||
self.model_runner_output.logprobs = self.logprobs_tensors.tolists()
|
||||
self.model_runner_output.prompt_logprobs_dict = self.prompt_logprobs_dict
|
||||
|
||||
if self._has_fault is not None and self._has_fault.item():
|
||||
mask = get_ep_all2all_manager().query_active_mask()
|
||||
raise RuntimeError(
|
||||
"Fault detected in EP all2all communication: one or more ranks "
|
||||
f"timed out during dispatch/combine. Mask: {mask.cpu().tolist()}"
|
||||
)
|
||||
|
||||
# Pooler output. Populate two channels from the same per-request payloads,
|
||||
# mirroring the V1 runner:
|
||||
# * pooler_output -> sync/full-payload path (inline pooling_output bridge)
|
||||
# * multimodal_outputs -> wire multimodal_output, which the async_chunk
|
||||
# stage-input processor (talker2code2wav_async_chunk) reads for codes.
|
||||
if self._need_pooler and self._async_chunk:
|
||||
pooler_inter, pooler_client = OmniARModelRunner._build_async_chunk_outputs_from_mm(
|
||||
self._mm_snapshot,
|
||||
self._query_start_loc_np,
|
||||
self._num_scheduled_tokens,
|
||||
self._num_reqs,
|
||||
self._total_tokens,
|
||||
self._padded_total_tokens,
|
||||
)
|
||||
self.model_runner_output.pooler_output = None
|
||||
self.model_runner_output.inter_stage_outputs = pooler_inter
|
||||
self.model_runner_output.multimodal_outputs = (
|
||||
[_ensure_tensor_values(_async_copy_mm_value(p)) if p else {} for p in pooler_client]
|
||||
if pooler_client
|
||||
else None
|
||||
)
|
||||
elif self._need_pooler and self._hidden_cpu is not None:
|
||||
pooler_output = OmniARModelRunner._build_pooler_output_from_cpu(
|
||||
self._hidden_cpu,
|
||||
self._mm_cpu,
|
||||
self._query_start_loc_np,
|
||||
self._num_scheduled_tokens,
|
||||
self._num_reqs,
|
||||
)
|
||||
pooler_payload = cast(list[dict[str, Any] | None], pooler_output) if pooler_output else None
|
||||
self.model_runner_output.pooler_output = pooler_payload
|
||||
self.model_runner_output.inter_stage_outputs = pooler_payload
|
||||
self.model_runner_output.multimodal_outputs = (
|
||||
[_ensure_tensor_values(p) if p else {} for p in pooler_payload] if pooler_payload else None
|
||||
)
|
||||
|
||||
if self._finalize_output is not None:
|
||||
return self._finalize_output(self.model_runner_output)
|
||||
return self.model_runner_output
|
||||
@@ -0,0 +1,604 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from dataclasses import dataclass, field
|
||||
from queue import Full, Queue
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.data_entry_keys import OmniPayload, unflatten_payload
|
||||
from vllm_omni.worker.omni_connector_model_runner_mixin import (
|
||||
OmniConnectorModelRunnerMixin,
|
||||
)
|
||||
from vllm_omni.worker.payload_span import get_tensor_span, merge_tensor_spans
|
||||
from vllm_omni.worker_v2.delivery import (
|
||||
DeliveryTimeoutError,
|
||||
OmniDeliveryManager,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _NativeRequestState:
|
||||
request_id: str
|
||||
external_req_id: str
|
||||
prompt_token_ids: list[int]
|
||||
additional_information: Any = None
|
||||
sampling_params: Any = None
|
||||
num_computed_tokens: int = 0
|
||||
resumable: bool = False
|
||||
output_token_ids: list[int] = field(default_factory=list)
|
||||
finished: bool = False
|
||||
|
||||
def snapshot(self, *, include_token_history: bool) -> SimpleNamespace:
|
||||
prompt = list(self.prompt_token_ids) if include_token_history else []
|
||||
output = list(self.output_token_ids) if include_token_history else []
|
||||
finished = self.finished
|
||||
request = SimpleNamespace(
|
||||
request_id=self.request_id,
|
||||
req_id=self.request_id,
|
||||
external_req_id=self.external_req_id,
|
||||
prompt_token_ids=prompt,
|
||||
output_token_ids=output,
|
||||
all_token_ids=prompt + output,
|
||||
output_token_count=len(self.output_token_ids),
|
||||
additional_information=self.additional_information,
|
||||
sampling_params=self.sampling_params,
|
||||
num_computed_tokens=self.num_computed_tokens,
|
||||
resumable=self.resumable,
|
||||
)
|
||||
request.is_finished = lambda: finished
|
||||
return request
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _NativeOutputBatch:
|
||||
req_ids: list[str]
|
||||
inter_stage_outputs: list[Any | None] | None
|
||||
sampled_token_ids: list[list[int]] | None
|
||||
|
||||
|
||||
_NATIVE_OUTPUT_STOP = object()
|
||||
_NATIVE_OUTPUT_QUEUE_DEPTH = 8
|
||||
_DEFAULT_DELIVERY_TIMEOUT_S = 30.0
|
||||
_DEFAULT_SHUTDOWN_TIMEOUT_S = 5.0
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class OmniRunnerDataPlane(OmniConnectorModelRunnerMixin):
|
||||
"""MRv2-owned stage transport and request-side payload state."""
|
||||
|
||||
def __init__(self, vllm_config: Any, model_config: Any) -> None:
|
||||
self._native_requests: dict[str, _NativeRequestState] = {}
|
||||
self._native_output_lock = threading.RLock()
|
||||
self._native_send_lock = threading.Lock()
|
||||
self._native_outputs_in_flight: dict[str, int] = defaultdict(int)
|
||||
self._native_terminal_pending: set[str] = set()
|
||||
self.init_omni_connectors(model_config=model_config)
|
||||
self._delivery_manager = OmniDeliveryManager(
|
||||
delivery_timeout_s=self._connector_delivery_timeout(),
|
||||
shutdown_timeout_s=_DEFAULT_SHUTDOWN_TIMEOUT_S,
|
||||
)
|
||||
self._can_send = self._custom_process_func is not None
|
||||
self._start_output_worker(max_pending_batches=_NATIVE_OUTPUT_QUEUE_DEPTH)
|
||||
|
||||
def _connector_delivery_timeout(self) -> float:
|
||||
config = getattr(getattr(self, "_omni_connector", None), "config", None)
|
||||
extra = config.get("extra", {}) if isinstance(config, dict) else {}
|
||||
value = extra.get("delivery_timeout_s", _DEFAULT_DELIVERY_TIMEOUT_S)
|
||||
try:
|
||||
timeout = float(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(f"invalid connector delivery_timeout_s: {value!r}") from error
|
||||
if timeout <= 0:
|
||||
raise ValueError("connector delivery_timeout_s must be positive")
|
||||
return timeout
|
||||
|
||||
def _create_send_completion(self, *, request_id: str, put_key: str) -> Any:
|
||||
return self._delivery_manager.create_ticket(
|
||||
request_id=request_id,
|
||||
put_key=put_key,
|
||||
)
|
||||
|
||||
def _start_output_worker(self, *, max_pending_batches: int) -> None:
|
||||
if max_pending_batches <= 0:
|
||||
raise ValueError("max_pending_batches must be positive")
|
||||
self._native_output_queue: Queue[_NativeOutputBatch | object] = Queue(maxsize=max_pending_batches)
|
||||
self._native_output_error_lock = threading.Lock()
|
||||
self._native_output_error: BaseException | None = None
|
||||
self._connector_send_error_sink = self._record_output_error
|
||||
self._native_output_closed = False
|
||||
stage_id = getattr(self, "_stage_id", "unknown")
|
||||
self._native_output_worker: threading.Thread | None = threading.Thread(
|
||||
target=self._output_worker_loop,
|
||||
name=f"omni-native-output-{stage_id}",
|
||||
daemon=True,
|
||||
)
|
||||
self._native_output_worker.start()
|
||||
|
||||
def _record_output_error(self, error: BaseException) -> None:
|
||||
first_error = False
|
||||
with self._native_output_error_lock:
|
||||
if self._native_output_error is None:
|
||||
self._native_output_error = error
|
||||
first_error = True
|
||||
if first_error:
|
||||
manager = getattr(self, "_delivery_manager", None)
|
||||
if manager is not None:
|
||||
manager.quarantine(error)
|
||||
|
||||
def _raise_output_error(self) -> None:
|
||||
with self._native_output_error_lock:
|
||||
error = self._native_output_error
|
||||
if error is not None:
|
||||
raise RuntimeError(f"native output worker failed: {error}") from error
|
||||
|
||||
def _output_worker_loop(self) -> None:
|
||||
while True:
|
||||
batch = self._native_output_queue.get()
|
||||
try:
|
||||
if batch is _NATIVE_OUTPUT_STOP:
|
||||
return
|
||||
if self._native_output_error is not None:
|
||||
continue
|
||||
assert isinstance(batch, _NativeOutputBatch)
|
||||
self.complete_outputs(
|
||||
req_ids=batch.req_ids,
|
||||
inter_stage_outputs=batch.inter_stage_outputs,
|
||||
sampled_token_ids=batch.sampled_token_ids,
|
||||
)
|
||||
except BaseException as error:
|
||||
self._record_output_error(error)
|
||||
finally:
|
||||
self._native_output_queue.task_done()
|
||||
|
||||
def enqueue_outputs(
|
||||
self,
|
||||
*,
|
||||
req_ids: list[str],
|
||||
inter_stage_outputs: list[Any | None] | None,
|
||||
sampled_token_ids: list[list[int]] | None,
|
||||
) -> None:
|
||||
"""Transfer one completed model batch to the ordered output worker."""
|
||||
self._raise_output_error()
|
||||
if self._native_output_closed:
|
||||
raise RuntimeError("native output worker is closed")
|
||||
batch = _NativeOutputBatch(
|
||||
req_ids=req_ids,
|
||||
inter_stage_outputs=inter_stage_outputs,
|
||||
sampled_token_ids=sampled_token_ids,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
self._native_output_queue.put(batch, timeout=0.1)
|
||||
break
|
||||
except Full:
|
||||
self._raise_output_error()
|
||||
self._raise_output_error()
|
||||
|
||||
def drain_outputs(self) -> None:
|
||||
queue = self._native_output_queue
|
||||
pending_batches = max(1, queue.qsize() + 1)
|
||||
timeout_s = self._delivery_manager.delivery_timeout_s * pending_batches
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while queue.unfinished_tasks:
|
||||
self._raise_output_error()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
error = DeliveryTimeoutError(
|
||||
"native output queue drain timed out after "
|
||||
f"{timeout_s:.3f}s with {queue.unfinished_tasks} "
|
||||
"unfinished batch(es)"
|
||||
)
|
||||
self._record_output_error(error)
|
||||
self._raise_output_error()
|
||||
with queue.all_tasks_done:
|
||||
if queue.unfinished_tasks:
|
||||
queue.all_tasks_done.wait(timeout=min(0.05, remaining))
|
||||
self._raise_output_error()
|
||||
|
||||
def _stop_output_worker(self) -> None:
|
||||
worker = getattr(self, "_native_output_worker", None)
|
||||
if worker is None:
|
||||
return
|
||||
self._native_output_closed = True
|
||||
self._delivery_manager.shutdown(RuntimeError("MRv2 output worker shutdown"))
|
||||
deadline = time.monotonic() + self._delivery_manager.shutdown_timeout_s
|
||||
while worker.is_alive():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
self._native_output_queue.put(
|
||||
_NATIVE_OUTPUT_STOP,
|
||||
timeout=min(0.05, remaining),
|
||||
)
|
||||
break
|
||||
except Full:
|
||||
continue
|
||||
worker.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||
if worker.is_alive():
|
||||
raise RuntimeError("MRv2 output worker did not stop within its shutdown deadline")
|
||||
self._native_output_worker = None
|
||||
|
||||
def register_request(self, request_data: Any) -> None:
|
||||
self._raise_output_error()
|
||||
req_id = str(request_data.req_id)
|
||||
external_req_id = str(getattr(request_data, "external_req_id", None) or req_id)
|
||||
with self._native_output_lock:
|
||||
self._native_outputs_in_flight.pop(req_id, None)
|
||||
self._native_terminal_pending.discard(req_id)
|
||||
self._native_requests[req_id] = _NativeRequestState(
|
||||
request_id=req_id,
|
||||
external_req_id=external_req_id,
|
||||
prompt_token_ids=list(getattr(request_data, "prompt_token_ids", None) or []),
|
||||
additional_information=getattr(request_data, "additional_information", None),
|
||||
sampling_params=getattr(request_data, "sampling_params", None),
|
||||
num_computed_tokens=int(getattr(request_data, "num_computed_tokens", 0) or 0),
|
||||
resumable=bool(getattr(request_data, "resumable", False)),
|
||||
)
|
||||
|
||||
def reserve_outputs(self, req_ids: list[str]) -> None:
|
||||
"""Keep request state alive until deferred runner outputs are consumed."""
|
||||
self._raise_output_error()
|
||||
with self._native_output_lock:
|
||||
for req_id in dict.fromkeys(req_ids):
|
||||
if req_id in self._native_requests:
|
||||
self._native_outputs_in_flight[req_id] += 1
|
||||
|
||||
def _ready_terminal_requests(self) -> set[str]:
|
||||
return {
|
||||
req_id for req_id in self._native_terminal_pending if self._native_outputs_in_flight.get(req_id, 0) == 0
|
||||
}
|
||||
|
||||
def _emit_ready_terminals(self) -> int:
|
||||
ready = self._ready_terminal_requests()
|
||||
if not ready:
|
||||
return 0
|
||||
emitted = self.emit_chunks(
|
||||
req_ids=[],
|
||||
inter_stage_outputs=None,
|
||||
sampled_token_ids=None,
|
||||
terminal_req_ids=ready,
|
||||
)
|
||||
self._native_terminal_pending.difference_update(ready)
|
||||
for req_id in ready:
|
||||
self._native_outputs_in_flight.pop(req_id, None)
|
||||
return emitted
|
||||
|
||||
def request_terminal(self, req_ids: set[str]) -> int:
|
||||
"""Emit terminal markers only after all deferred outputs are enqueued."""
|
||||
self._raise_output_error()
|
||||
with self._native_output_lock:
|
||||
self._native_terminal_pending.update(req_id for req_id in req_ids if req_id in self._native_requests)
|
||||
return self._emit_ready_terminals()
|
||||
|
||||
def abort_requests(self, req_ids: set[str]) -> int:
|
||||
"""Cancel deferred outputs and terminate each live request once."""
|
||||
with self._native_output_lock:
|
||||
active_req_ids = {req_id for req_id in req_ids if req_id in self._native_requests}
|
||||
# Receivers are registered before requests acquire model slots.
|
||||
# Cancelling during that interval must still stop recv polling.
|
||||
for req_id in req_ids - active_req_ids:
|
||||
self.cleanup_finished_request(req_id)
|
||||
if not active_req_ids:
|
||||
return 0
|
||||
for req_id in active_req_ids:
|
||||
self._native_outputs_in_flight.pop(req_id, None)
|
||||
self._native_terminal_pending.discard(req_id)
|
||||
return self.emit_chunks(
|
||||
req_ids=[],
|
||||
inter_stage_outputs=None,
|
||||
sampled_token_ids=None,
|
||||
terminal_req_ids=active_req_ids,
|
||||
)
|
||||
|
||||
def complete_outputs(
|
||||
self,
|
||||
*,
|
||||
req_ids: list[str],
|
||||
inter_stage_outputs: list[Any | None] | None,
|
||||
sampled_token_ids: list[list[int]] | None,
|
||||
) -> int:
|
||||
"""Commit one deferred output batch, then release its lifecycle holds."""
|
||||
send_lock_held = False
|
||||
with self._native_output_lock:
|
||||
entries, finished_req_ids = self._build_chunk_entries(
|
||||
req_ids=req_ids,
|
||||
inter_stage_outputs=inter_stage_outputs,
|
||||
sampled_token_ids=sampled_token_ids,
|
||||
terminal_req_ids=set(),
|
||||
)
|
||||
# Claim connector ordering before publishing the updated request
|
||||
# state. request_terminal() can still register a pending terminal
|
||||
# while the enqueue runs, but abort cannot overtake this output.
|
||||
if entries:
|
||||
self._native_send_lock.acquire()
|
||||
send_lock_held = True
|
||||
try:
|
||||
emitted = self._send_chunk_entries(
|
||||
entries,
|
||||
send_lock_held=send_lock_held,
|
||||
)
|
||||
finally:
|
||||
if send_lock_held:
|
||||
self._native_send_lock.release()
|
||||
with self._native_output_lock:
|
||||
self._cleanup_finished_entries(finished_req_ids)
|
||||
for req_id in dict.fromkeys(req_ids):
|
||||
in_flight = self._native_outputs_in_flight.get(req_id, 0)
|
||||
if in_flight > 1:
|
||||
self._native_outputs_in_flight[req_id] = in_flight - 1
|
||||
elif in_flight == 1:
|
||||
self._native_outputs_in_flight.pop(req_id, None)
|
||||
return emitted + self._emit_ready_terminals()
|
||||
|
||||
def register_receivers(self, handles: list[Any]) -> None:
|
||||
for handle in handles:
|
||||
self.register_chunk_recv(handle)
|
||||
|
||||
@classmethod
|
||||
def _copy_payload_structure(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: cls._copy_payload_structure(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return list(value)
|
||||
return value
|
||||
|
||||
def _accumulate_payload(self, req_id: str, payload_data: OmniPayload) -> OmniPayload:
|
||||
"""Accumulate chunk payloads (concat tensors, extend lists)."""
|
||||
if req_id not in self._send_side_request_payload:
|
||||
self._send_side_request_payload[req_id] = dict(payload_data)
|
||||
return cast(OmniPayload, dict(self._send_side_request_payload[req_id]))
|
||||
|
||||
origin = self._send_side_request_payload[req_id]
|
||||
merged = dict(origin)
|
||||
raw_ok = payload_data.get("meta", {}).get("override_keys", []) if isinstance(payload_data, dict) else []
|
||||
override_keys = {tuple(k) if isinstance(k, list) else k for k in raw_ok}
|
||||
|
||||
for key, value in payload_data.items():
|
||||
if isinstance(value, dict):
|
||||
origin_sub = origin.get(key)
|
||||
merged_sub = dict(origin_sub) if isinstance(origin_sub, dict) else {}
|
||||
span_handled: set[str] = set()
|
||||
if key == "embed" and isinstance(origin_sub, dict):
|
||||
for tk, sk, ek in (("decode", "decode_token_start", "decode_token_end"),):
|
||||
if tk not in value or (key, tk) in override_keys:
|
||||
continue
|
||||
span = merge_tensor_spans(
|
||||
get_tensor_span(origin_sub, tensor_key=tk, start_key=sk, end_key=ek),
|
||||
get_tensor_span(value, tensor_key=tk, start_key=sk, end_key=ek),
|
||||
)
|
||||
if span is None:
|
||||
continue
|
||||
t, s, e = span
|
||||
merged_sub[tk] = t
|
||||
merged_sub[sk] = s
|
||||
merged_sub[ek] = e
|
||||
span_handled |= {tk, sk, ek}
|
||||
for qual, qval in value.items():
|
||||
if qual in span_handled:
|
||||
continue
|
||||
if key == "meta" and qual == "finished":
|
||||
merged_sub[qual] = qval
|
||||
continue
|
||||
if (key, qual) in override_keys:
|
||||
merged_sub[qual] = qval
|
||||
continue
|
||||
osv = merged_sub.get(qual)
|
||||
if isinstance(qval, torch.Tensor) and isinstance(osv, torch.Tensor):
|
||||
merged_sub[qual] = torch.cat([osv, qval], dim=0)
|
||||
elif isinstance(qval, list) and isinstance(osv, list):
|
||||
merged_sub[qual] = osv + qval
|
||||
else:
|
||||
merged_sub[qual] = qval
|
||||
merged[key] = merged_sub
|
||||
else:
|
||||
if key in override_keys:
|
||||
merged[key] = value
|
||||
continue
|
||||
ov = origin.get(key)
|
||||
if isinstance(value, torch.Tensor) and isinstance(ov, torch.Tensor):
|
||||
merged[key] = torch.cat([ov, value], dim=0)
|
||||
elif isinstance(value, list) and isinstance(ov, list):
|
||||
merged[key] = ov + value
|
||||
else:
|
||||
merged[key] = value
|
||||
|
||||
self._send_side_request_payload[req_id] = merged
|
||||
return cast(OmniPayload, dict(merged))
|
||||
|
||||
def pop_local_stage_payload(self, req_id: str) -> Any:
|
||||
"""Hand one accumulated delta to the model and acknowledge its rows.
|
||||
|
||||
Connector accumulation and ``model_intermediate_buffer`` are separate
|
||||
ownership domains. Once decode rows are handed to the model, remove
|
||||
them from connector accumulation so the next chunk cannot replay the
|
||||
same rows into Talker's ``cached_decode``.
|
||||
"""
|
||||
with self._lock:
|
||||
payload = self._local_stage_payload_cache.pop(req_id, None)
|
||||
if payload is None:
|
||||
return None
|
||||
delivered = self._copy_payload_structure(payload)
|
||||
external_req_id = self._request_ids_mapping.get(req_id, req_id)
|
||||
accumulated = self._send_side_request_payload.get(external_req_id)
|
||||
if isinstance(accumulated, dict):
|
||||
embed = accumulated.get("embed")
|
||||
if isinstance(embed, dict):
|
||||
embed.pop("decode", None)
|
||||
embed.pop("decode_token_start", None)
|
||||
embed.pop("decode_token_end", None)
|
||||
hidden_states = accumulated.get("hidden_states")
|
||||
if isinstance(hidden_states, dict):
|
||||
hidden_states.pop("output", None)
|
||||
ids = accumulated.get("ids")
|
||||
if isinstance(ids, dict):
|
||||
ids.pop("output", None)
|
||||
return delivered
|
||||
|
||||
def emit_chunks(
|
||||
self,
|
||||
*,
|
||||
req_ids: list[str],
|
||||
inter_stage_outputs: list[Any | None] | None,
|
||||
sampled_token_ids: list[list[int]] | None,
|
||||
terminal_req_ids: set[str],
|
||||
) -> int:
|
||||
with self._native_output_lock:
|
||||
entries, finished_req_ids = self._build_chunk_entries(
|
||||
req_ids=req_ids,
|
||||
inter_stage_outputs=inter_stage_outputs,
|
||||
sampled_token_ids=sampled_token_ids,
|
||||
terminal_req_ids=terminal_req_ids,
|
||||
)
|
||||
emitted = self._send_chunk_entries(entries)
|
||||
self._cleanup_finished_entries(finished_req_ids)
|
||||
return emitted
|
||||
|
||||
def _build_chunk_entries(
|
||||
self,
|
||||
*,
|
||||
req_ids: list[str],
|
||||
inter_stage_outputs: list[Any | None] | None,
|
||||
sampled_token_ids: list[list[int]] | None,
|
||||
terminal_req_ids: set[str],
|
||||
) -> tuple[list[tuple[Any, Any | None]], list[str]]:
|
||||
if not getattr(self, "_can_send", True):
|
||||
receive_finished_ids = [req_id for req_id in sorted(terminal_req_ids) if req_id in self._native_requests]
|
||||
return [], receive_finished_ids
|
||||
payload_by_req = {
|
||||
req_id: inter_stage_outputs[index]
|
||||
for index, req_id in enumerate(req_ids)
|
||||
if inter_stage_outputs is not None and index < len(inter_stage_outputs)
|
||||
}
|
||||
sampled_by_req = {
|
||||
req_id: sampled_token_ids[index]
|
||||
for index, req_id in enumerate(req_ids)
|
||||
if sampled_token_ids is not None and index < len(sampled_token_ids)
|
||||
}
|
||||
entries: list[tuple[Any, Any | None]] = []
|
||||
finished_req_ids: list[str] = []
|
||||
for req_id in dict.fromkeys([*req_ids, *sorted(terminal_req_ids)]):
|
||||
state = self._native_requests.get(req_id)
|
||||
if state is None:
|
||||
continue
|
||||
state.output_token_ids.extend(int(token_id) for token_id in sampled_by_req.get(req_id, []))
|
||||
state.finished = req_id in terminal_req_ids
|
||||
payload = payload_by_req.get(req_id)
|
||||
if isinstance(payload, dict):
|
||||
payload = unflatten_payload(payload)
|
||||
if payload is None and not state.finished:
|
||||
continue
|
||||
include_token_history = bool(state.resumable or self._put_req_chunk.get(state.external_req_id, 0) == 0)
|
||||
entries.append(
|
||||
(
|
||||
state.snapshot(include_token_history=include_token_history),
|
||||
payload,
|
||||
)
|
||||
)
|
||||
if state.finished:
|
||||
finished_req_ids.append(req_id)
|
||||
return entries, finished_req_ids
|
||||
|
||||
def _send_chunk_entries(
|
||||
self,
|
||||
entries: list[tuple[Any, Any | None]],
|
||||
*,
|
||||
send_lock_held: bool = False,
|
||||
) -> int:
|
||||
if not entries:
|
||||
return 0
|
||||
context: AbstractContextManager
|
||||
if send_lock_held:
|
||||
context = nullcontext()
|
||||
else:
|
||||
context = self._native_send_lock
|
||||
with context:
|
||||
return self.send_chunks(entries, propagate_errors=True, wait_for_delivery=True)
|
||||
|
||||
def _cleanup_finished_entries(self, finished_req_ids: list[str]) -> None:
|
||||
for req_id in finished_req_ids:
|
||||
self._native_requests.pop(req_id, None)
|
||||
self.cleanup_finished_request(req_id)
|
||||
|
||||
def shutdown_omni_connectors(self) -> None:
|
||||
"""Bound MRv2 shutdown even if a connector call cannot be cancelled.
|
||||
|
||||
Connector ``close()`` runs concurrently with the I/O threads so a
|
||||
backend that supports cancellation can release a blocked ``put()``.
|
||||
Backends that do not support it are quarantined and left only on
|
||||
daemon threads after the bounded deadline; V1 keeps its existing
|
||||
shutdown implementation in the shared mixin.
|
||||
"""
|
||||
timeout_s = self._delivery_manager.shutdown_timeout_s
|
||||
deadline = time.monotonic() + timeout_s
|
||||
self._delivery_manager.shutdown(RuntimeError("MRv2 connector shutdown"))
|
||||
self._stop_event.set()
|
||||
self._work_available.set()
|
||||
|
||||
close_errors: list[BaseException] = []
|
||||
connector = getattr(self, "_omni_connector", None)
|
||||
|
||||
def close_connector() -> None:
|
||||
if connector is None:
|
||||
return
|
||||
try:
|
||||
connector.close()
|
||||
except BaseException as error:
|
||||
close_errors.append(error)
|
||||
|
||||
close_thread = threading.Thread(
|
||||
target=close_connector,
|
||||
name=f"omni-native-connector-close-{getattr(self, '_stage_id', 'unknown')}",
|
||||
daemon=True,
|
||||
)
|
||||
close_thread.start()
|
||||
|
||||
live_threads: list[str] = []
|
||||
for name, thread in (
|
||||
("recv", getattr(self, "_recv_thread", None)),
|
||||
("save", getattr(self, "_save_thread", None)),
|
||||
("connector-close", close_thread),
|
||||
):
|
||||
if thread is None:
|
||||
continue
|
||||
thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||
if thread.is_alive():
|
||||
live_threads.append(name)
|
||||
|
||||
if close_errors:
|
||||
raise RuntimeError("MRv2 connector close failed") from close_errors[0]
|
||||
if live_threads:
|
||||
raise RuntimeError(
|
||||
f"MRv2 connector shutdown exceeded {timeout_s:.3f}s; blocked thread(s): {', '.join(live_threads)}"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
error: BaseException | None = None
|
||||
try:
|
||||
self.drain_outputs()
|
||||
except BaseException as exc:
|
||||
error = exc
|
||||
finally:
|
||||
self._delivery_manager.shutdown(error or RuntimeError("MRv2 data plane close"))
|
||||
try:
|
||||
self._stop_output_worker()
|
||||
except BaseException as exc:
|
||||
if error is None:
|
||||
error = exc
|
||||
try:
|
||||
self.shutdown_omni_connectors()
|
||||
except BaseException as exc:
|
||||
if error is None:
|
||||
error = exc
|
||||
if error is not None:
|
||||
raise error
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user