Created
February 13, 2026 17:00
-
-
Save edthrn/d0dabaa757b93396cee561b4cb6f1970 to your computer and use it in GitHub Desktop.
A htop-like program for NVIDIA GPU usage, based on nvidia-smi
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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