From be55285796e391706273d5a2dc317ed9b0bbf3f6 Mon Sep 17 00:00:00 2001 From: jtannahill <161522526+jtannahill@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:39:05 -0400 Subject: [PATCH] Add Apple Silicon GPU custom metrics sample Adds docs/samples/gpu, a producer that reads GPU utilization and VRAM usage from the AGXAccelerator node of the IOKit registry and writes a Custom Metrics snapshot to ~/.runcat/gpu.json. Unlike the existing samples it runs as a long-lived process kept alive by launchd, since GPU load changes too quickly for StartInterval scheduling. It requires no sudo, no network access, and no third party tools. Signed-off-by: jtannahill <161522526+jtannahill@users.noreply.github.com> --- docs/samples/gpu/README.md | 74 +++++++++++ docs/samples/gpu/dev.runcat.gpu-sample.plist | 19 +++ docs/samples/gpu/runcat-gpu.py | 124 +++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 docs/samples/gpu/README.md create mode 100644 docs/samples/gpu/dev.runcat.gpu-sample.plist create mode 100755 docs/samples/gpu/runcat-gpu.py diff --git a/docs/samples/gpu/README.md b/docs/samples/gpu/README.md new file mode 100644 index 0000000..3c6aa66 --- /dev/null +++ b/docs/samples/gpu/README.md @@ -0,0 +1,74 @@ +# GPU Sample + +A Python script that shows Apple Silicon GPU utilization and VRAM usage on RunCat Neo's Custom Metrics card and in the Metrics Bar. It reads the `AGXAccelerator` node of the IOKit registry with `ioreg`, so it needs no `sudo`, no network access, and no third party tools. The snapshot is written to `~/.runcat/gpu.json`, and a `launchd` LaunchAgent keeps the script running so the card refreshes every 2 seconds. + +```text +ioreg (IOKit registry) -> runcat-gpu.py -> gpu.json -> RunCat Neo +``` + +The Bitcoin sample schedules a producer that exits after each run (`StartInterval`). GPU load changes far too quickly for that, so this sample demonstrates the other shape: a long running producer kept alive by `launchd` (`KeepAlive`) that rewrites the snapshot on its own timer. + +## Requirements + +- Apple Silicon (M series). Intel Macs expose different accelerator classes and are not supported by this sample. +- `python3`, which ships with the Xcode Command Line Tools. + +## Setup + +1. Copy the script and make it executable: + ```bash + mkdir -p ~/.runcat + cp runcat-gpu.py ~/.runcat/runcat-gpu.py + chmod +x ~/.runcat/runcat-gpu.py + ``` +2. Run it once by hand and check the output: + ```bash + ~/.runcat/runcat-gpu.py && cat ~/.runcat/gpu.json + ``` +3. Register the LaunchAgent so it keeps updating: + ```bash + cp dev.runcat.gpu-sample.plist ~/Library/LaunchAgents/dev.runcat.gpu-sample.plist + ``` + Open the copied plist and replace `/Users/YOU` with your home path, then: + ```bash + launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.runcat.gpu-sample.plist + ``` +4. In RunCat Neo, open **Settings → Metrics → Custom Metrics**, click **Add Custom Metrics Source**, and choose `~/.runcat/gpu.json`. The folder is hidden in the open panel, so press `⌘⇧.` or `⌘⇧G` and type the path. The card appears on the dashboard immediately. +5. Optional: click the Metrics Bar and flip the source's toggle to show GPU utilization (`metricsBarValue`, for example `24%`) directly in the menu bar. + +To stop updating, unload the agent: + +```bash +launchctl bootout gui/$(id -u)/dev.runcat.gpu-sample +``` + +## What the rows mean + +`ioreg -r -d 1 -w 0 -c AGXAccelerator` reports a `PerformanceStatistics` dictionary. The sample surfaces five of its entries: + +| Row | Source key | Meaning | +|-----|-----------|---------| +| Utilization | `Device Utilization %` | Overall share of time the GPU was busy | +| Renderer | `Renderer Utilization %` | Share spent in the render stage | +| Tiler | `Tiler Utilization %` | Share spent in the tiling stage | +| VRAM in use | `In use system memory` | Memory currently held by the GPU, normalized against `hw.memsize` | +| VRAM allocated | `Alloc system memory` | Memory reserved for the GPU | + +Apple Silicon uses unified memory, so the VRAM rows are drawn from the same pool the CPU uses. That is why "VRAM in use" is normalized against total system memory rather than a dedicated card's capacity. + +## Customizing the output + +The output JSON shape is documented in [`../../CustomMetricsSchema.md`](../../CustomMetricsSchema.md). `PerformanceStatistics` carries more entries than the five shown here, so adding a row is a one line edit in `build_snapshot`. Inspect what your machine reports with: + +```bash +ioreg -r -d 1 -w 0 -c AGXAccelerator | grep PerformanceStatistics +``` + +`RUNCAT_OUT_FILE` overrides where the snapshot is written (default: `~/.runcat/gpu.json`). The `--watch` argument in the plist controls the update cadence. Without `--watch` the script writes one snapshot and exits, which lets you schedule it with `StartInterval` instead if you prefer the Bitcoin sample's shape. + +## Troubleshooting + +- File never appears → run the script by hand (step 2). Errors print to stderr. +- `no AGXAccelerator PerformanceStatistics found` → the machine is not Apple Silicon, or the GPU driver exposes no statistics node. +- File exists but stops updating → check the agent is loaded: `launchctl print gui/$(id -u)/dev.runcat.gpu-sample`. +- Card footer shows **Last updated: Failed** in red → the file became unreadable or contains invalid JSON. Re-run the script by hand (step 2); the card recovers on the next successful read. diff --git a/docs/samples/gpu/dev.runcat.gpu-sample.plist b/docs/samples/gpu/dev.runcat.gpu-sample.plist new file mode 100644 index 0000000..5a68612 --- /dev/null +++ b/docs/samples/gpu/dev.runcat.gpu-sample.plist @@ -0,0 +1,19 @@ + + + + + Label + dev.runcat.gpu-sample + ProgramArguments + + /usr/bin/python3 + /Users/YOU/.runcat/runcat-gpu.py + --watch + 2 + + KeepAlive + + RunAtLoad + + + diff --git a/docs/samples/gpu/runcat-gpu.py b/docs/samples/gpu/runcat-gpu.py new file mode 100755 index 0000000..3d9d5af --- /dev/null +++ b/docs/samples/gpu/runcat-gpu.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +RunCat Neo - Apple Silicon GPU sample. + +Reads GPU statistics from the IOKit registry (no sudo, no network) and writes +~/.runcat/gpu.json shaped like: + + { + "title": "GPU", + "symbol": "memorychip.fill", + "metricsBarValue": "24%", + "metrics": [ + {"title": "Utilization", "formattedValue": "24%", "normalizedValue": 0.24}, + {"title": "Renderer", "formattedValue": "23%", "normalizedValue": 0.23}, + {"title": "Tiler", "formattedValue": "24%", "normalizedValue": 0.24}, + {"title": "VRAM in use", "formattedValue": "0.46 GB", "normalizedValue": 0.0285}, + {"title": "VRAM allocated", "formattedValue": "2.25 GB"} + ], + "lastUpdatedDate": "2026-08-12T18:25:08Z" + } + +Usage: + runcat-gpu.py write one snapshot and exit + runcat-gpu.py --watch 2 rewrite the snapshot every 2 seconds +""" + +import argparse +import json +import os +import plistlib +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path + +OUT = Path(os.environ.get("RUNCAT_OUT_FILE", str(Path.home() / ".runcat" / "gpu.json"))) + + +def gpu_statistics(): + """Return PerformanceStatistics from the first AGXAccelerator node.""" + raw = subprocess.run( + ["ioreg", "-r", "-d", "1", "-w", "0", "-c", "AGXAccelerator", "-a"], + capture_output=True, + check=True, + ).stdout + for node in plistlib.loads(raw) if raw else []: + statistics = node.get("PerformanceStatistics") + if statistics: + return statistics + raise RuntimeError("no AGXAccelerator PerformanceStatistics found") + + +def total_memory(): + output = subprocess.run( + ["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, check=True + ).stdout + return int(output.strip()) + + +def percent(title, value): + return { + "title": title, + "formattedValue": f"{value:g}%", + "normalizedValue": round(value / 100, 4), + } + + +def gigabytes(title, value, total=None): + metric = {"title": title, "formattedValue": f"{value / (1024 ** 3):.2f} GB"} + if total: + metric["normalizedValue"] = round(value / total, 4) + return metric + + +def build_snapshot(): + statistics = gpu_statistics() + utilization = int(statistics.get("Device Utilization %", 0)) + total = total_memory() + + return { + "title": "GPU", + "symbol": "memorychip.fill", + "metricsBarValue": f"{utilization:g}%", + "metrics": [ + percent("Utilization", utilization), + percent("Renderer", int(statistics.get("Renderer Utilization %", 0))), + percent("Tiler", int(statistics.get("Tiler Utilization %", 0))), + gigabytes("VRAM in use", int(statistics.get("In use system memory", 0)), total), + gigabytes("VRAM allocated", int(statistics.get("Alloc system memory", 0))), + ], + "lastUpdatedDate": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + + +def write(snapshot): + """Write via a temporary file so RunCat Neo never reads a partial snapshot.""" + OUT.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".runcat-", dir=str(OUT.parent)) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(snapshot, f, ensure_ascii=False) + os.replace(tmp, OUT) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--watch", type=float, metavar="SECONDS") + arguments = parser.parse_args() + + while True: + try: + write(build_snapshot()) + except Exception as error: + print(f"runcat-gpu: {error}", file=sys.stderr) + if not arguments.watch: + return 1 + if not arguments.watch: + return 0 + time.sleep(arguments.watch) + + +if __name__ == "__main__": + sys.exit(main())