From a02b04d4b20facfcc5154ed9b9b45ee8c6b72fe1 Mon Sep 17 00:00:00 2001 From: mukmckenzie Date: Fri, 16 Oct 2026 11:38:28 -0700 Subject: [PATCH 1/7] feat(gguf): serve GLM-5.3-Flash GGUF checkpoints on glm5-next Adds the llama.cpp glm5-next GGUF reader (unsloth GLM-5.3-Flash-GGUF, both the glm5-next and the older glm5next arch spellings) on the GGUF quant kind from #690: - parse_gguf_config derives the text config from the GGUF KV metadata, with per-block attention.head_count_kv laying out the KDA/DSA hybrid, the trailing MTP block dropped like the HF loader drops it, and the MLA head widths from the *_length_mla keys. - iter_gguf_weights maps every tensor to the glm5_next state dict and undoes the converter transforms: A_log stored as -exp(A_log) is inverted, the per-head attn_k_b/attn_v_b pair (k transposed) fuses into the dense kv_b_proj layout the MLA absorption reads, and the q|k|v conv kernels concatenate on the channel axis. The mixed-type fused KDA in_proj (Q8_0 q|k|v + BF16 b|f_a|g_a) rides #702 segments; the routed experts load as GGUF expert banks at their shipped types (unsloth dynamic quants: IQ3_S gate/up + IQ4_XS down, Q8_0 outlier layers pass through natively). - passes quant_config to embed_tokens so the Q8_0 token_embd serves packed; registers Glm5NextGGUFForCausalLM and claims it in the AOT table. Verified end to end against unsloth GLM-5.3-Flash-UD-IQ4_XS (5 split shards): the full engine on a 32GB GPU serves coherent output with the experts off host banks. Text-only, TP=1. Part of #671. --- python/freetoken/kernel/aot_models.py | 2 +- python/freetoken/models/gguf/config.py | 3 + python/freetoken/models/glm5_next/__init__.py | 4 + python/freetoken/models/glm5_next/gguf.py | 360 ++++++++++++++++++ python/freetoken/models/glm5_next/model.py | 2 + python/freetoken/models/register.py | 10 + tests/models/test_glm5_next_gguf.py | 305 +++++++++++++++ 7 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 python/freetoken/models/glm5_next/gguf.py create mode 100644 tests/models/test_glm5_next_gguf.py diff --git a/python/freetoken/kernel/aot_models.py b/python/freetoken/kernel/aot_models.py index fd67d56541..dff23e117c 100644 --- a/python/freetoken/kernel/aot_models.py +++ b/python/freetoken/kernel/aot_models.py @@ -281,7 +281,7 @@ def expert_bank_row_bytes(fmt: str, hidden_size: int, moe_intermediate_size: int # KDA conv/recurrent state lives in the LinearStatePool, not paged KV. name="RedHatAI/GLM-5.3-Flash-NVFP4", architecture="Glm5NextForCausalLM", - arch_aliases=("Glm5NextForConditionalGeneration",), + arch_aliases=("Glm5NextForConditionalGeneration", "Glm5NextGGUFForCausalLM"), # GGUF expert rows differ per layer and reach the fused copy as runtime sizes hidden_size=4096, kv_groups=(), top_k=8, diff --git a/python/freetoken/models/gguf/config.py b/python/freetoken/models/gguf/config.py index 5b780fe0ef..5767d4eb1c 100644 --- a/python/freetoken/models/gguf/config.py +++ b/python/freetoken/models/gguf/config.py @@ -19,6 +19,9 @@ GGUF_ARCH_TO_REGISTRY: dict[str, str] = { "gemma4": "Gemma4GGUFForCausalLM", "glm4moe": "Glm4MoeGGUFForCausalLM", + # glm5-next: the llama.cpp spelling; glm5next: unsloth's first release + "glm5-next": "Glm5NextGGUFForCausalLM", + "glm5next": "Glm5NextGGUFForCausalLM", "llama": "LlamaGGUFForCausalLM", "minimax-m2": "MiniMaxM2GGUFForCausalLM", "minimax-m3": "MiniMaxM3GGUFForCausalLM", diff --git a/python/freetoken/models/glm5_next/__init__.py b/python/freetoken/models/glm5_next/__init__.py index 7eee33dfee..0c548da7f6 100644 --- a/python/freetoken/models/glm5_next/__init__.py +++ b/python/freetoken/models/glm5_next/__init__.py @@ -1,4 +1,5 @@ from .config import VisionConfig, parse_config, parse_vision_config +from .gguf import iter_gguf_weights, parse_gguf_config, to_gguf from .model import Glm5NextForCausalLM, Glm5NextForConditionalGeneration from .vision import Glm5NextVisionModel from .weight import iter_expert_pieces, iter_vision_weights, iter_weights, nvfp4_expert_spec @@ -10,8 +11,11 @@ "Glm5NextVisionModel", "VisionConfig", "parse_config", + "parse_gguf_config", "parse_vision_config", "iter_vision_weights", + "iter_gguf_weights", "iter_weights", + "to_gguf", "iter_expert_pieces", ] diff --git a/python/freetoken/models/glm5_next/gguf.py b/python/freetoken/models/glm5_next/gguf.py new file mode 100644 index 0000000000..e3bf275ad4 --- /dev/null +++ b/python/freetoken/models/glm5_next/gguf.py @@ -0,0 +1,360 @@ +"""GLM-5.3-Flash from a llama.cpp ``glm5-next`` GGUF (unsloth ``GLM-5.3-Flash-GGUF``): config from the GGUF metadata, tensor names, and the converter's transforms undone. + +The GGUF geometry is identical to the HF checkpoint (34 KDA linear-attention layers, +11 NoPE-MLA/DSA layers with a kpool-compressed indexer on a 3+1 hybrid block layout, +mHC x4 residual streams, 288-expert sigmoid/noaux_tc MoE with a shared expert and +clamped SwiGLU), so ``parse_gguf_config`` derives a text-config-shaped namespace from +the GGUF KV metadata (plus a tensor-name census for the facts the KV predates) and +reuses ``glm5_next.config.parse_config`` wholesale. + +The llama.cpp converter (unsloth's glm5-next writer; tensor names identical across +the ``glm5-next`` and the older ``glm5next`` arch spellings) stores KDA's ``A_log`` +pre-transformed as ``-exp(A_log)`` -- inverted at load -- and splits the MLA +``kv_b_proj`` into per-head 3-D ``attn_k_b`` / ``attn_v_b`` tensors (k stored +transposed, [heads, kv_lora, nope] next to [heads, v, kv_lora]); both halves ship +BF16, so they dequantize and fuse into the dense layout the module's MLA absorption +reads. The trailing MTP block (``blk.N.nextn.*``, ``nextn_predict_layers == 1``) is +dropped exactly like the HF loader drops the never-read MTP layer; routed experts +serve from the GGUF expert banks (``freetoken.models.gguf.experts``). +""" + +from __future__ import annotations + +import re +from dataclasses import replace +from types import SimpleNamespace +from typing import TYPE_CHECKING, Iterator + +import torch + +from freetoken.models.gguf.reader import GgufTensorInfo, gguf_tensor_index, gguf_tensor_names +from freetoken.models.gguf.tensors import float_tensor, fused, matrix + +from .config import parse_config + +if TYPE_CHECKING: + from freetoken.models.config import ModelConfig + from freetoken.models.gguf.config import GgufConfigShim + +# HF config fields the GGUF metadata does not carry (GLM-5.3-Flash's config.json values). +_FAMILY_DEFAULTS = dict( + hidden_act="silu", + attention_bias=False, + # NoPE means no rope on the main attention; the indexer's interleaved rope is a + # served-model fact the GGUF KV predates, and getting it wrong scores the indexer + # keys incorrectly (DSA retrieval degrades to garbage). + indexer_rope_interleave=True, +) + +# checkpoint module leaf (glm5_next HF naming, language-model rooted) -> GGUF tensors +_LAYER_LEAVES: dict[str, tuple[str, ...]] = { + # KDA input projections (the six leaves of the fused in_proj) + "self_attn.q_proj": ("attn_q",), + "self_attn.k_proj": ("attn_k",), + "self_attn.v_proj": ("attn_v",), + "self_attn.b_proj": ("ssm_beta",), + "self_attn.f_a_proj": ("ssm_f_a",), + "self_attn.g_a_proj": ("ssm_g_a",), + "self_attn.f_b_proj": ("ssm_f_b",), + "self_attn.g_b_proj": ("ssm_g_b",), + # both layer kinds name their output projection the same + "self_attn.o_proj": ("attn_output",), + # DSA (MLA) projections + "self_attn.q_a_proj": ("attn_q_a",), + "self_attn.q_b_proj": ("attn_q_b",), + "self_attn.kv_a_proj_with_mqa": ("attn_kv_a_mqa",), + # both halves are float in every release: the module keeps its dense bf16 kv_b + "self_attn.kv_b_proj": ("attn_k_b", "attn_v_b"), + # kpool indexer + "self_attn.indexer.wq_b": ("indexer.attn_q_b",), + "self_attn.indexer.wk": ("indexer.attn_k",), + "self_attn.indexer.weights_proj": ("indexer.proj",), + # dense MLP of the leading blocks + "mlp.gate_proj": ("ffn_gate",), + "mlp.up_proj": ("ffn_up",), + "mlp.down_proj": ("ffn_down",), + # shared expert of the MoE blocks + "mlp.shared_experts.gate_proj": ("ffn_gate_shexp",), + "mlp.shared_experts.up_proj": ("ffn_up_shexp",), + "mlp.shared_experts.down_proj": ("ffn_down_shexp",), + # routed experts (GGUF expert banks) + "mlp.experts.0.gate_proj": ("ffn_gate_exps",), + "mlp.experts.0.up_proj": ("ffn_up_exps",), + "mlp.experts.0.down_proj": ("ffn_down_exps",), +} +_TOP_LEAVES: dict[str, tuple[str, ...]] = { + # checkpoint-space names: the language-model root is already applied when to_gguf runs + "model.language_model.embed_tokens": ("token_embd",), + "lm_head": ("output",), +} +_LAYER_RE = re.compile(r"^model\.language_model\.layers\.(\d+)\.(.+)$") + + +def to_gguf(name: str, tensors: dict[str, int]) -> tuple[str, ...]: + """Checkpoint module name -> the GGUF tensors (without ``.weight``) that store it; empty for modules the GGUF has no matrix for.""" + if name in _TOP_LEAVES: + return _TOP_LEAVES[name] + m = _LAYER_RE.match(name) + if m is None: + return () + return tuple(f"blk.{m.group(1)}.{leaf}" for leaf in _LAYER_LEAVES.get(m.group(2), ())) + + +def _layer_types(shim: "GgufConfigShim", num_layers: int) -> tuple[str, ...]: + """Per-decoder-layer kinds: DSA where the MLA projections sit, KDA elsewhere. + + The new writer carries ``attention.head_count_kv`` as a per-block list (0 on the + KDA blocks, 1 on the MLA ones); the older spelling predates it, so fall back to a + tensor-name census (``attn_q_a`` is DSA-only). A metadata-only GGUF (an FTW's + ``source_metadata.gguf``) has neither the list nor the tensor table, and only the + new writer's files can convert.""" + from .args import DSA_LAYER, KDA_LAYER + + md = shim.metadata + per_block = md.get(f"{shim.model_type}.attention.head_count_kv") + if per_block is not None and len(per_block) >= num_layers: + return tuple(DSA_LAYER if int(kv) else KDA_LAYER for kv in per_block[:num_layers]) + names = gguf_tensor_names(shim.model_path) + if not names: + raise ValueError( + f"{shim.model_path}: metadata-only GGUF without attention.head_count_kv; " + "the layer layout cannot be recovered (reconvert from a current writer)" + ) + return tuple( + DSA_LAYER if f"blk.{i}.attn_q_a.weight" in names else KDA_LAYER for i in range(num_layers) + ) + + +def _head_dims(shim: "GgufConfigShim") -> tuple[int, int]: + """(qk_nope, v) head widths: the new writer's ``*_length_mla`` KVs, else the q_b/v_b rows.""" + md = shim.metadata + prefix = shim.model_type + nope, v = md.get(f"{prefix}.attention.key_length_mla"), md.get(f"{prefix}.attention.value_length_mla") + if nope is None or v is None: + index = gguf_tensor_index(shim.model_path) + q_b = next((i for i in index.values() if i.name.endswith("attn_q_b.weight")), None) + v_b = next((i for i in index.values() if i.name.endswith("attn_v_b.weight")), None) + if q_b is None or v_b is None: + raise ValueError(f"{shim.model_path}: GGUF lacks attn_q_b/attn_v_b to size the MLA heads") + heads = int(md[f"{prefix}.attention.head_count"]) + nope, v = q_b.ne[1] // heads, v_b.ne[1] + return int(nope), int(v) + + +def parse_gguf_config(shim: "GgufConfigShim") -> ModelConfig: + md = shim.metadata + prefix = shim.model_type + + def kv(key: str, default=None): + return md.get(f"{prefix}.{key}", default) + + def need(key: str): + val = kv(key) + if val is None: + raise KeyError(f"missing GGUF metadata key {prefix}.{key}") + return val + + # block_count also counts the MTP block a GGUF carries after the decoder + n_mtp = int(kv("nextn_predict_layers", 0) or 0) + num_layers = int(need("block_count")) - n_mtp + layer_types = _layer_types(shim, num_layers) + first_dense = int(need("leading_dense_block_count")) + + heads = int(need("attention.head_count")) + qk_nope, v_head = _head_dims(shim) + # NoPE: the main attention carries no rotary dims (rope survives only in the indexer) + assert int(kv("rope.dimension_count", 0) or 0) == 0, "glm5-next GGUF: unexpected rotary dims on the main attention" + + # KDA head count: one q/k/v head set of kda.head_dim each; the conv channels or the + # beta rows count them, falling back to the shared head_count (the two coincide on + # every release) when the tensor table is gone (metadata-only GGUF). + kda_head_dim = int(need("kda.head_dim")) + index = gguf_tensor_index(shim.model_path) + conv_q = next((i for i in index.values() if i.name.endswith("ssm_conv1d_q.weight")), None) + kda_heads = conv_q.ne[2] // kda_head_dim if conv_q is not None else heads + + kpool = int(kv("attention.indexer.kpool", 1) or 1) + # the compressor tensors exist exactly when the indexer pools; a metadata-only GGUF + # keeps the fact only through kpool > 1 + names = gguf_tensor_names(shim.model_path) + kpool_compress = any(n.endswith("indexer_compressor_gate.weight") for n in names) if names else kpool > 1 + indexer_types_kv = kv("attention.indexer.types") + if indexer_types_kv is not None and len(indexer_types_kv) >= num_layers: + indexer_types = tuple("full" if b else "disabled" for b in indexer_types_kv[:num_layers]) + else: + # every release carries all-"full" in the HF config (the engine builds only the DSA-owned indexers) + indexer_types = ("full",) * num_layers + + clamp = need("swiglu_clamp_exp") + text = SimpleNamespace( + **_FAMILY_DEFAULTS, + num_hidden_layers=num_layers, + layer_types=tuple(layer_types), + mlp_layer_types=("dense",) * first_dense + ("sparse",) * (num_layers - first_dense), + first_k_dense_replace=first_dense, + hidden_size=int(need("embedding_length")), + num_attention_heads=heads, + vocab_size=int(shim.vocab_size), + intermediate_size=int(need("feed_forward_length")), + rms_norm_eps=float(need("attention.layer_norm_rms_epsilon")), + max_position_embeddings=int(need("context_length")), + q_lora_rank=int(need("attention.q_lora_rank")), + kv_lora_rank=int(need("attention.kv_lora_rank")), + qk_nope_head_dim=qk_nope, + qk_rope_head_dim=0, + v_head_dim=v_head, + mla_use_nope=True, + index_n_heads=int(need("attention.indexer.head_count")), + index_head_dim=int(need("attention.indexer.key_length")), + index_topk=int(need("attention.indexer.top_k")), + indexer_types=indexer_types, + index_kpool=kpool, + index_kpool_compress=kpool_compress, + # old converters predate the key; both released checkpoints set it true + index_kpool_always_select_tail=bool(kv("attention.indexer.kpool_select_tail", True)), + linear_attn_config={ + "num_heads": kda_heads, + "head_dim": kda_head_dim, + "short_conv_kernel_size": int(need("ssm.conv_kernel")), + "gate_lower_bound": float(need("kda.gate_lower_bound")), + }, + mhc=True, + hc_mult=int(need("hyper_connection.count")), + hc_sinkhorn_iters=int(need("hyper_connection.sinkhorn_iterations")), + hc_eps=float(need("hyper_connection.epsilon")), + n_routed_experts=int(need("expert_count")), + n_shared_experts=int(kv("expert_shared_count", 1) or 1), + num_experts_per_tok=int(need("expert_used_count")), + moe_intermediate_size=int(need("expert_feed_forward_length")), + norm_topk_prob=bool(need("expert_weights_norm")), + routed_scaling_factor=float(need("expert_weights_scale")), + n_group=int(kv("expert_group_count", 1) or 1), + topk_group=int(kv("expert_group_used_count", 1) or 1), + tie_word_embeddings=bool(shim.tie_word_embeddings), + swiglu_limit=float(clamp[0] if isinstance(clamp, list) else clamp), + ) + hf_like = SimpleNamespace( + text_config=text, + model_type="glm5_next", + architectures=list(shim.architectures), + vision_config=None, + image_token_id=None, + ) + config = parse_config(hf_like) + return replace(config, expert_quant="gguf") + + +# ---------------------------------------------------------------------------------------------- +# Weights +# ---------------------------------------------------------------------------------------------- + +def _kv_b(kb: GgufTensorInfo, vb: GgufTensorInfo, num_heads: int, kv_lora: int) -> torch.Tensor: + """Per-head k [kv_lora, nope] (stored transposed) + v [v, kv_lora] -> the module's fused dense [heads * (nope + v), kv_lora].""" + k = float_tensor(kb).transpose(1, 2) # [heads, nope, kv_lora] + fused_w = torch.cat([k, float_tensor(vb)], dim=1) + assert fused_w.shape[0] == num_heads, (fused_w.shape, num_heads) + return fused_w.reshape(num_heads * fused_w.shape[1], kv_lora).to(torch.bfloat16).contiguous() + + +def iter_gguf_weights( + model_path: str, + device: torch.device, + *, + include_moe_experts: bool, + include_non_moe: bool, + include_vision: bool = True, +) -> Iterator[tuple[str, torch.Tensor]]: + """The dense state dict from the GGUF; the routed experts load as offload banks (``freetoken.models.gguf.experts``).""" + if not include_non_moe: + return + from freetoken.utils import cached_load_hf_config + + config = parse_gguf_config(cached_load_hf_config(model_path)) + args = config.glm5_args + index = gguf_tensor_index(model_path) + hidden = config.hidden_size + + def t(name: str) -> GgufTensorInfo: + return index[name if name in index else f"{name}.weight"] + + def bf16(name: str) -> torch.Tensor: + return float_tensor(t(name)).to(torch.bfloat16) + + yield from fused("model.embed_tokens", [t("token_embd")], [matrix(t("token_embd"))], hidden) + yield from fused("lm_head", [t("output")], [matrix(t("output"))], hidden) + yield "model.norm.weight", bf16("output_norm") + + for layer in range(config.num_layers): + blk, pre = f"blk.{layer}", f"model.layers.{layer}" + yield f"{pre}.input_layernorm.weight", bf16(f"{blk}.attn_norm") + yield f"{pre}.post_attention_layernorm.weight", bf16(f"{blk}.ffn_norm") + # mHC mixing tensors, fp32 on every layer + for sub in ("attn", "ffn"): + for part in ("fn", "base", "scale"): + yield f"{pre}.hc_{sub}_{part}", float_tensor(t(f"{blk}.hc_{sub}_{part}")) + + if args.is_kda_layer(layer): + sa = f"{pre}.self_attn" + p, h, d = args.linear_num_heads * args.linear_head_dim, args.linear_num_heads, args.linear_head_dim + group = [t(f"{blk}.{n}") for n in ("attn_q", "attn_k", "attn_v", "ssm_beta", "ssm_f_a", "ssm_g_a")] + yield from fused(f"{sa}.in_proj", group, [matrix(i) for i in group], hidden) + # the GGUF conv kernels already carry the [channels, 1, kernel] singleton dim + conv = torch.cat([float_tensor(t(f"{blk}.ssm_conv1d_{q}")) for q in ("q", "k", "v")], dim=0) + yield f"{sa}.conv1d.weight", conv.to(torch.bfloat16) + for proj, src, k in (("f_b_proj", "ssm_f_b", d), ("g_b_proj", "ssm_g_b", d), ("o_proj", "attn_output", p)): + info = t(f"{blk}.{src}") + yield from fused(f"{sa}.{proj}", [info], [matrix(info)], k) + # the converter stores A_log pre-transformed as -exp(A_log); the kernels want the raw log + yield f"{sa}.A_log", torch.log(-float_tensor(t(f"{blk}.ssm_a"))) + yield f"{sa}.dt_bias", float_tensor(t(f"{blk}.ssm_dt.bias")) + yield f"{sa}.o_norm.weight", bf16(f"{blk}.ssm_norm") + else: + sa = f"{pre}.self_attn" + for proj, src, k in ( + ("q_a_proj", "attn_q_a", hidden), + ("q_b_proj", "attn_q_b", args.q_lora_rank), + ("kv_a_proj_with_mqa", "attn_kv_a_mqa", hidden), + ("o_proj", "attn_output", args.num_heads * args.v_head_dim), + ): + info = t(f"{blk}.{src}") + yield from fused(f"{sa}.{proj}", [info], [matrix(info)], k) + yield f"{sa}.kv_b_proj.weight", _kv_b( + t(f"{blk}.attn_k_b"), t(f"{blk}.attn_v_b"), args.num_heads, args.kv_lora_rank + ) + yield f"{sa}.q_a_layernorm.weight", bf16(f"{blk}.attn_q_a_norm") + yield f"{sa}.kv_a_layernorm.weight", bf16(f"{blk}.attn_kv_a_norm") + for proj, src in ( + ("indexer.wq_b", "indexer.attn_q_b"), + ("indexer.wk", "indexer.attn_k"), + ("indexer.weights_proj", "indexer.proj"), + ): + info = t(f"{blk}.{src}") + yield from fused(f"{sa}.{proj}", [info], [matrix(info)], int(info.ne[0])) + yield f"{sa}.indexer.k_norm.weight", bf16(f"{blk}.indexer.k_norm") + yield f"{sa}.indexer.k_norm.bias", bf16(f"{blk}.indexer.k_norm.bias") + yield f"{sa}.indexer.index_kpool_compress_gate", bf16(f"{blk}.indexer_compressor_gate") + yield f"{sa}.indexer.index_kpool_compress_ape", float_tensor(t(f"{blk}.indexer_compressor_ape")) + + mlp = f"{pre}.mlp" + if layer < config.first_k_dense_replace: + for proj, src, k in ( + ("gate_proj", "ffn_gate", hidden), + ("up_proj", "ffn_up", hidden), + ("down_proj", "ffn_down", config.intermediate_size), + ): + info = t(f"{blk}.{src}") + yield from fused(f"{mlp}.{proj}", [info], [matrix(info)], k) + else: + yield f"{mlp}.gate.weight", bf16(f"{blk}.ffn_gate_inp") + yield f"{mlp}.e_score_correction_bias", float_tensor(t(f"{blk}.exp_probs_b.bias")) + shared_i = config.moe_intermediate_size * max(1, config.n_shared_experts) + for proj, src, k in ( + ("gate_proj", "ffn_gate_shexp", hidden), + ("up_proj", "ffn_up_shexp", hidden), + ("down_proj", "ffn_down_shexp", shared_i), + ): + info = t(f"{blk}.{src}") + yield from fused(f"{mlp}.shared_experts.{proj}", [info], [matrix(info)], k) + + +__all__ = ["iter_gguf_weights", "parse_gguf_config", "to_gguf"] diff --git a/python/freetoken/models/glm5_next/model.py b/python/freetoken/models/glm5_next/model.py index d2bfa684c1..d92977dea2 100644 --- a/python/freetoken/models/glm5_next/model.py +++ b/python/freetoken/models/glm5_next/model.py @@ -142,6 +142,8 @@ def __init__(self, config: ModelConfig, *, prefix: str = "model"): self.embed_tokens = VocabParallelEmbedding( num_embeddings=config.vocab_size, embedding_dim=config.hidden_size, + quant_config=config.quant, + prefix=f"{prefix}.embed_tokens", ) self.layers = OPList( [Glm5NextDecoderLayer(config, i, prefix=f"{prefix}.layers.{i}") for i in range(config.num_layers)] diff --git a/python/freetoken/models/register.py b/python/freetoken/models/register.py index 42c6f3f548..b892d5cfcf 100644 --- a/python/freetoken/models/register.py +++ b/python/freetoken/models/register.py @@ -418,6 +418,16 @@ class ModelSpec: "Glm5NextForCausalLM", packed_modules_mapping=_GLM5_NEXT_PACKED, ), + # llama.cpp glm5-next GGUF (unsloth GLM-5.3-Flash-GGUF; text only): the same model + # classes with every tensor in its packed ggml type + "Glm5NextGGUFForCausalLM": ModelSpec( + "freetoken.models.glm5_next", + "Glm5NextForCausalLM", + parse_config="parse_gguf_config", + iter_weights="iter_gguf_weights", + checkpoint_roots=_LANGUAGE_MODEL_ROOT, + packed_modules_mapping=_GLM5_NEXT_PACKED, + ), } diff --git a/tests/models/test_glm5_next_gguf.py b/tests/models/test_glm5_next_gguf.py new file mode 100644 index 0000000000..085fffd727 --- /dev/null +++ b/tests/models/test_glm5_next_gguf.py @@ -0,0 +1,305 @@ +"""glm5-next GGUF: config parse and weight-reader mapping against a synthetic checkpoint. + +Builds a tiny two-layer (KDA + DSA) glm5-next GGUF in tmp -- geometry scaled down but +every tensor, KV key and converter quirk of the real writer present (A_log stored as +``-exp(A_log)``, the per-head ``attn_k_b``/``attn_v_b`` split with k transposed, the +per-block ``head_count_kv`` list, the trailing MTP block) -- then checks the config the +shim derives and the state dict the reader yields: names complete against the model's, +kv_b fused to the module's dense layout, A_log inverted, conv1d concatenation. +""" + +from __future__ import annotations + +import struct + +import pytest +import torch + +from freetoken.distributed import set_tp_info, try_get_tp_info + +# GGUF v3 value types (the vendored gguf-py GGUFValueType numbering) +_T_UINT32, _T_FLOAT32, _T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64 = 4, 6, 7, 8, 9, 10 + + +def _tp1(): + if try_get_tp_info() is None: + set_tp_info(rank=0, size=1) + + +def _pack_val(v): + if isinstance(v, str): + b = v.encode() + return struct.pack("