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;