Performance monitoring CLI tool for Apple Silicon
Project description
actop
Watch your Apple Silicon Mac the way it actually works — and profile your own workloads from Python.
actop is a sudoless, in-process performance monitor for M1–M4 Macs: a real-time
TUI for CPU/GPU/ANE utilization, per-core frequency, memory bandwidth, power,
and thermals — plus a Python API (Monitor / Profiler, to_pandas()) so you
can instrument your own local LLM / MLX / CoreML inference and training runs with
SoC-accurate power and energy context.
Above: actop live under a local LLM run — the stack layout with braille then block sparklines, then the process panel (t) with its watt-attributed PWR column. Below: the default two-column grid layout (l cycles between them).
Who it's for
- Running LLMs locally (MLX, llama.cpp, Ollama) and want to see whether you're GPU-bound, memory-bandwidth-bound, or leaving the ANE idle — at a glance.
- Profiling your own code: wrap a workload in
Monitor/Profiler, get a pandas frame of power, frequency, residency, and cumulative session energy. - Just want a clean
*topfor Apple Silicon that needs nosudo.
Install in one line — Homebrew or uv — then run actop.
Background
actop is an independent project with its own idea, architecture, codebase, and release cycle. It was inspired by tlkh/asitop — built to fill the gaps that tool left for whole-chip, sudoless, programmable monitoring. The name carries the Unix *top lineage: actop (Apple Chip top) follows asitop (Apple Silicon top), and covers the whole SoC — CPU, GPU, ANE, power, memory bandwidth, and thermal.
The original asitop shells out to Apple's powermetrics CLI, a high-level tool that requires sudo, writes to temp files, and returns pre-aggregated metrics at a fixed cadence. actop instead calls the underlying IOReport C library directly via Python ctypes — the same library that powermetrics itself uses internally. This low-level approach runs unprivileged, avoids subprocess and file I/O overhead, gives access to raw per-core residency states and energy counters, and lets the application control its own sampling interval and delta computation.
Why another *top? mactop (Go) and macmon (Rust) are excellent sudoless TUIs — mactop the broadest in features, macmon the leanest binary. actop's reason to exist is different: it is the programmable, Python-native one. A public API (Monitor / Profiler, to_pandas()) lets you instrument and profile your own workloads — local LLM / CoreML / MLX inference, training loops — from Python, with SoC-accurate power context and cumulative session energy. It's the data scientist's profiler, not just a dashboard — and unlike the sudo-bound asitop that inspired it, it needs no sudo. See Where actop fits for the head-to-head.
Key Features
- Textual TUI dashboard: custom
BrailleChartsparklines for E-CPU, P-CPU, GPU, ANE, RAM, and power — rendered on the Textual framework. Four titled sections (CPU, GPU·ANE, Memory, Power) in two switchable layout presets —grid(two columns, fits short terminals) andstack(single full-width column), cycled live withl. Supportsdots(braille) andblockglyph styles, and selectable color palettes (--palette:thermal, colorblind-safeviridis, grayscalemono) on top of adaptive truecolor/256/16-color/NO_COLORdegradation. Resizes cleanly; no raw ANSI escape sequences. - In-process IOReport sampling: reads Apple Silicon power, frequency, and residency metrics via Python ctypes bindings to
libIOReport.dyliband CoreFoundation. No subprocesses, no temp files. - Per-core visibility: per-core panels on by default; toggle with
--no-show_coresfor a cluster-level view. - Diagnosis-oriented alerts: configurable sustained-sample thresholds for thermal pressure, bandwidth saturation, swap growth, and package power. Active alerts are shown inline in the status line.
- Process monitoring (optional): top CPU/RSS processes panel is off by default. Enable at launch with
--show-processesor presstin the TUI. Regex filtering is available via--proc-filteror/interactively. - Profile-aware power scaling:
profilemode (default) scales charts against the SoC's known reference wattage for stable cross-session comparison;automode scales against rolling peak. - SoC compatibility: 16 built-in M1–M4 profiles (base, Pro, Max, Ultra). Unknown future chips fall back to tier-based defaults using the latest generation's reference values.
- CPU/GPU temperature: reads die temperatures from the Apple SMC (System Management Controller) via IOKit ctypes. Displayed inline in gauge titles (e.g. "P-CPU Usage: 12% @ 3504 MHz (58°C)"). No sudo required.
Where actop fits
How the sudoless, in-process field stacks up:
| actop | mactop | macmon | |
|---|---|---|---|
| Unprivileged, in-process (no sudo) | ✅ | ✅ | ✅ |
| CPU/GPU/ANE power · temps · bandwidth | ✅ | ✅ | ✅ |
Per-process power/energy attribution (PWR column) |
✅ | — | — |
Bandwidth % of SoC peak + MEM-BOUND saturation alert |
✅ | — | — |
Throttle-state indicator (THROTTLING:CPU/GPU) |
✅ | — | — |
| DVFS P-state residency distribution (per-cluster) | ✅ | — | — |
Python API (Monitor/Profiler, to_pandas()) |
✅ | — | — |
| SoC-accurate power scaling (M1–M4 profiles) | ✅ | rolling peak | rolling peak |
| Session energy (∫ package power) | ✅ | — | — |
| Net/disk I/O · fan RPM · menu bar | — | ✅ | fan only |
For the broadest TUI and DevOps feature set (network/disk I/O, a menu-bar app, more export formats), use mactop; for the leanest single Rust binary, macmon. Full head-to-head: docs/REVIEW-architecture-comparison.md.
Installation
Homebrew (recommended)
brew tap binlecode/actop
brew install binlecode/actop/actop
The tap name binlecode/actop resolves to the formula repo
binlecode/homebrew-actop (Homebrew's
user/repo → github.com/user/homebrew-repo convention). The formula is
self-contained: it depends on Homebrew's python@3.13 and installs actop into its
own isolated libexec virtualenv — it does not use (or interfere with) the macOS
system Python.
Upgrade / uninstall:
brew upgrade binlecode/actop/actop
brew uninstall binlecode/actop/actop && brew untap binlecode/actop
uv (recommended for non-Homebrew users)
uv installs actop into a sandboxed, per-tool
environment with its own managed CPython — no system Python required and no
interpreter drift:
uv tool install git+https://github.com/binlecode/actop.git
Upgrade / uninstall:
uv tool upgrade actop
uv tool uninstall actop
pip
pip install git+https://github.com/binlecode/actop.git
Quick Start
actop # full dashboard with per-core panels, profile power scaling
actop --interval 1 --avg 10 # faster refresh, shorter rolling window
actop --show-processes # include top process panel at startup
actop --proc-filter "python|ollama|vllm|docker|mlx" # filter process panel at launch
actop --no-show_cores # cluster-level view without per-core panels
actop --chart-glyph block # square block chart glyphs
actop --layout stack # single full-width scrolling column (default is grid)
actop --json # stream NDJSON metrics to stdout (no TUI)
actop --serve 9095 # serve Prometheus metrics at :9095/metrics (no TUI)
Interactive keys: p pause · s cycle sort (CPU%→RSS→PID) · g toggle chart glyph (dots/block) · l cycle layout (grid⇄stack) · t toggle process panel · / filter processes · ? help overlay · q quit
Python API
Wrap any workload to get a pandas frame of power/frequency/residency/energy — no TUI needed:
from actop import Profiler
with Profiler() as p:
run_my_inference()
df = p.to_pandas() # rows = samples; cols = power/freq/residency/energy
to_pandas() needs the pandas extra: pip install "actop[pandas]". For a single point-in-time reading instead of a background collector, use Monitor().get_snapshot().
Per-process data is opt-in (off by default, so metrics-only callers pay no process-enumeration cost). Enable it to get the same watt-attributed processes the TUI shows:
from actop import Monitor
with Monitor(include_processes=True) as m:
snap = m.get_snapshot()
for p in snap.processes: # list[ProcessSample], CPU-sorted
print(p.pid, p.command, p.attributed_w) # attributed_w is None until the first CPU delta
CLI Reference
| Option | Purpose | Default |
|---|---|---|
--interval |
Sampling and refresh interval (seconds) | 2 |
--avg |
Rolling average window (seconds) | 30 |
--subsamples |
Internal sampler deltas per interval (≥1) | 1 |
--show_cores / --no-show_cores |
Per-core panels | on |
--show-processes |
Show top process panel at startup | off |
--power-scale profile|auto |
Power chart scaling | profile |
--chart-glyph dots|block |
Chart glyph style | dots |
--palette thermal|viridis|mono |
Chart color palette: thermal (blue→red), viridis (colorblind-safe), mono (grayscale). Applies at truecolor/256 tiers |
thermal |
--layout grid|stack |
Dashboard layout preset (grid auto-degrades to stack under ~96 cols; cycle live with l) |
grid |
--proc-filter REGEX |
Filter process panel by command name | all (applies when panel is enabled) |
--alert-bw-sat-percent |
Bandwidth saturation alert threshold | 85 |
--alert-package-power-percent |
Package power alert threshold (profile-relative) | 85 |
--alert-swap-rise-gb |
Swap growth alert threshold (GB) | 0.3 |
--alert-sustain-samples |
Consecutive samples for sustained alerts | 3 |
--json |
Stream metrics as NDJSON to stdout instead of the TUI | off |
--serve PORT |
Serve Prometheus metrics on http://0.0.0.0:PORT/metrics instead of the TUI |
off |
Metrics Export
Beyond the interactive dashboard, actop can act as an observability source. Both
modes reuse the same unprivileged IOReport backend and exit on Ctrl-C.
-
NDJSON stream (
--json): emits one compact JSON snapshot per--intervalto stdout — everySystemSnapshotfield, including per-core lists. Pipe it tojq, a log shipper, or a file:actop --json --interval 1 | jq '{cpu: .cpu_watts, pkg: .package_watts}'
-
Prometheus endpoint (
--serve PORT): exposes gauges at/metrics(actop_cpu_power_watts,actop_pcpu_utilization_percent, per-coreactop_core_utilization_percent{cluster,core}, …). A background sampler keeps the latest reading warm so scrapes return immediately:actop --serve 9095 curl -s localhost:9095/metrics
How It Works
actop accesses Apple Silicon hardware telemetry through three OS-level interfaces, all called in-process:
IOReport framework (libIOReport.dylib)
The primary data source. actop loads libIOReport.dylib and CoreFoundation.framework via ctypes.cdll.LoadLibrary, then:
- Subscribes to three IOReport channel groups: Energy Model (CPU/GPU/ANE energy in nanojoules), CPU Core Performance States (per-core ECPU/PCPU DVFS residency), and GPU Performance States (GPU DVFS residency).
- Takes periodic snapshots with
IOReportCreateSamplesand computes deltas between consecutive snapshots withIOReportCreateSamplesDelta. - Extracts per-channel energy values (
IOReportSimpleGetIntegerValue) and per-state residency tables (IOReportStateGetCount,IOReportStateGetNameForIndex,IOReportStateGetResidency). - Converts raw items into a
SampleResult— power (watts), frequency (MHz), and activity (percent). Energy values are converted from nanojoules to joules and scaled by elapsed time for correct wattage.
All CoreFoundation objects are managed via CFRelease to prevent memory leaks.
IOKit registry (ioreg)
At startup, reads ioreg -a -r -d 1 -n pmgr to get DVFS frequency tables from the power manager device node. Parses voltage-states* binary blobs as 8-byte (freq_hz, voltage) pairs and heuristically assigns tables to E-CPU, P-CPU, and GPU clusters. These translate opaque V{n}P{m} (CPU) and P{n} (GPU) state names into actual MHz values, computed as weighted averages across active P-states by residency time.
SMC (System Management Controller)
Reads CPU and GPU die temperatures via IOKit ctypes bindings to the AppleSMC kernel service. Discovers temperature sensor keys (Tp*/Te* for CPU, Tg* for GPU) at startup and reads flt (IEEE 754 float) values each sample. Runs unprivileged.
Thermal pressure (NSProcessInfo)
Reads the macOS thermal state via the Objective-C runtime (libobjc.A.dylib + Foundation.framework) using ctypes. Calls [NSProcessInfo processInfo].thermalState each sample. The result is shown in the status line above the per-core history tracks:
| State | Meaning |
|---|---|
| Nominal | Normal operating conditions — no throttling |
| Fair | Mild thermal pressure — light throttling may begin |
| Serious | Significant thermal pressure — noticeable throttling in effect |
| Critical | Severe thermal pressure — aggressive throttling, system is very hot |
No sudo required. Degrades to Unknown if the ObjC runtime call fails.
System context
sysctl: SoC chip name, total/P/E core counts.system_profiler: GPU core count.- Native
ctypes(native_sys.py): RAM/swap usage (Mach VM stats,XSWUsage) and process enumeration (proc_listpids/proc_pidinfo) — nopsutil.
Signal Sources
| Signal | Source | Notes |
|---|---|---|
| CPU/GPU/ANE power (W) | IOReport Energy Model | nJ per sample interval → watts |
| Per-core frequency (MHz) | IOReport residency + DVFS tables | Weighted average of active P-states |
| Per-core activity (%) | IOReport CPU Core Performance States | Via CoreSample (residency-weighted active%) |
| GPU frequency and activity | IOReport GPU Performance States | Weighted average of GPUPH residencies |
| CPU/GPU temperature (°C) | SMC via IOKit ctypes | Max die temp per cluster |
| RAM / swap | Native ctypes (host_statistics64 + XSWUsage) |
total - available for used |
| SoC profile | sysctl brand → 16 M1–M4 profiles |
Tier fallbacks for unknown chips |
| Top processes | Native ctypes (proc_listpids/proc_pidinfo) |
Optional --proc-filter regex |
| Bandwidth | IOReport (when available) | N/A if DCS counters not exposed |
| Thermal pressure | NSProcessInfo.thermalState via ObjC runtime |
Nominal / Fair / Serious / Critical |
Architecture
| Module | Role |
|---|---|
actop/actop.py |
CLI entry point and argument parsing; thin wrapper launching the Textual TUI |
actop/ioreport.py |
ctypes bindings to libIOReport.dylib and CoreFoundation — IOReportSubscription lifecycle, snapshot, delta, and CF helpers |
actop/sampler.py |
IOReportSampler: two-snapshot delta logic, SampleResult conversion, DVFS table discovery from ioreg pmgr, SMC temperature integration |
actop/smc.py |
SMC temperature reader: IOKit ctypes bindings to AppleSMC, key discovery, CPU/GPU die temperature reads |
actop/utils.py |
L1 acquisition + platform discovery: native ctypes RAM/swap and per-process CPU/GPU time enumeration (via native_sys.py), sysctl/system_profiler SoC info |
actop/analytics.py |
L2 domain analytics over acquired data points: per-process power attribution (attribute_power), throttle detection, and the AlertEngine (sustain-counted alerts + session-energy integral) |
actop/soc_profiles.py |
16 SocProfile dataclasses (M1–M4) with reference wattage/bandwidth; tier fallbacks for unknown chips |
actop/power_scaling.py |
power_to_percent(): profile mode (SoC reference) vs auto mode (rolling peak x1.25) |
actop/config.py |
DashboardConfig frozen dataclass; create_dashboard_config() merges CLI args with SoC info |
actop/models.py |
SystemSnapshot, CoreSample, ProcessSample dataclasses (public API types) |
actop/api.py |
Monitor, Profiler, AsyncMonitor — public Python API; Monitor(include_processes=True) opts into watt-attributed ProcessSamples |
actop/tui/app.py |
ActopApp: Textual App with polling worker, process table, app-level status bar, interactive sort/filter/pause/layout |
actop/tui/widgets.py |
HardwareDashboard widget (four titled sections, grid/stack layout presets with width auto-degrade) plus the custom BrailleChart sparkline widget, core rows, and alert computation. TUI CSS is embedded inline as each component's DEFAULT_CSS (no separate .tcss file). |
graph TD
subgraph "macOS Frameworks"
IOR[libIOReport.dylib]
CF[CoreFoundation.framework]
IOKIT[IOKit Registry<br/>ioreg pmgr device]
end
subgraph "macOS System Commands"
SYSCTL[sysctl<br/>CPU brand, core counts]
SYSPROF[system_profiler<br/>GPU core count]
end
subgraph "Python Libraries"
TEXTUAL[textual<br/>terminal TUI framework]
end
subgraph "actop Modules"
IORPY[ioreport.py<br/>ctypes bindings]
SAMPLER[sampler.py<br/>IOReportSampler]
SMC[smc.py<br/>SMC temperature reader]
NATIVESYS[native_sys.py<br/>ctypes: RAM, swap,<br/>process enumeration]
UTILS[utils.py<br/>system context]
PROFILES[soc_profiles.py<br/>M1-M4 profiles]
POWER[power_scaling.py<br/>chart scaling]
CONFIG[config.py<br/>DashboardConfig]
TUI[tui/app.py + widgets.py<br/>Textual dashboard]
MAIN[actop.py<br/>CLI entry point]
end
IOR -->|ctypes.cdll| IORPY
CF -->|ctypes.cdll| IORPY
IOKIT -->|native ctypes| SAMPLER
IORPY -->|IOReportSubscription<br/>sample/delta| SAMPLER
SMC -->|TemperatureReading| SAMPLER
SAMPLER -->|SampleResult| TUI
SYSCTL --> UTILS
SYSPROF --> UTILS
NATIVESYS --> UTILS
UTILS -->|SoC info, RAM, processes| TUI
PROFILES --> CONFIG
CONFIG --> TUI
POWER --> TUI
TEXTUAL --> TUI
TUI --> MAIN
Troubleshooting
- Bandwidth shows N/A: IOReport does not expose memory bandwidth counters on all SoCs.
- Thermal shows "Unknown": ObjC runtime failed to read
NSProcessInfo.thermalState(unexpected on macOS 12+). - Frequencies show 0 MHz: DVFS table discovery failed. File an issue with
sysctl -n machdep.cpu.brand_stringoutput. - Metric differences vs other tools: expected due to sampling window and source timing differences.
- ANE reads
0% (0.0W): expected when idle — the Neural Engine is power-gated unless an app runs CoreML inference. To confirm the gauge works, drive a load withscripts/ane_load.py(see Development) or trigger on-device ML (Photos library scan, Live Text, dictation). Note: MLX/Ollama/llama.cpp use the GPU, not the ANE.
Development
.venv/bin/python -m pip install -e ".[dev]" # install editable + dev deps
.venv/bin/python -m actop.actop --help # validate CLI
.venv/bin/python -m actop.actop # run with defaults
.venv/bin/pytest -q # run tests
.venv/bin/python -m ruff check . && .venv/bin/python -m ruff format . # lint + format
Exercising the ANE gauge
The Neural Engine idles at 0% (0.0W) unless something runs CoreML inference.
scripts/ane_load.py generates a deterministic ANE load so you can verify the
gauge. Its deps (coremltools, numpy) are macOS-only and heavy, so they live
in a separate ane extra — deliberately kept out of dev so Linux CI stays lean.
.venv/bin/python -m pip install -e ".[ane]" # one-time, macOS only
.venv/bin/python scripts/ane_load.py --duration 60 # then watch actop's ANE gauge
Contributing
PRs and issues welcome. Bug reports, new SoC profiles, metric fixes, and docs
improvements are all appreciated — for anything larger than a small fix, open an
issue first. See CONTRIBUTING.md for setup, the branch/PR flow,
and the functional-tests-only policy (CLAUDE.md is the full source of truth).
Release
See GUIDE-release-operations.md for the full runbook.
# 1. Bump version and changelog
edit pyproject.toml CHANGELOG.md
# 2. Run checks
.venv/bin/python -m ruff check --fix . && .venv/bin/python -m ruff format .
.venv/bin/python -m actop.actop --help
.venv/bin/pytest -q
# 3. Commit and tag
git add pyproject.toml CHANGELOG.md
git commit -m "Release v$VERSION"
scripts/tag_release.sh "$VERSION"
# 4. Verify
brew update && brew upgrade binlecode/actop/actop
CI handles formula sync automatically on tag push.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file actop-1.4.6.tar.gz.
File metadata
- Download URL: actop-1.4.6.tar.gz
- Upload date:
- Size: 102.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
63d107b92e83ad2c64c3b9839aa84af5e42bb3b2ce09b2aa4c9acec0708468b8
|
|
| MD5 |
ca1c6668ad90dad62e437e3f3e6910d3
|
|
| BLAKE2b-256 |
65f0b3c9d9bfdcc2e6a0a7319e6532aca336df80996fda370eef84cbd3458fb8
|
Provenance
The following attestation bundles were made for actop-1.4.6.tar.gz:
Publisher:
publish-pypi.yml on binlecode/actop
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
actop-1.4.6.tar.gz -
Subject digest:
63d107b92e83ad2c64c3b9839aa84af5e42bb3b2ce09b2aa4c9acec0708468b8 - Sigstore transparency entry: 2050199932
- Sigstore integration time:
-
Permalink:
binlecode/actop@e07a89a4ac759cc37875b7435b49d1dd12f55674 -
Branch / Tag:
refs/tags/v1.4.6 - Owner: https://github.com/binlecode
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@e07a89a4ac759cc37875b7435b49d1dd12f55674 -
Trigger Event:
push
-
Statement type:
File details
Details for the file actop-1.4.6-py3-none-any.whl.
File metadata
- Download URL: actop-1.4.6-py3-none-any.whl
- Upload date:
- Size: 72.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aecd896c5e16c4c0d02612786983fdc26a1b29c87791e0d3416846a65a9b7cc2
|
|
| MD5 |
47f25ceb1d96138d0f0584b369031f83
|
|
| BLAKE2b-256 |
c81c917c9fe2cfef42aad09913f47bbdc265a6e28f2486f680f4e8021f95cc6a
|
Provenance
The following attestation bundles were made for actop-1.4.6-py3-none-any.whl:
Publisher:
publish-pypi.yml on binlecode/actop
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
actop-1.4.6-py3-none-any.whl -
Subject digest:
aecd896c5e16c4c0d02612786983fdc26a1b29c87791e0d3416846a65a9b7cc2 - Sigstore transparency entry: 2050200269
- Sigstore integration time:
-
Permalink:
binlecode/actop@e07a89a4ac759cc37875b7435b49d1dd12f55674 -
Branch / Tag:
refs/tags/v1.4.6 - Owner: https://github.com/binlecode
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@e07a89a4ac759cc37875b7435b49d1dd12f55674 -
Trigger Event:
push
-
Statement type: