Skip to content

Gather zero-sized parameters in ZeRO-3 - #8375

Open
alanhuangyoo wants to merge 1 commit into
deepspeedai:masterfrom
alanhuangyoo:fix/zero3-zero-sized-params
Open

Gather zero-sized parameters in ZeRO-3#8375
alanhuangyoo wants to merge 1 commit into
deepspeedai:masterfrom
alanhuangyoo:fix/zero3-zero-sized-params

Conversation

@alanhuangyoo

@alanhuangyoo alanhuangyoo commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

ZeRO-3 fails on a zero-sized trainable parameter, where stages 1 and 2 now work.

nn.Linear(8, 0, bias=False) used in the loss, one step:

stage 1: OK
stage 2: OK
stage 3: AssertionError: {'id': 1, 'status': 'NOT_AVAILABLE', 'numel': 0, 'ds_numel': 0,
                          'shape': (0,), 'ds_shape': (0, 8), 'requires_grad': True, ...}

raised at partitioned_param_coordinator.py:413.

Root cause

fetch_sub_module decides whether to run the all-gather from the number of elements left to
fetch:

fetch_numel = sum(
    [p.partition_numel() for p in params_to_fetch if p.ds_status == ZeroParamStatus.NOT_AVAILABLE])

if fetch_numel > 0:
    ...
    self.__all_gather_params(params_to_fetch, forward)

A submodule whose only parameter is zero-sized contributes 0 to that sum, so the gather is
skipped entirely. The parameter never leaves NOT_AVAILABLE, and the wait loop immediately
below asserts that it is AVAILABLE.

The element count is a proxy for "is there anything to fetch", and it is the wrong proxy: a
zero-sized parameter has no bytes to move but still needs its status transitioned.

The change

Gate on whether any parameter still needs gathering. fetch_numel is kept for the profiler
event, which is what it was actually for.

params_to_gather = [p for p in params_to_fetch if p.ds_status == ZeroParamStatus.NOT_AVAILABLE]
fetch_numel = sum(p.partition_numel() for p in params_to_gather)

if params_to_gather:

Nothing changes when at least one parameter has elements — the two conditions agree everywhere
except the all-zero case, which today cannot proceed at all.

Relation to the stage 1/2 fixes

Same class of failure, different place. #8280 (issue #8279) skipped zero-sized parameters before
ZeRO-1/2 gradient reduction and #8298 (issue #8297) skipped them in HP fragment mapping; both
are about not processing a parameter with no elements. Stage 3 is the opposite — it has to
process it, because the status machine tracks the parameter and not its bytes.

I did not find an issue or PR covering stage 3.

Verification

Reproducer run against a clean upstream/master worktree and against this branch, same
environment, one process and two gloo ranks:

                     master        this branch
world_size=1
  stage 1              OK              OK
  stage 2              OK              OK
  stage 3      AssertionError          OK
world_size=2
  stage 1              OK              OK
  stage 3      AssertionError          OK

The two-rank case matters on its own: at world_size=1 the fetch takes the
_no_gather_coalesced shortcut, so only the multi-rank run exercises the real all-gather behind
the gate.

tests/unit/runtime/zero/test_zero_empty_param.py covers all three stages at world_size 1 and
2. Note that #8280's description mentions a file of this name, but no test file landed with it,
so stages 1 and 2 have had no in-tree coverage either — this adds it alongside stage 3.

$ pytest tests/unit/runtime/zero/test_zero_empty_param.py     # this branch
3 passed, 3 skipped

$ pytest tests/unit/runtime/zero/test_zero_empty_param.py     # upstream/master
1 failed, 2 passed, 3 skipped
E   AssertionError: {'id': 1, 'status': 'NOT_AVAILABLE', 'numel': 0, ...}

$ yapf==0.40.0 --diff  /  flake8
(clean)

Is one gate enough?

Checked that this is the only thing standing in the way, rather than the first of several, by
running the same zero-sized-parameter model through the other stage-3 paths on this branch:

zero.Init context          OK
three steps (prefetch)     OK
CPU param + optimizer offload   OK
save_checkpoint + load_checkpoint   OK

All four fail at the same assert on master, and all four pass here, so the numel gate in
fetch_sub_module is the whole of it.

The 3 skips are the world_size=2 class, which this single-accelerator box cannot schedule;
those cases were run directly over gloo instead, and are the two-rank rows in the table above.

fetch_sub_module decides whether to run the all-gather from the number of
elements still to fetch:

    fetch_numel = sum(p.partition_numel() for p in params_to_fetch
                      if p.ds_status == ZeroParamStatus.NOT_AVAILABLE)
    if fetch_numel > 0:

A submodule whose only parameter is zero-sized contributes nothing to that sum,
so the gather is skipped, the parameter never leaves NOT_AVAILABLE, and the wait
loop right below asserts that it is AVAILABLE:

    AssertionError: {'id': 1, 'status': 'NOT_AVAILABLE', 'numel': 0, ...}

Gate on whether any parameter still needs gathering instead. The element count
is kept for the profiler, which is what it was for.

This is the ZeRO-3 counterpart of deepspeedai#8280 and deepspeedai#8298, which fixed the same class of
failure for stages 1 and 2 (issues deepspeedai#8279 and deepspeedai#8297).

Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
params_to_fetch = set(iter_params(current_submodule, recurse=is_leaf))
fetch_numel = sum(
[p.partition_numel() for p in params_to_fetch if p.ds_status == ZeroParamStatus.NOT_AVAILABLE])
# Gate on whether anything still has to be gathered, not on how many elements that is:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What is the source of zero-sized parameters and how they are set to ZeroParamStatus.NOT_AVAILABLE status? I think a better solution would be to avoid them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Where they come from. Model code declares them. An in-tree example is transformers'
FP-Quant integration, which installs zero-sized placeholders for the quantized tensors:

# transformers/integrations/fp_quant.py:101
".weight":   torch.nn.Parameter(torch.zeros(0)),
".dqweight": torch.nn.Parameter(torch.zeros(0)),
".qweight":  torch.nn.Parameter(torch.zeros(0)),
".scales":   torch.nn.Parameter(torch.zeros(0)),

More generally any config that drives a dimension to zero produces one — a classifier head with
no labels, an adapter left at rank 0, a modality tower that is configured off. The repro in the
PR is the minimal version of that, not a synthetic special case.

How they end up NOT_AVAILABLE. Nothing special happens to them.
partition_parameters.py:324 sets NOT_AVAILABLE on every parameter it partitions, regardless
of size, and release_and_reset_all does the same at :1744. So a zero-sized parameter enters
fetch_sub_module in exactly the state every other parameter is in.

The mismatch is that the status is tracked per parameter while the gate is on a sum of
elements
:

fetch_numel = sum(p.partition_numel() for p in params_to_fetch
                  if p.ds_status == ZeroParamStatus.NOT_AVAILABLE)
if fetch_numel > 0:
    ...   # this is what flips them to AVAILABLE

A submodule whose only ungathered parameter is zero-sized sums to 0, the block is skipped, and
nothing moves the parameter out of NOT_AVAILABLE. Twelve lines further down the same function
asserts that it did:

assert param.ds_status == ZeroParamStatus.AVAILABLE, param.ds_summary()

which is the AssertionError in the PR description. The fetch_numel value is still needed for
the trace and prefetch accounting, so the change keeps computing it and only moves the branch
onto "is anything still ungathered".

On avoiding them instead. That would be a change of policy rather than a smaller fix — the
runtime already tolerates them in every other place it meets them:

runtime/utils.py:51 "Filter out empty parameters (numel == 0) from optimizer params"
runtime/utils.py:245 if x.numel() == 0
runtime/engine.py:3804 if param.numel() == 0
runtime/zero/stage_1_and_2.py:1205, :1270 zero-sized guards on the ZeRO-1/2 paths
runtime/zenflow/engine_stage3.py:281 if param.selected_indices.numel() == 0
fp16/onebit/lamb.py:86 "Filter out empty parameters (numel == 0) to avoid NaN"

ZeRO-3's own test fixtures assume the same. tests/unit/v1/compile/test_z3_eager_fallback.py:23
builds a stage-3 module whose single parameter is exactly this:

param = torch.nn.Parameter(torch.empty(0))
param.ds_id = 7
param.ds_status = ZeroParamStatus.NOT_AVAILABLE

Avoiding them would mean either rejecting models that legitimately declare them, or dropping
them from the ZeRO-3 registry — and dropping them changes what state_dict() contains, so
checkpoints would no longer round-trip against the original module.

I also checked this is the only gate in the way rather than the first of several: on this branch
the same model runs through zero.Init, three steps of prefetch, CPU param + optimizer offload,
and save_checkpoint/load_checkpoint; all four raise the same assert on master and all four
pass here.

Happy to go the other way if you would rather ZeRO-3 reject these outright — just say which, and
I will redo it as a clear error at Init time instead.

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.

2 participants