44#
55# See LICENSE for license information.
66
7+ import argparse
78import os
89import sys
9- import argparse
10+ import shutil
11+ from contextlib import nullcontext
12+ from copy import deepcopy
1013from dataclasses import dataclass
14+ from pathlib import Path
1115
1216import transformer_engine .pytorch as te
17+ from transformer_engine .pytorch import QuantizedTensor
1318from transformer_engine .common .recipe import (
1419 Format ,
1520 DelayedScaling ,
3237from torch .distributed import DeviceMesh
3338from torch .distributed ._composable .fsdp import fully_shard
3439from torch .distributed .device_mesh import init_device_mesh
35- from transformer_engine .pytorch import QuantizedTensor
36- from contextlib import nullcontext
3740
3841LOCAL_RANK = None
3942
43+ # Needed for `torch.distributed.checkpoint.{save,load}` because
44+ # multiple processes need to write to the same directory.
45+ SHARED_TMP_DIR = "/tmp/pytest-shared-tmp"
46+
4047
4148@dataclass
4249class AppState (Stateful ):
@@ -68,7 +75,7 @@ def state_dict(self):
6875 # yet get_state_dict / _init_optim_state produce empty Tensors.
6976 # TransformerEngine uses empty Tensors for dummy Parameters.
7077 optimizer_state_dict ["state" ][fqn ] = {}
71- if fqn .endswith (". _extra_state" ):
78+ if fqn .endswith ("_extra_state" ):
7279 # Evict `_extra_state` quantization data from model checkpoint.
7380 model_state_dict .pop (fqn )
7481 return {
@@ -355,7 +362,9 @@ def test_fp8_fsdp2_allgather(model):
355362 # FP32 manual weight allgather
356363 fp32_allgathered_params = {}
357364 for name , param in model .named_parameters ():
358- assert isinstance (param , DTensor )
365+ assert isinstance (
366+ param , DTensor
367+ ), f"[test_fp8_fsdp2_allgather] { param } should be a DTensor."
359368 local_tensor = param ._local_tensor
360369 device_mesh = param .device_mesh
361370 dist_group = (
@@ -382,7 +391,11 @@ def test_fp8_fsdp2_allgather(model):
382391 # Will still be a DTensor in the case of TP, even after FSDP2 AG,
383392 # because we wrap our weights as DTensor shards over the TP group.
384393 param = param ._local_tensor
385- assert torch .allclose (param .dequantize (), fp32_allgathered_params [name ])
394+ dequantized_param = param .dequantize ()
395+ assert torch .allclose (dequantized_param , fp32_allgathered_params [name ]), (
396+ f"Dequantized parameter ({ dequantized_param } ) is not close to "
397+ f"FP32 AG parameter ({ fp32_allgathered_params [name ]} )!"
398+ )
386399 # Revert model to original sharded state
387400 for module in model .modules ():
388401 # Not all modules are wrapped/sharded with FSDP2.
@@ -475,7 +488,7 @@ def _train(args):
475488 optimizer = optim .Adam (model .parameters (), lr = 1e-3 )
476489
477490 """
478- Pre-Save Training
491+ FSDP2 Training
479492 """
480493 for iteration in range (args .iter ):
481494 # Zero the parameter gradients
@@ -494,6 +507,131 @@ def _train(args):
494507 if args .fp8_init :
495508 test_fp8_fsdp2_allgather (model )
496509
510+ """
511+ DCP Checkpoint Testing
512+ """
513+ # Compute the pre-save model loss to the last random input
514+ # with respect to the last random target.
515+ model .eval ()
516+ with te .autocast (enabled = True , recipe = fp8_recipe ):
517+ output = model (input_data )
518+ pre_save_loss = F .mse_loss (output , target )
519+
520+ # Save deep copy of the model and optimizer state before checkpointing.
521+ # NOTE(@cspades): deepcopy has issues with DTensors. Just clone().
522+ s1 = {}
523+ for key , val in model .state_dict ().items ():
524+ s1 [key ] = val .clone ()
525+ optim_state_dict = optimizer .state_dict ()
526+ o1 = {"state" : {}}
527+ for idx , state in optim_state_dict ["state" ].items ():
528+ o1_state = o1 ["state" ].setdefault (idx , {})
529+ for key , val in state .items ():
530+ o1_state [key ] = val .clone ()
531+ o1 ["param_groups" ] = deepcopy (optim_state_dict ["param_groups" ])
532+
533+ # Write model to checkpoint.
534+ CKPT_DIR = (
535+ Path (SHARED_TMP_DIR )
536+ / "run_fsdp2_model"
537+ / f"dcp-{ '_' .join (str (x ) for x in args .sharding_dims )} -{ args .layer_type } -{ args .recipe } -fp8_init_{ args .fp8_init } "
538+ )
539+ CKPT_DIR .mkdir (parents = True , exist_ok = True , mode = 0o777 )
540+ state_dict = {"app" : AppState (model = model , optimizer = optimizer )}
541+ torch .distributed .checkpoint .save (state_dict , checkpoint_id = str (CKPT_DIR ))
542+
543+ # Perform an extra training step to change the weights such that
544+ # state parity tests will fail unless the checkpoint is loaded
545+ # without any errors or incongruities vs. the saved model state.
546+ model .train ()
547+ for iteration in range (args .iter ):
548+ with te .autocast (enabled = True , recipe = fp8_recipe ):
549+ output = model (torch .randn (inp_shape ).to (device ))
550+ loss = F .mse_loss (output , torch .randn (out_shape ).to (device ))
551+ loss .backward ()
552+ optimizer .step ()
553+
554+ # Load the checkpoint.
555+ state_dict = {"app" : AppState (model = model , optimizer = optimizer )}
556+ torch .distributed .checkpoint .load (state_dict = state_dict , checkpoint_id = str (CKPT_DIR ))
557+
558+ # Validate checkpoint parity with pre-save state dictionaries.
559+ # Compare pre-save and post-load model state dictionaries.
560+ s2 = model .state_dict ()
561+ nonempty_model_state = False
562+ for key in s1 .keys () | s2 .keys ():
563+ if key .endswith ("_extra_state" ):
564+ # Don't parity test _extra_state. Shape can change after reset_parameters().
565+ continue
566+ v1 = s1 .get (key , None )
567+ if isinstance (v1 , DTensor ):
568+ v1 = v1 .to_local ()
569+ v2 = s2 .get (key , None )
570+ if isinstance (v2 , DTensor ):
571+ v2 = v2 .to_local ()
572+ assert (
573+ v1 is not None and v2 is not None
574+ ), f"[{ key } Not Found] Original Param: { v1 } | Checkpoint Param: { v2 } "
575+ assert (
576+ v1 .shape == v2 .shape
577+ ), f"[Checkpoint Param { key } Shape Mismatch] { v1 .shape } != { v2 .shape } "
578+ assert torch .allclose (v1 , v2 ), f"[Checkpoint Param { key } Value Mismatch] { v1 } != { v2 } "
579+ nonempty_model_state = True
580+ assert nonempty_model_state , "Model state should not be empty for evenly-sharded DTensors!"
581+
582+ # Compare pre-save and post-load optimizer state dictionaries.
583+ o2 = optimizer .state_dict ()
584+ nonempty_optim_state = False
585+ for param_id in o1 ["state" ].keys () | o2 ["state" ].keys ():
586+ param_state_1 = o1 ["state" ].get (param_id , None )
587+ param_state_2 = o2 ["state" ].get (param_id , None )
588+ assert param_state_1 is not None and param_state_2 is not None , (
589+ f"[{ param_id } Not Found] Original Optim State: { param_state_1 } | Checkpoint Optim"
590+ f" State: { param_state_2 } "
591+ )
592+ for key in param_state_1 .keys () | param_state_2 .keys ():
593+ v1 = param_state_1 .get (key , None )
594+ if isinstance (v1 , DTensor ):
595+ v1 = v1 .to_local ()
596+ v2 = param_state_2 .get (key , None )
597+ if isinstance (v2 , DTensor ):
598+ v2 = v2 .to_local ()
599+ assert v1 is not None and v2 is not None , (
600+ f"[{ param_id } { key } Not Found] Original Optim State: { v1 } | Checkpoint Optim State:"
601+ f" { v2 } "
602+ )
603+ assert (
604+ v1 .shape == v2 .shape
605+ ), f"[Optim State { param_id } { key } Shape Mismatch] { v1 .shape } != { v2 .shape } "
606+ assert torch .allclose (
607+ v1 , v2
608+ ), f"[Optim State { param_id } { key } Value Mismatch] { v1 } != { v2 } "
609+ nonempty_optim_state = True # Optimizer state depends on wgrad, verify this!
610+ assert nonempty_optim_state , "Optimizer state should not be empty for evenly-sharded DTensors!"
611+ assert len (o1 ["param_groups" ]) == len (
612+ o2 ["param_groups" ]
613+ ), f"[Optim State Param Groups Length Mismatch] { o1 ['param_groups' ]} != { o2 ['param_groups' ]} "
614+ for i in range (len (o2 ["param_groups" ])):
615+ for key in o1 ["param_groups" ][i ].keys ():
616+ v1 = o1 ["param_groups" ][i ][key ]
617+ v2 = o2 ["param_groups" ][i ][key ]
618+ assert v1 == v2 , f"[Optim State Param Group { i } { key } Value Mismatch] { v1 } != { v2 } "
619+
620+ # Validate post-load model loss.
621+ model .eval ()
622+ with te .autocast (enabled = True , recipe = fp8_recipe ):
623+ output = model (input_data )
624+ post_load_loss = F .mse_loss (output , target )
625+ # Allow for 1% disparity due to _extra_state disparity.
626+ assert torch .allclose (
627+ pre_save_loss , post_load_loss , rtol = 1e-2
628+ ), f"Pre-Save Loss: { pre_save_loss } != Post-Load Loss: { post_load_loss } "
629+
630+ # Clean up temporary checkpoint directory.
631+ if not torch .distributed .is_initialized () or torch .distributed .get_rank () == 0 :
632+ shutil .rmtree (CKPT_DIR )
633+ torch .distributed .barrier ()
634+
497635 dist .destroy_process_group ()
498636 return 0
499637
0 commit comments