spark_model/layers/ops/glm5next_mhc.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3-Flash mHC kernel dispatch â the parts that are NOT DeepSeek-V4's.
4//!
5//! Separate file so `ops/hyper_connection.rs` (V4's proven dispatch) stays byte-untouched.
6//!
7//! `hc_pre` and `hc_post` need no wrapper here: the GLM kernels
8//! `glm5next_mhc::{glm5next_hc_pre, glm5next_hc_post}` have signatures IDENTICAL to their
9//! `hyper_connection` counterparts, so the GLM path calls `ops::hc_pre` / `ops::hc_post` with a
10//! GLM `KernelHandle`. Only `hc_head` needs its own entry point, because GLM's takes no weights
11//! at all.
12
13use anyhow::Result;
14use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
15use spark_runtime::kernel_args::KernelLaunch;
16
17/// Every kernel GLM-5.3's hyper-connection needs, all from the single module `glm5next_mhc`.
18///
19/// The point of this struct is target independence: a GLM kernel target must not have to carry
20/// the DeepSeek-V4 `hyper_connection` module to resolve half of its own mHC.
21/// `Copy` so all 45 layers can share one resolution â these are opaque handles, not state.
22#[derive(Clone, Copy)]
23pub struct Glm5NextMhcKernels {
24 /// Broadcast the embedding into the `hc_mult` streams. First text layer only.
25 ///
26 /// ðŠĪ This is an architecture-neutral broadcast that Atlas already had â and GLM still
27 /// needs its own, because `hyper_connection::hc_expand` lives in the **DeepSeek-V4
28 /// target directory**. A kernel target merges `common/` plus its OWN model dir and
29 /// cannot reach into another target's, so for the GLM target that module does not
30 /// exist. Resolving it would fail at first construction, not fall back.
31 pub hc_expand: KernelHandle,
32 /// The FUSED single-block `hc_pre`. Still resolved, and still the oracle
33 /// `examples/glm5next_hc_split_gate.rs` gates the split pair against â but the serve path
34 /// goes through `hc_mix` + `hc_finish`, which are bit-identical and ~mix_hc times wider.
35 pub hc_pre: KernelHandle,
36 /// One block per mixing row: grid `(T, mix_hc)`.
37 pub hc_mix: KernelHandle,
38 /// Same kernel reading `hc_fn` at the width the checkpoint stores it (BF16).
39 /// **Bit-identical** â widening BF16 to F32 is lossless, so it multiplies the same floats.
40 /// `try_kernel`; selected per site by `Glm5NextMhcSiteWeights::hc_fn_bf16`.
41 pub hc_mix_bf16: KernelHandle,
42 /// Split + Sinkhorn + collapse, reading the mixes from global.
43 pub hc_finish: KernelHandle,
44 pub hc_post: KernelHandle,
45 pub hc_head: KernelHandle,
46}
47
48/// The one module name GLM's mHC resolves from.
49pub const GLM5NEXT_MHC_MODULE: &str = "glm5next_mhc";
50
51impl Glm5NextMhcKernels {
52 /// Resolve all six. `kernel()` (not `try_kernel`) â a missing mHC kernel is a hard error,
53 /// never a silent fallback onto the DeepSeek variant.
54 pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
55 Ok(Self {
56 hc_expand: gpu.kernel(GLM5NEXT_MHC_MODULE, "glm5next_hc_expand")?,
57 hc_pre: gpu.kernel(GLM5NEXT_MHC_MODULE, "glm5next_hc_pre")?,
58 hc_mix: gpu.kernel(GLM5NEXT_MHC_MODULE, "glm5next_hc_mix")?,
59 hc_mix_bf16: crate::layers::try_kernel(
60 gpu,
61 GLM5NEXT_MHC_MODULE,
62 "glm5next_hc_mix_bf16",
63 ),
64 hc_finish: gpu.kernel(GLM5NEXT_MHC_MODULE, "glm5next_hc_finish")?,
65 hc_post: gpu.kernel(GLM5NEXT_MHC_MODULE, "glm5next_hc_post")?,
66 hc_head: gpu.kernel(GLM5NEXT_MHC_MODULE, "glm5next_hc_head")?,
67 })
68 }
69}
70
71/// Expand one BF16 hidden state into the `hc_mult` FP32 highway streams. First layer only.
72pub fn glm_hc_expand(
73 gpu: &dyn GpuBackend,
74 kernel: KernelHandle,
75 hidden: DevicePtr,
76 streams: DevicePtr,
77 num_tokens: u32,
78 hidden_size: u32,
79 hc_mult: u32,
80 stream: u64,
81) -> Result<()> {
82 KernelLaunch::new(gpu, kernel)
83 .grid([num_tokens, 1, 1])
84 .block([256, 1, 1])
85 .arg_ptr(hidden)
86 .arg_ptr(streams)
87 .arg_u32(hidden_size)
88 .arg_u32(hc_mult)
89 .launch(stream)
90}
91
92/// Final collapse before the LM head: an **unweighted mean** over the `hc_mult` streams.
93///
94/// ðī Deliberately takes NO weight pointers. GLM's `Glm5NextTextHyperHead` has no parameters and
95/// the checkpoint carries zero `hc_head` tensors; DeepSeek-V4's `ops::hc_head` reads
96/// `hc_head.{fn,base,scale}`. The absent arguments are the guard against reaching for weights
97/// that do not exist.
98pub fn hc_head_mean(
99 gpu: &dyn GpuBackend,
100 kernel: KernelHandle,
101 streams: DevicePtr,
102 y_out: DevicePtr,
103 num_tokens: u32,
104 hidden_size: u32,
105 hc_mult: u32,
106 stream: u64,
107) -> Result<()> {
108 KernelLaunch::new(gpu, kernel)
109 .grid([num_tokens, 1, 1])
110 .block([256, 1, 1])
111 .arg_ptr(streams)
112 .arg_ptr(y_out)
113 .arg_u32(hidden_size)
114 .arg_u32(hc_mult)
115 .launch(stream)
116}
117
118/// Per-site mHC weights. One set for the attention site, one for the FFN site.
119///
120/// ðŠĪ `hc_fn` is **FP32 to the kernel** and BF16 on disk â the loader upcasts. Reading it at
121/// the on-disk width is the #341/#347 dtype-mismatch class, and the shapes do not say so.
122#[derive(Debug, Clone, Copy)]
123pub struct Glm5NextMhcSiteWeights {
124 /// `[mix_hc, hc_mult * hidden]`, where `mix_hc = (2 + hc_mult) * hc_mult`. FP32 unless
125 /// `hc_fn_bf16`, in which case BF16 â the width the checkpoint actually stores.
126 pub hc_fn: DevicePtr,
127 /// Is `hc_fn` BF16? Production sets it; the microtests and the split gate build their own
128 /// F32 weights and leave it false, so the oracle they compare against is unchanged.
129 pub hc_fn_bf16: bool,
130 /// `[3]` FP32 â the three logit scales (pre, post, comb), in that order.
131 pub hc_scale: DevicePtr,
132 /// `[mix_hc]` FP32.
133 pub hc_base: DevicePtr,
134 /// `[MHC_MIX_MAX_TOKENS, mix_hc]` FP32 scratch: `hc_mix` writes it, `hc_finish` reads it.
135 /// Per-site, so the two sites of a layer cannot alias; both run on one stream in order.
136 pub mix: DevicePtr,
137}
138
139/// Token bound on the `mix` scratch. The GLM stack drives mHC one token at a time (the highway
140/// forces a serial prefill), so this is slack, not a shape â but `glm_hc_pre` REFUSES above it
141/// rather than writing past the allocation.
142pub const MHC_MIX_MAX_TOKENS: usize = 256;
143
144/// `hc_pre`: collapse the `hc_mult` FP32 streams to one BF16 sequence and emit this site's
145/// `post` / `comb` mixing coefficients.
146///
147/// ðŠĪ Named `glm_hc_pre`, not `hc_pre`: `ops` already exports DeepSeek-V4's `hc_pre`/`hc_post`
148/// through a glob, and the two are NOT interchangeable â V4's reads `hc_head.{fn,base,scale}`
149/// and uses a different mixing law. The rename is the compiler-enforced version of this
150/// module's "never silently fall back onto the DeepSeek variant" rule; a glob collision here
151/// resolved the wrong way would be a silent architecture swap.
152///
153/// The residual that `glm_hc_post` mixes is the stream tensor **as it entered here** â snapshot it
154/// before calling, which is the skeleton's `ResidualStep::SaveResidual`. Overwriting the streams
155/// in place before `hc_post` runs silently changes what the residual means.
156#[allow(clippy::too_many_arguments)]
157pub fn glm_hc_pre(
158 gpu: &dyn GpuBackend,
159 kernels: &Glm5NextMhcKernels,
160 streams: DevicePtr,
161 w: &Glm5NextMhcSiteWeights,
162 y_out: DevicePtr,
163 post_out: DevicePtr,
164 comb_out: DevicePtr,
165 num_tokens: u32,
166 hidden_size: u32,
167 hc_mult: u32,
168 sinkhorn_iters: u32,
169 norm_eps: f32,
170 hc_eps: f32,
171 stream: u64,
172) -> Result<()> {
173 let mix_hc = (2 + hc_mult) * hc_mult;
174 if num_tokens as usize > MHC_MIX_MAX_TOKENS {
175 anyhow::bail!(
176 "glm_hc_pre: {num_tokens} tokens exceeds the {MHC_MIX_MAX_TOKENS}-token `mix` \
177 scratch. Raise MHC_MIX_MAX_TOKENS and rebind; do not launch past the allocation."
178 );
179 }
180 // ðŠĪ The two kernels take the SAME arguments; only `hc_fn`'s element width differs, and it
181 // is the pointer's own dtype, not something the signature can catch. Pairing the wrong
182 // flag with the pointer reads BF16 as F32 (or the reverse) and produces plausible garbage.
183 let mix_kernel = if w.hc_fn_bf16 && kernels.hc_mix_bf16.0 != 0 {
184 kernels.hc_mix_bf16
185 } else {
186 kernels.hc_mix
187 };
188 KernelLaunch::new(gpu, mix_kernel)
189 .grid([num_tokens, mix_hc, 1])
190 .block([256, 1, 1])
191 .arg_ptr(streams)
192 .arg_ptr(w.hc_fn)
193 .arg_ptr(w.mix)
194 .arg_u32(hidden_size)
195 .arg_u32(hc_mult)
196 .arg_f32(norm_eps)
197 .launch(stream)?;
198 KernelLaunch::new(gpu, kernels.hc_finish)
199 // `1 +` â `blockIdx.y == 0` runs the Sinkhorn and does NOT take a share of the collapse.
200 .grid([num_tokens, 1 + collapse_blocks(hidden_size), 1])
201 .block([256, 1, 1])
202 .arg_ptr(streams)
203 .arg_ptr(w.mix)
204 .arg_ptr(w.hc_scale)
205 .arg_ptr(w.hc_base)
206 .arg_ptr(y_out)
207 .arg_ptr(post_out)
208 .arg_ptr(comb_out)
209 .arg_u32(hidden_size)
210 .arg_u32(hc_mult)
211 .arg_u32(sinkhorn_iters)
212 .arg_f32(hc_eps)
213 .launch(stream)
214}
215
216/// `glm_hc_post`: `out[j] = post[j] * block_out + ÎĢ_i comb[i][j] * residual[i]`.
217///
218/// `residual` is the pre-`hc_pre` stream snapshot, `block_out` this site's sublayer output.
219/// `out` may alias `streams` â each output stream is a fresh combination, so writing back over
220/// the highway is the intended flow.
221#[allow(clippy::too_many_arguments)]
222pub fn glm_hc_post(
223 gpu: &dyn GpuBackend,
224 kernel: KernelHandle,
225 block_out: DevicePtr,
226 residual: DevicePtr,
227 post: DevicePtr,
228 comb: DevicePtr,
229 out: DevicePtr,
230 num_tokens: u32,
231 hidden_size: u32,
232 hc_mult: u32,
233 stream: u64,
234) -> Result<()> {
235 KernelLaunch::new(gpu, kernel)
236 .grid([num_tokens, collapse_blocks(hidden_size), 1])
237 .block([256, 1, 1])
238 .arg_ptr(block_out)
239 .arg_ptr(residual)
240 .arg_ptr(post)
241 .arg_ptr(comb)
242 .arg_ptr(out)
243 .arg_u32(hidden_size)
244 .arg_u32(hc_mult)
245 .launch(stream)
246}
247
248/// Blocks to spread an `H`-wide, per-element-independent pass over â `hc_finish`'s collapse and
249/// all of `hc_post`.
250///
251/// ðī Both used to run on grid `(T, 1, 1)`: one block, i.e. ONE of the GB10's 48 SMs, moving
252/// `hc_mult * H` floats, 90 times per token each. Nothing in either is a reduction â every `d`
253/// is an independent output element â so the block count is free parallelism and the result is
254/// bit-identical at any value of it. 256 is the block width both kernels launch at.
255const fn collapse_blocks(hidden_size: u32) -> u32 {
256 // `max(1)` by hand: `Ord::max` is not const yet.
257 if hidden_size < 256 {
258 1
259 } else {
260 hidden_size.div_ceil(256)
261 }
262}
263
264/// `mix_hc` â the row count of `hc_fn` and `hc_base`: `(2 + hc_mult) * hc_mult`.
265///
266/// `pre` and `post` contribute one row per stream each, `comb` contributes `hc_mult` rows per
267/// stream. Sizing either tensor with a different formula still yields a well-formed 2-D weight.
268pub fn mix_hc(hc_mult: usize) -> usize {
269 (2 + hc_mult) * hc_mult
270}
271
272#[cfg(test)]
273mod mhc_shape_tests {
274 use super::*;
275
276 /// The `[pre | post | comb]` split of `hc_fn`'s rows. Getting `mix_hc` wrong shifts every
277 /// coefficient the kernel reads, with no shape error.
278 #[test]
279 fn mix_hc_splits_into_pre_post_and_comb() {
280 for hc in 1usize..=8 {
281 assert_eq!(mix_hc(hc), hc + hc + hc * hc, "pre + post + comb rows");
282 }
283 // GLM-5.3 carries hc_mult = 2.
284 assert_eq!(mix_hc(2), 8);
285 }
286
287 /// The kernel caps `hc_mult` at 4 via `GLM_HC_MAX_MIX = 24 = (2 + 4) * 4`. A larger
288 /// multiplicity would overrun its fixed-size register arrays.
289 #[test]
290 fn the_kernels_mix_bound_is_hc_mult_four() {
291 assert_eq!(mix_hc(4), 24, "GLM_HC_MAX_MIX in glm5next_mhc.cu");
292 assert!(mix_hc(5) > 24, "hc_mult 5 would exceed the kernel's bound");
293 }
294}