Fix both GPU readings on Apple Silicon - #5
Merged
Conversation
Neither reading was wrong in a way that looked broken, which is why they lasted: one was a plausible number that meant something else, the other pinned high. Utilization was read once per update. macOS reports "Device Utilization %" as a span between reads rather than as a level -- it covers the time since the statistic was last read, and its denominator discounts time the GPU had nothing queued -- so how often it is read changes what it says. Measured on an M4 against a load busy exactly half the time: 47% read every 10ms, 61% at 100ms, 80% at 300ms, 93% at 500ms. Read once per update it answered "did the GPU do anything since you last looked", reporting 88% for that load on a 3s interval and approaching 100% on the 10s default. Utilization is now sampled in the background at a fixed cadence and averaged over the interval, counting only readings spaced as intended, since readings taken at different spacings measure different spans. The same load now reports 78% at the default cadence and 60% at 100ms; an idle GPU and a steady load were and remain exact. New setting systemvitals.gpu.sampleintervalms (50-2000ms, default 250) trades CPU for accuracy: each read costs ~9ms of CPU, so the default spends roughly 3.5% of one core while the reading is on screen. Polling relaxes while the GPU is idle and stops when the reading is hidden. Memory led with a figure that does not track GPU memory. The status bar showed "In use system memory / Alloc system memory", which reads as used-out-of-total but is neither: holding 6GB on the GPU moved the first figure by -0.07GB while the second tracked it exactly. It now shows the allocation against total system memory, which on unified memory is the pool it is genuinely drawn from. "In use system memory" is kept in the hover as "Mapped now", named for what it is, and a discrete GPU shows the allocation with no total rather than a made-up one. Also fixed while verifying: - A Mac listing an accelerator that reports no utilization ahead of one that does showed no GPU section at all, because the parser stopped at the first statistics block and gave up when it lacked utilization. - The accelerator's name and core count are bounded to the node that supplied the statistics, so a neighbour cannot lend its name to another one's numbers. - ioreg is invoked by absolute path, so a GUI-launched VS Code with an unusual PATH cannot silently lose the GPU section. - The hover gains a peak figure, since an average hides whether the GPU was ever pegged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cost quoted for a read was measured back to back, which is not how the poller makes them. In situ each read costs about 28ms of CPU rather than 9ms: a spawn spaced hundreds of milliseconds apart re-faults and re-links instead of running warm. Measured on an M4, per read in situ, the 250ms default therefore spends around 10% of one core while the GPU is busy, not the 3.5% previously documented. That is worth paying only while there is something to measure, and the idle back-off was too timid to keep it off the bill otherwise: it relaxed by a factor of four, to 1s, which still cost 4% of a core doing nothing. An idle GPU reads zero however often it is asked, so the poller now drops to 2s instead, measured at 2% of a core over a 60s idle run. It never polls more briskly than asked for, so a deliberately slow cadence is not sped up by the GPU going idle. Utilization accuracy is unchanged: the same 47%-busy load still reports 78% at the default cadence. The README now carries the measured cadence/accuracy/cost table, including the point that 500ms and slower report 87-96% for that load and so are no better than not averaging at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The background poller was the wrong shape. Every VS Code window runs its own extension host, so it multiplied by the number of open windows: measured at 7.6%, 13.7% and 29.2% of one core for one, two and four windows. That is not a price a status bar should charge. No implementation trick rescued it. A persistent child process was measured at 18.8ms per read against 17.5ms for spawning each time, so the cost is inherent to reading the registry at intervals rather than to the spawn. What fixes it is measuring differently. "Device Utilization %" reports on the span since it was last read, so the width of that span decides accuracy -- not how much of the interval is covered. Reading twice in quick succession turns "since you last looked" into a window of known width. Each update therefore now takes one reading to re-base the statistic and then a burst of up to eight, 50ms apart, and averages those. Nothing runs between updates. This is both cheaper and more accurate than the poller it replaces. On a load busy 47% of the time the reading settles around 50%, where continuous 250ms polling reported 78%; the whole extension costs 1.5% of one core against 0.9% for main, and four windows come to 6.9% rather than 29.2%. A burst is unbiased but samples only a fraction of a second, so successive bursts are eased together to stop the figure jumping. The hover's peak is the burst's own and is not eased, so whether the GPU is busy right now is answerable on the first update. Bursts are cut short as soon as the GPU reads idle, and sized against the update interval so they cannot make an update late. Also fixed: a GPU reading that was switched off still cost a full sample every update, because isShown() gathered the statistics before asking whether the reading was wanted. It now costs nothing. The gpu.sampleintervalms setting is gone with the poller it configured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI failed on Node 22 with six tests cancelled rather than failed, which is what node:test reports when the process exits while tests are still pending. The delay between readings in a burst used an unreferenced timer, on the reasoning that a sample in flight should never hold the extension host open at shutdown. But an unreferenced timer is the only thing pending during the gap between two readings, so the event loop drains and the burst never resolves. ResMon.update() awaits every resource together with Promise.all, so this would hang the entire status bar, not merely the GPU reading. It reproduces on every Node version, not just 22 -- a sampler used with nothing else pending simply never resolves. The full suite hid it because the other tests kept the loop alive, and Node 24 happened to get away with it. The timer is now referenced. A burst is a few hundred milliseconds at most, so it cannot hold shutdown up for long, and dispose() is now checked between readings so a burst is cut short instead of running to the end. Guarded by a test that runs a sampler in a child process, where nothing else keeps the loop alive; it fails if the timer is unreferenced again. The test asserting nothing runs between samples was also asserting a constant, and now counts registry reads instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sachinsachdeva
force-pushed
the
fix/apple-silicon-gpu-readings
branch
from
August 29, 2026 12:37
c6c513b to
5634d0e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Both GPU readings were wrong on M-series Macs. Neither was wrong in a way that looked broken, which is why they lasted: one was a plausible number that meant something else, and the other pinned high.
Reproduced and measured on an Apple M4 (10 cores, 16 GB) against synthetic Metal loads with known duty cycles.
GPU usage was effectively a yes/no answer
Device Utilization %is not a level. It is a span between reads: it reports on the time since the statistic was last read, and its denominator discounts time the GPU had nothing queued. So the width of that span decides what it says. Against a load busy exactly 47% of wall time:Each update now takes one reading to re-base the statistic, then a burst of up to eight readings 50 ms apart, and averages those. Reading twice in quick succession is what turns "since you last looked" into a window of known width.
Nothing runs between updates. That is the load-bearing part of the design: every VS Code window runs its own extension host, so anything polling in the background multiplies by the number of open windows. An earlier revision of this PR did exactly that, and it was not acceptable:
Those figures are for the whole extension, not just the GPU. No implementation trick rescued the poller — a persistent child process measured 18.8 ms per read against 17.5 ms for spawning each time, so the cost is inherent to reading the registry at intervals.
Burst sampling is both cheaper and more accurate than polling, because accuracy depends on the spacing within the measurement rather than on covering the whole interval.
A burst is unbiased but samples only a fraction of a second, so successive bursts are eased together to stop the figure jumping. The hover's peak is the burst's own and is not eased, so "is it busy right now" is answerable on the first update. Bursts are cut short as soon as the GPU reads idle, and sized against the update interval so they cannot make an update late.
GPU memory led with a figure that does not track GPU memory
The status bar showed
In use system memory / Alloc system memory, which reads as used-out-of-total but is neither:6 GB of real GPU allocation moved the displayed numerator by −0.07 GB, while the denominator was not a capacity at all — it was the actual usage.
The reading now shows the allocation against total system memory, which on unified memory is the pool it is genuinely drawn from.
In use system memoryis kept in the hover as "Mapped now", named for what it is. A discrete GPU, whose VRAM the registry does not report, shows the allocation with no total rather than a fabricated one.Also fixed
isShown()gathered the statistics before asking whether the reading was wanted. It now costs nothing — verified by countingioreginvocations.ioregis invoked by absolute path, so a GUI-launched VS Code with an unusualPATHcannot silently lose the whole GPU section.Verification
Accuracy, at the shipped 10 s update interval:
On the 50% load it reported
25 → 43 → 46 → 51 → 54 → 58 → 57 → 57 → 57over 95 s, converging rather than jumping.Cost, whole extension, shipped defaults:
65 tests pass (up from 48),
npm run lintclean, and the full extension was smoke-tested through a mockedvscodemodule — all five sections render and the host exits cleanly after dispose.Known limitation
A burst samples a fraction of a second out of each update, so against genuinely intermittent work individual updates vary and the eased figure takes about three updates to settle after a change. The peak in the hover covers the immediate question. This is inherent to the only privilege-free source macOS exposes;
powermetricsand IOReport need root or native code.🤖 Generated with Claude Code