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 +18 -0
- actop/actop.py +219 -0
- actop/api.py +202 -0
- actop/config.py +95 -0
- actop/export.py +175 -0
- actop/ioreport.py +298 -0
- actop/models.py +34 -0
- actop/native_sys.py +643 -0
- actop/power_scaling.py +36 -0
- actop/sampler.py +508 -0
- actop/smc.py +360 -0
- actop/soc_profiles.py +201 -0
- actop/tui/__init__.py +0 -0
- actop/tui/app.py +434 -0
- actop/tui/widgets.py +799 -0
- actop/utils.py +217 -0
- actop-1.0.0.dist-info/METADATA +353 -0
- actop-1.0.0.dist-info/RECORD +22 -0
- actop-1.0.0.dist-info/WHEEL +5 -0
- actop-1.0.0.dist-info/entry_points.txt +2 -0
- actop-1.0.0.dist-info/licenses/LICENSE +21 -0
- actop-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|