1use 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_paged,
20 causal_gqa_attention_prefill_shared_kv_windowed, causal_gqa_attention_softcap,
21 causal_gqa_attention_windowed_softcap,
22};
23use ferrox_core::cache::{KvCache, PagedKvCache, PagedKvStore, PagedStoreExhausted};
24use ferrox_core::matmul::{geglu, rms_norm, rms_norm_per_head, softcap_inplace};
25use rayon::prelude::*;
26
27#[cfg(feature = "cuda")]
31fn cuda_gqa_enabled() -> bool {
32 use std::sync::OnceLock;
33 static ENABLED: OnceLock<bool> = OnceLock::new();
34 *ENABLED.get_or_init(|| {
35 matches!(
36 std::env::var("FERROX_CUDA_GQA").ok().as_deref(),
37 Some("1") | Some("true") | Some("on")
38 )
39 })
40}
41use ferrox_core::tensor::Tensor;
42use ferrox_core::weight_matrix::WeightMatrix;
43use ferrox_moe::{
44 combine_expert_outputs, route_top_k, run_expert, run_expert_placed, ExpertPlacement,
45 ExpertWeights, PlacementPlan,
46};
47
48use crate::config::ModelConfig;
49
50pub struct AttnWeights {
51 pub q_proj: WeightMatrix, pub k_proj: WeightMatrix, pub v_proj: WeightMatrix, pub o_proj: WeightMatrix, pub norm_weight: Vec<f32>,
56 pub q_norm: Option<Vec<f32>>,
69 pub k_norm: Option<Vec<f32>>,
70 pub q_bias: Option<Vec<f32>>,
82 pub k_bias: Option<Vec<f32>>,
83 pub v_bias: Option<Vec<f32>>,
84 pub post_attn_norm: Option<Vec<f32>>,
88 pub post_ffn_norm: Option<Vec<f32>>,
90}
91
92pub enum ExpertBacking {
104 Resident(Vec<ExpertWeights>),
105 Stored {
106 store:
107 std::sync::Arc<ferrox_core::expert_store::ExpertStore<crate::loader::GgufExpertSource>>,
108 layouts: Vec<crate::loader::StoredExpertLayout>,
109 layer: u32,
110 },
111}
112
113impl ExpertBacking {
114 pub fn n_experts(&self) -> usize {
115 match self {
116 ExpertBacking::Resident(v) => v.len(),
117 ExpertBacking::Stored { layouts, .. } => layouts.len(),
118 }
119 }
120}
121
122pub struct MoeWeights {
123 pub router: WeightMatrix, pub experts: ExpertBacking,
125 pub shared_experts: Vec<ExpertWeights>,
126 pub shared_expert_gate: Option<Vec<f32>>,
143 pub norm_weight: Vec<f32>,
144 pub activation_counts: Vec<AtomicU64>,
151 #[cfg(feature = "metal")]
158 pub packed_q4: Option<MoePackedQ4Planes>,
159}
160
161#[cfg(feature = "metal")]
163pub struct MoePackedQ4Planes {
164 gate: ferrox_core::weight_matrix::WeightBytes,
165 up: ferrox_core::weight_matrix::WeightBytes,
166 down: ferrox_core::weight_matrix::WeightBytes,
167 gate_stride: usize,
168 up_stride: usize,
169 down_stride: usize,
170 n_experts: usize,
171 ffn_rows: usize,
172 hidden_rows: usize,
173 gate_row_bytes: usize,
174 down_row_bytes: usize,
175 gate_kind: &'static str,
176 up_kind: &'static str,
177 down_kind: &'static str,
178}
179
180#[cfg(feature = "metal")]
181impl MoePackedQ4Planes {
182 #[allow(clippy::too_many_arguments)]
183 pub(crate) fn new(
184 gate: ferrox_core::weight_matrix::WeightBytes,
185 up: ferrox_core::weight_matrix::WeightBytes,
186 down: ferrox_core::weight_matrix::WeightBytes,
187 gate_stride: usize,
188 up_stride: usize,
189 down_stride: usize,
190 n_experts: usize,
191 ffn_rows: usize,
192 hidden_rows: usize,
193 gate_kind: &'static str,
194 up_kind: &'static str,
195 down_kind: &'static str,
196 ) -> Self {
197 Self {
198 gate,
199 up,
200 down,
201 gate_stride,
202 up_stride,
203 down_stride,
204 n_experts,
205 ffn_rows,
206 hidden_rows,
207 gate_row_bytes: gate_stride / ffn_rows,
208 down_row_bytes: down_stride / hidden_rows,
209 gate_kind,
210 up_kind,
211 down_kind,
212 }
213 }
214
215 pub fn view(&self) -> ferrox_metal::gpu::MoePackedQ4<'_> {
216 ferrox_metal::gpu::MoePackedQ4 {
217 gate: self.gate.as_slice(),
218 up: self.up.as_slice(),
219 down: self.down.as_slice(),
220 gate_stride: self.gate_stride,
221 up_stride: self.up_stride,
222 down_stride: self.down_stride,
223 n_experts: self.n_experts,
224 ffn_rows: self.ffn_rows,
225 hidden_rows: self.hidden_rows,
226 gate_row_bytes: self.gate_row_bytes,
227 down_row_bytes: self.down_row_bytes,
228 gate_kind: self.gate_kind,
229 up_kind: self.up_kind,
230 down_kind: self.down_kind,
231 }
232 }
233}
234
235impl MoeWeights {
236 pub fn n_experts(&self) -> usize {
237 self.experts.n_experts()
238 }
239
240 pub fn expert_bytes(&self, e: usize) -> usize {
244 match &self.experts {
245 ExpertBacking::Resident(v) => {
246 let ex = &v[e];
247 ex.gate.resident_bytes() + ex.up.resident_bytes() + ex.down.resident_bytes()
248 }
249 ExpertBacking::Stored { layouts, .. } => layouts[e].total_bytes(),
250 }
251 }
252
253 pub fn with_expert<R>(&self, e: usize, f: impl FnOnce(&ExpertWeights) -> R) -> R {
258 match &self.experts {
259 ExpertBacking::Resident(v) => f(&v[e]),
260 ExpertBacking::Stored {
261 store,
262 layouts,
263 layer,
264 } => {
265 let lease = store
266 .acquire(ferrox_core::expert_store::ExpertKey {
267 layer: *layer,
268 expert: e as u32,
269 })
270 .unwrap_or_else(|err| {
271 panic!(
272 "expert store read failed for layer {layer} expert {e}: {err} \
273 (checkpoint file unreadable mid-decode)"
274 )
275 });
276 let tmp = layouts[e].materialize(&lease);
277 f(&tmp)
278 }
279 }
280 }
281
282 fn record_activations(&self, expert_ids: &[usize]) {
283 for &eid in expert_ids {
284 if let Some(counter) = self.activation_counts.get(eid) {
285 counter.fetch_add(1, Ordering::Relaxed);
286 }
287 }
288 }
289
290 pub fn placement_plan(&self, vram_budget_bytes: u64) -> PlacementPlan {
297 let sizes: Vec<usize> = (0..self.n_experts())
298 .map(|e| self.expert_bytes(e))
299 .collect();
300 let counts: Vec<u64> = self
301 .activation_counts
302 .iter()
303 .map(|c| c.load(Ordering::Relaxed))
304 .collect();
305 let has_observations = counts.iter().any(|&c| c > 0);
306 PlacementPlan::from_budget(
307 &sizes,
308 has_observations.then_some(counts.as_slice()),
309 vram_budget_bytes,
310 )
311 }
312}
313
314pub struct LayerWeights {
315 pub attn: AttnWeights,
316 pub moe: MoeWeights,
317}
318
319pub struct GptOssLayer {
335 pub attn_sinks: Vec<f32>,
337 pub o_bias: Vec<f32>,
339 pub router_bias: Vec<f32>,
341 pub expert_bias: Vec<ferrox_moe::ExpertBias>,
343}
344
345pub struct GptOssWeights {
347 pub layers: Vec<GptOssLayer>,
348}
349
350pub struct Decoder {
351 pub config: ModelConfig,
352 pub embedding: WeightMatrix,
359 pub layers: Vec<LayerWeights>,
360 pub final_norm: Vec<f32>,
361 pub output_head: WeightMatrix, pub gpu_vram_budget_bytes: Option<u64>,
382 pub gpt_oss: Option<GptOssWeights>,
389 #[cfg(feature = "metal")]
397 pub(crate) metal_attn_kv: std::sync::Mutex<Option<Vec<ferrox_metal::attn::MetalKvBuffers>>>,
398 pub execution_plan: crate::execution_plan::ExecutionPlan,
402 pub plan_cache: std::sync::Mutex<
405 std::collections::HashMap<
406 crate::execution_plan::PlanGeometry,
407 crate::execution_plan::FusedOpCaps,
408 >,
409 >,
410}
411
412struct Lcg(u64);
415impl Lcg {
416 fn new(seed: u64) -> Self {
417 Lcg(seed)
418 }
419 fn next_f32(&mut self) -> f32 {
420 self.0 ^= self.0 << 13;
422 self.0 ^= self.0 >> 7;
423 self.0 ^= self.0 << 17;
424 ((self.0 >> 40) as f32 / (1u64 << 24) as f32) - 0.5
425 }
426 fn vec(&mut self, n: usize) -> Vec<f32> {
427 (0..n).map(|_| self.next_f32() * 0.1).collect()
428 }
429}
430
431impl Decoder {
432 pub fn probe_kernels(&self) {
448 use ferrox_core::kernel_registry as reg;
449
450 if !reg::enabled() {
451 return;
452 }
453 self.embedding.probe_kernels("token_embd");
454 self.output_head.probe_kernels("output_head");
455 for layer in &self.layers {
456 layer.attn.q_proj.probe_kernels("attn_q");
457 layer.attn.k_proj.probe_kernels("attn_k");
458 layer.attn.v_proj.probe_kernels("attn_v");
459 layer.attn.o_proj.probe_kernels("attn_o");
460 layer.moe.router.probe_kernels("moe_router");
461 for e in &layer.moe.shared_experts {
462 e.gate.probe_kernels("shexp_gate");
463 e.up.probe_kernels("shexp_up");
464 e.down.probe_kernels("shexp_down");
465 }
466 if let ExpertBacking::Resident(experts) = &layer.moe.experts {
467 for e in experts {
468 e.gate.probe_kernels("ffn_gate");
469 e.up.probe_kernels("ffn_up");
470 e.down.probe_kernels("ffn_down");
471 }
472 }
473 }
474 reg::record_build(
480 reg::Lookup::new(
481 ferrox_core::weight_matrix::active_backend(),
482 reg::op::ENGINE_PREFILL_BATCH,
483 None,
484 )
485 .with_role("generic_decoder"),
486 reg::Outcome::Hit,
487 );
488 }
489
490 pub fn new_random_small(config: ModelConfig, n_layers: usize, vocab_size: usize) -> Self {
497 let mut rng = Lcg::new(42);
498 let mut config = config;
499 config.n_layers = n_layers;
500 config.vocab_size = vocab_size;
501 let hidden = config.hidden_dim;
502 let head_dim = config.head_dim;
503 let n_heads = config.n_heads;
504 let n_kv_heads = config.n_kv_heads;
505
506 let embedding = WeightMatrix::F32(Tensor::new(
507 rng.vec(vocab_size * hidden),
508 vec![vocab_size, hidden],
509 ));
510
511 let wm = |data: Vec<f32>, shape: Vec<usize>| WeightMatrix::F32(Tensor::new(data, shape));
512
513 let mut layers = Vec::with_capacity(n_layers);
514 for layer_idx in 0..n_layers {
515 let attn = AttnWeights {
516 q_proj: wm(
517 rng.vec(n_heads * head_dim * hidden),
518 vec![n_heads * head_dim, hidden],
519 ),
520 k_proj: wm(
521 rng.vec(n_kv_heads * head_dim * hidden),
522 vec![n_kv_heads * head_dim, hidden],
523 ),
524 v_proj: wm(
525 rng.vec(n_kv_heads * head_dim * hidden),
526 vec![n_kv_heads * head_dim, hidden],
527 ),
528 o_proj: wm(
529 rng.vec(hidden * n_heads * head_dim),
530 vec![hidden, n_heads * head_dim],
531 ),
532 norm_weight: vec![1.0; hidden],
533 q_norm: None,
534 k_norm: None,
535 q_bias: None,
536 k_bias: None,
537 v_bias: None,
538 post_attn_norm: None,
539 post_ffn_norm: None,
540 };
541
542 let is_dense_layer = config.layer_is_dense(layer_idx);
548 let n_experts = if is_dense_layer {
549 1
550 } else {
551 config.moe.n_experts
552 };
553 let n_shared = if is_dense_layer {
554 0
555 } else {
556 config.moe.n_shared_experts
557 };
558 let ffn_dim = config.moe.expert_ffn_dim;
559 let make_expert = |rng: &mut Lcg| ExpertWeights {
560 gate: WeightMatrix::F32(Tensor::new(
561 rng.vec(ffn_dim * hidden),
562 vec![ffn_dim, hidden],
563 )),
564 up: WeightMatrix::F32(Tensor::new(
565 rng.vec(ffn_dim * hidden),
566 vec![ffn_dim, hidden],
567 )),
568 down: WeightMatrix::F32(Tensor::new(
569 rng.vec(hidden * ffn_dim),
570 vec![hidden, ffn_dim],
571 )),
572 };
573 let experts: Vec<ExpertWeights> =
574 (0..n_experts).map(|_| make_expert(&mut rng)).collect();
575 let shared_experts = (0..n_shared).map(|_| make_expert(&mut rng)).collect();
576 let activation_counts = (0..experts.len()).map(|_| AtomicU64::new(0)).collect();
577
578 let moe = MoeWeights {
579 router: wm(rng.vec(n_experts * hidden), vec![n_experts, hidden]),
580 experts: ExpertBacking::Resident(experts),
581 shared_experts,
582 shared_expert_gate: None,
583 norm_weight: vec![1.0; hidden],
584 activation_counts,
585 #[cfg(feature = "metal")]
586 packed_q4: None,
587 };
588
589 layers.push(LayerWeights { attn, moe });
590 }
591
592 let final_norm = vec![1.0; hidden];
593 let output_head = wm(rng.vec(vocab_size * hidden), vec![vocab_size, hidden]);
594 let execution_plan = crate::execution_plan::ExecutionPlan::from_config(
595 &config,
596 crate::capability::DecoderFamily::StandardGqa,
597 crate::capability::MemoryKind::KvGqa,
598 crate::execution_plan::ExecutionPlan::probe_metal_caps(),
599 );
600
601 Decoder {
602 config,
603 embedding,
604 layers,
605 final_norm,
606 output_head,
607 gpu_vram_budget_bytes: None,
608 gpt_oss: None,
610 #[cfg(feature = "metal")]
611 metal_attn_kv: std::sync::Mutex::new(None),
612 execution_plan,
613 plan_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
614 }
615 }
616
617 fn apply_rope_head_theta(&self, slice: &mut [f32], pos: usize, theta: f32) {
625 use crate::config::RopeLayout;
626 let slice = match self.config.rope_dim {
633 Some(rot) if rot < slice.len() => &mut slice[..rot],
634 _ => slice,
635 };
636 match (self.config.rope_layout, &self.config.rope_freqs) {
637 (RopeLayout::Norm, Some(freq_factors)) => {
638 apply_rope_interleaved_with_freq_factors(slice, pos, theta, freq_factors)
639 }
640 (RopeLayout::Norm, None) => apply_rope_interleaved(slice, pos, theta),
641 (RopeLayout::Neox, Some(freq_factors)) => {
642 apply_rope_with_freq_factors(slice, pos, theta, freq_factors)
643 }
644 (RopeLayout::Neox, None) => apply_rope(slice, pos, theta),
645 }
646 }
647
648 fn apply_rope_head_layer(&self, slice: &mut [f32], pos: usize, layer_idx: usize) {
649 self.apply_rope_head_theta(slice, pos, self.config.layer_rope_theta(layer_idx))
650 }
651
652 #[inline]
663 fn apply_rope_attn_factor(&self, q: &mut [f32], k: &mut [f32]) {
664 let m = self.config.rope_attn_factor;
665 if m == 1.0 {
666 return;
667 }
668 let head_dim = self.config.head_dim;
678 let rot = self.config.rope_dim.unwrap_or(head_dim).min(head_dim);
679 for buf in [q, k] {
680 for head in buf.chunks_mut(head_dim) {
681 let n = rot.min(head.len());
682 for v in head[..n].iter_mut() {
683 *v *= m;
684 }
685 }
686 }
687 }
688
689 fn apply_qk_norm(&self, x: &[f32], weight: &[f32]) -> Vec<f32> {
691 use crate::capability::QkNormStyle;
692 match self.config.qk_norm_style {
693 QkNormStyle::WholeVector => rms_norm(x, weight, self.config.rms_norm_eps),
694 QkNormStyle::PerHead => {
695 rms_norm_per_head(x, weight, self.config.head_dim, self.config.rms_norm_eps)
696 }
697 }
698 }
699
700 #[cfg(feature = "metal")]
703 fn metal_matvec_launch<'a>(m: &'a WeightMatrix) -> Option<ferrox_metal::gpu::MatvecLaunch<'a>> {
704 match m {
705 WeightMatrix::F32(t) => {
706 let rows = t.shape[0];
707 let cols = t.shape[1];
708 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
709 ferrox_metal::gpu::matvec_launch_meta("F32")?;
710 let bytes = unsafe {
712 std::slice::from_raw_parts(t.data.as_ptr() as *const u8, t.data.len() * 4)
713 };
714 Some(ferrox_metal::gpu::MatvecLaunch {
715 kernel_src: src,
716 fn_name,
717 block_bytes,
718 block_elems,
719 weights: bytes,
720 rows,
721 row_bytes: cols * 4,
722 rows_per_tg,
723 })
724 }
725 WeightMatrix::Quantized {
726 data,
727 rows,
728 cols: _,
729 kind,
730 } => {
731 let kind_name = match kind {
732 ferrox_core::QuantKind::Q8_0 => "Q8_0",
733 ferrox_core::QuantKind::Q4_0 => "Q4_0",
734 ferrox_core::QuantKind::Q4K => "Q4_K",
735 ferrox_core::QuantKind::Q5K => "Q5_K",
736 ferrox_core::QuantKind::Q6K => "Q6_K",
737 ferrox_core::QuantKind::IQ4XS => "IQ4_XS",
738 _ => return None,
739 };
740 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
741 ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
742 let row_bytes = data.as_slice().len().checked_div(*rows).unwrap_or(0);
747 Some(ferrox_metal::gpu::MatvecLaunch {
748 kernel_src: src,
749 fn_name,
750 block_bytes,
751 block_elems,
752 weights: data.as_slice(),
753 rows: *rows,
754 row_bytes,
755 rows_per_tg,
756 })
757 }
758 _ => None,
759 }
760 }
761
762 #[cfg(feature = "metal")]
766 fn layer_supports_metal_attn(&self, layer: &LayerWeights) -> bool {
767 use crate::config::RopeLayout;
768 if self.gpt_oss.is_some() {
773 return false;
774 }
775 if !matches!(self.config.rope_layout, RopeLayout::Norm | RopeLayout::Neox) {
776 return false;
777 }
778 let q_len = self.config.n_heads * self.config.head_dim;
781 let k_len = self.config.n_kv_heads * self.config.head_dim;
782 let qk_norm_ok = |w: Option<&Vec<f32>>, vec_len: usize| -> bool {
783 match w {
784 None => true,
785 Some(w) if w.len() == self.config.head_dim => true,
786 Some(w) if w.len() == vec_len => true,
787 _ => false,
788 }
789 };
790 if !qk_norm_ok(layer.attn.q_norm.as_ref(), q_len)
791 || !qk_norm_ok(layer.attn.k_norm.as_ref(), k_len)
792 {
793 return false;
794 }
795 if self.config.head_dim > 256 {
800 return false;
801 }
802 if self.config.rope_dim.is_some() || self.config.rope_attn_factor != 1.0 {
809 return false;
810 }
811 Self::metal_matvec_launch(&layer.attn.q_proj).is_some()
812 && Self::metal_matvec_launch(&layer.attn.k_proj).is_some()
813 && Self::metal_matvec_launch(&layer.attn.v_proj).is_some()
814 && Self::metal_matvec_launch(&layer.attn.o_proj).is_some()
815 }
816
817 #[cfg(feature = "metal")]
820 fn layer_needs_metal_stack(&self, layer: &LayerWeights, layer_idx: usize) -> bool {
821 layer.attn.post_attn_norm.is_some()
822 || layer.attn.post_ffn_norm.is_some()
823 || self.config.layer_sliding_window(layer_idx).is_some()
824 || matches!(
825 self.config.ffn_activation,
826 crate::config::FfnActivation::Gelu
827 )
828 || self.config.layer_rope_theta(layer_idx) != self.config.rope_theta
829 }
830
831 #[cfg(feature = "metal")]
833 fn metal_attn_extras<'a>(&self, layer: &'a LayerWeights) -> ferrox_metal::attn::AttnExtras<'a> {
834 ferrox_metal::attn::AttnExtras {
835 q_bias: layer.attn.q_bias.as_deref(),
836 k_bias: layer.attn.k_bias.as_deref(),
837 v_bias: layer.attn.v_bias.as_deref(),
838 q_norm: layer.attn.q_norm.as_deref(),
839 k_norm: layer.attn.k_norm.as_deref(),
840 attn_logit_softcap: self.config.attn_logit_softcap,
841 }
842 }
843
844 #[cfg(feature = "metal")]
848 fn expert_residency_plan(&self, use_metal_attn: bool) -> Option<ferrox_moe::ResidencyPlan> {
849 if ferrox_core::metal_dense_enabled()
850 && ferrox_metal::attn::metal_attn_enabled()
851 && !use_metal_attn
852 {
853 return None;
854 }
855 self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b))
856 }
857
858 #[cfg(not(feature = "metal"))]
859 fn expert_residency_plan(&self, _use_metal_attn: bool) -> Option<ferrox_moe::ResidencyPlan> {
860 self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b))
861 }
862
863 #[cfg(feature = "metal")]
865 fn metal_rope_layout(&self) -> ferrox_metal::attn::MetalRopeLayout {
866 use crate::config::RopeLayout;
867 match self.config.rope_layout {
868 RopeLayout::Norm => ferrox_metal::attn::MetalRopeLayout::Norm,
869 RopeLayout::Neox => ferrox_metal::attn::MetalRopeLayout::Neox,
870 }
871 }
872
873 #[cfg(feature = "metal")]
875 fn layer_supports_metal_dense_ffn(layer: &LayerWeights) -> bool {
876 Self::is_dense_layer(layer)
877 && layer.moe.with_expert(0, |ex| {
878 Self::metal_matvec_launch(&ex.gate).is_some()
879 && Self::metal_matvec_launch(&ex.up).is_some()
880 && Self::metal_matvec_launch(&ex.down).is_some()
881 })
882 }
883
884 #[cfg(feature = "metal")]
888 fn metal_prefill_dense_layer_eligible(layer: &LayerWeights) -> bool {
889 Self::is_dense_layer(layer)
890 }
891
892 #[cfg(feature = "metal")]
893 fn metal_prefill_dense_swa_fits(
894 &self,
895 layer_idx: usize,
896 start_pos: usize,
897 batch_size: usize,
898 ) -> bool {
899 match self.config.layer_sliding_window(layer_idx) {
900 Some(window) => start_pos + batch_size <= window,
901 None => true,
902 }
903 }
904
905 #[cfg(feature = "metal")]
912 fn metal_prefill_moe<'a>(
913 layer: &'a LayerWeights,
914 config: &ModelConfig,
915 ) -> Option<ferrox_metal::gpu::PrefillMoeMetal<'a>> {
916 if !ferrox_metal::attn::metal_moe_stack_enabled()
917 || !ferrox_metal::attn::metal_moe_resident_enabled()
918 || Self::is_dense_layer(layer)
919 || !layer.moe.shared_experts.is_empty()
920 || !matches!(
921 config.ffn_activation,
922 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
923 )
924 || !matches!(config.moe.gating, ferrox_moe::GatingFunction::Softmax)
925 || config.moe.expert_group_count.is_some()
926 {
927 return None;
928 }
929 let ferrox_core::weight_matrix::WeightMatrix::F32(router) = &layer.moe.router else {
930 return None;
931 };
932 let packed = Self::moe_packed_q4(&layer.moe)?;
933 let moe = ferrox_metal::gpu::PrefillMoeMetal {
934 router_w: &router.data,
935 top_k: config.moe.n_experts_active,
936 renormalize: config.moe.norm_topk_prob,
937 packed,
938 };
939 moe.is_supported().then_some(moe)
940 }
941
942 #[cfg(feature = "metal")]
945 fn metal_prefill_ffn<'a>(
946 layer: &'a LayerWeights,
947 config: &ModelConfig,
948 ) -> Option<ferrox_metal::attn::PrefillFfnMetal<'a>> {
949 if let Some(moe) = Self::metal_prefill_moe(layer, config) {
950 return Some(ferrox_metal::attn::PrefillFfnMetal::Moe(moe));
951 }
952 if !Self::is_dense_layer(layer) {
953 return None;
954 }
955 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
956 return None;
957 };
958 let ex = experts.first()?;
959 Some(ferrox_metal::attn::PrefillFfnMetal::Dense {
960 gate: ex.gate.mul_mm_sg_launch()?,
961 up: ex.up.mul_mm_sg_launch()?,
962 down: ex.down.mul_mm_sg_launch()?,
963 })
964 }
965
966 #[cfg(feature = "metal")]
969 fn metal_prefill_dense_stack_run_len(
970 &self,
971 start: usize,
972 start_pos: usize,
973 batch_size: usize,
974 kv_caches: &[KvCache],
975 metal_kvs: Option<&[ferrox_metal::attn::MetalKvBuffers]>,
976 ) -> Option<usize> {
977 if self.gpt_oss.is_some() {
979 return None;
980 }
981 let metal_kvs = metal_kvs?;
982 let mut run = 0usize;
983 for li in start..self.layers.len() {
984 let layer = &self.layers[li];
985 let cache = &kv_caches[li];
986 if !self.metal_prefill_dense_swa_fits(li, start_pos, batch_size) {
987 break;
988 }
989 if metal_kvs[li].seq_len != cache.seq_len || start_pos != cache.seq_len {
990 break;
991 }
992 let ok = layer.attn.q_proj.mul_mm_sg_launch().is_some()
993 && layer.attn.k_proj.mul_mm_sg_launch().is_some()
994 && layer.attn.v_proj.mul_mm_sg_launch().is_some()
995 && layer.attn.o_proj.mul_mm_sg_launch().is_some()
996 && Self::metal_prefill_ffn(layer, &self.config).is_some();
997 if !ok {
998 break;
999 }
1000 run += 1;
1001 }
1002 (run >= 2).then_some(run)
1003 }
1004
1005 #[cfg(feature = "metal")]
1009 #[allow(clippy::too_many_arguments)]
1010 fn try_metal_prefill_dense_stack(
1011 &self,
1012 start: usize,
1013 run_len: usize,
1014 hidden_batch: &[f32],
1015 start_pos: usize,
1016 batch_size: usize,
1017 n_heads: usize,
1018 metal_kvs: &mut [ferrox_metal::attn::MetalKvBuffers],
1019 kv_caches: &mut [KvCache],
1020 ) -> Option<Vec<f32>> {
1021 let gelu = matches!(
1022 self.config.ffn_activation,
1023 crate::config::FfnActivation::Gelu
1024 );
1025 let mut prefill_layers = Vec::with_capacity(run_len);
1026 let mut rope_thetas = Vec::with_capacity(run_len);
1027 for li in start..start + run_len {
1028 let layer = &self.layers[li];
1029 let ffn = Self::metal_prefill_ffn(layer, &self.config)?;
1030 if matches!(ffn, ferrox_metal::attn::PrefillFfnMetal::Dense { .. }) {
1031 layer.moe.record_activations(&[0]);
1032 }
1033 let (q, k, v, o) = (
1034 layer.attn.q_proj.mul_mm_sg_launch()?,
1035 layer.attn.k_proj.mul_mm_sg_launch()?,
1036 layer.attn.v_proj.mul_mm_sg_launch()?,
1037 layer.attn.o_proj.mul_mm_sg_launch()?,
1038 );
1039 prefill_layers.push(ferrox_metal::attn::PrefillDenseLayerMetal {
1040 attn_norm_w: &layer.attn.norm_weight,
1041 ffn_norm_w: &layer.moe.norm_weight,
1042 q,
1043 k,
1044 v,
1045 o,
1046 ffn,
1047 post_attn_norm: layer.attn.post_attn_norm.as_deref(),
1048 post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
1049 extras: self.metal_attn_extras(layer),
1050 layer_idx: li as u32,
1051 });
1052 rope_thetas.push(self.config.layer_rope_theta(li));
1053 }
1054 let kvs = &mut metal_kvs[start..start + run_len];
1055 let h_out = ferrox_metal::attn::launch_prefill_dense_stack(
1056 hidden_batch,
1057 &prefill_layers,
1058 kvs,
1059 n_heads,
1060 batch_size,
1061 self.metal_rope_layout(),
1062 &rope_thetas,
1063 self.config.rope_freqs.as_deref(),
1064 start_pos,
1065 self.config.rms_norm_eps,
1066 gelu,
1067 self.config.attn_logit_softcap,
1068 )
1069 .ok()?;
1070 for cache in &mut kv_caches[start..start + run_len] {
1071 cache
1072 .advance_len(batch_size)
1073 .expect("unbounded/planned KvCache growth is infallible");
1074 }
1075 Some(h_out)
1076 }
1077
1078 #[cfg(feature = "metal")]
1082 fn layer_supports_metal_moe_resident(layer: &LayerWeights, config: &ModelConfig) -> bool {
1083 !Self::is_dense_layer(layer)
1084 && layer.moe.shared_experts.is_empty()
1085 && matches!(
1086 config.ffn_activation,
1087 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
1088 )
1089 && matches!(layer.moe.experts, ExpertBacking::Resident(_))
1090 && Self::metal_matvec_launch(&layer.moe.router).is_some()
1091 && Self::metal_matvec_launch(&layer.attn.q_proj).is_some()
1092 && Self::metal_matvec_launch(&layer.attn.k_proj).is_some()
1093 && Self::metal_matvec_launch(&layer.attn.v_proj).is_some()
1094 && Self::metal_matvec_launch(&layer.attn.o_proj).is_some()
1095 }
1096
1097 #[cfg(feature = "metal")]
1100 fn try_metal_moe_topk(
1101 layer: &LayerWeights,
1102 normed2: &[f32],
1103 decision: &ferrox_moe::RoutingDecision,
1104 ) -> Option<Vec<f32>> {
1105 if decision.expert_ids.is_empty() {
1106 return Some(vec![0f32; normed2.len()]);
1107 }
1108 let mut launches: Vec<ferrox_metal::gpu::MoeExpertLaunch<'_>> =
1111 Vec::with_capacity(decision.expert_ids.len());
1112 struct Pending {
1117 eid: usize,
1118 weight: f32,
1119 }
1120 let pending: Vec<Pending> = decision
1121 .expert_ids
1122 .iter()
1123 .zip(decision.weights.iter())
1124 .map(|(&eid, &w)| Pending { eid, weight: w })
1125 .collect();
1126
1127 for p in &pending {
1129 let ok = layer.moe.with_expert(p.eid, |ex| {
1130 Self::metal_matvec_launch(&ex.gate).is_some()
1131 && Self::metal_matvec_launch(&ex.up).is_some()
1132 && Self::metal_matvec_launch(&ex.down).is_some()
1133 });
1134 if !ok {
1135 return None;
1136 }
1137 }
1138
1139 match &layer.moe.experts {
1143 ExpertBacking::Resident(experts) => {
1144 for p in &pending {
1145 let ex = &experts[p.eid];
1146 launches.push(ferrox_metal::gpu::MoeExpertLaunch {
1147 gate: Self::metal_matvec_launch(&ex.gate)?,
1148 up: Self::metal_matvec_launch(&ex.up)?,
1149 down: Self::metal_matvec_launch(&ex.down)?,
1150 weight: p.weight,
1151 });
1152 }
1153 }
1154 ExpertBacking::Stored { .. } => {
1155 return None;
1157 }
1158 }
1159
1160 match ferrox_metal::gpu::launch_moe_topk_swiglu(normed2, &launches) {
1161 Ok(out) => Some(out),
1162 Err(e) => {
1163 eprintln!("ferrox: Metal MoE top-k fuse failed, falling back: {e}");
1164 None
1165 }
1166 }
1167 }
1168
1169 #[cfg(feature = "metal")]
1171 fn moe_packed_q4(moe: &MoeWeights) -> Option<ferrox_metal::gpu::MoePackedQ4<'_>> {
1172 moe.packed_q4.as_ref().map(MoePackedQ4Planes::view)
1173 }
1174
1175 #[cfg(feature = "metal")]
1180 fn try_metal_moe_prefill_batch(
1181 layer: &LayerWeights,
1182 normed2_batch: &[f32],
1183 router_logits_batch: &[f32],
1184 batch_size: usize,
1185 hidden_dim: usize,
1186 config: &ModelConfig,
1187 ) -> Option<Vec<f32>> {
1188 if batch_size == 0
1189 || !ferrox_core::metal_dense_enabled()
1190 || !ferrox_metal::attn::metal_moe_resident_enabled()
1191 || !matches!(
1192 config.ffn_activation,
1193 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
1194 )
1195 || !matches!(config.moe.gating, ferrox_moe::GatingFunction::Softmax)
1196 || config.moe.expert_group_count.is_some()
1197 {
1198 return None;
1199 }
1200 let ExpertBacking::Resident(_) = &layer.moe.experts else {
1201 return None;
1202 };
1203 let packed = Self::moe_packed_q4(&layer.moe)?;
1204 let top_k = config.moe.n_experts_active;
1205 if top_k == 0 || top_k > 8 || packed.hidden_rows != hidden_dim {
1206 return None;
1207 }
1208 let n_experts = layer.moe.n_experts().max(1);
1209 let mut ids = Vec::with_capacity(batch_size * top_k);
1210 let mut route = Vec::with_capacity(batch_size * top_k);
1211 for b in 0..batch_size {
1212 let logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
1213 let decision = route_top_k(logits, top_k, config.moe.gating, config.moe.norm_topk_prob);
1214 layer.moe.record_activations(&decision.expert_ids);
1215 if decision.expert_ids.len() != top_k {
1216 return None;
1217 }
1218 for (&eid, &w) in decision.expert_ids.iter().zip(decision.weights.iter()) {
1219 ids.push(eid as i32);
1220 route.push(w);
1221 }
1222 }
1223 let mut out = match ferrox_metal::gpu::launch_moe_prefill_q4_0(
1224 normed2_batch,
1225 batch_size,
1226 &packed,
1227 &ids,
1228 &route,
1229 top_k,
1230 ) {
1231 Ok(out) => out,
1232 Err(e) => {
1233 eprintln!("ferrox: Metal MoE prefill failed, CPU fallback: {e}");
1234 return None;
1235 }
1236 };
1237 Self::accumulate_shared_experts_batch(
1238 layer,
1239 normed2_batch,
1240 batch_size,
1241 hidden_dim,
1242 &mut out,
1243 );
1244 Some(out)
1245 }
1246
1247 fn accumulate_shared_experts_batch(
1250 layer: &LayerWeights,
1251 normed2_batch: &[f32],
1252 batch_size: usize,
1253 hidden_dim: usize,
1254 acc: &mut [f32],
1255 ) {
1256 for shex in &layer.moe.shared_experts {
1257 #[cfg(feature = "metal")]
1260 let down = if ferrox_core::metal_dense_enabled() && batch_size >= 4 {
1261 match (
1262 shex.gate.mul_mm_sg_launch(),
1263 shex.up.mul_mm_sg_launch(),
1264 shex.down.mul_mm_sg_launch(),
1265 ) {
1266 (Some(g), Some(u), Some(d)) => {
1267 ferrox_metal::gpu::launch_dense_ffn_swiglu_batch(
1268 &g,
1269 &u,
1270 &d,
1271 normed2_batch,
1272 batch_size,
1273 false,
1274 )
1275 .ok()
1276 }
1277 _ => None,
1278 }
1279 } else {
1280 None
1281 };
1282 #[cfg(not(feature = "metal"))]
1283 let down: Option<Vec<f32>> = None;
1284 #[cfg_attr(not(feature = "metal"), allow(clippy::unnecessary_literal_unwrap))]
1287 let down = down.unwrap_or_else(|| {
1288 let ffn_acts = shex.gate.quantize_batch_acts(normed2_batch, batch_size);
1289 let gate =
1290 shex.gate
1291 .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
1292 let up =
1293 shex.up
1294 .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
1295 let activated = ferrox_core::matmul::swiglu(&gate, &up);
1296 shex.down.apply_batch(&activated, batch_size)
1297 });
1298 if let Some(gate_w) = &layer.moe.shared_expert_gate {
1299 for b in 0..batch_size {
1300 let x = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
1301 let logit: f32 = gate_w.iter().zip(x.iter()).map(|(g, v)| g * v).sum();
1302 let scale = 1.0 / (1.0 + (-logit).exp());
1303 let out = &down[b * hidden_dim..(b + 1) * hidden_dim];
1304 let row = &mut acc[b * hidden_dim..(b + 1) * hidden_dim];
1305 for (a, &o) in row.iter_mut().zip(out.iter()) {
1306 *a += scale * o;
1307 }
1308 }
1309 } else {
1310 for (a, &o) in acc.iter_mut().zip(down.iter()) {
1311 *a += o;
1312 }
1313 }
1314 }
1315 }
1316
1317 #[cfg(feature = "metal")]
1319 fn try_metal_moe_experts_resident(
1320 layer: &LayerWeights,
1321 decision: &ferrox_moe::RoutingDecision,
1322 ) -> Option<()> {
1323 if decision.expert_ids.is_empty() {
1324 return Some(());
1325 }
1326 let pending: Vec<(usize, f32)> = decision
1327 .expert_ids
1328 .iter()
1329 .zip(decision.weights.iter())
1330 .map(|(&eid, &w)| (eid, w))
1331 .collect();
1332 for &(eid, _) in &pending {
1333 let ok = layer.moe.with_expert(eid, |ex| {
1334 Self::metal_matvec_launch(&ex.gate).is_some()
1335 && Self::metal_matvec_launch(&ex.up).is_some()
1336 && Self::metal_matvec_launch(&ex.down).is_some()
1337 });
1338 if !ok {
1339 return None;
1340 }
1341 }
1342 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
1343 return None;
1344 };
1345 let mut launches = Vec::with_capacity(pending.len());
1346 for &(eid, weight) in &pending {
1347 let ex = &experts[eid];
1348 launches.push(ferrox_metal::gpu::MoeExpertLaunch {
1349 gate: Self::metal_matvec_launch(&ex.gate)?,
1350 up: Self::metal_matvec_launch(&ex.up)?,
1351 down: Self::metal_matvec_launch(&ex.down)?,
1352 weight,
1353 });
1354 }
1355 match ferrox_metal::attn::launch_moe_decode_experts(&launches) {
1356 Ok(()) => Some(()),
1357 Err(e) => {
1358 eprintln!("ferrox: Metal MoE experts failed, falling back: {e}");
1359 None
1360 }
1361 }
1362 }
1363
1364 #[cfg(feature = "metal")]
1367 fn catch_up_host_kv_from_metal(mkv: &ferrox_metal::attn::MetalKvBuffers, cache: &mut KvCache) {
1368 if cache.seq_len >= mkv.seq_len {
1369 return;
1370 }
1371 let start = cache.seq_len;
1372 let n = mkv.seq_len - start;
1373 let (k, v) = mkv.tokens_host(start, n);
1374 let per = cache.n_kv_heads * cache.head_dim;
1375 for i in 0..n {
1376 let off = i * per;
1377 cache
1378 .push(&k[off..off + per], &v[off..off + per])
1379 .expect("unbounded/planned KvCache growth is infallible");
1380 }
1381 }
1382
1383 #[cfg(feature = "metal")]
1386 pub fn sync_metal_attn_kv_to_host(&self, kv_caches: &mut [KvCache]) {
1387 assert_eq!(kv_caches.len(), self.layers.len());
1388 let Ok(guard) = self.metal_attn_kv.lock() else {
1389 return;
1390 };
1391 let Some(metal_kvs) = guard.as_ref() else {
1392 return;
1393 };
1394 if metal_kvs.len() != kv_caches.len() {
1395 return;
1396 }
1397 for (mkv, cache) in metal_kvs.iter().zip(kv_caches.iter_mut()) {
1398 Self::catch_up_host_kv_from_metal(mkv, cache);
1399 }
1400 }
1401
1402 #[allow(clippy::too_many_arguments)]
1409 fn gqa_attention(
1410 &self,
1411 layer: usize,
1412 q: &[f32],
1413 k: &[f32],
1414 v: &[f32],
1415 n_heads: usize,
1416 n_kv_heads: usize,
1417 head_dim: usize,
1418 seq_len: usize,
1419 ) -> Vec<f32> {
1420 #[cfg(feature = "cuda")]
1421 {
1422 if cuda_gqa_enabled() {
1423 match ferrox_cuda::attn::launch_gqa_decode_resident(
1424 layer, q, k, v, n_heads, n_kv_heads, head_dim, seq_len,
1425 ) {
1426 Ok(out) => return out,
1427 Err(e) => {
1428 eprintln!(
1429 "ferrox: CUDA GQA resident decode failed, trying full upload: {e}"
1430 );
1431 }
1432 }
1433 match ferrox_cuda::attn::launch_gqa_decode(
1434 q, k, v, n_heads, n_kv_heads, head_dim, seq_len,
1435 ) {
1436 Ok(out) => return out,
1437 Err(e) => {
1438 eprintln!("ferrox: CUDA GQA decode failed, host fallback: {e}");
1439 }
1440 }
1441 }
1442 }
1443 let _ = layer;
1444 causal_gqa_attention_softcap(
1445 q,
1446 k,
1447 v,
1448 n_heads,
1449 n_kv_heads,
1450 head_dim,
1451 seq_len,
1452 self.config.attn_logit_softcap,
1453 )
1454 }
1455
1456 pub fn forward_token(
1460 &self,
1461 token_id: usize,
1462 pos: usize,
1463 kv_caches: &mut [KvCache],
1464 ) -> Vec<f32> {
1465 #[cfg(feature = "metal")]
1468 ferrox_metal::gpu::clear_resident_activation();
1469
1470 assert_eq!(kv_caches.len(), self.layers.len());
1471 let hidden_dim = self.config.hidden_dim;
1472 let head_dim = self.config.head_dim;
1473 let n_heads = self.config.n_heads;
1474 let n_kv_heads = self.config.n_kv_heads;
1475
1476 #[cfg(feature = "metal")]
1477 let metal_embd_kind = {
1478 let metal_path = ferrox_core::metal_dense_enabled()
1479 && ferrox_metal::attn::metal_attn_enabled()
1480 && self
1481 .layers
1482 .iter()
1483 .all(|l| self.layer_supports_metal_attn(l))
1484 && self.layers.iter().all(Self::layer_supports_metal_dense_ffn);
1485 if metal_path && self.config.embedding_scale.is_none() {
1488 Self::metal_matvec_launch(&self.embedding)
1489 .and_then(|l| ferrox_metal::embd::EmbdKind::from_fn_name(l.fn_name))
1490 } else {
1491 None
1492 }
1493 };
1494 #[cfg(feature = "metal")]
1495 let mut hidden = if metal_embd_kind.is_some() {
1496 Vec::new()
1497 } else {
1498 self.embedding.dequant_row(token_id)
1499 };
1500 #[cfg(not(feature = "metal"))]
1501 let mut hidden = self.embedding.dequant_row(token_id);
1502 if let Some(scale) = self.config.embedding_scale {
1503 for v in hidden.iter_mut() {
1504 *v *= scale;
1505 }
1506 }
1507 #[cfg(feature = "cuda")]
1508 if cuda_gqa_enabled() {
1509 const CUDA_KV_CAP: usize = 4096;
1512 if let Err(e) = ferrox_cuda::attn::ensure_layer_kv(
1513 self.layers.len(),
1514 self.config.n_kv_heads,
1515 self.config.head_dim,
1516 CUDA_KV_CAP,
1517 ) {
1518 eprintln!("ferrox: CUDA KV residency init failed: {e}");
1519 }
1520 if pos == 0 {
1521 ferrox_cuda::attn::clear_layer_kv();
1522 }
1523 }
1524
1525 #[cfg(feature = "metal")]
1526 let use_metal_attn = ferrox_core::metal_dense_enabled()
1527 && ferrox_metal::attn::metal_attn_enabled()
1528 && self
1529 .layers
1530 .iter()
1531 .all(|l| self.layer_supports_metal_attn(l));
1532
1533 #[cfg(not(feature = "metal"))]
1534 let use_metal_attn = false;
1535
1536 let residency = self.expert_residency_plan(use_metal_attn);
1537
1538 #[cfg(feature = "metal")]
1539 let mut metal_kv_guard: Option<
1540 std::sync::MutexGuard<'_, Option<Vec<ferrox_metal::attn::MetalKvBuffers>>>,
1541 > = if use_metal_attn {
1542 Some(self.metal_attn_kv.lock().unwrap())
1543 } else {
1544 None
1545 };
1546
1547 #[cfg(feature = "metal")]
1548 if let Some(guard) = metal_kv_guard.as_mut() {
1549 let need = self.layers.len();
1550 let cap = kv_caches
1551 .iter()
1552 .map(|c| c.seq_len.max(pos + 1).saturating_add(256))
1553 .max()
1554 .unwrap_or(512)
1555 .max(512)
1556 .max(pos + 1);
1557 let reset = match guard.as_ref() {
1558 None => true,
1559 Some(v) => {
1560 if v.len() != need || v.iter().any(|m| m.capacity() < pos + 1) {
1561 if v.len() == need {
1563 for (m, c) in v.iter().zip(kv_caches.iter_mut()) {
1564 Self::catch_up_host_kv_from_metal(m, c);
1565 }
1566 }
1567 true
1568 } else if v.iter().all(|m| m.seq_len == pos) {
1569 false
1572 } else {
1573 true
1575 }
1576 }
1577 };
1578 if reset {
1579 let mut bufs = Vec::with_capacity(need);
1580 for _ in 0..need {
1581 match ferrox_metal::attn::MetalKvBuffers::with_capacity(
1582 n_kv_heads, head_dim, cap,
1583 ) {
1584 Ok(b) => bufs.push(b),
1585 Err(_) => {
1586 **guard = None;
1587 break;
1588 }
1589 }
1590 }
1591 if bufs.len() == need {
1592 let mut ok = true;
1594 for (m, c) in bufs.iter_mut().zip(kv_caches.iter()) {
1595 if c.seq_len > 0 && m.upload_from_host(&c.k, &c.v, c.seq_len).is_err() {
1596 ok = false;
1597 break;
1598 }
1599 }
1600 if ok {
1601 **guard = Some(bufs);
1602 } else {
1603 **guard = None;
1604 }
1605 } else {
1606 **guard = None;
1607 }
1608 }
1609 }
1610
1611 #[cfg(feature = "metal")]
1612 let mut metal_stack_done = false;
1613 #[cfg(feature = "metal")]
1614 let mut final_norm_done_in_stack = false;
1615 #[cfg(feature = "metal")]
1617 if use_metal_attn
1618 && ferrox_metal::attn::metal_moe_resident_enabled()
1619 && matches!(self.config.moe.gating, ferrox_moe::GatingFunction::Softmax)
1620 && self
1621 .layers
1622 .iter()
1623 .all(|l| Self::layer_supports_metal_moe_resident(l, &self.config))
1624 && !self.layers.iter().all(Self::layer_supports_metal_dense_ffn)
1625 {
1626 if let Some(guard) = metal_kv_guard.as_mut() {
1627 if let Some(metal_kvs) = guard.as_mut() {
1628 if metal_kvs.iter().all(|m| m.seq_len == pos) {
1629 let mut moe_layers = Vec::with_capacity(self.layers.len());
1630 let mut ok = true;
1631 for layer in &self.layers {
1632 let ExpertBacking::Resident(_) = &layer.moe.experts else {
1633 ok = false;
1634 break;
1635 };
1636 let Some(packed) = Self::moe_packed_q4(&layer.moe) else {
1637 ok = false;
1638 break;
1639 };
1640 let (Some(q), Some(k), Some(v), Some(o), Some(r)) = (
1641 Self::metal_matvec_launch(&layer.attn.q_proj),
1642 Self::metal_matvec_launch(&layer.attn.k_proj),
1643 Self::metal_matvec_launch(&layer.attn.v_proj),
1644 Self::metal_matvec_launch(&layer.attn.o_proj),
1645 Self::metal_matvec_launch(&layer.moe.router),
1646 ) else {
1647 ok = false;
1648 break;
1649 };
1650 moe_layers.push(ferrox_metal::attn::MoeLayerMetal {
1651 attn_norm_w: &layer.attn.norm_weight,
1652 ffn_norm_w: &layer.moe.norm_weight,
1653 q,
1654 k,
1655 v,
1656 o,
1657 router: r,
1658 packed,
1659 extras: self.metal_attn_extras(layer),
1660 });
1661 }
1662 if ok {
1663 let greedy_gpu = ferrox_metal::attn::metal_greedy_argmax_active();
1666 let lm_head_gpu_launch = Self::metal_matvec_launch(&self.output_head);
1667 let out_launch =
1668 if greedy_gpu || ferrox_metal::attn::metal_logits_enabled() {
1669 lm_head_gpu_launch
1670 } else {
1671 None
1672 };
1673 let embd_launch = Self::metal_matvec_launch(&self.embedding);
1674 let embd_gather = if self.config.embedding_scale.is_some() {
1676 None
1677 } else {
1678 match (metal_embd_kind, embd_launch.as_ref()) {
1679 (Some(kind), Some(launch)) => {
1680 Some(ferrox_metal::attn::EmbdGatherMetal {
1681 kind,
1682 weights: launch.weights,
1683 rows: launch.rows,
1684 row_bytes: launch.row_bytes,
1685 n_cols: hidden_dim,
1686 token_id,
1687 })
1688 }
1689 _ => None,
1690 }
1691 };
1692 if embd_gather.is_none() && hidden.is_empty() {
1693 hidden = self.embedding.dequant_row(token_id);
1694 if let Some(scale) = self.config.embedding_scale {
1695 for v in hidden.iter_mut() {
1696 *v *= scale;
1697 }
1698 }
1699 }
1700 let seed = if embd_gather.is_some() {
1701 ferrox_metal::attn::moe_decode_ensure(hidden_dim)
1702 } else {
1703 ferrox_metal::attn::moe_decode_seed(&hidden)
1704 };
1705 let hidden_ref: &[f32] =
1706 if embd_gather.is_some() { &[] } else { &hidden };
1707 match seed.and_then(|_| {
1708 ferrox_metal::attn::launch_moe_decode_stack(
1709 hidden_ref,
1710 &moe_layers,
1711 metal_kvs,
1712 self.config.moe.n_experts_active,
1713 self.config.moe.norm_topk_prob,
1714 n_heads,
1715 self.metal_rope_layout(),
1716 self.config.rope_theta,
1717 self.config.rope_freqs.as_deref(),
1718 pos,
1719 self.config.rms_norm_eps,
1720 Some(&self.final_norm),
1721 out_launch.as_ref(),
1722 greedy_gpu && out_launch.is_some(),
1723 true,
1724 embd_gather.as_ref(),
1725 )
1726 }) {
1727 Ok((out, per_layer_ids)) => {
1728 for (layer, ids) in self.layers.iter().zip(per_layer_ids.iter())
1729 {
1730 if !ids.is_empty() {
1731 layer.moe.record_activations(ids);
1732 }
1733 }
1734 if out_launch.is_some() {
1735 #[cfg(feature = "metal")]
1736 ferrox_metal::gpu::clear_resident_activation();
1737 return out;
1738 }
1739 hidden = out;
1740 final_norm_done_in_stack = true;
1741 metal_stack_done = true;
1742 }
1743 Err(e) => {
1744 eprintln!(
1745 "ferrox: Metal MoE stack failed, per-layer fallback: {e}"
1746 );
1747 if hidden.is_empty() {
1748 hidden = self.embedding.dequant_row(token_id);
1749 if let Some(scale) = self.config.embedding_scale {
1750 for v in hidden.iter_mut() {
1751 *v *= scale;
1752 }
1753 }
1754 }
1755 }
1756 }
1757 }
1758 }
1759 }
1760 }
1761 }
1762 #[cfg(feature = "metal")]
1763 if !metal_stack_done
1764 && use_metal_attn
1765 && self.layers.iter().all(Self::layer_supports_metal_dense_ffn)
1766 {
1767 if let Some(guard) = metal_kv_guard.as_mut() {
1768 let mut clear_metal_after_stack = false;
1769 if let Some(metal_kvs) = guard.as_mut() {
1770 let seq_ok = metal_kvs.iter().all(|m| m.seq_len == pos);
1771 if seq_ok {
1772 let mut dense_layers = Vec::with_capacity(self.layers.len());
1774 let mut ok = true;
1775 for (li, layer) in self.layers.iter().enumerate() {
1776 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
1777 ok = false;
1778 break;
1779 };
1780 let ex = &experts[0];
1781 let (Some(q), Some(k), Some(v), Some(o), Some(g), Some(u), Some(d)) = (
1782 Self::metal_matvec_launch(&layer.attn.q_proj),
1783 Self::metal_matvec_launch(&layer.attn.k_proj),
1784 Self::metal_matvec_launch(&layer.attn.v_proj),
1785 Self::metal_matvec_launch(&layer.attn.o_proj),
1786 Self::metal_matvec_launch(&ex.gate),
1787 Self::metal_matvec_launch(&ex.up),
1788 Self::metal_matvec_launch(&ex.down),
1789 ) else {
1790 ok = false;
1791 break;
1792 };
1793 dense_layers.push(ferrox_metal::attn::DenseLayerMetal {
1794 attn_norm_w: &layer.attn.norm_weight,
1795 ffn_norm_w: &layer.moe.norm_weight,
1796 q,
1797 k,
1798 v,
1799 o,
1800 gate: g,
1801 up: u,
1802 down: d,
1803 extras: self.metal_attn_extras(layer),
1804 rope_theta: {
1805 let t = self.config.layer_rope_theta(li);
1806 (t != self.config.rope_theta).then_some(t)
1807 },
1808 window: self.config.layer_sliding_window(li),
1809 post_attn_norm: layer.attn.post_attn_norm.as_deref(),
1810 post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
1811 });
1812 }
1813 if ok {
1814 let greedy_gpu = ferrox_metal::attn::metal_greedy_argmax_active();
1819 let lm_head_gpu_launch = Self::metal_matvec_launch(&self.output_head);
1820 let out_launch =
1825 if greedy_gpu || ferrox_metal::attn::metal_logits_enabled() {
1826 lm_head_gpu_launch
1827 } else {
1828 None
1829 };
1830 let final_norm_w =
1834 if out_launch.is_some() || lm_head_gpu_launch.is_some() {
1835 Some(self.final_norm.as_slice())
1836 } else {
1837 None
1838 };
1839 let embd_launch = Self::metal_matvec_launch(&self.embedding);
1840 let embd_gather = if self.config.embedding_scale.is_some() {
1844 None
1845 } else {
1846 match (metal_embd_kind, embd_launch.as_ref()) {
1847 (Some(kind), Some(launch)) => {
1848 Some(ferrox_metal::attn::EmbdGatherMetal {
1849 kind,
1850 weights: launch.weights,
1851 rows: launch.rows,
1852 row_bytes: launch.row_bytes,
1853 n_cols: hidden_dim,
1854 token_id,
1855 })
1856 }
1857 _ => None,
1858 }
1859 };
1860 let hidden_ref: &[f32] =
1861 if embd_gather.is_some() { &[] } else { &hidden };
1862 match ferrox_metal::attn::launch_decode_dense_stack(
1863 hidden_ref,
1864 &dense_layers,
1865 metal_kvs,
1866 n_heads,
1867 self.metal_rope_layout(),
1868 self.config.rope_theta,
1869 self.config.rope_freqs.as_deref(),
1870 pos,
1871 self.config.rms_norm_eps,
1872 final_norm_w,
1873 out_launch.as_ref(),
1874 greedy_gpu && out_launch.is_some(),
1875 embd_gather.as_ref(),
1876 matches!(
1877 self.config.ffn_activation,
1878 crate::config::FfnActivation::Gelu
1879 ),
1880 ) {
1881 Ok(out) => {
1882 if out_launch.is_some() {
1888 #[cfg(feature = "metal")]
1891 ferrox_metal::gpu::clear_resident_activation();
1892 return out;
1893 }
1894 final_norm_done_in_stack = final_norm_w.is_some();
1898 hidden = out;
1899 metal_stack_done = true;
1900 }
1901 Err(e) => {
1902 eprintln!(
1903 "ferrox: Metal dense stack failed, per-layer fallback: {e}"
1904 );
1905 if hidden.is_empty() {
1906 hidden = self.embedding.dequant_row(token_id);
1907 if let Some(scale) = self.config.embedding_scale {
1908 for v in hidden.iter_mut() {
1909 *v *= scale;
1910 }
1911 }
1912 }
1913 for (m, c) in metal_kvs.iter().zip(kv_caches.iter_mut()) {
1916 Self::catch_up_host_kv_from_metal(m, c);
1917 }
1918 clear_metal_after_stack = true;
1919 }
1920 }
1921 }
1922 }
1923 }
1924 if clear_metal_after_stack {
1925 **guard = None;
1926 }
1927 }
1928 }
1929
1930 #[cfg(feature = "metal")]
1931 let run_cpu_layers = !metal_stack_done;
1932 #[cfg(not(feature = "metal"))]
1933 let run_cpu_layers = true;
1934
1935 #[cfg(feature = "metal")]
1937 let mut metal_moe_resident = false;
1938
1939 if run_cpu_layers {
1940 for (l, (layer, cache)) in self.layers.iter().zip(kv_caches.iter_mut()).enumerate() {
1941 #[cfg(feature = "metal")]
1943 if metal_moe_resident
1944 && (!Self::layer_supports_metal_moe_resident(layer, &self.config)
1945 || self.layer_needs_metal_stack(layer, l))
1946 {
1947 if let Some(h) = ferrox_metal::attn::moe_decode_take_hidden() {
1948 hidden = h;
1949 }
1950 metal_moe_resident = false;
1951 }
1952
1953 #[cfg(feature = "metal")]
1954 let normed = if metal_moe_resident {
1955 Vec::new()
1957 } else {
1958 rms_norm(&hidden, &layer.attn.norm_weight, self.config.rms_norm_eps)
1959 };
1960 #[cfg(not(feature = "metal"))]
1961 let normed = rms_norm(&hidden, &layer.attn.norm_weight, self.config.rms_norm_eps);
1962
1963 #[cfg(feature = "metal")]
1964 {
1965 let mut did_metal_attn = false;
1966 let mut did_metal_dense = false;
1967 let mut did_metal_moe = false;
1968 let mut clear_metal_kv = false;
1969 if let Some(guard) = metal_kv_guard.as_mut() {
1970 if let Some(metal_kvs) = guard.as_mut() {
1971 if metal_kvs[l].seq_len == pos
1976 && !self.layer_needs_metal_stack(layer, l)
1977 {
1978 if let (Some(q_l), Some(k_l), Some(v_l), Some(o_l)) = (
1979 Self::metal_matvec_launch(&layer.attn.q_proj),
1980 Self::metal_matvec_launch(&layer.attn.k_proj),
1981 Self::metal_matvec_launch(&layer.attn.v_proj),
1982 Self::metal_matvec_launch(&layer.attn.o_proj),
1983 ) {
1984 if Self::layer_supports_metal_dense_ffn(layer) {
1986 let dense_ok = layer.moe.with_expert(0, |ex| {
1987 let (Some(g_l), Some(u_l), Some(d_l)) = (
1988 Self::metal_matvec_launch(&ex.gate),
1989 Self::metal_matvec_launch(&ex.up),
1990 Self::metal_matvec_launch(&ex.down),
1991 ) else {
1992 return false;
1993 };
1994 match ferrox_metal::attn::launch_decode_dense_layer(
1995 &hidden,
1996 &layer.attn.norm_weight,
1997 &q_l,
1998 &k_l,
1999 &v_l,
2000 &o_l,
2001 &mut metal_kvs[l],
2002 &layer.moe.norm_weight,
2003 &g_l,
2004 &u_l,
2005 &d_l,
2006 n_heads,
2007 self.metal_rope_layout(),
2008 self.config.rope_theta,
2009 self.config.rope_freqs.as_deref(),
2010 pos,
2011 self.config.rms_norm_eps,
2012 &self.metal_attn_extras(layer),
2013 ) {
2014 Ok(new_h) => {
2015 Self::catch_up_host_kv_from_metal(
2017 &metal_kvs[l],
2018 cache,
2019 );
2020 layer.moe.record_activations(&[0]);
2021 hidden = new_h;
2022 true
2023 }
2024 Err(e) => {
2025 eprintln!(
2026 "ferrox: Metal dense layer failed, CPU fallback: {e}"
2027 );
2028 false
2029 }
2030 }
2031 });
2032 if dense_ok {
2033 did_metal_dense = true;
2034 did_metal_attn = true;
2035 } else if metal_kvs[l].seq_len != cache.seq_len {
2036 Self::catch_up_host_kv_from_metal(&metal_kvs[l], cache);
2038 clear_metal_kv = true;
2039 }
2040 }
2041
2042 if !did_metal_dense
2045 && !clear_metal_kv
2046 && ferrox_metal::attn::metal_moe_resident_enabled()
2047 && Self::layer_supports_metal_moe_resident(
2048 layer,
2049 &self.config,
2050 )
2051 {
2052 if let Some(router_l) =
2053 Self::metal_matvec_launch(&layer.moe.router)
2054 {
2055 let seed_ok = if metal_moe_resident {
2056 true
2057 } else {
2058 match ferrox_metal::attn::moe_decode_seed(&hidden) {
2059 Ok(()) => {
2060 metal_moe_resident = true;
2061 true
2062 }
2063 Err(e) => {
2064 eprintln!(
2065 "ferrox: Metal MoE seed failed: {e}"
2066 );
2067 false
2068 }
2069 }
2070 };
2071 if seed_ok {
2072 let fused_ok = matches!(
2074 self.config.moe.gating,
2075 ferrox_moe::GatingFunction::Softmax
2076 ) && match &layer.moe.experts {
2077 ExpertBacking::Resident(_) => {
2078 if let Some(packed) =
2079 Self::moe_packed_q4(&layer.moe)
2080 {
2081 match ferrox_metal::attn::launch_moe_decode_layer_fused(
2082 &layer.attn.norm_weight,
2083 &q_l,
2084 &k_l,
2085 &v_l,
2086 &o_l,
2087 &mut metal_kvs[l],
2088 &layer.moe.norm_weight,
2089 &router_l,
2090 &packed,
2091 self.config.moe.n_experts_active,
2092 self.config.moe.norm_topk_prob,
2093 n_heads,
2094 self.metal_rope_layout(),
2095 self.config.rope_theta,
2096 self.config.rope_freqs.as_deref(),
2097 pos,
2098 self.config.rms_norm_eps,
2099 &self.metal_attn_extras(layer),
2100 ) {
2101 Ok(ids) => {
2102 layer.moe.record_activations(&ids);
2103 did_metal_moe = true;
2104 did_metal_attn = true;
2105 true
2106 }
2107 Err(e) => {
2108 eprintln!(
2109 "ferrox: Metal MoE fused layer failed: {e}"
2110 );
2111 false
2112 }
2113 }
2114 } else {
2115 false
2116 }
2117 }
2118 _ => false,
2119 };
2120
2121 if !fused_ok {
2122 match ferrox_metal::attn::launch_moe_decode_pre(
2123 &layer.attn.norm_weight,
2124 &q_l,
2125 &k_l,
2126 &v_l,
2127 &o_l,
2128 &mut metal_kvs[l],
2129 &layer.moe.norm_weight,
2130 &router_l,
2131 n_heads,
2132 self.metal_rope_layout(),
2133 self.config.rope_theta,
2134 self.config.rope_freqs.as_deref(),
2135 pos,
2136 self.config.rms_norm_eps,
2137 &self.metal_attn_extras(layer),
2138 ) {
2139 Ok(logits) => {
2140 let decision = route_top_k(
2141 &logits,
2142 self.config.moe.n_experts_active,
2143 self.config.moe.gating,
2144 self.config.moe.norm_topk_prob,
2145 );
2146 layer.moe.record_activations(
2147 &decision.expert_ids,
2148 );
2149 if let Some(()) = Self::try_metal_moe_experts_resident(
2150 layer,
2151 &decision,
2152 ) {
2153 did_metal_moe = true;
2154 did_metal_attn = true;
2155 } else if let Some(h) =
2156 ferrox_metal::attn::moe_decode_take_hidden()
2157 {
2158 hidden = h;
2159 metal_moe_resident = false;
2160 let normed2 = rms_norm(
2162 &hidden,
2163 &layer.moe.norm_weight,
2164 self.config.rms_norm_eps,
2165 );
2166 let ffn_out = Self::combine_ffn_outputs_for_position(
2167 layer,
2168 &normed2,
2169 &logits,
2170 &self.config,
2171 hidden_dim,
2172 residency.as_ref().map(|p| p.layer_plan(l)),
2173 );
2174 for (h, f) in
2175 hidden.iter_mut().zip(ffn_out.iter())
2176 {
2177 *h += f;
2178 }
2179 did_metal_attn = true;
2180 did_metal_moe = true; }
2182 }
2183 Err(e) => {
2184 eprintln!(
2185 "ferrox: Metal MoE pre failed, fallback: {e}"
2186 );
2187 if let Some(h) =
2188 ferrox_metal::attn::moe_decode_take_hidden()
2189 {
2190 hidden = h;
2191 }
2192 metal_moe_resident = false;
2193 if metal_kvs[l].seq_len != cache.seq_len
2194 {
2195 Self::catch_up_host_kv_from_metal(
2196 &metal_kvs[l],
2197 cache,
2198 );
2199 clear_metal_kv = true;
2200 }
2201 }
2202 }
2203 }
2204 }
2205 }
2206 }
2207
2208 if !did_metal_dense && !did_metal_moe && !clear_metal_kv {
2209 match ferrox_metal::attn::launch_decode_attn_block(
2210 &normed,
2211 &q_l,
2212 &k_l,
2213 &v_l,
2214 &o_l,
2215 &mut metal_kvs[l],
2216 n_heads,
2217 self.metal_rope_layout(),
2218 self.config.rope_theta,
2219 self.config.rope_freqs.as_deref(),
2220 pos,
2221 &self.metal_attn_extras(layer),
2222 self.config.rms_norm_eps,
2223 ) {
2224 Ok(projected) => {
2225 for (h, p) in
2229 hidden.iter_mut().zip(projected.iter())
2230 {
2231 *h += p;
2232 }
2233 did_metal_attn = true;
2234 }
2235 Err(e) => {
2236 eprintln!(
2237 "ferrox: Metal attn block failed, CPU fallback: {e}"
2238 );
2239 Self::catch_up_host_kv_from_metal(
2240 &metal_kvs[l],
2241 cache,
2242 );
2243 clear_metal_kv = true;
2244 }
2245 }
2246 }
2247 }
2248 } else if metal_kvs[l].seq_len > cache.seq_len {
2249 Self::catch_up_host_kv_from_metal(&metal_kvs[l], cache);
2251 }
2252 }
2253 if clear_metal_kv {
2254 **guard = None;
2255 }
2256 }
2257 if did_metal_attn {
2258 if !did_metal_dense && !did_metal_moe {
2259 let normed2 =
2260 rms_norm(&hidden, &layer.moe.norm_weight, self.config.rms_norm_eps);
2261 let ffn_out = Self::run_ffn_block(
2262 layer,
2263 &normed2,
2264 &self.config,
2265 hidden_dim,
2266 residency.as_ref().map(|p| p.layer_plan(l)),
2267 );
2268 for (h, f) in hidden.iter_mut().zip(ffn_out.iter()) {
2269 *h += f;
2270 }
2271 }
2272 continue;
2273 }
2274 }
2275
2276 let (mut q, mut k, mut v) = {
2277 #[cfg(any(feature = "cuda", feature = "metal"))]
2278 {
2279 if let Some(mut outs) = ferrox_core::WeightMatrix::apply_gpu_multi(
2280 &[&layer.attn.q_proj, &layer.attn.k_proj, &layer.attn.v_proj],
2281 &normed,
2282 ) {
2283 let v = outs.pop().unwrap();
2284 let k = outs.pop().unwrap();
2285 let q = outs.pop().unwrap();
2286 (q, k, v)
2287 } else {
2288 ferrox_core::weight_matrix::WeightMatrix::apply_three(
2289 &layer.attn.q_proj,
2290 &layer.attn.k_proj,
2291 &layer.attn.v_proj,
2292 &normed,
2293 )
2294 }
2295 }
2296 #[cfg(not(any(feature = "cuda", feature = "metal")))]
2297 {
2298 ferrox_core::weight_matrix::WeightMatrix::apply_three(
2299 &layer.attn.q_proj,
2300 &layer.attn.k_proj,
2301 &layer.attn.v_proj,
2302 &normed,
2303 )
2304 }
2305 };
2306
2307 if let Some(bias) = &layer.attn.q_bias {
2308 for (x, b) in q.iter_mut().zip(bias.iter()) {
2309 *x += b;
2310 }
2311 }
2312 if let Some(bias) = &layer.attn.k_bias {
2313 for (x, b) in k.iter_mut().zip(bias.iter()) {
2314 *x += b;
2315 }
2316 }
2317 if let Some(bias) = &layer.attn.v_bias {
2318 for (x, b) in v.iter_mut().zip(bias.iter()) {
2319 *x += b;
2320 }
2321 }
2322
2323 if let Some(q_norm) = &layer.attn.q_norm {
2324 q = self.apply_qk_norm(&q, q_norm);
2325 }
2326 if let Some(k_norm) = &layer.attn.k_norm {
2327 k = self.apply_qk_norm(&k, k_norm);
2328 }
2329 self.apply_rope_attn_factor(&mut q, &mut k);
2330
2331 for h in 0..n_heads {
2332 self.apply_rope_head_layer(&mut q[h * head_dim..(h + 1) * head_dim], pos, l);
2333 }
2334 for h in 0..n_kv_heads {
2335 self.apply_rope_head_layer(&mut k[h * head_dim..(h + 1) * head_dim], pos, l);
2336 }
2337 if let Some(scale) = self.config.attention_scale {
2342 let compensate = scale * (head_dim as f32).sqrt();
2343 for v in q.iter_mut() {
2344 *v *= compensate;
2345 }
2346 }
2347
2348 cache
2349 .push(&k, &v)
2350 .expect("unbounded/planned KvCache growth is infallible");
2351
2352 let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
2353 let attn_out = match (oai, self.config.layer_sliding_window(l)) {
2354 (Some(oai), window) => ferrox_core::causal_gqa_attention_sinks(
2355 &q,
2356 &cache.k,
2357 &cache.v,
2358 n_heads,
2359 n_kv_heads,
2360 head_dim,
2361 cache.seq_len,
2362 window,
2363 &oai.attn_sinks,
2364 ),
2365 (None, Some(window)) => causal_gqa_attention_windowed_softcap(
2366 &q,
2367 &cache.k,
2368 &cache.v,
2369 n_heads,
2370 n_kv_heads,
2371 head_dim,
2372 cache.seq_len,
2373 window,
2374 self.config.attn_logit_softcap,
2375 ),
2376 (None, None) => self.gqa_attention(
2377 l,
2378 &q,
2379 &cache.k,
2380 &cache.v,
2381 n_heads,
2382 n_kv_heads,
2383 head_dim,
2384 cache.seq_len,
2385 ),
2386 };
2387 let mut projected = layer.attn.o_proj.apply(&attn_out);
2388 if let Some(oai) = oai {
2389 for (x, b) in projected.iter_mut().zip(oai.o_bias.iter()) {
2390 *x += b;
2391 }
2392 }
2393 if let Some(post) = &layer.attn.post_attn_norm {
2394 projected = rms_norm(&projected, post, self.config.rms_norm_eps);
2395 }
2396
2397 for (h, p) in hidden.iter_mut().zip(projected.iter()) {
2398 *h += p;
2399 }
2400
2401 let normed2 = rms_norm(&hidden, &layer.moe.norm_weight, self.config.rms_norm_eps);
2403 let mut ffn_out = match oai {
2404 Some(oai) => Self::gpt_oss_ffn(layer, oai, &normed2, &self.config, hidden_dim),
2405 None => Self::run_ffn_block(
2406 layer,
2407 &normed2,
2408 &self.config,
2409 hidden_dim,
2410 residency.as_ref().map(|p| p.layer_plan(l)),
2411 ),
2412 };
2413 if let Some(post) = &layer.attn.post_ffn_norm {
2414 ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
2415 }
2416 for (h, f) in hidden.iter_mut().zip(ffn_out.iter()) {
2417 *h += f;
2418 }
2419 }
2420 } #[cfg(feature = "metal")]
2423 if metal_moe_resident {
2424 if let Some(h) = ferrox_metal::attn::moe_decode_take_hidden() {
2425 hidden = h;
2426 }
2427 }
2428
2429 #[cfg(feature = "metal")]
2432 let final_normed = if final_norm_done_in_stack {
2433 hidden.clone()
2434 } else {
2435 rms_norm(&hidden, &self.final_norm, self.config.rms_norm_eps)
2436 };
2437 #[cfg(not(feature = "metal"))]
2438 let final_normed = rms_norm(&hidden, &self.final_norm, self.config.rms_norm_eps);
2439
2440 let mut logits = self.output_head.apply(&final_normed);
2441 if let Some(sc) = self.config.final_logit_softcap {
2442 softcap_inplace(&mut logits, sc);
2443 }
2444 #[cfg(feature = "metal")]
2448 ferrox_metal::gpu::clear_resident_activation();
2449 logits
2450 }
2451
2452 pub fn forward_token_paged(
2463 &self,
2464 token_id: usize,
2465 pos: usize,
2466 kv_caches: &mut [PagedKvCache],
2467 stores: &mut [PagedKvStore],
2468 ) -> Result<Vec<f32>, PagedStoreExhausted> {
2469 assert_eq!(kv_caches.len(), self.layers.len());
2470 assert_eq!(stores.len(), self.layers.len());
2471 assert!(
2477 self.gpt_oss.is_none(),
2478 "gpt-oss requires attention sinks; the paged-KV decode path does not implement them. \
2479 Run this model without a KV pool (FERROX_KV_POOL_BLOCKS unset)."
2480 );
2481 let hidden_dim = self.config.hidden_dim;
2482 let head_dim = self.config.head_dim;
2483 let n_heads = self.config.n_heads;
2484 let n_kv_heads = self.config.n_kv_heads;
2485
2486 let mut hidden = self.embedding.dequant_row(token_id);
2487 let residency = self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b));
2488
2489 for (l, ((layer, cache), store)) in self
2490 .layers
2491 .iter()
2492 .zip(kv_caches.iter_mut())
2493 .zip(stores.iter_mut())
2494 .enumerate()
2495 {
2496 let normed = rms_norm(&hidden, &layer.attn.norm_weight, self.config.rms_norm_eps);
2498
2499 let (mut q, mut k, mut v) = {
2500 #[cfg(any(feature = "cuda", feature = "metal"))]
2501 {
2502 if let Some(mut outs) = ferrox_core::WeightMatrix::apply_gpu_multi(
2503 &[&layer.attn.q_proj, &layer.attn.k_proj, &layer.attn.v_proj],
2504 &normed,
2505 ) {
2506 let v = outs.pop().unwrap();
2507 let k = outs.pop().unwrap();
2508 let q = outs.pop().unwrap();
2509 (q, k, v)
2510 } else {
2511 ferrox_core::weight_matrix::WeightMatrix::apply_three(
2512 &layer.attn.q_proj,
2513 &layer.attn.k_proj,
2514 &layer.attn.v_proj,
2515 &normed,
2516 )
2517 }
2518 }
2519 #[cfg(not(any(feature = "cuda", feature = "metal")))]
2520 {
2521 (
2522 layer.attn.q_proj.apply(&normed),
2523 layer.attn.k_proj.apply(&normed),
2524 layer.attn.v_proj.apply(&normed),
2525 )
2526 }
2527 };
2528
2529 if let Some(bias) = &layer.attn.q_bias {
2530 for (x, b) in q.iter_mut().zip(bias.iter()) {
2531 *x += b;
2532 }
2533 }
2534 if let Some(bias) = &layer.attn.k_bias {
2535 for (x, b) in k.iter_mut().zip(bias.iter()) {
2536 *x += b;
2537 }
2538 }
2539 if let Some(bias) = &layer.attn.v_bias {
2540 for (x, b) in v.iter_mut().zip(bias.iter()) {
2541 *x += b;
2542 }
2543 }
2544
2545 if let Some(q_norm) = &layer.attn.q_norm {
2546 q = self.apply_qk_norm(&q, q_norm);
2547 }
2548 if let Some(k_norm) = &layer.attn.k_norm {
2549 k = self.apply_qk_norm(&k, k_norm);
2550 }
2551 self.apply_rope_attn_factor(&mut q, &mut k);
2552
2553 for h in 0..n_heads {
2554 self.apply_rope_head_layer(&mut q[h * head_dim..(h + 1) * head_dim], pos, l);
2555 }
2556 for h in 0..n_kv_heads {
2557 self.apply_rope_head_layer(&mut k[h * head_dim..(h + 1) * head_dim], pos, l);
2558 }
2559
2560 cache.push(store, &k, &v)?;
2561
2562 let attn_out = causal_gqa_attention_paged(
2563 &q,
2564 store,
2565 cache.block_table(),
2566 n_heads,
2567 n_kv_heads,
2568 head_dim,
2569 cache.seq_len(),
2570 );
2571 let projected = layer.attn.o_proj.apply(&attn_out);
2572
2573 for (h, p) in hidden.iter_mut().zip(projected.iter()) {
2574 *h += p;
2575 }
2576
2577 let normed2 = rms_norm(&hidden, &layer.moe.norm_weight, self.config.rms_norm_eps);
2579 let ffn_out = Self::run_ffn_block(
2580 layer,
2581 &normed2,
2582 &self.config,
2583 hidden_dim,
2584 residency.as_ref().map(|p| p.layer_plan(l)),
2585 );
2586 for (h, f) in hidden.iter_mut().zip(ffn_out.iter()) {
2587 *h += f;
2588 }
2589 }
2590
2591 let final_normed = rms_norm(&hidden, &self.final_norm, self.config.rms_norm_eps);
2592 Ok(self.output_head.apply(&final_normed))
2593 }
2594
2595 pub fn expert_store_stats(&self) -> Option<ferrox_core::expert_store::ExpertStoreStats> {
2600 self.layers.iter().find_map(|l| match &l.moe.experts {
2601 ExpertBacking::Stored { store, .. } => Some(store.stats()),
2602 ExpertBacking::Resident(_) => None,
2603 })
2604 }
2605
2606 fn residency_plan(&self, vram_budget_bytes: u64) -> ferrox_moe::ResidencyPlan {
2617 let mut sizes_per_layer: Vec<Vec<usize>> = Vec::with_capacity(self.layers.len());
2618 let mut counts_per_layer: Vec<Vec<u64>> = Vec::with_capacity(self.layers.len());
2619 let mut any_observed = false;
2620 for layer in &self.layers {
2621 if Self::is_dense_layer(layer) {
2622 sizes_per_layer.push(Vec::new());
2623 counts_per_layer.push(Vec::new());
2624 continue;
2625 }
2626 sizes_per_layer.push(
2627 (0..layer.moe.n_experts())
2628 .map(|e| layer.moe.expert_bytes(e))
2629 .collect(),
2630 );
2631 let counts: Vec<u64> = layer
2632 .moe
2633 .activation_counts
2634 .iter()
2635 .map(|c| c.load(Ordering::Relaxed))
2636 .collect();
2637 any_observed |= counts.iter().any(|&c| c > 0);
2638 counts_per_layer.push(counts);
2639 }
2640 PlacementPlan::plan_layers_against_global_budget(
2641 &sizes_per_layer,
2642 any_observed.then_some(counts_per_layer.as_slice()),
2643 vram_budget_bytes,
2644 )
2645 }
2646
2647 fn is_dense_layer(layer: &LayerWeights) -> bool {
2658 layer.moe.n_experts() == 1 && layer.moe.shared_experts.is_empty()
2659 }
2660
2661 fn cpu_moe_topk_parallel_slots(
2665 experts: &[ExpertWeights],
2666 normed2: &[f32],
2667 decision: &ferrox_moe::RoutingDecision,
2668 hidden_dim: usize,
2669 ) -> Option<Vec<(Vec<f32>, f32)>> {
2670 use rayon::prelude::*;
2671 if !ferrox_core::weight_matrix::cpu_int_dot_enabled() || !normed2.len().is_multiple_of(32) {
2672 return None;
2673 }
2674 let n_slots = decision.expert_ids.len();
2675 if n_slots == 0 {
2676 return Some(Vec::new());
2677 }
2678 for &eid in &decision.expert_ids {
2679 let ex = experts.get(eid)?;
2680 if ex.gate.rows() == 0
2681 || ex.up.rows() != ex.gate.rows()
2682 || ex.down.rows() != hidden_dim
2683 || ex.gate.cols() != normed2.len()
2684 || ex.up.cols() != normed2.len()
2685 || ex.down.cols() != ex.gate.rows()
2686 {
2687 return None;
2688 }
2689 if !matches!(
2690 &ex.gate,
2691 WeightMatrix::Quantized {
2692 kind: ferrox_core::QuantKind::Q4_0 | ferrox_core::QuantKind::Q8_0,
2693 ..
2694 }
2695 ) || !matches!(
2696 &ex.up,
2697 WeightMatrix::Quantized {
2698 kind: ferrox_core::QuantKind::Q4_0 | ferrox_core::QuantKind::Q8_0,
2699 ..
2700 }
2701 ) {
2702 return None;
2703 }
2704 }
2705 let ffn_rows = experts[decision.expert_ids[0]].gate.rows();
2706 if !ffn_rows.is_multiple_of(2) {
2708 return None;
2709 }
2710 let act = ferrox_quant::quantize_activations_q8(normed2);
2711 let eids = &decision.expert_ids;
2712 let mut gate = vec![0f32; n_slots * ffn_rows];
2713 let mut up = vec![0f32; n_slots * ffn_rows];
2714 gate.par_chunks_mut(2)
2715 .zip(up.par_chunks_mut(2))
2716 .enumerate()
2717 .for_each(|(p, (gc, uc))| {
2718 let row0 = p * 2;
2719 let slot = row0 / ffn_rows;
2720 let r = row0 % ffn_rows;
2721 let ex = &experts[eids[slot]];
2722 if let (Some((g0, g1)), Some((u0, u1))) = (
2723 ex.gate.dot_pair_cpu_q8(r, &act),
2724 ex.up.dot_pair_cpu_q8(r, &act),
2725 ) {
2726 gc[0] = g0;
2727 gc[1] = g1;
2728 uc[0] = u0;
2729 uc[1] = u1;
2730 } else {
2731 gc[0] = ex.gate.dot_row_cpu_q8(r, &act).unwrap_or(0.0);
2732 gc[1] = ex.gate.dot_row_cpu_q8(r + 1, &act).unwrap_or(0.0);
2733 uc[0] = ex.up.dot_row_cpu_q8(r, &act).unwrap_or(0.0);
2734 uc[1] = ex.up.dot_row_cpu_q8(r + 1, &act).unwrap_or(0.0);
2735 }
2736 });
2737 let mut activated = vec![0f32; n_slots * ffn_rows];
2738 activated.par_iter_mut().enumerate().for_each(|(idx, a)| {
2739 let g = gate[idx];
2740 *a = (g / (1.0 + (-g).exp())) * up[idx];
2741 });
2742 let mut outs: Vec<(Vec<f32>, f32)> = decision
2743 .weights
2744 .iter()
2745 .map(|&w| (vec![0f32; hidden_dim], w))
2746 .collect();
2747 outs.par_iter_mut()
2748 .enumerate()
2749 .for_each(|(slot, (out, _))| {
2750 let ex = &experts[eids[slot]];
2751 let act_slot = &activated[slot * ffn_rows..(slot + 1) * ffn_rows];
2752 if act_slot.len().is_multiple_of(32) {
2753 let q8 = ferrox_quant::quantize_activations_q8(act_slot);
2754 if let Some(d) = ex.down.apply_cpu_q8(&q8) {
2755 *out = d;
2756 return;
2757 }
2758 }
2759 *out = ex.down.apply(act_slot);
2760 });
2761 Some(outs)
2762 }
2763
2764 fn cpu_moe_serial_experts(
2766 layer: &LayerWeights,
2767 normed2: &[f32],
2768 decision: &ferrox_moe::RoutingDecision,
2769 plan: Option<&PlacementPlan>,
2770 ) -> Vec<(Vec<f32>, f32)> {
2771 let shared_act = if ferrox_core::weight_matrix::cpu_int_dot_enabled()
2772 && normed2.len().is_multiple_of(32)
2773 && plan
2774 .map(|p| {
2775 decision
2776 .expert_ids
2777 .iter()
2778 .all(|&eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu))
2779 })
2780 .unwrap_or(true)
2781 {
2782 Some(ferrox_quant::quantize_activations_q8(normed2))
2783 } else {
2784 None
2785 };
2786 decision
2787 .expert_ids
2788 .iter()
2789 .zip(decision.weights.iter())
2790 .map(|(&eid, &w)| {
2791 let placement = plan
2792 .map(|p| p.placement_for(eid))
2793 .unwrap_or(ExpertPlacement::Cpu);
2794 let out = layer.moe.with_expert(eid, |ex| {
2795 if let Some(ref act) = shared_act {
2796 if let (Some(gate), Some(up)) =
2797 (ex.gate.apply_cpu_q8(act), ex.up.apply_cpu_q8(act))
2798 {
2799 let activated = ferrox_core::matmul::swiglu(&gate, &up);
2800 return ex.down.apply(&activated);
2801 }
2802 }
2803 run_expert_placed(normed2, ex, placement)
2804 });
2805 (out, w)
2806 })
2807 .collect()
2808 }
2809
2810 fn combine_ffn_outputs_for_position(
2825 layer: &LayerWeights,
2826 normed2: &[f32],
2827 router_logits: &[f32],
2828 config: &ModelConfig,
2829 hidden_dim: usize,
2830 plan: Option<&PlacementPlan>,
2831 ) -> Vec<f32> {
2832 let decision = match (
2833 config.moe.expert_group_count,
2834 config.moe.expert_group_used_count,
2835 ) {
2836 (Some(n_groups), Some(k_per_group)) if n_groups > 1 && k_per_group > 0 => {
2837 ferrox_moe::route_top_k_grouped(
2838 router_logits,
2839 n_groups,
2840 k_per_group,
2841 config.moe.n_experts_active,
2842 config.moe.gating,
2843 config.moe.norm_topk_prob,
2844 )
2845 }
2846 _ => route_top_k(
2847 router_logits,
2848 config.moe.n_experts_active,
2849 config.moe.gating,
2850 config.moe.norm_topk_prob,
2851 ),
2852 };
2853 layer.moe.record_activations(&decision.expert_ids);
2854 if let ExpertBacking::Stored {
2858 store,
2859 layer: layer_id,
2860 ..
2861 } = &layer.moe.experts
2862 {
2863 let keys: Vec<ferrox_core::expert_store::ExpertKey> = decision
2864 .expert_ids
2865 .iter()
2866 .map(|&eid| ferrox_core::expert_store::ExpertKey {
2867 layer: *layer_id,
2868 expert: eid as u32,
2869 })
2870 .collect();
2871 store.prefetch(&keys);
2872 }
2873
2874 #[cfg(feature = "metal")]
2878 if ferrox_core::metal_dense_enabled()
2879 && matches!(
2880 config.ffn_activation,
2881 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
2882 )
2883 && layer.moe.shared_experts.is_empty()
2884 {
2885 if let Some(fused) = Self::try_metal_moe_topk(layer, normed2, &decision) {
2886 return fused;
2887 }
2888 }
2889
2890 let routed_outputs: Vec<(Vec<f32>, f32)> = {
2891 let all_cpu = plan
2895 .map(|p| {
2896 decision
2897 .expert_ids
2898 .iter()
2899 .all(|&eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu))
2900 })
2901 .unwrap_or(true);
2902 if let (true, ExpertBacking::Resident(experts)) = (all_cpu, &layer.moe.experts) {
2903 if let Some(outs) =
2904 Self::cpu_moe_topk_parallel_slots(experts, normed2, &decision, hidden_dim)
2905 {
2906 outs
2907 } else {
2908 Self::cpu_moe_serial_experts(layer, normed2, &decision, plan)
2909 }
2910 } else {
2911 Self::cpu_moe_serial_experts(layer, normed2, &decision, plan)
2912 }
2913 };
2914 let mut shared_outputs: Vec<Vec<f32>> = layer
2918 .moe
2919 .shared_experts
2920 .iter()
2921 .map(|e| run_expert(normed2, e))
2922 .collect();
2923 if let Some(gate) = &layer.moe.shared_expert_gate {
2929 let gate_logit: f32 = gate.iter().zip(normed2.iter()).map(|(g, x)| g * x).sum();
2930 let gate_value = 1.0 / (1.0 + (-gate_logit).exp());
2931 for out in shared_outputs.iter_mut() {
2932 for x in out.iter_mut() {
2933 *x *= gate_value;
2934 }
2935 }
2936 }
2937
2938 combine_expert_outputs(&routed_outputs, &shared_outputs, hidden_dim)
2939 }
2940
2941 fn dense_ffn_batch(
2968 layer: &LayerWeights,
2969 normed2_batch: &[f32],
2970 batch_size: usize,
2971 config: &ModelConfig,
2972 ) -> Option<Vec<f32>> {
2973 if !Self::is_dense_layer(layer) || batch_size < 4 {
2976 return None;
2977 }
2978 #[cfg(any(feature = "metal", feature = "cuda"))]
2982 {
2983 #[cfg(feature = "metal")]
2984 let gpu_dense = ferrox_core::weight_matrix::metal_dense_enabled();
2985 #[cfg(not(feature = "metal"))]
2986 let gpu_dense = false;
2987 #[cfg(feature = "cuda")]
2988 let gpu_dense = gpu_dense || ferrox_core::weight_matrix::cuda_dense_enabled();
2989 if gpu_dense {
2990 let all_gemm = layer.moe.with_expert(0, |ex| {
2991 ex.gate.prefers_gpu_batch()
2992 && ex.up.prefers_gpu_batch()
2993 && ex.down.prefers_gpu_batch()
2994 });
2995 if !all_gemm {
2996 return None;
2997 }
2998 }
2999 }
3000 layer.moe.record_activations(&[0]);
3001 #[cfg(feature = "metal")]
3007 if ferrox_core::weight_matrix::metal_dense_enabled() {
3008 let gelu = matches!(config.ffn_activation, crate::config::FfnActivation::Gelu);
3009 let fused = layer.moe.with_expert(0, |ex| {
3010 let (g, u, d) = (
3011 ex.gate.mul_mm_sg_launch()?,
3012 ex.up.mul_mm_sg_launch()?,
3013 ex.down.mul_mm_sg_launch()?,
3014 );
3015 ferrox_metal::gpu::launch_dense_ffn_swiglu_batch(
3016 &g,
3017 &u,
3018 &d,
3019 normed2_batch,
3020 batch_size,
3021 gelu,
3022 )
3023 .ok()
3024 });
3025 if let Some(out) = fused {
3026 return Some(out);
3027 }
3028 }
3029 Some(layer.moe.with_expert(0, |ex| {
3030 let ffn_acts = ex.gate.quantize_batch_acts(normed2_batch, batch_size);
3031 let gate = ex
3032 .gate
3033 .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
3034 let up = ex
3035 .up
3036 .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
3037 let activated: Vec<f32> = match config.ffn_activation {
3038 crate::config::FfnActivation::Swiglu
3039 | crate::config::FfnActivation::SwigluFused => {
3040 ferrox_core::matmul::swiglu(&gate, &up)
3041 }
3042 crate::config::FfnActivation::Gelu => geglu(&gate, &up),
3043 };
3044 ex.down.apply_batch(&activated, batch_size)
3045 }))
3046 }
3047
3048 fn moe_ffn_batch(
3055 layer: &LayerWeights,
3056 normed2_batch: &[f32],
3057 router_logits_batch: &[f32],
3058 batch_size: usize,
3059 hidden_dim: usize,
3060 config: &ModelConfig,
3061 plan: Option<&PlacementPlan>,
3062 ) -> Option<Vec<f32>> {
3063 if batch_size < 32 || Self::is_dense_layer(layer) {
3064 return None;
3065 }
3066 #[cfg(feature = "metal")]
3069 if ferrox_core::metal_dense_enabled() {
3070 return None;
3071 }
3072 if !matches!(
3073 config.ffn_activation,
3074 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
3075 ) {
3076 return None;
3077 }
3078 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
3079 return None;
3080 };
3081 let n_experts = experts.len();
3082 let all_cpu = plan
3083 .map(|p| (0..n_experts).all(|eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu)))
3084 .unwrap_or(true);
3085 if !all_cpu || n_experts == 0 {
3086 return None;
3087 }
3088
3089 let mut buckets: Vec<Vec<(usize, f32)>> = vec![Vec::new(); n_experts];
3090 for b in 0..batch_size {
3091 let logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
3092 let decision = match (
3093 config.moe.expert_group_count,
3094 config.moe.expert_group_used_count,
3095 ) {
3096 (Some(n_groups), Some(k_per_group)) if n_groups > 1 && k_per_group > 0 => {
3097 ferrox_moe::route_top_k_grouped(
3098 logits,
3099 n_groups,
3100 k_per_group,
3101 config.moe.n_experts_active,
3102 config.moe.gating,
3103 config.moe.norm_topk_prob,
3104 )
3105 }
3106 _ => route_top_k(
3107 logits,
3108 config.moe.n_experts_active,
3109 config.moe.gating,
3110 config.moe.norm_topk_prob,
3111 ),
3112 };
3113 layer.moe.record_activations(&decision.expert_ids);
3114 for (&eid, &w) in decision.expert_ids.iter().zip(decision.weights.iter()) {
3115 buckets[eid].push((b, w));
3116 }
3117 }
3118
3119 let mut acc = vec![0f32; batch_size * hidden_dim];
3120 for (eid, toks) in buckets.iter().enumerate() {
3121 if toks.is_empty() {
3122 continue;
3123 }
3124 let n = toks.len();
3125 let mut gathered = vec![0f32; n * hidden_dim];
3126 for (i, &(tok, _)) in toks.iter().enumerate() {
3127 gathered[i * hidden_dim..(i + 1) * hidden_dim]
3128 .copy_from_slice(&normed2_batch[tok * hidden_dim..(tok + 1) * hidden_dim]);
3129 }
3130 let ex = &experts[eid];
3131 let ffn_acts = ex.gate.quantize_batch_acts(&gathered, n);
3132 let gate = ex
3133 .gate
3134 .apply_batch_with_acts(&gathered, n, ffn_acts.as_ref());
3135 let up = ex.up.apply_batch_with_acts(&gathered, n, ffn_acts.as_ref());
3136 let activated = ferrox_core::matmul::swiglu(&gate, &up);
3137 let down = ex.down.apply_batch(&activated, n);
3138 for (i, &(tok, w)) in toks.iter().enumerate() {
3139 let out = &down[i * hidden_dim..(i + 1) * hidden_dim];
3140 let row = &mut acc[tok * hidden_dim..(tok + 1) * hidden_dim];
3141 for (a, &o) in row.iter_mut().zip(out.iter()) {
3142 *a += w * o;
3143 }
3144 }
3145 }
3146
3147 Self::accumulate_shared_experts_batch(
3148 layer,
3149 normed2_batch,
3150 batch_size,
3151 hidden_dim,
3152 &mut acc,
3153 );
3154 Some(acc)
3155 }
3156
3157 fn gpt_oss_ffn(
3174 layer: &LayerWeights,
3175 oai: &GptOssLayer,
3176 normed2: &[f32],
3177 config: &ModelConfig,
3178 hidden_dim: usize,
3179 ) -> Vec<f32> {
3180 let mut router_logits = layer.moe.router.apply(normed2);
3181 for (x, b) in router_logits.iter_mut().zip(oai.router_bias.iter()) {
3182 *x += b;
3183 }
3184 let decision =
3187 ferrox_moe::route_top_k_softmax_weight(&router_logits, config.moe.n_experts_active);
3188 layer.moe.record_activations(&decision.expert_ids);
3189
3190 let mut out = vec![0f32; hidden_dim];
3191 for (slot, &eid) in decision.expert_ids.iter().enumerate() {
3192 let w = decision.weights[slot];
3193 let expert_out = layer.moe.with_expert(eid, |ex| {
3194 ferrox_moe::run_expert_oai(
3195 normed2,
3196 ex,
3197 &oai.expert_bias[eid],
3198 ferrox_moe::SWIGLU_OAI_ALPHA,
3199 ferrox_moe::SWIGLU_OAI_LIMIT,
3200 )
3201 });
3202 for (o, e) in out.iter_mut().zip(expert_out.iter()) {
3203 *o += w * e;
3204 }
3205 }
3206 out
3207 }
3208
3209 fn run_ffn_block(
3213 layer: &LayerWeights,
3214 normed2: &[f32],
3215 config: &ModelConfig,
3216 hidden_dim: usize,
3217 plan: Option<&PlacementPlan>,
3218 ) -> Vec<f32> {
3219 if Self::is_dense_layer(layer) {
3220 layer.moe.record_activations(&[0]);
3221 return layer.moe.with_expert(0, |ex| match config.ffn_activation {
3222 crate::config::FfnActivation::Swiglu
3223 | crate::config::FfnActivation::SwigluFused => run_expert(normed2, ex),
3224 crate::config::FfnActivation::Gelu => {
3225 if ferrox_core::weight_matrix::cpu_int_dot_enabled()
3228 && normed2.len().is_multiple_of(32)
3229 {
3230 let act = ferrox_quant::quantize_activations_q8(normed2);
3231 if let (Some(gate), Some(up)) =
3232 (ex.gate.apply_cpu_q8(&act), ex.up.apply_cpu_q8(&act))
3233 {
3234 let activated = geglu(&gate, &up);
3235 return ex.down.apply(&activated);
3236 }
3237 }
3238 let gate = ex.gate.apply(normed2);
3239 let up = ex.up.apply(normed2);
3240 let activated = geglu(&gate, &up);
3241 ex.down.apply(&activated)
3242 }
3243 });
3244 }
3245 let router_logits = layer.moe.router.apply(normed2);
3246 Self::combine_ffn_outputs_for_position(
3247 layer,
3248 normed2,
3249 &router_logits,
3250 config,
3251 hidden_dim,
3252 plan,
3253 )
3254 }
3255
3256 pub fn forward_batch(
3283 &self,
3284 tokens: &[usize],
3285 start_pos: usize,
3286 kv_caches: &mut [KvCache],
3287 ) -> Vec<Vec<f32>> {
3288 let hiddens = self.forward_hidden_batch(tokens, start_pos, kv_caches);
3289 if hiddens.is_empty() {
3290 return Vec::new();
3291 }
3292 let batch_size = hiddens.len();
3293 let vocab_size = self.output_head.rows();
3294 let flat: Vec<f32> = hiddens.into_iter().flatten().collect();
3295 let mut logits_batch = self.output_head.apply_batch(&flat, batch_size);
3296 if let Some(sc) = self.config.final_logit_softcap {
3297 softcap_inplace(&mut logits_batch, sc);
3298 }
3299 logits_batch
3300 .chunks(vocab_size)
3301 .map(|c| c.to_vec())
3302 .collect()
3303 }
3304
3305 pub fn forward_batch_last(
3325 &self,
3326 tokens: &[usize],
3327 start_pos: usize,
3328 kv_caches: &mut [KvCache],
3329 ) -> Vec<f32> {
3330 let hiddens = self.forward_hidden_batch(tokens, start_pos, kv_caches);
3331 let Some(last) = hiddens.last() else {
3332 return Vec::new();
3333 };
3334 let mut logits = self.output_head.apply(last);
3335 if let Some(sc) = self.config.final_logit_softcap {
3336 softcap_inplace(&mut logits, sc);
3337 }
3338 logits
3339 }
3340
3341 pub fn forward_hidden_batch(
3345 &self,
3346 tokens: &[usize],
3347 start_pos: usize,
3348 kv_caches: &mut [KvCache],
3349 ) -> Vec<Vec<f32>> {
3350 assert_eq!(kv_caches.len(), self.layers.len());
3351 let batch_size = tokens.len();
3352 if batch_size == 0 {
3353 return Vec::new();
3354 }
3355
3356 let hidden_dim = self.config.hidden_dim;
3357 let head_dim = self.config.head_dim;
3358 let n_heads = self.config.n_heads;
3359 let n_kv_heads = self.config.n_kv_heads;
3360
3361 let mut hidden_batch: Vec<f32> = tokens
3363 .iter()
3364 .flat_map(|&t| self.embedding.dequant_row(t))
3365 .collect();
3366 if let Some(scale) = self.config.embedding_scale {
3367 for v in hidden_batch.iter_mut() {
3368 *v *= scale;
3369 }
3370 }
3371
3372 #[cfg(feature = "metal")]
3373 let use_metal_attn = ferrox_core::metal_dense_enabled()
3374 && ferrox_metal::attn::metal_attn_enabled()
3375 && self
3376 .layers
3377 .iter()
3378 .all(|l| self.layer_supports_metal_attn(l));
3379
3380 #[cfg(not(feature = "metal"))]
3381 let use_metal_attn = false;
3382
3383 let residency = self.expert_residency_plan(use_metal_attn);
3384
3385 #[cfg(feature = "metal")]
3386 let mut metal_kv_guard: Option<
3387 std::sync::MutexGuard<'_, Option<Vec<ferrox_metal::attn::MetalKvBuffers>>>,
3388 > = if use_metal_attn {
3389 Some(self.metal_attn_kv.lock().unwrap())
3390 } else {
3391 None
3392 };
3393
3394 #[cfg(feature = "metal")]
3395 if let Some(guard) = metal_kv_guard.as_mut() {
3396 let need = self.layers.len();
3397 let need_cap = start_pos
3398 .saturating_add(batch_size)
3399 .saturating_add(256)
3400 .max(512);
3401 let reset = match guard.as_ref() {
3402 None => true,
3403 Some(v) => {
3404 v.len() != need
3405 || v.iter().any(|m| m.capacity() < need_cap)
3406 || v.iter()
3407 .zip(kv_caches.iter())
3408 .any(|(m, c)| m.seq_len != c.seq_len)
3409 }
3410 };
3411 if reset {
3412 let mut bufs = Vec::with_capacity(need);
3413 for _ in 0..need {
3414 match ferrox_metal::attn::MetalKvBuffers::with_capacity(
3415 n_kv_heads, head_dim, need_cap,
3416 ) {
3417 Ok(b) => bufs.push(b),
3418 Err(_) => {
3419 **guard = None;
3420 break;
3421 }
3422 }
3423 }
3424 if bufs.len() == need {
3425 let mut ok = true;
3426 for (m, c) in bufs.iter_mut().zip(kv_caches.iter()) {
3427 if c.seq_len > 0 && m.upload_from_host(&c.k, &c.v, c.seq_len).is_err() {
3428 ok = false;
3429 break;
3430 }
3431 }
3432 if ok {
3433 **guard = Some(bufs);
3434 } else {
3435 **guard = None;
3436 }
3437 } else {
3438 **guard = None;
3439 }
3440 }
3441 }
3442
3443 let n_layers = self.layers.len();
3444 let mut l = 0usize;
3445 while l < n_layers {
3446 let layer = &self.layers[l];
3447 let q_width = n_heads * head_dim;
3448 let kv_width = n_kv_heads * head_dim;
3449
3450 #[cfg(feature = "metal")]
3452 if use_metal_attn && batch_size >= 4 {
3453 if let Some(guard) = metal_kv_guard.as_mut() {
3454 if let Some(metal_kvs) = guard.as_mut() {
3455 if let Some(run_len) = self.metal_prefill_dense_stack_run_len(
3456 l,
3457 start_pos,
3458 batch_size,
3459 kv_caches,
3460 Some(metal_kvs.as_slice()),
3461 ) {
3462 if let Some(h_out) = self.try_metal_prefill_dense_stack(
3463 l,
3464 run_len,
3465 &hidden_batch,
3466 start_pos,
3467 batch_size,
3468 n_heads,
3469 metal_kvs,
3470 kv_caches,
3471 ) {
3472 hidden_batch = h_out;
3473 l += run_len;
3474 continue;
3475 }
3476 }
3477 }
3478 }
3479 }
3480
3481 let cache = &mut kv_caches[l];
3482
3483 #[cfg(feature = "metal")]
3486 if use_metal_attn && batch_size >= 4 && Self::metal_prefill_dense_layer_eligible(layer)
3487 {
3488 let swa_fits = self.metal_prefill_dense_swa_fits(l, start_pos, batch_size);
3489 if swa_fits {
3490 if let Some(guard) = metal_kv_guard.as_mut() {
3491 if let Some(metal_kvs) = guard.as_mut() {
3492 if metal_kvs[l].seq_len == cache.seq_len && start_pos == cache.seq_len {
3493 layer.moe.record_activations(&[0]);
3494 let fused = layer.moe.with_expert(0, |ex| {
3495 let (q, k, v, o) = (
3496 layer.attn.q_proj.mul_mm_sg_launch()?,
3497 layer.attn.k_proj.mul_mm_sg_launch()?,
3498 layer.attn.v_proj.mul_mm_sg_launch()?,
3499 layer.attn.o_proj.mul_mm_sg_launch()?,
3500 );
3501 let ffn = ferrox_metal::attn::PrefillFfnMetal::Dense {
3502 gate: ex.gate.mul_mm_sg_launch()?,
3503 up: ex.up.mul_mm_sg_launch()?,
3504 down: ex.down.mul_mm_sg_launch()?,
3505 };
3506 let gelu = matches!(
3507 self.config.ffn_activation,
3508 crate::config::FfnActivation::Gelu
3509 );
3510 let prefill_layer =
3511 ferrox_metal::attn::PrefillDenseLayerMetal {
3512 attn_norm_w: &layer.attn.norm_weight,
3513 ffn_norm_w: &layer.moe.norm_weight,
3514 q,
3515 k,
3516 v,
3517 o,
3518 ffn,
3519 post_attn_norm: layer.attn.post_attn_norm.as_deref(),
3520 post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
3521 extras: self.metal_attn_extras(layer),
3522 layer_idx: l as u32,
3523 };
3524 ferrox_metal::attn::launch_prefill_dense_layer(
3525 &hidden_batch,
3526 &prefill_layer,
3527 &mut metal_kvs[l],
3528 n_heads,
3529 batch_size,
3530 self.metal_rope_layout(),
3531 self.config.layer_rope_theta(l),
3532 self.config.rope_freqs.as_deref(),
3533 start_pos,
3534 self.config.rms_norm_eps,
3535 gelu,
3536 self.config.attn_logit_softcap,
3537 )
3538 .ok()
3539 });
3540 if let Some(h_out) = fused {
3541 cache
3542 .advance_len(batch_size)
3543 .expect("unbounded/planned KvCache growth is infallible");
3544 hidden_batch = h_out;
3545 l += 1;
3546 continue;
3547 }
3548 }
3549 }
3550 }
3551 }
3552 }
3553
3554 let normed_batch: Vec<f32> = hidden_batch
3556 .par_chunks(hidden_dim)
3557 .map(|h| rms_norm(h, &layer.attn.norm_weight, self.config.rms_norm_eps))
3558 .flatten()
3559 .collect();
3560
3561 let qkv_acts = layer
3566 .attn
3567 .q_proj
3568 .quantize_batch_acts(&normed_batch, batch_size);
3569 let mut q_batch = layer.attn.q_proj.apply_batch_with_acts(
3570 &normed_batch,
3571 batch_size,
3572 qkv_acts.as_ref(),
3573 );
3574 let mut k_batch = layer.attn.k_proj.apply_batch_with_acts(
3575 &normed_batch,
3576 batch_size,
3577 qkv_acts.as_ref(),
3578 );
3579 let mut v_batch = layer.attn.v_proj.apply_batch_with_acts(
3580 &normed_batch,
3581 batch_size,
3582 qkv_acts.as_ref(),
3583 );
3584 drop(qkv_acts);
3585
3586 if let Some(bias) = &layer.attn.q_bias {
3587 for row in q_batch.chunks_mut(q_width) {
3588 for (x, b) in row.iter_mut().zip(bias.iter()) {
3589 *x += b;
3590 }
3591 }
3592 }
3593 if let Some(bias) = &layer.attn.k_bias {
3594 for row in k_batch.chunks_mut(kv_width) {
3595 for (x, b) in row.iter_mut().zip(bias.iter()) {
3596 *x += b;
3597 }
3598 }
3599 }
3600 if let Some(bias) = &layer.attn.v_bias {
3601 for row in v_batch.chunks_mut(kv_width) {
3602 for (x, b) in row.iter_mut().zip(bias.iter()) {
3603 *x += b;
3604 }
3605 }
3606 }
3607
3608 if let Some(q_norm) = &layer.attn.q_norm {
3609 for row in q_batch.chunks_mut(q_width) {
3610 let normed = self.apply_qk_norm(row, q_norm);
3611 row.copy_from_slice(&normed);
3612 }
3613 }
3614 if let Some(k_norm) = &layer.attn.k_norm {
3615 for row in k_batch.chunks_mut(kv_width) {
3616 let normed = self.apply_qk_norm(row, k_norm);
3617 row.copy_from_slice(&normed);
3618 }
3619 }
3620 self.apply_rope_attn_factor(&mut q_batch, &mut k_batch);
3621
3622 #[cfg(feature = "metal")]
3623 {
3624 let mut did_metal_prefill = false;
3625 let swa_fits = match self.config.layer_sliding_window(l) {
3629 Some(window) => start_pos + batch_size <= window,
3630 None => true,
3631 };
3632 if let Some(guard) = metal_kv_guard.as_mut() {
3634 if let Some(metal_kvs) = guard.as_mut() {
3635 if metal_kvs[l].seq_len == cache.seq_len
3636 && start_pos == cache.seq_len
3637 && swa_fits
3638 {
3639 let prefill_res = {
3640 let o_launch = Self::metal_matvec_launch(&layer.attn.o_proj);
3641 let fuse_o = matches!(
3645 std::env::var("FERROX_METAL_PREFILL_FUSE_O").ok().as_deref(),
3646 Some("1") | Some("true") | Some("on")
3647 ) && o_launch.as_ref().is_some_and(|o| {
3648 o.fn_name == "q4_0_matvec"
3649 && o.block_bytes == 18
3650 && layer.attn.post_attn_norm.is_none()
3651 });
3652 if fuse_o {
3653 let o = o_launch.as_ref().unwrap();
3654 ferrox_metal::attn::launch_prefill_attn_o_residual(
3655 &q_batch,
3656 &k_batch,
3657 &v_batch,
3658 &hidden_batch,
3659 o,
3660 &mut metal_kvs[l],
3661 n_heads,
3662 batch_size,
3663 self.metal_rope_layout(),
3664 self.config.layer_rope_theta(l),
3665 self.config.rope_freqs.as_deref(),
3666 start_pos,
3667 self.config.attn_logit_softcap,
3668 )
3669 .map(|h_out| {
3670 cache.advance_len(batch_size).expect(
3671 "unbounded/planned KvCache growth is infallible",
3672 );
3673 hidden_batch = h_out;
3674 true
3675 })
3676 } else {
3677 ferrox_metal::attn::launch_prefill_attn_block(
3678 &q_batch,
3679 &k_batch,
3680 &v_batch,
3681 &mut metal_kvs[l],
3682 n_heads,
3683 batch_size,
3684 self.metal_rope_layout(),
3685 self.config.layer_rope_theta(l),
3686 self.config.rope_freqs.as_deref(),
3687 start_pos,
3688 self.config.attn_logit_softcap,
3689 false,
3690 )
3691 .map(
3692 |(attn_out_batch, _, _)| {
3693 cache.advance_len(batch_size).expect(
3694 "unbounded/planned KvCache growth is infallible",
3695 );
3696 let projected_batch = layer
3697 .attn
3698 .o_proj
3699 .apply_batch(&attn_out_batch, batch_size);
3700 let projected_batch =
3701 if let Some(post) = &layer.attn.post_attn_norm {
3702 projected_batch
3703 .chunks(hidden_dim)
3704 .flat_map(|row| {
3705 rms_norm(
3706 row,
3707 post,
3708 self.config.rms_norm_eps,
3709 )
3710 })
3711 .collect::<Vec<_>>()
3712 } else {
3713 projected_batch
3714 };
3715 for (h, p) in
3716 hidden_batch.iter_mut().zip(projected_batch.iter())
3717 {
3718 *h += p;
3719 }
3720 true
3721 },
3722 )
3723 }
3724 };
3725 match prefill_res {
3726 Ok(true) => {
3727 did_metal_prefill = true;
3728 }
3729 Ok(false) => {}
3730 Err(e) => {
3731 eprintln!(
3732 "ferrox: Metal prefill attn failed, CPU fallback: {e}"
3733 );
3734 **guard = None;
3735 }
3736 }
3737 }
3738 }
3739 }
3740 if did_metal_prefill {
3741 let normed2_batch: Vec<f32> = hidden_batch
3743 .chunks(hidden_dim)
3744 .flat_map(|h| rms_norm(h, &layer.moe.norm_weight, self.config.rms_norm_eps))
3745 .collect();
3746 let dense = Self::is_dense_layer(layer);
3747 let router_logits_batch = if dense {
3748 Vec::new()
3749 } else {
3750 layer.moe.router.apply_batch(&normed2_batch, batch_size)
3751 };
3752 let metal_ffn = if !dense {
3753 Self::try_metal_moe_prefill_batch(
3754 layer,
3755 &normed2_batch,
3756 &router_logits_batch,
3757 batch_size,
3758 hidden_dim,
3759 &self.config,
3760 )
3761 } else {
3762 None
3763 };
3764 if let Some(mut ffn_batch) = metal_ffn {
3765 if let Some(post) = &layer.attn.post_ffn_norm {
3766 ffn_batch = ffn_batch
3767 .chunks(hidden_dim)
3768 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
3769 .collect();
3770 }
3771 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
3772 *h += f;
3773 }
3774 } else if let Some(mut ffn_batch) =
3775 Self::dense_ffn_batch(layer, &normed2_batch, batch_size, &self.config)
3776 {
3777 if let Some(post) = &layer.attn.post_ffn_norm {
3778 ffn_batch = ffn_batch
3779 .chunks(hidden_dim)
3780 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
3781 .collect();
3782 }
3783 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
3784 *h += f;
3785 }
3786 } else if let Some(mut ffn_batch) = Self::moe_ffn_batch(
3787 layer,
3788 &normed2_batch,
3789 &router_logits_batch,
3790 batch_size,
3791 hidden_dim,
3792 &self.config,
3793 residency.as_ref().map(|p| p.layer_plan(l)),
3794 ) {
3795 if let Some(post) = &layer.attn.post_ffn_norm {
3796 ffn_batch = ffn_batch
3797 .chunks(hidden_dim)
3798 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
3799 .collect();
3800 }
3801 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
3802 *h += f;
3803 }
3804 } else {
3805 let n_experts = layer.moe.n_experts().max(1);
3806 for b in 0..batch_size {
3807 let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
3808 let mut ffn_out = if dense {
3809 Self::run_ffn_block(
3810 layer,
3811 normed2,
3812 &self.config,
3813 hidden_dim,
3814 residency.as_ref().map(|p| p.layer_plan(l)),
3815 )
3816 } else {
3817 let router_logits =
3818 &router_logits_batch[b * n_experts..(b + 1) * n_experts];
3819 Self::combine_ffn_outputs_for_position(
3820 layer,
3821 normed2,
3822 router_logits,
3823 &self.config,
3824 hidden_dim,
3825 residency.as_ref().map(|p| p.layer_plan(l)),
3826 )
3827 };
3828 if let Some(post) = &layer.attn.post_ffn_norm {
3829 ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
3830 }
3831 let hidden_row =
3832 &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
3833 for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
3834 *h += f;
3835 }
3836 }
3837 }
3838 l += 1;
3839 continue;
3840 }
3841 }
3842
3843 q_batch
3845 .par_chunks_mut(q_width)
3846 .zip(k_batch.par_chunks_mut(kv_width))
3847 .enumerate()
3848 .for_each(|(b, (q_row, k_row))| {
3849 let pos = start_pos + b;
3850 for h in 0..n_heads {
3851 self.apply_rope_head_layer(
3852 &mut q_row[h * head_dim..(h + 1) * head_dim],
3853 pos,
3854 l,
3855 );
3856 }
3857 for h in 0..n_kv_heads {
3858 self.apply_rope_head_layer(
3859 &mut k_row[h * head_dim..(h + 1) * head_dim],
3860 pos,
3861 l,
3862 );
3863 }
3864 });
3865
3866 let base_seq_len = cache.seq_len;
3867 for b in 0..batch_size {
3868 cache
3869 .push(
3870 &k_batch[b * kv_width..(b + 1) * kv_width],
3871 &v_batch[b * kv_width..(b + 1) * kv_width],
3872 )
3873 .expect("unbounded/planned KvCache growth is infallible");
3874 }
3875
3876 let cache_k = &cache.k;
3881 let cache_v = &cache.v;
3882 let softcap = self.config.attn_logit_softcap;
3883 let window = self.config.layer_sliding_window(l);
3884 let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
3885 let attn_out_batch = if let Some(oai) = oai {
3898 let mut out = vec![0f32; batch_size * q_width];
3899 out.par_chunks_mut(q_width)
3900 .enumerate()
3901 .for_each(|(b, dest)| {
3902 let seq_len_b = base_seq_len + b + 1;
3903 let cache_elems = seq_len_b * kv_width;
3904 let attn_out = ferrox_core::causal_gqa_attention_sinks(
3905 &q_batch[b * q_width..(b + 1) * q_width],
3906 &cache_k[..cache_elems],
3907 &cache_v[..cache_elems],
3908 n_heads,
3909 n_kv_heads,
3910 head_dim,
3911 seq_len_b,
3912 window,
3913 &oai.attn_sinks,
3914 );
3915 dest.copy_from_slice(&attn_out);
3916 });
3917 out
3918 } else {
3919 causal_gqa_attention_prefill_shared_kv_windowed(
3920 &q_batch,
3921 cache_k,
3922 cache_v,
3923 n_heads,
3924 n_kv_heads,
3925 head_dim,
3926 batch_size,
3927 base_seq_len,
3928 softcap,
3929 window,
3930 )
3931 };
3932
3933 let mut projected_batch = layer.attn.o_proj.apply_batch(&attn_out_batch, batch_size);
3934 if let Some(oai) = oai {
3935 for row in projected_batch.chunks_mut(hidden_dim) {
3936 for (x, b) in row.iter_mut().zip(oai.o_bias.iter()) {
3937 *x += b;
3938 }
3939 }
3940 }
3941 let projected_batch = if let Some(post) = &layer.attn.post_attn_norm {
3942 projected_batch
3943 .chunks(hidden_dim)
3944 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
3945 .collect::<Vec<_>>()
3946 } else {
3947 projected_batch
3948 };
3949 for (h, p) in hidden_batch.iter_mut().zip(projected_batch.iter()) {
3950 *h += p;
3951 }
3952
3953 let normed2_batch: Vec<f32> = hidden_batch
3955 .par_chunks(hidden_dim)
3956 .map(|h| rms_norm(h, &layer.moe.norm_weight, self.config.rms_norm_eps))
3957 .flatten()
3958 .collect();
3959 if let Some(oai) = oai {
3960 for b in 0..batch_size {
3964 let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
3965 let ffn_out = Self::gpt_oss_ffn(layer, oai, normed2, &self.config, hidden_dim);
3966 let hidden_row = &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
3967 for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
3968 *h += f;
3969 }
3970 }
3971 l += 1;
3972 continue;
3973 }
3974 let dense = Self::is_dense_layer(layer);
3975 let router_logits_batch = if dense {
3981 Vec::new()
3982 } else {
3983 layer.moe.router.apply_batch(&normed2_batch, batch_size)
3984 };
3985 #[cfg(feature = "metal")]
3986 let metal_ffn = if !dense {
3987 Self::try_metal_moe_prefill_batch(
3988 layer,
3989 &normed2_batch,
3990 &router_logits_batch,
3991 batch_size,
3992 hidden_dim,
3993 &self.config,
3994 )
3995 } else {
3996 None
3997 };
3998 #[cfg(not(feature = "metal"))]
3999 let metal_ffn: Option<Vec<f32>> = None;
4000 if let Some(mut ffn_batch) = metal_ffn {
4001 if let Some(post) = &layer.attn.post_ffn_norm {
4002 ffn_batch = ffn_batch
4003 .chunks(hidden_dim)
4004 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4005 .collect();
4006 }
4007 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4008 *h += f;
4009 }
4010 } else if let Some(mut ffn_batch) =
4011 Self::dense_ffn_batch(layer, &normed2_batch, batch_size, &self.config)
4012 {
4013 if let Some(post) = &layer.attn.post_ffn_norm {
4019 ffn_batch = ffn_batch
4020 .chunks(hidden_dim)
4021 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4022 .collect();
4023 }
4024 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4025 *h += f;
4026 }
4027 } else if let Some(mut ffn_batch) = Self::moe_ffn_batch(
4028 layer,
4029 &normed2_batch,
4030 &router_logits_batch,
4031 batch_size,
4032 hidden_dim,
4033 &self.config,
4034 residency.as_ref().map(|p| p.layer_plan(l)),
4035 ) {
4036 if let Some(post) = &layer.attn.post_ffn_norm {
4037 ffn_batch = ffn_batch
4038 .chunks(hidden_dim)
4039 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4040 .collect();
4041 }
4042 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4043 *h += f;
4044 }
4045 } else {
4046 let n_experts = layer.moe.n_experts().max(1);
4047 for b in 0..batch_size {
4048 let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
4049 let mut ffn_out = if dense {
4050 Self::run_ffn_block(
4051 layer,
4052 normed2,
4053 &self.config,
4054 hidden_dim,
4055 residency.as_ref().map(|p| p.layer_plan(l)),
4056 )
4057 } else {
4058 let router_logits =
4059 &router_logits_batch[b * n_experts..(b + 1) * n_experts];
4060 Self::combine_ffn_outputs_for_position(
4061 layer,
4062 normed2,
4063 router_logits,
4064 &self.config,
4065 hidden_dim,
4066 residency.as_ref().map(|p| p.layer_plan(l)),
4067 )
4068 };
4069 if let Some(post) = &layer.attn.post_ffn_norm {
4070 ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
4071 }
4072 let hidden_row = &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
4073 for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
4074 *h += f;
4075 }
4076 }
4077 }
4078 l += 1;
4079 }
4080
4081 hidden_batch
4082 .chunks(hidden_dim)
4083 .map(|h| rms_norm(h, &self.final_norm, self.config.rms_norm_eps))
4084 .collect()
4085 }
4086
4087 pub fn forward_multi_seq(
4109 &self,
4110 tokens: &[usize],
4111 positions: &[usize],
4112 kv_caches: &mut [Vec<KvCache>],
4113 ) -> Vec<Vec<f32>> {
4114 assert_eq!(tokens.len(), positions.len());
4115 assert_eq!(tokens.len(), kv_caches.len());
4116 let batch_size = tokens.len();
4117 if batch_size == 0 {
4118 return Vec::new();
4119 }
4120 for seq in kv_caches.iter() {
4121 assert_eq!(seq.len(), self.layers.len());
4122 }
4123
4124 let hidden_dim = self.config.hidden_dim;
4125 let head_dim = self.config.head_dim;
4126 let n_heads = self.config.n_heads;
4127 let n_kv_heads = self.config.n_kv_heads;
4128
4129 let mut hidden_batch: Vec<f32> = tokens
4131 .iter()
4132 .flat_map(|&t| self.embedding.dequant_row(t))
4133 .collect();
4134 if let Some(scale) = self.config.embedding_scale {
4135 for v in hidden_batch.iter_mut() {
4136 *v *= scale;
4137 }
4138 }
4139
4140 let residency = self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b));
4141
4142 for (l, layer) in self.layers.iter().enumerate() {
4143 let normed_batch: Vec<f32> = hidden_batch
4145 .par_chunks(hidden_dim)
4146 .map(|h| rms_norm(h, &layer.attn.norm_weight, self.config.rms_norm_eps))
4147 .flatten()
4148 .collect();
4149
4150 let qkv_acts = layer
4155 .attn
4156 .q_proj
4157 .quantize_batch_acts(&normed_batch, batch_size);
4158 let mut q_batch = layer.attn.q_proj.apply_batch_with_acts(
4159 &normed_batch,
4160 batch_size,
4161 qkv_acts.as_ref(),
4162 );
4163 let mut k_batch = layer.attn.k_proj.apply_batch_with_acts(
4164 &normed_batch,
4165 batch_size,
4166 qkv_acts.as_ref(),
4167 );
4168 let mut v_batch = layer.attn.v_proj.apply_batch_with_acts(
4169 &normed_batch,
4170 batch_size,
4171 qkv_acts.as_ref(),
4172 );
4173 drop(qkv_acts);
4174
4175 let q_width = n_heads * head_dim;
4176 let kv_width = n_kv_heads * head_dim;
4177
4178 if let Some(bias) = &layer.attn.q_bias {
4179 for row in q_batch.chunks_mut(q_width) {
4180 for (x, b) in row.iter_mut().zip(bias.iter()) {
4181 *x += b;
4182 }
4183 }
4184 }
4185 if let Some(bias) = &layer.attn.k_bias {
4186 for row in k_batch.chunks_mut(kv_width) {
4187 for (x, b) in row.iter_mut().zip(bias.iter()) {
4188 *x += b;
4189 }
4190 }
4191 }
4192 if let Some(bias) = &layer.attn.v_bias {
4193 for row in v_batch.chunks_mut(kv_width) {
4194 for (x, b) in row.iter_mut().zip(bias.iter()) {
4195 *x += b;
4196 }
4197 }
4198 }
4199
4200 if let Some(q_norm) = &layer.attn.q_norm {
4201 for row in q_batch.chunks_mut(q_width) {
4202 let normed = self.apply_qk_norm(row, q_norm);
4203 row.copy_from_slice(&normed);
4204 }
4205 }
4206 if let Some(k_norm) = &layer.attn.k_norm {
4207 for row in k_batch.chunks_mut(kv_width) {
4208 let normed = self.apply_qk_norm(row, k_norm);
4209 row.copy_from_slice(&normed);
4210 }
4211 }
4212 self.apply_rope_attn_factor(&mut q_batch, &mut k_batch);
4213
4214 for b in 0..batch_size {
4215 let pos = positions[b];
4216 let q_row = &mut q_batch[b * q_width..(b + 1) * q_width];
4217 for h in 0..n_heads {
4218 self.apply_rope_head_layer(
4219 &mut q_row[h * head_dim..(h + 1) * head_dim],
4220 pos,
4221 l,
4222 );
4223 }
4224 let k_row = &mut k_batch[b * kv_width..(b + 1) * kv_width];
4225 for h in 0..n_kv_heads {
4226 self.apply_rope_head_layer(
4227 &mut k_row[h * head_dim..(h + 1) * head_dim],
4228 pos,
4229 l,
4230 );
4231 }
4232 }
4233
4234 let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
4235 let mut attn_out_batch = vec![0f32; batch_size * q_width];
4236 for b in 0..batch_size {
4237 let cache = &mut kv_caches[b][l];
4238 cache
4239 .push(
4240 &k_batch[b * kv_width..(b + 1) * kv_width],
4241 &v_batch[b * kv_width..(b + 1) * kv_width],
4242 )
4243 .expect("unbounded/planned KvCache growth is infallible");
4244 if let Some(oai) = oai {
4245 let attn_out = ferrox_core::causal_gqa_attention_sinks(
4246 &q_batch[b * q_width..(b + 1) * q_width],
4247 &cache.k,
4248 &cache.v,
4249 n_heads,
4250 n_kv_heads,
4251 head_dim,
4252 cache.seq_len,
4253 self.config.layer_sliding_window(l),
4254 &oai.attn_sinks,
4255 );
4256 attn_out_batch[b * q_width..(b + 1) * q_width].copy_from_slice(&attn_out);
4257 continue;
4258 }
4259 let attn_out = match self.config.layer_sliding_window(l) {
4260 Some(window) => causal_gqa_attention_windowed_softcap(
4261 &q_batch[b * q_width..(b + 1) * q_width],
4262 &cache.k,
4263 &cache.v,
4264 n_heads,
4265 n_kv_heads,
4266 head_dim,
4267 cache.seq_len,
4268 window,
4269 self.config.attn_logit_softcap,
4270 ),
4271 None => causal_gqa_attention_softcap(
4272 &q_batch[b * q_width..(b + 1) * q_width],
4273 &cache.k,
4274 &cache.v,
4275 n_heads,
4276 n_kv_heads,
4277 head_dim,
4278 cache.seq_len,
4279 self.config.attn_logit_softcap,
4280 ),
4281 };
4282 attn_out_batch[b * q_width..(b + 1) * q_width].copy_from_slice(&attn_out);
4283 }
4284
4285 let mut projected_batch = layer.attn.o_proj.apply_batch(&attn_out_batch, batch_size);
4286 if let Some(oai) = oai {
4287 for row in projected_batch.chunks_mut(hidden_dim) {
4288 for (x, b) in row.iter_mut().zip(oai.o_bias.iter()) {
4289 *x += b;
4290 }
4291 }
4292 }
4293 let projected_batch = if let Some(post) = &layer.attn.post_attn_norm {
4294 projected_batch
4295 .chunks(hidden_dim)
4296 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4297 .collect::<Vec<_>>()
4298 } else {
4299 projected_batch
4300 };
4301 for (h, p) in hidden_batch.iter_mut().zip(projected_batch.iter()) {
4302 *h += p;
4303 }
4304
4305 let normed2_batch: Vec<f32> = hidden_batch
4307 .par_chunks(hidden_dim)
4308 .map(|h| rms_norm(h, &layer.moe.norm_weight, self.config.rms_norm_eps))
4309 .flatten()
4310 .collect();
4311 let dense = Self::is_dense_layer(layer);
4312 let router_logits_batch = if dense || oai.is_some() {
4313 Vec::new()
4314 } else {
4315 layer.moe.router.apply_batch(&normed2_batch, batch_size)
4316 };
4317 let n_experts = layer.moe.n_experts().max(1);
4318
4319 for b in 0..batch_size {
4320 let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
4321 let mut ffn_out = if let Some(oai) = oai {
4322 Self::gpt_oss_ffn(layer, oai, normed2, &self.config, hidden_dim)
4323 } else if dense {
4324 Self::run_ffn_block(
4325 layer,
4326 normed2,
4327 &self.config,
4328 hidden_dim,
4329 residency.as_ref().map(|p| p.layer_plan(l)),
4330 )
4331 } else {
4332 let router_logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
4333 Self::combine_ffn_outputs_for_position(
4334 layer,
4335 normed2,
4336 router_logits,
4337 &self.config,
4338 hidden_dim,
4339 residency.as_ref().map(|p| p.layer_plan(l)),
4340 )
4341 };
4342 if let Some(post) = &layer.attn.post_ffn_norm {
4343 ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
4344 }
4345 let hidden_row = &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
4346 for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
4347 *h += f;
4348 }
4349 }
4350 }
4351
4352 let vocab_size = self.output_head.rows();
4353 let final_normed_batch: Vec<f32> = hidden_batch
4354 .par_chunks(hidden_dim)
4355 .map(|h| rms_norm(h, &self.final_norm, self.config.rms_norm_eps))
4356 .flatten()
4357 .collect();
4358 let mut logits_batch = self
4359 .output_head
4360 .apply_batch(&final_normed_batch, batch_size);
4361 if let Some(sc) = self.config.final_logit_softcap {
4362 softcap_inplace(&mut logits_batch, sc);
4363 }
4364
4365 logits_batch
4366 .chunks(vocab_size)
4367 .map(|c| c.to_vec())
4368 .collect()
4369 }
4370}
4371
4372#[cfg(test)]
4373mod partial_rotary_tests {
4374 use super::*;
4375
4376 #[test]
4381 fn partial_rotary_leaves_the_tail_untouched() {
4382 let mut cfg = crate::config::test_dense_fixture();
4383 cfg.head_dim = 8;
4384 cfg.rope_layout = crate::config::RopeLayout::Neox;
4385 cfg.rope_freqs = None;
4386 cfg.rope_dim = Some(4);
4387 let decoder = Decoder::new_random_small(cfg, 1, 32);
4388
4389 let mut head: Vec<f32> = (0..8).map(|i| 1.0 + i as f32).collect();
4390 let before = head.clone();
4391 decoder.apply_rope_head_theta(&mut head, 3, 10000.0);
4392
4393 assert_eq!(
4394 &head[4..],
4395 &before[4..],
4396 "dims at or past rope_dim must not rotate"
4397 );
4398 assert!(
4399 head[..4] != before[..4],
4400 "dims below rope_dim must rotate at a non-zero position"
4401 );
4402 }
4403
4404 #[test]
4411 fn attn_factor_scales_only_the_rotated_channels() {
4412 let mut cfg = crate::config::test_dense_fixture();
4413 cfg.head_dim = 8;
4414 cfg.n_heads = 2;
4415 cfg.n_kv_heads = 2;
4416 cfg.rope_dim = Some(4);
4417 cfg.rope_attn_factor = 2.0;
4418 let decoder = Decoder::new_random_small(cfg, 1, 32);
4419
4420 let mut q: Vec<f32> = (0..16).map(|i| 1.0 + i as f32).collect();
4423 let mut k: Vec<f32> = (0..16).map(|i| 1.0 + i as f32).collect();
4424 let before = q.clone();
4425 decoder.apply_rope_attn_factor(&mut q, &mut k);
4426
4427 for h in 0..2 {
4428 let base = h * 8;
4429 for i in 0..4 {
4430 assert_eq!(
4431 q[base + i],
4432 before[base + i] * 2.0,
4433 "rotated channel {i} of head {h} must be scaled"
4434 );
4435 }
4436 for i in 4..8 {
4437 assert_eq!(
4438 q[base + i],
4439 before[base + i],
4440 "pass-through channel {i} of head {h} must be untouched"
4441 );
4442 }
4443 }
4444 assert_eq!(q, k, "q and k take the same magnitude scale");
4445 }
4446
4447 #[test]
4450 fn attn_factor_scales_the_whole_head_without_partial_rotary() {
4451 let mut cfg = crate::config::test_dense_fixture();
4452 cfg.head_dim = 8;
4453 cfg.n_heads = 1;
4454 cfg.n_kv_heads = 1;
4455 cfg.rope_dim = None;
4456 cfg.rope_attn_factor = 3.0;
4457 let decoder = Decoder::new_random_small(cfg, 1, 32);
4458
4459 let mut q: Vec<f32> = (0..8).map(|i| 1.0 + i as f32).collect();
4460 let mut k = q.clone();
4461 let before = q.clone();
4462 decoder.apply_rope_attn_factor(&mut q, &mut k);
4463 for i in 0..8 {
4464 assert_eq!(q[i], before[i] * 3.0);
4465 }
4466 }
4467
4468 #[test]
4471 fn full_rotary_still_rotates_the_whole_head() {
4472 let mut cfg = crate::config::test_dense_fixture();
4473 cfg.head_dim = 8;
4474 cfg.rope_layout = crate::config::RopeLayout::Neox;
4475 cfg.rope_freqs = None;
4476 cfg.rope_dim = None;
4477 let decoder = Decoder::new_random_small(cfg, 1, 32);
4478
4479 let mut head: Vec<f32> = (0..8).map(|i| 1.0 + i as f32).collect();
4480 let before = head.clone();
4481 decoder.apply_rope_head_theta(&mut head, 3, 10000.0);
4482 assert!(head[4..] != before[4..]);
4483 }
4484
4485 #[test]
4488 fn rope_attn_factor_scales_q_and_k_only() {
4489 let mut cfg = crate::config::test_dense_fixture();
4490 cfg.rope_attn_factor = 2.0;
4491 let decoder = Decoder::new_random_small(cfg, 1, 32);
4492 let mut q = vec![1.0f32, -2.0, 3.0];
4493 let mut k = vec![0.5f32, 4.0];
4494 decoder.apply_rope_attn_factor(&mut q, &mut k);
4495 assert_eq!(q, vec![2.0, -4.0, 6.0]);
4496 assert_eq!(k, vec![1.0, 8.0]);
4497
4498 let mut cfg = crate::config::test_dense_fixture();
4499 cfg.rope_attn_factor = 1.0;
4500 let decoder = Decoder::new_random_small(cfg, 1, 32);
4501 let mut q = vec![1.0f32, -2.0];
4502 let mut k = vec![3.0f32];
4503 decoder.apply_rope_attn_factor(&mut q, &mut k);
4504 assert_eq!(q, vec![1.0, -2.0]);
4505 assert_eq!(k, vec![3.0]);
4506 }
4507}
4508
4509#[cfg(test)]
4510mod tests {
4511 use super::*;
4512 use crate::config::glm_5_2;
4513
4514 fn tiny_test_config() -> ModelConfig {
4518 let mut cfg = glm_5_2();
4519 cfg.hidden_dim = 16;
4520 cfg.n_heads = 4;
4521 cfg.n_kv_heads = 2;
4522 cfg.head_dim = 4;
4523 cfg.moe.hidden_dim = 16;
4524 cfg.moe.n_experts = 6;
4525 cfg.moe.n_experts_active = 2;
4526 cfg.moe.n_shared_experts = 1;
4527 cfg.moe.expert_ffn_dim = 8;
4528 cfg
4529 }
4530
4531 #[test]
4532 fn forward_pass_produces_finite_logits_of_correct_shape() {
4533 let vocab = 10;
4534 let decoder = Decoder::new_random_small(tiny_test_config(), 2, vocab);
4535 let mut caches: Vec<KvCache> = (0..2)
4536 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4537 .collect();
4538
4539 let logits = decoder.forward_token(3, 0, &mut caches);
4540 assert_eq!(logits.len(), vocab);
4541 assert!(
4542 logits.iter().all(|v| v.is_finite()),
4543 "logits must not contain NaN/Inf"
4544 );
4545 }
4546
4547 #[test]
4558 fn gpu_vram_budget_bytes_with_nothing_placed_matches_the_default() {
4559 let mut decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
4560 let mut caches_default: Vec<KvCache> = (0..2)
4561 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4562 .collect();
4563 let default_logits = decoder.forward_token(3, 0, &mut caches_default);
4564
4565 decoder.gpu_vram_budget_bytes = Some(0);
4566 let mut caches_zero_budget: Vec<KvCache> = (0..2)
4567 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4568 .collect();
4569 let zero_budget_logits = decoder.forward_token(3, 0, &mut caches_zero_budget);
4570
4571 assert_eq!(
4572 default_logits, zero_budget_logits,
4573 "a placement plan that places nothing on GPU must match the None default exactly"
4574 );
4575 }
4576
4577 #[test]
4588 fn shared_expert_gate_scales_shared_output_by_sigmoid_of_the_gate_logit() {
4589 let cfg = tiny_test_config();
4590 let mut decoder = Decoder::new_random_small(cfg, 2, 8);
4591 let hidden_dim = decoder.config.hidden_dim;
4592 assert_eq!(
4593 decoder.layers[1].moe.shared_experts.len(),
4594 1,
4595 "test assumes tiny_test_config's real MoE layer has exactly one shared expert"
4596 );
4597
4598 let normed2: Vec<f32> = (0..hidden_dim).map(|i| (i as f32 * 0.37).sin()).collect();
4599 let gate_vec: Vec<f32> = (0..hidden_dim).map(|i| i as f32 * 0.13 - 0.5).collect();
4600
4601 let shared_out_raw = run_expert(&normed2, &decoder.layers[1].moe.shared_experts[0]);
4605 let gate_logit: f32 = gate_vec
4606 .iter()
4607 .zip(normed2.iter())
4608 .map(|(g, x)| g * x)
4609 .sum();
4610 let gate_value = 1.0 / (1.0 + (-gate_logit).exp());
4611 let expected_gated_shared: Vec<f32> =
4612 shared_out_raw.iter().map(|x| x * gate_value).collect();
4613
4614 let router_logits = decoder.layers[1].moe.router.apply(&normed2);
4620 let ungated_total = Decoder::combine_ffn_outputs_for_position(
4621 &decoder.layers[1],
4622 &normed2,
4623 &router_logits,
4624 &decoder.config,
4625 hidden_dim,
4626 None,
4627 );
4628 decoder.layers[1].moe.shared_expert_gate = Some(gate_vec);
4629 let gated_total = Decoder::combine_ffn_outputs_for_position(
4630 &decoder.layers[1],
4631 &normed2,
4632 &router_logits,
4633 &decoder.config,
4634 hidden_dim,
4635 None,
4636 );
4637
4638 for (i, ((u, g), expected_shared)) in ungated_total
4639 .iter()
4640 .zip(gated_total.iter())
4641 .zip(expected_gated_shared.iter())
4642 .enumerate()
4643 {
4644 let routed_contribution = u - shared_out_raw[i];
4645 let gated_shared_recovered = g - routed_contribution;
4646 assert!(
4647 (gated_shared_recovered - expected_shared).abs() < 1e-4,
4648 "index {i}: recovered gated shared output {gated_shared_recovered} != expected {expected_shared} (sigmoid({gate_logit})={gate_value})"
4649 );
4650 }
4651 }
4652
4653 #[test]
4654 fn kv_cache_grows_by_one_position_per_layer_per_step() {
4655 let decoder = Decoder::new_random_small(tiny_test_config(), 3, 5);
4656 let mut caches: Vec<KvCache> = (0..3)
4657 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4658 .collect();
4659
4660 decoder.forward_token(0, 0, &mut caches);
4661 decoder.forward_token(1, 1, &mut caches);
4662 decoder.forward_token(2, 2, &mut caches);
4663
4664 for cache in &caches {
4665 assert_eq!(cache.seq_len, 3);
4666 }
4667 }
4668
4669 #[test]
4670 fn same_token_same_position_is_deterministic() {
4671 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
4672 let mut caches_a: Vec<KvCache> = (0..2)
4673 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4674 .collect();
4675 let mut caches_b: Vec<KvCache> = (0..2)
4676 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4677 .collect();
4678
4679 let out_a = decoder.forward_token(4, 0, &mut caches_a);
4680 let out_b = decoder.forward_token(4, 0, &mut caches_b);
4681 assert_eq!(out_a, out_b, "identical input state must yield identical output (no hidden randomness in the forward pass)");
4682 }
4683
4684 #[test]
4685 fn multi_step_decode_stays_finite_across_positions() {
4686 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
4687 let mut caches: Vec<KvCache> = (0..2)
4688 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4689 .collect();
4690
4691 for pos in 0..16 {
4692 let logits = decoder.forward_token(pos % 8, pos, &mut caches);
4693 assert!(
4694 logits.iter().all(|v| v.is_finite()),
4695 "position {pos}: logits must stay finite across an extended decode run"
4696 );
4697 }
4698 }
4699
4700 #[test]
4706 fn forward_token_paged_matches_forward_token_bit_identical() {
4707 let n_layers = 2;
4708 let decoder = Decoder::new_random_small(tiny_test_config(), n_layers, 10);
4709
4710 let mut caches: Vec<KvCache> = (0..n_layers)
4711 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4712 .collect();
4713 let mut plain_logits = Vec::new();
4714 for (pos, &tok) in [3usize, 5, 7].iter().enumerate() {
4715 plain_logits.push(decoder.forward_token(tok, pos, &mut caches));
4716 }
4717
4718 let block_size = 2;
4719 let mut paged_caches: Vec<PagedKvCache> =
4720 (0..n_layers).map(|_| PagedKvCache::new()).collect();
4721 let mut stores: Vec<PagedKvStore> = (0..n_layers)
4722 .map(|_| {
4723 PagedKvStore::new(
4724 block_size,
4725 16,
4726 decoder.config.n_kv_heads,
4727 decoder.config.head_dim,
4728 )
4729 })
4730 .collect();
4731 let mut paged_logits = Vec::new();
4732 for (pos, &tok) in [3usize, 5, 7].iter().enumerate() {
4733 paged_logits.push(
4734 decoder
4735 .forward_token_paged(tok, pos, &mut paged_caches, &mut stores)
4736 .expect("store sized generously, must not exhaust"),
4737 );
4738 }
4739
4740 assert_eq!(plain_logits.len(), paged_logits.len());
4741 for (a, b) in plain_logits.iter().zip(paged_logits.iter()) {
4742 assert_eq!(a.len(), b.len());
4743 for (x, y) in a.iter().zip(b.iter()) {
4744 assert_eq!(
4745 x.to_bits(),
4746 y.to_bits(),
4747 "paged decode must be bit-identical to contiguous decode"
4748 );
4749 }
4750 }
4751 }
4752
4753 #[test]
4762 fn forward_batch_matches_sequential_forward_token_exactly() {
4763 let cfg = tiny_test_config();
4764 let vocab = 8;
4765 let tokens = [1usize, 3, 5, 2, 7];
4766
4767 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
4768 let mut caches_a: Vec<KvCache> = (0..2)
4769 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
4770 .collect();
4771 let sequential: Vec<Vec<f32>> = tokens
4772 .iter()
4773 .enumerate()
4774 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
4775 .collect();
4776
4777 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
4782 let mut caches_b: Vec<KvCache> = (0..2)
4783 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
4784 .collect();
4785 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
4786
4787 assert_eq!(batched.len(), sequential.len());
4788 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
4789 assert_eq!(seq_logits.len(), batch_logits.len());
4790 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
4791 assert!(
4792 (s - b).abs() < 1e-3,
4793 "position {pos}, logit {i}: sequential={s} batched={b}"
4794 );
4795 }
4796 }
4797 }
4798
4799 #[test]
4806 fn forward_batch_last_matches_the_final_row_of_forward_batch() {
4807 let cfg = tiny_test_config();
4808 let vocab = 16;
4809 let tokens = vec![1usize, 4, 7, 2, 9];
4810
4811 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
4812 let mut caches_a: Vec<KvCache> = (0..2)
4813 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
4814 .collect();
4815 let all_rows = decoder_a.forward_batch(&tokens, 0, &mut caches_a);
4816
4817 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
4818 let mut caches_b: Vec<KvCache> = (0..2)
4819 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
4820 .collect();
4821 let last = decoder_b.forward_batch_last(&tokens, 0, &mut caches_b);
4822
4823 let expected = all_rows.last().expect("one row per prompt token");
4824 assert_eq!(last.len(), expected.len());
4825 for (i, (a, b)) in expected.iter().zip(last.iter()).enumerate() {
4826 assert!(
4827 (a - b).abs() < 1e-4,
4828 "logit {i}: forward_batch={a} forward_batch_last={b}"
4829 );
4830 }
4831
4832 let next_a = decoder_a.forward_token(3, tokens.len(), &mut caches_a);
4834 let next_b = decoder_b.forward_token(3, tokens.len(), &mut caches_b);
4835 for (i, (a, b)) in next_a.iter().zip(next_b.iter()).enumerate() {
4836 assert!(
4837 (a - b).abs() < 1e-4,
4838 "post-prefill decode logit {i}: {a} vs {b}"
4839 );
4840 }
4841
4842 let mut caches_c: Vec<KvCache> = (0..2)
4844 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
4845 .collect();
4846 assert!(decoder_b
4847 .forward_batch_last(&[], 0, &mut caches_c)
4848 .is_empty());
4849 }
4850
4851 #[test]
4860 fn forward_multi_seq_matches_independent_forward_token_per_sequence() {
4861 let cfg = tiny_test_config();
4862 let vocab = 8;
4863 let seq_histories: [&[usize]; 3] = [&[1, 3, 5], &[2, 7], &[4, 4, 4, 6]];
4867
4868 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
4869 let mut independent_logits: Vec<Vec<f32>> = Vec::new();
4870 for history in seq_histories.iter() {
4871 let mut caches: Vec<KvCache> = (0..2)
4872 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
4873 .collect();
4874 let mut logits = Vec::new();
4875 for (pos, &tok) in history.iter().enumerate() {
4876 logits = decoder_a.forward_token(tok, pos, &mut caches);
4877 }
4878 independent_logits.push(logits);
4879 }
4880
4881 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
4884 let mut per_seq_caches: Vec<Vec<KvCache>> = seq_histories
4885 .iter()
4886 .map(|_| {
4887 (0..2)
4888 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
4889 .collect()
4890 })
4891 .collect();
4892
4893 let max_len = seq_histories.iter().map(|h| h.len()).max().unwrap();
4901 let mut batched_logits: Vec<Vec<f32>> = vec![Vec::new(); seq_histories.len()];
4902 for step in 0..max_len {
4903 let mut tokens = Vec::new();
4904 let mut positions = Vec::new();
4905 let mut active: Vec<usize> = Vec::new();
4906 for (s, history) in seq_histories.iter().enumerate() {
4907 if step < history.len() {
4908 tokens.push(history[step]);
4909 positions.push(step);
4910 active.push(s);
4911 }
4912 }
4913 if tokens.is_empty() {
4914 continue;
4915 }
4916 let mut active_caches: Vec<Vec<KvCache>> = active
4917 .iter()
4918 .map(|&s| std::mem::take(&mut per_seq_caches[s]))
4919 .collect();
4920 let step_logits = decoder_b.forward_multi_seq(&tokens, &positions, &mut active_caches);
4921 for ((&s, caches), logits) in active.iter().zip(active_caches).zip(step_logits) {
4922 per_seq_caches[s] = caches;
4923 batched_logits[s] = logits;
4924 }
4925 }
4926
4927 assert_eq!(batched_logits.len(), independent_logits.len());
4928 for (s, (seq_logits, batch_logits)) in independent_logits
4929 .iter()
4930 .zip(batched_logits.iter())
4931 .enumerate()
4932 {
4933 assert_eq!(seq_logits.len(), batch_logits.len());
4934 for (i, (a, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
4935 assert!(
4936 (a - b).abs() < 1e-3,
4937 "sequence {s}, logit {i}: independent={a} batched={b}"
4938 );
4939 }
4940 }
4941 }
4942
4943 #[test]
4952 fn forward_batch_matches_forward_token_with_qk_norm_present() {
4953 let cfg = tiny_test_config();
4954 let vocab = 8;
4955 let tokens = [1usize, 3, 5, 2, 7];
4956 let q_width = cfg.n_heads * cfg.head_dim;
4957 let kv_width = cfg.n_kv_heads * cfg.head_dim;
4958
4959 let mut decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
4960 for layer in &mut decoder_a.layers {
4961 layer.attn.q_norm = Some((0..q_width).map(|i| 1.0 + i as f32 * 0.1).collect());
4962 layer.attn.k_norm = Some((0..kv_width).map(|i| 0.5 + i as f32 * 0.05).collect());
4963 }
4964 let mut caches_a: Vec<KvCache> = (0..2)
4965 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
4966 .collect();
4967 let sequential: Vec<Vec<f32>> = tokens
4968 .iter()
4969 .enumerate()
4970 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
4971 .collect();
4972
4973 let mut decoder_b = Decoder::new_random_small(cfg, 2, vocab);
4974 for layer in &mut decoder_b.layers {
4975 layer.attn.q_norm = Some((0..q_width).map(|i| 1.0 + i as f32 * 0.1).collect());
4976 layer.attn.k_norm = Some((0..kv_width).map(|i| 0.5 + i as f32 * 0.05).collect());
4977 }
4978 let mut caches_b: Vec<KvCache> = (0..2)
4979 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
4980 .collect();
4981 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
4982
4983 assert_eq!(batched.len(), sequential.len());
4984 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
4985 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
4986 assert!(
4987 (s - b).abs() < 1e-3,
4988 "position {pos}, logit {i}: sequential={s} batched={b}"
4989 );
4990 }
4991 }
4992 }
4993
4994 #[test]
5004 fn qk_norm_present_changes_output_versus_absent() {
5005 let cfg = tiny_test_config();
5006 let vocab = 8;
5007 let q_width = cfg.n_heads * cfg.head_dim;
5008 let kv_width = cfg.n_kv_heads * cfg.head_dim;
5009 let tokens = [3usize, 5];
5010
5011 let without_norm = Decoder::new_random_small(cfg.clone(), 1, vocab);
5012 let mut with_norm = Decoder::new_random_small(cfg, 1, vocab);
5013 for layer in &mut with_norm.layers {
5014 layer.attn.q_norm = Some(vec![2.0; q_width]);
5015 layer.attn.k_norm = Some(vec![2.0; kv_width]);
5016 }
5017
5018 let mut caches_a: Vec<KvCache> = (0..1)
5019 .map(|_| KvCache::new(without_norm.config.n_kv_heads, without_norm.config.head_dim))
5020 .collect();
5021 let mut caches_b: Vec<KvCache> = (0..1)
5022 .map(|_| KvCache::new(with_norm.config.n_kv_heads, with_norm.config.head_dim))
5023 .collect();
5024
5025 let mut out_a = Vec::new();
5026 let mut out_b = Vec::new();
5027 for (pos, &t) in tokens.iter().enumerate() {
5028 out_a = without_norm.forward_token(t, pos, &mut caches_a);
5029 out_b = with_norm.forward_token(t, pos, &mut caches_b);
5030 }
5031
5032 let differs = out_a
5033 .iter()
5034 .zip(out_b.iter())
5035 .any(|(a, b)| (a - b).abs() > 1e-4);
5036 assert!(
5037 differs,
5038 "QK-norm weights changed nothing -- forward_token likely isn't applying q_norm/k_norm"
5039 );
5040 }
5041
5042 #[test]
5058 fn forward_batch_matches_forward_token_with_qkv_bias_present() {
5059 let cfg = tiny_test_config();
5060 let vocab = 8;
5061 let tokens = [1usize, 3, 5, 2, 7];
5062 let q_width = cfg.n_heads * cfg.head_dim;
5063 let kv_width = cfg.n_kv_heads * cfg.head_dim;
5064
5065 let mut decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
5066 for layer in &mut decoder_a.layers {
5067 layer.attn.q_bias = Some((0..q_width).map(|i| 0.3 + i as f32 * 0.02).collect());
5068 layer.attn.k_bias = Some((0..kv_width).map(|i| -0.2 + i as f32 * 0.03).collect());
5069 layer.attn.v_bias = Some((0..kv_width).map(|i| 0.1 - i as f32 * 0.01).collect());
5070 }
5071 let mut caches_a: Vec<KvCache> = (0..2)
5072 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5073 .collect();
5074 let sequential: Vec<Vec<f32>> = tokens
5075 .iter()
5076 .enumerate()
5077 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
5078 .collect();
5079
5080 let mut decoder_b = Decoder::new_random_small(cfg, 2, vocab);
5081 for layer in &mut decoder_b.layers {
5082 layer.attn.q_bias = Some((0..q_width).map(|i| 0.3 + i as f32 * 0.02).collect());
5083 layer.attn.k_bias = Some((0..kv_width).map(|i| -0.2 + i as f32 * 0.03).collect());
5084 layer.attn.v_bias = Some((0..kv_width).map(|i| 0.1 - i as f32 * 0.01).collect());
5085 }
5086 let mut caches_b: Vec<KvCache> = (0..2)
5087 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5088 .collect();
5089 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
5090
5091 assert_eq!(batched.len(), sequential.len());
5092 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
5093 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
5094 assert!(
5095 (s - b).abs() < 1e-3,
5096 "position {pos}, logit {i}: sequential={s} batched={b}"
5097 );
5098 }
5099 }
5100 }
5101
5102 #[test]
5103 fn qkv_bias_present_changes_output_versus_absent() {
5104 let cfg = tiny_test_config();
5105 let vocab = 8;
5106 let q_width = cfg.n_heads * cfg.head_dim;
5107 let kv_width = cfg.n_kv_heads * cfg.head_dim;
5108 let tokens = [3usize, 5];
5109
5110 let without_bias = Decoder::new_random_small(cfg.clone(), 1, vocab);
5111 let mut with_bias = Decoder::new_random_small(cfg, 1, vocab);
5112 for layer in &mut with_bias.layers {
5113 layer.attn.q_bias = Some(vec![0.5; q_width]);
5114 layer.attn.k_bias = Some(vec![0.5; kv_width]);
5115 layer.attn.v_bias = Some(vec![0.5; kv_width]);
5116 }
5117
5118 let mut caches_a: Vec<KvCache> = (0..1)
5119 .map(|_| KvCache::new(without_bias.config.n_kv_heads, without_bias.config.head_dim))
5120 .collect();
5121 let mut caches_b: Vec<KvCache> = (0..1)
5122 .map(|_| KvCache::new(with_bias.config.n_kv_heads, with_bias.config.head_dim))
5123 .collect();
5124
5125 let mut out_a = Vec::new();
5126 let mut out_b = Vec::new();
5127 for (pos, &t) in tokens.iter().enumerate() {
5128 out_a = without_bias.forward_token(t, pos, &mut caches_a);
5129 out_b = with_bias.forward_token(t, pos, &mut caches_b);
5130 }
5131
5132 let differs = out_a
5133 .iter()
5134 .zip(out_b.iter())
5135 .any(|(a, b)| (a - b).abs() > 1e-4);
5136 assert!(
5137 differs,
5138 "QKV bias changed nothing -- forward_token likely isn't applying q_bias/k_bias/v_bias"
5139 );
5140 }
5141
5142 #[test]
5143 fn forward_batch_and_forward_token_leave_kv_caches_in_the_same_state() {
5144 let cfg = tiny_test_config();
5145 let tokens = [2usize, 4, 6];
5146
5147 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, 8);
5148 let mut caches_a: Vec<KvCache> = (0..2)
5149 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5150 .collect();
5151 for (pos, &t) in tokens.iter().enumerate() {
5152 decoder_a.forward_token(t, pos, &mut caches_a);
5153 }
5154
5155 let decoder_b = Decoder::new_random_small(cfg, 2, 8);
5156 let mut caches_b: Vec<KvCache> = (0..2)
5157 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5158 .collect();
5159 decoder_b.forward_batch(&tokens, 0, &mut caches_b);
5160
5161 for (ca, cb) in caches_a.iter().zip(caches_b.iter()) {
5162 assert_eq!(ca.seq_len, cb.seq_len);
5163 assert_eq!(ca.k.len(), cb.k.len());
5164 for (a, b) in ca.k.iter().zip(cb.k.iter()) {
5165 assert!((a - b).abs() < 1e-4);
5166 }
5167 }
5168 }
5169
5170 fn tiny_dense_test_config() -> ModelConfig {
5175 let mut cfg = tiny_test_config();
5176 cfg.moe.n_experts = 1;
5177 cfg.moe.n_experts_active = 1;
5178 cfg.moe.n_shared_experts = 0;
5179 cfg
5180 }
5181
5182 #[test]
5183 fn dense_layer_forward_pass_produces_finite_logits_of_correct_shape() {
5184 let vocab = 10;
5185 let decoder = Decoder::new_random_small(tiny_dense_test_config(), 2, vocab);
5186 let mut caches: Vec<KvCache> = (0..2)
5187 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5188 .collect();
5189
5190 let logits = decoder.forward_token(3, 0, &mut caches);
5191 assert_eq!(logits.len(), vocab);
5192 assert!(
5193 logits.iter().all(|v| v.is_finite()),
5194 "logits must not contain NaN/Inf"
5195 );
5196 }
5197
5198 #[test]
5199 fn dense_layer_forward_batch_matches_sequential_forward_token_exactly() {
5200 let cfg = tiny_dense_test_config();
5201 let vocab = 8;
5202 let tokens = [1usize, 3, 5, 2, 7];
5203
5204 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
5205 let mut caches_a: Vec<KvCache> = (0..2)
5206 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5207 .collect();
5208 let sequential: Vec<Vec<f32>> = tokens
5209 .iter()
5210 .enumerate()
5211 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
5212 .collect();
5213
5214 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
5215 let mut caches_b: Vec<KvCache> = (0..2)
5216 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5217 .collect();
5218 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
5219
5220 assert_eq!(batched.len(), sequential.len());
5221 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
5222 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
5223 assert!(
5224 (s - b).abs() < 1e-3,
5225 "position {pos}, logit {i}: sequential={s} batched={b}"
5226 );
5227 }
5228 }
5229 }
5230
5231 #[test]
5232 fn dense_layer_fast_path_still_records_expert_zero_activations() {
5233 let decoder = Decoder::new_random_small(tiny_dense_test_config(), 1, 8);
5239 let mut caches: Vec<KvCache> = (0..1)
5240 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5241 .collect();
5242
5243 decoder.forward_token(0, 0, &mut caches);
5244 decoder.forward_token(1, 1, &mut caches);
5245 decoder.forward_token(2, 2, &mut caches);
5246
5247 let count =
5248 decoder.layers[0].moe.activation_counts[0].load(std::sync::atomic::Ordering::Relaxed);
5249 assert_eq!(count, 3);
5250 }
5251
5252 #[test]
5253 fn forward_batch_with_empty_tokens_returns_empty() {
5254 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
5255 let mut caches: Vec<KvCache> = (0..2)
5256 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5257 .collect();
5258 let out = decoder.forward_batch(&[], 0, &mut caches);
5259 assert!(out.is_empty());
5260 }
5261
5262 #[test]
5263 fn forward_batch_continues_correctly_after_prior_forward_token_calls() {
5264 let cfg = tiny_test_config();
5270
5271 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, 8);
5272 let mut caches_a: Vec<KvCache> = (0..2)
5273 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5274 .collect();
5275 decoder_a.forward_token(1, 0, &mut caches_a);
5276 decoder_a.forward_token(3, 1, &mut caches_a);
5277 let seq_next = decoder_a.forward_token(5, 2, &mut caches_a);
5278
5279 let decoder_b = Decoder::new_random_small(cfg, 2, 8);
5280 let mut caches_b: Vec<KvCache> = (0..2)
5281 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5282 .collect();
5283 decoder_b.forward_token(1, 0, &mut caches_b);
5284 let batch_next = decoder_b.forward_batch(&[3, 5], 1, &mut caches_b);
5285
5286 for (s, b) in seq_next.iter().zip(batch_next[1].iter()) {
5287 assert!((s - b).abs() < 1e-3, "sequential={s} batched={b}");
5288 }
5289 }
5290
5291 #[test]
5300 fn placement_plan_reflects_real_observed_expert_activations() {
5301 let cfg = tiny_test_config(); let decoder = Decoder::new_random_small(cfg, 2, 16);
5303 let mut caches: Vec<KvCache> = (0..2)
5304 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5305 .collect();
5306
5307 let n_calls = 20;
5308 for pos in 0..n_calls {
5309 decoder.forward_token(pos % 16, pos, &mut caches);
5310 }
5311
5312 let layer0 = &decoder.layers[0].moe;
5313 let counts: Vec<u64> = layer0
5314 .activation_counts
5315 .iter()
5316 .map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
5317 .collect();
5318 let total: u64 = counts.iter().sum();
5319 assert_eq!(
5320 total,
5321 (n_calls as u64) * (decoder.config.moe.n_experts_active as u64),
5322 "total recorded activations must equal calls * experts_active_per_call"
5323 );
5324
5325 let hottest_count = *counts.iter().max().unwrap();
5331 let hottest_idx = counts.iter().position(|&c| c == hottest_count).unwrap();
5332 assert!(hottest_count > 0);
5333
5334 let per_expert_bytes = layer0.expert_bytes(0);
5336 let plan = layer0.placement_plan(per_expert_bytes as u64);
5337
5338 assert_eq!(
5339 plan.placement_for(hottest_idx),
5340 ferrox_moe::ExpertPlacement::GpuDevice(0),
5341 "the genuinely hottest expert (index {hottest_idx}, {hottest_count} activations) \
5342 must be the one the plan places on GPU when only one expert fits the budget"
5343 );
5344 }
5345}