Architecture
gputop is organized as a one-way pipeline:
Providers ──► Collectors ──► Publisher ──► Snapshot ──► TUI / JSON / API / Prometheus
│
├─► derive (state, efficiency, imbalance, health)
├─► events + alerts
└─► history store
Each stage has one owner. Collectors never render. The history store never collects. The UI never calls a provider.
Vendor-neutral core (internal/gpu)
gpu.Provider is the only interface a vendor backend implements:
type Provider interface {
Name() string
Vendor() Vendor
Open(ctx) (Diagnostics, error)
Close() error
System(ctx) (SystemInfo, error)
Devices(ctx) ([]Device, error)
Sample(ctx, id) (Sample, error)
Processes(ctx, id) ([]Process, error)
Health(ctx, id) (HealthCounters, error)
Links(ctx, id) ([]Link, error)
Partitions(ctx, id) ([]Partition, error)
}
Optional interfaces: gpu.EventSource (event-driven notifications such as
Xids) and gpu.TopologyProvider (PCIe relationships).
Design rules:
- Stable identity. Devices are keyed by vendor UUID (
gpu.ID), never by enumeration index. - Optional values. Every reading is a
metric.Opt[T]. Unsupported, denied or failed readings are unavailable, not zero. - Neutral concepts. NVIDIA clock event reasons map to
gpu.ThrottleReasons, MIG instances togpu.Partition, NVLink togpu.Link. An AMD or Intel provider maps its own concepts onto the same types. - Classified errors. Providers wrap
gpu.ErrNotSupported,ErrNoPermission,ErrDeviceLost,ErrNotFoundandErrUnavailable, so the collector can tell “feature absent” from “something broke”. - Diagnostics.
Openreports every check it performed; the UI shows them when no GPU is found.
Adding a vendor means creating internal/gpu/<vendor>, implementing Provider,
and registering it in internal/app. No other package changes.
NVIDIA provider (internal/gpu/nvidia)
nvml/is a minimal binding tolibnvidia-ml.so.1. It uses purego todlopenthe library at runtime, so gputop is built withCGO_ENABLED=0for every target and runs (with diagnostics) where no driver exists. Symbols missing from older drivers returnERROR_FUNCTION_NOT_FOUNDinstead of failing to load.- Struct layouts and constants come from
nvml.h(as vendored by NVIDIA’s go-nvml) and are asserted bylayout_test.go.scripts/test-fake-nvml.shruns the Linux binary against a C implementation of the signatures on amd64 and arm64. nvml.APIis a Go interface; provider tests use in-memory fakes built onnvml.Unsupported.- The provider remembers per-device capabilities. A call that returns
NOT_SUPPORTEDorNO_PERMISSIONis not repeated on every refresh. - Expensive calls are avoided on the fast path. PCIe throughput uses the
cumulative
NVML_FI_DEV_PCIE_COUNT_{TX,RX}_BYTEScounters where available; the ~20 ms samplingnvmlDeviceGetPcieThroughputfallback runs at most every 5 seconds. - Xid, ECC and MIG configuration events are delivered via NVML event sets
(
nvmlEventSetWait_v2), not polling. Xid descriptions and severity come from NVIDIA’s published Xid catalog (xid_catalog.go, generated).
Apple silicon provider (internal/gpu/apple)
- Selected by
gpu.providers: [auto]on macOS (NVIDIA elsewhere), or explicitly withapple. - Bindings to CoreFoundation, IOKit and
libIOReport.dylibare loaded with purego (cf_darwin.go), keeping the macOS build cgo-free. Nothing needs root. - Sources: the
IOAcceleratorentry’sPerformanceStatistics(device utilization, GPU memory in use and allocated); an IOReport subscription to the “Energy Model” and “GPU Stats / GPU Performance States” channels (GPU energy per sample interval → power; P-state residency weighted by thepmgrDVFS table → average active frequency); the SMCTg??float keys averaged (GPU die temperature, with the HID “GPU MTR Temp Sensor” services as a fallback on older SoCs); and theAGXDeviceUserClientchildren’sAppUsage.accumulatedGPUTimesummed per PID (per-process GPU time share). - Hardware access sits behind a small
backendinterface; provider tests use a fake, andiokit_darwin_test.goreads the real GPU when one is present. - Energy is accumulated from provider start. ECC, PCIe, NVLink, MIG, power limits and throttle reasons are reported as unsupported; the TUI hides or replaces the views that depend on them.
Collector engine (internal/collector)
| Tier | Default | Collectors |
|---|---|---|
| fast | 1s | gpu (samples), host (CPU, memory) |
| normal | 3s | processes, health, links, network, disks, inference |
| slow | 30s | partitions (MIG), filesystems, kubernetes |
| inventory | 5m | inventory (discovery, static info, topology) |
| event | — | provider event sources |
Each collector:
- runs in its own goroutine loop with its own ticker;
- is bounded by
refresh.timeout; - has an in-flight flag, so a slow run is never started twice (reported as an overrun);
- records runs, errors, durations and health, shown in the Health tab and exported as Prometheus metrics;
- recovers from panics and reports them as errors.
Per-device work runs on a bounded worker pool (4 by default). In-flight state is
tracked per (collector, device): a hung call on one GPU does not delay the
others, and the pass returns at its deadline. A GPU with no fresh sample for
max(3×interval, 2×timeout) is marked unavailable (“not responding”).
A GPU reported as lost (ERROR_GPU_IS_LOST) becomes unavailable, generates an
event, and scores 0 health. Other GPUs keep updating. A device that disappears
from enumeration triggers an inventory refresh.
Publisher and snapshots
On every fast tick the publisher:
- copies collector state into a new
model.Snapshot(deep-copying slices); - runs
derive.Tracker.Apply: state, VRAM headroom, idle tracking, efficiency, NVLink rates, health score and fleet summary; - runs
events.Detector.Diffagainst the previous snapshot and adds provider events; computes active alerts; - feeds the history store;
- atomically stores the snapshot and notifies subscribers.
Published snapshots are immutable. Subscribers receive the latest snapshot on a buffered channel of size one; slow consumers skip intermediate snapshots but never block the engine.
History (internal/history)
- In memory: one timestamp ring plus one fixed-width
float32column block per series (GPU UUID orhost), sizedretention / resolution + 1. A series is dropped once all of its slots have rolled off. - Samples are averaged per resolution bucket; throttle masks are OR-ed.
- On disk: append-only segment files (
seg-<unix_ms>.gth) of CRC32C-framed records (frame, series metadata, event). Segments rotate everyclamp(retention/10, 1m, 1h)and are deleted by retention and disk cap. Frames are flushed on commit andfsynced at most every 30 seconds. - Replay stops at the first damaged record of a segment and keeps everything
before it. A
LOCKfile (flock) prevents two writers; the second process runs in memory-only mode. history.Readeris implemented by the store and by the remote client, so the History tab works the same for remote agents.
TUI (internal/tui)
- Bubble Tea model with a registry of tabs. Each tab defines
visible,view, key handling and footer hints. - Every view returns lines of an exact width (
widgets.Fit, ANSI-aware truncation), so resizing never produces horizontal overflow. Render tests assert dimensions for every tab at 80×24 through 220×60. - Tables drop low-priority columns and shrink flexible columns on narrow terminals.
- A small in-memory “live” buffer (600 samples) backs short-term charts, so charts work with history disabled and for remote sources.
- All keys go through
internal/keymapactions; all colors throughinternal/themeroles.
Service and remote
internal/serverserves JSON and Prometheus from the same snapshots. See remote.md for the security model.internal/remote.Clientpolls an agent and implements the sameSourceinterface as the local engine; the TUI cannot tell the difference.
Concurrency and cancellation
- A single root context (SIGINT/SIGTERM) cancels collectors, event watchers, servers and remote polling.
- Shared collector state is protected by one mutex held only for copies; no provider calls are made under it.
- The whole test suite runs with
-racein CI.
Spotted something wrong? Edit this page on GitHub ↗