Worker pool with disk-based communication for distributed inference#131
Worker pool with disk-based communication for distributed inference#131dtegunov wants to merge 34 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rkers, model param - Cluster mode triggered by --n-cluster-workers, not env vars alone - Env vars required (not optional) when cluster mode is active - ClusterProvisioner submits N jobs, each draining the queue - evaluate_tilt_series gains optional model param for checkpoint reuse - clear_queue bug fixed (delete before recover) - manager heartbeat written before workers start (race fix) - tasks/ deleted on manager shutdown - __main__.py added for python -m miss_alignment launch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… reuse Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…leanup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ster-workers; register worker subcommand; delete _parallel.py Also migrate prepare_stacks.py and preprocessing.py off _parallel import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n tasks - TaskSpec gains task_type, desired_pixel_size, lowpass_cutoff, pretilt_search_range - Worker dispatches on task_type; alignment/prepare_stacks/cross_correlation all supported - prepare_stacks_parallel and run_cross_correlation_alignment_parallel migrated off local _run_device_pool copies onto run_distributed - n_cluster_workers threaded through to all three task types in train.py and infer.py - Duplicate _run_device_pool code removed from prepare_stacks.py and preprocessing.py Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…EADME Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
last_sweep=0.0 caused the scheduler thread to call ensure_workers() immediately, racing with the explicit startup call in run_distributed and doubling the number of submitted jobs (e.g. 40 instead of 20). Initialize last_sweep=time.time() so the scheduler's first sweep fires after _SCHEDULER_INTERVAL_S, leaving the explicit startup call as the sole first submission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…down Records why each worker stopped: 'queue empty', 'manager heartbeat stale', or the traceback on an unhandled exception. Includes done/failed counts. Equivalent to WarpTools' logs/<workerId>.exit files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Heartbeat was written between task claims, so a single long-running series (>120s) would trigger the manager's stall sweep, deleting the worker's running/ directory. The worker would then crash writing the next heartbeat tick (FileNotFoundError), even though it had been doing useful work the whole time. Fix: _start_heartbeat_thread() ticks every 5s in a daemon thread, independent of task execution. The running/ dir is also recreated if the manager sweeps it mid-task. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Provisioner now substitutes {{logs_dir}} (tasks/logs/) and {{tasks_dir}}
in the submission script template alongside {{command}}. The example
worker.sh uses {{logs_dir}} for --output and --error so all slurm-*.out
and slurm-*.err files land in the same directory as the worker .exit files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a long-running series caused the old (pre-background-thread) sweep to re-pend a task that the worker subsequently completed, the task ended up in both pending/ and done/ simultaneously. Now the sweep checks for a matching file in done/ or failed/ before re-pending, and discards the running/ copy if found. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'12 workers -> 8 partitions' implied the workers were divided into partitions. The arrow is misleading: reconstruction workers and partitions are sized independently. Use a comma instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
max(...).stat().st_mtime called stat() twice on the same path: once inside the key function to find the newest tick, and again on the result to read its mtime. The heartbeat thread could delete the file between those two calls (hb-43 → hb-44), causing FileNotFoundError in the scheduler thread and crashing the whole run. Fix: iterate ticks once, catching FileNotFoundError per-tick, and track the newest mtime seen without re-stating the winning file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An unhandled exception in _scheduler_thread previously killed it silently while the poll loop hung forever waiting for tasks to complete. Now: - _scheduler_thread catches all exceptions, stores them in error_box, and sets stop_event to wake the poll loop immediately - The poll loop checks error_box each iteration and re-raises as RuntimeError with the original exception chained Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The tests are failing. It seems to have something to do with torch-projectors, might be a combination of the new version with a pytorch mismatch in the CI. |
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Fixed. |
|
Thanks for the CI fix! I had some time to read thought it now. I like the functionality, but have a concern about the added complexity this adds to miss-alignment while cluster job spawning might be better handled via manager programs. Would it not be easier to run the inference script on a single GPU with a single tilt-series, and let manager programs handle launching jobs over GPU's ? Such as Warp or the Relay GUI, or RELION/Doppio if they want to integrate it. That would keep the code here more minimal. Since that is not supported yet, it might be nice for now, and I also like that it is backwards compatible. What are your thoughts on this? Also question about the cluster workers in the training script, does it submit to the gpu's on the training node as well? |
|
I agree that a single-item-per-invocation model would help replicate the behavior in inference when paired with a scheduler that's aware of individual tilt series. However, such schedulers are hard to come by (Relay doesn't support this; not sure about Doppio), and it wouldn't solve the training problem because it alternates between training and inference. The latter is certainly not impossible, but it would require a bespoke communication mechanism between MA and the scheduler that I doubt anyone would want to implement (I briefly considered using Relay's native worker pools, but it was too much of a mess). And then you'd still want to support scheduler-free inference, where MA has to handle the parallelization internally. I think using the same parallelization mechanism everywhere is just easier. |
|
I get what you mean. It does seem like the original GPU's for training are left idle, might be a bit wasteful. Although it would be difficult to submit to those of course |
…rence # Conflicts: # src/miss_alignment/preprocessing.py
|
FYI: I merged my changes to main with this PR -- it removed pre-tilt estimation with tiltxcorr which was not working very reliably. I found WarpTools --auto_zero does a very decent job and finding the pretilt. |
Local GPUs were idle during cluster-distributed alignment phases because only cluster jobs were submitted. Since all workers share the same disk-based task queue regardless of whether they run locally or remotely, there is no coordination overhead in mixing them. When --n-cluster-workers is set and local GPU devices are available, a CompositeProvisioner now runs both ClusterProvisioner and LocalProvisioner simultaneously. Local workers claim tasks from the same queue as cluster workers and are respawned by the scheduler on exit, just like in local-only mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ounts in progress bar ClusterProvisioner.ensure_workers() now submits against the live worker count (running/ dirs with a fresh heartbeat) rather than the submitted- job-ids list, so preempted jobs are automatically resubmitted each scheduler tick. A startup grace period (60s) prevents newly-created worker dirs from being misread as stale. The scheduler gates replenishment on n_pending > 0 and caps at min(n_workers, n_pending). This prevents spurious resubmissions when workers exit cleanly on an empty queue: a clean exit leaves nothing in pending/, so ensure_workers is never called. Only if the stall sweep re-pends an orphaned task does n_pending go positive again. _live_worker_dirs() iterates heartbeat tick mtimes with per-file FileNotFoundError handling, fixing the TOCTOU race where the heartbeat thread rotates a tick between glob and stat. All provisioners expose live_worker_count() and worker_counts_by_type(). CompositeProvisioner sums across its children and merges the type dicts. The manager poll loop updates the tqdm postfix each scheduler tick with live counts e.g. 'cluster=87 local=2', so pool depletion is visible without checking squeue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ClusterProvisioner.shutdown() was cancelling jobs serially, one scancel subprocess call per job ID. With preemption-triggered resubmissions accumulating over a long run this could mean dozens of sequential 1-2s round-trips to the scheduler, blocking the manager after the progress bar hit 100%. Use ThreadPoolExecutor to fire all cancel commands concurrently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/PBS/SGE) Replaces heartbeat-based liveness with a proper scheduler status query. ClusterConfig gains a 'status_list' command (replacing 'status' + 'status_job_id_regex') that lists all active user jobs in 'id,STATUS' format. The recommended SLURM command is: squeue -u $USER -h -o "%i,%T" _parse_status_output() maps status tokens to alive/terminal across four schedulers: SLURM, LSF, PBS, SGE. The scheduler is auto-detected from output tokens unless 'scheduler' is set explicitly in the config. A 'custom' mode accepts user-defined alive status strings. This correctly handles jobs in PENDING state (queued but not yet started), which the heartbeat approach could not see -- preventing the runaway resubmission that produced >3000 cluster jobs for a pool of 160. cluster_example/cluster_config.json updated to use status_list. cluster_example/README.md adds per-scheduler examples for SLURM/LSF/PBS/custom. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
worker_counts_by_type() now returns 'cluster-running' and 'cluster-pending' separately so the progress bar shows e.g. 'cluster-running=3 cluster-pending=157' instead of a misleading 'cluster=160'. _parse_status_output() returns dict[job_id -> 'running'|'pending'] instead of set[job_id]. _RUNNING_STATUSES and _PENDING_STATUSES replace _ALIVE_STATUSES. _query_job_states() is the new primary method; _alive_job_ids() is a thin wrapper over it for ensure_workers(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two causes:
1. worker_counts_by_type() returned {'cluster': 0} as a fallback when no
jobs were alive yet, which got merged into worker_counts and stuck there
even after real keys appeared. Removed the fallback — empty dict is fine.
2. worker_counts was initialised by calling worker_counts_by_type() before
any jobs were submitted (always empty). Changed to start as {}.
3. The scheduler tick used dict.update() which merges, preserving stale keys
from previous ticks. Added worker_counts.clear() before the update.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Jobs absent from squeue are kept for _JOB_GRACE_S (120s) before being pruned, rather than being dropped immediately or requiring an explicit terminal status. This correctly handles the window between job submission and scheduler registration (typically seconds, but up to minutes on busy clusters), which was causing runaway resubmission (>3000 jobs for a pool of 160). _job_submit_time replaces _job_ids to track each job's submission timestamp. ensure_workers uses len(_job_submit_time) as the authoritative count; _query_job_states only prunes jobs that are both absent from squeue AND have exceeded the grace period. worker_counts_by_type reports not-yet-visible jobs as cluster-pending so the progress bar count is accurate from the moment of submission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e-spawn The scheduler thread and the startup ensure_workers() call in run_distributed could execute concurrently. If both checked _procs[device] before either had stored the new Popen, both would see None and each spawn a separate process for the same GPU, explaining the 2 processes per GPU observed in running/. Added threading.Lock to LocalProvisioner; ensure_workers and live_worker_count both acquire it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
That's actually easy since we already have separate logic for spawning local and remote workers – they just need to be combined. Good point! Implemented. |
What this does
The alignment phase — where the trained model scores reconstruction quality across all tilt series — previously ran sequentially on local GPUs. With 300+ tilt series this took hours per macro-iteration, making it the dominant bottleneck in a full training run. This PR adds a worker pool with disk-based communication that distributes inference across a SLURM cluster, bringing that phase from hours to minutes.
How it works
A filesystem queue under
<training_dir>/tasks/coordinates work with no network dependency — atomic file rename is the claim mutex (the same protocol used by WarpTools). The head node writes one task JSON per tilt series intotasks/pending/, submits N cluster jobs, and blocks until all tasks reachtasks/done/ortasks/failed/. Workers claim tasks by rename, process them, and loop until the queue drains.The same mechanism covers the stack preparation (
--prepare-stacks) and cross-correlation pre-alignment (--preprocess) phases.Usage
Set two environment variables pointing at a cluster config and submission script template, then add
--n-cluster-workers Nto yourtrainorinfercommand:SLURM config and a ready-to-adapt submission script are provided in
cluster_example/. Without--n-cluster-workers, behaviour is identical to before (local multi-GPU pool).New CLI command
Workers can also be launched manually against a running queue, useful for topping up a depleted pool mid-run.
Key design details
running/directory is cleaned up so it can recreate it if it comes backfailed/, the manager raises after all remaining tasks complete — training stops rather than producing silently incomplete alignmenttasks/logs/<worker_id>.exiton shutdown, recording why it stopped (queue empty / manager stale / unhandled exception with traceback) and how many series it processedtasks/logs/:slurm-<jobid>.out/errland alongside the exit files, not in the working directoryBugs fixed during testing
max()and the second.stat()call)ensure_workerson the same empty job list)done/(visible asls tasks/done | wc -lexceeding total series count)🤖 Generated with Claude Code