nvmon 0.3.2__tar.gz → 0.3.4__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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nvmon
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: A fancy NVIDIA GPU monitor for the terminal. One Python file, no dependencies.
|
|
5
5
|
Project-URL: Source, https://github.com/Han-DongHeun/nvmon
|
|
6
6
|
Project-URL: Issues, https://github.com/Han-DongHeun/nvmon/issues
|
|
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|
A fancy NVIDIA GPU monitor for the terminal. One Python file, no dependencies.
|
|
26
26
|
|
|
27
|
-

|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
@@ -50,13 +50,14 @@ ssh -t HOST nvmon # over ssh: -t gives it a terminal
|
|
|
50
50
|
|
|
51
51
|
- **Top edge**: number and name, power against its limit, temperature, fan (if any), clock. Warnings
|
|
52
52
|
only when something is off: `SLOWED: power cap | too hot | hw brake`, `PCIe DEGRADED: x16 -> x8`.
|
|
53
|
-
- **Graph**: utilization over time, newest on the right.
|
|
53
|
+
- **Graph**: utilization over time, newest on the right. Point at it for the utilization and memory at that
|
|
54
|
+
moment.
|
|
54
55
|
- **GPU**: how much of the time a kernel ran. **cores / tensor** (H100 and newer): how busy that kept
|
|
55
56
|
the chip, and how much was Tensor Core math. One small kernel running nonstop reads GPU 100 % with
|
|
56
57
|
cores at 30 %.
|
|
57
58
|
- **MEM**: memory in use. **CPU -> GPU**, **GPU -> CPU**: PCIe traffic against the link's top speed.
|
|
58
59
|
- **Bottom edge**: the processes, with run time and memory, by account and conda environment (yours
|
|
59
|
-
|
|
60
|
+
underlined); `+3` for those that did not fit.
|
|
60
61
|
|
|
61
62
|
The bottom line has the GPU numbers (click one, or press its key, to hide or show it), the keys, and a
|
|
62
63
|
newer release when there is one.
|
|
@@ -77,21 +78,27 @@ Shift+wheel or ← → sideways.
|
|
|
77
78
|
|
|
78
79
|
**Numbers only** and **GPU and MEM only**: narrow, always one column, for a window beside another.
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
<div align="center">
|
|
82
|
+
|
|
83
|
+
| Numbers only | GPU and MEM only |
|
|
84
|
+
|:---:|:---:|
|
|
82
85
|
| <img src="https://raw.githubusercontent.com/Han-DongHeun/nvmon/main/docs/view-numbers.png" alt="nvmon with the numbers alone" width="200"> | <img src="https://raw.githubusercontent.com/Han-DongHeun/nvmon/main/docs/view-brief.png" alt="nvmon with GPU and MEM alone" width="200"> |
|
|
83
86
|
|
|
87
|
+
</div>
|
|
88
|
+
|
|
84
89
|
## Processes and jobs: `p`
|
|
85
90
|
|
|
86
91
|
A **job** is a process, or a launcher (torchrun, deepspeed…) with its workers.
|
|
87
92
|
|
|
88
|
-
- **Pick** one: click a process, or press Tab (Tab and the arrows move on). Its GPUs stand out,
|
|
89
|
-
bottom line sums it up.
|
|
90
|
-
-
|
|
93
|
+
- **Pick** one: click a process, or press Tab (Tab and the arrows move on). Its GPUs stand out, the
|
|
94
|
+
list opens with it picked, and the bottom line sums it up.
|
|
95
|
+
- **Pick a GPU's jobs**: click anywhere else in its box. The same again lets go, and closes the list it
|
|
96
|
+
opened.
|
|
97
|
+
- `f`, or a double click: only the picked jobs' GPUs; again for all.
|
|
91
98
|
- `p`: every job in a list. Click a heading to sort, again to reverse (or `s`, `r`). ← → scroll the
|
|
92
99
|
picked job's command. Esc, or a click on an empty spot, lets go.
|
|
93
|
-
- `t` stops
|
|
94
|
-
the
|
|
100
|
+
- `t` stops the picked jobs of yours (SIGTERM), `k` kills them (SIGKILL): after a `y`, and only those
|
|
101
|
+
still the jobs you picked.
|
|
95
102
|
|
|
96
103
|
The list, busiest first, one job picked; and that job with `f`:
|
|
97
104
|
|
|
@@ -118,9 +125,11 @@ The list, busiest first, one job picked; and that job with `f`:
|
|
|
118
125
|
| `v` / `V` | next / previous view |
|
|
119
126
|
| `c` / `C` | next / previous theme |
|
|
120
127
|
| `p` | process list, open or close |
|
|
128
|
+
| point at a graph | utilization and memory then |
|
|
121
129
|
| click, Tab, ↑ ↓ | pick a job |
|
|
122
|
-
|
|
|
123
|
-
| `
|
|
130
|
+
| click a GPU | pick its jobs |
|
|
131
|
+
| `f`, double click | only the picked jobs' GPUs, or all |
|
|
132
|
+
| `t`, `k`, then `y` | stop (SIGTERM) or kill (SIGKILL) the picked jobs |
|
|
124
133
|
| click a heading, `s`, `r` | sort the list, by the next heading, the other way |
|
|
125
134
|
| wheel, ← → | scroll; sideways the commands |
|
|
126
135
|
| PgUp, PgDn | scroll the GPUs |
|
|
@@ -146,4 +155,5 @@ The view, theme, list order and hidden GPUs, for each computer you connect from,
|
|
|
146
155
|
|
|
147
156
|
## License
|
|
148
157
|
|
|
149
|
-
MIT
|
|
158
|
+
MIT. I designed nvmon's features and usability and tested it on shared H100 servers; most of the code was
|
|
159
|
+
written with an AI coding assistant.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A fancy NVIDIA GPU monitor for the terminal. One Python file, no dependencies.
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -28,13 +28,14 @@ ssh -t HOST nvmon # over ssh: -t gives it a terminal
|
|
|
28
28
|
|
|
29
29
|
- **Top edge**: number and name, power against its limit, temperature, fan (if any), clock. Warnings
|
|
30
30
|
only when something is off: `SLOWED: power cap | too hot | hw brake`, `PCIe DEGRADED: x16 -> x8`.
|
|
31
|
-
- **Graph**: utilization over time, newest on the right.
|
|
31
|
+
- **Graph**: utilization over time, newest on the right. Point at it for the utilization and memory at that
|
|
32
|
+
moment.
|
|
32
33
|
- **GPU**: how much of the time a kernel ran. **cores / tensor** (H100 and newer): how busy that kept
|
|
33
34
|
the chip, and how much was Tensor Core math. One small kernel running nonstop reads GPU 100 % with
|
|
34
35
|
cores at 30 %.
|
|
35
36
|
- **MEM**: memory in use. **CPU -> GPU**, **GPU -> CPU**: PCIe traffic against the link's top speed.
|
|
36
37
|
- **Bottom edge**: the processes, with run time and memory, by account and conda environment (yours
|
|
37
|
-
|
|
38
|
+
underlined); `+3` for those that did not fit.
|
|
38
39
|
|
|
39
40
|
The bottom line has the GPU numbers (click one, or press its key, to hide or show it), the keys, and a
|
|
40
41
|
newer release when there is one.
|
|
@@ -55,21 +56,27 @@ Shift+wheel or ← → sideways.
|
|
|
55
56
|
|
|
56
57
|
**Numbers only** and **GPU and MEM only**: narrow, always one column, for a window beside another.
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
<div align="center">
|
|
60
|
+
|
|
61
|
+
| Numbers only | GPU and MEM only |
|
|
62
|
+
|:---:|:---:|
|
|
60
63
|
| <img src="https://raw.githubusercontent.com/Han-DongHeun/nvmon/main/docs/view-numbers.png" alt="nvmon with the numbers alone" width="200"> | <img src="https://raw.githubusercontent.com/Han-DongHeun/nvmon/main/docs/view-brief.png" alt="nvmon with GPU and MEM alone" width="200"> |
|
|
61
64
|
|
|
65
|
+
</div>
|
|
66
|
+
|
|
62
67
|
## Processes and jobs: `p`
|
|
63
68
|
|
|
64
69
|
A **job** is a process, or a launcher (torchrun, deepspeed…) with its workers.
|
|
65
70
|
|
|
66
|
-
- **Pick** one: click a process, or press Tab (Tab and the arrows move on). Its GPUs stand out,
|
|
67
|
-
bottom line sums it up.
|
|
68
|
-
-
|
|
71
|
+
- **Pick** one: click a process, or press Tab (Tab and the arrows move on). Its GPUs stand out, the
|
|
72
|
+
list opens with it picked, and the bottom line sums it up.
|
|
73
|
+
- **Pick a GPU's jobs**: click anywhere else in its box. The same again lets go, and closes the list it
|
|
74
|
+
opened.
|
|
75
|
+
- `f`, or a double click: only the picked jobs' GPUs; again for all.
|
|
69
76
|
- `p`: every job in a list. Click a heading to sort, again to reverse (or `s`, `r`). ← → scroll the
|
|
70
77
|
picked job's command. Esc, or a click on an empty spot, lets go.
|
|
71
|
-
- `t` stops
|
|
72
|
-
the
|
|
78
|
+
- `t` stops the picked jobs of yours (SIGTERM), `k` kills them (SIGKILL): after a `y`, and only those
|
|
79
|
+
still the jobs you picked.
|
|
73
80
|
|
|
74
81
|
The list, busiest first, one job picked; and that job with `f`:
|
|
75
82
|
|
|
@@ -96,9 +103,11 @@ The list, busiest first, one job picked; and that job with `f`:
|
|
|
96
103
|
| `v` / `V` | next / previous view |
|
|
97
104
|
| `c` / `C` | next / previous theme |
|
|
98
105
|
| `p` | process list, open or close |
|
|
106
|
+
| point at a graph | utilization and memory then |
|
|
99
107
|
| click, Tab, ↑ ↓ | pick a job |
|
|
100
|
-
|
|
|
101
|
-
| `
|
|
108
|
+
| click a GPU | pick its jobs |
|
|
109
|
+
| `f`, double click | only the picked jobs' GPUs, or all |
|
|
110
|
+
| `t`, `k`, then `y` | stop (SIGTERM) or kill (SIGKILL) the picked jobs |
|
|
102
111
|
| click a heading, `s`, `r` | sort the list, by the next heading, the other way |
|
|
103
112
|
| wheel, ← → | scroll; sideways the commands |
|
|
104
113
|
| PgUp, PgDn | scroll the GPUs |
|
|
@@ -124,4 +133,5 @@ The view, theme, list order and hidden GPUs, for each computer you connect from,
|
|
|
124
133
|
|
|
125
134
|
## License
|
|
126
135
|
|
|
127
|
-
MIT
|
|
136
|
+
MIT. I designed nvmon's features and usability and tested it on shared H100 servers; most of the code was
|
|
137
|
+
written with an AI coding assistant.
|
|
@@ -29,7 +29,7 @@ from ctypes import byref, c_int, c_uint, c_ulonglong, c_void_p
|
|
|
29
29
|
from functools import lru_cache
|
|
30
30
|
from typing import NamedTuple, Optional
|
|
31
31
|
|
|
32
|
-
__version__ = "0.3.
|
|
32
|
+
__version__ = "0.3.4" # "+dev": work past this release; the release commit sets the next number
|
|
33
33
|
REPO = "https://github.com/Han-DongHeun/nvmon"
|
|
34
34
|
RELEASES = REPO + "/releases" # release notes, and nvmon.py for those who copy the file
|
|
35
35
|
PYPI_JSON = "https://pypi.org/pypi/nvmon/json"
|
|
@@ -238,6 +238,7 @@ SHOWS = "truecolor" # what the terminal shows (color_mode), set with it
|
|
|
238
238
|
# Warnings on the top edge, the same in every theme: yellow = worth a look, orange = slowed, red = act.
|
|
239
239
|
WARN, SLOW, ALERT = _rgb(YELLOW), _rgb(ORANGE), _rgb(RED)
|
|
240
240
|
DIM, FAINT = _fg(240), _fg(238)
|
|
241
|
+
UNDERLINE = "[4m" # first in a style, so that the next one starts with a reset (see paint)
|
|
241
242
|
BOLD, RESET = "\x1b[1m", "\x1b[0m"
|
|
242
243
|
|
|
243
244
|
|
|
@@ -282,7 +283,6 @@ class ProcessInfo(ctypes.Structure): # nvmlProcessInfo_v2_t
|
|
|
282
283
|
|
|
283
284
|
|
|
284
285
|
NVML_VALUE_NOT_AVAILABLE = 2**64 - 1 # usedGpuMemory under Windows WDDM
|
|
285
|
-
MAX_PROCESSES = 64
|
|
286
286
|
|
|
287
287
|
|
|
288
288
|
class GpmSupport(ctypes.Structure): # nvmlGpmSupport_t
|
|
@@ -349,7 +349,8 @@ class Process(NamedTuple):
|
|
|
349
349
|
name: str # script name for Python, else the executable
|
|
350
350
|
command: str # the command line from `name` on, e.g. "train.py --lr 3e-4"
|
|
351
351
|
mem: Optional[float] # GPU memory, GiB
|
|
352
|
-
owner: Optional[str] # account; None
|
|
352
|
+
owner: Optional[str] # account; None where unknown (Windows)
|
|
353
|
+
mine: bool # ours: its account underlined, and only ours can be stopped
|
|
353
354
|
env: Optional[str] # conda environment
|
|
354
355
|
started: Optional[float] # start time, seconds since the epoch
|
|
355
356
|
job: int # the job it is part of: its launcher's PID (torchrun...), else its own
|
|
@@ -388,7 +389,8 @@ class Gpu:
|
|
|
388
389
|
nv.nvmlDeviceGetName(handle, name, NVML_DEVICE_NAME_BUFFER_SIZE)
|
|
389
390
|
name = name.value.decode("utf-8", "replace")
|
|
390
391
|
self.name = name[len("NVIDIA "):] if name.startswith("NVIDIA ") else name
|
|
391
|
-
self.history = deque(maxlen=HISTORY)
|
|
392
|
+
self.history = deque(maxlen=HISTORY) # utilization, one a frame: the graph
|
|
393
|
+
self.memory = deque(maxlen=HISTORY) # and the memory in use then (GiB), for the chip (View.hover)
|
|
392
394
|
self.sample = None # the newest reading, written by poll()
|
|
393
395
|
self.now = None # the reading on screen, taken from `sample` by frame()
|
|
394
396
|
self.pcie_max_gen = self._uint("nvmlDeviceGetMaxPcieLinkGeneration")
|
|
@@ -398,7 +400,8 @@ class Gpu:
|
|
|
398
400
|
self._facts = {} # pid -> what _facts_of says: fixed for a process's life, so read once
|
|
399
401
|
self._listed, self._listed_mem = [], None # process list, and the memory in use when it was read
|
|
400
402
|
# Per-process utilization: the driver's samples since `seen` (µs), read at most every second.
|
|
401
|
-
self._util, self._util_due, self._util_seen
|
|
403
|
+
self._util, self._util_due, self._util_seen = None, 0, 0
|
|
404
|
+
self._sizes = {} # entry type -> how many the last array of them held, see _entries
|
|
402
405
|
|
|
403
406
|
def _call(self, fn, *args):
|
|
404
407
|
"""Run the device query `fn`; its NVML return code. A query this GPU or driver cannot answer is not
|
|
@@ -475,21 +478,35 @@ class Gpu:
|
|
|
475
478
|
return None
|
|
476
479
|
return next(((label, color) for bits, label, color in SLOWDOWNS if reasons.value & bits), None)
|
|
477
480
|
|
|
481
|
+
def _entries(self, kind, query):
|
|
482
|
+
"""(return code, entries) of `query(array, count)`, a query that fills an array of `kind` and sets
|
|
483
|
+
`count`. The array is made bigger while the driver says it is too small (more may come between its
|
|
484
|
+
answer and asking again), and starts that big the next time."""
|
|
485
|
+
size = self._sizes.get(kind, 64)
|
|
486
|
+
for _ in range(3):
|
|
487
|
+
array, count = (kind * size)(), c_uint(size)
|
|
488
|
+
rc = query(array, count)
|
|
489
|
+
if rc != NVML_ERROR_INSUFFICIENT_SIZE:
|
|
490
|
+
break
|
|
491
|
+
size = 2 * max(count.value, size)
|
|
492
|
+
self._sizes[kind] = size
|
|
493
|
+
return rc, array[:count.value] if rc == NVML_SUCCESS else []
|
|
494
|
+
|
|
478
495
|
def _processes(self):
|
|
479
|
-
|
|
480
|
-
|
|
496
|
+
rc, running = self._entries(ProcessInfo, lambda array, count: self._call(
|
|
497
|
+
"nvmlDeviceGetComputeRunningProcesses_v3", byref(count), array))
|
|
498
|
+
if rc != NVML_SUCCESS:
|
|
481
499
|
return []
|
|
482
|
-
running = infos[:count.value]
|
|
483
500
|
self._facts = {p.pid: self._facts.get(p.pid) or self._facts_of(p.pid) for p in running}
|
|
484
501
|
procs = []
|
|
485
502
|
for p in running:
|
|
486
503
|
mem = None if p.usedGpuMemory == NVML_VALUE_NOT_AVAILABLE else p.usedGpuMemory / 2**30
|
|
487
|
-
name, command, owner, env, started, job, launcher = self._facts[p.pid]
|
|
488
|
-
procs.append(Process(p.pid, name, command, mem, owner, env, started, job, launcher))
|
|
504
|
+
name, command, owner, mine, env, started, job, launcher = self._facts[p.pid]
|
|
505
|
+
procs.append(Process(p.pid, name, command, mem, owner, mine, env, started, job, launcher))
|
|
489
506
|
return procs
|
|
490
507
|
|
|
491
508
|
def _facts_of(self, pid):
|
|
492
|
-
"""(name, command, owner, conda environment, start time, job, launcher name) of `pid`."""
|
|
509
|
+
"""(name, command, owner, ours, conda environment, start time, job, launcher name) of `pid`."""
|
|
493
510
|
launcher = launcher_of(pid)
|
|
494
511
|
return (self._command(pid) + process_facts(pid)
|
|
495
512
|
+ ((launcher, self._command(launcher)[0]) if launcher else (pid, None)))
|
|
@@ -520,18 +537,12 @@ class Gpu:
|
|
|
520
537
|
def _process_util(self):
|
|
521
538
|
"""{pid: % of the time its kernels ran} since the last call; None where the GPU cannot tell
|
|
522
539
|
(with MIG, say). The driver takes some 2 ms to answer, so this is asked only while it is shown."""
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
rc = self._call("nvmlDeviceGetProcessUtilization", self._util_buf, byref(count),
|
|
526
|
-
c_ulonglong(self._util_seen))
|
|
527
|
-
if rc != NVML_ERROR_INSUFFICIENT_SIZE:
|
|
528
|
-
break
|
|
529
|
-
self._util_buf = (ProcessUtil * (2 * count.value))()
|
|
540
|
+
rc, samples = self._entries(ProcessUtil, lambda array, count: self._call(
|
|
541
|
+
"nvmlDeviceGetProcessUtilization", array, byref(count), c_ulonglong(self._util_seen)))
|
|
530
542
|
if rc == NVML_ERROR_NOT_FOUND: # no new samples: nothing ran
|
|
531
543
|
return {}
|
|
532
544
|
if rc != NVML_SUCCESS:
|
|
533
545
|
return None
|
|
534
|
-
samples = self._util_buf[:count.value]
|
|
535
546
|
self._util_seen = max([self._util_seen] + [s.timeStamp for s in samples])
|
|
536
547
|
runs = {}
|
|
537
548
|
for s in samples:
|
|
@@ -578,6 +589,7 @@ class Gpu:
|
|
|
578
589
|
"""Put the newest reading on screen for the next frame; the graph gains one column per frame."""
|
|
579
590
|
self.now = self.sample
|
|
580
591
|
self.history.append(self.now.util or 0)
|
|
592
|
+
self.memory.append(self.now.mem_used)
|
|
581
593
|
|
|
582
594
|
|
|
583
595
|
class Poller(threading.Thread):
|
|
@@ -633,8 +645,10 @@ def versions(nv):
|
|
|
633
645
|
|
|
634
646
|
|
|
635
647
|
def process_facts(pid):
|
|
636
|
-
"""(owner, conda environment, start time) of `pid` from /proc; Nones
|
|
637
|
-
|
|
648
|
+
"""(owner, whether it is ours, conda environment, start time) of `pid` from /proc; Nones, and not ours,
|
|
649
|
+
where unknown (e.g. Windows)."""
|
|
650
|
+
uid = uid_of(pid)
|
|
651
|
+
return account_name(uid), uid is not None and uid == os.getuid(), conda_env(pid), start_time(pid)
|
|
638
652
|
|
|
639
653
|
|
|
640
654
|
def conda_env(pid):
|
|
@@ -666,10 +680,9 @@ def start_time(pid):
|
|
|
666
680
|
return boot + ticks / os.sysconf("SC_CLK_TCK")
|
|
667
681
|
|
|
668
682
|
|
|
669
|
-
def
|
|
670
|
-
"""
|
|
671
|
-
uid
|
|
672
|
-
if uid is None or uid == os.getuid():
|
|
683
|
+
def account_name(uid):
|
|
684
|
+
"""The name of the account `uid`; None for None."""
|
|
685
|
+
if uid is None:
|
|
673
686
|
return None
|
|
674
687
|
import pwd
|
|
675
688
|
try:
|
|
@@ -757,6 +770,7 @@ class Job(NamedTuple):
|
|
|
757
770
|
mem: float # GiB, on all of them
|
|
758
771
|
started: Optional[float]
|
|
759
772
|
owner: Optional[str]
|
|
773
|
+
mine: bool
|
|
760
774
|
env: Optional[str]
|
|
761
775
|
command: str # its biggest process's command line
|
|
762
776
|
|
|
@@ -780,7 +794,7 @@ def jobs(gpus):
|
|
|
780
794
|
found.append(Job(pid, main.name, main.launcher, tuple(sorted({(p.pid, p.started) for p in procs})),
|
|
781
795
|
tuple(sorted({gpu.index for gpu, _ in entries})),
|
|
782
796
|
sum(shares.values()) / len(shares) if shares else None, sum(p.mem or 0 for p in procs),
|
|
783
|
-
min(starts) if starts else None, main.owner, main.env, main.command))
|
|
797
|
+
min(starts) if starts else None, main.owner, main.mine, main.env, main.command))
|
|
784
798
|
return sorted(found, key=lambda job: (job.gpus, job.pid))
|
|
785
799
|
|
|
786
800
|
|
|
@@ -795,16 +809,29 @@ def ranges(numbers):
|
|
|
795
809
|
return ",".join(str(a) if a == b else "{}-{}".format(a, b) for a, b in spans)
|
|
796
810
|
|
|
797
811
|
|
|
812
|
+
def gpu_numbers(indices):
|
|
813
|
+
""""GPU 3", "GPUs 4-5,7"."""
|
|
814
|
+
indices = sorted(set(indices))
|
|
815
|
+
return "GPU{} {}".format("s" if len(indices) > 1 else "", ranges(indices))
|
|
816
|
+
|
|
817
|
+
|
|
798
818
|
def account(job):
|
|
799
|
-
""""kim/torch": whose it is, and the conda environment
|
|
819
|
+
""""kim/torch": whose it is, and the conda environment."""
|
|
800
820
|
return "/".join(part for part in (job.owner, job.env) if part)
|
|
801
821
|
|
|
802
822
|
|
|
823
|
+
def tagged(who):
|
|
824
|
+
""""kim/torch", whose a process or job (`who`) is and its conda environment, as segments: dim, and ours
|
|
825
|
+
underlined, so that they stand apart without standing out."""
|
|
826
|
+
text = "/".join(part for part in (who.owner, who.env) if part)
|
|
827
|
+
return [(text, UNDERLINE + DIM if who.mine else DIM)] if text else []
|
|
828
|
+
|
|
829
|
+
|
|
803
830
|
def about(job):
|
|
804
831
|
""""kim/torch · GPUs 4-5 · util 86% · 30.2G · 2h13m · torchrun job" as segments, util in its color;
|
|
805
832
|
the launcher, often a long name, comes last, where a narrow line cuts first."""
|
|
806
|
-
parts = [(
|
|
807
|
-
parts.append((
|
|
833
|
+
parts = [sum(tagged(job), ())] if account(job) else []
|
|
834
|
+
parts.append((gpu_numbers(job.gpus), DIM))
|
|
808
835
|
if job.util is not None:
|
|
809
836
|
parts.append(("util ", DIM, "{:.0f}%".format(job.util), heat(job.util / 100)))
|
|
810
837
|
parts.append(("{:.1f}G".format(job.mem), DIM))
|
|
@@ -817,6 +844,14 @@ def about(job):
|
|
|
817
844
|
return out
|
|
818
845
|
|
|
819
846
|
|
|
847
|
+
def about_all(jobs):
|
|
848
|
+
""""GPUs 4-7 · 61.2G · 2 yours" for several jobs picked together, as segments."""
|
|
849
|
+
parts = [gpu_numbers(index for job in jobs for index in job.gpus), "{:.1f}G".format(sum(j.mem for j in jobs))]
|
|
850
|
+
ours = sum(job.mine for job in jobs)
|
|
851
|
+
parts += ["{} yours".format(ours)] if 0 < ours < len(jobs) else []
|
|
852
|
+
return [(" · ".join(parts), DIM)]
|
|
853
|
+
|
|
854
|
+
|
|
820
855
|
def stop(job, name):
|
|
821
856
|
"""Send signal `name` ("SIGTERM" or "SIGKILL") to `job`, once /proc confirms its PIDs still are the
|
|
822
857
|
processes we saw: started when we saw them, holding a GPU open (inside a container, NVML can report
|
|
@@ -986,6 +1021,31 @@ def graph(history, width, height, two_tone=True):
|
|
|
986
1021
|
return rows
|
|
987
1022
|
|
|
988
1023
|
|
|
1024
|
+
# The pointer over a graph (View.hover): a guide up the empty part of its column, and a chip with that moment.
|
|
1025
|
+
GUIDE = _rgb((150, 150, 150))
|
|
1026
|
+
CHIP = "\x1b[48;2;235;235;235m" + _rgb((24, 24, 27))
|
|
1027
|
+
COLOR_24 = re.compile(r"(\x1b\[[34]8;2;)(\d+);(\d+);(\d+)m")
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def lit(style):
|
|
1031
|
+
"""`style` lit up, for the graph column under the pointer: its colors halfway to white; on PAPER, where
|
|
1032
|
+
restyle turns lightness around, halfway to black."""
|
|
1033
|
+
paper = ON_PAPER if style.startswith(ON_PAPER) else ""
|
|
1034
|
+
to = 0 if paper else 255
|
|
1035
|
+
return paper + COLOR_24.sub(lambda m: m.group(1) + ";".join(
|
|
1036
|
+
str((int(v) + to) // 2) for v in m.group(2, 3, 4)) + "m", style[len(paper):])
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def ago(seconds):
|
|
1040
|
+
"""How long ago, as short as a run time: now, 14.5s ago, 3m05s ago, 1h02m ago."""
|
|
1041
|
+
if seconds < 0.05:
|
|
1042
|
+
return "now"
|
|
1043
|
+
if seconds < 60:
|
|
1044
|
+
return "{:g}s ago".format(round(seconds, 1))
|
|
1045
|
+
m, s = divmod(int(round(seconds)), 60)
|
|
1046
|
+
return "{}m{:02d}s ago".format(m, s) if m < 60 else "{}h{:02d}m ago".format(m // 60, m % 60)
|
|
1047
|
+
|
|
1048
|
+
|
|
989
1049
|
def row(left, right=()):
|
|
990
1050
|
"""One stats line: `left`, then the `right` segments flush right; exactly INFO_W cells."""
|
|
991
1051
|
room = INFO_W - width_of(right)
|
|
@@ -1034,12 +1094,12 @@ def degraded(gpu):
|
|
|
1034
1094
|
return bool(s.pcie_width and gpu.pcie_max_width and s.pcie_width < gpu.pcie_max_width)
|
|
1035
1095
|
|
|
1036
1096
|
|
|
1037
|
-
def panel(gpu, width, height, selected=
|
|
1097
|
+
def panel(gpu, width, height, selected=frozenset(), left="graph", scroll=(0, 0)):
|
|
1038
1098
|
"""One GPU's box: beside the stats, as `left` says (see LEFTS), the utilization graph, or the GPU's
|
|
1039
1099
|
processes one a line, moved `scroll` = (lines, cells) on (see process_lines); or the stats alone. Faded
|
|
1040
|
-
when
|
|
1100
|
+
when jobs are `selected` (their PIDs) and none runs here."""
|
|
1041
1101
|
s = gpu.now
|
|
1042
|
-
faded = selected
|
|
1102
|
+
faded = bool(selected) and not any(p.job in selected for p in s.processes)
|
|
1043
1103
|
# Both sides as (segments, rank); when space runs out the lowest rank goes first:
|
|
1044
1104
|
# fan, name, power, clock, temperature, PCIe warning, slowdown warning. The GPU number stays.
|
|
1045
1105
|
# Fixed widths keep things from shifting.
|
|
@@ -1053,20 +1113,20 @@ def panel(gpu, width, height, selected=None, left="graph", scroll=(0, 0)):
|
|
|
1053
1113
|
parts += [] if s.fan is None else [([("FAN {:>3}%".format(s.fan), "")], 1)]
|
|
1054
1114
|
parts += [] if s.clock is None else [([("{:>4} MHz".format(s.clock), s.slowdown[1] if s.slowdown else "")], 4)]
|
|
1055
1115
|
top = edge(width, label, parts)
|
|
1056
|
-
if left in CARDS: # the numbers only
|
|
1116
|
+
if left in CARDS: # the numbers only, no processes
|
|
1057
1117
|
split = width - 1
|
|
1058
1118
|
body = [[("├" + "─" * (width - 2) + "┤", DIM)] if i is None else [("│ ", DIM)] + i + [(" │", DIM)]
|
|
1059
1119
|
for i in info(gpu, height)]
|
|
1060
|
-
label = [
|
|
1120
|
+
label = []
|
|
1061
1121
|
else:
|
|
1062
1122
|
graph_w = max(0, width - CHROME_W - INFO_W)
|
|
1063
1123
|
split = 2 + graph_w + 1 # column of the graph | stats divider
|
|
1064
1124
|
rule = [(" ├" + "─" * (INFO_W + 2) + "┤", DIM)] # across the stats, joined to the borders
|
|
1065
1125
|
if left == "graph":
|
|
1066
1126
|
beside = graph(gpu.history, graph_w, height, not faded)
|
|
1067
|
-
label = process_label(s.processes, split - 4, selected
|
|
1127
|
+
label = process_label(s.processes, split - 4, selected)
|
|
1068
1128
|
else:
|
|
1069
|
-
beside, label = process_lines(s.processes, graph_w, height, selected,
|
|
1129
|
+
beside, label = process_lines(s.processes, graph_w, height, selected, *scroll)
|
|
1070
1130
|
body = [[("│ ", DIM)] + g + (rule if i is None else [(" │ ", DIM)] + i + [(" │", DIM)])
|
|
1071
1131
|
for g, i in zip(beside, info(gpu, height))]
|
|
1072
1132
|
lines = [top] + body + [bottom_edge(width, split, label)]
|
|
@@ -1115,7 +1175,7 @@ def elapsed(started):
|
|
|
1115
1175
|
|
|
1116
1176
|
def process_entries(processes):
|
|
1117
1177
|
"""Processes grouped by owner and conda environment, groups and processes by memory, biggest first, as
|
|
1118
|
-
(tag, process): the tag "kim/torch", ""
|
|
1178
|
+
(tag, process): the tag "kim/torch", "" where neither is known."""
|
|
1119
1179
|
def mem(process):
|
|
1120
1180
|
return process.mem or 0
|
|
1121
1181
|
groups = {}
|
|
@@ -1126,9 +1186,9 @@ def process_entries(processes):
|
|
|
1126
1186
|
for process in group]
|
|
1127
1187
|
|
|
1128
1188
|
|
|
1129
|
-
def process_name(process, selected=
|
|
1130
|
-
"""The segment of `process`'s name: clicked, it means its job;
|
|
1131
|
-
return process.name, THEME.selected if process.job
|
|
1189
|
+
def process_name(process, selected=frozenset()):
|
|
1190
|
+
"""The segment of `process`'s name: clicked, it means its job; those of `selected` jobs reversed."""
|
|
1191
|
+
return process.name, THEME.selected if process.job in selected else THEME.accent, process.job
|
|
1132
1192
|
|
|
1133
1193
|
|
|
1134
1194
|
def run_time(process):
|
|
@@ -1139,45 +1199,45 @@ def memory(process):
|
|
|
1139
1199
|
return "-" if process.mem is None else "{:.1f}G".format(process.mem)
|
|
1140
1200
|
|
|
1141
1201
|
|
|
1142
|
-
def process_label(processes, room, selected=
|
|
1202
|
+
def process_label(processes, room, selected=frozenset()):
|
|
1143
1203
|
"""The processes (process_entries) on one line: "migi: train.py 2h13m 15.1G · eval.py 5m 0.5G kim/torch:
|
|
1144
|
-
a.py 1d4h 9.0G". As many whole entries as fit in `room` cells, then "+N" for the rest
|
|
1145
|
-
is left out: clicked, it opens the process list at GPU `index`."""
|
|
1204
|
+
a.py 1d4h 9.0G". As many whole entries as fit in `room` cells, then "+N" for the rest."""
|
|
1146
1205
|
entries, last = [], None
|
|
1147
1206
|
for tag, process in process_entries(processes):
|
|
1148
1207
|
gap = [] if not entries else [(" · ", DIM)] if tag == last else [(" ", "")]
|
|
1149
1208
|
details = " ".join(part for part in (run_time(process), memory(process)) if part != "-")
|
|
1150
|
-
entries.append(gap + (
|
|
1151
|
-
+ ([(" " + details, DIM, process.job)] if details else []))
|
|
1209
|
+
entries.append(gap + (tagged(process) + [(": ", DIM)] if tag and tag != last else [])
|
|
1210
|
+
+ [process_name(process, selected)] + ([(" " + details, DIM, process.job)] if details else []))
|
|
1152
1211
|
last = tag
|
|
1153
1212
|
|
|
1154
1213
|
def more(n):
|
|
1155
|
-
return (" +{}".format(n) if n else
|
|
1214
|
+
return [(" +{}".format(n), DIM)] if n else []
|
|
1156
1215
|
label = []
|
|
1157
1216
|
for i, entry in enumerate(entries):
|
|
1158
1217
|
rest = len(entries) - i - 1
|
|
1159
|
-
if width_of(label + entry
|
|
1218
|
+
if width_of(label + entry + more(rest)) > room:
|
|
1160
1219
|
# The first entry is always shown, cut if need be; later ones collapse into the count.
|
|
1161
|
-
return clip(entry, room -
|
|
1220
|
+
return clip(entry, room - width_of(more(rest))) + more(rest) if not label else label + more(rest + 1)
|
|
1162
1221
|
label += entry
|
|
1163
|
-
return label
|
|
1222
|
+
return label
|
|
1164
1223
|
|
|
1165
1224
|
|
|
1166
|
-
def process_lines(processes, room, height, selected=
|
|
1225
|
+
def process_lines(processes, room, height, selected=frozenset(), top=0, shift=0):
|
|
1167
1226
|
"""The processes (process_listing) one a line, `height` lines of `room` cells, from
|
|
1168
1227
|
line `top` on and `shift` cells in (see listing_extent), and the label for the box's bottom edge: which
|
|
1169
|
-
lines show of how many, when not all do
|
|
1228
|
+
lines show of how many, when not all do."""
|
|
1170
1229
|
lines = process_listing(processes, selected)
|
|
1171
|
-
# Sideways the command moves; account, run time and memory
|
|
1172
|
-
shown = [pad(clip(
|
|
1173
|
-
label = [("{}-{} of {}
|
|
1174
|
-
return shown + [[(" " * room, "")]] * (height - len(shown)), label
|
|
1230
|
+
# Sideways the command moves; account, run time and memory stay.
|
|
1231
|
+
shown = [pad(clip(fixed + skip(moving, shift), room), room) for fixed, moving in lines[top:top + height]]
|
|
1232
|
+
label = [("{}-{} of {}".format(top + 1, top + len(shown), len(lines)), DIM)] if len(lines) > height else []
|
|
1233
|
+
return shown + [[(" " * room, "")]] * (height - len(shown)), label
|
|
1175
1234
|
|
|
1176
1235
|
|
|
1177
|
-
def process_listing(processes, selected=
|
|
1178
|
-
"""process_lines's lines, whole
|
|
1179
|
-
then the command line, the name in the process names' color and
|
|
1180
|
-
26.1G train.py --lr 3". The columns are as wide as the GPU's
|
|
1236
|
+
def process_listing(processes, selected=frozenset()):
|
|
1237
|
+
"""process_lines's lines, whole, as (what stays, what moves sideways): account and environment (see
|
|
1238
|
+
tagged), run time and memory in columns; then the command line, the name in the process names' color and
|
|
1239
|
+
the arguments dim: "kim/torch: 2d5h 26.1G train.py --lr 3". The columns are as wide as the GPU's
|
|
1240
|
+
longest."""
|
|
1181
1241
|
entries = process_entries(processes)
|
|
1182
1242
|
tag_w = max([0] + [len(tag) + 1 for tag, _ in entries]) # the longest "kim/torch:", when any has one
|
|
1183
1243
|
time_w = max([0] + [len(run_time(process)) for _, process in entries])
|
|
@@ -1186,16 +1246,18 @@ def process_listing(processes, selected=None):
|
|
|
1186
1246
|
for tag, process in entries:
|
|
1187
1247
|
command = process.command
|
|
1188
1248
|
rest = command[len(process.name):] if command.startswith(process.name) else " " + command
|
|
1189
|
-
|
|
1249
|
+
who = tagged(process) + [(":", DIM)] if tag else []
|
|
1250
|
+
numbers = " " * (tag_w + 2 - width_of(who) if tag_w else 0) + "{:>{}} {:>{}} ".format(
|
|
1190
1251
|
run_time(process), time_w, memory(process), mem_w)
|
|
1191
|
-
lines.append([
|
|
1252
|
+
lines.append(([seg[:2] + (process.job,) for seg in who] + [(numbers, DIM, process.job)],
|
|
1253
|
+
[process_name(process, selected), (rest, DIM, process.job)]))
|
|
1192
1254
|
return lines
|
|
1193
1255
|
|
|
1194
1256
|
|
|
1195
1257
|
def listing_extent(processes, room, height):
|
|
1196
1258
|
"""How far process_lines can move on for these processes: (lines, cells)."""
|
|
1197
1259
|
lines = process_listing(processes)
|
|
1198
|
-
return max(0, len(lines) - height), max([0] + [width_of(
|
|
1260
|
+
return max(0, len(lines) - height), max([0] + [width_of(fixed + moving) - room for fixed, moving in lines])
|
|
1199
1261
|
|
|
1200
1262
|
|
|
1201
1263
|
def skip(line, cells):
|
|
@@ -1240,7 +1302,7 @@ def layout(count, width, height, left="graph"):
|
|
|
1240
1302
|
return cols, max(MIN_INNER_H, min(MAX_INNER_H, height // rows - 2))
|
|
1241
1303
|
|
|
1242
1304
|
|
|
1243
|
-
def render(gpus, width, height, selected=
|
|
1305
|
+
def render(gpus, width, height, selected=frozenset(), shape=None, left="graph", scroll=None):
|
|
1244
1306
|
"""The GPU boxes, in `shape` = (columns, inner height) or else the layout that fits: exactly `height`
|
|
1245
1307
|
lines of exactly `width` cells. `left`: see panel; the processes moved on as `scroll` has it for each
|
|
1246
1308
|
GPU (its index: (lines, cells))."""
|
|
@@ -1256,57 +1318,60 @@ def render(gpus, width, height, selected=None, shape=None, left="graph", scroll=
|
|
|
1256
1318
|
return lines + [[(" " * width, "")]] * (height - len(lines))
|
|
1257
1319
|
|
|
1258
1320
|
|
|
1259
|
-
# The process list's columns: heading, width (">" when flush right), the cell for a job as
|
|
1260
|
-
#
|
|
1321
|
+
# The process list's columns: heading, width (">" when flush right), the cell for a job as segments, and the
|
|
1322
|
+
# key it sorts by. "command" takes whatever width is left.
|
|
1261
1323
|
COLUMNS = [
|
|
1262
|
-
("PID", 8, lambda j: (str(j.pid), ""), lambda j: j.pid),
|
|
1263
|
-
("account/env", 14, lambda j: (
|
|
1264
|
-
("process", 22, lambda j: (j.name + (" {} workers".format(len(j.members)) if len(j.members) > 1 else ""),
|
|
1265
|
-
|
|
1266
|
-
("GPUs", 8, lambda j: (ranges(j.gpus), ""), lambda j: j.gpus),
|
|
1267
|
-
(">util", 5, lambda j: share(j.util, 100)
|
|
1324
|
+
("PID", 8, lambda j: [(str(j.pid), "")], lambda j: j.pid),
|
|
1325
|
+
("account/env", 14, lambda j: tagged(j), account),
|
|
1326
|
+
("process", 22, lambda j: [(j.name + (" {} workers".format(len(j.members)) if len(j.members) > 1 else ""),
|
|
1327
|
+
THEME.accent)], lambda j: j.name.lower()),
|
|
1328
|
+
("GPUs", 8, lambda j: [(ranges(j.gpus), "")], lambda j: j.gpus),
|
|
1329
|
+
(">util", 5, lambda j: share(j.util, 100) if j.util is not None else [("-", "")],
|
|
1268
1330
|
lambda j: -1 if j.util is None else j.util),
|
|
1269
|
-
(">memory", 7, lambda j: ("{:.1f}G".format(j.mem), ""), lambda j: j.mem),
|
|
1270
|
-
(">time", 7, lambda j: (elapsed(j.started) if j.started is not None else "-", ""),
|
|
1331
|
+
(">memory", 7, lambda j: [("{:.1f}G".format(j.mem), "")], lambda j: j.mem),
|
|
1332
|
+
(">time", 7, lambda j: [(elapsed(j.started) if j.started is not None else "-", "")],
|
|
1271
1333
|
lambda j: -(j.started or 0)),
|
|
1272
|
-
("command", 0, lambda j: (j.command, DIM), lambda j: j.command),
|
|
1334
|
+
("command", 0, lambda j: [(j.command, DIM)], lambda j: j.command),
|
|
1273
1335
|
]
|
|
1274
1336
|
SORTS = {heading.lstrip(">"): key for heading, _, _, key in COLUMNS}
|
|
1275
1337
|
COMMAND_AT = 1 + sum(size + 2 for _, size, _, _ in COLUMNS[:-1]) # the column where commands start
|
|
1276
1338
|
|
|
1277
1339
|
|
|
1278
1340
|
def cells(values, width, fill=("",)):
|
|
1279
|
-
"""One list line of `width` cells from
|
|
1280
|
-
|
|
1341
|
+
"""One list line of `width` cells from the columns' segments, two spaces apart; `fill` = (style, ...) of
|
|
1342
|
+
the spaces, so that a selected row stays one bar and clicks anywhere: those padding a column mean what
|
|
1343
|
+
its last segment does, but take nothing else of it, an underline, say."""
|
|
1281
1344
|
line, used = [(" ",) + fill], 1
|
|
1282
|
-
for (heading, size, _, _),
|
|
1345
|
+
for (heading, size, _, _), segments in zip(COLUMNS, values):
|
|
1283
1346
|
size = size or max(0, width - used - 1)
|
|
1284
|
-
|
|
1285
|
-
|
|
1347
|
+
segments = clip(segments, size)
|
|
1348
|
+
padding = [(" " * (size - width_of(segments)),) + fill[:1] + (segments[-1][2:3] if segments else fill[1:])]
|
|
1349
|
+
line += padding + segments if heading.startswith(">") else segments + padding
|
|
1350
|
+
line.append((" " if heading == "command" else " ",) + fill)
|
|
1286
1351
|
used += size + 2
|
|
1287
1352
|
return line
|
|
1288
1353
|
|
|
1289
1354
|
|
|
1290
|
-
def process_list(jobs, width, rows, selected, top, sort, shift=0):
|
|
1355
|
+
def process_list(jobs, width, rows, selected, focus, top, sort, shift=0):
|
|
1291
1356
|
"""The list p opens, `rows` lines: the headings (clicking one sorts by it; `sort` = (column, descending)),
|
|
1292
|
-
the jobs from index `top` on (clicking one means it; the `selected`
|
|
1293
|
-
`shift` characters on), how many more. A command too long to show ends in "…"."""
|
|
1357
|
+
the jobs from index `top` on (clicking one means it; the `selected` ones reversed, the command of the one
|
|
1358
|
+
in `focus` moved `shift` characters on), how many more. A command too long to show ends in "…"."""
|
|
1294
1359
|
room = max(0, width - COMMAND_AT - 1)
|
|
1295
1360
|
column, descending = sort
|
|
1296
1361
|
head = []
|
|
1297
1362
|
for heading, _, _, _ in COLUMNS:
|
|
1298
1363
|
name = heading.lstrip(">")
|
|
1299
|
-
head.append((name + ("▼" if descending else "▲") if name == column else name,
|
|
1300
|
-
|
|
1364
|
+
head.append([(name + ("▼" if descending else "▲") if name == column else name,
|
|
1365
|
+
"" if name == column else DIM, ("sort", name))])
|
|
1301
1366
|
lines = [cells(head, width)]
|
|
1302
1367
|
shown = jobs[top:top + rows - 2]
|
|
1303
1368
|
for job in shown:
|
|
1304
|
-
chosen = job.pid
|
|
1369
|
+
chosen = job.pid in selected
|
|
1305
1370
|
values = [cell(job) for _, _, cell, _ in COLUMNS]
|
|
1306
|
-
command = "…" + job.command[shift:] if
|
|
1307
|
-
values[-1] = (command if len(command) <= room else command[:max(0, room - 1)] + "…", DIM)
|
|
1308
|
-
lines.append(cells([(
|
|
1309
|
-
|
|
1371
|
+
command = "…" + job.command[shift:] if job.pid == focus and shift else job.command
|
|
1372
|
+
values[-1] = [(command if len(command) <= room else command[:max(0, room - 1)] + "…", DIM)]
|
|
1373
|
+
lines.append(cells([[(seg[0], THEME.selected if chosen else seg[1], job.pid) for seg in column]
|
|
1374
|
+
for column in values], width, (THEME.selected if chosen else "", job.pid)))
|
|
1310
1375
|
if not jobs:
|
|
1311
1376
|
lines.append([(" no processes on these GPUs", DIM)])
|
|
1312
1377
|
above, below = top, len(jobs) - top - len(shown)
|
|
@@ -1395,22 +1460,24 @@ MOVES = {"tab": 1, "down": 1, "up": -1, "backtab": -1, "pgdn": 10, "pgup": -10,
|
|
|
1395
1460
|
|
|
1396
1461
|
|
|
1397
1462
|
class View:
|
|
1398
|
-
"""What the screen shows around the numbers, and what keys and clicks do to it: the
|
|
1399
|
-
only
|
|
1463
|
+
"""What the screen shows around the numbers, and what keys and clicks do to it: the picked jobs (one, or
|
|
1464
|
+
a GPU's), only their GPUs, the process list, a question before stopping, a note on how that went."""
|
|
1400
1465
|
|
|
1401
1466
|
def __init__(self):
|
|
1402
1467
|
self.shows = "truecolor" # the colors the terminal shows, see color_mode
|
|
1403
1468
|
self.theme = THEMES[0] # the look, see THEMES (c)
|
|
1404
1469
|
self.left = "graph" # what the boxes have beside the stats, see LEFTS (v)
|
|
1405
1470
|
self.hidden, self.indices = set(), [] # the GPUs left out, and all there are
|
|
1406
|
-
self.
|
|
1407
|
-
self.
|
|
1471
|
+
self.picked = set() # the picked jobs' PIDs
|
|
1472
|
+
self.job = None # of them the one in focus: the arrows go on from it, ← → move its command
|
|
1473
|
+
self.only = False # show only the GPUs they use
|
|
1408
1474
|
self.listing = False # the process list is open
|
|
1409
|
-
self.
|
|
1475
|
+
self.opened = False # by a pick, and so to close when the picks are let go
|
|
1476
|
+
self.top, self.follow = 0, True # the list's first job; keep the one in focus in view
|
|
1410
1477
|
self.sort = ("GPUs", False) # the list's order, and the order Tab and the arrows go in
|
|
1411
1478
|
self.asking = None # "SIGTERM" or "SIGKILL" awaiting y or n
|
|
1412
1479
|
self.note = None # (segments, until): what the last stop did
|
|
1413
|
-
self.sent = None # (
|
|
1480
|
+
self.sent = None # (PIDs, when) of a SIGTERM, to point at k if a job lives on
|
|
1414
1481
|
self.lines, self.jobs = [], [] # the last screen drawn, and the jobs on it
|
|
1415
1482
|
self.gpu_top, self.gpu_page = 0, 1 # the first row of boxes shown, and how many rows fit
|
|
1416
1483
|
self.list_rows = range(0) # the screen rows the list takes, when it is open
|
|
@@ -1418,6 +1485,10 @@ class View:
|
|
|
1418
1485
|
self.clicked = (0, None) # (when, what): the last click, to tell a double click
|
|
1419
1486
|
self.scroll = {} # GPU index: (lines, cells) its processes are moved on
|
|
1420
1487
|
self.areas = [] # (rows, columns, GPU) where those processes are on screen
|
|
1488
|
+
self.graphs = [] # (rows, columns, GPU) where the graphs are on screen
|
|
1489
|
+
self.panels = [] # (rows, columns, GPU index) where the boxes are on screen
|
|
1490
|
+
self.pointer = None # (row, column) of the mouse pointer, as the terminal last said
|
|
1491
|
+
self.interval = DEFAULT_INTERVAL # seconds from one graph column to the next
|
|
1421
1492
|
|
|
1422
1493
|
def restore(self, saved, indices):
|
|
1423
1494
|
"""Take up what an earlier run kept (Settings.load); of the GPUs `indices`, one always stays shown."""
|
|
@@ -1431,8 +1502,19 @@ class View:
|
|
|
1431
1502
|
"""What is kept for the next run: see Settings."""
|
|
1432
1503
|
return {"left": self.left, "theme": self.theme.name, "sort": self.sort, "hidden": set(self.hidden)}
|
|
1433
1504
|
|
|
1505
|
+
def pick(self, pids):
|
|
1506
|
+
"""Pick the jobs `pids`, in the list's order, the first in focus; none lets all go, and closes the
|
|
1507
|
+
list if a pick opened it."""
|
|
1508
|
+
self.picked = set(pids)
|
|
1509
|
+
self.job = next((job.pid for job in self.jobs if job.pid in self.picked), None)
|
|
1510
|
+
if not self.picked:
|
|
1511
|
+
self.only = False
|
|
1512
|
+
self.listing = self.listing and not self.opened
|
|
1513
|
+
self.opened = False
|
|
1514
|
+
|
|
1434
1515
|
def selected(self):
|
|
1435
|
-
|
|
1516
|
+
"""The picked jobs, in the list's order."""
|
|
1517
|
+
return [job for job in self.jobs if job.pid in self.picked]
|
|
1436
1518
|
|
|
1437
1519
|
def screen(self, gpus, width, height, top_line, newer):
|
|
1438
1520
|
"""The whole screen: `top_line`, the GPU boxes (and the list, when open), the bottom line."""
|
|
@@ -1440,12 +1522,17 @@ class View:
|
|
|
1440
1522
|
THEME, SHOWS = self.theme, self.shows
|
|
1441
1523
|
column, descending = self.sort
|
|
1442
1524
|
self.jobs = sorted(jobs(gpus), key=lambda job: (SORTS[column](job), job.pid), reverse=descending)
|
|
1443
|
-
job
|
|
1444
|
-
if
|
|
1525
|
+
self.picked &= {job.pid for job in self.jobs} # those that ended go
|
|
1526
|
+
if not self.picked:
|
|
1445
1527
|
self.job, self.only, self.asking = None, False, None
|
|
1528
|
+
elif self.job not in self.picked:
|
|
1529
|
+
self.job = next(job.pid for job in self.jobs if job.pid in self.picked)
|
|
1530
|
+
picked = self.selected()
|
|
1531
|
+
focus = next(job for job in picked if job.pid == self.job) if picked else None
|
|
1446
1532
|
self.remind()
|
|
1447
1533
|
self.indices = [g.index for g in gpus]
|
|
1448
|
-
|
|
1534
|
+
used = {index for job in picked for index in job.gpus}
|
|
1535
|
+
shown = [g for g in gpus if (g.index in used if self.only else g.index not in self.hidden)]
|
|
1449
1536
|
rows = min(len(self.jobs) + 2, max(4, (height - 2) // 2)) if self.listing else 0
|
|
1450
1537
|
body = self.boxes(shown, width, height - 2 - rows)
|
|
1451
1538
|
self.list_rows = range(1 + len(body), 1 + len(body) + rows)
|
|
@@ -1453,9 +1540,10 @@ class View:
|
|
|
1453
1540
|
if self.shifted != self.job:
|
|
1454
1541
|
self.shift, self.shifted = 0, self.job
|
|
1455
1542
|
room = max(0, width - COMMAND_AT - 1)
|
|
1456
|
-
self.shift = max(0, min(self.shift, len(
|
|
1457
|
-
body += process_list(self.jobs, width, rows, self.job, self.scrolled(rows - 2), self.sort,
|
|
1458
|
-
|
|
1543
|
+
self.shift = max(0, min(self.shift, len(focus.command) + 1 - room)) if focus else 0
|
|
1544
|
+
body += process_list(self.jobs, width, rows, self.picked, self.job, self.scrolled(rows - 2), self.sort,
|
|
1545
|
+
self.shift)
|
|
1546
|
+
self.lines = self.hover([top_line] + body + [self.bottom_line(width, picked, newer, gpus)])
|
|
1459
1547
|
return self.lines
|
|
1460
1548
|
|
|
1461
1549
|
def boxes(self, gpus, width, height):
|
|
@@ -1474,34 +1562,87 @@ class View:
|
|
|
1474
1562
|
height -= 1
|
|
1475
1563
|
else:
|
|
1476
1564
|
self.gpu_top, self.gpu_page = 0, rows
|
|
1477
|
-
box_w
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1565
|
+
box_w = CARD_W if self.left in CARDS else width // cols
|
|
1566
|
+
room = max(0, box_w - CHROME_W - INFO_W)
|
|
1567
|
+
self.areas, self.graphs = [], []
|
|
1568
|
+
self.panels = [(range(1 + k // cols * (inner + 2), 1 + (k // cols + 1) * (inner + 2)),
|
|
1569
|
+
range(k % cols * box_w, (k % cols + 1) * box_w), g.index) for k, g in enumerate(gpus)]
|
|
1570
|
+
for k, g in enumerate(gpus if self.left in ("graph", "processes") else []):
|
|
1571
|
+
y, x = 2 + k // cols * (inner + 2), k % cols * box_w + 2 # 2: the top line and the box's edge
|
|
1572
|
+
if self.left == "graph":
|
|
1573
|
+
self.graphs.append((range(y, y + inner), range(x, x + room), g))
|
|
1574
|
+
continue
|
|
1575
|
+
most = listing_extent(g.now.processes, room, inner) # the processes' offsets in range
|
|
1481
1576
|
top, shift = self.scroll.get(g.index, (0, 0))
|
|
1482
1577
|
self.scroll[g.index] = min(max(top, 0), most[0]), min(max(shift, 0), most[1])
|
|
1483
|
-
y, x = 2 + k // cols * (inner + 2), k % cols * box_w + 2 # 2: the top line and the box's edge
|
|
1484
1578
|
self.areas.append((range(y, y + inner), range(x, x + room), g.index))
|
|
1485
|
-
return render(gpus, width, height, self.
|
|
1579
|
+
return render(gpus, width, height, self.picked, (cols, inner), self.left, self.scroll) + note
|
|
1580
|
+
|
|
1581
|
+
def hovered(self):
|
|
1582
|
+
"""(GPU, graph rows, graph columns, the column pointed at, how many samples back from the newest that
|
|
1583
|
+
is) when the pointer is over a graph column that has a sample; else None."""
|
|
1584
|
+
if self.pointer is None:
|
|
1585
|
+
return None
|
|
1586
|
+
row, col = self.pointer
|
|
1587
|
+
for rows, cols, gpu in self.graphs:
|
|
1588
|
+
if row in rows and col in cols:
|
|
1589
|
+
back = cols.stop - 1 - col # the newest is on the right
|
|
1590
|
+
if back < len(gpu.history):
|
|
1591
|
+
return gpu, rows, cols, col, back
|
|
1592
|
+
return None
|
|
1593
|
+
|
|
1594
|
+
def pointed(self):
|
|
1595
|
+
"""(GPU index, column) of the graph column under the pointer, or None: what the chip is about."""
|
|
1596
|
+
hovered = self.hovered()
|
|
1597
|
+
return hovered and (hovered[0].index, hovered[3])
|
|
1598
|
+
|
|
1599
|
+
def hover(self, lines):
|
|
1600
|
+
"""`lines` with the column under the pointer lit up, and beside the pointer a chip with that moment:
|
|
1601
|
+
the utilization, the memory in use, and how long ago."""
|
|
1602
|
+
hovered = self.hovered()
|
|
1603
|
+
if hovered is None:
|
|
1604
|
+
return lines
|
|
1605
|
+
gpu, rows, cols, col, back = hovered
|
|
1606
|
+
mem = gpu.memory[-1 - back]
|
|
1607
|
+
parts = ["{}%".format(gpu.history[-1 - back]), None if mem is None else "{:.1f}G".format(mem),
|
|
1608
|
+
ago(back * self.interval)]
|
|
1609
|
+
# As much as fits on the pointer's right, or else its left: all, without the memory, the utilization alone.
|
|
1610
|
+
chips = [" {} ".format(" · ".join(filter(None, shown))) for shown in (parts, parts[::2], parts[:1])]
|
|
1611
|
+
place = next(((chip, start) for chip in chips for start in (col + 2, col - 1 - len(chip))
|
|
1612
|
+
if cols.start <= start and start + len(chip) <= cols.stop), None)
|
|
1613
|
+
lines = list(lines)
|
|
1614
|
+
for row in rows:
|
|
1615
|
+
cells = [(ch,) + tuple(seg[1:]) for seg in lines[row] for ch in seg[0]]
|
|
1616
|
+
ch, style = cells[col][:2]
|
|
1617
|
+
paper = ON_PAPER if style.startswith(ON_PAPER) else ""
|
|
1618
|
+
cells[col] = ("│", paper + GUIDE) if ch == " " else (ch, lit(style))
|
|
1619
|
+
if row == self.pointer[0] and place:
|
|
1620
|
+
chip, start = place
|
|
1621
|
+
bold = chip.index("%") + 1
|
|
1622
|
+
for k, c in enumerate(chip):
|
|
1623
|
+
cells[start + k] = (c, paper + CHIP + (BOLD if k < bold else ""))
|
|
1624
|
+
lines[row] = [("".join(c[0] for c in run),) + key for key, run in
|
|
1625
|
+
itertools.groupby(cells, key=lambda c: c[1:])]
|
|
1626
|
+
return lines
|
|
1486
1627
|
|
|
1487
1628
|
def detail(self, gpus):
|
|
1488
|
-
"""The GPUs whose processes' utilization is shown: all with the list open, else the
|
|
1489
|
-
|
|
1629
|
+
"""The GPUs whose processes' utilization is shown: all with the list open, else the picked jobs'.
|
|
1630
|
+
Only those are asked, as the driver takes some 2 ms a GPU to answer."""
|
|
1490
1631
|
if self.listing:
|
|
1491
1632
|
return {g.index for g in gpus}
|
|
1492
|
-
job
|
|
1493
|
-
return set(job.gpus) if job else set()
|
|
1633
|
+
return {index for job in self.selected() for index in job.gpus}
|
|
1494
1634
|
|
|
1495
1635
|
def remind(self):
|
|
1496
1636
|
"""A job still there 5 s after its SIGTERM may be stuck, or slow to save: say that k ends it."""
|
|
1497
1637
|
if not self.sent:
|
|
1498
1638
|
return
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
if
|
|
1502
|
-
|
|
1639
|
+
pids, when = self.sent
|
|
1640
|
+
living = [job for job in self.jobs if job.pid in pids]
|
|
1641
|
+
if living and time.monotonic() - when > 5:
|
|
1642
|
+
what = living[0].name + " is" if len(living) == 1 else "{} jobs are".format(len(living))
|
|
1643
|
+
self.note = ([("{} still running 5 s after SIGTERM; k kills at once".format(what), WARN)],
|
|
1503
1644
|
time.monotonic() + 6)
|
|
1504
|
-
if not
|
|
1645
|
+
if not living or time.monotonic() - when > 5:
|
|
1505
1646
|
self.sent = None
|
|
1506
1647
|
|
|
1507
1648
|
def scrolled(self, room):
|
|
@@ -1521,14 +1662,18 @@ class View:
|
|
|
1521
1662
|
return [("GPUs", DIM)] + [(" {}".format(g.index), FAINT if g.index in self.hidden else BOLD, ("gpu", g.index))
|
|
1522
1663
|
for g in gpus]
|
|
1523
1664
|
|
|
1524
|
-
def bottom_line(self, width,
|
|
1665
|
+
def bottom_line(self, width, picked, newer, gpus):
|
|
1525
1666
|
if self.asking:
|
|
1526
|
-
return spread(width, [(self.question(
|
|
1667
|
+
return spread(width, [(self.question(picked), WARN)], keys(("y", "yes"), ("n", "no")))
|
|
1527
1668
|
note = self.note[0] if self.note and time.monotonic() < self.note[1] else None
|
|
1528
|
-
if
|
|
1669
|
+
if picked:
|
|
1529
1670
|
hints = [("f", "all GPUs" if self.only else "only these GPUs")]
|
|
1530
|
-
|
|
1531
|
-
|
|
1671
|
+
ours = any(job.mine for job in picked) and os.name != "nt"
|
|
1672
|
+
hints += [("t", "stop"), ("k", "kill")] if ours else []
|
|
1673
|
+
if len(picked) == 1:
|
|
1674
|
+
left = [(picked[0].name, THEME.accent, picked[0].pid), (" ", DIM)] + about(picked[0])
|
|
1675
|
+
else:
|
|
1676
|
+
left = [("{} jobs".format(len(picked)), THEME.accent), (" ", DIM)] + about_all(picked)
|
|
1532
1677
|
return spread(width, note or left, keys(*hints, ("Esc", "back")))
|
|
1533
1678
|
if self.listing:
|
|
1534
1679
|
return spread(width, note or [("click a job or a heading", DIM)],
|
|
@@ -1537,14 +1682,24 @@ class View:
|
|
|
1537
1682
|
return spread(width, note, keys(("p", "processes"), ("Esc / q", "quit")))
|
|
1538
1683
|
return footer(width, newer, self.picker(gpus))
|
|
1539
1684
|
|
|
1540
|
-
def question(self,
|
|
1541
|
-
""""stop train.py: SIGTERM to its torchrun (PID 48213), which ends its 2 workers?" and the
|
|
1685
|
+
def question(self, picked):
|
|
1686
|
+
""""stop train.py on GPUs 4-5: SIGTERM to its torchrun (PID 48213), which ends its 2 workers?" and the
|
|
1687
|
+
like; for several jobs "stop your 2 of 3 jobs on GPUs 4-5,7, SIGTERM to each: train.py, eval.py?". The
|
|
1688
|
+
GPUs are those the signal reaches, whichever was clicked."""
|
|
1542
1689
|
what = "stop" if self.asking == "SIGTERM" else "kill"
|
|
1690
|
+
ours = [job for job in picked if job.mine]
|
|
1691
|
+
where = "on " + gpu_numbers(index for job in ours for index in job.gpus)
|
|
1692
|
+
if len(picked) > 1:
|
|
1693
|
+
which = "{} jobs".format(len(ours)) if len(ours) == len(picked) else "your {} of {} jobs".format(
|
|
1694
|
+
len(ours), len(picked))
|
|
1695
|
+
return "{} {} {}, {} to each: {}?".format(what, which, where, self.asking,
|
|
1696
|
+
", ".join(job.name for job in ours))
|
|
1697
|
+
job = ours[0]
|
|
1543
1698
|
if not job.launcher:
|
|
1544
|
-
return "{} {} (PID {}) with {}?".format(what, job.name, job.pid, self.asking)
|
|
1699
|
+
return "{} {} {} (PID {}) with {}?".format(what, job.name, where, job.pid, self.asking)
|
|
1545
1700
|
passed = ", which ends its" if self.asking == "SIGTERM" else " and to its"
|
|
1546
|
-
return "{} {}: {} to its {} (PID {}){} {} workers?".format(what, job.name, self.asking,
|
|
1547
|
-
|
|
1701
|
+
return "{} {} {}: {} to its {} (PID {}){} {} workers?".format(what, job.name, where, self.asking,
|
|
1702
|
+
job.launcher, job.pid, passed, len(job.members))
|
|
1548
1703
|
|
|
1549
1704
|
def target(self, row, col):
|
|
1550
1705
|
"""What a click at (row, col) of the last screen means; None for nothing."""
|
|
@@ -1559,6 +1714,9 @@ class View:
|
|
|
1559
1714
|
def handle(self, event):
|
|
1560
1715
|
"""Act on one key or mouse event; False means quit."""
|
|
1561
1716
|
kind, value = event[0], event[1:]
|
|
1717
|
+
if kind == "move":
|
|
1718
|
+
self.pointer = value
|
|
1719
|
+
return True
|
|
1562
1720
|
if kind in ("wheel", "hwheel"): # over a GPU's processes, they scroll, either way;
|
|
1563
1721
|
step, row, col = value # over the list, the list, or the picked job's command sideways;
|
|
1564
1722
|
area = next((index for rows, cols, index in self.areas # anywhere else, the GPU boxes
|
|
@@ -1577,10 +1735,6 @@ class View:
|
|
|
1577
1735
|
else:
|
|
1578
1736
|
self.gpu_top += step
|
|
1579
1737
|
return True
|
|
1580
|
-
if kind == "list": # "+" under a GPU: the list, from that GPU's first job
|
|
1581
|
-
self.listing, self.follow = True, False
|
|
1582
|
-
self.top = next((i for i, job in enumerate(self.jobs) if value[0] in job.gpus), self.top)
|
|
1583
|
-
return True
|
|
1584
1738
|
if kind == "gpu": # a GPU's number at the bottom: hide that GPU, or show it again; one always stays
|
|
1585
1739
|
index = value[0]
|
|
1586
1740
|
if index in self.hidden:
|
|
@@ -1593,26 +1747,40 @@ class View:
|
|
|
1593
1747
|
self.sort, self.follow = (value[0], not descending if value[0] == column else False), True
|
|
1594
1748
|
return True
|
|
1595
1749
|
if kind == "click":
|
|
1596
|
-
|
|
1597
|
-
|
|
1750
|
+
row, col = value
|
|
1751
|
+
target = self.target(row, col)
|
|
1752
|
+
if isinstance(target, tuple): # a key hint, a heading of the list, a GPU's number at the bottom
|
|
1598
1753
|
return self.handle(target)
|
|
1599
|
-
|
|
1600
|
-
|
|
1754
|
+
if target is None: # anywhere else in a GPU's box: that GPU
|
|
1755
|
+
target = next((("box", index) for rows, cols, index in self.panels if row in rows and col in cols),
|
|
1756
|
+
None)
|
|
1601
1757
|
now = time.monotonic()
|
|
1602
|
-
|
|
1603
|
-
self.listing = False
|
|
1604
|
-
if target is not None and self.clicked[1] == target and now - self.clicked[0] < 0.4:
|
|
1605
|
-
self.job, self.only = target, not self.only
|
|
1606
|
-
else:
|
|
1607
|
-
self.job = target if target is not None and target != self.job else None
|
|
1758
|
+
double = target is not None and self.clicked[1] == target and now - self.clicked[0] < 0.4
|
|
1608
1759
|
self.asking, self.follow, self.clicked = None, True, (now, target)
|
|
1760
|
+
# A job picks it, a GPU the jobs on it, and outside the list opens that; the same again lets them
|
|
1761
|
+
# go, as does a place meaning nothing, which outside the list closes that too. A double click picks
|
|
1762
|
+
# them and shows only their GPUs, or all again.
|
|
1763
|
+
if target is None:
|
|
1764
|
+
self.pick(())
|
|
1765
|
+
self.listing = self.listing and row in self.list_rows
|
|
1766
|
+
return True
|
|
1767
|
+
pids = [job.pid for job in self.jobs if target[1] in job.gpus] if isinstance(target, tuple) else [target]
|
|
1768
|
+
if not pids:
|
|
1769
|
+
self.note = ([("no processes on GPU {}".format(target[1]), DIM)], now + 2)
|
|
1770
|
+
elif double:
|
|
1771
|
+
self.pick(pids)
|
|
1772
|
+
self.only = not self.only
|
|
1773
|
+
elif set(pids) == self.picked:
|
|
1774
|
+
self.pick(())
|
|
1775
|
+
else:
|
|
1776
|
+
self.pick(pids)
|
|
1777
|
+
self.opened = self.opened or not self.listing
|
|
1778
|
+
self.listing = True
|
|
1609
1779
|
return True
|
|
1610
1780
|
key = value[0].lower() if len(value[0]) == 1 else value[0]
|
|
1611
1781
|
if self.asking: # y stops; anything else, n and Esc among them, lets it be
|
|
1612
1782
|
if key == "y":
|
|
1613
|
-
|
|
1614
|
-
self.note = (note, time.monotonic() + 4)
|
|
1615
|
-
self.sent = (self.job, time.monotonic()) if sent and self.asking == "SIGTERM" else None
|
|
1783
|
+
self.stop_picked()
|
|
1616
1784
|
self.asking = None
|
|
1617
1785
|
return key != "q"
|
|
1618
1786
|
if key == "q":
|
|
@@ -1622,12 +1790,12 @@ class View:
|
|
|
1622
1790
|
self.only = False
|
|
1623
1791
|
elif self.listing:
|
|
1624
1792
|
self.listing = False
|
|
1625
|
-
elif self.
|
|
1626
|
-
self.
|
|
1793
|
+
elif self.picked:
|
|
1794
|
+
self.pick(())
|
|
1627
1795
|
else:
|
|
1628
1796
|
return False
|
|
1629
|
-
elif key == "p":
|
|
1630
|
-
self.listing = not self.listing
|
|
1797
|
+
elif key == "p": # opened or closed by hand, the list stays so when the picks go
|
|
1798
|
+
self.listing, self.opened = not self.listing, False
|
|
1631
1799
|
elif key == "v": # the graph, the processes, the stats alone, GPU and MEM alone; V the one before
|
|
1632
1800
|
self.left = LEFTS[(LEFTS.index(self.left) + (-1 if value[0] == "V" else 1)) % len(LEFTS)]
|
|
1633
1801
|
name = {"graph": "graphs", "processes": "processes", "numbers": "numbers only",
|
|
@@ -1656,16 +1824,32 @@ class View:
|
|
|
1656
1824
|
elif key in MOVES and self.jobs:
|
|
1657
1825
|
order = [job.pid for job in self.jobs]
|
|
1658
1826
|
i = order.index(self.job) if self.job in order else (-1 if MOVES[key] > 0 else len(order))
|
|
1659
|
-
self.
|
|
1660
|
-
|
|
1827
|
+
self.pick([order[min(max(i + MOVES[key], 0), len(order) - 1)]])
|
|
1828
|
+
self.follow = True
|
|
1829
|
+
elif self.picked and key in ("f", "enter"):
|
|
1661
1830
|
self.only = not self.only
|
|
1662
|
-
elif self.
|
|
1663
|
-
if self.selected()
|
|
1831
|
+
elif self.picked and key in ("t", "k"):
|
|
1832
|
+
if any(job.mine for job in self.selected()):
|
|
1664
1833
|
self.asking = "SIGTERM" if key == "t" else "SIGKILL"
|
|
1665
1834
|
else:
|
|
1666
1835
|
self.note = ([("only your own processes can be stopped", WARN)], time.monotonic() + 3)
|
|
1667
1836
|
return True
|
|
1668
1837
|
|
|
1838
|
+
def stop_picked(self):
|
|
1839
|
+
"""Send the signal asked for to each picked job of ours (see stop), and say how that went."""
|
|
1840
|
+
ours = [job for job in self.selected() if job.mine]
|
|
1841
|
+
results = [stop(job, self.asking) for job in ours]
|
|
1842
|
+
sent = {job.pid for job, (_, ok) in zip(ours, results) if ok}
|
|
1843
|
+
failed = [note for note, ok in results if not ok]
|
|
1844
|
+
if len(ours) == 1:
|
|
1845
|
+
note = results[0][0]
|
|
1846
|
+
elif not failed:
|
|
1847
|
+
note = [("sent {} to {} jobs".format(self.asking, len(sent)), "")]
|
|
1848
|
+
else:
|
|
1849
|
+
note = [("sent {} to {} of {} jobs; ".format(self.asking, len(sent), len(ours)), WARN)] + failed[0]
|
|
1850
|
+
self.note = (note, time.monotonic() + 4)
|
|
1851
|
+
self.sent = (sent, time.monotonic()) if sent and self.asking == "SIGTERM" else None
|
|
1852
|
+
|
|
1669
1853
|
|
|
1670
1854
|
# For terminals that draw the line, block and other characters above two cells wide, as they may for Korean,
|
|
1671
1855
|
# Chinese and Japanese (Unicode calls their width "ambiguous"): ASCII look-alikes, one for one.
|
|
@@ -1675,26 +1859,21 @@ ASCII = str.maketrans({"─": "-", "│": "|", "├": "+", "┤": "+", "╭": "+
|
|
|
1675
1859
|
"°": " ", "·": "-", "…": "~", "↑": "^", "↓": "v", "▲": "^", "▼": "v"})
|
|
1676
1860
|
|
|
1677
1861
|
|
|
1678
|
-
def paint(
|
|
1679
|
-
"""
|
|
1680
|
-
ASCII,
|
|
1862
|
+
def paint(line, mode="truecolor", ascii=False, gray=False):
|
|
1863
|
+
"""One line as the terminal gets it, from plain to plain: in color `mode` (with `gray`, grays only; see
|
|
1864
|
+
restyle), with `ascii` in ASCII, a style code only where the style changes."""
|
|
1681
1865
|
out, current = [], ""
|
|
1682
|
-
for
|
|
1683
|
-
if
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
if
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
colors = (style.startswith("\x1b[38") and current.startswith("\x1b[38")
|
|
1694
|
-
and "\x1b[48" not in current)
|
|
1695
|
-
out.append(style if colors else RESET + style)
|
|
1696
|
-
current = style
|
|
1697
|
-
out.append(text)
|
|
1866
|
+
for text, style, *_ in line:
|
|
1867
|
+
if ascii:
|
|
1868
|
+
text = text.translate(ASCII)
|
|
1869
|
+
if mode != "truecolor" or gray:
|
|
1870
|
+
style = restyle(style, mode, gray)
|
|
1871
|
+
if style != current:
|
|
1872
|
+
# One color replaces another directly; anything else (bold, a background, plain) needs a reset first.
|
|
1873
|
+
colors = style.startswith("\x1b[38") and current.startswith("\x1b[38") and "\x1b[48" not in current
|
|
1874
|
+
out.append(style if colors else RESET + style)
|
|
1875
|
+
current = style
|
|
1876
|
+
out.append(text)
|
|
1698
1877
|
return "".join(out) + RESET
|
|
1699
1878
|
|
|
1700
1879
|
|
|
@@ -1730,32 +1909,27 @@ class Settings:
|
|
|
1730
1909
|
return data if isinstance(data, dict) else {}
|
|
1731
1910
|
|
|
1732
1911
|
def load(self, host):
|
|
1733
|
-
"""This client's settings as View.restore takes them, the GPUs hidden those on `host
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
hidden = mine.get("hidden")
|
|
1743
|
-
if isinstance(hidden, dict) and isinstance(hidden.get(host), list):
|
|
1744
|
-
out["hidden"] = {i for i in hidden[host] if isinstance(i, int)}
|
|
1745
|
-
return out
|
|
1912
|
+
"""This client's settings as View.restore takes them, the GPUs hidden those on `host`; none when they
|
|
1913
|
+
are not as nvmon writes them (edited by hand, say)."""
|
|
1914
|
+
try:
|
|
1915
|
+
mine = self._read()[self.client]
|
|
1916
|
+
kept = {"left": mine["left"], "theme": mine["theme"], "sort": (mine["sort"][0], bool(mine["sort"][1])),
|
|
1917
|
+
"hidden": set(map(int, mine["hidden"].get(host, [])))}
|
|
1918
|
+
return kept if kept["left"] in LEFTS and kept["sort"][0] in SORTS else {}
|
|
1919
|
+
except (KeyError, IndexError, TypeError, ValueError, AttributeError):
|
|
1920
|
+
return {}
|
|
1746
1921
|
|
|
1747
1922
|
def save(self, kept, indices, host):
|
|
1748
|
-
"""Keep `kept` (View.kept) for this client; for GPUs that are not `indices` (nvmon -g),
|
|
1749
|
-
kept for them. Everyone else's settings stay as the file has them now."""
|
|
1923
|
+
"""Keep `kept` (View.kept) for this client; for GPUs that are not `indices` (nvmon -g), and on other
|
|
1924
|
+
hosts, what was kept for them. Everyone else's settings stay as the file has them now."""
|
|
1750
1925
|
data = self._read()
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
data[self.client] = mine
|
|
1926
|
+
try:
|
|
1927
|
+
hidden = {name: set(map(int, gpus)) for name, gpus in data[self.client]["hidden"].items()}
|
|
1928
|
+
except (KeyError, TypeError, ValueError, AttributeError):
|
|
1929
|
+
hidden = {}
|
|
1930
|
+
hidden[host] = hidden.get(host, set()) - set(indices) | kept["hidden"]
|
|
1931
|
+
data[self.client] = {"left": kept["left"], "theme": kept["theme"], "sort": list(kept["sort"]),
|
|
1932
|
+
"hidden": {name: sorted(gpus) for name, gpus in hidden.items() if gpus}}
|
|
1759
1933
|
temp = "{}.{}".format(self.path, os.getpid())
|
|
1760
1934
|
try:
|
|
1761
1935
|
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
@@ -1782,7 +1956,7 @@ INPUT = re.compile(rb"\x1b\[<(?P<button>\d+);(?P<x>\d+);(?P<y>\d+)(?P<act>[Mm])"
|
|
|
1782
1956
|
def events(data):
|
|
1783
1957
|
"""The input in `data`, the bytes a terminal sends: ("key", name) events, ("click", row, column) for
|
|
1784
1958
|
a left-button press, ("wheel", +1 down or -1 up, row, column), ("hwheel", +1 right or -1 left, row,
|
|
1785
|
-
column)."""
|
|
1959
|
+
column), ("move", row, column) where the pointer went."""
|
|
1786
1960
|
out = []
|
|
1787
1961
|
for m in INPUT.finditer(data):
|
|
1788
1962
|
if m.group("button") or m.group("old"):
|
|
@@ -1795,7 +1969,9 @@ def events(data):
|
|
|
1795
1969
|
if button & 64: # the wheel: 64 up, 65 down, 66 left, 67 right; with Shift (+4) sideways too
|
|
1796
1970
|
sideways = button & 2 or button & 4
|
|
1797
1971
|
out.append(("hwheel" if sideways else "wheel", 1 if button & 1 else -1, y - 1, x - 1))
|
|
1798
|
-
elif
|
|
1972
|
+
elif button & 32: # a move, a button held or not
|
|
1973
|
+
out.append(("move", y - 1, x - 1))
|
|
1974
|
+
elif press and button & 3 == 0: # left button down
|
|
1799
1975
|
out.append(("click", y - 1, x - 1))
|
|
1800
1976
|
elif m.group("report"):
|
|
1801
1977
|
continue
|
|
@@ -1830,15 +2006,17 @@ TRUECOLOR_ANSWERS = re.compile(
|
|
|
1830
2006
|
|
|
1831
2007
|
|
|
1832
2008
|
class Screen:
|
|
1833
|
-
"""Alternate screen, hidden cursor, no auto-wrap, unbuffered keys, mouse clicks and
|
|
2009
|
+
"""Alternate screen, hidden cursor, no auto-wrap, unbuffered keys, mouse clicks, wheel and moves; all
|
|
1834
2010
|
restored on exit."""
|
|
1835
2011
|
|
|
1836
2012
|
def __enter__(self):
|
|
1837
2013
|
self._keys = sys.stdin.isatty()
|
|
1838
2014
|
self._mouse = self._keys and os.name != "nt" # Windows reads keys with msvcrt, which sees no mouse
|
|
1839
2015
|
self._restore = [enable_vt(), raw_keys() if self._keys else (lambda: None)]
|
|
1840
|
-
|
|
1841
|
-
|
|
2016
|
+
self._painted, self._size = [], None # the lines last sent, and the window they were sent to
|
|
2017
|
+
# 1000 + 1003 + 1006: clicks, the wheel and every move of the pointer come in as SGR-encoded sequences
|
|
2018
|
+
# (Shift+drag still selects text). A terminal without 1003 sends clicks and the wheel all the same.
|
|
2019
|
+
self._write("\x1b[?1049h\x1b[?25l\x1b[?7l" + ("\x1b[?1000h\x1b[?1003h\x1b[?1006h" if self._mouse else ""))
|
|
1842
2020
|
# Windows Terminal and the console of Windows 10 on show 24-bit color and draw lines one cell wide.
|
|
1843
2021
|
self.wide, self.truecolor = self.probe() if self._mouse else (False, os.name == "nt")
|
|
1844
2022
|
return self
|
|
@@ -1873,7 +2051,8 @@ class Screen:
|
|
|
1873
2051
|
return bool(cursor) and int(cursor.group(1)) > 2, bool(TRUECOLOR_ANSWERS.search(data))
|
|
1874
2052
|
|
|
1875
2053
|
def __exit__(self, *exc):
|
|
1876
|
-
|
|
2054
|
+
mouse = "\x1b[?1006l\x1b[?1003l\x1b[?1000l" if self._mouse else ""
|
|
2055
|
+
self._write(RESET + mouse + "\x1b[?7h\x1b[?25h\x1b[?1049l")
|
|
1877
2056
|
for undo in self._restore:
|
|
1878
2057
|
undo()
|
|
1879
2058
|
|
|
@@ -1907,9 +2086,16 @@ class Screen:
|
|
|
1907
2086
|
self._keys = False
|
|
1908
2087
|
return events(data)
|
|
1909
2088
|
|
|
1910
|
-
def draw(self, lines, mode, gray):
|
|
2089
|
+
def draw(self, lines, mode, gray, size):
|
|
2090
|
+
"""The screen `lines`, in a window of `size`: only the lines that changed since the last time, so that
|
|
2091
|
+
a frame, or the pointer moving over a graph, sends a few hundred bytes rather than the whole screen."""
|
|
2092
|
+
painted = [paint(line, mode, self.wide, gray) for line in lines]
|
|
2093
|
+
last = self._painted if self._size == size else [] # a new size: all anew
|
|
2094
|
+
changed = "".join("\x1b[{};1H{}".format(row + 1, text) for row, text in enumerate(painted)
|
|
2095
|
+
if row >= len(last) or last[row] != text)
|
|
2096
|
+
self._painted, self._size = painted, size
|
|
1911
2097
|
# 2026 = synchronized output: terminals that know it swap the frame in at once.
|
|
1912
|
-
self._write("\x1b[?2026h
|
|
2098
|
+
self._write("\x1b[?2026h" + changed + "\x1b[?2026l")
|
|
1913
2099
|
|
|
1914
2100
|
@staticmethod
|
|
1915
2101
|
def _write(text):
|
|
@@ -1964,13 +2150,14 @@ DEFAULT_INTERVAL, MIN_INTERVAL = 0.5, 0.1 # seconds
|
|
|
1964
2150
|
|
|
1965
2151
|
def interval(text):
|
|
1966
2152
|
"""argparse type for -i: seconds between updates. The GPU's own readings change every 0.1-0.2 s, so
|
|
1967
|
-
anything faster would only keep the driver busier and counts as MIN_INTERVAL
|
|
1968
|
-
seconds (abc, nan, inf) means the default."""
|
|
2153
|
+
anything faster would only keep the driver busier and counts as MIN_INTERVAL."""
|
|
1969
2154
|
try:
|
|
1970
2155
|
seconds = float(text)
|
|
1971
2156
|
except ValueError:
|
|
1972
|
-
|
|
1973
|
-
|
|
2157
|
+
seconds = math.nan
|
|
2158
|
+
if not seconds >= 0 or math.isinf(seconds): # abc, nan, -1, inf
|
|
2159
|
+
raise argparse.ArgumentTypeError("expected seconds such as 0.5 or 2, got {!r}".format(text))
|
|
2160
|
+
return max(MIN_INTERVAL, seconds)
|
|
1974
2161
|
|
|
1975
2162
|
|
|
1976
2163
|
def main():
|
|
@@ -2009,6 +2196,7 @@ def main():
|
|
|
2009
2196
|
host, settings, indices = socket.gethostname(), Settings(), [g.index for g in gpus]
|
|
2010
2197
|
poller, view = Poller(gpus), View()
|
|
2011
2198
|
view.restore(settings.load(host), indices)
|
|
2199
|
+
view.interval = args.interval
|
|
2012
2200
|
kept = view.kept()
|
|
2013
2201
|
with Screen() as screen:
|
|
2014
2202
|
view.shows = color_mode(screen.truecolor)
|
|
@@ -2042,16 +2230,19 @@ def main():
|
|
|
2042
2230
|
width, height = os.get_terminal_size()
|
|
2043
2231
|
screen.draw(view.screen(gpus, width, height,
|
|
2044
2232
|
header(width, args.interval, driver, poller.waiting()), update.newer),
|
|
2045
|
-
view.shows, view.theme.gray)
|
|
2046
|
-
# Keys and clicks redraw at once
|
|
2233
|
+
view.shows, view.theme.gray, (width, height))
|
|
2234
|
+
# Keys and clicks redraw at once, the pointer when it goes to another graph column; the
|
|
2235
|
+
# numbers move on at the next frame.
|
|
2047
2236
|
events = screen.read(0.01 if peek is not None else (poll_at or frame_at) - time.monotonic())
|
|
2237
|
+
pointed = view.pointed()
|
|
2048
2238
|
going = all(map(view.handle, events))
|
|
2049
2239
|
if view.kept() != kept: # a setting changed: keep it for next time
|
|
2050
2240
|
kept = view.kept()
|
|
2051
2241
|
settings.save(kept, indices, host)
|
|
2052
2242
|
if not going:
|
|
2053
2243
|
break
|
|
2054
|
-
redraw
|
|
2244
|
+
redraw = any(e[0] != "move" for e in events) or view.pointed() != pointed
|
|
2245
|
+
detail = view.detail(gpus)
|
|
2055
2246
|
if detail - poller.detail: # at the latest the next round, or the one after the round running
|
|
2056
2247
|
busy = not poller.done.is_set()
|
|
2057
2248
|
peek = (detail - poller.detail | (peek[0] if peek else set()), poller.rounds + 1 + busy)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|