[Feat][Perf][AuK]Support Online Serving and DiT CUDAGraph (#7469)

Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-09-30 00:18:38 +00:00
1 parent 3a28882946
commit 35cf6ac355
22 files changed
+1473 -38

No files matched your search

+24 -3
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Universal TTS benchmark CLI for vllm-omni.
Runs ``vllm bench serve --omni`` with model-aware defaults loaded from
@@ -87,6 +90,7 @@ def build_bench_args(
served_model_name: str | None = None,
num_warmups: int = 2,
request_seed: int | None = None,
duration_seconds: float | None = None,
) -> list[str]:
"""Build the ``vllm bench serve --omni`` command for one (task, concurrency) run."""
dataset_name = _TASK_TO_DATASET[task]
@@ -95,7 +99,10 @@ def build_bench_args(
task_extra_body: dict[str, Any] = dict((model_cfg.get("task_extra_body") or {}).get(task) or {})
if request_seed is not None:
task_extra_body["seed"] = request_seed
if duration_seconds is not None:
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
raise ValueError("duration_seconds must be finite and positive")
task_extra_body["duration_seconds"] = duration_seconds
# Resolve dataset path
if dataset_path:
resolved_dataset_path = dataset_path
@@ -194,7 +201,7 @@ def print_summary_table(results: list[dict[str, Any]]) -> None:
if not results:
return
header = (
f"{'Task':<16} {'Concurrency':>11} {'RTF mean':>10} "
f"{'Task':<16} {'Task Type':<12} {'Concurrency':>11} {'RTF mean':>10} "
f"{'TTFP (ms)':>10} {'Throughput':>12} {'WER':>7} {'SIM':>7} {'UTMOS':>7}"
)
print(f"\n{'=' * len(header)}")
@@ -204,6 +211,7 @@ def print_summary_table(results: list[dict[str, Any]]) -> None:
print("-" * len(header))
for r in results:
task = r.get("_task", "?")
task_type = r.get("_task_type", "-")
conc = r.get("_concurrency", "?")
rtf = r.get("mean_audio_rtf", float("nan"))
ttfp = r.get("mean_audio_ttfp_ms", float("nan"))
@@ -216,7 +224,7 @@ def print_summary_table(results: list[dict[str, Any]]) -> None:
return f"{v:.{digits}f}" if not math.isnan(v) else " n/a"
print(
f"{task:<16} {str(conc):>11} {fmt(rtf):>10} {fmt(ttfp, 0):>10} "
f"{task:<16} {task_type:<12} {str(conc):>11} {fmt(rtf):>10} {fmt(ttfp, 0):>10} "
f"{fmt(throughput):>12} {fmt(wer):>7} {fmt(sim):>7} {fmt(utmos):>7}"
)
print("=" * len(header))
@@ -249,6 +257,12 @@ def main() -> None:
help="Number of prompts per run. If one value, applied to all concurrency levels.",
)
parser.add_argument("--num-warmups", type=int, default=2, help="Warmup requests before each measured run")
parser.add_argument(
"--duration-seconds",
type=float,
default=None,
help="Target audio duration per request (AuK defaults to 5 seconds). Independent of --output-len.",
)
parser.add_argument(
"--request-seed",
type=int,
@@ -277,6 +291,8 @@ def main() -> None:
if args.num_warmups < 0:
parser.error("--num-warmups cannot be negative")
if args.duration_seconds is not None and (not math.isfinite(args.duration_seconds) or args.duration_seconds <= 0):
parser.error("--duration-seconds must be finite and positive")
model_configs = load_model_configs(Path(args.model_configs))
if args.model not in model_configs:
@@ -333,11 +349,16 @@ def main() -> None:
served_model_name=args.served_model_name,
num_warmups=args.num_warmups,
request_seed=args.request_seed,
duration_seconds=args.duration_seconds,
)
result = run_one_benchmark(cmd)
if result is not None:
result["_task"] = task
result["_concurrency"] = concurrency
task_body = model_cfg.get("task_extra_body", {}).get(task, {})
task_type = task_body.get("task_type")
if task_type is not None:
result["_task_type"] = task_type
all_results.append(result)
# Persist the metadata so plot_results.py can pick it up.
if args.output_dir and result_filename:
+16
View File
@@ -7,6 +7,22 @@
# is tracked here.
models:
tencent/AuK: &auk
supported_tasks: [default_voice, voice_clone]
backend: openai-audio-speech
endpoint: /v1/audio/speech
trust_remote_code: true
task_extra_body:
default_voice:
duration_seconds: 5.0
voice: default
task_type: CustomVoice
voice_clone:
duration_seconds: 5.0
voice: default
task_type: Base
tencent/AuK-Flash: *auk
BreezeBlue/Breeze-TTS-2:
supported_tasks: [default_voice, voice_design, voice_clone]
backend: openai-audio-speech
@@ -16,6 +16,7 @@ For the full list of supported architectures across all modalities, see
| Model | HuggingFace repo | Voice cloning | Streaming | Voice presets / upload | Gradio demo |
| --- | --- | --- | --- | --- | --- |
| AuK / AuK-Flash | assembled local bundle | ✓ (`ref_audio`) | HTTP audio after full generation | — | — |
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | — |
| Fish Speech S2 Pro | `fishaudio/s2-pro` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | ✓ |
| Gepard-1.0 | `nineninesix/gepard-1.0` | — (zero-shot default voice) | ✓ (PCM / WAV stream) | `default` only | — |
@@ -99,6 +100,41 @@ For full request-shape documentation (all parameters, response formats, error co
---
## AuK / AuK-Flash
AuK serves speech generation and reference-voice synthesis at 24 kHz. Assemble a local bundle before serving:
```bash
python tools/prepare_auk_checkpoint.py \
--auk-dir ckpts/AuK --qwen-dir ckpts/Qwen2.5-Omni-3B --out ckpts/auk-omni
MODEL=ckpts/auk-omni bash examples/online_serving/text_to_speech/auk/run_server.sh
```
AuK accepts the complete instruction after you fill in the [local recipe template](../../../../recipes/Tencent/AuK-H100.md#supported-model-contract) or the [official AuK cookbook](https://github.com/Tencent-Hunyuan/AuK/blob/main/docs/COOKBOOK.md). Pass it in `instructions` and leave `input` empty. For instruct TTS, fill in `Generate speech based on the following description: "{voice description}". The content to speak is: "{text}".`; for zero-shot TTS, editing, enhancement and separation, use the corresponding complete instruction from the cookbook. Set `duration_seconds` for text-only generation; with `ref_audio`, omitting it keeps the source length.
For compatibility with clients that can only send `input`, AuK treats an input-only request as the complete instruction and logs a recommendation to move it to `instructions` with `input=""`.
`extra_params` accepts `num_inference_steps`, `guidance_scale`, `sway`, `t_grid`, and `vae_sample`; `seed` is a top-level field.
```bash
python examples/online_serving/text_to_speech/auk/speech_client.py \
--model ckpts/auk-omni \
--instructions 'Generate speech based on the following description: "A calm young woman speaking warmly and slowly.". The content to speak is: "Welcome back, how was your day?".' \
--duration-seconds 3 --seed 7
```
Complete-instruction editing also does not need a separate text argument:
```bash
python examples/online_serving/text_to_speech/auk/speech_client.py \
--model ckpts/auk-omni --instructions "Keep pure speech voice, remove noise and reverberation." \
--ref-audio noisy.wav --seed 7 --output clean.wav
```
The client accepts local reference files, URLs, and data URLs. `--stream` writes signed 16-bit 24 kHz PCM after the complete waveform is ready. See `speech_client.py --help` for editing and sampling options.
---
## CosyVoice3
2-stage TTS (`talker` + flow-matching `code2wav`) at 24 kHz. Voice cloning only — every request needs `ref_audio` + `ref_text`; there are no built-in voice presets.
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# MODEL=/models/auk-omni bash examples/online_serving/text_to_speech/auk/run_server.sh
set -euo pipefail
: "${MODEL:?Set MODEL to the assembled AuK or AuK-Flash checkpoint directory}"
exec vllm serve "$MODEL" --omni --host "${HOST:-127.0.0.1}" --port "${PORT:-8091}" "$@"
@@ -0,0 +1,103 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""AuK speech generation and audio editing through /v1/audio/speech.
See the online TTS user guide for checkpoint preparation and serving. Local
reference audio is sent as a data URL; it need not exist on the server.
"""
from __future__ import annotations
import argparse
import base64
import mimetypes
import os
from pathlib import Path
import httpx
def reference_audio(value: str) -> str:
if value.startswith(("http://", "https://", "data:")):
return value
path = Path(value).expanduser()
mime = mimetypes.guess_type(path.name)[0] or "audio/wav"
return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api-base", default="http://localhost:8091")
parser.add_argument("--api-key", default=os.environ.get("OPENAI_API_KEY", "EMPTY"))
parser.add_argument("--model", required=True, help="Model name or assembled bundle path used by the server")
parser.add_argument(
"--instructions",
required=True,
help=(
"Complete AuK instruction after filling the cookbook template "
"(https://github.com/Tencent-Hunyuan/AuK/blob/main/docs/COOKBOOK.md)"
),
)
parser.add_argument("--ref-audio", help="Reference/source audio: local file, HTTP URL or data URL")
parser.add_argument("--duration-seconds", type=float, help="Target duration; defaults to source length with audio")
parser.add_argument("--seed", type=int)
parser.add_argument("--num-inference-steps", type=int)
parser.add_argument("--guidance-scale", type=float)
parser.add_argument("--sway", type=float)
parser.add_argument("--t-grid", type=float, nargs="+", help="Explicit increasing Euler time grid")
parser.add_argument("--vae-sample", action="store_true", help="Sample the VAE posterior instead of taking its mean")
parser.add_argument("--stream", action="store_true", help="Receive raw PCM over HTTP after full generation")
parser.add_argument("--response-format", choices=["wav", "pcm", "flac", "mp3", "opus"], default="wav")
parser.add_argument("--output", type=Path, help="Output file; defaults to output.wav or output.pcm")
parser.add_argument("--timeout", type=float, default=300)
args = parser.parse_args()
if args.duration_seconds is None and args.ref_audio is None:
parser.error("--duration-seconds is required without --ref-audio")
response_format = "pcm" if args.stream else args.response_format
payload = {
"model": args.model,
# OpenAI's shared Speech schema still declares input, while AuK's
# canonical request carries all content in the complete instruction.
"input": "",
"voice": "default",
"response_format": response_format,
}
for name in ("duration_seconds", "seed"):
value = getattr(args, name)
if value is not None:
payload[name] = value
payload["instructions"] = args.instructions
if args.ref_audio:
payload["ref_audio"] = reference_audio(args.ref_audio)
extra = {
name: getattr(args, name)
for name in ("num_inference_steps", "guidance_scale", "sway", "t_grid")
if getattr(args, name) is not None
}
if args.vae_sample:
extra["vae_sample"] = True
if extra:
payload["extra_params"] = extra
if args.stream:
payload.update(stream=True, stream_format="audio")
output = args.output or Path(f"output.{response_format}")
headers = {"Authorization": f"Bearer {args.api_key}"}
with httpx.Client(timeout=args.timeout) as client:
with client.stream(
"POST", f"{args.api_base.rstrip('/')}/v1/audio/speech", json=payload, headers=headers
) as response:
if response.is_error:
response.read()
raise RuntimeError(f"Speech API returned {response.status_code}: {response.text}")
with output.open("wb") as handle:
for chunk in response.iter_bytes():
handle.write(chunk)
print(f"Saved {output} ({output.stat().st_size} bytes). Native audio: 24000 Hz, mono.")
if response_format == "pcm":
print("PCM format: signed 16-bit little-endian, 24000 Hz, mono.")
if __name__ == "__main__":
main()
+87
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for the universal benchmarks/tts/bench_tts.py CLI."""
from __future__ import annotations
@@ -51,6 +54,73 @@ def test_load_model_configs(model_configs_path: Path) -> None:
assert configs["test/ModelA"]["supported_tasks"] == ["voice_clone", "default_voice"]
@pytest.mark.parametrize("model", ["tencent/AuK", "tencent/AuK-Flash"])
@pytest.mark.parametrize("task", ["default_voice", "voice_clone"])
def test_auk_benchmark_duration_and_local_bundle(model, task):
config = bench_tts.load_model_configs(bench_tts._DEFAULT_MODEL_CONFIGS)[model]
cmd = bench_tts.build_bench_args(
host="localhost",
port=8000,
model=model,
task=task,
model_cfg=config,
locale="en",
num_prompts=20,
concurrency=8,
dataset_path="/data/seed-tts",
wer_eval=False,
output_dir=None,
result_filename=None,
extra_cli_args=[],
served_model_name="/models/auk",
duration_seconds=3.5,
request_seed=7,
)
assert cmd[cmd.index("--model") + 1] == "/models/auk"
body = json.loads(cmd[cmd.index("--extra-body") + 1])
assert body == {
"voice": "default",
"duration_seconds": 3.5,
"seed": 7,
"task_type": "CustomVoice" if task == "default_voice" else "Base",
}
assert config["task_extra_body"][task]["duration_seconds"] == 5.0
@pytest.mark.parametrize(
("task", "expected_task_type"),
[
("default_voice", "CustomVoice"),
("voice_clone", "Base"),
],
)
def test_auk_task_type_survives_served_model_alias(task, expected_task_type):
model = "tencent/AuK-Flash"
served_model = "models/flash"
config = bench_tts.load_model_configs(bench_tts._DEFAULT_MODEL_CONFIGS)[model]
cmd = bench_tts.build_bench_args(
host="localhost",
port=8000,
model=model,
task=task,
model_cfg=config,
locale="en",
num_prompts=1,
concurrency=1,
dataset_path="/data/seed-tts",
wer_eval=False,
output_dir=None,
result_filename=None,
extra_cli_args=[],
served_model_name=served_model,
)
assert cmd[cmd.index("--model") + 1] == served_model
extra_body = json.loads(cmd[cmd.index("--extra-body") + 1])
assert extra_body["task_type"] == expected_task_type
assert "_vllm_omni_benchmark_model" not in extra_body
def test_indextts25_is_registered_in_shared_model_configs() -> None:
config_path = Path(bench_tts.__file__).with_name("model_configs.yaml")
config = bench_tts.load_model_configs(config_path)["IndexTeam/IndexTTS-2.5"]
@@ -173,3 +243,20 @@ def test_unsupported_task_exits(model_configs_path: Path, capsys: pytest.Capture
)
with pytest.raises(SystemExit):
bench_tts.main()
def test_summary_table_includes_task_type(capsys: pytest.CaptureFixture) -> None:
bench_tts.print_summary_table(
[
{
"_task": "voice_clone",
"_task_type": "Base",
"_concurrency": 1,
}
]
)
output = capsys.readouterr().out
assert "Task Type" in output
assert "voice_clone" in output
assert "Base" in output
@@ -0,0 +1,225 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Single-request eager and CUDA-graph AuK DiT sampling equivalence."""
import pytest
import torch
from vllm_omni.diffusion.models.auk.auk_transformer import AuKTransformer, sample_latents
from vllm_omni.diffusion.models.auk.cudagraph_wrapper import AuKCUDAGraphWrapper
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_dit(device: str) -> AuKTransformer:
torch.manual_seed(12)
return (
AuKTransformer(
dim=32,
heads=2,
dim_head=16,
ff_mult=2,
latent_dim=4,
text_hidden_dim=8,
num_layers=2,
num_single_layers=2,
)
.eval()
.to(device)
)
def _sample_inputs(device: str, offset: float = 0.0) -> dict[str, torch.Tensor]:
generator = torch.Generator(device=device).manual_seed(42)
return {
"text": torch.randn(1, 7, 8, generator=generator, device=device) + offset,
"c_mask": torch.ones(1, 7, dtype=torch.bool, device=device),
"ref": torch.randn(1, 4, 4, generator=generator, device=device) - offset,
"ref_mask": torch.ones(1, 4, dtype=torch.bool, device=device),
}
@torch.inference_mode()
@pytest.mark.parametrize("cfg_strength", [0.0, 2.0])
def test_single_request_graph_wrapper_cpu_falls_back_to_eager(cfg_strength: float, mocker) -> None:
dit = _make_dit("cpu")
wrapper = AuKCUDAGraphWrapper(dit)
run_spy = mocker.spy(wrapper, "_run")
run_cfg_spy = mocker.spy(wrapper, "_run_cfg")
capture_spy = mocker.spy(wrapper, "_capture")
inputs = _sample_inputs("cpu")
common = dict(
**inputs,
gen_frames=9,
t_grid=[0.0, 0.4, 1.0],
cfg_strength=cfg_strength,
)
eager = sample_latents(dit, **common, generator=torch.Generator().manual_seed(7))
graph = sample_latents(dit, **common, generator=torch.Generator().manual_seed(7), sampler=wrapper)
torch.testing.assert_close(graph, eager)
if cfg_strength >= 1e-5:
run_spy.assert_not_called()
assert run_cfg_spy.call_count == 2
else:
assert run_spy.call_count == 2
run_cfg_spy.assert_not_called()
capture_spy.assert_not_called()
assert not wrapper._cache
def test_graph_inputs_use_bounded_length_buckets() -> None:
wrapper = AuKCUDAGraphWrapper(_make_dit("cpu"))
x = torch.ones(1, 65, 4)
text = torch.ones(1, 65, 8)
c_mask = torch.ones(1, 65, dtype=torch.bool)
ref = torch.ones(1, 51, 4)
ref_mask = torch.ones(1, 51, dtype=torch.bool)
padded = wrapper._bucket_inputs(x, text, c_mask, ref, ref_mask)
assert padded[0].shape == (1, 128, 4)
assert padded[1].shape == (1, 128)
assert padded[2].shape == (1, 128, 8)
assert padded[3].shape == (1, 128)
assert padded[4].shape == (1, 100, 4)
assert padded[5].shape == (1, 100)
assert [mask.sum().item() for mask in (padded[1], padded[3], padded[5])] == [65, 65, 51]
assert wrapper._key(padded[0], padded[2], padded[4], False) == (128, 128, 100, False)
assert wrapper.max_graphs == 32
def test_full_graph_cache_retires_as_one_generation() -> None:
wrapper = AuKCUDAGraphWrapper(_make_dit("cpu"), max_graphs=2)
first = (64, 64, 50, False)
second = (128, 64, 50, False)
wrapper._cache[first] = object()
wrapper._retire_graph_generation_if_full()
assert list(wrapper._cache) == [first]
wrapper._cache[second] = object()
wrapper._retire_graph_generation_if_full()
assert not wrapper._cache
@torch.inference_mode()
@pytest.mark.parametrize("cfg_strength", [0.0, 2.0])
def test_bucket_padding_preserves_real_frame_outputs(cfg_strength: float) -> None:
dit = _make_dit("cpu")
wrapper = AuKCUDAGraphWrapper(dit)
inputs = _sample_inputs("cpu")
x = torch.randn(1, 9, 4)
timestep = torch.tensor(0.4)
cfg = torch.tensor(cfg_strength)
if cfg_strength >= 1e-5:
eager = wrapper._run_cfg(
x,
None,
inputs["text"],
inputs["c_mask"],
inputs["ref"],
inputs["ref_mask"],
timestep,
cfg_strength=cfg,
)
else:
eager = wrapper._run(
x,
None,
inputs["text"],
inputs["c_mask"],
inputs["ref"],
inputs["ref_mask"],
timestep,
)
dit.clear_cache()
bucketed = wrapper._bucket_inputs(x, inputs["text"], inputs["c_mask"], inputs["ref"], inputs["ref_mask"])
if cfg_strength >= 1e-5:
padded = wrapper._run_cfg(*bucketed, timestep, cfg_strength=cfg)
else:
padded = wrapper._run(*bucketed, timestep)
dit.clear_cache()
torch.testing.assert_close(padded[:, : x.shape[1]], eager)
@pytest.mark.cuda
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA graph replay requires CUDA")
@torch.inference_mode()
@pytest.mark.parametrize("cfg_strength", [0.0, 2.0])
def test_single_request_graph_replay_matches_eager_and_updates_inputs(cfg_strength: float) -> None:
dit = _make_dit("cuda")
wrapper = AuKCUDAGraphWrapper(dit)
for offset in (0.0, 0.25):
inputs = _sample_inputs("cuda", offset)
common = dict(
**inputs,
gen_frames=9,
t_grid=[0.0, 0.4, 1.0],
cfg_strength=cfg_strength,
)
eager = sample_latents(dit, **common, generator=torch.Generator(device="cuda").manual_seed(7))
graph = sample_latents(dit, **common, generator=torch.Generator(device="cuda").manual_seed(7), sampler=wrapper)
torch.testing.assert_close(graph, eager, atol=3e-6, rtol=3e-5)
bucketed = wrapper._bucket_inputs(
torch.empty(1, 9, 4, device="cuda"),
inputs["text"],
inputs["c_mask"],
inputs["ref"],
inputs["ref_mask"],
)
key = wrapper._key(bucketed[0], bucketed[2], bucketed[4], cfg_strength >= 1e-5)
assert key in wrapper._cache
entry = wrapper._cache[key]
torch.testing.assert_close(entry.static_x_mask, bucketed[1])
torch.testing.assert_close(entry.static_text, bucketed[2])
torch.testing.assert_close(entry.static_c_mask, bucketed[3])
torch.testing.assert_close(entry.static_ref, bucketed[4])
torch.testing.assert_close(entry.static_ref_mask, bucketed[5])
torch.testing.assert_close(entry.static_timestep, torch.tensor(0.4, device="cuda"))
assert len(wrapper._cache) == 1
@pytest.mark.cuda
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA graph replay requires CUDA")
@torch.inference_mode()
def test_graph_capture_failure_is_propagated(mocker) -> None:
dit = _make_dit("cuda")
wrapper = AuKCUDAGraphWrapper(dit)
capture = mocker.patch.object(wrapper, "_capture", side_effect=RuntimeError("capture failed"))
eager = mocker.spy(wrapper, "_run")
inputs = _sample_inputs("cuda")
common = dict(
**inputs,
gen_frames=9,
t_grid=[0.0, 0.4, 1.0],
cfg_strength=0.0,
)
with pytest.raises(RuntimeError, match="capture failed"):
sample_latents(
dit,
**common,
generator=torch.Generator(device="cuda").manual_seed(7),
sampler=wrapper,
)
result = sample_latents(
dit,
**common,
generator=torch.Generator(device="cuda").manual_seed(7),
sampler=wrapper,
)
assert result.shape == (1, 9, 4)
assert capture.call_count == 1
assert eager.call_count == 2
assert not wrapper.enabled
assert not wrapper._cache
@@ -227,13 +227,16 @@ def build_pipeline(tmp_path, monkeypatch):
monkeypatch.setattr(pipeline_auk, "AuKTransformer", _StubTransformer)
monkeypatch.setattr(pipeline_auk, "AuKVAE", _StubVAE)
def _build(variant: str = "base") -> tuple[AuKPipeline, list[dict[str, Any]]]:
def _build(
variant: str = "base", *, model_config: dict[str, Any] | None = None
) -> tuple[AuKPipeline, list[dict[str, Any]]]:
calls: list[dict[str, Any]] = []
monkeypatch.setattr(pipeline_auk, "sample_latents", _stub_sampler(calls))
od_config = OmniDiffusionConfig(
model=str(_write_checkpoint(tmp_path, variant)),
dtype=torch.float32,
model_class_name="AuKPipeline",
model_config=model_config or {},
)
return AuKPipeline(od_config=od_config), calls
@@ -274,6 +277,20 @@ def _silence(seconds: float, sample_rate: int = SAMPLE_RATE) -> tuple[np.ndarray
@pytest.mark.core_model
@pytest.mark.cpu
class TestRequestParsing:
@pytest.mark.parametrize(
"model_config, expected_slots",
[({}, 32), ({"max_dit_graphs": 1}, 1), ({"max_dit_graphs": 3}, 3), ({"max_dit_graphs": 64}, 64)],
)
def test_max_dit_graphs_reaches_lazy_cache(self, build_pipeline, model_config, expected_slots):
pipeline, _ = build_pipeline(model_config=model_config)
assert pipeline.cudagraph_wrapper.max_graphs == expected_slots
assert not pipeline.cudagraph_wrapper._cache
@pytest.mark.parametrize("max_dit_graphs", [0, -1, True, False, 1.5, "3", None])
def test_invalid_max_dit_graphs_is_rejected(self, build_pipeline, max_dit_graphs):
with pytest.raises(ValueError, match="AuK max_dit_graphs must be a positive integer"):
build_pipeline(model_config={"max_dit_graphs": max_dit_graphs})
def test_pipeline_declares_audio_output(self, build_pipeline):
pipeline, _ = build_pipeline()
@@ -299,6 +316,7 @@ class TestRequestParsing:
assert calls[0]["ref"].shape == (1, 100, LATENT_DIM)
assert calls[0]["ref_mask"].shape == (1, 100)
assert calls[0]["c_mask"].shape == (1, 8)
assert calls[0]["sampler"] is pipeline.cudagraph_wrapper
assert outputs[0].output.shape == (300 * HOP,)
assert outputs[0].output.dtype is torch.float32
+85
View File
@@ -0,0 +1,85 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""AuK Speech API E2E coverage for the encoder -> diffusion deployment."""
from __future__ import annotations
import io
import os
import numpy as np
import pytest
import requests
import soundfile as sf
from tests.helpers.mark import hardware_test
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
MODEL = os.environ.get("VLLM_OMNI_AUK_MODEL_DIR")
pytestmark = [
pytest.mark.slow,
pytest.mark.tts,
pytest.mark.skipif(not MODEL, reason="set VLLM_OMNI_AUK_MODEL_DIR to an assembled AuK checkpoint"),
]
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("reference", [False, True], ids=["instruct", "voice-clone"])
@pytest.mark.parametrize("stream", [False, True], ids=["wav", "streaming-pcm"])
@pytest.mark.parametrize(
"omni_server",
[
pytest.param(
OmniServerParams(
model=MODEL or ".",
stage_config_path=get_deploy_config_path("auk.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="auk",
)
],
indirect=True,
)
def test_auk_speech_api_request(omni_server, reference, stream) -> None:
"""The adapter accepts Speech API requests and returns bounded 24 kHz WAVs."""
def generate(duration: float) -> int:
extra = {}
if reference:
extra["ref_audio"] = get_asset_path("cosyvoice3/zero_shot_prompt.wav", as_data_url=True)
# Match the shared seed-tts benchmark request envelope.
extra["ref_text"] = "Reference transcript is not required by AuK."
response = requests.post(
f"http://{omni_server.host}:{omni_server.port}/v1/audio/speech",
json={
"model": omni_server.model,
"input": "",
"instructions": (
"Say the following with the same voice: 'Hello, this is an AuK speech API test.'"
if reference
else 'Generate speech based on the following description: "A clear, natural voice.". '
'The content to speak is: "Hello, this is an AuK speech API test.".'
),
"voice": "default",
"duration_seconds": duration,
"seed": 7,
"response_format": "pcm" if stream else "wav",
"stream": stream,
"stream_format": "audio" if stream else None,
**extra,
},
timeout=300,
)
assert response.status_code == 200, response.text
if stream:
waveform = np.frombuffer(response.content, dtype="<i2").astype(np.float32) / 32768
else:
waveform, sample_rate = sf.read(io.BytesIO(response.content), dtype="float32")
assert sample_rate == 24_000
assert np.isfinite(waveform).all()
assert np.max(np.abs(waveform)) > 0
return int(np.asarray(waveform).reshape(-1).shape[0])
assert generate(2.0) == 100 * 480
@@ -5367,6 +5367,9 @@ class TestTTSAsyncOffloading:
adapter_model_type = "adapter_dummy_tts"
class FakeAdapter:
def normalize(self, request):
return
def validate(self, request):
return None
@@ -8,11 +8,22 @@ Pure-Python registry/resolution logic; no model or GPU resources are loaded.
import asyncio
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
import torch
from vllm.sampling_params import SamplingParams
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.engine.cfg_companion_tracker import CfgCompanionTracker
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
from vllm_omni.engine.stage_pool import StagePool
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.tts_adapters import (
TTS_ADAPTER_REGISTRY,
ARTTSAdapter,
@@ -22,6 +33,7 @@ from vllm_omni.entrypoints.openai.tts_adapters import (
detect_tts_model_type,
resolve_adapter,
)
from vllm_omni.entrypoints.openai.tts_adapters.auk import AuKAdapter
from vllm_omni.entrypoints.openai.tts_adapters.base import TTSCapabilities, resolve_stage_model_path
from vllm_omni.entrypoints.openai.tts_adapters.covo_audio import CovoAudioAdapter
from vllm_omni.entrypoints.openai.tts_adapters.higgs_audio_v2 import HiggsAudioV2Adapter
@@ -42,10 +54,12 @@ from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import (
)
from vllm_omni.entrypoints.openai.tts_adapters.step_audio2 import StepAudio2Adapter
from vllm_omni.entrypoints.openai.tts_adapters.voxtral import VoxtralTTSAdapter
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.indextts2 import prompt_utils
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
INDEXTTS25_TOKENIZER_FILE,
)
from vllm_omni.model_executor.stage_input_processors.auk import encoder2dit
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -70,6 +84,7 @@ EXPECTED_MODEL_TYPES = {
"step_audio2",
"indextts2",
"indextts2_5",
"auk",
"gepard",
}
@@ -99,6 +114,314 @@ def test_resolve_unknown_returns_none():
assert resolve_adapter(None) is None
@pytest.fixture
def auk_adapter(mocker):
server = mocker.Mock(spec=OmniOpenAIServingSpeech)
server._max_instructions_length = 4096
server._validate_ref_audio_format.return_value = None
server._resolve_ref_audio = AsyncMock(return_value=([0.1] * 480, 24000, "key"))
return AuKAdapter(SpeechServingContext(server=server))
def test_auk_adapter_detection(mocker):
assert detect_tts_model_type("encoder", "AuKForConditionalGeneration") == "auk"
# ``encoder`` is shared with MiniMax H3, so AuK must not claim the stage key.
assert AuKAdapter.stage_keys == frozenset()
assert AuKAdapter.arch_identifies_entry_stage
assert detect_tts_model_type("encoder", "MiniMaxH3Encoder") is None
assert detect_tts_model_type("encoder", None) is None
server = mocker.Mock(spec=OmniOpenAIServingSpeech)
server._diffusion_mode = False
server._tts_model_type = detect_tts_model_type("encoder", "AuKForConditionalGeneration")
server._adapter = None
server.engine_client = SimpleNamespace()
adapter = OmniOpenAIServingSpeech._get_tts_adapter(server)
assert isinstance(adapter, AuKAdapter)
server._drop_shadowing_uploads.assert_called_once_with()
def test_auk_source_length_default_and_complete_instruction(auk_adapter):
request = OpenAICreateSpeechRequest(
input="",
ref_audio="reference.wav",
ref_text="Accepted transcript",
instructions="Change the pitch by one semitone.",
extra_params={"sway": -0.5, "t_grid": [0, 0.25, 1], "vae_sample": True},
)
assert auk_adapter.validate(request) is None
prepared = asyncio.run(auk_adapter.build(request, [], True))
assert "Change the pitch by one semitone." in prepared.prompt["prompt"]
assert "content to speak" not in prepared.prompt["prompt"]
knobs = prepared.prompt["additional_information"]["auk"]
assert knobs["gen_seconds"] is None
assert knobs["vae_sample"] is True
assert knobs["t_grid"] == [0, 0.25, 1]
auk_adapter.ctx.server._resolve_ref_audio.assert_awaited_once_with("reference.wav")
@pytest.mark.parametrize(
"t_grid",
[
[],
[0.0],
[0.0, 0.0],
[0.5, 0.25],
[0.0, float("nan")],
[0.0, float("inf")],
[0.0, "invalid"],
"0,1",
],
)
def test_auk_rejects_invalid_t_grid_before_dispatch(auk_adapter, t_grid):
request = OpenAICreateSpeechRequest(
input="Say the following: 'target text'",
duration_seconds=2,
extra_params={"t_grid": t_grid},
)
error = auk_adapter.validate(request)
assert error == "AuK extra_params.t_grid must contain at least two finite, strictly increasing values"
auk_adapter.ctx.server._resolve_ref_audio.assert_not_awaited()
@pytest.mark.parametrize(
("task_type", "expected_instruction"),
[
("CustomVoice", 'Say the following: "target text"'),
("Base", 'Say the following with the same voice: "target text"'),
],
)
def test_auk_task_type_normalizes_benchmark_text(auk_adapter, task_type, expected_instruction, mocker):
warning_once = mocker.patch("vllm_omni.entrypoints.openai.tts_adapters.auk.logger.warning_once")
request = OpenAICreateSpeechRequest(
input="target text",
task_type=task_type,
duration_seconds=2,
ref_audio="reference.wav" if task_type == "Base" else None,
)
server = mocker.Mock(spec=OmniOpenAIServingSpeech)
server._validate_speech_sample_rate.return_value = None
server._get_tts_adapter.return_value = auk_adapter
assert OmniOpenAIServingSpeech._validate_tts_request(server, request) is None
assert request.input == ""
assert request.instructions == expected_instruction
assert request.task_type is None
warning_once.assert_called_once()
assert "prefer a complete `instructions` prompt" in warning_once.call_args.args[0]
auk_adapter.normalize(request)
assert request.instructions == expected_instruction
warning_once.assert_called_once()
def test_auk_task_type_quotes_embedded_delimiters(auk_adapter):
request = OpenAICreateSpeechRequest(
input='It\'s called "AuK"\\Flash',
task_type="CustomVoice",
duration_seconds=2,
)
auk_adapter.normalize(request)
assert request.instructions == 'Say the following: "It\'s called \\"AuK\\"\\\\Flash"'
assert request.task_type is None
def test_auk_task_type_preserves_explicit_instructions(auk_adapter, mocker):
warning_once = mocker.patch("vllm_omni.entrypoints.openai.tts_adapters.auk.logger.warning_once")
warning = mocker.patch("vllm_omni.entrypoints.openai.tts_adapters.auk.logger.warning")
request = OpenAICreateSpeechRequest(
input="target text",
task_type="Base",
instructions="Use this complete AuK instruction.",
duration_seconds=2,
)
server = mocker.Mock(spec=OmniOpenAIServingSpeech)
server._validate_speech_sample_rate.return_value = None
server._get_tts_adapter.return_value = auk_adapter
assert OmniOpenAIServingSpeech._validate_tts_request(server, request) is None
assert request.input == ""
assert request.instructions == "Use this complete AuK instruction."
assert request.task_type is None
warning_once.assert_called_once()
warning.assert_called_once()
assert "without applying another task template" in warning.call_args.args[0]
def test_auk_task_type_does_not_double_wrap_preformatted_input(auk_adapter, mocker):
warning_once = mocker.patch("vllm_omni.entrypoints.openai.tts_adapters.auk.logger.warning_once")
warning = mocker.patch("vllm_omni.entrypoints.openai.tts_adapters.auk.logger.warning")
instruction = "Say the following with the same voice: 'target text'"
request = OpenAICreateSpeechRequest(
input=instruction,
task_type="Base",
duration_seconds=2,
ref_audio="reference.wav",
)
server = mocker.Mock(spec=OmniOpenAIServingSpeech)
server._validate_speech_sample_rate.return_value = None
server._get_tts_adapter.return_value = auk_adapter
assert OmniOpenAIServingSpeech._validate_tts_request(server, request) is None
assert request.input == ""
assert request.instructions == instruction
assert request.task_type is None
warning_once.assert_called_once()
warning.assert_called_once()
assert "already contains a complete task instruction" in warning.call_args.args[0]
def test_auk_sampling_overrides_reach_stage1_pipeline(auk_adapter, mocker):
"""Exercise one request from the Speech adapter through stage-1 admission."""
class RecordingDiffusionStage:
stage_type = "diffusion"
final_output = True
engine_input_source = [0]
requires_multimodal_data = False
custom_process_input_func = staticmethod(encoder2dit)
def __init__(self) -> None:
self.request: OmniDiffusionRequest | None = None
async def add_request_async(self, request_id, prompt, sampling_params, **_kwargs) -> None:
# Match the stage-client wire boundary and the receiving diffusion
# process, where params are serialized then reconstructed before
# creating the request consumed by the pipeline.
wire_params = StageDiffusionClient._sampling_params_to_dict(sampling_params)
self.request = OmniDiffusionRequest(
prompt=prompt,
sampling_params=StageDiffusionProc._reconstruct_sampling_params(
object.__new__(StageDiffusionProc), wire_params
),
request_id=request_id,
)
defaults = [SamplingParams(max_tokens=1), OmniDiffusionSamplingParams(seed=0, num_inference_steps=32)]
request = OpenAICreateSpeechRequest(
input="",
instructions='Generate speech based on the following description: "A calm voice". The content to speak is: "Hello".',
duration_seconds=2,
seed=9,
extra_params={
"num_inference_steps": 4,
"guidance_scale": 1.5,
"sway": -0.5,
"t_grid": [0.0, 0.25, 1.0],
"vae_sample": True,
},
)
prepared = asyncio.run(auk_adapter.build(request, defaults, False))
updated = auk_adapter.apply_sampling_overrides(defaults, request)
assert defaults[1].num_inference_steps == 32
assert defaults[1].seed == 0
assert updated[0].max_tokens == 1
source_stage = SimpleNamespace(
stage_type="llm",
final_output=False,
get_kv_sender_info=lambda: None,
)
diffusion_stage = RecordingDiffusionStage()
orchestrator = object.__new__(Orchestrator)
orchestrator.stage_pools = [StagePool(0, source_stage), StagePool(1, diffusion_stage)]
orchestrator._cfg_tracker = CfgCompanionTracker()
orchestrator.duplex_control_plane = None
orchestrator._running_counter = None
req_state = OrchestratorRequestState(
request_id="auk-sampling-override",
prompt=prepared.prompt,
sampling_params_list=updated,
final_stage_id=1,
)
encoder_output = SimpleNamespace(
request_id=req_state.request_id,
finished=True,
multimodal_output={"hidden_states": {"output": torch.ones(3, 4)}},
outputs=[],
)
asyncio.run(Orchestrator._forward_to_next_stage(orchestrator, req_state.request_id, 0, encoder_output, req_state))
received = diffusion_stage.request
assert received is not None
assert received.prompt["prompt_embeds"].shape == (3, 4)
assert received.prompt["additional_information"]["auk"] == {
"gen_seconds": 2,
"sway": -0.5,
"t_grid": [0.0, 0.25, 1.0],
"vae_sample": True,
"has_audio": False,
}
assert received.sampling_params.num_inference_steps == 4
assert received.sampling_params.guidance_scale == 1.5
assert received.sampling_params.guidance_scale_provided is True
assert received.sampling_params.seed == 9
runner = mocker.Mock(spec=DiffusionModelRunner)
runner.device = torch.device("cpu")
DiffusionModelRunner._initialize_generator(runner, received.sampling_params)
assert received.sampling_params.generator.initial_seed() == 9
pipeline_params = DiffusionRequestBatch(requests=[received]).sampling_params
assert pipeline_params is received.sampling_params
assert pipeline_params.generator.initial_seed() == 9
def test_auk_instructions_take_precedence_over_input(auk_adapter, mocker):
warning = mocker.patch("vllm_omni.entrypoints.openai.tts_adapters.auk.logger.warning")
request = OpenAICreateSpeechRequest(
input="ignored input",
instructions='Generate speech based on the following description: "Speak warmly". The content to speak is: "Hello".',
duration_seconds=2,
)
assert auk_adapter.validate(request) is None
prepared = asyncio.run(auk_adapter.build(request, [], False))
prompt = prepared.prompt
assert 'Generate speech based on the following description: "Speak warmly".' in prompt["prompt"]
assert 'The content to speak is: "Hello".' in prompt["prompt"]
assert "|<no_prompt_audio>|" in prompt["prompt"]
assert prompt["additional_information"]["auk"]["gen_seconds"] == 2
warning.assert_called_once()
assert "using the complete `instructions`" in warning.call_args.args[0]
def test_auk_input_only_is_treated_as_complete_instruction(auk_adapter, mocker):
warning = mocker.patch("vllm_omni.entrypoints.openai.tts_adapters.auk.logger.warning")
request = OpenAICreateSpeechRequest(
input="Say the following with the same voice: 'Hello world.'",
ref_audio="reference.wav",
duration_seconds=2,
)
assert auk_adapter.validate(request) is None
prepared = asyncio.run(auk_adapter.build(request, [], True))
assert "Say the following with the same voice: 'Hello world.'" in prepared.prompt["prompt"]
assert warning.call_count == 1
assert 'Prefer `input=""`' in warning.call_args.args[0]
def test_auk_complete_instruction_does_not_require_spoken_text(auk_adapter):
request = OpenAICreateSpeechRequest(
input="",
instructions="Replace 'morning' with 'evening' in the source recording.",
ref_audio="reference.wav",
)
assert auk_adapter.validate(request) is None
prepared = asyncio.run(auk_adapter.build(request, [], True))
assert prepared.prompt["prompt"].count("Replace 'morning'") == 1
assert "Generate speech based on" not in prepared.prompt["prompt"]
assert prepared.prompt["additional_information"]["auk"]["gen_seconds"] is None
def test_stage_model_path_prefers_typed_override():
engine_client = SimpleNamespace(
stage_configs=[
@@ -182,6 +182,7 @@ _PIPELINE_STAGES = [
"token2text",
"token2wav",
"tts",
"encoder",
]
_STAGES = [*_PIPELINE_STAGES, None, "vae", "not_a_real_stage"]
@@ -283,14 +284,22 @@ def test_pipeline_stage_list_is_complete():
assert not missing, f"stage keys claimed by adapters but absent from _PIPELINE_STAGES: {sorted(missing)}"
def test_entry_stage_archs_is_ming_only():
"""Only Ming dense identifies its entry stage by architecture.
def test_entry_stage_archs_are_explicit():
"""Only Ming dense and AuK identify their entry stage by architecture.
Widening this set would change which stage ``_find_tts_stage`` selects in
mixed deployments — notably VoxCPM2, which declares ``model_archs`` but is
found by its ``latent_generator`` stage key.
Both use a generic ``model_stage`` (``llm`` and ``encoder``) that other
pipelines share. Widening this set would change which stage
``_find_tts_stage`` selects in mixed deployments — notably VoxCPM2, which
declares ``model_archs`` but is found by its ``latent_generator`` stage key.
"""
assert tts_entry_stage_archs() == frozenset({"MingTTSForConditionalGeneration"})
assert tts_entry_stage_archs() == frozenset({"MingTTSForConditionalGeneration", "AuKForConditionalGeneration"})
def test_minimax_h3_encoder_is_not_a_tts_stage():
"""MiniMax H3 shares AuK's ``encoder`` stage key but is not a speech model."""
assert "encoder" not in all_tts_stage_keys()
assert detect_tts_model_type("encoder", "MiniMaxH3Encoder") is None
assert detect_tts_model_type("encoder", "AuKForConditionalGeneration") == "auk"
def test_every_detected_type_is_adapter_backed():
+1
View File
@@ -1475,6 +1475,7 @@ class OnlineOmniClient:
"max_new_tokens",
"seed",
"instructions",
"duration_seconds",
"speed",
"sample_rate",
"extra_params",
+3
View File
@@ -214,6 +214,9 @@ def test_speech_extra_params_reach_model_sampler_as_sampling_metadata(monkeypatc
)
class Adapter:
def normalize(self, request):
return
def validate(self, request):
return None
+1 -1
View File
@@ -29,7 +29,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
SERVING_SPEECH = Path("vllm_omni/entrypoints/openai/serving_speech.py")
# Budgets. These may only go DOWN. See the module docstring.
MAX_MODEL_TYPE_BRANCHES = 6
MAX_MODEL_TYPE_BRANCHES = 5
_GUIDANCE = """
Move the per-model behaviour into its adapter under
+6 -2
View File
@@ -43,8 +43,8 @@ stages:
# Not eager: the codec decode is compiled into bucketed CUDA graphs at
# startup (about a minute more on a cold Inductor cache, ~30 s warm, and
# about 1 GB of graph pools); clips longer than the largest bucket are
# decoded in overlapping tiles of it. The DiT stays eager unless
# diffusion_compile_granularity is set to full. Buckets, tile size and the
# decoded in overlapping tiles of it. Each DiT denoise step replays a lazily
# captured per-shape CUDA graph. Buckets, tile size and the
# plain-graph cache are tunable through model_config:
# model_config: {auk_vae_compile_shapes: [128, 256, 512], auk_vae_tile_frames: 512, auk_vae_max_graphs: 32}
- stage_id: 1
@@ -52,6 +52,10 @@ stages:
max_num_seqs: 1
gpu_memory_utilization: 0.35
enforce_eager: false
model_config:
# Maximum number of cached DiT graphs. When the cache reaches this limit,
# capturing a new shape retires all existing graphs before capture.
max_dit_graphs: 32
default_sampling_params:
seed: 0
num_inference_steps: 32
@@ -28,13 +28,13 @@ Deliberate differences from the reference, all inference-only:
"""
import math
from collections.abc import Iterable, Mapping
from collections.abc import Callable, Iterable, Mapping
import torch
import torch.nn.functional as F
from torch import nn
__all__ = ["AuKTransformer", "dit_state_dict", "sample_latents"]
__all__ = ["AuKTransformer", "build_time_grid", "dit_state_dict", "sample_latents"]
def _sdpa(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, mask: torch.Tensor | None) -> torch.Tensor:
@@ -86,14 +86,17 @@ class Rotary(nn.Module):
exponents = torch.arange(0, self.dim, 2, device=device, dtype=torch.float32) / self.dim
return 1.0 / (self.base**exponents)
def forward(self, seq_len: int) -> torch.Tensor:
"""Return frequencies ``[1, 1, seq_len, dim]`` for positions ``0..seq_len-1``."""
def forward(self, seq_len: int, mask: torch.Tensor | None = None) -> torch.Tensor:
"""Return rotary frequencies, compressing positions across padding."""
inv_freq = self.inv_freq
if inv_freq.dtype != torch.float32:
inv_freq = self._frequencies(inv_freq.device)
pos = torch.arange(seq_len, device=inv_freq.device, dtype=torch.float32)
freqs = torch.outer(pos, inv_freq)
return torch.stack((freqs, freqs), dim=-1).flatten(-2)[None, None]
if mask is None:
pos = torch.arange(seq_len, device=inv_freq.device, dtype=torch.float32)[None]
else:
pos = (mask.to(torch.int32).cumsum(dim=1) - 1).clamp_min(0).to(torch.float32)
freqs = pos.unsqueeze(-1) * inv_freq
return torch.stack((freqs, freqs), dim=-1).flatten(-2).unsqueeze(1)
class TimeEmbedding(nn.Module):
@@ -541,15 +544,15 @@ class AuKTransformer(nn.Module):
seq_len = x.shape[1]
text_len = c.shape[1]
rope_audio = self.rotary_embed(seq_len)
rope_text = self.rotary_embed(text_len)
rope_audio = self.rotary_embed(seq_len, audio_mask)
rope_text = self.rotary_embed(text_len, c_mask)
for block in self.transformer_blocks:
c, x = block(x, c, t, mask=audio_mask, rope=rope_audio, c_rope=rope_text, c_mask=c_mask)
x = torch.cat([c, x], dim=1)
rope = self.rotary_embed(text_len + seq_len)
single_mask = None if audio_mask is None else torch.cat([c_mask, audio_mask], dim=1)
rope = self.rotary_embed(text_len + seq_len, single_mask)
for block in self.single_transformer_blocks:
x = block(x, t, mask=single_mask, rope=rope)
@@ -571,6 +574,31 @@ def dit_state_dict(
return {name[len(prefix) :]: tensor for name, tensor in items if name.startswith(prefix)}
def build_time_grid(
*,
nfe: int,
sway_sampling_coef: float | None,
t_grid: list[float] | None,
device: torch.device | str,
) -> torch.Tensor:
"""Build and validate the Euler schedule before a graph replay."""
if t_grid is not None:
timesteps = torch.tensor(t_grid, device="cpu", dtype=torch.float32)
else:
timesteps = torch.linspace(0, 1, nfe + 1, device="cpu", dtype=torch.float32)
if sway_sampling_coef is not None:
timesteps = timesteps + sway_sampling_coef * (torch.cos(math.pi / 2 * timesteps) - 1 + timesteps)
if (
timesteps.ndim != 1
or timesteps.numel() < 2
or not bool(torch.isfinite(timesteps).all() & torch.all(timesteps[1:] > timesteps[:-1]))
):
raise ValueError(
f"AuK sampling needs a strictly increasing time grid with at least two points; got {timesteps.tolist()}"
)
return timesteps.to(device)
@torch.no_grad()
def sample_latents(
dit: AuKTransformer,
@@ -589,6 +617,7 @@ def sample_latents(
latent_dim: int | None = None,
device: torch.device | str | None = None,
dtype: torch.dtype | None = None,
sampler: Callable[..., torch.Tensor] | None = None,
) -> torch.Tensor:
"""Integrate the flow from noise to audio latents with explicit Euler steps.
@@ -640,25 +669,61 @@ def sample_latents(
.unsqueeze(0)
)
if t_grid is not None:
t = torch.tensor(t_grid, device=device, dtype=torch.float32)
else:
t = torch.linspace(0, 1, nfe + 1, device=device, dtype=torch.float32)
if sway_sampling_coef is not None:
t = t + sway_sampling_coef * (torch.cos(math.pi / 2 * t) - 1 + t)
guided = cfg_strength >= 1e-5
if t.numel() < 2 or not bool(torch.all(t[1:] > t[:-1])):
raise ValueError(
f"AuK sampling needs a strictly increasing time grid with at least two points; got {t.tolist()}"
t = build_time_grid(
nfe=nfe,
sway_sampling_coef=sway_sampling_coef,
t_grid=t_grid,
device=device,
)
if sampler is not None:
try:
for i in range(t.shape[0] - 1):
velocity = sampler(
x=x,
text=text,
c_mask=c_mask,
ref=ref,
ref_mask=ref_mask,
timestep=t[i],
cfg_strength=cfg_strength,
)
x = x + (t[i + 1] - t[i]) * velocity
finally:
dit.clear_cache()
return x
return _sample_latents(
dit,
initial_latents=x,
text=text,
c_mask=c_mask,
ref=ref,
ref_mask=ref_mask,
timesteps=t,
cfg_strength=cfg_strength,
)
def _sample_latents(
dit: AuKTransformer,
*,
initial_latents: torch.Tensor,
text: torch.Tensor,
c_mask: torch.Tensor | None,
ref: torch.Tensor,
ref_mask: torch.Tensor | None,
timesteps: torch.Tensor,
cfg_strength: float,
) -> torch.Tensor:
"""Euler integration shared by eager sampling and CUDA graph capture."""
x = initial_latents
guided = cfg_strength >= 1e-5
try:
for i in range(timesteps.shape[0] - 1):
if guided:
pred = dit(
x,
text,
t[i],
timesteps[i],
c_mask=c_mask,
ref=ref,
ref_mask=ref_mask,
@@ -668,8 +733,8 @@ def sample_latents(
v_cond, v_uncond = pred.chunk(2, dim=0)
v = v_cond + (v_cond - v_uncond) * cfg_strength
else:
v = dit(x, text, t[i], c_mask=c_mask, ref=ref, ref_mask=ref_mask)
x = x + (t[i + 1] - t[i]) * v
v = dit(x, text, timesteps[i], c_mask=c_mask, ref=ref, ref_mask=ref_mask)
x = x + (timesteps[i + 1] - timesteps[i]) * v
finally:
# The cached text projections belong to this request only; a failed
# step must not leak them into the next one.
@@ -0,0 +1,242 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Per-shape CUDA graph for one AuK DiT denoise step."""
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass
import torch
import torch.nn.functional as F
from vllm.logger import init_logger
from vllm.utils.math_utils import round_up
from vllm_omni.diffusion.models.auk.auk_transformer import AuKTransformer
logger = init_logger(__name__)
@dataclass
class _GraphEntry:
graph: torch.cuda.CUDAGraph
static_x: torch.Tensor
static_x_mask: torch.Tensor
static_text: torch.Tensor
static_c_mask: torch.Tensor
static_ref: torch.Tensor
static_ref_mask: torch.Tensor
static_timestep: torch.Tensor
static_cfg: torch.Tensor | None
static_out: torch.Tensor
class AuKCUDAGraphWrapper:
"""Replay one DiT denoise step and leave Euler scheduling to the caller.
The graph is keyed by the target, text and reference sequence lengths plus
whether the CFG branch is enabled. Timestep and the CFG strength are
mutable scalar buffers, so all Euler steps and CFG values within one path
reuse one graph.
"""
_TARGET_ALIGNMENT = 64
_TEXT_ALIGNMENT = 64
_REF_ALIGNMENT = 50
def __init__(self, dit: AuKTransformer, *, enabled: bool = True, max_graphs: int = 32) -> None:
self.dit = dit
self.enabled = bool(enabled)
self.max_graphs = max(1, int(max_graphs))
self._cache: OrderedDict[tuple, _GraphEntry] = OrderedDict()
self._pool_handle: int | None = None
logger.info("Initialized AuK DiT lazy CUDA graph cache: enabled=%s slots=%d", self.enabled, self.max_graphs)
@classmethod
def _bucket_inputs(
cls,
x: torch.Tensor,
text: torch.Tensor,
c_mask: torch.Tensor,
ref: torch.Tensor,
ref_mask: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
target_bucket = round_up(x.shape[1], cls._TARGET_ALIGNMENT)
text_bucket = round_up(text.shape[1], cls._TEXT_ALIGNMENT)
ref_bucket = round_up(ref.shape[1], cls._REF_ALIGNMENT)
x_mask = torch.ones(x.shape[:2], dtype=torch.bool, device=x.device)
return (
F.pad(x, (0, 0, 0, target_bucket - x.shape[1])),
F.pad(x_mask, (0, target_bucket - x_mask.shape[1]), value=False),
F.pad(text, (0, 0, 0, text_bucket - text.shape[1])),
F.pad(c_mask, (0, text_bucket - c_mask.shape[1]), value=False),
F.pad(ref, (0, 0, 0, ref_bucket - ref.shape[1])),
F.pad(ref_mask, (0, ref_bucket - ref_mask.shape[1]), value=False),
)
@staticmethod
def _key(
x: torch.Tensor,
text: torch.Tensor,
ref: torch.Tensor,
uses_cfg: bool,
) -> tuple[int, int, int, bool]:
return (x.shape[1], text.shape[1], ref.shape[1], uses_cfg)
def _retire_graph_generation_if_full(self) -> None:
"""Retire all graphs together so none outlive shared workspaces."""
if len(self._cache) >= self.max_graphs:
self._cache.clear()
@torch.no_grad()
def __call__(
self,
*,
x: torch.Tensor,
text: torch.Tensor,
c_mask: torch.Tensor,
ref: torch.Tensor,
ref_mask: torch.Tensor,
timestep: torch.Tensor,
cfg_strength: float,
) -> torch.Tensor:
uses_cfg = cfg_strength >= 1e-5
if not self.enabled or x.device.type != "cuda" or torch.cuda.is_current_stream_capturing():
if uses_cfg:
return self._run_cfg(x, None, text, c_mask, ref, ref_mask, timestep, cfg_strength=cfg_strength)
return self._run(x, None, text, c_mask, ref, ref_mask, timestep)
target_frames = x.shape[1]
x, x_mask, text, c_mask, ref, ref_mask = self._bucket_inputs(x, text, c_mask, ref, ref_mask)
inputs = (x, x_mask, text, c_mask, ref, ref_mask, timestep)
key = self._key(x, text, ref, uses_cfg)
entry = self._cache.get(key)
if entry is None:
# Same Retirement as #6587, details discussed in #7469
self._retire_graph_generation_if_full()
try:
entry = self._capture(*inputs, cfg_strength=cfg_strength, uses_cfg=uses_cfg)
except Exception:
self._cache.clear()
self.enabled = False
logger.exception(
"Disabling AuK DiT CUDA graphs after capture failure for key=%s; "
"subsequent requests will use eager execution.",
key,
)
raise
self._cache[key] = entry
else:
self._cache.move_to_end(key)
entry.static_x.copy_(x)
entry.static_x_mask.copy_(x_mask)
entry.static_text.copy_(text)
entry.static_c_mask.copy_(c_mask)
entry.static_ref.copy_(ref)
entry.static_ref_mask.copy_(ref_mask)
entry.static_timestep.copy_(timestep)
if entry.static_cfg is not None:
entry.static_cfg.fill_(cfg_strength)
entry.graph.replay()
return entry.static_out[:, :target_frames].clone()
def _run(
self,
x: torch.Tensor,
x_mask: torch.Tensor | None,
text: torch.Tensor,
c_mask: torch.Tensor,
ref: torch.Tensor,
ref_mask: torch.Tensor,
timestep: torch.Tensor,
) -> torch.Tensor:
return self.dit(x, text, timestep, mask=x_mask, c_mask=c_mask, ref=ref, ref_mask=ref_mask)
def _run_cfg(
self,
x: torch.Tensor,
x_mask: torch.Tensor | None,
text: torch.Tensor,
c_mask: torch.Tensor,
ref: torch.Tensor,
ref_mask: torch.Tensor,
timestep: torch.Tensor,
*,
cfg_strength: torch.Tensor | float,
) -> torch.Tensor:
pred = self.dit(
x,
text,
timestep,
mask=x_mask,
c_mask=c_mask,
ref=ref,
ref_mask=ref_mask,
cfg_infer=True,
cache=True,
)
conditional, unconditional = pred.chunk(2, dim=0)
return conditional + (conditional - unconditional) * cfg_strength
def _capture(
self,
*inputs: torch.Tensor,
cfg_strength: float,
uses_cfg: bool,
) -> _GraphEntry:
static_inputs = tuple(value.clone() for value in inputs)
static_cfg = None
if uses_cfg:
static_cfg = torch.empty((), device=static_inputs[0].device, dtype=torch.float32)
static_cfg.fill_(cfg_strength)
try:
for _ in range(3):
if uses_cfg:
assert static_cfg is not None
self._run_cfg(*static_inputs, cfg_strength=static_cfg)
else:
self._run(*static_inputs)
# CFG warm-up populates AuKTransformer's Python-side projected-text
# cache. Clear it before capture so the graph includes
# project_text(static_text), rather than closing over the first
# request's projection and ignoring later static_text updates.
self.dit.clear_cache()
if static_cfg is not None:
static_cfg.fill_(cfg_strength)
if self._pool_handle is None:
self._pool_handle = torch.cuda.graph_pool_handle()
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, pool=self._pool_handle):
# Capture the actual CFG path. The scalar value remains a
# mutable buffer, so cfg=2 and cfg=3 share this graph.
if uses_cfg:
assert static_cfg is not None
static_out = self._run_cfg(*static_inputs, cfg_strength=static_cfg)
else:
static_out = self._run(*static_inputs)
finally:
self.dit.clear_cache()
logger.info(
"Captured AuK DiT single-step CUDA graph: target_frames=%d text_tokens=%d ref_frames=%d cfg=%s",
inputs[0].shape[1],
inputs[2].shape[1],
inputs[4].shape[1],
cfg_strength,
)
return _GraphEntry(
graph=graph,
static_x=static_inputs[0],
static_x_mask=static_inputs[1],
static_text=static_inputs[2],
static_c_mask=static_inputs[3],
static_ref=static_inputs[4],
static_ref_mask=static_inputs[5],
static_timestep=static_inputs[6],
static_cfg=static_cfg,
static_out=static_out,
)
__all__ = ["AuKCUDAGraphWrapper"]
@@ -27,6 +27,7 @@ from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.auk.auk_transformer import AuKTransformer, dit_state_dict, sample_latents
from vllm_omni.diffusion.models.auk.auk_vae import AuKVAE
from vllm_omni.diffusion.models.auk.cudagraph_wrapper import AuKCUDAGraphWrapper
from vllm_omni.diffusion.models.auk.vae_cudagraph import AuKVAEDecodeGraph
from vllm_omni.diffusion.models.interface import (
SupportAudioInput,
@@ -139,6 +140,9 @@ class AuKPipeline(nn.Module, SupportAudioInput, SupportAudioOutput, SupportsComp
super().__init__()
del prefix # Weights are not namespaced: one checkpoint, one pipeline.
self.od_config = od_config
max_dit_graphs = od_config.model_config.get("max_dit_graphs", 32)
if isinstance(max_dit_graphs, bool) or not isinstance(max_dit_graphs, int) or max_dit_graphs < 1:
raise ValueError("AuK max_dit_graphs must be a positive integer")
self.device = get_local_device()
self.dtype = getattr(od_config, "dtype", None) or torch.bfloat16
@@ -183,6 +187,9 @@ class AuKPipeline(nn.Module, SupportAudioInput, SupportAudioOutput, SupportsComp
self.dit.load_state_dict(_read_dit_weights(model_dir, self.dtype), strict=True)
self.dit = self.dit.to(device=self.device).eval()
self.dit.requires_grad_(False)
self.cudagraph_wrapper = AuKCUDAGraphWrapper(
self.dit, enabled=not od_config.enforce_eager, max_graphs=max_dit_graphs
)
# The compiled decode buckets are warmed by setup_compile(), which the
# model runner calls at startup unless the stage is enforce_eager. The
# bucket list and the plain-graph cache size come from the stage's
@@ -462,6 +469,7 @@ class AuKPipeline(nn.Module, SupportAudioInput, SupportAudioOutput, SupportsComp
device=self.device,
dtype=torch.float32,
generator=generator,
sampler=self.cudagraph_wrapper,
)
latents = latents.float()
@@ -1173,6 +1173,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
adapter = self._get_tts_adapter()
if adapter is not None:
adapter.normalize(request)
return adapter.validate(request)
adapter_cls = resolve_adapter("qwen3_tts")
@@ -1183,7 +1184,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
server=self,
engine_client=self.engine_client,
)
return adapter_cls(ctx).validate(request)
adapter = adapter_cls(ctx)
adapter.normalize(request)
return adapter.validate(request)
def _validate_speech_sample_rate(self, request: OpenAICreateSpeechRequest) -> str | None:
if request.sample_rate is None:
@@ -2000,6 +2003,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
model_type: str | None = None
has_inline_ref_audio = (request.ref_audio is not None) if has_inline_ref_audio is None else has_inline_ref_audio
if (adapter := self._get_tts_adapter()) is not None:
adapter.normalize(request)
validation_error = adapter.validate(request)
if validation_error:
raise ValueError(validation_error)
@@ -105,6 +105,7 @@ from vllm_omni.entrypoints.openai.tts_adapters import ( # noqa: E402,F401
audex,
audex_tta,
audio8_tts,
auk,
breeze_tts_2,
cosyvoice3,
covo_audio,
@@ -0,0 +1,173 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""AuK Speech API adapter for the current multi-stage serving contract."""
from __future__ import annotations
import copy
import json
import math
from typing import TYPE_CHECKING, Any
import numpy as np
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
from vllm_omni.model_extras.auk import DEFAULT_SWAY, auk_prompt
logger = init_logger(__name__)
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
@register_tts_adapter
class AuKAdapter(ARTTSAdapter):
"""Translate Speech API fields into the existing AuK encoder/DiT prompt."""
name = "auk"
# AuK's entry stage uses the generic ``model_stage="encoder"``, which other
# pipelines (MiniMax H3) also declare, so both stage discovery and
# model-type detection go through the architecture instead.
model_archs = frozenset({"AuKForConditionalGeneration"})
arch_identifies_entry_stage = True
def normalize(self, request: OpenAICreateSpeechRequest) -> None:
if request.task_type is None:
return
if request.task_type in ("CustomVoice", "Base"):
logger.warning_once(
"AuK `task_type` is a compatibility shortcut; prefer a complete `instructions` prompt. "
'CustomVoice applies `Say the following: "<input>"`; Base applies '
'`Say the following with the same voice: "<input>"`.'
)
if request.instructions and request.instructions.strip():
logger.warning(
"AuK request contains both `task_type` and a complete `instructions` prompt; "
"using `instructions` without applying another task template."
)
request.input = ""
request.task_type = None
return
instruction = request.input.strip()
if instruction.startswith(("Say the following:", "Say the following with the same voice:")):
logger.warning(
"AuK request `input` already contains a complete task instruction; "
"using it without applying another task template."
)
request.instructions = instruction
request.input = ""
request.task_type = None
return
if not instruction:
return
quoted_instruction = json.dumps(instruction, ensure_ascii=False)
if request.task_type == "Base":
request.instructions = f"Say the following with the same voice: {quoted_instruction}"
elif request.task_type == "CustomVoice":
request.instructions = f"Say the following: {quoted_instruction}"
else:
return
request.input = ""
# task_type is a compatibility shortcut for AuK, not a model-side
# parameter. Consuming it makes normalization idempotent when the
# shared batch path validates and later prepares the same request.
request.task_type = None
def validate(self, request: OpenAICreateSpeechRequest) -> str | None:
if not request.input.strip() and not (request.instructions and request.instructions.strip()):
return "AuK requires input text or a complete instructions prompt"
if request.duration_seconds is not None:
if not math.isfinite(request.duration_seconds) or request.duration_seconds <= 0:
return "AuK requires duration_seconds to be a positive finite value"
if request.duration_seconds is None and request.ref_audio is None:
return "AuK requires duration_seconds for requests without ref_audio"
if request.instructions is not None:
if len(request.instructions) > self.ctx.server._max_instructions_length:
return f"instructions exceeds max length {self.ctx.server._max_instructions_length}"
if request.ref_audio is not None:
if not isinstance(request.ref_audio, str):
return "AuK supports one ref_audio string per request"
error = self.ctx.server._validate_ref_audio_format(request.ref_audio)
if error:
return error
extra = request.extra_params or {}
for key in extra:
if key not in {"num_inference_steps", "guidance_scale", "sway", "t_grid", "vae_sample"}:
return f"AuK does not support extra_params.{key}"
t_grid = extra.get("t_grid")
if t_grid is not None:
t_grid_error = "AuK extra_params.t_grid must contain at least two finite, strictly increasing values"
if not isinstance(t_grid, (list, tuple)) or len(t_grid) < 2:
return t_grid_error
try:
values = [float(value) for value in t_grid]
except (TypeError, ValueError):
return t_grid_error
if not all(math.isfinite(value) for value in values) or any(
right <= left for left, right in zip(values, values[1:])
):
return t_grid_error
return None
async def build(
self,
request: OpenAICreateSpeechRequest,
sampling_params_list: list,
has_inline_ref_audio: bool,
) -> PreparedRequest:
del sampling_params_list, has_inline_ref_audio
reference: tuple[np.ndarray, int] | None = None
if request.ref_audio is not None:
samples, sample_rate, _ = await self.ctx.server._resolve_ref_audio(request.ref_audio)
reference = (np.asarray(samples, dtype=np.float32), int(sample_rate))
extra = request.extra_params or {}
# The caller supplies the complete cookbook prompt in instructions;
# this adapter deliberately does not invent or compose a template.
if request.instructions and request.instructions.strip():
if request.input.strip():
logger.warning(
"AuK request contains both non-empty `input` and `instructions`; "
"using the complete `instructions` prompt and ignoring `input`."
)
instruction = request.instructions.strip()
else:
logger.warning(
"AuK request only provides `input`; treating it as the complete AuK instruction. "
'Prefer `input=""` with the instruction in `instructions`.'
)
instruction = request.input.strip()
prompt = auk_prompt(
instruction,
reference,
gen_seconds=request.duration_seconds,
sway=float(extra.get("sway", DEFAULT_SWAY)),
t_grid=extra.get("t_grid"),
vae_sample=extra.get("vae_sample", False),
)
return PreparedRequest(prompt=prompt, tts_params={}, model_type=self.name)
def apply_sampling_overrides(
self,
sampling_params_list: list,
request: OpenAICreateSpeechRequest,
prompt: dict[str, Any] | None = None,
request_id: str | None = None,
) -> list:
if len(sampling_params_list) < 2:
return sampling_params_list
updated = copy.deepcopy(sampling_params_list)
stage1 = updated[1]
extra = request.extra_params or {}
if "num_inference_steps" in extra:
stage1.num_inference_steps = int(extra["num_inference_steps"])
if "guidance_scale" in extra:
stage1.guidance_scale = float(extra["guidance_scale"])
if request.seed is not None:
stage1.seed = int(request.seed)
return updated
__all__ = ["AuKAdapter"]