Skip to content

Worker pool with disk-based communication for distributed inference#131

Open
dtegunov wants to merge 34 commits into
mainfrom
feat/distributed-inference
Open

Worker pool with disk-based communication for distributed inference#131
dtegunov wants to merge 34 commits into
mainfrom
feat/distributed-inference

Conversation

@dtegunov

@dtegunov dtegunov commented Jul 6, 2026

Copy link
Copy Markdown
Member

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 into tasks/pending/, submits N cluster jobs, and blocks until all tasks reach tasks/done/ or tasks/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 N to your train or infer command:

export MISS_CLUSTER_CONFIG=/path/to/cluster_config.json
export MISS_CLUSTER_SCRIPT=/path/to/worker.sh
miss-alignment train --config-file config.yaml --n-cluster-workers 100

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

miss-alignment worker --queue-dir <path> --device <int> [--worker-id <str>]

Workers can also be launched manually against a running queue, useful for topping up a depleted pool mid-run.

Key design details

  • Model checkpoint reuse: workers load the checkpoint once and reuse it across all series they process in a macro-iteration — for 300 series split across 10 workers that is 30 loads instead of 300
  • Heartbeat in background thread: the worker heartbeat runs in a daemon thread independent of task execution, so long-running series (>2 min) do not appear stale to the manager's sweep
  • Stall recovery: the manager's scheduler thread detects stalled workers (missing heartbeat >120s) and re-pends their tasks; a stalled worker's running/ directory is cleaned up so it can recreate it if it comes back
  • Hard fail on any series failure: if any series lands in failed/, the manager raises after all remaining tasks complete — training stops rather than producing silently incomplete alignment
  • Exit reason files: each worker writes tasks/logs/<worker_id>.exit on shutdown, recording why it stopped (queue empty / manager stale / unhandled exception with traceback) and how many series it processed
  • SLURM logs in tasks/logs/: slurm-<jobid>.out/err land alongside the exit files, not in the working directory

Bugs fixed during testing

  • Scheduler thread crashing silently (unhandled exception now surfaces to main thread immediately)
  • TOCTOU race reading heartbeat mtime (heartbeat thread could delete the tick file between max() and the second .stat() call)
  • Double worker submission at startup (scheduler thread and explicit startup call both fired ensure_workers on the same empty job list)
  • Stall sweep re-pending tasks already in done/ (visible as ls tasks/done | wc -l exceeding total series count)

🤖 Generated with Claude Code

dtegunov and others added 23 commits July 4, 2026 17:03
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>
@dtegunov dtegunov requested a review from McHaillet July 6, 2026 05:13
@McHaillet

Copy link
Copy Markdown
Collaborator

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.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  src/miss_alignment
  __init__.py
  __main__.py 1-3
  _cli.py
  infer.py 120
  prepare_stacks.py 89-92, 138-167
  preprocessing.py
  train.py 418-424
  src/miss_alignment/alignment
  parallel.py 28-30
  tilt_series.py 174-175, 187-191
  src/miss_alignment/data
  training_datamodule.py 215-217
  src/miss_alignment/distributed
  __init__.py
  config.py 55
  manager.py 44, 48-58, 60-79, 81-85, 112-114, 117-126, 129-131
  provisioner.py 26-41, 43-50, 52, 73, 86-87, 90, 121-122, 181, 245, 284-285, 288, 330-331, 377, 380-384
  queue.py 148-150, 196, 201, 204, 207
  worker.py 41-49, 64-65, 76, 92, 139
Project Total  

This report was generated by python-coverage-comment-action

@dtegunov

dtegunov commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

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.

Fixed.

@McHaillet

Copy link
Copy Markdown
Collaborator

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?

@dtegunov

dtegunov commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

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.

@McHaillet

Copy link
Copy Markdown
Collaborator

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
@McHaillet

Copy link
Copy Markdown
Collaborator

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.

dtegunov and others added 8 commits July 8, 2026 11:29
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>
@dtegunov

dtegunov commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

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

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants