Repository navigation
ZeRO-3: copy offloaded master weights back to the device through pinned buffer - #8840
pengdurice wants to merge 3 commits into
Conversation
…ed buffers Signed-off-by: pengdurice <pengduhit@gmail.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
| model, _, _, _ = deepspeed.initialize(model=model, optimizer=optimizer, config=config_dict) | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not get_accelerator().is_bf16_supported(), reason="bf16 is not supported on this accelerator") |
There was a problem hiding this comment.
suppose we run v1 tests on CPU, then these tests should be skipped on CPU because offload is not a CPU scenario.
There was a problem hiding this comment.
great catch, added a line to skip cpu below (similar to what TestZero3DictFwd does it);-)
|
Hi @pengdurice , thanks for your PR. I have left my comments. |
Signed-off-by: pengdurice <pengduhit@gmail.com>
| # fp32_partitioned_groups are aliases. Calling tensor.data.copy_ will not trigger any copy in that case. | ||
| self.fp16_partitioned_groups_flat[sub_group_id].data.copy_( | ||
| self.fp32_partitioned_groups_flat[sub_group_id].data) | ||
| dst = self.fp16_partitioned_groups_flat[sub_group_id].data |
There was a problem hiding this comment.
@pengdurice can you confirm why self.fp16_partitioned_groups_flat and self.fp32_partitioned_groups_flat are not pinned at construction if pin_memory option is selected
There was a problem hiding this comment.
Hi @sfc-gh-truwase , thank you for the review. For self.fp16_partitioned_groups_flat, it is on the GPU unless offloading parameters are on, so pinning doesn't apply here. For self.fp32_partitioned_groups_flat, it is indeed pinned, but during copying back to the GPU, it uses an pageable memory by PyTorch which is the inefficient part. So, this PR creates two fixed sized pinned buffer. LMK if that's clear. Thank you!
ZeRO-3: copy offloaded master weights back to the device through pinned buffers
Part of: #8672 (Q4 2026 roadmap), "Enable training of large scale models".
Summary
With
offload_optimizer.device: cpu, ZeRO-3 copies each sub-group's updated float32 master partition back to thedevice from host memory that is never pinned, even with
pin_memory: true(the default since #8207). This PR copiesit through two fixed-size pinned buffers instead, with the same values.
Duplicate check
Searched issues and PRs on 2026-10-13 ("offload pinned fp32 partition", "zero3 offload pin_memory copy",
"fp32_partitioned_groups_flat", "offload ratio pinned", "pageable offload"). Related, not duplicates: #8207 (merged:
pinned the ZeRO-3 gradient offload buffers, default
pin_memory: true; master partitions unchanged), #8256 (merged:routes pin sites through
get_accelerator().pin_memory(); this PR uses the same accelerator calls), #8618 (closed:a CPU-offload benchmark).
Sibling path: ZeRO-1/2 does not have the defect. It pins the float32 partition and casts into a pinned bf16 buffer
of the partition's size before a
non_blockingcopy (stage_1_and_2.pylines 546-558 and 2841-2844).Root cause
_create_fp32_partitions(stage3.py) builds each CPU master partition with.to('cpu', dtype=...)and.clone().detach(), which is pageable memory whateverpin_memorysays. After the CPU optimizer step,_reassign_or_swap_out_partitioned_parametersrunsfp16_flat.data.copy_(fp32_flat.data): a blocking copy from CPUfloat32 to device bf16. For a blocking copy with a dtype change, PyTorch converts on the CPU into a new pageable bf16
tensor of the whole partition, copies it from pageable memory and synchronizes.
Found in a profile (MiniMax-M3, first 8 layers, 8 H200, 75% of the optimizer on the CPU, rank 0): 13 bare
aten::copy_host-to-device copies of 576 MiB per step from pageable memory, at 3-7 GB/s, 1,648 ms per step.The change (
deepspeed/runtime/zero/stage3.py, 49 added, 2 removed lines)_reassign_or_swap_out_partitioned_parametersoffload_optimizer.pin_memoryis true, the source is on the CPU and not pinned (get_accelerator().is_pinned), and the destination is on the device; every other case keepsdst.copy_(src)_copy_through_pinned_staging(dst, src)dst's dtype (get_accelerator().pin_empty, created on first use). Per piece: wait for the previous copy out of this buffer (an event), cast the piece into the buffer on the CPU,copy_(..., non_blocking=True)to the device, record an event. Before returning, wait for the last piece__init__,_unpin_offload_buffersDS_PIN_MEMORY_BACKEND=native)Design notes:
non_blockingcopy from pinned float32 into a bf16tensor converts on the device and needs a float32 device temporary of the sub-group's size. Avoiding that needs a
pinned bf16 buffer of each partition's size, as ZeRO-1/2 keeps. Either way pinned host memory would grow with the
model, and PyTorch's pinned allocator rounds each allocation up to a power of two. The two buffers here have a fixed
size; 2**26 is a power of two, so nothing is rounded up.
partition is bitwise the same.
wait keeps what callers had before: the partition is on the device when the function returns. The next forward's
parameter all-gathers run on their own stream, and it does not wait for the main stream.
for the whole copy.
Measured
Qwen2.5-32B (all 64 layers, real weights), 1 node of 8 H200 (143,771 MiB, driver 580.126.09), torch 2.8.0+cu126.
Settings:
offload_optimizer: {device: cpu, pin_memory: true};dcd8595c2wall_clock_breakdownstep/ of which the sub-group loop (optimizer_step)The losses differ from step 2 by run-to-run variation. Two master runs differ by as much: step 2 is 0.976376 with
wall_clock_breakdownand 0.976567 without it. The flash-attention backward adds with atomics. The bitwise check isthe unit test below.
MiniMax-M3 runs (DeepSpeed master + unmerged MiniMax-M3 model code: #8658, #8652)
Settings for both: ZeRO-3 + AutoEP (expert parallel 8), real weights, each 65,536-token sequence split over 4 GPUs,
65% of the optimizer on the CPU (
offload_optimizer.ratio0.65), float32 master + Adam.(mean of steps 3-10); peak device memory 132.4 GB in both.
This change, and the fused CPU Adam change stacked on it, were in the DeepSpeed tree of our MiniMax-M3 and GLM-5.2
runs against Megatron-LM (real weights, 65,536-token sequences, 80-95% of the optimizer on the CPU):
On one node (first 4 layers of each model), master plus this PR, the fused CPU Adam PR and the two model
PRs (#8658, #8659) gives the same loss at every step as that tree.
Tests
New:
TestZero3OffloadPinnedCopyBackintests/unit/v1/zero/test_zero.py(2 ranks,ratio1.0 and 0.5,sub_group_size20,000 so partial offload has sub-groups on both sides).pin_memory: false(direct copy) and withpin_memory: true, and requires the gatheredparameters to be bitwise equal.
copy with itself.
The test is in
tests/unit/v1so that PR CI selects it. With this diff, themodal-torch-latestselector(
ci/tests_fetcher.py) picks 85 test files, includingtests/unit/v1/zero/test_zero.py. I simulated the selector'sdiff with the working-tree files, since nothing was committed.
Commands, from
tests/, on 1 node of 8 H200 (143,771 MiB, driver 580.126.09), torch 2.8.0+cu126, pytest 9.1.1:The last two ran on the first version of this patch, whose
stage3.pydiffers from this one only in two comments;the timings above also used the first version.
Compatibility
pin_memorytrue, whole optimizer on the CPUratio< 1)pin_memoryfalseDS_PIN_MEMORY_BACKEND=nativeget_accelerator()calls are used (pin_empty,is_pinned,Event,unpin_memory)No config, checkpoint or public API change.
AI assistance
This change, its tests and this description were prepared with AI assistance (Claude).