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) = rayon::join(
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) = rayon::join(|| expert.gate.apply(hidden), || expert.up.apply(hidden));
1045    let activated = act.apply(&gate, &up);
1046    expert.down.apply(&activated)
1047}
1048
1049/// `run_expert`, but actually consulting `placement` instead of always
1050/// running on CPU -- this is the real execution consequence
1051/// `PlacementPlan` previously computed but nothing acted on: a
1052/// `GpuDevice`-placed expert's gate/up/down matvecs go through
1053/// `WeightMatrix::apply_gpu` (real CUDA and/or Metal kernels for
1054/// Q8_0/Q4_0/Q4_K/Q5_K/Q6_K), falling straight through to the ordinary
1055/// CPU path for any matrix `apply_gpu` returns `None` for (an
1056/// unsupported quant kind, or a real launch failure) -- so this is
1057/// always correct, never a hard failure, regardless of GPU availability.
1058///
1059/// Every call re-uploads each weight matrix to the device from scratch
1060/// (see `WeightMatrix::apply_gpu`'s doc comment) -- correct, but not
1061/// yet the persistent-GPU-residency throughput win real expert offload
1062/// needs; a real, disclosed limit of this round, not overclaimed.
1063///
1064/// Without a GPU feature (`cuda` / `metal`) compiled in, this has the
1065/// exact same signature and always calls `run_expert` (ignoring
1066/// `placement`), so callers (e.g. `ferrox-models::decoder::Decoder`)
1067/// can call it unconditionally regardless of how this crate was built,
1068/// with correct behavior either way.
1069#[cfg(any(feature = "cuda", feature = "metal"))]
1070pub fn run_expert_placed(
1071    hidden: &[f32],
1072    expert: &ExpertWeights,
1073    placement: ExpertPlacement,
1074    act: GluAct,
1075) -> Vec<f32> {
1076    if matches!(placement, ExpertPlacement::GpuDevice(_)) {
1077        #[cfg(any(feature = "cuda", feature = "metal"))]
1078        if act.is_swiglu() {
1079            // SwiGLU-only fused kernel; a GeGLU expert falls through to
1080            // the split gate/up launches below, which are activation
1081            // agnostic because the combine runs on the host.
1082            if let Some(out) = ferrox_core::WeightMatrix::apply_gpu_dense_ffn_swiglu(
1083                &expert.gate,
1084                &expert.up,
1085                &expert.down,
1086                hidden,
1087            ) {
1088                return out;
1089            }
1090        }
1091        #[cfg(any(feature = "cuda", feature = "metal"))]
1092        {
1093            if let Some(mut outs) =
1094                ferrox_core::WeightMatrix::apply_gpu_multi(&[&expert.gate, &expert.up], hidden)
1095            {
1096                let up = outs.pop().unwrap();
1097                let gate = outs.pop().unwrap();
1098                let activated = act.apply(&gate, &up);
1099                if let Some(down) = expert.down.apply_gpu(&activated) {
1100                    return down;
1101                }
1102                return expert.down.apply(&activated);
1103            }
1104        }
1105        if let Some(gate) = expert.gate.apply_gpu(hidden) {
1106            if let Some(up) = expert.up.apply_gpu(hidden) {
1107                let activated = act.apply(&gate, &up);
1108                if let Some(down) = expert.down.apply_gpu(&activated) {
1109                    return down;
1110                }
1111            }
1112        }
1113    }
1114    run_expert(hidden, expert, act)
1115}
1116
1117#[cfg(not(any(feature = "cuda", feature = "metal")))]
1118pub fn run_expert_placed(
1119    hidden: &[f32],
1120    expert: &ExpertWeights,
1121    _placement: ExpertPlacement,
1122    act: GluAct,
1123) -> Vec<f32> {
1124    run_expert(hidden, expert, act)
1125}
1126
1127/// Combines routed + shared expert outputs for one token.
1128pub fn combine_expert_outputs(
1129    routed_outputs: &[(Vec<f32>, f32)],
1130    shared_outputs: &[Vec<f32>],
1131    hidden_dim: usize,
1132) -> Vec<f32> {
1133    let mut out = vec![0f32; hidden_dim];
1134    for (expert_out, weight) in routed_outputs {
1135        for (o, e) in out.iter_mut().zip(expert_out.iter()) {
1136            *o += e * weight;
1137        }
1138    }
1139    for shared_out in shared_outputs {
1140        for (o, e) in out.iter_mut().zip(shared_out.iter()) {
1141            *o += e;
1142        }
1143    }
1144    out
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149    /// The property the global planner exists for: with N layers of
1150    /// identical experts and a budget that fits exactly K experts,
1151    /// exactly K experts are device-placed across ALL layers combined
1152    /// -- not K per layer, which is what independent per-layer
1153    /// `from_budget` calls with the same budget would produce (N*K).
1154    #[test]
1155    fn global_budget_cannot_be_multiplied_across_layers() {
1156        let n_layers = 10;
1157        let sizes: Vec<Vec<usize>> = (0..n_layers).map(|_| vec![100usize; 4]).collect();
1158        let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 250);
1159
1160        let total_placed: usize = (0..n_layers)
1161            .map(|l| {
1162                (0..4)
1163                    .filter(|&e| plan.layer_plan(l).placement_for(e) != ExpertPlacement::Cpu)
1164                    .count()
1165            })
1166            .sum();
1167        assert_eq!(
1168            total_placed, 2,
1169            "250 bytes fits exactly 2 x 100-byte experts, globally"
1170        );
1171        assert_eq!(plan.device_bytes_planned, 200);
1172        assert!(plan.device_bytes_planned <= plan.vram_budget_bytes);
1173
1174        // The old shape of the bug, for contrast: per-layer planning
1175        // with the same budget places 2 experts in EVERY layer.
1176        let per_layer_total: usize = (0..n_layers)
1177            .map(|_| {
1178                let p = PlacementPlan::from_budget(&[100; 4], None, 250);
1179                (0..4)
1180                    .filter(|&e| p.placement_for(e) != ExpertPlacement::Cpu)
1181                    .count()
1182            })
1183            .sum();
1184        assert_eq!(per_layer_total, 20, "per-layer planning overcommits 10x");
1185    }
1186
1187    /// Hot experts win device slots across layer boundaries: a single
1188    /// very hot expert in a late layer beats cold experts in earlier
1189    /// layers.
1190    #[test]
1191    fn global_planning_prioritizes_hotness_across_layers() {
1192        let sizes: Vec<Vec<usize>> = (0..3).map(|_| vec![100usize; 2]).collect();
1193        let mut counts: Vec<Vec<u64>> = (0..3).map(|_| vec![0u64; 2]).collect();
1194        counts[2][1] = 50; // the only hot expert lives in the last layer
1195        counts[0][0] = 10;
1196        let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, Some(&counts), 200);
1197
1198        assert_eq!(
1199            plan.layer_plan(2).placement_for(1),
1200            ExpertPlacement::GpuDevice(0),
1201            "hottest expert (layer 2) must win a slot"
1202        );
1203        assert_eq!(
1204            plan.layer_plan(0).placement_for(0),
1205            ExpertPlacement::GpuDevice(0),
1206            "second-hottest expert (layer 0) takes the remaining slot"
1207        );
1208        assert_eq!(plan.device_bytes_planned, 200);
1209    }
1210
1211    /// Zero budget places nothing anywhere; empty (dense) layers are
1212    /// legal and contribute no candidates.
1213    #[test]
1214    fn global_planning_handles_zero_budget_and_dense_layers() {
1215        let sizes = vec![Vec::new(), vec![100usize; 3], Vec::new()];
1216        let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 0);
1217        assert_eq!(plan.device_bytes_planned, 0);
1218        assert_eq!(plan.n_layers(), 3);
1219        for e in 0..3 {
1220            assert_eq!(plan.layer_plan(1).placement_for(e), ExpertPlacement::Cpu);
1221        }
1222    }
1223
1224    use super::*;
1225
1226    #[test]
1227    fn top_k_selects_highest_scoring_experts() {
1228        let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1229        let decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1230        assert_eq!(decision.expert_ids, vec![1, 3]);
1231        let sum: f32 = decision.weights.iter().sum();
1232        assert!((sum - 1.0).abs() < 1e-5);
1233        assert!(decision.weights[0] > decision.weights[1]);
1234    }
1235
1236    #[test]
1237    fn top_k_weights_always_sum_to_one_regardless_of_k() {
1238        let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1239        for k in 1..=8 {
1240            let decision = route_top_k(&logits, k, GatingFunction::Softmax, true);
1241            let sum: f32 = decision.weights.iter().sum();
1242            assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
1243        }
1244    }
1245
1246    /// `norm_topk_prob: false` -- OLMoE's real convention (see
1247    /// `MoeLayerConfig::norm_topk_prob`'s doc comment). Golden values
1248    /// hand-computed independently: full softmax over all 8 logits
1249    /// (sum of exp(l_i - 8) = 1.5814460129), then the raw (un-renormalized)
1250    /// probabilities of the top-3 selected experts (indices 7, 6, 5 --
1251    /// logits 8, 7, 6). This is the exact bug that was silently producing
1252    /// wrong OLMoE output: the old code could only ever compute a
1253    /// top-k-local softmax (mathematically identical to
1254    /// always-renormalize), with no way to recover the un-renormalized
1255    /// probability relative to *all* experts.
1256    #[test]
1257    fn norm_topk_prob_false_uses_raw_full_softmax_probability_not_renormalized() {
1258        let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1259        let decision = route_top_k(&logits, 3, GatingFunction::Softmax, false);
1260
1261        assert_eq!(decision.expert_ids, vec![7, 6, 5]);
1262
1263        let expected = [0.6323223_f32, 0.2326232, 0.0855683];
1264        for (got, want) in decision.weights.iter().zip(expected.iter()) {
1265            assert!((got - want).abs() < 1e-4, "got={got} want={want}");
1266        }
1267
1268        let sum: f32 = decision.weights.iter().sum();
1269        assert!(
1270            (sum - 0.9505138).abs() < 1e-4,
1271            "raw top-3 probability mass should be < 1 (it's a subset of a full 8-way softmax), got sum={sum}"
1272        );
1273
1274        // Selecting the same experts with norm_topk_prob=true must
1275        // renormalize to the exact same values divided by that sum --
1276        // proving the two modes agree on *which* experts fire and differ
1277        // only in the final weight scaling.
1278        let normalized = route_top_k(&logits, 3, GatingFunction::Softmax, true);
1279        assert_eq!(normalized.expert_ids, decision.expert_ids);
1280        for (raw, norm) in decision.weights.iter().zip(normalized.weights.iter()) {
1281            assert!(
1282                (raw / sum - norm).abs() < 1e-4,
1283                "raw={raw} sum={sum} normalized={norm}"
1284            );
1285        }
1286    }
1287
1288    #[test]
1289    fn sigmoid_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1290        // Sigmoid is monotonic in its input, so for a given set of
1291        // logits, top-k-by-sigmoid-score must select the exact same
1292        // expert ids as top-k-by-raw-logit (sigmoid just changes the
1293        // *weights*, not which experts are chosen).
1294        let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1295        let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1296        let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1297        assert_eq!(softmax_decision.expert_ids, sigmoid_decision.expert_ids);
1298    }
1299
1300    #[test]
1301    fn sigmoid_gating_weights_sum_to_one() {
1302        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1303        for k in 1..=8 {
1304            let decision = route_top_k(&logits, k, GatingFunction::Sigmoid, true);
1305            let sum: f32 = decision.weights.iter().sum();
1306            assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
1307        }
1308    }
1309
1310    #[test]
1311    fn bias_only_affects_selection_not_the_final_weight_value() {
1312        // Expert 0 has the lower raw score but a large positive bias, so
1313        // biased selection must pick it over expert 1 -- but the WEIGHT
1314        // it ends up with must be its raw (unbiased) sigmoid score, not
1315        // score+bias. Getting this backwards would silently make a
1316        // barely-selected expert dominate the combine.
1317        let logits = vec![0.1, 2.0];
1318        let bias = vec![10.0, 0.0];
1319        let decision = route_top_k_sigmoid_with_bias(&logits, &bias, 1, true, 1.0);
1320        assert_eq!(decision.expert_ids, vec![0]);
1321        // k=1 -> renormalization is a no-op (single weight / itself = 1,
1322        // scaled by 1.0), so the weight is just sigmoid(0.1), not 1.0.
1323        assert!((decision.weights[0] - sigmoid(0.1)).abs() < 1e-5);
1324    }
1325
1326    #[test]
1327    fn without_bias_selection_falls_back_to_plain_sigmoid_top_k() {
1328        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1329        let zero_bias = vec![0.0; logits.len()];
1330        let biased = route_top_k_sigmoid_with_bias(&logits, &zero_bias, 3, true, 1.0);
1331        let plain = route_top_k(&logits, 3, GatingFunction::Sigmoid, true);
1332        assert_eq!(biased.expert_ids, plain.expert_ids);
1333        for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1334            assert!((a - b).abs() < 1e-6);
1335        }
1336    }
1337
1338    #[test]
1339    fn scaling_factor_multiplies_every_weight() {
1340        let logits = vec![1.0, 2.0, 3.0];
1341        let bias = vec![0.0; 3];
1342        let unscaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 1.0);
1343        let scaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 2.5);
1344        for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1345            assert!((u * 2.5 - s).abs() < 1e-5);
1346        }
1347    }
1348
1349    #[test]
1350    fn sigmoid_and_softmax_weights_differ_for_the_same_logits() {
1351        // The whole point of the distinction: sigmoid scores each
1352        // expert independently (not as a joint distribution), so the
1353        // relative weighting between two selected experts differs from
1354        // softmax's, even though both sum to one and pick the same
1355        // experts. If this test ever fails by finding the two paths
1356        // identical, something has collapsed the sigmoid path back
1357        // into softmax.
1358        let logits = vec![3.0, 1.0, -2.0, 0.5];
1359        let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1360        let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1361        assert!(
1362            (softmax_decision.weights[0] - sigmoid_decision.weights[0]).abs() > 1e-3,
1363            "softmax and sigmoid gating should generally produce different weight splits for the same logits"
1364        );
1365    }
1366
1367    #[test]
1368    fn sqrt_softplus_matches_hand_computed_values_at_zero_and_positive_logit() {
1369        // softplus(0) = ln(2), sqrt(ln(2)) -- exact closed form, not just a
1370        // property check, to pin the real DeepSeek V4 formula
1371        // (sqrt(softplus(x)), not e.g. softplus(sqrt(x)) or sqrt(sigmoid)).
1372        assert!((sqrt_softplus(0.0) - 2.0_f32.ln().sqrt()).abs() < 1e-6);
1373        // softplus(x) -> x for large positive x, so sqrt_softplus(x) -> sqrt(x).
1374        assert!((sqrt_softplus(20.0) - 20.0_f32.sqrt()).abs() < 1e-3);
1375    }
1376
1377    /// Group-limited routing CONCENTRATES; it does not spread.
1378    ///
1379    /// This test replaces one that asserted the opposite -- that
1380    /// `total_k = 2` over two groups keeps "both group winners" -- which
1381    /// was the shape of the bug, not a property of the rule. The real
1382    /// DeepSeek-V3 / GLM `n_group`/`topk_group` router scores each group
1383    /// by the sum of its top-2 members, keeps the `topk_group` best
1384    /// groups, masks every expert in the rest to `-inf`, and then runs
1385    /// ONE GLOBAL top-k over what survives. With `topk_group = 1` only
1386    /// group 0 survives, so both selected experts must come from it and
1387    /// expert 3 must NOT fire.
1388    ///
1389    /// It therefore fails against the previous implementation, which
1390    /// took `k_per_group` from every group and truncated afterwards.
1391    #[test]
1392    fn group_limited_routing_concentrates_into_the_surviving_groups() {
1393        // 4 experts, 2 groups of 2. Group 0 holds the two best scores.
1394        let logits = vec![5.0, 4.5, 0.2, 0.1];
1395        let d = route_top_k_grouped(&logits, 2, 1, 2, GatingFunction::Softmax, true);
1396        assert_eq!(d.expert_ids.len(), 2);
1397        let mut ids = d.expert_ids.clone();
1398        ids.sort_unstable();
1399        assert_eq!(ids, vec![0, 1], "both experts must come from group 0");
1400        let sum: f32 = d.weights.iter().sum();
1401        assert!((sum - 1.0).abs() < 1e-4);
1402    }
1403
1404    /// The group score is the sum of a group's top TWO, not its best
1405    /// single member. A group with one spike and nothing behind it loses
1406    /// to a group with two strong members.
1407    #[test]
1408    fn a_group_is_scored_by_its_top_two_not_by_its_best_member() {
1409        // Group 0: one spike and a dead expert, carrying 0.426 of the
1410        // softmax mass between them. Group 1: two solid members
1411        // carrying 0.574 together, neither of which beats the spike on
1412        // its own. Scoring by best member picks group 0; scoring by
1413        // top-2 sum picks group 1, which is the reference rule.
1414        let logits = vec![1.0, -20.0, 0.7, 0.5];
1415        let d = route_top_k_grouped(&logits, 2, 1, 2, GatingFunction::Softmax, true);
1416        let mut ids = d.expert_ids.clone();
1417        ids.sort_unstable();
1418        assert_eq!(ids, vec![2, 3], "the two-strong-members group wins");
1419    }
1420
1421    #[test]
1422    fn sqrtsoftplus_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1423        // sqrt(softplus(x)) is monotonically increasing in x (both sqrt
1424        // and softplus are), so top-k-by-score must agree with top-k by
1425        // raw logit on *which* experts fire, same reasoning as the
1426        // sigmoid monotonicity test above.
1427        let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1428        let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1429        let sqrtsoftplus_decision = route_top_k(&logits, 2, GatingFunction::SqrtSoftplus, true);
1430        assert_eq!(
1431            softmax_decision.expert_ids,
1432            sqrtsoftplus_decision.expert_ids
1433        );
1434    }
1435
1436    #[test]
1437    fn sqrtsoftplus_weights_sum_to_one_when_normalized() {
1438        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1439        for k in 1..=8 {
1440            let decision = route_top_k(&logits, k, GatingFunction::SqrtSoftplus, true);
1441            let sum: f32 = decision.weights.iter().sum();
1442            assert!((sum - 1.0).abs() < 1e-4, "k={k} sum={sum}");
1443        }
1444    }
1445
1446    #[test]
1447    fn sqrtsoftplus_bias_only_affects_selection_not_the_final_weight_value() {
1448        // Same structure as `bias_only_affects_selection_not_the_final_weight_value`
1449        // but for the sqrt-softplus scoring function DeepSeek V4's real
1450        // non-hash MoE layers use.
1451        let logits = vec![0.1, 2.0];
1452        let bias = vec![10.0, 0.0];
1453        let decision = route_top_k_sqrtsoftplus_with_bias(&logits, &bias, 1, true, 1.0);
1454        assert_eq!(decision.expert_ids, vec![0]);
1455        assert!((decision.weights[0] - sqrt_softplus(0.1)).abs() < 1e-5);
1456    }
1457
1458    #[test]
1459    fn sqrtsoftplus_without_bias_selection_falls_back_to_plain_top_k() {
1460        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1461        let zero_bias = vec![0.0; logits.len()];
1462        let biased = route_top_k_sqrtsoftplus_with_bias(&logits, &zero_bias, 3, true, 1.0);
1463        let plain = route_top_k(&logits, 3, GatingFunction::SqrtSoftplus, true);
1464        assert_eq!(biased.expert_ids, plain.expert_ids);
1465        for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1466            assert!((a - b).abs() < 1e-6);
1467        }
1468    }
1469
1470    #[test]
1471    fn hash_routing_uses_the_fixed_table_ids_regardless_of_logit_ranking() {
1472        // Expert 0 has by far the highest logit, but the real mechanism
1473        // never looks at the router's ranking to choose experts for a
1474        // hash-routed layer -- the table says [2, 1], so that's what
1475        // fires, full stop.
1476        let logits = vec![100.0, 1.0, 0.5, -3.0];
1477        let hash_expert_ids = vec![2usize, 1usize];
1478        let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1479        assert_eq!(decision.expert_ids, vec![2, 1]);
1480    }
1481
1482    #[test]
1483    fn hash_routing_weights_come_from_the_real_router_logits_not_a_fixed_split() {
1484        // The table fixes *which* experts fire, but their relative
1485        // combine weight still comes from sqrt(softplus(logit)) gathered
1486        // at those ids -- not a uniform 1/n split. Expert 2's logit (3.0)
1487        // is much larger than expert 1's (0.1), so its weight must
1488        // dominate even though both were unconditionally selected.
1489        let logits = vec![-5.0, 0.1, 3.0, -5.0];
1490        let hash_expert_ids = vec![2usize, 1usize];
1491        let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1492        assert!(decision.weights[0] > decision.weights[1]);
1493        let sum: f32 = decision.weights.iter().sum();
1494        assert!((sum - 1.0).abs() < 1e-5);
1495        let expected0 = sqrt_softplus(3.0) / (sqrt_softplus(3.0) + sqrt_softplus(0.1));
1496        assert!((decision.weights[0] - expected0).abs() < 1e-5);
1497    }
1498
1499    #[test]
1500    fn hash_routing_scaling_factor_multiplies_every_weight() {
1501        let logits = vec![1.0, 2.0, 3.0];
1502        let hash_expert_ids = vec![0usize, 2usize];
1503        let unscaled = route_hash(&hash_expert_ids, &logits, true, 1.0);
1504        let scaled = route_hash(&hash_expert_ids, &logits, true, 2.5);
1505        for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1506            assert!((u * 2.5 - s).abs() < 1e-5);
1507        }
1508    }
1509
1510    #[test]
1511    fn placement_plan_defaults_to_cpu_for_unlisted_experts() {
1512        let plan = PlacementPlan::hot_experts_on_gpu(256, 8);
1513        assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1514        assert_eq!(plan.placement_for(7), ExpertPlacement::GpuDevice(0));
1515        assert_eq!(plan.placement_for(8), ExpertPlacement::Cpu);
1516        assert_eq!(plan.placement_for(255), ExpertPlacement::Cpu);
1517    }
1518
1519    #[test]
1520    fn all_cpu_plan_never_returns_gpu() {
1521        let plan = PlacementPlan::all_cpu(64);
1522        for i in 0..64 {
1523            assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1524        }
1525    }
1526
1527    #[test]
1528    fn from_budget_fits_as_many_experts_as_the_vram_budget_allows() {
1529        // 4 experts, 100 bytes each: a 250-byte budget fits exactly 2.
1530        let sizes = vec![100usize, 100, 100, 100];
1531        let plan = PlacementPlan::from_budget(&sizes, None, 250);
1532        let on_gpu = (0..4)
1533            .filter(|&i| plan.placement_for(i) == ExpertPlacement::GpuDevice(0))
1534            .count();
1535        assert_eq!(on_gpu, 2);
1536    }
1537
1538    #[test]
1539    fn from_budget_prioritizes_the_most_frequently_activated_experts() {
1540        // Expert 2 is by far the hottest but is neither first nor
1541        // largest -- a real budget-aware plan must still pick it first.
1542        let sizes = vec![50usize, 50, 50, 50];
1543        let counts = vec![1u64, 2, 100, 3];
1544        // Budget for exactly one expert.
1545        let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 50);
1546        assert_eq!(
1547            plan.placement_for(2),
1548            ExpertPlacement::GpuDevice(0),
1549            "the hottest expert (index 2) must be the one placed on GPU"
1550        );
1551        assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1552        assert_eq!(plan.placement_for(1), ExpertPlacement::Cpu);
1553        assert_eq!(plan.placement_for(3), ExpertPlacement::Cpu);
1554    }
1555
1556    #[test]
1557    fn from_budget_skips_an_expert_that_does_not_fit_and_tries_the_next() {
1558        // Expert 0 is too big for the budget alone; experts 1 and 2
1559        // together fit and should both be placed.
1560        let sizes = vec![200usize, 60, 60];
1561        let plan = PlacementPlan::from_budget(&sizes, None, 120);
1562        assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1563        assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1564        assert_eq!(plan.placement_for(2), ExpertPlacement::GpuDevice(0));
1565    }
1566
1567    #[test]
1568    fn from_budget_with_zero_vram_places_nothing_on_gpu() {
1569        let sizes = vec![10usize, 20, 30];
1570        let plan = PlacementPlan::from_budget(&sizes, None, 0);
1571        for i in 0..3 {
1572            assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1573        }
1574    }
1575
1576    #[test]
1577    fn from_budget_ignores_mismatched_activation_counts_length_rather_than_panicking() {
1578        let sizes = vec![10usize, 10];
1579        let counts = vec![1u64]; // wrong length
1580        let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 100);
1581        // Falls back to index order; both fit within the budget either way.
1582        assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1583        assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1584    }
1585
1586    #[test]
1587    fn combine_expert_outputs_weights_routed_and_adds_shared() {
1588        let routed = vec![(vec![2.0, 2.0], 0.5), (vec![4.0, 4.0], 0.5)];
1589        let shared = vec![vec![1.0, 1.0]];
1590        let out = combine_expert_outputs(&routed, &shared, 2);
1591        assert_eq!(out, vec![4.0, 4.0]);
1592    }
1593
1594    #[test]
1595    fn run_expert_produces_correct_output_dimension() {
1596        use ferrox_core::tensor::Tensor;
1597        let hidden_dim = 4;
1598        let ffn_dim = 3;
1599        let expert = ExpertWeights {
1600            gate: WeightMatrix::F32(Tensor::new(
1601                vec![0.1; ffn_dim * hidden_dim],
1602                vec![ffn_dim, hidden_dim],
1603            )),
1604            up: WeightMatrix::F32(Tensor::new(
1605                vec![0.2; ffn_dim * hidden_dim],
1606                vec![ffn_dim, hidden_dim],
1607            )),
1608            down: WeightMatrix::F32(Tensor::new(
1609                vec![0.3; hidden_dim * ffn_dim],
1610                vec![hidden_dim, ffn_dim],
1611            )),
1612        };
1613        let hidden = vec![1.0, -1.0, 0.5, 0.5];
1614        let out = run_expert(&hidden, &expert, GluAct::Swiglu);
1615        assert_eq!(out.len(), hidden_dim);
1616        assert!(out.iter().all(|v| v.is_finite()));
1617    }
1618
1619    /// A GeGLU expert must compute `gelu(gate) * up`, not SwiGLU.
1620    ///
1621    /// Checked against an independently written scalar reference, and
1622    /// separately asserted to DIFFER from the SwiGLU result: without the
1623    /// second half, an implementation that ignored `act` entirely and
1624    /// always ran SwiGLU could still pass a loose tolerance check on
1625    /// weights where the two activations happen to be close.
1626    #[test]
1627    fn geglu_expert_computes_gelu_not_silu() {
1628        use ferrox_core::tensor::Tensor;
1629        let hidden_dim = 4;
1630        let ffn_dim = 3;
1631        let g: Vec<f32> = (0..ffn_dim * hidden_dim)
1632            .map(|i| (i as f32 * 0.7).sin() * 3.0)
1633            .collect();
1634        let u: Vec<f32> = (0..ffn_dim * hidden_dim)
1635            .map(|i| (i as f32 * 0.31).cos())
1636            .collect();
1637        let d: Vec<f32> = (0..hidden_dim * ffn_dim)
1638            .map(|i| (i as f32 * 0.19).sin() * 0.5)
1639            .collect();
1640        let expert = ExpertWeights {
1641            gate: WeightMatrix::F32(Tensor::new(g.clone(), vec![ffn_dim, hidden_dim])),
1642            up: WeightMatrix::F32(Tensor::new(u.clone(), vec![ffn_dim, hidden_dim])),
1643            down: WeightMatrix::F32(Tensor::new(d.clone(), vec![hidden_dim, ffn_dim])),
1644        };
1645        let hidden = vec![1.0, -1.0, 0.5, 0.25];
1646
1647        // Independent reference: three plain loops, tanh-approx GELU
1648        // spelled out rather than borrowed from ferrox-core.
1649        let row = |w: &[f32], cols: usize, r: usize, x: &[f32]| -> f32 {
1650            (0..cols).map(|c| w[r * cols + c] * x[c]).sum()
1651        };
1652        let mut activated = vec![0f32; ffn_dim];
1653        for (r, a) in activated.iter_mut().enumerate() {
1654            let gv = row(&g, hidden_dim, r, &hidden);
1655            let uv = row(&u, hidden_dim, r, &hidden);
1656            let t = (0.797_884_6f32 * (gv + 0.044_715 * gv * gv * gv)).tanh();
1657            *a = 0.5 * gv * (1.0 + t) * uv;
1658        }
1659        let expected: Vec<f32> = (0..hidden_dim)
1660            .map(|r| row(&d, ffn_dim, r, &activated))
1661            .collect();
1662
1663        let got = run_expert(&hidden, &expert, GluAct::Geglu);
1664        assert_eq!(got.len(), hidden_dim);
1665        for (i, (a, b)) in got.iter().zip(expected.iter()).enumerate() {
1666            assert!(
1667                (a - b).abs() < 1e-5,
1668                "GeGLU expert row {i}: got {a}, expected {b}"
1669            );
1670        }
1671
1672        let swiglu_out = run_expert(&hidden, &expert, GluAct::Swiglu);
1673        assert!(
1674            got.iter()
1675                .zip(swiglu_out.iter())
1676                .any(|(a, b)| (a - b).abs() > 1e-4),
1677            "GeGLU and SwiGLU must not agree on these weights -- if they do, \
1678             this test cannot detect a routed expert that silently ran SwiGLU"
1679        );
1680
1681        // Placement must not change the activation either.
1682        assert_eq!(
1683            run_expert_placed(
1684                &hidden,
1685                &expert,
1686                ExpertPlacement::GpuDevice(0),
1687                GluAct::Geglu
1688            ),
1689            got,
1690            "a GPU-placed GeGLU expert must not fall into a SwiGLU kernel"
1691        );
1692    }
1693
1694    /// `run_expert_placed` must be a real drop-in for `run_expert` when
1695    /// no GPU dispatch actually happens -- true unconditionally without
1696    /// the `cuda` feature, and true even *with* the feature for `Cpu`
1697    /// placement (which never calls `apply_gpu` at all) or an
1698    /// unsupported quant kind (F32 here, which `apply_gpu` always
1699    /// returns `None` for, falling through to `run_expert`).
1700    #[test]
1701    fn run_expert_placed_matches_run_expert_when_nothing_is_gpu_dispatched() {
1702        use ferrox_core::tensor::Tensor;
1703        let hidden_dim = 4;
1704        let ffn_dim = 3;
1705        let expert = ExpertWeights {
1706            gate: WeightMatrix::F32(Tensor::new(
1707                vec![0.1; ffn_dim * hidden_dim],
1708                vec![ffn_dim, hidden_dim],
1709            )),
1710            up: WeightMatrix::F32(Tensor::new(
1711                vec![0.2; ffn_dim * hidden_dim],
1712                vec![ffn_dim, hidden_dim],
1713            )),
1714            down: WeightMatrix::F32(Tensor::new(
1715                vec![0.3; hidden_dim * ffn_dim],
1716                vec![hidden_dim, ffn_dim],
1717            )),
1718        };
1719        let hidden = vec![1.0, -1.0, 0.5, 0.5];
1720        let expected = run_expert(&hidden, &expert, GluAct::Swiglu);
1721
1722        assert_eq!(
1723            run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu, GluAct::Swiglu),
1724            expected
1725        );
1726        assert_eq!(
1727            run_expert_placed(
1728                &hidden,
1729                &expert,
1730                ExpertPlacement::GpuDevice(0),
1731                GluAct::Swiglu
1732            ),
1733            expected,
1734            "F32 has no GPU kernel, so GpuDevice placement must still fall through to the CPU path"
1735        );
1736    }
1737
1738    #[cfg(any(feature = "cuda", feature = "metal"))]
1739    #[test]
1740    #[ignore = "requires real GPU hardware (CUDA or Metal) -- run with --ignored"]
1741    fn run_expert_placed_on_gpu_matches_cpu_for_a_real_quantized_expert() {
1742        let hidden_dim = 32;
1743        let ffn_dim = 32; // must be a multiple of Q8_0 block elems (32)
1744        let make_row = |cols: usize, seed: f32| -> Vec<f32> {
1745            (0..cols)
1746                .map(|i| ((i as f32) - (cols as f32) / 2.0) * 0.01 * seed)
1747                .collect()
1748        };
1749        let quantize_matrix = |rows: usize, cols: usize, seed: f32| {
1750            let mut packed = Vec::new();
1751            for r in 0..rows {
1752                packed.extend(ferrox_quant::quantize_q8_0(&make_row(
1753                    cols,
1754                    seed + r as f32,
1755                )));
1756            }
1757            WeightMatrix::Quantized {
1758                data: ferrox_core::weight_matrix::WeightBytes::Owned(packed),
1759                rows,
1760                cols,
1761                kind: ferrox_core::weight_matrix::QuantKind::Q8_0,
1762            }
1763        };
1764        let expert = ExpertWeights {
1765            gate: quantize_matrix(ffn_dim, hidden_dim, 1.0),
1766            up: quantize_matrix(ffn_dim, hidden_dim, 2.0),
1767            down: quantize_matrix(hidden_dim, ffn_dim, 3.0),
1768        };
1769        let hidden = make_row(hidden_dim, 0.5);
1770
1771        let cpu = run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu, GluAct::Swiglu);
1772        let gpu = run_expert_placed(
1773            &hidden,
1774            &expert,
1775            ExpertPlacement::GpuDevice(0),
1776            GluAct::Swiglu,
1777        );
1778        assert_eq!(cpu.len(), gpu.len());
1779        for (c, g) in cpu.iter().zip(gpu.iter()) {
1780            assert!((c - g).abs() < 1e-1, "cpu={c} gpu={g}");
1781        }
1782    }
1783}
1784
1785/// Gemma-4's MoE router: how a hidden state becomes routing weights.
1786///
1787/// Four things differ from every other family here, and three of them
1788/// change the numbers without changing any shape -- so getting one
1789/// wrong produces a model that is fluent and wrong, with nothing to
1790/// catch it.
1791///
1792/// 1. The router input is normalized by a **weightless** RMSNorm. No
1793///    learned per-channel scale, unlike every other norm in the stack.
1794///    Reusing a weighted `rms_norm` here silently applies whatever
1795///    weight vector happened to be at hand.
1796/// 2. The normalized state is multiplied by a learned `router_scale`
1797///    vector, **and** by `hidden^-0.5`. The second factor is a
1798///    function of the width alone, so it is easy to omit and impossible
1799///    to notice: it rescales every logit by the same constant, which
1800///    changes the softmax temperature over the selected experts and
1801///    therefore the mixing weights, while leaving the top-k selection
1802///    itself identical.
1803/// 3. Selection is by **raw logit**, and the softmax runs over just the
1804///    selected `k`. That is not the same as softmaxing all experts and
1805///    slicing (see [`route_top_k_softmax`], where the surviving weights
1806///    sum to less than one); here they sum to exactly one.
1807/// 4. Each selected weight is then multiplied by
1808///    `per_expert_scale[expert_id]` -- a per-expert rescale applied to
1809///    the ROUTING WEIGHT rather than to the expert's output. No other
1810///    family here has it, and after it the weights no longer sum to
1811///    one, which is correct and must not be "fixed" by renormalizing.
1812///
1813/// `hidden` is the router's input; `router_weight` is the router
1814/// projection's output for it (one logit per expert, already computed
1815/// by the caller from the scaled state -- see
1816/// [`gemma4_router_logits`]).
1817pub fn route_gemma4_moe(logits: &[f32], k: usize, per_expert_scale: &[f32]) -> RoutingDecision {
1818    let mut idx: Vec<usize> = (0..logits.len()).collect();
1819    // Top-k by RAW logit, ties toward the lower expert id so a cached
1820    // prefix cannot disagree with the run that produced it.
1821    idx.sort_unstable_by(|&a, &b| logits[b].total_cmp(&logits[a]).then(a.cmp(&b)));
1822    let top = &idx[..k.min(idx.len())];
1823
1824    let selected: Vec<f32> = top.iter().map(|&i| logits[i]).collect();
1825    let max = selected.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1826    let exps: Vec<f32> = selected.iter().map(|&l| (l - max).exp()).collect();
1827    let sum: f32 = exps.iter().sum();
1828    let weights: Vec<f32> = if sum > 0.0 {
1829        top.iter()
1830            .zip(exps.iter())
1831            .map(|(&e, &x)| {
1832                // The per-expert scale lands on the weight, after the
1833                // softmax. The weights deliberately no longer sum to
1834                // one afterwards.
1835                (x / sum) * per_expert_scale.get(e).copied().unwrap_or(1.0)
1836            })
1837            .collect()
1838    } else {
1839        exps
1840    };
1841
1842    RoutingDecision {
1843        expert_ids: top.to_vec(),
1844        weights,
1845    }
1846}
1847
1848/// The router logits Gemma-4 feeds to [`route_gemma4_moe`]: a
1849/// weightless RMSNorm of the hidden state, scaled by `router_scale` and
1850/// by `hidden^-0.5`, then projected.
1851///
1852/// Split from the routing itself so the two unusual scalings are
1853/// testable without a projection matrix -- see [`route_gemma4_moe`]'s
1854/// docs on why the `hidden^-0.5` factor is the easy one to lose.
1855pub fn gemma4_router_logits(
1856    hidden: &[f32],
1857    router_scale: &[f32],
1858    router_proj: &WeightMatrix,
1859    eps: f32,
1860) -> Vec<f32> {
1861    debug_assert_eq!(hidden.len(), router_scale.len());
1862    let n = hidden.len() as f32;
1863    // Weightless RMSNorm: no learned per-channel term.
1864    let mean_sq = hidden.iter().map(|v| v * v).sum::<f32>() / n;
1865    let inv_rms = 1.0 / (mean_sq + eps).sqrt();
1866    let width_scale = n.powf(-0.5);
1867    let scaled: Vec<f32> = hidden
1868        .iter()
1869        .zip(router_scale.iter())
1870        .map(|(&v, &s)| v * inv_rms * s * width_scale)
1871        .collect();
1872    router_proj.apply(&scaled)
1873}
1874
1875#[cfg(test)]
1876mod gemma4_router_tests {
1877    use super::*;
1878
1879    /// The per-expert scale lands on the routing WEIGHT, after the
1880    /// softmax, and the weights deliberately stop summing to one. A
1881    /// renormalization "fixing" that would cancel the scale exactly,
1882    /// which is the whole failure this pins: no shape changes, and the
1883    /// model stays fluent.
1884    #[test]
1885    fn the_per_expert_scale_multiplies_the_weight_and_breaks_the_sum_to_one() {
1886        let logits = vec![3.0, 1.0, 2.0, 0.0];
1887        let flat = route_gemma4_moe(&logits, 2, &[1.0; 4]);
1888        let sum: f32 = flat.weights.iter().sum();
1889        assert!(
1890            (sum - 1.0).abs() < 1e-6,
1891            "with unit scales, k weights sum to one"
1892        );
1893
1894        let scaled = route_gemma4_moe(&logits, 2, &[2.0, 1.0, 0.5, 1.0]);
1895        assert_eq!(scaled.expert_ids, flat.expert_ids, "selection is unchanged");
1896        // Experts 0 and 2 were selected; their scales are 2.0 and 0.5.
1897        assert!((scaled.weights[0] - flat.weights[0] * 2.0).abs() < 1e-6);
1898        assert!((scaled.weights[1] - flat.weights[1] * 0.5).abs() < 1e-6);
1899        let sum: f32 = scaled.weights.iter().sum();
1900        assert!(
1901            (sum - 1.0).abs() > 1e-3,
1902            "the scaled weights must NOT be renormalized back to one, got {sum}"
1903        );
1904    }
1905
1906    /// Softmax over just the selected k, not a slice of the full
1907    /// distribution. The two pick the same experts and weight them
1908    /// differently, which is exactly the kind of difference that
1909    /// produces a fluent wrong model.
1910    #[test]
1911    fn the_softmax_runs_over_the_selected_experts_only() {
1912        let logits = vec![3.0, 1.0, 2.0, 0.0];
1913        let gemma = route_gemma4_moe(&logits, 2, &[1.0; 4]);
1914        let sliced = route_top_k_softmax(&logits, 2, false);
1915        assert_eq!(gemma.expert_ids, sliced.expert_ids);
1916        let gemma_sum: f32 = gemma.weights.iter().sum();
1917        let sliced_sum: f32 = sliced.weights.iter().sum();
1918        assert!((gemma_sum - 1.0).abs() < 1e-6);
1919        assert!(
1920            sliced_sum < 0.99,
1921            "a slice of the full softmax sums to less than one, got {sliced_sum}"
1922        );
1923    }
1924
1925    /// Selection is by raw logit, so the largest logits win regardless
1926    /// of the per-expert scales -- the scale rescales a weight, it does
1927    /// not buy an expert its way into the selection.
1928    #[test]
1929    fn selection_is_by_raw_logit_and_the_scale_cannot_change_it() {
1930        let logits = vec![3.0, 1.0, 2.0, 0.0];
1931        let huge = route_gemma4_moe(&logits, 2, &[1.0, 1000.0, 1.0, 1000.0]);
1932        assert_eq!(
1933            huge.expert_ids,
1934            vec![0, 2],
1935            "expert 1's scale must not select it"
1936        );
1937    }
1938
1939    /// The width factor is a function of the hidden size alone, so it
1940    /// leaves the selection identical and changes the softmax
1941    /// temperature -- which is what makes omitting it invisible.
1942    #[test]
1943    fn the_width_scaling_changes_the_weights_but_not_the_selection() {
1944        let hidden = vec![1.0, -2.0, 0.5, 3.0];
1945        let router_scale = vec![1.0; 4];
1946        let proj = WeightMatrix::F32(ferrox_core::tensor::Tensor::new(
1947            vec![
1948                1.0, 0.0, 0.0, 0.0, //
1949                0.0, 1.0, 0.0, 0.0, //
1950                0.0, 0.0, 1.0, 0.0, //
1951                0.0, 0.0, 0.0, 1.0,
1952            ],
1953            vec![4, 4],
1954        ));
1955        let with_width = gemma4_router_logits(&hidden, &router_scale, &proj, 1e-6);
1956
1957        // The same thing without the hidden^-0.5 factor: every logit is
1958        // larger by exactly sqrt(hidden).
1959        let n = hidden.len() as f32;
1960        let without: Vec<f32> = with_width.iter().map(|v| v * n.sqrt()).collect();
1961
1962        let a = route_gemma4_moe(&with_width, 2, &[1.0; 4]);
1963        let b = route_gemma4_moe(&without, 2, &[1.0; 4]);
1964        assert_eq!(a.expert_ids, b.expert_ids, "the selection is unaffected");
1965        assert!(
1966            a.weights
1967                .iter()
1968                .zip(b.weights.iter())
1969                .any(|(x, y)| (x - y).abs() > 1e-4),
1970            "but the mixing weights are not: {:?} vs {:?}",
1971            a.weights,
1972            b.weights
1973        );
1974    }
1975
1976    /// The router's norm is WEIGHTLESS. Feeding a non-unit scale vector
1977    /// through must change the logits, which is what proves the norm
1978    /// itself is not quietly applying one.
1979    #[test]
1980    fn the_router_norm_carries_no_learned_weight_of_its_own() {
1981        let hidden = vec![1.0, -2.0, 0.5, 3.0];
1982        let proj = WeightMatrix::F32(ferrox_core::tensor::Tensor::new(
1983            (0..16)
1984                .map(|i| if i % 5 == 0 { 1.0 } else { 0.0 })
1985                .collect(),
1986            vec![4, 4],
1987        ));
1988        let unit = gemma4_router_logits(&hidden, &[1.0; 4], &proj, 1e-6);
1989        let scaled = gemma4_router_logits(&hidden, &[2.0; 4], &proj, 1e-6);
1990        for (u, s) in unit.iter().zip(scaled.iter()) {
1991            assert!(
1992                (s - u * 2.0).abs() < 1e-5,
1993                "router_scale is the ONLY learned scale on this path: {u} -> {s}"
1994            );
1995        }
1996    }
1997
1998    /// Ties break toward the lower expert id, deterministically.
1999    #[test]
2000    fn ties_break_toward_the_lower_expert_id() {
2001        let logits = vec![1.0, 1.0, 1.0, 1.0];
2002        for _ in 0..8 {
2003            assert_eq!(
2004                route_gemma4_moe(&logits, 2, &[1.0; 4]).expert_ids,
2005                vec![0, 1]
2006            );
2007        }
2008    }
2009}