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 exp_probs_bias: Option<Vec<f32>>,
158 pub activation_counts: Vec<AtomicU64>,
165 #[cfg(feature = "metal")]
172 pub packed_q4: Option<MoePackedQ4Planes>,
173}
174
175#[cfg(feature = "metal")]
177pub struct MoePackedQ4Planes {
178 gate: ferrox_core::weight_matrix::WeightBytes,
179 up: ferrox_core::weight_matrix::WeightBytes,
180 down: ferrox_core::weight_matrix::WeightBytes,
181 gate_stride: usize,
182 up_stride: usize,
183 down_stride: usize,
184 n_experts: usize,
185 ffn_rows: usize,
186 hidden_rows: usize,
187 gate_row_bytes: usize,
188 down_row_bytes: usize,
189 gate_kind: &'static str,
190 up_kind: &'static str,
191 down_kind: &'static str,
192}
193
194#[cfg(feature = "metal")]
195impl MoePackedQ4Planes {
196 #[allow(clippy::too_many_arguments)]
197 pub(crate) fn new(
198 gate: ferrox_core::weight_matrix::WeightBytes,
199 up: ferrox_core::weight_matrix::WeightBytes,
200 down: ferrox_core::weight_matrix::WeightBytes,
201 gate_stride: usize,
202 up_stride: usize,
203 down_stride: usize,
204 n_experts: usize,
205 ffn_rows: usize,
206 hidden_rows: usize,
207 gate_kind: &'static str,
208 up_kind: &'static str,
209 down_kind: &'static str,
210 ) -> Self {
211 Self {
212 gate,
213 up,
214 down,
215 gate_stride,
216 up_stride,
217 down_stride,
218 n_experts,
219 ffn_rows,
220 hidden_rows,
221 gate_row_bytes: gate_stride / ffn_rows,
222 down_row_bytes: down_stride / hidden_rows,
223 gate_kind,
224 up_kind,
225 down_kind,
226 }
227 }
228
229 pub fn view(&self) -> ferrox_metal::gpu::MoePackedQ4<'_> {
230 ferrox_metal::gpu::MoePackedQ4 {
231 gate: self.gate.as_slice(),
232 up: self.up.as_slice(),
233 down: self.down.as_slice(),
234 gate_stride: self.gate_stride,
235 up_stride: self.up_stride,
236 down_stride: self.down_stride,
237 n_experts: self.n_experts,
238 ffn_rows: self.ffn_rows,
239 hidden_rows: self.hidden_rows,
240 gate_row_bytes: self.gate_row_bytes,
241 down_row_bytes: self.down_row_bytes,
242 gate_kind: self.gate_kind,
243 up_kind: self.up_kind,
244 down_kind: self.down_kind,
245 }
246 }
247}
248
249impl MoeWeights {
250 pub fn n_experts(&self) -> usize {
251 self.experts.n_experts()
252 }
253
254 pub fn expert_bytes(&self, e: usize) -> usize {
258 match &self.experts {
259 ExpertBacking::Resident(v) => {
260 let ex = &v[e];
261 ex.gate.resident_bytes() + ex.up.resident_bytes() + ex.down.resident_bytes()
262 }
263 ExpertBacking::Stored { layouts, .. } => layouts[e].total_bytes(),
264 }
265 }
266
267 pub fn with_expert<R>(&self, e: usize, f: impl FnOnce(&ExpertWeights) -> R) -> R {
272 match &self.experts {
273 ExpertBacking::Resident(v) => f(&v[e]),
274 ExpertBacking::Stored {
275 store,
276 layouts,
277 layer,
278 } => {
279 let lease = store
280 .acquire(ferrox_core::expert_store::ExpertKey {
281 layer: *layer,
282 expert: e as u32,
283 })
284 .unwrap_or_else(|err| {
285 panic!(
286 "expert store read failed for layer {layer} expert {e}: {err} \
287 (checkpoint file unreadable mid-decode)"
288 )
289 });
290 let tmp = layouts[e].materialize(&lease);
291 f(&tmp)
292 }
293 }
294 }
295
296 fn record_activations(&self, expert_ids: &[usize]) {
297 for &eid in expert_ids {
298 if let Some(counter) = self.activation_counts.get(eid) {
299 counter.fetch_add(1, Ordering::Relaxed);
300 }
301 }
302 }
303
304 pub fn placement_plan(&self, vram_budget_bytes: u64) -> PlacementPlan {
311 let sizes: Vec<usize> = (0..self.n_experts())
312 .map(|e| self.expert_bytes(e))
313 .collect();
314 let counts: Vec<u64> = self
315 .activation_counts
316 .iter()
317 .map(|c| c.load(Ordering::Relaxed))
318 .collect();
319 let has_observations = counts.iter().any(|&c| c > 0);
320 PlacementPlan::from_budget(
321 &sizes,
322 has_observations.then_some(counts.as_slice()),
323 vram_budget_bytes,
324 )
325 }
326}
327
328pub struct LayerWeights {
329 pub attn: AttnWeights,
330 pub moe: MoeWeights,
331}
332
333pub struct GptOssLayer {
349 pub attn_sinks: Vec<f32>,
351 pub o_bias: Vec<f32>,
353 pub router_bias: Vec<f32>,
355 pub expert_bias: Vec<ferrox_moe::ExpertBias>,
357}
358
359pub struct GptOssWeights {
361 pub layers: Vec<GptOssLayer>,
362}
363
364pub struct Decoder {
365 pub config: ModelConfig,
366 pub embedding: WeightMatrix,
373 pub layers: Vec<LayerWeights>,
374 pub final_norm: Vec<f32>,
375 pub output_head: WeightMatrix, pub gpu_vram_budget_bytes: Option<u64>,
396 pub gpt_oss: Option<GptOssWeights>,
403 #[cfg(feature = "metal")]
411 pub(crate) metal_attn_kv: std::sync::Mutex<Option<Vec<ferrox_metal::attn::MetalKvBuffers>>>,
412 pub execution_plan: crate::execution_plan::ExecutionPlan,
416 pub plan_cache: std::sync::Mutex<
419 std::collections::HashMap<
420 crate::execution_plan::PlanGeometry,
421 crate::execution_plan::FusedOpCaps,
422 >,
423 >,
424}
425
426struct Lcg(u64);
429impl Lcg {
430 fn new(seed: u64) -> Self {
431 Lcg(seed)
432 }
433 fn next_f32(&mut self) -> f32 {
434 self.0 ^= self.0 << 13;
436 self.0 ^= self.0 >> 7;
437 self.0 ^= self.0 << 17;
438 ((self.0 >> 40) as f32 / (1u64 << 24) as f32) - 0.5
439 }
440 fn vec(&mut self, n: usize) -> Vec<f32> {
441 (0..n).map(|_| self.next_f32() * 0.1).collect()
442 }
443}
444
445impl Decoder {
446 pub fn probe_kernels(&self) {
462 use ferrox_core::kernel_registry as reg;
463
464 if !reg::enabled() {
465 return;
466 }
467 self.embedding.probe_kernels("token_embd");
468 self.output_head.probe_kernels("output_head");
469 for layer in &self.layers {
470 layer.attn.q_proj.probe_kernels("attn_q");
471 layer.attn.k_proj.probe_kernels("attn_k");
472 layer.attn.v_proj.probe_kernels("attn_v");
473 layer.attn.o_proj.probe_kernels("attn_o");
474 layer.moe.router.probe_kernels("moe_router");
475 for e in &layer.moe.shared_experts {
476 e.gate.probe_kernels("shexp_gate");
477 e.up.probe_kernels("shexp_up");
478 e.down.probe_kernels("shexp_down");
479 }
480 if let ExpertBacking::Resident(experts) = &layer.moe.experts {
481 for e in experts {
482 e.gate.probe_kernels("ffn_gate");
483 e.up.probe_kernels("ffn_up");
484 e.down.probe_kernels("ffn_down");
485 }
486 }
487 }
488 reg::record_build(
494 reg::Lookup::new(
495 ferrox_core::weight_matrix::active_backend(),
496 reg::op::ENGINE_PREFILL_BATCH,
497 None,
498 )
499 .with_role("generic_decoder"),
500 reg::Outcome::Hit,
501 );
502 }
503
504 pub fn new_random_small(config: ModelConfig, n_layers: usize, vocab_size: usize) -> Self {
511 let mut rng = Lcg::new(42);
512 let mut config = config;
513 config.n_layers = n_layers;
514 config.vocab_size = vocab_size;
515 let hidden = config.hidden_dim;
516 let head_dim = config.head_dim;
517 let n_heads = config.n_heads;
518 let n_kv_heads = config.n_kv_heads;
519
520 let embedding = WeightMatrix::F32(Tensor::new(
521 rng.vec(vocab_size * hidden),
522 vec![vocab_size, hidden],
523 ));
524
525 let wm = |data: Vec<f32>, shape: Vec<usize>| WeightMatrix::F32(Tensor::new(data, shape));
526
527 let mut layers = Vec::with_capacity(n_layers);
528 for layer_idx in 0..n_layers {
529 let attn = AttnWeights {
530 q_proj: wm(
531 rng.vec(n_heads * head_dim * hidden),
532 vec![n_heads * head_dim, hidden],
533 ),
534 k_proj: wm(
535 rng.vec(n_kv_heads * head_dim * hidden),
536 vec![n_kv_heads * head_dim, hidden],
537 ),
538 v_proj: wm(
539 rng.vec(n_kv_heads * head_dim * hidden),
540 vec![n_kv_heads * head_dim, hidden],
541 ),
542 o_proj: wm(
543 rng.vec(hidden * n_heads * head_dim),
544 vec![hidden, n_heads * head_dim],
545 ),
546 norm_weight: vec![1.0; hidden],
547 q_norm: None,
548 k_norm: None,
549 q_bias: None,
550 k_bias: None,
551 v_bias: None,
552 post_attn_norm: None,
553 post_ffn_norm: None,
554 };
555
556 let is_dense_layer = config.layer_is_dense(layer_idx);
562 let n_experts = if is_dense_layer {
563 1
564 } else {
565 config.moe.n_experts
566 };
567 let n_shared = if is_dense_layer {
568 0
569 } else {
570 config.moe.n_shared_experts
571 };
572 let ffn_dim = config.moe.expert_ffn_dim;
573 let make_expert = |rng: &mut Lcg| ExpertWeights {
574 gate: WeightMatrix::F32(Tensor::new(
575 rng.vec(ffn_dim * hidden),
576 vec![ffn_dim, hidden],
577 )),
578 up: WeightMatrix::F32(Tensor::new(
579 rng.vec(ffn_dim * hidden),
580 vec![ffn_dim, hidden],
581 )),
582 down: WeightMatrix::F32(Tensor::new(
583 rng.vec(hidden * ffn_dim),
584 vec![hidden, ffn_dim],
585 )),
586 };
587 let experts: Vec<ExpertWeights> =
588 (0..n_experts).map(|_| make_expert(&mut rng)).collect();
589 let shared_experts = (0..n_shared).map(|_| make_expert(&mut rng)).collect();
590 let activation_counts = (0..experts.len()).map(|_| AtomicU64::new(0)).collect();
591
592 let moe = MoeWeights {
593 exp_probs_bias: None,
594 router: wm(rng.vec(n_experts * hidden), vec![n_experts, hidden]),
595 experts: ExpertBacking::Resident(experts),
596 shared_experts,
597 shared_expert_gate: None,
598 norm_weight: vec![1.0; hidden],
599 activation_counts,
600 #[cfg(feature = "metal")]
601 packed_q4: None,
602 };
603
604 layers.push(LayerWeights { attn, moe });
605 }
606
607 let final_norm = vec![1.0; hidden];
608 let output_head = wm(rng.vec(vocab_size * hidden), vec![vocab_size, hidden]);
609 let execution_plan = crate::execution_plan::ExecutionPlan::from_config(
610 &config,
611 crate::capability::DecoderFamily::StandardGqa,
612 crate::capability::MemoryKind::KvGqa,
613 crate::execution_plan::ExecutionPlan::probe_metal_caps(),
614 );
615
616 Decoder {
617 config,
618 embedding,
619 layers,
620 final_norm,
621 output_head,
622 gpu_vram_budget_bytes: None,
623 gpt_oss: None,
625 #[cfg(feature = "metal")]
626 metal_attn_kv: std::sync::Mutex::new(None),
627 execution_plan,
628 plan_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
629 }
630 }
631
632 fn apply_rope_head_theta(&self, slice: &mut [f32], pos: usize, theta: f32) {
640 use crate::config::RopeLayout;
641 let slice = match self.config.rope_dim {
648 Some(rot) if rot < slice.len() => &mut slice[..rot],
649 _ => slice,
650 };
651 match (self.config.rope_layout, &self.config.rope_freqs) {
652 (RopeLayout::Norm, Some(freq_factors)) => {
653 apply_rope_interleaved_with_freq_factors(slice, pos, theta, freq_factors)
654 }
655 (RopeLayout::Norm, None) => apply_rope_interleaved(slice, pos, theta),
656 (RopeLayout::Neox, Some(freq_factors)) => {
657 apply_rope_with_freq_factors(slice, pos, theta, freq_factors)
658 }
659 (RopeLayout::Neox, None) => apply_rope(slice, pos, theta),
660 }
661 }
662
663 fn apply_rope_head_layer(&self, slice: &mut [f32], pos: usize, layer_idx: usize) {
664 self.apply_rope_head_theta(slice, pos, self.config.layer_rope_theta(layer_idx))
665 }
666
667 #[inline]
678 fn apply_rope_attn_factor(&self, q: &mut [f32], k: &mut [f32]) {
679 let m = self.config.rope_attn_factor;
680 if m == 1.0 {
681 return;
682 }
683 let head_dim = self.config.head_dim;
693 let rot = self.config.rope_dim.unwrap_or(head_dim).min(head_dim);
694 for buf in [q, k] {
695 for head in buf.chunks_mut(head_dim) {
696 let n = rot.min(head.len());
697 for v in head[..n].iter_mut() {
698 *v *= m;
699 }
700 }
701 }
702 }
703
704 fn apply_qk_norm(&self, x: &[f32], weight: &[f32]) -> Vec<f32> {
706 use crate::capability::QkNormStyle;
707 match self.config.qk_norm_style {
708 QkNormStyle::WholeVector => rms_norm(x, weight, self.config.rms_norm_eps),
709 QkNormStyle::PerHead => {
710 rms_norm_per_head(x, weight, self.config.head_dim, self.config.rms_norm_eps)
711 }
712 }
713 }
714
715 #[cfg(feature = "metal")]
718 fn metal_matvec_launch<'a>(m: &'a WeightMatrix) -> Option<ferrox_metal::gpu::MatvecLaunch<'a>> {
719 match m {
720 WeightMatrix::F32(t) => {
721 let rows = t.shape[0];
722 let cols = t.shape[1];
723 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
724 ferrox_metal::gpu::matvec_launch_meta("F32")?;
725 let bytes = unsafe {
727 std::slice::from_raw_parts(t.data.as_ptr() as *const u8, t.data.len() * 4)
728 };
729 Some(ferrox_metal::gpu::MatvecLaunch {
730 kernel_src: src,
731 fn_name,
732 block_bytes,
733 block_elems,
734 weights: bytes,
735 rows,
736 row_bytes: cols * 4,
737 rows_per_tg,
738 })
739 }
740 WeightMatrix::Quantized {
741 data,
742 rows,
743 cols: _,
744 kind,
745 } => {
746 let kind_name = match kind {
747 ferrox_core::QuantKind::Q8_0 => "Q8_0",
748 ferrox_core::QuantKind::Q4_0 => "Q4_0",
749 ferrox_core::QuantKind::Q4K => "Q4_K",
750 ferrox_core::QuantKind::Q5K => "Q5_K",
751 ferrox_core::QuantKind::Q6K => "Q6_K",
752 ferrox_core::QuantKind::IQ4XS => "IQ4_XS",
753 _ => return None,
754 };
755 let (src, fn_name, block_bytes, block_elems, rows_per_tg) =
756 ferrox_metal::gpu::matvec_launch_meta(kind_name)?;
757 let row_bytes = data.as_slice().len().checked_div(*rows).unwrap_or(0);
762 Some(ferrox_metal::gpu::MatvecLaunch {
763 kernel_src: src,
764 fn_name,
765 block_bytes,
766 block_elems,
767 weights: data.as_slice(),
768 rows: *rows,
769 row_bytes,
770 rows_per_tg,
771 })
772 }
773 _ => None,
774 }
775 }
776
777 #[cfg(feature = "metal")]
781 fn layer_supports_metal_attn(&self, layer: &LayerWeights) -> bool {
782 use crate::config::RopeLayout;
783 if self.gpt_oss.is_some() {
788 return false;
789 }
790 if !matches!(self.config.rope_layout, RopeLayout::Norm | RopeLayout::Neox) {
791 return false;
792 }
793 let q_len = self.config.n_heads * self.config.head_dim;
796 let k_len = self.config.n_kv_heads * self.config.head_dim;
797 let qk_norm_ok = |w: Option<&Vec<f32>>, vec_len: usize| -> bool {
798 match w {
799 None => true,
800 Some(w) if w.len() == self.config.head_dim => true,
801 Some(w) if w.len() == vec_len => true,
802 _ => false,
803 }
804 };
805 if !qk_norm_ok(layer.attn.q_norm.as_ref(), q_len)
806 || !qk_norm_ok(layer.attn.k_norm.as_ref(), k_len)
807 {
808 return false;
809 }
810 if self.config.head_dim > 256 {
815 return false;
816 }
817 if self
824 .config
825 .rope_dim
826 .is_some_and(|rot| rot == 0 || rot % 2 != 0 || rot > self.config.head_dim)
827 {
828 return false;
829 }
830 Self::metal_matvec_launch(&layer.attn.q_proj).is_some()
831 && Self::metal_matvec_launch(&layer.attn.k_proj).is_some()
832 && Self::metal_matvec_launch(&layer.attn.v_proj).is_some()
833 && Self::metal_matvec_launch(&layer.attn.o_proj).is_some()
834 }
835
836 #[cfg(feature = "metal")]
839 fn layer_needs_metal_stack(&self, layer: &LayerWeights, layer_idx: usize) -> bool {
840 layer.attn.post_attn_norm.is_some()
841 || layer.attn.post_ffn_norm.is_some()
842 || self.config.layer_sliding_window(layer_idx).is_some()
843 || matches!(
844 self.config.ffn_activation,
845 crate::config::FfnActivation::Gelu
846 )
847 || self.config.layer_rope_theta(layer_idx) != self.config.rope_theta
848 }
849
850 #[cfg(feature = "metal")]
852 fn metal_attn_extras<'a>(&self, layer: &'a LayerWeights) -> ferrox_metal::attn::AttnExtras<'a> {
853 ferrox_metal::attn::AttnExtras {
854 q_bias: layer.attn.q_bias.as_deref(),
855 k_bias: layer.attn.k_bias.as_deref(),
856 v_bias: layer.attn.v_bias.as_deref(),
857 q_norm: layer.attn.q_norm.as_deref(),
858 k_norm: layer.attn.k_norm.as_deref(),
859 attn_logit_softcap: self.config.attn_logit_softcap,
860 }
861 }
862
863 #[cfg(feature = "metal")]
867 fn expert_residency_plan(&self, use_metal_attn: bool) -> Option<ferrox_moe::ResidencyPlan> {
868 if ferrox_core::metal_dense_enabled()
869 && ferrox_metal::attn::metal_attn_enabled()
870 && !use_metal_attn
871 {
872 return None;
873 }
874 self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b))
875 }
876
877 #[cfg(not(feature = "metal"))]
878 fn expert_residency_plan(&self, _use_metal_attn: bool) -> Option<ferrox_moe::ResidencyPlan> {
879 self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b))
880 }
881
882 #[cfg(feature = "metal")]
889 fn metal_rope(&self) -> ferrox_metal::attn::MetalRope {
890 use crate::config::RopeLayout;
891 let layout = match self.config.rope_layout {
892 RopeLayout::Norm => ferrox_metal::attn::MetalRopeLayout::Norm,
893 RopeLayout::Neox => ferrox_metal::attn::MetalRopeLayout::Neox,
894 };
895 ferrox_metal::attn::MetalRope {
896 layout,
897 rot_dim: self
898 .config
899 .rope_dim
900 .filter(|rot| *rot < self.config.head_dim),
901 attn_factor: self.config.rope_attn_factor,
902 }
903 }
904
905 #[cfg(feature = "metal")]
907 fn layer_supports_metal_dense_ffn(layer: &LayerWeights) -> bool {
908 Self::is_dense_layer(layer)
909 && layer.moe.with_expert(0, |ex| {
910 Self::metal_matvec_launch(&ex.gate).is_some()
911 && Self::metal_matvec_launch(&ex.up).is_some()
912 && Self::metal_matvec_launch(&ex.down).is_some()
913 })
914 }
915
916 #[cfg(feature = "metal")]
920 fn metal_prefill_dense_layer_eligible(layer: &LayerWeights) -> bool {
921 Self::is_dense_layer(layer)
922 }
923
924 #[cfg(feature = "metal")]
925 fn metal_prefill_dense_swa_fits(
926 &self,
927 layer_idx: usize,
928 start_pos: usize,
929 batch_size: usize,
930 ) -> bool {
931 match self.config.layer_sliding_window(layer_idx) {
932 Some(window) => start_pos + batch_size <= window,
933 None => true,
934 }
935 }
936
937 #[cfg(feature = "metal")]
944 fn metal_prefill_moe<'a>(
945 layer: &'a LayerWeights,
946 config: &ModelConfig,
947 ) -> Option<ferrox_metal::gpu::PrefillMoeMetal<'a>> {
948 if !ferrox_metal::attn::metal_moe_stack_enabled()
949 || !ferrox_metal::attn::metal_moe_resident_enabled()
950 || Self::is_dense_layer(layer)
951 || !layer.moe.shared_experts.is_empty()
952 || !matches!(
953 config.ffn_activation,
954 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
955 )
956 || !matches!(config.moe.gating, ferrox_moe::GatingFunction::Softmax)
957 || config.moe.expert_group_count.is_some()
958 || layer.moe.exp_probs_bias.is_some()
963 || config.moe.expert_weights_scale != 1.0
964 {
965 return None;
966 }
967 let ferrox_core::weight_matrix::WeightMatrix::F32(router) = &layer.moe.router else {
968 return None;
969 };
970 let packed = Self::moe_packed_q4(&layer.moe)?;
971 let moe = ferrox_metal::gpu::PrefillMoeMetal {
972 router_w: &router.data,
973 top_k: config.moe.n_experts_active,
974 renormalize: config.moe.norm_topk_prob,
975 packed,
976 };
977 moe.is_supported().then_some(moe)
978 }
979
980 #[cfg(feature = "metal")]
983 fn metal_prefill_ffn<'a>(
984 layer: &'a LayerWeights,
985 config: &ModelConfig,
986 ) -> Option<ferrox_metal::attn::PrefillFfnMetal<'a>> {
987 if let Some(moe) = Self::metal_prefill_moe(layer, config) {
988 return Some(ferrox_metal::attn::PrefillFfnMetal::Moe(moe));
989 }
990 if !Self::is_dense_layer(layer) {
991 return None;
992 }
993 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
994 return None;
995 };
996 let ex = experts.first()?;
997 Some(ferrox_metal::attn::PrefillFfnMetal::Dense {
998 gate: ex.gate.mul_mm_sg_launch()?,
999 up: ex.up.mul_mm_sg_launch()?,
1000 down: ex.down.mul_mm_sg_launch()?,
1001 })
1002 }
1003
1004 #[cfg(feature = "metal")]
1007 fn metal_prefill_dense_stack_run_len(
1008 &self,
1009 start: usize,
1010 start_pos: usize,
1011 batch_size: usize,
1012 kv_caches: &[KvCache],
1013 metal_kvs: Option<&[ferrox_metal::attn::MetalKvBuffers]>,
1014 ) -> Option<usize> {
1015 if self.gpt_oss.is_some() {
1017 return None;
1018 }
1019 let metal_kvs = metal_kvs?;
1020 let mut run = 0usize;
1021 for li in start..self.layers.len() {
1022 let layer = &self.layers[li];
1023 let cache = &kv_caches[li];
1024 if !self.metal_prefill_dense_swa_fits(li, start_pos, batch_size) {
1025 break;
1026 }
1027 if metal_kvs[li].seq_len != cache.seq_len || start_pos != cache.seq_len {
1028 break;
1029 }
1030 let ok = layer.attn.q_proj.mul_mm_sg_launch().is_some()
1031 && layer.attn.k_proj.mul_mm_sg_launch().is_some()
1032 && layer.attn.v_proj.mul_mm_sg_launch().is_some()
1033 && layer.attn.o_proj.mul_mm_sg_launch().is_some()
1034 && Self::metal_prefill_ffn(layer, &self.config).is_some();
1035 if !ok {
1036 break;
1037 }
1038 run += 1;
1039 }
1040 (run >= 2).then_some(run)
1041 }
1042
1043 #[cfg(feature = "metal")]
1047 #[allow(clippy::too_many_arguments)]
1048 fn try_metal_prefill_dense_stack(
1049 &self,
1050 start: usize,
1051 run_len: usize,
1052 hidden_batch: &[f32],
1053 start_pos: usize,
1054 batch_size: usize,
1055 n_heads: usize,
1056 metal_kvs: &mut [ferrox_metal::attn::MetalKvBuffers],
1057 kv_caches: &mut [KvCache],
1058 ) -> Option<Vec<f32>> {
1059 let gelu = matches!(
1060 self.config.ffn_activation,
1061 crate::config::FfnActivation::Gelu
1062 );
1063 let mut prefill_layers = Vec::with_capacity(run_len);
1064 let mut rope_thetas = Vec::with_capacity(run_len);
1065 for li in start..start + run_len {
1066 let layer = &self.layers[li];
1067 let ffn = Self::metal_prefill_ffn(layer, &self.config)?;
1068 if matches!(ffn, ferrox_metal::attn::PrefillFfnMetal::Dense { .. }) {
1069 layer.moe.record_activations(&[0]);
1070 }
1071 let (q, k, v, o) = (
1072 layer.attn.q_proj.mul_mm_sg_launch()?,
1073 layer.attn.k_proj.mul_mm_sg_launch()?,
1074 layer.attn.v_proj.mul_mm_sg_launch()?,
1075 layer.attn.o_proj.mul_mm_sg_launch()?,
1076 );
1077 prefill_layers.push(ferrox_metal::attn::PrefillDenseLayerMetal {
1078 attn_norm_w: &layer.attn.norm_weight,
1079 ffn_norm_w: &layer.moe.norm_weight,
1080 q,
1081 k,
1082 v,
1083 o,
1084 ffn,
1085 post_attn_norm: layer.attn.post_attn_norm.as_deref(),
1086 post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
1087 extras: self.metal_attn_extras(layer),
1088 layer_idx: li as u32,
1089 });
1090 rope_thetas.push(self.config.layer_rope_theta(li));
1091 }
1092 let kvs = &mut metal_kvs[start..start + run_len];
1093 let h_out = ferrox_metal::attn::launch_prefill_dense_stack(
1094 hidden_batch,
1095 &prefill_layers,
1096 kvs,
1097 n_heads,
1098 batch_size,
1099 self.metal_rope(),
1100 &rope_thetas,
1101 self.config.rope_freqs.as_deref(),
1102 start_pos,
1103 self.config.rms_norm_eps,
1104 gelu,
1105 self.config.attn_logit_softcap,
1106 )
1107 .ok()?;
1108 for cache in &mut kv_caches[start..start + run_len] {
1109 cache
1110 .advance_len(batch_size)
1111 .expect("unbounded/planned KvCache growth is infallible");
1112 }
1113 Some(h_out)
1114 }
1115
1116 #[cfg(feature = "metal")]
1120 fn layer_supports_metal_moe_resident(layer: &LayerWeights, config: &ModelConfig) -> bool {
1121 !Self::is_dense_layer(layer)
1122 && layer.moe.shared_experts.is_empty()
1123 && matches!(
1124 config.ffn_activation,
1125 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
1126 )
1127 && matches!(layer.moe.experts, ExpertBacking::Resident(_))
1128 && Self::metal_matvec_launch(&layer.moe.router).is_some()
1129 && Self::metal_matvec_launch(&layer.attn.q_proj).is_some()
1130 && Self::metal_matvec_launch(&layer.attn.k_proj).is_some()
1131 && Self::metal_matvec_launch(&layer.attn.v_proj).is_some()
1132 && Self::metal_matvec_launch(&layer.attn.o_proj).is_some()
1133 }
1134
1135 #[cfg(feature = "metal")]
1138 fn try_metal_moe_topk(
1139 layer: &LayerWeights,
1140 normed2: &[f32],
1141 decision: &ferrox_moe::RoutingDecision,
1142 ) -> Option<Vec<f32>> {
1143 if decision.expert_ids.is_empty() {
1144 return Some(vec![0f32; normed2.len()]);
1145 }
1146 let mut launches: Vec<ferrox_metal::gpu::MoeExpertLaunch<'_>> =
1149 Vec::with_capacity(decision.expert_ids.len());
1150 struct Pending {
1155 eid: usize,
1156 weight: f32,
1157 }
1158 let pending: Vec<Pending> = decision
1159 .expert_ids
1160 .iter()
1161 .zip(decision.weights.iter())
1162 .map(|(&eid, &w)| Pending { eid, weight: w })
1163 .collect();
1164
1165 for p in &pending {
1167 let ok = layer.moe.with_expert(p.eid, |ex| {
1168 Self::metal_matvec_launch(&ex.gate).is_some()
1169 && Self::metal_matvec_launch(&ex.up).is_some()
1170 && Self::metal_matvec_launch(&ex.down).is_some()
1171 });
1172 if !ok {
1173 return None;
1174 }
1175 }
1176
1177 match &layer.moe.experts {
1181 ExpertBacking::Resident(experts) => {
1182 for p in &pending {
1183 let ex = &experts[p.eid];
1184 launches.push(ferrox_metal::gpu::MoeExpertLaunch {
1185 gate: Self::metal_matvec_launch(&ex.gate)?,
1186 up: Self::metal_matvec_launch(&ex.up)?,
1187 down: Self::metal_matvec_launch(&ex.down)?,
1188 weight: p.weight,
1189 });
1190 }
1191 }
1192 ExpertBacking::Stored { .. } => {
1193 return None;
1195 }
1196 }
1197
1198 match ferrox_metal::gpu::launch_moe_topk_swiglu(normed2, &launches) {
1199 Ok(out) => Some(out),
1200 Err(e) => {
1201 eprintln!("ferrox: Metal MoE top-k fuse failed, falling back: {e}");
1202 None
1203 }
1204 }
1205 }
1206
1207 #[cfg(feature = "metal")]
1209 fn moe_packed_q4(moe: &MoeWeights) -> Option<ferrox_metal::gpu::MoePackedQ4<'_>> {
1210 moe.packed_q4.as_ref().map(MoePackedQ4Planes::view)
1211 }
1212
1213 #[cfg(feature = "metal")]
1218 fn try_metal_moe_prefill_batch(
1219 layer: &LayerWeights,
1220 normed2_batch: &[f32],
1221 router_logits_batch: &[f32],
1222 batch_size: usize,
1223 hidden_dim: usize,
1224 config: &ModelConfig,
1225 ) -> Option<Vec<f32>> {
1226 if batch_size == 0
1227 || !ferrox_core::metal_dense_enabled()
1228 || !ferrox_metal::attn::metal_moe_resident_enabled()
1229 || !matches!(
1230 config.ffn_activation,
1231 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
1232 )
1233 || !matches!(config.moe.gating, ferrox_moe::GatingFunction::Softmax)
1234 || config.moe.expert_group_count.is_some()
1235 || layer.moe.exp_probs_bias.is_some()
1240 || config.moe.expert_weights_scale != 1.0
1241 {
1242 return None;
1243 }
1244 let ExpertBacking::Resident(_) = &layer.moe.experts else {
1245 return None;
1246 };
1247 let packed = Self::moe_packed_q4(&layer.moe)?;
1248 let top_k = config.moe.n_experts_active;
1249 if top_k == 0 || top_k > 8 || packed.hidden_rows != hidden_dim {
1250 return None;
1251 }
1252 let n_experts = layer.moe.n_experts().max(1);
1253 let mut ids = Vec::with_capacity(batch_size * top_k);
1254 let mut route = Vec::with_capacity(batch_size * top_k);
1255 for b in 0..batch_size {
1256 let logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
1257 let decision = route_top_k(logits, top_k, config.moe.gating, config.moe.norm_topk_prob);
1258 layer.moe.record_activations(&decision.expert_ids);
1259 if decision.expert_ids.len() != top_k {
1260 return None;
1261 }
1262 for (&eid, &w) in decision.expert_ids.iter().zip(decision.weights.iter()) {
1263 ids.push(eid as i32);
1264 route.push(w);
1265 }
1266 }
1267 let mut out = match ferrox_metal::gpu::launch_moe_prefill_q4_0(
1268 normed2_batch,
1269 batch_size,
1270 &packed,
1271 &ids,
1272 &route,
1273 top_k,
1274 ) {
1275 Ok(out) => out,
1276 Err(e) => {
1277 eprintln!("ferrox: Metal MoE prefill failed, CPU fallback: {e}");
1278 return None;
1279 }
1280 };
1281 Self::accumulate_shared_experts_batch(
1282 layer,
1283 normed2_batch,
1284 batch_size,
1285 hidden_dim,
1286 &mut out,
1287 );
1288 Some(out)
1289 }
1290
1291 fn accumulate_shared_experts_batch(
1294 layer: &LayerWeights,
1295 normed2_batch: &[f32],
1296 batch_size: usize,
1297 hidden_dim: usize,
1298 acc: &mut [f32],
1299 ) {
1300 for shex in &layer.moe.shared_experts {
1301 #[cfg(feature = "metal")]
1304 let down = if ferrox_core::metal_dense_enabled() && batch_size >= 4 {
1305 match (
1306 shex.gate.mul_mm_sg_launch(),
1307 shex.up.mul_mm_sg_launch(),
1308 shex.down.mul_mm_sg_launch(),
1309 ) {
1310 (Some(g), Some(u), Some(d)) => {
1311 ferrox_metal::gpu::launch_dense_ffn_swiglu_batch(
1312 &g,
1313 &u,
1314 &d,
1315 normed2_batch,
1316 batch_size,
1317 false,
1318 )
1319 .ok()
1320 }
1321 _ => None,
1322 }
1323 } else {
1324 None
1325 };
1326 #[cfg(not(feature = "metal"))]
1327 let down: Option<Vec<f32>> = None;
1328 #[cfg_attr(not(feature = "metal"), allow(clippy::unnecessary_literal_unwrap))]
1331 let down = down.unwrap_or_else(|| {
1332 let ffn_acts = shex.gate.quantize_batch_acts(normed2_batch, batch_size);
1333 let gate =
1334 shex.gate
1335 .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
1336 let up =
1337 shex.up
1338 .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
1339 let activated = ferrox_core::matmul::swiglu(&gate, &up);
1340 shex.down.apply_batch(&activated, batch_size)
1341 });
1342 if let Some(gate_w) = &layer.moe.shared_expert_gate {
1343 for b in 0..batch_size {
1344 let x = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
1345 let logit: f32 = gate_w.iter().zip(x.iter()).map(|(g, v)| g * v).sum();
1346 let scale = 1.0 / (1.0 + (-logit).exp());
1347 let out = &down[b * hidden_dim..(b + 1) * hidden_dim];
1348 let row = &mut acc[b * hidden_dim..(b + 1) * hidden_dim];
1349 for (a, &o) in row.iter_mut().zip(out.iter()) {
1350 *a += scale * o;
1351 }
1352 }
1353 } else {
1354 for (a, &o) in acc.iter_mut().zip(down.iter()) {
1355 *a += o;
1356 }
1357 }
1358 }
1359 }
1360
1361 #[cfg(feature = "metal")]
1363 fn try_metal_moe_experts_resident(
1364 layer: &LayerWeights,
1365 decision: &ferrox_moe::RoutingDecision,
1366 ) -> Option<()> {
1367 if decision.expert_ids.is_empty() {
1368 return Some(());
1369 }
1370 let pending: Vec<(usize, f32)> = decision
1371 .expert_ids
1372 .iter()
1373 .zip(decision.weights.iter())
1374 .map(|(&eid, &w)| (eid, w))
1375 .collect();
1376 for &(eid, _) in &pending {
1377 let ok = layer.moe.with_expert(eid, |ex| {
1378 Self::metal_matvec_launch(&ex.gate).is_some()
1379 && Self::metal_matvec_launch(&ex.up).is_some()
1380 && Self::metal_matvec_launch(&ex.down).is_some()
1381 });
1382 if !ok {
1383 return None;
1384 }
1385 }
1386 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
1387 return None;
1388 };
1389 let mut launches = Vec::with_capacity(pending.len());
1390 for &(eid, weight) in &pending {
1391 let ex = &experts[eid];
1392 launches.push(ferrox_metal::gpu::MoeExpertLaunch {
1393 gate: Self::metal_matvec_launch(&ex.gate)?,
1394 up: Self::metal_matvec_launch(&ex.up)?,
1395 down: Self::metal_matvec_launch(&ex.down)?,
1396 weight,
1397 });
1398 }
1399 match ferrox_metal::attn::launch_moe_decode_experts(&launches) {
1400 Ok(()) => Some(()),
1401 Err(e) => {
1402 eprintln!("ferrox: Metal MoE experts failed, falling back: {e}");
1403 None
1404 }
1405 }
1406 }
1407
1408 #[cfg(feature = "metal")]
1411 fn catch_up_host_kv_from_metal(mkv: &ferrox_metal::attn::MetalKvBuffers, cache: &mut KvCache) {
1412 if cache.seq_len >= mkv.seq_len {
1413 return;
1414 }
1415 let start = cache.seq_len;
1416 let n = mkv.seq_len - start;
1417 let (k, v) = mkv.tokens_host(start, n);
1418 let per = cache.n_kv_heads * cache.head_dim;
1419 for i in 0..n {
1420 let off = i * per;
1421 cache
1422 .push(&k[off..off + per], &v[off..off + per])
1423 .expect("unbounded/planned KvCache growth is infallible");
1424 }
1425 }
1426
1427 #[cfg(feature = "metal")]
1430 pub fn sync_metal_attn_kv_to_host(&self, kv_caches: &mut [KvCache]) {
1431 assert_eq!(kv_caches.len(), self.layers.len());
1432 let Ok(guard) = self.metal_attn_kv.lock() else {
1433 return;
1434 };
1435 let Some(metal_kvs) = guard.as_ref() else {
1436 return;
1437 };
1438 if metal_kvs.len() != kv_caches.len() {
1439 return;
1440 }
1441 for (mkv, cache) in metal_kvs.iter().zip(kv_caches.iter_mut()) {
1442 Self::catch_up_host_kv_from_metal(mkv, cache);
1443 }
1444 }
1445
1446 #[allow(clippy::too_many_arguments)]
1453 fn gqa_attention(
1454 &self,
1455 layer: usize,
1456 q: &[f32],
1457 k: &[f32],
1458 v: &[f32],
1459 n_heads: usize,
1460 n_kv_heads: usize,
1461 head_dim: usize,
1462 seq_len: usize,
1463 ) -> Vec<f32> {
1464 #[cfg(feature = "cuda")]
1465 {
1466 if cuda_gqa_enabled() {
1467 match ferrox_cuda::attn::launch_gqa_decode_resident(
1468 layer, q, k, v, n_heads, n_kv_heads, head_dim, seq_len,
1469 ) {
1470 Ok(out) => return out,
1471 Err(e) => {
1472 eprintln!(
1473 "ferrox: CUDA GQA resident decode failed, trying full upload: {e}"
1474 );
1475 }
1476 }
1477 match ferrox_cuda::attn::launch_gqa_decode(
1478 q, k, v, n_heads, n_kv_heads, head_dim, seq_len,
1479 ) {
1480 Ok(out) => return out,
1481 Err(e) => {
1482 eprintln!("ferrox: CUDA GQA decode failed, host fallback: {e}");
1483 }
1484 }
1485 }
1486 }
1487 let _ = layer;
1488 causal_gqa_attention_softcap(
1489 q,
1490 k,
1491 v,
1492 n_heads,
1493 n_kv_heads,
1494 head_dim,
1495 seq_len,
1496 self.config.attn_logit_softcap,
1497 )
1498 }
1499
1500 pub fn forward_token(
1504 &self,
1505 token_id: usize,
1506 pos: usize,
1507 kv_caches: &mut [KvCache],
1508 ) -> Vec<f32> {
1509 #[cfg(feature = "metal")]
1512 ferrox_metal::gpu::clear_resident_activation();
1513
1514 assert_eq!(kv_caches.len(), self.layers.len());
1515 let hidden_dim = self.config.hidden_dim;
1516 let head_dim = self.config.head_dim;
1517 let n_heads = self.config.n_heads;
1518 let n_kv_heads = self.config.n_kv_heads;
1519
1520 #[cfg(feature = "metal")]
1521 let metal_embd_kind = {
1522 let metal_path = ferrox_core::metal_dense_enabled()
1523 && ferrox_metal::attn::metal_attn_enabled()
1524 && self
1525 .layers
1526 .iter()
1527 .all(|l| self.layer_supports_metal_attn(l))
1528 && self.layers.iter().all(Self::layer_supports_metal_dense_ffn);
1529 if metal_path && self.config.embedding_scale.is_none() {
1532 Self::metal_matvec_launch(&self.embedding)
1533 .and_then(|l| ferrox_metal::embd::EmbdKind::from_fn_name(l.fn_name))
1534 } else {
1535 None
1536 }
1537 };
1538 #[cfg(feature = "metal")]
1539 let mut hidden = if metal_embd_kind.is_some() {
1540 Vec::new()
1541 } else {
1542 self.embedding.dequant_row(token_id)
1543 };
1544 #[cfg(not(feature = "metal"))]
1545 let mut hidden = self.embedding.dequant_row(token_id);
1546 if let Some(scale) = self.config.embedding_scale {
1547 for v in hidden.iter_mut() {
1548 *v *= scale;
1549 }
1550 }
1551 #[cfg(feature = "cuda")]
1552 if cuda_gqa_enabled() {
1553 const CUDA_KV_CAP: usize = 4096;
1556 if let Err(e) = ferrox_cuda::attn::ensure_layer_kv(
1557 self.layers.len(),
1558 self.config.n_kv_heads,
1559 self.config.head_dim,
1560 CUDA_KV_CAP,
1561 ) {
1562 eprintln!("ferrox: CUDA KV residency init failed: {e}");
1563 }
1564 if pos == 0 {
1565 ferrox_cuda::attn::clear_layer_kv();
1566 }
1567 }
1568
1569 #[cfg(feature = "metal")]
1570 let use_metal_attn = ferrox_core::metal_dense_enabled()
1571 && ferrox_metal::attn::metal_attn_enabled()
1572 && self
1573 .layers
1574 .iter()
1575 .all(|l| self.layer_supports_metal_attn(l));
1576
1577 #[cfg(not(feature = "metal"))]
1578 let use_metal_attn = false;
1579
1580 let residency = self.expert_residency_plan(use_metal_attn);
1581
1582 #[cfg(feature = "metal")]
1583 let mut metal_kv_guard: Option<
1584 std::sync::MutexGuard<'_, Option<Vec<ferrox_metal::attn::MetalKvBuffers>>>,
1585 > = if use_metal_attn {
1586 Some(self.metal_attn_kv.lock().unwrap())
1587 } else {
1588 None
1589 };
1590
1591 #[cfg(feature = "metal")]
1592 if let Some(guard) = metal_kv_guard.as_mut() {
1593 let need = self.layers.len();
1594 let cap = kv_caches
1595 .iter()
1596 .map(|c| c.seq_len.max(pos + 1).saturating_add(256))
1597 .max()
1598 .unwrap_or(512)
1599 .max(512)
1600 .max(pos + 1);
1601 let reset = match guard.as_ref() {
1602 None => true,
1603 Some(v) => {
1604 if v.len() != need || v.iter().any(|m| m.capacity() < pos + 1) {
1605 if v.len() == need {
1607 for (m, c) in v.iter().zip(kv_caches.iter_mut()) {
1608 Self::catch_up_host_kv_from_metal(m, c);
1609 }
1610 }
1611 true
1612 } else if v.iter().all(|m| m.seq_len == pos) {
1613 false
1616 } else {
1617 true
1619 }
1620 }
1621 };
1622 if reset {
1623 let mut bufs = Vec::with_capacity(need);
1624 for _ in 0..need {
1625 match ferrox_metal::attn::MetalKvBuffers::with_capacity(
1626 n_kv_heads, head_dim, cap,
1627 ) {
1628 Ok(b) => bufs.push(b),
1629 Err(_) => {
1630 **guard = None;
1631 break;
1632 }
1633 }
1634 }
1635 if bufs.len() == need {
1636 let mut ok = true;
1638 for (m, c) in bufs.iter_mut().zip(kv_caches.iter()) {
1639 if c.seq_len > 0 && m.upload_from_host(&c.k, &c.v, c.seq_len).is_err() {
1640 ok = false;
1641 break;
1642 }
1643 }
1644 if ok {
1645 **guard = Some(bufs);
1646 } else {
1647 **guard = None;
1648 }
1649 } else {
1650 **guard = None;
1651 }
1652 }
1653 }
1654
1655 #[cfg(feature = "metal")]
1656 let mut metal_stack_done = false;
1657 #[cfg(feature = "metal")]
1658 let mut final_norm_done_in_stack = false;
1659 #[cfg(feature = "metal")]
1661 if use_metal_attn
1662 && ferrox_metal::attn::metal_moe_resident_enabled()
1663 && matches!(self.config.moe.gating, ferrox_moe::GatingFunction::Softmax)
1664 && self
1665 .layers
1666 .iter()
1667 .all(|l| Self::layer_supports_metal_moe_resident(l, &self.config))
1668 && !self.layers.iter().all(Self::layer_supports_metal_dense_ffn)
1669 {
1670 if let Some(guard) = metal_kv_guard.as_mut() {
1671 if let Some(metal_kvs) = guard.as_mut() {
1672 if metal_kvs.iter().all(|m| m.seq_len == pos) {
1673 let mut moe_layers = Vec::with_capacity(self.layers.len());
1674 let mut ok = true;
1675 for layer in &self.layers {
1676 let ExpertBacking::Resident(_) = &layer.moe.experts else {
1677 ok = false;
1678 break;
1679 };
1680 let Some(packed) = Self::moe_packed_q4(&layer.moe) else {
1681 ok = false;
1682 break;
1683 };
1684 let (Some(q), Some(k), Some(v), Some(o), Some(r)) = (
1685 Self::metal_matvec_launch(&layer.attn.q_proj),
1686 Self::metal_matvec_launch(&layer.attn.k_proj),
1687 Self::metal_matvec_launch(&layer.attn.v_proj),
1688 Self::metal_matvec_launch(&layer.attn.o_proj),
1689 Self::metal_matvec_launch(&layer.moe.router),
1690 ) else {
1691 ok = false;
1692 break;
1693 };
1694 moe_layers.push(ferrox_metal::attn::MoeLayerMetal {
1695 attn_norm_w: &layer.attn.norm_weight,
1696 ffn_norm_w: &layer.moe.norm_weight,
1697 q,
1698 k,
1699 v,
1700 o,
1701 router: r,
1702 packed,
1703 extras: self.metal_attn_extras(layer),
1704 });
1705 }
1706 if ok {
1707 let greedy_gpu = ferrox_metal::attn::metal_greedy_argmax_active();
1710 let lm_head_gpu_launch = Self::metal_matvec_launch(&self.output_head);
1711 let out_launch =
1712 if greedy_gpu || ferrox_metal::attn::metal_logits_enabled() {
1713 lm_head_gpu_launch
1714 } else {
1715 None
1716 };
1717 let embd_launch = Self::metal_matvec_launch(&self.embedding);
1718 let embd_gather = if self.config.embedding_scale.is_some() {
1720 None
1721 } else {
1722 match (metal_embd_kind, embd_launch.as_ref()) {
1723 (Some(kind), Some(launch)) => {
1724 Some(ferrox_metal::attn::EmbdGatherMetal {
1725 kind,
1726 weights: launch.weights,
1727 rows: launch.rows,
1728 row_bytes: launch.row_bytes,
1729 n_cols: hidden_dim,
1730 token_id,
1731 })
1732 }
1733 _ => None,
1734 }
1735 };
1736 if embd_gather.is_none() && hidden.is_empty() {
1737 hidden = self.embedding.dequant_row(token_id);
1738 if let Some(scale) = self.config.embedding_scale {
1739 for v in hidden.iter_mut() {
1740 *v *= scale;
1741 }
1742 }
1743 }
1744 let seed = if embd_gather.is_some() {
1745 ferrox_metal::attn::moe_decode_ensure(hidden_dim)
1746 } else {
1747 ferrox_metal::attn::moe_decode_seed(&hidden)
1748 };
1749 let hidden_ref: &[f32] =
1750 if embd_gather.is_some() { &[] } else { &hidden };
1751 match seed.and_then(|_| {
1752 ferrox_metal::attn::launch_moe_decode_stack(
1753 hidden_ref,
1754 &moe_layers,
1755 metal_kvs,
1756 self.config.moe.n_experts_active,
1757 self.config.moe.norm_topk_prob,
1758 n_heads,
1759 self.metal_rope(),
1760 self.config.rope_theta,
1761 self.config.rope_freqs.as_deref(),
1762 pos,
1763 self.config.rms_norm_eps,
1764 Some(&self.final_norm),
1765 out_launch.as_ref(),
1766 greedy_gpu && out_launch.is_some(),
1767 true,
1768 embd_gather.as_ref(),
1769 )
1770 }) {
1771 Ok((out, per_layer_ids)) => {
1772 for (layer, ids) in self.layers.iter().zip(per_layer_ids.iter())
1773 {
1774 if !ids.is_empty() {
1775 layer.moe.record_activations(ids);
1776 }
1777 }
1778 if out_launch.is_some() {
1779 #[cfg(feature = "metal")]
1780 ferrox_metal::gpu::clear_resident_activation();
1781 return out;
1782 }
1783 hidden = out;
1784 final_norm_done_in_stack = true;
1785 metal_stack_done = true;
1786 }
1787 Err(e) => {
1788 eprintln!(
1789 "ferrox: Metal MoE stack failed, per-layer fallback: {e}"
1790 );
1791 if hidden.is_empty() {
1792 hidden = self.embedding.dequant_row(token_id);
1793 if let Some(scale) = self.config.embedding_scale {
1794 for v in hidden.iter_mut() {
1795 *v *= scale;
1796 }
1797 }
1798 }
1799 }
1800 }
1801 }
1802 }
1803 }
1804 }
1805 }
1806 #[cfg(feature = "metal")]
1807 if !metal_stack_done
1808 && use_metal_attn
1809 && self.layers.iter().all(Self::layer_supports_metal_dense_ffn)
1810 {
1811 if let Some(guard) = metal_kv_guard.as_mut() {
1812 let mut clear_metal_after_stack = false;
1813 if let Some(metal_kvs) = guard.as_mut() {
1814 let seq_ok = metal_kvs.iter().all(|m| m.seq_len == pos);
1815 if seq_ok {
1816 let mut dense_layers = Vec::with_capacity(self.layers.len());
1818 let mut ok = true;
1819 for (li, layer) in self.layers.iter().enumerate() {
1820 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
1821 ok = false;
1822 break;
1823 };
1824 let ex = &experts[0];
1825 let (Some(q), Some(k), Some(v), Some(o), Some(g), Some(u), Some(d)) = (
1826 Self::metal_matvec_launch(&layer.attn.q_proj),
1827 Self::metal_matvec_launch(&layer.attn.k_proj),
1828 Self::metal_matvec_launch(&layer.attn.v_proj),
1829 Self::metal_matvec_launch(&layer.attn.o_proj),
1830 Self::metal_matvec_launch(&ex.gate),
1831 Self::metal_matvec_launch(&ex.up),
1832 Self::metal_matvec_launch(&ex.down),
1833 ) else {
1834 ok = false;
1835 break;
1836 };
1837 dense_layers.push(ferrox_metal::attn::DenseLayerMetal {
1838 attn_norm_w: &layer.attn.norm_weight,
1839 ffn_norm_w: &layer.moe.norm_weight,
1840 q,
1841 k,
1842 v,
1843 o,
1844 gate: g,
1845 up: u,
1846 down: d,
1847 extras: self.metal_attn_extras(layer),
1848 rope_theta: {
1849 let t = self.config.layer_rope_theta(li);
1850 (t != self.config.rope_theta).then_some(t)
1851 },
1852 window: self.config.layer_sliding_window(li),
1853 post_attn_norm: layer.attn.post_attn_norm.as_deref(),
1854 post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
1855 });
1856 }
1857 if ok {
1858 let greedy_gpu = ferrox_metal::attn::metal_greedy_argmax_active();
1863 let lm_head_gpu_launch = Self::metal_matvec_launch(&self.output_head);
1864 let out_launch =
1869 if greedy_gpu || ferrox_metal::attn::metal_logits_enabled() {
1870 lm_head_gpu_launch
1871 } else {
1872 None
1873 };
1874 let final_norm_w =
1878 if out_launch.is_some() || lm_head_gpu_launch.is_some() {
1879 Some(self.final_norm.as_slice())
1880 } else {
1881 None
1882 };
1883 let embd_launch = Self::metal_matvec_launch(&self.embedding);
1884 let embd_gather = if self.config.embedding_scale.is_some() {
1888 None
1889 } else {
1890 match (metal_embd_kind, embd_launch.as_ref()) {
1891 (Some(kind), Some(launch)) => {
1892 Some(ferrox_metal::attn::EmbdGatherMetal {
1893 kind,
1894 weights: launch.weights,
1895 rows: launch.rows,
1896 row_bytes: launch.row_bytes,
1897 n_cols: hidden_dim,
1898 token_id,
1899 })
1900 }
1901 _ => None,
1902 }
1903 };
1904 let hidden_ref: &[f32] =
1905 if embd_gather.is_some() { &[] } else { &hidden };
1906 match ferrox_metal::attn::launch_decode_dense_stack(
1907 hidden_ref,
1908 &dense_layers,
1909 metal_kvs,
1910 n_heads,
1911 self.metal_rope(),
1912 self.config.rope_theta,
1913 self.config.rope_freqs.as_deref(),
1914 pos,
1915 self.config.rms_norm_eps,
1916 final_norm_w,
1917 out_launch.as_ref(),
1918 greedy_gpu && out_launch.is_some(),
1919 embd_gather.as_ref(),
1920 matches!(
1921 self.config.ffn_activation,
1922 crate::config::FfnActivation::Gelu
1923 ),
1924 ) {
1925 Ok(out) => {
1926 if out_launch.is_some() {
1932 #[cfg(feature = "metal")]
1935 ferrox_metal::gpu::clear_resident_activation();
1936 return out;
1937 }
1938 final_norm_done_in_stack = final_norm_w.is_some();
1942 hidden = out;
1943 metal_stack_done = true;
1944 }
1945 Err(e) => {
1946 eprintln!(
1947 "ferrox: Metal dense stack failed, per-layer fallback: {e}"
1948 );
1949 if hidden.is_empty() {
1950 hidden = self.embedding.dequant_row(token_id);
1951 if let Some(scale) = self.config.embedding_scale {
1952 for v in hidden.iter_mut() {
1953 *v *= scale;
1954 }
1955 }
1956 }
1957 for (m, c) in metal_kvs.iter().zip(kv_caches.iter_mut()) {
1960 Self::catch_up_host_kv_from_metal(m, c);
1961 }
1962 clear_metal_after_stack = true;
1963 }
1964 }
1965 }
1966 }
1967 }
1968 if clear_metal_after_stack {
1969 **guard = None;
1970 }
1971 }
1972 }
1973
1974 #[cfg(feature = "metal")]
1975 let run_cpu_layers = !metal_stack_done;
1976 #[cfg(not(feature = "metal"))]
1977 let run_cpu_layers = true;
1978
1979 #[cfg(feature = "metal")]
1981 let mut metal_moe_resident = false;
1982
1983 if run_cpu_layers {
1984 for (l, (layer, cache)) in self.layers.iter().zip(kv_caches.iter_mut()).enumerate() {
1985 #[cfg(feature = "metal")]
1987 if metal_moe_resident
1988 && (!Self::layer_supports_metal_moe_resident(layer, &self.config)
1989 || self.layer_needs_metal_stack(layer, l))
1990 {
1991 if let Some(h) = ferrox_metal::attn::moe_decode_take_hidden() {
1992 hidden = h;
1993 }
1994 metal_moe_resident = false;
1995 }
1996
1997 #[cfg(feature = "metal")]
1998 let normed = if metal_moe_resident {
1999 Vec::new()
2001 } else {
2002 rms_norm(&hidden, &layer.attn.norm_weight, self.config.rms_norm_eps)
2003 };
2004 #[cfg(not(feature = "metal"))]
2005 let normed = rms_norm(&hidden, &layer.attn.norm_weight, self.config.rms_norm_eps);
2006
2007 #[cfg(feature = "metal")]
2008 {
2009 let mut did_metal_attn = false;
2010 let mut did_metal_dense = false;
2011 let mut did_metal_moe = false;
2012 let mut clear_metal_kv = false;
2013 if let Some(guard) = metal_kv_guard.as_mut() {
2014 if let Some(metal_kvs) = guard.as_mut() {
2015 if metal_kvs[l].seq_len == pos
2020 && !self.layer_needs_metal_stack(layer, l)
2021 {
2022 if let (Some(q_l), Some(k_l), Some(v_l), Some(o_l)) = (
2023 Self::metal_matvec_launch(&layer.attn.q_proj),
2024 Self::metal_matvec_launch(&layer.attn.k_proj),
2025 Self::metal_matvec_launch(&layer.attn.v_proj),
2026 Self::metal_matvec_launch(&layer.attn.o_proj),
2027 ) {
2028 if Self::layer_supports_metal_dense_ffn(layer) {
2030 let dense_ok = layer.moe.with_expert(0, |ex| {
2031 let (Some(g_l), Some(u_l), Some(d_l)) = (
2032 Self::metal_matvec_launch(&ex.gate),
2033 Self::metal_matvec_launch(&ex.up),
2034 Self::metal_matvec_launch(&ex.down),
2035 ) else {
2036 return false;
2037 };
2038 match ferrox_metal::attn::launch_decode_dense_layer(
2039 &hidden,
2040 &layer.attn.norm_weight,
2041 &q_l,
2042 &k_l,
2043 &v_l,
2044 &o_l,
2045 &mut metal_kvs[l],
2046 &layer.moe.norm_weight,
2047 &g_l,
2048 &u_l,
2049 &d_l,
2050 n_heads,
2051 self.metal_rope(),
2052 self.config.rope_theta,
2053 self.config.rope_freqs.as_deref(),
2054 pos,
2055 self.config.rms_norm_eps,
2056 &self.metal_attn_extras(layer),
2057 ) {
2058 Ok(new_h) => {
2059 Self::catch_up_host_kv_from_metal(
2061 &metal_kvs[l],
2062 cache,
2063 );
2064 layer.moe.record_activations(&[0]);
2065 hidden = new_h;
2066 true
2067 }
2068 Err(e) => {
2069 eprintln!(
2070 "ferrox: Metal dense layer failed, CPU fallback: {e}"
2071 );
2072 false
2073 }
2074 }
2075 });
2076 if dense_ok {
2077 did_metal_dense = true;
2078 did_metal_attn = true;
2079 } else if metal_kvs[l].seq_len != cache.seq_len {
2080 Self::catch_up_host_kv_from_metal(&metal_kvs[l], cache);
2082 clear_metal_kv = true;
2083 }
2084 }
2085
2086 if !did_metal_dense
2089 && !clear_metal_kv
2090 && ferrox_metal::attn::metal_moe_resident_enabled()
2091 && Self::layer_supports_metal_moe_resident(
2092 layer,
2093 &self.config,
2094 )
2095 {
2096 if let Some(router_l) =
2097 Self::metal_matvec_launch(&layer.moe.router)
2098 {
2099 let seed_ok = if metal_moe_resident {
2100 true
2101 } else {
2102 match ferrox_metal::attn::moe_decode_seed(&hidden) {
2103 Ok(()) => {
2104 metal_moe_resident = true;
2105 true
2106 }
2107 Err(e) => {
2108 eprintln!(
2109 "ferrox: Metal MoE seed failed: {e}"
2110 );
2111 false
2112 }
2113 }
2114 };
2115 if seed_ok {
2116 let fused_ok = matches!(
2125 self.config.moe.gating,
2126 ferrox_moe::GatingFunction::Softmax
2127 ) && layer
2128 .moe
2129 .exp_probs_bias
2130 .is_none()
2131 && self.config.moe.expert_weights_scale == 1.0
2132 && match &layer.moe.experts {
2133 ExpertBacking::Resident(_) => {
2134 if let Some(packed) =
2135 Self::moe_packed_q4(&layer.moe)
2136 {
2137 match ferrox_metal::attn::launch_moe_decode_layer_fused(
2138 &layer.attn.norm_weight,
2139 &q_l,
2140 &k_l,
2141 &v_l,
2142 &o_l,
2143 &mut metal_kvs[l],
2144 &layer.moe.norm_weight,
2145 &router_l,
2146 &packed,
2147 self.config.moe.n_experts_active,
2148 self.config.moe.norm_topk_prob,
2149 n_heads,
2150 self.metal_rope(),
2151 self.config.rope_theta,
2152 self.config.rope_freqs.as_deref(),
2153 pos,
2154 self.config.rms_norm_eps,
2155 &self.metal_attn_extras(layer),
2156 ) {
2157 Ok(ids) => {
2158 layer.moe.record_activations(&ids);
2159 did_metal_moe = true;
2160 did_metal_attn = true;
2161 true
2162 }
2163 Err(e) => {
2164 eprintln!(
2165 "ferrox: Metal MoE fused layer failed: {e}"
2166 );
2167 false
2168 }
2169 }
2170 } else {
2171 false
2172 }
2173 }
2174 _ => false,
2175 };
2176
2177 if !fused_ok {
2178 match ferrox_metal::attn::launch_moe_decode_pre(
2179 &layer.attn.norm_weight,
2180 &q_l,
2181 &k_l,
2182 &v_l,
2183 &o_l,
2184 &mut metal_kvs[l],
2185 &layer.moe.norm_weight,
2186 &router_l,
2187 n_heads,
2188 self.metal_rope(),
2189 self.config.rope_theta,
2190 self.config.rope_freqs.as_deref(),
2191 pos,
2192 self.config.rms_norm_eps,
2193 &self.metal_attn_extras(layer),
2194 ) {
2195 Ok(logits) => {
2196 let decision = route_top_k(
2197 &logits,
2198 self.config.moe.n_experts_active,
2199 self.config.moe.gating,
2200 self.config.moe.norm_topk_prob,
2201 );
2202 layer.moe.record_activations(
2203 &decision.expert_ids,
2204 );
2205 if let Some(()) = Self::try_metal_moe_experts_resident(
2206 layer,
2207 &decision,
2208 ) {
2209 did_metal_moe = true;
2210 did_metal_attn = true;
2211 } else if let Some(h) =
2212 ferrox_metal::attn::moe_decode_take_hidden()
2213 {
2214 hidden = h;
2215 metal_moe_resident = false;
2216 let normed2 = rms_norm(
2218 &hidden,
2219 &layer.moe.norm_weight,
2220 self.config.rms_norm_eps,
2221 );
2222 let ffn_out = Self::combine_ffn_outputs_for_position(
2223 layer,
2224 &normed2,
2225 &logits,
2226 &self.config,
2227 hidden_dim,
2228 residency.as_ref().map(|p| p.layer_plan(l)),
2229 );
2230 for (h, f) in
2231 hidden.iter_mut().zip(ffn_out.iter())
2232 {
2233 *h += f;
2234 }
2235 did_metal_attn = true;
2236 did_metal_moe = true; }
2238 }
2239 Err(e) => {
2240 eprintln!(
2241 "ferrox: Metal MoE pre failed, fallback: {e}"
2242 );
2243 if let Some(h) =
2244 ferrox_metal::attn::moe_decode_take_hidden()
2245 {
2246 hidden = h;
2247 }
2248 metal_moe_resident = false;
2249 if metal_kvs[l].seq_len != cache.seq_len
2250 {
2251 Self::catch_up_host_kv_from_metal(
2252 &metal_kvs[l],
2253 cache,
2254 );
2255 clear_metal_kv = true;
2256 }
2257 }
2258 }
2259 }
2260 }
2261 }
2262 }
2263
2264 if !did_metal_dense && !did_metal_moe && !clear_metal_kv {
2265 match ferrox_metal::attn::launch_decode_attn_block(
2266 &normed,
2267 &q_l,
2268 &k_l,
2269 &v_l,
2270 &o_l,
2271 &mut metal_kvs[l],
2272 n_heads,
2273 self.metal_rope(),
2274 self.config.rope_theta,
2275 self.config.rope_freqs.as_deref(),
2276 pos,
2277 &self.metal_attn_extras(layer),
2278 self.config.rms_norm_eps,
2279 ) {
2280 Ok(projected) => {
2281 for (h, p) in
2285 hidden.iter_mut().zip(projected.iter())
2286 {
2287 *h += p;
2288 }
2289 did_metal_attn = true;
2290 }
2291 Err(e) => {
2292 eprintln!(
2293 "ferrox: Metal attn block failed, CPU fallback: {e}"
2294 );
2295 Self::catch_up_host_kv_from_metal(
2296 &metal_kvs[l],
2297 cache,
2298 );
2299 clear_metal_kv = true;
2300 }
2301 }
2302 }
2303 }
2304 } else if metal_kvs[l].seq_len > cache.seq_len {
2305 Self::catch_up_host_kv_from_metal(&metal_kvs[l], cache);
2307 }
2308 }
2309 if clear_metal_kv {
2310 **guard = None;
2311 }
2312 }
2313 if did_metal_attn {
2314 if !did_metal_dense && !did_metal_moe {
2315 let normed2 =
2316 rms_norm(&hidden, &layer.moe.norm_weight, self.config.rms_norm_eps);
2317 let ffn_out = Self::run_ffn_block(
2318 layer,
2319 &normed2,
2320 &self.config,
2321 hidden_dim,
2322 residency.as_ref().map(|p| p.layer_plan(l)),
2323 );
2324 for (h, f) in hidden.iter_mut().zip(ffn_out.iter()) {
2325 *h += f;
2326 }
2327 }
2328 continue;
2329 }
2330 }
2331
2332 let (mut q, mut k, mut v) = {
2333 #[cfg(any(feature = "cuda", feature = "metal"))]
2334 {
2335 if let Some(mut outs) = ferrox_core::WeightMatrix::apply_gpu_multi(
2336 &[&layer.attn.q_proj, &layer.attn.k_proj, &layer.attn.v_proj],
2337 &normed,
2338 ) {
2339 let v = outs.pop().unwrap();
2340 let k = outs.pop().unwrap();
2341 let q = outs.pop().unwrap();
2342 (q, k, v)
2343 } else {
2344 ferrox_core::weight_matrix::WeightMatrix::apply_three(
2345 &layer.attn.q_proj,
2346 &layer.attn.k_proj,
2347 &layer.attn.v_proj,
2348 &normed,
2349 )
2350 }
2351 }
2352 #[cfg(not(any(feature = "cuda", feature = "metal")))]
2353 {
2354 ferrox_core::weight_matrix::WeightMatrix::apply_three(
2355 &layer.attn.q_proj,
2356 &layer.attn.k_proj,
2357 &layer.attn.v_proj,
2358 &normed,
2359 )
2360 }
2361 };
2362
2363 if let Some(bias) = &layer.attn.q_bias {
2364 for (x, b) in q.iter_mut().zip(bias.iter()) {
2365 *x += b;
2366 }
2367 }
2368 if let Some(bias) = &layer.attn.k_bias {
2369 for (x, b) in k.iter_mut().zip(bias.iter()) {
2370 *x += b;
2371 }
2372 }
2373 if let Some(bias) = &layer.attn.v_bias {
2374 for (x, b) in v.iter_mut().zip(bias.iter()) {
2375 *x += b;
2376 }
2377 }
2378
2379 if let Some(q_norm) = &layer.attn.q_norm {
2380 q = self.apply_qk_norm(&q, q_norm);
2381 }
2382 if let Some(k_norm) = &layer.attn.k_norm {
2383 k = self.apply_qk_norm(&k, k_norm);
2384 }
2385 self.apply_rope_attn_factor(&mut q, &mut k);
2386
2387 for h in 0..n_heads {
2388 self.apply_rope_head_layer(&mut q[h * head_dim..(h + 1) * head_dim], pos, l);
2389 }
2390 for h in 0..n_kv_heads {
2391 self.apply_rope_head_layer(&mut k[h * head_dim..(h + 1) * head_dim], pos, l);
2392 }
2393 if let Some(scale) = self.config.attention_scale {
2398 let compensate = scale * (head_dim as f32).sqrt();
2399 for v in q.iter_mut() {
2400 *v *= compensate;
2401 }
2402 }
2403
2404 cache
2405 .push(&k, &v)
2406 .expect("unbounded/planned KvCache growth is infallible");
2407
2408 let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
2409 let attn_out = match (oai, self.config.layer_sliding_window(l)) {
2410 (Some(oai), window) => ferrox_core::causal_gqa_attention_sinks(
2411 &q,
2412 &cache.k,
2413 &cache.v,
2414 n_heads,
2415 n_kv_heads,
2416 head_dim,
2417 cache.seq_len,
2418 window,
2419 &oai.attn_sinks,
2420 ),
2421 (None, Some(window)) => causal_gqa_attention_windowed_softcap(
2422 &q,
2423 &cache.k,
2424 &cache.v,
2425 n_heads,
2426 n_kv_heads,
2427 head_dim,
2428 cache.seq_len,
2429 window,
2430 self.config.attn_logit_softcap,
2431 ),
2432 (None, None) => self.gqa_attention(
2433 l,
2434 &q,
2435 &cache.k,
2436 &cache.v,
2437 n_heads,
2438 n_kv_heads,
2439 head_dim,
2440 cache.seq_len,
2441 ),
2442 };
2443 let mut projected = layer.attn.o_proj.apply(&attn_out);
2444 if let Some(oai) = oai {
2445 for (x, b) in projected.iter_mut().zip(oai.o_bias.iter()) {
2446 *x += b;
2447 }
2448 }
2449 if let Some(post) = &layer.attn.post_attn_norm {
2450 projected = rms_norm(&projected, post, self.config.rms_norm_eps);
2451 }
2452
2453 for (h, p) in hidden.iter_mut().zip(projected.iter()) {
2454 *h += p;
2455 }
2456
2457 let normed2 = rms_norm(&hidden, &layer.moe.norm_weight, self.config.rms_norm_eps);
2459 let mut ffn_out = match oai {
2460 Some(oai) => Self::gpt_oss_ffn(layer, oai, &normed2, &self.config, hidden_dim),
2461 None => Self::run_ffn_block(
2462 layer,
2463 &normed2,
2464 &self.config,
2465 hidden_dim,
2466 residency.as_ref().map(|p| p.layer_plan(l)),
2467 ),
2468 };
2469 if let Some(post) = &layer.attn.post_ffn_norm {
2470 ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
2471 }
2472 for (h, f) in hidden.iter_mut().zip(ffn_out.iter()) {
2473 *h += f;
2474 }
2475 }
2476 } #[cfg(feature = "metal")]
2479 if metal_moe_resident {
2480 if let Some(h) = ferrox_metal::attn::moe_decode_take_hidden() {
2481 hidden = h;
2482 }
2483 }
2484
2485 #[cfg(feature = "metal")]
2488 let final_normed = if final_norm_done_in_stack {
2489 hidden.clone()
2490 } else {
2491 rms_norm(&hidden, &self.final_norm, self.config.rms_norm_eps)
2492 };
2493 #[cfg(not(feature = "metal"))]
2494 let final_normed = rms_norm(&hidden, &self.final_norm, self.config.rms_norm_eps);
2495
2496 let mut logits = self.output_head.apply(&final_normed);
2497 if let Some(sc) = self.config.final_logit_softcap {
2498 softcap_inplace(&mut logits, sc);
2499 }
2500 #[cfg(feature = "metal")]
2504 ferrox_metal::gpu::clear_resident_activation();
2505 logits
2506 }
2507
2508 pub fn forward_token_paged(
2519 &self,
2520 token_id: usize,
2521 pos: usize,
2522 kv_caches: &mut [PagedKvCache],
2523 stores: &mut [PagedKvStore],
2524 ) -> Result<Vec<f32>, PagedStoreExhausted> {
2525 assert_eq!(kv_caches.len(), self.layers.len());
2526 assert_eq!(stores.len(), self.layers.len());
2527 assert!(
2533 self.gpt_oss.is_none(),
2534 "gpt-oss requires attention sinks; the paged-KV decode path does not implement them. \
2535 Run this model without a KV pool (FERROX_KV_POOL_BLOCKS unset)."
2536 );
2537 let hidden_dim = self.config.hidden_dim;
2538 let head_dim = self.config.head_dim;
2539 let n_heads = self.config.n_heads;
2540 let n_kv_heads = self.config.n_kv_heads;
2541
2542 let mut hidden = self.embedding.dequant_row(token_id);
2543 let residency = self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b));
2544
2545 for (l, ((layer, cache), store)) in self
2546 .layers
2547 .iter()
2548 .zip(kv_caches.iter_mut())
2549 .zip(stores.iter_mut())
2550 .enumerate()
2551 {
2552 let normed = rms_norm(&hidden, &layer.attn.norm_weight, self.config.rms_norm_eps);
2554
2555 let (mut q, mut k, mut v) = {
2556 #[cfg(any(feature = "cuda", feature = "metal"))]
2557 {
2558 if let Some(mut outs) = ferrox_core::WeightMatrix::apply_gpu_multi(
2559 &[&layer.attn.q_proj, &layer.attn.k_proj, &layer.attn.v_proj],
2560 &normed,
2561 ) {
2562 let v = outs.pop().unwrap();
2563 let k = outs.pop().unwrap();
2564 let q = outs.pop().unwrap();
2565 (q, k, v)
2566 } else {
2567 ferrox_core::weight_matrix::WeightMatrix::apply_three(
2568 &layer.attn.q_proj,
2569 &layer.attn.k_proj,
2570 &layer.attn.v_proj,
2571 &normed,
2572 )
2573 }
2574 }
2575 #[cfg(not(any(feature = "cuda", feature = "metal")))]
2576 {
2577 (
2578 layer.attn.q_proj.apply(&normed),
2579 layer.attn.k_proj.apply(&normed),
2580 layer.attn.v_proj.apply(&normed),
2581 )
2582 }
2583 };
2584
2585 if let Some(bias) = &layer.attn.q_bias {
2586 for (x, b) in q.iter_mut().zip(bias.iter()) {
2587 *x += b;
2588 }
2589 }
2590 if let Some(bias) = &layer.attn.k_bias {
2591 for (x, b) in k.iter_mut().zip(bias.iter()) {
2592 *x += b;
2593 }
2594 }
2595 if let Some(bias) = &layer.attn.v_bias {
2596 for (x, b) in v.iter_mut().zip(bias.iter()) {
2597 *x += b;
2598 }
2599 }
2600
2601 if let Some(q_norm) = &layer.attn.q_norm {
2602 q = self.apply_qk_norm(&q, q_norm);
2603 }
2604 if let Some(k_norm) = &layer.attn.k_norm {
2605 k = self.apply_qk_norm(&k, k_norm);
2606 }
2607 self.apply_rope_attn_factor(&mut q, &mut k);
2608
2609 for h in 0..n_heads {
2610 self.apply_rope_head_layer(&mut q[h * head_dim..(h + 1) * head_dim], pos, l);
2611 }
2612 for h in 0..n_kv_heads {
2613 self.apply_rope_head_layer(&mut k[h * head_dim..(h + 1) * head_dim], pos, l);
2614 }
2615
2616 cache.push(store, &k, &v)?;
2617
2618 let attn_out = causal_gqa_attention_paged(
2619 &q,
2620 store,
2621 cache.block_table(),
2622 n_heads,
2623 n_kv_heads,
2624 head_dim,
2625 cache.seq_len(),
2626 );
2627 let projected = layer.attn.o_proj.apply(&attn_out);
2628
2629 for (h, p) in hidden.iter_mut().zip(projected.iter()) {
2630 *h += p;
2631 }
2632
2633 let normed2 = rms_norm(&hidden, &layer.moe.norm_weight, self.config.rms_norm_eps);
2635 let ffn_out = Self::run_ffn_block(
2636 layer,
2637 &normed2,
2638 &self.config,
2639 hidden_dim,
2640 residency.as_ref().map(|p| p.layer_plan(l)),
2641 );
2642 for (h, f) in hidden.iter_mut().zip(ffn_out.iter()) {
2643 *h += f;
2644 }
2645 }
2646
2647 let final_normed = rms_norm(&hidden, &self.final_norm, self.config.rms_norm_eps);
2648 Ok(self.output_head.apply(&final_normed))
2649 }
2650
2651 pub fn expert_store_stats(&self) -> Option<ferrox_core::expert_store::ExpertStoreStats> {
2656 self.layers.iter().find_map(|l| match &l.moe.experts {
2657 ExpertBacking::Stored { store, .. } => Some(store.stats()),
2658 ExpertBacking::Resident(_) => None,
2659 })
2660 }
2661
2662 fn residency_plan(&self, vram_budget_bytes: u64) -> ferrox_moe::ResidencyPlan {
2673 let mut sizes_per_layer: Vec<Vec<usize>> = Vec::with_capacity(self.layers.len());
2674 let mut counts_per_layer: Vec<Vec<u64>> = Vec::with_capacity(self.layers.len());
2675 let mut any_observed = false;
2676 for layer in &self.layers {
2677 if Self::is_dense_layer(layer) {
2678 sizes_per_layer.push(Vec::new());
2679 counts_per_layer.push(Vec::new());
2680 continue;
2681 }
2682 sizes_per_layer.push(
2683 (0..layer.moe.n_experts())
2684 .map(|e| layer.moe.expert_bytes(e))
2685 .collect(),
2686 );
2687 let counts: Vec<u64> = layer
2688 .moe
2689 .activation_counts
2690 .iter()
2691 .map(|c| c.load(Ordering::Relaxed))
2692 .collect();
2693 any_observed |= counts.iter().any(|&c| c > 0);
2694 counts_per_layer.push(counts);
2695 }
2696 PlacementPlan::plan_layers_against_global_budget(
2697 &sizes_per_layer,
2698 any_observed.then_some(counts_per_layer.as_slice()),
2699 vram_budget_bytes,
2700 )
2701 }
2702
2703 fn is_dense_layer(layer: &LayerWeights) -> bool {
2714 layer.moe.n_experts() == 1 && layer.moe.shared_experts.is_empty()
2715 }
2716
2717 fn cpu_moe_topk_parallel_slots(
2721 experts: &[ExpertWeights],
2722 normed2: &[f32],
2723 decision: &ferrox_moe::RoutingDecision,
2724 hidden_dim: usize,
2725 ) -> Option<Vec<(Vec<f32>, f32)>> {
2726 use rayon::prelude::*;
2727 if !ferrox_core::weight_matrix::cpu_int_dot_enabled() || !normed2.len().is_multiple_of(32) {
2728 return None;
2729 }
2730 let n_slots = decision.expert_ids.len();
2731 if n_slots == 0 {
2732 return Some(Vec::new());
2733 }
2734 for &eid in &decision.expert_ids {
2735 let ex = experts.get(eid)?;
2736 if ex.gate.rows() == 0
2737 || ex.up.rows() != ex.gate.rows()
2738 || ex.down.rows() != hidden_dim
2739 || ex.gate.cols() != normed2.len()
2740 || ex.up.cols() != normed2.len()
2741 || ex.down.cols() != ex.gate.rows()
2742 {
2743 return None;
2744 }
2745 if !matches!(
2746 &ex.gate,
2747 WeightMatrix::Quantized {
2748 kind: ferrox_core::QuantKind::Q4_0 | ferrox_core::QuantKind::Q8_0,
2749 ..
2750 }
2751 ) || !matches!(
2752 &ex.up,
2753 WeightMatrix::Quantized {
2754 kind: ferrox_core::QuantKind::Q4_0 | ferrox_core::QuantKind::Q8_0,
2755 ..
2756 }
2757 ) {
2758 return None;
2759 }
2760 }
2761 let ffn_rows = experts[decision.expert_ids[0]].gate.rows();
2762 if !ffn_rows.is_multiple_of(2) {
2764 return None;
2765 }
2766 let act = ferrox_quant::quantize_activations_q8(normed2);
2767 let eids = &decision.expert_ids;
2768 let mut gate = vec![0f32; n_slots * ffn_rows];
2769 let mut up = vec![0f32; n_slots * ffn_rows];
2770 gate.par_chunks_mut(2)
2771 .zip(up.par_chunks_mut(2))
2772 .enumerate()
2773 .for_each(|(p, (gc, uc))| {
2774 let row0 = p * 2;
2775 let slot = row0 / ffn_rows;
2776 let r = row0 % ffn_rows;
2777 let ex = &experts[eids[slot]];
2778 if let (Some((g0, g1)), Some((u0, u1))) = (
2779 ex.gate.dot_pair_cpu_q8(r, &act),
2780 ex.up.dot_pair_cpu_q8(r, &act),
2781 ) {
2782 gc[0] = g0;
2783 gc[1] = g1;
2784 uc[0] = u0;
2785 uc[1] = u1;
2786 } else {
2787 gc[0] = ex.gate.dot_row_cpu_q8(r, &act).unwrap_or(0.0);
2788 gc[1] = ex.gate.dot_row_cpu_q8(r + 1, &act).unwrap_or(0.0);
2789 uc[0] = ex.up.dot_row_cpu_q8(r, &act).unwrap_or(0.0);
2790 uc[1] = ex.up.dot_row_cpu_q8(r + 1, &act).unwrap_or(0.0);
2791 }
2792 });
2793 let mut activated = vec![0f32; n_slots * ffn_rows];
2794 activated.par_iter_mut().enumerate().for_each(|(idx, a)| {
2795 let g = gate[idx];
2796 *a = (g / (1.0 + (-g).exp())) * up[idx];
2797 });
2798 let mut outs: Vec<(Vec<f32>, f32)> = decision
2799 .weights
2800 .iter()
2801 .map(|&w| (vec![0f32; hidden_dim], w))
2802 .collect();
2803 outs.par_iter_mut()
2804 .enumerate()
2805 .for_each(|(slot, (out, _))| {
2806 let ex = &experts[eids[slot]];
2807 let act_slot = &activated[slot * ffn_rows..(slot + 1) * ffn_rows];
2808 if act_slot.len().is_multiple_of(32) {
2809 let q8 = ferrox_quant::quantize_activations_q8(act_slot);
2810 if let Some(d) = ex.down.apply_cpu_q8(&q8) {
2811 *out = d;
2812 return;
2813 }
2814 }
2815 *out = ex.down.apply(act_slot);
2816 });
2817 Some(outs)
2818 }
2819
2820 fn cpu_moe_serial_experts(
2822 layer: &LayerWeights,
2823 normed2: &[f32],
2824 decision: &ferrox_moe::RoutingDecision,
2825 plan: Option<&PlacementPlan>,
2826 ) -> Vec<(Vec<f32>, f32)> {
2827 let shared_act = if ferrox_core::weight_matrix::cpu_int_dot_enabled()
2828 && normed2.len().is_multiple_of(32)
2829 && plan
2830 .map(|p| {
2831 decision
2832 .expert_ids
2833 .iter()
2834 .all(|&eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu))
2835 })
2836 .unwrap_or(true)
2837 {
2838 Some(ferrox_quant::quantize_activations_q8(normed2))
2839 } else {
2840 None
2841 };
2842 decision
2843 .expert_ids
2844 .iter()
2845 .zip(decision.weights.iter())
2846 .map(|(&eid, &w)| {
2847 let placement = plan
2848 .map(|p| p.placement_for(eid))
2849 .unwrap_or(ExpertPlacement::Cpu);
2850 let out = layer.moe.with_expert(eid, |ex| {
2851 if let Some(ref act) = shared_act {
2852 if let (Some(gate), Some(up)) =
2853 (ex.gate.apply_cpu_q8(act), ex.up.apply_cpu_q8(act))
2854 {
2855 let activated = ferrox_core::matmul::swiglu(&gate, &up);
2856 return ex.down.apply(&activated);
2857 }
2858 }
2859 run_expert_placed(normed2, ex, placement)
2860 });
2861 (out, w)
2862 })
2863 .collect()
2864 }
2865
2866 fn route_for_layer(
2894 layer: &LayerWeights,
2895 router_logits: &[f32],
2896 config: &ModelConfig,
2897 ) -> ferrox_moe::RoutingDecision {
2898 match (
2899 config.moe.expert_group_count,
2900 config.moe.expert_group_used_count,
2901 ) {
2902 (Some(n_groups), Some(k_per_group)) if n_groups > 1 && k_per_group > 0 => {
2903 ferrox_moe::route_top_k_grouped(
2904 router_logits,
2905 n_groups,
2906 k_per_group,
2907 config.moe.n_experts_active,
2908 config.moe.gating,
2909 config.moe.norm_topk_prob,
2910 )
2911 }
2912 _ if layer.moe.exp_probs_bias.is_some() || config.moe.expert_weights_scale != 1.0 => {
2913 ferrox_moe::route_top_k_biased(
2914 router_logits,
2915 layer.moe.exp_probs_bias.as_deref(),
2916 config.moe.n_experts_active,
2917 config.moe.gating,
2918 config.moe.norm_topk_prob,
2919 config.moe.expert_weights_scale,
2920 )
2921 }
2922 _ => route_top_k(
2923 router_logits,
2924 config.moe.n_experts_active,
2925 config.moe.gating,
2926 config.moe.norm_topk_prob,
2927 ),
2928 }
2929 }
2930
2931 fn combine_ffn_outputs_for_position(
2932 layer: &LayerWeights,
2933 normed2: &[f32],
2934 router_logits: &[f32],
2935 config: &ModelConfig,
2936 hidden_dim: usize,
2937 plan: Option<&PlacementPlan>,
2938 ) -> Vec<f32> {
2939 let decision = Self::route_for_layer(layer, router_logits, config);
2940 layer.moe.record_activations(&decision.expert_ids);
2941 if let ExpertBacking::Stored {
2945 store,
2946 layer: layer_id,
2947 ..
2948 } = &layer.moe.experts
2949 {
2950 let keys: Vec<ferrox_core::expert_store::ExpertKey> = decision
2951 .expert_ids
2952 .iter()
2953 .map(|&eid| ferrox_core::expert_store::ExpertKey {
2954 layer: *layer_id,
2955 expert: eid as u32,
2956 })
2957 .collect();
2958 store.prefetch(&keys);
2959 }
2960
2961 #[cfg(feature = "metal")]
2965 if ferrox_core::metal_dense_enabled()
2966 && matches!(
2967 config.ffn_activation,
2968 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
2969 )
2970 && layer.moe.shared_experts.is_empty()
2971 {
2972 if let Some(fused) = Self::try_metal_moe_topk(layer, normed2, &decision) {
2973 return fused;
2974 }
2975 }
2976
2977 let routed_outputs: Vec<(Vec<f32>, f32)> = {
2978 let all_cpu = plan
2982 .map(|p| {
2983 decision
2984 .expert_ids
2985 .iter()
2986 .all(|&eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu))
2987 })
2988 .unwrap_or(true);
2989 if let (true, ExpertBacking::Resident(experts)) = (all_cpu, &layer.moe.experts) {
2990 if let Some(outs) =
2991 Self::cpu_moe_topk_parallel_slots(experts, normed2, &decision, hidden_dim)
2992 {
2993 outs
2994 } else {
2995 Self::cpu_moe_serial_experts(layer, normed2, &decision, plan)
2996 }
2997 } else {
2998 Self::cpu_moe_serial_experts(layer, normed2, &decision, plan)
2999 }
3000 };
3001 let mut shared_outputs: Vec<Vec<f32>> = layer
3005 .moe
3006 .shared_experts
3007 .iter()
3008 .map(|e| run_expert(normed2, e))
3009 .collect();
3010 if let Some(gate) = &layer.moe.shared_expert_gate {
3016 let gate_logit: f32 = gate.iter().zip(normed2.iter()).map(|(g, x)| g * x).sum();
3017 let gate_value = 1.0 / (1.0 + (-gate_logit).exp());
3018 for out in shared_outputs.iter_mut() {
3019 for x in out.iter_mut() {
3020 *x *= gate_value;
3021 }
3022 }
3023 }
3024
3025 combine_expert_outputs(&routed_outputs, &shared_outputs, hidden_dim)
3026 }
3027
3028 fn dense_ffn_batch(
3055 layer: &LayerWeights,
3056 normed2_batch: &[f32],
3057 batch_size: usize,
3058 config: &ModelConfig,
3059 ) -> Option<Vec<f32>> {
3060 if !Self::is_dense_layer(layer) || batch_size < 4 {
3063 return None;
3064 }
3065 #[cfg(any(feature = "metal", feature = "cuda"))]
3069 {
3070 #[cfg(feature = "metal")]
3071 let gpu_dense = ferrox_core::weight_matrix::metal_dense_enabled();
3072 #[cfg(not(feature = "metal"))]
3073 let gpu_dense = false;
3074 #[cfg(feature = "cuda")]
3075 let gpu_dense = gpu_dense || ferrox_core::weight_matrix::cuda_dense_enabled();
3076 if gpu_dense {
3077 let all_gemm = layer.moe.with_expert(0, |ex| {
3078 ex.gate.prefers_gpu_batch()
3079 && ex.up.prefers_gpu_batch()
3080 && ex.down.prefers_gpu_batch()
3081 });
3082 if !all_gemm {
3083 return None;
3084 }
3085 }
3086 }
3087 layer.moe.record_activations(&[0]);
3088 #[cfg(feature = "metal")]
3094 if ferrox_core::weight_matrix::metal_dense_enabled() {
3095 let gelu = matches!(config.ffn_activation, crate::config::FfnActivation::Gelu);
3096 let fused = layer.moe.with_expert(0, |ex| {
3097 let (g, u, d) = (
3098 ex.gate.mul_mm_sg_launch()?,
3099 ex.up.mul_mm_sg_launch()?,
3100 ex.down.mul_mm_sg_launch()?,
3101 );
3102 ferrox_metal::gpu::launch_dense_ffn_swiglu_batch(
3103 &g,
3104 &u,
3105 &d,
3106 normed2_batch,
3107 batch_size,
3108 gelu,
3109 )
3110 .ok()
3111 });
3112 if let Some(out) = fused {
3113 return Some(out);
3114 }
3115 }
3116 Some(layer.moe.with_expert(0, |ex| {
3117 let ffn_acts = ex.gate.quantize_batch_acts(normed2_batch, batch_size);
3118 let gate = ex
3119 .gate
3120 .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
3121 let up = ex
3122 .up
3123 .apply_batch_with_acts(normed2_batch, batch_size, ffn_acts.as_ref());
3124 let activated: Vec<f32> = match config.ffn_activation {
3125 crate::config::FfnActivation::Swiglu
3126 | crate::config::FfnActivation::SwigluFused => {
3127 ferrox_core::matmul::swiglu(&gate, &up)
3128 }
3129 crate::config::FfnActivation::Gelu => geglu(&gate, &up),
3130 };
3131 ex.down.apply_batch(&activated, batch_size)
3132 }))
3133 }
3134
3135 fn moe_ffn_batch(
3142 layer: &LayerWeights,
3143 normed2_batch: &[f32],
3144 router_logits_batch: &[f32],
3145 batch_size: usize,
3146 hidden_dim: usize,
3147 config: &ModelConfig,
3148 plan: Option<&PlacementPlan>,
3149 ) -> Option<Vec<f32>> {
3150 if batch_size < 32 || Self::is_dense_layer(layer) {
3151 return None;
3152 }
3153 #[cfg(feature = "metal")]
3156 if ferrox_core::metal_dense_enabled() {
3157 return None;
3158 }
3159 if !matches!(
3160 config.ffn_activation,
3161 crate::config::FfnActivation::Swiglu | crate::config::FfnActivation::SwigluFused
3162 ) {
3163 return None;
3164 }
3165 let ExpertBacking::Resident(experts) = &layer.moe.experts else {
3166 return None;
3167 };
3168 let n_experts = experts.len();
3169 let all_cpu = plan
3170 .map(|p| (0..n_experts).all(|eid| matches!(p.placement_for(eid), ExpertPlacement::Cpu)))
3171 .unwrap_or(true);
3172 if !all_cpu || n_experts == 0 {
3173 return None;
3174 }
3175
3176 let mut buckets: Vec<Vec<(usize, f32)>> = vec![Vec::new(); n_experts];
3177 for b in 0..batch_size {
3178 let logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
3179 let decision = Self::route_for_layer(layer, logits, config);
3180 layer.moe.record_activations(&decision.expert_ids);
3181 for (&eid, &w) in decision.expert_ids.iter().zip(decision.weights.iter()) {
3182 buckets[eid].push((b, w));
3183 }
3184 }
3185
3186 let mut acc = vec![0f32; batch_size * hidden_dim];
3187 for (eid, toks) in buckets.iter().enumerate() {
3188 if toks.is_empty() {
3189 continue;
3190 }
3191 let n = toks.len();
3192 let mut gathered = vec![0f32; n * hidden_dim];
3193 for (i, &(tok, _)) in toks.iter().enumerate() {
3194 gathered[i * hidden_dim..(i + 1) * hidden_dim]
3195 .copy_from_slice(&normed2_batch[tok * hidden_dim..(tok + 1) * hidden_dim]);
3196 }
3197 let ex = &experts[eid];
3198 let ffn_acts = ex.gate.quantize_batch_acts(&gathered, n);
3199 let gate = ex
3200 .gate
3201 .apply_batch_with_acts(&gathered, n, ffn_acts.as_ref());
3202 let up = ex.up.apply_batch_with_acts(&gathered, n, ffn_acts.as_ref());
3203 let activated = ferrox_core::matmul::swiglu(&gate, &up);
3204 let down = ex.down.apply_batch(&activated, n);
3205 for (i, &(tok, w)) in toks.iter().enumerate() {
3206 let out = &down[i * hidden_dim..(i + 1) * hidden_dim];
3207 let row = &mut acc[tok * hidden_dim..(tok + 1) * hidden_dim];
3208 for (a, &o) in row.iter_mut().zip(out.iter()) {
3209 *a += w * o;
3210 }
3211 }
3212 }
3213
3214 Self::accumulate_shared_experts_batch(
3215 layer,
3216 normed2_batch,
3217 batch_size,
3218 hidden_dim,
3219 &mut acc,
3220 );
3221 Some(acc)
3222 }
3223
3224 fn gpt_oss_ffn(
3241 layer: &LayerWeights,
3242 oai: &GptOssLayer,
3243 normed2: &[f32],
3244 config: &ModelConfig,
3245 hidden_dim: usize,
3246 ) -> Vec<f32> {
3247 let mut router_logits = layer.moe.router.apply(normed2);
3248 for (x, b) in router_logits.iter_mut().zip(oai.router_bias.iter()) {
3249 *x += b;
3250 }
3251 let decision =
3254 ferrox_moe::route_top_k_softmax_weight(&router_logits, config.moe.n_experts_active);
3255 layer.moe.record_activations(&decision.expert_ids);
3256
3257 let mut out = vec![0f32; hidden_dim];
3258 for (slot, &eid) in decision.expert_ids.iter().enumerate() {
3259 let w = decision.weights[slot];
3260 let expert_out = layer.moe.with_expert(eid, |ex| {
3261 ferrox_moe::run_expert_oai(
3262 normed2,
3263 ex,
3264 &oai.expert_bias[eid],
3265 ferrox_moe::SWIGLU_OAI_ALPHA,
3266 ferrox_moe::SWIGLU_OAI_LIMIT,
3267 )
3268 });
3269 for (o, e) in out.iter_mut().zip(expert_out.iter()) {
3270 *o += w * e;
3271 }
3272 }
3273 out
3274 }
3275
3276 fn run_ffn_block(
3280 layer: &LayerWeights,
3281 normed2: &[f32],
3282 config: &ModelConfig,
3283 hidden_dim: usize,
3284 plan: Option<&PlacementPlan>,
3285 ) -> Vec<f32> {
3286 if Self::is_dense_layer(layer) {
3287 layer.moe.record_activations(&[0]);
3288 return layer.moe.with_expert(0, |ex| match config.ffn_activation {
3289 crate::config::FfnActivation::Swiglu
3290 | crate::config::FfnActivation::SwigluFused => run_expert(normed2, ex),
3291 crate::config::FfnActivation::Gelu => {
3292 if ferrox_core::weight_matrix::cpu_int_dot_enabled()
3295 && normed2.len().is_multiple_of(32)
3296 {
3297 let act = ferrox_quant::quantize_activations_q8(normed2);
3298 if let (Some(gate), Some(up)) =
3299 (ex.gate.apply_cpu_q8(&act), ex.up.apply_cpu_q8(&act))
3300 {
3301 let activated = geglu(&gate, &up);
3302 return ex.down.apply(&activated);
3303 }
3304 }
3305 let gate = ex.gate.apply(normed2);
3306 let up = ex.up.apply(normed2);
3307 let activated = geglu(&gate, &up);
3308 ex.down.apply(&activated)
3309 }
3310 });
3311 }
3312 let router_logits = layer.moe.router.apply(normed2);
3313 Self::combine_ffn_outputs_for_position(
3314 layer,
3315 normed2,
3316 &router_logits,
3317 config,
3318 hidden_dim,
3319 plan,
3320 )
3321 }
3322
3323 pub fn forward_batch(
3350 &self,
3351 tokens: &[usize],
3352 start_pos: usize,
3353 kv_caches: &mut [KvCache],
3354 ) -> Vec<Vec<f32>> {
3355 let hiddens = self.forward_hidden_batch(tokens, start_pos, kv_caches);
3356 if hiddens.is_empty() {
3357 return Vec::new();
3358 }
3359 let batch_size = hiddens.len();
3360 let vocab_size = self.output_head.rows();
3361 let flat: Vec<f32> = hiddens.into_iter().flatten().collect();
3362 let mut logits_batch = self.output_head.apply_batch(&flat, batch_size);
3363 if let Some(sc) = self.config.final_logit_softcap {
3364 softcap_inplace(&mut logits_batch, sc);
3365 }
3366 logits_batch
3367 .chunks(vocab_size)
3368 .map(|c| c.to_vec())
3369 .collect()
3370 }
3371
3372 pub fn forward_batch_last(
3392 &self,
3393 tokens: &[usize],
3394 start_pos: usize,
3395 kv_caches: &mut [KvCache],
3396 ) -> Vec<f32> {
3397 let hiddens = self.forward_hidden_batch(tokens, start_pos, kv_caches);
3398 let Some(last) = hiddens.last() else {
3399 return Vec::new();
3400 };
3401 let mut logits = self.output_head.apply(last);
3402 if let Some(sc) = self.config.final_logit_softcap {
3403 softcap_inplace(&mut logits, sc);
3404 }
3405 logits
3406 }
3407
3408 pub fn forward_hidden_batch(
3412 &self,
3413 tokens: &[usize],
3414 start_pos: usize,
3415 kv_caches: &mut [KvCache],
3416 ) -> Vec<Vec<f32>> {
3417 assert_eq!(kv_caches.len(), self.layers.len());
3418 let batch_size = tokens.len();
3419 if batch_size == 0 {
3420 return Vec::new();
3421 }
3422
3423 let hidden_dim = self.config.hidden_dim;
3424 let head_dim = self.config.head_dim;
3425 let n_heads = self.config.n_heads;
3426 let n_kv_heads = self.config.n_kv_heads;
3427
3428 let mut hidden_batch: Vec<f32> = tokens
3430 .iter()
3431 .flat_map(|&t| self.embedding.dequant_row(t))
3432 .collect();
3433 if let Some(scale) = self.config.embedding_scale {
3434 for v in hidden_batch.iter_mut() {
3435 *v *= scale;
3436 }
3437 }
3438
3439 #[cfg(feature = "metal")]
3440 let use_metal_attn = ferrox_core::metal_dense_enabled()
3441 && ferrox_metal::attn::metal_attn_enabled()
3442 && self
3443 .layers
3444 .iter()
3445 .all(|l| self.layer_supports_metal_attn(l));
3446
3447 #[cfg(not(feature = "metal"))]
3448 let use_metal_attn = false;
3449
3450 let residency = self.expert_residency_plan(use_metal_attn);
3451
3452 #[cfg(feature = "metal")]
3453 let mut metal_kv_guard: Option<
3454 std::sync::MutexGuard<'_, Option<Vec<ferrox_metal::attn::MetalKvBuffers>>>,
3455 > = if use_metal_attn {
3456 Some(self.metal_attn_kv.lock().unwrap())
3457 } else {
3458 None
3459 };
3460
3461 #[cfg(feature = "metal")]
3462 if let Some(guard) = metal_kv_guard.as_mut() {
3463 let need = self.layers.len();
3464 let need_cap = start_pos
3465 .saturating_add(batch_size)
3466 .saturating_add(256)
3467 .max(512);
3468 let reset = match guard.as_ref() {
3469 None => true,
3470 Some(v) => {
3471 v.len() != need
3472 || v.iter().any(|m| m.capacity() < need_cap)
3473 || v.iter()
3474 .zip(kv_caches.iter())
3475 .any(|(m, c)| m.seq_len != c.seq_len)
3476 }
3477 };
3478 if reset {
3479 let mut bufs = Vec::with_capacity(need);
3480 for _ in 0..need {
3481 match ferrox_metal::attn::MetalKvBuffers::with_capacity(
3482 n_kv_heads, head_dim, need_cap,
3483 ) {
3484 Ok(b) => bufs.push(b),
3485 Err(_) => {
3486 **guard = None;
3487 break;
3488 }
3489 }
3490 }
3491 if bufs.len() == need {
3492 let mut ok = true;
3493 for (m, c) in bufs.iter_mut().zip(kv_caches.iter()) {
3494 if c.seq_len > 0 && m.upload_from_host(&c.k, &c.v, c.seq_len).is_err() {
3495 ok = false;
3496 break;
3497 }
3498 }
3499 if ok {
3500 **guard = Some(bufs);
3501 } else {
3502 **guard = None;
3503 }
3504 } else {
3505 **guard = None;
3506 }
3507 }
3508 }
3509
3510 let n_layers = self.layers.len();
3511 let mut l = 0usize;
3512 while l < n_layers {
3513 let layer = &self.layers[l];
3514 let q_width = n_heads * head_dim;
3515 let kv_width = n_kv_heads * head_dim;
3516
3517 #[cfg(feature = "metal")]
3519 if use_metal_attn && batch_size >= 4 {
3520 if let Some(guard) = metal_kv_guard.as_mut() {
3521 if let Some(metal_kvs) = guard.as_mut() {
3522 if let Some(run_len) = self.metal_prefill_dense_stack_run_len(
3523 l,
3524 start_pos,
3525 batch_size,
3526 kv_caches,
3527 Some(metal_kvs.as_slice()),
3528 ) {
3529 if let Some(h_out) = self.try_metal_prefill_dense_stack(
3530 l,
3531 run_len,
3532 &hidden_batch,
3533 start_pos,
3534 batch_size,
3535 n_heads,
3536 metal_kvs,
3537 kv_caches,
3538 ) {
3539 hidden_batch = h_out;
3540 l += run_len;
3541 continue;
3542 }
3543 }
3544 }
3545 }
3546 }
3547
3548 let cache = &mut kv_caches[l];
3549
3550 #[cfg(feature = "metal")]
3553 if use_metal_attn && batch_size >= 4 && Self::metal_prefill_dense_layer_eligible(layer)
3554 {
3555 let swa_fits = self.metal_prefill_dense_swa_fits(l, start_pos, batch_size);
3556 if swa_fits {
3557 if let Some(guard) = metal_kv_guard.as_mut() {
3558 if let Some(metal_kvs) = guard.as_mut() {
3559 if metal_kvs[l].seq_len == cache.seq_len && start_pos == cache.seq_len {
3560 layer.moe.record_activations(&[0]);
3561 let fused = layer.moe.with_expert(0, |ex| {
3562 let (q, k, v, o) = (
3563 layer.attn.q_proj.mul_mm_sg_launch()?,
3564 layer.attn.k_proj.mul_mm_sg_launch()?,
3565 layer.attn.v_proj.mul_mm_sg_launch()?,
3566 layer.attn.o_proj.mul_mm_sg_launch()?,
3567 );
3568 let ffn = ferrox_metal::attn::PrefillFfnMetal::Dense {
3569 gate: ex.gate.mul_mm_sg_launch()?,
3570 up: ex.up.mul_mm_sg_launch()?,
3571 down: ex.down.mul_mm_sg_launch()?,
3572 };
3573 let gelu = matches!(
3574 self.config.ffn_activation,
3575 crate::config::FfnActivation::Gelu
3576 );
3577 let prefill_layer =
3578 ferrox_metal::attn::PrefillDenseLayerMetal {
3579 attn_norm_w: &layer.attn.norm_weight,
3580 ffn_norm_w: &layer.moe.norm_weight,
3581 q,
3582 k,
3583 v,
3584 o,
3585 ffn,
3586 post_attn_norm: layer.attn.post_attn_norm.as_deref(),
3587 post_ffn_norm: layer.attn.post_ffn_norm.as_deref(),
3588 extras: self.metal_attn_extras(layer),
3589 layer_idx: l as u32,
3590 };
3591 ferrox_metal::attn::launch_prefill_dense_layer(
3592 &hidden_batch,
3593 &prefill_layer,
3594 &mut metal_kvs[l],
3595 n_heads,
3596 batch_size,
3597 self.metal_rope(),
3598 self.config.layer_rope_theta(l),
3599 self.config.rope_freqs.as_deref(),
3600 start_pos,
3601 self.config.rms_norm_eps,
3602 gelu,
3603 self.config.attn_logit_softcap,
3604 )
3605 .ok()
3606 });
3607 if let Some(h_out) = fused {
3608 cache
3609 .advance_len(batch_size)
3610 .expect("unbounded/planned KvCache growth is infallible");
3611 hidden_batch = h_out;
3612 l += 1;
3613 continue;
3614 }
3615 }
3616 }
3617 }
3618 }
3619 }
3620
3621 let normed_batch: Vec<f32> = hidden_batch
3623 .par_chunks(hidden_dim)
3624 .map(|h| rms_norm(h, &layer.attn.norm_weight, self.config.rms_norm_eps))
3625 .flatten()
3626 .collect();
3627
3628 let qkv_acts = layer
3633 .attn
3634 .q_proj
3635 .quantize_batch_acts(&normed_batch, batch_size);
3636 let mut q_batch = layer.attn.q_proj.apply_batch_with_acts(
3637 &normed_batch,
3638 batch_size,
3639 qkv_acts.as_ref(),
3640 );
3641 let mut k_batch = layer.attn.k_proj.apply_batch_with_acts(
3642 &normed_batch,
3643 batch_size,
3644 qkv_acts.as_ref(),
3645 );
3646 let mut v_batch = layer.attn.v_proj.apply_batch_with_acts(
3647 &normed_batch,
3648 batch_size,
3649 qkv_acts.as_ref(),
3650 );
3651 drop(qkv_acts);
3652
3653 if let Some(bias) = &layer.attn.q_bias {
3654 for row in q_batch.chunks_mut(q_width) {
3655 for (x, b) in row.iter_mut().zip(bias.iter()) {
3656 *x += b;
3657 }
3658 }
3659 }
3660 if let Some(bias) = &layer.attn.k_bias {
3661 for row in k_batch.chunks_mut(kv_width) {
3662 for (x, b) in row.iter_mut().zip(bias.iter()) {
3663 *x += b;
3664 }
3665 }
3666 }
3667 if let Some(bias) = &layer.attn.v_bias {
3668 for row in v_batch.chunks_mut(kv_width) {
3669 for (x, b) in row.iter_mut().zip(bias.iter()) {
3670 *x += b;
3671 }
3672 }
3673 }
3674
3675 if let Some(q_norm) = &layer.attn.q_norm {
3676 for row in q_batch.chunks_mut(q_width) {
3677 let normed = self.apply_qk_norm(row, q_norm);
3678 row.copy_from_slice(&normed);
3679 }
3680 }
3681 if let Some(k_norm) = &layer.attn.k_norm {
3682 for row in k_batch.chunks_mut(kv_width) {
3683 let normed = self.apply_qk_norm(row, k_norm);
3684 row.copy_from_slice(&normed);
3685 }
3686 }
3687 self.apply_rope_attn_factor(&mut q_batch, &mut k_batch);
3692
3693 #[cfg(feature = "metal")]
3694 {
3695 let mut did_metal_prefill = false;
3696 let swa_fits = match self.config.layer_sliding_window(l) {
3700 Some(window) => start_pos + batch_size <= window,
3701 None => true,
3702 };
3703 if let Some(guard) = metal_kv_guard.as_mut() {
3705 if let Some(metal_kvs) = guard.as_mut() {
3706 if metal_kvs[l].seq_len == cache.seq_len
3707 && start_pos == cache.seq_len
3708 && swa_fits
3709 {
3710 let prefill_res = {
3711 let o_launch = Self::metal_matvec_launch(&layer.attn.o_proj);
3712 let fuse_o = matches!(
3716 std::env::var("FERROX_METAL_PREFILL_FUSE_O").ok().as_deref(),
3717 Some("1") | Some("true") | Some("on")
3718 ) && o_launch.as_ref().is_some_and(|o| {
3719 o.fn_name == "q4_0_matvec"
3720 && o.block_bytes == 18
3721 && layer.attn.post_attn_norm.is_none()
3722 });
3723 if fuse_o {
3724 let o = o_launch.as_ref().unwrap();
3725 ferrox_metal::attn::launch_prefill_attn_o_residual(
3726 &q_batch,
3727 &k_batch,
3728 &v_batch,
3729 &hidden_batch,
3730 o,
3731 &mut metal_kvs[l],
3732 n_heads,
3733 batch_size,
3734 self.metal_rope().attn_factor_applied_by_caller(),
3735 self.config.layer_rope_theta(l),
3736 self.config.rope_freqs.as_deref(),
3737 start_pos,
3738 self.config.attn_logit_softcap,
3739 )
3740 .map(|h_out| {
3741 cache.advance_len(batch_size).expect(
3742 "unbounded/planned KvCache growth is infallible",
3743 );
3744 hidden_batch = h_out;
3745 true
3746 })
3747 } else {
3748 ferrox_metal::attn::launch_prefill_attn_block(
3749 &q_batch,
3750 &k_batch,
3751 &v_batch,
3752 &mut metal_kvs[l],
3753 n_heads,
3754 batch_size,
3755 self.metal_rope().attn_factor_applied_by_caller(),
3756 self.config.layer_rope_theta(l),
3757 self.config.rope_freqs.as_deref(),
3758 start_pos,
3759 self.config.attn_logit_softcap,
3760 false,
3761 )
3762 .map(
3763 |(attn_out_batch, _, _)| {
3764 cache.advance_len(batch_size).expect(
3765 "unbounded/planned KvCache growth is infallible",
3766 );
3767 let projected_batch = layer
3768 .attn
3769 .o_proj
3770 .apply_batch(&attn_out_batch, batch_size);
3771 let projected_batch =
3772 if let Some(post) = &layer.attn.post_attn_norm {
3773 projected_batch
3774 .chunks(hidden_dim)
3775 .flat_map(|row| {
3776 rms_norm(
3777 row,
3778 post,
3779 self.config.rms_norm_eps,
3780 )
3781 })
3782 .collect::<Vec<_>>()
3783 } else {
3784 projected_batch
3785 };
3786 for (h, p) in
3787 hidden_batch.iter_mut().zip(projected_batch.iter())
3788 {
3789 *h += p;
3790 }
3791 true
3792 },
3793 )
3794 }
3795 };
3796 match prefill_res {
3797 Ok(true) => {
3798 did_metal_prefill = true;
3799 }
3800 Ok(false) => {}
3801 Err(e) => {
3802 eprintln!(
3803 "ferrox: Metal prefill attn failed, CPU fallback: {e}"
3804 );
3805 **guard = None;
3806 }
3807 }
3808 }
3809 }
3810 }
3811 if did_metal_prefill {
3812 let normed2_batch: Vec<f32> = hidden_batch
3814 .chunks(hidden_dim)
3815 .flat_map(|h| rms_norm(h, &layer.moe.norm_weight, self.config.rms_norm_eps))
3816 .collect();
3817 let dense = Self::is_dense_layer(layer);
3818 let router_logits_batch = if dense {
3819 Vec::new()
3820 } else {
3821 layer.moe.router.apply_batch(&normed2_batch, batch_size)
3822 };
3823 let metal_ffn = if !dense {
3824 Self::try_metal_moe_prefill_batch(
3825 layer,
3826 &normed2_batch,
3827 &router_logits_batch,
3828 batch_size,
3829 hidden_dim,
3830 &self.config,
3831 )
3832 } else {
3833 None
3834 };
3835 if let Some(mut ffn_batch) = metal_ffn {
3836 if let Some(post) = &layer.attn.post_ffn_norm {
3837 ffn_batch = ffn_batch
3838 .chunks(hidden_dim)
3839 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
3840 .collect();
3841 }
3842 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
3843 *h += f;
3844 }
3845 } else if let Some(mut ffn_batch) =
3846 Self::dense_ffn_batch(layer, &normed2_batch, batch_size, &self.config)
3847 {
3848 if let Some(post) = &layer.attn.post_ffn_norm {
3849 ffn_batch = ffn_batch
3850 .chunks(hidden_dim)
3851 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
3852 .collect();
3853 }
3854 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
3855 *h += f;
3856 }
3857 } else if let Some(mut ffn_batch) = Self::moe_ffn_batch(
3858 layer,
3859 &normed2_batch,
3860 &router_logits_batch,
3861 batch_size,
3862 hidden_dim,
3863 &self.config,
3864 residency.as_ref().map(|p| p.layer_plan(l)),
3865 ) {
3866 if let Some(post) = &layer.attn.post_ffn_norm {
3867 ffn_batch = ffn_batch
3868 .chunks(hidden_dim)
3869 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
3870 .collect();
3871 }
3872 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
3873 *h += f;
3874 }
3875 } else {
3876 let n_experts = layer.moe.n_experts().max(1);
3877 for b in 0..batch_size {
3878 let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
3879 let mut ffn_out = if dense {
3880 Self::run_ffn_block(
3881 layer,
3882 normed2,
3883 &self.config,
3884 hidden_dim,
3885 residency.as_ref().map(|p| p.layer_plan(l)),
3886 )
3887 } else {
3888 let router_logits =
3889 &router_logits_batch[b * n_experts..(b + 1) * n_experts];
3890 Self::combine_ffn_outputs_for_position(
3891 layer,
3892 normed2,
3893 router_logits,
3894 &self.config,
3895 hidden_dim,
3896 residency.as_ref().map(|p| p.layer_plan(l)),
3897 )
3898 };
3899 if let Some(post) = &layer.attn.post_ffn_norm {
3900 ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
3901 }
3902 let hidden_row =
3903 &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
3904 for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
3905 *h += f;
3906 }
3907 }
3908 }
3909 l += 1;
3910 continue;
3911 }
3912 }
3913
3914 q_batch
3916 .par_chunks_mut(q_width)
3917 .zip(k_batch.par_chunks_mut(kv_width))
3918 .enumerate()
3919 .for_each(|(b, (q_row, k_row))| {
3920 let pos = start_pos + b;
3921 for h in 0..n_heads {
3922 self.apply_rope_head_layer(
3923 &mut q_row[h * head_dim..(h + 1) * head_dim],
3924 pos,
3925 l,
3926 );
3927 }
3928 for h in 0..n_kv_heads {
3929 self.apply_rope_head_layer(
3930 &mut k_row[h * head_dim..(h + 1) * head_dim],
3931 pos,
3932 l,
3933 );
3934 }
3935 });
3936
3937 let base_seq_len = cache.seq_len;
3938 for b in 0..batch_size {
3939 cache
3940 .push(
3941 &k_batch[b * kv_width..(b + 1) * kv_width],
3942 &v_batch[b * kv_width..(b + 1) * kv_width],
3943 )
3944 .expect("unbounded/planned KvCache growth is infallible");
3945 }
3946
3947 let cache_k = &cache.k;
3952 let cache_v = &cache.v;
3953 let softcap = self.config.attn_logit_softcap;
3954 let window = self.config.layer_sliding_window(l);
3955 let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
3956 let attn_out_batch = if let Some(oai) = oai {
3969 let mut out = vec![0f32; batch_size * q_width];
3970 out.par_chunks_mut(q_width)
3971 .enumerate()
3972 .for_each(|(b, dest)| {
3973 let seq_len_b = base_seq_len + b + 1;
3974 let cache_elems = seq_len_b * kv_width;
3975 let attn_out = ferrox_core::causal_gqa_attention_sinks(
3976 &q_batch[b * q_width..(b + 1) * q_width],
3977 &cache_k[..cache_elems],
3978 &cache_v[..cache_elems],
3979 n_heads,
3980 n_kv_heads,
3981 head_dim,
3982 seq_len_b,
3983 window,
3984 &oai.attn_sinks,
3985 );
3986 dest.copy_from_slice(&attn_out);
3987 });
3988 out
3989 } else {
3990 causal_gqa_attention_prefill_shared_kv_windowed(
3991 &q_batch,
3992 cache_k,
3993 cache_v,
3994 n_heads,
3995 n_kv_heads,
3996 head_dim,
3997 batch_size,
3998 base_seq_len,
3999 softcap,
4000 window,
4001 )
4002 };
4003
4004 let mut projected_batch = layer.attn.o_proj.apply_batch(&attn_out_batch, batch_size);
4005 if let Some(oai) = oai {
4006 for row in projected_batch.chunks_mut(hidden_dim) {
4007 for (x, b) in row.iter_mut().zip(oai.o_bias.iter()) {
4008 *x += b;
4009 }
4010 }
4011 }
4012 let projected_batch = if let Some(post) = &layer.attn.post_attn_norm {
4013 projected_batch
4014 .chunks(hidden_dim)
4015 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4016 .collect::<Vec<_>>()
4017 } else {
4018 projected_batch
4019 };
4020 for (h, p) in hidden_batch.iter_mut().zip(projected_batch.iter()) {
4021 *h += p;
4022 }
4023
4024 let normed2_batch: Vec<f32> = hidden_batch
4026 .par_chunks(hidden_dim)
4027 .map(|h| rms_norm(h, &layer.moe.norm_weight, self.config.rms_norm_eps))
4028 .flatten()
4029 .collect();
4030 if let Some(oai) = oai {
4031 for b in 0..batch_size {
4035 let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
4036 let ffn_out = Self::gpt_oss_ffn(layer, oai, normed2, &self.config, hidden_dim);
4037 let hidden_row = &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
4038 for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
4039 *h += f;
4040 }
4041 }
4042 l += 1;
4043 continue;
4044 }
4045 let dense = Self::is_dense_layer(layer);
4046 let router_logits_batch = if dense {
4052 Vec::new()
4053 } else {
4054 layer.moe.router.apply_batch(&normed2_batch, batch_size)
4055 };
4056 #[cfg(feature = "metal")]
4057 let metal_ffn = if !dense {
4058 Self::try_metal_moe_prefill_batch(
4059 layer,
4060 &normed2_batch,
4061 &router_logits_batch,
4062 batch_size,
4063 hidden_dim,
4064 &self.config,
4065 )
4066 } else {
4067 None
4068 };
4069 #[cfg(not(feature = "metal"))]
4070 let metal_ffn: Option<Vec<f32>> = None;
4071 if let Some(mut ffn_batch) = metal_ffn {
4072 if let Some(post) = &layer.attn.post_ffn_norm {
4073 ffn_batch = ffn_batch
4074 .chunks(hidden_dim)
4075 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4076 .collect();
4077 }
4078 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4079 *h += f;
4080 }
4081 } else if let Some(mut ffn_batch) =
4082 Self::dense_ffn_batch(layer, &normed2_batch, batch_size, &self.config)
4083 {
4084 if let Some(post) = &layer.attn.post_ffn_norm {
4090 ffn_batch = ffn_batch
4091 .chunks(hidden_dim)
4092 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4093 .collect();
4094 }
4095 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4096 *h += f;
4097 }
4098 } else if let Some(mut ffn_batch) = Self::moe_ffn_batch(
4099 layer,
4100 &normed2_batch,
4101 &router_logits_batch,
4102 batch_size,
4103 hidden_dim,
4104 &self.config,
4105 residency.as_ref().map(|p| p.layer_plan(l)),
4106 ) {
4107 if let Some(post) = &layer.attn.post_ffn_norm {
4108 ffn_batch = ffn_batch
4109 .chunks(hidden_dim)
4110 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4111 .collect();
4112 }
4113 for (h, f) in hidden_batch.iter_mut().zip(ffn_batch.iter()) {
4114 *h += f;
4115 }
4116 } else {
4117 let n_experts = layer.moe.n_experts().max(1);
4118 for b in 0..batch_size {
4119 let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
4120 let mut ffn_out = if dense {
4121 Self::run_ffn_block(
4122 layer,
4123 normed2,
4124 &self.config,
4125 hidden_dim,
4126 residency.as_ref().map(|p| p.layer_plan(l)),
4127 )
4128 } else {
4129 let router_logits =
4130 &router_logits_batch[b * n_experts..(b + 1) * n_experts];
4131 Self::combine_ffn_outputs_for_position(
4132 layer,
4133 normed2,
4134 router_logits,
4135 &self.config,
4136 hidden_dim,
4137 residency.as_ref().map(|p| p.layer_plan(l)),
4138 )
4139 };
4140 if let Some(post) = &layer.attn.post_ffn_norm {
4141 ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
4142 }
4143 let hidden_row = &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
4144 for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
4145 *h += f;
4146 }
4147 }
4148 }
4149 l += 1;
4150 }
4151
4152 hidden_batch
4153 .chunks(hidden_dim)
4154 .map(|h| rms_norm(h, &self.final_norm, self.config.rms_norm_eps))
4155 .collect()
4156 }
4157
4158 pub fn forward_multi_seq(
4180 &self,
4181 tokens: &[usize],
4182 positions: &[usize],
4183 kv_caches: &mut [Vec<KvCache>],
4184 ) -> Vec<Vec<f32>> {
4185 assert_eq!(tokens.len(), positions.len());
4186 assert_eq!(tokens.len(), kv_caches.len());
4187 let batch_size = tokens.len();
4188 if batch_size == 0 {
4189 return Vec::new();
4190 }
4191 for seq in kv_caches.iter() {
4192 assert_eq!(seq.len(), self.layers.len());
4193 }
4194
4195 let hidden_dim = self.config.hidden_dim;
4196 let head_dim = self.config.head_dim;
4197 let n_heads = self.config.n_heads;
4198 let n_kv_heads = self.config.n_kv_heads;
4199
4200 let mut hidden_batch: Vec<f32> = tokens
4202 .iter()
4203 .flat_map(|&t| self.embedding.dequant_row(t))
4204 .collect();
4205 if let Some(scale) = self.config.embedding_scale {
4206 for v in hidden_batch.iter_mut() {
4207 *v *= scale;
4208 }
4209 }
4210
4211 let residency = self.gpu_vram_budget_bytes.map(|b| self.residency_plan(b));
4212
4213 for (l, layer) in self.layers.iter().enumerate() {
4214 let normed_batch: Vec<f32> = hidden_batch
4216 .par_chunks(hidden_dim)
4217 .map(|h| rms_norm(h, &layer.attn.norm_weight, self.config.rms_norm_eps))
4218 .flatten()
4219 .collect();
4220
4221 let qkv_acts = layer
4226 .attn
4227 .q_proj
4228 .quantize_batch_acts(&normed_batch, batch_size);
4229 let mut q_batch = layer.attn.q_proj.apply_batch_with_acts(
4230 &normed_batch,
4231 batch_size,
4232 qkv_acts.as_ref(),
4233 );
4234 let mut k_batch = layer.attn.k_proj.apply_batch_with_acts(
4235 &normed_batch,
4236 batch_size,
4237 qkv_acts.as_ref(),
4238 );
4239 let mut v_batch = layer.attn.v_proj.apply_batch_with_acts(
4240 &normed_batch,
4241 batch_size,
4242 qkv_acts.as_ref(),
4243 );
4244 drop(qkv_acts);
4245
4246 let q_width = n_heads * head_dim;
4247 let kv_width = n_kv_heads * head_dim;
4248
4249 if let Some(bias) = &layer.attn.q_bias {
4250 for row in q_batch.chunks_mut(q_width) {
4251 for (x, b) in row.iter_mut().zip(bias.iter()) {
4252 *x += b;
4253 }
4254 }
4255 }
4256 if let Some(bias) = &layer.attn.k_bias {
4257 for row in k_batch.chunks_mut(kv_width) {
4258 for (x, b) in row.iter_mut().zip(bias.iter()) {
4259 *x += b;
4260 }
4261 }
4262 }
4263 if let Some(bias) = &layer.attn.v_bias {
4264 for row in v_batch.chunks_mut(kv_width) {
4265 for (x, b) in row.iter_mut().zip(bias.iter()) {
4266 *x += b;
4267 }
4268 }
4269 }
4270
4271 if let Some(q_norm) = &layer.attn.q_norm {
4272 for row in q_batch.chunks_mut(q_width) {
4273 let normed = self.apply_qk_norm(row, q_norm);
4274 row.copy_from_slice(&normed);
4275 }
4276 }
4277 if let Some(k_norm) = &layer.attn.k_norm {
4278 for row in k_batch.chunks_mut(kv_width) {
4279 let normed = self.apply_qk_norm(row, k_norm);
4280 row.copy_from_slice(&normed);
4281 }
4282 }
4283 self.apply_rope_attn_factor(&mut q_batch, &mut k_batch);
4284
4285 for b in 0..batch_size {
4286 let pos = positions[b];
4287 let q_row = &mut q_batch[b * q_width..(b + 1) * q_width];
4288 for h in 0..n_heads {
4289 self.apply_rope_head_layer(
4290 &mut q_row[h * head_dim..(h + 1) * head_dim],
4291 pos,
4292 l,
4293 );
4294 }
4295 let k_row = &mut k_batch[b * kv_width..(b + 1) * kv_width];
4296 for h in 0..n_kv_heads {
4297 self.apply_rope_head_layer(
4298 &mut k_row[h * head_dim..(h + 1) * head_dim],
4299 pos,
4300 l,
4301 );
4302 }
4303 }
4304
4305 let oai = self.gpt_oss.as_ref().map(|g| &g.layers[l]);
4306 let mut attn_out_batch = vec![0f32; batch_size * q_width];
4307 for b in 0..batch_size {
4308 let cache = &mut kv_caches[b][l];
4309 cache
4310 .push(
4311 &k_batch[b * kv_width..(b + 1) * kv_width],
4312 &v_batch[b * kv_width..(b + 1) * kv_width],
4313 )
4314 .expect("unbounded/planned KvCache growth is infallible");
4315 if let Some(oai) = oai {
4316 let attn_out = ferrox_core::causal_gqa_attention_sinks(
4317 &q_batch[b * q_width..(b + 1) * q_width],
4318 &cache.k,
4319 &cache.v,
4320 n_heads,
4321 n_kv_heads,
4322 head_dim,
4323 cache.seq_len,
4324 self.config.layer_sliding_window(l),
4325 &oai.attn_sinks,
4326 );
4327 attn_out_batch[b * q_width..(b + 1) * q_width].copy_from_slice(&attn_out);
4328 continue;
4329 }
4330 let attn_out = match self.config.layer_sliding_window(l) {
4331 Some(window) => causal_gqa_attention_windowed_softcap(
4332 &q_batch[b * q_width..(b + 1) * q_width],
4333 &cache.k,
4334 &cache.v,
4335 n_heads,
4336 n_kv_heads,
4337 head_dim,
4338 cache.seq_len,
4339 window,
4340 self.config.attn_logit_softcap,
4341 ),
4342 None => causal_gqa_attention_softcap(
4343 &q_batch[b * q_width..(b + 1) * q_width],
4344 &cache.k,
4345 &cache.v,
4346 n_heads,
4347 n_kv_heads,
4348 head_dim,
4349 cache.seq_len,
4350 self.config.attn_logit_softcap,
4351 ),
4352 };
4353 attn_out_batch[b * q_width..(b + 1) * q_width].copy_from_slice(&attn_out);
4354 }
4355
4356 let mut projected_batch = layer.attn.o_proj.apply_batch(&attn_out_batch, batch_size);
4357 if let Some(oai) = oai {
4358 for row in projected_batch.chunks_mut(hidden_dim) {
4359 for (x, b) in row.iter_mut().zip(oai.o_bias.iter()) {
4360 *x += b;
4361 }
4362 }
4363 }
4364 let projected_batch = if let Some(post) = &layer.attn.post_attn_norm {
4365 projected_batch
4366 .chunks(hidden_dim)
4367 .flat_map(|row| rms_norm(row, post, self.config.rms_norm_eps))
4368 .collect::<Vec<_>>()
4369 } else {
4370 projected_batch
4371 };
4372 for (h, p) in hidden_batch.iter_mut().zip(projected_batch.iter()) {
4373 *h += p;
4374 }
4375
4376 let normed2_batch: Vec<f32> = hidden_batch
4378 .par_chunks(hidden_dim)
4379 .map(|h| rms_norm(h, &layer.moe.norm_weight, self.config.rms_norm_eps))
4380 .flatten()
4381 .collect();
4382 let dense = Self::is_dense_layer(layer);
4383 let router_logits_batch = if dense || oai.is_some() {
4384 Vec::new()
4385 } else {
4386 layer.moe.router.apply_batch(&normed2_batch, batch_size)
4387 };
4388 let n_experts = layer.moe.n_experts().max(1);
4389
4390 for b in 0..batch_size {
4391 let normed2 = &normed2_batch[b * hidden_dim..(b + 1) * hidden_dim];
4392 let mut ffn_out = if let Some(oai) = oai {
4393 Self::gpt_oss_ffn(layer, oai, normed2, &self.config, hidden_dim)
4394 } else if dense {
4395 Self::run_ffn_block(
4396 layer,
4397 normed2,
4398 &self.config,
4399 hidden_dim,
4400 residency.as_ref().map(|p| p.layer_plan(l)),
4401 )
4402 } else {
4403 let router_logits = &router_logits_batch[b * n_experts..(b + 1) * n_experts];
4404 Self::combine_ffn_outputs_for_position(
4405 layer,
4406 normed2,
4407 router_logits,
4408 &self.config,
4409 hidden_dim,
4410 residency.as_ref().map(|p| p.layer_plan(l)),
4411 )
4412 };
4413 if let Some(post) = &layer.attn.post_ffn_norm {
4414 ffn_out = rms_norm(&ffn_out, post, self.config.rms_norm_eps);
4415 }
4416 let hidden_row = &mut hidden_batch[b * hidden_dim..(b + 1) * hidden_dim];
4417 for (h, f) in hidden_row.iter_mut().zip(ffn_out.iter()) {
4418 *h += f;
4419 }
4420 }
4421 }
4422
4423 let vocab_size = self.output_head.rows();
4424 let final_normed_batch: Vec<f32> = hidden_batch
4425 .par_chunks(hidden_dim)
4426 .map(|h| rms_norm(h, &self.final_norm, self.config.rms_norm_eps))
4427 .flatten()
4428 .collect();
4429 let mut logits_batch = self
4430 .output_head
4431 .apply_batch(&final_normed_batch, batch_size);
4432 if let Some(sc) = self.config.final_logit_softcap {
4433 softcap_inplace(&mut logits_batch, sc);
4434 }
4435
4436 logits_batch
4437 .chunks(vocab_size)
4438 .map(|c| c.to_vec())
4439 .collect()
4440 }
4441}
4442
4443#[cfg(test)]
4444mod partial_rotary_tests {
4445 use super::*;
4446
4447 #[test]
4452 fn partial_rotary_leaves_the_tail_untouched() {
4453 let mut cfg = crate::config::test_dense_fixture();
4454 cfg.head_dim = 8;
4455 cfg.rope_layout = crate::config::RopeLayout::Neox;
4456 cfg.rope_freqs = None;
4457 cfg.rope_dim = Some(4);
4458 let decoder = Decoder::new_random_small(cfg, 1, 32);
4459
4460 let mut head: Vec<f32> = (0..8).map(|i| 1.0 + i as f32).collect();
4461 let before = head.clone();
4462 decoder.apply_rope_head_theta(&mut head, 3, 10000.0);
4463
4464 assert_eq!(
4465 &head[4..],
4466 &before[4..],
4467 "dims at or past rope_dim must not rotate"
4468 );
4469 assert!(
4470 head[..4] != before[..4],
4471 "dims below rope_dim must rotate at a non-zero position"
4472 );
4473 }
4474
4475 #[test]
4482 fn attn_factor_scales_only_the_rotated_channels() {
4483 let mut cfg = crate::config::test_dense_fixture();
4484 cfg.head_dim = 8;
4485 cfg.n_heads = 2;
4486 cfg.n_kv_heads = 2;
4487 cfg.rope_dim = Some(4);
4488 cfg.rope_attn_factor = 2.0;
4489 let decoder = Decoder::new_random_small(cfg, 1, 32);
4490
4491 let mut q: Vec<f32> = (0..16).map(|i| 1.0 + i as f32).collect();
4494 let mut k: Vec<f32> = (0..16).map(|i| 1.0 + i as f32).collect();
4495 let before = q.clone();
4496 decoder.apply_rope_attn_factor(&mut q, &mut k);
4497
4498 for h in 0..2 {
4499 let base = h * 8;
4500 for i in 0..4 {
4501 assert_eq!(
4502 q[base + i],
4503 before[base + i] * 2.0,
4504 "rotated channel {i} of head {h} must be scaled"
4505 );
4506 }
4507 for i in 4..8 {
4508 assert_eq!(
4509 q[base + i],
4510 before[base + i],
4511 "pass-through channel {i} of head {h} must be untouched"
4512 );
4513 }
4514 }
4515 assert_eq!(q, k, "q and k take the same magnitude scale");
4516 }
4517
4518 #[test]
4521 fn attn_factor_scales_the_whole_head_without_partial_rotary() {
4522 let mut cfg = crate::config::test_dense_fixture();
4523 cfg.head_dim = 8;
4524 cfg.n_heads = 1;
4525 cfg.n_kv_heads = 1;
4526 cfg.rope_dim = None;
4527 cfg.rope_attn_factor = 3.0;
4528 let decoder = Decoder::new_random_small(cfg, 1, 32);
4529
4530 let mut q: Vec<f32> = (0..8).map(|i| 1.0 + i as f32).collect();
4531 let mut k = q.clone();
4532 let before = q.clone();
4533 decoder.apply_rope_attn_factor(&mut q, &mut k);
4534 for i in 0..8 {
4535 assert_eq!(q[i], before[i] * 3.0);
4536 }
4537 }
4538
4539 #[test]
4542 fn full_rotary_still_rotates_the_whole_head() {
4543 let mut cfg = crate::config::test_dense_fixture();
4544 cfg.head_dim = 8;
4545 cfg.rope_layout = crate::config::RopeLayout::Neox;
4546 cfg.rope_freqs = None;
4547 cfg.rope_dim = None;
4548 let decoder = Decoder::new_random_small(cfg, 1, 32);
4549
4550 let mut head: Vec<f32> = (0..8).map(|i| 1.0 + i as f32).collect();
4551 let before = head.clone();
4552 decoder.apply_rope_head_theta(&mut head, 3, 10000.0);
4553 assert!(head[4..] != before[4..]);
4554 }
4555
4556 #[test]
4559 fn rope_attn_factor_scales_q_and_k_only() {
4560 let mut cfg = crate::config::test_dense_fixture();
4561 cfg.rope_attn_factor = 2.0;
4562 let decoder = Decoder::new_random_small(cfg, 1, 32);
4563 let mut q = vec![1.0f32, -2.0, 3.0];
4564 let mut k = vec![0.5f32, 4.0];
4565 decoder.apply_rope_attn_factor(&mut q, &mut k);
4566 assert_eq!(q, vec![2.0, -4.0, 6.0]);
4567 assert_eq!(k, vec![1.0, 8.0]);
4568
4569 let mut cfg = crate::config::test_dense_fixture();
4570 cfg.rope_attn_factor = 1.0;
4571 let decoder = Decoder::new_random_small(cfg, 1, 32);
4572 let mut q = vec![1.0f32, -2.0];
4573 let mut k = vec![3.0f32];
4574 decoder.apply_rope_attn_factor(&mut q, &mut k);
4575 assert_eq!(q, vec![1.0, -2.0]);
4576 assert_eq!(k, vec![3.0]);
4577 }
4578}
4579
4580#[cfg(test)]
4581mod tests {
4582 use super::*;
4583 use crate::config::glm_5_2;
4584
4585 fn tiny_test_config() -> ModelConfig {
4589 let mut cfg = glm_5_2();
4590 cfg.hidden_dim = 16;
4591 cfg.n_heads = 4;
4592 cfg.n_kv_heads = 2;
4593 cfg.head_dim = 4;
4594 cfg.moe.hidden_dim = 16;
4595 cfg.moe.n_experts = 6;
4596 cfg.moe.n_experts_active = 2;
4597 cfg.moe.n_shared_experts = 1;
4598 cfg.moe.expert_ffn_dim = 8;
4599 cfg
4600 }
4601
4602 #[test]
4603 fn forward_pass_produces_finite_logits_of_correct_shape() {
4604 let vocab = 10;
4605 let decoder = Decoder::new_random_small(tiny_test_config(), 2, vocab);
4606 let mut caches: Vec<KvCache> = (0..2)
4607 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4608 .collect();
4609
4610 let logits = decoder.forward_token(3, 0, &mut caches);
4611 assert_eq!(logits.len(), vocab);
4612 assert!(
4613 logits.iter().all(|v| v.is_finite()),
4614 "logits must not contain NaN/Inf"
4615 );
4616 }
4617
4618 #[test]
4629 fn gpu_vram_budget_bytes_with_nothing_placed_matches_the_default() {
4630 let mut decoder = Decoder::new_random_small(tiny_test_config(), 2, 10);
4631 let mut caches_default: Vec<KvCache> = (0..2)
4632 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4633 .collect();
4634 let default_logits = decoder.forward_token(3, 0, &mut caches_default);
4635
4636 decoder.gpu_vram_budget_bytes = Some(0);
4637 let mut caches_zero_budget: Vec<KvCache> = (0..2)
4638 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4639 .collect();
4640 let zero_budget_logits = decoder.forward_token(3, 0, &mut caches_zero_budget);
4641
4642 assert_eq!(
4643 default_logits, zero_budget_logits,
4644 "a placement plan that places nothing on GPU must match the None default exactly"
4645 );
4646 }
4647
4648 #[test]
4659 fn shared_expert_gate_scales_shared_output_by_sigmoid_of_the_gate_logit() {
4660 let cfg = tiny_test_config();
4661 let mut decoder = Decoder::new_random_small(cfg, 2, 8);
4662 let hidden_dim = decoder.config.hidden_dim;
4663 assert_eq!(
4664 decoder.layers[1].moe.shared_experts.len(),
4665 1,
4666 "test assumes tiny_test_config's real MoE layer has exactly one shared expert"
4667 );
4668
4669 let normed2: Vec<f32> = (0..hidden_dim).map(|i| (i as f32 * 0.37).sin()).collect();
4670 let gate_vec: Vec<f32> = (0..hidden_dim).map(|i| i as f32 * 0.13 - 0.5).collect();
4671
4672 let shared_out_raw = run_expert(&normed2, &decoder.layers[1].moe.shared_experts[0]);
4676 let gate_logit: f32 = gate_vec
4677 .iter()
4678 .zip(normed2.iter())
4679 .map(|(g, x)| g * x)
4680 .sum();
4681 let gate_value = 1.0 / (1.0 + (-gate_logit).exp());
4682 let expected_gated_shared: Vec<f32> =
4683 shared_out_raw.iter().map(|x| x * gate_value).collect();
4684
4685 let router_logits = decoder.layers[1].moe.router.apply(&normed2);
4691 let ungated_total = Decoder::combine_ffn_outputs_for_position(
4692 &decoder.layers[1],
4693 &normed2,
4694 &router_logits,
4695 &decoder.config,
4696 hidden_dim,
4697 None,
4698 );
4699 decoder.layers[1].moe.shared_expert_gate = Some(gate_vec);
4700 let gated_total = Decoder::combine_ffn_outputs_for_position(
4701 &decoder.layers[1],
4702 &normed2,
4703 &router_logits,
4704 &decoder.config,
4705 hidden_dim,
4706 None,
4707 );
4708
4709 for (i, ((u, g), expected_shared)) in ungated_total
4710 .iter()
4711 .zip(gated_total.iter())
4712 .zip(expected_gated_shared.iter())
4713 .enumerate()
4714 {
4715 let routed_contribution = u - shared_out_raw[i];
4716 let gated_shared_recovered = g - routed_contribution;
4717 assert!(
4718 (gated_shared_recovered - expected_shared).abs() < 1e-4,
4719 "index {i}: recovered gated shared output {gated_shared_recovered} != expected {expected_shared} (sigmoid({gate_logit})={gate_value})"
4720 );
4721 }
4722 }
4723
4724 #[test]
4725 fn kv_cache_grows_by_one_position_per_layer_per_step() {
4726 let decoder = Decoder::new_random_small(tiny_test_config(), 3, 5);
4727 let mut caches: Vec<KvCache> = (0..3)
4728 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4729 .collect();
4730
4731 decoder.forward_token(0, 0, &mut caches);
4732 decoder.forward_token(1, 1, &mut caches);
4733 decoder.forward_token(2, 2, &mut caches);
4734
4735 for cache in &caches {
4736 assert_eq!(cache.seq_len, 3);
4737 }
4738 }
4739
4740 #[test]
4741 fn same_token_same_position_is_deterministic() {
4742 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
4743 let mut caches_a: Vec<KvCache> = (0..2)
4744 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4745 .collect();
4746 let mut caches_b: Vec<KvCache> = (0..2)
4747 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4748 .collect();
4749
4750 let out_a = decoder.forward_token(4, 0, &mut caches_a);
4751 let out_b = decoder.forward_token(4, 0, &mut caches_b);
4752 assert_eq!(out_a, out_b, "identical input state must yield identical output (no hidden randomness in the forward pass)");
4753 }
4754
4755 #[test]
4756 fn multi_step_decode_stays_finite_across_positions() {
4757 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
4758 let mut caches: Vec<KvCache> = (0..2)
4759 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4760 .collect();
4761
4762 for pos in 0..16 {
4763 let logits = decoder.forward_token(pos % 8, pos, &mut caches);
4764 assert!(
4765 logits.iter().all(|v| v.is_finite()),
4766 "position {pos}: logits must stay finite across an extended decode run"
4767 );
4768 }
4769 }
4770
4771 #[test]
4777 fn forward_token_paged_matches_forward_token_bit_identical() {
4778 let n_layers = 2;
4779 let decoder = Decoder::new_random_small(tiny_test_config(), n_layers, 10);
4780
4781 let mut caches: Vec<KvCache> = (0..n_layers)
4782 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
4783 .collect();
4784 let mut plain_logits = Vec::new();
4785 for (pos, &tok) in [3usize, 5, 7].iter().enumerate() {
4786 plain_logits.push(decoder.forward_token(tok, pos, &mut caches));
4787 }
4788
4789 let block_size = 2;
4790 let mut paged_caches: Vec<PagedKvCache> =
4791 (0..n_layers).map(|_| PagedKvCache::new()).collect();
4792 let mut stores: Vec<PagedKvStore> = (0..n_layers)
4793 .map(|_| {
4794 PagedKvStore::new(
4795 block_size,
4796 16,
4797 decoder.config.n_kv_heads,
4798 decoder.config.head_dim,
4799 )
4800 })
4801 .collect();
4802 let mut paged_logits = Vec::new();
4803 for (pos, &tok) in [3usize, 5, 7].iter().enumerate() {
4804 paged_logits.push(
4805 decoder
4806 .forward_token_paged(tok, pos, &mut paged_caches, &mut stores)
4807 .expect("store sized generously, must not exhaust"),
4808 );
4809 }
4810
4811 assert_eq!(plain_logits.len(), paged_logits.len());
4812 for (a, b) in plain_logits.iter().zip(paged_logits.iter()) {
4813 assert_eq!(a.len(), b.len());
4814 for (x, y) in a.iter().zip(b.iter()) {
4815 assert_eq!(
4816 x.to_bits(),
4817 y.to_bits(),
4818 "paged decode must be bit-identical to contiguous decode"
4819 );
4820 }
4821 }
4822 }
4823
4824 #[test]
4833 fn forward_batch_matches_sequential_forward_token_exactly() {
4834 let cfg = tiny_test_config();
4835 let vocab = 8;
4836 let tokens = [1usize, 3, 5, 2, 7];
4837
4838 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
4839 let mut caches_a: Vec<KvCache> = (0..2)
4840 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
4841 .collect();
4842 let sequential: Vec<Vec<f32>> = tokens
4843 .iter()
4844 .enumerate()
4845 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
4846 .collect();
4847
4848 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
4853 let mut caches_b: Vec<KvCache> = (0..2)
4854 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
4855 .collect();
4856 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
4857
4858 assert_eq!(batched.len(), sequential.len());
4859 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
4860 assert_eq!(seq_logits.len(), batch_logits.len());
4861 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
4862 assert!(
4863 (s - b).abs() < 1e-3,
4864 "position {pos}, logit {i}: sequential={s} batched={b}"
4865 );
4866 }
4867 }
4868 }
4869
4870 #[test]
4877 fn forward_batch_last_matches_the_final_row_of_forward_batch() {
4878 let cfg = tiny_test_config();
4879 let vocab = 16;
4880 let tokens = vec![1usize, 4, 7, 2, 9];
4881
4882 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
4883 let mut caches_a: Vec<KvCache> = (0..2)
4884 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
4885 .collect();
4886 let all_rows = decoder_a.forward_batch(&tokens, 0, &mut caches_a);
4887
4888 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
4889 let mut caches_b: Vec<KvCache> = (0..2)
4890 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
4891 .collect();
4892 let last = decoder_b.forward_batch_last(&tokens, 0, &mut caches_b);
4893
4894 let expected = all_rows.last().expect("one row per prompt token");
4895 assert_eq!(last.len(), expected.len());
4896 for (i, (a, b)) in expected.iter().zip(last.iter()).enumerate() {
4897 assert!(
4898 (a - b).abs() < 1e-4,
4899 "logit {i}: forward_batch={a} forward_batch_last={b}"
4900 );
4901 }
4902
4903 let next_a = decoder_a.forward_token(3, tokens.len(), &mut caches_a);
4905 let next_b = decoder_b.forward_token(3, tokens.len(), &mut caches_b);
4906 for (i, (a, b)) in next_a.iter().zip(next_b.iter()).enumerate() {
4907 assert!(
4908 (a - b).abs() < 1e-4,
4909 "post-prefill decode logit {i}: {a} vs {b}"
4910 );
4911 }
4912
4913 let mut caches_c: Vec<KvCache> = (0..2)
4915 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
4916 .collect();
4917 assert!(decoder_b
4918 .forward_batch_last(&[], 0, &mut caches_c)
4919 .is_empty());
4920 }
4921
4922 #[test]
4931 fn forward_multi_seq_matches_independent_forward_token_per_sequence() {
4932 let cfg = tiny_test_config();
4933 let vocab = 8;
4934 let seq_histories: [&[usize]; 3] = [&[1, 3, 5], &[2, 7], &[4, 4, 4, 6]];
4938
4939 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
4940 let mut independent_logits: Vec<Vec<f32>> = Vec::new();
4941 for history in seq_histories.iter() {
4942 let mut caches: Vec<KvCache> = (0..2)
4943 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
4944 .collect();
4945 let mut logits = Vec::new();
4946 for (pos, &tok) in history.iter().enumerate() {
4947 logits = decoder_a.forward_token(tok, pos, &mut caches);
4948 }
4949 independent_logits.push(logits);
4950 }
4951
4952 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
4955 let mut per_seq_caches: Vec<Vec<KvCache>> = seq_histories
4956 .iter()
4957 .map(|_| {
4958 (0..2)
4959 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
4960 .collect()
4961 })
4962 .collect();
4963
4964 let max_len = seq_histories.iter().map(|h| h.len()).max().unwrap();
4972 let mut batched_logits: Vec<Vec<f32>> = vec![Vec::new(); seq_histories.len()];
4973 for step in 0..max_len {
4974 let mut tokens = Vec::new();
4975 let mut positions = Vec::new();
4976 let mut active: Vec<usize> = Vec::new();
4977 for (s, history) in seq_histories.iter().enumerate() {
4978 if step < history.len() {
4979 tokens.push(history[step]);
4980 positions.push(step);
4981 active.push(s);
4982 }
4983 }
4984 if tokens.is_empty() {
4985 continue;
4986 }
4987 let mut active_caches: Vec<Vec<KvCache>> = active
4988 .iter()
4989 .map(|&s| std::mem::take(&mut per_seq_caches[s]))
4990 .collect();
4991 let step_logits = decoder_b.forward_multi_seq(&tokens, &positions, &mut active_caches);
4992 for ((&s, caches), logits) in active.iter().zip(active_caches).zip(step_logits) {
4993 per_seq_caches[s] = caches;
4994 batched_logits[s] = logits;
4995 }
4996 }
4997
4998 assert_eq!(batched_logits.len(), independent_logits.len());
4999 for (s, (seq_logits, batch_logits)) in independent_logits
5000 .iter()
5001 .zip(batched_logits.iter())
5002 .enumerate()
5003 {
5004 assert_eq!(seq_logits.len(), batch_logits.len());
5005 for (i, (a, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
5006 assert!(
5007 (a - b).abs() < 1e-3,
5008 "sequence {s}, logit {i}: independent={a} batched={b}"
5009 );
5010 }
5011 }
5012 }
5013
5014 #[test]
5023 fn forward_batch_matches_forward_token_with_qk_norm_present() {
5024 let cfg = tiny_test_config();
5025 let vocab = 8;
5026 let tokens = [1usize, 3, 5, 2, 7];
5027 let q_width = cfg.n_heads * cfg.head_dim;
5028 let kv_width = cfg.n_kv_heads * cfg.head_dim;
5029
5030 let mut decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
5031 for layer in &mut decoder_a.layers {
5032 layer.attn.q_norm = Some((0..q_width).map(|i| 1.0 + i as f32 * 0.1).collect());
5033 layer.attn.k_norm = Some((0..kv_width).map(|i| 0.5 + i as f32 * 0.05).collect());
5034 }
5035 let mut caches_a: Vec<KvCache> = (0..2)
5036 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5037 .collect();
5038 let sequential: Vec<Vec<f32>> = tokens
5039 .iter()
5040 .enumerate()
5041 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
5042 .collect();
5043
5044 let mut decoder_b = Decoder::new_random_small(cfg, 2, vocab);
5045 for layer in &mut decoder_b.layers {
5046 layer.attn.q_norm = Some((0..q_width).map(|i| 1.0 + i as f32 * 0.1).collect());
5047 layer.attn.k_norm = Some((0..kv_width).map(|i| 0.5 + i as f32 * 0.05).collect());
5048 }
5049 let mut caches_b: Vec<KvCache> = (0..2)
5050 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5051 .collect();
5052 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
5053
5054 assert_eq!(batched.len(), sequential.len());
5055 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
5056 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
5057 assert!(
5058 (s - b).abs() < 1e-3,
5059 "position {pos}, logit {i}: sequential={s} batched={b}"
5060 );
5061 }
5062 }
5063 }
5064
5065 #[test]
5075 fn qk_norm_present_changes_output_versus_absent() {
5076 let cfg = tiny_test_config();
5077 let vocab = 8;
5078 let q_width = cfg.n_heads * cfg.head_dim;
5079 let kv_width = cfg.n_kv_heads * cfg.head_dim;
5080 let tokens = [3usize, 5];
5081
5082 let without_norm = Decoder::new_random_small(cfg.clone(), 1, vocab);
5083 let mut with_norm = Decoder::new_random_small(cfg, 1, vocab);
5084 for layer in &mut with_norm.layers {
5085 layer.attn.q_norm = Some(vec![2.0; q_width]);
5086 layer.attn.k_norm = Some(vec![2.0; kv_width]);
5087 }
5088
5089 let mut caches_a: Vec<KvCache> = (0..1)
5090 .map(|_| KvCache::new(without_norm.config.n_kv_heads, without_norm.config.head_dim))
5091 .collect();
5092 let mut caches_b: Vec<KvCache> = (0..1)
5093 .map(|_| KvCache::new(with_norm.config.n_kv_heads, with_norm.config.head_dim))
5094 .collect();
5095
5096 let mut out_a = Vec::new();
5097 let mut out_b = Vec::new();
5098 for (pos, &t) in tokens.iter().enumerate() {
5099 out_a = without_norm.forward_token(t, pos, &mut caches_a);
5100 out_b = with_norm.forward_token(t, pos, &mut caches_b);
5101 }
5102
5103 let differs = out_a
5104 .iter()
5105 .zip(out_b.iter())
5106 .any(|(a, b)| (a - b).abs() > 1e-4);
5107 assert!(
5108 differs,
5109 "QK-norm weights changed nothing -- forward_token likely isn't applying q_norm/k_norm"
5110 );
5111 }
5112
5113 #[test]
5129 fn forward_batch_matches_forward_token_with_qkv_bias_present() {
5130 let cfg = tiny_test_config();
5131 let vocab = 8;
5132 let tokens = [1usize, 3, 5, 2, 7];
5133 let q_width = cfg.n_heads * cfg.head_dim;
5134 let kv_width = cfg.n_kv_heads * cfg.head_dim;
5135
5136 let mut decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
5137 for layer in &mut decoder_a.layers {
5138 layer.attn.q_bias = Some((0..q_width).map(|i| 0.3 + i as f32 * 0.02).collect());
5139 layer.attn.k_bias = Some((0..kv_width).map(|i| -0.2 + i as f32 * 0.03).collect());
5140 layer.attn.v_bias = Some((0..kv_width).map(|i| 0.1 - i as f32 * 0.01).collect());
5141 }
5142 let mut caches_a: Vec<KvCache> = (0..2)
5143 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5144 .collect();
5145 let sequential: Vec<Vec<f32>> = tokens
5146 .iter()
5147 .enumerate()
5148 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
5149 .collect();
5150
5151 let mut decoder_b = Decoder::new_random_small(cfg, 2, vocab);
5152 for layer in &mut decoder_b.layers {
5153 layer.attn.q_bias = Some((0..q_width).map(|i| 0.3 + i as f32 * 0.02).collect());
5154 layer.attn.k_bias = Some((0..kv_width).map(|i| -0.2 + i as f32 * 0.03).collect());
5155 layer.attn.v_bias = Some((0..kv_width).map(|i| 0.1 - i as f32 * 0.01).collect());
5156 }
5157 let mut caches_b: Vec<KvCache> = (0..2)
5158 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5159 .collect();
5160 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
5161
5162 assert_eq!(batched.len(), sequential.len());
5163 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
5164 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
5165 assert!(
5166 (s - b).abs() < 1e-3,
5167 "position {pos}, logit {i}: sequential={s} batched={b}"
5168 );
5169 }
5170 }
5171 }
5172
5173 #[test]
5174 fn qkv_bias_present_changes_output_versus_absent() {
5175 let cfg = tiny_test_config();
5176 let vocab = 8;
5177 let q_width = cfg.n_heads * cfg.head_dim;
5178 let kv_width = cfg.n_kv_heads * cfg.head_dim;
5179 let tokens = [3usize, 5];
5180
5181 let without_bias = Decoder::new_random_small(cfg.clone(), 1, vocab);
5182 let mut with_bias = Decoder::new_random_small(cfg, 1, vocab);
5183 for layer in &mut with_bias.layers {
5184 layer.attn.q_bias = Some(vec![0.5; q_width]);
5185 layer.attn.k_bias = Some(vec![0.5; kv_width]);
5186 layer.attn.v_bias = Some(vec![0.5; kv_width]);
5187 }
5188
5189 let mut caches_a: Vec<KvCache> = (0..1)
5190 .map(|_| KvCache::new(without_bias.config.n_kv_heads, without_bias.config.head_dim))
5191 .collect();
5192 let mut caches_b: Vec<KvCache> = (0..1)
5193 .map(|_| KvCache::new(with_bias.config.n_kv_heads, with_bias.config.head_dim))
5194 .collect();
5195
5196 let mut out_a = Vec::new();
5197 let mut out_b = Vec::new();
5198 for (pos, &t) in tokens.iter().enumerate() {
5199 out_a = without_bias.forward_token(t, pos, &mut caches_a);
5200 out_b = with_bias.forward_token(t, pos, &mut caches_b);
5201 }
5202
5203 let differs = out_a
5204 .iter()
5205 .zip(out_b.iter())
5206 .any(|(a, b)| (a - b).abs() > 1e-4);
5207 assert!(
5208 differs,
5209 "QKV bias changed nothing -- forward_token likely isn't applying q_bias/k_bias/v_bias"
5210 );
5211 }
5212
5213 #[test]
5214 fn forward_batch_and_forward_token_leave_kv_caches_in_the_same_state() {
5215 let cfg = tiny_test_config();
5216 let tokens = [2usize, 4, 6];
5217
5218 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, 8);
5219 let mut caches_a: Vec<KvCache> = (0..2)
5220 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5221 .collect();
5222 for (pos, &t) in tokens.iter().enumerate() {
5223 decoder_a.forward_token(t, pos, &mut caches_a);
5224 }
5225
5226 let decoder_b = Decoder::new_random_small(cfg, 2, 8);
5227 let mut caches_b: Vec<KvCache> = (0..2)
5228 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5229 .collect();
5230 decoder_b.forward_batch(&tokens, 0, &mut caches_b);
5231
5232 for (ca, cb) in caches_a.iter().zip(caches_b.iter()) {
5233 assert_eq!(ca.seq_len, cb.seq_len);
5234 assert_eq!(ca.k.len(), cb.k.len());
5235 for (a, b) in ca.k.iter().zip(cb.k.iter()) {
5236 assert!((a - b).abs() < 1e-4);
5237 }
5238 }
5239 }
5240
5241 fn tiny_dense_test_config() -> ModelConfig {
5246 let mut cfg = tiny_test_config();
5247 cfg.moe.n_experts = 1;
5248 cfg.moe.n_experts_active = 1;
5249 cfg.moe.n_shared_experts = 0;
5250 cfg
5251 }
5252
5253 #[test]
5254 fn dense_layer_forward_pass_produces_finite_logits_of_correct_shape() {
5255 let vocab = 10;
5256 let decoder = Decoder::new_random_small(tiny_dense_test_config(), 2, vocab);
5257 let mut caches: Vec<KvCache> = (0..2)
5258 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5259 .collect();
5260
5261 let logits = decoder.forward_token(3, 0, &mut caches);
5262 assert_eq!(logits.len(), vocab);
5263 assert!(
5264 logits.iter().all(|v| v.is_finite()),
5265 "logits must not contain NaN/Inf"
5266 );
5267 }
5268
5269 #[test]
5270 fn dense_layer_forward_batch_matches_sequential_forward_token_exactly() {
5271 let cfg = tiny_dense_test_config();
5272 let vocab = 8;
5273 let tokens = [1usize, 3, 5, 2, 7];
5274
5275 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
5276 let mut caches_a: Vec<KvCache> = (0..2)
5277 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5278 .collect();
5279 let sequential: Vec<Vec<f32>> = tokens
5280 .iter()
5281 .enumerate()
5282 .map(|(pos, &t)| decoder_a.forward_token(t, pos, &mut caches_a))
5283 .collect();
5284
5285 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
5286 let mut caches_b: Vec<KvCache> = (0..2)
5287 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5288 .collect();
5289 let batched = decoder_b.forward_batch(&tokens, 0, &mut caches_b);
5290
5291 assert_eq!(batched.len(), sequential.len());
5292 for (pos, (seq_logits, batch_logits)) in sequential.iter().zip(batched.iter()).enumerate() {
5293 for (i, (s, b)) in seq_logits.iter().zip(batch_logits.iter()).enumerate() {
5294 assert!(
5295 (s - b).abs() < 1e-3,
5296 "position {pos}, logit {i}: sequential={s} batched={b}"
5297 );
5298 }
5299 }
5300 }
5301
5302 #[test]
5303 fn dense_layer_fast_path_still_records_expert_zero_activations() {
5304 let decoder = Decoder::new_random_small(tiny_dense_test_config(), 1, 8);
5310 let mut caches: Vec<KvCache> = (0..1)
5311 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5312 .collect();
5313
5314 decoder.forward_token(0, 0, &mut caches);
5315 decoder.forward_token(1, 1, &mut caches);
5316 decoder.forward_token(2, 2, &mut caches);
5317
5318 let count =
5319 decoder.layers[0].moe.activation_counts[0].load(std::sync::atomic::Ordering::Relaxed);
5320 assert_eq!(count, 3);
5321 }
5322
5323 #[test]
5324 fn forward_batch_with_empty_tokens_returns_empty() {
5325 let decoder = Decoder::new_random_small(tiny_test_config(), 2, 8);
5326 let mut caches: Vec<KvCache> = (0..2)
5327 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5328 .collect();
5329 let out = decoder.forward_batch(&[], 0, &mut caches);
5330 assert!(out.is_empty());
5331 }
5332
5333 #[test]
5334 fn forward_batch_continues_correctly_after_prior_forward_token_calls() {
5335 let cfg = tiny_test_config();
5341
5342 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, 8);
5343 let mut caches_a: Vec<KvCache> = (0..2)
5344 .map(|_| KvCache::new(decoder_a.config.n_kv_heads, decoder_a.config.head_dim))
5345 .collect();
5346 decoder_a.forward_token(1, 0, &mut caches_a);
5347 decoder_a.forward_token(3, 1, &mut caches_a);
5348 let seq_next = decoder_a.forward_token(5, 2, &mut caches_a);
5349
5350 let decoder_b = Decoder::new_random_small(cfg, 2, 8);
5351 let mut caches_b: Vec<KvCache> = (0..2)
5352 .map(|_| KvCache::new(decoder_b.config.n_kv_heads, decoder_b.config.head_dim))
5353 .collect();
5354 decoder_b.forward_token(1, 0, &mut caches_b);
5355 let batch_next = decoder_b.forward_batch(&[3, 5], 1, &mut caches_b);
5356
5357 for (s, b) in seq_next.iter().zip(batch_next[1].iter()) {
5358 assert!((s - b).abs() < 1e-3, "sequential={s} batched={b}");
5359 }
5360 }
5361
5362 #[test]
5371 fn placement_plan_reflects_real_observed_expert_activations() {
5372 let cfg = tiny_test_config(); let decoder = Decoder::new_random_small(cfg, 2, 16);
5374 let mut caches: Vec<KvCache> = (0..2)
5375 .map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
5376 .collect();
5377
5378 let n_calls = 20;
5379 for pos in 0..n_calls {
5380 decoder.forward_token(pos % 16, pos, &mut caches);
5381 }
5382
5383 let layer0 = &decoder.layers[0].moe;
5384 let counts: Vec<u64> = layer0
5385 .activation_counts
5386 .iter()
5387 .map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
5388 .collect();
5389 let total: u64 = counts.iter().sum();
5390 assert_eq!(
5391 total,
5392 (n_calls as u64) * (decoder.config.moe.n_experts_active as u64),
5393 "total recorded activations must equal calls * experts_active_per_call"
5394 );
5395
5396 let hottest_count = *counts.iter().max().unwrap();
5402 let hottest_idx = counts.iter().position(|&c| c == hottest_count).unwrap();
5403 assert!(hottest_count > 0);
5404
5405 let per_expert_bytes = layer0.expert_bytes(0);
5407 let plan = layer0.placement_plan(per_expert_bytes as u64);
5408
5409 assert_eq!(
5410 plan.placement_for(hottest_idx),
5411 ferrox_moe::ExpertPlacement::GpuDevice(0),
5412 "the genuinely hottest expert (index {hottest_idx}, {hottest_count} activations) \
5413 must be the one the plan places on GPU when only one expert fits the budget"
5414 );
5415 }
5416}