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 +76 -0
- gpuops-0.1.0/README.md +67 -0
- gpuops-0.1.0/pyproject.toml +19 -0
- gpuops-0.1.0/setup.cfg +4 -0
- gpuops-0.1.0/src/gpuops/__init__.py +5 -0
- gpuops-0.1.0/src/gpuops/__main__.py +5 -0
- gpuops-0.1.0/src/gpuops/actions.py +86 -0
- gpuops-0.1.0/src/gpuops/alerts.py +61 -0
- gpuops-0.1.0/src/gpuops/cli.py +226 -0
- gpuops-0.1.0/src/gpuops/formatting.py +72 -0
- gpuops-0.1.0/src/gpuops/history.py +87 -0
- gpuops-0.1.0/src/gpuops/models.py +49 -0
- gpuops-0.1.0/src/gpuops/nvidia.py +121 -0
- gpuops-0.1.0/src/gpuops/process_info.py +73 -0
- gpuops-0.1.0/src/gpuops.egg-info/PKG-INFO +76 -0
- gpuops-0.1.0/src/gpuops.egg-info/SOURCES.txt +19 -0
- gpuops-0.1.0/src/gpuops.egg-info/dependency_links.txt +1 -0
- gpuops-0.1.0/src/gpuops.egg-info/entry_points.txt +2 -0
- gpuops-0.1.0/src/gpuops.egg-info/top_level.txt +1 -0
- gpuops-0.1.0/tests/test_alerts_history_actions.py +57 -0
- gpuops-0.1.0/tests/test_nvidia.py +25 -0
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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|