Skip to main content

gputaskman

An htop for what's inside your GPU, not just how full it is.

Windows + NVIDIA/AMD, with Linux and WSL support (NVIDIA everywhere, AMD on native Linux; see Linux and WSL) and macOS on Apple Silicon (see macOS). A terminal UI that connects per-process GPU memory and engine activity with loaded runtimes, model files, and LLM buffer breakdowns.

Task Manager says "15 GB used." This tries to answer: by what? Model weights? KV cache? Your browser? The desktop compositor? And why is so much GPU memory in system RAM?

Scope: local GPU workloads, not cloud services. Decision models, vision, speech, LLM inference, training/fine-tuning (including Unsloth), graphics, and video all belong here. Visibility varies: process memory and engine activity are measured; identifying individual models and their work requires supporting evidence. A PyTorch/CUDA DLL alone cannot identify a model or prove training, and a mapped file does not prove that model is resident on the GPU or being exercised. CPU-only, NPU-only, and cloud workloads are out of scope.

Live GPU overview with an LLM's weights, KV cache, and compute buffer breakdown

Early, experimental software. Device and process counters are measurements; classifications and GGUF-derived breakdowns are estimates. This does not dump VRAM or enumerate a game's textures.

Portable Windows download

The Releases page is where tagged builds publish two portable ZIPs: gputaskman-VERSION-windows-x64.zip for Intel/AMD Windows PCs, and gputaskman-VERSION-windows-arm64.zip for native Windows ARM64. These are separate native builds, not a universal executable. If no release is published yet, use the source instructions below.

Extract the entire ZIP, keeping _internal beside gputaskman.exe. Open Windows Terminal or PowerShell in the extracted folder:

.\gputaskman.exe
.\gputaskman.exe --once
.\gputaskman.exe --json

No Python, uv, CUDA Toolkit, model download, or installation is needed. A supported GPU with a current Windows WDDM driver is required. Builds are currently unsigned; code signing and an installer are not part of this initial distribution. Each ZIP includes licenses and a .zip.sha256 companion file is published for integrity checks.

Clone and run

Use 64-bit Windows 10/11, an NVIDIA or AMD GPU with a current WDDM driver, and a terminal (Windows Terminal is recommended). Live workload checks have included an RTX 4080 SUPER and an RTX Spark N1X on Windows ARM64. CUDA workloads are supported on ARM64 with a compatible NVIDIA driver and ARM64 CUDA runtime.

AMD live check: Radeon 860M Graphics on Windows 11 Enterprise build 26300, AMD driver 32.0.22050.1003. During desktop/browser activity, WDDM reported per-process VRAM and AMD ADL reported GPU utilization, clocks, and ASIC power; temperature, memory activity, and PCIe readings were unavailable. Vulkan/ROCm workloads were not tested on this machine.

Install Git for Windows and uv if needed. With WinGet:

winget install --id Git.Git --exact
winget install --id astral-sh.uv --exact

Open a new PowerShell window so those tools are on PATH, then:

git clone https://github.com/shanselman/gputaskman.git
cd gputaskman
uv run --locked gputaskman

That's it. uv obtains the pinned Python version if needed, creates .venv, and installs the locked dependencies. The first run needs internet access. There is no CUDA Toolkit, Visual Studio, administrator session, API key, or model download required to monitor the GPU. NVML ships with the NVIDIA driver; AMD ADL is supplied by supported AMD Windows drivers.

Already have Python 3.11+ and prefer pip?

git clone https://github.com/shanselman/gputaskman.git
cd gputaskman
py -m venv .venv
.\.venv\Scripts\python.exe -m pip install .
.\.venv\Scripts\gputaskman.exe

No PowerShell activation script or execution-policy change is needed. The pip route resolves compatible dependencies; the uv route uses the checked-in lockfile.

If an alternate package index makes --locked report a stale lockfile on an unchanged checkout, uv run --frozen gputaskman uses the checked-in lockfile without re-resolving it. --frozen skips the consistency check, so do not use it after changing dependencies.

Linux and WSL

Tagged releases include portable Linux builds: gputaskman-VERSION-linux-x64.tar.gz and gputaskman-VERSION-linux-arm64.tar.gz. They need glibc 2.35 or newer (Ubuntu 22.04, Debian 12, or newer) and an NVIDIA driver (or, on native Linux, an AMD GPU on the amdgpu driver), and work natively, in containers, and in WSL:

tar -xzf gputaskman-VERSION-linux-x64.tar.gz
cd gputaskman-VERSION-linux-x64
./gputaskman

Or clone and run from source, in a Linux or WSL shell:

git clone https://github.com/shanselman/gputaskman.git
cd gputaskman
uv run --locked gputaskman

What can be measured depends on where it runs, and the UI says which mode it is in.

Native Linux WSL, interop on (default) WSL, interop off or --no-host
Device totals, load, temperature, power, clocks NVML NVML NVML
Per-process VRAM NVML, measured (in a container: owner matched, * or ?) Matched from the Windows host, marked * or ? n/a
Per-process GPU load NVML (SM, encoder, decoder) n/a for Linux processes n/a
Spill / Paged Not available (blank) Windows rows only Not available
WSL VM total, Windows apps — Windows host counters —
Runtimes, model files, LLM breakdown /proc/<pid>/maps, command line Same Same

Native Linux reads per-process memory from NVML's running compute and graphics process lists and per-process SM, encoder, and decoder load from NVML utilization samples. Linux has no equivalent of WDDM's shared-memory or commit counters, so Spill and Paged stay blank rather than showing zero. Only processes you can inspect are listed; other users' processes need sudo.

JSON uses null for unavailable counters, not a measured zero: spill/commit/paged values on native Linux, and per-process engine load for WSL Linux rows. Native NVML engine readings remain available even if NVML hides that process's VRAM. Windows host rows retain their measured counters.

Containers (Docker, Kubernetes, rented GPU boxes) need one more step: NVML reports host PIDs, which mean nothing in the container's /proc, and the container cannot see the host PID of its own processes. gputaskman pairs NVML's unresolved PIDs with the container's processes that have libcuda.so mapped (NVML-only tools such as nvidia-smi open the same device files but never load libcuda). Both PID namespaces number processes in creation order, so the pairing is by order: * when the counts match or a process starting while gputaskman runs pins it, ? when NVML lists more compute processes than the container has (another container on the same GPU). Per-process GPU load follows the match. Anything left over is shown as H:<pid>, outside this container (for example, the host's display server), and is never looked up locally.

Live check, on a Vast.ai Docker container (Ubuntu 22.04, RTX 3060 Ti, driver 580.82.09): two PyTorch jobs already running at startup, a third started while watching, and one killed, were all matched to the right container PIDs with their NVML sizes (1.12, 2.52 and 1.59 GB) and live GPU%. An Ollama qwen3:0.6b runner was detected and named from Ollama's store with api+mapped+cmdline evidence, its weights / KV-cache / compute split shown, and the TUI rendered. Bare-metal Linux (same PID namespace, no matching needed) is covered by tests but has not been run on hardware.

AMD GPUs on native Linux (the amdgpu driver) need no ROCm, AMD SMI, or extra package: the kernel already exposes what's needed.

Source
Device VRAM, GTT, load, temperature, power, clocks, fan, PCIe link /sys/class/drm/card*/device and its hwmon
Per-process VRAM and GTT drm-resident-vram / drm-resident-gtt (older kernels: drm-memory-*) and drm-shared-* in /proc/<pid>/fdinfo/<fd>
Per-process engine load (gfx, compute, video) drm-engine-* busy time, sampled twice (n/a until a new client has two samples); n/a for ROCm/HIP processes (see below)

GTT is GPU-accessible system RAM, so it fills the SPILL column (and shared (in system RAM) for the device), like WDDM's non-local usage. On an APU (an integrated GPU, detected by amdgpu not reporting a VRAM vendor, a heuristic validated on one APU) GTT is the GPU's normal memory and "VRAM" is only a RAM carve-out, so GTT is still shown but never flagged as spill, and an LLM's memory is its VRAM plus GTT. The SDMA engine (dma) fills the COPY column. Paged stays blank: there is no commit counter. VRAM and SPILL are charged to a process only when nothing else can hold its bytes: each of its DRM clients has exactly one visible holder, was opened through a render node (/dev/dri/renderD*), and reports zero bytes in every drm-shared-* region. Then its resident VRAM and GTT are its own and add up without double counting. Otherwise the process shows VRAM and SPILL as n/a (null in JSON, with vram_unattributed_reason) and its bytes stay in unattributed:

  • Shared client (fd passing, fork; 1Password and its crash handler on the test desktop): one set of counters, and nothing says which process uses it. GPU% is n/a too.
  • Shared buffers (a window a compositor imports, video frames): drm-shared-* counts a buffer by where it prefers to live, resident or not, while drm-resident-* counts where buffers are, so resident minus shared is not private memory and is never computed.
  • Primary node (/dev/dri/card*, typically a compositor): root-owned logind keeps a copy of that fd, which a normal user can't see, so a single holder can't be proven.
  • Legacy: older kernels whose amdgpu fdinfo lacks drm-shared-vram/drm-shared-gtt.
  • Incomplete: some of the process's fds or fdinfo could not be read (GPU% is n/a too).
  • Partial scan: the TUI's 50 ms listing budget ran out this refresh, so a process it skipped might hold the same client (GPU% is n/a too). Expected to be rare on a desktop (~9 ms for ~8,000 fds, not yet re-measured with this rule); one-shot output always scans fully.

The inspector and JSON (vram_resident_bytes, gtt_resident_bytes) still show every buffer the process can reach, shared ones included; the same buffer can appear under several processes, so these are a reference and are never summed. Unparseable or missing values are unknown (null), not 0. amdgpu has no device-wide video engine counter, so the device's encode/decode load stays N/A; per-process video load is measured. On VCN 4 and later (RDNA3 and newer, including the RX 9070) decode and encode share one ring, so it is reported as video rather than split. Only processes you can inspect are listed; the header and JSON (process_status) say how many could not be, and sudo includes other users'. --gpu 1 selects the next card, such as an APU's integrated GPU next to a discrete one. A ROCm/HIP process (tagged HIP) has its VRAM in fdinfo like any other. Its GPU% is not: HIP submits compute through user-mode queues on /dev/kfd, which the kernel does not time per process, so a process holding /dev/kfd shows engines as n/a (null in JSON) rather than a measured-looking 0. The device's AMD gfx load does include that work. This path is native-Linux only; under WSL the GPU is /dev/dxg, with no DRM driver involved.

Live check, bare-metal Arch-based Linux (kernel 7.2.15, amdgpu), Radeon RX 9070-series (Navi 48) plus a Radeon 610M iGPU: both cards were found, named, and read; VAAPI HEVC encode and decode with ffmpeg each showed as a video row at ~95-100% video load (on the RX 9070 both were charged to amdgpu's unified enc ring, hence video); desktop processes' VRAM summed to less than the card's used VRAM, with the compositor's imported windows listed as shared. llama.cpp's ROCm server image (ghcr.io/ggml-org/llama.cpp:server-rocm, Qwen3-4B-Instruct-2507 Q4_K_M, -ngl 99 -c 8192, next to a running game) showed as an LLM row with llama.cpp+ggml+HIP and 3.95 GB of VRAM, matching the 2.49 GB of weights plus 1.21 GB of f16 KV cache plus compute buffers, and the 4.11 GB KFD reports for the process in /sys/class/kfd/kfd/proc/<pid>/vram_*. While it generated at 103 tok/s the device read 99% gfx and 339 W, but fdinfo had no engine lines for it, which is why ROCm rows show engines as n/a. PyTorch-ROCm has not been run on this path. That check predates the stricter attribution above (no subtraction of shared bytes; render-node, single-holder and complete-scan requirements; per-client timestamps; PID-reuse checks), which is covered by unit tests only and not yet validated on AMD hardware.

WSL is different, because Windows owns the GPU. Linux cannot see how much VRAM its own processes use: nvidia-smi lists no processes, NVML's per-process samples carry Windows PIDs (never looked up in Linux's /proc), and Windows charges all Linux GPU memory to the VM worker process, vmwp.exe. When WSL interop is enabled (/proc/sys/fs/binfmt_misc/WSLInterop reads enabled), gputaskman starts one long-lived powershell.exe that reads the Windows GPU counters through the same PDH array API as the Windows backend and streams them back once a second:

  • WSL VM (vmwp.exe) is the exact total of Linux GPU memory, across every distro, WSLg, and Docker Desktop.
  • Windows apps appear as W:<pid> rows with their VRAM, spill, paged, and engine load. Windows and Linux PIDs are separate namespaces, so the prefix keeps them apart.
  • Linux rows get a size by matching vmwp.exe's GPU contexts to Linux processes (below).

The first host sample takes about 2.5 s; the TUI shows starting until then, and --once waits for it. The host adapter is paired with the NVML device by memory usage, because WSL's adapter LUIDs differ from the host's. An ambiguous pairing shows unmatched instead of guessing. With other Hyper-V VMs or Windows Sandbox running, the row is labeled VM worker because it cannot be tied to WSL alone. --no-host turns the host stream off; without interop you get device-wide numbers and a list of the Linux processes that hold /dev/dxg, with VRAM shown as n/a.

How WSL per-process VRAM is matched

vmwp.exe exposes one GPU-memory instance per guest GPU context, all with the same name: sizes without owners. Measured on WSL2 with driver 610.88, every Linux process that opens /dev/dxg gets exactly one context (an NVML-only process too, at 0 bytes), and Windows lists them newest first, keeping that order as contexts come and go. Contexts from outside this distro (WSLg, other distros, Docker, services you cannot inspect) are interleaved by their own age.

gputaskman follows the contexts from sample to sample and labels them with evidence:

Marker Meaning Established by
* matched Windows' exact size; the owner is inferred, and only one explanation fits (or every one that fits gives the same size) A new Linux GPU process and a new context appearing together; elimination; this monitor's own context being the only zero-byte candidate at startup; candidate contexts that are exactly the same size
? guess Heuristic owner for processes that were already running Newer process ↔ newer context, outside contexts assumed oldest; cached foreign sizes can rank extra candidates but never establish ownership
n/a No context could be assigned For example, a process that has not created its GPU context yet

A guess is recomputed every refresh and never counts as evidence for itself. An exited process's context is held aside until it disappears, so it is not handed to a newcomer, and a context that grows by gigabytes between refreshes (weights uploading) keeps an established owner. Multiple zero-byte contexts at startup do not pin the monitor to an arbitrary one that might later grow. Foreign sizes observed while only the monitor is running are cached per WSL boot in ~/.cache/gputaskman/, but remain hints: a process may exit while the monitor is off and a local process may reuse its size. Observed foreign-context exits and idle samples clear obsolete hints.

The matched size also feeds VRAM sorting, initial model selection, the bounded model-file scan priority, and the LLM weights / KV-cache breakdown. Inferred Linux rows remain below the VM total and are never added to it again. The inspector and --json (vram_inferred_by) say which kind of match each row has.

In one live check, a 2 GB and a 5 GB PyTorch allocation were matched to the right PIDs at 2.119 GiB and 4.914 GiB: the allocator's own figure plus about 0.26 GiB of CUDA context. That is one machine and driver, not a guarantee. A process that started long before it first used the GPU can break the start-order guess, and processes started in the same refresh are ordered by guess unless their sizes match.

NixOS

With flakes enabled, run directly from this checkout:

nix run path:.
nix run path:. -- --list-gpus
nix run path:. -- --once
nix run path:. -- --json

Build both gputaskman and its gtm alias with nix build path:. (executables are in result/bin/). The flake provides packages for x86_64-linux and aarch64-linux; flake.lock pins nixpkgs. Dependencies come from nixpkgs, independently of uv.lock. No pip installation, virtual environment, CUDA Toolkit, or ROCm installation is needed. GPU drivers remain supplied by the host, never bundled with the package. NVIDIA's nixpkgs Python binding finds NVML through /run/opengl-driver/lib; AMD uses kernel sysfs and DRM fdinfo interfaces directly.

For source development and tests:

nix develop path:.
python -m gputaskman --once
python -m unittest discover -s tests -q

nix flake check path:. builds the package and runs the same hardware-independent tests. For a flake-based NixOS configuration, add this repository as a gputaskman input, then include inputs.gputaskman.packages.${pkgs.stdenv.hostPlatform.system}.default in environment.systemPackages. Enable your GPU's normal NixOS driver configuration first. The flake was contributed and checked on x86_64 NixOS 26.05; CI does not build it, and ARM64 has not been validated.

macOS (Apple Silicon)

Run from a clone (uv run gputaskman); there is no portable macOS download yet. Apple GPUs share one pool of memory with the CPU, so there is no VRAM to split and no spill/commit counters. What macOS exposes without root:

Column Source Who
GPU memory (header) IOAccelerator PerformanceStatistics: Alloc system memory (all GPU allocations) out of physical RAM device
GPU% (header) Device Utilization % device
GPU% Each Metal client (AGXDeviceUserClient) names its creator PID and keeps a cumulative accumulatedGPUTime; the rate between samples, per client (a closed client never hides the others' work) every process
VRAM footprint: the process's memory macOS charges to graphics (the (graphics) categories: Metal buffers, textures) your own processes
Runtime, model files Mapped files from proc_pidinfo region walks (psutil has no memory maps on macOS): libggml*.dylib, libllama*.dylib, libmlx*.dylib, libtorch_cpu.dylib, the Metal driver bundle your own processes

Other users' processes (WindowServer, system daemons) show GPU% with memory n/a: footprint needs root for them. Idle ones are left out of the table, except WindowServer. Run with sudo to measure every process's memory. footprint takes ~70 ms per process, so it runs in a background thread: busy processes and new ones every pass (~2 s), idle ones every 30 s. A new process shows n/a for a second or two until its first measurement; a single failed measurement keeps the last size, and three in a row back off for a minute. Measurements are tied to the process's start time, so a reused PID never shows the previous process's numbers.

Metal reports one GPU time per process, with no 3D/compute/copy/video split, so those columns show n/a; a Metal process above 10% GPU is classified 3D/game unless something more specific applies (AI runtime, browser, compositor). Spill, paged, clocks, power, and temperature are not available, so the chart picker offers only GPU and GPU memory. Handy's log is read from ~/Library/Logs/com.pais.handy/handy.log.

Live check on an M4 Mac mini (macOS 26.3.1): a PyTorch MPS job with a 2 GiB buffer and a matmul loop showed Python AI PyTorch+Metal 3.06 GB 99%, with device utilization at 99% and allocated GPU memory rising by the same amount.

Scans skip other apps' sandbox containers (~/Library/Containers, ~/Library/Group Containers) and system files without opening them: opening a file in a protected container blocks while macOS waits for a privacy consent prompt.

What you get

  • Device view: VRAM usage, GPU activity, memory activity, PCIe traffic, temperature, power, video-engine clock, and responsive history charts with an all-charts or single-metric view.
  • Process view: local GPU memory, GPU-accessible system RAM, estimated non-resident commit, engine activity, and detected runtimes such as CUDA, HIP, Vulkan, Direct3D, ggml and ONNX Runtime.
  • Model inspector: which model each process holds (LLM, decision, embedding, speech, or diffusion), how it was identified, architecture, quantization, layer count, weights, estimated KV cost, and buffer sizes from compatible load logs.
  • Live server hints: context size, busy slots, throughput when available, and friendly Ollama or LM Studio model names.
  • Scriptable output: one-shot text and JSON, using the same collector as the TUI.

Screenshots

These are captures of real local workloads through Textual's screenshot renderer, not mock data. Full filesystem paths and command lines were omitted in the capture copy. Measurements vary with the workload; the examples are not benchmarks.

The compositor, without treating all shared surfaces as its private VRAM

Compositor selected, showing local memory and the explanation of shared-surface accounting

GPU-accessible system RAM, sorted by the Spill column

Processes sorted by system-memory usage, alongside the selected process's memory explanation

An explanation of what is measured, inferred, and unavailable

Built-in help explaining GPU counters and the limits of memory introspection

Usage

uv run --locked gputaskman              # live TUI
uv run --locked gtm                     # shorter alias
uv run --locked gputaskman -i 2         # sample every two seconds
uv run --locked gputaskman --once       # one text snapshot
uv run --locked gputaskman --json       # one JSON snapshot; implies --once
uv run --locked gputaskman diff before.json after.json   # compare two --json snapshots
uv run --locked gputaskman --no-host    # WSL: don't read Windows host counters through interop
uv run --locked gputaskman --help
Key Action
Up / Down Select a process and inspect it
s Cycle sort: VRAM, GPU%, spill, paged, name
c Cycle all charts or a single metric (also available in the Charts dropdown)
a Switch between top and all collected processes
p Pause / resume collection
? Explain the counters and their limits
Escape Close help
q Quit

A wide terminal (roughly 140 columns or more) makes the full table easier to read. All eight history charts are shown by default: GPU, SM clock (GFX clock on AMD), VRAM, memory activity, temperature, power, encoder, and decoder. They stack in narrow terminals and share rows as the window widens. Click the Charts dropdown to focus on one metric or return to All charts. A blank sample means no reading; zero is drawn at the baseline. Percentages use a 0–100% scale, temperature uses 0–120°C, and the power and clock scales are displayed below the charts. The dashboard and inspector scroll independently in short terminals so the process list stays usable. Model/runtime discovery is throttled, so newly loaded models may take about 15 seconds to appear.

--list-gpus lists selectable hardware adapters with their vendor and LUID. --gpu N selects one of these adapters: NVIDIA adapters are listed first, followed by other vendors, with each group ordered by dedicated memory. An out-of-range index is rejected rather than silently selecting another device. Compute-only devices such as an NPU are not graphics adapters. AMD and NVIDIA hardware coverage is described below; configurations with multiple GPUs and mixed vendors need further validation. Device telemetry is matched to WDDM adapters by PCI location, not display name or enumeration order. If a unique match cannot be established, telemetry is unavailable; buffer logs can still span devices, so model-buffer attribution across GPUs is not yet reliable. This conservative matching also applies to NVIDIA and may suppress telemetry when Windows does not expose a usable PCI address.

AMD adapters can be selected and use the existing Windows GPU process counters; HIP runtime DLLs are recognized as runtime evidence. On supported drivers, the AMD Display Library supplied by the Windows driver can provide graphics activity, clocks, edge temperature, board or ASIC power, fan and PCIe lane count. An optional AMD SMI Python installation is a fallback and may provide graphics activity, edge temperature and socket power. Both require a PCI match to the selected WDDM adapter. The SMI library and its native dependencies must be available to the Python environment; it is not bundled in the portable build. The RX 9060 XT HIP workload and ADL telemetry have been observed on this machine; AMD SMI telemetry and Vulkan workloads remain unvalidated. A loaded DLL does not prove GPU execution.

Unavailable utilization, temperature, power, clock and PCIe readings are displayed as N/A in text and the TUI and as null in JSON. For example, a missing NVML power limit means that the driver did not expose that reading; it does not by itself indicate a hardware fault. Missing per-process memory or engine samples likewise show N/A/null; the paged estimate is unavailable when any of its input counters is missing.

On GPU-paravirtualized VMs, including Windows Sandbox, Windows may expose GPU counter LUIDs that do not match the selected DXGI adapter. When that happens, the counters cannot safely be assigned to the adapter: the process list and usage are unavailable, not zero. The text and TUI explain the mismatch; JSON reports it in counter_status and sets counter_adapter_matched to false (null when the check does not apply). GPU-PV guests may also omit adapter memory counters. Counter values that are actually reported as zero remain zero; a zero alone does not establish that a counter is unavailable.

Live check: Windows Sandbox on Windows 11 build 26100, hosted by an RTX 4080 SUPER with NVIDIA driver 630.20. D3DKMTEnumAdapters2/3 exposed the PDH LUID 0x4BF1 as a software adapter with a different adapter/render GUID from DXGI's 0x4BB7; it was not a proven alias for the NVIDIA GPU. The portable build's --once explanation and --json unmatched status were verified in the guest. Other Hyper-V GPU-PV configurations have not been validated.

Comparing snapshots

To see which memory bucket actually grew when you load a model or change the context length, save a JSON snapshot before and after and compare them:

uv run --locked gputaskman --json > before.json
# load the model, or restart the server with a different -c, then send it one request
uv run --locked gputaskman --json > after.json
uv run --locked gputaskman diff before.json after.json          # add --json for machine-readable output

The device and process tables compare measured counters only. Runtime-reported segments (llama.cpp buffer logs) and estimates (KV cache from GGUF metadata and -c) are listed separately under each process and are never added to measured totals. A model file that appears in a process is listed as evidence that the process opened or named it, not as GPU growth.

Processes are paired by PID, then by name and listening ports, then by a unique name, because a model reload often restarts the server under a new PID. A process missing from one snapshot shows not listed, not zero: it was not running or used too little GPU to be listed. Processes that moved by less than 16 MB are hidden from the text output but included in --json.

Take the "after" snapshot once the model has answered one request: CUDA and Vulkan can allocate lazily, and allocators keep freed memory cached, so shrinkage may appear late. On an RTX 4080 SUPER with Docker Model Runner's llama-server (Vulkan) and gemma-3-270m, changing -c 4096 to -c 32768 estimated +504 MB of KV cache, but the server's measured dedicated memory grew by only +83 MB. Gemma 3 uses sliding-window attention, so the estimate is an upper bound. The comparison makes that gap visible instead of hiding it.

How can it know what's inside?

It combines evidence rather than reading arbitrary GPU memory. Windows' graphics kernel supplies per-process memory and engine counters, while NVML or AMD ADL/SMI supplies device metrics. Process DLLs suggest the runtime, and mapped files or command-line arguments identify candidate models. GGUF metadata and tensor offsets let it estimate weights and KV-cache costs; compatible runtime logs and local server APIs add allocated buffer sizes and live state. That can explain a llama.cpp process well, but a runtime DLL or filename alone does not prove which allocation contains which object.

Question Evidence Confidence / limitation
How full and busy is the card? WDDM adapter counters and NVML or AMD ADL/SMI Measured; driver support varies by metric
Which process uses GPU memory? WDDM process counters; NVML on Linux Measured accounting, not a resource ownership graph
Which WSL process uses GPU memory? Windows host counters for vmwp.exe, matched to Linux processes Exact VM total; per-process owner inferred (*) or guessed (?)
Which engines are active? WDDM engine counters CUDA can appear on the 3D engine
Which runtime is loaded? Mapped DLL names Detection, not proof that every library is doing GPU work
Which model file is present? Mapped files, CLI flags, GGUF magic/header A file mapping does not prove GPU placement
Weights / KV / compute sizes? Load logs, GGUF math, server state Log-reported allocations or estimates, not a VRAM scan
Which texture, tensor, or kernel is this? Not available here Use PIX, Nsight, or runtime-specific instrumentation

Why not just nvidia-smi?

On GeForce under WDDM, Windows manages GPU memory and NVML may report per-process memory as N/A. Windows GPU counters can still attribute memory and activity across Direct3D, Vulkan, OpenGL, and CUDA. NVML remains useful for device-level readings. Under WSL, nvidia-smi lists no processes at all; see Linux and WSL.

DWM, Spill, and Paged: read these carefully

DWM is not everything. Windows' per-process Dedicated Usage includes resources shared between processes. The same surface may appear against its application, csrss.exe, and dwm.exe. Adding those rows double-counts memory. This tool prefers Local Usage and Non Local Usage; it falls back to Dedicated Usage / Shared Usage when the preferred counters cannot be added. The adapter total remains the reference. See Microsoft's GPU accounting explanation.

In one live sample, DWM's older counter read about 15.3 GiB while Local Usage was about 1.4 GiB. The latter reduced the inflated process sum substantially. This is an observed improvement, not a guarantee that every driver's process counters add up exactly. When the row sum exceeds the adapter total, the category bar is normalized to fit; legend values remain raw, and the UI labels the normalization.

Spill means GPU-accessible system RAM, not proven overflow. It can include intentionally allocated or pinned host buffers. Large usage alongside a nearly full GPU can indicate fallback and slower inference, but the counter alone cannot establish why it was allocated.

Paged is an estimate: max(Total Committed - Local Usage - Non Local Usage, 0). It is not an eviction trace, a pagefile measurement, or proof that Windows just moved those bytes out of VRAM. The value is also less reliable when fallback counters are in use.

Memory labels currently use GB / MB for binary units (GiB / MiB). The Mem activity reading is NVML's memory-activity percentage, not measured bandwidth in GB/s.

LLM and model support

Runtime Current support
llama.cpp / llama-server GGUF discovery, CLI settings, buffer logs, /props + /v1/models state; exercised live
Ollama Bundled runners, mapped GGUF blobs named from Ollama manifests (qwen3:14b), parent /api/ps, compatible server.log; exercised live
LM Studio Mapped files named from its models folder, /api/v1/models (falls back to /api/v0/models); tested with fixture responses, not yet end to end
stable-diffusion.cpp (--diffusion-model) Diffusion, text-encoder and VAE files from launch arguments; no LLM memory breakdown
Hugging Face cache models--org--repo paths are named org/repo
Other AI runtimes DLL detection, plus model files installed with the app (marked not proven loaded)
Handy Reads selected model from settings and bounded load/start/preview/unload events from handy.log; selection is not load evidence and log events are historical, not proof of current residency
WinML / local helper processes GPU-backed execution is in scope; generic process counters and runtime detection only. No WinML-specific model/session or client-to-helper attribution yet
Windows Studio Effects Only any observable GPU portion is in scope. NPU inference is out of scope; do not interpret GPU camera/render activity as proof of model execution

WinML can select different execution backends; a local helper/service is still local, not cloud-hosted. The GPU counters belong to whichever process holds/uses the GPU resources. Parent-PID lookup currently helps find Ollama/LM Studio APIs; it is not a general cross-process ownership graph for shared Windows services.

GPU-backed WinML and Studio Effects paths have not been validated on a Copilot+ PC. A future hardware check should compare idle versus active workload samples, record the actual GPU adapter and resource-owning PID (including helpers), and verify the execution provider using runtime diagnostics. Match any reported model/session to that PID before claiming attribution. If execution is CPU/NPU-only, record it as out of scope rather than a missing GPU model. No NPU collector is planned.

How models are identified

Every model row shows its evidence, strongest first:

Evidence Meaning
api The runtime's local API reports the model as loaded
mapped Windows shows the file memory-mapped into the process right now
cmdline The file was a launch argument (-m model.gguf)
installed Found in the app's install or data folder; nothing proves it is loaded
Handy settings / log Settings show selection; timestamped log events show what Handy reported, not current residency

Two sources including api or mapped show as confirmed. Runtime logs can report model events, but are displayed as timestamped history and do not prove current residency. Model-store folders only supply a friendly name. The authoritative model kind comes from GGUF header keys: decision.type marks a decision model (for example Laya), pooling_type an embedding model or reranker, stt.* a speech model, and a causal model with layers and a trained context an LLM. llama-server's /v1/models output_modalities (text, decisions) confirms it. Non-causal encoders such as Laya allocate no KV cache, so none is estimated.

When a file lacks specific kind metadata, known local decision-model names (openjev, laya, julia-1, lev, kev) supply a likely decision model (name) hint. Matching uses delimited names in the filename, immediate model folder, or resolved store model name, not arbitrary path substrings, publishers, or Ollama tags. The process category is decision?; specific GGUF kinds take precedence. Hints do not change loading evidence, causal/KV calculations, or installed-candidate status. JSON preserves the header-derived kind separately from kind_hint and kind_label.

For example, ggml-org/OpenJev-GGUF includes qwen35.decision.type=openjev; the inspected community openjev/openjev-GGUF Q4_K_M header lacks that key and uses the generic internal name Snapshot_Cfg, so its filename or store name supplies only a hint. Neither a type hint nor an API/file match proves which model produced the process's GPU activity.

Some runtimes (ONNX Runtime, whisper.cpp) read a model into memory and close the file. Windows then has no record of which file is loaded, so the app's installed models are listed as candidates only. For Handy, gputaskman reads the configured selected_model and only model-load, streaming-start, preview, and unload events from its local log. Settings prove selection; a fresh log event proves Handy reported that event after the current process started. Neither proves continuous GPU residency. Log parsing is bounded to the last 512 KiB, cached until the file changes, and ignores transcription content. Handy's selected Hugging Face model can live outside its Roaming models folder. Handy logs in UTC; JSON keeps that UTC timestamp and the TUI/text output show local time.

Other speech and vision apps. Handy is the first app adapter: a read-only recipe for where an app keeps its settings and which log lines mark a model load or unload. Most whisper-style apps (whisper.cpp, faster-whisper, sherpa-onnx, ONNX Runtime) read a model and close the file, so the OS keeps no record of it. Adapters give exact names when an app logs them, but each app needs its own recipe, and a log-format change can break it. The runtime-agnostic answer is to see the file being opened during the load: Windows kernel file-I/O ETW events record which process opened which model file. Combined with that process's VRAM jump, this identifies the model without app-specific code. It needs elevation and must already be running when the load happens, so it is planned as an opt-in mode. Until then, unknown apps show their runtime DLLs, VRAM, engine activity, and installed candidates. Reading model names out of another process's memory is deliberately not done.

Identification is designed to stay cheap. Measured on one Windows machine:

Probe Cost Used
Process list, command line, listening ports under 1 ms Once per process / every 5 s
Memory-map scan (DLLs + mapped files) 1-50 ms per process New processes, a 64 MiB / 10% memory change, or every 2 min; at most 150 ms per refresh
GGUF header, model-store manifests ms, cached Once per file / every 60 s
Local API probes ms, local only llama-server /v1/models only when its model path changes
psutil.open_files() 0.6-2.5 s per process Never
AMD on Linux: DRM fdinfo scan (readlink every fd, read fdinfo of /dev/dri ones) ~9 ms for ~8,000 fds (Linux desktop) Every refresh, only when an amdgpu card is shown; listing fds is bounded to 50 ms in the TUI and resumes next refresh, known GPU fds are still read
macOS: ioreg device stats + Metal clients ~10 ms Every refresh
macOS: memory-map scan (proc_pidinfo region walk) ~4 ms per process Same policy as the memory-map scan above
macOS: footprint GPU memory ~70 ms per process, background thread Busy and new processes every ~2 s, idle ones every 30 s (54 runs in 40 s with 27 GPU processes, M4 Mac mini)

With two llama-servers running, a steady refresh costs roughly 15-30 ms of CPU (about 340 ms before process details were cached).

For log-reported buffer sizes, start your own llama-server with logging enabled:

llama-server.exe -m .\model.gguf -ngl 99 -c 8192 --metrics --log-file server.log -lv 4

Use a real model you already have. This is an example workload, not a prerequisite for running gputaskman. Verbosity 4 was needed to capture the buffer-size lines in the tested llama.cpp build. Prefer an absolute log path if the server and monitor use different working directories.

The collector probes existing local listening ports with GET requests to /props, /slots, /metrics, /v1/models, /api/ps, /api/v1/models, and /api/v0/models. Endpoints may be absent, disabled, or authenticated; other evidence is still useful. It does not launch ollama, lms, or an inference request. Throughput may update only after a request completes.

Ollama's local server.log is considered when one Ollama runner is detected. Log formats, rotation, and stale load blocks can affect attribution, so compare the reported model and process when interpreting logs. Log sizes describe allocations at load time, not necessarily which pages remain resident now. An inferred remainder is runtime/unknown overhead, not an exact measurement of the CUDA context.

Without logs, estimates assume llama.cpp-style placement. Architecture, partial offload, KV quantization, sliding-window attention, and multiple models in one process can all change the result. Separate runner processes get separate rows; per-model KV accounting within one process is not yet supported.

Spot checks against llama.cpp

The following comparisons used llama.cpp b12061, CUDA 13.4, and an RTX 4080 SUPER. They validate these examples, not every model architecture or backend.

Allocation GGUF estimate Runtime log
Qwen3-0.6B Q8_0 GPU weights 604.15 MiB 604.15 MiB
Qwen3-0.6B KV, 8,192 context, f16 896 MiB 896 MiB
Gemma-3-270M GPU weights 271.81 MiB 271.86 MiB
Gemma-3-270M KV, 4,096 context 72 MiB upper bound 49.5 MiB with sliding-window attention

Privacy and troubleshooting

The monitor reads local process metadata, compatible model headers, load logs, and local server responses. It does not dump model weights or send telemetry. The normal inspector and JSON output can contain paths, command-line flags, model names, and local ports. Review and redact captures before posting them publicly. The screenshot helper omits command lines and full file paths, but model and process names still require review.

Symptom What to check
uv or git is not recognized Install the prerequisites, then open a new terminal
Empty or incomplete GPU readings Check the GPU driver and WDDM 2.0+ support in dxdiag; use a normal local desktop session
"supports Windows and Linux/WSL only" macOS is not supported
WSL: Windows host counters: off (WSL interop is disabled) Enable [interop] in /etc/wsl.conf, then wsl --shutdown; or accept device-wide numbers
WSL: starting for a few seconds The first Windows counter sample takes about 2.5 s
WSL: unmatched No Windows adapter's usage matched the NVML device; multi-GPU WSL is not validated
WSL: Linux rows show ? Processes were running before gputaskman started; they become * with evidence (see the matching table)
Linux: a GPU process is missing Processes owned by other users need sudo; Docker and other WSL distros are not visible from this one
Linux container: H:<pid> rows GPU processes outside this container's PID namespace (host or other containers); NVML sees them, the container cannot name them
Missing DLLs or model details Protected processes may deny inspection; basic counters can still work without elevation
No exact LLM breakdown Enable compatible buffer logging, or use the GGUF estimate; API/log access is optional
The screen looks cramped Widen the terminal or use --once / --json

Development

uv sync --locked
uv run --locked python -m unittest discover -s tests -v
uv build

CI runs hardware-independent checks on Windows, Ubuntu and macOS, Python 3.11 and 3.13. Live GPU behavior still needs real hardware; the CI does not pretend to validate that. The WSL matching tests replay context sequences measured on a real WSL2 machine.

Contributors and coding agents: AGENTS.md lists the evidence rules every change must keep, a module map, verification steps, and the release process.

Building portable releases

Use Python 3.13 of the target architecture. PyInstaller does not cross-compile: an x64 Python process makes an x64 executable even when running under ARM64 emulation.

uv sync --locked --group release --python 3.13
uv run --locked --group release python tools\build_portable.py --arch x64
# On a native ARM64 interpreter, use --arch arm64 instead.

The same command on Linux (tools/build_portable.py) produces a .tar.gz, which keeps the executable bit. The builder refuses to package NVIDIA driver libraries (libnvidia-ml.so, libcuda.so) on either platform; they come from the installed driver.

The builder verifies the interpreter and executable architectures, includes Textual resources and dependency licenses, and writes the complete portable ZIP and SHA-256 file to dist. It does not bundle Windows or GPU driver DLLs, inference servers, or models. Keep uv for development; the executable ZIP is for running the monitor without a Python environment.

The Portable release GitHub Actions workflow builds on windows-2022 (x64), windows-11-arm (ARM64), ubuntu-22.04 (Linux x64), and ubuntu-22.04-arm (Linux ARM64). Linux builds use the oldest supported Ubuntu because the frozen binary needs the build machine's glibc or newer. A manual run produces downloadable workflow artifacts without publishing a release. A pushed vVERSION tag must match project.version in pyproject.toml; once all builds pass, the workflow publishes all four archives and checksums together with generated release notes. The workflow also builds a Python wheel and source distribution. On tags, after all builds succeed, a separate job publishes them to PyPI using Trusted Publishing (GitHub OIDC), with no stored API token. Manual runs produce artifacts only and publish neither a GitHub release nor a PyPI package.

Before the first PyPI publication, configure a pending publisher for project gputaskman on PyPI: owner shanselman, repository gputaskman, workflow release.yml, environment pypi. The workflow uses that exact GitHub environment; any environment approval rules must be satisfied before publishing. The first successful tagged publication creates the PyPI project. Once published, install with uv tool install gputaskman or pipx install gputaskman (Python 3.11+).

Build jobs have read-only permissions; only the tag-triggered GitHub release job can write releases, and only the PyPI publishing job has id-token: write. CI checks the extracted executable's --help outside the checkout with Python removed from PATH; interactive rendering and real GPU snapshots require local hardware testing.

To capture native SVG screenshots of your own running workloads:

uv run --locked python tools\capture_screenshots.py --output .\captures

The checked-in gallery uses PNG renderings of these captures. No models, logs, or executable downloads are bundled in this repository.

Module Responsibility
pdh.py / dxgi.py Windows counters and adapter enumeration
linux.py Linux backend: NVML adapters and per-process usage, container PID matching, WSL detection, /dev/dxg holders
drm.py AMD on native Linux: amdgpu cards and device readings from sysfs, per-process VRAM/GTT/engine time from DRM fdinfo
wslhost.py WSL: interop check, Windows host counter stream, adapter pairing, context matching
macos.py macOS backend: IOKit device stats and per-process GPU time, footprint GPU memory poller, libproc memory maps
nvml.py NVIDIA device readings
adl.py, amdsmi.py Optional AMD device readings
procinfo.py Runtime, mapped-file, and process discovery
stores.py Friendly names from Ollama, LM Studio, and Hugging Face model stores
gguf.py Model metadata, kind, and size estimates
llm.py Buffer logs, local server probing, and breakdowns
collector.py Shared snapshot collection
app.py / report.py / cli.py TUI, text/JSON, and command-line entry points
diff.py Before/after comparison of two --json snapshots

Next

  • Model identity for non-GGUF runtimes (ONNX Runtime, PyTorch, TensorRT) beyond installed candidates.
  • Explicit provenance and availability for every counter and estimate.
  • Reliable multi-GPU matching and per-device model placement.
  • Bare-metal Linux validation on NVIDIA hardware; Intel GPUs (i915/xe) through the same DRM fdinfo path as AMD.
  • macOS: a portable build, Ollama/llama.cpp/MLX validation, and compressed GPU memory (footprint's swapped) as the paged column.
  • WSL: per-process GPU load for Linux rows, and Ollama's systemd journal as a log source.
  • Live throughput deltas, LM Studio validation, and per-model context accounting.
  • Opt-in ETW file-open tracing to name models loaded by read-and-close runtimes (any whisper or ONNX app), correlated with the process's VRAM jump at load time.
  • Data-driven app adapters (settings path plus load/unload log patterns) once a second app needs one.
  • Optional ETW / runtime instrumentation for allocation lifetime and residency detail.

License

MIT.

Metadata

Release files for gputaskman 0.3.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for gputaskman 0.3.2
File Size Uploaded Provenance Actions
gputaskman-0.3.2.tar.gz 121.6 kB With provenance Details for gputaskman-0.3.2.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for gputaskman 0.3.2
File Interpreter ABI Platform Actions
gputaskman-0.3.2-py3-none-any.whl Python 3 none any Details for gputaskman-0.3.2-py3-none-any.whl

Total release size: 233.4 kB

Release files / gputaskman-0.3.2.tar.gz

Download URL gputaskman-0.3.2.tar.gz
Size 121.6 kB
Tags Source
SHA-256 checksum
How to use checksums
d6b694353098c4a01514c39a695e4f2217f4b50a391825c5c8a8a2e26eb22e55
BLAKE2b-256 checksum
How to use checksums
8201a553258b8f504ae1a6c2907fc3420b54e77e7f7fc5d492cd0da8c4ea32b5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.

Transparency log

Release files / gputaskman-0.3.2-py3-none-any.whl

Download URL gputaskman-0.3.2-py3-none-any.whl
Size 111.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0c5ecc3e1015902626611837d2200a988587a62aab621d1b81feaad1bbfee7ab
BLAKE2b-256 checksum
How to use checksums
ed188030a72f6df8ba6cf5a7bf39eacbb475cb6add42472331693b1968fb76a9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.

Transparency log

Security for gputaskman 0.3.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for gputaskman-0.3.2.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.3.2 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page