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