Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2a51f38
Add the Kimi-K2.5 expert-parallel adapter
shengliangxu Oct 30, 2026
21d43db
Say why the Kimi-K2.5 spec keeps modeling_source at transformers, and…
shengliangxu Oct 30, 2026
3c0336b
Keep only the sibling-module claim in the Kimi-K2.5 swap test
shengliangxu Oct 30, 2026
15c11eb
Prepare a remote MoE's spec only for an expert-parallel plan
shengliangxu Oct 30, 2026
80a8a26
Make a remote Kimi's code importable for the plan decision, before pr…
shengliangxu Oct 30, 2026
bced888
Say plainly when a spec's prepare, make_importable and stated plan run
shengliangxu Oct 30, 2026
3aade0d
Count a checkpoint its spec decompresses as one the expert-parallel l…
shengliangxu Oct 30, 2026
8af85cf
Mirror the Kimi-K2.5 adapter's module in its tests
shengliangxu Oct 31, 2026
1eed0d0
Have a remote Kimi's code build, not only import, before the plan dec…
shengliangxu Oct 31, 2026
ea86e8f
Derive the Kimi-K2.5 decompressor test's expectation from its spec
shengliangxu Oct 31, 2026
fe075f4
Pin a rank failing alone where the decided expert-parallel plan is pr…
shengliangxu Oct 31, 2026
978b270
Unload the build-step test's remote Kimi code after it
shengliangxu Oct 31, 2026
c83024c
Run the Kimi build step only when there is code of the checkpoint's t…
shengliangxu Oct 31, 2026
84c06e0
State the formats Kimi-K2.5's prepare decompresses once
shengliangxu Oct 31, 2026
5ae6705
Keep the Kimi build step's tie_weights shim to remote code
shengliangxu Oct 31, 2026
7a1f1cf
Test that a spec with a prepare states the plan the decision reads
shengliangxu Oct 31, 2026
b88d349
Say how the plan decision treats the formats Kimi-K2.5's prepare deco…
shengliangxu Oct 31, 2026
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
1 change: 1 addition & 0 deletions modelopt/torch/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
minimax_m3_vl,
llama4_text,
kimi_vl,
kimi_k25,
nemotron_h_omni,
qwen2_moe,
qwen3,
Expand Down
67 changes: 55 additions & 12 deletions modelopt/torch/models/_kimi.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

# modelopt.torch.utils and transformers are imported inside the functions: the model registry imports
# this module, and it must import without transformers, the optional [hf] extra.
from ._remote_code import keep_patched
from ._remote_code import _apply, keep_patched, stage_full_remote_closure


def checkpoint_to_patch(
Expand Down Expand Up @@ -114,6 +114,55 @@ def _prepare_4d_causal_attention_mask(*args, **kwargs): # pragma: no cover
print_rank_0(f"[kimi] compat-shim warning (attn_mask_utils): {type(e).__name__}: {e}")


def make_importable() -> None:
"""The names newer transformers dropped, and the code's two-deep relative imports staged."""
install_compat_import_shims()
# transformers stages only 1-hop relative imports; Kimi's remote code is 2 hops deep.
stage_full_remote_closure()


def make_buildable(
modeling: str, vlm_class: str, hf_config, ckpt_path: str | None, trust_remote_code: bool
) -> None:
"""What both checkpoints' own code needs to import and build on this transformers, and no more.

The names newer transformers dropped, the code's two-deep relative imports staged, and the VLM
class's ``tie_weights`` taking the keyword arguments transformers' ``post_init`` passes it. None
of it changes what the model computes or loads, so the plan decision runs it before ``prepare``,
whatever plan comes out. A config of transformers' own port, or code this load may not run, has
nothing of the checkpoint's to build. Idempotent.
"""
from transformers.dynamic_module_utils import get_class_from_dynamic_module

if type(hf_config).__module__.startswith("transformers.") or ckpt_path is None:
return
if not trust_remote_code:
return
make_importable()
get_class_from_dynamic_module(
f"{modeling}.{vlm_class}", ckpt_path, trust_remote_code=trust_remote_code
)
# Remote code only: transformers' own port of a model can share the module's name.
_apply(f".{modeling}", lambda module: _shim_tie_weights(module, vlm_class))


def _shim_tie_weights(module, vlm_class: str) -> bool:
"""Have ``module``'s VLM class take the keyword arguments transformers passes ``tie_weights``.

The remote ``tie_weights()`` takes none, but transformers' ``post_init`` and load call
``tie_weights(recompute_mapping=False)``: a TypeError at construction. Returns whether it changed
the class.
"""
kimi_cls = getattr(module, vlm_class, None)
if kimi_cls is None:
return False
own_tie = vars(kimi_cls).get("tie_weights")
if own_tie is None or getattr(own_tie, "_kimi_tie_weights_shim", False):
return False
kimi_cls.tie_weights = _drop_tie_weights_kwargs(own_tie)
return True


def _drop_tie_weights_kwargs(orig_tie):
"""Wrap a remote ``tie_weights()`` that predates the kwargs newer transformers pass to it.

Expand All @@ -134,10 +183,10 @@ def _native_for_the_prepared_config(own_class, native_class):
"""The ``DeepseekV3ForCausalLM`` the patched module builds its language model with.

transformers' native class for the native ``DeepseekV3Config`` ``prepare`` puts on
``text_config``, and the checkpoint's own class for its own config. Deciding the plan prepares a
copy of the config before the plan is known, and the patch outlives that copy: a load the plan
keeps off expert parallelism builds from the config as read, and so gets the checkpoint's own
language model, as it would with no patch.
``text_config``, and the checkpoint's own class for its own config. The patch outlives the load
it was made for: a later load in the process that its plan keeps off expert parallelism builds
from the config as read, and so gets the checkpoint's own language model, as it would with no
patch.
"""
from transformers import DeepseekV3Config

Expand Down Expand Up @@ -183,13 +232,7 @@ def to_native(module):
module.DeepseekV3ForCausalLM = _native_for_the_prepared_config(
current, NativeDeepseekV3ForCausalLM
)
# The remote tie_weights() takes no kwargs, but transformers' post_init and load call
# tie_weights(recompute_mapping=False) -> TypeError.
kimi_cls = getattr(module, vlm_class, None)
if kimi_cls is not None:
own_tie = vars(kimi_cls).get("tie_weights")
if own_tie is not None and not getattr(own_tie, "_kimi_tie_weights_shim", False):
kimi_cls.tie_weights = _drop_tie_weights_kwargs(own_tie)
_shim_tie_weights(module, vlm_class)
return True

# The dynamic-module loader names the module like
Expand Down
18 changes: 18 additions & 0 deletions modelopt/torch/models/kimi_k25/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 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.

"""kimi_k25 specs (HF model type ``kimi_k25``)."""

from . import specs
128 changes: 128 additions & 0 deletions modelopt/torch/models/kimi_k25/ep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# 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.

"""DP=EP a2a support for Kimi-K2.5 (``kimi_k25``).

a remote-code VLM whose text backbone is a DeepSeek-V3 variant with compressed-tensors-compressed experts.
Kimi-K2.6 and Kimi-K2.7-Code ship the same model type and modeling classes.

The challenge: Kimi's bundled ``modeling_deepseek.py`` defines ``DeepseekV3MoE`` as an
``nn.ModuleList`` of per-expert ``DeepseekV3MLP`` (separate ``gate_proj`` / ``up_proj`` /
``down_proj`` Linears per expert). That layout is incompatible with transformers' EP machinery,
which expects the **fused** 3-D ``gate_up_proj`` / ``down_proj`` Parameters that the
transformers-native ``deepseek_v3`` modeling ships. We need the native modeling to get EP-capable
experts, but we also want the rest of the VLM (vision tower + mm projector) to come from Kimi's
custom code.

Solution -- monkey-patch ``modeling_kimi_k25``'s ``DeepseekV3ForCausalLM`` import to point at the
transformers-native class, AND swap ``hf_config.text_config`` to a transformers-native
``DeepseekV3Config`` (the native class won't accept Kimi's custom config subclass). Then
``KimiK25ForConditionalGeneration.__init__`` (which does ``self.language_model =
DeepseekV3ForCausalLM(config.text_config)``) builds the native EP-capable model for the language
sub-model, while vision_tower + mm_projector are still built from Kimi's custom code.

On-the-fly compressed-tensors expert decompress happens during from_pretrained's
``WeightConverter`` machinery (installed by ``_onfly_decompress.install_onfly_decompress``); the
fused per-expert source ``.weight`` tensors are decompressed + merged into the 3-D
``gate_up_proj`` / ``down_proj`` parameters. Format-agnostic (compressed_tensors decides the
dequant from the checkpoint's quantization_config).

EP plan: attached to the (now native) ``text_config``. transformers' ``post_init`` aggregates
child-module plans up to the root with name prefix, so the final root ``_ep_plan`` keys end up as
``language_model.model.layers.*.mlp.experts.{gate_up_proj,down_proj,...}`` -- matching the actual
parameter paths in the loaded VLM.

Version floor: transformers >= 5.8, which the spec declares. The on-the-fly decompressor, installed
for a compressed-tensors checkpoint, needs it: earlier releases' ``Concatenate`` trips over the
sibling keys it has already consumed. The decompressor couples tightly to
transformers' still-evolving ``WeightConverter`` conversion-ops API.
"""

from .. import _kimi, _onfly_decompress

# The checkpoint formats prepare has the experts decompress as they load; the spec states them for
# the plan decision, which runs before prepare.
DECOMPRESSED_FORMATS = ("compressed-tensors",)


Comment thread
shengliangxu marked this conversation as resolved.
Resolved
def make_buildable(
hf_config, *, ckpt_path: str | None = None, trust_remote_code: bool = False, **_
) -> None:
"""Have the checkpoint's own code import and build on this transformers.

The plan decision needs it before ``prepare``; it changes nothing the model computes or loads.
"""
_kimi.make_buildable(
"modeling_kimi_k25",
"KimiK25ForConditionalGeneration",
hf_config,
ckpt_path,
trust_remote_code,
)


def prepare(hf_config, *, ckpt_path: str | None = None, trust_remote_code: bool = False, **_):
"""Load Kimi-K2.5's language model as the transformers-native deepseek_v3.

Swap text_config to native deepseek_v3 + monkey-patch Kimi's modeling to use the native
DeepseekV3ForCausalLM + apply the EP plan + install the on-the-fly decompressor for
compressed-tensors experts.
"""
# Not at module scope: the model registry imports this module, and modelopt.torch.utils.moe
# imports the registry.
from modelopt.torch.utils.moe import apply_distributed_support

ckpt_path = _kimi.checkpoint_to_patch("kimi_k25", hf_config, ckpt_path, trust_remote_code)

# 1. Install on-the-fly compressed-tensors expert decompressor. Operates on transformers-native
# deepseek_v3's per-expert WeightConverter (which fuses per-expert .weight tensors into the
# 3-D gate_up_proj/down_proj Parameters). Format-agnostic; driven by checkpoint's
# quantization_config, which Kimi-K2.5 carries only on the text_config step 2 replaces. It
# goes first, so a checkpoint it refuses leaves everything as it was. Experts stored
# otherwise are left to the expert-parallel load's format handling, which reads their scheme
# from the copy of quantization_config Kimi's config class keeps on the top-level config;
# step 2 leaves that copy alone. A checkpoint without that copy is refused.
quantization_config = _onfly_decompress._find_quantization_config(hf_config) or {}
if quantization_config.get("quant_method") in DECOMPRESSED_FORMATS:
_onfly_decompress.install_onfly_decompress(hf_config)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

The decompressor is installed (process-wide hooks, and the config is marked) before the compat shims, to_native_backbone_config and the remote-module patch run. If patch_language_model_to_native then raises (no module loaded, or no DeepseekV3ForCausalLM), the process is left with the hooks installed and text_config already swapped. That's probably fine on a single rank. Under torchrun, though: can that raise on only some ranks (the module-cache race the PR's GPU table describes), and if so, do the peers fail fast or block in the load's first collective? A sentence on this, or a collective check of the patch result, would settle it.

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.

Peers fail fast. prepare runs inside a collective wherever a run reaches it:

The hooks the decompressor installed before such a failure stay installed, but the run is over on every rank.

Comment on lines +97 to +99

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] "compressed-tensors" is now stated twice and the two must agree: here, as the condition that actually installs the decompressor, and in specs.py as decompressed_formats=("compressed-tensors",), which _ep_load_refusal reads before prepare runs to decide the checkpoint is one the expert-parallel load takes. If they ever drift, the failure is silent in one direction (the decision plans expert-parallel for a format prepare then leaves compressed, and the load fails later) and invisible in the other (a format prepare would decompress is planned without expert parallelism).

The new test already compensates by deriving its expectation from get_spec("kimi_k25").ep_spec.decompressed_formats rather than from the literal — worth making the code share the same single definition, since specs.py already imports from this module:

# ep.py
DECOMPRESSED_FORMATS = ("compressed-tensors",)
...
    if quantization_config.get("quant_method") in DECOMPRESSED_FORMATS:
        _onfly_decompress.install_onfly_decompress(hf_config)

# specs.py
from .ep import DECOMPRESSED_FORMATS, make_buildable, prepare
...
            decompressed_formats=DECOMPRESSED_FORMATS,

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.

Taken in 777ac2a. kimi_k25.ep.DECOMPRESSED_FORMATS is the one definition: prepare installs the decompressor for those formats, and the spec's decompressed_formats is that tuple.

else:
_kimi.refuse_quantization_only_on_text_config("kimi_k25", hf_config)
Comment on lines +97 to +101

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 adapter reaches into _onfly_decompress._find_quantization_config — a private helper — and re-implements the "is this compressed-tensors?" test that the decompressor module already owns (_read_compression makes the same determination internally when deciding what it can handle).

Two consequences, both mild: the quant_method spelling now lives in two modules, so a format the decompressor learns to accept under a different spelling (or an alias) silently falls through to the else branch here and gets refused instead of decompressed; and the or {} / .get dance only works because _find_quantization_config happens to normalize to a plain dict, which is an invariant of a private function this file does not own.

A one-line predicate on _onfly_decompress would keep the format knowledge in one place and read better at the call site:

# in _onfly_decompress.py
def is_compressed_tensors(hf_config) -> bool:
    """Whether the checkpoint's experts are compressed-tensors-compressed, so install_onfly_decompress applies."""
    return (_find_quantization_config(hf_config) or {}).get("quant_method") == "compressed-tensors"
# here
if _onfly_decompress.is_compressed_tensors(hf_config):
    _onfly_decompress.install_onfly_decompress(hf_config)
else:
    _kimi.refuse_quantization_only_on_text_config("kimi_k25", hf_config)

Behaviour-identical; non-blocking.

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.

Not taken: the check has one caller and no twin. _onfly_decompress never reads quant_method: it parses the config through compressed-tensors' QuantizationConfig (_read_compression), so the "compressed-tensors" spelling exists only here, where it routes between decompressing and the block-FP8 refusal. _find_quantization_config's docstring states the plain-dict return this relies on, and both modules are private siblings under modelopt/torch/models.

Comment on lines +97 to +101

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 discriminator reads only the first quantization_config it finds, so two levels that disagree take the wrong branch.

_onfly_decompress._find_quantization_config returns the first non-None quantization_config over (hf_config, text_config, llm_config) — top level first. For the shipped Kimi-K2.5/K2.6/K2.7-Code checkpoints the two copies agree, so this is correct today. But a checkpoint whose top-level copy names some other scheme while text_config still carries the compressed-tensors one falls through to the else: refuse_quantization_only_on_text_config does not fire (the top-level copy exists), step 2 drops text_config's compressed-tensors scheme, and load_and_prepare_ep then reads the top-level name — so the packed experts load against the wrong format rather than being refused. Nothing in prepare or in the load notices the two levels disagree.

The cheap hardening is to test every level the load reads rather than only the first, e.g.

levels = _onfly_decompress._config_levels(hf_config)
schemes = {
    (qc.get("quant_method") if isinstance(qc, dict) else getattr(qc, "quant_method", None))
    for c in levels
    if (qc := getattr(c, "quantization_config", None)) is not None
}
if "compressed-tensors" in schemes:
    if len(schemes) > 1:
        raise ValueError(
            f"kimi_k25: the checkpoint declares disagreeing quantization schemes {sorted(schemes)} "
            "across its config levels; the expert-parallel load cannot tell which describes the experts."
        )
    _onfly_decompress.install_onfly_decompress(hf_config)
else:
    _kimi.refuse_quantization_only_on_text_config("kimi_k25", hf_config)

Non-blocking — it needs a self-inconsistent checkpoint — but it keeps the "a checkpoint we cannot place is refused before anything changes" property that the rest of step 1 is built around, instead of silently mis-routing. If you'd rather not grow the branch, a one-line is_compressed_tensors(hf_config) predicate owned by _onfly_decompress (which already owns _config_levels) would also stop the adapter from re-spelling that module's format knowledge through a private helper.

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.

Not a case a real checkpoint can reach, so I'm keeping it as is.

  • One copy on disk. Kimi-K2.5, K2.6 and K2.7-Code all ship the scheme on text_config only; their top-level config has no quantization_config.
  • The class copies it. Their config class (KimiK25Config, identical in all three) sets self.quantization_config = self.text_config.quantization_config. The top level then holds the same object as text_config, so the two levels cannot disagree.
  • A top-level copy of its own would need a hand-edited config.json with two different schemes. The class would keep the top-level one, and the load would then refuse it at the format check if it names a scheme the expert-parallel load does not dequantize.

The predicate idea is reasonable if a second adapter ever needs the same question; today this is the only caller.


# Before any remote-code load: this plugin's get_class_from_dynamic_module below, and
# from_pretrained later in load_and_prepare_ep.
_kimi.make_importable() # the swap below loads the code and adapts its tie_weights

# 2. Replace text_config with a transformers-native DeepseekV3Config built from the same fields.
# Kimi's __init__ passes config.text_config to DeepseekV3ForCausalLM; the native class rejects
# Kimi's custom config subclass. to_native_backbone_config also drops auto_map +
# quantization_config so transformers builds the native modeling instead of re-routing back
# to Kimi's remote code via auto_map.
hf_config.text_config = _onfly_decompress.to_native_backbone_config(hf_config)
Comment thread
shengliangxu marked this conversation as resolved.
Resolved
Comment thread
shengliangxu marked this conversation as resolved.
Resolved

# 3. Apply the EP plan to the now-native text_config. transformers' post_init walks children
# and prefixes their _ep_plan onto the parent's: text_config's plan ends up at the VLM root
# as `language_model.model.layers.*.mlp.experts.{gate_up_proj,down_proj}`.
hf_config.text_config = apply_distributed_support("deepseek_v3", hf_config.text_config)

# 4. Force-load Kimi's modeling_kimi_k25 module (so we can patch its DeepseekV3ForCausalLM
# symbol) and swap to the native class.
_kimi.patch_language_model_to_native(
"kimi_k25",
"modeling_kimi_k25",
"KimiK25ForConditionalGeneration",
ckpt_path,
trust_remote_code,
)
return hf_config
55 changes: 55 additions & 0 deletions modelopt/torch/models/kimi_k25/specs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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.

"""kimi_k25 specs (HF model type ``kimi_k25``).

Kimi-K2.5 (Kimi-K2.6 and Kimi-K2.7-Code share its model type): the compressed-expert sibling of
``kimi_vl`` -- same swap to a native deepseek_v3 language model, plus on-the-fly decompression of
its packed experts at load.

Only the expert-parallel section is registered under ``kimi_k25``: the MoE and export sections stay
``deepseek_v3``'s, so lookups by ``kimi_k25`` find neither yet. Nor do ``is_moe``'s name and structure
fallbacks recognize the native ``DeepseekV3MoE`` blocks ``prepare`` installs, so the HF export's
MoE-block handling skips them. Sharing the sections is left to a follow-up.
"""

from ..deepseek_v3.specs import BASE_MODEL_EP_PLAN
from ..specs import EPSpec, ModelSpec, register
from .ep import DECOMPRESSED_FORMATS, make_buildable, prepare

__all__: list[str] = []

register(
ModelSpec(
model_type="kimi_k25",
# transformers ships Kimi-K2.5 itself from 5.13, so modeling_source stays "transformers": the
# registry pairs it with that release, and a "remote_code" claim would be stale from 5.13 on.
# Expert parallelism still runs on the checkpoint's own code (``trust_remote_code=True``),
# which ``prepare`` patches, refusing the port. So the version describes transformers' port,
# not the model this spec's load builds: a MoE or export section added here must name the
# native deepseek_v3 language model's modules.
min_transformers_version="5.13",
Comment thread
shengliangxu marked this conversation as resolved.
Resolved

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] min_transformers_version="5.13" (and the implied modeling_source="transformers") record transformers' port, which this adapter deliberately never uses: _kimi.checkpoint_to_patch refuses a port config outright and prepare requires trust_remote_code=True. Per the field docs, modeling_source="transformers" asserts the opposite — that "model_type is a transformers spelling" and that loading does not need the flag. I realize the registry's two-valued modeling_source can't express "remote code for a model transformers also ports" (flipping it to remote_code would trip test_remote_code_models_are_really_absent on ≥5.13, and dropping the version would trip test_version_claim_matches_modeling_source), so this is the only combination that passes bookkeeping today.

Nothing consumes min_transformers_version at runtime, so there is no bug now. The risk is the follow-up this file already plans ("the MoE and export sections stay deepseek_v3's […] left to a follow-up"): the moment a moe_spec lands under kimi_k25, test_moe_block_resolves_from_its_declared_version will resolve its block_names against transformers.models.kimi_k25.modeling_kimi_k25 — the port the adapter refuses — rather than the deepseek_v3 modeling the load actually builds, and will pass or fail for the wrong reason.

Worth extending the comment above to say the 5.13 claim is registry bookkeeping for the port, not the modeling this spec's EP path loads, so whoever adds the MoE section knows the version claim does not describe it.

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.

Taken in e4762e5: the comment says the 5.13 claim describes transformers' own port, not the model this spec's load builds, and that a MoE or export section added later must name the native deepseek_v3 language model's modules.

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 comment explains why min_transformers_version="5.13" is here, but not the thing that reads oddest next to the sibling spec: modeling_source is left at its "transformers" default, while kimi_vl/specs.py:35 declares modeling_source="remote_code" for an adapter that works exactly the same way.

Why it matters: the field's docstring (models/specs.py:297-303) draws a behavioural inference from it — "carried by the checkpoint and loaded with trust_remote_code=True… loading the model needs the flag". For kimi_k25 that inference is false: _kimi.checkpoint_to_patch requires trust_remote_code=True and actively refuses transformers' own port, so the load this spec drives is remote-code in every sense. Nothing consumes modeling_source at runtime today (it only feeds test_version_claim_matches_modeling_source / test_remote_code_models_are_really_absent), so this is not a bug — but a future consumer that uses it to decide whether to pass trust_remote_code would get the wrong answer for this one spec, and a reader diffing the two Kimi specs will read the omission as an oversight.

The choice is also forced: declaring remote_code would fail test_remote_code_models_are_really_absent once transformers ≥ 5.13 provides transformers.models.kimi_k25, and test_version_claim_matches_modeling_source ties modeling_source and min_transformers_version together. That constraint is worth recording where the next person will look.

Suggest extending the existing comment to name the consequence, e.g.:

        model_type="kimi_k25",
        # transformers ships Kimi-K2.5 itself from 5.13, so modeling_source stays "transformers"
        # (the registry pairs it with a release, and a "remote_code" claim would be stale from
        # 5.13 on) -- even though this spec's load runs on the checkpoint's own code
        # (``trust_remote_code=True``), which ``prepare`` patches, and refuses the port. So the
        # version describes transformers' port, not the model this spec's load builds: a MoE or
        # export section added here must name the native deepseek_v3 language model's modules.
        min_transformers_version="5.13",

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.

Taken in e1c538f. The comment now says why modeling_source stays "transformers":

  • transformers ships Kimi-K2.5 from 5.13, and the registry pairs a "transformers" source with that release.
  • A "remote_code" claim would be stale from 5.13 on.
  • Even so, prepare runs the checkpoint's own code and refuses the port.

The docstring also gets the same note as Kimi-VL's: is_moe misses the native DeepseekV3MoE blocks until the MoE and export sections are shared.

# prepare swaps a native deepseek_v3 in as the language model, with deepseek_v3's plan,
# and decompresses the experts as they load, which needs transformers 5.8.
ep_spec=EPSpec(
prepare=prepare,
prepared_ep_plan=BASE_MODEL_EP_PLAN,
plan_config_attr="text_config",
make_buildable=make_buildable,
decompressed_formats=DECOMPRESSED_FORMATS,
requires_transformers="5.8",
),
)
)
Comment on lines +34 to +55

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.

[IMPORTANT Compatibility] Registering kimi_k25 (and kimi_vl) with only an ep_spec leaves their MoE blocks invisible to every registry-keyed MoE lookup.

match_moe_block(module, model_type) treats model_type as a strict filter: "only that model's own spec is consulted, and an unregistered model type resolves to None even if the module's class names coincide with another model's." Consumers pass the root config.model_type (hf_model_type(model)), which for a prepared Kimi VLM is still kimi_k25 / kimi_vl — prepare only swaps text_config.

The blocks are DeepseekV3MoE, and deepseek_v3/specs.py documents (lines 35-38 of that file) that this class "is invisible to the generic MoE detection: its class name does not end in SparseMoeBlock and it calls its router gate, so neither the name test nor the structural router+experts test in is_moe matches. block_names below is what puts it on the MoE path at all."

Consequence: is_moe(block, "kimi_k25") is False, so in unified_export_hf.py:549 the AWQ / NVFP4-SVDQuant expert resmoothing loop skips every Kimi MoE block silently, and layer_utils.py:383's fused gate_up_proj amax sync skips them too (that function's own comment calls the resulting inconsistent weight_scale_2 across the two halves "exactly the corruption this function exists to prevent"). The second one bites hardest on the per-expert layout the checkpoint's own code builds — the layout a non-EP / EP-degree-1 run loads, where experts is iterable. Note also that get_experts_list would raise NotImplementedError if is_moe ever did return true, because there is no ExportSpec(grouped_expert_export=True) either.

This is an absence rather than a regression (before this PR get_spec("kimi_vl") was None, same outcome), but this PR is where these model types enter the registry, and the project already has the pattern for it: gemma4 / gemma4_text share one _GEMMA4_MOE_SPEC constant "rather than restating it -- the two must not drift apart". The same shape works here — export deepseek_v3's MoESpec next to BASE_MODEL_EP_PLAN and reuse it:

# deepseek_v3/specs.py
MOE_SPEC = MoESpec(
    block_names=("DeepseekV3MoE",),
    expert_linear_names=("gate_proj", "down_proj", "up_proj"),
    gate_up_pair=("gate_proj", "up_proj"),
)

# kimi_k25/specs.py and kimi_vl/specs.py
register(
    ModelSpec(
        model_type="kimi_k25",
        ...
        export_spec=ExportSpec(grouped_expert_export=True),
        moe_spec=MOE_SPEC,
    )
)

If leaving the layout out is deliberate for this slice of the stack (e.g. export lands in a later PR), a one-line comment here saying so would keep the next reader from reading it as an omission.

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: not a regression, and nothing changes on the expert-parallel path these specs serve. Before this PR neither kimi_k25 nor kimi_vl was registered (main has no Kimi spec), so match_moe_block(block, "kimi_k25") already returned None and is_moe was False, as now. Under expert parallelism the language model is transformers' native DeepSeek-V3, whose experts are one fused DeepseekV3Experts module: gate and up share one weight quantizer per expert, so they share weight_scale_2 by construction, in the per-expert split (_export_fused_experts) and in the keep-fused fold (split_fused_experts_state_dict gives both halves the fused gate_up_proj scale). sync_moe_gate_up_amax skips a container that is not iterable, the fused input-prep handler is a no-op, and get_experts_list returns no groups for it. Where deepseek_v3's MoE and export sections under the root types would act is the single-process export of Kimi's own per-expert modeling (gate/up amax sync, the input-amax fill for uncalibrated experts, AWQ expert grouping): a change to how the export treats those checkpoints, which belongs with a Kimi export test. Both specs now say only the expert-parallel section is registered, as nemotron_h_omni's does.

20 changes: 17 additions & 3 deletions modelopt/torch/models/kimi_vl/ep.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,22 @@
"""

from .. import _kimi, _onfly_decompress
from .._remote_code import stage_full_remote_closure


def make_buildable(
hf_config, *, ckpt_path: str | None = None, trust_remote_code: bool = False, **_
) -> None:
"""Have the checkpoint's own code import and build on this transformers.

The plan decision needs it before ``prepare``; it changes nothing the model computes or loads.
"""
_kimi.make_buildable(
"modeling_kimi_vl",
"KimiVLForConditionalGeneration",
hf_config,
ckpt_path,
trust_remote_code,
)


def prepare(hf_config, *, ckpt_path: str | None = None, trust_remote_code: bool = False, **_):
Expand All @@ -38,8 +53,7 @@ def prepare(hf_config, *, ckpt_path: str | None = None, trust_remote_code: bool
ckpt_path = _kimi.checkpoint_to_patch("kimi_vl", hf_config, ckpt_path, trust_remote_code)
_kimi.refuse_quantization_only_on_text_config("kimi_vl", hf_config)

_kimi.install_compat_import_shims()
stage_full_remote_closure()
_kimi.make_importable() # the swap below loads the code and adapts its tie_weights

# Native deepseek_v3 config for the LM (drops the remote auto_map so the native class is used).
hf_config.text_config = _onfly_decompress.to_native_backbone_config(hf_config)
Expand Down
9 changes: 7 additions & 2 deletions modelopt/torch/models/kimi_vl/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

from ..deepseek_v3.specs import BASE_MODEL_EP_PLAN
from ..specs import EPSpec, ModelSpec, register
from .ep import prepare
from .ep import make_buildable, prepare

__all__: list[str] = []

Expand All @@ -36,6 +36,11 @@
model_type="kimi_vl",
modeling_source="remote_code",
# prepare swaps a native deepseek_v3 in as the language model, with deepseek_v3's plan.
ep_spec=EPSpec(prepare=prepare, prepared_ep_plan=BASE_MODEL_EP_PLAN),
ep_spec=EPSpec(
prepare=prepare,
prepared_ep_plan=BASE_MODEL_EP_PLAN,
plan_config_attr="text_config",
make_buildable=make_buildable,
),
)
)
Loading
Loading