Add Qwen3-TTS-12Hz-0.6B-CustomVoice support - #184
Conversation
There was a problem hiding this comment.
Overall looks good. The model needs to be added to the docs/cli, and as a note, and I made #199 for the issues with the cuda graphable sampler that you had to worker around.
I also got a few issues when running it on a GPU:
(1) I tried running it on an H100 and I ran out of memory when capturing the code2wav cuda graphs until I removed batch size 16 from the codec cuda graph. I'd recommend either reducing the maximum batch size to 8, or perhaps the codec decoder can be made more lightweight (I see it is now a wrapper around the decoder from the qwen_tts package; it can, in the future maybe, be ported over and optimized, like the Qwen3-Omni codec).
(2) When testing with
python -m benchmark.runner \
--url localhost:8000 \
--model orpheus \
--profiling-type closed_loop \
--request-type text_to_speech \
--num-requests 20 \
--inference-system ours \
--num-warmup 0 \
--max-concurrency 4 \
--dataset seed_tts \
--output-dir .bench_outs
the requests were giving correct output audio, but all were running to max tokens, with the remainder of the audio silent. I think either the model is not properly outputting EOS or there is a bug in check_stop.
(3) I got
/bin/sh: 1: sox: not found
2026-07-31 17:53:38,435 WARNING [worker_0] sox: SoX could not be found!
If you do not have SoX, proceed here:
- - - http://sox.sourceforge.net/ - - -
If you do (or think that you should) have SoX, double-check your
path variables.
on startup, even though sox appears to exist in my Python environment.
Thanks for pointing this out. I also added a modular test that verifies the CLI and benchmark registrations. (1) Fixed. (2) Fixed. We confirmed through a CUDA-Graph-versus-eager A/B test that the full Talker CUDA Graph was the cause of the missing EOS, rather than the Talker weights or The safe fix is therefore to keep the outer Talker recurrent walk eager while retaining the independently captured 15-step CodePredictor loop and the Codec graphs. This preserves the useful inner-loop optimization without capturing request-owned recurrent state and sampling together. Before the fix, a 128-frame request always returned 10.24 seconds of audio and typically became all-zero after about 3.7–4.0 seconds. With the final path, six real-server requests all stopped naturally at 47–56 frames, returned 3.76–4.48 seconds of non-silent audio, and never reached the 256-frame safety limit. The final steady-state result on H20 was 1.13 seconds mean latency and 0.278 mean RTF over five measured requests. (3) Fixed. The 12 Hz speech-tokenizer decoder does not use SoX; the warning came from importing the broad |
|
@qaqjx you're right that the full Talker CUDA Graph was the cause of the missing EOS, and your safe fix does work, but I think I know the (fixable) root cause. The runner re-runs engine_inputs = ModelInputsFromEngine(request_ids=dummy_rids, ...) # cuda_graph_runner.py:1403
real_inputs = submodule.preprocess(..., engine_inputs=engine_inputs) # :1416Only
Proposed fix: keep # prepare_inputs, decode branch
suppress_eos = int(state.get("generated_frames", 0)) < self.config.generation.min_new_tokens
return ARNodeInputs(..., tensor_inputs={"suppress_eos": torch.tensor([suppress_eos], device=...)})
# preprocess
mask[:, eos] = torch.cat([item.tensor_inputs["suppress_eos"] for item in inputs])Two things to keep in mind: I verified this on GPU (starting from I also did another pass and found some minor issues; I'll make comments on those; otherwise overall looks good. |
NSagan271
left a comment
There was a problem hiding this comment.
Some minor comments; main thing is #184 (comment) on the root cause behind the missing EOS.
Thanks for digging into this. Your analysis is correct: the use of dummy request IDs was the underlying cause. I followed your suggested approach by computing suppress_eos in prepare_inputs for both prefill and decode, carrying it through ARNodeInputs, and building the mask only from those input tensors in preprocess; I also added the key to single_request_inputs and restored the full Talker decode CUDA Graph. |
NSagan271
left a comment
There was a problem hiding this comment.
LGTM, I also tested out the latest changes and it works
What does this PR do?
Adds initial serving support for
Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice.qwen3_ttsmodel and adds its serving YAML and dependencies.Partition and graph-walk layout
Qwen3-TTS is split into two independently scheduled partitions:
talker_prefillandtalker_decode. Prefill consumes the text, speaker, and language inputs, then transitions to the autoregressive decode loop. Each decode iteration predicts one complete 16-group codec frame and feeds its embedding back into the next iteration.codec_chunkwalk. Codec tokens are streamed from Talker and buffered with left context. Once enough new frames are available, the conductor schedulescodec_chunk, emits the resulting PCM audio to the client, and re-arms the partition for the next chunk.This separation allows waveform decoding and audio streaming to overlap with subsequent Talker decode iterations instead of waiting for the full token sequence.
How was it tested?
.venv/bin/ruff check .FLASHINFER_WORKSPACE_BASE=/tmp/mstar-flashinfer .venv/bin/pytest -q test/modular/test_qwen3_tts_model.py27 passedReal-weight CUDA integration tests are included in
test/integration/test_qwen3_tts_real_weights.pyfor GPU environments with the Qwen3-TTS checkpoint available.Checklist
ruff check .passes