# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import os.path import io import torch.nn from typing import Optional, Dict, Union, Any from torch.distributed.checkpoint import FileSystemWriter, FileSystemReader import torch.distributed.checkpoint as dcp from torch.distributed.checkpoint.state_dict import ( get_model_state_dict, set_model_state_dict, get_optimizer_state_dict, set_optimizer_state_dict, StateDictOptions, ) from torch.distributed.checkpoint.stateful import Stateful from lipforcing.configs.config import BaseCheckpointerConfig from lipforcing.utils.distributed.s3_filesystem import S3StorageWriter, S3StorageReader from lipforcing.utils.io_utils import s3_load, s3_save, latest_checkpoint import lipforcing.utils.logging_utils as logger from lipforcing.utils.distributed import synchronize, is_rank0 from lipforcing.callbacks.callback import CallbackDict class Checkpointer: """Class to save and load model checkpoints""" def __init__(self, config: BaseCheckpointerConfig): self.config = config def _save_checkpoint(self, save_dict: Dict[str, Any], path: str): assert path.endswith(".pth"), f"{path} does not end with .pth" if self.config.use_s3: assert path.startswith("s3://"), f"{path} does not start with s3:// when using s3 storage" logger.info(f"Saving the model to {path}") # convert the save_dict to bytes buffer = io.BytesIO() torch.save(save_dict, buffer) s3_save(path, buffer.getvalue()) else: if not os.path.exists(os.path.dirname(path)): os.makedirs(os.path.dirname(path), exist_ok=True) torch.save(save_dict, path) def _load_checkpoint(self, path: str, device: torch.device = "cpu") -> Dict[str, Any]: assert path.endswith(".pth"), f"{path} does not end with .pth" if self.config.use_s3: assert path.startswith("s3://"), f"{path} does not start with s3:// when using s3 storage" state = torch.load(s3_load(path), map_location=device) else: assert os.path.exists(path), f"{path} does not exist" state = torch.load(path, map_location=device, weights_only=False) return state def save( self, model_dict: torch.nn.ModuleDict, optimizer_dict: Dict[str, torch.optim.Optimizer] | None = None, scheduler_dict: Dict[str, torch.optim.lr_scheduler.LambdaLR] | None = None, grad_scaler: torch.amp.GradScaler | None = None, callbacks: CallbackDict | None = None, path: str | None = None, iteration: int = 0, ) -> str: """Save a checkpoint of the model (and optionally optimizer, scheduler, and grad scaler to resume training) Args: model_dict (torch.nn.ModuleDict): The model dict to save optimizer_dict (Dict[str, torch.optim.Optimizer]): The optimizer dict to save scheduler_dict (Dict[str, torch.optim.lr_scheduler]): The scheduler dict to save grad_scaler (torch.amp.GradScaler | None): The gradient scaler (for mixed precision training) callbacks (CallbackDict | None): The callbacks to save path (str): The path to save the checkpoint file iteration (int): The iteration number Returns: str: The path to the saved checkpoint file """ synchronize() model_state = {k: v.state_dict() for k, v in model_dict.items()} optim_state = None if optimizer_dict is None else {k: v.state_dict() for k, v in optimizer_dict.items()} scheduler_state = None if scheduler_dict is None else {k: v.state_dict() for k, v in scheduler_dict.items()} grad_scaler_state = None if grad_scaler is None else grad_scaler.state_dict() callbacks_state = None if callbacks is None else callbacks.state_dict() save_dict = { "model": model_state, "optimizer": optim_state, "scheduler": scheduler_state, "grad_scaler": grad_scaler_state, "callbacks": callbacks_state, "iteration": iteration, } if is_rank0(): if path is None: if self.config.use_s3: path = os.path.join(self.config.s3_container, self.config.save_dir, f"{iteration:07d}.pth") else: path = os.path.join(self.config.save_dir, f"{iteration:07d}.pth") if not self.config.use_s3: os.makedirs(os.path.dirname(path), exist_ok=True) logger.info(f"Saving the model to {path}") self._save_checkpoint(save_dict, path) logger.success(f"Model saved at iteration {iteration}") synchronize() return path def load( self, model_dict: torch.nn.ModuleDict, optimizer_dict: Dict[str, torch.optim.Optimizer] | None = None, scheduler_dict: Dict[str, torch.optim.lr_scheduler.LambdaLR] | None = None, grad_scaler: torch.amp.GradScaler | None = None, callbacks: CallbackDict | None = None, path: str | None = None, device: Optional[torch.device] = "cpu", ) -> int: """Load the model checkpoint Args: model_dict (torch.nn.ModuleDict): The model dict to load optimizer_dict (Dict[str, torch.optim.Optimizer]): The optimizer dict to load scheduler_dict (Dict[str, torch.optim.lr_scheduler]): The scheduler dict to load grad_scaler (torch.amp.GradScaler | None): The gradient scaler (for mixed precision training) callbacks (CallbackDict | None): The callbacks to load path (str): The path to the checkpoint file device (Optional[torch.device]): The device to load the model to. Defaults to "cpu". Returns: int: The iteration number """ # TODO: rank 0 load and broadcast to all other ranks if path is None: if self.config.use_s3: checkpoint_path = os.path.join(self.config.s3_container, self.config.save_dir) else: checkpoint_path = self.config.save_dir path = latest_checkpoint(checkpoint_path) + ".pth" if path == ".pth": # no checkpoint found, starting from iteration 0 return 0 if not os.path.exists(path): logger.critical(f"Checkpoint file not found at {path}") return 0 logger.info(f"Loading model from {path}") state = self._load_checkpoint(path, device=device) logger.info("Loading the model_dict...") for k, v in model_dict.items(): if k in state["model"] and v is not None: # strict is False to allow evaluating external checkpoints without, e.g., logvar parameters model_load_info = v.load_state_dict(state["model"][k], strict=False) logger.info(f"Model {k}, loading info: {model_load_info}") else: logger.warning(f"Model {k} not found in checkpoint.") if optimizer_dict is not None: logger.info("Loading the optimizer_dict...") for k, v in optimizer_dict.items(): if k in state["optimizer"] and v is not None: v.load_state_dict(state["optimizer"][k]) else: logger.warning(f"Optimizer {k} not found in checkpoint.") if scheduler_dict is not None: logger.info("Loading the scheduler_dict...") for k, v in scheduler_dict.items(): if k in state["scheduler"] and v is not None: v.load_state_dict(state["scheduler"][k]) else: logger.warning(f"Scheduler {k} not found in checkpoint.") if grad_scaler is not None: logger.info("Loading the gradient scaler...") # Check if saved grad_scaler state is non-empty (disabled scalers save empty state) if state.get("grad_scaler") and len(state["grad_scaler"]) > 0: grad_scaler.load_state_dict(state["grad_scaler"]) else: logger.warning("Gradient scaler state is empty (likely saved from disabled scaler), skipping load.") if callbacks is not None: logger.info("Loading the callbacks...") if "callbacks" in state: callbacks.load_state_dict(state["callbacks"]) else: logger.warning("Callbacks not found in checkpoint.") if "iteration" not in state: logger.warning("Iteration not found in checkpoint.") return 0 return state["iteration"] class ModelWrapper(Stateful): """ Wrapper for model state dict handling Code taken from this tutorial: https://pytorch.org/tutorials/recipes/distributed_checkpoint_recipe.html This is a useful wrapper for checkpointing the Application State. Since this object is compliant with the Stateful protocol, DCP will automatically call state_dict/load_stat_dict as needed in the dcp.save/load APIs. """ def __init__(self, model: torch.nn.Module, options: StateDictOptions | None = None): self.model = model # Default to strict=False so partial-state saves (e.g., LoRA + # selective-unfreeze runs that filter to trainable params only at # save time, see FSDPCheckpointer.save) can be loaded back without # PyTorch raising on missing frozen-base keys. The frozen base # values are filled in during model construction (load base # safetensors + V2V adapter ckpt), so a strict=False load just # leaves them at their construction-time values — which is exactly # what we want for resume / inference. if options is None: options = StateDictOptions(strict=False) self.options = options def state_dict(self) -> Dict[str, Any]: # this line automatically manages FSDP FQN's, and sets the default state dict type to FSDP.SHARDED_STATE_DICT return get_model_state_dict(self.model, options=self.options) def load_state_dict(self, state_dict: Dict[str, Any]): set_model_state_dict(self.model, model_state_dict=state_dict, options=self.options) class OptimizerWrapper(Stateful): """ Wrapper for optimizer state dict handling Code taken from this tutorial: https://pytorch.org/tutorials/recipes/distributed_checkpoint_recipe.html This is a useful wrapper for checkpointing the Application State. Since this object is compliant with the Stateful protocol, DCP will automatically call state_dict/load_stat_dict as needed in the dcp.save/load APIs. """ def __init__( self, model: torch.nn.Module, optimizer: torch.optim.Optimizer, options: StateDictOptions | None = None ): self.model = model self.optimizer = optimizer self.options = options def state_dict(self) -> Dict[str, Any]: # this line automatically manages FSDP FQN's, and sets the default state dict type to FSDP.SHARDED_STATE_DICT optimizer_state_dict = get_optimizer_state_dict(self.model, self.optimizer, options=self.options) return optimizer_state_dict def load_state_dict(self, state_dict: Dict[str, Any]): set_optimizer_state_dict(self.model, self.optimizer, optim_state_dict=state_dict, options=self.options) class FSDPCheckpointer(Checkpointer): """Class to save and load model checkpoints""" def get_storage_writer(self, checkpoint_path: str) -> Union[S3StorageWriter, FileSystemWriter]: if self.config.use_s3: return S3StorageWriter( credential_path=self.config.s3_credential, path=checkpoint_path, ) return FileSystemWriter(path=checkpoint_path) def get_storage_reader(self, checkpoint_path: str) -> Union[S3StorageReader, FileSystemReader]: if self.config.use_s3: return S3StorageReader( credential_path=self.config.s3_credential, path=checkpoint_path, ) return FileSystemReader(checkpoint_path) def save( self, model_dict: torch.nn.ModuleDict, optimizer_dict: Dict[str, torch.optim.Optimizer] | None = None, scheduler_dict: Dict[str, torch.optim.lr_scheduler.LambdaLR] | None = None, grad_scaler: torch.amp.GradScaler | None = None, callbacks: CallbackDict | None = None, path: str | None = None, iteration: int = 0, ) -> str: """Save a checkpoint of the model (and optionally optimizer, scheduler, and grad scaler to resume training) Args: model_dict (torch.nn.ModuleDict): The model dict to save optimizer_dict (Dict[str, torch.optim.Optimizer]): The optimizer dict to save scheduler_dict (Dict[str, torch.optim.lr_scheduler]): The scheduler dict to save grad_scaler (torch.amp.GradScaler | None): The gradient scaler (for mixed precision training) callbacks (CallbackDict | None): The callbacks to save path (str): The path to save the checkpoint file iteration (int): The iteration number Returns: str: The path to the saved checkpoint file """ if path is None: if self.config.use_s3: path = os.path.join(self.config.s3_container, self.config.save_dir, f"{iteration:07d}") else: path = os.path.join(self.config.save_dir, f"{iteration:07d}") if not os.path.exists(self.config.save_dir): os.makedirs(self.config.save_dir, exist_ok=True) if path.endswith(".pth"): # In the case of autoresume path = path[:-4] logger.info(f"Saving FSDP model to prefix {path}") synchronize() # fsdp should save on all ranks for k, v in model_dict.items(): model_state_dict = ModelWrapper(model=v).state_dict() # Filter to params with requires_grad=True (trainable-only) when # partial-freeze is in effect. No-op for full-FT runs (where # every param has requires_grad=True; the filter keeps everything). # For LoRA + selective-unfreeze runs at 14B, this collapses save # size from ~56 GB (full base) to ~5 GB (LoRA + unfreeze). # # Non-parameter state (registered buffers, module-level Tensors # that aren't nn.Parameters) is preserved — only nn.Parameters # are filtered. ``v.named_parameters()`` enumerates only # parameters; anything else in ``model_state_dict`` is kept. params_dict = dict(v.named_parameters()) filtered = { key: tensor for key, tensor in model_state_dict.items() if key not in params_dict or params_dict[key].requires_grad } n_kept = len(filtered) n_total = len(model_state_dict) if n_kept < n_total: logger.info( f"[FSDPCheckpointer] {k}_model: saving {n_kept}/{n_total} state-dict " f"entries (filtered out {n_total - n_kept} frozen-param entries)" ) storage_writer = self.get_storage_writer(checkpoint_path=f"{path}.{k}_model") dcp.save(filtered, storage_writer=storage_writer) if optimizer_dict is not None: for k, v in optimizer_dict.items(): optim_state_dict = OptimizerWrapper(model=model_dict[k], optimizer=v).state_dict() storage_writer = self.get_storage_writer(checkpoint_path=f"{path}.{k}_optim") dcp.save(optim_state_dict, storage_writer=storage_writer) # other scalars only save on rank 0 if is_rank0(): scheduler_state = None if scheduler_dict is None else {k: v.state_dict() for k, v in scheduler_dict.items()} grad_scaler_state = None if grad_scaler is None else grad_scaler.state_dict() callbacks_state = None if callbacks is None else callbacks.state_dict() save_dict = { "scheduler": scheduler_state, "grad_scaler": grad_scaler_state, "callbacks": callbacks_state, "iteration": iteration, } self._save_checkpoint(save_dict, f"{path}.pth") logger.success(f"Model saved at iteration {iteration}") synchronize() return path def load( self, model_dict: torch.nn.ModuleDict, optimizer_dict: Dict[str, torch.optim.Optimizer] | None = None, scheduler_dict: Dict[str, torch.optim.lr_scheduler.LambdaLR] | None = None, grad_scaler: torch.amp.GradScaler | None = None, callbacks: CallbackDict | None = None, path: str | None = None, device: Optional[torch.device] = "cpu", ) -> int: """Load the model checkpoint Args: model_dict (torch.nn.ModuleDict): The model dict to load optimizer_dict (Dict[str, torch.optim.Optimizer]): The optimizer dict to load scheduler_dict (Dict[str, torch.optim.lr_scheduler]): The scheduler dict to load grad_scaler (torch.amp.GradScaler | None): The gradient scaler (for mixed precision training) callbacks (CallbackDict | None): The callbacks to load path (str): The path to the checkpoint file device (Optional[torch.device]): The device to load the model to. Defaults to "cpu". Returns: int: The iteration number """ if path is None: if self.config.use_s3: checkpoint_path = os.path.join(self.config.s3_container, self.config.save_dir) else: checkpoint_path = self.config.save_dir path = latest_checkpoint(checkpoint_path) if path == "": # no checkpoint found, starting from iteration 0 return 0 if path.endswith(".pth"): # An FSDPCheckpointer-saved checkpoint stores model/optim shards # in sibling `.{k}_model/` and `.{k}_optim/` dirs and # writes only metadata (iteration, scheduler, callbacks, grad_scaler) # into the .pth file — there is NO `"model"` key in that .pth. # The regular `Checkpointer.load` at L156 indexes `state["model"]` # unconditionally and would KeyError. Detect the FSDP layout by # looking for any sibling `*._model/` dir, and if present, fall # through to the FSDP load path below (treating the .pth as a # metadata stub for an FSDP-saved bundle). stripped_path = path[:-4] has_fsdp_layout = any( os.path.isdir(f"{stripped_path}.{k}_model") for k in model_dict.keys() ) if has_fsdp_layout: logger.info( f"Loading FSDP model (.pth metadata stub at {path}, " f"sharded state in sibling *.{{k}}_model dirs)" ) path = stripped_path # fall through to the FSDP code path below else: # regular DDP-saved single-file checkpoint logger.debug(f"Loading non-FSDP model from {path}") return super().load( model_dict, optimizer_dict=optimizer_dict, scheduler_dict=scheduler_dict, grad_scaler=grad_scaler, callbacks=callbacks, path=path, device=device, ) if not os.path.exists(f"{path}.pth"): logger.critical(f"Checkpoint file not found at {path}") return 0 logger.info(f"Loading FSDP model from prefix {path}") for k, v in model_dict.items(): logger.info(f"Loading the FSDP model dict for key {k}...") assert os.path.exists(f"{path}.{k}_model"), f"Key {k} does not exist in FSDP model dict" # Skip on ranks where parameters live on the meta device. This # check MUST come before constructing the ModelWrapper / calling # state_dict(), because get_model_state_dict on a non-FSDP-wrapped # meta-init model can materialize the meta tensors as a side # effect, defeating the very memory savings we're trying to # protect. # # Context for the skip: # - This load path runs in two contexts: pretrained_ckpt_path # (BEFORE FSDP wrap, model is unsharded) and resume (AFTER FSDP # wrap, model is sharded with real per-rank tensors). # - Pre-FSDP-wrap, with fsdp_meta_init=True, only rank 0 holds # real weights; ranks 1-3 hold meta tensors. dcp.load on a meta # state_dict materializes those tensors, allocating real memory # on every rank and defeating the meta-init memory savings — # which combined with the bf16->fp32 cast in on_train_begin # can push the cgroup over its memory cap. # - The skip is safe because model_to_fsdp(sync_module_states=True) # broadcasts rank-0's loaded state to all ranks during FSDP wrap # via set_model_state_dict(broadcast_from_rank0=True), filling # the per-rank shards with the loaded values. # - Post-FSDP-wrap (resume path), parameters are real DTensors with # per-rank shards — never on meta — so this check is False and # the load proceeds normally. has_meta_params = any(p.is_meta for p in v.parameters()) if has_meta_params: # Participate in DCP's collective planning protocol with an # empty state_dict so we don't allocate, but rank 0's dcp.load # doesn't deadlock waiting for our all_gather. An empty dict # tells the planner this rank wants nothing — no allocation, # no disk read. storage_reader = self.get_storage_reader(checkpoint_path=f"{path}.{k}_model") logger.info( f"[FSDPCheckpointer] {k}_model: meta-rank empty load " f"(rank-0 broadcasts via sync_module_states later)" ) dcp.load(state_dict={}, storage_reader=storage_reader) continue model_wrapper = ModelWrapper(model=v) model_state_dict = model_wrapper.state_dict() storage_reader = self.get_storage_reader(checkpoint_path=f"{path}.{k}_model") # Symmetric to save filter: only load trainable param entries. # Frozen base params remain at the model's current state (from # PEFT injection + pretrained init). Non-parameter entries # (registered buffers) pass through. No-op for full-FT runs. params_dict = dict(v.named_parameters()) filtered_state_dict = { key: tensor for key, tensor in model_state_dict.items() if key not in params_dict or params_dict[key].requires_grad } n_kept = len(filtered_state_dict) n_total = len(model_state_dict) if n_kept < n_total: logger.info( f"[FSDPCheckpointer] {k}_model: loading {n_kept}/{n_total} " f"state-dict entries (skipping {n_total - n_kept} frozen-param entries)" ) dcp.load( state_dict=filtered_state_dict, storage_reader=storage_reader, ) model_wrapper.load_state_dict(model_state_dict) if optimizer_dict is not None: allow_missing_optim = os.environ.get("LIPFORCING_ALLOW_MISSING_FSDP_OPTIM", "").lower() in { "1", "true", "yes", } for k, v in optimizer_dict.items(): logger.info(f"Loading the FSDP optimizer dict for key {k}...") optim_wrapper = OptimizerWrapper(model=model_dict[k], optimizer=v) # For fresh optimizers with no state, we need to initialize with fake gradients # that are DTensors (not regular Tensors) to avoid the mixed Tensor/DTensor error if len(v.state) == 0: # Set fake DTensor gradients to initialize optimizer state for param in model_dict[k].parameters(): if param.requires_grad and param.grad is None: param.grad = torch.zeros_like(param) optim_state_dict = optim_wrapper.state_dict() assert os.path.exists(f"{path}.{k}_model"), f"Key {k} does not exist in FSDP model dict" optim_path = f"{path}.{k}_optim" if not os.path.exists(optim_path): if allow_missing_optim: logger.warning( f"Optimizer checkpoint for {k} missing at {optim_path}; " "LIPFORCING_ALLOW_MISSING_FSDP_OPTIM=1, so continuing with fresh optimizer state." ) v.state.clear() continue raise FileNotFoundError( f"Optimizer checkpoint for {k} not found at {optim_path}. " "Set LIPFORCING_ALLOW_MISSING_FSDP_OPTIM=1 to resume model weights with fresh optimizer state." ) storage_reader = self.get_storage_reader(checkpoint_path=optim_path) try: dcp.load( state_dict=optim_state_dict, storage_reader=storage_reader, ) optim_wrapper.load_state_dict(optim_state_dict) logger.success(f"Successfully loaded optimizer state for {k}") except Exception as e: error_msg = str(e) if ( "Missing key" in error_msg or "Unexpected key" in error_msg or "CheckpointException" in error_msg ): logger.warning(f"Optimizer checkpoint compatibility issue for {k}: {type(e).__name__}") logger.warning(f"Initializing fresh optimizer state for {k} - training will continue") # Reset to fresh optimizer state while preserving the optimizer's # defaultdict-backed lazy state initialization. v.state.clear() logger.info(f"Reset optimizer state for {k} due to parameter mismatch") else: logger.error(f"Unexpected optimizer loading error for {k}: {e}") raise e state = self._load_checkpoint(f"{path}.pth", device=device) if scheduler_dict is not None: logger.info("Loading the scheduler_dict...") for k, v in scheduler_dict.items(): if k in state["scheduler"]: v.load_state_dict(state["scheduler"][k]) else: logger.warning(f"Scheduler {k} not found in checkpoint.") if grad_scaler is not None: logger.info("Loading the gradient scaler...") # Check if saved grad_scaler state is non-empty (disabled scalers save empty state) if state.get("grad_scaler") and len(state["grad_scaler"]) > 0: grad_scaler.load_state_dict(state["grad_scaler"]) else: logger.warning("Gradient scaler state is empty (likely saved from disabled scaler), skipping load.") if callbacks is not None: logger.info("Loading the callbacks...") if "callbacks" in state: callbacks.load_state_dict(state["callbacks"]) else: logger.warning("Callbacks not found in checkpoint.") return state["iteration"]