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}.")