Skip to main content

needle_core/v2/
batch.rs

1//! Batched prefill: many prompt positions through one pass over the weights.
2//!
3//! [`V2Model::step`] is the reference path and processes one position at a time,
4//! which means it walks the entire packed weight set once per token. Prefill
5//! feeds a whole prompt, so those walks are pure repetition: every position
6//! multiplies by the *same* weights.
7//!
8//! This module keeps the same arithmetic but inverts the loop nesting, so each
9//! quantisation group is decoded once and applied to every position in the chunk
10//! (see [`CqWeight::matmul_rows_prepared`]). The decode work and the weight
11//! traffic are paid once per chunk instead of once per token.
12//!
13//! # Why it is exact, not merely close
14//!
15//! `matmul_rows_prepared` is bit-identical to repeated `matvec_rows_prepared`,
16//! and everything outside the projections is per-position arithmetic that is
17//! untouched. So a batched prefill must reproduce the sequential path's logits
18//! exactly, which is what `prefill_batch_matches_sequential` asserts. The
19//! sequential path stays under test by the 788-point reference ladder, so the two
20//! paths hold each other in place.
21//!
22//! # What has to stay sequential
23//!
24//! Only attention. Position `p` attends to keys at `<= p`, including keys from
25//! earlier positions *in the same chunk*, so the chunk's K/V are all computed and
26//! written to the cache before any position attends. Everything else — the mHC
27//! router, the Engram lookups, HadamardMLP — is per-position and order-free.
28//!
29//! Chunking is therefore also a correctness check: a prompt longer than one chunk
30//! exercises the cross-chunk KV handoff, which is the same machinery a multi-turn
31//! session needs.
32
33use crate::math;
34use crate::norm::zc_rms_norm_vec;
35use crate::ops::{sigmoid, softmax_inplace};
36use crate::v2::kernels::{hadamard_mlp, rms_unit_to, sinkhorn};
37use crate::v2::model::{V2Model, V2State};
38use alloc::vec;
39use alloc::vec::Vec;
40
41/// Default positions per chunk.
42///
43/// Measured on the shipped geometry with `parallel` enabled, prefill cost per
44/// token: 5.74 ms unbatched, 5.06 at chunk 8, 2.80 at 32, 2.32 at 64, 2.03 at 128
45/// and above.
46///
47/// Batching *loses* below about 16 positions — the per-group weight decode is
48/// amortised over too few of them to pay for the extra passes over the
49/// activations — so a small chunk is worse than none. The curve flattens at 128,
50/// which is where this sits: the earlier default of 64 was chosen when the path
51/// was serial and 64 was within 1% of the peak, but with row-split threading the
52/// larger chunk is worth 8% and the extra 4 MB of scratch is small next to the
53/// 14 MB KV ring.
54pub const DEFAULT_CHUNK: usize = 128;
55
56/// Reusable scratch for [`V2Model::prefill_batch`].
57///
58/// Allocated once and reused across prompts. Separate from [`V2State`] because it
59/// is per-call working memory, not per-sequence state: the KV cache, token
60/// history and Engram ring stay in `V2State` and are shared with the sequential
61/// path.
62pub struct V2Batch {
63    cap: usize,
64    lanes: Vec<f32>,
65    lanes_prev: Vec<f32>,
66    nx: Vec<f32>,
67    nx_prep: Vec<f32>,
68    u: Vec<f32>,
69    bx: Vec<f32>,
70    y: Vec<f32>,
71    h: Vec<f32>,
72    h_prep: Vec<f32>,
73    hpre: Vec<f32>,
74    hpost: Vec<f32>,
75    hres: Vec<f32>,
76    q: Vec<f32>,
77    k: Vec<f32>,
78    v: Vec<f32>,
79    gate: Vec<f32>,
80    attn_out: Vec<f32>,
81    attn_prep: Vec<f32>,
82    attn_result: Vec<f32>,
83    mlp_out: Vec<f32>,
84    hada: Vec<f32>,
85    engram_k: Vec<f32>,
86    engram_val: Vec<f32>,
87    /// Per-position fetched-entry vectors, and their rotations, for one site.
88    e_all: Vec<f32>,
89    e_prep_all: Vec<f32>,
90    /// Per-position un-convolved Engram values for the chunk.
91    e_val_all: Vec<f32>,
92    scores: Vec<f32>,
93    /// Column accumulators for the batched matmul.
94    acc: Vec<f32>,
95}
96
97impl V2Batch {
98    /// Allocate scratch for up to `cap` positions per chunk. `cap` is clamped to
99    /// at least 1.
100    pub fn new(model: &V2Model, cap: usize) -> Self {
101        let cap = cap.max(1);
102        let cfg = &model.cfg;
103        let (d, n) = (cfg.d_model, cfg.mhc_lanes);
104        let sites = cfg.engram.sites.len();
105        let layer0 = &model.layers[0];
106        Self {
107            cap,
108            lanes: vec![0.0; cap * n * d],
109            lanes_prev: vec![0.0; cap * n * d],
110            nx: vec![0.0; cap * n * d],
111            nx_prep: vec![0.0; cap * model.mhc.phi_pre.prepared_len()],
112            u: vec![0.0; cap * d],
113            bx: vec![0.0; cap * d],
114            y: vec![0.0; cap * d],
115            h: vec![0.0; cap * d],
116            h_prep: vec![0.0; cap * layer0.q_proj.prepared_len()],
117            hpre: vec![0.0; cap * n],
118            hpost: vec![0.0; cap * n],
119            hres: vec![0.0; cap * n * n],
120            q: vec![0.0; cap * cfg.attn_dim()],
121            k: vec![0.0; cap * cfg.kv_dim()],
122            v: vec![0.0; cap * cfg.kv_dim()],
123            gate: vec![0.0; cap * cfg.attn_dim()],
124            attn_out: vec![0.0; cap * cfg.attn_dim()],
125            attn_prep: vec![0.0; cap * layer0.out_proj.prepared_len()],
126            attn_result: vec![0.0; cap * d],
127            mlp_out: vec![0.0; cap * d],
128            hada: vec![0.0; cfg.hada_n],
129            engram_k: vec![0.0; cap * sites * d],
130            engram_val: vec![0.0; cap * sites * d],
131            e_all: vec![0.0; cap * cfg.engram.fetched_dim().max(1)],
132            e_prep_all: vec![
133                0.0;
134                cap * model
135                    .engrams
136                    .first()
137                    .map_or(1, |e| e.value_proj.prepared_len())
138            ],
139            e_val_all: vec![0.0; cap * d],
140            scores: vec![0.0; cfg.max_seq_len],
141            acc: vec![0.0; cap],
142        }
143    }
144
145    /// Positions per chunk.
146    pub fn cap(&self) -> usize {
147        self.cap
148    }
149
150    /// Approximate scratch footprint in bytes.
151    pub fn bytes(&self) -> usize {
152        let f = |v: &Vec<f32>| v.len() * 4;
153        f(&self.lanes)
154            + f(&self.lanes_prev)
155            + f(&self.nx)
156            + f(&self.nx_prep)
157            + f(&self.u)
158            + f(&self.bx)
159            + f(&self.y)
160            + f(&self.h)
161            + f(&self.h_prep)
162            + f(&self.hpre)
163            + f(&self.hpost)
164            + f(&self.hres)
165            + f(&self.q)
166            + f(&self.k)
167            + f(&self.v)
168            + f(&self.gate)
169            + f(&self.attn_out)
170            + f(&self.attn_prep)
171            + f(&self.attn_result)
172            + f(&self.mlp_out)
173            + f(&self.hada)
174            + f(&self.engram_k)
175            + f(&self.engram_val)
176            + f(&self.scores)
177            + f(&self.acc)
178    }
179}
180
181impl V2Model {
182    /// Feed `tokens` at the current position, batching the projections.
183    ///
184    /// Chunks internally at [`V2Batch::cap`]. When `logits` is `Some`, it receives
185    /// the logits for the **final** token only — the intermediate positions skip
186    /// the tied LM head, which is what makes prefill cheaper than the same number
187    /// of [`step`] calls even before batching.
188    ///
189    /// Leaves `state` exactly as the equivalent run of [`step_prefill`] would,
190    /// so decoding can continue with [`step`].
191    ///
192    /// [`step`]: V2Model::step
193    /// [`step_prefill`]: V2Model::step_prefill
194    pub fn prefill_batch(
195        &self,
196        tokens: &[u32],
197        state: &mut V2State,
198        batch: &mut V2Batch,
199        mut logits: Option<&mut [f32]>,
200    ) -> Result<(), &'static str> {
201        let cfg = &self.cfg;
202        if tokens.is_empty() {
203            return Ok(());
204        }
205        if state.pos() + tokens.len() > cfg.max_seq_len {
206            return Err("sequence exceeds max_seq_len");
207        }
208        if tokens.iter().any(|&t| t as usize >= cfg.vocab_size) {
209            return Err("token id outside vocabulary");
210        }
211        if logits.as_ref().is_some_and(|l| l.len() != cfg.vocab_size) {
212            return Err("logits buffer is not vocab_size long");
213        }
214
215        let n_chunks = tokens.len().div_ceil(batch.cap);
216        for (ci, chunk) in tokens.chunks(batch.cap).enumerate() {
217            let last_chunk = ci + 1 == n_chunks;
218            self.prefill_chunk(chunk, state, batch)?;
219            if last_chunk {
220                if let Some(l) = logits.as_deref_mut() {
221                    // The final position's lane state is the last row of `lanes`.
222                    self.finish_logits(chunk.len() - 1, batch, l);
223                }
224            }
225        }
226        Ok(())
227    }
228
229    /// Pool, norm and project the LM head for one position of the chunk.
230    fn finish_logits(&self, i: usize, batch: &mut V2Batch, logits: &mut [f32]) {
231        let cfg = &self.cfg;
232        let (d, n) = (cfg.d_model, cfg.mhc_lanes);
233        let lanes = &batch.lanes[i * n * d..(i + 1) * n * d];
234        let out = &mut batch.attn_result[..d];
235        for (c, slot) in out.iter_mut().enumerate() {
236            let mut acc = 0.0f32;
237            for lane in 0..n {
238                acc += lanes[lane * d + c];
239            }
240            *slot = acc / n as f32;
241        }
242        zc_rms_norm_vec(out, &self.final_norm);
243        // `attn_prep` is at least `d` rounded to the group, so it can hold the
244        // rotated LM-head input.
245        let prep_len = self.embedding.prepared_len();
246        let prep = &mut batch.attn_prep[..prep_len];
247        self.embedding.prepare_input(out, prep);
248        self.embedding.matvec_prepared(prep, logits);
249    }
250
251    fn prefill_chunk(
252        &self,
253        chunk: &[u32],
254        state: &mut V2State,
255        batch: &mut V2Batch,
256    ) -> Result<(), &'static str> {
257        let cfg = &self.cfg;
258        let (d, n) = (cfg.d_model, cfg.mhc_lanes);
259        let k = chunk.len();
260        let p0 = state.pos();
261        let sites = cfg.engram.sites.len();
262
263        // ---- per-position setup: embedding and Engram.
264        for (i, &tok) in chunk.iter().enumerate() {
265            let pos = p0 + i;
266            state.set_history(pos, tok);
267            let row = &mut batch.u[..d];
268            self.embedding.dequantize_row(tok as usize, row);
269            let scale = math::sqrt(d as f32);
270            for v in row.iter_mut() {
271                *v *= scale;
272            }
273            for lane in 0..n {
274                batch.lanes[(i * n + lane) * d..(i * n + lane + 1) * d].copy_from_slice(row);
275            }
276        }
277
278        // Engram, batched. The per-position form was the last serial matmul left
279        // in prefill: two 512x512 projections per site per position, which at a
280        // 103-token prompt is as many multiply-adds as all the attention
281        // projections put together.
282        self.batch_engram(chunk.len(), p0, state, batch);
283        let _ = sites;
284
285        for li in 0..cfg.num_layers {
286            self.batch_mhc_pre(li, k, batch);
287            self.batch_engram_gate(li, k, batch);
288            self.batch_attention(li, k, p0, state, batch);
289            self.batch_mlp(li, k, batch);
290            self.batch_mhc_post(li, k, batch);
291        }
292
293        state.advance(k);
294        Ok(())
295    }
296
297    /// Engram keys and values for a whole chunk.
298    ///
299    /// Mirrors `V2Model::engram_fill` but hoists the two projections out of the
300    /// position loop. The tap convolution still runs per position and still reads
301    /// `pos - j * dilation`, which may fall in an earlier chunk — those values are
302    /// in the ring, and the ones inside this chunk have just been written, so the
303    /// convolution can run after the whole batch of values exists.
304    fn batch_engram(&self, k: usize, p0: usize, state: &mut V2State, b: &mut V2Batch) {
305        let cfg = &self.cfg;
306        if cfg.engram.sites.is_empty() {
307            return;
308        }
309        let d = cfg.d_model;
310        let eg = &cfg.engram;
311        let sites = eg.sites.len();
312        let heads = eg.heads();
313        let dil = eg.conv_dilation;
314        let fetched = eg.fetched_dim();
315        let ring = state.ring();
316
317        for site in 0..self.engrams.len() {
318            let weights = &self.engrams[site];
319            let prep = weights.value_proj.prepared_len();
320
321            // Fetched entries for every position, then one rotation each.
322            for i in 0..k {
323                let (e_all, hist) = (&mut b.e_all, state.history_slice());
324                self.engram_fetch_into(
325                    p0 + i,
326                    site,
327                    heads,
328                    hist,
329                    &mut e_all[i * fetched..(i + 1) * fetched],
330                );
331            }
332            for i in 0..k {
333                let (e_all, e_prep_all) = (&b.e_all, &mut b.e_prep_all);
334                weights.value_proj.prepare_input(
335                    &e_all[i * fetched..(i + 1) * fetched],
336                    &mut e_prep_all[i * prep..(i + 1) * prep],
337                );
338            }
339            let xh = &b.e_prep_all[..k * prep];
340
341            // Un-convolved values for the whole chunk, into a per-position
342            // buffer — NOT straight into the ring.
343            //
344            // The ring holds only `conv_taps * dilation + 1` positions (13 at the
345            // shipped geometry). Writing a 64-position chunk into it before
346            // convolving would overwrite each position's own inputs; the values
347            // therefore live in `e_val_all` for the duration of the chunk, and
348            // the ring is refreshed afterwards for the *next* chunk to read.
349            weights.value_proj.matmul_rows_prepared(
350                xh,
351                k,
352                0,
353                d,
354                &mut b.e_val_all[..k * d],
355                &mut b.acc,
356            );
357
358            // Keys for the chunk.
359            let mut keys = core::mem::take(&mut b.mlp_out);
360            weights
361                .key_proj
362                .matmul_rows_prepared(xh, k, 0, d, &mut keys[..k * d], &mut b.acc);
363            for i in 0..k {
364                let base = (i * sites + site) * d;
365                b.engram_k[base..base + d].copy_from_slice(&keys[i * d..(i + 1) * d]);
366            }
367            b.mlp_out = keys;
368
369            // Tap convolution. A tap reaching back inside this chunk reads
370            // `e_val_all`; one reaching into an earlier chunk reads the ring.
371            for i in 0..k {
372                let pos = p0 + i;
373                let vbase = (i * sites + site) * d;
374                b.engram_val[vbase..vbase + d].fill(0.0);
375                for j in 0..eg.conv_taps {
376                    let shift = j * dil;
377                    if shift > pos {
378                        continue;
379                    }
380                    let src_pos = pos - shift;
381                    let tap = &weights.taps[j * d..(j + 1) * d];
382                    if src_pos >= p0 {
383                        let off = (src_pos - p0) * d;
384                        let v = &b.e_val_all[off..off + d];
385                        for (c, (&t, &vc)) in tap.iter().zip(v.iter()).enumerate() {
386                            b.engram_val[vbase + c] += t * vc;
387                        }
388                    } else {
389                        let slot = (site * ring + src_pos % ring) * d;
390                        let v = state.engram_v_row(slot, d);
391                        for (c, (&t, &vc)) in tap.iter().zip(v.iter()).enumerate() {
392                            b.engram_val[vbase + c] += t * vc;
393                        }
394                    }
395                }
396            }
397
398            // Refresh the ring so the next chunk's taps can reach back into this
399            // one. Only the last `ring` positions survive, which is all a tap can
400            // reach.
401            for i in 0..k {
402                let slot = (site * ring + (p0 + i) % ring) * d;
403                state
404                    .engram_v_slot(slot, d)
405                    .copy_from_slice(&b.e_val_all[i * d..(i + 1) * d]);
406            }
407        }
408    }
409
410    fn batch_mhc_pre(&self, li: usize, k: usize, b: &mut V2Batch) {
411        let cfg = &self.cfg;
412        let (d, n) = (cfg.d_model, cfg.mhc_lanes);
413        let prep = self.mhc.phi_pre.prepared_len();
414
415        // Per position and independent: normalise the lane stream, then rotate it
416        // for the three phi projections that follow.
417        #[cfg(feature = "parallel")]
418        {
419            use rayon::prelude::*;
420            let phi = &self.mhc.phi_pre;
421            b.nx[..k * n * d]
422                .par_chunks_mut(n * d)
423                .zip(b.nx_prep[..k * prep].par_chunks_mut(prep))
424                .zip(b.lanes[..k * n * d].par_chunks(n * d))
425                .for_each(|((nx, nx_prep), lanes)| {
426                    rms_unit_to(lanes, nx);
427                    phi.prepare_input(nx, nx_prep);
428                });
429        }
430        #[cfg(not(feature = "parallel"))]
431        for i in 0..k {
432            rms_unit_to(
433                &b.lanes[i * n * d..(i + 1) * n * d],
434                &mut b.nx[i * n * d..(i + 1) * n * d],
435            );
436            let (nx, nx_prep) = (&b.nx, &mut b.nx_prep);
437            self.mhc.phi_pre.prepare_input(
438                &nx[i * n * d..(i + 1) * n * d],
439                &mut nx_prep[i * prep..(i + 1) * prep],
440            );
441        }
442
443        self.mhc.phi_pre.matmul_rows_prepared(
444            &b.nx_prep[..k * prep],
445            k,
446            li * n,
447            n,
448            &mut b.hpre[..k * n],
449            &mut b.acc,
450        );
451        let a = self.mhc.a_pre[li];
452        for i in 0..k {
453            for lane in 0..n {
454                let z = a * b.hpre[i * n + lane]
455                    + self.mhc.b_pre[li * n + lane]
456                    + cfg.pre_off(li, lane);
457                b.hpre[i * n + lane] = sigmoid(z);
458            }
459            for c in 0..d {
460                let mut acc = 0.0f32;
461                for lane in 0..n {
462                    acc += b.hpre[i * n + lane] * b.lanes[(i * n + lane) * d + c];
463                }
464                b.u[i * d + c] = acc;
465            }
466            let (src, dst) = (i * d, i * d);
467            for c in 0..d {
468                b.bx[dst + c] = b.u[src + c];
469            }
470        }
471    }
472
473    fn batch_engram_gate(&self, li: usize, k: usize, b: &mut V2Batch) {
474        let Some(site) = self.cfg.engram.site_of_layer(li) else {
475            return;
476        };
477        let d = self.cfg.d_model;
478        let sites = self.cfg.engram.sites.len();
479        // Divide, as `V2Model::engram_gate` does. Multiplying by a precomputed
480        // reciprocal is a different rounding, and at layers 2 and 15 that
481        // difference propagates through the remaining blocks.
482        let denom = math::sqrt(d as f32);
483
484        for i in 0..k {
485            let base = (i * sites + site) * d;
486            // rms_unit of u and of the site key, then their dot.
487            let u = &b.u[i * d..(i + 1) * d];
488            let ek = &b.engram_k[base..base + d];
489            let u_rms = rms_inv(u);
490            let k_rms = rms_inv(ek);
491            let mut dot = 0.0f32;
492            for c in 0..d {
493                dot += (u[c] * u_rms) * (ek[c] * k_rms);
494            }
495            let alpha = sigmoid(dot / denom);
496            for c in 0..d {
497                b.bx[i * d + c] = b.u[i * d + c] + alpha * b.engram_val[base + c];
498            }
499        }
500    }
501
502    fn batch_attention(
503        &self,
504        li: usize,
505        k: usize,
506        p0: usize,
507        state: &mut V2State,
508        b: &mut V2Batch,
509    ) {
510        let layer = &self.layers[li];
511        let cfg = &self.cfg;
512        let (h_n, kv_n, hd) = (cfg.num_heads, cfg.num_kv_heads, cfg.head_dim);
513        let (d, attn, kv) = (cfg.d_model, cfg.attn_dim(), cfg.kv_dim());
514        let reps = cfg.kv_repeat();
515        let prep = layer.q_proj.prepared_len();
516
517        // Normed block input, rotated once per position and shared by q/k/v/gate.
518        #[cfg(feature = "parallel")]
519        {
520            use rayon::prelude::*;
521            b.h[..k * d]
522                .par_chunks_mut(d)
523                .zip(b.h_prep[..k * prep].par_chunks_mut(prep))
524                .zip(b.bx[..k * d].par_chunks(d))
525                .for_each(|((h, h_prep), bx)| {
526                    h.copy_from_slice(bx);
527                    zc_rms_norm_vec(h, &layer.norm_in);
528                    layer.q_proj.prepare_input(h, h_prep);
529                });
530        }
531        #[cfg(not(feature = "parallel"))]
532        for i in 0..k {
533            b.h[i * d..(i + 1) * d].copy_from_slice(&b.bx[i * d..(i + 1) * d]);
534            zc_rms_norm_vec(&mut b.h[i * d..(i + 1) * d], &layer.norm_in);
535            let (h, h_prep) = (&b.h, &mut b.h_prep);
536            layer.q_proj.prepare_input(
537                &h[i * d..(i + 1) * d],
538                &mut h_prep[i * prep..(i + 1) * prep],
539            );
540        }
541        let xh = &b.h_prep[..k * prep];
542        layer
543            .q_proj
544            .matmul_rows_prepared(xh, k, 0, attn, &mut b.q[..k * attn], &mut b.acc);
545        layer
546            .k_proj
547            .matmul_rows_prepared(xh, k, 0, kv, &mut b.k[..k * kv], &mut b.acc);
548        layer
549            .v_proj
550            .matmul_rows_prepared(xh, k, 0, kv, &mut b.v[..k * kv], &mut b.acc);
551        layer
552            .gate_proj
553            .matmul_rows_prepared(xh, k, 0, attn, &mut b.gate[..k * attn], &mut b.acc);
554
555        // Per-head norms and RoPE for the whole chunk, then write-and-attend in
556        // position order.
557        //
558        // The write and the attention MUST be interleaved, not batched into two
559        // passes. The KV cache is a ring of `kv_window` positions, so writing
560        // position `p+1` overwrites the slot holding `p+1-window` — which is
561        // still inside position `p`'s window. Writing the whole chunk first would
562        // clobber the oldest key every earlier position in the chunk still needs.
563        // (The projections above are still batched; only this loop is ordered.)
564        #[cfg(feature = "parallel")]
565        {
566            use rayon::prelude::*;
567            b.q[..k * attn]
568                .par_chunks_mut(attn)
569                .zip(b.k[..k * kv].par_chunks_mut(kv))
570                .enumerate()
571                .for_each(|(i, (q, kk))| {
572                    for head in 0..h_n {
573                        zc_rms_norm_vec(&mut q[head * hd..(head + 1) * hd], &layer.q_norm);
574                    }
575                    for head in 0..kv_n {
576                        zc_rms_norm_vec(&mut kk[head * hd..(head + 1) * hd], &layer.k_norm);
577                    }
578                    self.rope_apply(q, 1, h_n, hd, p0 + i);
579                    self.rope_apply(kk, 1, kv_n, hd, p0 + i);
580                });
581        }
582        #[cfg(not(feature = "parallel"))]
583        for i in 0..k {
584            let pos = p0 + i;
585            let q = &mut b.q[i * attn..(i + 1) * attn];
586            for head in 0..h_n {
587                zc_rms_norm_vec(&mut q[head * hd..(head + 1) * hd], &layer.q_norm);
588            }
589            let kk = &mut b.k[i * kv..(i + 1) * kv];
590            for head in 0..kv_n {
591                zc_rms_norm_vec(&mut kk[head * hd..(head + 1) * hd], &layer.k_norm);
592            }
593            self.rope_apply(q, 1, h_n, hd, pos);
594            self.rope_apply(kk, 1, kv_n, hd, pos);
595        }
596
597        let scale = 1.0 / math::sqrt(hd as f32);
598        for i in 0..k {
599            let pos = p0 + i;
600            state.write_kv(
601                li,
602                pos,
603                &b.k[i * kv..(i + 1) * kv],
604                &b.v[i * kv..(i + 1) * kv],
605            );
606
607            let lo = state.window_lo(pos);
608            let span = pos - lo + 1;
609            for head in 0..h_n {
610                let kvh = head / reps;
611                let qrow = &b.q[i * attn + head * hd..i * attn + (head + 1) * hd];
612                for s in 0..span {
613                    let krow = state.k_at(li, kvh, lo + s);
614                    let mut acc = 0.0f32;
615                    for (t, &kt) in krow.iter().enumerate() {
616                        acc += qrow[t] * kt;
617                    }
618                    b.scores[s] = acc * scale;
619                }
620                softmax_inplace(&mut b.scores[..span]);
621                let obase = i * attn + head * hd;
622                b.attn_out[obase..obase + hd].fill(0.0);
623                for s in 0..span {
624                    let w = b.scores[s];
625                    let vrow = state.v_at(li, kvh, lo + s);
626                    for (t, &vt) in vrow.iter().enumerate() {
627                        b.attn_out[obase + t] += w * vt;
628                    }
629                }
630            }
631        }
632
633        // Sigmoid gate, then out_proj for the chunk.
634        for i in 0..k * attn {
635            b.attn_out[i] *= sigmoid(b.gate[i]);
636        }
637        let oprep = layer.out_proj.prepared_len();
638        #[cfg(feature = "parallel")]
639        {
640            use rayon::prelude::*;
641            b.attn_prep[..k * oprep]
642                .par_chunks_mut(oprep)
643                .zip(b.attn_out[..k * attn].par_chunks(attn))
644                .for_each(|(ap, ao)| layer.out_proj.prepare_input(ao, ap));
645        }
646        #[cfg(not(feature = "parallel"))]
647        for i in 0..k {
648            let (ao, ap) = (&b.attn_out, &mut b.attn_prep);
649            layer.out_proj.prepare_input(
650                &ao[i * attn..(i + 1) * attn],
651                &mut ap[i * oprep..(i + 1) * oprep],
652            );
653        }
654        layer.out_proj.matmul_rows_prepared(
655            &b.attn_prep[..k * oprep],
656            k,
657            0,
658            d,
659            &mut b.attn_result[..k * d],
660            &mut b.acc,
661        );
662    }
663
664    /// Post-attention residual and HadamardMLP.
665    ///
666    /// Purely per position — no cross-position term anywhere — so with the
667    /// `parallel` feature the chunk's positions run concurrently. This and
668    /// [`batch_mhc_post`] are where prefill's remaining serial time sat once the
669    /// projections were batched.
670    ///
671    /// [`batch_mhc_post`]: V2Model::batch_mhc_post
672    fn batch_mlp(&self, li: usize, k: usize, b: &mut V2Batch) {
673        let layer = &self.layers[li];
674        let d = self.cfg.d_model;
675        let g = sigmoid(layer.attn_gate);
676
677        #[cfg(feature = "parallel")]
678        {
679            use rayon::prelude::*;
680            let hada_n = self.cfg.hada_n;
681            b.y[..k * d]
682                .par_chunks_mut(d)
683                .zip(b.attn_result[..k * d].par_chunks_mut(d))
684                .zip(b.h[..k * d].par_chunks_mut(d))
685                .zip(b.mlp_out[..k * d].par_chunks_mut(d))
686                .zip(b.bx[..k * d].par_chunks(d))
687                .zip(b.u[..k * d].par_chunks(d))
688                .for_each_init(
689                    || vec![0.0f32; hada_n],
690                    |hada, (((((y, ar), h), mlp), bx), u)| {
691                        mlp_one(layer, g, y, ar, h, mlp, bx, u, hada);
692                    },
693                );
694        }
695
696        #[cfg(not(feature = "parallel"))]
697        {
698            let mut hada = core::mem::take(&mut b.hada);
699            for i in 0..k {
700                let (lo, hi) = (i * d, (i + 1) * d);
701                // Split the borrows by hand: each of these is a distinct field.
702                let (y, ar) = (
703                    &mut b.y[lo..hi] as *mut [f32],
704                    &mut b.attn_result[lo..hi] as *mut [f32],
705                );
706                // SAFETY: y, ar, h, mlp, bx and u are disjoint fields of `b`.
707                unsafe {
708                    mlp_one(
709                        layer,
710                        g,
711                        &mut *y,
712                        &mut *ar,
713                        &mut b.h[lo..hi],
714                        &mut b.mlp_out[lo..hi],
715                        &b.bx[lo..hi],
716                        &b.u[lo..hi],
717                        &mut hada,
718                    );
719                }
720            }
721            b.hada = hada;
722        }
723    }
724
725    fn batch_mhc_post(&self, li: usize, k: usize, b: &mut V2Batch) {
726        let cfg = &self.cfg;
727        let (d, n) = (cfg.d_model, cfg.mhc_lanes);
728        let prep = self.mhc.phi_pre.prepared_len();
729        let xh = &b.nx_prep[..k * prep];
730
731        self.mhc
732            .phi_post
733            .matmul_rows_prepared(xh, k, li * n, n, &mut b.hpost[..k * n], &mut b.acc);
734        self.mhc.phi_res.matmul_rows_prepared(
735            xh,
736            k,
737            li * n * n,
738            n * n,
739            &mut b.hres[..k * n * n],
740            &mut b.acc,
741        );
742
743        let (a_post, a_res) = (self.mhc.a_post[li], self.mhc.a_res[li]);
744        let b_post = &self.mhc.b_post[li * n..(li + 1) * n];
745        let b_res = &self.mhc.b_res[li * n * n..(li + 1) * n * n];
746        let offs: Vec<f32> = (0..n).map(|lane| cfg.post_off(li, lane)).collect();
747
748        #[cfg(feature = "parallel")]
749        {
750            use rayon::prelude::*;
751            b.lanes[..k * n * d]
752                .par_chunks_mut(n * d)
753                .zip(b.lanes_prev[..k * n * d].par_chunks_mut(n * d))
754                .zip(b.hpost[..k * n].par_chunks_mut(n))
755                .zip(b.hres[..k * n * n].par_chunks_mut(n * n))
756                .zip(b.y[..k * d].par_chunks(d))
757                .for_each(|((((lanes, prev), hpost), hres), y)| {
758                    mhc_post_one(
759                        n, d, a_post, a_res, b_post, b_res, &offs, lanes, prev, hpost, hres, y,
760                    );
761                });
762        }
763
764        #[cfg(not(feature = "parallel"))]
765        for i in 0..k {
766            let lanes = &mut b.lanes[i * n * d..(i + 1) * n * d] as *mut [f32];
767            let prev = &mut b.lanes_prev[i * n * d..(i + 1) * n * d] as *mut [f32];
768            // SAFETY: lanes, lanes_prev, hpost, hres and y are distinct fields.
769            unsafe {
770                mhc_post_one(
771                    n,
772                    d,
773                    a_post,
774                    a_res,
775                    b_post,
776                    b_res,
777                    &offs,
778                    &mut *lanes,
779                    &mut *prev,
780                    &mut b.hpost[i * n..(i + 1) * n],
781                    &mut b.hres[i * n * n..(i + 1) * n * n],
782                    &b.y[i * d..(i + 1) * d],
783                );
784            }
785        }
786    }
787}
788
789/// One position's post-attention residual and HadamardMLP.
790#[allow(clippy::too_many_arguments)]
791#[inline]
792fn mlp_one(
793    layer: &crate::v2::model::V2Layer,
794    g: f32,
795    y: &mut [f32],
796    attn_result: &mut [f32],
797    h: &mut [f32],
798    mlp_out: &mut [f32],
799    bx: &[f32],
800    u: &[f32],
801    hada: &mut [f32],
802) {
803    let d = y.len();
804    zc_rms_norm_vec(attn_result, &layer.post_norm);
805    for c in 0..d {
806        y[c] = bx[c] + g * attn_result[c];
807    }
808    h.copy_from_slice(y);
809    zc_rms_norm_vec(h, &layer.pre_hada);
810    hadamard_mlp(h, &layer.d1, &layer.d2, &layer.d3, hada, mlp_out);
811    for c in 0..d {
812        y[c] += mlp_out[c];
813        y[c] -= u[c];
814    }
815}
816
817/// One position's mHC post-routing and lane update.
818#[allow(clippy::too_many_arguments)]
819#[inline]
820fn mhc_post_one(
821    n: usize,
822    d: usize,
823    a_post: f32,
824    a_res: f32,
825    b_post: &[f32],
826    b_res: &[f32],
827    post_off: &[f32],
828    lanes: &mut [f32],
829    prev: &mut [f32],
830    hpost: &mut [f32],
831    hres: &mut [f32],
832    y: &[f32],
833) {
834    for lane in 0..n {
835        let z = a_post * hpost[lane] + b_post[lane] + post_off[lane];
836        hpost[lane] = 2.0 * sigmoid(z);
837    }
838    for (j, v) in hres.iter_mut().enumerate() {
839        *v = a_res * *v + b_res[j];
840    }
841    sinkhorn(hres, n);
842
843    prev.copy_from_slice(lanes);
844    for lane_i in 0..n {
845        let hp = hpost[lane_i];
846        for c in 0..d {
847            let mut acc = 0.0f32;
848            for lane_j in 0..n {
849                acc += hres[lane_i * n + lane_j] * prev[lane_j * d + c];
850            }
851            lanes[lane_i * d + c] = acc + hp * y[c];
852        }
853    }
854}
855
856/// `1 / sqrt(mean(x^2) + eps)`, the scale `rms_unit` applies.
857#[inline]
858fn rms_inv(x: &[f32]) -> f32 {
859    let mean_sq = x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32;
860    1.0 / math::sqrt(mean_sq + crate::v2::kernels::EPS)
861}