Merge branch 'main' into fix/tp-local-kv-recv-consensus

This commit is contained in:
Gao Han authored and GitHub committed 2026-08-01 10:50:23 +08:00
commit 0eda607077
22 files changed
+961 -197

No files matched your search

+43 -78
View File
@@ -14,7 +14,7 @@ The full set of backends and their platform defaults is in the **Backend Options
| Value | Notes |
|---|---|
| `TRTLLM_ATTN` | FlashInfer's trtllm-gen FMHA (TensorRT-LLM's generated kernels, vendored by FlashInfer). BF16, GQA native, `head_dim=128`. Datacenter Blackwell only (sm_100 / sm_103). Supports **Skip-Softmax** sparse attention — see below. Requires `flashinfer`. |
| `TRTLLM_ATTN` | FlashInfer's trtllm-gen FMHA (TensorRT-LLM's generated kernels, vendored by FlashInfer). BF16, GQA native, `head_dim=128`. Datacenter Blackwell only (sm_100 / sm_103). Supports **Skip-Softmax** sparse attention and **SAGE** quantized attention — see below. Requires `flashinfer`. |
| `FLASH_ATTN` | Wraps FlashAttention 2. Default on Hopper / Ada / Ampere when `flash-attn` is installed. |
| `CUDNN_ATTN` | Pins `sdpa_kernel([CUDNN_ATTENTION])`. Default on Blackwell (sm_10x / sm_12x) with cuDNN ≥ 9.5. Wins on mask-heavy DiTs (HunyuanVideo-1.5: 2× e2e vs SDPA). |
| `FLASHINFER_ATTN` | Calls FlashInfer's dense `single_prefill_with_kv_cache` directly with `custom_mask` for non-causal masked attention. Used as Blackwell fallback when cuDNN is unavailable. Requires `flashinfer`. |
@@ -167,6 +167,48 @@ sequence length, since Skip-Softmax only accelerates attention. Dense BF16 is `T
Requires datacenter Blackwell with `head_dim == 128`; elsewhere, or without FlashInfer, selecting
`TRTLLM_ATTN` raises rather than silently degrading.
## TRTLLM_ATTN SAGE Quantization
On top of dense/Skip-Softmax, `TRTLLM_ATTN` can run **SAGE** (SageAttention) — the trtllm-gen
FMHA kernel with per-block quantized Q/K and per-channel fp8 V, so the two attention matmuls run
in low precision. Enable it through the typed `quant` block on the attention spec:
| Key | Valid values | Meaning |
|---|---|---|
| `dtype_qk` | `int8`, `fp8_e4m3` | Quantization dtype for Q and K. Absent ⇒ dense (SAGE off). |
| `q_block_size` | `1`, `4`, `16` | Per-token block size for Q scales (default `1`). |
| `k_block_size` | `1`, `4`, `16` | Per-token block size for K scales (default `16`). |
V is always quantized per-channel to fp8_e4m3 and K-smoothing is applied inside the routine, so
neither is a user knob. Only block sizes `1`, `4`, `16` have compiled kernels.
The `quant` block is a shared `AttnQuantSpec` consumed by more than one backend; each reads the
fields that apply and rejects the rest. `TRTLLM_ATTN` (SAGE) reads `dtype_qk` (`int8` / `fp8_e4m3`),
`q_block_size`, and `k_block_size`; `FLASHINFER_ATTN` reads `dtype_qk` (`float16` / `bfloat16`),
`dtype_vo`, and `flashinfer_backend` (see its section). A `dtype_qk` from the wrong set raises.
```bash
vllm-omni serve Wan-AI/Wan2.2-T2V-A14B-Diffusers \
--diffusion-attention-config '{"default": {"backend": "TRTLLM_ATTN",
"quant": {"dtype_qk": "fp8_e4m3", "q_block_size": 1, "k_block_size": 16}}}'
```
```python
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, AttnQuantSpec
AttentionConfig(
default=AttentionSpec(
backend="TRTLLM_ATTN",
quant=AttnQuantSpec(dtype_qk="fp8_e4m3", q_block_size=1, k_block_size=16),
),
)
```
**Requirements.** Needs FlashInfer ≥ 0.6.16rc1. Kernel
availability is arch-dependent: `fp8_e4m3` QK has kernels on both **SM100** (B200) and **SM103**
(B300); `int8` QK kernels are compiled for **SM100 only**.
## End-to-End Benchmark (BF16, sm_120 RTX Pro 6000 Blackwell)
Same prompt and seed across runs. `Total generation time` from `text_to_video.py` / `text_to_image.py`.
@@ -180,22 +222,6 @@ Same prompt and seed across runs. `Total generation time` from `text_to_video.py
Pattern: mask-heavy DiTs (HV-1.5, Qwen-Image) favor `CUDNN_ATTN`; lighter-mask DiTs and TP-saturated configs (Wan 2.2, FLUX.2 TP=2) tie within noise.
## Known Limitations
### LTX-2.0: `CUDNN_ATTN` crashes under torch.compile
LTX-2's audio attention has a symbolic head_dim under torch.compile tracing. cuDNN's SDPA backend selector rejects symbolic dims and Dynamo aborts compilation. Tracked in [#3121](https://github.com/vllm-project/vllm-omni/issues/3121).
**Workaround**: explicitly select `FLASHINFER_ATTN` or `TORCH_SDPA` for LTX-2.0:
```bash
DIFFUSION_ATTENTION_BACKEND=FLASHINFER_ATTN python examples/offline_inference/text_to_video/text_to_video.py \
--model Lightricks/LTX-2 ...
```
### FA4 not yet integrated
FlashAttention-4 (released March 2026) targets Blackwell natively and reportedly beats cuDNN by ~20% on B200. As of this writing the `flash-attn-4 4.0.0b10` wheel crashes with `AttributeError: 'NoneType' object has no attribute '_trait'` during JIT on sm_120. Not yet wired into vLLM-Omni; revisit when stable lands.
## Choosing a Backend Manually
@@ -353,64 +379,3 @@ DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN_3 python examples/offline_inference/text_t
--tensor-parallel-size 2 \
--output outputs/hv15_sage3.mp4
```
### Mixed backends across roles
Use `FLASH_ATTN` for self-attention and `TORCH_SDPA` for cross-attention:
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model Wan-AI/Wan2.2-TI2V-5B-Diffusers \
--prompt "A dog running across a field of golden wheat." \
--diffusion-attention-config.per_role.self.backend FLASH_ATTN \
--diffusion-attention-config.per_role.cross.backend TORCH_SDPA \
--tensor-parallel-size 2 \
--output outputs/wan22_mixed.mp4
```
### Compare against FlashAttention
Unset the backend override, or explicitly use `FLASH_ATTN`:
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model Wan-AI/Wan2.2-TI2V-5B-Diffusers \
--prompt "A dog running across a field of golden wheat." \
--height 704 --width 1280 --num-frames 49 \
--num-inference-steps 30 --seed 42 --guidance-scale 5.0 \
--tensor-parallel-size 2 \
--output outputs/wan22_fa3.mp4
```
## Validation Guidance
Don't assume a faster attention backend is numerically interchangeable with `TORCH_SDPA`.
Always compare:
- End-to-end runtime
- Diffusion-stage runtime (`add_req_and_wait` line in DiffusionEngine.step breakdown)
- Output quality against a known-good baseline (CLIP similarity, frame-level diff, or visual review)
At minimum, keep the same:
- model
- prompt
- seed
- resolution
- frame count / step count
- parallel config (TP / CFG-parallel / Ulysses degrees)
## Reproducing the Benchmark Table
The end-to-end numbers above were collected by running `text_to_video.py` /
`text_to_image.py` with the same prompt and seed while varying
`DIFFUSION_ATTENTION_BACKEND`. For a quick kernel-level comparison of the
backends without loading a model:
```bash
python benchmarks/diffusion/bench_attention_backends.py --preset hv15
```
It runs all three BF16 backends on representative DiT attention shapes and
prints a ranking table at the end.
+5 -3
View File
@@ -23,9 +23,11 @@ including `librosa`.
The deploy config auto-loads via `--omni`.
The default `vllm_omni/deploy/minicpmo_4_5.yaml` keeps all three stages on
logical device 0 with memory budgets of 55%, 22%, and 22%. The profile admits
at most four sequences per stage and bounds startup video profiling to 32
frames per video. For throughput,`minicpmo_4_5_2gpu.yaml` gives the Thinker
logical device 0 with memory budgets of 55%, 15%, and 15%, leaving headroom
for runtime kernels such as the HiFi-GAN vocoder's cuDNN workspace. The
profile admits at most four sequences per stage and bounds startup video
profiling to 32 frames per video. For throughput,
`minicpmo_4_5_2gpu.yaml` gives the Thinker
GPU 0 (90%) and colocates the Talker (55%) and Code2Wav (35%) on GPU 1. That
profile admits at most four concurrent sequences per stage.
+1
View File
@@ -75,6 +75,7 @@ dev = [
]
demo = [
"gradio>=6.7.0",
"opencv-python>=4.12.0.88", # cli/stream_client frame reading
"requests>=2.28.0", # cli/stream_client HTTP
"cn2an>=0.5.22", # Chinese number normalization for TTS (e.g. "1次" -> "一次")
+7 -4
View File
@@ -77,7 +77,9 @@ a fallback because it would duplicate state machines and correctness paths.
The default
[`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml)
co-locates Thinker, codec-only Talker, and Code2Wav on GPU 0. Their
`gpu_memory_utilization` budgets are 0.55, 0.22, and 0.22. All stages admit
`gpu_memory_utilization` budgets are 0.55, 0.15, and 0.15. The remaining
device memory is left available for runtime workspaces such as the HiFi-GAN
vocoder's cuDNN kernels. All stages admit
up to four sequences. Startup video profiling is bounded to 32 frames per
video. Use the two-GPU profile for production throughput.
@@ -202,9 +204,10 @@ speech output (TTS)"** checkbox on / off.
#### Notes
- Memory budget: Thinker, Talker, and Code2Wav reserve 0.55, 0.22, and
0.22 of GPU 0. The larger Thinker share protects its multimodal KV cache;
all three model processes still share one CUDA device.
- Memory budget: Thinker, Talker, and Code2Wav reserve 0.55, 0.15, and
0.15 of GPU 0. The larger Thinker share protects its multimodal KV cache,
while the unreserved memory remains available to runtime kernels; all three
model processes still share one CUDA device.
- `--trust-remote-code` is required — the HF repo ships a custom
`MiniCPMO` config / model class.
- Stage 0 Thinker and Stage 1 Talker enable vLLM CUDA Graphs. Stage 2 remains
+67 -22
View File
@@ -1,6 +1,8 @@
import json
import os
import threading
from collections.abc import Callable
from contextlib import AbstractContextManager, ExitStack, contextmanager
from pathlib import Path
from typing import Any
@@ -63,35 +65,78 @@ paired_benchmark_params = create_paired_omni_benchmark_pytest_params(BENCHMARK_C
_omni_server_lock = threading.Lock()
class _SingleActiveContext:
"""Reuse one active context while its configuration key is unchanged."""
def __init__(self) -> None:
self._key: Any = None
self._stack: ExitStack | None = None
self._value: Any = None
def acquire(self, key: Any, factory: Callable[[], AbstractContextManager[Any]]) -> Any:
if self._stack is not None and key == self._key:
return self._value
self.close()
stack = ExitStack()
value = stack.enter_context(factory())
self._key = key
self._stack = stack
self._value = value
return value
def close(self) -> None:
stack = self._stack
self._key = None
self._stack = None
self._value = None
if stack is not None:
stack.close()
@contextmanager
def _start_omni_server(server_param):
test_name, model, stage_config_path, stage_overrides, extra_cli_args, use_omni = server_param
print(f"Starting OmniServer with test: {test_name}, model: {model}")
server_args: list[str] = []
if use_omni:
server_args += ["--stage-init-timeout", "600", "--init-timeout", "900"]
# --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
# deploy-config sets the base; stage-overrides are applied on top. Both can be set.
if stage_config_path:
server_args = ["--deploy-config", stage_config_path] + server_args
if stage_overrides:
server_args = ["--stage-overrides", stage_overrides] + server_args
if extra_cli_args:
server_args = list(extra_cli_args) + server_args
with OmniServer(model, server_args, use_omni=use_omni) as server:
server.test_name = test_name
print("OmniServer started successfully")
yield server
print("OmniServer stopping...")
print("OmniServer stopped")
@pytest.fixture(scope="module")
def omni_server(request):
def omni_server_context():
"""Start vLLM-Omni server as a subprocess with actual model weights.
Uses session scope so the server starts only once for the entire test session.
Reuse it for adjacent benchmark cases with the same server configuration.
Multi-stage initialization can take 10-20+ minutes.
"""
with _omni_server_lock:
test_name, model, stage_config_path, stage_overrides, extra_cli_args, use_omni = request.param
active_context = _SingleActiveContext()
try:
yield active_context
finally:
active_context.close()
print(f"Starting OmniServer with test: {test_name}, model: {model}")
server_args: list[str] = []
if use_omni:
server_args += ["--stage-init-timeout", "600", "--init-timeout", "900"]
# --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
# deploy-config sets the base; stage-overrides are applied on top. Both can be set.
if stage_config_path:
server_args = ["--deploy-config", stage_config_path] + server_args
if stage_overrides:
server_args = ["--stage-overrides", stage_overrides] + server_args
if extra_cli_args:
server_args = list(extra_cli_args) + server_args
with OmniServer(model, server_args, use_omni=use_omni) as server:
server.test_name = test_name
print("OmniServer started successfully")
yield server
print("OmniServer stopping...")
print("OmniServer stopped")
@pytest.fixture
def omni_server(request, omni_server_context):
return omni_server_context.acquire(request.param, lambda: _start_omni_server(request.param))
@pytest.fixture
+52 -1
View File
@@ -1,6 +1,8 @@
"""Tests for DFX runner metadata field exclusion."""
import json
from contextlib import contextmanager
from types import SimpleNamespace
import pytest
@@ -254,8 +256,9 @@ def test_benchmark_param_id_suffix_from_task_eval_phase():
]
def test_create_paired_omni_benchmark_pytest_params(tmp_path):
def test_paired_omni_benchmark_reuses_server_and_preserves_case_metadata(tmp_path, monkeypatch):
from tests.dfx.conftest import create_paired_omni_benchmark_pytest_params
from tests.dfx.perf.scripts import run_benchmark
configs = [
{
@@ -285,3 +288,51 @@ def test_create_paired_omni_benchmark_pytest_params(tmp_path):
assert bench_row == ("test_tts", 0)
assert any(m.name == "tts" for m in by_id["test_tts-p0"].marks)
assert not any(m.name == "tts" for m in by_id["test_omni-p0"].marks)
omni_p0_server = by_id["test_omni-p0"].values[0]
omni_p1_server = by_id["test_omni-p1"].values[0]
tts_server = by_id["test_tts-p0"].values[0]
assert omni_p0_server == omni_p1_server
assert omni_p0_server != tts_server
events = []
@contextmanager
def fake_start(server_param):
events.append(("start", server_param))
yield object()
events.append(("stop", server_param))
monkeypatch.setattr(run_benchmark, "_start_omni_server", fake_start)
active_context = run_benchmark._SingleActiveContext()
try:
first = run_benchmark.omni_server.__wrapped__(
SimpleNamespace(param=omni_p0_server),
active_context,
)
second = run_benchmark.omni_server.__wrapped__(
SimpleNamespace(param=omni_p1_server),
active_context,
)
assert first is second
assert events == [("start", omni_p0_server)]
third = run_benchmark.omni_server.__wrapped__(
SimpleNamespace(param=tts_server),
active_context,
)
assert third is not first
assert events == [
("start", omni_p0_server),
("stop", omni_p0_server),
("start", tts_server),
]
finally:
active_context.close()
assert events == [
("start", omni_p0_server),
("stop", omni_p0_server),
("start", tts_server),
("stop", tts_server),
]
@@ -52,6 +52,44 @@ class TestAttentionSpec:
with pytest.raises(ValueError, match="only supported by the TRTLLM_ATTN"):
AttentionSpec(backend="TORCH_SDPA", skip_softmax={"target_sparsity": 0.5})
def test_quant_serialized_with_defaults_and_overrides(self):
assert AttentionSpec(backend="TRTLLM_ATTN", quant={"dtype_qk": "fp8_e4m3"}).backend_kwargs()["quant"] == {
"dtype_qk": "fp8_e4m3",
"q_block_size": 1,
"k_block_size": 16,
}
assert AttentionSpec(
backend="TRTLLM_ATTN", quant={"dtype_qk": "int8", "q_block_size": 4, "k_block_size": 16}
).backend_kwargs()["quant"] == {"dtype_qk": "int8", "q_block_size": 4, "k_block_size": 16}
def test_quant_superset_fields_passed_through(self):
# FLASHINFER_ATTN-style config (dtype_vo / flashinfer_backend) round-trips on the shared spec.
bk = AttentionSpec(
backend="FLASHINFER_ATTN",
quant={"dtype_qk": "bfloat16", "dtype_vo": "fp8_e4m3", "flashinfer_backend": "trtllm-gen"},
).backend_kwargs()["quant"]
assert bk["dtype_qk"] == "bfloat16"
assert bk["dtype_vo"] == "fp8_e4m3"
assert bk["flashinfer_backend"] == "trtllm-gen"
def test_quant_and_skip_softmax_coexist(self):
bk = AttentionSpec(
backend="TRTLLM_ATTN", quant={"dtype_qk": "int8"}, skip_softmax={"target_sparsity": 0.5}
).backend_kwargs()
assert bk["target_sparsity"] == 0.5 and bk["quant"]["dtype_qk"] == "int8"
@pytest.mark.parametrize(
"spec, match",
[
({"backend": "TORCH_SDPA", "quant": {"dtype_qk": "int8"}}, "only supported by the TRTLLM_ATTN"),
({"backend": "TRTLLM_ATTN", "quant": {"dtype_qk": "int4"}}, "quant.dtype_qk"),
({"backend": "TRTLLM_ATTN", "quant": {"dtype_qk": "int8", "k_block_size": 8}}, "quant.k_block_size"),
],
)
def test_quant_validation_rejects(self, spec, match):
with pytest.raises(ValueError, match=match):
AttentionSpec(**spec)
class TestAttentionConfig:
def test_empty_config(self):
@@ -47,6 +47,44 @@ def test_skip_config_pure_resolution():
assert cfg.resolve_factor(4096, timestep=None) == pytest.approx(0.01 * 4096)
def test_quant_config_from_backend_kwargs():
from vllm_omni.diffusion.attention.backends.trtllm_attn import QuantConfig
assert not QuantConfig.from_backend_kwargs(None).enabled
assert not QuantConfig.from_backend_kwargs({}).enabled
assert QuantConfig.from_backend_kwargs({"quant": {"dtype_qk": "fp8_e4m3"}}) == QuantConfig("fp8_e4m3", 1, 16)
qc = QuantConfig.from_backend_kwargs({"quant": {"dtype_qk": "int8", "q_block_size": 4, "k_block_size": 16}})
assert qc.enabled and (qc.dtype_qk, qc.q_block_size, qc.k_block_size) == ("int8", 4, 16)
def test_quant_rejects_non_sage_dtype():
with pytest.raises(RuntimeError, match="supports dtype_qk"):
_impl(quant={"dtype_qk": "bfloat16"})
def test_quant_quantize_requires_flashinfer_routine(monkeypatch):
import vllm_omni.diffusion.attention.backends.trtllm_attn as mod
monkeypatch.setattr(mod, "_sage_kernel_available", lambda: True)
monkeypatch.setattr(mod, "_sage_quantize_fn", lambda: None)
with pytest.raises(RuntimeError, match="trtllm_sage_attention_quantize"):
_impl(quant={"dtype_qk": "int8"})
def test_quant_quantize_calls_routine_and_shapes_sfs():
from vllm_omni.diffusion.attention.backends.trtllm_attn import QuantConfig
captured = {}
def fake_quantize(q, k, v, q_block_size, k_block_size, qk_quant_dtype):
captured.update(q_block_size=q_block_size, k_block_size=k_block_size, qk_quant_dtype=qk_quant_dtype)
return "qq", "kq", "vq", "qsfs", "ksfs", "vsfs"
q_q, k_q, v_q, sfs, blk = QuantConfig(dtype_qk="int8").quantize(object(), object(), object(), fake_quantize)
assert (q_q, k_q, v_q, sfs, blk) == ("qq", "kq", "vq", ("qsfs", "ksfs", None, "vsfs"), (1, 16, 0, 1))
assert captured == {"q_block_size": 1, "k_block_size": 16, "qk_quant_dtype": torch.int8}
def test_skip_factor_none_without_curve():
assert _impl(target_sparsity=0.5)._resolve_skip_factor(4096) is None
@@ -257,6 +295,32 @@ def test_bf16_dense_matches_sdpa():
assert rel < 0.01, f"BF16 dense rel err {rel:.4f} too high"
requires_sage = pytest.mark.skipif(
not (_has_trtllm_attn() and tg._sage_quantize_fn() is not None),
reason="requires Blackwell SM100+ GPU with flashinfer >= 0.6.16rc1 (trtllm_sage_attention_quantize)",
)
@requires_sage
@pytest.mark.parametrize("dtype_qk", ["fp8_e4m3", "int8"])
def test_sage_quant_matches_sdpa(dtype_qk):
torch.manual_seed(0)
B, S, H, D = 2, 256, 8, 128
scale = 1.0 / math.sqrt(D)
q, k, v = (torch.randn(B, S, H, D, device="cuda", dtype=torch.bfloat16) for _ in range(3))
impl = _impl(quant={"dtype_qk": dtype_qk, "q_block_size": 1, "k_block_size": 16})
try:
out = impl.forward_cuda(q, k, v, None)
except RuntimeError as e: # pragma: no cover - arch-dependent (int8 is SM100-only)
if "Missing TRTLLM-GEN kernel" in str(e):
pytest.skip(f"no trtllm-gen SAGE kernel for dtype_qk={dtype_qk} on this GPU arch")
raise
assert out.shape == (B, S, H, D) and torch.isfinite(out).all()
ref = _sdpa_ref(q, k, v, scale)
rel = (out.float() - ref).abs().mean() / ref.abs().mean()
assert rel < 0.1, f"SAGE({dtype_qk}) rel err {rel:.4f} too high"
@requires_trtllm_attn
def test_skip_tiny_threshold_matches_dense():
torch.manual_seed(0)
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for duplex ``runtime_control`` payload redaction.
The redacted result is emitted on the WebSocket (for example
``session.created.runtime_control``), so it must always survive
``json.dumps``. Control results carry typed values straight from the engine --
``fence`` and ``accepted_fence`` are ``DuplexFence`` instances added by
``DuplexControlClient.execute`` -- which previously reached
``WebSocket.send_json`` unconverted and killed the session at handshake.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from enum import Enum
import pytest
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.openai.runtime_bridge import NativeRuntimeBridgeMixin
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_redact = NativeRuntimeBridgeMixin._redact_runtime_control_result
def _control_result() -> dict[str, object]:
"""The shape ``DuplexControlClient.execute`` returns for an open."""
return {
"ok": True,
"operation": "open",
"session_id": "sess-1",
"fence": DuplexFence("sess-1", epoch=0, turn_id=0, response_seq=0, incarnation=0),
"accepted_fence": DuplexFence("sess-1", epoch=1, turn_id=2, response_seq=3, incarnation=4),
"stage_results": [{"stage_id": -1, "replica_id": -1, "result": {"supported": True}}],
}
def test_redacted_control_result_is_json_serializable() -> None:
"""The whole payload must serialize the way send_json does."""
redacted = _redact(_control_result())
# Must not raise.
json.dumps({"type": "session.created", "runtime_control": redacted})
def test_fences_are_converted_to_plain_dicts() -> None:
redacted = _redact(_control_result())
assert redacted["fence"] == {
"session_id": "sess-1",
"epoch": 0,
"turn_id": 0,
"response_seq": 0,
"incarnation": 0,
}
# Field values are preserved, not just the type.
assert redacted["accepted_fence"]["epoch"] == 1
assert redacted["accepted_fence"]["turn_id"] == 2
assert redacted["accepted_fence"]["response_seq"] == 3
assert redacted["accepted_fence"]["incarnation"] == 4
def test_nested_dataclasses_are_converted() -> None:
"""Fences can appear inside stage results, not only at the top level."""
redacted = _redact(
{
"stage_results": [
{"stage_id": 0, "result": {"fence": DuplexFence("sess-2", epoch=7)}},
]
}
)
json.dumps(redacted)
assert redacted["stage_results"][0]["result"]["fence"]["epoch"] == 7
def test_enums_are_reduced_to_values() -> None:
class _Mode(str, Enum):
APPEND = "append_audio_chunk"
redacted = _redact({"mode": _Mode.APPEND})
json.dumps(redacted)
assert redacted["mode"] == "append_audio_chunk"
def test_redaction_still_drops_heavy_payload_keys() -> None:
"""The pre-existing contract must be preserved."""
redacted = _redact(
{
"ok": True,
"stage_handoff": object(),
"tts_handoff": object(),
"omni_payload": object(),
"tts_hidden_states": object(),
"tts_token_ids": object(),
"traceback": "long trace",
}
)
assert redacted == {"ok": True}
def test_plain_values_pass_through_unchanged() -> None:
payload = {"ok": True, "count": 3, "name": "x", "missing": None, "items": [1, "two"]}
assert _redact(payload) == payload
def test_dataclass_types_are_not_treated_as_instances() -> None:
"""A class object is not a dataclass instance and must not be asdict'd."""
@dataclass
class _Shape:
x: int = 1
assert _redact(_Shape) is _Shape
@@ -150,7 +150,16 @@ def test_text_to_audio_long_output_001(omni_server, openai_client) -> None:
request_config = {
"model": omni_server.model,
"messages": messages,
"modalities": ["text", "audio"],
"stream": True,
# Delimit the assistant answer with the TTS template so the Talker
# does not spend its codec-token budget speaking hidden reasoning.
"extra_body": {
"chat_template_kwargs": {
"use_tts_template": True,
"enable_thinking": False,
}
},
"key_words": {"audio": ["Beijing"]},
}
+13
View File
@@ -158,3 +158,16 @@ def test_extract_stage_metadata_preserves_legacy_one_argument_api():
assert metadata.stage_id == 0
assert metadata.model_stage == "thinker"
assert metadata.custom_process_input_func is operator.add
def test_extract_stage_metadata_defaults_missing_engine_input_source():
pipeline, deploy = _metadata_inputs()
legacy_config = merge_pipeline_deploy(
pipeline,
deploy,
)[0].to_omegaconf()
del legacy_config["engine_input_source"]
metadata = extract_stage_metadata(legacy_config)
assert metadata.engine_input_source == []
+58
View File
@@ -3,6 +3,8 @@
import os
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
import pytest
import torch
@@ -13,6 +15,10 @@ from vllm_omni.config.yaml_util import create_config
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.engine.arg_utils import OmniEngineArgs
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.stage_init_utils import (
get_stage_connector_spec,
load_omni_transfer_config_for_model,
)
from vllm_omni.entrypoints.utils import (
_convert_dataclasses_to_dict,
_filter_dict_like_object,
@@ -313,6 +319,58 @@ class TestResolveModelConfigPath:
assert result is not None
assert "glm_image.yaml" in result
def test_uses_registry_default_deploy_when_hf_model_type_differs(
self,
mocker: MockerFixture,
tmp_path,
):
from vllm_omni.config.stage_config import _DEPLOY_DIR as _REAL_DEPLOY_DIR
from vllm_omni.entrypoints import utils as utils_mod
hf_config = SimpleNamespace(
model_type="minicpmo",
architectures=["MiniCPMO"],
version="4.5",
)
model = "local/minicpmo-registry-default-test"
mocker.patch(
"vllm_omni.entrypoints.utils.get_config",
return_value=hf_config,
)
mocker.patch(
"vllm_omni.config.config_factory.get_config",
return_value=hf_config,
)
# A bare deploy/<hf_model_type>.yaml must not win over the registered
# pipeline default when HF model_type is not an OMNI_PIPELINES key.
(tmp_path / "minicpmo.yaml").write_text("stages: []\n", encoding="utf-8")
(tmp_path / "minicpmo_4_5.yaml").write_bytes((_REAL_DEPLOY_DIR / "minicpmo_4_5.yaml").read_bytes())
mocker.patch.object(utils_mod, "_DEPLOY_DIR", tmp_path)
result = resolve_model_config_path(model)
assert result is not None
assert Path(result).as_posix().endswith("minicpmo_4_5.yaml")
assert Path(result).name == "minicpmo_4_5.yaml"
transfer_config = load_omni_transfer_config_for_model(model, result)
assert transfer_config is not None
sender = get_stage_connector_spec(
omni_transfer_config=transfer_config,
stage_id=1,
async_chunk=True,
)
receiver = get_stage_connector_spec(
omni_transfer_config=transfer_config,
stage_id=2,
async_chunk=True,
)
assert sender["extra"]["role"] == "sender"
assert receiver["extra"]["role"] == "receiver"
assert sender["extra"]["connector_get_sleep_s"] == 0.01
assert sender["extra"]["connector_get_max_wait_first_chunk"] == 3000
assert sender["extra"]["connector_get_max_wait"] == 300
class TestLoadAndResolveStageConfigs:
def test_load_and_resolve_with_kwargs(self, mocker: MockerFixture):
@@ -163,11 +163,13 @@ class TestDeployTopology:
assert stages[1].yaml_engine_args["custom_process_next_stage_input_func"].endswith(expected_processor)
if filename == "minicpmo_4_5.yaml":
assert [stage.yaml_engine_args["max_num_seqs"] for stage in stages] == [4, 4, 4]
assert [stage.yaml_engine_args["gpu_memory_utilization"] for stage in stages] == [
memory_utilizations = [stage.yaml_engine_args["gpu_memory_utilization"] for stage in stages]
assert memory_utilizations == [
0.55,
0.22,
0.22,
0.15,
0.15,
]
assert sum(memory_utilizations) <= 0.9 + 1e-6
assert stages[0].yaml_engine_args["limit_mm_per_prompt"] == {"video": {"count": 1, "num_frames": 32}}
elif filename in {"minicpmo_4_5_2gpu.yaml"}:
assert [stage.yaml_engine_args["gpu_memory_utilization"] for stage in stages] == [
@@ -235,6 +235,9 @@ class _NoopPerf:
def stop(self, name: str) -> None:
pass
def reset(self) -> None:
pass
class _FakeTTS:
feat_decoder = SimpleNamespace(estimator=SimpleNamespace(_compiled=False))
@@ -253,6 +256,164 @@ class _FakeTTS:
return x.squeeze(1).mean(dim=1).repeat(1, 4)
class TestChunkedPrefillContract:
@staticmethod
def _make_talker(prompt_len: int = 30):
from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import _PrefillInputs
class FakeTTS:
def feat_encoder(self, x: torch.Tensor) -> torch.Tensor:
return x
def enc_to_lm_proj(self, x: torch.Tensor) -> torch.Tensor:
return x.squeeze(2)
def stop_proj(self, x: torch.Tensor) -> torch.Tensor:
return x
def stop_actn(self, x: torch.Tensor) -> torch.Tensor:
return x
def stop_head(self, x: torch.Tensor) -> torch.Tensor:
return x[:, :2]
def lm_to_dit_proj(self, x: torch.Tensor) -> torch.Tensor:
return x
def res_to_dit_proj(self, x: torch.Tensor) -> torch.Tensor:
return x
class FakeScaffold:
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return input_ids.to(torch.float32).unsqueeze(-1).repeat(1, 1, 4)
talker = _make_bare_talker()
talker._tts = FakeTTS()
talker.model = FakeScaffold()
talker.config = SimpleNamespace(bos_token_id=-1, hidden_size=4)
talker._side_dtype = torch.float32
talker._get_multichar_zh_split = lambda: {}
talker._perf = _NoopPerf()
talker._enable_torch_compile = False
talker._setup_cfm_buffers = lambda: None
talker._run_cfm_for_state = lambda *_args: torch.ones(1, 1, 4)
text_token = torch.arange(prompt_len, dtype=torch.int32).unsqueeze(0)
audio_feat = torch.zeros(1, prompt_len, 1, 4)
text_mask = torch.ones(1, prompt_len, dtype=torch.int32)
audio_mask = torch.zeros(1, prompt_len, dtype=torch.int32)
talker._build_prefill_inputs = lambda *_args, **_kwargs: _PrefillInputs(
text_token=text_token,
audio_feat=audio_feat,
text_mask=text_mask,
audio_mask=audio_mask,
)
return talker
def test_preprocess_slices_full_embeddings_by_request_position(self) -> None:
talker = self._make_talker()
_, first, _ = talker.preprocess(
input_ids=torch.ones(15, dtype=torch.int32),
input_embeds=None,
request_id="req",
text_token_ids=[list(range(29))],
_omni_is_prefill=True,
_omni_num_computed_tokens=0,
)
state = talker._active_states["req"]
state.decode_step_count = 7
_, second, _ = talker.preprocess(
input_ids=torch.ones(15, dtype=torch.int32),
input_embeds=None,
request_id="req",
text_token_ids=[list(range(29))],
_omni_is_prefill=True,
_omni_num_computed_tokens=15,
)
torch.testing.assert_close(first[:, 0], torch.arange(15, dtype=torch.float32))
torch.testing.assert_close(second[:, 0], torch.arange(15, 30, dtype=torch.float32))
assert state.decode_step_count == 7
assert state.is_last_prefill_chunk is True
def test_prefill_cache_lifecycle_across_chunks(self) -> None:
talker = self._make_talker()
prompt_cache = {"mode": "reference", "ref_audio_feat": torch.ones(1)}
build_calls = []
def _build_prompt_cache(**_kwargs):
build_calls.append(1)
return prompt_cache
talker._build_prompt_cache = _build_prompt_cache
talker.preprocess(
input_ids=torch.ones(15, dtype=torch.int32),
input_embeds=None,
request_id="req",
text_token_ids=[list(range(29))],
reference_audio="ref.wav",
_omni_is_prefill=True,
_omni_num_computed_tokens=0,
)
state = talker._active_states["req"]
prefill_embeds = state.prefill_embeds
prefill_masks = state.prefill_masks
talker.preprocess(
input_ids=torch.ones(15, dtype=torch.int32),
input_embeds=None,
request_id="req",
text_token_ids=[list(range(29))],
reference_audio="ref.wav",
_omni_is_prefill=True,
_omni_num_computed_tokens=15,
)
assert build_calls == [1]
assert state.prompt_cache is prompt_cache
assert state.prefill_embeds is prefill_embeds
assert state.prefill_masks is prefill_masks
from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import _PrefillResidualMeta
talker._finish_prefill(
state,
_PrefillResidualMeta(
lm_hidden=torch.ones(1, 4),
prefix_feat_cond=torch.ones(1, 1, 4),
),
res_out=torch.ones(1, 4),
dev=torch.device("cpu"),
)
assert state.prefill_completed is True
assert state.prompt_cache is None
assert state.prefill_embeds is None
assert state.prefill_masks is None
assert talker._active_states["req"] is state
def test_single_token_tail_is_still_prefill(self) -> None:
talker = self._make_talker()
_, embeds, _ = talker.preprocess(
input_ids=torch.ones(1, dtype=torch.int32),
input_embeds=None,
request_id="req",
text_token_ids=[list(range(29))],
_omni_is_prefill=True,
_omni_num_computed_tokens=29,
)
is_last_prefill_chunk = talker._active_states["req"].is_last_prefill_chunk
is_prefill = talker._pending_requests[-1][1]
assert embeds.shape == (1, 4)
assert embeds[0, 0] == 29
assert is_prefill is True
assert is_last_prefill_chunk is True
class TestDecodeBatchContract:
def test_forward_skip_pending_prefill_keeps_audio_queue_aligned(self) -> None:
class FakeScaffold:
+3 -2
View File
@@ -38,7 +38,8 @@ stages:
- stage_id: 1
max_num_seqs: 4
gpu_memory_utilization: 0.22
# Leave device-level headroom for the colocated HiFi-GAN cuDNN workspace.
gpu_memory_utilization: 0.15
enforce_eager: false
max_num_batched_tokens: 8192
devices: "0"
@@ -58,7 +59,7 @@ stages:
- stage_id: 2
max_num_seqs: 4
gpu_memory_utilization: 0.22
gpu_memory_utilization: 0.15
enforce_eager: true
enable_chunked_prefill: false
max_num_batched_tokens: 65536
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import functools
import inspect
import math
from dataclasses import dataclass, replace
@@ -83,6 +85,66 @@ except Exception as e: # pragma: no cover - import guard
)
@functools.lru_cache(maxsize=1)
def _sage_kernel_available() -> bool:
if not HAS_FLASHINFER:
return False
try:
return "sage_attn_sfs" in inspect.signature(trtllm_ragged_attention_deepseek).parameters
except (TypeError, ValueError):
return False
@functools.lru_cache(maxsize=1)
def _sage_quantize_fn():
try:
from flashinfer import trtllm_sage_attention_quantize
return trtllm_sage_attention_quantize
except Exception: # pragma: no cover
return None
_QK_QUANT_DTYPES = {
"int8": torch.int8,
"fp8_e4m3": torch.float8_e4m3fn,
}
@dataclass(frozen=True)
class QuantConfig:
dtype_qk: str | None = None
q_block_size: int = 1
k_block_size: int = 16
@classmethod
def from_backend_kwargs(cls, backend_kwargs: dict | None) -> "QuantConfig":
q = (backend_kwargs or {}).get("quant") or {}
return cls(
dtype_qk=q.get("dtype_qk"),
q_block_size=int(q.get("q_block_size", 1) or 1),
k_block_size=int(q.get("k_block_size", 16) or 16),
)
@property
def enabled(self) -> bool:
return self.dtype_qk is not None
def quantize(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, quantize_fn):
qk_quant_dtype = _QK_QUANT_DTYPES[self.dtype_qk]
q_q, k_q, v_q, q_sfs, k_sfs, v_sfs = quantize_fn(
q,
k,
v,
q_block_size=self.q_block_size,
k_block_size=self.k_block_size,
qk_quant_dtype=qk_quant_dtype,
)
sage_attn_sfs = (q_sfs, k_sfs, None, v_sfs)
num_elts_per_sage_attn_blk = (self.q_block_size, self.k_block_size, 0, 1)
return q_q, k_q, v_q, sage_attn_sfs, num_elts_per_sage_attn_blk
def _workspace_bytes() -> int:
import vllm.envs as envs
@@ -129,6 +191,30 @@ class TrtllmAttentionImpl(AttentionImpl):
self.skip = SkipSoftmaxConfig.from_backend_kwargs(backend_kwargs)
self._warned_missing_timestep = False
self.quant = QuantConfig.from_backend_kwargs(backend_kwargs)
# Resolve the SAGE quantize fn once at init so the compiled forward path never calls the
# lru_cache-wrapped getter (which triggers a Dynamo graph break every step).
self._sage_quantize_fn = None
if self.quant.enabled:
if self.quant.dtype_qk not in _QK_QUANT_DTYPES:
raise RuntimeError(
f"TRTLLM_ATTN quant (SAGE) supports dtype_qk in {sorted(_QK_QUANT_DTYPES)}, got "
f"{self.quant.dtype_qk!r}. FLASHINFER_ATTN dtypes (float16/bfloat16) are not SAGE."
)
if not _sage_kernel_available():
raise RuntimeError(
"TRTLLM_ATTN quant (SAGE) was requested but this FlashInfer build does not "
"expose the trtllm-gen sage_attn_sfs kernel path. Install a FlashInfer build "
"that provides it, or remove the quant config."
)
self._sage_quantize_fn = _sage_quantize_fn()
if self._sage_quantize_fn is None:
raise RuntimeError(
"TRTLLM_ATTN quant (SAGE) was requested but this FlashInfer build lacks "
"trtllm_sage_attention_quantize (added in flashinfer >= 0.6.16rc1). Upgrade "
"FlashInfer, or remove the quant config."
)
def set_layer_calibration(self, a: float, b: float) -> None:
self.skip = replace(self.skip, a=a, b=b)
@@ -203,6 +289,15 @@ class TrtllmAttentionImpl(AttentionImpl):
_skip_factor = self._resolve_skip_factor(kv_len)
# SAGE kwargs are only understood by newer FlashInfer builds; pass them exclusively when
# SAGE quant is active (which already requires the kernel, checked at init) so the dense
# path stays compatible with older builds that lack these parameters.
sage_kwargs: dict = {}
if self.quant.enabled:
q, k, v, sage_attn_sfs, sage_block_sizes = self.quant.quantize(q, k, v, self._sage_quantize_fn)
sage_kwargs["sage_attn_sfs"] = sage_attn_sfs
sage_kwargs["num_elts_per_sage_attn_blk"] = sage_block_sizes
out = trtllm_ragged_attention_deepseek(
query=q,
key=k,
@@ -222,5 +317,6 @@ class TrtllmAttentionImpl(AttentionImpl):
is_causal=self.causal,
return_lse=False,
skip_softmax_threshold_scale_factor=_skip_factor,
**sage_kwargs,
)
return out.reshape(batch, q_len, num_q_heads, head_dim)
+60 -16
View File
@@ -1419,44 +1419,88 @@ class SkipSoftmaxSpec:
raise ValueError("skip_softmax: set either target_sparsity or threshold, not both.")
@dataclass
class AttnQuantSpec:
dtype_qk: str | None = None
dtype_vo: str | None = None
q_block_size: int = 1
k_block_size: int = 16
flashinfer_backend: str | None = None
_VALID_DTYPES = frozenset({"float16", "bfloat16", "int8", "fp8_e4m3"})
_VALID_BLOCK_SIZES = frozenset({1, 4, 16})
def __post_init__(self) -> None:
for name, v in (("dtype_qk", self.dtype_qk), ("dtype_vo", self.dtype_vo)):
if v is not None and v not in self._VALID_DTYPES:
raise ValueError(f"quant.{name}={v!r} unsupported; use one of {sorted(self._VALID_DTYPES)}.")
for name, v in (("q_block_size", self.q_block_size), ("k_block_size", self.k_block_size)):
if v not in self._VALID_BLOCK_SIZES:
raise ValueError(
f"quant.{name}={v!r} unsupported; kernels exist only for {sorted(self._VALID_BLOCK_SIZES)}."
)
@property
def enabled(self) -> bool:
return self.dtype_qk is not None or self.dtype_vo is not None
@dataclass
class AttentionSpec:
"""Specifies a backend and its typed backend-specific config for one attention role."""
backend: str
skip_softmax: SkipSoftmaxSpec | None = None
quant: AttnQuantSpec | None = None
skip_calibration: dict | None = field(default=None, repr=False)
def __post_init__(self) -> None:
if not isinstance(self.backend, str):
raise TypeError(f"Expected str for AttentionSpec.backend, got {type(self.backend)!r}")
self.skip_softmax = self._coerce_skip_softmax(self.skip_softmax)
self.skip_softmax = self._coerce(self.skip_softmax, SkipSoftmaxSpec, "skip_softmax")
self.quant = self._coerce(self.quant, AttnQuantSpec, "quant")
if self.skip_softmax is not None and self.backend.upper() != "TRTLLM_ATTN":
raise ValueError(
f"skip_softmax is only supported by the TRTLLM_ATTN backend, but backend={self.backend!r}. "
f"Remove skip_softmax or set backend to TRTLLM_ATTN."
"Remove skip_softmax or set backend to TRTLLM_ATTN."
)
if self.quant is not None and self.backend.upper() not in ("TRTLLM_ATTN", "FLASHINFER_ATTN"):
raise ValueError(
f"quant is only supported by the TRTLLM_ATTN and FLASHINFER_ATTN backends, but "
f"backend={self.backend!r}. Remove quant or set a supported backend."
)
@staticmethod
def _coerce_skip_softmax(value: Any) -> SkipSoftmaxSpec | None:
if value is None or isinstance(value, SkipSoftmaxSpec):
def _coerce(value: Any, cls: type, field_name: str) -> Any:
if value is None or isinstance(value, cls):
return value
if isinstance(value, Mapping):
return SkipSoftmaxSpec(**dict(value))
raise TypeError(f"Expected dict or SkipSoftmaxSpec for skip_softmax, got {type(value)!r}")
return cls(**dict(value))
raise TypeError(f"Expected dict or {cls.__name__} for {field_name}, got {type(value)!r}")
def backend_kwargs(self) -> dict[str, Any] | None:
"""Serialize typed backend config into the kwargs dict the backend impl consumes."""
if self.skip_softmax is None:
return None
ss = self.skip_softmax
kw: dict[str, Any] = {}
if ss.threshold is not None:
kw["skip_softmax_threshold"] = ss.threshold
if ss.target_sparsity is not None:
kw["target_sparsity"] = ss.target_sparsity
if ss.disabled_until_timestep:
kw["disabled_until_timestep"] = ss.disabled_until_timestep
if self.skip_softmax is not None:
ss = self.skip_softmax
if ss.threshold is not None:
kw["skip_softmax_threshold"] = ss.threshold
if ss.target_sparsity is not None:
kw["target_sparsity"] = ss.target_sparsity
if ss.disabled_until_timestep:
kw["disabled_until_timestep"] = ss.disabled_until_timestep
if self.quant is not None and self.quant.enabled:
q = self.quant
quant_kw: dict[str, Any] = {
"dtype_qk": q.dtype_qk,
"q_block_size": q.q_block_size,
"k_block_size": q.k_block_size,
}
if q.dtype_vo is not None:
quant_kw["dtype_vo"] = q.dtype_vo
if q.flashinfer_backend is not None:
quant_kw["flashinfer_backend"] = q.flashinfer_backend
kw["quant"] = quant_kw
return kw or None
@@ -1526,7 +1570,7 @@ class AttentionConfig:
normalized[role] = node
return
spec_keys = {"backend", "skip_softmax"}
spec_keys = {"backend", "skip_softmax", "quant"}
node_dict = dict(node)
node_keys = set(node_dict)
if node_keys & spec_keys:
+1 -1
View File
@@ -372,7 +372,7 @@ def extract_legacy_stage_metadata(stage_config: Any) -> StageMetadata:
engine_args["attention_backend"] = "TRITON_ATTN"
runtime_cfg = stage_config.runtime
engine_input_source: list[int] = stage_config.engine_input_source
engine_input_source: list[int] = _get_attr_or_item(stage_config, "engine_input_source", [])
final_output: bool = stage_config.final_output
final_output_type: str | None = stage_config.final_output_type
+49 -11
View File
@@ -13,6 +13,8 @@ from vllm.transformers_utils.config import get_config, get_hf_file_to_dict
from vllm.transformers_utils.repo_utils import file_or_path_exists
from vllm_omni.config.config_factory import StageConfigFactory, with_trust_remote_code_override
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES
from vllm_omni.config.stage_config import _DEPLOY_DIR
from vllm_omni.config.yaml_util import create_config, load_yaml_config, merge_configs
from vllm_omni.diffusion.utils.hf_utils import _looks_like_dreamzero
from vllm_omni.entrypoints.stage_utils import _to_dict
@@ -226,22 +228,49 @@ def _try_resolve_omni_model_type(model: str) -> str | None:
return best_match
def resolve_model_config_path(model: str) -> str:
"""Resolve the stage config file path from the model name.
def _registry_default_deploy_path(model: str) -> str | None:
"""Return the registered pipeline's default deploy YAML path, if any.
Resolves stage configuration path based on the model type and device type.
First tries to find a device-specific YAML file from stage_configs/{device_type}/
directory. If not found, falls back to the default config file.
A checkpoint's HF ``model_type`` is not always the registered Omni pipeline
key. MiniCPM-o 4.5, for example, reports ``minicpmo`` while its
predicate-selected pipeline is ``minicpmo_4_5``. Stage construction already
loads that pipeline's default deploy config, so connector discovery must
resolve the same path.
"""
pipeline_config = StageConfigFactory.get_pipeline_config(
model=model,
trust_remote_code=True,
)
if pipeline_config is None or pipeline_config.default_deploy_config_name is None:
return None
default_deploy_path = _DEPLOY_DIR / pipeline_config.default_deploy_config_name
if default_deploy_path.is_file():
return str(default_deploy_path)
return None
def resolve_model_config_path(model: str) -> str | None:
"""Resolve the stage/deploy config file path from the model name.
Resolves configuration path based on the model type and device type.
Order:
1. Device-specific stage config under ``stage_configs/{device_type}/``
2. If HF ``model_type`` is not an ``OMNI_PIPELINES`` key, the registered
pipeline's ``default_deploy_config_name`` (keeps connectors aligned with
stage construction when HF type and pipeline key differ)
3. ``deploy/{model_type}.yaml``
4. Legacy ``stage_configs/{model_type}.yaml``
5. Registered pipeline default deploy config (final fallthrough)
Args:
model: Model name or path (used to determine model_type)
Returns:
String path to the stage configuration file
String path to the stage/deploy configuration file, or ``None`` if no
matching config file exists.
Raises:
ValueError: If model_type cannot be determined
FileNotFoundError: If no stage config file exists for the model type
"""
# Try to get config from standard transformers format first
try:
@@ -296,15 +325,24 @@ def resolve_model_config_path(model: str) -> str:
if os.path.exists(complete_config_path):
return str(complete_config_path)
deploy_config_path = PROJECT_ROOT / "vllm_omni" / "deploy" / model_type_str
# Prefer the registry default before deploy/<hf_model_type>.yaml when the
# HF type is not itself a pipeline key. Otherwise a future
# deploy/minicpmo.yaml would desync connectors from stages for MiniCPM-o 4.5.
if normalized_model_type not in OMNI_PIPELINES:
registry_path = _registry_default_deploy_path(model)
if registry_path is not None:
return registry_path
deploy_config_path = _DEPLOY_DIR / model_type_str
if os.path.exists(deploy_config_path):
return str(deploy_config_path)
stage_config_file = f"vllm_omni/model_executor/stage_configs/{normalized_model_type}.yaml"
stage_config_path = PROJECT_ROOT / stage_config_file
if not os.path.exists(stage_config_path):
return None
return str(stage_config_path)
if os.path.exists(stage_config_path):
return str(stage_config_path)
return _registry_default_deploy_path(model)
def load_stage_configs_from_model(
@@ -4,6 +4,8 @@ import asyncio
import base64
import inspect
from collections.abc import Mapping
from dataclasses import asdict, is_dataclass
from enum import Enum
import numpy as np
from vllm.logger import init_logger
@@ -620,6 +622,17 @@ class NativeRuntimeBridgeMixin:
return redacted
if isinstance(value, list | tuple):
return [cls._redact_runtime_control_result(item) for item in value]
if is_dataclass(value) and not isinstance(value, type):
# Control results carry typed values straight from the engine:
# `fence` and `accepted_fence` are `DuplexFence` instances added by
# `DuplexControlClient.execute`. This redacted value is emitted on
# the wire (for example `session.created.runtime_control` in
# `session_runner`), where `WebSocket.send_json` would raise
# `TypeError: Object of type DuplexFence is not JSON serializable`
# and kill the session during the handshake.
return {key: cls._redact_runtime_control_result(child) for key, child in asdict(value).items()}
if isinstance(value, Enum):
return value.value
return value
async def _send_native_duplex_events(
@@ -89,7 +89,7 @@ def _rotate_half(x: torch.Tensor) -> torch.Tensor:
class _RotaryEmbedding(CustomOp):
"""RoPE with HuggingFace-compatible CPU/CUDA math and cached NPU tables."""
"""RoPE with cached cos/sin tables; ``forward_native`` keeps the HF on-the-fly math."""
def __init__(self, config) -> None:
super().__init__()
@@ -101,25 +101,34 @@ class _RotaryEmbedding(CustomOp):
rope_theta = getattr(config, "rope_theta", 10000.0)
inv_freq = 1.0 / (rope_theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
self.register_buffer("inv_freq", inv_freq, persistent=False)
if current_omni_platform.is_npu():
max_seq = int(getattr(config, "num_code_groups", 0) or 0) + 1
positions = torch.arange(max_seq, dtype=torch.float32)
freqs = torch.outer(positions, inv_freq)
emb = torch.cat((freqs, freqs), dim=-1)
self.register_buffer("cos_cached", emb.cos(), persistent=False)
self.register_buffer("sin_cached", emb.sin(), persistent=False)
# Build the cos/sin lookup tables once. ``num_code_groups + 1``
# positions cover every re-prefill step of the code predictor. Compute
# in float32 (matching HF) and cast per-call in forward.
max_seq = int(getattr(config, "num_code_groups", 0) or 0) + 1
positions = torch.arange(max_seq, dtype=torch.float32)
freqs = torch.outer(positions, inv_freq)
emb = torch.cat((freqs, freqs), dim=-1)
self.register_buffer("cos_cached", emb.cos(), persistent=False)
self.register_buffer("sin_cached", emb.sin(), persistent=False)
def _lookup(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
# position_ids: [batch, seq_len] -> cos/sin: [batch, seq_len, head_dim]
cos = self.cos_cached[position_ids]
sin = self.sin_cached[position_ids]
return cos.to(dtype=x.dtype), sin.to(dtype=x.dtype)
def forward_npu(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
return self.cos_cached[position_ids].to(dtype=x.dtype), self.sin_cached[position_ids].to(dtype=x.dtype)
return self._lookup(x, position_ids)
def forward_cuda(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
return self.forward_native(x, position_ids)
return self._lookup(x, position_ids)
def forward_xpu(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
return self.forward_native(x, position_ids)
return self._lookup(x, position_ids)
def forward_native(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
# position_ids: [batch, seq_len]
# HuggingFace on-the-fly computation, kept as a numeric reference.
inv_freq_expanded = self.inv_freq[None, :, None].float().expand(position_ids.shape[0], -1, 1)
position_ids_expanded = position_ids[:, None, :].float()
@@ -264,10 +264,12 @@ class _RequestState:
decode_step_count: int = 0
request_start_time: float = 0.0
prefill_completed: bool = False
is_last_prefill_chunk: bool = False
prompt_cache: dict | None = None
prefill_masks: tuple | None = None
is_stopping: bool = False
precomputed_is_stopping: bool | None = None
prefill_embeds: torch.Tensor | None = None
@dataclasses.dataclass
@@ -2054,7 +2056,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
if is_prefill:
flush_decode_fsq_batch()
res_input, meta = self._prepare_residual_prefill(state, req_hidden, dev)
res_input, meta = self._prepare_residual_prefill(state, req_hidden, dev, req_pos)
elif state.prefill_completed:
if (
self._enable_batched_fsq_fusion
@@ -2141,7 +2143,8 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
offset += n
if is_prefill:
prefill_batch.append((state, meta, res_out))
if state.is_last_prefill_chunk:
prefill_batch.append((state, meta, res_out))
else:
self._finish_decode(state, meta, res_out)
decoded_states.append(state)
@@ -2152,20 +2155,28 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
for state, meta, res_out in prefill_batch:
self._finish_prefill(state, meta, res_out, dev)
collect_states = [state for state, _, _ in req_metas]
self._precompute_stop_flags_for_audio_collect(collect_states)
ready_audio_by_req = self._drain_ready_audio_copies_for_states(collect_states)
audio_by_req = self._collect_audio_batch(
collect_states,
initial_delayed_chunks_by_req={
state.request_id: ready_audio_by_req.get(state.request_id)
for state, is_prefill, _ in req_metas
if not is_prefill
},
)
collect_states = [
state for state, is_prefill, _ in req_metas if not is_prefill or state.is_last_prefill_chunk
]
audio_by_req: dict[str, torch.Tensor | None] = {}
if collect_states:
self._precompute_stop_flags_for_audio_collect(collect_states)
ready_audio_by_req = self._drain_ready_audio_copies_for_states(collect_states)
audio_by_req = self._collect_audio_batch(
collect_states,
initial_delayed_chunks_by_req={
state.request_id: ready_audio_by_req.get(state.request_id)
for state, is_prefill, _ in req_metas
if not is_prefill
},
)
for state, is_prefill, _ in req_metas:
self._results_queue.append((state.request_id, state.precomputed_stop_logits))
self._audio_queue.append((state.request_id, audio_by_req.get(state.request_id)))
if is_prefill and not state.is_last_prefill_chunk:
self._results_queue.append((state.request_id, None))
self._audio_queue.append((state.request_id, None))
else:
self._results_queue.append((state.request_id, state.precomputed_stop_logits))
self._audio_queue.append((state.request_id, audio_by_req.get(state.request_id)))
self._pending_requests.clear()
self._flush_deferred_cleanup()
@@ -2179,11 +2190,14 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
state: _RequestState,
base_lm_out: torch.Tensor,
dev: torch.device,
req_pos: torch.Tensor,
) -> tuple[torch.Tensor, _PrefillResidualMeta]:
tts = self.tts
text_mask, feat_mask, feat, feat_embed = state.prefill_masks
state.prefill_masks = None
text_mask = text_mask[:, req_pos]
feat_mask = feat_mask[:, req_pos]
feat = feat[:, req_pos]
feat_embed = feat_embed[:, req_pos]
tts_len = text_mask.shape[1]
scaffold_len = base_lm_out.shape[0]
assert scaffold_len == tts_len, (
@@ -2424,6 +2438,9 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
state.decode_step_count = 0
state.request_start_time = time.perf_counter()
state.prefill_completed = True
state.prefill_masks = None
state.prefill_embeds = None
state.prompt_cache = None
self._perf.stop("prefill_tail_batch")
if self._enable_profiling:
@@ -2465,6 +2482,9 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
state.decode_step_count = 0
state.request_start_time = time.perf_counter()
state.prefill_completed = True
state.prefill_embeds = None
state.prefill_masks = None
state.prompt_cache = None
self._perf.stop("prefill_tail")
if logger.isEnabledFor(logging.DEBUG):
@@ -3033,7 +3053,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
span_len = int(input_ids.shape[0])
dev = input_ids.device
req_id = info_dict.get("request_id", "default")
is_prefill = span_len > 1
is_prefill = bool(info_dict.get("_omni_is_prefill", span_len > 1))
if is_prefill:
# Do not evict state here: _pending_requests is a per-step prefix,
@@ -3056,22 +3076,29 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
token_ids = token_ids[1:]
state = self._get_or_create_state(req_id)
state.decode_pad = None
state.prefill_completed = False
state.decode_step_count = 0
state.precomputed_stop_logits = None
state.precomputed_is_stopping = None
state.last_audio_patch_gpu = None
if not hasattr(state, "pending_audio_chunks_gpu"):
state.pending_audio_chunks_gpu = []
if not hasattr(state, "pending_audio_copies"):
state.pending_audio_copies = []
state.pending_audio_chunks_gpu.clear()
state.pending_audio_copies.clear()
state.curr_embed_for_next = None
state.prev_feat_embed = None
state.curr_prefix_feat_cond = None
state.is_stopping = False
num_computed_tokens = int(info_dict.get("_omni_num_computed_tokens", 0))
is_first_prefill_chunk = num_computed_tokens == 0
if is_first_prefill_chunk:
state.decode_pad = None
state.prefill_completed = False
state.is_last_prefill_chunk = False
state.decode_step_count = 0
state.precomputed_stop_logits = None
state.precomputed_is_stopping = None
state.last_audio_patch_gpu = None
if not hasattr(state, "pending_audio_chunks_gpu"):
state.pending_audio_chunks_gpu = []
if not hasattr(state, "pending_audio_copies"):
state.pending_audio_copies = []
state.pending_audio_chunks_gpu.clear()
state.pending_audio_copies.clear()
state.curr_embed_for_next = None
state.prev_feat_embed = None
state.curr_prefix_feat_cond = None
state.is_stopping = False
state.prompt_cache = None
state.prefill_embeds = None
state.prefill_masks = None
# Voice clone / continuation
ref_audio = info_dict.get("reference_audio") or info_dict.get("ref_audio")
@@ -3088,13 +3115,12 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
voice_profile = voice_profile[0] if voice_profile else None
requires_precomputed_cache = isinstance(voice_profile, dict)
state.prompt_cache = None
voice_name = info_dict.get("voice_name")
if isinstance(voice_name, list):
voice_name = voice_name[0] if voice_name else None
_created_at = int(info_dict.get("voice_created_at") or 0)
if voice_name:
if state.prompt_cache is None and voice_name:
_cache_key = self._speaker_cache.make_cache_key(
voice_name, model_type="voxcpm2", created_at=_created_at
)
@@ -3131,14 +3157,24 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module):
_key = self._speaker_cache.make_cache_key(voice_name, model_type="voxcpm2", created_at=_created_at)
self._speaker_cache.put(_key, {"ref_audio_feat": state.prompt_cache["ref_audio_feat"].cpu()})
logger.debug("Speaker cache STORE for VoxCPM2 speaker '%s'", voice_name)
inputs = self._build_prefill_inputs(token_ids, dev, req_id)
tts = self.tts
feat_embed = tts.enc_to_lm_proj(tts.feat_encoder(inputs.audio_feat))
text_embed = self.model.embed_input_ids(inputs.text_token.to(dev))
text_mask, feat_mask = inputs.text_mask, inputs.audio_mask
embeds = (text_mask.unsqueeze(-1) * text_embed + feat_mask.unsqueeze(-1) * feat_embed).squeeze(0)
state.prefill_masks = (text_mask, feat_mask, inputs.audio_feat, feat_embed)
if state.prefill_embeds is None or state.prefill_masks is None:
inputs = self._build_prefill_inputs(token_ids, dev, req_id)
tts = self.tts
feat_embed = tts.enc_to_lm_proj(tts.feat_encoder(inputs.audio_feat))
text_embed = self.model.embed_input_ids(inputs.text_token.to(dev))
text_mask, feat_mask = inputs.text_mask, inputs.audio_mask
embeds = (text_mask.unsqueeze(-1) * text_embed + feat_mask.unsqueeze(-1) * feat_embed).squeeze(0)
state.prefill_masks = (text_mask, feat_mask, inputs.audio_feat, feat_embed)
state.prefill_embeds = embeds
chunk_end = num_computed_tokens + span_len
state.is_last_prefill_chunk = chunk_end == state.prefill_embeds.shape[0]
if chunk_end > state.prefill_embeds.shape[0]:
raise ValueError(
"VoxCPM2 chunked prefill exceeds the constructed prompt: "
f"request_id={req_id} start={num_computed_tokens} end={chunk_end} "
f"prompt_len={state.prefill_embeds.shape[0]}"
)
embeds = state.prefill_embeds[num_computed_tokens:chunk_end]
else:
state = self._active_states.get(req_id)
curr = state.curr_embed_for_next if state else None