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