Skip to content

Fix both GPU readings on Apple Silicon - #5

Merged
sachinsachdeva merged 4 commits into
mainfrom
fix/apple-silicon-gpu-readings
Aug 29, 2026
Merged

Fix both GPU readings on Apple Silicon#5
sachinsachdeva merged 4 commits into
mainfrom
fix/apple-silicon-gpu-readings

Conversation

@sachinsachdeva

@sachinsachdeva sachinsachdeva commented Aug 28, 2026

Copy link
Copy Markdown
Owner

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:

Span between reads Reports
50 ms ~50% (the true duty cycle)
100 ms 61%
300 ms 80%
500 ms 93%
once per update (shipped) 88–100%

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:

Windows open Background poller Burst sampling (this PR)
1 7.6% of a core 2.3%
2 13.7% 3.4%
4 29.2% 6.9%

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:

baseline            inUse= 1.05 GB   alloc= 3.60 GB
after +4GB private  inUse= 1.05 GB   alloc= 7.60 GB   <- exactly +4.00
after +2GB shared   inUse= 0.98 GB   alloc= 9.60 GB   <- exactly +2.00
after release       inUse= 1.10 GB   alloc= 5.56 GB

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 memory is 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

  • A switched-off GPU reading still cost a full sample every update. isShown() gathered the statistics before asking whether the reading was wanted. It now costs nothing — verified by counting ioreg invocations.
  • A Mac listing an accelerator that reports no utilization ahead of one that does showed no GPU section at all — the parser stopped at the first statistics block and gave up if it lacked utilization.
  • Name and core count are bounded to the node that supplied the statistics, so a neighbouring accelerator can no longer 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 whole GPU section.
  • The hover gains a peak figure alongside the average.

Verification

Accuracy, at the shipped 10 s update interval:

Regime Truth main This PR
Idle 0% 0% 0.00%
Sustained load 100% ~100% 99%
50% duty cycle 47% 88–100% settles ~57%, mean 49.6%
Memory, +5 GB held +5.00 GB +0.00 GB +5.01 GB (3.55 → 8.56)

On the 50% load it reported 25 → 43 → 46 → 51 → 54 → 58 → 57 → 57 → 57 over 95 s, converging rather than jumping.

Cost, whole extension, shipped defaults:

main This PR
GPU idle 0.9% of a core 1.5%
GPU busy 0.8% 1.3%
GPU reading switched off 0.9% 0.8%

65 tests pass (up from 48), npm run lint clean, and the full extension was smoke-tested through a mocked vscode module — 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; powermetrics and IOReport need root or native code.

🤖 Generated with Claude Code

sachinsachdeva and others added 4 commits August 29, 2026 22:36
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
sachinsachdeva force-pushed the fix/apple-silicon-gpu-readings branch from c6c513b to 5634d0e Compare August 29, 2026 12:37
@sachinsachdeva
sachinsachdeva merged commit da49947 into main Aug 29, 2026
2 checks passed
@sachinsachdeva
sachinsachdeva deleted the fix/apple-silicon-gpu-readings branch August 29, 2026 12:38
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.

1 participant