Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/samples/gpu/README.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions docs/samples/gpu/dev.runcat.gpu-sample.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.runcat.gpu-sample</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/YOU/.runcat/runcat-gpu.py</string>
<string>--watch</string>
<string>2</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
124 changes: 124 additions & 0 deletions docs/samples/gpu/runcat-gpu.py
Original file line number Diff line number Diff line change
@@ -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())