Files
vllm-omni/vllm_omni/entrypoints/async_omni.py
T
2026-09-10 00:41:52 +08:00

1790 lines
76 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
AsyncOmni - Refactored async orchestrator using AsyncOmniEngine.
This is the new implementation that uses AsyncOmniEngine (which manages
StageEngineCoreClient instances) instead of OmniStage with worker processes.
"""
from __future__ import annotations
import asyncio
import time
import uuid
from collections.abc import AsyncGenerator, Iterable, Mapping, Sequence
from typing import TYPE_CHECKING, Any
from vllm import TokensPrompt
from vllm.engine.protocol import EngineClient, StreamingInput
from vllm.logger import init_logger
from vllm.lora.request import LoRARequest
from vllm.outputs import CompletionOutput, PoolingRequestOutput
from vllm.plugins.io_processors import get_io_processor
from vllm.pooling_params import PoolingParams
from vllm.renderers.inputs.preprocess import extract_prompt_components
from vllm.sampling_params import RequestOutputKind, SamplingParams
from vllm.tasks import SupportedTask
from vllm.utils import random_uuid
from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.data import CuMemTag, OmniACK, OmniSleepTask, OmniWakeTask
from vllm_omni.engine.duplex.lease import DuplexLeaseActivity
from vllm_omni.engine.duplex.messages import (
DuplexFence,
DuplexSessionLifecycleMessage,
)
from vllm_omni.engine.messages import ErrorMessage, OutputMessage
from vllm_omni.entrypoints.client_request_state import ClientRequestState
from vllm_omni.entrypoints.duplex_request_client import (
DuplexRequestClient,
DuplexRequestOutputPort,
)
from vllm_omni.entrypoints.omni_base import (
OmniBase,
OmniEngineDeadError,
)
from vllm_omni.errors import client_error_metadata
from vllm_omni.inputs.data import OmniSamplingParams
from vllm_omni.metrics.stats import OrchestratorAggregator as OrchestratorMetrics
from vllm_omni.outputs import OmniRequestOutput
if TYPE_CHECKING:
from vllm.tokenizers import TokenizerLike
from vllm.v1.engine import PauseMode
from vllm.v1.engine.input_processor import InputProcessor
from vllm_omni.inputs.data import OmniInteractionPrompt, OmniPromptType
logger = init_logger(__name__)
_FINAL_OUTPUT_IDLE_SLEEP_S = 0.001
# Blocking-wait interval for the event-driven final-output drain
# (VLLM_OMNI_EVENT_DRIVEN_ORCH=1): a message wakes the drain immediately via
# the janus queue's condition variable; this timeout only bounds how often the
# orchestrator liveness check runs while the pipeline is idle.
_FINAL_OUTPUT_BLOCKING_WAIT_S = 1.0
class AsyncEventResolver:
"""
A generic signal aggregator designed for synchronized handshakes in
distributed or multi-stage environments. Supports waiting for a specified
number (expected_count) of worker signals in both inline and multiprocess modes.
"""
def __init__(self, orchestrator=None):
self._pending_tasks: dict[str, dict] = {}
self.orchestrator = orchestrator
self._lock = asyncio.Lock()
def watch_task(self, task_id: str, expected_count: int = 1) -> asyncio.Future:
loop = asyncio.get_running_loop()
fut = loop.create_future()
self._pending_tasks[task_id] = {
"future": fut,
"expected_count": expected_count,
"received": [],
"start_time": time.time(),
}
return fut
async def resolve(self, ack: OmniACK):
tid = getattr(ack, "task_id", None)
if tid is None and isinstance(ack, dict):
tid = ack.get("task_id")
async with self._lock:
task_info = self._pending_tasks.get(tid)
if task_info is None:
logger.warning(f"Received stray ACK for task_id {tid}. Task might have timed out.")
return
task_info["received"].append(ack)
current_count = len(task_info["received"])
expected = task_info["expected_count"]
orchestrator = self.orchestrator
if orchestrator and hasattr(orchestrator, "metrics") and orchestrator.metrics:
freed = getattr(ack, "freed_bytes", 0)
if freed == 0 and isinstance(ack, dict):
freed = ack.get("freed_bytes", 0)
orchestrator.metrics.record_vram_reclaimed(freed)
logger.info(f"[Resolver] Task {tid} progress: {current_count}/{expected} ACKs received.")
if current_count >= expected:
self._pending_tasks.pop(tid)
fut = task_info["future"]
if not fut.done():
elapsed = time.time() - task_info["start_time"]
logger.info(f"[Resolver] Task {tid} completed successfully in {elapsed:.2f}s.")
fut.set_result(task_info["received"])
class AsyncOmni(EngineClient, OmniBase):
"""Asynchronous unified entry point for multi-stage pipelines using AsyncOmniEngine.
This is the refactored version that uses AsyncOmniEngine instead of
OmniStage workers. It provides the same interface as AsyncOmni but with
a cleaner architecture.
Args:
model: Model name or path to load.
**kwargs: Additional keyword arguments.
- deploy_config: Optional path to a deploy YAML. If None,
configurations are resolved from the model pipeline factory.
- log_stats: Whether to enable statistics logging.
- stage_init_timeout: Timeout for per-stage initialization.
- init_timeout: Total timeout for orchestrator startup.
- async_chunk: Whether to enable async chunk mode.
- output_modalities: Requested output modalities.
- Additional keyword arguments passed to stage engines.
Example:
>>> async_omni = AsyncOmni(model="Qwen/Qwen2.5-Omni-7B")
>>> async for output in async_omni.generate(
... prompt="Hello",
... request_id="req-1",
... sampling_params_list=[SamplingParams(), SamplingParams()]
... ):
... print(output)
"""
def __init__(self, *args: Any, model: str = "", **kwargs: Any) -> None:
OmniBase.__init__(self, model=model, **kwargs)
self._pause_cond: asyncio.Condition = asyncio.Condition()
self._paused: bool = False
# In-flight EngineCore submits (non-streaming add_request, or each
# streaming ADD/update/final marker). sleep() waits for this to hit
# zero so a pipelined request cannot race into EngineCore during
# drain/offload. Streaming generate does not hold a slot while
# waiting for the next client chunk.
self._admitting: int = 0
# True after pause_generation() or AR EngineCore sleep; wake_up must
# not reopen generate() until resume_generation(). Diffusion-only
# sleep uses _paused as a temporary admission gate and clears it
# on wake so sleep → wake → generate keeps working.
self._hold_admission_until_resume: bool = False
self._sleeping_tags: set[str] = set()
self._stage_sleeping_tags: dict[int, set[str]] = {}
self._level2_sleeping: bool = False
self._duplex_request_client: DuplexRequestClient | None = None
self.duplex_lifecycle_events: asyncio.Queue[DuplexSessionLifecycleMessage] = asyncio.Queue()
self.final_output_task: asyncio.Task | None = None
self.event_resolver = AsyncEventResolver(orchestrator=self)
self.config_path = self.engine.config_path
self.tts_max_instructions_length = kwargs.get("tts_max_instructions_length", None)
self.input_processor = self.engine.input_processor
self.endpoint_restrictions = self.engine.endpoint_restrictions
self.duplex_session_config = self.engine.duplex_session_config
self.duplex_serving_adapter_path = self.engine.duplex_serving_adapter_path
stage_index = self._get_comprehension_stage_index()
if stage_index is None:
self.io_processor = None
else:
vllm_config = self.engine.stage_vllm_configs[stage_index]
io_processor_plugin = vllm_config.model_config.io_processor_plugin
renderer = self.renderer
if renderer is None:
from vllm.renderers import renderer_from_config
renderer = renderer_from_config(vllm_config)
self.io_processor = get_io_processor(vllm_config, renderer, io_processor_plugin)
def _resolve_transfer_replica(self, stage_id: int, request_id: str) -> int | None:
"""Look up the sticky-routed replica for (stage_id, request_id).
Used as the ``replica_resolver`` callback by ``OrchestratorAggregator``
to label transfer_* metrics without plumbing replica ids through
``TransferEdgeStats`` / ``StageRequestStats`` / connector adapters.
Returns None when stage_id is out of range or the request hasn't been
bound to a replica yet — the metric emit then defensive-skips.
"""
pools = getattr(self.engine, "stage_pools", None)
if pools is None or not (0 <= stage_id < len(pools)):
return None
return pools[stage_id].get_bound_replica_id(request_id)
def _get_comprehension_stage_index(self) -> int | None:
fallback_idx: int | None = None
for idx, stage_client in enumerate(self.engine.stage_clients):
stage_vllm_config = self.engine.stage_vllm_configs[idx]
if stage_vllm_config is None:
continue
if fallback_idx is None:
fallback_idx = idx
if stage_client.is_comprehension:
return idx
return fallback_idx
@property
def renderer(self):
"""Return the renderer from the engine input processor when available."""
if self.input_processor is None:
return None
return self.input_processor.renderer
@property
def vllm_config(self):
"""Return the vLLM config for the comprehension stage when present."""
stage_index = self._get_comprehension_stage_index()
if stage_index is None:
return None
return self.engine.stage_vllm_configs[stage_index]
async def get_vllm_config(self) -> Any:
"""Compatibility helper for call sites expecting async vllm config access."""
return self.vllm_config
def get_diffusion_od_config(self) -> Any | None:
"""Return the diffusion-stage config when the pipeline has one."""
saw_diffusion_stage = False
for stage_client in self.engine.stage_clients:
if getattr(stage_client, "stage_type", None) != "diffusion":
continue
saw_diffusion_stage = True
od_config = getattr(stage_client, "od_config", None)
if od_config is not None:
return od_config
inner_engine = getattr(stage_client, "_engine", None)
od_config = getattr(inner_engine, "od_config", None)
if od_config is not None:
return od_config
# Out-of-process diffusion clients don't carry od_config (it lives in the
# worker); fall back to the engine's model_class_name resolution.
if saw_diffusion_stage:
return self.engine.get_diffusion_od_config()
return None
@property
def model_config(self):
"""Return the model config for the comprehension stage when present."""
vllm_config = self.vllm_config
if vllm_config is None:
return None
return vllm_config.model_config
@staticmethod
def _get_unique_request_id(external_request_id: str):
"""Get a random new request ID for this request; at the server level,
this is usually set by the calling entrypoint, but in direct calls, we
need to set it explicitly since we do not allow empty IDs.
NOTE: in the upstream vLLM, this is done in the InputProcessor's
`assign_request_id`.
"""
uuid = random_uuid()
prefix = "" if not external_request_id else f"{external_request_id}-"
return f"{prefix}{uuid:.8}"
async def open_duplex_session_async(
self,
session_id: str,
*,
session_mode: str = "duplex",
capabilities: dict[str, object] | None = None,
session_config: dict[str, object] | None = None,
runtime_config: dict[str, object] | None = None,
fence: DuplexFence,
timeout: float | None = 10.0,
) -> dict[str, object]:
"""Open an engine-level duplex session when the backend supports it."""
return await self._get_duplex_request_client().open(
session_id,
session_mode=session_mode,
capabilities=capabilities,
session_config=session_config,
runtime_config=runtime_config,
fence=fence,
timeout=timeout,
)
async def append_duplex_input_async(
self,
session_id: str,
*,
mode: str,
payload: object,
operation_id: str | None = None,
final: bool = False,
expected_epoch: int | None = None,
fence: DuplexFence,
timeout: float | None = 10.0,
collect_outputs: bool = True,
) -> dict[str, object]:
"""Append input to an engine-level duplex session."""
return await self._get_duplex_request_client().append(
session_id,
mode=mode,
payload=payload,
operation_id=operation_id,
final=final,
expected_epoch=expected_epoch,
fence=fence,
timeout=timeout,
collect_outputs=collect_outputs,
)
async def collect_duplex_data_plane_outputs_async(
self,
request_id: str,
*,
response_stage_id: int | None = None,
timeout: float | None = 10.0,
) -> list[OmniRequestOutput]:
"""Collect the next duplex data-plane output batch for a live request."""
return await self._get_duplex_request_client().collect_registered_outputs(
request_id,
response_stage_id=response_stage_id,
timeout=timeout,
)
async def signal_duplex_turn_async(
self,
session_id: str,
*,
event: str,
fence: DuplexFence,
next_fence: DuplexFence | None = None,
session_config: dict[str, object] | None = None,
runtime_config: dict[str, object] | None = None,
timeout: float | None = 10.0,
) -> dict[str, object]:
"""Send a turn/control signal to an engine-level duplex session."""
return await self._get_duplex_request_client().signal(
session_id,
event=event,
fence=fence,
next_fence=next_fence,
session_config=session_config,
runtime_config=runtime_config,
timeout=timeout,
)
async def close_duplex_session_async(
self,
session_id: str,
*,
reason: str = "client_close",
fence: DuplexFence,
timeout: float | None = 10.0,
) -> dict[str, object]:
"""Close an engine-level duplex session."""
return await self._get_duplex_request_client().close(
session_id,
reason=reason,
fence=fence,
timeout=timeout,
)
async def touch_duplex_session_async(
self,
session_id: str,
*,
fence: DuplexFence,
activity: DuplexLeaseActivity,
timeout: float | None = 10.0,
) -> dict[str, object]:
return await self._get_duplex_request_client().touch(
session_id,
fence=fence,
activity=activity,
timeout=timeout,
)
async def resume_duplex_session_async(
self,
session_id: str,
*,
fence: DuplexFence,
expected_lease_generation: int,
timeout: float | None = 10.0,
) -> dict[str, object]:
return await self._get_duplex_request_client().resume(
session_id,
fence=fence,
expected_lease_generation=expected_lease_generation,
timeout=timeout,
)
def _get_duplex_request_client(self) -> DuplexRequestClient:
client = getattr(self, "_duplex_request_client", None)
if client is None:
engine = getattr(self, "engine", None)
client = DuplexRequestClient(
engine,
DuplexRequestOutputPort(
request_states=getattr(self, "request_states", {}),
num_stages=getattr(engine, "num_stages", 1),
log_stats=getattr(self, "log_stats", False),
start_output_handler=self._final_output_handler,
process_single_result=self._process_single_result,
),
)
self._duplex_request_client = client
return client
@staticmethod
def _duplex_data_plane_request_info(result: dict[str, object]) -> tuple[str | None, int | None]:
return DuplexRequestClient.request_info(result)
async def _collect_duplex_data_plane_outputs(
self,
request_id: str,
req_state: ClientRequestState,
*,
response_stage_id: int | None,
timeout: float | None,
) -> list[OmniRequestOutput]:
return await self._get_duplex_request_client().collect_outputs(
request_id,
req_state,
response_stage_id=response_stage_id,
timeout=timeout,
)
@classmethod
def _is_direct_duplex_data_plane_response(cls, output: object) -> bool:
return DuplexRequestClient.is_direct_response(output)
@classmethod
def _duplex_multimodal_output(cls, output: object) -> dict[str, object]:
return DuplexRequestClient.multimodal_output(output)
# ==================== Generate Method ====================
async def generate(
self,
prompt: OmniPromptType | AsyncGenerator[StreamingInput, None] | list[OmniPromptType],
sampling_params: Any = None,
request_id: str = "",
*,
prompt_text: str | None = None,
lora_request: Any = None,
tokenization_kwargs: dict[str, Any] | None = None,
sampling_params_list: Sequence[OmniSamplingParams] | None = None,
output_modalities: list[str] | None = None,
trace_headers: Mapping[str, str] | None = None,
priority: int = 0,
data_parallel_rank: int | None = None,
reasoning_ended: bool | None = None,
reasoning_parser_kwargs: dict[str, Any] | None = None,
arrival_time: float | None = None,
) -> AsyncGenerator[OmniRequestOutput, None]:
"""Generate outputs for the given prompt(s) asynchronously.
Coordinates multi-stage pipeline execution. Processes the prompt
through all stages in the pipeline and yields outputs as they become
available.
**Diffusion batching:**
Diffusion stages accept only a single prompt per request. Passing a
``list`` of prompts to a diffusion stage will raise ``ValueError``.
To batch multiple diffusion prompts, submit each as an independent
request; the scheduler will automatically co-batch compatible requests.
Args:
prompt: A single prompt **or** a list of prompts. For diffusion
stages, only a single prompt is accepted; a list will be
rejected with an error.
request_id: Unique identifier for this request. If one is not provided,
a random one will be generated.
sampling_params_list: List of SamplingParams, one per stage.
Must have the same length as the number of stages.
If *None*, uses default sampling params for each stage.
output_modalities: Optional list of output modalities.
Yields:
OmniRequestOutput objects as they are produced by each stage.
Raises:
ValueError: If sampling_params_list has incorrect length, or
if a list prompt is submitted to a diffusion stage.
"""
# Append a random UUID suffix to the request_id to ensure it is unique
# and non-empty, similar to vLLM's input processor. The suffix is used
# only for internal tracking throughout the request's life.
external_request_id = request_id
request_id = self._get_unique_request_id(external_request_id)
# Wait until generation is resumed if the engine is paused. Non-streaming
# generate holds an admission slot until add_request completes so sleep()
# cannot race a just-unblocked generate into EngineCore during offload.
# Streaming generate does **not** hold the slot while waiting for the
# first client chunk; the input pump acquires it immediately before each
# EngineCore ADD/update.
streaming_input = isinstance(prompt, AsyncGenerator)
async with self._pause_cond:
await self._pause_cond.wait_for(lambda: not self._paused)
if not streaming_input:
self._admitting = getattr(self, "_admitting", 0) + 1
admitting = not streaming_input
logger.debug(f"[AsyncOmni] generate() called for request {external_request_id}")
input_stream_task: asyncio.Task | None = None
try:
_sleeping_tags = getattr(self, "_sleeping_tags", None)
if _sleeping_tags:
raise RuntimeError(
f"Generation rejected: Engine is partially or fully asleep. "
f"Currently sleeping tags: {list(_sleeping_tags)}. "
f"Please perform a full wake_up before generating."
)
# Reject diffusion list-prompt early with a clear API error.
if isinstance(prompt, list) and any(
getattr(client, "stage_type", "") == "diffusion" for client in getattr(self.engine, "stage_clients", [])
):
raise ValueError(
"Diffusion stages accept only a single prompt per request. "
"Submit multiple independent requests to use scheduler batching."
)
# Start final output dispatcher on the first call to generate()
self._final_output_handler()
# Forward bare sampling_params (e.g. from /v1/completions) as the stage-0 entry.
if sampling_params_list is None and sampling_params is not None:
if self.num_stages == 1:
sampling_params_list = [sampling_params]
else:
default = list(self.default_sampling_params_list)
default[0] = sampling_params
sampling_params_list = default
# 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))
# Set the output kind to delta output if sampling params were omitted,
# since AsyncOmni is typically used for streaming.
sampling_params_list = self.resolve_sampling_params_list(
sampling_params_list,
allow_delta_coercion=True,
)
# Track per-request metrics
wall_start_ts = float(arrival_time) if arrival_time is not None else time.time()
req_start_ts: dict[str, float] = {}
# Determine the final stage for E2E stats
final_stage_id_for_e2e = self._compute_final_stage_id(output_modalities)
final_output_stage_ids = self._compute_final_output_stage_ids(output_modalities) or [final_stage_id_for_e2e]
metrics = OrchestratorMetrics(
self.num_stages,
self.log_stats,
wall_start_ts,
final_stage_id_for_e2e,
transfer_emitter=getattr(self, "transfer_metrics", None),
replica_resolver=self._resolve_transfer_replica,
)
req_state = ClientRequestState(
request_id=request_id,
external_request_id=external_request_id,
)
req_state.metrics = metrics
req_state.request_arrival_ts = wall_start_ts
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. The helper
# returns as soon as the pump task is created; each ADD/update
# takes its own admission slot inside the pump.
if streaming_input:
first_chunk_submitted = asyncio.get_running_loop().create_future()
input_stream_task = await self._add_streaming_input_request(
request_id=request_id,
input_stream=prompt,
sampling_params_list=req_sp_list,
final_stage_id=final_stage_id_for_e2e,
final_output_stage_ids=final_output_stage_ids,
arrival_time=wall_start_ts,
lora_request=lora_request,
first_chunk_submitted=first_chunk_submitted,
)
await first_chunk_submitted
else:
await self.engine.add_request_async(
request_id=request_id,
prompt=prompt,
sampling_params_list=req_sp_list,
final_stage_id=final_stage_id_for_e2e,
final_output_stage_ids=final_output_stage_ids,
arrival_time=wall_start_ts,
lora_request=lora_request,
)
submit_ts = time.time()
req_state.metrics.stage_first_ts[0] = submit_ts
req_start_ts[request_id] = submit_ts
if admitting:
await self._release_generate_admission()
admitting = False
# Refresh gauges on arrival.
self._publish_request_gauges(len(self.request_states))
# Process results based on mode
# Both sequential and async_chunk modes read the same message stream
# from Orchestrator; stage-transfer behavior differs inside
# Orchestrator._route_output().
async for output in self._process_orchestrator_results(
request_id,
metrics,
final_stage_id_for_e2e,
req_start_ts,
wall_start_ts,
):
yield output
logger.debug(f"[AsyncOmni] Request {request_id} completed")
except (asyncio.CancelledError, GeneratorExit):
self._record_request_failure_once(request_id, reason="client_disconnect")
await self._abort_internal_requests(request_id)
logger.info(f"[AsyncOmni] Request {request_id} aborted.")
raise
except Exception as e:
self._record_request_failure_once(request_id, reason="stage_error")
await self._abort_internal_requests(request_id)
logger.info(f"[AsyncOmni] Request {request_id} failed (input error): {e}")
raise
finally:
if input_stream_task is not None and not input_stream_task.done():
input_stream_task.cancel()
if admitting:
await self._release_generate_admission()
self._log_summary_and_cleanup(request_id)
async def _release_generate_admission(self) -> None:
"""Drop one in-flight generate admission slot held across add_request."""
async with self._pause_cond:
self._admitting = max(getattr(self, "_admitting", 1) - 1, 0)
self._pause_cond.notify_all()
async def _submit_with_admission(self, awaitable):
"""Wait for resume, hold one admission slot for a single EngineCore submit."""
async with self._pause_cond:
await self._pause_cond.wait_for(lambda: not self._paused)
self._admitting = getattr(self, "_admitting", 0) + 1
try:
return await awaitable
finally:
await self._release_generate_admission()
async def _add_streaming_input_request(
self,
*,
request_id: str,
input_stream: AsyncGenerator[StreamingInput, None],
sampling_params_list: Sequence[OmniSamplingParams],
final_stage_id: int,
final_output_stage_ids: Sequence[int],
arrival_time: float,
lora_request: Any = None,
first_chunk_submitted: asyncio.Future[None] | None = None,
) -> asyncio.Task:
"""Submit a streaming input generator as incremental stage-0 updates."""
if not sampling_params_list:
raise ValueError("sampling_params_list cannot be empty for streaming input")
# only check thinker's sampling params now
stage0_params = sampling_params_list[0]
self._validate_streaming_input_sampling_params(stage0_params)
req_state = self.request_states[request_id]
has_submitted_first_chunk = False
# NOTE: InputProcessor in vLLM should generally do this too, but for
# now we do it defensively. TODO (Alex) ensure clones/copying are optimized
if not stage0_params.skip_clone:
stage0_params = stage0_params.clone()
stage0_params.skip_clone = True
def _mark_first_chunk_submitted() -> None:
if first_chunk_submitted is not None and not first_chunk_submitted.done():
first_chunk_submitted.set_result(None)
async def handle_inputs() -> None:
nonlocal has_submitted_first_chunk
cancelled = False
try:
async for chunk in input_stream:
chunk_params = getattr(chunk, "sampling_params", None) or stage0_params
self._validate_streaming_input_sampling_params(chunk_params)
chunk_sampling_params_list = list(sampling_params_list)
chunk_sampling_params_list[0] = chunk_params
chunk_prompt = chunk.prompt
prompt_text, _, _ = extract_prompt_components(self.model_config, chunk_prompt)
if not has_submitted_first_chunk:
await self._submit_with_admission(
self.engine.add_request_async(
request_id=request_id,
prompt=chunk_prompt,
prompt_text=prompt_text,
sampling_params_list=chunk_sampling_params_list,
final_stage_id=final_stage_id,
final_output_stage_ids=final_output_stage_ids,
arrival_time=arrival_time,
lora_request=lora_request,
resumable=True,
)
)
has_submitted_first_chunk = True
_mark_first_chunk_submitted()
else:
await self._submit_with_admission(
self.engine.add_streaming_update_async(
request_id=request_id,
prompt=chunk_prompt,
prompt_text=prompt_text,
sampling_params_list=chunk_sampling_params_list,
final_stage_id=final_stage_id,
final_output_stage_ids=final_output_stage_ids,
arrival_time=arrival_time,
lora_request=lora_request,
resumable=True,
)
)
except (asyncio.CancelledError, GeneratorExit):
cancelled = True
except Exception as error:
status_code, error_type = client_error_metadata(error)
await req_state.queue.put(
ErrorMessage(
request_id=request_id,
error=str(error),
status_code=status_code,
error_type=error_type,
)
)
finally:
try:
if not cancelled:
# Send empty final request to indicate that inputs have
# finished. Don't send if canceled (session was aborted).
final_sampling_params_list = list(sampling_params_list)
final_sampling_params_list[0] = stage0_params
final_prompt = TokensPrompt(prompt_token_ids=[0])
if has_submitted_first_chunk:
await self._submit_with_admission(
self.engine.add_streaming_update_async(
request_id=request_id,
prompt=final_prompt,
prompt_text=None,
sampling_params_list=final_sampling_params_list,
final_stage_id=final_stage_id,
final_output_stage_ids=final_output_stage_ids,
arrival_time=arrival_time,
lora_request=lora_request,
resumable=False,
)
)
else:
await self._submit_with_admission(
self.engine.add_request_async(
request_id=request_id,
prompt=final_prompt,
prompt_text=None,
sampling_params_list=final_sampling_params_list,
final_stage_id=final_stage_id,
final_output_stage_ids=final_output_stage_ids,
arrival_time=arrival_time,
lora_request=lora_request,
resumable=False,
)
)
has_submitted_first_chunk = True
finally:
# Unblock generate() even on cancel / empty stream / submit
# failure so it can observe a terminal abort or empty result.
_mark_first_chunk_submitted()
input_stream_task = asyncio.create_task(handle_inputs())
req_state.input_stream_task = input_stream_task
return input_stream_task
@staticmethod
def _validate_streaming_input_sampling_params(params: OmniSamplingParams) -> None:
if (
not isinstance(params, SamplingParams)
or params.n > 1
or params.output_kind == RequestOutputKind.FINAL_ONLY
or params.stop
):
raise ValueError(
"Input streaming is currently supported only for SamplingParams "
"with n == 1, output_kind != FINAL_ONLY, and without stop strings."
)
async def encode(
self,
prompt: Any,
pooling_params: PoolingParams,
request_id: str,
lora_request: LoRARequest | None = None,
trace_headers: dict[str, str] | None = None,
priority: int = 0,
tokenization_kwargs: dict[str, Any] | None = None,
reasoning_ended: bool | None = None,
) -> AsyncGenerator[PoolingRequestOutput, None]:
"""EngineClient.encode() stub.
Omni pipeline currently exposes only generate() API at orchestrator level.
"""
raise NotImplementedError("AsyncOmni.encode is not implemented.")
# ==================== Processing Methods ====================
async def _process_orchestrator_results(
self,
request_id: str,
metrics: OrchestratorMetrics,
final_stage_id_for_e2e: int,
req_start_ts: dict[str, float],
wall_start_ts: float,
) -> AsyncGenerator[OmniRequestOutput, None]:
"""Read results from the Orchestrator (via the request's asyncio.Queue)
and yield OmniRequestOutput objects.
The Orchestrator handles all stage-to-stage transfers. This method
only processes final outputs that arrive on the per-request queue.
"""
req_state = self.request_states.get(request_id)
if req_state is None:
return
while True:
result = await req_state.queue.get()
if isinstance(result, ErrorMessage):
logger.error(
"[AsyncOmni] Orchestrator error for req=%s stage-%s: %s",
request_id,
result.stage_id,
result.error,
)
if result.fatal:
raise OmniEngineDeadError(
result.error,
error_stage_id=result.stage_id,
)
self._raise_nonfatal_error_message(result)
if not isinstance(result, OutputMessage):
logger.warning("[AsyncOmni] Dropping unexpected per-request message %r", result)
continue
stage_id = result.stage_id
self._check_engine_output_error(result, request_id, stage_id)
# Process the result (constructs OmniRequestOutput)
output_to_yield = self._process_single_result(
result,
stage_id,
metrics,
req_start_ts,
wall_start_ts,
final_stage_id_for_e2e,
)
if output_to_yield:
# Set the external request ID back to the user yielded input
output_to_yield.request_id = req_state.external_request_id or output_to_yield.request_id
logger.debug(
"[AsyncOmni] req=%s stage-%s yielding final_output_type=%s",
request_id,
stage_id,
getattr(output_to_yield, "final_output_type", None),
)
yield output_to_yield
# The Orchestrator sets "finished" when the final stage is done
if result.finished:
break
# ==================== Output Handler ====================
def _final_output_handler(self) -> None:
"""Start the final output handler if not already running.
This handler reads messages from the Orchestrator output queue and
routes them to per-request asyncio.Queues.
"""
if self.final_output_task is not None:
return
engine = self.engine
# Event-driven drain (VLLM_OMNI_EVENT_DRIVEN_ORCH=1): block on the
# queue's condition variable in a dedicated thread instead of the
# get_nowait + 1 ms sleep cadence. Same flag as the orchestrator-side
# event-driven loop (vllm_omni/engine/orchestrator.py).
from vllm_omni.engine.orchestrator import _event_driven_orch_enabled
event_driven_drain = _event_driven_orch_enabled() and hasattr(engine, "get_output_blocking_async")
async def _final_output_loop():
"""Background coroutine that dispatches final outputs to request queues."""
try:
while True:
if event_driven_drain:
msg = await engine.get_output_blocking_async(timeout=_FINAL_OUTPUT_BLOCKING_WAIT_S)
if msg is None:
# Timed out with the orchestrator alive; loop for
# the periodic liveness check.
continue
else:
msg = await engine.try_get_output_async()
if msg is None:
await asyncio.sleep(_FINAL_OUTPUT_IDLE_SLEEP_S)
continue
if isinstance(msg, dict) and msg.get("type") == "ack":
ack_data = msg.get("ack")
tid = getattr(ack_data, "task_id", "unknown")
logger.info(f"[{self._name}] Intercepted wrapped ACK for task {tid}")
await self.event_resolver.resolve(ack_data)
continue
if isinstance(msg, OmniACK):
logger.info(f"[{self._name}] Intercepted raw ACK object: {msg.task_id}")
await self.event_resolver.resolve(msg)
continue
if hasattr(msg, "task_id"):
tid = getattr(msg, "task_id")
logger.info(f"[{self._name}] Intercepted task-ID object: {tid}")
await self.event_resolver.resolve(msg)
continue
if getattr(msg, "type", None) == "duplex_session_lifecycle":
await self.duplex_lifecycle_events.put(msg)
continue
if isinstance(msg, ErrorMessage):
# Route request-scoped errors to that request's queue and
# keep the loop alive. A request whose stage replica died
# and was evicted gets a fatal error delivered here; only
# that request fails (its consumer raises), the server
# stays up for other stages/requests (#4285). A fatal
# error without a request_id is a genuine engine-wide
# death and falls through to the except handler below.
if msg.request_id is not None:
req_state = self.request_states.get(msg.request_id)
if req_state is not None:
await req_state.queue.put(msg)
else:
logger.warning(
"[%s] dropping error for unknown req %s",
self._name,
msg.request_id,
)
continue
if not msg.fatal:
continue
should_continue, _, stage_id, req_state = self._handle_output_message(msg)
if should_continue:
continue
req_state.stage_id = stage_id
# Route to the per-request queue
await req_state.queue.put(msg)
except asyncio.CancelledError:
raise
except OmniEngineDeadError as e:
logger.error("[AsyncOmni] Engine dead: %s", e)
for req_state in list(self.request_states.values()):
error_msg = ErrorMessage(
error=str(e),
fatal=True,
request_id=req_state.request_id,
stage_id=e.error_stage_id,
)
await req_state.queue.put(error_msg)
except EngineDeadError as e:
logger.error("[AsyncOmni] Engine dead: %s", e)
for req_state in list(self.request_states.values()):
error_msg = ErrorMessage(
error=str(e),
fatal=True,
request_id=req_state.request_id,
)
await req_state.queue.put(error_msg)
except Exception as e:
logger.exception("[AsyncOmni] final_output_loop failed.")
for req_state in list(self.request_states.values()):
error_msg = ErrorMessage(
request_id=req_state.request_id,
error=str(e),
)
await req_state.queue.put(error_msg)
self.final_output_task = None
self.final_output_task = asyncio.create_task(_final_output_loop())
logger.debug("[AsyncOmni] Final output handler started")
# ==================== Control Methods ====================
async def collective_rpc(
self,
method: str,
timeout: float | None = None,
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
stage_ids: list[int] | None = None,
) -> list[Any]:
"""Execute a best-effort control RPC on selected stages.
Unsupported stages currently return a TODO-style result dict instead of
failing the entire call. This keeps AsyncOmni usable while the orchestrator
control plane is still being filled out.
"""
results = await self.engine.collective_rpc_async(
method=method,
timeout=timeout,
args=args,
kwargs=kwargs,
stage_ids=stage_ids,
)
unsupported_stage_ids: list[int] = []
effective_stage_ids = stage_ids or list(range(len(results)))
for index, result in enumerate(results):
if isinstance(result, dict) and result.get("todo"):
unsupported_stage_ids.append(effective_stage_ids[index])
if unsupported_stage_ids:
logger.warning(
"[AsyncOmni] collective_rpc(%s) has TODO support on stage(s): %s",
method,
unsupported_stage_ids,
)
return results
async def _engine_core_rpc(
self,
method: str,
*,
stage_ids: list[int],
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
) -> list[Any]:
"""Call an AR EngineCore helper via collective_rpc (orchestrator loop).
StagePool resolves ``{method}_async`` on the AR client when present
(vLLM AsyncMPClient convention). Raises if any replica reports failure.
"""
results = await self.collective_rpc(
method=method,
args=args,
kwargs=kwargs,
stage_ids=stage_ids,
)
for result in results:
if isinstance(result, dict) and result.get("error"):
raise RuntimeError(f"{method} failed: {result['error']}")
return results
@staticmethod
def _coerce_stage_bool(result: Any) -> bool:
"""Reduce a stage RPC result to a boolean.
Some stage RPCs may return worker-level lists like ``[True]``;
diffusion wrappers usually return a plain bool.
"""
if isinstance(result, list):
return all(bool(item) for item in result)
return bool(result)
async def abort(self, request_id: str | Iterable[str]) -> None:
"""Abort request(s) via the Orchestrator."""
request_ids = [request_id] if isinstance(request_id, str) else list(request_id)
# Map the external user request IDs to internal IDs used by the Orchestrator.
# NOTE: If the user request_id matches multiple requests, all of them will be
# aborted. This is also what happens in this case in vLLM's output processor.
internal_ids = [s.request_id for s in self.request_states.values() if s.external_request_id in request_ids]
await self._abort(internal_ids)
async def submit_interaction_async(
self,
request_id: str,
*,
interaction: OmniInteractionPrompt,
) -> None:
"""Apply a midway interaction to an active streaming diffusion request.
``request_id`` is the external id created by the server-side session,
matching the value passed to :meth:`generate`.
"""
event = interaction.get("event")
prompt = event.get("prompt") if isinstance(event, dict) else None
if isinstance(event, dict) and "prompt" in event and (not isinstance(prompt, str) or not prompt):
raise ValueError("prompt must be non-empty")
transition_chunks = interaction.get("transition_chunks")
if transition_chunks is not None and transition_chunks < 0:
raise ValueError("transition_chunks must be >= 0")
if self.num_stages != 1:
raise ValueError("interaction requires single-stage diffusion")
stage_meta = self.engine.get_stage_metadata(0)
if stage_meta.stage_type != "diffusion":
raise ValueError("interaction requires a diffusion stage")
internal_ids = [s.request_id for s in self.request_states.values() if s.external_request_id == request_id]
if not internal_ids:
raise ValueError(f"No active request for interaction: {request_id!r}")
if len(internal_ids) > 1:
raise ValueError(
f"interaction requires exactly one active request for {request_id!r}, found {len(internal_ids)}"
)
await self.engine.submit_interaction_async(
internal_ids[0],
interaction=interaction,
)
if self.log_stats:
logger.info("[AsyncOmni] Queued interaction for request %s", request_id)
async def _abort_internal_requests(self, request_id: str | Iterable[str]):
"""Abort request(s) via the Orchestrator given internal request IDs,
which take the format <external_request_id>-<UUID>.
"""
request_ids = [request_id] if isinstance(request_id, str) else list(request_id)
# Request IDs are already internal, so we just need to get the matching states.
internal_req_ids = [rid for rid in request_ids if rid in self.request_states]
await self._abort(internal_req_ids)
async def _abort(self, request_ids: list[str]) -> None:
"""Abort request IDs via the engine and enqueue terminal abort outputs.
Waits for orchestrator abort acknowledgment, enqueues any AR terminal
abort outputs (partial tokens) into each request's asyncio queue, then
cancels the input pump. Frontend ``request_states`` stay registered so
``generate()`` can consume the terminal message in
``_process_orchestrator_results`` and run normal cleanup.
When ``abort_async`` returns no output for an active request (OP not
registered yet, unbound replica, or orchestrator id drop), enqueue a
synthetic finished abort so ``generate()`` cannot hang on ``queue.get``.
"""
abort_outputs = await self.engine.abort_async(request_ids) or []
delivered: set[str] = set()
for output_msg in abort_outputs:
req_id = getattr(output_msg, "request_id", None)
if req_id is None:
continue
state = self.request_states.get(req_id)
if state is None:
logger.debug("[AsyncOmni] Dropping abort output for unknown req %s", req_id)
continue
await state.queue.put(output_msg)
delivered.add(req_id)
for rid in request_ids:
state = self.request_states.get(rid)
if state is not None and rid not in delivered:
queue = getattr(state, "queue", None)
if queue is not None:
await state.queue.put(self._synthetic_abort_output_message(rid))
delivered.add(rid)
for rid in request_ids:
self._record_request_failure_once(rid, reason="client_abort")
state = self.request_states.get(rid)
input_stream_task = getattr(state, "input_stream_task", None)
if input_stream_task is not None and not input_stream_task.done():
input_stream_task.cancel()
if self.log_stats:
logger.info("[AsyncOmni] Aborted request(s) %s", ",".join(request_ids))
@staticmethod
def _synthetic_abort_output_message(request_id: str) -> OutputMessage:
"""Terminal abort OutputMessage used when the engine returned none."""
engine_output = OmniRequestOutput(
request_id=request_id,
finished=True,
stage_id=0,
final_output_type="text",
outputs=[
CompletionOutput(
index=0,
text="",
token_ids=[],
cumulative_logprob=None,
logprobs=None,
finish_reason="abort",
stop_reason=None,
)
],
)
return OutputMessage(
request_id=request_id,
stage_id=0,
replica_id=None,
engine_outputs=engine_output,
metrics=None,
finished=True,
stage_submit_ts=None,
)
def _split_stage_ids_by_type(self, stage_ids: list[int] | None = None) -> tuple[list[int], list[int]]:
"""Split stage ids into AR/LLM (EngineCore) vs diffusion (worker RPC)."""
n_stages = len(self.engine.stage_clients)
if stage_ids is None:
stage_ids = list(range(n_stages))
else:
invalid = [sid for sid in stage_ids if not isinstance(sid, int) or sid < 0 or sid >= n_stages]
if invalid:
raise ValueError(
f"Invalid stage_ids {invalid}; valid range is 0..{n_stages - 1}"
if n_stages
else f"Invalid stage_ids {invalid}; this engine has no stages"
)
ar_stage_ids: list[int] = []
diffusion_stage_ids: list[int] = []
for sid in stage_ids:
client = self.engine.stage_clients[sid]
if getattr(client, "stage_type", "llm") == "diffusion":
diffusion_stage_ids.append(sid)
else:
ar_stage_ids.append(sid)
return ar_stage_ids, diffusion_stage_ids
def _sleeping_tags_for_stages(self, stage_ids: list[int]) -> set[str]:
"""Union of sleeping tags recorded for the given stages."""
per_stage = getattr(self, "_stage_sleeping_tags", None) or {}
tags: set[str] = set()
for sid in stage_ids:
tags.update(per_stage.get(sid, set()))
return tags
def _refresh_union_sleeping_tags(self) -> None:
"""Keep ``_sleeping_tags`` as the engine-wide union of per-stage tags."""
per_stage = getattr(self, "_stage_sleeping_tags", None) or {}
union: set[str] = set()
for stage_tags in per_stage.values():
union.update(stage_tags)
self._sleeping_tags = union
def _record_stage_sleep(self, stage_ids: list[int], tags: Iterable[str]) -> None:
per_stage = getattr(self, "_stage_sleeping_tags", None)
if per_stage is None:
per_stage = {}
self._stage_sleeping_tags = per_stage
tag_set = set(tags)
for sid in stage_ids:
per_stage.setdefault(sid, set()).update(tag_set)
self._refresh_union_sleeping_tags()
def _clear_stage_sleep(self, stage_ids: list[int], tags: Iterable[str]) -> None:
per_stage = getattr(self, "_stage_sleeping_tags", None)
if per_stage is None:
self._sleeping_tags = set()
return
tag_set = set(tags)
for sid in stage_ids:
remaining = per_stage.get(sid)
if remaining is None:
continue
remaining.difference_update(tag_set)
if not remaining:
per_stage.pop(sid, None)
self._refresh_union_sleeping_tags()
async def pause_generation(
self,
*,
mode: PauseMode = "abort",
wait_for_inflight_requests: bool = False,
clear_cache: bool = True,
stage_ids: list[int] | None = None,
) -> None:
"""Pause generation, mirroring vLLM AsyncLLM.pause_generation.
1. Stop frontend admission (``_paused``).
2. For AR/LLM stages, call EngineCore.pause_scheduler via the
Orchestrator loop (abort/wait/keep + optional cache clear).
3. Diffusion stages have no EngineCore scheduler — only frontend
admission is paused for them.
Note: ``sleep()`` already pauses the AR scheduler internally (same as
vLLM EngineCore.sleep). Call this API when you need pause *without*
freeing GPU memory (e.g. weight sync).
"""
if wait_for_inflight_requests:
mode = "wait"
async with self._pause_cond:
# Keep running EngineCore pause + cache clear even when frontend
# admission is already paused (sleep or a prior pause_generation).
self._paused = True
self._hold_admission_until_resume = True
ar_stage_ids, _diffusion_stage_ids = self._split_stage_ids_by_type(stage_ids)
if ar_stage_ids:
logger.info(
"[%s] Pausing AR stage(s) %s via EngineCore.pause_scheduler(mode=%s)",
self._name,
ar_stage_ids,
mode,
)
# Same API name as vLLM AsyncMPClient.pause_scheduler_async; routed
# through collective_rpc so it runs on the orchestrator event loop.
await self._engine_core_rpc(
"pause_scheduler",
stage_ids=ar_stage_ids,
kwargs={"mode": mode, "clear_cache": clear_cache},
)
# Frontend / sender-side cache clear (P0). EngineCore.pause_scheduler
# already clears AR-side caches when clear_cache=True.
if clear_cache:
await self.reset_prefix_cache(
reset_running_requests=not wait_for_inflight_requests,
reset_connector=True,
)
await self.reset_mm_cache()
await self.reset_encoder_cache()
async def resume_generation(self, stage_ids: list[int] | None = None) -> None:
"""Resume generation after :meth:`pause_generation`."""
ar_stage_ids, _diffusion_stage_ids = self._split_stage_ids_by_type(stage_ids)
if ar_stage_ids:
logger.info("[%s] Resuming AR stage(s) %s via EngineCore", self._name, ar_stage_ids)
await self._engine_core_rpc("resume_scheduler", stage_ids=ar_stage_ids)
async with self._pause_cond:
self._paused = False
self._hold_admission_until_resume = False
self._pause_cond.notify_all()
async def is_paused(self) -> bool:
"""Check if frontend admission is paused."""
async with self._pause_cond:
return self._paused
async def start_profile(
self,
profile_prefix: str | None = None,
stages: list[int] | None = None,
) -> list[Any]:
"""Start profiling specified stages.
Uses vLLM-compatible profile(is_start=True, profile_prefix) interface.
Args:
profile_prefix: Optional prefix for the trace file names.
stages: List of stage IDs to profile. If None, profiles all stages.
"""
return await self.collective_rpc(method="profile", args=(True, profile_prefix), stage_ids=stages)
async def stop_profile(self, stages: list[int] | None = None) -> list[Any]:
"""Stop profiling specified stages.
Uses vLLM-compatible profile(is_start=False) interface.
Args:
stages: List of stage IDs to profile. If None, stops all stages.
"""
return await self.collective_rpc(method="profile", args=(False, None), stage_ids=stages)
async def reset_mm_cache(self) -> None:
"""Reset the frontend (P0) multimodal processor cache.
``EngineCore.sleep(level>=1)`` already clears the P1 receiver cache.
Clearing P0 avoids hash-only follow-up requests after that reset.
"""
renderer = self.renderer
if renderer is not None:
await renderer.clear_mm_cache_async()
async def reset_encoder_cache(self) -> None:
"""Reset the encoder cache for all stages.
TODO: Forward to Orchestrator process via message.
"""
logger.warning("[AsyncOmni] reset_encoder_cache not yet supported with Orchestrator process")
async def reset_prefix_cache(
self,
reset_running_requests: bool = False,
reset_connector: bool = False,
) -> bool:
"""Reset the prefix cache for all stages.
TODO: Forward to Orchestrator process via message.
"""
logger.warning("[AsyncOmni] reset_prefix_cache not yet supported with Orchestrator process")
return True
async def sleep(
self, stage_ids: list[int] | None = None, level: int = 2, mode: PauseMode = "abort"
) -> list[OmniACK]:
"""Put stages to sleep.
AR/LLM stages use EngineCore.sleep (pause scheduler, wait idle, then
offload/discard memory) — matching vLLM AsyncLLM.sleep.
Diffusion stages keep the existing worker-level handle_sleep_task RPC
because StageDiffusionProc does not expose EngineCore.pause_scheduler.
Frontend admission is blocked at the start of this call (``_paused``)
so pipelined :meth:`generate` cannot race into stages while sleep is
in flight. This does **not** invoke EngineCore.pause_scheduler again
(sleep already pauses the AR scheduler).
For AR / mixed engines, ``wake_up`` does **not** clear ``_paused``;
callers must :meth:`resume_generation` when ready (typical trainer
order: pause → abort → sleep → train → wake → resume). Diffusion-only
engines have no EngineCore pause to hold, so ``wake_up`` restores
admission and ``sleep → wake → generate`` keeps working.
"""
# Block admission before any sleep RPC so generate() waits on
# _pause_cond during the drain/offload window. Wait until generate()
# coroutines that already passed the pause check have submitted (or
# failed) so EngineCore does not see ADD frames while sleeping.
async with self._pause_cond:
self._paused = True
await self._pause_cond.wait_for(lambda: getattr(self, "_admitting", 0) == 0)
# P0 sender cache must drop hashes before EngineCore.sleep clears P1.
await self.reset_mm_cache()
self._final_output_handler()
ar_stage_ids, diffusion_stage_ids = self._split_stage_ids_by_type(stage_ids)
final_acks: list[OmniACK] = []
if ar_stage_ids:
self._hold_admission_until_resume = True
logger.info(
"[%s] Sleeping AR stage(s) %s via EngineCore.sleep(level=%s, mode=%s)",
self._name,
ar_stage_ids,
level,
mode,
)
await self._engine_core_rpc(
"sleep",
stage_ids=ar_stage_ids,
args=(level, mode),
)
# EngineCore.sleep has no OmniACK handshake; emit stage-level SUCCESS
# markers so callers/tests that count ACKs keep a stable API.
task_id = f"engine_core-sleep-{uuid.uuid4().hex[:8]}"
final_acks.extend(
OmniACK(
task_id=task_id,
status="SUCCESS",
stage_id=sid,
rank=0,
metadata={"path": "engine_core", "level": level, "mode": mode},
)
for sid in ar_stage_ids
)
if diffusion_stage_ids:
final_acks.extend(await self._sleep_diffusion(diffusion_stage_ids, level))
self._record_stage_sleep(
ar_stage_ids + diffusion_stage_ids,
[CuMemTag.WEIGHTS.value, CuMemTag.KV_CACHE.value],
)
if level == 2:
self._level2_sleeping = True
return final_acks
async def _sleep_diffusion(self, stage_ids: list[int], level: int) -> list[OmniACK]:
"""Worker-level sleep RPC for diffusion stages only."""
# Diffusion reports one summary ACK at rank 0 regardless of TP.
total_workers = len(stage_ids)
task_id = str(uuid.uuid4())
self.event_resolver.watch_task(task_id, expected_count=total_workers)
logger.info("[%s] Sleep (diffusion) initiated (Task: %s).", self._name, task_id)
task = OmniSleepTask(level=level, task_id=task_id)
rpc_results = await self.collective_rpc(method="handle_sleep_task", args=(task,), stage_ids=stage_ids)
final_acks: list[OmniACK] = []
for stage_res in rpc_results:
worker_acks = stage_res if isinstance(stage_res, list) else [stage_res]
for ack in worker_acks:
if ack is not None:
await self.event_resolver.resolve(ack)
final_acks.append(ack)
return final_acks
async def wake_up(self, stage_ids: list[int] | None = None, tags: list[str] | None = None) -> list[OmniACK]:
"""Wake stages after sleep.
AR/LLM stages use EngineCore.wake_up (restore memory, auto-resume
scheduler). Diffusion stages keep the worker-level wake RPC.
Does **not** clear the frontend ``_paused`` admission gate when
:meth:`pause_generation` ran or AR stages were slept — call
:meth:`resume_generation` when the trainer is ready to admit new
requests. Diffusion-only ``sleep`` uses ``_paused`` only as a race
guard; this method restores admission after a successful wake.
"""
self._final_output_handler()
if getattr(self, "_level2_sleeping", False):
raise NotImplementedError(
"wake_up() after sleep(level=2) is not yet implemented: weights were "
"discarded from GPU and reloading from disk is not yet supported. "
"Use sleep(level=1) instead, which offloads weights to CPU RAM "
"and supports fast DMA restore."
)
ar_stage_ids, diffusion_stage_ids = self._split_stage_ids_by_type(stage_ids)
target_stage_ids = ar_stage_ids + diffusion_stage_ids
_current_tags = self._sleeping_tags_for_stages(target_stage_ids)
per_stage = getattr(self, "_stage_sleeping_tags", None) or {}
if not _current_tags and not per_stage:
_current_tags = set(getattr(self, "_sleeping_tags", set()))
if tags is None:
requested_tags = list(_current_tags)
else:
requested_tags = [t for t in tags if t in _current_tags]
if not requested_tags:
logger.info(f"[{self._name}] Requested tags {tags} are already warm. Skipping wake_up.")
return []
final_acks: list[OmniACK] = []
if ar_stage_ids:
logger.info("[%s] Waking AR stage(s) %s via EngineCore", self._name, ar_stage_ids)
await self._engine_core_rpc(
"wake_up",
stage_ids=ar_stage_ids,
kwargs={"tags": requested_tags},
)
task_id = f"engine_core-wake-{uuid.uuid4().hex[:8]}"
final_acks.extend(
OmniACK(
task_id=task_id,
status="SUCCESS",
stage_id=sid,
rank=0,
metadata={"path": "engine_core", "tags": list(requested_tags)},
)
for sid in ar_stage_ids
)
if diffusion_stage_ids:
final_acks.extend(await self._wake_diffusion(diffusion_stage_ids, requested_tags))
self._clear_stage_sleep(target_stage_ids, requested_tags)
# Only clear the level-2 flag once all tags are warm, in case partial
# wake support (e.g. tags=["kv_cache"] only) is added in the future.
if not getattr(self, "_sleeping_tags", None):
self._level2_sleeping = False
logger.info(
"[%s] Wake-up complete for stage(s) %s.",
self._name,
ar_stage_ids + diffusion_stage_ids,
)
# Diffusion-only sleep uses `_paused` as a race guard. Restore
# generate() admission after memory is back. AR/mixed sleep and
# pause_generation keep the trainer hold until resume_generation.
if not getattr(self, "_hold_admission_until_resume", False):
async with self._pause_cond:
self._paused = False
self._pause_cond.notify_all()
return final_acks
async def _wake_diffusion(self, stage_ids: list[int], requested_tags: list[str]) -> list[OmniACK]:
"""Worker-level wake RPC for diffusion stages only."""
total_workers = len(stage_ids)
task_id = str(uuid.uuid4())
self.event_resolver.watch_task(task_id, expected_count=total_workers)
logger.info("[%s] Wake-up (diffusion) initiated (Task: %s).", self._name, task_id)
task = OmniWakeTask(tags=requested_tags, task_id=task_id)
rpc_results = await self.collective_rpc(method="handle_wake_task", args=(task,), stage_ids=stage_ids)
final_acks: list[OmniACK] = []
for stage_res in rpc_results:
worker_acks = stage_res if isinstance(stage_res, list) else [stage_res]
for ack in worker_acks:
if ack is not None:
await self.event_resolver.resolve(ack)
final_acks.append(ack)
await asyncio.sleep(0.1)
return final_acks
async def is_sleeping(self) -> bool:
"""Return whether all stages are sleeping.
TODO(AsyncOmni): query the orchestrator once all stage backends expose
a real sleeping-state RPC. For now we track the requested state locally.
"""
return bool(getattr(self, "_sleeping_tags", None))
async def add_lora(self, lora_request: LoRARequest) -> bool:
"""Load a new LoRA adapter into all stages.
Returns True only if all concretely-implemented stages report success.
"""
results = await self.collective_rpc(method="add_lora", args=(lora_request,))
concrete_results = [r for r in results if not (isinstance(r, dict) and r.get("todo"))]
return all(self._coerce_stage_bool(r) for r in concrete_results) if concrete_results else False
async def remove_lora(self, adapter_id: int) -> bool:
"""Remove a LoRA adapter from all stages.
TODO(AsyncOmni): add richer per-stage error reporting to the public API.
"""
results = await self.collective_rpc(method="remove_lora", args=(adapter_id,))
concrete_results = [r for r in results if not (isinstance(r, dict) and r.get("todo"))]
return all(self._coerce_stage_bool(r) for r in concrete_results) if concrete_results else False
async def list_loras(self) -> list[int]:
"""List all loaded LoRA adapter IDs across stages."""
results = await self.collective_rpc(method="list_loras")
merged: set[int] = set()
for result in results:
if isinstance(result, dict) and result.get("todo"):
continue
if isinstance(result, (list, set)):
for item in result:
if isinstance(item, (list, set)):
merged.update(item)
elif isinstance(item, int):
merged.add(item)
elif isinstance(result, int):
merged.add(result)
return sorted(merged)
async def pin_lora(self, adapter_id: int) -> bool:
"""Pin a LoRA adapter across stages."""
results = await self.collective_rpc(method="pin_lora", args=(adapter_id,))
concrete_results = [r for r in results if not (isinstance(r, dict) and r.get("todo"))]
return all(self._coerce_stage_bool(r) for r in concrete_results) if concrete_results else False
# ==================== Properties ====================
@property
def is_running(self) -> bool:
"""Check if the engine is running."""
orchestrator_alive = self.engine.is_alive()
task_alive = self.final_output_task is not None and not self.final_output_task.done()
return orchestrator_alive and task_alive
@property
def errored(self) -> bool:
"""Whether the engine is in a process-fatal error state.
Delegates to ``OmniBase.errored``, which is true only when the
orchestrator thread is dead; per-stage liveness is reported via
``check_health`` instead. Redeclared here to satisfy the
``EngineClient`` abstract-property requirement (Python's ABC
mechanism does not resolve abstract methods from sibling MRO
entries).
"""
return OmniBase.errored.fget(self) # type: ignore[union-attr]
@property
def _name(self) -> str:
return "AsyncOrchestrator"
@property
def is_stopped(self) -> bool:
"""EngineClient abstract property implementation."""
return self.errored
@property
def dead_error(self) -> BaseException:
"""EngineClient abstract property implementation."""
return OmniEngineDeadError()
# ==================== EngineClient Interface ====================
async def get_input_preprocessor(self) -> InputProcessor:
"""Get input preprocessor."""
return self.input_processor
async def get_tokenizer(self) -> TokenizerLike:
"""Get tokenizer for the comprehension stage."""
stage_index = self._get_comprehension_stage_index()
if stage_index is not None:
tokenizer = self.engine.output_processors[stage_index].tokenizer
if tokenizer is not None:
return tokenizer
return self.input_processor.tokenizer # type: ignore[return-value]
async def is_tracing_enabled(self) -> bool:
"""Check if tracing is enabled."""
return False
async def notify_kv_transfer_request_rejected(
self,
request_id: str,
kv_transfer_params: dict[str, Any],
*,
data_parallel_rank: int | None = None,
) -> None:
"""Notify engine that a KV-transfer request was rejected before admission.
Omni does not currently use KV-transfer pre-admission resources,
so this is a no-op.
"""
logger.debug(
"KV-transfer request rejected (no-op in omni): request_id=%s",
request_id,
)
async def start_weight_update(self, is_checkpoint_format: bool = True) -> None:
"""Start a new weight update.
Omni does not currently support weight transfer, so this is a no-op.
"""
logger.debug("Weight update start requested (no-op in omni)")
async def finish_weight_update(self, weight_version: str | None = None) -> None:
"""Finish the current weight update.
Omni does not currently support weight transfer, so this is a no-op.
``weight_version`` is accepted for upstream ``EngineClient`` protocol
compatibility (RLHF weight-transfer routers pass it positionally).
"""
logger.debug("Weight update finish requested (no-op in omni)")
async def do_log_stats(self) -> None:
"""Log statistics.
TODO: Forward to Orchestrator process via message.
"""
pass
async def get_supported_tasks(self) -> tuple[SupportedTask, ...]:
"""Return the task set exposed by the orchestrator-backed engine."""
return tuple(self.engine.supported_tasks)
async def check_health(self) -> None:
"""Check engine health by verifying the Orchestrator process is alive."""
OmniBase.check_health(self)
# ==================== Shutdown ====================
def shutdown(self, timeout: float | None = None) -> None:
"""Shutdown the engine."""
if self.final_output_task is not None:
self.final_output_task.cancel()
self.final_output_task = None
OmniBase.shutdown(self)