Gather zero-sized parameters in ZeRO-3 - #8375
Conversation
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 AVAILABLEA 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_AVAILABLEAvoiding 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.
|
@fwerkor @sfc-gh-truwase — this is the ZeRO-3 half of #8280, which you two wrote and reviewed for ZeRO-1/2, so flagging it rather than letting it sit unreviewed. Same failure, same cause, different stage: the all-gather is gated on No rush if it is queued; I mostly want to be sure it did not fall through because it looks like a duplicate of #8280. |
|
Correction to the description, and a narrowing. I ran the parametrized test rather than trusting the earlier claim in it, and stages 1 and 2 do not work on this model shape with #8280 and #8298 in master: That is a different bug in a different file: So this PR is now scoped to the ZeRO-3 gather gate: |
fwerkor
left a comment
There was a problem hiding this comment.
I think these need to be addressed before merge:
-
Coalesced gather still fails.
AllGatherCoalescedHandle.wait()gets an emptypartitionslist for a zero-sized parameter and then callstorch.cat(partitions), raisingValueError. The current test only covers the single-parameter sequential path. Please add a coalesced regression test and handle this case. -
zero_quantized_weights=Truestill fails. Empty params reachCUDAQuantizer.quantize(), wheregroups == 0leads toZeroDivisionError. Please bypass or explicitly handle empty params in the quantized path, with coverage. -
Prefetch still uses a numel gate. An all-zero prefetch set is popped from the queue but not gathered because submission still requires
numel_prefetching > 0. Please make this consistent with the new parameter-based fetch condition.
Running the parametrized test showed stages 1 and 2 still failing on this model shape with deepspeedai#8280 and deepspeedai#8298 in master: RuntimeError: size mismatch, got input (1), mat (1x8), vec (0) That is not the gather gate this PR is about. _update_model_bit16_weights drops a zero-element parameter's shape when it repoints it at the ZeRO-1/2 flat buffer, so (0, 8) comes back as (0,) and the module's own forward breaks before any of this code runs. Fixing it belongs in its own change. Narrow the parametrize to stage 3 and correct the docstring, which claimed stages 1 and 2 already worked. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
d4ea27b to
5cda14e
Compare
Three more places treat "has elements" and "needs handling" as the same
question. All three reproduce on unmodified master, so they are pre-existing
rather than introduced here — but they sit on the path this PR opens up, and a
zero-sized parameter cannot get through ZeRO-3 without them.
1. `AllGatherCoalescedHandle.wait` concatenates the per-rank slices of each
parameter. No rank holds a slice of a zero-sized one, so the list is empty:
ValueError: torch.cat(): expected a non-empty list of Tensors
Give the parameter an empty tensor of its own shape instead. The dtype has to
be the one the sized parameters of the bucket end up with, which is the
partition's dtype normally and the parameter's own under quantization, where
the partition is stored as int8.
2. `CUDAQuantizer.quantize` derives its group count from numel, so a zero-sized
partition makes `groups` 0 and the next `numel % (8 * groups * 2)` raises
ZeroDivisionError. Return the empty (int8, fp32 scale) pair the callers
expect, so `ds_quant_scale` is still set, with the mirror guard in
`dequantize`.
3. The prefetch submit gates on `numel_prefetching > 0`, the same proxy this PR
removed from the fetch. Gate on the parameter set instead.
Tests: coalesced gather (shape and dtype of the parameter it hands back),
`zero_quantized_weights`, `zero_quantized_nontrainable_weights` (both skipped
when QuantizerBuilder is unavailable), and a prefetch-enabled model.
6 passed on this branch, 6 failed on master — ValueError for the coalesced case,
ZeroDivisionError for the quantized non-trainable one, and the NOT_AVAILABLE
assertion for the rest.
The prefetch change is the one exception: reverting only that line still leaves
all six passing, and tracing `__all_gather_params` shows the same 8 all-zero
submissions either way, because the fetch gathers those parameters when their
submodule is reached. It is a consistency fix with no case I could construct
where it changes behaviour, so the test above does not claim to cover it.
Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Thanks — all three are real. Correction first: all three reproduce on unmodified master ( The first one already bites on master because a bucket mixing sized and zero-sized parameters passes the 1. Coalesced gather. Confirmed, exactly as you describe. Fixed by handing the parameter an empty tensor of its own shape rather than concatenating nothing. The dtype is the part worth a look. The sized parameters of a bucket come out at So the branch picks 2. The fix is inside 3. Prefetch gate. Changed to gate on the parameter set, as you asked. But I could not construct a case where it changes behaviour, and I would rather say so than imply the test covers it:
The reason looks to be that the parameters are popped from the queue but Testing. 2×H20, torch 2.9.1+cu128, The quantized tests skip when |
ZeRO-3 fails on a zero-sized trainable parameter.
nn.Linear(8, 0, bias=False)used in the loss, one step:Correction to an earlier version of this description, which said stages 1 and 2 already
worked. They do not, on this model shape, with #8280 and #8298 in master:
That is a different bug in a different place —
_update_model_bit16_weightsdrops azero-element parameter's shape when it repoints it at the ZeRO-1/2 flat buffer, so the
module's own forward breaks before any of the code in this PR runs. It is fixed separately;
this PR is scoped to the ZeRO-3 gather gate and its test is now parametrized on stage 3 only.
raised at
partitioned_param_coordinator.py:413.Root cause
fetch_sub_moduledecides whether to run the all-gather from the number of elements left tofetch:
A submodule whose only parameter is zero-sized contributes
0to that sum, so the gather isskipped entirely. The parameter never leaves
NOT_AVAILABLE, and the wait loop immediatelybelow 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_numelis kept for the profilerevent, which is what it was actually for.
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/masterworktree and against this branch, sameenvironment, one process and two gloo ranks:
The two-rank case matters on its own: at
world_size=1the fetch takes the_no_gather_coalescedshortcut, so only the multi-rank run exercises the real all-gather behindthe gate.
tests/unit/runtime/zero/test_zero_empty_param.pycovers all three stages atworld_size1 and2. 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.
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:
All four fail at the same assert on master, and all four pass here, so the numel gate in
fetch_sub_moduleis the whole of it.The 3 skips are the
world_size=2class, 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.