Allocate the gradient accumulation carry in config.grad_dtype - #5039
Allocate the gradient accumulation carry in config.grad_dtype#5039NuojCheng wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request optimizes gradient accumulation memory usage by storing the accumulator in config.grad_dtype instead of always using float32. It introduces a downcasting helper, initializes the accumulator with this dtype, and ensures gradients are cast back to the accumulator's dtype after division. Feedback suggests performing the accumulation addition in float32 before casting to the accumulator's dtype, rather than casting the microbatch gradient beforehand, to prevent numerical precision loss and underflow during accumulation.
| acc_grad_and_loss["grad"] = jax.tree_util.tree_map( | ||
| lambda x, y: y + x.astype(y.dtype), cur_batch_gradient, acc_grad_and_loss["grad"] | ||
| ) |
There was a problem hiding this comment.
Performing the addition in float32 and then casting the result back to y.dtype is numerically more precise than casting x to y.dtype before the addition.
When y is bfloat16 and x is float32, doing y + x.astype(y.dtype) performs the addition in bfloat16 (7-bit mantissa), which can lead to significant rounding errors or underflow (swamping) over multiple accumulation steps. By doing (y + x).astype(y.dtype), the addition is performed in float32 precision (23-bit mantissa) and only the final sum is rounded back to bfloat16. This preserves the carry's dtype for jax.lax.scan while maintaining much higher numerical precision.
| acc_grad_and_loss["grad"] = jax.tree_util.tree_map( | |
| lambda x, y: y + x.astype(y.dtype), cur_batch_gradient, acc_grad_and_loss["grad"] | |
| ) | |
| acc_grad_and_loss["grad"] = jax.tree_util.tree_map( | |
| lambda x, y: (y + x).astype(y.dtype), cur_batch_gradient, acc_grad_and_loss["grad"] | |
| ) |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
35efd0e to
bc7c6cf
Compare
The gradient accumulation scan carries a full-size copy of the gradient tree that stays live for the entire loop. It was always allocated as float32 (`jnp.zeros_like(ga_params)`), while `config.grad_dtype` was only applied to the final gradients after the loop, in the trainer. Without gradient accumulation the same cast happens straight out of the backward pass, so a `grad_dtype=bfloat16` recipe paid twice as much gradient memory with accumulation enabled as without it. Allocate the accumulator in `config.grad_dtype`, accumulate into the carry's dtype, and cast back after the post-loop division (which would otherwise promote every leaf back to float32 via the float32 divisor). `grad_dtype` defaults to float32, so the default path is unchanged: AOT compilation of a 4-layer model on v5e-8 produces byte-identical memory statistics before and after for every `grad_dtype=float32` config tested. With `grad_dtype=bfloat16` on that model, gradient_accumulation_steps=2: temp_size_in_bytes 135,877,632 -> 99,719,168 (-26.6%) peak HBM 165.88MiB -> 148.63MiB (-10.4%) The scan carry in the optimized HLO loses its f32[32000,64], f32[64,32000], f32[64,4,2048], f32[2048,4,64], f32[64,4,8,64] and f32[8,4,64,64] entries, which become bf16.
bc7c6cf to
7a4e898
Compare
Description
Allocate the gradient accumulation scan carry in
config.grad_dtypeinstead of always float32.FIXES: b/505094303
Why does turning on gradient accumulation increase HBM?
Short answer: at a matched global batch size it does not — GA is a large win. But enabling GA does add a fixed cost that is independent of the number of microbatches, and today half of that fixed cost is avoidable.
All numbers below are from AOT compilation (
maxtext.trainers.pre_train.train_compile,compile_topology=v5e-8) of a 4-layer,base_emb_dim=512,vocab_size=32000,max_target_length=512model, with--xla_dump_toHLO dumps.1. At matched global batch, GA saves memory (expected, working as intended)
gradient_accumulation_steps=1, per_device_batch_size=4gradient_accumulation_steps=4, per_device_batch_size=1Activations shrink by
1/K, which is the whole point.2. But GA adds a constant overhead, independent of K
ga=1, pdbs=1ga=2, pdbs=1ga=4, pdbs=1ga=2andga=4cost the same, so this is a one-time cost of enabling GA, not a per-microbatch cost.The cause is visible in the optimized HLO. Without GA, XLA interleaves the backward pass with the optimizer update, so only one tensor's gradient is live at a time and no full-size gradient tree is ever materialized. With GA,
jax.lax.scanmust keep the entire accumulated gradient tree live for the duration of the loop. That is inherent to accumulation.3. Half of that overhead was avoidable
The accumulator was created with
jnp.zeros_like(ga_params), i.e. always float32, regardless ofconfig.grad_dtype.grad_dtypewas applied only after the loop, by the trainer:On the non-GA path that cast happens straight out of the backward pass, so a
grad_dtype=bfloat16recipe never holds a float32 gradient tree. On the GA path the float32 tree is held for the whole scan and only narrowed at the very end — so GA used 2x the gradient memory of the equivalent non-GA step for the same recipe.The
whilecarry of the GA scan in the pre-fix HLO shows it directly — a full float32 gradient tree alongside the hoisted bf16 parameter copies:The change
init_gradinconfig.grad_dtyperather than always float32.y + x.astype(y.dtype)) — required anyway, since adding a float32 microbatch gradient to a narrower accumulator would promote the result and change the carry type, whichscanrejects.divisoris a float32 scalar, soarr / divisorpromotes every leaf back to float32 and would materialize a full float32 gradient tree immediately after the loop, undoing the saving.This aligns the GA path with the non-GA path, and with the existing
shard_optimizer_over_data(ZeRO-1) path, which already casts params to bf16 before the loop and therefore already accumulated in bf16.Results
grad_dtype=bfloat16, same model as above:temp_size_in_bytes(ga=2)The saving is
2 bytes x (parameters per device shard), so it scales with model size.After the fix the scan carry is bf16 throughout —
f32[32000,64],f32[64,32000],f32[64,4,2048],f32[2048,4,64],f32[64,4,8,64]andf32[8,4,64,64]are all gone.No change at the default
grad_dtypedefaults tofloat32, so the default path is untouched. AOT memory statistics are byte-identical before and after for everygrad_dtype=float32config tested:ga=1, grad_dtype=float32ga=2, grad_dtype=float32ga=1, grad_dtype=bfloat16What this does not fix
The remaining GA overhead (~36 MiB on the test model) is:
weight_dtype=float32->dtype=bfloat16cast, which without GA is recomputed per step and never held). Eliminating that would mean castingga_paramsbefore the loop the wayshard_optimizer_over_datadoes, which changes gradient numerics, so it is left out of this PR.Also checked and ruled out:
ga_paramsin the scan carry. Hoisting it into a closure produces byte-identical output — XLA already performs that motion.Tests
1. Existing GA integration tests (v5e-4, 2 passed):
These assert that GA and non-GA produce the same loss.
2. Numerical parity between
grad_dtype=float32andgrad_dtype=bfloat16under GA. 8-step training run on TPU, identical loss to 3 decimal places at every step (10.865 / 10.851 / 10.839 / 10.828 / 10.819 / 10.813 / 10.810 / 10.808):3. AOT memory comparison (the tables above). Reproduce with:
Peak HBM is the
Total bytesline of/tmp/hlo/module_*jit_train_step*-memory-usage-report.txt.4.
tests/unit/gradient_accumulation_nnx_test.py— 6 passed (JAX_PLATFORMS=cpu). The stub_Cfgin that test enumerates the config fields the function reads, so it gainsgrad_dtype: jnp.dtype = jnp.float32.5. Lint:
pyinkandpylintclean. The one remaining pylint warning,too-many-positional-arguments, is pre-existing and the function signature is unchanged.No large-scale workload link: the change is dtype-only and gated on a config default that is unchanged, and the effect is fully characterized by the AOT memory statistics above.
Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.