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    /// llama.cpp's `expert_weights_scale` hparam (GGUF
70    /// `{arch}.expert_weights_scale`, `LLM_KV_EXPERT_WEIGHTS_SCALE`): a
71    /// constant every routed expert's combine weight is multiplied by
72    /// *after* the optional top-k renormalisation
73    /// (`build_moe_ffn`: `weights = ggml_scale(ctx0, weights, w_scale)`).
74    /// `1.0` when the checkpoint does not carry the key, which is a real
75    /// no-op rather than a guess -- llama.cpp skips the scale for both
76    /// `0.0` and `1.0`. DeepSeek-V3-lineage MoE recipes (dots1,
77    /// bailingmoe2, hunyuan-moe, …) set it to values like 2.5, and
78    /// ignoring it scales every routed contribution wrong.
79    pub expert_weights_scale: f32,
80}
81
82/// Router output for one token: which experts fire, and their
83/// (already-normalized) combination weights.
84#[derive(Debug, Clone)]
85pub struct RoutingDecision {
86    pub expert_ids: Vec<usize>,
87    pub weights: Vec<f32>,
88}
89
90/// Top-k softmax router over per-expert logits, as used by DeepSeek /
91/// GLM / Kimi-style MoE layers (a linear gate scores every expert, top-k
92/// experts are kept, and their scores are renormalized to sum to one).
93/// Which function converts a router's raw per-expert logits into
94/// selection scores, before top-k selection and normalization.
95///
96/// This distinction is not cosmetic: reading ik_llama.cpp's actual
97/// GGUF-loading source (`llama-hparams.cpp`) directly showed that
98/// DeepSeek-2/3-family models (`LLM_ARCH_DEEPSEEK2`) and newer
99/// GLM-MoE-family models (`LLM_ARCH_GLM4_MOE`) both default to
100/// **sigmoid** gating with post-selection score normalization, not
101/// softmax -- matching DeepSeek-V3's own published technical report,
102/// which documents computing per-expert affinity via sigmoid and then
103/// normalizing the *selected* experts' scores to sum to one. Only
104/// older DeepSeek-2.0/2.5-era models default to softmax. Using softmax
105/// unconditionally, which ferrox did before this was found, would
106/// silently produce wrong routing decisions for any model in the
107/// DeepSeek-3/GLM4-MoE lineage -- which very plausibly includes
108/// DeepSeek V4 Pro and GLM-5.2, both presumed continuations of these
109/// architecture families (see docs/MODELS.md for the exact
110/// confidence level on this).
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum GatingFunction {
113    /// exp(logit) / sum(exp(selected logits)) -- the older convention.
114    Softmax,
115    /// sigmoid(logit) for scoring and top-k selection, then the
116    /// selected sigmoid scores are renormalized to sum to one -- the
117    /// DeepSeek-V3 / GLM4-MoE convention.
118    Sigmoid,
119    /// `sqrt(softplus(logit))`, i.e. `sqrt(ln(1 + exp(logit)))` --
120    /// DeepSeek V4's real MoE scoring function. Confirmed two ways from
121    /// llama.cpp PR #24162 (`src/models/deepseek4.cpp`): (1)
122    /// `load_arch_hparams` hard-throws
123    /// (`"DeepSeek-V4 loader currently expects sqrtsoftplus MoE
124    /// scoring"`) unless the GGUF's `expert_gating_func` metadata is
125    /// exactly `LLAMA_EXPERT_GATING_FUNC_TYPE_SQRT_SOFTPLUS`; (2)
126    /// `llm_graph_context::build_moe_ffn`'s real scoring switch computes
127    /// `probs = ggml_sqrt(ctx0, ggml_softplus(ctx0, logits))` for that
128    /// enum case. This supersedes the earlier sigmoid guess this crate
129    /// carried (inherited from the DeepSeek-2/3 lineage), which the real
130    /// loader source shows is wrong for V4 specifically.
131    SqrtSoftplus,
132}
133
134fn sigmoid(x: f32) -> f32 {
135    1.0 / (1.0 + (-x).exp())
136}
137
138/// `sqrt(softplus(x))` = `sqrt(ln(1 + exp(x)))`, computed the numerically
139/// stable way (`softplus(x) = max(x, 0) + ln(1 + exp(-|x|))`, avoiding
140/// overflow in `exp(x)` for large positive `x`) -- DeepSeek V4's real
141/// per-expert MoE scoring function, see [`GatingFunction::SqrtSoftplus`].
142fn sqrt_softplus(x: f32) -> f32 {
143    let softplus = x.max(0.0) + (-x.abs()).exp().ln_1p();
144    softplus.sqrt()
145}
146
147/// Top-k router over per-expert logits, dispatching to softmax, sigmoid,
148/// or sqrt-softplus scoring per `gating`. See `GatingFunction`'s doc
149/// comment for why this distinction is real and evidence-backed, not a
150/// stylistic choice.
151pub 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
164/// llama.cpp's `build_moe_ffn` selection, with the DeepSeek-V3
165/// aux-loss-free bias applied to the **selection score only**.
166///
167/// Port of `llm_graph_context::build_moe_ffn` (`src/llama-graph.cpp`),
168/// which is where every architecture carrying `exp_probs_b` routes:
169///
170/// 1. `probs = gating(logits)`;
171/// 2. `selection_probs = probs + exp_probs_b` -- the comment in the
172///    reference reads "leave probs unbiased as it's later used to get
173///    expert weights", which is the whole point: the bias steers *which*
174///    experts fire, never *how much* each one counts;
175/// 3. top-k over `selection_probs`, weights gathered from `probs`;
176/// 4. optional renormalisation of the selected weights (`norm_w`,
177///    i.e. `{arch}.expert_weights_norm`);
178/// 5. optional constant scale (`w_scale`,
179///    i.e. `{arch}.expert_weights_scale`).
180///
181/// With `bias = None` this is the unbiased routing plus the scale, so a
182/// caller does not need a second code path for layers that happen not to
183/// carry the tensor.
184pub 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        // ggml clamps the divisor to the smallest normal f16 rather than
222        // testing for zero (`ggml_clamp(..., 6.103515625e-5, INFINITY)`),
223        // so a degenerate all-zero row yields zeros, not a uniform split.
224        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
241/// Mixtral-/DeepSeek-style grouped top-k: split `logits` into
242/// `n_groups` contiguous expert groups, pick the `k_per_group` highest
243/// within each group (by the same score as flat [`route_top_k`]), then
244/// optionally keep only the global top-`total_k` across groups.
245///
246/// When `n_groups <= 1` this is identical to flat routing with `k =
247/// total_k`. Used when GGUF carries `expert_group_count` /
248/// `expert_group_used_count`.
249pub 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
287/// sqrt-softplus top-k routing, DeepSeek V4's real non-hash-routed MoE
288/// layers (`ffn_exp_probs_b` present, i.e. every layer at or past
289/// `hash_layer_count`): score every expert with `sqrt(softplus(logit))`
290/// (independently per expert, like [`route_top_k_sigmoid`]'s sigmoid --
291/// not a joint softmax distribution), pick the top-k by that score, then
292/// (if `norm_topk_prob`) renormalize the selected scores to sum to one.
293/// See [`GatingFunction::SqrtSoftplus`] for the real citation. DeepSeek
294/// V4's real non-hash MoE layers additionally add a learned bias
295/// (`ffn_exp_probs_b`) to the *selection* score only -- see
296/// [`route_top_k_sqrtsoftplus_with_bias`] for that variant; this plain
297/// version is the bias-free building block, analogous to
298/// [`route_top_k_sigmoid`] vs [`route_top_k_sigmoid_with_bias`].
299pub 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
320/// sqrt-softplus top-k routing with a selection-only bias term, mirroring
321/// [`route_top_k_sigmoid_with_bias`] but for DeepSeek V4's real
322/// [`GatingFunction::SqrtSoftplus`] scoring: selection uses
323/// `sqrt(softplus(logit)) + bias[expert]`, but each selected expert's
324/// combine *weight* uses the raw, unbiased `sqrt(softplus(logit))`.
325/// Weights are renormalized to sum to one (if `k>1` and `renormalize`),
326/// then multiplied by `scaling_factor` (DeepSeek V4's real
327/// `expert_weights_norm`/`expert_weights_scale` hparams, read directly in
328/// `load_arch_hparams`).
329pub 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
365/// DeepSeek V4's real hash-based first-layer MoE routing: for the first
366/// `hash_layer_count` layers, which experts fire is *not* learned
367/// top-k/sigmoid/sqrt-softplus selection at all -- it's a direct
368/// token-id-to-expert-id lookup table (`ffn_gate_tid2eid`, GGUF shape
369/// `[n_expert_used, n_vocab]`; real per-layer dispatch in
370/// `src/models/deepseek4.cpp`: `selected_experts =
371/// ggml_get_rows(ctx0, layer.ffn_gate_tid2eid, res->t_inp_tokens)`, with
372/// `exp_probs_b` (the selection-bias tensor) set to `nullptr` for these
373/// layers specifically because there is no learned selection to bias --
374/// the expert ids are fixed by the table, not chosen by a score).
375///
376/// The selected experts' *combine weights*, however, are **not** fixed by
377/// the table -- `build_moe_ffn` still computes `sqrt(softplus(logits))`
378/// from the real per-token router logits (`ffn_gate_inp`) and gathers
379/// those scores at the table-provided expert ids, exactly like the
380/// weight half of [`route_top_k_sqrtsoftplus_with_bias`] (just with a
381/// fixed selection instead of a chosen top-k). `hash_expert_ids` must
382/// have exactly the model's real `n_expert_used` length (one lookup-table
383/// row for this token's id); `logits` is the full `[n_expert]`-wide
384/// router output for this token.
385pub 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
411/// softmax-then-top-k routing (the Mixtral/older-DeepSeek convention:
412/// softmax over *every* expert first, then select the top-k of those
413/// probabilities -- not "top-k logits, then softmax just those"; the two
414/// are mathematically different since softmax's denominator would only
415/// sum the selected subset in the latter). `norm_topk_prob` controls
416/// whether the selected top-k probabilities are then renormalized to sum
417/// to one -- true is the right default for any architecture that doesn't
418/// document otherwise (Mixtral does this), but it is a real per-model
419/// choice: see `MoeLayerConfig::norm_topk_prob`'s doc comment for why
420/// OLMoE specifically needs `false`.
421pub 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
445/// sigmoid-then-renormalize top-k routing: score every expert with
446/// `sigmoid(logit)` (independently per expert, not a joint softmax
447/// distribution), pick the top-k by that score, then renormalize just
448/// the selected experts' sigmoid scores to sum to one. This is the
449/// DeepSeek-V3 / GLM4-MoE convention found in ik_llama.cpp's real GGUF
450/// hparams-loading source.
451pub 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        // Degenerate case (all selected scores are exactly zero,
463        // essentially never in practice for a real trained router):
464        // fall back to a uniform split rather than dividing by zero.
465        vec![1.0 / top.len() as f32; top.len()]
466    };
467
468    RoutingDecision {
469        expert_ids: top.to_vec(),
470        weights,
471    }
472}
473
474/// Sigmoid top-k routing with a real "aux-loss-free" per-expert bias
475/// term added *only* for top-k selection (`topk_method: "noaux_tc"` in
476/// Kimi K3's real `config.json`, `KimiMoEGate.forward` in
477/// `modeling_kimi_linear.py`, adapted from DeepSeek-V3's own MoE gate --
478/// the same convention, not Kimi-specific): the selection scores are
479/// `sigmoid(logit) + bias[expert]`, but the *weight* each selected
480/// expert's output gets multiplied by uses the raw, unbiased
481/// `sigmoid(logit)` -- getting this backwards (biasing the weight
482/// itself, not just the selection) would silently skew routed-expert
483/// contribution away from what the router actually learned. Weights are
484/// renormalized to sum to 1 (if `k>1`) then multiplied by
485/// `scaling_factor` (Kimi K3's `routed_scaling_factor`, 1.0 in its real
486/// config, i.e. a no-op there, but a real multiplier for any other
487/// model using this same convention with a different value).
488pub 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/// A CPU/GPU placement plan for a layer's experts.
525#[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    /// Index-based placeholder: puts the first `n_gpu_resident`
540    /// experts on GPU regardless of their actual size or how often
541    /// they're activated. Kept only as a trivial fallback for callers
542    /// with no real budget/hotness data at all (e.g. `ferrox smoke`'s
543    /// synthetic-weight demo); real deployments should use
544    /// `from_budget` instead, which places by measured VRAM budget and
545    /// observed expert hotness rather than by index.
546    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    /// Builds a placement plan from a real VRAM budget and each
558    /// expert's actual resident byte size (e.g. summed
559    /// `WeightMatrix::resident_bytes()` across an expert's gate/up/down
560    /// matrices), following ik_llama.cpp's `--cpu-moe`/`--override-tensor`
561    /// pattern of deciding CPU-vs-GPU per tensor rather than by a fixed
562    /// index cutoff.
563    ///
564    /// `activation_counts`, if given (one count per expert, e.g.
565    /// accumulated from `RoutingDecision::expert_ids` over a real or
566    /// representative workload), places the *most frequently activated*
567    /// experts on GPU first -- the actual point of expert offload,
568    /// since keeping a rarely-used expert resident in VRAM wastes the
569    /// budget a hot expert could have used instead. Without observed
570    /// counts, falls back to a documented, deterministic policy (index
571    /// order) rather than guessing at hotness.
572    ///
573    /// Greedy, not globally optimal (a smaller-but-colder expert can
574    /// still be skipped in favor of trying the next candidate once a
575    /// larger higher-priority expert doesn't fit) -- optimal knapsack
576    /// packing is not worth the complexity here, and greedy-by-priority
577    /// is the same approach real offload tooling uses.
578    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    /// A device-placement plan for EVERY layer's routed experts against
609    /// ONE shared VRAM budget -- the fix for the real accounting bug
610    /// where each layer independently called `from_budget` with the
611    /// full budget, so a model with N layers would plan N x the
612    /// configured bytes of GPU residency. All `(layer, expert)`
613    /// candidates compete in one global priority order (hottest first,
614    /// ties broken by layer then expert index for determinism), and a
615    /// candidate is only placed on the device if the *global* running
616    /// total still fits.
617    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(); // (count, layer, expert)
623        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
671/// The output of `PlacementPlan::plan_layers_against_global_budget`:
672/// one per-layer `PlacementPlan` view over a single, globally-accounted
673/// device budget. `device_bytes_planned <= vram_budget_bytes` holds by
674/// construction across ALL layers combined -- the property the old
675/// per-layer planning could not provide.
676pub struct ResidencyPlan {
677    layer_plans: Vec<PlacementPlan>,
678    /// Total bytes this plan places on the device, summed across every
679    /// layer.
680    pub device_bytes_planned: u64,
681    /// The single budget every layer's placements were accounted
682    /// against.
683    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
696/// One expert's gate/up/down weight matrices. Each may be plain f32
697/// (synthetic/test weights) or still-quantized bytes loaded straight
698/// from a GGUF file (real checkpoints) -- `WeightMatrix::apply`
699/// dispatches to the right kernel either way.
700pub struct ExpertWeights {
701    pub gate: WeightMatrix,
702    pub up: WeightMatrix,
703    pub down: WeightMatrix,
704}
705
706/// One expert's gate/up/down bias vectors.
707///
708/// Kept separate from [`ExpertWeights`] because only the gpt-oss family
709/// ships them: every other MoE checkpoint ferrox loads has bias-free
710/// experts, and threading three `Option`s through the thirty
711/// `ExpertWeights` construction sites would pay for a feature one
712/// architecture uses.
713///
714/// Lengths are `expert_ffn_dim` for `gate`/`up` and `hidden_dim` for
715/// `down`, matching llama.cpp's `ffn_{gate,up}_exps_b` `{n_ff_exp,
716/// n_expert}` and `ffn_down_exps_b` `{n_embd, n_expert}`.
717#[derive(Debug, Clone, Default)]
718pub struct ExpertBias {
719    pub gate: Vec<f32>,
720    pub up: Vec<f32>,
721    pub down: Vec<f32>,
722}
723
724/// gpt-oss's SwiGLU sigmoid steepness (`llama-graph.cpp`,
725/// `LLM_FFN_SWIGLU_OAI_MOE`: `constexpr float alpha = 1.702f`).
726pub const SWIGLU_OAI_ALPHA: f32 = 1.702;
727/// gpt-oss's SwiGLU clamp (`constexpr float limit = 7.0f`, same site).
728pub const SWIGLU_OAI_LIMIT: f32 = 7.0;
729
730/// gpt-oss's clamped SwiGLU.
731///
732/// Transcribed from `ggml/src/ggml-cpu/ops.cpp`
733/// `ggml_compute_forward_swiglu_oai_f32`:
734///
735/// ```text
736/// x = min(gate, limit);
737/// y = clamp(up, -limit, limit);
738/// out_glu = x / (1 + expf(alpha * -x));
739/// dst = out_glu * (y + 1);
740/// ```
741///
742/// Three things differ from ordinary SwiGLU and all three matter: the
743/// gate is clamped from *above only*, the sigmoid is scaled by `alpha`
744/// rather than being plain `silu`, and the up branch carries a `+1`
745/// offset so a zero `up` passes the gate through instead of killing it.
746pub 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
759/// gpt-oss routing: pick the top-`k` experts by their **raw** router
760/// logits, then softmax over just those `k`.
761///
762/// This is llama.cpp's `LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX_WEIGHT`
763/// (`llama-graph.cpp::build_moe_ffn`), and it is *not* the same as
764/// [`route_top_k_softmax`]: there the softmax runs over all `n_expert`
765/// logits before selection, so the surviving weights are a slice of the
766/// full distribution and sum to less than one; here the normalization
767/// happens after selection, so the `k` weights sum to exactly one.
768/// Feeding a gpt-oss checkpoint through the ordinary softmax gating
769/// picks the same experts but weights them wrong.
770pub 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
791/// [`run_expert`] for gpt-oss: per-expert biases on all three matmuls
792/// and [`swiglu_oai`] in place of SwiGLU.
793///
794/// Deliberately plain CPU with no GPU or shared-activation fast path.
795/// gpt-oss is admitted to the CPU graph only, so a fast path here would
796/// be a second, unvalidated copy of the math.
797pub 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
820/// Runs one token's hidden state through a single expert's SwiGLU FFN.
821pub fn run_expert(hidden: &[f32], expert: &ExpertWeights) -> Vec<f32> {
822    #[cfg(any(feature = "cuda", feature = "metal"))]
823    {
824        // Full SwiGLU on-device (1× upload + 1× download) when dense GPU is on.
825        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        // Gate and up share `hidden` — one GPU upload / multi-matvec.
837        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    // Share one Q8 activation quant across gate+up when INT_DOT is on
847    // (OLMoE: avoids 2× quantize_activations_q8 per expert).
848    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        // gate and up are independent over the same activation, so their
851        // parallel regions can overlap instead of running back to back.
852        // Decode's deficit is scheduling, not kernels -- see
853        // `WeightMatrix::apply_three`, which does this for q/k/v.
854        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/// `run_expert`, but actually consulting `placement` instead of always
869/// running on CPU -- this is the real execution consequence
870/// `PlacementPlan` previously computed but nothing acted on: a
871/// `GpuDevice`-placed expert's gate/up/down matvecs go through
872/// `WeightMatrix::apply_gpu` (real CUDA and/or Metal kernels for
873/// Q8_0/Q4_0/Q4_K/Q5_K/Q6_K), falling straight through to the ordinary
874/// CPU path for any matrix `apply_gpu` returns `None` for (an
875/// unsupported quant kind, or a real launch failure) -- so this is
876/// always correct, never a hard failure, regardless of GPU availability.
877///
878/// Every call re-uploads each weight matrix to the device from scratch
879/// (see `WeightMatrix::apply_gpu`'s doc comment) -- correct, but not
880/// yet the persistent-GPU-residency throughput win real expert offload
881/// needs; a real, disclosed limit of this round, not overclaimed.
882///
883/// Without a GPU feature (`cuda` / `metal`) compiled in, this has the
884/// exact same signature and always calls `run_expert` (ignoring
885/// `placement`), so callers (e.g. `ferrox-models::decoder::Decoder`)
886/// can call it unconditionally regardless of how this crate was built,
887/// with correct behavior either way.
888#[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
941/// Combines routed + shared expert outputs for one token.
942pub 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    /// The property the global planner exists for: with N layers of
964    /// identical experts and a budget that fits exactly K experts,
965    /// exactly K experts are device-placed across ALL layers combined
966    /// -- not K per layer, which is what independent per-layer
967    /// `from_budget` calls with the same budget would produce (N*K).
968    #[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        // The old shape of the bug, for contrast: per-layer planning
989        // with the same budget places 2 experts in EVERY layer.
990        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    /// Hot experts win device slots across layer boundaries: a single
1002    /// very hot expert in a late layer beats cold experts in earlier
1003    /// layers.
1004    #[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; // the only hot expert lives in the last layer
1009        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    /// Zero budget places nothing anywhere; empty (dense) layers are
1026    /// legal and contribute no candidates.
1027    #[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    /// `norm_topk_prob: false` -- OLMoE's real convention (see
1061    /// `MoeLayerConfig::norm_topk_prob`'s doc comment). Golden values
1062    /// hand-computed independently: full softmax over all 8 logits
1063    /// (sum of exp(l_i - 8) = 1.5814460129), then the raw (un-renormalized)
1064    /// probabilities of the top-3 selected experts (indices 7, 6, 5 --
1065    /// logits 8, 7, 6). This is the exact bug that was silently producing
1066    /// wrong OLMoE output: the old code could only ever compute a
1067    /// top-k-local softmax (mathematically identical to
1068    /// always-renormalize), with no way to recover the un-renormalized
1069    /// probability relative to *all* experts.
1070    #[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        // Selecting the same experts with norm_topk_prob=true must
1089        // renormalize to the exact same values divided by that sum --
1090        // proving the two modes agree on *which* experts fire and differ
1091        // only in the final weight scaling.
1092        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        // Sigmoid is monotonic in its input, so for a given set of
1105        // logits, top-k-by-sigmoid-score must select the exact same
1106        // expert ids as top-k-by-raw-logit (sigmoid just changes the
1107        // *weights*, not which experts are chosen).
1108        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        // Expert 0 has the lower raw score but a large positive bias, so
1127        // biased selection must pick it over expert 1 -- but the WEIGHT
1128        // it ends up with must be its raw (unbiased) sigmoid score, not
1129        // score+bias. Getting this backwards would silently make a
1130        // barely-selected expert dominate the combine.
1131        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        // k=1 -> renormalization is a no-op (single weight / itself = 1,
1136        // scaled by 1.0), so the weight is just sigmoid(0.1), not 1.0.
1137        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        // The whole point of the distinction: sigmoid scores each
1166        // expert independently (not as a joint distribution), so the
1167        // relative weighting between two selected experts differs from
1168        // softmax's, even though both sum to one and pick the same
1169        // experts. If this test ever fails by finding the two paths
1170        // identical, something has collapsed the sigmoid path back
1171        // into softmax.
1172        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        // softplus(0) = ln(2), sqrt(ln(2)) -- exact closed form, not just a
1184        // property check, to pin the real DeepSeek V4 formula
1185        // (sqrt(softplus(x)), not e.g. softplus(sqrt(x)) or sqrt(sigmoid)).
1186        assert!((sqrt_softplus(0.0) - 2.0_f32.ln().sqrt()).abs() < 1e-6);
1187        // softplus(x) -> x for large positive x, so sqrt_softplus(x) -> sqrt(x).
1188        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        // 4 experts, 2 groups of 2. Scores favor expert 1 in group0 and
1194        // expert 3 in group1; total_k=2 should keep both group winners.
1195        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        // sqrt(softplus(x)) is monotonically increasing in x (both sqrt
1207        // and softplus are), so top-k-by-score must agree with top-k by
1208        // raw logit on *which* experts fire, same reasoning as the
1209        // sigmoid monotonicity test above.
1210        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        // Same structure as `bias_only_affects_selection_not_the_final_weight_value`
1232        // but for the sqrt-softplus scoring function DeepSeek V4's real
1233        // non-hash MoE layers use.
1234        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        // Expert 0 has by far the highest logit, but the real mechanism
1256        // never looks at the router's ranking to choose experts for a
1257        // hash-routed layer -- the table says [2, 1], so that's what
1258        // fires, full stop.
1259        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        // The table fixes *which* experts fire, but their relative
1268        // combine weight still comes from sqrt(softplus(logit)) gathered
1269        // at those ids -- not a uniform 1/n split. Expert 2's logit (3.0)
1270        // is much larger than expert 1's (0.1), so its weight must
1271        // dominate even though both were unconditionally selected.
1272        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        // 4 experts, 100 bytes each: a 250-byte budget fits exactly 2.
1313        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        // Expert 2 is by far the hottest but is neither first nor
1324        // largest -- a real budget-aware plan must still pick it first.
1325        let sizes = vec![50usize, 50, 50, 50];
1326        let counts = vec![1u64, 2, 100, 3];
1327        // Budget for exactly one expert.
1328        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        // Expert 0 is too big for the budget alone; experts 1 and 2
1342        // together fit and should both be placed.
1343        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]; // wrong length
1363        let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 100);
1364        // Falls back to index order; both fit within the budget either way.
1365        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    /// `run_expert_placed` must be a real drop-in for `run_expert` when
1403    /// no GPU dispatch actually happens -- true unconditionally without
1404    /// the `cuda` feature, and true even *with* the feature for `Cpu`
1405    /// placement (which never calls `apply_gpu` at all) or an
1406    /// unsupported quant kind (F32 here, which `apply_gpu` always
1407    /// returns `None` for, falling through to `run_expert`).
1408    #[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; // must be a multiple of Q8_0 block elems (32)
1447        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}