Measured on a Xeon 8480C (12+4 threads, 0.6B): the 15 residual-codebook sub-steps re-ran the 5-layer predictor over the whole prefix each time (97 ms per frame of the 134 ms talker step) and vLLM's stock sampler spent 9 ms per step in its torch.compile'd random-sample kernel on CPU. - code predictor: forward_cached (KV cache per layer, 2-token prefill then one token per sub-step) and a shape-static forward_static_step (tensor position + boolean mask) that torch.compile handles as one graph; incremental decoding is the default on CPU (VLLM_OMNI_PREDICTOR_INCREMENTAL), optional compile (VLLM_OMNI_PREDICTOR_COMPILE=step), optional thread cap. - CPU fast path: dynamic int8 Linear layers (fp32 activations) for the predictor and its lm heads (VLLM_OMNI_PREDICTOR_INT8, default on; micro-benchmark 70 -> 36 ms per frame for the 15 sub-steps); VLLM_OMNI_PREDICTOR_PARITY_CHECK=1 records top-1 agreement / max logit diff against the bf16 model in situ. - Qwen3-TTS talker: prefer_model_sampler on CPU with a sample() that mirrors vLLM's Sampler (logits processors + penalties, temperature, top-k/top-p, exponential-noise sampling with per-request generators) without the compiled kernel (VLLM_OMNI_CPU_FAST_SAMPLER, default on). - step_stats: sampler phase timers and sample-metadata timers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
301 lines
11 KiB
Python
301 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:
|
|
st = os.stat(f"/dev/shm/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)
|