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 /// ðŠĪ Sized by the FUNCTION, not by [`MHC_MIX_MAX_TOKENS`] â the const is only its floor.
137 pub mix: DevicePtr,
138}
139
140/// FLOOR of the `mix` scratch's token bound â the value the stack shipped with, and the value
141/// it still uses at every prefill sub-chunk of 256 rows or fewer.
142pub const MHC_MIX_MAX_TOKENS: usize = 256;
143
144/// Token bound the `mix` scratch is actually allocated at AND checked against.
145///
146/// ðī MEASURED 2026-09-22, WS1/P2: this was a bare `256` const, and it is a **hard ceiling on
147/// `AVAROK_GLM_PREFILL_ROWS`** that has nothing to do with memory. At 512 rows the serve boots
148/// fine and then every prefill dies at layer 0 with
149/// *"glm_hc_pre: 512 tokens exceeds the 256-token `mix` scratch"* â HTTP 200, empty completion,
150/// `runs/ws1-p2-prefill-n1n2/out/shared-r512/`. P1's "rows=1024 is not memory-feasible" never
151/// saw this because the LOAD died first; memory was the second blocker, not the first.
152///
153/// The scratch is `[tokens, mix_hc(hc_mult)] FP32` **per site**: at GLM-5.3's `hc_mult = 4`
154/// that is `tokens * 96 B` per site, 90 sites â **2.2 MB for the whole model at 256, 8.8 MB at
155/// 1024**. The ceiling was never buying anything; it was slack that hardened into a limit.
156///
157/// ðŠĪ The allocation in `glm5_next_load` and the refusal in [`glm_hc_pre`] MUST read the same
158/// number, which is why this is one `OnceLock` function and not two `max()` expressions. Two
159/// expressions that agree today are a buffer overrun the day one of them is edited.
160pub fn mhc_mix_max_tokens() -> usize {
161 static T: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
162 *T.get_or_init(|| {
163 let t = crate::layers::glm5next_layer::prefill_rows().max(MHC_MIX_MAX_TOKENS);
164 if t != MHC_MIX_MAX_TOKENS {
165 tracing::warn!(
166 "GLM mHC `mix` scratch widened to {t} tokens (floor {MHC_MIX_MAX_TOKENS}) to \
167 match AVAROK_GLM_PREFILL_ROWS"
168 );
169 }
170 t
171 })
172}
173
174/// `hc_pre`: collapse the `hc_mult` FP32 streams to one BF16 sequence and emit this site's
175/// `post` / `comb` mixing coefficients.
176///
177/// ðŠĪ Named `glm_hc_pre`, not `hc_pre`: `ops` already exports DeepSeek-V4's `hc_pre`/`hc_post`
178/// through a glob, and the two are NOT interchangeable â V4's reads `hc_head.{fn,base,scale}`
179/// and uses a different mixing law. The rename is the compiler-enforced version of this
180/// module's "never silently fall back onto the DeepSeek variant" rule; a glob collision here
181/// resolved the wrong way would be a silent architecture swap.
182///
183/// The residual that `glm_hc_post` mixes is the stream tensor **as it entered here** â snapshot it
184/// before calling, which is the skeleton's `ResidualStep::SaveResidual`. Overwriting the streams
185/// in place before `hc_post` runs silently changes what the residual means.
186#[allow(clippy::too_many_arguments)]
187pub fn glm_hc_pre(
188 gpu: &dyn GpuBackend,
189 kernels: &Glm5NextMhcKernels,
190 streams: DevicePtr,
191 w: &Glm5NextMhcSiteWeights,
192 y_out: DevicePtr,
193 post_out: DevicePtr,
194 comb_out: DevicePtr,
195 num_tokens: u32,
196 hidden_size: u32,
197 hc_mult: u32,
198 sinkhorn_iters: u32,
199 norm_eps: f32,
200 hc_eps: f32,
201 stream: u64,
202) -> Result<()> {
203 let mix_hc = (2 + hc_mult) * hc_mult;
204 let mix_cap = mhc_mix_max_tokens();
205 if num_tokens as usize > mix_cap {
206 anyhow::bail!(
207 "glm_hc_pre: {num_tokens} tokens exceeds the {mix_cap}-token `mix` scratch \
208 (floor {MHC_MIX_MAX_TOKENS}, widened to AVAROK_GLM_PREFILL_ROWS). Do not launch \
209 past the allocation."
210 );
211 }
212 // ðŠĪ The two kernels take the SAME arguments; only `hc_fn`'s element width differs, and it
213 // is the pointer's own dtype, not something the signature can catch. Pairing the wrong
214 // flag with the pointer reads BF16 as F32 (or the reverse) and produces plausible garbage.
215 let mix_kernel = if w.hc_fn_bf16 && kernels.hc_mix_bf16.0 != 0 {
216 kernels.hc_mix_bf16
217 } else {
218 kernels.hc_mix
219 };
220 KernelLaunch::new(gpu, mix_kernel)
221 .grid([num_tokens, mix_hc, 1])
222 .block([256, 1, 1])
223 .arg_ptr(streams)
224 .arg_ptr(w.hc_fn)
225 .arg_ptr(w.mix)
226 .arg_u32(hidden_size)
227 .arg_u32(hc_mult)
228 .arg_f32(norm_eps)
229 .launch(stream)?;
230 KernelLaunch::new(gpu, kernels.hc_finish)
231 // `1 +` â `blockIdx.y == 0` runs the Sinkhorn and does NOT take a share of the collapse.
232 .grid([num_tokens, 1 + collapse_blocks(hidden_size), 1])
233 .block([256, 1, 1])
234 .arg_ptr(streams)
235 .arg_ptr(w.mix)
236 .arg_ptr(w.hc_scale)
237 .arg_ptr(w.hc_base)
238 .arg_ptr(y_out)
239 .arg_ptr(post_out)
240 .arg_ptr(comb_out)
241 .arg_u32(hidden_size)
242 .arg_u32(hc_mult)
243 .arg_u32(sinkhorn_iters)
244 .arg_f32(hc_eps)
245 .launch(stream)
246}
247
248/// `glm_hc_post`: `out[j] = post[j] * block_out + ÎĢ_i comb[i][j] * residual[i]`.
249///
250/// `residual` is the pre-`hc_pre` stream snapshot, `block_out` this site's sublayer output.
251/// `out` may alias `streams` â each output stream is a fresh combination, so writing back over
252/// the highway is the intended flow.
253#[allow(clippy::too_many_arguments)]
254pub fn glm_hc_post(
255 gpu: &dyn GpuBackend,
256 kernel: KernelHandle,
257 block_out: DevicePtr,
258 residual: DevicePtr,
259 post: DevicePtr,
260 comb: DevicePtr,
261 out: DevicePtr,
262 num_tokens: u32,
263 hidden_size: u32,
264 hc_mult: u32,
265 stream: u64,
266) -> Result<()> {
267 KernelLaunch::new(gpu, kernel)
268 .grid([num_tokens, collapse_blocks(hidden_size), 1])
269 .block([256, 1, 1])
270 .arg_ptr(block_out)
271 .arg_ptr(residual)
272 .arg_ptr(post)
273 .arg_ptr(comb)
274 .arg_ptr(out)
275 .arg_u32(hidden_size)
276 .arg_u32(hc_mult)
277 .launch(stream)
278}
279
280/// Blocks to spread an `H`-wide, per-element-independent pass over â `hc_finish`'s collapse and
281/// all of `hc_post`.
282///
283/// ðī Both used to run on grid `(T, 1, 1)`: one block, i.e. ONE of the GB10's 48 SMs, moving
284/// `hc_mult * H` floats, 90 times per token each. Nothing in either is a reduction â every `d`
285/// is an independent output element â so the block count is free parallelism and the result is
286/// bit-identical at any value of it. 256 is the block width both kernels launch at.
287const fn collapse_blocks(hidden_size: u32) -> u32 {
288 // `max(1)` by hand: `Ord::max` is not const yet.
289 if hidden_size < 256 {
290 1
291 } else {
292 hidden_size.div_ceil(256)
293 }
294}
295
296/// `mix_hc` â the row count of `hc_fn` and `hc_base`: `(2 + hc_mult) * hc_mult`.
297///
298/// `pre` and `post` contribute one row per stream each, `comb` contributes `hc_mult` rows per
299/// stream. Sizing either tensor with a different formula still yields a well-formed 2-D weight.
300pub fn mix_hc(hc_mult: usize) -> usize {
301 (2 + hc_mult) * hc_mult
302}
303
304#[cfg(test)]
305mod mhc_shape_tests {
306 use super::*;
307
308 /// The `[pre | post | comb]` split of `hc_fn`'s rows. Getting `mix_hc` wrong shifts every
309 /// coefficient the kernel reads, with no shape error.
310 #[test]
311 fn mix_hc_splits_into_pre_post_and_comb() {
312 for hc in 1usize..=8 {
313 assert_eq!(mix_hc(hc), hc + hc + hc * hc, "pre + post + comb rows");
314 }
315 // GLM-5.3 carries hc_mult = 2.
316 assert_eq!(mix_hc(2), 8);
317 }
318
319 /// The kernel caps `hc_mult` at 4 via `GLM_HC_MAX_MIX = 24 = (2 + 4) * 4`. A larger
320 /// multiplicity would overrun its fixed-size register arrays.
321 #[test]
322 fn the_kernels_mix_bound_is_hc_mult_four() {
323 assert_eq!(mix_hc(4), 24, "GLM_HC_MAX_MIX in glm5next_mhc.cu");
324 assert!(mix_hc(5) > 24, "hc_mult 5 would exceed the kernel's bound");
325 }
326
327 /// The `mix` bound must never drop BELOW the shipped floor, whatever the sub-chunk is:
328 /// `glm_hc_pre` checks against this number and `glm5_next_load` allocates from it, and a
329 /// bound under the floor would turn a refusal into an overrun. With no
330 /// `AVAROK_GLM_PREFILL_ROWS` in the environment â the state of a test process â the
331 /// default sub-chunk is 16, so the answer must still be the 256-token floor.
332 #[test]
333 fn the_mix_bound_never_goes_below_the_shipped_floor() {
334 assert_eq!(mhc_mix_max_tokens(), MHC_MIX_MAX_TOKENS);
335 assert!(mhc_mix_max_tokens() >= MHC_MIX_MAX_TOKENS);
336 }
337
338 /// The scratch's real cost, so "raise the ceiling" is never argued from a guess. Per SITE:
339 /// `tokens * mix_hc(hc_mult) * 4 B`. GLM-5.3 is `hc_mult = 4`, two sites per layer, 45
340 /// layers = 90 sites.
341 #[test]
342 fn the_mix_scratch_costs_megabytes_not_gigabytes() {
343 let total = |tokens: usize| tokens * mix_hc(4) * 4 * 90;
344 assert_eq!(
345 total(256),
346 2_211_840,
347 "2.2 MB for the whole model at the floor"
348 );
349 assert_eq!(total(1024), 8_847_360, "8.8 MB at a 1024-row sub-chunk");
350 }
351}