Repository navigation
Expand file tree
/
Copy pathamd_gpu_monitor.py
More file actions
2051 lines (1806 loc) · 95.3 KB
/
Copy pathamd_gpu_monitor.py
File metadata and controls
2051 lines (1806 loc) · 95.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Luxs
"""amd_gpu_monitor - see what is using your AMD GPUs on Linux: memory per
program, load, clocks, temperatures, power and running model servers.
Graphics programs are read from /proc/<pid>/fdinfo. GPU memory belongs to a
GPU client (an open file), which several processes can hold at once. Each
client is counted once and credited to one holder: the owner the kernel
records (debugfs, root only, newer kernels) if it is a holder; else the only
holder that is not a system process (systemd's descriptor store, logind);
else, as a marked guess, the oldest holder. Compute programs (ROCm, e.g. Ollama) are read
from /sys/class/kfd. Which kernel counter feeds which figure, and why, is
documented next to REQUESTED_KEY below.
Requires Linux, an AMD GPU driven by amdgpu, and Python 3.7 or newer.
Run as root to include every user's programs.
"""
# Keeps annotations as unevaluated strings, so the modern syntax used in
# them (int | None, list[str]) also runs on Python 3.7-3.9. Do not remove,
# and do not evaluate annotations at runtime (e.g. typing.get_type_hints).
from __future__ import annotations
import argparse
import glob
import json
import math
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import textwrap
import time
import unicodedata
import urllib.error
import urllib.request
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass, field
from datetime import datetime
# ===========================================================================
# EDIT HERE: name and credit line
# CREDIT_LINE is printed at the bottom of every view, in --help and in
# --version. Set it to "" to print nothing.
# ===========================================================================
PROGRAM = "amd_gpu_monitor"
CREDIT_LINE = ("(c) Luxs 2026 · GitHub: https://github.com/luxs-code/amd_gpu_monitor"
" · Web: https://mycoding.uk/")
# ===========================================================================
__version__ = "2.11.0"
EXIT_OK = 0
EXIT_ERROR = 1
EXIT_USAGE = 2 # used by argparse for invalid arguments
EXIT_UNSUPPORTED = 3
MIB = 1024 ** 2
GIB = 1024 ** 3
DEFAULT_REFRESH = 2.0
MIN_REFRESH = 0.2
CRITICAL_FREE = GIB # below this, the desktop will lag
TIGHT_FREE = int(2.5 * GIB) # below this, warn that room is tight
EVICTED_NOTICE = 512 * MIB # mention evicted memory above this
EVICTED_HIGHLIGHT = 64 * MIB # highlight a program's evicted memory above this
TEMP_WARN_MARGIN = 10.0 # warn this many degrees below a sensor's critical limit
# Per-program GPU time: fdinfo drm-engine-<name> is each client's cumulative
# busy time per engine type, in ns (amdgpu_fdinfo.c). GPU% = growth of the
# shader engines' time between two refreshes / elapsed time; several rings of
# one type running at once can push it above 100%. ROCm work runs on
# user-mode queues outside this accounting (KFD's per-process cu_occupancy
# exists only on gfx9 GPUs), so ROCm rows show n/a.
SHADER_ENGINES = ("gfx", "compute")
# GPU sensors (hwmon), checked against the amdgpu driver of Linux 5.19-6.17 and
# its own documentation (DOC: hwmon in drivers/gpu/drm/amd/pm/amdgpu_pm.c):
#
# Only a hwmon chip whose "name" is "amdgpu" is interpreted. The hwmon ABI
# lets other chips use the same attribute names with other units (some
# report temperatures in millivolts), so names alone prove nothing.
# temp[1-3]_input/_crit/_emergency: millidegrees Celsius. Labels: "edge"
# (die edge), "junction" (hotspot, hottest point), "mem" (VRAM). Channels
# 2 and 3 exist on SOC15 discrete GPUs only. emergency = chip shutdown.
# power1_average: average SoC power, microwatts (the driver converts
# explicitly). power1_input: instantaneous SoC power (Linux 6.12+). These
# are different sensors and are reported separately. On APUs both
# include the CPU, so they are labelled "SoC", not "GPU".
# freq1_input: shader (gfx/compute) clock, freq2_input: memory clock, both
# in hertz (all versions 5.19-6.17), read under the same sleep rule.
# power1_cap: the selected power limit, microwatts. power1_label: "PPT"
# (or "slowPPT"/"fastPPT" on one APU).
# Side effect: up to Linux 6.14, reading gpu_busy_percent or any of these
# wakes a runtime-suspended GPU (pm_runtime_get_sync); from 6.15 the read
# fails instead. So nothing is read while power/runtime_status says the GPU
# is suspended. mem_info_vram_* are plain counters and never wake it.
# Values outside the ranges below are treated as bad readings.
TEMP_RANGE = (-40.0, 200.0) # degrees Celsius
POWER_RANGE = (0.0, 3000.0) # watts
CLOCK_RANGE = (1.0, 10000.0) # MHz
TEMP_NAMES = {"edge": "edge", "junction": "hotspot", "mem": "memory"}
ASLEEP_STATES = {"suspended", "suspending"}
# Live view averages, like top's load averages. The kernel only reports
# current values, so these are built from the tool's own samples: the plain
# mean of every sample taken in the last 1, 5 and 15 minutes (top uses
# exponentially damped averages instead). A single snapshot has none.
AVERAGE_WINDOWS = ((60, "1 min"), (300, "5 min"), (900, "15 min"))
PCI_VENDORS = {"0x1002": "AMD", "0x10de": "NVIDIA", "0x8086": "Intel",
"0x1a03": "ASPEED", "0x15ad": "VMware", "0x1af4": "virtio",
"0x1234": "QEMU"}
UNIT_BYTES = {"": 1, "b": 1, "bytes": 1, "kib": 1024, "kb": 1024,
"mib": MIB, "mb": MIB, "gib": GIB, "gb": GIB}
# Kernel counters and what they mean, checked against the amdgpu source of
# Linux 5.19, 6.2, 6.4, 6.8, 6.10-6.17 and against real output from 6.14+:
#
# amd-requested-vram (6.4+) buffers that prefer VRAM. 6.4-6.13: VRAM is one
# of the preferred domains; 6.14+: VRAM is the top
# preferred domain, buffers without memory included.
# amd-evicted-vram (6.4+) the part of those buffers that has memory, but
# not where it prefers: 6.4-6.13: not in VRAM; 6.14+:
# outside EVERY preferred domain (a buffer that also
# allows GTT and sits there is not counted). Always a
# subset of requested. Where such memory is: GTT or plain
# system memory (amdgpu_evict_flags), and system memory
# can be swapped out (ttm_tt_swapout), so the tool says
# "placed elsewhere", not "in system RAM". A rising count
# means buffers were moved out OR newly placed outside;
# it does not prove anything was pushed out.
# mem_info_gtt_used all GTT in use: system memory mapped for the GPU,
# including buffers placed there on purpose. Not a
# measure of eviction or spill-over.
# drm-resident-vram (6.13+) and drm-memory-vram (all versions; an alias
# on 6.13+): buffers currently in VRAM. Can never exceed
# requested, but is implausibly large for long-running
# clients on some 6.14+ kernels, so it is checked.
# drm-total-vram NOT used: on 6.13 it counts by current placement, on
# 6.14+ it equals amd-requested-vram, before 6.13 absent.
# KFD vram_<gpu_id> ROCm allocations made with the VRAM flag, counted at
# allocation time and subtracted at free; imported
# buffers never count. It says nothing about location;
# on APUs with apu_prefer_gtt these "VRAM" allocations
# are even placed in system memory. So it only ever
# feeds "requested", never "in VRAM".
#
# "In VRAM" = the resident counter when plausible, else requested minus
# evicted, shown as an estimate. On 6.4-6.13 that difference equals resident
# memory; on 6.14+ it is an upper bound, because buffers that are in another
# preferred domain, or have no memory yet, are not counted as evicted.
#
# Bounds. Requested memory has NO upper bound tied to the card: the DRM
# usage-stats spec counts buffers whose memory is not allocated yet, and
# amdgpu only limits each single buffer to the size of its domain
# (amdgpu_bo_validate_size), never the sum. A request larger than the card
# is therefore real (an overcommitted program) and is reported, not dropped.
# KFD refuses ROCm VRAM allocations beyond the card on discrete GPUs, but not
# on APUs that place them in system memory (apu_prefer_gtt), so ROCm figures
# are not checked against the card either. Only memory that must physically
# be in VRAM is bounded: a resident figure above the card's size is
# implausible, and every in-VRAM ESTIMATE is capped at what the whole card is
# currently using (mem_info_vram_used), since no client can hold more.
# Exception: GPUs in one XGMI hive (xgmi_hive_info/xgmi_hive_id) map each
# other's VRAM into their address spaces, so a client's "VRAM" may physically
# be on a peer GPU; for them the whole hive's size and use are the limits.
REQUESTED_KEY = "amd-requested-vram"
EVICTED_KEY = "amd-evicted-vram"
RESIDENT_KEYS = ("drm-resident-vram", "drm-memory-vram")
SYSTEM_HOLDERS = {"systemd", "systemd-logind"}
LINE_RE = re.compile(r"^([\w.-]+):[ \t]*(\S+)(?:[ \t]+(\S+))?[ \t]*$", re.M)
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
DESCRIPTIONS = { # shown in the table; anything else stays blank
# compute
"llama-server": "LLM server (llama.cpp)", "llama-cli": "LLM (llama.cpp)",
"ollama": "Ollama", "python": "Python program", "pt_main_thread": "PyTorch program",
"blender": "Blender",
# desktops and compositors
"gnome-shell": "desktop & compositor", "kwin_wayland": "KDE compositor",
"kwin_x11": "KDE window manager", "plasmashell": "KDE desktop shell",
"Xorg": "X server", "Xwayland": "X11 app support", "sway": "compositor (sway)",
"Hyprland": "compositor (Hyprland)", "weston": "compositor (Weston)",
"gamescope": "game compositor", "mutter-x11-frames": "X11 window frames",
"xdg-desktop-portal": "desktop portal", "gsd-xsettings": "GNOME settings helper",
# applications
"firefox": "web browser", "RDD Process": "Firefox video decoder",
"chrome": "web browser", "chromium": "web browser", "steam": "Steam client",
"obs": "OBS Studio", "mpv": "video player", "vlc": "video player",
"nautilus": "file manager", "gnome-system-monitor": "system monitor",
"radeontop": "GPU monitor", "code": "VS Code",
}
class UnsupportedSystemError(Exception):
"""The platform or GPU cannot be monitored by this tool."""
# ---------------------------------------------------------------------------
# Data model (all sizes in bytes)
# ---------------------------------------------------------------------------
@dataclass
class PciGpu:
address: str
vendor: str
driver: str
path: str
@dataclass
class TempSensor:
channel: int # N in tempN_*
label: str # kernel label ("edge", "junction", "mem")
name: str # display name ("edge", "hotspot", "memory")
celsius: float
crit: float | None = None # critical limit
emergency: float | None = None # shutdown limit
@dataclass
class PowerReading:
"""SoC power from amdgpu; on APUs it includes the CPU."""
average_w: float | None # power1_average
input_w: float | None # power1_input (instantaneous, Linux 6.12+)
cap_w: float | None # power1_cap
label: str | None # power1_label, e.g. "PPT"
@dataclass
class GpuDevice:
address: str
name: str
vram_total: int
vram_used: int
gtt_total: int
gtt_used: int
busy_percent: int | None
temperatures: list[tuple[str, float]] # kept for compatibility
power_watts: float | None # kept: average, else instantaneous
temperature_sensors: list[TempSensor] = field(default_factory=list)
power: PowerReading | None = None
runtime_status: str | None = None # "active", "suspended", ...
xgmi_hive: str | None = None # GPUs in one hive share VRAM mappings
vram_used_reported: int | None = None # raw reading; vram_used is capped at total
gtt_used_reported: int | None = None
sclk_mhz: float | None = None # shader (gfx/compute) clock
mclk_mhz: float | None = None # memory clock
@property
def asleep(self) -> bool:
return self.runtime_status in ASLEEP_STATES
@property
def vram_free(self) -> int:
return max(0, self.vram_total - self.vram_used)
@dataclass
class ClientUsage:
"""One GPU client (open DRM file) and how it was credited to a process."""
client_id: str
gpu: str | None
pasid: int | None # address-space id; links ROCm to a client (<= 6.14)
requested: int | None # amd-requested-vram (None: kernel older than 6.4)
evicted: int | None # amd-evicted-vram (None: kernel older than 6.4)
resident_reported: int | None # resident counter exactly as reported
in_vram: int # best figure for memory currently in VRAM
in_vram_source: str # "resident", "requested-minus-evicted" or "unavailable"
holders: list[int] # every process holding this client
owner: int # the process it is credited to
attribution: str # "kernel", "sole-holder", "system" or "heuristic"
in_vram_capped: bool = False # estimate was capped at the card's VRAM use
engine_ns: dict[str, int] = field(default_factory=dict) # drm-engine-*: busy time
@dataclass
class ComputeUsage:
"""A process's ROCm (KFD) memory on one GPU and how it was merged."""
gpu: str | None
kfd_vram: int # KFD vram_<gpu_id>: VRAM allocations, counted at allocation
linked_client: str | None # drm-client-id of the address space KFD uses
link: str # "pasid", "only-client", "guess" or "none"
beyond_client: int # KFD memory the linked client does not show; at
# least this much is allocated but not yet mapped
in_vram: int # VRAM in the linked client's address space (DRM)
estimated: bool # that figure is requested-minus-evicted
in_vram_known: bool = True # False: no linked client visible
@dataclass
class GpuPart:
"""One process's memory on one GPU. With several GPUs, the table shows one
row per part, its bar scaled to that GPU."""
gpu: str | None
in_vram: int = 0
compute_in_vram: int = 0 # part of in_vram in the ROCm address space
evicted: int = 0
compute_evicted: int = 0
rocm_allocated: int = 0 # KFD allocations on this GPU (not a location)
in_vram_estimated: bool = False
graphics_estimated: bool = False
evicted_known: bool = True
in_vram_known: bool = True
clients: int = 0
@dataclass
class ProcessUsage:
pid: int
name: str
description: str
kind: str # "compute" or "graphics"
requested: int = 0 # buffers that prefer VRAM (see REQUESTED_KEY)
in_vram: int = 0 # best figure for memory currently in VRAM
evicted: int = 0 # part of requested that is elsewhere
compute_in_vram: int = 0 # part of in_vram that is ROCm (KFD) memory
compute_evicted: int = 0 # part of evicted that is ROCm (KFD) memory
in_vram_estimated: bool = False # some of in_vram is an estimate
evicted_known: bool = True # False on kernels older than 6.4
in_vram_capped: bool = False # an estimate was capped at the card's VRAM use
rocm_allocated: int = 0 # KFD: ROCm VRAM allocations (not a location)
in_vram_known: bool = True # False: ROCm memory with no visible address space
age_seconds: float | None = None # how long the process has been running
start_time: str | None = None # when it started (local time, ISO 8601)
clients: int = 0
gpus: list[str] = field(default_factory=list) # PCI addresses
client_details: list[ClientUsage] = field(default_factory=list)
compute_details: list[ComputeUsage] = field(default_factory=list)
parts: list[GpuPart] = field(default_factory=list) # one per GPU used
@property
def graphics_in_vram(self) -> int:
return max(0, self.in_vram - self.compute_in_vram)
@property
def graphics_evicted(self) -> int:
return max(0, self.evicted - self.compute_evicted)
@property
def graphics_estimated(self) -> bool:
linked = {c.linked_client for c in self.compute_details}
return any(c.in_vram_source != "resident" for c in self.client_details
if c.client_id not in linked)
@property
def attribution_guessed(self) -> bool:
"""True if any of this program's memory was credited by guesswork."""
return any(c.attribution == "heuristic" for c in self.client_details)
@dataclass
class ModelServer:
"""A local model server and what it reports."""
kind: str # "Ollama", "llama.cpp", "vLLM", ...
pid: int | None # None: found at its default address, not by process
address: str # "127.0.0.1:11434", or "" if its port is not known
status: str # "ok", "no reply", "refused", "HTTP 404",
# "unexpected reply", "port unknown"
models: list[str] = field(default_factory=list) # served model names
loaded: list[OllamaModel] = field(default_factory=list) # Ollama: with sizes
@dataclass
class OllamaModel:
name: str
size: int
size_vram: int
@dataclass
class Snapshot:
timestamp: datetime
devices: list[GpuDevice]
processes: list[ProcessUsage]
models: list[OllamaModel]
is_root: bool
warnings: list[str] = field(default_factory=list)
compute_by_device: dict[str, int] = field(default_factory=dict)
# live view only: GPU address -> metric -> window label -> mean
averages: dict[str, dict[str, dict[str, float]]] = field(default_factory=dict)
averaged_seconds: float = 0.0 # how much time the averages cover so far
ollama_running: bool = False # Ollama answered, even with no model loaded
servers: list[ModelServer] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"program": PROGRAM,
"version": __version__,
"timestamp": self.timestamp.isoformat(timespec="seconds"),
"root": self.is_root,
"devices": [dict(asdict(d), vram_free=d.vram_free,
compute_used=self.compute_by_device.get(d.address, 0))
for d in self.devices],
"processes": [dict(asdict(p), graphics_in_vram=p.graphics_in_vram,
graphics_evicted=p.graphics_evicted)
for p in self.processes],
"ollama_models": [asdict(m) for m in self.models],
"warnings": self.warnings,
"averages": self.averages, # live view only
"averaged_seconds": self.averaged_seconds,
"ollama_running": self.ollama_running,
"servers": [asdict(x) for x in self.servers],
}
# ---------------------------------------------------------------------------
# Low-level helpers
# ---------------------------------------------------------------------------
def _read(path: str) -> str | None:
try:
with open(path, errors="replace") as f:
return f.read()
except OSError:
return None
def _safe(text: str) -> str:
"""Untrusted text with control and format characters replaced by '?'.
Process and model names are chosen by whoever starts the program; printed
raw, escape sequences in them could move the cursor, recolour or clear the
terminal, and format characters could reverse the text direction.
"""
return "".join("?" if unicodedata.category(c) in ("Cc", "Cf", "Zl", "Zp") else c
for c in text)
def _int(text: str | None) -> int:
text = (text or "").strip()
return int(text) if text.isdigit() else 0
def _comm(pid: str) -> str:
return (_read(f"/proc/{pid}/comm") or "?").strip()
def _program_name(pid: str) -> str:
"""Program name, taken from the command line if the kernel truncated it."""
name = _comm(pid)
if len(name) == 15:
argv0 = (_read(f"/proc/{pid}/cmdline") or "").split("\0")[0]
base = os.path.basename(argv0)
if base.startswith(name):
return base
return name
def _counter(info: dict[str, tuple[str, str]], key: str,
bad_units: set[str]) -> int | None:
"""One fdinfo counter in bytes; None if absent or not understood."""
if key not in info:
return None
value = _to_bytes(*info[key])
if value is None:
bad_units.add(f"{key}: {' '.join(info[key])}")
return value
def _row_size(p: ProcessUsage) -> int:
"""Size used to order table rows; ROCm-only processes by their allocations."""
return max(p.in_vram + p.evicted, p.rocm_allocated if not p.in_vram_known else 0)
def _requested_or_resident(c: ClientUsage) -> int:
"""A client's requested VRAM; on kernels older than 6.4, its resident VRAM."""
return c.requested if c.requested is not None else c.in_vram
def _linked_client(details: list[ClientUsage],
kfd_pasid: int | None) -> tuple[ClientUsage | None, str]:
"""The DRM client whose address space KFD uses, and how it was found.
Up to Linux 6.14, KFD gives that client its own PASID and publishes it in
sysfs, so the match is exact. From 6.15 on, the PASID file reads 0: then
the process's only client on that GPU is taken, else the largest is
guessed. If the PASID is known but no visible client has it, the linked
client's descriptor has been closed and nothing is merged.
"""
if kfd_pasid:
match = next((c for c in details if c.pasid == kfd_pasid), None)
return (match, "pasid") if match else (None, "none")
if len(details) == 1:
return details[0], "only-client"
if details:
return max(details, key=_requested_or_resident), "guess"
return None, "none"
def _uptime() -> float | None:
"""Seconds since boot (/proc/uptime), the clock process start times use."""
words = (_read("/proc/uptime") or "").split()
try:
return float(words[0])
except (IndexError, ValueError):
return None
def _clock_ticks() -> int:
"""Clock ticks per second, the unit of process start times (USER_HZ)."""
try:
return int(os.sysconf("SC_CLK_TCK")) or 100
except (ValueError, OSError, AttributeError):
return 100
def _start_time(pid: str) -> int:
"""Process start time in clock ticks since boot (immune to PID reuse)."""
stat = _read(f"/proc/{pid}/stat") or ""
fields = stat[stat.rfind(")") + 2:].split() # comm may contain spaces
return int(fields[19]) if len(fields) > 19 and fields[19].isdigit() else 0
def _is_system_holder(pid: str) -> bool:
"""systemd (descriptor store) or logind, judged by executable, not name."""
if pid == "1":
return True
try:
exe = os.path.basename(os.readlink(f"/proc/{pid}/exe"))
exe = exe.replace(" (deleted)", "") # binary upgraded on disk
except OSError:
exe = _comm(pid) # not root: best effort
return exe in SYSTEM_HOLDERS
def _debugfs_device(clients_path: str) -> tuple[str, str] | None:
"""(driver, PCI address) of the device a debugfs 'clients' file belongs
to, from the 'name' file beside it: "<driver> dev=<address> ... unique=..."
(same directory in every kernel version: per minor up to 6.x, per device
later, with the minor numbers as symlinks to it)."""
words = (_read(os.path.join(os.path.dirname(clients_path), "name")) or "").split()
address = next((w[4:] for w in words[1:] if w.startswith("dev=")), "")
return (words[0], address.lower()) if words and address else None
def _kernel_client_owners() -> dict[tuple[str, str], int]:
"""(PCI address, drm-client-id) -> owner PID as recorded by the kernel.
For render nodes the kernel updates the owner to the process that last
used the client; for primary nodes it stays the process that opened it.
Needs root, a mounted debugfs and a kernel whose 'clients' file has an
'id' column. Returns {} when any of that is missing.
Keys include the device. Client IDs are in fact system-wide (amdgpu's
fence context up to 6.4, then a global counter in DRM core), but the
lookup does not rely on that: each file is tied to its device by the
'name' file beside it, and files of other drivers are skipped.
"""
owners: dict[tuple[str, str], int] = {}
for path in glob.glob("/sys/kernel/debug/dri/*/clients"):
device = _debugfs_device(path)
if device is None or device[0] != "amdgpu":
continue # device unknown, or not an AMD GPU
lines = (_read(path) or "").splitlines()
if not lines:
continue
header = lines[0].split()
if len(header) < 3 or header[0] != "command" or header[-1] != "id":
continue # unknown format
columns = header[1:] # parse from the right: command has spaces
for line in lines[1:]:
parts = line.split()
if len(parts) <= len(columns):
continue
row = dict(zip(columns, parts[-len(columns):]))
pid, cid = row.get("tgid") or row.get("pid"), row.get("id")
if pid and pid.isdigit() and cid and cid.isdigit():
owners[(device[1], cid)] = int(pid)
return owners
def _attribute(pids: set[str], kernel_owner: int | None) -> tuple[str, str]:
"""Pick the process to credit a shared GPU client to, and say how."""
candidates = sorted((p for p in pids if not _is_system_holder(p)), key=int)
if kernel_owner is not None and str(kernel_owner) in candidates:
return str(kernel_owner), "kernel"
if len(candidates) == 1:
return candidates[0], "sole-holder"
if not candidates: # only system processes still hold it
return min(pids, key=_age_key), "system"
return min(candidates, key=_age_key), "heuristic"
def _age_key(pid: str) -> tuple[int, int]:
"""Sort key: oldest process first; PID only breaks exact ties."""
return _start_time(pid), int(pid)
def _describe(name: str) -> str:
if name in DESCRIPTIONS:
return DESCRIPTIONS[name]
return next((text for key, text in DESCRIPTIONS.items()
if name.startswith(key)), "")
def _parse_fdinfo(text: str) -> dict[str, tuple[str, str]]:
return {m.group(1): (m.group(2), (m.group(3) or "").lower())
for m in LINE_RE.finditer(text)}
def _to_bytes(value: str, unit: str) -> int | None:
if not value.isdigit() or unit not in UNIT_BYTES:
return None
return int(value) * UNIT_BYTES[unit]
# ---------------------------------------------------------------------------
# Hardware detection
# ---------------------------------------------------------------------------
def detect_gpus() -> list[PciGpu]:
"""All display devices the kernel knows about, of any vendor."""
found: dict[str, PciGpu] = {}
for card in sorted(glob.glob("/sys/class/drm/card[0-9]*")):
if not re.fullmatch(r"card\d+", os.path.basename(card)):
continue # skip connectors like card1-DP-1
dev = os.path.join(card, "device")
if not os.path.exists(dev):
continue
address = os.path.basename(os.path.realpath(dev))
vendor_id = (_read(f"{dev}/vendor") or "").strip().lower()
driver_link = os.path.join(dev, "driver")
driver = (os.path.basename(os.path.realpath(driver_link))
if os.path.exists(driver_link) else "no driver")
vendor = PCI_VENDORS.get(vendor_id, f"vendor {vendor_id or 'unknown'}")
found.setdefault(address, PciGpu(address, vendor, driver, dev))
return list(found.values())
def find_supported_gpus() -> list[PciGpu]:
"""AMD GPUs this tool can monitor; raises UnsupportedSystemError if none."""
if not sys.platform.startswith("linux"):
raise UnsupportedSystemError(
f"Linux is required; this system is {sys.platform!r}.")
if not os.path.isdir("/sys/class/drm"):
raise UnsupportedSystemError(
"/sys/class/drm is missing: no GPU driver is loaded, "
"or /sys is not available (for example inside a container).")
gpus = detect_gpus()
amd = [g for g in gpus if g.vendor == "AMD"]
supported = [g for g in amd if g.driver == "amdgpu"
and _int(_read(f"{g.path}/mem_info_vram_total"))]
if supported:
return supported
if any(g.driver == "amdgpu" for g in amd):
raise UnsupportedSystemError(
"the amdgpu driver does not report VRAM statistics for this GPU; "
"a newer kernel is needed.")
if amd:
drivers = ", ".join(sorted({g.driver for g in amd}))
raise UnsupportedSystemError(
f"AMD GPU found, but it uses the '{drivers}' driver; "
"the 'amdgpu' driver is required.")
if gpus:
vendors = ", ".join(sorted({g.vendor for g in gpus}))
hint = (" For NVIDIA GPUs, use nvidia-smi."
if any(g.vendor == "NVIDIA" for g in gpus) else "")
raise UnsupportedSystemError(
f"no AMD GPU found (found: {vendors}). Only AMD GPUs using the "
f"amdgpu driver are supported.{hint}")
raise UnsupportedSystemError("no GPU found.")
# ---------------------------------------------------------------------------
# Data collection
# ---------------------------------------------------------------------------
_name_cache: dict[str, str] = {}
def _gpu_name(gpu: PciGpu) -> str:
if gpu.address not in _name_cache:
name = (_read(f"{gpu.path}/product_name") or "").strip()
if not name:
try:
out = subprocess.run(["lspci", "-s", gpu.address],
capture_output=True, text=True,
timeout=2).stdout
m = re.search(r"\[(Radeon[^\]]*)\]", out)
name = ("AMD " + m.group(1)) if m else out.split(": ", 1)[-1]
except (OSError, subprocess.SubprocessError):
name = ""
_name_cache[gpu.address] = _safe(name.strip()) or "AMD GPU"
return _name_cache[gpu.address]
def _scaled(path: str, divisor: float, valid: tuple[float, float]) -> float | None:
"""A hwmon integer divided by divisor; None if absent, unreadable or implausible."""
text = (_read(path) or "").strip()
if not text.lstrip("-").isdigit():
return None
value = int(text) / divisor
return value if valid[0] <= value <= valid[1] else None
def _amdgpu_hwmon(path: str) -> tuple[list[TempSensor], PowerReading | None,
dict[str, float]]:
"""Temperatures, power and clocks (MHz) from the GPU's own amdgpu hwmon chip."""
for hw in sorted(glob.glob(f"{path}/hwmon/hwmon*")):
if (_read(f"{hw}/name") or "").strip() != "amdgpu":
continue # not amdgpu's chip: units not guaranteed
sensors: list[TempSensor] = []
for t in glob.glob(f"{hw}/temp*_input"):
m = re.fullmatch(r"temp(\d+)_input", os.path.basename(t))
celsius = _scaled(t, 1000, TEMP_RANGE) if m else None
if m is None or celsius is None:
continue
base = f"{hw}/temp{m.group(1)}"
label = (_read(f"{base}_label") or "").strip() or f"temp{m.group(1)}"
sensors.append(TempSensor(
channel=int(m.group(1)), label=label,
name=TEMP_NAMES.get(label, label), celsius=celsius,
crit=_scaled(f"{base}_crit", 1000, TEMP_RANGE),
emergency=_scaled(f"{base}_emergency", 1000, TEMP_RANGE)))
sensors.sort(key=lambda x: x.channel)
power = PowerReading(
average_w=_scaled(f"{hw}/power1_average", 1e6, POWER_RANGE),
input_w=_scaled(f"{hw}/power1_input", 1e6, POWER_RANGE),
cap_w=_scaled(f"{hw}/power1_cap", 1e6, POWER_RANGE),
label=(_read(f"{hw}/power1_label") or "").strip() or None)
if power.average_w is None and power.input_w is None:
power = None
clocks = {name: mhz for name, attr in (("sclk", "freq1_input"), ("mclk", "freq2_input"))
for mhz in [_scaled(f"{hw}/{attr}", 1e6, CLOCK_RANGE)] if mhz is not None}
return sensors, power, clocks
return [], None, {}
def read_device(gpu: PciGpu) -> GpuDevice:
status = (_read(f"{gpu.path}/power/runtime_status") or "").strip() or None
asleep = status in ASLEEP_STATES # reading sensors could wake it
busy = "" if asleep else (_read(f"{gpu.path}/gpu_busy_percent") or "").strip()
sensors, power, clocks = ([], None, {}) if asleep else _amdgpu_hwmon(gpu.path)
legacy_power = None if power is None else (
power.average_w if power.average_w is not None else power.input_w)
total = _int(_read(f"{gpu.path}/mem_info_vram_total"))
raw_vram_used = _int(_read(f"{gpu.path}/mem_info_vram_used"))
gtt_total = _int(_read(f"{gpu.path}/mem_info_gtt_total"))
raw_gtt_used = _int(_read(f"{gpu.path}/mem_info_gtt_used"))
hive = (_read(f"{gpu.path}/xgmi_hive_info/xgmi_hive_id") or "").strip() # "0"/absent: none
return GpuDevice(
address=gpu.address, name=_gpu_name(gpu), vram_total=total,
vram_used=min(raw_vram_used, total), vram_used_reported=raw_vram_used,
gtt_total=gtt_total,
gtt_used=min(raw_gtt_used, gtt_total) if gtt_total else raw_gtt_used,
gtt_used_reported=raw_gtt_used,
busy_percent=int(busy) if busy.isdigit() and int(busy) <= 100 else None,
temperatures=[(x.label, x.celsius) for x in sensors],
power_watts=legacy_power, temperature_sensors=sensors, power=power,
runtime_status=status, xgmi_hive=hive if hive.strip("0") else None,
sclk_mhz=clocks.get("sclk"), mclk_mhz=clocks.get("mclk"))
def _kfd_gpu_addresses() -> dict[str, str]:
"""Map KFD gpu_id -> PCI address, using the KFD topology in sysfs."""
out: dict[str, str] = {}
for node in glob.glob("/sys/class/kfd/kfd/topology/nodes/*"):
gpu_id = (_read(f"{node}/gpu_id") or "").strip()
if not gpu_id.isdigit() or gpu_id == "0": # 0 = CPU node
continue
props = {}
for line in (_read(f"{node}/properties") or "").splitlines():
parts = line.split()
if len(parts) == 2:
props[parts[0]] = parts[1]
location, domain = props.get("location_id", ""), props.get("domain", "0")
if location.isdigit() and domain.isdigit():
loc = int(location) # (bus << 8) | devfn
out[gpu_id] = (f"{int(domain):04x}:{loc >> 8:02x}:"
f"{(loc >> 3) & 0x1f:02x}.{loc & 7:x}")
return out
def collect_processes(capacities: dict[str, int], used: dict[str, int] | None = None
) -> tuple[list[ProcessUsage], dict[str, int], list[str]]:
"""Per-program VRAM use, compute use per GPU, and data-quality warnings.
capacities and used map each monitored GPU's PCI address to its VRAM size
and current VRAM use. Reported figures are never dropped for being large
(see "Bounds" next to REQUESTED_KEY): only resident figures are checked
against the card's size, and in-VRAM estimates are capped at its use. If
a client's GPU cannot be identified, the largest card's figures are used.
"""
largest = max(capacities.values())
only = next(iter(capacities)) if len(capacities) == 1 else None
used = used or capacities
def capacity(address: str | None) -> int:
return capacities[address] if address else largest
def in_use(address: str | None) -> int:
return used.get(address, capacity(address)) if address else max(used.values())
# Graphics clients, each counted once even if several processes hold it
clients: dict[tuple[str | None, str], list] = {} # -> [req, ev, res, pasid, pids]
bad_units: set[str] = set()
without_stats = 0
for path in glob.glob("/proc/[0-9]*/fdinfo/*"):
text = _read(path)
if not text or "drm-driver" not in text:
continue
info = _parse_fdinfo(text)
if info.get("drm-driver", ("",))[0] != "amdgpu":
continue
address = info["drm-pdev"][0].lower() if "drm-pdev" in info else only
if address is not None and address not in capacities:
continue # a GPU this tool does not monitor
requested = _counter(info, REQUESTED_KEY, bad_units)
evicted = _counter(info, EVICTED_KEY, bad_units)
resident = next((_counter(info, k, bad_units)
for k in RESIDENT_KEYS if k in info), None)
if "drm-client-id" not in info or (requested is None and resident is None):
without_stats += 1
continue
pasid = info.get("pasid", ("",))[0]
cid = (address, info["drm-client-id"][0])
engine = {k[len("drm-engine-"):]: int(v) for k, (v, unit) in info.items()
if k.startswith("drm-engine-") and "capacity" not in k
and v.isdigit() and unit in ("ns", "")}
clients.setdefault(cid, [requested, evicted, resident,
int(pasid) if pasid.isdigit() else None,
set(), engine])[4].add(path.split("/")[2])
# Credit each client to one holder, per GPU
owners = _kernel_client_owners()
graphics: dict[tuple[str, str | None], list[ClientUsage]] = {}
for (address, cid), (requested, evicted, resident, pasid, pids, engine) in clients.items():
cap = capacity(address)
if requested is not None and evicted is not None:
evicted = min(evicted, requested)
# Resident memory is a subset of requested memory in every kernel
# version (a buffer can only be in VRAM if it lists VRAM as preferred).
capped = False
if resident is not None and resident <= cap and (
requested is None or resident <= requested):
in_vram, source = resident, "resident"
elif requested is None: # pre-6.4 kernel, implausible resident
in_vram, source = 0, "unavailable"
else:
estimate = max(0, requested - (evicted or 0))
in_vram, source = min(estimate, in_use(address)), "requested-minus-evicted"
capped = estimate > in_vram
pid, how = _attribute(pids, owners.get((address, cid)) if address else None)
graphics.setdefault((pid, address), []).append(ClientUsage(
client_id=cid, gpu=address, pasid=pasid, requested=requested, evicted=evicted,
resident_reported=resident, in_vram=in_vram, in_vram_source=source,
in_vram_capped=capped, engine_ns=engine,
holders=sorted(int(p) for p in pids), owner=int(pid), attribution=how))
# Compute (ROCm) memory, per GPU
kfd_map = _kfd_gpu_addresses()
compute: dict[tuple[str, str | None], int] = {}
kfd_pasids: dict[str, int] = {} # nonzero only on Linux 6.14 and older
for d in glob.glob("/sys/class/kfd/kfd/proc/[0-9]*"):
pid = os.path.basename(d)
if _int(_read(f"{d}/pasid")):
kfd_pasids[pid] = _int(_read(f"{d}/pasid"))
for f in glob.glob(f"{d}/vram_*"):
size = _int(_read(f))
if not size:
continue
address = kfd_map.get(f.rsplit("_", 1)[-1], only)
if address is not None and address not in capacities:
continue
compute[(pid, address)] = compute.get((pid, address), 0) + size
# Merge graphics and compute, per GPU. KFD runs a process's ROCm work in
# the address space of ONE of its DRM clients (the "linked" client); a
# ROCm buffer mapped on that GPU is that client's own buffer, so the
# client's fdinfo counts it, along with page tables and any other buffers
# in that address space. The KFD counter is an allocation total with no
# location. Therefore:
# in VRAM = DRM clients only (the only location figures);
# compute part = the linked client: "VRAM in the ROCm address space";
# requested += max(0, KFD - linked client's requested): at least that
# much ROCm memory is allocated but not mapped, location
# not reported (shown in the status box, not guessed).
# Which client is linked only changes the compute/graphics split, never
# the total: a guessed link (Linux 6.15+, several clients) is flagged.
usage: dict[str, ProcessUsage] = {}
compute_by_device: dict[str, int] = {}
for pid, address in sorted(set(graphics) | set(compute), key=str):
details = graphics.get((pid, address), [])
kfd = compute.get((pid, address), 0)
linked, link = (_linked_client(details, kfd_pasids.get(pid))
if kfd else (None, "none"))
u = usage.setdefault(pid, ProcessUsage(int(pid), "", "", "graphics"))
others = [c for c in details if c is not linked]
g_in = sum(c.in_vram for c in others)
c_in = linked.in_vram if linked else 0
g_estimated = any(c.in_vram_source != "resident" for c in others)
c_estimated = linked is not None and linked.in_vram_source != "resident"
# A process cannot hold more VRAM on a GPU than that GPU (or its hive)
# uses. Measured figures respect that already; trim estimated ones.
limit = in_use(address)
if g_in + c_in > limit:
u.in_vram_capped = True
if c_estimated:
c_in = max(0, min(c_in, limit - (0 if g_estimated else g_in)))
g_in = max(0, min(g_in, limit - c_in))
u.in_vram += g_in + c_in
u.requested += sum(_requested_or_resident(c) for c in details)
u.evicted += sum(c.evicted or 0 for c in details)
u.in_vram_estimated |= g_estimated or c_estimated
u.in_vram_capped |= any(c.in_vram_capped for c in details)
if kfd:
beyond = max(0, kfd - (_requested_or_resident(linked) if linked else 0))
u.kind = "compute"
u.rocm_allocated += kfd
u.requested += beyond
u.compute_in_vram += c_in
u.compute_evicted += (linked.evicted or 0) if linked else 0
if linked is None:
u.in_vram_known = False
u.compute_details.append(ComputeUsage(
gpu=address, kfd_vram=kfd,
linked_client=linked.client_id if linked else None, link=link,
beyond_client=beyond, in_vram=c_in, estimated=c_estimated,
in_vram_known=linked is not None))
if address and linked is not None:
compute_by_device[address] = compute_by_device.get(address, 0) + c_in
u.evicted_known &= all(c.evicted is not None for c in details)
u.clients += len(details)
u.client_details.extend(details)
if address and address not in u.gpus:
u.gpus.append(address)
u.parts.append(GpuPart(
gpu=address, in_vram=g_in + c_in, compute_in_vram=c_in,
evicted=sum(c.evicted or 0 for c in details),
compute_evicted=(linked.evicted or 0) if linked else 0,
rocm_allocated=kfd, in_vram_estimated=g_estimated or c_estimated,
graphics_estimated=g_estimated,
evicted_known=all(c.evicted is not None for c in details),
in_vram_known=not (kfd and linked is None), clients=len(details)))
uptime, ticks_per_second, now = _uptime(), _clock_ticks(), time.time()
for pid, u in usage.items():
u.name = _safe(_program_name(pid))
started = _start_time(pid) # clock ticks after boot; 0 = unreadable
if uptime is not None and started > 0:
u.age_seconds = max(0.0, uptime - started / ticks_per_second)
u.start_time = datetime.fromtimestamp(now - u.age_seconds).isoformat(
timespec="seconds")
u.description = _describe(u.name)
# Total compute memory on a GPU cannot exceed what the GPU uses either.
compute_by_device = {a: min(v, in_use(a)) for a, v in compute_by_device.items()}
warnings = []
warnings += [f"Unrecognised kernel value skipped: {b}"
for b in sorted(bad_units)]
if without_stats and not clients:
warnings.append("This kernel does not report per-program GPU memory, "
"so only compute (ROCm) programs are listed. A newer "
"kernel is needed for the full table.")
elif without_stats:
warnings.append(f"{without_stats} GPU client(s) report no usable memory "
"statistics and are not included in the table.")
ordered = sorted(usage.values(), key=lambda u: (u.kind != "compute", -_row_size(u)))
return ordered, compute_by_device, warnings
# Model servers. Recognised by process name; their listening TCP ports come
# from /proc/<pid>/fd and /proc/net/tcp{,6} (root is needed for other users'
# processes); their models are asked over HTTP: Ollama /api/ps (loaded models
# with sizes), the others the OpenAI-compatible /v1/models. Children of a
# server (Ollama's internal llama-server, worker processes) are skipped.
MODEL_SERVERS = ( # label, executable names, Python module prefixes, API
("Ollama", ("ollama",), (), "ollama"),
("llama.cpp", ("llama-server", "llamafile"), (), "openai"),
("vLLM", ("vllm",), ("vllm.",), "openai"),
("SGLang", (), ("sglang.",), "openai"),
("LM Studio", ("lm studio", "lm-studio", "lmstudio", "llmster"), (), "openai"),
("KoboldCpp", ("koboldcpp",), (), "openai"),
("LocalAI", ("local-ai",), (), "openai"),
("TGI", ("text-generation-launcher", "text-generation-router"), (), "openai"),
)
SERVER_TIMEOUT = 0.5 # seconds per request; requests run in parallel
SERVER_PORTS_TRIED = 3 # a server listening on many ports: ask at most this many
def _server_kind(pid: str) -> tuple[str, str] | None:
"""(label, API) if the process is a known model server."""
argv = [a for a in (_read(f"/proc/{pid}/cmdline") or "").split("\0") if a]
names = {_comm(pid).lower()} | ({os.path.basename(argv[0]).lower()} if argv else set())
for label, executables, modules, api in MODEL_SERVERS:
for name in names:
for exe in executables:
if (name == exe or name.startswith((exe + "-", exe + "."))
or (exe == "llamafile" and name.endswith(".llamafile"))):
return label, api
if any(arg.startswith(modules) for arg in argv[1:]) if modules else False:
return label, api
return None
def _parent(pid: str) -> str | None:
stat = _read(f"/proc/{pid}/stat") or ""
fields = stat[stat.rfind(")") + 2:].split()
return fields[1] if len(fields) > 1 and fields[1].isdigit() else None