One package that makes the POSIX assumptions measured in W0-W8 hold in every process vLLM / vLLM-Omni starts, and reports what it did: - provides fcntl (flock/lockf on msvcrt.locking; LOCK_SH served as exclusive) and uvloop (asyncio.run on a selector loop) when absent; - installs WindowsSelectorEventLoopPolicy and a signal.signal fallback for loop.add_signal_handler on the Windows loop classes; - replaces whole vLLM's get_open_zmq_ipc_path / find_loaded_library when the wheel lacks the branch patch and the upstream body hash is known; - verifies the wheel-only sites (sentinel poll, int64 seed) and reports wheel_required at the exact site instead of failing downstream; - shm_dir() replaces the /dev/shm literals; os.geteuid provided. Activated from vllm_omni/patch.py and as vllm.general_plugins / vllm_omni.general_plugins entry points (idempotent; no-op off Windows). prints the audit; the M0 placement report embeds it. 16 Linux tests through injected primitives. Design: analysis/design_windows_compat_plugin_20260921.md Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
303 lines
11 KiB
Python
303 lines
11 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Steady-state per-step overhead attribution (edge scheduling, WP8).
|
|
|
|
Process-local counters that answer "where does a codec frame's 80 ms budget go
|
|
outside the model forward?": scheduler ``schedule()`` / ``update_from_output()``
|
|
time, the whole engine-core step, the talker -> code2wav chunk hop (enqueue ->
|
|
connector put, put -> consumer get, get duration) and the orchestrator dispatch
|
|
loop. Enabled by ``VLLM_OMNI_STEP_STATS_DIR=<dir>``; every process dumps
|
|
``<dir>/<role>_<pid>.json`` periodically and at shutdown. ``merge_dir`` folds
|
|
the files into one attribution table (``benchmarks/tts/stream_latency_bench.py
|
|
--step-stats``).
|
|
|
|
Zero cost when disabled: ``StepStats.get().enabled`` is False and every hook
|
|
returns immediately.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable, Iterator
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ENV_DIR = "VLLM_OMNI_STEP_STATS_DIR"
|
|
ENV_SYNC = "VLLM_OMNI_STEP_STATS_SYNC" # "1": synchronize the accelerator around model-level timers
|
|
ENV_STAGE_ID = "VLLM_OMNI_STAGE_ID"
|
|
FRAME_BUDGET_MS = 80.0 # one Qwen3-TTS codec frame at 12.5 Hz
|
|
_MAX_SAMPLES = 20000
|
|
_FLUSH_EVERY = 500
|
|
|
|
|
|
class _Series:
|
|
__slots__ = ("count", "total", "max", "samples")
|
|
|
|
def __init__(self) -> None:
|
|
self.count = 0
|
|
self.total = 0.0
|
|
self.max = 0.0
|
|
self.samples: list[float] = []
|
|
|
|
def add(self, v: float) -> None:
|
|
self.count += 1
|
|
self.total += v
|
|
if v > self.max:
|
|
self.max = v
|
|
if len(self.samples) < _MAX_SAMPLES:
|
|
self.samples.append(v)
|
|
else: # keep a uniform-ish reservoir: overwrite a pseudo-random slot
|
|
self.samples[(self.count * 7919) % _MAX_SAMPLES] = v
|
|
|
|
def summary(self) -> dict[str, float | int]:
|
|
s = sorted(self.samples)
|
|
n = len(s)
|
|
|
|
def pct(p: float) -> float:
|
|
if not n:
|
|
return 0.0
|
|
return s[min(n - 1, int(round((n - 1) * p)))]
|
|
|
|
return {
|
|
"count": self.count,
|
|
"sum_ms": self.total,
|
|
"mean_ms": (self.total / self.count) if self.count else 0.0,
|
|
"p50_ms": pct(0.5),
|
|
"p95_ms": pct(0.95),
|
|
"max_ms": self.max,
|
|
}
|
|
|
|
|
|
class StepStats:
|
|
"""Process-local counter registry (thread-safe adds)."""
|
|
|
|
_instance: StepStats | None = None
|
|
_instance_lock = threading.Lock()
|
|
|
|
def __init__(self, out_dir: str | None, role: str | None = None) -> None:
|
|
self.out_dir = out_dir
|
|
self.enabled = bool(out_dir)
|
|
self.sync = os.environ.get(ENV_SYNC, "0").strip().lower() in ("1", "true", "yes", "on")
|
|
self.role = role or _default_role()
|
|
self.pid = os.getpid()
|
|
self._series: dict[str, _Series] = {}
|
|
self._lock = threading.Lock()
|
|
self._n_since_flush = 0
|
|
self.started_at = time.time()
|
|
if self.enabled:
|
|
Path(out_dir).mkdir(parents=True, exist_ok=True)
|
|
atexit.register(self.dump)
|
|
|
|
# ------------------------------------------------------------ singleton
|
|
@classmethod
|
|
def get(cls) -> StepStats:
|
|
inst = cls._instance
|
|
if inst is None:
|
|
with cls._instance_lock:
|
|
inst = cls._instance
|
|
if inst is None:
|
|
inst = cls(os.environ.get(ENV_DIR) or None)
|
|
cls._instance = inst
|
|
return inst
|
|
|
|
@classmethod
|
|
def reset(cls, out_dir: str | None = None, role: str | None = None) -> StepStats:
|
|
"""Replace the singleton (tests / explicit enablement in-process)."""
|
|
with cls._instance_lock:
|
|
cls._instance = cls(out_dir, role)
|
|
return cls._instance
|
|
|
|
# ------------------------------------------------------------ recording
|
|
def add(self, name: str, value_ms: float) -> None:
|
|
if not self.enabled:
|
|
return
|
|
with self._lock:
|
|
s = self._series.get(name)
|
|
if s is None:
|
|
s = self._series[name] = _Series()
|
|
s.add(float(value_ms))
|
|
self._n_since_flush += 1
|
|
flush = self._n_since_flush >= _FLUSH_EVERY
|
|
if flush:
|
|
self._n_since_flush = 0
|
|
if flush:
|
|
self.dump()
|
|
|
|
@contextmanager
|
|
def timed(self, name: str, sync_device: Any = None) -> Iterator[None]:
|
|
"""Time a block; with ``sync_device`` (a torch device) and ``VLLM_OMNI_STEP_STATS_SYNC=1``
|
|
the accelerator is synchronized before and after so async kernels are included."""
|
|
if not self.enabled:
|
|
yield
|
|
return
|
|
sync = sync_device is not None and self.sync and getattr(sync_device, "type", "cpu") == "cuda"
|
|
if sync:
|
|
import torch
|
|
|
|
torch.accelerator.synchronize(sync_device)
|
|
t0 = time.perf_counter()
|
|
try:
|
|
yield
|
|
finally:
|
|
if sync:
|
|
torch.accelerator.synchronize(sync_device)
|
|
self.add(name, (time.perf_counter() - t0) * 1000.0)
|
|
|
|
def wrap(self, name: str, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
"""Return ``fn`` timed under ``name`` (identity when disabled)."""
|
|
if not self.enabled:
|
|
return fn
|
|
|
|
def timed_fn(*args: Any, **kwargs: Any) -> Any:
|
|
t0 = time.perf_counter()
|
|
try:
|
|
return fn(*args, **kwargs)
|
|
finally:
|
|
self.add(name, (time.perf_counter() - t0) * 1000.0)
|
|
|
|
timed_fn.__name__ = getattr(fn, "__name__", name)
|
|
timed_fn.__wrapped__ = fn # type: ignore[attr-defined]
|
|
return timed_fn
|
|
|
|
# ------------------------------------------------------------ output
|
|
def to_dict(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
counters = {k: v.summary() for k, v in self._series.items()}
|
|
return {
|
|
"role": self.role,
|
|
"pid": self.pid,
|
|
"started_at": self.started_at,
|
|
"dumped_at": time.time(),
|
|
"counters": counters,
|
|
}
|
|
|
|
def dump(self) -> str | None:
|
|
if not self.enabled:
|
|
return None
|
|
path = Path(self.out_dir) / f"{self.role}_{self.pid}.json"
|
|
tmp = path.with_suffix(".json.tmp")
|
|
try:
|
|
with open(tmp, "w") as f:
|
|
json.dump(self.to_dict(), f, indent=1)
|
|
os.replace(tmp, path)
|
|
except OSError:
|
|
return None
|
|
return str(path)
|
|
|
|
|
|
def _default_role() -> str:
|
|
sid = os.environ.get(ENV_STAGE_ID)
|
|
return f"stage{sid}" if sid not in (None, "") else "orchestrator"
|
|
|
|
|
|
# ---------------------------------------------------------------- hooks
|
|
def instrument_engine_core(engine_core: Any) -> None:
|
|
"""Time the scheduler and the whole step of a (stage) EngineCore instance."""
|
|
stats = StepStats.get()
|
|
if not stats.enabled:
|
|
return
|
|
sched = getattr(engine_core, "scheduler", None)
|
|
if sched is not None:
|
|
for name in ("schedule", "update_from_output"):
|
|
fn = getattr(sched, name, None)
|
|
if callable(fn) and not hasattr(fn, "__wrapped__"):
|
|
setattr(sched, name, stats.wrap(f"sched.{name}_ms", fn))
|
|
step_fn = getattr(engine_core, "step_fn", None)
|
|
if callable(step_fn) and not hasattr(step_fn, "__wrapped__"):
|
|
engine_core.step_fn = stats.wrap("core.step_ms", step_fn)
|
|
|
|
|
|
def instrument_sampler(sampler: Any) -> None:
|
|
"""Time the phases of vLLM's v1 ``Sampler`` in place (idempotent, no-op when disabled)."""
|
|
stats = StepStats.get()
|
|
if not stats.enabled or sampler is None or getattr(sampler, "_omni_step_stats_instrumented", False):
|
|
return
|
|
for name in (
|
|
"apply_logits_processors",
|
|
"apply_penalties",
|
|
"apply_temperature",
|
|
"sample",
|
|
"compute_logprobs",
|
|
"gather_logprobs",
|
|
):
|
|
fn = getattr(sampler, name, None)
|
|
if callable(fn):
|
|
setattr(sampler, name, stats.wrap(f"sampler.{name}_ms", fn))
|
|
tk = getattr(sampler, "topk_topp_sampler", None)
|
|
if tk is not None and callable(getattr(tk, "forward", None)):
|
|
tk.forward = stats.wrap("sampler.topk_topp_ms", tk.forward)
|
|
sampler._omni_step_stats_instrumented = True
|
|
|
|
|
|
def shm_lockfile_age_ms(key: str, now: float | None = None) -> float | None:
|
|
"""Age of the SharedMemoryConnector lock file for ``key`` (written at put time)."""
|
|
try:
|
|
from vllm_omni.windows.paths import shm_path
|
|
|
|
st = os.stat(shm_path(f"shm_{key}_lockfile.lock"))
|
|
except OSError:
|
|
return None
|
|
return ((now if now is not None else time.time()) - st.st_mtime) * 1000.0
|
|
|
|
|
|
# ---------------------------------------------------------------- merge
|
|
def merge_dir(out_dir: str | Path) -> dict[str, Any]:
|
|
"""Merge per-process dumps into ``{role: {counter: summary}}`` plus a share table."""
|
|
roles: dict[str, dict[str, Any]] = {}
|
|
for f in sorted(Path(out_dir).glob("*.json")):
|
|
try:
|
|
d = json.loads(f.read_text())
|
|
except (OSError, ValueError):
|
|
continue
|
|
role = d.get("role", f.stem)
|
|
bucket = roles.setdefault(role, {})
|
|
for name, summ in d.get("counters", {}).items():
|
|
cur = bucket.get(name)
|
|
if cur is None:
|
|
bucket[name] = dict(summ)
|
|
else: # several processes with the same role: combine count/sum/max, keep worst p95
|
|
cnt = cur["count"] + summ["count"]
|
|
cur.update(
|
|
count=cnt,
|
|
sum_ms=cur["sum_ms"] + summ["sum_ms"],
|
|
mean_ms=((cur["sum_ms"] + summ["sum_ms"]) / cnt) if cnt else 0.0,
|
|
p50_ms=max(cur["p50_ms"], summ["p50_ms"]),
|
|
p95_ms=max(cur["p95_ms"], summ["p95_ms"]),
|
|
max_ms=max(cur["max_ms"], summ["max_ms"]),
|
|
)
|
|
return {"roles": roles, "attribution": attribution(roles)}
|
|
|
|
|
|
def attribution(roles: dict[str, dict[str, Any]], frame_budget_ms: float = FRAME_BUDGET_MS) -> list[dict[str, Any]]:
|
|
"""Rows of (role, counter, mean, p95, share of the frame budget) sorted by share."""
|
|
rows = []
|
|
for role, counters in roles.items():
|
|
for name, s in counters.items():
|
|
rows.append(
|
|
{
|
|
"role": role,
|
|
"counter": name,
|
|
"count": s["count"],
|
|
"mean_ms": s["mean_ms"],
|
|
"p95_ms": s["p95_ms"],
|
|
"share_of_frame": (s["mean_ms"] / frame_budget_ms) if frame_budget_ms else 0.0,
|
|
}
|
|
)
|
|
rows.sort(key=lambda r: -r["share_of_frame"])
|
|
return rows
|
|
|
|
|
|
def format_table(rows: list[dict[str, Any]]) -> str:
|
|
lines = ["| role | counter | n | mean ms | p95 ms | share of 80 ms |", "|---|---|---|---|---|---|"]
|
|
for r in rows:
|
|
lines.append(
|
|
f"| {r['role']} | {r['counter']} | {r['count']} | {r['mean_ms']:.2f} | {r['p95_ms']:.2f} "
|
|
f"| {100 * r['share_of_frame']:.1f}% |"
|
|
)
|
|
return "\n".join(lines)
|