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