Dion2: Add selection_scope="global_capped": global selection at local comm cost#99
Dion2: Add selection_scope="global_capped": global selection at local comm cost#99alint77 wants to merge 6 commits into
Conversation
…tion at local comm cost Third selection scope for the FSDP2 row-sharded path, between "global" (exact selection, full-shard comm) and "local" (fraction-scaled comm, per-shard approximate selection): 1. all-gather the slice L1 norms only (one fp32 per row, ~KBs vs MBs of rows); 2. every rank computes the same global top-(k*world_size) threshold from the same gathered tensor (rank-consistent by construction); 3. each rank sends its globally-selected rows through the same fixed k-slot alltoall as "local" (local_comm_size=k, wire format unchanged). A rank owning more winners than slots defers the overflow: only selected rows get error-feedback decay, so deferred winners keep accumulating momentum and win a slot on a later step. Spare slots fill with the rank's next-best rows. Implementation: dion2_pre_accumulate_norms (compiled: M+=G, stacked norms) + dion2_capped_priority_async (eager async all-gather -> capacity priority, yields in megabatch style) + a `priority` kwarg on dion2_pre_orthogonalize that swaps the topk key and skips re-accumulation. NorDion2 reuses all three. Unsharded/DDP falls through to the existing no-op selection path. Measured (NorDion2 5120-hidden/8-layer, 4xH100, fraction=0.25, gram-NS): a2a 1311 MB/step (= local; global ships 5243 MB), norms all-gather 2.5 MB + 0.02 ms, opt.step 33.2 ms (local 31.0, global 35.5), end-to-end step time and MFU equal across scopes at this size. 2-rank functional tests: selection == exact global top-k when winners fit per-rank capacity; on overflow the deferred winners are selected on the following step (EF deferral); even and uneven shard divisions both pass; existing selection_scope test suite 11/11. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
This is John's reviewbot. Read the full diff plus Correctness — solid, with one semantic nuance to documentVerified the load-bearing invariants:
But: "global selection" here is top-(k·world_size), not top-(fraction·global). They coincide only when
Design — the accumulation split is a footgunMomentum accumulation is now owned by two compiled functions and kept correct only by two matching Smaller items
TestsThe committed change ships with no tests — Security / backward-compatNo concerns. Numeric optimizer path, no I/O or untrusted input; collective sizes derive from tensor shapes. The new enum value and trailing optional Nice work — the offset-priority trick to fold "winners-first, then fill by norm" into a single |
…review) The review's asks, plus one deeper fix found while implementing them: The committed capped scope was trajectory-identical to "local". The global threshold is a value cut, so a rank's winners are always its top-norm rows; filling spare slots with next-best rows and giving fillers EF decay made the selected set exactly the per-rank top-k (verified analytically and by 20k-trial brute force). Fixed: spare slots now travel as zero rows (zeros pass through Newton-Schulz as exact zeros, so fillers get no weight update) and fillers skip error-feedback decay, so only global winners are applied and overflow deferral is real. Review items: - Accumulation contract: pass the threshold (not a priority tensor) into dion2_pre_orthogonalize; dion2_pre_accumulate_norms is the single accumulation owner and the priority offset (a topk no-op) is gone. - Document the budget as top-(k*world_size), not top-(ceil(fraction*global)); they coincide when world_size divides the sharded dim. - Drop the dead device_rank parameter; raise on negative pad instead of letting F.pad silently trim; comment the >=-tie inflation. - Port tests into tests/test_dion2_selection_scope.py: capped == global when winners fit, overflow deferral across steps (the test that distinguishes capped from local), uneven shards, fraction=1.0 == global, empty shard, bit-identical threshold across ranks, negative-pad raise. 21/21 pass on 2x/4x H100. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed 6ce9ac5 addressing the review — all asks are in:
One thing the thresh refactor surfaced that we both missed: as committed, the scope was trajectory-identical to Fixed in the same commit: spare slots now carry zero rows (zeros pass through Newton-Schulz as exact zeros → no update) and fillers skip EF decay, so only global winners are applied and deferral is real. The new same-seed run diverges from both One consequence for the A/B: per-step applied rows are k·world minus deferrals, so the effective fraction dips slightly below nominal whenever winners overflow a rank — your persistent-deferral probe is the adversarial version of this and still worth running. Perf unchanged (fresh 5120/8L trace, table in the description): a2a 655 MB vs global's 2621 per step, opt.step GPU 34.6 ms vs 35.5 (global) / 31.0 (local), MFU parity. Description updated to match the new semantics. Remaining ask before undraft is the convergence A/B vs |
|
This is John's reviewbot. Re-reviewed at Round-1 items — all resolved, verified
New winners-only masking — correctZeroing non-winner fillers and skipping their EF decay is sound: a zero input row survives Newton-Schulz as an exact zero (the Empirical findings from testing this (may inform the pending A/B)I ran the mechanism on H100s. Two things worth having before the convergence run:
The more elegant alternative — now validated end-to-endI prototyped the asymmetric (variable-length) reshard we discussed and it works on real hardware (2 & 4 ranks): drive VerdictIn good shape. No correctness blockers; the mechanism does what it claims and the tests back it. Merge-gating (per the PR) is still the convergence A/B — recommend running it on a |
|
This is John's reviewbot. Heads-up on a correctness blocker for Root cause: the EF write-back computes One-line fix — cast the EF write-back to # was: ef_src_list = list((selected_stacked * ef_factor).unbind(dim=0))
ef_src_list = list((selected_stacked * ef_factor).to(dtype).unbind(dim=0)) # dtype = M[0].dtypeVerified: pre-fix crashes as above; post-fix, Worth adding a bf16 param to the scope tests so this can't regress — happy to open a small PR with the fix + a bf16 test case if useful. |
The winners-only refactor changed the EF factor from the 0-dim ef_decay scalar to a dimensioned [N, k, 1] fp32 tensor. PyTorch promotion treats these differently: bf16 * 0-dim fp32 stays bf16, but bf16 * dimensioned fp32 promotes to fp32, and the in-place scatter_ into bf16 momentum then raises "Expected self.dtype to be equal to src.dtype". Only fires with bf16 optimizer state (real mixed-precision training); every existing test used fp32 params, so the suite stayed green. Fix per review: multiply in fp32, round once into M's dtype (a no-op on all fp32 paths). Add a bf16-param smoke test over local/global_capped x Dion2/NorDion2 so the promotion path can't regress. 25/25 on 2x/4x H100. Reported-by: JohnLangford (1b NorDion2 bf16 run) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Good catch — fixed in 987acaa with your one-liner (multiply in fp32, single round into Confirmed the root cause: the winners-only refactor changed the EF factor from the 0-dim Also added the regression guard you suggested: |
|
This is John's reviewbot. Re-reviewed at bf16 correctness blocker — resolved & verified
Everything else stands as reviewed at The convergence A/B (the merge gate) — real numbersI ran all scopes end-to-end on this PR's code: 1b NorDion2, phinext, 10B tokens, 8×H100.
*slack = an experimental variant I added (more comm slots so fewer winners defer), not part of this PR — included only as a near-exact reference point. All four land within 0.0026 nat on train loss, and the ordering is non-monotonic — tight- That's the crux for merge. Elegant alternative (recap, now with data)The variable-length |
|
I am working on a new solution where instead of naively adding a "slack", we flatten the topk selections in each megabatch comm packet which includes more than one matrix and pack them together, similar to how varlen sequence packing works in attention. Then on top of that, we add some extra capacity (or "slack") to the whole buffer instead of adding slack to each. this should reduce the number of dropped/overflowed rows significantly while preserving the comm volume of local, static buffer sizes and thus async cpu dispatches. @JohnLangford |
…on, zero syncs
Replaces the per-matrix fixed-k capped transport with pooled packing ("T2",
see dion2_capped_pooled_plan): each (rank -> dest) a2a chunk has a fixed
budget of rows SHARED across the destination's per_rank matrices, packed
back-to-back with an int32 count header bit-cast into the chunk's first row.
One matrix's winner overflow uses another's spare room, so deferral drops
from per-matrix fluctuation to pooled fluctuation. The receiver parses the
sender's header instead of recomputing counts from the gathered norms --
no bit-identical-plan invariant across ranks, and a receiver-side clip
guards divergence. All collective sizes are construction-time constants;
data-dependence lives in GPU gathers/scatters (static shapes, no host syncs,
compile-safe).
Also in this change:
- Exact-count winner set (T0): stable-argsort tie-break, budget is exactly
top-ceil(fraction*global) -- fixes the top-(k*world) overshoot and makes
sum(winners) <= NS buffer a hard invariant the transport relies on.
- capacity_factor knob: None (default) = auto per group,
1 + 2*sqrt((1-1/world)/(per_rank*k)); float >= 1.0 pins it. Groups whose
budget would reach the full shard (or whose header cannot fit the first
row) route to exact "global" scope.
- Cross-matrix deferral priority is scale-normalized (norm/thresh): a
large-norm matrix cannot evict a small-norm matrix's winners from the
shared chunk.
- The capped path now mirrors the global path's structure: returned shards
are zero except at applied winner rows and feed the existing masked post
(EF decay on applied rows only; deferred winners keep accumulating).
dion2_pre_orthogonalize no longer has a capped mode.
- CAPPED_STATS module dict exposes deferred/winner row counts per step
(GPU tensors, no sync) for deferral-rate logging.
Tests: pack/unpack identity roundtrip (pooled deferral, blackout matrix,
uneven counts); pooled cross-matrix skew with scale-normalized eviction
order; exact-count rank consistency; header bit-cast roundtrip; degeneracy
routing (empty shard, fraction=1.0); all prior scope + bf16 tests updated
for the multi-param harness. 28/28 on 2x/4x H100.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…routing break-even - CAPPED_STATS is now a reliable rank-local per-step reading: cleared once per optimizer step (top of _create_ortho_tasks, executed on the first task pull) and accumulated across the step's packed megabatches, instead of last-writer-wins across interleaved shape-group tasks. Empty dict means no packed group ran (no stale reuse); semantics documented at the definition. - Overflow deferral uses stable argsort instead of topk: tied normalized priorities break by flat (matrix, row) order, so WHICH row defers is reproducible across runs/devices -- matching the winner selection's stable tie contract. (Sender-local either way; this is a determinism fix, not a cross-rank one.) - Degeneracy routing accounts for the header row: the packed chunk ships 1 + budget rows, so break-even against the full shard is budget + 1. 28/28 on 2x/4x H100. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed the packing idea I teased above: The idea, simplyThe old design gave every matrix its own Deferral now requires a rank's winners summed over the pooled matrices to overflow, so it shrinks from per-matrix fluctuation to pooled fluctuation (relative sigma drops by sqrt(per_rank)). This is your slack experiment taken one step further: pool first, then put the slack on the shared buffer — at Implementation
Tests: 28/28 on 2x/4x H100 — including a bit-exact pack->a2a->NS->unpack roundtrip (pooled deferral, blackout matrix, uneven counts) and a cross-matrix-skew test proving pooling ships what the walls couldn't while a 100x-smaller-scale matrix's winner survives eviction. Your fraction=0.25 (>=2 seeds) sweep remains the merge gate and I'd suggest running it on this code — happy to take you up on the offer. PR description updated to match; the results table there is from the previous transport (comm volume is unchanged modulo the ~0.1% header; fresh trace pending). |
|
@JohnLangford please run an extended training run on all 3 algorithms so we can compare the numerics. global_capped should be very close to global now |
|
This is John's reviewbot. Re-reviewed at
Correctness spot-checks all hold: exact-count winner set via stable argsort (ties can't inflate past budget — my round-1 concern, now closed and the count is exact Hardware results I can contribute (caveat: on the previous fixed-slot design)I ran the convergence A/B and a same-pod timing sweep before this redesign, so treat them as a lower bound on what this version does:
The one thing still open (the merge gate)Both my results are at Minor
Backward-compat/security: clean — new |
|
This is John's reviewbot. Same-pod timing of the selection scopes, since the throughput claim is the crux of this PR. Two tables: A is the current pooled design (this PR head), B is the earlier fixed-slot design for reference. Definitions / method
Table A — pooled
|
| fraction | scale | global | local | capped |
|---|---|---|---|---|
| 0.25 | 1b | 25.15 (1.00) | 26.79 (1.07) | 28.61 (1.14) |
| 0.25 | 14b | 68.20 (1.00) | 51.60 (0.76) | 67.28 (0.99) |
| 0.50 | 1b | 26.13 (1.00) | 27.85 (1.07) | 30.11 (1.15) |
| 0.50 | 14b | 102.70 (1.00) | 80.40 (0.78) | 104.33 (1.02) |
Table B — earlier fixed-slot design (pre-redesign), fraction 0.5
capped here is the fixed-k-slot mechanism from before the pooled rewrite; slack1.5 and exact are my experimental variants (not in this PR), included to show the frontier.
| scale | global | local | capped (fixed-slot) | +slack1.5 | exact |
|---|---|---|---|---|---|
| 1b | 26.29 (1.00) | 27.58 (1.05) | 31.88 (1.21) | 31.89 (1.21) | 31.82 (1.21) |
| 14b | 100.27 (1.00) | 80.98 (0.81) | 90.67 (0.90) | 120.07 (1.20) | 101.05 (1.01) |
What stands out
localis the speed winner at 14b — 0.76–0.78× (pooled) / 0.81× (fixed-slot) — at every fraction. It ships the same reduced comm ascappedwith none of the packing machinery.global_capped≈globalat 14b (0.99–1.02×, pooled), and slower at 1b (1.14–1.15×). The pack/assemble/repack + norms all-gather overhead cancels the comm it saves — so on isolated opt.step time it doesn't get belowglobalhere.- The pooled redesign is ~15% slower at 14b than the old fixed-slot
capped(104.3 vs 90.7 ms; 1.02× vs 0.90×). The exactness + zero-syncs + routing are real wins, but theargsort/scatter/header/pooling cost more than the old uniform-slot path — which is why I don't reproduce the reported 0.94×. - Likely cause of that gap: my 14b is 40 layers vs the reported 8-layer config. The pooled pack's
argsort/scatter scales with the megabatch size (per_rank × matrices), so overhead grows with depth.capped's speed advantage looks config-dependent — present at shallow configs, absent at production depth.
Caveats: random gradients (real winner distributions could change the pack cost), and this is opt.step in isolation (raw comm volume still favors capped, which can matter under network contention even when isolated step time doesn't). Worth confirming on a real gradient step at depth before leaning on the throughput claim.
which script was used for testing? it mentions "no forward/backward, no data" which is pretty much the same as having a host sync at the start of opt.step... Please make sure the results are gathered using the new profile_training_step.py in Noah's fork and not "benchmark_optimizer.py". My testing shows much better perfromance for the new pooled implementation. You can see the result on the next comment: |
|
These are from real training steps (fwd/bwd running, real gradients, torch-profiler traces of the live run), not an isolated Per optimizer step, rank 0 (device-time kernel accounting from the trace)
NS runs at 97–99% of bf16 peak in every scope (identical selected shapes ⇒ identical 17.24 TF/step), so it's a fixed floor; the entire scope difference lives in the non-GEMM slice — and pooled wins it against both Why pooled beats local on GPU here1. Local pays for selection in full-momentum HBM traffic; pooled decides on kilobytes. Local's pre-path materializes 2. The a2a gap (2.9 vs 4.1 ms at the same bytes) is overlap, not transport. Per-peer messages are near-identical (2880 vs 2974 rows × 5120 cols, bf16). But a SendRecv kernel still reads/writes HBM, and local's a2a kernels spend 92% of their lifetime concurrent with HBM-saturating full-momentum kernels (mean 1.02 ms/kernel), while 18/20 of pooled's overlap only budget-sized bookkeeping (mean 0.66 ms). The control is in the data: the 2/20 pooled a2a kernels that did land on heavy kernels slowed to 1.33 ms — same wire, same slowdown. 3. Pooled's one real cost is launches, and it's host-side only. ~150 extra tiny index-arithmetic kernels ≈ 0.1 ms of GPU time, but 117 launches vs 60 and a 13.3 vs 10.6 ms CPU span. Reconciling with the isolated-timing tables abovePoint 3 is, I suspect, most of the disagreement. In a live training step the host runs far ahead of the device (CPU span 13 ms vs a 475 ms step), so launch overhead is fully hidden and only device time counts — which favors pooled. A sync-per-step The depth point itself is fair and this table can't refute it: at 8L/33 matrices the pack's sort/scan is 0.33 ms and the index arithmetic ~0.1 ms of device time; both scale ~linearly with matrix count, but so do local's per-matrix norm/gather/scatter chains, so on device time the ranking should hold at 40L — that's a prediction, not a measurement. Next run on our side is the same traced comparison at production depth to check it. |
|
not quite following the details here. i know that you’re trying to find a way to do global selection without sending assembling the full matrices, but not sure about the difference between the two versions of global_capped mode.
so basically this means that there is still an upper limit on how many winners can be selected from each shard? before, in the local version, the limit was just (fraction * shard_size). now you’ve added some extra slack. I think it’s clever to bundle together rows from different layers, so that if one layer needs more rows than expected it will balance another row that needs fewer than expected. but maybe this would be simpler: select This avoids the complexity of bundling across layers, uneven sizes, and variable length reshards (whatever that is), and parsing a header, and might be good enough? |
I think you're forgetting that in the global_capped mode we allgather the row norms and we know exactly how many rows have to be selected on each rank. this allgather is pretty much free in terms of performance becaue the payload is just a vector and gets done in nanoseconds. Now knowing that, I'm struggling to see the benefit in sending extra rows over the network just to be discarded after the c * frac * shard_size matrix is constructed on each rank... how is this different then not allgathering row norms and naively adding some extra "slack" to the buffer, which is something both John and I have tested already? |
…l_capped The masked posts (selection scopes "global" and "global_capped") previously had no triton_post_ortho variant: only the indexed local-scope post got the fused kernel from microsoft#81, so local enjoyed a single fused pass while the masked scopes ran the generic compiled path (separate full-matrix sweeps for weight decay, mask reduction, EF decay, NorMuon normalization, and weight update -- X crossing HBM twice, U twice, ~12 kernels per matrix). Adds row-programmed masked kernels that derive selection from the nonzero rows of U (no index map, preserving the masked transport's no-host-sync property -- hyperparameter scalars are 0-dim CPU tensors, so .item() is sync-free): - _dion2_post_ortho_masked_kernel: single pass fusing weight decay, masked update (a*x + neg_b*u, fp32, one rounding) and EF decay on M. Unselected rows skip M entirely (bitwise-identical to multiply-by-one). - _nordion2_post_ortho_masked_{stats,apply}_kernel: two phases forced by the NorMuon Frobenius rescale (matrix-level reduction). Phase A computes row sumsq, updates V, EF-decays M, and emits per-row partials; a batched deterministic torch reduction (no fp32 atomics) produces the per-matrix rescale; phase B applies the weight update. All four masked call sites (dion2/nordion2 x global/capped) dispatch on the existing triton_post_ortho flag, with fallback to the compiled masked posts for CPU/no-triton, column selection, tensor subclasses, and mixed shapes. Launch count per shape group drops from ~12*n_mat to 2*n_mat + 4, and memory traffic to one pass over each of W/M/V/U. Tests: function-level parity vs the compiled masked posts (bitwise on unselected rows, dtype-aware tolerances on selected; edge cases: none/all selected, single row, non-pow2 wide cols, 3D batch, empty shards) plus a 2-GPU NorDion2 global_capped end-to-end A/B. 28+17 relevant tests pass on 4xH100. (The 8 pre-existing test_post_ortho_triton_falls_back_for_wrapper_subclass failures also fail at the clean PR head -- inductor BackendCompilerFailed compiling the fused fallback against _MockWrapperTensor on this torch build -- unrelated.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
I see. You're right, it is just the same as naively adding slack to the buffer. If that has been tried already then disregard. But if I understand correctly, this still fixing a size for the packed matrix sent by each device. That creates the potential for overflows that have to be handled if a given shard selects more rows than we expected. Since "we know exactly how many rows have to be selected on each rank" after the first round (the row-norm selection step) we can use that to determine how big the chunks have to be to avoid overflow. Then every device can use that same maximum size (padding with zeros). Everything fits, and we don't have overflows. If we aggregate across all matrices in the megabatch, presumably this max size wouldn't be much bigger than whatever heuristic size you're using now? this is just a suggestion, not sure whether it's better or worse really, I'll need your expertise. but it's simpler and avoids overflows. handling overflows by dropping some of the selected rows again ties the mathematics of the optimizer step to the specific number of GPUs we are using (as well as to the heuristic we are using to pick the chunk size, and it links each layer to every other layer...) If we can avoid this complexity without hurting performance we should, right? |
|
A larger point: we've seen that the MSR cluster can give very different results from Ali's because the communication cost is so low. I guess this means we need to include some timing results from a different cluster in the paper, both for completeness and because it may be the only way to show the true benefits of our design |
what you're describing here would require knowing the size of the a2a comms buffer at the time of the kernel launch = cpu host sync. this is what we're trying to avoid. With the new global_capped packing behavior, we set the size of the buffers (the size of the slack) once at the contruction time which remains static throughout training so no host sync required at each step. also the size of the slack is chosen based on the (2 * std) of the binomial distribution to statistically minimize the chance of overflow (~0.02% of rows). (the choice of (2 * std) was arbitrary, could be 3.5 or 4 or whatever, but will cause a large percentage of slack size at smaller topk sizes) |
I can run multinode tests tonight when the cluster is less busy. will add them here. we should see the benefit of new global_capped more clearly in that regime due to lower comms volume. |
|
This is John's reviewbot. Reviewed The throughput premise, measured end-to-end on real architecturesThe PR's framing is "global selection at local comm cost" — i.e. a throughput win. I profiled the full training step (forward + backward +
On a real training step the selection scope barely moves throughput. The reasons:
Implication for the merge decisionThe comm saving is real but doesn't convert to end-to-end speedup on the models this targets — so
The one experiment that could still justify the scope is the Design/correctness of the mechanism itself remains solid (verified earlier: exact-count stable-sort, zero host syncs via the in-band header, break-even routing, 28/28 tests + now the triton-post tests). This is a well-built implementation of a mechanism whose end-to-end payoff, on the target models, is currently ~zero-to-negative — worth confirming with the 0.25 convergence run before taking on the maintenance surface. (Definitive pooled- |
…use_gram_newton_schulz Three changes to support benchmarking the Dion2/NorDion2 selection scopes (microsoft#99) with profile_training_step.py / benchmark_optimizer.py: - --selection_scope {local,global,global_capped}: passed through to Dion2/NorDion2 only when given, so dion builds without the parameter keep working and dion's own default applies when omitted. - qkv group now uses ortho_fraction instead of fraction=1.0 (adjust_lr stays None). Full orthogonalization exempted 24 of 56 matrices at 8L from selection, which diluted and skewed selection-scope comparisons. - Add the missing --use_gram_newton_schulz argparse flag: the nordion2 branch already referenced cli_args.use_gram_newton_schulz, so optimizer=nordion2 crashed before this. Makes the nordion2 path runnable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This is John's reviewbot. Re-ran the throughput measurement using the CUDA-event method from NoahAmsel/dion's mixformer-1b, mbs=7 (19 samples/scope)
mixformer-14b, mbs=2 (8-GPU FSDP) (48 samples/scope)
This matches the earlier full-step profiling, and agrees with a per-phase-
So on the models this targets, selection scope is a ~0.5% throughput effect. As before, that means |
|
This is John's reviewbot. fraction=0.25 convergence A/B — the quality question, settled. The remaining case for Matched at the final step (21789 ≈ 10B tokens):
Δ vs
Why the #98 gap doesn't appear here: Net for this PR. Both of |
|
Re: "unless there's a concrete target regime where the exact-global-at-local-comm tradeoff actually pays — a workload where the optimizer is genuinely comm-bound rather than NS-bound" — here's that regime, measured. Same benchmark as my earlier single-node tables, but across 2 nodes (2×4 H100, ws=8, FSDP row-sharded), where the optimizer a2a rides the inter-node link instead of NVLink. Setup: 5120-hidden/8-layer,
Three observations:
So the concrete regime: multinode training where the per-rank inter-node bandwidth is O(20 GB/s) — i.e. most IB/EFA clusters once you leave the NVLink island — at any meaningful fraction < 1. There, |
|
Node-count scaling of the 2-node result above — same benchmark (5120-hidden/8L, NorDion2,
Short version: NS scales out with ranks (opt GPU time 34→11 ms); the a2a doesn't — per-rank payload halves at each doubling but per-rank link bandwidth halves too, so @NoahAmsel , This is the data that we want for the paper. |
|
Completing the ws=16 matrix:
One methodological note: the identical capped run moved the same 205 MB at 12.7 GB/s on this morning's node set vs 25.8 GB/s on this one — inter-node fabric varies ~2× between sets, so all our pairs are same-set and the |
|
It looks like local is as good as global or global_capped empirically on the 1b/10b runs, so my inclination is to go with a local default with global as an argument. Is global_capped worth keeping as a default? |
yes I'm not seeing much of a difference in throughput between local and global_capped even in the slow-internode comms regime, which is great news. global causes massive slowdown in my setup with multiple nodes. |
|
@JohnLangford I think the numerics should be tested a bit more extensively across various hidden_dim sizes, I would suggets running a 100B test for each hdim=[2048, 4096, 6144, 8192], my inkling is that on smaller sizes, local might cause worse numerics. Also regarding the statistical significance of the results, I think following modded-nanogpt's track3 rules would be a good idea, to make sure we're dealing with real signal and not run-to-run noise |
Follow-up to #97/#98.
globalselects better but ships the full shard (no comm saving vs Muon);localgets the fraction-scaled comm but selects per-shard, which converges worse (~0.09 nat in #98's A/B at small fraction). This adds a third scope that gets (nearly) both.How it works
ceil(fraction·global)by stable argsort (deterministic tie-break), so the total arrivals per matrix are bounded by construction.per_rankmatrices (one matrix's overflow uses another's spare room), back-to-back, with the per-matrix counts bit-cast as an int32 header into the chunk's first row. The receiver parses the sender's header — no recomputed plan, no host syncs, static shapes throughout. Winners that overflow the pooled budget are deferred: they skip error-feedback decay, so their momentum accumulates and they win a slot on a later step. Non-winners are never applied and never decayed.capacity_factorcontrols the pooled budget:None(default) = auto per group,1 + 2·√((1−1/world)/(per_rank·k))(~0.02% expected deferral under a binomial winner-scatter model); a float ≥ 1.0 pins it (1.0 = pooling only, zero extra comm). Groups where packing cannot pay for itself (budget+1 reaches the full shard, header doesn't fit, col-sharded/non-2D) route to exactglobal.dion2.CAPPED_STATSexposes per-step deferred/winner row counts for measuring the real deferral rate.Results (NorDion2, 5120-hidden/8-layer, 4×H100, fraction=0.25)
Measured on the previous fixed-slot transport; comm volume is unchanged modulo the ~0.1% header row (fresh trace pending).
Tests in
tests/test_dion2_selection_scope.py(28/28 on 2×/4× H100): bit-exact pack→a2a→NS→unpack roundtrip (pooled deferral, blackout matrix, uneven counts); cross-matrix skew absorbed by pooling with scale-normalized eviction order; exact-count rank consistency; header bit-cast roundtrip; overflow deferral across steps; uneven/empty shards; fraction=1.0 == global; bf16 momentum.Draft because
localactually loses.🤖 Generated with Claude Code