Skip to content

Add macOS per-process GPU sorting - #2

Draft
Andrew Gazelka (andrewgazelka) wants to merge 4 commits into
ix-patchedfrom
macos-per-process-gpu
Draft

Add macOS per-process GPU sorting#2
Andrew Gazelka (andrewgazelka) wants to merge 4 commits into
ix-patchedfrom
macos-per-process-gpu

Conversation

@andrewgazelka

@andrewgazelka Andrew Gazelka (andrewgazelka) commented Jul 27, 2026

Copy link
Copy Markdown
Member

Adds a gpu sort option to the process view showing per-process GPU utilization on Apple Silicon, next to the existing io read / io write / io total columns.

Motivation: btop has device-level GPU support on macOS (Gpu::AppleSilicon, IOReport) but no per-process GPU accounting on any platform — btop_shared.hpp still carries // vector<proc_info> graphics_processes = {}; // TODO.

Live output

Headless Metal compute burner running, sorted by gpu:

│    Pid: Program:         Command:                        Threads: User:       MemB       Gpu% ↑│
│     509 WindowServer     WindowServer                           0 _windowse+    0B        39% █│
│   64031 gpuburn          .../tmp/gpuburn                        4 andrewgaz+   13M        36%  │
│    1811 ghostty          /Applications/Ghostty.app/...        100 andrewgaz+  196M        16%  │
│   98289 Google Chrome He /Applications/Google Chrome...        26 andrewgaz+  104M       7.1%  │

Where the data comes from

Every Metal client owns an AGXDeviceUserClient under the IOAccelerator carrying a monotonic per-process nanosecond counter:

+-o AGXDeviceUserClient  <class AGXDeviceUserClient, ..., !registered, !matched, ...>
    "AppUsage" = ({"API"="Metal","accumulatedGPUTime"=22962953333791, ...})
    "IOUserClientCreator" = "pid 509, WindowServer"

Delta over wall time gives the same shape as cpu_p. No entitlement, no root. This is the source Activity Monitor's GPU column uses (it links libIOReport and carries _gpuUsedSecondsDelta per row).

Alternatives measured and rejected

  • task_info(TASK_POWER_INFO_V2) — compiles, returns KERN_SUCCESS with the full expected count, and gpu_energy.task_gpu_utilisation reads 0 forever on Apple Silicon. Probed as root against a process holding the GPU at 100% HW active residency (GPU Power: 54195 mW): still 0. Fleet-wide as root, gpu_util nonzero=0 across 270 accessible pids. It also needs task_for_pid, which fails for most pids without root. This one is dangerous because it looks correct on an idle machine.
  • macOS 27 metalperftrace — a frame-pacing instrument, not a utilization one. Its schema is per-CoreAnimation-layer (FPS, frame time, skipped frames). A headless Metal compute job that drove global GPU utilization from 59% → 98% appeared zero times in a retroactive collect --last 5m trace, and metalperftrace listen emitted 0 bytes over 40s even with several apps rendering at 40–120 FPS, with and without setup --enable per-frame-metrics, and as root.

Two API traps

  1. The user clients are !registered, so IOServiceGetMatchingServices("AGXDeviceUserClient") returns an empty iterator with no error — which reads as "no GPU activity". They have to be walked under the IOAccelerator (which does match, via its AGXAcceleratorG17X subclass).
  2. IOUserClientCreator is a formatted string and the kernel truncates the process name to 16 chars ("pid 1019, TextInputMenuAge"). Join on pid, never on name.

Cost

One registry walk covers every pid — sampled once per collect cycle, not per process. Measured ~1.5 ms against the 2000 ms default cadence. Cheaper than the existing per-process proc_pidinfo / proc_pid_rusage syscalls, which run once per process.

Layout

Like the io columns, gpu reuses the Cpu% slot and suppresses the per-process cpu sparkline while active, since the proc row has no spare width. Inserted into sort_vector between io total and cpu direct; all four positional switch blocks renumbered accordingly.

Verification

  • macOS 27.0, M5 Max, AGXAcceleratorG17X, Apple clang 21.
  • cmake --build clean, no new warnings; btop_test 3/3 passing.
  • Rendered through a pty + terminal emulator to confirm the column and sort indicator (output above).
  • Per-process shares sum to the driver's own "Device Utilization %" from IOAccelerator.PerformanceStatistics at the same instant (97.18% vs 97).

Not verified

  • Only tested on this M5 Max / macOS 27.0. No Intel Mac, no eGPU, no multi-GPU, no older Apple Silicon generation. On hardware exposing no AppUsage, the column reads 0% rather than blank — arguably it should distinguish unsupported from idle.
  • Not verified under the ix nix lane's cross = true darwin build, only a native Apple clang build.
  • AppUsage / accumulatedGPUTime are undocumented and unversioned and can break on any macOS update. This is consistent with the IOReport and IOHIDEvent private APIs the existing macOS GPU backend already depends on, but it is a real risk.
  • The nanosecond unit is inferred, not documented — summed deltas over a 1s wall interval reproduce the driver's independently reported utilization.

🤖 Generated with Claude Code

Note

Add macOS per-process GPU utilization sorting to the process list

  • Adds a collect_gpu_times function in btop_collect.cpp that reads accumulated GPU time per PID from IORegistry (IOAccelerator/AGXDeviceUserClient) without elevated privileges, computing gpu_p as a percentage over each sampling interval.
  • Extends proc_info with gpu_p, gpu_t, and gpu_s fields and adds "gpu" as a selectable sort key in sort_vector, proc_sorter, and tree_sort.
  • Updates the process list draw logic to show "Gpu%" in the CPU column header and display per-process GPU usage when GPU sorting is active, suppressing inline CPU graphs (same layout as IO sorting).
  • Updates the help menu to list "gpu" as a valid sort option.

Macroscope summarized 25a5a37.

Read each process's real working directory from the kernel (the cwd set by
chdir(2)), not the stale $PWD environment variable:

  - Linux: readlink /proc/<pid>/cwd
  - macOS: proc_pidinfo(PROC_PIDVNODEPATHINFO)

Surface it on the bottom row of the detail box's command area, but only when
the command fits in <= 2 rows so a long command is never truncated for it.
Other platforms leave cwd empty (field defaults to "").
Adds "io read", "io write", "io total" sort options to the process view,
with per-column sort logic and macOS io_read_rate/io_write_rate collection.

Rebase note (base d03b839 -> 9527231): upstream moved the Proc::sort_vector
definition out of the btop_shared.hpp header into btop_shared.cpp, leaving an
`extern` declaration behind. The three io entries are inserted into the
relocated .cpp definition between "memory" and "cpu direct" so their positions
(indices 6/7/8) line up with the io cases in the sorting switch.
Tree equals the full patch series applied linearly; parents are the
patch DAG heads. Built by the jj megamerge migration (index).
Adds a "gpu" sort option to the process view showing per-process GPU
utilization on Apple Silicon, alongside the existing io read/write/total
columns.

macOS exposes no public per-process GPU API, but every Metal client owns an
AGXDeviceUserClient under the IOAccelerator whose AppUsage array carries a
monotonic accumulatedGPUTime in nanoseconds. Delta that against wall time and
you get the same shape as cpu_p. It needs no entitlement and no root, and it
is the same source Activity Monitor's GPU column uses.

Two alternatives were measured and rejected:

  - task_info(TASK_POWER_INFO_V2) compiles, returns KERN_SUCCESS with the full
    expected count, and its gpu_energy fields read 0 forever on Apple Silicon.
    Probed as root against a process holding the GPU at 100% HW active
    residency: task_gpu_utilisation = 0. It also needs task_for_pid, which
    fails for most pids without root.
  - macOS 27's metalperftrace only observes processes that present a
    CoreAnimation layer. A headless Metal compute job that drove global GPU
    utilization from 59% to 98% appeared zero times in a retroactive trace,
    and `metalperftrace listen` emitted nothing at all. It is a frame-pacing
    instrument, not a utilization one.

Verified on macOS 27.0 (M5 Max, AGXAcceleratorG17X). A headless compute burner
with no window is attributed correctly and sorts into place; per-process shares
sum to the driver's own device-level "Device Utilization %". One registry walk
covers every pid and costs ~1.5 ms against the 2000 ms default cadence, so it
is sampled once per cycle rather than per process.

Two API traps worth recording: the user clients are !registered, so
IOServiceGetMatchingServices("AGXDeviceUserClient") silently returns an empty
iterator that reads as "no GPU activity" -- they have to be walked under the
IOAccelerator. And IOUserClientCreator is a formatted string whose process name
the kernel truncates to 16 chars, so entries must be joined on pid, never name.

AppUsage/accumulatedGPUTime are undocumented and unversioned, consistent with
the IOReport and IOHIDEvent private APIs the existing macOS GPU backend already
relies on. A pid drops out of the map once it releases its last Metal client,
which is treated as zero rather than as a negative delta.

Like the io columns, "gpu" reuses the Cpu% slot and suppresses the per-process
cpu sparkline while active, since the proc row has no spare width.
@andrewgazelka

Copy link
Copy Markdown
Member Author

Differential verification against the unpatched ix-patched baseline (df4c423), same machine, same conditions, GPU load in flight — confirming the sort_vector renumbering did not break the shifted cpu direct / cpu lazy indices or tree mode:

CASE         PATCHED                      BASELINE                     VERDICT
cpu lazy     Cpu% mono=False rows=37      Cpu% mono=False rows=37      same
cpu direct   Cpu% mono=True rows=37       Cpu% mono=True rows=37       same
memory       Cpu% mono=False rows=37      Cpu% mono=False rows=37      same
io total     Mem% mono=True rows=15       Mem% mono=True rows=28       same
threads      Cpu% mono=False rows=37      Cpu% mono=False rows=37      same
tree/cpu     ? mono=False rows=37         ? mono=False rows=37         same

REGRESSIONS vs baseline: none

Both binaries driven through a pty into a terminal emulator. cpu lazy is non-monotonic in both builds by design — it sorts on cumulative cpu_c while the column displays cpu_p — so that is baseline behavior, not a regression.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant