-
Notifications
You must be signed in to change notification settings - Fork 1
Add GLM-5.2 IndexShare DSA, MTP, and GGUF export support #404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
justinchuby
wants to merge
25
commits into
main
Choose a base branch
from
glm5.2-moe-export
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
887070a
Add GLM-5.2 MoE export support
justinchuby 77e2b0c
Implement GLM-5.2 IndexShare DSA and MTP export
justinchuby cff5bf2
fix(glm_moe_dsa): indexer RoPE must rotate full index_head_dim
justinchuby 5d9fd61
test(export): add tiny DeepSeek-V2 ONNX helper
justinchuby baff68c
tools: add quantized tiny glm_moe_dsa export helper for onnx-genai E2E
justinchuby bc93ccf
feat(moe): fused com.microsoft::QMoE emitter for GLM/DeepSeek routed …
justinchuby 87056ad
fix(deepseek): restore group top-k selection
justinchuby 8867ef3
style: fix GLM export lint
justinchuby ab78ba0
test: update onnx-genai runtime writer mock
justinchuby 2c4fdc9
test: cover streamed GLM GGUF quantization
justinchuby a5f4c49
ci: keep GPU validation on CUDA 12 ORT
justinchuby 671fc2d
test: handle keyed recurrent cache states
justinchuby 2f7c414
test: close ONNX temp files before Windows saves
justinchuby e98e698
fix: address GLM4 GPTQ review feedback
justinchuby 958f2eb
Address GLM export review feedback
justinchuby 751645b
feat(moe): pack routed experts for fused QMoE
justinchuby 6f2a52a
fix(lint): restore Ruff noqa suppressions
justinchuby bd88fa8
fix(deepseek): align low-precision router dtypes
justinchuby 4816c20
Lower GLM DSA sparse-attention to pkg.nxrt::IndexShare
justinchuby 7453e2c
test(glm-dsa): assert IndexShare lowering instead of dense attention
justinchuby 1c3396f
fix(export): address PR 404 review feedback
justinchuby 3fb1729
Merge main into GLM-5.2 export branch
justinchuby 5ba283f
Guard fused QMoE CUDA routing
justinchuby 3da96d0
Merge main into GLM-5.2 export branch
Copilot 66d9902
fix: merge main, fix RUF043 raw string pattern, reformat glm_moe_dsa.py
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| # GLM-5.2 export design | ||
|
|
||
| ## Verified architecture | ||
|
|
||
| Sources: | ||
|
|
||
| - [`zai-org/GLM-5.2` config](https://huggingface.co/zai-org/GLM-5.2/blob/main/config.json) | ||
| - Transformers `GlmMoeDsaForCausalLM` implementation | ||
| - llama.cpp `glm-dsa` GGUF metadata and tensor names | ||
| - [`unsloth/GLM-5.2-GGUF`](https://huggingface.co/unsloth/GLM-5.2-GGUF) | ||
| - [IndexShare paper, arXiv:2603.12201](https://arxiv.org/abs/2603.12201) | ||
| - [GLM-5 report, arXiv:2602.15763](https://arxiv.org/abs/2602.15763) | ||
|
|
||
| The Hugging Face model type is `glm_moe_dsa`, with | ||
| `GlmMoeDsaForCausalLM` as its architecture. llama.cpp writes the exact | ||
| `general.architecture` value `glm-dsa`. | ||
|
|
||
| | Field | GLM-5.2 value | | ||
| | --- | --- | | ||
| | Backbone layers | 78 | | ||
| | MTP layers | 1 additional NextN layer | | ||
| | Hidden / dense FFN size | 6144 / 12288 | | ||
| | Attention heads / KV heads | 64 / 64 | | ||
| | QK / NoPE / RoPE / V head dimensions | 256 / 192 / 64 / 256 | | ||
| | Q-LoRA / KV-LoRA rank | 2048 / 512 | | ||
| | Routed experts / experts per token | 256 / 8 | | ||
| | Routed / shared-expert FFN size | 2048 / 2048 | | ||
| | Shared experts | 1 | | ||
| | Dense prefix | first 3 layers | | ||
| | Router | float32 sigmoid, `noaux_tc`, normalized top-k, scale 2.5 | | ||
| | Norm / activation | pre-RMSNorm, epsilon 1e-5 / SiLU | | ||
| | RoPE | interleaved, theta 8,000,000 | | ||
| | Maximum context | 1,048,576 | | ||
| | Indexer | 32 heads, head dimension 128, top-k 2048 | | ||
|
|
||
| GGUF reports `block_count=79` and `nextn_predict_layers=1`; therefore the | ||
| causal-LM backbone has 78 layers. GGUF also splits the MLA KV decompression | ||
| weight into `attn_k_b` and `attn_v_b`, while Transformers exposes one | ||
| `kv_b_proj`. | ||
|
|
||
| ## Implemented scope | ||
|
|
||
| The exporter now preserves the 78-layer backbone, IndexShare DSA, and the | ||
| additional improved-MTP layer. Full indexers are emitted for layers 0, 1, 2, | ||
| 6, 10, ... 74; each full layer's INT64 top-k result is reused by the following | ||
| three shared layers. The indexer implements the checkpoint equations in fp32: | ||
| Q-LoRA residual projection, LayerNorm+interleaved-RoPE key projection, ReLU | ||
| scores, learned signed head weights, causal bias, and dynamic | ||
| `min(2048, key_length)` TopK. | ||
|
|
||
| Each full-indexer layer packs its 128-value index key beside the decompressed | ||
| MLA key in the standard `past_key_values.N.key` tensor. Shared layers retain | ||
| the normal MLA cache. This keeps the ORT GenAI key/value naming contract | ||
| without duplicating the index key across 64 attention heads. | ||
|
|
||
| The MTP component exports: | ||
|
|
||
| 1. `enorm(next-token embedding)` and `hnorm(target hidden state)`; | ||
| 2. concatenation and the `2H -> H` `eh_proj`; | ||
| 3. the complete layer-78 DSA+MoE decoder block; and | ||
| 4. `shared_head.norm`, producing `mtp_hidden` for the shared target LM head. | ||
|
|
||
| Multi-component packages contain `model/model.onnx`, `mtp/model.onnx`, and an | ||
| `mtp_config.json` sidecar. Both runtime aliases (`onnx-genai` and `ort-genai`) | ||
| emit these artifacts. ORT GenAI does not yet orchestrate the sidecar itself. | ||
|
|
||
| The importer: | ||
|
|
||
| - derives all MLA, MoE, IndexShare, RoPE, and NextN fields from GGUF metadata; | ||
| - separates the GGUF MTP block from the 78-layer backbone and exports it as `mtp`; | ||
| - fuses split `attn_k_b` and `attn_v_b` tensors into `kv_b_proj`; | ||
| - maps `blk.N.indexer.*` and supported `blk.78.nextn.*` tensors; | ||
| - streams stacked routed-expert tensors one expert at a time instead of | ||
| materializing all 256 experts as one float tensor; and | ||
| - preserves supported 4-bit and 8-bit tensors as MatMulNBits, requantizing | ||
| mixed source types to the selected graph-wide layout when necessary. | ||
|
|
||
| The generic portable MoE implementation evaluates every expert and masks the | ||
| outputs. It is correct for routing, but a fused sparse-MoE runtime kernel will | ||
| be required for practical GLM-5.2 execution. | ||
|
|
||
| ## Runtime follow-ups | ||
|
|
||
| Portable ONNX preserves DSA selection numerics by converting the selected | ||
| indices to an additive mask for the standard `Attention` operator. No custom op | ||
| is required for correctness. Practical million-token performance still needs | ||
| a selected-token sparse-attention kernel so the runtime avoids evaluating | ||
| masked K/V positions. | ||
|
|
||
| `index_share_for_mtp_iteration` also needs runtime orchestration. MTP step 0 can | ||
| return `topk_indices`, but accepted speculative positions make the MTP MLA and | ||
| indexer caches advance at different logical lengths. ORT GenAI currently has | ||
| no separate indexer-cache/control-state contract, so steps 1+ recompute the | ||
| index selection rather than silently applying an incorrect packed-cache reuse. | ||
|
|
||
| Use `--glm-full-attention` to retain the prior dense MLA fallback. The fallback | ||
| disables the DSA-dependent MTP artifact. | ||
|
|
||
| ## Separate workstream: Unsloth dynamic quantization | ||
|
|
||
| The six `UD-IQ1_M` GGUF shard headers were inspected. The dominant routed | ||
| expert tensors are below the currently targeted 4-bit floor: | ||
|
|
||
| - routed gate/up projections: 76 `IQ1_M` and 74 `IQ2_XXS` tensors; | ||
| - routed down projections: 71 `IQ3_XXS` and 4 `IQ4_XS` tensors; | ||
| - shared experts: mainly `Q5_K` / `Q6_K`; | ||
| - MLA projections: mainly `Q8_0` / `Q5_K`; | ||
| - norms: F32; embedding and output: Q4_K. | ||
|
|
||
| This increment can dequantize IQ sources and requantize them to a supported | ||
| Q4 MatMulNBits layout. That enables export but loses the intended sub-4-bit | ||
| size and may alter accuracy. Native preservation should use one or both of the | ||
| following paths. | ||
|
|
||
| ### Runtime custom-op requirement | ||
|
|
||
| Add an ONNX Runtime GenAI execution-provider capability for a fused sparse-MoE | ||
| operator that: | ||
|
|
||
| 1. consumes top-k expert indices and routing weights without evaluating all | ||
| 256 experts; | ||
| 2. supports the llama.cpp IQ1_M, IQ2_XXS, and IQ3_XXS block/codebook layouts | ||
| (plus IQ4_XS), including their scales/minimums; | ||
| 3. fuses gate/up activation and down projection where profitable; | ||
| 4. loads only selected expert blocks and supports CPU and target accelerator | ||
| implementations; and | ||
| 5. advertises supported bit layouts through Mobius EP capabilities so export | ||
| selects native packed initializers, otherwise falling back to Q4 | ||
| MatMulNBits requantization. | ||
|
|
||
| ### Draft ONNX Runtime issue | ||
|
|
||
| **Title:** Support sub-4-bit GGUF/IQ weights for MatMulNBits and sparse MoE | ||
|
|
||
| **Body:** | ||
|
|
||
| > GLM-5.2 dynamic GGUF distributions use IQ1_M and IQ2_XXS for most routed | ||
| > gate/up expert matrices and IQ3_XXS for most down matrices. Exporting these | ||
| > models to ONNX currently requires expansion or requantization to 4-bit, | ||
| > losing the distribution's size advantage and making a 256-expert model | ||
| > impractical. Please define supported 1/2/3-bit MatMulNBits representations, | ||
| > or provide an extensible packed/codebook-weight contract for the llama.cpp | ||
| > IQ formats, with CPU and execution-provider kernels. For MoE, an operator | ||
| > that accepts expert weights plus per-token top-k indices/weights should avoid | ||
| > evaluating or unpacking every expert. Required formats for the first target | ||
| > are IQ1_M, IQ2_XXS, IQ3_XXS, and IQ4_XS. Please also document zero-point, | ||
| > scale/codebook packing, block-size, alignment, and external-data constraints. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| """Export the tiny synthetic DeepSeek-V2 MLA + MoE model for onnx-genai E2E.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import os | ||
| import sys | ||
|
|
||
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "tests")) | ||
|
|
||
| import numpy as np | ||
| import onnx_ir as ir | ||
| from _test_configs import ALL_CAUSAL_LM_CONFIGS, _base_config | ||
|
|
||
| from mobius._config_resolver import _default_task_for_model | ||
| from mobius._registry import registry | ||
| from mobius.integrations.onnx_genai import write_onnx_genai_config | ||
| from mobius.tasks import get_task | ||
|
|
||
| ARTIFACTS_DIR = os.environ.get( | ||
| "MOBIUS_ARTIFACTS_DIR", os.path.join(os.path.dirname(__file__), "artifacts") | ||
| ) | ||
|
|
||
|
|
||
| def _fill_random_weights(model: ir.Model, rng: np.random.Generator) -> None: | ||
| for init in model.graph.initializers.values(): | ||
| if init.const_value is not None: | ||
| continue | ||
| shape = tuple(int(d) for d in init.shape) | ||
| if not shape: | ||
| continue | ||
| if init.dtype == ir.DataType.FLOAT: | ||
| data = rng.standard_normal(shape).astype(np.float32) * 0.02 | ||
| elif init.dtype == ir.DataType.FLOAT16: | ||
| data = (rng.standard_normal(shape) * 0.02).astype(np.float16) | ||
| elif init.dtype in (ir.DataType.INT64, ir.DataType.INT32): | ||
| npd = np.int64 if init.dtype == ir.DataType.INT64 else np.int32 | ||
| data = rng.integers(0, 10, size=shape).astype(npd) | ||
| else: | ||
| data = rng.standard_normal(shape).astype(np.float32) * 0.02 | ||
| init.const_value = ir.Tensor(data) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument( | ||
| "--output-dir", | ||
| default=os.path.join(ARTIFACTS_DIR, "deepseek-v2-tiny"), | ||
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| overrides = dict(next(ov for mt, ov, _ in ALL_CAUSAL_LM_CONFIGS if mt == "deepseek_v2")) | ||
| config = _base_config(**overrides) | ||
| config.dtype = ir.DataType.FLOAT | ||
|
|
||
| module = registry.get("deepseek_v2")(config) | ||
| task = get_task(_default_task_for_model("deepseek_v2")) | ||
| pkg = task.build(module, config) | ||
|
|
||
| rng = np.random.default_rng(0) | ||
| for model in pkg.values(): | ||
| _fill_random_weights(model, rng) | ||
|
|
||
| os.makedirs(args.output_dir, exist_ok=True) | ||
| pkg.save(args.output_dir, external_data="onnx", check_weights=False) | ||
| artifacts = write_onnx_genai_config(pkg, args.output_dir, config=config) | ||
| for name, path in artifacts.items(): | ||
| print(f"{name}:", path) | ||
| print("Saved to", args.output_dir) | ||
| print("files:", sorted(os.listdir(args.output_dir))) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| """Export a tiny synthetic glm_moe_dsa model with a FUSED ``com.microsoft::QMoE``. | ||
|
|
||
| Same tiny ``glm_moe_dsa`` config as ``export_glm_tiny_quant.py``, but sets | ||
| ``config.fused_quantized_moe = True`` so the routed MoE experts are emitted as a | ||
| single fused ``com.microsoft::QMoE`` node (int4, block-32, expert-major layout) | ||
| instead of a per-expert unroll of ``com.microsoft::MatMulNBits``. | ||
|
|
||
| Routing (GLM sigmoid + noaux_tc, n_group=1) is preserved exactly: the QMoE | ||
| kernel re-derives top-k selection from ``router_probs`` (the gate's | ||
| ``scores_for_choice``), and the normalized+scaled combine weights are scattered | ||
| into the optional ``router_weights`` input with ``normalize_routing_weights=0``. | ||
|
|
||
| Writes an onnx-genai-loadable artifact directory: | ||
| <out>/model.onnx (+ external data), inference_metadata.yaml, tokenizer.json | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import os | ||
| import shutil | ||
| import sys | ||
|
|
||
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "tests")) | ||
|
|
||
| import numpy as np | ||
| import onnx_ir as ir | ||
| from _test_configs import ALL_CAUSAL_LM_CONFIGS, _base_config | ||
|
|
||
| from mobius._config_resolver import _default_task_for_model | ||
| from mobius._configs import QuantizationConfig | ||
| from mobius._registry import registry | ||
| from mobius.integrations.onnx_genai import write_onnx_genai_config | ||
| from mobius.tasks import get_task | ||
|
|
||
| ARTIFACTS_DIR = os.environ.get( | ||
| "MOBIUS_ARTIFACTS_DIR", os.path.join(os.path.dirname(__file__), "artifacts") | ||
| ) | ||
|
|
||
|
|
||
| def _fill_random_weights(model: ir.Model, rng: np.random.Generator) -> None: | ||
| for init in model.graph.initializers.values(): | ||
| if init.const_value is not None: | ||
| continue | ||
| shape = tuple(int(d) for d in init.shape) | ||
| if not shape: | ||
| continue | ||
| if init.dtype == ir.DataType.FLOAT: | ||
| data = rng.standard_normal(shape).astype(np.float32) * 0.02 | ||
| elif init.dtype == ir.DataType.FLOAT16: | ||
| data = (rng.standard_normal(shape) * 0.02).astype(np.float16) | ||
| elif init.dtype == ir.DataType.UINT8: | ||
| # Packed int4/int8 quantized weights or bit-packed zero points. | ||
| data = rng.integers(0, 256, size=shape).astype(np.uint8) | ||
| elif init.dtype in (ir.DataType.INT64, ir.DataType.INT32): | ||
| npd = np.int64 if init.dtype == ir.DataType.INT64 else np.int32 | ||
| data = rng.integers(0, 10, size=shape).astype(npd) | ||
| else: | ||
| data = rng.standard_normal(shape).astype(np.float32) * 0.02 | ||
| init.const_value = ir.Tensor(data) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument( | ||
| "--output-dir", | ||
| default=os.path.join(ARTIFACTS_DIR, "glm-5.2-tiny-qmoe"), | ||
| ) | ||
| parser.add_argument( | ||
| "--fp32-output-dir", | ||
| default=os.path.join(ARTIFACTS_DIR, "glm-5.2-tiny"), | ||
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| overrides = dict(next(ov for mt, ov, _ in ALL_CAUSAL_LM_CONFIGS if mt == "glm_moe_dsa")) | ||
| config = _base_config(**overrides) | ||
| config.dtype = ir.DataType.FLOAT | ||
| # int4, block-32, symmetric -> fused QMoE (no zero points; the kernel | ||
| # defaults per-block zero-point to 1 << (bits - 1)). | ||
| config.quantization = QuantizationConfig( | ||
| bits=4, | ||
| group_size=32, | ||
| quant_method="gguf", | ||
| sym=True, | ||
| ) | ||
| # Emit routed experts as a single fused com.microsoft::QMoE node. | ||
| config.fused_quantized_moe = True | ||
|
|
||
| model_type = "glm_moe_dsa" | ||
| module = registry.get(model_type)(config) | ||
| task = get_task(_default_task_for_model(model_type)) | ||
| pkg = task.build(module, config) | ||
|
|
||
| rng = np.random.default_rng(0) | ||
| for model in pkg.values(): | ||
| _fill_random_weights(model, rng) | ||
|
|
||
| os.makedirs(args.output_dir, exist_ok=True) | ||
| pkg.save(args.output_dir, external_data="onnx", check_weights=False) | ||
| artifacts = write_onnx_genai_config(pkg, args.output_dir, config=config) | ||
| for name, path in artifacts.items(): | ||
| print(f"{name}:", path) | ||
|
|
||
| # Reuse tokenizer from the fp32 artifacts if present. | ||
| tok_src = os.path.join(args.fp32_output_dir, "tokenizer.json") | ||
| if os.path.exists(tok_src): | ||
| shutil.copy(tok_src, os.path.join(args.output_dir, "tokenizer.json")) | ||
|
|
||
| for attr in ( | ||
| "num_hidden_layers", | ||
| "num_attention_heads", | ||
| "num_key_value_heads", | ||
| "head_dim", | ||
| "hidden_size", | ||
| "vocab_size", | ||
| "max_position_embeddings", | ||
| "qk_nope_head_dim", | ||
| "qk_rope_head_dim", | ||
| "v_head_dim", | ||
| "kv_lora_rank", | ||
| "q_lora_rank", | ||
| "num_local_experts", | ||
| "num_experts_per_tok", | ||
| "moe_intermediate_size", | ||
| ): | ||
| print(f" {attr} =", getattr(config, attr, None)) | ||
|
|
||
| print("Saved to", args.output_dir) | ||
| print("files:", sorted(os.listdir(args.output_dir))) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Chose option (a): onnx-genai now recognizes the exact
{model, mtp}package, emits normal decoder metadata plus the samemtp_config.jsoncontract as ORT GenAI, and reports it through the CLI. This was lower risk than weakening the documented cross-runtime artifact contract because it is a narrow exact-shape dispatch with focused tests.