Skip to main content

ferrox_models/
decoder.rs

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