perf(recipes): skip scale_grads when gradient_accumulation_steps == 1 - #2980
perf(recipes): skip scale_grads when gradient_accumulation_steps == 1#2980n-dlms wants to merge 1 commit into
Conversation
ErenAta16
left a comment
There was a problem hiding this comment.
The single-device half of this looks fine to me. world_size is 1, there is no all-reduce, and num_tokens == current_num_tokens when accumulation is 1, so the multiply-then-divide really is a round trip and skipping it is free.
I am less sure about full_finetune_distributed.py, and I would like the author to confirm the reasoning before it lands, because the two token counts there are not the same quantity.
current_num_tokens is this rank's local count. num_tokens is all-reduced immediately before the scaler runs:
if (batch_count + 1) % self._gradient_accumulation_steps == 0:
if not self._optimizer_in_bwd:
# Get total number of tokens across all ranks to normalize gradients
torch.distributed.all_reduce(num_tokens)
...
self._grad_scaler(
list(self._model.parameters()),
self.world_size / num_tokens,
...
)So with accumulation at 1 the existing path applies current_num_tokens_local on the loss and world_size / num_tokens_global on the gradients. Those cancel to 1 only when every rank contributed the same number of tokens, since that is the case where num_tokens_global == world_size * current_num_tokens_local. With ragged batches, which is the normal case for instruction fine-tuning with variable-length samples, they do not cancel: the current code normalizes by the global token count, and skipping both steps normalizes each rank by its own local count instead.
The direction of the error is that ranks with shorter batches get up-weighted relative to ranks with longer ones, which is exactly the imbalance the global normalization exists to remove. It would not crash or NaN, it would show up as a small shift in convergence that is very hard to attribute later.
Two things that would settle it:
If sequences are padded to a fixed length in every supported distributed config, then token counts per rank are equal by construction and the cancellation is exact. I could not convince myself that holds, given current_num_tokens is computed from a mask sum rather than from the padded shape, but you would know.
If it does not hold, the optimization can still be kept by scaling by world_size alone rather than skipping the call, since that is the factor that does not cancel. That keeps the saved read-write pass over num_tokens out of the equation while preserving the global normalization.
Worth noting that a single-GPU smoke test cannot distinguish these, because world_size is 1 there and every version agrees. If you have a two-rank run available, comparing gradient norms for a step with deliberately uneven sequence lengths across ranks against main would answer it directly.
The perf argument itself is sound and the saving is real, so this is about scoping rather than about whether to do it.
When gradient_accumulation_steps is 1, the loss is already
normalized per-token and the subsequent training.scale_grads call
that divides by num_tokens is a no-op. Skipping it saves a full
read+write pass over the model parameters.
For an 8B-parameter bf16 model this is roughly 32GB of memory
traffic per step; on a 2TB/s bandwidth GPU this is about 16ms of
latency per step. On a Llama-3.1 8B / H100 with batch_size=16 the
issue author measured a 5% end-to-end speedup; the relative
savings shrink with larger batch sizes but the absolute savings
are constant.
This commit updates the training loops in:
- full_finetune_single_device.py
- full_finetune_distributed.py
- lora_finetune_single_device.py
- lora_finetune_distributed.py
- qat_single_device.py
The guard is: skip_loss_scale = (not self.optimizer_in_bwd
and self._gradient_accumulation_steps == 1)
When skip_loss_scale is True:
- the loss is NOT multiplied by current_num_tokens (the loss is
already normalized, so multiplying would make the gradient
artificially large)
- training.scale_grads is NOT called (the equivalent divide would
cancel the multiplication we just skipped)
knowledge_distillation_* recipes are intentionally left unchanged
because their loss_fn outputs a per-sample composite (class + KD)
that is not multiplied by current_num_tokens; scale_grads is
required there even at grad_accum=1.
Fixes meta-pytorch#2515
ecb0ad7 to
b4576ae
Compare
|
Thanks for the careful review — you were absolutely right about the distributed case. I've addressed both points: Distributed (your concern)The distributed skip has been fully reverted. Both I verified this with focused math tests — on a simulated 2-rank setup with imbalanced batches [5, 3], skipping produces gradients that differ by 25%, and with extreme imbalance [1, 64] the difference is 242%. Single-device bugs found and fixedWhile adding the verification tests I found three regressions in the original skip logic, all now fixed:
The existing |
ErenAta16
left a comment
There was a problem hiding this comment.
Checked the revert rather than taking it on trust, and it is complete. Both distributed recipes now carry only the explanatory comment, with no code change at all:
recipes/full_finetune_distributed.py + comment only, 0 code lines
recipes/lora_finetune_distributed.py + comment only, 0 code lines
so the loss multiply and scale_grads run exactly as they did before, and skip_loss_scale appears only in the single-device recipes where world_size == 1 collapses the two token counts into the same number.
The NOTE is the right artifact to leave behind. Stating that current_num_tokens is rank-local while num_tokens is all-reduced, and that skipping would normalise each rank by its own count, is the whole reason the optimisation cannot generalise. Without it someone reads the single-device version, sees an obvious win, and copies it across.
The numbers are the part I could not produce myself, having no multi-rank hardware, and they are more convincing than the argument was. A 25% gradient difference on a mildly imbalanced [5, 3] split is well inside what real instruction data produces, and 242% on [1, 64] shows it is not a rounding-scale effect. Those two figures belong in the PR description, since they answer the obvious "does this actually matter in practice" question that a reviewer would otherwise have to take on faith.
Finding three further single-device regressions while writing the verification tests is the useful kind of side effect, and it is an argument for the tests existing independently of this optimisation.
Scoping the change to single-device is the right resolution. The saving is real there and the invariant that makes it safe is checkable in one line, world_size == 1, rather than resting on an assumption about batch composition.
Nothing further from me.
Summary
When
gradient_accumulation_steps == 1, the loss is already normalized per-token by the loss function (returning mean CE per chunk). The recipe currently multiplies it back by the token count beforebackward(), then scales all parameter gradients viascale_grads(1 / num_tokens)to undo that multiplication. When accumulation == 1 these two steps cancel — the multiply and the full-parameter gradient scaling pass are redundant, costing ~32 GB of memory traffic (~16ms on H100 at 2TB/s) per optimizer step.This PR skips both for single-device recipes only: use mean loss directly for backward, no
scale_gradspass needed.Why single-device only?
Distributed recipes cannot safely skip this pass. The
current_num_tokens(per-rank valid-token count) need not equal each other across ranks (ragged batches are normal with variable-length instruction fine-tuning). The existing distributed path normalizes the loss by the all-reduced global token count, which cancels out the per-rank multiplication only when every rank has the same number of valid tokens. Skipping this path would up-weight ranks with fewer tokens — a silent convergence shift, not a crash. See the NOTE comments infull_finetune_distributed.pyandlora_finetune_distributed.py.The 3 other distributed recipes (
knowledge_distillation_distributed,qat_distributed,qat_lora_finetune_distributed) were not in the original PR scope and remain untouched.Changed files
recipes/full_finetune_single_device.py— skip whengrad_accum == 1 and not optimizer_in_bwd;scale_gradsgated out;clip_grad_normmoved outside the scaling gate;loss_valuedenominator adjustedrecipes/lora_finetune_single_device.py— skip whengrad_accum == 1(no in-bwd path in LoRA); logging denominator adjustedrecipes/qat_single_device.py— skip applied;optimizer.step/zero_gradkept outside the skip gate;grad_norm = Noneinitialized for robustnessrecipes/full_finetune_distributed.pyandrecipes/lora_finetune_distributed.py— no logic change. Only a NOTE comment explaining why the skip is invalid in distributed mode (rank-local vs global token counts)tests/recipes/test_full_finetune_single_device.py— added parametrize case(8, 1, False)to exercise the new skip pathVerification
Math equivalence verified by an 8-test CPU suite:
loss_to_logidentical between skip and non-skip pathsclip_grad_norm_produces identical norms whether skip or non-skip (after fix)