Add --distribute_latents: shard the latent dimension across ranks - #143
Open
alepot55 wants to merge 2 commits into
Open
Add --distribute_latents: shard the latent dimension across ranks#143alepot55 wants to merge 2 commits into
alepot55 wants to merge 2 commits into
Conversation
The backward pass builds `grad_values[:, :, None] * input[:, None, :]`, an `[N, k, D]` tensor holding every top-k contribution at once. It is the largest single allocation in SAE training: 288 MiB at batch 8, ctx 512, d_model 576, k 32, and 1 GiB in the shape `tests/test_encode.py` already exercises. It scales with k, so the `--k 192` configuration the README recommends pays six times that. Consume it in row-blocks sized to a fixed byte budget instead. The arithmetic is unchanged; only the accumulation order inside `index_add_` differs, and that was already unspecified on CUDA. Peak allocated per rank on SmolLM2-135M with 30 hookpoints at 2 GPUs: 9.19 -> 9.04 GiB under DDP, 4.24 -> 3.86 GiB under --distribute_modules. Step time is unchanged within run-to-run variance.
DDP replicates every sparse coder on every rank, and `--distribute_modules` removes that replication only when the number of hookpoints divides evenly by the world size. Sharding the latent dimension instead keeps every hookpoint on every rank and splits each coder's rows, so parameter, gradient and optimizer state shrink with the world size for any number of GPUs. Ranks agree on one global top-k through a candidate all-gather, decode only the latents they own, and sum the partial reconstructions. The backward needs no communication, because every rank computes the same loss from the same summed output. Only `b_dec` and `W_skip` are replicated, so only their gradients are reduced. Checkpoints are gathered on rank 0 and written unsharded, so they load with the ordinary loader and a sharded run is interchangeable with a single-GPU one. The survivors are kept as a ragged (token, latent, value) list rather than as the `[N, k]` candidate block with the losers zeroed. That matters: with the block layout both the decode and the encoder weight-gradient stay `O(N * k * d)` however few entries survive, so per-rank work would not fall as the world grows. Compacting makes them `O(nnz * d)` with `nnz = N * k / world_size`. Supports the AuxK loss, multi-TopK and skip connections. `groupmax` partitions the latent dimension itself and is rejected. Adds the first distributed tests in the suite: gloo on CPU, checking forward, gradient and checkpoint equivalence against an unsharded coder across every loss term, plus a regression test for the shard initialization.
|
|
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.
Summary
Adds
--distribute_latents, a third way to spread SAE training across GPUs: shard the latent dimension of every coder instead of assigning whole hookpoints to ranks. Rankrowns latents[r*M/W, (r+1)*M/W)of every coder.I opened this after profiling where the memory in
torchruntraining actually goes, so every number below is measured rather than modelled. Happy to split, rename, or restructure it if the shape is wrong for you.Why not
--distribute_modules--distribute_modulesrequires the hookpoint count to divide evenly by the world size, and it leaves each rank materialising the world-sized activation for every hookpoint, including the ones it does not own. Sharding latents instead keeps every hookpoint on every rank, so any GPU count works, and the[N, num_latents]pre-activation shrinks with the world size rather than staying fixed.Design
Two collectives per coder per forward, none in the backward:
b_decandW_skipare replicated rather than sharded, so each rank contributes1/Wof them and only their gradients are reduced. The sharded rows see every token on every rank, so their gradients are already complete.Two details worth flagging for review:
[N, k]candidate block with losers zeroed. With the block layout both the decode and the encoder weight-gradient stayO(N*k*d)however few entries survive, so per-rank work does not fall as the world grows, which defeats the point. Compacting makes themO(nnz*d)withnnz = N*k/world_size. Measured, at 8 GPUs: 944 -> 430 ms per step at batch 8, and 6988 -> 2752 ms at batch 64. Padding to the busiest token instead would have recovered only about 2.5x at k=32 and 8 ranks, againstworld_sizehere.num_latents / world_sizedistinct latents. It also makes a sharded run reproduce an unsharded run with the same seed, row for row.test_shard_initialization_matches_global_drawguards this.Checkpoints are gathered on rank 0 and written unsharded, so they load with the ordinary
SparseCoder.load_from_diskand a sharded run is interchangeable with a single-GPU one.Measurements
SmolLM2-135M, 30 hookpoints, batch 8 per rank, ctx 512, A100-40GB, 8-bit Adam. Peak allocated per rank and median step time. Every column processes the same number of tokens per step.
--distribute_modules--distribute_latents"Not runnable" is
AssertionError: Number of modules must be divisible by world size, since 30 is not divisible by 4 or 8.The trade-off, stated plainly
Against
--distribute_modulesthis is better on every axis I measured: lighter, faster, and it runs at GPU counts where the other refuses to start.Against DDP it buys memory with throughput. At 8 GPUs and batch 8 that is 5.4x less memory for 1.4x the step time. The slowdown grows with the batch, because the collectives scale with the gathered batch while the per-rank shard does not: at batch 64 the same run is 1.9x lighter and 2.1x slower. A phase breakdown at 8 GPUs and batch 64 puts the top-k all-gather at 9.2% of the step and the reconstruction all-reduce at 6.8%, with the rest being work DDP does too. The README says all of this and suggests preferring a smaller per-rank batch with more gradient accumulation at high rank counts.
Step times on this setup vary about 25% run to run, so I would only trust the large ratios above, not differences of a few percent.
Correctness
New
tests/test_latent_parallel.py, the first distributed tests in the suite. They run gloo on CPU, so they need no GPU and no launcher; the equivalence being checked is algebraic. Each case builds a full coder and a shard holding the same weights, then asserts the shard matches the unsharded coder on the reconstruction, every loss term, the gradients of the rows it owns, the reduced gradients of the replicated parameters, and the gathered checkpoint. Covered: k in {1, 4, 32}, multi-TopK, skip connections, AuxK, and AuxK with skip connections.End to end on GPU, FVU at the first step matches DDP across world sizes (0.6385 to 0.6408 against DDP's 0.6391 to 0.6407).
Existing suite on an A100:
maingives1 failed, 10 passed(the failure is #142), this branch gives the same single failure and 18 passed.Not supported
groupmaxpartitions the latent dimension itself, so it cannot be split this way and is rejected with an explicit error.--distribute_latentscannot be combined with--distribute_modules, or with theceandkllosses, matching the existing restriction on--distribute_modules.