feat(training): single-node data parallelism via leech model train --gpus N - #242
Merged
Conversation
…-gpus N` --batch-size stays the GLOBAL batch and is split across ranks, so the step count, the epoch-indexed LR schedule and the accumulation arithmetic are unchanged at any GPU count and existing arms stay comparable. The weighted sampler shards one global draw rather than drawing per rank: handed to N ranks, WeightedRandomSampler gives each the same indices and the run trains on world_size copies of one shard without erroring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B
setup_logging runs in the click entry point, which a spawned rank never reaches, so the leech logger had no handler and every INFO line was dropped -- including rank 0's, where the effective batch, the sampler statistics and the encoding-fallback warning are reported. Found by running --gpus 2 on the production corpus: the run works and says nothing about itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B
jayhesselberth
force-pushed
the
worktree-multi-gpu-ddp
branch
from
September 7, 2026 12:01
e207651 to
c6456ee
Compare
Every other loss here reduces by element count, which equal shards make exact under DDP. CrossEntropyLoss(weight=...) divides by the summed weight of the samples it sees, so each rank normalized by its own shard's class composition and the averaged gradient was the single-GPU one only when the shards happened to draw alike -- 8% off on a fixture where they do not, with nothing raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B
multiprocessing.spawn.prepare forces a spawned child's default start method to match how it was created, so every DataLoader a rank built pickled the dataset's stacked tensors through /dev/shm rather than COW-sharing them -- voiding the invariant those contiguous buffers exist for. It surfaces nowhere near the cause: on the production corpus the fourth rank's validation loader died with "No space left on device", at 176 GiB RSS against a 244 GiB allocation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B
The Rust crate and the Python package release in lockstep from escapepod-rs, and leech drives the same refinement settings through both, so a skew between them is the divergence #193 took four releases to notice. Moved together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B
Dependabot's cargo ecosystem bumps the tag-pinned escapepod-signal crate on its own schedule (#236 took it v0.16.1 -> v0.18.1) and cannot know the escapepod PyPI package in pyproject.toml has to move with it. leech drives both, so the skew that merge leaves behind lets the two prepare backends compute different dwells from the same read -- issue #193, invisible for four releases. Daily and PR-opening rather than a red check on dependabot's PR: failing it would leave a human to do the second half by hand. The lock entry is rewritten from PyPI's release record rather than by `uv lock`, which re-serializes the whole file (670 changed lines here, ~300 packages gaining emscripten markers) and buries the one line worth reviewing. `uv lock --check` is the guard on the result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B
Replaces the extrapolated memory footprint with three measured points, and adds the start-method rule: a spawned rank builds DataLoader workers by spawn, which pickles the corpus through /dev/shm instead of COW-sharing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B
Nothing else reads these files; a malformed workflow is reported only by GitHub, only after a push, and a scheduled one that never fires is indistinguishable from one with nothing to report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
leech model train --gpus Nruns N data-parallel ranks on one node. Opt-in,single-node, train only —
eval testis input-bound and gets nothing from it.Closes #241.
The two decisions worth reviewing
--batch-sizeis the GLOBAL batch, split across ranks. This is the oppositeof the PyTorch convention, and it is what keeps a multi-GPU run comparable with
the arms already measured: the optimizer-step count, the LR schedule,
ClipGrad's quantile buffer and thegrad_accum_splitarithmetic are allunchanged, so the same command line means the same recipe at any
--gpus.The weighted sampler shards one global draw rather than drawing per rank.
Every rank draws the same multinomial from a private generator seeded
seed + epoch, then takesdraw[rank::world_size]. The union of the shards isexactly the single-GPU epoch, so the class ratio
--oversample-minorityproduces is reproduced by construction rather than in expectation.
Measured scaling
Production charging corpus (6.72M chunks, 36.5 GiB npz), 2 epochs, one 4×A30
node. Full numbers and caveats in #241.
--gpus2.25x rather than 4x: ~3 min of per-rank corpus load and the final eval do not
shard, so they floor a 2-epoch run. Training time alone goes 18:30 → 10:14 →
8:55. Memory is ~1:1 with the corpus per rank, so
--gpusandmem_mbmovetogether or the second rank is OOM-killed during its load.
Three corrections to the issue
trainer counts optimizer steps, so criterion 4's second half needed stating
rather than fixing.
AdversarialHeadandRegressionHeadare in the optimizer but outside themodel, so a DDP around the model alone never allreduces their gradients.
Each now gets its own wrapper, with a test that fails if one is added without.
CrossEntropyLossdoes not decompose over shards. It normalizesby summed weight, not count, so each rank divided by its own shard's class
mix — 8% off on a fixture whose shards differ, silently. Ranks now form
world_size × local / globalweight sum.Two defects only the hardware found
Spawned ranks were silent.
setup_loggingruns in the click entry point,which a rank never reaches, so every INFO line was dropped — including rank 0's.
DataLoader workers were spawning, not forking.
multiprocessing.spawn.prepareforces a spawned child's start method to match how it was created, so every
DataLoader pickled the dataset's stacked tensors through
/dev/shminstead ofCOW-sharing them — voiding the invariant those contiguous buffers exist for. It
does not look like memory: shm is charged to the cgroup but not to RSS, so the
run died at 176 GiB RSS against a 244 GiB allocation, in the fourth rank's
validation loader, as
No space left on device. Fixing it took the same run to157.6 GiB.
Also here, by request
escapepod v0.16.1 → v0.21.0, on both backends. The crate and the PyPI
package are one upstream released in lockstep, and leech drives both, so they
have to move together — dependabot's #236 bumped the crate alone and left main
skewed. Validated by
tests/test_backend_parity.py(86 tests) against aleech_coreactually built on v0.21.0, not justcargo check.A daily check so that cannot recur (
.github/workflows/escapepod-sync.yml):compares the two pins, and when they disagree rewrites both and opens a PR.
Deliberately daily-and-propose rather than red-on-dependabot's-PR, which would
leave a human to do the second half by hand.
uv.lock's entry is rewritten fromPyPI's release record because
uv lock— including--upgrade-package—re-serializes the whole file at ~670 changed lines and buries the real edit.
Acceptance criteria
test_two_rank_gradients_match_the_single_rank_gradients— one step from identical init, 2x4 vs 1x8, equal to 1e-6test_weighted_shards_interleave_to_the_single_global_draw(union == the reference draw),test_weighted_shards_differ_between_rankstest_two_rank_checkpoint_has_the_single_rank_keys— key-set equality, nomodule.no_sync()+ schedulertest_no_sync_covers_every_micro_step_but_the_last; scheduler is epoch-indexed (correction 1)test_single_gpu_creates_no_process_group; every change is behindworld_size > 125 distributed tests run on gloo over CPU, so they run in CI rather than only on
a GPU node. Full suite 1559 passed / 44 skipped.
Not in this PR
The 1 vs 2 GPU same-seed convergence run (~8 GPU-hours) — worth scheduling
deliberately rather than folding into review. The AUROC spread in the table is
not that experiment: augmentation noise is drawn per DataLoader worker, so a
different rank count means a different realized trajectory by construction.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B