[Feature] Support Prefill-Decode disaggregation via vLLM KV transfer (#2220)

Signed-off-by: LiuBingyu <liubingyu62@gmail.com>
This commit is contained in:
Bingyu (Spencer) Liu authored and GitHub committed 2026-04-17 18:43:17 +08:00
1 parent b88d3ce75e
commit a5a4998ece
11 files changed
+1672 -48

No files matched your search

+26 -10
View File
@@ -26,9 +26,15 @@ models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
QWEN3_OMNI_CONFIG_PATH = str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml")
QWEN3_OMNI_XPU_CONFIG_PATH = str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")
_STAGE_CONFIGS_DIR = Path(__file__).parent.parent / "stage_configs"
_PD_SEP_CONFIG = str(_STAGE_CONFIGS_DIR / "qwen3_omni_moe_pd_ci.yaml")
def get_chunk_config(config_path: str):
path = modify_stage_config(
def get_chunk_config(config_path: str | None = None):
"""Load qwen3_omni_ci.yaml with async_chunk modifications for streaming mode."""
if config_path is None:
config_path = str(_STAGE_CONFIGS_DIR / "qwen3_omni_ci.yaml")
return modify_stage_config(
config_path,
updates={
"async_chunk": True,
@@ -43,7 +49,6 @@ def get_chunk_config(config_path: str):
},
deletes={"stage_args": {2: ["custom_process_input_func"]}},
)
return path
def get_prefix_caching_config(config_path: str):
@@ -59,11 +64,20 @@ def get_prefix_caching_config(config_path: str):
return path
if current_omni_platform.is_xpu():
stage_configs = [QWEN3_OMNI_XPU_CONFIG_PATH]
prefix_caching_stage_configs = [get_prefix_caching_config(QWEN3_OMNI_XPU_CONFIG_PATH)]
else: # MI325 GPU should share the same config as H100
stage_configs = [get_chunk_config(QWEN3_OMNI_CONFIG_PATH)]
# Set VLLM_TEST_PD_MODE=1 to test PD disaggregation, default tests async_chunk mode.
_USE_PD = os.environ.get("VLLM_TEST_PD_MODE", "0") == "1"
# Stage configs for H100/CUDA, ROCm MI325, and XPU platforms
if current_omni_platform.is_rocm():
rocm_config = str(_STAGE_CONFIGS_DIR / "rocm" / "qwen3_omni_ci.yaml")
stage_configs = [rocm_config]
prefix_caching_stage_configs = [get_prefix_caching_config(rocm_config)]
elif current_omni_platform.is_xpu():
xpu_config = str(_STAGE_CONFIGS_DIR / "xpu" / "qwen3_omni_ci.yaml")
stage_configs = [xpu_config]
prefix_caching_stage_configs = [get_prefix_caching_config(xpu_config)]
else:
stage_configs = [_PD_SEP_CONFIG if _USE_PD else get_chunk_config(QWEN3_OMNI_CONFIG_PATH)]
prefix_caching_stage_configs = [get_prefix_caching_config(QWEN3_OMNI_CONFIG_PATH)]
# Create parameter combinations for model and stage config
@@ -116,7 +130,8 @@ def get_max_batch_size(size_type="few"):
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.skipif(_USE_PD, reason="Temporarily skip PD mode in this test module.")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=3 if _USE_PD else 2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"""
@@ -155,7 +170,8 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.skipif(_USE_PD, reason="Temporarily skip PD mode in this test module.")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=3 if _USE_PD else 2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_text_001(omni_server, openai_client) -> None:
"""
File diff suppressed because it is too large. Load diff
+46
View File
@@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any
import janus
import torch
from omegaconf import OmegaConf
from vllm import envs as vllm_envs
from vllm.engine.arg_utils import EngineArgs
from vllm.inputs import PromptType
from vllm.logger import init_logger
@@ -79,6 +80,7 @@ from vllm_omni.engine.stage_init_utils import (
setup_stage_devices,
terminate_alive_proc,
)
from vllm_omni.entrypoints.pd_utils import PDDisaggregationMixin
from vllm_omni.entrypoints.utils import (
inject_omni_kv_config,
load_and_resolve_stage_configs,
@@ -902,6 +904,7 @@ class AsyncOmniEngine:
self._initialize_janus_queues()
self._initialize_stages(stage_init_timeout)
pd_config = self._detect_pd_config()
orchestrator = Orchestrator(
request_async_queue=self.request_queue.async_q,
output_async_queue=self.output_queue.async_q,
@@ -910,6 +913,7 @@ class AsyncOmniEngine:
stage_clients=self.stage_clients,
output_processors=self.output_processors,
stage_vllm_configs=self.stage_vllm_configs,
pd_config=pd_config,
)
if not startup_future.done():
startup_future.set_result(asyncio.get_running_loop())
@@ -1131,6 +1135,48 @@ class AsyncOmniEngine:
cache_config = AsyncOmniEngine._get_default_cache_config(cache_backend)
return cache_config
def _detect_pd_config(self) -> dict[str, Any] | None:
"""Detect PD (Prefill-Decode) disaggregation config from stage_configs.
Returns a dict with 'pd_pair' and 'bootstrap_addr', or None.
"""
pd_pair = PDDisaggregationMixin.detect_pd_separation_from_stage_configs(self.stage_configs)
if pd_pair is None:
return None
prefill_idx, decode_idx = pd_pair
# Extract bootstrap address from prefill stage engine_args
bootstrap_addr: str | None = None
try:
prefill_cfg = self.stage_configs[prefill_idx]
ea = getattr(prefill_cfg, "engine_args", None)
kv_cfg = getattr(ea, "kv_transfer_config", None) if ea is not None else None
if kv_cfg is not None:
port = vllm_envs.VLLM_MOONCAKE_BOOTSTRAP_PORT
kv_ip = getattr(kv_cfg, "kv_ip", None) or "127.0.0.1"
bootstrap_addr = f"http://{kv_ip}:{port}"
except Exception as exc:
logger.warning("[AsyncOmniEngine] Could not extract PD bootstrap address: %s", exc)
logger.info(
"[AsyncOmniEngine] PD disaggregation detected: prefill=stage-%d, decode=stage-%d, bootstrap=%s",
prefill_idx,
decode_idx,
bootstrap_addr,
)
prefill_engine_id: str | None = None
try:
prefill_client = self.stage_clients[prefill_idx]
kv_cfg = getattr(getattr(prefill_client, "vllm_config", None), "kv_transfer_config", None)
prefill_engine_id = getattr(kv_cfg, "engine_id", None)
except Exception as exc:
logger.warning("[AsyncOmniEngine] Could not extract prefill engine_id: %s", exc)
return {
"pd_pair": (prefill_idx, decode_idx),
"bootstrap_addr": bootstrap_addr,
"prefill_engine_id": prefill_engine_id,
}
@staticmethod
def _create_default_diffusion_stage_cfg(kwargs: dict[str, Any]) -> list:
"""Create a default single-stage diffusion config from kwargs."""
+131 -1
View File
@@ -42,6 +42,7 @@ def build_engine_core_request_from_tokens(
params: SamplingParams | PoolingParams,
arrival_time: float | None = None,
model_config: ModelConfig | None = None,
mm_features: list | None = None,
) -> OmniEngineCoreRequest:
"""Build an OmniEngineCoreRequest directly from an OmniTokensPrompt.
@@ -76,7 +77,7 @@ def build_engine_core_request_from_tokens(
return OmniEngineCoreRequest(
request_id=request_id,
prompt_token_ids=prompt_token_ids,
mm_features=None,
mm_features=mm_features,
sampling_params=sampling_params,
pooling_params=pooling_params,
arrival_time=arrival_time,
@@ -104,6 +105,8 @@ class OrchestratorRequestState:
# Metrics: timestamp when request was submitted to each stage
stage_submit_ts: dict[int, float] = field(default_factory=dict)
mm_processor_kwargs: dict | None = None
mm_features: list | None = None
class Orchestrator:
@@ -123,6 +126,7 @@ class Orchestrator:
stage_vllm_configs: list[Any],
*,
async_chunk: bool = False,
pd_config: dict[str, Any] | None = None,
) -> None:
self.request_async_queue = request_async_queue
self.output_async_queue = output_async_queue
@@ -135,6 +139,16 @@ class Orchestrator:
self.output_processors: list[Any] = output_processors
self.stage_vllm_configs: list[Any] = stage_vllm_configs
# PD disaggregation state
self._pd_pair: tuple[int, int] | None = None
self._pd_bootstrap_addr: str | None = None
self._pd_prefill_engine_id: str | None = None
self._pd_kv_params: dict[str, Any] = {}
if pd_config is not None:
self._pd_pair = pd_config.get("pd_pair")
self._pd_bootstrap_addr = pd_config.get("bootstrap_addr")
self._pd_prefill_engine_id = pd_config.get("prefill_engine_id")
# Per-request state
self.request_states: dict[str, OrchestratorRequestState] = {}
@@ -359,6 +373,23 @@ class Orchestrator:
}
)
# PD disaggregation: extract KV transfer params from prefill stage output
if self._pd_pair is not None and finished and stage_id == self._pd_pair[0]:
kv_params = getattr(output, "kv_transfer_params", None)
if kv_params is not None:
self._pd_kv_params[req_id] = kv_params if isinstance(kv_params, dict) else dict(kv_params)
logger.debug(
"[Orchestrator][PD] stored kv_transfer_params for req=%s (keys=%s)",
req_id,
list(self._pd_kv_params[req_id].keys()),
)
else:
logger.warning(
"[Orchestrator][PD] prefill stage output for req=%s has no kv_transfer_params; "
"KV transfer may fail. Ensure apply_mooncake_connector_patch() was called.",
req_id,
)
if (
finished
and stage_id < req_state.final_stage_id
@@ -371,6 +402,8 @@ class Orchestrator:
await self._forward_to_next_stage(req_id, stage_id, output, req_state)
if finished and stage_id == req_state.final_stage_id:
# PD: clean up any lingering KV params for this request
self._pd_kv_params.pop(req_id, None)
self._cfg_tracker.cleanup_parent(req_id)
self.request_states.pop(req_id, None)
@@ -418,6 +451,51 @@ class Orchestrator:
else:
await self._forward_to_next_stage(req_id, stage_id, raw_output, req_state)
def _build_pd_decode_params(self, req_id: str, sp: Any) -> Any:
"""Build decode-side sampling params with KV transfer params for PD routing.
Clones the sampling params and injects kv_transfer_params that tell the
decode engine where to pull the KV cache from (prefill engine's bootstrap addr).
"""
sp = sp.clone()
if sp.extra_args is None:
sp.extra_args = {}
# Get KV params captured from the prefill output (must include remote_request_id).
kv_prefill_params = self._pd_kv_params.pop(req_id, None)
if not kv_prefill_params or "remote_request_id" not in kv_prefill_params:
raise RuntimeError(
f"[Orchestrator][PD] Missing prefill kv_transfer_params.remote_request_id for req={req_id}"
)
decode_kv_params: dict[str, Any] = {
"transfer_id": f"xfer-{req_id}",
}
if self._pd_bootstrap_addr:
decode_kv_params["remote_bootstrap_addr"] = self._pd_bootstrap_addr
if self._pd_prefill_engine_id:
decode_kv_params["remote_engine_id"] = self._pd_prefill_engine_id
# Overlay params from prefill side (includes remote_request_id set by monkey patch).
decode_kv_params.update(kv_prefill_params)
# Ensure these flags are set correctly after any overlay.
decode_kv_params["do_remote_prefill"] = True
decode_kv_params["do_remote_decode"] = False
if not decode_kv_params.get("transfer_id"):
decode_kv_params["transfer_id"] = f"xfer-{req_id}"
sp.extra_args["kv_transfer_params"] = decode_kv_params
logger.debug(
"[Orchestrator][PD] decode kv_transfer_params for req=%s: %s",
req_id,
decode_kv_params,
)
return sp
def _build_stage_metrics(
self,
stage_id: int,
@@ -540,6 +618,52 @@ class Orchestrator:
req_state.stage_submit_ts[next_stage_id] = _time.time()
return
# PD disaggregation: prefill → decode routing uses original prompt + KV transfer params
if self._pd_pair is not None and (stage_id, next_stage_id) == self._pd_pair:
# Save prefill stage outputs so thinker2talker can merge embeddings later
self.stage_clients[stage_id].set_engine_outputs([output])
params = self._build_pd_decode_params(req_id, params)
# Use the original user prompt for the decode stage (not processed embeddings)
original_prompt = req_state.prompt
raw_decode_inputs = [original_prompt] if not isinstance(original_prompt, list) else original_prompt
decode_inputs: list[dict[str, Any]] = []
for decode_input in raw_decode_inputs:
if isinstance(decode_input, dict):
decode_inputs.append(decode_input)
continue
prompt_token_ids = getattr(decode_input, "prompt_token_ids", None)
if prompt_token_ids is None:
raise TypeError(
"[Orchestrator][PD] decode input must be dict or have prompt_token_ids, "
f"got {type(decode_input).__name__} for req={req_id}"
)
decode_inputs.append({"prompt_token_ids": list(prompt_token_ids)})
for decode_input in decode_inputs:
request = build_engine_core_request_from_tokens(
request_id=req_id,
prompt=decode_input,
params=params,
model_config=self.stage_vllm_configs[next_stage_id].model_config,
mm_features=req_state.mm_features, # Pass mm_features for M-RoPE
)
request.external_req_id = request.request_id
self.output_processors[next_stage_id].add_request(
request=request,
prompt=None,
parent_req=None,
request_index=0,
queue=None,
)
await next_client.add_request_async(request)
req_state.stage_submit_ts[next_stage_id] = _time.time()
return
self.stage_clients[stage_id].set_engine_outputs([output])
# Process inputs for next stage
@@ -558,11 +682,16 @@ class Orchestrator:
# Build and submit requests for each input
for next_input in next_inputs:
# Only AR thinker stages consume encoder mm_features; downstream
# (talker/code2wav/…) must not see them (avoids encoder-cache misses).
_ms = getattr(next_client, "model_stage", None)
_mm_features = req_state.mm_features if _ms == "thinker" else None
request = build_engine_core_request_from_tokens(
request_id=req_id,
prompt=next_input,
params=params,
model_config=self.stage_vllm_configs[next_stage_id].model_config,
mm_features=_mm_features,
)
# TODO: Here we directly use the req id to assign.
@@ -644,6 +773,7 @@ class Orchestrator:
prompt=original_prompt,
sampling_params_list=sampling_params_list,
final_stage_id=final_stage_id,
mm_features=getattr(prompt, "mm_features", None), # Save mm_features for PD
)
req_state.stage_submit_ts[stage_id] = _time.time()
self.request_states[request_id] = req_state
+2 -3
View File
@@ -233,10 +233,9 @@ class OmniRequestState(RequestState):
# Reuse base text/logprobs logic, then annotate with pooling_result.
base_output = super()._new_completion_output(token_ids, finish_reason, stop_reason, routed_experts)
try:
if not hasattr(base_output, "multimodal_output"):
setattr(base_output, "multimodal_output", {})
if self.mm_accumulated is not None:
# Attach accumulated multimodal dict on the completion output
if not hasattr(base_output, "multimodal_output"):
setattr(base_output, "multimodal_output", {})
mm_out = getattr(base_output, "multimodal_output")
if isinstance(mm_out, dict):
for k, v in self.mm_accumulated.items():
+16 -3
View File
@@ -78,7 +78,6 @@ class AsyncOmni(EngineClient, OmniBase):
self.final_output_task: asyncio.Task | None = None
self.config_path = self.engine.config_path
self.stage_configs = self.engine.stage_configs
self.tts_max_instructions_length = kwargs.get("tts_max_instructions_length", None)
self.input_processor = self.engine.input_processor
@@ -209,6 +208,13 @@ class AsyncOmni(EngineClient, OmniBase):
# Start final output dispatcher on the first call to generate()
self._final_output_handler()
# Expand sampling params for PD disaggregation (user may provide N-1 params)
if (
sampling_params_list is not None
and isinstance(sampling_params_list, Sequence)
and not isinstance(sampling_params_list, (str, bytes))
):
sampling_params_list = self._maybe_expand_sampling_params(list(sampling_params_list))
sampling_params_list = self.resolve_sampling_params_list(sampling_params_list)
# Track per-request metrics
@@ -228,20 +234,27 @@ class AsyncOmni(EngineClient, OmniBase):
req_state.metrics = metrics
self.request_states[request_id] = req_state
# PD disaggregation: modify prefill-stage sampling params per request
req_sp_list = list(sampling_params_list)
pd_pair = self._get_pd_separation_pair()
if pd_pair is not None:
p_id = pd_pair[0]
req_sp_list[p_id] = self._prepare_prefill_sampling_params(request_id, req_sp_list[p_id])
# Add request(s) to stage 0. For streaming inputs, submit
# chunks incrementally through streaming_update.
if isinstance(prompt, AsyncGenerator):
input_stream_task = await self._add_streaming_input_request(
request_id=request_id,
input_stream=prompt,
sampling_params_list=sampling_params_list,
sampling_params_list=req_sp_list,
final_stage_id=final_stage_id_for_e2e,
)
else:
await self.engine.add_request_async(
request_id=request_id,
prompt=prompt,
sampling_params_list=sampling_params_list,
sampling_params_list=req_sp_list,
final_stage_id=final_stage_id_for_e2e,
)
submit_ts = time.time()
+15 -1
View File
@@ -66,6 +66,13 @@ class Omni(OmniBase):
py_generator: bool = False,
use_tqdm: bool | Callable[..., tqdm] = True,
) -> Generator[OmniRequestOutput, None, None] | list[OmniRequestOutput]:
# Expand sampling params for PD disaggregation (user may provide N-1 params)
if (
sampling_params_list is not None
and isinstance(sampling_params_list, Sequence)
and not isinstance(sampling_params_list, (str, bytes))
):
sampling_params_list = self._maybe_expand_sampling_params(list(sampling_params_list))
sampling_params_list = self.resolve_sampling_params_list(sampling_params_list)
try:
if py_generator:
@@ -125,10 +132,17 @@ class Omni(OmniBase):
req_state.metrics = metrics
self.request_states[req_id] = req_state
# PD disaggregation: modify stage-0 (prefill) sampling params per request
req_sp_list = list(sampling_params_list)
pd_pair = self._get_pd_separation_pair()
if pd_pair is not None:
p_id = pd_pair[0]
req_sp_list[p_id] = self._prepare_prefill_sampling_params(req_id, req_sp_list[p_id])
self.engine.add_request(
request_id=req_id,
prompt=prompt,
sampling_params_list=sampling_params_list,
sampling_params_list=req_sp_list,
final_stage_id=final_stage_id,
)
submit_ts = time.time()
+11 -1
View File
@@ -14,6 +14,7 @@ from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.entrypoints.pd_utils import PDDisaggregationMixin
from vllm_omni.entrypoints.utils import get_final_stage_id_for_e2e
from vllm_omni.metrics.stats import OrchestratorAggregator as OrchestratorMetrics
from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific
@@ -65,7 +66,7 @@ def omni_snapshot_download(model_id: str) -> str:
OutputMessageHandleResult = tuple[Literal[True], None, None, None] | tuple[Literal[False], str, int, ClientRequestState]
class OmniBase:
class OmniBase(PDDisaggregationMixin):
"""Shared runtime foundation for AsyncOmni and Omni."""
def __init__(
@@ -84,6 +85,7 @@ class OmniBase:
if "log_requests" in kwargs:
raise TypeError("`log_requests` has been removed in Omni/AsyncOmni. Use `log_stats`.")
model = omni_snapshot_download(model)
self._name = self.__class__.__name__
self.model = model
self.log_stats = log_stats
self.async_chunk = async_chunk
@@ -125,10 +127,18 @@ class OmniBase:
model,
)
# PD disaggregation state (detects if a prefill/decode stage pair is configured)
self._init_pd_state()
@property
def num_stages(self) -> int:
return self.engine.num_stages
@property
def stage_configs(self) -> list:
"""Expose engine stage configs for PD disaggregation detection and validation."""
return self.engine.stage_configs
@property
def is_running(self) -> bool:
return self.engine.is_alive()
+38 -19
View File
@@ -23,9 +23,19 @@ logger = logging.getLogger(__name__)
class PDDisaggregationMixin:
"""Mixin supplying PD disaggregation helpers to OmniBase."""
def _get_pd_separation_pair(self) -> tuple[int, int] | None:
"""PD prefill/decode indices when ``_init_pd_state`` ran; else ``None``.
Partial test doubles may skip ``OmniBase.__init__``; treat missing state as
no PD disaggregation instead of raising ``AttributeError``.
"""
return getattr(self, "_pd_separation_pair", None)
def _init_pd_state(self) -> None:
"""Initialise PD disaggregation state."""
self._pd_separation_pair: tuple[int, int] | None = self._detect_pd_separation()
self._pd_separation_pair: tuple[int, int] | None = self.detect_pd_separation_from_stage_configs(
self.stage_configs
)
self._pd_connector_info: dict[str, Any] | None = None
self._pd_kv_params_by_req: dict[str, dict[str, Any]] = {}
self._pd_kv_params_lock = threading.Lock()
@@ -40,11 +50,19 @@ class PDDisaggregationMixin:
d_id,
)
def _detect_pd_separation(self) -> tuple[int, int] | None:
"""Scan stage_list for a prefill/decode pair. Returns (p_id, d_id) or None."""
@staticmethod
def detect_pd_separation_from_stage_configs(stage_configs: list[Any]) -> tuple[int, int] | None:
"""Scan stage configs for a prefill/decode pair.
Returns:
(prefill_idx, decode_idx) if one pair exists, None if not found.
Raises:
ValueError: if multiple candidate PD pairs are found.
"""
prefill_by_id: dict[int, int] = {}
decode_indices: list[int] = []
for i, stage in enumerate(self.stage_list):
for i, stage in enumerate(stage_configs):
if getattr(stage, "is_prefill_only", False):
prefill_by_id[i] = i
sid = getattr(stage, "stage_id", i)
@@ -55,7 +73,7 @@ class PDDisaggregationMixin:
pd_pairs: list[tuple[int, int]] = []
for j in decode_indices:
source_ids = getattr(self.stage_list[j], "engine_input_source", [])
source_ids = getattr(stage_configs[j], "engine_input_source", [])
for src in source_ids:
if src in prefill_by_id:
pd_pairs.append((prefill_by_id[src], j))
@@ -107,10 +125,11 @@ class PDDisaggregationMixin:
def _validate_pd_separation_config(self) -> None:
"""Validate PD stage configurations are consistent."""
assert self._pd_separation_pair is not None
p_id, d_id = self._pd_separation_pair
p_stage = self.stage_list[p_id]
d_stage = self.stage_list[d_id]
pair = self._get_pd_separation_pair()
assert pair is not None
p_id, d_id = pair
p_stage = self.stage_configs[p_id]
d_stage = self.stage_configs[d_id]
def _get_kv_cfg(stage: "OmniStage") -> dict[str, Any]:
ea = stage.engine_args
@@ -158,11 +177,12 @@ class PDDisaggregationMixin:
def _get_pd_connector_info(self) -> dict[str, Any] | None:
"""Extract prefill engine KV connector info."""
if self._pd_separation_pair is None:
pair = self._get_pd_separation_pair()
if pair is None:
return None
p_id, _ = self._pd_separation_pair
p_stage = self.stage_list[p_id]
p_id, _ = pair
p_stage = self.stage_configs[p_id]
ea = p_stage.engine_args
kv_cfg = getattr(ea, "kv_transfer_config", None)
@@ -241,18 +261,17 @@ class PDDisaggregationMixin:
def _is_pd_routing(self, stage_id: int, next_stage_id: int) -> bool:
"""True when edge stage_id → next_stage_id is the prefill→decode boundary."""
return self._pd_separation_pair is not None and self._pd_separation_pair == (
stage_id,
next_stage_id,
)
pair = self._get_pd_separation_pair()
return pair is not None and pair == (stage_id, next_stage_id)
def _maybe_expand_sampling_params(self, sampling_params_list: list) -> list:
"""Auto-duplicate thinker SP for decode stage when user provides N-1 params."""
if self._pd_separation_pair is None:
pair = self._get_pd_separation_pair()
if pair is None:
return sampling_params_list
if len(sampling_params_list) != len(self.stage_list) - 1:
if len(sampling_params_list) != len(self.stage_configs) - 1:
return sampling_params_list
p_id, d_id = self._pd_separation_pair
p_id, d_id = pair
sp_list = list(sampling_params_list)
sp_list.insert(d_id, sp_list[p_id])
return sp_list
@@ -891,10 +891,11 @@ class Qwen3OmniMoeForConditionalGeneration(
Returns:
(input_ids, input_embeds) for talker
"""
target_len = thinker_result_ids.shape[-1]
im_start_indexes = torch.cat(
(
torch.nonzero(input_ids[0] == self.config.im_start_token_id).squeeze(),
torch.tensor([thinker_result_ids.shape[-1]], device=input_ids.device, dtype=input_ids.dtype),
torch.tensor([target_len], device=input_ids.device, dtype=input_ids.dtype),
),
dim=-1,
) # Shape [n_starts + 1]; Take batch 0 since batched inference is not supported here.
@@ -1029,8 +1030,35 @@ class Qwen3OmniMoeForConditionalGeneration(
return last_talker_hidden, text_step, update_dict
def _get_talker_user_parts(self, im_start_index, segment_end_index, multimodal_mask, thinker_hidden, thinker_embed):
clamped = min(
segment_end_index,
multimodal_mask.shape[0],
thinker_hidden.shape[0],
thinker_embed.shape[0],
)
if clamped < segment_end_index:
logger.warning(
"_get_talker_user_parts: segment_end_index %d clamped to %d "
"(embed=%d, hidden=%d, mask=%d). "
"This usually means _merge_pd_embeddings failed to merge "
"prefill embeddings – check PD prefill_mm keys.",
segment_end_index,
clamped,
thinker_embed.shape[0],
thinker_hidden.shape[0],
multimodal_mask.shape[0],
)
segment_end_index = clamped
seg_len = segment_end_index - im_start_index
if seg_len <= 0:
return torch.empty(
(0, self.config.talker_config.text_config.hidden_size),
device=thinker_hidden.device,
dtype=torch.bfloat16,
)
user_talker_part = torch.empty(
(segment_end_index - im_start_index, self.config.talker_config.text_config.hidden_size),
(seg_len, self.config.talker_config.text_config.hidden_size),
device=thinker_hidden.device,
dtype=torch.bfloat16,
)
@@ -3,6 +3,7 @@
# Copyright 2025 The Qwen team.
"""Stage input processor for Qwen3 Omni MoE: Thinker → Talker transition."""
import logging
from typing import Any
import torch
@@ -18,6 +19,12 @@ from vllm_omni.model_executor.stage_input_processors.tts_utils import (
extract_speaker_from_request,
)
logger = logging.getLogger(__name__)
# Pooling output layer keys: "0" = word embedding, "24" = accept_hidden_layer
_EMBED_LAYER_KEY = "0"
_HIDDEN_LAYER_KEY = "24"
def _compute_talker_prompt_ids_length(info, device: torch.device | str = "cuda") -> int:
im_start_token_id = 151644
@@ -84,6 +91,95 @@ def _validate_stage_inputs(stage_list, engine_input_source):
return stage.engine_outputs
# =========================
# PD disaggregation helpers
# =========================
def _get_prefill_stage(stage_list: list[Any], source_stage_id: int) -> Any | None:
if source_stage_id <= 0:
return None
source_stage = stage_list[source_stage_id]
if not getattr(source_stage, "is_decode_only", False):
return None
prev_stage = stage_list[source_stage_id - 1]
if getattr(prev_stage, "is_prefill_only", False) and prev_stage.engine_outputs is not None:
return prev_stage
return None
def _merge_pd_embeddings(
decode_emb: torch.Tensor,
decode_hid: torch.Tensor,
prefill_mm: dict[str, Any],
device: torch.device,
expected_total: int | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Merge prefill prompt embeddings with decode generated embeddings.
In PD mode the prefill engine processes the prompt and the decode engine
generates tokens starting from position 1. This function concatenates
them, removing the overlapping token(s):
merged = prefill[:P] + decode[overlap:]
where overlap = P + D - expected_total.
"""
try:
p_emb = prefill_mm[_EMBED_LAYER_KEY].detach().to(device=device, dtype=torch.float)
p_hid = prefill_mm[_HIDDEN_LAYER_KEY].detach().to(device=device, dtype=torch.float)
except (KeyError, AttributeError, TypeError) as exc:
available_keys = list(prefill_mm.keys()) if isinstance(prefill_mm, dict) else type(prefill_mm).__name__
logger.error(
"_merge_pd_embeddings: failed to extract prefill embeddings (%s). "
"Expected keys %r and %r, got: %s. "
"Falling back to decode-only embeddings – talker user-segment will be degraded.",
exc,
_EMBED_LAYER_KEY,
_HIDDEN_LAYER_KEY,
available_keys,
)
return decode_emb, decode_hid
if p_emb.shape[0] == 0 or decode_emb.shape[0] == 0:
return decode_emb, decode_hid
raw_total = p_emb.shape[0] + decode_emb.shape[0]
overlap = max(0, raw_total - expected_total) if expected_total is not None else 0
merged_emb = torch.cat([p_emb, decode_emb[overlap:]], dim=0)
merged_hid = torch.cat([p_hid, decode_hid[overlap:]], dim=0)
return merged_emb, merged_hid
def _get_prefill_multimodal_output(prefill_stage: Any, output_index: int) -> dict[str, Any] | None:
"""Return multimodal_output dict from the PD prefill stage for a given batch index."""
try:
prefill_eos = prefill_stage.engine_outputs
prefill_eo = prefill_eos[min(output_index, len(prefill_eos) - 1)]
return prefill_eo.outputs[0].multimodal_output
except Exception:
return None
def _resolve_tts_token_embedding(
key: str,
*,
thinker_mm: dict[str, Any],
prefill_mm: dict[str, Any] | None,
device: torch.device,
) -> torch.Tensor | None:
"""Return TTS BOS/EOS/PAD embedding tensors for the talker projection path.
Values are taken from the current thinker (decode) ``multimodal_output``; in
PD mode, missing keys may be filled from the paired prefill stage output.
"""
val = thinker_mm.get(key)
if val is None and prefill_mm is not None:
val = prefill_mm.get(key)
return val.detach().to(device=device, dtype=torch.float) if val is not None else None
# =========================
# Thinker -> Talker
# =========================
@@ -111,8 +207,8 @@ def thinker2talker_async_chunk(
all_token_ids = _ensure_list(all_token_ids)
prompt_token_ids = _ensure_list(prompt_token_ids)
talker_additional_info = {
"thinker_prefill_embeddings": pooling_output.get("0").detach().cpu(),
"thinker_hidden_states": pooling_output.get("24").detach().cpu(),
"thinker_prefill_embeddings": pooling_output.get(_EMBED_LAYER_KEY).detach().cpu(),
"thinker_hidden_states": pooling_output.get(_HIDDEN_LAYER_KEY).detach().cpu(),
"thinker_sequences": all_token_ids,
"thinker_input_ids": prompt_token_ids,
# Provide thinker-side TTS token embeddings for talker projection
@@ -161,7 +257,7 @@ def thinker2talker_async_chunk(
if output_token_ids:
talker_additional_info["override_keys"] = ["thinker_decode_embeddings", "thinker_output_token_ids"]
talker_additional_info["thinker_decode_embeddings"] = pooling_output.get("0").detach().cpu()
talker_additional_info["thinker_decode_embeddings"] = pooling_output.get(_EMBED_LAYER_KEY).detach().cpu()
talker_additional_info["thinker_output_token_ids"] = output_token_ids
else:
# When prefilling a chunked thinker, thinker_hidden_states needs to be updated.
@@ -185,6 +281,9 @@ def thinker2talker(
2. Split hidden states into: prompt embeddings + generated embeddings
3. Package for talker with additional information
In PD disaggregation mode, merges prefill-stage prompt embeddings with
decode-stage generated embeddings before handing off to the talker.
Args:
stage_list: List of stage objects
engine_input_source: Source stage IDs (typically [0] for thinker)
@@ -199,21 +298,49 @@ def thinker2talker(
device = torch.device(current_platform.device_type)
# PD disaggregation: look up the preceding prefill stage (if any)
source_stage_id = engine_input_source[0]
prefill_stage = _get_prefill_stage(stage_list, source_stage_id)
# Process each thinker output
for i, thinker_output in enumerate(thinker_outputs):
output = thinker_output.outputs[0]
thinker_mm = output.multimodal_output
# Full thinker embedding sequence for the talker: single thinker engine in the
# non-PD path; after optional merge with prefill-side tensors in PD mode.
thinker_emb = thinker_mm[_EMBED_LAYER_KEY].detach().to(device=device, dtype=torch.float)
thinker_hid = thinker_mm[_HIDDEN_LAYER_KEY].detach().to(device=device, dtype=torch.float)
prefill_mm: dict[str, Any] | None = None
if prefill_stage is not None:
prefill_mm = _get_prefill_multimodal_output(prefill_stage, i)
if prefill_mm is not None:
expected_total = len(thinker_output.prompt_token_ids) + len(output.token_ids)
try:
thinker_emb, thinker_hid = _merge_pd_embeddings(
thinker_emb, thinker_hid, prefill_mm, device, expected_total=expected_total
)
except Exception as exc:
logger.warning("[PD] Could not merge prefill embeddings: %s", exc)
info = {
"thinker_prefill_embeddings": output.multimodal_output["0"].detach().to(device=device, dtype=torch.float),
"thinker_hidden_states": output.multimodal_output["24"].detach().to(device=device, dtype=torch.float),
"thinker_prefill_embeddings": thinker_emb,
"thinker_hidden_states": thinker_hid,
"thinker_sequences": (
thinker_output.prompt_token_ids + output.token_ids
), # the thinker_sequences is the whole ids
"thinker_input_ids": thinker_output.prompt_token_ids,
# Provide thinker-side TTS token embeddings for talker projection
"tts_bos_embed": output.multimodal_output["tts_bos_embed"].detach().to(device=device, dtype=torch.float),
"tts_eos_embed": output.multimodal_output["tts_eos_embed"].detach().to(device=device, dtype=torch.float),
"tts_pad_embed": output.multimodal_output["tts_pad_embed"].detach().to(device=device, dtype=torch.float),
"tts_bos_embed": _resolve_tts_token_embedding(
"tts_bos_embed", thinker_mm=thinker_mm, prefill_mm=prefill_mm, device=device
),
"tts_eos_embed": _resolve_tts_token_embedding(
"tts_eos_embed", thinker_mm=thinker_mm, prefill_mm=prefill_mm, device=device
),
"tts_pad_embed": _resolve_tts_token_embedding(
"tts_pad_embed", thinker_mm=thinker_mm, prefill_mm=prefill_mm, device=device
),
}
speaker = extract_speaker_from_prompt(prompt, index=i)
if speaker is not None: