From fda4e7847bb5dc985404269c81400eead52b0181 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 12 Nov 2026 19:07:18 +0100 Subject: feat(perf): show GPU render and compute load xe exposes no busy percentage, but its DRM fdinfo carries per-engine cycle counters. vram.sh becomes gpu.sh: the same scan also sums drm-cycles-rcs/ccs per client and prints them with the GPU's cycle counter, and Perf.js turns two scans into a percentage per engine. The GPU card's headline is now the busier engine, with render (games) and compute (local LLMs) as bars, then VRAM, temperature and fan. Co-Authored-By: Claude Opus 5.5 --- desktop/modules/perf/Perf.js | 18 +++++++++++ desktop/modules/perf/Perf.qml | 22 ++++++++++--- desktop/modules/perf/PerfPanel.qml | 30 ++++++++++++++++-- desktop/modules/perf/README.md | 10 +++--- desktop/modules/perf/gpu.sh | 65 ++++++++++++++++++++++++++++++++++++++ desktop/modules/perf/test_gpu.sh | 46 +++++++++++++++++++++++++++ desktop/modules/perf/test_perf.js | 9 +++++- desktop/modules/perf/test_vram.sh | 40 ----------------------- desktop/modules/perf/vram.sh | 54 ------------------------------- 9 files changed, 187 insertions(+), 107 deletions(-) create mode 100755 desktop/modules/perf/gpu.sh create mode 100755 desktop/modules/perf/test_gpu.sh delete mode 100755 desktop/modules/perf/test_vram.sh delete mode 100755 desktop/modules/perf/vram.sh diff --git a/desktop/modules/perf/Perf.js b/desktop/modules/perf/Perf.js index eb0dbd7..b94e5b6 100644 --- a/desktop/modules/perf/Perf.js +++ b/desktop/modules/perf/Perf.js @@ -44,6 +44,24 @@ function load(prev, cur) { return Math.max(0, Math.min(100, 100 * (1 - (cur.idle - prev.idle) / dt))); } +// gpu.sh's line as numbers: [vram, rcs busy, rcs clock, ccs busy, ccs clock], +// null when it printed nothing. +function gpuSample(text) { + const n = text.trim().split(/\s+/).map(Number); + return n.length === 5 && n.every(Number.isFinite) ? n : null; +} + +// {render, compute} percent busy between two gpuSample()s, each null when +// its clock did not move. A client exiting mid-sample takes its cycles with +// it, so a dip below zero is clamped. +function gpuLoad(prev, cur) { + const pct = (b, c) => { + if (!prev || !cur || cur[c] <= prev[c]) return null; + return Math.max(0, Math.min(100, 100 * (cur[b] - prev[b]) / (cur[c] - prev[c]))); + }; + return { render: pct(1, 2), compute: pct(3, 4) }; +} + // {total, used} in bytes; used is MemTotal - MemAvailable. function meminfo(text) { const kb = key => { diff --git a/desktop/modules/perf/Perf.qml b/desktop/modules/perf/Perf.qml index 5fcf0ab..33b5cbc 100644 --- a/desktop/modules/perf/Perf.qml +++ b/desktop/modules/perf/Perf.qml @@ -56,6 +56,9 @@ Singleton { property var temps: [] // [{label, value, warn, crit}], value null if unread property var disks: [] // Perf.js df rows property var vram: null // bytes + property var gpu: null // gpuSample, the last scan + property var gpuRender: null // percent, rcs + property var gpuCompute: null // percent, ccs property var fan: null // rpm property var rx: [] // bytes/s, newest last property var tx: [] @@ -181,18 +184,27 @@ Singleton { root.temps = t; if (fanView.path) { fanView.reload(); root.fan = root.num(fanView); } - if (!vramProc.running) vramProc.running = true; + if (!gpuProc.running) gpuProc.running = true; } } // ponytail: a shell glob over every fd on the system, ~14ms every 2s, // always; gate it on the panel again if that shows up. Only the user's own processes are readable, so a // system service's GPU use is not counted, as in conky. - // Summed in vram.sh, so JS gets one number, never the scan itself. + // Summed in gpu.sh, so JS gets five numbers, never the scan itself. Process { - id: vramProc - command: ["sh", `${Quickshell.shellDir}/modules/perf/vram.sh`] - stdout: StdioCollector { onStreamFinished: root.vram = text.trim() === "" ? null : Number(text) } + id: gpuProc + command: ["sh", `${Quickshell.shellDir}/modules/perf/gpu.sh`] + stdout: StdioCollector { + onStreamFinished: { + const cur = P.gpuSample(text); + const l = P.gpuLoad(root.gpu, cur); + root.vram = cur ? cur[0] : null; + root.gpuRender = l.render; + root.gpuCompute = l.compute; + root.gpu = cur; + } + } } // --- network ------------------------------------------------------------ diff --git a/desktop/modules/perf/PerfPanel.qml b/desktop/modules/perf/PerfPanel.qml index 784284a..8ea4af6 100644 --- a/desktop/modules/perf/PerfPanel.qml +++ b/desktop/modules/perf/PerfPanel.qml @@ -320,12 +320,31 @@ Column { // --- GPU ---------------------------------------------------------------- - // No utilisation: xe exposes no busy percentage. + // Busy per engine from fdinfo's cycle counters: render is games, compute + // local LLMs; the headline is the busier of the two. Card { + readonly property var busy: Perf.gpuRender === null && Perf.gpuCompute === null ? null + : Math.max(Perf.gpuRender ?? 0, Perf.gpuCompute ?? 0) title: "GPU ยท " + Perf.gpuName - headline: panel.deg(panel.gpuTemp.value) - headlineColor: panel.tone(panel.gpuTemp.value, panel.gpuTemp.warn, panel.gpuTemp.crit) + headline: busy === null ? "--" : Math.round(busy) + "%" + headlineColor: panel.tone(busy, 25, 75) + Pair { + label: "Render" + value: Perf.gpuRender === null ? "--" : Math.round(Perf.gpuRender) + "%" + } + Bar { + frac: (Perf.gpuRender ?? 0) / 100 + fill: panel.tone(Perf.gpuRender, 25, 75) + } + Pair { + label: "Compute" + value: Perf.gpuCompute === null ? "--" : Math.round(Perf.gpuCompute) + "%" + } + Bar { + frac: (Perf.gpuCompute ?? 0) / 100 + fill: panel.tone(Perf.gpuCompute, 25, 75) + } Pair { label: "VRAM" value: Perf.vram === null ? "--" : P.bytes(Perf.vram) + " / " + P.bytes(Perf.vramTotal) @@ -334,6 +353,11 @@ Column { frac: (Perf.vram ?? 0) / Perf.vramTotal fill: panel.tone(panel.pct(Perf.vram, Perf.vramTotal), 25, 75) } + Pair { + label: "Temperature" + value: panel.deg(panel.gpuTemp.value) + valueColor: panel.tone(panel.gpuTemp.value, panel.gpuTemp.warn, panel.gpuTemp.crit) + } Pair { label: "Fan" value: Perf.fan === null ? "--" : Perf.fan + " rpm" diff --git a/desktop/modules/perf/README.md b/desktop/modules/perf/README.md index d5ae0b3..9fdbc6e 100644 --- a/desktop/modules/perf/README.md +++ b/desktop/modules/perf/README.md @@ -2,10 +2,12 @@ The figures the conky dashboard used to show, as a drawer page of cards, one per row; the page scrolls. CPU load with a bar per core, the CPU model and -RAM; every temperature as a tile; the Arc B580's VRAM and fan (no -utilisation, xe exposes none); the five mounts with their usage; and +RAM; every temperature as a tile; the Arc B580's render and compute load +(games and local LLMs, from fdinfo's cycle counters), VRAM, temperature and +fan; the five mounts with their usage; and `~/.cache` with its five largest children. `Perf.qml` reads them; `Perf.js` -holds the parsing, tested by `node desktop/modules/perf/test_perf.js`. +holds the parsing, tested by `node desktop/modules/perf/test_perf.js`; +`gpu.sh` sums the fdinfo scan, tested by `sh desktop/modules/perf/test_gpu.sh`. The tile shows CPU and RAM, and a dot for the worst of these, yellow or red: @@ -27,7 +29,7 @@ into the LAN depends on the ISP's fixed address. The fetch is never a mismatch: the old value goes stale after 4 hours and shows as a dash. Everything is sampled always, including per-core load, the network rate, -VRAM (a scan of every fdinfo, ~14ms every 2s) and the fan. The network rate +GPU load and VRAM (a scan of every fdinfo, ~14ms every 2s) and the fan. The network rate is drawn by the network page, which reads it from here, so its graph opens on the last minute of history instead of empty. The mount list, `br0`, the GPU name and the 12 GiB VRAM size are constants in `Perf.qml`. diff --git a/desktop/modules/perf/gpu.sh b/desktop/modules/perf/gpu.sh new file mode 100755 index 0000000..31f8a6e --- /dev/null +++ b/desktop/modules/perf/gpu.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# +# Copyright (C) 2026 Danilo M. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# GPU from the fdinfo scan, every xe fd counted once per drm-client-id +# because a client holds several fds. Prints one line, "vram rcs_busy +# rcs_clock ccs_busy ccs_clock": VRAM in bytes (drm-resident-vram0), then +# per engine the busy cycles summed over clients and the GPU's cycle +# counter, which Perf.js turns into a percentage between two scans. rcs is +# render (games), ccs compute (local LLMs). Prints nothing when no xe +# client is found. With a file argument it reads that instead of scanning, +# for test_gpu.sh. +# +# Summed here rather than in JS: a process holding thousands of DRM fds +# makes the scan a few hundred KB, which is a lot of string churn to parse +# in QML every 2 s. It was moved here while chasing a GC underflow that +# turned out to be the sound OSD's createImageData (AGENTS.md), not this. +# +# Fds of a repeated client id with different values: the first one awk's +# for-in reaches counts, where Perf.js took the first in scan order. They +# share one client, so the values agree in practice. + +scan() { + if [ -n "$1" ]; then cat "$1" + else grep -sH -e '^drm-driver' -e '^drm-client-id' -e '^drm-resident-vram0' \ + -e '^drm-cycles-[rc]cs' -e '^drm-total-cycles-[rc]cs' \ + /proc/[0-9]*/fdinfo/* 2>/dev/null + fi +} + +scan "$1" | awk -F: ' + NF >= 3 && $2 ~ /^drm-/ { + v = $3 + for (i = 4; i <= NF; i++) v = v ":" $i + sub(/^[ \t]+/, "", v) + d[$1, $2] = v + fd[$1] + } + END { + for (p in fd) { + id = d[p, "drm-client-id"] + if (d[p, "drm-driver"] != "xe" || id == "" || (id in seen)) continue + if (split(d[p, "drm-resident-vram0"], n, " ") < 1 || n[1] !~ /^[0-9]+$/) continue + for (i = 1; i <= 2; i++) { + e = i == 1 ? "rcs" : "ccs" + busy[e] += d[p, "drm-cycles-" e] + c = d[p, "drm-total-cycles-" e] + 0 + if (c > clock[e]) clock[e] = c + } + seen[id] + u = n[2] == "KiB" ? 1024 : n[2] == "MiB" ? 1048576 : n[2] == "GiB" ? 1073741824 : 1 + total += n[1] * u + any = 1 + } + if (any) printf "%.0f %.0f %.0f %.0f %.0f\n", total, busy["rcs"], clock["rcs"], busy["ccs"], clock["ccs"] + }' diff --git a/desktop/modules/perf/test_gpu.sh b/desktop/modules/perf/test_gpu.sh new file mode 100755 index 0000000..8467c74 --- /dev/null +++ b/desktop/modules/perf/test_gpu.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# +# Copyright (C) 2026 Danilo M. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# gpu.sh: VRAM and engine cycles summed once per xe client id, units +# honoured, other drivers ignored; nothing at all when no xe client is found. +set -e +dir=$(dirname "$0") +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT + +printf '%s\n' \ + "/proc/1/fdinfo/7:drm-driver: xe" \ + "/proc/1/fdinfo/7:drm-client-id: 38" \ + "/proc/1/fdinfo/7:drm-resident-vram0: 1024 KiB" \ + "/proc/1/fdinfo/7:drm-cycles-rcs: 30" \ + "/proc/1/fdinfo/7:drm-total-cycles-rcs: 1000" \ + "/proc/1/fdinfo/10:drm-driver: xe" \ + "/proc/1/fdinfo/10:drm-client-id: 38" \ + "/proc/1/fdinfo/10:drm-resident-vram0: 1024 KiB" \ + "/proc/1/fdinfo/10:drm-cycles-rcs: 30" \ + "/proc/1/fdinfo/10:drm-total-cycles-rcs: 1000" \ + "/proc/2/fdinfo/5:drm-driver: xe" \ + "/proc/2/fdinfo/5:drm-client-id: 40" \ + "/proc/2/fdinfo/5:drm-resident-vram0: 2 MiB" \ + "/proc/2/fdinfo/5:drm-cycles-ccs: 7" \ + "/proc/2/fdinfo/5:drm-total-cycles-ccs: 900" \ + "/proc/3/fdinfo/4:drm-driver: amdgpu" \ + "/proc/3/fdinfo/4:drm-client-id: 1" \ + "/proc/3/fdinfo/4:drm-resident-vram0: 9 GiB" > "$tmp" +got=$(sh "$dir/gpu.sh" "$tmp") +[ "$got" = "$((1024 * 1024 + 2 * 1024 * 1024)) 30 1000 7 900" ] || { echo "gpu: got '$got'"; exit 1; } + +: > "$tmp" +got=$(sh "$dir/gpu.sh" "$tmp") +[ -z "$got" ] || { echo "empty: got '$got'"; exit 1; } +echo ok diff --git a/desktop/modules/perf/test_perf.js b/desktop/modules/perf/test_perf.js index d5856e8..a1a068d 100644 --- a/desktop/modules/perf/test_perf.js +++ b/desktop/modules/perf/test_perf.js @@ -20,7 +20,7 @@ const src = fs.readFileSync(__dirname + "/Perf.js", "utf8") .replace(/^\.pragma library$/m, ""); const P = new Function(src + "\nreturn { cpuTimes, load, meminfo, df, rate, " + "hwmonMap, lanAddr, cpuModel, du, band, worst, isIPv4, parsePubip, formatPubip, " + - "ipCheck, due, push, bytes, speed, procs, sockets, netProcs };")(); + "ipCheck, due, push, bytes, speed, procs, sockets, netProcs, gpuSample, gpuLoad };")(); // cpuTimes: aggregate first, then cores; idle is idle + iowait. const stat = [ @@ -183,4 +183,11 @@ assert.deepStrictEqual(P.netProcs([{ key: "a", name: "firefox", rx: 5000, tx: 10 [{ name: "firefox", rx: 3000, tx: 0 }]); assert.deepStrictEqual(P.netProcs([], later, 0), []); + +// gpu.sh: five numbers or nothing; load per engine between two scans. +assert.strictEqual(P.gpuSample(""), null); +const g1 = P.gpuSample("100 0 1000 0 1000\n"), g2 = P.gpuSample("100 200 2000 900 2000"); +assert.deepStrictEqual(P.gpuLoad(g1, g2), { render: 20, compute: 90 }); +assert.deepStrictEqual(P.gpuLoad(null, g2), { render: null, compute: null }); +assert.deepStrictEqual(P.gpuLoad(g2, [100, 0, 3000, 0, 2000]), { render: 0, compute: null }); console.log("test_perf: ok"); diff --git a/desktop/modules/perf/test_vram.sh b/desktop/modules/perf/test_vram.sh deleted file mode 100755 index 7ef4ade..0000000 --- a/desktop/modules/perf/test_vram.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/sh -# -# Copyright (C) 2026 Danilo M. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# vram.sh: summed once per xe client id, units honoured, other drivers -# ignored; nothing at all when no xe client is found. -set -e -dir=$(dirname "$0") -tmp=$(mktemp) -trap 'rm -f "$tmp"' EXIT - -printf '%s\n' \ - "/proc/1/fdinfo/7:drm-driver: xe" \ - "/proc/1/fdinfo/7:drm-client-id: 38" \ - "/proc/1/fdinfo/7:drm-resident-vram0: 1024 KiB" \ - "/proc/1/fdinfo/10:drm-driver: xe" \ - "/proc/1/fdinfo/10:drm-client-id: 38" \ - "/proc/1/fdinfo/10:drm-resident-vram0: 1024 KiB" \ - "/proc/2/fdinfo/5:drm-driver: xe" \ - "/proc/2/fdinfo/5:drm-client-id: 40" \ - "/proc/2/fdinfo/5:drm-resident-vram0: 2 MiB" \ - "/proc/3/fdinfo/4:drm-driver: amdgpu" \ - "/proc/3/fdinfo/4:drm-client-id: 1" \ - "/proc/3/fdinfo/4:drm-resident-vram0: 9 GiB" > "$tmp" -got=$(sh "$dir/vram.sh" "$tmp") -[ "$got" = $((1024 * 1024 + 2 * 1024 * 1024)) ] || { echo "vram: got '$got'"; exit 1; } - -: > "$tmp" -got=$(sh "$dir/vram.sh" "$tmp") -[ -z "$got" ] || { echo "empty: got '$got'"; exit 1; } -echo ok diff --git a/desktop/modules/perf/vram.sh b/desktop/modules/perf/vram.sh deleted file mode 100755 index 83db27d..0000000 --- a/desktop/modules/perf/vram.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/sh -# -# Copyright (C) 2026 Danilo M. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# VRAM in bytes from the fdinfo scan: drm-resident-vram0 of every xe fd, -# counted once per drm-client-id because a client holds several fds. Prints -# nothing when no xe client is found. With a file argument it reads that -# instead of scanning, for test_vram.sh. -# -# Summed here rather than in JS: a process holding thousands of DRM fds -# makes the scan a few hundred KB, which is a lot of string churn to parse -# in QML every 2 s. It was moved here while chasing a GC underflow that -# turned out to be the sound OSD's createImageData (AGENTS.md), not this. -# -# Fds of a repeated client id with different values: the first one awk's -# for-in reaches counts, where Perf.js took the first in scan order. They -# share one client, so the values agree in practice. - -scan() { - if [ -n "$1" ]; then cat "$1" - else grep -sH -e '^drm-driver' -e '^drm-client-id' -e '^drm-resident-vram0' \ - /proc/[0-9]*/fdinfo/* 2>/dev/null - fi -} - -scan "$1" | awk -F: ' - NF >= 3 && $2 ~ /^drm-/ { - v = $3 - for (i = 4; i <= NF; i++) v = v ":" $i - sub(/^[ \t]+/, "", v) - d[$1, $2] = v - fd[$1] - } - END { - for (p in fd) { - id = d[p, "drm-client-id"] - if (d[p, "drm-driver"] != "xe" || id == "" || (id in seen)) continue - if (split(d[p, "drm-resident-vram0"], n, " ") < 1 || n[1] !~ /^[0-9]+$/) continue - seen[id] - u = n[2] == "KiB" ? 1024 : n[2] == "MiB" ? 1048576 : n[2] == "GiB" ? 1073741824 : 1 - total += n[1] * u - any = 1 - } - if (any) printf "%.0f\n", total - }' -- cgit v1.2.3