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.
@@ -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()