Repository navigation
[Model] Add single-stage Qwen3-TTS with in-Talker streaming codec decode #8259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
680f276
74810fa
7376726
42ed85f
22016c3
3722539
c6bd305
6148511
fd9e573
0393699
0a8e794
cbb3a8a
bd950f7
cf956be
6f003b8
29c2ea0
a013bbc
e4ef96e
c92c203
7eefbd3
4e7e97e
43f9bfa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -319,7 +319,9 @@ ROCm and MUSA. Select one of these deployment profiles: | |
| | `qwen3_tts_mrv2.yaml` | V2 | B1 | Explicit MRV2 profile (same runner selection as the default) | | ||
| | `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 | | ||
| | `qwen3_tts_high_concurrency_mrv2_single_gpu.yaml` | V2 | B1–B8 | Experimental two-stage deployment on one GPU, with MPS | | ||
| | `qwen3_tts_high_concurrency.yaml` | V1 | Existing defaults | V1 high-concurrency control | | ||
| | `qwen3_tts_fused_single_gpu.yaml` | V2 | N/A (in-Talker decoder) | Opt-in single-stage CUDA pipeline on one GPU | | ||
|
|
||
| ```bash | ||
| # MRV2 is the default; pass a copy with `model_runner: v1` to force V1. | ||
|
|
@@ -350,12 +352,73 @@ supply `mtp_sampling_params` and `get_mtp_seed(sampling_params)` for model-local | |
| (with `mtp_sample_steps` and `mtp_sample_vocab_size`) as needed. Qwen3-TTS retains | ||
| its existing `talker_mtp` entry point for V1. | ||
|
|
||
| ### Selecting one or two Qwen3-TTS stages | ||
|
|
||
| Choose the deployment profile explicitly with `--deploy-config`. The default | ||
| `qwen3_tts` pipeline retains two stages: the Talker publishes codec chunks to | ||
| Code2Wav, which produces audio. Its stage devices can be placed on the same | ||
| GPU or on separate GPUs. `qwen3_tts_fused` is a separate, opt-in pipeline for | ||
| one CUDA GPU: the Talker owns the stateful codec decoder and emits PCM directly, | ||
| without a Code2Wav engine or stage connector. | ||
|
|
||
| ```bash | ||
| # Two stages on one GPU; the two-stage pipeline remains independently selectable. | ||
| vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni \ | ||
| --deploy-config vllm_omni/deploy/qwen3_tts_high_concurrency_mrv2_single_gpu.yaml | ||
|
|
||
| # One stage on one GPU; no inter-stage transport or Code2Wav scheduling. | ||
| vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni \ | ||
| --deploy-config vllm_omni/deploy/qwen3_tts_fused_single_gpu.yaml | ||
| ``` | ||
|
|
||
| The single-stage profile requires CUDA, V2, asynchronous chunks, TP=PP=1, | ||
| an in-process worker and disabled prefix caching. Select the full profile; | ||
| adding `talker_stream_decode: true` to a two-stage Talker is rejected because | ||
| its latent output belongs to the Code2Wav input contract. Single-stage options | ||
| live in `stages[].additional_config`; two-stage transport and first-frame | ||
| options live in `connectors.*.extra`. | ||
|
|
||
| The single-stage profile defaults `talker_stream_first_audio` to `false` for | ||
| throughput. To opt into a separate first-frame delivery queue, save this | ||
| overlay beside the bundled deployment files and pass its path to | ||
| `--deploy-config`: | ||
|
|
||
| ```yaml | ||
| base_config: qwen3_tts_fused_single_gpu.yaml | ||
| stages: | ||
| - stage_id: 0 | ||
| additional_config: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] The first-audio overlay in docs/configuration/stage_configs.md:386-397 restates… The first-audio overlay in docs/configuration/stage_configs.md:386-397 restates the entire additional_config map to flip talker_stream_first_audio. resolve_deploy_yaml merges matching stages with _deep_merge_stage (stage_config.py:766), which recursively merges only _DEEP_MERGE_KEYS (stage_config.py:709-716: sampling params, engine_extras, engine_args). additional_config is not in that set, so _merge_config_fields replaces it wholesale (stage_config.py:723-728). A one-key overlay would drop talker_stream_decode; QWEN3_TTS_FUSED_PIPELINE still has engine_output_type=audio, so talker_stream_decode_enabled raises. Two-stage connectors.*.extra is different: _merge_connectors recursively merges. Add additional_config to _DEEP_MERGE_KEYS so the opt-in can be a single key, or state in this example that the map is replaced and must be copied in full. Evidence: docs/configuration/stage_configs.md:386-397 (this diff) |
||
| talker_stream_decode: true | ||
| talker_stream_first_audio: true | ||
| ref_code_context_frames: 72 | ||
| code_predictor_kv_cache: true | ||
| code_predictor_fused_sampling: true | ||
| code_predictor_fused: true | ||
| ``` | ||
|
|
||
| This queue uses PCM from the single-stage decoder. The two-stage | ||
| `talker_first_audio` option below uses its own first-frame decoder and does | ||
| not require single-stage execution. The two options cannot be enabled | ||
| together. Single-stage prefix-cache reset with | ||
| `reset_running_requests=True` returns `False` while requests are active; | ||
| wait for completion or abort them before retrying. | ||
|
|
||
| Reference-cloning context is selected independently by each profile. The | ||
| single-stage profile explicitly uses 72 reference frames, matching the | ||
| `qwen3_tts.yaml` context boundary. A shorter | ||
| `additional_config.ref_code_context_frames` (for example 25) can change | ||
| timbre continuity even when WER is similar; | ||
| the throughput measurements for CustomVoice do not qualify Base voice | ||
| cloning quality. Reference layout, grouping and priming are model hooks; | ||
| the shared worker does not select Qwen's context length or codebook layout. | ||
|
|
||
| ### Qwen3-TTS first-frame delivery and rollback | ||
|
|
||
| The standard `qwen3_tts.yaml`, `qwen3_tts_high_concurrency.yaml`, | ||
| `qwen3_tts_mrv2.yaml` and `qwen3_tts_high_concurrency_mrv2.yaml` profiles | ||
| enable the `talker_first_audio` connector option, as does the experimental | ||
| single-GPU profile. This changes the default CUDA MRV2 streaming path: | ||
| two-stage `qwen3_tts_high_concurrency_mrv2_single_gpu.yaml` profile. | ||
| This changes the default CUDA MRV2 streaming path: | ||
| residual prediction runs eagerly after the Talker sample, the Talker loads | ||
| an additional first-frame decoder with its weights and CUDA graphs, and the | ||
| orchestrator orders its audio before subsequent Code2Wav chunks. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -106,15 +106,15 @@ def _make_drain_sched(session: Request) -> MagicMock: | |
| return sched | ||
|
|
||
|
|
||
| def _run_step(sched: MagicMock, session: Request, *, num_scheduled: int, token: int) -> bool: | ||
| def _run_step(sched: MagicMock, session: Request, *, num_scheduled: int, token: int, spec=None) -> bool: | ||
| """Feed one model-runner frame through update_from_output. | ||
|
|
||
| Returns True when the frame's tokens were delivered (the append ran), | ||
| False when the frame was swallowed by the stale drain. | ||
| """ | ||
| scheduler_output = MagicMock(spec=SchedulerOutput) | ||
| scheduler_output.num_scheduled_tokens = {session.request_id: num_scheduled} | ||
| scheduler_output.scheduled_spec_decode_tokens = {} | ||
| scheduler_output.scheduled_spec_decode_tokens = {session.request_id: spec} if spec else {} | ||
| scheduler_output.num_invalid_spec_tokens = 0 | ||
|
|
||
| model_runner_output = MagicMock(spec=ModelRunnerOutput) | ||
|
|
@@ -134,6 +134,27 @@ def _run_step(sched: MagicMock, session: Request, *, num_scheduled: int, token: | |
| return sched._update_request_with_output.called | ||
|
|
||
|
|
||
| def test_connector_prompt_replacement_drops_old_frame_and_delivers_new_frame() -> None: | ||
| from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin | ||
|
|
||
| session = _make_session() | ||
| session.num_computed_tokens = 6 | ||
| session.num_in_flight_tokens = 1 | ||
| session.num_output_placeholders = 1 | ||
| sched = _make_drain_sched(session) | ||
| sched.chunk_transfer_adapter.replaced_streaming_prompt_ids = {session.request_id} | ||
| sched.chunk_transfer_adapter.requests_with_ready_chunks = {session.request_id} | ||
| sched.chunk_transfer_adapter.requests_num_chunks_sent = {session.external_req_id: 2} | ||
|
|
||
| OmniSchedulerMixin._reset_ready_async_chunk_replacements(sched) | ||
| assert session.num_stale_output_tokens == 1 and session.drop_stale_output | ||
| assert session.num_output_placeholders == 0 | ||
| sched._release_replaced_streaming_prompt_cache.assert_called_once_with(session) | ||
| assert _run_step(sched, session, num_scheduled=1, token=42) is False | ||
| assert session.num_stale_output_tokens == 0 | ||
| assert _run_step(sched, session, num_scheduled=1, token=43) is True | ||
|
|
||
|
|
||
| def test_exact_drain_delivers_new_segment_frame() -> None: | ||
| """One in-flight decode frame at replacement: its late output drains the | ||
| counter exactly and the new segment's first frame is delivered.""" | ||
|
|
@@ -225,3 +246,80 @@ def test_in_flight_prefill_chunk_drains_exactly_without_underflow() -> None: | |
| assert _run_step(sched, session, num_scheduled=5, token=42) is False # late prefill chunk dropped | ||
| assert session.num_stale_output_tokens == 0 | ||
| assert _run_step(sched, session, num_scheduled=1, token=43) is True # new segment survives | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("drop", [False, True]) | ||
| def test_preemption_stale_output_follows_upstream_delivery_policy(drop): | ||
| request = _make_session() | ||
| request.status = RequestStatus.PREEMPTED | ||
| request.num_stale_output_tokens = 1 | ||
| request.num_in_flight_tokens = 1 | ||
| request.drop_stale_output = drop | ||
| sched = _make_drain_sched(request) | ||
| assert _run_step(sched, request, num_scheduled=1, token=42) is (not drop) | ||
| assert request.num_stale_output_tokens == 0 | ||
| if not drop: | ||
| sched._update_request_with_output.assert_called_once_with(request, [42], is_stale=True) | ||
|
|
||
|
|
||
| def test_preemption_stale_spec_rejection_does_not_roll_back_resumed_counters(): | ||
| request = _make_session() | ||
| request.num_stale_output_tokens = 2 | ||
| request.num_in_flight_tokens = 2 | ||
| request.num_computed_tokens = 3 | ||
| request.num_output_placeholders = 2 | ||
| request.drop_stale_output = False | ||
| sched = _make_drain_sched(request) | ||
| assert _run_step(sched, request, num_scheduled=2, token=42, spec=[41]) | ||
| assert request.num_computed_tokens == 3 | ||
| assert request.num_output_placeholders == 2 | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "arch,supports_reset,running,reset_running,blocked", | ||
| [ | ||
| ("Qwen3TTSTalkerForConditionalGeneration", False, True, True, True), | ||
| ("Qwen3TTSTalkerForConditionalGeneration", False, False, True, False), | ||
| ("Qwen3TTSTalkerForConditionalGeneration", False, True, False, False), | ||
| ("Qwen3TTSTalkerForConditionalGeneration", True, True, True, False), | ||
| ("AnotherStatefulAudioModel", False, True, True, True), | ||
| ("AnotherStatefulAudioModel", True, True, True, False), | ||
| ], | ||
| ) | ||
| def test_reset_running_streaming_codec_is_rejected_before_preemption( | ||
| mocker, arch, supports_reset, running, reset_running, blocked | ||
| ): | ||
| from vllm.config import VllmConfig | ||
| from vllm.v1.core.sched.scheduler import Scheduler | ||
|
|
||
| from vllm_omni.config.model import OmniModelConfig | ||
|
|
||
| scheduler = OmniARScheduler.__new__(OmniARScheduler) | ||
| scheduler.vllm_config = mocker.Mock( | ||
| spec=VllmConfig, | ||
| model_config=mocker.Mock( | ||
| spec=OmniModelConfig, | ||
| model_arch=arch, | ||
| supports_running_prefix_cache_reset=supports_reset, | ||
| ), | ||
| ) | ||
| request = _make_session() | ||
| request.num_in_flight_tokens = 1 | ||
| request.num_output_placeholders = 1 | ||
| scheduler.running = [request] if running else [] | ||
|
|
||
| def reset_cache(_self, reset_running_requests=False, reset_connector=False): | ||
| assert reset_running_requests is reset_running and reset_connector is True | ||
| return True | ||
|
|
||
| reset = mocker.patch.object(Scheduler, "reset_prefix_cache", autospec=True, side_effect=reset_cache) | ||
|
|
||
| assert scheduler.reset_prefix_cache(reset_running, reset_connector=True) is (not blocked) | ||
| if blocked: | ||
| reset.assert_not_called() | ||
| assert scheduler.running == [request] | ||
| assert request.status == RequestStatus.RUNNING | ||
| assert request.num_in_flight_tokens == request.num_output_placeholders == 1 | ||
| assert not request.drop_stale_output | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Blocked Blocked Evidence: tests/core/sched/test_omni_ar_scheduler_stale_drain.py:323 Suggestion: assert not getattr(request, "drop_stale_output", False) |
||
| else: | ||
| reset.assert_called_once() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] The new fused row is a V2 profile with no Code2Wav
The new fused row is a V2 profile with no Code2Wav. The following paragraph still says the V2 profiles select the Talker AR runner and the Code2Wav generation runner together. Scope that sentence to two-stage V2 profiles.
Evidence: docs/configuration/stage_configs.md:324
|qwen3_tts_fused_single_gpu.yaml| V2 | N/A (in-Talker decoder) | Opt-in single-stage CUDA pipeline on one GPU |. Unchanged by this diff, present in the PR-time tree: docs/configuration/stage_configs.md:332-334The V2 profiles bound Talker prefill to 512 tokens per step and select the Talker AR runner and the Code2Wav generation runner together.. vllm_omni/model_executor/models/qwen3_tts/pipeline.py:77-79# Single-stage variant: the Talker decodes each frame with the stateful streaming codec decoder and emits PCM as the final output (enable with the ``talker_stream_decode`` model option). No Code2Wav stage, no connector.Suggestion: The two-stage V2 profiles bound Talker prefill to 512 tokens per step and select the
Talker AR runner and the Code2Wav generation
runner together. The single-stage fused profile has no Code2Wav runner.