gpuops 0.1.0__tar.gz

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.
gpuops-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: gpuops
3
+ Version: 0.1.0
4
+ Summary: A small GPU operations CLI for shared lab servers.
5
+ Author: GPUOps contributors
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # GPUOps
11
+
12
+ GPUOps is a small CLI for managing shared lab GPUs. It focuses on the first
13
+ things an admin usually needs: seeing who is using GPUs, finding suspicious
14
+ idle jobs, safely killing selected GPU processes, and keeping lightweight usage
15
+ history.
16
+
17
+ ## Features
18
+
19
+ - Real-time GPU status: memory, utilization, temperature, power, and processes.
20
+ - GPU process list with user, command, working directory, and running time.
21
+ - Kill all processes on a GPU, all GPU processes for a user, or that user on one GPU.
22
+ - Sorting by free memory, used memory, utilization, temperature, or GPU index.
23
+ - Stalled-GPU warning when memory is occupied but utilization stays near zero.
24
+ - Per-user summary across all GPUs.
25
+ - Memory/running-time top view.
26
+ - Lightweight JSONL history sampling and GPU-hour aggregation.
27
+ - Safe-by-default process management: `kill` is dry-run unless `--yes` is passed.
28
+
29
+ ## Install locally
30
+
31
+ ```bash
32
+ python3 -m pip install -e .
33
+ ```
34
+
35
+ ## Commands
36
+
37
+ ```bash
38
+ gpuops status
39
+ gpuops status --sort free --record
40
+ gpuops ps --gpu 0
41
+ gpuops users
42
+ gpuops top --by memory
43
+ gpuops doctor
44
+ gpuops history --days 30
45
+ ```
46
+
47
+ Kill commands are dry-run by default:
48
+
49
+ ```bash
50
+ gpuops kill --gpu 3
51
+ gpuops kill --user alice
52
+ gpuops kill --gpu 3 --user alice
53
+ ```
54
+
55
+ To actually send signals:
56
+
57
+ ```bash
58
+ gpuops kill --gpu 3 --user alice --yes
59
+ gpuops kill --user alice --force --yes
60
+ ```
61
+
62
+ ## History
63
+
64
+ `gpuops status --record` appends one sample to:
65
+
66
+ ```text
67
+ ~/.local/share/gpuops/history.jsonl
68
+ ```
69
+
70
+ Run it from cron or systemd every minute if you want durable usage stats.
71
+
72
+ ## Notes
73
+
74
+ GPUOps uses `nvidia-smi` when available and enriches process information from
75
+ `/proc` on Linux. On machines without NVIDIA GPUs, commands fail cleanly with a
76
+ message instead of crashing.
gpuops-0.1.0/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # GPUOps
2
+
3
+ GPUOps is a small CLI for managing shared lab GPUs. It focuses on the first
4
+ things an admin usually needs: seeing who is using GPUs, finding suspicious
5
+ idle jobs, safely killing selected GPU processes, and keeping lightweight usage
6
+ history.
7
+
8
+ ## Features
9
+
10
+ - Real-time GPU status: memory, utilization, temperature, power, and processes.
11
+ - GPU process list with user, command, working directory, and running time.
12
+ - Kill all processes on a GPU, all GPU processes for a user, or that user on one GPU.
13
+ - Sorting by free memory, used memory, utilization, temperature, or GPU index.
14
+ - Stalled-GPU warning when memory is occupied but utilization stays near zero.
15
+ - Per-user summary across all GPUs.
16
+ - Memory/running-time top view.
17
+ - Lightweight JSONL history sampling and GPU-hour aggregation.
18
+ - Safe-by-default process management: `kill` is dry-run unless `--yes` is passed.
19
+
20
+ ## Install locally
21
+
22
+ ```bash
23
+ python3 -m pip install -e .
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ ```bash
29
+ gpuops status
30
+ gpuops status --sort free --record
31
+ gpuops ps --gpu 0
32
+ gpuops users
33
+ gpuops top --by memory
34
+ gpuops doctor
35
+ gpuops history --days 30
36
+ ```
37
+
38
+ Kill commands are dry-run by default:
39
+
40
+ ```bash
41
+ gpuops kill --gpu 3
42
+ gpuops kill --user alice
43
+ gpuops kill --gpu 3 --user alice
44
+ ```
45
+
46
+ To actually send signals:
47
+
48
+ ```bash
49
+ gpuops kill --gpu 3 --user alice --yes
50
+ gpuops kill --user alice --force --yes
51
+ ```
52
+
53
+ ## History
54
+
55
+ `gpuops status --record` appends one sample to:
56
+
57
+ ```text
58
+ ~/.local/share/gpuops/history.jsonl
59
+ ```
60
+
61
+ Run it from cron or systemd every minute if you want durable usage stats.
62
+
63
+ ## Notes
64
+
65
+ GPUOps uses `nvidia-smi` when available and enriches process information from
66
+ `/proc` on Linux. On machines without NVIDIA GPUs, commands fail cleanly with a
67
+ message instead of crashing.
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gpuops"
7
+ version = "0.1.0"
8
+ description = "A small GPU operations CLI for shared lab servers."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "GPUOps contributors" }]
13
+ dependencies = []
14
+
15
+ [project.scripts]
16
+ gpuops = "gpuops.cli:main"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
gpuops-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """GPUOps package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
5
+
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import signal
5
+ from dataclasses import dataclass
6
+ from typing import Iterable, List, Optional
7
+
8
+ from .models import GPUProcess
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class KillResult:
13
+ pid: int
14
+ user: Optional[str]
15
+ gpu_index: Optional[int]
16
+ signal_name: str
17
+ killed: bool
18
+ message: str
19
+
20
+
21
+ def filter_processes(processes: Iterable[GPUProcess], gpu: Optional[int] = None, user: Optional[str] = None) -> List[GPUProcess]:
22
+ selected = []
23
+ for proc in processes:
24
+ if gpu is not None and proc.gpu_index != gpu:
25
+ continue
26
+ if user is not None and proc.user != user:
27
+ continue
28
+ selected.append(proc)
29
+ return selected
30
+
31
+
32
+ def signal_for(force: bool) -> signal.Signals:
33
+ return signal.SIGKILL if force else signal.SIGTERM
34
+
35
+
36
+ def kill_processes(processes: Iterable[GPUProcess], sig: signal.Signals, dry_run: bool = True) -> List[KillResult]:
37
+ results: List[KillResult] = []
38
+ for proc in processes:
39
+ if dry_run:
40
+ results.append(
41
+ KillResult(
42
+ pid=proc.pid,
43
+ user=proc.user,
44
+ gpu_index=proc.gpu_index,
45
+ signal_name=sig.name,
46
+ killed=False,
47
+ message="dry-run",
48
+ )
49
+ )
50
+ continue
51
+ try:
52
+ os.kill(proc.pid, sig)
53
+ results.append(
54
+ KillResult(
55
+ pid=proc.pid,
56
+ user=proc.user,
57
+ gpu_index=proc.gpu_index,
58
+ signal_name=sig.name,
59
+ killed=True,
60
+ message="signal sent",
61
+ )
62
+ )
63
+ except ProcessLookupError:
64
+ results.append(
65
+ KillResult(
66
+ pid=proc.pid,
67
+ user=proc.user,
68
+ gpu_index=proc.gpu_index,
69
+ signal_name=sig.name,
70
+ killed=False,
71
+ message="process already exited",
72
+ )
73
+ )
74
+ except PermissionError:
75
+ results.append(
76
+ KillResult(
77
+ pid=proc.pid,
78
+ user=proc.user,
79
+ gpu_index=proc.gpu_index,
80
+ signal_name=sig.name,
81
+ killed=False,
82
+ message="permission denied",
83
+ )
84
+ )
85
+ return results
86
+
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List
5
+
6
+ from .models import GPU, Snapshot
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Alert:
11
+ gpu_index: int
12
+ level: str
13
+ message: str
14
+
15
+
16
+ def evaluate_snapshot(
17
+ snapshot: Snapshot,
18
+ idle_util_percent: int = 5,
19
+ occupied_memory_mb: int = 1024,
20
+ high_temp_c: int = 82,
21
+ ) -> List[Alert]:
22
+ alerts: List[Alert] = []
23
+ process_by_gpu = {}
24
+ for proc in snapshot.processes:
25
+ if proc.gpu_index is not None:
26
+ process_by_gpu.setdefault(proc.gpu_index, 0)
27
+ process_by_gpu[proc.gpu_index] += 1
28
+
29
+ for gpu in snapshot.gpus:
30
+ alerts.extend(_gpu_alerts(gpu, process_by_gpu.get(gpu.index, 0), idle_util_percent, occupied_memory_mb, high_temp_c))
31
+ return alerts
32
+
33
+
34
+ def _gpu_alerts(
35
+ gpu: GPU,
36
+ process_count: int,
37
+ idle_util_percent: int,
38
+ occupied_memory_mb: int,
39
+ high_temp_c: int,
40
+ ) -> List[Alert]:
41
+ alerts: List[Alert] = []
42
+ if gpu.memory_used_mb >= occupied_memory_mb and gpu.utilization_gpu_percent <= idle_util_percent:
43
+ alerts.append(
44
+ Alert(
45
+ gpu_index=gpu.index,
46
+ level="warn",
47
+ message="memory occupied while utilization is near zero",
48
+ )
49
+ )
50
+ if gpu.memory_used_mb >= occupied_memory_mb and process_count == 0:
51
+ alerts.append(
52
+ Alert(
53
+ gpu_index=gpu.index,
54
+ level="warn",
55
+ message="memory occupied but nvidia-smi reports no compute process",
56
+ )
57
+ )
58
+ if gpu.temperature_c is not None and gpu.temperature_c >= high_temp_c:
59
+ alerts.append(Alert(gpu_index=gpu.index, level="crit", message=f"temperature is high ({gpu.temperature_c}C)"))
60
+ return alerts
61
+
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ from .actions import filter_processes, kill_processes, signal_for
10
+ from .alerts import evaluate_snapshot
11
+ from .formatting import gpu_rows, json_dump, process_rows, table
12
+ from .history import DEFAULT_HISTORY_PATH, read_snapshots, record_snapshot, since_days, summarize_user_usage
13
+ from .nvidia import NvidiaSmiError, collect_snapshot
14
+ from .process_info import enrich_processes
15
+ from .models import Snapshot
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(prog="gpuops", description="GPUOps shared GPU administration CLI")
20
+ parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
21
+ subparsers = parser.add_subparsers(dest="command", required=True)
22
+
23
+ status = subparsers.add_parser("status", help="show real-time GPU status")
24
+ status.add_argument("--json", action="store_true", help="print machine-readable JSON")
25
+ status.add_argument("--sort", choices=["index", "free", "memory", "util", "temp"], default="index")
26
+ status.add_argument("--record", action="store_true", help="append this snapshot to the history file")
27
+
28
+ ps = subparsers.add_parser("ps", help="show GPU processes")
29
+ ps.add_argument("--json", action="store_true", help="print machine-readable JSON")
30
+ ps.add_argument("--gpu", type=int)
31
+ ps.add_argument("--user")
32
+
33
+ users = subparsers.add_parser("users", help="summarize current usage by user")
34
+ users.add_argument("--json", action="store_true", help="print machine-readable JSON")
35
+ users.add_argument("--sort", choices=["memory", "processes", "runtime"], default="memory")
36
+
37
+ top = subparsers.add_parser("top", help="rank GPU processes")
38
+ top.add_argument("--json", action="store_true", help="print machine-readable JSON")
39
+ top.add_argument("--by", choices=["memory", "runtime"], default="memory")
40
+ top.add_argument("--limit", type=int, default=20)
41
+
42
+ doctor = subparsers.add_parser("doctor", help="show suspicious GPU states")
43
+ doctor.add_argument("--json", action="store_true", help="print machine-readable JSON")
44
+ doctor.add_argument("--idle-util", type=int, default=5)
45
+ doctor.add_argument("--occupied-mb", type=int, default=1024)
46
+
47
+ kill = subparsers.add_parser("kill", help="kill selected GPU processes; dry-run unless --yes is passed")
48
+ kill.add_argument("--json", action="store_true", help="print machine-readable JSON")
49
+ kill.add_argument("--gpu", type=int)
50
+ kill.add_argument("--user")
51
+ kill.add_argument("--force", action="store_true", help="use SIGKILL instead of SIGTERM")
52
+ kill.add_argument("--yes", action="store_true", help="actually send the signal")
53
+
54
+ history = subparsers.add_parser("history", help="summarize recorded GPU usage")
55
+ history.add_argument("--json", action="store_true", help="print machine-readable JSON")
56
+ history.add_argument("--days", type=int, default=30)
57
+ history.add_argument("--path", default=str(DEFAULT_HISTORY_PATH))
58
+
59
+ return parser
60
+
61
+
62
+ def _snapshot() -> Snapshot:
63
+ snap = collect_snapshot()
64
+ enrich_processes(snap.processes)
65
+ return snap
66
+
67
+
68
+ def _sort_gpus(gpus, key: str):
69
+ if key == "free":
70
+ return sorted(gpus, key=lambda gpu: gpu.memory_free_mb, reverse=True)
71
+ if key == "memory":
72
+ return sorted(gpus, key=lambda gpu: gpu.memory_used_mb, reverse=True)
73
+ if key == "util":
74
+ return sorted(gpus, key=lambda gpu: gpu.utilization_gpu_percent, reverse=True)
75
+ if key == "temp":
76
+ return sorted(gpus, key=lambda gpu: gpu.temperature_c or -1, reverse=True)
77
+ return sorted(gpus, key=lambda gpu: gpu.index)
78
+
79
+
80
+ def cmd_status(args) -> int:
81
+ snap = _snapshot()
82
+ if args.record:
83
+ record_snapshot(snap)
84
+ alerts = evaluate_snapshot(snap)
85
+ gpus = _sort_gpus(snap.gpus, args.sort)
86
+ if args.json:
87
+ print(json_dump({"snapshot": snap.to_dict(), "alerts": [alert.__dict__ for alert in alerts]}))
88
+ return 0
89
+ print(table(["GPU", "Name", "Memory", "Free", "Util", "Temp", "Power", "Alerts"], gpu_rows(gpus, alerts)))
90
+ return 0
91
+
92
+
93
+ def cmd_ps(args) -> int:
94
+ snap = _snapshot()
95
+ processes = filter_processes(snap.processes, gpu=args.gpu, user=args.user)
96
+ processes.sort(key=lambda proc: (proc.gpu_index if proc.gpu_index is not None else 9999, -proc.used_memory_mb))
97
+ if args.json:
98
+ print(json_dump([proc.__dict__ for proc in processes]))
99
+ return 0
100
+ print(table(["GPU", "PID", "User", "Memory", "Running", "Command", "CWD"], process_rows(processes)))
101
+ return 0
102
+
103
+
104
+ def cmd_users(args) -> int:
105
+ snap = _snapshot()
106
+ buckets = defaultdict(lambda: {"memory": 0, "processes": 0, "runtime": 0.0, "gpus": set()})
107
+ for proc in snap.processes:
108
+ user = proc.user or "unknown"
109
+ buckets[user]["memory"] += proc.used_memory_mb
110
+ buckets[user]["processes"] += 1
111
+ buckets[user]["runtime"] += proc.running_seconds or 0.0
112
+ if proc.gpu_index is not None:
113
+ buckets[user]["gpus"].add(proc.gpu_index)
114
+ summaries = []
115
+ for user, values in buckets.items():
116
+ summaries.append(
117
+ {
118
+ "user": user,
119
+ "gpus": ",".join(str(gpu) for gpu in sorted(values["gpus"])) or "-",
120
+ "processes": values["processes"],
121
+ "memory_mb": values["memory"],
122
+ "runtime_seconds": values["runtime"],
123
+ }
124
+ )
125
+ if args.sort == "memory":
126
+ summaries.sort(key=lambda item: item["memory_mb"], reverse=True)
127
+ elif args.sort == "processes":
128
+ summaries.sort(key=lambda item: item["processes"], reverse=True)
129
+ else:
130
+ summaries.sort(key=lambda item: item["runtime_seconds"], reverse=True)
131
+ if args.json:
132
+ print(json_dump(summaries))
133
+ return 0
134
+ rows = [
135
+ [item["user"], item["gpus"], item["processes"], f"{item['memory_mb']} MB", _duration(item["runtime_seconds"])]
136
+ for item in summaries
137
+ ]
138
+ print(table(["User", "GPUs", "Processes", "Memory", "Total Running"], rows))
139
+ return 0
140
+
141
+
142
+ def _duration(seconds: float) -> str:
143
+ from .formatting import human_duration
144
+
145
+ return human_duration(seconds)
146
+
147
+
148
+ def cmd_top(args) -> int:
149
+ snap = _snapshot()
150
+ if args.by == "runtime":
151
+ processes = sorted(snap.processes, key=lambda proc: proc.running_seconds or 0, reverse=True)
152
+ else:
153
+ processes = sorted(snap.processes, key=lambda proc: proc.used_memory_mb, reverse=True)
154
+ processes = processes[: max(0, args.limit)]
155
+ if args.json:
156
+ print(json_dump([proc.__dict__ for proc in processes]))
157
+ return 0
158
+ print(table(["GPU", "PID", "User", "Memory", "Running", "Command", "CWD"], process_rows(processes)))
159
+ return 0
160
+
161
+
162
+ def cmd_doctor(args) -> int:
163
+ snap = _snapshot()
164
+ alerts = evaluate_snapshot(snap, idle_util_percent=args.idle_util, occupied_memory_mb=args.occupied_mb)
165
+ if args.json:
166
+ print(json_dump([alert.__dict__ for alert in alerts]))
167
+ return 0
168
+ if not alerts:
169
+ print("No suspicious GPU state found.")
170
+ return 0
171
+ rows = [[alert.level, alert.gpu_index, alert.message] for alert in alerts]
172
+ print(table(["Level", "GPU", "Message"], rows))
173
+ return 1 if any(alert.level == "crit" for alert in alerts) else 0
174
+
175
+
176
+ def cmd_kill(args) -> int:
177
+ if args.gpu is None and args.user is None:
178
+ print("Refusing to select every GPU process. Pass --gpu, --user, or both.", file=sys.stderr)
179
+ return 2
180
+ snap = _snapshot()
181
+ selected = filter_processes(snap.processes, gpu=args.gpu, user=args.user)
182
+ sig = signal_for(args.force)
183
+ results = kill_processes(selected, sig=sig, dry_run=not args.yes)
184
+ rows = [[r.gpu_index if r.gpu_index is not None else "-", r.pid, r.user or "-", r.signal_name, r.message] for r in results]
185
+ if args.json:
186
+ print(json_dump([result.__dict__ for result in results]))
187
+ return 0
188
+ if not rows:
189
+ print("No matching GPU processes.")
190
+ return 0
191
+ print(table(["GPU", "PID", "User", "Signal", "Result"], rows))
192
+ if not args.yes:
193
+ print("\nDry-run only. Re-run with --yes to send the signal.")
194
+ return 0
195
+
196
+
197
+ def cmd_history(args) -> int:
198
+ samples = read_snapshots(path=Path(args.path).expanduser(), since=since_days(args.days))
199
+ usage = summarize_user_usage(samples)
200
+ if args.json:
201
+ print(json_dump([item.__dict__ for item in usage]))
202
+ return 0
203
+ if not usage:
204
+ print("No history samples found. Run `gpuops status --record` periodically first.")
205
+ return 0
206
+ rows = [[item.user, f"{item.gpu_hours:.2f}", f"{item.memory_gb_hours:.2f}", item.samples] for item in usage]
207
+ print(table(["User", "GPU Hours", "GB Hours", "Samples"], rows))
208
+ return 0
209
+
210
+
211
+ def main(argv: Optional[List[str]] = None) -> int:
212
+ parser = build_parser()
213
+ args = parser.parse_args(argv)
214
+ try:
215
+ return {
216
+ "status": cmd_status,
217
+ "ps": cmd_ps,
218
+ "users": cmd_users,
219
+ "top": cmd_top,
220
+ "doctor": cmd_doctor,
221
+ "kill": cmd_kill,
222
+ "history": cmd_history,
223
+ }[args.command](args)
224
+ except NvidiaSmiError as exc:
225
+ print(str(exc), file=sys.stderr)
226
+ return 127
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Iterable, List, Sequence
5
+
6
+ from .alerts import Alert
7
+ from .models import GPU, GPUProcess
8
+
9
+
10
+ def human_duration(seconds: float | None) -> str:
11
+ if seconds is None:
12
+ return "-"
13
+ seconds = int(seconds)
14
+ days, seconds = divmod(seconds, 86400)
15
+ hours, seconds = divmod(seconds, 3600)
16
+ minutes, _ = divmod(seconds, 60)
17
+ if days:
18
+ return f"{days}d{hours}h"
19
+ if hours:
20
+ return f"{hours}h{minutes}m"
21
+ return f"{minutes}m"
22
+
23
+
24
+ def json_dump(data: object) -> str:
25
+ return json.dumps(data, indent=2, sort_keys=True)
26
+
27
+
28
+ def table(headers: Sequence[str], rows: Iterable[Sequence[object]]) -> str:
29
+ rows = [[str(cell) for cell in row] for row in rows]
30
+ widths: List[int] = [len(header) for header in headers]
31
+ for row in rows:
32
+ for index, cell in enumerate(row):
33
+ widths[index] = max(widths[index], len(cell))
34
+ line = " ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
35
+ sep = " ".join("-" * width for width in widths)
36
+ body = [" ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) for row in rows]
37
+ return "\n".join([line, sep, *body])
38
+
39
+
40
+ def gpu_rows(gpus: Sequence[GPU], alerts: Sequence[Alert]) -> List[List[object]]:
41
+ alert_by_gpu = {}
42
+ for alert in alerts:
43
+ alert_by_gpu.setdefault(alert.gpu_index, []).append(alert.message)
44
+ return [
45
+ [
46
+ gpu.index,
47
+ gpu.name,
48
+ f"{gpu.memory_used_mb}/{gpu.memory_total_mb} MB",
49
+ f"{gpu.memory_free_mb} MB",
50
+ f"{gpu.utilization_gpu_percent}%",
51
+ "-" if gpu.temperature_c is None else f"{gpu.temperature_c}C",
52
+ "-" if gpu.power_w is None else f"{gpu.power_w:.0f}W",
53
+ "; ".join(alert_by_gpu.get(gpu.index, [])) or "-",
54
+ ]
55
+ for gpu in gpus
56
+ ]
57
+
58
+
59
+ def process_rows(processes: Sequence[GPUProcess]) -> List[List[object]]:
60
+ return [
61
+ [
62
+ proc.gpu_index if proc.gpu_index is not None else "-",
63
+ proc.pid,
64
+ proc.user or "-",
65
+ f"{proc.used_memory_mb} MB",
66
+ human_duration(proc.running_seconds),
67
+ proc.command or proc.process_name,
68
+ proc.cwd or "-",
69
+ ]
70
+ for proc in processes
71
+ ]
72
+
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Dict, Iterable, List, Optional
9
+
10
+ from .models import Snapshot
11
+
12
+
13
+ DEFAULT_HISTORY_PATH = Path(os.environ.get("GPUOPS_HISTORY", "~/.local/share/gpuops/history.jsonl")).expanduser()
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class UserUsage:
18
+ user: str
19
+ gpu_hours: float
20
+ memory_gb_hours: float
21
+ samples: int
22
+
23
+
24
+ def record_snapshot(snapshot: Snapshot, path: Path = DEFAULT_HISTORY_PATH) -> None:
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ with path.open("a", encoding="utf-8") as handle:
27
+ handle.write(json.dumps(snapshot.to_dict(), sort_keys=True) + "\n")
28
+
29
+
30
+ def read_snapshots(path: Path = DEFAULT_HISTORY_PATH, since: Optional[float] = None) -> List[dict]:
31
+ if not path.exists():
32
+ return []
33
+ samples: List[dict] = []
34
+ with path.open("r", encoding="utf-8") as handle:
35
+ for line in handle:
36
+ line = line.strip()
37
+ if not line:
38
+ continue
39
+ try:
40
+ sample = json.loads(line)
41
+ except json.JSONDecodeError:
42
+ continue
43
+ if since is None or float(sample.get("timestamp", 0)) >= since:
44
+ samples.append(sample)
45
+ samples.sort(key=lambda item: float(item.get("timestamp", 0)))
46
+ return samples
47
+
48
+
49
+ def summarize_user_usage(samples: Iterable[dict], max_interval_seconds: int = 300) -> List[UserUsage]:
50
+ ordered = sorted(samples, key=lambda item: float(item.get("timestamp", 0)))
51
+ accum: Dict[str, Dict[str, float]] = {}
52
+ for current, nxt in zip(ordered, ordered[1:]):
53
+ timestamp = float(current.get("timestamp", 0))
54
+ next_timestamp = float(nxt.get("timestamp", timestamp))
55
+ interval = max(0.0, min(max_interval_seconds, next_timestamp - timestamp))
56
+ if interval <= 0:
57
+ continue
58
+ seen_user_gpu = set()
59
+ for proc in current.get("processes", []):
60
+ user = proc.get("user") or "unknown"
61
+ gpu_index = proc.get("gpu_index")
62
+ used_memory_mb = float(proc.get("used_memory_mb") or 0)
63
+ bucket = accum.setdefault(user, {"gpu_seconds": 0.0, "memory_mb_seconds": 0.0, "samples": 0.0})
64
+ key = (user, gpu_index)
65
+ if key not in seen_user_gpu:
66
+ bucket["gpu_seconds"] += interval
67
+ seen_user_gpu.add(key)
68
+ bucket["memory_mb_seconds"] += used_memory_mb * interval
69
+ bucket["samples"] += 1
70
+
71
+ return sorted(
72
+ [
73
+ UserUsage(
74
+ user=user,
75
+ gpu_hours=values["gpu_seconds"] / 3600.0,
76
+ memory_gb_hours=values["memory_mb_seconds"] / 1024.0 / 3600.0,
77
+ samples=int(values["samples"]),
78
+ )
79
+ for user, values in accum.items()
80
+ ],
81
+ key=lambda item: item.gpu_hours,
82
+ reverse=True,
83
+ )
84
+
85
+
86
+ def since_days(days: int) -> float:
87
+ return time.time() - (days * 86400)
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class GPU:
9
+ index: int
10
+ uuid: str
11
+ name: str
12
+ memory_total_mb: int
13
+ memory_used_mb: int
14
+ utilization_gpu_percent: int
15
+ temperature_c: Optional[int]
16
+ power_w: Optional[float]
17
+
18
+ @property
19
+ def memory_free_mb(self) -> int:
20
+ return max(0, self.memory_total_mb - self.memory_used_mb)
21
+
22
+
23
+ @dataclass
24
+ class GPUProcess:
25
+ pid: int
26
+ gpu_uuid: str
27
+ gpu_index: Optional[int]
28
+ used_memory_mb: int
29
+ process_name: str
30
+ user: Optional[str] = None
31
+ command: Optional[str] = None
32
+ cwd: Optional[str] = None
33
+ started_at: Optional[float] = None
34
+ running_seconds: Optional[float] = None
35
+
36
+
37
+ @dataclass
38
+ class Snapshot:
39
+ timestamp: float
40
+ gpus: List[GPU] = field(default_factory=list)
41
+ processes: List[GPUProcess] = field(default_factory=list)
42
+
43
+ def to_dict(self) -> Dict[str, Any]:
44
+ return {
45
+ "timestamp": self.timestamp,
46
+ "gpus": [gpu.__dict__ for gpu in self.gpus],
47
+ "processes": [proc.__dict__ for proc in self.processes],
48
+ }
49
+
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import subprocess
5
+ import time
6
+ from io import StringIO
7
+ from typing import Callable, Iterable, List, Optional
8
+
9
+ from .models import GPU, GPUProcess, Snapshot
10
+
11
+
12
+ class NvidiaSmiError(RuntimeError):
13
+ pass
14
+
15
+
16
+ Runner = Callable[[List[str]], str]
17
+
18
+
19
+ def _default_runner(args: List[str]) -> str:
20
+ try:
21
+ result = subprocess.run(
22
+ ["nvidia-smi", *args],
23
+ check=True,
24
+ text=True,
25
+ stdout=subprocess.PIPE,
26
+ stderr=subprocess.PIPE,
27
+ )
28
+ except FileNotFoundError as exc:
29
+ raise NvidiaSmiError("nvidia-smi was not found on this machine") from exc
30
+ except subprocess.CalledProcessError as exc:
31
+ message = exc.stderr.strip() or exc.stdout.strip() or str(exc)
32
+ raise NvidiaSmiError(f"nvidia-smi failed: {message}") from exc
33
+ return result.stdout
34
+
35
+
36
+ def _rows(text: str) -> Iterable[List[str]]:
37
+ reader = csv.reader(StringIO(text))
38
+ for row in reader:
39
+ cleaned = [cell.strip() for cell in row]
40
+ if cleaned and any(cell for cell in cleaned):
41
+ yield cleaned
42
+
43
+
44
+ def _int(value: str, default: int = 0) -> int:
45
+ value = value.strip()
46
+ if not value or value.upper() == "[N/A]" or value == "N/A":
47
+ return default
48
+ return int(float(value))
49
+
50
+
51
+ def _float_or_none(value: str) -> Optional[float]:
52
+ value = value.strip()
53
+ if not value or value.upper() == "[N/A]" or value == "N/A":
54
+ return None
55
+ return float(value)
56
+
57
+
58
+ def query_gpus(runner: Runner = _default_runner) -> List[GPU]:
59
+ output = runner(
60
+ [
61
+ "--query-gpu=index,uuid,name,memory.total,memory.used,utilization.gpu,temperature.gpu,power.draw",
62
+ "--format=csv,noheader,nounits",
63
+ ]
64
+ )
65
+ gpus: List[GPU] = []
66
+ for row in _rows(output):
67
+ if len(row) < 8:
68
+ continue
69
+ gpus.append(
70
+ GPU(
71
+ index=_int(row[0]),
72
+ uuid=row[1],
73
+ name=row[2],
74
+ memory_total_mb=_int(row[3]),
75
+ memory_used_mb=_int(row[4]),
76
+ utilization_gpu_percent=_int(row[5]),
77
+ temperature_c=None if row[6] in {"", "N/A", "[N/A]"} else _int(row[6]),
78
+ power_w=_float_or_none(row[7]),
79
+ )
80
+ )
81
+ return gpus
82
+
83
+
84
+ def query_processes(runner: Runner = _default_runner) -> List[GPUProcess]:
85
+ try:
86
+ output = runner(
87
+ [
88
+ "--query-compute-apps=pid,gpu_uuid,used_gpu_memory,process_name",
89
+ "--format=csv,noheader,nounits",
90
+ ]
91
+ )
92
+ except NvidiaSmiError as exc:
93
+ text = str(exc).lower()
94
+ if "not supported" in text or "no running processes" in text:
95
+ return []
96
+ raise
97
+
98
+ processes: List[GPUProcess] = []
99
+ for row in _rows(output):
100
+ if len(row) < 4 or row[0] in {"", "N/A", "[Not Supported]"}:
101
+ continue
102
+ processes.append(
103
+ GPUProcess(
104
+ pid=_int(row[0]),
105
+ gpu_uuid=row[1],
106
+ gpu_index=None,
107
+ used_memory_mb=_int(row[2]),
108
+ process_name=row[3],
109
+ )
110
+ )
111
+ return processes
112
+
113
+
114
+ def collect_snapshot(runner: Runner = _default_runner) -> Snapshot:
115
+ gpus = query_gpus(runner)
116
+ uuid_to_index = {gpu.uuid: gpu.index for gpu in gpus}
117
+ processes = query_processes(runner)
118
+ for proc in processes:
119
+ proc.gpu_index = uuid_to_index.get(proc.gpu_uuid)
120
+ return Snapshot(timestamp=time.time(), gpus=gpus, processes=processes)
121
+
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pwd
5
+ import time
6
+ from typing import Iterable, Optional
7
+
8
+ from .models import GPUProcess
9
+
10
+
11
+ def _boot_time() -> Optional[float]:
12
+ try:
13
+ with open("/proc/stat", "r", encoding="utf-8") as handle:
14
+ for line in handle:
15
+ if line.startswith("btime "):
16
+ return float(line.split()[1])
17
+ except OSError:
18
+ return None
19
+ return None
20
+
21
+
22
+ def _clock_ticks() -> int:
23
+ try:
24
+ return os.sysconf(os.sysconf_names["SC_CLK_TCK"])
25
+ except (KeyError, ValueError, OSError):
26
+ return 100
27
+
28
+
29
+ def enrich_process(proc: GPUProcess, now: Optional[float] = None) -> GPUProcess:
30
+ now = time.time() if now is None else now
31
+ proc_dir = f"/proc/{proc.pid}"
32
+
33
+ try:
34
+ stat_info = os.stat(proc_dir)
35
+ proc.user = pwd.getpwuid(stat_info.st_uid).pw_name
36
+ except (OSError, KeyError):
37
+ pass
38
+
39
+ try:
40
+ with open(f"{proc_dir}/cmdline", "rb") as handle:
41
+ raw = handle.read().replace(b"\x00", b" ").strip()
42
+ if raw:
43
+ proc.command = raw.decode("utf-8", errors="replace")
44
+ except OSError:
45
+ pass
46
+
47
+ try:
48
+ proc.cwd = os.readlink(f"{proc_dir}/cwd")
49
+ except OSError:
50
+ pass
51
+
52
+ boot = _boot_time()
53
+ if boot is not None:
54
+ try:
55
+ with open(f"{proc_dir}/stat", "r", encoding="utf-8") as handle:
56
+ stat = handle.read()
57
+ parts = stat.rsplit(") ", 1)[1].split()
58
+ start_ticks = int(parts[19])
59
+ proc.started_at = boot + (start_ticks / _clock_ticks())
60
+ proc.running_seconds = max(0.0, now - proc.started_at)
61
+ except (OSError, ValueError, IndexError):
62
+ pass
63
+
64
+ if not proc.command:
65
+ proc.command = proc.process_name
66
+ return proc
67
+
68
+
69
+ def enrich_processes(processes: Iterable[GPUProcess]) -> None:
70
+ now = time.time()
71
+ for proc in processes:
72
+ enrich_process(proc, now=now)
73
+
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: gpuops
3
+ Version: 0.1.0
4
+ Summary: A small GPU operations CLI for shared lab servers.
5
+ Author: GPUOps contributors
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # GPUOps
11
+
12
+ GPUOps is a small CLI for managing shared lab GPUs. It focuses on the first
13
+ things an admin usually needs: seeing who is using GPUs, finding suspicious
14
+ idle jobs, safely killing selected GPU processes, and keeping lightweight usage
15
+ history.
16
+
17
+ ## Features
18
+
19
+ - Real-time GPU status: memory, utilization, temperature, power, and processes.
20
+ - GPU process list with user, command, working directory, and running time.
21
+ - Kill all processes on a GPU, all GPU processes for a user, or that user on one GPU.
22
+ - Sorting by free memory, used memory, utilization, temperature, or GPU index.
23
+ - Stalled-GPU warning when memory is occupied but utilization stays near zero.
24
+ - Per-user summary across all GPUs.
25
+ - Memory/running-time top view.
26
+ - Lightweight JSONL history sampling and GPU-hour aggregation.
27
+ - Safe-by-default process management: `kill` is dry-run unless `--yes` is passed.
28
+
29
+ ## Install locally
30
+
31
+ ```bash
32
+ python3 -m pip install -e .
33
+ ```
34
+
35
+ ## Commands
36
+
37
+ ```bash
38
+ gpuops status
39
+ gpuops status --sort free --record
40
+ gpuops ps --gpu 0
41
+ gpuops users
42
+ gpuops top --by memory
43
+ gpuops doctor
44
+ gpuops history --days 30
45
+ ```
46
+
47
+ Kill commands are dry-run by default:
48
+
49
+ ```bash
50
+ gpuops kill --gpu 3
51
+ gpuops kill --user alice
52
+ gpuops kill --gpu 3 --user alice
53
+ ```
54
+
55
+ To actually send signals:
56
+
57
+ ```bash
58
+ gpuops kill --gpu 3 --user alice --yes
59
+ gpuops kill --user alice --force --yes
60
+ ```
61
+
62
+ ## History
63
+
64
+ `gpuops status --record` appends one sample to:
65
+
66
+ ```text
67
+ ~/.local/share/gpuops/history.jsonl
68
+ ```
69
+
70
+ Run it from cron or systemd every minute if you want durable usage stats.
71
+
72
+ ## Notes
73
+
74
+ GPUOps uses `nvidia-smi` when available and enriches process information from
75
+ `/proc` on Linux. On machines without NVIDIA GPUs, commands fail cleanly with a
76
+ message instead of crashing.
@@ -0,0 +1,19 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/gpuops/__init__.py
4
+ src/gpuops/__main__.py
5
+ src/gpuops/actions.py
6
+ src/gpuops/alerts.py
7
+ src/gpuops/cli.py
8
+ src/gpuops/formatting.py
9
+ src/gpuops/history.py
10
+ src/gpuops/models.py
11
+ src/gpuops/nvidia.py
12
+ src/gpuops/process_info.py
13
+ src/gpuops.egg-info/PKG-INFO
14
+ src/gpuops.egg-info/SOURCES.txt
15
+ src/gpuops.egg-info/dependency_links.txt
16
+ src/gpuops.egg-info/entry_points.txt
17
+ src/gpuops.egg-info/top_level.txt
18
+ tests/test_alerts_history_actions.py
19
+ tests/test_nvidia.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gpuops = gpuops.cli:main
@@ -0,0 +1 @@
1
+ gpuops
@@ -0,0 +1,57 @@
1
+ import signal
2
+ import unittest
3
+
4
+ from gpuops.actions import filter_processes, kill_processes
5
+ from gpuops.alerts import evaluate_snapshot
6
+ from gpuops.history import summarize_user_usage
7
+ from gpuops.models import GPU, GPUProcess, Snapshot
8
+
9
+
10
+ class AlertsHistoryActionsTests(unittest.TestCase):
11
+ def test_alerts_idle_occupied_gpu(self):
12
+ snapshot = Snapshot(
13
+ timestamp=1,
14
+ gpus=[GPU(0, "GPU-a", "A100", 80000, 70000, 0, 74, 220.0)],
15
+ processes=[GPUProcess(10, "GPU-a", 0, 70000, "python", user="alice")],
16
+ )
17
+
18
+ alerts = evaluate_snapshot(snapshot)
19
+
20
+ self.assertEqual(alerts[0].gpu_index, 0)
21
+ self.assertIn("near zero", alerts[0].message)
22
+
23
+ def test_history_counts_gpu_hours_once_per_user_gpu(self):
24
+ samples = [
25
+ {
26
+ "timestamp": 0,
27
+ "processes": [
28
+ {"user": "alice", "gpu_index": 0, "used_memory_mb": 1024},
29
+ {"user": "alice", "gpu_index": 0, "used_memory_mb": 2048},
30
+ {"user": "bob", "gpu_index": 1, "used_memory_mb": 1024},
31
+ ],
32
+ },
33
+ {"timestamp": 3600, "processes": []},
34
+ ]
35
+
36
+ usage = summarize_user_usage(samples, max_interval_seconds=3600)
37
+
38
+ self.assertEqual(usage[0].user, "alice")
39
+ self.assertAlmostEqual(usage[0].gpu_hours, 1.0)
40
+ self.assertAlmostEqual(usage[0].memory_gb_hours, 3.0)
41
+
42
+ def test_filter_and_dry_run_kill(self):
43
+ processes = [
44
+ GPUProcess(10, "GPU-a", 0, 100, "python", user="alice"),
45
+ GPUProcess(11, "GPU-b", 1, 100, "python", user="bob"),
46
+ ]
47
+
48
+ selected = filter_processes(processes, gpu=0, user="alice")
49
+ results = kill_processes(selected, signal.SIGTERM, dry_run=True)
50
+
51
+ self.assertEqual([proc.pid for proc in selected], [10])
52
+ self.assertFalse(results[0].killed)
53
+ self.assertEqual(results[0].message, "dry-run")
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()
@@ -0,0 +1,25 @@
1
+ import unittest
2
+
3
+ from gpuops.nvidia import collect_snapshot
4
+
5
+
6
+ class NvidiaParsingTests(unittest.TestCase):
7
+ def test_collect_snapshot_maps_processes_to_gpu_index(self):
8
+ def runner(args):
9
+ query = args[0]
10
+ if query.startswith("--query-gpu"):
11
+ return "0, GPU-a, NVIDIA A100, 81920, 40960, 0, 70, 250.5\n1, GPU-b, NVIDIA A100, 81920, 0, 0, 35, [N/A]\n"
12
+ if query.startswith("--query-compute-apps"):
13
+ return "123, GPU-a, 20480, python\n"
14
+ raise AssertionError(args)
15
+
16
+ snapshot = collect_snapshot(runner)
17
+
18
+ self.assertEqual(len(snapshot.gpus), 2)
19
+ self.assertEqual(snapshot.gpus[0].memory_free_mb, 40960)
20
+ self.assertEqual(snapshot.processes[0].gpu_index, 0)
21
+ self.assertEqual(snapshot.processes[0].used_memory_mb, 20480)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ unittest.main()