Spaces:
Running on L40S
Running on L40S
File size: 21,599 Bytes
9f818c5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 | # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1
"""HFExportCallback: export VLM DCP checkpoints to HuggingFace safetensors format.
Design notes
------------
- Hooks into ``on_save_checkpoint`` (called by DistributedCheckpointer.save() before I/O).
- All ranks participate in the weight-gather phase (DTensor.full_tensor() all-gathers).
- Rank 0 accumulates CPU tensors, writes shards, and uploads β other ranks exit early.
- File I/O and upload run in a background thread on rank 0 to avoid blocking training.
- Worker exceptions are stored in ``_worker_exception`` and re-raised on the next
checkpoint or at train end, so failures are never silently swallowed.
- Controlled entirely via ``config.checkpoint.hf_export`` (HFExportConfig).
Phase 2+ note
-------------
Weight parameters are iterated via ``model.model.model.named_parameters()`` where
``model.model`` is the ``HFModel`` wrapper and ``model.model.model`` is the underlying
HuggingFace transformer. Parameter names are already HF-native β no weight_mapper
remapping is required.
"""
import json
import os
import shutil
import threading
from typing import Any
import torch
from cosmos_framework.utils import log
from cosmos_framework.utils.callback import Callback
from cosmos_framework.utils.distributed import is_rank0
try:
from safetensors.torch import save_file as _safetensors_save_file
except ImportError:
_safetensors_save_file = None
try:
from transformers import AutoTokenizer, GenerationConfig
except ImportError:
AutoTokenizer = None
GenerationConfig = None
# Map string dtype names (as stored in ParallelismConfig.precision) to torch dtypes.
_DTYPE_MAP: dict[str, torch.dtype] = {
"float32": torch.float32,
"float16": torch.float16,
"bfloat16": torch.bfloat16,
"float64": torch.float64,
}
def _upload_folder_to_s3(local_folder: str, bucket: str, s3_prefix: str, credential_path: str) -> None:
"""Upload every file under *local_folder* to ``s3://{bucket}/{s3_prefix}/...``.
Uses the i4 ``easy_io`` S3 backend (Boto3Backend), which reads credentials from
*credential_path*. Files are uploaded as streaming transfers via boto3's
``upload_file()`` β the full shard is never loaded into memory.
"""
from cosmos_framework.utils.easy_io import easy_io
backend = easy_io.get_file_backend(
backend_args={
"backend": "s3",
"s3_credential_path": credential_path,
"path_mapping": None,
}
)
for root, _, files in os.walk(local_folder):
for fname in sorted(files):
local_path = os.path.join(root, fname)
rel = os.path.relpath(local_path, local_folder)
s3_path = f"s3://{bucket}/{s3_prefix}/{rel}"
# Pass the local path string so Boto3Backend uses upload_file() β
# a streaming transfer that avoids reading the whole shard into memory.
backend.put(local_path, s3_path)
log.info(f"[HFExportCallback] Uploaded {local_path} β {s3_path}")
class HFExportCallback(Callback):
"""Export VLM weights to HuggingFace-compatible safetensors after each DCP checkpoint.
Enabled / configured via ``config.checkpoint.hf_export`` (HFExportConfig). Disabled
by default β add this callback and set ``hf_export.enabled = True`` to activate.
Exports written to::
{job.path_local}/hf_exports/iter_{iteration:09d}/
00000.safetensors
...
model.safetensors.index.json
config.json
tokenizer.json (if tokenizer can be loaded from model_name_or_path)
Optionally uploads to:
- S3 (``hf_export.upload_to_object_store``)
- HuggingFace Hub (``hf_export.hf_repo_id``)
Args:
dtype: Export weight dtype (e.g. ``"bfloat16"``). Use
``"${model.config.precision}"`` in the Hydra callback config to
inherit from the training precision.
"""
# HuggingFace convention: max 4 GB per shard file.
_MAX_SHARD_BYTES: int = 4 * 1024**3
def __init__(self, dtype: str = "bfloat16") -> None:
self._export_dtype: torch.dtype | None = _DTYPE_MAP.get(dtype)
self._current_iteration: int = 0
self._export_thread: threading.Thread | None = None
# Stores any exception raised inside the background worker so it can be
# re-raised on the main thread at the next checkpoint or train end.
self._worker_exception: BaseException | None = None
# ------------------------------------------------------------------
# Callback hooks
# ------------------------------------------------------------------
def on_save_checkpoint_start(self, model: Any, iteration: int = 0) -> None:
self._current_iteration = iteration
def on_save_checkpoint(self, model: Any, state_dict: dict[str, Any]) -> None:
hf_cfg = self.config.checkpoint.hf_export
if not hf_cfg.enabled:
return
iteration = self._current_iteration
if iteration % hf_cfg.export_every_n != 0:
return
# Deferred import to avoid circular dependency at module load time.
from cosmos_framework.model.vfm.vlm_model import VLMModel
if not isinstance(model, VLMModel):
# The legacy vlm/train.py path passes model_parts: list[nn.Module] (raw HF
# models without the VLMModel attribute structure). HF export requires the
# VLMModel wrapper, which is only available via the unified cosmos_framework/scripts/train.py path.
if isinstance(model, list):
log.warning(
"[HFExportCallback] Received model_parts (list) instead of VLMModel. "
"HF export requires the unified training path (cosmos_framework/scripts/train.py). Skipping."
)
else:
log.warning(
"[HFExportCallback] model is not VLMModel (got %s); skipping HF export.",
type(model).__name__,
)
return
if _safetensors_save_file is None:
raise ImportError("safetensors is required for HFExportCallback. Install it with: pip install safetensors")
output_dir = os.path.join(self.config.job.path_local, "hf_exports", f"iter_{iteration:09d}")
# ----------------------------------------------------------------
# Phase 1 (all ranks): gather sharded parameters into CPU chunks.
# full_tensor() is a collective operation β all ranks must participate.
# ----------------------------------------------------------------
cpu_chunks, manifest, total_size = self._gather_weights(model)
# ----------------------------------------------------------------
# Phase 2 (rank 0, background thread): file I/O + optional upload.
# ----------------------------------------------------------------
if not is_rank0():
return
# Block on any still-running export from the previous checkpoint and
# propagate any worker exception before starting a new export.
if self._export_thread is not None and self._export_thread.is_alive():
log.warning(
"[HFExportCallback] Previous export thread still running; waiting before starting export for iter %d.",
iteration,
)
self._export_thread.join()
if self._worker_exception is not None:
exc = self._worker_exception
self._worker_exception = None
raise RuntimeError(f"[HFExportCallback] Previous export failed with: {exc}") from exc
self._export_thread = threading.Thread(
target=self._save_and_upload,
args=(cpu_chunks, manifest, total_size, model.hf_config, model.model_name_or_path, output_dir, iteration),
daemon=True,
)
self._export_thread.start()
def on_train_end(self, model: Any, iteration: int = 0) -> None:
"""Wait for the final export thread so the process does not exit prematurely."""
if self._export_thread is not None and self._export_thread.is_alive():
log.info("[HFExportCallback] Waiting for export thread to finish...")
self._export_thread.join()
log.info("[HFExportCallback] Export thread done.")
if self._worker_exception is not None:
exc = self._worker_exception
self._worker_exception = None
raise RuntimeError(f"[HFExportCallback] Export thread failed with: {exc}") from exc
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _gather_weights(self, model: Any) -> tuple[list[dict[str, torch.Tensor]], dict[str, str], int]:
"""Iterate model parameters, all-gather DTensor shards, and build CPU chunks.
Must be called on **all ranks**. Only rank 0 populates the returned
``cpu_chunks`` and ``manifest``; other ranks return empty structures but still
participate in the distributed all-gathers.
Returns:
cpu_chunks: List of ``{weight_name: cpu_tensor}`` dicts, one per shard file.
manifest: Mapping of ``weight_name β shard_filename``.
total_size: Total byte count of all exported tensors (for the index JSON).
"""
cpu_chunks: list[dict[str, torch.Tensor]] = []
manifest: dict[str, str] = {}
current_chunk: dict[str, torch.Tensor] = {}
current_chunk_bytes: int = 0
total_size: int = 0
file_idx: int = 0
for name, param in model.model.model.named_parameters():
# Phase 2+: HFModel initialises _model via AutoModelForImageTextToText /
# AutoModelForCausalLM, so parameter names are HF-native and match the
# safetensors checkpoint keys loaded by _load_vlm_weights().
#
# MoE note: Qwen3VLMoeTextExpertsGroupedMm stores expert weights in HF-native
# grouped layout β gate_up_proj: [E, H, 2F], down_proj: [E, F, H] β matching
# the checkpoint format exactly. No transposition or per-expert fan-out is
# needed. (The legacy Phase 0 path stored tensors in a transposed internal
# format [E, 2F, H] under the name "gate_and_up_projs" and required
# weight_mapper.policy_map_local_key_for_export_tensor() to transpose back on
# export. Phase 2 uses HFModel and has no such internal reformat.)
#
# torch.compile and gradient-checkpointing wrappers inject prefixes into
# named_parameters() output. Strip them so exported keys are HF-native,
# matching what HFModel._load_vlm_weights() does for the in-memory state dict.
name = name.replace("_orig_mod.", "").replace("_checkpoint_wrapped_module.", "")
# Gather across FSDP / TP / CP ranks (collective β all ranks must call).
if isinstance(param, torch.distributed.tensor.DTensor):
param = param.full_tensor()
param = param.detach()
if self._export_dtype is not None:
param = param.to(dtype=self._export_dtype)
tensor_bytes = param.element_size() * param.numel()
# Flush the current chunk when the shard size limit would be exceeded.
# current_chunk_bytes is tracked on ALL ranks so shard boundaries are
# consistent (the shard_name written into manifest must agree everywhere).
if current_chunk_bytes + tensor_bytes > self._MAX_SHARD_BYTES and current_chunk_bytes > 0:
if is_rank0():
cpu_chunks.append(current_chunk)
current_chunk = {}
file_idx += 1
current_chunk_bytes = 0
shard_name = f"{file_idx:05d}.safetensors"
if is_rank0():
current_chunk[name] = param.cpu()
manifest[name] = shard_name
total_size += tensor_bytes
current_chunk_bytes += tensor_bytes
# Flush the final (possibly partial) chunk.
if current_chunk_bytes > 0 and is_rank0() and current_chunk:
cpu_chunks.append(current_chunk)
return cpu_chunks, manifest, total_size
def _save_and_upload(
self,
cpu_chunks: list[dict[str, torch.Tensor]],
manifest: dict[str, str],
total_size: int,
hf_config: Any,
model_name_or_path: str,
output_dir: str,
iteration: int,
) -> None:
"""Write safetensors shards, HF config, tokenizer; upload to S3 / HF Hub.
Runs on rank 0 inside a background thread. Any exception is stored in
``self._worker_exception`` so the main thread can re-raise it.
"""
try:
self._do_save_and_upload(
cpu_chunks, manifest, total_size, hf_config, model_name_or_path, output_dir, iteration
)
except Exception as exc:
log.error(
"[HFExportCallback] Export worker for iter %d raised an exception: %s",
iteration,
exc,
exc_info=True,
)
self._worker_exception = exc
def _do_save_and_upload(
self,
cpu_chunks: list[dict[str, torch.Tensor]],
manifest: dict[str, str],
total_size: int,
hf_config: Any,
model_name_or_path: str,
output_dir: str,
iteration: int,
) -> None:
"""Core export logic (called from the background thread via ``_save_and_upload``).
Error handling is tiered:
- Steps 1-4 (shards, index JSON, HF config, source-model file copy): any exception
propagates to the outer ``_save_and_upload`` wrapper so the main thread is notified.
A failed file copy leaves the checkpoint unusable for trust_remote_code models, so
it is treated as a hard failure like the shard writes.
- Steps 5-7 (tokenizer, generation_config, S3 upload, HF Hub upload): failures are
treated as soft warnings. The tokenizer and generation config are best-effort; upload
failures do not invalidate the local safetensors export, so an outage must not abort
training.
"""
hf_cfg = self.config.checkpoint.hf_export
os.makedirs(output_dir, exist_ok=True)
log.info(f"[HFExportCallback] Writing iter {iteration} export to {output_dir}")
# 1. Safetensors shards β one file per chunk (ordered by file_idx).
# Each chunk is cleared after writing so its tensors can be GC'd
# incrementally rather than being held until the whole loop completes.
for i in range(len(cpu_chunks)):
chunk = cpu_chunks[i]
shard_path = os.path.join(output_dir, f"{i:05d}.safetensors")
_safetensors_save_file(chunk, shard_path)
log.info(f"[HFExportCallback] Wrote {shard_path}")
cpu_chunks[i] = {} # release tensor references for GC
# 2. model.safetensors.index.json
# total_size is pre-computed in _gather_weights to avoid needing chunks here.
index_json = {"metadata": {"total_size": total_size}, "weight_map": manifest}
index_path = os.path.join(output_dir, "model.safetensors.index.json")
with open(index_path, "w") as fh:
json.dump(index_json, fh, indent=4)
# 3. HuggingFace model config.
hf_config.save_pretrained(output_dir)
# 4. Copy missing .py/.json files for trust_remote_code models.
# Only applicable when model_name_or_path is a local directory.
# The full directory layout is preserved so nested packages referenced by
# auto_map are included (mirroring convert_checkpoint.py's copytree approach).
# Files already present in the export dir (e.g., config.json written by
# hf_config.save_pretrained) are never overwritten.
# HARD failure: a broken copy leaves the checkpoint unloadable, so any I/O error
# propagates to the background-worker wrapper (same as shard writes).
if model_name_or_path and os.path.isdir(model_name_or_path):
real_src = os.path.realpath(model_name_or_path)
real_out = os.path.realpath(output_dir)
copied = []
for root, dirs, files in os.walk(real_src):
real_root = os.path.realpath(root)
# Prune any subtree that is, leads to, or is inside output_dir.
# This prevents recursing into previously written export dirs when
# output_dir (or a parent of it) lives inside model_name_or_path.
dirs[:] = [
d
for d in dirs
if not (
(p := os.path.realpath(os.path.join(real_root, d))) == real_out
or p.startswith(real_out + os.sep)
or real_out.startswith(p + os.sep)
)
]
if real_root == real_out or real_root.startswith(real_out + os.sep):
continue
rel_dir = os.path.relpath(real_root, real_src)
for fname in files:
if not (fname.endswith(".py") or fname.endswith(".json")):
continue
src = os.path.join(real_root, fname)
dst_dir = output_dir if rel_dir == "." else os.path.join(output_dir, rel_dir)
dst = os.path.join(dst_dir, fname)
if not os.path.exists(dst):
os.makedirs(dst_dir, exist_ok=True)
shutil.copy2(src, dst)
copied.append(os.path.join(rel_dir, fname) if rel_dir != "." else fname)
if copied:
log.info(f"[HFExportCallback] Copied missing files from source model: {copied}")
# 5. Tokenizer (best-effort β may fail for custom / gated models).
if AutoTokenizer is not None and model_name_or_path:
try:
tok = AutoTokenizer.from_pretrained(model_name_or_path, trust_remote_code=True)
tok.save_pretrained(output_dir)
except Exception as exc:
log.warning(f"[HFExportCallback] Tokenizer save skipped: {exc}")
# 6. Generation config (best-effort β not all models expose one).
if GenerationConfig is not None and model_name_or_path:
try:
gen_cfg = GenerationConfig.from_pretrained(model_name_or_path, trust_remote_code=True)
gen_cfg.save_pretrained(output_dir)
except Exception as exc:
log.warning(f"[HFExportCallback] generation_config save skipped: {exc}")
# 7. S3 upload β soft failure: local export is intact regardless of upload outcome.
obj_store = hf_cfg.upload_to_object_store
if obj_store.enabled:
s3_prefix = f"{self.config.job.path}/hf_exports/iter_{iteration:09d}"
try:
_upload_folder_to_s3(output_dir, obj_store.bucket, s3_prefix, obj_store.credentials)
log.info(f"[HFExportCallback] S3 upload done: s3://{obj_store.bucket}/{s3_prefix}")
except Exception as exc:
# Intentionally soft: an upload outage must not crash training.
log.warning(f"[HFExportCallback] S3 upload failed (local export intact): {exc}")
# 8. HuggingFace Hub upload β soft failure: see comment above.
if hf_cfg.hf_repo_id:
self._upload_to_hf_hub(output_dir, hf_cfg.hf_repo_id)
log.info(f"[HFExportCallback] Export complete for iter {iteration}.")
@staticmethod
def _upload_to_hf_hub(output_dir: str, repo_id: str, max_retries: int = 3) -> None:
try:
from huggingface_hub import HfApi
except ImportError:
log.warning("[HFExportCallback] huggingface_hub not installed; skipping HF Hub upload.")
return
api = HfApi()
for attempt in range(1, max_retries + 1):
try:
api.create_repo(repo_id=repo_id, exist_ok=True)
break
except Exception as exc:
log.warning(f"[HFExportCallback] create_repo attempt {attempt}/{max_retries} failed: {exc}")
if attempt == max_retries:
log.warning(
f"[HFExportCallback] Could not create HF Hub repo '{repo_id}' after "
f"{max_retries} attempts; skipping upload."
)
return
for attempt in range(1, max_retries + 1):
try:
api.upload_folder(
folder_path=output_dir,
repo_id=repo_id,
commit_message=f"Upload checkpoint from {os.path.basename(output_dir)}",
)
log.info(f"[HFExportCallback] Uploaded to HF Hub: {repo_id}")
return
except Exception as exc:
log.warning(f"[HFExportCallback] HF Hub upload attempt {attempt}/{max_retries} failed: {exc}")
log.warning(f"[HFExportCallback] All {max_retries} HF Hub upload attempts failed for {repo_id}.")
|