Skip to main content

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.

actop dashboard: live E-CPU/P-CPU/GPU/ANE utilization, per-core frequency, memory bandwidth, and power charts on Apple Silicon

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 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 BrailleChart sparklines 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) 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 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/repogithub.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 (gridstack) · 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 --interval to stdout — every SystemSnapshot field, including per-core lists. Pipe it to jq, 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-core actop_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 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 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_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

.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

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

actop-1.4.1.tar.gz (101.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

actop-1.4.1-py3-none-any.whl (71.8 kB view details)

Uploaded Python 3

File details

Details for the file actop-1.4.1.tar.gz.

File metadata

  • Download URL: actop-1.4.1.tar.gz
  • Upload date:
  • Size: 101.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for actop-1.4.1.tar.gz
Algorithm Hash digest
SHA256 d9bdaf5979027a677fb213876e9b117b82a704604f7b866d88b800c798cb4871
MD5 506d97fa7be368308c12c3974e49b2f3
BLAKE2b-256 129bc75af97681eb6871730320b5b998c09256bb16efaad761cf449cd523e7e5

See more details on using hashes here.

Provenance

The following attestation bundles were made for actop-1.4.1.tar.gz:

Publisher: publish-pypi.yml on binlecode/actop

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file actop-1.4.1-py3-none-any.whl.

File metadata

  • Download URL: actop-1.4.1-py3-none-any.whl
  • Upload date:
  • Size: 71.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for actop-1.4.1-py3-none-any.whl
Algorithm Hash digest
SHA256 a581a414d179084163be14ecaa5e21e97fa34e5bbbcd19381d41603006278b55
MD5 30f6efea3e56f61031c745282632fb2c
BLAKE2b-256 9a44dd14c063a2b38fd41c32701700c983b5ee6d4d77cca482c1bfba53259788

See more details on using hashes here.

Provenance

The following attestation bundles were made for actop-1.4.1-py3-none-any.whl:

Publisher: publish-pypi.yml on binlecode/actop

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page