Skip to content

ZeRO-2 CPU offload: accumulate in the compact partition between requests - #9044

Merged
sfc-gh-truwase merged 13 commits into
masterfrom
truwase/zero-offload-free-compact-partition
Nov 11, 2026
Merged

sfc-gh-truwase merged 13 commits into
masterfrom
truwase/zero-offload-free-compact-partition

Conversation

@sfc-gh-truwase

@sfc-gh-truwase sfc-gh-truwase commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

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).

With stage-2 CPU offload, the running gradient sum now lives in one
partition-sized buffer in grad_accum_dtype during each request, and is
published to the host FP32 partition at the request boundary. The GPU
partition is freed between requests when the host copy can restore the
sum exactly (same dtype, or FP32 host holding bf16/fp16 values), and is
restored by the next backward in the same optimizer window.

The compact path is limited to stage-2 CPU offload. Stage 1 (cumulative
unreduced grad_accum across reentrant phases), Muon, and ZenFlow keep
their existing gating.

Config now rejects data_types.grad_accum_dtype="fp32" combined with
fp16/bf16 master weights and grads, since the optimizer step runs in the
master dtype and would discard the extra accumulation precision.

Tests: update the stubs in the gradient-safety tests to the legacy path,
read the running partition sum for grad-accum assertions, skip the
fp32-accum overflow case under low-precision masters, and add config
tests for the new rejection rule.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T14:11:39.226018Z 00bcc41 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00bcc411a5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread deepspeed/runtime/config.py Outdated
Comment on lines +719 to +722
if (self.grad_accum_dtype is not None and DtypeEnum(self.grad_accum_dtype).value == torch.float32
and (self.float16_config.fp16_master_weights_and_grads
or self.bfloat16_config.bf16_master_weights_and_grads)):
raise ValueError("data_types.grad_accum_dtype='fp32' is incompatible with "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep FP32 accumulation available with low-precision masters

This unconditional configuration check rejects every ZeRO stage using grad_accum_dtype="fp32" with FP16/BF16 master weights, including Stage 1, Stage 3, and non-offload paths unrelated to the new compact Stage-2 buffer. The extra precision is not simply discarded: summing microbatch gradients in FP32 before one final cast can avoid overflow and rounding loss that occurs when each addition is performed in the master dtype. Previously valid production configurations now fail during initialization, and the changed overflow test merely skips this combination; scope the rejection to a genuinely unsupported implementation path or preserve the conversion at publication.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that the check was too broad, and it is already gone on this branch (be4ef875).

The compact Stage-2 path does not need the rejection: when the host copy cannot restore the running sum exactly (bf16/fp16 masters with fp32 accumulation), the GPU partition stays resident until step(). The original check ran at config time for every ZeRO stage and offload mode, including paths that never use that buffer.

One correction to the production-config claim: Stage 1 / Stage 3 without CPU-Adam offload already fail on master (stage_1_and_2.py and stage3.py require offload + DeepSpeedCPUAdam for master-weight features). The check mainly blocked Stage 2 without offload, plus CPU-offload configs that still train.

On 4× B200 with the rejection removed: test_finite_step_after_overflow passed 8/8, including the fp32-accumulation cases with low-precision masters. A scratch smoke test also trained with finite losses for Stage 2 with and without CPU offload, and for Stage 1 with CPU offload. Stage 1 without offload still hits the existing master-weight assertion.

Not claimed: that fp32 accumulation is more accurate than bf16 accumulation on this path. That comparison is not in the PR.

sfc-gh-truwase and others added 5 commits November 9, 2026 14:13
Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Remove the config-time rejection of data_types.grad_accum_dtype='fp32'
combined with fp16/bf16 master weights and grads. The rejection applied
to every ZeRO stage and offload mode, although the compact-buffer path
it was meant to protect already keeps the device sum resident when the
bf16/fp16 host copy cannot restore it exactly.

Validated on 4x B200: the overflow-recovery tests pass for all
fp32-accumulation cases with low-precision masters, and a bf16-master
fp32-accumulation smoke test trains with finite losses for stage 2 with
and without CPU offload and for stages 1/2 with CPU offload. Stage 1
without offload is still rejected by the existing master-weight
assertion in stage_1_and_2.py.

Tests: drop the rejection test and the overflow-test skip that covered
the removed rule.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…s norm test

Check the compact partition's overflow after the cast to the host gradient dtype, so an fp32
sum that exceeds the fp16 range is caught when fp16 master weights are used.
Cover the host-accumulator path across request boundaries, the fp16 host overflow, and the
freed-buffer state in the accumulation norm test. Drop a config test that never exercised
a rejection.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
…lation on 16-bit models

Restore the gate that applied before this series. Default bf16/fp16 accumulation keeps the
per-parameter path, so it does not hold a partition-sized buffer on the device.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Characterize the window sum for stage x master x accumulation with bf16-exact micro-step
gradients: stage 2 with fp32 masters and fp32 accumulation is exact; other cells round once
(bf16 accumulation, or the host copy for bf16 masters). Also pin safe_set_full_grad: a value
set after a freed boundary is kept and accumulated; one set while the partition is resident is
overwritten at the boundary.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@sfc-gh-truwase

sfc-gh-truwase commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Raw verification data for the numbers in the PR description, with base and PR side by side. Source: B200 job logs job-20261109T232543Z and job-20261109T235123Z. 'base' is a4c989b and 'PR' is ebf0112. Between-request memory is torch.cuda.memory_allocated after one boundary backward, on rank 0; window peak is the last window's max_memory_allocated. Backward and step times are medians.

Synthetic probe (eight 4096x4096 linears, bf16), base / PR side by side, last window:

stage GPUs masters accum between-request MB (base / PR) window peak MB (base / PR) median backward ms (base / PR) median step ms (base / PR)
1 1 bf16 fp32 816.3 / 816.3 3492.3 / 3492.3 10.4 / 10.4 17.0 / 18.6
1 1 fp32 bf16 304.3 / 304.3 1514.4 / 1514.4 23.5 / 23.6 26.0 / 25.8
1 1 fp32 fp32 816.3 / 304.3 3492.3 / 3492.3 15.0 / 24.7 26.1 / 26.0
2 1 bf16 fp32 816.3 / 816.3 2090.3 / 2090.3 9.8 / 9.5 19.4 / 17.4
2 1 fp32 bf16 304.3 / 304.3 1386.3 / 1386.3 23.7 / 24.0 26.9 / 29.1
2 1 fp32 fp32 816.3 / 304.3 2090.3 / 2090.3 14.1 / 23.7 26.1 / 26.0
1 2 fp32 fp32 2468.3 / 2212.3 3236.3 / 3236.3 14.2 / 23.7 126.9 / 128.4
2 2 fp32 fp32 560.3 / 304.3 1834.3 / 1834.3 13.6 / 23.1 134.5 / 129.9

Qwen3-4B (random-init, seq 512, 4 boundary backwards/window), base / PR side by side, last window:

stage GPUs masters accum between-request MB (base / PR) window peak MB (base / PR) median backward ms (base / PR) median step ms (base / PR)
1 1 fp32 bf16 8430.7 / 8430.7 19142.3 / 19142.3 753.3 / 752.8 3898.8 / 3793.2
1 1 fp32 fp32 23775.2 / 8430.7 42181.8 / 42017.4 496.2 / 785.3 3889.2 / 3929.1
2 1 fp32 bf16 8430.7 / 8430.7 13251.1 / 13251.1 763.0 / 763.2 3923.3 / 3781.8
2 1 fp32 fp32 23775.2 / 8430.7 29187.4 / 29187.4 473.9 / 733.5 3965.9 / 3984.1
2 2 fp32 bf16 8430.6 / 8430.6 13251.0 / 13251.0 785.1 / 776.7 16766.6 / 16374.6
2 2 fp32 fp32 16102.8 / 8430.6 20923.3 / 20024.0 483.2 / 686.6 16176.4 / 15781.3

The released fp32 path is faster to hold memory but slower per backward on Qwen3-4B (for example 474→733 ms, stage 2, 1 GPU). See the PR description.

With ZeRO-2 CPU offload and fp32 accumulation on a 16-bit model, the compact GPU partition is freed
after each request boundary by default. Setting release_compact_grad_partition=false keeps it
resident until step(), trading one fp32 partition of device memory for no per-request host copy
and restore. Test: residency per boundary and final weights match in both settings.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@delock
delock self-requested a review November 10, 2026 09:04
sfc-gh-truwase and others added 3 commits November 10, 2026 11:31
…een_requests

The new name states what the option controls: whether the fp32 gradient partition is freed from
GPU HBM between requests. No behavior change.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ition_cache

No behavior change.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Invert the flag: retain_hbm_grad_partition=true keeps the fp32 gradient partition in HBM until
step(); the default false frees it between requests, as before. Behavior at the default is
unchanged.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@stas00

stas00 commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

why is there an 2.4e-3 error in row 5 of the pr description ( 1 | fp32 | fp32) and not 0, like z2?

Comment thread deepspeed/runtime/zero/config.py Resolved
Per review: name the memory as GPU rather than HBM (LPDDR-attached GPUs exist) and state
the span: the partition is kept until step(), not for each step. Behavior is unchanged.

Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@sfc-gh-truwase

Copy link
Copy Markdown
Collaborator Author

why is there an 2.4e-3 error in row 5 of the pr description ( 1 | fp32 | fp32) and not 0, like z2?

This is a pre-existing divergence of z1 and z2 gas implementations

z1: a single bf16 reduction of fp32 accumulated grads
z2: repeated fp32 acummulations of bf16 reduced grads

@stas00

stas00 commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

z1: a single bf16 reduction of fp32 accumulated grads

so should this be fixed? I suppose not many use z1, so it was never a priority?

@sfc-gh-truwase

Copy link
Copy Markdown
Collaborator Author

so should this be fixed? I suppose not many use z1, so it was never a priority?

Yes, low priority for that reason. Also, selecting fp32 reduction type avoids the issue.

@stas00

stas00 commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

Yes, low priority for that reason. Also, selecting fp32 reduction type avoids the issue.

The user is unlikely to know that though. I'd say a short term solution could be having fp32 reduction as a default for Z1 (albeit not sure about BC).

@sfc-gh-truwase

Copy link
Copy Markdown
Collaborator Author

The user is unlikely to know that though. I'd say a short term solution could be having fp32 reduction as a default for Z1 (albeit not sure about BC).

Since this requires a bit of coding, so I will follow up with a different PR for the issue.

@tohtana tohtana added the needs-gpu-test GPU testing is recommended to validate the changes in this PR. label Nov 10, 2026
@sfc-gh-truwase
sfc-gh-truwase added this pull request to the merge queue Nov 11, 2026
Merged via the queue into master with commit f9fe4df Nov 11, 2026
18 checks passed
@sfc-gh-truwase
sfc-gh-truwase deleted the truwase/zero-offload-free-compact-partition branch November 11, 2026 01:25
@tohtana tohtana removed the needs-gpu-test GPU testing is recommended to validate the changes in this PR. label Nov 14, 2026
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.

3 participants