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
6 changes: 5 additions & 1 deletion examples/speculative_decoding/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ def train():
assert checkpoint is not None # guaranteed by checkpoint_is_hf
with patch_transformers5_params_loading():
model = load_vlm_or_llm(
checkpoint, dtype="auto", trust_remote_code=recipe.model.trust_remote_code
checkpoint,
dtype="auto",
trust_remote_code=recipe.model.trust_remote_code,
config_overrides=recipe.model.config_overrides,
)
tokenizer = transformers.AutoTokenizer.from_pretrained(
checkpoint, trust_remote_code=recipe.model.trust_remote_code
Expand All @@ -242,6 +245,7 @@ def train():
dtype="auto",
device_map="cpu",
trust_remote_code=recipe.model.trust_remote_code,
config_overrides=recipe.model.config_overrides,
)
tokenizer = transformers.AutoTokenizer.from_pretrained(
model_name_or_path,
Expand Down
19 changes: 17 additions & 2 deletions examples/speculative_decoding/scripts/ar_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@

import modelopt.torch.opt as mto
from modelopt.torch.speculative.plugins.hf_eagle import HFARValidation
from modelopt.torch.speculative.utils import load_vlm_or_llm
from modelopt.torch.speculative.utils import (
CONFIG_OVERRIDES_HELP,
load_vlm_or_llm,
parse_config_overrides,
)

mto.enable_huggingface_checkpointing()

Expand Down Expand Up @@ -100,11 +104,22 @@ def main():
default=None,
help="Error if AR is below this threshold.",
)
parser.add_argument(
"--config_overrides",
type=str,
default=None,
help=CONFIG_OVERRIDES_HELP,
)
args = parser.parse_args()

config_overrides = parse_config_overrides(args.config_overrides)

accelerator = Accelerator()
model = load_vlm_or_llm(
args.model_path, device_map="auto", trust_remote_code=args.trust_remote_code
args.model_path,
device_map="auto",
trust_remote_code=args.trust_remote_code,
config_overrides=config_overrides,
)
tokenizer = AutoTokenizer.from_pretrained(
args.model_path, trust_remote_code=args.trust_remote_code
Expand Down
19 changes: 17 additions & 2 deletions examples/speculative_decoding/scripts/export_hf_checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@

import modelopt.torch.opt as mto
from modelopt.torch.export import export_speculative_decoding
from modelopt.torch.speculative.utils import load_vlm_or_llm
from modelopt.torch.speculative.utils import (
CONFIG_OVERRIDES_HELP,
load_vlm_or_llm,
parse_config_overrides,
)


def parse_args():
Expand All @@ -33,13 +37,24 @@ def parse_args():
parser.add_argument(
"--export_path", type=str, default="Destination directory for exported files."
)
parser.add_argument(
"--config_overrides",
type=str,
default=None,
help=CONFIG_OVERRIDES_HELP,
)
return parser.parse_args()


mto.enable_huggingface_checkpointing()

args = parse_args()
model = load_vlm_or_llm(args.model_path, dtype="auto", trust_remote_code=args.trust_remote_code)
model = load_vlm_or_llm(
args.model_path,
dtype="auto",
trust_remote_code=args.trust_remote_code,
config_overrides=parse_config_overrides(args.config_overrides),
)
model.eval()
with torch.inference_mode():
export_speculative_decoding(
Expand Down
40 changes: 35 additions & 5 deletions examples/speculative_decoding/scripts/merge_lora.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@
from pathlib import Path

from safetensors.torch import load_file
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers import AutoTokenizer

from modelopt.torch.speculative.utils import (
CONFIG_OVERRIDES_HELP,
load_vlm_or_llm,
parse_config_overrides,
)


def parse_args():
Expand Down Expand Up @@ -61,11 +67,18 @@ def parse_args():
action="store_true",
help="Allow loading models that define custom code on the HF Hub. Off by default.",
)
parser.add_argument(
"--config_overrides",
type=str,
default=None,
help=CONFIG_OVERRIDES_HELP,
)
return parser.parse_args()


def main():
args = parse_args()
config_overrides = parse_config_overrides(args.config_overrides)
lora_dir = Path(args.exported_lora_dir)

# Verify exported files exist (standard peft naming)
Expand All @@ -81,13 +94,21 @@ def main():
print(f"Loaded {len(lora_sd)} LoRA tensors from {lora_dir}")
print(f" Sample keys: {list(lora_sd.keys())[:4]}")

# Load the original base model
# Load the original base model.
#
# Use load_vlm_or_llm rather than AutoModelForCausalLM directly: it falls back to
# AutoModelForCausalLM for plain LLMs (same dtype/device_map, so unchanged behavior), but also
# handles VLMs and registers/loads architectures the Auto* maps don't cover. Cosmos3 is the
# motivating case -- the transformers-cosmos3 plugin registers only the `cosmos3_omni` config,
# never a model under Auto*, so AutoModelForCausalLM raises KeyError('cosmos3_omni') no matter
# what is imported.
print(f"Loading base model from {args.base_model_path}...")
model = AutoModelForCausalLM.from_pretrained(
model = load_vlm_or_llm(
args.base_model_path,
torch_dtype="auto",
dtype="auto",
device_map="cpu",
trust_remote_code=args.trust_remote_code,
config_overrides=config_overrides,
)
tokenizer = AutoTokenizer.from_pretrained(
args.base_model_path, trust_remote_code=args.trust_remote_code
Expand Down Expand Up @@ -135,9 +156,18 @@ def main():
# Since LoRA only changes weights — not architecture — the original config is correct.
import shutil

# ...but only when the loaded config still matches the base's. With --config_overrides the
# weights were built from corrected dims, so copying the uncorrected base config back would
# leave config.json disagreeing with model.safetensors and force every downstream reader to
# re-supply the same overrides.
base_config = Path(args.base_model_path) / "config.json"
output_config = Path(args.output_path) / "config.json"
if base_config.exists():
if config_overrides:
print(
" Keeping the saved config.json (config_overrides were applied, so the original "
"base config would not match the merged weights)"
)
elif base_config.exists():
shutil.copy2(str(base_config), str(output_config))
print(f" Restored original config.json from {base_config}")

Expand Down
5 changes: 5 additions & 0 deletions modelopt/torch/speculative/plugins/hf_training_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ class ModelArguments(BaseModel):
model_name_or_path: str | None = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
use_fake_base_for_offline: bool = False
trust_remote_code: bool = False
# Optional config field overrides applied to the loaded model config (and its
# text_config) before instantiation. Needed for checkpoints whose config doesn't
# round-trip cleanly through transformers (e.g. Cosmos3's Qwen3-VL text tower,
# where intermediate_size/num_key_value_heads don't propagate from text_config).
config_overrides: dict | None = None


class DataArguments(BaseModel):
Expand Down
136 changes: 131 additions & 5 deletions modelopt/torch/speculative/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import contextlib
import copy
import importlib.util
import json
import os
import sys
import warnings
Expand All @@ -36,6 +37,11 @@
KIMI_K2_PACKAGE_NAME = "kimi_k2_temp"


# Attributes under which a checkpoint may nest its text-tower config. Mirrors
# modelopt.torch.speculative.plugins.modeling_fakebase._VLM_CONFIG_ATTRS.
NESTED_CONFIG_ATTRS = ["text_config", "llm_config"]


REMOVE_THINK_CHAT_TEMPLATE = (
"{% if '</think>' in content %}{% set content = content.split('</think>')[-1] %}{% endif %}"
)
Expand Down Expand Up @@ -584,13 +590,47 @@ def enable_cp_ttt_patch(cp_size: int = 1):
modelopt.torch.speculative.plugins.hf_eagle.ENABLE_CP_TTT_PATCH = False


CONFIG_OVERRIDES_HELP = (
"JSON object of config fields to override on the model config and its text_config before "
"instantiation, e.g. '{\"num_hidden_layers\": 36}'. Needed for checkpoints whose nested "
"text_config dims don't propagate to the parent config."
)


def parse_config_overrides(raw: str | None) -> dict | None:
"""Parse a ``--config_overrides`` CLI value into a dict, or ``None`` if not supplied.

Rejects malformed JSON and non-object payloads here, with an actionable message, rather than
letting them surface later as a raw ``JSONDecodeError`` or an ``AttributeError`` from deep
inside model loading.
"""
if not raw:
return None
try:
# Reject the JSON5-ish constants Python's json accepts by default: NaN/Infinity
# would sail through as floats and land on a config field as a dimension.
def _reject(const):
raise ValueError(f"--config_overrides contains non-finite value {const!r}")

parsed = json.loads(raw, parse_constant=_reject)
Comment on lines +609 to +615

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] The non-finite guard has a hole: parse_constant only fires on the literal tokens NaN / Infinity / -Infinity. Overflowing decimal literals go through parse_float (plain float()), so --config_overrides '{"num_hidden_layers": 1e999}' still yields inf and lands on a config field as a dimension — the exact outcome this callback was added to prevent.

Since the values here are all config scalars, a post-parse check over the values is both simpler and complete, and it also catches the adjacent case of a wrong-typed value ({"num_hidden_layers": "36"} currently sets a str on the config, which surfaces later as an opaque error inside module construction):

    if not isinstance(parsed, dict):
        raise ValueError(...)
    for key, value in parsed.items():
        if isinstance(value, float) and not math.isfinite(value):
            raise ValueError(f"--config_overrides[{key!r}] is non-finite ({value!r}).")
    return parsed

with import math at the top; the parse_constant=_reject closure can then go away.

except json.JSONDecodeError as e:
raise ValueError(f"--config_overrides is not valid JSON: {e}") from e
if not isinstance(parsed, dict):
raise ValueError(
f"--config_overrides must be a JSON object mapping field names to values, got "
f"{type(parsed).__name__}: {raw!r}"
)
return parsed


def load_vlm_or_llm(
model_name_or_path: str,
use_fake_base: bool = False,
use_offline_training: bool = False,
dtype: str | torch.dtype | None = None,
device_map: str | None = None,
trust_remote_code: bool = False,
config_overrides: dict | None = None,
):
"""Load a VLM or LLM. Returns the model.

Expand All @@ -605,26 +645,84 @@ def load_vlm_or_llm(
dtype: dtype to use when loading the model.
device_map: Device map passed to ``from_pretrained``.
trust_remote_code: Whether to trust remote code.
config_overrides: Optional config field overrides applied to the model config and
its ``text_config`` before instantiation (e.g. to correct dims that don't
propagate from a checkpoint's nested text config).
"""

def _warn_overrides_on_fake_base():
# FakeBaseModel.from_source re-reads the checkpoint config itself, so overrides applied
# here never reach it -- but it is not silently wrong: from_source resolves dims from the
# nested text_config/llm_config first (modeling_fakebase._VLM_CONFIG_ATTRS), which is the
# very problem config_overrides exists to work around, so this path is already correct
# without them. Warn rather than raise: main.py forwards config_overrides unconditionally,
# so hard-failing would leave a single recipe unable to run offline at all.
if config_overrides:
warnings.warn(
"config_overrides is ignored on the FakeBaseModel path: from_source rebuilds the "
"config from the checkpoint, reading dims from the nested text_config/llm_config "
"directly, so the overrides are not needed there.",
stacklevel=2,
)

if use_offline_training and use_fake_base:
_warn_overrides_on_fake_base()
from modelopt.torch.speculative.plugins.modeling_fakebase import FakeBaseModel

return FakeBaseModel.from_source(model_name_or_path, trust_remote_code=trust_remote_code)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Import the transformers-cosmos3 plugin if available: it registers the `cosmos3_omni`
# architecture with AutoConfig on import, so the from_pretrained below recognizes it.
with contextlib.suppress(ImportError):
import transformers_cosmos3 # noqa: F401

model_config = transformers.AutoConfig.from_pretrained(
model_name_or_path,
trust_remote_code=trust_remote_code,
)

# Apply caller-supplied config corrections to the parent config and every nested config
# (some checkpoints don't propagate the nested dims up to the parent).
if config_overrides:
# Cover every nested attribute VLM detection accepts, not just text_config: an
# llm_config-nesting checkpoint mirrors the fields on the parent as None, so an override
# would land on the parent, count as "applied", and never reach the real text tower.
targets = [
cfg
for cfg in (
model_config,
*(getattr(model_config, a, None) for a in NESTED_CONFIG_ATTRS),
)
if cfg is not None
]
unmatched = []
for key, value in config_overrides.items():
applied = False
for cfg_obj in targets:
if hasattr(cfg_obj, key):
setattr(cfg_obj, key, value)
Comment on lines +686 to +703

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] A mistyped or non-applicable override key is silently dropped, reproducing the exact bug this flag exists to fix.

hasattr(cfg_obj, key) guards every assignment, and PretrainedConfig doesn't define a catch-all __getattr__, so --config_overrides '{"num_hiden_layers": 36}' (typo) or a key that exists on neither the parent nor text_config is skipped without a word. The user then gets a wrong-shaped text tower — the original failure mode — but now with a flag that appears to have taken effect. On the cosmos3_omni path this compounds with ignore_mismatched_sizes=True below, so the wrong shapes don't even hard-fail at weight load.

Suggest failing loudly when a key matched nothing:

    if config_overrides:
        targets = [c for c in (model_config, getattr(model_config, "text_config", None)) if c]
        for key, value in config_overrides.items():
            applied = [c for c in targets if hasattr(c, key)]
            if not applied:
                raise ValueError(
                    f"config_overrides key '{key}' is not a field of {type(model_config).__name__} "
                    "or its text_config."
                )
            for cfg_obj in applied:
                setattr(cfg_obj, key, value)

Related: since the loop writes to both configs unconditionally, an override for a name that means different things at the two levels (e.g. hidden_size, where the parent may describe the projector/vision side) will corrupt the parent. Worth a note in the docstring that this flag is for keys whose parent and text meanings coincide.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8eed412. Scoped the guard to the two FakeBaseModel returns rather than rejecting config_overrides + use_offline_training outright — offline training without fake-base goes through the normal path, which now applies overrides correctly, so blanket-rejecting would have blocked a working combination.

applied = True
if not applied:
unmatched.append(key)
if unmatched:
# Silently skipping a key would hand back a wrong-shaped model while appearing to
# have applied the override -- the exact failure this option exists to correct.
raise ValueError(
f"config_overrides key(s) {sorted(unmatched)} matched no field on the model "
f"config (model_type={getattr(model_config, 'model_type', None)!r}) or its "
"text_config. Check for typos."
)

# Detect VLMs: either "vl" in model_type (e.g. "llava") or has a nested text config
# (e.g. Mistral3Config with model_type="mistral3" and text_config attribute).
_is_vlm = "vl" in model_config.model_type.lower() or any(
getattr(model_config, attr, None) is not None for attr in ["text_config", "llm_config"]
getattr(model_config, attr, None) is not None for attr in NESTED_CONFIG_ATTRS
)

if _is_vlm and use_offline_training:
# For VLMs in offline training, FakeBaseModel loads only embed_tokens + lm_head
# and auto-detects VLM weight key layouts (e.g. "language_model.model.embed_tokens").
_warn_overrides_on_fake_base()
from modelopt.torch.speculative.plugins.modeling_fakebase import FakeBaseModel

return FakeBaseModel.from_source(model_name_or_path, trust_remote_code=trust_remote_code)
Expand All @@ -641,13 +739,41 @@ def load_vlm_or_llm(
model_cls = transformers.AutoModelForCausalLM

extra = {}

# Cosmos3 omni checkpoints: the transformers-cosmos3 plugin registers only the config
# (cosmos3_omni) with AutoConfig, never a model under the Auto* maps, so dispatch to its
# Cosmos3ForConditionalGeneration (a Qwen3-VL subclass) directly. The unused vision
# tower has mismatched dims vs the text-only use, so ignore those on load.
if getattr(model_config, "model_type", None) == "cosmos3_omni":
from transformers_cosmos3 import Cosmos3ForConditionalGeneration
Comment on lines +747 to +748

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] This import is reachable without transformers_cosmos3 installed, and then raises a bare ImportError.

The soft import at line ~672 is contextlib.suppress(ImportError), so the plugin isn't guaranteed present here. model_type == "cosmos3_omni" is also satisfiable via trust_remote_code=True, where AutoConfig resolves the config from the checkpoint's own configuration_*.py with no plugin involved. In that case the user gets ModuleNotFoundError: No module named 'transformers_cosmos3' from the middle of load_vlm_or_llm, which reads as a modelopt bug rather than a missing optional package.

A wrapped message points at the install:

    if getattr(model_config, "model_type", None) == "cosmos3_omni":
        try:
            from transformers_cosmos3 import Cosmos3ForConditionalGeneration
        except ImportError as e:
            raise ImportError(
                "Loading a cosmos3_omni checkpoint requires the transformers-cosmos3 package, "
                "which registers only the config with AutoConfig -- the model class must be "
                "imported directly. Install it to load this checkpoint."
            ) from e

Unrelated and trivial: the unmatched ValueError at line 711-713 says "or its text_config", but the targets now span NESTED_CONFIG_ATTRS (text_config and llm_config) — worth saying "or its nested text config" so the message doesn't mislead someone debugging an llm_config checkpoint.


model_cls = Cosmos3ForConditionalGeneration
extra["ignore_mismatched_sizes"] = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] ignore_mismatched_sizes=True turns the one failure mode config_overrides is most likely to produce into a warning.

ignore_mismatched_sizes=True makes from_pretrained randomly re-initialize any tensor whose checkpoint shape disagrees with the config shape, instead of raising. The comment justifies it for the unused vision tower, but the flag is global — it also covers the text tower, which is precisely where config_overrides writes hand-supplied dims.

Concretely: --config_overrides '{"intermediate_size": 12288}' where the true value is 11008 now yields a text tower whose MLP weights are freshly random, with only a logger.warning to show for it. The last review round hardened parse_config_overrides and added the unmatched-key ValueError to catch typo'd key names; a typo'd value on a cosmos3 checkpoint slips through here silently. Two of the PR's own validation steps would not catch it either — merge_lora.py only asserts LoRA-B norms are non-zero, and lm_eval on a partly-random 16B model degrades rather than errors.

The second-order effect is worse in merge_lora.py: the re-initialized tensors are then save_pretrained'd, so the "merged base" checkpoint on disk permanently contains random weights for anything that mismatched (including the vision tower this flag was added for). A downstream multimodal consumer gets garbage with no record of why.

Suggested fix — keep the escape hatch but verify what it actually let through, e.g. request the loading info and fail on anything outside the vision prefix:

model, loading_info = model_cls.from_pretrained(
    model_name_or_path,
    config=model_config if pass_config else None,
    trust_remote_code=trust_remote_code,
    torch_dtype=dtype,
    device_map=device_map,
    output_loading_info=True,
    **extra,
)
unexpected = [k for k, *_ in loading_info.get("mismatched_keys", ()) if not _is_vision_key(k)]
if unexpected:
    raise ValueError(
        f"Checkpoint weights {unexpected[:8]} were re-initialized because their shapes "
        f"disagree with the config. If you passed config_overrides, one of the values is "
        f"wrong for this checkpoint."
    )

(output_loading_info=True changes the return type, so gate it on "ignore_mismatched_sizes" in extra if you'd rather not take it on every path.) A narrower alternative is to only set ignore_mismatched_sizes when config_overrides is not supplied — the two features are individually reasonable but compose badly.


# Pass our config object only when we had to modify it (overrides) or when the model class
# needs the plugin-built config; otherwise let from_pretrained build its own, exactly as before.
pass_config = bool(config_overrides) or "ignore_mismatched_sizes" in extra

# Capture the true depth before any zeroing below, since it is restored after load.
orig_num_hidden_layers = getattr(model_config, "num_hidden_layers", None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] The getattr(..., None) softens a previously-loud failure into a None that propagates.

Before this change the offline path did model.config.num_orig_hidden_layers = model_config.num_hidden_layers, which raised AttributeError immediately on a config that lacks the field. Now orig_num_hidden_layers can be None and gets assigned at line 785, so the attribute exists with value None. That defeats the two consumers' defaults: hf_eagle.py:205 does getattr(self.config, "num_orig_hidden_layers", 0) and gets None rather than 0, and hf_dflash.py:450 reads it directly — both then fail somewhere in draft-model setup with a TypeError far from the cause.

Reachability is low (any decoder config reaching this branch normally has num_hidden_layers), so this is only a hardening nit, but the fix is one line:

Suggested change
orig_num_hidden_layers = getattr(model_config, "num_hidden_layers", None)
# Capture the true depth before any zeroing below, since it is restored after load.
orig_num_hidden_layers = getattr(model_config, "num_hidden_layers", None)
if use_offline_training and orig_num_hidden_layers is None:
raise ValueError(
"Offline training needs the base depth, but the config for "
f"{model_name_or_path} has no num_hidden_layers "
f"(model_type={getattr(model_config, 'model_type', None)!r})."
)


if use_offline_training:
extra["num_hidden_layers"] = 0
if hasattr(model_config, "layer_types"):
extra["layer_types"] = []
if pass_config:
# from_pretrained only forwards unrecognized kwargs into the config when it builds
# that config itself. Given a PretrainedConfig instance it deep-copies it and leaves
# the rest in model_kwargs, so num_hidden_layers=0 would never reach the config and
# the full model would be materialized. Set the fields on the config directly.
model_config.num_hidden_layers = 0
if hasattr(model_config, "layer_types"):
model_config.layer_types = []
else:
extra["num_hidden_layers"] = 0
if hasattr(model_config, "layer_types"):
extra["layer_types"] = []

model = model_cls.from_pretrained(
model_name_or_path,
config=model_config if pass_config else None,
trust_remote_code=trust_remote_code,
torch_dtype=dtype,
device_map=device_map,
Expand All @@ -656,7 +782,7 @@ def load_vlm_or_llm(

if use_offline_training:
# Preserve the original layer count since we loaded with num_hidden_layers=0
model.config.num_orig_hidden_layers = model_config.num_hidden_layers
model.config.num_orig_hidden_layers = orig_num_hidden_layers

return model

Expand Down