-
Notifications
You must be signed in to change notification settings - Fork 5k
Gather zero-sized parameters in ZeRO-3 #8375
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
alanhuangyoo
wants to merge
4
commits into
deepspeedai:master
Choose a base branch
from
alanhuangyoo:fix/zero3-zero-sized-params
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
50da554
Gather zero-sized parameters in ZeRO-3
alanhuangyoo ece8aa2
Merge remote-tracking branch 'upstream/master' into fix/zero3-zero-si…
alanhuangyoo 5cda14e
Scope this to ZeRO-3; stages 1 and 2 fail from a different place
alanhuangyoo 889e4b5
Address review: coalesced gather, quantizer, prefetch gate
alanhuangyoo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| # DeepSpeed Team | ||
| """A zero-sized trainable parameter must survive a full ZeRO-3 step. | ||
|
|
||
| Four places treat "has elements" and "needs handling" as the same question: | ||
|
|
||
| * ``fetch_sub_module`` gates the all-gather on ``fetch_numel > 0``, so a submodule holding | ||
| only zero-sized parameters is skipped, the parameters stay ``NOT_AVAILABLE``, and the | ||
| wait loop immediately below asserts that they are ``AVAILABLE``; | ||
| * the prefetch submit gates on ``numel_prefetching > 0``, so an all-zero prefetch set is | ||
| popped off the queue and then never prefetched (the fetch above still gathers those | ||
| parameters when the submodule is reached, so this one costs the overlap, not the step); | ||
| * ``AllGatherCoalescedHandle.wait`` concatenates the per-rank slices, and no rank holds a | ||
| slice of a zero-sized parameter, so ``torch.cat`` is handed an empty list; | ||
| * ``CUDAQuantizer.quantize`` derives its group count from ``numel``, so a zero-sized | ||
| partition divides by zero. | ||
|
|
||
| Stages 1 and 2 are deliberately not covered here. #8280 and #8298 (issues #8279, #8297) | ||
| fixed the reduction path they were reported against, but this model shape still fails on | ||
| both from a different place — ``_update_model_bit16_weights`` drops a zero-element | ||
| parameter's shape when it repoints it at the flat buffer — which is a separate fix. | ||
| """ | ||
|
|
||
| import pytest | ||
| import torch | ||
|
|
||
| from unit.common import DistributedTest | ||
|
|
||
| import deepspeed | ||
|
|
||
| HIDDEN = 8 | ||
|
|
||
|
|
||
| class EmptyTailModel(torch.nn.Module): | ||
| """The shape from issue #8279: a trainable parameter with no elements, used in the loss.""" | ||
|
|
||
| def __init__(self, hidden=HIDDEN): | ||
| super().__init__() | ||
| self.dense = torch.nn.Linear(hidden, hidden, bias=False) | ||
| self.empty = torch.nn.Linear(hidden, 0, bias=False) | ||
|
|
||
| def forward(self, x): | ||
| hidden = self.dense(x) | ||
| # `empty(hidden)` is (batch, 0); summing it keeps the parameter in the autograd graph. | ||
| return hidden.sum() + self.empty(hidden).sum() | ||
|
|
||
|
|
||
| class SharedModuleBlock(torch.nn.Module): | ||
| """A sized and a zero-sized parameter on one module, so they are gathered together.""" | ||
|
|
||
| def __init__(self, hidden=HIDDEN): | ||
| super().__init__() | ||
| self.weight = torch.nn.Parameter(torch.randn(hidden, hidden)) | ||
| self.empty = torch.nn.Parameter(torch.empty(0, hidden)) | ||
|
|
||
| def forward(self, x): | ||
| return (x @ self.weight.t()).sum() + (x @ self.empty.t()).sum() | ||
|
|
||
|
|
||
| class OnlyEmptyBlock(torch.nn.Module): | ||
| """A submodule whose parameters are *all* zero-sized.""" | ||
|
|
||
| def __init__(self, hidden=HIDDEN): | ||
| super().__init__() | ||
| self.first = torch.nn.Parameter(torch.empty(0, hidden)) | ||
| self.second = torch.nn.Parameter(torch.empty(0, hidden)) | ||
|
|
||
| def forward(self, x): | ||
| return (x @ self.first.t()).sum() + (x @ self.second.t()).sum() | ||
|
|
||
|
|
||
| class SharedModuleModel(torch.nn.Module): | ||
| """Several blocks, each owning a sized and a zero-sized parameter.""" | ||
|
|
||
| def __init__(self, hidden=HIDDEN, blocks=2): | ||
| super().__init__() | ||
| self.blocks = torch.nn.ModuleList([SharedModuleBlock(hidden) for _ in range(blocks)]) | ||
|
|
||
| def forward(self, x): | ||
| return sum(block(x) for block in self.blocks) | ||
|
|
||
|
|
||
| class MixedModel(torch.nn.Module): | ||
| """A sized submodule next to one holding only zero-sized parameters.""" | ||
|
|
||
| def __init__(self, hidden=HIDDEN, blocks=4): | ||
| super().__init__() | ||
| self.dense = torch.nn.Linear(hidden, hidden, bias=False) | ||
| self.only_empty = torch.nn.ModuleList([OnlyEmptyBlock(hidden) for _ in range(blocks)]) | ||
|
|
||
| def forward(self, x): | ||
| return self.dense(x).sum() + sum(block(x) for block in self.only_empty) | ||
|
|
||
|
|
||
| def _config(stage=3, **zero_overrides): | ||
| return { | ||
| "train_micro_batch_size_per_gpu": 1, | ||
| "optimizer": { | ||
| "type": "Adam", | ||
| "params": { | ||
| "lr": 1e-3 | ||
| } | ||
| }, | ||
| "zero_optimization": { | ||
| "stage": stage, | ||
| **zero_overrides | ||
| }, | ||
| "bf16": { | ||
| "enabled": True | ||
| }, | ||
| } | ||
|
|
||
|
|
||
| def _run_steps(model, config, steps=1): | ||
| trainable = [p for p in model.parameters() if p.requires_grad] | ||
| engine, *_ = deepspeed.initialize(model=model, model_parameters=trainable, config=config) | ||
| for _ in range(steps): | ||
| loss = engine(torch.randn(1, HIDDEN, device=engine.device, dtype=torch.bfloat16)) | ||
| engine.backward(loss) | ||
| engine.step() | ||
| return engine | ||
|
|
||
|
|
||
| class TestZeroSizedParameterSingleRank(DistributedTest): | ||
| world_size = 1 | ||
|
|
||
| @pytest.mark.parametrize("stage", [3]) | ||
| def test_step_completes(self, stage): | ||
| engine = _run_steps(EmptyTailModel(), _config(stage)) | ||
| assert engine.global_steps == 1 | ||
|
|
||
|
|
||
| class TestZeroSizedParameterPartitioned(DistributedTest): | ||
| world_size = 2 | ||
|
|
||
| @pytest.mark.parametrize("stage", [3]) | ||
| def test_step_completes(self, stage): | ||
| # With more than one rank the stage-3 path goes through the real all-gather rather than | ||
| # the single-rank shortcut, which is where the gate lives. | ||
| engine = _run_steps(EmptyTailModel(), _config(stage)) | ||
| assert engine.global_steps == 1 | ||
|
|
||
| def test_coalesced_gather_completes(self): | ||
| # One module owning both parameters puts them in a single coalesced gather, where | ||
| # the zero-sized one contributes no slice to concatenate. | ||
| engine = _run_steps(SharedModuleModel(), _config(), steps=2) | ||
|
|
||
| assert engine.global_steps == 2 | ||
| block = engine.module.blocks[0] | ||
| assert block.empty.shape == torch.Size([0, HIDDEN]) | ||
| # The gather has to hand back a dtype the module's own forward can use. | ||
| assert block.empty.dtype == block.weight.dtype | ||
|
|
||
| def test_completes_with_prefetch_enabled(self): | ||
| # Submodules holding only zero-sized parameters, with prefetch on. This covers the | ||
| # fetch gate under a prefetch configuration; it does not pin the prefetch submit | ||
| # gate itself, which costs the prefetch rather than the gather. | ||
| engine = _run_steps(MixedModel(), _config(stage3_prefetch_bucket_size=10000), steps=2) | ||
| assert engine.global_steps == 2 | ||
|
|
||
|
|
||
| class TestZeroSizedParameterQuantized(DistributedTest): | ||
| world_size = 2 | ||
|
|
||
| def _skip_without_quantizer(self): | ||
| from deepspeed.ops.op_builder import QuantizerBuilder | ||
| if not deepspeed.ops.__compatible_ops__[QuantizerBuilder.NAME]: | ||
| pytest.skip("QuantizerBuilder is not implemented") | ||
|
|
||
| def test_quantized_weights(self): | ||
| self._skip_without_quantizer() | ||
|
|
||
| engine = _run_steps(MixedModel(), _config(zero_quantized_weights=True)) | ||
|
|
||
| assert engine.global_steps == 1 | ||
|
|
||
| def test_quantized_nontrainable_weights(self): | ||
| self._skip_without_quantizer() | ||
| model = MixedModel() | ||
| for block in model.only_empty: | ||
| block.first.requires_grad = False | ||
| block.second.requires_grad = False | ||
|
|
||
| engine = _run_steps(model, _config(zero_quantized_nontrainable_weights=True)) | ||
|
|
||
| assert engine.global_steps == 1 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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_AVAILABLEstatus? I think a better solution would be to avoid them.There was a problem hiding this comment.
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:
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:324setsNOT_AVAILABLEon every parameter it partitions, regardlessof size, and
release_and_reset_alldoes the same at:1744. So a zero-sized parameter entersfetch_sub_modulein 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:
A submodule whose only ungathered parameter is zero-sized sums to
0, the block is skipped, andnothing moves the parameter out of
NOT_AVAILABLE. Twelve lines further down the same functionasserts that it did:
which is the
AssertionErrorin the PR description. Thefetch_numelvalue is still needed forthe 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:51runtime/utils.py:245if x.numel() == 0runtime/engine.py:3804if param.numel() == 0runtime/zero/stage_1_and_2.py:1205, :1270runtime/zenflow/engine_stage3.py:281if param.selected_indices.numel() == 0fp16/onebit/lamb.py:86ZeRO-3's own test fixtures assume the same.
tests/unit/v1/compile/test_z3_eager_fallback.py:23builds a stage-3 module whose single parameter is exactly this:
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, socheckpoints 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 fourpass 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
Inittime instead.