[Perf] Fix Qwen3-TTS latency regression (#3485)

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 authored and GitHub committed 2026-05-10 23:07:38 +00:00
1 parent 857356d5b7
commit 2c73cf3522
12 files changed
+315 -85

No files matched your search

@@ -291,8 +291,18 @@ def test_deterministic_across_calls(decoder, wrapper):
[2, 4, 8, 16, 25, 32, 50, 64, 128, 256, 325],
[512],
),
(
{
"codec_chunk_frames": 25,
"codec_left_context_frames": 72,
"decode_chunk_size": 400,
"decode_left_context": 17,
},
[2, 4, 8, 16, 25, 32, 64, 97, 128, 256, 417],
[325, 512],
),
],
ids=["default", "streaming_c33", "streaming_c25"],
ids=["default", "streaming_c33", "streaming_c25", "custom_decode_chunk"],
)
def test_compute_capture_sizes(kwargs, expected_in, not_expected):
"""compute_capture_sizes produces expected sizes capped by max useful size."""
@@ -23,13 +23,33 @@ class _FakeDecoder(nn.Module):
def __init__(self, total_upsample: int = _TOTAL_UPSAMPLE):
super().__init__()
self.total_upsample = total_upsample
self.decode_calls: list[dict[str, int]] = []
self.cudagraph_calls: list[dict[str, int | torch.device]] = []
def chunked_decode(self, codes: torch.Tensor) -> torch.Tensor:
def to(self, *args, **kwargs):
return self
def chunked_decode(
self,
codes: torch.Tensor,
*,
chunk_size: int = 300,
left_context_size: int = 25,
) -> torch.Tensor:
self.decode_calls.append(
{
"chunk_size": chunk_size,
"left_context_size": left_context_size,
}
)
frames = codes.shape[-1]
wav_len = frames * self.total_upsample + 6
wav = torch.arange(wav_len, dtype=torch.float32)
return wav.view(1, 1, -1)
def enable_cudagraph(self, **kwargs):
self.cudagraph_calls.append(kwargs)
def _fake_dec_config():
return SimpleNamespace(
@@ -38,7 +58,12 @@ def _fake_dec_config():
)
def _make_model() -> Qwen3TTSCode2Wav:
def _make_model(
*,
stage_connector_config=None,
async_chunk: bool = False,
device: torch.device | None = None,
) -> Qwen3TTSCode2Wav:
dec_config = _fake_dec_config()
tok_config = SimpleNamespace(
decoder_config=dec_config,
@@ -56,13 +81,51 @@ def _make_model() -> Qwen3TTSCode2Wav:
):
model = Qwen3TTSCode2Wav(
vllm_config=SimpleNamespace(
model_config=SimpleNamespace(model="unused"),
device_config=SimpleNamespace(device=torch.device("cpu")),
load_config=SimpleNamespace(),
model_config=SimpleNamespace(
model="unused",
revision=None,
stage_connector_config=stage_connector_config,
async_chunk=async_chunk,
),
device_config=SimpleNamespace(device=device or torch.device("cpu")),
)
)
return model
def _load_weights_noop(model: Qwen3TTSCode2Wav) -> set[str]:
class _FakeModelLoader:
class Source:
def __init__(self, **_: object):
pass
def __init__(self, _load_config: object):
pass
def _get_weights_iterator(self, _source: object):
return iter(())
class _FakeAutoWeightsLoader:
def __init__(self, *_: object, **__: object):
pass
def load_weights(self, _weights: object) -> set[str]:
return {"decoder.fake_weight"}
with (
patch(
"vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav.DefaultModelLoader",
_FakeModelLoader,
),
patch(
"vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav.AutoWeightsLoader",
_FakeAutoWeightsLoader,
),
):
return model.load_weights(iter(()))
def test_forward_trims_context_on_exact_frame_boundaries():
model = _make_model()
@@ -87,3 +150,89 @@ def test_forward_trims_trailing_padding_without_context():
audio = out.multimodal_outputs["model_outputs"][0]
expected = torch.arange(24, dtype=torch.float32)
torch.testing.assert_close(audio, expected)
def test_connector_codec_chunking_does_not_override_decode_chunking():
model = _make_model(
async_chunk=True,
stage_connector_config={
"extra": {
"codec_chunk_frames": 25,
"codec_left_context_frames": 72,
}
},
)
loaded = _load_weights_noop(model)
assert loaded == {"decoder.fake_weight"}
assert model._decode_chunk_frames == 300
assert model._decode_left_context_frames == 25
model.forward(
input_ids=torch.arange(12, dtype=torch.long),
runtime_additional_information=[{"meta": {"left_context_size": 0}}],
)
assert model.decoder.decode_calls[-1] == {
"chunk_size": 300,
"left_context_size": 25,
}
def test_decode_chunking_can_be_overridden_separately():
model = _make_model(
async_chunk=True,
stage_connector_config={
"extra": {
"codec_chunk_frames": 25,
"codec_left_context_frames": 72,
"decode_chunk_frames": 400,
"decode_left_context_frames": 17,
}
},
)
_load_weights_noop(model)
assert model._decode_chunk_frames == 400
assert model._decode_left_context_frames == 17
def test_decode_chunking_override_is_passed_to_cudagraph():
model = _make_model(
async_chunk=True,
device=torch.device("cuda"),
stage_connector_config={
"extra": {
"codec_chunk_frames": 25,
"codec_left_context_frames": 72,
"decode_chunk_frames": 400,
"decode_left_context_frames": 17,
}
},
)
_load_weights_noop(model)
assert model.decoder.cudagraph_calls[-1] == {
"device": torch.device("cuda"),
"codec_chunk_frames": 25,
"codec_left_context_frames": 72,
"decode_chunk_size": 400,
"decode_left_context": 17,
}
def test_invalid_decode_chunking_is_rejected():
model = _make_model(
async_chunk=True,
stage_connector_config={
"extra": {
"decode_chunk_frames": 0,
}
},
)
with pytest.raises(ValueError, match="decode_chunk_frames=0"):
_load_weights_noop(model)
@@ -34,7 +34,7 @@ def _req(rid, *, finished, initial_codec_chunk_frames=None):
)
def _tm(*, chunk_frames=25, left_context=25, max_num_seqs=1):
def _tm(*, chunk_frames=25, left_context=25, max_num_seqs=1, initial_chunk_frames=0):
return SimpleNamespace(
code_prompt_token_ids=defaultdict(list),
scheduler_max_num_seqs=max_num_seqs,
@@ -45,6 +45,7 @@ def _tm(*, chunk_frames=25, left_context=25, max_num_seqs=1):
"extra": {
"codec_chunk_frames": chunk_frames,
"codec_left_context_frames": left_context,
"initial_codec_chunk_frames": initial_chunk_frames,
}
}
),
@@ -99,48 +100,37 @@ def test_flush_on_finish():
_CASES = [
# ── IC boundary rule ──────────────────────────────────────────────
# IC phase: length <= chunk_size (uses <=, consistent with fish_speech)
# IC emits fill the entire first chunk_size worth of frames, so the
# normal phase always starts at a clean chunk boundary.
# initial_coverage = (chunk_size // initial_chunk_size) * initial_chunk_size
# initial_codec_chunk_frames only controls the first emitted chunk.
# After that, the processor returns to codec_chunk_frames-sized windows
# to avoid flooding Code2Wav with repeated tiny overlapping decodes.
#
# Dynamic IC=16, cs=25, initial_coverage=16
# IC does NOT evenly divide cs, so initial_coverage < cs.
# IC emits at 16; frames 17-25 remain in IC phase but 25%16!=0 -> hold.
# Normal phase: adjusted = length - 16, emit when adjusted % 25 == 0.
((25, 25, 0), 24, False, None), # IC: 24<=25, 24%16!=0 -> hold
((25, 25, 0), 25, False, None), # IC: 25<=25, 25%16!=0 -> hold
((25, 25, 0), 24, False, None),
((25, 25, 0), 25, False, None),
((25, 25, 0), 41, False, (16, 41)), # normal: adjusted=25, 25%25==0 -> emit, lc=16
#
# Per-request IC=10, cs=25, initial_coverage=20
# IC does NOT evenly divide cs; IC emits at 10, 20.
# Frames 21-25 are still IC phase but 21..25 % 10 != 0 -> hold.
((25, 25, 10), 9, False, None), # IC: 9%10!=0 -> hold
((25, 25, 10), 10, False, (0, 10)), # IC: 10%10==0 -> emit, lc=0
((25, 25, 10), 25, False, None), # IC: 25<=25, 25%10!=0 -> hold
((25, 25, 10), 45, False, (20, 45)), # normal: adjusted=25, 25%25==0 -> emit, lc=20
# Per-request IC=10, cs=25: first emit at 10, then 35, 60...
((25, 25, 10), 9, False, None),
((25, 25, 10), 10, False, (0, 10)),
((25, 25, 10), 25, False, None),
((25, 25, 10), 35, False, (10, 35)),
((25, 25, 10), 45, False, None),
((25, 25, 10), 5, True, (0, 5)), # finished flushes IC tail
((25, 25, 10), 33, True, (20, 33)), # finished flushes normal tail
((25, 25, 10), 33, True, (10, 33)), # finished flushes normal tail
#
# IC=8, cs=16: IC evenly divides chunk_size (edge case)
# initial_coverage = (16//8)*8 = 16 == chunk_size.
# IC fills the entire first chunk: emits at 8 and 16.
# Normal phase starts at frame 17; first normal emit at 16+16=32.
((16, 25, 8), 8, False, (0, 8)), # IC: 8%8==0 -> emit, lc=0
((16, 25, 8), 16, False, (8, 16)), # IC: 16<=16, 16%8==0 -> emit, lc=8
((16, 25, 8), 24, False, None), # normal: adjusted=8, 8%16!=0 -> hold
((16, 25, 8), 32, False, (16, 32)), # normal: adjusted=16, 16%16==0 -> first emit, lc=16
# IC=8, cs=16: first emit at 8, then 24, 40...
((16, 25, 8), 8, False, (0, 8)),
((16, 25, 8), 16, False, None),
((16, 25, 8), 24, False, (8, 24)),
((16, 25, 8), 32, False, None),
#
# IC=5, cs=25: IC evenly divides chunk_size
# initial_coverage = (25//5)*5 = 25 == chunk_size.
# IC fills the entire first chunk: emits at 5, 10, 15, 20, 25.
# Normal phase starts at frame 26; first normal emit at 25+25=50.
# Emit intervals: 5,5,5,5,5,25,25,... — smooth transition, no gap.
((25, 25, 5), 5, False, (0, 5)), # IC: 5%5==0 -> emit, lc=0
((25, 25, 5), 12, False, None), # IC: 12%5!=0 -> hold
((25, 25, 5), 25, False, (20, 25)), # IC: 25<=25, 25%5==0 -> emit, lc=20
((25, 25, 5), 30, False, None), # normal: adjusted=5, 5%25!=0 -> hold
((25, 25, 5), 50, False, (25, 50)), # normal: adjusted=25, 25%25==0 -> first emit, lc=25
# IC=5, cs=25: first emit at 5, then 30, 55...
((25, 25, 5), 5, False, (0, 5)),
((25, 25, 5), 12, False, None),
((25, 25, 5), 25, False, None),
((25, 25, 5), 30, False, (5, 30)),
((25, 25, 5), 50, False, None),
#
# Per-request override: IC=15 at n_frames=10 -> 10%15!=0 -> hold
((25, 25, 15), 10, False, None),
@@ -172,10 +162,10 @@ def test_dynamic_ic_adapts_to_load():
assert p1 is not None
assert len(p1["codes"]["audio"]) == _Q * 2
# High load: add 4 others -> active=5/8 -> IC=8 -> emit at 8
# High load on a new request: active=6/8 -> IC=8 -> emit at 8
for i in range(4):
tm.code_prompt_token_ids[f"other-{i}"] = [[0]]
p2 = _call(tm, "r", n_frames=8)
p2 = _call(tm, "new-high-load", n_frames=8)
assert p2 is not None
assert len(p2["codes"]["audio"]) == _Q * 8
@@ -201,13 +191,11 @@ def test_ic_load_change_mid_request():
for i in range(6):
tm.code_prompt_token_ids[f"other-{i}"] = [[0]] * 10
# IC for "r" is still cached as 2.
# initial_coverage = ((25-1)//2)*2 = 24, first normal emit at 24+25=49
# IC for "r" is still cached as 2. The first normal emit is at 2+25=27.
assert _call(tm, "r", n_frames=25) is None
assert _call(tm, "r", n_frames=27) is None
p3 = _call(tm, "r", n_frames=49)
p3 = _call(tm, "r", n_frames=27)
assert p3 is not None
assert p3["meta"]["left_context_size"] == 24
assert p3["meta"]["left_context_size"] == 2
# A *new* request under high load gets IC=16 (not IC=2).
# Frame 2 would emit under IC=2 but must hold under IC=16.
@@ -216,6 +204,25 @@ def test_ic_load_change_mid_request():
assert p4 is not None
def test_connector_initial_chunk_config_overrides_dynamic_ic():
tm = _tm(initial_chunk_frames=4, max_num_seqs=8)
# Under high load dynamic IC would be 16, but connector config pins the
# first chunk to 4 frames.
for i in range(7):
tm.code_prompt_token_ids[f"other-{i}"] = [[0]]
p1 = _call(tm, "r", n_frames=4)
assert p1 is not None
assert len(p1["codes"]["audio"]) == _Q * 4
# Only the first chunk uses the small size; the next emit is 4+25.
assert _call(tm, "r", n_frames=25) is None
p2 = _call(tm, "r", n_frames=29)
assert p2 is not None
assert p2["meta"]["left_context_size"] == 4
@pytest.mark.parametrize(
"active,max_bs,max_ic,expected",
[
@@ -269,7 +276,7 @@ def test_ref_code_context_applies_to_all_streaming_chunks():
"""ref_code is prepended as decoder context on every chunk, not just the first."""
tm = _tm()
rid = "r-ref2"
tm.code_prompt_token_ids[rid] = [_FRAME[:] for _ in range(20)]
tm.code_prompt_token_ids[rid] = [_FRAME[:] for _ in range(35)]
tm.put_req_chunk[rid] = 1
ref_code = torch.tensor([[9, 9, 9, 9], [8, 8, 8, 8]], dtype=torch.long)
tm.request_payload[rid] = ref_code
@@ -284,7 +291,7 @@ def test_ref_code_context_applies_to_all_streaming_chunks():
assert payload is not None
# ref_code (2 frames) prepended as left context on second chunk too
assert payload["meta"]["left_context_size"] == 10 + 2
assert len(payload["codes"]["audio"]) == _Q * (20 + 2)
assert len(payload["codes"]["audio"]) == _Q * (35 + 2)
def test_ref_code_context_can_be_buffered_before_first_emit():