File size: 17,157 Bytes
e1acc15 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 | # SPDX-License-Identifier: Apache-2.0
"""Host side of the async hand-off (torch-free): libdsv41_hostflag.so (hostflag.c), the page with the gathered /
released / claimed words and the reply area, the proxy's wait and publish, fail-all and the native watchdog.
Split out of streamsync.py unchanged so that the out-of-process proxy (proxy_proc.py) can use it without importing
torch; streamsync.py re-exports every name. The page is either allocated here (one process: the thread proxy) or lies
in a shared region another object owns (shmregion.py: the process proxy), see HostFlagCore(page=...).
"""
from __future__ import annotations
import atexit
import ctypes
import hashlib
import os
import shutil
import stat
import subprocess
import tempfile
import numpy as np
CU_STREAM_WAIT_VALUE_GEQ = 0x0 # (int32)(*addr - value) >= 0: wrap-safe
CU_STREAM_WRITE_VALUE_DEFAULT = 0x0 # with a memory fence before the write
CU_MEMHOSTREGISTER_PORTABLE = 0x01
CU_MEMHOSTREGISTER_DEVICEMAP = 0x02
HOSTFLAG_LIB = "libdsv41_hostflag.so"
HOSTFLAG_VERSION = 2
PAGE_BYTES = 65536
OFF_GATHERED = 0 # written by the engine stream
OFF_RELEASED = 128 # written by publish / fail-release (CPU), each word on its own cache line
OFF_CLAIMED = 256 # claim word: the last round the proxy or the watchdog took
OFF_CONSUMED = 384 # consumer acknowledgment: the last round whose reply the engine stream has copied (R2)
OFF_REPLY = 4096 # the reply the engine stream copies after the wait
REPLY_CAPACITY = PAGE_BYTES - OFF_REPLY
FAR_AHEAD = 1 << 30 # fail-all value: cyclically after every round within 2**30
_M32 = 0xFFFFFFFF
_SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hostflag.c")
class StreamSyncError(RuntimeError):
pass
class StreamStuckError(RuntimeError):
"""The engine stream stayed parked after the self-test's rescue: this device does not observe host stores. Not
recoverable (the stream cannot be unparked), so the caller must fail loudly, not fall back (S9 F4)."""
# ---------------------------------------------------------------------------------------------------- the C helper
_V = ctypes.c_void_p
def _bind(lib: ctypes.CDLL) -> ctypes.CDLL:
try:
sig = {
"dsv41_hf_version": (ctypes.c_int, []),
"dsv41_hf_alloc": (_V, [ctypes.c_size_t]),
"dsv41_hf_free": (None, [_V]),
"dsv41_hf_lock": (ctypes.c_int, [_V, ctypes.c_size_t]),
"dsv41_hf_load": (ctypes.c_uint32, [_V]),
"dsv41_hf_release_max": (None, [_V, ctypes.c_uint32]),
"dsv41_hf_store": (None, [_V, ctypes.c_uint32]),
"dsv41_hf_set": (None, [_V, ctypes.c_uint32]),
"dsv41_hf_claim": (ctypes.c_int, [_V, ctypes.c_uint32]),
"dsv41_hf_publish": (ctypes.c_int, [_V, _V, ctypes.c_uint32, _V, _V, ctypes.c_size_t]),
"dsv41_hf_fail_release": (ctypes.c_int, [_V, _V, ctypes.c_uint32, _V, ctypes.c_size_t, ctypes.c_int32]),
"dsv41_hf_wait_geq": (ctypes.c_int, [_V, ctypes.c_uint32, ctypes.c_int64, ctypes.c_int64, ctypes.c_int64]),
"dsv41_hf_watchdog_start": (_V, [_V, _V, _V, _V, ctypes.c_size_t, ctypes.c_int32, ctypes.c_int64,
ctypes.c_int64]),
"dsv41_hf_watchdog_stats": (None, [_V, ctypes.POINTER(ctypes.c_uint64)]),
"dsv41_hf_watchdog_stop": (ctypes.c_int, [_V]),
}
for name, (res, args) in sig.items():
f = getattr(lib, name)
f.restype = res
f.argtypes = args
except AttributeError as e: # an older library without a v2 symbol: a clean refusal (S9 F7)
raise StreamSyncError(f"{lib._name}: {e}") from e
v = lib.dsv41_hf_version()
if v != HOSTFLAG_VERSION:
raise StreamSyncError(f"{lib._name}: hostflag version {v}, expected {HOSTFLAG_VERSION}")
return lib
def _private_dir(path: str) -> None:
"""Create (or accept) a build directory only this user can write."""
os.makedirs(path, mode=0o700, exist_ok=True)
st = os.lstat(path)
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o022:
raise StreamSyncError(f"hostflag build directory {path} is not a private directory of uid {os.getuid()}")
def _compile(cc: str, out_dir: str) -> str:
with open(_SRC, "rb") as f:
digest = hashlib.sha256(f.read()).hexdigest()[:16]
d = os.path.join(out_dir, f"dsv41-hostflag-{os.getuid()}-{digest}")
_private_dir(d)
so = os.path.join(d, HOSTFLAG_LIB)
if not os.path.exists(so):
tmp = so + f".tmp{os.getpid()}"
try:
r = subprocess.run([cc, "-O2", "-Wall", "-shared", "-fPIC", "-o", tmp, _SRC, "-lpthread"],
capture_output=True, text=True, timeout=120)
except (subprocess.TimeoutExpired, OSError) as e: # S9 F7: a clean refusal, not a crash
raise StreamSyncError(f"compiling {_SRC} with {cc}: {e}") from e
if r.returncode != 0:
raise StreamSyncError(f"compiling {_SRC} with {cc} failed ({r.returncode}): {r.stderr.strip()[:400]}")
os.replace(tmp, so)
return so
def load_hostflag(lib_path: str = "") -> tuple[ctypes.CDLL, str]:
"""libdsv41_hostflag.so: an explicit path, else VLLM_DSPARK_REMOTE_OFL_DIR's copy, else hostflag.c compiled with
the system C compiler into a private directory under the temp dir. Returns (library, where it came from)."""
if lib_path:
if not os.path.exists(lib_path):
raise StreamSyncError(f"VLLM_DSPARK_REMOTE_HOSTFLAG_LIB={lib_path} does not exist")
return _bind(ctypes.CDLL(lib_path)), lib_path
ofl_dir = (os.environ.get("VLLM_DSPARK_REMOTE_OFL_DIR") or "").strip()
if ofl_dir and os.path.exists(os.path.join(ofl_dir, HOSTFLAG_LIB)):
p = os.path.join(ofl_dir, HOSTFLAG_LIB)
return _bind(ctypes.CDLL(p)), p
cc = shutil.which("cc") or shutil.which("gcc")
if cc is None:
raise StreamSyncError(f"no {HOSTFLAG_LIB} (set VLLM_DSPARK_REMOTE_HOSTFLAG_LIB or put it in "
"VLLM_DSPARK_REMOTE_OFL_DIR) and no C compiler to build hostflag.c")
p = _compile(cc, tempfile.gettempdir())
return _bind(ctypes.CDLL(p)), f"{p} (compiled at startup)"
PR_SET_TIMERSLACK = 29
def set_timer_slack(ns: int = 1000) -> bool:
"""Linux timer slack of the calling thread: with VLLM_DSPARK_REMOTE_ASYNC_NAP_US > 0 the proxy's waits nap in
short nanosleeps, which the default 50 us slack would stretch. False where prctl is unavailable."""
try:
libc = ctypes.CDLL(None, use_errno=True)
return libc.prctl(PR_SET_TIMERSLACK, ctypes.c_ulong(int(ns)), ctypes.c_ulong(0), ctypes.c_ulong(0),
ctypes.c_ulong(0)) == 0
except (OSError, AttributeError):
return False
# ---------------------------------------------------------------------------------------------------- host side
class HostFlagCore:
"""The host side of the hand-off (no CUDA): the C-owned page with the three words and the reply area, the proxy's
wait and publish, fail-all, and the native watchdog. The page is never freed while the process may still copy from
it (close() only after the round stopped and the stream drained)."""
cuda_free_proxy = True
def __init__(self, lib_path: str = "", spin_us: int = 1000, nap_us: int = 0, page: int = 0):
"""page: 0 = allocate the page here (freed by close()); else the address of a PAGE_BYTES page, 4096-aligned,
in memory the caller owns and keeps mapped (a shared region: shmregion.py). An external page is never freed
here, and its words are not reset: the region's creator zeroed them."""
self.lib, self.lib_origin = load_hostflag(lib_path)
self.spin_ns = int(spin_us) * 1000
self.nap_ns = int(nap_us) * 1000
self.owns_page = not page
if page:
if page % 4096:
raise StreamSyncError(f"external hostflag page {page:#x} is not 4096-aligned")
else:
page = self.lib.dsv41_hf_alloc(PAGE_BYTES)
if not page:
raise StreamSyncError("hostflag page allocation failed")
self.page = int(page)
self.mlock_errno = int(self.lib.dsv41_hf_lock(_V(self.page), PAGE_BYTES))
self.gathered_host = self.page + OFF_GATHERED
self.released_host = self.page + OFF_RELEASED
self.claimed_host = self.page + OFF_CLAIMED
self.consumed_host = self.page + OFF_CONSUMED
self.reply_host = self.page + OFF_REPLY
self.reply_bytes = 0
self.fail_status = 0
self.deadline_ms = 0
self._wd: int | None = None
self._atexit_registered = False
# -------------------------------------------------- proxy thread (CPU only, no torch)
def wait_gathered(self, value: int, timeout_s: float) -> bool:
"""True once gathered >= value; False after timeout_s. Releases the GIL while it waits."""
r = self.lib.dsv41_hf_wait_geq(_V(self.gathered_host), value & _M32, int(timeout_s * 1e9),
self.spin_ns, self.nap_ns)
return r == 0
def publish(self, value: int, src: np.ndarray) -> bool:
"""Claim round value, copy src into the reply area, release value: one GIL-free C call. False when the round
had already been claimed (the watchdog failed it first); nothing is written then."""
if not src.flags.c_contiguous or src.nbytes > REPLY_CAPACITY:
raise ValueError(f"reply of {src.nbytes} bytes (contiguous {src.flags.c_contiguous}) does not fit")
return bool(self.lib.dsv41_hf_publish(_V(self.claimed_host), _V(self.released_host), value & _M32,
_V(self.reply_host), _V(src.ctypes.data), src.nbytes))
def taken(self, value: int) -> bool:
"""True once round value (or a later one) has been claimed: by this proxy's publish, the watchdog or a
fail-all. A proxy that finds its round taken before it starts must not read that round's staging."""
c = int(self.lib.dsv41_hf_load(_V(self.claimed_host)))
return ((c - value) & _M32) < (1 << 31)
def reply_consumed(self, value: int) -> bool:
"""The consumer acknowledgment: True once the engine stream has copied round value's reply out of the page
(it writes the word right after the copy). Round t's reply may be written only when round t-1's is consumed;
gathered >= t implies it by stream order, and the proxy checks the word before every publish."""
c = int(self.lib.dsv41_hf_load(_V(self.consumed_host)))
return ((c - value) & _M32) < (1 << 31)
def consumed(self, value: int, stream=None) -> None:
"""Engine side, after copy_reply(): acknowledge round value's reply. CPU stand-ins copy synchronously, so the
store follows the copy; HostFlagSync enqueues it on the stream instead."""
self.lib.dsv41_hf_release_max(_V(self.consumed_host), value & _M32)
def release(self, value: int) -> None:
"""Monotonic release (cyclic max) without a reply: the self-test's rescue."""
self.lib.dsv41_hf_release_max(_V(self.released_host), value & _M32)
def drain_marker(self):
"""Engine side: a marker after everything enqueued on the engine stream so far (process proxy respawn, S11 P-4).
CPU stand-ins run their "stream" synchronously, so nothing is ever pending: None."""
return None
def drained(self, marker) -> bool:
"""True once everything before the marker ran (never waits)."""
return True
# -------------------------------------------------- any thread
def fail_all(self, value: int) -> bool:
"""Claim value, write the failed reply, release value. With value FAR_AHEAD of every enqueued round this fails
every pending and later round at once (proxy death, close, exit). False if value was already claimed."""
n = self.reply_bytes or REPLY_CAPACITY
return bool(self.lib.dsv41_hf_fail_release(_V(self.claimed_host), _V(self.released_host), value & _M32,
_V(self.reply_host), n, self.fail_status))
def fail_ahead(self) -> bool:
"""fail_all at FAR_AHEAD past the furthest of the three words."""
g, r, c = self.values()
base = g
for w in (r, c):
if ((w - base) & _M32) < (1 << 31): # w cyclically at or after base
base = w
return self.fail_all(base + FAR_AHEAD)
def values(self) -> tuple[int, int, int]:
ld = self.lib.dsv41_hf_load
return (int(ld(_V(self.gathered_host))), int(ld(_V(self.released_host))), int(ld(_V(self.claimed_host))))
# -------------------------------------------------- setup (engine thread)
def attach(self, reply_bytes: int, fail_status: int, deadline_ms: int, poll_us: int = 1000) -> None:
"""Size the reply, write a failed reply into it (what the stream copies before any round was published) and
start the watchdog."""
if self._wd is not None:
raise StreamSyncError("hostflag watchdog already attached")
if not 4 <= reply_bytes <= REPLY_CAPACITY:
raise StreamSyncError(f"reply of {reply_bytes} bytes does not fit the {REPLY_CAPACITY}-byte area")
if deadline_ms <= 0:
raise StreamSyncError(f"watchdog deadline {deadline_ms} ms")
self.configure_reply(reply_bytes, fail_status)
self.deadline_ms = int(deadline_ms)
failed = np.zeros(self.reply_bytes // 4, dtype=np.int32)
failed[0] = self.fail_status
ctypes.memmove(self.reply_host, failed.ctypes.data, self.reply_bytes)
poll_ns = max(50_000, min(int(poll_us) * 1000, self.deadline_ms * 100_000))
wd = self.lib.dsv41_hf_watchdog_start(_V(self.gathered_host), _V(self.released_host),
_V(self.claimed_host), _V(self.reply_host), self.reply_bytes,
self.fail_status, self.deadline_ms * 1_000_000, poll_ns)
if not wd:
raise StreamSyncError(f"hostflag watchdog did not start (errno {ctypes.get_errno()})")
self._wd = int(wd)
if not self._atexit_registered:
atexit.register(self._at_exit)
self._atexit_registered = True
def configure_reply(self, reply_bytes: int, fail_status: int) -> None:
"""Reply size and failed status, without touching the page (attach() and a process proxy's view of a page
the engine process attached)."""
if not 4 <= reply_bytes <= REPLY_CAPACITY:
raise StreamSyncError(f"reply of {reply_bytes} bytes does not fit the {REPLY_CAPACITY}-byte area")
self.reply_bytes = int(reply_bytes)
self.fail_status = int(fail_status)
def watchdog_stats(self) -> dict:
if self._wd is None:
return {"running": False}
out = (ctypes.c_uint64 * 3)()
self.lib.dsv41_hf_watchdog_stats(_V(self._wd), out)
return {"running": True, "deadline_ms": self.deadline_ms, "fires": int(out[0]),
"last_failed_round": int(out[1]), "max_late_ms": round(out[2] / 1e6, 3)}
def watchdog_mirror(self, dst: int) -> None:
"""Copy the watchdog's raw stats (fires, last failed round, max lateness in ns: 3 x u64) to address dst (the
process proxy's shared control block). No-op without a watchdog."""
if self._wd is not None:
self.lib.dsv41_hf_watchdog_stats(_V(self._wd), ctypes.cast(dst, ctypes.POINTER(ctypes.c_uint64)))
def stop_watchdog(self) -> None:
if self._wd is not None:
wd, self._wd = self._wd, None
self.lib.dsv41_hf_watchdog_stop(_V(wd))
def reset(self) -> None:
"""All words to 0. Only with no round in flight and no watchdog (setup, self-test)."""
if self._wd is not None:
raise StreamSyncError("reset with the watchdog running")
for a in (self.gathered_host, self.released_host, self.claimed_host, self.consumed_host):
self.lib.dsv41_hf_set(_V(a), 0)
def describe(self) -> dict:
return {"mechanism": "host_flags", "lib": self.lib_origin, "spin_us": self.spin_ns // 1000,
"nap_us": self.nap_ns // 1000, "mlock": "ok" if self.mlock_errno == 0 else f"errno {self.mlock_errno}",
"watchdog_deadline_ms": self.deadline_ms or None}
def _at_exit(self) -> None:
# interpreter shutdown: nothing may stay parked on a round no thread will release (no free: the stream may
# still copy the reply during teardown)
if self.page:
self.fail_ahead()
self.stop_watchdog()
def close(self) -> None:
"""Only after the round stopped and the stream drained (nothing may copy from the page afterwards)."""
self.stop_watchdog()
if self.page:
if self.owns_page:
self.lib.dsv41_hf_free(_V(self.page))
self.page = 0
|