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