mixle.utils.parallel.fault_tolerant_training module¶
Fault-tolerant gradient training: async DCP snapshots, loader-state capture, elastic restart, resume-with-receipts (roadmap F2).
This is the gradient-training-side analogue of mixle.utils.parallel.resilient_em (K4): that module
makes the accumulator-combining EM path tolerant of a dying worker (retry, blacklist, elastic
re-partition, deterministic rendezvous-based chaos test); this module carries the same PATTERN –
“detect a failure, don’t restart the whole job, resume from a checkpoint” – to the gradient-training path,
where the parallelism is data-parallel gradient averaging rather than additive sufficient-statistic
folding, so the mechanics differ even though the shape of the fault-tolerance story does not:
Async DCP snapshots (
save_checkpoint_async()) – wrapsmixle.utils.parallel.dcp_checkpoint.save_sharded()’s underlyingtorch.distributed.checkpointcall so a checkpoint does not block training: the (bounded, D2H-copy) cost of cloning the state dict to a frozen CPU snapshot happens synchronously on the caller’s thread, and the (unbounded, I/O-latency-bound) cost of actually writing it to disk happens on a background thread. Loader state rides along in the same checkpoint directory (a siblingloader_state.json), so a resume restores model + optimizer + data position together, atomically from the caller’s point of view.Loader-state capture (
LoaderState) – mirrors the resumability contractmixle.data.streaming_corpus.StreamingCorpus(F3, PR #139) already guarantees:epoch_batches(epoch)is a pure, deterministic function of(seed, epoch, rank, world_size), so the ONLY thing that changes as an epoch progresses is how many batches have been consumed – capturing(seed, epoch, rank, world_size, batch_idx)is sufficient to reconstruct the identical remaining stream.resume_batches()resumes any loader that exposes that sameepoch_batchescontract, F3’s or a synthetic stand-in.Elastic restart (
SimulatedRank,ElasticTrainingJob) – mirrorsResilientMPEncodedData’s deterministic kill rendezvous (each rank signals “started” – here, once its forward+backward for a step is done – then blocks for an explicit “go” from the driver before the optimizer step commits, so a chaos test’s kill lands at a known point, not a timing race) but adapted to data-parallel gradient averaging: a dead rank’s gradient is simply excluded from the step’s average (the job continues with fewer ranks, degrading gracefully) rather than failing the whole step, and a dead rank can be elastically respawned and resume from the last checkpoint’s loader state (not from scratch, not re-running the whole job).Resume-with-receipts –
ElasticTrainingJob.respawn_rank()marks the NEXT observed step asrestart=Truewhen it feedsmixle.utils.parallel.training_health.TrainingHealthMonitor(F4, PR #147), so every restart automatically gets F4’s per-restart continuity verdict for free – this module does not reimplement that check, it wires into it.
Scope note (mirrors the dcp_checkpoint / resilient_em modules’ own scoping): “10k A100s” framing
aside, checkpointing, async snapshotting, elastic restart bookkeeping, and loss-continuity verification are
all exact regardless of scale – what genuinely does not exist on a laptop is FSDP2 sharding a model too
big for one device and a real multi-node NCCL all-reduce. Those two are simulated here: world_size
ranks are real concurrent OS threads (not a real distributed job), and gradient “all-reduce” is a plain
mean over surviving ranks’ locally computed grads. Every other piece – DCP save/load, the CPU-clone async
mechanism, loader-state round-tripping, the kill rendezvous, and the continuity check – is the same code
that would run at 10k GPUs, exercised at small scale.
- class LoaderState(seed, epoch, rank, world_size, batch_idx=0)[source]
Bases:
objectResumability state of one rank’s data loader: enough to reproduce its exact next batch after a restart, without serializing RNG internals or buffered batches.
Mirrors
mixle.data.streaming_corpus.StreamingCorpus’s contract:epoch_batches(epoch)is a pure function of(seed, epoch, rank, world_size)(global_document_order()reseeds from(seed, epoch)viaSeedSequence, thenshard_documents_for_rank()deterministically slices per rank) – the only thing that varies as an epoch progresses is how many batches of that deterministic stream have already been consumed, i.e.batch_idx.
- resume_batches(corpus, state)[source]
Resume a loader shaped like
mixle.data.streaming_corpus.StreamingCorpus(anything exposingepoch_batches(epoch) -> Iterator[(x, y)]with that contract) exactly atstate.batch_idx.Determinism is what makes this correct rather than approximate: re-materializing the whole epoch and discarding the already-consumed prefix reproduces bitwise-identical remaining batches to what an uninterrupted run would have yielded from that point on – the same trick
mixle.utils.parallel.resilient_em.checkpointed_fold()relies on for exact accumulator recovery.- Parameters:
corpus (Any)
state (LoaderState)
- class AsyncCheckpointHandle(thread, path, prepare_time_s)[source]
Bases:
objectA checkpoint write in flight (or finished) on a background thread.
- save_checkpoint_async(module, optimizer, path, loader_state, *, extra=None)[source]
Snapshot
(model, optimizer, loader_state)topathwithout blocking the training loop.Refines
mixle.utils.parallel.dcp_checkpoint.save_sharded()for the async case: that function callsdcp.savedirectly on the live state dict, which blocks the caller for the full write – fine for a synchronous checkpoint, unsafe to background (the live tensors keep changing under the writer). Here, the ONLY synchronous work isget_state_dict+ a detached CPU clone (a bounded D2H-copy cost that does not scale with disk/network write latency); the actualdcp.savecall – and the siblingloader_state.jsonwrite – happen on a background thread, so this function returns as soon as the clone is done and the training loop’s next step can start immediately.loader_state(plus any caller-suppliedextra, e.g. every rank’sLoaderStatein a multi-rank job) is written alongside the DCP checkpoint directory as JSON – resuming needs both the model/optimizer AND the data position, and this keeps them physically bundled under onepath.
- load_checkpoint(module, optimizer, path)[source]
Load a checkpoint written by
save_checkpoint_async()(or plainsave_sharded, if a siblingloader_state.jsonwas written by hand) intomodule/optimizerin place; returns the capturedLoaderStateso the caller’s data loader can resume from the exact same position.
- class StepResult(rank: 'int', step: 'int', loss: 'float', grad_norm: 'float', grads: 'list[torch.Tensor]')[source]
Bases:
object
- class SimulatedRank(rank_id, model_factory, batch_fn)[source]
Bases:
objectOne data-parallel rank’s local training-step worker, run on a real background thread.
Mirrors
mixle.utils.parallel.resilient_em.ResilientMPEncodedData’s rendezvous: after computing a full forward+backward pass for a step – the point at which a real GPU worker would ordinarily all-reduce gradients and step the optimizer – the thread signalsstartedand BLOCKS waiting for an explicitgofrom the driver. This pins “mid-step” to a known point (strictly after gradient computation, strictly before the step is applied), so a chaos test’s kill is deterministic, not a timing race: a kill issued at this rendezvous is guaranteed to land before any weight update happens.kill()needs no OS-level teardown (real process kill, asresilient_emdoes, is not available for an in-process thread): simply never releasing the rendezvous IS the simulated crash – the thread times out and exits with no result, exactly as a real dead worker would leave the driver’srecv()hanging until it gives up.- Parameters:
- release()[source]
Wave this rank through the rendezvous – it survives this step.
- Return type:
None
- class ElasticTrainingJob(model_factory, world_size, batch_fn_for_rank, checkpoint_dir, *, seed=0, lr=1e-2, health_monitor=None)[source]
Bases:
objectA data-parallel training loop, chaos-tolerant: a rank dying mid-step degrades gracefully – the job continues averaging over fewer surviving ranks that step, rather than hard-failing – and a dead rank can be elastically respawned and resume from the last checkpoint’s model/optimizer/loader state instead of the whole job restarting from scratch.
Holds one canonical
(model, optimizer)(what gets checkpointed and what a respawned rank loads); eachSimulatedRankcomputes its OWN local forward+backward against a fresh copy of the canonical weights (the data-parallel replica), and the driver applies the mean of surviving ranks’ gradients to the canonical model once per step – the plain-mean “all-reduce” this module’s docstring flags as the one piece that is genuinely simulated rather than exercised for real.Every restart is wired into F4’s continuity check for free:
respawn_rank()marks the NEXTrun_stepcall asrestart=Truewhen it feedshealth, sohealth.report()["restarts"]always carries a real per-restart continuity verdict, not something the caller has to remember to ask for.- Parameters:
- run_step(step, kill_ranks=frozenset())[source]
Run one data-parallel step.
kill_rankssimulates a mid-step death for those ranks: they reach the post-backward rendezvous (so their compute genuinely happened) but are never released, so their gradient is excluded from this step’s average – the job continues with fewer ranks rather than raising.
- checkpoint(path=None)[source]
Async-snapshot the canonical model/optimizer plus every rank’s loader state.
- Parameters:
path (str | None)
- Return type:
AsyncCheckpointHandle
- respawn_rank(rank_id, checkpoint_path=None)[source]
Elastic restart: bring
rank_idback from the last checkpoint – model, optimizer, and every rank’s loader state – instead of restarting the whole job from scratch. Mirrorsresilient_em’s_respawn_worker: same rank id, resumed data position, job otherwise untouched. Marks the nextrun_stepas a restart so F4’s continuity check evaluates it.