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.2
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). `+` opens the process list.
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, and the
89
- bottom line sums it up.
90
- - `f`, or a double click: only its GPUs; again for all.
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 a job of yours (SIGTERM), `k` kills it (SIGKILL): each after a `y`, and only if it is still
94
- the job you picked.
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
- | `f`, double click | only the picked job's GPUs, or all |
123
- | `t`, `k`, then `y` | stop (SIGTERM) or kill (SIGKILL) the picked job |
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). `+` opens the process list.
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, and the
67
- bottom line sums it up.
68
- - `f`, or a double click: only its GPUs; again for all.
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 a job of yours (SIGTERM), `k` kills it (SIGKILL): each after a `y`, and only if it is still
72
- the job you picked.
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
- | `f`, double click | only the picked job's GPUs, or all |
101
- | `t`, `k`, then `y` | stop (SIGTERM) or kill (SIGKILL) the picked job |
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.2" # "+dev": work past this release; the release commit sets the next number
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(("GPU{} {}".format("s" if len(job.gpus) > 1 else "", ranges(job.gpus)), DIM))
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=None, left="graph", scroll=(0, 0)):
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 a job is `selected` and it does not run here."""
1079
+ when jobs are `selected` (their PIDs) and none runs here."""
1041
1080
  s = gpu.now
1042
- faded = selected is not None and not any(p.job == selected for p in s.processes)
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: of the processes, just the way to the list
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 = [("+", DIM, ("list", gpu.index))]
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, gpu.index)
1106
+ label = process_label(s.processes, split - 4, selected)
1068
1107
  else:
1069
- beside, label = process_lines(s.processes, graph_w, height, selected, gpu.index, *scroll)
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=None):
1130
- """The segment of `process`'s name: clicked, it means its job; the `selected` job's is reversed."""
1131
- return process.name, THEME.selected if process.job == selected else THEME.accent, 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=None, index=None):
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, or "+" when none
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 " +", DIM, ("list", index))
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) + len(more(rest)[0]) > room:
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 - len(more(rest)[0])) + [more(rest)] if not label else label + [more(rest + 1)]
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 + [more(0)] if label else []
1201
+ return label
1164
1202
 
1165
1203
 
1166
- def process_lines(processes, room, height, selected=None, index=None, top=0, shift=0):
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, and "+", which clicked opens the process list at GPU `index`."""
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 {} ".format(top + 1, top + len(shown), len(lines)), DIM)] if len(lines) > height else []
1174
- return shown + [[(" " * room, "")]] * (height - len(shown)), label + [("+", DIM, ("list", index))]
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=None):
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=None, shape=None, left="graph", scroll=None):
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` one reversed, its command moved
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 == selected
1342
+ chosen = job.pid in selected
1305
1343
  values = [cell(job) for _, _, cell, _ in COLUMNS]
1306
- command = "…" + job.command[shift:] if chosen and shift else job.command
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 selected job,
1399
- only its GPUs, the process list, a question before stopping, a note on how that went."""
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.job = None # the selected job's PID
1407
- self.only = False # show only the GPUs it uses
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.top, self.follow = 0, True # the list's first job; keep the selected one in view
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 # (PID, when) of a SIGTERM, to point at k if the job lives on
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
- return next((job for job in self.jobs if job.pid == self.job), None)
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 = self.selected()
1444
- if job is None: # nothing selected, or its job ended
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
- shown = [g for g in gpus if (g.index in job.gpus if self.only else g.index not in self.hidden)]
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(job.command) + 1 - room)) if job else 0
1457
- body += process_list(self.jobs, width, rows, self.job, self.scrolled(rows - 2), self.sort, self.shift)
1458
- self.lines = [top_line] + body + [self.bottom_line(width, job, newer, gpus)]
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, room = width // cols, max(0, width // cols - CHROME_W - INFO_W)
1478
- self.areas = []
1479
- for k, g in enumerate(gpus if self.left == "processes" else []): # their offsets in range, where they are
1480
- most = listing_extent(g.now.processes, room, inner)
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.job, (cols, inner), self.left, self.scroll) + note
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 selected
1489
- job's. Only those are asked, as the driver takes some 2 ms a GPU to answer."""
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 = self.selected()
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
- pid, when = self.sent
1500
- job = next((job for job in self.jobs if job.pid == pid), None)
1501
- if job and time.monotonic() - when > 5:
1502
- self.note = ([("{} is still running 5 s after SIGTERM; k kills it at once".format(job.name), WARN)],
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 job or time.monotonic() - when > 5:
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, job, newer, gpus):
1634
+ def bottom_line(self, width, picked, newer, gpus):
1525
1635
  if self.asking:
1526
- return spread(width, [(self.question(job), WARN)], keys(("y", "yes"), ("n", "no")))
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 job:
1638
+ if picked:
1529
1639
  hints = [("f", "all GPUs" if self.only else "only these GPUs")]
1530
- hints += [("t", "stop"), ("k", "kill")] if job.owner is None and os.name != "nt" else []
1531
- left = [(job.name, THEME.accent, job.pid), (" ", DIM)] + about(job)
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, job):
1541
- """"stop train.py: SIGTERM to its torchrun (PID 48213), which ends its 2 workers?" and the like."""
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, job.launcher,
1547
- job.pid, passed, len(job.members))
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
- target = self.target(*value)
1597
- if isinstance(target, tuple): # a key hint, a heading of the list, "+" under a GPU
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
- # A job picks it; the picked job again, or a place meaning nothing, lets it go, and outside the
1600
- # list closes that too. A double click on a job picks it and shows only its GPUs, or all again.
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
- if target is None and value[0] not in self.list_rows:
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
- note, sent = stop(self.selected(), self.asking)
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.job is not None:
1626
- self.job = None
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.job, self.follow = order[min(max(i + MOVES[key], 0), len(order) - 1)], True
1660
- elif self.job is not None and key in ("f", "enter"):
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.job is not None and key in ("t", "k"):
1663
- if self.selected().owner is None:
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(lines, mode="truecolor", ascii=False, gray=False):
1679
- """Join lines into one string in color `mode` (with `gray`, grays only; see restyle), and with `ascii` in
1680
- ASCII, sending a style code only where the style changes."""
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 i, line in enumerate(lines):
1683
- if i:
1684
- out.append("\r\n")
1685
- for text, style, *_ in line:
1686
- if ascii:
1687
- text = text.translate(ASCII)
1688
- if mode != "truecolor" or gray:
1689
- style = restyle(style, mode, gray)
1690
- if style != current:
1691
- # One color replaces another directly; anything else (bold, a background, plain) needs a
1692
- # reset first.
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 press and button & 3 == 0 and not button & 32: # left button down, not a drag
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 wheel; all
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
- # 1000 + 1006: clicks and the wheel come in as SGR-encoded sequences (Shift+drag still selects text).
1841
- self._write("\x1b[?1049h\x1b[?25l\x1b[?7l" + ("\x1b[?1000h\x1b[?1006h" if self._mouse else ""))
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
- self._write(RESET + ("\x1b[?1006l\x1b[?1000l" if self._mouse else "") + "\x1b[?7h\x1b[?25h\x1b[?1049l")
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\x1b[H" + paint(lines, mode, self.wide, gray) + "\x1b[?2026l")
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; the numbers move on at the next frame.
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, detail = bool(events), view.detail(gpus)
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