Source code for tensorrt_edgellm.checkpoint.checkpoint_utils

# SPDX-FileCopyrightText: Copyright (c) 2025-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.
"""
Checkpoint metadata I/O and runtime sidecars next to exported ONNX.

Weights are loaded only via :func:`loader.load_weights`.
"""

from __future__ import annotations

import json
import logging
import math
import os
import shutil
from typing import TYPE_CHECKING, Any, Dict, Tuple

if TYPE_CHECKING:
    from ..models.default.modeling_default import CausalLM

logger = logging.getLogger(__name__)

__all__ = [
    "RUNTIME_TOKENIZER_FILENAMES",
    "normalize_rope_scaling_for_runtime",
    "rotary_dim_for_runtime",
    "load_checkpoint_config_dicts",
    "load_config_dict",
    "build_runtime_llm_config_dict",
    "write_runtime_artifacts",
]

RUNTIME_TOKENIZER_FILENAMES: Tuple[str, ...] = (
    "tokenizer.json",
    "tokenizer_config.json",
    "tokenizer.model",
    "special_tokens_map.json",
    "processed_chat_template.json",
    "chat_template.jinja",
    "chat_template.json",
)


def normalize_rope_scaling_for_runtime(rope_scaling: Any) -> Any:
    """Normalize HF MRoPE / rope_parameters metadata to the shape expected by
    the C++ runtime.
    """
    if not isinstance(rope_scaling, dict):
        return rope_scaling

    normalized = dict(rope_scaling)
    if "mrope_section" in normalized:
        rope_type = normalized.get("type") or normalized.get("rope_type")
        if rope_type in (None, "default", "mrope"):
            normalized["type"] = "default"
            normalized["rope_type"] = "default"
    # rope_parameters (transformers v5) carries "rope_type" without "type";
    # propagate the alias so Python callers keyed off "type" still work.
    # C++ collectRopeConfig() accepts either key.
    if "type" not in normalized and "rope_type" in normalized:
        normalized["type"] = normalized["rope_type"]
    return normalized


def rotary_dim_for_runtime(rope_config: Dict[str, Any], head_dim: int,
                           fallback_partial_rotary_factor: float) -> int:
    """Return the RoPE binding width expected by the C++ builder/runtime."""
    rope_scaling = rope_config.get("rope_scaling")
    if isinstance(rope_scaling, dict):
        rope_type = str(
            rope_scaling.get("type") or rope_scaling.get("rope_type") or "")
        if rope_type in ("default", "proportional"):
            return int(head_dim)
    partial_rotary_factor = float(
        rope_config.get("partial_rotary_factor",
                        fallback_partial_rotary_factor))
    return int(float(head_dim) * partial_rotary_factor)


def _normalize_explicit_rope_config_for_runtime(
        rope_config: Dict[str, Any]) -> Dict[str, Any]:
    """Normalize one explicit runtime RoPE config block."""
    normalized = dict(rope_config)
    normalized["rope_scaling"] = normalize_rope_scaling_for_runtime(
        normalized.get("rope_scaling"))
    return normalized


def _torch_dtype_to_config_str(dtype: Any) -> str:
    """Map a ``torch.dtype`` to the string token the runtime parser accepts
    (see cpp/runtime/config/llmEngineConfig.cpp::parseStateDtype).

    Deferred import of torch so importing this module does not drag it in on
    pure-CPU tooling paths.
    """
    import torch
    mapping = {
        torch.float16: "fp16",
        torch.float32: "fp32",
        torch.bfloat16: "bf16",
    }
    if dtype not in mapping:
        raise ValueError(
            f"No config-string mapping for torch dtype {dtype!r}. "
            f"Supported: {sorted(v for v in mapping.values())}")
    return mapping[dtype]


def _nested_config_to_dict(sub: Any) -> Dict[str, Any]:
    if isinstance(sub, dict):
        return sub
    if hasattr(sub, "to_dict"):
        return sub.to_dict()
    return {}


def _promote_llm_subconfig(config: Any, root: Dict[str,
                                                   Any]) -> Dict[str, Any]:
    """Return the dict used for LLM architecture fields (text / nested block)."""
    if root.get("num_attention_heads") is not None:
        return root

    # Qwen3-ASR / Qwen3-Omni: LLM lives at thinker_config.text_config.
    # Resolve the thinker/talker wrappers BEFORE the generic top-level loop:
    # newer transformers can expose a spurious top-level ``text_config`` (with
    # the wrong hidden_size) alongside the real thinker_config.text_config, and
    # the generic loop would otherwise pick that wrong one.
    thinker = root.get("thinker_config")
    if isinstance(thinker, dict):
        for name in ("text_config", "llm_config", "language_config"):
            sub_dict = _nested_config_to_dict(thinker.get(name, {}))
            if (sub_dict.get("hidden_size") is not None
                    and sub_dict.get("num_attention_heads") is not None):
                return sub_dict

    # Qwen3-TTS: LLM (talker) lives at talker_config
    talker = root.get("talker_config")
    if isinstance(talker, dict):
        if (talker.get("hidden_size") is not None
                and talker.get("num_attention_heads") is not None):
            return talker

    # Standard VLMs (e.g. Qwen2.5-VL): LLM is a top-level sub-config.
    for name in ("llm_config", "text_config", "language_config"):
        sub = getattr(config, name, None)
        if sub is None and name in root:
            sub = root[name]
        sub_dict = _nested_config_to_dict(sub)
        if (sub_dict.get("hidden_size") is not None
                and sub_dict.get("num_attention_heads") is not None):
            return sub_dict

    return root


def _promote_alpamayo_llm_config(root: Dict[str, Any]) -> Dict[str, Any]:
    """For Alpamayo-R1: load the full VLM text config from ``vlm_name_or_path``.

    The Alpamayo root config.json is flat and does not embed VLM architecture
    fields.  We load ``AutoConfig.from_pretrained(vlm_name_or_path)`` to get
    the full Qwen3-VL config and then promote its text sub-config (which
    contains ``num_attention_heads``, ``hidden_size``, etc.).
    """
    from transformers import AutoConfig

    # vlm_name_or_path is a default in AlpamayoR1Config, not persisted in
    # config.json.  Fall back to the known default for Alpamayo-R1.
    vlm_name = root.get("vlm_name_or_path", "Qwen/Qwen3-VL-8B-Instruct")
    if not vlm_name:
        logger.warning("alpamayo_r1 config missing vlm_name_or_path; "
                       "falling back to root config")
        return root

    try:
        vlm_cfg = AutoConfig.from_pretrained(vlm_name, trust_remote_code=True)
        vlm_dict = vlm_cfg.to_dict()
    except (ValueError, OSError) as exc:
        logger.warning(
            "Failed to load VLM config from %s (%s); "
            "falling back to root config", vlm_name, exc)
        return root

    # The VLM config (e.g. Qwen3-VL) has a text sub-config at text_config
    # or language_config.  Promote it using the existing helper.
    llm = _promote_llm_subconfig(vlm_cfg, vlm_dict)

    # Alpamayo extends the vocabulary with trajectory tokens; the root
    # config carries the true vocab_size which must override the base VLM's.
    if root.get("vocab_size") is not None:
        llm["vocab_size"] = root["vocab_size"]

    # Qwen3-VL text_config stores mRoPE info under ``rope_parameters``
    # rather than ``rope_scaling``.  Promote it so downstream code and the
    # C++ runtime find it under the expected ``rope_scaling`` key.
    if not llm.get("rope_scaling") and llm.get("rope_parameters"):
        llm["rope_scaling"] = llm["rope_parameters"]

    return llm


[docs] def load_checkpoint_config_dicts( model_dir: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: """Return ``(root_dict, llm_dict)`` from the checkpoint config. Tries ``AutoConfig.from_pretrained`` first (handles registered HF model types). Falls back to reading ``config.json`` directly for custom / not-yet-registered model types (e.g. ``qwen3_asr``, ``qwen3_tts``). For multimodal models (e.g. Qwen2.5-VL, Qwen3-ASR), the LLM text config is promoted out of the nested sub-object by :func:`_promote_llm_subconfig`. Any fields lost during promotion are patched back from the raw JSON. """ from transformers import AutoConfig raw_path = os.path.join(model_dir, "config.json") raw: Dict[str, Any] = {} if os.path.exists(raw_path): with open(raw_path) as _f: raw = json.load(_f) elif not os.path.isdir(model_dir): # model_dir is likely an HF model ID (e.g. "Qwen/Qwen3-ASR-0.6B"). # Download config.json from HF Hub so the raw fallback works. try: from huggingface_hub import hf_hub_download local = hf_hub_download(model_dir, "config.json") with open(local) as _f: raw = json.load(_f) except (OSError, ImportError, ValueError): pass try: config = AutoConfig.from_pretrained(model_dir, trust_remote_code=True) root = config.to_dict() except (ValueError, OSError) as exc: # Unknown / not-yet-registered model type — fall back to raw JSON. logger.warning( "AutoConfig.from_pretrained failed for %s (%s); " "falling back to raw config.json.", model_dir, exc, ) root = raw config = root # Alpamayo-R1: flat config with no embedded VLM sub-config. # Load the full VLM architecture config from vlm_name_or_path and promote # the text sub-config so downstream sees a standard Qwen3-VL text config. if root.get("model_type") == "alpamayo_r1": llm = _promote_alpamayo_llm_config(root) else: llm = _promote_llm_subconfig(config, root) # Patch: for multimodal models where AutoConfig loses top-level fields, # merge them in from the raw config.json. Only fields absent from llm # are copied; existing llm fields are never overwritten. for key, val in raw.items(): if key not in llm and val is not None: llm[key] = val # VLM / transformers v5 rope compatibility: rope_scaling may be null # while rope_parameters carries the real config (transformers v5 # convention), and either may live only in a nested sub-config # (e.g. text_config for Qwen3-VL). Recover into rope_scaling so # collectRopeConfig() in C++ detects kMRope correctly and Python # readers that key off rope_scaling (e.g. longrope in # build_runtime_llm_config_dict) still work. if not llm.get("rope_scaling"): candidate = llm.get("rope_parameters") if not candidate: for subkey in ("text_config", "language_config", "llm_config"): raw_sub = raw.get(subkey) or {} if isinstance(raw_sub, dict): candidate = (raw_sub.get("rope_scaling") or raw_sub.get("rope_parameters")) if candidate: break if isinstance(candidate, dict): llm["rope_scaling"] = candidate if llm.get("rope_scaling"): llm["rope_scaling"] = normalize_rope_scaling_for_runtime( llm["rope_scaling"]) return root, llm
[docs] def load_config_dict(model_dir: str) -> Dict[str, Any]: """Return only the promoted LLM config dict.""" return load_checkpoint_config_dicts(model_dir)[1]
def _export_tool_version() -> str: """Version string for runtime ``config.json`` (``edgellm_version`` field).""" from .._version import __version__ return __version__ def _determine_spec_decode_type(config) -> str: """Return the speculative decoding algorithm for runtime config.""" if config.gemma4_mtp_base or config.gemma4_mtp_draft: return "gemma4_mtp" if config.is_eagle3_draft or config.eagle_base: return "eagle3" if config.is_dflash_draft or config.dflash_base: return "dflash" if config.is_dspark_draft or config.dspark_base: return "dspark" if config.is_mtp_draft or config.mtp_base: return "mtp" return "none" def _determine_engine_role(config) -> str: """Return the engine role within the speculative decoding deployment.""" if (config.is_eagle3_draft or config.is_dflash_draft or config.is_dspark_draft or config.is_mtp_draft or config.gemma4_mtp_draft): return "draft" if (config.eagle_base or config.dflash_base or config.dspark_base or config.mtp_base or config.gemma4_mtp_base): return "base" return "llm" def build_runtime_llm_config_dict(model: "CausalLM") -> Dict[str, Any]: """JSON object written as the runtime config beside the ONNX export. Head and intermediate sizes describe the per-rank ONNX file this config sits next to. When ``tp_size > 1`` the config is per-rank, stamped with ``tp_size`` and ``tp_rank`` so each rank's artifact is self-describing. For single-device exports no TP fields are emitted. """ config = model.config mc = config.mamba_cfg rope_scaling = normalize_rope_scaling_for_runtime(config.rope_scaling) tp_size = max(1, getattr(config, "tp_size", 1)) tp_rank = max(0, getattr(config, "tp_rank", 0)) diffusion_engine_role = getattr(model, "diffusion_engine_role", None) if config.is_diffusion_gemma and diffusion_engine_role is None: diffusion_engine_role = "dllm" out: Dict[str, Any] = { "model": ("diffusion_gemma_text" if config.is_diffusion_gemma else config.model_type), "spec_decode_type": _determine_spec_decode_type(config), "engine_role": diffusion_engine_role or _determine_engine_role(config), "edgellm_version": _export_tool_version(), "vocab_size": config.vocab_size, "hidden_size": config.hidden_size, "intermediate_size": config.intermediate_size, "num_hidden_layers": config.num_hidden_layers, "num_attention_heads": config.num_attention_heads, "num_key_value_heads": config.num_key_value_heads, "head_dim": config.head_dim, "max_position_embeddings": config.max_position_embeddings, "rope_theta": config.rope_theta, "rope_scaling": rope_scaling, "partial_rotary_factor": config.partial_rotary_factor, "num_deepstack_features": config.num_deepstack_features, "use_vision_bidirectional_attention": bool(config.use_vision_bidirectional_attention and not (config.eagle_base or config.dflash_base or config.dspark_base or config.mtp_base or config.gemma4_mtp_base or config.is_eagle3_draft or config.is_dflash_draft or config.is_dspark_draft or config.is_mtp_draft or config.gemma4_mtp_draft)), "rms_norm_eps": float(config.rms_norm_eps), } if config.attention_scaling: out["attention_scaling"] = float(config.attention_scaling) if config.final_logit_softcapping is not None: out["final_logit_softcapping"] = float(config.final_logit_softcapping) if config.attention_layer_types: out["attention_layer_types"] = list(config.attention_layer_types) if config.sliding_window_size >= 0: out["sliding_window_size"] = int(config.sliding_window_size) if config.attention_k_eq_v: out["attention_k_eq_v"] = True if config.num_global_key_value_heads: out["num_global_key_value_heads"] = int( config.num_global_key_value_heads) if config.num_experts > 0: out.update({ "num_experts": int(config.num_experts), "num_experts_per_tok": int(config.num_experts_per_tok), "moe_intermediate_size": int(config.moe_intermediate_size), "enable_moe_block": bool(config.enable_moe_block), }) if tp_size > 1: out["tp_size"] = tp_size out["tp_rank"] = tp_rank if config.is_diffusion_gemma: diffusion_cfg = (config.diffusion.to_dict() if config.diffusion is not None else {}) diffusion_unified_conditioning = bool( getattr(model, "diffusion_unified_conditioning", False)) if diffusion_engine_role == "dllm" and not diffusion_unified_conditioning: raise ValueError( "DiffusionGemma dllm export requires unified self-conditioning. " "Use DiffusionGemmaBackbone so the self-conditioning module is " "created with the backbone before writing runtime config.") out.update({ "decoding_strategy": "block_diffusion", "diffusion_config": diffusion_cfg, "self_conditioning_size": config.self_conditioning_size, "diffusion_unified_conditioning": diffusion_unified_conditioning, "context_mask_selector_enabled": (diffusion_engine_role == "dllm"), "diffusion_engines": { "dllm": { "path": "dllm.engine", "role": "dllm", }, }, }) # Heterogeneous head dimensions (e.g. Gemma4: sliding=256, global=512) if config.global_head_dim and config.global_head_dim != config.head_dim: out["global_head_dim"] = config.global_head_dim use_global_kv_heads = bool(config.attention_k_eq_v and config.num_global_key_value_heads) # C++ sizes KV tensors from kv_layer_configs below. Keep the top-level # field only for Python/config round-trip metadata. if (config.num_global_key_value_heads and use_global_kv_heads and config.num_global_key_value_heads != config.num_key_value_heads): out["num_global_key_value_heads"] = config.num_global_key_value_heads out["layer_types"] = config.layer_types # Emit kv_layer_configs so C++ runtime sizes per-layer KV cache correctly. # The C++ parser expects "attention"/"mamba" strings in layer_types when # kv_layer_configs is present, so emit a normalised copy. norm_lt: list = [] kv_cfgs: list = [] full_attention_kv_heads = (config.num_global_key_value_heads if use_global_kv_heads else config.num_key_value_heads) for lt in config.layer_types: norm_lt.append("attention") # all layers are attention in Gemma4 if lt == "full_attention": kv_cfgs.append({ "num_kv_heads": full_attention_kv_heads, "head_dim": config.global_head_dim }) else: kv_cfgs.append({ "num_kv_heads": config.num_key_value_heads, "head_dim": config.head_dim }) out["layer_types"] = norm_lt out["kv_layer_configs"] = kv_cfgs # Per-layer-type RoPE: extract global attention RoPE parameters from # rope_scaling.full_attention (Gemma4: theta=1000000, prf=0.25). full_attn_rope = (rope_scaling or {}).get("full_attention", {}) if full_attn_rope.get("rope_theta"): out["global_rope_theta"] = float(full_attn_rope["rope_theta"]) # KV-sharing donors: shared layers read from donor layer's KV cache. num_kv_shared = getattr(config, "num_kv_shared_layers", 0) if num_kv_shared > 0 and not config.gemma4_mtp_draft: from ..models.gemma4.modeling_gemma4_text import \ _compute_kv_donor_indices donor_map = _compute_kv_donor_indices(config) # Build donors array of length num_hidden_layers (all are attention). # -1 = no sharing, otherwise = donor layer index. donors = [-1] * config.num_hidden_layers for shared_idx, donor_idx in donor_map.items(): donors[shared_idx] = donor_idx out["kv_sharing_donors"] = donors ple_enabled = config.hidden_size_per_layer_input > 0 out["ple_enabled"] = ple_enabled out["num_ple_inputs"] = config.num_hidden_layers if ple_enabled else 0 out["ple_hidden_size"] = (config.hidden_size_per_layer_input if ple_enabled else 0) # longrope requires original_max_position_embeddings for scaling factor computation. if (isinstance(rope_scaling, dict) and rope_scaling.get("type") == "longrope" and config.original_max_position_embeddings is not None): out["original_max_position_embeddings"] = config.original_max_position_embeddings if config.use_dual_rope: out["sliding_rope_config"] = _normalize_explicit_rope_config_for_runtime( config.sliding_rope_config or {}) out["full_rope_config"] = _normalize_explicit_rope_config_for_runtime( config.full_rope_config or {}) if config.gemma4_mtp_draft: out["sliding_rotary_dim"] = rotary_dim_for_runtime( out["sliding_rope_config"], config.head_dim, 1.0) out["full_rotary_dim"] = rotary_dim_for_runtime( out["full_rope_config"], config.global_head_dim or config.head_dim, config.partial_rotary_factor, ) if config.is_hybrid and mc is not None: out.update({ "num_linear_attn_layers": config.num_mamba_layers, "num_attention_layers": config.num_attn_layers, "recurrent_state_num_heads": mc.num_heads, "recurrent_state_head_dim": mc.head_dim, "recurrent_state_size": mc.ssm_state_size, "recurrent_state_num_groups": mc.n_groups, "recurrent_spec_verify_mode": "replay", "conv_dim": mc.conv_dim, "conv_kernel": mc.conv_kernel, # Nemotron-H attention is NoPE "use_rope": config.num_attn_layers > 0 and not config.is_nemotron_h, }) gc = config.gdn_cfg if config.is_hybrid and gc is not None: out.update({ "num_linear_attn_layers": config.num_gdn_layers, "num_attention_layers": config.num_attn_layers, "recurrent_state_num_heads": gc.num_value_heads, "recurrent_state_head_dim": gc.key_head_dim, "recurrent_state_size": gc.value_head_dim, "recurrent_spec_verify_mode": "snapshot", "conv_dim": gc.conv_dim, "conv_kernel": gc.conv_kernel, "use_rope": config.num_attn_layers > 0, }) if (not config.is_hybrid and config.num_attn_layers != config.num_hidden_layers): out["num_attention_layers"] = config.num_attn_layers # Emit canonical per-layer config consumed by the C++ HybridCacheManager. # Only attention and linear-attention layers carry KV/recurrent state and # must appear in the per-layer routing table. MLP/MoE layers are skipped. _emit_kv_table = config.layer_types and ( config.is_hybrid or config.num_attn_layers != config.num_hidden_layers) if _emit_kv_table: from ..config import (_VALID_ATTENTION_LAYER_TYPES, LAYER_ATTN, LAYER_GDN, LAYER_MAMBA) # ``config.layer_types`` normalizes recurrent layers to LAYER_GDN / # LAYER_MAMBA, but attention layers keep their raw HF type (e.g. # Qwen3.5 uses ``"full_attention"``), so match the attention family # explicitly. Matching only LAYER_ATTN silently drops those layers, # collapsing the per-layer routing table and shifting every attention # layer's position (see num_attn_layers, which counts the same set). attention_types = (LAYER_ATTN, ) + _VALID_ATTENTION_LAYER_TYPES normalized_layer_types: list = [] kv_layer_configs: list = [] for layer_idx, lt in enumerate(config.layer_types): if lt in attention_types: normalized_layer_types.append("attention") num_kv_heads = config.num_key_value_heads head_dim = config.head_dim if (str(config.model_type).startswith("gemma4") and config.attention_layer_types): attention_type = config.attention_layer_types[layer_idx] if attention_type == "full_attention": if config.global_head_dim: head_dim = config.global_head_dim if (config.attention_k_eq_v and config.num_global_key_value_heads): num_kv_heads = config.num_global_key_value_heads kv_layer_configs.append({ "num_kv_heads": num_kv_heads, "head_dim": head_dim, }) elif lt in (LAYER_MAMBA, LAYER_GDN): normalized_layer_types.append("mamba") kv_layer_configs.append(None) # Non-stateful layers (e.g. LAYER_MLP) have no cache slot and are # intentionally omitted from the per-layer routing table. out["layer_types"] = normalized_layer_types out["kv_layer_configs"] = kv_layer_configs if config.is_eagle3_draft: draft_vocab = config.draft_vocab_size or config.vocab_size target_hidden = config.eagle3_target_hidden_size out.update({ "draft_vocab_size": draft_vocab, "base_model_hidden_size": target_hidden * config.eagle3_num_target_layers, "eagle3_config": { "target_layer_ids": list(config.eagle3_target_layer_ids), "num_target_layers": config.eagle3_num_target_layers, }, }) if config.is_mtp_draft: # MTP draft shares vocab with base (no reduced vocab) and receives # base hidden states of size hidden_size (not 3x like EAGLE3). out.update({ "draft_vocab_size": config.vocab_size, "base_model_hidden_size": config.hidden_size, }) if config.gemma4_mtp_base: out.update({ "base_model_hidden_size": config.hidden_size, "layer_types": list(config.raw_layer_types or config.layer_types), "sliding_window": config.sliding_window_size, "global_head_dim": config.global_head_dim, "num_kv_shared_layers": config.num_kv_shared_layers, "rope_parameters": normalize_rope_scaling_for_runtime(config.rope_parameters), "attention_k_eq_v": config.attention_k_eq_v, }) if config.gemma4_mtp_draft: out.update({ "model": "gemma4_assistant", "draft_vocab_size": config.vocab_size, "base_model_hidden_size": config.backbone_hidden_size, "assistant_hidden_size": config.assistant_hidden_size or config.hidden_size, "shares_target_kv": config.shares_target_kv, "has_own_kv_cache": config.has_own_kv_cache, "constant_draft_positions": config.constant_draft_positions, "returns_feedback_hidden": config.returns_feedback_hidden, "use_ordered_embeddings": config.use_ordered_embeddings, "num_centroids": config.num_centroids, "centroid_intermediate_top_k": config.centroid_intermediate_top_k, "sparse_logits_enabled": config.sparse_logits_enabled, "layer_types": list(config.raw_layer_types or config.layer_types), "sliding_window": config.sliding_window_size, "global_head_dim": config.global_head_dim, "num_kv_shared_layers": config.num_kv_shared_layers, "rope_parameters": normalize_rope_scaling_for_runtime(config.rope_parameters), "attention_k_eq_v": config.attention_k_eq_v, "kv_sharing_map": list(config.kv_sharing_map), }) if config.is_dflash_draft: out.update({ "draft_vocab_size": config.vocab_size, "base_model_hidden_size": len(config.dflash_target_layer_ids) * config.hidden_size, "dflash_config": { "target_layer_ids": list(config.dflash_target_layer_ids), "block_size": config.dflash_block_size, "mask_token_id": config.dflash_mask_token_id, }, }) if config.dflash_base: out.update({ "dflash_config": { "target_layer_ids": list(config.dflash_target_layer_ids), "block_size": config.dflash_block_size, "mask_token_id": config.dflash_mask_token_id, }, }) if config.is_dspark_draft: dspark_cfg = { "target_layer_ids": list(config.dspark_target_layer_ids), "block_size": config.dspark_block_size, "mask_token_id": config.dspark_mask_token_id, "enable_confidence_head": config.dspark_enable_confidence_head, "confidence_head_with_markov": config.dspark_confidence_head_with_markov, "markov_head_type": config.dspark_markov_head_type, "markov_rank": config.dspark_markov_rank, "heads_file": "dspark_heads.safetensors", "heads_info_file": "dspark_heads_info.json", } out.update({ "draft_vocab_size": config.vocab_size, "base_model_hidden_size": len(config.dspark_target_layer_ids) * config.hidden_size, "dspark_config": dspark_cfg, }) if config.dspark_base: out.update({ "dspark_config": { "target_layer_ids": list(config.dspark_target_layer_ids), "block_size": config.dspark_block_size, "mask_token_id": config.dspark_mask_token_id, "enable_confidence_head": config.dspark_enable_confidence_head, "confidence_head_with_markov": config.dspark_confidence_head_with_markov, "markov_head_type": config.dspark_markov_head_type, "markov_rank": config.dspark_markov_rank, }, }) if config.eagle_base: # EAGLE3 base: record which layers provide hidden states to the draft. target_layers = list(config.eagle3_target_layer_ids) if not target_layers: n_layers = config.num_hidden_layers target_layers = [2, n_layers // 2, n_layers - 4] out["eagle_hidden_state_layers"] = target_layers if config.reduced_vocab_size: out["reduced_vocab_size"] = config.reduced_vocab_size # KV cache dtype is baked in at export time. The C++ runtime parses it # strictly from config.json (no engine-introspection back-patching). # Mirrors llm_export.py: "fp8" when KV cache is quantised, otherwise "fp16". out["kv_cache_dtype"] = ("fp8" if config.quant.kv_cache_quant == "fp8" else "fp16") # Hybrid models (Mamba / GDN / Nemotron-H) bake in recurrent-state and # conv-state dtypes at export time. The authoritative source is the # model class itself — `export_onnx` constructs dummy tensors with these # dtypes, which in turn fix the ONNX binding dtypes the engine is built # with. Reading them from the same class attribute used there guarantees # the config string cannot drift from the engine binding. The C++ runtime # validator cross-checks the config dtype against the engine binding at # init, so a drift would fail loudly at load time; this keeps both sides # pinned to one source. if out.get("num_linear_attn_layers", 0) > 0: for attr, key in (("RECURRENT_STATE_DTYPE", "recurrent_state_dtype"), ("CONV_STATE_DTYPE", "conv_state_dtype")): torch_dtype = getattr(model, attr, None) if torch_dtype is None: raise AttributeError( f"{type(model).__name__} is hybrid (num_linear_attn_layers>0) " f"but does not expose {attr}. Add a class-level {attr} " f"(torch.dtype) to the model class; its value must match " f"the dtype of the dummy state tensor its export_onnx " f"builds and the dtype mandated by the plugin schema.") out[key] = _torch_dtype_to_config_str(torch_dtype) return out def _build_alpamayo_tokenizer(config: Dict[str, Any], out_dir: str) -> None: """Build and save the Alpamayo-R1 tokenizer with added trajectory tokens. The base tokenizer comes from the VLM (e.g. Qwen3-VL-8B-Instruct). Alpamayo adds discrete trajectory tokens (<i0> .. <i767>) and special trajectory tokens (<|traj_history|>, <|traj_future|>, etc.) on top. """ vlm_name = config.get("vlm_name_or_path", "Qwen/Qwen3-VL-8B-Instruct") if not vlm_name: return try: from transformers import AutoProcessor try: processor = AutoProcessor.from_pretrained(vlm_name, trust_remote_code=True) except (OSError, ValueError) as online_exc: logger.warning( "Failed to load Alpamayo tokenizer from %s (%s); " "retrying local cache", vlm_name, online_exc) processor = AutoProcessor.from_pretrained(vlm_name, trust_remote_code=True, local_files_only=True) tokenizer = processor.tokenizer # Add discrete trajectory tokens traj_vocab_size = config.get("traj_vocab_size", 768) if traj_vocab_size: discrete_tokens = [f"<i{v}>" for v in range(traj_vocab_size)] tokenizer.add_tokens(discrete_tokens) # Add special trajectory tokens _TRAJ_TOKENS = [ "<|traj_history|>", "<|traj_future|>", "<|traj_history_start|>", "<|traj_future_start|>", "<|traj_history_end|>", "<|traj_future_end|>", ] add_special = config.get("add_special_tokens", False) if add_special: _SPECIAL_TOKENS_KEYS = [ "prompt_start", "prompt_end", "image_start", "image_pre_tkn", "image_end", "traj_history_start", "traj_history_pre_tkn", "traj_history_end", "cot_start", "cot_end", "meta_action_start", "meta_action_end", "traj_future_start", "traj_future_pre_tkn", "traj_future_end", "traj_history", "traj_future", "image_pad", "vectorized_wm", "vectorized_wm_start", "vectorized_wm_end", "vectorized_wm_pre_tkn", "route_start", "route_pad", "route_end", "question_start", "question_end", "answer_start", "answer_end", ] special_tokens = ["<|" + k + "|>" for k in _SPECIAL_TOKENS_KEYS] tokenizer.add_tokens(special_tokens, special_tokens=True) else: tokenizer.add_tokens(_TRAJ_TOKENS, special_tokens=True) os.makedirs(out_dir, exist_ok=True) tokenizer.save_pretrained(out_dir) logger.info("Saved Alpamayo tokenizer (%d tokens) to %s", len(tokenizer), out_dir) except (ImportError, OSError, ValueError) as exc: logger.warning("Failed to build Alpamayo tokenizer: %s", exc) def _runtime_embedding_scale(model: "CausalLM") -> float: """Return the scale folded into runtime token embedding sidecars.""" config = model.config explicit_scale = getattr(config, "embedding_scale", None) if explicit_scale is not None: return float(explicit_scale) if str(getattr(config, "model_type", "")).startswith("gemma4"): return math.sqrt(float(config.hidden_size)) return 1.0 def write_runtime_artifacts(model: "CausalLM", model_dir: str, out_dir: str, fp8_embedding: bool = False, reduced_vocab_dir: str = "", config_filename: str = "config.json") -> None: """Write the runtime config, ``embedding.safetensors``, tokenizer copies, chat template. ``config_filename`` selects the filename for the runtime config. Use the default ``"config.json"`` for single-device exports, or ``"config_tp{N}_rank{R}.json"`` for per-rank TP exports. """ import torch from tensorrt_edgellm._safetensors_io import save_file from ..chat_template import (process_chat_template, write_fallback_processed_chat_template) os.makedirs(out_dir, exist_ok=True) cfg_json = build_runtime_llm_config_dict(model) # For VLM models, the C++ VLM runner (qwenViTRunner, internViTRunner) # reads vision_config from the LLM config.json. Preserve it from the # original HF config so the runtime can find deepstack_visual_indexes, # num_position_embeddings, etc. root_cfg = {} if model_dir: hf_cfg_path = os.path.join(model_dir, "config.json") if os.path.exists(hf_cfg_path): with open(hf_cfg_path) as _f: root_cfg = json.load(_f) if root_cfg.get("vision_config"): cfg_json["vision_config"] = root_cfg["vision_config"] # Propagate eos_token_id so the C++ runtime can stop on any EOS # token (e.g. Gemma4 uses [1, 106]). Check config.json first, # then fall back to generation_config.json (some models only set # eos_token_id there). eos = root_cfg.get("eos_token_id") if eos is None: gen_cfg_path = os.path.join(model_dir, "generation_config.json") if os.path.exists(gen_cfg_path): with open(gen_cfg_path) as _gf: gen_cfg = json.load(_gf) eos = gen_cfg.get("eos_token_id") if isinstance(eos, list): cfg_json["eos_token_id"] = [int(x) for x in eos] elif isinstance(eos, int): cfg_json["eos_token_id"] = [eos] cfg_path = os.path.join(out_dir, config_filename) with open(cfg_path, "w") as f: json.dump(cfg_json, f, indent=2) logger.info("Wrote %s to %s", config_filename, out_dir) # EAGLE3 draft models don't need embedding.safetensors — the C++ runtime # uses the base model's shared embedding table (the builder already skips # copying for draft models). if (model.config.is_eagle3_draft or model.config.is_mtp_draft or model.config.is_gemma4_mtp_draft): kind = ("EAGLE3 draft" if model.config.is_eagle3_draft else "Gemma4 MTP draft" if model.config.is_gemma4_mtp_draft else "MTP draft") logger.info( "%s: skipping embedding.safetensors (uses base model embedding)", kind) else: embed = getattr(model, "embed_tokens", None) if embed is None: embed = getattr(getattr(model, "model", None), "embed_tokens", None) if embed is None: embed = getattr(getattr(model, "backbone", None), "embeddings", None) if embed is not None: weight = embed.weight.data.detach().cpu() embedding_scale = _runtime_embedding_scale(model) if embedding_scale != 1.0: weight = weight * embedding_scale # C++ runtime requires FP16 (or FP8) embedding; cast if needed. if weight.dtype in (torch.float32, torch.bfloat16): weight = weight.to(torch.float16) embedding_path = os.path.join(out_dir, "embedding.safetensors") if fp8_embedding: from .embedding_quantization import quantize_embedding_to_fp8 embedding_fp8, scales = quantize_embedding_to_fp8(weight) save_file( { "embedding": embedding_fp8, "embedding_scale": scales }, embedding_path) logger.info("Wrote FP8 embedding.safetensors (%s)", list(weight.shape)) else: save_file({"embedding": weight}, embedding_path) logger.info("Wrote embedding.safetensors (%s)", list(weight.shape)) else: logger.warning( "embed_tokens not found; skipping embedding.safetensors") if model.config.ple_enabled: ple_embed = getattr(getattr(model, "model", None), "embed_tokens_per_layer", None) if ple_embed is None: raise ValueError( "Gemma4 PLE is enabled but embed_tokens_per_layer is missing" ) ple_weight = ple_embed.weight.data.detach().cpu() ple_weight = ple_weight * math.sqrt( model.config.hidden_size_per_layer_input) if ple_weight.dtype in (torch.float32, torch.bfloat16): ple_weight = ple_weight.to(torch.float16) ple_path = os.path.join(out_dir, "ple_embedding.safetensors") save_file({"weight": ple_weight.contiguous()}, ple_path) logger.info("Wrote ple_embedding.safetensors (%s)", list(ple_weight.shape)) # Alpamayo-R1: tokenizer lives in the VLM checkpoint, not in model_dir. # Build it first so that tokenizer files exist before the copy loop # (which is a no-op for Alpamayo) and before process_chat_template. if root_cfg.get("model_type") == "alpamayo_r1": _build_alpamayo_tokenizer(root_cfg, out_dir) for fname in RUNTIME_TOKENIZER_FILENAMES: src = os.path.join(model_dir, fname) if os.path.exists(src): shutil.copy2(src, os.path.join(out_dir, fname)) logger.info("Copied %s", fname) # If tokenizer.json is missing but vocab.json+merges.txt exist (GPT-2 # format, used by Qwen3-ASR/TTS), generate tokenizer.json using the # transformers library so the C++ runtime can load it. tok_json_dst = os.path.join(out_dir, "tokenizer.json") if not os.path.exists(tok_json_dst) and model_dir: vocab_src = os.path.join(model_dir, "vocab.json") merges_src = os.path.join(model_dir, "merges.txt") if os.path.exists(vocab_src) and os.path.exists(merges_src): try: from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained(model_dir) tok.save_pretrained(out_dir) logger.info( "Generated tokenizer.json from vocab.json+merges.txt") except (OSError, ValueError, ImportError): logger.warning("Failed to generate tokenizer.json; " "copying vocab.json and merges.txt as fallback") shutil.copy2(vocab_src, os.path.join(out_dir, "vocab.json")) shutil.copy2(merges_src, os.path.join(out_dir, "merges.txt")) # EAGLE3 draft: save d2t (draft-to-target vocab map) d2t = getattr(model, "d2t", None) if d2t is not None: d2t_cpu = d2t.data.cpu().to(torch.int32) save_file({"d2t": d2t_cpu}, os.path.join(out_dir, "d2t.safetensors")) logger.info("Wrote d2t.safetensors (%s)", list(d2t_cpu.shape)) from ..vocab_reduction.onnx_export import copy_reduced_vocab_artifacts copy_reduced_vocab_artifacts(model, out_dir, reduced_vocab_dir) template_dst = os.path.join(out_dir, "processed_chat_template.json") if not os.path.exists(template_dst) and model_dir: process_chat_template(model_dir, out_dir) if not os.path.exists(template_dst): write_fallback_processed_chat_template(model_dir, out_dir)