Files
vllm-omni/vllm_omni/edge/local/external/launch.py
T
tzhouam 723c550209 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.
2026-09-22 13:12:33 +08:00

298 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Which interpreter runs a stage worker, and why one is missing.
Four routes reach the two AMD devices on this laptop, and no two of them can
share an interpreter:
============== ========================== ====================================
route interpreter why it is separate
============== ========================== ====================================
``ort-vitisai`` native Windows, plain ORT the NPU is an MCDM device; WSL's
>= 1.25 GPU-PV forwards WDDM adapters only,
so ``/dev/accel`` does not exist
``ort-dml`` native Windows, ``onnxruntime-directml`` is pinned
onnxruntime-directml at 1.24.4 and cannot load the
VitisAI EP, which needs >= 1.25
``torch-dml`` WSL, ``.venvs/dml`` ``torch-directml`` pins torch 2.4.1;
Omni runs 2.13
``ort-cpu`` anything with onnxruntime the reference arm, and what the
tests use on a machine with no AMD
hardware
============== ========================== ====================================
Every lookup returns a :class:`Route` either way. A missing interpreter is a
first-class answer with a ``reason`` a person can act on, not an exception and
not silence: "the NPU is unusable" and "the NPU venv is not installed" lead to
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
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
ROUTE_VITISAI = "ort-vitisai"
ROUTE_DML = "ort-dml"
ROUTE_TORCH_DML = "torch-dml"
ROUTE_CPU = "ort-cpu"
ROUTE_WIN_CUDA = "win-cuda"
"""[W4] Native-Windows vLLM on the NVIDIA GPU.
The first route whose far side is **torch**, not onnxruntime, and the first
that exists to escape WSL rather than to reach a device WSL cannot see. It is
declared here so a Windows engine is a row in this table instead of a parallel
universe -- but see :data:`WIN_CUDA_PLACEMENT_EVIDENCE`: it cannot currently
pass the placement gate, and that is deliberate, not an oversight.
"""
ENV_PREFIX = "VLLM_OMNI_EXTERNAL_PYTHON_"
"""``VLLM_OMNI_EXTERNAL_PYTHON_ORT_VITISAI=...`` overrides one route's
interpreter. Route names are upper-cased with ``-`` turned into ``_``."""
WORKER_ENV_PREFIX = "VLLM_OMNI_EXTERNAL_WORKER_"
"""``VLLM_OMNI_EXTERNAL_WORKER_ORT_CPU=...`` overrides one route's worker
script. The tests point this at a fake that speaks the protocol and never
imports onnxruntime, so the client, the framing and the refusal paths are
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: (
str(_WINDOWS_HOME / "npu-ep/Scripts/python.exe"),
str(_WINDOWS_HOME / "ryzenai/Scripts/python.exe"),
),
ROUTE_DML: (
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_WIN_CUDA: (
str(_WINDOWS_HOME / "w0/w0venv/Scripts/python.exe"),
str(_WINDOWS_HOME / "vllm-win/Scripts/python.exe"),
),
}
_WORKER_FOR_ROUTE = {
ROUTE_VITISAI: "worker_ort.py",
ROUTE_DML: "worker_ort.py",
ROUTE_CPU: "worker_ort.py",
ROUTE_TORCH_DML: "worker_dml.py",
ROUTE_WIN_CUDA: "worker_vllm.py",
}
_EP_FOR_ROUTE = {
ROUTE_VITISAI: "vitisai",
ROUTE_DML: "dml",
ROUTE_CPU: "cpu",
ROUTE_TORCH_DML: "dml",
ROUTE_WIN_CUDA: "cuda",
}
WIN_CUDA_PLACEMENT_EVIDENCE: str | None = None
"""What a ``win-cuda`` worker can prove about where its work ran. Currently: nothing.
Every other route in this table earns its placement from a *record the runtime
writes while the graph runs*: onnxruntime reports per-node provider assignments,
and ``torch-directml`` reports the device of the output tensor. M3 made that a
hard admission condition after a VitisAI session listed its EP, took **zero**
nodes, and returned output bit-identical to the CPU's -- so neither the provider
list nor the numbers can tell a real device run from a silent fallback.
**vLLM has no analogue.** It does not emit a per-operation device map, and the
cheap substitutes are exactly the ones M3 proved worthless: ``current_platform``
is a *selection*, not an observation; allocated VRAM says weights are resident,
not that the decode step ran on them; and plausible output is what a silent CPU
fallback also produces.
``None`` therefore means *unverified*, which
:func:`vllm_omni.edge.local.external.stage` already refuses with
``REFUSE_EP_PLACEMENT`` -- the same answer the NPU gets when it has not been
measured. Refusing our own engine on the rule we wrote for a vendor's is the
point: the rule is about evidence, not about whose code it is.
To make this route admissible, give it a *measured* device attribution -- a
worker that runs a marked op under a profiler and reports where it landed -- and
set this to that fraction. Do not set it to 1.0 because the code looks right.
"""
@dataclass(frozen=True)
class Route:
"""One way to reach a device, resolved or not."""
name: str
interpreter: str | None
worker: str
"""Absolute path to the worker script, in this filesystem's spelling."""
ep: str
"""The execution provider the worker should ask for."""
is_windows: bool
"""Whether paths handed to this interpreter need Windows spelling."""
available: bool
reason: str = ""
extra: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def repo_root() -> Path:
"""The checkout this package was imported from, when there is one.
Used only to resolve the relative interpreter candidates above. An
installed wheel has no checkout around it, in which case the relative
routes simply do not resolve and say so.
"""
override = os.environ.get("VLLM_OMNI_REPO_ROOT")
if override:
return Path(override)
here = Path(__file__).resolve()
# ``.venvs`` first, and in a separate pass: ``vllm-omni`` is a nested
# checkout with its own ``.git``, so a single loop that accepts either
# marker stops one level too deep and every relative candidate misses.
for marker in (".venvs", ".git"):
for parent in here.parents:
if (parent / marker).is_dir():
return parent
return here.parents[-1]
def is_wsl() -> bool:
"""True when this process can launch native-Windows executables.
The marker is interop, not the kernel string: a WSL2 kernel without
``/proc/sys/fs/binfmt_misc/WSLInterop`` cannot exec a ``.exe``, and that is
the capability the caller actually needs.
"""
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()
)
def to_worker_path(path: str | Path, *, is_windows: bool) -> str:
"""Spell a path the way the worker's interpreter will understand it.
A native-Windows worker running a script that lives in WSL sees it as a
``\\\\wsl.localhost\\...`` UNC path. ``wslpath -w`` is the translation, and
it is the reason a Windows process can load graphs and write profiles
straight into the Linux tree with no copying.
"""
text = str(path)
if not is_windows or os.name == "nt":
return text
if not shutil.which("wslpath"):
return text
try:
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
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:
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))
try:
if path.is_file():
return str(path), ""
except OSError:
continue
return None, f"no interpreter found; tried {tried} and {env_key} is unset"
def resolve(route: str, *, root: Path | None = None) -> Route:
"""Find the interpreter for one route, or say why it is not there."""
if route not in _WORKER_FOR_ROUTE:
raise ValueError(f"unknown route {route!r}; expected one of {sorted(_WORKER_FOR_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])
if is_windows and not is_wsl() and os.name != "nt":
return Route(
name=route,
interpreter=None,
worker=worker,
ep=_EP_FOR_ROUTE[route],
is_windows=True,
available=False,
reason=(
"this route needs a native-Windows interpreter, and this "
"process is neither on Windows nor in a WSL distro with "
"interop enabled, so it cannot start one"
),
)
interpreter, reason = _resolve_interpreter(route, root)
# An interpreter without a worker script is not a route. Checked here
# rather than at start(), because ``available`` is what the device
# enumeration turns into ``runnable`` -- reporting a device as runnable and
# then failing to launch it is the same silent-substitution mistake this
# module exists to avoid, one layer down.
if interpreter is not None and not Path(worker).is_file():
return Route(
name=route,
interpreter=interpreter,
worker=worker,
ep=_EP_FOR_ROUTE[route],
is_windows=is_windows,
available=False,
reason=f"worker script not found: {worker}",
)
return Route(
name=route,
interpreter=interpreter,
worker=worker,
ep=_EP_FOR_ROUTE[route],
is_windows=is_windows,
available=interpreter is not None,
reason=reason,
)
def resolve_all(*, root: Path | None = None) -> list[Route]:
"""Every route, resolved or not, in a stable order."""
root = root or repo_root()
return [resolve(name, root=root) for name in (ROUTE_VITISAI, ROUTE_DML, ROUTE_TORCH_DML, ROUTE_CPU)]
def describe(routes: list[Route]) -> str:
lines = []
for route in routes:
mark = "available" if route.available else "missing"
lines.append(f" [{mark:^9}] {route.name:<12} ep={route.ep:<8} {route.interpreter or ''}")
if route.reason:
lines.append(f" {route.reason}")
return "\n".join(lines) if lines else " (none)"