Skip to content

Commit 4f7f9c0

Browse files
committed
Add TP to FSDP and HSDP functional tests.
Signed-off-by: Cory Ye <cye@nvidia.com>
1 parent 1d13b2a commit 4f7f9c0

8 files changed

Lines changed: 203 additions & 33 deletions

File tree

tests/pytorch/distributed/run_fsdp2_model.py

Lines changed: 176 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@
77
import os
88
import sys
99
import argparse
10+
from dataclasses import dataclass
1011

1112
import transformer_engine.pytorch as te
1213
import transformer_engine.common.recipe
1314

1415
import torch
1516
import torch.distributed as dist
17+
from torch.distributed.checkpoint import save, load
18+
from torch.distributed.checkpoint.state_dict import (
19+
StateDictOptions,
20+
get_state_dict,
21+
set_state_dict,
22+
)
23+
from torch.distributed.checkpoint.stateful import Stateful
1624
from torch.distributed.tensor import DTensor
1725
import torch.nn.functional as F
1826
from torch import nn, optim
@@ -25,6 +33,61 @@
2533
LOCAL_RANK = None
2634

2735

36+
@dataclass
37+
class AppState(Stateful):
38+
"""AppState for FSDP2 checkpoint via Torch DCP.
39+
40+
Adapted from https://docs.pytorch.org/tutorials/recipes/distributed_checkpoint_recipe.html
41+
"""
42+
43+
model: torch.nn.Module
44+
optimizer: torch.optim.Optimizer
45+
46+
def state_dict(self):
47+
"""
48+
Get the state dict for the model, optimizer, scheduler, and step.
49+
This factory both retrieves the model state dictionary when saving
50+
checkpoints and initializes a destination for the state read from
51+
DCP checkpoint files when loading checkpoints.
52+
"""
53+
model_state_dict, optimizer_state_dict = get_state_dict(self.model, self.optimizer)
54+
for fqn in list(model_state_dict.keys()):
55+
# Get the model parameter.
56+
model_param = model_state_dict[fqn]
57+
if isinstance(model_param, DTensor):
58+
model_param = model_param.to_local()
59+
if model_param.numel() == 0 and fqn in optimizer_state_dict["state"]:
60+
# Empty model parameter. Clear the associated optimizer state
61+
# when initializing the optimizer state upon DCP load, because
62+
# empty optimizer state DTensors are not checkpointed with DCP,
63+
# yet get_state_dict / _init_optim_state produce empty Tensors.
64+
# TransformerEngine uses empty Tensors for dummy Parameters.
65+
optimizer_state_dict["state"][fqn] = {}
66+
if fqn.endswith("._extra_state"):
67+
# Evict `_extra_state` quantization data from model checkpoint.
68+
model_state_dict.pop(fqn)
69+
return {
70+
"model": model_state_dict,
71+
"optim": optimizer_state_dict,
72+
}
73+
74+
def load_state_dict(self, state_dict: dict):
75+
"""
76+
Load the state dict for the model, optimizer, scheduler, and step.
77+
Given the checkpoint-loaded state_dict, set the state of the model,
78+
optimizer, scheduler, step, and epoch to the values in state_dict.
79+
"""
80+
set_state_dict(
81+
self.model,
82+
self.optimizer,
83+
model_state_dict=state_dict["model"],
84+
optim_state_dict=state_dict["optim"],
85+
# Non-strict checkpoint loading ignores empty optimizer states,
86+
# skips loading non-FP8 checkpoint weights (e.g. _extra_state).
87+
options=StateDictOptions(strict=False),
88+
)
89+
90+
2891
def dist_print(msg):
2992
if LOCAL_RANK == 0:
3093
print(msg)
@@ -86,11 +149,16 @@ def _parse_args(argv=None, namespace=None):
86149
"--sharding-dims",
87150
type=int,
88151
nargs="+",
89-
help='FSDP/HSDP sharding dimensions ("replicate", "shard")',
152+
help='FSDP/HSDP sharding dimensions ("dp_replicate", "dp_shard", "tp")',
90153
)
91154
args = parser.parse_args(argv, namespace)
92155
if args.sharding_dims:
93-
assert len(args.sharding_dims) <= 2
156+
assert len(args.sharding_dims) <= 3
157+
if len(args.sharding_dims) >= 3:
158+
# Set the TP size in args.
159+
args.tp_size = args.sharding_dims[2]
160+
else:
161+
args.tp_size = 1
94162
return args
95163

96164

@@ -133,11 +201,17 @@ def init_te_model(config):
133201
"params_dtype": params_dtype,
134202
}
135203
kwargs["device"] = config.device
204+
kwargs["tp_size"] = config.tp_size
136205

137206
layer_type = get_te_layer_from_string(config.layer_type)
138207
# We are creating model in a way so that we can test both reshard_after_forward=True/False cases.
139208
# more details below.
140-
if layer_type in [te.MultiheadAttention, te.TransformerLayer]:
209+
if layer_type in [
210+
te.TransformerLayer,
211+
te.MultiheadAttention,
212+
te.LayerNormMLP,
213+
# TODO(@cspades): GroupedLinear testing.
214+
]:
141215
# For this case, we are creating a model that resemebles production use-cases
142216
# wherein there are mltiple TransformerLayers in the model. And we would need
143217
# to shard each transformer layer. Since each transformer layer is not a root module,
@@ -147,44 +221,102 @@ def init_te_model(config):
147221
kwargs["fuse_qkv_params"] = True
148222
if layer_type is te.MultiheadAttention:
149223
kwargs["input_layernorm"] = True
224+
# DeviceMesh / DTensor-related model parameter operations!
225+
# NOTE(@cspades): `set_device_mesh` works, but needs to be called before reset_parameters.
226+
# If not using meta device initialization, reset_parameters is called during __init__.
227+
if config.tp_size > 1:
228+
assert "dp_shard" in config.mesh.mesh_dim_names
229+
assert "tp" in config.mesh.mesh_dim_names
230+
dist_print(f"Tensor parallelism activated with size: {config.tp_size}")
231+
# Activate TP in TE.
232+
kwargs["set_parallel_mode"] = True
233+
# For TP shards as DTensors.
234+
kwargs["tp_mesh"] = config.mesh["tp"]
235+
# For per-tensor quantization recipes with TP.
236+
kwargs["weight_mesh"] = config.mesh["dp_shard", "tp"]._flatten("weight_mesh")
237+
elif len(config.mesh.mesh_dim_names) > 1:
238+
assert "dp_shard" in config.mesh.mesh_dim_names
239+
# HSDP (DP-Repl, DP-Shard) requires a call to `set_device_mesh(weight_mesh)`.
240+
# Used for per-tensor quantization recipes like Float8CurrentScaling.
241+
kwargs["weight_mesh"] = config.mesh["dp_shard"] # Only sharding with FSDP.
242+
# Initialize model.
150243
model = nn.Sequential(*[layer_type(*args, **kwargs) for _ in range(config.num_layers)])
151-
elif layer_type == te.LayerNormLinear:
244+
elif layer_type in [te.LayerNormLinear, te.Linear]:
152245
# For this case, we are creating a model with just one LayerNormLinear layer
153246
# so that the model itself is a root module, and FSDP2's fully_shard assigns
154247
# reshard_after_forward=True for the parameters of these model.
155248
args[1] *= 3 # QKV projection
156249
out_shape[-1] *= 3
250+
# DeviceMesh / DTensor-related model parameter operations!
251+
# NOTE(@cspades): `set_device_mesh` works, but needs to be called before reset_parameters.
252+
# If not using meta device initialization, reset_parameters is called during __init__.
253+
if config.tp_size > 1:
254+
assert "dp_shard" in config.mesh.mesh_dim_names
255+
assert "tp" in config.mesh.mesh_dim_names
256+
dist_print(f"Tensor parallelism activated with size: {config.tp_size}")
257+
# Activate TP in TE.
258+
kwargs["parallel_mode"] = "column"
259+
# For TP shards as DTensors.
260+
kwargs["tp_mesh"] = config.mesh["tp"]
261+
# For per-tensor quantization recipes with TP.
262+
kwargs["weight_mesh"] = config.mesh["dp_shard", "tp"]._flatten("weight_mesh")
263+
# Modify output shape for column-parallel Linear.
264+
out_shape[-1] //= config.tp_size
265+
elif len(config.mesh.mesh_dim_names) > 1:
266+
assert "dp_shard" in config.mesh.mesh_dim_names
267+
# HSDP (DP-Repl, DP-Shard) requires a call to `set_device_mesh(weight_mesh)`.
268+
# Used for per-tensor quantization recipes like Float8CurrentScaling.
269+
kwargs["weight_mesh"] = config.mesh["dp_shard"] # Only sharding with FSDP.
270+
# Initialize model.
157271
model = layer_type(*args, **kwargs)
158272
else:
273+
# Other TE module. Just ambiguously initialize it.
159274
model = layer_type(*args, **kwargs)
160275

161276
return model, inp_shape, out_shape
162277

163278

164279
def get_device_mesh(world_size, sharding_dims):
165-
dist_print(f"sharding-dims:{sharding_dims}")
280+
dist_print(f"sharding-dims: {sharding_dims}")
166281
device_ids = list(range(world_size))
167-
if sharding_dims is None: # FSDP
168-
mesh = DeviceMesh("cuda", device_ids)
169-
elif len(sharding_dims) == 1:
170-
assert sharding_dims[0] == world_size
171-
mesh = DeviceMesh("cuda", device_ids)
172-
elif len(sharding_dims) == 2: # HSDP
282+
# FSDP
283+
if sharding_dims is None or len(sharding_dims) == 1:
284+
assert sharding_dims is None or sharding_dims[0] == world_size
285+
mesh = init_device_mesh(
286+
"cuda",
287+
(world_size,),
288+
mesh_dim_names=("dp_shard",),
289+
)
290+
# HSDP
291+
elif len(sharding_dims) == 2:
173292
assert sharding_dims[0] * sharding_dims[1] == world_size
174293
mesh = init_device_mesh(
175294
"cuda",
176295
(sharding_dims[0], sharding_dims[1]),
177-
mesh_dim_names=("replicate", "shard"),
296+
mesh_dim_names=("dp_replicate", "dp_shard"),
297+
)
298+
# (H/F)SDP-TP
299+
elif len(sharding_dims) == 3:
300+
assert sharding_dims[0] * sharding_dims[1] * sharding_dims[2] == world_size
301+
mesh = init_device_mesh(
302+
"cuda",
303+
(sharding_dims[0], sharding_dims[1], sharding_dims[2]),
304+
mesh_dim_names=("dp_replicate", "dp_shard", "tp"),
178305
)
179306
else:
307+
# Unsupported topology.
180308
assert False
181309
return mesh
182310

183311

184312
def shard_model_with_fsdp2(model, mesh):
313+
assert "dp_shard" in mesh.mesh_dim_names
314+
dp_dims = (
315+
("dp_replicate", "dp_shard") if "dp_replicate" in mesh.mesh_dim_names else ("dp_shard",)
316+
)
185317
for child in model.children():
186-
fully_shard(child, mesh=mesh)
187-
fully_shard(model, mesh=mesh)
318+
fully_shard(child, mesh=mesh[dp_dims])
319+
fully_shard(model, mesh=mesh[dp_dims])
188320
return model
189321

190322

@@ -213,16 +345,18 @@ def restore_custom_attrs(module, custom_attrs):
213345

214346
@torch.no_grad()
215347
def test_fp8_fsdp2_allgather(model):
216-
# Do manual allgather in fp32 and match against fp8 allgather done
217-
# with fsdp2
348+
"""
349+
Compare the result of the FP8 AG by FSDP2 with a manual AG in FP32
350+
after dequantizing the FP8 values.
351+
"""
218352
# FP32 manual weight allgather
219353
fp32_allgathered_params = {}
220354
for name, param in model.named_parameters():
221355
assert isinstance(param, DTensor)
222356
local_tensor = param._local_tensor
223357
device_mesh = param.device_mesh
224358
dist_group = (
225-
device_mesh.get_group(mesh_dim="shard")
359+
device_mesh.get_group(mesh_dim="dp_shard")
226360
if device_mesh.ndim > 1
227361
else device_mesh.get_group()
228362
)
@@ -241,6 +375,10 @@ def test_fp8_fsdp2_allgather(model):
241375
module.unshard()
242376
# Make sure allgathered parameters match exactly
243377
for name, param in model.named_parameters():
378+
if isinstance(param, DTensor):
379+
# Will still be a DTensor in the case of TP, even after FSDP2 AG,
380+
# because we wrap our weights as DTensor shards over the TP group.
381+
param = param._local_tensor
244382
torch.testing.assert_close(param.dequantize(), fp32_allgathered_params[name])
245383
# Revert model to original sharded state
246384
for module in model.modules():
@@ -250,6 +388,9 @@ def test_fp8_fsdp2_allgather(model):
250388

251389

252390
def _train(args):
391+
"""
392+
Torch Distributed Initialization
393+
"""
253394
global LOCAL_RANK
254395
assert "TORCHELASTIC_RUN_ID" in os.environ
255396
WORLD_RANK = int(os.getenv("RANK", "0"))
@@ -274,9 +415,19 @@ def _train(args):
274415
nccl_world = dist.new_group(backend="nccl")
275416
device = torch.device(f"cuda:{LOCAL_RANK}")
276417

418+
# Create a DeviceMesh for fully_shard.
419+
world_size = int(WORLD_SIZE)
420+
# Setup the sharding mesh for FSDP/HSDP.
421+
mesh = get_device_mesh(world_size, args.sharding_dims)
422+
args.mesh = mesh
423+
424+
"""
425+
TransformerEngine Model Initialization
426+
"""
277427
# FP8 Configuration
278428
fp8_recipe = get_recipe_from_string(args.recipe)
279429

430+
# Model initialization context.
280431
build_model_context_args = {}
281432
if not args.fp8_init:
282433
# Build model context (FP8 init)
@@ -297,18 +448,17 @@ def _train(args):
297448
f" {torch.cuda.memory_allocated(device) / 1e6} MB"
298449
)
299450

300-
# Creating a DeviceMesh for fully_shard
301-
world_size = int(WORLD_SIZE)
302-
# Setup the sharding mesh for FSDP/HSDP
303-
mesh = get_device_mesh(world_size, args.sharding_dims)
451+
# Avoid passing custom attributes to FSDP2.
304452
custom_attrs = save_custom_attrs(model)
453+
# Fully-shard the model. Will convert model parameters into DTensor
454+
# if not already converted by TP.
305455
model = shard_model_with_fsdp2(model, mesh)
456+
# Restore custom attributes on parameters.
306457
restore_custom_attrs(model, custom_attrs)
307-
# model now has DTensors as its parameters
308458

309459
if args.device == "meta":
310460
# After FSDP2 has been applied, materialize and initialize the sharded parameters
311-
# TE base.py's reset_parameters() handles DTensors with FP8 initialization
461+
# TE base.py's reset_parameters() handles DTensors with FP8 initialization.
312462
for module in model.modules():
313463
if hasattr(module, "reset_parameters"):
314464
module.reset_parameters()
@@ -320,6 +470,9 @@ def _train(args):
320470

321471
optimizer = optim.Adam(model.parameters(), lr=1e-3)
322472

473+
"""
474+
Pre-Save Training
475+
"""
323476
for iteration in range(args.iter):
324477
# Zero the parameter gradients
325478
optimizer.zero_grad()

tests/pytorch/distributed/test_torch_fsdp2.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,25 +64,31 @@ def fp_recipe(request):
6464
def _run_test(fp_init, sharding_dims, recipe, layer_type):
6565
test_path = Path(__file__).parent.resolve() / "run_fsdp2_model.py"
6666
test_cmd = ["torchrun", f"--nproc_per_node={NUM_PROCS}", str(test_path)]
67-
6867
if fp_init:
6968
test_cmd += ["--fp8-init"]
70-
71-
if len(sharding_dims) == 1:
72-
test_cmd += ["--sharding-dims", str(sharding_dims[0])]
73-
elif len(sharding_dims) == 2:
74-
test_cmd += ["--sharding-dims", str(sharding_dims[0]), str(sharding_dims[1])]
75-
else:
76-
assert False
69+
test_cmd += ["--sharding-dims"]
70+
for x in sharding_dims:
71+
test_cmd.append(str(x))
7772
test_cmd += ["--recipe", recipe]
7873
test_cmd += ["--layer-type", layer_type]
79-
8074
subprocess.run(test_cmd, env=os.environ, check=True)
8175

8276

8377
@pytest.mark.skipif(NUM_PROCS % 2 != 0, reason="Requires even number of GPUs")
8478
@pytest.mark.skipif(not te.torch_version() >= (2, 4, 0), reason="Requires PyTorch 2.4.0+")
85-
@pytest.mark.parametrize("sharding_dims", ([NUM_PROCS], [2, NUM_PROCS // 2]))
79+
@pytest.mark.parametrize(
80+
"sharding_dims",
81+
(
82+
# FSDP
83+
[NUM_PROCS],
84+
# HSDP
85+
[2, NUM_PROCS // 2],
86+
# FSDP-TP
87+
[1, 2, NUM_PROCS // 2],
88+
# HSDP-TP
89+
[NUM_PROCS // 4, 2, 2],
90+
),
91+
)
8692
@pytest.mark.parametrize("fp8_init", (False, True))
8793
@pytest.mark.parametrize("layer_type", ("LayerNormLinear", "TransformerLayer"))
8894
def test_distributed(fp8_init, sharding_dims, fp_recipe, layer_type):

transformer_engine/pytorch/attention/multi_head_attention.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ class MultiheadAttention(torch.nn.Module):
203203
For example:
204204
- device_mesh["dp"] for FSDP.
205205
- device_mesh["dp_cp"] if using CP ranks in FSDP.
206+
- device_mesh["dp_shard"] if using HSDP ("dp_replicate", "dp_shard").
206207
- device_mesh["tp"] if using TP.
207208
- device_mesh["dp_cp_tp"] if strided-sharding with FSDP-TP.
208209
@@ -643,6 +644,7 @@ def set_device_mesh(
643644
For example:
644645
- device_mesh["dp"] for FSDP.
645646
- device_mesh["dp_cp"] if using CP ranks in FSDP.
647+
- device_mesh["dp_shard"] if using HSDP ("dp_replicate", "dp_shard").
646648
- device_mesh["tp"] if using TP.
647649
- device_mesh["dp_cp_tp"] if strided-sharding with FSDP-TP.
648650
"""

transformer_engine/pytorch/module/grouped_linear.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,7 @@ def set_device_mesh(
864864
For example:
865865
- device_mesh["dp"] for FSDP.
866866
- device_mesh["dp_cp"] if using CP ranks in FSDP.
867+
- device_mesh["dp_shard"] if using HSDP ("dp_replicate", "dp_shard").
867868
- device_mesh["tp"] if using TP.
868869
- device_mesh["dp_cp_tp"] if strided-sharding with FSDP-TP.
869870
"""

0 commit comments

Comments
 (0)