Fix ZeRO-3 all_reduce param fetch stride for padded parameters#8158
Fix ZeRO-3 all_reduce param fetch stride for padded parameters#8158ebarkhordar wants to merge 2 commits into
Conversation
In the all_reduce reconstruction path, the flat buffer is sized sum(ds_numel_aligned) but the per-parameter offset advanced by the unaligned ds_numel. When a parameter's numel is not divisible by the partition world size the two differ, so each subsequent parameter's span started inside its predecessor's aligned span. The overlapping slots are the predecessor's partition padding, which is never written and therefore holds uninitialized memory, and the all_reduce SUM adds it into the next parameter's leading elements. Advance by ds_numel_aligned so each parameter owns the disjoint span the buffer sizing already reserves for it. Signed-off-by: Ehsan Barkhordar <realbarkhordar@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0ea262c4f1
ℹ️ 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".
| partition_parameters._orig_torch_empty = poisoned_empty | ||
| try: | ||
| with deepspeed.zero.Init(config_dict_or_path=config, mem_efficient_linear=False, enabled=True): | ||
| module = ParamHolder(numels) | ||
| finally: | ||
| partition_parameters._orig_torch_empty = real_empty |
There was a problem hiding this comment.
Restore torch.empty after poisoning _orig_torch_empty
When this test exits the deepspeed.zero.Init context, Init.__exit__ restores torch.empty from partition_parameters._orig_torch_empty; because this assignment is still pointing at poisoned_empty until the finally block runs, torch.empty is left globally patched to return sentinel-filled tensors for the remainder of the worker process. In contexts that reuse the distributed worker or add any later allocation in this test, unrelated code will see poisoned allocations, so the cleanup needs to restore the public torch.empty binding as well or avoid mutating _orig_torch_empty through the context teardown.
Useful? React with 👍 / 👎.
…lock Init.__exit__ restores torch.empty by reading _orig_torch_empty, so leaving the test's poisoned function in that global rebound torch.empty to it on the way out and left every later allocation in the worker process sentinel-filled. Signed-off-by: Ehsan Barkhordar <realbarkhordar@gmail.com>
|
Good catch, this is real and I have pushed a fix in 409ccd7.
Every later allocation in that worker process would have come back sentinel-filled. The fix saves the public binding before the block and restores it alongside the module global; the same probe after the change reports The poisoning inside the block is unchanged, so the padded/aligned differential the PR rests on still holds. |
tohtana
left a comment
There was a problem hiding this comment.
Hi @ebarkhordar,
Great catch! It's surprising that we still have such a bug. I really appreciate your contribution.
PKUWZP
left a comment
There was a problem hiding this comment.
@ebarkhordar Thanks so much for this PR! In order to merge the PR, it would be ideal to run some tests on multi-GPU settings. I can help run some tests on my end, can you give me a 1-2 days so we can fully validate the fix and merge? Thanks for your contribution to DeepSpeed.
|
Thanks @tohtana, and thanks @PKUWZP for offering to run the multi-GPU validation. That plan works for me, please take the time you need. I don't have a multi-GPU box, so I verified the fix single-process only (the unit test simulates world_size=2 with padded numels); a real multi-rank run on the all_reduce fetch path is exactly the check it should have before merging, so I would rather you confirm it than merge on my word. Happy to follow up on anything the runs turn up. On the red checks, so they do not muddy the picture: the two failing modal-torch-latest legs are the base workflow's pull_request_target checkout refusing fork code on the re-run ("Refusing to check out fork pull request code from a 'pull_request_target' workflow"), not the change. The same commit (409ccd7) passed the full modal CI on the first run. |
|
Thanks @tohtana and @PKUWZP. No rush at all, take the days you need. I verified the stride fix with a 2-rank test locally (test_zero_allreduce_fetch_params.py), but I do not have real multi-GPU hardware here, so your validation is the right gate. Happy to add cases or adjust if your runs surface anything. |
Problem
In
Init._all_gather_coalesced, the branch taken whenstage3_use_all_reduce_for_fetch_paramsis enabled sizes the flat buffer with thealigned element count but walks it with the unaligned one:
ds_numel_alignedisds_numelrounded up to a multiple of the partition world size, sothe two are equal only when
ds_numel % world_size == 0. When a parameter is padded, everyfollowing parameter's base offset lands
padding_islots inside its predecessor's alignedspan.
Those overlapping slots are the predecessor's partition padding. They are not harmless:
_partition_paramallocates the partition withtorch.emptyand, on the one rank whosepartition extends past the end of the parameter, copies only
elems_to_copyelements, sothe tail is never written and holds whatever the allocator returned. Because the fetch is
an
all_reducewith SUM, that uninitialized tail is added into the next parameter'sleading elements. The sibling
all_gatherpath writes the same padding but each parameternarrows only its own
ds_numelout of the result, so it is never read; the corruption isspecific to the SUM reconstruction.
Total stride is smaller than the allocated buffer, so there is no out-of-bounds access and
nothing crashes. The parameter simply comes back with wrong leading values.
Invariant
Each parameter owns a disjoint contiguous span of
ds_numel_alignedslots inflat_tensor,which is exactly what
flat_buffer_sizealready reserves for it.ds_numel_alignedhas a single writer in the package,param.ds_numel_aligned = tensor_sizein
_partition_param, set in the same block that createsds_tensor. It has two readers:the buffer sizing above and the line changed here. Since the buffer sizing already reads the
attribute for every param in this same loop, the change introduces no new requirement on
when the attribute must exist.
Fix
Advance
start_parambyds_numel_aligned, matching the buffer sizing.Verification
Added
tests/unit/runtime/zero/test_zero_allreduce_fetch_params.py, parametrized over apadded case (numels 5 and 7 under
world_size=2) and an aligned case (numels 4 and 6):On master the padded case reports:
7778 is 7777 (the sentinel filling
p0's uninitialized padding) plus 1.0 (p1's realfirst element), which is the SUM overlap rather than a generically wrong value.
About that sentinel: the padding tail is uninitialized by construction, and a fresh
allocation usually reads back as zero, in which case the SUM adds zero and the bug is
invisible. A first attempt at this reproduction showed no corruption for exactly that
reason. The test therefore fills new allocations with a sentinel so the tail's contents are
deterministic rather than dependent on whether the allocator hands back a recycled block.
It does not inject anything into the buffer under test.
Run in a CPU-only container, world_size 2 over gloo,
LOCAL_SIZE=2:pre-commit run --filespasses on both changed files.What I did not verify
I have no multi-GPU machine here, so this was exercised only on CPU with gloo, not with
NCCL on real devices. I also did not measure the effect on a full training run, so I cannot
say how the corrupted leading elements affect convergence in practice; the claim here is
limited to the reconstructed parameter values being wrong.