aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-04 18:52:40 +0200
committerDanilo M. <danix@danix.xyz>2026-10-04 18:52:40 +0200
commit416fa4876018e5fbb9c3f537ea9c353d1d8b9982 (patch)
treeba72117f5a7a8dda2cbe65614b410e6ba2bd4d09
parent96ac1ee65da31055704e0a14cafacc33914d7e86 (diff)
downloadconky-theme-udt-416fa4876018e5fbb9c3f537ea9c353d1d8b9982.tar.gz
conky-theme-udt-416fa4876018e5fbb9c3f537ea9c353d1d8b9982.zip
feat(gpu): show VRAM in use as a bar with a percentage
Sum drm-resident-vram0 from DRM fdinfo, the same per-client counters gputop reads, counting each drm-client-id once since a client repeats its totals on every fd it holds. One grep over /proc/*/fdinfo, about 10ms warm, sampled through new_gate every ~2s. The 12 GiB total is hardcoded: xe exposes no VRAM capacity in sysfs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--README.md16
-rw-r--r--lib/data.lua41
-rw-r--r--test/test_data.lua20
-rw-r--r--widgets/gpu.lua40
4 files changed, 105 insertions, 12 deletions
diff --git a/README.md b/README.md
index 3de25f7..b137121 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,8 @@ Seven cards cover the machine itself:
- **System**: CPU load as a per-core equaliser, the CPU and motherboard model
as two rows under the header, RAM used/total, and every temperature (CPU
package, CCD die, NVMe and two board sensors).
-- **GPU**: Arc B580 package and VRAM temperatures, and fan speed.
+- **GPU**: Arc B580 package and VRAM temperatures, VRAM in use as a bar, and
+ fan speed.
- **Disks**: `/`, `/home`, `/data` and one NFS mount per server, as usage
rings, with free space and total size printed under each ring. Two servers
are configured, each with the same export mounted twice; the card shows one
@@ -488,10 +489,15 @@ card that computed free space as `size - used` would overstate it by exactly
that reserve.
**The Arc B580 reports no utilisation through any stable interface.** The `xe`
-driver exposes no `gpu_busy_percent` (unlike `amdgpu`), `intel_gpu_top` refuses
-the device, and `gputop` prints per-process rows with ANSI escapes, which is not
-something to build a widget on. The GPU card therefore has no load bar; it shows
-what the hardware actually reports rather than inventing a number.
+driver exposes no `gpu_busy_percent` (unlike `amdgpu`) and `intel_gpu_top`
+refuses the device. The GPU card therefore has no load bar; it shows what the
+hardware actually reports rather than inventing a number.
+
+**VRAM in use comes from DRM fdinfo**, the per-client counters `gputop` reads:
+`drm-resident-vram0` in `/proc/<pid>/fdinfo/<fd>`, summed once per
+`drm-client-id`. Only processes readable by conky's user are seen, so a GPU
+client running as another user is not counted. The card's 12 GiB total is
+hardcoded: xe puts no VRAM capacity in sysfs.
**`/data` has no temperature.** It sits on a spinning disk with no hwmon chip;
reading its SMART temperature would need `smartctl` as root and would spin up a
diff --git a/lib/data.lua b/lib/data.lua
index 7ae96a7..d02d0f7 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -690,4 +690,45 @@ function M.nvtable_parse(text)
return seen and out or nil
end
+-- VRAM in use on an xe device, in bytes, from DRM fdinfo: the same source
+-- gputop reads. Input is `grep -H` output, one 'path:key:<tab>value' line per
+-- field, so fields are grouped by the fd file they came from.
+--
+-- One client (drm-client-id) is usually open on several fds, each repeating
+-- the client's full totals, so clients are counted once, not fds. Buffers
+-- shared between clients are still counted in each, as gputop does.
+local UNIT = { B = 1, KiB = 1024, MiB = 1024 ^ 2, GiB = 1024 ^ 3 }
+
+function M.drm_vram_parse(text)
+ if type(text) ~= 'string' then return nil end
+ local fds = {}
+ for path, key, val in text:gmatch('([^\n]-):(drm%-[%w%-]+):%s*([^\n]*)') do
+ local fd = fds[path] or {}
+ fds[path] = fd
+ fd[key] = val
+ end
+ local seen, total = {}, nil
+ for _, fd in pairs(fds) do
+ local id = fd['drm-client-id']
+ local n, unit = (fd['drm-resident-vram0'] or ''):match('^(%d+)%s*(%a*)')
+ if fd['drm-driver'] == 'xe' and id and n and not seen[id] then
+ seen[id] = true
+ total = (total or 0) + tonumber(n) * (UNIT[unit ~= '' and unit or 'B'] or 1)
+ end
+ end
+ return total
+end
+
+-- ponytail: a popen per call, ~10ms warm; callers gate it. Only fds of
+-- processes readable by conky's user are seen, so another user's GPU client
+-- (a system service) is not counted.
+function M.drm_vram()
+ local p = io.popen("grep -sH -e '^drm-driver' -e '^drm-client-id'"
+ .. " -e '^drm-resident-vram0' /proc/[0-9]*/fdinfo/* 2>/dev/null")
+ if not p then return nil end
+ local out = p:read('*a')
+ p:close()
+ return M.drm_vram_parse(out)
+end
+
return M
diff --git a/test/test_data.lua b/test/test_data.lua
index 2d4d5c7..ec2b6ed 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -479,6 +479,26 @@ assert(up.upgrade == 3 and up.bump == 1 and up.current == 46 and up.missing == 1
'updates summary')
assert(data.nvtable_parse('') == nil, 'updates: empty cache is nil')
+-- drm_vram_parse: xe clients counted once across their fds, other drivers
+-- skipped, units honoured.
+local fdinfo = table.concat({
+ '/proc/1/fdinfo/5:drm-driver:\txe',
+ '/proc/1/fdinfo/5:drm-client-id:\t7',
+ '/proc/1/fdinfo/5:drm-resident-vram0:\t1024 KiB',
+ '/proc/1/fdinfo/6:drm-driver:\txe',
+ '/proc/1/fdinfo/6:drm-client-id:\t7',
+ '/proc/1/fdinfo/6:drm-resident-vram0:\t1024 KiB',
+ '/proc/2/fdinfo/9:drm-driver:\txe',
+ '/proc/2/fdinfo/9:drm-client-id:\t8',
+ '/proc/2/fdinfo/9:drm-resident-vram0:\t2 MiB',
+ '/proc/3/fdinfo/4:drm-driver:\tamdgpu',
+ '/proc/3/fdinfo/4:drm-client-id:\t9',
+ '/proc/3/fdinfo/4:drm-resident-vram0:\t5 GiB',
+}, '\n')
+assert(data.drm_vram_parse(fdinfo) == 3 * 1024 ^ 2,
+ 'drm vram: got ' .. tostring(data.drm_vram_parse(fdinfo)))
+assert(data.drm_vram_parse('') == nil, 'drm vram: no clients is nil')
+
-- new_gate: opens at most once per interval on the clock it is given.
local gate = data.new_gate(1.9)
assert(gate(100.0) == true, 'gate: first call opens')
diff --git a/widgets/gpu.lua b/widgets/gpu.lua
index 7d5f2ed..abac856 100644
--- a/widgets/gpu.lua
+++ b/widgets/gpu.lua
@@ -1,10 +1,9 @@
--- GPU: Arc B580 temperatures and fan speed.
+-- GPU: Arc B580 temperatures, VRAM in use and fan speed.
--
-- No utilisation figure, deliberately. The xe driver exposes no
--- gpu_busy_percent, intel_gpu_top refuses the device outright ("Detected Xe
--- device which is not supported"), and gputop prints per-process rows with
--- ANSI escapes, which is not an interface to build a widget on. The card shows
--- what the hardware actually reports rather than inventing a number.
+-- gpu_busy_percent and intel_gpu_top refuses the device outright ("Detected Xe
+-- device which is not supported"). VRAM comes from DRM fdinfo, the per-client
+-- counters gputop itself reads (see data.drm_vram).
--
-- The integrated AMD GPU does expose gpu_busy_percent and is deliberately not
-- shown: the discrete card is the one in use.
@@ -14,6 +13,15 @@ local data = require 'lib.data'
local M = {}
+-- The B580's VRAM. fdinfo reports what clients hold, not the card's size, and
+-- xe puts no capacity in sysfs. ponytail: hardcoded like the 'ARC B580' label.
+local VRAM_TOTAL = 12 * 1024 ^ 3
+
+-- fdinfo is a popen over every fd on the system, so sample it every ~2s, not
+-- every frame.
+local gate = data.new_gate(1.9)
+local vram
+
-- chip, file, label, warn, crit. Package (temp2) is the headline above, so it
-- is not repeated here; these are the rows drawn under it.
local TEMPS = {
@@ -24,6 +32,8 @@ function M.draw(cr, rect, colors)
local inner = card.card(cr, rect, colors)
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
+ if gate() then vram = data.drm_vram() end
+
local pkg = data.sensor('xe', 'temp2_input')
local big_txt = pkg and string.format('%d\u{00B0}', pkg) or '--'
@@ -32,8 +42,8 @@ function M.draw(cr, rect, colors)
-- only where the header or a row would overrun the card. So a tall cell
-- gets larger type instead of empty space, and nothing ever collides.
local LABEL_F, ROW_F = 0.32, 0.34
- local row_n = #TEMPS + 1 -- VRAM + FAN
- local S_h = inner.h * 0.94 / (2.088 + row_n * 0.646)
+ local row_n = #TEMPS + 2 -- VRAM + MEM + FAN
+ local S_h = inner.h * 0.94 / (2.088 + row_n * 0.646 + 0.3) -- + MEM bar
local groups = {
{ { card.title_str(card.ICON.gpu, 'GPU'), card.FONT_MONO, LABEL_F },
{ big_txt, card.FONT_HEAVY, 1.0 } },
@@ -43,6 +53,8 @@ function M.draw(cr, rect, colors)
groups[#groups + 1] = { { t[3], card.FONT_MONO, ROW_F },
{ '888\u{00B0}', card.FONT_MONO, ROW_F } }
end
+ groups[#groups + 1] = { { 'MEM', card.FONT_MONO, ROW_F },
+ { '100%', card.FONT_MONO, ROW_F } }
groups[#groups + 1] = { { 'FAN', card.FONT_MONO, ROW_F },
{ '8888 RPM', card.FONT_MONO, ROW_F } }
local S = clamp(math.min(S_h, card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
@@ -80,6 +92,20 @@ function M.draw(cr, rect, colors)
ey = ey + step
end
+ -- VRAM as a fraction of the card's, a bar under the row like RAM in system.
+ local bar_h = S * 0.12
+ if ey + step + bar_h + S * 0.1 <= inner.y + inner.h then
+ local frac = vram and vram / VRAM_TOTAL
+ local colour = frac and card.threshold(frac * 100, 25, 75, colors) or colors.label
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, 'MEM')
+ card.rgba(cr, colour)
+ card.text_right(cr, inner.x + inner.w, ey,
+ frac and string.format('%d%%', math.floor(frac * 100 + 0.5)) or '--')
+ card.bar(cr, inner.x, ey + row_size * 0.6, inner.w, bar_h, frac, colour, colors)
+ ey = ey + step + bar_h + S * 0.1
+ end
+
-- Fan RPM is not a temperature, so it carries no threshold colour: a fast
-- fan is the cooling working, not a fault.
if ey + step <= inner.y + inner.h then