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