# Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: Apache-2.0 # DeepSpeed Team import pytest import deepspeed.comm as dist import torch from unit.common import DistributedTest, preferred_dtype from unit.simple_model import random_dataloader import deepspeed from deepspeed.utils import set_z3_leaf_modules, unset_z3_leaf_modules, get_z3_leaf_modules, z3_leaf_module, \ set_z3_leaf_modules_by_name, set_z3_leaf_modules_by_suffix from deepspeed.runtime.zero.config import DeepSpeedZeroConfig from deepspeed.runtime.zero.leaf_module_config import (DEFAULT_LEAF_MODULE_CLASSES, DEFAULT_LEAF_MODULE_NAMES, DEFAULT_LEAF_MODULE_NAME_SUFFIXES) from deepspeed.accelerator import get_accelerator from torch import nn import time class ChooseModuleByCounter(torch.nn.Module): def __init__(self, hidden_dim): super(ChooseModuleByCounter, self).__init__() self.linears = torch.nn.ModuleList( [torch.nn.Linear(hidden_dim, hidden_dim, bias=False), torch.nn.Linear(hidden_dim, hidden_dim, bias=False)]) self.act = torch.nn.ReLU() self.cel = torch.nn.CrossEntropyLoss() self.counter = 0 def forward(self, x, y): # This fails without setting this module as a leaf module. # See the comment in `set_z3_leaf_modules()`. x = self.linears[self.counter % len(self.linears)](x) x = self.act(x) loss = self.cel(x, y) self.counter += 1 return x, loss class ChooseModuleByRankModel(torch.nn.Module): def __init__(self, hidden_dim): super(ChooseModuleByRankModel, self).__init__() self.linears = torch.nn.ModuleList( [torch.nn.Linear(hidden_dim, hidden_dim, bias=False), torch.nn.Linear(hidden_dim, hidden_dim, bias=False)]) self.act = torch.nn.ReLU() self.cel = torch.nn.CrossEntropyLoss() def forward(self, x, y): # Each rank runs only one of the linear layers x = self.linears[dist.get_rank() % len(self.linears)](x) x = self.act(x) loss = self.cel(x, y) return x, loss class MultiOutputMoEBlock(nn.Module): """A simplified MoE block that returns multiple tensors. This model mimics Qwen3 MoE which returns (hidden_states, router_logits). When used with ZeRO3 leaf modules and autograd multithreading enabled, this pattern previously caused race conditions in fetch_sub_module because backward hooks could be triggered concurrently from multiple threads. See: https://github.com/deepspeedai/DeepSpeed/issues/7824 """ def __init__(self, hidden_dim, num_experts=4): super(MultiOutputMoEBlock, self).__init__() self.num_experts = num_experts self.gate = nn.Linear(hidden_dim, num_experts, bias=False) self.experts = nn.ModuleList([nn.Linear(hidden_dim, hidden_dim, bias=False) for _ in range(num_experts)]) self.act = nn.ReLU() self.cel = nn.CrossEntropyLoss() def forward(self, x, y): # Compute router logits - this tensor will have gradients flowing through it router_logits = self.gate(x) # Process through experts for expert in self.experts: x = expert(x) x = self.act(x) loss = self.cel(x, y) # Return multiple tensors - this triggers concurrent backward hooks # when autograd multithreading is enabled return x, loss, router_logits class MLPBlock(nn.Module): def __init__(self, hidden_dim): super(MLPBlock, self).__init__() self.gate_proj = nn.Linear(hidden_dim, hidden_dim, bias=False) self.up_proj = nn.Linear(hidden_dim, hidden_dim, bias=False) self.down_proj = nn.Linear(hidden_dim, hidden_dim, bias=False) self.act_fn = nn.GELU() def forward(self, x): return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x)) class FineGrainedBlock(nn.Module): def __init__(self, hidden_dim, num_block): super(FineGrainedBlock, self).__init__() self.num_block = num_block self.mlp_layers = torch.nn.ModuleList([MLPBlock(hidden_dim=hidden_dim) for _ in range(self.num_block)]) def forward(self, x): for i in range(self.num_block): x = self.mlp_layers[i](x) return x class BaseLeafModule(nn.Module): def __init__(self): super(BaseLeafModule, self).__init__() class SubLeafModule(BaseLeafModule): def __init__(self, hidden_dim): super(SubLeafModule, self).__init__() self.proj = nn.Linear(hidden_dim, hidden_dim) def forward(self, x): return self.proj(x) class WrapperLeafModule(nn.Module): def __init__(self, hidden_dim): super(WrapperLeafModule, self).__init__() self.child = SubLeafModule(hidden_dim) def forward(self, x): return self.child(x) def test_set_leaf_modules_with_fully_qualified_name(): hidden_dim = 16 model = WrapperLeafModule(hidden_dim) fq_name = f"{SubLeafModule.__module__}.{SubLeafModule.__qualname__}" matched = set_z3_leaf_modules(model, [fq_name]) assert len(matched) == 1 assert matched[0] is model.child assert z3_leaf_module(model.child) assert not z3_leaf_module(model) def test_set_leaf_modules_no_raise_when_missing(): hidden_dim = 16 model = WrapperLeafModule(hidden_dim) matched = set_z3_leaf_modules(model, ["NonExistentClass"], raise_if_not_found=False) assert matched == [] assert not z3_leaf_module(model.child) def test_set_leaf_modules_by_name(): hidden_dim = 16 model = WrapperLeafModule(hidden_dim) matched, missing = set_z3_leaf_modules_by_name(model, ["child"]) assert matched == [model.child] assert missing == [] assert z3_leaf_module(model.child) def test_set_leaf_modules_by_name_missing(): hidden_dim = 16 model = WrapperLeafModule(hidden_dim) matched, missing = set_z3_leaf_modules_by_name(model, ["missing"], raise_if_not_found=False) assert matched == [] assert missing == ["missing"] def test_set_leaf_modules_by_suffix(): hidden_dim = 16 model = WrapperLeafModule(hidden_dim) matched, missing = set_z3_leaf_modules_by_suffix(model, ["child"]) assert missing == [] assert matched == [model.child] assert z3_leaf_module(model.child) def test_set_leaf_modules_by_suffix_missing(): hidden_dim = 16 model = WrapperLeafModule(hidden_dim) matched, missing = set_z3_leaf_modules_by_suffix(model, ["missing"], raise_if_not_found=False) assert matched == [] assert missing == ["missing"] def test_zero_leaf_module_default_config(): config = DeepSpeedZeroConfig() assert config.leaf_module.classes == DEFAULT_LEAF_MODULE_CLASSES assert config.leaf_module.names == DEFAULT_LEAF_MODULE_NAMES assert config.leaf_module.name_suffixes == DEFAULT_LEAF_MODULE_NAME_SUFFIXES def test_zero_leaf_module_custom_config(): payload = { "leaf_module": { "classes": ["custom.module.CustomClass"], "names": ["transformer.layer"], "name_suffixes": ["experts"] } } config = DeepSpeedZeroConfig(**payload) assert config.leaf_module.classes == ["custom.module.CustomClass"] assert config.leaf_module.names == ["transformer.layer"] assert config.leaf_module.name_suffixes == ["experts"] def test_zero_leaf_module_string_coercion(): payload = {"leaf_module": {"classes": "my.Class", "names": "submodule", "name_suffixes": "tail"}} config = DeepSpeedZeroConfig(**payload) assert config.leaf_module.classes == ["my.Class"] assert config.leaf_module.names == ["submodule"] assert config.leaf_module.name_suffixes == ["tail"] @pytest.mark.skip(reason="Requires Hugging Face transformers; run manually when validating defaults.") def test_default_leaf_module_classes_exist(): import importlib from deepspeed.runtime.zero.leaf_module_config import DEFAULT_LEAF_MODULE_CLASSES for cls_path in DEFAULT_LEAF_MODULE_CLASSES: module_name, _, class_name = cls_path.rpartition('.') module = importlib.import_module(module_name) assert hasattr(module, class_name), f"Expected {class_name} in {module_name}" class modelWithFineGrainedBlock(nn.Module): def __init__(self, hidden_dim, num_block): super(modelWithFineGrainedBlock, self).__init__() self.coarse_grained_layer1 = nn.Linear(hidden_dim, 8 * hidden_dim) self.coarse_grained_layer2 = nn.Linear(8 * hidden_dim, hidden_dim) self.fine_grained_layer = FineGrainedBlock(hidden_dim, num_block) self.cel = torch.nn.CrossEntropyLoss() def forward(self, x, y): x = self.coarse_grained_layer1(x) x = self.coarse_grained_layer2(x) x = self.fine_grained_layer(x) loss = self.cel(x, y) return x, loss def run_model(model, config_dict, hidden_dim, dtype, requires_grad): model, _, _, _ = deepspeed.initialize(model=model, model_parameters=model.parameters(), config=config_dict) data_loader = random_dataloader(model=model, total_samples=10, hidden_dim=hidden_dim, device=model.device, dtype=dtype) dist.barrier() for batch in data_loader: batch[0].requires_grad = requires_grad loss = model(batch[0], batch[1]) loss = loss[1] model.backward(loss) model.step() # Needed in ZeRO 3. Not doing so can give memory leak model.destroy() class TestSetZ3LeafModule(DistributedTest): # Need multiple gpus to test possible hanging world_size = 2 reuse_dist_env = True def _create_zero_config(self, hidden_dim, leaf_module=None): config_dict = { "train_micro_batch_size_per_gpu": 1, "steps_per_print": 1, "optimizer": { "type": "Adam", "params": { "lr": 1e-6 } }, "zero_optimization": { "stage": 3, "stage3_prefetch_bucket_size": hidden_dim**2, "stage3_param_persistence_threshold": 0, "stage3_max_reuse_distance": 0, } } if leaf_module is not None: config_dict["zero_optimization"]["leaf_module"] = leaf_module if preferred_dtype() is torch.float16: config_dict["fp16"] = {"enabled": True} elif preferred_dtype() is torch.bfloat16: config_dict["bf16"] = {"enabled": True} return config_dict def _test_set_z3_leaf_modules(self, cls, requires_grad): hidden_dim = 128 config_dict = self._create_zero_config(hidden_dim) model = cls(hidden_dim) assert not z3_leaf_module(model) set_z3_leaf_modules(model, [cls]) assert z3_leaf_module(model) run_model(model, config_dict, hidden_dim, preferred_dtype(), requires_grad) def test_choose_module_by_counter(self): self._test_set_z3_leaf_modules(ChooseModuleByCounter, True) def test_choose_module_by_rank(self): self._test_set_z3_leaf_modules(ChooseModuleByRankModel, True) def test_multi_output_leaf_module_thread_safety(self): """Test that leaf modules returning multiple tensors work correctly with autograd multithreading. This tests the fix for https://github.com/deepspeedai/DeepSpeed/issues/7824 where MoE models (like Qwen3) returning multiple tensors caused race conditions in fetch_sub_module when autograd executed backward hooks from multiple threads. """ # Ensure autograd multithreading is enabled (this is the default, but be explicit) torch.autograd.set_multithreading_enabled(True) hidden_dim = 128 config_dict = self._create_zero_config(hidden_dim) model = MultiOutputMoEBlock(hidden_dim, num_experts=4) assert not z3_leaf_module(model) set_z3_leaf_modules(model, [MultiOutputMoEBlock]) assert z3_leaf_module(model) model, _, _, _ = deepspeed.initialize(model=model, model_parameters=model.parameters(), config=config_dict) data_loader = random_dataloader(model=model, total_samples=10, hidden_dim=hidden_dim, device=model.device, dtype=preferred_dtype()) dist.barrier() # Run multiple iterations to increase chance of hitting race conditions for batch in data_loader: batch[0].requires_grad = True # Model returns (output, loss, router_logits) output, loss, router_logits = model(batch[0], batch[1]) # Include router_logits in the loss to ensure multiple backward paths total_loss = loss + 0.01 * router_logits.mean() model.backward(total_loss) model.step() model.destroy() def test_multi_output_non_leaf_module_thread_safety(self): """Ensure non-leaf modules returning multiple tensors remain thread-safe. This covers the multi-output autograd multithreading case without marking the module as a ZeRO leaf module. """ torch.autograd.set_multithreading_enabled(True) hidden_dim = 128 config_dict = self._create_zero_config(hidden_dim) model = MultiOutputMoEBlock(hidden_dim, num_experts=4) assert not z3_leaf_module(model) model, _, _, _ = deepspeed.initialize(model=model, model_parameters=model.parameters(), config=config_dict) data_loader = random_dataloader(model=model, total_samples=10, hidden_dim=hidden_dim, device=model.device, dtype=preferred_dtype()) dist.barrier() for batch in data_loader: batch[0].requires_grad = True output, loss, router_logits = model(batch[0], batch[1]) total_loss = loss + 0.01 * router_logits.mean() model.backward(total_loss) model.step() model.destroy() def test_no_grad_input_error(self): try: self._test_set_z3_leaf_modules(ChooseModuleByCounter, False) raise AssertionError( "Expected RuntimeError: inputs with requires_grad=False is not supported for a leaf module") except RuntimeError as e: pass def test_set_unset_leaf_modules(self): hidden_dim = 128 model = ChooseModuleByCounter(hidden_dim) assert len(set_z3_leaf_modules(model, [torch.nn.ModuleList])) == 1, \ "Expected only one module to be set as a leaf module" assert len(get_z3_leaf_modules(model)) == 1, "Expected there is only one leaf module" assert len(unset_z3_leaf_modules(model, [torch.nn.ModuleList])) == 1, \ "Expected only one module to be unset as a leaf module" assert len(get_z3_leaf_modules(model)) == 0, "Expected there is no leaf module" def test_set_leaf_modules_with_subclass(self): hidden_dim = 32 model = WrapperLeafModule(hidden_dim) leaf_modules = set_z3_leaf_modules(model, [BaseLeafModule]) assert len(leaf_modules) == 1, "Expected the subclass instance to be marked as leaf" assert leaf_modules[0] is model.child, "Expected the subclass instance to be returned" assert z3_leaf_module(model.child), "Expected subclass instance flagged as leaf" assert not z3_leaf_module(model), "Expected wrapper module to remain non-leaf" def test_set_no_match_class(self): hidden_dim = 128 model = ChooseModuleByCounter(hidden_dim) try: set_z3_leaf_modules(model, [torch.nn.Conv2d]) raise AssertionError("Expected error that no module is set as a leaf module") except ValueError as e: pass def test_leaf_module_enabled_via_config(self): hidden_dim = 128 leaf_class_fqn = f"{ChooseModuleByCounter.__module__}.{ChooseModuleByCounter.__qualname__}" config_dict = self._create_zero_config(hidden_dim, leaf_module={ "classes": [leaf_class_fqn], "name_suffixes": ["linears"] }) model = ChooseModuleByCounter(hidden_dim) assert not z3_leaf_module(model) run_model(model, config_dict, hidden_dim, preferred_dtype(), True) assert z3_leaf_module(model) modules_by_name = dict(model.named_modules()) assert "linears" in modules_by_name assert z3_leaf_module(modules_by_name["linears"]) @pytest.mark.parametrize("module_granularity_threshold", [0, 100, 12100, 10000000]) class TestZ3LeafOptimization(DistributedTest): world_size = 2 reuse_dist_env = True def test_finegrained_optimization(self, module_granularity_threshold: int): hidden_dim = 128 num_block = 16 config_dict = { "train_micro_batch_size_per_gpu": 1, "steps_per_print": 1, "optimizer": { "type": "Adam", "params": { "lr": 1e-6 } }, "zero_optimization": { "stage": 3, "stage3_prefetch_bucket_size": hidden_dim**2, "stage3_param_persistence_threshold": 0, "stage3_max_reuse_distance": 0, } } if preferred_dtype() is torch.float16: config_dict["fp16"] = {"enabled": True} elif preferred_dtype() is torch.bfloat16: config_dict["bf16"] = {"enabled": True} def bench_loss_and_time(config): warm_up_step = 10 model = modelWithFineGrainedBlock(hidden_dim, num_block) model, _, _, _ = deepspeed.initialize(model=model, model_parameters=model.parameters(), config=config) data_loader = random_dataloader(model=model, total_samples=20, hidden_dim=hidden_dim, device=model.device, dtype=preferred_dtype()) dist.barrier() loss_list = [] for i, batch in enumerate(data_loader): if i == warm_up_step: dist.barrier() get_accelerator().synchronize() start_time = time.time() batch[0].requires_grad = True loss = model(batch[0], batch[1]) loss = loss[1] loss_list.append(loss) model.backward(loss) model.step() get_accelerator().synchronize() end_time = time.time() duration = end_time - start_time model.destroy() return loss_list, duration baseline_loss_list, baseline_exec_time = bench_loss_and_time(config_dict) config_dict["zero_optimization"]["stage3_module_granularity_threshold"] = module_granularity_threshold loss, duration = bench_loss_and_time(config_dict) if dist.get_rank() == 0: print("baseline exec time:", baseline_exec_time) print( f"finegrained optimziation exec time: {duration},granularity threshold:{module_granularity_threshold} " ) assert baseline_loss_list == loss, f"incorrect loss value with threshold:{module_granularity_threshold}"