DSv3 improvement by keeping the unreduced gradient tag under ZeRO-1 + explicit sharding, including through the MoE shard_map - #5056
DSv3 improvement by keeping the unreduced gradient tag under ZeRO-1 + explicit sharding, including through the MoE shard_map#5056NuojCheng wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request optimizes ZeRO-1 + gradient accumulation by tagging the accumulator as reduced and gradients as unreduced over the data axis, allowing the cross-replica all-reduce to run once per step instead of once per microbatch. It updates various sharding helpers and layers (MoE, normalizations) to correctly handle and propagate these tagged PartitionSpecs, which reject direct indexing or iteration. The review feedback highlights two robustness improvements: catching potential AttributeErrors in data_is_only_batch_axis when accessing the mesh of a sharding that lacks it, and defensively handling None specs in get_mesh_axes_used_by_tensor_spec to prevent runtime crashes.
| try: | ||
| batch_axes = batch_mesh_axes(param_shardings[0].mesh, rules=config.logical_axis_rules) | ||
| except (KeyError, ValueError): | ||
| # No "activation_batch" rule for this mesh: leave the gradients untagged. | ||
| return False |
There was a problem hiding this comment.
In data_is_only_batch_axis, param_shardings[0].mesh is accessed. However, if params_shardings contains None as a leaf (which is common for unpartitioned/replicated parameters or placeholders), or if the sharding is a SingleDeviceSharding (which lacks a mesh attribute), this will raise an AttributeError. We should catch AttributeError in the try...except block to make the helper robust.
| try: | |
| batch_axes = batch_mesh_axes(param_shardings[0].mesh, rules=config.logical_axis_rules) | |
| except (KeyError, ValueError): | |
| # No "activation_batch" rule for this mesh: leave the gradients untagged. | |
| return False | |
| try: | |
| batch_axes = batch_mesh_axes(param_shardings[0].mesh, rules=config.logical_axis_rules) | |
| except (AttributeError, KeyError, ValueError): | |
| # No "activation_batch" rule for this mesh: leave the gradients untagged. | |
| return False |
| # Read through `.partitions`: a spec carrying reduced/unreduced axes refuses plain iteration. | ||
| if isinstance(tensor_sharding_spec, P): | ||
| tensor_sharding_spec = tensor_sharding_spec.partitions |
There was a problem hiding this comment.
In get_mesh_axes_used_by_tensor_spec, if tensor_sharding_spec is None, the function will raise a TypeError when attempting to iterate over it in the list comprehension. Adding a defensive check for None at the beginning of the function will prevent this crash and align with the docstring's mention that the spec can be None.
if tensor_sharding_spec is None:
return []
# Read through `.partitions`: a spec carrying reduced/unreduced axes refuses plain iteration.
if isinstance(tensor_sharding_spec, P):
tensor_sharding_spec = tensor_sharding_spec.partitions…harding
Under explicit sharding the gradient accumulator is supposed to carry an
`unreduced` PartitionSpec tag over the data axis, so each microbatch's gradient
is summed locally and the cross-replica all-reduce runs once, when the
accumulated gradient is resharded back after the scan. That pair was dropped
from the scan carry and replaced with a post-scan round trip that compiles to
nothing, leaving `update_sharding_for_reduced` dead and the reduction emitted
once per microbatch inside the loop.
The justification given at the time was that a reduced/unreduced spec is
rejected in a `jax.lax.scan` carry. jax 0.11.1 accepts it. What does break is
everything downstream that indexes, slices, unpacks or iterates a tagged spec:
only `.partitions` reads through one. So tag the carry again and make the
helpers that rebuild specs go through `.partitions`, carrying the tags onto the
spec they produce:
- sharding.remove_size_one_mesh_axis, adjust_pspec_for_indivisible_shapes and
get_mesh_axes_used_by_tensor_spec read `.partitions`
- sharding.truncate_out_sharding carries unreduced/reduced through both its
NamedSharding and bare-PartitionSpec branches; those name mesh axes, not
tensor dimensions, so truncation must not drop them
- normalizations._align_scale_with_normalized_axis reshards the scale onto a
spec that keeps the tags, so the scale stays in the deferred all-reduce
The tag is only valid when the unreduced axes equal the axes the gradient was
contracted over, which are the mesh axes the activation batch dimension is
sharded on. `data_is_only_batch_axis` reads those from the resolved mesh via
the new `sharding.batch_mesh_axes` rather than from ici_data_parallelism, which
can be -1 and resolve to 1. Parameters that are themselves sharded over "data"
keep the untagged spec, since a spec's partitions cannot overlap its tags.
Measured on the repro (AOT v5e-8, deepseek3-tiny, shard_optimizer_over_data,
shard_mode=explicit, gradient_accumulation_steps=4, dp=8/fsdp=1), the in-loop
cross-replica all-reduce traffic drops from 187,395 elements over 4 ops per
microbatch to 3 scalars, and the ~17.2M elements of layer gradients are reduced
once per step instead of once per microbatch.
Losing the tag is numerically silent, so the new end-to-end test asserts on
where the collective sits: it AOT-compiles the repro config and requires no
cross-replica all-reduce larger than a scalar inside the accumulation loop,
with a guard that at least one such collective exists outside it.
`sparse_matmul` reshards the expert kernels onto a pspec rebuilt from the logical axis rules, which has no `reduced` tag. `jax.shard_map` makes every mesh axis manual, so the kernel cotangent was reduced over the data axis inside the body — once per microbatch per layer under gradient accumulation — and the accumulated gradient was then reduced again on the way out of the scan. Numerics were unaffected, since JAX converts the fully-reduced value back to a partial, but the traffic was pure waste: it made ZeRO-1 + gradient accumulation under explicit sharding slower on MoE models than leaving the accumulator untagged. Carry the tag the accumulator put on the parameters onto the shard_map in_specs via a new `carry_reduced_axes` helper, so the expert weights reduce once per step like every other parameter. On a 2x2 v6e host with a ~1.3B DeepSeek MoE this moves 2809 MiB of all-reduce out of the accumulation loop and takes the step from 0.3827 s to 0.3164 s (1.21x) at unchanged peak HBM. Add `test_deepseek_zero1_ga_scale_memory_and_step_time`, which trains that config twice on real chips — once as shipped, once with the tagging suppressed — and asserts on both step time and peak HBM. The existing test only reads collective placement out of the HLO, which says nothing about what the deferred reduction is worth.
ffdc254 to
6e9bcca
Compare
Description
Running DeepSeek + ZeRO-1 + gradient accumulation under
shard_mode=explicitdrops theunreducedtag on some parameter gradients, so those gradients get all-reduced once per microbatch inside the accumulation loop instead of once per step after it.The failure is silent — gradients stay numerically identical, because JAX happily converts a fully-reduced value back to a partial one. The only symptom is wasted collective traffic.
Background: how the tag works
Under explicit sharding, gradient accumulation marks the parameters it carries through the scan as
reducedover thedataaxis. Areducedinput means "already summed over that axis", so its cotangent comes outunreduced, and the all-reduce is deferred until something reshards away fromunreduced— which happens once, after the loop. Any code path that rebuilds aPartitionSpecfrom the logical axis rules produces an untagged spec, and resharding a parameter onto it silently drops that parameter off the deferred path.Fix 1 — restore the tag under ZeRO-1 + explicit sharding
A tagged
PartitionSpecrejects indexing, slicing, unpacking and iteration; only.partitionsreads through it. Several sharding helpers were doing exactly that and so were either raising or quietly dropping the tag.src/maxtext/utils/sharding.py:remove_size_one_mesh_axisandadjust_pspec_for_indivisible_shapesnow read.partitionsinstead of indexing the spec directly.get_mesh_axes_used_by_tensor_specreads through.partitions.batch_mesh_axeshelper.truncate_out_shardingneeded the same treatment;mainhas since landed an equivalent_truncate_pspechelper independently, so this PR now takes upstream's version and only keeps the accompanying tests.src/maxtext/utils/gradient_accumulation.py:data_is_only_batch_axis(config, params_shardings), plusupdate_sharding_for_reduced/update_sharding_for_unreducedand a post-scanjax.tree.map(_maybe_shard_with_name, raw_grads, params_shardings).No changes to
train.py.Fix 2 — keep the tag through the MoE
shard_mapFix 1 alone turned out to be a wall-clock regression on MoE models — 2–4% slower. Root cause:
moe.py:sparse_matmulreshards the expert kernels onto a pspec rebuilt from the logical axis rules, which carries no tag.jax.shard_mapmakes every mesh axis manual, so the kernel cotangent was psum'd overdatainside the body — once per microbatch, per layer — and the accumulated gradient was reduced again on the way out of the scan. Double work, correct numerics.carry_reduced_axes(spec, value)insharding.py, which copies thereducedmesh axes of a value's own spec onto a rebuilt spec. It declines when the tag would overlap the partitions (whichPartitionSpecrejects) and when there is no spec to read, so it is a no-op outside explicit sharding.sparse_matmulapplies it tow0/w1/woand their biases right aftermaybe_aqt_partition.get_wi_gmm_params/get_wo_gmm_paramsread.partitions, since a tagged spec refuses indexing.Verified in isolation that
jax.shard_mapaccepts areduced-taggedin_specand then emits zero in-body all-reduce.Rebase note
Rebased onto
mainatd2d73155b(30 commits). Two conflicts, both from upstream converging on the same ideas:truncate_out_sharding—mainlanded_truncate_pspec, which carriesreduced/unreducedacross truncation exactly as this branch did. Resolved in favour of upstream's factoring; only the tests from this branch remain.sharding_nnx_test.py—mainadded equivalent coverage for tag-preserving truncation. Kept upstream's two tests, dropped the redundant one from this branch, kept the newTaggedPartitionSpecTestclass (which coversget_mesh_axes_used_by_tensor_spec,remove_size_one_mesh_axisandadjust_pspec_for_indivisible_shapes).moe.pywas heavily refactored upstream (~350 lines, including the newget_routed_moe_shardings) but merged cleanly: thecarry_reduced_axescalls still sit betweenmaybe_aqt_partitionand theshard_map, whosein_specsstill consume those pspecs. All measurements below were re-run after the rebase and are unchanged.One thing did change upstream: the
ds_dp2fsdp4_z1_ga4sweep case previously died with aShardingTypeErrordeep in sharding.mainnow rejects it up front at config validation with a clear message —`shard_optimizer_over_data` (Zero-1) cannot be combined with FSDP— so that gap is closed and is no longer an open item for this PR.Tests
New coverage
tests/integration/gradient_accumulation_test.pygains two tests.test_deepseek_zero1_reduces_gradients_once_per_step(AOT, v5e-8 topology) asserts no cross-replica all-reduce larger than 8 elements sits inside the accumulation loop — the loss and token count are legitimately reduced per microbatch and are scalars, so the bound separates those from parameter gradients. A vacuity guard checks that one does exist outside the loop. Supporting helpers live intests/utils/hlo_test_utils.py(split_by_entry_loop,cross_replica_all_reduce_sizes).test_deepseek_zero1_ga_scale_memory_and_step_timeactually trains a ~1.29B DeepSeek MoE on real chips, twice — once as shipped, once withdata_is_only_batch_axispatched toFalseto reproduce pre-fix behaviour — and asserts on both step time and peak HBM. HLO placement says nothing about what the deferred reduction is worth; this catches a change that keeps the tag but reduces gradients per microbatch somewhere downstream.Asserts speedup > 1.05x (measured 1.21x), memory ratio < 1.02 (measured 1.00005), and a >4 GiB peak floor so the config cannot be silently shrunk past the point where the thresholds mean anything. Skips below 4 devices or 24 GiB/chip.
One design note: the runs are ordered untagged first, on purpose.
peak_bytes_in_useis a high-water mark the TPU allocator never resets, so the second reading ismax(both); asserting "second <= first" is then exactly "tagging did not raise the peak", with no way for a regression to hide behind the mark. An earlier subprocess-per-variant design was abandoned because the parent pytest process initialises the TPU when it checks the device count, which then locks the children out.How to reproduce
Results
tests/integration/gradient_accumulation_test.py: 4 passed (67 s) on a 2x2 v6e host. Scale numbers reproduce identically when sharing a process with the other tests, confirming no peak-HBM carryover between runs.tests/unit/moe_test.py: 32 passed, 42 skipped, 1 failed. The failure istest_gmm_grad_equivalence_tokamax_v2_fp8_dynamic_ep4(NaN relative-norm) and is pre-existing — it fails identically withmoe.pystashed back tomain.ds_dp2fsdp4_z1_ga4is now rejected bymain's own config validation as an unsupported combination (see the rebase note above).pyink --pyink-indentation=2 --line-length=122clean on every file this PR touches exceptmoe.py, which is already not pyink-clean onmainin regions this PR does not touch; reformatting them would bury the diff.pylint --rcfile=pylintrcscores 10.00/10 on the test file, and the twoE1128inmoe.pyare pre-existing onmain.Measurements
All on a single 2x2 v6e host (4 chips, 31.24 GiB/chip),
bf16, synthetic data, median of 8 steady-state steps after 4 warmup steps.The MoE fix flips the sign of Fix 1
Collective placement at 24L/16E: tagged has 2 in-loop all-reduce ops totalling 0.0 MiB plus 2809 MiB once outside; untagged has 34 MiB in-loop (x23 layers x4 microbatches ~ 9 GiB) plus 509 MiB outside.
Explicit vs auto sharding
The tags only exist on an all-Explicit mesh, so this fix is a no-op under
shard_mode=auto. What changes is where explicit mode lands relative to auto:explicit(with fix)autoexplicit(pre-fix)Explicit went from 4.9% slower than auto to 13.3% faster. Peak HBM is flat across all three (spread 0.04%), so this is pure traffic, not a memory/compute tradeoff.
On stock
deepseek3-tiny(pdbs 1, 0.35 GiB) explicit and auto are indistinguishable — 0.5871 s vs 0.5864 s. The model is far too small for collective traffic to register against fixed per-step overhead, which is why the original bug was invisible as a perf problem on the config it was reported against.When the fix pays off
One factor at a time from the 24L/16E/pdbs2/GA4 base:
The eliminated work is exactly
(N-1)redundant all-reduces, so time isN*(c+a)before andN*c + aafter, giving a speedup of(c+a)/(c+a/N).T = o + N*c + {N*a or a}to the three points givesa ~ 22 ms,c ~ 63 ms,o ~ 41 msfixed overhead, so the ceiling is ~1.35x for this shape. GA=8 is already at 1.28.a/cbarely moves across 3x the layers. The mild 1.19 -> 1.24 drift is fixed per-step overhead being diluted, not a scaling effect.Summary: the gain is
(1 - 1/N) * a/cwherea/cis gradient-bytes-per-microbatch-FLOP. Largest with many microbatches, few tokens each, and a sparse MoE — precisely the regime GA exists to serve.Not measured: this is 4-way DP on one host. More replicas make each all-reduce costlier, which should push
a/cand therefore the win higher, but there is no data on it here. Treat multi-host gains as an expectation, not a number.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.reduced/unreducedtag semantics are subtle and fail silently, socarry_reduced_axes, every.partitionsread, and the untagged-first ordering in the scale test each carry a comment explaining why.)