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