Spaces:
Running on Zero
Running on Zero
| # Copyright 2025 Bytedance Ltd. and/or its affiliates | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| import gc | |
| import os | |
| import shutil | |
| import time | |
| from typing import Dict, Optional, Sequence | |
| import torch | |
| import torch.distributed as dist | |
| import torch.distributed.checkpoint as dcp | |
| from veomni.checkpoint import ckpt_to_state_dict | |
| from veomni.models import save_model_assets, save_model_weights | |
| from veomni.models.module_utils import _save_state_dict | |
| from veomni.utils import helper | |
| from veomni.utils.device import synchronize | |
| from veomni.utils.import_utils import is_torch_version_greater_than | |
| logger = helper.create_logger(__name__) | |
| def get_model_save_state( | |
| model: torch.nn.Module, | |
| fqn_to_index_mapping: Optional[Dict[str, int]], | |
| ) -> Dict[str, torch.Tensor]: | |
| """Build a flat state dict suitable for HuggingFace safetensors saving. | |
| 1. Extracts a flat state dict via ``ModelState`` (FQNs match HF weight_map keys). | |
| 2. Casts float32 tensors to bfloat16 on copies (original model dtypes are preserved). | |
| 3. Filters out tied weights not present in ``fqn_to_index_mapping``. | |
| """ | |
| from veomni.checkpoint.dcp_checkpointer import ModelState | |
| # Use flat state dict so DCP FQNs match the original HF weight_map keys | |
| # (e.g. "model.embed_tokens.weight" instead of "model.model.embed_tokens.weight") | |
| save_state = ModelState(model).state_dict() | |
| # Convert float32 tensors to bfloat16 on a copy of the state dict, | |
| # so the original model parameters remain unchanged. | |
| converted_state = {} | |
| for k, v in save_state.items(): | |
| if v.dtype == torch.float32: | |
| logger.info_rank0(f"Converting {k} from {v.dtype} to torch.bfloat16") | |
| converted_state[k] = v.to(torch.bfloat16) | |
| else: | |
| converted_state[k] = v | |
| save_state = converted_state | |
| # Remove tied weights not present in the HF weight_map | |
| # (e.g. lm_head.weight is tied to model.embed_tokens.weight via tie_word_embeddings) | |
| if fqn_to_index_mapping is not None: | |
| filtered_state = {} | |
| for k, v in save_state.items(): | |
| if k in fqn_to_index_mapping: | |
| filtered_state[k] = v | |
| else: | |
| logger.info_rank0(f"Skipping weight not in HF weight_map: {k}") | |
| save_state = filtered_state | |
| else: | |
| logger.warning_rank0( | |
| "fqn_to_index_mapping is None, HuggingFaceStorageWriter will save " | |
| "all model weights into a single safetensors file." | |
| ) | |
| return save_state | |
| def _save_hf_safetensor_distributed( | |
| model: torch.nn.Module, | |
| save_path: str, | |
| fqn_to_index_mapping: Optional[Dict[str, int]], | |
| model_assets: Optional[Sequence], | |
| ): | |
| """Distributed HuggingFace safetensors save using HuggingFaceStorageWriter (PyTorch >= 2.9). | |
| All ranks must call this function. | |
| """ | |
| from torch.distributed.checkpoint import HuggingFaceStorageWriter | |
| # Apply DCP consolidation patch just-in-time for HDFS FUSE compatibility | |
| # This patches torch.distributed.checkpoint._consolidate_hf_safetensors._process_output_file | |
| # to use append mode instead of r+b mode, which is required for append-only file systems | |
| from veomni.checkpoint.dcp_consolidation import apply_dcp_consolidation_patch | |
| apply_dcp_consolidation_patch() | |
| save_state = get_model_save_state(model, fqn_to_index_mapping) | |
| # Filter fqn_to_index_mapping to only include keys that exist in save_state. | |
| # This is necessary when training excludes certain modules (e.g., MTP) but the original | |
| # fqn_to_index_mapping parsed from model.safetensors.index.json still contains those weights. | |
| # Without this filtering, HuggingFaceStorageWriter's consolidation phase would create invalid | |
| # metadata entries (with default values like empty shape and 0 dtype_size) for non-existent | |
| # weights, resulting in corrupted safetensors output. | |
| if fqn_to_index_mapping is not None: | |
| original_mapping_size = len(fqn_to_index_mapping) | |
| fqn_to_index_mapping = {k: v for k, v in fqn_to_index_mapping.items() if k in save_state} | |
| if len(fqn_to_index_mapping) < original_mapping_size: | |
| logger.info_rank0( | |
| f"Filtered fqn_to_index_mapping from {original_mapping_size} to {len(fqn_to_index_mapping)} keys " | |
| f"to match actual model weights" | |
| ) | |
| storage_writer = HuggingFaceStorageWriter( | |
| path=save_path, | |
| save_distributed=True, | |
| fqn_to_index_mapping=fqn_to_index_mapping, | |
| enable_consolidation=True, | |
| thread_count_consolidation=5, | |
| ) | |
| logger.info_rank0("Starting distributed HuggingFace safetensors save...") | |
| if dist.is_initialized(): | |
| dist.barrier() | |
| start_time = time.time() | |
| dcp.save( | |
| state_dict=save_state, | |
| storage_writer=storage_writer, | |
| ) | |
| del save_state # Free copied tensors (e.g. fp32->bf16) to reduce peak memory | |
| if dist.is_initialized(): | |
| dist.barrier() | |
| gc.collect() | |
| helper.empty_cache() | |
| elapsed_time = time.time() - start_time | |
| logger.info_rank0(f"Distributed HuggingFace safetensors save took {elapsed_time:.2f}s") | |
| # Save model assets (config, tokenizer, etc.) on rank 0 | |
| if model_assets and (not dist.is_initialized() or dist.get_rank() == 0): | |
| save_model_assets(save_path, model_assets) | |
| logger.info_rank0(f"HuggingFace checkpoint saved at {save_path} successfully!") | |
| def _save_hf_safetensor_legacy( | |
| save_checkpoint_path: str, | |
| save_hf_safetensor_path: str, | |
| model_assets: Optional[Sequence], | |
| ckpt_manager: str, | |
| output_dir: Optional[str], | |
| ): | |
| """Legacy HuggingFace safetensors save via checkpoint conversion (rank-0 only).""" | |
| model_state_dict = ckpt_to_state_dict( | |
| save_checkpoint_path=save_checkpoint_path, | |
| ckpt_manager=ckpt_manager, | |
| output_dir=output_dir, | |
| ) | |
| save_model_weights(save_hf_safetensor_path, model_state_dict, model_assets=model_assets) | |
| logger.info_rank0(f"HuggingFace checkpoint saved at {save_hf_safetensor_path} successfully!") | |
| def save_hf_safetensor( | |
| save_hf_safetensor_path: Optional[str] = None, | |
| ckpt_manager: Optional[str] = None, | |
| model_assets: Optional[Sequence] = None, | |
| # Legacy only | |
| save_checkpoint_path: Optional[str] = None, | |
| output_dir: Optional[str] = None, | |
| is_rank_0: bool = False, | |
| # Distributed only | |
| model: Optional[torch.nn.Module] = None, | |
| fqn_to_index_mapping: Optional[Dict[str, int]] = None, | |
| ): | |
| """Save model weights in HuggingFace safetensors format. | |
| This function is self-contained w.r.t. synchronization: it calls ``synchronize()`` at | |
| entry to flush pending GPU operations before reading tensor data, and calls | |
| ``dist.barrier()`` before returning to ensure all ranks complete the save. Callers | |
| do not need to add external synchronization around this function. | |
| Supports two modes: | |
| - Distributed mode (PyTorch >= 2.9, ckpt_manager="dcp", non-LoRA): Uses HuggingFaceStorageWriter | |
| for efficient distributed save directly from the live FSDP model. Must be called on all ranks. | |
| - Legacy mode: Loads from checkpoint and converts to safetensors on rank 0. | |
| Args: | |
| save_hf_safetensor_path: Output path for saved HuggingFace safetensors. | |
| ckpt_manager: Checkpoint manager type. Used for routing (distributed when "dcp") | |
| and passed to legacy ``ckpt_to_state_dict``. | |
| model_assets: Model assets (e.g., config, tokenizer) to save alongside weights. | |
| save_checkpoint_path: [Legacy only] Path to the distributed checkpoint for conversion. | |
| output_dir: [Legacy only] Output directory passed to ``ckpt_to_state_dict``. | |
| is_rank_0: [Legacy only] Whether the current process is global rank 0. | |
| Legacy save is rank-0 only; non-rank-0 processes return immediately. | |
| Required by non-dcp checkpoint managers (e.g., omnistore). | |
| model: [Distributed only] Live FSDP model for distributed save. | |
| fqn_to_index_mapping: [Distributed only] Maps FQNs to safetensors file indices | |
| for multi-file output. | |
| """ | |
| from veomni.checkpoint.dcp_checkpointer import DistributedCheckpointer | |
| use_distributed = is_torch_version_greater_than("2.9") and ckpt_manager == "dcp" | |
| # Ensure all GPU operations are complete before reading tensor data for saving | |
| synchronize() | |
| # Wait for any pending async DCP save | |
| if ckpt_manager == "dcp" and DistributedCheckpointer.save_future is not None: | |
| logger.info_rank0("Waiting for pending async DCP save to complete before HF safetensor save...") | |
| DistributedCheckpointer.save_future.result() | |
| DistributedCheckpointer.save_future = None | |
| if dist.is_initialized(): | |
| dist.barrier() | |
| if use_distributed: | |
| _save_hf_safetensor_distributed(model, save_hf_safetensor_path, fqn_to_index_mapping, model_assets) | |
| else: | |
| # Legacy path is rank-0 only; non-rank-0 waits at the barrier below | |
| if is_rank_0: | |
| _save_hf_safetensor_legacy( | |
| save_checkpoint_path, | |
| save_hf_safetensor_path, | |
| model_assets, | |
| ckpt_manager, | |
| output_dir, | |
| ) | |
| # Ensure all ranks finish saving before anyone proceeds | |
| if dist.is_initialized(): | |
| dist.barrier() | |
| def save_lora_adapter_with_dcp( | |
| model: torch.nn.Module, | |
| save_path: str, | |
| adapter_name: str = "default", | |
| dcp_subdir: str = ".lora_dcp_tmp", | |
| ) -> None: | |
| """Save LoRA adapter with DCP parallel write and rank-0 consolidation. | |
| All ranks must call this function. It performs: | |
| 1. Extract LoRA-only state from the live model. | |
| 2. Save with ``dcp.save`` in parallel to a temporary DCP directory. | |
| 3. Consolidate on rank 0 into ``adapter_model.bin`` and ``adapter_config.json``. | |
| """ | |
| from peft import get_peft_model_state_dict | |
| synchronize() | |
| if dist.is_initialized(): | |
| dist.barrier() | |
| os.makedirs(save_path, exist_ok=True) | |
| dcp_save_path = os.path.join(save_path, dcp_subdir) | |
| os.makedirs(dcp_save_path, exist_ok=True) | |
| lora_state = get_peft_model_state_dict(model) | |
| lora_state = {k: v.to(torch.bfloat16) if v.dtype == torch.float32 else v for k, v in lora_state.items()} | |
| # ckpt_to_state_dict's DCP conversion path only recognizes keys starting with "model.". | |
| # Prefix LoRA keys temporarily for DCP save so consolidation can reuse existing conversion logic. | |
| dcp_lora_state = {k if k.startswith("model.") else f"model.{k}": v for k, v in lora_state.items()} | |
| storage_writer = dcp.FileSystemWriter( | |
| dcp_save_path, | |
| thread_count=16, | |
| single_file_per_rank=True, | |
| sync_files=False, | |
| ) | |
| dcp.save( | |
| state_dict=dcp_lora_state, | |
| storage_writer=storage_writer, | |
| ) | |
| if dist.is_initialized(): | |
| dist.barrier() | |
| is_rank_0 = not dist.is_initialized() or dist.get_rank() == 0 | |
| if is_rank_0: | |
| consolidated_state = ckpt_to_state_dict( | |
| save_checkpoint_path=dcp_save_path, | |
| ckpt_manager="dcp", | |
| ) | |
| adapter_model_file = os.path.join(save_path, "adapter_model.bin") | |
| _save_state_dict(consolidated_state, adapter_model_file, safe_serialization=False) | |
| if not hasattr(model, "peft_config") or adapter_name not in model.peft_config: | |
| raise ValueError(f"Cannot find peft config for adapter '{adapter_name}' on model.") | |
| model.peft_config[adapter_name].save_pretrained(save_path) | |
| shutil.rmtree(dcp_save_path, ignore_errors=True) | |
| logger.info_rank0(f"LoRA adapter saved at {save_path} successfully!") | |
| if dist.is_initialized(): | |
| dist.barrier() | |
| gc.collect() | |
| helper.empty_cache() | |