Skip to main content

ferrox_models/
decoder.rs

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