aetherscan 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.
- aetherscan/__init__.py +45 -0
- aetherscan/benchmark.py +179 -0
- aetherscan/candidate_figures.py +328 -0
- aetherscan/cli.py +3032 -0
- aetherscan/config.py +1002 -0
- aetherscan/dashboard.py +709 -0
- aetherscan/dashboard_cli.py +51 -0
- aetherscan/dashboard_launcher.py +194 -0
- aetherscan/data_generation.py +1448 -0
- aetherscan/db/__init__.py +21 -0
- aetherscan/db/db.py +2789 -0
- aetherscan/hf_hub.py +547 -0
- aetherscan/inference.py +912 -0
- aetherscan/inference_viz.py +1494 -0
- aetherscan/latent_gif.py +512 -0
- aetherscan/latent_variants.py +352 -0
- aetherscan/logger/__init__.py +21 -0
- aetherscan/logger/logger.py +467 -0
- aetherscan/logger/slack_handler.py +760 -0
- aetherscan/main.py +1269 -0
- aetherscan/manager/__init__.py +21 -0
- aetherscan/manager/manager.py +781 -0
- aetherscan/models/__init__.py +21 -0
- aetherscan/models/random_forest.py +171 -0
- aetherscan/models/vae.py +849 -0
- aetherscan/monitor/__init__.py +19 -0
- aetherscan/monitor/monitor.py +935 -0
- aetherscan/pfb.py +161 -0
- aetherscan/preprocessing.py +2718 -0
- aetherscan/rf_metrics.py +100 -0
- aetherscan/round_data.py +932 -0
- aetherscan/run_state.py +277 -0
- aetherscan/seeding.py +160 -0
- aetherscan/shap_parallel.py +238 -0
- aetherscan/tag_guards.py +206 -0
- aetherscan/train.py +7681 -0
- aetherscan-1.0.0.dist-info/METADATA +1187 -0
- aetherscan-1.0.0.dist-info/RECORD +41 -0
- aetherscan-1.0.0.dist-info/WHEEL +4 -0
- aetherscan-1.0.0.dist-info/entry_points.txt +2 -0
- aetherscan-1.0.0.dist-info/licenses/LICENSE +13 -0
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
# BUG: sometimes entire sections (e.g. data generation for round X) just don't show up in the resource utilization plot. not sure if data isn't being written to the db properly (haven't verified)?
|
|
2
|
+
# TODO: add a threshold to config where if RAM usage > threshold, immediately exit & initiate cleanup. set threshold in monitor config
|
|
3
|
+
"""
|
|
4
|
+
Resource monitor for Aetherscan Pipeline
|
|
5
|
+
Runs as background thread & records system metrics (CPU, RAM, GPU) to database writer queue
|
|
6
|
+
Saves resource utilization plot on exit
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import gc
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
import matplotlib
|
|
22
|
+
import matplotlib.pyplot as plt
|
|
23
|
+
import numpy as np
|
|
24
|
+
import psutil
|
|
25
|
+
import tensorflow as tf
|
|
26
|
+
from matplotlib.lines import Line2D
|
|
27
|
+
from matplotlib.patches import ConnectionPatch
|
|
28
|
+
|
|
29
|
+
matplotlib.use("Agg") # Non-interactive backend for headless environments
|
|
30
|
+
|
|
31
|
+
from aetherscan.config import get_config
|
|
32
|
+
from aetherscan.logger import get_logger
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Only pipeline_stages spans this shallow (dot-separated components) are overlaid on the
|
|
37
|
+
# resource plot — deep spans (per-ON-file energy detection, encode/rf sub-stages, ...) stay
|
|
38
|
+
# report-tool-only so the panels don't drown in divider lines
|
|
39
|
+
_ANNOTATION_MAX_DEPTH = 2
|
|
40
|
+
|
|
41
|
+
# Color shared by the stage boundary lines and their text labels (matplotlib named color)
|
|
42
|
+
_ANNOTATION_COLOR = "dimgray"
|
|
43
|
+
|
|
44
|
+
# GPU display names shown in the resource-plot legend ONLY (never the DB resource_name or
|
|
45
|
+
# self.gpu_names): a raw name containing one of these substrings collapses to the short alias,
|
|
46
|
+
# preserving the ":<idx>" suffix. _sanitize_gpu_display_name returns on the FIRST substring
|
|
47
|
+
# match in dict (insertion) order, so if a future key is a substring of another, list the more
|
|
48
|
+
# specific (longer) one first. The current two keys don't overlap, so order isn't load-bearing
|
|
49
|
+
# yet. Anything outside the whitelist falls back to length-based truncation.
|
|
50
|
+
_GPU_DISPLAY_NAME_WHITELIST = {
|
|
51
|
+
"NVIDIA RTX PRO 6000 Blackwell": "PRO 6000",
|
|
52
|
+
"NVIDIA RTX A4000": "A4000",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# GPU display names past this length fall back to <cutoff-1> chars + "..." (whitelist misses)
|
|
56
|
+
_GPU_NAME_MAX_LEN = 20
|
|
57
|
+
|
|
58
|
+
# Cap the GPU legend at this many rows per metric group before spilling into another column,
|
|
59
|
+
# biasing the busy GPU legend toward fewer columns / more rows so it clears the centered title
|
|
60
|
+
_GPU_LEGEND_MAX_ROWS = 6
|
|
61
|
+
|
|
62
|
+
# Headroom-band layout for the per-panel legends moved outside/above each panel (issue #214):
|
|
63
|
+
# each legend's lower edge is pinned at _LEGEND_Y (axes fraction, just above the top spine) so
|
|
64
|
+
# it grows upward into the band, and the subplot title is lifted enough to sit above it. The
|
|
65
|
+
# title pad must exceed its panel's legend height so the legend's upper edge stays no higher
|
|
66
|
+
# than the title: a small pad clears the single-row CPU/RAM legends, a taller one clears the
|
|
67
|
+
# multi-row GPU legend (up to _GPU_LEGEND_MAX_ROWS rows).
|
|
68
|
+
_TITLE_PAD = 26
|
|
69
|
+
_GPU_TITLE_PAD = 80
|
|
70
|
+
_LEGEND_Y = 1.005
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def select_annotation_spans(rows: list[dict], max_depth: int = _ANNOTATION_MAX_DEPTH) -> list[dict]:
|
|
74
|
+
"""
|
|
75
|
+
Filter pipeline_stages rows down to the ones the resource plot overlays: spans whose
|
|
76
|
+
dot-name has at most max_depth components (e.g. "train.round_03" but not
|
|
77
|
+
"train.round_03.epochs"), sorted by start_time. Pure helper, unit-testable without a
|
|
78
|
+
monitor instance.
|
|
79
|
+
"""
|
|
80
|
+
spans = [row for row in rows if len(str(row["stage"]).split(".")) <= max_depth]
|
|
81
|
+
spans.sort(key=lambda row: row["start_time"])
|
|
82
|
+
return spans
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sanitize_gpu_display_name(name: str) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Map a raw GPU name to the short label shown in the resource-plot legend ONLY (never the DB
|
|
88
|
+
resource_name or self.gpu_names). Whitelisted product strings (_GPU_DISPLAY_NAME_WHITELIST)
|
|
89
|
+
collapse to a short alias; anything else falls back to the previous length-based truncation
|
|
90
|
+
(name parts longer than _GPU_NAME_MAX_LEN chars become _GPU_NAME_MAX_LEN-1 chars + "...").
|
|
91
|
+
The ":<idx>" suffix appended by _detect_gpus() is preserved in both cases. Pure helper,
|
|
92
|
+
unit-testable.
|
|
93
|
+
|
|
94
|
+
e.g. "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition:2" -> "PRO 6000:2",
|
|
95
|
+
"NVIDIA RTX A4000:0" -> "A4000:0", "GPU:1" -> "GPU:1" (short, unchanged).
|
|
96
|
+
"""
|
|
97
|
+
name_part, sep, idx_part = name.rpartition(":")
|
|
98
|
+
if not sep:
|
|
99
|
+
name_part = name
|
|
100
|
+
|
|
101
|
+
for needle, alias in _GPU_DISPLAY_NAME_WHITELIST.items():
|
|
102
|
+
if needle in name_part:
|
|
103
|
+
return f"{alias}:{idx_part}" if sep else alias
|
|
104
|
+
|
|
105
|
+
# Fallback: hard cutoff, truncating to _GPU_NAME_MAX_LEN-1 chars + "..." keeping the
|
|
106
|
+
# ":<idx>" suffix when present (fires with or without a colon).
|
|
107
|
+
if len(name_part) > _GPU_NAME_MAX_LEN:
|
|
108
|
+
truncated = f"{name_part[: _GPU_NAME_MAX_LEN - 1]}..."
|
|
109
|
+
return f"{truncated}:{idx_part}" if sep else truncated
|
|
110
|
+
return name
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _grouped_legend_entries(
|
|
114
|
+
handles1: list,
|
|
115
|
+
labels1: list,
|
|
116
|
+
handles2: list,
|
|
117
|
+
labels2: list,
|
|
118
|
+
*,
|
|
119
|
+
max_rows: int,
|
|
120
|
+
) -> tuple[list, list, int]:
|
|
121
|
+
"""
|
|
122
|
+
Arrange two metric groups (GPU usage + GPU memory) into a column-major legend grid that
|
|
123
|
+
keeps each group in its own column block and caps the row count at `max_rows`, adding a
|
|
124
|
+
column block to a group only once it exceeds `max_rows` entries. Matplotlib fills legends
|
|
125
|
+
column-major, so each group is padded with invisible handles up to a whole number of
|
|
126
|
+
columns — usage entries then occupy their own column(s) and memory entries always start a
|
|
127
|
+
fresh column (e.g. 5 GPUs at max_rows>=5 -> columns of 5 | 5). Returns (handles, labels,
|
|
128
|
+
ncol). Pure helper.
|
|
129
|
+
"""
|
|
130
|
+
n = max(len(handles1), len(handles2))
|
|
131
|
+
cols_per_group = math.ceil(n / max_rows) if n else 1
|
|
132
|
+
nrows = math.ceil(n / cols_per_group) if n else 0
|
|
133
|
+
slots = nrows * cols_per_group
|
|
134
|
+
|
|
135
|
+
handles = list(handles1) + [Line2D([], [], alpha=0)] * (slots - len(handles1))
|
|
136
|
+
labels = list(labels1) + [""] * (slots - len(labels1))
|
|
137
|
+
handles += list(handles2) + [Line2D([], [], alpha=0)] * (slots - len(handles2))
|
|
138
|
+
labels += list(labels2) + [""] * (slots - len(labels2))
|
|
139
|
+
|
|
140
|
+
return handles, labels, 2 * cols_per_group
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _legend_above_panel(
|
|
144
|
+
ax, handles: list, labels: list, *, ncol: int, fontsize: int, y_anchor: float = _LEGEND_Y
|
|
145
|
+
) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Place `ax`'s legend outside the panel, in the headroom band between the panel and its title:
|
|
148
|
+
the legend's lower-centre is pinned to (0.5, `y_anchor` in axes fraction) so it is centred
|
|
149
|
+
horizontally under the (also-centred) title and grows upward into the reserved band. The
|
|
150
|
+
title pad must exceed the legend height so the two stay vertically stacked without overlap.
|
|
151
|
+
No-op when there are no labelled artists.
|
|
152
|
+
"""
|
|
153
|
+
if not handles:
|
|
154
|
+
return
|
|
155
|
+
ax.legend(
|
|
156
|
+
handles,
|
|
157
|
+
labels,
|
|
158
|
+
loc="lower center",
|
|
159
|
+
bbox_to_anchor=(0.5, y_anchor),
|
|
160
|
+
ncol=ncol,
|
|
161
|
+
fontsize=fontsize,
|
|
162
|
+
frameon=True,
|
|
163
|
+
borderaxespad=0.0,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _draw_stage_boundaries(
|
|
168
|
+
axes: list, spans: list[dict], start_time: float, current_time: float
|
|
169
|
+
) -> None:
|
|
170
|
+
"""
|
|
171
|
+
Draw the pipeline-stage overlay: ONE dashed, semi-transparent `dimgray` vertical line at
|
|
172
|
+
each span's right edge (its end time, in minutes since `start_time`), spanning
|
|
173
|
+
continuously from the top of `axes[0]` to the bottom of `axes[-1]` — crossing the
|
|
174
|
+
inter-panel gaps instead of rendering as three clipped per-panel segments (#280) — and
|
|
175
|
+
label each stage once, anchored just left of its line and rotated 30 deg from horizontal,
|
|
176
|
+
sitting just BELOW the x-axis of `axes[0]` (in the inter-panel band, not inside the
|
|
177
|
+
plot). Pure drawing helper (no DB or instance state) so it's exercisable with synthetic
|
|
178
|
+
spans in tests and the render harness. `axes` must be non-empty; `axes[0]` is the top
|
|
179
|
+
(CPU) panel whose x-axis the labels hang under.
|
|
180
|
+
"""
|
|
181
|
+
label_ax = axes[0]
|
|
182
|
+
figure = label_ax.get_figure()
|
|
183
|
+
for span in spans:
|
|
184
|
+
end_min = (min(span["end_time"], current_time) - start_time) / 60
|
|
185
|
+
# ConnectionPatch with get_xaxis_transform() endpoints: x in data coords (the shared
|
|
186
|
+
# x-axis makes one value valid for both panels), y in axes fraction — (end_min, 1.0)
|
|
187
|
+
# is the top of the first panel, (end_min, 0.0) the bottom of the last, independent
|
|
188
|
+
# of y-limits. Figure-level artists draw over panel contents, so the line is dashed
|
|
189
|
+
# and lighter (alpha 0.4) than the old per-panel alpha 0.7 to keep titles, tick
|
|
190
|
+
# labels, and the rotated stage annotations readable underneath it.
|
|
191
|
+
boundary = ConnectionPatch(
|
|
192
|
+
xyA=(end_min, 1.0),
|
|
193
|
+
coordsA=axes[0].get_xaxis_transform(),
|
|
194
|
+
xyB=(end_min, 0.0),
|
|
195
|
+
coordsB=axes[-1].get_xaxis_transform(),
|
|
196
|
+
color=_ANNOTATION_COLOR,
|
|
197
|
+
linewidth=1.0,
|
|
198
|
+
linestyle="--",
|
|
199
|
+
alpha=0.4,
|
|
200
|
+
zorder=2,
|
|
201
|
+
)
|
|
202
|
+
figure.add_artist(boundary)
|
|
203
|
+
# Leaf name only ("round_03", not "train.round_03"), anchored just left of the line and
|
|
204
|
+
# angled so long names stay legible without overrunning into the neighbouring stage
|
|
205
|
+
label = str(span["stage"]).split(".")[-1]
|
|
206
|
+
label_ax.annotate(
|
|
207
|
+
label,
|
|
208
|
+
# x in data coords (the line); y in axes fraction just BELOW the bottom spine
|
|
209
|
+
# (negative) so the label sits under the top panel's x-axis, in the inter-panel
|
|
210
|
+
# band, rather than inside the plot. y-fraction (not data) survives any y-limit change.
|
|
211
|
+
xy=(end_min, -0.02),
|
|
212
|
+
xycoords=label_ax.get_xaxis_transform(),
|
|
213
|
+
xytext=(-3, 0),
|
|
214
|
+
textcoords="offset points",
|
|
215
|
+
rotation=30,
|
|
216
|
+
rotation_mode="anchor",
|
|
217
|
+
ha="right",
|
|
218
|
+
va="top",
|
|
219
|
+
fontsize=7,
|
|
220
|
+
color=_ANNOTATION_COLOR,
|
|
221
|
+
clip_on=False, # label lives outside the axes (below the x-axis) — must not be clipped
|
|
222
|
+
zorder=3,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_process_tree_stats(
|
|
227
|
+
process: psutil.Process, proc_cache: dict[int, psutil.Process] | None = None
|
|
228
|
+
) -> dict[str, float]:
|
|
229
|
+
"""
|
|
230
|
+
Sum CPU and RAM usage across `process` and its descendants (the multiprocessing pool
|
|
231
|
+
workers it spawned), returning {cpu_percent, ram_percent, ram_bytes, ram_gb}.
|
|
232
|
+
|
|
233
|
+
`proc_cache` maps PID -> psutil.Process and should persist across calls when CPU accuracy
|
|
234
|
+
matters: cpu_percent(interval=0) measures against the baseline recorded by the previous
|
|
235
|
+
call on the *same* Process object, so a freshly created object always reads 0.0 (issue
|
|
236
|
+
#12's undercount). With a persistent cache, each PID is accurate from its second sample
|
|
237
|
+
onward (a new PID contributes one 0.0 reading when first seen), and PIDs that leave the
|
|
238
|
+
tree are evicted. Without a cache (None), every call reads 0.0 CPU per process — fine for
|
|
239
|
+
RAM-only callers.
|
|
240
|
+
|
|
241
|
+
cpu_percent is normalized against the system core count (0-100). ram_bytes uses PSS
|
|
242
|
+
(Proportional Set Size) rather than RSS so shared pages aren't double-counted across the
|
|
243
|
+
process tree — summing RSS would let the total exceed system RAM. Dead children that vanish
|
|
244
|
+
mid-iteration are silently skipped.
|
|
245
|
+
"""
|
|
246
|
+
if proc_cache is None:
|
|
247
|
+
proc_cache = {}
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
# Get all processes in tree (main + children)
|
|
251
|
+
processes = [process]
|
|
252
|
+
with contextlib.suppress(psutil.NoSuchProcess):
|
|
253
|
+
processes.extend(process.children(recursive=True))
|
|
254
|
+
|
|
255
|
+
# Aggregate CPU and RAM usage across all processes, measuring through the cached
|
|
256
|
+
# Process object for any PID seen on a previous call — children() returns brand-new
|
|
257
|
+
# objects every call, which would reset the cpu_percent baseline each interval
|
|
258
|
+
total_cpu = 0.0
|
|
259
|
+
total_ram_bytes = 0
|
|
260
|
+
|
|
261
|
+
for proc in processes:
|
|
262
|
+
cached = proc_cache.setdefault(proc.pid, proc)
|
|
263
|
+
try:
|
|
264
|
+
# CPU: Get percentage (can be >100% for multi-core usage)
|
|
265
|
+
cpu = cached.cpu_percent(interval=0.0) # Non-blocking
|
|
266
|
+
total_cpu += cpu
|
|
267
|
+
|
|
268
|
+
# RAM: Get PSS (Proportional Set Size)
|
|
269
|
+
# Use PSS instead of RSS to avoid double-counting shared memory across processes.
|
|
270
|
+
# RSS counts shared pages once per process, so summing RSS across a process tree
|
|
271
|
+
# can exceed system total RAM. PSS divides shared pages by # of sharing processes,
|
|
272
|
+
# making it additive and accurate when summing across multiple processes.
|
|
273
|
+
mem_info = cached.memory_full_info()
|
|
274
|
+
total_ram_bytes += mem_info.pss
|
|
275
|
+
|
|
276
|
+
except psutil.NoSuchProcess:
|
|
277
|
+
# Process died between children() and the stat calls — drop it immediately
|
|
278
|
+
proc_cache.pop(proc.pid, None)
|
|
279
|
+
continue
|
|
280
|
+
except (psutil.AccessDenied, psutil.ZombieProcess):
|
|
281
|
+
# Still exists (just unreadable/unreaped) — keep the cache entry; it gets
|
|
282
|
+
# evicted below once the PID leaves the tree
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
# Evict cached entries for PIDs no longer in the tree so dead pool workers don't
|
|
286
|
+
# accumulate across a long run
|
|
287
|
+
tree_pids = {proc.pid for proc in processes}
|
|
288
|
+
for pid in set(proc_cache) - tree_pids:
|
|
289
|
+
del proc_cache[pid]
|
|
290
|
+
|
|
291
|
+
# Convert CPU to percentage of total system CPU
|
|
292
|
+
num_cores = psutil.cpu_count() or 0
|
|
293
|
+
cpu_percent = total_cpu / num_cores if num_cores > 0 else 0.0
|
|
294
|
+
|
|
295
|
+
# Convert RAM to percentage of total system RAM
|
|
296
|
+
total_system_ram = psutil.virtual_memory().total
|
|
297
|
+
ram_percent = (total_ram_bytes / total_system_ram) * 100
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
"cpu_percent": cpu_percent,
|
|
301
|
+
"ram_percent": ram_percent,
|
|
302
|
+
"ram_bytes": total_ram_bytes,
|
|
303
|
+
"ram_gb": total_ram_bytes / 1e9,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
|
|
307
|
+
logger.warning(f"Error getting process tree stats for PID {process.pid}: {e}")
|
|
308
|
+
return {
|
|
309
|
+
"cpu_percent": 0.0,
|
|
310
|
+
"ram_percent": 0.0,
|
|
311
|
+
"ram_bytes": 0,
|
|
312
|
+
"ram_gb": 0.0,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class ResourceMonitor:
|
|
317
|
+
"""Background thread to monitor system resources"""
|
|
318
|
+
|
|
319
|
+
_instance = None # Stores singleton instance
|
|
320
|
+
_lock = threading.Lock() # Ensures thread safety on object initialization
|
|
321
|
+
|
|
322
|
+
# __new__ allocates the object in memory (constructor at the object-creation level)
|
|
323
|
+
# __init__ initializes the object's attributes after it's created
|
|
324
|
+
# since __new__ is called before __init__ every time we instantiate a class,
|
|
325
|
+
# by overriding __new__, we can short-circuit object creation entirely, and control whether a
|
|
326
|
+
# new instance is created, or just return the existing instance
|
|
327
|
+
def __new__(cls):
|
|
328
|
+
# Double-checked locking pattern:
|
|
329
|
+
# First check if _instance is None, without lock (for performance)
|
|
330
|
+
if cls._instance is None:
|
|
331
|
+
# If None, acquire the lock to serialize the initialization path,
|
|
332
|
+
# preventing race conditions (2 threads violating singleton semantics)
|
|
333
|
+
with cls._lock:
|
|
334
|
+
# Check if _instance is None again inside the lock
|
|
335
|
+
# (since multiple threads can be calling simultaneously)
|
|
336
|
+
if cls._instance is None:
|
|
337
|
+
# If still None, only then we construct the singleton instance
|
|
338
|
+
cls._instance = super().__new__(cls)
|
|
339
|
+
cls._instance._initialized = False # Mark as not initialized (for __init__)
|
|
340
|
+
# Return the same instance for all subsequent constructor calls
|
|
341
|
+
return cls._instance
|
|
342
|
+
|
|
343
|
+
def __init__(self):
|
|
344
|
+
"""Initialize monitor"""
|
|
345
|
+
# Note, __init__ is triggered every time the class's constructor is called,
|
|
346
|
+
# even if __new__ returned the existing singleton instance
|
|
347
|
+
# Hence, we use the _initialized flag to make sure __init__ only runs once
|
|
348
|
+
if self._initialized:
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
self._initialized = True
|
|
352
|
+
|
|
353
|
+
self.config = get_config()
|
|
354
|
+
if self.config is None:
|
|
355
|
+
raise ValueError("get_config() returned None")
|
|
356
|
+
|
|
357
|
+
self.tag = self.config.checkpoint.save_tag
|
|
358
|
+
self.get_gpu_timeout = self.config.monitor.get_gpu_timeout
|
|
359
|
+
self.stop_monitor_timeout = self.config.monitor.stop_monitor_timeout
|
|
360
|
+
self.monitor_interval = self.config.monitor.monitor_interval
|
|
361
|
+
self.monitor_retry_delay = self.config.monitor.monitor_retry_delay
|
|
362
|
+
|
|
363
|
+
self.monitor_thread = None
|
|
364
|
+
self.stop_event = threading.Event() # Thread-safe flag for stopping
|
|
365
|
+
|
|
366
|
+
# Get main process ID
|
|
367
|
+
self.process = psutil.Process(os.getpid())
|
|
368
|
+
|
|
369
|
+
# PID -> psutil.Process cache reused across monitoring intervals so per-child
|
|
370
|
+
# cpu_percent(interval=0) has a baseline from the previous sample (issue #12)
|
|
371
|
+
self._proc_cache: dict[int, psutil.Process] = {}
|
|
372
|
+
|
|
373
|
+
# Detect GPUs
|
|
374
|
+
self._detect_gpus()
|
|
375
|
+
|
|
376
|
+
# Get database instance
|
|
377
|
+
# Late import to avoid circular dependency (db imports from manager)
|
|
378
|
+
from aetherscan.db import get_db # noqa: PLC0415
|
|
379
|
+
|
|
380
|
+
self.db = get_db()
|
|
381
|
+
if self.db is None:
|
|
382
|
+
raise RuntimeError(
|
|
383
|
+
"Database not initialized - resource monitoring data won't be persisted"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
logger.info("Resource monitor initialized")
|
|
387
|
+
logger.info(f"Main process PID: {self.process.pid}")
|
|
388
|
+
logger.info(f"CPU cores: {psutil.cpu_count() or 0}")
|
|
389
|
+
logger.info(f"Total memory: {psutil.virtual_memory().total / (1024**3):.2f} GB")
|
|
390
|
+
logger.info(f"GPUs detected: {self.num_gpus}")
|
|
391
|
+
if self.num_gpus > 0:
|
|
392
|
+
for name in self.gpu_names:
|
|
393
|
+
logger.info(f" {name}")
|
|
394
|
+
logger.info(f"Monitor interval: {self.monitor_interval} seconds")
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def _reset(cls):
|
|
398
|
+
"""
|
|
399
|
+
Teardown hook for thread-safe singleton
|
|
400
|
+
Resets the monitor instance to None
|
|
401
|
+
|
|
402
|
+
WARNING: Only use for testing or cleanup after shutdown.
|
|
403
|
+
Calling this while the monitor is active will cause issues.
|
|
404
|
+
Should only be called after stop() has completed.
|
|
405
|
+
"""
|
|
406
|
+
# Acquire lock to prevent race conditions
|
|
407
|
+
with cls._lock:
|
|
408
|
+
# Discard the singleton instance by removing the global reference
|
|
409
|
+
# Guarantees the next constructor call will produce a fresh instance
|
|
410
|
+
# Note, resources held by the old instance will remain alive unless explicitly closed beforehand
|
|
411
|
+
cls._instance = None
|
|
412
|
+
logger.info("Monitor singleton instance reset")
|
|
413
|
+
|
|
414
|
+
def _detect_gpus(self):
|
|
415
|
+
"""Detect available GPUs"""
|
|
416
|
+
try:
|
|
417
|
+
gpus = tf.config.list_physical_devices("GPU")
|
|
418
|
+
self.num_gpus = len(gpus)
|
|
419
|
+
|
|
420
|
+
# Try to get GPU names using nvidia-smi if available
|
|
421
|
+
if self.num_gpus > 0:
|
|
422
|
+
try:
|
|
423
|
+
result = subprocess.run(
|
|
424
|
+
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
|
|
425
|
+
check=False,
|
|
426
|
+
capture_output=True,
|
|
427
|
+
text=True,
|
|
428
|
+
timeout=self.get_gpu_timeout,
|
|
429
|
+
)
|
|
430
|
+
if result.returncode == 0:
|
|
431
|
+
self.gpu_names = [
|
|
432
|
+
f"{name.strip()}:{i}"
|
|
433
|
+
for i, name in enumerate(result.stdout.strip().split("\n"))
|
|
434
|
+
]
|
|
435
|
+
else:
|
|
436
|
+
self.gpu_names = [f"GPU:{i}" for i in range(self.num_gpus)]
|
|
437
|
+
except Exception:
|
|
438
|
+
self.gpu_names = [f"GPU:{i}" for i in range(self.num_gpus)]
|
|
439
|
+
else:
|
|
440
|
+
self.gpu_names = []
|
|
441
|
+
|
|
442
|
+
except Exception:
|
|
443
|
+
self.num_gpus = 0
|
|
444
|
+
self.gpu_names = []
|
|
445
|
+
|
|
446
|
+
def _get_process_tree_stats(self):
|
|
447
|
+
"""Convenience wrapper returning (cpu_percent_total, ram_percent) from
|
|
448
|
+
get_process_tree_stats() against the monitor's own root process, threading the
|
|
449
|
+
persistent Process cache through so per-child CPU readings are accurate."""
|
|
450
|
+
stats = get_process_tree_stats(self.process, self._proc_cache)
|
|
451
|
+
return stats["cpu_percent"], stats["ram_percent"]
|
|
452
|
+
|
|
453
|
+
def _get_gpu_stats(self):
|
|
454
|
+
"""Get GPU usage and memory statistics"""
|
|
455
|
+
gpu_utils = []
|
|
456
|
+
gpu_mems = []
|
|
457
|
+
|
|
458
|
+
if self.num_gpus > 0:
|
|
459
|
+
try:
|
|
460
|
+
# Get GPU utilization
|
|
461
|
+
result = subprocess.run(
|
|
462
|
+
[
|
|
463
|
+
"nvidia-smi",
|
|
464
|
+
"--query-gpu=utilization.gpu,memory.used,memory.total",
|
|
465
|
+
"--format=csv,noheader,nounits",
|
|
466
|
+
],
|
|
467
|
+
check=False,
|
|
468
|
+
capture_output=True,
|
|
469
|
+
text=True,
|
|
470
|
+
timeout=self.get_gpu_timeout,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if result.returncode == 0:
|
|
474
|
+
for line in result.stdout.strip().split("\n"):
|
|
475
|
+
parts = line.split(",")
|
|
476
|
+
util = float(parts[0].strip())
|
|
477
|
+
mem_used = float(parts[1].strip())
|
|
478
|
+
mem_total = float(parts[2].strip())
|
|
479
|
+
mem_percent = (mem_used / mem_total) * 100 if mem_total > 0 else 0
|
|
480
|
+
|
|
481
|
+
gpu_utils.append(util)
|
|
482
|
+
gpu_mems.append(mem_percent)
|
|
483
|
+
else:
|
|
484
|
+
gpu_utils = [0.0] * self.num_gpus
|
|
485
|
+
gpu_mems = [0.0] * self.num_gpus
|
|
486
|
+
except Exception:
|
|
487
|
+
gpu_utils = [0.0] * self.num_gpus
|
|
488
|
+
gpu_mems = [0.0] * self.num_gpus
|
|
489
|
+
|
|
490
|
+
return gpu_utils, gpu_mems
|
|
491
|
+
|
|
492
|
+
def start(self):
|
|
493
|
+
"""Start monitoring in background thread"""
|
|
494
|
+
if self.monitor_thread is not None and self.monitor_thread.is_alive():
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
self.stop_event.clear()
|
|
498
|
+
# NOTE: should monitor be daemon or non-daemon thread?
|
|
499
|
+
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=False)
|
|
500
|
+
self.monitor_thread.start()
|
|
501
|
+
logger.info("Resource monitoring thread started")
|
|
502
|
+
|
|
503
|
+
def stop(self):
|
|
504
|
+
"""Stop monitoring"""
|
|
505
|
+
if self.monitor_thread is None:
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
logger.info("Stopping resource monitoring thread...")
|
|
509
|
+
self.stop_event.set() # Signal thread to stop
|
|
510
|
+
|
|
511
|
+
# Wait for monitoring thread to finish
|
|
512
|
+
self.monitor_thread.join(timeout=self.stop_monitor_timeout)
|
|
513
|
+
|
|
514
|
+
if self.monitor_thread.is_alive():
|
|
515
|
+
logger.warning("Resource monitoring thread did not stop cleanly")
|
|
516
|
+
else:
|
|
517
|
+
logger.info("Resource monitoring thread stopped")
|
|
518
|
+
|
|
519
|
+
def _monitor_loop(self):
|
|
520
|
+
"""Background monitoring loop with database writes"""
|
|
521
|
+
self.start_time = time.time()
|
|
522
|
+
|
|
523
|
+
# Keep looping until told to stop
|
|
524
|
+
while not self.stop_event.is_set():
|
|
525
|
+
try:
|
|
526
|
+
current_time = time.time()
|
|
527
|
+
|
|
528
|
+
if self.db is None:
|
|
529
|
+
raise RuntimeError("No database instance detected - cannot run monitoring loop")
|
|
530
|
+
|
|
531
|
+
# Get system resources & queue db writes (non-blocking)
|
|
532
|
+
self.db.write_system_resource(
|
|
533
|
+
resource_type="cpu",
|
|
534
|
+
resource_name="system_total",
|
|
535
|
+
value=psutil.cpu_percent(interval=0.1),
|
|
536
|
+
unit="percent",
|
|
537
|
+
tag=self.tag,
|
|
538
|
+
timestamp=current_time,
|
|
539
|
+
)
|
|
540
|
+
self.db.write_system_resource(
|
|
541
|
+
resource_type="ram",
|
|
542
|
+
resource_name="system_total",
|
|
543
|
+
value=psutil.virtual_memory().percent,
|
|
544
|
+
unit="percent",
|
|
545
|
+
tag=self.tag,
|
|
546
|
+
timestamp=current_time,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
cpu_process, ram_process = self._get_process_tree_stats()
|
|
550
|
+
self.db.write_system_resource(
|
|
551
|
+
resource_type="cpu",
|
|
552
|
+
resource_name="process_tree",
|
|
553
|
+
value=cpu_process,
|
|
554
|
+
unit="percent",
|
|
555
|
+
tag=self.tag,
|
|
556
|
+
timestamp=current_time,
|
|
557
|
+
)
|
|
558
|
+
self.db.write_system_resource(
|
|
559
|
+
resource_type="ram",
|
|
560
|
+
resource_name="process_tree",
|
|
561
|
+
value=ram_process,
|
|
562
|
+
unit="percent",
|
|
563
|
+
tag=self.tag,
|
|
564
|
+
timestamp=current_time,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
gpu_utils, gpu_mems = self._get_gpu_stats()
|
|
568
|
+
for gpu_idx, (gpu_util, gpu_mem) in enumerate(
|
|
569
|
+
zip(gpu_utils, gpu_mems, strict=False)
|
|
570
|
+
):
|
|
571
|
+
gpu_name = (
|
|
572
|
+
self.gpu_names[gpu_idx]
|
|
573
|
+
if gpu_idx < len(self.gpu_names)
|
|
574
|
+
else f"GPU:{gpu_idx}"
|
|
575
|
+
)
|
|
576
|
+
self.db.write_system_resource(
|
|
577
|
+
resource_type="gpu",
|
|
578
|
+
resource_name=f"{gpu_name}_utilization",
|
|
579
|
+
value=gpu_util,
|
|
580
|
+
unit="percent",
|
|
581
|
+
tag=self.tag,
|
|
582
|
+
timestamp=current_time,
|
|
583
|
+
)
|
|
584
|
+
self.db.write_system_resource(
|
|
585
|
+
resource_type="gpu",
|
|
586
|
+
resource_name=f"{gpu_name}_memory",
|
|
587
|
+
value=gpu_mem,
|
|
588
|
+
unit="percent",
|
|
589
|
+
tag=self.tag,
|
|
590
|
+
timestamp=current_time,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
# Sleep until next interval (interruptible for faster shutdown)
|
|
594
|
+
self.stop_event.wait(self.monitor_interval)
|
|
595
|
+
|
|
596
|
+
except Exception as e:
|
|
597
|
+
logger.error(f"Error in resource monitoring loop: {e}")
|
|
598
|
+
# Sleep until next interval (interruptible for faster shutdown)
|
|
599
|
+
self.stop_event.wait(self.monitor_retry_delay)
|
|
600
|
+
|
|
601
|
+
# Save plot on shutdown
|
|
602
|
+
self._save_plot()
|
|
603
|
+
|
|
604
|
+
def _annotate_stage_spans(self, axes: list, current_time: float) -> None:
|
|
605
|
+
"""
|
|
606
|
+
Overlay this run's top-level pipeline_stages spans (depth <= 2 dot-names) as `dimgray`
|
|
607
|
+
vertical boundary lines at each span's right edge on every panel in `axes`, labelled
|
|
608
|
+
once (left of the line, angled 30 deg) on `axes[0]` (the CPU panel). X units match the
|
|
609
|
+
panels: minutes since monitor start. Flushes and queries the DB exactly once even
|
|
610
|
+
though the lines span all three panels, so spans recorded moments before shutdown
|
|
611
|
+
(final_save, viz) make it onto the plot.
|
|
612
|
+
"""
|
|
613
|
+
# The writer thread outlives the monitor (manager cleanup order: monitor before
|
|
614
|
+
# db), so a flush here is safe; a timeout just means the newest spans are missing
|
|
615
|
+
self.db.flush()
|
|
616
|
+
|
|
617
|
+
rows = self.db.query_pipeline_stages(
|
|
618
|
+
tag=self.tag,
|
|
619
|
+
start_time=self.start_time,
|
|
620
|
+
end_time=current_time,
|
|
621
|
+
)
|
|
622
|
+
spans = select_annotation_spans(rows)
|
|
623
|
+
if not spans:
|
|
624
|
+
logger.info("No top-level pipeline stage spans to annotate")
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
_draw_stage_boundaries(axes, spans, self.start_time, current_time)
|
|
628
|
+
logger.info(
|
|
629
|
+
f"Annotated {len(spans)} pipeline stage boundary line(s) across {len(axes)} panel(s)"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
def _save_plot(self):
|
|
633
|
+
"""Generate and save resource utilization plot from database"""
|
|
634
|
+
current_time = time.time()
|
|
635
|
+
|
|
636
|
+
# Query resource metrics from database
|
|
637
|
+
if self.db is None:
|
|
638
|
+
raise RuntimeError("No database instance detected - cannot generate resource plot")
|
|
639
|
+
|
|
640
|
+
# Decimated per series (#301): a multi-week run holds tens of millions of rows;
|
|
641
|
+
# the plot renders ~2k px wide, so >4k points/line is invisible and the full
|
|
642
|
+
# materialization cost a multi-GB teardown RAM spike.
|
|
643
|
+
all_resources = self.db.query_system_resource_decimated(
|
|
644
|
+
tag=self.tag,
|
|
645
|
+
start_time=self.start_time,
|
|
646
|
+
end_time=current_time,
|
|
647
|
+
max_points_per_series=4096,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
if not all_resources:
|
|
651
|
+
logger.warning("No resource monitoring data to plot")
|
|
652
|
+
return
|
|
653
|
+
|
|
654
|
+
# TODO: potential memory optimization here with array pre-allocation? or instead of extracting dict -> ndarray|dict, just use dict directly? is the potential improvement worth the effort?
|
|
655
|
+
# Organize resources by type and name
|
|
656
|
+
timestamps_dict = {}
|
|
657
|
+
values_dict = {}
|
|
658
|
+
|
|
659
|
+
for resource in all_resources:
|
|
660
|
+
key = f"{resource['resource_type']}_{resource['resource_name']}"
|
|
661
|
+
if key not in timestamps_dict:
|
|
662
|
+
timestamps_dict[key] = []
|
|
663
|
+
values_dict[key] = []
|
|
664
|
+
|
|
665
|
+
# Timestamps measured relative to start time, in minutes
|
|
666
|
+
timestamps_dict[key].append((resource["timestamp"] - self.start_time) / 60)
|
|
667
|
+
values_dict[key].append(resource["value"])
|
|
668
|
+
|
|
669
|
+
del all_resources
|
|
670
|
+
gc.collect()
|
|
671
|
+
|
|
672
|
+
# Extract CPU data
|
|
673
|
+
cpu_system_timestamps = np.array(timestamps_dict.get("cpu_system_total", []))
|
|
674
|
+
cpu_system_data = np.array(values_dict.get("cpu_system_total", []))
|
|
675
|
+
cpu_process_timestamps = np.array(timestamps_dict.get("cpu_process_tree", []))
|
|
676
|
+
cpu_process_data = np.array(values_dict.get("cpu_process_tree", []))
|
|
677
|
+
|
|
678
|
+
# Extract RAM data
|
|
679
|
+
ram_system_timestamps = np.array(timestamps_dict.get("ram_system_total", []))
|
|
680
|
+
ram_system_data = np.array(values_dict.get("ram_system_total", []))
|
|
681
|
+
ram_process_timestamps = np.array(timestamps_dict.get("ram_process_tree", []))
|
|
682
|
+
ram_process_data = np.array(values_dict.get("ram_process_tree", []))
|
|
683
|
+
|
|
684
|
+
# Extract GPU data (organized by GPU)
|
|
685
|
+
gpu_data = {}
|
|
686
|
+
for key in timestamps_dict:
|
|
687
|
+
if key.startswith("gpu_"):
|
|
688
|
+
parts = key.split("_")
|
|
689
|
+
# Format: gpu_<name>_<utilization|memory>
|
|
690
|
+
if len(parts) >= 2:
|
|
691
|
+
metric_type = parts[-1] # utilization or memory
|
|
692
|
+
gpu_name = "_".join(parts[1:-1]) # everything between gpu and metric_type
|
|
693
|
+
|
|
694
|
+
if gpu_name not in gpu_data:
|
|
695
|
+
gpu_data[gpu_name] = {}
|
|
696
|
+
|
|
697
|
+
timestamps = np.array(timestamps_dict.get(key, []))
|
|
698
|
+
values = np.array(values_dict.get(key, []))
|
|
699
|
+
gpu_data[gpu_name][metric_type] = (timestamps, values)
|
|
700
|
+
|
|
701
|
+
del timestamps_dict, values_dict
|
|
702
|
+
gc.collect()
|
|
703
|
+
|
|
704
|
+
# Create figure with 3 subplots. Extra height over the classic 3-panel figure buys the
|
|
705
|
+
# headroom band above each panel that now holds the external legend (issue #214).
|
|
706
|
+
fig, axes = plt.subplots(3, 1, figsize=(14, 15), sharex=True)
|
|
707
|
+
|
|
708
|
+
# Late import to avoid circular dependency (db imports from manager)
|
|
709
|
+
from aetherscan.db import get_system_metadata # noqa: PLC0415
|
|
710
|
+
|
|
711
|
+
metadata_json = get_system_metadata()
|
|
712
|
+
machine_name = json.loads(metadata_json).get("machine_name")
|
|
713
|
+
|
|
714
|
+
fig.suptitle(
|
|
715
|
+
f"Aetherscan Pipeline: Resource Utilization ({self.tag}, {machine_name})",
|
|
716
|
+
fontsize=16,
|
|
717
|
+
fontweight="bold",
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# CPU plot
|
|
721
|
+
ax_cpu = axes[0]
|
|
722
|
+
if len(cpu_process_data) > 0:
|
|
723
|
+
ax_cpu.plot(
|
|
724
|
+
cpu_process_timestamps,
|
|
725
|
+
cpu_process_data,
|
|
726
|
+
color="#1f77b4",
|
|
727
|
+
linewidth=1.5,
|
|
728
|
+
label="Aetherscan",
|
|
729
|
+
alpha=0.8,
|
|
730
|
+
)
|
|
731
|
+
ax_cpu.fill_between(
|
|
732
|
+
cpu_process_timestamps, cpu_process_data, alpha=0.3, color="#1f77b4"
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
if len(cpu_system_data) > 0:
|
|
736
|
+
ax_cpu.plot(
|
|
737
|
+
cpu_system_timestamps,
|
|
738
|
+
cpu_system_data,
|
|
739
|
+
color="#ff7f0e",
|
|
740
|
+
linewidth=2.0,
|
|
741
|
+
label="System Total",
|
|
742
|
+
alpha=0.9,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
ax_cpu.set_ylabel("CPU Usage (%)", fontsize=12, fontweight="bold")
|
|
746
|
+
ax_cpu.set_ylim(0, 100)
|
|
747
|
+
ax_cpu.grid(True, alpha=0.3)
|
|
748
|
+
ax_cpu.set_title(
|
|
749
|
+
f"CPU Pressure (n={psutil.cpu_count()} cores)", fontsize=12, pad=_TITLE_PAD
|
|
750
|
+
)
|
|
751
|
+
_legend_above_panel(ax_cpu, *ax_cpu.get_legend_handles_labels(), ncol=2, fontsize=10)
|
|
752
|
+
|
|
753
|
+
# RAM plot
|
|
754
|
+
ax_ram = axes[1]
|
|
755
|
+
if len(ram_process_data) > 0:
|
|
756
|
+
ax_ram.plot(
|
|
757
|
+
ram_process_timestamps,
|
|
758
|
+
ram_process_data,
|
|
759
|
+
color="#2ca02c",
|
|
760
|
+
linewidth=1.5,
|
|
761
|
+
label="Aetherscan",
|
|
762
|
+
alpha=0.8,
|
|
763
|
+
)
|
|
764
|
+
ax_ram.fill_between(
|
|
765
|
+
ram_process_timestamps, ram_process_data, alpha=0.3, color="#2ca02c"
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
if len(ram_system_data) > 0:
|
|
769
|
+
ax_ram.plot(
|
|
770
|
+
ram_system_timestamps,
|
|
771
|
+
ram_system_data,
|
|
772
|
+
color="#d62728",
|
|
773
|
+
linewidth=2.0,
|
|
774
|
+
label="System Total",
|
|
775
|
+
alpha=0.9,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
ax_ram.set_ylabel("RAM Usage (%)", fontsize=12, fontweight="bold")
|
|
779
|
+
ax_ram.set_ylim(0, 100)
|
|
780
|
+
ax_ram.grid(True, alpha=0.3)
|
|
781
|
+
ax_ram.set_title(
|
|
782
|
+
f"Memory Pressure (total={psutil.virtual_memory().total / (1024**3):.2f} GB)",
|
|
783
|
+
fontsize=12,
|
|
784
|
+
pad=_TITLE_PAD,
|
|
785
|
+
)
|
|
786
|
+
_legend_above_panel(ax_ram, *ax_ram.get_legend_handles_labels(), ncol=2, fontsize=10)
|
|
787
|
+
|
|
788
|
+
# GPU plot
|
|
789
|
+
ax_gpu = axes[2]
|
|
790
|
+
if gpu_data and self.num_gpus > 0:
|
|
791
|
+
# Create second y-axis
|
|
792
|
+
ax_gpu_mem = ax_gpu.twinx()
|
|
793
|
+
|
|
794
|
+
colors = plt.cm.tab10(np.linspace(0, 1, len(gpu_data)))
|
|
795
|
+
|
|
796
|
+
for gpu_idx, (gpu_name, metrics) in enumerate(gpu_data.items()):
|
|
797
|
+
color = colors[gpu_idx]
|
|
798
|
+
|
|
799
|
+
# Shorten the GPU name for the legend only (whitelist -> short alias, else the
|
|
800
|
+
# length-based truncation), preserving the ":<idx>" suffix. The DB
|
|
801
|
+
# resource_name and self.gpu_names are untouched.
|
|
802
|
+
display_name = _sanitize_gpu_display_name(gpu_name)
|
|
803
|
+
|
|
804
|
+
# Usage (solid line, y1)
|
|
805
|
+
if "utilization" in metrics:
|
|
806
|
+
timestamps, values = metrics["utilization"]
|
|
807
|
+
ax_gpu.plot(
|
|
808
|
+
timestamps,
|
|
809
|
+
values,
|
|
810
|
+
label=f"{display_name} (Usage)",
|
|
811
|
+
color=color,
|
|
812
|
+
linewidth=1.5,
|
|
813
|
+
alpha=0.9,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
# Memory (dashed line, y2, dimmer)
|
|
817
|
+
if "memory" in metrics:
|
|
818
|
+
timestamps, values = metrics["memory"]
|
|
819
|
+
ax_gpu_mem.plot(
|
|
820
|
+
timestamps,
|
|
821
|
+
values,
|
|
822
|
+
label=f"{display_name} (Memory)",
|
|
823
|
+
color=color,
|
|
824
|
+
linewidth=1.5,
|
|
825
|
+
alpha=0.6,
|
|
826
|
+
linestyle="--",
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
ax_gpu.set_ylabel("GPU Usage (%)", fontsize=12, fontweight="bold")
|
|
830
|
+
ax_gpu_mem.set_ylabel("GPU Memory (%)", fontsize=12, fontweight="bold")
|
|
831
|
+
ax_gpu.set_ylim(0, 100)
|
|
832
|
+
ax_gpu_mem.set_ylim(0, 100)
|
|
833
|
+
|
|
834
|
+
# Combine both y-axes' legends, grouping usage and memory into their own column
|
|
835
|
+
# blocks and biasing toward more rows / fewer columns so the busy legend stays
|
|
836
|
+
# narrow enough to clear the centered title once moved above the panel.
|
|
837
|
+
lines1, labels1 = ax_gpu.get_legend_handles_labels()
|
|
838
|
+
lines2, labels2 = ax_gpu_mem.get_legend_handles_labels()
|
|
839
|
+
handles, labels, ncol = _grouped_legend_entries(
|
|
840
|
+
lines1, labels1, lines2, labels2, max_rows=_GPU_LEGEND_MAX_ROWS
|
|
841
|
+
)
|
|
842
|
+
_legend_above_panel(ax_gpu, handles, labels, ncol=ncol, fontsize=8)
|
|
843
|
+
else:
|
|
844
|
+
ax_gpu.text(
|
|
845
|
+
0.5,
|
|
846
|
+
0.5,
|
|
847
|
+
"No GPUs detected",
|
|
848
|
+
ha="center",
|
|
849
|
+
va="center",
|
|
850
|
+
transform=ax_gpu.transAxes,
|
|
851
|
+
fontsize=14,
|
|
852
|
+
)
|
|
853
|
+
ax_gpu.set_ylabel("GPU Usage (%)", fontsize=12, fontweight="bold")
|
|
854
|
+
|
|
855
|
+
ax_gpu.grid(True, alpha=0.3)
|
|
856
|
+
ax_gpu.set_title(
|
|
857
|
+
f"GPU Pressure (n={self.num_gpus} devices)", fontsize=12, pad=_GPU_TITLE_PAD
|
|
858
|
+
)
|
|
859
|
+
ax_gpu.set_xlabel("Time (minutes)", fontsize=12, fontweight="bold")
|
|
860
|
+
|
|
861
|
+
# Overlay top-level pipeline stage boundaries as dimgray divider lines on all three
|
|
862
|
+
# panels (labelled once on the CPU panel) so resource plateaus are attributable at a
|
|
863
|
+
# glance ("this plateau = round 3 data gen"). Done after all panels exist so the lines
|
|
864
|
+
# land on every shared-x axis. Fully exception-guarded: a broken overlay must never
|
|
865
|
+
# cost the resource plot.
|
|
866
|
+
if self.config.monitor.annotate_stages:
|
|
867
|
+
try:
|
|
868
|
+
self._annotate_stage_spans([ax_cpu, ax_ram, ax_gpu], current_time)
|
|
869
|
+
except Exception as e:
|
|
870
|
+
logger.error(f"Failed to annotate pipeline stages on resource plot: {e}")
|
|
871
|
+
|
|
872
|
+
# Reserve the headroom band above each panel for the external legends and lifted titles
|
|
873
|
+
# (per-axes bbox_to_anchor handles the right-alignment). `top` leaves room above the CPU
|
|
874
|
+
# panel for its lifted title to clear the suptitle; `hspace` sizes the inter-panel band
|
|
875
|
+
# that holds the (tallest) GPU legend. tight_layout is intentionally not used: it doesn't
|
|
876
|
+
# account for the out-of-axes legends and warns on the twinx GPU panel;
|
|
877
|
+
# savefig(bbox_inches="tight") still trims the final canvas.
|
|
878
|
+
fig.subplots_adjust(left=0.07, right=0.91, top=0.91, bottom=0.05, hspace=0.5)
|
|
879
|
+
|
|
880
|
+
# Save plot
|
|
881
|
+
output_path = os.path.join(
|
|
882
|
+
self.config.output_path, "plots", f"resource_utilization_{self.tag}.png"
|
|
883
|
+
)
|
|
884
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True) # Create dir if it doesn't exist
|
|
885
|
+
|
|
886
|
+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
|
887
|
+
|
|
888
|
+
plt.close(fig)
|
|
889
|
+
|
|
890
|
+
logger.info(f"Resource utilization plot saved to: {output_path}")
|
|
891
|
+
|
|
892
|
+
# Upload to Slack
|
|
893
|
+
logger_instance = get_logger()
|
|
894
|
+
if logger_instance:
|
|
895
|
+
logger_instance.upload_image_to_slack(
|
|
896
|
+
output_path,
|
|
897
|
+
title=f"Resource Utilization - {self.tag}",
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def init_monitor() -> ResourceMonitor:
|
|
902
|
+
"""
|
|
903
|
+
Initialize global monitor instance (call once at startup)
|
|
904
|
+
"""
|
|
905
|
+
monitor = ResourceMonitor()
|
|
906
|
+
monitor.start()
|
|
907
|
+
|
|
908
|
+
# Late import to avoid circular dependency (manager imports from monitor)
|
|
909
|
+
from aetherscan.manager import register_monitor # noqa: PLC0415
|
|
910
|
+
|
|
911
|
+
register_monitor(monitor)
|
|
912
|
+
|
|
913
|
+
return monitor
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def get_monitor() -> ResourceMonitor | None:
|
|
917
|
+
"""Get the global monitor instance"""
|
|
918
|
+
monitor = ResourceMonitor._instance
|
|
919
|
+
|
|
920
|
+
if monitor is None:
|
|
921
|
+
logger.warning("No monitor instance initialized")
|
|
922
|
+
|
|
923
|
+
return monitor
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def shutdown_monitor():
|
|
927
|
+
"""Shutdown the global monitor instance (call on exit)"""
|
|
928
|
+
monitor = ResourceMonitor._instance
|
|
929
|
+
|
|
930
|
+
if monitor is None:
|
|
931
|
+
logger.warning("No monitor instance initialized")
|
|
932
|
+
return
|
|
933
|
+
|
|
934
|
+
monitor.stop()
|
|
935
|
+
ResourceMonitor._reset()
|