Apple Silicon telemetry for machine-learning workloads — it answers the question you actually have after a slow training run: what was holding it back?
Most system monitors show you a graph and leave the interpretation to you. Dev-Pulse lets you bracket a run, then tells you whether it was GPU-bound, CPU-bound, memory-bound, or waiting on I/O — with the evidence behind the verdict.
finetune-resnet-v3 (session 4)
bs=64, lr=3e-4
Duration: 1840s
Bottleneck: CPU [confidence: high]
CPU-bound: CPU averaged 94% while GPU averaged only 12%. The accelerator
is being starved — time is going to data loading or preprocessing.
Evidence
cpu_mean 94.1
gpu_mean 11.8
ram_mean_percent 61.0
p_core_mean 97.2
e_core_mean 88.4
Suggested next steps
- Raise DataLoader worker count and enable prefetching.
- Move augmentation off the hot path, or cache it.
- Profile the input pipeline before touching the model.
Training on a laptop, the interesting failure is rarely "the model is wrong". It is that an expensive GPU sits at 12% while eight CPU cores thrash through JPEG decoding. That is invisible on a utilisation graph unless you know to look for the combination — high CPU, low GPU, at the same time.
Dev-Pulse records that combination and names it.
cli.py start <label> opens a workload session
│
▼
collector.py ── samples ──▶ SQLite ◀── reads ── dashboard.py
│ │
topology.py (P/E core split, from sysctl) │
gpu.py (powermetrics, degrades honestly) │
│ ▼
▼ analysis.py
cli.py stop (bottleneck verdict)
| Module | Responsibility |
|---|---|
devpulse/config.py |
Every tunable, overridable by environment variable |
devpulse/topology.py |
Detects performance/efficiency core counts via sysctl |
devpulse/gpu.py |
GPU residency via powermetrics; reports None when unavailable |
devpulse/collector.py |
The sampling loop — batched writes, periodic pruning |
devpulse/schema.py |
SQLAlchemy models, indexes, retention sweep |
devpulse/workload.py |
Session lifecycle and analysis entry point |
devpulse/analysis.py |
Bottleneck classification |
python -m venv venv && source venv/bin/activate
pip install -r requirements.txtStart the collector in one terminal — it runs until you stop it:
python cli.py collectBracket a run in another terminal:
python cli.py start finetune-resnet-v3 -n "bs=64, lr=3e-4"
# ... run your training ...
python cli.py stop
python cli.py reportOr from Python, which closes the session even if training raises:
from devpulse.workload import track
with track("finetune-resnet-v3", notes="bs=64"):
train(model, loader)Live dashboard:
streamlit run dashboard.pypowermetrics requires root. Without it the GPU column reads n/a —
deliberately, rather than a misleading 0%. Everything else still works.
To enable it, add a passwordless entry with sudo visudo:
your-username ALL=(root) NOPASSWD: /usr/bin/powermetrics
This grants passwordless root for powermetrics only. Skip it if you would
rather not.
Every value in devpulse/config.py reads an environment variable:
| Variable | Default | Meaning |
|---|---|---|
DEVPULSE_DB |
./devpulse.db |
Database location |
DEVPULSE_INTERVAL |
1.0 |
Seconds between samples |
DEVPULSE_COMMIT_EVERY |
10 |
Samples buffered per commit |
DEVPULSE_RETENTION_HOURS |
72 |
Age at which untagged samples are pruned |
DEVPULSE_GPU |
1 |
Set 0 to skip GPU sampling entirely |
DEVPULSE_GPU_BOUND |
70.0 |
GPU % above which a run is GPU-bound |
DEVPULSE_CPU_BOUND |
70.0 |
CPU % above which a run is CPU-bound |
pip install pytest && python -m pytest tests/ -qTests run against a temporary database and never touch collected telemetry.
The database records the name of every process that crosses the CPU
threshold — effectively a log of what you run. It is listed in
.gitignore and is never committed. Delete it whenever you like; the
schema is recreated on the next launch.
See CHANGELOG.md for the upgrade history, including the defects found in the first version and how each was fixed.