https://github.com/binlecode/actop
Apple Silicon (M1–M4) power, GPU, ANE & memory-bandwidth monitor — sudoless TUI + Python API for profiling local LLM / MLX / CoreML inference
https://github.com/binlecode/actop
ane apple-silicon gpu-monitoring llm macos mlx ollama performance-monitoring python tui
Last synced: 3 days ago
JSON representation
Apple Silicon (M1–M4) power, GPU, ANE & memory-bandwidth monitor — sudoless TUI + Python API for profiling local LLM / MLX / CoreML inference
- Host: GitHub
- URL: https://github.com/binlecode/actop
- Owner: binlecode
- License: mit
- Created: 2026-02-16T00:03:36.000Z (5 months ago)
- Default Branch: main
- Last Pushed: 2026-07-01T00:10:44.000Z (22 days ago)
- Last Synced: 2026-07-01T00:14:53.100Z (22 days ago)
- Topics: ane, apple-silicon, gpu-monitoring, llm, macos, mlx, ollama, performance-monitoring, python, tui
- Language: Python
- Size: 3.38 MB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
- Agents: AGENTS.md
Awesome Lists containing this project
README
# 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 `*top`** for Apple Silicon that needs **no `sudo`**.
Install in one line — [Homebrew](#homebrew-recommended) or [uv](#uv-recommended-for-non-homebrew-users) — 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 **C**hip 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](#where-actop-fits) for the head-to-head.
## Key Features
- **Textual TUI dashboard**: custom `BrailleChart` sparklines for E-CPU, P-CPU, GPU, ANE, RAM, and power — rendered on the [Textual](https://textual.textualize.io/) framework. Four titled sections (CPU, GPU·ANE, Memory, Power) in two switchable layout presets — `grid` (two columns, fits short terminals) and `stack` (single full-width column), cycled live with `l`. Supports `dots` (braille) and `block` glyph styles, and selectable color palettes (`--palette`: `thermal`, colorblind-safe `viridis`, grayscale `mono`) on top of adaptive truecolor/256/16-color/`NO_COLOR` degradation. 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.dylib` and CoreFoundation. No subprocesses, no temp files.
- **Per-core visibility**: per-core panels on by default; toggle with `--no-show_cores` for 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-processes` or press `t` in the TUI. Regex filtering is available via `--proc-filter` or `/` interactively.
- **Profile-aware power scaling**: `profile` mode (default) scales charts against the SoC's known reference wattage for stable cross-session comparison; `auto` mode 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](https://github.com/metaspartan/mactop) | [macmon](https://github.com/vladkens/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](docs/REVIEW-architecture-comparison.md).
## Roadmap
The gaps above are tracked, not abandoned — see
[docs/TODO-architecture-roadmap.md](docs/TODO-architecture-roadmap.md) for the open
items and their reasoning:
- **Net/disk I/O** — the one open must-have for hardware/metric coverage; the
feasibility spike is done (verified on-device, unprivileged) and the full
implementation-ready design lives in `docs/TODO-net-disk-io-2026-07-02.md`.
- **Menu bar mode** — deliberately deferred post-launch; a second application
surface (not a feature add), revisited only after the initial launch cycle.
actop stays scoped to one thesis — a fast, unprivileged, resource-efficient Apple
Silicon telemetry monitor — and rejects feature creep into general ML/APM frameworks.
## Installation
### Homebrew (recommended)
```shell
brew tap binlecode/actop
brew install binlecode/actop/actop
```
The tap name `binlecode/actop` resolves to the formula repo
[`binlecode/homebrew-actop`](https://github.com/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:
```shell
brew upgrade binlecode/actop/actop
brew uninstall binlecode/actop/actop && brew untap binlecode/actop
```
### uv (recommended for non-Homebrew users)
[`uv`](https://docs.astral.sh/uv/) installs `actop` into a sandboxed, per-tool
environment with its own managed CPython — no system Python required and no
interpreter drift:
```shell
uv tool install git+https://github.com/binlecode/actop.git
```
Upgrade / uninstall:
```shell
uv tool upgrade actop
uv tool uninstall actop
```
### pip
```shell
pip install git+https://github.com/binlecode/actop.git
```
## Quick Start
```shell
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:
```python
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:
```python
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 `--interval`
to stdout — every `SystemSnapshot` field, including per-core lists. Pipe it to
`jq`, a log shipper, or a file:
```shell
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-core
`actop_core_utilization_percent{cluster,core}`, …). A background sampler keeps
the latest reading warm so scrapes return immediately:
```shell
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 `IOReportCreateSamples` and computes deltas between consecutive snapshots with `IOReportCreateSamplesDelta`.
- 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`) — no `psutil`.
### 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 `ProcessSample`s |
| `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). |
```mermaid
graph TD
subgraph "macOS Frameworks"
IOR[libIOReport.dylib]
CF[CoreFoundation.framework]
IOKIT[IOKit Registry
ioreg pmgr device]
end
subgraph "macOS System Commands"
SYSCTL[sysctl
CPU brand, core counts]
SYSPROF[system_profiler
GPU core count]
end
subgraph "Python Libraries"
TEXTUAL[textual
terminal TUI framework]
end
subgraph "actop Modules"
IORPY[ioreport.py
ctypes bindings]
SAMPLER[sampler.py
IOReportSampler]
SMC[smc.py
SMC temperature reader]
NATIVESYS[native_sys.py
ctypes: RAM, swap,
process enumeration]
UTILS[utils.py
system context]
PROFILES[soc_profiles.py
M1-M4 profiles]
POWER[power_scaling.py
chart scaling]
CONFIG[config.py
DashboardConfig]
TUI[tui/app.py + widgets.py
Textual dashboard]
MAIN[actop.py
CLI entry point]
end
IOR -->|ctypes.cdll| IORPY
CF -->|ctypes.cdll| IORPY
IOKIT -->|native ctypes| SAMPLER
IORPY -->|IOReportSubscription
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_string` output.
- **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 with `scripts/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
```bash
.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.
```bash
.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](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.
```bash
# 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.