GPU boxes on the Processes tab: busy, engines, temps, and who has the card open

Every DRM device with a real GPU driver gets a box under the cpu box, laid out
like it: the busy curve, a meter for the device and one per engine, temperature,
clock, power and fan where the card has them, and the processes holding it open
with their share. The process table gains a Gpu% column and sort.

Device busy is Intel's RC6 idle residency inverted (what intel_gpu_top shows,
and readable without root) or amdgpu's gpu_busy_percent. Per-process shares are
DRM fdinfo deltas, as nvtop reads them; other users' fdinfo needs root, so a
daemon running as a user keeps one root child (hamlab drm-clients) over a pipe
rather than paying a sudo a second. Everything is also a gpu.* sensor.

What cannot be measured is left out, not shown as zero: an Arc A380 on i915 6.12
has no temperature sensor, an integrated GPU wears the package temperature
marked *, and Asahi has neither a busy counter nor fdinfo yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-02 18:18:02 -07:00
co-authored by Claude Fable 5.1
parent 0e82719070
commit db40220365
11 changed files with 981 additions and 19 deletions
+84 -12
View File
@@ -4,7 +4,7 @@
// /api/procs for the table — so this works on any machine the console can see, whether or not
// btop is installed on it.
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { api, ProcTable, Proc, Sample } from './api'
import { api, GPU, ProcTable, Proc, Sample } from './api'
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
@@ -186,7 +186,7 @@ function braille(values: number[], cells = 7, max = 100): string {
}
// ---- the curves this page keeps ----
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util'
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util,gpu.*.usage'
function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) {
const cur = useRef<Map<string, number[]>>(new Map())
@@ -216,7 +216,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
const n = binN.current
for (const [id, v] of Object.entries(live)) {
if (typeof v !== 'number') continue
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$/.test(id)) continue
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$|^gpu\..+\.usage$/.test(id)) continue
const arr = cur.current.get(id) ?? []
if (!cur.current.has(id)) cur.current.set(id, arr)
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
@@ -280,7 +280,7 @@ export default function Top() {
// how many points the history window holds at this rate: the width of every graph
const cap = Math.max(2, Math.min(MAX, Math.round((historySec * 1000) / strideMs(historySec, refreshMs))))
const [sort, setSort] = useState<'cpu' | 'mem' | 'pid' | 'name'>('cpu')
const [sort, setSort] = useState<'cpu' | 'gpu' | 'mem' | 'pid' | 'name'>('cpu')
const [rev, setRev] = useState(false)
const [filter, setFilter] = useState('')
const [sel, setSel] = useState<number | null>(null)
@@ -303,8 +303,8 @@ export default function Top() {
if (!pt) return []
const f = filter.trim().toLowerCase()
const list = pt.procs.filter((p) => !f || p.name.toLowerCase().includes(f) || (p.cmd ?? '').toLowerCase().includes(f) || p.user.toLowerCase().includes(f) || String(p.pid) === f)
const key = (p: Proc) => (sort === 'cpu' ? p.cpu : sort === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0)
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a)))
const key = (p: Proc) => (sort === 'cpu' ? p.cpu : sort === 'gpu' ? (p.gpu ?? 0) : sort === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0)
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a) || b.cpu - a.cpu))
return rev ? list.reverse() : list
}, [pt, filter, sort, rev])
@@ -342,10 +342,16 @@ export default function Top() {
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
// the GPUs, with each one's sensor stem: the same rule the server uses, so two identical
// cards are gpu.<name> and gpu.<name>_2 here as there
const gpus = (pt?.gpus ?? []).map((g, i, all) => {
const seg = segOf(g.name), n = all.slice(0, i).filter((o) => segOf(o.name) === seg).length
return { g, stem: 'gpu.' + seg + (n ? `_${n + 1}` : '') }
})
return (
<Pal.Provider value={pal}>
<div className="top" style={pal.vars as React.CSSProperties}>
<div className={'top' + (gpus.length ? ' has-gpu' : '')} style={pal.vars as React.CSSProperties}>
<Box n={1} name="cpu" className="top-cpu" right={<>
<span className="t-clock">{clock.toLocaleTimeString([], { hour12: false })}</span>
<span className="t-rate">
@@ -383,6 +389,16 @@ export default function Top() {
</div>
</Box>
{gpus.length > 0 && (
<div className="top-gpus">
{gpus.map(({ g, stem }, i) => (
<Box key={stem} name={`gpu${i}`} className="top-gpu" right={<span className="t-gpu-name" title={[g.name, g.driver, g.bus].filter(Boolean).join(' · ')}>{g.name}</span>}>
<GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} cap={cap} />
</Box>
))}
</div>
)}
<Box n={2} name="mem" className="top-mem">
<div className="t-row"><b>Total:</b><span className="spacer" />{bytes(mem.total)}</div>
{([['Used', mem.used, 'used'], ['Available', mem.avail, 'avail'], ['Cached', cached, 'cached'], ['Free', free, 'free']] as const).map(([k, v, g]) => (
@@ -445,17 +461,17 @@ export default function Top() {
<Box n={5} name="proc" className="top-proc" right={<>
<span className="t-sorts">
{(['pid', 'name', 'mem', 'cpu'] as const).map((k) => <button key={k} className={sort === k ? 'on' : ''} onClick={() => setSort(k)}>{k}</button>)}
{(['pid', 'name', 'mem', ...(gpus.length ? ['gpu' as const] : []), 'cpu'] as const).map((k) => <button key={k} className={sort === k ? 'on' : ''} onClick={() => setSort(k)}>{k}</button>)}
<button className={rev ? 'on' : ''} onClick={() => setRev(!rev)}>reverse</button>
</span>
</>}>
<div className="t-proc">
<div className={'t-proc' + (gpus.length ? ' has-gpu' : '')}>
<input className="t-filter" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
<div className="t-thead">
<span className="c-pid">Pid:</span><span className="c-name">Program:</span><span className="c-cmd">Command:</span>
<span className="c-thr">Threads:</span><span className="c-user">User:</span><span className="c-mem">MemB</span><span className="c-g" /><span className="c-cpu">Cpu%</span>
<span className="c-thr">Threads:</span><span className="c-user">User:</span><span className="c-mem">MemB</span><span className="c-g" />{gpus.length > 0 && <span className="c-gpu">Gpu%</span>}<span className="c-cpu">Cpu%</span>
</div>
<Rows procs={procs} sel={sel} onSel={setSel} curves={pcurve} empty={err || 'no process matches'} />
<Rows procs={procs} sel={sel} onSel={setSel} curves={pcurve} gpu={gpus.length > 0} empty={err || 'no process matches'} />
<div className="t-foot">
{sel !== null && procs.find((p) => p.pid === sel) ? (() => { const p = procs.find((x) => x.pid === sel)!; return <>
<span className="t-dim">{p.pid} {p.name}</span><span className="spacer" />
@@ -473,7 +489,7 @@ export default function Top() {
// The rows are the heaviest thing on the page — hundreds of them — so they are redrawn when the
// table is read again, not on every tick of the curves above them.
const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject<Map<number, number[]>>; empty: string }) {
const Rows = memo(function Rows({ procs, sel, onSel, curves, gpu, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject<Map<number, number[]>>; gpu: boolean; empty: string }) {
return (
<div className="t-rows">
{procs.map((p) => {
@@ -487,6 +503,7 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
<span className="c-user">{p.user}</span>
<span className="c-mem">{shortBytes(p.rss)}</span>
<span className="c-g">{braille(c, 7, Math.max(5, ...(c.length ? c : [0])))}</span>
{gpu && <span className={'c-gpu' + (p.gpu ? ' lit' : '')}>{p.gpu ? p.gpu.toFixed(1) : ''}</span>}
<span className="c-cpu">{p.cpu.toFixed(1)}</span>
</div>
)
@@ -496,6 +513,61 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
)
})
// One GPU, laid out like the cpu box: the busy curve on the left; on the right the device's
// meters — busy, each engine, temperature — then clock, power and memory, then the processes
// that have it open with their share. What cannot be measured on this card is left out rather
// than shown as zero: an Arc on i915 has no temperature sensor, Asahi has no busy counter.
const ENGINES: [string, string][] = [['render', 'render'], ['compute', 'compute'], ['video', 'video'], ['video-enhance', 'enhance'], ['copy', 'copy']]
function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; tick: number; advance: number; cap: number }) {
const engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined)
const temp = g.temp !== undefined && g.temp > 0 ? g.temp : undefined
const how = g.usage_src === 'idle' ? 'busy, from idle residency' : g.usage_src === 'busy' ? 'busy, as the driver reports it' : g.usage_src === 'clients' ? 'the busiest engine, summed over its clients' : 'this driver exposes no activity counter'
return (
<div className="t-gpu">
<div className="t-gpu-graph">
<Graph values={values} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
<div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div>
</div>
<div className="t-gpu-stats">
<div className="t-cores-head"><b>{g.name}</b><span className="spacer" />{g.driver}{g.integrated ? ' · integrated' : ''}</div>
<div className="t-core">
<span className="t-core-n">GPU</span>
<Meter pct={g.usage_src ? g.usage : 0} grad="cpu" />
<span className="t-core-p">{g.usage_src ? `${Math.round(g.usage)}%` : '—'}</span>
<span className="t-core-t">{temp !== undefined && <Meter pct={Math.min(100, temp)} grad="temp" />}</span>
<span className="t-core-c" title={temp !== undefined ? (g.temp_src === 'package' ? 'the CPU package: an integrated GPU shares the die, and this is its only sensor' : "the card's own sensor") : 'no temperature sensor on this device'}>{temp !== undefined ? `${Math.round(temp)}°C${g.temp_src === 'package' ? '*' : ''}` : ''}</span>
</div>
{engines.map(([k, label]) => (
<div className="t-core" key={k}>
<span className="t-core-n t-eng">{label}</span>
<Meter pct={g.engines![k]} grad="cpu" />
<span className="t-core-p">{Math.round(g.engines![k])}%</span>
<span className="t-core-t" /><span className="t-core-c" />
</div>
))}
<div className="t-gpu-facts">
{(g.mhz !== undefined || g.max_mhz) ? <div className="t-row t-sub"><span>Clock</span><span className="spacer" />
<span className="t-fact" title={g.req_mhz ? `${Math.round(g.req_mhz)} MHz requested${g.max_mhz ? `, ${Math.round(g.max_mhz)} MHz max` : ''}` : undefined}>{Math.round(g.mhz ?? 0)}{g.max_mhz ? ` / ${Math.round(g.max_mhz)}` : ''} MHz</span></div> : null}
{(g.power_w !== undefined && g.power_w > 0) || g.power_cap_w ? <div className="t-row t-sub"><span>Power</span><span className="spacer" />
<span className="t-fact">{(g.power_w ?? 0).toFixed(1)}{g.power_cap_w ? ` / ${Math.round(g.power_cap_w)}` : ''} W</span></div> : null}
{g.fan_rpm ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{g.fan_rpm} rpm</span></div> : null}
{g.mem_total ? <div className="t-row t-sub"><span>Mem</span><span className="t-pct">{Math.round(((g.mem_used ?? 0) / g.mem_total) * 100)}%</span><Meter pct={((g.mem_used ?? 0) / g.mem_total) * 100} grad="used" /><span className="t-amt">{bytes(g.mem_used ?? 0, 1)}</span></div>
: g.mem_used ? <div className="t-row t-sub"><span>Mem</span><span className="spacer" /><span className="t-fact" title="resident memory of the processes that have it open">{bytes(g.mem_used, 1)}</span></div> : null}
</div>
<div className="t-gpu-clients">
<div className="t-thead"><span className="c-pid">Pid:</span><span className="c-name">Program:</span><span className="c-mem">MemB</span><span className="c-cpu">Gpu%</span></div>
{g.clients.map((c) => (
<div className="t-crow" key={c.pid} title={c.engines ? Object.entries(c.engines).map(([k, v]) => `${k} ${v.toFixed(1)}%`).join(' ') : undefined}>
<span className="c-pid">{c.pid}</span><span className="c-name">{c.name}</span><span className="c-mem">{c.mem ? shortBytes(c.mem) : ''}</span><span className="c-cpu">{c.usage.toFixed(1)}</span>
</div>
))}
{g.clients.length === 0 && <div className="t-dim t-crow-none">{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden: the console has no root here'}</div>}
</div>
</div>
</div>
)
}
// One physical drive. `used` is the bytes of the filesystems on it, left out when none can
// be attributed to it — a pool member's share of a pool is not a number worth inventing.
function Disk({ name, note, size, used, io, temp, mounts, pool }: { name: string; note?: string; size: number; used?: number; io?: number; temp?: number; mounts?: string[]; pool?: string }) {