gpu-container 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,563 @@
1
+ """gpu-container-watchdog — a rig-safety control plane for GPU workloads.
2
+
3
+ Two ways to use it:
4
+
5
+ 1. **Monitor** (one-shot or `--watch`) — poll GPU + host metrics, compare against thresholds, emit
6
+ an ok/warn/ABORT verdict (exit 0/5/7) that an AI agent or a `--watch` loop reads to halt a job.
7
+
8
+ 2. **Supervisor** (`run -- <command...>`) — launch a GPU job AS A CHILD, poll metrics in parallel
9
+ while it runs, and on a hard breach act: `kill-job` terminates just the child (a soft abort);
10
+ `wsl-shutdown` nukes the whole VM (the catastrophic case). This makes "run a GPU job safely" one
11
+ command, and records the run's PEAK envelope for the receipt (proof it stayed inside the limits).
12
+
13
+ Born from the 2026-06-04 incident: a too-large MoE drove host memory to 92-98% and throttled the
14
+ machine for over a minute. The lesson, institutionalized — size + WATCH every GPU run, and abort the
15
+ instant a hard threshold is crossed. On a WSL2 rig the abort of record is `wsl --shutdown` (instant;
16
+ frees all VM RAM in ~5s). The monitor's DEFAULT action is `alert` (surface, never auto-kill); the
17
+ supervisor's default is `kill-job` (stop just the job it launched).
18
+
19
+ Metrics (None when unavailable — never guessed):
20
+ - GPU via `nvidia-smi`: power draw vs board limit (%), temperature, VRAM used/total (%), utilization.
21
+ Multi-GPU rigs are folded WORST-CASE (the hottest/fullest/most-drawing GPU drives the verdict).
22
+ - Host via `psutil` (optional dep): memory % used, available MiB — THE incident metric. `mem_source`
23
+ tags whether psutil is reading the WINDOWS HOST (run the watchdog on Windows — the incident metric
24
+ is the host) or a WSL2 VM / Linux container (run in-container — host coverage is then partial).
25
+
26
+ VRAM source note: the watchdog reads VRAM via `nvidia-smi memory.used`, which INCLUDES driver-reserved
27
+ memory; the profiler (`gpu_container.profiler.hardware`) prefers pynvml v2, which separates `reserved`
28
+ from `used`. They agree on `total`; the watchdog's `used` runs a touch higher. That is deliberate — a
29
+ safety monitor should read conservative (over-, not under-count), and `nvidia-smi` is always present
30
+ on the host where pynvml may not be installed.
31
+
32
+ Exit code (ANDON, scriptable): 0 = ok, 5 = warn (approaching a limit), 7 = ABORT (a hard limit crossed).
33
+ For `run`: 7 = a breach aborted the job; 0 = the job finished with no breach; otherwise the job's own
34
+ non-zero exit code (a job that failed on its own, no watchdog involvement).
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import platform
41
+ import subprocess
42
+ import sys
43
+ import time
44
+ from dataclasses import asdict, dataclass, field
45
+ from typing import List, Optional
46
+
47
+ from .errors import GpuContainerError, guard
48
+
49
+
50
+ @dataclass
51
+ class Thresholds:
52
+ """Hard limits; a breach => abort. Within `warn_fraction` of a max (or its reciprocal for a min)
53
+ => warn. Defaults tuned for the Robot rig (RTX 5090 / 64 GB / WSL2 28 GB cap) and the incident."""
54
+ power_pct_max: float = 95.0 # GPU power draw as % of the board power limit
55
+ gpu_temp_c_max: float = 87.0
56
+ vram_pct_max: float = 98.0
57
+ host_mem_pct_max: float = 90.0 # the incident hit 92-98%; abort with margin
58
+ host_avail_mib_min: float = 2000.0 # abort if free host/VM RAM drops below this
59
+ warn_fraction: float = 0.9
60
+
61
+ def to_dict(self) -> dict:
62
+ return asdict(self)
63
+
64
+
65
+ @dataclass
66
+ class Sample:
67
+ gpu_power_w: Optional[float] = None
68
+ gpu_power_limit_w: Optional[float] = None
69
+ gpu_power_pct: Optional[float] = None
70
+ gpu_temp_c: Optional[float] = None
71
+ gpu_vram_used_mib: Optional[float] = None
72
+ gpu_vram_total_mib: Optional[float] = None
73
+ gpu_vram_pct: Optional[float] = None
74
+ gpu_util_pct: Optional[float] = None
75
+ gpu_count: Optional[int] = None # GPUs seen; metrics above are the WORST-CASE across them
76
+ host_mem_pct: Optional[float] = None
77
+ host_avail_mib: Optional[float] = None
78
+ mem_source: Optional[str] = None # "windows-host" | "wsl2-vm" | "linux" — what psutil read
79
+ notes: List[str] = field(default_factory=list)
80
+
81
+ def to_dict(self) -> dict:
82
+ return asdict(self)
83
+
84
+
85
+ @dataclass
86
+ class Breach:
87
+ metric: str
88
+ value: float
89
+ threshold: float
90
+ level: str # "warn" | "abort"
91
+
92
+
93
+ @dataclass
94
+ class WatchdogReport:
95
+ verdict: str # "ok" | "warn" | "abort"
96
+ breaches: List[Breach] = field(default_factory=list)
97
+ sample: Optional[Sample] = None
98
+ action_taken: Optional[str] = None
99
+
100
+ def to_dict(self) -> dict:
101
+ return asdict(self)
102
+
103
+ def to_json(self, indent: int = 2) -> str:
104
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
105
+
106
+
107
+ def _num(s: str) -> Optional[float]:
108
+ try:
109
+ return float((s or "").strip())
110
+ except ValueError:
111
+ return None # "[N/A]" and friends -> unknown, never 0
112
+
113
+
114
+ def _max(a: Optional[float], b: Optional[float]) -> Optional[float]:
115
+ """None-safe max: a missing reading never lowers (or raises) a peak."""
116
+ if b is None:
117
+ return a
118
+ if a is None:
119
+ return b
120
+ return max(a, b)
121
+
122
+
123
+ def _min(a: Optional[float], b: Optional[float]) -> Optional[float]:
124
+ if b is None:
125
+ return a
126
+ if a is None:
127
+ return b
128
+ return min(a, b)
129
+
130
+
131
+ def sample_nvidia_smi(run=subprocess.run) -> Sample:
132
+ """GPU metrics via nvidia-smi, folded WORST-CASE across all GPUs (the safety-relevant view).
133
+
134
+ Each reported percentage keeps its own GPU's absolute pair (power_w/limit, vram_used/total) so the
135
+ numbers stay coherent. Returns an all-None Sample (+ a note) if nvidia-smi is unavailable.
136
+ """
137
+ s = Sample()
138
+ q = "power.draw,power.limit,temperature.gpu,memory.used,memory.total,utilization.gpu"
139
+ try:
140
+ out = run(["nvidia-smi", f"--query-gpu={q}", "--format=csv,noheader,nounits"],
141
+ capture_output=True, text=True, timeout=10)
142
+ except (OSError, subprocess.SubprocessError):
143
+ s.notes.append("nvidia-smi unavailable — GPU metrics unknown")
144
+ return s
145
+ if out.returncode != 0:
146
+ s.notes.append(f"nvidia-smi exit {out.returncode} — GPU metrics unknown")
147
+ return s
148
+ rows = (out.stdout or "").strip().splitlines()
149
+ if not rows:
150
+ s.notes.append("nvidia-smi returned no rows")
151
+ return s
152
+
153
+ gpus = []
154
+ for line in rows:
155
+ p = [x.strip() for x in line.split(",")]
156
+ if len(p) < 6:
157
+ continue
158
+ pw, lim, temp, used, total, util = (_num(p[0]), _num(p[1]), _num(p[2]),
159
+ _num(p[3]), _num(p[4]), _num(p[5]))
160
+ gpus.append({
161
+ "power_w": pw, "limit_w": lim, "temp_c": temp,
162
+ "vram_used": used, "vram_total": total, "util": util,
163
+ "power_pct": round(100.0 * pw / lim, 1) if (pw and lim) else None,
164
+ "vram_pct": round(100.0 * used / total, 1) if (used and total) else None,
165
+ })
166
+ if not gpus:
167
+ s.notes.append("nvidia-smi rows had too few fields — GPU metrics unknown")
168
+ return s
169
+
170
+ s.gpu_count = len(gpus)
171
+ # power: the GPU drawing the highest % of its limit (carry its absolute w + limit for a coherent pair)
172
+ pw_gpus = [g for g in gpus if g["power_pct"] is not None]
173
+ if pw_gpus:
174
+ g = max(pw_gpus, key=lambda g: g["power_pct"])
175
+ s.gpu_power_w, s.gpu_power_limit_w, s.gpu_power_pct = g["power_w"], g["limit_w"], g["power_pct"]
176
+ # vram: the fullest GPU (carry its used + total)
177
+ vr_gpus = [g for g in gpus if g["vram_pct"] is not None]
178
+ if vr_gpus:
179
+ g = max(vr_gpus, key=lambda g: g["vram_pct"])
180
+ s.gpu_vram_used_mib, s.gpu_vram_total_mib, s.gpu_vram_pct = g["vram_used"], g["vram_total"], g["vram_pct"]
181
+ temps = [g["temp_c"] for g in gpus if g["temp_c"] is not None]
182
+ utils = [g["util"] for g in gpus if g["util"] is not None]
183
+ s.gpu_temp_c = max(temps) if temps else None
184
+ s.gpu_util_pct = max(utils) if utils else None
185
+ if s.gpu_count > 1:
186
+ s.notes.append(f"worst-case across {s.gpu_count} GPUs")
187
+ return s
188
+
189
+
190
+ def _host_source() -> str:
191
+ """Tag what psutil is actually reading. The 2026-06-04 incident metric is WINDOWS HOST memory;
192
+ run the watchdog on the Windows host for true coverage. In a WSL2/Linux container psutil only
193
+ sees the VM, which can sit calm while the host is starved."""
194
+ if platform.system() == "Windows":
195
+ return "windows-host"
196
+ for path in ("/proc/version", "/proc/sys/kernel/osrelease"):
197
+ try:
198
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
199
+ if "microsoft" in f.read().lower():
200
+ return "wsl2-vm"
201
+ except OSError:
202
+ continue
203
+ return "linux"
204
+
205
+
206
+ def sample_host(into: Sample) -> Sample:
207
+ """Host memory via psutil (optional dep). Leaves host metrics None (+ a note) if psutil is absent,
208
+ and tags `mem_source` so the reading's vantage (host vs VM) is never ambiguous."""
209
+ into.mem_source = _host_source()
210
+ try:
211
+ import psutil
212
+ except ImportError:
213
+ into.notes.append("psutil not installed — host memory unknown (pip install psutil); the "
214
+ "incident metric IS host memory, so install it for full coverage")
215
+ return into
216
+ vm = psutil.virtual_memory()
217
+ into.host_mem_pct = round(vm.percent, 1)
218
+ into.host_avail_mib = round(vm.available / (1024 * 1024), 1)
219
+ if into.mem_source != "windows-host":
220
+ into.notes.append(f"psutil is reading the {into.mem_source}, NOT the Windows host — the "
221
+ "2026-06-04 incident metric is HOST memory; run the watchdog on Windows "
222
+ "for true host coverage")
223
+ return into
224
+
225
+
226
+ def sample() -> Sample:
227
+ """One full reading: GPU (nvidia-smi) + host (psutil)."""
228
+ return sample_host(sample_nvidia_smi())
229
+
230
+
231
+ def evaluate(s: Sample, t: Thresholds) -> WatchdogReport:
232
+ """Pure: a Sample + Thresholds -> ok/warn/abort + the breach list. None metrics are skipped."""
233
+ breaches: List[Breach] = []
234
+
235
+ def check_max(value, limit, name):
236
+ if value is None:
237
+ return
238
+ if value >= limit:
239
+ breaches.append(Breach(name, value, limit, "abort"))
240
+ elif value >= t.warn_fraction * limit:
241
+ breaches.append(Breach(name, value, round(t.warn_fraction * limit, 1), "warn"))
242
+
243
+ def check_min(value, limit, name):
244
+ if value is None:
245
+ return
246
+ if value <= limit:
247
+ breaches.append(Breach(name, value, limit, "abort"))
248
+ elif t.warn_fraction and value <= limit / t.warn_fraction:
249
+ breaches.append(Breach(name, value, round(limit / t.warn_fraction, 1), "warn"))
250
+
251
+ check_max(s.gpu_power_pct, t.power_pct_max, "gpu_power_pct")
252
+ check_max(s.gpu_temp_c, t.gpu_temp_c_max, "gpu_temp_c")
253
+ check_max(s.gpu_vram_pct, t.vram_pct_max, "gpu_vram_pct")
254
+ check_max(s.host_mem_pct, t.host_mem_pct_max, "host_mem_pct")
255
+ check_min(s.host_avail_mib, t.host_avail_mib_min, "host_avail_mib")
256
+
257
+ if any(b.level == "abort" for b in breaches):
258
+ verdict = "abort"
259
+ elif any(b.level == "warn" for b in breaches):
260
+ verdict = "warn"
261
+ else:
262
+ verdict = "ok"
263
+ return WatchdogReport(verdict=verdict, breaches=breaches, sample=s)
264
+
265
+
266
+ _EXIT = {"ok": 0, "warn": 5, "abort": 7}
267
+
268
+
269
+ @dataclass
270
+ class PeakTracker:
271
+ """Running worst-case envelope over a supervised run — the proof a job stayed inside the limits.
272
+ Fed to the Receipt (A2) so a receipt can say 'decode 302 tok/s; peak host-mem 31%, peak power 41%
273
+ — within envelope.' None-safe: a missing reading never moves a peak."""
274
+ samples: int = 0
275
+ peak_gpu_power_pct: Optional[float] = None
276
+ peak_gpu_power_w: Optional[float] = None
277
+ peak_gpu_temp_c: Optional[float] = None
278
+ peak_gpu_vram_used_mib: Optional[float] = None
279
+ peak_gpu_vram_pct: Optional[float] = None
280
+ peak_gpu_util_pct: Optional[float] = None
281
+ peak_host_mem_pct: Optional[float] = None
282
+ min_host_avail_mib: Optional[float] = None
283
+
284
+ def update(self, s: Sample) -> "PeakTracker":
285
+ self.samples += 1
286
+ self.peak_gpu_power_pct = _max(self.peak_gpu_power_pct, s.gpu_power_pct)
287
+ self.peak_gpu_power_w = _max(self.peak_gpu_power_w, s.gpu_power_w)
288
+ self.peak_gpu_temp_c = _max(self.peak_gpu_temp_c, s.gpu_temp_c)
289
+ self.peak_gpu_vram_used_mib = _max(self.peak_gpu_vram_used_mib, s.gpu_vram_used_mib)
290
+ self.peak_gpu_vram_pct = _max(self.peak_gpu_vram_pct, s.gpu_vram_pct)
291
+ self.peak_gpu_util_pct = _max(self.peak_gpu_util_pct, s.gpu_util_pct)
292
+ self.peak_host_mem_pct = _max(self.peak_host_mem_pct, s.host_mem_pct)
293
+ self.min_host_avail_mib = _min(self.min_host_avail_mib, s.host_avail_mib)
294
+ return self
295
+
296
+ def to_dict(self) -> dict:
297
+ return asdict(self)
298
+
299
+
300
+ def execute_action(action: str, report: WatchdogReport, run=subprocess.run) -> str:
301
+ """Perform a configured on-breach action that does NOT need the supervised process handle.
302
+ (`kill-job` is handled by the supervisor, which owns the child.) Default 'alert' never kills."""
303
+ if not action or action == "alert":
304
+ return "alert only (no kill) — abort surfaced; a human/AI decides the next move"
305
+ try:
306
+ if action == "wsl-shutdown":
307
+ run(["wsl", "--shutdown"], timeout=30)
308
+ return "ran `wsl --shutdown` (instant VM kill — frees all WSL2 RAM in ~5s)"
309
+ if action.startswith("docker-stop:"):
310
+ name = action.split(":", 1)[1]
311
+ run(["docker", "stop", name], timeout=60)
312
+ return f"ran `docker stop {name}`"
313
+ if action.startswith("kill:"):
314
+ pid = action.split(":", 1)[1]
315
+ run(["kill", "-9", pid], timeout=10)
316
+ return f"killed pid {pid}"
317
+ if action.startswith("command:"):
318
+ cmd = action.split(":", 1)[1]
319
+ run(cmd, shell=True, timeout=60)
320
+ return f"ran `{cmd}`"
321
+ except (OSError, subprocess.SubprocessError) as e:
322
+ return f"action '{action}' FAILED: {e} — INTERVENE MANUALLY"
323
+ return (f"unknown action '{action}' — did nothing "
324
+ "(use alert | kill-job | wsl-shutdown | docker-stop:NAME | kill:PID | command:CMD)")
325
+
326
+
327
+ def _terminate_job(proc, grace: float = 5.0) -> str:
328
+ """Stop a supervised child: terminate() (polite), then kill() if it ignores the grace window.
329
+
330
+ On Windows both map to TerminateProcess; this stops the direct child. For a containerized job
331
+ (e.g. `docker run ...`) the child is the docker client — prefer `--on-breach docker-stop:NAME`
332
+ or `wsl-shutdown` when you need the whole container/VM gone, not just the launcher.
333
+ """
334
+ if proc.poll() is not None:
335
+ return f"job already exited (rc={proc.poll()})"
336
+ try:
337
+ proc.terminate()
338
+ except OSError as e:
339
+ return f"terminate failed: {e} — INTERVENE MANUALLY"
340
+ try:
341
+ proc.wait(timeout=grace)
342
+ return f"terminated the job (rc={proc.poll()})"
343
+ except subprocess.TimeoutExpired:
344
+ try:
345
+ proc.kill()
346
+ proc.wait(timeout=grace)
347
+ return f"job ignored terminate; killed it (rc={proc.poll()})"
348
+ except (OSError, subprocess.SubprocessError) as e:
349
+ return f"kill failed: {e} — INTERVENE MANUALLY"
350
+
351
+
352
+ @dataclass
353
+ class SuperviseResult:
354
+ job_rc: Optional[int]
355
+ verdict: str # "ok" | "abort"
356
+ peaks: PeakTracker
357
+ breached: bool = False
358
+ breach: Optional[WatchdogReport] = None
359
+
360
+
361
+ def supervise(command: List[str], t: Thresholds, *, interval: float = 5.0,
362
+ on_breach: str = "kill-job", popen=subprocess.Popen, sampler=sample,
363
+ run=subprocess.run, sleep=time.sleep, emit=None,
364
+ max_polls: Optional[int] = None) -> SuperviseResult:
365
+ """Launch `command`, poll metrics every `interval`s WHILE IT RUNS, and on a hard breach act:
366
+ 'kill-job' terminates the child (soft abort); anything else goes through execute_action
367
+ (wsl-shutdown / docker-stop / ...) AND still stops the child. Tracks the run's peak envelope.
368
+
369
+ Every external dependency (popen, sampler, run, sleep) is injectable, so the whole loop is
370
+ unit-testable with a fake process and no real GPU.
371
+ """
372
+ proc = popen(command)
373
+ peaks = PeakTracker()
374
+ breach_report: Optional[WatchdogReport] = None
375
+ while True:
376
+ if proc.poll() is not None:
377
+ break # job finished on its own
378
+ rep = evaluate(sampler(), t)
379
+ peaks.update(rep.sample)
380
+ if emit:
381
+ emit(rep)
382
+ if rep.verdict == "abort":
383
+ breach_report = rep
384
+ if on_breach == "kill-job":
385
+ rep.action_taken = _terminate_job(proc)
386
+ else:
387
+ rep.action_taken = execute_action(on_breach, rep, run=run)
388
+ _terminate_job(proc) # aborting => always stop the job too
389
+ break
390
+ if max_polls and peaks.samples >= max_polls:
391
+ break
392
+ sleep(max(0.5, interval))
393
+ _terminate_job(proc) # never leave the child running
394
+ return SuperviseResult(
395
+ job_rc=proc.poll(), verdict=("abort" if breach_report else "ok"),
396
+ peaks=peaks, breached=breach_report is not None, breach=breach_report,
397
+ )
398
+
399
+
400
+ def _human(r: WatchdogReport) -> str:
401
+ s = r.sample
402
+ bits = []
403
+ if s.gpu_power_pct is not None: bits.append(f"power {s.gpu_power_pct:.0f}%")
404
+ if s.gpu_temp_c is not None: bits.append(f"{s.gpu_temp_c:.0f}C")
405
+ if s.gpu_vram_pct is not None: bits.append(f"vram {s.gpu_vram_pct:.0f}%")
406
+ if s.host_mem_pct is not None: bits.append(f"host-mem {s.host_mem_pct:.0f}%")
407
+ if s.host_avail_mib is not None: bits.append(f"host-free {s.host_avail_mib / 1024:.1f}GiB")
408
+ tag = f" [{s.mem_source}]" if s.mem_source else ""
409
+ if s.gpu_count and s.gpu_count > 1:
410
+ tag += f" [{s.gpu_count}GPU worst-case]"
411
+ br = "; ".join(f"{b.metric}={b.value} vs {b.threshold} ({b.level})" for b in r.breaches)
412
+ return f"[{r.verdict.upper()}]{tag} {', '.join(bits) or 'no metrics'}" + (f" — {br}" if br else "")
413
+
414
+
415
+ def _append_log(path: str, rep: WatchdogReport, elapsed_s: float) -> None:
416
+ """Append one poll to a JSONL trajectory log — so an AI reads the TREND, not just the instant."""
417
+ entry = {
418
+ "elapsed_s": elapsed_s, "t": time.time(), "verdict": rep.verdict,
419
+ "sample": rep.sample.to_dict(),
420
+ "breaches": [asdict(b) for b in rep.breaches],
421
+ }
422
+ with open(path, "a", encoding="utf-8") as f:
423
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
424
+
425
+
426
+ def _add_threshold_args(p: argparse.ArgumentParser) -> None:
427
+ p.add_argument("--config", help="JSON file of threshold overrides (see watchdog.example.json)")
428
+ p.add_argument("--power-max", type=float, help="abort over this %% of the GPU power limit (default 95)")
429
+ p.add_argument("--temp-max", type=float, help="abort over this GPU temperature in C (default 87)")
430
+ p.add_argument("--vram-max", type=float, help="abort over this %% VRAM (default 98)")
431
+ p.add_argument("--host-mem-max", type=float, help="abort over this %% host memory (default 90)")
432
+ p.add_argument("--host-avail-min", type=float, help="abort under this host free MiB (default 2000)")
433
+ p.add_argument("--warn-fraction", type=float, help="warn at this fraction of a limit (default 0.9)")
434
+
435
+
436
+ def _thresholds_from_args(args: argparse.Namespace) -> Thresholds:
437
+ t = Thresholds()
438
+ if getattr(args, "config", None):
439
+ with open(args.config, "r", encoding="utf-8") as f:
440
+ for k, v in json.load(f).items():
441
+ if hasattr(t, k):
442
+ setattr(t, k, float(v)) # unknown keys (e.g. "_comment") are ignored
443
+ for attr, val in [("power_pct_max", args.power_max), ("gpu_temp_c_max", args.temp_max),
444
+ ("vram_pct_max", args.vram_max), ("host_mem_pct_max", args.host_mem_max),
445
+ ("host_avail_mib_min", args.host_avail_min), ("warn_fraction", args.warn_fraction)]:
446
+ if val is not None:
447
+ setattr(t, attr, val)
448
+ return t
449
+
450
+
451
+ def _run_supervise(args: argparse.Namespace) -> int:
452
+ command = list(args.command or [])
453
+ if command and command[0] == "--": # tolerate the `--` separator argparse may keep
454
+ command = command[1:]
455
+ if not command:
456
+ raise GpuContainerError("INPUT_NO_COMMAND", "no command to supervise",
457
+ hint="usage: gpu-container-watchdog run [opts] -- <command...>")
458
+ t = _thresholds_from_args(args)
459
+ t0 = time.monotonic()
460
+
461
+ def emit(rep: WatchdogReport) -> None:
462
+ if args.json:
463
+ print(rep.to_json())
464
+ else:
465
+ print(_human(rep), file=sys.stderr)
466
+ for n in rep.sample.notes:
467
+ print(f" note: {n}", file=sys.stderr)
468
+ if args.log:
469
+ _append_log(args.log, rep, round(time.monotonic() - t0, 2))
470
+
471
+ print(f"supervising (on-breach={args.on_breach}): {' '.join(command)}", file=sys.stderr)
472
+ res = supervise(command, t, interval=args.interval, on_breach=args.on_breach, emit=emit)
473
+
474
+ if args.peaks_out:
475
+ payload = res.peaks.to_dict()
476
+ payload.update({"breached": res.breached, "stayed_within_envelope": not res.breached,
477
+ "on_breach": args.on_breach, "thresholds": t.to_dict()})
478
+ with open(args.peaks_out, "w", encoding="utf-8") as f:
479
+ json.dump(payload, f, indent=2)
480
+ print(f"wrote peaks -> {args.peaks_out} "
481
+ f"(feed it to `gpu-container-receipt --peaks {args.peaks_out}`)", file=sys.stderr)
482
+
483
+ if res.breached:
484
+ print(f"ABORT: {res.breach.action_taken}", file=sys.stderr)
485
+ return _EXIT["abort"] # 7 — the safety verdict dominates
486
+ if res.job_rc: # job's own non-zero exit (no watchdog breach)
487
+ print(f"job exited {res.job_rc} (no watchdog breach)", file=sys.stderr)
488
+ return res.job_rc
489
+ return 0
490
+
491
+
492
+ def _main(argv: Optional[List[str]] = None) -> int:
493
+ for _stream in (sys.stdout, sys.stderr):
494
+ try:
495
+ _stream.reconfigure(encoding="utf-8")
496
+ except (AttributeError, ValueError):
497
+ pass
498
+
499
+ ap = argparse.ArgumentParser(
500
+ prog="gpu-container-watchdog",
501
+ description="Rig-safety control plane: watch GPU+host metrics, abort on a hard-threshold breach.",
502
+ )
503
+ _add_threshold_args(ap)
504
+ ap.add_argument("--debug", action="store_true", help="show the full traceback on an unexpected error")
505
+ ap.add_argument("--watch", action="store_true", help="loop until a breach (else a single one-shot reading)")
506
+ ap.add_argument("--interval", type=float, default=5.0, help="--watch poll seconds (default 5)")
507
+ ap.add_argument("--max-polls", type=int, help="--watch: stop after N polls (default: until breach/interrupt)")
508
+ ap.add_argument("--on-breach", default="alert",
509
+ help="alert | wsl-shutdown | docker-stop:NAME | kill:PID | command:CMD (default alert — no kill)")
510
+ ap.add_argument("--json", action="store_true", help="emit the JSON report per poll (else a human line)")
511
+ ap.add_argument("--log", help="append each poll as a JSONL line here (the rolling trajectory)")
512
+
513
+ sub = ap.add_subparsers(dest="mode")
514
+ rp = sub.add_parser("run", help="supervise a command: launch it, poll metrics in parallel, act on a breach")
515
+ _add_threshold_args(rp)
516
+ rp.add_argument("--interval", type=float, default=5.0, help="poll seconds while the job runs (default 5)")
517
+ rp.add_argument("--on-breach", default="kill-job",
518
+ help="kill-job (terminate the child — default) | wsl-shutdown | docker-stop:NAME | alert | command:CMD")
519
+ rp.add_argument("--json", action="store_true", help="emit the JSON report per poll")
520
+ rp.add_argument("--log", help="append each poll as a JSONL line here (the rolling trajectory)")
521
+ rp.add_argument("--peaks-out", help="write the run's peak-metrics JSON here (feed to gpu-container-receipt --peaks)")
522
+ rp.add_argument("--debug", action="store_true", help="show the full traceback on an unexpected error")
523
+ rp.add_argument("command", nargs=argparse.REMAINDER, help="the command to supervise, after `--`")
524
+
525
+ args = ap.parse_args(argv)
526
+ if getattr(args, "mode", None) == "run":
527
+ return _run_supervise(args)
528
+
529
+ # --- legacy monitor: one-shot or --watch ---------------------------------
530
+ t = _thresholds_from_args(args)
531
+ t0 = time.monotonic()
532
+
533
+ def one() -> WatchdogReport:
534
+ rep = evaluate(sample(), t)
535
+ print(rep.to_json() if args.json else _human(rep), file=sys.stdout if args.json else sys.stderr)
536
+ for n in rep.sample.notes:
537
+ print(f" note: {n}", file=sys.stderr)
538
+ if args.log:
539
+ _append_log(args.log, rep, round(time.monotonic() - t0, 2))
540
+ return rep
541
+
542
+ if not args.watch:
543
+ return _EXIT[one().verdict]
544
+
545
+ polls = 0
546
+ while True:
547
+ rep = one()
548
+ if rep.verdict == "abort":
549
+ rep.action_taken = execute_action(args.on_breach, rep)
550
+ print(f"ABORT: {rep.action_taken}", file=sys.stderr)
551
+ return _EXIT["abort"]
552
+ polls += 1
553
+ if args.max_polls and polls >= args.max_polls:
554
+ return _EXIT[rep.verdict]
555
+ time.sleep(max(0.5, args.interval))
556
+
557
+
558
+ def main(argv: Optional[List[str]] = None) -> int:
559
+ return guard(_main, argv)
560
+
561
+
562
+ if __name__ == "__main__":
563
+ raise SystemExit(main())
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: gpu-container
3
+ Version: 0.1.0
4
+ Summary: Model-aware inference memory-placement planner for single-GPU rigs — profile, plan, prove.
5
+ Author-email: mcp-tool-shop <64996768+mcp-tool-shop@users.noreply.github.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: gpu,inference,llm,moe,offload,placement,profiler,vram
9
+ Requires-Python: >=3.10
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Provides-Extra: gpu
13
+ Requires-Dist: nvidia-ml-py>=12.535.0; extra == 'gpu'
14
+ Provides-Extra: host
15
+ Requires-Dist: numpy>=1.24; extra == 'host'
16
+ Requires-Dist: psutil>=5.9.0; extra == 'host'
17
+ Description-Content-Type: text/markdown
18
+
19
+ <p align="center">
20
+ <a href="README.ja.md">日本語</a> | <a href="README.zh.md">中文</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.hi.md">हिन्दी</a> | <a href="README.it.md">Italiano</a> | <a href="README.pt-BR.md">Português (BR)</a>
21
+ </p>
22
+
23
+ <div align="center">
24
+
25
+ <img src="https://raw.githubusercontent.com/mcp-tool-shop-org/gpu-container/main/assets/logo.png" width="400" alt="gpu-container" />
26
+
27
+ [![CI](https://github.com/mcp-tool-shop-org/gpu-container/actions/workflows/ci.yml/badge.svg)](https://github.com/mcp-tool-shop-org/gpu-container/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/gpu-container)](https://pypi.org/project/gpu-container/)
29
+ [![npm](https://img.shields.io/npm/v/gpu-container)](https://www.npmjs.com/package/gpu-container)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
31
+ [![Handbook](https://img.shields.io/badge/handbook-docs-blue)](https://mcp-tool-shop-org.github.io/gpu-container/)
32
+
33
+ **A GPU-enabled container exposes the device. A model-aware runtime decides what lives in VRAM, pinned RAM, and NVMe.**
34
+
35
+ </div>
36
+
37
+ Run the largest useful local model your machine can honestly support, with explicit placement plans, benchmark receipts, and refusal when the plan would thrash.
38
+
39
+ ## Architecture
40
+
41
+ ```
42
+ Windows / WSL2 / Linux host
43
+ └─ GPU-enabled Docker container
44
+ └─ Inference runtime
45
+ ├─ VRAM: hot weights, active layers, activations, KV working set
46
+ ├─ pinned RAM: CPU-offloaded weights, MoE experts, KV spill/reuse
47
+ └─ NVMe: mmap shards, disk offload, cold experts, cold KV
48
+ ```
49
+
50
+ ## Product Boundary
51
+
52
+ ```
53
+ Docker = packaging + GPU exposure
54
+ CUDA/runtime = compute backend
55
+ Planner = memory law
56
+ Inference engine = execution
57
+ ```
58
+
59
+ ## Core Features
60
+
61
+ 1. **Hardware profiler** — Detect VRAM, RAM, GPU type, WSL/native Linux, NVMe speed, CUDA availability
62
+ 2. **Model profiler** — Detect dense vs MoE, largest layer, total weights, quantization, KV growth by context length
63
+ 3. **Runtime planner** — Generate launch plans for llama.cpp, vLLM, Accelerate, TensorRT-LLM, or DeepSpeed-style offload
64
+ 4. **Placement receipt** — Show what is in VRAM, what is in RAM, what is on disk, expected bottleneck, measured tokens/sec
65
+ 5. **MoE-specialized path** — Keep always-active layers on GPU, route experts to CPU/RAM, NVMe for cold fallback
66
+ 6. **Routing de-risk** — Measure whether a model's MoE routing is skewed enough that a per-expert cache would help, before building for it (`gpu-container-concentration`)
67
+ 7. **Rig-safety watchdog** — Poll GPU power/temperature/VRAM + host memory against configurable thresholds; an AI agent or an autonomous loop aborts a run before it endangers the machine (`gpu-container-watchdog`)
68
+
69
+ ## Key Constraint
70
+
71
+ On Windows/WSL, CUDA Unified Memory oversubscription is **not the path**. CUDA treats Windows/WSL as limited unified-memory support — no fine-grained GPU page-fault migration, no GPU-memory oversubscription beyond physical VRAM. This product is **explicit inference memory placement**, not "Docker VRAM overflow."
72
+
73
+ ## Status
74
+
75
+ Built and working today: `gpu-container-profile`, `gpu-container-plan`, `gpu-container-receipt` (with the recalibration loop), `gpu-container-concentration` (routing de-risk), and `gpu-container-watchdog` (supervise a GPU job safely). llama.cpp is the integrated backend; the placement math is backend-agnostic. Start with the [quickstart](docs/quickstart.md).
76
+
77
+ ## Privacy & safety
78
+
79
+ `gpu-container` is a **local, offline tool** — it makes no network calls and collects **no telemetry**, by default or otherwise. It reads GPU metrics (`nvidia-smi` / NVML) and host memory (`psutil`), the model `config.json` you supply, and the JSON files you point it at; it writes only to the output paths you specify. It does **not** read or transmit model weights, credentials, or tokens. Host-level actions (`wsl --shutdown`, `docker stop`, `kill`) run only when you explicitly opt in via the watchdog's `--on-breach`; the defaults never touch your machine beyond the job they supervise. Full policy: [SECURITY.md](SECURITY.md).
80
+
81
+ ## Documentation
82
+
83
+ - [`docs/quickstart.md`](docs/quickstart.md) — end-to-end walkthrough: profile → plan → launch under the watchdog → receipt → recalibrate
84
+ - [`docs/cli.md`](docs/cli.md) — the five commands: synopsis, flags, exit codes, worked examples
85
+ - [`docs/architecture.md`](docs/architecture.md) — memory-tier model, data flow, MoE expert routing, the recalibration loop
86
+ - [`docs/features.md`](docs/features.md) — the seven core features in depth
87
+ - [`docs/moe-lane-architecture.md`](docs/moe-lane-architecture.md) — the flagship MoE lane in depth
88
+ - [`docs/derisk-concentration.md`](docs/derisk-concentration.md) — the per-expert-cache de-risk gate (routing concentration)
89
+ - [`docs/decisions/0001-per-expert-cache-build-vs-upstream.md`](docs/decisions/0001-per-expert-cache-build-vs-upstream.md) — ADR-0001: consume the cache mechanism, contribute the policy
90
+ - [`docs/constraints.md`](docs/constraints.md) — non-goals + the Windows/WSL CUDA Unified-Memory correction
91
+ - [`docs/prior-art.md`](docs/prior-art.md) — runtimes we orchestrate, and the gap this product fills
92
+ - [`docs/feasibility.md`](docs/feasibility.md) — feasibility assessment, research grounding, and what's confirmed live
93
+
94
+ ---
95
+
96
+ <div align="center">
97
+
98
+ Built by <a href="https://mcp-tool-shop.github.io/">MCP Tool Shop</a> · MIT Licensed
99
+
100
+ </div>