actop 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
actop/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ import importlib.metadata
2
+
3
+ from .api import AsyncMonitor, Monitor, Profiler
4
+ from .models import CoreSample, SystemSnapshot
5
+
6
+ try:
7
+ __version__ = importlib.metadata.version("actop")
8
+ except importlib.metadata.PackageNotFoundError:
9
+ __version__ = "dev"
10
+
11
+ __all__ = [
12
+ "Monitor",
13
+ "Profiler",
14
+ "AsyncMonitor",
15
+ "SystemSnapshot",
16
+ "CoreSample",
17
+ "__version__",
18
+ ]
actop/actop.py ADDED
@@ -0,0 +1,219 @@
1
+ import argparse
2
+ import re
3
+
4
+ from actop import __version__
5
+
6
+
7
+ def build_parser():
8
+ parser = argparse.ArgumentParser(
9
+ description="actop: Performance monitoring CLI tool for Apple Silicon"
10
+ )
11
+ parser.add_argument(
12
+ "--version", action="version", version=f"%(prog)s {__version__}"
13
+ )
14
+ parser.add_argument(
15
+ "--interval",
16
+ type=int,
17
+ default=2,
18
+ help="Display and sampling interval in seconds",
19
+ )
20
+ parser.add_argument(
21
+ "--avg", type=int, default=30, help="Interval for averaged values (seconds)"
22
+ )
23
+ parser.add_argument(
24
+ "--subsamples",
25
+ type=_validate_subsamples,
26
+ default=1,
27
+ help="Number of internal sampler deltas per interval (>=1)",
28
+ )
29
+ parser.add_argument(
30
+ "--show_cores",
31
+ action=argparse.BooleanOptionalAction,
32
+ default=True,
33
+ help="Enable per-core panels (disable with --no-show_cores)",
34
+ )
35
+ parser.add_argument(
36
+ "--power-scale",
37
+ choices=["auto", "profile"],
38
+ default="profile",
39
+ help="Power chart scaling mode: profile uses SoC reference, auto uses rolling peak",
40
+ )
41
+ parser.add_argument(
42
+ "--chart-glyph",
43
+ choices=["dots", "block"],
44
+ default="dots",
45
+ help="Chart glyph style: dots (braille) or block (square)",
46
+ )
47
+ parser.add_argument(
48
+ "--proc-filter",
49
+ type=_validate_proc_filter,
50
+ default="",
51
+ help='Regex filter for process panel command names (example: "python|ollama|vllm|docker|mlx")',
52
+ )
53
+ parser.add_argument(
54
+ "--show-processes",
55
+ action="store_true",
56
+ default=False,
57
+ help="Show top process panel at startup (default: off)",
58
+ )
59
+ parser.add_argument(
60
+ "--alert-bw-sat-percent",
61
+ type=_validate_percent_threshold,
62
+ default=85,
63
+ help="Bandwidth saturation alert threshold percent (1-100)",
64
+ )
65
+ parser.add_argument(
66
+ "--alert-package-power-percent",
67
+ type=_validate_percent_threshold,
68
+ default=85,
69
+ help="Package power alert threshold percent (1-100, profile-relative)",
70
+ )
71
+ parser.add_argument(
72
+ "--alert-swap-rise-gb",
73
+ type=_validate_swap_rise_gb,
74
+ default=0.3,
75
+ help="Alert when swap rises by at least this many GB over sustained samples",
76
+ )
77
+ parser.add_argument(
78
+ "--alert-sustain-samples",
79
+ type=_validate_sustain_samples,
80
+ default=3,
81
+ help="Consecutive samples required for sustained alerts",
82
+ )
83
+ parser.add_argument(
84
+ "--json",
85
+ action="store_true",
86
+ default=False,
87
+ help="Stream metrics as NDJSON to stdout instead of launching the TUI",
88
+ )
89
+ parser.add_argument(
90
+ "--serve",
91
+ type=_validate_port,
92
+ default=None,
93
+ metavar="PORT",
94
+ help="Serve Prometheus metrics on http://0.0.0.0:PORT/metrics (no TUI)",
95
+ )
96
+ return parser
97
+
98
+
99
+ def _validate_proc_filter(value):
100
+ if value in (None, ""):
101
+ return ""
102
+ try:
103
+ re.compile(value, re.IGNORECASE)
104
+ except re.error as error:
105
+ raise argparse.ArgumentTypeError(
106
+ "invalid --proc-filter regex: {}".format(error)
107
+ ) from error
108
+ return value
109
+
110
+
111
+ def _validate_percent_threshold(value):
112
+ try:
113
+ threshold = int(value)
114
+ except (TypeError, ValueError) as error:
115
+ raise argparse.ArgumentTypeError("threshold must be an integer") from error
116
+ if threshold < 1 or threshold > 100:
117
+ raise argparse.ArgumentTypeError("threshold must be in the range 1-100")
118
+ return threshold
119
+
120
+
121
+ def _validate_swap_rise_gb(value):
122
+ try:
123
+ swap_rise = float(value)
124
+ except (TypeError, ValueError) as error:
125
+ raise argparse.ArgumentTypeError(
126
+ "swap rise threshold must be a number"
127
+ ) from error
128
+ if swap_rise < 0:
129
+ raise argparse.ArgumentTypeError("swap rise threshold must be >= 0")
130
+ return swap_rise
131
+
132
+
133
+ def _validate_sustain_samples(value):
134
+ try:
135
+ samples = int(value)
136
+ except (TypeError, ValueError) as error:
137
+ raise argparse.ArgumentTypeError(
138
+ "sustain samples must be an integer"
139
+ ) from error
140
+ if samples < 1:
141
+ raise argparse.ArgumentTypeError("sustain samples must be >= 1")
142
+ return samples
143
+
144
+
145
+ def _validate_port(value):
146
+ try:
147
+ port = int(value)
148
+ except (TypeError, ValueError) as error:
149
+ raise argparse.ArgumentTypeError("port must be an integer") from error
150
+ if port < 1 or port > 65535:
151
+ raise argparse.ArgumentTypeError("port must be in the range 1-65535")
152
+ return port
153
+
154
+
155
+ def _validate_subsamples(value):
156
+ try:
157
+ subsamples = int(value)
158
+ except (TypeError, ValueError) as error:
159
+ raise argparse.ArgumentTypeError("subsamples must be an integer") from error
160
+ if subsamples < 1:
161
+ raise argparse.ArgumentTypeError("subsamples must be >= 1")
162
+ return subsamples
163
+
164
+
165
+ def _run_dashboard(args, runtime_state):
166
+ from actop.tui.app import ActopApp
167
+
168
+ app = ActopApp(args)
169
+ app.run()
170
+
171
+
172
+ def _run_export(args):
173
+ """Route to a non-TUI export backend. Returns an exit code."""
174
+ from actop import export
175
+
176
+ interval_s = max(1, int(args.interval))
177
+ subsamples = max(1, int(args.subsamples))
178
+ try:
179
+ if args.serve is not None:
180
+ export.serve_prometheus(args.serve, interval_s, subsamples)
181
+ else:
182
+ export.run_json_stream(interval_s, subsamples)
183
+ return 0
184
+ except KeyboardInterrupt:
185
+ return 130
186
+
187
+
188
+ def main(args=None):
189
+ if args is None:
190
+ args = build_parser().parse_args()
191
+ if getattr(args, "json", False) or getattr(args, "serve", None) is not None:
192
+ return _run_export(args)
193
+ runtime_state = {"monitor": None, "cursor_hidden": False}
194
+ try:
195
+ _run_dashboard(args, runtime_state)
196
+ return 0
197
+ except KeyboardInterrupt:
198
+ print("Stopping...")
199
+ return 130
200
+ finally:
201
+ monitor = runtime_state.get("monitor")
202
+ if monitor is not None:
203
+ monitor.close()
204
+ if runtime_state["cursor_hidden"]:
205
+ print("\033[?25h")
206
+
207
+
208
+ def cli(argv=None):
209
+ parser = build_parser()
210
+ args = parser.parse_args(argv)
211
+ try:
212
+ return main(args)
213
+ except Exception as e:
214
+ print(e)
215
+ return 1
216
+
217
+
218
+ if __name__ == "__main__":
219
+ raise SystemExit(cli())
actop/api.py ADDED
@@ -0,0 +1,202 @@
1
+ """Public Python API for actop hardware profiling."""
2
+
3
+ import dataclasses
4
+ import threading
5
+ import time
6
+
7
+ from .models import CoreSample, SystemSnapshot
8
+ from .sampler import SampleResult, create_sampler
9
+ from .utils import get_ram_metrics_dict
10
+
11
+
12
+ def _sample_to_snapshot(
13
+ sample: SampleResult, ram: dict, interval_s: float
14
+ ) -> SystemSnapshot:
15
+ """Map raw SampleResult + RAM dict to a clean SystemSnapshot."""
16
+ cm = sample.cpu_metrics
17
+ gm = sample.gpu_metrics
18
+ bw = sample.bandwidth_metrics
19
+ bw_avail = bool(isinstance(bw, dict) and bw.get("_available", False))
20
+ # total_gbps is a residency-weighted average already in GB/s — not a
21
+ # byte counter, so it is not divided by the sample interval.
22
+ total_bw = float(bw.get("total_gbps", 0.0)) if bw_avail else 0.0
23
+ e_cores = [
24
+ CoreSample(
25
+ index=sys_idx,
26
+ active_pct=int(cm.get("E-Cluster" + str(sys_idx) + "_active", 0)),
27
+ freq_mhz=int(cm.get("E-Cluster" + str(sys_idx) + "_freq_Mhz", 0)),
28
+ )
29
+ for sys_idx in cm.get("e_core", [])
30
+ ]
31
+ p_cores = [
32
+ CoreSample(
33
+ index=sys_idx,
34
+ active_pct=int(cm.get("P-Cluster" + str(sys_idx) + "_active", 0)),
35
+ freq_mhz=int(cm.get("P-Cluster" + str(sys_idx) + "_freq_Mhz", 0)),
36
+ )
37
+ for sys_idx in cm.get("p_core", [])
38
+ ]
39
+ return SystemSnapshot(
40
+ timestamp=sample.timestamp,
41
+ cpu_watts=cm["cpu_W"] / interval_s,
42
+ gpu_watts=cm["gpu_W"] / interval_s,
43
+ ane_watts=cm["ane_W"] / interval_s,
44
+ package_watts=cm["package_W"] / interval_s,
45
+ ecpu_util_pct=float(cm["E-Cluster_active"]),
46
+ pcpu_util_pct=float(cm["P-Cluster_active"]),
47
+ gpu_util_pct=float(gm["active"]),
48
+ cpu_temp_c=sample.cpu_temp_c,
49
+ gpu_temp_c=sample.gpu_temp_c,
50
+ ecpu_freq_mhz=int(cm["E-Cluster_freq_Mhz"]),
51
+ pcpu_freq_mhz=int(cm["P-Cluster_freq_Mhz"]),
52
+ gpu_freq_mhz=int(gm["freq_MHz"]),
53
+ ram_used_gb=float(ram.get("used_GB", 0.0)),
54
+ swap_used_gb=float(ram.get("swap_used_GB", 0.0)),
55
+ thermal_state=sample.thermal_pressure,
56
+ bandwidth_gbps=total_bw,
57
+ bandwidth_available=bw_avail,
58
+ e_cores=e_cores,
59
+ p_cores=p_cores,
60
+ )
61
+
62
+
63
+ class Monitor:
64
+ """Synchronous, single-sample hardware monitor."""
65
+
66
+ def __init__(self, interval_s: float = 1.0, subsamples: int = 1):
67
+ self._interval_s = max(1, int(interval_s))
68
+ self._sampler, self.backend_name = create_sampler(
69
+ self._interval_s, subsamples=subsamples
70
+ )
71
+ # Prime delta: first sample() always returns None
72
+ self._sampler.sample()
73
+
74
+ @property
75
+ def manages_timing(self) -> bool:
76
+ """True if the underlying sampler manages its own sleep timing."""
77
+ return bool(getattr(self._sampler, "manages_timing", False))
78
+
79
+ def get_snapshot(self) -> SystemSnapshot:
80
+ """Block for interval_s (unless sampler manages timing), return SystemSnapshot."""
81
+ if not self.manages_timing:
82
+ time.sleep(self._interval_s)
83
+ sample = self._sampler.sample()
84
+ while sample is None:
85
+ # A None sample means the delta interval was non-positive; sleep
86
+ # briefly so the re-sample sees a meaningful elapsed time (avoids a
87
+ # frame with an inflated interval/elapsed power scale).
88
+ time.sleep(0.01)
89
+ sample = self._sampler.sample()
90
+ ram = get_ram_metrics_dict()
91
+ return _sample_to_snapshot(sample, ram, self._interval_s)
92
+
93
+ def close(self):
94
+ self._sampler.close()
95
+
96
+ def __enter__(self):
97
+ return self
98
+
99
+ def __exit__(self, *_):
100
+ self.close()
101
+
102
+
103
+ class Profiler:
104
+ """Threaded background collector. Use as a context manager."""
105
+
106
+ def __init__(self, interval_s: float = 1.0):
107
+ self._interval_s = interval_s
108
+ self._monitor = Monitor(interval_s)
109
+ self._samples: list = []
110
+ self._lock = threading.Lock()
111
+ self._stop_event = threading.Event()
112
+ self._thread: threading.Thread | None = None
113
+ self._alerts: list = [] # list of (metric, threshold, callback)
114
+
115
+ def __enter__(self):
116
+ self.start()
117
+ return self
118
+
119
+ def __exit__(self, *_):
120
+ self.stop()
121
+
122
+ def start(self):
123
+ with self._lock:
124
+ self._samples.clear()
125
+ self._stop_event.clear()
126
+ self._thread = threading.Thread(target=self._run_loop, daemon=True)
127
+ self._thread.start()
128
+
129
+ def stop(self):
130
+ self._stop_event.set()
131
+ if self._thread:
132
+ self._thread.join()
133
+ self._monitor.close()
134
+
135
+ def _run_loop(self):
136
+ while not self._stop_event.is_set():
137
+ snapshot = self._monitor.get_snapshot() # blocks for interval_s
138
+ with self._lock:
139
+ self._samples.append(snapshot)
140
+ for metric, threshold, callback in self._alerts:
141
+ val = getattr(snapshot, metric, None)
142
+ if val is not None and val >= threshold:
143
+ try:
144
+ callback(val)
145
+ except Exception:
146
+ pass
147
+
148
+ def register_alert(self, metric: str, threshold: float, callback):
149
+ """Fire callback(value) when snapshot.metric >= threshold."""
150
+ if metric not in SystemSnapshot.__dataclass_fields__:
151
+ raise ValueError(f"Unknown SystemSnapshot field: {metric!r}")
152
+ self._alerts.append((metric, threshold, callback))
153
+
154
+ def get_summary(self) -> dict:
155
+ with self._lock:
156
+ samples = list(self._samples)
157
+ if not samples:
158
+ return {}
159
+ duration_s = (
160
+ samples[-1].timestamp - samples[0].timestamp if len(samples) > 1 else 0.0
161
+ )
162
+ cpu_w = [s.cpu_watts for s in samples]
163
+ gpu_w = [s.gpu_watts for s in samples]
164
+ pkg_w = [s.package_watts for s in samples]
165
+ avg_cpu = sum(cpu_w) / len(cpu_w)
166
+ avg_gpu = sum(gpu_w) / len(gpu_w)
167
+ avg_pkg = sum(pkg_w) / len(pkg_w)
168
+ return {
169
+ "sample_count": len(samples),
170
+ "duration_s": duration_s,
171
+ "avg_cpu_watts": avg_cpu,
172
+ "avg_gpu_watts": avg_gpu,
173
+ "avg_package_watts": avg_pkg,
174
+ "peak_cpu_watts": max(cpu_w),
175
+ "peak_gpu_watts": max(gpu_w),
176
+ "peak_package_watts": max(pkg_w),
177
+ "total_cpu_joules": avg_cpu * duration_s,
178
+ "total_gpu_joules": avg_gpu * duration_s,
179
+ "total_package_joules": avg_pkg * duration_s,
180
+ }
181
+
182
+ def to_pandas(self):
183
+ try:
184
+ import pandas as pd
185
+ except ImportError:
186
+ raise ImportError("pandas is required: pip install actop[pandas]")
187
+ with self._lock:
188
+ samples = list(self._samples)
189
+ df = pd.DataFrame([dataclasses.asdict(s) for s in samples])
190
+ df["datetime"] = pd.to_datetime(df["timestamp"], unit="s")
191
+ df.set_index("datetime", inplace=True)
192
+ return df
193
+
194
+
195
+ class AsyncMonitor(Monitor):
196
+ """Async wrapper around Monitor; runs blocking get_snapshot in a thread pool."""
197
+
198
+ async def get_snapshot_async(self) -> SystemSnapshot:
199
+ import asyncio
200
+
201
+ loop = asyncio.get_running_loop()
202
+ return await loop.run_in_executor(None, self.get_snapshot)
actop/config.py ADDED
@@ -0,0 +1,95 @@
1
+ """Dashboard configuration dataclass."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class DashboardConfig:
10
+ """Immutable values computed once from args + soc_info_dict."""
11
+
12
+ sample_interval: int
13
+ avg_window: int
14
+ usage_track_window: int
15
+ core_history_window: int
16
+
17
+ cpu_chart_ref_w: float
18
+ gpu_chart_ref_w: float
19
+ ane_max_power: float
20
+ package_ref_w: float
21
+ max_cpu_bw: float
22
+ max_gpu_bw: float
23
+ max_media_bw: float
24
+
25
+ e_core_count: int
26
+ p_core_count: int
27
+
28
+ power_scale: str
29
+ chart_glyph: str
30
+ show_cores: bool
31
+
32
+ alert_bw_sat_percent: int
33
+ alert_package_power_percent: int
34
+ alert_swap_rise_gb: float
35
+ alert_sustain_samples: int
36
+
37
+ subsamples: int
38
+
39
+ process_display_count: int
40
+ show_processes: bool
41
+ process_filter_pattern: Optional[object] # compiled regex or None
42
+ proc_filter_raw: str
43
+
44
+
45
+ def create_dashboard_config(args, soc_info_dict):
46
+ """Build an immutable DashboardConfig from parsed CLI args and SoC info."""
47
+ sample_interval = max(1, args.interval)
48
+ avg_window = max(1, int(args.avg / sample_interval))
49
+ usage_track_window = max(200, int(args.avg / sample_interval))
50
+ core_history_window = max(200, int(args.avg / sample_interval))
51
+
52
+ cpu_chart_ref_w = soc_info_dict["cpu_chart_ref_w"]
53
+ gpu_chart_ref_w = soc_info_dict["gpu_chart_ref_w"]
54
+ ane_max_power = 8.0
55
+ package_ref_w = max(cpu_chart_ref_w + gpu_chart_ref_w + ane_max_power, 1.0)
56
+ max_cpu_bw = max(float(soc_info_dict.get("cpu_max_bw", 0.0)), 1.0)
57
+ max_gpu_bw = max(float(soc_info_dict.get("gpu_max_bw", 0.0)), 1.0)
58
+ max_media_bw = max(max_cpu_bw, max_gpu_bw)
59
+
60
+ e_core_count = max(0, int(soc_info_dict["e_core_count"]))
61
+ p_core_count = max(0, int(soc_info_dict["p_core_count"]))
62
+
63
+ process_filter_pattern = (
64
+ re.compile(args.proc_filter, re.IGNORECASE)
65
+ if getattr(args, "proc_filter", "")
66
+ else None
67
+ )
68
+
69
+ return DashboardConfig(
70
+ sample_interval=sample_interval,
71
+ avg_window=avg_window,
72
+ usage_track_window=usage_track_window,
73
+ core_history_window=core_history_window,
74
+ cpu_chart_ref_w=cpu_chart_ref_w,
75
+ gpu_chart_ref_w=gpu_chart_ref_w,
76
+ ane_max_power=ane_max_power,
77
+ package_ref_w=package_ref_w,
78
+ max_cpu_bw=max_cpu_bw,
79
+ max_gpu_bw=max_gpu_bw,
80
+ max_media_bw=max_media_bw,
81
+ e_core_count=e_core_count,
82
+ p_core_count=p_core_count,
83
+ power_scale=args.power_scale,
84
+ chart_glyph=getattr(args, "chart_glyph", "dots"),
85
+ show_cores=args.show_cores,
86
+ alert_bw_sat_percent=args.alert_bw_sat_percent,
87
+ alert_package_power_percent=args.alert_package_power_percent,
88
+ alert_swap_rise_gb=args.alert_swap_rise_gb,
89
+ alert_sustain_samples=max(1, int(args.alert_sustain_samples)),
90
+ process_display_count=50,
91
+ show_processes=bool(getattr(args, "show_processes", False)),
92
+ subsamples=max(1, int(args.subsamples)),
93
+ process_filter_pattern=process_filter_pattern,
94
+ proc_filter_raw=getattr(args, "proc_filter", ""),
95
+ )