Skip to content

Training data pipeline: disk-backed memmap rounds, background producer, batched tf.data input#117

Open
zachtheyek wants to merge 9 commits into
feature/test-suitefrom
feature/training-data-pipeline
Open

Training data pipeline: disk-backed memmap rounds, background producer, batched tf.data input#117
zachtheyek wants to merge 9 commits into
feature/test-suitefrom
feature/training-data-pipeline

Conversation

@zachtheyek

Copy link
Copy Markdown
Owner

Closes #114
Stacked on #116 (feature/test-suite) — this PR's base is that branch, so the diff shows only this unit's changes.

Summary

Overhauls the training data pipeline to fix four coupled problems that share one design (full details in #114): the ~294 GB/round in-RAM arrays that OOM-killed full-scale runs, GIL contention that made round 2+ data generation far slower than round 1, strictly serial generate-then-train phases, and per-sample tf.data generators that starved the GPUs (0–14 % utilization).

(a) Memmap-backed round datasets — new src/aetherscan/round_data.py

  • Each round's {main,true,false,labels}.npy live on disk under {round_data_dir}/{save_tag}/round_XX/ (round_data_dir defaults to <output_path>/round_data), created with np.lib.format.open_memmap and read back via np.load(mmap_mode="r").
  • A round_XX.done manifest (sample counts, shapes, SNR params, element-count + sampled-value checksums, wall time, chunk count) is written atomically (.tmpos.replace) only after all workers finish — a crash mid-generation leaves no manifest and the dir reads as garbage.
  • _setup_directories() prepares the tag's round-data dir with checkpoint-archiving-style resume semantics: delete round dirs ≥ start_round, keep earlier dirs only if their manifest validates, delete .done-less dirs (deleted rather than archived — a round is ~295 GB).
  • Round k's dir is deleted as soon as round k's training completes (--keep-round-data retains for debugging), so the steady-state disk footprint is ≤ 2 rounds with overlap.

(b) Workers write straight into the memmap — data_generation.py rewrite

  • The 1-task-per-sample dispatch (per-sample IPC pickling + main-process copies + ~20 DB writes/sample on the training thread — the GIL bottleneck) is replaced by batched tasks of --data-gen-task-size (default 256) cadences; each worker opens the round memmap r+ and writes its disjoint row range in place, returning only the small per-sample stats dicts.
  • The 8 class-segments per chunk go through one unified pool.map barrier instead of 8 sequential ones. signal_injection_chunk_size % 4 == 0 (already CLI-validated) makes quarters/halves exact, removing the ceil-division oversample/subsample dance and its stats-filtering code.
  • The old commented-out shared-memory-output redesign blocks are removed — this PR is that redesign.

(c) Background producer process — overlap + GIL isolation

  • A RoundDataProducer process (owning its own worker pool attached to the background-plate SHM by name) generates round k+1 while round k trains. Queue protocol: generate/shutdown in; stats/progress/done/error out.
  • DB writes stay in the main process: a drainer thread consumes streamed stats and calls db.write_injection_stat (the DB queue is a thread queue.Queue, not process-safe) — also moving the per-sample DB-write GIL load off the training thread's critical path.
  • The producer is spawn-started, not forked: the first cluster smoke froze on a futex before the forked child reached its own code — a lock inherited in a locked state from the training parent's deep TF/NCCL/gRPC thread stack. A spawned child runs a fresh interpreter (its own pool workers then fork from the clean single-threaded producer, which is safe). Its logging goes through a spawn-context queue relayed into the main logging pipeline by a QueueListener (the Logger singleton's fork-context queue can't cross the spawn boundary), and CUDA_VISIBLE_DEVICES is blanked around Process.start() so nothing in the producer tree can initialize CUDA.
  • Producer + pool workers use init_worker_logging() and the canonical no-log SIGTERM handler; the producer is registered with the ResourceManager via a new tracked ManagedProcess (terminate → join → kill escalation mirroring ManagedPool.close, including killing orphaned pool children before SIGKILL).
  • --no-overlap-data-generation drops to sequential in-process generation for debugging (also the automatic fallback when n_processes=1 leaves no SHM to attach to).

(d) Batched tf.data input — prepare_distributed_train_dataset rewrite

  • Generators yield whole global batches gathered from the memmaps (sorted fancy indices per batch; epoch-level shuffle preserved), dropping .batch() and keeping .repeat().prefetch(AUTOTUNE) — ~global-batch-size× fewer Python/tf.data boundary crossings.
  • The stratified split and the shuffle=False alignment contract used by train_random_forest are preserved (train/val indices are sorted once so yield order matches the returned index arrays); the train_steps × accumulation_steps encode-count contract still holds and stays guarded by _distributed_encode's drift check.
  • Val and viz datasets get the same batched treatment (viz order guarantee kept — plot_latent_space_gif and _capture_latent_snapshot depend on it).
  • TrainDataHolder now holds memmap references; clear() semantics unchanged; the stale lock-contention comment is refreshed. Page-cache framing documented in code comments: steady-state reads come from otherwise-free RAM, and the kernel evicts pages under pressure instead of OOM-killing the run.

Config / CLI

New TrainingConfig fields + train-only Pattern A flags (per docs/CONFIG_AND_CLI.md), serialized via Config.to_dict(): --round-data-dir, --overlap-data-generation/--no-overlap-data-generation, --keep-round-data/--no-keep-round-data, --data-gen-task-size (validated ≥ 1). collect_validation_errors gains a train-mode disk-budget check: ≥ 2.2× one round's estimated size free with overlap (two rounds coexist on disk), 1.1× without — a hard ValidationError with the computed numbers. README CLI Reference blocks regenerated with PYTHONPATH=src python utils/print_cli_help.py all.

Verification

Local (macOS arm64, CPU-only venv: Python 3.12, tensorflow 2.17.1, pins from requirements-container.txt):

$ pytest -m "not gpu and not cluster" -q
235 passed, 3 skipped, 2 deselected

New tests: tests/unit/test_round_data.py — paths/manifest/dir-prep/task-partitioning, end-to-end sequential generation, producer protocol driven with a stub generator fn, drainer + await_round, plus a real spawn-started producer end-to-end test (which would have caught both cluster failures below); tests/unit/test_train_datasets.py — batched yields, exact index coverage, stratification, the shuffle=False alignment contract, memmap inputs, viz order; ManagedProcess tests in test_manager.py; round-data flag/validation tests in test_cli_validation.py.

Cluster smoke (blpc3, NGC 25.02 container, 5× RTX PRO 6000, tag test_v21, branch @ 291334f):

./utils/run_container.sh python -m aetherscan.main train --save-tag test_v21 --max-retries 1 \
  --num-training-rounds 3 --epochs-per-round 3 \
  --per-replica-batch-size 4 --per-replica-val-batch-size 4 --effective-batch-size 20 \
  --num-samples-beta-vae 2000 --num-samples-rf 600 --latent-viz-num-cadences-per-type 5

(Divisibility pre-validated for 5 replicas with utils/find_optimal_configs.py --num-gpus 5; GPUs confirmed idle before launch.) Result: clean completion, 0 ERROR/Traceback lines, all artifacts saved, /dev/shm clean, round-data dirs auto-deleted.

Acceptance criteria (PLAN §5.4, adapted to blpc3):

  • (i) Data-gen wall time per round (GIL fix): 303.6 s / 303.2 s / 280.8 s for rounds 1/2/3 — rounds 2–3 ≈ round 1 (the old pipeline's round-2+ slowdown under a warm TF process is gone; generation now runs in its own process).
  • (ii) Overlap: log timeline shows round k+1 generating during round k's epochs. Main-thread wait for data: 318.3 s (round 1, unavoidable serial start) → 151.6 s (round 2) → 105.9 s (round 3). E.g. 16:16:35 RoundDataProducer: generating round 3 data runs through round 2's epochs (16:17:29 Epoch 1 … 16:18:35 Epoch 3) and completes at 16:21:16.
  • (iii) RSS bounded: process-tree RAM mean 7.4 %, max 26.3 % of 503 GB across the whole run (dominated by the 45.8 GB background-plate SHM + TF runtime); system RAM max 22.7 %. No per-round saw-tooth, no growth across rounds (the resource plot renders flat between rounds).
  • (iv) GPU utilization during epochs: at this smoke's 5-replica-divisible geometry (batch 4/replica — 1/32 of the full-scale batch), the 1 Hz monitor recorded mean 5.4 % / p90 15 % / max 28 % during train windows. A follow-up probe at the full-scale per-replica batch (test_v22: batch 128/replica, effective 640, 3200 samples) sustained 5120 samples/epoch in 7.8 s once warm (~660 samples/s end-to-end) — the input pipeline is no longer the bottleneck at smoke scale, but per-step GPU compute for this VAE at these sample counts sits far below the 1 Hz sampling interval, so a comparable full-scale GPU-% number must come from the bla0 rehearsal (deferred below).
  • (v) Loss curves sane, RF trains, plots render: beta-VAE total loss 433.8 → 279.9 (train) and 417.4 → 255.3 (val) monotonically per-round across 3 rounds; RF trained and persisted (random_forest_test_v21.joblib + eval/SHAP artifacts); 56 tagged plots rendered incl. 24 latent-space UMAP GIF variants and the 10 RF diagnostics.

The smoke also exercised: manifest-gated reuse short-circuiting, per-round dir deletion, RF's sequential in-process generation path, producer graceful shutdown, and (via two interrupted runs) the Ctrl-C cleanup path through the new ManagedProcess.

Observation (pre-existing, not a regression): the RF dataset generation (600 samples, 1022.5 s) was dominated by one worker spinning ~10 min in create_true_double's non-intersection retry loop — the loop predates this PR (see its Note, small but nonzero probability for "infinite" (long-running) loops). Batched tasks make such stragglers more visible since one unlucky sample stalls its whole task; a retry cap is worth a follow-up issue.

Deferred validation

  • Full-scale OOM-rehearsal on bla0 (6× A4000, default config — the previous round-2 OOM-killer scenario) is deferred; bla0 is unreachable this session. This is also where the definitive full-scale GPU-utilization comparison against the 0–14 % baseline gets recorded.

AI assistance disclosure

Implemented with Claude Code (implementation, unit tests, docs/README regeneration, and cluster smoke verification), per AI_POLICY.md.

🤖 Generated with Claude Code

New src/aetherscan/round_data.py:
- RoundDataPaths: on-disk layout of one round's dataset
  ({main,true,false,labels}.npy + round_XX.done manifest)
- Atomic .done manifest protocol (.tmp -> os.replace) with cheap
  element-count + sampled-value checksums; validate_done_manifest()
  gates reuse of on-disk rounds
- prepare_round_data_dir(): startup archive/reuse/delete semantics
  mirroring checkpoint archiving (delete rounds >= start_round, keep
  earlier rounds only with a validating manifest)
- RoundDataProducer: dedicated multiprocessing.Process that generates
  round k+1 while round k trains (generate/shutdown in;
  stats/progress/done/error out); a main-process drainer thread writes
  streamed injection stats to the DB (the DB queue is thread-only);
  producer installs the canonical no-log SIGTERM handler and uses
  init_worker_logging()

ResourceManager gains a tracked ManagedProcess (terminate -> join ->
kill escalation mirroring ManagedPool.close, including killing the
producer's orphaned pool children before SIGKILL); processes are closed
first in cleanup_all().

Part of the training-data-pipeline overhaul (#114).
…mmaps

Replaces the 1-task-per-sample pool dispatch (per-sample IPC pickling +
main-process copies were the GIL bottleneck that made round 2+ data-gen
far slower than round 1) with batched tasks: each task generates up to
training.data_gen_task_size cadences and writes them directly into its
disjoint slice of the round's on-disk memmap (open_memmap r+, like
preprocessing's _extract_stamps_worker), returning only the small
per-sample stats dicts.

- The 8 class-segments per chunk are submitted as ONE unified task list
  through a single pool.map barrier (instead of 8 sequential barriers)
- Requires chunk_size % 4 == 0 (already CLI-validated), which drops the
  ceil-division oversample/subsample dance and its stats-filtering code
- generate_round_to_memmap() replaces generate_triplet_batch(): labels
  written per chunk, atomic .done manifest written only after all
  chunks finish; per-task seeds keep results independent of worker
  scheduling
- write_segment_stats() (module-level) replaces
  DataGenerator._write_batch_stats so the producer's main-process
  drainer can reuse it
- DataGenerator pool creation is now lazy (_ensure_pool): with overlap
  enabled the producer process owns the only worker pool, and the
  in-process pool exists only for the sequential path / RF dataset
- Remove the commented-out shared-memory-output redesign blocks: this
  change is that redesign

Part of the training-data-pipeline overhaul (#114).
prepare_distributed_train_dataset() now yields whole GLOBAL batches
gathered from np.load(mmap_mode="r") arrays (sorted fancy indices per
batch, epoch-level shuffle preserved), drops the .batch() op, and keeps
.repeat().prefetch(AUTOTUNE) — removing the per-sample Python/tf.data
boundary crossings behind the 0-14 % GPU utilization. The stratified
split and the shuffle=False alignment contract used by
train_random_forest are preserved (train/val indices are sorted once so
the yield order matches the returned index arrays); val and viz
datasets get the same batched treatment (viz order guarantee kept).
TrainDataHolder semantics are unchanged, now holding memmap refs; the
stale lock-contention comment is refreshed.

train_beta_vae() schedules generation through the RoundDataProducer
when overlap_data_generation is on: request round k+1 as soon as round
k's data is ready, train round k, delete round k's directory after it
completes (keep_round_data retains it). Sequential in-process
generation remains for --no-overlap-data-generation and pool-less
(n_processes=1) runs. _setup_directories() prepares the tag's
round-data dir with checkpoint-style resume semantics.

train_random_forest() generates its dataset through the same
disk-backed builder (per-round dir sibling "rf") — the
train_steps x accumulation_steps encode-count contract still holds and
remains guarded by _distributed_encode's drift check.

RAM no longer scales with round size: batches gather through the OS
page cache, which evicts under pressure instead of OOM-killing the run.

Part of the training-data-pipeline overhaul (#114).
New TrainingConfig fields (+ Config.to_dict entries):
- round_data_dir (None -> <output_path>/round_data at runtime)
- overlap_data_generation (True), keep_round_data (False) — both
  BooleanOptionalAction flags
- data_gen_task_size (256), validated >= 1

collect_validation_errors() gains a train-mode disk-budget check:
--round-data-dir must have >= 2.2x one round's estimated size free with
overlap enabled (two rounds coexist on disk), 1.1x without — a hard
ValidationError carrying the computed numbers. The estimate is stdlib-
only so utils/print_cli_help.py keeps importing cli.py without the
scientific stack.

README CLI Reference blocks regenerated with
`PYTHONPATH=src python utils/print_cli_help.py all`.

Part of the training-data-pipeline overhaul (#114).
- tests/unit/test_round_data.py: RoundDataPaths layout; .done manifest
  roundtrip + corruption/shape/count mismatches; prepare_round_data_dir
  resume semantics; chunk-segment and task partitioning covering every
  row exactly once; sequential end-to-end generate_round_to_memmap
  (labels layout, stats segments, manifest, progress); producer
  protocol driven in-thread with a stub generator fn (stats/progress/
  done, error reporting, valid-round short-circuit); main-side drainer
  + await_round against a fake process (DB writes land in main)
- tests/unit/test_train_datasets.py (slow): whole-global-batch yields,
  exact per-epoch index coverage, stratified split, the shuffle=False
  train_random_forest alignment contract, memmap inputs, viz order
  guarantee with padding
- tests/unit/test_manager.py: ManagedProcess register/close, idempotent
  close, SIGTERM-ignoring kill escalation, dead-process close
- tests/unit/test_cli_validation.py: new round-data flags routing +
  defaults, data_gen_task_size bound, disk-budget check (deterministic
  via an autouse shutil.disk_usage stub — the full-scale default needs
  more free disk than CI runners have)
- tests/conftest.py: teardown also closes leaked ManagedProcesses

Part of the training-data-pipeline overhaul (#114).
- RoundDataProducer's drainer thread now survives a failure while
  handling a single message (e.g. a DB hiccup on a stats write) —
  otherwise every later await_round() would raise a misleading
  "producer exited" error while the producer kept generating fine
- Update the --num-samples-rf divisibility message that referenced the
  removed generate_triplet_batch()

Part of the training-data-pipeline overhaul (#114).
The first cluster smoke hung: the fork-started producer froze on a
futex before reaching its own code — a lock inherited in a locked state
from the training parent's deep TF/NCCL/gRPC thread stack (single
thread, 0% CPU, no pool children, nothing logged from the child).

Fix: start the producer with the multiprocessing "spawn" context — a
fresh interpreter inherits no locks (its own pool workers are then
forked from the clean single-threaded producer, which is safe).
Plumbing that spawn requires:

- init_worker_logging() accepts an explicit log_queue (a spawned child
  has no inherited Logger singleton); _init_worker forwards it to the
  producer's pool workers via initargs
- Producer queues come from the spawn context so they cross the spawn
  pickling boundary
- CUDA_VISIBLE_DEVICES is blanked around Process.start() so the
  spawned child's TF import chain can never initialize CUDA
  (generation is pure CPU)

Part of the training-data-pipeline overhaul (#114).
The spawn-started producer cannot receive the Logger singleton's
fork-context multiprocessing queue ("A SemLock created in a fork
context is being shared with a process in a spawn context" at
Process.start()). The producer tree now logs into its own spawn-context
queue, and a main-side logging.handlers.QueueListener relays each
record into the main process's root handlers (the normal
file/console/Slack pipeline). Relay lifecycle is tied to
start()/shutdown().

Adds a slow-marked end-to-end test that runs a REAL spawn-started
producer against real shared memory with a real tiny generation — the
two cluster-smoke failures this PR hit (a fork-inherited deadlocked
lock, and the fork/spawn SemLock mix above) were only reachable through
a real child process, which the in-thread protocol tests can't cover.

Part of the training-data-pipeline overhaul (#114).
@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zachtheyek's task in 2m 39s —— View job


Code Review: Training Data Pipeline

  • Read and analyze the PR diff
  • Review round_data.py (new file, core of the change)
  • Review data_generation.py changes
  • Review train.py changes
  • Review manager.py changes
  • Review logger.py changes
  • Review config/CLI changes
  • Review test coverage
  • Post review findings

Summary

This is an impressive, well-engineered overhaul that simultaneously solves four coupled problems (OOM, GIL contention, serial phases, GPU starvation) with a cohesive design. The architecture decisions are sound — spawn-started producer, atomic manifests, batched tf.data yields — and the cluster smoke results validate the approach convincingly.

Strengths

  • Atomic manifest protocol (round_data.py): The .tmpos.replace pattern with checksummed validation is battle-tested and correct. Crash-safety semantics are clearly documented and tested.
  • Spawn vs fork decision: The explanation of why fork deadlocked (TF/NCCL/gRPC lock state) and the mitigation (spawn + CUDA_VISIBLE_DEVICES blanking) is exactly right. The logging relay via a spawn-context QueueListener is the correct solution for cross-boundary logging.
  • Batched tf.data generators: Moving from per-sample yields to whole-global-batch yields is the correct fix for the Python/TF boundary crossing bottleneck. The sorted-within-batch optimization for memmap locality is a nice touch.
  • Test coverage: The test suite is thorough — manifest validation edge cases, segment/task partitioning invariants, the shuffle=False alignment contract, and a real spawn-started e2e test that would catch the exact failures hit on the cluster.
  • ManagedProcess: Clean parallel to ManagedPool with the same terminate → join → kill escalation, including recursive child killing before SIGKILL.

Issues & Suggestions

I've posted 6 inline comments on specific code locations. The most actionable one:

  1. _run_memmap_task flush on exception path (data_generation.py:697-714): If create_fn raises mid-loop, out.flush() is skipped. Moving the flush into the finally block is a one-line fix that makes the intent clearer (the round is treated as garbage without a manifest regardless, so this is defensive rather than critical).

The remaining inline comments are informational/defensive notes rather than blocking issues:

  • _array_checksum using reshape(-1) vs ravel() on memmaps
  • Legacy RNG seeding coupling in _run_memmap_task
  • TOCTOU window in the CUDA_VISIBLE_DEVICES blanking (safe in practice)
  • The 1s polling ceiling in await_round (negligible in practice)
  • Label size estimate documentation

Architecture Observations (non-blocking)

  • create_true_double straggler risk: The PR description already flags this (the unbounded retry loop). With batched tasks, one unlucky sample stalls task_size cadences worth of work. A follow-up issue with a retry cap + fallback (different background index?) would be valuable.
  • Disk budget check timing: The collect_validation_errors disk check runs at CLI parse time. If another process fills the volume between validation and generation (hours later for round 2+), the check can't help. This is probably fine — it catches the common misconfiguration case — but a pre-generation sanity check in the producer would add defense-in-depth.
  • _PRODUCER_POOL global: Using a module-level global for the pool reference (so the SIGTERM handler can reach it) is the standard pattern for signal handlers, but it means only one producer can exist per interpreter. This is fine for the current single-producer design but worth noting if multi-producer ever becomes relevant.

Verdict

The PR is well-structured, well-tested, and addresses a real cluster-scale problem with appropriate engineering rigor. The one suggested code change (flush in finally) is minor. Ready to merge after the bla0 full-scale validation confirms the OOM fix at production geometry.

@claude claude Bot added the needs-issue PR is missing a linked issue label Jul 12, 2026
@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Contribution compliance note: This PR's Closes #114 keyword isn't creating a formal issue link — likely because the PR targets feature/test-suite rather than the default branch (GitHub only auto-links closing keywords when merging into the default branch). Please link #114 manually via the "Development" sidebar so the issue closes automatically when this eventually reaches main. Removing the needs-issue label once the formal link is in place.

deterministically-sampled flat-index values. Positions are derived from the element count,
so validation re-samples the same spots without storing an RNG state.
"""
flat = arr.reshape(-1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: arr.reshape(-1) on a memmap will create a full copy in RAM if the array isn't C-contiguous. Since these are always C-contiguous float32 memmaps created by open_memmap, this is fine in practice, but it'd be defensive to use arr.ravel() (which avoids a copy when contiguous) or .flat[positions] directly.

Not a bug at current usage, just a note for future-proofing if the layout ever changes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — added a NOTE at the checksum flatten (2bfa118) documenting the C-contiguity assumption and pointing at .ravel()/.flat if the layout ever changes. Kept reshape(-1) since open_memmap/np.save arrays are always C-contiguous today.

Comment on lines +535 to +544
while True:
with self._condition:
if round_idx in self._results:
kind, payload = self._results[round_idx]
break
if self._drainer_done:
raise RuntimeError(
f"RoundDataProducer exited before producing round {round_idx} data"
)
self._condition.wait(timeout=1.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the while True / wait(timeout=1.0) polling loop means await_round can take up to 1 second longer than strictly necessary after the result arrives (if notify_all() races with the timeout expiry). In practice this is negligible compared to generation wall times, but calling this out in case latency ever matters here. The Condition pattern is correct.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — one clarification: notify_all() from the drainer wakes the wait immediately, so the 1 s timeout isn't added latency on the happy path; it's only a liveness re-check so a drainer that died without notifying can't strand the wait. Added a NOTE saying exactly that (2bfa118).

Comment on lines +693 to +694

create_fn = _CREATE_FUNCTIONS[create_fn_name]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ np.random.seed(seed % (2**32)) seeds the legacy global RNG. This is needed because new_cadence and the create_* functions use both random.random() and np.random.choice([-1, 1]) (via the legacy API). Worth noting that this creates implicit coupling between per-task determinism and legacy RNG state — if any library call mutates the global RNG between cadences in the same task, results become scheduling-dependent.

Not actionable right now (the create_* functions would need to accept an explicit rng parameter), but flagging for awareness — especially since the PR description calls out that results don't depend on worker scheduling.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good flag. Added a NOTE (2bfa118) spelling out the legacy-global-RNG coupling and that determinism holds only while nothing else mutates global RNG state within a task; threading an explicit np.random.Generator through the create_* functions is the eventual fix if it ever matters.

Comment on lines 697 to +714
@@ -733,161 +707,283 @@ def batch_create_cadence(
inject=inject,
dynamic_range=dynamic_range,
)
cadence[i, :, :, :] = result
out[start_idx + i] = cadence
all_sample_info.append(sample_info)
out.flush()
finally:
del out

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The memmap is opened r+ without a lock, relying on disjoint row ranges for safety. This is correct and well-documented, but I want to flag one edge case: if create_fn raises partway through the for i in range(count) loop, out.flush() is never called for the rows that were written before the exception (the finally only dels the reference).

On Linux this is fine (the kernel will flush dirty pages eventually and the half-written round has no manifest so it's treated as garbage), but explicitly calling out.flush() in the finally block would make the intent clearer and help on exotic filesystems that might not auto-flush on munmap.

Suggested change
out = np.lib.format.open_memmap(array_path, mode="r+")
try:
for i in range(count):
cadence, sample_info = create_fn(
backgrounds,
snr_base=snr_base,
snr_range=snr_range,
width_bin=width_bin,
freq_resolution=freq_resolution,
time_resolution=time_resolution,
inject=inject,
dynamic_range=dynamic_range,
)
out[start_idx + i] = cadence
all_sample_info.append(sample_info)
finally:
out.flush()
del out

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 2bfa118 — out.flush() now runs in the finally (plus a comment on why: deterministic write-back for rows written before a mid-task exception; the missing .done manifest already guards correctness either way).

Comment thread src/aetherscan/cli.py
Comment on lines +66 to +77
keep importing cli.py without the scientific stack.
"""
per_sample_bytes = num_observations * time_bins * width_bin_downsampled * 4 # float32
labels_bytes = n_samples * 20 * 4 # numpy "U20" = 20 UCS-4 code points per label
return 3 * n_samples * per_sample_bytes + labels_bytes


def _nearest_existing_ancestor(path: str) -> str:
"""Walk up from `path` to the closest directory that exists (for shutil.disk_usage on a
round-data dir that hasn't been created yet)."""
probe = os.path.abspath(path)
while probe and not os.path.exists(probe):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good design — keeping this stdlib-only so utils/print_cli_help.py doesn't need numpy.

One note: the U20 label size calculation (n_samples * 20 * 4) is correct for numpy's UCS-4 encoding on disk, but could drift if np.save changes encoding or if labels grow beyond U20. A brief inline comment noting the assumption (e.g. # numpy U20 on-disk: 20 UCS-4 codepoints × 4 bytes each) would help future maintainers.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assumption is already annotated inline on that line: labels_bytes = n_samples * 20 * 4 # numpy "U20" = 20 UCS-4 code points per label — so skipping an additional comment to avoid duplication. If labels ever outgrow U20, the dtype literal and this estimate live one grep apart.

Comment thread src/aetherscan/round_data.py Outdated
Comment on lines +503 to +512
# its own GPU init, so temporarily mutating it here is safe.
original_cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES")
os.environ["CUDA_VISIBLE_DEVICES"] = ""
try:
self._process.start()
finally:
if original_cuda_visible is None:
os.environ.pop("CUDA_VISIBLE_DEVICES", None)
else:
os.environ["CUDA_VISIBLE_DEVICES"] = original_cuda_visible

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CUDA_VISIBLE_DEVICES blanking is clever and well-motivated, but there's a small TOCTOU window: if another thread in the parent reads os.environ["CUDA_VISIBLE_DEVICES"] between lines 505 and 509-511, it would see the blanked value.

In practice this is safe because:

  1. TF reads this env var only at GPU initialization (already done by this point)
  2. No other thread should be spawning GPU-visible subprocesses concurrently

But a brief comment noting this is intentionally safe only because TF's GPU init is already complete would help a future reader who wonders about the race.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a NOTE (2bfa118) making the safety argument explicit: the brief env-mutation window is acceptable only because the parent's TF read CUDA_VISIBLE_DEVICES once at GPU init (long before this point) and nothing else in the pipeline spawns GPU-visible subprocesses concurrently with training startup.

- _run_memmap_task: move out.flush() into the finally block so rows
  written before a mid-task exception still reach disk deterministically
  (review suggestion on #117)
- Add clarifying NOTE comments from the review's informational points:
  reshape(-1) contiguity assumption in the manifest checksums, legacy
  global-RNG coupling of per-task seeding, the CUDA_VISIBLE_DEVICES
  env-mutation window, and the await_round wait-timeout being a
  liveness re-check rather than polling latency
- Follow-up for the create_true_double retry-loop straggler filed as #118

Part of the training-data-pipeline overhaul (#114).
@zachtheyek

Copy link
Copy Markdown
Owner Author

Review addressed in 2bfa118:

  • Actionable fix applied: _run_memmap_task now calls out.flush() inside the finally, so rows written before a mid-task exception reach disk deterministically (the missing .done manifest already guarded correctness, but the intent is now explicit and exotic-filesystem-safe).
  • Clarifying notes added for the informational points: the reshape(-1) C-contiguity assumption in the manifest checksums, the legacy global-RNG coupling of per-task seeding, the CUDA_VISIBLE_DEVICES env-mutation window around Process.start(), and await_round's 1 s wait-timeout being a liveness re-check rather than polling latency. The U20 label-size assumption was already annotated inline in cli.py, so no duplicate comment was added there.
  • Follow-up filed: the create_true_double unbounded retry-loop straggler (observed as a ~10-minute single-worker spin during the smoke's RF generation) is now tracked in create_true_double retry-loop straggler: cap retries + fallback background #118 with the suggested retry-cap + fallback design — kept out of this PR as separate future work.

Each inline thread has an individual reply. Full local suite re-run after the change: 235 passed, 3 skipped, 2 deselected.

@zachtheyek

Copy link
Copy Markdown
Owner Author

Re: the contribution compliance note — the missing formal issue link is an artifact of this being a stacked PR, not a missing reference:

No action needed otherwise; the body already carries Closes #114 and the stacking is called out in the first line.

@zachtheyek

Copy link
Copy Markdown
Owner Author

@claude please perform a second-pass code review of this PR. Re-review the current diff for any remaining correctness bugs, security issues, or significant quality problems, and post your findings. If the diff is clean, please say so explicitly. (Final verification round before merge.)

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Second-pass code review — ✅ Clean

I've performed a thorough second-pass review of the full diff (round_data.py, data_generation.py, train.py, manager.py, config.py, cli.py, logger.py, and the test files). Checked for correctness bugs, race conditions, security issues, resource leaks, and significant quality problems.

Verdict: No blocking issues found.

The core patterns are sound:

  1. Memmap addressing — All three destination arrays share the shape (n_samples, ...). The 8-segment layout (build_chunk_segments) correctly covers all rows per chunk. this_chunk_size % 4 == 0 is guaranteed by the validators (n_samples % 4 == 0chunk_size % 4 == 0 → their difference mod 4 ≡ 0). No off-by-one or row-collision risk.

  2. Manifest protocol — The atomic .tmpos.replace write and the element-count + sampled-value checksum validation are correct. The validate_done_manifestbuild_manifest round-trip properly re-derives the same sample positions from the RNG seeded by element count.

  3. Producer process — Spawn context avoids the fork-with-TF-locks hazard; CUDA_VISIBLE_DEVICES blanking during Process.start() is documented as intentionally acceptable (TF already read it at GPU init); the SIGTERM handler correctly avoids logging (no deadlock); the pool teardown in the finally block prevents orphaned workers.

  4. Drainer thread_handle_safely wrapping ensures a DB hiccup can't strand await_round(). The _drainer_done + Condition.notify_all() protocol correctly unblocks the training thread on producer death. The 1s poll timeout is a liveness re-check, not a latency source (the notify_all() on done/error wakes await_round immediately).

  5. Cleanup orderingcleanup_all() closes processes first (including the producer's pool children via its SIGTERM handler), then standalone pools, then shared memory. Correct.

  6. tf.data batching — Generators yield whole global batches (leading batch dim in the output signature). Stratified split preserves label proportionality. shuffle=False path maintains the sorted-index alignment contract for train_random_forest. Trimming logic avoids uneven final batches.

  7. Disk-budget validation — The 2.2× / 1.1× free-space check in collect_validation_errors correctly accounts for the overlap case (two rounds coexist on disk).

  8. Resource lifecycleManagedProcess.close() escalates terminate → join → kill (with psutil child-tree cleanup before SIGKILL). Round-data dirs are deleted after train_holder.clear() + clear_session() — POSIX keeps inodes alive for any lingering mappings. --keep-round-data bypass is clean.

Minor observations (non-blocking, informational):

  • respect_handler_level=False on the producer's QueueListener relay (round_data.py line 487) means handler-level filtering is bypassed for producer log records. In practice this is fine because init_worker_logging sets root to INFO, so DEBUG records never enter the queue — but it's worth knowing the asymmetry vs. the main Logger's respect_handler_level=True if handler levels are ever tightened below INFO.

  • Global RNG seeding in _run_memmap_task (data_generation.py lines 696-697) — the comment correctly notes that determinism is best-effort since legacy random/np.random module state can be mutated by library code between cadences. Acceptable for this use case (generation, not reproducibility-critical validation).

  • _ensure_pool() short-circuits when self.shm is falsy (data_generation.py line 1092) — the not self.shm check relies on SharedMemory being truthy. Standard SharedMemory objects are always truthy, so this is fine, but it's an implicit contract.

LGTM for merge. 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-issue PR is missing a linked issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant