Skip to content

Commit 1aaae99

Browse files
committed
feat: add GLM MoE DSA pre-load patch
Based on ml-explore/mlx-lm#1410 by @pcuenca (Pedro Cuenca): [transformers-to-mlx skill] glm_moe_dsa: DSA cross-layer indexer sharing (GLM-5.2) Upstream PR: ml-explore/mlx-lm#1410 Upstream head: 3cb18b51892a7800678923116f5b4920a7666208 Port the GLM-5.2 model patch into oMLX as a pre-load monkey patch so the pinned mlx-lm runtime can load glm_moe_dsa checkpoints with native full/shared DSA indexer sharing. Keep post-load IndexCache limited to DeepSeek DSA and cover CacheList hot/cold round trips.
1 parent 13abe21 commit 1aaae99

7 files changed

Lines changed: 638 additions & 6 deletions

File tree

omlx/model_settings.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ class ModelSettings:
5050
ttl_seconds: Auto-unload after idle seconds (None = no TTL).
5151
model_type_override: "llm", "vlm", "embedding", "reranker", or None (auto-detect).
5252
model_alias: API-visible alternative to the directory name.
53-
index_cache_freq: IndexCache: every Nth layer keeps indexer (DSA models only).
53+
index_cache_freq: IndexCache: every Nth layer keeps indexer (DeepSeek DSA
54+
only; GLM-5.2 uses its native checkpoint schedule).
5455
enable_thinking: Explicit toggle for thinking/reasoning mode (None = auto).
5556
thinking_budget_enabled: Whether a thinking token budget is active.
5657
thinking_budget_tokens: Max tokens for thinking/reasoning.
@@ -124,7 +125,7 @@ class ModelSettings:
124125
None # API-visible name (alternative to directory name)
125126
)
126127
index_cache_freq: Optional[int] = (
127-
None # IndexCache: every Nth layer keeps indexer (DSA models only)
128+
None # IndexCache: every Nth layer keeps indexer (DeepSeek DSA only)
128129
)
129130
enable_thinking: Optional[bool] = (
130131
None # Explicit toggle for thinking/reasoning mode (None = auto)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""GLM-5.2 ``glm_moe_dsa`` monkey-patch for mlx-lm.
3+
4+
Brings ml-explore/mlx-lm#1410 into oMLX without modifying the pinned
5+
mlx-lm package. The upstream change turns the stock bare DeepSeek-V3.2
6+
subclass into a GLM-5.2-aware model with native DSA indexer sharing:
7+
full layers compute top-k indices, shared layers reuse the previous full
8+
layer's top-k, and shared layers carry no indexer KV cache.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import importlib
14+
import importlib.util
15+
import logging
16+
import sys
17+
from pathlib import Path
18+
19+
logger = logging.getLogger(__name__)
20+
21+
PR_HEAD_SHA = "3cb18b51892a7800678923116f5b4920a7666208"
22+
PR_URL = "https://github.com/ml-explore/mlx-lm/pull/1410"
23+
24+
_APPLIED = False
25+
26+
27+
def _upstream_has_glm_moe_dsa_support() -> bool:
28+
"""Return True when the installed mlx-lm already has PR #1410 support."""
29+
try:
30+
module = importlib.import_module("mlx_lm.models.glm_moe_dsa")
31+
except Exception:
32+
return False
33+
34+
fields = getattr(getattr(module, "ModelArgs", None), "__dataclass_fields__", {})
35+
return "indexer_types" in fields and hasattr(module, "GlmMoeDsaModel")
36+
37+
38+
def _register_module() -> None:
39+
qualname = "mlx_lm.models.glm_moe_dsa"
40+
here = Path(__file__).parent
41+
file_path = here / "glm_moe_dsa_model.py"
42+
spec = importlib.util.spec_from_file_location(qualname, str(file_path))
43+
if spec is None or spec.loader is None:
44+
raise ImportError(f"Could not create spec for {qualname} from {file_path}")
45+
46+
module = importlib.util.module_from_spec(spec)
47+
module.__package__ = "mlx_lm.models"
48+
sys.modules[qualname] = module
49+
spec.loader.exec_module(module)
50+
51+
import mlx_lm.models as models_pkg
52+
53+
models_pkg.glm_moe_dsa = module
54+
logger.info("Registered %s from %s", qualname, file_path.name)
55+
56+
57+
def apply_glm_moe_dsa_patch() -> bool:
58+
"""Apply the GLM MoE DSA patch. Idempotent.
59+
60+
Must run before ``mlx_lm.load()`` imports ``mlx_lm.models.glm_moe_dsa``.
61+
62+
Returns True when oMLX registered its vendored module, False when the
63+
patch was already applied, mlx-lm is unavailable, or upstream already
64+
ships equivalent support.
65+
"""
66+
global _APPLIED
67+
if _APPLIED:
68+
return False
69+
70+
try:
71+
import mlx_lm # noqa: F401
72+
except ImportError:
73+
logger.debug("mlx_lm not importable - glm_moe_dsa patch skipped")
74+
return False
75+
76+
if _upstream_has_glm_moe_dsa_support():
77+
_APPLIED = True
78+
logger.debug("mlx_lm.models.glm_moe_dsa already supports GLM-5.2 sharing")
79+
return False
80+
81+
_register_module()
82+
_APPLIED = True
83+
logger.info("GLM MoE DSA mlx-lm patch applied (PR 1410 head %s)", PR_HEAD_SHA[:8])
84+
return True
85+
86+
87+
def is_applied() -> bool:
88+
return _APPLIED
89+
90+
91+
__all__ = ["apply_glm_moe_dsa_patch", "is_applied", "PR_HEAD_SHA", "PR_URL"]
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# Copyright (c) 2025 Apple Inc.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""GLM-5.2 ``glm_moe_dsa`` model for the pinned mlx-lm runtime.
4+
5+
Vendored from ml-explore/mlx-lm#1410 so oMLX can load GLM-5.2 checkpoints
6+
while the pinned mlx-lm still exposes ``glm_moe_dsa`` as a bare
7+
DeepSeek-V3.2 subclass.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass
13+
from typing import Any
14+
15+
import mlx.core as mx
16+
17+
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
18+
from .cache import CacheList, KVCache
19+
from .deepseek_v32 import (
20+
DeepseekV32Attention,
21+
DeepseekV32DecoderLayer,
22+
DeepseekV32Model,
23+
)
24+
from .deepseek_v32 import Model as DSV32Model
25+
26+
27+
@dataclass
28+
class ModelArgs(BaseModelArgs):
29+
model_type: str
30+
vocab_size: int
31+
hidden_size: int
32+
index_head_dim: int
33+
index_n_heads: int
34+
index_topk: int
35+
intermediate_size: int
36+
moe_intermediate_size: int
37+
num_hidden_layers: int
38+
num_attention_heads: int
39+
num_key_value_heads: int
40+
n_shared_experts: int | None
41+
n_routed_experts: int | None
42+
routed_scaling_factor: float
43+
kv_lora_rank: int
44+
q_lora_rank: int
45+
qk_rope_head_dim: int
46+
v_head_dim: int
47+
qk_nope_head_dim: int
48+
topk_method: str
49+
scoring_func: str
50+
norm_topk_prob: bool
51+
n_group: int
52+
topk_group: int
53+
num_experts_per_tok: int
54+
moe_layer_freq: int
55+
first_k_dense_replace: int
56+
max_position_embeddings: int
57+
rms_norm_eps: float
58+
rope_parameters: dict
59+
attention_bias: bool
60+
rope_scaling: dict | None = None
61+
rope_theta: float | None = None
62+
indexer_types: list[str] | None = None
63+
index_topk_pattern: Any | None = None
64+
index_topk_freq: int = 1
65+
index_skip_topk_offset: int = 2
66+
67+
def __post_init__(self):
68+
self.rope_scaling = self.rope_parameters
69+
self.rope_theta = self.rope_parameters["rope_theta"]
70+
71+
if self.indexer_types is None:
72+
if self.index_topk_pattern is not None:
73+
pattern = self.index_topk_pattern
74+
if isinstance(pattern, str):
75+
self.indexer_types = [
76+
{"F": "full", "S": "shared"}[c] for c in pattern
77+
]
78+
else:
79+
self.indexer_types = list(pattern)
80+
else:
81+
freq = max(int(self.index_topk_freq), 1)
82+
offset = int(self.index_skip_topk_offset)
83+
self.indexer_types = [
84+
"full" if (max(i - offset + 1, 0) % freq) == 0 else "shared"
85+
for i in range(self.num_hidden_layers)
86+
]
87+
else:
88+
self.indexer_types = list(self.indexer_types)
89+
90+
if len(self.indexer_types) != self.num_hidden_layers:
91+
raise ValueError(
92+
"`indexer_types` must have one entry per hidden layer, "
93+
f"got {len(self.indexer_types)} for {self.num_hidden_layers} layers."
94+
)
95+
invalid = sorted(set(self.indexer_types) - {"full", "shared"})
96+
if invalid:
97+
raise ValueError(f"Unsupported GLM MoE DSA indexer types: {invalid}")
98+
if self.indexer_types and self.indexer_types[0] != "full":
99+
raise ValueError("The first GLM MoE DSA layer must be a full indexer layer.")
100+
101+
102+
class GlmMoeDsaAttention(DeepseekV32Attention):
103+
def __init__(self, config: ModelArgs, layer_idx: int):
104+
super().__init__(config)
105+
self.skip_topk = config.indexer_types[layer_idx] == "shared"
106+
if self.skip_topk:
107+
self.indexer = None
108+
109+
def __call__(
110+
self,
111+
x: mx.array,
112+
mask: mx.array | None = None,
113+
cache: Any | None = None,
114+
prev_topk_indices: mx.array | None = None,
115+
):
116+
batch_size, seq_len, hidden_dim = x.shape
117+
118+
qr = self.q_a_layernorm(self.q_a_proj(x))
119+
q = self.q_b_proj(qr)
120+
121+
q = q.reshape(
122+
batch_size, seq_len, self.num_heads, self.q_head_dim
123+
).transpose(0, 2, 1, 3)
124+
q_nope, q_pe = mx.split(q, [self.qk_nope_head_dim], axis=-1)
125+
compressed_kv = self.kv_a_proj_with_mqa(x)
126+
compressed_kv, k_pe = mx.split(compressed_kv, [self.kv_lora_rank], axis=-1)
127+
k_pe = k_pe.reshape(
128+
batch_size, seq_len, 1, self.qk_rope_head_dim
129+
).transpose(0, 2, 1, 3)
130+
kv_latent = self.kv_a_layernorm(compressed_kv)
131+
132+
offset = cache[0].offset if cache is not None else 0
133+
q_pe = self.rope(q_pe, offset)
134+
k_pe = self.rope(k_pe, offset)
135+
136+
kv_latent = mx.expand_dims(kv_latent, axis=1)
137+
138+
if cache is not None:
139+
kv_latent, k_pe = cache[0].update_and_fetch(kv_latent, k_pe)
140+
else:
141+
cache = [None] * 2
142+
143+
if self.indexer is not None:
144+
topk_indices = self.indexer(x, qr, mask, cache=cache[1])
145+
else:
146+
topk_indices = prev_topk_indices
147+
148+
if topk_indices is not None:
149+
if seq_len == 1:
150+
idx = topk_indices[:, :, 0, :, None]
151+
kv_latent = mx.take_along_axis(
152+
kv_latent,
153+
mx.broadcast_to(idx, idx.shape[:-1] + (kv_latent.shape[-1],)),
154+
axis=2,
155+
)
156+
k_pe = mx.take_along_axis(
157+
k_pe,
158+
mx.broadcast_to(idx, idx.shape[:-1] + (k_pe.shape[-1],)),
159+
axis=2,
160+
)
161+
if mask is not None:
162+
mask = mx.take_along_axis(mask, topk_indices, axis=-1)
163+
else:
164+
shape = list(topk_indices.shape)
165+
shape[-1] = kv_latent.shape[2]
166+
sparse_mask = mx.zeros(shape, dtype=mx.bool_)
167+
sparse_mask = mx.put_along_axis(
168+
sparse_mask, topk_indices, mx.array(True), axis=-1
169+
)
170+
if mask is not None:
171+
sparse_mask = sparse_mask & mask
172+
mask = sparse_mask
173+
174+
if self.indexer is not None and cache is not None and cache[0] is not None:
175+
cache[0].keys = mx.depends(cache[0].keys, (cache[1].keys, cache[1].values))
176+
177+
pe_scores = (q_pe * self.scale) @ k_pe.swapaxes(-1, -2)
178+
if mask is not None:
179+
pe_scores = mx.where(
180+
mask,
181+
pe_scores,
182+
mx.array(mx.finfo(pe_scores.dtype).min, pe_scores.dtype),
183+
)
184+
185+
if seq_len == 1:
186+
q_nope = self.embed_q(q_nope)
187+
k = v = kv_latent
188+
else:
189+
k = self.embed_q(kv_latent, transpose=False)
190+
v = self.unembed_out(kv_latent)
191+
192+
output = scaled_dot_product_attention(
193+
q_nope, k, v, cache=cache, scale=self.scale, mask=pe_scores
194+
)
195+
if seq_len == 1:
196+
output = self.unembed_out(output)
197+
198+
output = output.transpose(0, 2, 1, 3).reshape(
199+
batch_size, seq_len, hidden_dim
200+
)
201+
return self.o_proj(output), topk_indices
202+
203+
204+
class GlmMoeDsaDecoderLayer(DeepseekV32DecoderLayer):
205+
def __init__(self, config: ModelArgs, layer_idx: int):
206+
super().__init__(config, layer_idx)
207+
self.self_attn = GlmMoeDsaAttention(config, layer_idx)
208+
209+
def __call__(
210+
self,
211+
x: mx.array,
212+
mask: mx.array | None = None,
213+
cache: Any | None = None,
214+
prev_topk_indices: mx.array | None = None,
215+
):
216+
r, topk_indices = self.self_attn(
217+
self.input_layernorm(x), mask, cache, prev_topk_indices
218+
)
219+
h = x + r
220+
r = self.mlp(self.post_attention_layernorm(h))
221+
return h + r, topk_indices
222+
223+
224+
class GlmMoeDsaModel(DeepseekV32Model):
225+
def __init__(self, config: ModelArgs):
226+
super().__init__(config)
227+
self.layers = [
228+
GlmMoeDsaDecoderLayer(config, idx)
229+
for idx in range(config.num_hidden_layers)
230+
]
231+
232+
def __call__(
233+
self,
234+
x: mx.array,
235+
cache: Any | None = None,
236+
) -> mx.array:
237+
h = self.embed_tokens(x)
238+
239+
pipeline_rank = self.pipeline_rank
240+
pipeline_size = self.pipeline_size
241+
242+
if cache is None:
243+
cache = [None] * self.num_layers
244+
mask = create_attention_mask(
245+
h, cache[0][0] if cache[0] else None, return_array=True
246+
)
247+
248+
if pipeline_rank < pipeline_size - 1:
249+
h = mx.distributed.recv_like(h, (pipeline_rank + 1))
250+
251+
prev_topk_indices = None
252+
for i in range(self.num_layers):
253+
h, prev_topk_indices = self.layers[self.start_idx + i](
254+
h, mask, cache[i], prev_topk_indices
255+
)
256+
257+
if pipeline_rank != 0:
258+
h = mx.distributed.send(h, (pipeline_rank - 1) % pipeline_size)
259+
if cache[-1] is not None:
260+
cache[-1][0].keys = mx.depends(cache[-1][0].keys, h)
261+
262+
if pipeline_size > 1:
263+
h = mx.distributed.all_gather(h)[: h.shape[0]]
264+
265+
return self.norm(h)
266+
267+
268+
class Model(DSV32Model):
269+
def __init__(self, config: ModelArgs):
270+
super().__init__(config)
271+
self.model = GlmMoeDsaModel(config)
272+
273+
def make_cache(self):
274+
caches = []
275+
for layer in self.layers:
276+
if getattr(layer.self_attn, "skip_topk", False):
277+
caches.append(CacheList(KVCache()))
278+
else:
279+
caches.append(CacheList(KVCache(), KVCache()))
280+
return caches

0 commit comments

Comments
 (0)