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