nvmon 0.3.2__tar.gz → 0.3.4__tar.gz

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