Skip to content

Commit c7cf5b2

Browse files
committed
feat(v4): vendor mlx-lm PR #1189 mHC — HyperConnection + fused Sinkhorn
Two verbatim vendored modules from ml-explore/mlx-lm PR #1189 (MIT, © 2025-2026 Apple Inc.): - _mlx_lm_sinkhorn_vendored.py — Sinkhorn projection onto the Birkhoff polytope with a fused register-resident Metal kernel. One thread per token, hc² floats in registers, fully unrolled row_softmax + 20 iterations of (row_norm, col_norm). No threadgroup memory, no atomics, no global-memory traffic between iters. Pure-MLX fallback for hc>8 or when Metal isn't available. - _mlx_lm_hyper_connection_vendored.py — HyperConnection (per-block mHC) + HyperHead (final-layer head). hc_pre reduces [B,S,hc,D]→[B,S,D] via learned pre-gating + Sinkhorn; hc_post expands back via comb @ residual + post * f_out. Complements our existing tilekernels_mhc.py (pure-MLX port of DeepSeek's torch reference): the vendored version gives us the fused Sinkhorn kernel that the upstream paper relies on for production speed. Tests (6 new in tests/v4/test_mhc_metal_v4.py): - hc_split_sinkhorn shapes / doubly-stochastic property (rows, cols ≈ 1) - Metal kernel vs pure-MLX parity (atol 1e-3 on comb, 1e-6 on pre/post) - HyperConnection module hc_pre/hc_post shape contract - HyperHead head reduction shape - Sinkhorn kernel cache returns identity for repeated build v4 suite: 184 passed / 2 skipped.
1 parent 93c4706 commit c7cf5b2

3 files changed

Lines changed: 417 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Vendored verbatim from ml-explore/mlx-lm PR #1189
2+
# (mlx_lm/models/hyper_connection.py)
3+
# Upstream license: MIT, Copyright © 2026 Apple Inc.
4+
#
5+
# Public surface used by cppmega_v4:
6+
# - HyperConnection — per-block mHC (hc_pre + hc_post)
7+
# - HyperHead — final-layer head variant (sigmoid-weighted reduction)
8+
#
9+
# No edits — pure copy. cppmega_v4/nn/mhc_v4.py exposes the v4 wrapper
10+
# that integrates these into our UnifiedSuperblock.
11+
12+
"""Manifold-constrained Hyper-Connections (mHC) — shared building blocks.
13+
14+
DeepSeek-V4 introduced multi-HyperConnection (mHC) as a residual replacement:
15+
expand the hidden state into `hc_mult` parallel copies, mix them via a
16+
doubly-stochastic matrix on the Birkhoff polytope, apply the block, and
17+
recombine. This module hosts the `nn.Module` layers; the Sinkhorn projection
18+
that produces the doubly-stochastic mixing matrix lives in
19+
`_mlx_lm_sinkhorn_vendored`.
20+
21+
Two layers:
22+
- HyperConnection — per-block mHC: hc_pre reduces hc_mult -> 1; block F runs;
23+
hc_post expands 1 -> hc_mult via (post * f_out + comb @ residual).
24+
- HyperHead — final-layer head variant: sigmoid-weighted reduction
25+
hc_mult -> 1 with no Sinkhorn (simpler than HyperConnection).
26+
27+
References:
28+
- mHC: arXiv:2512.24880 (DeepSeek, Dec 2025)
29+
- HC base: arXiv:2409.19606 (Sep 2024)
30+
"""
31+
32+
import mlx.core as mx
33+
import mlx.nn as nn
34+
35+
from ._mlx_lm_sinkhorn_vendored import hc_split_sinkhorn
36+
37+
38+
class HyperConnection(nn.Module):
39+
"""Per-block mHC parameters: projects x -> (pre, post, comb) used in hc_pre/hc_post.
40+
41+
Paper/ref stores the weights as:
42+
hc_fn : [(2+hc)*hc, hc*dim]
43+
hc_scale : [3]
44+
hc_base : [(2+hc)*hc]
45+
46+
hc_pre reduces `hc_mult` parallel hidden states to 1 via `pre`.
47+
Block F is applied to the reduced state. hc_post expands 1 -> hc via `post` (the new
48+
contribution) added to `comb @ residual` (where `comb` is a doubly-stochastic mix
49+
that recombines the input `hc_mult` copies to stay on the Birkhoff manifold).
50+
"""
51+
52+
def __init__(self, dim: int, hc_mult: int, norm_eps: float, sinkhorn_iters: int, hc_eps: float):
53+
super().__init__()
54+
self.dim = dim
55+
self.hc_mult = hc_mult
56+
self.norm_eps = norm_eps
57+
self.sinkhorn_iters = sinkhorn_iters
58+
self.hc_eps = hc_eps
59+
mix_hc = (2 + hc_mult) * hc_mult
60+
hc_dim = hc_mult * dim
61+
# All mHC params are fp32 in the checkpoint.
62+
self.fn = mx.zeros((mix_hc, hc_dim), dtype=mx.float32)
63+
self.base = mx.zeros((mix_hc,), dtype=mx.float32)
64+
self.scale = mx.zeros((3,), dtype=mx.float32)
65+
self._fn_t = None # lazy transpose cache (avoids 86 .T calls/token)
66+
67+
def hc_pre(self, x: mx.array):
68+
B, S, hc, D = x.shape
69+
dtype = x.dtype
70+
xf = x.reshape(B, S, hc * D).astype(mx.float32)
71+
xf_norm = mx.fast.rms_norm(xf, weight=None, eps=self.norm_eps)
72+
if self._fn_t is None:
73+
self._fn_t = self.fn.T
74+
mixes = (xf_norm @ self._fn_t).reshape(B * S, -1)
75+
pre, post, comb = hc_split_sinkhorn(
76+
mixes, self.scale, self.base, hc, self.sinkhorn_iters, self.hc_eps
77+
)
78+
pre = pre.reshape(B, S, hc)
79+
post = post.reshape(B, S, hc)
80+
comb = comb.reshape(B, S, hc, hc)
81+
y = (pre[..., None] * x.astype(mx.float32)).sum(axis=2)
82+
return y.astype(dtype), post, comb
83+
84+
def hc_post(self, f_out: mx.array, residual: mx.array, post: mx.array, comb: mx.array):
85+
# f_out [B,S,D] (block output, reduced state)
86+
# residual [B,S,hc,D] (input to hc_pre)
87+
# post [B,S,hc]
88+
# comb [B,S,hc,hc]
89+
# returns [B,S,hc,D]
90+
dtype = f_out.dtype
91+
# post.unsqueeze(-1) * f_out.unsqueeze(-2) -> [B,S,hc,D]
92+
term_new = post[..., None] * f_out[:, :, None, :].astype(mx.float32)
93+
# comb @ residual: [B,S,hc,hc] @ [B,S,hc,D] -> [B,S,hc,D]
94+
term_res = comb.astype(mx.float32) @ residual.astype(mx.float32)
95+
y = term_new + term_res
96+
return y.astype(dtype)
97+
98+
99+
class HyperHead(nn.Module):
100+
"""Final (head) mHC projection: reduces [B,S,hc,D] -> [B,S,D] via sigmoid-weighted sum.
101+
No Sinkhorn here — this is the simpler head variant from `ParallelHead.hc_head`.
102+
"""
103+
104+
def __init__(self, dim: int, hc_mult: int, norm_eps: float, hc_eps: float):
105+
super().__init__()
106+
self.dim = dim
107+
self.hc_mult = hc_mult
108+
self.norm_eps = norm_eps
109+
self.hc_eps = hc_eps
110+
self.fn = mx.zeros((hc_mult, hc_mult * dim), dtype=mx.float32)
111+
self.base = mx.zeros((hc_mult,), dtype=mx.float32)
112+
self.scale = mx.zeros((1,), dtype=mx.float32)
113+
self._fn_t = None # lazy transpose cache
114+
115+
def __call__(self, x: mx.array):
116+
B, S, hc, D = x.shape
117+
dtype = x.dtype
118+
xf = x.reshape(B, S, hc * D).astype(mx.float32)
119+
inv = mx.rsqrt((xf * xf).mean(axis=-1, keepdims=True) + self.norm_eps)
120+
if self._fn_t is None:
121+
self._fn_t = self.fn.T
122+
mixes = (xf @ self._fn_t) * inv # [B,S,hc]
123+
pre = mx.sigmoid(mixes * self.scale[0] + self.base) + self.hc_eps
124+
y = (pre[..., None] * x.astype(mx.float32)).sum(axis=2)
125+
return y.astype(dtype)
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Vendored verbatim from ml-explore/mlx-lm PR #1189 (mlx_lm/models/sinkhorn.py)
2+
# Upstream license: MIT, Copyright © 2025 Apple Inc.
3+
#
4+
# Public surface used by cppmega_v4:
5+
# - hc_split_sinkhorn(mixes, hc_scale, hc_base, hc_mult, sinkhorn_iters, eps)
6+
# → (pre, post, comb) where comb is doubly-stochastic on the Birkhoff
7+
# polytope (Sinkhorn-normalized).
8+
# - _make_sinkhorn_kernel(hc, iters, eps) — fused register-resident Metal
9+
# kernel (one thread per token, hc^2 floats in registers, no atomics).
10+
#
11+
# No edits — pure copy. cppmega_v4/nn/_external/mlx_lm_hyper_connection.py
12+
# wraps our (pre, post, comb) API to this module's surface.
13+
14+
"""Sinkhorn normalization for doubly-stochastic matrices (HyperConnection mHC).
15+
16+
Shared module for models that use learned mixing weights on the Birkhoff
17+
polytope (e.g., DeepSeek-V4's multi-HyperConnection). Provides both a
18+
fused Metal kernel (register-resident, one thread per token) and a pure-MLX
19+
reference path.
20+
"""
21+
22+
import mlx.core as mx
23+
24+
_SINKHORN_KERNELS: dict = {}
25+
26+
27+
def _make_sinkhorn_kernel(hc: int, iters: int, eps: float):
28+
"""Build a fully-unrolled, register-resident Sinkhorn Metal kernel.
29+
30+
Each thread owns one token's [hc, hc] matrix in registers (hc^2 floats).
31+
No threadgroup memory, no atomics, no global memory traffic between iters
32+
— kernel reads input once and writes output once.
33+
"""
34+
key = (hc, iters)
35+
if key in _SINKHORN_KERNELS:
36+
return _SINKHORN_KERNELS[key]
37+
38+
n_elem = hc * hc
39+
eps_lit = f"{eps:.8e}f"
40+
41+
def row_softmax():
42+
out = []
43+
for r in range(hc):
44+
base = r * hc
45+
out.append(f" {{ float mx = m[{base}];")
46+
for c in range(1, hc):
47+
out.append(f" mx = metal::max(mx, m[{base + c}]);")
48+
out.append(f" float s = 0.0f;")
49+
for c in range(hc):
50+
out.append(f" m[{base + c}] = metal::exp(m[{base + c}] - mx); s += m[{base + c}];")
51+
out.append(f" float inv = 1.0f / s;")
52+
for c in range(hc):
53+
out.append(f" m[{base + c}] *= inv;")
54+
out.append(" }")
55+
return "\n".join(out)
56+
57+
def row_norm():
58+
out = []
59+
for r in range(hc):
60+
base = r * hc
61+
terms = " + ".join(f"m[{base + c}]" for c in range(hc))
62+
out.append(f" {{ float inv = 1.0f / ({terms} + {eps_lit});")
63+
for c in range(hc):
64+
out.append(f" m[{base + c}] *= inv;")
65+
out.append(" }")
66+
return "\n".join(out)
67+
68+
def col_norm():
69+
out = []
70+
for c in range(hc):
71+
terms = " + ".join(f"m[{r * hc + c}]" for r in range(hc))
72+
out.append(f" {{ float inv = 1.0f / ({terms} + {eps_lit});")
73+
for r in range(hc):
74+
out.append(f" m[{r * hc + c}] *= inv;")
75+
out.append(" }")
76+
return "\n".join(out)
77+
78+
def add_eps():
79+
return "\n".join(f" m[{i}] += {eps_lit};" for i in range(n_elem))
80+
81+
iter_body = "\n".join([row_norm(), col_norm()])
82+
inner_iters = "\n".join([iter_body] * (iters - 1))
83+
84+
source = f"""
85+
uint n = thread_position_in_grid.x;
86+
if (n >= n_tokens[0]) return;
87+
88+
uint base = n * {n_elem};
89+
float m[{n_elem}];
90+
{chr(10).join(f" m[{i}] = comb_log[base + {i}];" for i in range(n_elem))}
91+
92+
// Row softmax
93+
{row_softmax()}
94+
95+
// Add eps
96+
{add_eps()}
97+
98+
// Initial column normalization (matches reference: cols first after softmax)
99+
{col_norm()}
100+
101+
// Remaining (iters - 1) rounds of (row_norm, col_norm)
102+
{inner_iters}
103+
104+
{chr(10).join(f" comb[base + {i}] = m[{i}];" for i in range(n_elem))}
105+
"""
106+
107+
kernel = mx.fast.metal_kernel(
108+
name=f"sinkhorn_hc{hc}_it{iters}",
109+
input_names=["comb_log", "n_tokens"],
110+
output_names=["comb"],
111+
source=source,
112+
)
113+
_SINKHORN_KERNELS[key] = kernel
114+
return kernel
115+
116+
117+
def hc_split_sinkhorn(
118+
mixes: mx.array, # [B*S, (2+hc)*hc] fp32
119+
hc_scale: mx.array, # [3] fp32
120+
hc_base: mx.array, # [(2+hc)*hc] fp32
121+
hc_mult: int = 4,
122+
sinkhorn_iters: int = 20,
123+
eps: float = 1e-6,
124+
):
125+
"""Split `mixes` into (pre, post, comb_logits); Sinkhorn-normalize comb to doubly stochastic.
126+
127+
Returns:
128+
pre [N, hc] — sigmoid(mixes[:,:hc] * s0 + base[:hc]) + eps
129+
post [N, hc] — 2*sigmoid(mixes[:,hc:2hc] * s1 + base[hc:2hc])
130+
comb [N, hc, hc] — Sinkhorn-normalized (rows & cols ~= 1) from the last hc*hc logits.
131+
"""
132+
n = mixes.shape[0]
133+
mix = mixes
134+
s0, s1, s2 = hc_scale[0], hc_scale[1], hc_scale[2]
135+
136+
pre_log = mix[:, :hc_mult] * s0 + hc_base[:hc_mult]
137+
post_log = mix[:, hc_mult:2 * hc_mult] * s1 + hc_base[hc_mult:2 * hc_mult]
138+
comb_log = (
139+
mix[:, 2 * hc_mult:].reshape(n, hc_mult, hc_mult) * s2
140+
+ hc_base[2 * hc_mult:].reshape(hc_mult, hc_mult)
141+
)
142+
143+
pre = mx.sigmoid(pre_log) + eps
144+
post = 2 * mx.sigmoid(post_log)
145+
146+
use_kernel = (
147+
hc_mult <= 8
148+
and mx.metal.is_available()
149+
and comb_log.size > 0
150+
)
151+
if use_kernel:
152+
kernel = _make_sinkhorn_kernel(hc_mult, sinkhorn_iters, eps)
153+
flat = comb_log.reshape(n, hc_mult * hc_mult).astype(mx.float32)
154+
n_tokens = mx.array([n], dtype=mx.uint32)
155+
(comb_flat,) = kernel(
156+
inputs=[flat, n_tokens],
157+
output_shapes=[(n, hc_mult * hc_mult)],
158+
output_dtypes=[mx.float32],
159+
grid=(n, 1, 1),
160+
threadgroup=(min(n, 256) or 1, 1, 1),
161+
)
162+
comb = comb_flat.reshape(n, hc_mult, hc_mult)
163+
else:
164+
comb = mx.softmax(comb_log, axis=-1, precise=True) + eps
165+
col_sum = comb.sum(axis=1, keepdims=True) + eps
166+
comb = comb / col_sum
167+
for _ in range(sinkhorn_iters - 1):
168+
row_sum = comb.sum(axis=2, keepdims=True) + eps
169+
comb = comb / row_sum
170+
col_sum = comb.sum(axis=1, keepdims=True) + eps
171+
comb = comb / col_sum
172+
173+
return pre, post, comb

0 commit comments

Comments
 (0)