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