Repository navigation
ZeRO-3: copy offloaded master weights back to the device through pinned buffer - #8840
pengdurice wants to merge 4 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!
There was a problem hiding this comment.
Thanks. I recommend mirroring #5301: when offload_optimizer.pin_memory is enabled, pin the final CPU FP32 master partition at construction—after clone().detach()—and explicitly transfer it to the accelerator before the low-precision copy. This is simpler and consistent with ZeRO-1/2 semantics, though it accepts model-scale pinned memory.
One correction: the linked initialization code pins fp32_partitioned_groups_flat[i].grad, not its .data. The flat FP32 master weight currently remains pageable.
There was a problem hiding this comment.
Oh, @sfc-gh-truwase thanks for the review, I just redid the code to match #5301. Also thank you for the comment of the master weights are not pinned. this is also implemented in the current version. (also sorry for the mis-understanding of the code base where I thought the master was already pinned)Thank you!
…vice when copying back Signed-off-by: pengdurice <pengduhit@gmail.com>
|
Hi @sfc-gh-truwase if you have no further comments I'll help merge this PR, thanks! |
ZeRO-3: pin the offloaded master weights and cast them on the device when copying them back
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 as bf16 after every step.
pin_memory: true(the default since fix(zero3): async grad offload + pinned offload buffers by default #8207) pins the float32 gradient partitions. It does not pin the masterpartitions, so this copy reads pageable host memory.
copy_from float32 into bf16 casts on the CPU into a new pageable tensor andcopies from that at a few GB/s.
This PR follows ZeRO-1/2 and #5301:
pin_memory: true, each CPU master partition is pinned when it is created, as ZeRO-1/2 does;device temporary is one piece (0.25 GiB) instead of a whole sub-group.
Results:
same losses (the new unit test compares the parameters bitwise).
each allocation up to a power of two: a 1e9-element sub-group (3.73 GiB in float32) reserves 4.00 GiB.
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 pins its master partitions
(stage_1_and_2.py#L563-L575).
#5301 added the pinning and copied the pinned master to the device before the cast; ZeRO-1/2 now casts into a pinned
bf16 buffer of each partition's size instead
(#L3093-L3096).
This PR uses #5301's form, which needs no second pinned buffer.
Root cause
What
offload_optimizer.pin_memory: truepins in ZeRO-3 on master, checked at runtime on the earlier version of thisPR, which pins the same buffers as master (one GPU, bf16, DeepSpeedCPUAdam, after
deepspeed.initializeand after onestep):
fp16_partitioned_groups_flat(bf16 parameter partitions)fp32_partitioned_groups_flat[i].grad(float32 gradient partitions)fp32_partitioned_groups_flat(float32 master partitions)exp_avg,exp_avg_sqWith
offload_param,fp16_partitioned_groups_flatis on the host and already pinned: it is a view ofparam_groups_fp16_flat_cpu_memory(stage3.py#L906)._create_fp32_partitionsbuilds each CPU master partition with.to('cpu', dtype=...)and.clone().detach()(stage3.py#L1157-L1160),
which gives pageable memory. After the CPU optimizer step,
_reassign_or_swap_out_partitioned_parametersrunsfp16_flat.data.copy_(fp32_flat.data): a blocking copy from CPU float32 to device bf16, which PyTorch does by castingon the CPU into a new pageable bf16 tensor of the whole partition, copying it from pageable memory and synchronizing.
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, 43 added, 4 removed lines)_create_fp32_partitionsoffload_optimizer.pin_memory, pins each CPU master partition afterclone().detach()(get_accelerator().pin_memory)_reassign_or_swap_out_partitioned_parametersdst.copy_(src):pin_memoryfalse, sub-groups kept on the device (ratio< 1), NVMe-swapped sub-groups_copy_pinned_master_to_device(dst, src)src[piece].to(device, non_blocking=True), thendst[piece].copy_(...), which casts on the device; at the end, records an event and waits for it_master_pinned_at_construction(sub_group_id)_unpin_offload_buffers__init__,_unpin_offload_buffersDS_PIN_MEMORY_BACKEND=native)Design notes:
Why the cast moves to the device: pinning alone does not make the copy faster. A blocking
copy_from pinnedfloat32 into bf16 still casts on the CPU into a new pageable tensor (136.2 ms against 136.5 ms from pageable memory,
300M elements, in an earlier measurement on the same setup).
Why pieces:
.to(device)of a whole sub-group makes a float32 device temporary of the sub-group's size,3.73 GiB at the default
sub_group_sizeof 1e9. Pieces of 2**26 elements keep it at 0.25 GiB at the same speed.Measured on one H200, torch 2.8.0, 12 CPU threads, copying one partition back into its bf16 device partition:
copy_from pageable float32 (master).to(device), thencopy_(fix pagable h2d memcpy #5301)Same values: the device cast rounds to nearest even, as the CPU cast does. The new test compares the trained
parameters bitwise against
pin_memory: false.Stream order: the pieces go on the current stream. The final 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
that stream does not wait for the main stream.
Host synchronization: one wait per sub-group, at the end of its copy. The old path also blocked 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};0f8c9d7f1wall_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.976235 with
wall_clock_breakdownand 0.976290 without it). The flash-attention backward adds with atomics. The bitwise check isthe unit test below.
This version is faster than the earlier one in training (9.42 against 10.20 s), although the two copies take the
same time alone at 1e9 elements. A likely reason, not measured: the earlier version cast on the CPU, which the CPU
optimizer of all 8 ranks on the node also uses; this version casts on the device.
Earlier version of this PR (staging buffers) on MiniMax-M3 (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.
These were not repeated with this version.
Tests
Updated:
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.
pin_memory: true, and none withpin_memory: false.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 test is in
tests/unit/v1, so PR CI selects it: themodal-torch-latestselector (ci/tests_fetcher.py)picked
tests/unit/v1/zero/test_zero.pyfor the earlier version, which changes the same two files.Compatibility
pin_memorytrue, whole optimizer on the CPUratio< 1)pin_memoryfalseDS_PIN_MEMORY_BACKEND=nativeget_accelerator()calls are used (pin_memory,is_pinned,Event,unpin_memory)No config, checkpoint format or public API change.
AI assistance
This change, its tests and this description were prepared with AI assistance (Claude).