Repository navigation
[Perf][Qwen3-TTS]Cached Incremental Decode - #5202
Conversation
…uest Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
…eletion Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
…guration Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
…lice Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
… remove ref_context cache in code2wav, using decoder_state_cache for incremental decode, hold batched_chunked_decode as the only decode entrypoints rather than keep decode_group_chunks as fallback Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
… eager and graph, support eager batch and set default eager batch_size=4 to save mems, add guard for graph hit/fallback logging Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
…llm-omni into feat/qwen3tts-cache-decode
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
|
Resolved the review comments and revised the relevant test. All tests passed except for Test command: pytest -v \
tests/model_executor/models/qwen3_tts/test_qwen3_tts_incremental_decode.py \
tests/model_executor/models/qwen3_tts/test_qwen3_tts_code2wav.py \
tests/model_executor/stage_input_processors/test_qwen3_tts_async_chunk.pyResult: 159 passed, 1 failed. @linyueqian Please feel free to take another look. Thanks! |
… need to be routed to forward_exact explictly, remove single request handling protocal, only keeps batch helpers, return list[torch.Tensor] now to avoid reduntant pad->slice, now forward only slice stateless requests(left trim), all right padding-> right trim is holded inside wrapper or decoder, rather than in code2wav. Add related regression tests. Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
|
CI exposed a missing dummy-run guard that did not reproduce in my local serving tests for This is the scenario that the original In addition, this update removes the redundant: The updated output contracts are:
Feedback and suggestions regarding this contract change are welcome. The single-request stateful path has also been consolidated into Unit tests: pytest -v \
tests/model_executor/models/qwen3_tts/test_qwen3_tts_incremental_decode.py \
tests/model_executor/models/qwen3_tts/test_qwen3_tts_code2wav.py \
tests/model_executor/stage_input_processors/test_qwen3_tts_async_chunk.py \
tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.pyAll functional tests passed. The only remaining local failure is the previously mentioned log-capture issue. E2E tests: pytest -v \
tests/e2e/online_serving/test_qwen3_tts_customvoice.py \
tests/e2e/online_serving/test_qwen3_tts_base.pyBoth passed. I also reran the benchmark, and the numbers reported in the PR description remain unchanged. These changes primarily consolidate the decode routing, remove redundant output padding, and add robust CUDA Graph dummy-run fallbacks. No behavioral or performance regression was observed. |
linyueqian
left a comment
There was a problem hiding this comment.
Re-reviewed at e43c98aec. Five of my six items are cleanly fixed, and I re-ran the end-to-end check on hardware. Two things still need a change.
The reference-audio leak is fixed — measured, not just read
I re-ran the same A/B I used to find it: H20, Qwen3-TTS-12Hz-1.7B-Base, vLLM 0.26.0, async_chunk: false, voice=clone, 8.0800s reference, merge-base vs this head.
| arm | correlation of the leading 8.08s vs. the reference |
|---|---|
b3f4fbf9e (base) |
−0.0124 / +0.0012 |
e43c98aec (before your fix) |
+0.9010 / +0.9010 |
e43c98aec (now) |
+0.0005 / −0.0038 |
Durations no longer differ by the reference length either. #4421 stays fixed.
Thanks for the other four as well — and particularly for replacing the codes.shape[-1] == 4 shortcut with an explicit _is_dummy_run flag rather than just moving the magic number around. I only asked where the 4 came from; you turned it into a real contract.
1. left_context_size is indexed with the wrong index
row walks the compacted valid_codes_qf, but left_context_size is still sized and filled by original batch position:
279: left_context_size = [0] * len(request_ids_list) # original batch index
384: valid_indices.append(i) # the mapping, already here
459: for row in range(len(valid_codes_qf)): # compacted index
466: start = left_context_size[row] * self._total_upsampleAny request skipped earlier in the loop (empty codec payload, or a length not divisible by num_quantizers) shifts every later row. A valid ICL row then gets someone else's trim — too small and the reference audio comes back, too large and generated speech is cut. valid_indices[row] is the fix; it is already built for exactly this.
My A/B was single-request, so it cannot see this one. It needs a batch with a skipped row.
2. Aborted requests never free their decoder state
on_requests_finished pops the scheduler's request id, but _decoder_state_cache is keyed on meta.request_id from the payload — the external id. chunk_transfer_adapter.py keeps a request_ids_mapping precisely because those two can differ. An aborted request produces no terminal payload, so this pop is the only cleanup path, and it misses.
Combined with the cache now being a warning rather than a bound, that is an unbounded leak of GPU decoder state. I raised this as a soft question last time; on a second look it deserves to block. Either key the cache on the internal id, or translate before popping.
Two external reviewers independently flagged this one as the most serious item, which is what changed my mind about its severity.
| elif wav.dim() != 1: | ||
| raise ValueError(f"Qwen3-TTS batched decoder returned unexpected row shape {tuple(wav.shape)}") | ||
| if request_states is None: | ||
| start = left_context_size[row] * self._total_upsample |
There was a problem hiding this comment.
row is the compacted index over valid_codes_qf, but left_context_size is [0] * len(request_ids_list) — original batch positions. The loop above continues past empty and malformed requests without appending, so once one is skipped every later row reads a neighbour's trim.
Too small and the reference audio leaks again; too large and generated speech gets cut. valid_indices is already populated at line 384 for exactly this mapping:
| start = left_context_size[row] * self._total_upsample | |
| start = left_context_size[valid_indices[row]] * self._total_upsample |
Worth a regression test with a batch where the first request has an empty codec payload — the single-request A/B I ran cannot reach this.
| self._ref_context_cache_bytes -= self._tensor_nbytes(cached) | ||
| def on_requests_finished(self, finished_req_ids: set[str] | list[str]) -> None: | ||
| for req_id in finished_req_ids: | ||
| self._decoder_state_cache.pop(req_id, None) |
There was a problem hiding this comment.
This pops the scheduler's request id, but the cache is keyed on meta.request_id — the external id from the payload. chunk_transfer_adapter.py maintains a request_ids_mapping because those two are allowed to differ.
An aborted request emits no terminal payload, so the finished or segment_finished path in forward() never runs and this is the only cleanup left. When the ids differ, the pop misses and the entry stays. _decoder_state_cache_warn_entries logs but does not bound, so nothing stops it growing.
Either key the cache on the internal id, or translate here before popping. I asked about this as a question last round rather than a blocker; two external reviewers both rated it the most serious item and I think they are right.
…_size index misalignment, add regression for internal/external mismatch and index used for trimming Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
…llm-omni into feat/qwen3tts-cache-decode
|
Thanks for catching the potential issues that were not exposed by my local tests, @linyueqian. I’ve added regression tests for both issues. Regarding the external/internal request IDs, I previously verified that the cache key and finished-request ID were identical in my test setup, so I did not treat this as a blocker. But as you pointed out, the two IDs are not guaranteed to be identical and may drift when request ID mapping is involved. The decoder state cache is now explicitly keyed by the scheduler’s internal request ID, matching |
linyueqian
left a comment
There was a problem hiding this comment.
Approving at c63c2efc0. Both remaining items are fixed, and I re-ran the end-to-end check on the new head rather than carrying over the old result.
No reference-audio leak on this head. Same H20 A/B, control arm is current main (12a5f6fb), not the stale merge-base I used the first time:
| arm | leading-8.08s correlation vs. the reference |
|---|---|
12a5f6fb (main) |
−0.0034 / +0.0028 |
c63c2efc0 (this PR) |
+0.0005 / −0.0038 |
For contrast, the same probe read +0.9010 / +0.9010 before you fixed it. #4421 stays closed.
One thing worth stating plainly: the pr arm's output bytes are identical to my previous run against e43c98aec. That is expected, not a stale run — with a single valid request valid_indices[0] == 0, so the index fix is an identity transform on this path. My A/B therefore does not exercise fix 1 at all. Your test_qwen3_tts_code2wav.py additions do, which is the right place for it.
Fix 1 — left_context_size[valid_indices[row]] at :483. Exactly right.
Fix 2 — keying the cache on the runner-side id is better than what I suggested. I said "key on the internal id or translate before popping" and missed that direct forward() calls and CUDA Graph dummy runs have no runner id at all; a straight swap would have left them without a key. The cache_request_ids copy with a payload-id fallback handles that. Thanks for thinking it through further than the review did.
For the record, the six items I raised and where they landed:
| fix | |
|---|---|
| reference audio leaked into stateless output | left_context_size parsing restored, trim under request_states is None |
| uninitialized tail on short rows | decoder returns a list, so no padded region to trim |
is_segment_finished never evicted state |
finished or segment_finished |
codes.shape[-1] == 4 magic number |
explicit _is_dummy_run — you turned a question into a contract |
initial_chunk_frames hardcoded to 1 |
plumbed from config |
| unbounded state cache + id mismatch | internal-id key, plus a size warning |
Nice work on the perf numbers too — +34–40% at c8–c16 with WER 0.0089 is a real result, and the design held up under three rounds of review without needing to be restructured.
…#6898) Drop serving auto-enable after single-request RTF matched windowed+vllm-project#5202. Online stays on the default async-chunk path; offline may still opt in. Signed-off-by: Zhichao Zhang <60429419+smartDream-chao@users.noreply.github.com>
…#6898) Drop serving auto-enable after single-request RTF matched windowed+vllm-project#5202. Online stays on the default async-chunk path; offline may still opt in. Signed-off-by: Zhichao Zhang <60429419+smartDream-chao@users.noreply.github.com>
Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com> Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Purpose
Part W5 of #4855.
The existing
ref_context_cachestores reference codec tokens but not decoder state, causing every async Code2Wav invocation to repeatedly decode previous context.This PR introduces request-local incremental decoding for Qwen3-TTS Code2Wav. After the first chunk, async requests decode only newly completed codec frames using bounded cached context. Both ICL and x-vec-only requests are supported through shared eager and CUDA Graph phase grouping.
Files Modified
The core implementation changes and their test coverage are concentrated in the following files:
segmented_graph_wrapper.pymodeling_qwen3_tts_tokenizer_v2.pyqwen3_tts_code2wav.pystage_input_processors/qwen3_tts.pytest_qwen3_tts_incremental_decode.pyThe main implementation and test changes are concentrated in these five files. The detailed contract changes are described below. If needed, this PR can be split into smaller review units.
Contract Changes
For
async_chunk=true, the Talker-to-Code2Wav connector uses a delta-input protocol:Code2Wav maintains request-local decoder state keyed by request ID and releases it when the request finishes. The cached state contains:
pre_convboundary reconstruction;Stateful eager and CUDA Graph execution share the same
batched_request_decode()grouping policy. Requests are grouped by mode (iclorxvec), phase (first, growing, or rolling), and target suffix shape. Outputs and updated caches are restored to their original request slots.Code2Wav constructs one right-padded
[B, Q, F]transport batch and passes true codec lengths together withlist[request_state]for stateful decoding orNonefor stateless decoding. Padding and trimming remain decoder responsibilities:_last_output_audio_length.Async CUDA Graph shapes are derived from
initial_codec_chunk_frames,codec_chunk_frames, and optionalcodec_chunk_ramp. Non-async shapes continue to usedecode_cudagraph_capture_sizes, while captured batch sizes usedecode_cudagraph_batch_sizes.Stateful dummy runs use synthetic request IDs to initialize caches during warmup and external CUDA Graph capture. Dummy-run replay and fallback events are excluded from runtime CUDA Graph statistics.
decode_batch_max_sizelimits temporary batched-cache allocation for eager stateful phase groups.0leaves eager batching unlimited and does not affect CUDA Graph replay batch sizes.The non-async/stateless path remains supported and continues to use full-sequence chunked decoding without requiring request IDs.
CUDA Graph Shapes
The segmented wrapper captures graphs for ICL and xvec first chunks, every reachable growing suffix phase, and stable rolling decode.
With the default configuration:
the suffix transitions remain unchanged:
When a chunk ramp is configured, capture shapes are derived from its actual deltas. For example:
produces:
Intermediate ramp phases therefore use their real delta shapes instead of being padded to
codec_chunk_frames. Default graph shapes and runtime behavior are unchanged.Compatibility Notes
Cached decoding is numerically close to full decoding but is not guaranteed to be bit-exact. Differences in input shape, memory layout, and contiguity may produce small floating-point differences.
Test Plan
test_qwen3_tts_incremental_decode.pytest_qwen3_tts_code2wav.pytest_qwen3_tts_async_chunk.pyThe single local failure is
test_parse_ramp_warns_on_tail_mismatch. The expected warning is emitted but is not captured by the localcaplogconfiguration. The test and logging path are unchanged by this PR.Benchmark
Benchmarked on single 3090-24GB
Both baseline and cached runs used batch-size-1 graph capture. Additional experiments with graph buckets
[1, 2, 4]showed little difference up to concurrency 8, while batch size 4 or larger improved performance at concurrency 16 and above.Seed-TTS evaluation of 32 cached-path requests produced a mean WER of
0.0089, with no request, PCM capture, or ASR failures.0.26.0b8edbc8d23d24bf310f671a5cafe6b4d8949973ecc @Sy0307 @linyueqian
BEFORE SUBMITTING: read CONTRIBUTING.md and run the precheck-pr skill with the code agent for a self-check against project conventions.
(anything written below this line will be removed by GitHub Actions)