Orchestrator architecture
Internals doc — for contributors. End users running
mhcflurry class1-train-pan-allele-modelsdon’t need to read this.
The one-line summary
The orchestrator owns the run-wide resources (parallel workers, env tuning); workers consume those and own their per-fit device state. Workers never build shared state and never set env knobs that affect other workers.
If you find yourself adding orchestrator-shaped logic inside
fit(), train_model(), or any worker function, stop and think
whether it belongs in the orchestrator instead.
Locus of control
orchestrator process training worker process
───────────────────── ───────────────────────
parse args (forked / spawned)
load training data receive WORKER_CONTEXT once
build WORKER_CONTEXT ─┐
│
hoist env knobs │
TORCHINDUCTOR_COMPILE_THREADS
│
start worker pool ─┘
fit() builds device-resident
AffinityDeviceTrainingData
(peptide/allele/y/weights and
random-negative slice live on
the active torch device)
collect results ◀────────────────── return predictor
The orchestrator does the expensive, single-threaded preparation ONCE. Workers do the parallel, per-task work N times.
Tensor residency: device-resident affinity fit
Affinity fit() is device-resident. One container,
mhcflurry.class1_affinity_training_data.AffinityDeviceTrainingData, holds
the row space [random negatives | real training rows] as torch tensors on
the active device for the lifetime of one fit() call. The inner loop
forms batches via index_select against the combined buffer — no per-batch
host-to-device copies, no DataLoader workers feeding the affinity loop.
Random-negative peptides are refilled in-place once per epoch into the top slice of the combined buffer, so the real-data block is never recopied.
The pretrain-streaming path (fit_streaming_batches) is a separate code
path that does use a PyTorch DataLoader with optional prefetch workers.
That’s controlled by dataloader_num_workers; 0 means in-process. It
feeds the pretrain generator only and does not affect the affinity row
layout above.
Random negatives
Random negatives are worker-local. RandomNegativesPool amortizes
generation over random_negative_pool_epochs, but it does not try to
share encoded pools between model fits. This preserves the historical
behavior where independently trained workers sample independent negative
peptides.
For the default torch peptide-encoding path, the pool samples amino-acid indices directly on the worker’s active torch device and writes fixed-length int8 rows. The model’s embedding layer expands those indices to BLOSUM62 / PMBEC / physchem features during the forward pass. Legacy host-vector models fall back to the host encoder so their input shape remains unchanged.
Parallelism backends
Two backends, one CLI surface:
Local (
mhcflurry.parallelism):multiprocessing.Poolof non-daemon workers. Workers can spawn DataLoader children for the pretrain streaming path.resolve_local_parallelism_argsis the single pre-fork normalization point: it resolves--max-workers-per-gpu=autoand--dataloader-num-workers=autowithout touching CUDA, caps local--num-jobsto GPU capacity when auto was requested, and hoists torch.compile’s thread cap before the Pool starts. Fork workers inheritWORKER_CONTEXTthrough copy-on-write; spawn workers receive it once in their initializer, never once per queued task. Before a spawn pool starts, the planner deep-sizes that loaded context and rechecks current host RAM, so worker copies are capped even when the source CSV was compressed or torch compilation is disabled. Explicit numeric--max-workers-per-gpukeeps the historical CPU-overflow behavior.Cluster (
cluster_parallelism.py): one job per work-item submitted viabsub/sbatch/sh. Workers serialize WORKER_CONTEXT to NFS and deserialize on the worker side.
Worker-side code (train_model(), etc.) is identical between
backends. Only the orchestrator branches on args.cluster_parallelism.
Coverage matrix
The 2.3.0 modernization concentrates on the pan-allele affinity training + percentile calibration paths. Other components inherit shared backend selection and worker orchestration, but their data paths remain intentionally smaller.
pretrain |
finetune |
select |
calibrate |
|
|---|---|---|---|---|
affinity (pan-allele) |
streaming DataLoader + compact torch-index peptide batches |
device-resident tensors + worker-local RN pool |
filter ✓; pool/cache n/a (no fit) |
filter ✓; pool/cache n/a |
affinity (allele-specific) |
n/a |
device-resident tensors + worker-local RN pool |
filter ✓ |
shares calibrate command |
processing |
n/a |
local+cluster worker pool |
local+cluster worker pool |
n/a (allele-independent) |
presentation |
n/a |
parallel auto-sized feature generation + deterministic fit |
n/a |
filter ✓ (shares calibrate command) |
Auto-tuned parallelism knobs
Three knobs auto-derive from the box’s hardware so the orchestrator
keeps working when the recipe lands on a different tier. Every auto
resolver lives in mhcflurry.parallelism and is exercised by
the unit-test matrix in test/test_orchestrator_helpers.py. The
production recipes pass auto for each; pin a literal int only when
intentionally re-benchmarking.
--max-workers-per-gpu auto → auto_max_workers_per_gpu
Picks per-GPU worker concurrency from the number of complete estimated worker
working sets that fit in detected free VRAM after one shared reserve (10%, at
least 1 GiB). There is no default worker hard cap. The working set is derived
from model artifacts/parameters, gradients and optimizer state for training,
resident encoded data, and the configured training minibatch where those facts
are available; otherwise the workload profile supplies a conservative fallback.
Free VRAM is read from nvidia-smi (no torch import — the parent process must
not initialize CUDA before forking). The final job count is also bounded by
available host RAM and vCPUs. In containers, host-memory sizing honors the
current cgroup limit and usage rather than the physical host’s /proc/meminfo.
Spawn pools then refine the host cap from the loaded per-worker context and
current available RAM; fork pools retain copy-on-write sharing and skip that
copy multiplier. If no additional spawn copy fits safely, automatic local
execution falls back to serial work in the parent instead of forcing one child.
When torch compilation is enabled, the existing one-worker compile warmup also records CUDA peak-reserved memory and process peak RSS. A measured peak can tighten an automatic plan before the production pool starts, but can never change explicit CLI values. Runtime validation and inference batches use the same shared reserve against live free memory, so they do not independently spend VRAM already assigned to workers.
For the generic 4 GiB fallback, 80 GiB free fits 18 workers and 40 GiB fits 9; workload-specific estimates, host RAM, vCPUs, and the number of work items can reduce those capacities. CPU-only execution resolves to one device worker.
--dataloader-num-workers auto → auto_dataloader_num_workers
Picks the per-fit-worker DataLoader prefetch child count from box
capacity. This applies to the pretrain streaming path
(fit_streaming_batches); the affinity fit() path is device-resident
and ignores it. Inputs: total vCPUs, total RAM in GB, the post-cap
fit-worker count (--num-jobs resolved by
resolve_local_parallelism_args), and a hard cap (default 4 — the
empirical SM-scheduler-style wall, env-overridable via
MHCFLURRY_AUTO_DATALOADER_HARD_CAP).
Heuristic:
cpu_per_fit = vcpus // num_fit_workerscpu_cap = cpu_per_fit // 2— each DL child needs ~2 effective cores (1 for the main loop, 1 for queue/collate/copy).RAM cap (when
ram_gbis provided): assume ~2 GB baseline per fit-worker plus ~0.5 GB per DL child;ram_cap = max(0, (ram_gb / num_fit_workers - 2.0) / 0.5).Result:
min(hard_cap, cpu_cap, ram_cap)clamped to ≥1, except when any input cap is 0 (oversubscribed mains, RAM exhaustion, or user overrideMHCFLURRY_AUTO_DATALOADER_HARD_CAP=0) in which case the result is 0 — i.e. in-process batching, no children.Serial / no-fit-worker case: returns 0.
Cross-checked configurations (see
test_auto_dataloader_num_workers_hardware_tiers):
Box |
vCPU |
RAM |
fit |
resolved |
|---|---|---|---|---|
8×A100-80GB Verda |
176 |
400 G |
16 |
4 |
8×A100-40GB |
176 |
400 G |
8 |
4 |
8×L40S sweep box |
96 |
200 G |
16 |
3 |
Single A100-80G Lambda |
30 |
200 G |
2 |
4 |
Single A100-80G tight |
16 |
64 G |
2 |
4 |
Single T4 / RTX |
8 |
16 G |
1 |
4 |
Tight cluster node |
32 |
64 G |
16 |
1 |
Very tight (8v / 8fit) |
8 |
16 G |
8 |
0 |
RAM-starved (32G / 16fit) |
176 |
32 G |
16 |
0 |
Serial / CPU |
— |
— |
0 |
0 |
The heuristic is hardware-only by design — train_row_count and
random_negative_rate do not enter the formula. Per-batch CPU work is
bounded by minibatch_size, not total dataset rows.
--num-jobs (auto-derives from MWPG × GPUs)
--num-jobs defaults to auto, which resolves to gpus × max_workers_per_gpu
via mhcflurry.parallelism.auto_num_jobs(num_gpus, max_workers_per_gpu)
once auto_max_workers_per_gpu has resolved. Pass an integer to pin it.
Cross-model coverage
Model |
|
|
Notes |
|---|---|---|---|
Pan-allele affinity |
✓ |
✓ (pretrain only) |
Default in release recipe; affinity |
Allele-specific affinity |
✓ |
✓ |
Same |
Processing |
✓ |
n/a |
Parallel model fits; the processing network has no DataLoader pretraining path. |
Presentation |
✓ |
n/a |
Parallel feature generation followed by deterministic fitting. |
Env overrides
Env var |
Default |
Effect |
|---|---|---|
|
4.0 generic fallback |
Expert per-worker VRAM override; workload-specific estimates normally replace the fallback. |
|
unset |
Optional expert ceiling for workers per GPU. |
|
(auto-detect) |
Pin free VRAM (CSV per GPU); for tests / hidden- |
|
4 |
DL child cap for |
Env knobs vs CLI flags
A persistent question: when does a setting belong on argparse vs
the environment?
Rule of thumb:
CLI flag when the orchestrator owns it and propagates it. (Examples:
--max-workers-per-gpu,--random-negative-pool-epochs,--num-jobs.)Env var with optional CLI relay when the consumer is inside
fit()or another worker-private code path, and the orchestrator only centralizes policy. (Examples:MHCFLURRY_TORCH_COMPILE,MHCFLURRY_TORCH_COMPILE_LOSS,TORCHINDUCTOR_COMPILE_THREADS.)
The orchestrator may hoist env vars: read its own args, compute a
sensible default, and put a concrete value in os.environ so workers
inherit it. resolve_local_parallelism_args calls
hoist_torchinductor_compile_threads for local runs — it sizes the
inductor compile pool against the resolved --num-jobs so N workers
don’t each spawn cpu_count() compile threads. If
TORCHINDUCTOR_COMPILE_THREADS=auto, the orchestrator replaces it
with a numeric value before any worker sees it.
Torch Compile Warmup
torch.compile is worker-local: the Python wrapper, graph guards, and
CUDA module handles cannot be shared across processes. What can be
shared on one machine is the Inductor/Triton on-disk cache. For local
Pool training, the orchestrator therefore runs one real work item
first in a one-worker warmup pool with a larger compile-thread budget,
saves that result, then restores production compile-thread sizing and
launches the full worker pool.
Cluster workers are different: they may land on different nodes, so
mhcflurry does not try to share Inductor cache across a cluster. Each
cluster worker process auto-sizes TORCHINDUCTOR_COMPILE_THREADS
locally when the env is unset or auto. If a scheduler packs multiple
mhcflurry work items onto one node, set
MHCFLURRY_CLUSTER_WORKERS_PER_NODE so each process uses a fair share
of cores.
Compiled losses are enabled by default when MHCFLURRY_TORCH_COMPILE=1
and can be disabled with MHCFLURRY_TORCH_COMPILE_LOSS=0 or
--torch-compile-loss 0. CUDA workers run a one-op autograd warmup
before compiling losses to avoid the PyTorch 2.4 / Triton
invalid device context failure in the first compiled backward kernel.
What is NOT the orchestrator’s job
Compiling models (
torch.compile). Compilation is per-network and happens insidefit(). The orchestrator only sizes the compile worker pool via env.Calling
predict()on individual alleles. The orchestrator builds work items; workers iterate.Validating data shape consistency between work items beyond what the worker-side fit/data constructors already check.
Recipes
Adding a new pre-fork resource
Add a
_initialize_<name>(args, all_work_items)helper near the existing ones intrain_pan_allele_models_command.py.Stash the result in
WORKER_CONTEXT["<name>"].Document the lookup key. Workers retrieve via
constant_data["<name>"](forked workers inherit; spawned workers receive one initializer payload per process; cluster workers receive the serialized context through the shared filesystem).Add a
getattr(args, "<flag>", None)gate so the helper is opt-in on the CLI side.
Adding a new env knob
Decide: orchestrator-set or worker-private?
If orchestrator-set: add a
_hoist_<knob>(args)helper that reads args + system info andos.environ.setdefault(...)s the value. Document the rule the orchestrator applies.If worker-private: just read it from
os.environinsidefit()or wherever it’s consumed. Document it inRELEASE_NOTES_<version>.md.
Adding a new worker-side filter
Drop into
mhcflurry/common.py(e.g.,filter_canonicalizable_alleles).Apply at every iteration site that could trip on the bad input. The 2.3.0 application sites are calibrate (affinity + presentation paths) and select (pan-allele + allele-specific paths).
Fit-time data flow
Affinity training has two configuration knobs that are deliberately configured separately. Do not collapse these into one.
1. Peptide amino-acid vector lookup
Controlled by the model hyperparameter
peptide_amino_acid_encoding_torch.
When enabled (the default), peptide strings are encoded as (N, L)
integer amino-acid indices. The model owns a frozen torch buffer for
the configured vector encoding (BLOSUM62, PMBEC, simons1999_contact,
or combinations), moves that buffer with .to(device), and widens
indices to (N, L, V) inside forward() using
torch.nn.functional.embedding.
This path is device agnostic: CUDA, MPS, and CPU all execute the same torch embedding operation on the active device. It is model semantics, not DataLoader behavior.
Disable it only with peptide_amino_acid_encoding_torch=False, which
restores the old path where numpy expands peptide strings into
(N, L, V) vectors before they are moved to the device.
2. Pretrain DataLoader process parallelism
Controlled by the model hyperparameter dataloader_num_workers.
This applies to the pretrain streaming path
(fit_streaming_batches) only. It is pure process-count policy
for the pretrain data pipeline:
value |
meaning |
|---|---|
|
build batches in the training worker process |
|
spawn that many DataLoader child processes per training worker |
Release recipes set only this knob. On a local 8-GPU run with
NUM_JOBS=16, dataloader_num_workers=1 means up to 16 extra
fit-local DataLoader children while pretrain epochs are active; 2
means up to 32. The thread-budget helper accounts for this when sizing
OMP_NUM_THREADS, MKL_NUM_THREADS, and OPENBLAS_NUM_THREADS.
Caller-provided values remain authoritative and are not replaced by the
planner’s uniform runtime limit. For serial (num_jobs=0) execution, an
auto-owned budget is applied directly to the current native and PyTorch pools
because there is no worker initializer.
The affinity fit() path does not use a DataLoader and therefore
ignores this hyperparameter.
Component consistency
The Class1NeuralNetwork affinity component model owns both
hyperparameters above, so allele-specific affinity models, pan-allele
affinity models, and affinity ensembles resolve the same internal rules
after config load. Missing keys in old component configs are filled
from defaults (peptide_amino_acid_encoding_torch=True,
dataloader_num_workers=0).
Processing models do not use the affinity fit() row layout and
therefore have no AffinityDeviceTrainingData.
rsync hygiene (laptop ↔ remote training box)
runplz rsyncs the working tree up to the box on every launch, and
the run’s out/ directory back to the laptop on completion. Two
asymmetries to know about:
Up direction: runplz’s hardcoded exclude list (
.git,.venv,__pycache__, etc.) doesn’t anticipate per-project output dirs. In mhcflurry,brev_runs/accumulates 7-15 GB of past-run artifacts that ride along on every launch unless relocated. Runbash scripts/dev/relocate_run_outputs.sh --applyonce to movebrev_runs/andresults/to~/mhcflurry-brev-runs/and~/mhcflurry-results/, with symlinks back into the repo. After that, rsync ships ~tiny symlinks instead of multi-GB directories.Down direction: the post-run rsync has NO excludes — everything under
out/returns. Keep large throwaway run artifacts outsideout/unless they are meant to ship back.
Pointers to code
Random-negative planning and pooling:
mhcflurry/random_negative_peptides.pyAffinity device-resident row space:
mhcflurry/class1_affinity_training_data.py(AffinityDeviceTrainingData)Pseudogene/null filter:
mhcflurry/common.py(filter_canonicalizable_alleles)Worker pool sizing:
mhcflurry/parallelism(auto_max_workers_per_gpu,resolve_max_workers_per_gpu)Compile-thread hoist:
mhcflurry.parallelism.hoist_torchinductor_compile_threadsCluster fork point:
mhcflurry/cluster_parallelism.cluster_results
Known asymmetries (deliberate)
These show up to readers as “why is this only done in pan-allele?” The answer in each case is “the other components don’t yet need it and adding it would be feature work, not a fix”:
torch.compileis off by default everywhere; opt-in viaMHCFLURRY_TORCH_COMPILE=1or--torch-compile 1. When enabled, the shared local-parallelism layer owns compile-thread sizing and local one-worker cache warmup for affinity and processing trainers. Presentation fitting is a separate model family and does not enter this central torch-training path.
Future tightening (not in 2.3.0)
Presentation-training orchestration. Today
train_presentation_models_commandruns single-process; mirror the pan-allele orchestration shape if presentation retraining becomes GPU-bound.