Skip to content

SuperOffload - PCIe Optimization - #9021

Draft
xylian86 wants to merge 13 commits into
deepspeedai:masterfrom
xylian86:xinyu/superoffload-pcie
Draft

xylian86 wants to merge 13 commits into
deepspeedai:masterfrom
xylian86:xinyu/superoffload-pcie

Conversation

@xylian86

@xylian86 xylian86 commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

No description provided.

…llbacks, speculate with clipping

- Save the worker process's Adam state in checkpoints, in the ZeRO-3 CPU offload layout, and give a loaded state to
  the worker.
- Step a sub-group that has a param without a gradient (in step()), give such a param a zero gradient, keep the
  gradients of earlier micro-steps, and add up a second gradient of a param in the same step.
- Do not step during backward on inf/nan gradients; clamp exp_avg_sq at 0 when a step is rolled back.
- Pass adam_w_mode, bias_correction and fp32_optimizer_states to the worker, allocate its gradient buffer in the
  master weights dtype, and step during backward only in the settings adam_rollback supports.
- Step during backward also with gradient clipping, and roll back and redo the steps when clipping triggers.
- Unscale fp16 gradients in the worker for steps taken during backward.
- Partial offload (ZeRO-3 and SuperOffload): the backup optimizer follows the LR schedule, and its state is
  checkpointed.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
… commit decoupled saves

- Send lr, betas, eps and weight_decay with every CPU step, so that momentum and weight decay schedules and the
  values of a resumed checkpoint reach the worker; a rollback uses those of the step it undoes.
- Undo and redo the steps taken during backward when dynamic loss scaling raises the scale in step().
- With the decoupled checkpoint engine, commit a SuperOffload save before save_checkpoint returns: the next backward
  updates the fp32 weights and Adam state that the writer reads from shared memory.
- Warn at initialization when /dev/shm is too small for the tensors shared with the worker.
- Keep a nan exp_avg_sq nan in the rollback clamp.
- Move the partial offload backup optimizer changes to their own PR.
- Tests: two param groups, bf16_optimizer_states, hyperparameter changes, fp16 loss scale increase, CPU steps
  submitted during backward; checkpoint tests with one and two param groups.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
… sub-groups and checkpoint their Adam state

With offload_optimizer.ratio < 1, the sub-groups kept on the device are stepped by a separate torch optimizer.

- Give each of its param groups its own dict and copy lr, betas, eps and weight_decay from self.optimizer's group
  before each device step (ZeRO-3 and SuperOffload). LR schedules ignored the first group, and the shared dicts of the
  other groups kept every CPU partition after a checkpoint load, so device steps also updated CPU sub-groups.
- Save the device sub-groups' Adam state with the other sub-groups' in optimizer_state_dict['state'], and move it to
  the backup optimizer on load and on universal load, so that checkpoints do not depend on the ratio and
  ds_to_universal finds every sub-group.
- Read the state of a device sub-group from the backup optimizer in safe_get/set_full_optimizer_state.
- Use torch Adam (L2) when adam_w_mode is off, and reject bias_correction=False.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
…ram dtype

torch Adam keeps the moments of the device sub-groups in the param dtype and fails on fp32 moments for bf16 params
("expected dtype float for `end`"). DeepSpeedCPUAdam keeps fp32 moments for bf16 params (fp32_optimizer_states), so a
load that keeps them in fp32 would pass fp32 moments to the backup optimizer. Cast them to the param dtype when they
move there. Test: resume with bf16 master weights at ratio 0.5.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
The rollback recovers the old moments by dividing by beta1 and beta2, so with
a beta of 0 (betas=(0, 0.99), for example) undoing a CPU step after clipping,
an overflow or a loss scale change wrote nan into the moments and then into
the weights. A sub-group now steps during backward only when both betas of its
param group are nonzero at that step; otherwise step() runs it after the
checks, as for bf16 master weights.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
…ients

adam_update and adam_rollback held the GIL for the whole step, so a step
in one thread of a process stopped its other Python threads. They now
release it, as the ZenFlow entry points already do.

adam_update read the gradient in the dtype of the params: it chose the
kernel by the param and state dtypes and cast the gradient pointer to the
param type, so a bf16 gradient of fp32 params was read as fp32 bytes. The
kernels now take the gradient type as a template parameter and are chosen
by all three dtypes, which registers fp32 params and states on bf16
gradients next to the existing combinations, whose gradients keep the
param dtype.
- AVX-512 and NEON load the bf16 gradient as they load bf16 params and
  promote it in registers; AVX2, SVE and RVV builds step it in the scalar
  loop.
- A combination without a kernel raises instead of being misread.
- ZenFlow looks its kernel up by the gradient dtype too, and offsets the
  gradient by its own element size.

Tests: another Python thread keeps waking up while a step runs, which it
cannot while the step holds the GIL; fp32 params on bf16 gradients step
bit for bit as on the same gradients in fp32 over 3 steps, with a scalar
tail; fp16 gradients of fp32 params are rejected.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
…gradients to the CPU once

SuperOffload stepped the CPU sub-groups in a worker process per rank. The
process needed the fp32 weights and gradients of those sub-groups in
/dev/shm (223 GiB for OPT-30B on 4 ranks), a message protocol for steps,
results and checkpoints, and polling with timeouts. The gradients went
into the reduce buffer, back to the GPU for the norm and out again, on
the compute stream.

CPU Adam:
- SuperOffloadCPUAdam runs DeepSpeed's CPU Adam kernels in one thread
  bound to the CPU Adam cores. They release the GIL (previous commit), so
  the training thread keeps queueing backward kernels while a sub-group
  steps. The worker process, its protocol and /dev/shm go.
- The Adam state stays where ZeRO-3 CPU offload keeps it, in the optimizer
  state of each sub-group's flat fp32 weights, so checkpoints save and
  load it without an override.
- A step submitted during backward is a future. The CPU Adam thread waits
  for the sub-group's gradient copies itself, and the training thread
  collects the steps in the order the thread runs them; an exception in a
  step reaches the training thread through its future.
- AMSGrad is rejected when SuperOffload starts, instead of failing in the
  worker at the first step.

Gradients and weights:
- The gradient copies to the CPU, and the finiteness flags behind them,
  run on a dedicated stream instead of the compute stream.
- With one micro-step per step and a non-fp16 accumulation dtype, a CPU
  sub-group's gradient goes to the CPU once: in the master dtype, or in
  bf16 for bf16 gradients of fp32 master weights, which CPU Adam promotes
  in its kernel (previous commit). A param's second gradient in a step
  adds to the first in the accumulation dtype, as the reduce buffer adds
  it. fp16 and gradient accumulation keep the reduce buffer.
- The CPU sub-groups' fp32 weights and gradients and the bf16 gradients
  are page-locked, registered in place where ZeRO-3 did not pin them, so
  the copies are asynchronous.
- The weights go to the GPU in fp32 and are converted there. Those of
  sub-groups stepped during backward wait in a GPU staging buffer per
  sub-group, which step() copies into the params after the overflow and
  clipping checks, without unscaling and clipping those sub-groups again.
- CPU Adam's scratch gradient, for fp16 loss scaling and for the rollback
  of a step on bf16 gradients (adam_rollback reads fp32 only), is
  allocated when a step needs it and is not pinned; it was 1.6 GB of
  pinned memory per rank at sub_group_size 4e8.

Tests: the CPU buffers are page-locked; a step on a bf16 gradient matches
DeepSpeedCPUAdam on the same gradient in fp32, and its rollback undoes
it; a param whose gradient hook fires twice in a step, through a
reentrant checkpoint, matches autograd in bf16 and fp32; the comparisons
against ZeRO-3 run the bf16 path.

OPT-30B, 4xB200, sub_group_size 4e8, 10 CPU Adam cores per rank, against
the SuperOffload of deepspeedai#8972, both measured on master 8f9db2c: 13.82 -> 5.87
s per step; /dev/shm 223 GiB -> 0; peak host memory of the socket,
/dev/shm included, 804 -> 740 GiB; GPU memory 58.6 -> 67.3 GiB per GPU,
for the conversion and the staging buffers. On b62afa8 the step takes
5.86 s, and the socket's host memory peaks at 775 GiB: deepspeedai#8840 pins the
master weights there in power-of-two blocks.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
The copy engine keeps serving a stream while it has a copy ready, so a
copy to the CPU on another stream waited behind all the gradient copies
queued on the gradient copy stream before it. Host syncs in backward
make such copies: transformers' flash-attention unpadding runs nonzero()
and .item() on the attention mask of a padded batch in every layer, and
again in each layer recomputed in backward. With OPT-30B at batch 88 on
4xB200 (PCIe Gen5), with gradient partitions of up to 103 MB, 23 of the
96 syncs of a backward waited behind gradient copies: 463 ms per step,
up to 28 ms each.

A gradient larger than 16 MiB now goes in 16 MiB pieces that take turns
on the copy stream and a second stream, each piece waiting for the one
before. While a piece runs, neither stream has a copy ready, so the
copies of other streams go in between. The copy stream ends waiting for
the last piece, so the CPU steps and step(), which wait for the copy
stream, still wait for the whole gradient.

One B200 alone, 4 x 103 MB of bf16 gradients queued, then an .item():
- whole copies: the .item() waits 5.1 ms; the copies run at 56.3 GB/s.
- 16 MiB pieces: 0.21 ms; 55.1 GB/s. 8 MiB pieces copy at 53.9 GB/s,
  4 MiB at 51.7, 1 MiB at 41.8.
16 MiB pieces all on one stream, the .item() on a high-priority stream,
and CUDA_DEVICE_MAX_CONNECTIONS=32 left the wait as it was.

Tests: a 4090 x 4090 weight, whose bf16 gradient goes to the CPU in two
pieces, matches ZeRO-3 CPU offload exactly with pinned memory. The test
fails when the last piece is dropped, and when the copy stream does not
wait for it.

OPT-30B, 4xB200, median s per step over steps 3-5 of one run (batch 88:
steps 3-4 of two runs, profiled with nsys), overlap_comm and
sub_group_size 1e8 unless noted:
- batch 88: 8.62 -> 8.34; one run of the code as committed: 8.34
- batch 88, unpadding computed once per batch (a benchmark patch): 8.22
  -> 8.21
- batch 64, unpadding computed once per batch: 6.66 -> 6.63
- batch 32, overlap_comm off, sub_group_size 4e8, as in the previous
  commit: 5.83 -> 5.83
Peak GPU memory is unchanged: 90.4 GiB allocated at batch 88.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
@xylian86 xylian86 changed the title SuperOffload: run CPU Adam in a thread of the training process, copy gradients to the CPU once SuperOffload - PCIe Optimization Nov 8, 2026
…other gradient scale in one pass

adam_update read the gradient as the caller left it, so a caller that
unscales and clips had to write a scaled copy first: for a bf16 gradient
of fp32 params, an fp32 copy and a multiply over it, a pass over the
gradient before the step's own. adam_update now takes grad_scale
(default 1) and multiplies each gradient element by it as it loads it,
after the bf16 promotion; fp32 times fp32 rounds as the separate
multiply did. AVX-512, AVX2 and NEON scale in registers; SVE and RVV
builds leave scaled steps to the scalar loop.

adam_rescale_step(..., old_scale, new_scale) is new. Given the weights
and moments after a step on grad * old_scale, it writes those of the
step on grad * new_scale from the same starting point, in one pass, with
the same step count and betas:
- m += (1 - beta1) * (new_scale - old_scale) * grad
- v = max(0, v + (1 - beta2) * (new_scale^2 - old_scale^2) * grad^2)
- p += step_size * (update(m, v) - update(m_old, v_old)); AdamW's decay
  of p does not depend on the gradient, so it cancels.
It supports AdamW, and Adam without weight decay; L2 weight decay adds
the old weights to the gradient, so it raises. Undoing a step with
adam_rollback and stepping again takes two passes, and adam_rollback is
a scalar loop bound by compute. 4 ranks on one socket, 10 cores each,
1e8 fp32 params with bf16 gradients: adam_rescale_step 0.051 s, as a
step (0.051 s); adam_rollback 0.070 s, plus 0.040 s for its fp32 copy of
the gradient. simd.h gains SIMD_MAX; max(0, v) keeps a NaN in v on every
ISA.

The rescaled state is the directly stepped one up to rounding: with warm
moments the weights differ by 7.5e-6 of the update and the moments by
about 1e-7 of their largest value. On the first step, where v holds only
the step's own grad^2, the rounding of v grows like 2^-24 / c^2 for c =
new_scale / old_scale (4.4e-4 of v at c = 0.01), as it does when the
step is undone and taken again.

Tests: grad_scale steps bf16 and fp32 gradients bit for bit as on the
gradient scaled before, over 3 steps with a scalar tail; a rescale
matches the direct step on the new scale, on the first and on a later
step, for bf16 and fp32 gradients, and a rescale to the same scale keeps
the moments bit for bit; L2 weight decay is rejected.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
…ead of undoing and redoing them

SuperOffload steps the CPU sub-groups during backward, on gradients
without clipping and with the loss scale of the backward. When step()
then clipped, or the loss scale had changed, it undid every one of those
steps with adam_rollback and stepped them again on a scaled fp32 copy of
the gradient, which the training thread made on its 2 cores. Fine-tuning
clips nearly every step: OPT-30B on Alpaca at batch 32 has grad norms of
3 to 15, so with gradient_clipping 1.0 every step clipped and took 20.73
s, against 5.86 s without clipping and 11.36 s for ZeRO-3 offload with
the same clipping.

step() now computes the factor that unscale_and_clip_grads multiplies
the gradients by, with _unscale_and_clip_factor, which ZeRO-3's
unscale_and_clip_grads now also uses (the same expression), and:
- changes each CPU step taken during backward to that factor with one
  adam_rescale_step (previous commit), or leaves it when the step used
  that factor already;
- steps the other CPU sub-groups on the gradient the worker reads, bf16
  or fp32, with the factor applied as the kernel reads it, so the
  training thread no longer copies and scales gradients.
adam_rollback stays for a skipped step (overflow or an invalid norm),
for a param's second gradient in backward after its sub-group stepped,
and for hyperparameters changed between backward and step(), where the
step is undone and taken again. check_clip_grads, _loss_scale_changed
and _cpu_step_grad go.

Tests: a clipped step makes one rescale per CPU sub-group and no
rollback; clipping with bf16 gradients matches ZeRO-3 CPU offload within
1e-5 over 2 steps (over 8 steps, bf16 weights rounding the other way
grow the difference to 6e-5, and to 3e-5 when the steps are undone and
redone); clipping in fp32 stays within 1e-5 of ZeRO-3 over 8 steps.

OPT-30B, 4xB200, gradient_clipping 1.0 (every step clips), median s per
step over steps 3-5:
- batch 32, sub_group_size 4e8: 20.73 -> 10.34. step() took 16.8 s
  (waiting for the CPU steps of backward 1.7, rollbacks 7.9, gradient
  copies 2.7, steps 4.2) and takes 6.4 s (waiting 1.7, rescales 4.45).
- batch 64 x seq 4096, sub_group_size 1e8, overlap_comm: 26.73 -> 16.05
  (ZeRO-3 offload with the same clipping: 17.51).
Without clipping nothing changes: OPT-350M, 50 steps with deterministic
kernels, bit for bit the same losses; OPT-30B 5.86 and 11.55 s. ZeRO-3
offload with clipping keeps bit for bit the same losses too. With
clipping, OPT-350M's 50-step losses differ from ZeRO-3 offload's by
3.7e-3 to 4.6e-3 per step on average, over two runs at each of two clips
(undoing and redoing: 3.2e-3 to 3.4e-3; ZeRO-3 against itself with the
clip changed by 1e-7: 2.7e-3 to 3.6e-3). Qwen2.5-7B, SFT on MetaMathQA
(395K rows, one epoch, 68 of 3085 steps clip): the loss is within 4e-4
of ZeRO-Infinity's per step on average, GSM8K 82.6 vs 82.1.

Signed-off-by: Xinyu Lian <lian7@illinois.edu>
@tohtana tohtana added needs-gpu-test GPU testing is recommended to validate the changes in this PR. and removed needs-gpu-test GPU testing is recommended to validate the changes in this PR. labels Nov 10, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants