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