Files
deepspeedai--deepspeed/tests/unit/v1/zero/test_zero_coalesce_grad_reduction.py
2026-07-13 13:18:33 +08:00

634 lines
29 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
"""Tests for engine.coalesce_grad_reduction() -- ZeRO 1/2/3 coalesced reduction
across multiple engine.backward() calls per engine.step()."""
import pytest
import torch
import deepspeed
from unit.common import DistributedTest
from unit.simple_model import SimpleModel, SimpleMoEModel, random_dataloader, sequence_dataloader
from deepspeed.accelerator import get_accelerator
from deepspeed.utils import set_z3_leaf_modules
def _config(zero_stage,
world_size=2,
contiguous_gradients=False,
overlap_comm=False,
force_fp16=False,
reduce_bucket_size=None,
gradient_clipping=None):
config = {
"train_batch_size": world_size,
"gradient_accumulation_steps": 1,
"train_micro_batch_size_per_gpu": 1,
"steps_per_print": 1,
"zero_optimization": {
"stage": zero_stage,
"contiguous_gradients": contiguous_gradients,
"overlap_comm": overlap_comm,
},
"zero_force_ds_cpu_optimizer": False,
"optimizer": {
"type": "Adam",
"params": {
"lr": 1e-3,
"torch_adam": True,
},
},
}
if reduce_bucket_size is not None:
config["zero_optimization"]["reduce_bucket_size"] = reduce_bucket_size
if gradient_clipping is not None:
config["gradient_clipping"] = gradient_clipping
if force_fp16 or not get_accelerator().is_bf16_supported():
config["fp16"] = {"enabled": True, "initial_scale_power": 8, "loss_scale_window": 50}
else:
config["bf16"] = {"enabled": True}
return config
def _build_model(hidden_dim, nlayers, config, seed=42):
torch.manual_seed(seed)
if config["zero_optimization"]["stage"] == 3:
with deepspeed.zero.Init(config_dict_or_path=config):
return SimpleModel(hidden_dim, nlayers=nlayers)
return SimpleModel(hidden_dim, nlayers=nlayers)
def _init(config, hidden_dim, seed=42, nlayers=2):
model = _build_model(hidden_dim, nlayers, config, seed=seed)
engine, _, _, _ = deepspeed.initialize(model=model, model_parameters=model.parameters(), config=config)
return engine
def _params(engine):
return {n: p.detach().float().cpu().clone() for n, p in engine.module.named_parameters()}
def _assert_close(ref, test, label, tol=1e-6):
for name in ref:
diff = (ref[name] - test[name]).abs().max().item()
assert diff < tol, f"{label}: {name} max-abs-diff {diff:.3e} >= {tol:.0e}"
class _NullCtx:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def _config_dtype(config):
if config.get("fp16", {}).get("enabled"):
return torch.float16
return torch.bfloat16
def _train(config, hidden_dim, num_chunks, num_steps, use_no_sync, seed=42, nlayers=2):
engine = _init(config, hidden_dim, seed=seed, nlayers=nlayers)
batches = list(
random_dataloader(model=engine,
total_samples=num_chunks * num_steps,
hidden_dim=hidden_dim,
device=engine.device,
dtype=_config_dtype(config)))
for step_idx in range(num_steps):
step_batches = batches[step_idx * num_chunks:(step_idx + 1) * num_chunks]
ctx = engine.coalesce_grad_reduction() if use_no_sync else _NullCtx()
with ctx:
for i, batch in enumerate(step_batches):
loss = engine(batch[0], batch[1])
engine.set_gradient_accumulation_boundary(i == num_chunks - 1)
engine.backward(loss)
engine.step()
out = _params(engine)
engine.destroy()
return out
# ---------------------------------------------------------------------------
# Bit-exact correctness across stage / contiguous_gradients / overlap_comm
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("zero_stage", [1, 2, 3])
@pytest.mark.parametrize("contiguous_gradients,overlap_comm", [
(False, False),
(True, False),
(False, True),
(True, True),
])
class TestCoalesceCombinations(DistributedTest):
world_size = 2
def test_multi_backward_bit_exact(self, zero_stage, contiguous_gradients, overlap_comm):
cfg = _config(zero_stage, contiguous_gradients=contiguous_gradients, overlap_comm=overlap_comm)
ref = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=False)
test = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=True)
_assert_close(ref, test, label=f"ZeRO-{zero_stage} cg={contiguous_gradients} oc={overlap_comm}")
# ---------------------------------------------------------------------------
# Larger model + small reduce_bucket_size to force multi-bucket flush
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("zero_stage", [2, 3])
class TestCoalesceBucketOverflow(DistributedTest):
world_size = 2
def test_multi_bucket_flush(self, zero_stage):
# hidden=64 with nlayers=4 -> ~50K params, reduce_bucket_size=8K forces
# multiple bucket flushes inside the single coalesced reduction pass.
cfg = _config(zero_stage, contiguous_gradients=True, overlap_comm=True, reduce_bucket_size=8192)
ref = _train(cfg, hidden_dim=64, num_chunks=4, num_steps=1, use_no_sync=False, nlayers=4)
test = _train(cfg, hidden_dim=64, num_chunks=4, num_steps=1, use_no_sync=True, nlayers=4)
_assert_close(ref, test, label=f"ZeRO-{zero_stage} multi-bucket", tol=5e-6)
# ---------------------------------------------------------------------------
# CPU offload combinations
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("zero_stage,offload_optimizer,offload_param", [
(1, True, False),
(2, True, False),
(3, True, False),
(3, True, True),
])
class TestCoalesceCpuOffload(DistributedTest):
world_size = 2
def test_cpu_offload_bit_exact(self, zero_stage, offload_optimizer, offload_param):
cfg = _config(zero_stage)
if offload_optimizer:
cfg["zero_optimization"]["offload_optimizer"] = {"device": "cpu", "pin_memory": False}
if offload_param:
cfg["zero_optimization"]["offload_param"] = {"device": "cpu", "pin_memory": False}
ref = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=False)
test = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=True)
label = f"ZeRO-{zero_stage} offload_opt={offload_optimizer} offload_param={offload_param}"
_assert_close(ref, test, label=label)
# ---------------------------------------------------------------------------
# FP16 + dynamic loss scaling
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("zero_stage", [1, 2, 3])
class TestCoalesceFP16(DistributedTest):
world_size = 2
def test_fp16_bit_exact(self, zero_stage):
# FP16 with dynamic loss scaling: loss_scaler reads grads only at
# boundary. Verify coalesce path still yields identical params.
cfg = _config(zero_stage, force_fp16=True)
ref = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=False)
test = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=True)
_assert_close(ref, test, label=f"FP16 ZeRO-{zero_stage}")
# ---------------------------------------------------------------------------
# Gradient clipping + multi-step state hygiene
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("zero_stage", [1, 2, 3])
class TestCoalesceCorrectness(DistributedTest):
world_size = 2
def test_multi_step_no_state_leak(self, zero_stage):
# Three step() cycles back to back: no_sync state must reset cleanly
# between contexts. The two runs reduce in different orders (baseline
# reduces per-chunk via per-param hooks; coalesced reduces all params
# once at flush in bit16_groups order), so bf16 fp32-master accumulation
# diverges by a small amount that grows over multiple steps. Tol is
# set to lr (1e-3); a tighter tol would fail due to reduction-order
# non-associativity, not state leakage.
cfg = _config(zero_stage)
ref = _train(cfg, hidden_dim=8, num_chunks=3, num_steps=3, use_no_sync=False)
test = _train(cfg, hidden_dim=8, num_chunks=3, num_steps=3, use_no_sync=True)
_assert_close(ref, test, label=f"ZeRO-{zero_stage} 3x3", tol=1e-3)
def test_gradient_clipping(self, zero_stage):
# Gradient clipping reads the global grad norm at engine.step() time;
# averaged_gradients must be populated by our flush before that point.
cfg = _config(zero_stage, gradient_clipping=0.5)
ref = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=False)
test = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=True)
_assert_close(ref, test, label=f"ZeRO-{zero_stage} clip=0.5")
def test_n1_inside_context(self, zero_stage):
cfg = _config(zero_stage)
ref = _train(cfg, hidden_dim=8, num_chunks=1, num_steps=1, use_no_sync=False)
test = _train(cfg, hidden_dim=8, num_chunks=1, num_steps=1, use_no_sync=True)
_assert_close(ref, test, label=f"ZeRO-{zero_stage} N=1")
# ---------------------------------------------------------------------------
# use_grad_accum_attribute=True (ZeRO-1 + bf16 + grad_accum_dtype=fp32 + offload)
# ---------------------------------------------------------------------------
class TestCoalesceGradAccumDtype(DistributedTest):
world_size = 2
def test_zero1_offload_bf16_fp32_grad_accum(self):
# ZeRO-1 + bf16 + grad_accum_dtype=fp32 + cpu_offload routes through
# DeepSpeedZeroOptimizer with use_grad_accum_attribute=True. Each
# backward's optimizer.backward_epilogue() drains param.grad into
# param.grad_accum and clears param.grad. The flush iteration must
# check get_gradient_for_reduction (which returns grad_accum) instead
# of param.grad to avoid silently dropping the accumulated gradient.
if not get_accelerator().is_bf16_supported():
pytest.skip("requires bf16")
cfg = _config(zero_stage=1)
cfg["zero_optimization"]["offload_optimizer"] = {"device": "cpu", "pin_memory": False}
cfg["data_types"] = {"grad_accum_dtype": "fp32"}
ref = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=False)
test = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=True)
_assert_close(ref, test, label="grad_accum_fp32 ZeRO-1 + offload")
def test_zero2_bf16_fp32_grad_accum(self):
# ZeRO-2 + same options uses use_grad_accum_attribute=False (param
# grads route through normal param.grad), exercising the other branch.
if not get_accelerator().is_bf16_supported():
pytest.skip("requires bf16")
cfg = _config(zero_stage=2)
cfg["data_types"] = {"grad_accum_dtype": "fp32"}
ref = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=False)
test = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=1, use_no_sync=True)
_assert_close(ref, test, label="grad_accum_fp32 ZeRO-2")
def test_zero1_no_offload_uses_bf16_optimizer(self):
# ZeRO-1 + bf16 + grad_accum_dtype=fp32 + NO offload dispatches to
# BF16_Optimizer (engine.py:1565-1567), which our context cannot
# patch. Verify clear NotImplementedError.
if not get_accelerator().is_bf16_supported():
pytest.skip("requires bf16")
cfg = _config(zero_stage=1)
cfg["data_types"] = {"grad_accum_dtype": "fp32"}
engine = _init(cfg, hidden_dim=8)
with pytest.raises(NotImplementedError, match="optimizer wrapper"):
with engine.coalesce_grad_reduction():
pass
engine.destroy()
# ---------------------------------------------------------------------------
# MoE: ep_size=1 smoke test + ep_size=2 with world_size=4 for the real path
# ---------------------------------------------------------------------------
def _train_moe(config, hidden_dim, num_chunks, num_steps, use_no_sync, ep_size=1, seed=42):
torch.manual_seed(seed)
model = SimpleMoEModel(hidden_dim=hidden_dim, ep_size=ep_size)
engine, _, _, _ = deepspeed.initialize(config=config, model=model, model_parameters=model.parameters())
dtype = torch.bfloat16 if get_accelerator().is_bf16_supported() else torch.float16
batches = list(
sequence_dataloader(model=engine,
total_samples=num_chunks * num_steps,
hidden_dim=hidden_dim,
device=engine.device,
dtype=dtype))
for step_idx in range(num_steps):
step_batches = batches[step_idx * num_chunks:(step_idx + 1) * num_chunks]
ctx = engine.coalesce_grad_reduction() if use_no_sync else _NullCtx()
with ctx:
for i, batch in enumerate(step_batches):
loss = engine(batch[0], batch[1])
engine.set_gradient_accumulation_boundary(i == num_chunks - 1)
engine.backward(loss)
engine.step()
out = _params(engine)
engine.destroy()
return out
@pytest.mark.parametrize("zero_stage", [1, 2])
class TestCoalesceMoE_EpSize1(DistributedTest):
world_size = 2
def test_smoke(self, zero_stage):
config = _config(zero_stage, contiguous_gradients=(zero_stage == 2))
ref = _train_moe(config, hidden_dim=16, num_chunks=4, num_steps=1, use_no_sync=False, ep_size=1)
test = _train_moe(config, hidden_dim=16, num_chunks=4, num_steps=1, use_no_sync=True, ep_size=1)
_assert_close(ref, test, label=f"MoE ep1 ZeRO-{zero_stage}")
@pytest.mark.parametrize("zero_stage", [1, 2])
class TestCoalesceMoE_EpSize2(DistributedTest):
# ep_size=2 with world_size=4 exercises the real heterogeneous process
# group path (expert_dp_process_group differs from dp_process_group).
world_size = 4
def test_expert_parallel(self, zero_stage):
config = _config(zero_stage, world_size=4, contiguous_gradients=(zero_stage == 2))
ref = _train_moe(config, hidden_dim=16, num_chunks=4, num_steps=1, use_no_sync=False, ep_size=2)
test = _train_moe(config, hidden_dim=16, num_chunks=4, num_steps=1, use_no_sync=True, ep_size=2)
_assert_close(ref, test, label=f"MoE ep2 ZeRO-{zero_stage}")
# ---------------------------------------------------------------------------
# ZeRO-3 leaf modules: leaf_module_hook zero-fills missing grads; the flush
# must mirror that to keep the per-rank reduction signature consistent.
# ---------------------------------------------------------------------------
class TestCoalesceZero3LeafModule(DistributedTest):
"""ZeRO-3 + leaf-module + multi-backward is broken on the baseline path
(leaf hooks fire per backward and bucket-flush asserts on duplicate
ds_ids -- independent of this PR). Instead of attempting bit-exact
comparison against a broken baseline, we only verify that:
1. Flush works under N=1 (the usual case).
2. The flush's leaf-zero-fill mirror does not crash for unused leaf
params.
"""
world_size = 2
def test_leaf_module_n1(self):
cfg = _config(zero_stage=3)
with deepspeed.zero.Init(config_dict_or_path=cfg):
torch.manual_seed(42)
model = SimpleModel(hidden_dim=8, nlayers=2)
set_z3_leaf_modules(model, [torch.nn.Linear])
engine, _, _, _ = deepspeed.initialize(model=model, model_parameters=model.parameters(), config=cfg)
batch = next(iter(random_dataloader(model=engine, total_samples=1, hidden_dim=8, device=engine.device)))
with engine.coalesce_grad_reduction():
loss = engine(batch[0], batch[1])
engine.set_gradient_accumulation_boundary(True)
engine.backward(loss)
engine.step()
engine.destroy()
# ---------------------------------------------------------------------------
# Reentrant gradient checkpointing (use_reentrant=True): epilogue is invoked
# multiple times per backward for checkpointed regions.
# ---------------------------------------------------------------------------
# Reentrant gradient checkpointing (use_reentrant=True) is intentionally not
# tested here: combining `torch.utils.checkpoint(..., use_reentrant=True)` with
# DeepSpeed ZeRO + multi-backward + small models surfaces an upstream
# `loss.grad_fn is None` issue that is not specific to this PR. The coalesce
# path's hook accounting (max_expected_hooks_seen / hooks_fired_this_backward)
# is exercised by the existing tests via update_hook_state_and_maybe_run_epilogue.
# ---------------------------------------------------------------------------
# Failure modes: clear errors instead of silent state corruption
# ---------------------------------------------------------------------------
class TestCoalesceCollectiveCount(DistributedTest):
"""Validates the PR's central performance claim: coalesced backward issues
strictly fewer cross-rank gradient-reduction collectives than baseline.
Baseline: each engine.backward() drives the reducer hook -> dist.all_reduce
(ZeRO-1/2 grad-partition path uses dist.all_reduce or dist.reduce; ZeRO-3
uses dist.reduce_scatter_fn via reduce_scatter_coalesced), so the count
scales with N=num_chunks.
Coalesced: the per-param hooks are no-ops. Only the flush issues collectives,
so the count is independent of N.
We patch the actual primitives that the reducer calls (not just dist.all_reduce
/ dist.reduce_scatter; the latter is not a name DeepSpeed uses). Counting is
confined to the backward phase via a 'recording' flag so optimizer.step()'s
norm/overflow reductions (invariant across both runs) are excluded.
"""
world_size = 2
# ZeRO-1 with partition_gradients=False already issues exactly one
# collective per step (reduce_gradients fires only at the boundary), so
# baseline == coalesced for stage 1. The "N->1" claim only applies to
# stages 2 and 3 which reduce in per-param hooks.
@pytest.mark.parametrize("zero_stage", [2, 3])
def test_coalesce_issues_fewer_collectives(self, zero_stage):
import deepspeed.comm as dist
original_all_reduce = dist.all_reduce
original_reduce = dist.reduce
original_rs_fn = dist.reduce_scatter_fn
def run(use_no_sync, counts):
def all_reduce_counter(*a, **k):
if counts["recording"]:
counts["all_reduce"] += 1
return original_all_reduce(*a, **k)
def reduce_counter(*a, **k):
if counts["recording"]:
counts["reduce"] += 1
return original_reduce(*a, **k)
def rs_fn_counter(*a, **k):
if counts["recording"]:
counts["reduce_scatter_fn"] += 1
return original_rs_fn(*a, **k)
dist.all_reduce = all_reduce_counter
dist.reduce = reduce_counter
dist.reduce_scatter_fn = rs_fn_counter
try:
cfg = _config(zero_stage)
engine = _init(cfg, hidden_dim=8)
batches = list(
random_dataloader(model=engine,
total_samples=4,
hidden_dim=8,
device=engine.device,
dtype=_config_dtype(cfg)))
ctx = engine.coalesce_grad_reduction() if use_no_sync else _NullCtx()
counts["recording"] = True
with ctx:
for i, batch in enumerate(batches):
loss = engine(batch[0], batch[1])
engine.set_gradient_accumulation_boundary(i == 3)
engine.backward(loss)
counts["recording"] = False
engine.step()
engine.destroy()
finally:
dist.all_reduce = original_all_reduce
dist.reduce = original_reduce
dist.reduce_scatter_fn = original_rs_fn
baseline = {"all_reduce": 0, "reduce": 0, "reduce_scatter_fn": 0, "recording": False}
coalesced = {"all_reduce": 0, "reduce": 0, "reduce_scatter_fn": 0, "recording": False}
run(use_no_sync=False, counts=baseline)
run(use_no_sync=True, counts=coalesced)
baseline_total = baseline["all_reduce"] + baseline["reduce"] + baseline["reduce_scatter_fn"]
coalesced_total = coalesced["all_reduce"] + coalesced["reduce"] + coalesced["reduce_scatter_fn"]
assert baseline_total > 0, f"ZeRO-{zero_stage} baseline issued no collectives: {baseline}"
assert coalesced_total < baseline_total, (
f"ZeRO-{zero_stage}: coalesced issued {coalesced_total} collectives, "
f"baseline {baseline_total} -- coalesce did not reduce count. "
f"baseline={baseline} coalesced={coalesced}")
class TestCoalesceZero3MicroStepInvariant(DistributedTest):
"""ZeRO-3 partition_grads at stage3.py:1717 takes the copy_ path only when
micro_step_id == 0 (otherwise it does add_ on top of stale buffer state).
Therefore the coalesce-mode flush MUST observe micro_step_id == 0 to avoid
silently accumulating step k's partition into step k+1's buffer.
The chain that would normally bump micro_step_id during the coalesce block
is hook -> update_hook_state_and_maybe_run_epilogue -> _backward_post_hook
-> _backward_epilogue -> allreduce_gradients -> independent_gradient_partition_epilogue
(which sets _epilogue_ran_this_backward=True, picked up by next forward's
clear_backward_seen_flag to bump micro_step_id). The chain is broken at
engine.py:2480 by 'not self.inside_no_sync_ctxt'. This test pins down that
invariant by snapshotting micro_step_id at flush time across multiple
chunks and steps.
"""
world_size = 2
def test_micro_step_id_is_zero_at_flush(self):
cfg = _config(zero_stage=3)
engine = _init(cfg, hidden_dim=8)
observed = []
original = engine._flush_coalesced_reduction_zero3
def spy(optimizer):
observed.append(optimizer.micro_step_id)
return original(optimizer)
engine._flush_coalesced_reduction_zero3 = spy
batches = list(
random_dataloader(model=engine,
total_samples=12,
hidden_dim=8,
device=engine.device,
dtype=_config_dtype(cfg)))
for step in range(3):
with engine.coalesce_grad_reduction():
for i in range(4):
batch = batches[step * 4 + i]
loss = engine(batch[0], batch[1])
engine.set_gradient_accumulation_boundary(i == 3)
engine.backward(loss)
engine.step()
engine.destroy()
assert observed == [0, 0, 0], (f"micro_step_id at flush across 3 steps was {observed}; "
"expected all zeros. A non-zero value would force partition_grads "
"into the add_ branch and silently accumulate stale partition data "
"from the previous step (gradient corruption).")
def test_zero3_multi_step_diff_under_corruption_threshold(self):
# The hypothetical add_-path corruption (reviewer claim) would inject
# the previous step's full gradient partition into the current step's
# buffer, producing per-step divergence proportional to lr (Adam steps
# ~2x the intended size). Across 3 steps that is roughly 3 * lr = 3e-3.
# We assert the actual divergence is well under that threshold to
# falsify the corruption hypothesis (typical bf16 reduction-order noise
# is ~1e-3 over 3 steps).
cfg = _config(zero_stage=3)
ref = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=3, use_no_sync=False)
test = _train(cfg, hidden_dim=8, num_chunks=4, num_steps=3, use_no_sync=True)
_assert_close(ref, test, label="ZeRO-3 4x3 corruption-threshold", tol=2e-3)
class TestCoalesceFailureModes(DistributedTest):
world_size = 1
def test_step_inside_context_raises(self):
engine = _init(_config(zero_stage=2, world_size=1), hidden_dim=8)
with engine.coalesce_grad_reduction():
with pytest.raises(AssertionError, match="no_sync"):
engine.step()
engine.destroy()
def test_existing_no_sync_still_blocked_for_zero2(self):
engine = _init(_config(zero_stage=2, world_size=1), hidden_dim=8)
with pytest.raises(AssertionError, match="ZeRO stage"):
with engine.no_sync():
pass
engine.destroy()
def test_nested_no_sync_outer_coalesce_grad_reduction(self):
# Outer coalesce_grad_reduction must reject inner no_sync (and vice
# versa) since both share inside_no_sync_ctxt and the reentry would
# corrupt the outer flag on inner exit.
engine = _init(_config(zero_stage=1, world_size=1), hidden_dim=8)
with engine.coalesce_grad_reduction():
with pytest.raises(AssertionError, match="no_sync"):
with engine.no_sync():
pass
engine.destroy()
def test_nested_coalesce_grad_reduction_inside_no_sync(self):
engine = _init(_config(zero_stage=1, world_size=1), hidden_dim=8)
with engine.no_sync():
with pytest.raises(AssertionError, match="nested"):
with engine.coalesce_grad_reduction():
pass
engine.destroy()
def test_pipeline_parallelism_rejected(self, monkeypatch):
# No PipelineModule fixture in this test set; monkey-patch the
# pipeline_parallelism flag so the precheck path is exercised.
engine = _init(_config(zero_stage=2, world_size=1), hidden_dim=8)
monkeypatch.setattr(engine, "pipeline_parallelism", True)
with pytest.raises(NotImplementedError, match="pipeline parallelism"):
with engine.coalesce_grad_reduction():
pass
engine.destroy()
class TestCoalesceDocumentedBehaviors(DistributedTest):
"""These tests pin down behaviors the docstring promises but that could
silently regress: safe_get_full_grad returning local-only grads, optimizer
boundary state restored on context exit."""
world_size = 2
def test_safe_get_full_grad_returns_local_pre_reduce_value(self):
# Documented behavior: inside the context safe_get_full_grad reads
# the locally-accumulated param.grad (no all-reduce yet). Compare it
# against param.grad directly to confirm the path is local-only and
# not eagerly reducing.
from deepspeed.utils import safe_get_full_grad
engine = _init(_config(zero_stage=2), hidden_dim=8)
batch = next(
iter(
random_dataloader(model=engine,
total_samples=1,
hidden_dim=8,
device=engine.device,
dtype=torch.bfloat16 if get_accelerator().is_bf16_supported() else torch.float16)))
with engine.coalesce_grad_reduction():
loss = engine(batch[0], batch[1])
engine.set_gradient_accumulation_boundary(True)
engine.backward(loss)
for param in engine.module.parameters():
if not param.requires_grad or param.grad is None:
continue
full_grad = safe_get_full_grad(param)
assert full_grad is not None
# Same-data check: safe_get_full_grad must surface the same
# tensor that param.grad currently holds (the unreduced local
# value), not a freshly all-reduced full gradient.
assert torch.equal(full_grad, param.grad), \
"safe_get_full_grad returned a tensor different from local param.grad"
break
engine.step()
engine.destroy()
def test_engine_boundary_restored_and_step_works_on_exit(self):
# Enter the context with boundary=False (mid-accumulation pattern).
# On exit, the engine flag must be restored. The user is responsible
# for setting boundary=True before engine.step() -- verify that this
# contract is real (post-exit step does advance global_steps once
# the user toggles boundary back).
engine = _init(_config(zero_stage=2), hidden_dim=8)
engine.set_gradient_accumulation_boundary(False)
saved = engine._is_gradient_accumulation_boundary
with engine.coalesce_grad_reduction():
batch = next(
iter(
random_dataloader(
model=engine,
total_samples=1,
hidden_dim=8,
device=engine.device,
dtype=torch.bfloat16 if get_accelerator().is_bf16_supported() else torch.float16)))
engine.backward(engine(batch[0], batch[1]))
assert engine._is_gradient_accumulation_boundary == saved
prev_steps = engine.global_steps
engine.set_gradient_accumulation_boundary(True)
engine.step()
assert engine.global_steps == prev_steps + 1
engine.destroy()