Skip to main content

ferrox_moe/
lib.rs

1//! ferrox-moe: sparse Mixture-of-Experts routing, a shared-expert path,
2//! and a CPU/GPU expert-placement scheduler.
3//!
4//! The placement design mirrors the pattern popularized by ik_llama.cpp
5//! (tensor-name-regex overrides deciding which experts live on GPU vs
6//! CPU RAM, e.g. `--cpu-moe` / `-ncmoe`) and by llama.cpp's
7//! layer-split conventions, adapted here to a config-driven Rust
8//! scheduler rather than copied CLI-flag parsing code. See
9//! docs/THIRD_PARTY_NOTICES.md.
10
11use ferrox_core::matmul::swiglu;
12use ferrox_core::weight_matrix::WeightMatrix;
13
14/// Where a given expert's weights currently live. `GpuDevice`-placed
15/// experts only actually execute on a GPU under `--features cuda` and/or
16/// `--features metal` (see `run_expert_placed`); without a GPU feature
17/// the CPU path executes regardless. Device id is meaningful for CUDA;
18/// Metal currently uses the system default device and ignores the id.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ExpertPlacement {
21    Cpu,
22    GpuDevice(u32),
23}
24
25/// Static per-layer MoE configuration. One of these is built per layer
26/// from a ModelConfig preset (ferrox-models).
27#[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    /// Which function converts router logits into selection scores.
35    /// See `GatingFunction`'s doc comment: this is not a stylistic
36    /// choice, it's an evidence-backed architectural detail that
37    /// differs by model family.
38    pub gating: GatingFunction,
39    /// Only meaningful for `GatingFunction::Softmax` (the `Sigmoid` path
40    /// has its own separate, always-renormalized convention -- see
41    /// `route_top_k_sigmoid`'s doc comment). Whether the top-k selected
42    /// experts' softmax weights get renormalized to sum to one after
43    /// selection. Mixtral's real routing does this
44    /// (`routing_weights /= routing_weights.sum(...)` in its reference
45    /// implementation) and it's the right default for any architecture
46    /// that doesn't document otherwise -- but it is a real, per-model
47    /// choice, not a law of nature: OLMoE's real `config.json` sets
48    /// `norm_topk_prob: false`, confirmed against
49    /// `OlmoeTopKRouter.forward` in
50    /// `transformers/models/olmoe/modeling_olmoe.py` (`router_top_value
51    /// /= router_top_value.sum(...)` only runs `if self.norm_topk_prob`)
52    /// and against llama.cpp's real hardcoded `build_moe_ffn(..., false,
53    /// ..., LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX, ...)` call for
54    /// `LLM_ARCH_OLMOE` in `src/models/olmoe.cpp` (GGUF carries no
55    /// metadata key for this -- it's an architecture-hardcoded fact in
56    /// the reference implementation, not something read from the file).
57    /// Getting this wrong silently produces a real, wrong generation:
58    /// caught by comparing ferrox's real OLMoE output directly against
59    /// llama.cpp loading the identical GGUF file (llama.cpp answered
60    /// "Paris" for "the capital of France is"; ferrox, with this bug,
61    /// answered something else entirely).
62    pub norm_topk_prob: bool,
63    /// Optional Mixtral-style expert grouping (`expert_group_count` /
64    /// `expert_group_used_count` in GGUF). `None` means flat top-k over
65    /// all experts (Llama / OLMoE / Qwen2-MoE). Grouped routing is
66    /// selected at load time; the hot path reads these fields as data.
67    pub expert_group_count: Option<usize>,
68    pub expert_group_used_count: Option<usize>,
69}
70
71/// Router output for one token: which experts fire, and their
72/// (already-normalized) combination weights.
73#[derive(Debug, Clone)]
74pub struct RoutingDecision {
75    pub expert_ids: Vec<usize>,
76    pub weights: Vec<f32>,
77}
78
79/// Top-k softmax router over per-expert logits, as used by DeepSeek /
80/// GLM / Kimi-style MoE layers (a linear gate scores every expert, top-k
81/// experts are kept, and their scores are renormalized to sum to one).
82/// Which function converts a router's raw per-expert logits into
83/// selection scores, before top-k selection and normalization.
84///
85/// This distinction is not cosmetic: reading ik_llama.cpp's actual
86/// GGUF-loading source (`llama-hparams.cpp`) directly showed that
87/// DeepSeek-2/3-family models (`LLM_ARCH_DEEPSEEK2`) and newer
88/// GLM-MoE-family models (`LLM_ARCH_GLM4_MOE`) both default to
89/// **sigmoid** gating with post-selection score normalization, not
90/// softmax -- matching DeepSeek-V3's own published technical report,
91/// which documents computing per-expert affinity via sigmoid and then
92/// normalizing the *selected* experts' scores to sum to one. Only
93/// older DeepSeek-2.0/2.5-era models default to softmax. Using softmax
94/// unconditionally, which ferrox did before this was found, would
95/// silently produce wrong routing decisions for any model in the
96/// DeepSeek-3/GLM4-MoE lineage -- which very plausibly includes
97/// DeepSeek V4 Pro and GLM-5.2, both presumed continuations of these
98/// architecture families (see docs/MODELS.md for the exact
99/// confidence level on this).
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum GatingFunction {
102    /// exp(logit) / sum(exp(selected logits)) -- the older convention.
103    Softmax,
104    /// sigmoid(logit) for scoring and top-k selection, then the
105    /// selected sigmoid scores are renormalized to sum to one -- the
106    /// DeepSeek-V3 / GLM4-MoE convention.
107    Sigmoid,
108    /// `sqrt(softplus(logit))`, i.e. `sqrt(ln(1 + exp(logit)))` --
109    /// DeepSeek V4's real MoE scoring function. Confirmed two ways from
110    /// llama.cpp PR #24162 (`src/models/deepseek4.cpp`): (1)
111    /// `load_arch_hparams` hard-throws
112    /// (`"DeepSeek-V4 loader currently expects sqrtsoftplus MoE
113    /// scoring"`) unless the GGUF's `expert_gating_func` metadata is
114    /// exactly `LLAMA_EXPERT_GATING_FUNC_TYPE_SQRT_SOFTPLUS`; (2)
115    /// `llm_graph_context::build_moe_ffn`'s real scoring switch computes
116    /// `probs = ggml_sqrt(ctx0, ggml_softplus(ctx0, logits))` for that
117    /// enum case. This supersedes the earlier sigmoid guess this crate
118    /// carried (inherited from the DeepSeek-2/3 lineage), which the real
119    /// loader source shows is wrong for V4 specifically.
120    SqrtSoftplus,
121}
122
123fn sigmoid(x: f32) -> f32 {
124    1.0 / (1.0 + (-x).exp())
125}
126
127/// `sqrt(softplus(x))` = `sqrt(ln(1 + exp(x)))`, computed the numerically
128/// stable way (`softplus(x) = max(x, 0) + ln(1 + exp(-|x|))`, avoiding
129/// overflow in `exp(x)` for large positive `x`) -- DeepSeek V4's real
130/// per-expert MoE scoring function, see [`GatingFunction::SqrtSoftplus`].
131fn sqrt_softplus(x: f32) -> f32 {
132    let softplus = x.max(0.0) + (-x.abs()).exp().ln_1p();
133    softplus.sqrt()
134}
135
136/// Top-k router over per-expert logits, dispatching to softmax, sigmoid,
137/// or sqrt-softplus scoring per `gating`. See `GatingFunction`'s doc
138/// comment for why this distinction is real and evidence-backed, not a
139/// stylistic choice.
140pub 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
153/// Mixtral-/DeepSeek-style grouped top-k: split `logits` into
154/// `n_groups` contiguous expert groups, pick the `k_per_group` highest
155/// within each group (by the same score as flat [`route_top_k`]), then
156/// optionally keep only the global top-`total_k` across groups.
157///
158/// When `n_groups <= 1` this is identical to flat routing with `k =
159/// total_k`. Used when GGUF carries `expert_group_count` /
160/// `expert_group_used_count`.
161pub 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
199/// sqrt-softplus top-k routing, DeepSeek V4's real non-hash-routed MoE
200/// layers (`ffn_exp_probs_b` present, i.e. every layer at or past
201/// `hash_layer_count`): score every expert with `sqrt(softplus(logit))`
202/// (independently per expert, like [`route_top_k_sigmoid`]'s sigmoid --
203/// not a joint softmax distribution), pick the top-k by that score, then
204/// (if `norm_topk_prob`) renormalize the selected scores to sum to one.
205/// See [`GatingFunction::SqrtSoftplus`] for the real citation. DeepSeek
206/// V4's real non-hash MoE layers additionally add a learned bias
207/// (`ffn_exp_probs_b`) to the *selection* score only -- see
208/// [`route_top_k_sqrtsoftplus_with_bias`] for that variant; this plain
209/// version is the bias-free building block, analogous to
210/// [`route_top_k_sigmoid`] vs [`route_top_k_sigmoid_with_bias`].
211pub 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
232/// sqrt-softplus top-k routing with a selection-only bias term, mirroring
233/// [`route_top_k_sigmoid_with_bias`] but for DeepSeek V4's real
234/// [`GatingFunction::SqrtSoftplus`] scoring: selection uses
235/// `sqrt(softplus(logit)) + bias[expert]`, but each selected expert's
236/// combine *weight* uses the raw, unbiased `sqrt(softplus(logit))`.
237/// Weights are renormalized to sum to one (if `k>1` and `renormalize`),
238/// then multiplied by `scaling_factor` (DeepSeek V4's real
239/// `expert_weights_norm`/`expert_weights_scale` hparams, read directly in
240/// `load_arch_hparams`).
241pub 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
277/// DeepSeek V4's real hash-based first-layer MoE routing: for the first
278/// `hash_layer_count` layers, which experts fire is *not* learned
279/// top-k/sigmoid/sqrt-softplus selection at all -- it's a direct
280/// token-id-to-expert-id lookup table (`ffn_gate_tid2eid`, GGUF shape
281/// `[n_expert_used, n_vocab]`; real per-layer dispatch in
282/// `src/models/deepseek4.cpp`: `selected_experts =
283/// ggml_get_rows(ctx0, layer.ffn_gate_tid2eid, res->t_inp_tokens)`, with
284/// `exp_probs_b` (the selection-bias tensor) set to `nullptr` for these
285/// layers specifically because there is no learned selection to bias --
286/// the expert ids are fixed by the table, not chosen by a score).
287///
288/// The selected experts' *combine weights*, however, are **not** fixed by
289/// the table -- `build_moe_ffn` still computes `sqrt(softplus(logits))`
290/// from the real per-token router logits (`ffn_gate_inp`) and gathers
291/// those scores at the table-provided expert ids, exactly like the
292/// weight half of [`route_top_k_sqrtsoftplus_with_bias`] (just with a
293/// fixed selection instead of a chosen top-k). `hash_expert_ids` must
294/// have exactly the model's real `n_expert_used` length (one lookup-table
295/// row for this token's id); `logits` is the full `[n_expert]`-wide
296/// router output for this token.
297pub 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
323/// softmax-then-top-k routing (the Mixtral/older-DeepSeek convention:
324/// softmax over *every* expert first, then select the top-k of those
325/// probabilities -- not "top-k logits, then softmax just those"; the two
326/// are mathematically different since softmax's denominator would only
327/// sum the selected subset in the latter). `norm_topk_prob` controls
328/// whether the selected top-k probabilities are then renormalized to sum
329/// to one -- true is the right default for any architecture that doesn't
330/// document otherwise (Mixtral does this), but it is a real per-model
331/// choice: see `MoeLayerConfig::norm_topk_prob`'s doc comment for why
332/// OLMoE specifically needs `false`.
333pub 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
357/// sigmoid-then-renormalize top-k routing: score every expert with
358/// `sigmoid(logit)` (independently per expert, not a joint softmax
359/// distribution), pick the top-k by that score, then renormalize just
360/// the selected experts' sigmoid scores to sum to one. This is the
361/// DeepSeek-V3 / GLM4-MoE convention found in ik_llama.cpp's real GGUF
362/// hparams-loading source.
363pub 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        // Degenerate case (all selected scores are exactly zero,
375        // essentially never in practice for a real trained router):
376        // fall back to a uniform split rather than dividing by zero.
377        vec![1.0 / top.len() as f32; top.len()]
378    };
379
380    RoutingDecision {
381        expert_ids: top.to_vec(),
382        weights,
383    }
384}
385
386/// Sigmoid top-k routing with a real "aux-loss-free" per-expert bias
387/// term added *only* for top-k selection (`topk_method: "noaux_tc"` in
388/// Kimi K3's real `config.json`, `KimiMoEGate.forward` in
389/// `modeling_kimi_linear.py`, adapted from DeepSeek-V3's own MoE gate --
390/// the same convention, not Kimi-specific): the selection scores are
391/// `sigmoid(logit) + bias[expert]`, but the *weight* each selected
392/// expert's output gets multiplied by uses the raw, unbiased
393/// `sigmoid(logit)` -- getting this backwards (biasing the weight
394/// itself, not just the selection) would silently skew routed-expert
395/// contribution away from what the router actually learned. Weights are
396/// renormalized to sum to 1 (if `k>1`) then multiplied by
397/// `scaling_factor` (Kimi K3's `routed_scaling_factor`, 1.0 in its real
398/// config, i.e. a no-op there, but a real multiplier for any other
399/// model using this same convention with a different value).
400pub 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/// A CPU/GPU placement plan for a layer's experts.
437#[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    /// Index-based placeholder: puts the first `n_gpu_resident`
452    /// experts on GPU regardless of their actual size or how often
453    /// they're activated. Kept only as a trivial fallback for callers
454    /// with no real budget/hotness data at all (e.g. `ferrox smoke`'s
455    /// synthetic-weight demo); real deployments should use
456    /// `from_budget` instead, which places by measured VRAM budget and
457    /// observed expert hotness rather than by index.
458    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    /// Builds a placement plan from a real VRAM budget and each
470    /// expert's actual resident byte size (e.g. summed
471    /// `WeightMatrix::resident_bytes()` across an expert's gate/up/down
472    /// matrices), following ik_llama.cpp's `--cpu-moe`/`--override-tensor`
473    /// pattern of deciding CPU-vs-GPU per tensor rather than by a fixed
474    /// index cutoff.
475    ///
476    /// `activation_counts`, if given (one count per expert, e.g.
477    /// accumulated from `RoutingDecision::expert_ids` over a real or
478    /// representative workload), places the *most frequently activated*
479    /// experts on GPU first -- the actual point of expert offload,
480    /// since keeping a rarely-used expert resident in VRAM wastes the
481    /// budget a hot expert could have used instead. Without observed
482    /// counts, falls back to a documented, deterministic policy (index
483    /// order) rather than guessing at hotness.
484    ///
485    /// Greedy, not globally optimal (a smaller-but-colder expert can
486    /// still be skipped in favor of trying the next candidate once a
487    /// larger higher-priority expert doesn't fit) -- optimal knapsack
488    /// packing is not worth the complexity here, and greedy-by-priority
489    /// is the same approach real offload tooling uses.
490    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    /// A device-placement plan for EVERY layer's routed experts against
521    /// ONE shared VRAM budget -- the fix for the real accounting bug
522    /// where each layer independently called `from_budget` with the
523    /// full budget, so a model with N layers would plan N x the
524    /// configured bytes of GPU residency. All `(layer, expert)`
525    /// candidates compete in one global priority order (hottest first,
526    /// ties broken by layer then expert index for determinism), and a
527    /// candidate is only placed on the device if the *global* running
528    /// total still fits.
529    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(); // (count, layer, expert)
535        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
583/// The output of `PlacementPlan::plan_layers_against_global_budget`:
584/// one per-layer `PlacementPlan` view over a single, globally-accounted
585/// device budget. `device_bytes_planned <= vram_budget_bytes` holds by
586/// construction across ALL layers combined -- the property the old
587/// per-layer planning could not provide.
588pub struct ResidencyPlan {
589    layer_plans: Vec<PlacementPlan>,
590    /// Total bytes this plan places on the device, summed across every
591    /// layer.
592    pub device_bytes_planned: u64,
593    /// The single budget every layer's placements were accounted
594    /// against.
595    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
608/// One expert's gate/up/down weight matrices. Each may be plain f32
609/// (synthetic/test weights) or still-quantized bytes loaded straight
610/// from a GGUF file (real checkpoints) -- `WeightMatrix::apply`
611/// dispatches to the right kernel either way.
612pub struct ExpertWeights {
613    pub gate: WeightMatrix,
614    pub up: WeightMatrix,
615    pub down: WeightMatrix,
616}
617
618/// One expert's gate/up/down bias vectors.
619///
620/// Kept separate from [`ExpertWeights`] because only the gpt-oss family
621/// ships them: every other MoE checkpoint ferrox loads has bias-free
622/// experts, and threading three `Option`s through the thirty
623/// `ExpertWeights` construction sites would pay for a feature one
624/// architecture uses.
625///
626/// Lengths are `expert_ffn_dim` for `gate`/`up` and `hidden_dim` for
627/// `down`, matching llama.cpp's `ffn_{gate,up}_exps_b` `{n_ff_exp,
628/// n_expert}` and `ffn_down_exps_b` `{n_embd, n_expert}`.
629#[derive(Debug, Clone, Default)]
630pub struct ExpertBias {
631    pub gate: Vec<f32>,
632    pub up: Vec<f32>,
633    pub down: Vec<f32>,
634}
635
636/// gpt-oss's SwiGLU sigmoid steepness (`llama-graph.cpp`,
637/// `LLM_FFN_SWIGLU_OAI_MOE`: `constexpr float alpha = 1.702f`).
638pub const SWIGLU_OAI_ALPHA: f32 = 1.702;
639/// gpt-oss's SwiGLU clamp (`constexpr float limit = 7.0f`, same site).
640pub const SWIGLU_OAI_LIMIT: f32 = 7.0;
641
642/// gpt-oss's clamped SwiGLU.
643///
644/// Transcribed from `ggml/src/ggml-cpu/ops.cpp`
645/// `ggml_compute_forward_swiglu_oai_f32`:
646///
647/// ```text
648/// x = min(gate, limit);
649/// y = clamp(up, -limit, limit);
650/// out_glu = x / (1 + expf(alpha * -x));
651/// dst = out_glu * (y + 1);
652/// ```
653///
654/// Three things differ from ordinary SwiGLU and all three matter: the
655/// gate is clamped from *above only*, the sigmoid is scaled by `alpha`
656/// rather than being plain `silu`, and the up branch carries a `+1`
657/// offset so a zero `up` passes the gate through instead of killing it.
658pub 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
671/// gpt-oss routing: pick the top-`k` experts by their **raw** router
672/// logits, then softmax over just those `k`.
673///
674/// This is llama.cpp's `LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX_WEIGHT`
675/// (`llama-graph.cpp::build_moe_ffn`), and it is *not* the same as
676/// [`route_top_k_softmax`]: there the softmax runs over all `n_expert`
677/// logits before selection, so the surviving weights are a slice of the
678/// full distribution and sum to less than one; here the normalization
679/// happens after selection, so the `k` weights sum to exactly one.
680/// Feeding a gpt-oss checkpoint through the ordinary softmax gating
681/// picks the same experts but weights them wrong.
682pub 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
703/// [`run_expert`] for gpt-oss: per-expert biases on all three matmuls
704/// and [`swiglu_oai`] in place of SwiGLU.
705///
706/// Deliberately plain CPU with no GPU or shared-activation fast path.
707/// gpt-oss is admitted to the CPU graph only, so a fast path here would
708/// be a second, unvalidated copy of the math.
709pub 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
732/// Runs one token's hidden state through a single expert's SwiGLU FFN.
733pub fn run_expert(hidden: &[f32], expert: &ExpertWeights) -> Vec<f32> {
734    #[cfg(any(feature = "cuda", feature = "metal"))]
735    {
736        // Full SwiGLU on-device (1× upload + 1× download) when dense GPU is on.
737        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        // Gate and up share `hidden` — one GPU upload / multi-matvec.
749        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    // Share one Q8 activation quant across gate+up when INT_DOT is on
759    // (OLMoE: avoids 2× quantize_activations_q8 per expert).
760    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        // gate and up are independent over the same activation, so their
763        // parallel regions can overlap instead of running back to back.
764        // Decode's deficit is scheduling, not kernels -- see
765        // `WeightMatrix::apply_three`, which does this for q/k/v.
766        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/// `run_expert`, but actually consulting `placement` instead of always
781/// running on CPU -- this is the real execution consequence
782/// `PlacementPlan` previously computed but nothing acted on: a
783/// `GpuDevice`-placed expert's gate/up/down matvecs go through
784/// `WeightMatrix::apply_gpu` (real CUDA and/or Metal kernels for
785/// Q8_0/Q4_0/Q4_K/Q5_K/Q6_K), falling straight through to the ordinary
786/// CPU path for any matrix `apply_gpu` returns `None` for (an
787/// unsupported quant kind, or a real launch failure) -- so this is
788/// always correct, never a hard failure, regardless of GPU availability.
789///
790/// Every call re-uploads each weight matrix to the device from scratch
791/// (see `WeightMatrix::apply_gpu`'s doc comment) -- correct, but not
792/// yet the persistent-GPU-residency throughput win real expert offload
793/// needs; a real, disclosed limit of this round, not overclaimed.
794///
795/// Without a GPU feature (`cuda` / `metal`) compiled in, this has the
796/// exact same signature and always calls `run_expert` (ignoring
797/// `placement`), so callers (e.g. `ferrox-models::decoder::Decoder`)
798/// can call it unconditionally regardless of how this crate was built,
799/// with correct behavior either way.
800#[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
853/// Combines routed + shared expert outputs for one token.
854pub 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    /// The property the global planner exists for: with N layers of
876    /// identical experts and a budget that fits exactly K experts,
877    /// exactly K experts are device-placed across ALL layers combined
878    /// -- not K per layer, which is what independent per-layer
879    /// `from_budget` calls with the same budget would produce (N*K).
880    #[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        // The old shape of the bug, for contrast: per-layer planning
901        // with the same budget places 2 experts in EVERY layer.
902        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    /// Hot experts win device slots across layer boundaries: a single
914    /// very hot expert in a late layer beats cold experts in earlier
915    /// layers.
916    #[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; // the only hot expert lives in the last layer
921        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    /// Zero budget places nothing anywhere; empty (dense) layers are
938    /// legal and contribute no candidates.
939    #[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    /// `norm_topk_prob: false` -- OLMoE's real convention (see
973    /// `MoeLayerConfig::norm_topk_prob`'s doc comment). Golden values
974    /// hand-computed independently: full softmax over all 8 logits
975    /// (sum of exp(l_i - 8) = 1.5814460129), then the raw (un-renormalized)
976    /// probabilities of the top-3 selected experts (indices 7, 6, 5 --
977    /// logits 8, 7, 6). This is the exact bug that was silently producing
978    /// wrong OLMoE output: the old code could only ever compute a
979    /// top-k-local softmax (mathematically identical to
980    /// always-renormalize), with no way to recover the un-renormalized
981    /// probability relative to *all* experts.
982    #[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        // Selecting the same experts with norm_topk_prob=true must
1001        // renormalize to the exact same values divided by that sum --
1002        // proving the two modes agree on *which* experts fire and differ
1003        // only in the final weight scaling.
1004        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        // Sigmoid is monotonic in its input, so for a given set of
1017        // logits, top-k-by-sigmoid-score must select the exact same
1018        // expert ids as top-k-by-raw-logit (sigmoid just changes the
1019        // *weights*, not which experts are chosen).
1020        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        // Expert 0 has the lower raw score but a large positive bias, so
1039        // biased selection must pick it over expert 1 -- but the WEIGHT
1040        // it ends up with must be its raw (unbiased) sigmoid score, not
1041        // score+bias. Getting this backwards would silently make a
1042        // barely-selected expert dominate the combine.
1043        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        // k=1 -> renormalization is a no-op (single weight / itself = 1,
1048        // scaled by 1.0), so the weight is just sigmoid(0.1), not 1.0.
1049        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        // The whole point of the distinction: sigmoid scores each
1078        // expert independently (not as a joint distribution), so the
1079        // relative weighting between two selected experts differs from
1080        // softmax's, even though both sum to one and pick the same
1081        // experts. If this test ever fails by finding the two paths
1082        // identical, something has collapsed the sigmoid path back
1083        // into softmax.
1084        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        // softplus(0) = ln(2), sqrt(ln(2)) -- exact closed form, not just a
1096        // property check, to pin the real DeepSeek V4 formula
1097        // (sqrt(softplus(x)), not e.g. softplus(sqrt(x)) or sqrt(sigmoid)).
1098        assert!((sqrt_softplus(0.0) - 2.0_f32.ln().sqrt()).abs() < 1e-6);
1099        // softplus(x) -> x for large positive x, so sqrt_softplus(x) -> sqrt(x).
1100        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        // 4 experts, 2 groups of 2. Scores favor expert 1 in group0 and
1106        // expert 3 in group1; total_k=2 should keep both group winners.
1107        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        // sqrt(softplus(x)) is monotonically increasing in x (both sqrt
1119        // and softplus are), so top-k-by-score must agree with top-k by
1120        // raw logit on *which* experts fire, same reasoning as the
1121        // sigmoid monotonicity test above.
1122        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        // Same structure as `bias_only_affects_selection_not_the_final_weight_value`
1144        // but for the sqrt-softplus scoring function DeepSeek V4's real
1145        // non-hash MoE layers use.
1146        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        // Expert 0 has by far the highest logit, but the real mechanism
1168        // never looks at the router's ranking to choose experts for a
1169        // hash-routed layer -- the table says [2, 1], so that's what
1170        // fires, full stop.
1171        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        // The table fixes *which* experts fire, but their relative
1180        // combine weight still comes from sqrt(softplus(logit)) gathered
1181        // at those ids -- not a uniform 1/n split. Expert 2's logit (3.0)
1182        // is much larger than expert 1's (0.1), so its weight must
1183        // dominate even though both were unconditionally selected.
1184        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        // 4 experts, 100 bytes each: a 250-byte budget fits exactly 2.
1225        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        // Expert 2 is by far the hottest but is neither first nor
1236        // largest -- a real budget-aware plan must still pick it first.
1237        let sizes = vec![50usize, 50, 50, 50];
1238        let counts = vec![1u64, 2, 100, 3];
1239        // Budget for exactly one expert.
1240        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        // Expert 0 is too big for the budget alone; experts 1 and 2
1254        // together fit and should both be placed.
1255        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]; // wrong length
1275        let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 100);
1276        // Falls back to index order; both fit within the budget either way.
1277        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    /// `run_expert_placed` must be a real drop-in for `run_expert` when
1315    /// no GPU dispatch actually happens -- true unconditionally without
1316    /// the `cuda` feature, and true even *with* the feature for `Cpu`
1317    /// placement (which never calls `apply_gpu` at all) or an
1318    /// unsupported quant kind (F32 here, which `apply_gpu` always
1319    /// returns `None` for, falling through to `run_expert`).
1320    #[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; // must be a multiple of Q8_0 block elems (32)
1359        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}