feat(observability): per-GPU metrics for the mixed AMD/NVIDIA fleet

Add a host-process GPU exporter, scrape it on both servers, and graph it
on the host-system dashboard. The fleet has four GPUs across three vendors
of interest (2x R9700 on Dankserver, the 8060S iGPU + RTX 3090 on Dankclaw)
and nothing exposed them until now.

scripts/gpu-exporter.py reads each AMD card's amdgpu sysfs
(gpu_busy_percent / mem_info_vram_* / hwmon temp1_input / power1_average)
plus one nvidia-smi --query-gpu CSV, and emits unified gpu_* Prometheus
families (utilization_percent, memory_used/total_bytes, temperature_celsius,
power_watts) labelled gpu/vendor/model. Absent hardware yields ABSENT series,
never a fabricated 0: the R9700s expose no sysfs power, so they simply carry
no gpu_power_watts line. Both sources are injectable, so the seam runs on a
GPU-less dev box; live-verified on Dankserver's real sysfs.

It runs as a plain user unit (not a quadlet): it must read the host's own DRM
sysfs and shell to nvidia-smi, which a container cannot do without GPU
passthrough. Same posture as signal-relay. The base unit binds loopback; the
deploy drop-in appends the host LAN address, never a wildcard (matching
node-exporter/cadvisor). deploy_gpu_exporter installs/enables/health-gates it
on both hosts and is wired into the planner (both targets), the test-changed
router, and two Prometheus scrape jobs using the node/cadvisor reach rules
(gateway on Dankclaw, LAN on Dankserver).
This commit is contained in:
Frank committed 2026-10-20 23:42:26 -07:00
1 parent b35269bfa5
commit b02647b992
9 files changed
+592 -9

No files matched your search

@@ -18,6 +18,16 @@
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" }
},
{
"name": "gpuhost",
"label": "gpu host",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": { "query": "label_values(gpu_utilization_percent, host)", "refId": "A" },
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" }
}
]
},
@@ -139,6 +149,56 @@
{ "expr": "sum by (job,device) (rate(node_network_receive_errs_total{job=~\"$node\"}[5m]))", "legendFormat": "{{job}} {{device}} RX err", "refId": "A" },
{ "expr": "sum by (job,device) (rate(node_network_transmit_errs_total{job=~\"$node\"}[5m]))", "legendFormat": "{{job}} {{device}} TX err", "refId": "B" }
]
},
{
"id": 13, "type": "timeseries", "title": "GPU busy %",
"description": "Per-GPU utilization from gpu-exporter (amdgpu gpu_busy_percent / nvidia-smi). The picker above selects the host; each series is one card.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] },
"targets": [
{ "expr": "gpu_utilization_percent{host=~\"$gpuhost\"}", "legendFormat": "{{host}} {{gpu}} ({{model}})", "refId": "A" }
]
},
{
"id": 14, "type": "timeseries", "title": "GPU VRAM used",
"description": "Device memory in use. On the R9700s (frank-archon) and the 3090 (frank-zealot) this is the loaded model's footprint.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "bytes", "min": 0 }, "overrides": [] },
"targets": [
{ "expr": "gpu_memory_used_bytes{host=~\"$gpuhost\"}", "legendFormat": "{{host}} {{gpu}} ({{model}})", "refId": "A" }
]
},
{
"id": 15, "type": "timeseries", "title": "GPU VRAM used %",
"description": "Used / total device memory. Near 100% is the pre-OOM wall.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 44 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] },
"targets": [
{ "expr": "100 * gpu_memory_used_bytes{host=~\"$gpuhost\"} / gpu_memory_total_bytes{host=~\"$gpuhost\"}", "legendFormat": "{{host}} {{gpu}} ({{model}})", "refId": "A" }
]
},
{
"id": 16, "type": "timeseries", "title": "GPU temperature",
"description": "Package temperature. AMD edge temp (hwmon temp1_input); NVIDIA gpu temp.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 44 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "celsius", "min": 0 }, "overrides": [] },
"targets": [
{ "expr": "gpu_temperature_celsius{host=~\"$gpuhost\"}", "legendFormat": "{{host}} {{gpu}} ({{model}})", "refId": "A" }
]
},
{
"id": 17, "type": "timeseries", "title": "GPU power draw",
"description": "Board power draw. Emitted only where readable: NVIDIA and the Dankclaw iGPU. The R9700s have no sysfs power sensor, so they are simply absent here — never a fabricated 0.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 52 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "watt", "min": 0 }, "overrides": [] },
"targets": [
{ "expr": "gpu_power_watts{host=~\"$gpuhost\"}", "legendFormat": "{{host}} {{gpu}} ({{model}})", "refId": "A" }
]
}
]
}
+22
View File
@@ -191,3 +191,25 @@ scrape_configs:
action: keep
- regex: 'id|name|image|container_label.*|machine_id|boot_id'
action: labeldrop
# Per-GPU metrics (gpu_utilization_percent / gpu_memory_used_bytes /
# gpu_memory_total_bytes / gpu_temperature_celsius / gpu_power_watts) from the
# host-process gpu-exporter (scripts/gpu-exporter.py). One job per host, same
# reach rules as node/cadvisor: Dankclaw is scraped at the podman gateway
# because the netavark hairpin quirk makes Prometheus fail to dial its own
# host LAN IP; Dankserver is plain LAN. The gpu label (PCI BDF on AMD, index on
# NVIDIA) + vendor/model come from the exporter, so the GPU row on the
# host-system dashboard filters by host. Power is emitted only where readable
# (NVIDIA + the Dankclaw iGPU); the R9700s have no sysfs power, so they simply
# have no gpu_power_watts series — no fabricated zero.
- job_name: "gpu-dankclaw"
static_configs:
- targets: ["host.containers.internal:9401"]
labels:
host: dankclaw
- job_name: "gpu-dankserver"
static_configs:
- targets: ["192.168.0.235:9401"]
labels:
host: dankserver