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}