Skip to main content

ferrox_models/
decoder.rs

1//! Generic decoder-only transformer forward pass, assembled from a
2//! ModelConfig. Each layer is: RMSNorm -> GQA attention (+RoPE) ->
3//! residual -> RMSNorm -> MoE FFN (router + routed experts + shared
4//! experts) -> residual. This is the standard decoder block shape
5//! shared by the LLaMA/DeepSeek/GLM/Kimi family of open-weight models.
6//!
7//! Weight loading from a real GGUF checkpoint lives in `loader`
8//! (`Decoder::from_gguf`); `Decoder::new_random` builds
9//! correctly-shaped, randomly initialized weights so the full pipeline
10//! -- embedding lookup, N decoder layers, output head -- can be
11//! exercised end to end with real assertions about shapes, finiteness,
12//! and determinism, without requiring a multi-hundred-gigabyte
13//! checkpoint to be present.
14
15use std::sync::atomic::{AtomicU64, Ordering};
16
17use ferrox_core::attention::{
18    apply_rope, apply_rope_interleaved, apply_rope_interleaved_with_freq_factors,
19    apply_rope_with_freq_factors, causal_gqa_attention_prefill_shared_kv_windowed,
20    causal_gqa_attention_softcap, causal_gqa_attention_windowed_softcap,
21};
22use ferrox_core::cache::{KvCache, PagedKvCache, PagedStoreExhausted, SharedPagedKv};
23use ferrox_core::matmul::{geglu, rms_norm, rms_norm_per_head, softcap_inplace};
24use rayon::prelude::*;
25
26/// Whether the CUDA `gqa_decode` kernel should serve the per-token GQA
27/// reduction (`FERROX_CUDA_GQA=1`). Off by default and only compiled with
28/// `--features cuda`; the host path is byte-identical when unset.
29#[cfg(feature = "cuda")]
30fn cuda_gqa_enabled() -> bool {
31    use std::sync::OnceLock;
32    static ENABLED: OnceLock<bool> = OnceLock::new();
33    *ENABLED.get_or_init(|| {
34        matches!(
35            std::env::var("FERROX_CUDA_GQA").ok().as_deref(),
36            Some("1") | Some("true") | Some("on")
37        )
38    })
39}
40use ferrox_core::tensor::Tensor;
41use ferrox_core::weight_matrix::WeightMatrix;
42use ferrox_moe::{
43    combine_expert_outputs, route_top_k, run_expert, run_expert_placed, ExpertPlacement,
44    ExpertWeights, PlacementPlan,
45};
46
47use crate::config::ModelConfig;
48
49pub struct AttnWeights {
50    pub q_proj: WeightMatrix, // [n_heads*head_dim, hidden_dim]
51    pub k_proj: WeightMatrix, // [n_kv_heads*head_dim, hidden_dim]
52    pub v_proj: WeightMatrix, // [n_kv_heads*head_dim, hidden_dim]
53    pub o_proj: WeightMatrix, // [hidden_dim, n_heads*head_dim]
54    pub norm_weight: Vec<f32>,
55    /// OLMoE-style QK-RMSNorm (`attn_q_norm`/`attn_k_norm` GGUF tensors),
56    /// applied to the *whole* q_proj/k_proj output (width `n_heads*head_dim`
57    /// / `n_kv_heads*head_dim`) before RoPE -- confirmed against
58    /// `OlmoeAttention.forward` in `transformers/models/olmoe/modeling_olmoe.py`
59    /// (`q_norm(q_proj(x))`, `k_norm(k_proj(x))`, both plain whole-vector
60    /// RMSNorm, not per-head). `None` for every model that doesn't ship
61    /// these tensors -- absent, not zero/identity-weighted, so existing
62    /// presets/fixtures are byte-for-byte unaffected.
63    ///
64    /// Qwen3 / Gemma3 ship the same tensor names with length `head_dim`
65    /// (per-head). Which style is used is selected by
66    /// [`ModelConfig::qk_norm_style`] (refined at load from weight length).
67    pub q_norm: Option<Vec<f32>>,
68    pub k_norm: Option<Vec<f32>>,
69    /// Qwen2/Qwen2-MoE-family QKV attention bias (`attn_{q,k,v}.bias`
70    /// GGUF tensors, real `config.qkv_bias`), added elementwise to the
71    /// corresponding projection's output before QK-norm/RoPE -- confirmed
72    /// against the real `transformers` source
73    /// (`Qwen2MoeAttention.__init__`: `q_proj = nn.Linear(..., bias=
74    /// config.qkv_bias)`, same for `k_proj`/`v_proj`; `o_proj` has no
75    /// bias). Found as a real, previously-unhandled architecture gap:
76    /// ferrox's generic GGUF loader silently ignored these real tensors
77    /// entirely, producing fluent-but-wrong output on a real downloaded
78    /// Qwen1.5-MoE checkpoint (same failure class as OLMoE's missing
79    /// QK-norm). `None` for every model that doesn't ship these tensors.
80    pub q_bias: Option<Vec<f32>>,
81    pub k_bias: Option<Vec<f32>>,
82    pub v_bias: Option<Vec<f32>>,
83    /// Gemma 2+/3 post-attention RMSNorm (`blk.N.post_attention_norm.weight`
84    /// / llama.cpp `attn_post_norm`). Applied to attention output before
85    /// the residual add. `None` for Llama/Qwen/OLMoE.
86    pub post_attn_norm: Option<Vec<f32>>,
87    /// Gemma 2+/3 post-FFN RMSNorm (`blk.N.post_ffw_norm.weight`).
88    pub post_ffn_norm: Option<Vec<f32>>,
89}
90
91/// How a layer's routed experts are held. `Resident` is the original
92/// always-in-memory form (owned f32 or zero-copy mmap views).
93/// `Stored` holds only byte-range layouts; each use acquires the
94/// expert's bytes from a bounded, lease-protected
95/// `ferrox_core::expert_store::ExpertStore` shared by every layer
96/// (one global byte budget), builds temporary `WeightMatrix` views
97/// over the leased buffer (`WeightBytes::Shared`, which pins the
98/// cache entry for the views' lifetime), and drops them after the
99/// expert runs. Dequantized math over identical bytes is identical,
100/// so the two backings are bit-equivalent by construction -- pinned
101/// by an integration test against the MoE fixture.
102pub enum ExpertBacking {
103    Resident(Vec<ExpertWeights>),
104    Stored {
105        store:
106            std::sync::Arc<ferrox_core::expert_store::ExpertStore<crate::loader::GgufExpertSource>>,
107        layouts: Vec<crate::loader::StoredExpertLayout>,
108        layer: u32,
109    },
110}
111
112impl ExpertBacking {
113    pub fn n_experts(&self) -> usize {
114        match self {
115            ExpertBacking::Resident(v) => v.len(),
116            ExpertBacking::Stored { layouts, .. } => layouts.len(),
117        }
118    }
119}
120
121pub struct MoeWeights {
122    pub router: WeightMatrix, // [n_experts, hidden_dim]
123    pub experts: ExpertBacking,
124    pub shared_experts: Vec<ExpertWeights>,
125    /// Qwen2-MoE-specific: when present, the shared experts' combined
126    /// output is scaled by `sigmoid(shared_expert_gate . x)` before
127    /// being added to the routed output, instead of added unconditionally
128    /// -- confirmed against the real `transformers` source
129    /// (`Qwen2MoeSparseMoeBlock.forward`: `shared_expert_output =
130    /// F.sigmoid(self.shared_expert_gate(hidden_states)) *
131    /// shared_expert_output`) and llama.cpp's real `qwen2moe.cpp`
132    /// (`ffn_gate_inp_shexp` dotted against the hidden state, sigmoid,
133    /// multiplied into the shared-expert branch before the final add).
134    /// Real on-disk shape is `[hidden_dim]` (a `Linear(hidden_dim, 1,
135    /// bias=false)`'s weight, flattened -- ggml's real `create_tensor`
136    /// call declares it as `{n_embd}`, not a 2D matrix), so this is a
137    /// plain owned vector dotted with the normed hidden state directly,
138    /// not a `WeightMatrix`. `None` for every other architecture
139    /// (DeepSeek-V3's shared experts, for one real confirmed contrast,
140    /// add unconditionally with no gate at all).
141    pub shared_expert_gate: Option<Vec<f32>>,
142    pub norm_weight: Vec<f32>,
143    /// DeepSeek-V3's aux-loss-free expert-selection bias, on disk as
144    /// `blk.{N}.exp_probs_b.bias` (llama.cpp's `LLM_TENSOR_FFN_EXP_PROBS_B`
145    /// -- note the on-disk name has no `ffn_` prefix, `llama-arch.cpp:416`).
146    /// It is added to the *selection* score only: the top-k is taken over
147    /// `gating(logit) + bias[expert]`, while each winner's combine weight
148    /// comes from the unbiased `gating(logit)`
149    /// (`build_moe_ffn`: "leave probs unbiased as it's later used to get
150    /// expert weights"). Biasing the weight too would silently skew every
151    /// routed contribution away from what the router learned.
152    ///
153    /// `None` for every checkpoint that does not ship the tensor. When it
154    /// *is* present, the GPU MoE fast paths refuse the layer rather than
155    /// route without it -- their kernels have no bias input.
156    pub exp_probs_bias: Option<Vec<f32>>,
157    /// How many times each routed expert (index into `experts`) has been
158    /// selected by `route_top_k` across every `forward_token`/
159    /// `forward_batch` call so far. Real observed hotness, not a
160    /// placeholder -- feeds `placement_plan` below, which is what
161    /// `PlacementPlan::from_budget` needs to prioritize actually-hot
162    /// experts for GPU residency instead of guessing by index.
163    pub activation_counts: Vec<AtomicU64>,
164    /// Verified-at-load contiguous expert planes for Metal MoE
165    /// (`mul_mm_sg` gather/id). Built in `loader` when every routed expert
166    /// is mmap-backed with a simdgroup-GEMM quant (Q4_0 / Q4_K / Q8_0 / …)
167    /// and back-to-back gate/up/down slices. Gate/up/down kinds may differ
168    /// (Qwen1.5-MoE: Q4_K gate/up + Q8_0 down). `None` for store-backed,
169    /// F32, or non-contiguous layouts.
170    #[cfg(feature = "metal")]
171    pub packed_q4: Option<MoePackedQ4Planes>,
172}
173
174/// Load-time validated contiguous expert tensor planes (any `mul_mm_sg` quant).
175#[cfg(feature = "metal")]
176pub struct MoePackedQ4Planes {
177    gate: ferrox_core::weight_matrix::WeightBytes,
178    up: ferrox_core::weight_matrix::WeightBytes,
179    down: ferrox_core::weight_matrix::WeightBytes,
180    gate_stride: usize,
181    up_stride: usize,
182    down_stride: usize,
183    n_experts: usize,
184    ffn_rows: usize,
185    hidden_rows: usize,
186    gate_row_bytes: usize,
187    down_row_bytes: usize,
188    gate_kind: &'static str,
189    up_kind: &'static str,
190    down_kind: &'static str,
191}
192
193#[cfg(feature = "metal")]
194impl MoePackedQ4Planes {
195    #[allow(clippy::too_many_arguments)]
196    pub(crate) fn new(
197        gate: ferrox_core::weight_matrix::WeightBytes,
198        up: ferrox_core::weight_matrix::WeightBytes,
199        down: ferrox_core::weight_matrix::WeightBytes,
200        gate_stride: usize,
201        up_stride: usize,
202        down_stride: usize,
203        n_experts: usize,
204        ffn_rows: usize,
205        hidden_rows: usize,
206        gate_kind: &'static str,
207        up_kind: &'static str,
208        down_kind: &'static str,
209    ) -> Self {
210        Self {
211            gate,
212            up,
213            down,
214            gate_stride,
215            up_stride,
216            down_stride,
217            n_experts,
218            ffn_rows,
219            hidden_rows,
220            gate_row_bytes: gate_stride / ffn_rows,
221            down_row_bytes: down_stride / hidden_rows,
222            gate_kind,
223            up_kind,
224            down_kind,
225        }
226    }
227
228    pub fn view(&self) -> ferrox_metal::gpu::MoePackedQ4<'_> {
229        ferrox_metal::gpu::MoePackedQ4 {
230            gate: self.gate.as_slice(),
231            up: self.up.as_slice(),
232            down: self.down.as_slice(),
233            gate_stride: self.gate_stride,
234            up_stride: self.up_stride,
235            down_stride: self.down_stride,
236            n_experts: self.n_experts,
237            ffn_rows: self.ffn_rows,
238            hidden_rows: self.hidden_rows,
239            gate_row_bytes: self.gate_row_bytes,
240            down_row_bytes: self.down_row_bytes,
241            gate_kind: self.gate_kind,
242            up_kind: self.up_kind,
243            down_kind: self.down_kind,
244        }
245    }
246}
247
248impl MoeWeights {
249    pub fn n_experts(&self) -> usize {
250        self.experts.n_experts()
251    }
252
253    /// This routed expert's weight byte footprint, from resident
254    /// matrices or the stored layout -- identical numbers either way,
255    /// so residency planning is backing-independent.
256    pub fn expert_bytes(&self, e: usize) -> usize {
257        match &self.experts {
258            ExpertBacking::Resident(v) => {
259                let ex = &v[e];
260                ex.gate.resident_bytes() + ex.up.resident_bytes() + ex.down.resident_bytes()
261            }
262            ExpertBacking::Stored { layouts, .. } => layouts[e].total_bytes(),
263        }
264    }
265
266    /// Runs `f` against expert `e`'s weights, materializing them from
267    /// the store first when this layer is store-backed. The lease (and
268    /// therefore the cache entry's pin) lives exactly as long as `f`'s
269    /// borrow.
270    pub fn with_expert<R>(&self, e: usize, f: impl FnOnce(&ExpertWeights) -> R) -> R {
271        match &self.experts {
272            ExpertBacking::Resident(v) => f(&v[e]),
273            ExpertBacking::Stored {
274                store,
275                layouts,
276                layer,
277            } => {
278                let lease = store
279                    .acquire(ferrox_core::expert_store::ExpertKey {
280                        layer: *layer,
281                        expert: e as u32,
282                    })
283                    .unwrap_or_else(|err| {
284                        panic!(
285                            "expert store read failed for layer {layer} expert {e}: {err} \
286                             (checkpoint file unreadable mid-decode)"
287                        )
288                    });
289                let tmp = layouts[e].materialize(&lease);
290                f(&tmp)
291            }
292        }
293    }
294
295    fn record_activations(&self, expert_ids: &[usize]) {
296        for &eid in expert_ids {
297            if let Some(counter) = self.activation_counts.get(eid) {
298                counter.fetch_add(1, Ordering::Relaxed);
299            }
300        }
301    }
302
303    /// A real VRAM-budget-and-hotness-driven placement plan for this
304    /// layer's routed experts, built from each expert's actual resident
305    /// byte size (`WeightMatrix::resident_bytes()` summed across its
306    /// gate/up/down matrices, so it reflects the real quantization
307    /// format in use, not an estimate) and the activation counts
308    /// observed so far. See `ferrox_moe::PlacementPlan::from_budget`.
309    pub fn placement_plan(&self, vram_budget_bytes: u64) -> PlacementPlan {
310        let sizes: Vec<usize> = (0..self.n_experts())
311            .map(|e| self.expert_bytes(e))
312            .collect();
313        let counts: Vec<u64> = self
314            .activation_counts
315            .iter()
316            .map(|c| c.load(Ordering::Relaxed))
317            .collect();
318        let has_observations = counts.iter().any(|&c| c > 0);
319        PlacementPlan::from_budget(
320            &sizes,
321            has_observations.then_some(counts.as_slice()),
322            vram_budget_bytes,
323        )
324    }
325}
326
327pub struct LayerWeights {
328    pub attn: AttnWeights,
329    pub moe: MoeWeights,
330}
331
332/// The per-layer weights the gpt-oss graph carries and the generic GQA
333/// layer structs do not.
334///
335/// Held as a side table on [`Decoder`] rather than as new `Option`
336/// fields on [`AttnWeights`]/[`MoeWeights`] for two reasons. The first
337/// is mechanical: those two structs have thirty construction sites
338/// across seven loaders and every dedicated engine, and none of them
339/// will ever set these. The second is the point of the exercise — a
340/// checkpoint either has the whole gpt-oss graph or none of it, so
341/// `Decoder::gpt_oss.is_some()` is a single, checkable predicate for
342/// "this model needs the gpt-oss path", which is what the CPU-only and
343/// paged-attention refusals below key off. Scattering five independent
344/// `Option`s would make "half the graph is wired" representable, and
345/// that state is precisely the silent-wrong-answer bug this work exists
346/// to remove.
347pub struct GptOssLayer {
348    /// `blk.N.attn_sinks.weight`, one learned logit per query head.
349    pub attn_sinks: Vec<f32>,
350    /// `blk.N.attn_output.bias`, added after the output projection.
351    pub o_bias: Vec<f32>,
352    /// `blk.N.ffn_gate_inp.bias`, added to the router logits.
353    pub router_bias: Vec<f32>,
354    /// `blk.N.ffn_{gate,up,down}_exps.bias`, one entry per expert.
355    pub expert_bias: Vec<ferrox_moe::ExpertBias>,
356}
357
358/// gpt-oss side table: one entry per layer, in layer order.
359pub struct GptOssWeights {
360    pub layers: Vec<GptOssLayer>,
361}
362
363pub struct Decoder {
364    pub config: ModelConfig,
365    /// `[vocab_size, hidden_dim]`. A `WeightMatrix` rather than an
366    /// eagerly-widened f32 `Tensor`, so a quantized `token_embd.weight`
367    /// stays quantized on disk/mmap and token lookup dequantizes one
368    /// row at a time (`WeightMatrix::dequant_row`) -- a large-vocab
369    /// model's embedding table is multi-GB in f32 and only ever read
370    /// row-wise.
371    pub embedding: WeightMatrix,
372    pub layers: Vec<LayerWeights>,
373    pub final_norm: Vec<f32>,
374    pub output_head: WeightMatrix, // [vocab_size, hidden_dim]
375    /// Real VRAM budget for GPU-resident routed experts.
376    /// `None` (both constructors below
377    /// set it) means every expert always runs on CPU -- the exact
378    /// behavior this field's absence had before it existed. `Some(bytes)`
379    /// makes each forward call build ONE global `ResidencyPlan`
380    /// (`Decoder::residency_plan`) across every layer's actual
381    /// resident expert sizes and observed activation counts against
382    /// this single budget -- the budget is never re-spent per layer --
383    /// dispatching device-placed routed experts through
384    /// `ferrox_moe::run_expert_placed` (a real CUDA kernel when the
385    /// `cuda` feature is compiled in and the expert's quant kind has
386    /// one; a correct CPU fallback otherwise, so setting this on a
387    /// non-`cuda` build is harmless, just never GPU-accelerated).
388    /// Shared experts and a dense layer's sole expert always run on
389    /// CPU regardless -- every token activates them, so there's no
390    /// routing decision to offload the way routed-expert placement is.
391    /// Rebuilding the plan on every forward call is real but not yet
392    /// performance-tuned; a real, disclosed limit, not a correctness
393    /// gap.
394    pub gpu_vram_budget_bytes: Option<u64>,
395    /// `Some` only for the gpt-oss family. See [`GptOssWeights`]. When
396    /// set, every layer runs the gpt-oss CPU graph (attention sinks,
397    /// alternating SWA, biased router + experts, `swiglu_oai`), GPU
398    /// offload is refused at load time, and the paged-KV decode path is
399    /// refused at call time — neither implements sinks, and answering
400    /// with a different distribution is the failure this replaces.
401    pub gpt_oss: Option<GptOssWeights>,
402    /// Per-layer Metal-resident KV for fused decode/prefill attention
403    /// (`FERROX_METAL_ATTN`). Lazily allocated. After
404    /// [`ferrox_metal::attn::launch_decode_dense_stack`], Metal KV is
405    /// authoritative for the next decode step; host [`KvCache`] may lag
406    /// until [`Self::sync_metal_attn_kv_to_host`] or a CPU fallback.
407    /// Prefill / prefix restore still upload host → Metal when lengths
408    /// diverge for other reasons.
409    #[cfg(feature = "metal")]
410    pub(crate) metal_attn_kv: std::sync::Mutex<Option<Vec<ferrox_metal::attn::MetalKvBuffers>>>,
411    /// Load-time execution plan (family, fused-op caps, SWA/RoPE
412    /// policy). Built once; hot path must not re-resolve architecture
413    /// strings. See [`crate::execution_plan`].
414    pub execution_plan: crate::execution_plan::ExecutionPlan,
415    /// Cache key hit → fused caps last used for that geometry (enables
416    /// decode/prefill plan reuse without rebuilding residency).
417    pub plan_cache: std::sync::Mutex<
418        std::collections::HashMap<
419            crate::execution_plan::PlanGeometry,
420            crate::execution_plan::FusedOpCaps,
421        >,
422    >,
423}
424
425/// Simple deterministic pseudo-random generator so tests are
426/// reproducible without pulling in an external `rand` dependency.
427struct Lcg(u64);
428impl Lcg {
429    fn new(seed: u64) -> Self {
430        Lcg(seed)
431    }
432    fn next_f32(&mut self) -> f32 {
433        // xorshift64*
434        self.0 ^= self.0 << 13;
435        self.0 ^= self.0 >> 7;
436        self.0 ^= self.0 << 17;
437        ((self.0 >> 40) as f32 / (1u64 << 24) as f32) - 0.5
438    }
439    fn vec(&mut self, n: usize) -> Vec<f32> {
440        (0..n).map(|_| self.next_f32() * 0.1).collect()
441    }
442}
443
444/// Where a batch of independent sequences keeps its KV.
445///
446/// `forward_multi_seq` batches the projections across sequences but
447/// must attend per sequence, because each has its own length and its
448/// own history. That per-sequence step is the ONLY place the batched
449/// path touches a cache, which is why paging it is a parameter here
450/// rather than a second copy of a 300-line function -- the lesson the
451/// paged decode path taught by losing five model features one at a
452/// time to exactly that kind of copy.
453pub enum MultiSeqKv<'a> {
454    Contiguous(&'a mut [Vec<KvCache>]),
455    Paged {
456        caches: &'a mut [Vec<PagedKvCache>],
457        stores: &'a SharedPagedKv,
458    },
459}
460
461impl MultiSeqKv<'_> {
462    /// Sequences in the batch.
463    pub fn len(&self) -> usize {
464        match self {
465            MultiSeqKv::Contiguous(c) => c.len(),
466            MultiSeqKv::Paged { caches, .. } => caches.len(),
467        }
468    }
469
470    pub fn is_empty(&self) -> bool {
471        self.len() == 0
472    }
473
474    /// Layers each sequence carries, for the shape assertion.
475    fn layers_per_seq(&self, seq: usize) -> usize {
476        match self {
477            MultiSeqKv::Contiguous(c) => c[seq].len(),
478            MultiSeqKv::Paged { caches, .. } => caches[seq].len(),
479        }
480    }
481}
482
483impl Decoder {
484    /// Eagerly resolve every kernel lookup this model's dispatch paths
485    /// will make, and record it in
486    /// [`ferrox_core::kernel_registry`] before anything runs.
487    ///
488    /// Call once, at the end of loading, immediately before
489    /// [`ferrox_core::kernel_registry::seal`]. Nothing here dispatches
490    /// or decides anything: it asks the same predicates the hot path
491    /// asks and writes the answers down, so a kernel that is missing
492    /// becomes a startup line instead of an unexplained benchmark row.
493    ///
494    /// Routed experts held in an [`ExpertBacking::Stored`] layer are not
495    /// probed -- they exist only as byte ranges until a token routes to
496    /// them, and materialising every expert here would defeat the
497    /// bounded expert store. Their kinds are the same as the resident
498    /// case, and a dispatch-site miss still trips the sealed registry.
499    pub fn probe_kernels(&self) {
500        use ferrox_core::kernel_registry as reg;
501
502        if !reg::enabled() {
503            return;
504        }
505        self.embedding.probe_kernels("token_embd");
506        self.output_head.probe_kernels("output_head");
507        for layer in &self.layers {
508            layer.attn.q_proj.probe_kernels("attn_q");
509            layer.attn.k_proj.probe_kernels("attn_k");
510            layer.attn.v_proj.probe_kernels("attn_v");
511            layer.attn.o_proj.probe_kernels("attn_o");
512            layer.moe.router.probe_kernels("moe_router");
513            for e in &layer.moe.shared_experts {
514                e.gate.probe_kernels("shexp_gate");
515                e.up.probe_kernels("shexp_up");
516                e.down.probe_kernels("shexp_down");
517            }
518            if let ExpertBacking::Resident(experts) = &layer.moe.experts {
519                for e in experts {
520                    e.gate.probe_kernels("ffn_gate");
521                    e.up.probe_kernels("ffn_up");
522                    e.down.probe_kernels("ffn_down");
523                }
524            }
525        }
526        // The generic decoder has a real batched prefill
527        // (`forward_hidden_batch`), so a `pp512` here is one GEMM per
528        // projection, not 512 matvecs. Recorded as a hit so that an
529        // engine which lacks it stands out as a miss rather than as an
530        // absence.
531        reg::record_build(
532            reg::Lookup::new(
533                ferrox_core::weight_matrix::active_backend(),
534                reg::op::ENGINE_PREFILL_BATCH,
535                None,
536            )
537            .with_role("generic_decoder"),
538            reg::Outcome::Hit,
539        );
540    }
541
542    /// Builds a decoder with correctly-shaped, randomly initialized
543    /// weights for `config`, but overrides `n_layers` and `vocab_size`
544    /// with small test-scale numbers so it can actually be allocated and
545    /// run inside a CI sandbox. Use this to validate the forward-pass
546    /// plumbing only, never to draw conclusions about real model
547    /// quality.
548    pub fn new_random_small(config: ModelConfig, n_layers: usize, vocab_size: usize) -> Self {
549        let mut rng = Lcg::new(42);
550        let mut config = config;
551        config.n_layers = n_layers;
552        config.vocab_size = vocab_size;
553        let hidden = config.hidden_dim;
554        let head_dim = config.head_dim;
555        let n_heads = config.n_heads;
556        let n_kv_heads = config.n_kv_heads;
557
558        let embedding = WeightMatrix::F32(Tensor::new(
559            rng.vec(vocab_size * hidden),
560            vec![vocab_size, hidden],
561        ));
562
563        let wm = |data: Vec<f32>, shape: Vec<usize>| WeightMatrix::F32(Tensor::new(data, shape));
564
565        let mut layers = Vec::with_capacity(n_layers);
566        for layer_idx in 0..n_layers {
567            let attn = AttnWeights {
568                q_proj: wm(
569                    rng.vec(n_heads * head_dim * hidden),
570                    vec![n_heads * head_dim, hidden],
571                ),
572                k_proj: wm(
573                    rng.vec(n_kv_heads * head_dim * hidden),
574                    vec![n_kv_heads * head_dim, hidden],
575                ),
576                v_proj: wm(
577                    rng.vec(n_kv_heads * head_dim * hidden),
578                    vec![n_kv_heads * head_dim, hidden],
579                ),
580                o_proj: wm(
581                    rng.vec(hidden * n_heads * head_dim),
582                    vec![hidden, n_heads * head_dim],
583                ),
584                norm_weight: vec![1.0; hidden],
585                q_norm: None,
586                k_norm: None,
587                q_bias: None,
588                k_bias: None,
589                v_bias: None,
590                post_attn_norm: None,
591                post_ffn_norm: None,
592            };
593
594            // Leading dense layers (see ModelConfig::layer_is_dense's
595            // doc comment) get a single-expert, no-shared-expert
596            // dense-equivalent FFN regardless of this model's global
597            // MoE topology, matching the DeepSeek-2/3-family
598            // convention found in ik_llama.cpp's source.
599            let is_dense_layer = config.layer_is_dense(layer_idx);
600            let n_experts = if is_dense_layer {
601                1
602            } else {
603                config.moe.n_experts
604            };
605            let n_shared = if is_dense_layer {
606                0
607            } else {
608                config.moe.n_shared_experts
609            };
610            let ffn_dim = config.moe.expert_ffn_dim;
611            let make_expert = |rng: &mut Lcg| ExpertWeights {
612                gate: WeightMatrix::F32(Tensor::new(
613                    rng.vec(ffn_dim * hidden),
614                    vec![ffn_dim, hidden],
615                )),
616                up: WeightMatrix::F32(Tensor::new(
617                    rng.vec(ffn_dim * hidden),
618                    vec![ffn_dim, hidden],
619                )),
620                down: WeightMatrix::F32(Tensor::new(
621                    rng.vec(hidden * ffn_dim),
622                    vec![hidden, ffn_dim],
623                )),
624            };
625            let experts: Vec<ExpertWeights> =
626                (0..n_experts).map(|_| make_expert(&mut rng)).collect();
627            let shared_experts = (0..n_shared).map(|_| make_expert(&mut rng)).collect();
628            let activation_counts = (0..experts.len()).map(|_| AtomicU64::new(0)).collect();
629
630            let moe = MoeWeights {
631                exp_probs_bias: None,
632                router: wm(rng.vec(n_experts * hidden), vec![n_experts, hidden]),
633                experts: ExpertBacking::Resident(experts),
634                shared_experts,
635                shared_expert_gate: None,
636                norm_weight: vec![1.0; hidden],
637                activation_counts,
638                #[cfg(feature = "metal")]
639                packed_q4: None,
640            };
641
642            layers.push(LayerWeights { attn, moe });
643        }
644
645        let final_norm = vec![1.0; hidden];
646        let output_head = wm(rng.vec(vocab_size * hidden), vec![vocab_size, hidden]);
647        let execution_plan = crate::execution_plan::ExecutionPlan::from_config(
648            &config,
649            crate::capability::DecoderFamily::StandardGqa,
650            crate::capability::MemoryKind::KvGqa,
651            crate::execution_plan::ExecutionPlan::probe_metal_caps(),
652        );
653
654        Decoder {
655            config,
656            embedding,
657            layers,
658            final_norm,
659            output_head,
660            gpu_vram_budget_bytes: None,
661            // Synthetic-weights constructor: no checkpoint, no gpt-oss.
662            gpt_oss: None,
663            #[cfg(feature = "metal")]
664            metal_attn_kv: std::sync::Mutex::new(None),
665            execution_plan,
666            plan_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
667        }
668    }
669
670    /// Applies RoPE to one head's Q or K slice. Dispatches on both
671    /// `rope_layout` (Norm = adjacent-pair / NeoX = split-half -- see
672    /// `RopeLayout`) and whether this checkpoint carries a real
673    /// `rope_freqs.weight` tensor (Llama 3/3.1/3.2's per-band frequency
674    /// correction). Getting the layout wrong for `llama` was the real
675    /// root cause of the Llama-3.1-8B early-stop bug: ferrox applied
676    /// NeoX pairing to an architecture that needs Norm.
677    fn apply_rope_head_theta(&self, slice: &mut [f32], pos: usize, theta: f32) {
678        use crate::config::RopeLayout;
679        // Partial rotary (llama.cpp `hparams.n_rot` < `n_embd_head_k`,
680        // GGUF `<arch>.rope.dimension_count`): Phi-3/Phi-4 rotate only the
681        // first 96 of each 128-wide head and pass the remaining 32
682        // through untouched. Rotating the whole head instead is not a
683        // subtle error — it moves dimensions the model never trained to
684        // be position-dependent.
685        let slice = match self.config.rope_dim {
686            Some(rot) if rot < slice.len() => &mut slice[..rot],
687            _ => slice,
688        };
689        match (self.config.rope_layout, &self.config.rope_freqs) {
690            (RopeLayout::Norm, Some(freq_factors)) => {
691                apply_rope_interleaved_with_freq_factors(slice, pos, theta, freq_factors)
692            }
693            (RopeLayout::Norm, None) => apply_rope_interleaved(slice, pos, theta),
694            (RopeLayout::Neox, Some(freq_factors)) => {
695                apply_rope_with_freq_factors(slice, pos, theta, freq_factors)
696            }
697            (RopeLayout::Neox, None) => apply_rope(slice, pos, theta),
698        }
699    }
700
701    fn apply_rope_head_layer(&self, slice: &mut [f32], pos: usize, layer_idx: usize) {
702        self.apply_rope_head_theta(slice, pos, self.config.layer_rope_theta(layer_idx))
703    }
704
705    /// llama.cpp's RoPE `mscale` (ggml `rope_yarn`), applied where the
706    /// QKV biases and QK-norms are: multiplying `cos`/`sin` by a constant
707    /// is the same as scaling the vector RoPE rotates, and rotation is
708    /// linear, so pre-scaling q and k here is exactly what the kernel
709    /// would do post-hoc — without a new uniform on five backends' RoPE
710    /// kernels.
711    ///
712    /// Both q and k are scaled, so attention logits carry `m²`, which is
713    /// the whole observable effect (V is untouched, and k enters the
714    /// cache scaled exactly as llama.cpp's does).
715    #[inline]
716    fn apply_rope_attn_factor(&self, q: &mut [f32], k: &mut [f32]) {
717        let m = self.config.rope_attn_factor;
718        if m == 1.0 {
719            return;
720        }
721        // ggml folds `attn_factor` into cos_theta/sin_theta inside
722        // `rope_yarn` (ops.cpp), so it reaches ONLY the rotated channels;
723        // `[n_rot, head_dim)` is then copied through untouched by the
724        // "fill the remain channels with data from src tensor" loop.
725        // Scaling the pass-through tail as well is a different graph, and
726        // `ferrox parity` caught it as the one DRIFT verdict in a
727        // 17-model sweep: Phi-4-mini rotates 96 of 128 dims with
728        // attn_factor 1.1902, so 32 dims per head were scaled that
729        // llama.cpp leaves alone.
730        let head_dim = self.config.head_dim;
731        let rot = self.config.rope_dim.unwrap_or(head_dim).min(head_dim);
732        for buf in [q, k] {
733            for head in buf.chunks_mut(head_dim) {
734                let n = rot.min(head.len());
735                for v in head[..n].iter_mut() {
736                    *v *= m;
737                }
738            }
739        }
740    }
741
742    /// Applies Q/K RMSNorm according to [`ModelConfig::qk_norm_style`].
743    fn apply_qk_norm(&self, x: &[f32], weight: &[f32]) -> Vec<f32> {
744        use crate::capability::QkNormStyle;
745        match self.config.qk_norm_style {
746            QkNormStyle::WholeVector => rms_norm(x, weight, self.config.rms_norm_eps),
747            QkNormStyle::PerHead => {
748                rms_norm_per_head(x, weight, self.config.head_dim, self.config.rms_norm_eps)
749            }
750        }
751    }
752
753    /// Builds a Metal [`MatvecLaunch`] for a quantized matrix, or `None`
754    /// if the storage/kind cannot run on Metal.
755    #[cfg(feature = "metal")]
756    fn metal_matvec_launch<'a>(m: &'a WeightMatrix) -> Option<ferrox_metal::gpu::MatvecLaunch<'a>> {
757        match m {
758            WeightMatrix::F32(t) => {
759                let rows = t.shape[0];
760                let cols = t.shape[1];
761                let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
762                    ferrox_metal::gpu::matvec_launch_meta("F32")?;
763                // SAFETY: f32 ↔ little-endian byte view for Metal upload/alias.
764                let bytes = unsafe {
765                    std::slice::from_raw_parts(t.data.as_ptr() as *const u8, t.data.len() * 4)
766                };
767                Some(ferrox_metal::gpu::MatvecLaunch {
768                    kernel_src: src,
769                    fn_name,
770                    block_bytes,
771                    block_elems,
772                    weights: bytes,
773                    rows,
774                    row_bytes: cols * 4,
775                    rows_per_tg,
776                })
777            }
778            WeightMatrix::Quantized {
779                data,
780                rows,
781                cols: _,
782                kind,
783            } => {
784                let kind_name = match kind {
785                    ferrox_core::QuantKind::Q8_0 => "Q8_0",
786                    ferrox_core::QuantKind::Q4_0 => "Q4_0",
787                    ferrox_core::QuantKind::Q4K => "Q4_K",
788                    ferrox_core::QuantKind::Q5K => "Q5_K",
789                    ferrox_core::QuantKind::Q6K => "Q6_K",
790                    ferrox_core::QuantKind::IQ4XS => "IQ4_XS",
791                    _ => return None,
792                };
793                let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
794                    ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
795                // A zero-row matrix has no rows to stride over, so
796                // there is no meaningful row size; `checked_div`
797                // says that once instead of splitting it across a
798                // guard and a bare division.
799                let row_bytes = data.as_slice().len().checked_div(*rows).unwrap_or(0);
800                Some(ferrox_metal::gpu::MatvecLaunch {
801                    kernel_src: src,
802                    fn_name,
803                    block_bytes,
804                    block_elems,
805                    weights: data.as_slice(),
806                    rows: *rows,
807                    row_bytes,
808                    rows_per_tg,
809                })
810            }
811            _ => None,
812        }
813    }
814
815    /// True when this layer can use the fused Metal attention block
816    /// (Norm or NeoX RoPE, quantized projections; QKV bias + QK-norm
817    /// via [`ferrox_metal::attn::AttnExtras`]).
818    #[cfg(feature = "metal")]
819    fn layer_supports_metal_attn(&self, layer: &LayerWeights) -> bool {
820        use crate::config::RopeLayout;
821        // gpt-oss: no Metal kernel implements attention sinks, so the
822        // fused stacks would compute a *different* attention than the
823        // CPU path for the same weights. Keep this family on CPU rather
824        // than letting the two backends disagree. See `Decoder::gpt_oss`.
825        if self.gpt_oss.is_some() {
826            return false;
827        }
828        if !matches!(self.config.rope_layout, RopeLayout::Norm | RopeLayout::Neox) {
829            return false;
830        }
831        // QKV bias (Qwen2) and QK-norm — per-head (Qwen3/Gemma-3) or
832        // whole-vector (OLMoE) — run on Metal via AttnExtras.
833        let q_len = self.config.n_heads * self.config.head_dim;
834        let k_len = self.config.n_kv_heads * self.config.head_dim;
835        let qk_norm_ok = |w: Option<&Vec<f32>>, vec_len: usize| -> bool {
836            match w {
837                None => true,
838                Some(w) if w.len() == self.config.head_dim => true,
839                Some(w) if w.len() == vec_len => true,
840                _ => false,
841            }
842        };
843        if !qk_norm_ok(layer.attn.q_norm.as_ref(), q_len)
844            || !qk_norm_ok(layer.attn.k_norm.as_ref(), k_len)
845        {
846            return false;
847        }
848        // Softcaps: final logit softcap is applied on the host after
849        // lm_head (Metal-safe). Attention softcap runs on Metal FA-vec /
850        // legacy GQA (decode + prefill).
851        //
852        // NOT attention_scale. This comment used to claim it was
853        // "compensated by scaling Q on the host/Metal extras path".
854        // There is no such compensation: `AttnExtras` has no field for
855        // it, and repo-wide `attention_scale` is applied at exactly two
856        // sites, both in the single-token CPU arm. So the claim
857        // certified a gap shut that is still open, which is worse than
858        // saying nothing. It is inert only because `loader.rs` hardcodes
859        // `attention_scale = None`, and the day a loader sets it, the
860        // batched, multi-seq and Metal paths will all ignore it.
861        if self.config.head_dim > 256 {
862            return false;
863        }
864        // Partial rotary (`n_rot < head_dim`) and LongRoPE's `mscale`
865        // now ride the Metal RoPE kernels as the `rot_dim` / `mscale`
866        // uniforms on [`ferrox_metal::attn::MetalRope`], so Phi-3/Phi-4
867        // are admitted here. `n_rot` must still be even — ggml's
868        // `ggml_rope_impl` asserts it, and an odd width would leave one
869        // channel's pairing undefined rather than merely unrotated.
870        if self
871            .config
872            .rope_dim
873            .is_some_and(|rot| rot == 0 || rot % 2 != 0 || rot > self.config.head_dim)
874        {
875            return false;
876        }
877        Self::metal_matvec_launch(&layer.attn.q_proj).is_some()
878            && Self::metal_matvec_launch(&layer.attn.k_proj).is_some()
879            && Self::metal_matvec_launch(&layer.attn.v_proj).is_some()
880            && Self::metal_matvec_launch(&layer.attn.o_proj).is_some()
881    }
882
883    /// Layer features only the fused dense stack implements — the
884    /// per-layer Metal launches would silently skip them (wrong output).
885    #[cfg(feature = "metal")]
886    fn layer_needs_metal_stack(&self, layer: &LayerWeights, layer_idx: usize) -> bool {
887        layer.attn.post_attn_norm.is_some()
888            || layer.attn.post_ffn_norm.is_some()
889            || self.config.layer_sliding_window(layer_idx).is_some()
890            || matches!(
891                self.config.ffn_activation,
892                crate::config::FfnActivation::Gelu
893            )
894            || self.config.layer_rope_theta(layer_idx) != self.config.rope_theta
895    }
896
897    /// Optional QKV bias / QK-norm ops for the Metal attn paths.
898    #[cfg(feature = "metal")]
899    fn metal_attn_extras<'a>(&self, layer: &'a LayerWeights) -> ferrox_metal::attn::AttnExtras<'a> {
900        ferrox_metal::attn::AttnExtras {
901            q_bias: layer.attn.q_bias.as_deref(),
902            k_bias: layer.attn.k_bias.as_deref(),
903            v_bias: layer.attn.v_bias.as_deref(),
904            q_norm: layer.attn.q_norm.as_deref(),
905            k_norm: layer.attn.k_norm.as_deref(),
906            attn_logit_softcap: self.config.attn_logit_softcap,
907        }
908    }
909
910    /// GPU expert residency only when Metal attention stays on-device
911    /// (when the Metal dense+attn path is active). Avoids CPU-attention
912    /// ↔ GPU-expert activation ping-pong on Metal MoE.
913    #[cfg(feature = "metal")]
914    fn expert_residency_plan(&self, use_metal_attn: bool) -> Option<ferrox_moe::ResidencyPlan> {
915        if ferrox_core::metal_dense_enabled()
916            && ferrox_metal::attn::metal_attn_enabled()
917            && !use_metal_attn
918        {
919            return None;
920        }
921        self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b))
922    }
923
924    #[cfg(not(feature = "metal"))]
925    fn expert_residency_plan(&self, _use_metal_attn: bool) -> Option<ferrox_moe::ResidencyPlan> {
926        self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b))
927    }
928
929    /// Map this checkpoint's RoPE onto the Metal kernel uniforms:
930    /// pairing convention, rotary width (`n_rot`), and ggml `rope_yarn`'s
931    /// `mscale`. The last two are what
932    /// [`Decoder::apply_rope_head_theta`] and
933    /// [`Decoder::apply_rope_attn_factor`] do on the CPU side, so the
934    /// two backends stay one graph.
935    #[cfg(feature = "metal")]
936    fn metal_rope(&self) -> ferrox_metal::attn::MetalRope {
937        use crate::config::RopeLayout;
938        let layout = match self.config.rope_layout {
939            RopeLayout::Norm => ferrox_metal::attn::MetalRopeLayout::Norm,
940            RopeLayout::Neox => ferrox_metal::attn::MetalRopeLayout::Neox,
941        };
942        ferrox_metal::attn::MetalRope {
943            layout,
944            rot_dim: self
945                .config
946                .rope_dim
947                .filter(|rot| *rot < self.config.head_dim),
948            attn_factor: self.config.rope_attn_factor,
949        }
950    }
951
952    /// Dense FFN (single expert) with Metal-capable gate/up/down.
953    #[cfg(feature = "metal")]
954    fn layer_supports_metal_dense_ffn(layer: &LayerWeights) -> bool {
955        Self::is_dense_layer(layer)
956            && layer.moe.with_expert(0, |ex| {
957                Self::metal_matvec_launch(&ex.gate).is_some()
958                    && Self::metal_matvec_launch(&ex.up).is_some()
959                    && Self::metal_matvec_launch(&ex.down).is_some()
960            })
961    }
962
963    /// Dense layer eligible for the one-CB `mul_mm_sg` prefill stack.
964    /// QKV bias / QK-norm are applied on-GPU via [`AttnExtras`] (same as
965    /// decode); SWA fit is checked separately.
966    #[cfg(feature = "metal")]
967    fn metal_prefill_dense_layer_eligible(layer: &LayerWeights) -> bool {
968        Self::is_dense_layer(layer)
969    }
970
971    #[cfg(feature = "metal")]
972    fn metal_prefill_dense_swa_fits(
973        &self,
974        layer_idx: usize,
975        start_pos: usize,
976        batch_size: usize,
977    ) -> bool {
978        match self.config.layer_sliding_window(layer_idx) {
979            Some(window) => start_pos + batch_size <= window,
980            None => true,
981        }
982    }
983
984    /// Routed-expert FFN for the fused prefill stack, or `None` when this
985    /// layer must keep the host-routed path (`launch_moe_prefill_q4_0`).
986    ///
987    /// Note: routing happens on the GPU here, so prefill no longer feeds
988    /// `record_activations`. Expert hotness for `inspect-plan` comes from
989    /// decode, which still routes on the host.
990    #[cfg(feature = "metal")]
991    fn metal_prefill_moe<'a>(
992        layer: &'a LayerWeights,
993        config: &ModelConfig,
994    ) -> Option<ferrox_metal::gpu::PrefillMoeMetal<'a>> {
995        if Self::is_dense_layer(layer)
996            || !layer.moe.shared_experts.is_empty()
997            || !matches!(
998                config.ffn_activation,
999                crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
1000            )
1001            || !matches!(config.moe.gating, ferrox_moe::GatingFunction::Softmax)
1002            || config.moe.expert_group_count.is_some()
1003            // The GPU router kernels take router weights and nothing
1004            // else: no `exp_probs_b` input, no `expert_weights_scale`
1005            // uniform. A layer carrying either must stay on the CPU
1006            // router rather than be routed without them.
1007            || layer.moe.exp_probs_bias.is_some()
1008            || config.moe.expert_weights_scale != 1.0
1009        {
1010            return None;
1011        }
1012        let ferrox_core::weight_matrix::WeightMatrix::F32(router) = &layer.moe.router else {
1013            return None;
1014        };
1015        let packed = Self::moe_packed_q4(&layer.moe)?;
1016        let moe = ferrox_metal::gpu::PrefillMoeMetal {
1017            router_w: &router.data,
1018            top_k: config.moe.n_experts_active,
1019            renormalize: config.moe.norm_topk_prob,
1020            packed,
1021        };
1022        moe.is_supported().then_some(moe)
1023    }
1024
1025    /// FFN half of a fused-prefill-stack layer: dense `mul_mm_sg` launches
1026    /// or (MoE) the routed-expert description.
1027    #[cfg(feature = "metal")]
1028    fn metal_prefill_ffn<'a>(
1029        layer: &'a LayerWeights,
1030        config: &ModelConfig,
1031    ) -> Option<ferrox_metal::attn::PrefillFfnMetal<'a>> {
1032        if let Some(moe) = Self::metal_prefill_moe(layer, config) {
1033            return Some(ferrox_metal::attn::PrefillFfnMetal::Moe(moe));
1034        }
1035        if !Self::is_dense_layer(layer) {
1036            return None;
1037        }
1038        let ExpertBacking::Resident(experts) = &layer.moe.experts else {
1039            return None;
1040        };
1041        let ex = experts.first()?;
1042        Some(ferrox_metal::attn::PrefillFfnMetal::Dense {
1043            gate: ex.gate.mul_mm_sg_launch()?,
1044            up: ex.up.mul_mm_sg_launch()?,
1045            down: ex.down.mul_mm_sg_launch()?,
1046        })
1047    }
1048
1049    /// Length of a consecutive run of Metal prefill-stack layers from
1050    /// `start`, or `None` when fewer than two layers qualify.
1051    #[cfg(feature = "metal")]
1052    fn metal_prefill_dense_stack_run_len(
1053        &self,
1054        start: usize,
1055        start_pos: usize,
1056        batch_size: usize,
1057        kv_caches: &[KvCache],
1058        metal_kvs: Option<&[ferrox_metal::attn::MetalKvBuffers]>,
1059    ) -> Option<usize> {
1060        // See `layer_supports_metal_attn`: gpt-oss stays on CPU.
1061        if self.gpt_oss.is_some() {
1062            return None;
1063        }
1064        let metal_kvs = metal_kvs?;
1065        let mut run = 0usize;
1066        for li in start..self.layers.len() {
1067            let layer = &self.layers[li];
1068            let cache = &kv_caches[li];
1069            if !self.metal_prefill_dense_swa_fits(li, start_pos, batch_size) {
1070                break;
1071            }
1072            if metal_kvs[li].seq_len != cache.seq_len || start_pos != cache.seq_len {
1073                break;
1074            }
1075            let ok = layer.attn.q_proj.mul_mm_sg_launch().is_some()
1076                && layer.attn.k_proj.mul_mm_sg_launch().is_some()
1077                && layer.attn.v_proj.mul_mm_sg_launch().is_some()
1078                && layer.attn.o_proj.mul_mm_sg_launch().is_some()
1079                && Self::metal_prefill_ffn(layer, &self.config).is_some();
1080            if !ok {
1081                break;
1082            }
1083            run += 1;
1084        }
1085        (run >= 2).then_some(run)
1086    }
1087
1088    /// Try [`ferrox_metal::attn::launch_prefill_dense_stack`] for
1089    /// `run_len` layers starting at `start`. Advances host + Metal KV
1090    /// on success.
1091    #[cfg(feature = "metal")]
1092    #[allow(clippy::too_many_arguments)]
1093    fn try_metal_prefill_dense_stack(
1094        &self,
1095        start: usize,
1096        run_len: usize,
1097        hidden_batch: &[f32],
1098        start_pos: usize,
1099        batch_size: usize,
1100        n_heads: usize,
1101        metal_kvs: &mut [ferrox_metal::attn::MetalKvBuffers],
1102        kv_caches: &mut [KvCache],
1103        host_kv_authoritative: bool,
1104    ) -> Option<Vec<f32>> {
1105        let gelu = matches!(
1106            self.config.ffn_activation,
1107            crate::config::FfnActivation::Gelu
1108        );
1109        let mut prefill_layers = Vec::with_capacity(run_len);
1110        let mut rope_thetas = Vec::with_capacity(run_len);
1111        for li in start..start + run_len {
1112            let layer = &self.layers[li];
1113            let ffn = Self::metal_prefill_ffn(layer, &self.config)?;
1114            if matches!(ffn, ferrox_metal::attn::PrefillFfnMetal::Dense { .. }) {
1115                layer.moe.record_activations(&[0]);
1116            }
1117            let (q, k, v, o) = (
1118                layer.attn.q_proj.mul_mm_sg_launch()?,
1119                layer.attn.k_proj.mul_mm_sg_launch()?,
1120                layer.attn.v_proj.mul_mm_sg_launch()?,
1121                layer.attn.o_proj.mul_mm_sg_launch()?,
1122            );
1123            prefill_layers.push(ferrox_metal::attn::PrefillDenseLayerMetal {
1124                attn_norm_w: &layer.attn.norm_weight,
1125                ffn_norm_w: &layer.moe.norm_weight,
1126                q,
1127                k,
1128                v,
1129                o,
1130                ffn,
1131                post_attn_norm: layer.attn.post_attn_norm.as_deref(),
1132                post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
1133                extras: self.metal_attn_extras(layer),
1134                layer_idx: li as u32,
1135            });
1136            rope_thetas.push(self.config.layer_rope_theta(li));
1137        }
1138        let kvs = &mut metal_kvs[start..start + run_len];
1139        let h_out = ferrox_metal::attn::launch_prefill_dense_stack(
1140            hidden_batch,
1141            &prefill_layers,
1142            kvs,
1143            n_heads,
1144            batch_size,
1145            self.metal_rope(),
1146            &rope_thetas,
1147            self.config.rope_freqs.as_deref(),
1148            start_pos,
1149            self.config.rms_norm_eps,
1150            gelu,
1151            self.config.attn_logit_softcap,
1152        )
1153        .ok()?;
1154        for (mkv, cache) in kvs.iter().zip(&mut kv_caches[start..start + run_len]) {
1155            Self::advance_host_kv_after_metal_prefill(
1156                mkv,
1157                cache,
1158                batch_size,
1159                host_kv_authoritative,
1160            );
1161        }
1162        Some(h_out)
1163    }
1164
1165    /// MoE layer eligible for resident Metal decode (attn+router+experts
1166    /// without host residual ping-pong). Requires SwiGLU, no shared
1167    /// experts, Resident expert backing, and Metal router/QKV/O.
1168    #[cfg(feature = "metal")]
1169    fn layer_supports_metal_moe_resident(layer: &LayerWeights, config: &ModelConfig) -> bool {
1170        !Self::is_dense_layer(layer)
1171            && layer.moe.shared_experts.is_empty()
1172            && matches!(
1173                config.ffn_activation,
1174                crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
1175            )
1176            // Streamed experts are eligible too. They were excluded
1177            // while the fused launch could not hold all of top-k at
1178            // once; it can now, by materialising each expert into an
1179            // owned view that carries its own pin on the store entry.
1180            && matches!(
1181                layer.moe.experts,
1182                ExpertBacking::Resident(_) | ExpertBacking::Stored { .. }
1183            )
1184            && Self::metal_matvec_launch(&layer.moe.router).is_some()
1185            && Self::metal_matvec_launch(&layer.attn.q_proj).is_some()
1186            && Self::metal_matvec_launch(&layer.attn.k_proj).is_some()
1187            && Self::metal_matvec_launch(&layer.attn.v_proj).is_some()
1188            && Self::metal_matvec_launch(&layer.attn.o_proj).is_some()
1189    }
1190
1191    /// One Metal CB for all top-k routed experts (weighted sum). Returns
1192    /// `None` if any expert lacks a Metal launch (caller falls back).
1193    #[cfg(feature = "metal")]
1194    fn try_metal_moe_topk(
1195        layer: &LayerWeights,
1196        normed2: &[f32],
1197        decision: &ferrox_moe::RoutingDecision,
1198    ) -> Option<Vec<f32>> {
1199        if decision.expert_ids.is_empty() {
1200            return Some(vec![0f32; normed2.len()]);
1201        }
1202        // Build launches while holding each expert briefly; collect owned
1203        // weight refs via with_expert into temporary MatvecLaunch list.
1204        let mut launches: Vec<ferrox_metal::gpu::MoeExpertLaunch<'_>> =
1205            Vec::with_capacity(decision.expert_ids.len());
1206        // Lifetime: MatvecLaunch borrows WeightMatrix bytes that live in
1207        // layer.moe for the duration of this call. Collect via a scoped
1208        // approach — we need all launches alive together.
1209        // Use indices + rebuild inside a single with_experts loop.
1210        struct Pending {
1211            eid: usize,
1212            weight: f32,
1213        }
1214        let pending: Vec<Pending> = decision
1215            .expert_ids
1216            .iter()
1217            .zip(decision.weights.iter())
1218            .map(|(&eid, &w)| Pending { eid, weight: w })
1219            .collect();
1220
1221        // Validate all experts have Metal launches first.
1222        for p in &pending {
1223            let ok = layer.moe.with_expert(p.eid, |ex| {
1224                Self::metal_matvec_launch(&ex.gate).is_some()
1225                    && Self::metal_matvec_launch(&ex.up).is_some()
1226                    && Self::metal_matvec_launch(&ex.down).is_some()
1227            });
1228            if !ok {
1229                return None;
1230            }
1231        }
1232
1233        // A `MatvecLaunch` borrows the expert's bytes, so every expert
1234        // in the batch has to stay alive until the command buffer is
1235        // encoded. Resident experts live in a `Vec` and can simply be
1236        // indexed. Streamed experts used to fall back to the CPU here,
1237        // on the reasoning that `with_expert` lends one at a time so
1238        // all of top-k could not be held at once.
1239        //
1240        // That was true of `with_expert` and not of the store beneath
1241        // it: `StoredExpertLayout::materialize` returns an OWNED
1242        // `ExpertWeights` whose `WeightBytes::Shared` clones the
1243        // lease's `Arc`, so each one carries its own pin and the store
1244        // cannot evict it while the view is alive. Materialising all of
1245        // top-k into a vector that outlives the launches is therefore
1246        // sound, and the vector is what holds the pins.
1247        //
1248        // The fallback was not a small loss. Expert streaming is how a
1249        // model larger than memory runs at all, so refusing the fused
1250        // path here meant that turning streaming on silently disabled
1251        // the Metal MoE kernels: exactly the configuration where the
1252        // GPU matters most ran on the CPU instead.
1253        let streamed: Vec<ExpertWeights>;
1254        match &layer.moe.experts {
1255            ExpertBacking::Resident(experts) => {
1256                for p in &pending {
1257                    let ex = &experts[p.eid];
1258                    launches.push(ferrox_metal::gpu::MoeExpertLaunch {
1259                        gate: Self::metal_matvec_launch(&ex.gate)?,
1260                        up: Self::metal_matvec_launch(&ex.up)?,
1261                        down: Self::metal_matvec_launch(&ex.down)?,
1262                        weight: p.weight,
1263                    });
1264                }
1265            }
1266            ExpertBacking::Stored {
1267                store,
1268                layouts,
1269                layer: layer_idx,
1270            } => {
1271                // Ask for the whole batch before touching any of it, so
1272                // a miss on the last expert cannot evict the first: the
1273                // store is bounded, and top-k reads are what compete
1274                // for it.
1275                let keys: Vec<_> = pending
1276                    .iter()
1277                    .map(|p| ferrox_core::expert_store::ExpertKey {
1278                        layer: *layer_idx,
1279                        expert: p.eid as u32,
1280                    })
1281                    .collect();
1282                store.prefetch(&keys);
1283
1284                let mut held = Vec::with_capacity(pending.len());
1285                for (p, key) in pending.iter().zip(keys) {
1286                    // A read failure here is not fatal: the CPU path
1287                    // reads the same bytes and will report it. Falling
1288                    // back beats panicking mid-decode.
1289                    let lease = store.acquire(key).ok()?;
1290                    held.push(layouts[p.eid].materialize(&lease));
1291                }
1292                streamed = held;
1293
1294                for (p, ex) in pending.iter().zip(streamed.iter()) {
1295                    launches.push(ferrox_metal::gpu::MoeExpertLaunch {
1296                        gate: Self::metal_matvec_launch(&ex.gate)?,
1297                        up: Self::metal_matvec_launch(&ex.up)?,
1298                        down: Self::metal_matvec_launch(&ex.down)?,
1299                        weight: p.weight,
1300                    });
1301                }
1302            }
1303        }
1304
1305        match ferrox_metal::gpu::launch_moe_topk_swiglu(normed2, &launches) {
1306            Ok(out) => Some(out),
1307            Err(e) => {
1308                eprintln!("ferrox: Metal MoE top-k fuse failed, falling back: {e}");
1309                None
1310            }
1311        }
1312    }
1313
1314    /// Contiguous Q4_0 expert planes for llama-style `mul_mv_id` MoE.
1315    #[cfg(feature = "metal")]
1316    fn moe_packed_q4(moe: &MoeWeights) -> Option<ferrox_metal::gpu::MoePackedQ4<'_>> {
1317        moe.packed_q4.as_ref().map(MoePackedQ4Planes::view)
1318    }
1319
1320    /// Prefill MoE FFN on Metal: host route over T, then one packed-id CB
1321    /// (`launch_moe_prefill_q4_0`). Shared experts (if any) run as dense
1322    /// batch FFN on the host/GPU path afterwards — not through `mul_mm_id`.
1323    /// Returns FFN outs `[T, H]` or `None`.
1324    #[cfg(feature = "metal")]
1325    fn try_metal_moe_prefill_batch(
1326        layer: &LayerWeights,
1327        normed2_batch: &[f32],
1328        router_logits_batch: &[f32],
1329        batch_size: usize,
1330        hidden_dim: usize,
1331        config: &ModelConfig,
1332    ) -> Option<Vec<f32>> {
1333        if batch_size == 0
1334            || !ferrox_core::metal_dense_enabled()
1335            || !matches!(
1336                config.ffn_activation,
1337                crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
1338            )
1339            || !matches!(config.moe.gating, ferrox_moe::GatingFunction::Softmax)
1340            || config.moe.expert_group_count.is_some()
1341            // The GPU router kernels take router weights and nothing
1342            // else: no `exp_probs_b` input, no `expert_weights_scale`
1343            // uniform. A layer carrying either must stay on the CPU
1344            // router rather than be routed without them.
1345            || layer.moe.exp_probs_bias.is_some()
1346            || config.moe.expert_weights_scale != 1.0
1347        {
1348            return None;
1349        }
1350        let ExpertBacking::Resident(_) = &layer.moe.experts else {
1351            return None;
1352        };
1353        let packed = Self::moe_packed_q4(&layer.moe)?;
1354        let top_k = config.moe.n_experts_active;
1355        if top_k == 0 || top_k > 8 || packed.hidden_rows != hidden_dim {
1356            return None;
1357        }
1358        let n_experts = layer.moe.n_experts().max(1);
1359        let mut ids = Vec::with_capacity(batch_size * top_k);
1360        let mut route = Vec::with_capacity(batch_size * top_k);
1361        for b in 0..batch_size {
1362            let logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
1363            let decision = route_top_k(logits, top_k, config.moe.gating, config.moe.norm_topk_prob);
1364            layer.moe.record_activations(&decision.expert_ids);
1365            if decision.expert_ids.len() != top_k {
1366                return None;
1367            }
1368            for (&eid, &w) in decision.expert_ids.iter().zip(decision.weights.iter()) {
1369                ids.push(eid as i32);
1370                route.push(w);
1371            }
1372        }
1373        let mut out = match ferrox_metal::gpu::launch_moe_prefill_q4_0(
1374            normed2_batch,
1375            batch_size,
1376            &packed,
1377            &ids,
1378            &route,
1379            top_k,
1380        ) {
1381            Ok(out) => out,
1382            Err(e) => {
1383                eprintln!("ferrox: Metal MoE prefill failed, CPU fallback: {e}");
1384                return None;
1385            }
1386        };
1387        Self::accumulate_shared_experts_batch(
1388            layer,
1389            normed2_batch,
1390            batch_size,
1391            hidden_dim,
1392            &mut out,
1393        );
1394        Some(out)
1395    }
1396
1397    /// Shared expert as dense batch FFN (llama qwen2moe: not through
1398    /// `mul_mat_id`). Optional sigmoid gate scales per token.
1399    fn accumulate_shared_experts_batch(
1400        layer: &LayerWeights,
1401        normed2_batch: &[f32],
1402        batch_size: usize,
1403        hidden_dim: usize,
1404        acc: &mut [f32],
1405    ) {
1406        for shex in &layer.moe.shared_experts {
1407            // Prefer one Metal FFN CB (gate∥up→SiLU→down) over three
1408            // `apply_batch` round-trips — Qwen shexp is 4× routed width.
1409            #[cfg(feature = "metal")]
1410            let down = if ferrox_core::metal_dense_enabled() && batch_size >= 4 {
1411                match (
1412                    shex.gate.mul_mm_sg_launch(),
1413                    shex.up.mul_mm_sg_launch(),
1414                    shex.down.mul_mm_sg_launch(),
1415                ) {
1416                    (Some(g), Some(u), Some(d)) => {
1417                        ferrox_metal::gpu::launch_dense_ffn_swiglu_batch(
1418                            &g,
1419                            &u,
1420                            &d,
1421                            normed2_batch,
1422                            batch_size,
1423                            false,
1424                        )
1425                        .ok()
1426                    }
1427                    _ => None,
1428                }
1429            } else {
1430                None
1431            };
1432            #[cfg(not(feature = "metal"))]
1433            let down: Option<Vec<f32>> = None;
1434            // Without `metal` the binding above is a literal `None`; the
1435            // fallback is the only arm and clippy flags the unwrap.
1436            #[cfg_attr(not(feature = "metal"), allow(clippy::unnecessary_literal_unwrap))]
1437            let down = down.unwrap_or_else(|| {
1438                let ffn_acts = shex.gate.quantize_batch_acts(normed2_batch, batch_size);
1439                let gate =
1440                    shex.gate
1441                        .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
1442                let up =
1443                    shex.up
1444                        .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
1445                let activated = ferrox_core::matmul::swiglu(&gate, &up);
1446                shex.down.apply_batch(&activated, batch_size)
1447            });
1448            if let Some(gate_w) = &layer.moe.shared_expert_gate {
1449                for b in 0..batch_size {
1450                    let x = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
1451                    let logit: f32 = gate_w.iter().zip(x.iter()).map(|(g, v)| g * v).sum();
1452                    let scale = 1.0 / (1.0 + (-logit).exp());
1453                    let out = &down[b * hidden_dim..(b + 1) * hidden_dim];
1454                    let row = &mut acc[b * hidden_dim..(b + 1) * hidden_dim];
1455                    for (a, &o) in row.iter_mut().zip(out.iter()) {
1456                        *a += scale * o;
1457                    }
1458                }
1459            } else {
1460                for (a, &o) in acc.iter_mut().zip(down.iter()) {
1461                    *a += o;
1462                }
1463            }
1464        }
1465    }
1466
1467    /// Phase-2 of resident MoE decode: experts on GPU `x2`, add into GPU `h`.
1468    #[cfg(feature = "metal")]
1469    fn try_metal_moe_experts_resident(
1470        layer: &LayerWeights,
1471        decision: &ferrox_moe::RoutingDecision,
1472    ) -> Option<()> {
1473        if decision.expert_ids.is_empty() {
1474            return Some(());
1475        }
1476        let pending: Vec<(usize, f32)> = decision
1477            .expert_ids
1478            .iter()
1479            .zip(decision.weights.iter())
1480            .map(|(&eid, &w)| (eid, w))
1481            .collect();
1482        // Bail on the backing BEFORE validating the experts. The
1483        // validation loop below calls `with_expert`, which for
1484        // `Stored` backing acquires a lease and so can read from the
1485        // checkpoint file. Refusing afterwards meant a streamed layer
1486        // paid one read per top-k expert and then threw all of them
1487        // away, before `try_metal_moe_topk` read the same experts
1488        // again. This path stays resident-only for now, but it must
1489        // decline for free.
1490        let ExpertBacking::Resident(experts) = &layer.moe.experts else {
1491            return None;
1492        };
1493        for &(eid, _) in &pending {
1494            let ex = &experts[eid];
1495            if Self::metal_matvec_launch(&ex.gate).is_none()
1496                || Self::metal_matvec_launch(&ex.up).is_none()
1497                || Self::metal_matvec_launch(&ex.down).is_none()
1498            {
1499                return None;
1500            }
1501        }
1502        let mut launches = Vec::with_capacity(pending.len());
1503        for &(eid, weight) in &pending {
1504            let ex = &experts[eid];
1505            launches.push(ferrox_metal::gpu::MoeExpertLaunch {
1506                gate: Self::metal_matvec_launch(&ex.gate)?,
1507                up: Self::metal_matvec_launch(&ex.up)?,
1508                down: Self::metal_matvec_launch(&ex.down)?,
1509                weight,
1510            });
1511        }
1512        match ferrox_metal::attn::launch_moe_decode_experts(&launches) {
1513            Ok(()) => Some(()),
1514            Err(e) => {
1515                eprintln!("ferrox: Metal MoE experts failed, falling back: {e}");
1516                None
1517            }
1518        }
1519    }
1520
1521    /// Advance the host [`KvCache`] over the positions a Metal prefill
1522    /// kernel just wrote to the device.
1523    ///
1524    /// Two ways to do that, and which one is right depends on whether
1525    /// anyone will READ the host rows.
1526    ///
1527    /// The contiguous path never does: Metal stays authoritative from
1528    /// prefill through decode, so [`KvCache::advance_len`]'s zero fill
1529    /// is a placeholder that only has to keep `seq_len` in step for the
1530    /// sync checks, and skipping the download is the whole point.
1531    ///
1532    /// The PAGED path does. `forward_batch_last_paged` scatters these
1533    /// rows into the page store, and a caller that reads placeholders
1534    /// gets a prompt the model never saw -- which is exactly how paged
1535    /// KV on Metal came to answer fluent nonsense while paged-on-CPU
1536    /// and contiguous-on-Metal were each correct. So it asks for the
1537    /// real rows and pays one download per layer, against a gather and
1538    /// a scatter it was already paying.
1539    ///
1540    /// Done here, per layer, immediately after the launch, rather than
1541    /// once at the end: the Metal KV buffers are dropped outright when
1542    /// a later layer's launch fails, and rows nobody downloaded before
1543    /// that are simply gone.
1544    #[cfg(feature = "metal")]
1545    fn advance_host_kv_after_metal_prefill(
1546        mkv: &ferrox_metal::attn::MetalKvBuffers,
1547        cache: &mut KvCache,
1548        batch_size: usize,
1549        host_kv_authoritative: bool,
1550    ) {
1551        if host_kv_authoritative {
1552            Self::catch_up_host_kv_from_metal(mkv, cache);
1553            debug_assert_eq!(cache.seq_len, mkv.seq_len);
1554        } else {
1555            cache
1556                .advance_len(batch_size)
1557                .expect("unbounded/planned KvCache growth is infallible");
1558        }
1559    }
1560
1561    /// Append host [`KvCache`] positions that Metal already holds but host
1562    /// skipped (dense-stack fast path). No-op when `cache.seq_len` is caught up.
1563    #[cfg(feature = "metal")]
1564    fn catch_up_host_kv_from_metal(mkv: &ferrox_metal::attn::MetalKvBuffers, cache: &mut KvCache) {
1565        if cache.seq_len >= mkv.seq_len {
1566            return;
1567        }
1568        let start = cache.seq_len;
1569        let n = mkv.seq_len - start;
1570        let (k, v) = mkv.tokens_host(start, n);
1571        let per = cache.n_kv_heads * cache.head_dim;
1572        for i in 0..n {
1573            let off = i * per;
1574            cache
1575                .push(&k[off..off + per], &v[off..off + per])
1576                .expect("unbounded/planned KvCache growth is infallible");
1577        }
1578    }
1579
1580    /// Pull every layer's Metal-ahead suffix into `kv_caches` (prefix-cache
1581    /// store, continuous-batch / CPU readers). Safe no-op without Metal KV.
1582    #[cfg(feature = "metal")]
1583    pub fn sync_metal_attn_kv_to_host(&self, kv_caches: &mut [KvCache]) {
1584        assert_eq!(kv_caches.len(), self.layers.len());
1585        let Ok(guard) = self.metal_attn_kv.lock() else {
1586            return;
1587        };
1588        let Some(metal_kvs) = guard.as_ref() else {
1589            return;
1590        };
1591        if metal_kvs.len() != kv_caches.len() {
1592            return;
1593        }
1594        for (mkv, cache) in metal_kvs.iter().zip(kv_caches.iter_mut()) {
1595            Self::catch_up_host_kv_from_metal(mkv, cache);
1596        }
1597    }
1598
1599    /// GQA decode reduction for one token. Uses the CUDA `gqa_decode`
1600    /// kernel when built with `--features cuda` and `FERROX_CUDA_GQA=1`
1601    /// (falling back to the host path on any launch error), else the
1602    /// portable [`causal_gqa_attention`]. With residency enabled the
1603    /// K/V append stays in [`ferrox_cuda::attn::CudaKvBuffers`] so only
1604    /// Q crosses the bus per call (plus a prefix refresh on append).
1605    #[allow(clippy::too_many_arguments)]
1606    fn gqa_attention(
1607        &self,
1608        layer: usize,
1609        q: &[f32],
1610        k: &[f32],
1611        v: &[f32],
1612        n_heads: usize,
1613        n_kv_heads: usize,
1614        head_dim: usize,
1615        seq_len: usize,
1616    ) -> Vec<f32> {
1617        #[cfg(feature = "cuda")]
1618        {
1619            if cuda_gqa_enabled() {
1620                match ferrox_cuda::attn::launch_gqa_decode_resident(
1621                    layer, q, k, v, n_heads, n_kv_heads, head_dim, seq_len,
1622                ) {
1623                    Ok(out) => return out,
1624                    Err(e) => {
1625                        eprintln!(
1626                            "ferrox: CUDA GQA resident decode failed, trying full upload: {e}"
1627                        );
1628                    }
1629                }
1630                match ferrox_cuda::attn::launch_gqa_decode(
1631                    q, k, v, n_heads, n_kv_heads, head_dim, seq_len,
1632                ) {
1633                    Ok(out) => return out,
1634                    Err(e) => {
1635                        eprintln!("ferrox: CUDA GQA decode failed, host fallback: {e}");
1636                    }
1637                }
1638            }
1639        }
1640        let _ = layer;
1641        causal_gqa_attention_softcap(
1642            q,
1643            k,
1644            v,
1645            n_heads,
1646            n_kv_heads,
1647            head_dim,
1648            seq_len,
1649            self.config.attn_logit_softcap,
1650        )
1651    }
1652
1653    /// Runs one decode step for `token_id` at position `pos`, updating
1654    /// `kv_caches` (one per layer) in place, and returns the logits over
1655    /// the (test-scale) vocabulary.
1656    pub fn forward_token(
1657        &self,
1658        token_id: usize,
1659        pos: usize,
1660        kv_caches: &mut [KvCache],
1661    ) -> Vec<f32> {
1662        // Clear stale dense-stack activation TLS. MoE scratch buffers are
1663        // reused across tokens (re-seeded); cleared after lm_head below.
1664        #[cfg(feature = "metal")]
1665        ferrox_metal::gpu::clear_resident_activation();
1666
1667        assert_eq!(kv_caches.len(), self.layers.len());
1668        let hidden_dim = self.config.hidden_dim;
1669        let head_dim = self.config.head_dim;
1670        let n_heads = self.config.n_heads;
1671        let n_kv_heads = self.config.n_kv_heads;
1672
1673        #[cfg(feature = "metal")]
1674        let metal_embd_kind = {
1675            let metal_path = ferrox_core::metal_dense_enabled()
1676                && ferrox_metal::attn::metal_attn_enabled()
1677                && self
1678                    .layers
1679                    .iter()
1680                    .all(|l| self.layer_supports_metal_attn(l))
1681                && self.layers.iter().all(Self::layer_supports_metal_dense_ffn);
1682            // Gemma scales the embedding row (`embedding_scale`) — the GPU
1683            // gather has no scale op, so dequant + scale on the host.
1684            if metal_path && self.config.embedding_scale.is_none() {
1685                Self::metal_matvec_launch(&self.embedding)
1686                    .and_then(|l| ferrox_metal::embd::EmbdKind::from_fn_name(l.fn_name))
1687            } else {
1688                None
1689            }
1690        };
1691        // `metal_embd_kind` is only `Some` when `embedding_scale` is
1692        // `None` (the GPU gather has no scale op), so the empty vector
1693        // this leaves behind is one the scale would not have touched.
1694        #[cfg(feature = "metal")]
1695        let mut hidden = if metal_embd_kind.is_some() {
1696            Vec::new()
1697        } else {
1698            self.embed_token(token_id)
1699        };
1700        #[cfg(not(feature = "metal"))]
1701        let mut hidden = self.embed_token(token_id);
1702        #[cfg(feature = "cuda")]
1703        if cuda_gqa_enabled() {
1704            // Fixed capacity so ensure_layer_kv does not recreate (and
1705            // wipe) mid-sequence as pos grows.
1706            const CUDA_KV_CAP: usize = 4096;
1707            if let Err(e) = ferrox_cuda::attn::ensure_layer_kv(
1708                self.layers.len(),
1709                self.config.n_kv_heads,
1710                self.config.head_dim,
1711                CUDA_KV_CAP,
1712            ) {
1713                eprintln!("ferrox: CUDA KV residency init failed: {e}");
1714            }
1715            if pos == 0 {
1716                ferrox_cuda::attn::clear_layer_kv();
1717            }
1718        }
1719
1720        #[cfg(feature = "metal")]
1721        let use_metal_attn = ferrox_core::metal_dense_enabled()
1722            && ferrox_metal::attn::metal_attn_enabled()
1723            && self
1724                .layers
1725                .iter()
1726                .all(|l| self.layer_supports_metal_attn(l));
1727
1728        #[cfg(not(feature = "metal"))]
1729        let use_metal_attn = false;
1730
1731        let residency = self.expert_residency_plan(use_metal_attn);
1732
1733        #[cfg(feature = "metal")]
1734        let mut metal_kv_guard: Option<
1735            std::sync::MutexGuard<'_, Option<Vec<ferrox_metal::attn::MetalKvBuffers>>>,
1736        > = if use_metal_attn {
1737            Some(self.metal_attn_kv.lock().unwrap())
1738        } else {
1739            None
1740        };
1741
1742        #[cfg(feature = "metal")]
1743        if let Some(guard) = metal_kv_guard.as_mut() {
1744            let need = self.layers.len();
1745            let cap = kv_caches
1746                .iter()
1747                .map(|c| c.seq_len.max(pos + 1).saturating_add(256))
1748                .max()
1749                .unwrap_or(512)
1750                .max(512)
1751                .max(pos + 1);
1752            let reset = match guard.as_ref() {
1753                None => true,
1754                Some(v) => {
1755                    if v.len() != need || v.iter().any(|m| m.capacity() < pos + 1) {
1756                        // Growing / reshaping: preserve Metal-ahead tokens on host first.
1757                        if v.len() == need {
1758                            for (m, c) in v.iter().zip(kv_caches.iter_mut()) {
1759                                Self::catch_up_host_kv_from_metal(m, c);
1760                            }
1761                        }
1762                        true
1763                    } else if v.iter().all(|m| m.seq_len == pos) {
1764                        // Metal already holds tokens [0, pos). Host may lag
1765                        // after dense-stack decode — do not re-upload from host.
1766                        false
1767                    } else {
1768                        // Stale Metal (new request / prefix restore): rebuild from host.
1769                        true
1770                    }
1771                }
1772            };
1773            if reset {
1774                let mut bufs = Vec::with_capacity(need);
1775                for _ in 0..need {
1776                    match ferrox_metal::attn::MetalKvBuffers::with_capacity(
1777                        n_kv_heads, head_dim, cap,
1778                    ) {
1779                        Ok(b) => bufs.push(b),
1780                        Err(_) => {
1781                            **guard = None;
1782                            break;
1783                        }
1784                    }
1785                }
1786                if bufs.len() == need {
1787                    // Sync from host after CPU prefill / prefix restore / capacity grow.
1788                    let mut ok = true;
1789                    for (m, c) in bufs.iter_mut().zip(kv_caches.iter()) {
1790                        if c.seq_len > 0 && m.upload_from_host(&c.k, &c.v, c.seq_len).is_err() {
1791                            ok = false;
1792                            break;
1793                        }
1794                    }
1795                    if ok {
1796                        **guard = Some(bufs);
1797                    } else {
1798                        **guard = None;
1799                    }
1800                } else {
1801                    **guard = None;
1802                }
1803            }
1804        }
1805
1806        #[cfg(feature = "metal")]
1807        let mut metal_stack_done = false;
1808        #[cfg(feature = "metal")]
1809        let mut final_norm_done_in_stack = false;
1810        // OLMoE: all MoE layers in one CB (llama graph style).
1811        #[cfg(feature = "metal")]
1812        if use_metal_attn
1813            && matches!(self.config.moe.gating, ferrox_moe::GatingFunction::Softmax)
1814            && self.layers.iter().enumerate().all(|(i, l)| {
1815                // Both halves are required. `layer_supports_metal_moe_resident`
1816                // answers "is this an MoE layer the GPU router can serve",
1817                // and says nothing about the four features
1818                // `MoeLayerMetal` has no fields for: a per-layer
1819                // `rope_theta`, a sliding `window`, `post_attn_norm`
1820                // and `post_ffn_norm`. `DenseLayerMetal` carries all
1821                // four and `launch_decode_dense_stack` implements
1822                // them; the MoE stack does neither, and nothing here
1823                // refused, so a windowed or sandwich-normed MoE
1824                // checkpoint would have answered as a different
1825                // model with no error.
1826                //
1827                // The per-layer path already pairs these two checks
1828                // (see the `metal_moe_resident` branch in the decode
1829                // loop). Only the whole-stack path was missing it.
1830                // Latent today because OLMoE and Qwen3-MoE ship none
1831                // of the four, which is exactly how `attention_scale`
1832                // stayed latent.
1833                Self::layer_supports_metal_moe_resident(l, &self.config)
1834                    && !self.layer_needs_metal_stack(l, i)
1835            })
1836            && !self.layers.iter().all(Self::layer_supports_metal_dense_ffn)
1837        {
1838            if let Some(guard) = metal_kv_guard.as_mut() {
1839                if let Some(metal_kvs) = guard.as_mut() {
1840                    if metal_kvs.iter().all(|m| m.seq_len == pos) {
1841                        let mut moe_layers = Vec::with_capacity(self.layers.len());
1842                        let mut ok = true;
1843                        for layer in &self.layers {
1844                            let ExpertBacking::Resident(_) = &layer.moe.experts else {
1845                                ok = false;
1846                                break;
1847                            };
1848                            let Some(packed) = Self::moe_packed_q4(&layer.moe) else {
1849                                ok = false;
1850                                break;
1851                            };
1852                            let (Some(q), Some(k), Some(v), Some(o), Some(r)) = (
1853                                Self::metal_matvec_launch(&layer.attn.q_proj),
1854                                Self::metal_matvec_launch(&layer.attn.k_proj),
1855                                Self::metal_matvec_launch(&layer.attn.v_proj),
1856                                Self::metal_matvec_launch(&layer.attn.o_proj),
1857                                Self::metal_matvec_launch(&layer.moe.router),
1858                            ) else {
1859                                ok = false;
1860                                break;
1861                            };
1862                            moe_layers.push(ferrox_metal::attn::MoeLayerMetal {
1863                                attn_norm_w: &layer.attn.norm_weight,
1864                                ffn_norm_w: &layer.moe.norm_weight,
1865                                q,
1866                                k,
1867                                v,
1868                                o,
1869                                router: r,
1870                                packed,
1871                                extras: self.metal_attn_extras(layer),
1872                            });
1873                        }
1874                        if ok {
1875                            // Greedy: fold lm_head+argmax into the stack like the
1876                            // dense path does, and download one u32 instead of a
1877                            // hidden vector.
1878                            let greedy_gpu = ferrox_metal::attn::metal_greedy_argmax_active();
1879                            let lm_head_gpu_launch = Self::metal_matvec_launch(&self.output_head);
1880                            let out_launch = if greedy_gpu { lm_head_gpu_launch } else { None };
1881                            let embd_launch = Self::metal_matvec_launch(&self.embedding);
1882                            // Gemma scales embd on host; GPU gather has no scale.
1883                            let embd_gather = if self.config.embedding_scale.is_some() {
1884                                None
1885                            } else {
1886                                match (metal_embd_kind, embd_launch.as_ref()) {
1887                                    (Some(kind), Some(launch)) => {
1888                                        Some(ferrox_metal::attn::EmbdGatherMetal {
1889                                            kind,
1890                                            weights: launch.weights,
1891                                            rows: launch.rows,
1892                                            row_bytes: launch.row_bytes,
1893                                            n_cols: hidden_dim,
1894                                            token_id,
1895                                        })
1896                                    }
1897                                    _ => None,
1898                                }
1899                            };
1900                            if embd_gather.is_none() && hidden.is_empty() {
1901                                hidden = self.embedding.dequant_row(token_id);
1902                                if let Some(scale) = self.config.embedding_scale {
1903                                    for v in hidden.iter_mut() {
1904                                        *v *= scale;
1905                                    }
1906                                }
1907                            }
1908                            let seed = if embd_gather.is_some() {
1909                                ferrox_metal::attn::moe_decode_ensure(hidden_dim)
1910                            } else {
1911                                ferrox_metal::attn::moe_decode_seed(&hidden)
1912                            };
1913                            let hidden_ref: &[f32] =
1914                                if embd_gather.is_some() { &[] } else { &hidden };
1915                            match seed.and_then(|_| {
1916                                ferrox_metal::attn::launch_moe_decode_stack(
1917                                    hidden_ref,
1918                                    &moe_layers,
1919                                    metal_kvs,
1920                                    self.config.moe.n_experts_active,
1921                                    self.config.moe.norm_topk_prob,
1922                                    n_heads,
1923                                    self.metal_rope(),
1924                                    self.config.rope_theta,
1925                                    self.config.rope_freqs.as_deref(),
1926                                    pos,
1927                                    self.config.rms_norm_eps,
1928                                    Some(&self.final_norm),
1929                                    out_launch.as_ref(),
1930                                    greedy_gpu && out_launch.is_some(),
1931                                    true,
1932                                    embd_gather.as_ref(),
1933                                )
1934                            }) {
1935                                Ok((out, per_layer_ids)) => {
1936                                    for (layer, ids) in self.layers.iter().zip(per_layer_ids.iter())
1937                                    {
1938                                        if !ids.is_empty() {
1939                                            layer.moe.record_activations(ids);
1940                                        }
1941                                    }
1942                                    if out_launch.is_some() {
1943                                        #[cfg(feature = "metal")]
1944                                        ferrox_metal::gpu::clear_resident_activation();
1945                                        return out;
1946                                    }
1947                                    hidden = out;
1948                                    final_norm_done_in_stack = true;
1949                                    metal_stack_done = true;
1950                                }
1951                                Err(e) => {
1952                                    eprintln!(
1953                                        "ferrox: Metal MoE stack failed, per-layer fallback: {e}"
1954                                    );
1955                                    if hidden.is_empty() {
1956                                        hidden = self.embedding.dequant_row(token_id);
1957                                        if let Some(scale) = self.config.embedding_scale {
1958                                            for v in hidden.iter_mut() {
1959                                                *v *= scale;
1960                                            }
1961                                        }
1962                                    }
1963                                }
1964                            }
1965                        }
1966                    }
1967                }
1968            }
1969        }
1970        #[cfg(feature = "metal")]
1971        if !metal_stack_done
1972            && use_metal_attn
1973            && self.layers.iter().all(Self::layer_supports_metal_dense_ffn)
1974        {
1975            if let Some(guard) = metal_kv_guard.as_mut() {
1976                let mut clear_metal_after_stack = false;
1977                if let Some(metal_kvs) = guard.as_mut() {
1978                    let seq_ok = metal_kvs.iter().all(|m| m.seq_len == pos);
1979                    if seq_ok {
1980                        // Build launches only for resident dense experts (Llama path).
1981                        let mut dense_layers = Vec::with_capacity(self.layers.len());
1982                        let mut ok = true;
1983                        for (li, layer) in self.layers.iter().enumerate() {
1984                            let ExpertBacking::Resident(experts) = &layer.moe.experts else {
1985                                ok = false;
1986                                break;
1987                            };
1988                            let ex = &experts[0];
1989                            let (Some(q), Some(k), Some(v), Some(o), Some(g), Some(u), Some(d)) = (
1990                                Self::metal_matvec_launch(&layer.attn.q_proj),
1991                                Self::metal_matvec_launch(&layer.attn.k_proj),
1992                                Self::metal_matvec_launch(&layer.attn.v_proj),
1993                                Self::metal_matvec_launch(&layer.attn.o_proj),
1994                                Self::metal_matvec_launch(&ex.gate),
1995                                Self::metal_matvec_launch(&ex.up),
1996                                Self::metal_matvec_launch(&ex.down),
1997                            ) else {
1998                                ok = false;
1999                                break;
2000                            };
2001                            dense_layers.push(ferrox_metal::attn::DenseLayerMetal {
2002                                attn_norm_w: &layer.attn.norm_weight,
2003                                ffn_norm_w: &layer.moe.norm_weight,
2004                                q,
2005                                k,
2006                                v,
2007                                o,
2008                                gate: g,
2009                                up: u,
2010                                down: d,
2011                                extras: self.metal_attn_extras(layer),
2012                                rope_theta: {
2013                                    let t = self.config.layer_rope_theta(li);
2014                                    (t != self.config.rope_theta).then_some(t)
2015                                },
2016                                window: self.config.layer_sliding_window(li),
2017                                post_attn_norm: layer.attn.post_attn_norm.as_deref(),
2018                                post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
2019                            });
2020                        }
2021                        if ok {
2022                            // Greedy GPU argmax-in-stack (1×u32 download) when
2023                            // generate marked this thread for temperature<=0.
2024                            // Otherwise host lm_head after the hidden download,
2025                            // which measured ~2x the tok/s of a full-vocab one.
2026                            let greedy_gpu = ferrox_metal::attn::metal_greedy_argmax_active();
2027                            let lm_head_gpu_launch = Self::metal_matvec_launch(&self.output_head);
2028                            let out_launch = if greedy_gpu { lm_head_gpu_launch } else { None };
2029                            // Pass final_norm_w when: (1) lm_head runs in stack (out_launch),
2030                            // OR (2) lm_head will route to GPU after stack (lm_head_gpu_launch
2031                            // but no out_launch) so we can skip download→reupload via TLS.
2032                            let final_norm_w =
2033                                if out_launch.is_some() || lm_head_gpu_launch.is_some() {
2034                                    Some(self.final_norm.as_slice())
2035                                } else {
2036                                    None
2037                                };
2038                            let embd_launch = Self::metal_matvec_launch(&self.embedding);
2039                            // Gemma scales the embedding row on the host
2040                            // (`hidden` already carries sqrt(hidden_dim));
2041                            // the GPU gather has no scale op — skip it.
2042                            let embd_gather = if self.config.embedding_scale.is_some() {
2043                                None
2044                            } else {
2045                                match (metal_embd_kind, embd_launch.as_ref()) {
2046                                    (Some(kind), Some(launch)) => {
2047                                        Some(ferrox_metal::attn::EmbdGatherMetal {
2048                                            kind,
2049                                            weights: launch.weights,
2050                                            rows: launch.rows,
2051                                            row_bytes: launch.row_bytes,
2052                                            n_cols: hidden_dim,
2053                                            token_id,
2054                                        })
2055                                    }
2056                                    _ => None,
2057                                }
2058                            };
2059                            let hidden_ref: &[f32] =
2060                                if embd_gather.is_some() { &[] } else { &hidden };
2061                            match ferrox_metal::attn::launch_decode_dense_stack(
2062                                hidden_ref,
2063                                &dense_layers,
2064                                metal_kvs,
2065                                n_heads,
2066                                self.metal_rope(),
2067                                self.config.rope_theta,
2068                                self.config.rope_freqs.as_deref(),
2069                                pos,
2070                                self.config.rms_norm_eps,
2071                                final_norm_w,
2072                                out_launch.as_ref(),
2073                                greedy_gpu && out_launch.is_some(),
2074                                embd_gather.as_ref(),
2075                                matches!(
2076                                    self.config.ffn_activation,
2077                                    crate::config::FfnActivation::Gelu
2078                                ),
2079                            ) {
2080                                Ok(out) => {
2081                                    // Metal KV advanced in-place. Skip host
2082                                    // last_token_host+push — host may lag until
2083                                    // sync_metal_attn_kv_to_host / CPU fallback.
2084                                    // Dense stack has no MoE routing; skip
2085                                    // per-layer hotness atomics on the hot path.
2086                                    if out_launch.is_some() {
2087                                        // Stack returned logits or [argmax id] —
2088                                        // skip host final_norm/lm_head. Clear TLS.
2089                                        #[cfg(feature = "metal")]
2090                                        ferrox_metal::gpu::clear_resident_activation();
2091                                        return out;
2092                                    }
2093                                    // Stack downloaded hidden (possibly normalized if
2094                                    // final_norm_w was Some). Track whether host should
2095                                    // skip final_norm.
2096                                    final_norm_done_in_stack = final_norm_w.is_some();
2097                                    hidden = out;
2098                                    metal_stack_done = true;
2099                                }
2100                                Err(e) => {
2101                                    eprintln!(
2102                                        "ferrox: Metal dense stack failed, per-layer fallback: {e}"
2103                                    );
2104                                    if hidden.is_empty() {
2105                                        hidden = self.embedding.dequant_row(token_id);
2106                                        if let Some(scale) = self.config.embedding_scale {
2107                                            for v in hidden.iter_mut() {
2108                                                *v *= scale;
2109                                            }
2110                                        }
2111                                    }
2112                                    // Preserve any prior Metal-ahead tokens on host
2113                                    // before dropping the device buffers.
2114                                    for (m, c) in metal_kvs.iter().zip(kv_caches.iter_mut()) {
2115                                        Self::catch_up_host_kv_from_metal(m, c);
2116                                    }
2117                                    clear_metal_after_stack = true;
2118                                }
2119                            }
2120                        }
2121                    }
2122                }
2123                if clear_metal_after_stack {
2124                    **guard = None;
2125                }
2126            }
2127        }
2128
2129        #[cfg(feature = "metal")]
2130        let run_cpu_layers = !metal_stack_done;
2131        #[cfg(not(feature = "metal"))]
2132        let run_cpu_layers = true;
2133
2134        // When true, residual lives in Metal MoE scratch — host `hidden` is stale.
2135        #[cfg(feature = "metal")]
2136        let mut metal_moe_resident = false;
2137
2138        if run_cpu_layers {
2139            for (l, (layer, cache)) in self.layers.iter().zip(kv_caches.iter_mut()).enumerate() {
2140                // --- attention block ---
2141                #[cfg(feature = "metal")]
2142                if metal_moe_resident
2143                    && (!Self::layer_supports_metal_moe_resident(layer, &self.config)
2144                        || self.layer_needs_metal_stack(layer, l))
2145                {
2146                    if let Some(h) = ferrox_metal::attn::moe_decode_take_hidden() {
2147                        hidden = h;
2148                    }
2149                    metal_moe_resident = false;
2150                }
2151
2152                #[cfg(feature = "metal")]
2153                let normed = if metal_moe_resident {
2154                    // Residual is on-device; host rms_norm would use stale hidden.
2155                    Vec::new()
2156                } else {
2157                    rms_norm(&hidden, &layer.attn.norm_weight, self.config.rms_norm_eps)
2158                };
2159                #[cfg(not(feature = "metal"))]
2160                let normed = rms_norm(&hidden, &layer.attn.norm_weight, self.config.rms_norm_eps);
2161
2162                #[cfg(feature = "metal")]
2163                {
2164                    let mut did_metal_attn = false;
2165                    let mut did_metal_dense = false;
2166                    let mut did_metal_moe = false;
2167                    let mut clear_metal_kv = false;
2168                    if let Some(guard) = metal_kv_guard.as_mut() {
2169                        if let Some(metal_kvs) = guard.as_mut() {
2170                            // Metal-authoritative: host may lag after dense-stack skip.
2171                            // Stack-only features (SWA / sandwich norms / GeGLU /
2172                            // per-layer theta) are NOT encoded by the per-layer
2173                            // launches — those layers must go to CPU here.
2174                            if metal_kvs[l].seq_len == pos
2175                                && !self.layer_needs_metal_stack(layer, l)
2176                            {
2177                                if let (Some(q_l), Some(k_l), Some(v_l), Some(o_l)) = (
2178                                    Self::metal_matvec_launch(&layer.attn.q_proj),
2179                                    Self::metal_matvec_launch(&layer.attn.k_proj),
2180                                    Self::metal_matvec_launch(&layer.attn.v_proj),
2181                                    Self::metal_matvec_launch(&layer.attn.o_proj),
2182                                ) {
2183                                    // Full dense layer on one CB when FFN is Metal-capable.
2184                                    if Self::layer_supports_metal_dense_ffn(layer) {
2185                                        let dense_ok = layer.moe.with_expert(0, |ex| {
2186                                        let (Some(g_l), Some(u_l), Some(d_l)) = (
2187                                            Self::metal_matvec_launch(&ex.gate),
2188                                            Self::metal_matvec_launch(&ex.up),
2189                                            Self::metal_matvec_launch(&ex.down),
2190                                        ) else {
2191                                            return false;
2192                                        };
2193                                        match ferrox_metal::attn::launch_decode_dense_layer(
2194                                            &hidden,
2195                                            &layer.attn.norm_weight,
2196                                            &q_l,
2197                                            &k_l,
2198                                            &v_l,
2199                                            &o_l,
2200                                            &mut metal_kvs[l],
2201                                            &layer.moe.norm_weight,
2202                                            &g_l,
2203                                            &u_l,
2204                                            &d_l,
2205                                            n_heads,
2206                                            self.metal_rope(),
2207                                            self.config.rope_theta,
2208                                            self.config.rope_freqs.as_deref(),
2209                                            pos,
2210                                            self.config.rms_norm_eps,
2211                                            &self.metal_attn_extras(layer),
2212                                        ) {
2213                                            Ok(new_h) => {
2214                                                // Catch up any dense-stack lag + this token.
2215                                                Self::catch_up_host_kv_from_metal(
2216                                                    &metal_kvs[l],
2217                                                    cache,
2218                                                );
2219                                                layer.moe.record_activations(&[0]);
2220                                                hidden = new_h;
2221                                                true
2222                                            }
2223                                            Err(e) => {
2224                                                eprintln!(
2225                                                    "ferrox: Metal dense layer failed, CPU fallback: {e}"
2226                                                );
2227                                                false
2228                                            }
2229                                        }
2230                                    });
2231                                        if dense_ok {
2232                                            did_metal_dense = true;
2233                                            did_metal_attn = true;
2234                                        } else if metal_kvs[l].seq_len != cache.seq_len {
2235                                            // Dense path may have advanced Metal KV before failing.
2236                                            Self::catch_up_host_kv_from_metal(&metal_kvs[l], cache);
2237                                            clear_metal_kv = true;
2238                                        }
2239                                    }
2240
2241                                    // Resident MoE: attn+router on GPU, host top-k only,
2242                                    // then batched experts — no hidden download/upload.
2243                                    if !did_metal_dense
2244                                        && !clear_metal_kv
2245                                        && Self::layer_supports_metal_moe_resident(
2246                                            layer,
2247                                            &self.config,
2248                                        )
2249                                    {
2250                                        if let Some(router_l) =
2251                                            Self::metal_matvec_launch(&layer.moe.router)
2252                                        {
2253                                            let seed_ok = if metal_moe_resident {
2254                                                true
2255                                            } else {
2256                                                match ferrox_metal::attn::moe_decode_seed(&hidden) {
2257                                                    Ok(()) => {
2258                                                        metal_moe_resident = true;
2259                                                        true
2260                                                    }
2261                                                    Err(e) => {
2262                                                        eprintln!(
2263                                                            "ferrox: Metal MoE seed failed: {e}"
2264                                                        );
2265                                                        false
2266                                                    }
2267                                                }
2268                                            };
2269                                            if seed_ok {
2270                                                // Prefer one-CB fused path (GPU top-k + packed experts).
2271                                                // See
2272                                                // `layer_supports_metal_moe_resident`:
2273                                                // the fused decode kernel
2274                                                // routes on the GPU and
2275                                                // has no `exp_probs_b` /
2276                                                // `expert_weights_scale`
2277                                                // input either.
2278                                                let fused_ok = matches!(
2279                                                    self.config.moe.gating,
2280                                                    ferrox_moe::GatingFunction::Softmax
2281                                                ) && layer
2282                                                    .moe
2283                                                    .exp_probs_bias
2284                                                    .is_none()
2285                                                    && self.config.moe.expert_weights_scale == 1.0
2286                                                    && match &layer.moe.experts {
2287                                                        ExpertBacking::Resident(_) => {
2288                                                            if let Some(packed) =
2289                                                                Self::moe_packed_q4(&layer.moe)
2290                                                            {
2291                                                                match ferrox_metal::attn::launch_moe_decode_layer_fused(
2292                                                                &layer.attn.norm_weight,
2293                                                                &q_l,
2294                                                                &k_l,
2295                                                                &v_l,
2296                                                                &o_l,
2297                                                                &mut metal_kvs[l],
2298                                                                &layer.moe.norm_weight,
2299                                                                &router_l,
2300                                                                &packed,
2301                                                                self.config.moe.n_experts_active,
2302                                                                self.config.moe.norm_topk_prob,
2303                                                                n_heads,
2304                                                                self.metal_rope(),
2305                                                                self.config.rope_theta,
2306                                                                self.config.rope_freqs.as_deref(),
2307                                                                pos,
2308                                                                self.config.rms_norm_eps,
2309                                                                &self.metal_attn_extras(layer),
2310                                                            ) {
2311                                                                Ok(ids) => {
2312                                                                    layer.moe.record_activations(&ids);
2313                                                                    did_metal_moe = true;
2314                                                                    did_metal_attn = true;
2315                                                                    true
2316                                                                }
2317                                                                Err(e) => {
2318                                                                    eprintln!(
2319                                                                        "ferrox: Metal MoE fused layer failed: {e}"
2320                                                                    );
2321                                                                    false
2322                                                                }
2323                                                            }
2324                                                            } else {
2325                                                                false
2326                                                            }
2327                                                        }
2328                                                        _ => false,
2329                                                    };
2330
2331                                                if !fused_ok {
2332                                                    match ferrox_metal::attn::launch_moe_decode_pre(
2333                                                        &layer.attn.norm_weight,
2334                                                        &q_l,
2335                                                        &k_l,
2336                                                        &v_l,
2337                                                        &o_l,
2338                                                        &mut metal_kvs[l],
2339                                                        &layer.moe.norm_weight,
2340                                                        &router_l,
2341                                                        n_heads,
2342                                                        self.metal_rope(),
2343                                                        self.config.rope_theta,
2344                                                        self.config.rope_freqs.as_deref(),
2345                                                        pos,
2346                                                        self.config.rms_norm_eps,
2347                                                        &self.metal_attn_extras(layer),
2348                                                    ) {
2349                                                        Ok(logits) => {
2350                                                            let decision = route_top_k(
2351                                                                &logits,
2352                                                                self.config.moe.n_experts_active,
2353                                                                self.config.moe.gating,
2354                                                                self.config.moe.norm_topk_prob,
2355                                                            );
2356                                                            layer.moe.record_activations(
2357                                                                &decision.expert_ids,
2358                                                            );
2359                                                            if let Some(()) = Self::try_metal_moe_experts_resident(
2360                                                            layer,
2361                                                            &decision,
2362                                                        ) {
2363                                                            did_metal_moe = true;
2364                                                            did_metal_attn = true;
2365                                                        } else if let Some(h) =
2366                                                            ferrox_metal::attn::moe_decode_take_hidden()
2367                                                        {
2368                                                            hidden = h;
2369                                                            metal_moe_resident = false;
2370                                                            // KV already advanced; finish FFN on host.
2371                                                            let normed2 = rms_norm(
2372                                                                &hidden,
2373                                                                &layer.moe.norm_weight,
2374                                                                self.config.rms_norm_eps,
2375                                                            );
2376                                                            let ffn_out = Self::combine_ffn_outputs_for_position(
2377                                                                layer,
2378                                                                &normed2,
2379                                                                &logits,
2380                                                                &self.config,
2381                                                                hidden_dim,
2382                                                                residency.as_ref().map(|p| p.layer_plan(l)),
2383                                                            );
2384                                                            for (h, f) in
2385                                                                hidden.iter_mut().zip(ffn_out.iter())
2386                                                            {
2387                                                                *h += f;
2388                                                            }
2389                                                            did_metal_attn = true;
2390                                                            did_metal_moe = true; // skip second FFN
2391                                                        }
2392                                                        }
2393                                                        Err(e) => {
2394                                                            eprintln!(
2395                                                            "ferrox: Metal MoE pre failed, fallback: {e}"
2396                                                        );
2397                                                            if let Some(h) =
2398                                                            ferrox_metal::attn::moe_decode_take_hidden()
2399                                                        {
2400                                                            hidden = h;
2401                                                        }
2402                                                            metal_moe_resident = false;
2403                                                            if metal_kvs[l].seq_len != cache.seq_len
2404                                                            {
2405                                                                Self::catch_up_host_kv_from_metal(
2406                                                                    &metal_kvs[l],
2407                                                                    cache,
2408                                                                );
2409                                                                clear_metal_kv = true;
2410                                                            }
2411                                                        }
2412                                                    }
2413                                                }
2414                                            }
2415                                        }
2416                                    }
2417
2418                                    if !did_metal_dense && !did_metal_moe && !clear_metal_kv {
2419                                        match ferrox_metal::attn::launch_decode_attn_block(
2420                                            &normed,
2421                                            &q_l,
2422                                            &k_l,
2423                                            &v_l,
2424                                            &o_l,
2425                                            &mut metal_kvs[l],
2426                                            n_heads,
2427                                            self.metal_rope(),
2428                                            self.config.rope_theta,
2429                                            self.config.rope_freqs.as_deref(),
2430                                            pos,
2431                                            &self.metal_attn_extras(layer),
2432                                            self.config.rms_norm_eps,
2433                                        ) {
2434                                            Ok(projected) => {
2435                                                // Keep Metal KV authoritative — skip per-layer
2436                                                // host catch-up (dense-stack style). Host is
2437                                                // flushed on CPU fallback / prefix sync.
2438                                                for (h, p) in
2439                                                    hidden.iter_mut().zip(projected.iter())
2440                                                {
2441                                                    *h += p;
2442                                                }
2443                                                did_metal_attn = true;
2444                                            }
2445                                            Err(e) => {
2446                                                eprintln!(
2447                                                "ferrox: Metal attn block failed, CPU fallback: {e}"
2448                                            );
2449                                                Self::catch_up_host_kv_from_metal(
2450                                                    &metal_kvs[l],
2451                                                    cache,
2452                                                );
2453                                                clear_metal_kv = true;
2454                                            }
2455                                        }
2456                                    }
2457                                }
2458                            } else if metal_kvs[l].seq_len > cache.seq_len {
2459                                // Leaving Metal path: host must see full KV for CPU attn.
2460                                Self::catch_up_host_kv_from_metal(&metal_kvs[l], cache);
2461                            }
2462                        }
2463                        if clear_metal_kv {
2464                            **guard = None;
2465                        }
2466                    }
2467                    if did_metal_attn {
2468                        if !did_metal_dense && !did_metal_moe {
2469                            let normed2 =
2470                                rms_norm(&hidden, &layer.moe.norm_weight, self.config.rms_norm_eps);
2471                            let ffn_out = Self::run_ffn_block(
2472                                layer,
2473                                &normed2,
2474                                &self.config,
2475                                hidden_dim,
2476                                residency.as_ref().map(|p| p.layer_plan(l)),
2477                            );
2478                            for (h, f) in hidden.iter_mut().zip(ffn_out.iter()) {
2479                                *h += f;
2480                            }
2481                        }
2482                        continue;
2483                    }
2484                }
2485
2486                let (mut q, mut k, mut v) = {
2487                    #[cfg(any(feature = "cuda", feature = "metal"))]
2488                    {
2489                        if let Some(mut outs) = ferrox_core::WeightMatrix::apply_gpu_multi(
2490                            &[&layer.attn.q_proj, &layer.attn.k_proj, &layer.attn.v_proj],
2491                            &normed,
2492                        ) {
2493                            let v = outs.pop().unwrap();
2494                            let k = outs.pop().unwrap();
2495                            let q = outs.pop().unwrap();
2496                            (q, k, v)
2497                        } else {
2498                            ferrox_core::weight_matrix::WeightMatrix::apply_three(
2499                                &layer.attn.q_proj,
2500                                &layer.attn.k_proj,
2501                                &layer.attn.v_proj,
2502                                &normed,
2503                            )
2504                        }
2505                    }
2506                    #[cfg(not(any(feature = "cuda", feature = "metal")))]
2507                    {
2508                        ferrox_core::weight_matrix::WeightMatrix::apply_three(
2509                            &layer.attn.q_proj,
2510                            &layer.attn.k_proj,
2511                            &layer.attn.v_proj,
2512                            &normed,
2513                        )
2514                    }
2515                };
2516
2517                if let Some(bias) = &layer.attn.q_bias {
2518                    for (x, b) in q.iter_mut().zip(bias.iter()) {
2519                        *x += b;
2520                    }
2521                }
2522                if let Some(bias) = &layer.attn.k_bias {
2523                    for (x, b) in k.iter_mut().zip(bias.iter()) {
2524                        *x += b;
2525                    }
2526                }
2527                if let Some(bias) = &layer.attn.v_bias {
2528                    for (x, b) in v.iter_mut().zip(bias.iter()) {
2529                        *x += b;
2530                    }
2531                }
2532
2533                if let Some(q_norm) = &layer.attn.q_norm {
2534                    q = self.apply_qk_norm(&q, q_norm);
2535                }
2536                if let Some(k_norm) = &layer.attn.k_norm {
2537                    k = self.apply_qk_norm(&k, k_norm);
2538                }
2539                self.apply_rope_attn_factor(&mut q, &mut k);
2540
2541                for h in 0..n_heads {
2542                    self.apply_rope_head_layer(&mut q[h * head_dim..(h + 1) * head_dim], pos, l);
2543                }
2544                for h in 0..n_kv_heads {
2545                    self.apply_rope_head_layer(&mut k[h * head_dim..(h + 1) * head_dim], pos, l);
2546                }
2547                // When an architecture overrides the score scale (llama.cpp
2548                // Gemma scales Q then calls build_attn with 1.0), compensate
2549                // for the kernel's built-in 1/sqrt(head_dim) so the net
2550                // score scale equals `attention_scale`.
2551                if let Some(scale) = self.config.attention_scale {
2552                    let compensate = scale * (head_dim as f32).sqrt();
2553                    for v in q.iter_mut() {
2554                        *v *= compensate;
2555                    }
2556                }
2557
2558                cache
2559                    .push(&k, &v)
2560                    .expect("unbounded/planned KvCache growth is infallible");
2561
2562                let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
2563                let attn_out = match (oai, self.config.layer_sliding_window(l)) {
2564                    (Some(oai), window) => ferrox_core::causal_gqa_attention_sinks(
2565                        &q,
2566                        &cache.k,
2567                        &cache.v,
2568                        n_heads,
2569                        n_kv_heads,
2570                        head_dim,
2571                        cache.seq_len,
2572                        window,
2573                        &oai.attn_sinks,
2574                    ),
2575                    (None, Some(window)) => causal_gqa_attention_windowed_softcap(
2576                        &q,
2577                        &cache.k,
2578                        &cache.v,
2579                        n_heads,
2580                        n_kv_heads,
2581                        head_dim,
2582                        cache.seq_len,
2583                        window,
2584                        self.config.attn_logit_softcap,
2585                    ),
2586                    (None, None) => self.gqa_attention(
2587                        l,
2588                        &q,
2589                        &cache.k,
2590                        &cache.v,
2591                        n_heads,
2592                        n_kv_heads,
2593                        head_dim,
2594                        cache.seq_len,
2595                    ),
2596                };
2597                let mut projected = layer.attn.o_proj.apply(&attn_out);
2598                if let Some(oai) = oai {
2599                    for (x, b) in projected.iter_mut().zip(oai.o_bias.iter()) {
2600                        *x += b;
2601                    }
2602                }
2603                if let Some(post) = &layer.attn.post_attn_norm {
2604                    projected = rms_norm(&projected, post, self.config.rms_norm_eps);
2605                }
2606
2607                for (h, p) in hidden.iter_mut().zip(projected.iter()) {
2608                    *h += p;
2609                }
2610
2611                // --- MoE FFN block ---
2612                let normed2 = rms_norm(&hidden, &layer.moe.norm_weight, self.config.rms_norm_eps);
2613                let mut ffn_out = match oai {
2614                    Some(oai) => Self::gpt_oss_ffn(layer, oai, &normed2, &self.config, hidden_dim),
2615                    None => Self::run_ffn_block(
2616                        layer,
2617                        &normed2,
2618                        &self.config,
2619                        hidden_dim,
2620                        residency.as_ref().map(|p| p.layer_plan(l)),
2621                    ),
2622                };
2623                if let Some(post) = &layer.attn.post_ffn_norm {
2624                    ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
2625                }
2626                for (h, f) in hidden.iter_mut().zip(ffn_out.iter()) {
2627                    *h += f;
2628                }
2629            }
2630        } // run_cpu_layers
2631
2632        #[cfg(feature = "metal")]
2633        if metal_moe_resident {
2634            if let Some(h) = ferrox_metal::attn::moe_decode_take_hidden() {
2635                hidden = h;
2636            }
2637        }
2638
2639        // If Metal stack already ran final_norm, hidden is normalized; else
2640        // normalize here.
2641        #[cfg(feature = "metal")]
2642        let final_normed = if final_norm_done_in_stack {
2643            hidden.clone()
2644        } else {
2645            rms_norm(&hidden, &self.final_norm, self.config.rms_norm_eps)
2646        };
2647        #[cfg(not(feature = "metal"))]
2648        let final_normed = rms_norm(&hidden, &self.final_norm, self.config.rms_norm_eps);
2649
2650        let logits = self.logits_from_normed(&final_normed);
2651        // Clear dense-stack activation TLS after lm_head (may have consumed it).
2652        // Keep MoE scratch buffers alive across tokens — `moe_decode_seed`
2653        // overwrites `h` each token; clearing here forced full realloc.
2654        #[cfg(feature = "metal")]
2655        ferrox_metal::gpu::clear_resident_activation();
2656        logits
2657    }
2658
2659    /// Same computation as `forward_token`, but each layer's K/V cache
2660    /// is a `PagedKvCache` (block-table-indexed into a per-layer
2661    /// `PagedKvStore`) instead of a `KvCache`'s contiguous buffer --
2662    /// exercises the paged attention kernel in a real decode loop
2663    /// instead of only in isolation. `kv_caches`/`stores` are parallel
2664    /// per-layer arrays, mirroring `forward_token`'s `kv_caches: &mut
2665    /// [KvCache]`. Must produce bit-identical output to `forward_token`
2666    /// given stores sized so no layer ever exhausts its blocks --
2667    /// pinned by
2668    /// `forward_token_paged_matches_forward_token_bit_identical` and,
2669    /// per attention arm, by
2670    /// `every_paged_attention_arm_is_bit_identical_to_its_contiguous_twin`.
2671    ///
2672    /// This used to refuse gpt-oss outright, because the paged kernel
2673    /// had no attention-sink term and no sliding-window arm and would
2674    /// have answered differently from the contiguous path without
2675    /// saying so. It now mirrors all three arms of that dispatch, so
2676    /// the refusal is gone rather than merely relaxed.
2677    pub fn forward_token_paged(
2678        &self,
2679        token_id: usize,
2680        pos: usize,
2681        kv_caches: &mut [PagedKvCache],
2682        stores: &SharedPagedKv,
2683    ) -> Result<Vec<f32>, PagedStoreExhausted> {
2684        assert_eq!(kv_caches.len(), self.layers.len());
2685        assert_eq!(stores.layer_count(), self.layers.len());
2686        // All layers advance or none do. Pushing per layer with `?` and
2687        // failing at layer 3 of 4 leaves layers 0..2 holding a position
2688        // the rest do not, and nothing downstream reports it: the next
2689        // step simply attends over a shorter history in the tail
2690        // layers. Reserving one position everywhere first turns that
2691        // into a clean refusal.
2692        //
2693        // The guards span the check AND the push for the same reason
2694        // the prefill path holds them: otherwise another request takes
2695        // the blocks in between.
2696        {
2697            let mut guards = stores.write_all();
2698            for (cache, store) in kv_caches.iter().zip(guards.iter()) {
2699                if cache.blocks_needed_for(store, 1) > store.free_block_count() {
2700                    return Err(PagedStoreExhausted);
2701                }
2702            }
2703            // Reserve by taking the blocks now, so the per-layer pushes
2704            // below cannot fail. `PagedKvCache::reserve` grows the block
2705            // table without advancing `seq_len`, leaving each push a
2706            // pure write into a block this sequence already owns.
2707            for (cache, store) in kv_caches.iter_mut().zip(guards.iter_mut()) {
2708                cache
2709                    .reserve(store, 1)
2710                    .expect("checked against free_block_count under this same guard");
2711            }
2712        }
2713        let hidden_dim = self.config.hidden_dim;
2714        let head_dim = self.config.head_dim;
2715        let n_heads = self.config.n_heads;
2716        let n_kv_heads = self.config.n_kv_heads;
2717
2718        let mut hidden = self.embed_token(token_id);
2719        let residency = self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b));
2720
2721        for (l, (layer, cache)) in self.layers.iter().zip(kv_caches.iter_mut()).enumerate() {
2722            // --- attention block ---
2723            let normed = rms_norm(&hidden, &layer.attn.norm_weight, self.config.rms_norm_eps);
2724
2725            let (mut q, mut k, mut v) = {
2726                #[cfg(any(feature = "cuda", feature = "metal"))]
2727                {
2728                    if let Some(mut outs) = ferrox_core::WeightMatrix::apply_gpu_multi(
2729                        &[&layer.attn.q_proj, &layer.attn.k_proj, &layer.attn.v_proj],
2730                        &normed,
2731                    ) {
2732                        let v = outs.pop().unwrap();
2733                        let k = outs.pop().unwrap();
2734                        let q = outs.pop().unwrap();
2735                        (q, k, v)
2736                    } else {
2737                        ferrox_core::weight_matrix::WeightMatrix::apply_three(
2738                            &layer.attn.q_proj,
2739                            &layer.attn.k_proj,
2740                            &layer.attn.v_proj,
2741                            &normed,
2742                        )
2743                    }
2744                }
2745                #[cfg(not(any(feature = "cuda", feature = "metal")))]
2746                {
2747                    (
2748                        layer.attn.q_proj.apply(&normed),
2749                        layer.attn.k_proj.apply(&normed),
2750                        layer.attn.v_proj.apply(&normed),
2751                    )
2752                }
2753            };
2754
2755            if let Some(bias) = &layer.attn.q_bias {
2756                for (x, b) in q.iter_mut().zip(bias.iter()) {
2757                    *x += b;
2758                }
2759            }
2760            if let Some(bias) = &layer.attn.k_bias {
2761                for (x, b) in k.iter_mut().zip(bias.iter()) {
2762                    *x += b;
2763                }
2764            }
2765            if let Some(bias) = &layer.attn.v_bias {
2766                for (x, b) in v.iter_mut().zip(bias.iter()) {
2767                    *x += b;
2768                }
2769            }
2770
2771            if let Some(q_norm) = &layer.attn.q_norm {
2772                q = self.apply_qk_norm(&q, q_norm);
2773            }
2774            if let Some(k_norm) = &layer.attn.k_norm {
2775                k = self.apply_qk_norm(&k, k_norm);
2776            }
2777            self.apply_rope_attn_factor(&mut q, &mut k);
2778
2779            for h in 0..n_heads {
2780                self.apply_rope_head_layer(&mut q[h * head_dim..(h + 1) * head_dim], pos, l);
2781            }
2782            for h in 0..n_kv_heads {
2783                self.apply_rope_head_layer(&mut k[h * head_dim..(h + 1) * head_dim], pos, l);
2784            }
2785            // The sixth feature this path lost by being a copy. Gemma
2786            // scales Q itself and asks for a score scale of 1.0, so the
2787            // kernel's built-in `1/sqrt(head_dim)` has to be
2788            // compensated for -- exactly as the contiguous path does.
2789            // Missing, it is not an error: it is Gemma answering with a
2790            // different temperature, on CPU as much as on GPU.
2791            if let Some(scale) = self.config.attention_scale {
2792                let compensate = scale * (head_dim as f32).sqrt();
2793                for v in q.iter_mut() {
2794                    *v *= compensate;
2795                }
2796            }
2797
2798            // Write guard for the push alone; see `SharedPagedKv`.
2799            // Holding it across attention below would serialise the
2800            // expensive half and give back a global lock.
2801            {
2802                let mut store = stores.write(l);
2803                cache
2804                    .push(&mut store, &k, &v)
2805                    .expect("reserved for every layer before the stack ran");
2806            }
2807            let store = stores.read(l);
2808
2809            // Mirrors the contiguous dispatch above arm for arm. It has
2810            // to: the premise of paged KV is that it changes where rows
2811            // live and nothing else, so any arm the paged path did not
2812            // reproduce would be a model that answers differently
2813            // depending on whether a KV pool happened to be configured.
2814            // `causal_gqa_attention_paged_sinks` covers all three, and
2815            // is bit-identical to its contiguous twin by construction.
2816            let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
2817            let window = self.config.layer_sliding_window(l);
2818            let attn_out = ferrox_core::causal_gqa_attention_paged_sinks(
2819                &q,
2820                &store,
2821                cache.block_table(),
2822                n_heads,
2823                n_kv_heads,
2824                head_dim,
2825                cache.seq_len(),
2826                window,
2827                oai.map(|o| o.attn_sinks.as_slice()),
2828                // The contiguous sink arm passes no softcap, so neither
2829                // does this one; the other two carry the config's.
2830                if oai.is_some() {
2831                    None
2832                } else {
2833                    self.config.attn_logit_softcap
2834                },
2835            );
2836            // Three more the copy had dropped, each silent: gpt-oss's O
2837            // bias, and the two sandwich norms Gemma-2 applies to the
2838            // attention and FFN branches before they rejoin the
2839            // residual. A residual short by a normalisation is still a
2840            // residual, so nothing fails -- the model just answers as a
2841            // different model.
2842            let mut projected = layer.attn.o_proj.apply(&attn_out);
2843            if let Some(oai) = oai {
2844                for (x, b) in projected.iter_mut().zip(oai.o_bias.iter()) {
2845                    *x += b;
2846                }
2847            }
2848            if let Some(post) = &layer.attn.post_attn_norm {
2849                projected = rms_norm(&projected, post, self.config.rms_norm_eps);
2850            }
2851
2852            for (h, p) in hidden.iter_mut().zip(projected.iter()) {
2853                *h += p;
2854            }
2855
2856            // --- MoE FFN block ---
2857            let normed2 = rms_norm(&hidden, &layer.moe.norm_weight, self.config.rms_norm_eps);
2858            let mut ffn_out = match oai {
2859                Some(oai) => Self::gpt_oss_ffn(layer, oai, &normed2, &self.config, hidden_dim),
2860                None => Self::run_ffn_block(
2861                    layer,
2862                    &normed2,
2863                    &self.config,
2864                    hidden_dim,
2865                    residency.as_ref().map(|p| p.layer_plan(l)),
2866                ),
2867            };
2868            if let Some(post) = &layer.attn.post_ffn_norm {
2869                ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
2870            }
2871            for (h, f) in hidden.iter_mut().zip(ffn_out.iter()) {
2872                *h += f;
2873            }
2874        }
2875
2876        let final_normed = rms_norm(&hidden, &self.final_norm, self.config.rms_norm_eps);
2877        Ok(self.logits_from_normed(&final_normed))
2878    }
2879
2880    /// The shared expert store's live counters, when this model runs
2881    /// with store-backed (streamed) routed experts -- `None` for fully
2882    /// resident models. Every store-backed layer shares one store, so
2883    /// the first one found speaks for the whole model.
2884    pub fn expert_store_stats(&self) -> Option<ferrox_core::expert_store::ExpertStoreStats> {
2885        self.layers.iter().find_map(|l| match &l.moe.experts {
2886            ExpertBacking::Stored { store, .. } => Some(store.stats()),
2887            ExpertBacking::Resident(_) => None,
2888        })
2889    }
2890
2891    /// Builds one global device-residency plan across ALL layers'
2892    /// routed experts against the single configured VRAM budget --
2893    /// every `(layer, expert)` candidate competes in one hotness-
2894    /// ordered pass and the running byte total is shared, so the
2895    /// budget cannot be re-spent per layer (the accounting bug the
2896    /// earlier per-layer `placement_plan` calls had: N layers would
2897    /// plan N x the configured bytes). Dense layers contribute no
2898    /// candidates (their sole expert always runs on CPU). Rebuilt per
2899    /// forward call so it tracks observed hotness; not yet
2900    /// performance-tuned, a disclosed limit.
2901    fn residency_plan(&self, vram_budget_bytes: u64) -> ferrox_moe::ResidencyPlan {
2902        let mut sizes_per_layer: Vec<Vec<usize>> = Vec::with_capacity(self.layers.len());
2903        let mut counts_per_layer: Vec<Vec<u64>> = Vec::with_capacity(self.layers.len());
2904        let mut any_observed = false;
2905        for layer in &self.layers {
2906            if Self::is_dense_layer(layer) {
2907                sizes_per_layer.push(Vec::new());
2908                counts_per_layer.push(Vec::new());
2909                continue;
2910            }
2911            sizes_per_layer.push(
2912                (0..layer.moe.n_experts())
2913                    .map(|e| layer.moe.expert_bytes(e))
2914                    .collect(),
2915            );
2916            let counts: Vec<u64> = layer
2917                .moe
2918                .activation_counts
2919                .iter()
2920                .map(|c| c.load(Ordering::Relaxed))
2921                .collect();
2922            any_observed |= counts.iter().any(|&c| c > 0);
2923            counts_per_layer.push(counts);
2924        }
2925        PlacementPlan::plan_layers_against_global_budget(
2926            &sizes_per_layer,
2927            any_observed.then_some(counts_per_layer.as_slice()),
2928            vram_budget_bytes,
2929        )
2930    }
2931
2932    /// True if this layer has nothing to route: exactly one expert and
2933    /// no shared experts, the shape every non-MoE model (and every
2934    /// DeepSeek-style "leading dense layer") loads as. Top-1 selection
2935    /// out of one expert always picks it, and its weight is always
2936    /// exactly 1.0 regardless of gating function (softmax over one
2937    /// logit is trivially 1.0; sigmoid-then-renormalize divides the
2938    /// selected score by itself) -- so skipping the router matmul,
2939    /// `route_top_k`'s sort/exp/renormalize work, and
2940    /// `combine_expert_outputs`'s Vec-wrapping for this case is not an
2941    /// approximation, it produces the exact same result.
2942    fn is_dense_layer(layer: &LayerWeights) -> bool {
2943        layer.moe.n_experts() == 1 && layer.moe.shared_experts.is_empty()
2944    }
2945
2946    /// llama.cpp `mul_mat_id` style: shared Q8 act + flat rayon over
2947    /// `(slot, row_pair)` for gate∥up (2-row SDOT), then SwiGLU, then
2948    /// per-slot down. One outer fork-join — no nested `apply_cpu_q8`.
2949    fn cpu_moe_topk_parallel_slots(
2950        experts: &[ExpertWeights],
2951        normed2: &[f32],
2952        decision: &ferrox_moe::RoutingDecision,
2953        hidden_dim: usize,
2954    ) -> Option<Vec<(Vec<f32>, f32)>> {
2955        use rayon::prelude::*;
2956        if !ferrox_core::weight_matrix::cpu_int_dot_enabled() || !normed2.len().is_multiple_of(32) {
2957            return None;
2958        }
2959        let n_slots = decision.expert_ids.len();
2960        if n_slots == 0 {
2961            return Some(Vec::new());
2962        }
2963        for &eid in &decision.expert_ids {
2964            let ex = experts.get(eid)?;
2965            if ex.gate.rows() == 0
2966                || ex.up.rows() != ex.gate.rows()
2967                || ex.down.rows() != hidden_dim
2968                || ex.gate.cols() != normed2.len()
2969                || ex.up.cols() != normed2.len()
2970                || ex.down.cols() != ex.gate.rows()
2971            {
2972                return None;
2973            }
2974            if !matches!(
2975                &ex.gate,
2976                WeightMatrix::Quantized {
2977                    kind: ferrox_core::QuantKind::Q4_0 | ferrox_core::QuantKind::Q8_0,
2978                    ..
2979                }
2980            ) || !matches!(
2981                &ex.up,
2982                WeightMatrix::Quantized {
2983                    kind: ferrox_core::QuantKind::Q4_0 | ferrox_core::QuantKind::Q8_0,
2984                    ..
2985                }
2986            ) {
2987                return None;
2988            }
2989        }
2990        let ffn_rows = experts[decision.expert_ids[0]].gate.rows();
2991        // Even ffn_rows: par_chunks_mut(2) never crosses a slot boundary.
2992        if !ffn_rows.is_multiple_of(2) {
2993            return None;
2994        }
2995        let act = ferrox_quant::quantize_activations_q8(normed2);
2996        let eids = &decision.expert_ids;
2997        let mut gate = vec![0f32; n_slots * ffn_rows];
2998        let mut up = vec![0f32; n_slots * ffn_rows];
2999        gate.par_chunks_mut(2)
3000            .zip(up.par_chunks_mut(2))
3001            .enumerate()
3002            .for_each(|(p, (gc, uc))| {
3003                let row0 = p * 2;
3004                let slot = row0 / ffn_rows;
3005                let r = row0 % ffn_rows;
3006                let ex = &experts[eids[slot]];
3007                if let (Some((g0, g1)), Some((u0, u1))) = (
3008                    ex.gate.dot_pair_cpu_q8(r, &act),
3009                    ex.up.dot_pair_cpu_q8(r, &act),
3010                ) {
3011                    gc[0] = g0;
3012                    gc[1] = g1;
3013                    uc[0] = u0;
3014                    uc[1] = u1;
3015                } else {
3016                    gc[0] = ex.gate.dot_row_cpu_q8(r, &act).unwrap_or(0.0);
3017                    gc[1] = ex.gate.dot_row_cpu_q8(r + 1, &act).unwrap_or(0.0);
3018                    uc[0] = ex.up.dot_row_cpu_q8(r, &act).unwrap_or(0.0);
3019                    uc[1] = ex.up.dot_row_cpu_q8(r + 1, &act).unwrap_or(0.0);
3020                }
3021            });
3022        let mut activated = vec![0f32; n_slots * ffn_rows];
3023        activated.par_iter_mut().enumerate().for_each(|(idx, a)| {
3024            let g = gate[idx];
3025            *a = (g / (1.0 + (-g).exp())) * up[idx];
3026        });
3027        let mut outs: Vec<(Vec<f32>, f32)> = decision
3028            .weights
3029            .iter()
3030            .map(|&w| (vec![0f32; hidden_dim], w))
3031            .collect();
3032        outs.par_iter_mut()
3033            .enumerate()
3034            .for_each(|(slot, (out, _))| {
3035                let ex = &experts[eids[slot]];
3036                let act_slot = &activated[slot * ffn_rows..(slot + 1) * ffn_rows];
3037                if act_slot.len().is_multiple_of(32) {
3038                    let q8 = ferrox_quant::quantize_activations_q8(act_slot);
3039                    if let Some(d) = ex.down.apply_cpu_q8(&q8) {
3040                        *out = d;
3041                        return;
3042                    }
3043                }
3044                *out = ex.down.apply(act_slot);
3045            });
3046        Some(outs)
3047    }
3048
3049    /// Fallback: serial top-k with shared Q8 act (pre-mul_mat_id path).
3050    fn cpu_moe_serial_experts(
3051        layer: &LayerWeights,
3052        normed2: &[f32],
3053        decision: &ferrox_moe::RoutingDecision,
3054        plan: Option<&PlacementPlan>,
3055    ) -> Vec<(Vec<f32>, f32)> {
3056        let shared_act = if ferrox_core::weight_matrix::cpu_int_dot_enabled()
3057            && normed2.len().is_multiple_of(32)
3058            && plan
3059                .map(|p| {
3060                    decision
3061                        .expert_ids
3062                        .iter()
3063                        .all(|&eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu))
3064                })
3065                .unwrap_or(true)
3066        {
3067            Some(ferrox_quant::quantize_activations_q8(normed2))
3068        } else {
3069            None
3070        };
3071        decision
3072            .expert_ids
3073            .iter()
3074            .zip(decision.weights.iter())
3075            .map(|(&eid, &w)| {
3076                let placement = plan
3077                    .map(|p| p.placement_for(eid))
3078                    .unwrap_or(ExpertPlacement::Cpu);
3079                let out = layer.moe.with_expert(eid, |ex| {
3080                    if let Some(ref act) = shared_act {
3081                        if let (Some(gate), Some(up)) =
3082                            (ex.gate.apply_cpu_q8(act), ex.up.apply_cpu_q8(act))
3083                        {
3084                            let activated = ferrox_core::matmul::swiglu(&gate, &up);
3085                            return ex.down.apply(&activated);
3086                        }
3087                    }
3088                    run_expert_placed(normed2, ex, placement)
3089                });
3090                (out, w)
3091            })
3092            .collect()
3093    }
3094
3095    /// Runs one position's normalized hidden state through this
3096    /// layer's MoE FFN block, given already-computed router logits for
3097    /// that position, returning the combined output to add back into
3098    /// the residual stream. Shared by `forward_token` (router computed
3099    /// via a single `apply` call, since there's only one position) and
3100    /// `forward_batch`'s per-position loop (router computed via one
3101    /// batched `apply_batch` call up front, sliced per position here --
3102    /// see `forward_batch`'s doc comment for why that batching matters
3103    /// and must not be lost by calling this per position instead).
3104    /// `gpu_vram_budget_bytes`: see `Decoder::gpu_vram_budget_bytes`'s
3105    /// doc comment -- `None` dispatches every routed expert through
3106    /// `run_expert_placed` with `ExpertPlacement::Cpu`, which is
3107    /// exactly `run_expert`'s own behavior, so this is a real
3108    /// zero-behavior-change default, not just "probably fine."
3109    /// One token's routing decision for one MoE layer.
3110    ///
3111    /// Three shapes, in the order llama.cpp's `build_moe_ffn` decides
3112    /// them: grouped selection when the checkpoint declares expert
3113    /// groups; the biased/scaled port when the layer carries
3114    /// `exp_probs_b` or the model carries a non-unit
3115    /// `expert_weights_scale`; otherwise the plain top-k this decoder has
3116    /// always used. The last arm is kept rather than folded into
3117    /// `route_top_k_biased` so that every checkpoint without those two
3118    /// features routes through byte-identical code to before.
3119    ///
3120    /// `exp_probs_b` together with expert groups is refused at load
3121    /// (`loader.rs`), so that combination cannot reach here.
3122    fn route_for_layer(
3123        layer: &LayerWeights,
3124        router_logits: &[f32],
3125        config: &ModelConfig,
3126    ) -> ferrox_moe::RoutingDecision {
3127        match (
3128            config.moe.expert_group_count,
3129            config.moe.expert_group_used_count,
3130        ) {
3131            (Some(n_groups), Some(k_per_group)) if n_groups > 1 && k_per_group > 0 => {
3132                ferrox_moe::route_top_k_grouped(
3133                    router_logits,
3134                    n_groups,
3135                    k_per_group,
3136                    config.moe.n_experts_active,
3137                    config.moe.gating,
3138                    config.moe.norm_topk_prob,
3139                )
3140            }
3141            _ if layer.moe.exp_probs_bias.is_some() || config.moe.expert_weights_scale != 1.0 => {
3142                ferrox_moe::route_top_k_biased(
3143                    router_logits,
3144                    layer.moe.exp_probs_bias.as_deref(),
3145                    config.moe.n_experts_active,
3146                    config.moe.gating,
3147                    config.moe.norm_topk_prob,
3148                    config.moe.expert_weights_scale,
3149                )
3150            }
3151            _ => route_top_k(
3152                router_logits,
3153                config.moe.n_experts_active,
3154                config.moe.gating,
3155                config.moe.norm_topk_prob,
3156            ),
3157        }
3158    }
3159
3160    fn combine_ffn_outputs_for_position(
3161        layer: &LayerWeights,
3162        normed2: &[f32],
3163        router_logits: &[f32],
3164        config: &ModelConfig,
3165        hidden_dim: usize,
3166        plan: Option<&PlacementPlan>,
3167    ) -> Vec<f32> {
3168        let decision = Self::route_for_layer(layer, router_logits, config);
3169        layer.moe.record_activations(&decision.expert_ids);
3170        // Best-effort warm of the routed experts for this layer into
3171        // the store cache (SSD streaming overlap). Resident-backed
3172        // layers skip this entirely.
3173        if let ExpertBacking::Stored {
3174            store,
3175            layer: layer_id,
3176            ..
3177        } = &layer.moe.experts
3178        {
3179            let keys: Vec<ferrox_core::expert_store::ExpertKey> = decision
3180                .expert_ids
3181                .iter()
3182                .map(|&eid| ferrox_core::expert_store::ExpertKey {
3183                    layer: *layer_id,
3184                    expert: eid as u32,
3185                })
3186                .collect();
3187            store.prefetch(&keys);
3188        }
3189
3190        // Metal: fuse all top-k experts into one CB (one wait) when every
3191        // routed expert has Metal matvec launches. Shared experts (rare
3192        // for OLMoE) still run on the host after.
3193        #[cfg(feature = "metal")]
3194        if ferrox_core::metal_dense_enabled()
3195            && matches!(
3196                config.ffn_activation,
3197                crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
3198            )
3199            && layer.moe.shared_experts.is_empty()
3200        {
3201            if let Some(fused) = Self::try_metal_moe_topk(layer, normed2, &decision) {
3202                return fused;
3203            }
3204        }
3205
3206        let routed_outputs: Vec<(Vec<f32>, f32)> = {
3207            // llama.cpp mul_mat_id: one shared Q8 act + flat (slot,row)
3208            // parallel over all top-k experts (not serial expert loops each
3209            // with their own rayon fork-join).
3210            let all_cpu = plan
3211                .map(|p| {
3212                    decision
3213                        .expert_ids
3214                        .iter()
3215                        .all(|&eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu))
3216                })
3217                .unwrap_or(true);
3218            if let (true, ExpertBacking::Resident(experts)) = (all_cpu, &layer.moe.experts) {
3219                if let Some(outs) =
3220                    Self::cpu_moe_topk_parallel_slots(experts, normed2, &decision, hidden_dim)
3221                {
3222                    outs
3223                } else {
3224                    Self::cpu_moe_serial_experts(layer, normed2, &decision, plan)
3225                }
3226            } else {
3227                Self::cpu_moe_serial_experts(layer, normed2, &decision, plan)
3228            }
3229        };
3230        // Shared experts fire on every token regardless of routing, so
3231        // there's no offload decision to make for them the way there
3232        // is for routed experts -- always CPU, matching `run_expert`.
3233        let mut shared_outputs: Vec<Vec<f32>> = layer
3234            .moe
3235            .shared_experts
3236            .iter()
3237            .map(|e| run_expert(normed2, e))
3238            .collect();
3239        // Qwen2-MoE-specific: see `MoeWeights::shared_expert_gate`'s doc
3240        // comment. Scaling here (before `combine_expert_outputs`, which
3241        // is architecture-agnostic and knows nothing about this gate)
3242        // keeps the gate a decoder-level detail, not a ferrox-moe API
3243        // change.
3244        if let Some(gate) = &layer.moe.shared_expert_gate {
3245            let gate_logit: f32 = gate.iter().zip(normed2.iter()).map(|(g, x)| g * x).sum();
3246            let gate_value = 1.0 / (1.0 + (-gate_logit).exp());
3247            for out in shared_outputs.iter_mut() {
3248                for x in out.iter_mut() {
3249                    *x *= gate_value;
3250                }
3251            }
3252        }
3253
3254        combine_expert_outputs(&routed_outputs, &shared_outputs, hidden_dim)
3255    }
3256
3257    /// The dense FFN for a whole batch of positions in three batched
3258    /// matmuls (gate, up, down) instead of three per position.
3259    ///
3260    /// This is the counterpart of what `forward_hidden_batch` already
3261    /// did for Q/K/V and the router, and it is where a dense model's
3262    /// prefill time actually goes: `WeightMatrix::apply_batch` reads
3263    /// each weight row once and dots it against every position, rather
3264    /// than re-reading the whole FFN for each one.
3265    ///
3266    /// `None` for anything that is not a plain dense layer -- MoE
3267    /// routing is per position by construction, so those keep the
3268    /// sequential path.
3269    ///
3270    /// On a GPU backend the per-position alternative is one *fused*
3271    /// gate+up+SiLU+down launch (`apply_gpu_dense_ffn_swiglu`), so this
3272    /// used to be gated off there: three separate batched launches lost
3273    /// to it while `apply_batch` was still a batched *matvec*.
3274    ///
3275    /// That stopped being true once the simdgroup GEMM landed, and the
3276    /// old gate turned out to be the dominant cost of Metal prefill --
3277    /// a 512-token prompt ran the FFN one position at a time, 512 x
3278    /// n_layers fused launches, which a profile put at 90% of prefill
3279    /// while the GEMM it bypassed accounted for 21%.
3280    ///
3281    /// Decode (`batch_size == 1`) still takes the fused per-position
3282    /// launch, which is the right shape there.
3283    fn dense_ffn_batch(
3284        layer: &LayerWeights,
3285        normed2_batch: &[f32],
3286        batch_size: usize,
3287        config: &ModelConfig,
3288    ) -> Option<Vec<f32>> {
3289        // Match the GPU `mul_mm` threshold: below it the per-call launch
3290        // overhead outweighs the weight reuse.
3291        if !Self::is_dense_layer(layer) || batch_size < 4 {
3292            return None;
3293        }
3294        // On a GPU backend this only wins when the weights have a real
3295        // batched GEMM; otherwise `apply_batch` is a batched matvec and
3296        // loses to the fused per-position launch.
3297        #[cfg(any(feature = "metal", feature = "cuda"))]
3298        {
3299            #[cfg(feature = "metal")]
3300            let gpu_dense = ferrox_core::weight_matrix::metal_dense_enabled();
3301            #[cfg(not(feature = "metal"))]
3302            let gpu_dense = false;
3303            #[cfg(feature = "cuda")]
3304            let gpu_dense = gpu_dense || ferrox_core::weight_matrix::cuda_dense_enabled();
3305            if gpu_dense {
3306                let all_gemm = layer.moe.with_expert(0, |ex| {
3307                    ex.gate.prefers_gpu_batch()
3308                        && ex.up.prefers_gpu_batch()
3309                        && ex.down.prefers_gpu_batch()
3310                });
3311                if !all_gemm {
3312                    return None;
3313                }
3314            }
3315        }
3316        layer.moe.record_activations(&[0]);
3317        // One command buffer for the whole FFN when every matrix has a
3318        // simdgroup GEMM: gate and up feed the activation and the down
3319        // projection without the intermediates ever touching the host.
3320        // Three separate launches cost three round trips per layer plus
3321        // four copies of a `batch x ffn_dim` tensor.
3322        #[cfg(feature = "metal")]
3323        if ferrox_core::weight_matrix::metal_dense_enabled() {
3324            let gelu = matches!(config.ffn_activation, crate::config::FfnActivation::Gelu);
3325            let fused = layer.moe.with_expert(0, |ex| {
3326                let (g, u, d) = (
3327                    ex.gate.mul_mm_sg_launch()?,
3328                    ex.up.mul_mm_sg_launch()?,
3329                    ex.down.mul_mm_sg_launch()?,
3330                );
3331                ferrox_metal::gpu::launch_dense_ffn_swiglu_batch(
3332                    &g,
3333                    &u,
3334                    &d,
3335                    normed2_batch,
3336                    batch_size,
3337                    gelu,
3338                )
3339                .ok()
3340            });
3341            if let Some(out) = fused {
3342                return Some(out);
3343            }
3344        }
3345        Some(layer.moe.with_expert(0, |ex| {
3346            let ffn_acts = ex.gate.quantize_batch_acts(normed2_batch, batch_size);
3347            let gate = ex
3348                .gate
3349                .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
3350            let up = ex
3351                .up
3352                .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
3353            let activated: Vec<f32> = match config.ffn_activation {
3354                crate::config::FfnActivation::Swiglu
3355                | crate::config::FfnActivation::SwigluFused => {
3356                    ferrox_core::matmul::swiglu(&gate, &up)
3357                }
3358                crate::config::FfnActivation::Gelu => geglu(&gate, &up),
3359            };
3360            ex.down.apply_batch(&activated, batch_size)
3361        }))
3362    }
3363
3364    /// CPU MoE prefill: bucket tokens by expert, then one
3365    /// `apply_batch` per expert with tokens instead of per-token
3366    /// `combine_ffn_outputs_for_position`. Shared experts append via
3367    /// [`Self::accumulate_shared_experts_batch`]. `None` when gates fail
3368    /// (small batch, dense, Metal preferred, non-SwiGLU, non-resident,
3369    /// or any GPU-placed expert).
3370    fn moe_ffn_batch(
3371        layer: &LayerWeights,
3372        normed2_batch: &[f32],
3373        router_logits_batch: &[f32],
3374        batch_size: usize,
3375        hidden_dim: usize,
3376        config: &ModelConfig,
3377        plan: Option<&PlacementPlan>,
3378    ) -> Option<Vec<f32>> {
3379        if batch_size < 32 || Self::is_dense_layer(layer) {
3380            return None;
3381        }
3382        // Metal prefill owns MoE when dense Metal is on
3383        // (`try_metal_moe_prefill_batch`); do not steal the path.
3384        #[cfg(feature = "metal")]
3385        if ferrox_core::metal_dense_enabled() {
3386            return None;
3387        }
3388        if !matches!(
3389            config.ffn_activation,
3390            crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
3391        ) {
3392            return None;
3393        }
3394        let ExpertBacking::Resident(experts) = &layer.moe.experts else {
3395            return None;
3396        };
3397        let n_experts = experts.len();
3398        let all_cpu = plan
3399            .map(|p| (0..n_experts).all(|eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu)))
3400            .unwrap_or(true);
3401        if !all_cpu || n_experts == 0 {
3402            return None;
3403        }
3404
3405        let mut buckets: Vec<Vec<(usize, f32)>> = vec![Vec::new(); n_experts];
3406        for b in 0..batch_size {
3407            let logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
3408            let decision = Self::route_for_layer(layer, logits, config);
3409            layer.moe.record_activations(&decision.expert_ids);
3410            for (&eid, &w) in decision.expert_ids.iter().zip(decision.weights.iter()) {
3411                buckets[eid].push((b, w));
3412            }
3413        }
3414
3415        let mut acc = vec![0f32; batch_size * hidden_dim];
3416        for (eid, toks) in buckets.iter().enumerate() {
3417            if toks.is_empty() {
3418                continue;
3419            }
3420            let n = toks.len();
3421            let mut gathered = vec![0f32; n * hidden_dim];
3422            for (i, &(tok, _)) in toks.iter().enumerate() {
3423                gathered[i * hidden_dim..(i + 1) * hidden_dim]
3424                    .copy_from_slice(&normed2_batch[tok * hidden_dim..(tok + 1) * hidden_dim]);
3425            }
3426            let ex = &experts[eid];
3427            let ffn_acts = ex.gate.quantize_batch_acts(&gathered, n);
3428            let gate = ex
3429                .gate
3430                .apply_batch_with_acts(&gathered, n, ffn_acts.as_ref());
3431            let up = ex.up.apply_batch_with_acts(&gathered, n, ffn_acts.as_ref());
3432            let activated = ferrox_core::matmul::swiglu(&gate, &up);
3433            let down = ex.down.apply_batch(&activated, n);
3434            for (i, &(tok, w)) in toks.iter().enumerate() {
3435                let out = &down[i * hidden_dim..(i + 1) * hidden_dim];
3436                let row = &mut acc[tok * hidden_dim..(tok + 1) * hidden_dim];
3437                for (a, &o) in row.iter_mut().zip(out.iter()) {
3438                    *a += w * o;
3439                }
3440            }
3441        }
3442
3443        Self::accumulate_shared_experts_batch(
3444            layer,
3445            normed2_batch,
3446            batch_size,
3447            hidden_dim,
3448            &mut acc,
3449        );
3450        Some(acc)
3451    }
3452
3453    /// gpt-oss's MoE FFN for one position.
3454    ///
3455    /// A separate function rather than another branch inside
3456    /// `combine_ffn_outputs_for_position` on purpose: that path carries
3457    /// expert-store prefetch, residency placement, a Metal top-k fusion
3458    /// and a batched parallel-slot kernel, and every one of them would
3459    /// need its own gpt-oss variant to stay honest. This is the whole
3460    /// gpt-oss FFN in one readable block, checked end-to-end against
3461    /// llama.cpp, and slow — routed experts run serially. It is the
3462    /// correct-first shape; making it fast is a separate change with its
3463    /// own A/B, not something to smuggle in under a correctness fix.
3464    ///
3465    /// Ported from `llama-graph.cpp::build_moe_ffn` with
3466    /// `gating_op = LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX_WEIGHT`,
3467    /// `type_op = LLM_FFN_SWIGLU_OAI_MOE`, `norm_w = false`,
3468    /// `w_scale = 1`, all four bias tensors present.
3469    fn gpt_oss_ffn(
3470        layer: &LayerWeights,
3471        oai: &GptOssLayer,
3472        normed2: &[f32],
3473        config: &ModelConfig,
3474        hidden_dim: usize,
3475    ) -> Vec<f32> {
3476        let mut router_logits = layer.moe.router.apply(normed2);
3477        for (x, b) in router_logits.iter_mut().zip(oai.router_bias.iter()) {
3478            *x += b;
3479        }
3480        // Selection on the raw biased logits, softmax over the winners
3481        // only -- see `route_top_k_softmax_weight`.
3482        let decision =
3483            ferrox_moe::route_top_k_softmax_weight(&router_logits, config.moe.n_experts_active);
3484        layer.moe.record_activations(&decision.expert_ids);
3485
3486        let mut out = vec![0f32; hidden_dim];
3487        for (slot, &eid) in decision.expert_ids.iter().enumerate() {
3488            let w = decision.weights[slot];
3489            let expert_out = layer.moe.with_expert(eid, |ex| {
3490                ferrox_moe::run_expert_oai(
3491                    normed2,
3492                    ex,
3493                    &oai.expert_bias[eid],
3494                    ferrox_moe::SWIGLU_OAI_ALPHA,
3495                    ferrox_moe::SWIGLU_OAI_LIMIT,
3496                )
3497            });
3498            for (o, e) in out.iter_mut().zip(expert_out.iter()) {
3499                *o += w * e;
3500            }
3501        }
3502        out
3503    }
3504
3505    /// `forward_token`'s MoE FFN block for one position: the dense
3506    /// fast path (see `is_dense_layer`) or the full router+combine path
3507    /// with the router computed inline via a single-position `apply`.
3508    fn run_ffn_block(
3509        layer: &LayerWeights,
3510        normed2: &[f32],
3511        config: &ModelConfig,
3512        hidden_dim: usize,
3513        plan: Option<&PlacementPlan>,
3514    ) -> Vec<f32> {
3515        if Self::is_dense_layer(layer) {
3516            layer.moe.record_activations(&[0]);
3517            return layer.moe.with_expert(0, |ex| match config.ffn_activation {
3518                crate::config::FfnActivation::Swiglu
3519                | crate::config::FfnActivation::SwigluFused => run_expert(normed2, ex),
3520                crate::config::FfnActivation::Gelu => {
3521                    // Share one Q8 act quant across gate+up when INT_DOT
3522                    // can serve both (Q8_0 / Q4_0); else two `.apply`s.
3523                    if ferrox_core::weight_matrix::cpu_int_dot_enabled()
3524                        && normed2.len().is_multiple_of(32)
3525                    {
3526                        let act = ferrox_quant::quantize_activations_q8(normed2);
3527                        if let (Some(gate), Some(up)) =
3528                            (ex.gate.apply_cpu_q8(&act), ex.up.apply_cpu_q8(&act))
3529                        {
3530                            let activated = geglu(&gate, &up);
3531                            return ex.down.apply(&activated);
3532                        }
3533                    }
3534                    let gate = ex.gate.apply(normed2);
3535                    let up = ex.up.apply(normed2);
3536                    let activated = geglu(&gate, &up);
3537                    ex.down.apply(&activated)
3538                }
3539            });
3540        }
3541        let router_logits = layer.moe.router.apply(normed2);
3542        Self::combine_ffn_outputs_for_position(
3543            layer,
3544            normed2,
3545            &router_logits,
3546            config,
3547            hidden_dim,
3548            plan,
3549        )
3550    }
3551
3552    /// Processes multiple new positions in one call instead of calling
3553    /// `forward_token` once per position. `tokens[i]` is the token at
3554    /// absolute position `start_pos + i`; all positions attend
3555    /// causally (position `i` sees positions `0..=i` of this batch
3556    /// plus everything already in `kv_caches`, nothing later).
3557    ///
3558    /// The attention block's Q/K/V/O projections and the MoE router
3559    /// are computed as batched matmuls (`WeightMatrix::apply_batch`),
3560    /// which for quantized weights means each weight row is read from
3561    /// memory once and dotted against every position in the batch,
3562    /// not once per position -- see `apply_batch`'s doc comment for
3563    /// why that's a real memory-bandwidth saving, not just fewer
3564    /// function calls. The expert FFN stage is *not* batched: which
3565    /// expert(s) a position routes to is data-dependent per position,
3566    /// so positions routed to different experts can't share a single
3567    /// matmul the way the shared Q/K/V/router projections can. RoPE
3568    /// and attention itself (causal masking, softmax) are also
3569    /// per-position, since they're cheap relative to the matmuls and
3570    /// batching them would add complexity for little benefit.
3571    ///
3572    /// This is what makes prompt-lookup speculative decoding
3573    /// (`speculative` module) actually save work rather than just
3574    /// reshuffle it: verifying `k` draft tokens costs one batched call
3575    /// here, not `k` calls to `forward_token`.
3576    ///
3577    /// Thin wrapper over [`Self::forward_hidden_batch`] + `output_head`.
3578    pub fn forward_batch(
3579        &self,
3580        tokens: &[usize],
3581        start_pos: usize,
3582        kv_caches: &mut [KvCache],
3583    ) -> Vec<Vec<f32>> {
3584        let hiddens = self.forward_hidden_batch(tokens, start_pos, kv_caches);
3585        if hiddens.is_empty() {
3586            return Vec::new();
3587        }
3588        let batch_size = hiddens.len();
3589        let flat: Vec<f32> = hiddens.into_iter().flatten().collect();
3590        self.logits_from_flat_hidden(flat, batch_size)
3591    }
3592
3593    /// [`Self::forward_batch`] that also hands back the final-layer
3594    /// hidden state for every position instead of dropping it.
3595    ///
3596    /// `forward_batch` computes these and throws them away; a
3597    /// hidden-state-conditioned drafter (EAGLE, MTP, dFlash) needs
3598    /// exactly the vector for the last *verified* position, so
3599    /// recomputing it would mean running the target model twice for
3600    /// something the first pass already had in hand. The extra cost
3601    /// here is one copy of `[batch x hidden]`, which is why
3602    /// `forward_batch` keeps its move-only path for the prefill case
3603    /// that does not want them.
3604    ///
3605    /// Returns `(logits_per_position, hidden_per_position)`, both
3606    /// indexed by position in `tokens`.
3607    pub fn forward_batch_with_hidden(
3608        &self,
3609        tokens: &[usize],
3610        start_pos: usize,
3611        kv_caches: &mut [KvCache],
3612    ) -> (Vec<Vec<f32>>, Vec<Vec<f32>>) {
3613        let hiddens = self.forward_hidden_batch(tokens, start_pos, kv_caches);
3614        if hiddens.is_empty() {
3615            return (Vec::new(), Vec::new());
3616        }
3617        let batch_size = hiddens.len();
3618        let flat: Vec<f32> = hiddens.iter().flatten().copied().collect();
3619        (self.logits_from_flat_hidden(flat, batch_size), hiddens)
3620    }
3621
3622    /// One token's embedding row, scaled if this checkpoint scales it.
3623    ///
3624    /// `embedding_scale` is `sqrt(hidden_dim)` on the Gemma family and
3625    /// `None` everywhere else, so a path that dequantizes the row and
3626    /// forgets the multiply is wrong on exactly one family and right on
3627    /// every other -- which is why it survived as a drift for as long as
3628    /// it did. The lookup and the scale live in one function so a caller
3629    /// cannot obtain the row without it.
3630    fn embed_token(&self, token_id: usize) -> Vec<f32> {
3631        let mut row = self.embedding.dequant_row(token_id);
3632        if let Some(scale) = self.config.embedding_scale {
3633            for v in row.iter_mut() {
3634                *v *= scale;
3635            }
3636        }
3637        row
3638    }
3639
3640    /// [`Self::embed_token`] for a whole batch: `[batch, hidden]`,
3641    /// flattened row-major.
3642    fn embed_tokens(&self, tokens: &[usize]) -> Vec<f32> {
3643        tokens.iter().flat_map(|&t| self.embed_token(t)).collect()
3644    }
3645
3646    /// The `output_head` half of a single-position forward: project the
3647    /// final-normed hidden state and softcap the result if this
3648    /// checkpoint softcaps it.
3649    ///
3650    /// The counterpart to [`Self::logits_from_flat_hidden`] for the
3651    /// one-row case, and held here for the same reason: Gemma-2 caps its
3652    /// final logits at 30.0, so a path that projects and returns without
3653    /// capping produces a different distribution -- not an error, just a
3654    /// quietly wrong one.
3655    fn logits_from_normed(&self, final_normed: &[f32]) -> Vec<f32> {
3656        let mut logits = self.output_head.apply(final_normed);
3657        if let Some(sc) = self.config.final_logit_softcap {
3658            softcap_inplace(&mut logits, sc);
3659        }
3660        logits
3661    }
3662
3663    /// The `output_head` half of [`Self::forward_batch`], split out so
3664    /// the hidden-state-returning variant cannot drift from it (a
3665    /// second copy of the softcap would be a silent quality bug).
3666    fn logits_from_flat_hidden(&self, flat: Vec<f32>, batch_size: usize) -> Vec<Vec<f32>> {
3667        let vocab_size = self.output_head.rows();
3668        let mut logits_batch = self.output_head.apply_batch(&flat, batch_size);
3669        if let Some(sc) = self.config.final_logit_softcap {
3670            softcap_inplace(&mut logits_batch, sc);
3671        }
3672        logits_batch
3673            .chunks(vocab_size)
3674            .map(|c| c.to_vec())
3675            .collect()
3676    }
3677
3678    /// [`Self::forward_batch`] for the common case where only the final
3679    /// position's logits are wanted: prefill a prompt, then sample the
3680    /// next token. Runs `output_head` on **one** row instead of all
3681    /// `batch_size` of them.
3682    ///
3683    /// The KV cache and every hidden state are identical either way —
3684    /// only the vocabulary projection is skipped, and only for rows
3685    /// whose logits the caller was going to drop. That projection is not
3686    /// a rounding error: it is `[batch x hidden] x [hidden x vocab]`,
3687    /// which for a large-vocabulary model with a small body is a large
3688    /// share of prefill. `V*H / (V*H + L*P_layer)` comes to 30% on
3689    /// Gemma-3-1B, 21% on Llama-3.2-1B and SmolLM2, 23% on Gemma-2-2B.
3690    /// llama.cpp does not do this work at all during `pp512` —
3691    /// `llama_batch_get_one` leaves `logits` unset, so `inp_out_ids`
3692    /// selects a single row.
3693    ///
3694    /// [`Self::forward_batch`] stays for the callers that genuinely need
3695    /// every row: speculative verification checks each draft position,
3696    /// and `/v1/embeddings` pools over all of them.
3697    pub fn forward_batch_last(
3698        &self,
3699        tokens: &[usize],
3700        start_pos: usize,
3701        kv_caches: &mut [KvCache],
3702    ) -> Vec<f32> {
3703        self.forward_batch_last_inner(tokens, start_pos, kv_caches, false)
3704    }
3705
3706    /// [`Self::forward_batch_last`] for a caller that will READ the
3707    /// caches afterwards rather than only decode from them.
3708    ///
3709    /// A Metal prefill otherwise leaves K/V on the device and the host
3710    /// rows zero-filled, which is invisible to a caller that keeps
3711    /// decoding (the device buffers stay authoritative) and fatal to
3712    /// one that copies the rows somewhere else. Two callers do copy
3713    /// them: `forward_batch_last_paged`, into the page store, and
3714    /// `ferrox-server`'s prefix cache, into a snapshot a later request
3715    /// restores from. Both used to get zeros, and both answered fluent
3716    /// nonsense from a prompt the model never attended over.
3717    ///
3718    /// Costs one KV download per layer. Use [`Self::forward_batch_last`]
3719    /// when nothing will read the caches back.
3720    pub fn forward_batch_last_host_kv(
3721        &self,
3722        tokens: &[usize],
3723        start_pos: usize,
3724        kv_caches: &mut [KvCache],
3725    ) -> Vec<f32> {
3726        self.forward_batch_last_inner(tokens, start_pos, kv_caches, true)
3727    }
3728
3729    /// [`Self::forward_batch_last`], plus the choice of whether the host
3730    /// caches have to hold the real K/V when it returns. See
3731    /// [`Self::advance_host_kv_after_metal_prefill`] for why that is a
3732    /// choice at all.
3733    fn forward_batch_last_inner(
3734        &self,
3735        tokens: &[usize],
3736        start_pos: usize,
3737        kv_caches: &mut [KvCache],
3738        host_kv_authoritative: bool,
3739    ) -> Vec<f32> {
3740        let hiddens =
3741            self.forward_hidden_batch_inner(tokens, start_pos, kv_caches, host_kv_authoritative);
3742        let Some(last) = hiddens.last() else {
3743            return Vec::new();
3744        };
3745        self.logits_from_normed(last)
3746    }
3747
3748    /// [`Self::forward_batch_last`] over paged KV: the prefill twin of
3749    /// [`Self::forward_token_paged`].
3750    ///
3751    /// # Why this gathers instead of paging the kernel
3752    ///
3753    /// `forward_hidden_batch`'s fast arm hands `cache.k` / `cache.v` to
3754    /// `causal_gqa_attention_prefill_shared_kv_windowed`, which is Rayon
3755    /// over `[query-block x head]` against one flat KV buffer. That
3756    /// blocking is why CPU prefill is not the per-query path, and a
3757    /// block table cannot be handed to it as a slice.
3758    ///
3759    /// The alternative was a second blocked kernel that reads through
3760    /// the table. This file has just finished paying for what a second
3761    /// copy of a rule costs: the paged decode path silently lost the
3762    /// window arm, the sink term, the attention softcap, the embedding
3763    /// scale and the final logit softcap, one at a time, because it was
3764    /// a copy. A prefill kernel is a much larger surface to keep in
3765    /// step than any of those. So the pages are materialised, the ONE
3766    /// prefill implementation every other path uses runs against them,
3767    /// and the new rows go back.
3768    ///
3769    /// Bit-identity is therefore by construction rather than by
3770    /// agreement between two kernels: this calls the same function with
3771    /// the same values. What the tests pin is that the gather and the
3772    /// scatter are faithful, not that two implementations of attention
3773    /// happen to match.
3774    ///
3775    /// The cost is one KV-sized copy per layer per call, against the
3776    /// matmuls that dominate prefill. Decode is untouched: it still
3777    /// reads through the block table and copies nothing, which is where
3778    /// page sharing pays.
3779    ///
3780    /// # Failure is checked before anything is written
3781    ///
3782    /// Every layer's blocks are reserved up front, so a store too small
3783    /// for the batch refuses with `PagedStoreExhausted` having mutated
3784    /// no layer. A partial append would leave some layers longer than
3785    /// others, and no caller can recover from that.
3786    pub fn forward_batch_last_paged(
3787        &self,
3788        tokens: &[usize],
3789        start_pos: usize,
3790        kv_caches: &mut [PagedKvCache],
3791        stores: &SharedPagedKv,
3792    ) -> Result<Vec<f32>, PagedStoreExhausted> {
3793        assert_eq!(kv_caches.len(), self.layers.len());
3794        assert_eq!(stores.layer_count(), self.layers.len());
3795        if tokens.is_empty() {
3796            return Ok(Vec::new());
3797        }
3798
3799        // Reserve every layer up front, under guards spanning the check
3800        // AND the take. Each layer has its own store, so one having
3801        // room says nothing about the next -- and under concurrency,
3802        // checking and then taking as separate steps lets another
3803        // request slip in between and leave this one half-written.
3804        //
3805        // Reserving before the forward rather than after also means a
3806        // request that cannot fit is refused before it burns a prefill.
3807        {
3808            let mut guards = stores.write_all();
3809            for (cache, store) in kv_caches.iter().zip(guards.iter()) {
3810                if cache.blocks_needed_for(store, tokens.len()) > store.free_block_count() {
3811                    return Err(PagedStoreExhausted);
3812                }
3813            }
3814            for (cache, store) in kv_caches.iter_mut().zip(guards.iter_mut()) {
3815                cache
3816                    .reserve(store, tokens.len())
3817                    .expect("checked against free_block_count under this same guard");
3818            }
3819        }
3820
3821        // Gather under read guards, one layer at a time: the forward
3822        // below is the expensive part and holds nothing.
3823        let mut scratch: Vec<KvCache> = kv_caches
3824            .iter()
3825            .enumerate()
3826            .map(|(l, cache)| cache.to_contiguous(&stores.read(l)))
3827            .collect();
3828
3829        // `host_kv_authoritative`: the scatter below READS these caches,
3830        // and a Metal prefill otherwise leaves them holding
3831        // `advance_len` placeholders while the real K/V sits on the
3832        // device. Copying those placeholders into the page store is
3833        // what made paged KV on Metal answer fluent nonsense from a
3834        // prompt the model never attended over.
3835        let logits = self.forward_batch_last_inner(tokens, start_pos, &mut scratch, true);
3836
3837        // Scatter into blocks this sequence already owns. Nothing here
3838        // can fail, which is the point of reserving above.
3839        for (l, (cache, gathered)) in kv_caches.iter_mut().zip(&scratch).enumerate() {
3840            let mut store = stores.write(l);
3841            let width = store.n_kv_heads() * store.head_dim();
3842            let base = cache.seq_len() * width;
3843            cache
3844                .append_contiguous(
3845                    &mut store,
3846                    &gathered.k[base..],
3847                    &gathered.v[base..],
3848                    tokens.len(),
3849                )
3850                .expect("blocks reserved above are still held by this sequence");
3851        }
3852        Ok(logits)
3853    }
3854
3855    /// Like [`Self::forward_batch`], but returns final RMS-normed hidden
3856    /// states (pre-`output_head`) — one `hidden_dim` vector per input
3857    /// token. Used by `/v1/embeddings` pooling (mean / last).
3858    pub fn forward_hidden_batch(
3859        &self,
3860        tokens: &[usize],
3861        start_pos: usize,
3862        kv_caches: &mut [KvCache],
3863    ) -> Vec<Vec<f32>> {
3864        self.forward_hidden_batch_inner(tokens, start_pos, kv_caches, false)
3865    }
3866
3867    /// [`Self::forward_hidden_batch`] with one extra promise the public
3868    /// signature cannot express.
3869    ///
3870    /// `host_kv_authoritative` says whether the caller will READ
3871    /// `kv_caches` afterwards. Metal prefill normally leaves K/V on the
3872    /// device and fills the host rows with a `advance_len` placeholder,
3873    /// which is correct only because the contiguous decode path then
3874    /// reads the device buffers too. `forward_batch_last_paged` reads
3875    /// the host rows -- it copies them into the page store -- so it
3876    /// passes `true` and pays for the download.
3877    fn forward_hidden_batch_inner(
3878        &self,
3879        tokens: &[usize],
3880        start_pos: usize,
3881        kv_caches: &mut [KvCache],
3882        host_kv_authoritative: bool,
3883    ) -> Vec<Vec<f32>> {
3884        // Read only by the Metal arms below; a CPU-only build fills the
3885        // host cache with real rows on every path and has nothing to
3886        // choose between.
3887        let _ = host_kv_authoritative;
3888        assert_eq!(kv_caches.len(), self.layers.len());
3889        let batch_size = tokens.len();
3890        if batch_size == 0 {
3891            return Vec::new();
3892        }
3893
3894        let hidden_dim = self.config.hidden_dim;
3895        let head_dim = self.config.head_dim;
3896        let n_heads = self.config.n_heads;
3897        let n_kv_heads = self.config.n_kv_heads;
3898
3899        // [batch, hidden], flattened row-major.
3900        let mut hidden_batch: Vec<f32> = self.embed_tokens(tokens);
3901
3902        #[cfg(feature = "metal")]
3903        let use_metal_attn = ferrox_core::metal_dense_enabled()
3904            && ferrox_metal::attn::metal_attn_enabled()
3905            && self
3906                .layers
3907                .iter()
3908                .all(|l| self.layer_supports_metal_attn(l));
3909
3910        #[cfg(not(feature = "metal"))]
3911        let use_metal_attn = false;
3912
3913        let residency = self.expert_residency_plan(use_metal_attn);
3914
3915        #[cfg(feature = "metal")]
3916        let mut metal_kv_guard: Option<
3917            std::sync::MutexGuard<'_, Option<Vec<ferrox_metal::attn::MetalKvBuffers>>>,
3918        > = if use_metal_attn {
3919            Some(self.metal_attn_kv.lock().unwrap())
3920        } else {
3921            None
3922        };
3923
3924        #[cfg(feature = "metal")]
3925        if let Some(guard) = metal_kv_guard.as_mut() {
3926            let need = self.layers.len();
3927            let need_cap = start_pos
3928                .saturating_add(batch_size)
3929                .saturating_add(256)
3930                .max(512);
3931            let reset = match guard.as_ref() {
3932                None => true,
3933                Some(v) => {
3934                    v.len() != need
3935                        || v.iter().any(|m| m.capacity() < need_cap)
3936                        || v.iter()
3937                            .zip(kv_caches.iter())
3938                            .any(|(m, c)| m.seq_len != c.seq_len)
3939                }
3940            };
3941            if reset {
3942                let mut bufs = Vec::with_capacity(need);
3943                for _ in 0..need {
3944                    match ferrox_metal::attn::MetalKvBuffers::with_capacity(
3945                        n_kv_heads, head_dim, need_cap,
3946                    ) {
3947                        Ok(b) => bufs.push(b),
3948                        Err(_) => {
3949                            **guard = None;
3950                            break;
3951                        }
3952                    }
3953                }
3954                if bufs.len() == need {
3955                    let mut ok = true;
3956                    for (m, c) in bufs.iter_mut().zip(kv_caches.iter()) {
3957                        if c.seq_len > 0 && m.upload_from_host(&c.k, &c.v, c.seq_len).is_err() {
3958                            ok = false;
3959                            break;
3960                        }
3961                    }
3962                    if ok {
3963                        **guard = Some(bufs);
3964                    } else {
3965                        **guard = None;
3966                    }
3967                } else {
3968                    **guard = None;
3969                }
3970            }
3971        }
3972
3973        let n_layers = self.layers.len();
3974        let mut l = 0usize;
3975        while l < n_layers {
3976            let layer = &self.layers[l];
3977            let q_width = n_heads * head_dim;
3978            let kv_width = n_kv_heads * head_dim;
3979
3980            // Multi-layer dense prefill: one CB, activations stay on GPU.
3981            #[cfg(feature = "metal")]
3982            if use_metal_attn && batch_size >= 4 {
3983                if let Some(guard) = metal_kv_guard.as_mut() {
3984                    if let Some(metal_kvs) = guard.as_mut() {
3985                        if let Some(run_len) = self.metal_prefill_dense_stack_run_len(
3986                            l,
3987                            start_pos,
3988                            batch_size,
3989                            kv_caches,
3990                            Some(metal_kvs.as_slice()),
3991                        ) {
3992                            if let Some(h_out) = self.try_metal_prefill_dense_stack(
3993                                l,
3994                                run_len,
3995                                &hidden_batch,
3996                                start_pos,
3997                                batch_size,
3998                                n_heads,
3999                                metal_kvs,
4000                                kv_caches,
4001                                host_kv_authoritative,
4002                            ) {
4003                                hidden_batch = h_out;
4004                                l += run_len;
4005                                continue;
4006                            }
4007                        }
4008                    }
4009                }
4010            }
4011
4012            let cache = &mut kv_caches[l];
4013
4014            // One-CB dense prefill (RMSNorm→QKV GEMM→attn→O→FFN) when every
4015            // projection has mul_mm_sg and the layer has no QKV bias / QK-norm.
4016            #[cfg(feature = "metal")]
4017            if use_metal_attn && batch_size >= 4 && Self::metal_prefill_dense_layer_eligible(layer)
4018            {
4019                let swa_fits = self.metal_prefill_dense_swa_fits(l, start_pos, batch_size);
4020                if swa_fits {
4021                    if let Some(guard) = metal_kv_guard.as_mut() {
4022                        if let Some(metal_kvs) = guard.as_mut() {
4023                            if metal_kvs[l].seq_len == cache.seq_len && start_pos == cache.seq_len {
4024                                layer.moe.record_activations(&[0]);
4025                                let fused = layer.moe.with_expert(0, |ex| {
4026                                    let (q, k, v, o) = (
4027                                        layer.attn.q_proj.mul_mm_sg_launch()?,
4028                                        layer.attn.k_proj.mul_mm_sg_launch()?,
4029                                        layer.attn.v_proj.mul_mm_sg_launch()?,
4030                                        layer.attn.o_proj.mul_mm_sg_launch()?,
4031                                    );
4032                                    let ffn = ferrox_metal::attn::PrefillFfnMetal::Dense {
4033                                        gate: ex.gate.mul_mm_sg_launch()?,
4034                                        up: ex.up.mul_mm_sg_launch()?,
4035                                        down: ex.down.mul_mm_sg_launch()?,
4036                                    };
4037                                    let gelu = matches!(
4038                                        self.config.ffn_activation,
4039                                        crate::config::FfnActivation::Gelu
4040                                    );
4041                                    let prefill_layer =
4042                                        ferrox_metal::attn::PrefillDenseLayerMetal {
4043                                            attn_norm_w: &layer.attn.norm_weight,
4044                                            ffn_norm_w: &layer.moe.norm_weight,
4045                                            q,
4046                                            k,
4047                                            v,
4048                                            o,
4049                                            ffn,
4050                                            post_attn_norm: layer.attn.post_attn_norm.as_deref(),
4051                                            post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
4052                                            extras: self.metal_attn_extras(layer),
4053                                            layer_idx: l as u32,
4054                                        };
4055                                    ferrox_metal::attn::launch_prefill_dense_layer(
4056                                        &hidden_batch,
4057                                        &prefill_layer,
4058                                        &mut metal_kvs[l],
4059                                        n_heads,
4060                                        batch_size,
4061                                        self.metal_rope(),
4062                                        self.config.layer_rope_theta(l),
4063                                        self.config.rope_freqs.as_deref(),
4064                                        start_pos,
4065                                        self.config.rms_norm_eps,
4066                                        gelu,
4067                                        self.config.attn_logit_softcap,
4068                                    )
4069                                    .ok()
4070                                });
4071                                if let Some(h_out) = fused {
4072                                    Self::advance_host_kv_after_metal_prefill(
4073                                        &metal_kvs[l],
4074                                        cache,
4075                                        batch_size,
4076                                        host_kv_authoritative,
4077                                    );
4078                                    hidden_batch = h_out;
4079                                    l += 1;
4080                                    continue;
4081                                }
4082                            }
4083                        }
4084                    }
4085                }
4086            }
4087
4088            // --- attention block ---
4089            let normed_batch: Vec<f32> = hidden_batch
4090                .par_chunks(hidden_dim)
4091                .map(|h| rms_norm(h, &layer.attn.norm_weight, self.config.rms_norm_eps))
4092                .flatten()
4093                .collect();
4094
4095            // One shared activation-quant pass for q/k/v (plan 1e): the
4096            // three projections read the same normed batch, so quantize it
4097            // once instead of once per projection. A kind mismatch inside
4098            // the group just re-quantizes locally.
4099            let qkv_acts = layer
4100                .attn
4101                .q_proj
4102                .quantize_batch_acts(&normed_batch, batch_size);
4103            let mut q_batch = layer.attn.q_proj.apply_batch_with_acts(
4104                &normed_batch,
4105                batch_size,
4106                qkv_acts.as_ref(),
4107            );
4108            let mut k_batch = layer.attn.k_proj.apply_batch_with_acts(
4109                &normed_batch,
4110                batch_size,
4111                qkv_acts.as_ref(),
4112            );
4113            let mut v_batch = layer.attn.v_proj.apply_batch_with_acts(
4114                &normed_batch,
4115                batch_size,
4116                qkv_acts.as_ref(),
4117            );
4118            drop(qkv_acts);
4119
4120            if let Some(bias) = &layer.attn.q_bias {
4121                for row in q_batch.chunks_mut(q_width) {
4122                    for (x, b) in row.iter_mut().zip(bias.iter()) {
4123                        *x += b;
4124                    }
4125                }
4126            }
4127            if let Some(bias) = &layer.attn.k_bias {
4128                for row in k_batch.chunks_mut(kv_width) {
4129                    for (x, b) in row.iter_mut().zip(bias.iter()) {
4130                        *x += b;
4131                    }
4132                }
4133            }
4134            if let Some(bias) = &layer.attn.v_bias {
4135                for row in v_batch.chunks_mut(kv_width) {
4136                    for (x, b) in row.iter_mut().zip(bias.iter()) {
4137                        *x += b;
4138                    }
4139                }
4140            }
4141
4142            if let Some(q_norm) = &layer.attn.q_norm {
4143                for row in q_batch.chunks_mut(q_width) {
4144                    let normed = self.apply_qk_norm(row, q_norm);
4145                    row.copy_from_slice(&normed);
4146                }
4147            }
4148            if let Some(k_norm) = &layer.attn.k_norm {
4149                for row in k_batch.chunks_mut(kv_width) {
4150                    let normed = self.apply_qk_norm(row, k_norm);
4151                    row.copy_from_slice(&normed);
4152                }
4153            }
4154            // Host-side `mscale`, applied before either backend ropes.
4155            // The Metal branch below therefore hands its kernels
4156            // `attn_factor_applied_by_caller()` — folding it into cos/sin
4157            // there as well would square it.
4158            self.apply_rope_attn_factor(&mut q_batch, &mut k_batch);
4159
4160            #[cfg(feature = "metal")]
4161            {
4162                let mut did_metal_prefill = false;
4163                // The Metal prefill kernel is full-causal: only safe on a
4164                // SWA layer while every causal position is still inside
4165                // the window. Longer prompts fall back to CPU attention.
4166                let swa_fits = match self.config.layer_sliding_window(l) {
4167                    Some(window) => start_pos + batch_size <= window,
4168                    None => true,
4169                };
4170                // Metal prefill applies attn softcap in FA-vec / legacy GQA.
4171                if let Some(guard) = metal_kv_guard.as_mut() {
4172                    if let Some(metal_kvs) = guard.as_mut() {
4173                        if metal_kvs[l].seq_len == cache.seq_len
4174                            && start_pos == cache.seq_len
4175                            && swa_fits
4176                        {
4177                            let prefill_res = {
4178                                ferrox_metal::attn::launch_prefill_attn_block(
4179                                    &q_batch,
4180                                    &k_batch,
4181                                    &v_batch,
4182                                    &mut metal_kvs[l],
4183                                    n_heads,
4184                                    batch_size,
4185                                    self.metal_rope().attn_factor_applied_by_caller(),
4186                                    self.config.layer_rope_theta(l),
4187                                    self.config.rope_freqs.as_deref(),
4188                                    start_pos,
4189                                    self.config.attn_logit_softcap,
4190                                    false,
4191                                )
4192                                .map(|(attn_out_batch, _, _)| {
4193                                    Self::advance_host_kv_after_metal_prefill(
4194                                        &metal_kvs[l],
4195                                        cache,
4196                                        batch_size,
4197                                        host_kv_authoritative,
4198                                    );
4199                                    let projected_batch =
4200                                        layer.attn.o_proj.apply_batch(&attn_out_batch, batch_size);
4201                                    let projected_batch =
4202                                        if let Some(post) = &layer.attn.post_attn_norm {
4203                                            projected_batch
4204                                                .chunks(hidden_dim)
4205                                                .flat_map(|row| {
4206                                                    rms_norm(row, post, self.config.rms_norm_eps)
4207                                                })
4208                                                .collect::<Vec<_>>()
4209                                        } else {
4210                                            projected_batch
4211                                        };
4212                                    for (h, p) in
4213                                        hidden_batch.iter_mut().zip(projected_batch.iter())
4214                                    {
4215                                        *h += p;
4216                                    }
4217                                    true
4218                                })
4219                            };
4220                            match prefill_res {
4221                                Ok(true) => {
4222                                    did_metal_prefill = true;
4223                                }
4224                                Ok(false) => {}
4225                                Err(e) => {
4226                                    eprintln!(
4227                                        "ferrox: Metal prefill attn failed, CPU fallback: {e}"
4228                                    );
4229                                    **guard = None;
4230                                }
4231                            }
4232                        }
4233                    }
4234                }
4235                if did_metal_prefill {
4236                    // --- MoE FFN block (batched Metal when packed Q4) ---
4237                    let normed2_batch: Vec<f32> = hidden_batch
4238                        .chunks(hidden_dim)
4239                        .flat_map(|h| rms_norm(h, &layer.moe.norm_weight, self.config.rms_norm_eps))
4240                        .collect();
4241                    let dense = Self::is_dense_layer(layer);
4242                    let router_logits_batch = if dense {
4243                        Vec::new()
4244                    } else {
4245                        layer.moe.router.apply_batch(&normed2_batch, batch_size)
4246                    };
4247                    let metal_ffn = if !dense {
4248                        Self::try_metal_moe_prefill_batch(
4249                            layer,
4250                            &normed2_batch,
4251                            &router_logits_batch,
4252                            batch_size,
4253                            hidden_dim,
4254                            &self.config,
4255                        )
4256                    } else {
4257                        None
4258                    };
4259                    if let Some(mut ffn_batch) = metal_ffn {
4260                        if let Some(post) = &layer.attn.post_ffn_norm {
4261                            ffn_batch = ffn_batch
4262                                .chunks(hidden_dim)
4263                                .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4264                                .collect();
4265                        }
4266                        for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4267                            *h += f;
4268                        }
4269                    } else if let Some(mut ffn_batch) =
4270                        Self::dense_ffn_batch(layer, &normed2_batch, batch_size, &self.config)
4271                    {
4272                        if let Some(post) = &layer.attn.post_ffn_norm {
4273                            ffn_batch = ffn_batch
4274                                .chunks(hidden_dim)
4275                                .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4276                                .collect();
4277                        }
4278                        for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4279                            *h += f;
4280                        }
4281                    } else if let Some(mut ffn_batch) = Self::moe_ffn_batch(
4282                        layer,
4283                        &normed2_batch,
4284                        &router_logits_batch,
4285                        batch_size,
4286                        hidden_dim,
4287                        &self.config,
4288                        residency.as_ref().map(|p| p.layer_plan(l)),
4289                    ) {
4290                        if let Some(post) = &layer.attn.post_ffn_norm {
4291                            ffn_batch = ffn_batch
4292                                .chunks(hidden_dim)
4293                                .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4294                                .collect();
4295                        }
4296                        for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4297                            *h += f;
4298                        }
4299                    } else {
4300                        let n_experts = layer.moe.n_experts().max(1);
4301                        for b in 0..batch_size {
4302                            let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
4303                            let mut ffn_out = if dense {
4304                                Self::run_ffn_block(
4305                                    layer,
4306                                    normed2,
4307                                    &self.config,
4308                                    hidden_dim,
4309                                    residency.as_ref().map(|p| p.layer_plan(l)),
4310                                )
4311                            } else {
4312                                let router_logits =
4313                                    &router_logits_batch[b * n_experts..(b + 1) * n_experts];
4314                                Self::combine_ffn_outputs_for_position(
4315                                    layer,
4316                                    normed2,
4317                                    router_logits,
4318                                    &self.config,
4319                                    hidden_dim,
4320                                    residency.as_ref().map(|p| p.layer_plan(l)),
4321                                )
4322                            };
4323                            if let Some(post) = &layer.attn.post_ffn_norm {
4324                                ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
4325                            }
4326                            let hidden_row =
4327                                &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
4328                            for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
4329                                *h += f;
4330                            }
4331                        }
4332                    }
4333                    l += 1;
4334                    continue;
4335                }
4336            }
4337
4338            // RoPE per token is independent; parallelize for CPU pp512.
4339            q_batch
4340                .par_chunks_mut(q_width)
4341                .zip(k_batch.par_chunks_mut(kv_width))
4342                .enumerate()
4343                .for_each(|(b, (q_row, k_row))| {
4344                    let pos = start_pos + b;
4345                    for h in 0..n_heads {
4346                        self.apply_rope_head_layer(
4347                            &mut q_row[h * head_dim..(h + 1) * head_dim],
4348                            pos,
4349                            l,
4350                        );
4351                    }
4352                    for h in 0..n_kv_heads {
4353                        self.apply_rope_head_layer(
4354                            &mut k_row[h * head_dim..(h + 1) * head_dim],
4355                            pos,
4356                            l,
4357                        );
4358                    }
4359                });
4360
4361            let base_seq_len = cache.seq_len;
4362            for b in 0..batch_size {
4363                cache
4364                    .push(
4365                        &k_batch[b * kv_width..(b + 1) * kv_width],
4366                        &v_batch[b * kv_width..(b + 1) * kv_width],
4367                    )
4368                    .expect("unbounded/planned KvCache growth is infallible");
4369            }
4370
4371            // Prefill attention over the just-written KV prefix. Parallel
4372            // over query positions — the serial loop was a dominant CPU
4373            // pp512 bottleneck (each query still attends only its causal
4374            // prefix; K/V slices are immutable after the pushes above).
4375            let cache_k = &cache.k;
4376            let cache_v = &cache.v;
4377            let softcap = self.config.attn_logit_softcap;
4378            let window = self.config.layer_sliding_window(l);
4379            let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
4380            // gpt-oss takes the per-query path on every layer, windowed
4381            // or not: the blocked kernel has no sink term. Everything
4382            // else goes through the blocked kernel, which is Rayon over
4383            // `[query-block x head]` against one shared KV buffer,
4384            // windowed or not. SWA layers used to take a per-query
4385            // `causal_gqa_attention_windowed_softcap` instead, which is
4386            // `online_attn_accumulate`: two scalar `exp` and a
4387            // head_dim-wide rescale per KV position, with the head axis
4388            // serial inside each task. On Gemma-3-1B (22 of 26 layers
4389            // are SWA) that arm was 19.6% of non-idle CPU `pp512`
4390            // samples while doing the *same* KV work as this one - at
4391            // `pp512` the 512-wide window covers the whole prompt.
4392            let attn_out_batch = if let Some(oai) = oai {
4393                let mut out = vec![0f32; batch_size * q_width];
4394                out.par_chunks_mut(q_width)
4395                    .enumerate()
4396                    .for_each(|(b, dest)| {
4397                        let seq_len_b = base_seq_len + b + 1;
4398                        let cache_elems = seq_len_b * kv_width;
4399                        let attn_out = ferrox_core::causal_gqa_attention_sinks(
4400                            &q_batch[b * q_width..(b + 1) * q_width],
4401                            &cache_k[..cache_elems],
4402                            &cache_v[..cache_elems],
4403                            n_heads,
4404                            n_kv_heads,
4405                            head_dim,
4406                            seq_len_b,
4407                            window,
4408                            &oai.attn_sinks,
4409                        );
4410                        dest.copy_from_slice(&attn_out);
4411                    });
4412                out
4413            } else {
4414                causal_gqa_attention_prefill_shared_kv_windowed(
4415                    &q_batch,
4416                    cache_k,
4417                    cache_v,
4418                    n_heads,
4419                    n_kv_heads,
4420                    head_dim,
4421                    batch_size,
4422                    base_seq_len,
4423                    softcap,
4424                    window,
4425                )
4426            };
4427
4428            let mut projected_batch = layer.attn.o_proj.apply_batch(&attn_out_batch, batch_size);
4429            if let Some(oai) = oai {
4430                for row in projected_batch.chunks_mut(hidden_dim) {
4431                    for (x, b) in row.iter_mut().zip(oai.o_bias.iter()) {
4432                        *x += b;
4433                    }
4434                }
4435            }
4436            let projected_batch = if let Some(post) = &layer.attn.post_attn_norm {
4437                projected_batch
4438                    .chunks(hidden_dim)
4439                    .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4440                    .collect::<Vec<_>>()
4441            } else {
4442                projected_batch
4443            };
4444            for (h, p) in hidden_batch.iter_mut().zip(projected_batch.iter()) {
4445                *h += p;
4446            }
4447
4448            // --- MoE FFN block ---
4449            let normed2_batch: Vec<f32> = hidden_batch
4450                .par_chunks(hidden_dim)
4451                .map(|h| rms_norm(h, &layer.moe.norm_weight, self.config.rms_norm_eps))
4452                .flatten()
4453                .collect();
4454            if let Some(oai) = oai {
4455                // gpt-oss: one position at a time through the single
4456                // validated FFN. None of the batched fast paths below
4457                // knows about router bias, expert bias or swiglu_oai.
4458                for b in 0..batch_size {
4459                    let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
4460                    let ffn_out = Self::gpt_oss_ffn(layer, oai, normed2, &self.config, hidden_dim);
4461                    let hidden_row = &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
4462                    for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
4463                        *h += f;
4464                    }
4465                }
4466                l += 1;
4467                continue;
4468            }
4469            let dense = Self::is_dense_layer(layer);
4470            // Skip the batched router matmul entirely for a dense
4471            // layer -- there's nothing to route (see
4472            // `is_dense_layer`'s doc comment), so computing it here
4473            // just to ignore it below would waste the one matmul this
4474            // fast path exists to avoid.
4475            let router_logits_batch = if dense {
4476                Vec::new()
4477            } else {
4478                layer.moe.router.apply_batch(&normed2_batch, batch_size)
4479            };
4480            #[cfg(feature = "metal")]
4481            let metal_ffn = if !dense {
4482                Self::try_metal_moe_prefill_batch(
4483                    layer,
4484                    &normed2_batch,
4485                    &router_logits_batch,
4486                    batch_size,
4487                    hidden_dim,
4488                    &self.config,
4489                )
4490            } else {
4491                None
4492            };
4493            #[cfg(not(feature = "metal"))]
4494            let metal_ffn: Option<Vec<f32>> = None;
4495            if let Some(mut ffn_batch) = metal_ffn {
4496                if let Some(post) = &layer.attn.post_ffn_norm {
4497                    ffn_batch = ffn_batch
4498                        .chunks(hidden_dim)
4499                        .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4500                        .collect();
4501                }
4502                for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4503                    *h += f;
4504                }
4505            } else if let Some(mut ffn_batch) =
4506                Self::dense_ffn_batch(layer, &normed2_batch, batch_size, &self.config)
4507            {
4508                // Dense FFN, batched. Without this the FFN -- the
4509                // majority of a dense model's prefill work -- ran one
4510                // position at a time while Q/K/V and the router were
4511                // already batched, which is why `pp512` measured about
4512                // the same as `tg128`.
4513                if let Some(post) = &layer.attn.post_ffn_norm {
4514                    ffn_batch = ffn_batch
4515                        .chunks(hidden_dim)
4516                        .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4517                        .collect();
4518                }
4519                for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4520                    *h += f;
4521                }
4522            } else if let Some(mut ffn_batch) = Self::moe_ffn_batch(
4523                layer,
4524                &normed2_batch,
4525                &router_logits_batch,
4526                batch_size,
4527                hidden_dim,
4528                &self.config,
4529                residency.as_ref().map(|p| p.layer_plan(l)),
4530            ) {
4531                if let Some(post) = &layer.attn.post_ffn_norm {
4532                    ffn_batch = ffn_batch
4533                        .chunks(hidden_dim)
4534                        .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4535                        .collect();
4536                }
4537                for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4538                    *h += f;
4539                }
4540            } else {
4541                let n_experts = layer.moe.n_experts().max(1);
4542                for b in 0..batch_size {
4543                    let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
4544                    let mut ffn_out = if dense {
4545                        Self::run_ffn_block(
4546                            layer,
4547                            normed2,
4548                            &self.config,
4549                            hidden_dim,
4550                            residency.as_ref().map(|p| p.layer_plan(l)),
4551                        )
4552                    } else {
4553                        let router_logits =
4554                            &router_logits_batch[b * n_experts..(b + 1) * n_experts];
4555                        Self::combine_ffn_outputs_for_position(
4556                            layer,
4557                            normed2,
4558                            router_logits,
4559                            &self.config,
4560                            hidden_dim,
4561                            residency.as_ref().map(|p| p.layer_plan(l)),
4562                        )
4563                    };
4564                    if let Some(post) = &layer.attn.post_ffn_norm {
4565                        ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
4566                    }
4567                    let hidden_row = &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
4568                    for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
4569                        *h += f;
4570                    }
4571                }
4572            }
4573            l += 1;
4574        }
4575
4576        hidden_batch
4577            .chunks(hidden_dim)
4578            .map(|h| rms_norm(h, &self.final_norm, self.config.rms_norm_eps))
4579            .collect()
4580    }
4581
4582    /// Continuous-batching primitive: one decode step across N
4583    /// independent *sequences*, each contributing exactly one new
4584    /// token at its own current position, sharing every layer's
4585    /// projection/router matmuls the same way `forward_batch` shares
4586    /// them across positions of a single sequence -- but each
4587    /// sequence keeps its own `KvCache`, independent `seq_len`, and
4588    /// independent position, so sequences admitted/evicted at
4589    /// different times can still share one batched matmul per step
4590    /// (this is what "continuous" batching means: the batch
4591    /// membership can change every step, unlike `forward_batch`'s
4592    /// fixed-size prompt-processing batch). `kv_caches[s][l]` is
4593    /// sequence `s`'s layer-`l` cache; `tokens[s]`/`positions[s]` is
4594    /// that sequence's next token and its position within its own
4595    /// history. Returns one logits vector per sequence, same order as
4596    /// `tokens`.
4597    ///
4598    /// Must produce bit-identical output to calling `forward_token`
4599    /// once per sequence with that sequence's own cache/position --
4600    /// batching independent sequences together is a scheduling detail,
4601    /// not a math change (no sequence's attention ever reads another
4602    /// sequence's cache).
4603    pub fn forward_multi_seq(
4604        &self,
4605        tokens: &[usize],
4606        positions: &[usize],
4607        kv_caches: &mut [Vec<KvCache>],
4608    ) -> Vec<Vec<f32>> {
4609        self.forward_multi_seq_kv(tokens, positions, &mut MultiSeqKv::Contiguous(kv_caches))
4610    }
4611
4612    /// Appends one position to sequence `b`'s layer-`l` KV, then
4613    /// attends over everything that sequence holds.
4614    ///
4615    /// The only place `forward_multi_seq_kv` touches a cache, and so
4616    /// the only place the backing matters. The contiguous arm keeps
4617    /// its three specialised kernels; the paged arm reaches
4618    /// `causal_gqa_attention_paged_sinks`, which covers all three of
4619    /// them in one entry point and is bit-identical to each by
4620    /// construction.
4621    #[allow(clippy::too_many_arguments)] // one per thing the step needs
4622    fn push_and_attend(
4623        &self,
4624        kv: &mut MultiSeqKv<'_>,
4625        b: usize,
4626        l: usize,
4627        k: &[f32],
4628        v: &[f32],
4629        q: &[f32],
4630        oai: Option<&GptOssLayer>,
4631    ) -> Vec<f32> {
4632        let n_heads = self.config.n_heads;
4633        let n_kv_heads = self.config.n_kv_heads;
4634        let head_dim = self.config.head_dim;
4635        let window = self.config.layer_sliding_window(l);
4636        match kv {
4637            MultiSeqKv::Contiguous(caches) => {
4638                let cache = &mut caches[b][l];
4639                cache
4640                    .push(k, v)
4641                    .expect("unbounded/planned KvCache growth is infallible");
4642                if let Some(oai) = oai {
4643                    return ferrox_core::causal_gqa_attention_sinks(
4644                        q,
4645                        &cache.k,
4646                        &cache.v,
4647                        n_heads,
4648                        n_kv_heads,
4649                        head_dim,
4650                        cache.seq_len,
4651                        window,
4652                        &oai.attn_sinks,
4653                    );
4654                }
4655                match window {
4656                    Some(window) => causal_gqa_attention_windowed_softcap(
4657                        q,
4658                        &cache.k,
4659                        &cache.v,
4660                        n_heads,
4661                        n_kv_heads,
4662                        head_dim,
4663                        cache.seq_len,
4664                        window,
4665                        self.config.attn_logit_softcap,
4666                    ),
4667                    None => causal_gqa_attention_softcap(
4668                        q,
4669                        &cache.k,
4670                        &cache.v,
4671                        n_heads,
4672                        n_kv_heads,
4673                        head_dim,
4674                        cache.seq_len,
4675                        self.config.attn_logit_softcap,
4676                    ),
4677                }
4678            }
4679            MultiSeqKv::Paged { caches, stores } => {
4680                let cache = &mut caches[b][l];
4681                // Write guard for the push alone, then a read guard for
4682                // the attention: the rule `SharedPagedKv` documents.
4683                {
4684                    let mut store = stores.write(l);
4685                    cache
4686                        .push(&mut store, k, v)
4687                        .expect("the batcher reserves every row's pages at admission");
4688                }
4689                let store = stores.read(l);
4690                ferrox_core::causal_gqa_attention_paged_sinks(
4691                    q,
4692                    &store,
4693                    cache.block_table(),
4694                    n_heads,
4695                    n_kv_heads,
4696                    head_dim,
4697                    cache.seq_len(),
4698                    window,
4699                    oai.map(|o| o.attn_sinks.as_slice()),
4700                    // The sink arm carries no softcap, matching the
4701                    // contiguous dispatch above.
4702                    if oai.is_some() {
4703                        None
4704                    } else {
4705                        self.config.attn_logit_softcap
4706                    },
4707                )
4708            }
4709        }
4710    }
4711
4712    /// [`Self::forward_multi_seq`] over either KV backing.
4713    ///
4714    /// One body for both: the batched projections are identical, and
4715    /// the per-sequence attention step is the only place the backing
4716    /// shows through.
4717    pub fn forward_multi_seq_kv(
4718        &self,
4719        tokens: &[usize],
4720        positions: &[usize],
4721        kv: &mut MultiSeqKv<'_>,
4722    ) -> Vec<Vec<f32>> {
4723        assert_eq!(tokens.len(), positions.len());
4724        assert_eq!(tokens.len(), kv.len());
4725        let batch_size = tokens.len();
4726        if batch_size == 0 {
4727            return Vec::new();
4728        }
4729        for seq in 0..batch_size {
4730            assert_eq!(kv.layers_per_seq(seq), self.layers.len());
4731        }
4732
4733        let hidden_dim = self.config.hidden_dim;
4734        let head_dim = self.config.head_dim;
4735        let n_heads = self.config.n_heads;
4736        let n_kv_heads = self.config.n_kv_heads;
4737
4738        // [batch, hidden], flattened row-major.
4739        let mut hidden_batch: Vec<f32> = self.embed_tokens(tokens);
4740
4741        let residency = self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b));
4742
4743        for (l, layer) in self.layers.iter().enumerate() {
4744            // --- attention block ---
4745            let normed_batch: Vec<f32> = hidden_batch
4746                .par_chunks(hidden_dim)
4747                .map(|h| rms_norm(h, &layer.attn.norm_weight, self.config.rms_norm_eps))
4748                .flatten()
4749                .collect();
4750
4751            // One shared activation-quant pass for q/k/v (plan 1e): the
4752            // three projections read the same normed batch, so quantize it
4753            // once instead of once per projection. A kind mismatch inside
4754            // the group just re-quantizes locally.
4755            let qkv_acts = layer
4756                .attn
4757                .q_proj
4758                .quantize_batch_acts(&normed_batch, batch_size);
4759            let mut q_batch = layer.attn.q_proj.apply_batch_with_acts(
4760                &normed_batch,
4761                batch_size,
4762                qkv_acts.as_ref(),
4763            );
4764            let mut k_batch = layer.attn.k_proj.apply_batch_with_acts(
4765                &normed_batch,
4766                batch_size,
4767                qkv_acts.as_ref(),
4768            );
4769            let mut v_batch = layer.attn.v_proj.apply_batch_with_acts(
4770                &normed_batch,
4771                batch_size,
4772                qkv_acts.as_ref(),
4773            );
4774            drop(qkv_acts);
4775
4776            let q_width = n_heads * head_dim;
4777            let kv_width = n_kv_heads * head_dim;
4778
4779            if let Some(bias) = &layer.attn.q_bias {
4780                for row in q_batch.chunks_mut(q_width) {
4781                    for (x, b) in row.iter_mut().zip(bias.iter()) {
4782                        *x += b;
4783                    }
4784                }
4785            }
4786            if let Some(bias) = &layer.attn.k_bias {
4787                for row in k_batch.chunks_mut(kv_width) {
4788                    for (x, b) in row.iter_mut().zip(bias.iter()) {
4789                        *x += b;
4790                    }
4791                }
4792            }
4793            if let Some(bias) = &layer.attn.v_bias {
4794                for row in v_batch.chunks_mut(kv_width) {
4795                    for (x, b) in row.iter_mut().zip(bias.iter()) {
4796                        *x += b;
4797                    }
4798                }
4799            }
4800
4801            if let Some(q_norm) = &layer.attn.q_norm {
4802                for row in q_batch.chunks_mut(q_width) {
4803                    let normed = self.apply_qk_norm(row, q_norm);
4804                    row.copy_from_slice(&normed);
4805                }
4806            }
4807            if let Some(k_norm) = &layer.attn.k_norm {
4808                for row in k_batch.chunks_mut(kv_width) {
4809                    let normed = self.apply_qk_norm(row, k_norm);
4810                    row.copy_from_slice(&normed);
4811                }
4812            }
4813            self.apply_rope_attn_factor(&mut q_batch, &mut k_batch);
4814
4815            for b in 0..batch_size {
4816                let pos = positions[b];
4817                let q_row = &mut q_batch[b * q_width..(b + 1) * q_width];
4818                for h in 0..n_heads {
4819                    self.apply_rope_head_layer(
4820                        &mut q_row[h * head_dim..(h + 1) * head_dim],
4821                        pos,
4822                        l,
4823                    );
4824                }
4825                let k_row = &mut k_batch[b * kv_width..(b + 1) * kv_width];
4826                for h in 0..n_kv_heads {
4827                    self.apply_rope_head_layer(
4828                        &mut k_row[h * head_dim..(h + 1) * head_dim],
4829                        pos,
4830                        l,
4831                    );
4832                }
4833            }
4834
4835            let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
4836            let mut attn_out_batch = vec![0f32; batch_size * q_width];
4837            for b in 0..batch_size {
4838                let attn_out = self.push_and_attend(
4839                    kv,
4840                    b,
4841                    l,
4842                    &k_batch[b * kv_width..(b + 1) * kv_width],
4843                    &v_batch[b * kv_width..(b + 1) * kv_width],
4844                    &q_batch[b * q_width..(b + 1) * q_width],
4845                    oai,
4846                );
4847                attn_out_batch[b * q_width..(b + 1) * q_width].copy_from_slice(&attn_out);
4848            }
4849
4850            let mut projected_batch = layer.attn.o_proj.apply_batch(&attn_out_batch, batch_size);
4851            if let Some(oai) = oai {
4852                for row in projected_batch.chunks_mut(hidden_dim) {
4853                    for (x, b) in row.iter_mut().zip(oai.o_bias.iter()) {
4854                        *x += b;
4855                    }
4856                }
4857            }
4858            let projected_batch = if let Some(post) = &layer.attn.post_attn_norm {
4859                projected_batch
4860                    .chunks(hidden_dim)
4861                    .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4862                    .collect::<Vec<_>>()
4863            } else {
4864                projected_batch
4865            };
4866            for (h, p) in hidden_batch.iter_mut().zip(projected_batch.iter()) {
4867                *h += p;
4868            }
4869
4870            // --- MoE FFN block ---
4871            let normed2_batch: Vec<f32> = hidden_batch
4872                .par_chunks(hidden_dim)
4873                .map(|h| rms_norm(h, &layer.moe.norm_weight, self.config.rms_norm_eps))
4874                .flatten()
4875                .collect();
4876            let dense = Self::is_dense_layer(layer);
4877            let router_logits_batch = if dense || oai.is_some() {
4878                Vec::new()
4879            } else {
4880                layer.moe.router.apply_batch(&normed2_batch, batch_size)
4881            };
4882            let n_experts = layer.moe.n_experts().max(1);
4883
4884            for b in 0..batch_size {
4885                let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
4886                let mut ffn_out = if let Some(oai) = oai {
4887                    Self::gpt_oss_ffn(layer, oai, normed2, &self.config, hidden_dim)
4888                } else if dense {
4889                    Self::run_ffn_block(
4890                        layer,
4891                        normed2,
4892                        &self.config,
4893                        hidden_dim,
4894                        residency.as_ref().map(|p| p.layer_plan(l)),
4895                    )
4896                } else {
4897                    let router_logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
4898                    Self::combine_ffn_outputs_for_position(
4899                        layer,
4900                        normed2,
4901                        router_logits,
4902                        &self.config,
4903                        hidden_dim,
4904                        residency.as_ref().map(|p| p.layer_plan(l)),
4905                    )
4906                };
4907                if let Some(post) = &layer.attn.post_ffn_norm {
4908                    ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
4909                }
4910                let hidden_row = &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
4911                for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
4912                    *h += f;
4913                }
4914            }
4915        }
4916
4917        let final_normed_batch: Vec<f32> = hidden_batch
4918            .par_chunks(hidden_dim)
4919            .map(|h| rms_norm(h, &self.final_norm, self.config.rms_norm_eps))
4920            .flatten()
4921            .collect();
4922        self.logits_from_flat_hidden(final_normed_batch, batch_size)
4923    }
4924}
4925
4926#[cfg(test)]
4927mod partial_rotary_tests {
4928    use super::*;
4929
4930    /// Phi-3/Phi-4 rotate `rope.dimension_count` of each head and pass
4931    /// the rest through. The tail staying bit-identical is the whole
4932    /// property: rotating it would make dimensions position-dependent
4933    /// that the model never trained that way.
4934    #[test]
4935    fn partial_rotary_leaves_the_tail_untouched() {
4936        let mut cfg = crate::config::test_dense_fixture();
4937        cfg.head_dim = 8;
4938        cfg.rope_layout = crate::config::RopeLayout::Neox;
4939        cfg.rope_freqs = None;
4940        cfg.rope_dim = Some(4);
4941        let decoder = Decoder::new_random_small(cfg, 1, 32);
4942
4943        let mut head: Vec<f32> = (0..8).map(|i| 1.0 + i as f32).collect();
4944        let before = head.clone();
4945        decoder.apply_rope_head_theta(&mut head, 3, 10000.0);
4946
4947        assert_eq!(
4948            &head[4..],
4949            &before[4..],
4950            "dims at or past rope_dim must not rotate"
4951        );
4952        assert!(
4953            head[..4] != before[..4],
4954            "dims below rope_dim must rotate at a non-zero position"
4955        );
4956    }
4957
4958    /// `attn_factor` is a magnitude scale folded into cos/sin inside
4959    /// ggml's `rope_yarn`, so it can only ever touch the rotated
4960    /// channels. The pass-through tail must come out bit-identical —
4961    /// scaling it is a different graph, and it was one, until
4962    /// `ferrox parity` reported Phi-4-mini as the single DRIFT in a
4963    /// 17-model sweep against llama.cpp.
4964    #[test]
4965    fn attn_factor_scales_only_the_rotated_channels() {
4966        let mut cfg = crate::config::test_dense_fixture();
4967        cfg.head_dim = 8;
4968        cfg.n_heads = 2;
4969        cfg.n_kv_heads = 2;
4970        cfg.rope_dim = Some(4);
4971        cfg.rope_attn_factor = 2.0;
4972        let decoder = Decoder::new_random_small(cfg, 1, 32);
4973
4974        // Two heads, so a per-head slice bug cannot hide behind a single
4975        // head that happens to span the whole buffer.
4976        let mut q: Vec<f32> = (0..16).map(|i| 1.0 + i as f32).collect();
4977        let mut k: Vec<f32> = (0..16).map(|i| 1.0 + i as f32).collect();
4978        let before = q.clone();
4979        decoder.apply_rope_attn_factor(&mut q, &mut k);
4980
4981        for h in 0..2 {
4982            let base = h * 8;
4983            for i in 0..4 {
4984                assert_eq!(
4985                    q[base + i],
4986                    before[base + i] * 2.0,
4987                    "rotated channel {i} of head {h} must be scaled"
4988                );
4989            }
4990            for i in 4..8 {
4991                assert_eq!(
4992                    q[base + i],
4993                    before[base + i],
4994                    "pass-through channel {i} of head {h} must be untouched"
4995                );
4996            }
4997        }
4998        assert_eq!(q, k, "q and k take the same magnitude scale");
4999    }
5000
5001    /// With no partial rotary the whole head is rotated, so the whole
5002    /// head takes the scale — the narrow case must not become the rule.
5003    #[test]
5004    fn attn_factor_scales_the_whole_head_without_partial_rotary() {
5005        let mut cfg = crate::config::test_dense_fixture();
5006        cfg.head_dim = 8;
5007        cfg.n_heads = 1;
5008        cfg.n_kv_heads = 1;
5009        cfg.rope_dim = None;
5010        cfg.rope_attn_factor = 3.0;
5011        let decoder = Decoder::new_random_small(cfg, 1, 32);
5012
5013        let mut q: Vec<f32> = (0..8).map(|i| 1.0 + i as f32).collect();
5014        let mut k = q.clone();
5015        let before = q.clone();
5016        decoder.apply_rope_attn_factor(&mut q, &mut k);
5017        for i in 0..8 {
5018            assert_eq!(q[i], before[i] * 3.0);
5019        }
5020    }
5021
5022    /// The same call with no `rope_dim` must rotate everything, so the
5023    /// narrow case cannot silently become the default.
5024    #[test]
5025    fn full_rotary_still_rotates_the_whole_head() {
5026        let mut cfg = crate::config::test_dense_fixture();
5027        cfg.head_dim = 8;
5028        cfg.rope_layout = crate::config::RopeLayout::Neox;
5029        cfg.rope_freqs = None;
5030        cfg.rope_dim = None;
5031        let decoder = Decoder::new_random_small(cfg, 1, 32);
5032
5033        let mut head: Vec<f32> = (0..8).map(|i| 1.0 + i as f32).collect();
5034        let before = head.clone();
5035        decoder.apply_rope_head_theta(&mut head, 3, 10000.0);
5036        assert!(head[4..] != before[4..]);
5037    }
5038
5039    /// `mscale` scales q and k and nothing else; `1.0` must be a literal
5040    /// no-op so every other model pays nothing.
5041    #[test]
5042    fn rope_attn_factor_scales_q_and_k_only() {
5043        let mut cfg = crate::config::test_dense_fixture();
5044        cfg.rope_attn_factor = 2.0;
5045        let decoder = Decoder::new_random_small(cfg, 1, 32);
5046        let mut q = vec![1.0f32, -2.0, 3.0];
5047        let mut k = vec![0.5f32, 4.0];
5048        decoder.apply_rope_attn_factor(&mut q, &mut k);
5049        assert_eq!(q, vec![2.0, -4.0, 6.0]);
5050        assert_eq!(k, vec![1.0, 8.0]);
5051
5052        let mut cfg = crate::config::test_dense_fixture();
5053        cfg.rope_attn_factor = 1.0;
5054        let decoder = Decoder::new_random_small(cfg, 1, 32);
5055        let mut q = vec![1.0f32, -2.0];
5056        let mut k = vec![3.0f32];
5057        decoder.apply_rope_attn_factor(&mut q, &mut k);
5058        assert_eq!(q, vec![1.0, -2.0]);
5059        assert_eq!(k, vec![3.0]);
5060    }
5061}
5062
5063#[cfg(test)]
5064mod tests {
5065    use super::*;
5066    use crate::config::glm_5_2;
5067    use ferrox_core::cache::PagedKvStore;
5068
5069    /// Small config used purely to keep the test fast: same
5070    /// architecture *shape* (GQA ratio, MoE topology) as GLM-5.2, but
5071    /// with tiny dims so the whole thing runs in milliseconds.
5072    fn tiny_test_config() -> ModelConfig {
5073        let mut cfg = glm_5_2();
5074        cfg.hidden_dim = 16;
5075        cfg.n_heads = 4;
5076        cfg.n_kv_heads = 2;
5077        cfg.head_dim = 4;
5078        cfg.moe.hidden_dim = 16;
5079        cfg.moe.n_experts = 6;
5080        cfg.moe.n_experts_active = 2;
5081        cfg.moe.n_shared_experts = 1;
5082        cfg.moe.expert_ffn_dim = 8;
5083        cfg
5084    }
5085
5086    #[test]
5087    fn forward_pass_produces_finite_logits_of_correct_shape() {
5088        let vocab = 10;
5089        let decoder = Decoder::new_random_small(tiny_test_config(), 2, vocab);
5090        let mut caches: Vec<KvCache> = (0..2)
5091            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5092            .collect();
5093
5094        let logits = decoder.forward_token(3, 0, &mut caches);
5095        assert_eq!(logits.len(), vocab);
5096        assert!(
5097            logits.iter().all(|v| v.is_finite()),
5098            "logits must not contain NaN/Inf"
5099        );
5100    }
5101
5102    /// `gpu_vram_budget_bytes` must be a real zero-behavior-change
5103    /// default at `None`, and a *real placement plan that places
5104    /// nothing* (a zero VRAM budget, so `PlacementPlan::from_budget`
5105    /// fits no expert at all) must produce byte-identical output to
5106    /// `None` too -- proving the new plumbing (building a plan,
5107    /// looking up each routed expert's placement, dispatching through
5108    /// `run_expert_placed`) doesn't change results when nothing is
5109    /// actually GPU-placed, without needing real CUDA hardware to
5110    /// check (that hardware-dependent half is
5111    /// `ferrox-moe`'s/`ferrox-core`'s own `#[ignore]`d tests).
5112    #[test]
5113    fn gpu_vram_budget_bytes_with_nothing_placed_matches_the_default() {
5114        let mut decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
5115        let mut caches_default: Vec<KvCache> = (0..2)
5116            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5117            .collect();
5118        let default_logits = decoder.forward_token(3, 0, &mut caches_default);
5119
5120        decoder.gpu_vram_budget_bytes = Some(0);
5121        let mut caches_zero_budget: Vec<KvCache> = (0..2)
5122            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5123            .collect();
5124        let zero_budget_logits = decoder.forward_token(3, 0, &mut caches_zero_budget);
5125
5126        assert_eq!(
5127            default_logits, zero_budget_logits,
5128            "a placement plan that places nothing on GPU must match the None default exactly"
5129        );
5130    }
5131
5132    /// Qwen2-MoE's real shared-expert sigmoid gate
5133    /// (`MoeWeights::shared_expert_gate`): exact math check by mutating
5134    /// `layer.moe.shared_expert_gate` in place on an already-built
5135    /// decoder (no need to reconstruct a `LayerWeights`/`MoeWeights`
5136    /// from scratch) and comparing against a hand-derived expectation:
5137    /// the *only* thing the gate changes is the shared experts' own
5138    /// contribution, scaled by `sigmoid(gate . x)` -- so
5139    /// `gated_shared_output == ungated_shared_output * sigmoid_value`
5140    /// exactly, computed independently here via `run_expert` on the
5141    /// same layer's shared expert.
5142    #[test]
5143    fn shared_expert_gate_scales_shared_output_by_sigmoid_of_the_gate_logit() {
5144        let cfg = tiny_test_config();
5145        let mut decoder = Decoder::new_random_small(cfg, 2, 8);
5146        let hidden_dim = decoder.config.hidden_dim;
5147        assert_eq!(
5148            decoder.layers[1].moe.shared_experts.len(),
5149            1,
5150            "test assumes tiny_test_config's real MoE layer has exactly one shared expert"
5151        );
5152
5153        let normed2: Vec<f32> = (0..hidden_dim).map(|i| (i as f32 * 0.37).sin()).collect();
5154        let gate_vec: Vec<f32> = (0..hidden_dim).map(|i| i as f32 * 0.13 - 0.5).collect();
5155
5156        // Independently compute what the shared expert alone produces,
5157        // and what sigmoid(gate . x) should scale it by -- this is the
5158        // ground truth the gated code path must reproduce exactly.
5159        let shared_out_raw = run_expert(&normed2, &decoder.layers[1].moe.shared_experts[0]);
5160        let gate_logit: f32 = gate_vec
5161            .iter()
5162            .zip(normed2.iter())
5163            .map(|(g, x)| g * x)
5164            .sum();
5165        let gate_value = 1.0 / (1.0 + (-gate_logit).exp());
5166        let expected_gated_shared: Vec<f32> =
5167            shared_out_raw.iter().map(|x| x * gate_value).collect();
5168
5169        // Run the real FFN combine path twice (gate absent, then
5170        // present) and recover each run's shared-only contribution by
5171        // subtracting the routed contribution, which the gate never
5172        // touches and is identical between the two runs (same router,
5173        // same experts, same input).
5174        let router_logits = decoder.layers[1].moe.router.apply(&normed2);
5175        let ungated_total = Decoder::combine_ffn_outputs_for_position(
5176            &decoder.layers[1],
5177            &normed2,
5178            &router_logits,
5179            &decoder.config,
5180            hidden_dim,
5181            None,
5182        );
5183        decoder.layers[1].moe.shared_expert_gate = Some(gate_vec);
5184        let gated_total = Decoder::combine_ffn_outputs_for_position(
5185            &decoder.layers[1],
5186            &normed2,
5187            &router_logits,
5188            &decoder.config,
5189            hidden_dim,
5190            None,
5191        );
5192
5193        for (i, ((u, g), expected_shared)) in ungated_total
5194            .iter()
5195            .zip(gated_total.iter())
5196            .zip(expected_gated_shared.iter())
5197            .enumerate()
5198        {
5199            let routed_contribution = u - shared_out_raw[i];
5200            let gated_shared_recovered = g - routed_contribution;
5201            assert!(
5202                (gated_shared_recovered - expected_shared).abs() < 1e-4,
5203                "index {i}: recovered gated shared output {gated_shared_recovered} != expected {expected_shared} (sigmoid({gate_logit})={gate_value})"
5204            );
5205        }
5206    }
5207
5208    #[test]
5209    fn kv_cache_grows_by_one_position_per_layer_per_step() {
5210        let decoder = Decoder::new_random_small(tiny_test_config(), 3, 5);
5211        let mut caches: Vec<KvCache> = (0..3)
5212            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5213            .collect();
5214
5215        decoder.forward_token(0, 0, &mut caches);
5216        decoder.forward_token(1, 1, &mut caches);
5217        decoder.forward_token(2, 2, &mut caches);
5218
5219        for cache in &caches {
5220            assert_eq!(cache.seq_len, 3);
5221        }
5222    }
5223
5224    #[test]
5225    fn same_token_same_position_is_deterministic() {
5226        let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
5227        let mut caches_a: Vec<KvCache> = (0..2)
5228            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5229            .collect();
5230        let mut caches_b: Vec<KvCache> = (0..2)
5231            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5232            .collect();
5233
5234        let out_a = decoder.forward_token(4, 0, &mut caches_a);
5235        let out_b = decoder.forward_token(4, 0, &mut caches_b);
5236        assert_eq!(out_a, out_b, "identical input state must yield identical output (no hidden randomness in the forward pass)");
5237    }
5238
5239    #[test]
5240    fn multi_step_decode_stays_finite_across_positions() {
5241        let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
5242        let mut caches: Vec<KvCache> = (0..2)
5243            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5244            .collect();
5245
5246        for pos in 0..16 {
5247            let logits = decoder.forward_token(pos % 8, pos, &mut caches);
5248            assert!(
5249                logits.iter().all(|v| v.is_finite()),
5250                "position {pos}: logits must stay finite across an extended decode run"
5251            );
5252        }
5253    }
5254
5255    /// `forward_token_paged` must produce bit-identical output to
5256    /// `forward_token` across a multi-step decode (each layer's paged
5257    /// store sized generously so no layer ever exhausts its blocks) --
5258    /// the block-table indirection is a storage-layout detail, not a
5259    /// math change.
5260    #[test]
5261    fn forward_token_paged_matches_forward_token_bit_identical() {
5262        paged_matches_contiguous(tiny_test_config());
5263    }
5264
5265    /// Every arm of the attention dispatch, not just the plain one.
5266    ///
5267    /// The paged path used to implement only full causal attention, and
5268    /// `forward_token_paged` asserted rather than run gpt-oss, because a
5269    /// missing sink term would have changed the distribution silently.
5270    /// Now that it mirrors all three arms, each one has to be held to
5271    /// the same bar the plain arm always was: BIT-identical, not close.
5272    ///
5273    /// A sliding window and a softcap are both driven from the config
5274    /// here, so a future edit that wires one arm and forgets another
5275    /// fails on the arm it forgot rather than on a model nobody tests.
5276    #[test]
5277    fn every_paged_attention_arm_is_bit_identical_to_its_contiguous_twin() {
5278        let windowed = || {
5279            let mut cfg = tiny_test_config();
5280            // Smaller than the decode length below, so the window really
5281            // drops positions rather than degenerating to full causal.
5282            cfg.sliding_window = Some(2);
5283            cfg.swa_pattern = None;
5284            cfg
5285        };
5286        let softcapped = || {
5287            let mut cfg = tiny_test_config();
5288            // Small enough that `sc * tanh(s / sc)` actually compresses.
5289            // A realistic 30.0 is numerically indistinguishable from no
5290            // cap at these tiny weights, so a test using it would pass
5291            // whether or not the arm was wired -- checked by breaking
5292            // the arm on purpose and watching it still pass.
5293            cfg.attn_logit_softcap = Some(0.05);
5294            cfg
5295        };
5296        let both = || {
5297            let mut cfg = windowed();
5298            cfg.attn_logit_softcap = Some(0.05);
5299            cfg
5300        };
5301        // Alternating window/full layers: the per-layer arm choice has
5302        // to be honoured per layer, not decided once for the model.
5303        let alternating = || {
5304            let mut cfg = tiny_test_config();
5305            cfg.sliding_window = Some(2);
5306            cfg.swa_pattern = Some(2);
5307            cfg
5308        };
5309
5310        for cfg in [windowed(), softcapped(), both(), alternating()] {
5311            paged_matches_contiguous(cfg);
5312        }
5313    }
5314
5315    /// Five MORE model features the paged path had lost the same way
5316    /// the first five went: by being a copy of the contiguous loop that
5317    /// nothing forced to stay in step.
5318    ///
5319    /// Found by running Gemma-2-2B through paged KV and watching it
5320    /// answer differently from the same model on the same backend with
5321    /// a contiguous cache -- on CPU, with no GPU involved at all. None
5322    /// of the arm tests above could see it, because `tiny_test_config`
5323    /// sets none of these and `new_random_small` builds every layer
5324    /// without the two sandwich norms.
5325    ///
5326    /// - `attention_scale`: Gemma scales Q itself and asks the kernel
5327    ///   for a score scale of 1.0, so the built-in `1/sqrt(head_dim)`
5328    ///   has to be compensated for. Missing, the model answers at a
5329    ///   different temperature.
5330    /// - `post_attn_norm` / `post_ffn_norm`: Gemma-2's sandwich norms,
5331    ///   applied to each branch before it rejoins the residual.
5332    /// - gpt-oss's `o_bias`, and its own FFN (`gpt_oss_ffn`, which
5333    ///   biases the router and runs the clamped OAI SwiGLU) instead of
5334    ///   the generic one.
5335    ///
5336    /// Every one of them produces a plausible distribution rather than
5337    /// an error, which is exactly why they are pinned rather than
5338    /// trusted. Values are chosen so each really bites: a scale of 1.0
5339    /// or an all-ones norm would let this pass either way.
5340    #[test]
5341    fn the_paged_path_keeps_every_per_layer_feature_the_contiguous_one_applies() {
5342        // Gemma's query pre-attention scalar, well away from the
5343        // kernel's own 1/sqrt(head_dim).
5344        let mut scaled = tiny_test_config();
5345        scaled.attention_scale = Some(0.37);
5346        paged_matches_contiguous_with(scaled, |_| {});
5347
5348        // Sandwich norms, one at a time and then together, so a wired
5349        // half is not covered for by the other.
5350        for (attn, ffn) in [(true, false), (false, true), (true, true)] {
5351            paged_matches_contiguous_with(tiny_test_config(), |d| {
5352                let hidden = d.config.hidden_dim;
5353                for (i, layer) in d.layers.iter_mut().enumerate() {
5354                    // Per-layer values, so a path that applied layer 0's
5355                    // norm everywhere would still fail.
5356                    let w: Vec<f32> = (0..hidden)
5357                        .map(|j| 0.5 + (i * hidden + j) as f32 * 0.01)
5358                        .collect();
5359                    if attn {
5360                        layer.attn.post_attn_norm = Some(w.clone());
5361                    }
5362                    if ffn {
5363                        layer.attn.post_ffn_norm = Some(w);
5364                    }
5365                }
5366            });
5367        }
5368
5369        // gpt-oss: the O bias and the OAI FFN, which the paged path was
5370        // substituting the generic router+SwiGLU for.
5371        paged_matches_contiguous_with(tiny_test_config(), |d| {
5372            let hidden = d.config.hidden_dim;
5373            let n_heads = d.config.n_heads;
5374            let n_experts = d.config.moe.n_experts;
5375            let ffn = d.config.moe.expert_ffn_dim;
5376            let n_layers = d.layers.len();
5377            d.gpt_oss = Some(GptOssWeights {
5378                layers: (0..n_layers)
5379                    .map(|l| GptOssLayer {
5380                        attn_sinks: (0..n_heads).map(|h| 0.1 + (l + h) as f32 * 0.05).collect(),
5381                        o_bias: (0..hidden).map(|j| 0.02 * (j as f32 - 8.0)).collect(),
5382                        router_bias: (0..n_experts).map(|e| 0.03 * e as f32).collect(),
5383                        expert_bias: (0..n_experts)
5384                            .map(|e| ferrox_moe::ExpertBias {
5385                                gate: vec![0.01 * (e + 1) as f32; ffn],
5386                                up: vec![-0.02 * (e + 1) as f32; ffn],
5387                                down: vec![0.005 * (e + 1) as f32; hidden],
5388                            })
5389                            .collect(),
5390                    })
5391                    .collect(),
5392            });
5393        });
5394    }
5395
5396    /// The two rules that live OUTSIDE the layer loop, which the arm
5397    /// test above cannot reach.
5398    ///
5399    /// The paged path had drifted from the contiguous one at both ends
5400    /// of the stack, and neither drift was visible to any existing test
5401    /// because `tiny_test_config` sets neither field:
5402    ///
5403    /// - it called `embedding.dequant_row` directly instead of scaling
5404    ///   the row by `embedding_scale`, so every Gemma token entered the
5405    ///   stack `sqrt(hidden_dim)` times too small;
5406    /// - it returned `output_head.apply(..)` raw instead of applying
5407    ///   `final_logit_softcap`, so Gemma-2's 30.0 cap never ran.
5408    ///
5409    /// Both produce a plausible distribution rather than an error, which
5410    /// is the whole reason to pin them: a wrong answer that still looks
5411    /// like an answer is what a parity test is for. Values here are
5412    /// chosen so each one actually bites -- a scale of 1.0 or a cap far
5413    /// above the logit range would let this pass either way.
5414    #[test]
5415    fn the_paged_path_scales_embeddings_and_softcaps_logits_like_the_contiguous_one() {
5416        let scaled = || {
5417            let mut cfg = tiny_test_config();
5418            cfg.embedding_scale = Some(7.5);
5419            cfg
5420        };
5421        let capped = || {
5422            let mut cfg = tiny_test_config();
5423            // Small enough that `sc * tanh(x / sc)` really compresses at
5424            // this model's logit magnitudes, on the same reasoning as
5425            // the attention softcap above.
5426            cfg.final_logit_softcap = Some(0.05);
5427            cfg
5428        };
5429        let both = || {
5430            let mut cfg = scaled();
5431            cfg.final_logit_softcap = Some(0.05);
5432            cfg
5433        };
5434
5435        for cfg in [scaled(), capped(), both()] {
5436            paged_matches_contiguous(cfg);
5437        }
5438    }
5439
5440    /// Paged prefill must agree with contiguous prefill, and must leave
5441    /// the KV in a state a paged DECODE can continue from.
5442    ///
5443    /// The second half is the one worth having. `forward_batch_last`
5444    /// returns only the last row's logits, so a gather/scatter that
5445    /// mangled the KV -- wrote the rows in the wrong order, dropped the
5446    /// part-full tail block, mis-sized a copy -- could still return the
5447    /// right logits for THIS call and only surface on the next token.
5448    /// Decoding four more tokens after the prefill is what makes the
5449    /// stored KV observable, so both paths are compared over the whole
5450    /// continuation rather than at the seam.
5451    ///
5452    /// A block size of 2 against a 5-token prompt is deliberate: it
5453    /// leaves the tail block part-full, which is the case
5454    /// `blocks_needed_for` exists for and the one a `n / block_size`
5455    /// reservation would get wrong.
5456    fn paged_prefill_matches_contiguous(config: ModelConfig) {
5457        let n_layers = 2;
5458        let decoder = Decoder::new_random_small(config, n_layers, 10);
5459        let prompt = [3usize, 1, 4, 1, 5];
5460        let continuation = [9usize, 2, 6, 5];
5461
5462        let mut caches: Vec<KvCache> = (0..n_layers)
5463            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5464            .collect();
5465        let mut plain = vec![decoder.forward_batch_last(&prompt, 0, &mut caches)];
5466        for (i, &tok) in continuation.iter().enumerate() {
5467            plain.push(decoder.forward_token(tok, prompt.len() + i, &mut caches));
5468        }
5469
5470        let mut paged_caches: Vec<PagedKvCache> =
5471            (0..n_layers).map(|_| PagedKvCache::new()).collect();
5472        let stores = SharedPagedKv::from_stores(
5473            (0..n_layers)
5474                .map(|_| {
5475                    PagedKvStore::new(
5476                        /* block_size = */ 2,
5477                        /* total_blocks = */ 16,
5478                        decoder.config.n_kv_heads,
5479                        decoder.config.head_dim,
5480                    )
5481                })
5482                .collect(),
5483        );
5484        let mut paged = vec![decoder
5485            .forward_batch_last_paged(&prompt, 0, &mut paged_caches, &stores)
5486            .expect("store sized generously, must not exhaust")];
5487        for (i, &tok) in continuation.iter().enumerate() {
5488            paged.push(
5489                decoder
5490                    .forward_token_paged(tok, prompt.len() + i, &mut paged_caches, &stores)
5491                    .expect("store sized generously, must not exhaust"),
5492            );
5493        }
5494
5495        assert_eq!(
5496            paged_caches[0].seq_len(),
5497            prompt.len() + continuation.len(),
5498            "paged prefill must advance seq_len by exactly the batch size"
5499        );
5500        assert_eq!(plain.len(), paged.len());
5501        for (step, (a, b)) in plain.iter().zip(paged.iter()).enumerate() {
5502            assert_eq!(a.len(), b.len(), "step {step}: logit count");
5503            for (x, y) in a.iter().zip(b.iter()) {
5504                assert_eq!(
5505                    x.to_bits(),
5506                    y.to_bits(),
5507                    "step {step}: paged prefill + decode must be bit-identical to contiguous"
5508                );
5509            }
5510        }
5511    }
5512
5513    /// Every arm again, this time through the prefill entry point. The
5514    /// gather is shared, but the kernel the gathered buffer reaches is
5515    /// the BLOCKED prefill one rather than the per-query decode one, so
5516    /// arm coverage here is not implied by the decode tests above.
5517    #[test]
5518    fn paged_prefill_is_bit_identical_across_every_arm() {
5519        let windowed = || {
5520            let mut cfg = tiny_test_config();
5521            cfg.sliding_window = Some(2);
5522            cfg.swa_pattern = None;
5523            cfg
5524        };
5525        let scaled_and_capped = || {
5526            let mut cfg = tiny_test_config();
5527            cfg.embedding_scale = Some(7.5);
5528            cfg.final_logit_softcap = Some(0.05);
5529            cfg.attn_logit_softcap = Some(0.05);
5530            cfg
5531        };
5532        let alternating = || {
5533            let mut cfg = tiny_test_config();
5534            cfg.sliding_window = Some(2);
5535            cfg.swa_pattern = Some(2);
5536            cfg
5537        };
5538
5539        for cfg in [
5540            tiny_test_config(),
5541            windowed(),
5542            scaled_and_capped(),
5543            alternating(),
5544        ] {
5545            paged_prefill_matches_contiguous(cfg);
5546        }
5547    }
5548
5549    /// A prefill the stores cannot hold refuses having written NOTHING
5550    /// -- checked on the case that actually needs the up-front loop.
5551    ///
5552    /// Each layer owns its own store, so layer 0 having room says
5553    /// nothing about layer 1. `append_contiguous` already refuses
5554    /// rather than half-writing a single layer, so a test whose layers
5555    /// are sized alike passes with the cross-layer reservation deleted
5556    /// -- it would be asserting a property it never exercises. Here
5557    /// layer 0 has room for the whole prompt and layer 1 does not, so
5558    /// without the up-front check layer 0 is written, layer 1 refuses,
5559    /// and the sequence ends up with its layers at DIFFERENT lengths.
5560    /// No caller can recover from that, and nothing downstream would
5561    /// report it: the next decode step simply attends over a shorter
5562    /// history in one layer than the others.
5563    ///
5564    /// Verified by deleting the reservation loop and watching this fail
5565    /// on `layer 1 must be untouched`.
5566    /// Three requests sharing one set of per-layer stores must get
5567    /// exactly what they would get alone.
5568    ///
5569    /// This is the property the RwLock exists for, and it cannot be
5570    /// asserted single-threaded. Every request writes only blocks it
5571    /// owns, so sharing changes where rows live and nothing else --
5572    /// bit-identical, not close. A store that let one request's rows
5573    /// land in another's blocks shows up here and nowhere else.
5574    #[test]
5575    fn concurrent_decodes_against_one_shared_store_match_running_them_alone() {
5576        use std::sync::Arc;
5577
5578        let decoder = Arc::new(Decoder::new_random_small(tiny_test_config(), 2, 10));
5579        let prompts: [&[usize]; 3] = [&[3, 1, 4], &[1, 5, 9], &[2, 6, 5]];
5580        let continuation = [7usize, 8, 3];
5581
5582        // Each request run alone, against its own store, is the answer
5583        // sharing must not change.
5584        let solo: Vec<Vec<Vec<f32>>> = prompts
5585            .iter()
5586            .map(|prompt| {
5587                let stores = SharedPagedKv::new(
5588                    2,
5589                    4,
5590                    32,
5591                    decoder.config.n_kv_heads,
5592                    decoder.config.head_dim,
5593                );
5594                let mut caches: Vec<PagedKvCache> = (0..2).map(|_| PagedKvCache::new()).collect();
5595                run_one(&decoder, prompt, &continuation, &mut caches, &stores)
5596            })
5597            .collect();
5598
5599        // The same three, concurrently, sharing ONE set of per-layer
5600        // stores. Every request writes only blocks it owns, so the
5601        // answers must be identical -- not close, identical. A store
5602        // that let one request's rows land in another's blocks would
5603        // show up here and nowhere else.
5604        let shared = Arc::new(SharedPagedKv::new(
5605            2,
5606            4,
5607            96,
5608            decoder.config.n_kv_heads,
5609            decoder.config.head_dim,
5610        ));
5611        let together: Vec<Vec<Vec<f32>>> = std::thread::scope(|scope| {
5612            let handles: Vec<_> = prompts
5613                .iter()
5614                .map(|prompt| {
5615                    let decoder = Arc::clone(&decoder);
5616                    let shared = Arc::clone(&shared);
5617                    scope.spawn(move || {
5618                        let mut caches: Vec<PagedKvCache> =
5619                            (0..2).map(|_| PagedKvCache::new()).collect();
5620                        run_one(&decoder, prompt, &continuation, &mut caches, &shared)
5621                    })
5622                })
5623                .collect();
5624            handles.into_iter().map(|h| h.join().unwrap()).collect()
5625        });
5626
5627        for (r, (alone, concurrent)) in solo.iter().zip(together.iter()).enumerate() {
5628            assert_eq!(alone.len(), concurrent.len(), "request {r}: step count");
5629            for (step, (a, b)) in alone.iter().zip(concurrent.iter()).enumerate() {
5630                for (x, y) in a.iter().zip(b.iter()) {
5631                    assert_eq!(
5632                        x.to_bits(),
5633                        y.to_bits(),
5634                        "request {r} step {step}: sharing a store changed the answer"
5635                    );
5636                }
5637            }
5638        }
5639    }
5640
5641    /// Prefill then decode, returning every step's logits.
5642    fn run_one(
5643        decoder: &Decoder,
5644        prompt: &[usize],
5645        continuation: &[usize],
5646        caches: &mut [PagedKvCache],
5647        stores: &SharedPagedKv,
5648    ) -> Vec<Vec<f32>> {
5649        let mut out = vec![decoder
5650            .forward_batch_last_paged(prompt, 0, caches, stores)
5651            .expect("sized generously")];
5652        for (i, &tok) in continuation.iter().enumerate() {
5653            out.push(
5654                decoder
5655                    .forward_token_paged(tok, prompt.len() + i, caches, stores)
5656                    .expect("sized generously"),
5657            );
5658        }
5659        out
5660    }
5661
5662    /// A decode step the stores cannot hold advances NO layer.
5663    ///
5664    /// This was a real defect until the reservation moved into
5665    /// `forward_token_paged`: it pushed per layer with `?`, so a store
5666    /// exhausting at layer 1 of 2 left layer 0 holding a position layer
5667    /// 1 did not. Nothing downstream reports that -- the next step just
5668    /// attends over a shorter history in the tail layers -- and the
5669    /// prefill path had the guard while decode never did.
5670    ///
5671    /// Layer 0 is given room and layer 1 none, so the bug is reachable:
5672    /// with the reservation removed, layer 0 advances and layer 1
5673    /// refuses.
5674    #[test]
5675    fn a_decode_step_the_stores_cannot_hold_advances_no_layer() {
5676        let decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
5677        let mut caches: Vec<PagedKvCache> = (0..2).map(|_| PagedKvCache::new()).collect();
5678        // Block size 1 so "one more position" always needs a block.
5679        // Layer 0 gets two, layer 1 exactly one: the prompt fills layer
5680        // 1 completely, so the decode step below cannot fit there.
5681        let stores = SharedPagedKv::from_stores(
5682            [2usize, 1]
5683                .into_iter()
5684                .map(|blocks| {
5685                    PagedKvStore::new(
5686                        1,
5687                        blocks,
5688                        decoder.config.n_kv_heads,
5689                        decoder.config.head_dim,
5690                    )
5691                })
5692                .collect(),
5693        );
5694
5695        decoder
5696            .forward_batch_last_paged(&[1usize], 0, &mut caches, &stores)
5697            .expect("one position fits in both layers");
5698        assert_eq!(caches[0].seq_len(), 1);
5699        assert_eq!(caches[1].seq_len(), 1);
5700
5701        let result = decoder.forward_token_paged(2, 1, &mut caches, &stores);
5702        assert!(result.is_err(), "layer 1 has no block left");
5703        assert_eq!(
5704            caches[0].seq_len(),
5705            1,
5706            "layer 0 must not advance past a layer that could not"
5707        );
5708        assert_eq!(caches[1].seq_len(), 1);
5709    }
5710
5711    #[test]
5712    fn a_prefill_the_stores_cannot_hold_refuses_before_writing_any_layer() {
5713        let decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
5714        let prompt = [1usize, 2, 3, 4, 5, 6];
5715        let mut paged_caches: Vec<PagedKvCache> = (0..2).map(|_| PagedKvCache::new()).collect();
5716        // Layer 0 fits the prompt with room to spare; layer 1's two
5717        // blocks of 2 hold 4 positions against a prompt of 6.
5718        let stores = SharedPagedKv::from_stores(
5719            [8usize, 2]
5720                .into_iter()
5721                .map(|blocks| {
5722                    PagedKvStore::new(
5723                        2,
5724                        blocks,
5725                        decoder.config.n_kv_heads,
5726                        decoder.config.head_dim,
5727                    )
5728                })
5729                .collect(),
5730        );
5731
5732        let result = decoder.forward_batch_last_paged(&prompt, 0, &mut paged_caches, &stores);
5733        assert!(result.is_err(), "layer 1's store cannot hold the prompt");
5734        for (i, cache) in paged_caches.iter().enumerate() {
5735            assert_eq!(cache.seq_len(), 0, "layer {i} must be untouched");
5736            assert!(cache.block_table().is_empty(), "layer {i} holds no block");
5737        }
5738        for (i, expected) in [8usize, 2].into_iter().enumerate() {
5739            assert_eq!(stores.free_blocks(i), expected, "layer {i} leaked no block");
5740        }
5741    }
5742
5743    /// Chunked prefill: two calls appending into the same sequence must
5744    /// equal one call over the concatenation.
5745    ///
5746    /// This is the case the part-full tail block breaks if
5747    /// `to_contiguous` or the reservation is wrong, and it is how the
5748    /// serving path actually prefills long prompts.
5749    #[test]
5750    fn two_paged_prefill_chunks_equal_one_call_over_the_whole_prompt() {
5751        let decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
5752        let prompt = [3usize, 1, 4, 1, 5, 9, 2];
5753        let split = 3;
5754
5755        let run = |chunks: &[&[usize]]| {
5756            let mut caches: Vec<PagedKvCache> = (0..2).map(|_| PagedKvCache::new()).collect();
5757            let stores = SharedPagedKv::from_stores(
5758                (0..2)
5759                    .map(|_| {
5760                        PagedKvStore::new(2, 16, decoder.config.n_kv_heads, decoder.config.head_dim)
5761                    })
5762                    .collect(),
5763            );
5764            let mut pos = 0;
5765            let mut last = Vec::new();
5766            for chunk in chunks {
5767                last = decoder
5768                    .forward_batch_last_paged(chunk, pos, &mut caches, &stores)
5769                    .expect("sized generously");
5770                pos += chunk.len();
5771            }
5772            last
5773        };
5774
5775        let whole = run(&[&prompt]);
5776        let chunked = run(&[&prompt[..split], &prompt[split..]]);
5777        assert_eq!(whole.len(), chunked.len());
5778        for (x, y) in whole.iter().zip(chunked.iter()) {
5779            assert_eq!(
5780                x.to_bits(),
5781                y.to_bits(),
5782                "a chunked prefill must equal one call over the same tokens"
5783            );
5784        }
5785    }
5786
5787    fn paged_matches_contiguous(config: ModelConfig) {
5788        paged_matches_contiguous_with(config, |_| {});
5789    }
5790
5791    /// [`paged_matches_contiguous`] for the features that live on the
5792    /// WEIGHTS rather than in the config, and so cannot be switched on
5793    /// by handing a different `ModelConfig` in.
5794    fn paged_matches_contiguous_with(config: ModelConfig, prepare: impl FnOnce(&mut Decoder)) {
5795        let n_layers = 2;
5796        let mut decoder = Decoder::new_random_small(config, n_layers, 10);
5797        prepare(&mut decoder);
5798        let decoder = decoder;
5799
5800        let mut caches: Vec<KvCache> = (0..n_layers)
5801            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5802            .collect();
5803        let steps = [3usize, 5, 7, 2, 9, 1];
5804        let mut plain_logits = Vec::new();
5805        for (pos, &tok) in steps.iter().enumerate() {
5806            plain_logits.push(decoder.forward_token(tok, pos, &mut caches));
5807        }
5808
5809        let block_size = 2;
5810        let mut paged_caches: Vec<PagedKvCache> =
5811            (0..n_layers).map(|_| PagedKvCache::new()).collect();
5812        let stores = SharedPagedKv::from_stores(
5813            (0..n_layers)
5814                .map(|_| {
5815                    PagedKvStore::new(
5816                        block_size,
5817                        /* total_blocks = */ 16,
5818                        decoder.config.n_kv_heads,
5819                        decoder.config.head_dim,
5820                    )
5821                })
5822                .collect(),
5823        );
5824        let mut paged_logits = Vec::new();
5825        for (pos, &tok) in steps.iter().enumerate() {
5826            paged_logits.push(
5827                decoder
5828                    .forward_token_paged(tok, pos, &mut paged_caches, &stores)
5829                    .expect("store sized generously, must not exhaust"),
5830            );
5831        }
5832
5833        assert_eq!(plain_logits.len(), paged_logits.len());
5834        for (a, b) in plain_logits.iter().zip(paged_logits.iter()) {
5835            assert_eq!(a.len(), b.len());
5836            for (x, y) in a.iter().zip(b.iter()) {
5837                assert_eq!(
5838                    x.to_bits(),
5839                    y.to_bits(),
5840                    "paged decode must be bit-identical to contiguous decode"
5841                );
5842            }
5843        }
5844    }
5845
5846    /// The single most important correctness property of
5847    /// `forward_batch`: batching positions together for shared matmuls
5848    /// must produce EXACTLY the same result as processing them one at
5849    /// a time with `forward_token`, since causal masking guarantees
5850    /// position `i` only ever sees positions `<= i`. If this test
5851    /// fails, `forward_batch` is not a safe drop-in replacement for
5852    /// sequential decode, which would make speculative decoding built
5853    /// on top of it produce silently wrong output.
5854    #[test]
5855    fn forward_batch_matches_sequential_forward_token_exactly() {
5856        let cfg = tiny_test_config();
5857        let vocab = 8;
5858        let tokens = [1usize, 3, 5, 2, 7];
5859
5860        let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
5861        let mut caches_a: Vec<KvCache> = (0..2)
5862            .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5863            .collect();
5864        let sequential: Vec<Vec<f32>> = tokens
5865            .iter()
5866            .enumerate()
5867            .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
5868            .collect();
5869
5870        // A second decoder built with the same seed produces identical
5871        // weights (Decoder::new_random_small is deterministic), so
5872        // this is a fair like-for-like comparison against a fresh
5873        // cache rather than reusing decoder_a's now-mutated cache.
5874        let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
5875        let mut caches_b: Vec<KvCache> = (0..2)
5876            .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5877            .collect();
5878        let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
5879
5880        assert_eq!(batched.len(), sequential.len());
5881        for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
5882            assert_eq!(seq_logits.len(), batch_logits.len());
5883            for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
5884                assert!(
5885                    (s - b).abs() < 1e-3,
5886                    "position {pos}, logit {i}: sequential={s} batched={b}"
5887                );
5888            }
5889        }
5890    }
5891
5892    /// `forward_batch_last` exists to skip the vocabulary projection for
5893    /// every position but the last, so the one thing that must hold is
5894    /// that the row it *does* produce is the same row `forward_batch`
5895    /// would have produced. It must also leave the KV cache in the same
5896    /// state -- prefill's whole purpose -- which is checked by decoding
5897    /// one more token from each cache and comparing.
5898    #[test]
5899    fn forward_batch_last_matches_the_final_row_of_forward_batch() {
5900        let cfg = tiny_test_config();
5901        let vocab = 16;
5902        let tokens = vec![1usize, 4, 7, 2, 9];
5903
5904        let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
5905        let mut caches_a: Vec<KvCache> = (0..2)
5906            .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5907            .collect();
5908        let all_rows = decoder_a.forward_batch(&tokens, 0, &mut caches_a);
5909
5910        let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
5911        let mut caches_b: Vec<KvCache> = (0..2)
5912            .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5913            .collect();
5914        let last = decoder_b.forward_batch_last(&tokens, 0, &mut caches_b);
5915
5916        let expected = all_rows.last().expect("one row per prompt token");
5917        assert_eq!(last.len(), expected.len());
5918        for (i, (a, b)) in expected.iter().zip(last.iter()).enumerate() {
5919            assert!(
5920                (a - b).abs() < 1e-4,
5921                "logit {i}: forward_batch={a} forward_batch_last={b}"
5922            );
5923        }
5924
5925        // Same KV state: the next token's logits must agree too.
5926        let next_a = decoder_a.forward_token(3, tokens.len(), &mut caches_a);
5927        let next_b = decoder_b.forward_token(3, tokens.len(), &mut caches_b);
5928        for (i, (a, b)) in next_a.iter().zip(next_b.iter()).enumerate() {
5929            assert!(
5930                (a - b).abs() < 1e-4,
5931                "post-prefill decode logit {i}: {a} vs {b}"
5932            );
5933        }
5934
5935        // Empty prompt is the degenerate case both paths must survive.
5936        let mut caches_c: Vec<KvCache> = (0..2)
5937            .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5938            .collect();
5939        assert!(decoder_b
5940            .forward_batch_last(&[], 0, &mut caches_c)
5941            .is_empty());
5942    }
5943
5944    /// `forward_multi_seq`'s core correctness property: batching N
5945    /// independent sequences (different token histories, different
5946    /// current positions, different KV caches) together must produce
5947    /// EXACTLY the same per-sequence output as running each sequence
5948    /// through `forward_token` alone, one step at a time. This is what
5949    /// makes continuous batching safe -- no sequence's attention may
5950    /// ever be perturbed by another sequence sharing its batched
5951    /// matmul step.
5952    /// The PAGED batch step must equal the contiguous one, bit for bit.
5953    ///
5954    /// Continuous batching and paging are independent choices, so a
5955    /// deployment can have either, both or neither; if they disagree,
5956    /// the answer depends on two switches nobody thinks of as changing
5957    /// the model. Every sequence here is at a different position with a
5958    /// different length, which is the case the batched path exists for
5959    /// and the one where a shared-KV mistake would surface.
5960    #[test]
5961    fn a_paged_multi_seq_step_is_bit_identical_to_the_contiguous_one() {
5962        for cfg in [
5963            tiny_test_config(),
5964            {
5965                let mut c = tiny_test_config();
5966                c.sliding_window = Some(2);
5967                c.swa_pattern = None;
5968                c
5969            },
5970            {
5971                let mut c = tiny_test_config();
5972                c.embedding_scale = Some(7.5);
5973                c.final_logit_softcap = Some(0.05);
5974                c.attn_logit_softcap = Some(0.05);
5975                c
5976            },
5977        ] {
5978            let n_layers = 2;
5979            let decoder = Decoder::new_random_small(cfg, n_layers, 10);
5980            let histories: [&[usize]; 3] = [&[1, 3, 5], &[2, 7], &[4, 4, 4, 6]];
5981            let next = [6usize, 1, 2];
5982
5983            // Contiguous: build each sequence's history, then one step.
5984            let mut contiguous: Vec<Vec<KvCache>> = histories
5985                .iter()
5986                .map(|h| {
5987                    let mut caches: Vec<KvCache> = (0..n_layers)
5988                        .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5989                        .collect();
5990                    for (pos, &tok) in h.iter().enumerate() {
5991                        decoder.forward_token(tok, pos, &mut caches);
5992                    }
5993                    caches
5994                })
5995                .collect();
5996            let positions: Vec<usize> = histories.iter().map(|h| h.len()).collect();
5997            let want = decoder.forward_multi_seq(&next, &positions, &mut contiguous);
5998
5999            // Paged: same histories through the paged decode path, then
6000            // one batched step over the shared store.
6001            let stores = SharedPagedKv::new(
6002                n_layers,
6003                /* block_size = */ 2,
6004                /* blocks_per_layer = */ 64,
6005                decoder.config.n_kv_heads,
6006                decoder.config.head_dim,
6007            );
6008            let mut paged: Vec<Vec<PagedKvCache>> = histories
6009                .iter()
6010                .map(|h| {
6011                    let mut caches: Vec<PagedKvCache> =
6012                        (0..n_layers).map(|_| PagedKvCache::new()).collect();
6013                    for (pos, &tok) in h.iter().enumerate() {
6014                        decoder
6015                            .forward_token_paged(tok, pos, &mut caches, &stores)
6016                            .expect("sized generously");
6017                    }
6018                    caches
6019                })
6020                .collect();
6021            let got = decoder.forward_multi_seq_kv(
6022                &next,
6023                &positions,
6024                &mut MultiSeqKv::Paged {
6025                    caches: &mut paged,
6026                    stores: &stores,
6027                },
6028            );
6029
6030            assert_eq!(want.len(), got.len());
6031            for (s, (a, b)) in want.iter().zip(got.iter()).enumerate() {
6032                assert_eq!(a.len(), b.len(), "sequence {s}: logit count");
6033                for (x, y) in a.iter().zip(b.iter()) {
6034                    assert_eq!(
6035                        x.to_bits(),
6036                        y.to_bits(),
6037                        "sequence {s}: paged batching changed the answer"
6038                    );
6039                }
6040            }
6041        }
6042    }
6043
6044    #[test]
6045    fn forward_multi_seq_matches_independent_forward_token_per_sequence() {
6046        let cfg = tiny_test_config();
6047        let vocab = 8;
6048        // 3 independent sequences, deliberately different lengths/
6049        // histories/current tokens, so no two sequences are at the
6050        // same position when batched together.
6051        let seq_histories: [&[usize]; 3] = [&[1, 3, 5], &[2, 7], &[4, 4, 4, 6]];
6052
6053        let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
6054        let mut independent_logits: Vec<Vec<f32>> = Vec::new();
6055        for history in seq_histories.iter() {
6056            let mut caches: Vec<KvCache> = (0..2)
6057                .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
6058                .collect();
6059            let mut logits = Vec::new();
6060            for (pos, &tok) in history.iter().enumerate() {
6061                logits = decoder_a.forward_token(tok, pos, &mut caches);
6062            }
6063            independent_logits.push(logits);
6064        }
6065
6066        // Same seed -> identical weights, fresh caches for a fair
6067        // comparison (mirrors forward_batch_matches_sequential_forward_token_exactly).
6068        let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
6069        let mut per_seq_caches: Vec<Vec<KvCache>> = seq_histories
6070            .iter()
6071            .map(|_| {
6072                (0..2)
6073                    .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
6074                    .collect()
6075            })
6076            .collect();
6077
6078        // Feed every sequence's prefix (all but its last token)
6079        // through forward_multi_seq one shared step at a time, then
6080        // do a final batched step for the last token of every
6081        // sequence so all three arrive at their final position in
6082        // the same batched call -- exercising genuinely different
6083        // per-sequence positions/histories within one batch, not just
6084        // parallel identical-length sequences.
6085        let max_len = seq_histories.iter().map(|h| h.len()).max().unwrap();
6086        let mut batched_logits: Vec<Vec<f32>> = vec![Vec::new(); seq_histories.len()];
6087        for step in 0..max_len {
6088            let mut tokens = Vec::new();
6089            let mut positions = Vec::new();
6090            let mut active: Vec<usize> = Vec::new();
6091            for (s, history) in seq_histories.iter().enumerate() {
6092                if step < history.len() {
6093                    tokens.push(history[step]);
6094                    positions.push(step);
6095                    active.push(s);
6096                }
6097            }
6098            if tokens.is_empty() {
6099                continue;
6100            }
6101            let mut active_caches: Vec<Vec<KvCache>> = active
6102                .iter()
6103                .map(|&s| std::mem::take(&mut per_seq_caches[s]))
6104                .collect();
6105            let step_logits = decoder_b.forward_multi_seq(&tokens, &positions, &mut active_caches);
6106            for ((&s, caches), logits) in active.iter().zip(active_caches).zip(step_logits) {
6107                per_seq_caches[s] = caches;
6108                batched_logits[s] = logits;
6109            }
6110        }
6111
6112        assert_eq!(batched_logits.len(), independent_logits.len());
6113        for (s, (seq_logits, batch_logits)) in independent_logits
6114            .iter()
6115            .zip(batched_logits.iter())
6116            .enumerate()
6117        {
6118            assert_eq!(seq_logits.len(), batch_logits.len());
6119            for (i, (a, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
6120                assert!(
6121                    (a - b).abs() < 1e-3,
6122                    "sequence {s}, logit {i}: independent={a} batched={b}"
6123                );
6124            }
6125        }
6126    }
6127
6128    /// OLMoE-style QK-norm (`attn_q_norm`/`attn_k_norm`, see `AttnWeights`'
6129    /// doc comment): with both set, `forward_batch` must still match
6130    /// sequential `forward_token` calls exactly -- the same consistency
6131    /// property `forward_batch_matches_sequential_forward_token_exactly`
6132    /// checks for the no-QK-norm path, now exercising the norm-applied
6133    /// per-row slicing (`q_batch.chunks_mut(q_width)`,
6134    /// `k_batch.chunks_mut(kv_width)`) instead of trusting it by
6135    /// inspection.
6136    #[test]
6137    fn forward_batch_matches_forward_token_with_qk_norm_present() {
6138        let cfg = tiny_test_config();
6139        let vocab = 8;
6140        let tokens = [1usize, 3, 5, 2, 7];
6141        let q_width = cfg.n_heads * cfg.head_dim;
6142        let kv_width = cfg.n_kv_heads * cfg.head_dim;
6143
6144        let mut decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
6145        for layer in &mut decoder_a.layers {
6146            layer.attn.q_norm = Some((0..q_width).map(|i| 1.0 + i as f32 * 0.1).collect());
6147            layer.attn.k_norm = Some((0..kv_width).map(|i| 0.5 + i as f32 * 0.05).collect());
6148        }
6149        let mut caches_a: Vec<KvCache> = (0..2)
6150            .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
6151            .collect();
6152        let sequential: Vec<Vec<f32>> = tokens
6153            .iter()
6154            .enumerate()
6155            .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
6156            .collect();
6157
6158        let mut decoder_b = Decoder::new_random_small(cfg, 2, vocab);
6159        for layer in &mut decoder_b.layers {
6160            layer.attn.q_norm = Some((0..q_width).map(|i| 1.0 + i as f32 * 0.1).collect());
6161            layer.attn.k_norm = Some((0..kv_width).map(|i| 0.5 + i as f32 * 0.05).collect());
6162        }
6163        let mut caches_b: Vec<KvCache> = (0..2)
6164            .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
6165            .collect();
6166        let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
6167
6168        assert_eq!(batched.len(), sequential.len());
6169        for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
6170            for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
6171                assert!(
6172                    (s - b).abs() < 1e-3,
6173                    "position {pos}, logit {i}: sequential={s} batched={b}"
6174                );
6175            }
6176        }
6177    }
6178
6179    /// QK-norm being present must actually change the output -- otherwise
6180    /// the `Some(...)` branches in `forward_token`/`forward_batch` could
6181    /// silently be dead code and this feature would ship unverified. Must
6182    /// decode at least 2 positions: at position 0 with a fresh cache,
6183    /// causal softmax has exactly one candidate (the token attending to
6184    /// itself) and always evaluates to weight 1.0 regardless of the Q*K
6185    /// dot product -- so the attention output there is Q/K-invariant by
6186    /// construction, and a single-position version of this test would
6187    /// pass even with `q_norm`/`k_norm` silently never applied.
6188    #[test]
6189    fn qk_norm_present_changes_output_versus_absent() {
6190        let cfg = tiny_test_config();
6191        let vocab = 8;
6192        let q_width = cfg.n_heads * cfg.head_dim;
6193        let kv_width = cfg.n_kv_heads * cfg.head_dim;
6194        let tokens = [3usize, 5];
6195
6196        let without_norm = Decoder::new_random_small(cfg.clone(), 1, vocab);
6197        let mut with_norm = Decoder::new_random_small(cfg, 1, vocab);
6198        for layer in &mut with_norm.layers {
6199            layer.attn.q_norm = Some(vec![2.0; q_width]);
6200            layer.attn.k_norm = Some(vec![2.0; kv_width]);
6201        }
6202
6203        let mut caches_a: Vec<KvCache> = (0..1)
6204            .map(|_| KvCache::new(without_norm.config.n_kv_heads, without_norm.config.head_dim))
6205            .collect();
6206        let mut caches_b: Vec<KvCache> = (0..1)
6207            .map(|_| KvCache::new(with_norm.config.n_kv_heads, with_norm.config.head_dim))
6208            .collect();
6209
6210        let mut out_a = Vec::new();
6211        let mut out_b = Vec::new();
6212        for (pos, &t) in tokens.iter().enumerate() {
6213            out_a = without_norm.forward_token(t, pos, &mut caches_a);
6214            out_b = with_norm.forward_token(t, pos, &mut caches_b);
6215        }
6216
6217        let differs = out_a
6218            .iter()
6219            .zip(out_b.iter())
6220            .any(|(a, b)| (a - b).abs() > 1e-4);
6221        assert!(
6222            differs,
6223            "QK-norm weights changed nothing -- forward_token likely isn't applying q_norm/k_norm"
6224        );
6225    }
6226
6227    /// Qwen2/Qwen2-MoE-family QKV attention bias (`AttnWeights::q_bias`/
6228    /// `k_bias`/`v_bias`): a real, previously-unhandled gap found by
6229    /// running ferrox's generic GGUF loader against a real downloaded
6230    /// Qwen1.5-MoE-A2.7B-Chat checkpoint, which produced fluent-but-wrong
6231    /// output because these real `attn_{q,k,v}.bias` tensors were
6232    /// silently never added anywhere. Same two real properties checked
6233    /// as the QK-norm tests above: (1) `forward_batch` must match
6234    /// sequential `forward_token` exactly with bias present (batched
6235    /// per-row broadcast must be correct, not just the single-token
6236    /// path), and (2) bias must actually change the output at position
6237    /// 0 or later (not silently dead code) -- checked at position 1
6238    /// specifically, since position 0's causal softmax has exactly one
6239    /// candidate and is Q/K-invariant regardless of any additive bias
6240    /// shifting Q/K, for the same reason the QK-norm test above needs
6241    /// >=2 positions.
6242    #[test]
6243    fn forward_batch_matches_forward_token_with_qkv_bias_present() {
6244        let cfg = tiny_test_config();
6245        let vocab = 8;
6246        let tokens = [1usize, 3, 5, 2, 7];
6247        let q_width = cfg.n_heads * cfg.head_dim;
6248        let kv_width = cfg.n_kv_heads * cfg.head_dim;
6249
6250        let mut decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
6251        for layer in &mut decoder_a.layers {
6252            layer.attn.q_bias = Some((0..q_width).map(|i| 0.3 + i as f32 * 0.02).collect());
6253            layer.attn.k_bias = Some((0..kv_width).map(|i| -0.2 + i as f32 * 0.03).collect());
6254            layer.attn.v_bias = Some((0..kv_width).map(|i| 0.1 - i as f32 * 0.01).collect());
6255        }
6256        let mut caches_a: Vec<KvCache> = (0..2)
6257            .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
6258            .collect();
6259        let sequential: Vec<Vec<f32>> = tokens
6260            .iter()
6261            .enumerate()
6262            .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
6263            .collect();
6264
6265        let mut decoder_b = Decoder::new_random_small(cfg, 2, vocab);
6266        for layer in &mut decoder_b.layers {
6267            layer.attn.q_bias = Some((0..q_width).map(|i| 0.3 + i as f32 * 0.02).collect());
6268            layer.attn.k_bias = Some((0..kv_width).map(|i| -0.2 + i as f32 * 0.03).collect());
6269            layer.attn.v_bias = Some((0..kv_width).map(|i| 0.1 - i as f32 * 0.01).collect());
6270        }
6271        let mut caches_b: Vec<KvCache> = (0..2)
6272            .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
6273            .collect();
6274        let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
6275
6276        assert_eq!(batched.len(), sequential.len());
6277        for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
6278            for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
6279                assert!(
6280                    (s - b).abs() < 1e-3,
6281                    "position {pos}, logit {i}: sequential={s} batched={b}"
6282                );
6283            }
6284        }
6285    }
6286
6287    #[test]
6288    fn qkv_bias_present_changes_output_versus_absent() {
6289        let cfg = tiny_test_config();
6290        let vocab = 8;
6291        let q_width = cfg.n_heads * cfg.head_dim;
6292        let kv_width = cfg.n_kv_heads * cfg.head_dim;
6293        let tokens = [3usize, 5];
6294
6295        let without_bias = Decoder::new_random_small(cfg.clone(), 1, vocab);
6296        let mut with_bias = Decoder::new_random_small(cfg, 1, vocab);
6297        for layer in &mut with_bias.layers {
6298            layer.attn.q_bias = Some(vec![0.5; q_width]);
6299            layer.attn.k_bias = Some(vec![0.5; kv_width]);
6300            layer.attn.v_bias = Some(vec![0.5; kv_width]);
6301        }
6302
6303        let mut caches_a: Vec<KvCache> = (0..1)
6304            .map(|_| KvCache::new(without_bias.config.n_kv_heads, without_bias.config.head_dim))
6305            .collect();
6306        let mut caches_b: Vec<KvCache> = (0..1)
6307            .map(|_| KvCache::new(with_bias.config.n_kv_heads, with_bias.config.head_dim))
6308            .collect();
6309
6310        let mut out_a = Vec::new();
6311        let mut out_b = Vec::new();
6312        for (pos, &t) in tokens.iter().enumerate() {
6313            out_a = without_bias.forward_token(t, pos, &mut caches_a);
6314            out_b = with_bias.forward_token(t, pos, &mut caches_b);
6315        }
6316
6317        let differs = out_a
6318            .iter()
6319            .zip(out_b.iter())
6320            .any(|(a, b)| (a - b).abs() > 1e-4);
6321        assert!(
6322            differs,
6323            "QKV bias changed nothing -- forward_token likely isn't applying q_bias/k_bias/v_bias"
6324        );
6325    }
6326
6327    #[test]
6328    fn forward_batch_and_forward_token_leave_kv_caches_in_the_same_state() {
6329        let cfg = tiny_test_config();
6330        let tokens = [2usize, 4, 6];
6331
6332        let decoder_a = Decoder::new_random_small(cfg.clone(), 2, 8);
6333        let mut caches_a: Vec<KvCache> = (0..2)
6334            .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
6335            .collect();
6336        for (pos, &t) in tokens.iter().enumerate() {
6337            decoder_a.forward_token(t, pos, &mut caches_a);
6338        }
6339
6340        let decoder_b = Decoder::new_random_small(cfg, 2, 8);
6341        let mut caches_b: Vec<KvCache> = (0..2)
6342            .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
6343            .collect();
6344        decoder_b.forward_batch(&tokens, 0, &mut caches_b);
6345
6346        for (ca, cb) in caches_a.iter().zip(caches_b.iter()) {
6347            assert_eq!(ca.seq_len, cb.seq_len);
6348            assert_eq!(ca.k.len(), cb.k.len());
6349            for (a, b) in ca.k.iter().zip(cb.k.iter()) {
6350                assert!((a - b).abs() < 1e-4);
6351            }
6352        }
6353    }
6354
6355    /// Same architecture shape as `tiny_test_config` but genuinely
6356    /// dense (one expert, no shared experts) -- the shape every non-MoE
6357    /// model, and every DeepSeek-style leading dense layer, loads as.
6358    /// Exercises `Decoder::is_dense_layer`'s fast path.
6359    fn tiny_dense_test_config() -> ModelConfig {
6360        let mut cfg = tiny_test_config();
6361        cfg.moe.n_experts = 1;
6362        cfg.moe.n_experts_active = 1;
6363        cfg.moe.n_shared_experts = 0;
6364        cfg
6365    }
6366
6367    #[test]
6368    fn dense_layer_forward_pass_produces_finite_logits_of_correct_shape() {
6369        let vocab = 10;
6370        let decoder = Decoder::new_random_small(tiny_dense_test_config(), 2, vocab);
6371        let mut caches: Vec<KvCache> = (0..2)
6372            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
6373            .collect();
6374
6375        let logits = decoder.forward_token(3, 0, &mut caches);
6376        assert_eq!(logits.len(), vocab);
6377        assert!(
6378            logits.iter().all(|v| v.is_finite()),
6379            "logits must not contain NaN/Inf"
6380        );
6381    }
6382
6383    #[test]
6384    fn dense_layer_forward_batch_matches_sequential_forward_token_exactly() {
6385        let cfg = tiny_dense_test_config();
6386        let vocab = 8;
6387        let tokens = [1usize, 3, 5, 2, 7];
6388
6389        let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
6390        let mut caches_a: Vec<KvCache> = (0..2)
6391            .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
6392            .collect();
6393        let sequential: Vec<Vec<f32>> = tokens
6394            .iter()
6395            .enumerate()
6396            .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
6397            .collect();
6398
6399        let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
6400        let mut caches_b: Vec<KvCache> = (0..2)
6401            .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
6402            .collect();
6403        let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
6404
6405        assert_eq!(batched.len(), sequential.len());
6406        for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
6407            for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
6408                assert!(
6409                    (s - b).abs() < 1e-3,
6410                    "position {pos}, logit {i}: sequential={s} batched={b}"
6411                );
6412            }
6413        }
6414    }
6415
6416    #[test]
6417    fn dense_layer_fast_path_still_records_expert_zero_activations() {
6418        // The dense fast path bypasses `route_top_k` entirely, but
6419        // must still record an activation for expert 0 every step --
6420        // `MoeWeights::placement_plan` and hotness-based GPU placement
6421        // depend on this being real for every model shape, not just
6422        // genuinely-MoE ones.
6423        let decoder = Decoder::new_random_small(tiny_dense_test_config(), 1, 8);
6424        let mut caches: Vec<KvCache> = (0..1)
6425            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
6426            .collect();
6427
6428        decoder.forward_token(0, 0, &mut caches);
6429        decoder.forward_token(1, 1, &mut caches);
6430        decoder.forward_token(2, 2, &mut caches);
6431
6432        let count =
6433            decoder.layers[0].moe.activation_counts[0].load(std::sync::atomic::Ordering::Relaxed);
6434        assert_eq!(count, 3);
6435    }
6436
6437    #[test]
6438    fn forward_batch_with_empty_tokens_returns_empty() {
6439        let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
6440        let mut caches: Vec<KvCache> = (0..2)
6441            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
6442            .collect();
6443        let out = decoder.forward_batch(&[], 0, &mut caches);
6444        assert!(out.is_empty());
6445    }
6446
6447    #[test]
6448    fn forward_batch_continues_correctly_after_prior_forward_token_calls() {
6449        // Realistic usage pattern: some tokens processed one at a time
6450        // (e.g. the first generated token), then a batch verifying
6451        // several draft tokens at once, continuing from the same
6452        // cache. The batch's positions must be numbered starting from
6453        // wherever the cache left off, not from zero.
6454        let cfg = tiny_test_config();
6455
6456        let decoder_a = Decoder::new_random_small(cfg.clone(), 2, 8);
6457        let mut caches_a: Vec<KvCache> = (0..2)
6458            .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
6459            .collect();
6460        decoder_a.forward_token(1, 0, &mut caches_a);
6461        decoder_a.forward_token(3, 1, &mut caches_a);
6462        let seq_next = decoder_a.forward_token(5, 2, &mut caches_a);
6463
6464        let decoder_b = Decoder::new_random_small(cfg, 2, 8);
6465        let mut caches_b: Vec<KvCache> = (0..2)
6466            .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
6467            .collect();
6468        decoder_b.forward_token(1, 0, &mut caches_b);
6469        let batch_next = decoder_b.forward_batch(&[3, 5], 1, &mut caches_b);
6470
6471        for (s, b) in seq_next.iter().zip(batch_next[1].iter()) {
6472            assert!((s - b).abs() < 1e-3, "sequential={s} batched={b}");
6473        }
6474    }
6475
6476    /// `PlacementPlan::from_budget` is
6477    /// real and tested in isolation, but only meaningful once it's fed
6478    /// genuinely observed per-expert activation counts rather than
6479    /// zeros. This proves the full loop: run real forward passes,
6480    /// confirm `MoeWeights::activation_counts` actually reflects what
6481    /// `route_top_k` selected, and confirm `placement_plan` prioritizes
6482    /// the expert that was genuinely hottest -- not just that the
6483    /// budget/size arithmetic works on synthetic inputs.
6484    #[test]
6485    fn placement_plan_reflects_real_observed_expert_activations() {
6486        let cfg = tiny_test_config(); // 6 experts, top-2 active/token
6487        let decoder = Decoder::new_random_small(cfg, 2, 16);
6488        let mut caches: Vec<KvCache> = (0..2)
6489            .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
6490            .collect();
6491
6492        let n_calls = 20;
6493        for pos in 0..n_calls {
6494            decoder.forward_token(pos % 16, pos, &mut caches);
6495        }
6496
6497        let layer0 = &decoder.layers[0].moe;
6498        let counts: Vec<u64> = layer0
6499            .activation_counts
6500            .iter()
6501            .map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
6502            .collect();
6503        let total: u64 = counts.iter().sum();
6504        assert_eq!(
6505            total,
6506            (n_calls as u64) * (decoder.config.moe.n_experts_active as u64),
6507            "total recorded activations must equal calls * experts_active_per_call"
6508        );
6509
6510        // Ties are realistic at this small a sample size; break them the
6511        // same way `PlacementPlan::from_budget` does (lowest index
6512        // wins), so this assertion can't spuriously fail on a tie that
6513        // `from_budget` resolves differently than a naive `max_by_key`
6514        // (which returns the *last* max element) would.
6515        let hottest_count = *counts.iter().max().unwrap();
6516        let hottest_idx = counts.iter().position(|&c| c == hottest_count).unwrap();
6517        assert!(hottest_count > 0);
6518
6519        // A per-expert resident size big enough for exactly one expert.
6520        let per_expert_bytes = layer0.expert_bytes(0);
6521        let plan = layer0.placement_plan(per_expert_bytes as u64);
6522
6523        assert_eq!(
6524            plan.placement_for(hottest_idx),
6525            ferrox_moe::ExpertPlacement::GpuDevice(0),
6526            "the genuinely hottest expert (index {hottest_idx}, {hottest_count} activations) \
6527             must be the one the plan places on GPU when only one expert fits the budget"
6528        );
6529    }
6530}
6531
6532/// The Metal side of Phi-3/Phi-4's RoPE: partial rotary and LongRoPE's
6533/// `attn_factor` used to be a refusal in `layer_supports_metal_attn`
6534/// and are now two uniforms on [`ferrox_metal::attn::MetalRope`].
6535#[cfg(all(test, feature = "metal"))]
6536mod metal_rope_tests {
6537    use super::*;
6538
6539    fn phi_like_config() -> ModelConfig {
6540        let mut cfg = crate::config::test_dense_fixture();
6541        cfg.head_dim = 128;
6542        cfg.rope_layout = crate::config::RopeLayout::Neox;
6543        cfg.rope_dim = Some(96);
6544        cfg.rope_attn_factor = 1.1902381;
6545        cfg
6546    }
6547
6548    /// Both values must reach the kernels, and they must be the same two
6549    /// the CPU path reads — otherwise the backends compute different
6550    /// attention for the same weights, which is the whole reason the
6551    /// model was refused Metal in the first place.
6552    #[test]
6553    fn metal_rope_carries_partial_rotary_and_mscale() {
6554        let decoder = Decoder::new_random_small(phi_like_config(), 1, 32);
6555        let rope = decoder.metal_rope();
6556        assert_eq!(rope.layout, ferrox_metal::attn::MetalRopeLayout::Neox);
6557        assert_eq!(rope.rot_dim, Some(96));
6558        assert_eq!(rope.attn_factor, 1.1902381);
6559    }
6560
6561    /// `rope.dimension_count == head_dim` is "the whole head rotates",
6562    /// which must reach the kernel as `None` rather than as a width —
6563    /// same graph, one code path.
6564    #[test]
6565    fn rot_dim_equal_to_head_dim_becomes_none() {
6566        let mut cfg = phi_like_config();
6567        cfg.rope_dim = Some(cfg.head_dim);
6568        let decoder = Decoder::new_random_small(cfg, 1, 32);
6569        assert_eq!(decoder.metal_rope().rot_dim, None);
6570    }
6571
6572    /// A non-unit `attn_factor` is no longer a reason to refuse Metal;
6573    /// an odd `n_rot` still is, because ggml's `ggml_rope_impl` asserts
6574    /// an even width and the split-half pairing is otherwise undefined
6575    /// for the last channel.
6576    #[test]
6577    fn odd_rot_dim_is_still_refused_but_mscale_is_not() {
6578        let supported = |cfg: ModelConfig| {
6579            let d = Decoder::new_random_small(cfg, 1, 32);
6580            d.layer_supports_metal_attn(&d.layers[0])
6581        };
6582
6583        // The control: with no rope oddity the fixture is admitted, so
6584        // the two assertions below are about the rope config and not
6585        // about the fixture failing some other check.
6586        let mut plain = phi_like_config();
6587        plain.rope_dim = None;
6588        plain.rope_attn_factor = 1.0;
6589        assert!(supported(plain), "fixture must be Metal-eligible to start");
6590
6591        assert!(
6592            supported(phi_like_config()),
6593            "partial rotary + a non-unit attn_factor must no longer refuse Metal"
6594        );
6595
6596        let mut odd = phi_like_config();
6597        odd.rope_dim = Some(95);
6598        assert!(!supported(odd), "odd n_rot must keep the model off Metal");
6599    }
6600}