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