workflow-studio / worker_probe.py
owenisas's picture
Parent-side worker probe (/proc state, SIGUSR1 stack dump); watchdog covers stage prelude
3b5f15c verified
Raw History Blame Contribute Delete
3.69 kB
"""Watch forked ZeroGPU workers from the parent process (Linux /proc only).
A worker that stalls before its first output leaves nothing in the logs, and a
frozen process cannot report on itself. While a GPU call is pending, the parent
logs each python child's state, per-thread kernel wait channels, CPU time and
I/O. When a worker makes no progress between two looks, the parent sends it
SIGUSR1 once; faulthandler, registered before the fork, then dumps every
Python thread's stack from inside the worker.
"""
from __future__ import annotations
from collections import Counter
import os
from pathlib import Path
import signal
PROC = Path("/proc")
# No progress: under 5% of one CPU and under 1 MiB of reads/writes per interval.
STALL_CPU_FRACTION = 0.05
STALL_IO_BYTES = 1 << 20
def _stat(text):
"""(comm, fields after comm); comm may itself contain spaces or parentheses."""
end = text.rindex(")")
return text[text.index("(") + 1:end], text[end + 2:].split()
def child_pids(parent=None, proc=PROC):
parent = os.getpid() if parent is None else parent
pids = []
for entry in proc.iterdir():
if not entry.name.isdigit():
continue
try:
comm, fields = _stat((entry / "stat").read_text())
except (OSError, ValueError):
continue
if int(fields[1]) == parent and "python" in comm:
pids.append(int(entry.name))
return sorted(pids)
def snapshot(pid, proc=PROC):
base = proc / str(pid)
_, fields = _stat((base / "stat").read_text())
try:
io = dict(line.split(": ", 1) for line in (base / "io").read_text().splitlines())
io_bytes = int(io["rchar"]) + int(io["wchar"])
except (OSError, KeyError, ValueError):
io_bytes = None
threads = Counter()
for task in (base / "task").iterdir():
try:
state = _stat((task / "stat").read_text())[1][0]
wchan = (task / "wchan").read_text().strip() or "-"
except (OSError, ValueError, IndexError):
continue
threads[f"{state}:{wchan}"] += 1
return {"state": fields[0], "cpu_ticks": int(fields[11]) + int(fields[12]),
"io_bytes": io_bytes, "threads": dict(threads)}
def stalled(before, now, interval, ticks_per_s):
if before is None:
return False
cpu_s = (now["cpu_ticks"] - before["cpu_ticks"]) / ticks_per_s
io = None if None in (now["io_bytes"], before["io_bytes"]) else now["io_bytes"] - before["io_bytes"]
return cpu_s < STALL_CPU_FRACTION * interval and (io is None or io < STALL_IO_BYTES)
def watch(stop, interval, proc=PROC, kill=os.kill):
"""Until stop is set, log every python child each interval; dump a stalled one once."""
ticks_per_s = os.sysconf("SC_CLK_TCK")
last, dumped = {}, set()
while not stop.wait(interval):
for pid in child_pids(proc=proc):
try:
now = snapshot(pid, proc)
except (OSError, ValueError, IndexError):
continue
stuck = stalled(last.get(pid), now, interval, ticks_per_s)
last[pid] = now
threads = ",".join(f"{key}x{count}" for key, count in sorted(now["threads"].items()))
io_mib = "?" if now["io_bytes"] is None else now["io_bytes"] >> 20
print(f"worker_probe pid={pid} state={now['state']} cpu_s={now['cpu_ticks'] / ticks_per_s:.0f} "
f"io_mib={io_mib} stalled={stuck} threads={threads}", flush=True)
if stuck and pid not in dumped:
dumped.add(pid)
try:
kill(pid, signal.SIGUSR1)
except OSError:
pass