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 pub expert_weights_scale: f32,
80}
81
82#[derive(Debug, Clone)]
85pub struct RoutingDecision {
86 pub expert_ids: Vec<usize>,
87 pub weights: Vec<f32>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum GatingFunction {
113 Softmax,
115 Sigmoid,
119 SqrtSoftplus,
132}
133
134fn sigmoid(x: f32) -> f32 {
135 1.0 / (1.0 + (-x).exp())
136}
137
138fn sqrt_softplus(x: f32) -> f32 {
143 let softplus = x.max(0.0) + (-x.abs()).exp().ln_1p();
144 softplus.sqrt()
145}
146
147pub fn route_top_k(
152 logits: &[f32],
153 k: usize,
154 gating: GatingFunction,
155 norm_topk_prob: bool,
156) -> RoutingDecision {
157 match gating {
158 GatingFunction::Softmax => route_top_k_softmax(logits, k, norm_topk_prob),
159 GatingFunction::Sigmoid => route_top_k_sigmoid(logits, k),
160 GatingFunction::SqrtSoftplus => route_top_k_sqrtsoftplus(logits, k, norm_topk_prob),
161 }
162}
163
164pub fn route_top_k_biased(
185 logits: &[f32],
186 bias: Option<&[f32]>,
187 k: usize,
188 gating: GatingFunction,
189 norm_w: bool,
190 w_scale: f32,
191) -> RoutingDecision {
192 let probs: Vec<f32> = match gating {
193 GatingFunction::Softmax => {
194 let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
195 let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
196 let sum: f32 = exps.iter().sum();
197 exps.iter().map(|e| e / sum).collect()
198 }
199 GatingFunction::Sigmoid => logits.iter().map(|&l| sigmoid(l)).collect(),
200 GatingFunction::SqrtSoftplus => logits.iter().map(|&l| sqrt_softplus(l)).collect(),
201 };
202
203 let selection: Vec<f32> = match bias {
204 Some(b) => {
205 assert_eq!(
206 b.len(),
207 probs.len(),
208 "exp_probs_b must have one entry per expert"
209 );
210 probs.iter().zip(b.iter()).map(|(p, b)| p + b).collect()
211 }
212 None => probs.clone(),
213 };
214
215 let mut idx: Vec<usize> = (0..selection.len()).collect();
216 idx.sort_unstable_by(|&a, &b| selection[b].partial_cmp(&selection[a]).unwrap());
217 let top = &idx[..k.min(idx.len())];
218
219 let mut weights: Vec<f32> = top.iter().map(|&i| probs[i]).collect();
220 if norm_w {
221 let sum = weights.iter().sum::<f32>().max(6.103_515_6e-5);
225 for w in weights.iter_mut() {
226 *w /= sum;
227 }
228 }
229 if w_scale != 0.0 && w_scale != 1.0 {
230 for w in weights.iter_mut() {
231 *w *= w_scale;
232 }
233 }
234
235 RoutingDecision {
236 expert_ids: top.to_vec(),
237 weights,
238 }
239}
240
241pub fn route_top_k_grouped(
250 logits: &[f32],
251 n_groups: usize,
252 k_per_group: usize,
253 total_k: usize,
254 gating: GatingFunction,
255 norm_topk_prob: bool,
256) -> RoutingDecision {
257 if n_groups <= 1 || !logits.len().is_multiple_of(n_groups) {
258 return route_top_k(logits, total_k, gating, norm_topk_prob);
259 }
260 let group_size = logits.len() / n_groups;
261 let mut selected: Vec<(usize, f32)> = Vec::new();
262 for g in 0..n_groups {
263 let start = g * group_size;
264 let slice = &logits[start..start + group_size];
265 let local = route_top_k(slice, k_per_group.min(group_size), gating, false);
266 for (i, &expert) in local.expert_ids.iter().enumerate() {
267 selected.push((start + expert, local.weights[i]));
268 }
269 }
270 selected.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
271 selected.truncate(total_k.min(selected.len()));
272 let mut weights: Vec<f32> = selected.iter().map(|(_, w)| *w).collect();
273 if norm_topk_prob {
274 let sum: f32 = weights.iter().sum();
275 if sum > 0.0 {
276 for w in weights.iter_mut() {
277 *w /= sum;
278 }
279 }
280 }
281 RoutingDecision {
282 expert_ids: selected.into_iter().map(|(i, _)| i).collect(),
283 weights,
284 }
285}
286
287pub fn route_top_k_sqrtsoftplus(logits: &[f32], k: usize, norm_topk_prob: bool) -> RoutingDecision {
300 let scores: Vec<f32> = logits.iter().map(|&l| sqrt_softplus(l)).collect();
301
302 let mut idx: Vec<usize> = (0..scores.len()).collect();
303 idx.sort_unstable_by(|&a, &b| scores[b].partial_cmp(&scores[a]).unwrap());
304 let top = &idx[..k.min(idx.len())];
305
306 let mut weights: Vec<f32> = top.iter().map(|&i| scores[i]).collect();
307 if norm_topk_prob {
308 let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
309 for w in weights.iter_mut() {
310 *w /= sum;
311 }
312 }
313
314 RoutingDecision {
315 expert_ids: top.to_vec(),
316 weights,
317 }
318}
319
320pub fn route_top_k_sqrtsoftplus_with_bias(
330 logits: &[f32],
331 bias: &[f32],
332 k: usize,
333 renormalize: bool,
334 scaling_factor: f32,
335) -> RoutingDecision {
336 assert_eq!(logits.len(), bias.len());
337 let scores: Vec<f32> = logits.iter().map(|&l| sqrt_softplus(l)).collect();
338 let scores_for_choice: Vec<f32> = scores.iter().zip(bias.iter()).map(|(s, b)| s + b).collect();
339
340 let mut idx: Vec<usize> = (0..scores.len()).collect();
341 idx.sort_unstable_by(|&a, &b| {
342 scores_for_choice[b]
343 .partial_cmp(&scores_for_choice[a])
344 .unwrap()
345 });
346 let top = &idx[..k.min(idx.len())];
347
348 let mut weights: Vec<f32> = top.iter().map(|&i| scores[i]).collect();
349 if k > 1 && renormalize {
350 let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
351 for w in weights.iter_mut() {
352 *w /= sum;
353 }
354 }
355 for w in weights.iter_mut() {
356 *w *= scaling_factor;
357 }
358
359 RoutingDecision {
360 expert_ids: top.to_vec(),
361 weights,
362 }
363}
364
365pub fn route_hash(
386 hash_expert_ids: &[usize],
387 logits: &[f32],
388 renormalize: bool,
389 scaling_factor: f32,
390) -> RoutingDecision {
391 let mut weights: Vec<f32> = hash_expert_ids
392 .iter()
393 .map(|&e| sqrt_softplus(logits[e]))
394 .collect();
395 if hash_expert_ids.len() > 1 && renormalize {
396 let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
397 for w in weights.iter_mut() {
398 *w /= sum;
399 }
400 }
401 for w in weights.iter_mut() {
402 *w *= scaling_factor;
403 }
404
405 RoutingDecision {
406 expert_ids: hash_expert_ids.to_vec(),
407 weights,
408 }
409}
410
411pub fn route_top_k_softmax(logits: &[f32], k: usize, norm_topk_prob: bool) -> RoutingDecision {
422 let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
423 let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
424 let sum: f32 = exps.iter().sum();
425 let probs: Vec<f32> = exps.iter().map(|e| e / sum).collect();
426
427 let mut idx: Vec<usize> = (0..probs.len()).collect();
428 idx.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
429 let top = &idx[..k.min(idx.len())];
430
431 let mut weights: Vec<f32> = top.iter().map(|&i| probs[i]).collect();
432 if norm_topk_prob {
433 let top_sum: f32 = weights.iter().sum();
434 for w in weights.iter_mut() {
435 *w /= top_sum;
436 }
437 }
438
439 RoutingDecision {
440 expert_ids: top.to_vec(),
441 weights,
442 }
443}
444
445pub fn route_top_k_sigmoid(logits: &[f32], k: usize) -> RoutingDecision {
452 let scores: Vec<f32> = logits.iter().map(|&l| sigmoid(l)).collect();
453
454 let mut idx: Vec<usize> = (0..scores.len()).collect();
455 idx.sort_unstable_by(|&a, &b| scores[b].partial_cmp(&scores[a]).unwrap());
456 let top = &idx[..k.min(idx.len())];
457
458 let sum: f32 = top.iter().map(|&i| scores[i]).sum();
459 let weights: Vec<f32> = if sum > 0.0 {
460 top.iter().map(|&i| scores[i] / sum).collect()
461 } else {
462 vec![1.0 / top.len() as f32; top.len()]
466 };
467
468 RoutingDecision {
469 expert_ids: top.to_vec(),
470 weights,
471 }
472}
473
474pub fn route_top_k_sigmoid_with_bias(
489 logits: &[f32],
490 bias: &[f32],
491 k: usize,
492 renormalize: bool,
493 scaling_factor: f32,
494) -> RoutingDecision {
495 assert_eq!(logits.len(), bias.len());
496 let scores: Vec<f32> = logits.iter().map(|&l| sigmoid(l)).collect();
497 let scores_for_choice: Vec<f32> = scores.iter().zip(bias.iter()).map(|(s, b)| s + b).collect();
498
499 let mut idx: Vec<usize> = (0..scores.len()).collect();
500 idx.sort_unstable_by(|&a, &b| {
501 scores_for_choice[b]
502 .partial_cmp(&scores_for_choice[a])
503 .unwrap()
504 });
505 let top = &idx[..k.min(idx.len())];
506
507 let mut weights: Vec<f32> = top.iter().map(|&i| scores[i]).collect();
508 if k > 1 && renormalize {
509 let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
510 for w in weights.iter_mut() {
511 *w /= sum;
512 }
513 }
514 for w in weights.iter_mut() {
515 *w *= scaling_factor;
516 }
517
518 RoutingDecision {
519 expert_ids: top.to_vec(),
520 weights,
521 }
522}
523
524#[derive(Debug, Clone)]
526pub struct PlacementPlan {
527 pub default_placement: ExpertPlacement,
528 pub overrides: std::collections::HashMap<usize, ExpertPlacement>,
529}
530
531impl PlacementPlan {
532 pub fn all_cpu(n_experts: usize) -> Self {
533 PlacementPlan {
534 default_placement: ExpertPlacement::Cpu,
535 overrides: (0..n_experts).map(|i| (i, ExpertPlacement::Cpu)).collect(),
536 }
537 }
538
539 pub fn hot_experts_on_gpu(n_experts: usize, n_gpu_resident: usize) -> Self {
547 let mut overrides = std::collections::HashMap::new();
548 for i in 0..n_experts.min(n_gpu_resident) {
549 overrides.insert(i, ExpertPlacement::GpuDevice(0));
550 }
551 PlacementPlan {
552 default_placement: ExpertPlacement::Cpu,
553 overrides,
554 }
555 }
556
557 pub fn from_budget(
579 expert_bytes: &[usize],
580 activation_counts: Option<&[u64]>,
581 vram_budget_bytes: u64,
582 ) -> Self {
583 let n = expert_bytes.len();
584 let mut order: Vec<usize> = (0..n).collect();
585 if let Some(counts) = activation_counts {
586 if counts.len() == n {
587 order.sort_by(|&a, &b| counts[b].cmp(&counts[a]).then(a.cmp(&b)));
588 }
589 }
590
591 let mut overrides = std::collections::HashMap::new();
592 let mut used: u64 = 0;
593 for idx in order {
594 let size = expert_bytes[idx] as u64;
595 if size == 0 || used + size > vram_budget_bytes {
596 continue;
597 }
598 used += size;
599 overrides.insert(idx, ExpertPlacement::GpuDevice(0));
600 }
601
602 PlacementPlan {
603 default_placement: ExpertPlacement::Cpu,
604 overrides,
605 }
606 }
607
608 pub fn plan_layers_against_global_budget(
618 expert_bytes_per_layer: &[Vec<usize>],
619 activation_counts_per_layer: Option<&[Vec<u64>]>,
620 vram_budget_bytes: u64,
621 ) -> ResidencyPlan {
622 let mut candidates: Vec<(u64, usize, usize)> = Vec::new(); for (l, sizes) in expert_bytes_per_layer.iter().enumerate() {
624 for e in 0..sizes.len() {
625 let count = activation_counts_per_layer
626 .and_then(|cs| cs.get(l))
627 .and_then(|c| c.get(e))
628 .copied()
629 .unwrap_or(0);
630 candidates.push((count, l, e));
631 }
632 }
633 candidates.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
634
635 let mut layer_overrides: Vec<std::collections::HashMap<usize, ExpertPlacement>> =
636 expert_bytes_per_layer
637 .iter()
638 .map(|_| std::collections::HashMap::new())
639 .collect();
640 let mut used: u64 = 0;
641 for (_, l, e) in candidates {
642 let size = expert_bytes_per_layer[l][e] as u64;
643 if size == 0 || used + size > vram_budget_bytes {
644 continue;
645 }
646 used += size;
647 layer_overrides[l].insert(e, ExpertPlacement::GpuDevice(0));
648 }
649
650 ResidencyPlan {
651 layer_plans: layer_overrides
652 .into_iter()
653 .map(|overrides| PlacementPlan {
654 default_placement: ExpertPlacement::Cpu,
655 overrides,
656 })
657 .collect(),
658 device_bytes_planned: used,
659 vram_budget_bytes,
660 }
661 }
662
663 pub fn placement_for(&self, expert_id: usize) -> ExpertPlacement {
664 self.overrides
665 .get(&expert_id)
666 .copied()
667 .unwrap_or(self.default_placement)
668 }
669}
670
671pub struct ResidencyPlan {
677 layer_plans: Vec<PlacementPlan>,
678 pub device_bytes_planned: u64,
681 pub vram_budget_bytes: u64,
684}
685
686impl ResidencyPlan {
687 pub fn layer_plan(&self, layer: usize) -> &PlacementPlan {
688 &self.layer_plans[layer]
689 }
690
691 pub fn n_layers(&self) -> usize {
692 self.layer_plans.len()
693 }
694}
695
696pub struct ExpertWeights {
701 pub gate: WeightMatrix,
702 pub up: WeightMatrix,
703 pub down: WeightMatrix,
704}
705
706#[derive(Debug, Clone, Default)]
718pub struct ExpertBias {
719 pub gate: Vec<f32>,
720 pub up: Vec<f32>,
721 pub down: Vec<f32>,
722}
723
724pub const SWIGLU_OAI_ALPHA: f32 = 1.702;
727pub const SWIGLU_OAI_LIMIT: f32 = 7.0;
729
730pub fn swiglu_oai(gate: &[f32], up: &[f32], alpha: f32, limit: f32) -> Vec<f32> {
747 debug_assert_eq!(gate.len(), up.len());
748 gate.iter()
749 .zip(up.iter())
750 .map(|(&g, &u)| {
751 let x = g.min(limit);
752 let y = u.clamp(-limit, limit);
753 let out_glu = x / (1.0 + (alpha * -x).exp());
754 out_glu * (y + 1.0)
755 })
756 .collect()
757}
758
759pub fn route_top_k_softmax_weight(logits: &[f32], k: usize) -> RoutingDecision {
771 let mut idx: Vec<usize> = (0..logits.len()).collect();
772 idx.sort_unstable_by(|&a, &b| logits[b].partial_cmp(&logits[a]).unwrap());
773 let top = &idx[..k.min(idx.len())];
774
775 let selected: Vec<f32> = top.iter().map(|&i| logits[i]).collect();
776 let max = selected.iter().copied().fold(f32::NEG_INFINITY, f32::max);
777 let exps: Vec<f32> = selected.iter().map(|&l| (l - max).exp()).collect();
778 let sum: f32 = exps.iter().sum();
779 let weights = if sum > 0.0 {
780 exps.iter().map(|&e| e / sum).collect()
781 } else {
782 exps
783 };
784
785 RoutingDecision {
786 expert_ids: top.to_vec(),
787 weights,
788 }
789}
790
791pub fn run_expert_oai(
798 hidden: &[f32],
799 expert: &ExpertWeights,
800 bias: &ExpertBias,
801 alpha: f32,
802 limit: f32,
803) -> Vec<f32> {
804 let mut gate = expert.gate.apply(hidden);
805 let mut up = expert.up.apply(hidden);
806 for (x, b) in gate.iter_mut().zip(bias.gate.iter()) {
807 *x += b;
808 }
809 for (x, b) in up.iter_mut().zip(bias.up.iter()) {
810 *x += b;
811 }
812 let activated = swiglu_oai(&gate, &up, alpha, limit);
813 let mut out = expert.down.apply(&activated);
814 for (x, b) in out.iter_mut().zip(bias.down.iter()) {
815 *x += b;
816 }
817 out
818}
819
820pub fn run_expert(hidden: &[f32], expert: &ExpertWeights) -> Vec<f32> {
822 #[cfg(any(feature = "cuda", feature = "metal"))]
823 {
824 if let Some(out) = ferrox_core::WeightMatrix::apply_gpu_dense_ffn_swiglu(
826 &expert.gate,
827 &expert.up,
828 &expert.down,
829 hidden,
830 ) {
831 return out;
832 }
833 }
834 #[cfg(any(feature = "cuda", feature = "metal"))]
835 {
836 if let Some(mut outs) =
838 ferrox_core::WeightMatrix::apply_gpu_multi(&[&expert.gate, &expert.up], hidden)
839 {
840 let up = outs.pop().unwrap();
841 let gate = outs.pop().unwrap();
842 let activated = swiglu(&gate, &up);
843 return expert.down.apply(&activated);
844 }
845 }
846 if ferrox_core::weight_matrix::cpu_int_dot_enabled() && hidden.len().is_multiple_of(32) {
849 let act = ferrox_quant::quantize_activations_q8(hidden);
850 let (g, u) = rayon::join(
855 || expert.gate.apply_cpu_q8(&act),
856 || expert.up.apply_cpu_q8(&act),
857 );
858 if let (Some(gate), Some(up)) = (g, u) {
859 let activated = swiglu(&gate, &up);
860 return expert.down.apply(&activated);
861 }
862 }
863 let (gate, up) = rayon::join(|| expert.gate.apply(hidden), || expert.up.apply(hidden));
864 let activated = swiglu(&gate, &up);
865 expert.down.apply(&activated)
866}
867
868#[cfg(any(feature = "cuda", feature = "metal"))]
889pub fn run_expert_placed(
890 hidden: &[f32],
891 expert: &ExpertWeights,
892 placement: ExpertPlacement,
893) -> Vec<f32> {
894 if matches!(placement, ExpertPlacement::GpuDevice(_)) {
895 #[cfg(any(feature = "cuda", feature = "metal"))]
896 {
897 if let Some(out) = ferrox_core::WeightMatrix::apply_gpu_dense_ffn_swiglu(
898 &expert.gate,
899 &expert.up,
900 &expert.down,
901 hidden,
902 ) {
903 return out;
904 }
905 }
906 #[cfg(any(feature = "cuda", feature = "metal"))]
907 {
908 if let Some(mut outs) =
909 ferrox_core::WeightMatrix::apply_gpu_multi(&[&expert.gate, &expert.up], hidden)
910 {
911 let up = outs.pop().unwrap();
912 let gate = outs.pop().unwrap();
913 let activated = swiglu(&gate, &up);
914 if let Some(down) = expert.down.apply_gpu(&activated) {
915 return down;
916 }
917 return expert.down.apply(&activated);
918 }
919 }
920 if let Some(gate) = expert.gate.apply_gpu(hidden) {
921 if let Some(up) = expert.up.apply_gpu(hidden) {
922 let activated = swiglu(&gate, &up);
923 if let Some(down) = expert.down.apply_gpu(&activated) {
924 return down;
925 }
926 }
927 }
928 }
929 run_expert(hidden, expert)
930}
931
932#[cfg(not(any(feature = "cuda", feature = "metal")))]
933pub fn run_expert_placed(
934 hidden: &[f32],
935 expert: &ExpertWeights,
936 _placement: ExpertPlacement,
937) -> Vec<f32> {
938 run_expert(hidden, expert)
939}
940
941pub fn combine_expert_outputs(
943 routed_outputs: &[(Vec<f32>, f32)],
944 shared_outputs: &[Vec<f32>],
945 hidden_dim: usize,
946) -> Vec<f32> {
947 let mut out = vec![0f32; hidden_dim];
948 for (expert_out, weight) in routed_outputs {
949 for (o, e) in out.iter_mut().zip(expert_out.iter()) {
950 *o += e * weight;
951 }
952 }
953 for shared_out in shared_outputs {
954 for (o, e) in out.iter_mut().zip(shared_out.iter()) {
955 *o += e;
956 }
957 }
958 out
959}
960
961#[cfg(test)]
962mod tests {
963 #[test]
969 fn global_budget_cannot_be_multiplied_across_layers() {
970 let n_layers = 10;
971 let sizes: Vec<Vec<usize>> = (0..n_layers).map(|_| vec![100usize; 4]).collect();
972 let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 250);
973
974 let total_placed: usize = (0..n_layers)
975 .map(|l| {
976 (0..4)
977 .filter(|&e| plan.layer_plan(l).placement_for(e) != ExpertPlacement::Cpu)
978 .count()
979 })
980 .sum();
981 assert_eq!(
982 total_placed, 2,
983 "250 bytes fits exactly 2 x 100-byte experts, globally"
984 );
985 assert_eq!(plan.device_bytes_planned, 200);
986 assert!(plan.device_bytes_planned <= plan.vram_budget_bytes);
987
988 let per_layer_total: usize = (0..n_layers)
991 .map(|_| {
992 let p = PlacementPlan::from_budget(&[100; 4], None, 250);
993 (0..4)
994 .filter(|&e| p.placement_for(e) != ExpertPlacement::Cpu)
995 .count()
996 })
997 .sum();
998 assert_eq!(per_layer_total, 20, "per-layer planning overcommits 10x");
999 }
1000
1001 #[test]
1005 fn global_planning_prioritizes_hotness_across_layers() {
1006 let sizes: Vec<Vec<usize>> = (0..3).map(|_| vec![100usize; 2]).collect();
1007 let mut counts: Vec<Vec<u64>> = (0..3).map(|_| vec![0u64; 2]).collect();
1008 counts[2][1] = 50; counts[0][0] = 10;
1010 let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, Some(&counts), 200);
1011
1012 assert_eq!(
1013 plan.layer_plan(2).placement_for(1),
1014 ExpertPlacement::GpuDevice(0),
1015 "hottest expert (layer 2) must win a slot"
1016 );
1017 assert_eq!(
1018 plan.layer_plan(0).placement_for(0),
1019 ExpertPlacement::GpuDevice(0),
1020 "second-hottest expert (layer 0) takes the remaining slot"
1021 );
1022 assert_eq!(plan.device_bytes_planned, 200);
1023 }
1024
1025 #[test]
1028 fn global_planning_handles_zero_budget_and_dense_layers() {
1029 let sizes = vec![Vec::new(), vec![100usize; 3], Vec::new()];
1030 let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 0);
1031 assert_eq!(plan.device_bytes_planned, 0);
1032 assert_eq!(plan.n_layers(), 3);
1033 for e in 0..3 {
1034 assert_eq!(plan.layer_plan(1).placement_for(e), ExpertPlacement::Cpu);
1035 }
1036 }
1037
1038 use super::*;
1039
1040 #[test]
1041 fn top_k_selects_highest_scoring_experts() {
1042 let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1043 let decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1044 assert_eq!(decision.expert_ids, vec![1, 3]);
1045 let sum: f32 = decision.weights.iter().sum();
1046 assert!((sum - 1.0).abs() < 1e-5);
1047 assert!(decision.weights[0] > decision.weights[1]);
1048 }
1049
1050 #[test]
1051 fn top_k_weights_always_sum_to_one_regardless_of_k() {
1052 let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1053 for k in 1..=8 {
1054 let decision = route_top_k(&logits, k, GatingFunction::Softmax, true);
1055 let sum: f32 = decision.weights.iter().sum();
1056 assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
1057 }
1058 }
1059
1060 #[test]
1071 fn norm_topk_prob_false_uses_raw_full_softmax_probability_not_renormalized() {
1072 let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1073 let decision = route_top_k(&logits, 3, GatingFunction::Softmax, false);
1074
1075 assert_eq!(decision.expert_ids, vec![7, 6, 5]);
1076
1077 let expected = [0.6323223_f32, 0.2326232, 0.0855683];
1078 for (got, want) in decision.weights.iter().zip(expected.iter()) {
1079 assert!((got - want).abs() < 1e-4, "got={got} want={want}");
1080 }
1081
1082 let sum: f32 = decision.weights.iter().sum();
1083 assert!(
1084 (sum - 0.9505138).abs() < 1e-4,
1085 "raw top-3 probability mass should be < 1 (it's a subset of a full 8-way softmax), got sum={sum}"
1086 );
1087
1088 let normalized = route_top_k(&logits, 3, GatingFunction::Softmax, true);
1093 assert_eq!(normalized.expert_ids, decision.expert_ids);
1094 for (raw, norm) in decision.weights.iter().zip(normalized.weights.iter()) {
1095 assert!(
1096 (raw / sum - norm).abs() < 1e-4,
1097 "raw={raw} sum={sum} normalized={norm}"
1098 );
1099 }
1100 }
1101
1102 #[test]
1103 fn sigmoid_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1104 let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1109 let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1110 let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1111 assert_eq!(softmax_decision.expert_ids, sigmoid_decision.expert_ids);
1112 }
1113
1114 #[test]
1115 fn sigmoid_gating_weights_sum_to_one() {
1116 let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1117 for k in 1..=8 {
1118 let decision = route_top_k(&logits, k, GatingFunction::Sigmoid, true);
1119 let sum: f32 = decision.weights.iter().sum();
1120 assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
1121 }
1122 }
1123
1124 #[test]
1125 fn bias_only_affects_selection_not_the_final_weight_value() {
1126 let logits = vec![0.1, 2.0];
1132 let bias = vec![10.0, 0.0];
1133 let decision = route_top_k_sigmoid_with_bias(&logits, &bias, 1, true, 1.0);
1134 assert_eq!(decision.expert_ids, vec![0]);
1135 assert!((decision.weights[0] - sigmoid(0.1)).abs() < 1e-5);
1138 }
1139
1140 #[test]
1141 fn without_bias_selection_falls_back_to_plain_sigmoid_top_k() {
1142 let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1143 let zero_bias = vec![0.0; logits.len()];
1144 let biased = route_top_k_sigmoid_with_bias(&logits, &zero_bias, 3, true, 1.0);
1145 let plain = route_top_k(&logits, 3, GatingFunction::Sigmoid, true);
1146 assert_eq!(biased.expert_ids, plain.expert_ids);
1147 for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1148 assert!((a - b).abs() < 1e-6);
1149 }
1150 }
1151
1152 #[test]
1153 fn scaling_factor_multiplies_every_weight() {
1154 let logits = vec![1.0, 2.0, 3.0];
1155 let bias = vec![0.0; 3];
1156 let unscaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 1.0);
1157 let scaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 2.5);
1158 for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1159 assert!((u * 2.5 - s).abs() < 1e-5);
1160 }
1161 }
1162
1163 #[test]
1164 fn sigmoid_and_softmax_weights_differ_for_the_same_logits() {
1165 let logits = vec![3.0, 1.0, -2.0, 0.5];
1173 let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1174 let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1175 assert!(
1176 (softmax_decision.weights[0] - sigmoid_decision.weights[0]).abs() > 1e-3,
1177 "softmax and sigmoid gating should generally produce different weight splits for the same logits"
1178 );
1179 }
1180
1181 #[test]
1182 fn sqrt_softplus_matches_hand_computed_values_at_zero_and_positive_logit() {
1183 assert!((sqrt_softplus(0.0) - 2.0_f32.ln().sqrt()).abs() < 1e-6);
1187 assert!((sqrt_softplus(20.0) - 20.0_f32.sqrt()).abs() < 1e-3);
1189 }
1190
1191 #[test]
1192 fn grouped_routing_picks_within_each_group_then_global_top_k() {
1193 let logits = vec![0.1, 5.0, 0.2, 4.0];
1196 let d = route_top_k_grouped(&logits, 2, 1, 2, GatingFunction::Softmax, true);
1197 assert_eq!(d.expert_ids.len(), 2);
1198 assert!(d.expert_ids.contains(&1));
1199 assert!(d.expert_ids.contains(&3));
1200 let sum: f32 = d.weights.iter().sum();
1201 assert!((sum - 1.0).abs() < 1e-4);
1202 }
1203
1204 #[test]
1205 fn sqrtsoftplus_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1206 let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1211 let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1212 let sqrtsoftplus_decision = route_top_k(&logits, 2, GatingFunction::SqrtSoftplus, true);
1213 assert_eq!(
1214 softmax_decision.expert_ids,
1215 sqrtsoftplus_decision.expert_ids
1216 );
1217 }
1218
1219 #[test]
1220 fn sqrtsoftplus_weights_sum_to_one_when_normalized() {
1221 let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1222 for k in 1..=8 {
1223 let decision = route_top_k(&logits, k, GatingFunction::SqrtSoftplus, true);
1224 let sum: f32 = decision.weights.iter().sum();
1225 assert!((sum - 1.0).abs() < 1e-4, "k={k} sum={sum}");
1226 }
1227 }
1228
1229 #[test]
1230 fn sqrtsoftplus_bias_only_affects_selection_not_the_final_weight_value() {
1231 let logits = vec![0.1, 2.0];
1235 let bias = vec![10.0, 0.0];
1236 let decision = route_top_k_sqrtsoftplus_with_bias(&logits, &bias, 1, true, 1.0);
1237 assert_eq!(decision.expert_ids, vec![0]);
1238 assert!((decision.weights[0] - sqrt_softplus(0.1)).abs() < 1e-5);
1239 }
1240
1241 #[test]
1242 fn sqrtsoftplus_without_bias_selection_falls_back_to_plain_top_k() {
1243 let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1244 let zero_bias = vec![0.0; logits.len()];
1245 let biased = route_top_k_sqrtsoftplus_with_bias(&logits, &zero_bias, 3, true, 1.0);
1246 let plain = route_top_k(&logits, 3, GatingFunction::SqrtSoftplus, true);
1247 assert_eq!(biased.expert_ids, plain.expert_ids);
1248 for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1249 assert!((a - b).abs() < 1e-6);
1250 }
1251 }
1252
1253 #[test]
1254 fn hash_routing_uses_the_fixed_table_ids_regardless_of_logit_ranking() {
1255 let logits = vec![100.0, 1.0, 0.5, -3.0];
1260 let hash_expert_ids = vec![2usize, 1usize];
1261 let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1262 assert_eq!(decision.expert_ids, vec![2, 1]);
1263 }
1264
1265 #[test]
1266 fn hash_routing_weights_come_from_the_real_router_logits_not_a_fixed_split() {
1267 let logits = vec![-5.0, 0.1, 3.0, -5.0];
1273 let hash_expert_ids = vec![2usize, 1usize];
1274 let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1275 assert!(decision.weights[0] > decision.weights[1]);
1276 let sum: f32 = decision.weights.iter().sum();
1277 assert!((sum - 1.0).abs() < 1e-5);
1278 let expected0 = sqrt_softplus(3.0) / (sqrt_softplus(3.0) + sqrt_softplus(0.1));
1279 assert!((decision.weights[0] - expected0).abs() < 1e-5);
1280 }
1281
1282 #[test]
1283 fn hash_routing_scaling_factor_multiplies_every_weight() {
1284 let logits = vec![1.0, 2.0, 3.0];
1285 let hash_expert_ids = vec![0usize, 2usize];
1286 let unscaled = route_hash(&hash_expert_ids, &logits, true, 1.0);
1287 let scaled = route_hash(&hash_expert_ids, &logits, true, 2.5);
1288 for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1289 assert!((u * 2.5 - s).abs() < 1e-5);
1290 }
1291 }
1292
1293 #[test]
1294 fn placement_plan_defaults_to_cpu_for_unlisted_experts() {
1295 let plan = PlacementPlan::hot_experts_on_gpu(256, 8);
1296 assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1297 assert_eq!(plan.placement_for(7), ExpertPlacement::GpuDevice(0));
1298 assert_eq!(plan.placement_for(8), ExpertPlacement::Cpu);
1299 assert_eq!(plan.placement_for(255), ExpertPlacement::Cpu);
1300 }
1301
1302 #[test]
1303 fn all_cpu_plan_never_returns_gpu() {
1304 let plan = PlacementPlan::all_cpu(64);
1305 for i in 0..64 {
1306 assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1307 }
1308 }
1309
1310 #[test]
1311 fn from_budget_fits_as_many_experts_as_the_vram_budget_allows() {
1312 let sizes = vec![100usize, 100, 100, 100];
1314 let plan = PlacementPlan::from_budget(&sizes, None, 250);
1315 let on_gpu = (0..4)
1316 .filter(|&i| plan.placement_for(i) == ExpertPlacement::GpuDevice(0))
1317 .count();
1318 assert_eq!(on_gpu, 2);
1319 }
1320
1321 #[test]
1322 fn from_budget_prioritizes_the_most_frequently_activated_experts() {
1323 let sizes = vec![50usize, 50, 50, 50];
1326 let counts = vec![1u64, 2, 100, 3];
1327 let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 50);
1329 assert_eq!(
1330 plan.placement_for(2),
1331 ExpertPlacement::GpuDevice(0),
1332 "the hottest expert (index 2) must be the one placed on GPU"
1333 );
1334 assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1335 assert_eq!(plan.placement_for(1), ExpertPlacement::Cpu);
1336 assert_eq!(plan.placement_for(3), ExpertPlacement::Cpu);
1337 }
1338
1339 #[test]
1340 fn from_budget_skips_an_expert_that_does_not_fit_and_tries_the_next() {
1341 let sizes = vec![200usize, 60, 60];
1344 let plan = PlacementPlan::from_budget(&sizes, None, 120);
1345 assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1346 assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1347 assert_eq!(plan.placement_for(2), ExpertPlacement::GpuDevice(0));
1348 }
1349
1350 #[test]
1351 fn from_budget_with_zero_vram_places_nothing_on_gpu() {
1352 let sizes = vec![10usize, 20, 30];
1353 let plan = PlacementPlan::from_budget(&sizes, None, 0);
1354 for i in 0..3 {
1355 assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1356 }
1357 }
1358
1359 #[test]
1360 fn from_budget_ignores_mismatched_activation_counts_length_rather_than_panicking() {
1361 let sizes = vec![10usize, 10];
1362 let counts = vec![1u64]; let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 100);
1364 assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1366 assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1367 }
1368
1369 #[test]
1370 fn combine_expert_outputs_weights_routed_and_adds_shared() {
1371 let routed = vec![(vec![2.0, 2.0], 0.5), (vec![4.0, 4.0], 0.5)];
1372 let shared = vec![vec![1.0, 1.0]];
1373 let out = combine_expert_outputs(&routed, &shared, 2);
1374 assert_eq!(out, vec![4.0, 4.0]);
1375 }
1376
1377 #[test]
1378 fn run_expert_produces_correct_output_dimension() {
1379 use ferrox_core::tensor::Tensor;
1380 let hidden_dim = 4;
1381 let ffn_dim = 3;
1382 let expert = ExpertWeights {
1383 gate: WeightMatrix::F32(Tensor::new(
1384 vec![0.1; ffn_dim * hidden_dim],
1385 vec![ffn_dim, hidden_dim],
1386 )),
1387 up: WeightMatrix::F32(Tensor::new(
1388 vec![0.2; ffn_dim * hidden_dim],
1389 vec![ffn_dim, hidden_dim],
1390 )),
1391 down: WeightMatrix::F32(Tensor::new(
1392 vec![0.3; hidden_dim * ffn_dim],
1393 vec![hidden_dim, ffn_dim],
1394 )),
1395 };
1396 let hidden = vec![1.0, -1.0, 0.5, 0.5];
1397 let out = run_expert(&hidden, &expert);
1398 assert_eq!(out.len(), hidden_dim);
1399 assert!(out.iter().all(|v| v.is_finite()));
1400 }
1401
1402 #[test]
1409 fn run_expert_placed_matches_run_expert_when_nothing_is_gpu_dispatched() {
1410 use ferrox_core::tensor::Tensor;
1411 let hidden_dim = 4;
1412 let ffn_dim = 3;
1413 let expert = ExpertWeights {
1414 gate: WeightMatrix::F32(Tensor::new(
1415 vec![0.1; ffn_dim * hidden_dim],
1416 vec![ffn_dim, hidden_dim],
1417 )),
1418 up: WeightMatrix::F32(Tensor::new(
1419 vec![0.2; ffn_dim * hidden_dim],
1420 vec![ffn_dim, hidden_dim],
1421 )),
1422 down: WeightMatrix::F32(Tensor::new(
1423 vec![0.3; hidden_dim * ffn_dim],
1424 vec![hidden_dim, ffn_dim],
1425 )),
1426 };
1427 let hidden = vec![1.0, -1.0, 0.5, 0.5];
1428 let expected = run_expert(&hidden, &expert);
1429
1430 assert_eq!(
1431 run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu),
1432 expected
1433 );
1434 assert_eq!(
1435 run_expert_placed(&hidden, &expert, ExpertPlacement::GpuDevice(0)),
1436 expected,
1437 "F32 has no GPU kernel, so GpuDevice placement must still fall through to the CPU path"
1438 );
1439 }
1440
1441 #[cfg(any(feature = "cuda", feature = "metal"))]
1442 #[test]
1443 #[ignore = "requires real GPU hardware (CUDA or Metal) -- run with --ignored"]
1444 fn run_expert_placed_on_gpu_matches_cpu_for_a_real_quantized_expert() {
1445 let hidden_dim = 32;
1446 let ffn_dim = 32; let make_row = |cols: usize, seed: f32| -> Vec<f32> {
1448 (0..cols)
1449 .map(|i| ((i as f32) - (cols as f32) / 2.0) * 0.01 * seed)
1450 .collect()
1451 };
1452 let quantize_matrix = |rows: usize, cols: usize, seed: f32| {
1453 let mut packed = Vec::new();
1454 for r in 0..rows {
1455 packed.extend(ferrox_quant::quantize_q8_0(&make_row(
1456 cols,
1457 seed + r as f32,
1458 )));
1459 }
1460 WeightMatrix::Quantized {
1461 data: ferrox_core::weight_matrix::WeightBytes::Owned(packed),
1462 rows,
1463 cols,
1464 kind: ferrox_core::weight_matrix::QuantKind::Q8_0,
1465 }
1466 };
1467 let expert = ExpertWeights {
1468 gate: quantize_matrix(ffn_dim, hidden_dim, 1.0),
1469 up: quantize_matrix(ffn_dim, hidden_dim, 2.0),
1470 down: quantize_matrix(hidden_dim, ffn_dim, 3.0),
1471 };
1472 let hidden = make_row(hidden_dim, 0.5);
1473
1474 let cpu = run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu);
1475 let gpu = run_expert_placed(&hidden, &expert, ExpertPlacement::GpuDevice(0));
1476 assert_eq!(cpu.len(), gpu.len());
1477 for (c, g) in cpu.iter().zip(gpu.iter()) {
1478 assert!((c - g).abs() < 1e-1, "cpu={c} gpu={g}");
1479 }
1480 }
1481}