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