From 0bc358a61e285d438980fe19840e6894212f6b02 Mon Sep 17 00:00:00 2001 From: Chakshu Dhannawat Date: Mon, 17 Aug 2026 10:03:51 +0900 Subject: [PATCH 1/2] conversion : fix Qwen3.5 MTP config lookup from text_config (fixes unslothai/unsloth#8443) - Merge text_config into hparams inside _QwenMtpMixin.__init__ so num_hidden_layers and mtp_num_hidden_layers are visible when nested. - Replace the bare assert with an actionable ValueError when the MTP layer count is missing and has not yet been recovered from tensors. Assisted-by: Claude Sonnet --- conversion/qwen.py | 19 ++++++-- conversion/tests/test_qwen_mtp.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 conversion/tests/test_qwen_mtp.py diff --git a/conversion/qwen.py b/conversion/qwen.py index b4ae528bf2d..08d74cd6dc0 100644 --- a/conversion/qwen.py +++ b/conversion/qwen.py @@ -289,12 +289,25 @@ class _QwenMtpMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.block_count = self.hparams["num_hidden_layers"] + # HF configs for Qwen3.5/3.6 text variants may nest the text hyperparameters + # under `text_config`. Reuse the same merge `index_tensors` already applies + # so `num_hidden_layers` and `mtp_num_hidden_layers` are visible here. + hparams = {**self.hparams, **self.hparams.get("text_config", {})} + self.block_count = hparams["num_hidden_layers"] if not self.no_mtp: - n_mtp = self.hparams.get("mtp_num_hidden_layers", 0) + n_mtp = hparams.get("mtp_num_hidden_layers", 0) # Qwen-3-Next doesn't include `mtp_num_hidden_layers` in config. if n_mtp == 0: - assert self.opt_num_mtp_layers != 0 + # The count is recovered from tensor names in `filter_tensors`, which + # runs during `index_tensors` - strictly after __init__. On a cold + # process the class attribute is still 0, so the old assert gave the + # user nothing actionable. Fail with a clear message instead. + if self.opt_num_mtp_layers == 0: + raise ValueError( + "MTP layer count not found in config (checked top level and " + "text_config) and not yet recovered from tensor names. " + "Re-export with --no-mtp, or add mtp_num_hidden_layers to config." + ) n_mtp = self.opt_num_mtp_layers self.block_count += n_mtp self.tensor_map = gguf.get_tensor_name_map(self.model_arch, self.block_count) diff --git a/conversion/tests/test_qwen_mtp.py b/conversion/tests/test_qwen_mtp.py new file mode 100644 index 00000000000..1f48707905d --- /dev/null +++ b/conversion/tests/test_qwen_mtp.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import pytest + +import gguf +from conversion.qwen import _QwenMtpMixin + + +class _DummyParent: + def __init__(self, *, hparams: dict, no_mtp: bool = False, model_arch: gguf.MODEL_ARCH, **kwargs): + self.hparams = hparams + self.no_mtp = no_mtp + self.model_arch = model_arch + + +class _MtpModel(_QwenMtpMixin, _DummyParent): + pass + + +@pytest.fixture(autouse=True) +def _reset_mtp_class_state(): + """The mixin uses class attributes that persist across conversions; isolate tests.""" + _MtpModel._original_block_count = None + _MtpModel.opt_num_mtp_layers = 0 + yield + _MtpModel._original_block_count = None + _MtpModel.opt_num_mtp_layers = 0 + + +def test_mtp_count_read_from_text_config(): + model = _MtpModel( + hparams={ + "num_hidden_layers": 24, + "text_config": {"num_hidden_layers": 24, "mtp_num_hidden_layers": 1}, + }, + model_arch=gguf.MODEL_ARCH.QWEN3NEXT, + ) + assert model.block_count == 25 + + +def test_mtp_count_read_from_top_level_config(): + model = _MtpModel( + hparams={"num_hidden_layers": 24, "mtp_num_hidden_layers": 2}, + model_arch=gguf.MODEL_ARCH.QWEN3NEXT, + ) + assert model.block_count == 26 + + +def test_mtp_count_falls_back_to_recovered_class_attribute(): + _MtpModel.opt_num_mtp_layers = 1 + model = _MtpModel( + hparams={"num_hidden_layers": 24}, + model_arch=gguf.MODEL_ARCH.QWEN3NEXT, + ) + assert model.block_count == 25 + + +def test_missing_mtp_count_raises_actionable_error(): + with pytest.raises(ValueError, match="MTP layer count not found"): + _MtpModel( + hparams={"num_hidden_layers": 24}, + model_arch=gguf.MODEL_ARCH.QWEN3NEXT, + ) + + +def test_no_mtp_skips_mtp_count_lookup(): + model = _MtpModel( + hparams={"num_hidden_layers": 24}, + no_mtp=True, + model_arch=gguf.MODEL_ARCH.QWEN3NEXT, + ) + assert model.block_count == 24 From 85538568606afd59afce9f131eb836dabb0fd1f7 Mon Sep 17 00:00:00 2001 From: Chakshu Dhannawat Date: Mon, 17 Aug 2026 10:53:12 +0900 Subject: [PATCH 2/2] conversion : tighten comments in _QwenMtpMixin per review feedback --- conversion/qwen.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/conversion/qwen.py b/conversion/qwen.py index 08d74cd6dc0..a2f4e995c2b 100644 --- a/conversion/qwen.py +++ b/conversion/qwen.py @@ -289,19 +289,14 @@ class _QwenMtpMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # HF configs for Qwen3.5/3.6 text variants may nest the text hyperparameters - # under `text_config`. Reuse the same merge `index_tensors` already applies - # so `num_hidden_layers` and `mtp_num_hidden_layers` are visible here. + # text_config may nest the text hyperparameters; merge them like index_tensors does hparams = {**self.hparams, **self.hparams.get("text_config", {})} self.block_count = hparams["num_hidden_layers"] if not self.no_mtp: n_mtp = hparams.get("mtp_num_hidden_layers", 0) - # Qwen-3-Next doesn't include `mtp_num_hidden_layers` in config. + # Qwen-3-Next doesn't include mtp_num_hidden_layers in config. if n_mtp == 0: - # The count is recovered from tensor names in `filter_tensors`, which - # runs during `index_tensors` - strictly after __init__. On a cold - # process the class attribute is still 0, so the old assert gave the - # user nothing actionable. Fail with a clear message instead. + # The count is recovered from tensor names later; fail clearly instead of asserting. if self.opt_num_mtp_layers == 0: raise ValueError( "MTP layer count not found in config (checked top level and "