Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
459 changes: 459 additions & 0 deletions modelopt/torch/models/_onfly_decompress.py

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions modelopt/torch/models/_remote_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.

"""Loading the modeling code a checkpoint ships with itself."""

import glob
import os
import shutil
import tempfile
import warnings


def stage_full_remote_closure() -> None:
"""Make transformers stage every module of a local checkpoint's remote code, not just one hop.

transformers' ``get_cached_module_file`` copies a remote module and its *direct* relative imports
into ``transformers_modules/<repo>/<hash>/``, but does not recurse. Remote code more than one import
deep -- Kimi-K2.5's ``modeling_kimi_k25 -> modeling_deepseek -> configuration_deepseek`` -- is
then staged incomplete, and the later recursive walk in ``get_relative_import_files`` fails with
``FileNotFoundError``, from ``get_class_from_dynamic_module`` and ``from_pretrained`` alike.

Wrap ``get_cached_module_file`` so that, loading from a local checkpoint dir, it copies every
sibling ``*.py`` into the stage dir it just populated. Idempotent.
"""
import transformers.dynamic_module_utils as dmu
from transformers.utils import HF_MODULES_CACHE

if getattr(dmu.get_cached_module_file, "_modelopt_full_closure", False):
return
orig = dmu.get_cached_module_file

def _staged(pretrained_model_name_or_path, module_file, *args, **kwargs):
resolved = orig(pretrained_model_name_or_path, module_file, *args, **kwargs)
src = str(pretrained_model_name_or_path)
if os.path.isdir(src):
staged = str(resolved)
if not os.path.isabs(staged):
staged = os.path.join(HF_MODULES_CACHE, staged)
stage_dir = os.path.dirname(staged)
try:
for py in glob.glob(os.path.join(src, "*.py")):
dst = os.path.join(stage_dir, os.path.basename(py))
if not os.path.exists(dst):
# Every rank loading the checkpoint stages into this dir and imports from it:
# renamed into place (atomic), a file appears there whole, never half written.
fd, tmp = tempfile.mkstemp(dir=stage_dir)
os.close(fd)
shutil.copy(py, tmp)
os.replace(tmp, dst)
except OSError as e: # a failed copy only matters if a deeper import needs the file
warnings.warn(f"Could not stage the remote code of {src}: {e}")
Comment on lines +54 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The shutil.copy-onto-mkstemp rewrite addressed the 0600 mode from the last round, but two edges remain in the error path, and both come from the try sitting outside the loop rather than inside it:

  1. One bad sibling abandons the rest. The first OSError — an unreadable *.py, a full disk, a permission problem on one file — exits the whole loop, so the siblings after it are never staged. Since the point of this helper is to stage the full closure, a partial stage reproduces the FileNotFoundError from get_relative_import_files that the docstring describes, just one import deeper.
  2. The temp file leaks. If shutil.copy raises, os.replace never runs and tmp stays in the stage dir. It is not a *.py, so it does not break get_relative_import_files, but with every rank staging into a shared HF_MODULES_CACHE the droppings accumulate silently.

Moving the try inside the loop with a finally that unlinks fixes both — stage what you can, warn per file, leave nothing behind:

for py in glob.glob(os.path.join(src, "*.py")):
    dst = os.path.join(stage_dir, os.path.basename(py))
    if os.path.exists(dst):
        continue
    # Every rank loading the checkpoint stages into this dir and imports from it:
    # renamed into place (atomic), a file appears there whole, never half written.
    tmp = None
    try:
        fd, tmp = tempfile.mkstemp(dir=stage_dir)
        os.close(fd)
        shutil.copy(py, tmp)
        os.replace(tmp, dst)
        tmp = None
    except OSError as e:  # a failed copy only matters if a deeper import needs the file
        warnings.warn(f"Could not stage {py} of the remote code of {src}: {e}")
    finally:
        if tmp is not None:
            with contextlib.suppress(OSError):
                os.unlink(tmp)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Postponed: both edges are real, but only after a copy has already failed and warned, and an import that needs a file left unstaged still fails loudly with FileNotFoundError. Moving the try into the loop, with a finally that unlinks the temp file, is a follow-up rather than a change to this approved slice.

return resolved
Comment on lines +53 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The atomic rename is the right call; three follow-ups on the staging loop:

  1. The temp file leaks on failure. When shutil.copy raises (the OSError branch is exactly that case, and the new test exercises it), the mkstemp file stays in the stage dir forever — an extension-less, partially-written copy of a modeling file sitting next to the modules transformers imports. One finally/except with os.unlink(tmp) keeps the cache clean, and the existing test can assert the dir holds only .py files.
  2. mkstemp mode is 0600, and os.replace carries that through, whereas transformers' own shutil.copy leaves the source's 0644. On a cluster where HF_HOME/HF_MODULES_CACHE is shared between users (or between a job user and an interactive user), the first rank to stage makes the module unreadable to everyone else and their import fails. os.chmod(tmp, 0o644 & ~current_umask) — or simply copying the source file's mode — avoids that.
  3. A changed sibling is never refreshed. if not os.path.exists(dst) means an edited checkpoint .py keeps serving the previously staged copy, while transformers re-stages the entry module itself. During local iteration on a checkpoint's modeling code that shows up as edits that appear to have no effect. Comparing size/mtime (or content) before skipping would cost little here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back on (2), and postponing (1) and (3). (2): shutil.copy is copyfile plus copymode, so it gives the temp file the source's permission bits before the rename, and the staged module gets the checkpoint file's mode, not mkstemp's 0600: with a 0644 source under umask 022, the temp file goes from 0600 to 0644 at the copy and is staged 0644 (Python 3.12). (1) and (3) are real but small: a failed copy leaves an extension-less temp file, which neither transformers nor Python imports, beside the warning that reports the failure; and a sibling edited after it was staged keeps its staged copy until the stage dir is cleared, while transformers refreshes the modules it stages itself. They are postponed because [19/21] (#2748) changes this module in the same round, and an edit here would conflict with it.


setattr(_staged, "_modelopt_full_closure", True)
dmu.get_cached_module_file = _staged
Comment thread
shengliangxu marked this conversation as resolved.
29 changes: 28 additions & 1 deletion modelopt/torch/utils/plugins/model_load_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2100,6 +2100,13 @@ def _fsdp2_shard_dense(model: nn.Module, fsdp_mesh, expert_modules) -> int:
_EP_SOURCE_FORMATS = frozenset({None, "fp8", "mxfp4"})


def _decompressed_as_loaded(cfg) -> bool:
"""Whether a model spec installed the on-the-fly decompressor for this config's checkpoint."""
from modelopt.torch.models import _onfly_decompress

return _onfly_decompress.is_marked(cfg)


def _ckpt_quant_method(cfg):
"""Return the checkpoint's native quantization method, or ``None``.

Expand Down Expand Up @@ -2367,7 +2374,7 @@ class builds one; ``require_lm_head=False`` builds a VLM without it, through ``A
f"{plan!r}: the dense part of a block-fp8 checkpoint cannot be sharded: Distributed "
"Checkpoint reads it as stored, with no dequantization. Plan dense='replicate'."
)
if quant_method not in _EP_SOURCE_FORMATS:
if quant_method not in _EP_SOURCE_FORMATS and not _decompressed_as_loaded(hf_config):
Comment thread
shengliangxu marked this conversation as resolved.
# transformers' own quantizer would load it as stored, for PTQ to treat as plain weights.
dequantized = ", ".join(sorted(f for f in _EP_SOURCE_FORMATS if f))
raise NotImplementedError(
Expand All @@ -2377,6 +2384,20 @@ class builds one; ``require_lm_head=False`` builds a VLM without it, through ``A
)
# The swapped plan is this load's own: the caller's config keeps the plan its checkpoint declares.
hf_config = copy.deepcopy(hf_config)
if _decompressed_as_loaded(hf_config):
# The decompressor hands the experts over dequantized. Left on the config (Kimi-K2.5's config
# class copies its text config's to the top level), the checkpoint's quantization_config
# would build transformers' compressed-tensors quantizer, which from 5.13 claims the same
# expert converters first. Deleted rather than set to None: before 5.11 transformers takes
# any quantization_config attribute, None too, for a quantized checkpoint. Every level
# _ckpt_quant_method reads is cleared.
for cfg in (
hf_config,
getattr(hf_config, "text_config", None),
getattr(hf_config, "llm_config", None),
):
if cfg is not None and hasattr(cfg, "quantization_config"):
del cfg.quantization_config
ep_cfg = _ep_config(hf_config) # for VLMs the plan lives on .text_config
ep_plan = dict(getattr(ep_cfg, "base_model_ep_plan", None) or {})
if not ep_plan:
Expand All @@ -2400,6 +2421,12 @@ class builds one; ``require_lm_head=False`` builds a VLM without it, through ``A
# from, take the local snapshot: a Hub ID recorded there would leave the export nothing to read.
# Resolved once the plan and the config pass, so a refused load downloads nothing.
ckpt_path = _resolve_checkpoint_dir(ckpt_path, torch.distributed.get_rank())
if _decompressed_as_loaded(hf_config):
# Only the experts decompress as they load, and the config cannot say what else the
# checkpoint compressed, or in which dtypes: what it stores is checked before anything loads.
from modelopt.torch.models import _onfly_decompress

_onfly_decompress.check_compressed_keys(hf_config, ckpt_path)

# Two INDEPENDENT meshes (NeMo-AutoModel style), named by the model part they govern -- two
# factorizations of the same world:
Expand Down
7 changes: 6 additions & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ def _cov_args():
@nox.parametrize("torch_ver", [nox.param(k, id=k) for k in TORCH_VERSIONS])
def unit(session, torch_ver, tf_ver):
"""Unit tests — parametrized over torch and transformers versions."""
session.install(TORCH_VERSIONS[torch_ver], "-e", ".[all,dev-test]")
# compressed-tensors runs the real-library tests of on-the-fly expert decompression. It needs
# torch>=2.10, so the older legs skip those tests; it goes in with the torchvision pin so the
# resolver keeps the leg's torch. Not in dev-test: the transformers-free partial_unit(torch)
# lane installs that extra, and compressed-tensors pulls in transformers.
extra = [] if torch_ver in ("torch_28", "torch_29") else ["compressed-tensors"]
Comment thread
shengliangxu marked this conversation as resolved.
session.install(TORCH_VERSIONS[torch_ver], "-e", ".[all,dev-test]", *extra)
tf_pins = TRANSFORMERS_VERSIONS[tf_ver]
if tf_pins:
session.install(*tf_pins)
Expand Down
Loading
Loading