Skip to main content

ferrox_models/
decoder.rs

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