Skip to content

mactop v2.1.6#75

Open
metaspartan wants to merge 26 commits into
mainfrom
development
Open

mactop v2.1.6#75
metaspartan wants to merge 26 commits into
mainfrom
development

Conversation

@metaspartan

@metaspartan metaspartan commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Note

Medium Risk
Changes core native sampling and UI metric interpretation across many Apple Silicon/OS combinations; regressions could affect displayed ANE/memory/battery values, though behavior is heavily unit-tested.

Overview
v2.1.6 improves how mactop reports ANE, memory, battery, and bandwidth when macOS 27 / M5-class hardware leaves IOReport or energy counters empty (especially without root).

The native sampler (ioreport.m) adds an IORegistry H11ANEIn power-domain duty fallback, per-die cluster metrics, exclave detection (binary ON/idle), and PMP combined ANE RD+WR bandwidth when split counters are missing. Go layers (metrics.go, app.go, info.go) route gauges, history charts, and the info panel through shared helpers so power-state tiers show ON/idle or powered/idle instead of misleading % or 0.00 W; history_soc can plot dual ANE0/ANE1 traces only when the gauge is on the same power-state tier.

Memory used now follows Activity Monitor (anonymous + wired + compressed), fixing collapsed usage when large inactive anonymous pages are present. Battery lines include instantaneous W from AppleSmartBattery. SMC decoding adds fixed-point types for fan RPM; debug dumps add fan key diagnostics. Headless mode no longer starts the display FPS counter (avoids Screen Recording prompts). Expanded unit tests cover ANE labeling and memory math.

Reviewed by Cursor Bugbot for commit 9ce10f9. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Low Risk

Overview
This PR releases mactop v2.1.6, focusing on improved Apple Silicon telemetry robustness when IOReport/PMP data is missing or misleading, particularly on macOS 27 and M5-class hardware without root privileges. Key changes include a substantially rewritten ioreport.m/ioreport.go sampling path, expanded SMC access via smc.c and battery.go (notably fan RPM), and refined ANE utilization labeling so the value reflects actual neural engine activity rather than a fallback. The TUI/metrics layer in app.go, metrics.go, and info.go was restructured to surface these new signals, with corresponding headless export updates in headless.go and CLI flag tweaks in cli.go. Comprehensive unit-test coverage was added in app_test.go, alongside a minor displayfps cleanup and a version bump in mactop.rb.

Written by Gitzilla for commit 9ce10f9. This will update automatically on new runs. Configure in the Gitzilla dashboard.

metaspartan and others added 4 commits June 14, 2026 03:19
Memory used was computed as total - (free + inactive_count). macOS keeps
a large share of anonymous app memory — e.g. MLX/LLM model weights — in
the INACTIVE queue, but those pages are dirty and not freely reclaimable.
Counting all of inactive as available made 'used' collapse toward 0
whenever a workload parked gigabytes there (gauge reading 0% at ~79% real
usage) and flicker as pages migrated between the active and inactive
queues. It wasn't chip-specific — it surfaced on whichever machine ran
that workload.

Switch to the Activity Monitor 'Memory Used' definition: App Memory
(non-purgeable anonymous, internal_page_count) + Wired + Compressed.
internal_page_count spans both the active and inactive queues, so the
reading is correct and stable regardless of how macOS ages the pages.

Reproduced on M1 Ultra with 45 GB of incompressible anonymous memory:
old formula read 53-57% and drifted; new reads a stable 77.6%, matching
top/Activity Monitor (and the reporter's Mole reading). Extracted
activityMonitorMemoryUsed with a unit test covering the inactive-anon,
purgeable>anon, and clamp cases.
On M5 Max / macOS 27 the PMP performance-floor IOReport channels that
mactop derives ANE utilization from (ANE-AF-BW / ANE-DCS-BW) return zero
channels for a non-root process, so ANE usage was stuck at a constant 0%
even under on-device inference.

Fall back to the Apple Neural Engine driver's (H11ANEIn)
IOPowerManagement.CurrentPowerState in the IORegistry (0 = idle,
1 = powered), sampled as a duty cycle across the measurement window. It
is readable without root and used only when no PMP ANE utilization
channel is present, so chips that do expose PMP keep their
higher-resolution floor-residency signal.

Because the signal is binary and system-wide (reflects any ANE use,
including macOS background ML), it is surfaced as a powered/idle state
rather than a percentage: new ANEPowered flag threaded through to the
gauge, SoC history chart, info panel, CLI, and headless JSON
(ane_powered).

docs/m5max_ane_diag/ documents the diagnosis and ships a standalone
scanner.
On chips where the AMC 'ANE RD/WR' byte counters are kernel-blocked
(M5+, and M-series Ultra/Max variants on macOS 27), PMP is the only ANE
bandwidth source — and on those chips PMP exposes ANE only as the
combined 'ANE0 RD+WR' histogram, with no separate RD/WR. The AF BW
parser explicitly skipped any channel containing 'RD+WR' (to avoid
double-counting the split channels), so those chips fell through to 0
GB/s and a 0% ANE gauge even under full Foundation Models load
(fm respond --model system) — the symptom anemll/ivanfioravanti hit on
M5 Max and M3 Ultra.

Parse the combined RD+WR histogram into a separate accumulator and use
it as the total when neither AMC nor split PMP RD/WR is available.
Source priority unchanged: AMC exact bytes > split PMP RD/WR > combined
PMP RD+WR. No read/write split is fabricated; the combined total drives
the gauge and utilization, which is what these chips need.

Validated on M1 Ultra / macOS 27: AMC path unchanged (~28 GB/s under
fm); forcing AMC off exercises the new path and reads ~16 GB/s from
ANE0 RD+WR where it previously read 0.
The earlier fix (05a180e) only suppressed the permission PROMPT when the
grant was missing — but when Screen Recording was already granted (e.g.
from prior overlay use), --headless still created the CGDisplayStream and
lit up the 'mactop is recording your screen' indicator. Reporters
running headless on a machine that had granted the permission saw screen
capture engage, which should only ever happen with --overlay.

Display FPS is the sole screen-capture consumer and a niche metric for a
cron/SSH/script tool, so stop starting the counter in headless at all.
Now only the overlay touches Screen Recording. display_fps and
frame_interval_ms are omitted from headless output (omitempty). Removes
the now-dead HasScreenRecordingAccess / hasScreenRecordingAccess helper.

Verified: with Screen Recording granted, 'mactop --headless --count 1'
now omits display_fps (no stream created); other metrics unaffected.
Copilot AI review requested due to automatic review settings June 14, 2026 20:33

Copilot AI 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.

Pull request overview

This PR updates macOS metric sourcing to better match system behavior on newer Apple Silicon / macOS versions: it aligns “Memory Used” with Activity Monitor, adjusts headless behavior to avoid any Screen Recording / CGDisplayStream activation, and improves ANE bandwidth fallback parsing when AMC counters are unavailable.

Changes:

  • Compute memory used as non‑purgeable anonymous + wired + compressed (Activity Monitor–style) via a new helper and update GetNativeMemoryMetrics to use it.
  • Extend PMP “AF BW” ANE histogram parsing to support combined channels (e.g., RD+WR) for platforms where split counters aren’t available.
  • Remove Screen Recording preflight plumbing from the headless/displayfps Go/C bridge and add a unit test for the new memory-used helper.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/app/native_stats.go Adds Activity Monitor–style memory-used helper and switches native memory metrics to use it.
internal/app/ioreport.m Updates ANE PMP “AF BW” parsing to handle combined channels when split RD/WR counters are missing.
internal/app/headless.go Ensures headless mode never starts CGDisplayStream-based FPS capture and updates comments accordingly.
internal/app/displayfps.m Removes the non-prompting Screen Recording access helper (headless no longer uses it).
internal/app/displayfps.go Removes HasScreenRecordingAccess Go wrapper and its cgo declaration.
internal/app/app_test.go Adds TestActivityMonitorMemoryUsed coverage for the new memory-used formula.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/app/ioreport.m
Comment thread internal/app/headless.go
Anemll and others added 9 commits June 14, 2026 22:33
Sample all H11ANEIn IORegistry nodes on Ultra-class chips (OR aggregate
for activity, per-die duty cycle for cluster metrics). Expose
ane_cluster_count/ane_cluster_active in headless JSON and the info panel.

On layout 19 (history_soc), render separate red ANE0/ANE1 history traces
with consistent powered/idle titles and a display stagger when both lines
would otherwise overlap at identical values.
Integrate M5 Max and Ultra dual-ANE detection with upstream v2.1.5 ANE
refactor (aneUtilizationPercent, bandwidth latch, localized history_soc
charts). Resolves merge conflicts across ioreport, metrics, and app UI.
On M5 / M5 Max the ANE is an exclave-based driver (IOExclaveProxy=Yes,
IONameMatched "ane,*exclave"). Its IOPowerManagement.CurrentPowerState is
binary and stays pinned high while any background macOS ML service uses the
ANE, so the power-state duty cycle is only meaningful as a powered/idle
indicator, never a utilization %.

Previously the power-state fallback latched bandwidth mode (watts read 0 on
this silicon), so the gauge fell through to the %-form template and rendered
the binary value as a misleading 100% in both the title and the gauge bar
label.

Detect exclave ANE at runtime and thread ANEExclave through
PowerMetrics -> SocMetrics -> CPUMetrics:
- Exclave: collapse util to 0/100, show "ANE: ON" / "idle" in the gauge
  title, bar label, and info panel; never latch bandwidth/residency modes.
- Non-exclave (e.g. M3 Ultra dual-die): unchanged dual-cluster duty-cycle
  behavior.

Detection is automatic per node, no hardcoded chip model. Adds
TestANEExclaveBinary.
The non-exclave IORegistry power-domain tier (Ultra dies on macOS 27 with
empty PMP for non-root) has no bandwidth component, so latching bandwidth
mode would mislabel it as "@ 0.00 GB/s". aneBWLabelMode already exempts
ANEPowered, so the gauge correctly shows "powered/idle (W)" instead.
sawAneUtilChannel is set only by the util-floor residency channels, not by
the combined RD+WR PMP bandwidth path (v2.1.6). So the binary power-state
fallback could override a real ANE bandwidth signal on chips with
combined-BW-but-no-util-floor (M1 Ultra, or M5 Max as root).

Gate the fallback on no util channel AND no bandwidth (AMC or PMP
RD/WR/RD+WR), and only surface aneIsExclave when the fallback actually
engages. Any working channel now keeps its real %/GB-s display; the binary
ON/idle is a strict last resort.
Remove docs/m5max_ane_diag/ (investigation scanner + raw scan output; its
rationale is in the code comments and commit history) and revert mactop.rb
to the development baseline so the change set is purely the ANE feature.
The exclave binary treatment was only applied to the ANE gauge; the
history_soc ANE chart still printed the raw percent (e.g. 100.0%). Mirror
the gauge: on exclave ANE (M5 / M5 Max) the layout-19 chart title and data
label now read ON/idle, matching the gauge instead of a misleading %.
ANE utilization on M5/M5 Max/Ultra (macOS 27): per-chip detection with non-root fallback
Comment thread mactop.rb
Comment thread internal/app/app.go

@gitzillabot gitzillabot 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.

Gitzilla has reviewed your changes and found 2 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread mactop.rb
Comment thread internal/app/app.go Outdated
Extract ANE gauge title logic into aneGaugeTitle so title/label selection (including the IORegistry power-state fallback that must not show a wattage) is unit-testable. Add shouldRenderDualANEClusters to gate plotting of per-die ANE traces so the history chart only renders dual-cluster traces when the gauge is driven by the power-state duty cycle (ANEPowered/ANEExclave), avoiding divergence when PMP/residency or bandwidth channels drive the gauge. Update renderANEHistoryChart and updateCPUGaugeTitles to use the new helpers, and add unit tests (TestANEPowerStateGaugeTitle, TestANEDualClusterChartGatesOnPowerStateTier). Also add strings import for tests.
Comment thread internal/app/app.go

@gitzillabot gitzillabot 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.

Gitzilla has reviewed your changes and found 4 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread internal/app/app.go
Comment thread internal/app/app.go
Comment thread internal/app/app.go
Comment thread mactop.rb
Introduce aneChartTitle to centralize ANE history chart title logic and handle ANEExclave and ANEPowered (power-state) fallbacks so titles don't incorrectly show wattage like "0.00W". Refactor renderANEHistoryChart to use the new helper and broaden the power-state condition to include ANEPowered && !bwMode. Add TestANEPowerStateChartTitle to verify powered/idle wording is used (no wattage or percentage) and that normal ANE samples still render wattage where appropriate. This prevents a regression where power-state samples were routed through wattage-bearing templates.
Comment thread internal/app/info.go Outdated
Replace the previous len(lastCPUMetrics.ANEClusterActive) > 1 check with shouldRenderDualANEClusters(lastCPUMetrics) and add a comment explaining the rationale. This ensures per-cluster ANE status is only shown when the ANE power-state tier is appropriate (ANEPowered/ANEExclave) to avoid showing IORegistry duty-cycle values that would diverge from residency % on multi-die chips with PMP residency. Keeps the existing fallback logic for cluster count.
Comment thread internal/app/app.go
Comment thread internal/app/app.go Outdated

@gitzillabot gitzillabot 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.

Gitzilla has reviewed your changes and found 1 potential issue.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread internal/app/app_test.go
metaspartan and others added 3 commits June 18, 2026 14:17
Introduce aneClusterLabelMode and aneClusterLabelModeFor to centralize ANE label wording (percent / powered / exclave). Move the ANEPowered power-state branch ahead of the compact-layout wattage branch in aneGaugeTitle to avoid rendering contradictory “0.0W” on IORegistry-powered fallbacks. Update formatDualANEClusterStatus and formatDualANEClusterChartText to accept the label mode and produce consistent ON/idle, powered/idle or percentage wording. Wire the mode through renderANEHistoryChart and the info panel. Add unit tests to guard the compact power-state title and exclave dual-cluster labels.
Reads Amperage (mA, signed) and Voltage (mV) directly from the
AppleSmartBattery IOKit registry entry — IOPSGetPowerSourceDescription
does not reliably expose these keys on Apple Silicon. The product
(mA × mV / 1000) gives milliwatts; positive = charging, negative =
discharging. The absolute wattage is appended to the battery state
label, e.g. "Battery: 72% (charging, 25.9 W)" or
"Battery: 60% (on battery, 12.4 W)". No wattage is shown when the
value is zero (desktop Macs, or battery full on AC).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Show instantaneous charging/discharging watts in power panel
Comment thread internal/app/app.go
Comment thread internal/app/ioreport.m
Cursor review of #77: on the ANEPowered IORegistry fallback the gauge
title reads powered/idle but the inner bar label still showed 'NN%'
(only ANEExclave overrode it), mixing a binary title with a percentage.
Extract aneGaugeInnerLabel (mirrors aneGaugeTitle's power-state
branches) so exclave -> ON/idle and ANEPowered -> powered/idle, with a
unit test (TestANEGaugeInnerLabel).

Cursor's second finding (matching-dict leak in collectAneServices) is a
false positive: IOServiceGetMatchingServices consumes one reference to
the dictionary on all paths (macOS 10.5+), matching the four other call
sites in ioreport.m — releasing it would be an over-release. No change.

The merged ANE PRs (#76/#77 + follow-ups) left five functions over the
gocyclo>15 limit, so 'make sexy' was failing. Restore it with
behavior-preserving extractions: aneBandwidthUtilization (metrics.go),
aneClusterActiveSlice (ioreport.go), renderDualANEClusterChart +
renderPowerStateANEChart + aneGaugeInnerLabel (app.go),
insertANEClusterLine + buildFanLines (info.go), and collapse
formatDualANEClusterStatus's three identical switches into a word
select. Full gate green; all ANE tests pass; M1 Ultra gauge unchanged.
go fmt (part of make sexy) reformats struct-field and var-block column
alignment that the PR #76/#77 merges left inconsistent. Formatting only,
no behavior change.
Fan speed read 0 RPM on some Macs (reported on Mac Studio M2 Ultra)
because SMCGetFloatValue only decoded 'flt ' and 'ui8 ', returning 0.0
for every other type. The fan RPM key F<n>Ac is 'flt' on this M1 Ultra
(reads fine) but other Macs expose it as a fixed-point or wide-integer
type, which fell through to 0.

Decode the full set of SMC numeric encodings: flt, ui8/ui16/ui32 and
si8/si16 (big-endian), and the 'fpXY'/'spXY' fixed-point family (raw
big-endian 16-bit scaled by 2^-frac, the last type char = fractional
bits) — covering fan RPM ('fpe2', /4) and fixed-point temps ('sp78',
/256). Purely additive: flt/ui8 behave identically (M1 Ultra fans still
read ~3500 RPM, no regression).

Also add an SMC fan-key section to --dump-temps (key, FourCC type,
decoded value) so 'fan reads 0' reports are self-diagnosable. Validated
the new path on real data: F0CR (ui16) now decodes to 38400 where it
previously read 0.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a8554ec. Configure here.

Comment thread internal/app/ioreport.m
collectAneServices stopped at MAX_ANE_SERVICES via the loop condition,
but the && short-circuit had already called IOIteratorNext (returning a
+1-refed entry) before the count check failed — leaking that one entry.
Only reachable on a chip with more H11ANEIn nodes than the cap of 4 (no
current Mac: max 2 ANE dies), but a valid latent leak. Drain the full
iterator, releasing entries beyond the cap; stored entries are still
released by the caller.

@gitzillabot gitzillabot 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.

Gitzilla has reviewed your changes and found 1 potential issue.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread mactop.rb
@SuhasSrinivasan

SuhasSrinivasan commented Jun 24, 2026

Copy link
Copy Markdown

I tested it specifically against the fan-RPM regression in #78 on the affected hardware.
The PR does not resolve the 0 RPM readout on my M2 Ultra — because on this machine the fan keys aren't fixed-point.

Setup

Mac Studio, Apple M2 Ultra; macOS 26.5.1 (25F80)
Built development @ 2ea5238 with Go 1.26.4 (go build -o mactop-dev main.go)
make test (full internal/app + internal/i18n suite) → green
Side-by-side baseline: brew mactop v2.1.5

Steps & results
Headless JSON (--headless --format json) on both v2.1.5 and the v2.1.6 build → byte-identical fan output: rpm: 0, target_rpm: 0, min_rpm: 1000, max_rpm: 3500 for both fans.
New --dump-temps fan diagnostic, idle and under sustained 24-thread CPU load:

F0Ac  type=flt   value=0.00     ← actual RPM
F0Mn  type=flt   value=1000.00
F0Mx  type=flt   value=3500.00
F0Tg  type=flt   value=0.00
F0CR  type=ui16  value=38400.00
FNum  type=ui8   value=2.00

The new SMCGetFloatValue path adds fixed-point (fpe2/spXY) decoding, which fixes machines where F*Ac was a fixed-point type previously decoding to 0. On my M2 Ultra, F0Ac is already flt — handled identically by the old and new decoder — and the SMC returns a genuine 0.00. So this is a different failure mode than the one the patch addresses: the value is missing at the source, not mis-decoded. F0Mn/F0Mx (also flt ) decode correctly, confirming SMC access works.

Fan RPM reads 0 on M2 Ultra (issue #78) even though TG Pro shows it.
Root cause is not decoding: F0Ac is type flt and reads 0, while F0Mn/Mx
read fine. Apple Silicon fan tach SMC keys are near-arbitrary
per-generation FourCCs (iStat/TG Pro keep a hand-curated per-model map;
Asahi's macsmc reads the key from the device tree) — F0Ac is the tach on
M1 Ultra but an empty/vestigial key on M2 Ultra, whose real RPM is under
a different key that can't be guessed.

Add a candidate scanner to --dump-temps that prints every SMC key whose
decoded value is in a plausible fan-RPM range, so the actual-RPM key on
an affected model can be identified (by matching another tool's reading)
and then mapped in. Diagnostic only; no behavior change to fan reads.
Enhance dumpSMCFanKeys to surface read result and raw bytes for SMC fan keys. Previously the function printed only the declared type and SMCGetFloatValue, which is ambiguous because SMCGetFloatValue returns 0.0 for both real zero and failed reads. The new code initializes key info, calls SMCReadKey to get a result code and raw bytes, prints a clear read-failed line with the result code, or prints type, size, raw hex and decoded value on success. This makes it easier to distinguish unreadable tach keys (e.g. privileged-only reads on M2 Ultra) from genuine 0 RPM readings.
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.

4 participants