Skip to content

Linux: read GPU usage from drm-cycles when there is no ns counter - #2069

Open
bogdanr wants to merge 1 commit into
htop-dev:mainfrom
bogdanr:linux-gpu-drm-cycles
Open

Linux: read GPU usage from drm-cycles when there is no ns counter#2069
bogdanr wants to merge 1 commit into
htop-dev:mainfrom
bogdanr:linux-gpu-drm-cycles

Conversation

@bogdanr

@bogdanr bogdanr commented Aug 14, 2026

Copy link
Copy Markdown

On my Lunar Lake laptop (Arc 140V, xe driver) the GPU meter and the GPU columns
are stuck at 0.0% no matter what the GPU is doing, while nvtop and gputop show
it pegged.

The reason is that drm-usage-stats
allows two ways of reporting engine utilisation:

  • drm-engine-<engine>: <uint> ns - busy time in nanoseconds
  • drm-cycles-<engine> plus drm-total-cycles-<engine> - busy cycles and
    elapsed cycles, whose ratio is the utilisation

We only parse the first one, and the Xe driver only implements the second one.
There is not a single drm-engine-* line to be found:

$ grep drm- /proc/$(pidof glxgears)/fdinfo/*
drm-driver:xe
drm-client-id:607
drm-pdev:0000:00:02.0
...
drm-cycles-rcs:29821070
drm-total-cycles-rcs:5044293186233
drm-cycles-ccs:571
drm-total-cycles-ccs:5044293186233
...

Since Xe is what recent Intel hardware gets by default, this will affect more
and more machines. intel_gpu_top has the same problem, it refuses to start and
tells you to use gputop instead.

What the patch does

GPU.c now also parses drm-cycles-* and drm-total-cycles-*, reusing the
existing client-id/pdev deduplication. Utilisation is Δcycles / Δtotal-cycles,
scaled with the sampling interval so it can be added up together with the
nanosecond values everywhere else. Doing it as a ratio means there is no need to
know the GPU clock, which is good, because on this machine it swings between
400 MHz and 1950 MHz while sampling.

drm-total-cycles is a device wide free running counter that every client
repeats, so it is aggregated with max instead of sum, both per process and per
engine.

Because the raw counters are no longer always nanoseconds, LinuxProcess keeps
them separately (gpu_timeRaw, gpu_cycles, gpu_totalCycles) and gpu_time
accumulates the observed busy time. For drm-engine-* drivers the accumulated
value comes out the same as the old absolute one, except it no longer goes
backwards when a client closes its fds.

Two things in Platform.c that got in the way and are fixed here as well: the
meter residue is now derived from the per engine deltas instead of a second
absolute counter, and the first sample is skipped because there is no interval
to divide by yet. That first sample used to display things like 20679d of GPU
time.

I also pulled the line parsing and the duplicate client bookkeeping into two
small helpers, they were about to be written a third time.

Testing

One machine per reporting style.

Arc 140V, xe, cycle counters, compared against gputop from
igt-gpu-tools:

workload                    htop GPU%   gputop
glxgears (rcs)               46.9 %     47.1 %
compute benchmark (ccs)      99.9 %    100.0 %
idle                          0.0 %      0.0 %

With glxgears running, the meter total of 85.6-86.4 % matches gputop adding up
its clients for the same period (Xorg 40.3 % + glxgears 45.3 % + picom 1.7 %).

Radeon 8060S (Strix Halo), amdgpu, nanosecond counters, to check that the
existing path did not move. Two glmark2 processes rendering off-screen at
1920x1080, gpu_busy_percent pinned at 100 %, one sample per second, this
branch next to the same tree without the patch:

this branch   96.6 101.4  99.2 100.4 101.4  98.6  99.5 100.6  99.7 101.9  98.0 ...
main          96.7 100.9  99.9  99.6 102.0  97.8 100.8  99.5  99.7 102.6  97.9 ...

Per process both glmark2 clients read ~49 % in either build, while an
independent aggregation of the fdinfo counters says 50.08 % and 49.92 %. Killing
the load gives a flat 0.00 % for as long as I watched it.

Builds clean with -Wall -Wextra on both machines, and with --enable-debug,
where no assertion fired during the runs. Still untested on NVIDIA.

Two things I noticed but did not touch

GPUMeter_engineData holds four engines while Xe exposes five (rcs, vcs,
vecs, bcs, ccs), so the fifth one always lands in the residue. On this
laptop compute work goes to ccs, which means the named bars stay empty while
the total is right. Bumping the array is easy, but it needs a fifth colour and I
did not want to mix that in here.

A process with fds on two devices whose drm-total-cycles counters differ will
be slightly off, as only one reference counter is kept per process. That is the
same kind of conflation as #1657.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

GPU accounting parses DRM timestamp, busy-cycle, and total-cycle metrics through shared helpers. It filters duplicate client sections and persists raw counters between scans. Platform_setGPUValues uses saturating deltas and cycle ratios to calculate engine and total GPU usage. GPU engine counters reset after each sampling interval.

Poem

Counters wake at scan of day,
Cycles mark the busy way.
Deltas flow through guarded streams,
Engines measure GPU dreams.
Raw values rest, then rise anew.

Merge Risk: 🟡 Moderate · up to 85873

This change enables GPU utilization reporting from DRM cycle counters, but malformed or rejected counters can still produce incorrect one-sample GPU percentages, and processes using multiple DRM devices may receive an inaccurate combined ratio. Merge should wait for these bounded reporting-correctness issues to be fixed or explicitly accepted.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8dc9927e-4b62-4b2f-bfd4-9886da69682f

📥 Commits

Reviewing files that changed from the base of the PR and between 8da1d45 and bfad1a2.

📒 Files selected for processing (5)
  • linux/GPU.c
  • linux/LinuxMachine.h
  • linux/LinuxProcess.h
  • linux/LinuxProcessTable.c
  • linux/Platform.c

Comment thread linux/GPU.c
Comment thread linux/GPU.c
Comment thread linux/GPU.c Outdated
@bogdanr
bogdanr force-pushed the linux-gpu-drm-cycles branch from bfad1a2 to d1ba2f3 Compare August 14, 2026 15:53
@bogdanr

bogdanr commented Aug 14, 2026

Copy link
Copy Markdown
Author

Force pushed one fixup after getting hold of an AMD machine (Strix Halo,
amdgpu), which reports nanoseconds, so it exercises the path I could not test
before.

It found a bug in the first version. GPU_readProcessData() only rescans a
process every 5s unless it is marked active, and I had moved that marking behind
"the busy counter moved since the last sample". Processes that own a DRM client
but are momentarily idle then dropped out of the scan, and since the machine wide
totals are summed from the cumulative counters of the processes visited in a
pass, the sum collapsed and came back:

GPUDBG dt=1031ms total=100.12%
GPUDBG dt=1031ms total=14580.86%   <- idle client rejoins, its whole history counted
GPUDBG dt=1048ms total=0.00%       <- and drops out again
GPUDBG dt=1030ms total=99.57%

Marking a process active whenever it has a counter at all, as on master, keeps
it in the scan and the totals steady. It is only visible on drm-engine-*
hardware, because there you have long lived clients sitting on a large
cumulative time; on Xe the ratio resets every sample.

Numbers before and after are in the description, the two builds now agree sample
for sample.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
linux/GPU.c (2)

48-54: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Keep device identity when aggregating total-cycle counters.

get_machine_gpu_engine() keys counters by engine only. The maximum rule is valid only for reports from the same pdev. If two devices expose the same engine name, such as rcs0, Lines 95-96 merge their device-wide totals and discard one denominator.

Retain pdev in the aggregation identity. Calculate each device ratio separately before combining device activity. The PR objective defines total-cycle counters as device-wide.

Also applies to: 92-96, 275-277


48-135: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use module-prefixed names for the new helpers.

Rename get_machine_gpu_engine, update_machine_gpu, update_machine_gpu_cycles, update_machine_gpu_total_cycles, count_section, and parse_engine_value to the GPU_functionName() convention.

As per coding guidelines: **/*.c: “Use ModuleName_functionName() naming convention for functions.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 505b996b-7022-4d1b-b92a-04948be265f3

📥 Commits

Reviewing files that changed from the base of the PR and between bfad1a2 and d1ba2f3.

📒 Files selected for processing (1)
  • linux/GPU.c

@bogdanr
bogdanr force-pushed the linux-gpu-drm-cycles branch from d1ba2f3 to a9b5ab0 Compare August 15, 2026 18:54
@bogdanr

bogdanr commented Aug 15, 2026

Copy link
Copy Markdown
Author

Two of the three comments are worth fixing, so I did both in a9b5ab0.

Raw counters on the error path.
Non-numeric values.

Consolidating the three branches. - Leaving this one. They look alike, but the
aggregation rules genuinely differ: engine- sums, cycles- sums,
total-cycles- takes a maximum because it is a device wide counter that every
client repeats. The shared five lines are already behind parse_engine_value()
and count_section(); folding the rest into a callback or a table would make
the difference between the three harder to see, not easier.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2f92a365-a8f0-49c5-8f17-523906bb6a85

📥 Commits

Reviewing files that changed from the base of the PR and between d1ba2f3 and a9b5ab0.

📒 Files selected for processing (1)
  • linux/GPU.c

Comment thread linux/GPU.c
}

static void update_machine_gpu(LinuxProcessTable* lpt, unsigned long long int time, const char* engine, size_t engine_len) {
static GPUEngineData* get_machine_gpu_engine(LinuxProcessTable* lpt, const char* engine, size_t engine_len) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the required GPU_ function naming convention.

The new helper names use snake_case. Rename them to GPU_<functionName> names and update their local call sites. For example, use GPU_getMachineGpuEngine() instead of get_machine_gpu_engine().

As per coding guidelines: "**/*.c: Use ModuleName_functionName() naming convention for functions."

Also applies to: 79-79, 86-86, 92-92, 99-99, 114-115

Source: Coding guidelines

Comment thread linux/GPU.c
Drivers may report engine usage either as a busy time in nanoseconds
(drm-engine-<engine>), or as busy cycles plus the cycles elapsed on that
engine (drm-cycles-<engine> and drm-total-cycles-<engine>). We only ever
handled the first form.

Intel's Xe driver, which took over from i915 on recent hardware, only
implements the second one. On a Lunar Lake laptop the GPU meter and the
GPU_PERCENT/GPU_TIME columns therefore sit at 0.0% while nvtop and
gputop happily show the GPU pegged.

So take the ratio of busy to elapsed cycles and scale it with the
sampling interval, which lets it be accounted just like the nanosecond
values. Nice side effect: the ratio doesn't depend on the wall clock,
and the GPU frequency moves around a lot. drm-total-cycles is a device
wide counter that every client repeats, so take the maximum of it
instead of summing it up.

While here, compute the meter residue from the per engine deltas instead
of keeping a second absolute counter around, and skip the very first
sample, where there is no interval to divide by yet - it used to report
nonsense such as "20679d" of total GPU time in the first frame.
@bogdanr
bogdanr force-pushed the linux-gpu-drm-cycles branch from a9b5ab0 to 85873cb Compare August 15, 2026 18:58
@bogdanr

bogdanr commented Aug 15, 2026

Copy link
Copy Markdown
Author

Braces added in 85873cb - the styleguide is explicit that if one block of an
if/else has them, all of them do, and I am touching that statement anyway.
(Master gets away without them there, but no reason to keep that going.)

Not doing the rename to GPU_*. The two helpers that were already in this file,
is_duplicate_client() and update_machine_gpu(), are snake_case, and the new
ones were written to match them. Renaming only the new ones would leave the file
half and half, which is worse than either convention on its own, and renaming
all of them is churn that does not belong in a bug fix. Happy to send a separate
patch that converts the whole file if a maintainer wants it.

@bogdanr

bogdanr commented Aug 15, 2026

Copy link
Copy Markdown
Author

The AMD machine came back, so the drm-engine-* half is re-verified on
85873cb as well: Radeon 8060S / amdgpu, two off-screen glmark2 runs holding
gpu_busy_percent at 100, meter over eleven samples 97.03 / 101.04 / 99.52 /
100.13 / 101.12 / 98.72 / 100.07 / 100.01 / 99.57 / 102.42 / 97.60 %, and flat
0.00 % once the load is gone. Same picture as before the review fixes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7fa41188-abd5-4467-ba6b-4866a626c2d1

📥 Commits

Reviewing files that changed from the base of the PR and between a9b5ab0 and 85873cb.

📒 Files selected for processing (1)
  • linux/GPU.c

Comment thread linux/GPU.c
Comment on lines +118 to +120
const char* delim = strchr(engineStart, ':');
if (!delim)
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject an empty engine name.

parse_engine_value accepts drm-engine-: 1 ns because delim == engineStart is valid. The caller then creates and updates an engine with an empty key. Reject this malformed metric.

Proposed fix
-   if (!delim)
+   if (!delim || delim == engineStart)
       return false;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const char* delim = strchr(engineStart, ':');
if (!delim)
return false;
const char* delim = strchr(engineStart, ':');
if (!delim || delim == engineStart)
return false;

Comment thread linux/GPU.c
Comment on lines +250 to +285
if (parse_engine_value(line, "engine-", " ns", &engine, &engine_len, &value)) {
if (count_section(&sstate, client_id, pdev, parsed_ids)) {
new_gpu_time += value;
update_machine_gpu(lpt, value, engine, engine_len);
}
}
} else if (line[0] == 'c' && String_startsWith(line, "cycles-")) {
/* Drivers that cannot provide a nanosecond resolution timestamp
* (e.g. Intel Xe) export the busy cycles of an engine together with
* the cycles elapsed on that engine. */
if (sstate == SECST_DUPLICATE)
continue;

char* endptr;
errno = 0;
unsigned long long int value = strtoull(delim + 1, &endptr, 10);
if (errno == 0 && String_startsWith(endptr, " ns")) {
if (sstate == SECST_UNKNOWN) {
if (client_id != INVALID_CLIENT_ID && !is_duplicate_client(parsed_ids, client_id, pdev))
sstate = SECST_NEW;
else
sstate = SECST_DUPLICATE;
const char* engine;
size_t engine_len;
unsigned long long int value;
if (parse_engine_value(line, "cycles-", "", &engine, &engine_len, &value)) {
if (count_section(&sstate, client_id, pdev, parsed_ids)) {
new_gpu_cycles += value;
update_machine_gpu_cycles(lpt, value, engine, engine_len);
}
}
} else if (line[0] == 't' && String_startsWith(line, "total-cycles-")) {
if (sstate == SECST_DUPLICATE)
continue;

if (sstate == SECST_NEW) {
new_gpu_time += value;
update_machine_gpu(lpt, value, engineStart, delim - engineStart);
const char* engine;
size_t engine_len;
unsigned long long int value;
if (parse_engine_value(line, "total-cycles-", "", &engine, &engine_len, &value)) {
if (count_section(&sstate, client_id, pdev, parsed_ids)) {
/* The same free running counter is reported for all engines
* of a device, so don't accumulate it. */
if (value > new_gpu_totalCycles)
new_gpu_totalCycles = value;
update_machine_gpu_total_cycles(lpt, value, engine, engine_len);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep raw baselines after a rejected metric.

If parse_engine_value rejects a matching metric, its accumulator remains zero or partial. Lines 332-334 then replace the prior raw baseline. The next valid absolute counter can be reported as a full-uptime delta in one scan.

Track parse success per counter family. If a matching metric is rejected, retain that family’s previous raw counter and clear gpu_percent for the scan.

Also applies to: 332-334

Comment thread linux/GPU.c
Comment on lines +309 to +317
uint64_t monotonicTimeDelta = host->monotonicMs - host->prevMonotonicMs;
unsigned long long int gputimeDelta = saturatingSub(new_gpu_time, lp->gpu_timeRaw);

/* Cycle based accounting only yields a ratio of busy to elapsed cycles,
* which is turned into a busy time using the sampling interval. */
unsigned long long int cyclesDelta = saturatingSub(new_gpu_cycles, lp->gpu_cycles);
unsigned long long int totalCyclesDelta = lp->gpu_totalCycles ? saturatingSub(new_gpu_totalCycles, lp->gpu_totalCycles) : 0;
if (cyclesDelta > 0 && totalCyclesDelta > 0)
gputimeDelta += (unsigned long long int)((double)cyclesDelta / totalCyclesDelta * monotonicTimeDelta * (1000 * 1000));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the raw GPU counter fields and process initialization paths.
ast-grep outline linux/LinuxProcess.h --items all --view expanded
fd -t f '^LinuxProcess\.(c|h)$' linux | xargs -r rg -n -C 4 \
  '\bgpu_timeRaw\b|\bgpu_cycles\b|\bgpu_totalCycles\b|GPU_readProcessData'

Repository: htop-dev/htop

Length of output: 3142


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Trace LinuxProcess allocation and all assignments to the GPU raw fields.
fd -t f . linux | sort | xargs -r rg -n -C 5 \
  'LinuxProcess_new|gpu_timeRaw|gpu_cycles|gpu_totalCycles|xCalloc|calloc|memset'

Repository: htop-dev/htop

Length of output: 14396


Skip delta accounting on the first GPU sample.

LinuxProcess_new() zero-initializes gpu_timeRaw, so the first sample subtracts zero from the absolute new_gpu_time counter and reports lifetime GPU time as interval usage. Add explicit baseline state and skip delta calculation until the raw counters are initialized.

@ravi-arnan ravi-arnan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have covered xe and amdgpu. I have an Alder Lake-P / Iris Xe laptop on
i915, which is a third driver on the drm-engine-* nanosecond path, so I
built the branch and exercised that half.

No regression there. Clean build, no new warnings. With vblank_mode=0 glxgears
under load, both upstream main and this branch report non-zero,
similar-magnitude GPU% and a monotonically increasing GPU_TIME for the same
process:

main     18.1  17.9  28.2  36.8  21.1  17.8  ...
pr2069   15.7  21.1  19.8  21.5  16.0  ...

(Samples are not aligned and the load is not steady, so read that as shape, not
as a numeric comparison.)

One thing worth recording for anyone else trying to check the numbers from
outside htop: a naive sum over /proc/<pid>/fdinfo/* multiplies the result. My
first cross-check script read roughly four times the real utilisation, because
glxgears holds four fds that are all the same DRM client:

/proc/522302/fdinfo/{4,5,6,7}
drm-client-id:  135        (identical in all four)
drm-pdev:       0000:00:02.0
drm-engine-render: 33641346752 ns / 33641937680 / 33642722984 / 33643895844

That is exactly what is_duplicate_client() already handles, so htop is right
and the script was wrong, but it means any external verification has to dedupe
on (drm-pdev, drm-client-id) first.

Two things in the diff looked risky enough to check, and both are fine. Noting
them so nobody else has to re-derive them:

  • parse_engine_value() with an empty unit requires the number to be the last
    thing on the line (*endptr != '\0'). That holds only because lines come from
    strsep(&buf, "\n") and therefore carry no trailing newline. It is correct as
    written, but it is a non-local assumption; a comment on the empty-unit case
    would keep a future change to the line splitting from breaking the cycles path
    silently.
  • lp->gpu_time changes meaning, from "last raw counter value" to "accumulated
    busy time". The displayed GPU_TIME survives that, because on the first sample
    saturatingSub(new_gpu_time, 0) is the whole lifetime counter, so the
    accumulator starts from the same place the old raw assignment did.

One actual question, on the cycles branch:

gputimeDelta += (unsigned long long int)((double)cyclesDelta / totalCyclesDelta * monotonicTimeDelta * (1000 * 1000));

monotonicTimeDelta is the global sampling interval, but a process can have
been skipped for up to 5 seconds by the activity check at the top of the
function. On the ns path that skew is visible: the delta covers 5 seconds of
busy time and is divided by one interval, so the percentage overshoots. On the
cycles path it is silently smoothed instead, since the ratio is already
normalised over whatever period the counters covered, and it is then scaled by a
single interval. So the two paths disagree in that corner. Is that intended? I
can see the argument that the cycles behaviour is the more correct of the two,
in which case the ns path is the one worth revisiting later.

I cannot test the drm-cycles-* half here, since i915 only exports
drm-engine-*.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants