Skip to content

Instantly share code, notes, and snippets.

@edthrn
Created February 13, 2026 17:00
Show Gist options
  • Select an option

  • Save edthrn/d0dabaa757b93396cee561b4cb6f1970 to your computer and use it in GitHub Desktop.

Select an option

Save edthrn/d0dabaa757b93396cee561b4cb6f1970 to your computer and use it in GitHub Desktop.
A htop-like program for NVIDIA GPU usage, based on nvidia-smi
#!/usr/bin/env python3
"""
nvtop.py - Terminal-based NVIDIA GPU monitor (htop-style).
Zero dependencies beyond Python 3 stdlib + nvidia-smi on PATH.
Controls:
q / Esc - Quit
Up / Down - Select GPU (when multiple)
p - Toggle process list sort (pid / memory)
h - Toggle help overlay
"""
import curses
import subprocess
import xml.etree.ElementTree as ET
import time
import signal
import sys
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class GpuProcess:
pid: int
name: str
used_memory_mib: float
@dataclass
class GpuInfo:
index: int
name: str
driver_version: str
temperature_c: int
fan_speed_pct: Optional[int]
power_draw_w: float
power_limit_w: float
memory_used_mib: float
memory_total_mib: float
gpu_util_pct: int
mem_util_pct: int
encoder_util_pct: int
decoder_util_pct: int
pcie_gen: str
pcie_width: str
pstate: str
processes: List[GpuProcess] = field(default_factory=list)
def _query_power_csv() -> List[tuple]:
"""Use --query-gpu CSV interface for power data (stable across driver versions)."""
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=power.draw,power.limit", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return []
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
powers = []
for line in result.stdout.strip().splitlines():
parts = line.split(",")
if len(parts) >= 2:
try:
draw = float(parts[0].strip())
limit = float(parts[1].strip())
powers.append((draw, limit))
except ValueError:
powers.append((0.0, 0.0))
return powers
def query_smi() -> List[GpuInfo]:
try:
result = subprocess.run(
["nvidia-smi", "-x", "-q"],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return []
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
# Get reliable power data via CSV
power_data = _query_power_csv()
root = ET.fromstring(result.stdout)
driver = root.findtext("driver_version", "N/A")
gpus: List[GpuInfo] = []
for i, gpu_el in enumerate(root.findall("gpu")):
def _t(path, default="0"):
raw = gpu_el.findtext(path, default)
if raw in ("N/A", "[N/A]", "Default", "", None):
return default
return raw.split()[0]
def _int(path, default=0):
try: return int(_t(path, str(default)))
except (ValueError, TypeError): return default
def _float(path, default=0.0):
try: return float(_t(path, str(default)))
except (ValueError, TypeError): return default
def _find_float(tag_name, default=0.0):
"""Search for a tag name anywhere in the GPU element tree.
Iterates all matches, returns first valid numeric value."""
for el in gpu_el.iter(tag_name):
if el.text and el.text.strip() not in ("N/A", "[N/A]", ""):
try:
val = float(el.text.strip().split()[0])
if val > 0:
return val
except (ValueError, IndexError):
pass
return default
fan_raw = gpu_el.findtext("fan_speed", "N/A")
fan_pct = None
if fan_raw not in ("N/A", "[N/A]", None, ""):
try: fan_pct = int(fan_raw.split()[0])
except (ValueError, IndexError): pass
processes = []
procs_el = gpu_el.find("processes")
if procs_el is not None:
for pi in procs_el.findall("process_info"):
pid_text = pi.findtext("pid", "0")
name_text = pi.findtext("process_name", "unknown")
mem_text = pi.findtext("used_memory", "0 MiB")
try: mem_val = float(mem_text.split()[0])
except (ValueError, IndexError): mem_val = 0.0
try: pid_val = int(pid_text)
except ValueError: pid_val = 0
processes.append(GpuProcess(pid=pid_val, name=name_text, used_memory_mib=mem_val))
# Power: prefer CSV data (stable), fall back to XML
if i < len(power_data):
pwr_draw, pwr_limit = power_data[i]
else:
pwr_draw = _find_float("power_draw")
pwr_limit = _find_float("max_power_limit", _find_float("power_limit"))
gpus.append(GpuInfo(
index=i,
name=gpu_el.findtext("product_name", "Unknown GPU"),
driver_version=driver,
temperature_c=_int("temperature/gpu_temp"),
fan_speed_pct=fan_pct,
power_draw_w=pwr_draw,
power_limit_w=pwr_limit,
memory_used_mib=_float("fb_memory_usage/used"),
memory_total_mib=_float("fb_memory_usage/total"),
gpu_util_pct=_int("utilization/gpu_util"),
mem_util_pct=_int("utilization/memory_util"),
encoder_util_pct=_int("utilization/encoder_util"),
decoder_util_pct=_int("utilization/decoder_util"),
pcie_gen=_t("pci/pci_gpu_link_info/pcie_gen/current_link_gen", "?"),
pcie_width=_t("pci/pci_gpu_link_info/link_widths/current_link_width", "?"),
pstate=gpu_el.findtext("performance_state", "?"),
processes=processes,
))
return gpus
def _bar(width, pct, label="", label_width=0):
"""Return list of (string, attr) tuples. Filled chars are batched by gradient color.
If label_width > 0, the suffix is padded/truncated to exactly that width."""
inner = max(width - 2, 0)
filled = int(inner * min(pct, 100.0) / 100.0)
empty = inner - filled
suffix = f" {label}" if label else f" {pct:5.1f}%"
if label_width > 0:
suffix = suffix[:label_width].ljust(label_width)
segments = [("|", curses.A_DIM)]
if filled > 0:
current_pair = gradient_pair(((0 + 1) / inner * 100.0) if inner else 0)
batch = "\u2588"
for ci in range(1, filled):
char_pct = (ci + 1) / inner * 100.0 if inner else 0
pair = gradient_pair(char_pct)
if pair == current_pair:
batch += "\u2588"
else:
segments.append((batch, curses.color_pair(current_pair) | curses.A_BOLD))
current_pair = pair
batch = "\u2588"
segments.append((batch, curses.color_pair(current_pair) | curses.A_BOLD))
segments.append(("-" * empty, curses.A_DIM))
segments.append(("|", curses.A_DIM))
segments.append((suffix, curses.color_pair(gradient_pair(pct)) | curses.A_BOLD))
return segments
# ---------------------------------------------------------------------------
# Gradient color system (256-color)
# Ramp: white -> yellow -> orange -> red over 0-100%
# We define 20 gradient steps using custom colors (indices 16-35 to avoid
# clobbering the standard 16). Color pairs 20-39 map to these.
# ---------------------------------------------------------------------------
GRADIENT_STEPS = 20
GRADIENT_PAIR_BASE = 20 # curses color pair IDs 20..39
GRADIENT_COLOR_BASE = 16 # curses color IDs 16..35
# Pre-computed RGB ramp (0-1000 scale, as curses uses 0-1000).
# white(1000,1000,1000) -> yellow(1000,1000,0) -> orange(1000,500,0) -> red(1000,0,0)
def _build_gradient():
"""Build a list of (r, g, b) tuples in curses 0-1000 scale."""
steps = GRADIENT_STEPS
colors = []
for i in range(steps):
t = i / max(steps - 1, 1) # 0.0 .. 1.0
if t < 0.33:
# white -> yellow: drop blue
p = t / 0.33
r, g, b = 1000, 1000, int(1000 * (1 - p))
elif t < 0.66:
# yellow -> orange: drop some green
p = (t - 0.33) / 0.33
r, g, b = 1000, int(1000 - 500 * p), 0
else:
# orange -> red: drop remaining green
p = (t - 0.66) / 0.34
r, g, b = 1000, int(500 * (1 - p)), 0
colors.append((r, g, b))
return colors
_GRADIENT_RGB = _build_gradient()
def init_gradient_colors():
"""Call once after curses.start_color(). Requires can_change_color()."""
if not curses.can_change_color():
return False
for i, (r, g, b) in enumerate(_GRADIENT_RGB):
curses.init_color(GRADIENT_COLOR_BASE + i, r, g, b)
curses.init_pair(GRADIENT_PAIR_BASE + i, GRADIENT_COLOR_BASE + i, -1)
return True
# Whether we successfully initialized gradient colors
_gradient_available = False
def gradient_pair(pct):
"""Return curses color pair index for a 0-100% value."""
if not _gradient_available:
# Fallback: map to the basic 3-step color pairs
if pct >= 90: return 2 # red
if pct >= 60: return 4 # yellow
return 3 # green
idx = int(min(pct, 100.0) / 100.0 * (GRADIENT_STEPS - 1))
idx = max(0, min(idx, GRADIENT_STEPS - 1))
return GRADIENT_PAIR_BASE + idx
def _temp_color(temp_c):
return gradient_pair(min(temp_c / 100.0 * 100, 100))
def _util_color(pct):
return gradient_pair(pct)
def draw_segments(win, y, x, segments, max_x):
cx = x
for text, attr in segments:
remaining = max_x - cx
if remaining <= 0: break
clipped = text[:remaining]
try:
win.addstr(y, cx, clipped, attr)
except curses.error:
pass
cx += len(clipped)
def safe_addstr(win, y, x, text, attr=0):
h, w = win.getmaxyx()
if y < 0 or y >= h or x >= w: return
try: win.addnstr(y, x, text, max(w - x, 0), attr)
except curses.error: pass
class NvTop:
REFRESH_INTERVAL = 1.0
def __init__(self, stdscr):
self.stdscr = stdscr
self.selected_gpu = 0
self.sort_mode = 0 # 0=MEM, 1=%USED, 2=%AVAIL, 3=PID
self.SORT_MODES = ["MEM", "%USED", "%AVAIL", "PID"]
self.show_help = False
self.running = True
self.gpus = []
self.last_query = 0.0
self._setup_curses()
def _setup_curses(self):
curses.curs_set(0)
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_RED, -1)
curses.init_pair(3, curses.COLOR_GREEN, -1)
curses.init_pair(4, curses.COLOR_YELLOW, -1)
curses.init_pair(5, curses.COLOR_BLUE, -1)
curses.init_pair(6, curses.COLOR_MAGENTA, -1)
curses.init_pair(7, curses.COLOR_WHITE, -1)
curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_WHITE)
global _gradient_available
_gradient_available = init_gradient_colors()
self.stdscr.timeout(200)
self.stdscr.keypad(True)
def _refresh_data(self):
now = time.monotonic()
if now - self.last_query >= self.REFRESH_INTERVAL:
self.gpus = query_smi()
self.last_query = now
if self.gpus and self.selected_gpu >= len(self.gpus):
self.selected_gpu = 0
def _handle_input(self):
key = self.stdscr.getch()
if key == -1: return
if key in (ord("q"), ord("Q"), 27):
self.running = False
elif key == curses.KEY_UP:
self.selected_gpu = max(0, self.selected_gpu - 1)
elif key == curses.KEY_DOWN:
if self.gpus:
self.selected_gpu = min(len(self.gpus) - 1, self.selected_gpu + 1)
elif key in (ord("p"), ord("P")):
self.sort_mode = (self.sort_mode + 1) % len(self.SORT_MODES)
elif key in (ord("h"), ord("H")):
self.show_help = not self.show_help
def _draw_header(self, y):
h, w = self.stdscr.getmaxyx()
title = " nvtop.py - NVIDIA GPU Monitor "
safe_addstr(self.stdscr, y, 0, title.center(w), curses.color_pair(8) | curses.A_BOLD)
return y + 1
def _draw_gpu_panel(self, y, gpu, selected):
h, w = self.stdscr.getmaxyx()
if y >= h - 1: return y
bar_width = max(min(w - 30, 50), 10)
marker = ">" if selected else " "
title_attr = curses.color_pair(1) | curses.A_BOLD if selected else curses.color_pair(1)
safe_addstr(self.stdscr, y, 0,
f"{marker} GPU {gpu.index}: {gpu.name} [Driver {gpu.driver_version}] {gpu.pstate}",
title_attr)
y += 1
if y >= h - 1: return y
indent = 4
temp_col = _temp_color(gpu.temperature_c)
parts = [(" Temp: ", curses.A_DIM), (f"{gpu.temperature_c}\u00b0C", curses.color_pair(temp_col) | curses.A_BOLD)]
if gpu.fan_speed_pct is not None:
parts += [(" Fan: ", curses.A_DIM), (f"{gpu.fan_speed_pct}%", curses.color_pair(7))]
pwr_pct = (gpu.power_draw_w / gpu.power_limit_w * 100) if gpu.power_limit_w else 0
parts += [(" Power: ", curses.A_DIM),
(f"{gpu.power_draw_w:.0f}W / {gpu.power_limit_w:.0f}W", curses.color_pair(_util_color(pwr_pct))),
(f" PCIe Gen{gpu.pcie_gen} x{gpu.pcie_width}", curses.A_DIM)]
draw_segments(self.stdscr, y, indent - 2, parts, w)
y += 1
if y >= h - 1: return y
# Fixed label width so all bars end at the same column
label_w = 22 # enough for " 16412 / 32607 MiB"
total_span = bar_width + label_w # total chars from indent to end
draw_segments(self.stdscr, y, indent,
_bar(bar_width, gpu.gpu_util_pct, f"{gpu.gpu_util_pct:3d}% GPU", label_w), w)
y += 1
if y >= h - 1: return y
mem_pct = (gpu.memory_used_mib / gpu.memory_total_mib * 100) if gpu.memory_total_mib else 0
draw_segments(self.stdscr, y, indent,
_bar(bar_width, mem_pct, f"{gpu.memory_used_mib:.0f} / {gpu.memory_total_mib:.0f} MiB", label_w), w)
y += 1
if y >= h - 1: return y
draw_segments(self.stdscr, y, indent,
_bar(bar_width, gpu.encoder_util_pct, f"{gpu.encoder_util_pct:3d}% ENC", label_w), w)
y += 1
if y >= h - 1: return y
draw_segments(self.stdscr, y, indent,
_bar(bar_width, gpu.decoder_util_pct, f"{gpu.decoder_util_pct:3d}% DEC", label_w), w)
y += 1
return y
def _draw_process_table(self, y):
h, w = self.stdscr.getmaxyx()
if y >= h - 2 or not self.gpus: return y
gpu = self.gpus[self.selected_gpu] if self.selected_gpu < len(self.gpus) else None
if gpu is None: return y
safe_addstr(self.stdscr, y, 0, "-" * w, curses.A_DIM)
y += 1
if y >= h - 1: return y
sort_indicator = self.SORT_MODES[self.sort_mode]
safe_addstr(self.stdscr, y, 0,
f" Processes on GPU {gpu.index} (sort: {sort_indicator}, press 'p' to cycle)",
curses.color_pair(6) | curses.A_BOLD)
y += 1
if y >= h - 1: return y
hdr = f" {'PID':>8} {'Mem (MiB)':>10} {'% Used':>7} {'% Avail':>8} {'Process Name'}"
safe_addstr(self.stdscr, y, 0, hdr[:w], curses.color_pair(8))
y += 1
total_mem = gpu.memory_total_mib if gpu.memory_total_mib else 1
total_used = gpu.memory_used_mib if gpu.memory_used_mib else 1
def _sort_key(p):
if self.sort_mode == 0: # MEM
return p.used_memory_mib
elif self.sort_mode == 1: # %USED
return p.used_memory_mib / total_used
elif self.sort_mode == 2: # %AVAIL
return p.used_memory_mib / total_mem
else: # PID
return p.pid
procs = sorted(gpu.processes, key=_sort_key, reverse=(self.sort_mode != 3))
if not procs:
safe_addstr(self.stdscr, y, 2, "No running processes.", curses.A_DIM)
return y + 1
for proc in procs:
if y >= h - 1: break
pct_used = (proc.used_memory_mib / total_used * 100) if total_used else 0
pct_avail = (proc.used_memory_mib / total_mem * 100)
max_name = max(w - 44, 10)
name = proc.name
if len(name) > max_name:
name = "..." + name[-(max_name - 3):]
line = f" {proc.pid:>8} {proc.used_memory_mib:>10.0f} {pct_used:>6.1f}% {pct_avail:>7.1f}% {name}"
safe_addstr(self.stdscr, y, 0, line[:w], curses.color_pair(gradient_pair(pct_avail)))
y += 1
return y
def _draw_footer(self):
h, w = self.stdscr.getmaxyx()
footer = " q:Quit Up/Down:Select GPU p:Sort h:Help "
ts = time.strftime("%H:%M:%S")
right = f" {ts} "
pad = max(w - len(footer) - len(right), 0)
safe_addstr(self.stdscr, h - 1, 0, (footer + " " * pad + right)[:w], curses.color_pair(8))
def _draw_help_overlay(self):
h, w = self.stdscr.getmaxyx()
lines = [
"+------ Help -------------------------+",
"| |",
"| q / Esc Quit |",
"| Up / Down Select GPU |",
"| p Cycle sort column |",
"| h Toggle this help |",
"| |",
"| Refresh interval: 1 second |",
"| Data source: nvidia-smi -x -q |",
"| |",
"+-------------------------------------+",
]
sy = max((h - len(lines)) // 2, 0)
sx = max((w - len(lines[0])) // 2, 0)
for i, line in enumerate(lines):
if sy + i >= h: break
safe_addstr(self.stdscr, sy + i, sx, line, curses.color_pair(1) | curses.A_BOLD)
def _draw(self):
self.stdscr.erase()
h, w = self.stdscr.getmaxyx()
if h < 5 or w < 40:
safe_addstr(self.stdscr, 0, 0, "Terminal too small.", curses.A_BOLD)
self.stdscr.noutrefresh()
curses.doupdate()
return
y = self._draw_header(0) + 1
if not self.gpus:
safe_addstr(self.stdscr, y, 2,
"No NVIDIA GPUs detected (is nvidia-smi available?).",
curses.color_pair(2) | curses.A_BOLD)
else:
for i, gpu in enumerate(self.gpus):
if y >= h - 2: break
y = self._draw_gpu_panel(y, gpu, selected=(i == self.selected_gpu)) + 1
self._draw_process_table(y)
self._draw_footer()
if self.show_help:
self._draw_help_overlay()
self.stdscr.noutrefresh()
curses.doupdate()
def run(self):
while self.running:
self._refresh_data()
self._draw()
self._handle_input()
def main(stdscr):
NvTop(stdscr).run()
if __name__ == "__main__":
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
try:
curses.wrapper(main)
except KeyboardInterrupt:
pass
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment