1use ferrox_core::matmul::swiglu;
12use ferrox_core::weight_matrix::WeightMatrix;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ExpertPlacement {
21 Cpu,
22 GpuDevice(u32),
23}
24
25#[derive(Debug, Clone)]
28pub struct MoeLayerConfig {
29 pub n_experts: usize,
30 pub n_experts_active: usize,
31 pub n_shared_experts: usize,
32 pub hidden_dim: usize,
33 pub expert_ffn_dim: usize,
34 pub gating: GatingFunction,
39 pub norm_topk_prob: bool,
63 pub expert_group_count: Option<usize>,
68 pub expert_group_used_count: Option<usize>,
69}
70
71#[derive(Debug, Clone)]
74pub struct RoutingDecision {
75 pub expert_ids: Vec<usize>,
76 pub weights: Vec<f32>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum GatingFunction {
102 Softmax,
104 Sigmoid,
108 SqrtSoftplus,
121}
122
123fn sigmoid(x: f32) -> f32 {
124 1.0 / (1.0 + (-x).exp())
125}
126
127fn sqrt_softplus(x: f32) -> f32 {
132 let softplus = x.max(0.0) + (-x.abs()).exp().ln_1p();
133 softplus.sqrt()
134}
135
136pub fn route_top_k(
141 logits: &[f32],
142 k: usize,
143 gating: GatingFunction,
144 norm_topk_prob: bool,
145) -> RoutingDecision {
146 match gating {
147 GatingFunction::Softmax => route_top_k_softmax(logits, k, norm_topk_prob),
148 GatingFunction::Sigmoid => route_top_k_sigmoid(logits, k),
149 GatingFunction::SqrtSoftplus => route_top_k_sqrtsoftplus(logits, k, norm_topk_prob),
150 }
151}
152
153pub fn route_top_k_grouped(
162 logits: &[f32],
163 n_groups: usize,
164 k_per_group: usize,
165 total_k: usize,
166 gating: GatingFunction,
167 norm_topk_prob: bool,
168) -> RoutingDecision {
169 if n_groups <= 1 || !logits.len().is_multiple_of(n_groups) {
170 return route_top_k(logits, total_k, gating, norm_topk_prob);
171 }
172 let group_size = logits.len() / n_groups;
173 let mut selected: Vec<(usize, f32)> = Vec::new();
174 for g in 0..n_groups {
175 let start = g * group_size;
176 let slice = &logits[start..start + group_size];
177 let local = route_top_k(slice, k_per_group.min(group_size), gating, false);
178 for (i, &expert) in local.expert_ids.iter().enumerate() {
179 selected.push((start + expert, local.weights[i]));
180 }
181 }
182 selected.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
183 selected.truncate(total_k.min(selected.len()));
184 let mut weights: Vec<f32> = selected.iter().map(|(_, w)| *w).collect();
185 if norm_topk_prob {
186 let sum: f32 = weights.iter().sum();
187 if sum > 0.0 {
188 for w in weights.iter_mut() {
189 *w /= sum;
190 }
191 }
192 }
193 RoutingDecision {
194 expert_ids: selected.into_iter().map(|(i, _)| i).collect(),
195 weights,
196 }
197}
198
199pub fn route_top_k_sqrtsoftplus(logits: &[f32], k: usize, norm_topk_prob: bool) -> RoutingDecision {
212 let scores: Vec<f32> = logits.iter().map(|&l| sqrt_softplus(l)).collect();
213
214 let mut idx: Vec<usize> = (0..scores.len()).collect();
215 idx.sort_unstable_by(|&a, &b| scores[b].partial_cmp(&scores[a]).unwrap());
216 let top = &idx[..k.min(idx.len())];
217
218 let mut weights: Vec<f32> = top.iter().map(|&i| scores[i]).collect();
219 if norm_topk_prob {
220 let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
221 for w in weights.iter_mut() {
222 *w /= sum;
223 }
224 }
225
226 RoutingDecision {
227 expert_ids: top.to_vec(),
228 weights,
229 }
230}
231
232pub fn route_top_k_sqrtsoftplus_with_bias(
242 logits: &[f32],
243 bias: &[f32],
244 k: usize,
245 renormalize: bool,
246 scaling_factor: f32,
247) -> RoutingDecision {
248 assert_eq!(logits.len(), bias.len());
249 let scores: Vec<f32> = logits.iter().map(|&l| sqrt_softplus(l)).collect();
250 let scores_for_choice: Vec<f32> = scores.iter().zip(bias.iter()).map(|(s, b)| s + b).collect();
251
252 let mut idx: Vec<usize> = (0..scores.len()).collect();
253 idx.sort_unstable_by(|&a, &b| {
254 scores_for_choice[b]
255 .partial_cmp(&scores_for_choice[a])
256 .unwrap()
257 });
258 let top = &idx[..k.min(idx.len())];
259
260 let mut weights: Vec<f32> = top.iter().map(|&i| scores[i]).collect();
261 if k > 1 && renormalize {
262 let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
263 for w in weights.iter_mut() {
264 *w /= sum;
265 }
266 }
267 for w in weights.iter_mut() {
268 *w *= scaling_factor;
269 }
270
271 RoutingDecision {
272 expert_ids: top.to_vec(),
273 weights,
274 }
275}
276
277pub fn route_hash(
298 hash_expert_ids: &[usize],
299 logits: &[f32],
300 renormalize: bool,
301 scaling_factor: f32,
302) -> RoutingDecision {
303 let mut weights: Vec<f32> = hash_expert_ids
304 .iter()
305 .map(|&e| sqrt_softplus(logits[e]))
306 .collect();
307 if hash_expert_ids.len() > 1 && renormalize {
308 let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
309 for w in weights.iter_mut() {
310 *w /= sum;
311 }
312 }
313 for w in weights.iter_mut() {
314 *w *= scaling_factor;
315 }
316
317 RoutingDecision {
318 expert_ids: hash_expert_ids.to_vec(),
319 weights,
320 }
321}
322
323pub fn route_top_k_softmax(logits: &[f32], k: usize, norm_topk_prob: bool) -> RoutingDecision {
334 let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
335 let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
336 let sum: f32 = exps.iter().sum();
337 let probs: Vec<f32> = exps.iter().map(|e| e / sum).collect();
338
339 let mut idx: Vec<usize> = (0..probs.len()).collect();
340 idx.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
341 let top = &idx[..k.min(idx.len())];
342
343 let mut weights: Vec<f32> = top.iter().map(|&i| probs[i]).collect();
344 if norm_topk_prob {
345 let top_sum: f32 = weights.iter().sum();
346 for w in weights.iter_mut() {
347 *w /= top_sum;
348 }
349 }
350
351 RoutingDecision {
352 expert_ids: top.to_vec(),
353 weights,
354 }
355}
356
357pub fn route_top_k_sigmoid(logits: &[f32], k: usize) -> RoutingDecision {
364 let scores: Vec<f32> = logits.iter().map(|&l| sigmoid(l)).collect();
365
366 let mut idx: Vec<usize> = (0..scores.len()).collect();
367 idx.sort_unstable_by(|&a, &b| scores[b].partial_cmp(&scores[a]).unwrap());
368 let top = &idx[..k.min(idx.len())];
369
370 let sum: f32 = top.iter().map(|&i| scores[i]).sum();
371 let weights: Vec<f32> = if sum > 0.0 {
372 top.iter().map(|&i| scores[i] / sum).collect()
373 } else {
374 vec![1.0 / top.len() as f32; top.len()]
378 };
379
380 RoutingDecision {
381 expert_ids: top.to_vec(),
382 weights,
383 }
384}
385
386pub fn route_top_k_sigmoid_with_bias(
401 logits: &[f32],
402 bias: &[f32],
403 k: usize,
404 renormalize: bool,
405 scaling_factor: f32,
406) -> RoutingDecision {
407 assert_eq!(logits.len(), bias.len());
408 let scores: Vec<f32> = logits.iter().map(|&l| sigmoid(l)).collect();
409 let scores_for_choice: Vec<f32> = scores.iter().zip(bias.iter()).map(|(s, b)| s + b).collect();
410
411 let mut idx: Vec<usize> = (0..scores.len()).collect();
412 idx.sort_unstable_by(|&a, &b| {
413 scores_for_choice[b]
414 .partial_cmp(&scores_for_choice[a])
415 .unwrap()
416 });
417 let top = &idx[..k.min(idx.len())];
418
419 let mut weights: Vec<f32> = top.iter().map(|&i| scores[i]).collect();
420 if k > 1 && renormalize {
421 let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
422 for w in weights.iter_mut() {
423 *w /= sum;
424 }
425 }
426 for w in weights.iter_mut() {
427 *w *= scaling_factor;
428 }
429
430 RoutingDecision {
431 expert_ids: top.to_vec(),
432 weights,
433 }
434}
435
436#[derive(Debug, Clone)]
438pub struct PlacementPlan {
439 pub default_placement: ExpertPlacement,
440 pub overrides: std::collections::HashMap<usize, ExpertPlacement>,
441}
442
443impl PlacementPlan {
444 pub fn all_cpu(n_experts: usize) -> Self {
445 PlacementPlan {
446 default_placement: ExpertPlacement::Cpu,
447 overrides: (0..n_experts).map(|i| (i, ExpertPlacement::Cpu)).collect(),
448 }
449 }
450
451 pub fn hot_experts_on_gpu(n_experts: usize, n_gpu_resident: usize) -> Self {
459 let mut overrides = std::collections::HashMap::new();
460 for i in 0..n_experts.min(n_gpu_resident) {
461 overrides.insert(i, ExpertPlacement::GpuDevice(0));
462 }
463 PlacementPlan {
464 default_placement: ExpertPlacement::Cpu,
465 overrides,
466 }
467 }
468
469 pub fn from_budget(
491 expert_bytes: &[usize],
492 activation_counts: Option<&[u64]>,
493 vram_budget_bytes: u64,
494 ) -> Self {
495 let n = expert_bytes.len();
496 let mut order: Vec<usize> = (0..n).collect();
497 if let Some(counts) = activation_counts {
498 if counts.len() == n {
499 order.sort_by(|&a, &b| counts[b].cmp(&counts[a]).then(a.cmp(&b)));
500 }
501 }
502
503 let mut overrides = std::collections::HashMap::new();
504 let mut used: u64 = 0;
505 for idx in order {
506 let size = expert_bytes[idx] as u64;
507 if size == 0 || used + size > vram_budget_bytes {
508 continue;
509 }
510 used += size;
511 overrides.insert(idx, ExpertPlacement::GpuDevice(0));
512 }
513
514 PlacementPlan {
515 default_placement: ExpertPlacement::Cpu,
516 overrides,
517 }
518 }
519
520 pub fn plan_layers_against_global_budget(
530 expert_bytes_per_layer: &[Vec<usize>],
531 activation_counts_per_layer: Option<&[Vec<u64>]>,
532 vram_budget_bytes: u64,
533 ) -> ResidencyPlan {
534 let mut candidates: Vec<(u64, usize, usize)> = Vec::new(); for (l, sizes) in expert_bytes_per_layer.iter().enumerate() {
536 for e in 0..sizes.len() {
537 let count = activation_counts_per_layer
538 .and_then(|cs| cs.get(l))
539 .and_then(|c| c.get(e))
540 .copied()
541 .unwrap_or(0);
542 candidates.push((count, l, e));
543 }
544 }
545 candidates.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
546
547 let mut layer_overrides: Vec<std::collections::HashMap<usize, ExpertPlacement>> =
548 expert_bytes_per_layer
549 .iter()
550 .map(|_| std::collections::HashMap::new())
551 .collect();
552 let mut used: u64 = 0;
553 for (_, l, e) in candidates {
554 let size = expert_bytes_per_layer[l][e] as u64;
555 if size == 0 || used + size > vram_budget_bytes {
556 continue;
557 }
558 used += size;
559 layer_overrides[l].insert(e, ExpertPlacement::GpuDevice(0));
560 }
561
562 ResidencyPlan {
563 layer_plans: layer_overrides
564 .into_iter()
565 .map(|overrides| PlacementPlan {
566 default_placement: ExpertPlacement::Cpu,
567 overrides,
568 })
569 .collect(),
570 device_bytes_planned: used,
571 vram_budget_bytes,
572 }
573 }
574
575 pub fn placement_for(&self, expert_id: usize) -> ExpertPlacement {
576 self.overrides
577 .get(&expert_id)
578 .copied()
579 .unwrap_or(self.default_placement)
580 }
581}
582
583pub struct ResidencyPlan {
589 layer_plans: Vec<PlacementPlan>,
590 pub device_bytes_planned: u64,
593 pub vram_budget_bytes: u64,
596}
597
598impl ResidencyPlan {
599 pub fn layer_plan(&self, layer: usize) -> &PlacementPlan {
600 &self.layer_plans[layer]
601 }
602
603 pub fn n_layers(&self) -> usize {
604 self.layer_plans.len()
605 }
606}
607
608pub struct ExpertWeights {
613 pub gate: WeightMatrix,
614 pub up: WeightMatrix,
615 pub down: WeightMatrix,
616}
617
618#[derive(Debug, Clone, Default)]
630pub struct ExpertBias {
631 pub gate: Vec<f32>,
632 pub up: Vec<f32>,
633 pub down: Vec<f32>,
634}
635
636pub const SWIGLU_OAI_ALPHA: f32 = 1.702;
639pub const SWIGLU_OAI_LIMIT: f32 = 7.0;
641
642pub fn swiglu_oai(gate: &[f32], up: &[f32], alpha: f32, limit: f32) -> Vec<f32> {
659 debug_assert_eq!(gate.len(), up.len());
660 gate.iter()
661 .zip(up.iter())
662 .map(|(&g, &u)| {
663 let x = g.min(limit);
664 let y = u.clamp(-limit, limit);
665 let out_glu = x / (1.0 + (alpha * -x).exp());
666 out_glu * (y + 1.0)
667 })
668 .collect()
669}
670
671pub fn route_top_k_softmax_weight(logits: &[f32], k: usize) -> RoutingDecision {
683 let mut idx: Vec<usize> = (0..logits.len()).collect();
684 idx.sort_unstable_by(|&a, &b| logits[b].partial_cmp(&logits[a]).unwrap());
685 let top = &idx[..k.min(idx.len())];
686
687 let selected: Vec<f32> = top.iter().map(|&i| logits[i]).collect();
688 let max = selected.iter().copied().fold(f32::NEG_INFINITY, f32::max);
689 let exps: Vec<f32> = selected.iter().map(|&l| (l - max).exp()).collect();
690 let sum: f32 = exps.iter().sum();
691 let weights = if sum > 0.0 {
692 exps.iter().map(|&e| e / sum).collect()
693 } else {
694 exps
695 };
696
697 RoutingDecision {
698 expert_ids: top.to_vec(),
699 weights,
700 }
701}
702
703pub fn run_expert_oai(
710 hidden: &[f32],
711 expert: &ExpertWeights,
712 bias: &ExpertBias,
713 alpha: f32,
714 limit: f32,
715) -> Vec<f32> {
716 let mut gate = expert.gate.apply(hidden);
717 let mut up = expert.up.apply(hidden);
718 for (x, b) in gate.iter_mut().zip(bias.gate.iter()) {
719 *x += b;
720 }
721 for (x, b) in up.iter_mut().zip(bias.up.iter()) {
722 *x += b;
723 }
724 let activated = swiglu_oai(&gate, &up, alpha, limit);
725 let mut out = expert.down.apply(&activated);
726 for (x, b) in out.iter_mut().zip(bias.down.iter()) {
727 *x += b;
728 }
729 out
730}
731
732pub fn run_expert(hidden: &[f32], expert: &ExpertWeights) -> Vec<f32> {
734 #[cfg(any(feature = "cuda", feature = "metal"))]
735 {
736 if let Some(out) = ferrox_core::WeightMatrix::apply_gpu_dense_ffn_swiglu(
738 &expert.gate,
739 &expert.up,
740 &expert.down,
741 hidden,
742 ) {
743 return out;
744 }
745 }
746 #[cfg(any(feature = "cuda", feature = "metal"))]
747 {
748 if let Some(mut outs) =
750 ferrox_core::WeightMatrix::apply_gpu_multi(&[&expert.gate, &expert.up], hidden)
751 {
752 let up = outs.pop().unwrap();
753 let gate = outs.pop().unwrap();
754 let activated = swiglu(&gate, &up);
755 return expert.down.apply(&activated);
756 }
757 }
758 if ferrox_core::weight_matrix::cpu_int_dot_enabled() && hidden.len().is_multiple_of(32) {
761 let act = ferrox_quant::quantize_activations_q8(hidden);
762 let (g, u) = rayon::join(
767 || expert.gate.apply_cpu_q8(&act),
768 || expert.up.apply_cpu_q8(&act),
769 );
770 if let (Some(gate), Some(up)) = (g, u) {
771 let activated = swiglu(&gate, &up);
772 return expert.down.apply(&activated);
773 }
774 }
775 let (gate, up) = rayon::join(|| expert.gate.apply(hidden), || expert.up.apply(hidden));
776 let activated = swiglu(&gate, &up);
777 expert.down.apply(&activated)
778}
779
780#[cfg(any(feature = "cuda", feature = "metal"))]
801pub fn run_expert_placed(
802 hidden: &[f32],
803 expert: &ExpertWeights,
804 placement: ExpertPlacement,
805) -> Vec<f32> {
806 if matches!(placement, ExpertPlacement::GpuDevice(_)) {
807 #[cfg(any(feature = "cuda", feature = "metal"))]
808 {
809 if let Some(out) = ferrox_core::WeightMatrix::apply_gpu_dense_ffn_swiglu(
810 &expert.gate,
811 &expert.up,
812 &expert.down,
813 hidden,
814 ) {
815 return out;
816 }
817 }
818 #[cfg(any(feature = "cuda", feature = "metal"))]
819 {
820 if let Some(mut outs) =
821 ferrox_core::WeightMatrix::apply_gpu_multi(&[&expert.gate, &expert.up], hidden)
822 {
823 let up = outs.pop().unwrap();
824 let gate = outs.pop().unwrap();
825 let activated = swiglu(&gate, &up);
826 if let Some(down) = expert.down.apply_gpu(&activated) {
827 return down;
828 }
829 return expert.down.apply(&activated);
830 }
831 }
832 if let Some(gate) = expert.gate.apply_gpu(hidden) {
833 if let Some(up) = expert.up.apply_gpu(hidden) {
834 let activated = swiglu(&gate, &up);
835 if let Some(down) = expert.down.apply_gpu(&activated) {
836 return down;
837 }
838 }
839 }
840 }
841 run_expert(hidden, expert)
842}
843
844#[cfg(not(any(feature = "cuda", feature = "metal")))]
845pub fn run_expert_placed(
846 hidden: &[f32],
847 expert: &ExpertWeights,
848 _placement: ExpertPlacement,
849) -> Vec<f32> {
850 run_expert(hidden, expert)
851}
852
853pub fn combine_expert_outputs(
855 routed_outputs: &[(Vec<f32>, f32)],
856 shared_outputs: &[Vec<f32>],
857 hidden_dim: usize,
858) -> Vec<f32> {
859 let mut out = vec![0f32; hidden_dim];
860 for (expert_out, weight) in routed_outputs {
861 for (o, e) in out.iter_mut().zip(expert_out.iter()) {
862 *o += e * weight;
863 }
864 }
865 for shared_out in shared_outputs {
866 for (o, e) in out.iter_mut().zip(shared_out.iter()) {
867 *o += e;
868 }
869 }
870 out
871}
872
873#[cfg(test)]
874mod tests {
875 #[test]
881 fn global_budget_cannot_be_multiplied_across_layers() {
882 let n_layers = 10;
883 let sizes: Vec<Vec<usize>> = (0..n_layers).map(|_| vec![100usize; 4]).collect();
884 let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 250);
885
886 let total_placed: usize = (0..n_layers)
887 .map(|l| {
888 (0..4)
889 .filter(|&e| plan.layer_plan(l).placement_for(e) != ExpertPlacement::Cpu)
890 .count()
891 })
892 .sum();
893 assert_eq!(
894 total_placed, 2,
895 "250 bytes fits exactly 2 x 100-byte experts, globally"
896 );
897 assert_eq!(plan.device_bytes_planned, 200);
898 assert!(plan.device_bytes_planned <= plan.vram_budget_bytes);
899
900 let per_layer_total: usize = (0..n_layers)
903 .map(|_| {
904 let p = PlacementPlan::from_budget(&[100; 4], None, 250);
905 (0..4)
906 .filter(|&e| p.placement_for(e) != ExpertPlacement::Cpu)
907 .count()
908 })
909 .sum();
910 assert_eq!(per_layer_total, 20, "per-layer planning overcommits 10x");
911 }
912
913 #[test]
917 fn global_planning_prioritizes_hotness_across_layers() {
918 let sizes: Vec<Vec<usize>> = (0..3).map(|_| vec![100usize; 2]).collect();
919 let mut counts: Vec<Vec<u64>> = (0..3).map(|_| vec![0u64; 2]).collect();
920 counts[2][1] = 50; counts[0][0] = 10;
922 let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, Some(&counts), 200);
923
924 assert_eq!(
925 plan.layer_plan(2).placement_for(1),
926 ExpertPlacement::GpuDevice(0),
927 "hottest expert (layer 2) must win a slot"
928 );
929 assert_eq!(
930 plan.layer_plan(0).placement_for(0),
931 ExpertPlacement::GpuDevice(0),
932 "second-hottest expert (layer 0) takes the remaining slot"
933 );
934 assert_eq!(plan.device_bytes_planned, 200);
935 }
936
937 #[test]
940 fn global_planning_handles_zero_budget_and_dense_layers() {
941 let sizes = vec![Vec::new(), vec![100usize; 3], Vec::new()];
942 let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 0);
943 assert_eq!(plan.device_bytes_planned, 0);
944 assert_eq!(plan.n_layers(), 3);
945 for e in 0..3 {
946 assert_eq!(plan.layer_plan(1).placement_for(e), ExpertPlacement::Cpu);
947 }
948 }
949
950 use super::*;
951
952 #[test]
953 fn top_k_selects_highest_scoring_experts() {
954 let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
955 let decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
956 assert_eq!(decision.expert_ids, vec![1, 3]);
957 let sum: f32 = decision.weights.iter().sum();
958 assert!((sum - 1.0).abs() < 1e-5);
959 assert!(decision.weights[0] > decision.weights[1]);
960 }
961
962 #[test]
963 fn top_k_weights_always_sum_to_one_regardless_of_k() {
964 let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
965 for k in 1..=8 {
966 let decision = route_top_k(&logits, k, GatingFunction::Softmax, true);
967 let sum: f32 = decision.weights.iter().sum();
968 assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
969 }
970 }
971
972 #[test]
983 fn norm_topk_prob_false_uses_raw_full_softmax_probability_not_renormalized() {
984 let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
985 let decision = route_top_k(&logits, 3, GatingFunction::Softmax, false);
986
987 assert_eq!(decision.expert_ids, vec![7, 6, 5]);
988
989 let expected = [0.6323223_f32, 0.2326232, 0.0855683];
990 for (got, want) in decision.weights.iter().zip(expected.iter()) {
991 assert!((got - want).abs() < 1e-4, "got={got} want={want}");
992 }
993
994 let sum: f32 = decision.weights.iter().sum();
995 assert!(
996 (sum - 0.9505138).abs() < 1e-4,
997 "raw top-3 probability mass should be < 1 (it's a subset of a full 8-way softmax), got sum={sum}"
998 );
999
1000 let normalized = route_top_k(&logits, 3, GatingFunction::Softmax, true);
1005 assert_eq!(normalized.expert_ids, decision.expert_ids);
1006 for (raw, norm) in decision.weights.iter().zip(normalized.weights.iter()) {
1007 assert!(
1008 (raw / sum - norm).abs() < 1e-4,
1009 "raw={raw} sum={sum} normalized={norm}"
1010 );
1011 }
1012 }
1013
1014 #[test]
1015 fn sigmoid_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1016 let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1021 let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1022 let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1023 assert_eq!(softmax_decision.expert_ids, sigmoid_decision.expert_ids);
1024 }
1025
1026 #[test]
1027 fn sigmoid_gating_weights_sum_to_one() {
1028 let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1029 for k in 1..=8 {
1030 let decision = route_top_k(&logits, k, GatingFunction::Sigmoid, true);
1031 let sum: f32 = decision.weights.iter().sum();
1032 assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
1033 }
1034 }
1035
1036 #[test]
1037 fn bias_only_affects_selection_not_the_final_weight_value() {
1038 let logits = vec![0.1, 2.0];
1044 let bias = vec![10.0, 0.0];
1045 let decision = route_top_k_sigmoid_with_bias(&logits, &bias, 1, true, 1.0);
1046 assert_eq!(decision.expert_ids, vec![0]);
1047 assert!((decision.weights[0] - sigmoid(0.1)).abs() < 1e-5);
1050 }
1051
1052 #[test]
1053 fn without_bias_selection_falls_back_to_plain_sigmoid_top_k() {
1054 let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1055 let zero_bias = vec![0.0; logits.len()];
1056 let biased = route_top_k_sigmoid_with_bias(&logits, &zero_bias, 3, true, 1.0);
1057 let plain = route_top_k(&logits, 3, GatingFunction::Sigmoid, true);
1058 assert_eq!(biased.expert_ids, plain.expert_ids);
1059 for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1060 assert!((a - b).abs() < 1e-6);
1061 }
1062 }
1063
1064 #[test]
1065 fn scaling_factor_multiplies_every_weight() {
1066 let logits = vec![1.0, 2.0, 3.0];
1067 let bias = vec![0.0; 3];
1068 let unscaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 1.0);
1069 let scaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 2.5);
1070 for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1071 assert!((u * 2.5 - s).abs() < 1e-5);
1072 }
1073 }
1074
1075 #[test]
1076 fn sigmoid_and_softmax_weights_differ_for_the_same_logits() {
1077 let logits = vec![3.0, 1.0, -2.0, 0.5];
1085 let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1086 let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1087 assert!(
1088 (softmax_decision.weights[0] - sigmoid_decision.weights[0]).abs() > 1e-3,
1089 "softmax and sigmoid gating should generally produce different weight splits for the same logits"
1090 );
1091 }
1092
1093 #[test]
1094 fn sqrt_softplus_matches_hand_computed_values_at_zero_and_positive_logit() {
1095 assert!((sqrt_softplus(0.0) - 2.0_f32.ln().sqrt()).abs() < 1e-6);
1099 assert!((sqrt_softplus(20.0) - 20.0_f32.sqrt()).abs() < 1e-3);
1101 }
1102
1103 #[test]
1104 fn grouped_routing_picks_within_each_group_then_global_top_k() {
1105 let logits = vec![0.1, 5.0, 0.2, 4.0];
1108 let d = route_top_k_grouped(&logits, 2, 1, 2, GatingFunction::Softmax, true);
1109 assert_eq!(d.expert_ids.len(), 2);
1110 assert!(d.expert_ids.contains(&1));
1111 assert!(d.expert_ids.contains(&3));
1112 let sum: f32 = d.weights.iter().sum();
1113 assert!((sum - 1.0).abs() < 1e-4);
1114 }
1115
1116 #[test]
1117 fn sqrtsoftplus_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1118 let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1123 let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1124 let sqrtsoftplus_decision = route_top_k(&logits, 2, GatingFunction::SqrtSoftplus, true);
1125 assert_eq!(
1126 softmax_decision.expert_ids,
1127 sqrtsoftplus_decision.expert_ids
1128 );
1129 }
1130
1131 #[test]
1132 fn sqrtsoftplus_weights_sum_to_one_when_normalized() {
1133 let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1134 for k in 1..=8 {
1135 let decision = route_top_k(&logits, k, GatingFunction::SqrtSoftplus, true);
1136 let sum: f32 = decision.weights.iter().sum();
1137 assert!((sum - 1.0).abs() < 1e-4, "k={k} sum={sum}");
1138 }
1139 }
1140
1141 #[test]
1142 fn sqrtsoftplus_bias_only_affects_selection_not_the_final_weight_value() {
1143 let logits = vec![0.1, 2.0];
1147 let bias = vec![10.0, 0.0];
1148 let decision = route_top_k_sqrtsoftplus_with_bias(&logits, &bias, 1, true, 1.0);
1149 assert_eq!(decision.expert_ids, vec![0]);
1150 assert!((decision.weights[0] - sqrt_softplus(0.1)).abs() < 1e-5);
1151 }
1152
1153 #[test]
1154 fn sqrtsoftplus_without_bias_selection_falls_back_to_plain_top_k() {
1155 let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1156 let zero_bias = vec![0.0; logits.len()];
1157 let biased = route_top_k_sqrtsoftplus_with_bias(&logits, &zero_bias, 3, true, 1.0);
1158 let plain = route_top_k(&logits, 3, GatingFunction::SqrtSoftplus, true);
1159 assert_eq!(biased.expert_ids, plain.expert_ids);
1160 for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1161 assert!((a - b).abs() < 1e-6);
1162 }
1163 }
1164
1165 #[test]
1166 fn hash_routing_uses_the_fixed_table_ids_regardless_of_logit_ranking() {
1167 let logits = vec![100.0, 1.0, 0.5, -3.0];
1172 let hash_expert_ids = vec![2usize, 1usize];
1173 let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1174 assert_eq!(decision.expert_ids, vec![2, 1]);
1175 }
1176
1177 #[test]
1178 fn hash_routing_weights_come_from_the_real_router_logits_not_a_fixed_split() {
1179 let logits = vec![-5.0, 0.1, 3.0, -5.0];
1185 let hash_expert_ids = vec![2usize, 1usize];
1186 let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1187 assert!(decision.weights[0] > decision.weights[1]);
1188 let sum: f32 = decision.weights.iter().sum();
1189 assert!((sum - 1.0).abs() < 1e-5);
1190 let expected0 = sqrt_softplus(3.0) / (sqrt_softplus(3.0) + sqrt_softplus(0.1));
1191 assert!((decision.weights[0] - expected0).abs() < 1e-5);
1192 }
1193
1194 #[test]
1195 fn hash_routing_scaling_factor_multiplies_every_weight() {
1196 let logits = vec![1.0, 2.0, 3.0];
1197 let hash_expert_ids = vec![0usize, 2usize];
1198 let unscaled = route_hash(&hash_expert_ids, &logits, true, 1.0);
1199 let scaled = route_hash(&hash_expert_ids, &logits, true, 2.5);
1200 for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1201 assert!((u * 2.5 - s).abs() < 1e-5);
1202 }
1203 }
1204
1205 #[test]
1206 fn placement_plan_defaults_to_cpu_for_unlisted_experts() {
1207 let plan = PlacementPlan::hot_experts_on_gpu(256, 8);
1208 assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1209 assert_eq!(plan.placement_for(7), ExpertPlacement::GpuDevice(0));
1210 assert_eq!(plan.placement_for(8), ExpertPlacement::Cpu);
1211 assert_eq!(plan.placement_for(255), ExpertPlacement::Cpu);
1212 }
1213
1214 #[test]
1215 fn all_cpu_plan_never_returns_gpu() {
1216 let plan = PlacementPlan::all_cpu(64);
1217 for i in 0..64 {
1218 assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1219 }
1220 }
1221
1222 #[test]
1223 fn from_budget_fits_as_many_experts_as_the_vram_budget_allows() {
1224 let sizes = vec![100usize, 100, 100, 100];
1226 let plan = PlacementPlan::from_budget(&sizes, None, 250);
1227 let on_gpu = (0..4)
1228 .filter(|&i| plan.placement_for(i) == ExpertPlacement::GpuDevice(0))
1229 .count();
1230 assert_eq!(on_gpu, 2);
1231 }
1232
1233 #[test]
1234 fn from_budget_prioritizes_the_most_frequently_activated_experts() {
1235 let sizes = vec![50usize, 50, 50, 50];
1238 let counts = vec![1u64, 2, 100, 3];
1239 let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 50);
1241 assert_eq!(
1242 plan.placement_for(2),
1243 ExpertPlacement::GpuDevice(0),
1244 "the hottest expert (index 2) must be the one placed on GPU"
1245 );
1246 assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1247 assert_eq!(plan.placement_for(1), ExpertPlacement::Cpu);
1248 assert_eq!(plan.placement_for(3), ExpertPlacement::Cpu);
1249 }
1250
1251 #[test]
1252 fn from_budget_skips_an_expert_that_does_not_fit_and_tries_the_next() {
1253 let sizes = vec![200usize, 60, 60];
1256 let plan = PlacementPlan::from_budget(&sizes, None, 120);
1257 assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1258 assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1259 assert_eq!(plan.placement_for(2), ExpertPlacement::GpuDevice(0));
1260 }
1261
1262 #[test]
1263 fn from_budget_with_zero_vram_places_nothing_on_gpu() {
1264 let sizes = vec![10usize, 20, 30];
1265 let plan = PlacementPlan::from_budget(&sizes, None, 0);
1266 for i in 0..3 {
1267 assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1268 }
1269 }
1270
1271 #[test]
1272 fn from_budget_ignores_mismatched_activation_counts_length_rather_than_panicking() {
1273 let sizes = vec![10usize, 10];
1274 let counts = vec![1u64]; let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 100);
1276 assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1278 assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1279 }
1280
1281 #[test]
1282 fn combine_expert_outputs_weights_routed_and_adds_shared() {
1283 let routed = vec![(vec![2.0, 2.0], 0.5), (vec![4.0, 4.0], 0.5)];
1284 let shared = vec![vec![1.0, 1.0]];
1285 let out = combine_expert_outputs(&routed, &shared, 2);
1286 assert_eq!(out, vec![4.0, 4.0]);
1287 }
1288
1289 #[test]
1290 fn run_expert_produces_correct_output_dimension() {
1291 use ferrox_core::tensor::Tensor;
1292 let hidden_dim = 4;
1293 let ffn_dim = 3;
1294 let expert = ExpertWeights {
1295 gate: WeightMatrix::F32(Tensor::new(
1296 vec![0.1; ffn_dim * hidden_dim],
1297 vec![ffn_dim, hidden_dim],
1298 )),
1299 up: WeightMatrix::F32(Tensor::new(
1300 vec![0.2; ffn_dim * hidden_dim],
1301 vec![ffn_dim, hidden_dim],
1302 )),
1303 down: WeightMatrix::F32(Tensor::new(
1304 vec![0.3; hidden_dim * ffn_dim],
1305 vec![hidden_dim, ffn_dim],
1306 )),
1307 };
1308 let hidden = vec![1.0, -1.0, 0.5, 0.5];
1309 let out = run_expert(&hidden, &expert);
1310 assert_eq!(out.len(), hidden_dim);
1311 assert!(out.iter().all(|v| v.is_finite()));
1312 }
1313
1314 #[test]
1321 fn run_expert_placed_matches_run_expert_when_nothing_is_gpu_dispatched() {
1322 use ferrox_core::tensor::Tensor;
1323 let hidden_dim = 4;
1324 let ffn_dim = 3;
1325 let expert = ExpertWeights {
1326 gate: WeightMatrix::F32(Tensor::new(
1327 vec![0.1; ffn_dim * hidden_dim],
1328 vec![ffn_dim, hidden_dim],
1329 )),
1330 up: WeightMatrix::F32(Tensor::new(
1331 vec![0.2; ffn_dim * hidden_dim],
1332 vec![ffn_dim, hidden_dim],
1333 )),
1334 down: WeightMatrix::F32(Tensor::new(
1335 vec![0.3; hidden_dim * ffn_dim],
1336 vec![hidden_dim, ffn_dim],
1337 )),
1338 };
1339 let hidden = vec![1.0, -1.0, 0.5, 0.5];
1340 let expected = run_expert(&hidden, &expert);
1341
1342 assert_eq!(
1343 run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu),
1344 expected
1345 );
1346 assert_eq!(
1347 run_expert_placed(&hidden, &expert, ExpertPlacement::GpuDevice(0)),
1348 expected,
1349 "F32 has no GPU kernel, so GpuDevice placement must still fall through to the CPU path"
1350 );
1351 }
1352
1353 #[cfg(any(feature = "cuda", feature = "metal"))]
1354 #[test]
1355 #[ignore = "requires real GPU hardware (CUDA or Metal) -- run with --ignored"]
1356 fn run_expert_placed_on_gpu_matches_cpu_for_a_real_quantized_expert() {
1357 let hidden_dim = 32;
1358 let ffn_dim = 32; let make_row = |cols: usize, seed: f32| -> Vec<f32> {
1360 (0..cols)
1361 .map(|i| ((i as f32) - (cols as f32) / 2.0) * 0.01 * seed)
1362 .collect()
1363 };
1364 let quantize_matrix = |rows: usize, cols: usize, seed: f32| {
1365 let mut packed = Vec::new();
1366 for r in 0..rows {
1367 packed.extend(ferrox_quant::quantize_q8_0(&make_row(
1368 cols,
1369 seed + r as f32,
1370 )));
1371 }
1372 WeightMatrix::Quantized {
1373 data: ferrox_core::weight_matrix::WeightBytes::Owned(packed),
1374 rows,
1375 cols,
1376 kind: ferrox_core::weight_matrix::QuantKind::Q8_0,
1377 }
1378 };
1379 let expert = ExpertWeights {
1380 gate: quantize_matrix(ffn_dim, hidden_dim, 1.0),
1381 up: quantize_matrix(ffn_dim, hidden_dim, 2.0),
1382 down: quantize_matrix(hidden_dim, ffn_dim, 3.0),
1383 };
1384 let hidden = make_row(hidden_dim, 0.5);
1385
1386 let cpu = run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu);
1387 let gpu = run_expert_placed(&hidden, &expert, ExpertPlacement::GpuDevice(0));
1388 assert_eq!(cpu.len(), gpu.len());
1389 for (c, g) in cpu.iter().zip(gpu.iter()) {
1390 assert!((c - g).abs() < 1e-1, "cpu={c} gpu={g}");
1391 }
1392 }
1393}