memra_engine/forward.rs
1//! Dense forward pass (Stage-1, all f32, prefill of T tokens, batch=1).
2//! Matches llama.cpp qwen3 graph: embed → per layer {RMSNorm, QKV, QK-norm, RoPE, SDPA, O,
3//! residual, RMSNorm, SwiGLU, residual} → output_norm → lm_head.
4//!
5//! Activation layout: x is [n_embd, T] but we store it row-major-per-token as [T, n_embd]
6//! (token t at offset t*n_embd) so cuBLASLt linear (m=T tokens, in=n_embd) works directly.
7
8use crate::Engine;
9use crate::model::Model;
10
11impl Model {
12 /// Run prefill over `tokens`, return logits [T, n_vocab] (host f32). positions = 0..T.
13 pub fn forward(
14 &self,
15 e: &Engine,
16 tokens: &[u32],
17 ) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
18 let cfg = &self.cfg;
19 let n_embd = cfg.n_embd as usize;
20 let n_head = cfg.n_head as usize;
21 let n_head_kv = cfg.n_head_kv as usize;
22 let head_dim = cfg.head_dim_k as usize;
23 let t = tokens.len();
24 let eps = cfg.rms_eps;
25 let scale = 1.0 / (head_dim as f32).sqrt();
26
27 // positions 0..T
28 let pos: Vec<i32> = (0..t as i32).collect();
29 let pos_d = e.htod_i32(&pos)?;
30
31 // x: [T, n_embd] (token-major)
32 let mut x = self.embed_tokens(e, tokens)?;
33 // Fixed MoE cache-slot size (0 for a non-MoE dense model). Computed once for the whole run.
34 let max_block = self.max_moe_block();
35
36 for (il, layer) in self.layers.iter().enumerate() {
37 // --- attention block ---
38 let mut h = e.zeros(t * n_embd)?;
39 e.rms_norm(&x, layer.attn_norm.float_data(), &mut h, n_embd, t, eps)?;
40
41 // QKV projections: q[T, n_head*head_dim], k/v[T, n_head_kv*head_dim]
42 let q_out = layer.wq.out_features(); // n_head*head_dim
43 let k_out = layer.wk.out_features();
44 let mut q = e.matmul(&layer.wq, &h, t)?;
45 let mut k = e.matmul(&layer.wk, &h, t)?;
46 let v = e.matmul(&layer.wv, &h, t)?;
47
48 // QK-norm: RMSNorm over head_dim, per (token, head). Layout [head_dim, n_head, T]
49 // == row-major rows of length head_dim. q currently [T, n_head*head_dim] which is
50 // exactly n_head*T rows of head_dim if we treat each head slice as a row. The memory
51 // for token t is [head0(head_dim) head1(head_dim) ...]; so rows of head_dim are
52 // contiguous and number n_head*T. RMSNorm with ncols=head_dim, nrows=n_head*T works.
53 // rms_norm(ncols=head_dim, nrows=n_head*T) multiplies each row by q_norm[head_dim] —
54 // exactly per-head QK-norm. Rows of head_dim are contiguous in the token-major buffer.
55 if let Some(qn) = &layer.q_norm {
56 let mut qn_out = e.zeros(t * q_out)?;
57 e.rms_norm(&q, qn.float_data(), &mut qn_out, head_dim, n_head * t, eps)?;
58 q = qn_out;
59 }
60 if let Some(kn) = &layer.k_norm {
61 let mut kn_out = e.zeros(t * k_out)?;
62 e.rms_norm(
63 &k,
64 kn.float_data(),
65 &mut kn_out,
66 head_dim,
67 n_head_kv * t,
68 eps,
69 )?;
70 k = kn_out;
71 }
72
73 // RoPE on q,k. Layout per token is [head_dim, n_head] contiguous. Our buffer is
74 // [T, n_head*head_dim]; rope_neox expects [head_dim, n_heads, n_tokens] with grid
75 // n_heads*n_tokens and head index = blockIdx % n_heads. Token-major works if we pass
76 // n_heads and the kernel treats hr = token*n_heads + head. Our layout: token t at
77 // t*(n_head*head_dim), head h at +h*head_dim. So hr index = t*n_head + h → matches
78 // kernel's head=hr%n_heads, tok=hr/n_heads ONLY if hr = tok*n_head+head. Good.
79 e.rope_neox(
80 &mut q,
81 &pos_d,
82 head_dim,
83 cfg.rope_dim_count as usize,
84 n_head,
85 t,
86 cfg.rope_freq_base,
87 1.0,
88 )?;
89 e.rope_neox(
90 &mut k,
91 &pos_d,
92 head_dim,
93 cfg.rope_dim_count as usize,
94 n_head_kv,
95 t,
96 cfg.rope_freq_base,
97 1.0,
98 )?;
99
100 // SDPA: q[head_dim,n_head,T], k/v[head_dim,n_head_kv,T] (T_kv = T for prefill).
101 // Our buffers are token-major [T, heads*head_dim] == [head_dim, heads, T] interpreting
102 // index (d, head, tok) at tok*(heads*head_dim)+head*head_dim+d. The SDPA kernel indexes
103 // Q at (qt*n_head+head)*head_dim+d — identical. Good.
104 let mut attn = e.zeros(t * q_out)?;
105 e.sdpa_naive(
106 &q, &k, &v, &mut attn, head_dim, n_head, n_head_kv, t, t, scale, true,
107 )?;
108
109 // O projection: attn[T, n_head*head_dim] @ wo[n_embd, n_head*head_dim]^T
110 let o = e.matmul(&layer.wo, &attn, t)?;
111
112 // residual 1
113 let mut x1 = e.zeros(t * n_embd)?;
114 e.add(&x, &o, &mut x1, t * n_embd)?;
115
116 // --- ffn block: dense SwiGLU or routed MoE (OLMoE) ---
117 let mut z = e.zeros(t * n_embd)?;
118 e.rms_norm(&x1, layer.ffn_norm.float_data(), &mut z, n_embd, t, eps)?;
119 let down = match &layer.ffn {
120 crate::hybrid::Ffn::Dense {
121 ffn_gate,
122 ffn_up,
123 ffn_down,
124 } => {
125 let n_ff = ffn_gate.out_features();
126 let gate = e.matmul(ffn_gate, &z, t)?;
127 let up = e.matmul(ffn_up, &z, t)?;
128 let mut act = e.zeros(t * n_ff)?;
129 crate::hybrid::HybridModel::ffn_act(e, cfg, &gate, &up, &mut act, t * n_ff)?;
130 e.matmul(ffn_down, &act, t)?
131 }
132 crate::hybrid::Ffn::Moe(m) => {
133 crate::hybrid::HybridModel::moe_ffn(e, m, &z, t, cfg, il as u16, max_block)?
134 }
135 };
136
137 // residual 2
138 let mut x2 = e.zeros(t * n_embd)?;
139 e.add(&x1, &down, &mut x2, t * n_embd)?;
140 x = x2;
141 }
142
143 // final norm + lm_head
144 let mut hn = e.zeros(t * n_embd)?;
145 e.rms_norm(&x, self.output_norm.float_data(), &mut hn, n_embd, t, eps)?;
146 let logits = e.matmul(&self.output, &hn, t)?;
147 let host = e.dtoh(&logits)?;
148 Ok(host)
149 }
150
151 /// Logits for just the last token (the decode-relevant row).
152 pub fn forward_last(
153 &self,
154 e: &Engine,
155 tokens: &[u32],
156 ) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
157 let all = self.forward(e, tokens)?;
158 let n_vocab = self.output.out_features();
159 let t = tokens.len();
160 Ok(all[(t - 1) * n_vocab..t * n_vocab].to_vec())
161 }
162}
163
164/// argmax helper.
165pub fn argmax(logits: &[f32]) -> usize {
166 let mut best = 0;
167 let mut bv = f32::NEG_INFINITY;
168 for (i, &v) in logits.iter().enumerate() {
169 if v > bv {
170 bv = v;
171 best = i;
172 }
173 }
174 best
175}
176
177/// top-1/top-2 (id, value) pairs — the greedy near-tie margin is v1 - v2.
178pub fn top2(logits: &[f32]) -> (usize, f32, usize, f32) {
179 let (mut i1, mut v1, mut i2, mut v2) = (0usize, f32::NEG_INFINITY, 0usize, f32::NEG_INFINITY);
180 for (i, &v) in logits.iter().enumerate() {
181 if v > v1 {
182 i2 = i1;
183 v2 = v1;
184 i1 = i;
185 v1 = v;
186 } else if v > v2 {
187 i2 = i;
188 v2 = v;
189 }
190 }
191 (i1, v1, i2, v2)
192}
193
194/// Gate #46 verdict: BATCHED-PRIME last-position logits vs the TOKENWISE-PRIME reference.
195///
196/// The two primes are different numeric configs by design (prefill GEMM m=T vs decode
197/// GEMV m=1 — cross-config drift class, like forward_last vs decode). An argmax flip on a
198/// near-tie under small logit drift is within that law; a flip on a WIDE margin, or drift
199/// beyond the calibrated ceiling, cannot come from the FP-composition class and fails hard.
200///
201/// Bounds (env-overridable; calibrated on the 2026-08-02 supported-set sweep,
202/// research/prime-gate-coverage-20260802 — recalibrate when the kernels under them move,
203/// the H100 stale-verdict law):
204/// MEMRA_PRIME_GATE_MAXDIFF — full-vocab logit maxdiff ceiling (default 8.0). Measured
205/// legal cross-config drift across the 144-prompt supported-set sweep: dense Q8_0 up
206/// to ~1.0, MoE IQ4_XS/Q4_K_M up to 3.1, gemma QAT Q4_0 up to 5.5 (its logit scale is
207/// larger); run-gen's own accepted forward-vs-decode drift is 1.39 on the q35 probe.
208/// A real defect (indexing/wrong-weights) lands decades above this.
209/// MEMRA_PRIME_GATE_MARGIN — tokenwise top1-top2 margin above which an argmax flip is
210/// treated as structured (default 1.0). All 10 measured legal first-token flips sat
211/// at margins <= 0.70 (per-position flips <= 0.92).
212#[derive(Debug, PartialEq, Eq, Clone, Copy)]
213pub enum PrimeGateClass {
214 Match,
215 NearTieFlip,
216 Structured,
217}
218
219pub struct PrimeGateVerdict {
220 pub tw_argmax: usize,
221 pub bp_argmax: usize,
222 pub tw_margin: f32,
223 pub bp_margin: f32,
224 pub maxdiff: f32,
225 pub class: PrimeGateClass,
226}
227
228fn env_f32(key: &str, default: f32) -> f32 {
229 std::env::var(key)
230 .ok()
231 .and_then(|v| v.parse().ok())
232 .unwrap_or(default)
233}
234
235pub fn prime_gate_verdict(tokenwise: &[f32], batched: &[f32]) -> PrimeGateVerdict {
236 let (t1, tv1, _, tv2) = top2(tokenwise);
237 let (b1, bv1, _, bv2) = top2(batched);
238 let maxdiff = tokenwise
239 .iter()
240 .zip(batched)
241 .map(|(a, b)| (a - b).abs())
242 .fold(0.0f32, f32::max);
243 let maxdiff_bound = env_f32("MEMRA_PRIME_GATE_MAXDIFF", 8.0);
244 let margin_bound = env_f32("MEMRA_PRIME_GATE_MARGIN", 1.0);
245 let tw_margin = tv1 - tv2;
246 let class = if !maxdiff.is_finite() || maxdiff > maxdiff_bound {
247 PrimeGateClass::Structured
248 } else if t1 == b1 {
249 PrimeGateClass::Match
250 } else if tw_margin <= margin_bound {
251 PrimeGateClass::NearTieFlip
252 } else {
253 PrimeGateClass::Structured
254 };
255 PrimeGateVerdict {
256 tw_argmax: t1,
257 bp_argmax: b1,
258 tw_margin,
259 bp_margin: bv1 - bv2,
260 maxdiff,
261 class,
262 }
263}