[Feature][TTS] adaptive chunk ramp (Phase 2 buffer-feedback controller) (#6001)

Signed-off-by: Wallbreazzz <110282866+Wallbreazzz@users.noreply.github.com>
This commit is contained in:
Wallbreazzz authored and GitHub committed 2026-08-28 02:11:05 -07:00
1 parent da94c3ebe1
commit 4ad455e61e
7 files changed
+871 -8

No files matched your search

@@ -8,9 +8,12 @@ import pytest
import torch
from vllm_omni.model_executor.stage_input_processors.chunk_size_utils import (
AdaptiveChunkController,
compute_adaptive_emit,
compute_dynamic_initial_chunk_size,
compute_ramp_emit,
max_ic_for_chunk_size,
parse_adaptive_config,
parse_chunk_ramp,
ramp_chunk_size,
ramp_cumulative,
@@ -41,7 +44,17 @@ def _req(rid, *, finished, initial_codec_chunk_frames=None):
)
def _tm(*, chunk_frames=25, left_context=25, max_num_seqs=1, initial_chunk_frames=0, chunk_ramp=None):
def _tm(
*,
chunk_frames=25,
left_context=25,
max_num_seqs=1,
initial_chunk_frames=0,
chunk_ramp=None,
adaptive=False,
adaptive_min=2,
adaptive_margin=200.0,
):
extra = {
"codec_chunk_frames": chunk_frames,
"codec_left_context_frames": left_context,
@@ -49,11 +62,16 @@ def _tm(*, chunk_frames=25, left_context=25, max_num_seqs=1, initial_chunk_frame
}
if chunk_ramp is not None:
extra["codec_chunk_ramp"] = chunk_ramp
if adaptive:
extra["codec_chunk_adaptive"] = True
extra["codec_chunk_min_frames"] = adaptive_min
extra["codec_chunk_safety_margin_ms"] = adaptive_margin
return SimpleNamespace(
code_prompt_token_ids=defaultdict(list),
scheduler_max_num_seqs=max_num_seqs,
put_req_chunk=defaultdict(int),
ramp_chunk_count=defaultdict(int),
_adaptive_states={},
request_payload={},
connector=SimpleNamespace(config={"extra": extra}),
)
@@ -605,18 +623,32 @@ class TestRampHelpers:
def test_parse_ramp_warns_on_tail_mismatch(self, caplog):
import logging
with caplog.at_level(logging.WARNING):
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)
assert result == [4, 4, 8]
assert any("reintroduces" in r.message for r in caplog.records)
assert "reintroduces" in caplog.text
def test_parse_ramp_no_warn_on_tail_match(self, caplog):
import logging
with caplog.at_level(logging.WARNING):
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)
assert result == [4, 4, 8, 16, 25]
assert not any("reintroduces" in r.message for r in caplog.records)
assert "reintroduces" not in caplog.text
@pytest.mark.parametrize(
"index,ramp,steady,expected",
@@ -941,3 +973,488 @@ class TestChunkRampEmission:
p_seg2_0 = self._emit(tm, rid, 4)
assert p_seg2_0 is not None
assert p_seg2_0.meta.left_context_size == 0
class TestAdaptiveController:
def test_compute_next_chunk_size_basic(self):
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=20.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=10,
)
now = 0.5
size = ctrl.compute_next_chunk_size(now, min_frames=2, max_frames=25, safety_margin_ms=100.0)
assert 2 <= size <= 25
def test_compute_next_chunk_size_clamp_min(self):
"""min_frames floor holds when ramp_floor (last_target + delta) is
below min_frames. Uses a small EWMA so delta=2 and last_target=0,
i.e. ramp_floor=2 < min_frames=4 -> greedy=min_frames wins."""
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=10.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=1,
)
# buffer = 80 - 70 = 10ms (positive but < margin=200)
# greedy: available=0, max_n=0, greedy=min_frames=4
# ramp_floor: last_target=0 + delta=max(2,int(10/15))=2 -> 2 < 4
now = 0.07
size = ctrl.compute_next_chunk_size(now, min_frames=4, max_frames=25, safety_margin_ms=200.0)
assert size == 4 # min_frames floor dominates ramp_floor=2
def test_compute_next_chunk_size_clamp_max(self):
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=10.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=100,
)
now = 1.0
size = ctrl.compute_next_chunk_size(now, min_frames=2, max_frames=25, safety_margin_ms=50.0)
assert size == 25
def test_hysteresis_locks_steady(self):
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=10.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=50,
)
now = 0.1
size1 = ctrl.compute_next_chunk_size(now, min_frames=2, max_frames=25, safety_margin_ms=50.0)
assert ctrl.steady_state_reached
assert size1 == 25
size2 = ctrl.compute_next_chunk_size(now + 10.0, min_frames=2, max_frames=25, safety_margin_ms=50.0)
assert size2 == 25
def test_ramp_floor_overrides_greedy_when_buffer_low(self):
"""When buffer is negative (overloaded), ramp_floor still forces
gradual growth to avoid stagnating at min_frames (which would cause
too many Code2Wav decode passes and RTF explosion)."""
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=50.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=2,
last_target=4,
)
# buffer = 160 - 200 = -40ms (negative)
# greedy: available=0, max_n=0, greedy=2
# ramp_floor: last_target=4 + delta=max(2,int(50/15))=3 -> 7
now = 0.2
size = ctrl.compute_next_chunk_size(now, min_frames=2, max_frames=25, safety_margin_ms=50.0)
assert not ctrl.steady_state_reached
assert size == 7 # ramp_floor (unconditional) dominates greedy=2
def test_ramp_floor_dominates_when_last_target_set(self):
"""Once last_target is set (post-emit), the unconditional ramp_floor
(last_target + ewma-scaled delta) dominates greedy whenever greedy is
smaller. This is intended: chunk size grows monotonically toward max
to minimize Code2Wav decode passes (pre-W5; see W5 coupling)."""
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=50.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=4,
last_target=4,
)
# buffer = 320 - 100 = 220ms (positive)
# greedy: available=170, max_n=int(170/50)=3, greedy=3
# ramp_floor: 4 + delta=max(2,int(50/15))=3 -> 7
now = 0.1
size = ctrl.compute_next_chunk_size(now, min_frames=2, max_frames=25, safety_margin_ms=50.0)
assert size == 7 # ramp_floor (7) dominates greedy (3)
def test_greedy_dominates_when_ramp_floor_low(self):
"""When last_target=0 (fresh, no prior emit) and buffer is positive
but below the hysteresis threshold, greedy selects an intermediate
size larger than ramp_floor and greedy dominates. This is the only
regime where greedy (not ramp_floor or hysteresis) sets the size."""
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=50.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=8,
last_target=0,
)
# buffer = 640 - 100 = 540ms (positive, < hysteresis 1300ms)
# greedy: available=490, max_n=int(490/50)=9, greedy=9
# ramp_floor: 0 + delta=max(2,int(50/15))=3 -> 3
now = 0.1
size = ctrl.compute_next_chunk_size(now, min_frames=2, max_frames=25, safety_margin_ms=50.0)
assert not ctrl.steady_state_reached
assert size == 9 # greedy (9) dominates ramp_floor (3)
def test_record_emit_ewma_update(self):
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=20.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=4,
)
ctrl.record_emit(now=0.5, n_frames=8, accumulated=12, target_size=8)
assert ctrl.frames_emitted_this_segment == 12
assert ctrl.accumulated_at_last_emit == 12
assert ctrl.last_emit_time == 0.5
assert ctrl.last_target == 8 # D6: stores target, not actual
measured = (0.5 - 0.0) * 1000.0 / 8
expected = 0.3 * measured + 0.7 * 20.0
assert abs(ctrl.frame_time_ewma_ms - expected) < 0.01
def test_record_emit_first_then_second(self):
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=0.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=0,
)
ctrl.record_emit(now=0.4, n_frames=4, accumulated=4, target_size=4)
assert ctrl.frame_time_ewma_ms == 0.0
assert ctrl.frames_emitted_this_segment == 4
assert ctrl.last_target == 4
ctrl.record_emit(now=0.8, n_frames=4, accumulated=8, target_size=4)
expected = (0.8 - 0.4) * 1000.0 / 4
assert abs(ctrl.frame_time_ewma_ms - expected) < 0.01
def test_record_emit_collects_underrun(self):
"""record_emit mirrors stream_client.py's playback-cursor underrun
model: per_chunk_gap_ms records each chunk's lateness, total_gap_s
accumulates gaps > 5ms. Deterministic given explicit `now`."""
ctrl = AdaptiveChunkController(
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=0,
)
# chunk0: now=0.0 (= first_emit_time), n=2 -> 0.16s audio
ctrl.record_emit(now=0.0, n_frames=2, accumulated=2, target_size=2)
assert ctrl.chunk_sizes == [2]
assert ctrl.per_chunk_gap_ms == [0.0]
assert ctrl.total_gap_s == 0.0
assert abs(ctrl.playback_cursor_s - 0.16) < 1e-9
# chunk1: now=0.2 (200ms wall) -> arrival 0.2, late by 0.2-0.16=0.04s
ctrl.record_emit(now=0.2, n_frames=2, accumulated=4, target_size=2)
assert ctrl.chunk_sizes == [2, 2]
assert abs(ctrl.per_chunk_gap_ms[1] - 40.0) < 1e-6
assert abs(ctrl.total_gap_s - 0.04) < 1e-9
assert abs(ctrl.playback_cursor_s - 0.36) < 1e-9
def test_ewma_zero_skips_hysteresis(self):
"""When EWMA=0, hysteresis must not trigger (steady_gen_ms=0
would make any positive buffer satisfy the condition)."""
ctrl = AdaptiveChunkController(
frame_time_ewma_ms=0.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=10,
)
now = 0.001
size = ctrl.compute_next_chunk_size(now, min_frames=2, max_frames=25, safety_margin_ms=50.0)
assert not ctrl.steady_state_reached
assert size == 2 # min_frames (EWMA=0 → max_n=min_frames)
def test_ramp_divisor_configurable(self):
"""ramp_divisor/ramp_delta_min are controller fields wired from config.
Defaults (15/2) preserve the original formula; changing them changes
delta. Lets a deploy tune the ramp via yaml without code changes, while
the unit tests (which use defaults) stay green."""
# default divisor=15: ewma=50 -> delta=max(2,int(50/15))=3 -> floor=4+3=7
c1 = AdaptiveChunkController(
frame_time_ewma_ms=50.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=2,
last_target=4,
)
assert c1.compute_next_chunk_size(0.2, 2, 25, 50.0) == 7
# divisor=10: delta=max(2,int(50/10))=5 -> floor=4+5=9
c2 = AdaptiveChunkController(
frame_time_ewma_ms=50.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=2,
last_target=4,
ramp_divisor=10,
)
assert c2.compute_next_chunk_size(0.2, 2, 25, 50.0) == 9
# delta_min=4 + ewma=10 (int(10/15)=0): delta=max(4,0)=4 -> floor=4+4=8
c3 = AdaptiveChunkController(
frame_time_ewma_ms=10.0,
first_emit_time=0.0,
last_emit_time=0.0,
frames_emitted_this_segment=2,
last_target=4,
ramp_delta_min=4,
)
assert c3.compute_next_chunk_size(0.2, 2, 25, 50.0) == 8
class TestAdaptiveEmitHelper:
@pytest.mark.parametrize(
"length,accumulated,target,finished,expected_emit,expected_ctx",
[
(4, 0, 4, False, True, 4),
(3, 0, 4, False, False, 0),
(12, 4, 8, False, True, 8),
(10, 4, 8, False, False, 0),
# D3: finished flushes ALL remaining frames, not just target_size
(6, 4, 8, True, True, 2),
(4, 4, 8, True, True, 0),
(20, 4, 8, True, True, 16), # new_frames=16 > target=8 → flush 16
(30, 0, 25, True, True, 30), # finished, new_frames=30 > target=25 → flush 30
# B1: non-finished path also ships ALL accumulated frames (new_frames),
# not just target_size. When new_frames > target_size (e.g. scheduler
# stall dropped target below queued frames), returning target_size
# would cause the [-context_length:] slice to skip surplus frames.
(10, 0, 4, False, True, 10), # new_frames=10 > target=4 → ship 10
(8, 2, 4, False, True, 6), # new_frames=6 > target=4 → ship 6
(100, 0, 25, False, True, 100), # new_frames=100 > target=25 → ship 100
],
)
def test_compute_adaptive_emit(self, length, accumulated, target, finished, expected_emit, expected_ctx):
emit, ctx = compute_adaptive_emit(length, accumulated, target, finished)
assert emit == expected_emit
assert ctx == expected_ctx
class TestAdaptiveConfigParsing:
def test_disabled_by_default(self):
enabled, min_f, margin, divisor, delta_min = parse_adaptive_config({})
assert enabled is False
assert min_f == 2
assert margin == 50.0
assert divisor == 15
assert delta_min == 2
def test_enabled(self):
cfg = {"codec_chunk_adaptive": True}
enabled, *_ = parse_adaptive_config(cfg)
assert enabled is True
def test_custom_values(self):
cfg = {
"codec_chunk_adaptive": True,
"codec_chunk_min_frames": 4,
"codec_chunk_safety_margin_ms": 300.0,
"codec_chunk_ramp_divisor": 10,
"codec_chunk_ramp_delta_min": 3,
}
enabled, min_f, margin, divisor, delta_min = parse_adaptive_config(cfg)
assert enabled is True
assert min_f == 4
assert margin == 300.0
assert divisor == 10
assert delta_min == 3
def test_min_frames_clamped(self):
cfg = {"codec_chunk_adaptive": True, "codec_chunk_min_frames": 0}
_, min_f, _, _, _ = parse_adaptive_config(cfg)
assert min_f == 1
def test_ramp_divisor_clamped(self):
cfg = {"codec_chunk_adaptive": True, "codec_chunk_ramp_divisor": 0}
_, _, _, divisor, _ = parse_adaptive_config(cfg)
assert divisor == 1
def test_ramp_delta_min_clamped(self):
cfg = {"codec_chunk_adaptive": True, "codec_chunk_ramp_delta_min": 0}
_, _, _, _, delta_min = parse_adaptive_config(cfg)
assert delta_min == 1
class TestAdaptiveEmission:
def _emit(self, tm, rid, n_frames, finished=False):
tm.code_prompt_token_ids[rid] = [_FRAME[:] for _ in range(n_frames)]
return talker2code2wav_async_chunk(
transfer_manager=tm,
multimodal_output={"codes": {"audio": torch.zeros((0,))}},
request=_req(rid, finished=finished),
is_finished=finished,
)
def test_adaptive_chunk0_uses_dynamic_ic(self):
tm = _tm(adaptive=True, adaptive_min=2, max_num_seqs=8)
rid = "adaptive-ic"
p0 = self._emit(tm, rid, 2)
assert p0 is not None
assert len(p0.codes.audio) == _Q * 2
def test_adaptive_chunk0_holds_until_ic(self):
tm = _tm(adaptive=True, adaptive_min=2, max_num_seqs=8)
rid = "adaptive-hold"
p = self._emit(tm, rid, 1)
assert p is None
p0 = self._emit(tm, rid, 2)
assert p0 is not None
assert len(p0.codes.audio) == _Q * 2
def test_adaptive_finished_flush(self):
tm = _tm(adaptive=True, adaptive_min=2, max_num_seqs=8)
rid = "adaptive-flush"
p0 = self._emit(tm, rid, 2)
assert p0 is not None
tm.ramp_chunk_count[rid] = 1
p_fin = self._emit(tm, rid, 5, finished=True)
assert p_fin is not None
assert p_fin.meta.finished.item() is True
def test_adaptive_finished_no_new_frames(self):
tm = _tm(adaptive=True, adaptive_min=2, max_num_seqs=8)
rid = "adaptive-no-new"
p0 = self._emit(tm, rid, 2)
assert p0 is not None
tm.ramp_chunk_count[rid] = 1
p_fin = self._emit(tm, rid, 2, finished=True)
assert p_fin is not None
assert p_fin.meta.finished.item() is True
assert p_fin.codes.audio.numel() == 0
def test_adaptive_precedence_over_fixed_ramp(self):
tm = _tm(chunk_ramp=[4, 4, 8, 16, 25], adaptive=True, adaptive_min=2, max_num_seqs=8)
rid = "adaptive-precedence"
p = self._emit(tm, rid, 1)
assert p is None
p0 = self._emit(tm, rid, 2)
assert p0 is not None
assert len(p0.codes.audio) == _Q * 2
def test_adaptive_backward_compat(self):
tm = _tm(initial_chunk_frames=1)
rid = "no-adaptive"
p0 = self._emit(tm, rid, 1)
assert p0 is not None
assert len(p0.codes.audio) == _Q * 1
def test_adaptive_segment_reset(self):
tm = _tm(adaptive=True, adaptive_min=2, max_num_seqs=8)
rid = "adaptive-segment"
p0 = self._emit(tm, rid, 2)
assert p0 is not None
tm.ramp_chunk_count[rid] = 1
tm.put_req_chunk[rid] = 1
tm.code_prompt_token_ids[rid] = []
tm.ramp_chunk_count.pop(rid, None)
tm._adaptive_states.pop(rid, None)
p_seg2 = self._emit(tm, rid, 2)
assert p_seg2 is not None
assert p_seg2.meta.left_context_size == 0
def test_adaptive_with_ref_code_first_chunk(self):
tm = _tm(adaptive=True, adaptive_min=2, left_context=25, max_num_seqs=8)
rid = "adaptive-ref"
tm.code_prompt_token_ids[rid] = [_FRAME[:] for _ in range(2)]
ref_code = torch.tensor([[9, 9, 9, 9], [8, 8, 8, 8]], dtype=torch.long)
payload = talker2code2wav_async_chunk(
transfer_manager=tm,
multimodal_output={"codes": {"audio": torch.zeros((0,)), "ref": ref_code}},
request=_req(rid, finished=False),
is_finished=False,
)
assert payload is not None
assert payload.meta.ref_context_size == 2
assert payload.meta.ref_context_included is True
assert payload.meta.left_context_size == 2
class TestAdaptiveAccumulationPath:
"""Regression test for the frame-skipping bug (PR #6001 review B1).
Appends one frame per call with a controlled clock, asserting that
the concatenation of every emitted chunk equals the accumulated
frame sequence, in order, with no gaps. Each frame has a distinct
value so the assertion can detect a skipped frame rather than just
a short total.
"""
def test_no_frames_skipped_when_target_drops(self, monkeypatch):
"""When greedy target_size is high (large buffer) and then drops
(clock jumps), new_frames > target_size at the emit point.
compute_adaptive_emit now returns new_frames (not target_size),
so all accumulated frames are shipped and the [-context_length:]
slice stays contiguous with the previous emit. last_target is set
to target_size (not n_frames) to prevent the ramp floor from
ratcheting up on transient overshoot.
Clock schedule (max_num_seqs=4 gives IC=2 via dynamic IC):
t=0.0 chunk 0 (IC=2), emit 2. ewma=0.
t=0.05 emit target=4 (ramp_floor). ewma=12.5 (direct assign).
t=0.05 greedy dominates (target=18), 17 frames accumulate (holds).
t=100.0 clock jumps -> greedy collapses, target drops to
ramp_floor=6. new_frames=18 > 6 -> emit 18 (all new frames).
last_target=6 (not 18, preventing floor ratchet).
"""
tm = _tm(
adaptive=True,
adaptive_min=2,
max_num_seqs=4,
)
rid = "adaptive-accum"
tm.code_prompt_token_ids[rid] = []
clock = [0.0]
monkeypatch.setattr(
"vllm_omni.model_executor.stage_input_processors.qwen3_tts.time.monotonic",
lambda: clock[0],
)
emitted_indices: list[int] = []
total_frames = 40
def append_and_emit(frame_idx, finished=False):
tm.code_prompt_token_ids[rid].append([frame_idx] * _Q)
p = talker2code2wav_async_chunk(
transfer_manager=tm,
multimodal_output={"codes": {"audio": torch.zeros((0,))}},
request=_req(rid, finished=finished),
is_finished=finished,
)
if p is not None:
if p.codes.audio.numel() > 0:
n = p.codes.audio.numel() // _Q
emitted_indices.extend(p.codes.audio[:n].tolist())
tm.ramp_chunk_count[rid] += 1
return p
# Phase 1 (t=0.0): chunk 0, IC=2
for i in range(2):
append_and_emit(i)
# Phase 2 (t=0.05): build EWMA, emit target=4 at length=6
clock[0] = 0.05
for i in range(2, 6):
append_and_emit(i)
# Phase 3 (t=0.05): greedy target=18 (large buffer), accumulate
# 17 frames while holding (new_frames 1..17 < 18)
for i in range(6, 23):
append_and_emit(i)
# Phase 4 (t=100.0): clock jumps, greedy collapses, target drops
# to ramp_floor=6. new_frames=18 > 6 -> bug trigger point.
clock[0] = 100.0
for i in range(23, total_frames):
append_and_emit(i, finished=(i == total_frames - 1))
# Assert: all frames 0..39 emitted, in order, no gaps.
expected = list(range(total_frames))
assert emitted_indices == expected, (
f"Frame loss detected! "
f"Missing: {sorted(set(expected) - set(emitted_indices))}, "
f"Extra: {sorted(set(emitted_indices) - set(expected))}"
)
+11
View File
@@ -33,6 +33,17 @@ connectors:
# initial_codec_chunk_frames (including the per-request API field).
# Example:
# codec_chunk_ramp: [4, 4, 8, 16, 25]
# Adaptive chunk ramp (opt-in, takes precedence over fixed codec_chunk_ramp):
# Chunk 0 uses dynamic IC; chunk 1+ sizes from live buffer-depth feedback.
# Self-tunes across hardware (NPU/NVIDIA) and load conditions.
# codec_chunk_adaptive: true
# codec_chunk_min_frames: 2
# Code2Wav decode + transfer margin
# codec_chunk_safety_margin_ms: 50
# EWMA->delta divisor (delta=max(delta_min, int(ewma/divisor))); higher=gentler ramp
# codec_chunk_ramp_divisor: 15
# min chunk-size growth per emit
# codec_chunk_ramp_delta_min: 2
# Decoder CUDA Graph sizing:
# * async_chunk=true derives all stateful ICL/xvec shapes from
# codec_chunk_ramp when enabled, otherwise from
@@ -38,6 +38,17 @@ connectors:
# initial_codec_chunk_frames (including the per-request API field).
# Example:
# codec_chunk_ramp: [4, 4, 8, 16, 25]
# Adaptive chunk ramp (opt-in, takes precedence over fixed codec_chunk_ramp):
# Chunk 0 uses dynamic IC; chunk 1+ sizes from live buffer-depth feedback.
# Self-tunes across hardware (NPU/NVIDIA) and load conditions.
# codec_chunk_adaptive: true
# codec_chunk_min_frames: 2
# Code2Wav decode + transfer margin
# codec_chunk_safety_margin_ms: 50
# EWMA->delta divisor (delta=max(delta_min, int(ewma/divisor))); higher=gentler ramp
# codec_chunk_ramp_divisor: 15
# min chunk-size growth per emit
# codec_chunk_ramp_delta_min: 2
# async_chunk derives stateful ICL/xvec graph shapes from the ramp when
# enabled, otherwise from the fixed chunk settings above, and ignores
# decode_cudagraph_capture_sizes. Keep B>1
@@ -94,6 +94,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
# but popped at segment boundaries (unlike put_req_chunk which is
# request-global for connector key continuity).
self.ramp_chunk_count: dict[str, int] = defaultdict(int)
self._adaptive_states: dict[str, Any] = {}
self.upstream_exhausted_requests: set[str] = set()
self.segment_finished_requests: set[str] = set()
self.request_payload = {}
@@ -584,6 +585,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
if is_segment_finished:
self.code_prompt_token_ids.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)
if cached_ic is not None:
cached_ic.pop(external_req_id, None)
@@ -677,6 +679,7 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
self.code_prompt_token_ids.pop(external_req_id, None)
self.requests_num_chunks_sent.pop(external_req_id, None)
self.ramp_chunk_count.pop(external_req_id, None)
self._adaptive_states.pop(external_req_id, None)
self._pending_streaming_prefills.pop(external_req_id, None)
cached_ic = getattr(self, "_cached_ic", None)
@@ -2,6 +2,8 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import logging
from dataclasses import dataclass, field
from typing import ClassVar
logger = logging.getLogger(__name__)
@@ -127,3 +129,228 @@ def compute_ramp_emit(
return True, 0
return True, length - prev_threshold
@dataclass
class AdaptiveChunkController:
"""Buffer-feedback controller for adaptive chunk sizing.
Replaces the fixed ramp table with a greedy buffer-feedback rule.
Chunk 0 uses dynamic IC; chunk 1+ sizes are decided per-chunk from
live buffer-depth signals.
State is per-segment: the controller is discarded and rebuilt at
segment boundaries via ``_adaptive_states.pop()`` in the adapter.
"""
frame_time_ewma_ms: float = 0.0
first_emit_time: float = 0.0
last_emit_time: float = 0.0
frames_emitted_this_segment: int = 0
steady_state_reached: bool = False
accumulated_at_last_emit: int = 0
last_target: int = 0
# Per-request underrun telemetry, mirroring stream_client.py's playback
# cursor model so server-side numbers are comparable to client-reported
# AUDIO_UNDERRUN. Collected in record_emit; logged once at request end via
# log_summary(). total_gap_s accumulates only gaps > 5ms (matching
# stream_client's silence-insert threshold); per_chunk_gap_ms records every
# chunk's gap (including 0) for the full trajectory.
chunk_sizes: list = field(default_factory=list)
per_chunk_gap_ms: list = field(default_factory=list)
playback_cursor_s: float = 0.0
total_gap_s: float = 0.0
# Ramp-rate tuning, sourced from connector config (codec_chunk_ramp_divisor
# / codec_chunk_ramp_delta_min) so deploys can tune the ramp without code
# changes. Defaults preserve the original 15/2 behavior.
ramp_divisor: int = 15
ramp_delta_min: int = 2
EWMA_ALPHA: ClassVar[float] = 0.3
FRAME_DURATION_MS: ClassVar[float] = 80.0
def compute_next_chunk_size(
self,
now: float,
min_frames: int,
max_frames: int,
safety_margin_ms: float,
) -> int:
if self.steady_state_reached:
return max_frames
emitted_ms = self.frames_emitted_this_segment * self.FRAME_DURATION_MS
elapsed_ms = (now - self.first_emit_time) * 1000.0
buffer_ms = emitted_ms - elapsed_ms
# Hysteresis: only check when EWMA has a valid measurement.
if self.frame_time_ewma_ms > 0:
steady_gen_ms = max_frames * self.frame_time_ewma_ms
if buffer_ms >= steady_gen_ms + safety_margin_ms:
self.steady_state_reached = True
return max_frames
# Greedy: select the largest n where buffer can absorb
# n * frame_time + safety_margin.
available_ms = max(0.0, buffer_ms - safety_margin_ms)
if self.frame_time_ewma_ms > 0:
max_n = int(available_ms / self.frame_time_ewma_ms)
else:
max_n = min_frames
greedy_result = max(min_frames, min(max_n, max_frames))
# Ramp floor: chunk size grows by an EWMA-scaled delta per emit.
# Unconditional (never gated on buffer sign): in the saturated
# regime (per-stream RTF > 1) buffer is structurally negative and
# shrinking chunks would multiply Code2Wav decode cost -- each chunk
# re-decodes the left_context, cost ~ (72+n)/n, so n=2 costs ~10x
# n=25. Growing toward max minimizes decode passes, the only lever
# pre-W5. delta scales with per-frame time: slow hardware (NPU,
# ewma~150) ramps fast (delta=10, ~3 emits to max); fast hardware
# (NVIDIA, ewma~16) ramps gently (delta=2) for lower TTFP. This
# cross-hardware self-tuning is why the adaptive controller exists.
# last_target is set to the controller's target_size in record_emit
# (on actual emit), not here -- compute_next_chunk_size may be called
# multiple times per emit as frames accumulate one-by-one.
# WARNING: do NOT gate ramp_floor on buffer>0. That collapses to
# greedy=min_frames under load and triggers a vocoder-cost death
# spiral (see #4855 linyueqian 2026-07-19 comment + W5 coupling).
# Downward chunk-size adaptation is deferred until W5 (streaming
# decoder state) lands -- see PR description.
if self.frame_time_ewma_ms > 0:
ramp_delta = max(self.ramp_delta_min, int(self.frame_time_ewma_ms / self.ramp_divisor))
else:
ramp_delta = self.ramp_delta_min
ramp_floor = self.last_target + ramp_delta
result = max(ramp_floor, greedy_result)
result = min(result, max_frames)
return result
def record_emit(
self,
now: float,
n_frames: int,
accumulated: int,
target_size: int,
) -> None:
if self.frames_emitted_this_segment > 0:
dt_ms = (now - self.last_emit_time) * 1000.0
measured_ms = dt_ms / n_frames if n_frames > 0 else self.frame_time_ewma_ms
if self.frame_time_ewma_ms == 0:
self.frame_time_ewma_ms = measured_ms
else:
self.frame_time_ewma_ms = (
self.EWMA_ALPHA * measured_ms + (1 - self.EWMA_ALPHA) * self.frame_time_ewma_ms
)
# Per-chunk underrun telemetry: mirror stream_client.py's playback
# cursor so server-side total_gap is comparable to the client's
# AUDIO_UNDERRUN. arrival_s = wall since first emit; gap_s = how late
# this chunk is vs where playback had reached.
arrival_s = now - self.first_emit_time
audio_dur_s = n_frames * self.FRAME_DURATION_MS / 1000.0
gap_s = max(0.0, arrival_s - self.playback_cursor_s)
if gap_s > 0.005:
self.total_gap_s += gap_s
self.per_chunk_gap_ms.append(gap_s * 1000.0)
self.chunk_sizes.append(n_frames)
self.playback_cursor_s = max(arrival_s, self.playback_cursor_s) + audio_dur_s
self.last_emit_time = now
self.frames_emitted_this_segment += n_frames
self.accumulated_at_last_emit = accumulated
# last_target tracks the controller's intended target_size, NOT the
# actual n_frames shipped. With the B1 fix (compute_adaptive_emit
# returns new_frames), n_frames can overshoot target_size during a
# latency spike. Using n_frames here would ratchet ramp_floor to the
# spike size and lock the controller toward max_frames prematurely.
self.last_target = target_size
def log_summary(self, request_id: str) -> None:
"""Emit one INFO line at segment/request end.
Mirrors stream_client.py's underrun model so total_gap_s is
comparable to the client-reported AUDIO_UNDERRUN. Fires on
``finished`` which is ``is_segment_finished or is_finished`` (see
chunk_transfer_adapter), so a resumable /v1/realtime session emits
one line per segment. INFO level so it is visible by default
without any env var or dedicated logger. Only emits fields vLLM
stats do NOT already report: chunk-size trajectory, per-chunk
underrun, and total underrun. (pure/wall/ewma were dropped: vLLM
already reports audio_duration, e2e/stage wall, and
tpot/time_per_output_unit respectively; a trailing ewma also could
not reflect its intra-request evolution.)
"""
logger.info(
"[ADAPTIVE] req=%s done: chunks=%s gaps_ms=%s total_gap=%.3fs",
request_id,
self.chunk_sizes,
[round(g, 1) for g in self.per_chunk_gap_ms],
self.total_gap_s,
)
def compute_adaptive_emit(
length: int,
accumulated_at_last_emit: int,
target_size: int,
finished: bool,
) -> tuple[bool, int]:
"""Decide whether to emit under the adaptive controller.
Same return contract as ``compute_ramp_emit``:
- ``(False, 0)``: hold.
- ``(True, >0)``: emit with this many new frames.
- ``(True, 0)``: finished with no new frames (empty-finished sentinel).
"""
new_frames = length - accumulated_at_last_emit
if finished and new_frames <= 0:
return True, 0
# When finished, flush ALL remaining frames to avoid data loss.
# Truncating to target_size would silently drop new_frames - target_size frames.
if finished:
return True, new_frames
if not finished and new_frames < target_size:
return False, 0
# Ship ALL accumulated frames, not just target_size. When new_frames
# exceeds target_size (e.g. a scheduler stall dropped target below the
# already-queued frames), returning target_size would cause the caller's
# [-context_length:] slice to take the newest frames, silently skipping
# the surplus. Shipping new_frames keeps the slice contiguous with the
# previous emit and preserves Code2Wav's per-request decoder cache.
return True, new_frames
def parse_adaptive_config(
cfg: dict,
) -> tuple[bool, int, float, int, int]:
"""Parse adaptive chunk settings from connector extra config.
The adaptive max is ``codec_chunk_frames`` (applied at the call site
as ``max_frames``); there is no separate max config -- capping below
steady would undermine the controller's ramp-to-large design (more
Code2Wav re-decode tax) and is a saturated-regime death-spiral risk,
so it is intentionally not exposed.
Returns:
``(enabled, min_frames, safety_margin_ms, ramp_divisor, ramp_delta_min)``
"""
enabled = bool(cfg.get("codec_chunk_adaptive", False))
min_frames = int(cfg.get("codec_chunk_min_frames", 2))
margin_ms = float(cfg.get("codec_chunk_safety_margin_ms", 50.0))
ramp_divisor = int(cfg.get("codec_chunk_ramp_divisor", 15))
ramp_delta_min = int(cfg.get("codec_chunk_ramp_delta_min", 2))
if min_frames < 1:
logger.warning("codec_chunk_min_frames must be >= 1, clamping to 1.")
min_frames = 1
if ramp_divisor < 1:
logger.warning("codec_chunk_ramp_divisor must be >= 1, clamping to 1.")
ramp_divisor = 1
if ramp_delta_min < 1:
logger.warning("codec_chunk_ramp_delta_min must be >= 1, clamping to 1.")
ramp_delta_min = 1
return enabled, min_frames, margin_ms, ramp_divisor, ramp_delta_min
@@ -1,5 +1,6 @@
"""Stage input processor for Qwen3-TTS: Talker -> Code2Wav."""
import time
from collections.abc import Mapping
from typing import Any
@@ -14,9 +15,12 @@ from vllm_omni.data_entry_keys import (
to_dict,
)
from vllm_omni.model_executor.stage_input_processors.chunk_size_utils import (
AdaptiveChunkController,
compute_adaptive_emit,
compute_dynamic_initial_chunk_size,
compute_ramp_emit,
max_ic_for_chunk_size,
parse_adaptive_config,
parse_chunk_ramp,
)
from vllm_omni.model_executor.stage_input_processors.tts_utils import (
@@ -108,6 +112,18 @@ def talker2code2wav_async_chunk(
transfer_manager._ramp_parsed = parse_chunk_ramp(cfg, steady=chunk_size)
ramp = transfer_manager._ramp_parsed
# Adaptive: parse once per transfer_manager.
# Takes precedence over fixed ramp when both are configured.
if not hasattr(transfer_manager, "_adaptive_parsed"):
transfer_manager._adaptive_parsed = parse_adaptive_config(cfg)
(
adaptive_enabled,
adaptive_min,
adaptive_margin,
adaptive_divisor,
adaptive_delta_min,
) = transfer_manager._adaptive_parsed
# Per-request override takes priority over dynamic IC.
fixed_initial_chunk_size = configured_initial_chunk_size > 0
initial_chunk_size = configured_initial_chunk_size
@@ -124,8 +140,12 @@ def talker2code2wav_async_chunk(
fixed_initial_chunk_size = True
# Dynamic IC: cache per request so boundaries stay stable for its lifetime.
# Skipped when ramp is active (ramp replaces IC/steady entirely).
if ramp is None and not fixed_initial_chunk_size:
# Skipped when fixed ramp is active (ramp replaces IC/steady entirely) or
# a fixed IC is configured — unless adaptive is active, in which case
# dynamic IC always runs (used for chunk 0), even if
# initial_codec_chunk_frames is configured.
skip_dynamic_ic = (ramp is not None or fixed_initial_chunk_size) and not adaptive_enabled
if not skip_dynamic_ic:
_ic_cache = getattr(transfer_manager, "_cached_ic", None)
if _ic_cache is None:
_ic_cache = {}
@@ -172,7 +192,71 @@ def talker2code2wav_async_chunk(
)
return None
if ramp is not None:
if adaptive_enabled:
_adaptive_states = getattr(transfer_manager, "_adaptive_states", None)
if _adaptive_states is None:
_adaptive_states = {}
transfer_manager._adaptive_states = _adaptive_states
ctrl = _adaptive_states.get(request_id)
chunk_index = transfer_manager.ramp_chunk_count.get(request_id, 0)
first_chunk = chunk_index <= 0
if ctrl is None or chunk_index == 0:
use_first_chunk = initial_chunk_size > 0 and initial_chunk_size < chunk_size
if use_first_chunk and length <= initial_chunk_size:
if not finished and length < initial_chunk_size:
return None
context_length = length if finished and length < initial_chunk_size else initial_chunk_size
else:
initial_coverage = initial_chunk_size if use_first_chunk else 0
adjusted = length - initial_coverage
if not finished and adjusted % chunk_size != 0:
return None
chunk_length = adjusted % chunk_size
context_length = chunk_length if chunk_length != 0 else chunk_size
# Chunk 0: target_size equals context_length (no overshoot possible
# — IC logic emits exactly what's accumulated up to the IC boundary).
target_size = context_length
now = time.monotonic()
ctrl = AdaptiveChunkController(
first_emit_time=now,
last_emit_time=now,
ramp_divisor=adaptive_divisor,
ramp_delta_min=adaptive_delta_min,
)
_adaptive_states[request_id] = ctrl
else:
now = time.monotonic()
target_size = ctrl.compute_next_chunk_size(
now,
adaptive_min,
chunk_size,
adaptive_margin,
)
emit, context_length = compute_adaptive_emit(
length,
ctrl.accumulated_at_last_emit,
target_size,
finished,
)
if not emit:
return None
if context_length == 0:
ctrl.log_summary(request_id)
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, dtype=torch.bool),
),
)
elif ramp is not None:
chunk_index = transfer_manager.ramp_chunk_count.get(request_id, 0)
first_chunk = chunk_index <= 0
emit, context_length = compute_ramp_emit(length, chunk_index, ramp, chunk_size, finished)
@@ -206,6 +290,13 @@ def talker2code2wav_async_chunk(
if finished and first_chunk:
context_length = length
if adaptive_enabled:
ctrl = transfer_manager._adaptive_states.get(request_id)
if ctrl is not None:
ctrl.record_emit(time.monotonic(), context_length, length, target_size)
if finished:
ctrl.log_summary(request_id)
# Code2Wav keeps the quantizer/conv/Transformer context per request. Ship
# only the newly completed codec frames after the first chunk.
window_frames = transfer_manager.code_prompt_token_ids[request_id][-context_length:]
@@ -179,6 +179,7 @@ class OmniConnectorModelRunnerMixin:
# does not have segment boundary infrastructure; multi-segment support
# is only available via chunk_transfer_adapter (distributed path).
self._ramp_chunk_count: dict[str, int] = defaultdict(int)
self._adaptive_states: dict[str, Any] = {}
# Send-side async accumulation / staging buffer. Receive-side payload
# ownership lives in ``_local_stage_payload_cache``.
self._send_side_request_payload: dict[str, dict[str, Any]] = {}
@@ -329,6 +330,7 @@ class OmniConnectorModelRunnerMixin:
self._code_prompt_token_ids.pop(k, None)
self._cached_ic.pop(k, None)
self._ramp_chunk_count.pop(k, None)
self._adaptive_states.pop(k, None)
self._kv_pending_transfers.pop(req_id, None)
self._kv_active_transfers.discard(req_id)
self._kv_completed_transfers.discard(req_id)
@@ -2027,6 +2029,7 @@ class OmniConnectorModelRunnerMixin:
self._code_prompt_token_ids.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)
# ------------------------------------------------------------------ #
# Payload accumulation (ported from OmniChunkTransferAdapter)