DeepSpeed
f9fe4df3 - ZeRO-2 CPU offload: accumulate in the compact partition between requests (#9044)

Commit
2 days ago
ZeRO-2 CPU offload: accumulate in the compact partition between requests (#9044) ## Summary Follow-up to #9018 (merged as `a4c989b7`). With CPU offload, the compact partition-sized accumulator (used for fp32 accumulation on a 16-bit model) now publishes to the host partition gradient at each request boundary and is freed between requests when the host copy can restore it exactly. - The device partition is freed after the boundary copy only if the host gradient restores the running sum bit for bit (same dtype, or an fp32 host holding bf16/fp16 values). The next backward in the same optimizer window restores it. Otherwise (for example bf16 masters with fp32 accumulation) it stays resident until `step()`. - The gate for the compact buffer is unchanged from before this series: fp32 accumulation on a 16-bit model, in the stages and offload modes that already used it (including stage 1 with offload). Muon groups keep the host accumulator. The default bf16/fp16 accumulation keeps the per-parameter path, so no partition-sized device buffer is added for it. - Overflow is now checked on the value cast to the host gradient dtype. With fp16 master weights and fp32 accumulation, an fp32 sum above the fp16 range used to pass the check and become inf on the host; it now skips the step. - Legacy host path: gradients that a non-boundary backward left only in the host accumulator are published at the next boundary (`_publish_unpublished_offload_grads`). - `config.py` is unchanged: fp32 accumulation with fp16/bf16 master weights is not rejected, as before. - New option `zero_optimization.retain_gpu_grad_partition_until_step` (default `false`). The default frees the fp32 gradient partition from GPU memory between requests. Set it to `true` to keep the partition in GPU memory until `step()`; GPU memory use between requests then rises by one partition per rank. With `true`, memory returns to the base level and the backward returns to base timing (Qwen3-4B stage 2, 1 GPU: 455 ms vs base 474 ms; synthetic: 14.4 ms vs 14.1 ms). Step time is unchanged. Test: `test_zero_offload_accum_precision.py::TestZeroOffloadReleaseOption` checks residency per boundary and that the final weights match in both settings. **Unsupported: bf16 master weights with fp32 gradient accumulation.** The host gradient is bf16 and cannot hold the fp32 sum, so the GPU partition is not released between requests in that case. It is also rounded once to bf16 when copied to the host (see below). ## Test plan - CPU: `test_zero2_gradient_safety.py`, `test_zero_cpu_offload_grad_accum.py`, `test_zero2_offload_multi_backward.py`: 88 passed, 12 skipped. New `test_zero_offload_accum_precision.py`: 10 passed. - B200 (4 GPUs), final commit `9960ecb7`: targeted offload suites (gradient safety, grad-accum norm, multi-backward, coalesced grad reduction, config dict) gave 227 passed, 28 warnings; the autoep gradient-parity file gave 18 passed, 2 skipped. - B200 (4 GPUs), new `test_multiple_request_boundaries_host_accumulator` and `test_fp16_host_gradient_overflow_skips_step`: pass. With the pre-fix overflow check restored in a scratch copy, the fp32 case of the overflow test fails. - B200 device-memory probe (single GPU, ZeRO-2 CPU offload, synthetic 134M-parameter bf16 stack of eight 4096x4096 linears, 3 optimizer windows, 4 backwards per window each marked as a request boundary, one `step()` per window). Values are MB of `torch.cuda.memory_allocated` after each request backward, and the per-window `max_memory_allocated`: | masters / accumulation | base `a4c989b7`: between requests | this PR `9960ecb7`: between requests | window peak, base / this PR | | --- | --- | --- | --- | | fp32 / fp32 | 816 | 304 | 2090 / 2090 | | bf16 / fp32 (unsupported) | 816 | 816 | 2090 / 2090 | | fp32 / bf16 (default) | 304 | 304 | 1386 / 1386 | The 512 MB saving between requests is the fp32 partition of this model. The in-window peak is unchanged, because the partition is still built within a window. Stage 1 and other model sizes were not measured. ## Test changes - `test_zero2_gradient_safety.py`: stubs pin the legacy host path with a comment; they bypass the reduction that fills the compact buffer. - `test_zero_cpu_offload_grad_accum.py`: the norm assertion reads the device partition sum, or the host gradient once the partition has been freed, and runs with and without fp32 accumulation. It checks every norm is above zero. - `test_zero2_offload_multi_backward.py`: adds a host-accumulator request-boundary test (exact gradient checks; fails if the publish call is disabled) and `test_fp16_host_gradient_overflow_skips_step`. - `test_zero_offload_accum_precision.py` (new): window-sum precision for stage x master x accumulation using micro-step gradients that are exact in bf16, and `safe_set_full_grad` residency behavior. See below. ## Measured precision and residency (not claims about production) Window sum over 16 micro-steps, relative max error against the exact sum, read before `step()`: | stage | masters | accumulation | error | notes | | --- | --- | --- | --- | --- | | 2 | fp32 | fp32 | 0 | the supported exact case | | 2 | fp32 | bf16 | 6.2e-3 | per-step bf16 accumulation | | 2 | bf16 | fp32 | 2.4e-3 | unsupported: one rounding to the bf16 host gradient | | 2 | bf16 | bf16 | 6.2e-3 | | | 1 | fp32 | fp32 | 2.4e-3 | the window sum is copied into the reduction bucket of the communication dtype (bf16 here) and rounded once before the all-reduce; identical on base | | 1 | fp32 | bf16 | 6.2e-3 | | | 1 | bf16 | fp32 | 2.4e-3 | | | 1 | bf16 | bf16 | 6.2e-3 | | The base commit gives the same table, so this PR does not change precision for these cells. Why stage 1 fp32/fp32 is 2.4e-3 and stage 2 fp32/fp32 is 0: stage 2 reduces each micro-step gradient into the fp32 partition. These micro-step values are exact in bf16, so every reduction is exact and the fp32 sum is exact. Stage 1 keeps the window sum in fp32 (`grad_accum`), but at the boundary it copies that sum into the reduction bucket, which has the communication dtype (bf16 in this test, since the model is bf16). The sum is therefore rounded once to bf16 before the all-reduce. Stage 1 is not changed by this PR. A fp32 reduction for stage 1 would remove the rounding at twice the communication volume; it is not in this PR. `safe_set_full_grad` between backwards in one window: - A value set while the GPU partition is resident (after the first backward of a window) is overwritten at the boundary; it is not kept. - A value set after a boundary has freed the partition (fp32 masters) is kept, and the next backward accumulates onto it exactly. ## Stage-1 memory, step time, multi-rank, and Qwen3-4B Same request-boundary probe as above (every backward is a boundary, then `step()`), comparing base `a4c989b7` to this PR. Times are median after the first window. Memory is `torch.cuda.memory_allocated` after a boundary backward, on rank 0. **Synthetic 134M-parameter stack (eight 4096x4096 linears), 1 GPU.** Between-request memory for fp32 masters + fp32 accumulation drops from 816 MB to 304 MB on both stage 1 and stage 2. Window peak is unchanged (stage 1: 3492 MB; stage 2: 2090 MB). Default bf16 accumulation stays at 304 MB. Unsupported bf16 masters + fp32 accumulation stays at 816 MB. Median `step()` is 26 ms on both trees. Median backward for the released fp32-accumulation path is about 10 ms slower on this stack (15→25 ms on stage 1, 14→24 ms on stage 2), which is the host copy plus restore on the next request. On Qwen3-4B the same path is much slower per backward: 474→733 ms (stage 2, 1 GPU), 496→785 ms (stage 1, 1 GPU), 483→687 ms (stage 2, 2 GPUs). This is the cost of releasing the partition. `zero_optimization.retain_gpu_grad_partition_until_step: true` removes it, at the price of keeping one fp32 partition resident (see the Summary). **Same stack, 2 GPUs, fp32 masters + fp32 accumulation.** Each rank's partition is half the model (256 MB of fp32). Stage 2 between-request memory drops from 560 MB to 304 MB; stage 1 from 2468 MB to 2212 MB. Window peaks are unchanged. Median `step()` is 127–135 ms on both trees. **Qwen3-4B (random-init from the published config, 4.02B parameters, seq 512, 4 request-boundary backwards per window), fp32 masters.** | stage / GPUs / accumulation | base between (MB) | this PR between (MB) | window peak base / this PR (MB) | median step ms base / this PR | | --- | --- | --- | --- | --- | | 2 / 1 / bf16 (default) | 8431 | 8431 | 13251 / 13251 | 3923 / 3782 | | 2 / 1 / fp32 | 23775 | 8431 | 29187 / 29187 | 3966 / 3984 | | 1 / 1 / bf16 | 8431 | 8431 | 19142 / 19142 | 3899 / 3793 | | 1 / 1 / fp32 | 23775 | 8431 | 42182 / 42017 | 3889 / 3929 | | 2 / 2 / bf16 | 8431 | 8431 | 13251 / 13251 | 16767 / 16375 | | 2 / 2 / fp32 | 16103 | 8431 | 20923 / 20024 | 16176 / 15781 | On 1 GPU the released fp32 partition is about 15.3 GB (4.02e9 × 4 bytes). On 2 GPUs it is about 7.7 GB per rank. Default bf16 accumulation is unchanged. `step()` is within a few percent of base. This is a request-boundary microbenchmark, not a full SFT job. ## Remaining limits - No full production training job (loss curve, tokens/s over many steps) was run. - ZeRO-1 offload with bf16/fp16 masters: fp32 accumulation does accumulate in fp32 on the device, but the window sum is rounded once when copied to the bf16 host gradient (table above; identical on base). ## Known follow-ups (not in this PR) - PCIe traffic optimization (deferred deliberately). - Stage 1 rounds the window sum to 16 bits at reduction (pre-existing). --------- Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Parents
Loading