Integrate portable edge graph backends with Omni runtime

Extract versioned stage contracts, model shared CPU/iGPU/NPU memory constraints, and add explicit graph routes, reservations, output acknowledgements, and worker retirement. Fix native Windows shared-buffer lifetime and host paths. Document qualified hardware paths and remaining rollout gates.
This commit is contained in:
tzhouam committed 2026-09-22 13:12:33 +08:00
1 parent 00510ce38b
commit 723c550209
55 files changed
+2900 -383

No files matched your search

+3
View File
@@ -295,3 +295,6 @@ benchmarks/edge_harness/results/**/*.npz
benchmarks/edge_harness/results/**/*.onnx
benchmarks/edge_harness/results/**/*.npy
benchmarks/edge_harness/results/**/*.wav
# Portable wire fixtures are source inputs, not generated measurements.
!packages/omni-stage-contracts/conformance/*.json
+5
View File
@@ -408,4 +408,9 @@ async def main() -> int:
if __name__ == "__main__":
# Install the Windows selector policy before creating the caller's loop.
# Importing AsyncOmni inside main() is too late for that existing loop.
from vllm_omni.windows.aio import install_selector_policy
install_selector_policy()
raise SystemExit(asyncio.run(main()))
+235
View File
@@ -0,0 +1,235 @@
# Local edge backends in Omni
The first backend is `external.graph.v1`: one complete, stateless graph operation
per request, with a fixed input bucket and copied host tensors. It uses the normal
`Omni` / `AsyncOmni` APIs, `PipelineConfig`, `StageRuntime`, `StagePool` and
orchestrator. Native vLLM stages retain their model execution, KV cache, sampling
and batching. No accelerator is mandatory.
## Boundaries
| Owner | Responsibility |
|---|---|
| `omni_stage_contracts` | Version/features, buffer descriptors, request/event identities, opaque state, physical device descriptors, artifact manifests. Root import uses only the standard library. |
| `vllm_omni.host` | Explicit interpreter/OS routes, process bootstrap, process identity and bounded tree retirement. |
| `vllm_omni.engine.backends` | Adapt backend operations to `StageClientBase`; observe placement; fence epochs and worker generations. |
| `StageRuntime` | Resolve all replicas, reserve the whole plan before loading, create clients, unwind failures. |
| `ResourceLedger` | Atomically charge every affected memory constraint; keep uncertain allocations quarantined. |
| `StagePool` / orchestrator | Existing stage dispatch, handoff, polling, metrics, cancellation and request cleanup. |
| Model adapter | Tokenization, media preparation, stage transforms, sampling semantics, artifact export and numerical/task validation. |
Native Windows device-init locks use the host temporary directory, independent
of a local or UNC checkout. This coordinates same-user native processes. It does
not establish a cross-user or Windows/WSL global lock authority; cross-domain
workers in one graph plan are coordinated by that controller's reservations.
Native Windows SHM transport also uses a host-local directory for locks and
acknowledgements. A producer retains each named mapping until its single consumer
copies the payload and acknowledges its generation, or the producer cancels/closes
it. A length header handles Windows page-rounded mapping sizes. Retained mappings
are limited to 64 MiB and 256 buffers per producer process; exceeding either limit
fails the write explicitly. This host transport fix does not enable cross-OS or
accelerator zero-copy. POSIX keeps its existing unlink-based lifetime.
`edge.local.session` and `edge.local.external.protocol` retain compatibility imports.
The old direct `ExternalStage` API remains available; new pipelines should use
the runtime-owned graph backend. New state handles default to non-replayable and
non-migratable. Legacy M0 defaults are explicitly preserved in `contracts.legacy`.
## Hardware composition
The abstraction supports these single-machine compositions. This table separates
topology/admission support from successful execution of a particular model; it
does not promise that every listed accelerator can run every model.
| Composition | Resource accounting | Current execution evidence |
|---|---|---|
| CPU only | Host RAM; WSL quota when applicable | ORT CPU graphs through public Omni on Windows and WSL. Native Windows CPU text remains unqualified. |
| CPU + integrated GPU | Shared host RAM | Radeon 890M DirectML graph through Omni. This extraction's probe is not a complete vision/language pipeline. |
| CPU + integrated NPU | Shared host RAM | AMD NPU VitisAI graph through Omni; permitted CPU partitions are explicit in the placement report. |
| CPU + integrated GPU + integrated NPU | Shared RAM plus optional observed package/power/bandwidth relationships | Topology and admission contracts tested; individual laptop routes execute. Simultaneous full-model overlap is not qualified. |
| CPU + integrated GPU + integrated NPU + discrete GPU | Shared RAM and separate discrete VRAM | Current Ryzen AI 9 HX370 / Radeon 890M / AMD NPU / RTX 5090 Laptop machine. Spark CUDA text and native two-stage TTS run on Windows and WSL; using all accelerators together is not qualified. |
| CPU + discrete GPU, with integrated devices unused or absent | Host RAM plus discrete VRAM | Native CUDA text/TTS execution uses this subset. NVIDIA is optional in the abstraction. |
Mobile/embedded SoCs fit the CPU + integrated GPU + NPU composition, but their
local controller and model paths still require device-specific qualification.
Galaxy S25 AI Hub inference/profiling passed for one Spark attention component;
this is component evidence, not full local mobile generation or streaming.
AI Hub is a test facility, never a deployment dependency. Intel, Apple and other
vendor combinations are not certified by the AMD/NVIDIA/Qualcomm results.
`DeviceDescriptor` separates physical device identity, execution domain and
integration. A CPU, integrated GPU and NPU can all reference `machine:ram`.
An additional discrete GPU references its own `machine:vram:<physical-id>`.
A WSL allocation can consume both `machine:ram` and `wsl:quota` constraints;
those are two limits on the same allocation, not two amounts of physical RAM.
Use the same physical GPU ID when Windows and WSL expose the same adapter.
Package IDs, bandwidth groups and power domains are optional observed relationships.
Missing values mean unknown. Fitting memory does not authorize overlap on a shared
package: selection and overlap still require model quality and complete workload
measurements. Synthetic topology tests cover CPU-only and all four integrated /
discrete combinations; they are not hardware certification.
## Deploy a graph
Register a `PipelineConfig` with `StageExecutionType.GRAPH`, `model_stage="graph"`
and an appropriate final output type. Set `async_chunk=False`. A graph following
another stage must have a model-owned `custom_process_input_func` that returns
`{"tensors": {name: numpy_array}}`. Names, dtypes and shapes must match the
manifest's placement examples. Graph stages do not tokenize prompts or apply
model-specific transformations.
Graph-containing v1 pipelines must be a linear chain with exactly one terminal
output at the last stage. Fan-out, joins and multiple final outputs are refused
before loading; those require reference-counted consumer leases.
Each stage in a graph-containing plan declares the same capacities and its own
demands, including native stages:
```yaml
pipeline: my_registered_pipeline
async_chunk: false
stages:
- stage_id: 0
backend:
name: external.graph.v1
manifest: /absolute/path/to/bundle/manifest.json
route:
name: local-cpu
interpreter: /absolute/path/to/worker/python
worker: /absolute/path/to/worker_ort.py
ep: cpu
os_domain: posix
max_io_bytes: 8388608
min_fraction_on_target: 1.0
allowed_providers: [CPUExecutionProvider]
resource_budget:
capacities: {machine:ram: 2147483648, wsl:quota: 1073741824}
demands: {machine:ram: 536870912, wsl:quota: 536870912}
```
Quote pool keys or use block mappings if your YAML producer treats colon-containing
keys specially. On native Windows use Windows paths and `os_domain: windows`.
From WSL use controller-visible paths; host services translate worker paths. Named
legacy routes still honor `VLLM_OMNI_EXTERNAL_PYTHON_<ROUTE>`, but explicit routes
have no developer-machine defaults.
Capacities are **pre-load controller ceilings**, below observed free host memory /
guest limits with safety margin. They are not continuously sampled available RAM.
Demands must include loading peaks, weights, state, activations, workspace,
transport copies, retained output and headroom. The ledger does not claim to be
an OS memory limiter or to attribute shared vendor allocations from RSS.
Driver/cache memory outside the worker remains part of the deployment estimate.
The graph worker environment needs NumPy, ONNX Runtime and ONNX (for checking
external weight references against the manifest). Vendor providers need their
own qualified environment. The controller need not install their runtimes.
## Artifact and placement gate
```json
{
"schema_version": 1,
"component": "model-owned-component-name",
"files": {"graph.onnx": "<sha256>", "weights.bin": "<sha256>", "inputs.npz": "<sha256>"},
"metadata": {
"graph_file": "graph.onnx", "example_inputs_file": "inputs.npz",
"checkpoint_revision": "<revision>", "precision": "<precision>",
"layout": "<layout>", "exporter": "<version>",
"calibration": "<record or not applicable>", "validation": "<record>"
}
}
```
All payload paths are relative to the manifest. Hashes are checked before startup;
ONNX external weight references must also belong to that verified set. Keep bundles
immutable during execution. Checkpoint, exporter, precision and validation metadata
are model-owned: supplying a manifest does not establish model quality.
Warmup records actual provider node assignments. Unverified placement, absent
providers and undeclared CPU partitions refuse startup. The default target fraction
is 1.0. A deliberately partitioned NPU graph can lower it and explicitly allow CPU,
but reports always retain the real split. `execution_plan` records the manifest
identity, worker generation, route, reservations and placement. It labels this B
(backend execution), not complete-model P.
## Flow, cancellation and ownership
* Graph pipelines admit one request before the engine ingress queue. Extra work
receives `ResourceUnavailable`; await consumption or cancellation and retry.
The current API does not queue a batch of graph requests.
* Each graph client has one in-flight call and one retained result. Delivery does
not free the slot. Public synchronous/asynchronous generators acknowledge when
iteration advances or closes. Direct engine consumers call
`OmniRequestOutput.release_stage_buffers()` in `finally`.
* After acknowledgement, retained result arrays belong to the application. Keeping
arbitrarily many consumed results is application memory, outside engine admission.
* Intermediate graph handoffs copy their next-stage inputs before acknowledgement.
Buffer descriptors carry owner, generation, exact byte size, dtype and shape.
Framing rejects unknown required features and malformed lengths before payload allocation.
* Cancellation fences the request epoch immediately. An active non-preemptible graph
call retires its worker. It does not replay work or restart transparently; recreate
the pipeline to use that backend again. Completed calls can cancel without killing
an otherwise reusable worker.
* Windows workers announce PID plus process creation time. Cross-OS termination
verifies that identity and retires the native process tree before stopping the WSL
launcher. An absent/unverified tree keeps its reservation quarantined. Python
call references stay charged until the call unwinds.
Native APIs and their admission logic remain compatible. This change adds a shared
explicit reservation transaction for graph-containing plans; it does not yet replace
every native memory observation/ledger or qualify mixed-model fan-out and streaming.
## Packaging, validation and scope
Build `packages/omni-stage-contracts` with `uv build`. Its wheel imports without
torch/vLLM; the optional `wire` extra installs NumPy. The main Omni wheel bundles
the same source. Python/native implementers can use `conformance/v1.json` from
the source distribution. A native mobile implementation has not yet run these fixtures.
`examples/edge/run_graph_stage.py` drives the public API, writes a deployment profile,
records the resolved plan and raw request samples, and saves outputs for numerical
comparison. It supports explicit CPU, DirectML and VitisAI worker routes.
The implementation has tests for real runtime startup, two-stage handoff on both
orchestrator modes, public APIs, ingress/consumer backpressure, malformed framing,
placement refusal, shared-pool overcommit, stale acknowledgements, cancellation,
worker failure and artifact membership. Native Spark acceptance and small actual
Windows/WSL graph probes are recorded separately in the workspace experiment report.
Validation snapshot (2026-09-22, base `00510ce38bbb96ef2e4ce462618d06ab142fae7c`):
* WSL focused regression: 415 passed, 1 skipped, 20 deselected. Windows focused
regression: 78 passed, 2 skipped; the controller lacks ONNX for those two tests,
while actual graph probes use their separate worker environments.
* Spark 4B BF16 CUDA M0: 11 tests passed on each host. These runs check the
specified text/state/cancellation workload, not every model or device.
* CPU and DirectML Add graph outputs are exact. AMD NPU versus the same A16W8
CPU graph has maximum absolute error `4.458427429199219e-05`, normalized
maximum `3.111236521857185e-05` against a `0.002` bound. This is backend
validation, not original-checkpoint model-quality validation.
* Real Qwen3-TTS 0.6B two-stage streaming completes on both hosts with checkpoint
revision `85e237c12c027371202489a0ec509ded67b5e4b5`. One measured utterance per
host still has playback stalls; these diagnostic samples do not satisfy the
full streaming, interruption, quality or sustained-thermal acceptance gates.
* The built Omni wheel runs 20 public-API CPU graph requests outside the checkout
with exact outputs and zero retained reservation after shutdown. The standalone
contracts source archive imports in a dependency-free environment.
* Fresh Galaxy S25 [inference](https://workbench.aihub.qualcomm.com/jobs/jp0mvkj6g/)
and [NPU profiling](https://workbench.aihub.qualcomm.com/jobs/jpyo7nx75/) passed
for the existing W8A16 attention component. Three outputs match the same
artifact/input reference exactly; the `full1024` artifact label denotes one
attention component's cache bucket, not the complete Spark model.
Raw records, machine/package inventories, failed attempts and reproduction commands
are preserved in the surrounding edge-infer workspace under
`analysis/experiments/omni_abstraction_20260922/`. They are not bundled as runtime
dependencies. Tests of reduced-device configurations are not measurements on a
different physical hardware SKU.
Still outside the completed v1 scope: arbitrary device/model certification, mobile
C++ control integration, persistent external AR sessions, shared-device zero-copy,
state migration/replay, mixed-model streaming/fan-out qualification, joint thermal /
bandwidth policy, native Windows CPU text qualification, and the full M1–M4 device
matrix. These remain explicit rollout gates, not implied supported features.
+18
View File
@@ -0,0 +1,18 @@
# Local graph example
See [the backend contract and deployment guide](../../docs/design/omni_edge_backends.md).
From a configured Omni environment:
```bash
python examples/edge/run_graph_stage.py /path/to/manifest.json \
--python /path/to/ort-venv/bin/python --report /path/to/run.json
```
The worker environment needs `numpy`, `onnx` and `onnxruntime`. For Windows,
use `--windows-worker` and the controller-visible path to `python.exe`.
`--ep vitisai` or `--ep dml` selects a vendor provider; declare every permitted
provider using `--allowed-provider`. No provider substitution is hidden.
The example reads fixed-bucket inputs from the bundle. It reports backend
execution only; use your own model adapter and quality fixtures for a model claim.
+145
View File
@@ -0,0 +1,145 @@
# SPDX-License-Identifier: Apache-2.0
"""Run a verified graph bundle through the public Omni API on one machine.
The bundle manifest identifies graph_file and example_inputs_file. This example
uses those fixed-bucket examples as inputs; real pipelines supply model-owned
input/handoff functions. All memory arguments are controller ceilings/estimates.
"""
from __future__ import annotations
import argparse
import json
import platform
import sys
import tempfile
import time
from pathlib import Path
import numpy as np
import yaml
from omni_stage_contracts import ArtifactManifest
from vllm_omni.config.pipeline_registry import register_pipeline
from vllm_omni.config.stage_config import PipelineConfig, StageExecutionType, StagePipelineConfig
from vllm_omni.entrypoints.omni import Omni
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("manifest", type=Path)
parser.add_argument("--route", default="ort-cpu")
parser.add_argument("--python", dest="interpreter")
parser.add_argument("--windows-worker", action="store_true")
parser.add_argument("--ep", default="cpu", choices=["cpu", "dml", "vitisai"])
parser.add_argument("--allowed-provider", action="append", default=[])
parser.add_argument("--min-fraction", type=float, default=1.0)
parser.add_argument("--capacity-mib", type=int, default=1024)
parser.add_argument("--stage-mib", type=int, default=512)
parser.add_argument("--max-io-mib", type=int, default=8)
parser.add_argument("--repeats", type=int, default=20)
parser.add_argument("--report", type=Path, required=True)
args = parser.parse_args()
if args.repeats < 1:
parser.error("--repeats must be positive")
manifest_path = args.manifest.resolve()
manifest = ArtifactManifest.read(manifest_path)
with np.load(manifest_path.parent / manifest.metadata["example_inputs_file"], allow_pickle=False) as samples:
inputs = {name: samples[name] for name in samples.files}
register_pipeline(
PipelineConfig(
model_type="omni_external_graph",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="graph",
execution_type=StageExecutionType.GRAPH,
final_output=True,
final_output_type="latent",
),
),
)
)
route = args.route
if args.interpreter:
import vllm_omni.edge.local.external.worker_ort as worker_module
route = {
"name": args.route,
"interpreter": args.interpreter,
"worker": worker_module.__file__,
"os_domain": "windows" if args.windows_worker else "posix",
"ep": args.ep,
}
backend = {
"name": "external.graph.v1",
"route": route,
"manifest": str(manifest_path),
"min_fraction_on_target": args.min_fraction,
"max_io_bytes": args.max_io_mib << 20,
"profile_prefix": str(args.report.resolve().with_suffix(".placement")),
}
if args.allowed_provider:
backend["allowed_providers"] = args.allowed_provider
result = {
"schema_version": 1,
"evidence": "B",
"platform": platform.platform(),
"python": sys.executable,
"workload": {"repeats": args.repeats, "concurrency": 1},
"artifact": str(manifest_path),
"samples_s": [],
"started_unix": time.time(),
}
engine = None
try:
with tempfile.TemporaryDirectory(prefix="omni-graph-") as tmp:
deploy = Path(tmp) / "deploy.yaml"
deploy.write_text(
yaml.safe_dump(
{
"pipeline": "omni_external_graph",
"async_chunk": False,
"stages": [
{
"stage_id": 0,
"backend": backend,
"resource_budget": {
"capacities": {"machine:ram": args.capacity_mib << 20},
"demands": {"machine:ram": args.stage_mib << 20},
},
}
],
}
)
)
started = time.perf_counter()
engine = Omni(model=str(manifest_path.parent), deploy_config=str(deploy))
result["startup_s"] = time.perf_counter() - started
client = engine.engine.stage_pools[0].stage_client
result["execution_plan"] = client.execution_plan
outputs = None
for _ in range(args.repeats):
started = time.perf_counter()
(output,) = engine.generate({"tensors": inputs}, use_tqdm=False)
result["samples_s"].append(time.perf_counter() - started)
outputs = output.custom_output["tensors"]
result["worker_stats"] = client._worker.stats()
result["finite"] = all(np.isfinite(v).all().item() for v in outputs.values())
args.report.parent.mkdir(parents=True, exist_ok=True)
np.savez(args.report.with_suffix(".npz"), **outputs)
result["status"] = "passed"
except Exception as exc:
result.update(status="failed", error=f"{type(exc).__name__}: {exc}")
raise
finally:
if engine is not None:
engine.shutdown()
result["ledger_after_shutdown"] = engine.engine._runtime.resource_ledger.snapshot()
result["ended_unix"] = time.time()
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
"""Source-checkout bridge; wheels use the canonical package directly."""
from pathlib import Path
__path__ = [str(Path(__file__).resolve().parents[1] / "packages/omni-stage-contracts/omni_stage_contracts")]
from .types import (
PROTOCOL_VERSION,
ArtifactManifest,
BufferRef,
DeviceDescriptor,
StageEvent,
StageRequest,
StateHandle,
negotiate,
)
__all__ = [
"PROTOCOL_VERSION",
"ArtifactManifest",
"BufferRef",
"DeviceDescriptor",
"StageEvent",
"StageRequest",
"StateHandle",
"negotiate",
]
@@ -0,0 +1 @@
recursive-include conformance *.json
+9
View File
@@ -0,0 +1,9 @@
# Omni stage contracts
Versioned, dependency-free data contracts for local Omni stage adapters.
The optional `wire` extra provides the NumPy copied-host tensor transport.
Importing the contracts never imports torch, vLLM or vLLM-Omni.
Build from this directory with `uv build`; both the wheel and source archive
contain the canonical package. The parent Omni distribution bundles the same
source for compatibility. Install matching releases when using both distributions.
@@ -0,0 +1,24 @@
{
"protocol_version": 1,
"required_features": ["host-copy", "request-epochs"],
"buffer": {
"object_id": "output-1", "owner": "stage-0", "generation": "worker-A",
"dtype": "float32", "shape": [2, 3], "nbytes": 24,
"memory_domain": "host-copy", "layout": "C", "ready": true
},
"request": {
"request_id": "request-1", "stage_id": 0, "epoch": 2,
"worker_generation": "worker-A", "operation": "run", "inputs": []
},
"event": {
"request_id": "request-1", "stage_id": 0, "epoch": 2, "seq": 1,
"kind": "tensor", "worker_generation": "worker-A", "buffers": [], "terminal": true
},
"state": {
"session_id": "session-1", "backend": "backend-1", "artifact_id": "sha256:fixture",
"backend_instance_id": "backend-instance-A", "worker_generation": "worker-A",
"layout_version": 1, "epoch": 2, "replayable": false, "migratable": false
},
"reject_buffer_overrides": [{"nbytes": 23}, {"shape": [-1, 3]}, {"memory_domain": "cuda-ipc"}],
"stale_state_overrides": [{"epoch": 1}, {"worker_generation": "worker-B"}, {"backend_instance_id": "backend-instance-B"}]
}
@@ -0,0 +1,23 @@
"""Portable Omni data contracts. Importing this module needs only the stdlib."""
from .types import (
PROTOCOL_VERSION,
ArtifactManifest,
BufferRef,
DeviceDescriptor,
StageEvent,
StageRequest,
StateHandle,
negotiate,
)
__all__ = [
"PROTOCOL_VERSION",
"ArtifactManifest",
"BufferRef",
"DeviceDescriptor",
"StageEvent",
"StageRequest",
"StateHandle",
"negotiate",
]
@@ -0,0 +1,87 @@
"""Compatible M0 contracts; new backend state uses types.StateHandle."""
from __future__ import annotations
import time
from dataclasses import asdict, dataclass, field
from typing import Any
STATE_LAYOUT_VERSION = 1
@dataclass(frozen=True)
class StateHandle:
"""A reference to session state the backend owns.
The engine never reads the state. It reads this, to decide whether a
request may continue a session or must start a new one.
"""
session_id: str
backend: str
artifact_id: str
"""Ties the state to the checkpoint that produced it. The same shapes from
a different quantization are not the same state."""
layout_version: int = STATE_LAYOUT_VERSION
epoch: int = 0
"""Incremented on every cancel. Events from a lower epoch are stale."""
replayable: bool = True
"""A text session can be rebuilt by re-submitting its committed prompt, so
recovery after a backend crash is possible at an output boundary."""
migratable: bool = False
"""Never true in M0. Moving KV between backends needs a measured layout
conversion for the specific pair; assuming it is the failure the proposal
warns about."""
created_unix: float = field(default_factory=time.time)
def next_epoch(self) -> StateHandle:
from dataclasses import replace
return replace(self, epoch=self.epoch + 1)
backend_instance_id: str = ""
worker_generation: str = ""
def accepts(self, other: StateHandle) -> bool:
"""Whether ``other`` may continue this session's state."""
return (
other.backend == self.backend
and other.backend_instance_id == self.backend_instance_id
and other.worker_generation == self.worker_generation
and other.session_id == self.session_id
and other.artifact_id == self.artifact_id
and other.layout_version == self.layout_version
and other.epoch == self.epoch
)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class ChunkEvent:
"""One unit of output, with everything needed to order and retire it."""
request_id: str
stage_id: int
seq: int
"""Monotonic within (request, epoch). Gaps mean loss; repeats mean a bug."""
epoch: int
kind: str
"""``token`` | ``done`` | ``error`` | ``cancelled``."""
payload: Any
"""For ``token``: the incremental text and token ids. Never the whole
output so far -- a cumulative payload makes a dropped chunk invisible."""
started_unix: float
emitted_unix: float
final: bool = False
error: str | None = None
input_watermark: int = 0
"""How much of the input the producer had consumed when this was emitted.
One number in M0 (prompt tokens); it is what a duplex turn will align on."""
release_token: str = ""
"""Returned by the consumer to give credit back. Empty when the event
carries no resource the producer is holding on its behalf."""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -0,0 +1,194 @@
"""Versioned host-copy contracts shared by Python and native stage adapters."""
from __future__ import annotations
import hashlib
import json
import math
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
PROTOCOL_VERSION = 1
FEATURES = frozenset({"host-copy", "request-epochs"})
_ITEM_SIZES = {
"bool": 1,
"uint8": 1,
"int8": 1,
"int16": 2,
"uint16": 2,
"int32": 4,
"uint32": 4,
"int64": 8,
"uint64": 8,
"float16": 2,
"float32": 4,
"float64": 8,
}
def negotiate(version: int, required: list[str] | tuple[str, ...] = ()) -> None:
if type(version) is not int or version != PROTOCOL_VERSION:
raise ValueError(f"unsupported stage protocol version: {version!r}")
missing = set(required) - FEATURES
if missing:
raise ValueError(f"unsupported required stage features: {sorted(missing)}")
@dataclass(frozen=True)
class BufferRef:
object_id: str
owner: str
generation: str
dtype: str
shape: tuple[int, ...]
nbytes: int
memory_domain: str = "host-copy"
layout: str = "C"
ready: bool = True
def __post_init__(self) -> None:
if not self.object_id or not self.owner or not self.generation:
raise ValueError("buffer identity/owner/generation are required")
if self.memory_domain != "host-copy" or self.layout != "C" or not self.ready:
raise ValueError("v1 only supports ready, contiguous copied host buffers")
if self.dtype not in _ITEM_SIZES:
raise ValueError(f"unsupported dtype: {self.dtype}")
if len(self.shape) > 32 or any(type(x) is not int or x < 0 for x in self.shape):
raise ValueError("invalid tensor dimensions")
expected = math.prod(self.shape) * _ITEM_SIZES[self.dtype]
if type(self.nbytes) is not int or self.nbytes != expected or self.nbytes > 2 << 30:
raise ValueError("tensor shape/dtype/byte length mismatch or payload exceeds limit")
@dataclass(frozen=True)
class StateHandle:
session_id: str
backend: str
artifact_id: str
layout_version: int = 1
epoch: int = 0
replayable: bool = False
migratable: bool = False
backend_instance_id: str = ""
worker_generation: str = ""
def accepts(self, other: StateHandle) -> bool:
return all(
getattr(self, k) == getattr(other, k)
for k in (
"session_id",
"backend",
"artifact_id",
"layout_version",
"epoch",
"backend_instance_id",
"worker_generation",
)
)
def next_epoch(self) -> StateHandle:
from dataclasses import replace
return replace(self, epoch=self.epoch + 1)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class StageRequest:
request_id: str
stage_id: int
epoch: int = 0
worker_generation: str = ""
operation: str = "run"
inputs: tuple[BufferRef, ...] = ()
def __post_init__(self):
if not self.request_id or not self.worker_generation or not self.operation:
raise ValueError("request identity, worker generation and operation are required")
if any(type(n) is not int or n < 0 for n in (self.stage_id, self.epoch)):
raise ValueError("stage_id and epoch must be nonnegative integers")
@dataclass(frozen=True)
class StageEvent:
request_id: str
stage_id: int
epoch: int
seq: int
kind: str
worker_generation: str
buffers: tuple[BufferRef, ...] = ()
terminal: bool = False
error: str | None = None
def __post_init__(self):
if not self.request_id or not self.worker_generation or not self.kind:
raise ValueError("event identity, worker generation and kind are required")
if any(type(n) is not int or n < 0 for n in (self.stage_id, self.epoch, self.seq)):
raise ValueError("stage_id, epoch and seq must be nonnegative integers")
@dataclass(frozen=True)
class DeviceDescriptor:
"""Physical identity is independent from per-domain execution routes.
Unknown package/power/bandwidth relationships are empty, never inferred
from an integrated/discrete label. Pool IDs identify backing constraints.
"""
device_id: str
kind: str
domain_id: str
memory_pool_ids: tuple[str, ...]
integration: str = "unknown"
package_id: str | None = None
bandwidth_group_ids: tuple[str, ...] = ()
power_domain_ids: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.kind not in {"cpu", "gpu", "npu"}:
raise ValueError(f"unknown device kind: {self.kind}")
if self.integration not in {"integrated", "discrete", "unknown"}:
raise ValueError("unknown integration class")
if not self.device_id or not self.domain_id or not self.memory_pool_ids:
raise ValueError("device, domain and physical memory constraints are required")
if len(set(self.memory_pool_ids)) != len(self.memory_pool_ids):
raise ValueError("duplicate physical memory constraint")
@dataclass(frozen=True)
class ArtifactManifest:
"""A complete artifact payload set; paths are relative to the manifest."""
schema_version: int
component: str
files: dict[str, str]
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def read(cls, path: str | Path) -> ArtifactManifest:
path = Path(path)
manifest = cls(**json.loads(path.read_text(encoding="utf-8")))
if manifest.schema_version != 1 or not manifest.files:
raise ValueError("unsupported or empty artifact manifest")
root = path.parent.resolve()
for name, digest in manifest.files.items():
if not isinstance(name, str) or "\\" in name or ":" in name:
raise ValueError("artifact payloads must use portable relative POSIX paths")
target = (root / name).resolve()
if not target.is_relative_to(root):
raise ValueError(f"artifact path leaves bundle: {name}")
if not target.is_file() or file_digest(target) != digest:
raise ValueError(f"artifact payload missing or digest mismatch: {name}")
return manifest
def file_digest(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1 << 20), b""):
h.update(block)
return h.hexdigest()
@@ -0,0 +1,233 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The wire between the engine and a stage that runs in another process.
Deliberately tiny, and deliberately **stdlib + numpy only**. The worker on the
other end of this socket may be a native-Windows interpreter that has no torch,
no vllm and no vllm_omni on its path -- ``C:\\Users\\zhout\\npu-ep`` is exactly
that: Python 3.12, onnxruntime, onnx, numpy, nothing else. Anything imported
here has to be installable there, so this module imports neither.
**Why a socket at all.** Two devices on this laptop cannot be reached from the
engine's own interpreter, and for different reasons:
* the **XDNA2 NPU** is an MCDM device. WSL2's GPU-PV forwards WDDM display
adapters only, so there is no ``/dev/accel`` inside WSL and no amount of
installing changes that. VitisAI is a Windows DLL.
* the **Radeon 890M** is reachable from WSL through DirectML, but
``torch-directml`` pins torch 2.4.1 and Omni runs 2.13.
So out-of-process is forced, not preferred. One protocol covers both, plus the
native-Windows DirectML route, which needs its own venv anyway because
``onnxruntime-directml`` is pinned at 1.24.4 and cannot load the VitisAI EP
(that needs ORT >= 1.25) -- the two AMD devices cannot share an interpreter.
**Framing.** ``[4-byte big-endian header length][JSON header][raw tensor bytes]``.
Tensors are described in the header (name, dtype, shape, nbytes) and their
buffers are concatenated after it in the order listed, so a receive is two
reads and one ``frombuffer`` per tensor -- no per-element work and no pickle.
Measured on this machine, WSL <-> native Windows: 11 us half round trip,
330 MB/s. That is ample for a stage boundary (a 2 s Code2Wav chunk is ~192 KB,
about 0.6 ms) and nowhere near enough for per-layer traffic inside a decode
loop, which is the arithmetic behind "coarse stages only".
``pickle`` is not used anywhere in here on purpose: the header is JSON, the
payload is raw buffers, so a hostile or merely broken peer cannot execute code
by replying.
"""
from __future__ import annotations
import json
import math
import os
import socket
import struct
from typing import Any
import numpy as np
from .types import negotiate
# Ops the client sends.
OP_LOAD = "load"
"""Open a session on a graph. Replies with the placement report."""
OP_RUN = "run"
"""One forward. Inputs in, outputs out."""
OP_STATS = "stats"
"""Counters and RSS. The worker's memory is invisible to the engine's sampler
when the worker is a Windows process, so it has to report its own."""
OP_CLOSE = "close"
# Ops the worker sends.
OP_HELLO = "hello"
"""First message after connect-back: interpreter, versions, available EPs."""
OP_OK = "ok"
OP_ERR = "err"
MAX_HEADER_BYTES = 1 << 20
"""A header is metadata; a megabyte of it is a bug or an attack, not a graph."""
MAX_PAYLOAD_BYTES = 2 << 30
"""Refuse rather than allocate on a corrupt length. Two gibibytes is far above
any legitimate stage payload here -- the largest is a vision tower's patch
tensor, single-digit MB."""
class ProtocolError(RuntimeError):
"""The peer sent something this protocol cannot represent."""
class WorkerError(RuntimeError):
"""The peer ran and failed. Carries the remote traceback when there is one."""
def __init__(self, message: str, *, remote_traceback: str = "", code: str = "") -> None:
# The remote traceback goes in the message, not only in an attribute:
# a bare "AssertionError:" surfacing here with the actual stack one
# attribute away is a debugging loop nobody should have to discover.
detail = f"{message}\n--- worker traceback ---\n{remote_traceback}" if remote_traceback else message
super().__init__(detail)
self.message = message
self.remote_traceback = remote_traceback
self.code = code
def _recv_exactly(sock: socket.socket, n: int) -> bytes:
"""Read exactly ``n`` bytes or raise.
``recv`` is allowed to return short reads on a stream socket and does, once
payloads get past a page or two; a partial read here would be silently
misparsed as the next tensor.
"""
if n == 0:
return b""
chunks: list[bytes] = []
remaining = n
while remaining:
chunk = sock.recv(min(remaining, 1 << 20))
if not chunk:
raise ProtocolError(f"peer closed with {remaining} of {n} bytes outstanding")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks) if len(chunks) > 1 else chunks[0]
def send_message(
sock: socket.socket,
op: str,
body: dict[str, Any] | None = None,
tensors: dict[str, np.ndarray] | None = None,
) -> None:
"""Send one message. ``tensors`` are sent as raw buffers after the header."""
tensors = tensors or {}
described: list[dict[str, Any]] = []
buffers: list[memoryview] = []
for name, array in tensors.items():
array = np.asarray(array)
if (
not isinstance(name, str)
or not name
or array.dtype.kind not in "biuf"
or array.dtype.itemsize not in (1, 2, 4, 8)
):
raise ProtocolError("unsupported tensor name or dtype")
if sum(b.nbytes for b in buffers) + array.nbytes > MAX_PAYLOAD_BYTES:
raise ProtocolError("outgoing payload exceeds limit")
# Record the shape *before* making it contiguous: ``ascontiguousarray``
# promotes a 0-d array to shape (1,), which would silently turn a
# scalar graph input into a length-1 vector on the far side.
shape = list(array.shape)
contiguous = np.ascontiguousarray(array)
described.append(
{
"name": name,
# ``str(dtype)`` loses endianness for multibyte types; ``.str``
# keeps the '<'/'>' so a big-endian peer would still decode.
"dtype": contiguous.dtype.str,
"shape": shape,
"nbytes": int(contiguous.nbytes),
}
)
# ``reshape(-1)`` rather than a direct cast: ``memoryview.cast`` refuses
# a 0-dimensional source.
buffers.append(memoryview(contiguous.reshape(-1)).cast("B"))
if op == OP_HELLO and os.environ.get("VLLM_OMNI_WORKER_IDENTITY"):
body = {**(body or {}), "process_identity": json.loads(os.environ["VLLM_OMNI_WORKER_IDENTITY"])}
header = json.dumps(
{"version": 1, "required_features": ["host-copy"], "op": op, "body": body or {}, "tensors": described}
).encode("utf-8")
if len(header) > MAX_HEADER_BYTES:
raise ProtocolError(f"header is {len(header)} bytes, over the {MAX_HEADER_BYTES} limit")
sock.sendall(struct.pack(">I", len(header)))
sock.sendall(header)
for buffer in buffers:
sock.sendall(buffer)
def recv_message(
sock: socket.socket, *, max_payload_bytes: int = MAX_PAYLOAD_BYTES
) -> tuple[str, dict[str, Any], dict[str, np.ndarray]]:
"""Receive one message as ``(op, body, tensors)``.
Raises :class:`WorkerError` on an ``err`` reply so callers do not each have
to remember to check; every other op is returned as-is.
"""
(header_len,) = struct.unpack(">I", _recv_exactly(sock, 4))
if header_len > MAX_HEADER_BYTES:
raise ProtocolError(f"peer announced a {header_len}-byte header, over the limit")
try:
header = json.loads(_recv_exactly(sock, header_len).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ProtocolError(f"header is not JSON: {exc}") from exc
if not isinstance(header, dict):
raise ProtocolError("invalid message header")
try:
required = header.get("required_features", [])
if not isinstance(required, list) or any(not isinstance(f, str) for f in required):
raise ValueError("required_features must be a list of names")
# Missing version/features is the explicit legacy-v1 conversion.
negotiate(header.get("version", 1), required)
except ValueError as exc:
raise ProtocolError(str(exc)) from exc
if not isinstance(header.get("body", {}), dict) or not isinstance(header.get("tensors", []), list):
raise ProtocolError("invalid message schema")
total = 0
names = set()
try:
for desc in header.get("tensors", ()):
dtype = np.dtype(desc["dtype"])
shape, size, name = desc["shape"], desc["nbytes"], desc["name"]
if dtype.hasobject or dtype.kind not in "biuf" or dtype.itemsize not in (1, 2, 4, 8):
raise ValueError("unsupported tensor dtype")
if not isinstance(shape, list) or len(shape) > 32 or any(type(x) is not int or x < 0 for x in shape):
raise ValueError("invalid tensor dimensions")
if type(size) is not int or size != math.prod(shape) * dtype.itemsize:
raise ValueError("tensor byte length disagrees with dtype/shape")
if not isinstance(name, str) or not name or name in names:
raise ValueError("invalid or duplicate tensor name")
names.add(name)
total += size
except (KeyError, TypeError, ValueError) as exc:
raise ProtocolError(f"invalid tensor descriptor: {exc}") from exc
if total > min(max_payload_bytes, MAX_PAYLOAD_BYTES):
raise ProtocolError(f"peer announced a {total}-byte payload, over the limit")
tensors: dict[str, np.ndarray] = {}
for spec in header.get("tensors", ()):
raw = _recv_exactly(sock, int(spec["nbytes"]))
array = np.frombuffer(raw, dtype=np.dtype(spec["dtype"]))
# ``frombuffer`` is read-only and aliases ``raw``; a consumer that
# writes in place (ORT's IOBinding does) needs its own buffer.
tensors[spec["name"]] = array.reshape(spec["shape"]).copy()
op = str(header.get("op", ""))
body = header.get("body") or {}
if op == OP_ERR:
raise WorkerError(
str(body.get("message", "worker failed with no message")),
remote_traceback=str(body.get("traceback", "")),
code=str(body.get("code", "")),
)
return op, body, tensors
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "omni-stage-contracts"
version = "0.1.0"
description = "Dependency-free stage contracts from the vLLM-Omni repository"
requires-python = ">=3.10"
readme = "README.md"
[project.optional-dependencies]
wire = ["numpy>=1.24"]
[tool.setuptools]
packages = ["omni_stage_contracts"]
+4 -1
View File
@@ -168,9 +168,12 @@ vllm_omni_windows = "vllm_omni.windows:activate"
vllm_omni_windows = "vllm_omni.windows:activate"
[tool.setuptools.package-dir]
omni_stage_contracts = "packages/omni-stage-contracts/omni_stage_contracts"
[tool.setuptools.packages.find]
where = ["."]
include = ["vllm_omni*"]
include = ["vllm_omni*", "omni_stage_contracts*"]
[tool.setuptools.package-data]
"vllm_omni" = ["_version.py", "py.typed", "transformers_utils/chat_templates/*.jinja"]
+15 -10
View File
@@ -25,9 +25,7 @@ from pathlib import Path
import numpy as np
_HERE = Path(__file__).resolve()
_PROTOCOL = (
_HERE.parents[4] / "vllm_omni" / "edge" / "local" / "external" / "protocol.py"
)
_PROTOCOL = _HERE.parents[4] / "vllm_omni" / "edge" / "local" / "external" / "protocol.py"
spec = importlib.util.spec_from_file_location("_fake_protocol", _PROTOCOL)
proto = importlib.util.module_from_spec(spec)
@@ -78,13 +76,15 @@ def main() -> int:
"session_create_s": 0.01,
"warmup_s": 0.001,
"rss_bytes": 111 * 2**20,
"inputs": [{"name": n, "type": "tensor(float)", "shape": list(t.shape)}
for n, t in tensors.items()],
"inputs": [{"name": n, "type": "tensor(float)", "shape": list(t.shape)} for n, t in tensors.items()],
"outputs": [{"name": "y", "type": "tensor(float)", "shape": []}],
}
reply.update(config.get("load", {}))
proto.send_message(sock, proto.OP_OK, reply)
elif op == proto.OP_RUN:
import time
time.sleep(float(config.get("run", {}).get("sleep_s", 0)))
scale = float(config.get("run", {}).get("scale", 2.0))
proto.send_message(
sock,
@@ -94,16 +94,21 @@ def main() -> int:
)
elif op == proto.OP_STATS:
proto.send_message(
sock, proto.OP_OK,
{"runs": 1, "total_run_s": 0.001, "rss_bytes": 111 * 2**20,
"peak_rss_bytes": 122 * 2**20, "report": {}},
sock,
proto.OP_OK,
{
"runs": 1,
"total_run_s": 0.001,
"rss_bytes": 111 * 2**20,
"peak_rss_bytes": 122 * 2**20,
"report": {},
},
)
elif op == proto.OP_CLOSE:
proto.send_message(sock, proto.OP_OK, {})
return 0
else:
proto.send_message(sock, proto.OP_ERR,
{"message": f"unknown op {op}", "code": "ValueError"})
proto.send_message(sock, proto.OP_ERR, {"message": f"unknown op {op}", "code": "ValueError"})
if __name__ == "__main__":
+29 -5
View File
@@ -16,9 +16,7 @@ from vllm_omni.edge.local.external import protocol
def _round_trip(op, body=None, tensors=None):
near, far = socket.socketpair()
try:
threading.Thread(
target=lambda: protocol.send_message(near, op, body, tensors), daemon=True
).start()
threading.Thread(target=lambda: protocol.send_message(near, op, body, tensors), daemon=True).start()
return protocol.recv_message(far)
finally:
near.close()
@@ -66,8 +64,7 @@ def test_empty_message_and_no_tensors():
def test_error_reply_raises_with_remote_detail():
with pytest.raises(protocol.WorkerError) as excinfo:
_round_trip(protocol.OP_ERR, {"message": "boom", "code": "ValueError",
"traceback": "far side traceback"})
_round_trip(protocol.OP_ERR, {"message": "boom", "code": "ValueError", "traceback": "far side traceback"})
assert excinfo.value.code == "ValueError"
assert "far side traceback" in excinfo.value.remote_traceback
@@ -92,3 +89,30 @@ def test_oversized_header_is_refused_before_allocation():
finally:
near.close()
far.close()
@pytest.mark.parametrize(
"override",
[
{"version": 2},
{"version": True},
{"required_features": ["unknown-required-feature"]},
{"tensors": [{"name": "x", "dtype": "<f4", "shape": [2], "nbytes": 1}]},
{"tensors": [{"name": "x", "dtype": "|O", "shape": [1], "nbytes": 8}]},
{"tensors": [{"name": "x", "dtype": "<f4", "shape": [-1], "nbytes": -4}]},
],
)
def test_invalid_frame_refused_without_reading_payload(override):
import json
import struct
near, far = socket.socketpair()
far.settimeout(0.2)
try:
header = json.dumps({"version": 1, "op": "run", "body": {}, "tensors": [], **override}).encode()
near.sendall(struct.pack(">I", len(header)) + header)
with pytest.raises(protocol.ProtocolError):
protocol.recv_message(far)
finally:
near.close()
far.close()
+526
View File
@@ -0,0 +1,526 @@
"""The real StageRuntime/StagePool with a subprocess graph worker."""
import asyncio
import hashlib
import json
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from omni_stage_contracts import BufferRef, StateHandle, negotiate
from vllm_omni.config.stage_config import (
DeployConfig,
PipelineConfig,
StageDeployConfig,
StageExecutionType,
StagePipelineConfig,
merge_pipeline_deploy,
)
from vllm_omni.engine.resource_ledger import ResourceLedger, ResourceUnavailable
from vllm_omni.engine.stage_runtime import StageRuntime
ROOT = Path(__file__).resolve().parents[2]
def artifact(tmp_path):
(tmp_path / "graph.onnx").write_bytes(b"synthetic graph, not model evidence")
np.savez(tmp_path / "inputs.npz", x=np.ones((2, 3), dtype=np.float32))
files = {n: hashlib.sha256((tmp_path / n).read_bytes()).hexdigest() for n in ("graph.onnx", "inputs.npz")}
path = tmp_path / "manifest.json"
path.write_text(
json.dumps(
{
"schema_version": 1,
"component": "test",
"files": files,
"metadata": {"graph_file": "graph.onnx", "example_inputs_file": "inputs.npz"},
}
)
)
return path
@pytest.fixture
def graph_runtime(tmp_path, monkeypatch):
config_path = tmp_path / "worker.json"
config_path.write_text("{}")
monkeypatch.setenv("VLLM_OMNI_FAKE_WORKER_JSON", str(config_path))
monkeypatch.setenv("VLLM_OMNI_EXTERNAL_WORKER_ORT_CPU", str(ROOT / "tests/edge/local/external/fake_worker.py"))
monkeypatch.setenv("VLLM_OMNI_EXTERNAL_PYTHON_ORT_CPU", sys.executable)
runtimes = []
def create(
*, stages=1, capacity=1024 << 20, worker_config=None, start=True, chain=False, real_ort=False, entrypoint=None
):
config_path.write_text(json.dumps(worker_config or {}))
manifest = artifact(tmp_path)
if real_ort:
onnx = pytest.importorskip("onnx")
pytest.importorskip("onnxruntime")
from onnx import TensorProto, helper
graph = helper.make_graph(
[helper.make_node("Add", ["x", "x"], ["x_out"])],
"double",
[helper.make_tensor_value_info("x", TensorProto.FLOAT, [2, 3])],
[helper.make_tensor_value_info("x_out", TensorProto.FLOAT, [2, 3])],
)
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 18)], ir_version=9)
onnx.save(model, tmp_path / "graph.onnx")
data = json.loads(manifest.read_text())
data["files"]["graph.onnx"] = hashlib.sha256((tmp_path / "graph.onnx").read_bytes()).hexdigest()
manifest.write_text(json.dumps(data))
monkeypatch.delenv("VLLM_OMNI_EXTERNAL_WORKER_ORT_CPU", raising=False)
pipeline = PipelineConfig(
model_type="test_graph",
stages=tuple(
StagePipelineConfig(
stage_id=i,
model_stage="graph",
execution_type=StageExecutionType.GRAPH,
final_output=(not chain or i == stages - 1),
final_output_type="latent",
input_sources=(i - 1,) if chain and i else (),
custom_process_input_func=(
"tests.engine.test_graph_backend.graph_handoff" if chain and i else None
),
)
for i in range(stages)
),
)
deploy = DeployConfig(
async_chunk=False,
stages=[
StageDeployConfig(
stage_id=i,
backend={
"name": "external.graph.v1",
"route": "ort-cpu",
"manifest": str(manifest),
"start_timeout_s": 5,
},
resource_budget={"capacities": {"host_ram": capacity}, "demands": {"host_ram": 512 << 20}},
)
for i in range(stages)
],
)
if entrypoint is not None:
import yaml
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES
monkeypatch.setitem(OMNI_PIPELINES, pipeline.model_type, pipeline)
(tmp_path / "config.json").write_text('{"model_type":"bert"}')
deploy_path = tmp_path / "deploy.yaml"
deploy_path.write_text(
yaml.safe_dump(
{
"pipeline": pipeline.model_type,
"async_chunk": False,
"stages": [
{"stage_id": s.stage_id, "backend": s.backend, "resource_budget": s.resource_budget}
for s in deploy.stages
],
}
)
)
engine = entrypoint(
model=str(tmp_path), deploy_config=str(deploy_path), stage_init_timeout=10, init_timeout=30
)
runtimes.append(engine)
return engine
configs = [s.to_omegaconf() for s in merge_pipeline_deploy(pipeline, deploy)]
runtime = StageRuntime(configs, "local-graph", "", stage_init_timeout=5, async_chunk=False)
runtimes.append(runtime)
if start:
runtime.initialize()
return runtime
yield create
for runtime in runtimes:
runtime.shutdown()
def test_contract_import_does_not_import_engine():
code = "import omni_stage_contracts; import sys; assert not ({'torch','vllm','vllm_omni'} & set(sys.modules))"
subprocess.run([sys.executable, "-S", "-c", code], cwd=ROOT, check=True)
def test_device_init_lock_is_host_local_and_preserves_exclusion(tmp_path, monkeypatch):
import os
from vllm_omni import host
from vllm_omni._filelock_compat import flock_exclusive_nb, funlock
from vllm_omni.engine.stage_init_utils import device_init_lock_path, open_device_lock_file
from vllm_omni.host import paths
assert host is not None
monkeypatch.setattr(paths, "device_lock_directory", lambda: str(tmp_path))
lock_path = device_init_lock_path(0)
assert Path(lock_path).parent == tmp_path
first, writable = open_device_lock_file(lock_path)
second = None
try:
assert writable
flock_exclusive_nb(first)
second, _ = open_device_lock_file(lock_path)
with pytest.raises(BlockingIOError):
flock_exclusive_nb(second)
funlock(first)
flock_exclusive_nb(second)
funlock(second)
finally:
os.close(first)
if second is not None:
os.close(second)
def test_graph_metadata_matches_structured_and_legacy_config():
from vllm_omni.config import VllmOmniConfig, VllmOmniGraphStageConfig
from vllm_omni.engine.stage_init_utils import (
extract_legacy_stage_metadata,
extract_stage_metadata_from_omni_stage_config,
)
pipeline = PipelineConfig(
model_type="test_graph",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="graph",
execution_type=StageExecutionType.GRAPH,
final_output=True,
final_output_type="latent",
),
),
)
deploy = DeployConfig(
async_chunk=False,
stages=[
StageDeployConfig(
stage_id=0,
backend={"name": "external.graph.v1"},
resource_budget={"capacities": {"ram": 1024}, "demands": {"ram": 512}},
)
],
)
config = VllmOmniConfig.from_pipeline_config(pipeline, user_deploy_config=deploy).stage_by_id(0)
assert isinstance(config, VllmOmniGraphStageConfig)
structured = extract_stage_metadata_from_omni_stage_config(config)
legacy = extract_legacy_stage_metadata(merge_pipeline_deploy(pipeline, deploy)[0].to_omegaconf())
assert structured.stage_type == legacy.stage_type == "graph"
assert type(structured.default_sampling_params) is type(legacy.default_sampling_params)
def test_language_independent_contract_fixtures():
from omni_stage_contracts import StageEvent, StageRequest
fixture = json.loads((ROOT / "packages/omni-stage-contracts/conformance/v1.json").read_text())
negotiate(fixture["protocol_version"], fixture["required_features"])
BufferRef(**fixture["buffer"])
StageRequest(**fixture["request"])
StageEvent(**fixture["event"])
state = StateHandle(**fixture["state"])
for override in fixture["reject_buffer_overrides"]:
with pytest.raises(ValueError):
BufferRef(**{**fixture["buffer"], **override})
for override in fixture["stale_state_overrides"]:
assert not state.accepts(StateHandle(**{**fixture["state"], **override}))
def test_external_weight_payload_must_be_in_manifest(tmp_path):
onnx = pytest.importorskip("onnx")
from onnx import helper, numpy_helper
from vllm_omni.edge.local.external.worker_ort import _verify_artifact_members
weight = numpy_helper.from_array(np.ones((32, 32), dtype=np.float32), "w")
graph = helper.make_graph([helper.make_node("Add", ["x", "w"], ["y"])], "external", [], [], [weight])
path = tmp_path / "model.onnx"
onnx.save_model(
helper.make_model(graph),
path,
save_as_external_data=True,
all_tensors_to_one_file=True,
location="weights.bin",
size_threshold=0,
)
with pytest.raises(ValueError, match="absent from verified manifest"):
_verify_artifact_members(str(path), [str(path)])
_verify_artifact_members(str(path), [str(path), str(tmp_path / "weights.bin")])
def test_contract_rejects_shape_and_generation_mismatch():
with pytest.raises(ValueError):
BufferRef("x", "a", "1", "float32", (2, 3), 23)
with pytest.raises(ValueError):
negotiate(2)
with pytest.raises(ValueError):
negotiate(1, ["zero-copy"])
a = StateHandle("s", "ort", "sha", worker_generation="old")
b = StateHandle("s", "ort", "sha", worker_generation="new")
assert not a.accepts(b)
def test_atomic_shared_constraints_and_quarantine():
ledger = ResourceLedger({"ram": 100, "wsl": 60})
first = ledger.reserve("wsl", {"ram": 50, "wsl": 50})
with pytest.raises(ResourceUnavailable):
ledger.reserve("igpu+npu", {"ram": 60})
with pytest.raises(ResourceUnavailable):
ledger.reserve("second-wsl", {"ram": 10, "wsl": 20})
assert ledger.snapshot()["reserved"] == {"ram": 50, "wsl": 50}
assert not ledger.release(first, drained=False)
assert ledger.snapshot()["quarantined"] == ["wsl"]
assert ledger.release(first, drained=True)
second = ledger.reserve("wsl", {"ram": 5})
assert not ledger.release(first, drained=True)
assert ledger.snapshot()["reserved"]["ram"] == 5
ledger.release(second, drained=True)
@pytest.mark.parametrize(
"kinds",
[
("cpu",),
("cpu", "gpu_integrated"),
("cpu", "npu"),
("cpu", "gpu_integrated", "npu"),
("cpu", "gpu_integrated", "npu", "gpu_discrete"),
],
)
def test_integrated_topology_has_one_physical_ram(kinds):
from vllm_omni.edge.topology import describe_topology
devices = [
SimpleNamespace(device_id=k, kind=k, extra={}, memory_pool="vram" if k == "gpu_discrete" else "host_ram")
for k in kinds
]
descriptions = describe_topology(devices, machine_id="pc", domain_id="windows")
for descriptor in descriptions:
if descriptor.integration != "discrete":
assert descriptor.memory_pool_ids == ("pc:ram",)
assert descriptor.power_domain_ids == () # unknown is not invented
async def wait_output(pool):
async def poll():
while (out := pool.poll_graph_output(0)) is None:
await asyncio.sleep(0.005)
return out
return await asyncio.wait_for(poll(), timeout=10)
@pytest.mark.asyncio
async def test_runtime_load_submit_lease_and_shutdown(graph_runtime):
runtime = graph_runtime()
pool = runtime.stage_pools[0]
state = SimpleNamespace(sampling_params_list=[None])
prompt = {"tensors": {"x": np.ones((2, 3), dtype=np.float32)}}
await pool.submit_initial("a", state, prompt)
output = await wait_output(pool)
np.testing.assert_array_equal(output.custom_output["tensors"]["x_out"], 2)
with pytest.raises(ResourceUnavailable):
await pool.submit_initial("b", state, prompt)
output.release_stage_buffers()
await asyncio.sleep(0)
await pool.submit_initial("b", state, prompt)
(await wait_output(pool)).release_stage_buffers()
runtime.shutdown()
assert runtime.resource_ledger.snapshot()["reserved"] == {"host_ram": 0}
def test_joint_admission_before_any_worker(graph_runtime):
runtime = graph_runtime(stages=2, chain=True, capacity=600 << 20, start=False)
with pytest.raises(ResourceUnavailable):
runtime.initialize()
assert runtime.resource_ledger.snapshot()["owners"] == []
assert runtime.stage_pools == []
@pytest.mark.parametrize("fanout", [False, True])
def test_unimplemented_graph_topologies_refused_before_loading(graph_runtime, fanout):
runtime = graph_runtime(stages=3, chain=fanout, start=False)
if fanout:
runtime._stage_configs[2].engine_input_source = [0]
with pytest.raises(ValueError, match="linear chain with exactly one final output"):
runtime.initialize()
assert runtime.resource_ledger is None
assert runtime.stage_pools == []
def test_placement_failure_releases_load(graph_runtime):
runtime = graph_runtime(worker_config={"load": {"fraction_on_target": 0.0}}, start=False)
with pytest.raises(RuntimeError, match="REFUSE_EP_PLACEMENT"):
runtime.initialize()
assert runtime.resource_ledger.snapshot()["owners"] == []
@pytest.mark.asyncio
async def test_worker_failure_produces_error_not_success(graph_runtime):
runtime = graph_runtime(worker_config={"die_on": "run"})
pool = runtime.stage_pools[0]
await pool.submit_initial(
"a", SimpleNamespace(sampling_params_list=[None]), {"tensors": {"x": np.ones((2, 3), dtype=np.float32)}}
)
output = await wait_output(pool)
assert output.finished and output.error
@pytest.mark.asyncio
async def test_cancel_fences_inflight_graph(graph_runtime):
runtime = graph_runtime(worker_config={"run": {"sleep_s": 30}})
pool = runtime.stage_pools[0]
await pool.submit_initial(
"a", SimpleNamespace(sampling_params_list=[None]), {"tensors": {"x": np.ones((2, 3), dtype=np.float32)}}
)
await asyncio.sleep(0.05)
await asyncio.wait_for(pool.abort_requests(["a"]), timeout=8)
assert pool.poll_graph_output(0) is None
assert runtime.resource_ledger.snapshot()["reserved"]["host_ram"] == 0
def graph_handoff(outputs, prompt, requires_multimodal_data):
return {"tensors": {"x": outputs[0].custom_output["tensors"]["x_out"]}}
@pytest.mark.asyncio
@pytest.mark.parametrize("event_driven", [False, True])
async def test_two_graphs_through_existing_orchestrator(graph_runtime, event_driven):
from vllm_omni.engine.messages import StageSubmissionMessage
from vllm_omni.engine.orchestrator import Orchestrator
runtime = graph_runtime(stages=2, chain=True)
request_queue, output_queue, rpc_queue = asyncio.Queue(), asyncio.Queue(), asyncio.Queue()
orch = Orchestrator(request_queue, output_queue, rpc_queue, runtime.stage_pools)
loop = asyncio.create_task(orch._orchestration_loop_event_driven() if event_driven else orch._orchestration_loop())
prompt = {"tensors": {"x": np.ones((2, 3), dtype=np.float32)}}
try:
await orch._handle_add_request(
StageSubmissionMessage(
type="add_request",
request_id="pipeline",
prompt=prompt,
original_prompt=prompt,
output_prompt_text=None,
sampling_params_list=[None, None],
final_stage_id=1,
final_output_stage_ids=[1],
preprocess_ms=0,
request_timestamp=0,
enqueue_ts=0,
)
)
from vllm_omni.engine.messages import StageMetricsMessage
while True:
result = await asyncio.wait_for(output_queue.get(), timeout=10)
if not isinstance(result, StageMetricsMessage):
break
assert result.finished
np.testing.assert_array_equal(result.engine_outputs.custom_output["tensors"]["x_out"], 4)
result.engine_outputs.release_stage_buffers()
await asyncio.sleep(0)
assert not orch.request_states
assert all(pool.stage_client._active is None for pool in runtime.stage_pools)
finally:
orch._shutdown_event.set()
await asyncio.wait_for(loop, timeout=5)
@pytest.mark.asyncio
async def test_stale_ack_cannot_release_reused_request_id(graph_runtime):
runtime = graph_runtime()
pool = runtime.stage_pools[0]
state = SimpleNamespace(sampling_params_list=[None])
prompt = {"tensors": {"x": np.ones((2, 3), dtype=np.float32)}}
await pool.submit_initial("same", state, prompt)
output = await wait_output(pool)
stale_ack = output._stage_release
output.release_stage_buffers()
await asyncio.sleep(0)
await pool.submit_initial("same", state, prompt)
next_output = await wait_output(pool)
stale_ack()
await asyncio.sleep(0)
assert pool.stage_client._active == "same"
next_output.release_stage_buffers()
@pytest.mark.asyncio
async def test_real_ort_cpu_graph_through_stage_runtime(graph_runtime):
runtime = graph_runtime(real_ort=True)
pool = runtime.stage_pools[0]
await pool.submit_initial(
"ort", SimpleNamespace(sampling_params_list=[None]), {"tensors": {"x": np.ones((2, 3), dtype=np.float32)}}
)
output = await wait_output(pool)
assert not output.error
np.testing.assert_array_equal(output.custom_output["tensors"]["x_out"], 2)
assert pool.stage_client.load_report.target_nodes > 0
output.release_stage_buffers()
@pytest.mark.asyncio
async def test_public_async_omni_graph_requests(graph_runtime):
from vllm_omni.entrypoints.async_omni import AsyncOmni
engine = graph_runtime(entrypoint=AsyncOmni)
for request_id in ("first", "second"):
outputs = [
out
async for out in engine.generate(
{"tensors": {"x": np.ones((2, 3), dtype=np.float32)}}, request_id=request_id
)
]
assert len(outputs) == 1
assert outputs[0].request_id == request_id
assert outputs[0].custom_output["tensors"]["x_out"].shape == (2, 3)
def test_public_sync_omni_graph_requests(graph_runtime):
from vllm_omni.entrypoints.omni import Omni
engine = graph_runtime(entrypoint=Omni)
for _ in range(2):
outputs = engine.generate({"tensors": {"x": np.ones((2, 3), dtype=np.float32)}}, use_tqdm=False)
assert len(outputs) == 1
assert outputs[0].custom_output["tensors"]["x_out"].shape == (2, 3)
@pytest.mark.asyncio
async def test_graph_ingress_stays_bounded_until_consumer_ack(graph_runtime):
from vllm_omni.engine.messages import OutputMessage
from vllm_omni.entrypoints.async_omni import AsyncOmni
frontend = graph_runtime(entrypoint=AsyncOmni)
engine = frontend.engine
prompt = {"tensors": {"x": np.ones((2, 3), dtype=np.float32)}}
engine.add_request("slow", prompt)
for i in range(100):
with pytest.raises(ResourceUnavailable, match="capacity is one"):
engine.add_request(f"blocked-{i}", prompt)
async def take():
while True:
msg = await engine.try_get_output_async()
if isinstance(msg, OutputMessage):
return msg.engine_outputs
await asyncio.sleep(0.005)
output = await asyncio.wait_for(take(), 5)
with pytest.raises(ResourceUnavailable):
engine.add_request("still-blocked", prompt)
stale_release = output._stage_release
output.release_stage_buffers()
await asyncio.sleep(0.01)
engine.add_request("next", prompt)
stale_release()
with pytest.raises(ResourceUnavailable):
engine.add_request("late-ack-must-not-release-next", prompt)
(await asyncio.wait_for(take(), 5)).release_stage_buffers()
+75
View File
@@ -0,0 +1,75 @@
"""Ownership/framing tests also run on POSIX; native connector tests run on Windows."""
import subprocess
import sys
import uuid
import pytest
from vllm_omni.host.shared_memory import SharedBufferOwner, read_buffer
def test_producer_retains_until_a_different_process_reads():
owner = SharedBufferOwner()
meta = owner.write(b"payload", f"omni-test-{uuid.uuid4().hex}")
try:
assert owner.retained_bytes > 0
code = (
"from vllm_omni.host.shared_memory import read_buffer; assert read_buffer(" + repr(meta) + ") == b'payload'"
)
subprocess.run([sys.executable, "-c", code], check=True, timeout=60)
owner.collect()
assert owner.retained_bytes == 0
finally:
owner.release(meta["name"])
def test_bounds_stale_generation_and_name_collision():
owner = SharedBufferOwner(max_bytes=128, max_buffers=1)
meta = owner.write(b"one", f"omni-test-{uuid.uuid4().hex}")
try:
with pytest.raises(FileExistsError):
SharedBufferOwner().write(b"collision", meta["name"])
with pytest.raises(ValueError, match="stale"):
read_buffer({**meta, "generation": "wrong"})
with pytest.raises(BufferError):
owner.write(b"two")
assert owner.retained_bytes > 0
assert read_buffer(meta) == b"one"
owner.collect()
assert owner.retained_bytes == 0
with pytest.raises(BufferError):
owner.write(b"x" * 129)
finally:
owner.release(meta["name"])
@pytest.mark.skipif(sys.platform != "win32", reason="native Windows path and mapping lifetime")
def test_native_connector_roundtrip_and_cleanup():
from vllm_omni.distributed.omni_connectors.connectors.shm_connector import SharedMemoryConnector
from vllm_omni.host.shared_memory import owner
from vllm_omni.windows.paths import shm_dir
assert not shm_dir().startswith("/dev/shm")
producer, consumer = SharedMemoryConnector({}), SharedMemoryConnector({})
key = f"omni-test-{uuid.uuid4().hex}"
try:
ok, size, _ = producer.put("0", "1", key, {"data": [1, 2, 3]})
assert ok and owner.retained_bytes > 0
assert consumer.get("0", "1", key) == ({"data": [1, 2, 3]}, size)
owner.collect()
assert owner.retained_bytes == 0
assert producer.put("0", "1", key, {"unconsumed": True})[0]
assert owner.retained_bytes > 0
producer.cleanup(key)
assert owner.retained_bytes == 0
assert consumer.get("0", "1", key) is None
for index in range(8):
next_key = f"{key}_{index}"
assert producer.put("0", "1", next_key, {"index": index})[0]
assert len(producer._pending_keys) <= 1
assert consumer.get("0", "1", next_key)[0] == {"index": index}
owner.collect()
finally:
producer.close()
consumer.close()
+17 -2
View File
@@ -7,6 +7,7 @@ that decide whether a vLLM function is replaced or left alone."""
from __future__ import annotations
import asyncio
import errno
import os
import sys
import tempfile
@@ -34,7 +35,7 @@ class FakeLocking:
self.calls.append((fd, mode))
if mode == self.NBLCK:
if fd in self.held:
raise OSError(36, "Resource deadlock avoided")
raise OSError(errno.EACCES, "Lock held")
self.held.add(fd)
elif mode == self.UNLCK:
self.held.discard(fd)
@@ -86,6 +87,20 @@ def test_flock_blocking_waits_until_released(lock_fd):
assert len(sleeps) == 2 and lock_fd in locking.held
@pytest.mark.parametrize("operation", [shims.LOCK_EX, shims.LOCK_EX | shims.LOCK_NB])
def test_flock_permanent_failure_does_not_retry(lock_fd, operation):
sleeps = []
def invalid_lock(*args):
raise OSError(errno.EINVAL, "Unsupported lock path")
shim = shims.FcntlShim(invalid_lock, 2, 0, sleep=sleeps.append)
with pytest.raises(OSError) as failure:
shim.flock(lock_fd, operation)
assert failure.value.errno == errno.EINVAL
assert sleeps == []
def test_shared_lock_is_served_as_exclusive_and_counted(lock_fd):
locking, sleeps = FakeLocking(), []
s = _shim(locking, sleeps)
@@ -237,7 +252,7 @@ def test_shm_dir_exists_and_path_joins():
d = shm_dir()
assert os.path.isdir(d)
assert shm_path("x.lock").startswith(d)
if os.path.isdir("/dev/shm"):
if sys.platform != "win32" and os.path.isdir("/dev/shm"):
assert d == "/dev/shm"
+13 -3
View File
@@ -43,9 +43,7 @@ def test_a_model_taking_kwargs_is_left_alone():
def test_a_vllm_native_model_reports_exactly_its_parameters():
accepted = _Probe(_VLLMNativeModel())._accepted_forward_kwargs()
assert accepted == frozenset(
{"input_ids", "positions", "intermediate_tensors", "inputs_embeds"}
)
assert accepted == frozenset({"input_ids", "positions", "intermediate_tensors", "inputs_embeds"})
for omni_only in ("sampling_metadata", "logits_index", "sampler"):
assert omni_only not in accepted
@@ -67,3 +65,15 @@ def test_an_uninspectable_forward_falls_back_to_passing_everything():
accepted = _Probe(Weird())._accepted_forward_kwargs()
assert accepted is None or "sampling_metadata" not in accepted
def test_cuda_graph_wrapper_preserves_underlying_forward_signature():
from vllm.compilation.cuda_graph import CUDAGraphWrapper
# No CUDA context is needed for attribute delegation. Use the actual vLLM
# wrapper rather than a fake that could conceal API changes.
wrapper = object.__new__(CUDAGraphWrapper)
wrapper.runnable = _VLLMNativeModel()
assert _Probe(wrapper)._accepted_forward_kwargs() == _Probe(wrapper.runnable)._accepted_forward_kwargs()
wrapper.runnable = _OmniNativeModel()
assert _Probe(wrapper)._accepted_forward_kwargs() is None
+2
View File
@@ -19,6 +19,7 @@ from vllm_omni.config.omni_config import (
VllmOmniConfig,
VllmOmniDiffusionStageConfig,
VllmOmniGenerationStageConfig,
VllmOmniGraphStageConfig,
VllmOmniOrchestratorConfig,
)
from vllm_omni.config.stage_config import (
@@ -60,6 +61,7 @@ __all__ = [
"BaseVllmOmniStageConfig",
"VllmOmniARStageConfig",
"VllmOmniGenerationStageConfig",
"VllmOmniGraphStageConfig",
"VllmOmniDiffusionStageConfig",
"StageConfigType",
"OmniConfigResolution",
+40 -4
View File
@@ -56,6 +56,7 @@ _EXECUTION_TYPE_TO_STAGE_WORKER: dict[StageExecutionType, tuple[StageType, str |
StageExecutionType.LLM_AR: (StageType.LLM, "ar"),
StageExecutionType.LLM_GENERATION: (StageType.LLM, "generation"),
StageExecutionType.DIFFUSION: (StageType.DIFFUSION, None),
StageExecutionType.GRAPH: (StageType.GRAPH, None),
}
_PIPELINE_DEPLOY_CLI_FIELDS = PIPELINE_WIDE_ENGINE_FIELDS
@@ -197,6 +198,8 @@ class _SchedulerEngineOverrides(TypedDict, total=False):
class _RuntimeEngineOverrides(TypedDict, total=False):
backend: dict[str, Any]
resource_budget: dict[str, Any]
additional_config: dict[str, Any]
distributed_executor_backend: Any
worker_cls: str
@@ -528,6 +531,8 @@ class OmniStageRuntimeConfig:
# LLM backend extensions; diffusion owns these in its config projection.
additional_config: dict[str, Any] | None = None
backend: dict[str, Any] | None = None
resource_budget: dict[str, Any] | None = None
distributed_executor_backend: Any = None
worker_cls: str | None = None
devices: str | None = None
@@ -1142,19 +1147,22 @@ _DIFFUSION_OWNED_STAGE_ENGINE_FIELDS = (
# validation must use the execution-type-specific sets below.
_STAGE_ENGINE_FIELDS = _LLM_STAGE_ENGINE_FIELDS | _DIFFUSION_OWNED_STAGE_ENGINE_FIELDS
_STAGE_ENGINE_FIELDS_BY_EXECUTION_TYPE = {
StageExecutionType.LLM_AR: _LLM_STAGE_ENGINE_FIELDS,
StageExecutionType.LLM_GENERATION: _LLM_STAGE_ENGINE_FIELDS,
StageExecutionType.DIFFUSION: _DIFFUSION_OWNED_STAGE_ENGINE_FIELDS,
StageExecutionType.LLM_AR: _LLM_STAGE_ENGINE_FIELDS - {"backend"},
StageExecutionType.LLM_GENERATION: _LLM_STAGE_ENGINE_FIELDS - {"backend"},
StageExecutionType.DIFFUSION: _DIFFUSION_OWNED_STAGE_ENGINE_FIELDS - {"backend"},
StageExecutionType.GRAPH: frozenset({"model", "backend", "resource_budget", "log_level", "log_stats"}),
}
_PARALLEL_CONFIG_ENGINE_FIELDS_BY_EXECUTION_TYPE = {
StageExecutionType.LLM_AR: _LLM_PARALLEL_CONFIG_ENGINE_FIELDS,
StageExecutionType.LLM_GENERATION: _LLM_PARALLEL_CONFIG_ENGINE_FIELDS,
StageExecutionType.DIFFUSION: _DIFFUSION_PARALLEL_CONFIG_ENGINE_FIELDS,
StageExecutionType.GRAPH: frozenset(),
}
_PARALLEL_CONFIG_FIELDS_BY_EXECUTION_TYPE = {
StageExecutionType.LLM_AR: _LLM_PARALLEL_CONFIG_FIELDS,
StageExecutionType.LLM_GENERATION: _LLM_PARALLEL_CONFIG_FIELDS,
StageExecutionType.DIFFUSION: frozenset(_DIFFUSION_PARALLEL_CONFIG_FIELD_MAP),
StageExecutionType.GRAPH: frozenset(),
}
_LOAD_STAGE_ENGINE_FIELD_MAP = {
@@ -1254,6 +1262,10 @@ def _stage_engine_values(
stage_cli_overrides: Mapping[str, Any] | None = None,
) -> _StageEngineValues:
engine = _stage_engine_overrides(stage_deploy)
if topology.execution_type == StageExecutionType.GRAPH and engine.get("silence_ban_frames") == 0:
# StageDeployConfig carries this speech-specific default even when no
# speech option was supplied. It is not an external-graph argument.
engine.pop("silence_ban_frames")
# Preserve legacy ordering: topology-owned KV roles override deploy
# extras, while an explicit CLI override remains highest priority.
if topology.omni_kv_config:
@@ -1467,7 +1479,14 @@ class VllmOmniDiffusionStageConfig(BaseVllmOmniStageConfig):
diffusion_config: _DiffusionConfigProjection = field(default_factory=_DiffusionConfigProjection)
StageConfigType: TypeAlias = VllmOmniARStageConfig | VllmOmniGenerationStageConfig | VllmOmniDiffusionStageConfig
@config(config=ConfigDict(arbitrary_types_allowed=True))
class VllmOmniGraphStageConfig(BaseVllmOmniStageConfig):
"""A whole external graph with an explicit backend and resource budget."""
StageConfigType: TypeAlias = (
VllmOmniARStageConfig | VllmOmniGenerationStageConfig | VllmOmniDiffusionStageConfig | VllmOmniGraphStageConfig
)
def _build_common_stage_config_kwargs(
@@ -1628,10 +1647,27 @@ def _build_diffusion_stage_config(
)
def _build_graph_stage_config(pipeline, deploy, topology, stage_deploy, engine, *, model):
if deploy.async_chunk or deploy.session_mode == "duplex":
raise ValueError("external graph v1 supports complete, stateless requests with async_chunk=False")
if stage_deploy is None or not engine.runtime.get("backend") or not engine.runtime.get("resource_budget"):
raise ValueError("graph stage requires backend and resource_budget")
common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs(
pipeline,
deploy,
topology,
stage_deploy,
engine,
model=model,
)
return _with_resolved_processors(VllmOmniGraphStageConfig(**common_kwargs), input_proc, next_stage_proc)
_STAGE_CONFIG_BUILDERS = {
StageExecutionType.LLM_AR: _build_ar_stage_config,
StageExecutionType.LLM_GENERATION: _build_generation_stage_config,
StageExecutionType.DIFFUSION: _build_diffusion_stage_config,
StageExecutionType.GRAPH: _build_graph_stage_config,
}
+5
View File
@@ -189,6 +189,7 @@ class StageType(str, Enum):
# TODO(@lishunyang12): remove once all models migrate to StageExecutionType
LLM = "llm"
DIFFUSION = "diffusion"
GRAPH = "graph"
class StageExecutionType(str, Enum):
@@ -197,6 +198,7 @@ class StageExecutionType(str, Enum):
LLM_AR = "llm_ar"
LLM_GENERATION = "llm_generation"
DIFFUSION = "diffusion"
GRAPH = "graph"
def _resolve_scheduler(
@@ -386,6 +388,8 @@ class StageDeployConfig:
devices: str | None = None
num_replicas: int = 1
env: dict[str, Any] | None = None
backend: dict[str, Any] | None = None
resource_budget: dict[str, Any] | None = None
# Inter-stage connector wiring and request defaults.
output_connectors: dict[str, str] | None = None
@@ -867,6 +871,7 @@ _EXECUTION_TYPE_TO_STAGE_WORKER: dict[StageExecutionType, tuple[StageType, str |
StageExecutionType.LLM_AR: (StageType.LLM, "ar"),
StageExecutionType.LLM_GENERATION: (StageType.LLM, "generation"),
StageExecutionType.DIFFUSION: (StageType.DIFFUSION, None),
StageExecutionType.GRAPH: (StageType.GRAPH, None),
}
@@ -3,9 +3,8 @@
# [edge-infer W1] This module is in the import closure of AsyncOmni, so a
# module-scope `import fcntl` makes the whole engine unimportable on Windows
# -- before anyone has chosen a connector. The transport itself is genuinely
# POSIX (it hardcodes /dev/shm lock paths), so the honest Windows behaviour
# is to stay importable and refuse at construction, not to fake a port.
# -- before anyone has chosen a connector. The Windows plugin supplies locks;
# host helpers keep mappings alive until the single consumer acknowledges them.
try:
import fcntl
except ImportError: # Windows
@@ -15,6 +14,7 @@ from multiprocessing import shared_memory as shm_pkg
from typing import Any
from vllm_omni.entrypoints.stage_utils import shm_read_bytes, shm_write_bytes
from vllm_omni.windows.paths import shm_path
from ..utils.logging import get_connector_logger
from .base import OmniConnectorBase
@@ -25,7 +25,7 @@ logger = get_connector_logger(__name__)
class SharedMemoryConnector(OmniConnectorBase):
"""Key-addressed local shared-memory connector.
SHM is a local-only transport: it reads/writes POSIX shared memory
SHM is a local-only transport: it reads/writes host shared memory
segments identified purely by *key*. It does **not** understand
remote-transport metadata such as ``source_host`` / ``source_port``
(that is the RDMA connector's job). When such metadata is passed in,
@@ -35,9 +35,8 @@ class SharedMemoryConnector(OmniConnectorBase):
def __init__(self, config: dict[str, Any]):
if fcntl is None:
raise RuntimeError(
"SharedMemoryConnector requires POSIX shared memory and fcntl "
"locking (it addresses lock files under /dev/shm); neither exists "
"on Windows. Select a different connector for this stage."
"SharedMemoryConnector requires host file-lock support. "
"On Windows, activate the vllm_omni.windows compatibility layer before importing connectors."
)
self.config = config
self.stage_id = config.get("stage_id", -1)
@@ -59,7 +58,7 @@ class SharedMemoryConnector(OmniConnectorBase):
payload = self.serialize_obj(data)
size = len(payload)
lock_file = f"/dev/shm/shm_{put_key}_lockfile.lock"
lock_file = shm_path(f"shm_{put_key}_lockfile.lock")
with open(lock_file, "wb+") as lockf:
fcntl.flock(lockf, fcntl.LOCK_EX)
meta = shm_write_bytes(payload, name=put_key)
@@ -67,6 +66,12 @@ class SharedMemoryConnector(OmniConnectorBase):
# meta contains {'name': ..., 'size': ...}
metadata = {"shm": meta, "size": size}
if os.name == "nt":
from vllm_omni.host.shared_memory import owner
# ACKs arrive in another process. Do not retain an unbounded
# history of keys after their producer handles are released.
self._pending_keys.intersection_update(owner.retained_names)
self._pending_keys.add(put_key)
self._metrics["puts"] += 1
@@ -86,7 +91,7 @@ class SharedMemoryConnector(OmniConnectorBase):
data_bytes = shm_read_bytes(shm_handle)
fcntl.flock(lockf, fcntl.LOCK_UN)
obj = self.deserialize_obj(data_bytes)
result = (obj, int(shm_handle.get("size", 0)))
result = (obj, len(data_bytes))
deserialized = True
return result
except Exception as e:
@@ -106,7 +111,7 @@ class SharedMemoryConnector(OmniConnectorBase):
shm = shm_pkg.SharedMemory(name=get_key)
if shm is None or shm.size == 0:
return None
lock_file = f"/dev/shm/shm_{get_key}_lockfile.lock"
lock_file = shm_path(f"shm_{get_key}_lockfile.lock")
shm_handle = {"name": get_key, "size": shm.size}
result = self._get_data_with_lock(lock_file, shm_handle)
if result is not None:
@@ -142,7 +147,7 @@ class SharedMemoryConnector(OmniConnectorBase):
if isinstance(metadata, dict) and "shm" in metadata:
shm_handle = metadata["shm"]
lock_file = f"/dev/shm/shm_{shm_handle['name']}_lockfile.lock"
lock_file = shm_path(f"shm_{shm_handle['name']}_lockfile.lock")
result = self._get_data_with_lock(lock_file, shm_handle)
if result is not None:
self._pending_keys.discard(get_key)
@@ -171,6 +176,7 @@ class SharedMemoryConnector(OmniConnectorBase):
]
for key in stale:
self._pending_keys.discard(key)
self._release_owned_buffer(key)
try:
seg = shm_pkg.SharedMemory(name=key)
seg.close()
@@ -180,7 +186,7 @@ class SharedMemoryConnector(OmniConnectorBase):
pass
except Exception as e:
logger.debug("cleanup: failed to unlink SHM segment %s: %s", key, e)
lock_file = f"/dev/shm/shm_{key}_lockfile.lock"
lock_file = shm_path(f"shm_{key}_lockfile.lock")
if os.path.exists(lock_file):
try:
os.remove(lock_file)
@@ -190,13 +196,14 @@ class SharedMemoryConnector(OmniConnectorBase):
def close(self) -> None:
"""Unlink all remaining tracked SHM segments."""
for key in list(self._pending_keys):
self._release_owned_buffer(key)
try:
seg = shm_pkg.SharedMemory(name=key)
seg.close()
seg.unlink()
except Exception:
pass
lock_file = f"/dev/shm/shm_{key}_lockfile.lock"
lock_file = shm_path(f"shm_{key}_lockfile.lock")
if os.path.exists(lock_file):
try:
os.remove(lock_file)
@@ -204,5 +211,12 @@ class SharedMemoryConnector(OmniConnectorBase):
pass
self._pending_keys.clear()
@staticmethod
def _release_owned_buffer(key: str) -> None:
if os.name == "nt":
from vllm_omni.host.shared_memory import owner
owner.release(key)
def health(self) -> dict[str, Any]:
return {"status": "healthy", **self._metrics}
+109 -24
View File
@@ -28,10 +28,12 @@ ledger, not a rounding error. So the worker reports its own RSS and
from __future__ import annotations
import json
import secrets
import socket
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
@@ -42,6 +44,7 @@ import numpy as np
from vllm_omni.edge.local.external import launch as _launch
from vllm_omni.edge.local.external import protocol as _proto
from vllm_omni.edge.local.external.protocol import ProtocolError, WorkerError
from vllm_omni.host.process import spawn_worker, terminate_worker
DEFAULT_START_TIMEOUT_S = 120.0
"""Generous on purpose: the first VitisAI session on a cold machine spends
@@ -212,12 +215,17 @@ class ExternalWorker:
*,
start_timeout_s: float = DEFAULT_START_TIMEOUT_S,
call_timeout_s: float = DEFAULT_CALL_TIMEOUT_S,
max_payload_bytes: int = _proto.MAX_PAYLOAD_BYTES,
) -> None:
if not route.available:
raise WorkerStartError(f"route {route.name} is not available: {route.reason}")
self.route = route
self.start_timeout_s = start_timeout_s
self.call_timeout_s = call_timeout_s
self.max_payload_bytes = max_payload_bytes
self._log = None
self._lifecycle_lock = threading.RLock()
self._drained = False
self._process: subprocess.Popen[bytes] | None = None
self._sock: socket.socket | None = None
self._listener: socket.socket | None = None
@@ -228,6 +236,8 @@ class ExternalWorker:
def start(self) -> dict[str, Any]:
"""Launch the worker and complete the handshake. Returns its ``hello``."""
if self._process is not None:
raise WorkerStartError("worker instances cannot be started twice")
token = secrets.token_hex(16)
host = _bind_address(is_windows=self.route.is_windows)
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -240,19 +250,29 @@ class ExternalWorker:
argv = [
str(self.route.interpreter),
_launch.to_worker_path(
Path(__file__).parents[3] / "host/worker_bootstrap.py", is_windows=self.route.is_windows
),
_launch.to_worker_path(self.route.worker, is_windows=self.route.is_windows),
"--host", host,
"--port", str(port),
"--token", token,
"--host",
host,
"--port",
str(port),
"--token",
token,
]
self._process = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self._log = tempfile.TemporaryFile()
try:
self._process = spawn_worker(argv, stdout=self._log, stderr=self._log)
except BaseException:
self.close()
raise
try:
conn, _ = listener.accept()
except socket.timeout as exc:
except TimeoutError as exc:
raise WorkerStartError(
f"worker did not connect back within {self.start_timeout_s:.0f}s: "
f"{self._drain_process()}"
f"worker did not connect back within {self.start_timeout_s:.0f}s: {self._drain_process()}"
) from exc
finally:
listener.close()
@@ -262,7 +282,11 @@ class ExternalWorker:
conn.settimeout(self.call_timeout_s)
self._sock = conn
op, body, _ = _proto.recv_message(conn)
try:
op, body, _ = _proto.recv_message(conn, max_payload_bytes=0)
except BaseException:
self.terminate()
raise
if op != _proto.OP_HELLO:
self.close()
raise WorkerStartError(f"first message was {op!r}, expected {_proto.OP_HELLO!r}")
@@ -275,20 +299,74 @@ class ExternalWorker:
return body
def _drain_process(self) -> str:
with self._lifecycle_lock:
return self._drain_process_locked()
def _drain_process_locked(self) -> str:
process = self._process
if process is None:
return "worker was never started"
if process.poll() is None:
process.kill()
try:
out, err = process.communicate(timeout=5)
except subprocess.TimeoutExpired: # pragma: no cover - kill just ran
if not terminate_worker(process):
return f"worker pid {process.pid} did not exit"
text = (err or b"").decode("utf-8", "replace").strip()
if not text:
text = (out or b"").decode("utf-8", "replace").strip()
text = ""
if self._log is not None:
self._log.seek(0, 2)
self._log.seek(max(0, self._log.tell() - 2000))
text = self._log.read().decode("utf-8", "replace").strip()
return f"exit={process.returncode} output={text[:2000]!r}" if text else f"exit={process.returncode}"
def terminate(self) -> bool:
"""Interrupt a blocking native call without sending on its data socket.
The caller retains reservations if the process tree did not drain.
This method may run concurrently with the one in-flight `_call`.
"""
with self._lifecycle_lock:
return self._terminate_locked()
def _terminate_locked(self) -> bool:
if self._drained:
return True
native_drained = True
if self._process is not None and self.route.is_windows and _launch.is_wsl():
identity = self.hello.get("process_identity") or {}
native_drained = False
if type(identity.get("pid")) is int and type(identity.get("created_filetime")) is int:
try:
helper = Path(__file__).parents[3] / "host/windows_process.py"
result = subprocess.run(
[
str(self.route.interpreter),
_launch.to_worker_path(helper, is_windows=True),
str(identity["pid"]),
str(identity["created_filetime"]),
],
capture_output=True,
timeout=20,
text=True,
)
native_drained = result.returncode == 0 and json.loads(result.stdout).get("drained") is True
except (OSError, subprocess.SubprocessError, ValueError):
pass
# Keep the data connection open until native tree retirement completes:
# closing it first lets an idle worker exit before we can verify its tree.
sock, self._sock = self._sock, None
if sock is not None:
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
sock.close()
if self._listener is not None:
self._listener.close()
self._listener = None
drained = (self._process is None or terminate_worker(self._process)) and native_drained
self._drained = drained
if drained and self._log is not None:
self._log.close()
self._log = None
return drained
def close(self, timeout_s: float = DEFAULT_CLOSE_TIMEOUT_S) -> None:
"""Ask the worker to exit; kill it if it will not. Safe to call twice."""
sock, self._sock = self._sock, None
@@ -304,17 +382,20 @@ class ExternalWorker:
if self._listener is not None:
self._listener.close()
self._listener = None
process, self._process = self._process, None
process = self._process
if process is not None and process.poll() is None:
try:
process.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
if not self.terminate():
raise WorkerStartError("worker tree did not drain during close")
if process is not None:
for stream in (process.stdout, process.stderr):
if stream is not None:
stream.close()
if self._log is not None:
self._log.close()
self._log = None
def __enter__(self) -> ExternalWorker:
self.start()
@@ -336,12 +417,13 @@ class ExternalWorker:
raise WorkerStartError("worker is not running; call start() first")
try:
_proto.send_message(sock, op, body, tensors)
reply_op, reply_body, reply_tensors = _proto.recv_message(sock)
reply_op, reply_body, reply_tensors = _proto.recv_message(sock, max_payload_bytes=self.max_payload_bytes)
except (OSError, ProtocolError) as exc:
# The socket is the only liveness signal we have; a dead worker
# shows up as a broken pipe or a short read, and the useful part of
# the diagnosis is on its stderr.
detail = self._drain_process()
sock.close()
self._sock = None
raise WorkerStartError(f"worker connection failed ({exc}); {detail}") from exc
if reply_op != _proto.OP_OK:
@@ -357,6 +439,7 @@ class ExternalWorker:
profile_prefix: str | Path | None = None,
ep_dir: str | Path | None = None,
intra_op_num_threads: int | None = None,
artifact_files: list[Path] | None = None,
) -> LoadReport:
"""Open a graph and measure where its nodes actually ran.
@@ -371,13 +454,15 @@ class ExternalWorker:
"profile": bool(profile),
}
if profile_prefix is not None:
body["profile_prefix"] = _launch.to_worker_path(
profile_prefix, is_windows=self.route.is_windows
)
body["profile_prefix"] = _launch.to_worker_path(profile_prefix, is_windows=self.route.is_windows)
if ep_dir is not None:
body["ep_dir"] = str(ep_dir)
if intra_op_num_threads is not None:
body["intra_op_num_threads"] = int(intra_op_num_threads)
if artifact_files is not None:
body["artifact_files"] = [
_launch.to_worker_path(path, is_windows=self.route.is_windows) for path in artifact_files
]
reply, _ = self._call(_proto.OP_LOAD, body, example_inputs or {})
self.report = LoadReport.from_body(reply, ep=self.route.ep, graph_path=str(graph_path))
+30 -21
View File
@@ -29,6 +29,7 @@ different work, and the planner has to be able to tell the caller which it hit.
from __future__ import annotations
import getpass
import os
import shutil
import subprocess
@@ -63,20 +64,25 @@ covered on a machine with no AMD hardware and no ORT at all."""
# Where the routes live on this machine. Defaults, not requirements: the env
# override above is the supported way to point at a different install, and the
# reason string names this list when nothing is found.
_WINDOWS_HOME = Path.home() if os.name == "nt" else Path("/mnt/c/Users") / getpass.getuser()
_DEFAULT_CANDIDATES: dict[str, tuple[str, ...]] = {
ROUTE_VITISAI: (
r"/mnt/c/Users/zhout/npu-ep/Scripts/python.exe",
r"/mnt/c/Users/zhout/ryzenai/Scripts/python.exe",
str(_WINDOWS_HOME / "npu-ep/Scripts/python.exe"),
str(_WINDOWS_HOME / "ryzenai/Scripts/python.exe"),
),
ROUTE_DML: (
r"/mnt/c/Users/zhout/dml-ep/Scripts/python.exe",
r"/mnt/c/Users/zhout/directml/Scripts/python.exe",
str(_WINDOWS_HOME / "dml-ep/Scripts/python.exe"),
str(_WINDOWS_HOME / "directml/Scripts/python.exe"),
),
ROUTE_TORCH_DML: () if os.name == "nt" else (".venvs/dml/bin/python",),
ROUTE_CPU: (
(".venvs/omni-cpu/Scripts/python.exe",)
if os.name == "nt"
else (".venvs/omni-cuda/bin/python", ".venvs/omni-cpu/bin/python")
),
ROUTE_TORCH_DML: (".venvs/dml/bin/python",),
ROUTE_CPU: (".venvs/omni-cuda/bin/python", ".venvs/omni-cpu/bin/python"),
ROUTE_WIN_CUDA: (
r"/mnt/c/Users/zhout/w0/w0venv/Scripts/python.exe",
r"/mnt/c/Users/zhout/vllm-win/Scripts/python.exe",
str(_WINDOWS_HOME / "w0/w0venv/Scripts/python.exe"),
str(_WINDOWS_HOME / "vllm-win/Scripts/python.exe"),
),
}
@@ -175,9 +181,10 @@ def is_wsl() -> bool:
"""
if os.name == "nt":
return False
return Path("/proc/sys/fs/binfmt_misc/WSLInterop").exists() or Path(
"/proc/sys/fs/binfmt_misc/WSLInterop-late"
).exists()
return (
Path("/proc/sys/fs/binfmt_misc/WSLInterop").exists()
or Path("/proc/sys/fs/binfmt_misc/WSLInterop-late").exists()
)
def to_worker_path(path: str | Path, *, is_windows: bool) -> str:
@@ -194,9 +201,7 @@ def to_worker_path(path: str | Path, *, is_windows: bool) -> str:
if not shutil.which("wslpath"):
return text
try:
out = subprocess.run(
["wslpath", "-w", text], capture_output=True, text=True, timeout=20, check=True
)
out = subprocess.run(["wslpath", "-w", text], capture_output=True, text=True, timeout=20, check=True)
except (subprocess.SubprocessError, OSError):
return text
return out.stdout.strip() or text
@@ -206,16 +211,22 @@ def _resolve_interpreter(route: str, root: Path) -> tuple[str | None, str]:
env_key = f"{ENV_PREFIX}{route.upper().replace('-', '_')}"
override = os.environ.get(env_key)
if override:
if Path(override).exists():
return override, ""
try:
if Path(override).is_file():
return override, ""
except OSError as exc:
return None, f"cannot access configured interpreter: {exc}"
return None, f"{env_key}={override!r} does not exist"
tried: list[str] = []
for candidate in _DEFAULT_CANDIDATES.get(route, ()):
path = Path(candidate) if Path(candidate).is_absolute() else root / candidate
tried.append(str(path))
if path.exists():
return str(path), ""
try:
if path.is_file():
return str(path), ""
except OSError:
continue
return None, f"no interpreter found; tried {tried} and {env_key} is unset"
@@ -226,9 +237,7 @@ def resolve(route: str, *, root: Path | None = None) -> Route:
root = root or repo_root()
is_windows = route in (ROUTE_VITISAI, ROUTE_DML, ROUTE_WIN_CUDA)
worker_key = f"{WORKER_ENV_PREFIX}{route.upper().replace('-', '_')}"
worker = os.environ.get(worker_key) or str(
Path(__file__).resolve().parent / _WORKER_FOR_ROUTE[route]
)
worker = os.environ.get(worker_key) or str(Path(__file__).resolve().parent / _WORKER_FOR_ROUTE[route])
if is_windows and not is_wsl() and os.name != "nt":
return Route(
+12 -180
View File
@@ -1,185 +1,17 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The wire between the engine and a stage that runs in another process.
"""Compatibility import for the extracted host-copy wire implementation.
Deliberately tiny, and deliberately **stdlib + numpy only**. The worker on the
other end of this socket may be a native-Windows interpreter that has no torch,
no vllm and no vllm_omni on its path -- ``C:\\Users\\zhout\\npu-ep`` is exactly
that: Python 3.12, onnxruntime, onnx, numpy, nothing else. Anything imported
here has to be installable there, so this module imports neither.
**Why a socket at all.** Two devices on this laptop cannot be reached from the
engine's own interpreter, and for different reasons:
* the **XDNA2 NPU** is an MCDM device. WSL2's GPU-PV forwards WDDM display
adapters only, so there is no ``/dev/accel`` inside WSL and no amount of
installing changes that. VitisAI is a Windows DLL.
* the **Radeon 890M** is reachable from WSL through DirectML, but
``torch-directml`` pins torch 2.4.1 and Omni runs 2.13.
So out-of-process is forced, not preferred. One protocol covers both, plus the
native-Windows DirectML route, which needs its own venv anyway because
``onnxruntime-directml`` is pinned at 1.24.4 and cannot load the VitisAI EP
(that needs ORT >= 1.25) -- the two AMD devices cannot share an interpreter.
**Framing.** ``[4-byte big-endian header length][JSON header][raw tensor bytes]``.
Tensors are described in the header (name, dtype, shape, nbytes) and their
buffers are concatenated after it in the order listed, so a receive is two
reads and one ``frombuffer`` per tensor -- no per-element work and no pickle.
Measured on this machine, WSL <-> native Windows: 11 us half round trip,
330 MB/s. That is ample for a stage boundary (a 2 s Code2Wav chunk is ~192 KB,
about 0.6 ms) and nowhere near enough for per-layer traffic inside a decode
loop, which is the arithmetic behind "coarse stages only".
``pickle`` is not used anywhere in here on purpose: the header is JSON, the
payload is raw buffers, so a hostile or merely broken peer cannot execute code
by replying.
Standalone legacy worker scripts load this file directly. The source-tree
fallback loads only the portable package, never the vllm_omni initializer.
"""
from __future__ import annotations
import sys
from pathlib import Path
import json
import socket
import struct
from typing import Any
import numpy as np
# Ops the client sends.
OP_LOAD = "load"
"""Open a session on a graph. Replies with the placement report."""
OP_RUN = "run"
"""One forward. Inputs in, outputs out."""
OP_STATS = "stats"
"""Counters and RSS. The worker's memory is invisible to the engine's sampler
when the worker is a Windows process, so it has to report its own."""
OP_CLOSE = "close"
# Ops the worker sends.
OP_HELLO = "hello"
"""First message after connect-back: interpreter, versions, available EPs."""
OP_OK = "ok"
OP_ERR = "err"
MAX_HEADER_BYTES = 1 << 20
"""A header is metadata; a megabyte of it is a bug or an attack, not a graph."""
MAX_PAYLOAD_BYTES = 2 << 30
"""Refuse rather than allocate on a corrupt length. Two gibibytes is far above
any legitimate stage payload here -- the largest is a vision tower's patch
tensor, single-digit MB."""
class ProtocolError(RuntimeError):
"""The peer sent something this protocol cannot represent."""
class WorkerError(RuntimeError):
"""The peer ran and failed. Carries the remote traceback when there is one."""
def __init__(self, message: str, *, remote_traceback: str = "", code: str = "") -> None:
# The remote traceback goes in the message, not only in an attribute:
# a bare "AssertionError:" surfacing here with the actual stack one
# attribute away is a debugging loop nobody should have to discover.
detail = f"{message}\n--- worker traceback ---\n{remote_traceback}" if remote_traceback else message
super().__init__(detail)
self.message = message
self.remote_traceback = remote_traceback
self.code = code
def _recv_exactly(sock: socket.socket, n: int) -> bytes:
"""Read exactly ``n`` bytes or raise.
``recv`` is allowed to return short reads on a stream socket and does, once
payloads get past a page or two; a partial read here would be silently
misparsed as the next tensor.
"""
if n == 0:
return b""
chunks: list[bytes] = []
remaining = n
while remaining:
chunk = sock.recv(min(remaining, 1 << 20))
if not chunk:
raise ProtocolError(f"peer closed with {remaining} of {n} bytes outstanding")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks) if len(chunks) > 1 else chunks[0]
def send_message(
sock: socket.socket,
op: str,
body: dict[str, Any] | None = None,
tensors: dict[str, np.ndarray] | None = None,
) -> None:
"""Send one message. ``tensors`` are sent as raw buffers after the header."""
tensors = tensors or {}
described: list[dict[str, Any]] = []
buffers: list[memoryview] = []
for name, array in tensors.items():
array = np.asarray(array)
# Record the shape *before* making it contiguous: ``ascontiguousarray``
# promotes a 0-d array to shape (1,), which would silently turn a
# scalar graph input into a length-1 vector on the far side.
shape = list(array.shape)
contiguous = np.ascontiguousarray(array)
described.append(
{
"name": name,
# ``str(dtype)`` loses endianness for multibyte types; ``.str``
# keeps the '<'/'>' so a big-endian peer would still decode.
"dtype": contiguous.dtype.str,
"shape": shape,
"nbytes": int(contiguous.nbytes),
}
)
# ``reshape(-1)`` rather than a direct cast: ``memoryview.cast`` refuses
# a 0-dimensional source.
buffers.append(memoryview(contiguous.reshape(-1)).cast("B"))
header = json.dumps({"op": op, "body": body or {}, "tensors": described}).encode("utf-8")
if len(header) > MAX_HEADER_BYTES:
raise ProtocolError(f"header is {len(header)} bytes, over the {MAX_HEADER_BYTES} limit")
sock.sendall(struct.pack(">I", len(header)))
sock.sendall(header)
for buffer in buffers:
sock.sendall(buffer)
def recv_message(sock: socket.socket) -> tuple[str, dict[str, Any], dict[str, np.ndarray]]:
"""Receive one message as ``(op, body, tensors)``.
Raises :class:`WorkerError` on an ``err`` reply so callers do not each have
to remember to check; every other op is returned as-is.
"""
(header_len,) = struct.unpack(">I", _recv_exactly(sock, 4))
if header_len > MAX_HEADER_BYTES:
raise ProtocolError(f"peer announced a {header_len}-byte header, over the limit")
try:
header = json.loads(_recv_exactly(sock, header_len).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ProtocolError(f"header is not JSON: {exc}") from exc
total = sum(int(t["nbytes"]) for t in header.get("tensors", ()))
if total > MAX_PAYLOAD_BYTES:
raise ProtocolError(f"peer announced a {total}-byte payload, over the limit")
tensors: dict[str, np.ndarray] = {}
for spec in header.get("tensors", ()):
raw = _recv_exactly(sock, int(spec["nbytes"]))
array = np.frombuffer(raw, dtype=np.dtype(spec["dtype"]))
# ``frombuffer`` is read-only and aliases ``raw``; a consumer that
# writes in place (ORT's IOBinding does) needs its own buffer.
tensors[spec["name"]] = array.reshape(spec["shape"]).copy()
op = str(header.get("op", ""))
body = header.get("body") or {}
if op == OP_ERR:
raise WorkerError(
str(body.get("message", "worker failed with no message")),
remote_traceback=str(body.get("traceback", "")),
code=str(body.get("code", "")),
)
return op, body, tensors
try:
from omni_stage_contracts.wire import * # noqa: F403
except ModuleNotFoundError as exc:
if exc.name != "omni_stage_contracts":
raise
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
from omni_stage_contracts.wire import * # noqa: F403
+27 -6
View File
@@ -164,12 +164,37 @@ def _provider_counts(profile_path: str) -> dict[str, int]:
return counts
def _verify_artifact_members(graph: str, files: list[str]) -> None:
"""Reject ONNX external data omitted from the controller's verified bundle."""
import onnx
allowed = {Path(name).resolve() for name in files}
graph_path = Path(graph).resolve()
if graph_path not in allowed:
raise ValueError("graph is not a verified artifact member")
pending = [onnx.load_model(graph, load_external_data=False)]
while pending:
message = pending.pop()
if message.DESCRIPTOR.full_name == "onnx.TensorProto":
locations = [entry.value for entry in message.external_data if entry.key == "location"]
if message.data_location == onnx.TensorProto.EXTERNAL and not locations:
raise ValueError("external tensor has no payload location")
for location in locations:
if (graph_path.parent / location).resolve() not in allowed:
raise ValueError(f"external tensor payload is absent from verified manifest: {location}")
for descriptor, value in message.ListFields():
if descriptor.type == descriptor.TYPE_MESSAGE:
pending.extend(value if descriptor.is_repeated else [value])
def _make_session(body: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
"""Build the ORT session for the requested execution provider."""
import onnxruntime as ort
ep = str(body.get("ep", "cpu"))
graph = str(body["graph_path"])
if "artifact_files" in body:
_verify_artifact_members(graph, body["artifact_files"])
info: dict[str, Any] = {"ep": ep, "onnxruntime": ort.__version__}
options = ort.SessionOptions()
@@ -241,12 +266,8 @@ def _load(state: _Session, body: dict[str, Any], tensors: dict[str, np.ndarray])
state.session = session
state.output_names = [o.name for o in session.get_outputs()]
info["inputs"] = [
{"name": i.name, "type": i.type, "shape": list(i.shape)} for i in session.get_inputs()
]
info["outputs"] = [
{"name": o.name, "type": o.type, "shape": list(o.shape)} for o in session.get_outputs()
]
info["inputs"] = [{"name": i.name, "type": i.type, "shape": list(i.shape)} for i in session.get_inputs()]
info["outputs"] = [{"name": o.name, "type": o.type, "shape": list(o.shape)} for o in session.get_outputs()]
# The placement evidence. One profiled run, because ORT only writes node
# assignments once nodes have actually executed -- a session that has never
+15 -80
View File
@@ -33,10 +33,7 @@ nothing that would let a caller reach inside it.
from __future__ import annotations
import asyncio
import time
import uuid
from dataclasses import asdict, dataclass, field
from typing import Any
STATE_LAYOUT_VERSION = 1
"""Bumped when the meaning of a handle's state changes. A handle from a
@@ -45,79 +42,10 @@ to treat state as non-migratable until a specific backend pair has been
measured, and a version that silently matches would hide that."""
@dataclass(frozen=True)
class StateHandle:
"""A reference to session state the backend owns.
The engine never reads the state. It reads this, to decide whether a
request may continue a session or must start a new one.
"""
session_id: str
backend: str
artifact_id: str
"""Ties the state to the checkpoint that produced it. The same shapes from
a different quantization are not the same state."""
layout_version: int = STATE_LAYOUT_VERSION
epoch: int = 0
"""Incremented on every cancel. Events from a lower epoch are stale."""
replayable: bool = True
"""A text session can be rebuilt by re-submitting its committed prompt, so
recovery after a backend crash is possible at an output boundary."""
migratable: bool = False
"""Never true in M0. Moving KV between backends needs a measured layout
conversion for the specific pair; assuming it is the failure the proposal
warns about."""
created_unix: float = field(default_factory=time.time)
def next_epoch(self) -> StateHandle:
from dataclasses import replace
return replace(self, epoch=self.epoch + 1)
def accepts(self, other: StateHandle) -> bool:
"""Whether ``other`` may continue this session's state."""
return (
other.session_id == self.session_id
and other.artifact_id == self.artifact_id
and other.layout_version == self.layout_version
and other.epoch == self.epoch
)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
from omni_stage_contracts.legacy import ChunkEvent, StateHandle # noqa: E402, F401
@dataclass(frozen=True)
class ChunkEvent:
"""One unit of output, with everything needed to order and retire it."""
request_id: str
stage_id: int
seq: int
"""Monotonic within (request, epoch). Gaps mean loss; repeats mean a bug."""
epoch: int
kind: str
"""``token`` | ``done`` | ``error`` | ``cancelled``."""
payload: Any
"""For ``token``: the incremental text and token ids. Never the whole
output so far -- a cumulative payload makes a dropped chunk invisible."""
started_unix: float
emitted_unix: float
final: bool = False
error: str | None = None
input_watermark: int = 0
"""How much of the input the producer had consumed when this was emitted.
One number in M0 (prompt tokens); it is what a duplex turn will align on."""
release_token: str = ""
"""Returned by the consumer to give credit back. Empty when the event
carries no resource the producer is holding on its behalf."""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class StreamClosed(RuntimeError):
class StreamClosed(RuntimeError): # noqa: N818 - retained public API
"""Raised on a stream that has been closed while a consumer waited."""
@@ -157,7 +85,12 @@ class BoundedEventStream:
return len(payload.encode("utf-8"))
if isinstance(payload, dict):
return sum(
len(str(k)) + (len(v.encode("utf-8")) if isinstance(v, str) else 8 * (len(v) if isinstance(v, (list, tuple)) else 1))
len(str(k))
+ (
len(v.encode("utf-8"))
if isinstance(v, str)
else 8 * (len(v) if isinstance(v, (list, tuple)) else 1)
)
for k, v in payload.items()
)
return 64
@@ -172,11 +105,13 @@ class BoundedEventStream:
size = self._sizeof(event)
async with self._credit:
await self._credit.wait_for(
lambda: self._closed
or (self._chunks < self.max_chunks and self._bytes + size <= self.max_bytes)
# A single event larger than the whole byte bound must still go
# through once the queue is empty, or it deadlocks forever.
or (self._chunks == 0 and size > self.max_bytes)
lambda: (
self._closed
or (self._chunks < self.max_chunks and self._bytes + size <= self.max_bytes)
# A single event larger than the whole byte bound must still go
# through once the queue is empty, or it deadlocks forever.
or (self._chunks == 0 and size > self.max_bytes)
)
)
if self._closed:
raise StreamClosed("stream closed while producing")
+54
View File
@@ -0,0 +1,54 @@
# SPDX-License-Identifier: Apache-2.0
"""Map discovery facts into physical constraints, without guessing performance."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict
from omni_stage_contracts import DeviceDescriptor
def describe_topology(
devices,
*,
machine_id: str,
domain_id: str,
physical_ids: Mapping[str, str] | None = None,
guest_pool: str | None = None,
) -> list[DeviceDescriptor]:
"""Normalize legacy discovery; caller reconciles cross-domain identities.
Physical IDs must be shared across routes when discovery proves they refer
to one GPU. Unknown bandwidth/power relationships stay empty. WSL quota is
an additional constraint on host allocations, never another RAM capacity.
"""
if not machine_id or not domain_id:
raise ValueError("machine and domain IDs must be explicit")
physical_ids = physical_ids or {}
result = []
for device in devices:
extra = device.extra or {}
physical = physical_ids.get(device.device_id, device.device_id)
kind = {"gpu_integrated": "gpu", "gpu_discrete": "gpu"}.get(device.kind, device.kind)
integrated = device.kind == "gpu_integrated"
integration = "integrated" if integrated else "discrete" if device.kind == "gpu_discrete" else "unknown"
pool = f"{machine_id}:ram" if device.memory_pool == "host_ram" else f"{machine_id}:vram:{physical}"
pools = (pool,) + ((guest_pool,) if guest_pool and device.memory_pool == "host_ram" else ())
result.append(
DeviceDescriptor(
device_id=f"{machine_id}:{physical}",
kind=kind,
domain_id=domain_id,
memory_pool_ids=pools,
integration=extra.get("integration", integration),
package_id=extra.get("package_id"),
bandwidth_group_ids=tuple(extra.get("bandwidth_group_ids", ())),
power_domain_ids=tuple(extra.get("power_domain_ids", ())),
)
)
return result
def topology_report(devices, **kwargs) -> list[dict]:
return [asdict(device) for device in describe_topology(devices, **kwargs)]
+37 -3
View File
@@ -244,6 +244,11 @@ class AsyncOmniEngine:
)
self.num_stages = len(self.stage_configs)
from vllm_omni.engine.resource_ledger import GraphRequestGate
self._graph_request_gate = (
GraphRequestGate() if any(c.stage_type == "graph" for c in self.stage_configs) else None
)
stage0_args = getattr(self.stage_configs[0], "engine_args", None) if self.num_stages > 0 else None
self.async_chunk = bool(getattr(stage0_args, "async_chunk", False))
self.stage_pools: list[StagePool] = []
@@ -454,6 +459,7 @@ class AsyncOmniEngine:
enable_duplex_control=self._duplex_control_enabled,
duplex_session_config=self.duplex_session_config,
)
orchestrator.graph_request_gate = self._graph_request_gate
if not startup_future.done():
startup_future.set_result(asyncio.get_running_loop())
await orchestrator.run()
@@ -757,7 +763,7 @@ class AsyncOmniEngine:
stage_type = self.stage_metadata[0].stage_type
output_prompt_text: Any = None
_preprocess_ms = 0.0
if stage_type != "diffusion" and not isinstance(prompt, EngineCoreRequest):
if stage_type not in ("diffusion", "graph") and not isinstance(prompt, EngineCoreRequest):
# Stage transforms and downstream stages must share the same
# request identity, including when the transform replaces the
# prompt object.
@@ -1145,6 +1151,8 @@ class AsyncOmniEngine:
a queue + coroutine-switch round-trip. The Orchestrator receives a
ready-to-submit OmniEngineCoreRequest.
"""
graph_gate = getattr(self, "_graph_request_gate", None)
graph_ticket = graph_gate.acquire(request_id) if graph_gate is not None else None
try:
msg = self._build_add_request_message(
request_id=request_id,
@@ -1163,6 +1171,8 @@ class AsyncOmniEngine:
resumable=resumable,
)
except BaseException:
if graph_gate is not None:
graph_gate.release(graph_ticket)
if isinstance(prompt, dict):
for artifact_dir in prompt.pop(REQUEST_ARTIFACT_DIRS_KEY, None) or ():
if isinstance(artifact_dir, str):
@@ -1186,6 +1196,8 @@ class AsyncOmniEngine:
self.request_queue.sync_q.put(msg)
except BaseException:
if graph_gate is not None:
graph_gate.release(graph_ticket)
for artifact_dir in msg.request_artifact_dirs or ():
shutil.rmtree(artifact_dir, ignore_errors=True)
raise
@@ -1554,7 +1566,7 @@ class AsyncOmniEngine:
def try_get_output(self, timeout: float = 0.001) -> EngineQueueMessage | None:
"""Read one output message from the Orchestrator output queue."""
try:
return self.output_queue.sync_q.get(timeout=timeout)
return self._attach_graph_release(self.output_queue.sync_q.get(timeout=timeout))
except queue.Empty:
if not self.is_alive():
raise RuntimeError("Orchestrator died unexpectedly. See logs above.")
@@ -1563,7 +1575,7 @@ class AsyncOmniEngine:
async def try_get_output_async(self) -> EngineQueueMessage | None:
"""Async read from the Orchestrator output queue."""
try:
return self.output_queue.sync_q.get_nowait()
return self._attach_graph_release(self.output_queue.sync_q.get_nowait())
except queue.Empty:
if not self.is_alive():
raise RuntimeError("Orchestrator died unexpectedly. See logs above.")
@@ -1603,6 +1615,24 @@ class AsyncOmniEngine:
msg = await loop.run_in_executor(executor, _drain_get)
if msg is None and not self.is_alive():
raise RuntimeError("Orchestrator died unexpectedly. See logs above.")
return self._attach_graph_release(msg)
def _attach_graph_release(self, msg):
from vllm_omni.outputs import OmniRequestOutput
gate = getattr(self, "_graph_request_gate", None)
if gate is not None and isinstance(msg, OutputMessage) and msg.finished:
ticket = gate.current(msg.request_id)
output = msg.engine_outputs
if isinstance(output, OmniRequestOutput):
previous = output._stage_release
def release():
if previous is not None:
previous()
gate.release(ticket)
output._stage_release = release
return msg
def get_stage_metadata(self, stage_id: int) -> StageRuntimeInfo:
@@ -1673,6 +1703,10 @@ class AsyncOmniEngine:
raise
if not result_msg.success:
raise RuntimeError(result_msg.error or "abort failed")
graph_gate = getattr(self, "_graph_request_gate", None)
if graph_gate is not None:
for request_id in request_ids:
graph_gate.release(graph_gate.current(request_id))
return list(result_msg.abort_outputs or [])
def submit_interaction(
+10
View File
@@ -0,0 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
"""Factories for non-vLLM stages owned by the existing StageRuntime."""
def create_graph_client(metadata, config, ledger, reservation):
from .graph import GraphStageClient
if config.get("backend", {}).get("name") != "external.graph.v1":
raise ValueError("graph stage requires backend.name=external.graph.v1")
return GraphStageClient(metadata, config["backend"], ledger, reservation)
+219
View File
@@ -0,0 +1,219 @@
# SPDX-License-Identifier: Apache-2.0
"""One bounded, non-preemptible graph stage using the existing worker.
The runtime owns startup and capacity. This adapter owns one in-flight call,
including its output until acknowledged by the consumer. No token scheduler,
provider fallback, or automatic worker replay is introduced here.
"""
from __future__ import annotations
import asyncio
import dataclasses
import math
import uuid
from pathlib import Path
from typing import Any
import numpy as np
from omni_stage_contracts import ArtifactManifest, BufferRef, StageEvent, StageRequest
from vllm_omni.edge.local.external.client import ExternalWorker
from vllm_omni.engine.resource_ledger import ResourceUnavailable
from vllm_omni.engine.stage_client import StageClientBase
from vllm_omni.host.routes import resolve_route
from vllm_omni.outputs import OmniRequestOutput
class GraphStageClient(StageClientBase):
def __init__(self, metadata, config, ledger, reservation) -> None:
for name, value in vars(metadata).items():
setattr(self, name, value)
self.stage_type = "graph"
self._ledger, self._reservation = ledger, reservation
self._generation = uuid.uuid4().hex
self._config = dict(config)
self._worker = None
self._closed = False
self._active: str | None = None
self._task: asyncio.Task | None = None
self._output: OmniRequestOutput | None = None
self._epoch = 0
self._max_io_bytes = int(config.get("max_io_bytes", 8 << 20))
if self._max_io_bytes <= 0 or self._max_io_bytes > min(reservation.demands.values()):
raise ValueError("graph I/O limit must be positive and fit the stage reservation")
try:
manifest_path = Path(config["manifest"]).resolve()
manifest = ArtifactManifest.read(manifest_path)
graph_name = manifest.metadata["graph_file"]
examples_name = manifest.metadata["example_inputs_file"]
if graph_name not in manifest.files or examples_name not in manifest.files:
raise ValueError("graph and placement examples must be hashed artifact payloads")
if sum((manifest_path.parent / n).stat().st_size for n in manifest.files) > min(
reservation.demands.values()
):
raise ResourceUnavailable("artifact files exceed stage memory reservation")
route = resolve_route(config["route"])
self._worker = ExternalWorker(
route, start_timeout_s=float(config.get("start_timeout_s", 120)), max_payload_bytes=self._max_io_bytes
)
self._worker.start()
with np.load(manifest_path.parent / examples_name, allow_pickle=False) as example:
inputs = {name: example[name] for name in example.files}
self._describe(inputs)
self.load_report = self._worker.load(
manifest_path.parent / graph_name,
example_inputs=inputs,
ep_dir=config.get("ep_dir"),
intra_op_num_threads=config.get("intra_op_num_threads"),
profile_prefix=config.get("profile_prefix"),
artifact_files=[manifest_path.parent / name for name in manifest.files],
)
report = self.load_report
minimum = float(config.get("min_fraction_on_target", 1.0))
if not 0 < minimum <= 1:
raise ValueError("placement threshold must be in (0, 1]")
if (
report.requested_provider_missing
or report.fraction_on_target is None
or not math.isfinite(report.fraction_on_target)
or not 0 < report.fraction_on_target <= 1
or report.target_nodes <= 0
or report.fraction_on_target < minimum
):
raise RuntimeError(f"REFUSE_EP_PLACEMENT: {report.summary()}")
allowed = set(config.get("allowed_providers", report.session_providers if route.ep == "cpu" else ()))
if not allowed or set(report.node_counts) - allowed:
raise RuntimeError("REFUSE_EP_PLACEMENT: declare all permitted execution providers")
if report.rss_bytes > min(reservation.demands.values()):
raise ResourceUnavailable("loaded worker RSS exceeds stage memory reservation")
self._input_schema = {name: (value.dtype.str, value.shape) for name, value in inputs.items()}
from omni_stage_contracts.types import file_digest
self.execution_plan = {
"backend": "external.graph.v1",
"stage_id": self.stage_id,
"worker_generation": self._generation,
"artifact_sha256": file_digest(manifest_path),
"component": manifest.component,
"route": route.to_dict(),
"reserved_bytes": dict(reservation.demands),
"max_io_bytes": self._max_io_bytes,
"placement": report.to_dict(),
"worker": {k: v for k, v in self._worker.hello.items() if k != "token"},
"evidence": "B",
"stateful": False,
}
except BaseException:
self.shutdown()
raise
def _describe(self, tensors: dict[str, np.ndarray]) -> tuple[BufferRef, ...]:
refs = tuple(
BufferRef(
name, str(self.stage_id), self._generation, array.dtype.name, tuple(array.shape), int(array.nbytes)
)
for name, array in tensors.items()
)
if sum(r.nbytes for r in refs) > self._max_io_bytes:
raise ResourceUnavailable("graph payload exceeds admitted I/O bound")
return refs
async def add_request_async(self, request_id: str, prompt: Any, params: Any = None) -> None:
self.check_health()
if self._active is not None:
raise ResourceUnavailable("graph stage has an unacknowledged request; capacity is one")
if not isinstance(prompt, dict) or not isinstance(prompt.get("tensors"), dict):
raise ValueError("graph prompt must contain a tensors mapping supplied by its model adapter")
inputs = {name: np.asarray(value) for name, value in prompt["tensors"].items()}
refs = self._describe(inputs)
if {name: (value.dtype.str, value.shape) for name, value in inputs.items()} != self._input_schema:
raise ValueError("input names/dtypes/shapes differ from the placement-verified artifact bucket")
# Copy at submission: application mutation cannot change an admitted call.
inputs = {name: value.copy() for name, value in inputs.items()}
self._epoch += 1
epoch = self._epoch
request = StageRequest(request_id, self.stage_id, epoch, self._generation, inputs=refs)
self._active = request_id
self._task = asyncio.create_task(self._run(request, inputs), name=f"graph-{self.stage_id}-{request_id}")
async def _run(self, request: StageRequest, inputs: dict[str, np.ndarray]) -> None:
try:
outputs, timing = await asyncio.to_thread(self._worker.run, inputs)
refs = self._describe(outputs)
if sum(x.nbytes for x in outputs.values()) + sum(x.nbytes for x in inputs.values()) > self._max_io_bytes:
raise ResourceUnavailable("combined graph input/output exceeds admitted I/O bound")
event = StageEvent(
request.request_id, self.stage_id, request.epoch, 1, "tensor", self._generation, refs, terminal=True
)
output = OmniRequestOutput(
request_id=request.request_id,
stage_id=self.stage_id,
final_output_type=self.final_output_type or "latent",
_custom_output={"tensors": outputs, "stage_event": dataclasses.asdict(event)},
metrics={"graph_timing": timing.to_dict()},
)
except asyncio.CancelledError:
raise
except Exception as exc:
output = OmniRequestOutput.from_error(request.request_id, str(exc))
output.stage_id = self.stage_id
if not self._closed and self._epoch == request.epoch:
loop = asyncio.get_running_loop()
output._stage_release = lambda: loop.call_soon_threadsafe(
self.acknowledge, request.request_id, request.epoch, request.worker_generation
)
self._output = output
def get_graph_output_nowait(self):
# Delivery is separate from release: the slot stays charged until ACK.
output, self._output = self._output, None
return output
def acknowledge(self, request_id: str, epoch: int, generation: str) -> None:
if (
self._active == request_id
and epoch == self._epoch
and generation == self._generation
and (self._task is None or self._task.done())
):
self._active = None
self._task = None
async def abort_requests_async(self, request_ids: list[str]) -> None:
if self._active not in request_ids:
return
self._epoch += 1
self._output = None
# A completed call needs no worker kill. Otherwise cancellation retires
# this single-session worker; the runtime will report it unavailable.
if self._task is not None and not self._task.done():
drained = await asyncio.to_thread(self._worker.terminate)
self._closed = True
done, _ = await asyncio.wait({self._task}, timeout=5)
self._ledger.release(self._reservation, drained=drained and bool(done) and not self._task.cancelled())
self._active = None
def check_health(self) -> None:
if self._closed:
from vllm.v1.engine.exceptions import EngineDeadError
raise EngineDeadError()
async def collective_rpc_async(self, method, timeout=None, args=(), kwargs=None):
raise NotImplementedError(f"graph backend does not implement collective RPC {method}")
def shutdown(self) -> None:
self._closed = True
self._output = None
drained = self._worker is None or self._worker.terminate()
task = self._task
if task is not None and not task.done():
# Process exit does not release Python input/transport references.
# Keep them charged until the call coroutine actually unwinds.
self._ledger.release(self._reservation, drained=False)
task.add_done_callback(
lambda done: self._ledger.release(self._reservation, drained=drained and not done.cancelled())
)
else:
self._ledger.release(self._reservation, drained=drained)
+34 -5
View File
@@ -1191,8 +1191,12 @@ class Orchestrator:
# Shared catch so a dead replica on either poll path is
# evicted rather than tearing down every stage (#4285).
try:
if pool.stage_type == "diffusion":
diffusion_output = pool.poll_diffusion_output(replica_id)
if pool.stage_type in ("diffusion", "graph"):
diffusion_output = (
pool.poll_graph_output(replica_id)
if pool.stage_type == "graph"
else pool.poll_diffusion_output(replica_id)
)
if diffusion_output is None:
continue
@@ -1282,7 +1286,11 @@ class Orchestrator:
got = False
for replica_id in pool.available_replica_ids():
try:
output = pool.poll_diffusion_output(replica_id)
output = (
pool.poll_graph_output(replica_id)
if pool.stage_type == "graph"
else pool.poll_diffusion_output(replica_id)
)
except EngineDeadError as e:
await ready_q.put(("error", stage_id, replica_id, e))
continue
@@ -1317,7 +1325,7 @@ class Orchestrator:
# No live replica yet; nothing to attach to. A later
# reconcile picks the stage up once one registers.
continue
if stage_type == "diffusion":
if stage_type in ("diffusion", "graph"):
live_pools.add(stage_id)
existing_poller = pollers.get(stage_id)
if existing_poller is None or existing_poller.done():
@@ -1500,6 +1508,8 @@ class Orchestrator:
for output in outputs:
req_state = self.request_states.get(output.request_id)
if req_state is None:
if hasattr(output, "release_stage_buffers"):
output.release_stage_buffers()
logger.warning(
"[Orchestrator] Dropping output for unknown req %s at stage-%s (known reqs: %s)",
output.request_id,
@@ -1524,7 +1534,11 @@ class Orchestrator:
)
stage_metrics.pipeline_timings = dict(req_state.pipeline_timings)
await self._route_output(stage_id, replica_id, output, req_state, stage_metrics)
try:
await self._route_output(stage_id, replica_id, output, req_state, stage_metrics)
finally:
if pool.stage_type == "graph" and stage_id not in req_state.final_output_stage_ids:
output.release_stage_buffers()
async def _handle_stage_error(self, stage_id: int, output: Any) -> None:
"""Emit a frontend-visible error and clean up request state."""
@@ -1834,6 +1848,10 @@ class Orchestrator:
try:
if abort:
abort_outputs = await self._abort_request_ids(cleanup_ids)
graph_gate = getattr(self, "graph_request_gate", None)
if graph_gate is not None:
for request_id in cleanup_ids:
graph_gate.release(graph_gate.current(request_id))
self._release_request_bindings(cleanup_ids)
for request_id in cleanup_ids:
self._pd_kv_params.pop(request_id, None)
@@ -2513,6 +2531,17 @@ class Orchestrator:
requires_multimodal_data = getattr(next_client, "requires_multimodal_data", False)
_t_submit_start = _time.perf_counter()
if next_pool.stage_type == "graph":
if is_streaming_session:
raise ValueError("external graph requires a complete upstream payload")
transform = next_client.custom_process_input_func
if transform is None:
raise ValueError("graph dependency requires a model-owned custom_process_input_func")
graph_prompt = transform(source_outputs, req_state.prompt, requires_multimodal_data)
await next_pool.submit_initial(req_id, req_state, graph_prompt)
req_state.stage_submit_ts[next_logical] = _time.time()
return
if next_pool.stage_type == "diffusion":
# Gate: never dispatch with an incomplete CFG bundle. Checked
# non-destructively BEFORE popping — a pop-then-redefer would lose
+101
View File
@@ -0,0 +1,101 @@
# SPDX-License-Identifier: Apache-2.0
"""Atomic reservations shared by native and external stage startup.
Capacities are admission ceilings for this controller, captured before loads.
They are not fresh `available` samples: resident allocations remain charged,
so sampling available memory cannot subtract our own allocation a second time.
Refresh ceilings only with an explicit, reconciled controller budget.
"""
from __future__ import annotations
import threading
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
class ResourceUnavailable(RuntimeError): # noqa: N818 - public admission result, like PlanNotAdmitted
pass
@dataclass(frozen=True)
class Reservation:
owner: str
demands: Mapping[str, int]
class ResourceLedger:
def __init__(self, capacities: Mapping[str, int]) -> None:
if not capacities or any(type(n) is not int or n < 0 for n in capacities.values()):
raise ValueError("explicit nonnegative memory ceilings are required")
self.capacities = MappingProxyType(dict(capacities))
self._active: dict[str, Reservation] = {}
self._quarantined: set[str] = set()
self._lock = threading.RLock()
def reserve(self, owner: str, demands: Mapping[str, int]) -> Reservation:
with self._lock:
if not owner or owner in self._active:
raise ValueError(f"reservation owner missing or already active: {owner}")
if not demands or any(type(n) is not int or n < 0 for n in demands.values()):
raise ValueError("nonnegative memory demands are required")
for pool, count in demands.items():
if pool not in self.capacities:
raise ResourceUnavailable(f"unknown memory pool: {pool}")
used = sum(r.demands.get(pool, 0) for r in self._active.values())
if used + count > self.capacities[pool]:
raise ResourceUnavailable(f"{pool}: {used} reserved + {count} requested > {self.capacities[pool]}")
reservation = Reservation(owner, MappingProxyType(dict(demands)))
self._active[owner] = reservation
return reservation
def release(self, reservation: Reservation, *, drained: bool) -> bool:
with self._lock:
if self._active.get(reservation.owner) is not reservation:
return False # stale token must never release a newer allocation
if not drained:
self._quarantined.add(reservation.owner)
return False
del self._active[reservation.owner]
self._quarantined.discard(reservation.owner)
return True
def snapshot(self) -> dict:
with self._lock:
return {
"capacities": dict(self.capacities),
"reserved": {p: sum(r.demands.get(p, 0) for r in self._active.values()) for p in self.capacities},
"owners": list(self._active),
"quarantined": sorted(self._quarantined),
}
class GraphRequestGate:
"""One admitted pipeline request, held through the final consumer ACK.
Stage reservations already include its payload allowance. Rejecting excess
work before the ingress queue keeps slow consumers from accumulating inputs
or error outputs, and leaves the existing control queue available to abort.
"""
def __init__(self):
self._lock = threading.Lock()
self._active = None
def acquire(self, request_id):
with self._lock:
if self._active is not None:
raise ResourceUnavailable("graph pipeline capacity is one; consume or cancel the active request first")
ticket = (request_id, object())
self._active = ticket
return ticket
def current(self, request_id):
with self._lock:
return self._active if self._active is not None and self._active[0] == request_id else None
def release(self, ticket):
with self._lock:
if ticket is not None and self._active is ticket:
self._active = None
+8
View File
@@ -71,6 +71,14 @@ class StagePoolClient(StageClient, Protocol):
) -> Any: ...
class StagePoolGraphClient(StagePoolClient, Protocol):
async def add_request_async(self, request_id: str, prompt: Any, params: Any = None) -> None: ...
def get_graph_output_nowait(self) -> Any | None: ...
def acknowledge(self, request_id: str, epoch: int, generation: str) -> None: ...
class StagePoolLLMClient(StagePoolClient, Protocol):
"""Pool-facing API for LLM-style stages."""
+24 -13
View File
@@ -12,12 +12,6 @@ out of StageEngineCoreClient into reusable functions.
from __future__ import annotations
import copy
# [edge-infer W1] fcntl does not exist on Windows; this module is in the
# import closure of AsyncOmni, so the bare import makes the engine
# unimportable before anything can be configured. The locking logic is
# sound and torch-free -- only the primitive changes. The shim keeps the
# BlockingIOError contract this file's stale-lock path depends on.
from vllm_omni._filelock_compat import flock_exclusive_nb, funlock
import importlib
import json
import multiprocessing as mp
@@ -41,6 +35,12 @@ from vllm.usage.usage_lib import UsageContext
from vllm.v1.engine.input_processor import InputProcessor
from vllm.v1.executor import Executor
# [edge-infer W1] fcntl does not exist on Windows; this module is in the
# import closure of AsyncOmni, so the bare import makes the engine
# unimportable before anything can be configured. The locking logic is
# sound and torch-free -- only the primitive changes. The shim keeps the
# BlockingIOError contract this file's stale-lock path depends on.
from vllm_omni._filelock_compat import flock_exclusive_nb, funlock
from vllm_omni.config.omni_config import (
_CACHE_STAGE_ENGINE_FIELD_MAP,
_DIFFUSION_CACHE_STAGE_ENGINE_FIELD_MAP,
@@ -549,7 +549,7 @@ class StageMetadata:
"""Lightweight stage attributes extracted from stage_config."""
stage_id: int
stage_type: Literal["llm", "diffusion"]
stage_type: Literal["llm", "diffusion", "graph"]
engine_output_type: str | None
is_comprehension: bool
requires_multimodal_data: bool
@@ -599,7 +599,7 @@ def extract_legacy_stage_metadata(stage_config: Any) -> StageMetadata:
engine-argument and stage-init consumers together.
"""
stage_id: int = stage_config.stage_id
stage_type: Literal["llm", "diffusion"] = _get_attr_or_item(stage_config, "stage_type", "llm")
stage_type: Literal["llm", "diffusion", "graph"] = _get_attr_or_item(stage_config, "stage_type", "llm")
engine_args = stage_config.engine_args
_apply_rocm_attention_backend(engine_args, stage_type)
@@ -711,8 +711,11 @@ def extract_stage_metadata_from_omni_stage_config(
layout, engine-argument, remote-diffusion, and platform setup paths still
require the legacy StageConfig/OmegaConf shape.
"""
stage_type: Literal["llm", "diffusion"] = "diffusion" if stage_config.stage_type == StageType.DIFFUSION else "llm"
sampling_params_cls = SamplingParams if stage_type == "llm" else OmniDiffusionSamplingParams
stage_type: Literal["llm", "diffusion", "graph"] = {
StageType.DIFFUSION: "diffusion",
StageType.GRAPH: "graph",
}.get(stage_config.stage_type, "llm")
sampling_params_cls = OmniDiffusionSamplingParams if stage_type == "diffusion" else SamplingParams
sampling_params: OmniSamplingParams = sampling_params_cls(
**(stage_config.model_config.default_sampling_params or {})
)
@@ -738,7 +741,7 @@ def extract_stage_metadata_from_omni_stage_config(
return StageMetadata(
stage_id=stage_config.stage_id,
stage_type="llm",
stage_type=stage_type,
engine_output_type=stage_config.engine_output_type,
is_comprehension=stage_config.is_comprehension,
requires_multimodal_data=stage_config.requires_multimodal_data,
@@ -1574,7 +1577,7 @@ def build_stage0_input_processor(stage_vllm_config: Any) -> InputProcessor:
return InputProcessor(vllm_config=stage_vllm_config, renderer=renderer)
def device_init_lock_path(device_id: int, lock_dir: str = "/tmp") -> str:
def device_init_lock_path(device_id: int, lock_dir: str | None = None) -> str:
"""Return the per-physical-device initialization lock file path.
Shared by the orchestrator-side ``acquire_device_locks`` (legacy full-init
@@ -1585,7 +1588,9 @@ def device_init_lock_path(device_id: int, lock_dir: str = "/tmp") -> str:
unlink this path: two holders of the same pathname on different inodes do not
conflict. The PID written into the file is diagnostic only.
"""
return os.path.join(lock_dir, f"vllm_omni_device_{device_id}_init.lock")
from vllm_omni.host.paths import device_lock_directory
return os.path.join(lock_dir or device_lock_directory(), f"vllm_omni_device_{device_id}_init.lock")
def _open_existing_lock_file(lock_file: str) -> tuple[int, bool] | None:
@@ -1637,6 +1642,12 @@ def open_device_lock_file(lock_file: str) -> tuple[int, bool]:
very failure this avoids. ``link`` also fails cleanly if another process wins
the race, which keeps the "first creator wins" inode stable.
"""
if os.name == "nt":
# Windows byte-range locks require a writable handle. O_CREAT without
# O_TRUNC atomically opens/creates the stable file; there is no fchmod
# or POSIX cross-user mode publication on this host.
return os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600), True
existing = _open_existing_lock_file(lock_file)
if existing is not None:
return existing
+2 -1
View File
@@ -76,6 +76,7 @@ def _unlock(fd: int) -> None:
funlock(fd)
logger = init_logger(__name__)
# Default upper bound on how long a single phase will wait for its device locks
@@ -144,7 +145,7 @@ class DevicePhaseLock:
device_ids: list[int],
*,
timeout_s: float = _DEFAULT_LOCK_TIMEOUT_S,
lock_dir: str = "/tmp",
lock_dir: str | None = None,
) -> None:
self._device_ids = sorted(device_ids)
self._timeout_s = timeout_s
+16
View File
@@ -990,6 +990,14 @@ class StagePool:
if self.stage_type == "diffusion":
params = OmniDiffusionSamplingParams.from_params(params)
submit_kwargs = dict(submit_kwargs or {})
if self.stage_type == "graph":
replica_id = await self._pick_or_select(request_id, affinity_request_id=affinity_request_id)
try:
await self.clients[replica_id].add_request_async(request_id, request, params)
except BaseException:
self.release_binding(request_id)
raise
return replica_id
if self.stage_type == "diffusion":
if isinstance(request, list):
raise ValueError(
@@ -1050,6 +1058,8 @@ class StagePool:
prompt_text: Any = None,
) -> int:
"""Submit a streaming update to an already admitted request."""
if self.stage_type == "graph":
raise ValueError("external.graph.v1 accepts complete graphs, not streaming updates")
params = req_state.sampling_params_list[self.stage_id]
if self.stage_type == "diffusion":
params = OmniDiffusionSamplingParams.from_params(params)
@@ -1207,6 +1217,12 @@ class StagePool:
return None
return cast(StagePoolDiffusionClient, raw_client).get_diffusion_output_nowait()
def poll_graph_output(self, replica_id: int) -> Any | None:
if not self.is_replica_available(replica_id):
return None
client = self.clients[replica_id]
return None if client is None else client.get_graph_output_nowait()
# ---- Stage-local control plane ----
async def abort_requests(self, request_ids: list[str]) -> list[tuple[str, Any]]:
+91 -6
View File
@@ -155,6 +155,9 @@ class StageRuntime:
# device groups can initialize concurrently.
self._replica_launch_lock = threading.Lock()
self._init_visible_devices_baseline: str | None = None
self.resource_ledger = None
self._resource_reservations = {}
self._resource_started = set()
@staticmethod
def _client_addresses_from_zmq(addresses: Any) -> dict[str, str]:
@@ -221,18 +224,21 @@ class StageRuntime:
return collected
@staticmethod
def _shutdown_initialized_clients(clients: Sequence[StageClient]) -> None:
def _shutdown_initialized_clients(clients: Sequence[StageClient]) -> set[int]:
"""Best-effort shutdown for attached clients after init failure."""
drained = set()
for client in reversed(list(clients)):
if client is None:
continue
try:
client.shutdown()
drained.add(id(client))
except Exception as cleanup_error:
logger.warning(
"[StageRuntime] Failed to shutdown initialized client after init failure: %s",
cleanup_error,
)
return drained
def initialize(self) -> None:
"""Run the full stage initialization sequence."""
@@ -241,6 +247,7 @@ class StageRuntime:
plan.stage_idx: [None] * len(plan.replicas) for plan in stage_plans
}
try:
self._reserve_stage_resources(stage_plans)
self._before_initialize_stage_replicas(stage_plans)
initialized_clients = self._initialize_stage_replicas(stage_plans, self._stage_init_timeout)
initialized_clients_by_stage = initialized_clients
@@ -259,16 +266,80 @@ class StageRuntime:
"[StageRuntime] Stage initialization failed; shutting down %s initialized client(s)",
len(cleanup_clients),
)
self._shutdown_initialized_clients(cleanup_clients)
drained = self._shutdown_initialized_clients(cleanup_clients) or set()
if self.resource_ledger is not None:
for client in cleanup_clients:
if client.stage_type != "graph":
reservation = self._resource_reservations.get((client.stage_id, client.replica_id))
if reservation is not None:
self.resource_ledger.release(reservation, drained=id(client) in drained)
self._cleanup_after_initialize_failure()
self._release_unstarted_resources(initialized_clients_by_stage)
raise exc
def _reserve_stage_resources(self, stage_plans) -> None:
"""Explicit controller ceilings are mandatory for mixed graph plans.
Native stage budgets join the same transaction; absent estimates fail
closed rather than treating native allocations as free memory.
"""
from vllm_omni.engine.resource_ledger import ResourceLedger
replicas = [r for plan in stage_plans for r in plan.replicas]
if not any(r.metadata.stage_type == "graph" for r in replicas):
return
# One final consumer owns the ingress ticket. Fan-out or multiple final
# outputs would need reference-counted leases before any early ACK can
# release that ticket. Refuse those topologies in the v1 graph backend.
previous_stage = None
for index, plan in enumerate(stage_plans):
metadata = plan.replicas[0].metadata
expected_sources = [] if previous_stage is None else [previous_stage]
if (
list(metadata.engine_input_source or []) != expected_sources
or metadata.final_output != (index == len(stage_plans) - 1)
):
raise ValueError("graph v1 requires a linear chain with exactly one final output")
previous_stage = metadata.stage_id
budgets = [r.stage_cfg.engine_args.get("resource_budget") for r in replicas]
if any(not b for b in budgets):
raise ValueError("every stage in a graph pipeline must declare resource_budget capacities and demands")
capacities = dict(budgets[0]["capacities"])
if any(dict(b["capacities"]) != capacities for b in budgets):
raise ValueError("all stages must agree on physical memory ceilings")
self.resource_ledger = ResourceLedger(capacities)
try:
for replica, budget in zip(replicas, budgets):
key = (replica.metadata.stage_id, replica.replica_id)
self._resource_reservations[key] = self.resource_ledger.reserve(str(key), dict(budget["demands"]))
except BaseException:
for reservation in self._resource_reservations.values():
self.resource_ledger.release(reservation, drained=True)
self._resource_reservations.clear()
raise
def _release_unstarted_resources(self, initialized) -> None:
if self.resource_ledger is None:
return
for (stage_id, replica_id), reservation in self._resource_reservations.items():
clients = initialized.get(stage_id, [])
if replica_id >= len(clients) or clients[replica_id] is None:
# Graph factories release or quarantine their own failed load.
if reservation.owner not in self.resource_ledger.snapshot()["quarantined"]:
self.resource_ledger.release(
reservation, drained=(stage_id, replica_id) not in self._resource_started
)
def shutdown(self) -> None:
for pool in self.stage_pools:
for client in pool.clients:
if client is not None and hasattr(client, "shutdown"):
try:
client.shutdown()
if self.resource_ledger is not None and client.stage_type != "graph":
reservation = self._resource_reservations.get((client.stage_id, client.replica_id))
if reservation is not None:
self.resource_ledger.release(reservation, drained=True)
except Exception:
logger.warning("[StageRuntime] client shutdown failed", exc_info=True)
if self._stage_init_executor is not None:
@@ -374,7 +445,7 @@ class StageRuntime:
stage_vllm_config = None
executor_class = None
engine_args_dict = None
if base_metadata.stage_type != "diffusion":
if base_metadata.stage_type not in ("diffusion", "graph"):
# The stable adapter entry point still receives the same
# legacy stage object as replica planning. Its implementation
# switches only at the coordinated RFC #4021 cutover.
@@ -384,6 +455,7 @@ class StageRuntime:
stage_connector_spec=stage_connector_spec,
cli_tokenizer=self._tokenizer,
)
engine_args_dict.pop("resource_budget", None)
inject_omni_kv_connector_config(
engine_args_dict,
omni_kv_connector,
@@ -597,8 +669,7 @@ class StageRuntime:
if local and all(self._cpu_placed(r) for r in local):
import psutil
self._run_host_admission(stage_plans, check_host_admission,
lambda: int(psutil.virtual_memory().available))
self._run_host_admission(stage_plans, check_host_admission, lambda: int(psutil.virtual_memory().available))
return
def _resolve(replica: ReplicaInitPlan) -> list[int] | AdmissionExempt | None:
@@ -718,6 +789,8 @@ class StageRuntime:
def _init_group_key_override(self, replica: ReplicaInitPlan) -> str | None:
"""Key that bypasses device-overlap grouping, or ``None`` to use it."""
if replica.metadata.stage_type == "graph":
return "inline:graph"
if replica.launch_mode == "local" and replica.metadata.stage_type == "diffusion":
# Local diffusion process spawning must stay on the orchestrator
# thread. Keep all local diffusion replicas in one sequential group.
@@ -736,6 +809,18 @@ class StageRuntime:
plan: ReplicaInitPlan,
stage_init_timeout: int,
) -> StagePoolClient:
if plan.metadata.stage_type == "graph":
if type(self) is not StageRuntime or plan.launch_mode != "local":
raise ValueError("graph backends support one local device only")
from vllm_omni.engine.backends import create_graph_client
return create_graph_client(
plan.metadata,
plan.stage_cfg.engine_args,
self.resource_ledger,
self._resource_reservations[(plan.metadata.stage_id, plan.replica_id)],
)
self._resource_started.add((plan.metadata.stage_id, plan.replica_id))
if plan.launch_mode == "remote":
return self._initialize_remote_replica(plan, stage_init_timeout)
if plan.metadata.stage_type == "diffusion":
@@ -985,7 +1070,7 @@ class StageRuntime:
clients: list[StagePoolClient] = [client for client in replica_clients if client is not None]
stage_vllm_config = None
output_processor = None
if plan.replicas[0].metadata.stage_type != "diffusion":
if plan.replicas[0].metadata.stage_type not in ("diffusion", "graph"):
stage_vllm_config = plan.replicas[0].stage_vllm_config
if stage_vllm_config is None:
raise RuntimeError(f"Stage {plan.stage_id} is missing vllm_config")
+4 -1
View File
@@ -918,7 +918,10 @@ class AsyncOmni(EngineClient, OmniBase):
stage_id,
getattr(output_to_yield, "final_output_type", None),
)
yield output_to_yield
try:
yield output_to_yield
finally:
output_to_yield.release_stage_buffers()
# The Orchestrator sets "finished" when the final stage is done
if result.finished:
+5 -2
View File
@@ -40,7 +40,7 @@ class Omni(OmniBase):
sp = copy.deepcopy(params)
stage_meta = self.engine.get_stage_metadata(stage_id)
if (
stage_meta.stage_type != "diffusion"
stage_meta.stage_type not in ("diffusion", "graph")
and hasattr(sp, "output_kind")
and sp.output_kind != RequestOutputKind.DELTA
):
@@ -191,7 +191,10 @@ class Omni(OmniBase):
final_stage_id_for_e2e=req_final_stage_ids[req_id],
)
if output_to_yield is not None:
yield output_to_yield
try:
yield output_to_yield
finally:
output_to_yield.release_stage_buffers()
if isinstance(msg, OutputMessage) and msg.finished:
active_reqs.discard(req_id)
+8
View File
@@ -191,6 +191,10 @@ def shm_write_bytes(payload: bytes, name: str | None = None) -> dict[str, Any]:
Caller should close the segment; the receiver should unlink.
"""
if os.name == "nt":
from vllm_omni.host.shared_memory import owner
return owner.write(payload, name)
try:
shm = _shm.SharedMemory(create=True, size=len(payload), name=name)
except FileExistsError:
@@ -218,6 +222,10 @@ def shm_write_bytes(payload: bytes, name: str | None = None) -> dict[str, Any]:
def shm_read_bytes(meta: dict[str, Any]) -> bytes:
"""Read bytes from SharedMemory by meta {name,size} and cleanup."""
if os.name == "nt":
from vllm_omni.host.shared_memory import read_buffer
return read_buffer(meta)
shm = _shm.SharedMemory(name=meta["name"]) # type: ignore[index]
mv = memoryview(shm.buf)
data = bytes(mv[: meta["size"]])
+2
View File
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
"""Host mechanics, independent of accelerator and model selection."""
+12
View File
@@ -0,0 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
"""Host-local paths, independent of the checkout and model location."""
import os
import tempfile
def device_lock_directory() -> str:
# A rooted POSIX path resolves against the current UNC share on Windows.
# Opening /tmp from a WSL checkout can block inside the filesystem driver.
# All same-user controllers/workers use the same native temporary directory.
return tempfile.gettempdir() if os.name == "nt" else "/tmp"
+58
View File
@@ -0,0 +1,58 @@
# SPDX-License-Identifier: Apache-2.0
"""Portable worker ownership; termination reports whether resources drained."""
from __future__ import annotations
import os
import signal
import subprocess
def spawn_worker(argv: list[str], **kwargs) -> subprocess.Popen:
if os.name == "nt":
kwargs.setdefault("creationflags", subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP)
else:
kwargs.setdefault("start_new_session", True)
return subprocess.Popen(argv, **kwargs)
def terminate_worker(process: subprocess.Popen, timeout: float = 5.0) -> bool:
"""Kill descendants and parent, retaining the caller's resource charge on failure."""
import psutil
try:
parent = psutil.Process(process.pid)
children = parent.children(recursive=True)
except psutil.NoSuchProcess:
process.wait(timeout=timeout)
if os.name == "nt":
return False # absent parent cannot prove that descendants exited
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
return True
except OSError:
return False
return False # an orphaned group was found; retain its charge until verified
except psutil.Error:
return False
for child in reversed(children):
try:
child.kill()
except psutil.NoSuchProcess:
pass
except psutil.Error:
return False
try:
if os.name == "nt":
process.kill()
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=timeout)
_, alive = psutil.wait_procs(children, timeout=timeout)
return not alive
except (OSError, subprocess.TimeoutExpired):
return False
+32
View File
@@ -0,0 +1,32 @@
# SPDX-License-Identifier: Apache-2.0
"""Deployment-owned worker routes, independent of model and physical device."""
from __future__ import annotations
import os
from collections.abc import Mapping
from pathlib import Path
from vllm_omni.edge.local.external.launch import Route, is_wsl, resolve
def resolve_route(spec: str | Mapping) -> Route:
"""Names retain compatibility; explicit profiles have no machine-local defaults."""
if isinstance(spec, str):
return resolve(spec)
required = {"name", "interpreter", "worker", "ep", "os_domain"}
if not required <= set(spec) or set(spec) - required:
raise ValueError(f"explicit route requires exactly {sorted(required)}")
if spec["os_domain"] not in {"windows", "posix"}:
raise ValueError("route os_domain must be windows or posix")
windows = spec["os_domain"] == "windows"
interpreter = Path(os.path.expandvars(str(spec["interpreter"]))).expanduser()
worker = Path(os.path.expandvars(str(spec["worker"]))).expanduser()
reason = ""
if windows and os.name != "nt" and not is_wsl():
reason = "Windows route requires Windows or WSL interop on this machine"
elif not windows and os.name == "nt":
reason = "POSIX route needs a POSIX controller; no implicit remote/WSL launch"
elif not interpreter.is_file() or not worker.is_file():
reason = "configured interpreter or worker script is missing"
return Route(str(spec["name"]), str(interpreter), str(worker), str(spec["ep"]), windows, not reason, reason)
+109
View File
@@ -0,0 +1,109 @@
# SPDX-License-Identifier: Apache-2.0
"""Bounded, single-consumer shared buffers with explicit Windows ownership.
Windows destroys a named mapping when its last handle closes. Keep a producer
handle until a generation-specific acknowledgement arrives from the reader.
The header also records payload length: opening a Windows mapping can report a
page-rounded allocation size. POSIX's existing unlink-based path is unchanged.
"""
from __future__ import annotations
import hashlib
import struct
import threading
import uuid
from multiprocessing import shared_memory
from pathlib import Path
_HEADER = struct.Struct("<8s16sQ")
_MAGIC = b"OMNISHM1"
def _ack_path(name: str, generation: bytes) -> Path:
from vllm_omni.windows.paths import shm_dir
identity = hashlib.sha256(name.encode() + generation).hexdigest()
return Path(shm_dir()) / f"ack_{identity}"
class SharedBufferOwner:
def __init__(self, max_bytes: int = 64 << 20, max_buffers: int = 256):
self.max_bytes, self.max_buffers = max_bytes, max_buffers
self._owned = {}
self._lock = threading.RLock()
self._collector = None
@property
def retained_bytes(self) -> int:
with self._lock:
return sum(size for _, _, size in self._owned.values())
@property
def retained_names(self) -> frozenset[str]:
with self._lock:
return frozenset(self._owned)
def collect(self) -> None:
with self._lock:
for name, (_, generation, _) in list(self._owned.items()):
if _ack_path(name, generation).exists():
self.release(name)
def release(self, name: str) -> None:
with self._lock:
item = self._owned.get(name)
if item is not None:
segment, generation, _ = item
segment.close()
segment.unlink() # no-op on Windows; useful for contract tests
self._owned.pop(name)
_ack_path(name, generation).unlink(missing_ok=True)
def _collect_loop(self) -> None:
event = threading.Event()
while not event.wait(0.05):
try:
self.collect()
except OSError:
# Keep ownership charged if acknowledgement cleanup fails.
continue
def write(self, payload: bytes, name: str | None = None) -> dict:
with self._lock:
self.collect()
size = _HEADER.size + len(payload)
if len(self._owned) >= self.max_buffers or self.retained_bytes + size > self.max_bytes:
raise BufferError("Windows shared-buffer ownership limit exceeded")
generation = uuid.uuid4().bytes
segment = shared_memory.SharedMemory(create=True, size=size, name=name)
try:
segment.buf[: _HEADER.size] = _HEADER.pack(_MAGIC, generation, len(payload))
segment.buf[_HEADER.size : size] = payload
except BaseException:
segment.close()
segment.unlink()
raise
self._owned[segment.name] = (segment, generation, size)
if self._collector is None:
self._collector = threading.Thread(target=self._collect_loop, daemon=True, name="omni-shm-owner")
self._collector.start()
return {"name": segment.name, "size": len(payload), "generation": generation.hex()}
def read_buffer(meta: dict) -> bytes:
segment = shared_memory.SharedMemory(name=meta["name"])
try:
magic, generation, size = _HEADER.unpack(segment.buf[: _HEADER.size])
if magic != _MAGIC or size > segment.size - _HEADER.size:
raise ValueError("invalid Windows shared-buffer header")
if meta.get("generation", generation.hex()) != generation.hex():
raise ValueError("stale Windows shared-buffer generation")
payload = bytes(segment.buf[_HEADER.size : _HEADER.size + size])
finally:
segment.close()
_ack_path(meta["name"], generation).touch()
return payload
owner = SharedBufferOwner()
+70
View File
@@ -0,0 +1,70 @@
# SPDX-License-Identifier: Apache-2.0
"""Standalone native-Windows process identity and bounded tree retirement.
This file is executed by the worker interpreter from WSL; it imports no engine
or third-party packages. A launcher PID is never treated as a Windows PID.
"""
from __future__ import annotations
import ctypes
import json
import os
import subprocess
import sys
from ctypes import wintypes
def _api():
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel.OpenProcess.restype = wintypes.HANDLE
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
kernel.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4
kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
return kernel
def _created(kernel, handle) -> int:
times = [wintypes.FILETIME() for _ in range(4)]
if not kernel.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)):
raise ctypes.WinError(ctypes.get_last_error())
return (times[0].dwHighDateTime << 32) | times[0].dwLowDateTime
def identity() -> dict:
kernel = _api()
handle = kernel.OpenProcess(0x1000, False, os.getpid()) # QUERY_LIMITED_INFORMATION
if not handle:
raise ctypes.WinError(ctypes.get_last_error())
try:
return {"pid": os.getpid(), "created_filetime": _created(kernel, handle)}
finally:
kernel.CloseHandle(handle)
def retire(pid: int, created_filetime: int) -> bool:
kernel = _api()
handle = kernel.OpenProcess(0x1000 | 0x100000, False, pid) # query + synchronize
if not handle:
return False # parent absence alone says nothing about its descendants
try:
if _created(kernel, handle) != created_filetime:
return False # stale identity must not kill a reused PID
result = subprocess.run(
["taskkill.exe", "/PID", str(pid), "/T", "/F"],
capture_output=True,
timeout=10,
creationflags=subprocess.CREATE_NO_WINDOW,
)
return result.returncode == 0 and kernel.WaitForSingleObject(handle, 5000) == 0
finally:
kernel.CloseHandle(handle)
if __name__ == "__main__":
try:
print(json.dumps({"drained": retire(int(sys.argv[1]), int(sys.argv[2]))}))
except Exception as exc:
print(json.dumps({"drained": False, "error": str(exc)}))
sys.exit(1)
+20
View File
@@ -0,0 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
"""Standalone worker bootstrap: record OS identity without importing Omni."""
import json
import os
import runpy
import sys
from pathlib import Path
if os.name == "nt":
from windows_process import identity
os.environ["VLLM_OMNI_WORKER_IDENTITY"] = json.dumps(identity())
else:
os.environ.pop("VLLM_OMNI_WORKER_IDENTITY", None)
worker = sys.argv.pop(1)
sys.argv[0] = worker
sys.path.insert(0, str(Path(worker).resolve().parent))
runpy.run_path(worker, run_name="__main__")
+8
View File
@@ -96,6 +96,7 @@ _REQUEST_OUTPUT_CONTENT_ATTRS = (
# Omni-specific content copied when the wrapped stage output is itself an
# OmniRequestOutput (e.g. a diffusion stage inside a multi-stage pipeline).
_OMNI_CONTENT_ATTRS = (
"_stage_release",
"images",
"latents",
"trajectory_latents",
@@ -170,6 +171,13 @@ class OmniRequestOutput(RequestOutput):
trajectory_decoded: list | None = None
_multimodal_output: dict[str, Any] = field(default_factory=dict)
_custom_output: dict[str, Any] = field(default_factory=dict)
_stage_release: Any = field(default=None, repr=False, compare=False)
def release_stage_buffers(self) -> None:
"""Acknowledge consumption of an externally owned stage payload."""
release, self._stage_release = self._stage_release, None
if release is not None:
release()
# profiling data
stage_durations: dict[str, float] = field(default_factory=dict)
+1 -1
View File
@@ -20,7 +20,7 @@ def shm_dir() -> str:
use. Callers that used to write ``f"/dev/shm/..."`` go through this so the
path is the same on every platform that has one and a real one elsewhere.
"""
if os.path.isdir(_POSIX_SHM):
if sys.platform != "win32" and os.path.isdir(_POSIX_SHM):
return _POSIX_SHM
base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
path = Path(base) / "vllm_omni" / "shm"
+4 -1
View File
@@ -18,6 +18,7 @@ built on injectable primitives so the logic is testable on Linux.
from __future__ import annotations
import asyncio
import errno
import os
import sys
import time
@@ -88,8 +89,10 @@ class FcntlShim:
self._at_lock_offset(fd, lambda: self._locking(fd, self._nblck, 1))
return
except OSError as exc:
if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
raise
if non_blocking:
raise BlockingIOError(str(exc)) from exc
raise BlockingIOError(exc.errno, str(exc)) from exc
self._sleep(self._poll_interval)
def lockf(self, fd: int, cmd: int, len: int = 0, start: int = 0, whence: int = 0) -> None: # noqa: A002
+5 -1
View File
@@ -2035,7 +2035,11 @@ class OmniGPUModelRunner(GPUModelRunner):
accepted: frozenset[str] | None
try:
parameters = inspect.signature(type(self.model).forward).parameters
# CUDA graph wrappers delegate attributes to their wrapped model;
# their class does not define ``forward``. Inspect the resolved
# callable so eager and captured native models use the same filter.
forward = getattr(self.model, "forward", self.model)
parameters = inspect.signature(forward).parameters
except (TypeError, ValueError):
accepted = None # not introspectable; assume it copes, as before
else: