[Model] Add single-stage Qwen3-TTS with in-Talker streaming codec decode (#8259)
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
43 files changed
+3565
-90
No files matched your search
@@ -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:
|
||||
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.
|
||||
|
||||
@@ -35,3 +35,161 @@ def test_recursive_deploy_inheritance(tmp_path):
|
||||
merged = resolve_deploy_yaml(leaf)
|
||||
assert merged["speech_cache"] == {"nested": {"a": 1, "b": 0}}
|
||||
assert merged["async_chunk"] is False
|
||||
|
||||
|
||||
def test_single_stage_streaming_capability_uses_central_async_resolver():
|
||||
from vllm_omni.config.stage_config import DeployConfig, _resolve_pipeline_async_chunk_enabled
|
||||
from vllm_omni.model_executor.models.qwen3_tts.pipeline import QWEN3_TTS_FUSED_PIPELINE
|
||||
|
||||
assert _resolve_pipeline_async_chunk_enabled(QWEN3_TTS_FUSED_PIPELINE, DeployConfig())
|
||||
with pytest.raises(ValueError, match="requires async_chunk=True"):
|
||||
_resolve_pipeline_async_chunk_enabled(QWEN3_TTS_FUSED_PIPELINE, DeployConfig(async_chunk=False))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pipeline_key,profile,stage_count",
|
||||
[
|
||||
("qwen3_tts", "qwen3_tts_high_concurrency_mrv2_single_gpu.yaml", 2),
|
||||
("qwen3_tts_fused", "qwen3_tts_fused_single_gpu.yaml", 1),
|
||||
],
|
||||
)
|
||||
def test_qwen3_tts_deployment_selection_preserves_output_and_transport_contract(pipeline_key, profile, stage_count):
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
from vllm_omni.config.pipeline_registry import resolve_pipeline_config
|
||||
from vllm_omni.config.stage_config import load_deploy_config, merge_pipeline_deploy
|
||||
|
||||
pipeline = resolve_pipeline_config(pipeline_key)
|
||||
assert pipeline is not None
|
||||
deploy = load_deploy_config(get_deploy_config_path(profile))
|
||||
stages = merge_pipeline_deploy(pipeline, deploy)
|
||||
assert len(stages) == stage_count
|
||||
talker = stages[0]
|
||||
if stage_count == 1:
|
||||
assert talker.final_output and talker.final_output_type == "audio"
|
||||
assert talker.yaml_engine_args["engine_output_type"] == "audio"
|
||||
assert "custom_process_next_stage_input_func" not in talker.yaml_engine_args
|
||||
assert not talker.yaml_extras.get("output_connectors")
|
||||
assert talker.yaml_engine_args["supports_running_prefix_cache_reset"] is False
|
||||
assert talker.yaml_engine_args["additional_config"]["ref_code_context_frames"] == 72
|
||||
else:
|
||||
assert not talker.final_output
|
||||
assert talker.yaml_engine_args["engine_output_type"] == "latent"
|
||||
assert talker.yaml_engine_args["custom_process_next_stage_input_func"].endswith("talker2code2wav_async_chunk")
|
||||
assert talker.yaml_extras["output_connectors"]
|
||||
assert stages[1].input_sources == [0] and stages[1].final_output_type == "audio"
|
||||
assert talker.yaml_engine_args.get("supports_running_prefix_cache_reset", True)
|
||||
|
||||
|
||||
def test_single_stage_running_reset_capability_cannot_be_overridden():
|
||||
from vllm_omni.config.omni_config import VllmOmniConfig
|
||||
from vllm_omni.config.stage_config import DeployConfig, StageDeployConfig, merge_pipeline_deploy
|
||||
from vllm_omni.model_executor.models.qwen3_tts.pipeline import QWEN3_TTS_FUSED_PIPELINE
|
||||
|
||||
deploy = DeployConfig(
|
||||
stages=[StageDeployConfig(stage_id=0, engine_extras={"supports_running_prefix_cache_reset": True})]
|
||||
)
|
||||
legacy = merge_pipeline_deploy(QWEN3_TTS_FUSED_PIPELINE, deploy)[0]
|
||||
legacy.runtime_overrides["supports_running_prefix_cache_reset"] = True
|
||||
assert legacy.to_omegaconf().engine_args.supports_running_prefix_cache_reset is False
|
||||
with pytest.raises(ValueError, match="no structured config owner: supports_running_prefix_cache_reset"):
|
||||
VllmOmniConfig.from_pipeline_config(QWEN3_TTS_FUSED_PIPELINE, user_deploy_config=deploy)
|
||||
structured = VllmOmniConfig.from_pipeline_config(QWEN3_TTS_FUSED_PIPELINE)
|
||||
assert structured.stage_by_id(0).model_config.supports_running_prefix_cache_reset is False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def single_stage_tts_config(mocker):
|
||||
from vllm.config import VllmConfig
|
||||
|
||||
config = mocker.Mock(spec=VllmConfig)
|
||||
config.additional_config = {"talker_stream_decode": True}
|
||||
config.model_config = mocker.Mock(
|
||||
stage_connector_config={},
|
||||
engine_output_type="audio",
|
||||
final_output=True,
|
||||
supports_running_prefix_cache_reset=False,
|
||||
use_v2_model_runner=True,
|
||||
async_chunk=True,
|
||||
)
|
||||
config.parallel_config = mocker.Mock(
|
||||
tensor_parallel_size=1, pipeline_parallel_size=1, distributed_executor_backend="uni"
|
||||
)
|
||||
config.cache_config = mocker.Mock(enable_prefix_caching=False)
|
||||
config.device_config = mocker.Mock(device="cuda")
|
||||
mocker.patch(
|
||||
"vllm_omni.model_executor.models.qwen3_tts.stream_decode.current_omni_platform.is_cuda", return_value=True
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def test_single_stage_tts_rejects_unsupported_decoder_configuration(single_stage_tts_config):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.stream_decode import talker_stream_decode_enabled
|
||||
|
||||
config = single_stage_tts_config
|
||||
config.cache_config.enable_prefix_caching = True
|
||||
with pytest.raises(ValueError, match="enable_prefix_caching=False"):
|
||||
talker_stream_decode_enabled(config)
|
||||
config.cache_config.enable_prefix_caching = False
|
||||
assert talker_stream_decode_enabled(config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("option_location", ["additional_config", "connector"])
|
||||
@pytest.mark.parametrize("output_type", ["latent", "audio"])
|
||||
def test_two_stage_talker_rejects_single_stage_pcm_option(single_stage_tts_config, option_location, output_type):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.stream_decode import talker_stream_decode_enabled
|
||||
|
||||
config = single_stage_tts_config
|
||||
config.model_config.engine_output_type = output_type
|
||||
config.model_config.final_output = False
|
||||
if option_location == "connector":
|
||||
config.model_config.stage_connector_config = {"extra": config.additional_config}
|
||||
config.additional_config = {}
|
||||
with pytest.raises(ValueError, match="final audio-output Talker"):
|
||||
talker_stream_decode_enabled(config)
|
||||
|
||||
|
||||
def test_two_stage_first_audio_remains_independent(single_stage_tts_config):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.first_audio import talker_first_audio_enabled
|
||||
from vllm_omni.model_executor.models.qwen3_tts.stream_decode import talker_stream_decode_enabled
|
||||
|
||||
config = single_stage_tts_config
|
||||
config.model_config.engine_output_type = "latent"
|
||||
config.additional_config = {}
|
||||
config.model_config.stage_connector_config = {"extra": {"talker_first_audio": True}}
|
||||
assert not talker_stream_decode_enabled(config)
|
||||
assert talker_first_audio_enabled(config)
|
||||
config.model_config.stage_connector_config["extra"]["talker_stream_first_audio"] = True
|
||||
with pytest.raises(ValueError, match="requires the single-stage"):
|
||||
talker_stream_decode_enabled(config)
|
||||
|
||||
|
||||
def test_single_stage_pcm_requires_cuda_device_and_own_first_audio_option(single_stage_tts_config):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.stream_decode import talker_stream_decode_enabled
|
||||
|
||||
config = single_stage_tts_config
|
||||
config.device_config.device = "cpu"
|
||||
with pytest.raises(ValueError, match="requires CUDA"):
|
||||
talker_stream_decode_enabled(config)
|
||||
config.device_config.device = "cuda"
|
||||
config.additional_config["talker_first_audio"] = True
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
talker_stream_decode_enabled(config)
|
||||
config.additional_config.pop("talker_first_audio")
|
||||
config.additional_config["talker_stream_first_audio"] = True
|
||||
assert talker_stream_decode_enabled(config)
|
||||
|
||||
|
||||
def test_single_stage_audio_talker_requires_pcm_option(single_stage_tts_config):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.stream_decode import talker_stream_decode_enabled
|
||||
|
||||
single_stage_tts_config.additional_config = {}
|
||||
with pytest.raises(ValueError, match="requires talker_stream_decode=True"):
|
||||
talker_stream_decode_enabled(single_stage_tts_config)
|
||||
|
||||
|
||||
def test_single_stage_pcm_requires_reset_capability_declaration(single_stage_tts_config):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.stream_decode import talker_stream_decode_enabled
|
||||
|
||||
single_stage_tts_config.model_config.supports_running_prefix_cache_reset = True
|
||||
with pytest.raises(ValueError, match="must disable running prefix-cache reset"):
|
||||
talker_stream_decode_enabled(single_stage_tts_config)
|
||||
@@ -1046,6 +1046,7 @@ def test_sub_config_fields_match_structured_scopes():
|
||||
"interleave_mm_strings",
|
||||
"media_io_kwargs",
|
||||
"final_output",
|
||||
"supports_running_prefix_cache_reset",
|
||||
"active_stream_window",
|
||||
"session_mode",
|
||||
"duplex_max_sessions",
|
||||
|
||||
@@ -106,7 +106,7 @@ 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),
|
||||
@@ -114,7 +114,7 @@ def _run_step(sched: MagicMock, session: Request, *, num_scheduled: int, token:
|
||||
"""
|
||||
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
|
||||
else:
|
||||
reset.assert_called_once()
|
||||
@@ -114,10 +114,10 @@ def _raw(*, first=False, required=False, terminal=False, samples=(1, 1, 1, 1)):
|
||||
)
|
||||
|
||||
|
||||
async def _codec_output(obj, raw):
|
||||
replica = obj.stage_pools[1].replica_id
|
||||
processed = await obj._process_llm_stage_outputs(1, replica, raw, set())
|
||||
await obj._handle_processed_outputs(1, replica, processed)
|
||||
async def _codec_output(obj, raw, stage=1):
|
||||
replica = obj.stage_pools[stage].replica_id
|
||||
processed = await obj._process_llm_stage_outputs(stage, replica, raw, set())
|
||||
await obj._handle_processed_outputs(stage, replica, processed)
|
||||
|
||||
|
||||
def _messages(obj):
|
||||
@@ -132,6 +132,57 @@ def _audio(output):
|
||||
return torch.cat(audio) if isinstance(audio, list) else audio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", list(RequestOutputKind))
|
||||
@pytest.mark.parametrize("suffix_overtook", [False, True])
|
||||
async def test_single_stage_first_audio_obeys_output_kind_and_terminal_order(kind, suffix_overtook):
|
||||
obj = _orchestrator(kind, final_stage_id=0)
|
||||
obj.stage_pools = [obj.stage_pools[1]]
|
||||
suffix = _raw(required=True, samples=(20, 21))
|
||||
terminal = _raw(required=True, terminal=True, samples=(30, 31))
|
||||
if suffix_overtook:
|
||||
await _codec_output(obj, suffix, stage=0)
|
||||
await _codec_output(obj, terminal, stage=0)
|
||||
assert obj.output_async_queue.empty()
|
||||
await obj._route_upstream_first_audio(0, 2, _raw(first=True, samples=(10, 11)))
|
||||
if not suffix_overtook:
|
||||
await _codec_output(obj, suffix, stage=0)
|
||||
await _codec_output(obj, terminal, stage=0)
|
||||
messages = _messages(obj)
|
||||
expected = {
|
||||
RequestOutputKind.DELTA: [[10, 11], [20, 21], [30, 31]],
|
||||
RequestOutputKind.CUMULATIVE: [[10, 11], [10, 11, 20, 21], [10, 11, 20, 21, 30, 31]],
|
||||
RequestOutputKind.FINAL_ONLY: [[10, 11, 20, 21, 30, 31]],
|
||||
}
|
||||
assert [_audio(message.engine_outputs).tolist() for message in messages] == expected[kind]
|
||||
assert all((message.stage_id, message.replica_id) == (0, 2) for message in messages)
|
||||
assert messages[-1].finished
|
||||
assert not obj.request_states["r"].pending_first_audio_outputs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_stage_cancellation_discards_overtaking_terminal_and_late_first_audio():
|
||||
obj = _orchestrator(final_stage_id=0)
|
||||
obj.stage_pools = [obj.stage_pools[1]]
|
||||
await _codec_output(obj, _raw(required=True, terminal=True), stage=0)
|
||||
assert obj.request_states["r"].pending_first_audio_outputs
|
||||
await obj._cleanup_request_ids(["r"], abort=True)
|
||||
await obj._route_upstream_first_audio(0, 2, _raw(first=True))
|
||||
assert not obj.request_states and obj.output_async_queue.empty()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_stage_first_audio_processing_failure_releases_waiting_terminal():
|
||||
obj = _orchestrator(final_stage_id=0)
|
||||
obj.stage_pools = [obj.stage_pools[1]]
|
||||
await _codec_output(obj, _raw(required=True, terminal=True), stage=0)
|
||||
obj.stage_pools[0].process_llm_raw_outputs = AsyncMock(side_effect=RuntimeError("processing failed"))
|
||||
await obj._route_upstream_first_audio(0, 2, _raw(first=True))
|
||||
error = obj.output_async_queue.get_nowait()
|
||||
assert isinstance(error, ErrorMessage) and error.stage_id == 0
|
||||
assert not obj.request_states
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", list(RequestOutputKind))
|
||||
@pytest.mark.parametrize("codec_overtook", [False, True])
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from vllm.v1.engine.core import EngineCoreProc
|
||||
from vllm.v1.executor.uniproc_executor import UniProcExecutor
|
||||
|
||||
from vllm_omni.engine.stage_engine_core_proc import StageEngineCoreProc
|
||||
from vllm_omni.engine.stage_engine_core_proc import StageEngineCoreProc, _bind_first_audio_sink
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_decoder,stream_decoder,stream_first_audio,expected",
|
||||
[(True, False, False, True), (False, True, False, False), (False, True, True, True), (False, False, True, False)],
|
||||
)
|
||||
def test_first_audio_sink_preserves_first_decoder_and_opts_in_stream_decoder(
|
||||
mocker,
|
||||
first_decoder,
|
||||
stream_decoder,
|
||||
stream_first_audio,
|
||||
expected,
|
||||
):
|
||||
executor = UniProcExecutor.__new__(UniProcExecutor)
|
||||
executor.driver_worker = mocker.Mock()
|
||||
runner = executor.driver_worker.worker.model_runner
|
||||
runner.model.first_frame_decoder = object() if first_decoder else None
|
||||
runner.model.stream_decoder = object() if stream_decoder else None
|
||||
runner.model.stream_first_audio = stream_first_audio
|
||||
assert _bind_first_audio_sink(executor, mocker.Mock(), mocker.Mock()) is expected
|
||||
assert runner.model_state.set_first_audio_sink.call_count == int(expected)
|
||||
|
||||
|
||||
def test_preprocess_add_request_preserves_omni_fields():
|
||||
|
||||
@@ -346,3 +346,19 @@ def test_chat_audio_metadata_survives_response_serialization(stream, fmt):
|
||||
assert audio_file.samplerate == 22050
|
||||
else:
|
||||
assert len(raw) == frame_count * 2
|
||||
|
||||
|
||||
def test_float32_pcm_fast_path_matches_soundfile():
|
||||
from vllm_omni.entrypoints.openai.audio_utils_mixin import _float32_to_pcm16_bytes
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
audio = np.concatenate(
|
||||
[
|
||||
rng.uniform(-1.2, 1.2, 4096),
|
||||
np.array([-1.0, 1.0, 0.0, -0.0, 32767 / 32768, -1 / 32768, 0.5 / 32768, -0.5 / 32768]),
|
||||
]
|
||||
).astype(np.float32)
|
||||
with BytesIO() as buffer:
|
||||
soundfile.write(buffer, audio, 24000, format="RAW", subtype="PCM_16")
|
||||
expected = buffer.getvalue()
|
||||
assert _float32_to_pcm16_bytes(audio) == expected
|
||||
@@ -569,6 +569,27 @@ class TestCodePredictorPerRowGenerators:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fused_requested", [False, True])
|
||||
@pytest.mark.parametrize("invalid_input", ["uniforms", "generators"])
|
||||
def test_sampling_contract_rejected_before_dispatch(mocker, loaded_target_classes, fused_requested, invalid_input):
|
||||
predictor, talker_config = TestCodePredictorPerRowGenerators()._make_predictor(mocker, loaded_target_classes)
|
||||
predictor._fused_requested = fused_requested
|
||||
predictor._fused = mocker.Mock()
|
||||
setup = mocker.patch.object(predictor, "_setup_compile")
|
||||
uniforms = torch.full((2, 3, 1 if invalid_input == "uniforms" else 64), 0.5)
|
||||
generators = [torch.Generator()] if invalid_input == "generators" else None
|
||||
with pytest.raises(ValueError, match="sample_uniforms must have shape|one entry per row"):
|
||||
predictor(
|
||||
torch.zeros(2, dtype=torch.long),
|
||||
torch.randn(2, talker_config.hidden_size),
|
||||
torch.randn(2, talker_config.hidden_size),
|
||||
sample_uniforms=uniforms,
|
||||
generators=generators,
|
||||
)
|
||||
setup.assert_not_called()
|
||||
predictor._fused.assert_not_called()
|
||||
|
||||
|
||||
class TestCodePredictorModelDtype:
|
||||
"""Test the inner model forward with different dtypes."""
|
||||
|
||||
@@ -663,6 +684,8 @@ class TestCodePredictorGraphReplay:
|
||||
|
||||
predictor = object.__new__(code_predictor_wrapper)
|
||||
torch.nn.Module.__init__(predictor)
|
||||
predictor._fused_requested = False
|
||||
predictor._fused = None
|
||||
predictor._num_groups = 3
|
||||
predictor._model_dtype = torch.float32
|
||||
predictor._setup_compile = mocker.Mock()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Supplied-uniform samples must match the existing ATen path exactly."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
@@ -12,6 +14,79 @@ pytestmark = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("top_k", [0, 1, 50, 2048])
|
||||
@torch.inference_mode()
|
||||
def test_fused_sample_kernel_preserves_signed_zero_cutoff_ties(top_k):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.fused_code_predictor import _cp_sample_kernel
|
||||
|
||||
vocab, hidden, groups = 2048, 64, 4
|
||||
logits = torch.zeros(2, vocab, device="cuda", dtype=torch.bfloat16)
|
||||
logits[0, 1] = -0.0
|
||||
logits[1, ::2] = -0.0
|
||||
uniforms = torch.full((2, groups - 1, vocab), 0.01, device="cuda")[:, 1, :]
|
||||
uniforms[0, 1] = uniforms[1, 2] = 0.99
|
||||
codes = torch.full((2, groups), -1, device="cuda", dtype=torch.int64)
|
||||
table = torch.randn(vocab, hidden, device="cuda", dtype=torch.bfloat16)
|
||||
next_input = torch.empty(2, hidden, device="cuda", dtype=table.dtype)
|
||||
|
||||
def sample():
|
||||
_cp_sample_kernel[(2,)](
|
||||
logits, uniforms, uniforms.stride(0), codes, 1, groups, table, next_input, 1.0,
|
||||
V=vocab, TOPK=top_k, HID=hidden, HAS_NEXT=True, num_warps=4,
|
||||
) # fmt: skip
|
||||
|
||||
scaled = logits.float()
|
||||
if top_k > 0:
|
||||
scaled = scaled.masked_fill(scaled < scaled.topk(top_k).values[:, -1:], -torch.inf)
|
||||
expected = (scaled - torch.log(-torch.log(uniforms))).argmax(-1)
|
||||
assert expected.tolist() == [1, 2]
|
||||
sample()
|
||||
torch.testing.assert_close(codes[:, 1], expected)
|
||||
torch.testing.assert_close(next_input, table[expected])
|
||||
assert (codes[:, [0, 2, 3]] == -1).all()
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
sample()
|
||||
codes.fill_(-1)
|
||||
graph.replay()
|
||||
torch.testing.assert_close(codes[:, 1], expected)
|
||||
torch.testing.assert_close(next_input, table[expected])
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def test_fused_predictor_warmup_with_capacity_one():
|
||||
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import (
|
||||
Qwen3TTSTalkerCodePredictorConfig,
|
||||
Qwen3TTSTalkerConfig,
|
||||
)
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code_predictor_vllm import (
|
||||
Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM,
|
||||
)
|
||||
|
||||
cp = Qwen3TTSTalkerCodePredictorConfig(
|
||||
vocab_size=64, hidden_size=64, intermediate_size=128, num_hidden_layers=1,
|
||||
num_attention_heads=4, num_key_value_heads=2, head_dim=16, num_code_groups=4,
|
||||
) # fmt: skip
|
||||
talker = Qwen3TTSTalkerConfig(hidden_size=64, num_code_groups=4)
|
||||
config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(stage_connector_config={}),
|
||||
additional_config={"code_predictor_kv_cache": True, "code_predictor_fused": True},
|
||||
scheduler_config=SimpleNamespace(max_num_seqs=1),
|
||||
)
|
||||
predictor = Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(
|
||||
vllm_config=config, config=cp, talker_config=talker
|
||||
).to(device="cuda", dtype=torch.bfloat16)
|
||||
predictor._setup_compile()
|
||||
assert predictor._fused is not None
|
||||
hidden = torch.randn(1, 1, 64, device="cuda", dtype=torch.bfloat16)
|
||||
result = predictor(
|
||||
torch.zeros(1, 1, device="cuda", dtype=torch.long), hidden, hidden,
|
||||
sample_uniforms=torch.full((1, 3, 64), 0.5, device="cuda"),
|
||||
) # fmt: skip
|
||||
assert result.shape == (1, 4)
|
||||
assert ((result >= 0) & (result < 64)).all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch", [1, 64])
|
||||
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16, torch.float32])
|
||||
@pytest.mark.parametrize("top_k", [-1, 1, 50, 2048])
|
||||
@@ -66,3 +141,47 @@ def test_topk_gumbel_accepts_strided_supplied_uniforms(top_k):
|
||||
expected = (scaled.float() - torch.log(-torch.log(uniforms))).argmax(-1, keepdim=True)
|
||||
actual = sample_code_topk_gumbel(logits, uniforms, top_k, 1 / 0.9)
|
||||
torch.testing.assert_close(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch", [1, 4])
|
||||
@pytest.mark.parametrize("top_k", [1, 50])
|
||||
@torch.inference_mode()
|
||||
def test_fused_predictor_matches_full_reference_with_fixed_uniforms(mocker, batch, top_k):
|
||||
from vllm.config import VllmConfig
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import (
|
||||
Qwen3TTSTalkerCodePredictorConfig,
|
||||
Qwen3TTSTalkerConfig,
|
||||
)
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code_predictor_vllm import (
|
||||
Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM,
|
||||
)
|
||||
|
||||
torch.manual_seed(42)
|
||||
cp = Qwen3TTSTalkerCodePredictorConfig(
|
||||
vocab_size=64, hidden_size=64, intermediate_size=128, num_hidden_layers=1,
|
||||
num_attention_heads=4, num_key_value_heads=2, head_dim=16, num_code_groups=4,
|
||||
) # fmt: skip
|
||||
config = mocker.Mock(
|
||||
spec=VllmConfig,
|
||||
model_config=mocker.Mock(stage_connector_config={}),
|
||||
additional_config={"code_predictor_kv_cache": True, "code_predictor_fused": True},
|
||||
scheduler_config=mocker.Mock(max_num_seqs=4),
|
||||
)
|
||||
predictor = Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(
|
||||
vllm_config=config, config=cp, talker_config=Qwen3TTSTalkerConfig(hidden_size=64, num_code_groups=4)
|
||||
).to(device="cuda", dtype=torch.bfloat16)
|
||||
predictor._setup_compile()
|
||||
assert predictor._fused is not None
|
||||
ids = torch.randint(0, 64, (batch, 1), device="cuda")
|
||||
embeds = torch.randn(batch, 1, 64, device="cuda", dtype=torch.bfloat16)
|
||||
hidden = torch.randn_like(embeds)
|
||||
uniforms = torch.rand(batch, 3, 64, device="cuda").clamp_(1e-6, 1 - 1e-6)
|
||||
actual = predictor(ids, embeds, hidden, top_k=top_k, sample_uniforms=uniforms).clone()
|
||||
# Use identical parameters through the full re-prefill and ATen sampling
|
||||
# path, bypassing both the fused predictor and its frame-local KV cache.
|
||||
predictor._fused_requested = False
|
||||
predictor._fused = None
|
||||
predictor._frame_cache = None
|
||||
expected = predictor(ids, embeds, hidden, top_k=top_k, sample_uniforms=uniforms)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Prefix graphs must follow per-request first-audio delivery, not a capture hint."""
|
||||
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -18,6 +19,38 @@ pytestmark = [pytest.mark.core_model]
|
||||
DEVICE = torch.device("cuda:0")
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("capacity,model_type", [(1, "base"), (128, "base"), (128, "custom_voice")])
|
||||
def test_stream_priming_graph_workspace_respects_token_budget(monkeypatch, capacity, model_type):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import Qwen3TTSTalkerForConditionalGeneration
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz import streaming_decoder
|
||||
|
||||
captures = []
|
||||
|
||||
def capture(stream, sizes, frames=1):
|
||||
captures.append((sizes, frames))
|
||||
return SimpleNamespace(sizes=sizes, frames=frames)
|
||||
|
||||
monkeypatch.setattr(streaming_decoder, "StreamingDecodeGraphs", capture)
|
||||
model = SimpleNamespace(
|
||||
stream_decoder=SimpleNamespace(max_frames=33),
|
||||
stream_graphs=None,
|
||||
stream_prime_graphs=None,
|
||||
stream_ref_context_frames=25,
|
||||
stream_chunk_frames=25,
|
||||
config=SimpleNamespace(tts_model_type=model_type),
|
||||
vllm_config=SimpleNamespace(scheduler_config=SimpleNamespace(max_num_batched_tokens=512)),
|
||||
)
|
||||
sizes = [size for size in [1, 2, 4, 8, 16, 32, 64, 128] if size <= capacity]
|
||||
Qwen3TTSTalkerForConditionalGeneration.capture_stream_decode_graphs(model, sizes)
|
||||
expected = [(sizes, 1)]
|
||||
if model_type == "base":
|
||||
expected.append(([size for size in sizes if size * 25 <= 512], 25))
|
||||
assert captures == expected
|
||||
Qwen3TTSTalkerForConditionalGeneration.capture_stream_decode_graphs(model, sizes)
|
||||
assert captures == expected
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("skip_flags", [(False, False), (True, True), (False, True), (True, False)])
|
||||
@pytest.mark.parametrize("initial_frames", [1, 3])
|
||||
@@ -124,3 +157,134 @@ def test_xvec_graphs_follow_request_first_audio_flags(initial_frames, time_major
|
||||
torch.testing.assert_close(got, want, atol=1e-4, rtol=1e-4)
|
||||
for key in ("ref_hidden", "ref_conv", "prefix_hidden", "suffix_quantized", "suffix_conv"):
|
||||
torch.testing.assert_close(cache[key], expected_cache[key], atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@torch.inference_mode()
|
||||
def test_stream_decoder_preemption_restores_all_state_into_a_different_slot():
|
||||
from tests.model_executor.models.qwen3_tts.test_time_major_decoder import _make_decoder
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.streaming_decoder import StreamingCodecDecoder
|
||||
|
||||
decoder = _make_decoder().to(device=DEVICE, dtype=torch.bfloat16)
|
||||
for name, param in decoder.named_parameters():
|
||||
if name.endswith("embedding_sum"):
|
||||
param.normal_()
|
||||
decoder.config.head_dim = decoder.config.hidden_size // decoder.config.num_attention_heads
|
||||
stream = StreamingCodecDecoder(decoder, num_slots=2, dtype=torch.bfloat16)
|
||||
codes = torch.randint(0, 32, (1, 8, 2), device=DEVICE)
|
||||
slot = torch.tensor([0], device=DEVICE, dtype=torch.int32)
|
||||
for pos in range(7):
|
||||
stream(codes[:, pos : pos + 1], slot, torch.tensor([pos], device=DEVICE, dtype=torch.int32))
|
||||
saved = stream.save_slot(0)
|
||||
expected = stream(codes[:, 7:], slot, torch.tensor([7], device=DEVICE, dtype=torch.int32)).clone()
|
||||
# Both the freed slot and the destination can hold an unrelated request.
|
||||
other_codes = (codes[:, :3] + 1) % 32
|
||||
for pos in range(3):
|
||||
stream(
|
||||
other_codes[:, pos : pos + 1].expand(2, -1, -1).contiguous(),
|
||||
torch.tensor([0, 1], device=DEVICE, dtype=torch.int32),
|
||||
torch.full((2,), pos, device=DEVICE, dtype=torch.int32),
|
||||
)
|
||||
without_restore = stream(
|
||||
codes[:, 7:],
|
||||
torch.tensor([1], device=DEVICE, dtype=torch.int32),
|
||||
torch.tensor([7], device=DEVICE, dtype=torch.int32),
|
||||
).clone()
|
||||
assert not torch.equal(without_restore, expected)
|
||||
stream.restore_slot(1, saved)
|
||||
actual = stream(
|
||||
codes[:, 7:],
|
||||
torch.tensor([1], device=DEVICE, dtype=torch.int32),
|
||||
torch.tensor([7], device=DEVICE, dtype=torch.int32),
|
||||
)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("use_graph", [False, True])
|
||||
@torch.inference_mode()
|
||||
def test_batched_reference_priming_matches_serial_decoder_continuation(use_graph):
|
||||
from tests.model_executor.models.qwen3_tts.test_time_major_decoder import _make_decoder
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import Qwen3TTSTalkerForConditionalGeneration
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.streaming_decoder import (
|
||||
StreamingCodecDecoder,
|
||||
StreamingDecodeGraphs,
|
||||
)
|
||||
|
||||
decoder = _make_decoder().to(device=DEVICE, dtype=torch.bfloat16)
|
||||
for name, param in decoder.named_parameters():
|
||||
if name.endswith("embedding_sum"):
|
||||
param.normal_()
|
||||
decoder.config.head_dim = decoder.config.hidden_size // decoder.config.num_attention_heads
|
||||
stream = StreamingCodecDecoder(decoder, num_slots=5, dtype=torch.bfloat16)
|
||||
graphs = StreamingDecodeGraphs(stream, [2], frames=25) if use_graph else None
|
||||
model = Qwen3TTSTalkerForConditionalGeneration.__new__(Qwen3TTSTalkerForConditionalGeneration)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.stream_decoder = stream
|
||||
model.stream_prime_graphs = graphs
|
||||
model.stream_chunk_frames = 25
|
||||
refs = [torch.randint(0, 32, (length, 2), device=DEVICE) for length in [25, 3, 51, 25]]
|
||||
slots = [2, 0, 3, 4]
|
||||
frame = torch.randint(0, 32, (4, 1, 2), device=DEVICE)
|
||||
slot_tensor = torch.tensor(slots, device=DEVICE, dtype=torch.int32)
|
||||
positions = torch.tensor([len(ref) for ref in refs], device=DEVICE, dtype=torch.int32)
|
||||
for slot, ref in zip(slots, refs, strict=True):
|
||||
for t0 in range(0, len(ref), 25):
|
||||
stream(
|
||||
ref[None, t0 : t0 + 25].to(torch.int32).contiguous(),
|
||||
torch.tensor([slot], device=DEVICE, dtype=torch.int32),
|
||||
torch.tensor([t0], device=DEVICE, dtype=torch.int32),
|
||||
)
|
||||
expected = stream(frame, slot_tensor, positions).clone()
|
||||
untouched = stream.save_slot(1)
|
||||
# Reuse dirty slots; pos=0 must reset every causal layer for each group.
|
||||
model.prime_stream_decoder(list(zip(slots, refs, strict=True)))
|
||||
actual = stream(frame, slot_tensor, positions)
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=3e-5)
|
||||
for got, want in zip(stream.save_slot(1), untouched, strict=True):
|
||||
torch.testing.assert_close(got, want, rtol=0, atol=0)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("use_graph", [False, True])
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
@torch.inference_mode()
|
||||
def test_stream_frames_match_fp32_exact_decoder(use_graph, dtype):
|
||||
from tests.model_executor.models.qwen3_tts.test_time_major_decoder import _make_decoder
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.streaming_decoder import (
|
||||
StreamingCodecDecoder,
|
||||
StreamingDecodeGraphs,
|
||||
)
|
||||
|
||||
decoder = _make_decoder().to(device=DEVICE, dtype=dtype)
|
||||
# The tiny HF default initialization shrinks the deep conv stack to
|
||||
# near-zero PCM. Keep the oracle signal large enough to catch lost state.
|
||||
for module in decoder.modules():
|
||||
if isinstance(module, (torch.nn.Conv1d, torch.nn.ConvTranspose1d, torch.nn.Linear)):
|
||||
torch.nn.init.kaiming_uniform_(module.weight, a=math.sqrt(5))
|
||||
if module.bias is not None:
|
||||
module.bias.uniform_(-0.01, 0.01)
|
||||
for name, param in decoder.named_parameters():
|
||||
if name.endswith("embedding_sum"):
|
||||
param.normal_()
|
||||
decoder.config.head_dim = decoder.config.hidden_size // decoder.config.num_attention_heads
|
||||
reference = _make_decoder().to(device=DEVICE, dtype=torch.float32)
|
||||
reference.load_state_dict(decoder.state_dict())
|
||||
reference.precompute_snake_caches()
|
||||
# Cross both the attention window and KV ring wrap, checking every frame.
|
||||
codes = torch.randint(0, 32, (2, 136, 2), device=DEVICE)
|
||||
expected = reference._forward_exact(codes.transpose(1, 2))[:, 0]
|
||||
assert expected.square().sum() > 1e-3
|
||||
stream = StreamingCodecDecoder(decoder, num_slots=3, dtype=dtype)
|
||||
decode = StreamingDecodeGraphs(stream, [2]) if use_graph else stream
|
||||
slots = torch.tensor([2, 0], device=DEVICE, dtype=torch.int32)
|
||||
spf = int(stream.spf)
|
||||
for frame in range(codes.shape[1]):
|
||||
positions = torch.full((2,), frame, device=DEVICE, dtype=torch.int32)
|
||||
actual = decode(codes[:, frame : frame + 1].contiguous().to(torch.int32), slots, positions).reshape(2, -1)
|
||||
exact_frame = expected[:, frame * spf : (frame + 1) * spf]
|
||||
relative_rms = (actual - exact_frame).square().mean(1).sqrt() / exact_frame.square().mean(1).sqrt()
|
||||
# BF16 kernels are approximate; compare every frame to the independent
|
||||
# FP32 full decoder, not only one streaming implementation to itself.
|
||||
tolerance = 0.05 if dtype == torch.bfloat16 else 5e-4
|
||||
assert torch.all(relative_rms < tolerance), (frame, relative_rms)
|
||||
@@ -0,0 +1,57 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Streaming col2im preserves PCM inputs and ping-pong slot state."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.triton_utils import triton
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.streaming_decoder import _col2im_kernel
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.tts]
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("batch,frames,channels,rate", [(1, 1, 5, 2), (3, 3, 129, 3), (9, 1, 64, 4)])
|
||||
def test_col2im_partial_tiles_and_reordered_slot_state(batch: int, frames: int, channels: int, rate: int):
|
||||
torch.manual_seed(23)
|
||||
capacity = batch + 2
|
||||
z = torch.randn(batch, frames, 2 * rate, channels).to(torch.bfloat16)
|
||||
bias = torch.randn(channels).to(torch.bfloat16)
|
||||
state = torch.randn(2, capacity, rate, channels).to(torch.bfloat16)
|
||||
expected_state = state.clone()
|
||||
slots = torch.arange(batch - 1, -1, -1, dtype=torch.int32)
|
||||
positions = torch.arange(batch, dtype=torch.int32)
|
||||
parity = torch.arange(capacity, dtype=torch.int32) % 2
|
||||
expected = torch.empty(batch, frames, rate, channels, dtype=torch.bfloat16)
|
||||
for b in range(batch):
|
||||
slot = int(slots[b])
|
||||
side = int(parity[slot])
|
||||
for t in range(frames):
|
||||
previous = z[b, t - 1, rate:] if t else state[side, slot] if positions[b] else torch.zeros(rate, channels)
|
||||
expected[b, t] = (z[b, t, :rate].float() + previous.float() + bias.float()).to(torch.bfloat16)
|
||||
expected_state[1 - side, slot] = z[b, -1, rate:]
|
||||
|
||||
z_gpu, bias_gpu, state_gpu = z.cuda(), bias.cuda(), state.cuda()
|
||||
slots_gpu, positions_gpu, parity_gpu = slots.cuda(), positions.cuda(), parity.cuda()
|
||||
actual = torch.empty_like(expected, device="cuda")
|
||||
width = min(128, triton.next_power_of_2(channels))
|
||||
rows = batch * frames
|
||||
_col2im_kernel[(rows, rate, triton.cdiv(channels, width))](
|
||||
z_gpu,
|
||||
bias_gpu,
|
||||
state_gpu,
|
||||
slots_gpu,
|
||||
positions_gpu,
|
||||
parity_gpu,
|
||||
actual,
|
||||
frames,
|
||||
capacity,
|
||||
R=rate,
|
||||
C=channels,
|
||||
BC=width,
|
||||
num_warps=4,
|
||||
)
|
||||
torch.testing.assert_close(actual.cpu(), expected, rtol=0, atol=0)
|
||||
torch.testing.assert_close(state_gpu.cpu(), expected_state, rtol=0, atol=0)
|
||||
@@ -594,6 +594,40 @@ def test_streaming_ref_code_context_is_bounded_for_batchable_shapes():
|
||||
torch.testing.assert_close(frames[:3], ref_code[-3:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tensor_frames", [False, True])
|
||||
def test_async_chunks_preserve_codebook_order_with_reference_and_short_tail(tensor_frames):
|
||||
tm = _tm(left_context=3, initial_chunk_frames=1)
|
||||
rid = "r-codebook-order"
|
||||
quantizers = 16
|
||||
reference = [[1500 + f * quantizers + q for q in range(quantizers)] for f in range(5)]
|
||||
tm.request_payload[rid] = torch.tensor(reference, dtype=torch.long)
|
||||
generated = [[1 + f * quantizers + q for q in range(quantizers)] for f in range(30)]
|
||||
|
||||
for start, end, finished in [(0, 1, False), (1, 26, False), (26, 30, True)]:
|
||||
rows = generated[:end]
|
||||
if tensor_frames:
|
||||
# Non-contiguous int32 rows exercise conversion independently of
|
||||
# the native producer's contiguous CPU-long representation.
|
||||
storage = torch.tensor(rows, dtype=torch.int32).repeat_interleave(2, dim=1)
|
||||
tm.code_prompt_token_ids[rid] = list(storage[:, ::2].unbind())
|
||||
else:
|
||||
tm.code_prompt_token_ids[rid] = rows
|
||||
payload = talker2code2wav_async_chunk(
|
||||
transfer_manager=tm,
|
||||
multimodal_output=None if finished else {},
|
||||
request=_req(rid, finished=finished),
|
||||
is_finished=finished,
|
||||
)
|
||||
assert payload is not None
|
||||
expected = (reference[-3:] if start == 0 else []) + generated[start:end]
|
||||
codes = payload.codes.audio
|
||||
assert codes.dtype == torch.long and codes.device.type == "cpu" and codes.is_contiguous()
|
||||
torch.testing.assert_close(codes.reshape(quantizers, -1).T, torch.tensor(expected))
|
||||
assert payload.meta.left_context_size == (3 if start == 0 else 0)
|
||||
assert payload.meta.finished.item() == finished
|
||||
tm.put_req_chunk[rid] += 1
|
||||
|
||||
|
||||
def test_ref_code_context_can_be_buffered_before_first_emit():
|
||||
tm = _tm()
|
||||
rid = "r-ref-buffered"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.utils.device_copy import index_to_device
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cuda]
|
||||
|
||||
|
||||
def test_index_staging_survives_later_copies_and_graph_replay():
|
||||
stream = torch.cuda.Stream()
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.stream(stream):
|
||||
with torch.cuda.graph(graph, stream=stream):
|
||||
captured = index_to_device([17] * 6, "cuda")
|
||||
for _ in range(32):
|
||||
index_to_device([99] * 6, "cuda")
|
||||
graph.replay()
|
||||
torch.accelerator.synchronize()
|
||||
assert captured.tolist() == [17] * 6
|
||||
@@ -25,6 +25,9 @@ class _FakeStream:
|
||||
def wait_stream(self, _stream) -> None:
|
||||
pass
|
||||
|
||||
def wait_event(self, _event) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeEvent:
|
||||
def record(self, _stream) -> None:
|
||||
@@ -138,6 +141,7 @@ def test_last_pp_rank_orchestration_and_kv_resolver(monkeypatch, needs_history)
|
||||
def test_async_mm_snapshot_owns_output_until_copy_finishes() -> None:
|
||||
runner = OmniARModelRunner.__new__(OmniARModelRunner)
|
||||
runner.model_config = SimpleNamespace(async_chunk=True)
|
||||
runner.model = SimpleNamespace()
|
||||
runner._async_mm_snapshot_slots, runner._async_mm_snapshot_events = [{}], [None]
|
||||
runner._async_mm_snapshot_pending, runner._async_mm_snapshot_cursor = [False], 0
|
||||
waited: list[object] = []
|
||||
@@ -307,6 +311,231 @@ def test_request_reference_codes_preserve_local_axis(prefill_first, padded):
|
||||
assert torch.equal(outputs[i]["codes.audio"], codes[offsets[i] : offsets[i + 1]])
|
||||
|
||||
|
||||
def test_stream_audio_cancel_rejects_inflight_output_and_id_reuse() -> None:
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioBuffer
|
||||
|
||||
buffer = StreamingAudioBuffer(samples_per_frame=4, chunk_frames=3)
|
||||
cancelled = buffer.add("request")
|
||||
buffer.finish({"request"})
|
||||
replacement = buffer.add("request")
|
||||
assert cancelled.push(torch.ones(4), False) is None
|
||||
assert not cancelled.pending
|
||||
assert replacement.push(torch.full((4,), 2.0), False).tolist() == [2.0] * 4
|
||||
buffer.finish({"request"})
|
||||
assert not buffer.requests
|
||||
|
||||
|
||||
def test_stream_audio_length_end_flushes_first_and_partial_chunks() -> None:
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioBuffer, StreamingAudioOutput
|
||||
|
||||
buffer = StreamingAudioBuffer(samples_per_frame=4, chunk_frames=3)
|
||||
state = buffer.add("request")
|
||||
output = StreamingAudioOutput(
|
||||
torch.ones(1, 4),
|
||||
torch.tensor([True]),
|
||||
torch.tensor(24000),
|
||||
None,
|
||||
np.array([0, 1]),
|
||||
[state],
|
||||
np.array([True]),
|
||||
)
|
||||
assert output.get_output()[0]["model_outputs"].numel() == 4
|
||||
assert not state.active
|
||||
state = buffer.add("another")
|
||||
assert state.push(torch.ones(4), False).numel() == 4
|
||||
assert state.push(torch.ones(4), False) is None
|
||||
assert state.push(torch.ones(4), True).numel() == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float16, torch.float32, torch.float64, torch.bfloat16])
|
||||
def test_stream_audio_batch_views_retain_pcm_after_output_release(dtype):
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioBuffer, StreamingAudioOutput
|
||||
|
||||
buffer = StreamingAudioBuffer(samples_per_frame=4, chunk_frames=3)
|
||||
states = [buffer.add("a"), buffer.add("b")]
|
||||
expected: list[list[torch.Tensor]] = [[], []]
|
||||
emitted: list[list[torch.Tensor]] = [[], []]
|
||||
for step in range(5):
|
||||
# Mixed prefill/decode spans, invalid rows, and a final partial chunk.
|
||||
wav = (torch.arange(16).reshape(4, 4) + 100 * step).to(dtype)
|
||||
valid = torch.tensor([False, True, True, step != 4])
|
||||
output = StreamingAudioOutput(
|
||||
wav,
|
||||
valid,
|
||||
torch.tensor(24000),
|
||||
None,
|
||||
np.array([0, 3, 4]),
|
||||
states,
|
||||
np.array([step == 4, step == 4]),
|
||||
)
|
||||
for i, (start, end) in enumerate([(0, 3), (3, 4)]):
|
||||
expected[i].append(wav[start:end][valid[start:end]].reshape(-1).clone())
|
||||
for i, payload in enumerate(output.get_output()):
|
||||
if payload is not None:
|
||||
emitted[i].append(payload["model_outputs"])
|
||||
del output, wav # Pending views must keep the old host allocation alive.
|
||||
for i in range(2):
|
||||
assert torch.equal(torch.cat(emitted[i]), torch.cat(expected[i]))
|
||||
assert torch.cat(emitted[i]).dtype == dtype
|
||||
assert not states[i].active and not states[i].pending
|
||||
assert states[i].pending_samples == 0
|
||||
|
||||
|
||||
def test_stream_audio_numpy_direct_first_cancel_and_mixed_fallback():
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioBuffer
|
||||
|
||||
buffer = StreamingAudioBuffer(samples_per_frame=4, chunk_frames=3)
|
||||
state = buffer.add("r")
|
||||
state.accept_first_audio()
|
||||
assert state.push(np.arange(8, dtype=np.float32), False) is None
|
||||
assert state.pending_samples == 4
|
||||
# A dtype fallback can arrive while a NumPy frame is pending.
|
||||
chunk = state.push(torch.arange(8, 12, dtype=torch.bfloat16), True)
|
||||
assert chunk.tolist() == list(range(4, 12))
|
||||
assert state.pending_samples == 0
|
||||
buffer.finish({"r"})
|
||||
replacement = buffer.add("r")
|
||||
assert state.push(np.ones(4, dtype=np.float32), False) is None
|
||||
assert replacement.push(np.full(4, 99, dtype=np.float32), False).tolist() == [99] * 4
|
||||
replacement.push(np.ones(4, dtype=np.float32), False)
|
||||
assert replacement.pending_samples == 4
|
||||
buffer.finish({"r"})
|
||||
assert replacement.pending_samples == 0 and not replacement.pending
|
||||
|
||||
|
||||
def test_stream_audio_prefill_length_end_reaches_output(mocker) -> None:
|
||||
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||
from vllm.v1.worker.gpu.states import RequestState
|
||||
|
||||
from vllm_omni.worker_v2.model_states.eager_mtp import EagerMTPState
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioBuffer
|
||||
|
||||
owner = mocker.Mock()
|
||||
owner.vllm_config.model_config.max_model_len = 4096
|
||||
owner._stream_decode_event = None
|
||||
eager = EagerMTPState(owner)
|
||||
eager._audio_buffer = StreamingAudioBuffer(4, 25)
|
||||
eager._audio_buffer.add("request")
|
||||
eager._frame_requests = {"request"}
|
||||
batch = InputBatch.__new__(InputBatch)
|
||||
batch.num_reqs, batch.req_ids = 1, ["request"]
|
||||
batch.num_computed_tokens_np = np.array([0])
|
||||
batch.num_scheduled_tokens = np.array([3])
|
||||
batch.idx_mapping_np = np.array([0])
|
||||
batch.query_start_loc_np = np.array([0, 3])
|
||||
batch.is_prefilling_np = np.array([True])
|
||||
states = RequestState.__new__(RequestState)
|
||||
states.max_seq_len = np.array([4])
|
||||
mm = {
|
||||
"model_outputs": torch.ones(3, 4),
|
||||
"meta": {"codec_frame_valid": torch.tensor([0, 0, 1])},
|
||||
"sr": [torch.tensor(24000)],
|
||||
}
|
||||
output = eager.prepare_audio_output(batch, states, mm)
|
||||
assert output.get_output()[0]["model_outputs"].numel() == 4
|
||||
assert "model_outputs" not in mm
|
||||
|
||||
|
||||
def test_stream_audio_preemption_preserves_position_rng_and_pending_pcm(mocker) -> None:
|
||||
from vllm_omni.worker_v2.model_states.eager_mtp import EagerMTPState, TalkerInputs
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioBuffer
|
||||
|
||||
owner = mocker.Mock()
|
||||
owner._stream_pos = {"request": 37}
|
||||
owner._eager_embeds = torch.arange(18).reshape(6, 3).float()
|
||||
owner._eager_ready = {}
|
||||
owner.intermediate_buffer.buffers = [{} for _ in range(6)]
|
||||
generator = torch.Generator().manual_seed(42)
|
||||
owner._mtp_generators = {"request": generator}
|
||||
saved = [torch.arange(6)]
|
||||
owner.model.stream_decoder.save_slot.return_value = saved
|
||||
eager = EagerMTPState(owner)
|
||||
eager._talker_inputs["request"] = TalkerInputs(torch.ones(20, 3), 10)
|
||||
eager._side_stream = mocker.Mock()
|
||||
eager._audio_buffer = StreamingAudioBuffer(4, 25)
|
||||
request = eager._audio_buffer.add("request")
|
||||
request.push(torch.ones(4), False)
|
||||
request.push(torch.ones(4), False)
|
||||
eager.suspend_audio("request", 2)
|
||||
owner._stream_pos.clear()
|
||||
owner._mtp_generators.clear()
|
||||
eager.resume_audio("request", 5)
|
||||
assert owner._stream_pos["request"] == 37
|
||||
assert owner._mtp_generators["request"] is generator
|
||||
assert eager._restore_audio["request"] is saved
|
||||
assert sum(part.numel() for part in request.pending) == 4
|
||||
# A second preemption can happen before the restored slot is used.
|
||||
eager.suspend_audio("request", 5)
|
||||
owner.model.stream_decoder.save_slot.assert_called_once_with(2)
|
||||
eager.finish_audio({"request"})
|
||||
assert not eager._suspended_audio and not eager._restore_audio
|
||||
assert not eager._audio_buffer.requests and not request.active
|
||||
|
||||
|
||||
def test_stream_audio_replay_uses_exact_inputs_and_consumes_last_frame_once(mocker):
|
||||
from vllm_omni.worker_v2.model_states.eager_mtp import EagerMTPState, TalkerInputs
|
||||
|
||||
owner = mocker.Mock()
|
||||
owner._eager_embeds = torch.tensor([[20.0, 30.0]])
|
||||
owner.model.preprocess.return_value = (None, None, {"mtp_inputs": (None, torch.tensor([[2.0, 3.0]]))})
|
||||
owner.intermediate_buffer.buffers = [{"req_id": "request"}]
|
||||
eager = EagerMTPState(owner)
|
||||
eager._restore_audio["request"] = []
|
||||
history = torch.arange(8).reshape(4, 2).float()
|
||||
eager._talker_inputs["request"] = TalkerInputs(history, 4)
|
||||
first = torch.empty(2, 2)
|
||||
assert eager.replay_inputs("request", 0, 0, torch.ones(2), first)
|
||||
torch.testing.assert_close(first, history[:2])
|
||||
owner.model.preprocess.assert_not_called()
|
||||
last = torch.empty(3, 2)
|
||||
assert eager.replay_inputs("request", 0, 2, torch.ones(3), last)
|
||||
torch.testing.assert_close(last, torch.cat((history[2:], torch.tensor([[22.0, 33.0]]))))
|
||||
owner.model.preprocess.assert_called_once()
|
||||
with pytest.raises(RuntimeError, match="exceeds saved inputs"):
|
||||
eager.replay_inputs("request", 0, 4, torch.ones(2), torch.empty(2, 2))
|
||||
|
||||
|
||||
def test_stream_reference_priming_groups_lengths_without_padding_or_slot_aliasing(mocker):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import Qwen3TTSTalkerForConditionalGeneration
|
||||
|
||||
primes = [(4, torch.full((26, 2), 4)), (1, torch.full((3, 2), 1)), (7, torch.full((26, 2), 7))]
|
||||
calls = []
|
||||
|
||||
def stream(codes, slots, pos):
|
||||
calls.append((codes.clone(), slots.clone(), pos.clone()))
|
||||
|
||||
decoder = mocker.Mock(side_effect=stream, device=torch.device("cpu"))
|
||||
model = mocker.Mock(stream_decoder=decoder, stream_prime_graphs=None, stream_chunk_frames=25)
|
||||
Qwen3TTSTalkerForConditionalGeneration.prime_stream_decoder(model, primes)
|
||||
assert len(calls) == 3
|
||||
for (codes, slots, pos), frames, indices, position in zip(
|
||||
calls, [25, 1, 3], [[4, 7], [4, 7], [1]], [0, 25, 0], strict=True
|
||||
):
|
||||
assert codes.shape == (len(indices), frames, 2)
|
||||
assert codes.dtype == slots.dtype == pos.dtype == torch.int32
|
||||
assert slots.tolist() == indices and pos.tolist() == [position] * len(indices)
|
||||
for row, idx in enumerate(indices):
|
||||
assert torch.all(codes[row] == idx)
|
||||
Qwen3TTSTalkerForConditionalGeneration.prime_stream_decoder(model, [])
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("context_frames", [25, 72])
|
||||
def test_model_owned_reference_context_respects_configured_boundary(mocker, context_frames):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import Qwen3TTSTalkerForConditionalGeneration
|
||||
|
||||
model = mocker.Mock(stream_ref_context_frames=context_frames, talker_config=mocker.Mock(num_code_groups=16))
|
||||
reference = torch.arange(100 * 16).reshape(100, 16)
|
||||
info = {"codes": {"ref": reference}}
|
||||
got = Qwen3TTSTalkerForConditionalGeneration.get_stream_ref_context(model, info)
|
||||
torch.testing.assert_close(got, reference[-context_frames:])
|
||||
assert info["codes"]["ref"] is reference
|
||||
assert Qwen3TTSTalkerForConditionalGeneration.get_stream_ref_context(model, {}) is None
|
||||
assert (
|
||||
Qwen3TTSTalkerForConditionalGeneration.get_stream_ref_context(model, {"codes": {"ref": torch.empty(0)}}) is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
def test_model_owned_audio_finalizer_runs_after_copy_without_hidden(monkeypatch, streaming):
|
||||
monkeypatch.setattr(torch.cuda, "set_stream", lambda _stream: None)
|
||||
@@ -340,6 +569,136 @@ def test_model_owned_audio_finalizer_runs_after_copy_without_hidden(monkeypatch,
|
||||
assert (result.pooler_output is None) == streaming
|
||||
|
||||
|
||||
def test_stream_audio_history_replays_exact_inputs_after_batch_reordering(mocker):
|
||||
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||
|
||||
from vllm_omni.worker_v2.model_states.eager_mtp import EagerMTPState
|
||||
|
||||
owner = mocker.Mock()
|
||||
owner.vllm_config.model_config.max_model_len = 12
|
||||
eager = EagerMTPState(owner)
|
||||
first = torch.arange(9, dtype=torch.float32).reshape(3, 3)
|
||||
second = torch.arange(9, 18, dtype=torch.float32).reshape(3, 3)
|
||||
expected = {"a": torch.cat((first[:2], second[2:])), "b": torch.cat((first[2:], second[:2]))}
|
||||
batch = InputBatch.__new__(InputBatch)
|
||||
batch.req_ids = ["a", "b"]
|
||||
batch.query_start_loc_np = np.array([0, 2, 3])
|
||||
batch.num_computed_tokens_np = np.array([0, 0])
|
||||
eager.record_inputs(batch, first)
|
||||
batch.req_ids = ["b", "a"]
|
||||
batch.num_computed_tokens_np = np.array([1, 2])
|
||||
eager.record_inputs(batch, second)
|
||||
# Later input-buffer reuse must not change the conditioned replay history.
|
||||
first.fill_(-1)
|
||||
second.fill_(-2)
|
||||
for request_id, wanted in expected.items():
|
||||
eager._restore_audio[request_id] = []
|
||||
replayed = torch.empty_like(wanted)
|
||||
assert eager.replay_inputs(request_id, 0, 0, torch.zeros(3, dtype=torch.long), replayed)
|
||||
torch.testing.assert_close(replayed, wanted)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("valid", [False, True])
|
||||
def test_stream_audio_direct_first_requires_only_real_pcm_on_terminal(valid):
|
||||
from vllm_omni.data_entry_keys import FIRST_AUDIO_REQUIRED_KEY
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioBuffer, StreamingAudioOutput
|
||||
|
||||
buffer = StreamingAudioBuffer(samples_per_frame=4, chunk_frames=3)
|
||||
state = buffer.add("r")
|
||||
state.accept_first_audio()
|
||||
first = StreamingAudioOutput(
|
||||
torch.arange(4).reshape(1, 4),
|
||||
torch.tensor([valid]),
|
||||
torch.tensor(24000),
|
||||
None,
|
||||
np.array([0, 1]),
|
||||
[state],
|
||||
np.array([True]),
|
||||
)
|
||||
payload = first.get_output()[0]
|
||||
if valid:
|
||||
assert payload == {FIRST_AUDIO_REQUIRED_KEY: torch.tensor(True)}
|
||||
else:
|
||||
assert payload is None # EOS has no promised first PCM to wait for.
|
||||
assert not state.active and not state.pending
|
||||
buffer.finish({"r"})
|
||||
replacement = buffer.add("r")
|
||||
assert replacement.push(torch.full((4,), 99), False).tolist() == [99] * 4
|
||||
|
||||
|
||||
def test_stream_audio_direct_first_drops_one_frame_and_retains_partial_suffix():
|
||||
from vllm_omni.worker_v2.streaming_audio import AudioRequest
|
||||
|
||||
state = AudioRequest(samples_per_frame=4, chunk_frames=3)
|
||||
state.accept_first_audio()
|
||||
assert state.push(torch.arange(4), False) is None
|
||||
assert state.first_audio_required and state.emitted
|
||||
assert state.push(torch.arange(4, 8), False) is None
|
||||
chunk = state.push(torch.arange(8, 12), True)
|
||||
assert chunk.tolist() == list(range(4, 12))
|
||||
assert not state.active and not state.pending
|
||||
|
||||
|
||||
@pytest.mark.parametrize("consumer", ["pcm_only", "finalizer", "extra"])
|
||||
def test_stream_audio_snapshot_skips_discarded_code_partition(monkeypatch, mocker, consumer):
|
||||
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||
|
||||
from vllm_omni.worker_v2.output_snapshot import RequestOutputSnapshot
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioBuffer, StreamingAudioOutput
|
||||
|
||||
monkeypatch.setattr(torch.cuda, "set_stream", lambda _stream: None)
|
||||
monkeypatch.setattr(
|
||||
OmniARModelRunner,
|
||||
"_build_async_chunk_outputs_from_mm",
|
||||
lambda *args: pytest.fail("in-stage PCM unnecessarily partitioned code outputs"),
|
||||
)
|
||||
batch = InputBatch.__new__(InputBatch)
|
||||
batch.num_reqs = 3
|
||||
batch.query_start_loc_np = np.array([0, 1, 2, 4])
|
||||
batch.num_scheduled_tokens = np.array([1, 1, 2])
|
||||
batch.num_tokens_after_padding = 4
|
||||
buffer = StreamingAudioBuffer(samples_per_frame=4, chunk_frames=3)
|
||||
states = [buffer.add(request_id) for request_id in ["frame", "eos", "prefill"]]
|
||||
wav = torch.arange(16, dtype=torch.float32).reshape(4, 4)
|
||||
streaming_audio = StreamingAudioOutput(
|
||||
wav,
|
||||
torch.tensor([True, False, True, False]),
|
||||
torch.tensor(24000),
|
||||
None,
|
||||
batch.query_start_loc_np.copy(),
|
||||
states,
|
||||
np.array([False, False, True]),
|
||||
)
|
||||
copy_mm = mocker.spy(omni_ar_model_runner, "_async_copy_mm")
|
||||
|
||||
def finalize(payload, counts):
|
||||
assert payload["codes"]["audio"].shape == (4, 2)
|
||||
return RequestOutputSnapshot([None] * batch.num_reqs)
|
||||
|
||||
finalizer = mocker.Mock(side_effect=finalize) if consumer == "finalizer" else None
|
||||
extra = ({"extra": torch.tensor([7])}, _FakeEvent()) if consumer == "extra" else None
|
||||
result = _async_output(
|
||||
req_ids=["frame", "eos", "prefill"],
|
||||
sampler_output=SamplerOutput(torch.tensor([[1], [2], [3]]), None, None, None),
|
||||
num_sampled_tokens=torch.tensor([1, 1, 1]),
|
||||
multimodal_outputs={"codes": {"audio": torch.ones(4, 2)}},
|
||||
input_batch=batch,
|
||||
async_chunk=True,
|
||||
streaming_audio=streaming_audio,
|
||||
finalize_multimodal=finalizer,
|
||||
extra_multimodal_outputs=extra,
|
||||
).get_output()
|
||||
assert copy_mm.call_count == {"pcm_only": 0, "finalizer": 1, "extra": 2}[consumer]
|
||||
if finalizer is not None:
|
||||
finalizer.assert_called_once()
|
||||
wav.fill_(-1)
|
||||
assert result.inter_stage_outputs is None
|
||||
assert result.multimodal_outputs[0]["model_outputs"].tolist() == [0, 1, 2, 3]
|
||||
assert result.multimodal_outputs[1] == {}
|
||||
assert result.multimodal_outputs[2]["model_outputs"].tolist() == [8, 9, 10, 11]
|
||||
assert not states[1].active and not states[2].active
|
||||
|
||||
|
||||
@pytest.mark.parametrize("streaming", [False, True])
|
||||
def test_request_owned_snapshot_skips_generic_partition(monkeypatch, streaming):
|
||||
from vllm_omni.worker_v2.output_snapshot import RequestOutputSnapshot
|
||||
|
||||
@@ -36,6 +36,7 @@ class _DummyInputBatch:
|
||||
def _make_state(max_num_reqs=4, has_preprocess=False, has_postprocess=False, have_multimodal_outputs=False):
|
||||
state = object.__new__(OmniModelState)
|
||||
model = MagicMock()
|
||||
model.stream_decoder = None
|
||||
model.has_preprocess = has_preprocess
|
||||
model.has_postprocess = has_postprocess
|
||||
model.have_multimodal_outputs = have_multimodal_outputs
|
||||
@@ -62,6 +63,10 @@ def _make_state(max_num_reqs=4, has_preprocess=False, has_postprocess=False, hav
|
||||
|
||||
state.intermediate_buffer = OmniIntermediateBuffer(max_num_reqs)
|
||||
state._static_inputs_embeds = None
|
||||
from vllm_omni.worker_v2.model_states.eager_mtp import EagerMTPState
|
||||
|
||||
state._eager_state = EagerMTPState(state)
|
||||
state._stream_pos = {}
|
||||
state._mtp_generators = {}
|
||||
state._mtp_runner = None
|
||||
for name in ("_mtp_input_ids", "_mtp_input_embeds", "_mtp_hidden", "_mtp_text_step", "_mtp_offsets"):
|
||||
@@ -264,6 +269,7 @@ def test_seed_independence_resolve_once_and_sampling_kwargs():
|
||||
# vLLM sampling seed must not produce a talker generator.
|
||||
cpu = torch.device("cpu")
|
||||
assert state._get_mtp_generator("r1", SimpleNamespace(extra_args={}, seed=42), cpu) is None
|
||||
state._mtp_generators.clear() # The following rows are new requests.
|
||||
# Same model-local seed reproduces identical uniforms regardless of batch makeup.
|
||||
state._mtp_sample_uniforms = torch.empty((2, 2, 4))
|
||||
assert torch.equal(
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import MISSING, field
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ConfigDict, TypeAdapter
|
||||
from transformers import PretrainedConfig
|
||||
from vllm.config import ModelConfig
|
||||
from vllm.config.utils import config
|
||||
from vllm.logger import init_logger
|
||||
@@ -124,9 +125,12 @@ class OmniModelConfig(ModelConfig):
|
||||
"""
|
||||
|
||||
stage_id: int = 0
|
||||
# Match the upstream derived field while its value is replaced per stage.
|
||||
hf_text_config: PretrainedConfig = field(init=False)
|
||||
async_chunk: bool = False
|
||||
session_mode: str = "turn"
|
||||
retains_state_across_chunks: bool = False
|
||||
supports_running_prefix_cache_reset: bool = True
|
||||
use_v2_model_runner: bool = False
|
||||
supports_native_mrv2_data_plane: bool = False
|
||||
# Stage-1 active stream slots; 0 keeps legacy chunk-level round-robin.
|
||||
@@ -218,7 +222,7 @@ class OmniModelConfig(ModelConfig):
|
||||
return convertor.convert()
|
||||
return super().get_model_arch_config()
|
||||
|
||||
def draw_hf_text_config(self):
|
||||
def draw_hf_text_config(self) -> PretrainedConfig:
|
||||
# transformers' get_text_config method is used to get the text config from thinker_config.
|
||||
# to handle the case that each model stage has their own text config,
|
||||
# we need to draw the text config from the corresponding model stage.
|
||||
|
||||
@@ -482,6 +482,7 @@ class OmniStageModelConfig(_TrackExplicitConfigFields):
|
||||
interleave_mm_strings: bool | None = None
|
||||
media_io_kwargs: dict[str, Any] | None = None
|
||||
final_output: bool = False
|
||||
supports_running_prefix_cache_reset: bool = True
|
||||
active_stream_window: int = Field(default=0, ge=0)
|
||||
session_mode: str = "turn"
|
||||
duplex_max_sessions: int = Field(default=1, ge=1)
|
||||
@@ -1325,6 +1326,7 @@ _DIFFUSION_STAGE_METADATA_FIELDS = frozenset(
|
||||
"model_arch",
|
||||
"model_stage",
|
||||
"retains_state_across_chunks",
|
||||
"supports_running_prefix_cache_reset",
|
||||
"scheduler_cls",
|
||||
"stage_connector_spec",
|
||||
"worker_type",
|
||||
@@ -1972,6 +1974,8 @@ def _build_model_config(
|
||||
if "active_stream_window" not in kwargs:
|
||||
kwargs["active_stream_window"] = _copy_value(deploy.active_stream_window)
|
||||
kwargs["final_output"] = topology.final_output
|
||||
if not topology.supports_running_prefix_cache_reset:
|
||||
kwargs["supports_running_prefix_cache_reset"] = False
|
||||
if "custom_voice_dir" not in kwargs and deploy.custom_voice_dir is not None:
|
||||
kwargs["custom_voice_dir"] = _copy_value(deploy.custom_voice_dir)
|
||||
stage_runner = resolve_stage_model_runner(deploy, stage_deploy)
|
||||
|
||||
@@ -117,7 +117,7 @@ from vllm_omni.model_executor.models.qwen3_omni.pipeline import (
|
||||
QWEN3_OMNI_THINKER_ONLY_PIPELINE,
|
||||
resolve_qwen3_omni_pipeline,
|
||||
)
|
||||
from vllm_omni.model_executor.models.qwen3_tts.pipeline import QWEN3_TTS_PIPELINE
|
||||
from vllm_omni.model_executor.models.qwen3_tts.pipeline import QWEN3_TTS_FUSED_PIPELINE, QWEN3_TTS_PIPELINE
|
||||
from vllm_omni.model_executor.models.step_audio2.pipeline import (
|
||||
STEP_AUDIO2_ASR_PIPELINE,
|
||||
STEP_AUDIO2_PIPELINE,
|
||||
@@ -204,6 +204,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
|
||||
"qwen3_omni_moe": resolve_qwen3_omni_pipeline,
|
||||
"qwen3_omni_moe_thinker_only": QWEN3_OMNI_THINKER_ONLY_PIPELINE,
|
||||
"qwen3_tts": QWEN3_TTS_PIPELINE,
|
||||
"qwen3_tts_fused": QWEN3_TTS_FUSED_PIPELINE,
|
||||
"step_audio_2": STEP_AUDIO2_PIPELINE,
|
||||
"step_audio_2_asr": STEP_AUDIO2_ASR_PIPELINE,
|
||||
"voxcpm2": VOXCPM2_PIPELINE,
|
||||
|
||||
@@ -245,6 +245,8 @@ class StagePipelineConfig:
|
||||
# The model keeps per-request execution state while awaiting the next
|
||||
# async chunk, so the parked request continues to consume model capacity.
|
||||
retains_state_across_chunks: bool = False
|
||||
# Some stateful audio stages cannot roll back already consumed frames.
|
||||
supports_running_prefix_cache_reset: bool = True
|
||||
sampling_constraints: dict[str, Any] = field(default_factory=dict)
|
||||
custom_process_input_func: str | None = None
|
||||
custom_process_next_stage_input_func: str | None = None
|
||||
@@ -295,6 +297,9 @@ class PipelineConfig:
|
||||
model_type: str
|
||||
model_arch: str = ""
|
||||
stages: tuple[StagePipelineConfig, ...] = ()
|
||||
# A single stage that streams its own final output in async-chunk mode
|
||||
# (e.g. a Talker decoding audio in-stage) keeps deploy.async_chunk.
|
||||
single_stage_async_chunk: bool = False
|
||||
# HF architecture aliases: used by StageConfigFactory when the model's
|
||||
# HF config reports a generic model_type that collides with a different
|
||||
# model (e.g. MiMo Audio reports model_type="qwen2"). The factory
|
||||
@@ -1107,6 +1112,8 @@ def _build_engine_args(
|
||||
if ps.omni_kv_config:
|
||||
engine_args["omni_kv_config"] = dict(ps.omni_kv_config)
|
||||
engine_args["requires_full_payload_input"] = ps.requires_full_payload_input
|
||||
if not ps.supports_running_prefix_cache_reset:
|
||||
engine_args["supports_running_prefix_cache_reset"] = False
|
||||
return engine_args
|
||||
|
||||
|
||||
@@ -1172,8 +1179,11 @@ def _resolve_pipeline_async_chunk_enabled(
|
||||
and False otherwise. If the user tried to enable async chunk through the deploy
|
||||
config, but it's inapplicable or unsupported, it will be disabled with a warning.
|
||||
"""
|
||||
# Single stage should never use async chunk
|
||||
if len(pipeline.stages) <= 1:
|
||||
if pipeline.single_stage_async_chunk:
|
||||
if deploy.async_chunk is False:
|
||||
raise ValueError(f"Pipeline {pipeline.model_type!r} requires async_chunk=True")
|
||||
return True
|
||||
if deploy.async_chunk:
|
||||
logger.warning(
|
||||
"Deploy config set async_chunk=True, but async chunk is inapplicable "
|
||||
@@ -1350,6 +1360,8 @@ class StageConfig:
|
||||
|
||||
# Terminal-stage ownership comes from topology, not engine overrides.
|
||||
engine_args["final_output"] = self.final_output
|
||||
if self.yaml_engine_args.get("supports_running_prefix_cache_reset") is False:
|
||||
engine_args["supports_running_prefix_cache_reset"] = False
|
||||
|
||||
# Build runtime config from YAML defaults + CLI overrides
|
||||
runtime: dict[str, Any] = dict(self.yaml_runtime)
|
||||
|
||||
@@ -101,6 +101,23 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
max_num_running_reqs: int
|
||||
|
||||
def reset_prefix_cache(self, reset_running_requests: bool = False, reset_connector: bool = False) -> bool:
|
||||
model_config = self.vllm_config.model_config
|
||||
if (
|
||||
reset_running_requests
|
||||
and self.running
|
||||
and not getattr(model_config, "supports_running_prefix_cache_reset", True)
|
||||
):
|
||||
# Reset preempts and resumes in the same step while discarding
|
||||
# in-flight tokens. The stateful codec and queued PCM have already
|
||||
# consumed those frames and cannot roll back to that boundary.
|
||||
logger.warning(
|
||||
"This stage cannot reset running requests; wait for "
|
||||
"completion or abort them before resetting the prefix cache."
|
||||
)
|
||||
return False
|
||||
return super().reset_prefix_cache(reset_running_requests, reset_connector)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Track requests that need KV cache transfer when finished
|
||||
@@ -517,7 +534,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
# domains must consume the old frame before new output passes.
|
||||
request.async_tokens_to_discard = max(0, stale_async_tokens - len(generated_token_ids))
|
||||
|
||||
if output_is_stale or async_output_is_stale:
|
||||
if async_output_is_stale or (output_is_stale and request.drop_stale_output):
|
||||
# Output of a step scheduled before the request's in-flight
|
||||
# tokens were discarded (segment stop / session replacement).
|
||||
# num_computed_tokens was rolled back at the discard site, so
|
||||
@@ -556,12 +573,13 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
# tokens and rejections. If some tokens are rejected,
|
||||
# num_computed_tokens is decreased by the number of rejected
|
||||
# tokens.
|
||||
if request.num_computed_tokens > 0:
|
||||
request.num_computed_tokens -= num_rejected
|
||||
# If async scheduling, num_output_placeholders also includes
|
||||
# the scheduled spec tokens count and so is similarly adjusted.
|
||||
if request.num_output_placeholders > 0:
|
||||
request.num_output_placeholders -= num_rejected
|
||||
if not output_is_stale:
|
||||
if request.num_computed_tokens > 0:
|
||||
request.num_computed_tokens -= num_rejected
|
||||
# If async scheduling, num_output_placeholders also includes
|
||||
# the scheduled spec tokens count and so is similarly adjusted.
|
||||
if request.num_output_placeholders > 0:
|
||||
request.num_output_placeholders -= num_rejected
|
||||
spec_decoding_stats = self.make_spec_decoding_stats(
|
||||
spec_decoding_stats,
|
||||
num_draft_tokens=num_draft_tokens,
|
||||
@@ -600,7 +618,10 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
# Check for stop and update request status.
|
||||
if new_token_ids:
|
||||
num_sampled_tokens = len(new_token_ids)
|
||||
new_token_ids, stopped = self._update_request_with_output(request, new_token_ids)
|
||||
if output_is_stale:
|
||||
new_token_ids, stopped = self._update_request_with_output(request, new_token_ids, is_stale=True)
|
||||
else:
|
||||
new_token_ids, stopped = self._update_request_with_output(request, new_token_ids)
|
||||
if new_logprobs is not None and len(new_token_ids) < num_sampled_tokens:
|
||||
# A mid-step stop (e.g. spec-decode tokens sampled past
|
||||
# EOS) trims new_token_ids after the validation slice
|
||||
@@ -704,6 +725,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
# a queued streaming update, and adding twice swallows the
|
||||
# next duplex unit's listen/speak under async scheduling.
|
||||
if request.num_in_flight_tokens > 0:
|
||||
request.drop_stale_output = True
|
||||
request.num_stale_output_tokens = request.num_in_flight_tokens
|
||||
if outstanding_async_tokens > 0:
|
||||
# Discard only outputs that are already in flight and
|
||||
@@ -884,6 +906,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
# a resumable stop applies a queued update through this helper.
|
||||
in_flight_tokens = int(getattr(session, "num_in_flight_tokens", 0) or 0)
|
||||
if in_flight_tokens > 0:
|
||||
session.drop_stale_output = True
|
||||
session.num_stale_output_tokens = in_flight_tokens
|
||||
if outstanding_async_tokens > 0:
|
||||
# Async scheduling may already have sampled the previous
|
||||
@@ -1405,6 +1428,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
if status is not None:
|
||||
request.status = status
|
||||
if native_transfer and connector_delay_free_blocks:
|
||||
assert transfer_params is not None
|
||||
kv_xfer_params = {
|
||||
**(kv_xfer_params or {}),
|
||||
"transfer_id": transfer_params["transfer_id"],
|
||||
|
||||
@@ -222,6 +222,7 @@ class OmniSchedulerMixin(_SchedulerMixinBase):
|
||||
# num_in_flight_tokens already includes any undrained stale share.
|
||||
# Assign instead of accumulating so callers that fenced the same
|
||||
# rollover before entering this helper do not count it twice.
|
||||
session.drop_stale_output = True
|
||||
session.num_stale_output_tokens = int(getattr(session, "num_in_flight_tokens", 0) or 0)
|
||||
session.num_output_placeholders = 0
|
||||
session.spec_token_ids = []
|
||||
@@ -316,6 +317,7 @@ class OmniSchedulerMixin(_SchedulerMixinBase):
|
||||
# The streaming update may already have fenced this same in-flight
|
||||
# frame. Seed idempotently so the replacement does not count it twice.
|
||||
request.num_stale_output_tokens = int(getattr(request, "num_in_flight_tokens", 0) or 0)
|
||||
request.drop_stale_output = True
|
||||
request.num_output_placeholders = 0
|
||||
request.spec_token_ids = []
|
||||
self._release_replaced_streaming_prompt_cache(request)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Single-stage Qwen3-TTS: the Talker decodes every frame with the stateful
|
||||
# streaming codec decoder and returns PCM itself (no Code2Wav stage).
|
||||
# reset_prefix_cache(reset_running_requests=True) returns False while requests
|
||||
# are running; wait for completion or abort them before resetting the cache.
|
||||
pipeline: qwen3_tts_fused
|
||||
model_runner: v2
|
||||
async_chunk: true
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "0"
|
||||
max_num_seqs: 128
|
||||
# Exact conditioned Talker inputs for preemption replay are allocated
|
||||
# after KV profiling: 16 MiB per active request at 1.7B/max_model_len=4096
|
||||
# (~2 GiB at 128 requests), in addition to codec state and output buffers.
|
||||
# Keep headroom when increasing gpu_memory_utilization or capacity.
|
||||
gpu_memory_utilization: 0.5
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
# Model options that multi-stage deployments put on the connector: a
|
||||
# single stage has no connector edge.
|
||||
additional_config:
|
||||
talker_stream_decode: true
|
||||
# Opt in to first-frame delivery on its own queue for lower first-audio
|
||||
# latency; leave disabled when prioritizing peak throughput.
|
||||
talker_stream_first_audio: false
|
||||
# Match the standard two-stage profile's voice-clone context boundary.
|
||||
# A shorter context (e.g. 25) can change timbre continuity.
|
||||
ref_code_context_frames: 72
|
||||
code_predictor_kv_cache: true
|
||||
code_predictor_fused_sampling: true
|
||||
code_predictor_fused: true
|
||||
# Talker decode contexts are short (~100-700 tokens): FA3 split-KV plus
|
||||
# its combine kernel costs more than one pass per head.
|
||||
attention_config:
|
||||
flash_attn_max_num_splits_for_cuda_graph: 1
|
||||
async_scheduling: true
|
||||
max_num_batched_tokens: 512
|
||||
max_model_len: 4096
|
||||
compilation_config:
|
||||
cudagraph_mode: FULL
|
||||
cudagraph_capture_sizes: [1, 2, 4, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128, 144, 160, 176, 192, 208, 224, 240, 256, 272, 288, 304, 320, 336, 352, 368, 384, 400, 416, 432, 448, 464, 480, 496, 512]
|
||||
max_cudagraph_capture_size: 512
|
||||
default_sampling_params:
|
||||
temperature: 0.9
|
||||
top_k: 50
|
||||
max_tokens: 4096
|
||||
min_tokens: 2
|
||||
repetition_penalty: 1.05
|
||||
subtalker_sampling_params:
|
||||
do_sample: true
|
||||
temperature: 0.9
|
||||
top_k: 50
|
||||
top_p: 1.0
|
||||
@@ -198,6 +198,7 @@ class OmniEngineArgs(EngineArgs):
|
||||
async_chunk: bool = False
|
||||
session_mode: str = "turn"
|
||||
retains_state_across_chunks: bool = False
|
||||
supports_running_prefix_cache_reset: bool = True
|
||||
use_v2_model_runner: bool = False
|
||||
supports_native_mrv2_data_plane: bool = False
|
||||
# WS-A: Stage-1 active stream slots. 0 = legacy preempt-everything.
|
||||
@@ -430,6 +431,7 @@ class OmniEngineArgs(EngineArgs):
|
||||
async_chunk=self.async_chunk,
|
||||
session_mode=self.session_mode,
|
||||
retains_state_across_chunks=self.retains_state_across_chunks,
|
||||
supports_running_prefix_cache_reset=self.supports_running_prefix_cache_reset,
|
||||
use_v2_model_runner=self.use_v2_model_runner,
|
||||
supports_native_mrv2_data_plane=self.supports_native_mrv2_data_plane,
|
||||
active_stream_window=self.active_stream_window,
|
||||
|
||||
@@ -898,7 +898,7 @@ class OrchestratorBase:
|
||||
req_state is None
|
||||
or req_state.upstream_first_audio
|
||||
or req_state.pending_upstream_first_audio is not None
|
||||
or stage_id + 1 > req_state.final_stage_id
|
||||
or (stage_id if final_output else stage_id + 1) > req_state.final_stage_id
|
||||
):
|
||||
continue
|
||||
audio = mm.get("model_outputs")
|
||||
@@ -935,7 +935,7 @@ class OrchestratorBase:
|
||||
if pending is None or self.request_states.get(req_state.request_id) is not req_state:
|
||||
return
|
||||
source_stage, first_output = pending
|
||||
codec_stage = source_stage + 1
|
||||
codec_stage = source_stage if self.stage_pools[source_stage].final_output else source_stage + 1
|
||||
pool = self.stage_pools[codec_stage]
|
||||
replica_id = pool.get_bound_replica_id(req_state.request_id)
|
||||
if replica_id is None or req_state.request_id not in pool.output_processor.request_states:
|
||||
|
||||
@@ -87,7 +87,10 @@ def _bind_first_audio_sink(model_executor: Any, output_queue: Any, scheduler: An
|
||||
model_runner = getattr(worker, "model_runner", None)
|
||||
model_state = getattr(model_runner, "model_state", None)
|
||||
model = getattr(model_runner, "model", None)
|
||||
if getattr(model, "first_frame_decoder", None) is None or not hasattr(model_state, "set_first_audio_sink"):
|
||||
decodes_audio = getattr(model, "first_frame_decoder", None) is not None or (
|
||||
getattr(model, "stream_decoder", None) is not None and bool(getattr(model, "stream_first_audio", False))
|
||||
)
|
||||
if not decodes_audio or not hasattr(model_state, "set_first_audio_sink"):
|
||||
return False
|
||||
from vllm_omni.worker_v2.first_audio_sender import engine_output_queue_sink
|
||||
|
||||
|
||||
@@ -249,6 +249,33 @@ async def omni_run_server(args, **uvicorn_kwargs) -> None:
|
||||
await omni_run_server_worker(listen_address, sock, args, **uvicorn_kwargs)
|
||||
|
||||
|
||||
def _own_reuseport_socket(sock: socket.socket, client_count: int) -> socket.socket:
|
||||
"""Give this API server its own SO_REUSEPORT listener on the shared address.
|
||||
|
||||
All API servers accepting from the one inherited socket lets whichever
|
||||
wakes first take a whole burst of connections (the event loop accepts
|
||||
in a loop), and keep-alive then pins that imbalance for the connections'
|
||||
lifetime. Separate reuseport listeners let the kernel spread connections
|
||||
by hash. The inherited socket is bound but never listened on, so it
|
||||
receives nothing.
|
||||
"""
|
||||
if client_count <= 1:
|
||||
return sock
|
||||
if sock.family not in (socket.AF_INET, socket.AF_INET6):
|
||||
return sock
|
||||
try:
|
||||
if not sock.getsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT):
|
||||
return sock
|
||||
own = socket.socket(family=sock.family, type=socket.SOCK_STREAM)
|
||||
own.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
own.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
own.bind(sock.getsockname()[:2])
|
||||
except OSError as e:
|
||||
logger.warning("Per-server reuseport listener unavailable (%s); sharing the inherited socket", e)
|
||||
return sock
|
||||
return own
|
||||
|
||||
|
||||
def run_omni_api_server_worker_proc(
|
||||
listen_address: str,
|
||||
sock: socket.socket,
|
||||
@@ -278,6 +305,7 @@ def run_omni_api_server_worker_proc(
|
||||
|
||||
set_process_title("APIServer", str(client_index))
|
||||
decorate_logs("APIServer", skip_if_decorated=True)
|
||||
sock = _own_reuseport_socket(sock, int(omni_client_config["client_count"]))
|
||||
uvloop.run(
|
||||
omni_run_server_worker(
|
||||
listen_address,
|
||||
@@ -512,7 +540,7 @@ async def build_async_omni(
|
||||
# Ensures everything is shutdown and cleaned up on error/exit
|
||||
async with build_async_omni_from_stage_config(
|
||||
args,
|
||||
disable_frontend_multiprocessing=disable_frontend_multiprocessing,
|
||||
disable_frontend_multiprocessing=bool(disable_frontend_multiprocessing),
|
||||
client_config=client_config,
|
||||
) as async_omni:
|
||||
yield async_omni
|
||||
|
||||
@@ -46,6 +46,15 @@ except ImportError:
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _float32_to_pcm16_bytes(audio: np.ndarray) -> bytes:
|
||||
"""PCM_16 bytes of float32 samples, bit-identical to libsndfile's RAW writer.
|
||||
|
||||
libsndfile (1.2) converts with ``floor(x * 0x8000)`` clipped to int16.
|
||||
"""
|
||||
scaled = np.floor(audio.astype(np.float64) * 32768.0)
|
||||
return np.clip(scaled, -32768, 32767).astype("<i2").tobytes()
|
||||
|
||||
|
||||
class AudioMixin:
|
||||
"""Mixin class to add audio-related utilities."""
|
||||
|
||||
@@ -93,9 +102,15 @@ class AudioMixin:
|
||||
|
||||
soundfile_format, media_type, kwargs = supported_formats[response_format]
|
||||
|
||||
with BytesIO() as buffer:
|
||||
soundfile.write(buffer, audio_tensor, sample_rate, format=soundfile_format, **kwargs)
|
||||
audio_data = buffer.getvalue()
|
||||
audio_data: bytes | str
|
||||
if response_format == "pcm" and isinstance(audio_tensor, np.ndarray) and audio_tensor.dtype == np.float32:
|
||||
# Through BytesIO, soundfile makes a Python callback per block;
|
||||
# streamed chunks made that the API workers' largest cost.
|
||||
audio_data = _float32_to_pcm16_bytes(audio_tensor)
|
||||
else:
|
||||
with BytesIO() as buffer:
|
||||
soundfile.write(buffer, audio_tensor, sample_rate, format=soundfile_format, **kwargs)
|
||||
audio_data = buffer.getvalue()
|
||||
|
||||
if base64_encode:
|
||||
import base64
|
||||
|
||||
@@ -397,34 +397,60 @@ class Qwen3TTSAdapter(ARTTSAdapter):
|
||||
|
||||
return params
|
||||
|
||||
def _load_text_tokenizer(self) -> None:
|
||||
server = self.ctx.server
|
||||
if server._tts_tokenizer is not None:
|
||||
return
|
||||
import transformers
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
# Same construction as the engine's text tokenizer
|
||||
# (Qwen3TTSPromptEmbedsBuilder.get_text_tokenizer), so the ids computed
|
||||
# here can be handed to the engine.
|
||||
kwargs: dict[str, Any] = dict(trust_remote_code=True, use_fast=True, padding_side="left")
|
||||
if transformers.__version__ < "5":
|
||||
kwargs["fix_mistral_regex"] = True
|
||||
assert self.ctx.engine_client is not None
|
||||
model_name = self.ctx.engine_client.model_config.model
|
||||
server._tts_tokenizer = AutoTokenizer.from_pretrained(model_name, **kwargs)
|
||||
|
||||
def _estimate_prompt_len(self, tts_params: dict[str, Any]) -> int:
|
||||
"""Estimate prompt length so the placeholder matches model-side embeddings."""
|
||||
try:
|
||||
from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import (
|
||||
PRECOMPUTED_TEXT_IDS_KEY,
|
||||
Qwen3TTSPromptEmbedsBuilder,
|
||||
build_assistant_text,
|
||||
)
|
||||
|
||||
server = self.ctx.server
|
||||
if server._tts_tokenizer is None:
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
model_name = self.ctx.engine_client.model_config.model
|
||||
server._tts_tokenizer = AutoTokenizer.from_pretrained(
|
||||
model_name,
|
||||
trust_remote_code=True,
|
||||
padding_side="left",
|
||||
)
|
||||
self._load_text_tokenizer()
|
||||
assert self.ctx.engine_client is not None
|
||||
hf_config = self.ctx.engine_client.model_config.hf_config
|
||||
talker_config = hf_config.talker_config
|
||||
task_type = (tts_params.get("task_type") or ["CustomVoice"])[0]
|
||||
return Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
|
||||
text = (tts_params.get("text") or [""])[0]
|
||||
assistant_text = build_assistant_text(text) if isinstance(text, str) else None
|
||||
assistant_ids: list[list[int]] = []
|
||||
|
||||
def tokenize_prompt(t: str) -> list[int]:
|
||||
ids = server._tts_tokenizer(t, padding=False)["input_ids"]
|
||||
if t == assistant_text:
|
||||
assistant_ids.append(ids)
|
||||
return ids
|
||||
|
||||
prompt_len = Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information(
|
||||
additional_information=tts_params,
|
||||
task_type=task_type,
|
||||
tokenize_prompt=lambda t: server._tts_tokenizer(t, padding=False)["input_ids"],
|
||||
tokenize_prompt=tokenize_prompt,
|
||||
codec_language_id=getattr(talker_config, "codec_language_id", None),
|
||||
spk_is_dialect=getattr(talker_config, "spk_is_dialect", None),
|
||||
estimate_ref_code_len=self._estimate_ref_code_len,
|
||||
)
|
||||
if assistant_ids:
|
||||
# Spares the engine tokenizing the same text on its step thread.
|
||||
tts_params[PRECOMPUTED_TEXT_IDS_KEY] = [assistant_ids[0]]
|
||||
return prompt_len
|
||||
except Exception as e:
|
||||
logger.warning("Failed to estimate TTS prompt length, using fallback 2048: %s", e)
|
||||
return 2048
|
||||
|
||||
@@ -954,7 +954,13 @@ class CodePredictorWrapper(nn.Module):
|
||||
extra_cfg = connector_cfg.get("extra", connector_cfg)
|
||||
else:
|
||||
extra_cfg = getattr(connector_cfg, "extra", None)
|
||||
return extra_cfg if isinstance(extra_cfg, dict) else {}
|
||||
extra_cfg = extra_cfg if isinstance(extra_cfg, dict) else {}
|
||||
# A single-stage deployment has no connector edge to carry model
|
||||
# options; it sets them in the stage's ``additional_config``.
|
||||
additional = getattr(vllm_config, "additional_config", None)
|
||||
if isinstance(additional, dict) and additional:
|
||||
return {**additional, **extra_cfg}
|
||||
return extra_cfg
|
||||
|
||||
@staticmethod
|
||||
def _parse_bool_config(value: object) -> bool:
|
||||
@@ -1332,6 +1338,21 @@ class CodePredictorWrapper(nn.Module):
|
||||
scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf"))
|
||||
return self._sample_codes_gumbel(scaled, generator=generator, uniforms=uniforms)
|
||||
|
||||
def _validate_sampling_inputs(
|
||||
self,
|
||||
bsz: int,
|
||||
generators: Sequence[torch.Generator | None] | None,
|
||||
sample_uniforms: torch.Tensor | None,
|
||||
) -> None:
|
||||
if generators is not None and len(generators) != bsz:
|
||||
raise ValueError(f"generators must have one entry per row: got {len(generators)} for batch {bsz}")
|
||||
if sample_uniforms is not None:
|
||||
expected_shape = (bsz, self._num_groups - 1, int(self.config.vocab_size))
|
||||
if tuple(sample_uniforms.shape) != expected_shape:
|
||||
raise ValueError(
|
||||
f"sample_uniforms must have shape {expected_shape}, got {tuple(sample_uniforms.shape)}"
|
||||
)
|
||||
|
||||
@torch.inference_mode()
|
||||
def forward(
|
||||
self,
|
||||
@@ -1349,14 +1370,7 @@ class CodePredictorWrapper(nn.Module):
|
||||
"""Predict residual codebooks 1..G-1 autoregressively via re-prefill."""
|
||||
bsz = int(layer0_code.shape[0])
|
||||
num_groups = self._num_groups
|
||||
if generators is not None and len(generators) != bsz:
|
||||
raise ValueError(f"generators must have one entry per row: got {len(generators)} for batch {bsz}")
|
||||
if sample_uniforms is not None:
|
||||
expected_shape = (bsz, num_groups - 1, int(self.config.vocab_size))
|
||||
if tuple(sample_uniforms.shape) != expected_shape:
|
||||
raise ValueError(
|
||||
f"sample_uniforms must have shape {expected_shape}, got {tuple(sample_uniforms.shape)}"
|
||||
)
|
||||
self._validate_sampling_inputs(bsz, generators, sample_uniforms)
|
||||
sample_generator: _GeneratorLike = generators if generators is not None else generator
|
||||
device = layer0_code.device
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
# ruff: noqa: N803 - Triton constexpr parameters use kernel-style capitals.
|
||||
"""Fused residual-codebook predictor for Qwen3-TTS (CUDA, BF16).
|
||||
|
||||
Same computation as the frame-local-KV predictor with per-call top-k Gumbel
|
||||
sampling, restructured to issue few kernels per residual step, since at serving
|
||||
batch sizes each of the 15 dependent steps is launch- and latency-bound:
|
||||
|
||||
- Per layer: RMSNorm, one QKV GEMM, one attention kernel (q/k RMSNorm, RoPE,
|
||||
this call's K/V write and attention over the frame's keys), O projection
|
||||
accumulated into the residual stream (``addmm_``), RMSNorm, gate/up GEMM,
|
||||
SiLU-mul, down projection accumulated into the stream.
|
||||
- The embedding of each sampled code followed by ``small_to_mtp_projection``
|
||||
is linear, so it folds at load into one ``[vocab, hidden]`` table per
|
||||
codebook; the sampling kernel writes the next step's input row directly.
|
||||
- Sampling finds the top-k threshold by bisection over the BF16 order keys
|
||||
(exact k-th largest, ties kept) instead of a sort.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
from vllm.triton_utils import tl, tldevice, triton
|
||||
|
||||
from .tokenizer_12hz.streaming_decoder import _silu_mul_kernel
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _rms_rows_kernel(x_ptr, x_stride, w_ptr, out_ptr, eps, N: tl.constexpr):
|
||||
row = tl.program_id(0).to(tl.int64)
|
||||
offs = tl.arange(0, N)
|
||||
x = tl.load(x_ptr + row * x_stride + offs).to(tl.float32)
|
||||
var = tl.sum(x * x, axis=0) / N
|
||||
dt = out_ptr.dtype.element_ty
|
||||
y = (x * tl.rsqrt(var + eps)).to(dt).to(tl.float32)
|
||||
w = tl.load(w_ptr + offs).to(tl.float32)
|
||||
tl.store(out_ptr + row * N + offs, (w * y).to(dt))
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _bf(x):
|
||||
return x.to(tl.bfloat16).to(tl.float32)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _head_norm_rope(x1, x2, w1, w2, c1, c2, s1, s2, eps, HD: tl.constexpr):
|
||||
"""HF RMSNorm (``weight * normed.to(bf16)``) then RoPE in BF16 elementwise steps, on the two halves."""
|
||||
var = (tl.sum(x1 * x1, axis=0) + tl.sum(x2 * x2, axis=0)) / HD
|
||||
r = tl.rsqrt(var + eps)
|
||||
n1 = _bf(w1 * _bf(x1 * r))
|
||||
n2 = _bf(w2 * _bf(x2 * r))
|
||||
# rotate_half(n) = [-n2, n1]
|
||||
o1 = _bf(_bf(n1 * c1) + _bf(-n2 * s1))
|
||||
o2 = _bf(_bf(n2 * c2) + _bf(n1 * s2))
|
||||
return o1, o2
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _cp_attention_kernel(
|
||||
qkv_ptr, # [B * NQ, (H + 2 * KVH) * HD]
|
||||
kc_ptr, # [Bmax, KVH, MAXP, HD]
|
||||
vc_ptr,
|
||||
qw_ptr,
|
||||
kw_ptr,
|
||||
cos_ptr, # [MAXP, HD] bf16
|
||||
sin_ptr,
|
||||
out_ptr, # [B * NQ, H * HD]
|
||||
first,
|
||||
eps,
|
||||
scale,
|
||||
NQ: tl.constexpr,
|
||||
H: tl.constexpr,
|
||||
KVH: tl.constexpr,
|
||||
HD: tl.constexpr,
|
||||
MAXP: tl.constexpr,
|
||||
BK: tl.constexpr,
|
||||
):
|
||||
b = tl.program_id(0)
|
||||
h = tl.program_id(1)
|
||||
qi = tl.program_id(2)
|
||||
kvh = h // (H // KVH)
|
||||
W: tl.constexpr = (H + 2 * KVH) * HD
|
||||
HALF: tl.constexpr = HD // 2
|
||||
d = tl.arange(0, HALF)
|
||||
p = first + qi
|
||||
row = (b * NQ + qi).to(tl.int64)
|
||||
qw1 = tl.load(qw_ptr + d).to(tl.float32)
|
||||
qw2 = tl.load(qw_ptr + HALF + d).to(tl.float32)
|
||||
kw1 = tl.load(kw_ptr + d).to(tl.float32)
|
||||
kw2 = tl.load(kw_ptr + HALF + d).to(tl.float32)
|
||||
qb = qkv_ptr + row * W + h * HD
|
||||
q1, q2 = _head_norm_rope(
|
||||
tl.load(qb + d).to(tl.float32), tl.load(qb + HALF + d).to(tl.float32), qw1, qw2,
|
||||
tl.load(cos_ptr + p * HD + d).to(tl.float32), tl.load(cos_ptr + p * HD + HALF + d).to(tl.float32),
|
||||
tl.load(sin_ptr + p * HD + d).to(tl.float32), tl.load(sin_ptr + p * HD + HALF + d).to(tl.float32),
|
||||
eps, HD,
|
||||
) # fmt: skip
|
||||
j = tl.arange(0, BK)
|
||||
cache = ((b * KVH + kvh) * MAXP).to(tl.int64) * HD
|
||||
old = (j < first)[:, None]
|
||||
kp = kc_ptr + cache + j[:, None] * HD + d[None, :]
|
||||
vp = vc_ptr + cache + j[:, None] * HD + d[None, :]
|
||||
k1 = tl.load(kp, mask=old, other=0.0).to(tl.float32)
|
||||
k2 = tl.load(kp + HALF, mask=old, other=0.0).to(tl.float32)
|
||||
v1 = tl.load(vp, mask=old, other=0.0).to(tl.float32)
|
||||
v2 = tl.load(vp + HALF, mask=old, other=0.0).to(tl.float32)
|
||||
for n in tl.static_range(NQ):
|
||||
# this call's position first + n, computed from its own QKV row
|
||||
on = first + n
|
||||
kb = qkv_ptr + (b * NQ + n).to(tl.int64) * W + (H + kvh) * HD
|
||||
vb = qkv_ptr + (b * NQ + n).to(tl.int64) * W + (H + KVH + kvh) * HD
|
||||
kn1, kn2 = _head_norm_rope(
|
||||
tl.load(kb + d).to(tl.float32), tl.load(kb + HALF + d).to(tl.float32), kw1, kw2,
|
||||
tl.load(cos_ptr + on * HD + d).to(tl.float32), tl.load(cos_ptr + on * HD + HALF + d).to(tl.float32),
|
||||
tl.load(sin_ptr + on * HD + d).to(tl.float32), tl.load(sin_ptr + on * HD + HALF + d).to(tl.float32),
|
||||
eps, HD,
|
||||
) # fmt: skip
|
||||
vn1 = tl.load(vb + d).to(tl.float32)
|
||||
vn2 = tl.load(vb + HALF + d).to(tl.float32)
|
||||
sel = (j == on)[:, None]
|
||||
k1 = tl.where(sel, kn1[None, :], k1)
|
||||
k2 = tl.where(sel, kn2[None, :], k2)
|
||||
v1 = tl.where(sel, vn1[None, :], v1)
|
||||
v2 = tl.where(sel, vn2[None, :], v2)
|
||||
wm = (d < HALF) & (qi == n) & (h % (H // KVH) == 0)
|
||||
dtc = kc_ptr.dtype.element_ty
|
||||
tl.store(kc_ptr + cache + on * HD + d, kn1.to(dtc), mask=wm)
|
||||
tl.store(kc_ptr + cache + on * HD + HALF + d, kn2.to(dtc), mask=wm)
|
||||
tl.store(vc_ptr + cache + on * HD + d, vn1.to(dtc), mask=wm)
|
||||
tl.store(vc_ptr + cache + on * HD + HALF + d, vn2.to(dtc), mask=wm)
|
||||
km = j <= p
|
||||
s = (tl.sum(k1 * q1[None, :], axis=1) + tl.sum(k2 * q2[None, :], axis=1)) * scale
|
||||
s = tl.where(km, s, -float("inf"))
|
||||
e = tl.exp(s - tl.max(s, axis=0))
|
||||
e = tl.where(km, e, 0.0)
|
||||
w = e / tl.sum(e, axis=0)
|
||||
ob = out_ptr + row * (H * HD) + h * HD
|
||||
dto = out_ptr.dtype.element_ty
|
||||
tl.store(ob + d, tl.sum(v1 * w[:, None], axis=0).to(dto))
|
||||
tl.store(ob + HALF + d, tl.sum(v2 * w[:, None], axis=0).to(dto))
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _cp_sample_kernel(
|
||||
logits_ptr, # [B, V] bf16
|
||||
u_ptr, # uniforms, row stride u_stride, contiguous over V
|
||||
u_stride,
|
||||
codes_ptr, # [B, G] int64
|
||||
step,
|
||||
G: tl.constexpr,
|
||||
table_ptr, # [V, HID] next-step input table, or dummy
|
||||
next_ptr, # [B, HID]
|
||||
inv_temperature,
|
||||
V: tl.constexpr,
|
||||
TOPK: tl.constexpr,
|
||||
HID: tl.constexpr,
|
||||
HAS_NEXT: tl.constexpr,
|
||||
):
|
||||
row = tl.program_id(0).to(tl.int64)
|
||||
idx = tl.arange(0, V)
|
||||
value = tl.load(logits_ptr + row * V + idx)
|
||||
scaled = (value.to(tl.float32) * inv_temperature).to(value.dtype)
|
||||
if TOPK > 0:
|
||||
# order-preserving 16-bit keys of the BF16 values
|
||||
bits = scaled.to(tl.int16, bitcast=True).to(tl.int32) & 0xFFFF
|
||||
# Top-k keeps all numeric ties at the cutoff, including both zeros.
|
||||
bits = tl.where((bits & 0x7FFF) == 0, 0, bits)
|
||||
key = tl.where(bits >= 0x8000, 0xFFFF - bits, bits | 0x8000)
|
||||
the = 0
|
||||
for bit in tl.static_range(15, -1, -1):
|
||||
cand = the | (1 << bit)
|
||||
cnt = tl.sum((key >= cand).to(tl.int32), axis=0)
|
||||
the = tl.where(cnt >= TOPK, cand, the)
|
||||
sf = tl.where(key >= the, scaled.to(tl.float32), -float("inf"))
|
||||
else:
|
||||
sf = scaled.to(tl.float32)
|
||||
u = tl.load(u_ptr + row * u_stride + idx)
|
||||
score = sf - tldevice.log(-tldevice.log(u))
|
||||
max_score = tl.max(score, 0)
|
||||
chosen = tl.min(tl.where(score == max_score, idx, V), 0)
|
||||
first_nan = tl.min(tl.where(score != score, idx, V), 0)
|
||||
code = tl.where(first_nan < V, first_nan, chosen)
|
||||
tl.store(codes_ptr + row * G + step, code.to(tl.int64))
|
||||
if HAS_NEXT:
|
||||
c = tl.arange(0, HID)
|
||||
tl.store(next_ptr + row * HID + c, tl.load(table_ptr + code.to(tl.int64) * HID + c))
|
||||
|
||||
|
||||
class FusedCodePredictor:
|
||||
"""Execution object built from a loaded ``Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM``."""
|
||||
|
||||
def __init__(self, predictor, max_batch: int) -> None:
|
||||
model = predictor.model
|
||||
weight = next(model.parameters())
|
||||
dev, dt = weight.device, weight.dtype
|
||||
if dt != torch.bfloat16:
|
||||
raise ValueError("fused code predictor requires BF16 weights")
|
||||
cfg = predictor.config
|
||||
self.groups = int(cfg.num_code_groups)
|
||||
self.vocab = int(cfg.vocab_size)
|
||||
self.hidden = int(cfg.hidden_size)
|
||||
attn0 = model.layers[0].self_attn
|
||||
self.heads, self.kv_heads, self.head_dim = attn0.num_heads, attn0.num_kv_heads, attn0.head_dim
|
||||
self.scale = attn0.scaling
|
||||
self.eps = float(model.norm.variance_epsilon)
|
||||
self.max_pos = attn0.max_seq
|
||||
self.max_batch = max_batch
|
||||
f32 = lambda t: t.detach().to(device=dev, dtype=torch.float32) # noqa: E731
|
||||
c = lambda t: t.to(dt).contiguous() # noqa: E731
|
||||
proj = predictor.small_to_mtp_projection
|
||||
if isinstance(proj, torch.nn.Identity):
|
||||
pw, pb = None, None
|
||||
else:
|
||||
pw, pb = f32(proj.weight), f32(proj.bias)
|
||||
self.proj_wt = c(pw.t()) if pw is not None else None
|
||||
self.proj_b = c(pb) if pb is not None else None
|
||||
tables = []
|
||||
for emb in model.codec_embedding:
|
||||
e = f32(emb.weight)
|
||||
tables.append(c(e @ pw.t() + pb) if pw is not None else c(e))
|
||||
self.tables = tables
|
||||
self.layers = []
|
||||
for layer in model.layers:
|
||||
a = layer.self_attn
|
||||
self.layers.append(
|
||||
dict(
|
||||
ln1=c(f32(layer.input_layernorm.weight)),
|
||||
ln2=c(f32(layer.post_attention_layernorm.weight)),
|
||||
eps1=float(layer.input_layernorm.variance_epsilon),
|
||||
eps2=float(layer.post_attention_layernorm.variance_epsilon),
|
||||
qkv=c(f32(a.qkv_proj.weight).t()),
|
||||
qn=c(f32(a.q_norm.weight)),
|
||||
kn=c(f32(a.k_norm.weight)),
|
||||
head_eps=float(a.q_norm.variance_epsilon),
|
||||
o=c(f32(a.o_proj.weight).t()),
|
||||
gu=c(f32(layer.mlp.gate_up_proj.weight).t()),
|
||||
down=c(f32(layer.mlp.down_proj.weight).t()),
|
||||
)
|
||||
)
|
||||
if a.qkv_proj.bias is not None:
|
||||
raise ValueError("fused code predictor assumes bias-free attention")
|
||||
self.norm_w = c(f32(model.norm.weight))
|
||||
self.lm_heads = [c(f32(h.weight).t()) for h in predictor.lm_head]
|
||||
rot = model.rotary_emb
|
||||
self.cos = rot.cos_cached.to(device=dev, dtype=dt).contiguous()
|
||||
self.sin = rot.sin_cached.to(device=dev, dtype=dt).contiguous()
|
||||
kv = (len(self.layers), 2, max_batch, self.kv_heads, self.max_pos, self.head_dim)
|
||||
self.cache = torch.zeros(kv, device=dev, dtype=dt)
|
||||
self._dummy = torch.zeros(1, device=dev, dtype=dt)
|
||||
|
||||
def _layer(self, x: torch.Tensor, li: int, B: int, nq: int, first: int) -> None:
|
||||
L = self.layers[li]
|
||||
rows = x.shape[0]
|
||||
h = torch.empty_like(x)
|
||||
_rms_rows_kernel[(rows,)](x, x.stride(0), L["ln1"], h, L["eps1"], N=self.hidden)
|
||||
qkv = torch.mm(h, L["qkv"])
|
||||
att = torch.empty(rows, self.heads * self.head_dim, device=x.device, dtype=x.dtype)
|
||||
_cp_attention_kernel[(B, self.heads, nq)](
|
||||
qkv, self.cache[li, 0], self.cache[li, 1], L["qn"], L["kn"], self.cos, self.sin, att,
|
||||
first, L["head_eps"], self.scale,
|
||||
NQ=nq, H=self.heads, KVH=self.kv_heads, HD=self.head_dim, MAXP=self.max_pos,
|
||||
BK=triton.next_power_of_2(self.max_pos), num_warps=1,
|
||||
) # fmt: skip
|
||||
x.addmm_(att, L["o"])
|
||||
_rms_rows_kernel[(rows,)](x, x.stride(0), L["ln2"], h, L["eps2"], N=self.hidden)
|
||||
gu = torch.mm(h, L["gu"])
|
||||
inter = gu.shape[1] // 2
|
||||
a = torch.empty(rows, inter, device=x.device, dtype=x.dtype)
|
||||
_silu_mul_kernel[(rows, triton.cdiv(inter, 1024))](gu, a, N=inter, BN=1024)
|
||||
x.addmm_(a, L["down"])
|
||||
|
||||
@torch.inference_mode()
|
||||
def __call__(
|
||||
self,
|
||||
layer0_code: torch.Tensor,
|
||||
layer0_embed: torch.Tensor,
|
||||
last_talker_hidden: torch.Tensor,
|
||||
inv_temperature: float,
|
||||
top_k: int,
|
||||
sample_uniforms: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
B = int(layer0_code.shape[0])
|
||||
if B > self.max_batch:
|
||||
raise ValueError("fused code predictor batch exceeds its capacity")
|
||||
dev, dt = layer0_embed.device, self.norm_w.dtype
|
||||
inp = torch.stack([last_talker_hidden.reshape(B, -1), layer0_embed.reshape(B, -1)], 1).to(dt)
|
||||
inp = inp.reshape(2 * B, -1)
|
||||
x = torch.addmm(self.proj_b, inp, self.proj_wt) if self.proj_wt is not None else inp.clone()
|
||||
codes = torch.empty(B, self.groups, device=dev, dtype=torch.int64)
|
||||
codes[:, 0] = layer0_code.reshape(B)
|
||||
u = sample_uniforms
|
||||
nq, first = 2, 0
|
||||
for step in range(1, self.groups):
|
||||
for li in range(len(self.layers)):
|
||||
self._layer(x, li, B, nq, first)
|
||||
last = x[nq - 1 :: nq] if nq > 1 else x
|
||||
hf = torch.empty(B, self.hidden, device=dev, dtype=dt)
|
||||
_rms_rows_kernel[(B,)](last, last.stride(0), self.norm_w, hf, self.eps, N=self.hidden)
|
||||
logits = torch.mm(hf, self.lm_heads[step - 1])
|
||||
has_next = step < self.groups - 1
|
||||
nxt = torch.empty(B, self.hidden, device=dev, dtype=dt) if has_next else self._dummy
|
||||
us = u[:, step - 1]
|
||||
_cp_sample_kernel[(B,)](
|
||||
logits, us, us.stride(0), codes, step, self.groups,
|
||||
self.tables[step - 1] if has_next else self._dummy, nxt, inv_temperature,
|
||||
V=self.vocab, TOPK=top_k, HID=self.hidden, HAS_NEXT=has_next, num_warps=4,
|
||||
) # fmt: skip
|
||||
x = nxt
|
||||
first = step + 1
|
||||
nq = 1
|
||||
return codes
|
||||
@@ -72,3 +72,33 @@ QWEN3_TTS_PIPELINE = PipelineConfig(
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# 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.
|
||||
QWEN3_TTS_FUSED_PIPELINE = PipelineConfig(
|
||||
model_type="qwen3_tts_fused",
|
||||
default_deploy_config_name="qwen3_tts_fused_single_gpu.yaml",
|
||||
single_stage_async_chunk=True,
|
||||
model_arch="Qwen3TTSTalkerForConditionalGeneration",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="qwen3_tts",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
input_sources=(),
|
||||
owns_tokenizer=True,
|
||||
final_output=True,
|
||||
final_output_type="audio",
|
||||
# The API output processor tags ``model_outputs`` by this modality.
|
||||
engine_output_type="audio",
|
||||
supports_running_prefix_cache_reset=False,
|
||||
supports_native_mrv2_data_plane=True,
|
||||
sampling_constraints={
|
||||
"detokenize": False,
|
||||
"stop_token_ids": [2150],
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -42,6 +42,7 @@ from vllm.logger import init_logger
|
||||
from vllm.multimodal.audio import AudioResampler
|
||||
|
||||
from vllm_omni.utils.audio import mel_filter_bank
|
||||
from vllm_omni.utils.device_copy import index_to_device, to_device_nonblocking
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .configuration_qwen3_tts import Qwen3TTSConfig, Qwen3TTSTalkerConfig
|
||||
@@ -160,7 +161,7 @@ def coerce_token_ids(value: object, *, device: torch.device) -> torch.Tensor | N
|
||||
ids = ids.unsqueeze(0)
|
||||
if ids.ndim != 2 or ids.numel() == 0:
|
||||
return None
|
||||
return ids.to(device=device, dtype=torch.long).contiguous()
|
||||
return to_device_nonblocking(ids.to(dtype=torch.long).contiguous(), device)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -366,6 +367,9 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
# Reusing them avoids launching the embedding/projection pair once per
|
||||
# request during prompt construction.
|
||||
self._projected_token_cache: dict[tuple[str, tuple[int, ...]], torch.Tensor] = {}
|
||||
# CustomVoice prompt pieces that do not depend on the text, per
|
||||
# (device, speaker, language): bounded by the model's voices.
|
||||
self._custom_voice_prompt_cache: dict[tuple, tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = {}
|
||||
|
||||
self._ref_audio_artifact_cache_max_entries = int(ref_audio_artifact_cache_max_entries)
|
||||
self._ref_audio_artifact_cache: OrderedDict[str, dict[str, torch.Tensor | bool]] = OrderedDict()
|
||||
@@ -1024,7 +1028,9 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
dev = self._device()
|
||||
input_ids = coerce_token_ids(info_dict.pop(PRECOMPUTED_TEXT_IDS_KEY, None), device=dev)
|
||||
if input_ids is None:
|
||||
input_ids = tok(build_assistant_text(text), return_tensors="pt", padding=False)["input_ids"].to(device=dev)
|
||||
input_ids = to_device_nonblocking(
|
||||
tok(build_assistant_text(text), return_tensors="pt", padding=False)["input_ids"], dev
|
||||
)
|
||||
|
||||
# Optional instruct prefix.
|
||||
instruct = (info_dict.get("instruct") or [""])[0]
|
||||
@@ -1411,35 +1417,44 @@ class Qwen3TTSPromptEmbedsBuilder:
|
||||
raise ValueError(f"Unsupported speaker: {speaker}")
|
||||
spk_id = spk_id_map[speaker]
|
||||
# Keep it at least 1D; embedding on a 0-d tensor can return 1D.
|
||||
spk_tensor = torch.tensor([spk_id], device=input_ids.device, dtype=torch.long)
|
||||
spk_tensor = index_to_device([spk_id], input_ids.device)
|
||||
spk_embed = codec_embed(spk_tensor)
|
||||
if spk_embed.ndim in (1, 2):
|
||||
spk_embed = spk_embed.view(1, 1, -1)
|
||||
speaker_embed = spk_embed
|
||||
codec_input = torch.cat([codec_input_0, speaker_embed, codec_input_1], dim=1)
|
||||
|
||||
role_embed = text_projection(text_embedding(input_ids[:, :3]))
|
||||
codec_prefix = torch.cat((tts_pad_embed.expand(-1, codec_input.shape[1] - 2, -1), tts_bos_embed), dim=1)
|
||||
codec_prefix = codec_prefix + codec_input[:, :-1]
|
||||
talker_prompt = torch.cat((role_embed, codec_prefix), dim=1)
|
||||
# The role tokens, speaker/language codec prefix, codec pad row and
|
||||
# closing row do not depend on the text: cache them per speaker and
|
||||
# language so a request only embeds and projects its own text.
|
||||
# A text starting with whitespace could merge with the template's
|
||||
# newline token, so it keeps the per-request role embedding.
|
||||
cache_key = None
|
||||
if non_streaming_mode and instruct_embed is None and not text[:1].isspace():
|
||||
cache_key = (
|
||||
str(input_ids.device),
|
||||
int(spk_id),
|
||||
codec_prefill_list[0][2] if len(codec_prefill_list[0]) == 4 else None,
|
||||
)
|
||||
cv_cache = self._custom_voice_prompt_cache
|
||||
cached = cv_cache.get(cache_key) if cache_key is not None else None
|
||||
if cached is None:
|
||||
role_embed = text_projection(text_embedding(input_ids[:, :3]))
|
||||
codec_prefix = torch.cat((tts_pad_embed.expand(-1, codec_input.shape[1] - 2, -1), tts_bos_embed), dim=1)
|
||||
codec_prefix = codec_prefix + codec_input[:, :-1]
|
||||
prefix = torch.cat((role_embed, codec_prefix), dim=1)
|
||||
pad_row = codec_embed(self._long_tensor([talker_config.codec_pad_id], input_ids.device)).view(1, 1, -1)
|
||||
tail = tts_pad_embed + codec_embed(self._long_tensor([talker_config.codec_bos_id], input_ids.device))
|
||||
if cache_key is not None:
|
||||
cv_cache[cache_key] = (prefix, pad_row, tail)
|
||||
else:
|
||||
prefix, pad_row, tail = cached
|
||||
talker_prompt = prefix
|
||||
|
||||
if non_streaming_mode:
|
||||
text_all = text_projection(text_embedding(input_ids[:, 3:-5]))
|
||||
text_all = torch.cat([text_all, tts_eos_embed], dim=1)
|
||||
pad_ids = torch.full(
|
||||
(1, int(text_all.shape[1])),
|
||||
int(talker_config.codec_pad_id),
|
||||
device=input_ids.device,
|
||||
dtype=torch.long,
|
||||
)
|
||||
talker_prompt = torch.cat(
|
||||
[
|
||||
talker_prompt,
|
||||
text_all + codec_embed(pad_ids),
|
||||
tts_pad_embed + codec_embed(self._long_tensor([talker_config.codec_bos_id], input_ids.device)),
|
||||
],
|
||||
dim=1,
|
||||
)
|
||||
talker_prompt = torch.cat([talker_prompt, text_all + pad_row, tail], dim=1)
|
||||
trailing_text_hidden = tts_pad_embed
|
||||
else:
|
||||
first_text = text_projection(text_embedding(input_ids[:, 3:4])) + codec_input[:, -1:]
|
||||
|
||||
@@ -65,6 +65,8 @@ class Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(CodePredictorWrapp
|
||||
self._kv_requested = self._parse_bool_config(extra.get("code_predictor_kv_cache"))
|
||||
self._fused_sampling = self._parse_bool_config(extra.get("code_predictor_fused_sampling"))
|
||||
self._frame_cache: FrameLocalKVCache | None = None
|
||||
self._fused_requested = self._parse_bool_config(extra.get("code_predictor_fused"))
|
||||
self._fused = None
|
||||
|
||||
def _setup_compile(self) -> None:
|
||||
if self._compiled_model_fwd is not None:
|
||||
@@ -100,11 +102,73 @@ class Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(CodePredictorWrapp
|
||||
cache(self._proj_buf, batch, step)
|
||||
self._synchronize_warmup(weight.device)
|
||||
self._frame_cache = cache
|
||||
if self._fused_requested:
|
||||
from .fused_code_predictor import FusedCodePredictor
|
||||
|
||||
self._fused = FusedCodePredictor(self, max_batch)
|
||||
# Compile every kernel variant outside any graph capture.
|
||||
hidden = int(self.talker_config.hidden_size)
|
||||
for batch in range(1, min(2, max_batch) + 1):
|
||||
zeros = torch.zeros(batch, 1, hidden, device=weight.device, dtype=weight.dtype)
|
||||
uniforms = torch.full(
|
||||
(batch, self._num_groups - 1, int(self.config.vocab_size)), 0.5, device=weight.device
|
||||
)
|
||||
codes = torch.zeros(batch, 1, device=weight.device, dtype=torch.long)
|
||||
self._fused(codes, zeros, zeros, 1.0, 50, uniforms)
|
||||
self._synchronize_warmup(weight.device)
|
||||
logger.info("Qwen3-TTS fused code predictor enabled (capacity %d)", max_batch)
|
||||
# Preserve the shared wrapper's initialization sentinel. Its full
|
||||
# re-prefill callable is unused while _frame_cache is active.
|
||||
self._compiled_model_fwd = self.model.forward
|
||||
logger.info("Qwen3-TTS frame-local KV warmed for batches %s", self._bucket_sizes)
|
||||
|
||||
@torch.inference_mode()
|
||||
def forward(
|
||||
self,
|
||||
layer0_code: torch.Tensor,
|
||||
layer0_embed: torch.Tensor,
|
||||
last_talker_hidden: torch.Tensor,
|
||||
do_sample: bool = True,
|
||||
temperature: float = 0.9,
|
||||
top_k: int = 50,
|
||||
top_p: float = 1.0,
|
||||
generator: torch.Generator | None = None,
|
||||
generators: Sequence[torch.Generator | None] | None = None,
|
||||
sample_uniforms: torch.Tensor | None = None,
|
||||
):
|
||||
self._validate_sampling_inputs(int(layer0_code.shape[0]), generators, sample_uniforms)
|
||||
if self._fused_requested:
|
||||
self._setup_compile()
|
||||
if (
|
||||
self._fused is not None
|
||||
and do_sample
|
||||
and temperature > 0
|
||||
and top_p == 1.0
|
||||
and sample_uniforms is not None
|
||||
and sample_uniforms.dtype == torch.float32
|
||||
and sample_uniforms.is_contiguous()
|
||||
):
|
||||
return self._fused(
|
||||
layer0_code,
|
||||
layer0_embed,
|
||||
last_talker_hidden,
|
||||
1.0 / temperature,
|
||||
int(top_k),
|
||||
sample_uniforms,
|
||||
)
|
||||
return super().forward(
|
||||
layer0_code,
|
||||
layer0_embed,
|
||||
last_talker_hidden,
|
||||
do_sample=do_sample,
|
||||
temperature=temperature,
|
||||
top_k=top_k,
|
||||
top_p=top_p,
|
||||
generator=generator,
|
||||
generators=generators,
|
||||
sample_uniforms=sample_uniforms,
|
||||
)
|
||||
|
||||
def _predict_step_logits(
|
||||
self,
|
||||
proj_buf: torch.Tensor,
|
||||
|
||||
@@ -27,6 +27,7 @@ from vllm.sequence import IntermediateTensors
|
||||
|
||||
from vllm_omni.data_entry_keys import OmniPayload
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput, OwnedBatchTensor
|
||||
from vllm_omni.utils.device_copy import index_to_device, to_device_nonblocking
|
||||
from vllm_omni.utils.speaker_cache import (
|
||||
get_speaker_cache,
|
||||
iter_custom_voice_profiles,
|
||||
@@ -39,6 +40,7 @@ from .configuration_qwen3_tts import Qwen3TTSConfig, Qwen3TTSSpeakerEncoderConfi
|
||||
from .first_audio import talker_first_audio_enabled
|
||||
from .prompt_embeds_builder import PRECOMPUTED_TEXT_IDS_KEY, Qwen3TTSPromptEmbedsBuilder, resolve_x_vector_only
|
||||
from .qwen3_tts_code_predictor_vllm import Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM
|
||||
from .stream_decode import stream_ref_context_frames, talker_stream_decode_enabled
|
||||
from .tokenizer_12hz.configuration_qwen3_tts_tokenizer_v2 import Qwen3TTSTokenizerV2Config
|
||||
from .tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import Qwen3TTSTokenizerV2Encoder
|
||||
|
||||
@@ -411,7 +413,21 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
# A frame's residual codebooks depend only on the Talker hidden and
|
||||
# CB0 of the step that sampled it, so MRV2 may complete the frame at
|
||||
# the end of that step instead of in the next step's preprocess.
|
||||
self.mtp_eager_frames = talker_first_audio_enabled(vllm_config)
|
||||
self.stream_decode = talker_stream_decode_enabled(vllm_config)
|
||||
predictor = Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM
|
||||
extra = predictor._stage_connector_extra_config(vllm_config)
|
||||
self.stream_first_audio = self.stream_decode and predictor._parse_bool_config(
|
||||
extra.get("talker_stream_first_audio")
|
||||
)
|
||||
# Model-local voice-clone priming boundary; each deployment selects
|
||||
# its reference context independently of the two-stage codec profile.
|
||||
self.stream_ref_context_frames = stream_ref_context_frames(vllm_config) if self.stream_decode else 0
|
||||
self.mtp_eager_frames = talker_first_audio_enabled(vllm_config) or self.stream_decode
|
||||
self.stream_decoder = None
|
||||
self.stream_graphs = None
|
||||
self.stream_prime_graphs = None
|
||||
self.stream_sample_rate = 0
|
||||
self.stream_chunk_frames = 25
|
||||
# The runners bypass only the outer whole-MTP graph when explicit
|
||||
# generators are present, so seeded requests can still share one raw
|
||||
# batched MTP call with independent per-row streams.
|
||||
@@ -718,6 +734,11 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
|
||||
# -------------------- Omni multimodal output plumbing --------------------
|
||||
|
||||
@property
|
||||
def mm_outputs_fresh_per_step(self) -> bool:
|
||||
"""Stream-decode outputs come from :meth:`_make_eager_omni_output`, allocated per step."""
|
||||
return self.stream_decoder is not None and self.eager_frames_active
|
||||
|
||||
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
|
||||
if isinstance(model_outputs, OmniOutput):
|
||||
return model_outputs
|
||||
@@ -895,6 +916,13 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
"first_audio": torch.zeros((num_tokens,), dtype=torch.int8, device=hidden.device),
|
||||
},
|
||||
}
|
||||
if self.stream_decoder is not None:
|
||||
# Filled per sampled row by the runner's eager MTP; token-major so
|
||||
# each request's slice is its frames of this step.
|
||||
spf = int(self.stream_decoder.spf)
|
||||
mm["model_outputs"] = torch.zeros((num_tokens, spf), dtype=torch.float32, device=hidden.device)
|
||||
sr = torch.tensor(self.stream_sample_rate, dtype=torch.int32)
|
||||
mm["sr"] = [sr] * len(info_dicts)
|
||||
ref_rows = [
|
||||
index
|
||||
for index, info in enumerate(info_dicts)
|
||||
@@ -1442,8 +1470,72 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
|
||||
self.first_frame_decoder = Qwen3TTSFirstFrameDecoder(self.model_path)
|
||||
decoder_loaded = self.first_frame_decoder.load(self.vllm_config)
|
||||
loaded = set(loaded) | {f"first_frame_decoder.{name}" for name in decoder_loaded}
|
||||
elif self.stream_decode:
|
||||
# The in-stage decoder owns every frame. Optional first-audio
|
||||
# delivery uses its PCM; later chunks use regular step outputs.
|
||||
from .first_frame_decoder import Qwen3TTSFirstFrameDecoder
|
||||
from .tokenizer_12hz.streaming_decoder import StreamingCodecDecoder
|
||||
|
||||
holder = Qwen3TTSFirstFrameDecoder(self.model_path)
|
||||
decoder_loaded = holder.load(self.vllm_config)
|
||||
loaded = set(loaded) | {f"stream_decoder.{name}" for name in decoder_loaded}
|
||||
slots = int(self.vllm_config.scheduler_config.max_num_seqs)
|
||||
self.stream_decoder = StreamingCodecDecoder(holder.decoder, num_slots=slots, dtype=torch.bfloat16)
|
||||
self.stream_sample_rate = int(holder.sample_rate)
|
||||
logger.info("Qwen3-TTS Talker stream decode enabled (%d slots)", slots)
|
||||
return loaded
|
||||
|
||||
def capture_stream_decode_graphs(self, batch_sizes: list[int]) -> None:
|
||||
if self.stream_decoder is None or self.stream_graphs is not None:
|
||||
return
|
||||
from .tokenizer_12hz.streaming_decoder import StreamingDecodeGraphs
|
||||
|
||||
with torch.inference_mode():
|
||||
self.stream_graphs = StreamingDecodeGraphs(self.stream_decoder, batch_sizes)
|
||||
frames = min(self.stream_ref_context_frames, self.stream_chunk_frames, self.stream_decoder.max_frames)
|
||||
if self.config.tts_model_type == "base" and frames > 1:
|
||||
# Bound the multi-frame graph workspace by the configured
|
||||
# token budget; larger priming groups keep the eager path.
|
||||
budget = max(1, self.vllm_config.scheduler_config.max_num_batched_tokens // frames)
|
||||
prime_sizes = sorted({1, *(size for size in batch_sizes if size <= budget)})
|
||||
self.stream_prime_graphs = StreamingDecodeGraphs(self.stream_decoder, prime_sizes, frames=frames)
|
||||
logger.info("Captured Talker stream decode graphs for batch sizes %s", self.stream_graphs.sizes)
|
||||
if self.stream_prime_graphs is not None:
|
||||
logger.info(
|
||||
"Captured Talker reference priming graphs for %d frames and batch sizes %s",
|
||||
self.stream_prime_graphs.frames,
|
||||
self.stream_prime_graphs.sizes,
|
||||
)
|
||||
|
||||
def get_stream_ref_context(self, info: OmniPayload) -> torch.Tensor | None:
|
||||
"""Model-owned reference-code layout and context boundary for a PCM slot."""
|
||||
codes = info.get("codes")
|
||||
ref = codes.get("ref") if isinstance(codes, dict) else None
|
||||
if not isinstance(ref, torch.Tensor) or not ref.numel():
|
||||
return None
|
||||
return ref.reshape(-1, int(self.talker_config.num_code_groups))[-self.stream_ref_context_frames :]
|
||||
|
||||
def prime_stream_decoder(self, primes: list[tuple[int, torch.Tensor]]) -> None:
|
||||
"""Prime equal-length reference groups without padding or aliasing live slots."""
|
||||
if not primes:
|
||||
return
|
||||
stream = self.stream_decoder
|
||||
if stream is None:
|
||||
raise RuntimeError("Reference priming requires the single-stage PCM decoder")
|
||||
groups: dict[int, list[tuple[int, torch.Tensor]]] = {}
|
||||
for req_idx, ref in primes:
|
||||
groups.setdefault(int(ref.shape[0]), []).append((req_idx, ref))
|
||||
for items in groups.values():
|
||||
codes = to_device_nonblocking(torch.stack([ref for _idx, ref in items]), stream.device).to(torch.int32)
|
||||
n = len(items)
|
||||
slots = index_to_device([idx for idx, _ref in items], stream.device, dtype=torch.int32)
|
||||
for t0 in range(0, int(codes.shape[1]), self.stream_chunk_frames):
|
||||
pos = index_to_device([t0] * n, stream.device, dtype=torch.int32)
|
||||
chunk = codes[:, t0 : t0 + self.stream_chunk_frames].contiguous()
|
||||
graphs = self.stream_prime_graphs
|
||||
decode = graphs if graphs is not None and chunk.shape[1] == graphs.frames else stream
|
||||
decode(chunk, slots, pos)
|
||||
|
||||
def _build_stacked_codec_embed(self) -> None:
|
||||
embeds = self.code_predictor.get_input_embeddings()
|
||||
if not embeds:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Eligibility and model options for the single-stage CUDA PCM pipeline."""
|
||||
|
||||
import torch
|
||||
from vllm.config import VllmConfig
|
||||
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
|
||||
def talker_stream_decode_enabled(vllm_config: VllmConfig) -> bool:
|
||||
"""The Talker decodes every frame itself and emits PCM as its final output."""
|
||||
from .qwen3_tts_code_predictor_vllm import Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM as Predictor
|
||||
|
||||
extra = Predictor._stage_connector_extra_config(vllm_config)
|
||||
model = vllm_config.model_config
|
||||
parallel = vllm_config.parallel_config
|
||||
requested = Predictor._parse_bool_config(extra.get("talker_stream_decode"))
|
||||
if not requested:
|
||||
if getattr(model, "engine_output_type", None) == "audio":
|
||||
raise ValueError("The single-stage Qwen3-TTS pipeline requires talker_stream_decode=True")
|
||||
if Predictor._parse_bool_config(extra.get("talker_stream_first_audio")):
|
||||
raise ValueError("talker_stream_first_audio requires the single-stage talker_stream_decode path")
|
||||
return False
|
||||
if getattr(model, "engine_output_type", None) != "audio" or not getattr(model, "final_output", False):
|
||||
raise ValueError(
|
||||
"talker_stream_decode requires a final audio-output Talker; "
|
||||
"select qwen3_tts_fused_single_gpu.yaml instead of enabling it on the two-stage pipeline"
|
||||
)
|
||||
if getattr(model, "supports_running_prefix_cache_reset", True):
|
||||
raise ValueError(
|
||||
"The single-stage PCM pipeline must disable running prefix-cache reset; "
|
||||
"select qwen3_tts_fused_single_gpu.yaml"
|
||||
)
|
||||
if not (
|
||||
current_omni_platform.is_cuda()
|
||||
and torch.device(vllm_config.device_config.device).type == "cuda"
|
||||
and bool(getattr(model, "use_v2_model_runner", False))
|
||||
and bool(getattr(model, "async_chunk", False))
|
||||
and parallel.tensor_parallel_size == 1
|
||||
and parallel.pipeline_parallel_size == 1
|
||||
and parallel.distributed_executor_backend in (None, "uni")
|
||||
and not vllm_config.cache_config.enable_prefix_caching
|
||||
):
|
||||
raise ValueError(
|
||||
"talker_stream_decode requires CUDA, MRv2, async_chunk=True, TP=PP=1, "
|
||||
"an in-process worker and enable_prefix_caching=False; "
|
||||
"use qwen3_tts_fused_single_gpu.yaml"
|
||||
)
|
||||
if Predictor._parse_bool_config(extra.get("talker_first_audio")):
|
||||
raise ValueError("talker_stream_decode and talker_first_audio are mutually exclusive")
|
||||
return True
|
||||
|
||||
|
||||
def stream_ref_context_frames(vllm_config: VllmConfig) -> int:
|
||||
"""Reference-code context frames for voice-clone priming in the Talker."""
|
||||
from .qwen3_tts_code_predictor_vllm import Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM as Predictor
|
||||
|
||||
extra = Predictor._stage_connector_extra_config(vllm_config)
|
||||
return int(extra.get("ref_code_context_frames") or extra.get("codec_left_context_frames", 25))
|
||||
@@ -0,0 +1,799 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
# ruff: noqa: N803 - Triton constexpr parameters use kernel-style capitals.
|
||||
"""Stateful, fused streaming decoder for the Qwen3-TTS 12 Hz codec.
|
||||
|
||||
Every layer of the codec decoder is causal, so what a new frame needs from the
|
||||
past is small and fixed: each causal conv's last ``(k - 1) * dilation`` input
|
||||
rows, each overlapping transposed conv's last GEMM row, and each attention
|
||||
layer's last ``sliding_window`` keys and values. A request owns a slot of
|
||||
per-layer state holding exactly that, and a call decodes ``T`` new frames for
|
||||
each of ``B`` requests, reading and advancing the state. The output equals the
|
||||
whole-utterance decode (``Qwen3TTSTokenizerV2Decoder._forward_exact``) frame by
|
||||
frame, up to floating point rounding: nothing is recomputed and nothing is
|
||||
truncated.
|
||||
|
||||
Layout and fusion:
|
||||
|
||||
- Activations are time-major ``[rows, C]``; every dense conv is a tap-major
|
||||
im2col (one Triton kernel that also applies the pending bias and SnakeBeta,
|
||||
and reads/writes the history) followed by one cuBLAS GEMM.
|
||||
- Linear chains fold at load: the RVQ output projections, ``pre_conv`` and the
|
||||
transformer's ``input_proj`` become one ``k = 3`` conv over the concatenated
|
||||
codebook embeddings; LayerScale folds into ``o_proj``/``down_proj``; ConvNeXt
|
||||
``gamma`` folds into ``pwconv2``.
|
||||
- A bias that feeds a residual stream is carried to that stream's consumer
|
||||
(the next im2col or transposed-conv GEMM) instead of being added in place.
|
||||
- Residual branches accumulate into their stream with in-place ``addmm_``.
|
||||
|
||||
History buffers are double-buffered per slot: a call reads half ``parity[slot]``
|
||||
and writes the other half, then flips the parity, so no kernel overwrites rows
|
||||
another program of the same launch still reads. A row whose first frame index
|
||||
``pos`` is 0 reads zeros, which are the convs' causal padding, and attention
|
||||
only looks back ``min(pos + 1, window)`` positions, so a new request needs no
|
||||
state reset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from vllm.triton_utils import tl, triton
|
||||
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
_KERNEL = 7
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- kernels
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _rvq_gather_kernel(codes_ptr, books_ptr, out_ptr, NQ: tl.constexpr, CB: tl.constexpr, HALF: tl.constexpr):
|
||||
row = tl.program_id(0)
|
||||
offs = tl.arange(0, HALF)
|
||||
c0 = tl.load(codes_ptr + row * NQ).to(tl.int64)
|
||||
c0 = tl.where((c0 >= 0) & (c0 < CB), c0, 0) # EOS/special rows decode as code 0; callers drop them
|
||||
first = tl.load(books_ptr + c0 * HALF + offs).to(tl.float32)
|
||||
acc = tl.zeros([HALF], dtype=tl.float32)
|
||||
for q in range(1, NQ):
|
||||
c = tl.load(codes_ptr + row * NQ + q).to(tl.int64)
|
||||
c = tl.where((c >= 0) & (c < CB), c, 0)
|
||||
acc += tl.load(books_ptr + (q * CB + c) * HALF + offs).to(tl.float32)
|
||||
dt = out_ptr.dtype.element_ty
|
||||
tl.store(out_ptr + row * 2 * HALF + offs, first.to(dt))
|
||||
tl.store(out_ptr + row * 2 * HALF + HALF + offs, acc.to(dt))
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _fast_sin(y):
|
||||
# Two-step reduction to [-pi, pi], then the hardware approximation
|
||||
# (absolute error ~4e-7 there): accurate sin's instruction count, not
|
||||
# memory, bounds every kernel that applies SnakeBeta.
|
||||
k = tl.extra.cuda.libdevice.rint(y * 0.15915494309189535)
|
||||
r = tl.fma(-k, -1.7484555314695172e-07, tl.fma(-k, 6.2831854820251465, y))
|
||||
return tl.inline_asm_elementwise("sin.approx.f32 $0, $1;", "=r,r", [r], dtype=tl.float32, is_pure=True, pack=1)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _snake(v, a, ib):
|
||||
s = _fast_sin(v * a)
|
||||
return v + ib * s * s
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _im2col_kernel(
|
||||
x_ptr, # [B * T, C] layer input rows
|
||||
bias_ptr, # [C] pending bias (added to x rows only) or dummy
|
||||
alpha_ptr, # [C] exp(alpha) or dummy
|
||||
ibeta_ptr, # [C] 1 / (exp(beta) + eps) or dummy
|
||||
hist_ptr, # [2, S, H, C] transformed history rows
|
||||
slots_ptr,
|
||||
pos_ptr,
|
||||
par_ptr,
|
||||
col_ptr, # [B * T, K * C]
|
||||
T,
|
||||
S,
|
||||
C: tl.constexpr,
|
||||
K: tl.constexpr,
|
||||
D: tl.constexpr,
|
||||
H: tl.constexpr,
|
||||
HAS_BIAS: tl.constexpr,
|
||||
HAS_SNAKE: tl.constexpr,
|
||||
BU: tl.constexpr,
|
||||
BC: tl.constexpr,
|
||||
EXT: tl.constexpr = False,
|
||||
):
|
||||
"""Source row ``u`` of ``[history (H) | x (T)]``: scatter to every tap that reads it, save the last H."""
|
||||
b = tl.program_id(0)
|
||||
u = tl.program_id(1) * BU + tl.arange(0, BU)
|
||||
c = tl.program_id(2) * BC + tl.arange(0, BC)
|
||||
cm = c < C
|
||||
n_src = H + T
|
||||
um = u < n_src
|
||||
slot = tl.load(slots_ptr + b).to(tl.int64)
|
||||
live = tl.load(pos_ptr + b) != 0
|
||||
par = tl.load(par_ptr + slot).to(tl.int64)
|
||||
# history rows
|
||||
from_hist = u < H
|
||||
hrow = tl.where(from_hist, u, 0)
|
||||
hv = tl.load(
|
||||
hist_ptr + ((par * S + slot) * H + hrow)[:, None] * C + c[None, :],
|
||||
mask=(from_hist & um & live)[:, None] & cm[None, :],
|
||||
other=0.0,
|
||||
).to(tl.float32)
|
||||
xrow = tl.where(from_hist, 0, u - H)
|
||||
xv = tl.load(
|
||||
x_ptr + (b * T + xrow).to(tl.int64)[:, None] * C + c[None, :],
|
||||
mask=((~from_hist) & um)[:, None] & cm[None, :],
|
||||
other=0.0,
|
||||
).to(tl.float32)
|
||||
if HAS_BIAS:
|
||||
xv += tl.load(bias_ptr + c, mask=cm, other=0.0).to(tl.float32)[None, :]
|
||||
if HAS_SNAKE:
|
||||
a = tl.load(alpha_ptr + c, mask=cm, other=0.0).to(tl.float32)[None, :]
|
||||
ib = tl.load(ibeta_ptr + c, mask=cm, other=0.0).to(tl.float32)[None, :]
|
||||
xv = _snake(xv, a, ib)
|
||||
dt = col_ptr.dtype.element_ty
|
||||
v = tl.where(from_hist[:, None], hv, xv.to(dt).to(tl.float32)).to(dt)
|
||||
if EXT:
|
||||
tl.store(col_ptr + (b * n_src + u).to(tl.int64)[:, None] * C + c[None, :], v, mask=um[:, None] & cm[None, :])
|
||||
else:
|
||||
for j in tl.static_range(K):
|
||||
t = u - j * D
|
||||
tm = (t >= 0) & (t < T) & um
|
||||
tl.store(
|
||||
col_ptr + (b * T + tl.where(tm, t, 0)).to(tl.int64)[:, None] * (K * C) + j * C + c[None, :],
|
||||
v,
|
||||
mask=tm[:, None] & cm[None, :],
|
||||
)
|
||||
if H > 0:
|
||||
hm = (u >= T) & um
|
||||
tl.store(
|
||||
hist_ptr + (((1 - par) * S + slot) * H + tl.where(hm, u - T, 0))[:, None] * C + c[None, :],
|
||||
v,
|
||||
mask=hm[:, None] & cm[None, :],
|
||||
)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _rms_norm_kernel(x_ptr, w_ptr, out_ptr, eps, N: tl.constexpr):
|
||||
row = tl.program_id(0).to(tl.int64)
|
||||
offs = tl.arange(0, N)
|
||||
x = tl.load(x_ptr + row * N + offs).to(tl.float32)
|
||||
var = tl.sum(x * x, axis=0) / N
|
||||
dt = out_ptr.dtype.element_ty
|
||||
y = (x * tl.rsqrt(var + eps)).to(dt).to(tl.float32)
|
||||
w = tl.load(w_ptr + offs).to(tl.float32)
|
||||
tl.store(out_ptr + row * N + offs, (w * y).to(dt))
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _rope_kv_write_kernel(
|
||||
qkv_ptr, # [B * T, 3 * NH * HD]
|
||||
q_ptr, # [B * T, NH * HD]
|
||||
k_ring_ptr, # [S, RING, NH * HD]
|
||||
v_ring_ptr,
|
||||
slots_ptr,
|
||||
pos_ptr,
|
||||
T,
|
||||
theta,
|
||||
NH: tl.constexpr,
|
||||
HD: tl.constexpr,
|
||||
RING: tl.constexpr,
|
||||
):
|
||||
r = tl.program_id(0)
|
||||
h = tl.program_id(1)
|
||||
b = r // T
|
||||
p = tl.load(pos_ptr + b) + r % T
|
||||
slot = tl.load(slots_ptr + b).to(tl.int64)
|
||||
half: tl.constexpr = HD // 2
|
||||
i = tl.arange(0, half)
|
||||
inv_freq = tl.exp(-(2.0 * i.to(tl.float32) / HD) * tl.log(theta))
|
||||
ang = p.to(tl.float32) * inv_freq
|
||||
dt = q_ptr.dtype.element_ty
|
||||
cos = tl.cos(ang).to(dt).to(tl.float32)
|
||||
sin = tl.sin(ang).to(dt).to(tl.float32)
|
||||
W: tl.constexpr = NH * HD
|
||||
base = qkv_ptr + r.to(tl.int64) * 3 * W + h * HD
|
||||
q1 = tl.load(base + i).to(tl.float32)
|
||||
q2 = tl.load(base + half + i).to(tl.float32)
|
||||
k1 = tl.load(base + W + i).to(tl.float32)
|
||||
k2 = tl.load(base + W + half + i).to(tl.float32)
|
||||
vv = tl.arange(0, HD)
|
||||
v = tl.load(base + 2 * W + vv)
|
||||
qo = q_ptr + r.to(tl.int64) * W + h * HD
|
||||
tl.store(qo + i, (q1 * cos - q2 * sin).to(dt))
|
||||
tl.store(qo + half + i, (q2 * cos + q1 * sin).to(dt))
|
||||
ring = (slot * RING + p % RING) * W + h * HD
|
||||
tl.store(k_ring_ptr + ring + i, (k1 * cos - k2 * sin).to(dt))
|
||||
tl.store(k_ring_ptr + ring + half + i, (k2 * cos + k1 * sin).to(dt))
|
||||
tl.store(v_ring_ptr + ring + vv, v)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _attend_kernel(
|
||||
q_ptr, # [B * T, NH * HD]
|
||||
k_ring_ptr,
|
||||
v_ring_ptr,
|
||||
out_ptr, # [B * T, NH * HD]
|
||||
slots_ptr,
|
||||
pos_ptr,
|
||||
T,
|
||||
scale,
|
||||
NH: tl.constexpr,
|
||||
HD: tl.constexpr,
|
||||
RING: tl.constexpr,
|
||||
WINDOW: tl.constexpr,
|
||||
BK: tl.constexpr,
|
||||
):
|
||||
r = tl.program_id(0)
|
||||
h = tl.program_id(1)
|
||||
b = r // T
|
||||
p = tl.load(pos_ptr + b) + r % T
|
||||
slot = tl.load(slots_ptr + b).to(tl.int64)
|
||||
W: tl.constexpr = NH * HD
|
||||
d = tl.arange(0, HD)
|
||||
q = tl.load(q_ptr + r.to(tl.int64) * W + h * HD + d).to(tl.float32)
|
||||
j = tl.arange(0, BK)
|
||||
n = tl.minimum(p + 1, WINDOW)
|
||||
km = j < n
|
||||
kp = p - j # key positions p, p-1, ...
|
||||
idx = (slot * RING + tl.where(km, kp, 0) % RING) * W + h * HD
|
||||
k = tl.load(k_ring_ptr + idx[:, None] + d[None, :], mask=km[:, None], other=0.0).to(tl.float32)
|
||||
s = tl.sum(k * q[None, :], axis=1) * scale
|
||||
s = tl.where(km, s, -float("inf"))
|
||||
m = tl.max(s, axis=0)
|
||||
e = tl.exp(s - m)
|
||||
e = tl.where(km, e, 0.0)
|
||||
w = e / tl.sum(e, axis=0)
|
||||
dt = out_ptr.dtype.element_ty
|
||||
w = w.to(dt).to(tl.float32)
|
||||
v = tl.load(v_ring_ptr + idx[:, None] + d[None, :], mask=km[:, None], other=0.0).to(tl.float32)
|
||||
o = tl.sum(v * w[:, None], axis=0)
|
||||
tl.store(out_ptr + r.to(tl.int64) * W + h * HD + d, o.to(dt))
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _silu_mul_kernel(gu_ptr, out_ptr, N: tl.constexpr, BN: tl.constexpr):
|
||||
row = tl.program_id(0).to(tl.int64)
|
||||
offs = tl.program_id(1) * BN + tl.arange(0, BN)
|
||||
m = offs < N
|
||||
g = tl.load(gu_ptr + row * 2 * N + offs, mask=m).to(tl.float32)
|
||||
u = tl.load(gu_ptr + row * 2 * N + N + offs, mask=m).to(tl.float32)
|
||||
dt = out_ptr.dtype.element_ty
|
||||
sg = (g / (1.0 + tl.exp(-g))).to(dt).to(tl.float32)
|
||||
tl.store(out_ptr + row * N + offs, (sg * u).to(dt), mask=m)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _dwconv_ln_kernel(
|
||||
z_ptr, # [B * T, C] ConvNeXt input rows (true values)
|
||||
w_ptr, # [K, C]
|
||||
b_ptr, # [C]
|
||||
lnw_ptr,
|
||||
lnb_ptr,
|
||||
hist_ptr, # [2, S, K - 1, C]
|
||||
slots_ptr,
|
||||
pos_ptr,
|
||||
par_ptr,
|
||||
out_ptr, # [B * T, C] LayerNorm output
|
||||
T,
|
||||
S,
|
||||
eps,
|
||||
C: tl.constexpr,
|
||||
K: tl.constexpr,
|
||||
):
|
||||
b = tl.program_id(0)
|
||||
t = tl.program_id(1) # 0 .. max(T, K - 1)
|
||||
H: tl.constexpr = K - 1
|
||||
c = tl.arange(0, C)
|
||||
slot = tl.load(slots_ptr + b).to(tl.int64)
|
||||
live = tl.load(pos_ptr + b) != 0
|
||||
par = tl.load(par_ptr + slot).to(tl.int64)
|
||||
dt = out_ptr.dtype.element_ty
|
||||
hbase = hist_ptr + (par * S + slot) * H * C
|
||||
acc = tl.load(b_ptr + c).to(tl.float32)
|
||||
for j in tl.static_range(K):
|
||||
u = t + j # index into [hist (H) | z (T)]
|
||||
fh = u < H
|
||||
hv = tl.load(hbase + tl.where(fh, u, 0) * C + c, mask=(c < C) & fh & live, other=0.0).to(tl.float32)
|
||||
zrow = (b * T + tl.where(fh, 0, u - H)).to(tl.int64)
|
||||
zv = tl.load(z_ptr + zrow * C + c, mask=(c < C) & (~fh) & (t < T), other=0.0).to(tl.float32)
|
||||
acc += tl.where(fh, hv, zv) * tl.load(w_ptr + j * C + c).to(tl.float32)
|
||||
h = acc.to(dt).to(tl.float32)
|
||||
mean = tl.sum(h, axis=0) / C
|
||||
hc = h - mean
|
||||
var = tl.sum(hc * hc, axis=0) / C
|
||||
y = hc * tl.rsqrt(var + eps) * tl.load(lnw_ptr + c).to(tl.float32) + tl.load(lnb_ptr + c).to(tl.float32)
|
||||
tl.store(out_ptr + (b * T + t).to(tl.int64) * C + c, y.to(dt), mask=(c < C) & (t < T))
|
||||
# new history row t: source row T + t of [hist | z]
|
||||
u = T + t
|
||||
fh = u < H
|
||||
hv = tl.load(hbase + tl.where(fh, u, 0) * C + c, mask=(c < C) & fh & live & (t < H), other=0.0)
|
||||
zrow = (b * T + tl.where(fh, 0, u - H)).to(tl.int64)
|
||||
zv = tl.load(z_ptr + zrow * C + c, mask=(c < C) & (~fh) & (t < H), other=0.0)
|
||||
tl.store(
|
||||
hist_ptr + (((1 - par) * S + slot) * H + tl.where(t < H, t, 0)) * C + c,
|
||||
tl.where(fh, hv, zv).to(dt),
|
||||
mask=(c < C) & (t < H),
|
||||
)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _bias_gelu_kernel(x_ptr, b_ptr, N: tl.constexpr, BN: tl.constexpr):
|
||||
row = tl.program_id(0).to(tl.int64)
|
||||
offs = tl.program_id(1) * BN + tl.arange(0, BN)
|
||||
m = offs < N
|
||||
x = tl.load(x_ptr + row * N + offs, mask=m).to(tl.float32) + tl.load(b_ptr + offs, mask=m).to(tl.float32)
|
||||
dt = x_ptr.dtype.element_ty
|
||||
x = x.to(dt).to(tl.float32)
|
||||
y = 0.5 * x * (1.0 + tl.erf(x * 0.7071067811865476))
|
||||
tl.store(x_ptr + row * N + offs, y.to(dt), mask=m)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _col2im_kernel(
|
||||
z_ptr, # [B * T, 2 * R, C] transposed-conv GEMM rows
|
||||
bias_ptr, # [C]
|
||||
prev_ptr, # [2, S, R, C] previous call's last GEMM row, second half
|
||||
slots_ptr,
|
||||
pos_ptr,
|
||||
par_ptr,
|
||||
out_ptr, # [B * T * R, C]
|
||||
T,
|
||||
S,
|
||||
R: tl.constexpr,
|
||||
C: tl.constexpr,
|
||||
BC: tl.constexpr,
|
||||
):
|
||||
row = tl.program_id(0) # b * T + t
|
||||
j = tl.program_id(1) # 0 .. R - 1
|
||||
c = tl.program_id(2) * BC + tl.arange(0, BC)
|
||||
cm = c < C
|
||||
b = row // T
|
||||
t = row % T
|
||||
slot = tl.load(slots_ptr + b).to(tl.int64)
|
||||
live = tl.load(pos_ptr + b) != 0
|
||||
par = tl.load(par_ptr + slot).to(tl.int64)
|
||||
zr = z_ptr + row.to(tl.int64) * 2 * R * C
|
||||
cur = tl.load(zr + j * C + c, mask=cm).to(tl.float32)
|
||||
prev_in = tl.load(zr - 2 * R * C + (R + j) * C + c, mask=cm & (t > 0), other=0.0).to(tl.float32)
|
||||
prev_st = tl.load(prev_ptr + ((par * S + slot) * R + j) * C + c, mask=cm & (t == 0) & live, other=0.0).to(
|
||||
tl.float32
|
||||
)
|
||||
y = cur + tl.where(t > 0, prev_in, prev_st) + tl.load(bias_ptr + c, mask=cm).to(tl.float32)
|
||||
dt = out_ptr.dtype.element_ty
|
||||
tl.store(out_ptr + (row.to(tl.int64) * R + j) * C + c, y.to(dt), mask=cm)
|
||||
last = tl.load(zr + (R + j) * C + c, mask=cm & (t == T - 1))
|
||||
tl.store(prev_ptr + (((1 - par) * S + slot) * R + j) * C + c, last, mask=cm & (t == T - 1))
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _conv_out_kernel(
|
||||
ext_ptr, # [B, H + T, C] transformed rows: history then this call's
|
||||
w_ptr, # [K, C]
|
||||
wb, # conv bias (float)
|
||||
out_ptr, # [B, T] float32 waveform
|
||||
T,
|
||||
C: tl.constexpr,
|
||||
K: tl.constexpr,
|
||||
BT: tl.constexpr,
|
||||
CP: tl.constexpr,
|
||||
):
|
||||
b = tl.program_id(0)
|
||||
t = tl.program_id(1) * BT + tl.arange(0, BT)
|
||||
c = tl.arange(0, CP)
|
||||
cm = c < C
|
||||
tm = t < T
|
||||
base = ext_ptr + b.to(tl.int64) * (T + K - 1) * C
|
||||
acc = tl.zeros([BT], dtype=tl.float32)
|
||||
for j in tl.static_range(K):
|
||||
v = tl.load(base + (t + j)[:, None] * C + c[None, :], mask=tm[:, None] & cm[None, :], other=0.0).to(tl.float32)
|
||||
acc += tl.sum(v * tl.load(w_ptr + j * C + c, mask=cm, other=0.0).to(tl.float32)[None, :], axis=1)
|
||||
dt = ext_ptr.dtype.element_ty
|
||||
y = acc.to(dt).to(tl.float32) + wb
|
||||
y = tl.minimum(tl.maximum(y.to(dt).to(tl.float32), -1.0), 1.0)
|
||||
tl.store(out_ptr + b.to(tl.int64) * T + t, y, mask=tm)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _flip_kernel(par_ptr, slots_ptr):
|
||||
slot = tl.load(slots_ptr + tl.program_id(0)).to(tl.int64)
|
||||
tl.store(par_ptr + slot, 1 - tl.load(par_ptr + slot))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- module
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConvSpec:
|
||||
weight: torch.Tensor # [K * C_in, C_out] tap-major
|
||||
c_in: int
|
||||
k: int
|
||||
d: int
|
||||
hist: torch.Tensor | None = None # [2, S, H, C_in]
|
||||
|
||||
|
||||
def _conv_taps(w: torch.Tensor) -> torch.Tensor:
|
||||
"""``[C_out, C_in, K]`` -> ``[K * C_in, C_out]``."""
|
||||
c_out, c_in, k = w.shape
|
||||
return w.permute(2, 1, 0).reshape(k * c_in, c_out)
|
||||
|
||||
|
||||
def _snake_params(module) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
# SnakeBeta: x + 1 / (exp(beta) + 1e-9) * sin(exp(alpha) * x) ** 2
|
||||
alpha = module.alpha.detach().float()
|
||||
beta = module.beta.detach().float()
|
||||
return torch.exp(alpha).contiguous(), (1.0 / (torch.exp(beta) + 1e-9)).contiguous()
|
||||
|
||||
|
||||
class StreamingCodecDecoder(nn.Module):
|
||||
"""Frame-exact stateful decoder built from a loaded ``Qwen3TTSTokenizerV2Decoder``."""
|
||||
|
||||
RING = 128 # KV ring positions; a call may carry up to RING - window + 1 frames
|
||||
|
||||
def __init__(self, decoder: nn.Module, num_slots: int, dtype: torch.dtype | None = None) -> None:
|
||||
super().__init__()
|
||||
cfg = decoder.config
|
||||
dev = next(decoder.parameters()).device
|
||||
dtype = dtype or next(decoder.parameters()).dtype
|
||||
self.dtype, self.device = dtype, dev
|
||||
self.num_slots = num_slots + 1 # last slot: scratch for padding rows
|
||||
self.scratch_slot = num_slots
|
||||
S = self.num_slots
|
||||
self.nq = int(cfg.num_quantizers)
|
||||
self.cb = int(cfg.codebook_size)
|
||||
self.half = int(cfg.codebook_dim) // 2
|
||||
self.hidden = int(cfg.hidden_size)
|
||||
self.heads = int(cfg.num_attention_heads)
|
||||
self.head_dim = int(cfg.head_dim)
|
||||
self.window = int(cfg.sliding_window)
|
||||
self.theta = float(cfg.rope_theta)
|
||||
self.eps = float(cfg.rms_norm_eps)
|
||||
self.max_frames = self.RING - self.window + 1
|
||||
self.spf = int(decoder.total_upsample)
|
||||
if self.heads != int(cfg.num_key_value_heads):
|
||||
raise ValueError("streaming codec decoder assumes no grouped KV")
|
||||
|
||||
f32 = lambda t: t.detach().to(device=dev, dtype=torch.float32) # noqa: E731
|
||||
cast = lambda t: t.to(dtype).contiguous() # noqa: E731
|
||||
|
||||
def hist(h: int, c: int) -> torch.Tensor:
|
||||
return torch.zeros(2, S, h, c, device=dev, dtype=dtype)
|
||||
|
||||
# RVQ books, pre-divided by usage: [NQ * CB, HALF]
|
||||
q = decoder.quantizer
|
||||
books = []
|
||||
for vq in [q.rvq_first.vq.layers[0]] + list(q.rvq_rest.vq.layers):
|
||||
cbk = vq._codebook
|
||||
books.append(f32(cbk.embedding_sum) / f32(cbk.cluster_usage).clamp(min=cbk.epsilon)[:, None])
|
||||
self.books = cast(torch.cat(books))
|
||||
w_rvq = torch.cat(
|
||||
[f32(q.rvq_first.output_proj.weight)[:, :, 0], f32(q.rvq_rest.output_proj.weight)[:, :, 0]], 1
|
||||
)
|
||||
# pre_conv (k=3) and input_proj folded onto the embedding concat
|
||||
pre_w, pre_b = f32(decoder.pre_conv.conv.weight), f32(decoder.pre_conv.conv.bias)
|
||||
pt = decoder.pre_transformer
|
||||
in_w, in_b = f32(pt.input_proj.weight), f32(pt.input_proj.bias)
|
||||
taps = torch.stack([in_w @ pre_w[:, :, j] @ w_rvq for j in range(pre_w.shape[2])], dim=2) # [hid, 2H, 3]
|
||||
self.pre = _ConvSpec(cast(_conv_taps(taps)), 2 * self.half, 3, 1, hist(2, 2 * self.half))
|
||||
self.pre_b = cast(in_w @ pre_b + in_b)
|
||||
|
||||
# transformer
|
||||
self.layers = []
|
||||
qd = self.heads * self.head_dim
|
||||
for layer in pt.layers:
|
||||
a = layer.self_attn
|
||||
qkv = torch.cat([f32(a.q_proj.weight), f32(a.k_proj.weight), f32(a.v_proj.weight)], 0)
|
||||
o = f32(a.o_proj.weight) * f32(layer.self_attn_layer_scale.scale)[:, None]
|
||||
gu = torch.cat([f32(layer.mlp.gate_proj.weight), f32(layer.mlp.up_proj.weight)], 0)
|
||||
down = f32(layer.mlp.down_proj.weight) * f32(layer.mlp_layer_scale.scale)[:, None]
|
||||
self.layers.append(
|
||||
dict(
|
||||
ln1=cast(f32(layer.input_layernorm.weight)),
|
||||
ln2=cast(f32(layer.post_attention_layernorm.weight)),
|
||||
qkv=cast(qkv.t()),
|
||||
o=cast(o.t()),
|
||||
gu=cast(gu.t()),
|
||||
down=cast(down.t()),
|
||||
k_ring=torch.zeros(S, self.RING, qd, device=dev, dtype=dtype),
|
||||
v_ring=torch.zeros(S, self.RING, qd, device=dev, dtype=dtype),
|
||||
)
|
||||
)
|
||||
self.norm_w = cast(f32(pt.norm.weight))
|
||||
self.out_w = cast(f32(pt.output_proj.weight).t())
|
||||
self.out_b = cast(f32(pt.output_proj.bias))
|
||||
|
||||
# upsample: transposed conv (kernel = stride, no overlap) + ConvNeXt
|
||||
self.ups = []
|
||||
latent = int(cfg.latent_dim)
|
||||
pending = torch.zeros(latent, device=dev, dtype=torch.float32)
|
||||
for trans, block in decoder.upsample:
|
||||
tw = f32(trans.conv.weight) # [C_in, C_out, r]
|
||||
r = tw.shape[2]
|
||||
w = tw.permute(0, 2, 1).reshape(latent, r * latent) # [C_in, r * C_out]
|
||||
bias = (pending @ w).view(r, latent) + f32(trans.conv.bias)[None, :]
|
||||
dw = block.dwconv.conv
|
||||
gamma = f32(block.gamma)
|
||||
self.ups.append(
|
||||
dict(
|
||||
r=r,
|
||||
w=cast(w),
|
||||
b=cast(bias.reshape(-1)),
|
||||
dw_w=cast(f32(dw.weight)[:, 0, :].t()),
|
||||
dw_b=cast(f32(dw.bias)),
|
||||
ln_w=cast(f32(block.norm.weight)),
|
||||
ln_b=cast(f32(block.norm.bias)),
|
||||
ln_eps=float(block.norm.eps),
|
||||
pw1=cast(f32(block.pwconv1.weight).t()),
|
||||
pw1_b=cast(f32(block.pwconv1.bias)),
|
||||
pw2=cast((f32(block.pwconv2.weight) * gamma[:, None]).t()),
|
||||
hist=hist(_KERNEL - 1, latent),
|
||||
)
|
||||
)
|
||||
pending = f32(block.pwconv2.bias) * gamma
|
||||
|
||||
# conv_in (pending bias of the last ConvNeXt added on load)
|
||||
dec = decoder.decoder
|
||||
cin = dec[0].conv
|
||||
self.conv_in = _ConvSpec(cast(_conv_taps(f32(cin.weight))), latent, _KERNEL, 1, hist(_KERNEL - 1, latent))
|
||||
self.conv_in_pb = cast(pending)
|
||||
pending = f32(cin.bias)
|
||||
|
||||
# decoder blocks
|
||||
self.blocks = []
|
||||
for i, blk in enumerate(dec[1:-2]):
|
||||
snake, trans, *units = blk.block
|
||||
c_in = trans.conv.in_channels
|
||||
c_out = trans.conv.out_channels
|
||||
rate = trans.conv.stride[0]
|
||||
tw = f32(trans.conv.weight) # [C_in, C_out, 2 * rate]
|
||||
entry = dict(
|
||||
pb=cast(pending),
|
||||
snake=_snake_params(snake),
|
||||
rate=rate,
|
||||
c_in=c_in,
|
||||
c_out=c_out,
|
||||
w=cast(tw.permute(0, 2, 1).reshape(c_in, 2 * rate * c_out)),
|
||||
b=cast(f32(trans.conv.bias)),
|
||||
prev=torch.zeros(2, S, rate, c_out, device=dev, dtype=dtype),
|
||||
units=[],
|
||||
)
|
||||
pending = torch.zeros(c_out, device=dev, dtype=torch.float32)
|
||||
for u in units:
|
||||
c1, c2 = u.conv1.conv, u.conv2.conv
|
||||
entry["units"].append(
|
||||
dict(
|
||||
pb=cast(pending),
|
||||
act1=_snake_params(u.act1),
|
||||
conv1=_ConvSpec(
|
||||
cast(_conv_taps(f32(c1.weight))),
|
||||
c_out,
|
||||
_KERNEL,
|
||||
c1.dilation[0],
|
||||
hist((_KERNEL - 1) * c1.dilation[0], c_out),
|
||||
),
|
||||
b1=cast(f32(c1.bias)),
|
||||
conv1_w4=(
|
||||
cast(f32(c1.weight)).unsqueeze(2).contiguous(memory_format=torch.channels_last)
|
||||
if c1.dilation[0] == 1
|
||||
else None
|
||||
),
|
||||
act2=_snake_params(u.act2),
|
||||
w2=cast(f32(c2.weight)[:, :, 0].t()),
|
||||
)
|
||||
)
|
||||
pending = pending + f32(c2.bias)
|
||||
self.blocks.append(entry)
|
||||
self.out_pb = cast(pending)
|
||||
self.out_snake = _snake_params(dec[-2])
|
||||
co = dec[-1].conv
|
||||
self.out_c = co.in_channels
|
||||
self.conv_out_w = cast(f32(co.weight)[0].t()) # [K, C]
|
||||
self.conv_out_b = float(f32(co.bias)[0])
|
||||
self.conv_out = _ConvSpec(self.conv_out_w, self.out_c, _KERNEL, 1, hist(_KERNEL - 1, self.out_c))
|
||||
self.parity = torch.zeros(S, device=dev, dtype=torch.int32)
|
||||
self._dummy = torch.zeros(1, device=dev, dtype=dtype)
|
||||
|
||||
def _slot_state(self, slot: int) -> list[torch.Tensor]:
|
||||
state = [self.parity[slot : slot + 1]]
|
||||
for layer in self.layers:
|
||||
state.extend((layer["k_ring"][slot], layer["v_ring"][slot]))
|
||||
for spec in (self.pre, self.conv_in, self.conv_out):
|
||||
assert spec.hist is not None
|
||||
state.append(spec.hist[:, slot])
|
||||
for up in self.ups:
|
||||
state.append(up["hist"][:, slot])
|
||||
for block in self.blocks:
|
||||
state.append(block["prev"][:, slot])
|
||||
for unit in block["units"]:
|
||||
state.append(unit["conv1"].hist[:, slot])
|
||||
return state
|
||||
|
||||
def save_slot(self, slot: int) -> list[torch.Tensor]:
|
||||
"""Offload a preempted request before its runner slot is recycled."""
|
||||
return [tensor.to(device="cpu", copy=True) for tensor in self._slot_state(slot)]
|
||||
|
||||
def restore_slot(self, slot: int, state: list[torch.Tensor]) -> None:
|
||||
for target, saved in zip(self._slot_state(slot), state, strict=True):
|
||||
target.copy_(saved)
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
def _im2col(self, x, spec: _ConvSpec, T, slots, pos, bias=None, snake=None, ext=False):
|
||||
k = spec.k
|
||||
d = spec.d
|
||||
c = x.shape[1]
|
||||
h = (k - 1) * d
|
||||
B = slots.shape[0]
|
||||
if ext:
|
||||
col = torch.empty(B * (h + T), c, device=x.device, dtype=self.dtype)
|
||||
else:
|
||||
col = torch.empty(B * T, k * c, device=x.device, dtype=self.dtype)
|
||||
bu = 32
|
||||
bc = min(128, triton.next_power_of_2(c))
|
||||
grid = (B, triton.cdiv(h + T, bu), triton.cdiv(c, bc))
|
||||
a, ib = snake if snake is not None else (self._dummy, self._dummy)
|
||||
_im2col_kernel[grid](
|
||||
x, bias if bias is not None else self._dummy, a, ib,
|
||||
spec.hist if h > 0 else self._dummy, slots, pos, self.parity, col,
|
||||
T, self.num_slots, C=c, K=k, D=d, H=h,
|
||||
HAS_BIAS=bias is not None, HAS_SNAKE=snake is not None, BU=bu, BC=bc, EXT=ext,
|
||||
) # fmt: skip
|
||||
return col
|
||||
|
||||
def _conv_cudnn(self, x, u, T, slots, pos):
|
||||
"""Undilated unit conv: one source-row pass, then cuDNN on its channels-last view.
|
||||
|
||||
The im2col path writes a ``K``-times patch matrix; cuDNN reads the
|
||||
``[history | new]`` rows directly (same result up to summation order).
|
||||
"""
|
||||
spec = u["conv1"]
|
||||
ext = self._im2col(x, spec, T, slots, pos, bias=u["pb"], snake=u["act1"], ext=True)
|
||||
b = slots.shape[0]
|
||||
c = x.shape[1]
|
||||
x4 = ext.view(b, -1, c).permute(0, 2, 1).unsqueeze(2) # [B, C, 1, H + T], channels-last memory
|
||||
y = torch.nn.functional.conv2d(x4, u["conv1_w4"]) # [B, N, 1, T], channels-last memory
|
||||
return y.permute(0, 2, 3, 1).reshape(b * T, -1)
|
||||
|
||||
def _snake(self, x, snake, bias):
|
||||
n, c = x.shape
|
||||
out = torch.empty_like(x)
|
||||
bu, bc = 32, min(128, triton.next_power_of_2(c))
|
||||
one = torch.zeros(1, device=x.device, dtype=torch.int32)
|
||||
_im2col_kernel[(1, triton.cdiv(n, bu), triton.cdiv(c, bc))](
|
||||
x, bias, snake[0], snake[1], self._dummy, one, one, self.parity, out,
|
||||
n, self.num_slots, C=c, K=1, D=1, H=0, HAS_BIAS=True, HAS_SNAKE=True, BU=bu, BC=bc,
|
||||
) # fmt: skip
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------ forward
|
||||
@torch.no_grad()
|
||||
def forward(self, codes: torch.Tensor, slots: torch.Tensor, pos: torch.Tensor) -> torch.Tensor:
|
||||
"""``codes`` [B, T, NQ] int, ``slots``/``pos`` [B] int32 -> waveform [B, T * spf] float32.
|
||||
|
||||
``pos[b]`` is the index of the row's first frame in its stream; 0 starts
|
||||
a new stream in ``slots[b]``. Frames beyond ``max_frames`` per call must
|
||||
be split by the caller.
|
||||
"""
|
||||
B, T, _ = codes.shape
|
||||
if T > self.max_frames:
|
||||
raise ValueError(f"at most {self.max_frames} frames per call, got {T}")
|
||||
dev, dt = codes.device, self.dtype
|
||||
codes = codes.reshape(B * T, self.nq).to(torch.int32).contiguous()
|
||||
emb = torch.empty(B * T, 2 * self.half, device=dev, dtype=dt)
|
||||
_rvq_gather_kernel[(B * T,)](codes, self.books, emb, NQ=self.nq, CB=self.cb, HALF=self.half)
|
||||
x = torch.addmm(self.pre_b, self._im2col(emb, self.pre, T, slots, pos), self.pre.weight)
|
||||
|
||||
hid, qd = self.hidden, self.heads * self.head_dim
|
||||
rows = B * T
|
||||
h = torch.empty(rows, hid, device=dev, dtype=dt)
|
||||
q = torch.empty(rows, qd, device=dev, dtype=dt)
|
||||
att = torch.empty(rows, qd, device=dev, dtype=dt)
|
||||
a = torch.empty(rows, self.layers[0]["down"].shape[0], device=dev, dtype=dt)
|
||||
inter = a.shape[1]
|
||||
scale = self.head_dim**-0.5
|
||||
for L in self.layers:
|
||||
_rms_norm_kernel[(rows,)](x, L["ln1"], h, self.eps, N=hid)
|
||||
qkv = torch.mm(h, L["qkv"])
|
||||
_rope_kv_write_kernel[(rows, self.heads)](
|
||||
qkv, q, L["k_ring"], L["v_ring"], slots, pos, T, self.theta,
|
||||
NH=self.heads, HD=self.head_dim, RING=self.RING,
|
||||
) # fmt: skip
|
||||
_attend_kernel[(rows, self.heads)](
|
||||
q, L["k_ring"], L["v_ring"], att, slots, pos, T, scale,
|
||||
NH=self.heads, HD=self.head_dim, RING=self.RING, WINDOW=self.window,
|
||||
BK=triton.next_power_of_2(self.window),
|
||||
) # fmt: skip
|
||||
x.addmm_(att, L["o"])
|
||||
_rms_norm_kernel[(rows,)](x, L["ln2"], h, self.eps, N=hid)
|
||||
gu = torch.mm(h, L["gu"])
|
||||
_silu_mul_kernel[(rows, triton.cdiv(inter, 1024))](gu, a, N=inter, BN=1024)
|
||||
x.addmm_(a, L["down"])
|
||||
_rms_norm_kernel[(rows,)](x, self.norm_w, h, self.eps, N=hid)
|
||||
y = torch.addmm(self.out_b, h, self.out_w) # [rows, latent]
|
||||
|
||||
t_rows = T
|
||||
for U in self.ups:
|
||||
r = U["r"]
|
||||
z = torch.addmm(U["b"], y, U["w"]).view(rows * r, -1)
|
||||
rows, t_rows = rows * r, t_rows * r
|
||||
c = z.shape[1]
|
||||
n = torch.empty_like(z)
|
||||
_dwconv_ln_kernel[(B, max(t_rows, _KERNEL - 1))](
|
||||
z, U["dw_w"], U["dw_b"], U["ln_w"], U["ln_b"], U["hist"], slots, pos, self.parity, n,
|
||||
t_rows, self.num_slots, U["ln_eps"], C=c, K=_KERNEL,
|
||||
) # fmt: skip
|
||||
p1 = torch.mm(n, U["pw1"])
|
||||
_bias_gelu_kernel[(rows, triton.cdiv(p1.shape[1], 1024))](p1, U["pw1_b"], N=p1.shape[1], BN=1024)
|
||||
z.addmm_(p1, U["pw2"])
|
||||
y = z
|
||||
|
||||
x = torch.mm(self._im2col(y, self.conv_in, t_rows, slots, pos, bias=self.conv_in_pb), self.conv_in.weight)
|
||||
for blk in self.blocks:
|
||||
s = self._snake(x, blk["snake"], blk["pb"])
|
||||
zt = torch.mm(s, blk["w"]) # [rows, 2 * rate * c_out]
|
||||
r, c_out = blk["rate"], blk["c_out"]
|
||||
x = torch.empty(rows * r, c_out, device=dev, dtype=dt)
|
||||
bc = min(128, triton.next_power_of_2(c_out))
|
||||
_col2im_kernel[(rows, r, triton.cdiv(c_out, bc))](
|
||||
zt, blk["b"], blk["prev"], slots, pos, self.parity, x, t_rows, self.num_slots,
|
||||
R=r, C=c_out, BC=bc,
|
||||
) # fmt: skip
|
||||
rows, t_rows = rows * r, t_rows * r
|
||||
for u in blk["units"]:
|
||||
if u.get("conv1_w4") is not None:
|
||||
c1 = self._conv_cudnn(x, u, t_rows, slots, pos)
|
||||
else:
|
||||
col = self._im2col(x, u["conv1"], t_rows, slots, pos, bias=u["pb"], snake=u["act1"])
|
||||
c1 = torch.mm(col, u["conv1"].weight)
|
||||
s2 = self._snake(c1, u["act2"], u["b1"])
|
||||
x.addmm_(s2, u["w2"])
|
||||
|
||||
ext = self._im2col(x, self.conv_out, t_rows, slots, pos, bias=self.out_pb, snake=self.out_snake, ext=True)
|
||||
wav = torch.empty(B, t_rows, device=dev, dtype=torch.float32)
|
||||
_conv_out_kernel[(B, triton.cdiv(t_rows, 256))](
|
||||
ext, self.conv_out_w, self.conv_out_b, wav, t_rows,
|
||||
C=self.out_c, K=_KERNEL, BT=256, CP=triton.next_power_of_2(self.out_c),
|
||||
) # fmt: skip
|
||||
_flip_kernel[(B,)](self.parity, slots)
|
||||
return wav
|
||||
|
||||
|
||||
class StreamingDecodeGraphs:
|
||||
"""CUDA graphs of ``StreamingCodecDecoder`` for a fixed frame count per batch bucket."""
|
||||
|
||||
def __init__(self, sd: StreamingCodecDecoder, batch_sizes: list[int], frames: int = 1) -> None:
|
||||
self.sd, self.frames = sd, frames
|
||||
self.graphs: dict[int, tuple] = {}
|
||||
dev = sd.device
|
||||
pool = torch.cuda.graph_pool_handle()
|
||||
for bsz in sorted(set(batch_sizes)):
|
||||
codes = torch.zeros(bsz, frames, sd.nq, dtype=torch.int32, device=dev)
|
||||
slots = torch.full((bsz,), sd.scratch_slot, dtype=torch.int32, device=dev)
|
||||
pos = torch.zeros(bsz, dtype=torch.int32, device=dev)
|
||||
sd(codes, slots, pos)
|
||||
current_omni_platform.synchronize()
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph, pool=pool):
|
||||
out = sd(codes, slots, pos)
|
||||
self.graphs[bsz] = (graph, codes, slots, pos, out)
|
||||
self.sizes = sorted(self.graphs)
|
||||
|
||||
def __call__(self, codes: torch.Tensor, slots: torch.Tensor, pos: torch.Tensor) -> torch.Tensor:
|
||||
"""``codes`` [n, frames, NQ], ``slots``/``pos`` [n] on device -> waveform view [n, frames * spf]."""
|
||||
n = int(codes.shape[0])
|
||||
size = next((b for b in self.sizes if b >= n), None)
|
||||
if size is None:
|
||||
return self.sd(codes, slots.to(torch.int32), pos.to(torch.int32))
|
||||
graph, s_codes, s_slots, s_pos, out = self.graphs[size]
|
||||
s_codes[:n].copy_(codes)
|
||||
s_slots[:n].copy_(slots)
|
||||
s_pos[:n].copy_(pos)
|
||||
if n < size:
|
||||
s_slots[n:].fill_(self.sd.scratch_slot)
|
||||
s_pos[n:].zero_()
|
||||
graph.replay()
|
||||
return out[:n]
|
||||
@@ -390,16 +390,17 @@ def talker2code2wav_async_chunk(
|
||||
if ref_context_size > 0:
|
||||
if emitted_chunks <= 0:
|
||||
ref_context_request_id = request_id
|
||||
ref_frames = ref_context.tolist()
|
||||
ref_frames = list(ref_context.to(device="cpu").unbind(0))
|
||||
window_frames = ref_frames + window_frames
|
||||
ref_context_included = True
|
||||
left_context_size = ref_context_size
|
||||
|
||||
num_quantizers = len(window_frames[0])
|
||||
num_frames = len(window_frames)
|
||||
code_predictor_codes = torch.tensor(
|
||||
[window_frames[f][q] for q in range(num_quantizers) for f in range(num_frames)],
|
||||
dtype=torch.long,
|
||||
# Keep the codebook-major CPU wire format without extracting every
|
||||
# tensor element as a Python scalar. Legacy buffered frames can be lists.
|
||||
code_predictor_codes = (
|
||||
torch.stack([torch.as_tensor(frame, dtype=torch.long, device="cpu") for frame in window_frames])
|
||||
.transpose(0, 1)
|
||||
.reshape(-1)
|
||||
)
|
||||
|
||||
meta = MetaStruct(
|
||||
|
||||
@@ -5,13 +5,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||
from vllm.v1.worker.gpu.states import RequestState
|
||||
|
||||
from vllm_omni.utils.device_copy import index_to_device
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioBuffer, StreamingAudioOutput
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState
|
||||
@@ -24,12 +28,175 @@ def _has_ref_codes(buffer: dict[str, Any]) -> bool:
|
||||
return isinstance(ref, torch.Tensor) and ref.numel() > 0
|
||||
|
||||
|
||||
def _snapshot_runtime(value: Any) -> Any:
|
||||
if isinstance(value, torch.Tensor):
|
||||
return value.to("cpu", copy=True)
|
||||
if isinstance(value, dict):
|
||||
return {key: _snapshot_runtime(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_snapshot_runtime(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_snapshot_runtime(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass
|
||||
class TalkerInputs:
|
||||
embeds: torch.Tensor
|
||||
length: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SuspendedAudio:
|
||||
position: int
|
||||
decoder_state: list[torch.Tensor]
|
||||
generator: torch.Generator | None
|
||||
runtime: dict[str, Any]
|
||||
frame_embed: torch.Tensor
|
||||
|
||||
|
||||
class EagerMTPState:
|
||||
"""Used only by models explicitly declaring ``mtp_eager_frames``."""
|
||||
|
||||
def __init__(self, owner: OmniModelState) -> None:
|
||||
self.owner = owner
|
||||
self._first_audio_valid: torch.Tensor | None = None
|
||||
self._side_stream: torch.cuda.Stream | None = None
|
||||
self._fast_ok: bool | None = None
|
||||
self._audio_buffer: StreamingAudioBuffer | None = None
|
||||
self._frame_requests: set[str] = set()
|
||||
self._suspended_audio: dict[str, SuspendedAudio] = {}
|
||||
self._restore_audio: dict[str, list[torch.Tensor]] = {}
|
||||
self._talker_inputs: dict[str, TalkerInputs] = {}
|
||||
|
||||
def suspend_audio(self, req_id: str, req_idx: int) -> None:
|
||||
decoder = getattr(self.owner.model, "stream_decoder", None)
|
||||
position = self.owner._stream_pos.get(req_id)
|
||||
if decoder is None or position is None:
|
||||
return
|
||||
# Slot reuse is rare and must wait for the last side-stream write.
|
||||
saved = self._restore_audio.pop(req_id, None)
|
||||
if saved is None:
|
||||
self._decode_stream.synchronize()
|
||||
saved = decoder.save_slot(req_idx)
|
||||
history = self._talker_inputs[req_id]
|
||||
history.embeds = history.embeds[: history.length].to("cpu", copy=True)
|
||||
runtime = self.owner.intermediate_buffer.buffers[req_idx]
|
||||
assert self.owner._eager_embeds is not None
|
||||
self._suspended_audio[req_id] = SuspendedAudio(
|
||||
position,
|
||||
saved,
|
||||
self.owner._mtp_generators.get(req_id),
|
||||
_snapshot_runtime(runtime),
|
||||
self.owner._eager_embeds[req_idx].to("cpu", copy=True),
|
||||
)
|
||||
|
||||
def resume_audio(self, req_id: str, req_idx: int) -> None:
|
||||
saved = self._suspended_audio.pop(req_id, None)
|
||||
if saved is not None:
|
||||
self.owner._stream_pos[req_id] = saved.position
|
||||
self.owner._mtp_generators[req_id] = saved.generator
|
||||
self._restore_audio[req_id] = saved.decoder_state
|
||||
self.owner.intermediate_buffer.buffers[req_idx] = saved.runtime
|
||||
assert self.owner._eager_embeds is not None
|
||||
self.owner._eager_embeds[req_idx].copy_(saved.frame_embed)
|
||||
self.owner._eager_ready[req_idx] = req_id
|
||||
|
||||
def finish_audio(self, req_ids: set[str]) -> None:
|
||||
for req_id in req_ids:
|
||||
self._suspended_audio.pop(req_id, None)
|
||||
self._restore_audio.pop(req_id, None)
|
||||
self.owner._stream_pos.pop(req_id, None)
|
||||
self._talker_inputs.pop(req_id, None)
|
||||
if self._audio_buffer is not None:
|
||||
self._audio_buffer.finish(req_ids)
|
||||
|
||||
def replay_inputs(self, req_id: str, req_idx: int, offset: int, ids: torch.Tensor, embeds: torch.Tensor) -> bool:
|
||||
"""Rebuild freed Talker KV from the exact conditioned inputs, without rerunning MTP."""
|
||||
if req_id not in self._restore_audio:
|
||||
return False
|
||||
history = self._talker_inputs[req_id]
|
||||
count = min(len(ids), max(0, history.length - offset))
|
||||
if count:
|
||||
embeds[:count].copy_(history.embeds[offset : offset + count])
|
||||
if count < len(ids):
|
||||
# Recompute ends with the last accepted CB0, whose full eager frame
|
||||
# was saved but has not yet been consumed as a Talker input.
|
||||
if len(ids) - count != 1 or offset + count != history.length:
|
||||
raise RuntimeError(f"Talker replay exceeds saved inputs for {req_id}")
|
||||
info = self.owner.intermediate_buffer.buffers[req_idx]
|
||||
_, _, updates = self.owner.model.preprocess(ids[count:], embeds[count:], **info, _omni_is_prefill=False)
|
||||
mtp_inputs = updates.pop("mtp_inputs", None)
|
||||
if mtp_inputs is None:
|
||||
raise RuntimeError(f"Talker replay has no text conditioning for {req_id}")
|
||||
assert self.owner._eager_embeds is not None
|
||||
embeds[count:].copy_(self.owner._eager_embeds[req_idx] + mtp_inputs[1].reshape(1, -1))
|
||||
self.owner.intermediate_buffer.update(req_idx, updates, self.owner.model.gpu_resident_buffer_keys)
|
||||
return True
|
||||
|
||||
def record_inputs(self, input_batch: InputBatch, embeds: torch.Tensor) -> None:
|
||||
if getattr(self.owner.model, "stream_decoder", None) is None:
|
||||
return
|
||||
history_slices: list[torch.Tensor] = []
|
||||
input_slices: list[torch.Tensor] = []
|
||||
for i, req_id in enumerate(input_batch.req_ids):
|
||||
start, end = input_batch.query_start_loc_np[i : i + 2]
|
||||
offset = int(input_batch.num_computed_tokens_np[i])
|
||||
length = offset + int(end - start)
|
||||
history = self._talker_inputs.get(req_id)
|
||||
if history is None or history.embeds.device != embeds.device:
|
||||
storage = torch.empty(
|
||||
self.owner.vllm_config.model_config.max_model_len,
|
||||
embeds.shape[-1],
|
||||
device=embeds.device,
|
||||
dtype=embeds.dtype,
|
||||
)
|
||||
if history is not None:
|
||||
storage[: history.length].copy_(history.embeds)
|
||||
history = TalkerInputs(storage, history.length if history else 0)
|
||||
self._talker_inputs[req_id] = history
|
||||
history_slices.append(history.embeds[offset:length])
|
||||
input_slices.append(embeds[start:end])
|
||||
history.length = max(history.length, length)
|
||||
if history_slices:
|
||||
# Keep exact inputs for replay without launching one copy per row.
|
||||
torch._foreach_copy_(history_slices, input_slices)
|
||||
|
||||
def prepare_audio_output(
|
||||
self, input_batch: InputBatch, req_states: RequestState, outputs: dict[str, Any]
|
||||
) -> StreamingAudioOutput | None:
|
||||
if self._audio_buffer is None:
|
||||
return None
|
||||
wav = outputs.pop("model_outputs", None)
|
||||
if not isinstance(wav, torch.Tensor):
|
||||
return None
|
||||
n = input_batch.num_reqs
|
||||
total_after = input_batch.num_computed_tokens_np[:n] + np.asarray(input_batch.num_scheduled_tokens[:n]) + 1
|
||||
limits = np.minimum(
|
||||
req_states.max_seq_len[input_batch.idx_mapping_np[:n]], self.owner.vllm_config.model_config.max_model_len
|
||||
)
|
||||
requests = [
|
||||
self._audio_buffer.requests.get(rid) if rid in self._frame_requests else None for rid in input_batch.req_ids
|
||||
]
|
||||
self._frame_requests.clear()
|
||||
event = self.owner._stream_decode_event
|
||||
self.owner._stream_decode_event = None
|
||||
return StreamingAudioOutput(
|
||||
wav,
|
||||
outputs["meta"]["codec_frame_valid"],
|
||||
outputs["sr"][0],
|
||||
event,
|
||||
input_batch.query_start_loc_np[: n + 1].copy(),
|
||||
requests,
|
||||
total_after >= limits,
|
||||
)
|
||||
|
||||
@property
|
||||
def _decode_stream(self) -> torch.cuda.Stream:
|
||||
"""Side stream for Talker stream decode, so the codec overlaps the next step."""
|
||||
if self._side_stream is None:
|
||||
self._side_stream = torch.cuda.Stream()
|
||||
return self._side_stream
|
||||
|
||||
def set_first_audio_sink(self, sink: Any) -> None:
|
||||
from vllm_omni.worker_v2.first_audio_sender import FirstAudioSender
|
||||
@@ -106,6 +273,14 @@ class EagerMTPState:
|
||||
|
||||
bsz = len(entries)
|
||||
device = text_hidden.device
|
||||
if self._fast_path_ok(codes_out, text_hidden):
|
||||
self._run_eager_mtp_fused(
|
||||
input_batch, text_hidden, sampled_token_ids, multimodal_outputs, entries, input_ids,
|
||||
codes_out, valid_out, mtp_batch_descriptor_dispatcher,
|
||||
) # fmt: skip
|
||||
return
|
||||
if getattr(self.owner.model, "stream_decoder", None) is not None:
|
||||
raise RuntimeError("Talker stream decode requires the fused eager-MTP path")
|
||||
batch_rows = index_to_device([i for i, _req_idx, _req_id, _prefill in entries], device)
|
||||
req_indices = [req_idx for _i, req_idx, _req_id, _prefill in entries]
|
||||
last_tokens = input_batch.query_start_loc.index_select(0, batch_rows + 1).long() - 1
|
||||
@@ -162,6 +337,168 @@ class EagerMTPState:
|
||||
scheduled *= self._first_audio_valid.index_select(0, rows)
|
||||
first_audio.index_copy_(0, last_tokens, scheduled.to(first_audio.dtype))
|
||||
|
||||
def _embedding_weight(self) -> torch.Tensor | None:
|
||||
inner = getattr(self.owner.model, "model", None)
|
||||
embed = getattr(inner, "embed_tokens", None)
|
||||
weight = getattr(embed, "weight", None)
|
||||
method = getattr(embed, "quant_method", None)
|
||||
unquantized = method is None or type(method).__name__ == "UnquantizedEmbeddingMethod"
|
||||
tp = getattr(embed, "tp_size", 1)
|
||||
if isinstance(weight, torch.Tensor) and weight.ndim == 2 and unquantized and tp == 1:
|
||||
return weight
|
||||
return None
|
||||
|
||||
def _fast_path_ok(self, codes_out: torch.Tensor, text_hidden: torch.Tensor) -> bool:
|
||||
if self._fast_ok is None:
|
||||
self._fast_ok = (
|
||||
text_hidden.is_cuda
|
||||
and not self.owner.vllm_config.cache_config.enable_prefix_caching
|
||||
and self._embedding_weight() is not None
|
||||
and hasattr(self.owner.model, "_codebook_vocab_size")
|
||||
)
|
||||
return self._fast_ok and codes_out.ndim == 2 and text_hidden.is_contiguous()
|
||||
|
||||
def _run_eager_mtp_fused(
|
||||
self,
|
||||
input_batch: InputBatch,
|
||||
text_hidden: torch.Tensor,
|
||||
sampled_token_ids: torch.Tensor,
|
||||
multimodal_outputs: dict[str, Any],
|
||||
entries: list[tuple[int, int, str, bool]],
|
||||
input_ids: torch.Tensor,
|
||||
codes_out: torch.Tensor,
|
||||
valid_out: torch.Tensor,
|
||||
mtp_batch_descriptor_dispatcher: Callable[[int], Any] | None,
|
||||
) -> None:
|
||||
from vllm_omni.worker_v2.model_states.eager_mtp_kernels import eager_post, eager_pre
|
||||
|
||||
owner = self.owner
|
||||
bsz = len(entries)
|
||||
device = text_hidden.device
|
||||
first_audio = multimodal_outputs.get("meta", {}).get("first_audio")
|
||||
fa_requests = owner._first_audio_requests
|
||||
model = owner.model
|
||||
stream = getattr(model, "stream_decoder", None)
|
||||
stream_out = multimodal_outputs.get("model_outputs") if stream is not None else None
|
||||
pos_list: list[int] = []
|
||||
first_rows: list[int] = []
|
||||
primes: list[tuple[int, torch.Tensor]] = []
|
||||
if isinstance(stream_out, torch.Tensor):
|
||||
assert stream is not None
|
||||
# Frame index of each row's frame in its request's stream; 0 starts
|
||||
# a fresh decoder state in the request's slot. A voice-clone
|
||||
# request's stream starts after its reference codes, which prime
|
||||
# the decoder state first (as Code2Wav's first-chunk context does).
|
||||
if self._audio_buffer is None:
|
||||
self._audio_buffer = StreamingAudioBuffer(int(stream.spf), int(model.stream_chunk_frames))
|
||||
self._frame_requests = {req_id for _i, _idx, req_id, _p in entries}
|
||||
positions = owner._stream_pos
|
||||
for row, (_i, req_idx, req_id, _p) in enumerate(entries):
|
||||
self._audio_buffer.add(req_id)
|
||||
p = positions.get(req_id)
|
||||
if p is None:
|
||||
first_rows.append(row)
|
||||
ref = model.get_stream_ref_context(owner.intermediate_buffer.buffers[req_idx])
|
||||
p = 0
|
||||
if ref is not None:
|
||||
primes.append((req_idx, ref))
|
||||
p = int(ref.shape[0])
|
||||
pos_list.append(p)
|
||||
positions[req_id] = p + 1
|
||||
meta_list = (
|
||||
[i for i, _r, _q, _p in entries]
|
||||
+ [r for _i, r, _q, _p in entries]
|
||||
+ [int(p) for _i, _r, _q, p in entries]
|
||||
+ [int(q in fa_requests) for _i, _r, q, _p in entries]
|
||||
+ pos_list
|
||||
)
|
||||
meta = index_to_device(meta_list, device)
|
||||
rows = meta[bsz : 2 * bsz]
|
||||
sampled = sampled_token_ids.reshape(input_batch.num_reqs, -1)
|
||||
assert owner._mtp_input_ids is not None and owner._mtp_input_embeds is not None
|
||||
assert owner._mtp_hidden is not None and owner._mtp_text_step is not None
|
||||
batch_ids = owner._mtp_input_ids[:bsz]
|
||||
batch_emb = owner._mtp_input_embeds[:bsz]
|
||||
batch_hidden = owner._mtp_hidden[:bsz]
|
||||
batch_step = owner._mtp_text_step[:bsz]
|
||||
last_tokens, layer0 = eager_pre(
|
||||
meta, bsz, input_batch.query_start_loc, sampled, text_hidden, self._embedding_weight(),
|
||||
batch_ids, batch_emb, batch_hidden, batch_step,
|
||||
) # fmt: skip
|
||||
req_indices = meta_list[bsz : 2 * bsz]
|
||||
frame_embeds, codes = owner._mtp_forward(
|
||||
req_indices, batch_ids, batch_emb, batch_hidden, batch_step, mtp_batch_descriptor_dispatcher
|
||||
)
|
||||
assert codes is not None
|
||||
has_prefill = any(p for _i, _r, _q, p in entries)
|
||||
fa_in_kernel = isinstance(first_audio, torch.Tensor) and not has_prefill
|
||||
valid = eager_post(
|
||||
meta, bsz, last_tokens, layer0, frame_embeds.reshape(frame_embeds.shape[0], -1).contiguous(),
|
||||
owner._eager_embeds, codes.contiguous(), codes_out, input_ids, valid_out,
|
||||
first_audio if fa_in_kernel else None, self._first_audio_valid if fa_in_kernel else None,
|
||||
owner.model._codebook_vocab_size,
|
||||
) # fmt: skip
|
||||
for _i, req_idx, req_id, _prefill in entries:
|
||||
owner._eager_ready[req_idx] = req_id
|
||||
if pos_list:
|
||||
assert stream is not None
|
||||
assert isinstance(stream_out, torch.Tensor)
|
||||
decode = model.stream_graphs if model.stream_graphs is not None else stream
|
||||
frame_codes = codes[:bsz].reshape(bsz, 1, -1).to(torch.int32)
|
||||
# The next Talker step does not read the PCM, so the codec runs
|
||||
# beside it; only the output copies wait for it.
|
||||
side = self._decode_stream
|
||||
side.wait_stream(torch.cuda.current_stream())
|
||||
for t in (frame_codes, meta, last_tokens, valid, stream_out):
|
||||
t.record_stream(side)
|
||||
with torch.cuda.stream(side):
|
||||
for _i, req_idx, req_id, _p in entries:
|
||||
saved = self._restore_audio.pop(req_id, None)
|
||||
if saved is not None:
|
||||
stream.restore_slot(req_idx, saved)
|
||||
model.prime_stream_decoder(primes)
|
||||
pcm = decode(frame_codes, rows, meta[4 * bsz : 5 * bsz])
|
||||
stream_out.index_copy_(0, last_tokens, pcm.reshape(bsz, -1).to(stream_out.dtype))
|
||||
self._send_stream_first_frames(entries, pcm, valid, first_rows)
|
||||
done = torch.cuda.Event()
|
||||
done.record(side)
|
||||
owner._stream_decode_event = done
|
||||
if has_prefill:
|
||||
self._publish_first_audio(input_batch, entries, codes[:bsz], valid)
|
||||
if isinstance(first_audio, torch.Tensor):
|
||||
# Requests accepted by this step's publish count as scheduled.
|
||||
scheduled = index_to_device(
|
||||
[int(q in owner._first_audio_requests) for _i, _r, q, _p in entries], device
|
||||
)
|
||||
if self._first_audio_valid is None:
|
||||
scheduled.zero_()
|
||||
else:
|
||||
scheduled *= self._first_audio_valid.index_select(0, rows)
|
||||
first_audio.index_copy_(0, last_tokens, scheduled.to(first_audio.dtype))
|
||||
|
||||
def _send_stream_first_frames(
|
||||
self,
|
||||
entries: list[tuple[int, int, str, bool]],
|
||||
pcm: torch.Tensor,
|
||||
valid: torch.Tensor,
|
||||
first_rows: list[int],
|
||||
) -> None:
|
||||
sender = self.owner._first_audio_sender
|
||||
if sender is None or not first_rows:
|
||||
return
|
||||
rows = index_to_device(first_rows, pcm.device)
|
||||
request_ids = [entries[row][2] for row in first_rows]
|
||||
sample_rate = torch.tensor(int(self.owner.model.stream_sample_rate), dtype=torch.int32)
|
||||
accepted = sender.submit(
|
||||
request_ids,
|
||||
pcm.reshape(pcm.shape[0], -1).index_select(0, rows).float(),
|
||||
sample_rate,
|
||||
valid=valid.index_select(0, rows),
|
||||
)
|
||||
assert self._audio_buffer is not None
|
||||
for request_id in accepted:
|
||||
self._audio_buffer.requests[request_id].accept_first_audio()
|
||||
|
||||
def _publish_first_audio(
|
||||
self,
|
||||
input_batch: InputBatch,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
# ruff: noqa: N803 - Triton constexpr parameters use kernel-style capitals.
|
||||
"""Gather/scatter kernels around the eager Talker-MTP call.
|
||||
|
||||
Each engine step, the rows sampled this step need their CB0, codec embedding
|
||||
and last hidden state gathered into the MTP inputs, and the MTP results
|
||||
scattered back into the eager-frame embeddings and the retained outputs. As
|
||||
separate torch ops that is a few dozen small launches per step, and the engine
|
||||
loop is host-bound, so each costs the whole batch. These two kernels do the
|
||||
same indexing in one launch each; the row metadata arrives in one H2D copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
from vllm.triton_utils import tl, triton
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _pre_kernel(
|
||||
meta_ptr, # int64 [4, n]: batch row, request index, prefill flag, first-audio scheduled
|
||||
n,
|
||||
qsl_ptr, # query_start_loc
|
||||
sampled_ptr, # [num_reqs, s_stride]
|
||||
s_stride,
|
||||
hidden_ptr, # [T, H]
|
||||
emb_w_ptr, # [V, H]
|
||||
ids_ptr, # [n] MTP input ids
|
||||
emb_ptr, # [n, H]
|
||||
mtp_hidden_ptr, # [n, H]
|
||||
step_ptr, # [n, H]
|
||||
last_ptr, # int64 [n] out
|
||||
layer0_ptr, # int64 [n] out
|
||||
H: tl.constexpr,
|
||||
BH: tl.constexpr,
|
||||
):
|
||||
b = tl.program_id(0)
|
||||
row = tl.load(meta_ptr + b)
|
||||
last = tl.load(qsl_ptr + row + 1).to(tl.int64) - 1
|
||||
l0 = tl.load(sampled_ptr + row * s_stride).to(tl.int64)
|
||||
tl.store(ids_ptr + b, l0.to(ids_ptr.dtype.element_ty))
|
||||
tl.store(last_ptr + b, last)
|
||||
tl.store(layer0_ptr + b, l0)
|
||||
for h0 in tl.static_range(0, H, BH):
|
||||
c = h0 + tl.arange(0, BH)
|
||||
m = c < H
|
||||
tl.store(emb_ptr + b * H + c, tl.load(emb_w_ptr + l0 * H + c, mask=m).to(emb_ptr.dtype.element_ty), mask=m)
|
||||
tl.store(mtp_hidden_ptr + b * H + c, tl.load(hidden_ptr + last * H + c, mask=m), mask=m)
|
||||
tl.store(step_ptr + b * H + c, tl.zeros([BH], dtype=step_ptr.dtype.element_ty), mask=m)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _post_kernel(
|
||||
meta_ptr,
|
||||
n,
|
||||
last_ptr,
|
||||
layer0_ptr,
|
||||
frame_ptr, # [>=n, H] MTP frame embeddings
|
||||
eager_ptr, # [R, H]
|
||||
codes_ptr, # [>=n, Q]
|
||||
codes_out_ptr, # [T, Q]
|
||||
input_ids_ptr, # [T] this step's input ids
|
||||
valid_out_ptr, # [T]
|
||||
valid_ptr, # [n] bool out
|
||||
fa_ptr, # [T] first_audio output or dummy
|
||||
fa_valid_ptr, # [R] bool or dummy
|
||||
vocab,
|
||||
H: tl.constexpr,
|
||||
Q: tl.constexpr,
|
||||
QP: tl.constexpr,
|
||||
BH: tl.constexpr,
|
||||
WRITE_FA: tl.constexpr,
|
||||
HAS_FA_VALID: tl.constexpr,
|
||||
):
|
||||
b = tl.program_id(0)
|
||||
req = tl.load(meta_ptr + n + b)
|
||||
prefill = tl.load(meta_ptr + 2 * n + b) != 0
|
||||
last = tl.load(last_ptr + b)
|
||||
l0 = tl.load(layer0_ptr + b)
|
||||
for h0 in tl.static_range(0, H, BH):
|
||||
c = h0 + tl.arange(0, BH)
|
||||
m = c < H
|
||||
v = tl.load(frame_ptr + b * H + c, mask=m)
|
||||
tl.store(eager_ptr + req * H + c, v.to(eager_ptr.dtype.element_ty), mask=m)
|
||||
q = tl.arange(0, QP)
|
||||
qm = q < Q
|
||||
cv = tl.load(codes_ptr + b * Q + q, mask=qm)
|
||||
tl.store(codes_out_ptr + last * Q + q, cv.to(codes_out_ptr.dtype.element_ty), mask=qm)
|
||||
inp = tl.load(input_ids_ptr + last).to(tl.int64)
|
||||
in_valid = ((inp >= 0) & (inp < vocab)) | prefill
|
||||
valid = (l0 >= 0) & (l0 < vocab) & in_valid
|
||||
tl.store(valid_out_ptr + last, valid.to(valid_out_ptr.dtype.element_ty))
|
||||
tl.store(valid_ptr + b, valid)
|
||||
if WRITE_FA:
|
||||
scheduled = tl.load(meta_ptr + 3 * n + b) != 0
|
||||
if HAS_FA_VALID:
|
||||
scheduled = scheduled & (tl.load(fa_valid_ptr + req) != 0)
|
||||
else:
|
||||
scheduled = scheduled & False
|
||||
tl.store(fa_ptr + last, scheduled.to(fa_ptr.dtype.element_ty))
|
||||
|
||||
|
||||
def eager_pre(meta, n, qsl, sampled, hidden, emb_w, ids, emb, mtp_hidden, step):
|
||||
last = torch.empty(n, dtype=torch.long, device=hidden.device)
|
||||
layer0 = torch.empty(n, dtype=torch.long, device=hidden.device)
|
||||
H = hidden.shape[-1]
|
||||
_pre_kernel[(n,)](
|
||||
meta, n, qsl, sampled, sampled.stride(0), hidden, emb_w, ids, emb, mtp_hidden, step, last, layer0,
|
||||
H=H, BH=min(1024, triton.next_power_of_2(H)),
|
||||
) # fmt: skip
|
||||
return last, layer0
|
||||
|
||||
|
||||
def eager_post(meta, n, last, layer0, frames, eager, codes, codes_out, input_ids, valid_out, fa, fa_valid, vocab):
|
||||
valid = torch.empty(n, dtype=torch.bool, device=last.device)
|
||||
H = eager.shape[-1]
|
||||
Q = codes_out.shape[-1]
|
||||
dummy = valid
|
||||
_post_kernel[(n,)](
|
||||
meta, n, last, layer0, frames, eager, codes, codes_out, input_ids, valid_out, valid,
|
||||
fa if fa is not None else dummy, fa_valid if fa_valid is not None else dummy, int(vocab),
|
||||
H=H, Q=Q, QP=triton.next_power_of_2(Q), BH=min(1024, triton.next_power_of_2(H)),
|
||||
WRITE_FA=fa is not None, HAS_FA_VALID=fa_valid is not None,
|
||||
) # fmt: skip
|
||||
return valid
|
||||
@@ -41,6 +41,7 @@ from vllm_omni.worker_v2.model_states.eager_mtp import EagerMTPState
|
||||
from vllm_omni.worker_v2.model_states.intermediate_buffer import (
|
||||
OmniIntermediateBuffer,
|
||||
)
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -85,6 +86,9 @@ def _make_safe_get_rope(orig_get_rope):
|
||||
return _safe_get_rope
|
||||
|
||||
|
||||
_NO_SEED = object()
|
||||
|
||||
|
||||
class OmniModelState(DefaultModelState):
|
||||
"""Generic Omni ``ModelState`` — works for **all** Omni model stages.
|
||||
|
||||
@@ -146,7 +150,7 @@ class OmniModelState(DefaultModelState):
|
||||
self.have_multimodal_outputs: bool = getattr(model, "have_multimodal_outputs", False)
|
||||
self._decode_preprocess = self._resolve_decode_preprocess(model)
|
||||
self._decode_preprocess_is_identity = bool(getattr(model, "mrv2_decode_preprocess_is_identity", False))
|
||||
self._mtp_generators: dict[str, torch.Generator] = {}
|
||||
self._mtp_generators: dict[str, torch.Generator | None] = {}
|
||||
# Talker's codec_embedding dim may differ from hf_text_config.hidden_size; probe real dim.
|
||||
self._embed_dim = self._get_embed_dim(model, device) if self.has_preprocess else 0
|
||||
|
||||
@@ -167,6 +171,10 @@ class OmniModelState(DefaultModelState):
|
||||
self._mtp_offsets: torch.Tensor | None = None
|
||||
self._mtp_sample_uniforms: torch.Tensor | None = None
|
||||
self._mtp_runner: Any | None = None
|
||||
# Talker stream decode: each request's next frame position, and the
|
||||
# side-stream event the step's PCM output copy must wait on.
|
||||
self._stream_pos: dict[str, int] = {}
|
||||
self._stream_decode_event: torch.cuda.Event | None = None
|
||||
if self._embed_dim > 0 and hasattr(model, "mtp"):
|
||||
max_bs = max_num_reqs
|
||||
self._mtp_input_ids = torch.zeros(max_bs, dtype=torch.long, device=device)
|
||||
@@ -218,6 +226,17 @@ class OmniModelState(DefaultModelState):
|
||||
input_batch, text_hidden, sampled_token_ids, multimodal_outputs, mtp_batch_descriptor_dispatcher
|
||||
)
|
||||
|
||||
def prepare_streaming_audio_output(
|
||||
self, input_batch: InputBatch, req_states: RequestState, outputs: dict[str, Any]
|
||||
) -> StreamingAudioOutput | None:
|
||||
return self._eager_state.prepare_audio_output(input_batch, req_states, outputs)
|
||||
|
||||
def on_requests_finished(self, req_ids: set[str]) -> None:
|
||||
self._eager_state.finish_audio(req_ids)
|
||||
|
||||
def on_request_preempted(self, req_id: str, req_index: int) -> None:
|
||||
self._eager_state.suspend_audio(req_id, req_index)
|
||||
|
||||
def custom_sampler(self, sampler: Any) -> tuple[Any, Any] | None:
|
||||
"""Let the model replace or wrap the MRv2 sampler (``mrv2_custom_sampler``)."""
|
||||
hook = getattr(self.model, "mrv2_custom_sampler", None)
|
||||
@@ -372,6 +391,7 @@ class OmniModelState(DefaultModelState):
|
||||
def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
|
||||
super().add_request(req_index, new_req_data)
|
||||
self.intermediate_buffer.add_request(req_index, new_req_data)
|
||||
self._eager_state.resume_audio(new_req_data.req_id, req_index)
|
||||
self._initialize_upstream_warmup_buffer(req_index, new_req_data.req_id)
|
||||
|
||||
def _initialize_upstream_warmup_buffer(self, req_index: int, req_id: str) -> None:
|
||||
@@ -407,6 +427,7 @@ class OmniModelState(DefaultModelState):
|
||||
req_id = self.intermediate_buffer.buffers[req_index].get("req_id")
|
||||
if req_id is not None:
|
||||
getattr(self, "_mtp_generators", {}).pop(req_id, None)
|
||||
self._stream_pos.pop(req_id, None)
|
||||
getattr(self, "_first_audio_requests", set()).discard(req_id)
|
||||
getattr(self, "_eager_ready", {}).pop(req_index, None)
|
||||
getattr(self, "_eager_settled", {}).pop(req_index, None)
|
||||
@@ -673,6 +694,7 @@ class OmniModelState(DefaultModelState):
|
||||
preprocess_entries: list[tuple[int, int, int, int, dict[str, Any], bool]] = []
|
||||
settled = self._eager_settled if self._eager_fastpath else None
|
||||
settled_rows: list[tuple[int, int, int, str]] = []
|
||||
replay_rows: list[tuple[int, int, str, bool]] = []
|
||||
# Declared identity decode has no per-row updates or hook work.
|
||||
skip_decode_rows = self._decode_preprocess_is_identity and not self._eager_mtp
|
||||
is_prefilling_np = getattr(input_batch, "is_prefilling_np", None) if skip_decode_rows else None
|
||||
@@ -684,6 +706,20 @@ class OmniModelState(DefaultModelState):
|
||||
continue
|
||||
start = int(input_batch.query_start_loc_np[i])
|
||||
n_tok = int(input_batch.num_scheduled_tokens[i])
|
||||
if (
|
||||
self._eager_mtp
|
||||
and self.model.stream_decoder is not None
|
||||
and self._eager_state.replay_inputs(
|
||||
buf["req_id"],
|
||||
req_idx,
|
||||
int(input_batch.num_computed_tokens_np[i]),
|
||||
input_ids[start : start + n_tok],
|
||||
embeds[start : start + n_tok],
|
||||
)
|
||||
):
|
||||
if input_batch.num_computed_prefill_tokens_np[i] + n_tok >= input_batch.prefill_len_np[i]:
|
||||
replay_rows.append((i, req_idx, buf["req_id"], True))
|
||||
continue
|
||||
if settled and n_tok == 1 and settled.get(req_idx) == buf["req_id"]:
|
||||
settled_rows.append((i, req_idx, start, buf["req_id"]))
|
||||
continue
|
||||
@@ -725,7 +761,8 @@ class OmniModelState(DefaultModelState):
|
||||
if not is_prefill
|
||||
or int(info.get("_omni_num_computed_tokens", 0)) + n_tok >= int(info.get("_omni_prompt_len", 0))
|
||||
]
|
||||
+ [(i, req_idx, str(req_id), False) for i, req_idx, _start, req_id in settled_rows],
|
||||
+ [(i, req_idx, str(req_id), False) for i, req_idx, _start, req_id in settled_rows]
|
||||
+ replay_rows,
|
||||
input_ids,
|
||||
)
|
||||
|
||||
@@ -760,7 +797,7 @@ class OmniModelState(DefaultModelState):
|
||||
batch_offsets = None
|
||||
else:
|
||||
# A pageable host tensor would block on the stream here.
|
||||
batch_offsets = torch.as_tensor(starts, device=input_ids.device, dtype=torch.long)
|
||||
batch_offsets = index_to_device(starts, input_ids.device)
|
||||
batch_ids = input_ids.index_select(0, batch_offsets)
|
||||
batch_embeds = embeds.index_select(0, batch_offsets)
|
||||
|
||||
@@ -866,6 +903,9 @@ class OmniModelState(DefaultModelState):
|
||||
prepacked_mtp_inputs=prepacked_mtp_inputs,
|
||||
)
|
||||
|
||||
if self._eager_mtp:
|
||||
self._eager_state.record_inputs(input_batch, embeds)
|
||||
|
||||
def publish_sampled_embeddings(
|
||||
self, input_batch: InputBatch, sampled_token_ids: torch.Tensor
|
||||
) -> tuple[dict[str, Any], torch.cuda.Event] | None:
|
||||
@@ -989,7 +1029,7 @@ class OmniModelState(DefaultModelState):
|
||||
offsets.copy_(query_start_loc[:bsz], non_blocking=True)
|
||||
return offsets
|
||||
|
||||
return torch.as_tensor([start for _i, start, _mtp in mtp_batches], device=device, dtype=torch.long)
|
||||
return index_to_device([start for _i, start, _mtp in mtp_batches], device)
|
||||
|
||||
def _run_batched_mtp(
|
||||
self,
|
||||
@@ -1180,15 +1220,21 @@ class OmniModelState(DefaultModelState):
|
||||
sampling_params: Any,
|
||||
device: torch.device,
|
||||
) -> torch.Generator | None:
|
||||
resolve_seed = getattr(self.model, "get_mtp_seed", None)
|
||||
seed = resolve_seed(sampling_params) if resolve_seed is not None else None
|
||||
if seed is None:
|
||||
return None
|
||||
|
||||
cache = getattr(self, "_mtp_generators", None)
|
||||
if cache is None:
|
||||
cache = {}
|
||||
self._mtp_generators = cache
|
||||
generator = cache.get(req_id, _NO_SEED)
|
||||
if generator is None:
|
||||
# Resolved earlier: this request has no seed.
|
||||
return None
|
||||
if generator is not _NO_SEED and generator.device == device:
|
||||
return generator
|
||||
resolve_seed = getattr(self.model, "get_mtp_seed", None)
|
||||
seed = resolve_seed(sampling_params) if resolve_seed is not None else None
|
||||
if seed is None:
|
||||
cache[req_id] = None
|
||||
return None
|
||||
generator = cache.get(req_id)
|
||||
if generator is None or generator.device != device:
|
||||
generator = torch.Generator(device=device)
|
||||
|
||||
@@ -14,6 +14,7 @@ Extends ``OmniGPUModelRunner`` with:
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from functools import partial
|
||||
from typing import Any, cast
|
||||
|
||||
import numpy as np
|
||||
@@ -37,6 +38,7 @@ from vllm_omni.utils.mm_outputs import partition_flat_payload
|
||||
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||
from vllm_omni.worker_v2.omni_sampler import sample_with_output
|
||||
from vllm_omni.worker_v2.output_snapshot import PackedOutputSnapshot, RequestOutputSnapshot, pack_output_snapshot
|
||||
from vllm_omni.worker_v2.streaming_audio import StreamingAudioOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
_ASYNC_MM_SNAPSHOT_MAX_BUCKETS_PER_SLOT = 64
|
||||
@@ -250,6 +252,13 @@ class OmniARModelRunner(OmniGPUModelRunner):
|
||||
model_runner_output.kv_extracted_req_ids = kv_extracted
|
||||
model_runner_output._async_chunk = bool(getattr(self.model_config, "async_chunk", False))
|
||||
|
||||
prepare_streaming = getattr(self.model_state, "prepare_streaming_audio_output", None)
|
||||
streaming_audio = (
|
||||
prepare_streaming(input_batch, self.req_states, multimodal_outputs)
|
||||
if need_pooler and multimodal_outputs and callable(prepare_streaming)
|
||||
else None
|
||||
)
|
||||
|
||||
# --- Async D2H via OmniAsyncOutput ---
|
||||
materialize_native = self._uses_native_output_materializer()
|
||||
async_output = OmniAsyncOutput(
|
||||
@@ -266,6 +275,7 @@ class OmniARModelRunner(OmniGPUModelRunner):
|
||||
finalize_multimodal=sampling_output.finalize_multimodal,
|
||||
check_ep_fault=self.check_ep_fault,
|
||||
routed_experts=routed_experts,
|
||||
streaming_audio=streaming_audio,
|
||||
extra_multimodal_outputs=extra_outputs,
|
||||
)
|
||||
self._release_multimodal_snapshot(snapshot_slot, async_output.copy_event)
|
||||
@@ -297,6 +307,10 @@ class OmniARModelRunner(OmniGPUModelRunner):
|
||||
def _retain_multimodal_outputs(self, outputs: dict[str, Any]) -> dict[str, Any]:
|
||||
if not bool(getattr(self.model_config, "async_chunk", False)) or not outputs:
|
||||
return outputs
|
||||
if getattr(self.model, "mm_outputs_fresh_per_step", False):
|
||||
# Freshly allocated each step and filled after sampling; nothing
|
||||
# overwrites them before the host copy.
|
||||
return outputs
|
||||
slot_index = self._async_mm_snapshot_cursor
|
||||
if self._async_mm_snapshot_pending[slot_index]:
|
||||
raise RuntimeError("Async multimodal snapshot ring exhausted before sample_tokens().")
|
||||
@@ -516,6 +530,12 @@ def _async_copy_mm_value(
|
||||
for key, val in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
if len(value) > 1 and isinstance(value[0], torch.Tensor) and value[0].device.type == "cpu":
|
||||
first = value[0]
|
||||
if all(val is first for val in value):
|
||||
# One shared per-step host tensor (e.g. every request's sample
|
||||
# rate): one copy serves all entries.
|
||||
return [_async_copy_tensor(first)] * len(value)
|
||||
return [
|
||||
_async_copy_mm_value(
|
||||
val,
|
||||
@@ -679,6 +699,7 @@ class OmniAsyncOutput(AsyncModelRunnerOutput):
|
||||
finalize_multimodal: Any | None = None,
|
||||
check_ep_fault: bool = False,
|
||||
routed_experts: RoutedExpertsTensors | None = None,
|
||||
streaming_audio: StreamingAudioOutput | None = None,
|
||||
extra_multimodal_outputs: tuple[dict[str, Any], torch.cuda.Event] | None = None,
|
||||
):
|
||||
self.model_runner_output = model_runner_output
|
||||
@@ -768,17 +789,31 @@ class OmniAsyncOutput(AsyncModelRunnerOutput):
|
||||
self._hidden_cpu: torch.Tensor | None = None
|
||||
self._mm_cpu: dict[str, Any] = {}
|
||||
self._mm_snapshot: dict[str, Any] = {}
|
||||
self._streaming_audio = (
|
||||
streaming_audio.to_cpu(
|
||||
copy_stream, partial(_async_copy_tensor, copy_stream=copy_stream, pin_memory=pin_memory)
|
||||
)
|
||||
if streaming_audio is not None
|
||||
else None
|
||||
)
|
||||
if self._need_pooler and (self._async_chunk or self._finalize_multimodal is not None):
|
||||
# CUDA graph replay reuses the model's output buffers. Take
|
||||
# ownership directly in pinned host memory on the output copy
|
||||
# stream so deferred finalization never performs a blocking
|
||||
# D2H copy on the runner thread.
|
||||
self._mm_snapshot = _async_copy_mm(
|
||||
multimodal_outputs,
|
||||
self._total_tokens,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
if (
|
||||
streaming_audio is None
|
||||
or self._finalize_multimodal is not None
|
||||
or extra_multimodal_outputs is not None
|
||||
):
|
||||
# PCM owns its request partition. Generic codes/meta are
|
||||
# discarded unless a finalizer or extra payload needs them.
|
||||
self._mm_snapshot = _async_copy_mm(
|
||||
multimodal_outputs,
|
||||
self._total_tokens,
|
||||
copy_stream=copy_stream,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
if extra_multimodal_outputs:
|
||||
# Produced after sampling on the producer stream; copy only
|
||||
# once its completion event has been observed.
|
||||
@@ -856,7 +891,9 @@ class OmniAsyncOutput(AsyncModelRunnerOutput):
|
||||
pooler_client is not None and len(pooler_client) != self._num_reqs
|
||||
):
|
||||
raise ValueError("Model-owned output snapshot does not match the request batch")
|
||||
else:
|
||||
elif self._streaming_audio is None:
|
||||
# In-stage PCM already owns its request partition. Building
|
||||
# generic code payloads here would immediately discard them.
|
||||
pooler_inter, pooler_client = OmniARModelRunner._build_async_chunk_outputs_from_mm(
|
||||
self._mm_snapshot,
|
||||
self._query_start_loc_np,
|
||||
@@ -865,6 +902,8 @@ class OmniAsyncOutput(AsyncModelRunnerOutput):
|
||||
self._total_tokens,
|
||||
self._padded_total_tokens,
|
||||
)
|
||||
if self._streaming_audio is not None:
|
||||
pooler_inter, pooler_client = None, self._streaming_audio.get_output()
|
||||
self.model_runner_output.pooler_output = None if self._async_chunk else pooler_inter
|
||||
self.model_runner_output.inter_stage_outputs = pooler_inter
|
||||
self.model_runner_output.multimodal_outputs = (
|
||||
|
||||
@@ -437,6 +437,10 @@ class OmniGPUModelRunner(GPUModelRunner):
|
||||
capture_first_frame = getattr(self.model, "capture_first_frame_graphs", None)
|
||||
if callable(capture_first_frame):
|
||||
capture_first_frame()
|
||||
capture_stream = getattr(self.model, "capture_stream_decode_graphs", None)
|
||||
if callable(capture_stream):
|
||||
sizes = [int(s) for s in self.model_state._get_mtp_capture_sizes()]
|
||||
capture_stream(sorted(set(sizes + [1])))
|
||||
return result
|
||||
|
||||
def _dispatch_mtp_batch_descriptor(self, num_mtp_reqs: int) -> Any:
|
||||
@@ -746,7 +750,16 @@ class OmniGPUModelRunner(GPUModelRunner):
|
||||
# Request-owned state outlives generation slots and chunk boundaries.
|
||||
# Notify even if the last chunk already released its runner slot.
|
||||
on_finished(finished)
|
||||
finish_state = getattr(self.model_state, "on_requests_finished", None)
|
||||
if finished and callable(finish_state):
|
||||
finish_state(finished)
|
||||
preempted = scheduler_output.preempted_req_ids
|
||||
suspend_state = getattr(self.model_state, "on_request_preempted", None)
|
||||
if preempted and callable(suspend_state):
|
||||
for req_id in preempted - finished:
|
||||
idx = self.req_states.req_id_to_index.get(req_id)
|
||||
if idx is not None:
|
||||
suspend_state(req_id, idx)
|
||||
all_done = finished | preempted if preempted else finished
|
||||
for req_id in all_done:
|
||||
idx = self.req_states.req_id_to_index.get(req_id)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Request-owned PCM accumulation for a stage that decodes audio in place."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from vllm_omni.data_entry_keys import FIRST_AUDIO_REQUIRED_KEY
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioRequest:
|
||||
samples_per_frame: int
|
||||
chunk_frames: int
|
||||
active: bool = True
|
||||
emitted: bool = False
|
||||
direct_first_pending: bool = False
|
||||
first_audio_required: bool = False
|
||||
pending: list[torch.Tensor | np.ndarray] = field(default_factory=list)
|
||||
pending_samples: int = 0
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
def accept_first_audio(self) -> None:
|
||||
with self.lock:
|
||||
if self.active:
|
||||
self.direct_first_pending = True
|
||||
|
||||
def push(self, pcm: torch.Tensor | np.ndarray, ended: bool) -> torch.Tensor | None:
|
||||
with self.lock:
|
||||
if not self.active:
|
||||
return None
|
||||
size = pcm.numel() if isinstance(pcm, torch.Tensor) else pcm.size
|
||||
if self.direct_first_pending:
|
||||
self.direct_first_pending = False
|
||||
if size:
|
||||
pcm = pcm[self.samples_per_frame :]
|
||||
size = max(0, size - self.samples_per_frame)
|
||||
self.emitted = True
|
||||
self.first_audio_required = True
|
||||
if size:
|
||||
self.pending.append(pcm)
|
||||
self.pending_samples += size
|
||||
frames = self.pending_samples // self.samples_per_frame
|
||||
out = None
|
||||
if frames and (ended or not self.emitted or frames >= self.chunk_frames):
|
||||
if all(isinstance(part, np.ndarray) for part in self.pending):
|
||||
array = np.concatenate(self.pending) if len(self.pending) > 1 else self.pending[0]
|
||||
out = torch.from_numpy(array)
|
||||
else:
|
||||
parts = [torch.from_numpy(part) if isinstance(part, np.ndarray) else part for part in self.pending]
|
||||
out = torch.cat(parts) if len(parts) > 1 else parts[0]
|
||||
self.pending.clear()
|
||||
self.pending_samples = 0
|
||||
self.emitted = True
|
||||
if ended:
|
||||
self.active = False
|
||||
self.pending.clear()
|
||||
self.pending_samples = 0
|
||||
return out
|
||||
|
||||
def finish(self) -> None:
|
||||
with self.lock:
|
||||
self.active = False
|
||||
self.pending.clear()
|
||||
self.pending_samples = 0
|
||||
|
||||
|
||||
class StreamingAudioBuffer:
|
||||
def __init__(self, samples_per_frame: int, chunk_frames: int) -> None:
|
||||
self.samples_per_frame = samples_per_frame
|
||||
self.chunk_frames = chunk_frames
|
||||
self.requests: dict[str, AudioRequest] = {}
|
||||
|
||||
def add(self, request_id: str) -> AudioRequest:
|
||||
if request_id not in self.requests:
|
||||
self.requests[request_id] = AudioRequest(self.samples_per_frame, self.chunk_frames)
|
||||
return self.requests[request_id]
|
||||
|
||||
def finish(self, request_ids: Iterable[str]) -> None:
|
||||
for request_id in request_ids:
|
||||
state = self.requests.pop(request_id, None)
|
||||
if state is not None:
|
||||
# Already queued outputs retain this object, never a fresh lookup
|
||||
# by ID. A late copy cannot resurrect a cancelled request.
|
||||
state.finish()
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamingAudioOutput:
|
||||
wav: torch.Tensor
|
||||
valid: torch.Tensor
|
||||
sample_rate: torch.Tensor
|
||||
event: torch.cuda.Event | None
|
||||
query_start_loc: np.ndarray
|
||||
requests: list[AudioRequest | None]
|
||||
length_end: np.ndarray
|
||||
|
||||
def to_cpu(
|
||||
self,
|
||||
copy_stream: torch.cuda.Stream,
|
||||
copy_tensor: Callable[[torch.Tensor], torch.Tensor],
|
||||
) -> StreamingAudioOutput:
|
||||
if self.event is not None:
|
||||
copy_stream.wait_event(self.event)
|
||||
return StreamingAudioOutput(
|
||||
copy_tensor(self.wav),
|
||||
copy_tensor(self.valid),
|
||||
self.sample_rate,
|
||||
None,
|
||||
self.query_start_loc,
|
||||
self.requests,
|
||||
self.length_end,
|
||||
)
|
||||
|
||||
def get_output(self) -> list[dict[str, torch.Tensor] | None]:
|
||||
valid = self.valid.numpy().astype(bool)
|
||||
# Each D2H batch owns its host allocation. NumPy slices retain that
|
||||
# backing tensor while a request accumulates PCM across steps; no
|
||||
# per-request Torch views are needed until an audio chunk is emitted.
|
||||
# Keep the tensor path for dtypes NumPy cannot represent (e.g. BF16).
|
||||
wav = self.wav.numpy() if self.wav.dtype in (torch.float16, torch.float32, torch.float64) else None
|
||||
output: list[dict[str, torch.Tensor] | None] = []
|
||||
required_marker = None
|
||||
for i, state in enumerate(self.requests):
|
||||
payload = None
|
||||
start, end = self.query_start_loc[i : i + 2]
|
||||
if state is not None and end > start:
|
||||
if wav is not None:
|
||||
if end - start == 1:
|
||||
pcm = wav[start:end].reshape(-1) if valid[start] else wav[0:0].reshape(-1)
|
||||
else:
|
||||
pcm = wav[start:end][valid[start:end]].reshape(-1)
|
||||
elif end - start == 1:
|
||||
pcm = self.wav[start:end].reshape(-1) if valid[start] else self.wav.new_empty(0)
|
||||
else:
|
||||
pcm = self.wav[start:end][torch.from_numpy(valid[start:end])].reshape(-1)
|
||||
ended = bool(self.length_end[i]) or not valid[end - 1]
|
||||
chunk = state.push(pcm, ended)
|
||||
if chunk is not None:
|
||||
payload = {"model_outputs": chunk, "sr": self.sample_rate}
|
||||
if state.first_audio_required and (chunk is not None or ended):
|
||||
if required_marker is None:
|
||||
required_marker = torch.tensor(True)
|
||||
if payload is None:
|
||||
payload = {}
|
||||
payload[FIRST_AUDIO_REQUIRED_KEY] = required_marker
|
||||
output.append(payload)
|
||||
return output
|
||||
Reference in new issue
Block a user