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::{geglu, 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 DeepSeek-V3 / GLM-family expert grouping
64    /// (`expert_group_count` / `expert_group_used_count` in GGUF, i.e.
65    /// `n_group` / `topk_group` in the HF configs). `None` means flat
66    /// top-k over all experts (Llama / OLMoE / Qwen2-MoE). Grouped
67    /// routing is selected at load time; the hot path reads these fields
68    /// as data.
69    ///
70    /// `expert_group_used_count` is the number of *groups* that survive
71    /// the group filter -- it is **not** a per-group expert quota. All
72    /// `n_experts_active` experts are then chosen by one global top-k
73    /// over the surviving groups, so a token may (and normally does) put
74    /// several experts in the same group. See
75    /// [`route_top_k_grouped_biased`] for the exact rule and for what
76    /// reading this field as "experts per group" silently does instead.
77    pub expert_group_count: Option<usize>,
78    pub expert_group_used_count: Option<usize>,
79    /// llama.cpp's `expert_weights_scale` hparam (GGUF
80    /// `{arch}.expert_weights_scale`, `LLM_KV_EXPERT_WEIGHTS_SCALE`): a
81    /// constant every routed expert's combine weight is multiplied by
82    /// *after* the optional top-k renormalisation
83    /// (`build_moe_ffn`: `weights = ggml_scale(ctx0, weights, w_scale)`).
84    /// `1.0` when the checkpoint does not carry the key, which is a real
85    /// no-op rather than a guess -- llama.cpp skips the scale for both
86    /// `0.0` and `1.0`. DeepSeek-V3-lineage MoE recipes (dots1,
87    /// bailingmoe2, hunyuan-moe, …) set it to values like 2.5, and
88    /// ignoring it scales every routed contribution wrong.
89    pub expert_weights_scale: f32,
90}
91
92/// Router output for one token: which experts fire, and their
93/// (already-normalized) combination weights.
94#[derive(Debug, Clone)]
95pub struct RoutingDecision {
96    pub expert_ids: Vec<usize>,
97    pub weights: Vec<f32>,
98}
99
100/// Top-k softmax router over per-expert logits, as used by DeepSeek /
101/// GLM / Kimi-style MoE layers (a linear gate scores every expert, top-k
102/// experts are kept, and their scores are renormalized to sum to one).
103/// Which function converts a router's raw per-expert logits into
104/// selection scores, before top-k selection and normalization.
105///
106/// This distinction is not cosmetic: reading ik_llama.cpp's actual
107/// GGUF-loading source (`llama-hparams.cpp`) directly showed that
108/// DeepSeek-2/3-family models (`LLM_ARCH_DEEPSEEK2`) and newer
109/// GLM-MoE-family models (`LLM_ARCH_GLM4_MOE`) both default to
110/// **sigmoid** gating with post-selection score normalization, not
111/// softmax -- matching DeepSeek-V3's own published technical report,
112/// which documents computing per-expert affinity via sigmoid and then
113/// normalizing the *selected* experts' scores to sum to one. Only
114/// older DeepSeek-2.0/2.5-era models default to softmax. Using softmax
115/// unconditionally, which ferrox did before this was found, would
116/// silently produce wrong routing decisions for any model in the
117/// DeepSeek-3/GLM4-MoE lineage -- which very plausibly includes
118/// DeepSeek V4 Pro and GLM-5.2, both presumed continuations of these
119/// architecture families (see docs/MODELS.md for the exact
120/// confidence level on this).
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum GatingFunction {
123    /// exp(logit) / sum(exp(selected logits)) -- the older convention.
124    Softmax,
125    /// sigmoid(logit) for scoring and top-k selection, then the
126    /// selected sigmoid scores are renormalized to sum to one -- the
127    /// DeepSeek-V3 / GLM4-MoE convention.
128    Sigmoid,
129    /// `sqrt(softplus(logit))`, i.e. `sqrt(ln(1 + exp(logit)))` --
130    /// DeepSeek V4's real MoE scoring function. Confirmed two ways from
131    /// llama.cpp PR #24162 (`src/models/deepseek4.cpp`): (1)
132    /// `load_arch_hparams` hard-throws
133    /// (`"DeepSeek-V4 loader currently expects sqrtsoftplus MoE
134    /// scoring"`) unless the GGUF's `expert_gating_func` metadata is
135    /// exactly `LLAMA_EXPERT_GATING_FUNC_TYPE_SQRT_SOFTPLUS`; (2)
136    /// `llm_graph_context::build_moe_ffn`'s real scoring switch computes
137    /// `probs = ggml_sqrt(ctx0, ggml_softplus(ctx0, logits))` for that
138    /// enum case. This supersedes the earlier sigmoid guess this crate
139    /// carried (inherited from the DeepSeek-2/3 lineage), which the real
140    /// loader source shows is wrong for V4 specifically.
141    SqrtSoftplus,
142}
143
144fn sigmoid(x: f32) -> f32 {
145    1.0 / (1.0 + (-x).exp())
146}
147
148/// `sqrt(softplus(x))` = `sqrt(ln(1 + exp(x)))`, computed the numerically
149/// stable way (`softplus(x) = max(x, 0) + ln(1 + exp(-|x|))`, avoiding
150/// overflow in `exp(x)` for large positive `x`) -- DeepSeek V4's real
151/// per-expert MoE scoring function, see [`GatingFunction::SqrtSoftplus`].
152fn sqrt_softplus(x: f32) -> f32 {
153    let softplus = x.max(0.0) + (-x.abs()).exp().ln_1p();
154    softplus.sqrt()
155}
156
157/// Top-k router over per-expert logits, dispatching to softmax, sigmoid,
158/// or sqrt-softplus scoring per `gating`. See `GatingFunction`'s doc
159/// comment for why this distinction is real and evidence-backed, not a
160/// stylistic choice.
161pub fn route_top_k(
162    logits: &[f32],
163    k: usize,
164    gating: GatingFunction,
165    norm_topk_prob: bool,
166) -> RoutingDecision {
167    match gating {
168        GatingFunction::Softmax => route_top_k_softmax(logits, k, norm_topk_prob),
169        GatingFunction::Sigmoid => route_top_k_sigmoid(logits, k),
170        GatingFunction::SqrtSoftplus => route_top_k_sqrtsoftplus(logits, k, norm_topk_prob),
171    }
172}
173
174/// llama.cpp's `build_moe_ffn` selection, with the DeepSeek-V3
175/// aux-loss-free bias applied to the **selection score only**.
176///
177/// Port of `llm_graph_context::build_moe_ffn` (`src/llama-graph.cpp`),
178/// which is where every architecture carrying `exp_probs_b` routes:
179///
180/// 1. `probs = gating(logits)`;
181/// 2. `selection_probs = probs + exp_probs_b` -- the comment in the
182///    reference reads "leave probs unbiased as it's later used to get
183///    expert weights", which is the whole point: the bias steers *which*
184///    experts fire, never *how much* each one counts;
185/// 3. top-k over `selection_probs`, weights gathered from `probs`;
186/// 4. optional renormalisation of the selected weights (`norm_w`,
187///    i.e. `{arch}.expert_weights_norm`);
188/// 5. optional constant scale (`w_scale`,
189///    i.e. `{arch}.expert_weights_scale`).
190///
191/// With `bias = None` this is the unbiased routing plus the scale, so a
192/// caller does not need a second code path for layers that happen not to
193/// carry the tensor.
194///
195/// This is exactly [`route_top_k_grouped_biased`] with a single expert
196/// group, i.e. no group filter at all -- the two share one body so that
197/// a checkpoint carrying `exp_probs_b` *and* expert groups cannot end up
198/// routed by a second, differently-behaving copy of the same rule.
199pub fn route_top_k_biased(
200    logits: &[f32],
201    bias: Option<&[f32]>,
202    k: usize,
203    gating: GatingFunction,
204    norm_w: bool,
205    w_scale: f32,
206) -> RoutingDecision {
207    route_top_k_grouped_biased(logits, bias, 1, 1, k, gating, norm_w, w_scale)
208}
209
210/// Scores each expert group as the **sum of its top two** member
211/// scores, keeps the `topk_group` highest-scoring groups, and masks
212/// every expert of every other group to `-inf` in place.
213///
214/// Transcribed from the reference's `_group_limited` (FreeToken
215/// `models/glm4_moe/moe.py`, identical copy in `models/glm_moe_dsa/`),
216/// which is itself HF's `Glm4MoeMoE` / DeepSeek-V3 group filter:
217///
218/// ```text
219/// group_scores = scores_for_choice.view(m, g, e // g).topk(2, -1)[0].sum(-1)
220/// group_idx    = topk(group_scores, topk_group)[1]
221/// scores_for_choice.masked_fill(~group_mask, -inf)
222/// ```
223///
224/// Two details are load-bearing. The group score is the top-**two**
225/// sum, not the group max: a group holding two good experts must be
226/// able to beat a group holding one great expert and nothing else,
227/// which is the entire reason the filter exists. And the masking runs
228/// on the **biased** selection scores (`probs + exp_probs_b`), so the
229/// aux-loss-free bias steers which *groups* survive, not only which
230/// experts win inside them.
231///
232/// `topk_group` is clamped to `1..=n_groups`: a stored zero would mask
233/// every expert to `-inf` and leave the following top-k picking experts
234/// out of an all-`-inf` array, which is a silently arbitrary routing
235/// rather than a loud failure.
236///
237/// Groups smaller than two experts sum whatever the group has (the
238/// reference cannot express this case at all -- `topk(2)` on a
239/// one-element group raises -- and no real checkpoint ships it).
240fn mask_unselected_groups(selection: &mut [f32], n_groups: usize, topk_group: usize) {
241    let group_size = selection.len() / n_groups;
242    let topk_group = topk_group.clamp(1, n_groups);
243
244    let mut group_scores: Vec<(usize, f32)> = (0..n_groups)
245        .map(|g| {
246            let mut members: Vec<f32> = selection[g * group_size..(g + 1) * group_size].to_vec();
247            members.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap());
248            (g, members.iter().take(2).sum::<f32>())
249        })
250        .collect();
251    // Descending by score, ties broken by group index so the choice is
252    // reproducible run to run (`torch.topk(..., sorted=False)` makes no
253    // ordering promise, but a router must not be nondeterministic).
254    group_scores.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then(a.0.cmp(&b.0)));
255
256    let mut keep = vec![false; n_groups];
257    for &(g, _) in group_scores.iter().take(topk_group) {
258        keep[g] = true;
259    }
260    for (g, kept) in keep.iter().enumerate() {
261        if !kept {
262            for s in selection[g * group_size..(g + 1) * group_size].iter_mut() {
263                *s = f32::NEG_INFINITY;
264            }
265        }
266    }
267}
268
269/// The real DeepSeek-V3 / GLM-family `n_group` / `topk_group` router:
270/// group-limited **selection**, followed by ONE GLOBAL top-k.
271///
272/// Port of the reference's `Glm4MoeSparseBlock._route` (FreeToken
273/// `models/glm4_moe/moe.py:63`, identical in `models/glm_moe_dsa/`),
274/// which matches HF `Glm4MoeMoE.route_tokens_to_experts` and
275/// llama.cpp's `build_moe_ffn` `n_expert_groups > 1` block:
276///
277/// 1. `probs = gating(logits)`;
278/// 2. `selection = probs + exp_probs_b` (bias steers selection only);
279/// 3. if `n_groups > 1`, [`mask_unselected_groups`] masks every expert
280///    outside the `topk_group` best groups to `-inf`;
281/// 4. **one global** top-`k` over the surviving `selection` scores --
282///    the groups constrain *where* experts may come from, they do not
283///    hand out per-group quotas;
284/// 5. weights gathered from the **unbiased** `probs` at the chosen ids;
285/// 6. optional renormalisation (`norm_w`) and constant scale
286///    (`w_scale`).
287///
288/// The invariant that makes this a different algorithm from "take a
289/// fixed number of experts from every group": the number of experts a
290/// surviving group contributes is decided by the scores, not by the
291/// config. A token whose eight active experts all belong to two hot
292/// groups must fire exactly those eight. Taking a quota per group
293/// instead spreads the same token's experts one-per-group across all
294/// eight groups -- eight *different* experts, a different FFN output,
295/// and no error anywhere: the wrong routing is only visible as degraded
296/// generation quality. That was ferrox's real behaviour before this
297/// function existed (see the `grouped_routing_concentrates_*` test).
298///
299/// `n_groups <= 1`, or an expert count that is not a multiple of
300/// `n_groups`, skips the group filter entirely and leaves plain flat
301/// biased routing -- the shape [`route_top_k_biased`] delegates here
302/// with.
303#[allow(clippy::too_many_arguments)]
304pub fn route_top_k_grouped_biased(
305    logits: &[f32],
306    bias: Option<&[f32]>,
307    n_groups: usize,
308    topk_group: usize,
309    k: usize,
310    gating: GatingFunction,
311    norm_w: bool,
312    w_scale: f32,
313) -> RoutingDecision {
314    let probs: Vec<f32> = match gating {
315        GatingFunction::Softmax => {
316            let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
317            let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
318            let sum: f32 = exps.iter().sum();
319            exps.iter().map(|e| e / sum).collect()
320        }
321        GatingFunction::Sigmoid => logits.iter().map(|&l| sigmoid(l)).collect(),
322        GatingFunction::SqrtSoftplus => logits.iter().map(|&l| sqrt_softplus(l)).collect(),
323    };
324
325    let mut selection: Vec<f32> = match bias {
326        Some(b) => {
327            assert_eq!(
328                b.len(),
329                probs.len(),
330                "exp_probs_b must have one entry per expert"
331            );
332            probs.iter().zip(b.iter()).map(|(p, b)| p + b).collect()
333        }
334        None => probs.clone(),
335    };
336
337    if n_groups > 1 && !selection.is_empty() && selection.len().is_multiple_of(n_groups) {
338        mask_unselected_groups(&mut selection, n_groups, topk_group);
339    }
340
341    let mut idx: Vec<usize> = (0..selection.len()).collect();
342    // Ties break by expert index: without the group filter exact ties
343    // essentially never happen, but every masked-out expert is exactly
344    // `-inf`, so a `k` larger than the surviving population would
345    // otherwise pick arbitrary losers in unspecified order.
346    idx.sort_unstable_by(|&a, &b| {
347        selection[b]
348            .partial_cmp(&selection[a])
349            .unwrap()
350            .then(a.cmp(&b))
351    });
352    let top = &idx[..k.min(idx.len())];
353
354    let mut weights: Vec<f32> = top.iter().map(|&i| probs[i]).collect();
355    if norm_w {
356        // ggml clamps the divisor to the smallest normal f16 rather than
357        // testing for zero (`ggml_clamp(..., 6.103515625e-5, INFINITY)`),
358        // so a degenerate all-zero row yields zeros, not a uniform split.
359        let sum = weights.iter().sum::<f32>().max(6.103_515_6e-5);
360        for w in weights.iter_mut() {
361            *w /= sum;
362        }
363    }
364    if w_scale != 0.0 && w_scale != 1.0 {
365        for w in weights.iter_mut() {
366            *w *= w_scale;
367        }
368    }
369
370    RoutingDecision {
371        expert_ids: top.to_vec(),
372        weights,
373    }
374}
375
376/// Bias-free grouped routing: [`route_top_k_grouped_biased`] for the
377/// checkpoints that declare `expert_group_count` /
378/// `expert_group_used_count` but carry no `exp_probs_b` tensor and no
379/// expert-weight scale.
380///
381/// `topk_group` is GGUF's `expert_group_used_count`, i.e. how many
382/// **groups** survive the filter -- not how many experts to take from
383/// each group. Reading it the second way is the routing bug this
384/// function used to have: see [`route_top_k_grouped_biased`]'s doc
385/// comment for the exact failure it produces.
386///
387/// When `n_groups <= 1`, or when the expert count is not a multiple of
388/// `n_groups`, this falls back to flat [`route_top_k`] -- including that
389/// function's per-gating conventions (notably `Sigmoid`'s
390/// always-renormalize rule, see [`route_top_k_sigmoid`]).
391pub fn route_top_k_grouped(
392    logits: &[f32],
393    n_groups: usize,
394    topk_group: usize,
395    total_k: usize,
396    gating: GatingFunction,
397    norm_topk_prob: bool,
398) -> RoutingDecision {
399    if n_groups <= 1 || !logits.len().is_multiple_of(n_groups) {
400        return route_top_k(logits, total_k, gating, norm_topk_prob);
401    }
402    route_top_k_grouped_biased(
403        logits,
404        None,
405        n_groups,
406        topk_group,
407        total_k,
408        gating,
409        norm_topk_prob,
410        1.0,
411    )
412}
413
414/// sqrt-softplus top-k routing, DeepSeek V4's real non-hash-routed MoE
415/// layers (`ffn_exp_probs_b` present, i.e. every layer at or past
416/// `hash_layer_count`): score every expert with `sqrt(softplus(logit))`
417/// (independently per expert, like [`route_top_k_sigmoid`]'s sigmoid --
418/// not a joint softmax distribution), pick the top-k by that score, then
419/// (if `norm_topk_prob`) renormalize the selected scores to sum to one.
420/// See [`GatingFunction::SqrtSoftplus`] for the real citation. DeepSeek
421/// V4's real non-hash MoE layers additionally add a learned bias
422/// (`ffn_exp_probs_b`) to the *selection* score only -- see
423/// [`route_top_k_sqrtsoftplus_with_bias`] for that variant; this plain
424/// version is the bias-free building block, analogous to
425/// [`route_top_k_sigmoid`] vs [`route_top_k_sigmoid_with_bias`].
426pub fn route_top_k_sqrtsoftplus(logits: &[f32], k: usize, norm_topk_prob: bool) -> RoutingDecision {
427    let scores: Vec<f32> = logits.iter().map(|&l| sqrt_softplus(l)).collect();
428
429    let mut idx: Vec<usize> = (0..scores.len()).collect();
430    idx.sort_unstable_by(|&a, &b| scores[b].partial_cmp(&scores[a]).unwrap());
431    let top = &idx[..k.min(idx.len())];
432
433    let mut weights: Vec<f32> = top.iter().map(|&i| scores[i]).collect();
434    if norm_topk_prob {
435        let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
436        for w in weights.iter_mut() {
437            *w /= sum;
438        }
439    }
440
441    RoutingDecision {
442        expert_ids: top.to_vec(),
443        weights,
444    }
445}
446
447/// sqrt-softplus top-k routing with a selection-only bias term, mirroring
448/// [`route_top_k_sigmoid_with_bias`] but for DeepSeek V4's real
449/// [`GatingFunction::SqrtSoftplus`] scoring: selection uses
450/// `sqrt(softplus(logit)) + bias[expert]`, but each selected expert's
451/// combine *weight* uses the raw, unbiased `sqrt(softplus(logit))`.
452/// Weights are renormalized to sum to one (if `k>1` and `renormalize`),
453/// then multiplied by `scaling_factor` (DeepSeek V4's real
454/// `expert_weights_norm`/`expert_weights_scale` hparams, read directly in
455/// `load_arch_hparams`).
456pub fn route_top_k_sqrtsoftplus_with_bias(
457    logits: &[f32],
458    bias: &[f32],
459    k: usize,
460    renormalize: bool,
461    scaling_factor: f32,
462) -> RoutingDecision {
463    assert_eq!(logits.len(), bias.len());
464    let scores: Vec<f32> = logits.iter().map(|&l| sqrt_softplus(l)).collect();
465    let scores_for_choice: Vec<f32> = scores.iter().zip(bias.iter()).map(|(s, b)| s + b).collect();
466
467    let mut idx: Vec<usize> = (0..scores.len()).collect();
468    idx.sort_unstable_by(|&a, &b| {
469        scores_for_choice[b]
470            .partial_cmp(&scores_for_choice[a])
471            .unwrap()
472    });
473    let top = &idx[..k.min(idx.len())];
474
475    let mut weights: Vec<f32> = top.iter().map(|&i| scores[i]).collect();
476    if k > 1 && renormalize {
477        let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
478        for w in weights.iter_mut() {
479            *w /= sum;
480        }
481    }
482    for w in weights.iter_mut() {
483        *w *= scaling_factor;
484    }
485
486    RoutingDecision {
487        expert_ids: top.to_vec(),
488        weights,
489    }
490}
491
492/// DeepSeek V4's real hash-based first-layer MoE routing: for the first
493/// `hash_layer_count` layers, which experts fire is *not* learned
494/// top-k/sigmoid/sqrt-softplus selection at all -- it's a direct
495/// token-id-to-expert-id lookup table (`ffn_gate_tid2eid`, GGUF shape
496/// `[n_expert_used, n_vocab]`; real per-layer dispatch in
497/// `src/models/deepseek4.cpp`: `selected_experts =
498/// ggml_get_rows(ctx0, layer.ffn_gate_tid2eid, res->t_inp_tokens)`, with
499/// `exp_probs_b` (the selection-bias tensor) set to `nullptr` for these
500/// layers specifically because there is no learned selection to bias --
501/// the expert ids are fixed by the table, not chosen by a score).
502///
503/// The selected experts' *combine weights*, however, are **not** fixed by
504/// the table -- `build_moe_ffn` still computes `sqrt(softplus(logits))`
505/// from the real per-token router logits (`ffn_gate_inp`) and gathers
506/// those scores at the table-provided expert ids, exactly like the
507/// weight half of [`route_top_k_sqrtsoftplus_with_bias`] (just with a
508/// fixed selection instead of a chosen top-k). `hash_expert_ids` must
509/// have exactly the model's real `n_expert_used` length (one lookup-table
510/// row for this token's id); `logits` is the full `[n_expert]`-wide
511/// router output for this token.
512pub fn route_hash(
513    hash_expert_ids: &[usize],
514    logits: &[f32],
515    renormalize: bool,
516    scaling_factor: f32,
517) -> RoutingDecision {
518    let mut weights: Vec<f32> = hash_expert_ids
519        .iter()
520        .map(|&e| sqrt_softplus(logits[e]))
521        .collect();
522    if hash_expert_ids.len() > 1 && renormalize {
523        let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
524        for w in weights.iter_mut() {
525            *w /= sum;
526        }
527    }
528    for w in weights.iter_mut() {
529        *w *= scaling_factor;
530    }
531
532    RoutingDecision {
533        expert_ids: hash_expert_ids.to_vec(),
534        weights,
535    }
536}
537
538/// softmax-then-top-k routing (the Mixtral/older-DeepSeek convention:
539/// softmax over *every* expert first, then select the top-k of those
540/// probabilities -- not "top-k logits, then softmax just those"; the two
541/// are mathematically different since softmax's denominator would only
542/// sum the selected subset in the latter). `norm_topk_prob` controls
543/// whether the selected top-k probabilities are then renormalized to sum
544/// to one -- true is the right default for any architecture that doesn't
545/// document otherwise (Mixtral does this), but it is a real per-model
546/// choice: see `MoeLayerConfig::norm_topk_prob`'s doc comment for why
547/// OLMoE specifically needs `false`.
548pub fn route_top_k_softmax(logits: &[f32], k: usize, norm_topk_prob: bool) -> RoutingDecision {
549    let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
550    let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
551    let sum: f32 = exps.iter().sum();
552    let probs: Vec<f32> = exps.iter().map(|e| e / sum).collect();
553
554    let mut idx: Vec<usize> = (0..probs.len()).collect();
555    idx.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
556    let top = &idx[..k.min(idx.len())];
557
558    let mut weights: Vec<f32> = top.iter().map(|&i| probs[i]).collect();
559    if norm_topk_prob {
560        let top_sum: f32 = weights.iter().sum();
561        for w in weights.iter_mut() {
562            *w /= top_sum;
563        }
564    }
565
566    RoutingDecision {
567        expert_ids: top.to_vec(),
568        weights,
569    }
570}
571
572/// sigmoid-then-renormalize top-k routing: score every expert with
573/// `sigmoid(logit)` (independently per expert, not a joint softmax
574/// distribution), pick the top-k by that score, then renormalize just
575/// the selected experts' sigmoid scores to sum to one. This is the
576/// DeepSeek-V3 / GLM4-MoE convention found in ik_llama.cpp's real GGUF
577/// hparams-loading source.
578pub fn route_top_k_sigmoid(logits: &[f32], k: usize) -> RoutingDecision {
579    let scores: Vec<f32> = logits.iter().map(|&l| sigmoid(l)).collect();
580
581    let mut idx: Vec<usize> = (0..scores.len()).collect();
582    idx.sort_unstable_by(|&a, &b| scores[b].partial_cmp(&scores[a]).unwrap());
583    let top = &idx[..k.min(idx.len())];
584
585    let sum: f32 = top.iter().map(|&i| scores[i]).sum();
586    let weights: Vec<f32> = if sum > 0.0 {
587        top.iter().map(|&i| scores[i] / sum).collect()
588    } else {
589        // Degenerate case (all selected scores are exactly zero,
590        // essentially never in practice for a real trained router):
591        // fall back to a uniform split rather than dividing by zero.
592        vec![1.0 / top.len() as f32; top.len()]
593    };
594
595    RoutingDecision {
596        expert_ids: top.to_vec(),
597        weights,
598    }
599}
600
601/// Sigmoid top-k routing with a real "aux-loss-free" per-expert bias
602/// term added *only* for top-k selection (`topk_method: "noaux_tc"` in
603/// Kimi K3's real `config.json`, `KimiMoEGate.forward` in
604/// `modeling_kimi_linear.py`, adapted from DeepSeek-V3's own MoE gate --
605/// the same convention, not Kimi-specific): the selection scores are
606/// `sigmoid(logit) + bias[expert]`, but the *weight* each selected
607/// expert's output gets multiplied by uses the raw, unbiased
608/// `sigmoid(logit)` -- getting this backwards (biasing the weight
609/// itself, not just the selection) would silently skew routed-expert
610/// contribution away from what the router actually learned. Weights are
611/// renormalized to sum to 1 (if `k>1`) then multiplied by
612/// `scaling_factor` (Kimi K3's `routed_scaling_factor`, 1.0 in its real
613/// config, i.e. a no-op there, but a real multiplier for any other
614/// model using this same convention with a different value).
615pub fn route_top_k_sigmoid_with_bias(
616    logits: &[f32],
617    bias: &[f32],
618    k: usize,
619    renormalize: bool,
620    scaling_factor: f32,
621) -> RoutingDecision {
622    assert_eq!(logits.len(), bias.len());
623    let scores: Vec<f32> = logits.iter().map(|&l| sigmoid(l)).collect();
624    let scores_for_choice: Vec<f32> = scores.iter().zip(bias.iter()).map(|(s, b)| s + b).collect();
625
626    let mut idx: Vec<usize> = (0..scores.len()).collect();
627    idx.sort_unstable_by(|&a, &b| {
628        scores_for_choice[b]
629            .partial_cmp(&scores_for_choice[a])
630            .unwrap()
631    });
632    let top = &idx[..k.min(idx.len())];
633
634    let mut weights: Vec<f32> = top.iter().map(|&i| scores[i]).collect();
635    if k > 1 && renormalize {
636        let sum: f32 = weights.iter().sum::<f32>() + 1e-20;
637        for w in weights.iter_mut() {
638            *w /= sum;
639        }
640    }
641    for w in weights.iter_mut() {
642        *w *= scaling_factor;
643    }
644
645    RoutingDecision {
646        expert_ids: top.to_vec(),
647        weights,
648    }
649}
650
651/// A CPU/GPU placement plan for a layer's experts.
652#[derive(Debug, Clone)]
653pub struct PlacementPlan {
654    pub default_placement: ExpertPlacement,
655    pub overrides: std::collections::HashMap<usize, ExpertPlacement>,
656}
657
658impl PlacementPlan {
659    pub fn all_cpu(n_experts: usize) -> Self {
660        PlacementPlan {
661            default_placement: ExpertPlacement::Cpu,
662            overrides: (0..n_experts).map(|i| (i, ExpertPlacement::Cpu)).collect(),
663        }
664    }
665
666    /// Index-based placeholder: puts the first `n_gpu_resident`
667    /// experts on GPU regardless of their actual size or how often
668    /// they're activated. Kept only as a trivial fallback for callers
669    /// with no real budget/hotness data at all (e.g. `ferrox smoke`'s
670    /// synthetic-weight demo); real deployments should use
671    /// `from_budget` instead, which places by measured VRAM budget and
672    /// observed expert hotness rather than by index.
673    pub fn hot_experts_on_gpu(n_experts: usize, n_gpu_resident: usize) -> Self {
674        let mut overrides = std::collections::HashMap::new();
675        for i in 0..n_experts.min(n_gpu_resident) {
676            overrides.insert(i, ExpertPlacement::GpuDevice(0));
677        }
678        PlacementPlan {
679            default_placement: ExpertPlacement::Cpu,
680            overrides,
681        }
682    }
683
684    /// Builds a placement plan from a real VRAM budget and each
685    /// expert's actual resident byte size (e.g. summed
686    /// `WeightMatrix::resident_bytes()` across an expert's gate/up/down
687    /// matrices), following ik_llama.cpp's `--cpu-moe`/`--override-tensor`
688    /// pattern of deciding CPU-vs-GPU per tensor rather than by a fixed
689    /// index cutoff.
690    ///
691    /// `activation_counts`, if given (one count per expert, e.g.
692    /// accumulated from `RoutingDecision::expert_ids` over a real or
693    /// representative workload), places the *most frequently activated*
694    /// experts on GPU first -- the actual point of expert offload,
695    /// since keeping a rarely-used expert resident in VRAM wastes the
696    /// budget a hot expert could have used instead. Without observed
697    /// counts, falls back to a documented, deterministic policy (index
698    /// order) rather than guessing at hotness.
699    ///
700    /// Greedy, not globally optimal (a smaller-but-colder expert can
701    /// still be skipped in favor of trying the next candidate once a
702    /// larger higher-priority expert doesn't fit) -- optimal knapsack
703    /// packing is not worth the complexity here, and greedy-by-priority
704    /// is the same approach real offload tooling uses.
705    pub fn from_budget(
706        expert_bytes: &[usize],
707        activation_counts: Option<&[u64]>,
708        vram_budget_bytes: u64,
709    ) -> Self {
710        let n = expert_bytes.len();
711        let mut order: Vec<usize> = (0..n).collect();
712        if let Some(counts) = activation_counts {
713            if counts.len() == n {
714                order.sort_by(|&a, &b| counts[b].cmp(&counts[a]).then(a.cmp(&b)));
715            }
716        }
717
718        let mut overrides = std::collections::HashMap::new();
719        let mut used: u64 = 0;
720        for idx in order {
721            let size = expert_bytes[idx] as u64;
722            if size == 0 || used + size > vram_budget_bytes {
723                continue;
724            }
725            used += size;
726            overrides.insert(idx, ExpertPlacement::GpuDevice(0));
727        }
728
729        PlacementPlan {
730            default_placement: ExpertPlacement::Cpu,
731            overrides,
732        }
733    }
734
735    /// A device-placement plan for EVERY layer's routed experts against
736    /// ONE shared VRAM budget -- the fix for the real accounting bug
737    /// where each layer independently called `from_budget` with the
738    /// full budget, so a model with N layers would plan N x the
739    /// configured bytes of GPU residency. All `(layer, expert)`
740    /// candidates compete in one global priority order (hottest first,
741    /// ties broken by layer then expert index for determinism), and a
742    /// candidate is only placed on the device if the *global* running
743    /// total still fits.
744    pub fn plan_layers_against_global_budget(
745        expert_bytes_per_layer: &[Vec<usize>],
746        activation_counts_per_layer: Option<&[Vec<u64>]>,
747        vram_budget_bytes: u64,
748    ) -> ResidencyPlan {
749        let mut candidates: Vec<(u64, usize, usize)> = Vec::new(); // (count, layer, expert)
750        for (l, sizes) in expert_bytes_per_layer.iter().enumerate() {
751            for e in 0..sizes.len() {
752                let count = activation_counts_per_layer
753                    .and_then(|cs| cs.get(l))
754                    .and_then(|c| c.get(e))
755                    .copied()
756                    .unwrap_or(0);
757                candidates.push((count, l, e));
758            }
759        }
760        candidates.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
761
762        let mut layer_overrides: Vec<std::collections::HashMap<usize, ExpertPlacement>> =
763            expert_bytes_per_layer
764                .iter()
765                .map(|_| std::collections::HashMap::new())
766                .collect();
767        let mut used: u64 = 0;
768        for (_, l, e) in candidates {
769            let size = expert_bytes_per_layer[l][e] as u64;
770            if size == 0 || used + size > vram_budget_bytes {
771                continue;
772            }
773            used += size;
774            layer_overrides[l].insert(e, ExpertPlacement::GpuDevice(0));
775        }
776
777        ResidencyPlan {
778            layer_plans: layer_overrides
779                .into_iter()
780                .map(|overrides| PlacementPlan {
781                    default_placement: ExpertPlacement::Cpu,
782                    overrides,
783                })
784                .collect(),
785            device_bytes_planned: used,
786            vram_budget_bytes,
787        }
788    }
789
790    pub fn placement_for(&self, expert_id: usize) -> ExpertPlacement {
791        self.overrides
792            .get(&expert_id)
793            .copied()
794            .unwrap_or(self.default_placement)
795    }
796}
797
798/// The output of `PlacementPlan::plan_layers_against_global_budget`:
799/// one per-layer `PlacementPlan` view over a single, globally-accounted
800/// device budget. `device_bytes_planned <= vram_budget_bytes` holds by
801/// construction across ALL layers combined -- the property the old
802/// per-layer planning could not provide.
803pub struct ResidencyPlan {
804    layer_plans: Vec<PlacementPlan>,
805    /// Total bytes this plan places on the device, summed across every
806    /// layer.
807    pub device_bytes_planned: u64,
808    /// The single budget every layer's placements were accounted
809    /// against.
810    pub vram_budget_bytes: u64,
811}
812
813impl ResidencyPlan {
814    pub fn layer_plan(&self, layer: usize) -> &PlacementPlan {
815        &self.layer_plans[layer]
816    }
817
818    pub fn n_layers(&self) -> usize {
819        self.layer_plans.len()
820    }
821}
822
823/// One expert's gate/up/down weight matrices. Each may be plain f32
824/// (synthetic/test weights) or still-quantized bytes loaded straight
825/// from a GGUF file (real checkpoints) -- `WeightMatrix::apply`
826/// dispatches to the right kernel either way.
827pub struct ExpertWeights {
828    pub gate: WeightMatrix,
829    pub up: WeightMatrix,
830    pub down: WeightMatrix,
831}
832
833/// One expert's gate/up/down bias vectors.
834///
835/// Kept separate from [`ExpertWeights`] because only the gpt-oss family
836/// ships them: every other MoE checkpoint ferrox loads has bias-free
837/// experts, and threading three `Option`s through the thirty
838/// `ExpertWeights` construction sites would pay for a feature one
839/// architecture uses.
840///
841/// Lengths are `expert_ffn_dim` for `gate`/`up` and `hidden_dim` for
842/// `down`, matching llama.cpp's `ffn_{gate,up}_exps_b` `{n_ff_exp,
843/// n_expert}` and `ffn_down_exps_b` `{n_embd, n_expert}`.
844#[derive(Debug, Clone, Default)]
845pub struct ExpertBias {
846    pub gate: Vec<f32>,
847    pub up: Vec<f32>,
848    pub down: Vec<f32>,
849}
850
851/// gpt-oss's SwiGLU sigmoid steepness (`llama-graph.cpp`,
852/// `LLM_FFN_SWIGLU_OAI_MOE`: `constexpr float alpha = 1.702f`).
853pub const SWIGLU_OAI_ALPHA: f32 = 1.702;
854/// gpt-oss's SwiGLU clamp (`constexpr float limit = 7.0f`, same site).
855pub const SWIGLU_OAI_LIMIT: f32 = 7.0;
856
857/// gpt-oss's clamped SwiGLU.
858///
859/// Transcribed from `ggml/src/ggml-cpu/ops.cpp`
860/// `ggml_compute_forward_swiglu_oai_f32`:
861///
862/// ```text
863/// x = min(gate, limit);
864/// y = clamp(up, -limit, limit);
865/// out_glu = x / (1 + expf(alpha * -x));
866/// dst = out_glu * (y + 1);
867/// ```
868///
869/// Three things differ from ordinary SwiGLU and all three matter: the
870/// gate is clamped from *above only*, the sigmoid is scaled by `alpha`
871/// rather than being plain `silu`, and the up branch carries a `+1`
872/// offset so a zero `up` passes the gate through instead of killing it.
873pub fn swiglu_oai(gate: &[f32], up: &[f32], alpha: f32, limit: f32) -> Vec<f32> {
874    debug_assert_eq!(gate.len(), up.len());
875    gate.iter()
876        .zip(up.iter())
877        .map(|(&g, &u)| {
878            let x = g.min(limit);
879            let y = u.clamp(-limit, limit);
880            let out_glu = x / (1.0 + (alpha * -x).exp());
881            out_glu * (y + 1.0)
882        })
883        .collect()
884}
885
886/// gpt-oss routing: pick the top-`k` experts by their **raw** router
887/// logits, then softmax over just those `k`.
888///
889/// This is llama.cpp's `LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX_WEIGHT`
890/// (`llama-graph.cpp::build_moe_ffn`), and it is *not* the same as
891/// [`route_top_k_softmax`]: there the softmax runs over all `n_expert`
892/// logits before selection, so the surviving weights are a slice of the
893/// full distribution and sum to less than one; here the normalization
894/// happens after selection, so the `k` weights sum to exactly one.
895/// Feeding a gpt-oss checkpoint through the ordinary softmax gating
896/// picks the same experts but weights them wrong.
897pub fn route_top_k_softmax_weight(logits: &[f32], k: usize) -> RoutingDecision {
898    let mut idx: Vec<usize> = (0..logits.len()).collect();
899    idx.sort_unstable_by(|&a, &b| logits[b].partial_cmp(&logits[a]).unwrap());
900    let top = &idx[..k.min(idx.len())];
901
902    let selected: Vec<f32> = top.iter().map(|&i| logits[i]).collect();
903    let max = selected.iter().copied().fold(f32::NEG_INFINITY, f32::max);
904    let exps: Vec<f32> = selected.iter().map(|&l| (l - max).exp()).collect();
905    let sum: f32 = exps.iter().sum();
906    let weights = if sum > 0.0 {
907        exps.iter().map(|&e| e / sum).collect()
908    } else {
909        exps
910    };
911
912    RoutingDecision {
913        expert_ids: top.to_vec(),
914        weights,
915    }
916}
917
918/// [`run_expert`] for gpt-oss: per-expert biases on all three matmuls
919/// and [`swiglu_oai`] in place of SwiGLU.
920///
921/// Deliberately plain CPU with no GPU or shared-activation fast path.
922/// gpt-oss is admitted to the CPU graph only, so a fast path here would
923/// be a second, unvalidated copy of the math.
924pub fn run_expert_oai(
925    hidden: &[f32],
926    expert: &ExpertWeights,
927    bias: &ExpertBias,
928    alpha: f32,
929    limit: f32,
930) -> Vec<f32> {
931    let mut gate = expert.gate.apply(hidden);
932    let mut up = expert.up.apply(hidden);
933    for (x, b) in gate.iter_mut().zip(bias.gate.iter()) {
934        *x += b;
935    }
936    for (x, b) in up.iter_mut().zip(bias.up.iter()) {
937        *x += b;
938    }
939    let activated = swiglu_oai(&gate, &up, alpha, limit);
940    let mut out = expert.down.apply(&activated);
941    for (x, b) in out.iter_mut().zip(bias.down.iter()) {
942        *x += b;
943    }
944    out
945}
946
947/// Which gated activation an expert's `down(act(gate(x)) * up(x))` FFN
948/// uses.
949///
950/// A named type rather than a `bool` or an implicit default, and a
951/// REQUIRED argument of [`run_expert`] / [`run_expert_placed`], because
952/// the alternative already failed once: every routed-expert path in
953/// `ferrox-models` hardcoded SwiGLU while only the dense arm consulted
954/// `ModelConfig::ffn_activation`, so a GeGLU MoE would have computed the
955/// wrong activation with nothing to notice. A caller cannot forget an
956/// argument the compiler demands.
957///
958/// [`Geglu`](GluAct::Geglu) is `gelu(gate) * up` with llama.cpp's tanh
959/// GELU approximation (`ferrox_core::matmul::gelu`), which is what
960/// `build_moe_ffn` does under `LLM_FFN_GELU` -- the real shape of
961/// llama.cpp's `grok` (`src/models/grok.cpp`, `LLM_FFN_GELU` passed to
962/// `build_moe_ffn`).
963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub enum GluAct {
965    /// `silu(gate) * up`.
966    Swiglu,
967    /// `gelu(gate) * up`.
968    Geglu,
969}
970
971impl GluAct {
972    /// The gated combine itself. One place, so a new variant is a
973    /// compile error at every site instead of a silent SwiGLU.
974    pub fn apply(self, gate: &[f32], up: &[f32]) -> Vec<f32> {
975        match self {
976            GluAct::Swiglu => swiglu(gate, up),
977            GluAct::Geglu => geglu(gate, up),
978        }
979    }
980
981    /// The scalar gate nonlinearity, for callers that fuse the multiply
982    /// into a loop of their own (`cpu_moe_topk_parallel_slots`).
983    pub fn gate_fn(self) -> fn(f32) -> f32 {
984        match self {
985            GluAct::Swiglu => ferrox_core::matmul::silu,
986            GluAct::Geglu => ferrox_core::matmul::gelu,
987        }
988    }
989
990    /// Whether the fused device kernels, which only implement SwiGLU,
991    /// may serve this activation.
992    pub fn is_swiglu(self) -> bool {
993        matches!(self, GluAct::Swiglu)
994    }
995}
996
997/// Runs one token's hidden state through a single expert's gated FFN.
998///
999/// `act` is not optional on purpose -- see [`GluAct`].
1000pub fn run_expert(hidden: &[f32], expert: &ExpertWeights, act: GluAct) -> Vec<f32> {
1001    #[cfg(any(feature = "cuda", feature = "metal"))]
1002    if act.is_swiglu() {
1003        // Full SwiGLU on-device (1× upload + 1× download) when dense GPU
1004        // is on. SwiGLU-only kernel: a GeGLU expert must not take it.
1005        if let Some(out) = ferrox_core::WeightMatrix::apply_gpu_dense_ffn_swiglu(
1006            &expert.gate,
1007            &expert.up,
1008            &expert.down,
1009            hidden,
1010        ) {
1011            return out;
1012        }
1013    }
1014    #[cfg(any(feature = "cuda", feature = "metal"))]
1015    {
1016        // Gate and up share `hidden` — one GPU upload / multi-matvec.
1017        // Activation-agnostic: the combine happens on the host below.
1018        if let Some(mut outs) =
1019            ferrox_core::WeightMatrix::apply_gpu_multi(&[&expert.gate, &expert.up], hidden)
1020        {
1021            let up = outs.pop().unwrap();
1022            let gate = outs.pop().unwrap();
1023            let activated = act.apply(&gate, &up);
1024            return expert.down.apply(&activated);
1025        }
1026    }
1027    // Share one Q8 activation quant across gate+up when INT_DOT is on
1028    // (OLMoE: avoids 2× quantize_activations_q8 per expert).
1029    if ferrox_core::weight_matrix::cpu_int_dot_enabled() && hidden.len().is_multiple_of(32) {
1030        let act_q8 = ferrox_quant::quantize_activations_q8(hidden);
1031        // gate and up are independent over the same activation, so their
1032        // parallel regions can overlap instead of running back to back.
1033        // Decode's deficit is scheduling, not kernels -- see
1034        // `WeightMatrix::apply_three`, which does this for q/k/v.
1035        let (g, u) = ferrox_core::par::join2(
1036            || expert.gate.apply_cpu_q8(&act_q8),
1037            || expert.up.apply_cpu_q8(&act_q8),
1038        );
1039        if let (Some(gate), Some(up)) = (g, u) {
1040            let activated = act.apply(&gate, &up);
1041            return expert.down.apply(&activated);
1042        }
1043    }
1044    let (gate, up) =
1045        ferrox_core::par::join2(|| expert.gate.apply(hidden), || expert.up.apply(hidden));
1046    let activated = act.apply(&gate, &up);
1047    expert.down.apply(&activated)
1048}
1049
1050/// `run_expert`, but actually consulting `placement` instead of always
1051/// running on CPU -- this is the real execution consequence
1052/// `PlacementPlan` previously computed but nothing acted on: a
1053/// `GpuDevice`-placed expert's gate/up/down matvecs go through
1054/// `WeightMatrix::apply_gpu` (real CUDA and/or Metal kernels for
1055/// Q8_0/Q4_0/Q4_K/Q5_K/Q6_K), falling straight through to the ordinary
1056/// CPU path for any matrix `apply_gpu` returns `None` for (an
1057/// unsupported quant kind, or a real launch failure) -- so this is
1058/// always correct, never a hard failure, regardless of GPU availability.
1059///
1060/// Every call re-uploads each weight matrix to the device from scratch
1061/// (see `WeightMatrix::apply_gpu`'s doc comment) -- correct, but not
1062/// yet the persistent-GPU-residency throughput win real expert offload
1063/// needs; a real, disclosed limit of this round, not overclaimed.
1064///
1065/// Without a GPU feature (`cuda` / `metal`) compiled in, this has the
1066/// exact same signature and always calls `run_expert` (ignoring
1067/// `placement`), so callers (e.g. `ferrox-models::decoder::Decoder`)
1068/// can call it unconditionally regardless of how this crate was built,
1069/// with correct behavior either way.
1070#[cfg(any(feature = "cuda", feature = "metal"))]
1071pub fn run_expert_placed(
1072    hidden: &[f32],
1073    expert: &ExpertWeights,
1074    placement: ExpertPlacement,
1075    act: GluAct,
1076) -> Vec<f32> {
1077    if matches!(placement, ExpertPlacement::GpuDevice(_)) {
1078        #[cfg(any(feature = "cuda", feature = "metal"))]
1079        if act.is_swiglu() {
1080            // SwiGLU-only fused kernel; a GeGLU expert falls through to
1081            // the split gate/up launches below, which are activation
1082            // agnostic because the combine runs on the host.
1083            if let Some(out) = ferrox_core::WeightMatrix::apply_gpu_dense_ffn_swiglu(
1084                &expert.gate,
1085                &expert.up,
1086                &expert.down,
1087                hidden,
1088            ) {
1089                return out;
1090            }
1091        }
1092        #[cfg(any(feature = "cuda", feature = "metal"))]
1093        {
1094            if let Some(mut outs) =
1095                ferrox_core::WeightMatrix::apply_gpu_multi(&[&expert.gate, &expert.up], hidden)
1096            {
1097                let up = outs.pop().unwrap();
1098                let gate = outs.pop().unwrap();
1099                let activated = act.apply(&gate, &up);
1100                if let Some(down) = expert.down.apply_gpu(&activated) {
1101                    return down;
1102                }
1103                return expert.down.apply(&activated);
1104            }
1105        }
1106        if let Some(gate) = expert.gate.apply_gpu(hidden) {
1107            if let Some(up) = expert.up.apply_gpu(hidden) {
1108                let activated = act.apply(&gate, &up);
1109                if let Some(down) = expert.down.apply_gpu(&activated) {
1110                    return down;
1111                }
1112            }
1113        }
1114    }
1115    run_expert(hidden, expert, act)
1116}
1117
1118#[cfg(not(any(feature = "cuda", feature = "metal")))]
1119pub fn run_expert_placed(
1120    hidden: &[f32],
1121    expert: &ExpertWeights,
1122    _placement: ExpertPlacement,
1123    act: GluAct,
1124) -> Vec<f32> {
1125    run_expert(hidden, expert, act)
1126}
1127
1128/// Combines routed + shared expert outputs for one token.
1129pub fn combine_expert_outputs(
1130    routed_outputs: &[(Vec<f32>, f32)],
1131    shared_outputs: &[Vec<f32>],
1132    hidden_dim: usize,
1133) -> Vec<f32> {
1134    let mut out = vec![0f32; hidden_dim];
1135    for (expert_out, weight) in routed_outputs {
1136        for (o, e) in out.iter_mut().zip(expert_out.iter()) {
1137            *o += e * weight;
1138        }
1139    }
1140    for shared_out in shared_outputs {
1141        for (o, e) in out.iter_mut().zip(shared_out.iter()) {
1142            *o += e;
1143        }
1144    }
1145    out
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    /// The property the global planner exists for: with N layers of
1151    /// identical experts and a budget that fits exactly K experts,
1152    /// exactly K experts are device-placed across ALL layers combined
1153    /// -- not K per layer, which is what independent per-layer
1154    /// `from_budget` calls with the same budget would produce (N*K).
1155    #[test]
1156    fn global_budget_cannot_be_multiplied_across_layers() {
1157        let n_layers = 10;
1158        let sizes: Vec<Vec<usize>> = (0..n_layers).map(|_| vec![100usize; 4]).collect();
1159        let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 250);
1160
1161        let total_placed: usize = (0..n_layers)
1162            .map(|l| {
1163                (0..4)
1164                    .filter(|&e| plan.layer_plan(l).placement_for(e) != ExpertPlacement::Cpu)
1165                    .count()
1166            })
1167            .sum();
1168        assert_eq!(
1169            total_placed, 2,
1170            "250 bytes fits exactly 2 x 100-byte experts, globally"
1171        );
1172        assert_eq!(plan.device_bytes_planned, 200);
1173        assert!(plan.device_bytes_planned <= plan.vram_budget_bytes);
1174
1175        // The old shape of the bug, for contrast: per-layer planning
1176        // with the same budget places 2 experts in EVERY layer.
1177        let per_layer_total: usize = (0..n_layers)
1178            .map(|_| {
1179                let p = PlacementPlan::from_budget(&[100; 4], None, 250);
1180                (0..4)
1181                    .filter(|&e| p.placement_for(e) != ExpertPlacement::Cpu)
1182                    .count()
1183            })
1184            .sum();
1185        assert_eq!(per_layer_total, 20, "per-layer planning overcommits 10x");
1186    }
1187
1188    /// Hot experts win device slots across layer boundaries: a single
1189    /// very hot expert in a late layer beats cold experts in earlier
1190    /// layers.
1191    #[test]
1192    fn global_planning_prioritizes_hotness_across_layers() {
1193        let sizes: Vec<Vec<usize>> = (0..3).map(|_| vec![100usize; 2]).collect();
1194        let mut counts: Vec<Vec<u64>> = (0..3).map(|_| vec![0u64; 2]).collect();
1195        counts[2][1] = 50; // the only hot expert lives in the last layer
1196        counts[0][0] = 10;
1197        let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, Some(&counts), 200);
1198
1199        assert_eq!(
1200            plan.layer_plan(2).placement_for(1),
1201            ExpertPlacement::GpuDevice(0),
1202            "hottest expert (layer 2) must win a slot"
1203        );
1204        assert_eq!(
1205            plan.layer_plan(0).placement_for(0),
1206            ExpertPlacement::GpuDevice(0),
1207            "second-hottest expert (layer 0) takes the remaining slot"
1208        );
1209        assert_eq!(plan.device_bytes_planned, 200);
1210    }
1211
1212    /// Zero budget places nothing anywhere; empty (dense) layers are
1213    /// legal and contribute no candidates.
1214    #[test]
1215    fn global_planning_handles_zero_budget_and_dense_layers() {
1216        let sizes = vec![Vec::new(), vec![100usize; 3], Vec::new()];
1217        let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 0);
1218        assert_eq!(plan.device_bytes_planned, 0);
1219        assert_eq!(plan.n_layers(), 3);
1220        for e in 0..3 {
1221            assert_eq!(plan.layer_plan(1).placement_for(e), ExpertPlacement::Cpu);
1222        }
1223    }
1224
1225    use super::*;
1226
1227    #[test]
1228    fn top_k_selects_highest_scoring_experts() {
1229        let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1230        let decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1231        assert_eq!(decision.expert_ids, vec![1, 3]);
1232        let sum: f32 = decision.weights.iter().sum();
1233        assert!((sum - 1.0).abs() < 1e-5);
1234        assert!(decision.weights[0] > decision.weights[1]);
1235    }
1236
1237    #[test]
1238    fn top_k_weights_always_sum_to_one_regardless_of_k() {
1239        let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1240        for k in 1..=8 {
1241            let decision = route_top_k(&logits, k, GatingFunction::Softmax, true);
1242            let sum: f32 = decision.weights.iter().sum();
1243            assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
1244        }
1245    }
1246
1247    /// `norm_topk_prob: false` -- OLMoE's real convention (see
1248    /// `MoeLayerConfig::norm_topk_prob`'s doc comment). Golden values
1249    /// hand-computed independently: full softmax over all 8 logits
1250    /// (sum of exp(l_i - 8) = 1.5814460129), then the raw (un-renormalized)
1251    /// probabilities of the top-3 selected experts (indices 7, 6, 5 --
1252    /// logits 8, 7, 6). This is the exact bug that was silently producing
1253    /// wrong OLMoE output: the old code could only ever compute a
1254    /// top-k-local softmax (mathematically identical to
1255    /// always-renormalize), with no way to recover the un-renormalized
1256    /// probability relative to *all* experts.
1257    #[test]
1258    fn norm_topk_prob_false_uses_raw_full_softmax_probability_not_renormalized() {
1259        let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1260        let decision = route_top_k(&logits, 3, GatingFunction::Softmax, false);
1261
1262        assert_eq!(decision.expert_ids, vec![7, 6, 5]);
1263
1264        let expected = [0.6323223_f32, 0.2326232, 0.0855683];
1265        for (got, want) in decision.weights.iter().zip(expected.iter()) {
1266            assert!((got - want).abs() < 1e-4, "got={got} want={want}");
1267        }
1268
1269        let sum: f32 = decision.weights.iter().sum();
1270        assert!(
1271            (sum - 0.9505138).abs() < 1e-4,
1272            "raw top-3 probability mass should be < 1 (it's a subset of a full 8-way softmax), got sum={sum}"
1273        );
1274
1275        // Selecting the same experts with norm_topk_prob=true must
1276        // renormalize to the exact same values divided by that sum --
1277        // proving the two modes agree on *which* experts fire and differ
1278        // only in the final weight scaling.
1279        let normalized = route_top_k(&logits, 3, GatingFunction::Softmax, true);
1280        assert_eq!(normalized.expert_ids, decision.expert_ids);
1281        for (raw, norm) in decision.weights.iter().zip(normalized.weights.iter()) {
1282            assert!(
1283                (raw / sum - norm).abs() < 1e-4,
1284                "raw={raw} sum={sum} normalized={norm}"
1285            );
1286        }
1287    }
1288
1289    #[test]
1290    fn sigmoid_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1291        // Sigmoid is monotonic in its input, so for a given set of
1292        // logits, top-k-by-sigmoid-score must select the exact same
1293        // expert ids as top-k-by-raw-logit (sigmoid just changes the
1294        // *weights*, not which experts are chosen).
1295        let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1296        let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1297        let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1298        assert_eq!(softmax_decision.expert_ids, sigmoid_decision.expert_ids);
1299    }
1300
1301    #[test]
1302    fn sigmoid_gating_weights_sum_to_one() {
1303        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1304        for k in 1..=8 {
1305            let decision = route_top_k(&logits, k, GatingFunction::Sigmoid, true);
1306            let sum: f32 = decision.weights.iter().sum();
1307            assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
1308        }
1309    }
1310
1311    #[test]
1312    fn bias_only_affects_selection_not_the_final_weight_value() {
1313        // Expert 0 has the lower raw score but a large positive bias, so
1314        // biased selection must pick it over expert 1 -- but the WEIGHT
1315        // it ends up with must be its raw (unbiased) sigmoid score, not
1316        // score+bias. Getting this backwards would silently make a
1317        // barely-selected expert dominate the combine.
1318        let logits = vec![0.1, 2.0];
1319        let bias = vec![10.0, 0.0];
1320        let decision = route_top_k_sigmoid_with_bias(&logits, &bias, 1, true, 1.0);
1321        assert_eq!(decision.expert_ids, vec![0]);
1322        // k=1 -> renormalization is a no-op (single weight / itself = 1,
1323        // scaled by 1.0), so the weight is just sigmoid(0.1), not 1.0.
1324        assert!((decision.weights[0] - sigmoid(0.1)).abs() < 1e-5);
1325    }
1326
1327    #[test]
1328    fn without_bias_selection_falls_back_to_plain_sigmoid_top_k() {
1329        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1330        let zero_bias = vec![0.0; logits.len()];
1331        let biased = route_top_k_sigmoid_with_bias(&logits, &zero_bias, 3, true, 1.0);
1332        let plain = route_top_k(&logits, 3, GatingFunction::Sigmoid, true);
1333        assert_eq!(biased.expert_ids, plain.expert_ids);
1334        for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1335            assert!((a - b).abs() < 1e-6);
1336        }
1337    }
1338
1339    #[test]
1340    fn scaling_factor_multiplies_every_weight() {
1341        let logits = vec![1.0, 2.0, 3.0];
1342        let bias = vec![0.0; 3];
1343        let unscaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 1.0);
1344        let scaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 2.5);
1345        for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1346            assert!((u * 2.5 - s).abs() < 1e-5);
1347        }
1348    }
1349
1350    #[test]
1351    fn sigmoid_and_softmax_weights_differ_for_the_same_logits() {
1352        // The whole point of the distinction: sigmoid scores each
1353        // expert independently (not as a joint distribution), so the
1354        // relative weighting between two selected experts differs from
1355        // softmax's, even though both sum to one and pick the same
1356        // experts. If this test ever fails by finding the two paths
1357        // identical, something has collapsed the sigmoid path back
1358        // into softmax.
1359        let logits = vec![3.0, 1.0, -2.0, 0.5];
1360        let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1361        let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1362        assert!(
1363            (softmax_decision.weights[0] - sigmoid_decision.weights[0]).abs() > 1e-3,
1364            "softmax and sigmoid gating should generally produce different weight splits for the same logits"
1365        );
1366    }
1367
1368    #[test]
1369    fn sqrt_softplus_matches_hand_computed_values_at_zero_and_positive_logit() {
1370        // softplus(0) = ln(2), sqrt(ln(2)) -- exact closed form, not just a
1371        // property check, to pin the real DeepSeek V4 formula
1372        // (sqrt(softplus(x)), not e.g. softplus(sqrt(x)) or sqrt(sigmoid)).
1373        assert!((sqrt_softplus(0.0) - 2.0_f32.ln().sqrt()).abs() < 1e-6);
1374        // softplus(x) -> x for large positive x, so sqrt_softplus(x) -> sqrt(x).
1375        assert!((sqrt_softplus(20.0) - 20.0_f32.sqrt()).abs() < 1e-3);
1376    }
1377
1378    /// Group-limited routing CONCENTRATES; it does not spread.
1379    ///
1380    /// This test replaces one that asserted the opposite -- that
1381    /// `total_k = 2` over two groups keeps "both group winners" -- which
1382    /// was the shape of the bug, not a property of the rule. The real
1383    /// DeepSeek-V3 / GLM `n_group`/`topk_group` router scores each group
1384    /// by the sum of its top-2 members, keeps the `topk_group` best
1385    /// groups, masks every expert in the rest to `-inf`, and then runs
1386    /// ONE GLOBAL top-k over what survives. With `topk_group = 1` only
1387    /// group 0 survives, so both selected experts must come from it and
1388    /// expert 3 must NOT fire.
1389    ///
1390    /// It therefore fails against the previous implementation, which
1391    /// took `k_per_group` from every group and truncated afterwards.
1392    #[test]
1393    fn group_limited_routing_concentrates_into_the_surviving_groups() {
1394        // 4 experts, 2 groups of 2. Group 0 holds the two best scores.
1395        let logits = vec![5.0, 4.5, 0.2, 0.1];
1396        let d = route_top_k_grouped(&logits, 2, 1, 2, GatingFunction::Softmax, true);
1397        assert_eq!(d.expert_ids.len(), 2);
1398        let mut ids = d.expert_ids.clone();
1399        ids.sort_unstable();
1400        assert_eq!(ids, vec![0, 1], "both experts must come from group 0");
1401        let sum: f32 = d.weights.iter().sum();
1402        assert!((sum - 1.0).abs() < 1e-4);
1403    }
1404
1405    /// The group score is the sum of a group's top TWO, not its best
1406    /// single member. A group with one spike and nothing behind it loses
1407    /// to a group with two strong members.
1408    #[test]
1409    fn a_group_is_scored_by_its_top_two_not_by_its_best_member() {
1410        // Group 0: one spike and a dead expert, carrying 0.426 of the
1411        // softmax mass between them. Group 1: two solid members
1412        // carrying 0.574 together, neither of which beats the spike on
1413        // its own. Scoring by best member picks group 0; scoring by
1414        // top-2 sum picks group 1, which is the reference rule.
1415        let logits = vec![1.0, -20.0, 0.7, 0.5];
1416        let d = route_top_k_grouped(&logits, 2, 1, 2, GatingFunction::Softmax, true);
1417        let mut ids = d.expert_ids.clone();
1418        ids.sort_unstable();
1419        assert_eq!(ids, vec![2, 3], "the two-strong-members group wins");
1420    }
1421
1422    #[test]
1423    fn sqrtsoftplus_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1424        // sqrt(softplus(x)) is monotonically increasing in x (both sqrt
1425        // and softplus are), so top-k-by-score must agree with top-k by
1426        // raw logit on *which* experts fire, same reasoning as the
1427        // sigmoid monotonicity test above.
1428        let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1429        let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1430        let sqrtsoftplus_decision = route_top_k(&logits, 2, GatingFunction::SqrtSoftplus, true);
1431        assert_eq!(
1432            softmax_decision.expert_ids,
1433            sqrtsoftplus_decision.expert_ids
1434        );
1435    }
1436
1437    #[test]
1438    fn sqrtsoftplus_weights_sum_to_one_when_normalized() {
1439        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1440        for k in 1..=8 {
1441            let decision = route_top_k(&logits, k, GatingFunction::SqrtSoftplus, true);
1442            let sum: f32 = decision.weights.iter().sum();
1443            assert!((sum - 1.0).abs() < 1e-4, "k={k} sum={sum}");
1444        }
1445    }
1446
1447    #[test]
1448    fn sqrtsoftplus_bias_only_affects_selection_not_the_final_weight_value() {
1449        // Same structure as `bias_only_affects_selection_not_the_final_weight_value`
1450        // but for the sqrt-softplus scoring function DeepSeek V4's real
1451        // non-hash MoE layers use.
1452        let logits = vec![0.1, 2.0];
1453        let bias = vec![10.0, 0.0];
1454        let decision = route_top_k_sqrtsoftplus_with_bias(&logits, &bias, 1, true, 1.0);
1455        assert_eq!(decision.expert_ids, vec![0]);
1456        assert!((decision.weights[0] - sqrt_softplus(0.1)).abs() < 1e-5);
1457    }
1458
1459    #[test]
1460    fn sqrtsoftplus_without_bias_selection_falls_back_to_plain_top_k() {
1461        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1462        let zero_bias = vec![0.0; logits.len()];
1463        let biased = route_top_k_sqrtsoftplus_with_bias(&logits, &zero_bias, 3, true, 1.0);
1464        let plain = route_top_k(&logits, 3, GatingFunction::SqrtSoftplus, true);
1465        assert_eq!(biased.expert_ids, plain.expert_ids);
1466        for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1467            assert!((a - b).abs() < 1e-6);
1468        }
1469    }
1470
1471    #[test]
1472    fn hash_routing_uses_the_fixed_table_ids_regardless_of_logit_ranking() {
1473        // Expert 0 has by far the highest logit, but the real mechanism
1474        // never looks at the router's ranking to choose experts for a
1475        // hash-routed layer -- the table says [2, 1], so that's what
1476        // fires, full stop.
1477        let logits = vec![100.0, 1.0, 0.5, -3.0];
1478        let hash_expert_ids = vec![2usize, 1usize];
1479        let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1480        assert_eq!(decision.expert_ids, vec![2, 1]);
1481    }
1482
1483    #[test]
1484    fn hash_routing_weights_come_from_the_real_router_logits_not_a_fixed_split() {
1485        // The table fixes *which* experts fire, but their relative
1486        // combine weight still comes from sqrt(softplus(logit)) gathered
1487        // at those ids -- not a uniform 1/n split. Expert 2's logit (3.0)
1488        // is much larger than expert 1's (0.1), so its weight must
1489        // dominate even though both were unconditionally selected.
1490        let logits = vec![-5.0, 0.1, 3.0, -5.0];
1491        let hash_expert_ids = vec![2usize, 1usize];
1492        let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1493        assert!(decision.weights[0] > decision.weights[1]);
1494        let sum: f32 = decision.weights.iter().sum();
1495        assert!((sum - 1.0).abs() < 1e-5);
1496        let expected0 = sqrt_softplus(3.0) / (sqrt_softplus(3.0) + sqrt_softplus(0.1));
1497        assert!((decision.weights[0] - expected0).abs() < 1e-5);
1498    }
1499
1500    #[test]
1501    fn hash_routing_scaling_factor_multiplies_every_weight() {
1502        let logits = vec![1.0, 2.0, 3.0];
1503        let hash_expert_ids = vec![0usize, 2usize];
1504        let unscaled = route_hash(&hash_expert_ids, &logits, true, 1.0);
1505        let scaled = route_hash(&hash_expert_ids, &logits, true, 2.5);
1506        for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1507            assert!((u * 2.5 - s).abs() < 1e-5);
1508        }
1509    }
1510
1511    #[test]
1512    fn placement_plan_defaults_to_cpu_for_unlisted_experts() {
1513        let plan = PlacementPlan::hot_experts_on_gpu(256, 8);
1514        assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1515        assert_eq!(plan.placement_for(7), ExpertPlacement::GpuDevice(0));
1516        assert_eq!(plan.placement_for(8), ExpertPlacement::Cpu);
1517        assert_eq!(plan.placement_for(255), ExpertPlacement::Cpu);
1518    }
1519
1520    #[test]
1521    fn all_cpu_plan_never_returns_gpu() {
1522        let plan = PlacementPlan::all_cpu(64);
1523        for i in 0..64 {
1524            assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1525        }
1526    }
1527
1528    #[test]
1529    fn from_budget_fits_as_many_experts_as_the_vram_budget_allows() {
1530        // 4 experts, 100 bytes each: a 250-byte budget fits exactly 2.
1531        let sizes = vec![100usize, 100, 100, 100];
1532        let plan = PlacementPlan::from_budget(&sizes, None, 250);
1533        let on_gpu = (0..4)
1534            .filter(|&i| plan.placement_for(i) == ExpertPlacement::GpuDevice(0))
1535            .count();
1536        assert_eq!(on_gpu, 2);
1537    }
1538
1539    #[test]
1540    fn from_budget_prioritizes_the_most_frequently_activated_experts() {
1541        // Expert 2 is by far the hottest but is neither first nor
1542        // largest -- a real budget-aware plan must still pick it first.
1543        let sizes = vec![50usize, 50, 50, 50];
1544        let counts = vec![1u64, 2, 100, 3];
1545        // Budget for exactly one expert.
1546        let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 50);
1547        assert_eq!(
1548            plan.placement_for(2),
1549            ExpertPlacement::GpuDevice(0),
1550            "the hottest expert (index 2) must be the one placed on GPU"
1551        );
1552        assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1553        assert_eq!(plan.placement_for(1), ExpertPlacement::Cpu);
1554        assert_eq!(plan.placement_for(3), ExpertPlacement::Cpu);
1555    }
1556
1557    #[test]
1558    fn from_budget_skips_an_expert_that_does_not_fit_and_tries_the_next() {
1559        // Expert 0 is too big for the budget alone; experts 1 and 2
1560        // together fit and should both be placed.
1561        let sizes = vec![200usize, 60, 60];
1562        let plan = PlacementPlan::from_budget(&sizes, None, 120);
1563        assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1564        assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1565        assert_eq!(plan.placement_for(2), ExpertPlacement::GpuDevice(0));
1566    }
1567
1568    #[test]
1569    fn from_budget_with_zero_vram_places_nothing_on_gpu() {
1570        let sizes = vec![10usize, 20, 30];
1571        let plan = PlacementPlan::from_budget(&sizes, None, 0);
1572        for i in 0..3 {
1573            assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1574        }
1575    }
1576
1577    #[test]
1578    fn from_budget_ignores_mismatched_activation_counts_length_rather_than_panicking() {
1579        let sizes = vec![10usize, 10];
1580        let counts = vec![1u64]; // wrong length
1581        let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 100);
1582        // Falls back to index order; both fit within the budget either way.
1583        assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1584        assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1585    }
1586
1587    #[test]
1588    fn combine_expert_outputs_weights_routed_and_adds_shared() {
1589        let routed = vec![(vec![2.0, 2.0], 0.5), (vec![4.0, 4.0], 0.5)];
1590        let shared = vec![vec![1.0, 1.0]];
1591        let out = combine_expert_outputs(&routed, &shared, 2);
1592        assert_eq!(out, vec![4.0, 4.0]);
1593    }
1594
1595    #[test]
1596    fn run_expert_produces_correct_output_dimension() {
1597        use ferrox_core::tensor::Tensor;
1598        let hidden_dim = 4;
1599        let ffn_dim = 3;
1600        let expert = ExpertWeights {
1601            gate: WeightMatrix::F32(Tensor::new(
1602                vec![0.1; ffn_dim * hidden_dim],
1603                vec![ffn_dim, hidden_dim],
1604            )),
1605            up: WeightMatrix::F32(Tensor::new(
1606                vec![0.2; ffn_dim * hidden_dim],
1607                vec![ffn_dim, hidden_dim],
1608            )),
1609            down: WeightMatrix::F32(Tensor::new(
1610                vec![0.3; hidden_dim * ffn_dim],
1611                vec![hidden_dim, ffn_dim],
1612            )),
1613        };
1614        let hidden = vec![1.0, -1.0, 0.5, 0.5];
1615        let out = run_expert(&hidden, &expert, GluAct::Swiglu);
1616        assert_eq!(out.len(), hidden_dim);
1617        assert!(out.iter().all(|v| v.is_finite()));
1618    }
1619
1620    /// A GeGLU expert must compute `gelu(gate) * up`, not SwiGLU.
1621    ///
1622    /// Checked against an independently written scalar reference, and
1623    /// separately asserted to DIFFER from the SwiGLU result: without the
1624    /// second half, an implementation that ignored `act` entirely and
1625    /// always ran SwiGLU could still pass a loose tolerance check on
1626    /// weights where the two activations happen to be close.
1627    #[test]
1628    fn geglu_expert_computes_gelu_not_silu() {
1629        use ferrox_core::tensor::Tensor;
1630        let hidden_dim = 4;
1631        let ffn_dim = 3;
1632        let g: Vec<f32> = (0..ffn_dim * hidden_dim)
1633            .map(|i| (i as f32 * 0.7).sin() * 3.0)
1634            .collect();
1635        let u: Vec<f32> = (0..ffn_dim * hidden_dim)
1636            .map(|i| (i as f32 * 0.31).cos())
1637            .collect();
1638        let d: Vec<f32> = (0..hidden_dim * ffn_dim)
1639            .map(|i| (i as f32 * 0.19).sin() * 0.5)
1640            .collect();
1641        let expert = ExpertWeights {
1642            gate: WeightMatrix::F32(Tensor::new(g.clone(), vec![ffn_dim, hidden_dim])),
1643            up: WeightMatrix::F32(Tensor::new(u.clone(), vec![ffn_dim, hidden_dim])),
1644            down: WeightMatrix::F32(Tensor::new(d.clone(), vec![hidden_dim, ffn_dim])),
1645        };
1646        let hidden = vec![1.0, -1.0, 0.5, 0.25];
1647
1648        // Independent reference: three plain loops, tanh-approx GELU
1649        // spelled out rather than borrowed from ferrox-core.
1650        let row = |w: &[f32], cols: usize, r: usize, x: &[f32]| -> f32 {
1651            (0..cols).map(|c| w[r * cols + c] * x[c]).sum()
1652        };
1653        let mut activated = vec![0f32; ffn_dim];
1654        for (r, a) in activated.iter_mut().enumerate() {
1655            let gv = row(&g, hidden_dim, r, &hidden);
1656            let uv = row(&u, hidden_dim, r, &hidden);
1657            let t = (0.797_884_6f32 * (gv + 0.044_715 * gv * gv * gv)).tanh();
1658            *a = 0.5 * gv * (1.0 + t) * uv;
1659        }
1660        let expected: Vec<f32> = (0..hidden_dim)
1661            .map(|r| row(&d, ffn_dim, r, &activated))
1662            .collect();
1663
1664        let got = run_expert(&hidden, &expert, GluAct::Geglu);
1665        assert_eq!(got.len(), hidden_dim);
1666        for (i, (a, b)) in got.iter().zip(expected.iter()).enumerate() {
1667            assert!(
1668                (a - b).abs() < 1e-5,
1669                "GeGLU expert row {i}: got {a}, expected {b}"
1670            );
1671        }
1672
1673        let swiglu_out = run_expert(&hidden, &expert, GluAct::Swiglu);
1674        assert!(
1675            got.iter()
1676                .zip(swiglu_out.iter())
1677                .any(|(a, b)| (a - b).abs() > 1e-4),
1678            "GeGLU and SwiGLU must not agree on these weights -- if they do, \
1679             this test cannot detect a routed expert that silently ran SwiGLU"
1680        );
1681
1682        // Placement must not change the activation either.
1683        assert_eq!(
1684            run_expert_placed(
1685                &hidden,
1686                &expert,
1687                ExpertPlacement::GpuDevice(0),
1688                GluAct::Geglu
1689            ),
1690            got,
1691            "a GPU-placed GeGLU expert must not fall into a SwiGLU kernel"
1692        );
1693    }
1694
1695    /// `run_expert_placed` must be a real drop-in for `run_expert` when
1696    /// no GPU dispatch actually happens -- true unconditionally without
1697    /// the `cuda` feature, and true even *with* the feature for `Cpu`
1698    /// placement (which never calls `apply_gpu` at all) or an
1699    /// unsupported quant kind (F32 here, which `apply_gpu` always
1700    /// returns `None` for, falling through to `run_expert`).
1701    #[test]
1702    fn run_expert_placed_matches_run_expert_when_nothing_is_gpu_dispatched() {
1703        use ferrox_core::tensor::Tensor;
1704        let hidden_dim = 4;
1705        let ffn_dim = 3;
1706        let expert = ExpertWeights {
1707            gate: WeightMatrix::F32(Tensor::new(
1708                vec![0.1; ffn_dim * hidden_dim],
1709                vec![ffn_dim, hidden_dim],
1710            )),
1711            up: WeightMatrix::F32(Tensor::new(
1712                vec![0.2; ffn_dim * hidden_dim],
1713                vec![ffn_dim, hidden_dim],
1714            )),
1715            down: WeightMatrix::F32(Tensor::new(
1716                vec![0.3; hidden_dim * ffn_dim],
1717                vec![hidden_dim, ffn_dim],
1718            )),
1719        };
1720        let hidden = vec![1.0, -1.0, 0.5, 0.5];
1721        let expected = run_expert(&hidden, &expert, GluAct::Swiglu);
1722
1723        assert_eq!(
1724            run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu, GluAct::Swiglu),
1725            expected
1726        );
1727        assert_eq!(
1728            run_expert_placed(
1729                &hidden,
1730                &expert,
1731                ExpertPlacement::GpuDevice(0),
1732                GluAct::Swiglu
1733            ),
1734            expected,
1735            "F32 has no GPU kernel, so GpuDevice placement must still fall through to the CPU path"
1736        );
1737    }
1738
1739    #[cfg(any(feature = "cuda", feature = "metal"))]
1740    #[test]
1741    #[ignore = "requires real GPU hardware (CUDA or Metal) -- run with --ignored"]
1742    fn run_expert_placed_on_gpu_matches_cpu_for_a_real_quantized_expert() {
1743        let hidden_dim = 32;
1744        let ffn_dim = 32; // must be a multiple of Q8_0 block elems (32)
1745        let make_row = |cols: usize, seed: f32| -> Vec<f32> {
1746            (0..cols)
1747                .map(|i| ((i as f32) - (cols as f32) / 2.0) * 0.01 * seed)
1748                .collect()
1749        };
1750        let quantize_matrix = |rows: usize, cols: usize, seed: f32| {
1751            let mut packed = Vec::new();
1752            for r in 0..rows {
1753                packed.extend(ferrox_quant::quantize_q8_0(&make_row(
1754                    cols,
1755                    seed + r as f32,
1756                )));
1757            }
1758            WeightMatrix::Quantized {
1759                data: ferrox_core::weight_matrix::WeightBytes::Owned(packed),
1760                rows,
1761                cols,
1762                kind: ferrox_core::weight_matrix::QuantKind::Q8_0,
1763            }
1764        };
1765        let expert = ExpertWeights {
1766            gate: quantize_matrix(ffn_dim, hidden_dim, 1.0),
1767            up: quantize_matrix(ffn_dim, hidden_dim, 2.0),
1768            down: quantize_matrix(hidden_dim, ffn_dim, 3.0),
1769        };
1770        let hidden = make_row(hidden_dim, 0.5);
1771
1772        let cpu = run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu, GluAct::Swiglu);
1773        let gpu = run_expert_placed(
1774            &hidden,
1775            &expert,
1776            ExpertPlacement::GpuDevice(0),
1777            GluAct::Swiglu,
1778        );
1779        assert_eq!(cpu.len(), gpu.len());
1780        for (c, g) in cpu.iter().zip(gpu.iter()) {
1781            assert!((c - g).abs() < 1e-1, "cpu={c} gpu={g}");
1782        }
1783    }
1784}
1785
1786/// Gemma-4's MoE router: how a hidden state becomes routing weights.
1787///
1788/// Four things differ from every other family here, and three of them
1789/// change the numbers without changing any shape -- so getting one
1790/// wrong produces a model that is fluent and wrong, with nothing to
1791/// catch it.
1792///
1793/// 1. The router input is normalized by a **weightless** RMSNorm. No
1794///    learned per-channel scale, unlike every other norm in the stack.
1795///    Reusing a weighted `rms_norm` here silently applies whatever
1796///    weight vector happened to be at hand.
1797/// 2. The normalized state is multiplied by a learned `router_scale`
1798///    vector, **and** by `hidden^-0.5`. The second factor is a
1799///    function of the width alone, so it is easy to omit and impossible
1800///    to notice: it rescales every logit by the same constant, which
1801///    changes the softmax temperature over the selected experts and
1802///    therefore the mixing weights, while leaving the top-k selection
1803///    itself identical.
1804/// 3. Selection is by **raw logit**, and the softmax runs over just the
1805///    selected `k`. That is not the same as softmaxing all experts and
1806///    slicing (see [`route_top_k_softmax`], where the surviving weights
1807///    sum to less than one); here they sum to exactly one.
1808/// 4. Each selected weight is then multiplied by
1809///    `per_expert_scale[expert_id]` -- a per-expert rescale applied to
1810///    the ROUTING WEIGHT rather than to the expert's output. No other
1811///    family here has it, and after it the weights no longer sum to
1812///    one, which is correct and must not be "fixed" by renormalizing.
1813///
1814/// `hidden` is the router's input; `router_weight` is the router
1815/// projection's output for it (one logit per expert, already computed
1816/// by the caller from the scaled state -- see
1817/// [`gemma4_router_logits`]).
1818pub fn route_gemma4_moe(logits: &[f32], k: usize, per_expert_scale: &[f32]) -> RoutingDecision {
1819    let mut idx: Vec<usize> = (0..logits.len()).collect();
1820    // Top-k by RAW logit, ties toward the lower expert id so a cached
1821    // prefix cannot disagree with the run that produced it.
1822    idx.sort_unstable_by(|&a, &b| logits[b].total_cmp(&logits[a]).then(a.cmp(&b)));
1823    let top = &idx[..k.min(idx.len())];
1824
1825    let selected: Vec<f32> = top.iter().map(|&i| logits[i]).collect();
1826    let max = selected.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1827    let exps: Vec<f32> = selected.iter().map(|&l| (l - max).exp()).collect();
1828    let sum: f32 = exps.iter().sum();
1829    let weights: Vec<f32> = if sum > 0.0 {
1830        top.iter()
1831            .zip(exps.iter())
1832            .map(|(&e, &x)| {
1833                // The per-expert scale lands on the weight, after the
1834                // softmax. The weights deliberately no longer sum to
1835                // one afterwards.
1836                (x / sum) * per_expert_scale.get(e).copied().unwrap_or(1.0)
1837            })
1838            .collect()
1839    } else {
1840        exps
1841    };
1842
1843    RoutingDecision {
1844        expert_ids: top.to_vec(),
1845        weights,
1846    }
1847}
1848
1849/// The router logits Gemma-4 feeds to [`route_gemma4_moe`]: a
1850/// weightless RMSNorm of the hidden state, scaled by `router_scale` and
1851/// by `hidden^-0.5`, then projected.
1852///
1853/// Split from the routing itself so the two unusual scalings are
1854/// testable without a projection matrix -- see [`route_gemma4_moe`]'s
1855/// docs on why the `hidden^-0.5` factor is the easy one to lose.
1856pub fn gemma4_router_logits(
1857    hidden: &[f32],
1858    router_scale: &[f32],
1859    router_proj: &WeightMatrix,
1860    eps: f32,
1861) -> Vec<f32> {
1862    debug_assert_eq!(hidden.len(), router_scale.len());
1863    let n = hidden.len() as f32;
1864    // Weightless RMSNorm: no learned per-channel term.
1865    let mean_sq = hidden.iter().map(|v| v * v).sum::<f32>() / n;
1866    let inv_rms = 1.0 / (mean_sq + eps).sqrt();
1867    let width_scale = n.powf(-0.5);
1868    let scaled: Vec<f32> = hidden
1869        .iter()
1870        .zip(router_scale.iter())
1871        .map(|(&v, &s)| v * inv_rms * s * width_scale)
1872        .collect();
1873    router_proj.apply(&scaled)
1874}
1875
1876#[cfg(test)]
1877mod gemma4_router_tests {
1878    use super::*;
1879
1880    /// The per-expert scale lands on the routing WEIGHT, after the
1881    /// softmax, and the weights deliberately stop summing to one. A
1882    /// renormalization "fixing" that would cancel the scale exactly,
1883    /// which is the whole failure this pins: no shape changes, and the
1884    /// model stays fluent.
1885    #[test]
1886    fn the_per_expert_scale_multiplies_the_weight_and_breaks_the_sum_to_one() {
1887        let logits = vec![3.0, 1.0, 2.0, 0.0];
1888        let flat = route_gemma4_moe(&logits, 2, &[1.0; 4]);
1889        let sum: f32 = flat.weights.iter().sum();
1890        assert!(
1891            (sum - 1.0).abs() < 1e-6,
1892            "with unit scales, k weights sum to one"
1893        );
1894
1895        let scaled = route_gemma4_moe(&logits, 2, &[2.0, 1.0, 0.5, 1.0]);
1896        assert_eq!(scaled.expert_ids, flat.expert_ids, "selection is unchanged");
1897        // Experts 0 and 2 were selected; their scales are 2.0 and 0.5.
1898        assert!((scaled.weights[0] - flat.weights[0] * 2.0).abs() < 1e-6);
1899        assert!((scaled.weights[1] - flat.weights[1] * 0.5).abs() < 1e-6);
1900        let sum: f32 = scaled.weights.iter().sum();
1901        assert!(
1902            (sum - 1.0).abs() > 1e-3,
1903            "the scaled weights must NOT be renormalized back to one, got {sum}"
1904        );
1905    }
1906
1907    /// Softmax over just the selected k, not a slice of the full
1908    /// distribution. The two pick the same experts and weight them
1909    /// differently, which is exactly the kind of difference that
1910    /// produces a fluent wrong model.
1911    #[test]
1912    fn the_softmax_runs_over_the_selected_experts_only() {
1913        let logits = vec![3.0, 1.0, 2.0, 0.0];
1914        let gemma = route_gemma4_moe(&logits, 2, &[1.0; 4]);
1915        let sliced = route_top_k_softmax(&logits, 2, false);
1916        assert_eq!(gemma.expert_ids, sliced.expert_ids);
1917        let gemma_sum: f32 = gemma.weights.iter().sum();
1918        let sliced_sum: f32 = sliced.weights.iter().sum();
1919        assert!((gemma_sum - 1.0).abs() < 1e-6);
1920        assert!(
1921            sliced_sum < 0.99,
1922            "a slice of the full softmax sums to less than one, got {sliced_sum}"
1923        );
1924    }
1925
1926    /// Selection is by raw logit, so the largest logits win regardless
1927    /// of the per-expert scales -- the scale rescales a weight, it does
1928    /// not buy an expert its way into the selection.
1929    #[test]
1930    fn selection_is_by_raw_logit_and_the_scale_cannot_change_it() {
1931        let logits = vec![3.0, 1.0, 2.0, 0.0];
1932        let huge = route_gemma4_moe(&logits, 2, &[1.0, 1000.0, 1.0, 1000.0]);
1933        assert_eq!(
1934            huge.expert_ids,
1935            vec![0, 2],
1936            "expert 1's scale must not select it"
1937        );
1938    }
1939
1940    /// The width factor is a function of the hidden size alone, so it
1941    /// leaves the selection identical and changes the softmax
1942    /// temperature -- which is what makes omitting it invisible.
1943    #[test]
1944    fn the_width_scaling_changes_the_weights_but_not_the_selection() {
1945        let hidden = vec![1.0, -2.0, 0.5, 3.0];
1946        let router_scale = vec![1.0; 4];
1947        let proj = WeightMatrix::F32(ferrox_core::tensor::Tensor::new(
1948            vec![
1949                1.0, 0.0, 0.0, 0.0, //
1950                0.0, 1.0, 0.0, 0.0, //
1951                0.0, 0.0, 1.0, 0.0, //
1952                0.0, 0.0, 0.0, 1.0,
1953            ],
1954            vec![4, 4],
1955        ));
1956        let with_width = gemma4_router_logits(&hidden, &router_scale, &proj, 1e-6);
1957
1958        // The same thing without the hidden^-0.5 factor: every logit is
1959        // larger by exactly sqrt(hidden).
1960        let n = hidden.len() as f32;
1961        let without: Vec<f32> = with_width.iter().map(|v| v * n.sqrt()).collect();
1962
1963        let a = route_gemma4_moe(&with_width, 2, &[1.0; 4]);
1964        let b = route_gemma4_moe(&without, 2, &[1.0; 4]);
1965        assert_eq!(a.expert_ids, b.expert_ids, "the selection is unaffected");
1966        assert!(
1967            a.weights
1968                .iter()
1969                .zip(b.weights.iter())
1970                .any(|(x, y)| (x - y).abs() > 1e-4),
1971            "but the mixing weights are not: {:?} vs {:?}",
1972            a.weights,
1973            b.weights
1974        );
1975    }
1976
1977    /// The router's norm is WEIGHTLESS. Feeding a non-unit scale vector
1978    /// through must change the logits, which is what proves the norm
1979    /// itself is not quietly applying one.
1980    #[test]
1981    fn the_router_norm_carries_no_learned_weight_of_its_own() {
1982        let hidden = vec![1.0, -2.0, 0.5, 3.0];
1983        let proj = WeightMatrix::F32(ferrox_core::tensor::Tensor::new(
1984            (0..16)
1985                .map(|i| if i % 5 == 0 { 1.0 } else { 0.0 })
1986                .collect(),
1987            vec![4, 4],
1988        ));
1989        let unit = gemma4_router_logits(&hidden, &[1.0; 4], &proj, 1e-6);
1990        let scaled = gemma4_router_logits(&hidden, &[2.0; 4], &proj, 1e-6);
1991        for (u, s) in unit.iter().zip(scaled.iter()) {
1992            assert!(
1993                (s - u * 2.0).abs() < 1e-5,
1994                "router_scale is the ONLY learned scale on this path: {u} -> {s}"
1995            );
1996        }
1997    }
1998
1999    /// Ties break toward the lower expert id, deterministically.
2000    #[test]
2001    fn ties_break_toward_the_lower_expert_id() {
2002        let logits = vec![1.0, 1.0, 1.0, 1.0];
2003        for _ in 0..8 {
2004            assert_eq!(
2005                route_gemma4_moe(&logits, 2, &[1.0; 4]).expert_ids,
2006                vec![0, 1]
2007            );
2008        }
2009    }
2010}