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