memra_engine/decode.rs
1//! Incremental decode (T=1) with the dual cache + greedy generation loop. Serves end-to-end.
2//! Reuses the validated kernels; threads KV (full-attn) and conv/SSM state (linear-attn) across steps.
3
4use crate::Engine;
5use crate::cache::{Cache, RecurLayer};
6use crate::forward::argmax;
7use crate::hybrid::{FullAttnLayer, HybridModel, LinearAttnLayer, Mixer};
8use cudarc::driver::CudaSlice;
9use std::collections::HashMap;
10
11/// Persistent CUDA-graph decode state (CUDA-GRAPH-PLAN Phase 3). Holds the device-resident counters
12/// the captured graph reads/writes (`token_d` = current/next token id, `pos_d` = rope position) — both
13/// at FIXED addresses baked into every captured graph — plus the per-`t_kv`-bucket graph cache. The
14/// bucket key is the eager `(fa_vec, n_splits)` pair (see `Engine::fa_bucket_key`): every t_kv that
15/// maps to the same key reproduces eager's split geometry, so one captured graph replays bit-identically
16/// for the whole bucket. A new key triggers a re-capture (n_splits changes ~every 64 tokens).
17pub struct GraphDecodeState {
18 pub token_d: CudaSlice<u32>, // [1] resident next-token id (argmax writes, embed reads)
19 pub pos_d: CudaSlice<i32>, // [1] resident rope position counter
20 pub graphs: HashMap<(bool, usize), cudarc::driver::CudaGraph>,
21 pub bucket_max: HashMap<(bool, usize), usize>, // bucket key -> bucket_max fed to the capture
22 pub captures: usize, // count of (re)captures, for reporting
23}
24
25/// Long-lived step-wise CUDA-graph decode session (see HybridModel::graph_session_new).
26/// One replay per step(); the only steady-state D2H is the 4-byte next-token read.
27pub struct GraphSession {
28 pub gs: GraphDecodeState,
29 pub cache: Cache,
30 /// LOAD-BEARING hold: the captured graph's embed-gather node references this
31 /// allocation — dropping it would free memory the graph still reads.
32 #[allow(dead_code)]
33 embd_gpu: CudaSlice<u8>,
34 graph: cudarc::driver::CudaGraph,
35 plan: Vec<crate::graph_update::FaMain>,
36 /// session budget: last valid t_kv (pos + max_new + 1 at creation).
37 pub bucket_max: usize,
38 /// current capture's kernel-class segment end — step() recaptures past it
39 /// (round 45: exec-update retunes splits, it cannot swap kernels; see
40 /// graph_decode_loop's SEGMENTS note).
41 seg_end: usize,
42 qt: i32,
43 row_bytes: usize,
44 n_vocab: usize,
45 /// GRAMMAR MASK (constrained decoding, 2026-08-03): packed llguidance bitset the
46 /// captured graph reads (mask_logits_f32 between lm_head and the in-graph argmax).
47 /// STABLE POINTER — baked at capture, carried across recaptures; the caller uploads
48 /// fresh contents (upload_mask) before every step. None = no mask node captured.
49 mask_dev: Option<CudaSlice<u32>>,
50 mask_words: usize,
51}
52
53impl GraphSession {
54 /// One graph-replay decode step. Returns the next token (already fed back into the
55 /// resident token_d — the following step consumes it). Errors past bucket_max
56 /// (the caller sized max_new at capture). Transparently recaptures when the eager
57 /// kernel class changes (fa_vec floor / v4 max / fa512 floor crossings).
58 pub fn step(
59 &mut self,
60 e: &Engine,
61 m: &crate::hybrid::HybridModel,
62 ) -> Result<u32, Box<dyn std::error::Error>> {
63 if self.cache.pos + 1 >= self.bucket_max {
64 return Err("GraphSession: past bucket_max (generation budget exceeded)".into());
65 }
66 if self.cache.pos + 1 > self.seg_end {
67 m.graph_session_recapture(e, self)?;
68 }
69 crate::graph_update::fa_apply(
70 &self.graph,
71 &mut self.plan,
72 self.cache.pos + 1,
73 crate::fa_split_keys,
74 )?;
75 self.graph.launch()?;
76 self.cache.pos += 1;
77 for kvl in self.cache.kv.iter_mut().filter_map(|k| k.as_mut()) {
78 kvl.len += 1;
79 }
80 e.dtoh_u32_one(&self.gs.token_d)
81 }
82
83 /// GRAMMAR MASK upload (constrained graph sessions): fresh packed-bitset contents into
84 /// the STABLE buffer the captured graph reads — call before every step(). The word
85 /// count is a capture-time kernel arg (constant per model: the tokenizer vocab is
86 /// fixed), so the length must match the capture exactly.
87 pub fn upload_mask(
88 &mut self,
89 e: &Engine,
90 words: &[u32],
91 ) -> Result<(), Box<dyn std::error::Error>> {
92 let Some(d) = self.mask_dev.as_mut() else {
93 return Err("upload_mask: session captured without a mask node".into());
94 };
95 if words.len() != self.mask_words {
96 return Err(format!(
97 "upload_mask: {} words != captured {}",
98 words.len(),
99 self.mask_words
100 )
101 .into());
102 }
103 e.htod_u32_into(d, words)
104 }
105
106 /// Profiling decomposition of step() (graph-session-gate MEMRA_GS_PROF): the three
107 /// phases exposed separately. prof_launch is ASYNC (no sync) — prof_read carries the
108 /// sync+D2H. Advances the session exactly like step().
109 pub fn prof_apply(&mut self, _e: &Engine) -> Result<(), Box<dyn std::error::Error>> {
110 crate::graph_update::fa_apply(
111 &self.graph,
112 &mut self.plan,
113 self.cache.pos + 1,
114 crate::fa_split_keys,
115 )
116 }
117 pub fn prof_launch(&mut self) -> Result<(), Box<dyn std::error::Error>> {
118 self.graph.launch()?;
119 self.cache.pos += 1;
120 for kvl in self.cache.kv.iter_mut().filter_map(|k| k.as_mut()) {
121 kvl.len += 1;
122 }
123 Ok(())
124 }
125 pub fn prof_read(&mut self, e: &Engine) -> Result<u32, Box<dyn std::error::Error>> {
126 e.dtoh_u32_one(&self.gs.token_d)
127 }
128}
129
130impl GraphDecodeState {
131 pub fn new(e: &Engine) -> Result<Self, Box<dyn std::error::Error>> {
132 Ok(GraphDecodeState {
133 token_d: e.stream().clone_htod(&[0u32])?,
134 pos_d: e.htod_i32(&[0])?,
135 graphs: HashMap::new(),
136 bucket_max: HashMap::new(),
137 captures: 0,
138 })
139 }
140}
141
142/// Generation parameters for the reusable serving API (`generate_with`).
143#[derive(Clone, Debug)]
144pub struct GenParams {
145 pub max_new: usize, // hard cap on generated tokens
146 pub max_ctx: Option<usize>, // context-length guard; None => prompt+max_new+8
147 pub eos: Vec<u32>, // stop on any of these token ids (eos/eog + specials)
148}
149impl Default for GenParams {
150 fn default() -> Self {
151 GenParams {
152 max_new: 128,
153 max_ctx: None,
154 eos: Vec::new(),
155 }
156 }
157}
158
159/// Why generation stopped.
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum StopReason {
162 Eos,
163 MaxNew,
164 ContextFull,
165 Callback,
166}
167
168/// Result of `generate_with`: the generated token ids + why it stopped.
169pub struct GenOutput {
170 pub tokens: Vec<u32>,
171 pub stop_reason: StopReason,
172}
173
174/// Diagnostic-only snapshots of Hy3 layer 0 in the eager T=1 serving path.
175/// Each buffer is one residual-width device row captured before the next stage can reuse it.
176pub struct Hy3Layer0Stages {
177 pub attention_output: CudaSlice<f32>,
178 pub after_attention: CudaSlice<f32>,
179 pub mlp_output: CudaSlice<f32>,
180 pub residual: CudaSlice<f32>,
181}
182
183impl HybridModel {
184 /// Device embed table for the dc fast loops (lazy ~0.5GB upload). On OOM — tight fits
185 /// where resident experts + KV leave no headroom (35B ct-NVFP4 artifact at default
186 /// budget, 2026-07-17) — returns None and the caller stays on the host-embd eager loop
187 /// instead of panicking. Double-init race is benign (identical bytes, loser dropped).
188 pub(crate) fn embd_gpu_try(&self, e: &Engine) -> Option<&cudarc::driver::CudaSlice<u8>> {
189 if let Some(v) = self.embd_gpu.get() {
190 return Some(v);
191 }
192 match e.upload_u8(&self.embd.raw) {
193 Ok(buf) => Some(self.embd_gpu.get_or_init(|| buf)),
194 Err(err) => {
195 eprintln!(
196 "[embd-gpu] upload failed ({err}); dc loop disabled, host-embd eager loop serves"
197 );
198 None
199 }
200 }
201 }
202}
203
204impl HybridModel {
205 /// One decode step for `token` at cache.pos; returns logits [n_vocab] (host f32). Advances cache.
206 pub fn decode_step(
207 &self,
208 e: &Engine,
209 token: u32,
210 cache: &mut Cache,
211 ) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
212 Ok(self.decode_step_h(e, token, cache)?.0)
213 }
214
215 /// Dense-FFN SwiGLU (T=1 decode): `down @ (silu(gate@z) * (up@z))`. Two fused levers stack here:
216 /// - RANK3 LEVER 2: gate+up NVFP4 macro-scales fold into ONE `silu_mul_scaled*` launch (via
217 /// `matmul_pre_noscale`), saving the two separate `scale_inplace` launches.
218 /// - RANK2 LEVER (q8_1 quant-fold): when ffn_down is ALSO on the q8_1 fast path, the SwiGLU
219 /// epilogue EMITS the q8_1 quantization of `act` directly (`silu_mul_scaled_q8_1`) and feeds
220 /// ffn_down via `matmul_pre`, removing ffn_down's standalone `quantize_q8_1` launch (the
221 /// down-proj activation has one consumer, so the quant folds into its producer for free).
222 /// BIT-IDENTICAL to matmul_pre(gate)+matmul_pre(up)+silu_mul+quantize_q8_1+matmul(down): same
223 /// float silu*mul, same amax/127 q8_1 rounding, same dp4a/mmvq dot. Falls back to the f32 `act`
224 /// + plain matmul(down) path whenever any of the three is off the fast path.
225 #[allow(clippy::too_many_arguments)]
226 pub(crate) fn ffn_swiglu_decode(
227 &self,
228 e: &Engine,
229 ffn_gate: &crate::model::GpuTensor,
230 ffn_up: &crate::model::GpuTensor,
231 ffn_down: &crate::model::GpuTensor,
232 z: &CudaSlice<f32>,
233 n_embd: usize,
234 n_ff: usize,
235 lim: Option<f32>,
236 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
237 // M3 dense layers use swigluoai (clamped) — the silu_mul fused fast paths below encode
238 // plain SiLU; route through ffn_act (macro-scales folded via matmul_pre) until clamped
239 // fused twins exist. step35's per-layer `lim` is the same problem, same escape hatch:
240 // silu_mul_scaled / silu_mul_scaled_q8_1 have no clamped twin.
241 if self.cfg.m3.is_some() || lim.is_some() {
242 let (zq, zd) = e.quantize_q8_1(z, 1, n_embd)?;
243 let gate = e.matmul_pre(ffn_gate, &zq, &zd, z, 1)?;
244 let up = e.matmul_pre(ffn_up, &zq, &zd, z, 1)?;
245 let mut act = e.uninit(n_ff)?;
246 Self::ffn_act_lim(e, &self.cfg, &gate, &up, 1.0, 1.0, lim, &mut act, n_ff)?;
247 return Ok(e.matmul(ffn_down, &act, 1)?);
248 }
249 if e.uses_q8_1_fast(ffn_gate) && e.uses_q8_1_fast(ffn_up) {
250 let (zq, zd) = e.quantize_q8_1(z, 1, n_embd)?;
251 // DUAL mm-fusion first (NVFP4 gate+up in ONE launch), else two noscale launches.
252 let pair = match e.matmul_pre_dual_noscale(ffn_gate, ffn_up, &zq, &zd, 1)? {
253 Some((g, u)) => (Some(g), Some(u)),
254 None => (
255 e.matmul_pre_noscale(ffn_gate, &zq, &zd, 1)?,
256 e.matmul_pre_noscale(ffn_up, &zq, &zd, 1)?,
257 ),
258 };
259 match pair {
260 (Some((gate, gs)), Some((up, us))) => {
261 // RANK2 fold: if ffn_down is q8_1-fast, emit act PRE-QUANTIZED and skip the
262 // standalone quantize_q8_1 before ffn_down.
263 if e.uses_q8_1_fast(ffn_down) {
264 let (aq, ad) = e.silu_mul_scaled_q8_1(&gate, &up, gs, us, n_ff)?;
265 return Ok(e.matmul_pre(
266 ffn_down, &aq, &ad, /*x_fallback unused on fast path*/ &gate, 1,
267 )?);
268 }
269 let mut act = e.uninit(n_ff)?;
270 e.silu_mul_scaled(&gate, &up, gs, us, &mut act, n_ff)?;
271 return Ok(e.matmul(ffn_down, &act, 1)?);
272 }
273 _ => {
274 // one (or both) not on the separable-scale fast path: scaled matmul + plain silu_mul.
275 let gate = e.matmul_pre(ffn_gate, &zq, &zd, z, 1)?;
276 let up = e.matmul_pre(ffn_up, &zq, &zd, z, 1)?;
277 let mut act = e.uninit(n_ff)?;
278 Self::ffn_act(e, &self.cfg, &gate, &up, &mut act, n_ff)?;
279 return Ok(e.matmul(ffn_down, &act, 1)?);
280 }
281 }
282 }
283 let gate = e.matmul(ffn_gate, z, 1)?;
284 let up = e.matmul(ffn_up, z, 1)?;
285 let mut act = e.uninit(n_ff)?;
286 Self::ffn_act(e, &self.cfg, &gate, &up, &mut act, n_ff)?;
287 Ok(e.matmul(ffn_down, &act, 1)?)
288 }
289
290 /// Like `ffn_swiglu_decode` but the input is ALREADY q8_1-quantized `(zq, zd)` — used by the
291 /// DECODE NORM-FUSION lever where `add_rms_norm_q8_1` emits the post-attn-normed activation
292 /// pre-quantized (no f32 `z` materialized, no standalone quantize_q8_1 launch). Caller GUARANTEES
293 /// ffn_gate and ffn_up are q8_1-fast (so `matmul_pre_noscale` returns Some at m=1). BIT-IDENTICAL
294 /// to ffn_swiglu_decode(z) when (zq,zd) == quantize_q8_1(z): same matmul_pre_noscale, same
295 /// silu_mul_scaled_q8_1 / silu_mul_scaled, same ffn_down dot.
296 fn ffn_swiglu_decode_pre(
297 &self,
298 e: &Engine,
299 ffn_gate: &crate::model::GpuTensor,
300 ffn_up: &crate::model::GpuTensor,
301 ffn_down: &crate::model::GpuTensor,
302 zq: &CudaSlice<i8>,
303 zd: &CudaSlice<f32>,
304 n_ff: usize,
305 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
306 let pair = match e.matmul_pre_dual_noscale(ffn_gate, ffn_up, zq, zd, 1)? {
307 Some((g, u)) => (Some(g), Some(u)),
308 None => (
309 e.matmul_pre_noscale(ffn_gate, zq, zd, 1)?,
310 e.matmul_pre_noscale(ffn_up, zq, zd, 1)?,
311 ),
312 };
313 match pair {
314 (Some((gate, gs)), Some((up, us))) => {
315 if e.uses_q8_1_fast(ffn_down) {
316 let (aq, ad) = e.silu_mul_scaled_q8_1(&gate, &up, gs, us, n_ff)?;
317 Ok(e.matmul_pre(ffn_down, &aq, &ad, &gate, 1)?)
318 } else {
319 let mut act = e.uninit(n_ff)?;
320 e.silu_mul_scaled(&gate, &up, gs, us, &mut act, n_ff)?;
321 Ok(e.matmul(ffn_down, &act, 1)?)
322 }
323 }
324 // Unreachable when the caller's q8_1-fast guarantee holds (m==1 + fast => Some). Guard
325 // anyway: re-quant from the dequantized pair would need f32; surface a clear error.
326 _ => Err("ffn_swiglu_decode_pre: gate/up not separable-scale at m=1 (caller must guarantee q8_1-fast)".into()),
327 }
328 }
329
330 /// Shared post-attention residual + post-attn-norm + FFN for ONE decode layer, routed by ALL
331 /// decode loops (eager + dc + dc_cap) so they stay bit-identical by construction. DECODE
332 /// NORM-FUSION LEVER: when the layer is Dense AND ffn_gate/ffn_up are q8_1-fast (the daily NVFP4
333 /// case), fuses residual-add + post_attn_norm + q8_1-quantize into ONE `add_rms_norm_q8_1` launch
334 /// and feeds the FFN the pre-quantized activation (skipping its internal quantize_q8_1) — removing
335 /// 1-2 launches + the f32 `z` HBM round-trip per layer. BIT-IDENTICAL to the unfused
336 /// add_rms_norm(or add+rms_norm) + quantize_q8_1 + ffn (all proven bit-identical in kernel_check).
337 /// MEMRA_NO_FUSE_NORMQ forces the unfused f32 path. Returns (x1 residual f32, ffn_out f32).
338 /// True when ALL of a mixer's input projections are on the q8_1 fast path (so the attn-input
339 /// rms_norm can emit q8_1 directly and the mixer skips its internal quantize_q8_1).
340 pub(crate) fn mixer_in_q8_1_fast(&self, e: &Engine, mixer: &Mixer) -> bool {
341 match mixer {
342 Mixer::Full(fa) => {
343 if fa.step_tp_qkv.is_some() {
344 return false;
345 }
346 // step35 also projects its head-wise GATE from the same attn-normed input, so
347 // the fused (h-less) arm requires attn_gate on the q8_1 fast path too — without
348 // this the gate matmul would get a zero-length `h`.
349 let gate_ok = match &fa.attn_gate {
350 Some(g) => e.uses_q8_1_fast(g),
351 None => true,
352 };
353 gate_ok
354 && e.uses_q8_1_fast(&fa.wq)
355 && e.uses_q8_1_fast(&fa.wk)
356 && e.uses_q8_1_fast(&fa.wv)
357 }
358 Mixer::Linear(la) => {
359 e.uses_q8_1_fast(&la.wqkv)
360 && e.uses_q8_1_fast(&la.wqkv_gate)
361 && e.uses_q8_1_fast(&la.ssm_beta)
362 && e.uses_q8_1_fast(&la.ssm_alpha)
363 }
364 // MLA (increment 2, loader-only): predicate only — never claim the fused
365 // norm+quantize chain for an arm that has no forward yet.
366 Mixer::Mla(_) => false,
367 }
368 }
369
370 /// attn_norm + mixer for the EAGER loop, with the attn-input NORM-FUSION. MEMRA_NO_FUSE_NORMQ
371 /// forces the unfused (separate rms_norm + mixer-internal quantize) path.
372 fn attn_in_norm_mixer(
373 &self,
374 e: &Engine,
375 layer: &crate::hybrid::HybridLayer,
376 x: &CudaSlice<f32>,
377 pos_d: &CudaSlice<i32>,
378 pos: usize,
379 cache: &mut Cache,
380 il: usize,
381 n_embd: usize,
382 eps: f32,
383 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
384 let anorm = layer.attn_norm.float_data();
385 let fuse = std::env::var("MEMRA_NO_FUSE_NORMQ").is_err()
386 && self.mixer_in_q8_1_fast(e, &layer.mixer);
387 if fuse {
388 let (hq, hd) = e.rms_norm_q8_1(x, anorm, n_embd, 1, eps)?;
389 // h is unused on the fast path (matmul_pre x_fallback only used at m>=16); pass a zero-len.
390 let h0 = e.zeros(0)?;
391 match &layer.mixer {
392 Mixer::Full(fa) => {
393 self.full_attn_decode_pre(e, fa, &h0, Some((&hq, &hd)), pos_d, pos, cache, il)
394 }
395 Mixer::Linear(la) => {
396 self.linear_attn_decode_pre(e, la, &h0, &hq, &hd, cache, il, false)
397 }
398 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
399 }
400 } else {
401 let mut h = e.uninit(n_embd)?;
402 e.rms_norm(x, anorm, &mut h, n_embd, 1, eps)?;
403 match &layer.mixer {
404 Mixer::Full(fa) => self.full_attn_decode(e, fa, &h, pos_d, pos, cache, il),
405 Mixer::Linear(la) => self.linear_attn_decode(e, la, &h, cache, il),
406 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
407 }
408 }
409 }
410
411 /// attn_norm + mixer for the DEVICE-COUNTER loop (decode_step_dc). Full-attn uses the dc path;
412 /// linear uses the eager-state path (persistent=false), same as decode_step_dc. NORM-FUSED.
413 fn attn_in_norm_mixer_dc(
414 &self,
415 e: &Engine,
416 layer: &crate::hybrid::HybridLayer,
417 x: &CudaSlice<f32>,
418 pos_d: &CudaSlice<i32>,
419 cache: &mut Cache,
420 il: usize,
421 n_embd: usize,
422 eps: f32,
423 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
424 let anorm = layer.attn_norm.float_data();
425 let fuse = std::env::var("MEMRA_NO_FUSE_NORMQ").is_err()
426 && self.mixer_in_q8_1_fast(e, &layer.mixer);
427 if fuse {
428 let (hq, hd) = e.rms_norm_q8_1(x, anorm, n_embd, 1, eps)?;
429 let h0 = e.zeros(0)?;
430 match &layer.mixer {
431 Mixer::Full(fa) => {
432 self.full_attn_decode_dc_pre(e, fa, &h0, &hq, &hd, pos_d, cache, il)
433 }
434 Mixer::Linear(la) => {
435 self.linear_attn_decode_pre(e, la, &h0, &hq, &hd, cache, il, false)
436 }
437 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
438 }
439 } else {
440 let mut h = e.uninit(n_embd)?;
441 e.rms_norm(x, anorm, &mut h, n_embd, 1, eps)?;
442 match &layer.mixer {
443 Mixer::Full(fa) => self.full_attn_decode_dc(e, fa, &h, pos_d, cache, il),
444 Mixer::Linear(la) => self.linear_attn_decode(e, la, &h, cache, il),
445 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
446 }
447 }
448 }
449
450 /// attn_norm + mixer for the CAPTURE loop (decode_step_dc_cap). Full-attn uses the dc_cap path
451 /// (fixed bucket_max); linear uses the persistent-state path. NORM-FUSED; capture-safe (rms_norm_q8_1
452 /// + the *_pre mixers enqueue the same kernels every replay, stable buffers).
453 fn attn_in_norm_mixer_dc_cap(
454 &self,
455 e: &Engine,
456 layer: &crate::hybrid::HybridLayer,
457 x: &CudaSlice<f32>,
458 pos_d: &CudaSlice<i32>,
459 cache: &mut Cache,
460 il: usize,
461 bucket_max: usize,
462 n_embd: usize,
463 eps: f32,
464 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
465 let anorm = layer.attn_norm.float_data();
466 let fuse = std::env::var("MEMRA_NO_FUSE_NORMQ").is_err()
467 && self.mixer_in_q8_1_fast(e, &layer.mixer);
468 if fuse {
469 let (hq, hd) = e.rms_norm_q8_1(x, anorm, n_embd, 1, eps)?;
470 let h0 = e.zeros(0)?;
471 match &layer.mixer {
472 Mixer::Full(fa) => self.full_attn_decode_dc_cap_pre(
473 e, fa, &h0, &hq, &hd, pos_d, cache, il, bucket_max,
474 ),
475 Mixer::Linear(la) => {
476 self.linear_attn_decode_pre(e, la, &h0, &hq, &hd, cache, il, true)
477 }
478 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
479 }
480 } else {
481 let mut h = e.uninit(n_embd)?;
482 e.rms_norm(x, anorm, &mut h, n_embd, 1, eps)?;
483 match &layer.mixer {
484 Mixer::Full(fa) => {
485 self.full_attn_decode_dc_cap(e, fa, &h, pos_d, cache, il, bucket_max)
486 }
487 Mixer::Linear(la) => self.linear_attn_decode_cap(e, la, &h, cache, il),
488 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
489 }
490 }
491 }
492
493 pub(crate) fn residual_norm_ffn(
494 &self,
495 e: &Engine,
496 layer: &crate::hybrid::HybridLayer,
497 x: &CudaSlice<f32>,
498 mixed: &CudaSlice<f32>,
499 n_embd: usize,
500 il: usize,
501 eps: f32,
502 ) -> Result<(CudaSlice<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
503 let pnorm = layer.post_attn_norm.float_data();
504 match &layer.ffn {
505 crate::hybrid::Ffn::Dense {
506 ffn_gate,
507 ffn_up,
508 ffn_down,
509 } => {
510 let n_ff = ffn_gate.out_features();
511 // cfg.m3: the fused-pre chain's silu_mul_scaled* epilogues are plain SiLU —
512 // M3's swigluoai must route through ffn_swiglu_decode's m3 arm (FAST-gate
513 // MISMATCH root cause #2, 2026-07-07: L0 dense FFN clamp skipped under FAST).
514 // step35: SAME failure shape, per LAYER. A dense FFN's limit is the SHEXP array
515 // (upstream's one build_ffn serves dense + shared expert, llama-graph.cpp:1751).
516 let lim = self.cfg.clamp_shexp_at(il as u32);
517 let fuse = std::env::var("MEMRA_NO_FUSE_NORMQ").is_err()
518 && self.cfg.m3.is_none()
519 && lim.is_none()
520 && e.uses_q8_1_fast(ffn_gate)
521 && e.uses_q8_1_fast(ffn_up);
522 if fuse {
523 // M2 safety: this q8 arm predates the deferred join and is never taken
524 // in the step37 config — refuse loudly rather than read unwritten mixed.
525 if crate::tp::take_oproj_tail().is_some() {
526 return Err(
527 "oproj tail handoff reached the q8 residual arm — unwired".into()
528 );
529 }
530 let mut x1 = e.uninit(n_embd)?;
531 let (zq, zd) = e.add_rms_norm_q8_1(x, mixed, pnorm, &mut x1, n_embd, 1, eps)?;
532 let ffn_out =
533 self.ffn_swiglu_decode_pre(e, ffn_gate, ffn_up, ffn_down, &zq, &zd, n_ff)?;
534 Ok((x1, ffn_out))
535 } else {
536 let mut x1 = e.uninit(n_embd)?;
537 let mut z = e.uninit(n_embd)?;
538 if let Some((a0, a1)) = crate::tp::take_oproj_tail() {
539 e.join_add_rms_norm_raw(a0, a1, x, pnorm, &mut x1, &mut z, n_embd, eps)?;
540 } else {
541 e.add_rms_norm(x, mixed, pnorm, &mut x1, &mut z, n_embd, 1, eps)?;
542 }
543 let ffn_out = self
544 .ffn_swiglu_decode(e, ffn_gate, ffn_up, ffn_down, &z, n_embd, n_ff, lim)?;
545 Ok((x1, ffn_out))
546 }
547 }
548 crate::hybrid::Ffn::Moe(m) => {
549 let mut x1 = e.uninit(n_embd)?;
550 let mut z = e.uninit(n_embd)?;
551 // z-quantize fuse (add_rms_norm_zq8) measured NEGATIVE here (158.8 vs 160.6:
552 // the fused warp-per-block quantize pass re-reads z slower than the dedicated
553 // coalesced quantize_q8_1). Kernel + threading kept for graph-capture use where
554 // launch count matters more; eager default = unfused (no gain = no change).
555 // O-PROJ TAIL FUSION M2: when the direct join deferred its add, compose
556 // mixed = a0+a1 in-register inside the norm (verbatim program).
557 if let Some((a0, a1)) = crate::tp::take_oproj_tail() {
558 e.join_add_rms_norm_raw(a0, a1, x, pnorm, &mut x1, &mut z, n_embd, eps)?;
559 } else {
560 e.add_rms_norm(x, mixed, pnorm, &mut x1, &mut z, n_embd, 1, eps)?;
561 }
562 // Feed the zq8 seam (orndecode B2): two consumers now share this quantize —
563 // the dev expert arm (clones at t==1) and the shexp fused2 pair — so the
564 // caller-side launch replaces two arm-side ones. Same kernel, same input,
565 // byte-identical per the (1, Some) clone contract.
566 let zq8 = e.quantize_q8_1(&z, 1, n_embd)?;
567 let ffn_out = self.moe_ffn_il_zq8(e, m, &z, Some(&zq8), 1, il as u16)?;
568 Ok((x1, ffn_out))
569 }
570 }
571 }
572
573 /// EAGLE3 aux-hidden capture (EAGLE-PLAN N1): one decode step that ALSO returns the trunk
574 /// residual-stream `x` taken AFTER each of the blocks in `aux_layers` (the EAGLE3 encoder feeds
575 /// these 3 layer hiddens through `fc`). Returns (logits[n_vocab] host, aux: Vec<[n_embd] dev>),
576 /// one device buffer per requested aux layer, in `aux_layers` order. The captured tensor is the
577 /// residual `x` produced by that block (`x2` at the loop tail), cloned before the next block
578 /// overwrites it — cheap (one clone_dtod of [n_embd] per aux layer). T=1 decode regime.
579 pub fn decode_step_aux(
580 &self,
581 e: &Engine,
582 token: u32,
583 cache: &mut Cache,
584 aux_layers: &[usize],
585 ) -> Result<(Vec<f32>, Vec<CudaSlice<f32>>), Box<dyn std::error::Error>> {
586 let (logits, aux, _) = self.decode_step_aux_inner(e, token, cache, aux_layers, false)?;
587 Ok((logits, aux))
588 }
589
590 /// Diagnostic-only Hy3 layer-0 trace through the real eager T=1 serving path. Besides the
591 /// final block residual, this captures the attention output before its residual add, the
592 /// after-attention residual, and the dense-MLP output before the final residual add.
593 pub fn decode_step_hy3_layer0_stages(
594 &self,
595 e: &Engine,
596 token: u32,
597 cache: &mut Cache,
598 ) -> Result<(Vec<f32>, Hy3Layer0Stages), Box<dyn std::error::Error>> {
599 if self.cfg.hy3.is_none() {
600 return Err("decode_step_hy3_layer0_stages requires a Hy3 model".into());
601 }
602 if !matches!(
603 self.layers.first().map(|layer| &layer.ffn),
604 Some(crate::hybrid::Ffn::Dense { .. })
605 ) {
606 return Err("Hy3 diagnostic expected layer 0 to use a dense MLP".into());
607 }
608 let (logits, _, stages) = self.decode_step_aux_inner(e, token, cache, &[], true)?;
609 Ok((
610 logits,
611 stages.ok_or("Hy3 layer-0 stages were not captured")?,
612 ))
613 }
614
615 fn decode_step_aux_inner(
616 &self,
617 e: &Engine,
618 token: u32,
619 cache: &mut Cache,
620 aux_layers: &[usize],
621 capture_hy3_layer0: bool,
622 ) -> Result<(Vec<f32>, Vec<CudaSlice<f32>>, Option<Hy3Layer0Stages>), Box<dyn std::error::Error>>
623 {
624 let cfg = &self.cfg;
625 let n_embd = cfg.n_embd as usize;
626 let eps = cfg.rms_eps;
627 let pos = cache.pos;
628 let pos_d = e.htod_i32(&[pos as i32])?;
629
630 let mut x = e.htod(&self.embd.gather(n_embd, &[token]))?;
631 let mut aux: Vec<CudaSlice<f32>> = Vec::with_capacity(aux_layers.len());
632 let mut hy3_layer0 = None;
633
634 for (il, layer) in self.layers.iter().enumerate() {
635 // attn-input NORM-FUSION (eager); shared with decode_step_h.
636 let mixed =
637 self.attn_in_norm_mixer(e, layer, &x, &pos_d, pos, cache, il, n_embd, eps)?;
638 // DECODE NORM-FUSION LEVER (residual_norm_ffn): residual add + post_attn RMSNorm +
639 // q8_1-quantize fused into ONE add_rms_norm_q8_1 launch on the Dense q8_1-fast path, then
640 // the FFN consumes the pre-quantized activation. Bit-identical to the unfused path.
641 let (x1, ffn_out) = self.residual_norm_ffn(e, layer, &x, &mixed, n_embd, il, eps)?;
642 // MEMRA_TG_PROBE_LAYER diagnostics (token-graph bisection): dump layer K's
643 // attention output and post-FFN residual through the real eager path.
644 if std::env::var("MEMRA_TG_PROBE_LAYER")
645 .ok()
646 .and_then(|v| v.parse::<usize>().ok())
647 == Some(il)
648 {
649 use std::io::Write;
650 let mut xp = e.uninit(n_embd)?;
651 e.add(&x1, &ffn_out, &mut xp, n_embd)?;
652 let (pm, px) = (e.dtoh(&mixed)?, e.dtoh(&xp)?);
653 for (path, data) in [
654 ("/root/eager-probe-mixed.bin", &pm),
655 ("/root/eager-probe-x.bin", &px),
656 ] {
657 let mut fo = std::fs::OpenOptions::new()
658 .create(true)
659 .append(true)
660 .open(path)?;
661 for v in data {
662 fo.write_all(&v.to_le_bytes())?;
663 }
664 }
665 }
666 let mut x2 = e.uninit(n_embd)?;
667 e.add(&x1, &ffn_out, &mut x2, n_embd)?;
668 if capture_hy3_layer0 && il == 0 {
669 hy3_layer0 = Some(Hy3Layer0Stages {
670 attention_output: e.clone_dtod(&mixed)?,
671 after_attention: e.clone_dtod(&x1)?,
672 mlp_output: e.clone_dtod(&ffn_out)?,
673 residual: e.clone_dtod(&x2)?,
674 });
675 }
676 // EAGLE3 N1: capture this block's residual output if it is an aux layer.
677 if aux_layers.contains(&il) {
678 aux.push(e.clone_dtod(&x2)?);
679 }
680 x = x2;
681 }
682 // re-order aux to match aux_layers order (contains() pushes in il order; aux_layers is the
683 // canonical order the encoder concats in — they coincide since aux_layers is ascending).
684 let mut hn = e.uninit(n_embd)?;
685 e.rms_norm(&x, self.output_norm.float_data(), &mut hn, n_embd, 1, eps)?;
686 let logits = e.matmul(&self.output, &hn, 1)?;
687 let host = e.dtoh(&logits)?;
688 cache.pos += 1;
689 Ok((host, aux, hy3_layer0))
690 }
691
692 /// Like `decode_step`, but ALSO returns the trunk's hidden state `x` taken BEFORE the final
693 /// `output_norm` (MTP-PLAN §A: this is `h_seed` for the NextN head). Device buffer [n_embd].
694 pub fn decode_step_h(
695 &self,
696 e: &Engine,
697 token: u32,
698 cache: &mut Cache,
699 ) -> Result<(Vec<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
700 if self.is_gemma4_e4b() {
701 crate::pp::warn_unwired_once("gemma4-e4b eager decode");
702 return self.gemma4_e4b_decode_step_h(e, token, cache);
703 }
704 if self.uses_gemma_program() {
705 // pp2 door for the gemma4 arm lives inside gemma4_decode_step_h.
706 return self.gemma4_decode_step_h(e, token, cache);
707 }
708 // M2 ppN door (crate::pp): N-stage split of this walk with an explicit activation
709 // handoff at each boundary. Default OFF — unset env means this branch never taken.
710 if let Some(fence) = crate::pp::pp_cuts(self.layers.len()) {
711 if !self.rewrite_allowed(memra_gguf::execution_manifest::RewriteSurface::Pipeline) {
712 return Err("pipeline rewrite is not qualified for this ModelPlan".into());
713 }
714 return self.decode_step_h_ppn(e, token, cache, &fence);
715 }
716 // Whole-token decode graph (step TP graph increment B, MEMRA_STEP_TP_GRAPH=1 +
717 // the dcw/fused/router doors): one stitched multi-device launch per token.
718 if self.uses_sliding_gated_moe_program() {
719 if let Some(result) = self.step35_token_graph_step(e, token, cache)? {
720 return Ok(result);
721 }
722 }
723 let cfg = &self.cfg;
724 let n_embd = cfg.n_embd as usize;
725 let eps = cfg.rms_eps;
726 let pos = cache.pos;
727 let pos_d = e.htod_i32(&[pos as i32])?;
728 // O-PROJ TAIL deferral eligibility: this walk flows into residual_norm_ffn.
729 let _oproj_tail_scope = crate::tp::oproj_tail_scope();
730 // RANK0 STREAM MERGE (MEMRA_RANK0_MERGE=1): rank0 shares dev0's PRIMARY context
731 // with e (cudarc primary_ctx::retain), so its per-layer work can ride e's stream —
732 // every e<->rank0 event hop becomes program order. Scheduling-only: BIT-IDENTICAL.
733 let _r0merge = if crate::tp::rank0_merge_on() && self.uses_sliding_gated_moe_program() {
734 Some(memra_runtime::rank0_redirect_scope(
735 e.ctx().ordinal(),
736 e.gpu.main_stream().clone(),
737 e.gpu.blas(),
738 ))
739 } else {
740 None
741 };
742
743 // MEMRA_DEV_EMBED=1 (RECEIPTED NEGATIVE, default OFF): device embed gather from
744 // the resident table replaces the host row expand + 16KB pageable H2D with a 4B
745 // id write + one gather launch. Bit-identical rows (2G-IDENTITY-MATCH), but
746 // interleaved x3 measured FLAT (56.03 vs 56.06) — the host expand fully overlaps
747 // GPU work — and the resident table costs ~2.1GB VRAM. Kept as an opt-in seam
748 // (a future device-chained loop wants it; do not re-flip without a new receipt).
749 static DEV_EMBED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
750 let dev_embed =
751 *DEV_EMBED.get_or_init(|| std::env::var("MEMRA_DEV_EMBED").as_deref() == Ok("1"));
752 // embed the single token -> [1, n_embd]
753 let mut x = match (dev_embed, self.embd_gpu_try(e)) {
754 (true, Some(embd_gpu)) => {
755 static TOK_D: std::sync::Mutex<Option<(usize, CudaSlice<u32>)>> =
756 std::sync::Mutex::new(None);
757 let mut guard = TOK_D.lock().map_err(|_| "dev-embed lock is poisoned")?;
758 if guard.as_ref().is_none_or(|(d, _)| *d != e.ctx().ordinal()) {
759 *guard = Some((e.ctx().ordinal(), e.stream().clone_htod(&[0u32])?));
760 }
761 let (_, tok_d) = guard.as_mut().expect("armed above");
762 e.set_u32_one(tok_d, token)?;
763 let (embd_qt, embd_rb) = self.embd.qt_and_row_bytes(n_embd);
764 e.embed_gather_device(embd_gpu, tok_d, n_embd, embd_qt, embd_rb)?
765 }
766 _ => e.htod(&self.embd.gather(n_embd, &[token]))?,
767 };
768
769 // CROSS-LAYER ADD+NORM FUSION (launch-arc 2026-07-07): layer il's post-FFN residual add
770 // (x2 = x1 + ffn_out) and layer il+1's attn_norm+quantize are consecutive row-wise ops —
771 // add_rms_norm_q8_1 does all three in ONE launch (bit-identity proven in kernel_check:
772 // add_rms_norm == add then rms_norm; _q8_1 == then quantize_q8_1). Carry the un-added
773 // (x1, ffn_out) pair into the next iteration; the fused launch materializes x2 (the
774 // residual this layer needs) as its `res` output. Falls back to the separate add when
775 // the next mixer is off the q8_1 fast path.
776 // MEMRA_STEP_TP_TIMING=1: whole-token bucket split of the eager decode walk — mixer vs
777 // FFN totals, the EP-tail layers (>= trunk-2) separated, plus the head. Each lap syncs
778 // e's stream, so async work bills to the section that queued it. Diagnostic only.
779 static B_MIX: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
780 static B_FFN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
781 static B_MIX_TAIL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
782 static B_FFN_TAIL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
783 static B_HEAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
784 static B_TOKENS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
785 let timing = std::env::var("MEMRA_STEP_TP_TIMING").as_deref() == Ok("1");
786 let lap = |timer: &std::sync::atomic::AtomicU64,
787 started: &mut Option<std::time::Instant>|
788 -> Result<(), Box<dyn std::error::Error>> {
789 let Some(start) = started.as_mut() else {
790 return Ok(());
791 };
792 e.stream().synchronize()?;
793 timer.fetch_add(
794 start.elapsed().as_nanos() as u64,
795 std::sync::atomic::Ordering::Relaxed,
796 );
797 *start = std::time::Instant::now();
798 Ok(())
799 };
800 let mut lap_start = timing.then(std::time::Instant::now);
801 let tail_from = self.layers.len().saturating_sub(2);
802 let mut pending: Option<(CudaSlice<f32>, CudaSlice<f32>)> = None;
803 for (il, layer) in self.layers.iter().enumerate() {
804 let anorm = layer.attn_norm.float_data();
805 let fuse = std::env::var("MEMRA_NO_FUSE_NORMQ").is_err()
806 && self.mixer_in_q8_1_fast(e, &layer.mixer);
807 // NOTE: take() FIRST, branch on fuse after — a tuple pattern like
808 // `if let (Some(p), true) = (pending.take(), fuse)` DROPS the taken pair when
809 // fuse is false (pattern fails post-take) and silently loses the residual add.
810 let taken = pending.take();
811 // FUSION #2f (bf16-mixer decode, MEMRA_FUSE_ADD_NORM=0 reverts): off the q8_1
812 // fast path the residual add and this layer's attn_norm ran as two launches;
813 // add_rms_norm does both (kernel_check identity: add_rms_norm == add then
814 // rms_norm; same rms_block()), then the mixer takes the pre-normed h directly.
815 static FUSE_AN: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
816 let fuse_add_norm =
817 *FUSE_AN.get_or_init(|| std::env::var("MEMRA_FUSE_ADD_NORM").as_deref() != Ok("0"));
818 let mixed = match (taken, fuse) {
819 (Some((x1, f1)), false) if fuse_add_norm => {
820 let mut x2 = e.uninit(n_embd)?;
821 let mut h = e.uninit(n_embd)?;
822 e.add_rms_norm(&x1, &f1, anorm, &mut x2, &mut h, n_embd, 1, eps)?;
823 x = x2;
824 match &layer.mixer {
825 Mixer::Full(fa) => {
826 self.full_attn_decode(e, fa, &h, &pos_d, pos, cache, il)?
827 }
828 Mixer::Linear(la) => self.linear_attn_decode(e, la, &h, cache, il)?,
829 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
830 }
831 }
832 (Some((x1, f1)), true) => {
833 // fused add + attn_norm + q8_1 (this layer's mixer input), res -> x2
834 let mut x2 = e.uninit(n_embd)?;
835 let (hq, hd) = e.add_rms_norm_q8_1(&x1, &f1, anorm, &mut x2, n_embd, 1, eps)?;
836 x = x2;
837 let h0 = e.zeros(0)?;
838 match &layer.mixer {
839 Mixer::Full(fa) => self.full_attn_decode_pre(
840 e,
841 fa,
842 &h0,
843 Some((&hq, &hd)),
844 &pos_d,
845 pos,
846 cache,
847 il,
848 )?,
849 Mixer::Linear(la) => {
850 self.linear_attn_decode_pre(e, la, &h0, &hq, &hd, cache, il, false)?
851 }
852 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
853 }
854 }
855 (taken, _) => {
856 if let Some((x1, f1)) = taken {
857 let mut x2 = e.uninit(n_embd)?;
858 e.add(&x1, &f1, &mut x2, n_embd)?;
859 x = x2;
860 }
861 self.attn_in_norm_mixer(e, layer, &x, &pos_d, pos, cache, il, n_embd, eps)?
862 }
863 };
864
865 lap(
866 if il >= tail_from { &B_MIX_TAIL } else { &B_MIX },
867 &mut lap_start,
868 )?;
869
870 // DECODE NORM-FUSION LEVER (residual_norm_ffn): add+post_attn_norm+q8_1 fused on the Dense
871 // fast path. Bit-identical to add + rms_norm + ffn (add_rms_norm == add then rms_norm,
872 // proven in kernel_check; add_rms_norm_q8_1 == add_rms_norm then quantize_q8_1).
873 let (x1, ffn_out) = self.residual_norm_ffn(e, layer, &x, &mixed, n_embd, il, eps)?;
874 // MEMRA_TG_PROBE_LAYER diagnostics (token-graph bisection): dump layer K's
875 // attention output and post-FFN residual through the real eager path.
876 if std::env::var("MEMRA_TG_PROBE_LAYER")
877 .ok()
878 .and_then(|v| v.parse::<usize>().ok())
879 == Some(il)
880 {
881 use std::io::Write;
882 let mut xp = e.uninit(n_embd)?;
883 e.add(&x1, &ffn_out, &mut xp, n_embd)?;
884 let (pm, px) = (e.dtoh(&mixed)?, e.dtoh(&xp)?);
885 for (path, data) in [
886 ("/root/eager-probe-mixed.bin", &pm),
887 ("/root/eager-probe-x.bin", &px),
888 ] {
889 let mut fo = std::fs::OpenOptions::new()
890 .create(true)
891 .append(true)
892 .open(path)?;
893 for v in data {
894 fo.write_all(&v.to_le_bytes())?;
895 }
896 }
897 }
898 lap(
899 if il >= tail_from { &B_FFN_TAIL } else { &B_FFN },
900 &mut lap_start,
901 )?;
902 pending = Some((x1, ffn_out));
903 }
904 // final layer's add (no next norm to fuse with — output_norm is f32-out)
905 if let Some((x1, f1)) = pending.take() {
906 let mut x2 = e.uninit(n_embd)?;
907 e.add(&x1, &f1, &mut x2, n_embd)?;
908 x = x2;
909 }
910
911 let mut hn = e.uninit(n_embd)?;
912 e.rms_norm(&x, self.output_norm.float_data(), &mut hn, n_embd, 1, eps)?;
913 // h_seed = trunk hidden BEFORE output_norm (default, §A) or AFTER it (MEMRA_SPEC_HPOST,
914 // the reference engines' convention — see spec::spec_hpost).
915 let h_seed = if crate::spec::spec_hpost() {
916 e.clone_dtod(&hn)?
917 } else {
918 e.clone_dtod(&x)?
919 };
920 // head-MIPS feasibility probe (MEMRA_DUMP_HN=<path>): append pre-head hiddens for
921 // offline bound analysis. Diagnostic only.
922 if let Ok(path) = std::env::var("MEMRA_DUMP_HN") {
923 let hh = e.dtoh(&hn)?;
924 use std::io::Write;
925 let mut fo = std::fs::OpenOptions::new()
926 .create(true)
927 .append(true)
928 .open(path)?;
929 for v in &hh {
930 fo.write_all(&v.to_le_bytes())?;
931 }
932 }
933 // MEMRA_HEAD_SPLIT=1 (step TP only): split the lm-head rows across both devices —
934 // dev1 idles at the token tail, rows are independent, and the per-row program is the
935 // same matvec_bf16 kernel, so the concatenated logits are BIT-IDENTICAL to the
936 // single-device head. Falls through to the plain matmul when ineligible.
937 let host = 'head: {
938 let split_on = {
939 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
940 *ON.get_or_init(|| std::env::var("MEMRA_HEAD_SPLIT").as_deref() == Ok("1"))
941 };
942 if split_on && self.uses_sliding_gated_moe_program() {
943 if let Some(host) = self.head_split_matvec(e, &hn)? {
944 break 'head host;
945 }
946 }
947 let logits = e.matmul(&self.output, &hn, 1)?;
948 e.dtoh(&logits)?
949 };
950 lap(&B_HEAD, &mut lap_start)?;
951 if timing {
952 use std::sync::atomic::Ordering;
953 let tokens = B_TOKENS.fetch_add(1, Ordering::Relaxed) + 1;
954 if tokens % 10 == 0 {
955 let per = |t: &std::sync::atomic::AtomicU64| {
956 t.load(Ordering::Relaxed) as f64 / tokens as f64 / 1.0e6
957 };
958 eprintln!(
959 "[decode-bucket-timing] tokens={tokens} ms/token mix={:.2} ffn={:.2} \
960 mix_tail={:.2} ffn_tail={:.2} head={:.2}",
961 per(&B_MIX),
962 per(&B_FFN),
963 per(&B_MIX_TAIL),
964 per(&B_FFN_TAIL),
965 per(&B_HEAD),
966 );
967 }
968 }
969 cache.pos += 1;
970 Ok((host, h_seed))
971 }
972
973 /// ASYNC-AHEAD DEVICE-CHAINED greedy decode (MEMRA_ASYNC_CHAIN=K): run up to `k`
974 /// tokens with NO host sync inside the chain — the tail argmax writes the resident
975 /// token_d on-device (host-identical tie-break, argmax_gate receipt), the next
976 /// iteration embeds straight from it (embed_gather_device, bit-identical rows), and
977 /// the host reads the id history ring ONCE per chunk. Unlike the graph chunk this
978 /// keeps EAGER kernels and streams (full stream concurrency); the host submit runs
979 /// ahead of the GPU, so the per-token host wall overlaps device work instead of
980 /// serializing after it.
981 /// Contract mirrors step35_token_graph_chunk: consumes `token` (already emitted by
982 /// the caller) as launch 0's input and returns (hist[0..k], last token's logits) —
983 /// hist[k-1] == argmax(logits), so the caller emits hist[..k-1] and re-derives the
984 /// last from the returned row. The head runs as the plain single-device matvec (the
985 /// HEAD_SPLIT path returns host logits, which would force a mid-chain sync); the
986 /// split head's concatenated logits are bit-identical to this, so tapes agree.
987 pub fn decode_step_chain(
988 &self,
989 e: &Engine,
990 token: u32,
991 k_target: usize,
992 cache: &mut Cache,
993 ) -> Result<Option<(Vec<u32>, Vec<f32>)>, Box<dyn std::error::Error>> {
994 if !self.uses_sliding_gated_moe_program() {
995 return Ok(None);
996 }
997 let k = k_target.min(16);
998 if k < 2 {
999 return Ok(None);
1000 }
1001 let Some(embd_gpu) = self.embd_gpu_try(e) else {
1002 return Ok(None);
1003 };
1004 let cfg = &self.cfg;
1005 let n_embd = cfg.n_embd as usize;
1006 let n_vocab = cfg.n_vocab as usize;
1007 let eps = cfg.rms_eps;
1008 let n_layers = self.layers.len();
1009 let (embd_qt, embd_rb) = self.embd.qt_and_row_bytes(n_embd);
1010
1011 // Resident chain state (token id, id history ring, ring index), one set per device.
1012 static CHAIN: std::sync::Mutex<
1013 Option<(usize, CudaSlice<u32>, CudaSlice<u32>, CudaSlice<i32>)>,
1014 > = std::sync::Mutex::new(None);
1015 let mut guard = CHAIN.lock().map_err(|_| "chain state lock is poisoned")?;
1016 if guard.as_ref().is_none_or(|(d, ..)| *d != e.ctx().ordinal()) {
1017 *guard = Some((
1018 e.ctx().ordinal(),
1019 e.stream().clone_htod(&[0u32])?,
1020 e.stream().clone_htod(&[0u32; 16])?,
1021 e.htod_i32(&[0])?,
1022 ));
1023 }
1024 let (_, token_d, hist, hist_idx) = guard.as_mut().expect("armed above");
1025
1026 // O-PROJ TAIL deferral eligibility (see decode_step_h).
1027 let _oproj_tail_scope = crate::tp::oproj_tail_scope();
1028 // RANK0 STREAM MERGE (see decode_step_h).
1029 let _r0merge = if crate::tp::rank0_merge_on() {
1030 Some(memra_runtime::rank0_redirect_scope(
1031 e.ctx().ordinal(),
1032 e.gpu.main_stream().clone(),
1033 e.gpu.blas(),
1034 ))
1035 } else {
1036 None
1037 };
1038 // Per-token pos buffers staged BEFORE the chain (the only H2D the chain needs).
1039 let mut pos_bufs = Vec::with_capacity(k);
1040 for step in 0..k {
1041 pos_bufs.push(e.htod_i32(&[(cache.pos + step) as i32])?);
1042 }
1043 e.set_u32_one(token_d, token)?;
1044 e.set_i32_one(hist_idx, 0)?;
1045
1046 // MEMRA_CHAIN_PHASE=1 (P0 CEILING PROBE — WRONG OUTPUT BY DESIGN): alternate
1047 // tokens ride disjoint phase streams with NO cross-token event edges yet, so the
1048 // schedule shows the token-pipeline overlap ceiling while the ids race. Timing
1049 // receipts only; never gate a tape under this door.
1050 static PHASE_ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1051 let phase_on =
1052 *PHASE_ON.get_or_init(|| std::env::var("MEMRA_CHAIN_PHASE").as_deref() == Ok("1"));
1053
1054 let mut last_logits: Option<Option<CudaSlice<f32>>> = None;
1055 for step in 0..k {
1056 let _phase_ov = if phase_on {
1057 let (ps, pb) = e.gpu.phase_pair(step & 1)?;
1058 memra_runtime::set_decode_phase(Some(step & 1));
1059 Some(memra_runtime::push_stream_override(ps, pb))
1060 } else {
1061 None
1062 };
1063 let pos = cache.pos;
1064 let step_r = (|| -> Result<Option<CudaSlice<f32>>, Box<dyn std::error::Error>> {
1065 let x = e.embed_gather_device(embd_gpu, token_d, n_embd, embd_qt, embd_rb)?;
1066 let x = self.decode_layers_eager(e, x, 0, n_layers, &pos_bufs[step], pos, cache)?;
1067 let mut hn = e.uninit(n_embd)?;
1068 e.rms_norm(&x, self.output_norm.float_data(), &mut hn, n_embd, 1, eps)?;
1069 // Split head when armed (MEMRA_HEAD_SPLIT env + eligibility): identical
1070 // concatenated logits, device argmax, no per-token readback.
1071 static HS_ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1072 let hs =
1073 *HS_ON.get_or_init(|| std::env::var("MEMRA_HEAD_SPLIT").as_deref() == Ok("1"));
1074 let logits = if hs
1075 && self.uses_sliding_gated_moe_program()
1076 && self.head_split_argmax_device(e, &hn, token_d)?
1077 {
1078 None
1079 } else {
1080 let logits = e.matmul(&self.output, &hn, 1)?;
1081 e.argmax_token_device_into(&logits, token_d, n_vocab)?;
1082 Some(logits)
1083 };
1084 e.u32_hist_append(token_d, hist, hist_idx)?;
1085 Ok(logits)
1086 })();
1087 if phase_on {
1088 memra_runtime::set_decode_phase(None);
1089 }
1090 let logits = step_r?;
1091 cache.pos += 1;
1092 last_logits = Some(logits);
1093 // (None = split-head path; the persistent row holds this token's logits.)
1094 }
1095 if phase_on {
1096 // Drain both phases on every engine before the host readback.
1097 for p in 0..2 {
1098 e.gpu.phase_pair(p)?.0.synchronize()?;
1099 }
1100 if let Some(tp) = self.layers.first().and_then(|l| match &l.mixer {
1101 Mixer::Full(fa) => fa.step_tp_qkv.as_ref(),
1102 _ => None,
1103 }) {
1104 for rank in 0..tp.runtime.devices().len() {
1105 if let Some(engine) = tp.runtime.rank_engine(rank) {
1106 let _main = engine.gpu.enter_main()?;
1107 for p in 0..2 {
1108 engine.gpu.phase_pair(p)?.0.synchronize()?;
1109 }
1110 }
1111 }
1112 }
1113 }
1114 let hist_h = e.dtoh_u32(hist)?;
1115 let logits_h = match last_logits.expect("k >= 2") {
1116 Some(row) => e.dtoh(&row)?,
1117 None => self.head_split_logits_dtoh(e)?,
1118 };
1119 Ok(Some((hist_h[..k].to_vec(), logits_h)))
1120 }
1121
1122 /// M1-PP2 stage subgraph: run layers [lo, hi) of the generic eager walk. Enters with a
1123 /// MATERIALIZED residual `x` (no pending fusion pair from outside the range) and exits
1124 /// with the range's final residual materialized (the trailing add executed, exactly like
1125 /// the last layer of an unsplit walk). Body is the `decode_step_h` loop verbatim with the
1126 /// cross-layer add+norm fusion carry LOCAL to the range — so the only state a stage
1127 /// boundary has to move is the [n_embd] hidden state. Bit-identity of the cut relies on
1128 /// the kernel-check-pinned `add_rms_norm_q8_1 == add then rms_norm_q8_1` identity
1129 /// (`pp2-gate` verifies end-to-end on real weights).
1130 /// `pub(crate)`: also the B=1 serve fast-path's trunk (decode_batch.rs
1131 /// `decode_step_b1_fast`, H3) — shared verbatim so the serve path inherits every m=1
1132 /// fusion instead of needing a batched twin per lever.
1133 #[allow(clippy::too_many_arguments)]
1134 pub(crate) fn decode_layers_eager(
1135 &self,
1136 e: &Engine,
1137 mut x: CudaSlice<f32>,
1138 lo: usize,
1139 hi: usize,
1140 pos_d: &CudaSlice<i32>,
1141 pos: usize,
1142 cache: &mut Cache,
1143 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
1144 let n_embd = self.cfg.n_embd as usize;
1145 let eps = self.cfg.rms_eps;
1146 let mut pending: Option<(CudaSlice<f32>, CudaSlice<f32>)> = None;
1147 for il in lo..hi {
1148 let layer = &self.layers[il];
1149 let anorm = layer.attn_norm.float_data();
1150 let fuse = std::env::var("MEMRA_NO_FUSE_NORMQ").is_err()
1151 && self.mixer_in_q8_1_fast(e, &layer.mixer);
1152 // take() FIRST, branch on fuse after (see decode_step_h: a tuple pattern drops
1153 // the taken pair when fuse is false and silently loses the residual add).
1154 let taken = pending.take();
1155 // FUSION #2f (same door as decode_step_h): off the q8_1 fast path, fuse the
1156 // residual add with this layer's attn_norm via add_rms_norm.
1157 static FUSE_AN_LE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1158 let fuse_add_norm = *FUSE_AN_LE
1159 .get_or_init(|| std::env::var("MEMRA_FUSE_ADD_NORM").as_deref() != Ok("0"));
1160 let mixed = match (taken, fuse) {
1161 (Some((x1, f1)), false) if fuse_add_norm => {
1162 let mut x2 = e.uninit(n_embd)?;
1163 let mut h = e.uninit(n_embd)?;
1164 e.add_rms_norm(&x1, &f1, anorm, &mut x2, &mut h, n_embd, 1, eps)?;
1165 x = x2;
1166 match &layer.mixer {
1167 Mixer::Full(fa) => {
1168 self.full_attn_decode(e, fa, &h, pos_d, pos, cache, il)?
1169 }
1170 Mixer::Linear(la) => self.linear_attn_decode(e, la, &h, cache, il)?,
1171 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
1172 }
1173 }
1174 (Some((x1, f1)), true) => {
1175 let mut x2 = e.uninit(n_embd)?;
1176 let (hq, hd) = e.add_rms_norm_q8_1(&x1, &f1, anorm, &mut x2, n_embd, 1, eps)?;
1177 x = x2;
1178 let h0 = e.zeros(0)?;
1179 match &layer.mixer {
1180 Mixer::Full(fa) => self.full_attn_decode_pre(
1181 e,
1182 fa,
1183 &h0,
1184 Some((&hq, &hd)),
1185 pos_d,
1186 pos,
1187 cache,
1188 il,
1189 )?,
1190 Mixer::Linear(la) => {
1191 self.linear_attn_decode_pre(e, la, &h0, &hq, &hd, cache, il, false)?
1192 }
1193 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
1194 }
1195 }
1196 (taken, _) => {
1197 if let Some((x1, f1)) = taken {
1198 let mut x2 = e.uninit(n_embd)?;
1199 e.add(&x1, &f1, &mut x2, n_embd)?;
1200 x = x2;
1201 }
1202 self.attn_in_norm_mixer(e, layer, &x, pos_d, pos, cache, il, n_embd, eps)?
1203 }
1204 };
1205 let (x1, ffn_out) = self.residual_norm_ffn(e, layer, &x, &mixed, n_embd, il, eps)?;
1206 // MEMRA_TG_PROBE_LAYER diagnostics (token-graph bisection): dump layer K's
1207 // attention output and post-FFN residual through the real eager path.
1208 if std::env::var("MEMRA_TG_PROBE_LAYER")
1209 .ok()
1210 .and_then(|v| v.parse::<usize>().ok())
1211 == Some(il)
1212 {
1213 use std::io::Write;
1214 let mut xp = e.uninit(n_embd)?;
1215 e.add(&x1, &ffn_out, &mut xp, n_embd)?;
1216 let (pm, px) = (e.dtoh(&mixed)?, e.dtoh(&xp)?);
1217 for (path, data) in [
1218 ("/root/eager-probe-mixed.bin", &pm),
1219 ("/root/eager-probe-x.bin", &px),
1220 ] {
1221 let mut fo = std::fs::OpenOptions::new()
1222 .create(true)
1223 .append(true)
1224 .open(path)?;
1225 for v in data {
1226 fo.write_all(&v.to_le_bytes())?;
1227 }
1228 }
1229 }
1230 pending = Some((x1, ffn_out));
1231 }
1232 // range's final add (no next norm inside the range to fuse with)
1233 if let Some((x1, f1)) = pending.take() {
1234 let mut x2 = e.uninit(n_embd)?;
1235 e.add(&x1, &f1, &mut x2, n_embd)?;
1236 x = x2;
1237 }
1238 Ok(x)
1239 }
1240
1241 /// M2: `decode_step_h` as N stage subgraphs, each on ITS OWN CUDA stream (and, under
1242 /// MEMRA_PP_DEVICES, its own device/engine), with the transport-selected boundary
1243 /// handoff at each fence cut. Stage 0 = embed + its layer range; each middle stage
1244 /// RXes boundary s-1 (waits its ev_tx), runs its range, TXes boundary s; the last
1245 /// stage adds output_norm + lm head. Per-layer KV/linear state stays owned by the
1246 /// stage that runs the layer; `cache.pos` is snapshotted once and advanced once.
1247 /// MEMRA_PP_STREAMS=0 = the increment-1 same-stream seam.
1248 /// Gate: `ppn-gate` (bit-identical logits vs unsplit at every N/knob combination).
1249 fn decode_step_h_ppn(
1250 &self,
1251 e: &Engine,
1252 token: u32,
1253 cache: &mut Cache,
1254 fence: &[usize],
1255 ) -> Result<(Vec<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
1256 if crate::pp::pp2_streams_off() {
1257 return self.decode_step_h_ppn_samestream(e, token, cache, fence);
1258 }
1259 let rt = crate::pp::PpNRt::get(e)?;
1260 let n_st = fence.len() - 1;
1261 assert_eq!(
1262 rt.n_stages(),
1263 n_st,
1264 "PpNRt stage count {} != fence stages {n_st}",
1265 rt.n_stages()
1266 );
1267 // #87 REVERSE PUBLICATION (lane/pp2spec-crash): this body's stage-stream
1268 // allocations may reuse pool blocks freed from a PREVIOUS ppn call's outputs
1269 // (h_seed, verify vx/ckpt) whose primary-stream consumers are still queued —
1270 // the reuse-write races the queued read. Order every stage stream behind the
1271 // caller's stream before the first stage allocation. Full anatomy:
1272 // `PpNRt::fence_stages_behind`.
1273 rt.fence_stages_behind(&e.stream())?;
1274 let cfg = &self.cfg;
1275 let n_embd = cfg.n_embd as usize;
1276 let eps = cfg.rms_eps;
1277 let pos = cache.pos;
1278
1279 // PER-STAGE pos_d (M2 pipelining law): every stage uploads its OWN copy of the
1280 // step's pos scalar on ITS stream, so the buffer is allocated, consumed, and
1281 // freed on one stream (a shared stage-0 pos_d freed at fn return breaks under
1282 // deferred readback: the free enqueues on stream 0 while stages 1..N-1 still
1283 // dereference it — the 2026-08-02 pipelined-gate all-logits divergence).
1284
1285 // ---- STAGE 0 (its own stream): embed + layers [0, fence[1]) + boundary-0 TX ----
1286 let mut slot = {
1287 let _st0 = rt.enter(0);
1288 let e0 = rt.engine(0, e);
1289 let pos_d = e0.htod_i32(&[pos as i32])?;
1290 let x = e0.htod(&self.embd.gather(n_embd, &[token]))?;
1291 let x = self.decode_layers_eager(e0, x, fence[0], fence[1], &pos_d, pos, cache)?;
1292 rt.tx(0, &x, n_embd)?
1293 // x + pos_d drop here: freed stream-ordered on stage-0's stream after use.
1294 };
1295
1296 // ---- MIDDLE STAGES s in [1, n_st-1): RX boundary s-1 -> range -> TX boundary s ----
1297 for s in 1..n_st - 1 {
1298 let _st = rt.enter(s);
1299 let es = rt.engine(s, e);
1300 let pos_d = es.htod_i32(&[pos as i32])?;
1301 let x = rt.rx(s - 1, slot, n_embd)?;
1302 let x = self.decode_layers_eager(es, x, fence[s], fence[s + 1], &pos_d, pos, cache)?;
1303 slot = rt.tx(s, &x, n_embd)?;
1304 }
1305
1306 // ---- LAST STAGE: RX + layers [fence[n_st-1], n) + output_norm + lm head ----
1307 let _stl = rt.enter(n_st - 1);
1308 let el = rt.engine(n_st - 1, e);
1309 let pos_d = el.htod_i32(&[pos as i32])?;
1310 let x = rt.rx(n_st - 2, slot, n_embd)?;
1311 let x =
1312 self.decode_layers_eager(el, x, fence[n_st - 1], fence[n_st], &pos_d, pos, cache)?;
1313 let e = el; // head runs through the last stage's engine on its stream
1314
1315 let mut hn = e.uninit(n_embd)?;
1316 e.rms_norm(&x, self.output_norm.float_data(), &mut hn, n_embd, 1, eps)?;
1317 let h_seed = if crate::spec::spec_hpost() {
1318 e.clone_dtod(&hn)?
1319 } else {
1320 e.clone_dtod(&x)?
1321 };
1322 // same diagnostics door as decode_step_h (MEMRA_DUMP_HN) so the arms stay observably
1323 // interchangeable.
1324 if let Ok(path) = std::env::var("MEMRA_DUMP_HN") {
1325 let hh = e.dtoh(&hn)?;
1326 use std::io::Write;
1327 let mut fo = std::fs::OpenOptions::new()
1328 .create(true)
1329 .append(true)
1330 .open(path)?;
1331 for v in &hh {
1332 fo.write_all(&v.to_le_bytes())?;
1333 }
1334 }
1335 let logits = e.matmul(&self.output, &hn, 1)?;
1336 let host = e.dtoh(&logits)?;
1337 cache.pos += 1;
1338 Ok((host, h_seed))
1339 }
1340
1341 /// MEMRA_PP_STREAMS=0 rollback seam: the increment-1 body generalized to N — every
1342 /// stage subgraph on the ambient compute stream, each boundary = two plain dtod copies.
1343 fn decode_step_h_ppn_samestream(
1344 &self,
1345 e: &Engine,
1346 token: u32,
1347 cache: &mut Cache,
1348 fence: &[usize],
1349 ) -> Result<(Vec<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
1350 let cfg = &self.cfg;
1351 let n_embd = cfg.n_embd as usize;
1352 let eps = cfg.rms_eps;
1353 let pos = cache.pos;
1354 let pos_d = e.htod_i32(&[pos as i32])?;
1355
1356 // ---- STAGE 0: embed (the table lives with stage 0) + layers [0, fence[1]) ----
1357 let x = e.htod(&self.embd.gather(n_embd, &[token]))?;
1358 let mut x = self.decode_layers_eager(e, x, fence[0], fence[1], &pos_d, pos, cache)?;
1359
1360 // ---- each later stage: explicit [n_embd] handoff (TX copy, RX copy) + range ----
1361 for s in 1..fence.len() - 1 {
1362 let boundary_tx = e.clone_dtod(&x)?;
1363 let boundary_rx = e.clone_dtod(&boundary_tx)?;
1364 x = self.decode_layers_eager(
1365 e,
1366 boundary_rx,
1367 fence[s],
1368 fence[s + 1],
1369 &pos_d,
1370 pos,
1371 cache,
1372 )?;
1373 }
1374
1375 let mut hn = e.uninit(n_embd)?;
1376 e.rms_norm(&x, self.output_norm.float_data(), &mut hn, n_embd, 1, eps)?;
1377 let h_seed = if crate::spec::spec_hpost() {
1378 e.clone_dtod(&hn)?
1379 } else {
1380 e.clone_dtod(&x)?
1381 };
1382 if let Ok(path) = std::env::var("MEMRA_DUMP_HN") {
1383 let hh = e.dtoh(&hn)?;
1384 use std::io::Write;
1385 let mut fo = std::fs::OpenOptions::new()
1386 .create(true)
1387 .append(true)
1388 .open(path)?;
1389 for v in &hh {
1390 fo.write_all(&v.to_le_bytes())?;
1391 }
1392 }
1393 let logits = e.matmul(&self.output, &hn, 1)?;
1394 let host = e.dtoh(&logits)?;
1395 cache.pos += 1;
1396 Ok((host, h_seed))
1397 }
1398
1399 /// M2 increment 3 (DEFERRED READBACK — the pipelining seed): the ppN step WITHOUT the
1400 /// terminal logits D2H. Returns `PendingLogits` (device logits + completion event +
1401 /// the runtime's dedicated readback stream); the caller keeps 2+ tokens in flight by
1402 /// enqueueing step t+1 BEFORE waiting step t (with MEMRA_PP_OVERLAP=1 the
1403 /// double-buffered boundary slots actually alternate, so stage 0 of t+1 runs under
1404 /// stage 1..N-1 of t; the slot ev_tx/ev_rx chain keeps each token's math fully
1405 /// event-ordered either way — enqueueing deeper than 2 is CORRECT, the slots simply
1406 /// serialize device-side).
1407 ///
1408 /// EXACTNESS CONTRACT: per-token logits are BIT-IDENTICAL to the serial arm — same
1409 /// kernels, same per-token event order; only the host-side wait moves (scheduling
1410 /// change, never math). The pipelined replay arm of `ppn-gate` proves it per step.
1411 ///
1412 /// NOT produced here (both are trunk COPIES — no math feeding the logits changes):
1413 /// h_seed and the MEMRA_DUMP_HN diagnostic tap. The serving loop decides their
1414 /// deferred form when it adopts this API.
1415 ///
1416 /// The caller advances the token stream, so `cache.pos` advances at ENQUEUE (host
1417 /// state; device work is event-ordered regardless).
1418 pub fn decode_step_h_ppn_deferred(
1419 &self,
1420 e: &Engine,
1421 token: u32,
1422 cache: &mut Cache,
1423 ) -> Result<crate::pp::PendingLogits, Box<dyn std::error::Error>> {
1424 let fence = crate::pp::pp_cuts(self.layers.len())
1425 .ok_or("ppn deferred: pp door closed (MEMRA_PP_STAGES unset)")?;
1426 if crate::pp::pp2_streams_off() {
1427 return Err("ppn deferred needs per-stage streams (MEMRA_PP_STREAMS=0 set)".into());
1428 }
1429 if self.uses_gemma_program() {
1430 return Err("ppn deferred: generic eager arm only (gemma4 is 2-stage serial)".into());
1431 }
1432 if crate::pp::pp_multi_stream_same_device()
1433 && std::env::var("MEMRA_PP_FORCE_SAME_DEV_PIPELINED").as_deref() != Ok("1")
1434 {
1435 return Err(
1436 "ppn deferred: refused with 2+ stage streams on one device — repro'd \
1437 nondeterministic logits (35% flake, 2026-08-02 x20 soak, root cause open: \
1438 shared-Engine kernels concurrent on co-located streams). Use one device \
1439 per stage (MEMRA_PP_DEVICES) or the serial arm. \
1440 MEMRA_PP_FORCE_SAME_DEV_PIPELINED=1 overrides for soak/bisect measurement."
1441 .into(),
1442 );
1443 }
1444 let rt = crate::pp::PpNRt::get(e)?;
1445 let n_st = fence.len() - 1;
1446 assert_eq!(
1447 rt.n_stages(),
1448 n_st,
1449 "PpNRt stage count {} != fence stages {n_st}",
1450 rt.n_stages()
1451 );
1452 let cfg = &self.cfg;
1453 let n_embd = cfg.n_embd as usize;
1454 let eps = cfg.rms_eps;
1455 let pos = cache.pos;
1456
1457 // Per-stage pos_d — see decode_step_h_ppn: under deferred readback a shared
1458 // pos_d's fn-end free races stages 1..N-1 (the free enqueues on stream 0 at
1459 // ENQUEUE time here, no terminal D2H to drain first). Each stage owns its copy.
1460 let mut slot = {
1461 let _st0 = rt.enter(0);
1462 let e0 = rt.engine(0, e);
1463 let pos_d = e0.htod_i32(&[pos as i32])?;
1464 let x = e0.htod(&self.embd.gather(n_embd, &[token]))?;
1465 let x = self.decode_layers_eager(e0, x, fence[0], fence[1], &pos_d, pos, cache)?;
1466 rt.tx(0, &x, n_embd)?
1467 };
1468 for s in 1..n_st - 1 {
1469 let _st = rt.enter(s);
1470 let es = rt.engine(s, e);
1471 let pos_d = es.htod_i32(&[pos as i32])?;
1472 let x = rt.rx(s - 1, slot, n_embd)?;
1473 let x = self.decode_layers_eager(es, x, fence[s], fence[s + 1], &pos_d, pos, cache)?;
1474 slot = rt.tx(s, &x, n_embd)?;
1475 }
1476 let _stl = rt.enter(n_st - 1);
1477 let el = rt.engine(n_st - 1, e);
1478 let pos_d = el.htod_i32(&[pos as i32])?;
1479 let x = rt.rx(n_st - 2, slot, n_embd)?;
1480 let x =
1481 self.decode_layers_eager(el, x, fence[n_st - 1], fence[n_st], &pos_d, pos, cache)?;
1482
1483 let mut hn = el.uninit(n_embd)?;
1484 el.rms_norm(&x, self.output_norm.float_data(), &mut hn, n_embd, 1, eps)?;
1485 let logits = el.matmul(&self.output, &hn, 1)?;
1486 let ev = rt.record_done()?;
1487 cache.pos += 1;
1488 Ok(crate::pp::PendingLogits::new(
1489 logits,
1490 ev,
1491 rt.readback_stream().clone(),
1492 ))
1493 }
1494
1495 /// LOCKSTEP MULTI-STREAM decode (lane-3 M1): m independent streams advance one token each
1496 /// through a single per-layer walk. Per-stream math is identical to `decode_step_h` (same
1497 /// fusion chain, same mixer and FFN calls against that stream's own `Cache`), so each
1498 /// stream's token sequence is bit-identical to its single-stream run. The lockstep order
1499 /// puts the m streams' layer-il MoE calls adjacent in time, so one stream's expert-cache
1500 /// fill serves its siblings within the step — the measured cross-stream io amortization
1501 /// (1.12x/1.32x/1.66x at m=2/4/8) lands without batching attention or the CPU ABI.
1502 pub fn decode_step_lockstep(
1503 &self,
1504 e: &Engine,
1505 tokens: &[u32],
1506 caches: &mut [Cache],
1507 ) -> Result<Vec<Vec<f32>>, Box<dyn std::error::Error>> {
1508 if tokens.len() != caches.len() || tokens.is_empty() {
1509 return Err("lockstep needs one token per stream cache".into());
1510 }
1511 if self.uses_gemma_program() {
1512 return Err("lockstep decode does not support the gemma4 paths".into());
1513 }
1514 let cfg = &self.cfg;
1515 let n_embd = cfg.n_embd as usize;
1516 let eps = cfg.rms_eps;
1517 let m = tokens.len();
1518
1519 let mut pos_d = Vec::with_capacity(m);
1520 let mut x: Vec<CudaSlice<f32>> = Vec::with_capacity(m);
1521 for (s, &token) in tokens.iter().enumerate() {
1522 pos_d.push(e.htod_i32(&[caches[s].pos as i32])?);
1523 x.push(e.htod(&self.embd.gather(n_embd, &[token]))?);
1524 }
1525 let mut pending: Vec<Option<(CudaSlice<f32>, CudaSlice<f32>)>> =
1526 (0..m).map(|_| None).collect();
1527
1528 // M2 (MEMRA_LOCKSTEP_GROUPED=1): MoE layers batch all m rows through
1529 // moe_ffn_lockstep — resident experts amortize weight reads across streams via the
1530 // grouped GEMM machinery; CPU-assigned experts keep per-row companion calls.
1531 let grouped = match std::env::var("MEMRA_LOCKSTEP_GROUPED").as_deref() {
1532 Ok("1") => true,
1533 Ok("0") => false,
1534 // Auto: grouped wins from m>=3 under the default q8 lanes (M2 gate 2026-07-23:
1535 // m=2 6.17 base vs 5.85 grouped; m=3 6.31 grouped; m=4 5.66 vs 5.34).
1536 _ => m >= 3,
1537 };
1538 // M4a (MEMRA_LOCKSTEP_BATCH_ATTN=1): EXPERIMENTAL DOOR, measured flat — default off.
1539 // Full-attention layers run their WEIGHT-BOUND work (q/k/v and output projections) once
1540 // at m instead of m times, KV-bound work stays per stream. Bit-identity PASS, but e2e
1541 // flat at m=2 (4.72/4.72) and -2% at m=3 (5.24 vs 5.35), 2026-07-25: full-attn is the
1542 // minority layer type here (GDN dominates), so the m-band weight-read saving covers few
1543 // layers and is cancelled by the norm->q8_1 fusion this path gives up on exactly those
1544 // layers, plus its gather/scatter copies. The primitive itself
1545 // (`full_attn_decode_batched`) stays as the m-band building block for a serve loop,
1546 // where batching happens across requests at higher m and no fused alternative exists.
1547 let batch_attn = matches!(
1548 std::env::var("MEMRA_LOCKSTEP_BATCH_ATTN").as_deref(),
1549 Ok("1")
1550 ) && m >= 2;
1551 let pos_cat = e.htod_i32(
1552 &caches
1553 .iter()
1554 .take(m)
1555 .map(|c| c.pos as i32)
1556 .collect::<Vec<_>>(),
1557 )?;
1558 let n_embd_total = n_embd * m;
1559 let mut xcat = e.uninit(n_embd_total)?;
1560 for (il, layer) in self.layers.iter().enumerate() {
1561 let anorm = layer.attn_norm.float_data();
1562 let fuse = std::env::var("MEMRA_NO_FUSE_NORMQ").is_err()
1563 && self.mixer_in_q8_1_fast(e, &layer.mixer);
1564 let mut mixed_rows: Vec<Option<CudaSlice<f32>>> = (0..m).map(|_| None).collect();
1565 if batch_attn && matches!(layer.mixer, Mixer::Full(_)) {
1566 // Unfused residual+norm into the contiguous m-band buffer. Bit-identical to the
1567 // fused arm by construction (add_rms_norm_q8_1 == add, rms_norm, quantize_q8_1);
1568 // the batched mixer quantizes all m rows in one call.
1569 for s in 0..m {
1570 if let Some((x1, f1)) = pending[s].take() {
1571 let mut x2 = e.uninit(n_embd)?;
1572 e.add(&x1, &f1, &mut x2, n_embd)?;
1573 x[s] = x2;
1574 }
1575 let mut hn = e.uninit(n_embd)?;
1576 e.rms_norm(&x[s], anorm, &mut hn, n_embd, 1, eps)?;
1577 e.copy_into(&mut xcat, s * n_embd, &hn, n_embd)?;
1578 }
1579 let Mixer::Full(fa) = &layer.mixer else {
1580 unreachable!()
1581 };
1582 let out_cat =
1583 self.full_attn_decode_batched(e, fa, &xcat, m, &pos_cat, caches, il)?;
1584 for s in 0..m {
1585 let mut mixed = e.uninit(n_embd)?;
1586 e.copy_view_into(
1587 &mut mixed,
1588 0,
1589 &out_cat.slice(s * n_embd..(s + 1) * n_embd),
1590 n_embd,
1591 )?;
1592 if grouped && matches!(&layer.ffn, crate::hybrid::Ffn::Moe(_)) {
1593 mixed_rows[s] = Some(mixed);
1594 } else {
1595 let (x1, ffn_out) =
1596 self.residual_norm_ffn(e, layer, &x[s], &mixed, n_embd, il, eps)?;
1597 pending[s] = Some((x1, ffn_out));
1598 }
1599 }
1600 } else {
1601 for s in 0..m {
1602 let pos = caches[s].pos;
1603 let taken = pending[s].take();
1604 let mixed = match (taken, fuse) {
1605 (Some((x1, f1)), true) => {
1606 let mut x2 = e.uninit(n_embd)?;
1607 let (hq, hd) =
1608 e.add_rms_norm_q8_1(&x1, &f1, anorm, &mut x2, n_embd, 1, eps)?;
1609 x[s] = x2;
1610 let h0 = e.zeros(0)?;
1611 match &layer.mixer {
1612 Mixer::Full(fa) => self.full_attn_decode_pre(
1613 e,
1614 fa,
1615 &h0,
1616 Some((&hq, &hd)),
1617 &pos_d[s],
1618 pos,
1619 &mut caches[s],
1620 il,
1621 )?,
1622 Mixer::Linear(la) => self.linear_attn_decode_pre(
1623 e,
1624 la,
1625 &h0,
1626 &hq,
1627 &hd,
1628 &mut caches[s],
1629 il,
1630 false,
1631 )?,
1632 Mixer::Mla(_) => crate::hybrid::mla_forward_unimplemented(),
1633 }
1634 }
1635 (taken, _) => {
1636 if let Some((x1, f1)) = taken {
1637 let mut x2 = e.uninit(n_embd)?;
1638 e.add(&x1, &f1, &mut x2, n_embd)?;
1639 x[s] = x2;
1640 }
1641 self.attn_in_norm_mixer(
1642 e,
1643 layer,
1644 &x[s],
1645 &pos_d[s],
1646 pos,
1647 &mut caches[s],
1648 il,
1649 n_embd,
1650 eps,
1651 )?
1652 }
1653 };
1654 if grouped && matches!(&layer.ffn, crate::hybrid::Ffn::Moe(_)) {
1655 mixed_rows[s] = Some(mixed);
1656 } else {
1657 let (x1, ffn_out) =
1658 self.residual_norm_ffn(e, layer, &x[s], &mixed, n_embd, il, eps)?;
1659 pending[s] = Some((x1, ffn_out));
1660 }
1661 }
1662 }
1663 if grouped {
1664 if let crate::hybrid::Ffn::Moe(moe_weights) = &layer.ffn {
1665 // Per-stream add+norm (identical math to residual_norm_ffn's MoE arm),
1666 // rows batched for the cross-stream MoE stage, outputs split back.
1667 let pnorm = layer.post_attn_norm.float_data();
1668 let mut zbatch = e.uninit(n_embd_total)?;
1669 let mut x1s: Vec<CudaSlice<f32>> = Vec::with_capacity(m);
1670 for s in 0..m {
1671 let mixed = mixed_rows[s].take().expect("grouped MoE row missing");
1672 let mut x1 = e.uninit(n_embd)?;
1673 let mut z = e.uninit(n_embd)?;
1674 e.add_rms_norm(&x[s], &mixed, pnorm, &mut x1, &mut z, n_embd, 1, eps)?;
1675 e.copy_view_into(&mut zbatch, s * n_embd, &z.slice(0..n_embd), n_embd)?;
1676 x1s.push(x1);
1677 }
1678 let max_block = self.max_moe_block();
1679 let ffn_all =
1680 self.moe_ffn_lockstep(e, moe_weights, &zbatch, m, il as u16, max_block)?;
1681 for (s, x1) in x1s.into_iter().enumerate() {
1682 let mut out = e.uninit(n_embd)?;
1683 e.copy_view_into(
1684 &mut out,
1685 0,
1686 &ffn_all.slice(s * n_embd..(s + 1) * n_embd),
1687 n_embd,
1688 )?;
1689 pending[s] = Some((x1, out));
1690 }
1691 }
1692 }
1693 }
1694
1695 let mut logits_host = Vec::with_capacity(m);
1696 for s in 0..m {
1697 if let Some((x1, f1)) = pending[s].take() {
1698 let mut x2 = e.uninit(n_embd)?;
1699 e.add(&x1, &f1, &mut x2, n_embd)?;
1700 x[s] = x2;
1701 }
1702 let mut hn = e.uninit(n_embd)?;
1703 e.rms_norm(
1704 &x[s],
1705 self.output_norm.float_data(),
1706 &mut hn,
1707 n_embd,
1708 1,
1709 eps,
1710 )?;
1711 let logits = e.matmul(&self.output, &hn, 1)?;
1712 logits_host.push(e.dtoh(&logits)?);
1713 caches[s].pos += 1;
1714 }
1715 Ok(logits_host)
1716 }
1717
1718 /// DEVICE-COUNTER decode step (CUDA-GRAPH-PLAN Phase 2). A clone of `decode_step_h` that removes
1719 /// the two per-step VARYING host kernel-args by reading them from device counters:
1720 /// 1. the KV-append write slot -> per-layer `kvl.len_d` (device i32[1])
1721 /// 2. the fa_decode t_kv bound -> the same `kvl.len_d` after `inc_seqlen`
1722 /// plus it keeps the token id + rope pos DEVICE-RESIDENT (embed_gather_device, device rope pos,
1723 /// argmax_token_device). NO graph capture yet — runs the kernels eagerly through the counter
1724 /// path. Must be BIT-IDENTICAL to `decode_step_h`'s token stream (the gate).
1725 ///
1726 /// Args: `token_d` = resident device token id [1] (this step's input token); `pos_d` = resident
1727 /// device rope pos i32[1] (== cache.pos at entry; INCREMENTED in-path); `embd_gpu` = resident embed
1728 /// table; (qt,row_bytes) from EmbedHost::qt_and_row_bytes. Returns the NEXT token id device buffer.
1729 /// `cache.pos` and each `kvl.len`/`kvl.len_d` are advanced to match `decode_step_h`.
1730 pub fn decode_step_dc(
1731 &self,
1732 e: &Engine,
1733 token_d: &CudaSlice<u32>,
1734 pos_d: &mut CudaSlice<i32>,
1735 embd_gpu: &CudaSlice<u8>,
1736 embd_qt: i32,
1737 embd_row_bytes: usize,
1738 cache: &mut Cache,
1739 n_vocab: usize,
1740 ) -> Result<CudaSlice<u32>, Box<dyn std::error::Error>> {
1741 // Route gemma4 to ITS dc twin (mirrors decode_step_h): the generic walk below is the
1742 // qwen-class layer stack — running gemma weights through it produced the argmax-INIT
1743 // passthrough the round-45 g12 gate caught (first Hopper gating of this lane).
1744 if self.is_gemma4_e4b() {
1745 return Err("e4b has no device-counter decode step (dc/graph unwired)".into());
1746 }
1747 // PP DOOR: fail closed (pp2-hardening 2026-08-06). Same hole the batched path had —
1748 // the dc walk below is `for (il, layer) in self.layers.iter().enumerate()` on one
1749 // stream, with no stage split, so a sharded cross-device placement would peer-read
1750 // every remote layer's weights per step. Sits BEFORE the gemma4 delegate because
1751 // that twin has the same unsplit shape. The graph-capture path (`decode_step_dc_cap*`)
1752 // is covered transitively: it captures this same kernel chain, and its drivers reach
1753 // dc first — but a future capture path that does NOT is why the guard is a shared
1754 // helper (`pp::refuse_unsplit_if_remote`) rather than four copies.
1755 crate::pp::refuse_unsplit_if_remote(
1756 "decode_step_dc",
1757 "use the eager pp arm (decode_step_h), which IS stage-split",
1758 )?;
1759 if self.uses_gemma_program() {
1760 return self.gemma4_decode_step_dc(
1761 e,
1762 token_d,
1763 pos_d,
1764 embd_gpu,
1765 embd_qt,
1766 embd_row_bytes,
1767 cache,
1768 n_vocab,
1769 None,
1770 );
1771 }
1772 let cfg = &self.cfg;
1773 let n_embd = cfg.n_embd as usize;
1774 let eps = cfg.rms_eps;
1775
1776 // embed the single (DEVICE-resident) token -> [1, n_embd], no host round-trip of the id.
1777 let mut x = e.embed_gather_device(embd_gpu, token_d, n_embd, embd_qt, embd_row_bytes)?;
1778
1779 for (il, layer) in self.layers.iter().enumerate() {
1780 // attn-input NORM-FUSION (dc path); bit-identical to decode_step_h (Phase-2 gate).
1781 let mixed = self.attn_in_norm_mixer_dc(e, layer, &x, pos_d, cache, il, n_embd, eps)?;
1782
1783 // DECODE NORM-FUSION LEVER (residual_norm_ffn): see decode_step_h. Shared helper -> dc
1784 // path stays bit-identical to decode_step_h's token stream (the Phase-2 gate).
1785 let (x1, ffn_out) = self.residual_norm_ffn(e, layer, &x, &mixed, n_embd, il, eps)?;
1786 // MEMRA_TG_PROBE_LAYER diagnostics (token-graph bisection): dump layer K's
1787 // attention output and post-FFN residual through the real eager path.
1788 if std::env::var("MEMRA_TG_PROBE_LAYER")
1789 .ok()
1790 .and_then(|v| v.parse::<usize>().ok())
1791 == Some(il)
1792 {
1793 use std::io::Write;
1794 let mut xp = e.uninit(n_embd)?;
1795 e.add(&x1, &ffn_out, &mut xp, n_embd)?;
1796 let (pm, px) = (e.dtoh(&mixed)?, e.dtoh(&xp)?);
1797 for (path, data) in [
1798 ("/root/eager-probe-mixed.bin", &pm),
1799 ("/root/eager-probe-x.bin", &px),
1800 ] {
1801 let mut fo = std::fs::OpenOptions::new()
1802 .create(true)
1803 .append(true)
1804 .open(path)?;
1805 for v in data {
1806 fo.write_all(&v.to_le_bytes())?;
1807 }
1808 }
1809 }
1810 let mut x2 = e.uninit(n_embd)?;
1811 e.add(&x1, &ffn_out, &mut x2, n_embd)?;
1812 x = x2;
1813 }
1814
1815 let mut hn = e.uninit(n_embd)?;
1816 e.rms_norm(&x, self.output_norm.float_data(), &mut hn, n_embd, 1, eps)?;
1817 let logits = e.matmul(&self.output, &hn, 1)?;
1818 // device argmax -> next token id stays resident (no logits dtoh).
1819 let next_tok = e.argmax_token_device(&logits, n_vocab)?;
1820 // advance rope pos counter on-device (replaces the per-step htod_i32(&[pos])).
1821 e.inc_seqlen(pos_d)?;
1822 cache.pos += 1;
1823 Ok(next_tok)
1824 }
1825
1826 /// CAPTURE body for CUDA-graph replay (CUDA-GRAPH-PLAN Phase 3). One full decode step enqueued
1827 /// entirely on `e.stream()` with ZERO host sync and ZERO per-step varying host kernel-args:
1828 /// - embed reads the PERSISTENT device `token_d` (last step's argmax), writes scratch `x`.
1829 /// - full-attn layers size n_splits from `bucket_max` (fixed for this capture); the kernel reads
1830 /// the ACTUAL t_kv from the device counter `kvl.len_d`. KV append + device-counter inc happen
1831 /// in-graph. The host `kvl.len`/`cache.pos` are NOT advanced here (the driver advances the host
1832 /// mirrors once per replay; only the DEVICE counters advance inside the graph).
1833 /// - linear-attn layers use the persistent-state variant (copy-back, stable pointers).
1834 /// - lm_head -> parallel 2-pass argmax (`argmax_partial_f32`+`argmax_final_f32`) writes the
1835 /// next id into the PERSISTENT `token_d`.
1836 /// - `inc_seqlen(pos_d)` advances the rope-pos device counter in-graph.
1837 /// Captured ONCE per `bucket_max`; replayed for every t_kv in that bucket. Bit-identical to eager
1838 /// when `bucket_max` reproduces eager's n_splits for the replayed t_kv (the bucket-key contract).
1839 pub fn decode_step_dc_cap(
1840 &self,
1841 e: &Engine,
1842 token_d: &mut CudaSlice<u32>,
1843 pos_d: &mut CudaSlice<i32>,
1844 embd_gpu: &CudaSlice<u8>,
1845 embd_qt: i32,
1846 embd_row_bytes: usize,
1847 cache: &mut Cache,
1848 n_vocab: usize,
1849 bucket_max: usize,
1850 ) -> Result<(), Box<dyn std::error::Error>> {
1851 self.decode_step_dc_cap_masked(
1852 e,
1853 token_d,
1854 pos_d,
1855 embd_gpu,
1856 embd_qt,
1857 embd_row_bytes,
1858 cache,
1859 n_vocab,
1860 bucket_max,
1861 None,
1862 )
1863 }
1864
1865 /// `decode_step_dc_cap` + GRAMMAR MASK (constrained decoding): with `mask =
1866 /// Some((buf, words))`, mask_logits_f32 bans the packed bitset's unset ids IN the
1867 /// captured graph — a stable-pointer read between lm_head and the in-graph argmax
1868 /// (the KV-pointer pattern: contents change per step, address is baked). `None` is
1869 /// bit-for-bit the unmasked capture.
1870 #[allow(clippy::too_many_arguments)]
1871 pub fn decode_step_dc_cap_masked(
1872 &self,
1873 e: &Engine,
1874 token_d: &mut CudaSlice<u32>,
1875 pos_d: &mut CudaSlice<i32>,
1876 embd_gpu: &CudaSlice<u8>,
1877 embd_qt: i32,
1878 embd_row_bytes: usize,
1879 cache: &mut Cache,
1880 n_vocab: usize,
1881 bucket_max: usize,
1882 mask: Option<(&CudaSlice<u32>, usize)>,
1883 ) -> Result<(), Box<dyn std::error::Error>> {
1884 let cfg = &self.cfg;
1885 let n_embd = cfg.n_embd as usize;
1886 let eps = cfg.rms_eps;
1887
1888 let mut x = e.embed_gather_device(embd_gpu, token_d, n_embd, embd_qt, embd_row_bytes)?;
1889
1890 for (il, layer) in self.layers.iter().enumerate() {
1891 // attn-input NORM-FUSION (capture path); capture-safe + bit-identical to eager.
1892 let mixed = self.attn_in_norm_mixer_dc_cap(
1893 e, layer, &x, pos_d, cache, il, bucket_max, n_embd, eps,
1894 )?;
1895 // DECODE NORM-FUSION LEVER (residual_norm_ffn): see decode_step_aux. Shared helper keeps
1896 // the capture path bit-identical to eager by construction.
1897 let (x1, ffn_out) = self.residual_norm_ffn(e, layer, &x, &mixed, n_embd, il, eps)?;
1898 // MEMRA_TG_PROBE_LAYER diagnostics (token-graph bisection): dump layer K's
1899 // attention output and post-FFN residual through the real eager path.
1900 if std::env::var("MEMRA_TG_PROBE_LAYER")
1901 .ok()
1902 .and_then(|v| v.parse::<usize>().ok())
1903 == Some(il)
1904 {
1905 use std::io::Write;
1906 let mut xp = e.uninit(n_embd)?;
1907 e.add(&x1, &ffn_out, &mut xp, n_embd)?;
1908 let (pm, px) = (e.dtoh(&mixed)?, e.dtoh(&xp)?);
1909 for (path, data) in [
1910 ("/root/eager-probe-mixed.bin", &pm),
1911 ("/root/eager-probe-x.bin", &px),
1912 ] {
1913 let mut fo = std::fs::OpenOptions::new()
1914 .create(true)
1915 .append(true)
1916 .open(path)?;
1917 for v in data {
1918 fo.write_all(&v.to_le_bytes())?;
1919 }
1920 }
1921 }
1922 let mut x2 = e.uninit(n_embd)?;
1923 e.add(&x1, &ffn_out, &mut x2, n_embd)?;
1924 x = x2;
1925 }
1926
1927 let mut hn = e.uninit(n_embd)?;
1928 e.rms_norm(&x, self.output_norm.float_data(), &mut hn, n_embd, 1, eps)?;
1929 let mut logits = e.matmul(&self.output, &hn, 1)?;
1930 // GRAMMAR MASK: ban before the argmax reads the row (masked argmax == host
1931 // masked-argmax — -FLT_MAX is the argmax kernels' init sentinel).
1932 if let Some((m, words)) = mask {
1933 e.mask_logits_col(&mut logits, m, 0, n_vocab, words)?;
1934 }
1935 // argmax into the PERSISTENT token_d (next step's embed reads it) — same buffer pointer baked
1936 // at capture, written each replay, so the token id never round-trips to host in steady state.
1937 e.argmax_token_device_into(&logits, token_d, n_vocab)?;
1938 e.inc_seqlen(pos_d)?;
1939 Ok(())
1940 }
1941
1942 /// CUDA-GRAPH decode driver (CUDA-GRAPH-PLAN Phase 3). Primes the prompt EAGERLY (device-counter
1943 /// `decode_step_dc`, advancing host + device counters together), then generates `max_new` tokens by
1944 /// CUDA-graph REPLAY: per step it picks the t_kv bucket key, captures a graph on first sight of that
1945 /// key (re-using the SAME persistent counters/cache so replays continue the sequence), and replays.
1946 /// The argmax-written next token stays device-resident in `gs.token_d`; we read back only the [1]
1947 /// u32 after each launch (the gate compares it; a real server can defer this). Returns the generated
1948 /// token ids. Greedy. Bit-identical to eager `decode_step` (the gate).
1949 ///
1950 /// CAPTURE STATE HYGIENE: `capture_graph` runs the step body 3x (2 warmup + 1 capture), each of
1951 /// which mutates the device KV/conv/ssm/counter state. We SNAPSHOT the cache + device counters +
1952 /// token id before capturing and RESTORE them after, so the 3 throwaway runs leave zero residue and
1953 /// replay resumes from the true pre-capture state.
1954 pub fn generate_graph(
1955 &self,
1956 e: &Engine,
1957 gs: &mut GraphDecodeState,
1958 prompt: &[u32],
1959 max_new: usize,
1960 ) -> Result<Vec<u32>, Box<dyn std::error::Error>> {
1961 if !self.rewrite_allowed(memra_gguf::execution_manifest::RewriteSurface::DecodeGraph) {
1962 if !self.rewrite_allowed(memra_gguf::execution_manifest::RewriteSurface::DecodeEager) {
1963 return Err("neither graph nor eager decode rewrite is qualified".into());
1964 }
1965 static ONCE: std::sync::Once = std::sync::Once::new();
1966 ONCE.call_once(|| {
1967 eprintln!(
1968 "[rewrite] decode-graph.v1 unqualified; using receipt-backed native eager decode"
1969 );
1970 });
1971 return self.generate(e, prompt, max_new);
1972 }
1973 let n_embd = self.cfg.n_embd as usize;
1974 let head_dim = self.cfg.head_dim_k as usize;
1975 let (qt, row_bytes) = self.embd.qt_and_row_bytes(n_embd);
1976
1977 // EVENT TRACKING OFF for the WHOLE graph-decode session. cudarc records a per-CudaSlice event
1978 // (the Engine is in multi-stream mode via copy_stream) and inserts `stream.wait(event)` on every
1979 // kernel arg whose buffer was touched — those waits are illegal inside a capture region. The
1980 // captured decode step is strictly single-stream, so this tracking is unnecessary. Disable it
1981 // BEFORE allocating ANY buffer the captured graph will reference (cache, embd, counters,
1982 // scratch) so none of them carry events. SAFETY: decode-dc touches only gpu.stream.
1983 let was_tracking = e.ctx().is_event_tracking();
1984 if was_tracking {
1985 unsafe {
1986 e.ctx().disable_event_tracking();
1987 }
1988 }
1989 let r = self.generate_graph_inner(e, gs, prompt, max_new, n_embd, head_dim, qt, row_bytes);
1990 if was_tracking {
1991 unsafe {
1992 e.ctx().enable_event_tracking();
1993 }
1994 }
1995 r
1996 }
1997
1998 fn generate_graph_inner(
1999 &self,
2000 e: &Engine,
2001 gs: &mut GraphDecodeState,
2002 prompt: &[u32],
2003 max_new: usize,
2004 n_embd: usize,
2005 head_dim: usize,
2006 qt: i32,
2007 row_bytes: usize,
2008 ) -> Result<Vec<u32>, Box<dyn std::error::Error>> {
2009 let _ = n_embd;
2010 let embd_gpu = e.upload_u8(&self.embd.raw)?;
2011 let max_ctx = prompt.len() + max_new + 8;
2012 let mut cache = Cache::new(e, &self.cfg, max_ctx)?;
2013
2014 // (Re)create the persistent counters tracking-OFF so they carry no events (the caller's
2015 // GraphDecodeState::new may have allocated them with tracking on).
2016 gs.pos_d = e.htod_i32(&[0])?;
2017 gs.token_d = e.stream().clone_htod(&[0u32])?;
2018 // PRIME eagerly: feed each prompt token; advance host + device counters together.
2019 let mut next_in = 0u32;
2020 for &tok in prompt {
2021 e.set_u32_one(&mut gs.token_d, tok)?;
2022 let nt = self.decode_step_dc(
2023 e,
2024 &gs.token_d,
2025 &mut gs.pos_d,
2026 &embd_gpu,
2027 qt,
2028 row_bytes,
2029 &mut cache,
2030 /*n_vocab*/ self.output.out_features(),
2031 )?;
2032 next_in = e.dtoh_u32_one(&nt)?;
2033 }
2034 // gs.token_d now must hold the first generated INPUT token (= argmax of the last prime step).
2035 e.set_u32_one(&mut gs.token_d, next_in)?;
2036
2037 // gemma4 rides ITS graph machinery (per-bucket captures + alloc-free slots; same token
2038 // stream convention: first generated token is out[0]) — graph_decode_loop below captures
2039 // the qwen-class dc step (the round-45 g12 illegal-address find).
2040 if self.uses_gemma_program() {
2041 let (toks, _reason) = self.gemma4_generate_graph(
2042 e,
2043 cache.pos,
2044 next_in,
2045 &mut cache,
2046 max_new,
2047 &[],
2048 |_| true,
2049 )?;
2050 gs.captures += 1;
2051 return Ok(toks);
2052 }
2053
2054 let mut out = Vec::with_capacity(max_new);
2055 self.graph_decode_loop(
2056 e,
2057 gs,
2058 &mut cache,
2059 &embd_gpu,
2060 qt,
2061 row_bytes,
2062 head_dim,
2063 max_new,
2064 |tok| {
2065 out.push(tok);
2066 None
2067 },
2068 )?;
2069 Ok(out)
2070 }
2071
2072 /// The CUDA-graph EXEC-UPDATE replay loop over an already-primed cache (2026-07-15,
2073 /// the E4B graph-exec pattern generalized): capture the dc step per KERNEL-CLASS
2074 /// SEGMENT, classify its fa nodes (`graph_update::fa_plan` — symbol list is
2075 /// model-generic), then per token retune the fa split geometry to the LIVE eager
2076 /// ladder (`fa_apply` keeps graph and eager in FP lockstep — bit-exact) and replay.
2077 /// The previous per-bucket-key capture map recaptured on every ladder rung
2078 /// (32 recaptures/256 tokens = 97 vs 128 tok/s eager; decode-bench 2026-07-15).
2079 ///
2080 /// SEGMENTS (round 45, the q35 graph-gate dig): exec-update can retune split counts
2081 /// but can NOT swap kernels — a session spanning an eager KERNEL-CLASS boundary
2082 /// (fa_vec floor, the v4 max, the fa512 floor) replayed the capture-time kernel
2083 /// against a different eager kernel below the boundary: valid softmax, different
2084 /// fold order, and the first near-tie flips the stream (q35: deterministic 144/256
2085 /// from step 110, exactly the scalar->vec crossing; regime pinned either way =
2086 /// BIT-IDENTICAL 256/256). One capture per crossed class boundary (2-3/session,
2087 /// not per rung) keeps graph and eager on the SAME kernel at every t_kv.
2088 ///
2089 /// Callers must have synced gs.token_d (= the FIRST generated token), gs.pos_d
2090 /// (= cache.pos) and every kvl.len_d (= kvl.len). Event tracking must be OFF.
2091 #[allow(clippy::too_many_arguments)]
2092 pub(crate) fn graph_decode_loop(
2093 &self,
2094 e: &Engine,
2095 gs: &mut GraphDecodeState,
2096 cache: &mut Cache,
2097 embd_gpu: &CudaSlice<u8>,
2098 qt: i32,
2099 row_bytes: usize,
2100 head_dim: usize,
2101 max_new: usize,
2102 mut emit: impl FnMut(u32) -> Option<StopReason>,
2103 ) -> Result<StopReason, Box<dyn std::error::Error>> {
2104 let _ = head_dim;
2105 let n_vocab = self.output.out_features();
2106 let final_max = cache.pos + max_new + 1;
2107
2108 // first generated token = argmax of the last prime step (emit before replay 1).
2109 let first = e.dtoh_u32_one(&gs.token_d)?;
2110 if let Some(r) = emit(first) {
2111 return Ok(r);
2112 }
2113 let mut done = 1usize;
2114 while done < max_new {
2115 let (graph, mut plan, seg_end) = self
2116 .graph_capture_segment(e, cache, gs, embd_gpu, qt, row_bytes, n_vocab, final_max)?;
2117
2118 while done < max_new && cache.pos + 1 <= seg_end {
2119 // retune fa geometry to the live t_kv AFTER this replay's in-graph append.
2120 crate::graph_update::fa_apply(
2121 &graph,
2122 &mut plan,
2123 cache.pos + 1,
2124 crate::fa_split_keys,
2125 )?;
2126 graph.launch()?;
2127 cache.pos += 1;
2128 for kvl in cache.kv.iter_mut().filter_map(|k| k.as_mut()) {
2129 kvl.len += 1;
2130 }
2131 // read back the [1] u32 next token (the only D2H in steady state).
2132 let tok = e.dtoh_u32_one(&gs.token_d)?;
2133 done += 1;
2134 if let Some(r) = emit(tok) {
2135 return Ok(r);
2136 }
2137 }
2138 }
2139 Ok(StopReason::MaxNew)
2140 }
2141
2142 /// Step-wise CUDA-graph decode session (ARCHITECTURE-H100.md graph-serving lane,
2143 /// 2026-07-26): generate_graph's prime+capture lifted into a long-lived session so a
2144 /// SERVING scheduler can replay ONE step per tick instead of blocking a whole
2145 /// generation. Serving policy (measured): graphs win only at B=1 (214 solo vs 425
2146 /// aggregate batched-eager at B=4) — this is the single-interactive-session path.
2147 /// Capture discipline is generate_graph's verbatim: event tracking must be OFF for
2148 /// every buffer the graph references (new() toggles it), capture at bucket_max =
2149 /// pos + max_new + 1, fa geometry retuned per step (fa_apply, FP lockstep with eager).
2150 pub fn graph_session_new(
2151 &self,
2152 e: &Engine,
2153 prompt: &[u32],
2154 max_new: usize,
2155 ) -> Result<(GraphSession, u32), Box<dyn std::error::Error>> {
2156 let n_embd = self.cfg.n_embd as usize;
2157 let (qt, row_bytes) = self.embd.qt_and_row_bytes(n_embd);
2158 let was_tracking = e.ctx().is_event_tracking();
2159 if was_tracking {
2160 unsafe {
2161 e.ctx().disable_event_tracking();
2162 }
2163 }
2164 let r = self.graph_session_new_inner(e, prompt, max_new, qt, row_bytes);
2165 if was_tracking {
2166 unsafe {
2167 e.ctx().enable_event_tracking();
2168 }
2169 }
2170 r
2171 }
2172
2173 fn graph_session_new_inner(
2174 &self,
2175 e: &Engine,
2176 prompt: &[u32],
2177 max_new: usize,
2178 qt: i32,
2179 row_bytes: usize,
2180 ) -> Result<(GraphSession, u32), Box<dyn std::error::Error>> {
2181 let n_vocab = self.output.out_features();
2182 let embd_gpu = e.upload_u8(&self.embd.raw)?;
2183 let max_ctx = prompt.len() + max_new + 8;
2184 let mut cache = Cache::new(e, &self.cfg, max_ctx)?;
2185 let mut gs = GraphDecodeState::new(e)?;
2186 gs.pos_d = e.htod_i32(&[0])?;
2187 gs.token_d = e.stream().clone_htod(&[0u32])?;
2188 // prime (dc path — device counters advance with the host)
2189 let mut next_in = 0u32;
2190 for &tok in prompt {
2191 e.set_u32_one(&mut gs.token_d, tok)?;
2192 let nt = self.decode_step_dc(
2193 e,
2194 &gs.token_d,
2195 &mut gs.pos_d,
2196 &embd_gpu,
2197 qt,
2198 row_bytes,
2199 &mut cache,
2200 n_vocab,
2201 )?;
2202 next_in = e.dtoh_u32_one(&nt)?;
2203 }
2204 e.set_u32_one(&mut gs.token_d, next_in)?;
2205 self.graph_session_capture(
2206 e, cache, gs, embd_gpu, max_new, qt, row_bytes, n_vocab, None, 0,
2207 )
2208 }
2209
2210 /// GraphSession over an ALREADY-PRIMED cache (round 35): keeps the chunked-prefill
2211 /// TTFT. graph_session_new's token-wise re-prime made solo long-prompt promotion a
2212 /// net ~3x END-TO-END LOSS (measured live: 871-tok prompt + 400 gen = 6.4s vs ~2.2s
2213 /// eager). Device counters sync from host state; capture recipe unchanged.
2214 /// Requires event tracking OFF (engine default; MEMRA_EVT=1 callers must not use this
2215 /// — the primed cache's buffers would carry events, illegal inside capture).
2216 pub fn graph_session_from_cache(
2217 &self,
2218 e: &Engine,
2219 cache: Cache,
2220 first_token: u32,
2221 max_new: usize,
2222 ) -> Result<(GraphSession, u32), Box<dyn std::error::Error>> {
2223 self.graph_session_from_cache_masked(e, cache, first_token, max_new, None)
2224 }
2225
2226 /// `graph_session_from_cache` + GRAMMAR MASK (constrained decoding, 2026-08-03):
2227 /// `mask_init = Some(packed bitset)` allocates the session's stable mask buffer
2228 /// (tracking is OFF here — capture-legal), seeds it with the FIRST step's mask, and
2229 /// captures mask_logits_f32 into the graphed step. The caller re-uploads contents
2230 /// per step via `GraphSession::upload_mask` — same stable-pointer discipline as the
2231 /// KV len_d counters. `None` = the unmasked session, byte-identical.
2232 pub fn graph_session_from_cache_masked(
2233 &self,
2234 e: &Engine,
2235 mut cache: Cache,
2236 first_token: u32,
2237 max_new: usize,
2238 mask_init: Option<&[u32]>,
2239 ) -> Result<(GraphSession, u32), Box<dyn std::error::Error>> {
2240 if e.ctx().is_event_tracking() {
2241 return Err(
2242 "graph_session_from_cache requires event tracking OFF (MEMRA_EVT unset)".into(),
2243 );
2244 }
2245 let n_embd = self.cfg.n_embd as usize;
2246 let (qt, row_bytes) = self.embd.qt_and_row_bytes(n_embd);
2247 let n_vocab = self.output.out_features();
2248 let embd_gpu = e.upload_u8(&self.embd.raw)?;
2249 let mut gs = GraphDecodeState::new(e)?;
2250 gs.pos_d = e.htod_i32(&[cache.pos as i32])?;
2251 gs.token_d = e.stream().clone_htod(&[first_token])?;
2252 for kvl in cache.kv.iter_mut().flatten() {
2253 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
2254 }
2255 let mask_dev = match mask_init {
2256 Some(w) => Some(e.htod_u32_v(w)?),
2257 None => None,
2258 };
2259 let mask_words = mask_init.map(|w| w.len()).unwrap_or(0);
2260 self.graph_session_capture(
2261 e, cache, gs, embd_gpu, max_new, qt, row_bytes, n_vocab, mask_dev, mask_words,
2262 )
2263 }
2264
2265 /// Eager fa kernel-class fingerprint at a given t_kv: the fa_vec pick plus the
2266 /// intra-vec variant switches (v4 max, fa512 floor) plus the split-ladder rung.
2267 /// fa_apply handles split-count changes WITHIN a rung; anything that changes this
2268 /// tuple needs a fresh capture (bucket_max drives the capture-time kernel pick).
2269 /// Round 45; LADDER RUNG ADDED 2026-08-02 (lane/ladder-3072): the dc kernels derive
2270 /// their in-kernel partition from the CAPTURED split_keys arg (ns_eff =
2271 /// ceil(T_kv/split_keys) — the ONE-PARTITION law), and fa_apply retunes only
2272 /// n_splits/grid. A capture whose segment straddled a ladder rung therefore replayed
2273 /// the far side's partition against eager's near side — same math, different FP fold
2274 /// order, and the first near-tie flips the stream (latent at the old 3072 rung: kat
2275 /// P=3000 passed on logit margins; exposed by the 512 rung: kat P=400 flipped 97/160).
2276 /// With the rung in the fingerprint a capture never straddles it, so the captured
2277 /// split_keys equals the live ladder on every replay — bit-exact at every t_kv.
2278 pub(crate) fn fa_class_of(&self, e: &Engine, t_kv: usize) -> (bool, bool, bool, usize) {
2279 let head_dim = self.cfg.head_dim_k as usize;
2280 let nkv = self.cfg.n_head_kv as usize;
2281 let g_fp8 = Engine::kv_fp8_on();
2282 (
2283 e.fa_geom_eager(t_kv, head_dim, nkv, g_fp8).0,
2284 crate::fa_v4_at_pub(t_kv),
2285 head_dim == 512 && t_kv >= crate::fa512_min_tkv(),
2286 crate::fa_split_keys_pub(t_kv, nkv),
2287 )
2288 }
2289
2290 /// Last t_kv (clamped to `final_max`) sharing `start`'s eager kernel class.
2291 pub(crate) fn fa_segment_end(&self, e: &Engine, start: usize, final_max: usize) -> usize {
2292 let cls = self.fa_class_of(e, start);
2293 let mut end = start;
2294 while end < final_max && self.fa_class_of(e, end + 1) == cls {
2295 end += 1;
2296 }
2297 end
2298 }
2299
2300 /// Capture one kernel-class segment: snapshot/rollback the warmup runs, capture the
2301 /// dc step at bucket_max = the segment's last t_kv, fa_plan. Shared by the session
2302 /// creation, the session's recapture-on-cross, and graph_decode_loop.
2303 #[allow(clippy::too_many_arguments)]
2304 pub(crate) fn graph_capture_segment(
2305 &self,
2306 e: &Engine,
2307 cache: &mut Cache,
2308 gs: &mut GraphDecodeState,
2309 embd_gpu: &CudaSlice<u8>,
2310 qt: i32,
2311 row_bytes: usize,
2312 n_vocab: usize,
2313 final_max: usize,
2314 ) -> Result<
2315 (
2316 cudarc::driver::CudaGraph,
2317 Vec<crate::graph_update::FaMain>,
2318 usize,
2319 ),
2320 Box<dyn std::error::Error>,
2321 > {
2322 self.graph_capture_segment_masked(
2323 e, cache, gs, embd_gpu, qt, row_bytes, n_vocab, final_max, None,
2324 )
2325 }
2326
2327 /// `graph_capture_segment` + optional in-graph grammar mask (see decode_step_dc_cap_masked).
2328 #[allow(clippy::too_many_arguments)]
2329 pub(crate) fn graph_capture_segment_masked(
2330 &self,
2331 e: &Engine,
2332 cache: &mut Cache,
2333 gs: &mut GraphDecodeState,
2334 embd_gpu: &CudaSlice<u8>,
2335 qt: i32,
2336 row_bytes: usize,
2337 n_vocab: usize,
2338 final_max: usize,
2339 mask: Option<(&CudaSlice<u32>, usize)>,
2340 ) -> Result<
2341 (
2342 cudarc::driver::CudaGraph,
2343 Vec<crate::graph_update::FaMain>,
2344 usize,
2345 ),
2346 Box<dyn std::error::Error>,
2347 > {
2348 let t0 = cache.pos + 1;
2349 let seg_end = self.fa_segment_end(e, t0, final_max);
2350 let bucket_max = seg_end;
2351 let snap = cache.snapshot(e)?;
2352 let pos_save = e.dtoh_i32_one(&gs.pos_d)?;
2353 let len_save: Vec<Option<i32>> = cache
2354 .kv
2355 .iter()
2356 .map(|k| k.as_ref().map(|kvl| e.dtoh_i32_one(&kvl.len_d).unwrap()))
2357 .collect();
2358 let tok_save = e.dtoh_u32_one(&gs.token_d)?;
2359 let graph = {
2360 let GraphDecodeState { token_d, pos_d, .. } = gs;
2361 let token_d: &mut CudaSlice<u32> = token_d;
2362 let pos_d: &mut CudaSlice<i32> = pos_d;
2363 let cache_ref = &mut *cache;
2364 e.capture_graph(|e| {
2365 self.decode_step_dc_cap_masked(
2366 e, token_d, pos_d, embd_gpu, qt, row_bytes, cache_ref, n_vocab, bucket_max,
2367 mask,
2368 )
2369 })?
2370 };
2371 gs.captures += 1;
2372 cache.rollback(e, &snap, 0)?;
2373 e.set_i32_one(&mut gs.pos_d, pos_save)?;
2374 for (il, ls) in len_save.iter().enumerate() {
2375 if let (Some(kvl), Some(v)) = (cache.kv[il].as_mut(), ls) {
2376 e.set_i32_one(&mut kvl.len_d, *v)?;
2377 }
2378 }
2379 e.set_u32_one(&mut gs.token_d, tok_save)?;
2380 let plan = crate::graph_update::fa_plan(&graph)?;
2381 if std::env::var("MEMRA_GRAPH_CENSUS").as_deref() == Ok("1") {
2382 eprintln!(
2383 "[graph-census] segment t_kv {t0}..={seg_end} fa_plan mains: {}",
2384 plan.len()
2385 );
2386 if let Ok(c) = crate::graph_update::node_census(&graph) {
2387 eprintln!("[graph-census] {c:?}");
2388 }
2389 }
2390 Ok((graph, plan, seg_end))
2391 }
2392
2393 /// Measurement door for `graph_session_recapture` (graph-allocfree-probe): the capture
2394 /// path timed WITHOUT the prompt prime. Same call the live step() makes at a
2395 /// kernel-class crossing.
2396 pub fn graph_session_recapture_pub(
2397 &self,
2398 e: &Engine,
2399 sess: &mut GraphSession,
2400 ) -> Result<(), Box<dyn std::error::Error>> {
2401 self.graph_session_recapture(e, sess)
2402 }
2403
2404 /// Session recapture at a kernel-class boundary (called by GraphSession::step).
2405 /// The mask node (when present) re-bakes the SAME stable buffer — contents carry over.
2406 pub(crate) fn graph_session_recapture(
2407 &self,
2408 e: &Engine,
2409 sess: &mut GraphSession,
2410 ) -> Result<(), Box<dyn std::error::Error>> {
2411 let mask = sess.mask_dev.take();
2412 let (graph, plan, seg_end) = self.graph_capture_segment_masked(
2413 e,
2414 &mut sess.cache,
2415 &mut sess.gs,
2416 &sess.embd_gpu,
2417 sess.qt,
2418 sess.row_bytes,
2419 sess.n_vocab,
2420 sess.bucket_max,
2421 mask.as_ref().map(|d| (d, sess.mask_words)),
2422 )?;
2423 sess.mask_dev = mask;
2424 sess.graph = graph;
2425 sess.plan = plan;
2426 sess.seg_end = seg_end;
2427 Ok(())
2428 }
2429
2430 /// Shared capture tail: capture the FIRST kernel-class segment, build the session.
2431 #[allow(clippy::too_many_arguments)]
2432 fn graph_session_capture(
2433 &self,
2434 e: &Engine,
2435 mut cache: Cache,
2436 mut gs: GraphDecodeState,
2437 embd_gpu_owned: CudaSlice<u8>,
2438 max_new: usize,
2439 qt: i32,
2440 row_bytes: usize,
2441 n_vocab: usize,
2442 mask_dev: Option<CudaSlice<u32>>,
2443 mask_words: usize,
2444 ) -> Result<(GraphSession, u32), Box<dyn std::error::Error>> {
2445 let embd_gpu = embd_gpu_owned;
2446 let bucket_max = cache.pos + max_new + 1;
2447 let (graph, plan, seg_end) = self.graph_capture_segment_masked(
2448 e,
2449 &mut cache,
2450 &mut gs,
2451 &embd_gpu,
2452 qt,
2453 row_bytes,
2454 n_vocab,
2455 bucket_max,
2456 mask_dev.as_ref().map(|d| (d, mask_words)),
2457 )?;
2458 let first = e.dtoh_u32_one(&gs.token_d)?;
2459 Ok((
2460 GraphSession {
2461 gs,
2462 cache,
2463 embd_gpu,
2464 graph,
2465 plan,
2466 bucket_max,
2467 seg_end,
2468 qt,
2469 row_bytes,
2470 n_vocab,
2471 mask_dev,
2472 mask_words,
2473 },
2474 first,
2475 ))
2476 }
2477
2478 /// Device-counter full-attention decode (CUDA-GRAPH-PLAN Phase 2): clone of `full_attn_decode`
2479 /// using the `_dc` KV-append (write slot from `kvl.len_d`) + `_dc` fa_decode (t_kv from `kvl.len_d`
2480 /// after inc), and the resident device rope `pos_d`. Bit-identical to `full_attn_decode` (the
2481 /// `_dc` kernels reproduce the same math; fa_decode_dc with bucket_max==t_kv reproduces the same
2482 /// n_splits/per/combine). Advances `kvl.len`/`kvl.len_d`.
2483 pub(crate) fn full_attn_decode_dc(
2484 &self,
2485 e: &Engine,
2486 fa: &FullAttnLayer,
2487 h: &CudaSlice<f32>,
2488 pos_d: &CudaSlice<i32>,
2489 cache: &mut Cache,
2490 il: usize,
2491 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
2492 // eager-mirror path: advance host counters and size n_splits from the live t_kv (bit-identical
2493 // to fa_decode). The capture path uses full_attn_decode_dc_cap (fixed bucket_max, no host
2494 // advance, full-buffer K/V view).
2495 self.full_attn_decode_dc_inner(e, fa, h, None, pos_d, cache, il, None)
2496 }
2497
2498 /// PRE-QUANTIZED-INPUT dc full-attn (device-counter path). See full_attn_decode_pre. BIT-IDENTICAL.
2499 pub(crate) fn full_attn_decode_dc_pre(
2500 &self,
2501 e: &Engine,
2502 fa: &FullAttnLayer,
2503 h: &CudaSlice<f32>,
2504 hq: &CudaSlice<i8>,
2505 hd: &CudaSlice<f32>,
2506 pos_d: &CudaSlice<i32>,
2507 cache: &mut Cache,
2508 il: usize,
2509 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
2510 self.full_attn_decode_dc_inner(e, fa, h, Some((hq, hd)), pos_d, cache, il, None)
2511 }
2512
2513 /// PRE-QUANTIZED-INPUT CAPTURE dc full-attn (graph path, fixed bucket_max). BIT-IDENTICAL.
2514 pub(crate) fn full_attn_decode_dc_cap_pre(
2515 &self,
2516 e: &Engine,
2517 fa: &FullAttnLayer,
2518 h: &CudaSlice<f32>,
2519 hq: &CudaSlice<i8>,
2520 hd: &CudaSlice<f32>,
2521 pos_d: &CudaSlice<i32>,
2522 cache: &mut Cache,
2523 il: usize,
2524 bucket_max: usize,
2525 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
2526 self.full_attn_decode_dc_inner(e, fa, h, Some((hq, hd)), pos_d, cache, il, Some(bucket_max))
2527 }
2528
2529 /// CAPTURE variant of `full_attn_decode_dc` (CUDA-GRAPH-PLAN Phase 3). `bucket_max` sizes the
2530 /// fa_decode_dc grid (n_splits) at capture time; the kernel reads the ACTUAL t_kv from the device
2531 /// counter `kvl.len_d`. Does NOT advance the host `kvl.len` (only the DEVICE counter via inc_seqlen,
2532 /// which is captured and replays each launch). Views the FULL K/V cache buffer so the kernel may
2533 /// safely read up to any t_kv within the bucket on replay. Bit-identical to eager when
2534 /// `bucket_max` yields the same n_splits as eager for the replayed t_kv (the bucket-key contract).
2535 pub(crate) fn full_attn_decode_dc_cap(
2536 &self,
2537 e: &Engine,
2538 fa: &FullAttnLayer,
2539 h: &CudaSlice<f32>,
2540 pos_d: &CudaSlice<i32>,
2541 cache: &mut Cache,
2542 il: usize,
2543 bucket_max: usize,
2544 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
2545 self.full_attn_decode_dc_inner(e, fa, h, None, pos_d, cache, il, Some(bucket_max))
2546 }
2547
2548 fn full_attn_decode_dc_inner(
2549 &self,
2550 e: &Engine,
2551 fa: &FullAttnLayer,
2552 h: &CudaSlice<f32>,
2553 pre_q: Option<(&CudaSlice<i8>, &CudaSlice<f32>)>,
2554 pos_d: &CudaSlice<i32>,
2555 cache: &mut Cache,
2556 il: usize,
2557 cap_bucket_max: Option<usize>,
2558 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
2559 // step35 has no device-counter twin yet: the `_dc` family needs a windowed dc fa_decode
2560 // (SWA layers read a token-OFFSET view, which the dc kernels' len_d-derived t_kv cannot
2561 // express) plus a per-layer-n_head capture. Refuse loudly instead of silently running
2562 // the generic geometry. The eager arm (`step35_decode_attn`) is the supported decode.
2563 if self.uses_sliding_gated_moe_program() {
2564 return Err(
2565 "step35 has no device-counter/graph decode arm (SWA needs an offset KV \
2566 view the dc kernels cannot express) — use the eager decode"
2567 .into(),
2568 );
2569 }
2570 let cfg = &self.cfg;
2571 let geometry = cfg.full_attention_geometry_at(il as u32);
2572 let n_head = geometry.n_head as usize;
2573 let n_head_kv = geometry.n_head_kv as usize;
2574 let head_dim = geometry.head_dim_k as usize;
2575 let eps = cfg.rms_eps;
2576 let scale = geometry.attention_scale();
2577
2578 let n_embd = cfg.n_embd as usize;
2579 // Q8 TRUNK-FUSION (2026-07-05): wq+wk+wv share input h — on the 35B every full-attn
2580 // projection is Q8_0, so ONE fused3 launch (block-offset split, out_f 8192/512/512)
2581 // replaces three launch-latency-class m=1 launches. BIT-IDENTICAL per (tensor,row) to
2582 // the three matmul_pre MMVQ dispatches (same kernel body). MEMRA_Q8_DUAL=0 rollback.
2583 let qkv_fused = |e: &Engine,
2584 hq: &CudaSlice<i8>,
2585 hd: &CudaSlice<f32>|
2586 -> Result<
2587 (CudaSlice<f32>, CudaSlice<f32>, CudaSlice<f32>),
2588 Box<dyn std::error::Error>,
2589 > {
2590 if let Some((qf, k, v)) = e.matmul_q8_fused3(&fa.wq, &fa.wk, &fa.wv, hq, hd)? {
2591 return Ok((qf, k, v));
2592 }
2593 Ok((
2594 e.matmul_pre(&fa.wq, hq, hd, h, 1)?,
2595 e.matmul_pre(&fa.wk, hq, hd, h, 1)?,
2596 e.matmul_pre(&fa.wv, hq, hd, h, 1)?,
2597 ))
2598 };
2599 let (qf, mut k, v) =
2600 if e.uses_q8_1_fast(&fa.wq) && e.uses_q8_1_fast(&fa.wk) && e.uses_q8_1_fast(&fa.wv) {
2601 match pre_q {
2602 Some((hq, hd)) => qkv_fused(e, hq, hd)?,
2603 None => {
2604 let (hq, hd) = e.quantize_q8_1(h, 1, n_embd)?;
2605 qkv_fused(e, &hq, &hd)?
2606 }
2607 }
2608 } else {
2609 (
2610 e.matmul(&fa.wq, h, 1)?,
2611 e.matmul(&fa.wk, h, 1)?,
2612 e.matmul(&fa.wv, h, 1)?,
2613 )
2614 };
2615 // M3/Hy3 have no attention output gate — wq out is exactly q; skip the split.
2616 let gated = geometry.attention_gate == memra_gguf::config::AttentionGateKind::FusedQ;
2617 let (mut q, gate) = if gated {
2618 let mut q = e.uninit(n_head * head_dim)?;
2619 let mut gate = e.uninit(n_head * head_dim)?;
2620 e.q_gate_split(&qf, &mut q, &mut gate, head_dim, n_head, 1)?;
2621 (q, Some(gate))
2622 } else {
2623 (qf, None)
2624 };
2625
2626 let mut qn = e.uninit(n_head * head_dim)?;
2627 e.rms_norm(&q, fa.q_norm.float_data(), &mut qn, head_dim, n_head, eps)?;
2628 q = qn;
2629 let mut kn = e.uninit(n_head_kv * head_dim)?;
2630 e.rms_norm(
2631 &k,
2632 fa.k_norm.float_data(),
2633 &mut kn,
2634 head_dim,
2635 n_head_kv,
2636 eps,
2637 )?;
2638 k = kn;
2639 let rope_dims = geometry.n_rot as usize;
2640 // rope pos from the resident device counter (no per-step host upload).
2641 e.rope_neox(
2642 &mut q,
2643 pos_d,
2644 head_dim,
2645 rope_dims,
2646 n_head,
2647 1,
2648 geometry.rope_base,
2649 1.0,
2650 )?;
2651 e.rope_neox(
2652 &mut k,
2653 pos_d,
2654 head_dim,
2655 rope_dims,
2656 n_head_kv,
2657 1,
2658 geometry.rope_base,
2659 1.0,
2660 )?;
2661
2662 let kvl = cache.kv[il].as_mut().unwrap();
2663 // (1) append at the device write slot kvl.len_d (== old len).
2664 e.append_kv_quantized_dc(
2665 &k,
2666 &v,
2667 &mut kvl.k,
2668 &mut kvl.v,
2669 &kvl.len_d,
2670 kvl.kv_dim_k,
2671 kvl.kv_dim_v,
2672 kvl.k_tok_bytes,
2673 kvl.v_tok_bytes,
2674 crate::Engine::kv_fp8_on(),
2675 )?;
2676 // (2) advance the device counter: kvl.len_d now holds new len == t_kv.
2677 e.inc_seqlen(&mut kvl.len_d)?;
2678 // n_splits sizing + K/V view extent:
2679 // - eager path (cap_bucket_max==None): advance host len; size from live t_kv == bit-identical
2680 // to fa_decode; view exactly t_kv*tok_bytes.
2681 // - capture path (Some(bucket_max)): DO NOT touch host len (replay advances only the device
2682 // counter); size n_splits from bucket_max; view the FULL cache buffer so any in-bucket t_kv
2683 // is in range on replay.
2684 let (bucket_max, k_view, v_view) = match cap_bucket_max {
2685 None => {
2686 kvl.len += 1;
2687 let t_kv = kvl.len;
2688 (
2689 t_kv,
2690 e.view_u8(&kvl.k, t_kv * kvl.k_tok_bytes),
2691 e.view_u8(&kvl.v, t_kv * kvl.v_tok_bytes),
2692 )
2693 }
2694 Some(bm) => (
2695 bm,
2696 e.view_u8(&kvl.k, kvl.k.len()),
2697 e.view_u8(&kvl.v, kvl.v.len()),
2698 ),
2699 };
2700 let (ktb, vtb) = (kvl.k_tok_bytes, kvl.v_tok_bytes);
2701 let mut attn = e.uninit(n_head * head_dim)?;
2702 if std::env::var("MEMRA_NOFA").is_ok() {
2703 return Err(
2704 "MEMRA_NOFA (naive f32 SDPA) is incompatible with the quantized KV cache; \
2705 unset MEMRA_NOFA to use fa_decode_dc"
2706 .into(),
2707 );
2708 }
2709 // (3) fa_decode reads t_kv from kvl.len_d; bucket_max yields the eager n_splits -> bit-identical.
2710 e.fa_decode_dc(
2711 &q,
2712 &k_view,
2713 &v_view,
2714 &mut attn,
2715 head_dim,
2716 n_head,
2717 n_head_kv,
2718 &kvl.len_d,
2719 bucket_max,
2720 scale,
2721 ktb,
2722 vtb,
2723 crate::Engine::kv_fp8_on(),
2724 )?;
2725
2726 let attn_g = match &gate {
2727 Some(gate) => {
2728 let mut gsig = e.uninit(n_head * head_dim)?;
2729 e.sigmoid(gate, &mut gsig, n_head * head_dim)?;
2730 let mut ag = e.uninit(n_head * head_dim)?;
2731 e.mul(&attn, &gsig, &mut ag, n_head * head_dim)?;
2732 ag
2733 }
2734 None => attn,
2735 };
2736 Ok(e.matmul(&fa.wo, &attn_g, 1)?)
2737 }
2738
2739 /// Greedy generation: prime with prompt tokens (decode them in sequence to build state),
2740 /// then generate `max_new` tokens. Returns the generated token ids. (Back-compat: greedy,
2741 /// no EOS/stop — used by the decode==prefill validation gate. New code uses `generate_with`.)
2742 pub fn generate(
2743 &self,
2744 e: &Engine,
2745 prompt: &[u32],
2746 max_new: usize,
2747 ) -> Result<Vec<u32>, Box<dyn std::error::Error>> {
2748 let max_ctx = prompt.len() + max_new + 8;
2749 let mut cache = Cache::new(e, &self.cfg, max_ctx)?;
2750 let mut last_logits = Vec::new();
2751 // prime: BATCHED cache prime (prime_cache — the prefill-throughput path, the measured #1
2752 // e2e gap: tokenwise primed at ~102/38 tok/s vs ~2000-5900 tok/s batched). Prompts below
2753 // PRIME_MIN_T, MEMRA_PRIME_TOKENWISE=1, and frozen Hy3 CPU/GPU expert splits take the
2754 // tokenwise loop. Frozen mixed residency would otherwise transiently stage the missing
2755 // expert bank through the GPU on every prompt replay.
2756 let t_prime = std::time::Instant::now();
2757 let batched_prime = prompt.len() >= crate::hybrid_forward::PRIME_MIN_T
2758 && std::env::var("MEMRA_PRIME_TOKENWISE").is_err()
2759 && !e.frozen_cpu_experts_prefer_tokenwise_prime();
2760 if batched_prime {
2761 let (l, _h_seed, _hiddens) = self.prime_cache(e, prompt, &mut cache, 0)?;
2762 last_logits = l;
2763 } else {
2764 for &tok in prompt {
2765 last_logits = self.decode_step(e, tok, &mut cache)?;
2766 }
2767 }
2768 e.stream().synchronize()?;
2769 // Harness timing contract: prime wall time published for gen-only throughput math
2770 // (bench binaries read this right after the call; subtraction-from-total breaks down
2771 // when prime >> gen — measured ±80% error at 6k-token prompts).
2772 crate::PRIME_NANOS.store(
2773 t_prime.elapsed().as_nanos() as u64,
2774 std::sync::atomic::Ordering::Relaxed,
2775 );
2776 let mut out = Vec::with_capacity(max_new);
2777 if self.uses_gemma_program()
2778 && let Some(embd_gpu) = self.embd_gpu_try(e)
2779 {
2780 // Graph serving probed FLAT vs this dc loop (2026-07-12, 1.7k N=2: 174.6/174.2 vs
2781 // 174.5/174.3) — the GRAPH-GATE's +2.5% is over the plain-eager loop, and the dc
2782 // arc already banked that; the gate (IDENTICAL at every ctx since the wkv
2783 // capture-arm fix) stays as the correctness harness.
2784 // DEVICE-COUNTER greedy loop (the dc arc): stream-identical to eager (DC-GATE).
2785 // E4B rides its own dc step (same trunk fns as its eager chain).
2786 let n_vocab = self.output.out_features();
2787 let (qt, rb) = self.embd.qt_and_row_bytes(self.cfg.n_embd as usize);
2788 for kvl in cache.kv.iter_mut().flatten() {
2789 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
2790 }
2791 let e4b = self.is_gemma4_e4b();
2792 // 26B/31B WHOLE-TOKEN GRAPH SERVING door (MEMRA_GEMMA_GRAPH=1): measured FLAT on
2793 // the 26B (jsonl 2026-07-12) but the 31B carries ~4% launch-gap share (HANDOVER
2794 // graph-arc note) and was never measured — the plain-short 1.00x cell probe.
2795 if !e4b && std::env::var("MEMRA_GEMMA_GRAPH").as_deref() == Ok("1") {
2796 let first = argmax(&last_logits) as u32;
2797 let (toks, _reason) = self.gemma4_generate_graph(
2798 e,
2799 cache.pos,
2800 first,
2801 &mut cache,
2802 max_new,
2803 &[],
2804 |_| true,
2805 )?;
2806 out.extend(toks);
2807 return Ok(out);
2808 }
2809 let mut token_d = e.stream().clone_htod(&[argmax(&last_logits) as u32])?;
2810 let mut pos_d = e.htod_i32(&[cache.pos as i32])?;
2811 // E4B GRAPH-EXEC-UPDATE SERVING: one capture at bucket=win, per-token fa
2812 // geometry retune, replay. The 2026-07-12 park ("flat 173.5, stream 64/64") did
2813 // NOT reproduce — the capture warmups are real self-feeding steps and the old
2814 // door dropped their 2 tokens (E4B-GRAPH-GATE 3/64). Snapshot/rollback (the 26B
2815 // graph-loop pattern) fixes the stream; the exec-update kills the bucket-split
2816 // tax (42 fa launches at 64 splits vs eager's ~ceil(t_kv/8)).
2817 // DEFAULT: budget-gated ON (2026-07-13 valid-window A/B: steady-state replay
2818 // beats eager but the one-time capture ~30ms crosses over near 200 tokens —
2819 // 128tok −1.3%, 400tok +0.9%). MEMRA_E4B_GRAPH=1 forces, =0 kills.
2820 let win = self
2821 .cfg
2822 .gemma4
2823 .as_ref()
2824 .map(|g| g.sliding_window as usize)
2825 .unwrap_or(0);
2826 let e4b_graph = match std::env::var("MEMRA_E4B_GRAPH").as_deref() {
2827 Ok("1") => true,
2828 Ok("0") => false,
2829 _ => max_new >= 256,
2830 };
2831 if e4b && cache.pos + max_new + 2 < win && e4b_graph {
2832 self.gemma4_e4b_graph_exec_loop(
2833 e,
2834 &mut cache,
2835 &mut token_d,
2836 &mut pos_d,
2837 embd_gpu,
2838 qt,
2839 rb,
2840 n_vocab,
2841 win,
2842 max_new,
2843 usize::MAX,
2844 |tok| {
2845 out.push(tok);
2846 None
2847 },
2848 )?;
2849 return Ok(out);
2850 }
2851 for _ in 0..max_new {
2852 out.push(e.dtoh_u32(&token_d)?[0]);
2853 token_d = if e4b {
2854 self.gemma4_e4b_decode_step_dc(
2855 e, &token_d, &mut pos_d, embd_gpu, qt, rb, &mut cache, n_vocab,
2856 )?
2857 } else {
2858 self.gemma4_decode_step_dc(
2859 e, &token_d, &mut pos_d, embd_gpu, qt, rb, &mut cache, n_vocab, None,
2860 )?
2861 };
2862 }
2863 return Ok(out);
2864 }
2865 // QWEN DC-EAGER route (2026-07-15, MEMRA_QWEN_DC=0 seam — mirror of generate_with's
2866 // serving loop; see the note there. The graph route probed −11% first.)
2867 // step35 is EXCLUDED: this route calls `decode_step_dc`, whose full-attn arm refuses
2868 // step35 by design (SWA layers need a token-OFFSET KV view the dc kernels' len_d-derived
2869 // t_kv cannot express). Without this gate the door opens for any greedy model and the
2870 // refusal surfaces as a user-visible generate() error — the first PP-2 boot of
2871 // Step-3.7-Flash died exactly there, AFTER a clean load and an argmax MATCH.
2872 static QWEN_DC2: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2873 let qwen_dc =
2874 *QWEN_DC2.get_or_init(|| std::env::var("MEMRA_QWEN_DC").as_deref() != Ok("0"));
2875 if qwen_dc
2876 && max_new > 0
2877 && !self.uses_sliding_gated_moe_program()
2878 && let Some(embd_gpu) = self.embd_gpu_try(e)
2879 {
2880 let n_vocab = self.output.out_features();
2881 let (qt, rb) = self.embd.qt_and_row_bytes(self.cfg.n_embd as usize);
2882 for kvl in cache.kv.iter_mut().flatten() {
2883 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
2884 }
2885 let mut pos_d = e.htod_i32(&[cache.pos as i32])?;
2886 let mut token_d = e.stream().clone_htod(&[argmax(&last_logits) as u32])?;
2887 for _ in 0..max_new {
2888 out.push(e.dtoh_u32(&token_d)?[0]);
2889 token_d = self.decode_step_dc(
2890 e, &token_d, &mut pos_d, embd_gpu, qt, rb, &mut cache, n_vocab,
2891 )?;
2892 }
2893 return Ok(out);
2894 }
2895 for _ in 0..max_new {
2896 let next = argmax(&last_logits) as u32;
2897 out.push(next);
2898 last_logits = self.decode_step(e, next, &mut cache)?;
2899 }
2900 Ok(out)
2901 }
2902
2903 /// E4B whole-token GRAPH-EXEC-UPDATE serving loop (shared by `generate` and
2904 /// `generate_with`): capture ONE self-feeding dcg step at bucket=`win`, then per token
2905 /// retune the fa nodes' split geometry to the live eager counts
2906 /// (`graph_update::fa_apply`) before replaying the instantiated exec.
2907 ///
2908 /// The capture's two warmup runs are REAL executions (self-feeding: they consume two
2909 /// tokens and advance KV/counters) — snapshot/rollback around the capture (the 26B
2910 /// graph-loop pattern) restores device+host state, or the stream drops those tokens
2911 /// (E4B-GRAPH-GATE 3/64 break, 2026-07-12). `emit` sees each token BEFORE its
2912 /// successor's replay; returning `Some(reason)` stops the loop. Caller owns the
2913 /// under-window gate (`cache.pos + budget + 2 < win`).
2914 #[allow(clippy::too_many_arguments)]
2915 fn gemma4_e4b_graph_exec_loop(
2916 &self,
2917 e: &Engine,
2918 cache: &mut Cache,
2919 token_d: &mut CudaSlice<u32>,
2920 pos_d: &mut CudaSlice<i32>,
2921 embd_gpu: &CudaSlice<u8>,
2922 qt: i32,
2923 rb: usize,
2924 n_vocab: usize,
2925 win: usize,
2926 budget: usize,
2927 ctx_cap: usize,
2928 mut emit: impl FnMut(u32) -> Option<StopReason>,
2929 ) -> Result<StopReason, Box<dyn std::error::Error>> {
2930 // BISECT ARM (MEMRA_E4B_DCG_EAGER=1): run the dcg step EAGERLY per token at the
2931 // exact live bucket — no capture/replay/exec-update. Separates "the dc-bucket path
2932 // diverges from dc-eager numerically" from "the replay/update mechanism is wrong".
2933 if let Ok(m) = std::env::var("MEMRA_E4B_DCG_EAGER") {
2934 // =1: exact live bucket per token; =2: the capture's fixed win bucket.
2935 let mut reason = StopReason::MaxNew;
2936 for _ in 0..budget {
2937 let tok = e.dtoh_u32_one(token_d)?;
2938 if let Some(r) = emit(tok) {
2939 reason = r;
2940 break;
2941 }
2942 if cache.pos >= ctx_cap {
2943 reason = StopReason::ContextFull;
2944 break;
2945 }
2946 let b = if m == "2" { win } else { cache.pos + 1 };
2947 self.gemma4_e4b_decode_step_dcg(
2948 e, token_d, pos_d, embd_gpu, qt, rb, cache, n_vocab, b,
2949 )?;
2950 cache.pos += 1;
2951 for kvl in cache.kv.iter_mut().flatten() {
2952 kvl.len += 1;
2953 }
2954 }
2955 return Ok(reason);
2956 }
2957 // snapshot device+host state (the 2 capture-warmup runs must leave no residue).
2958 let snap = cache.snapshot(e)?;
2959 let pos_save = e.dtoh_i32_one(pos_d)?;
2960 let len_save: Vec<Option<i32>> = cache
2961 .kv
2962 .iter()
2963 .map(|k| k.as_ref().map(|kvl| e.dtoh_i32_one(&kvl.len_d).unwrap()))
2964 .collect();
2965 let tok_save = e.dtoh_u32_one(token_d)?;
2966 let (graph, keeper) = e.capture_graph_retained(|e| {
2967 self.gemma4_e4b_decode_step_dcg(
2968 e, token_d, pos_d, embd_gpu, qt, rb, cache, n_vocab, win,
2969 )
2970 })?;
2971 cache.rollback(e, &snap, 0)?;
2972 e.set_i32_one(pos_d, pos_save)?;
2973 for (il, ls) in len_save.iter().enumerate() {
2974 if let (Some(kvl), Some(v)) = (cache.kv[il].as_mut(), ls) {
2975 e.set_i32_one(&mut kvl.len_d, *v)?;
2976 }
2977 }
2978 e.set_u32_one(token_d, tok_save)?;
2979 let mut plan = crate::graph_update::fa_plan(&graph)?;
2980 if std::env::var("MEMRA_GRAPH_NODES_DUMP").as_deref() == Ok("1") {
2981 let nodes = crate::graph_update::kernel_nodes(&graph)?;
2982 let mut counts: std::collections::BTreeMap<String, (usize, (u32, u32, u32))> =
2983 std::collections::BTreeMap::new();
2984 for n in &nodes {
2985 counts
2986 .entry(n.name.clone())
2987 .or_insert((0, (n.params.gridDimX, n.params.gridDimY, n.params.gridDimZ)))
2988 .0 += 1;
2989 }
2990 eprintln!(
2991 "[graph-nodes] {} kernel nodes, {} fa update units (bucket={win})",
2992 nodes.len(),
2993 plan.len()
2994 );
2995 for (name, (c, grid)) in &counts {
2996 eprintln!("[graph-nodes] {c:4}x {name} grid={grid:?}");
2997 }
2998 }
2999 let mut reason = StopReason::MaxNew;
3000 let timing = std::env::var("MEMRA_E4B_GRAPH_TIMING").as_deref() == Ok("1");
3001 let (mut t_dtoh, mut t_apply, mut t_launch) = (
3002 std::time::Duration::ZERO,
3003 std::time::Duration::ZERO,
3004 std::time::Duration::ZERO,
3005 );
3006 for _ in 0..budget {
3007 let t0 = std::time::Instant::now();
3008 let tok = e.dtoh_u32_one(token_d)?;
3009 let t1 = std::time::Instant::now();
3010 if let Some(r) = emit(tok) {
3011 reason = r;
3012 break;
3013 }
3014 if cache.pos >= ctx_cap {
3015 reason = StopReason::ContextFull;
3016 break;
3017 }
3018 // live t_kv AFTER this replay's in-graph append = pos + 1.
3019 crate::graph_update::fa_apply(&graph, &mut plan, cache.pos + 1, crate::fa_split_keys)?;
3020 let t2 = std::time::Instant::now();
3021 graph.launch()?;
3022 if timing {
3023 let t3 = std::time::Instant::now();
3024 t_dtoh += t1 - t0;
3025 t_apply += t2 - t1;
3026 t_launch += t3 - t2;
3027 }
3028 cache.pos += 1;
3029 for kvl in cache.kv.iter_mut().flatten() {
3030 kvl.len += 1;
3031 }
3032 }
3033 if timing {
3034 eprintln!(
3035 "[e4b-graph timing] dtoh(sync-wait) {:?} apply {:?} launch {:?}",
3036 t_dtoh, t_apply, t_launch
3037 );
3038 }
3039 drop(keeper); // capture-retained transients must outlive every replay
3040 Ok(reason)
3041 }
3042
3043 /// The reusable serving generation API (BASE-3). Primes the prompt, then samples up to
3044 /// `params.max_new` tokens, stopping on EOS, any stop-token, or the context-length guard.
3045 /// Calls `on_token(id)` after each emitted token (for streaming; return `false` to stop early).
3046 /// Returns `GenOutput { tokens, stop_reason }`. Does NOT detokenize — the caller (which owns
3047 /// the tokenizer) handles text + stop-STRING matching on the detokenized tail.
3048 pub fn generate_with<F: FnMut(u32) -> bool>(
3049 &self,
3050 e: &Engine,
3051 prompt: &[u32],
3052 params: &GenParams,
3053 sampler: &mut crate::sampler::Sampler,
3054 mut on_token: F,
3055 ) -> Result<GenOutput, Box<dyn std::error::Error>> {
3056 // Context guard: prompt + generated must fit max_ctx (caller-supplied or model default).
3057 let ctx_cap = params.max_ctx.unwrap_or(prompt.len() + params.max_new + 8);
3058 if prompt.len() >= ctx_cap {
3059 return Ok(GenOutput {
3060 tokens: Vec::new(),
3061 stop_reason: StopReason::ContextFull,
3062 });
3063 }
3064 let room = ctx_cap - prompt.len();
3065 let budget = params.max_new.min(room);
3066
3067 let mut cache = Cache::new(e, &self.cfg, ctx_cap)?;
3068 let mut last_logits = Vec::new();
3069 // BATCHED PRIME (2026-07-06 fix — generate_with was still tokenwise! run-gen's "decode"
3070 // numbers folded a ~40-100 tok/s tokenwise prime into the rate) + PRIME_NANOS contract.
3071 // Frozen Hy3 CPU/GPU expert serving is the deliberate exception: its batched MoE path
3072 // bypasses the CPU tier and rereads the spilled expert bank.
3073 let t_prime = std::time::Instant::now();
3074 let batched = prompt.len() >= crate::hybrid_forward::PRIME_MIN_T
3075 && std::env::var("MEMRA_PRIME_TOKENWISE").is_err()
3076 && !e.frozen_cpu_experts_prefer_tokenwise_prime();
3077 if batched {
3078 let (l, _h, _x) = self.prime_cache(e, prompt, &mut cache, 0)?;
3079 last_logits = l;
3080 for &tok in prompt {
3081 sampler.accept(tok);
3082 }
3083 } else {
3084 for &tok in prompt {
3085 last_logits = self.decode_step(e, tok, &mut cache)?;
3086 sampler.accept(tok);
3087 }
3088 }
3089 e.stream().synchronize()?;
3090 crate::PRIME_NANOS.store(
3091 t_prime.elapsed().as_nanos() as u64,
3092 std::sync::atomic::Ordering::Relaxed,
3093 );
3094 let mut out = Vec::with_capacity(budget);
3095 let mut reason = StopReason::MaxNew;
3096 // gemma4 DEVICE-COUNTER greedy serving loop (the dc arc): token/pos/kv-lens live in
3097 // device counters, argmax on device — host sees 4B/token. Stream-identical to the
3098 // eager chain (DC-GATE). Penalties/temp fall through to the host-logits loop.
3099 if self.uses_gemma_program()
3100 && sampler.is_greedy()
3101 && sampler.penalty_last_n() == 0
3102 && let Some(embd_gpu) = self.embd_gpu_try(e)
3103 {
3104 let n_vocab = self.output.out_features();
3105 let (qt, rb) = self.embd.qt_and_row_bytes(self.cfg.n_embd as usize);
3106 for kvl in cache.kv.iter_mut().flatten() {
3107 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
3108 }
3109 let first = crate::forward::argmax(&last_logits) as u32;
3110 let e4b = self.is_gemma4_e4b();
3111 let mut token_d = e.stream().clone_htod(&[first])?;
3112 let mut pos_d = e.htod_i32(&[cache.pos as i32])?;
3113 // E4B GRAPH-EXEC-UPDATE serving door (under-window regime) — mirror of the
3114 // `generate` door incl the budget-gated default; run-gen/serving measure here.
3115 let win = self
3116 .cfg
3117 .gemma4
3118 .as_ref()
3119 .map(|g| g.sliding_window as usize)
3120 .unwrap_or(0);
3121 let e4b_graph = match std::env::var("MEMRA_E4B_GRAPH").as_deref() {
3122 Ok("1") => true,
3123 Ok("0") => false,
3124 _ => budget >= 256,
3125 };
3126 if e4b && cache.pos + budget + 2 < win && e4b_graph {
3127 let (out_cell, sampler_cell) = (&mut out, &mut *sampler);
3128 let reason = self.gemma4_e4b_graph_exec_loop(
3129 e,
3130 &mut cache,
3131 &mut token_d,
3132 &mut pos_d,
3133 embd_gpu,
3134 qt,
3135 rb,
3136 n_vocab,
3137 win,
3138 budget,
3139 ctx_cap,
3140 |tok| {
3141 sampler_cell.accept(tok);
3142 out_cell.push(tok);
3143 if params.eos.contains(&tok) {
3144 return Some(StopReason::Eos);
3145 }
3146 if !on_token(tok) {
3147 return Some(StopReason::Callback);
3148 }
3149 None
3150 },
3151 )?;
3152 return Ok(GenOutput {
3153 tokens: out,
3154 stop_reason: reason,
3155 });
3156 }
3157 // 12B/31B WHOLE-TOKEN GRAPH door (MEMRA_GEMMA_GRAPH=1), mirrored from `generate`:
3158 // run-gen/serving measure THIS path, and the `generate` door never covered it —
3159 // the 2026-07-22 graph A/B read flat because the env engaged nothing here.
3160 if !e4b && std::env::var("MEMRA_GEMMA_GRAPH").as_deref() == Ok("1") {
3161 let (out_cell, sampler_cell) = (&mut out, &mut *sampler);
3162 let eos = params.eos.clone();
3163 let (toks, greason) = self.gemma4_generate_graph(
3164 e,
3165 cache.pos,
3166 first,
3167 &mut cache,
3168 budget,
3169 &eos,
3170 |tok| {
3171 sampler_cell.accept(tok);
3172 out_cell.push(tok);
3173 on_token(tok)
3174 },
3175 )?;
3176 let _ = toks;
3177 return Ok(GenOutput {
3178 tokens: out,
3179 stop_reason: greason,
3180 });
3181 }
3182 let mut next = first;
3183 for _ in 0..budget {
3184 sampler.accept(next);
3185 out.push(next);
3186 if params.eos.contains(&next) {
3187 reason = StopReason::Eos;
3188 break;
3189 }
3190 if !on_token(next) {
3191 reason = StopReason::Callback;
3192 break;
3193 }
3194 if cache.pos >= ctx_cap {
3195 reason = StopReason::ContextFull;
3196 break;
3197 }
3198 token_d = if e4b {
3199 self.gemma4_e4b_decode_step_dc(
3200 e, &token_d, &mut pos_d, embd_gpu, qt, rb, &mut cache, n_vocab,
3201 )?
3202 } else {
3203 self.gemma4_decode_step_dc(
3204 e, &token_d, &mut pos_d, embd_gpu, qt, rb, &mut cache, n_vocab, None,
3205 )?
3206 };
3207 next = e.dtoh_u32(&token_d)?[0];
3208 }
3209 return Ok(GenOutput {
3210 tokens: out,
3211 stop_reason: reason,
3212 });
3213 }
3214 // QWEN DC-EAGER serving loop (2026-07-15, MEMRA_QWEN_DC=0 seam — the gemma dc-arc
3215 // pattern): the eager tail dtoh'd the FULL VOCAB logits + host-argmax'd every
3216 // token (the duty map's 10.3%-of-wall gap at 13% DRAM duty). decode_step_dc keeps
3217 // the token id + argmax device-resident — 4B/token host traffic, same tuned eager
3218 // kernels. Greedy + no-penalty only (sampling needs host logits).
3219 // (The CUDA-graph route was probed first and read −11%: the replay's dc-fa family
3220 // + capture rungs lag the tuned eager lanes; jsonl 2026-07-15.)
3221 // step35 is EXCLUDED here for the same reason as the `generate` mirror above: every route
3222 // inside this door (`decode_step_dc` and the `graph_decode_loop` capture) reaches
3223 // `full_attn_decode_dc_inner`, which refuses step35 because its SWA layers read a
3224 // token-OFFSET KV view the dc kernels cannot express. step35 takes the host-logits eager
3225 // loop at the bottom of this function (`decode_step` -> `step35_decode_attn`), which is
3226 // the supported decode for this arch. Removing this gate requires a windowed dc fa_decode
3227 // plus a per-layer-n_head capture, not a flag.
3228 static QWEN_DC: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3229 let qwen_dc = *QWEN_DC.get_or_init(|| std::env::var("MEMRA_QWEN_DC").as_deref() != Ok("0"));
3230 if qwen_dc
3231 && sampler.is_greedy()
3232 && sampler.penalty_last_n() == 0
3233 && budget > 0
3234 && !self.uses_sliding_gated_moe_program()
3235 && let Some(embd_gpu) = self.embd_gpu_try(e)
3236 {
3237 let n_vocab = self.output.out_features();
3238 let (qt, rb) = self.embd.qt_and_row_bytes(self.cfg.n_embd as usize);
3239 for kvl in cache.kv.iter_mut().flatten() {
3240 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
3241 }
3242 let mut pos_d = e.htod_i32(&[cache.pos as i32])?;
3243 let mut token_d = e
3244 .stream()
3245 .clone_htod(&[crate::forward::argmax(&last_logits) as u32])?;
3246 // HYBRID GRAPH DOOR (round 35): graph_decode_loop over the batched-prime
3247 // cache — the E4B graph-exec door's hybrid mirror. Counters (pos_d/token_d/
3248 // len_d) synced above; event tracking is engine-default-OFF so capture over
3249 // these buffers is legal. PROMOTED default-ON at budget >= 256 (the E4B
3250 // door's amortization rule): official-shape A/B interleaved x5 = eager 190.3
3251 // -> graph 220.7 tok/s (+16.0%, 5/5, spread ±0.1); 128-tok stream IDENTICAL;
3252 // graph-decode-gate 256 steps x 16 buckets BIT-IDENTICAL. This REFUTES the
3253 // 2026-07-15 "-11%" qwen-graph verdict — it predated the exec-update rework
3254 // and the 07-26 FA family (stale-verdict law, round 35). =0 reverts.
3255 // Default ON at budget >= 256 on BOTH arches (unified-merge resolution,
3256 // 2026-07-30): main shipped this door budget-keyed on sm_120a (52222ddd,
3257 // E4B graph door) and every 5090 board row since measured with it; the H100
3258 // lane measured +16% x5. The branch-era arch-gate (79395a3e) cited the
3259 // stale 2026-07-15 "-11%" verdict, which predates main's promotion — the
3260 // rig-divergence law protects main's SHIPPED default, so the gate came off.
3261 // MEMRA_GEN_GRAPH=1 opts in anywhere; =0 reverts anywhere.
3262 //
3263 // KEY LOWERED 256 -> 48 (q27 deep dive, 2026-08-05, pro6000wk-runpod-community).
3264 // The 256 key was set by the E4B amortization rule, never by a measured crossover,
3265 // so every <=128-token generation — including the whole published board, which runs
3266 // --max-tokens 128 — was silently EAGER. Swept the actual crossover on TWO models
3267 // (the key is a cross-model default, so one artifact is not enough), interleaved
3268 // arms with the order alternated per rep, N=3, all runs argmax MATCH:
3269 // Qwen3.6-27B-Q8_0 : n=16 -7.47% | n=32 -1.35% | n=48 +0.90% | n=64 +1.93%
3270 // n=128 +3.80% | n=512 +5.50%
3271 // Qwen3.6-27B-NVFP4-MTP: n=16 -15.27% | n=32 +0.22% | n=48 +3.45%
3272 // n=64 +5.09% | n=128 +7.72%
3273 // Both models: clearly negative at 16, no reliable gain at 32, positive from 48 up,
3274 // monotone in budget from 48 on. 48 is the first budget where BOTH are positive, so
3275 // it is the key — the capture cost needs ~32 steps to amortize, not ~256. The n=32
3276 // nvfp4 cell is NOISY, not flat (graph arm 79.02/78.91/77.09, spread 1.93 vs an
3277 // eager spread of 0.04): it is not evidence of a win, and it is why the key sits at
3278 // 48 rather than 32. Exactness at the new key:
3279 // graph-decode-gate 256 steps BIT-IDENTICAL (buckets=16, captures=2),
3280 // graph-session-gate 96 tokens PASS, kernel-check ALL GREEN, run-spec K=1..8
3281 // self-consistency PASS. Board caveat: community board, RELATIVE deltas only.
3282 //
3283 // SM-GATED (5090-arbiter gate, 2026-08-05, research/q27-deepdive-20260805/local5090/):
3284 // the 48 key does NOT transfer to the 82-SM local rig. Same A/B protocol there
3285 // (tg128 d512, N=3 interleaved, order alternated, warmup discarded): q27-NVFP4-MTP
3286 // graph arm at n=128 = -1.61% (eager 45.86 / graph 45.12 median, 3/3 pairs lose),
3287 // and the crossover sweep stays negative through n=256 (-1.07%) and n=512 (-0.59%)
3288 // — on few-SM silicon the replay's fixed kernel forms lag the tuned eager lanes and
3289 // the launch-gap tax the graph amortizes is proportionally smaller. Key on SM count
3290 // (the fa_split_keys big_rig pattern, lib.rs fa_sm_count), threshold 180: the 48
3291 // crossover is MEASURED only at 188 SM (PRO 6000) and refuted at 82 SM; the 132-SM
3292 // H100 board and the 170-SM desktop 5090 are UNMEASURED at sub-256 budgets, so they
3293 // keep the shipped 256 key their board rows were measured with (rig-divergence +
3294 // stale-verdict laws). Widening the gate below 180 requires an on-box crossover
3295 // sweep on that silicon, not an inference from this comment.
3296 let big_rig = e.sm_count() >= 180;
3297 let gen_graph = match std::env::var("MEMRA_GEN_GRAPH").as_deref() {
3298 Ok("1") => true,
3299 Ok("0") => false,
3300 _ => budget >= if big_rig { 48 } else { 256 },
3301 };
3302 // SLRU expert cache is capture-ILLEGAL: a cache miss drains/H2Ds on the compute
3303 // stream mid-decode, which CUDA forbids while capturing (Ornith-35B Q4_K_M on the
3304 // 24GB rig died with CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED, 2026-08-01 — any MoE
3305 // model whose experts overflow the residency budget hit this at budget >= 256).
3306 // The door only opens with every MoE layer's experts device-resident; =1 cannot
3307 // legalize a capture, so this closes the forced door too.
3308 let moe_resident = self.layers.iter().all(|l| match &l.ffn {
3309 crate::hybrid::Ffn::Moe(m) => m.dev_exps.is_some(),
3310 _ => true,
3311 });
3312 if gen_graph && !moe_resident {
3313 static NOTICE: std::sync::Once = std::sync::Once::new();
3314 NOTICE.call_once(|| {
3315 eprintln!(
3316 "[gen-graph] door CLOSED: MoE experts on the SLRU cache path \
3317 (capture-illegal) — eager decode"
3318 )
3319 });
3320 }
3321 if gen_graph && moe_resident && budget > 0 {
3322 let head_dim = self.cfg.head_dim_k as usize;
3323 let mut gs = GraphDecodeState::new(e)?;
3324 gs.pos_d = pos_d;
3325 gs.token_d = token_d;
3326 let (out_cell, sampler_cell) = (&mut out, &mut *sampler);
3327 let reason = self.graph_decode_loop(
3328 e,
3329 &mut gs,
3330 &mut cache,
3331 embd_gpu,
3332 qt,
3333 rb,
3334 head_dim,
3335 budget,
3336 |tok| {
3337 sampler_cell.accept(tok);
3338 out_cell.push(tok);
3339 if params.eos.contains(&tok) {
3340 return Some(StopReason::Eos);
3341 }
3342 if !on_token(tok) {
3343 return Some(StopReason::Callback);
3344 }
3345 None
3346 },
3347 )?;
3348 return Ok(GenOutput {
3349 tokens: out,
3350 stop_reason: reason,
3351 });
3352 }
3353 let mut next = e.dtoh_u32(&token_d)?[0];
3354 for _ in 0..budget {
3355 sampler.accept(next);
3356 out.push(next);
3357 if params.eos.contains(&next) {
3358 reason = StopReason::Eos;
3359 break;
3360 }
3361 if !on_token(next) {
3362 reason = StopReason::Callback;
3363 break;
3364 }
3365 if cache.pos >= ctx_cap {
3366 reason = StopReason::ContextFull;
3367 break;
3368 }
3369 token_d = self.decode_step_dc(
3370 e, &token_d, &mut pos_d, embd_gpu, qt, rb, &mut cache, n_vocab,
3371 )?;
3372 next = e.dtoh_u32(&token_d)?[0];
3373 }
3374 return Ok(GenOutput {
3375 tokens: out,
3376 stop_reason: reason,
3377 });
3378 }
3379 for _ in 0..budget {
3380 let next = sampler.sample(&last_logits);
3381 sampler.accept(next);
3382 out.push(next);
3383 if params.eos.contains(&next) {
3384 reason = StopReason::Eos;
3385 break;
3386 }
3387 if !on_token(next) {
3388 reason = StopReason::Callback;
3389 break;
3390 }
3391 if cache.pos >= ctx_cap {
3392 reason = StopReason::ContextFull;
3393 break;
3394 }
3395 last_logits = self.decode_step(e, next, &mut cache)?;
3396 }
3397 Ok(GenOutput {
3398 tokens: out,
3399 stop_reason: reason,
3400 })
3401 }
3402
3403 /// Full-attention decode: project q/gate/k/v for the new token, QK-norm, RoPE at pos,
3404 /// append k,v to the layer KV cache, attend over the full [0..=pos] context.
3405 pub(crate) fn full_attn_decode(
3406 &self,
3407 e: &Engine,
3408 fa: &FullAttnLayer,
3409 h: &CudaSlice<f32>,
3410 pos_d: &CudaSlice<i32>,
3411 pos: usize,
3412 cache: &mut Cache,
3413 il: usize,
3414 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
3415 self.full_attn_decode_pre(e, fa, h, None, pos_d, pos, cache, il)
3416 }
3417
3418 /// PRE-QUANTIZED-INPUT eager full-attn (attn-input NORM-FUSION lever): caller passes the
3419 /// attn-normed activation already q8_1 `(hq,hd)` (rms_norm_q8_1) -> skips internal quantize_q8_1.
3420 /// `None` = quantize h here (the spec / non-fused path). BIT-IDENTICAL.
3421 pub(crate) fn full_attn_decode_pre(
3422 &self,
3423 e: &Engine,
3424 fa: &FullAttnLayer,
3425 h: &CudaSlice<f32>,
3426 pre_q: Option<(&CudaSlice<i8>, &CudaSlice<f32>)>,
3427 pos_d: &CudaSlice<i32>,
3428 pos: usize,
3429 cache: &mut Cache,
3430 il: usize,
3431 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
3432 if self.uses_sliding_gated_moe_program() {
3433 return self.step35_decode_attn(e, fa, il, h, pre_q, pos_d, cache);
3434 }
3435 let cfg = &self.cfg;
3436 let geometry = cfg.full_attention_geometry_at(il as u32);
3437 let n_head = geometry.n_head as usize;
3438 let n_head_kv = geometry.n_head_kv as usize;
3439 let head_dim = geometry.head_dim_k as usize;
3440 let eps = cfg.rms_eps;
3441 let scale = geometry.attention_scale();
3442
3443 // LATENCY-HIDING (MEMRA_KV_PREFETCH=1): warm this layer's KV stream into L2 while the
3444 // q/k/v projections run ahead of the fa (fa is latency-bound; its lines land warm).
3445 // Value-free scheduling — no numeric config change.
3446 static KV_PF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3447 if *KV_PF.get_or_init(|| std::env::var("MEMRA_KV_PREFETCH").as_deref() == Ok("1")) {
3448 let kvl = cache.kv[il].as_ref().unwrap();
3449 let t_kv = kvl.len + 1;
3450 e.prefetch_l2(&kvl.k, t_kv * kvl.k_tok_bytes)?;
3451 e.prefetch_l2(&kvl.v, t_kv * kvl.v_tok_bytes)?;
3452 }
3453
3454 // wq|wk|wv all take the same input `h` (in_f = n_embd) — quantize q8_1 ONCE, feed all three.
3455 // Q8 TRUNK-FUSION: on Q8_0 trunks (35B) the three fold into ONE fused3 launch (same MMVQ
3456 // body per (tensor,row) — bit-identical; see full_attn_decode_dc_inner). MEMRA_Q8_DUAL=0 off.
3457 let n_embd = cfg.n_embd as usize;
3458 let qkv_fused = |e: &Engine,
3459 hq: &CudaSlice<i8>,
3460 hd: &CudaSlice<f32>|
3461 -> Result<
3462 (CudaSlice<f32>, CudaSlice<f32>, CudaSlice<f32>),
3463 Box<dyn std::error::Error>,
3464 > {
3465 if let Some((qf, k, v)) = e.matmul_q8_fused3(&fa.wq, &fa.wk, &fa.wv, hq, hd)? {
3466 return Ok((qf, k, v));
3467 }
3468 Ok((
3469 e.matmul_pre(&fa.wq, hq, hd, h, 1)?,
3470 e.matmul_pre(&fa.wk, hq, hd, h, 1)?,
3471 e.matmul_pre(&fa.wv, hq, hd, h, 1)?,
3472 ))
3473 };
3474 let (qf, mut k, v) =
3475 if e.uses_q8_1_fast(&fa.wq) && e.uses_q8_1_fast(&fa.wk) && e.uses_q8_1_fast(&fa.wv) {
3476 match pre_q {
3477 Some((hq, hd)) => qkv_fused(e, hq, hd)?,
3478 None => {
3479 let (hq, hd) = e.quantize_q8_1(h, 1, n_embd)?;
3480 qkv_fused(e, &hq, &hd)?
3481 }
3482 }
3483 } else {
3484 (
3485 e.matmul(&fa.wq, h, 1)?,
3486 e.matmul(&fa.wk, h, 1)?,
3487 e.matmul(&fa.wv, h, 1)?,
3488 )
3489 };
3490 // q|gate fused: [2*head_dim per head]. Split on-device (no dtoh/host-loop/htod).
3491 // M3/Hy3 have no attention output gate — wq out is exactly q; skip the split.
3492 let gated = geometry.attention_gate == memra_gguf::config::AttentionGateKind::FusedQ;
3493 let (mut q, gate) = if gated {
3494 let mut q = e.uninit(n_head * head_dim)?;
3495 let mut gate = e.uninit(n_head * head_dim)?;
3496 e.q_gate_split(&qf, &mut q, &mut gate, head_dim, n_head, 1)?;
3497 (q, Some(gate))
3498 } else {
3499 (qf, None)
3500 };
3501
3502 // QK-norm + RoPE at position `pos`
3503 let mut qn = e.uninit(n_head * head_dim)?;
3504 e.rms_norm(&q, fa.q_norm.float_data(), &mut qn, head_dim, n_head, eps)?;
3505 q = qn;
3506 let mut kn = e.uninit(n_head_kv * head_dim)?;
3507 e.rms_norm(
3508 &k,
3509 fa.k_norm.float_data(),
3510 &mut kn,
3511 head_dim,
3512 n_head_kv,
3513 eps,
3514 )?;
3515 k = kn;
3516 let rope_dims = geometry.n_rot as usize;
3517 e.rope_neox(
3518 &mut q,
3519 pos_d,
3520 head_dim,
3521 rope_dims,
3522 n_head,
3523 1,
3524 geometry.rope_base,
3525 1.0,
3526 )?;
3527 e.rope_neox(
3528 &mut k,
3529 pos_d,
3530 head_dim,
3531 rope_dims,
3532 n_head_kv,
3533 1,
3534 geometry.rope_base,
3535 1.0,
3536 )?;
3537
3538 // append k,v into the RESIDENT GPU QUANTIZED KV cache at the current position (q8_0 K /
3539 // q5_1 V, on-device append-quantize kernel; no host round-trip). KVQUANT-PLAN §C/E2.
3540 let kvl = cache.kv[il].as_mut().unwrap();
3541 e.append_kv_quantized(
3542 &k,
3543 &v,
3544 &mut kvl.k,
3545 &mut kvl.v,
3546 kvl.len,
3547 kvl.kv_dim_k,
3548 kvl.kv_dim_v,
3549 kvl.k_tok_bytes,
3550 kvl.v_tok_bytes,
3551 crate::Engine::kv_fp8_on(),
3552 )?;
3553 kvl.len += 1;
3554 let t_kv = kvl.len;
3555
3556 // attend: q[hd,nh,1] over the resident byte K/V (view first t_kv*tok_bytes BYTES).
3557 let k_view = e.view_u8(&kvl.k, t_kv * kvl.k_tok_bytes);
3558 let v_view = e.view_u8(&kvl.v, t_kv * kvl.v_tok_bytes);
3559 let (ktb, vtb) = (kvl.k_tok_bytes, kvl.v_tok_bytes);
3560 let mut attn = e.uninit(n_head * head_dim)?;
3561 if std::env::var("MEMRA_NOFA").is_ok() {
3562 return Err(
3563 "MEMRA_NOFA (naive f32 SDPA) is incompatible with the quantized KV cache; \
3564 unset MEMRA_NOFA to use fa_decode"
3565 .into(),
3566 );
3567 }
3568 e.fa_decode_kvmod(
3569 &q,
3570 &k_view,
3571 &v_view,
3572 &mut attn,
3573 head_dim,
3574 n_head,
3575 n_head_kv,
3576 t_kv,
3577 scale,
3578 ktb,
3579 vtb,
3580 crate::Engine::kv_fp8_on(),
3581 )?;
3582 let _ = pos;
3583
3584 // output gate: attn * sigmoid(gate), then o-proj
3585 let attn_g = match &gate {
3586 Some(gate) => {
3587 let mut gsig = e.uninit(n_head * head_dim)?;
3588 e.sigmoid(gate, &mut gsig, n_head * head_dim)?;
3589 let mut ag = e.uninit(n_head * head_dim)?;
3590 e.mul(&attn, &gsig, &mut ag, n_head * head_dim)?;
3591 ag
3592 }
3593 None => attn,
3594 };
3595 Ok(e.matmul(&fa.wo, &attn_g, 1)?)
3596 }
3597
3598 /// BATCHED full-attention decode over `m` independent streams (one token each).
3599 ///
3600 /// Generic m-band primitive, not lockstep-specific: any caller holding `m` streams at the
3601 /// same layer (multi-stream decode, a continuous-batching serve loop) can use it. The split
3602 /// follows what the hardware cares about — WEIGHT-BOUND work runs once at `m` because all
3603 /// streams share the same projection weights (one weight read serves `m` tokens instead of
3604 /// `m` reads), while KV-BOUND work stays per stream because each stream owns its own cache.
3605 ///
3606 /// Bit-identity with the per-stream path holds by construction: `quantize_q8_1` and
3607 /// `rms_norm` are per-row, `rope_neox` takes a per-token position vector, the fused3/matmul
3608 /// m-band kernels are the same ones spec verify is gated on, and attention itself is
3609 /// untouched per stream.
3610 ///
3611 /// `xcat` is `[m, n_embd]` normed activations; `pos_cat` is the `m` rope positions;
3612 /// returns `[m, n_embd]` attention outputs.
3613 #[allow(clippy::too_many_arguments)]
3614 pub(crate) fn full_attn_decode_batched(
3615 &self,
3616 e: &Engine,
3617 fa: &FullAttnLayer,
3618 xcat: &CudaSlice<f32>,
3619 m: usize,
3620 pos_cat: &CudaSlice<i32>,
3621 caches: &mut [Cache],
3622 il: usize,
3623 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
3624 if self.uses_sliding_gated_moe_program() {
3625 return Err(
3626 "step35 has no batched (m-stream) decode mixer — per-layer n_head, \
3627 partial rope and the SWA offset view need a step35 twin"
3628 .into(),
3629 );
3630 }
3631 let cfg = &self.cfg;
3632 let geometry = cfg.full_attention_geometry_at(il as u32);
3633 let n_head = geometry.n_head as usize;
3634 let n_head_kv = geometry.n_head_kv as usize;
3635 let head_dim = geometry.head_dim_k as usize;
3636 let n_embd = cfg.n_embd as usize;
3637 let eps = cfg.rms_eps;
3638 let scale = geometry.attention_scale();
3639 let q_row = n_head * head_dim;
3640 let kv_row = n_head_kv * head_dim;
3641
3642 // --- weight-bound: one quantize + one q/k/v projection for all m streams ---
3643 let (hq, hd) = e.quantize_q8_1(xcat, m, n_embd)?;
3644 let use_q8 =
3645 e.uses_q8_1_fast(&fa.wq) && e.uses_q8_1_fast(&fa.wk) && e.uses_q8_1_fast(&fa.wv);
3646 let (qf, mut k, v) = if use_q8 {
3647 match e.matmul_q8_fused3_t(&fa.wq, &fa.wk, &fa.wv, &hq, &hd, m)? {
3648 Some(trio) => trio,
3649 None => (
3650 e.matmul_pre(&fa.wq, &hq, &hd, xcat, m)?,
3651 e.matmul_pre(&fa.wk, &hq, &hd, xcat, m)?,
3652 e.matmul_pre(&fa.wv, &hq, &hd, xcat, m)?,
3653 ),
3654 }
3655 } else {
3656 (
3657 e.matmul(&fa.wq, xcat, m)?,
3658 e.matmul(&fa.wk, xcat, m)?,
3659 e.matmul(&fa.wv, xcat, m)?,
3660 )
3661 };
3662
3663 // --- elementwise: batched by treating the m streams as extra rows/tokens ---
3664 let gated = geometry.attention_gate == memra_gguf::config::AttentionGateKind::FusedQ;
3665 let (mut q, gate) = if gated {
3666 let mut q = e.uninit(m * q_row)?;
3667 let mut gate = e.uninit(m * q_row)?;
3668 e.q_gate_split(&qf, &mut q, &mut gate, head_dim, n_head, m)?;
3669 (q, Some(gate))
3670 } else {
3671 (qf, None)
3672 };
3673 let mut qn = e.uninit(m * q_row)?;
3674 e.rms_norm(
3675 &q,
3676 fa.q_norm.float_data(),
3677 &mut qn,
3678 head_dim,
3679 n_head * m,
3680 eps,
3681 )?;
3682 q = qn;
3683 let mut kn = e.uninit(m * kv_row)?;
3684 e.rms_norm(
3685 &k,
3686 fa.k_norm.float_data(),
3687 &mut kn,
3688 head_dim,
3689 n_head_kv * m,
3690 eps,
3691 )?;
3692 k = kn;
3693 let rope_dims = geometry.n_rot as usize;
3694 e.rope_neox(
3695 &mut q,
3696 pos_cat,
3697 head_dim,
3698 rope_dims,
3699 n_head,
3700 m,
3701 geometry.rope_base,
3702 1.0,
3703 )?;
3704 e.rope_neox(
3705 &mut k,
3706 pos_cat,
3707 head_dim,
3708 rope_dims,
3709 n_head_kv,
3710 m,
3711 geometry.rope_base,
3712 1.0,
3713 )?;
3714
3715 // --- KV-bound: each stream appends to and attends over its own cache ---
3716 let mut attn_cat = e.uninit(m * q_row)?;
3717 let mut q_s = e.uninit(q_row)?;
3718 let mut k_s = e.uninit(kv_row)?;
3719 let mut v_s = e.uninit(kv_row)?;
3720 for (s, cache) in caches.iter_mut().enumerate().take(m) {
3721 e.copy_view_into(&mut k_s, 0, &k.slice(s * kv_row..(s + 1) * kv_row), kv_row)?;
3722 e.copy_view_into(&mut v_s, 0, &v.slice(s * kv_row..(s + 1) * kv_row), kv_row)?;
3723 e.copy_view_into(&mut q_s, 0, &q.slice(s * q_row..(s + 1) * q_row), q_row)?;
3724 let kvl = cache.kv[il].as_mut().unwrap();
3725 e.append_kv_quantized(
3726 &k_s,
3727 &v_s,
3728 &mut kvl.k,
3729 &mut kvl.v,
3730 kvl.len,
3731 kvl.kv_dim_k,
3732 kvl.kv_dim_v,
3733 kvl.k_tok_bytes,
3734 kvl.v_tok_bytes,
3735 crate::Engine::kv_fp8_on(),
3736 )?;
3737 kvl.len += 1;
3738 let t_kv = kvl.len;
3739 let k_view = e.view_u8(&kvl.k, t_kv * kvl.k_tok_bytes);
3740 let v_view = e.view_u8(&kvl.v, t_kv * kvl.v_tok_bytes);
3741 let mut attn = e.uninit(q_row)?;
3742 e.fa_decode_kvmod(
3743 &q_s,
3744 &k_view,
3745 &v_view,
3746 &mut attn,
3747 head_dim,
3748 n_head,
3749 n_head_kv,
3750 t_kv,
3751 scale,
3752 kvl.k_tok_bytes,
3753 kvl.v_tok_bytes,
3754 crate::Engine::kv_fp8_on(),
3755 )?;
3756 e.copy_into(&mut attn_cat, s * q_row, &attn, q_row)?;
3757 }
3758
3759 // --- weight-bound again: gate epilogue + one output projection for all m streams ---
3760 let attn_g = match &gate {
3761 Some(gate) => {
3762 let mut gsig = e.uninit(m * q_row)?;
3763 e.sigmoid(gate, &mut gsig, m * q_row)?;
3764 let mut ag = e.uninit(m * q_row)?;
3765 e.mul(&attn_cat, &gsig, &mut ag, m * q_row)?;
3766 ag
3767 }
3768 None => attn_cat,
3769 };
3770 e.matmul(&fa.wo, &attn_g, m)
3771 }
3772
3773 /// Linear-attention decode: conv with ring-buffer state, GDN scan carrying SSM state.
3774 pub fn linear_attn_decode(
3775 &self,
3776 e: &Engine,
3777 la: &LinearAttnLayer,
3778 h: &CudaSlice<f32>,
3779 cache: &mut Cache,
3780 il: usize,
3781 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
3782 self.linear_attn_decode_inner(e, la, h, None, cache, il, false)
3783 }
3784
3785 /// PRE-QUANTIZED-INPUT variant (DECODE attn-input NORM-FUSION lever): the caller passes the
3786 /// post-attn-norm activation ALREADY q8_1-quantized `(hq,hd)` (produced by rms_norm_q8_1, fusing
3787 /// the attn_norm + the mixer's internal quantize_q8_1). Skips the internal quantize. Caller
3788 /// GUARANTEES the projections are q8_1-fast. `persistent` selects the capture-safe state plumbing.
3789 /// BIT-IDENTICAL to linear_attn_decode(h) when (hq,hd)==quantize_q8_1(rms_norm(x)*w).
3790 pub fn linear_attn_decode_pre(
3791 &self,
3792 e: &Engine,
3793 la: &LinearAttnLayer,
3794 h: &CudaSlice<f32>,
3795 hq: &CudaSlice<i8>,
3796 hd: &CudaSlice<f32>,
3797 cache: &mut Cache,
3798 il: usize,
3799 persistent: bool,
3800 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
3801 self.linear_attn_decode_inner(e, la, h, Some((hq, hd)), cache, il, persistent)
3802 }
3803
3804 /// CAPTURE variant of `linear_attn_decode` (CUDA-GRAPH-PLAN Phase 3). The GDN scan needs distinct
3805 /// in/out SSM-state buffers; the eager path SWAPS a fresh scratch into `rl.ssm_state` (new pointer
3806 /// each step), which is a CAPTURE HAZARD — the graph bakes capture-time pointers and never re-runs
3807 /// the host swap, so replay would read a stale state buffer. Here we instead COPY the scratch back
3808 /// into the STABLE `rl.ssm_state` buffer (memcpy_dtod, captured, same pointers every replay). Math
3809 /// is identical; only the buffer plumbing differs. `conv_state` is already mutated in place (no
3810 /// pointer change) so it is capture-safe as-is.
3811 pub(crate) fn linear_attn_decode_cap(
3812 &self,
3813 e: &Engine,
3814 la: &LinearAttnLayer,
3815 h: &CudaSlice<f32>,
3816 cache: &mut Cache,
3817 il: usize,
3818 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
3819 self.linear_attn_decode_inner(e, la, h, None, cache, il, true)
3820 }
3821
3822 fn linear_attn_decode_inner(
3823 &self,
3824 e: &Engine,
3825 la: &LinearAttnLayer,
3826 h: &CudaSlice<f32>,
3827 pre_q: Option<(&CudaSlice<i8>, &CudaSlice<f32>)>,
3828 cache: &mut Cache,
3829 il: usize,
3830 persistent_state: bool,
3831 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
3832 let cfg = &self.cfg;
3833 let geometry = la.geometry;
3834 let d_state = geometry.key_head_dim as usize;
3835 let num_k = geometry.key_heads as usize;
3836 let num_v = geometry.value_heads as usize;
3837 let d_conv = geometry.conv_kernel as usize;
3838 let head_k = d_state;
3839 let key_dim = head_k * num_k;
3840 let value_dim = geometry.value_head_dim as usize * num_v;
3841 let conv_dim = key_dim * 2 + value_dim;
3842 let eps = cfg.rms_eps;
3843 let scale = 1.0 / (d_state as f32).sqrt();
3844
3845 // projections (T=1): wqkv, wqkv_gate, ssm_beta, ssm_alpha ALL take input `h` (in_f = n_embd)
3846 // -> quantize q8_1 ONCE, feed all four (was 4x redundant quantize_q8_1 of the same row).
3847 let n_embd = cfg.n_embd as usize;
3848 let all_fast = e.uses_q8_1_fast(&la.wqkv)
3849 && e.uses_q8_1_fast(&la.wqkv_gate)
3850 && e.uses_q8_1_fast(&la.ssm_beta)
3851 && e.uses_q8_1_fast(&la.ssm_alpha);
3852 // beta+alpha DUAL fuse (2026-07-05): ssm_beta and ssm_alpha are the same tiny shape
3853 // ([n_embd -> num_v=32]) — out_f=32 launches are pure launch latency (15-16us each,
3854 // HANDOVER b4-headroom note). The existing dual mr2 kernel (FFN gate+up) folds them into
3855 // ONE launch. Bit-identical per row: same MMVQ warp-per-row body, blockIdx.y picks the
3856 // weight; the separable macro-scale multiply is the same single f32 mul as matmul_pre's
3857 // in-kernel scale. Falls back to two matmul_pre when ineligible (Float layers 1/2/4 etc).
3858 let beta_alpha =
3859 |e: &Engine,
3860 hq: &CudaSlice<i8>,
3861 hd: &CudaSlice<f32>|
3862 -> Result<(CudaSlice<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
3863 if let Some(((mut b, bs), (mut a, as_))) =
3864 e.matmul_pre_dual_noscale(&la.ssm_beta, &la.ssm_alpha, hq, hd, 1)?
3865 {
3866 if bs != 1.0 {
3867 e.scale_inplace(&mut b, bs, la.ssm_beta.out_features())?;
3868 }
3869 if as_ != 1.0 {
3870 e.scale_inplace(&mut a, as_, la.ssm_alpha.out_features())?;
3871 }
3872 return Ok((b, a));
3873 }
3874 // Q8_0 twin of the NVFP4 dual (9B GGUFs store ssm_beta/alpha as Q8_0 on most layers):
3875 // one fused2 launch, bit-identical per row, no macro-scale (q8_0 scale==1.0).
3876 if let Some((b, a)) = e.matmul_q8_fused2(&la.ssm_beta, &la.ssm_alpha, hq, hd)? {
3877 return Ok((b, a));
3878 }
3879 Ok((
3880 e.matmul_pre(&la.ssm_beta, hq, hd, h, 1)?,
3881 e.matmul_pre(&la.ssm_alpha, hq, hd, h, 1)?,
3882 ))
3883 };
3884 // Q8 TRUNK-FUSION (2026-07-05): wqkv+wqkv_gate share (hq,hd) and in_f — on the 35B both
3885 // are Q8_0 (out_f 8192/4096), so ONE fused2 launch replaces the two biggest
3886 // launch-latency-class m=1 launches of every linear layer. BIT-IDENTICAL per (tensor,row)
3887 // (same MMVQ body, block-offset split). Falls back per-tensor when ineligible.
3888 let qkv_pair =
3889 |e: &Engine,
3890 hq: &CudaSlice<i8>,
3891 hd: &CudaSlice<f32>|
3892 -> Result<(CudaSlice<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
3893 if let Some((qkv, z)) = e.matmul_q8_fused2(&la.wqkv, &la.wqkv_gate, hq, hd)? {
3894 return Ok((qkv, z));
3895 }
3896 Ok((
3897 e.matmul_pre(&la.wqkv, hq, hd, h, 1)?,
3898 e.matmul_pre(&la.wqkv_gate, hq, hd, h, 1)?,
3899 ))
3900 };
3901 let (qkv_mixed, z, beta_raw, alpha) = if all_fast {
3902 // attn-input NORM-FUSION: use the caller's pre-quantized (hq,hd) when provided (the
3903 // attn_norm already emitted q8_1 via rms_norm_q8_1), else quantize h here. Bit-identical.
3904 match pre_q {
3905 Some((hq, hd)) => {
3906 let (b, a) = beta_alpha(e, hq, hd)?;
3907 let (qkv, z) = qkv_pair(e, hq, hd)?;
3908 (qkv, z, b, a)
3909 }
3910 None => {
3911 let (hq, hd) = e.quantize_q8_1(h, 1, n_embd)?;
3912 let (b, a) = beta_alpha(e, &hq, &hd)?;
3913 let (qkv, z) = qkv_pair(e, &hq, &hd)?;
3914 (qkv, z, b, a)
3915 }
3916 }
3917 } else {
3918 // 35B trunk lands HERE: wqkv/wqkv_gate are Q8_0 but ssm_beta/alpha are F32, so
3919 // all_fast is false. Still fuse the two Q8_0 projections (one quantize + ONE launch
3920 // instead of two matmuls each re-quantizing h) — matmul_q8_fused2_x is bit-identical
3921 // to the two m=1 MMVQ dispatches. beta/alpha keep the Float cuBLAS path.
3922 let (qm, zg) = match e.matmul_q8_fused2_x(&la.wqkv, &la.wqkv_gate, h)? {
3923 Some(pair) => pair,
3924 None => (e.matmul(&la.wqkv, h, 1)?, e.matmul(&la.wqkv_gate, h, 1)?),
3925 };
3926 (
3927 qm,
3928 zg,
3929 e.matmul(&la.ssm_beta, h, 1)?,
3930 e.matmul(&la.ssm_alpha, h, 1)?,
3931 )
3932 };
3933
3934 // RANK3 LEVER (conv fuse): assemble [conv_state | new col], depthwise causal conv + SiLU, and
3935 // roll the ring — ALL in ONE kernel (`ssm_conv1d_fused_decode`), never materializing conv_in
3936 // to HBM. Replaces conv_assemble_and_roll + ssm_conv1d. Bit-identical (same accumulation order).
3937 let rl = cache.recur[il].as_mut().unwrap();
3938 let mut conv_out = e.uninit(conv_dim)?; // [conv_dim, 1] channel-major, SiLU
3939 e.ssm_conv1d_fused_decode(
3940 &qkv_mixed,
3941 &mut rl.conv_state,
3942 la.ssm_conv1d.float_data(),
3943 &mut conv_out,
3944 conv_dim,
3945 d_conv,
3946 )?;
3947
3948 // GDN scan: SSM state stays RESIDENT on GPU. gdn needs DISTINCT in/out state buffers.
3949 // DECODE DETERMINISM FIX: write the new state into the PERSISTENT spare buffer
3950 // (`ssm_state_alt`) and PING-PONG the two owned buffers in place — instead of allocating a
3951 // fresh `state_scratch` via `e.uninit` each step and swapping its pointer in. The old
3952 // per-step alloc/free churned the stream-ordered async pool; the freed prior state block was
3953 // recycled by a later step's scratch while a kernel still referenced the swapped-in state,
3954 // a use-after-reuse that made decode RUN-TO-RUN nondeterministic (two identical primes
3955 // diverged). With two stable resident buffers there is no per-step alloc/free and no pool
3956 // churn; the math is byte-identical. `o` is a true per-step output (consumed immediately by
3957 // gated_rmsnorm below) so it stays a normal scratch.
3958 let mut o = e.uninit(d_state * num_v)?;
3959 let n_state = d_state * d_state * num_v;
3960 let _ = head_k; // head_k == d_state; the kernels use head_k = d_state internally.
3961 // GDN PREP, FUSED (2026-07-03): repack + q/k L2-norm + beta sigmoid + g_log in ONE
3962 // gdn_prep_decode launch (was 5 tiny serialized kernels: qkv_to_gdn_repack, 2x l2_norm,
3963 // sigmoid, gdn_glog). Same math; the L2 reduce runs a 32-lane warp tree instead of the
3964 // 256-thread two-level tree (different FP sum order) — gates: argmax + run-spec exactness.
3965 // (A prep+scan single-launch fusion — lane/gdnfuse, MEMRA_GDN_FUSE — measured NEUTRAL on
3966 // eager decode 2026-07-08 and was removed in the flag audit; rig5090.jsonl holds the record.)
3967 {
3968 let mut q_l2 = e.uninit(d_state * num_v)?;
3969 let mut k_l2 = e.uninit(d_state * num_v)?;
3970 let mut v_gd = e.uninit(d_state * num_v)?;
3971 let mut beta = e.uninit(num_v)?;
3972 let mut g_log = e.uninit(num_v)?;
3973 e.gdn_prep_decode(
3974 &conv_out,
3975 &beta_raw,
3976 &alpha,
3977 la.ssm_dt.float_data(),
3978 la.ssm_a.float_data(),
3979 &mut q_l2,
3980 &mut k_l2,
3981 &mut v_gd,
3982 &mut beta,
3983 &mut g_log,
3984 d_state,
3985 num_v,
3986 num_k,
3987 key_dim,
3988 eps,
3989 )?;
3990 // gdn reads ssm_state, writes the spare ssm_state_alt (disjoint resident fields).
3991 let RecurLayer {
3992 ssm_state,
3993 ssm_state_alt,
3994 ..
3995 } = rl;
3996 e.gdn_scan_s128(
3997 &q_l2,
3998 &k_l2,
3999 &v_gd,
4000 &g_log,
4001 &beta,
4002 ssm_state,
4003 ssm_state_alt,
4004 &mut o,
4005 num_v,
4006 1,
4007 scale,
4008 )?;
4009 }
4010 if persistent_state {
4011 // CAPTURE-safe (graph replay): the canonical state every replay reads must stay at a
4012 // FIXED pointer (baked into the captured graph). Copy the freshly-written spare BACK
4013 // into ssm_state (captured, replays each launch). No host pointer swap.
4014 let alt = std::mem::replace(&mut rl.ssm_state_alt, e.zeros(0)?);
4015 e.copy_into(&mut rl.ssm_state, 0, &alt, n_state)?;
4016 rl.ssm_state_alt = alt;
4017 } else {
4018 // EAGER: swap the two OWNED resident buffers in place (stable pointers, no alloc/free).
4019 std::mem::swap(&mut rl.ssm_state, &mut rl.ssm_state_alt);
4020 }
4021
4022 // gated RMSNorm + ssm_out. FUSED-QUANTIZE ARM (launch-arc): when ssm_out rides the
4023 // q8_1 fast path, emit q8_1 straight from the gated norm (bit-identical bytes to
4024 // gated_rmsnorm + quantize_q8_1) and feed matmul_pre — one launch instead of three
4025 // (norm, quantize, scale all fold away). Fallback = the original f32 chain.
4026 if e.uses_q8_1_fast(&la.ssm_out) {
4027 // norm is PER d_state-ROW (num_v rows), exactly like the f32 twin's grid; the q8_1
4028 // block stream is row-major so the flat bytes feed the matvec unchanged.
4029 let (gq, gd) =
4030 e.gated_rmsnorm_q8_1(&o, la.ssm_norm.float_data(), &z, d_state, num_v, eps)?;
4031 let g0 = e.zeros(0)?;
4032 return Ok(e.matmul_pre(&la.ssm_out, &gq, &gd, &g0, 1)?);
4033 }
4034 let mut gn = e.uninit(d_state * num_v)?;
4035 e.gated_rmsnorm(
4036 &o,
4037 la.ssm_norm.float_data(),
4038 &z,
4039 &mut gn,
4040 d_state,
4041 num_v,
4042 eps,
4043 )?;
4044 Ok(e.matmul(&la.ssm_out, &gn, 1)?)
4045 }
4046}