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
aetherscan/round_data.py
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Disk-backed (memmap) round datasets for Aetherscan training.
|
|
3
|
+
|
|
4
|
+
Each training round's data lives on disk as .npy memmaps under
|
|
5
|
+
{round_data_dir}/{save_tag}/round_{k:02d}/ instead of ~294 GB of main-process RAM:
|
|
6
|
+
workers write generated cadences straight into the memmaps, training reads them back
|
|
7
|
+
through np.load(mmap_mode="r"), and the OS page cache keeps steady-state reads at
|
|
8
|
+
RAM speed while remaining evictable under memory pressure (no more OOM kills).
|
|
9
|
+
|
|
10
|
+
This module owns:
|
|
11
|
+
- RoundDataPaths: the on-disk layout of one round's dataset.
|
|
12
|
+
- The atomic `.done` manifest protocol (written only after all workers finish, via
|
|
13
|
+
.tmp -> os.replace like preprocessing's _extract_stamps_worker).
|
|
14
|
+
- Startup archive/reuse/delete semantics for a tag's round-data directory.
|
|
15
|
+
- RoundDataProducer: a dedicated process that generates round k+1 while round k
|
|
16
|
+
trains (and pre-generates the RF dataset while the last round trains), streaming
|
|
17
|
+
stats back to the main process (DB writes stay in main — the DB queue is a thread
|
|
18
|
+
queue.Queue, not process-safe).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import math
|
|
27
|
+
import multiprocessing
|
|
28
|
+
import os
|
|
29
|
+
import queue
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import signal
|
|
33
|
+
import sys
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import traceback
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from logging.handlers import QueueListener
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
import psutil
|
|
42
|
+
|
|
43
|
+
from aetherscan.benchmark import record_stage, round_stage_name
|
|
44
|
+
from aetherscan.logger import init_worker_logging
|
|
45
|
+
from aetherscan.manager import get_manager
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# The producer process is started with the SPAWN method, not fork: the training parent holds
|
|
50
|
+
# deep TF / NCCL / gRPC / CUDA thread state whose locks a forked child can inherit in a locked
|
|
51
|
+
# state — an early producer prototype deadlocked on a futex before reaching its own code. A
|
|
52
|
+
# spawned child runs a fresh interpreter with none of that baggage (its own pool workers are
|
|
53
|
+
# then forked from the clean single-threaded producer, which is safe).
|
|
54
|
+
_MP_CONTEXT = multiprocessing.get_context("spawn")
|
|
55
|
+
|
|
56
|
+
# Directory-name pattern for one round's dataset (1-based, mirrors checkpoint round_XX tags)
|
|
57
|
+
_ROUND_DIR_PATTERN = re.compile(r"^round_(\d+)$")
|
|
58
|
+
|
|
59
|
+
# Number of values sampled per array for the manifest's cheap corruption check
|
|
60
|
+
_MANIFEST_SAMPLE_COUNT = 8
|
|
61
|
+
|
|
62
|
+
# Pidfile written next to a tag's round dirs by RoundDataProducer.start() (removed on graceful
|
|
63
|
+
# shutdown). An ungraceful main-process death (SIGKILL/OOM) leaves the producer orphaned with
|
|
64
|
+
# an argv of `... spawn_main ...` that no process-discovery pattern can attribute to Aetherscan
|
|
65
|
+
# — this file is what lets utils/kill_pipeline.sh and _reap_stale_producer() find the tree.
|
|
66
|
+
_PRODUCER_PIDFILE = "producer.pid"
|
|
67
|
+
|
|
68
|
+
# How long the producer's request loop waits on its queue before re-checking that the parent
|
|
69
|
+
# process is still alive (see the ppid watch in _producer_main).
|
|
70
|
+
_PARENT_POLL_INTERVAL_S = 5.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class RoundDataPaths:
|
|
75
|
+
"""On-disk layout of one round's dataset: {main,true,false,labels}.npy, sibling
|
|
76
|
+
{main,true,false}_lognorm.npy parameter arrays, + a .done manifest."""
|
|
77
|
+
|
|
78
|
+
round_dir: str
|
|
79
|
+
round_idx: int
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def for_round(cls, base_dir: str, round_idx: int) -> RoundDataPaths:
|
|
83
|
+
"""Build the paths for 1-based round `round_idx` under `base_dir` (round_{k:02d})."""
|
|
84
|
+
return cls(round_dir=os.path.join(base_dir, f"round_{round_idx:02d}"), round_idx=round_idx)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def main_path(self) -> str:
|
|
88
|
+
return os.path.join(self.round_dir, "main.npy")
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def true_path(self) -> str:
|
|
92
|
+
return os.path.join(self.round_dir, "true.npy")
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def false_path(self) -> str:
|
|
96
|
+
return os.path.join(self.round_dir, "false.npy")
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def labels_path(self) -> str:
|
|
100
|
+
return os.path.join(self.round_dir, "labels.npy")
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def done_path(self) -> str:
|
|
104
|
+
return os.path.join(self.round_dir, f"round_{self.round_idx:02d}.done")
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def array_paths(self) -> dict[str, str]:
|
|
108
|
+
"""Mapping of array name -> .npy path for the three cadence arrays."""
|
|
109
|
+
return {"main": self.main_path, "true": self.true_path, "false": self.false_path}
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def lognorm_paths(self) -> dict[str, str]:
|
|
113
|
+
"""Mapping of array name -> sibling .npy path holding that array's per-observation
|
|
114
|
+
(min_log, range_log) log-norm parameters, shape (n_samples, num_observations, 2)."""
|
|
115
|
+
return {
|
|
116
|
+
name: os.path.join(self.round_dir, f"{name}_lognorm.npy")
|
|
117
|
+
for name in ("main", "true", "false")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _array_checksum(arr: np.ndarray) -> dict:
|
|
122
|
+
"""
|
|
123
|
+
Cheap, sha-less checksum for a (possibly huge) memmap: total element count plus a few
|
|
124
|
+
deterministically-sampled flat-index values. Positions are derived from the element count,
|
|
125
|
+
so validation re-samples the same spots without storing an RNG state.
|
|
126
|
+
|
|
127
|
+
NOTE: this is a smoke test, not integrity protection. It reliably catches truncation and
|
|
128
|
+
gross corruption (wrong element count, shape, swapped arrays), but sub-threshold damage
|
|
129
|
+
slips through: a randomly corrupted fraction f of one array is only detected with
|
|
130
|
+
probability 1-(1-f)^_MANIFEST_SAMPLE_COUNT (e.g. a single lost page in a ~90 GB array is
|
|
131
|
+
~never sampled). That is acceptable because a crash leaves NO manifest at all (the dir is
|
|
132
|
+
then treated as garbage and regenerated), so the checksum only guards against same-run
|
|
133
|
+
disk/NFS corruption between writing .done and reading it back — not power-loss/torn-page
|
|
134
|
+
scenarios. If stronger guarantees are ever needed, upgrade to a full content hash.
|
|
135
|
+
"""
|
|
136
|
+
# NOTE: reshape(-1) is a zero-copy view here (and in validate_done_manifest) because
|
|
137
|
+
# open_memmap/np.save arrays are always C-contiguous; on a non-contiguous array it would
|
|
138
|
+
# silently materialize a full in-RAM copy — switch to .ravel()/.flat if the layout ever changes
|
|
139
|
+
flat = arr.reshape(-1)
|
|
140
|
+
n = int(flat.size)
|
|
141
|
+
rng = np.random.default_rng(n)
|
|
142
|
+
positions = sorted({int(i) for i in rng.integers(0, n, size=min(_MANIFEST_SAMPLE_COUNT, n))})
|
|
143
|
+
if arr.dtype.kind in ("U", "S"):
|
|
144
|
+
samples = [[i, str(flat[i])] for i in positions]
|
|
145
|
+
else:
|
|
146
|
+
samples = [[i, float(flat[i])] for i in positions]
|
|
147
|
+
return {"elements": n, "samples": samples}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _checksum_value_matches(recorded, actual) -> bool:
|
|
151
|
+
"""Compare one manifest sample against the on-disk value (NaN == NaN counts as a match)."""
|
|
152
|
+
if isinstance(recorded, str):
|
|
153
|
+
return recorded == str(actual)
|
|
154
|
+
actual = float(actual)
|
|
155
|
+
return recorded == actual or (math.isnan(recorded) and math.isnan(actual))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _manifest_paths(paths: RoundDataPaths) -> dict[str, str]:
|
|
159
|
+
"""Every array the .done manifest covers: cadence arrays, lognorm siblings, labels."""
|
|
160
|
+
return {
|
|
161
|
+
**paths.array_paths,
|
|
162
|
+
**{f"{name}_lognorm": p for name, p in paths.lognorm_paths.items()},
|
|
163
|
+
"labels": paths.labels_path,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_manifest(
|
|
168
|
+
paths: RoundDataPaths,
|
|
169
|
+
n_samples: int,
|
|
170
|
+
snr_base: float,
|
|
171
|
+
snr_range: float,
|
|
172
|
+
wall_time_s: float,
|
|
173
|
+
chunk_count: int,
|
|
174
|
+
array_dtype: str = "float32",
|
|
175
|
+
) -> dict:
|
|
176
|
+
"""Assemble the .done manifest dict by re-opening the finished arrays read-only.
|
|
177
|
+
`array_dtype` is the requested cadence-array dtype; the per-array `dtypes` map records
|
|
178
|
+
what is actually on disk (labels/lognorm sidecars stay float32/U regardless)."""
|
|
179
|
+
shapes: dict[str, list[int]] = {}
|
|
180
|
+
dtypes: dict[str, str] = {}
|
|
181
|
+
checksums: dict[str, dict] = {}
|
|
182
|
+
for name, path in _manifest_paths(paths).items():
|
|
183
|
+
arr = np.load(path, mmap_mode="r")
|
|
184
|
+
shapes[name] = list(arr.shape)
|
|
185
|
+
dtypes[name] = str(arr.dtype)
|
|
186
|
+
checksums[name] = _array_checksum(arr)
|
|
187
|
+
del arr
|
|
188
|
+
return {
|
|
189
|
+
"round_idx": paths.round_idx,
|
|
190
|
+
"n_samples": int(n_samples),
|
|
191
|
+
"shapes": shapes,
|
|
192
|
+
"dtypes": dtypes,
|
|
193
|
+
"array_dtype": str(array_dtype),
|
|
194
|
+
"snr_base": float(snr_base),
|
|
195
|
+
"snr_range": float(snr_range),
|
|
196
|
+
"checksums": checksums,
|
|
197
|
+
"wall_time_s": float(wall_time_s),
|
|
198
|
+
"chunk_count": int(chunk_count),
|
|
199
|
+
"created_at": time.time(),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def write_done_manifest(paths: RoundDataPaths, manifest: dict) -> None:
|
|
204
|
+
"""Write the manifest atomically (.tmp -> os.replace) so a crash can't leave a partial
|
|
205
|
+
.done file — a round dir either has a complete manifest or is treated as garbage."""
|
|
206
|
+
tmp_path = paths.done_path + ".tmp"
|
|
207
|
+
with open(tmp_path, "w") as f:
|
|
208
|
+
json.dump(manifest, f, indent=2)
|
|
209
|
+
os.replace(tmp_path, paths.done_path)
|
|
210
|
+
logger.info(f"Wrote round-data manifest: {paths.done_path}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def validate_done_manifest(
|
|
214
|
+
paths: RoundDataPaths,
|
|
215
|
+
expected_n_samples: int | None = None,
|
|
216
|
+
expected_array_dtype: str | None = None,
|
|
217
|
+
) -> dict | None:
|
|
218
|
+
"""
|
|
219
|
+
Validate a round dir's .done manifest against the arrays on disk. Returns the manifest
|
|
220
|
+
dict when everything checks out (round index, optional expected sample count, per-array
|
|
221
|
+
existence, shape, element count, and the sampled checksum values), else None.
|
|
222
|
+
|
|
223
|
+
NOTE: validation does NOT compare the manifest's snr_base/snr_range against what the caller
|
|
224
|
+
wants — it only proves the arrays match the manifest that was written with them. Reusing a
|
|
225
|
+
validated round dir is safe TODAY only because _setup_directories deletes every round dir
|
|
226
|
+
>= start_round before generation begins, so a reused dir is always one written earlier in
|
|
227
|
+
THIS run's curriculum. If cross-run round reuse is ever added (the obvious ~295 GB-saving
|
|
228
|
+
optimization), a dir generated under a different SNR curriculum would validate and be
|
|
229
|
+
silently trained on — add an expected_snr guard here (and at the _producer_main reuse
|
|
230
|
+
short-circuit) before relaxing the delete-on-startup policy.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
if not os.path.isfile(paths.done_path):
|
|
234
|
+
return None
|
|
235
|
+
with open(paths.done_path) as f:
|
|
236
|
+
manifest = json.load(f)
|
|
237
|
+
|
|
238
|
+
if manifest.get("round_idx") != paths.round_idx:
|
|
239
|
+
logger.warning(f"Manifest round_idx mismatch in {paths.done_path}")
|
|
240
|
+
return None
|
|
241
|
+
if expected_n_samples is not None and manifest.get("n_samples") != expected_n_samples:
|
|
242
|
+
logger.warning(
|
|
243
|
+
f"Manifest n_samples ({manifest.get('n_samples')}) != expected "
|
|
244
|
+
f"({expected_n_samples}) in {paths.done_path}"
|
|
245
|
+
)
|
|
246
|
+
return None
|
|
247
|
+
# Cadence-array dtype gate: a dir generated under a different round_array_dtype must
|
|
248
|
+
# not be silently reused (a float16 round fed to a float32-config resume — or vice
|
|
249
|
+
# versa — changes input numerics mid-run). Manifests predating the dtypes key are all
|
|
250
|
+
# float32 rounds.
|
|
251
|
+
if expected_array_dtype is not None:
|
|
252
|
+
manifest_dtype = manifest.get("array_dtype", "float32")
|
|
253
|
+
if manifest_dtype != expected_array_dtype:
|
|
254
|
+
logger.warning(
|
|
255
|
+
f"Manifest array_dtype ({manifest_dtype}) != expected "
|
|
256
|
+
f"({expected_array_dtype}) in {paths.done_path}"
|
|
257
|
+
)
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
manifest_dtypes = manifest.get("dtypes")
|
|
261
|
+
for name, path in _manifest_paths(paths).items():
|
|
262
|
+
if not os.path.isfile(path):
|
|
263
|
+
logger.warning(f"Manifest array missing on disk: {path}")
|
|
264
|
+
return None
|
|
265
|
+
arr = np.load(path, mmap_mode="r")
|
|
266
|
+
try:
|
|
267
|
+
if list(arr.shape) != manifest["shapes"][name]:
|
|
268
|
+
logger.warning(f"Shape mismatch for {path}")
|
|
269
|
+
return None
|
|
270
|
+
if manifest_dtypes is not None and str(arr.dtype) != manifest_dtypes[name]:
|
|
271
|
+
logger.warning(f"Dtype mismatch for {path}")
|
|
272
|
+
return None
|
|
273
|
+
checksum = manifest["checksums"][name]
|
|
274
|
+
if int(arr.size) != checksum["elements"]:
|
|
275
|
+
logger.warning(f"Element-count mismatch for {path}")
|
|
276
|
+
return None
|
|
277
|
+
flat = arr.reshape(-1)
|
|
278
|
+
for position, recorded in checksum["samples"]:
|
|
279
|
+
if not _checksum_value_matches(recorded, flat[position]):
|
|
280
|
+
logger.warning(f"Sampled-value mismatch for {path} at {position}")
|
|
281
|
+
return None
|
|
282
|
+
finally:
|
|
283
|
+
del arr
|
|
284
|
+
|
|
285
|
+
return manifest
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.warning(f"Failed to validate round-data manifest {paths.done_path}: {e}")
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def load_round_arrays(paths: RoundDataPaths) -> dict[str, np.ndarray]:
|
|
292
|
+
"""
|
|
293
|
+
Open one round's arrays for training. The three cadence arrays come back as
|
|
294
|
+
copy-on-write memmaps (np.load(mmap_mode="c")) so nothing is pulled into RAM until
|
|
295
|
+
batches gather it — the OS page cache keeps hot pages resident and evicts them under
|
|
296
|
+
memory pressure instead of OOM-killing the process. mmap_mode="c" (MAP_PRIVATE) rather
|
|
297
|
+
than "r": reads behave identically and the on-disk files stay write-protected, but the
|
|
298
|
+
mapping is writable from numpy's point of view, which lets train._as_cpu_tensor export
|
|
299
|
+
it zero-copy over dlpack for the graph-side gather (numpy refuses dlpack export of
|
|
300
|
+
read-only arrays; nothing ever writes, so no private pages are materialized). Labels and
|
|
301
|
+
the main array's log-norm parameters ("lognorm", consumed by the latent-traversal plot)
|
|
302
|
+
are small (~24 MB for the main array at full scale) and loaded eagerly.
|
|
303
|
+
"""
|
|
304
|
+
return {
|
|
305
|
+
"concatenated": np.load(paths.main_path, mmap_mode="c"),
|
|
306
|
+
"true": np.load(paths.true_path, mmap_mode="c"),
|
|
307
|
+
"false": np.load(paths.false_path, mmap_mode="c"),
|
|
308
|
+
"labels": np.load(paths.labels_path),
|
|
309
|
+
"lognorm": np.load(paths.lognorm_paths["main"]),
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _reap_stale_producer(base_dir: str, term_timeout: float = 5.0) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Restart-race guard: a previous run's producer that survived an ungraceful main-process
|
|
316
|
+
death (see the pidfile written by RoundDataProducer.start()) may still be writing into
|
|
317
|
+
this tag's round_XX dirs — racing prepare_round_data_dir's rmtree against those live
|
|
318
|
+
writes corrupts the very dirs this run is about to regenerate or reuse. Read the recorded
|
|
319
|
+
PID and terminate that tree (SIGTERM first, so the producer's handler reaps its own pool;
|
|
320
|
+
then SIGKILL any survivors) before any deletion happens. Always removes the pidfile.
|
|
321
|
+
"""
|
|
322
|
+
pidfile = os.path.join(base_dir, _PRODUCER_PIDFILE)
|
|
323
|
+
if not os.path.exists(pidfile):
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
with open(pidfile) as f:
|
|
328
|
+
pid = int(f.read().strip())
|
|
329
|
+
except (OSError, ValueError):
|
|
330
|
+
pid = None
|
|
331
|
+
|
|
332
|
+
if pid is not None:
|
|
333
|
+
try:
|
|
334
|
+
proc = psutil.Process(pid)
|
|
335
|
+
# PID-reuse guard: the recorded producer was already alive when its pidfile was
|
|
336
|
+
# written, so a process created after the pidfile's mtime cannot be it.
|
|
337
|
+
if proc.create_time() <= os.path.getmtime(pidfile) + 1.0:
|
|
338
|
+
logger.warning(
|
|
339
|
+
f"Reaping orphaned RoundDataProducer (PID {pid}) recorded in {pidfile}"
|
|
340
|
+
)
|
|
341
|
+
# Snapshot the subtree while the producer is alive: once it dies, its
|
|
342
|
+
# children reparent and can no longer be found through its PID.
|
|
343
|
+
children = proc.children(recursive=True)
|
|
344
|
+
proc.terminate()
|
|
345
|
+
with contextlib.suppress(psutil.TimeoutExpired):
|
|
346
|
+
proc.wait(timeout=term_timeout)
|
|
347
|
+
for child in children:
|
|
348
|
+
with contextlib.suppress(psutil.NoSuchProcess):
|
|
349
|
+
child.kill()
|
|
350
|
+
with contextlib.suppress(psutil.NoSuchProcess):
|
|
351
|
+
proc.kill()
|
|
352
|
+
except psutil.NoSuchProcess:
|
|
353
|
+
pass # already gone — the common case (producer exited via its own ppid watch)
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logger.warning(f"Failed to reap producer recorded in {pidfile}: {e}")
|
|
356
|
+
|
|
357
|
+
with contextlib.suppress(OSError):
|
|
358
|
+
os.remove(pidfile)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def prepare_round_data_dir(
|
|
362
|
+
base_dir: str, start_round: int, expected_array_dtype: str = "float32"
|
|
363
|
+
) -> None:
|
|
364
|
+
"""
|
|
365
|
+
Startup cleanup of a tag's round-data directory, mirroring checkpoint-archiving semantics
|
|
366
|
+
(train.archive_directory) for resumable runs — except entries are deleted rather than
|
|
367
|
+
archived (a round is ~295 GB; archiving would silently double the disk budget):
|
|
368
|
+
|
|
369
|
+
- any orphaned producer recorded in the tag's pidfile is reaped first (it could still be
|
|
370
|
+
writing into the round dirs this cleanup is about to delete or validate),
|
|
371
|
+
- round dirs with index >= start_round are deleted (regenerated by this run),
|
|
372
|
+
- round dirs with index < start_round are kept only if their .done manifest validates
|
|
373
|
+
(reusable instead of regenerated); .done-less or corrupt dirs are deleted,
|
|
374
|
+
- any non-round entry (e.g. a stale RF dataset dir) is deleted.
|
|
375
|
+
"""
|
|
376
|
+
os.makedirs(base_dir, exist_ok=True)
|
|
377
|
+
_reap_stale_producer(base_dir)
|
|
378
|
+
|
|
379
|
+
for entry in sorted(os.listdir(base_dir)):
|
|
380
|
+
entry_path = os.path.join(base_dir, entry)
|
|
381
|
+
if not os.path.isdir(entry_path):
|
|
382
|
+
continue
|
|
383
|
+
match = _ROUND_DIR_PATTERN.match(entry)
|
|
384
|
+
if match is None:
|
|
385
|
+
logger.info(f"Deleting stale round-data entry: {entry_path}")
|
|
386
|
+
shutil.rmtree(entry_path, ignore_errors=True)
|
|
387
|
+
continue
|
|
388
|
+
round_idx = int(match.group(1))
|
|
389
|
+
if round_idx >= start_round:
|
|
390
|
+
logger.info(f"Deleting round-data dir for round >= {start_round}: {entry_path}")
|
|
391
|
+
shutil.rmtree(entry_path, ignore_errors=True)
|
|
392
|
+
elif (
|
|
393
|
+
validate_done_manifest(
|
|
394
|
+
RoundDataPaths(entry_path, round_idx), expected_array_dtype=expected_array_dtype
|
|
395
|
+
)
|
|
396
|
+
is None
|
|
397
|
+
):
|
|
398
|
+
logger.info(f"Deleting round-data dir with missing/invalid manifest: {entry_path}")
|
|
399
|
+
shutil.rmtree(entry_path, ignore_errors=True)
|
|
400
|
+
else:
|
|
401
|
+
logger.info(f"Keeping validated round-data dir: {entry_path}")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
# RoundDataProducer — background generation process
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
# Producer-process-local handle to its worker pool, so the SIGTERM handler can
|
|
409
|
+
# terminate the pool's children before the producer itself dies.
|
|
410
|
+
_PRODUCER_POOL = None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _default_generate(paths, round_idx, snr_base, snr_range, pool, params, stats_cb, progress_cb):
|
|
414
|
+
"""Real generation entry point for the producer process (test hooks can replace it)."""
|
|
415
|
+
# Deferred import: data_generation pulls in setigen/scipy, which the parent may not need
|
|
416
|
+
# at round_data import time (cli.py must stay stdlib-importable via config/cli only).
|
|
417
|
+
from aetherscan.data_generation import generate_round_to_memmap # noqa: PLC0415
|
|
418
|
+
|
|
419
|
+
return generate_round_to_memmap(
|
|
420
|
+
paths=paths,
|
|
421
|
+
n_samples=params["n_samples"],
|
|
422
|
+
snr_base=snr_base,
|
|
423
|
+
snr_range=snr_range,
|
|
424
|
+
width_bin=params["width_bin"],
|
|
425
|
+
num_observations=params["num_observations"],
|
|
426
|
+
time_bins=params["time_bins"],
|
|
427
|
+
chunk_size=params["chunk_size"],
|
|
428
|
+
task_size=params["task_size"],
|
|
429
|
+
freq_resolution=params["freq_resolution"],
|
|
430
|
+
time_resolution=params["time_resolution"],
|
|
431
|
+
pool=pool,
|
|
432
|
+
round_num=round_idx,
|
|
433
|
+
seed=params["seed"],
|
|
434
|
+
stats_cb=stats_cb,
|
|
435
|
+
progress_cb=progress_cb,
|
|
436
|
+
array_dtype=params.get("array_dtype", "float32"),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _producer_main(
|
|
441
|
+
request_queue,
|
|
442
|
+
result_queue,
|
|
443
|
+
params: dict,
|
|
444
|
+
generate_fn=None,
|
|
445
|
+
log_queue=None,
|
|
446
|
+
parent_pid: int | None = None,
|
|
447
|
+
poll_interval: float = _PARENT_POLL_INTERVAL_S,
|
|
448
|
+
) -> None:
|
|
449
|
+
"""
|
|
450
|
+
Producer process entry point (spawn-started — see _MP_CONTEXT). Owns a private worker pool
|
|
451
|
+
(workers attach to the background-plate shared memory created by the main process) and
|
|
452
|
+
generates rounds on request, isolated from the main process's GIL (TF's prefetch threads no
|
|
453
|
+
longer slow generation down, and generation no longer steals cycles from training).
|
|
454
|
+
|
|
455
|
+
Protocol (multiprocessing.Queues):
|
|
456
|
+
- in: ("generate", round_idx, snr_base, snr_range[, overrides]) | ("shutdown",)
|
|
457
|
+
`overrides` (optional dict, absent on plain round requests — the historical
|
|
458
|
+
4-tuple shape) customizes one request without touching `params`:
|
|
459
|
+
- "dir_name": write into {base_dir}/{dir_name} (paths round_idx pinned to 0 —
|
|
460
|
+
see the request loop) instead of round_{round_idx:02d}
|
|
461
|
+
- "n_samples": per-request sample count (the RF dataset is num_samples_rf,
|
|
462
|
+
not the num_samples_beta_vae this producer was constructed with)
|
|
463
|
+
- out: ("stats", round_idx, segment_dict) per class-segment per chunk
|
|
464
|
+
("progress", round_idx, chunk, n_chunks) per chunk
|
|
465
|
+
("timing", round_idx, start_ts, end_ts) generation wall-clock span, sent
|
|
466
|
+
before "done" (skipped on reuse —
|
|
467
|
+
no generation happened); the
|
|
468
|
+
main-process drainer records it as
|
|
469
|
+
a pipeline_stages row (this
|
|
470
|
+
process can't reach the DB writer
|
|
471
|
+
queue, a thread queue.Queue)
|
|
472
|
+
("done", round_idx, manifest) on success (or valid reuse)
|
|
473
|
+
("error", round_idx, traceback_str) on failure (producer keeps serving)
|
|
474
|
+
("shutdown_ack",) right before a graceful exit
|
|
475
|
+
|
|
476
|
+
`log_queue` is a spawn-context multiprocessing queue relayed into the main process's
|
|
477
|
+
logging pipeline by RoundDataProducer.start() — a spawned child has no inherited Logger
|
|
478
|
+
singleton, so it (and its pool workers) attach QueueHandlers to this queue explicitly.
|
|
479
|
+
`generate_fn` is a test seam: when provided, no worker pool is created and the stub is
|
|
480
|
+
called in place of the real memmap generation (see tests/unit/test_round_data.py).
|
|
481
|
+
|
|
482
|
+
`parent_pid` is the PID of the process this producer must not outlive (passed by
|
|
483
|
+
RoundDataProducer.start(); defaults to os.getppid() for the thread-driven tests, where
|
|
484
|
+
it never changes). The request loop polls it every `poll_interval` seconds: an
|
|
485
|
+
ungraceful parent death (SIGKILL/OOM — no cleanup runs, no "shutdown" is ever sent)
|
|
486
|
+
reparents this process, and the watch terminates the pool and exits instead of leaving
|
|
487
|
+
an orphan generating at full CPU and pinning the background SHM forever.
|
|
488
|
+
"""
|
|
489
|
+
global _PRODUCER_POOL
|
|
490
|
+
|
|
491
|
+
if parent_pid is None:
|
|
492
|
+
parent_pid = os.getppid()
|
|
493
|
+
|
|
494
|
+
is_main_thread = threading.current_thread() is threading.main_thread()
|
|
495
|
+
if is_main_thread:
|
|
496
|
+
# The log queue is a multiprocessing.Queue — safe to use from this process.
|
|
497
|
+
init_worker_logging(log_queue)
|
|
498
|
+
|
|
499
|
+
# Ignore SIGINT: the main process's ResourceManager coordinates Ctrl-C cleanup.
|
|
500
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
501
|
+
|
|
502
|
+
# SIGTERM handler mirrors data_generation._init_worker's:
|
|
503
|
+
# WARN: DO NOT LOG ANYTHING IN THIS HANDLER — the log QueueHandler's feeder thread
|
|
504
|
+
# needs the GIL, which may be held elsewhere, and a blocked handler deadlocks
|
|
505
|
+
# termination (see data_generation.py's canonical worker handler).
|
|
506
|
+
def _cleanup_on_sigterm(signum, frame):
|
|
507
|
+
with contextlib.suppress(Exception):
|
|
508
|
+
if _PRODUCER_POOL is not None:
|
|
509
|
+
# Forward termination to the pool's children so they don't outlive us
|
|
510
|
+
# (SIGKILL on this process would otherwise orphan them).
|
|
511
|
+
_PRODUCER_POOL.terminate()
|
|
512
|
+
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
513
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
514
|
+
|
|
515
|
+
signal.signal(signal.SIGTERM, _cleanup_on_sigterm)
|
|
516
|
+
|
|
517
|
+
if sys.platform == "linux":
|
|
518
|
+
# Belt-and-braces (Linux only): ask the kernel to SIGTERM this process the moment
|
|
519
|
+
# its parent dies — covers mid-generation parent death, when the request-loop
|
|
520
|
+
# ppid watch below isn't polling. PR_SET_PDEATHSIG is set here in the child's
|
|
521
|
+
# main entry (it fires on exit of the *thread* that set it, and is cleared
|
|
522
|
+
# across this process's own fork() — the pool workers don't inherit it, but the
|
|
523
|
+
# SIGTERM handler above terminates the pool for them).
|
|
524
|
+
with contextlib.suppress(Exception):
|
|
525
|
+
import ctypes # noqa: PLC0415
|
|
526
|
+
|
|
527
|
+
_pr_set_pdeathsig = 1 # linux/prctl.h
|
|
528
|
+
ctypes.CDLL("libc.so.6", use_errno=True).prctl(
|
|
529
|
+
_pr_set_pdeathsig, signal.SIGTERM, 0, 0, 0
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
pool = None
|
|
533
|
+
if generate_fn is None:
|
|
534
|
+
generate_fn = _default_generate
|
|
535
|
+
# Deferred import (setigen/scipy) — only the real producer needs it.
|
|
536
|
+
from aetherscan.data_generation import _init_worker # noqa: PLC0415
|
|
537
|
+
|
|
538
|
+
# Plain fork-context Pool: this spawned process is single-threaded, so forking its
|
|
539
|
+
# workers is safe — and they attach to the parent-created background SHM by name.
|
|
540
|
+
pool = multiprocessing.Pool(
|
|
541
|
+
processes=max(1, params["n_processes"]),
|
|
542
|
+
initializer=_init_worker,
|
|
543
|
+
initargs=(
|
|
544
|
+
params["shm_name"],
|
|
545
|
+
tuple(params["background_shape"]),
|
|
546
|
+
np.dtype(params["background_dtype"]),
|
|
547
|
+
log_queue,
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
_PRODUCER_POOL = pool
|
|
551
|
+
|
|
552
|
+
logger.info(f"RoundDataProducer started (PID {os.getpid()})")
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
while True:
|
|
556
|
+
try:
|
|
557
|
+
message = request_queue.get(timeout=poll_interval)
|
|
558
|
+
except queue.Empty:
|
|
559
|
+
# Parent-death watch: reparenting (to init or a subreaper) changes our ppid.
|
|
560
|
+
# Exit without a shutdown_ack — there is no one left to ack to.
|
|
561
|
+
if os.getppid() != parent_pid:
|
|
562
|
+
logger.warning(
|
|
563
|
+
"RoundDataProducer: parent process died; terminating pool and exiting"
|
|
564
|
+
)
|
|
565
|
+
break
|
|
566
|
+
continue
|
|
567
|
+
if message[0] == "shutdown":
|
|
568
|
+
logger.info("RoundDataProducer received shutdown request")
|
|
569
|
+
result_queue.put(("shutdown_ack",))
|
|
570
|
+
break
|
|
571
|
+
if message[0] != "generate":
|
|
572
|
+
logger.warning(f"RoundDataProducer ignoring unknown message: {message[0]!r}")
|
|
573
|
+
continue
|
|
574
|
+
|
|
575
|
+
_, round_idx, snr_base, snr_range = message[:4]
|
|
576
|
+
overrides = message[4] if len(message) > 4 else {}
|
|
577
|
+
n_samples = overrides.get("n_samples", params["n_samples"])
|
|
578
|
+
# The override rides a shallow copy so generate_fn keeps its params-dict
|
|
579
|
+
# signature; plain round requests pass `params` through untouched
|
|
580
|
+
request_params = (
|
|
581
|
+
params if n_samples == params["n_samples"] else {**params, "n_samples": n_samples}
|
|
582
|
+
)
|
|
583
|
+
dir_name = overrides.get("dir_name")
|
|
584
|
+
if dir_name is None:
|
|
585
|
+
paths = RoundDataPaths.for_round(params["base_dir"], round_idx)
|
|
586
|
+
else:
|
|
587
|
+
# Explicit-dir request (the RF dataset). paths.round_idx is pinned to 0 to
|
|
588
|
+
# mirror train_random_forest's rf_paths exactly — same round_00.done name,
|
|
589
|
+
# same manifest round_idx — so the parent's validate_done_manifest accepts
|
|
590
|
+
# the result no matter which side generated it. The MESSAGE round_idx is the
|
|
591
|
+
# request's seed identity, not a dir index: it reaches generate_fn as
|
|
592
|
+
# round_num (for the RF set the num_training_rounds+1 sentinel — the SAME
|
|
593
|
+
# value train_random_forest's in-process path passes), so per-task seeds
|
|
594
|
+
# derive identically and the arrays are byte-identical to in-process
|
|
595
|
+
# generation. Keep in sync with train._obtain_rf_dataset.
|
|
596
|
+
paths = RoundDataPaths(os.path.join(params["base_dir"], dir_name), 0)
|
|
597
|
+
|
|
598
|
+
existing = validate_done_manifest(
|
|
599
|
+
paths,
|
|
600
|
+
expected_n_samples=n_samples,
|
|
601
|
+
expected_array_dtype=params.get("array_dtype", "float32"),
|
|
602
|
+
)
|
|
603
|
+
if existing is not None:
|
|
604
|
+
logger.info(f"RoundDataProducer: reusing validated round {round_idx} data")
|
|
605
|
+
result_queue.put(("done", round_idx, existing))
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
logger.info(
|
|
610
|
+
f"RoundDataProducer: generating round {round_idx} data "
|
|
611
|
+
f"(SNR {snr_base}-{snr_base + snr_range})"
|
|
612
|
+
)
|
|
613
|
+
generation_start = time.time()
|
|
614
|
+
manifest = generate_fn(
|
|
615
|
+
paths,
|
|
616
|
+
round_idx,
|
|
617
|
+
snr_base,
|
|
618
|
+
snr_range,
|
|
619
|
+
pool,
|
|
620
|
+
request_params,
|
|
621
|
+
lambda segment, _r=round_idx: result_queue.put(("stats", _r, segment)),
|
|
622
|
+
lambda chunk, n_chunks, _r=round_idx: result_queue.put(
|
|
623
|
+
("progress", _r, chunk, n_chunks)
|
|
624
|
+
),
|
|
625
|
+
)
|
|
626
|
+
logger.info(f"RoundDataProducer: round {round_idx} data complete")
|
|
627
|
+
# Timing before done: queue FIFO means the drainer records the stage span
|
|
628
|
+
# before await_round() unblocks on the done message
|
|
629
|
+
result_queue.put(("timing", round_idx, generation_start, time.time()))
|
|
630
|
+
result_queue.put(("done", round_idx, manifest))
|
|
631
|
+
except Exception:
|
|
632
|
+
result_queue.put(("error", round_idx, traceback.format_exc()))
|
|
633
|
+
finally:
|
|
634
|
+
_PRODUCER_POOL = None
|
|
635
|
+
if pool is not None:
|
|
636
|
+
with contextlib.suppress(Exception):
|
|
637
|
+
pool.terminate()
|
|
638
|
+
pool.join()
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class RoundDataProducer:
|
|
642
|
+
"""
|
|
643
|
+
Main-process handle for the background round-data generation process.
|
|
644
|
+
|
|
645
|
+
Lifecycle: start() spawns the producer process (registered with ResourceManager for
|
|
646
|
+
terminate -> join -> kill cleanup) plus a drainer thread that consumes the result queue —
|
|
647
|
+
writing streamed injection stats to the DB from the main process (the DB writer queue is
|
|
648
|
+
a thread queue.Queue, not process-safe) while the GPUs train. request_generation() is
|
|
649
|
+
fire-and-forget; await_round() blocks until that round's done/error message arrives.
|
|
650
|
+
"""
|
|
651
|
+
|
|
652
|
+
def __init__(
|
|
653
|
+
self,
|
|
654
|
+
*,
|
|
655
|
+
base_dir: str,
|
|
656
|
+
n_samples: int,
|
|
657
|
+
shm_name: str,
|
|
658
|
+
background_shape: tuple,
|
|
659
|
+
background_dtype: str,
|
|
660
|
+
n_processes: int,
|
|
661
|
+
width_bin: int,
|
|
662
|
+
num_observations: int,
|
|
663
|
+
time_bins: int,
|
|
664
|
+
chunk_size: int,
|
|
665
|
+
task_size: int,
|
|
666
|
+
freq_resolution: float,
|
|
667
|
+
time_resolution: float,
|
|
668
|
+
db,
|
|
669
|
+
tag: str,
|
|
670
|
+
seed: int | None = None,
|
|
671
|
+
array_dtype: str = "float32",
|
|
672
|
+
):
|
|
673
|
+
self._params = {
|
|
674
|
+
"base_dir": base_dir,
|
|
675
|
+
"n_samples": n_samples,
|
|
676
|
+
"shm_name": shm_name,
|
|
677
|
+
"background_shape": tuple(background_shape),
|
|
678
|
+
"background_dtype": str(background_dtype),
|
|
679
|
+
"n_processes": n_processes,
|
|
680
|
+
"width_bin": width_bin,
|
|
681
|
+
"num_observations": num_observations,
|
|
682
|
+
"time_bins": time_bins,
|
|
683
|
+
"chunk_size": chunk_size,
|
|
684
|
+
"task_size": task_size,
|
|
685
|
+
"freq_resolution": freq_resolution,
|
|
686
|
+
"time_resolution": time_resolution,
|
|
687
|
+
# Pipeline root seed (config.reproducibility.seed) — crosses the spawn boundary with the
|
|
688
|
+
# rest of the params so producer-generated rounds derive the same per-round
|
|
689
|
+
# streams as in-process generation. None = OS entropy
|
|
690
|
+
"seed": seed,
|
|
691
|
+
# On-disk dtype for the cadence arrays (config.training.round_array_dtype)
|
|
692
|
+
"array_dtype": str(array_dtype),
|
|
693
|
+
}
|
|
694
|
+
self._db = db
|
|
695
|
+
self._tag = tag
|
|
696
|
+
# Queues come from the spawn context so they can cross the spawn pickling boundary
|
|
697
|
+
# (mixing contexts raises "A SemLock created in a fork context is being shared with a
|
|
698
|
+
# process in a spawn context" — which also rules out handing the child the Logger
|
|
699
|
+
# singleton's fork-context queue directly; see the relay in start())
|
|
700
|
+
self._request_queue: multiprocessing.Queue = _MP_CONTEXT.Queue()
|
|
701
|
+
self._result_queue: multiprocessing.Queue = _MP_CONTEXT.Queue()
|
|
702
|
+
self._producer_log_queue: multiprocessing.Queue = _MP_CONTEXT.Queue()
|
|
703
|
+
self._log_relay: QueueListener | None = None
|
|
704
|
+
self._process: multiprocessing.Process | None = None
|
|
705
|
+
self._drainer: threading.Thread | None = None
|
|
706
|
+
self._drainer_done = False
|
|
707
|
+
self._results: dict[int, tuple[str, object]] = {}
|
|
708
|
+
# Per-request stage-name overrides for the drainer's timing handler (main-side
|
|
709
|
+
# only, never crosses the queue): non-round requests like the RF dataset record
|
|
710
|
+
# their span under the right stage subtree instead of a nonexistent round_XX
|
|
711
|
+
self._stage_names: dict[int, str] = {}
|
|
712
|
+
self._condition = threading.Condition()
|
|
713
|
+
self._pidfile = os.path.join(base_dir, _PRODUCER_PIDFILE)
|
|
714
|
+
|
|
715
|
+
def start(self) -> None:
|
|
716
|
+
"""Spawn the producer process and the main-side result drainer thread."""
|
|
717
|
+
# A spawned child has no inherited Logger singleton, and the singleton's fork-context
|
|
718
|
+
# queue can't cross the spawn boundary — so the producer tree logs into its own
|
|
719
|
+
# spawn-context queue, and this main-side QueueListener relays each record into the
|
|
720
|
+
# main process's root handlers (i.e. into the normal file/console/Slack pipeline).
|
|
721
|
+
self._log_relay = QueueListener(
|
|
722
|
+
self._producer_log_queue,
|
|
723
|
+
*logging.getLogger().handlers,
|
|
724
|
+
respect_handler_level=False,
|
|
725
|
+
)
|
|
726
|
+
self._log_relay.start()
|
|
727
|
+
|
|
728
|
+
self._process = _MP_CONTEXT.Process(
|
|
729
|
+
target=_producer_main,
|
|
730
|
+
args=(
|
|
731
|
+
self._request_queue,
|
|
732
|
+
self._result_queue,
|
|
733
|
+
self._params,
|
|
734
|
+
None,
|
|
735
|
+
self._producer_log_queue,
|
|
736
|
+
# This process's real PID, not the child's getppid(): if the parent dies
|
|
737
|
+
# before the child captures its ppid, the child would capture the reaper's
|
|
738
|
+
# PID and its parent-death watch could never fire.
|
|
739
|
+
os.getpid(),
|
|
740
|
+
),
|
|
741
|
+
name="RoundDataProducer",
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
# The spawned child re-imports the aetherscan module chain, which includes TF; blank
|
|
745
|
+
# out GPU visibility for the child's entire process tree so nothing in the producer
|
|
746
|
+
# can ever initialize CUDA (generation is pure CPU).
|
|
747
|
+
# NOTE: another thread reading os.environ during this brief window would see the
|
|
748
|
+
# blanked value — intentionally acceptable ONLY because the parent's TF read the var
|
|
749
|
+
# once at its own GPU init (long before this point) and nothing else in the pipeline
|
|
750
|
+
# spawns GPU-visible subprocesses concurrently with training startup.
|
|
751
|
+
original_cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES")
|
|
752
|
+
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
|
753
|
+
try:
|
|
754
|
+
self._process.start()
|
|
755
|
+
finally:
|
|
756
|
+
if original_cuda_visible is None:
|
|
757
|
+
os.environ.pop("CUDA_VISIBLE_DEVICES", None)
|
|
758
|
+
else:
|
|
759
|
+
os.environ["CUDA_VISIBLE_DEVICES"] = original_cuda_visible
|
|
760
|
+
|
|
761
|
+
# Record the producer PID for post-mortem discovery: the orphan left by an ungraceful
|
|
762
|
+
# main-process death has a bare spawn_main argv that utils/kill_pipeline.sh's pattern
|
|
763
|
+
# can't attribute to Aetherscan, so the kill script (and the next run's
|
|
764
|
+
# _reap_stale_producer) read this file instead. Removed on graceful shutdown.
|
|
765
|
+
try:
|
|
766
|
+
os.makedirs(self._params["base_dir"], exist_ok=True)
|
|
767
|
+
with open(self._pidfile, "w") as f:
|
|
768
|
+
f.write(str(self._process.pid))
|
|
769
|
+
except OSError as e:
|
|
770
|
+
logger.warning(f"Failed to write producer pidfile {self._pidfile}: {e}")
|
|
771
|
+
|
|
772
|
+
manager = get_manager()
|
|
773
|
+
if manager is not None:
|
|
774
|
+
manager.register_process(self._process, name="RoundDataProducer")
|
|
775
|
+
|
|
776
|
+
self._drainer = threading.Thread(
|
|
777
|
+
target=self._drain_results, name="RoundDataDrainer", daemon=True
|
|
778
|
+
)
|
|
779
|
+
self._drainer.start()
|
|
780
|
+
logger.info(f"RoundDataProducer process started (PID {self._process.pid})")
|
|
781
|
+
|
|
782
|
+
def request_generation(
|
|
783
|
+
self,
|
|
784
|
+
round_idx: int,
|
|
785
|
+
snr_base: float,
|
|
786
|
+
snr_range: float,
|
|
787
|
+
*,
|
|
788
|
+
dir_name: str | None = None,
|
|
789
|
+
n_samples: int | None = None,
|
|
790
|
+
stage_name: str | None = None,
|
|
791
|
+
) -> None:
|
|
792
|
+
"""Queue one generation request (non-blocking). Plain requests generate 1-based
|
|
793
|
+
round `round_idx`; `dir_name`/`n_samples` override the output dir and sample count
|
|
794
|
+
(the RF dataset — `round_idx` then carries the request's seed-identity round_num,
|
|
795
|
+
see _producer_main). `stage_name` renames the timing span the drainer records to
|
|
796
|
+
"{stage_name}.data_generation" (absolute; default round_stage_name(round_idx))."""
|
|
797
|
+
if stage_name is not None:
|
|
798
|
+
self._stage_names[round_idx] = stage_name
|
|
799
|
+
target = f"'{dir_name}' dataset" if dir_name is not None else f"round {round_idx} data"
|
|
800
|
+
logger.info(f"Requesting background generation of {target}")
|
|
801
|
+
overrides: dict = {}
|
|
802
|
+
if dir_name is not None:
|
|
803
|
+
overrides["dir_name"] = dir_name
|
|
804
|
+
if n_samples is not None:
|
|
805
|
+
overrides["n_samples"] = n_samples
|
|
806
|
+
if overrides:
|
|
807
|
+
self._request_queue.put(("generate", round_idx, snr_base, snr_range, overrides))
|
|
808
|
+
else:
|
|
809
|
+
# Plain round requests keep the historical 4-tuple shape
|
|
810
|
+
self._request_queue.put(("generate", round_idx, snr_base, snr_range))
|
|
811
|
+
|
|
812
|
+
def await_round(self, round_idx: int) -> dict:
|
|
813
|
+
"""
|
|
814
|
+
Block until round `round_idx`'s done/error message arrives; return the manifest on
|
|
815
|
+
success, raise RuntimeError (carrying the producer traceback) on generation failure
|
|
816
|
+
or if the producer died without answering.
|
|
817
|
+
"""
|
|
818
|
+
while True:
|
|
819
|
+
with self._condition:
|
|
820
|
+
if round_idx in self._results:
|
|
821
|
+
kind, payload = self._results[round_idx]
|
|
822
|
+
break
|
|
823
|
+
if self._drainer_done:
|
|
824
|
+
raise RuntimeError(
|
|
825
|
+
f"RoundDataProducer exited before producing round {round_idx} data"
|
|
826
|
+
)
|
|
827
|
+
# NOTE: the drainer's notify_all() wakes this immediately on done/error; the
|
|
828
|
+
# 1 s timeout is not a polling latency, just a liveness re-check so a drainer
|
|
829
|
+
# that died without notifying can't strand this wait forever
|
|
830
|
+
self._condition.wait(timeout=1.0)
|
|
831
|
+
|
|
832
|
+
if kind == "error":
|
|
833
|
+
raise RuntimeError(f"Round {round_idx} data generation failed in producer:\n{payload}")
|
|
834
|
+
return payload
|
|
835
|
+
|
|
836
|
+
def shutdown(self, timeout: float = 10.0) -> None:
|
|
837
|
+
"""Request a graceful producer exit, escalating to terminate/kill via the manager's
|
|
838
|
+
ManagedProcess if the producer doesn't wind down in time (e.g. mid-generation)."""
|
|
839
|
+
if self._process is None:
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
with contextlib.suppress(Exception):
|
|
843
|
+
self._request_queue.put(("shutdown",))
|
|
844
|
+
self._process.join(timeout)
|
|
845
|
+
|
|
846
|
+
manager = get_manager()
|
|
847
|
+
if manager is not None:
|
|
848
|
+
# No-op join if already exited; terminate -> join -> kill escalation otherwise.
|
|
849
|
+
manager.close_process(self._process)
|
|
850
|
+
elif self._process.is_alive():
|
|
851
|
+
self._process.terminate()
|
|
852
|
+
self._process.join(timeout)
|
|
853
|
+
|
|
854
|
+
if self._drainer is not None:
|
|
855
|
+
self._drainer.join(timeout=timeout)
|
|
856
|
+
if self._log_relay is not None:
|
|
857
|
+
with contextlib.suppress(Exception):
|
|
858
|
+
self._log_relay.stop()
|
|
859
|
+
self._log_relay = None
|
|
860
|
+
self._process = None
|
|
861
|
+
# Graceful shutdown: the pidfile only exists to mark an orphan candidate.
|
|
862
|
+
with contextlib.suppress(OSError):
|
|
863
|
+
os.remove(self._pidfile)
|
|
864
|
+
logger.info("RoundDataProducer shut down")
|
|
865
|
+
|
|
866
|
+
def _handle_message(self, message: tuple) -> None:
|
|
867
|
+
"""Dispatch one producer message (runs on the drainer thread)."""
|
|
868
|
+
kind = message[0]
|
|
869
|
+
if kind == "stats":
|
|
870
|
+
# Deferred import: keep round_data importable without the setigen stack.
|
|
871
|
+
from aetherscan.data_generation import write_segment_stats # noqa: PLC0415
|
|
872
|
+
|
|
873
|
+
_, _round_idx, segment = message
|
|
874
|
+
write_segment_stats(self._db, self._tag, segment)
|
|
875
|
+
elif kind == "progress":
|
|
876
|
+
_, round_idx, chunk, n_chunks = message
|
|
877
|
+
logger.info(f"Round {round_idx} data generation: chunk {chunk}/{n_chunks} complete")
|
|
878
|
+
elif kind == "timing":
|
|
879
|
+
# The producer's generation wall-clock span, recorded from this (main) process
|
|
880
|
+
# because the DB writer queue is thread-only
|
|
881
|
+
_, round_idx, start_ts, end_ts = message
|
|
882
|
+
stage = self._stage_names.get(round_idx) or round_stage_name(round_idx)
|
|
883
|
+
record_stage(
|
|
884
|
+
f"{stage}.data_generation",
|
|
885
|
+
start_ts,
|
|
886
|
+
end_ts,
|
|
887
|
+
tag=self._tag,
|
|
888
|
+
metadata={"source": "producer"},
|
|
889
|
+
)
|
|
890
|
+
elif kind in ("done", "error"):
|
|
891
|
+
_, round_idx, payload = message
|
|
892
|
+
with self._condition:
|
|
893
|
+
self._results[round_idx] = (kind, payload)
|
|
894
|
+
self._condition.notify_all()
|
|
895
|
+
else:
|
|
896
|
+
logger.warning(f"RoundDataDrainer ignoring unknown message: {kind!r}")
|
|
897
|
+
|
|
898
|
+
def _drain_results(self) -> None:
|
|
899
|
+
"""Drainer thread body: consume producer messages until shutdown-ack or producer death,
|
|
900
|
+
then sweep any messages still buffered in the queue. A failure handling one message
|
|
901
|
+
(e.g. a DB hiccup on a stats write) must not kill the drainer — done/error messages
|
|
902
|
+
for in-flight rounds still need to unblock await_round()."""
|
|
903
|
+
|
|
904
|
+
def _handle_safely(message: tuple) -> None:
|
|
905
|
+
try:
|
|
906
|
+
self._handle_message(message)
|
|
907
|
+
except Exception as e:
|
|
908
|
+
logger.error(f"RoundDataDrainer failed to handle {message[0]!r} message: {e}")
|
|
909
|
+
|
|
910
|
+
while True:
|
|
911
|
+
try:
|
|
912
|
+
message = self._result_queue.get(timeout=1.0)
|
|
913
|
+
except queue.Empty:
|
|
914
|
+
if self._process is None or not self._process.is_alive():
|
|
915
|
+
break
|
|
916
|
+
continue
|
|
917
|
+
if message[0] == "shutdown_ack":
|
|
918
|
+
break
|
|
919
|
+
_handle_safely(message)
|
|
920
|
+
|
|
921
|
+
# Final sweep: the producer may have exited with messages still in the pipe.
|
|
922
|
+
while True:
|
|
923
|
+
try:
|
|
924
|
+
message = self._result_queue.get_nowait()
|
|
925
|
+
except queue.Empty:
|
|
926
|
+
break
|
|
927
|
+
if message[0] != "shutdown_ack":
|
|
928
|
+
_handle_safely(message)
|
|
929
|
+
|
|
930
|
+
with self._condition:
|
|
931
|
+
self._drainer_done = True
|
|
932
|
+
self._condition.notify_all()
|