metrale_model_arch/glm5next_layer/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: `Glm5NextLayer`, the GLM-5.3 decoder layer behind `TransformerLayer`: a KDA or
4//! DSA mixer, a dense or routed-MoE MLP, and the mHC hyper-connection.
5//!
6//! Owner: model-arch (GLM-5.3).
7//! Invariants:
8//! - Every norm launches `rms_norm_vanilla` (`x * w / rms`), never `rms_norm`, which scales by
9//!   `1 + w`.
10//! - Highway slots are per token: `decode` uses slot 0, `prefill` gives prompt token `t` slot
11//!   `t`, and `decode_batched` gives verify row `t` slot `t`.
12//! - The last text layer collapses the highway with `hc_head_mean`, which takes no weights.
13//! - Any all-reduce of a mixer or MLP output happens before `hc_post` folds that output into
14//!   the highway.
15//!
16//! One mHC site, as `forward_one` and `forward_k` run it:
17//!
18//! ```text
19//! layer 0 only:  hc_expand(hidden) -> streams            [hc_mult, hidden] FP32
20//! each site:     hc_pre(streams) -> y (into hidden), post, comb
21//!                rms_norm_vanilla(y, site norm) -> normed
22//!                sublayer(normed) -> out
23//!                hc_post(out, residual = streams, post, comb) -> streams
24//! last layer:    hc_head_mean(streams) -> hidden
25//! ```
26//!
27//! `hc_pre` only reads `streams`, so `hc_post` takes the same buffer as its residual and writes
28//! its result over it.
29
30use std::sync::Arc;
31
32use anyhow::{Context, Result, bail};
33use metrale_cache::kv_cache::PagedKvCache;
34use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
35use metrale_gpu_runtime::kernel_args::KernelLaunch;
36
37use crate::glm5next_dsa::layer::Glm5NextDsaLayer;
38use crate::glm5next_dsa::state::Glm5NextDsaState;
39use crate::glm5next_kda::{Glm5NextKdaConfig, Glm5NextKdaLayer, Glm5NextKdaWorkspace, KdaSeqState};
40use crate::glm5next_mlp::forward::{Glm5NextMlpWorkspace, forward_dense, forward_moe};
41use crate::glm5next_mlp::weights::{Glm5NextDenseMlpWeights, Glm5NextMoeWeights};
42use crate::glm5next_mlp::{Glm5NextMlpConfig, Glm5NextMlpKernels};
43use metrale_model_layers::layer::{ForwardContext, LayerState, SsmLayerState, TransformerLayer};
44use metrale_model_layers::layer::{
45    LayerAuxState, LayerCapabilities, LayerGraphHooks, LayerSplitPrefill, LayerWeightSetup,
46    LayerWriteOnAccept,
47};
48// 2026-09-25: GLM's own mHC launchers, not DeepSeek-V4's `ops::hc_pre`/`ops::hc_post`.
49use crate::glm5next_mhc::{
50    Glm5NextMhcKernels, Glm5NextMhcSiteWeights, glm_hc_expand, glm_hc_post, glm_hc_pre,
51    hc_head_mean,
52};
53
54pub mod state;
55
56pub mod profile;
57pub use state::alloc_kda_ssm_state;
58
59mod levers;
60mod steps;
61mod types;
62pub use levers::prefill_rows;
63pub(crate) use levers::{PREFILL_ROWS, cublas_wide_proj, dsa_batch_qidx};
64pub use types::{Glm5NextLayer, Glm5NextMhc, Glm5NextMixer, Glm5NextMlpSite};
65
66impl TransformerLayer for Glm5NextLayer {
67    fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn LayerState>> {
68        Ok(match &self.mixer {
69            // 2026-09-25: On the model path a KDA layer gets SSM pool addresses instead
70            // (`uses_ssm_pool`); this builds a zeroed, pool-free state for any other caller.
71            Glm5NextMixer::Kda { cfg, .. } => Box::new(alloc_kda_ssm_state(gpu, cfg)?),
72            Glm5NextMixer::Dsa(l) => Box::new(Glm5NextDsaState::alloc(gpu, &l.cfg)?),
73        })
74    }
75
76    /// 2026-09-25: Frees a `Glm5NextDsaState`'s device buffers; the indexer cache is sized for
77    /// the configured maximum context (`max_dsa_context`), not the prompt. Any other state is
78    /// left alone, whatever the mixer: on the model path a KDA layer's `SsmLayerState` holds SSM
79    /// pool addresses, which this layer does not own.
80    fn release_state(&self, state: &mut dyn LayerState, gpu: &dyn GpuBackend) -> Result<()> {
81        if let Some(dsa) = state.as_any_mut().downcast_mut::<Glm5NextDsaState>() {
82            dsa.free(gpu)?;
83        }
84        Ok(())
85    }
86
87    #[allow(clippy::too_many_arguments)]
88    fn decode(
89        &self,
90        hidden: DevicePtr,
91        residual: DevicePtr,
92        state: &mut dyn LayerState,
93        kv_cache: &mut PagedKvCache,
94        seq_len: usize,
95        block_table: &mut Vec<u32>,
96        disk_block_ids: &mut Vec<u32>,
97        disk_last_offloaded_per_layer: &mut Vec<u32>,
98        ctx: &ForwardContext,
99        stream: u64,
100    ) -> Result<()> {
101        self.forward_one(
102            hidden,
103            residual,
104            0,
105            state,
106            kv_cache,
107            seq_len,
108            block_table,
109            disk_block_ids,
110            disk_last_offloaded_per_layer,
111            ctx,
112            stream,
113        )
114    }
115
116    /// 2026-09-25: Prefill with the highway indexed by token: prompt token `t` uses highway slot
117    /// `t`, so every token's streams are still there when the next layer reads them. The trait's
118    /// default runs each token through `decode`, which uses slot 0 for all of them.
119    ///
120    /// Errors when `num_tokens` exceeds `ctx.buffers.max_batch_tokens()`, the number of highway
121    /// slots in the buffer arena.
122    #[allow(clippy::too_many_arguments)]
123    fn prefill(
124        &self,
125        hidden: DevicePtr,
126        residual: DevicePtr,
127        num_tokens: usize,
128        state: &mut dyn LayerState,
129        kv_cache: &mut PagedKvCache,
130        seq_len_start: usize,
131        block_table: &mut Vec<u32>,
132        disk_block_ids: &mut Vec<u32>,
133        disk_last_offloaded_per_layer: &mut Vec<u32>,
134        _kv_write_start: usize,
135        ctx: &ForwardContext,
136        stream: u64,
137    ) -> Result<()> {
138        let cap = ctx.buffers.max_batch_tokens();
139        if num_tokens > cap {
140            bail!(
141                "GLM layer {}: prefill of {num_tokens} tokens exceeds the {cap}-token mHC \
142                 highway the buffer arena was sized for; each token needs its own slot",
143                self.layer_idx
144            );
145        }
146        // 2026-09-25: A text layer runs the prompt in sub-chunks of `prefill_rows()` tokens
147        // through `forward_k`. The MTP block (`mhc: None`) walks token by token, because
148        // `forward_k` refuses a layer without a highway.
149        let rows = if self.mhc.is_some() {
150            prefill_rows().min(cap)
151        } else {
152            1
153        };
154        if rows > 1 {
155            let mut t = 0usize;
156            while t < num_tokens {
157                let k = rows.min(num_tokens - t);
158                self.forward_k(
159                    hidden.offset(t * self.hidden * 2),
160                    k,
161                    state,
162                    kv_cache,
163                    seq_len_start + t,
164                    block_table,
165                    ctx,
166                    stream,
167                    false,
168                    t,
169                    // 2026-09-25: `is_prefill` is true only for this caller.
170                    // This IS the prefill sub-chunk caller.
171                    true,
172                )?;
173                t += k;
174            }
175            return Ok(());
176        }
177        for t in 0..num_tokens {
178            let off = t * self.hidden * 2;
179            self.forward_one(
180                hidden.offset(off),
181                residual.offset(off),
182                t,
183                state,
184                kv_cache,
185                seq_len_start + t,
186                block_table,
187                disk_block_ids,
188                disk_last_offloaded_per_layer,
189                ctx,
190                stream,
191            )?;
192        }
193        Ok(())
194    }
195
196    /// 2026-09-25: K tokens of one sequence in one call, the speculative-verify body:
197    /// `forward_k` over highway slots `0..K`, taking KDA state snapshots. The trait's default
198    /// calls `decode` per token, and `decode` uses highway slot 0 for every token.
199    #[allow(clippy::too_many_arguments)]
200    fn decode_batched(
201        &self,
202        hidden: DevicePtr,
203        _residual: DevicePtr,
204        num_tokens: usize,
205        state: &mut dyn LayerState,
206        kv_cache: &mut PagedKvCache,
207        seq_len: usize,
208        block_table: &mut Vec<u32>,
209        _disk_block_ids: &mut Vec<u32>,
210        _disk_last_offloaded_per_layer: &mut Vec<u32>,
211        ctx: &ForwardContext,
212        stream: u64,
213    ) -> Result<()> {
214        // 2026-09-25: For a KDA layer, row `t < K - 1` writes intermediate `t`, and
215        // `rollback_ssm_states_dispatch` restores intermediate `num_accepted - 1`.
216        let kda_bytes = matches!(self.mixer, Glm5NextMixer::Kda { .. }).then_some(());
217        if kda_bytes.is_some() && num_tokens > 1 {
218            let st = self.kda_state(state)?;
219            // 2026-09-25: Error rather than run without the intermediates: a rejected draft
220            // could then not be rewound.
221            if st.h_state_intermediates.len() + 1 < num_tokens
222                || st.conv_state_intermediates.len() + 1 < num_tokens
223            {
224                bail!(
225                    "GLM layer {}: a {num_tokens}-token verify needs {} per-token state \
226                     snapshots but the pool has h={} conv={}. With none, this is the \
227                     self-speculative / ngram path on a model whose MTP pool was never \
228                     sized; with too few, --num-drafts exceeds the pool's tier.",
229                    self.layer_idx,
230                    num_tokens - 1,
231                    st.h_state_intermediates.len(),
232                    st.conv_state_intermediates.len(),
233                );
234            }
235        }
236
237        self.forward_k(
238            hidden,
239            num_tokens,
240            state,
241            kv_cache,
242            seq_len,
243            block_table,
244            ctx,
245            stream,
246            true,
247            0,
248            // 2026-09-25: `is_prefill` is false here.
249            // A speculative verify, NOT a prefill sub-chunk: true here would give an eager
250            // verify the prefill-only batched DSA selector (`batch_select_enabled`).
251            false,
252        )
253    }
254}
255
256impl LayerCapabilities for Glm5NextLayer {
257    /// 2026-09-25: True. `decode` runs every sequence through highway slot 0, and the trait's
258    /// default `decode_multi_seq` calls `decode` once per sequence with one shared
259    /// `ForwardContext`, so every sequence would read and write the same highway slot. The DSA
260    /// mixer's single-row `decode` also reads `attn_metadata` row 0 on a decode step.
261    fn decode_multi_seq_unsupported(&self) -> bool {
262        true
263    }
264
265    /// 2026-09-25: True. This layer has no `decode_verify_multi`, and the trait's default
266    /// returns an error; answering true makes `can_batch_verify_dispatch` refuse the batched
267    /// verify.
268    fn decode_verify_multi_unsupported(&self) -> bool {
269        true
270    }
271
272    /// 2026-09-25: True. A DSA layer's per-sequence state comes from `gpu.alloc` in
273    /// `alloc_state`, so the addresses a captured graph holds belong to one sequence.
274    fn graph_stale_on_new_sequence(&self) -> bool {
275        true
276    }
277
278    /// 2026-09-25: True for a KDA layer. Its recurrent and conv state then come from the SSM
279    /// pool, and with MTP on, so do the checkpoint and per-token intermediate pointers that a
280    /// speculative rollback restores (`meta.rs`).
281    fn uses_ssm_pool(&self) -> bool {
282        matches!(self.mixer, Glm5NextMixer::Kda { .. })
283    }
284
285    /// 2026-09-25: True for a KDA layer, whose mixer carries recurrent state.
286    fn is_ssm_layer(&self) -> bool {
287        matches!(self.mixer, Glm5NextMixer::Kda { .. })
288    }
289}
290
291impl LayerWeightSetup for Glm5NextLayer {}
292impl LayerWriteOnAccept for Glm5NextLayer {}
293
294impl LayerGraphHooks for Glm5NextLayer {
295    /// 2026-09-25: A graph replay runs only kernels, so the host-side length of the DSA indexer
296    /// cache is reconciled here (`Glm5NextDsaState::sync_to`). The inner `Glm5NextDsaLayer`
297    /// implements this too, but the model's layer list holds this composite, so this impl is the
298    /// one called. KDA keeps no host-side state.
299    fn sync_replayed_step(
300        &self,
301        state: &mut dyn LayerState,
302        seq_len: usize,
303        k: usize,
304    ) -> Result<()> {
305        match &self.mixer {
306            Glm5NextMixer::Dsa(_) => self.dsa_state(state)?.sync_to(seq_len, k),
307            Glm5NextMixer::Kda { .. } => Ok(()),
308        }
309    }
310
311    /// 2026-09-25: Checks before a graph replay that the DSA indexer cache can hold `seq_len + k`
312    /// rows. As with `sync_replayed_step`, this composite impl is the one the model calls.
313    fn check_replay_room(&self, state: &dyn LayerState, seq_len: usize, k: usize) -> Result<()> {
314        match &self.mixer {
315            Glm5NextMixer::Dsa(_) => state
316                .as_any()
317                .downcast_ref::<Glm5NextDsaState>()
318                .ok_or_else(|| {
319                    anyhow::anyhow!(
320                        "GLM layer {}: a DSA mixer was handed state that is not a \
321                         Glm5NextDsaState",
322                        self.layer_idx
323                    )
324                })?
325                .ensure_room_through(seq_len + k)
326                // 2026-09-25: `ensure_room_through` raises the same error for every caller;
327                // the context names this route.
328                .with_context(|| {
329                    format!(
330                        "DSA replay pre-check (layer {}, before launch_graph, seq_len \
331                         {seq_len} + k {k})",
332                        self.layer_idx
333                    )
334                }),
335            Glm5NextMixer::Kda { .. } => Ok(()),
336        }
337    }
338}
339
340impl LayerAuxState for Glm5NextLayer {
341    /// 2026-09-25: True for a DSA layer: its indexer cache is the state `snapshot_aux` and
342    /// `restore_aux` carry. A KDA layer's state is in the SSM pool (`uses_ssm_pool`) and is not
343    /// carried here.
344    fn has_aux_state(&self) -> bool {
345        matches!(self.mixer, Glm5NextMixer::Dsa(_))
346    }
347
348    fn snapshot_aux(
349        &self,
350        state: &dyn LayerState,
351        gpu: &dyn GpuBackend,
352        stream: u64,
353    ) -> Result<Option<Vec<u8>>> {
354        if !matches!(self.mixer, Glm5NextMixer::Dsa(_)) {
355            return Ok(None);
356        }
357        let st = state
358            .as_any()
359            .downcast_ref::<Glm5NextDsaState>()
360            .ok_or_else(|| {
361                anyhow::anyhow!(
362                    "GLM layer {}: a DSA mixer was handed state that is not a Glm5NextDsaState",
363                    self.layer_idx
364                )
365            })?;
366        Ok(Some(st.snapshot_blob(gpu, stream)?))
367    }
368
369    /// 2026-09-25: Errors on a KDA layer, whose state travels with the SSM snapshot. On a DSA
370    /// layer it restores the blob through `Glm5NextDsaState::restore_blob`; `apply_aux_states`
371    /// propagates any error.
372    fn restore_aux(
373        &self,
374        state: &mut dyn LayerState,
375        blob: &[u8],
376        gpu: &dyn GpuBackend,
377        stream: u64,
378    ) -> Result<()> {
379        if !matches!(self.mixer, Glm5NextMixer::Dsa(_)) {
380            bail!(
381                "GLM layer {}: restore_aux on a KDA layer — KDA state is pool-backed and \
382                 travels with the SSM snapshot, so a blob addressed here is a routing bug",
383                self.layer_idx
384            );
385        }
386        self.dsa_state(state)?.restore_blob(blob, gpu, stream)
387    }
388}
389
390impl LayerSplitPrefill for Glm5NextLayer {}
391impl metrale_model_layers::circuit_exec::CircuitBindings for Glm5NextLayer {}
392
393#[cfg(test)]
394mod tests;