nvmon 0.3.2__tar.gz → 0.3.3__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.3
|
|
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
|
|
@@ -50,13 +50,13 @@ 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 value at that moment.
|
|
54
54
|
- **GPU**: how much of the time a kernel ran. **cores / tensor** (H100 and newer): how busy that kept
|
|
55
55
|
the chip, and how much was Tensor Core math. One small kernel running nonstop reads GPU 100 % with
|
|
56
56
|
cores at 30 %.
|
|
57
57
|
- **MEM**: memory in use. **CPU -> GPU**, **GPU -> CPU**: PCIe traffic against the link's top speed.
|
|
58
58
|
- **Bottom edge**: the processes, with run time and memory, by account and conda environment (yours
|
|
59
|
-
unlabelled)
|
|
59
|
+
unlabelled); `+3` for those that did not fit.
|
|
60
60
|
|
|
61
61
|
The bottom line has the GPU numbers (click one, or press its key, to hide or show it), the keys, and a
|
|
62
62
|
newer release when there is one.
|
|
@@ -85,13 +85,15 @@ Shift+wheel or ← → sideways.
|
|
|
85
85
|
|
|
86
86
|
A **job** is a process, or a launcher (torchrun, deepspeed…) with its workers.
|
|
87
87
|
|
|
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
|
-
-
|
|
88
|
+
- **Pick** one: click a process, or press Tab (Tab and the arrows move on). Its GPUs stand out, the
|
|
89
|
+
list opens with it picked, and the bottom line sums it up.
|
|
90
|
+
- **Pick a GPU's jobs**: click anywhere else in its box. The same again lets go, and closes the list it
|
|
91
|
+
opened.
|
|
92
|
+
- `f`, or a double click: only the picked jobs' GPUs; again for all.
|
|
91
93
|
- `p`: every job in a list. Click a heading to sort, again to reverse (or `s`, `r`). ← → scroll the
|
|
92
94
|
picked job's command. Esc, or a click on an empty spot, lets go.
|
|
93
|
-
- `t` stops
|
|
94
|
-
the
|
|
95
|
+
- `t` stops the picked jobs of yours (SIGTERM), `k` kills them (SIGKILL): after a `y`, and only those
|
|
96
|
+
still the jobs you picked.
|
|
95
97
|
|
|
96
98
|
The list, busiest first, one job picked; and that job with `f`:
|
|
97
99
|
|
|
@@ -119,8 +121,9 @@ The list, busiest first, one job picked; and that job with `f`:
|
|
|
119
121
|
| `c` / `C` | next / previous theme |
|
|
120
122
|
| `p` | process list, open or close |
|
|
121
123
|
| click, Tab, ↑ ↓ | pick a job |
|
|
122
|
-
|
|
|
123
|
-
| `
|
|
124
|
+
| click a GPU | pick its jobs |
|
|
125
|
+
| `f`, double click | only the picked jobs' GPUs, or all |
|
|
126
|
+
| `t`, `k`, then `y` | stop (SIGTERM) or kill (SIGKILL) the picked jobs |
|
|
124
127
|
| click a heading, `s`, `r` | sort the list, by the next heading, the other way |
|
|
125
128
|
| wheel, ← → | scroll; sideways the commands |
|
|
126
129
|
| PgUp, PgDn | scroll the GPUs |
|
|
@@ -28,13 +28,13 @@ 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 value at that moment.
|
|
32
32
|
- **GPU**: how much of the time a kernel ran. **cores / tensor** (H100 and newer): how busy that kept
|
|
33
33
|
the chip, and how much was Tensor Core math. One small kernel running nonstop reads GPU 100 % with
|
|
34
34
|
cores at 30 %.
|
|
35
35
|
- **MEM**: memory in use. **CPU -> GPU**, **GPU -> CPU**: PCIe traffic against the link's top speed.
|
|
36
36
|
- **Bottom edge**: the processes, with run time and memory, by account and conda environment (yours
|
|
37
|
-
unlabelled)
|
|
37
|
+
unlabelled); `+3` for those that did not fit.
|
|
38
38
|
|
|
39
39
|
The bottom line has the GPU numbers (click one, or press its key, to hide or show it), the keys, and a
|
|
40
40
|
newer release when there is one.
|
|
@@ -63,13 +63,15 @@ Shift+wheel or ← → sideways.
|
|
|
63
63
|
|
|
64
64
|
A **job** is a process, or a launcher (torchrun, deepspeed…) with its workers.
|
|
65
65
|
|
|
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
|
-
-
|
|
66
|
+
- **Pick** one: click a process, or press Tab (Tab and the arrows move on). Its GPUs stand out, the
|
|
67
|
+
list opens with it picked, and the bottom line sums it up.
|
|
68
|
+
- **Pick a GPU's jobs**: click anywhere else in its box. The same again lets go, and closes the list it
|
|
69
|
+
opened.
|
|
70
|
+
- `f`, or a double click: only the picked jobs' GPUs; again for all.
|
|
69
71
|
- `p`: every job in a list. Click a heading to sort, again to reverse (or `s`, `r`). ← → scroll the
|
|
70
72
|
picked job's command. Esc, or a click on an empty spot, lets go.
|
|
71
|
-
- `t` stops
|
|
72
|
-
the
|
|
73
|
+
- `t` stops the picked jobs of yours (SIGTERM), `k` kills them (SIGKILL): after a `y`, and only those
|
|
74
|
+
still the jobs you picked.
|
|
73
75
|
|
|
74
76
|
The list, busiest first, one job picked; and that job with `f`:
|
|
75
77
|
|
|
@@ -97,8 +99,9 @@ The list, busiest first, one job picked; and that job with `f`:
|
|
|
97
99
|
| `c` / `C` | next / previous theme |
|
|
98
100
|
| `p` | process list, open or close |
|
|
99
101
|
| click, Tab, ↑ ↓ | pick a job |
|
|
100
|
-
|
|
|
101
|
-
| `
|
|
102
|
+
| click a GPU | pick its jobs |
|
|
103
|
+
| `f`, double click | only the picked jobs' GPUs, or all |
|
|
104
|
+
| `t`, `k`, then `y` | stop (SIGTERM) or kill (SIGKILL) the picked jobs |
|
|
102
105
|
| click a heading, `s`, `r` | sort the list, by the next heading, the other way |
|
|
103
106
|
| wheel, ← → | scroll; sideways the commands |
|
|
104
107
|
| PgUp, PgDn | scroll the GPUs |
|
|
@@ -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.3" # "+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"
|
|
@@ -795,6 +795,12 @@ def ranges(numbers):
|
|
|
795
795
|
return ",".join(str(a) if a == b else "{}-{}".format(a, b) for a, b in spans)
|
|
796
796
|
|
|
797
797
|
|
|
798
|
+
def gpu_numbers(indices):
|
|
799
|
+
""""GPU 3", "GPUs 4-5,7"."""
|
|
800
|
+
indices = sorted(set(indices))
|
|
801
|
+
return "GPU{} {}".format("s" if len(indices) > 1 else "", ranges(indices))
|
|
802
|
+
|
|
803
|
+
|
|
798
804
|
def account(job):
|
|
799
805
|
""""kim/torch": whose it is, and the conda environment; our own go without an account."""
|
|
800
806
|
return "/".join(part for part in (job.owner, job.env) if part)
|
|
@@ -804,7 +810,7 @@ def about(job):
|
|
|
804
810
|
""""kim/torch · GPUs 4-5 · util 86% · 30.2G · 2h13m · torchrun job" as segments, util in its color;
|
|
805
811
|
the launcher, often a long name, comes last, where a narrow line cuts first."""
|
|
806
812
|
parts = [(account(job), DIM)] if account(job) else []
|
|
807
|
-
parts.append((
|
|
813
|
+
parts.append((gpu_numbers(job.gpus), DIM))
|
|
808
814
|
if job.util is not None:
|
|
809
815
|
parts.append(("util ", DIM, "{:.0f}%".format(job.util), heat(job.util / 100)))
|
|
810
816
|
parts.append(("{:.1f}G".format(job.mem), DIM))
|
|
@@ -817,6 +823,14 @@ def about(job):
|
|
|
817
823
|
return out
|
|
818
824
|
|
|
819
825
|
|
|
826
|
+
def about_all(jobs):
|
|
827
|
+
""""GPUs 4-7 · 61.2G · 2 yours" for several jobs picked together, as segments."""
|
|
828
|
+
parts = [gpu_numbers(index for job in jobs for index in job.gpus), "{:.1f}G".format(sum(j.mem for j in jobs))]
|
|
829
|
+
ours = sum(job.owner is None for job in jobs)
|
|
830
|
+
parts += ["{} yours".format(ours)] if 0 < ours < len(jobs) else []
|
|
831
|
+
return [(" · ".join(parts), DIM)]
|
|
832
|
+
|
|
833
|
+
|
|
820
834
|
def stop(job, name):
|
|
821
835
|
"""Send signal `name` ("SIGTERM" or "SIGKILL") to `job`, once /proc confirms its PIDs still are the
|
|
822
836
|
processes we saw: started when we saw them, holding a GPU open (inside a container, NVML can report
|
|
@@ -986,6 +1000,31 @@ def graph(history, width, height, two_tone=True):
|
|
|
986
1000
|
return rows
|
|
987
1001
|
|
|
988
1002
|
|
|
1003
|
+
# The pointer over a graph (View.hover): a guide up the empty part of its column, and a chip with that moment.
|
|
1004
|
+
GUIDE = _rgb((150, 150, 150))
|
|
1005
|
+
CHIP = "\x1b[48;2;235;235;235m" + _rgb((24, 24, 27))
|
|
1006
|
+
COLOR_24 = re.compile(r"(\x1b\[[34]8;2;)(\d+);(\d+);(\d+)m")
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def lit(style):
|
|
1010
|
+
"""`style` lit up, for the graph column under the pointer: its colors halfway to white; on PAPER, where
|
|
1011
|
+
restyle turns lightness around, halfway to black."""
|
|
1012
|
+
paper = ON_PAPER if style.startswith(ON_PAPER) else ""
|
|
1013
|
+
to = 0 if paper else 255
|
|
1014
|
+
return paper + COLOR_24.sub(lambda m: m.group(1) + ";".join(
|
|
1015
|
+
str((int(v) + to) // 2) for v in m.group(2, 3, 4)) + "m", style[len(paper):])
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def ago(seconds):
|
|
1019
|
+
"""How long ago, as short as a run time: now, 14.5s ago, 3m05s ago, 1h02m ago."""
|
|
1020
|
+
if seconds < 0.05:
|
|
1021
|
+
return "now"
|
|
1022
|
+
if seconds < 60:
|
|
1023
|
+
return "{:g}s ago".format(round(seconds, 1))
|
|
1024
|
+
m, s = divmod(int(round(seconds)), 60)
|
|
1025
|
+
return "{}m{:02d}s ago".format(m, s) if m < 60 else "{}h{:02d}m ago".format(m // 60, m % 60)
|
|
1026
|
+
|
|
1027
|
+
|
|
989
1028
|
def row(left, right=()):
|
|
990
1029
|
"""One stats line: `left`, then the `right` segments flush right; exactly INFO_W cells."""
|
|
991
1030
|
room = INFO_W - width_of(right)
|
|
@@ -1034,12 +1073,12 @@ def degraded(gpu):
|
|
|
1034
1073
|
return bool(s.pcie_width and gpu.pcie_max_width and s.pcie_width < gpu.pcie_max_width)
|
|
1035
1074
|
|
|
1036
1075
|
|
|
1037
|
-
def panel(gpu, width, height, selected=
|
|
1076
|
+
def panel(gpu, width, height, selected=frozenset(), left="graph", scroll=(0, 0)):
|
|
1038
1077
|
"""One GPU's box: beside the stats, as `left` says (see LEFTS), the utilization graph, or the GPU's
|
|
1039
1078
|
processes one a line, moved `scroll` = (lines, cells) on (see process_lines); or the stats alone. Faded
|
|
1040
|
-
when
|
|
1079
|
+
when jobs are `selected` (their PIDs) and none runs here."""
|
|
1041
1080
|
s = gpu.now
|
|
1042
|
-
faded = selected
|
|
1081
|
+
faded = bool(selected) and not any(p.job in selected for p in s.processes)
|
|
1043
1082
|
# Both sides as (segments, rank); when space runs out the lowest rank goes first:
|
|
1044
1083
|
# fan, name, power, clock, temperature, PCIe warning, slowdown warning. The GPU number stays.
|
|
1045
1084
|
# Fixed widths keep things from shifting.
|
|
@@ -1053,20 +1092,20 @@ def panel(gpu, width, height, selected=None, left="graph", scroll=(0, 0)):
|
|
|
1053
1092
|
parts += [] if s.fan is None else [([("FAN {:>3}%".format(s.fan), "")], 1)]
|
|
1054
1093
|
parts += [] if s.clock is None else [([("{:>4} MHz".format(s.clock), s.slowdown[1] if s.slowdown else "")], 4)]
|
|
1055
1094
|
top = edge(width, label, parts)
|
|
1056
|
-
if left in CARDS: # the numbers only
|
|
1095
|
+
if left in CARDS: # the numbers only, no processes
|
|
1057
1096
|
split = width - 1
|
|
1058
1097
|
body = [[("├" + "─" * (width - 2) + "┤", DIM)] if i is None else [("│ ", DIM)] + i + [(" │", DIM)]
|
|
1059
1098
|
for i in info(gpu, height)]
|
|
1060
|
-
label = [
|
|
1099
|
+
label = []
|
|
1061
1100
|
else:
|
|
1062
1101
|
graph_w = max(0, width - CHROME_W - INFO_W)
|
|
1063
1102
|
split = 2 + graph_w + 1 # column of the graph | stats divider
|
|
1064
1103
|
rule = [(" ├" + "─" * (INFO_W + 2) + "┤", DIM)] # across the stats, joined to the borders
|
|
1065
1104
|
if left == "graph":
|
|
1066
1105
|
beside = graph(gpu.history, graph_w, height, not faded)
|
|
1067
|
-
label = process_label(s.processes, split - 4, selected
|
|
1106
|
+
label = process_label(s.processes, split - 4, selected)
|
|
1068
1107
|
else:
|
|
1069
|
-
beside, label = process_lines(s.processes, graph_w, height, selected,
|
|
1108
|
+
beside, label = process_lines(s.processes, graph_w, height, selected, *scroll)
|
|
1070
1109
|
body = [[("│ ", DIM)] + g + (rule if i is None else [(" │ ", DIM)] + i + [(" │", DIM)])
|
|
1071
1110
|
for g, i in zip(beside, info(gpu, height))]
|
|
1072
1111
|
lines = [top] + body + [bottom_edge(width, split, label)]
|
|
@@ -1126,9 +1165,9 @@ def process_entries(processes):
|
|
|
1126
1165
|
for process in group]
|
|
1127
1166
|
|
|
1128
1167
|
|
|
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
|
|
1168
|
+
def process_name(process, selected=frozenset()):
|
|
1169
|
+
"""The segment of `process`'s name: clicked, it means its job; those of `selected` jobs reversed."""
|
|
1170
|
+
return process.name, THEME.selected if process.job in selected else THEME.accent, process.job
|
|
1132
1171
|
|
|
1133
1172
|
|
|
1134
1173
|
def run_time(process):
|
|
@@ -1139,10 +1178,9 @@ def memory(process):
|
|
|
1139
1178
|
return "-" if process.mem is None else "{:.1f}G".format(process.mem)
|
|
1140
1179
|
|
|
1141
1180
|
|
|
1142
|
-
def process_label(processes, room, selected=
|
|
1181
|
+
def process_label(processes, room, selected=frozenset()):
|
|
1143
1182
|
"""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`."""
|
|
1183
|
+
a.py 1d4h 9.0G". As many whole entries as fit in `room` cells, then "+N" for the rest."""
|
|
1146
1184
|
entries, last = [], None
|
|
1147
1185
|
for tag, process in process_entries(processes):
|
|
1148
1186
|
gap = [] if not entries else [(" · ", DIM)] if tag == last else [(" ", "")]
|
|
@@ -1152,29 +1190,29 @@ def process_label(processes, room, selected=None, index=None):
|
|
|
1152
1190
|
last = tag
|
|
1153
1191
|
|
|
1154
1192
|
def more(n):
|
|
1155
|
-
return (" +{}".format(n) if n else
|
|
1193
|
+
return [(" +{}".format(n), DIM)] if n else []
|
|
1156
1194
|
label = []
|
|
1157
1195
|
for i, entry in enumerate(entries):
|
|
1158
1196
|
rest = len(entries) - i - 1
|
|
1159
|
-
if width_of(label + entry
|
|
1197
|
+
if width_of(label + entry + more(rest)) > room:
|
|
1160
1198
|
# The first entry is always shown, cut if need be; later ones collapse into the count.
|
|
1161
|
-
return clip(entry, room -
|
|
1199
|
+
return clip(entry, room - width_of(more(rest))) + more(rest) if not label else label + more(rest + 1)
|
|
1162
1200
|
label += entry
|
|
1163
|
-
return label
|
|
1201
|
+
return label
|
|
1164
1202
|
|
|
1165
1203
|
|
|
1166
|
-
def process_lines(processes, room, height, selected=
|
|
1204
|
+
def process_lines(processes, room, height, selected=frozenset(), top=0, shift=0):
|
|
1167
1205
|
"""The processes (process_listing) one a line, `height` lines of `room` cells, from
|
|
1168
1206
|
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
|
|
1207
|
+
lines show of how many, when not all do."""
|
|
1170
1208
|
lines = process_listing(processes, selected)
|
|
1171
1209
|
# Sideways the command moves; account, run time and memory, the first segment, stay.
|
|
1172
1210
|
shown = [pad(clip(line[:1] + skip(line[1:], shift), room), room) for line in lines[top:top + height]]
|
|
1173
|
-
label = [("{}-{} of {}
|
|
1174
|
-
return shown + [[(" " * room, "")]] * (height - len(shown)), label
|
|
1211
|
+
label = [("{}-{} of {}".format(top + 1, top + len(shown), len(lines)), DIM)] if len(lines) > height else []
|
|
1212
|
+
return shown + [[(" " * room, "")]] * (height - len(shown)), label
|
|
1175
1213
|
|
|
1176
1214
|
|
|
1177
|
-
def process_listing(processes, selected=
|
|
1215
|
+
def process_listing(processes, selected=frozenset()):
|
|
1178
1216
|
"""process_lines's lines, whole: account and environment, run time and memory in columns (one segment),
|
|
1179
1217
|
then the command line, the name in the process names' color and the arguments dim: "kim/torch: 2d5h
|
|
1180
1218
|
26.1G train.py --lr 3". The columns are as wide as the GPU's longest."""
|
|
@@ -1240,7 +1278,7 @@ def layout(count, width, height, left="graph"):
|
|
|
1240
1278
|
return cols, max(MIN_INNER_H, min(MAX_INNER_H, height // rows - 2))
|
|
1241
1279
|
|
|
1242
1280
|
|
|
1243
|
-
def render(gpus, width, height, selected=
|
|
1281
|
+
def render(gpus, width, height, selected=frozenset(), shape=None, left="graph", scroll=None):
|
|
1244
1282
|
"""The GPU boxes, in `shape` = (columns, inner height) or else the layout that fits: exactly `height`
|
|
1245
1283
|
lines of exactly `width` cells. `left`: see panel; the processes moved on as `scroll` has it for each
|
|
1246
1284
|
GPU (its index: (lines, cells))."""
|
|
@@ -1287,10 +1325,10 @@ def cells(values, width, fill=("",)):
|
|
|
1287
1325
|
return line
|
|
1288
1326
|
|
|
1289
1327
|
|
|
1290
|
-
def process_list(jobs, width, rows, selected, top, sort, shift=0):
|
|
1328
|
+
def process_list(jobs, width, rows, selected, focus, top, sort, shift=0):
|
|
1291
1329
|
"""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 "…"."""
|
|
1330
|
+
the jobs from index `top` on (clicking one means it; the `selected` ones reversed, the command of the one
|
|
1331
|
+
in `focus` moved `shift` characters on), how many more. A command too long to show ends in "…"."""
|
|
1294
1332
|
room = max(0, width - COMMAND_AT - 1)
|
|
1295
1333
|
column, descending = sort
|
|
1296
1334
|
head = []
|
|
@@ -1301,9 +1339,9 @@ def process_list(jobs, width, rows, selected, top, sort, shift=0):
|
|
|
1301
1339
|
lines = [cells(head, width)]
|
|
1302
1340
|
shown = jobs[top:top + rows - 2]
|
|
1303
1341
|
for job in shown:
|
|
1304
|
-
chosen = job.pid
|
|
1342
|
+
chosen = job.pid in selected
|
|
1305
1343
|
values = [cell(job) for _, _, cell, _ in COLUMNS]
|
|
1306
|
-
command = "…" + job.command[shift:] if
|
|
1344
|
+
command = "…" + job.command[shift:] if job.pid == focus and shift else job.command
|
|
1307
1345
|
values[-1] = (command if len(command) <= room else command[:max(0, room - 1)] + "…", DIM)
|
|
1308
1346
|
lines.append(cells([(text, THEME.selected if chosen else color, job.pid) for text, color in values],
|
|
1309
1347
|
width, (THEME.selected if chosen else "", job.pid)))
|
|
@@ -1395,22 +1433,24 @@ MOVES = {"tab": 1, "down": 1, "up": -1, "backtab": -1, "pgdn": 10, "pgup": -10,
|
|
|
1395
1433
|
|
|
1396
1434
|
|
|
1397
1435
|
class View:
|
|
1398
|
-
"""What the screen shows around the numbers, and what keys and clicks do to it: the
|
|
1399
|
-
only
|
|
1436
|
+
"""What the screen shows around the numbers, and what keys and clicks do to it: the picked jobs (one, or
|
|
1437
|
+
a GPU's), only their GPUs, the process list, a question before stopping, a note on how that went."""
|
|
1400
1438
|
|
|
1401
1439
|
def __init__(self):
|
|
1402
1440
|
self.shows = "truecolor" # the colors the terminal shows, see color_mode
|
|
1403
1441
|
self.theme = THEMES[0] # the look, see THEMES (c)
|
|
1404
1442
|
self.left = "graph" # what the boxes have beside the stats, see LEFTS (v)
|
|
1405
1443
|
self.hidden, self.indices = set(), [] # the GPUs left out, and all there are
|
|
1406
|
-
self.
|
|
1407
|
-
self.
|
|
1444
|
+
self.picked = set() # the picked jobs' PIDs
|
|
1445
|
+
self.job = None # of them the one in focus: the arrows go on from it, ← → move its command
|
|
1446
|
+
self.only = False # show only the GPUs they use
|
|
1408
1447
|
self.listing = False # the process list is open
|
|
1409
|
-
self.
|
|
1448
|
+
self.opened = False # by a pick, and so to close when the picks are let go
|
|
1449
|
+
self.top, self.follow = 0, True # the list's first job; keep the one in focus in view
|
|
1410
1450
|
self.sort = ("GPUs", False) # the list's order, and the order Tab and the arrows go in
|
|
1411
1451
|
self.asking = None # "SIGTERM" or "SIGKILL" awaiting y or n
|
|
1412
1452
|
self.note = None # (segments, until): what the last stop did
|
|
1413
|
-
self.sent = None # (
|
|
1453
|
+
self.sent = None # (PIDs, when) of a SIGTERM, to point at k if a job lives on
|
|
1414
1454
|
self.lines, self.jobs = [], [] # the last screen drawn, and the jobs on it
|
|
1415
1455
|
self.gpu_top, self.gpu_page = 0, 1 # the first row of boxes shown, and how many rows fit
|
|
1416
1456
|
self.list_rows = range(0) # the screen rows the list takes, when it is open
|
|
@@ -1418,6 +1458,10 @@ class View:
|
|
|
1418
1458
|
self.clicked = (0, None) # (when, what): the last click, to tell a double click
|
|
1419
1459
|
self.scroll = {} # GPU index: (lines, cells) its processes are moved on
|
|
1420
1460
|
self.areas = [] # (rows, columns, GPU) where those processes are on screen
|
|
1461
|
+
self.graphs = [] # (rows, columns, GPU) where the graphs are on screen
|
|
1462
|
+
self.panels = [] # (rows, columns, GPU index) where the boxes are on screen
|
|
1463
|
+
self.pointer = None # (row, column) of the mouse pointer, as the terminal last said
|
|
1464
|
+
self.interval = DEFAULT_INTERVAL # seconds from one graph column to the next
|
|
1421
1465
|
|
|
1422
1466
|
def restore(self, saved, indices):
|
|
1423
1467
|
"""Take up what an earlier run kept (Settings.load); of the GPUs `indices`, one always stays shown."""
|
|
@@ -1431,8 +1475,19 @@ class View:
|
|
|
1431
1475
|
"""What is kept for the next run: see Settings."""
|
|
1432
1476
|
return {"left": self.left, "theme": self.theme.name, "sort": self.sort, "hidden": set(self.hidden)}
|
|
1433
1477
|
|
|
1478
|
+
def pick(self, pids):
|
|
1479
|
+
"""Pick the jobs `pids`, in the list's order, the first in focus; none lets all go, and closes the
|
|
1480
|
+
list if a pick opened it."""
|
|
1481
|
+
self.picked = set(pids)
|
|
1482
|
+
self.job = next((job.pid for job in self.jobs if job.pid in self.picked), None)
|
|
1483
|
+
if not self.picked:
|
|
1484
|
+
self.only = False
|
|
1485
|
+
self.listing = self.listing and not self.opened
|
|
1486
|
+
self.opened = False
|
|
1487
|
+
|
|
1434
1488
|
def selected(self):
|
|
1435
|
-
|
|
1489
|
+
"""The picked jobs, in the list's order."""
|
|
1490
|
+
return [job for job in self.jobs if job.pid in self.picked]
|
|
1436
1491
|
|
|
1437
1492
|
def screen(self, gpus, width, height, top_line, newer):
|
|
1438
1493
|
"""The whole screen: `top_line`, the GPU boxes (and the list, when open), the bottom line."""
|
|
@@ -1440,12 +1495,17 @@ class View:
|
|
|
1440
1495
|
THEME, SHOWS = self.theme, self.shows
|
|
1441
1496
|
column, descending = self.sort
|
|
1442
1497
|
self.jobs = sorted(jobs(gpus), key=lambda job: (SORTS[column](job), job.pid), reverse=descending)
|
|
1443
|
-
job
|
|
1444
|
-
if
|
|
1498
|
+
self.picked &= {job.pid for job in self.jobs} # those that ended go
|
|
1499
|
+
if not self.picked:
|
|
1445
1500
|
self.job, self.only, self.asking = None, False, None
|
|
1501
|
+
elif self.job not in self.picked:
|
|
1502
|
+
self.job = next(job.pid for job in self.jobs if job.pid in self.picked)
|
|
1503
|
+
picked = self.selected()
|
|
1504
|
+
focus = next(job for job in picked if job.pid == self.job) if picked else None
|
|
1446
1505
|
self.remind()
|
|
1447
1506
|
self.indices = [g.index for g in gpus]
|
|
1448
|
-
|
|
1507
|
+
used = {index for job in picked for index in job.gpus}
|
|
1508
|
+
shown = [g for g in gpus if (g.index in used if self.only else g.index not in self.hidden)]
|
|
1449
1509
|
rows = min(len(self.jobs) + 2, max(4, (height - 2) // 2)) if self.listing else 0
|
|
1450
1510
|
body = self.boxes(shown, width, height - 2 - rows)
|
|
1451
1511
|
self.list_rows = range(1 + len(body), 1 + len(body) + rows)
|
|
@@ -1453,9 +1513,10 @@ class View:
|
|
|
1453
1513
|
if self.shifted != self.job:
|
|
1454
1514
|
self.shift, self.shifted = 0, self.job
|
|
1455
1515
|
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
|
-
|
|
1516
|
+
self.shift = max(0, min(self.shift, len(focus.command) + 1 - room)) if focus else 0
|
|
1517
|
+
body += process_list(self.jobs, width, rows, self.picked, self.job, self.scrolled(rows - 2), self.sort,
|
|
1518
|
+
self.shift)
|
|
1519
|
+
self.lines = self.hover([top_line] + body + [self.bottom_line(width, picked, newer, gpus)])
|
|
1459
1520
|
return self.lines
|
|
1460
1521
|
|
|
1461
1522
|
def boxes(self, gpus, width, height):
|
|
@@ -1474,34 +1535,83 @@ class View:
|
|
|
1474
1535
|
height -= 1
|
|
1475
1536
|
else:
|
|
1476
1537
|
self.gpu_top, self.gpu_page = 0, rows
|
|
1477
|
-
box_w
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1538
|
+
box_w = CARD_W if self.left in CARDS else width // cols
|
|
1539
|
+
room = max(0, box_w - CHROME_W - INFO_W)
|
|
1540
|
+
self.areas, self.graphs = [], []
|
|
1541
|
+
self.panels = [(range(1 + k // cols * (inner + 2), 1 + (k // cols + 1) * (inner + 2)),
|
|
1542
|
+
range(k % cols * box_w, (k % cols + 1) * box_w), g.index) for k, g in enumerate(gpus)]
|
|
1543
|
+
for k, g in enumerate(gpus if self.left in ("graph", "processes") else []):
|
|
1544
|
+
y, x = 2 + k // cols * (inner + 2), k % cols * box_w + 2 # 2: the top line and the box's edge
|
|
1545
|
+
if self.left == "graph":
|
|
1546
|
+
self.graphs.append((range(y, y + inner), range(x, x + room), g))
|
|
1547
|
+
continue
|
|
1548
|
+
most = listing_extent(g.now.processes, room, inner) # the processes' offsets in range
|
|
1481
1549
|
top, shift = self.scroll.get(g.index, (0, 0))
|
|
1482
1550
|
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
1551
|
self.areas.append((range(y, y + inner), range(x, x + room), g.index))
|
|
1485
|
-
return render(gpus, width, height, self.
|
|
1552
|
+
return render(gpus, width, height, self.picked, (cols, inner), self.left, self.scroll) + note
|
|
1553
|
+
|
|
1554
|
+
def hovered(self):
|
|
1555
|
+
"""(GPU, graph rows, graph columns, the column pointed at, its sample) when the pointer is over a graph
|
|
1556
|
+
column that has one; else None."""
|
|
1557
|
+
if self.pointer is None:
|
|
1558
|
+
return None
|
|
1559
|
+
row, col = self.pointer
|
|
1560
|
+
for rows, cols, gpu in self.graphs:
|
|
1561
|
+
if row in rows and col in cols:
|
|
1562
|
+
back = cols.stop - 1 - col # samples back from the newest, on the right
|
|
1563
|
+
if back < len(gpu.history):
|
|
1564
|
+
return gpu, rows, cols, col, gpu.history[-1 - back]
|
|
1565
|
+
return None
|
|
1566
|
+
|
|
1567
|
+
def pointed(self):
|
|
1568
|
+
"""(GPU index, column) of the graph column under the pointer, or None: what the chip is about."""
|
|
1569
|
+
hovered = self.hovered()
|
|
1570
|
+
return hovered and (hovered[0].index, hovered[3])
|
|
1571
|
+
|
|
1572
|
+
def hover(self, lines):
|
|
1573
|
+
"""`lines` with the column under the pointer lit up, and beside the pointer a chip with that moment:
|
|
1574
|
+
the utilization, and how long ago."""
|
|
1575
|
+
hovered = self.hovered()
|
|
1576
|
+
if hovered is None:
|
|
1577
|
+
return lines
|
|
1578
|
+
gpu, rows, cols, col, value = hovered
|
|
1579
|
+
back = (cols.stop - 1 - col) * self.interval
|
|
1580
|
+
chip = " {}% · {} ".format(value, ago(back))
|
|
1581
|
+
lines = list(lines)
|
|
1582
|
+
for row in rows:
|
|
1583
|
+
cells = [(ch,) + tuple(seg[1:]) for seg in lines[row] for ch in seg[0]]
|
|
1584
|
+
ch, style = cells[col][:2]
|
|
1585
|
+
paper = ON_PAPER if style.startswith(ON_PAPER) else ""
|
|
1586
|
+
cells[col] = ("│", paper + GUIDE) if ch == " " else (ch, lit(style))
|
|
1587
|
+
if row == self.pointer[0]:
|
|
1588
|
+
start = col + 2 if col + 2 + len(chip) <= cols.stop else col - 1 - len(chip)
|
|
1589
|
+
if start >= cols.start:
|
|
1590
|
+
bold = chip.index("%") + 1
|
|
1591
|
+
for k, c in enumerate(chip):
|
|
1592
|
+
cells[start + k] = (c, paper + CHIP + (BOLD if k < bold else ""))
|
|
1593
|
+
lines[row] = [("".join(c[0] for c in run),) + key for key, run in
|
|
1594
|
+
itertools.groupby(cells, key=lambda c: c[1:])]
|
|
1595
|
+
return lines
|
|
1486
1596
|
|
|
1487
1597
|
def detail(self, gpus):
|
|
1488
|
-
"""The GPUs whose processes' utilization is shown: all with the list open, else the
|
|
1489
|
-
|
|
1598
|
+
"""The GPUs whose processes' utilization is shown: all with the list open, else the picked jobs'.
|
|
1599
|
+
Only those are asked, as the driver takes some 2 ms a GPU to answer."""
|
|
1490
1600
|
if self.listing:
|
|
1491
1601
|
return {g.index for g in gpus}
|
|
1492
|
-
job
|
|
1493
|
-
return set(job.gpus) if job else set()
|
|
1602
|
+
return {index for job in self.selected() for index in job.gpus}
|
|
1494
1603
|
|
|
1495
1604
|
def remind(self):
|
|
1496
1605
|
"""A job still there 5 s after its SIGTERM may be stuck, or slow to save: say that k ends it."""
|
|
1497
1606
|
if not self.sent:
|
|
1498
1607
|
return
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
if
|
|
1502
|
-
|
|
1608
|
+
pids, when = self.sent
|
|
1609
|
+
living = [job for job in self.jobs if job.pid in pids]
|
|
1610
|
+
if living and time.monotonic() - when > 5:
|
|
1611
|
+
what = living[0].name + " is" if len(living) == 1 else "{} jobs are".format(len(living))
|
|
1612
|
+
self.note = ([("{} still running 5 s after SIGTERM; k kills at once".format(what), WARN)],
|
|
1503
1613
|
time.monotonic() + 6)
|
|
1504
|
-
if not
|
|
1614
|
+
if not living or time.monotonic() - when > 5:
|
|
1505
1615
|
self.sent = None
|
|
1506
1616
|
|
|
1507
1617
|
def scrolled(self, room):
|
|
@@ -1521,14 +1631,18 @@ class View:
|
|
|
1521
1631
|
return [("GPUs", DIM)] + [(" {}".format(g.index), FAINT if g.index in self.hidden else BOLD, ("gpu", g.index))
|
|
1522
1632
|
for g in gpus]
|
|
1523
1633
|
|
|
1524
|
-
def bottom_line(self, width,
|
|
1634
|
+
def bottom_line(self, width, picked, newer, gpus):
|
|
1525
1635
|
if self.asking:
|
|
1526
|
-
return spread(width, [(self.question(
|
|
1636
|
+
return spread(width, [(self.question(picked), WARN)], keys(("y", "yes"), ("n", "no")))
|
|
1527
1637
|
note = self.note[0] if self.note and time.monotonic() < self.note[1] else None
|
|
1528
|
-
if
|
|
1638
|
+
if picked:
|
|
1529
1639
|
hints = [("f", "all GPUs" if self.only else "only these GPUs")]
|
|
1530
|
-
|
|
1531
|
-
|
|
1640
|
+
ours = any(job.owner is None for job in picked) and os.name != "nt"
|
|
1641
|
+
hints += [("t", "stop"), ("k", "kill")] if ours else []
|
|
1642
|
+
if len(picked) == 1:
|
|
1643
|
+
left = [(picked[0].name, THEME.accent, picked[0].pid), (" ", DIM)] + about(picked[0])
|
|
1644
|
+
else:
|
|
1645
|
+
left = [("{} jobs".format(len(picked)), THEME.accent), (" ", DIM)] + about_all(picked)
|
|
1532
1646
|
return spread(width, note or left, keys(*hints, ("Esc", "back")))
|
|
1533
1647
|
if self.listing:
|
|
1534
1648
|
return spread(width, note or [("click a job or a heading", DIM)],
|
|
@@ -1537,14 +1651,24 @@ class View:
|
|
|
1537
1651
|
return spread(width, note, keys(("p", "processes"), ("Esc / q", "quit")))
|
|
1538
1652
|
return footer(width, newer, self.picker(gpus))
|
|
1539
1653
|
|
|
1540
|
-
def question(self,
|
|
1541
|
-
""""stop train.py: SIGTERM to its torchrun (PID 48213), which ends its 2 workers?" and the
|
|
1654
|
+
def question(self, picked):
|
|
1655
|
+
""""stop train.py on GPUs 4-5: SIGTERM to its torchrun (PID 48213), which ends its 2 workers?" and the
|
|
1656
|
+
like; for several jobs "stop your 2 of 3 jobs on GPUs 4-5,7, SIGTERM to each: train.py, eval.py?". The
|
|
1657
|
+
GPUs are those the signal reaches, whichever was clicked."""
|
|
1542
1658
|
what = "stop" if self.asking == "SIGTERM" else "kill"
|
|
1659
|
+
ours = [job for job in picked if job.owner is None]
|
|
1660
|
+
where = "on " + gpu_numbers(index for job in ours for index in job.gpus)
|
|
1661
|
+
if len(picked) > 1:
|
|
1662
|
+
which = "{} jobs".format(len(ours)) if len(ours) == len(picked) else "your {} of {} jobs".format(
|
|
1663
|
+
len(ours), len(picked))
|
|
1664
|
+
return "{} {} {}, {} to each: {}?".format(what, which, where, self.asking,
|
|
1665
|
+
", ".join(job.name for job in ours))
|
|
1666
|
+
job = ours[0]
|
|
1543
1667
|
if not job.launcher:
|
|
1544
|
-
return "{} {} (PID {}) with {}?".format(what, job.name, job.pid, self.asking)
|
|
1668
|
+
return "{} {} {} (PID {}) with {}?".format(what, job.name, where, job.pid, self.asking)
|
|
1545
1669
|
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
|
-
|
|
1670
|
+
return "{} {} {}: {} to its {} (PID {}){} {} workers?".format(what, job.name, where, self.asking,
|
|
1671
|
+
job.launcher, job.pid, passed, len(job.members))
|
|
1548
1672
|
|
|
1549
1673
|
def target(self, row, col):
|
|
1550
1674
|
"""What a click at (row, col) of the last screen means; None for nothing."""
|
|
@@ -1559,6 +1683,9 @@ class View:
|
|
|
1559
1683
|
def handle(self, event):
|
|
1560
1684
|
"""Act on one key or mouse event; False means quit."""
|
|
1561
1685
|
kind, value = event[0], event[1:]
|
|
1686
|
+
if kind == "move":
|
|
1687
|
+
self.pointer = value
|
|
1688
|
+
return True
|
|
1562
1689
|
if kind in ("wheel", "hwheel"): # over a GPU's processes, they scroll, either way;
|
|
1563
1690
|
step, row, col = value # over the list, the list, or the picked job's command sideways;
|
|
1564
1691
|
area = next((index for rows, cols, index in self.areas # anywhere else, the GPU boxes
|
|
@@ -1577,10 +1704,6 @@ class View:
|
|
|
1577
1704
|
else:
|
|
1578
1705
|
self.gpu_top += step
|
|
1579
1706
|
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
1707
|
if kind == "gpu": # a GPU's number at the bottom: hide that GPU, or show it again; one always stays
|
|
1585
1708
|
index = value[0]
|
|
1586
1709
|
if index in self.hidden:
|
|
@@ -1593,26 +1716,40 @@ class View:
|
|
|
1593
1716
|
self.sort, self.follow = (value[0], not descending if value[0] == column else False), True
|
|
1594
1717
|
return True
|
|
1595
1718
|
if kind == "click":
|
|
1596
|
-
|
|
1597
|
-
|
|
1719
|
+
row, col = value
|
|
1720
|
+
target = self.target(row, col)
|
|
1721
|
+
if isinstance(target, tuple): # a key hint, a heading of the list, a GPU's number at the bottom
|
|
1598
1722
|
return self.handle(target)
|
|
1599
|
-
|
|
1600
|
-
|
|
1723
|
+
if target is None: # anywhere else in a GPU's box: that GPU
|
|
1724
|
+
target = next((("box", index) for rows, cols, index in self.panels if row in rows and col in cols),
|
|
1725
|
+
None)
|
|
1601
1726
|
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
|
|
1727
|
+
double = target is not None and self.clicked[1] == target and now - self.clicked[0] < 0.4
|
|
1608
1728
|
self.asking, self.follow, self.clicked = None, True, (now, target)
|
|
1729
|
+
# A job picks it, a GPU the jobs on it, and outside the list opens that; the same again lets them
|
|
1730
|
+
# go, as does a place meaning nothing, which outside the list closes that too. A double click picks
|
|
1731
|
+
# them and shows only their GPUs, or all again.
|
|
1732
|
+
if target is None:
|
|
1733
|
+
self.pick(())
|
|
1734
|
+
self.listing = self.listing and row in self.list_rows
|
|
1735
|
+
return True
|
|
1736
|
+
pids = [job.pid for job in self.jobs if target[1] in job.gpus] if isinstance(target, tuple) else [target]
|
|
1737
|
+
if not pids:
|
|
1738
|
+
self.note = ([("no processes on GPU {}".format(target[1]), DIM)], now + 2)
|
|
1739
|
+
elif double:
|
|
1740
|
+
self.pick(pids)
|
|
1741
|
+
self.only = not self.only
|
|
1742
|
+
elif set(pids) == self.picked:
|
|
1743
|
+
self.pick(())
|
|
1744
|
+
else:
|
|
1745
|
+
self.pick(pids)
|
|
1746
|
+
self.opened = self.opened or not self.listing
|
|
1747
|
+
self.listing = True
|
|
1609
1748
|
return True
|
|
1610
1749
|
key = value[0].lower() if len(value[0]) == 1 else value[0]
|
|
1611
1750
|
if self.asking: # y stops; anything else, n and Esc among them, lets it be
|
|
1612
1751
|
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
|
|
1752
|
+
self.stop_picked()
|
|
1616
1753
|
self.asking = None
|
|
1617
1754
|
return key != "q"
|
|
1618
1755
|
if key == "q":
|
|
@@ -1622,12 +1759,12 @@ class View:
|
|
|
1622
1759
|
self.only = False
|
|
1623
1760
|
elif self.listing:
|
|
1624
1761
|
self.listing = False
|
|
1625
|
-
elif self.
|
|
1626
|
-
self.
|
|
1762
|
+
elif self.picked:
|
|
1763
|
+
self.pick(())
|
|
1627
1764
|
else:
|
|
1628
1765
|
return False
|
|
1629
|
-
elif key == "p":
|
|
1630
|
-
self.listing = not self.listing
|
|
1766
|
+
elif key == "p": # opened or closed by hand, the list stays so when the picks go
|
|
1767
|
+
self.listing, self.opened = not self.listing, False
|
|
1631
1768
|
elif key == "v": # the graph, the processes, the stats alone, GPU and MEM alone; V the one before
|
|
1632
1769
|
self.left = LEFTS[(LEFTS.index(self.left) + (-1 if value[0] == "V" else 1)) % len(LEFTS)]
|
|
1633
1770
|
name = {"graph": "graphs", "processes": "processes", "numbers": "numbers only",
|
|
@@ -1656,16 +1793,32 @@ class View:
|
|
|
1656
1793
|
elif key in MOVES and self.jobs:
|
|
1657
1794
|
order = [job.pid for job in self.jobs]
|
|
1658
1795
|
i = order.index(self.job) if self.job in order else (-1 if MOVES[key] > 0 else len(order))
|
|
1659
|
-
self.
|
|
1660
|
-
|
|
1796
|
+
self.pick([order[min(max(i + MOVES[key], 0), len(order) - 1)]])
|
|
1797
|
+
self.follow = True
|
|
1798
|
+
elif self.picked and key in ("f", "enter"):
|
|
1661
1799
|
self.only = not self.only
|
|
1662
|
-
elif self.
|
|
1663
|
-
if
|
|
1800
|
+
elif self.picked and key in ("t", "k"):
|
|
1801
|
+
if any(job.owner is None for job in self.selected()):
|
|
1664
1802
|
self.asking = "SIGTERM" if key == "t" else "SIGKILL"
|
|
1665
1803
|
else:
|
|
1666
1804
|
self.note = ([("only your own processes can be stopped", WARN)], time.monotonic() + 3)
|
|
1667
1805
|
return True
|
|
1668
1806
|
|
|
1807
|
+
def stop_picked(self):
|
|
1808
|
+
"""Send the signal asked for to each picked job of ours (see stop), and say how that went."""
|
|
1809
|
+
ours = [job for job in self.selected() if job.owner is None]
|
|
1810
|
+
results = [stop(job, self.asking) for job in ours]
|
|
1811
|
+
sent = {job.pid for job, (_, ok) in zip(ours, results) if ok}
|
|
1812
|
+
failed = [note for note, ok in results if not ok]
|
|
1813
|
+
if len(ours) == 1:
|
|
1814
|
+
note = results[0][0]
|
|
1815
|
+
elif not failed:
|
|
1816
|
+
note = [("sent {} to {} jobs".format(self.asking, len(sent)), "")]
|
|
1817
|
+
else:
|
|
1818
|
+
note = [("sent {} to {} of {} jobs; ".format(self.asking, len(sent), len(ours)), WARN)] + failed[0]
|
|
1819
|
+
self.note = (note, time.monotonic() + 4)
|
|
1820
|
+
self.sent = (sent, time.monotonic()) if sent and self.asking == "SIGTERM" else None
|
|
1821
|
+
|
|
1669
1822
|
|
|
1670
1823
|
# For terminals that draw the line, block and other characters above two cells wide, as they may for Korean,
|
|
1671
1824
|
# Chinese and Japanese (Unicode calls their width "ambiguous"): ASCII look-alikes, one for one.
|
|
@@ -1675,26 +1828,21 @@ ASCII = str.maketrans({"─": "-", "│": "|", "├": "+", "┤": "+", "╭": "+
|
|
|
1675
1828
|
"°": " ", "·": "-", "…": "~", "↑": "^", "↓": "v", "▲": "^", "▼": "v"})
|
|
1676
1829
|
|
|
1677
1830
|
|
|
1678
|
-
def paint(
|
|
1679
|
-
"""
|
|
1680
|
-
ASCII,
|
|
1831
|
+
def paint(line, mode="truecolor", ascii=False, gray=False):
|
|
1832
|
+
"""One line as the terminal gets it, from plain to plain: in color `mode` (with `gray`, grays only; see
|
|
1833
|
+
restyle), with `ascii` in ASCII, a style code only where the style changes."""
|
|
1681
1834
|
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)
|
|
1835
|
+
for text, style, *_ in line:
|
|
1836
|
+
if ascii:
|
|
1837
|
+
text = text.translate(ASCII)
|
|
1838
|
+
if mode != "truecolor" or gray:
|
|
1839
|
+
style = restyle(style, mode, gray)
|
|
1840
|
+
if style != current:
|
|
1841
|
+
# One color replaces another directly; anything else (bold, a background, plain) needs a reset first.
|
|
1842
|
+
colors = style.startswith("\x1b[38") and current.startswith("\x1b[38") and "\x1b[48" not in current
|
|
1843
|
+
out.append(style if colors else RESET + style)
|
|
1844
|
+
current = style
|
|
1845
|
+
out.append(text)
|
|
1698
1846
|
return "".join(out) + RESET
|
|
1699
1847
|
|
|
1700
1848
|
|
|
@@ -1782,7 +1930,7 @@ INPUT = re.compile(rb"\x1b\[<(?P<button>\d+);(?P<x>\d+);(?P<y>\d+)(?P<act>[Mm])"
|
|
|
1782
1930
|
def events(data):
|
|
1783
1931
|
"""The input in `data`, the bytes a terminal sends: ("key", name) events, ("click", row, column) for
|
|
1784
1932
|
a left-button press, ("wheel", +1 down or -1 up, row, column), ("hwheel", +1 right or -1 left, row,
|
|
1785
|
-
column)."""
|
|
1933
|
+
column), ("move", row, column) where the pointer went."""
|
|
1786
1934
|
out = []
|
|
1787
1935
|
for m in INPUT.finditer(data):
|
|
1788
1936
|
if m.group("button") or m.group("old"):
|
|
@@ -1795,7 +1943,9 @@ def events(data):
|
|
|
1795
1943
|
if button & 64: # the wheel: 64 up, 65 down, 66 left, 67 right; with Shift (+4) sideways too
|
|
1796
1944
|
sideways = button & 2 or button & 4
|
|
1797
1945
|
out.append(("hwheel" if sideways else "wheel", 1 if button & 1 else -1, y - 1, x - 1))
|
|
1798
|
-
elif
|
|
1946
|
+
elif button & 32: # a move, a button held or not
|
|
1947
|
+
out.append(("move", y - 1, x - 1))
|
|
1948
|
+
elif press and button & 3 == 0: # left button down
|
|
1799
1949
|
out.append(("click", y - 1, x - 1))
|
|
1800
1950
|
elif m.group("report"):
|
|
1801
1951
|
continue
|
|
@@ -1830,15 +1980,17 @@ TRUECOLOR_ANSWERS = re.compile(
|
|
|
1830
1980
|
|
|
1831
1981
|
|
|
1832
1982
|
class Screen:
|
|
1833
|
-
"""Alternate screen, hidden cursor, no auto-wrap, unbuffered keys, mouse clicks and
|
|
1983
|
+
"""Alternate screen, hidden cursor, no auto-wrap, unbuffered keys, mouse clicks, wheel and moves; all
|
|
1834
1984
|
restored on exit."""
|
|
1835
1985
|
|
|
1836
1986
|
def __enter__(self):
|
|
1837
1987
|
self._keys = sys.stdin.isatty()
|
|
1838
1988
|
self._mouse = self._keys and os.name != "nt" # Windows reads keys with msvcrt, which sees no mouse
|
|
1839
1989
|
self._restore = [enable_vt(), raw_keys() if self._keys else (lambda: None)]
|
|
1840
|
-
|
|
1841
|
-
|
|
1990
|
+
self._painted, self._size = [], None # the lines last sent, and the window they were sent to
|
|
1991
|
+
# 1000 + 1003 + 1006: clicks, the wheel and every move of the pointer come in as SGR-encoded sequences
|
|
1992
|
+
# (Shift+drag still selects text). A terminal without 1003 sends clicks and the wheel all the same.
|
|
1993
|
+
self._write("\x1b[?1049h\x1b[?25l\x1b[?7l" + ("\x1b[?1000h\x1b[?1003h\x1b[?1006h" if self._mouse else ""))
|
|
1842
1994
|
# Windows Terminal and the console of Windows 10 on show 24-bit color and draw lines one cell wide.
|
|
1843
1995
|
self.wide, self.truecolor = self.probe() if self._mouse else (False, os.name == "nt")
|
|
1844
1996
|
return self
|
|
@@ -1873,7 +2025,8 @@ class Screen:
|
|
|
1873
2025
|
return bool(cursor) and int(cursor.group(1)) > 2, bool(TRUECOLOR_ANSWERS.search(data))
|
|
1874
2026
|
|
|
1875
2027
|
def __exit__(self, *exc):
|
|
1876
|
-
|
|
2028
|
+
mouse = "\x1b[?1006l\x1b[?1003l\x1b[?1000l" if self._mouse else ""
|
|
2029
|
+
self._write(RESET + mouse + "\x1b[?7h\x1b[?25h\x1b[?1049l")
|
|
1877
2030
|
for undo in self._restore:
|
|
1878
2031
|
undo()
|
|
1879
2032
|
|
|
@@ -1907,9 +2060,16 @@ class Screen:
|
|
|
1907
2060
|
self._keys = False
|
|
1908
2061
|
return events(data)
|
|
1909
2062
|
|
|
1910
|
-
def draw(self, lines, mode, gray):
|
|
2063
|
+
def draw(self, lines, mode, gray, size):
|
|
2064
|
+
"""The screen `lines`, in a window of `size`: only the lines that changed since the last time, so that
|
|
2065
|
+
a frame, or the pointer moving over a graph, sends a few hundred bytes rather than the whole screen."""
|
|
2066
|
+
painted = [paint(line, mode, self.wide, gray) for line in lines]
|
|
2067
|
+
last = self._painted if self._size == size else [] # a new size: all anew
|
|
2068
|
+
changed = "".join("\x1b[{};1H{}".format(row + 1, text) for row, text in enumerate(painted)
|
|
2069
|
+
if row >= len(last) or last[row] != text)
|
|
2070
|
+
self._painted, self._size = painted, size
|
|
1911
2071
|
# 2026 = synchronized output: terminals that know it swap the frame in at once.
|
|
1912
|
-
self._write("\x1b[?2026h
|
|
2072
|
+
self._write("\x1b[?2026h" + changed + "\x1b[?2026l")
|
|
1913
2073
|
|
|
1914
2074
|
@staticmethod
|
|
1915
2075
|
def _write(text):
|
|
@@ -2009,6 +2169,7 @@ def main():
|
|
|
2009
2169
|
host, settings, indices = socket.gethostname(), Settings(), [g.index for g in gpus]
|
|
2010
2170
|
poller, view = Poller(gpus), View()
|
|
2011
2171
|
view.restore(settings.load(host), indices)
|
|
2172
|
+
view.interval = args.interval
|
|
2012
2173
|
kept = view.kept()
|
|
2013
2174
|
with Screen() as screen:
|
|
2014
2175
|
view.shows = color_mode(screen.truecolor)
|
|
@@ -2042,16 +2203,19 @@ def main():
|
|
|
2042
2203
|
width, height = os.get_terminal_size()
|
|
2043
2204
|
screen.draw(view.screen(gpus, width, height,
|
|
2044
2205
|
header(width, args.interval, driver, poller.waiting()), update.newer),
|
|
2045
|
-
view.shows, view.theme.gray)
|
|
2046
|
-
# Keys and clicks redraw at once
|
|
2206
|
+
view.shows, view.theme.gray, (width, height))
|
|
2207
|
+
# Keys and clicks redraw at once, the pointer when it goes to another graph column; the
|
|
2208
|
+
# numbers move on at the next frame.
|
|
2047
2209
|
events = screen.read(0.01 if peek is not None else (poll_at or frame_at) - time.monotonic())
|
|
2210
|
+
pointed = view.pointed()
|
|
2048
2211
|
going = all(map(view.handle, events))
|
|
2049
2212
|
if view.kept() != kept: # a setting changed: keep it for next time
|
|
2050
2213
|
kept = view.kept()
|
|
2051
2214
|
settings.save(kept, indices, host)
|
|
2052
2215
|
if not going:
|
|
2053
2216
|
break
|
|
2054
|
-
redraw
|
|
2217
|
+
redraw = any(e[0] != "move" for e in events) or view.pointed() != pointed
|
|
2218
|
+
detail = view.detail(gpus)
|
|
2055
2219
|
if detail - poller.detail: # at the latest the next round, or the one after the round running
|
|
2056
2220
|
busy = not poller.done.is_set()
|
|
2057
2221
|
peek = (detail - poller.detail | (peek[0] if peek else set()), poller.rounds + 1 + busy)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|