Skip to main content

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::model::Model;
9use crate::Engine;
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}