File size: 25,960 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 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 | # SPDX-License-Identifier: Apache-2.0
"""Out-of-process async proxy (VLLM_DSPARK_REMOTE_ASYNC_PROXY=process). torch-free: the proxy process imports this
package as a top-level package and never imports vllm or torch.
Why a process (directive 2026-10-23 §3): the thread proxy's success path - wait for the gather, network round,
publish - is Python, so it needs the engine's GIL; during prefill the engine thread holds the GIL for long stretches
and the round's publication waits for it (GB10: +4-8 % TTFT, 31 watchdog-preempted rounds of 5,113 in the async
compact set). Here the same reviewed round code (roundcore.RoundCore, unchanged) runs in a child interpreter with its
own GIL; it shares nothing with the engine but the memory region (shmregion.py) and the C hand-off words (hostflag.c).
Its success path is: acquire-wait on the descriptor and gathered words, the round, one GIL-free C publish into the
shared page. No step needs the engine's GIL, a CUDA call or an engine-held lock.
engine thread: gather -> D2H into the region -> descriptor t -> desc_head = t -> [stream: gathered = t,
wait released >= t, H2D reply] -> return (never blocks)
proxy process: wait desc_head >= t -> copy descriptor, desc_tail = t -> wait gathered >= t -> RoundCore round
-> publish(t) (claim, copy, release: hostflag.c)
engine watchdog (C thread in the engine process): a round gathered but unreleased past the deadline is claimed,
failed and released, exactly as with the thread proxy.
Ownership of round t's completion is the claim word (one winner: proxy publish, watchdog, or a fail-all). Failure of
the proxy process is seen by the engine at the next round (Popen.poll): fail-all far ahead, then target-only rounds,
the same end state as a dead proxy thread (S9 F2). The proxy watches its parent (getppid, and PR_SET_PDEATHSIG when
started from the main thread) and exits when the engine is gone.
"""
from __future__ import annotations
import ctypes
import dataclasses
import json
import logging
import os
import signal
import subprocess
import sys
import time
import numpy as np
from . import wire
from .breaker import CircuitBreaker
from .client import RemoteDraftClient
from .config import RemoteConfig
from .hostflag import _M32, _V, HostFlagCore, set_timer_slack
from .roundcore import STATUS_ROUND_FAILED, BatchView, ReplyLayout, RoundCore, collect_meta
from .shmregion import (C_DESC_HEAD, C_DESC_TAIL, C_DONE, C_ENGINE_SEQ, C_FLUSH_ACK, C_FLUSH_REQ, C_HEARTBEAT, C_PID,
C_START, C_STATE, C_STOP, C_WD, F_ENQUEUE_FAILED, R_FAILED, R_OK, R_PENDING, S_FAILED,
S_RUNNING, S_STOPPED, RegionLayout, SharedRegion)
from .state import SlotTable
from .stats import Stats
from .transport import Endpoint
log = logging.getLogger("vllm.remote_dspark")
PR_SET_PDEATHSIG = 1
DESC_SPIN_NS = 200_000 # descriptor wait: spin 200 us, then 100 us naps (the gather leaves that much slack)
DESC_NAP_NS = 100_000
POLL_S = 0.1 # every wait wakes this often to check stop / parent / flush requests
START_TIMEOUT_S = 60.0 # interpreter start + imports (+ connect and HELLO when strict)
# ---------------------------------------------------------------------------------------------------- init blob
def _enc(v):
if isinstance(v, bytes):
return {"__hex__": v.hex()}
if isinstance(v, dict):
return {k: _enc(x) for k, x in v.items()}
if isinstance(v, (list, tuple)):
return [_enc(x) for x in v]
return v
def _dec(v):
if isinstance(v, dict):
if set(v) == {"__hex__"}:
return bytes.fromhex(v["__hex__"])
return {k: _dec(x) for k, x in v.items()}
if isinstance(v, list):
return [_dec(x) for x in v]
return v
def encode_init(cfg: RemoteConfig, hello: wire.Hello, layout: RegionLayout, *, n_out_by_reqs, lib_path: str,
spin_us: int, nap_us: int, watchdog_ms: int, strict: bool, parent_pid: int, pdeathsig: bool,
start_round: int = 1, session: int | None = None, base_counters: dict | None = None,
generation: int = 0) -> bytes:
"""start_round: the first round this proxy serves (a respawned proxy continues the engine's numbering);
session: its worker session id; base_counters: its predecessors' counters, carried on in its stats file;
generation: 0 for the first proxy, k for the k-th respawn."""
return json.dumps(_enc({
"cfg": dataclasses.asdict(cfg), "hello": dataclasses.asdict(hello), "layout": layout.to_dict(),
"n_out_by_reqs": list(n_out_by_reqs) if n_out_by_reqs else None, "lib_path": lib_path,
"spin_us": int(spin_us), "nap_us": int(nap_us), "watchdog_ms": int(watchdog_ms), "strict": bool(strict),
"parent_pid": int(parent_pid), "pdeathsig": bool(pdeathsig), "start_round": int(start_round),
"session": session, "base_counters": dict(base_counters or {}), "generation": int(generation)})).encode()
def decode_init(data: bytes) -> dict:
d = _dec(json.loads(data.decode()))
c = d["cfg"]
c["endpoint"] = Endpoint(**c["endpoint"])
d["cfg"] = RemoteConfig(**c)
h = d["hello"]
h["tap_layer_ids"] = tuple(h["tap_layer_ids"])
d["hello"] = wire.Hello(**h)
d["layout"] = RegionLayout(**d["layout"])
return d
ENGINE_OWNED = ("async_dead",) # the engine decides whether a proxy serves (a dying proxy also records its reason)
def merge_snapshots(proxy: dict, engine: dict | None) -> dict:
"""One stats file from the proxy's snapshot and the engine side's (counters add up; histograms and extras are
disjoint by construction, the proxy's win on a clash except ENGINE_OWNED)."""
if not engine:
return proxy
out = {k: v for k, v in engine.items() if k not in ("counters", "latency", "uptime_s")}
out.update({k: v for k, v in proxy.items() if k not in ("counters", "latency")})
for k in ENGINE_OWNED:
if k in engine:
out[k] = engine[k]
cnt = dict(engine.get("counters") or {})
for k, v in (proxy.get("counters") or {}).items():
cnt[k] = cnt.get(k, 0) + v
out["counters"] = dict(sorted(cnt.items()))
lat = dict(engine.get("latency") or {})
lat.update(proxy.get("latency") or {})
out["latency"] = dict(sorted(lat.items()))
# what the engine side added, so that the engine can rewrite the file without counting it twice
out["engine_share"] = {"counters": dict(engine.get("counters") or {}),
"latency": sorted((engine.get("latency") or {}).keys())}
return out
def unmerge_snapshot(merged: dict) -> dict:
"""The proxy's part of a file merge_snapshots() wrote (the inverse of the engine share)."""
share = merged.get("engine_share") or {}
out = {k: v for k, v in merged.items() if k != "engine_share"}
eng_c = share.get("counters") or {}
out["counters"] = {k: v - eng_c.get(k, 0) for k, v in (merged.get("counters") or {}).items()
if v - eng_c.get(k, 0)}
eng_l = set(share.get("latency") or ())
out["latency"] = {k: v for k, v in (merged.get("latency") or {}).items() if k not in eng_l}
return out
def write_json_atomic(path: str, obj: dict) -> None:
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w") as f:
json.dump(obj, f, indent=1, default=str)
os.replace(tmp, path)
# ---------------------------------------------------------------------------------------------------- proxy side
class _ProxyFlags(HostFlagCore):
"""The proxy process's view of the engine's hand-off page: wait, publish, fail-all. The watchdog runs in the
engine process; its stats are mirrored into the control block every round."""
def __init__(self, region: SharedRegion, lib_path: str, spin_us: int, nap_us: int, reply_bytes: int,
watchdog_ms: int):
super().__init__(lib_path, spin_us, nap_us, page=region.page_addr)
self.configure_reply(reply_bytes, STATUS_ROUND_FAILED)
self.region = region
self.deadline_ms = int(watchdog_ms)
def attach(self, *a, **k) -> None:
raise RuntimeError("the watchdog belongs to the engine process")
def watchdog_stats(self) -> dict:
fires, last, late = (int(x) for x in self.region.u64(C_WD, 3))
return {"running": True, "deadline_ms": self.deadline_ms, "fires": fires, "last_failed_round": last,
"max_late_ms": round(late / 1e6, 3), "mirrored": True}
def describe(self) -> dict:
d = super().describe()
d["mechanism"] = "host_flags_shared"
return d
class _RegionRows:
"""What RoundCore reads of the gatherer, from the region: the STEP message buffer and the gather metadata."""
def __init__(self, region: SharedRegion):
L = region.layout
self.region = region
self.msg_np = region.msg_np
self.max_rows = L.max_rows
self.row_bytes = L.row_bytes
def collect(self, n: int, n_candidates: int, meta_slot: int = 0):
return collect_meta(self.region.meta_np[meta_slot], n, n_candidates)
def gather_from_ring(self, n_req, spans):
raise RuntimeError("synchronous resync in the process proxy")
def monotonic_ms() -> int:
"""CLOCK_MONOTONIC in ms as a u32 (the heartbeat's clock; system-wide, so the engine compares it with its own)."""
return int(time.monotonic() * 1000) & _M32
class _Housekeeping:
"""RoundCore._stop for the proxy process: polled at least every POLL_S while it waits and once per round. Writes
the heartbeat (C_HEARTBEAT, S11 P-3), serves stats flush requests; reports stop when the engine asked for it or
the engine process is gone."""
def __init__(self, rnd: "ProcessRound"):
self.r = rnd
self.lib = rnd.sync.lib
self._stop_w = _V(rnd.region.word(C_STOP))
self._flush_w = _V(rnd.region.word(C_FLUSH_REQ))
self._hb_w = _V(rnd.region.word(C_HEARTBEAT))
self._flushed = 0
def beat(self) -> None:
self.lib.dsv41_hf_set(self._hb_w, monotonic_ms())
def is_set(self) -> bool:
self.beat()
req = int(self.lib.dsv41_hf_load(self._flush_w))
if req != self._flushed:
self._flushed = req
try:
self.r.write_stats()
finally:
self.lib.dsv41_hf_store(_V(self.r.region.word(C_FLUSH_ACK)), req)
if self.lib.dsv41_hf_load(self._stop_w):
return True
return os.getppid() != self.r.parent_pid
class ProcessRound(RoundCore):
"""The proxy process's round: RoundCore fed from the descriptor ring instead of the thread proxy's queue."""
def __init__(self, region: SharedRegion, cfg: RemoteConfig, hello: wire.Hello, *, n_out_by_reqs, lib_path: str,
spin_us: int, nap_us: int, watchdog_ms: int, parent_pid: int, background: bool,
transport_factory=None, start_round: int = 1, session: int | None = None,
base_counters: dict | None = None, generation: int = 0):
self.cfg = cfg
self.hello = hello
self.region = region
self.parent_pid = parent_pid
self.start_round = int(start_round)
self.stats = Stats()
for k, v in (base_counters or {}).items(): # a respawned proxy carries its predecessors' counts
self.stats.inc(k, int(v))
self.N, self.W, self.M = hello.num_spec, hello.window, hello.max_slots
self.layout = ReplyLayout(self.M, self.N, cfg.sampled_top32)
self.bcast_np = np.zeros(self.layout.size, dtype=np.int32)
self.n_out_by_reqs = list(n_out_by_reqs) if n_out_by_reqs else None
self.rounds = self.start_round - 1
self._last_epoch = 0
self._last_log = time.monotonic()
self._err_logged = 0.0
self.is_sender = True
self.async_mode = True
L = region.layout
if (L.max_slots, L.window, L.row_bytes, L.max_rows) != (self.M, self.W, hello.row_bytes,
hello.max_rows_per_step):
raise ValueError(f"region layout {L} does not match the HELLO")
self.gatherer = _RegionRows(region)
self._meta_slots = L.ring
self.resync_np = region.resync_np
self.slots = SlotTable(self.M, self.W)
self.breaker = CircuitBreaker(cfg.trip, cfg.cooldown)
self.stats.set("breaker", self.breaker.snapshot())
self.sync = _ProxyFlags(region, lib_path, spin_us, nap_us, self.layout.size * 4, watchdog_ms)
self.lib = self.sync.lib
self._staged: frozenset[int] = frozenset()
self._dead: str | None = None
self.client = RemoteDraftClient(cfg, hello, stats=self.stats, transport_factory=transport_factory,
background=background, session=session)
self.stats.set("contract_version", wire.CONTRACT_VERSION)
self.stats.set("policy", cfg.policy)
self.stats.set("endpoint", str(cfg.endpoint))
self.stats.set("n_out_by_reqs", self.n_out_by_reqs)
# the engine's "async_proxy" (region, mlock, current pid) stays authoritative in the merged stats file
self.stats.set("async_proxy_process", {"pid": os.getpid(), "generation": int(generation),
"start_round": self.start_round})
self._stop = _Housekeeping(self)
self._engine_seq = 0
self._engine_snap: dict | None = None
# resync requests: one byte per slot in the control block (engine reads them at submit)
def _request_resync(self, slots: set[int]) -> None:
f = self.region.resync_flags
for sl in slots:
f[sl] = 1
def _clear_resync(self, staged: frozenset[int]) -> None:
f = self.region.resync_flags
for sl in staged:
f[sl] = 0
def _wait_descriptor(self, t: int) -> bool:
head = _V(self.region.word(C_DESC_HEAD))
while not self._stop.is_set():
if self.lib.dsv41_hf_wait_geq(head, t & _M32, int(POLL_S * 1e9), DESC_SPIN_NS, DESC_NAP_NS) == 0:
return True
return False
def _take(self, t: int):
d = self.region.read_descriptor(t)
if d.flags & F_ENQUEUE_FAILED:
return (t, None, 0, frozenset())
bv = BatchView(req_ids=d.owners, slots=d.slots, qsl=np.zeros(0, dtype=np.int64),
need_draft=d.need_draft, k_window=d.k_window, max_q=d.max_q)
return (t, bv, d.R, d.staged)
def serve(self) -> None:
"""Rounds start_round, start_round + 1, ... until stopped. Raises on a broken hand-off (the caller fails
everything closed)."""
t = self.start_round
tail = _V(self.region.word(C_DESC_TAIL))
done = _V(self.region.word(C_DONE))
while True:
if not self._wait_descriptor(t):
return
item = self._take(t)
self.rounds = t
if not self._async_round(item):
return
# slot t % K (descriptor, metadata) may be reused only now (shmregion.py)
self.lib.dsv41_hf_store(tail, t & _M32)
self.lib.dsv41_hf_store(done, t & _M32)
t += 1
def _engine_snapshot(self) -> dict | None:
seq = int(self.lib.dsv41_hf_load(_V(self.region.word(C_ENGINE_SEQ))))
if seq and seq != self._engine_seq:
try:
self._engine_snap = json.loads(self.region.read_engine_blob(seq).decode())
self._engine_seq = seq
except (ValueError, UnicodeDecodeError):
pass # overwritten while read (stats only): the previous snapshot stands
return self._engine_snap
def write_stats(self) -> None:
if not self.cfg.stats_file:
return
self.stats.set("rounds", self.rounds)
self.stats.set("breaker", self.breaker.snapshot())
self.stats.set("async_watchdog", self.sync.watchdog_stats())
self.stats.set("proxy_torch_loaded", "torch" in sys.modules) # evidence: the proxy never imports it
try:
write_json_atomic(self.cfg.stats_file, merge_snapshots(self.stats.snapshot(), self._engine_snapshot()))
except OSError as e:
log.warning("remote DSpark stats file %s: %s", self.cfg.stats_file, e)
def _pdeathsig() -> None:
try:
libc = ctypes.CDLL(None, use_errno=True)
libc.prctl(PR_SET_PDEATHSIG, signal.SIGKILL, 0, 0, 0)
except (OSError, AttributeError):
pass
def _store(region: SharedRegion, lib, off: int, value: int) -> None:
lib.dsv41_hf_set(_V(region.word(off)), value & _M32)
def main(fd: int, init: bytes | None = None, transport_factory=None) -> int:
"""Proxy process entry (BOOT below): the init blob comes on stdin."""
d = decode_init(init if init is not None else sys.stdin.buffer.read())
if d["pdeathsig"]:
_pdeathsig()
set_timer_slack() # short naps (ASYNC_NAP_US > 0) wake on time
if os.getppid() != d["parent_pid"]:
return 5 # the engine is already gone
logging.basicConfig(level=logging.INFO, stream=sys.stderr,
format="(remote-dspark proxy pid=%(process)d) %(levelname)s %(asctime)s %(message)s")
region = SharedRegion.attach(d["layout"], fd)
region.u32(C_PID)[0] = os.getpid()
cfg: RemoteConfig = d["cfg"]
rnd = None
lib = None
try:
rnd = ProcessRound(region, cfg, d["hello"], n_out_by_reqs=d["n_out_by_reqs"], lib_path=d["lib_path"],
spin_us=d["spin_us"], nap_us=d["nap_us"], watchdog_ms=d["watchdog_ms"],
parent_pid=d["parent_pid"], background=not d["strict"],
transport_factory=transport_factory, start_round=d.get("start_round", 1),
session=d.get("session"), base_counters=d.get("base_counters"),
generation=d.get("generation", 0))
lib = rnd.lib
if d["strict"]:
if not rnd.client.connect_now():
raise RuntimeError(f"connect/HELLO to {cfg.endpoint} failed at startup "
f"({rnd.client.disabled_reason or rnd.client.last_error})")
rnd.client.start_background()
except BaseException as e: # noqa: BLE001 -- the engine reads the reason and raises
region.set_start_reason(f"{type(e).__name__}: {e}")
if lib is not None:
_store(region, lib, C_START, R_FAILED)
_store(region, lib, C_STATE, S_FAILED)
else:
region.u32(C_START)[0] = R_FAILED
region.u32(C_STATE)[0] = S_FAILED
if rnd is not None:
rnd.client.close()
return 2
rnd._stop.beat()
_store(region, lib, C_START, R_OK)
_store(region, lib, C_STATE, S_RUNNING)
log.info("remote DSpark proxy process running (engine pid %d, %s)", d["parent_pid"], rnd.sync.describe())
code = 0
try:
rnd.serve()
except BaseException as e: # noqa: BLE001 -- whatever ends the proxy must not park the GPU
rnd._die(f"proxy process: {type(e).__name__}: {e}")
code = 4
finally:
try:
rnd.write_stats()
finally:
rnd.client.close()
_store(region, lib, C_STATE, S_FAILED if code else S_STOPPED)
return code
# ---------------------------------------------------------------------------------------------------- engine side
class ProxyStartError(RuntimeError):
"""The proxy process did not start (spawn, interpreter, imports, start timeout; strict: connect + HELLO). The engine
then runs synchronous rounds unless the start is strict (round.make_remote_round, S11 P-2)."""
# the proxy's interpreter imports this package from its directory as the top-level package "remote_dspark" (never
# through vllm: no torch, no CUDA in the proxy)
BOOT = """import importlib.util, sys
pkg, fd = sys.argv[1], int(sys.argv[2])
spec = importlib.util.spec_from_file_location("remote_dspark", pkg + "/__init__.py", submodule_search_locations=[pkg])
mod = importlib.util.module_from_spec(spec)
sys.modules["remote_dspark"] = mod
spec.loader.exec_module(mod)
from remote_dspark.proxy_proc import main
sys.exit(main(fd))
"""
class ProxyProcess:
"""Engine side: start the proxy process on a region, watch it, stop it. CPU only."""
def __init__(self, region: SharedRegion, lib, init: bytes, *, python: str | None = None):
self.region = region
self.lib = lib
pkg = os.path.dirname(os.path.abspath(__file__))
# -I: isolated (no PYTHONPATH / user site / working directory on sys.path): the proxy imports only this
# package (by path) and the interpreter's own site packages
self.proc = subprocess.Popen([python or sys.executable, "-I", "-c", BOOT, pkg, str(region.fd)],
stdin=subprocess.PIPE, pass_fds=(region.fd,), close_fds=True)
try:
self.proc.stdin.write(init)
self.proc.stdin.close()
except OSError:
pass # died at once: wait_started() reports it
self._flush = 0
self._engine_seq = 0
self._blob_warned = False
@property
def pid(self) -> int:
return self.proc.pid
def word(self, off: int) -> int:
return int(self.lib.dsv41_hf_load(_V(self.region.word(off))))
def start_status(self) -> tuple[str, str]:
"""Without waiting: ("ok", ""), ("pending", "") or ("failed", why) (it reported a failure or exited)."""
r = self.word(C_START)
if r == R_OK:
return "ok", ""
if r == R_FAILED:
return "failed", f"remote DSpark proxy process failed to start: {self.region.start_reason()}"
rc = self.proc.poll()
if rc is not None:
return "failed", f"remote DSpark proxy process exited with {rc} before it started"
return "pending", ""
def wait_started(self, timeout_s: float = START_TIMEOUT_S) -> None:
"""Until the proxy reports its start (client built; when strict: connected + HELLO). Raises ProxyStartError
when it failed, exited or did not report in time; the process is then gone or killed."""
t_end = time.monotonic() + timeout_s
while True:
state, why = self.start_status()
if state == "ok":
return
if state == "failed":
self.kill(2.0)
raise ProxyStartError(why)
if time.monotonic() > t_end:
self.kill()
raise ProxyStartError(f"remote DSpark proxy process did not start within {timeout_s:.0f} s")
time.sleep(0.005)
def exit_code(self) -> int | None:
return self.proc.poll()
def tail(self) -> int:
return self.word(C_DESC_TAIL)
def heartbeat_age_ms(self) -> int:
"""Milliseconds since the proxy's last housekeeping poll (0 if its clock read is a hair ahead of ours)."""
d = (monotonic_ms() - self.word(C_HEARTBEAT)) & _M32
return d if d < (1 << 31) else 0
def publish_head(self, t: int) -> None:
self.lib.dsv41_hf_store(_V(self.region.word(C_DESC_HEAD)), t & _M32)
def resync_requests(self) -> list[int]:
return [int(i) for i in np.flatnonzero(self.region.resync_flags)]
def publish_engine_stats(self, snapshot: dict) -> None:
data = json.dumps(snapshot, default=str).encode()
seq = self._engine_seq + 1
if self.region.write_engine_blob(seq, data):
self._engine_seq = seq
self.lib.dsv41_hf_store(_V(self.region.word(C_ENGINE_SEQ)), seq & _M32)
elif not self._blob_warned:
self._blob_warned = True
log.warning("remote DSpark engine-side stats (%d B) do not fit the shared region's blob; the stats file "
"keeps the last engine snapshot that did", len(data))
def flush(self, timeout_s: float = 2.0) -> bool:
"""Ask the proxy to write the stats file now; True once it did."""
if self.proc.poll() is not None:
return False
self._flush += 1
self.lib.dsv41_hf_store(_V(self.region.word(C_FLUSH_REQ)), self._flush & _M32)
r = self.lib.dsv41_hf_wait_geq(_V(self.region.word(C_FLUSH_ACK)), self._flush & _M32, int(timeout_s * 1e9),
200_000, 1_000_000)
return r == 0
def stop(self, timeout_s: float = 5.0) -> int | None:
self.lib.dsv41_hf_store(_V(self.region.word(C_STOP)), 1)
try:
return self.proc.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
self.kill()
return None
def signal_kill(self) -> None:
"""SIGKILL, without waiting: the engine thread must not block on a child that may sit in uninterruptible
sleep (S11 P-6). exit_code() reaps it later. A killed process runs no more user code, so it can no longer
write the region (it may still be finishing a system call)."""
if self.proc.poll() is None:
self.proc.kill()
def kill(self, wait_s: float = 5.0) -> None:
"""SIGKILL and wait for the exit at most wait_s (startup and shutdown only; the round path uses signal_kill)."""
self.signal_kill()
if self.proc.returncode is None:
try:
self.proc.wait(timeout=wait_s)
except subprocess.TimeoutExpired:
log.error("remote DSpark proxy process %d did not exit within %.0f s of SIGKILL", self.pid, wait_s)
__all__ = ["ProcessRound", "ProxyProcess", "ProxyStartError", "encode_init", "decode_init", "merge_snapshots", "main",
"R_PENDING"]
|