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.
- gpu_container/__init__.py +9 -0
- gpu_container/__main__.py +60 -0
- gpu_container/errors.py +72 -0
- gpu_container/planner/__init__.py +17 -0
- gpu_container/planner/activation.py +225 -0
- gpu_container/planner/calibration.py +224 -0
- gpu_container/planner/calibration_seed.json +44 -0
- gpu_container/planner/cli.py +101 -0
- gpu_container/planner/concentration_cli.py +120 -0
- gpu_container/planner/placement.py +198 -0
- gpu_container/planner/receipt.py +155 -0
- gpu_container/planner/receipt_cli.py +143 -0
- gpu_container/profiler/__init__.py +24 -0
- gpu_container/profiler/baseline.py +122 -0
- gpu_container/profiler/cli.py +151 -0
- gpu_container/profiler/cuda_bench.py +306 -0
- gpu_container/profiler/hardware.py +304 -0
- gpu_container/profiler/model.py +178 -0
- gpu_container/profiler/nvme_bench.py +158 -0
- gpu_container/profiler/schema.py +245 -0
- gpu_container/watchdog.py +563 -0
- gpu_container-0.1.0.dist-info/METADATA +100 -0
- gpu_container-0.1.0.dist-info/RECORD +26 -0
- gpu_container-0.1.0.dist-info/WHEEL +4 -0
- gpu_container-0.1.0.dist-info/entry_points.txt +7 -0
- gpu_container-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
[](https://github.com/mcp-tool-shop-org/gpu-container/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/gpu-container/)
|
|
29
|
+
[](https://www.npmjs.com/package/gpu-container)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+
[](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>
|