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