Skip to main content

ferrox_moe/
lib.rs

1//! ferrox-moe: sparse Mixture-of-Experts routing, a shared-expert path,
2//! and a CPU/GPU expert-placement scheduler.
3//!
4//! The placement design mirrors the pattern popularized by ik_llama.cpp
5//! (tensor-name-regex overrides deciding which experts live on GPU vs
6//! CPU RAM, e.g. `--cpu-moe` / `-ncmoe`) and by llama.cpp's
7//! layer-split conventions, adapted here to a config-driven Rust
8//! scheduler rather than copied CLI-flag parsing code. See
9//! docs/THIRD_PARTY_NOTICES.md.
10
11use ferrox_core::matmul::swiglu;
12use ferrox_core::weight_matrix::WeightMatrix;
13
14/// Where a given expert's weights currently live. `GpuDevice`-placed
15/// experts only actually execute on a GPU under `--features cuda` and/or
16/// `--features metal` (see `run_expert_placed`); without a GPU feature
17/// the CPU path executes regardless. Device id is meaningful for CUDA;
18/// Metal currently uses the system default device and ignores the id.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ExpertPlacement {
21    Cpu,
22    GpuDevice(u32),
23}
24
25/// Static per-layer MoE configuration. One of these is built per layer
26/// from a ModelConfig preset (ferrox-models).
27#[derive(Debug, Clone)]
28pub struct MoeLayerConfig {
29    pub n_experts: usize,
30    pub n_experts_active: usize,
31    pub n_shared_experts: usize,
32    pub hidden_dim: usize,
33    pub expert_ffn_dim: usize,
34    /// Which function converts router logits into selection scores.
35    /// See `GatingFunction`'s doc comment: this is not a stylistic
36    /// choice, it's an evidence-backed architectural detail that
37    /// differs by model family.
38    pub gating: GatingFunction,
39    /// Only meaningful for `GatingFunction::Softmax` (the `Sigmoid` path
40    /// has its own separate, always-renormalized convention -- see
41    /// `route_top_k_sigmoid`'s doc comment). Whether the top-k selected
42    /// experts' softmax weights get renormalized to sum to one after
43    /// selection. Mixtral's real routing does this
44    /// (`routing_weights /= routing_weights.sum(...)` in its reference
45    /// implementation) and it's the right default for any architecture
46    /// that doesn't document otherwise -- but it is a real, per-model
47    /// choice, not a law of nature: OLMoE's real `config.json` sets
48    /// `norm_topk_prob: false`, confirmed against
49    /// `OlmoeTopKRouter.forward` in
50    /// `transformers/models/olmoe/modeling_olmoe.py` (`router_top_value
51    /// /= router_top_value.sum(...)` only runs `if self.norm_topk_prob`)
52    /// and against llama.cpp's real hardcoded `build_moe_ffn(..., false,
53    /// ..., LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX, ...)` call for
54    /// `LLM_ARCH_OLMOE` in `src/models/olmoe.cpp` (GGUF carries no
55    /// metadata key for this -- it's an architecture-hardcoded fact in
56    /// the reference implementation, not something read from the file).
57    /// Getting this wrong silently produces a real, wrong generation:
58    /// caught by comparing ferrox's real OLMoE output directly against
59    /// llama.cpp loading the identical GGUF file (llama.cpp answered
60    /// "Paris" for "the capital of France is"; ferrox, with this bug,
61    /// answered something else entirely).
62    pub norm_topk_prob: bool,
63    /// Optional 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/// Runs one token's hidden state through a single expert's SwiGLU FFN.
948pub fn run_expert(hidden: &[f32], expert: &ExpertWeights) -> Vec<f32> {
949    #[cfg(any(feature = "cuda", feature = "metal"))]
950    {
951        // Full SwiGLU on-device (1× upload + 1× download) when dense GPU is on.
952        if let Some(out) = ferrox_core::WeightMatrix::apply_gpu_dense_ffn_swiglu(
953            &expert.gate,
954            &expert.up,
955            &expert.down,
956            hidden,
957        ) {
958            return out;
959        }
960    }
961    #[cfg(any(feature = "cuda", feature = "metal"))]
962    {
963        // Gate and up share `hidden` — one GPU upload / multi-matvec.
964        if let Some(mut outs) =
965            ferrox_core::WeightMatrix::apply_gpu_multi(&[&expert.gate, &expert.up], hidden)
966        {
967            let up = outs.pop().unwrap();
968            let gate = outs.pop().unwrap();
969            let activated = swiglu(&gate, &up);
970            return expert.down.apply(&activated);
971        }
972    }
973    // Share one Q8 activation quant across gate+up when INT_DOT is on
974    // (OLMoE: avoids 2× quantize_activations_q8 per expert).
975    if ferrox_core::weight_matrix::cpu_int_dot_enabled() && hidden.len().is_multiple_of(32) {
976        let act = ferrox_quant::quantize_activations_q8(hidden);
977        // gate and up are independent over the same activation, so their
978        // parallel regions can overlap instead of running back to back.
979        // Decode's deficit is scheduling, not kernels -- see
980        // `WeightMatrix::apply_three`, which does this for q/k/v.
981        let (g, u) = rayon::join(
982            || expert.gate.apply_cpu_q8(&act),
983            || expert.up.apply_cpu_q8(&act),
984        );
985        if let (Some(gate), Some(up)) = (g, u) {
986            let activated = swiglu(&gate, &up);
987            return expert.down.apply(&activated);
988        }
989    }
990    let (gate, up) = rayon::join(|| expert.gate.apply(hidden), || expert.up.apply(hidden));
991    let activated = swiglu(&gate, &up);
992    expert.down.apply(&activated)
993}
994
995/// `run_expert`, but actually consulting `placement` instead of always
996/// running on CPU -- this is the real execution consequence
997/// `PlacementPlan` previously computed but nothing acted on: a
998/// `GpuDevice`-placed expert's gate/up/down matvecs go through
999/// `WeightMatrix::apply_gpu` (real CUDA and/or Metal kernels for
1000/// Q8_0/Q4_0/Q4_K/Q5_K/Q6_K), falling straight through to the ordinary
1001/// CPU path for any matrix `apply_gpu` returns `None` for (an
1002/// unsupported quant kind, or a real launch failure) -- so this is
1003/// always correct, never a hard failure, regardless of GPU availability.
1004///
1005/// Every call re-uploads each weight matrix to the device from scratch
1006/// (see `WeightMatrix::apply_gpu`'s doc comment) -- correct, but not
1007/// yet the persistent-GPU-residency throughput win real expert offload
1008/// needs; a real, disclosed limit of this round, not overclaimed.
1009///
1010/// Without a GPU feature (`cuda` / `metal`) compiled in, this has the
1011/// exact same signature and always calls `run_expert` (ignoring
1012/// `placement`), so callers (e.g. `ferrox-models::decoder::Decoder`)
1013/// can call it unconditionally regardless of how this crate was built,
1014/// with correct behavior either way.
1015#[cfg(any(feature = "cuda", feature = "metal"))]
1016pub fn run_expert_placed(
1017    hidden: &[f32],
1018    expert: &ExpertWeights,
1019    placement: ExpertPlacement,
1020) -> Vec<f32> {
1021    if matches!(placement, ExpertPlacement::GpuDevice(_)) {
1022        #[cfg(any(feature = "cuda", feature = "metal"))]
1023        {
1024            if let Some(out) = ferrox_core::WeightMatrix::apply_gpu_dense_ffn_swiglu(
1025                &expert.gate,
1026                &expert.up,
1027                &expert.down,
1028                hidden,
1029            ) {
1030                return out;
1031            }
1032        }
1033        #[cfg(any(feature = "cuda", feature = "metal"))]
1034        {
1035            if let Some(mut outs) =
1036                ferrox_core::WeightMatrix::apply_gpu_multi(&[&expert.gate, &expert.up], hidden)
1037            {
1038                let up = outs.pop().unwrap();
1039                let gate = outs.pop().unwrap();
1040                let activated = swiglu(&gate, &up);
1041                if let Some(down) = expert.down.apply_gpu(&activated) {
1042                    return down;
1043                }
1044                return expert.down.apply(&activated);
1045            }
1046        }
1047        if let Some(gate) = expert.gate.apply_gpu(hidden) {
1048            if let Some(up) = expert.up.apply_gpu(hidden) {
1049                let activated = swiglu(&gate, &up);
1050                if let Some(down) = expert.down.apply_gpu(&activated) {
1051                    return down;
1052                }
1053            }
1054        }
1055    }
1056    run_expert(hidden, expert)
1057}
1058
1059#[cfg(not(any(feature = "cuda", feature = "metal")))]
1060pub fn run_expert_placed(
1061    hidden: &[f32],
1062    expert: &ExpertWeights,
1063    _placement: ExpertPlacement,
1064) -> Vec<f32> {
1065    run_expert(hidden, expert)
1066}
1067
1068/// Combines routed + shared expert outputs for one token.
1069pub fn combine_expert_outputs(
1070    routed_outputs: &[(Vec<f32>, f32)],
1071    shared_outputs: &[Vec<f32>],
1072    hidden_dim: usize,
1073) -> Vec<f32> {
1074    let mut out = vec![0f32; hidden_dim];
1075    for (expert_out, weight) in routed_outputs {
1076        for (o, e) in out.iter_mut().zip(expert_out.iter()) {
1077            *o += e * weight;
1078        }
1079    }
1080    for shared_out in shared_outputs {
1081        for (o, e) in out.iter_mut().zip(shared_out.iter()) {
1082            *o += e;
1083        }
1084    }
1085    out
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090    /// The property the global planner exists for: with N layers of
1091    /// identical experts and a budget that fits exactly K experts,
1092    /// exactly K experts are device-placed across ALL layers combined
1093    /// -- not K per layer, which is what independent per-layer
1094    /// `from_budget` calls with the same budget would produce (N*K).
1095    #[test]
1096    fn global_budget_cannot_be_multiplied_across_layers() {
1097        let n_layers = 10;
1098        let sizes: Vec<Vec<usize>> = (0..n_layers).map(|_| vec![100usize; 4]).collect();
1099        let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 250);
1100
1101        let total_placed: usize = (0..n_layers)
1102            .map(|l| {
1103                (0..4)
1104                    .filter(|&e| plan.layer_plan(l).placement_for(e) != ExpertPlacement::Cpu)
1105                    .count()
1106            })
1107            .sum();
1108        assert_eq!(
1109            total_placed, 2,
1110            "250 bytes fits exactly 2 x 100-byte experts, globally"
1111        );
1112        assert_eq!(plan.device_bytes_planned, 200);
1113        assert!(plan.device_bytes_planned <= plan.vram_budget_bytes);
1114
1115        // The old shape of the bug, for contrast: per-layer planning
1116        // with the same budget places 2 experts in EVERY layer.
1117        let per_layer_total: usize = (0..n_layers)
1118            .map(|_| {
1119                let p = PlacementPlan::from_budget(&[100; 4], None, 250);
1120                (0..4)
1121                    .filter(|&e| p.placement_for(e) != ExpertPlacement::Cpu)
1122                    .count()
1123            })
1124            .sum();
1125        assert_eq!(per_layer_total, 20, "per-layer planning overcommits 10x");
1126    }
1127
1128    /// Hot experts win device slots across layer boundaries: a single
1129    /// very hot expert in a late layer beats cold experts in earlier
1130    /// layers.
1131    #[test]
1132    fn global_planning_prioritizes_hotness_across_layers() {
1133        let sizes: Vec<Vec<usize>> = (0..3).map(|_| vec![100usize; 2]).collect();
1134        let mut counts: Vec<Vec<u64>> = (0..3).map(|_| vec![0u64; 2]).collect();
1135        counts[2][1] = 50; // the only hot expert lives in the last layer
1136        counts[0][0] = 10;
1137        let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, Some(&counts), 200);
1138
1139        assert_eq!(
1140            plan.layer_plan(2).placement_for(1),
1141            ExpertPlacement::GpuDevice(0),
1142            "hottest expert (layer 2) must win a slot"
1143        );
1144        assert_eq!(
1145            plan.layer_plan(0).placement_for(0),
1146            ExpertPlacement::GpuDevice(0),
1147            "second-hottest expert (layer 0) takes the remaining slot"
1148        );
1149        assert_eq!(plan.device_bytes_planned, 200);
1150    }
1151
1152    /// Zero budget places nothing anywhere; empty (dense) layers are
1153    /// legal and contribute no candidates.
1154    #[test]
1155    fn global_planning_handles_zero_budget_and_dense_layers() {
1156        let sizes = vec![Vec::new(), vec![100usize; 3], Vec::new()];
1157        let plan = PlacementPlan::plan_layers_against_global_budget(&sizes, None, 0);
1158        assert_eq!(plan.device_bytes_planned, 0);
1159        assert_eq!(plan.n_layers(), 3);
1160        for e in 0..3 {
1161            assert_eq!(plan.layer_plan(1).placement_for(e), ExpertPlacement::Cpu);
1162        }
1163    }
1164
1165    use super::*;
1166
1167    #[test]
1168    fn top_k_selects_highest_scoring_experts() {
1169        let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1170        let decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1171        assert_eq!(decision.expert_ids, vec![1, 3]);
1172        let sum: f32 = decision.weights.iter().sum();
1173        assert!((sum - 1.0).abs() < 1e-5);
1174        assert!(decision.weights[0] > decision.weights[1]);
1175    }
1176
1177    #[test]
1178    fn top_k_weights_always_sum_to_one_regardless_of_k() {
1179        let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1180        for k in 1..=8 {
1181            let decision = route_top_k(&logits, k, GatingFunction::Softmax, true);
1182            let sum: f32 = decision.weights.iter().sum();
1183            assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
1184        }
1185    }
1186
1187    /// `norm_topk_prob: false` -- OLMoE's real convention (see
1188    /// `MoeLayerConfig::norm_topk_prob`'s doc comment). Golden values
1189    /// hand-computed independently: full softmax over all 8 logits
1190    /// (sum of exp(l_i - 8) = 1.5814460129), then the raw (un-renormalized)
1191    /// probabilities of the top-3 selected experts (indices 7, 6, 5 --
1192    /// logits 8, 7, 6). This is the exact bug that was silently producing
1193    /// wrong OLMoE output: the old code could only ever compute a
1194    /// top-k-local softmax (mathematically identical to
1195    /// always-renormalize), with no way to recover the un-renormalized
1196    /// probability relative to *all* experts.
1197    #[test]
1198    fn norm_topk_prob_false_uses_raw_full_softmax_probability_not_renormalized() {
1199        let logits = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1200        let decision = route_top_k(&logits, 3, GatingFunction::Softmax, false);
1201
1202        assert_eq!(decision.expert_ids, vec![7, 6, 5]);
1203
1204        let expected = [0.6323223_f32, 0.2326232, 0.0855683];
1205        for (got, want) in decision.weights.iter().zip(expected.iter()) {
1206            assert!((got - want).abs() < 1e-4, "got={got} want={want}");
1207        }
1208
1209        let sum: f32 = decision.weights.iter().sum();
1210        assert!(
1211            (sum - 0.9505138).abs() < 1e-4,
1212            "raw top-3 probability mass should be < 1 (it's a subset of a full 8-way softmax), got sum={sum}"
1213        );
1214
1215        // Selecting the same experts with norm_topk_prob=true must
1216        // renormalize to the exact same values divided by that sum --
1217        // proving the two modes agree on *which* experts fire and differ
1218        // only in the final weight scaling.
1219        let normalized = route_top_k(&logits, 3, GatingFunction::Softmax, true);
1220        assert_eq!(normalized.expert_ids, decision.expert_ids);
1221        for (raw, norm) in decision.weights.iter().zip(normalized.weights.iter()) {
1222            assert!(
1223                (raw / sum - norm).abs() < 1e-4,
1224                "raw={raw} sum={sum} normalized={norm}"
1225            );
1226        }
1227    }
1228
1229    #[test]
1230    fn sigmoid_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1231        // Sigmoid is monotonic in its input, so for a given set of
1232        // logits, top-k-by-sigmoid-score must select the exact same
1233        // expert ids as top-k-by-raw-logit (sigmoid just changes the
1234        // *weights*, not which experts are chosen).
1235        let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1236        let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1237        let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1238        assert_eq!(softmax_decision.expert_ids, sigmoid_decision.expert_ids);
1239    }
1240
1241    #[test]
1242    fn sigmoid_gating_weights_sum_to_one() {
1243        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1244        for k in 1..=8 {
1245            let decision = route_top_k(&logits, k, GatingFunction::Sigmoid, true);
1246            let sum: f32 = decision.weights.iter().sum();
1247            assert!((sum - 1.0).abs() < 1e-5, "k={k} sum={sum}");
1248        }
1249    }
1250
1251    #[test]
1252    fn bias_only_affects_selection_not_the_final_weight_value() {
1253        // Expert 0 has the lower raw score but a large positive bias, so
1254        // biased selection must pick it over expert 1 -- but the WEIGHT
1255        // it ends up with must be its raw (unbiased) sigmoid score, not
1256        // score+bias. Getting this backwards would silently make a
1257        // barely-selected expert dominate the combine.
1258        let logits = vec![0.1, 2.0];
1259        let bias = vec![10.0, 0.0];
1260        let decision = route_top_k_sigmoid_with_bias(&logits, &bias, 1, true, 1.0);
1261        assert_eq!(decision.expert_ids, vec![0]);
1262        // k=1 -> renormalization is a no-op (single weight / itself = 1,
1263        // scaled by 1.0), so the weight is just sigmoid(0.1), not 1.0.
1264        assert!((decision.weights[0] - sigmoid(0.1)).abs() < 1e-5);
1265    }
1266
1267    #[test]
1268    fn without_bias_selection_falls_back_to_plain_sigmoid_top_k() {
1269        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1270        let zero_bias = vec![0.0; logits.len()];
1271        let biased = route_top_k_sigmoid_with_bias(&logits, &zero_bias, 3, true, 1.0);
1272        let plain = route_top_k(&logits, 3, GatingFunction::Sigmoid, true);
1273        assert_eq!(biased.expert_ids, plain.expert_ids);
1274        for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1275            assert!((a - b).abs() < 1e-6);
1276        }
1277    }
1278
1279    #[test]
1280    fn scaling_factor_multiplies_every_weight() {
1281        let logits = vec![1.0, 2.0, 3.0];
1282        let bias = vec![0.0; 3];
1283        let unscaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 1.0);
1284        let scaled = route_top_k_sigmoid_with_bias(&logits, &bias, 2, true, 2.5);
1285        for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1286            assert!((u * 2.5 - s).abs() < 1e-5);
1287        }
1288    }
1289
1290    #[test]
1291    fn sigmoid_and_softmax_weights_differ_for_the_same_logits() {
1292        // The whole point of the distinction: sigmoid scores each
1293        // expert independently (not as a joint distribution), so the
1294        // relative weighting between two selected experts differs from
1295        // softmax's, even though both sum to one and pick the same
1296        // experts. If this test ever fails by finding the two paths
1297        // identical, something has collapsed the sigmoid path back
1298        // into softmax.
1299        let logits = vec![3.0, 1.0, -2.0, 0.5];
1300        let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1301        let sigmoid_decision = route_top_k(&logits, 2, GatingFunction::Sigmoid, true);
1302        assert!(
1303            (softmax_decision.weights[0] - sigmoid_decision.weights[0]).abs() > 1e-3,
1304            "softmax and sigmoid gating should generally produce different weight splits for the same logits"
1305        );
1306    }
1307
1308    #[test]
1309    fn sqrt_softplus_matches_hand_computed_values_at_zero_and_positive_logit() {
1310        // softplus(0) = ln(2), sqrt(ln(2)) -- exact closed form, not just a
1311        // property check, to pin the real DeepSeek V4 formula
1312        // (sqrt(softplus(x)), not e.g. softplus(sqrt(x)) or sqrt(sigmoid)).
1313        assert!((sqrt_softplus(0.0) - 2.0_f32.ln().sqrt()).abs() < 1e-6);
1314        // softplus(x) -> x for large positive x, so sqrt_softplus(x) -> sqrt(x).
1315        assert!((sqrt_softplus(20.0) - 20.0_f32.sqrt()).abs() < 1e-3);
1316    }
1317
1318    /// Group-limited routing CONCENTRATES; it does not spread.
1319    ///
1320    /// This test replaces one that asserted the opposite -- that
1321    /// `total_k = 2` over two groups keeps "both group winners" -- which
1322    /// was the shape of the bug, not a property of the rule. The real
1323    /// DeepSeek-V3 / GLM `n_group`/`topk_group` router scores each group
1324    /// by the sum of its top-2 members, keeps the `topk_group` best
1325    /// groups, masks every expert in the rest to `-inf`, and then runs
1326    /// ONE GLOBAL top-k over what survives. With `topk_group = 1` only
1327    /// group 0 survives, so both selected experts must come from it and
1328    /// expert 3 must NOT fire.
1329    ///
1330    /// It therefore fails against the previous implementation, which
1331    /// took `k_per_group` from every group and truncated afterwards.
1332    #[test]
1333    fn group_limited_routing_concentrates_into_the_surviving_groups() {
1334        // 4 experts, 2 groups of 2. Group 0 holds the two best scores.
1335        let logits = vec![5.0, 4.5, 0.2, 0.1];
1336        let d = route_top_k_grouped(&logits, 2, 1, 2, GatingFunction::Softmax, true);
1337        assert_eq!(d.expert_ids.len(), 2);
1338        let mut ids = d.expert_ids.clone();
1339        ids.sort_unstable();
1340        assert_eq!(ids, vec![0, 1], "both experts must come from group 0");
1341        let sum: f32 = d.weights.iter().sum();
1342        assert!((sum - 1.0).abs() < 1e-4);
1343    }
1344
1345    /// The group score is the sum of a group's top TWO, not its best
1346    /// single member. A group with one spike and nothing behind it loses
1347    /// to a group with two strong members.
1348    #[test]
1349    fn a_group_is_scored_by_its_top_two_not_by_its_best_member() {
1350        // Group 0: one spike and a dead expert, carrying 0.426 of the
1351        // softmax mass between them. Group 1: two solid members
1352        // carrying 0.574 together, neither of which beats the spike on
1353        // its own. Scoring by best member picks group 0; scoring by
1354        // top-2 sum picks group 1, which is the reference rule.
1355        let logits = vec![1.0, -20.0, 0.7, 0.5];
1356        let d = route_top_k_grouped(&logits, 2, 1, 2, GatingFunction::Softmax, true);
1357        let mut ids = d.expert_ids.clone();
1358        ids.sort_unstable();
1359        assert_eq!(ids, vec![2, 3], "the two-strong-members group wins");
1360    }
1361
1362    #[test]
1363    fn sqrtsoftplus_gating_selects_same_top_experts_as_softmax_for_monotonic_logits() {
1364        // sqrt(softplus(x)) is monotonically increasing in x (both sqrt
1365        // and softplus are), so top-k-by-score must agree with top-k by
1366        // raw logit on *which* experts fire, same reasoning as the
1367        // sigmoid monotonicity test above.
1368        let logits = vec![0.1, 5.0, 0.2, 3.0, -1.0];
1369        let softmax_decision = route_top_k(&logits, 2, GatingFunction::Softmax, true);
1370        let sqrtsoftplus_decision = route_top_k(&logits, 2, GatingFunction::SqrtSoftplus, true);
1371        assert_eq!(
1372            softmax_decision.expert_ids,
1373            sqrtsoftplus_decision.expert_ids
1374        );
1375    }
1376
1377    #[test]
1378    fn sqrtsoftplus_weights_sum_to_one_when_normalized() {
1379        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1380        for k in 1..=8 {
1381            let decision = route_top_k(&logits, k, GatingFunction::SqrtSoftplus, true);
1382            let sum: f32 = decision.weights.iter().sum();
1383            assert!((sum - 1.0).abs() < 1e-4, "k={k} sum={sum}");
1384        }
1385    }
1386
1387    #[test]
1388    fn sqrtsoftplus_bias_only_affects_selection_not_the_final_weight_value() {
1389        // Same structure as `bias_only_affects_selection_not_the_final_weight_value`
1390        // but for the sqrt-softplus scoring function DeepSeek V4's real
1391        // non-hash MoE layers use.
1392        let logits = vec![0.1, 2.0];
1393        let bias = vec![10.0, 0.0];
1394        let decision = route_top_k_sqrtsoftplus_with_bias(&logits, &bias, 1, true, 1.0);
1395        assert_eq!(decision.expert_ids, vec![0]);
1396        assert!((decision.weights[0] - sqrt_softplus(0.1)).abs() < 1e-5);
1397    }
1398
1399    #[test]
1400    fn sqrtsoftplus_without_bias_selection_falls_back_to_plain_top_k() {
1401        let logits = vec![-2.0, 0.5, 3.0, 1.2, -0.3, 4.0, 0.0, -1.5];
1402        let zero_bias = vec![0.0; logits.len()];
1403        let biased = route_top_k_sqrtsoftplus_with_bias(&logits, &zero_bias, 3, true, 1.0);
1404        let plain = route_top_k(&logits, 3, GatingFunction::SqrtSoftplus, true);
1405        assert_eq!(biased.expert_ids, plain.expert_ids);
1406        for (a, b) in biased.weights.iter().zip(plain.weights.iter()) {
1407            assert!((a - b).abs() < 1e-6);
1408        }
1409    }
1410
1411    #[test]
1412    fn hash_routing_uses_the_fixed_table_ids_regardless_of_logit_ranking() {
1413        // Expert 0 has by far the highest logit, but the real mechanism
1414        // never looks at the router's ranking to choose experts for a
1415        // hash-routed layer -- the table says [2, 1], so that's what
1416        // fires, full stop.
1417        let logits = vec![100.0, 1.0, 0.5, -3.0];
1418        let hash_expert_ids = vec![2usize, 1usize];
1419        let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1420        assert_eq!(decision.expert_ids, vec![2, 1]);
1421    }
1422
1423    #[test]
1424    fn hash_routing_weights_come_from_the_real_router_logits_not_a_fixed_split() {
1425        // The table fixes *which* experts fire, but their relative
1426        // combine weight still comes from sqrt(softplus(logit)) gathered
1427        // at those ids -- not a uniform 1/n split. Expert 2's logit (3.0)
1428        // is much larger than expert 1's (0.1), so its weight must
1429        // dominate even though both were unconditionally selected.
1430        let logits = vec![-5.0, 0.1, 3.0, -5.0];
1431        let hash_expert_ids = vec![2usize, 1usize];
1432        let decision = route_hash(&hash_expert_ids, &logits, true, 1.0);
1433        assert!(decision.weights[0] > decision.weights[1]);
1434        let sum: f32 = decision.weights.iter().sum();
1435        assert!((sum - 1.0).abs() < 1e-5);
1436        let expected0 = sqrt_softplus(3.0) / (sqrt_softplus(3.0) + sqrt_softplus(0.1));
1437        assert!((decision.weights[0] - expected0).abs() < 1e-5);
1438    }
1439
1440    #[test]
1441    fn hash_routing_scaling_factor_multiplies_every_weight() {
1442        let logits = vec![1.0, 2.0, 3.0];
1443        let hash_expert_ids = vec![0usize, 2usize];
1444        let unscaled = route_hash(&hash_expert_ids, &logits, true, 1.0);
1445        let scaled = route_hash(&hash_expert_ids, &logits, true, 2.5);
1446        for (u, s) in unscaled.weights.iter().zip(scaled.weights.iter()) {
1447            assert!((u * 2.5 - s).abs() < 1e-5);
1448        }
1449    }
1450
1451    #[test]
1452    fn placement_plan_defaults_to_cpu_for_unlisted_experts() {
1453        let plan = PlacementPlan::hot_experts_on_gpu(256, 8);
1454        assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1455        assert_eq!(plan.placement_for(7), ExpertPlacement::GpuDevice(0));
1456        assert_eq!(plan.placement_for(8), ExpertPlacement::Cpu);
1457        assert_eq!(plan.placement_for(255), ExpertPlacement::Cpu);
1458    }
1459
1460    #[test]
1461    fn all_cpu_plan_never_returns_gpu() {
1462        let plan = PlacementPlan::all_cpu(64);
1463        for i in 0..64 {
1464            assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1465        }
1466    }
1467
1468    #[test]
1469    fn from_budget_fits_as_many_experts_as_the_vram_budget_allows() {
1470        // 4 experts, 100 bytes each: a 250-byte budget fits exactly 2.
1471        let sizes = vec![100usize, 100, 100, 100];
1472        let plan = PlacementPlan::from_budget(&sizes, None, 250);
1473        let on_gpu = (0..4)
1474            .filter(|&i| plan.placement_for(i) == ExpertPlacement::GpuDevice(0))
1475            .count();
1476        assert_eq!(on_gpu, 2);
1477    }
1478
1479    #[test]
1480    fn from_budget_prioritizes_the_most_frequently_activated_experts() {
1481        // Expert 2 is by far the hottest but is neither first nor
1482        // largest -- a real budget-aware plan must still pick it first.
1483        let sizes = vec![50usize, 50, 50, 50];
1484        let counts = vec![1u64, 2, 100, 3];
1485        // Budget for exactly one expert.
1486        let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 50);
1487        assert_eq!(
1488            plan.placement_for(2),
1489            ExpertPlacement::GpuDevice(0),
1490            "the hottest expert (index 2) must be the one placed on GPU"
1491        );
1492        assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1493        assert_eq!(plan.placement_for(1), ExpertPlacement::Cpu);
1494        assert_eq!(plan.placement_for(3), ExpertPlacement::Cpu);
1495    }
1496
1497    #[test]
1498    fn from_budget_skips_an_expert_that_does_not_fit_and_tries_the_next() {
1499        // Expert 0 is too big for the budget alone; experts 1 and 2
1500        // together fit and should both be placed.
1501        let sizes = vec![200usize, 60, 60];
1502        let plan = PlacementPlan::from_budget(&sizes, None, 120);
1503        assert_eq!(plan.placement_for(0), ExpertPlacement::Cpu);
1504        assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1505        assert_eq!(plan.placement_for(2), ExpertPlacement::GpuDevice(0));
1506    }
1507
1508    #[test]
1509    fn from_budget_with_zero_vram_places_nothing_on_gpu() {
1510        let sizes = vec![10usize, 20, 30];
1511        let plan = PlacementPlan::from_budget(&sizes, None, 0);
1512        for i in 0..3 {
1513            assert_eq!(plan.placement_for(i), ExpertPlacement::Cpu);
1514        }
1515    }
1516
1517    #[test]
1518    fn from_budget_ignores_mismatched_activation_counts_length_rather_than_panicking() {
1519        let sizes = vec![10usize, 10];
1520        let counts = vec![1u64]; // wrong length
1521        let plan = PlacementPlan::from_budget(&sizes, Some(&counts), 100);
1522        // Falls back to index order; both fit within the budget either way.
1523        assert_eq!(plan.placement_for(0), ExpertPlacement::GpuDevice(0));
1524        assert_eq!(plan.placement_for(1), ExpertPlacement::GpuDevice(0));
1525    }
1526
1527    #[test]
1528    fn combine_expert_outputs_weights_routed_and_adds_shared() {
1529        let routed = vec![(vec![2.0, 2.0], 0.5), (vec![4.0, 4.0], 0.5)];
1530        let shared = vec![vec![1.0, 1.0]];
1531        let out = combine_expert_outputs(&routed, &shared, 2);
1532        assert_eq!(out, vec![4.0, 4.0]);
1533    }
1534
1535    #[test]
1536    fn run_expert_produces_correct_output_dimension() {
1537        use ferrox_core::tensor::Tensor;
1538        let hidden_dim = 4;
1539        let ffn_dim = 3;
1540        let expert = ExpertWeights {
1541            gate: WeightMatrix::F32(Tensor::new(
1542                vec![0.1; ffn_dim * hidden_dim],
1543                vec![ffn_dim, hidden_dim],
1544            )),
1545            up: WeightMatrix::F32(Tensor::new(
1546                vec![0.2; ffn_dim * hidden_dim],
1547                vec![ffn_dim, hidden_dim],
1548            )),
1549            down: WeightMatrix::F32(Tensor::new(
1550                vec![0.3; hidden_dim * ffn_dim],
1551                vec![hidden_dim, ffn_dim],
1552            )),
1553        };
1554        let hidden = vec![1.0, -1.0, 0.5, 0.5];
1555        let out = run_expert(&hidden, &expert);
1556        assert_eq!(out.len(), hidden_dim);
1557        assert!(out.iter().all(|v| v.is_finite()));
1558    }
1559
1560    /// `run_expert_placed` must be a real drop-in for `run_expert` when
1561    /// no GPU dispatch actually happens -- true unconditionally without
1562    /// the `cuda` feature, and true even *with* the feature for `Cpu`
1563    /// placement (which never calls `apply_gpu` at all) or an
1564    /// unsupported quant kind (F32 here, which `apply_gpu` always
1565    /// returns `None` for, falling through to `run_expert`).
1566    #[test]
1567    fn run_expert_placed_matches_run_expert_when_nothing_is_gpu_dispatched() {
1568        use ferrox_core::tensor::Tensor;
1569        let hidden_dim = 4;
1570        let ffn_dim = 3;
1571        let expert = ExpertWeights {
1572            gate: WeightMatrix::F32(Tensor::new(
1573                vec![0.1; ffn_dim * hidden_dim],
1574                vec![ffn_dim, hidden_dim],
1575            )),
1576            up: WeightMatrix::F32(Tensor::new(
1577                vec![0.2; ffn_dim * hidden_dim],
1578                vec![ffn_dim, hidden_dim],
1579            )),
1580            down: WeightMatrix::F32(Tensor::new(
1581                vec![0.3; hidden_dim * ffn_dim],
1582                vec![hidden_dim, ffn_dim],
1583            )),
1584        };
1585        let hidden = vec![1.0, -1.0, 0.5, 0.5];
1586        let expected = run_expert(&hidden, &expert);
1587
1588        assert_eq!(
1589            run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu),
1590            expected
1591        );
1592        assert_eq!(
1593            run_expert_placed(&hidden, &expert, ExpertPlacement::GpuDevice(0)),
1594            expected,
1595            "F32 has no GPU kernel, so GpuDevice placement must still fall through to the CPU path"
1596        );
1597    }
1598
1599    #[cfg(any(feature = "cuda", feature = "metal"))]
1600    #[test]
1601    #[ignore = "requires real GPU hardware (CUDA or Metal) -- run with --ignored"]
1602    fn run_expert_placed_on_gpu_matches_cpu_for_a_real_quantized_expert() {
1603        let hidden_dim = 32;
1604        let ffn_dim = 32; // must be a multiple of Q8_0 block elems (32)
1605        let make_row = |cols: usize, seed: f32| -> Vec<f32> {
1606            (0..cols)
1607                .map(|i| ((i as f32) - (cols as f32) / 2.0) * 0.01 * seed)
1608                .collect()
1609        };
1610        let quantize_matrix = |rows: usize, cols: usize, seed: f32| {
1611            let mut packed = Vec::new();
1612            for r in 0..rows {
1613                packed.extend(ferrox_quant::quantize_q8_0(&make_row(
1614                    cols,
1615                    seed + r as f32,
1616                )));
1617            }
1618            WeightMatrix::Quantized {
1619                data: ferrox_core::weight_matrix::WeightBytes::Owned(packed),
1620                rows,
1621                cols,
1622                kind: ferrox_core::weight_matrix::QuantKind::Q8_0,
1623            }
1624        };
1625        let expert = ExpertWeights {
1626            gate: quantize_matrix(ffn_dim, hidden_dim, 1.0),
1627            up: quantize_matrix(ffn_dim, hidden_dim, 2.0),
1628            down: quantize_matrix(hidden_dim, ffn_dim, 3.0),
1629        };
1630        let hidden = make_row(hidden_dim, 0.5);
1631
1632        let cpu = run_expert_placed(&hidden, &expert, ExpertPlacement::Cpu);
1633        let gpu = run_expert_placed(&hidden, &expert, ExpertPlacement::GpuDevice(0));
1634        assert_eq!(cpu.len(), gpu.len());
1635        for (c, g) in cpu.iter().zip(gpu.iter()) {
1636            assert!((c - g).abs() < 1e-1, "cpu={c} gpu={g}");
1637        }
1638    }
1639}
1640
1641/// Gemma-4's MoE router: how a hidden state becomes routing weights.
1642///
1643/// Four things differ from every other family here, and three of them
1644/// change the numbers without changing any shape -- so getting one
1645/// wrong produces a model that is fluent and wrong, with nothing to
1646/// catch it.
1647///
1648/// 1. The router input is normalized by a **weightless** RMSNorm. No
1649///    learned per-channel scale, unlike every other norm in the stack.
1650///    Reusing a weighted `rms_norm` here silently applies whatever
1651///    weight vector happened to be at hand.
1652/// 2. The normalized state is multiplied by a learned `router_scale`
1653///    vector, **and** by `hidden^-0.5`. The second factor is a
1654///    function of the width alone, so it is easy to omit and impossible
1655///    to notice: it rescales every logit by the same constant, which
1656///    changes the softmax temperature over the selected experts and
1657///    therefore the mixing weights, while leaving the top-k selection
1658///    itself identical.
1659/// 3. Selection is by **raw logit**, and the softmax runs over just the
1660///    selected `k`. That is not the same as softmaxing all experts and
1661///    slicing (see [`route_top_k_softmax`], where the surviving weights
1662///    sum to less than one); here they sum to exactly one.
1663/// 4. Each selected weight is then multiplied by
1664///    `per_expert_scale[expert_id]` -- a per-expert rescale applied to
1665///    the ROUTING WEIGHT rather than to the expert's output. No other
1666///    family here has it, and after it the weights no longer sum to
1667///    one, which is correct and must not be "fixed" by renormalizing.
1668///
1669/// `hidden` is the router's input; `router_weight` is the router
1670/// projection's output for it (one logit per expert, already computed
1671/// by the caller from the scaled state -- see
1672/// [`gemma4_router_logits`]).
1673pub fn route_gemma4_moe(logits: &[f32], k: usize, per_expert_scale: &[f32]) -> RoutingDecision {
1674    let mut idx: Vec<usize> = (0..logits.len()).collect();
1675    // Top-k by RAW logit, ties toward the lower expert id so a cached
1676    // prefix cannot disagree with the run that produced it.
1677    idx.sort_unstable_by(|&a, &b| logits[b].total_cmp(&logits[a]).then(a.cmp(&b)));
1678    let top = &idx[..k.min(idx.len())];
1679
1680    let selected: Vec<f32> = top.iter().map(|&i| logits[i]).collect();
1681    let max = selected.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1682    let exps: Vec<f32> = selected.iter().map(|&l| (l - max).exp()).collect();
1683    let sum: f32 = exps.iter().sum();
1684    let weights: Vec<f32> = if sum > 0.0 {
1685        top.iter()
1686            .zip(exps.iter())
1687            .map(|(&e, &x)| {
1688                // The per-expert scale lands on the weight, after the
1689                // softmax. The weights deliberately no longer sum to
1690                // one afterwards.
1691                (x / sum) * per_expert_scale.get(e).copied().unwrap_or(1.0)
1692            })
1693            .collect()
1694    } else {
1695        exps
1696    };
1697
1698    RoutingDecision {
1699        expert_ids: top.to_vec(),
1700        weights,
1701    }
1702}
1703
1704/// The router logits Gemma-4 feeds to [`route_gemma4_moe`]: a
1705/// weightless RMSNorm of the hidden state, scaled by `router_scale` and
1706/// by `hidden^-0.5`, then projected.
1707///
1708/// Split from the routing itself so the two unusual scalings are
1709/// testable without a projection matrix -- see [`route_gemma4_moe`]'s
1710/// docs on why the `hidden^-0.5` factor is the easy one to lose.
1711pub fn gemma4_router_logits(
1712    hidden: &[f32],
1713    router_scale: &[f32],
1714    router_proj: &WeightMatrix,
1715    eps: f32,
1716) -> Vec<f32> {
1717    debug_assert_eq!(hidden.len(), router_scale.len());
1718    let n = hidden.len() as f32;
1719    // Weightless RMSNorm: no learned per-channel term.
1720    let mean_sq = hidden.iter().map(|v| v * v).sum::<f32>() / n;
1721    let inv_rms = 1.0 / (mean_sq + eps).sqrt();
1722    let width_scale = n.powf(-0.5);
1723    let scaled: Vec<f32> = hidden
1724        .iter()
1725        .zip(router_scale.iter())
1726        .map(|(&v, &s)| v * inv_rms * s * width_scale)
1727        .collect();
1728    router_proj.apply(&scaled)
1729}
1730
1731#[cfg(test)]
1732mod gemma4_router_tests {
1733    use super::*;
1734
1735    /// The per-expert scale lands on the routing WEIGHT, after the
1736    /// softmax, and the weights deliberately stop summing to one. A
1737    /// renormalization "fixing" that would cancel the scale exactly,
1738    /// which is the whole failure this pins: no shape changes, and the
1739    /// model stays fluent.
1740    #[test]
1741    fn the_per_expert_scale_multiplies_the_weight_and_breaks_the_sum_to_one() {
1742        let logits = vec![3.0, 1.0, 2.0, 0.0];
1743        let flat = route_gemma4_moe(&logits, 2, &[1.0; 4]);
1744        let sum: f32 = flat.weights.iter().sum();
1745        assert!(
1746            (sum - 1.0).abs() < 1e-6,
1747            "with unit scales, k weights sum to one"
1748        );
1749
1750        let scaled = route_gemma4_moe(&logits, 2, &[2.0, 1.0, 0.5, 1.0]);
1751        assert_eq!(scaled.expert_ids, flat.expert_ids, "selection is unchanged");
1752        // Experts 0 and 2 were selected; their scales are 2.0 and 0.5.
1753        assert!((scaled.weights[0] - flat.weights[0] * 2.0).abs() < 1e-6);
1754        assert!((scaled.weights[1] - flat.weights[1] * 0.5).abs() < 1e-6);
1755        let sum: f32 = scaled.weights.iter().sum();
1756        assert!(
1757            (sum - 1.0).abs() > 1e-3,
1758            "the scaled weights must NOT be renormalized back to one, got {sum}"
1759        );
1760    }
1761
1762    /// Softmax over just the selected k, not a slice of the full
1763    /// distribution. The two pick the same experts and weight them
1764    /// differently, which is exactly the kind of difference that
1765    /// produces a fluent wrong model.
1766    #[test]
1767    fn the_softmax_runs_over_the_selected_experts_only() {
1768        let logits = vec![3.0, 1.0, 2.0, 0.0];
1769        let gemma = route_gemma4_moe(&logits, 2, &[1.0; 4]);
1770        let sliced = route_top_k_softmax(&logits, 2, false);
1771        assert_eq!(gemma.expert_ids, sliced.expert_ids);
1772        let gemma_sum: f32 = gemma.weights.iter().sum();
1773        let sliced_sum: f32 = sliced.weights.iter().sum();
1774        assert!((gemma_sum - 1.0).abs() < 1e-6);
1775        assert!(
1776            sliced_sum < 0.99,
1777            "a slice of the full softmax sums to less than one, got {sliced_sum}"
1778        );
1779    }
1780
1781    /// Selection is by raw logit, so the largest logits win regardless
1782    /// of the per-expert scales -- the scale rescales a weight, it does
1783    /// not buy an expert its way into the selection.
1784    #[test]
1785    fn selection_is_by_raw_logit_and_the_scale_cannot_change_it() {
1786        let logits = vec![3.0, 1.0, 2.0, 0.0];
1787        let huge = route_gemma4_moe(&logits, 2, &[1.0, 1000.0, 1.0, 1000.0]);
1788        assert_eq!(
1789            huge.expert_ids,
1790            vec![0, 2],
1791            "expert 1's scale must not select it"
1792        );
1793    }
1794
1795    /// The width factor is a function of the hidden size alone, so it
1796    /// leaves the selection identical and changes the softmax
1797    /// temperature -- which is what makes omitting it invisible.
1798    #[test]
1799    fn the_width_scaling_changes_the_weights_but_not_the_selection() {
1800        let hidden = vec![1.0, -2.0, 0.5, 3.0];
1801        let router_scale = vec![1.0; 4];
1802        let proj = WeightMatrix::F32(ferrox_core::tensor::Tensor::new(
1803            vec![
1804                1.0, 0.0, 0.0, 0.0, //
1805                0.0, 1.0, 0.0, 0.0, //
1806                0.0, 0.0, 1.0, 0.0, //
1807                0.0, 0.0, 0.0, 1.0,
1808            ],
1809            vec![4, 4],
1810        ));
1811        let with_width = gemma4_router_logits(&hidden, &router_scale, &proj, 1e-6);
1812
1813        // The same thing without the hidden^-0.5 factor: every logit is
1814        // larger by exactly sqrt(hidden).
1815        let n = hidden.len() as f32;
1816        let without: Vec<f32> = with_width.iter().map(|v| v * n.sqrt()).collect();
1817
1818        let a = route_gemma4_moe(&with_width, 2, &[1.0; 4]);
1819        let b = route_gemma4_moe(&without, 2, &[1.0; 4]);
1820        assert_eq!(a.expert_ids, b.expert_ids, "the selection is unaffected");
1821        assert!(
1822            a.weights
1823                .iter()
1824                .zip(b.weights.iter())
1825                .any(|(x, y)| (x - y).abs() > 1e-4),
1826            "but the mixing weights are not: {:?} vs {:?}",
1827            a.weights,
1828            b.weights
1829        );
1830    }
1831
1832    /// The router's norm is WEIGHTLESS. Feeding a non-unit scale vector
1833    /// through must change the logits, which is what proves the norm
1834    /// itself is not quietly applying one.
1835    #[test]
1836    fn the_router_norm_carries_no_learned_weight_of_its_own() {
1837        let hidden = vec![1.0, -2.0, 0.5, 3.0];
1838        let proj = WeightMatrix::F32(ferrox_core::tensor::Tensor::new(
1839            (0..16)
1840                .map(|i| if i % 5 == 0 { 1.0 } else { 0.0 })
1841                .collect(),
1842            vec![4, 4],
1843        ));
1844        let unit = gemma4_router_logits(&hidden, &[1.0; 4], &proj, 1e-6);
1845        let scaled = gemma4_router_logits(&hidden, &[2.0; 4], &proj, 1e-6);
1846        for (u, s) in unit.iter().zip(scaled.iter()) {
1847            assert!(
1848                (s - u * 2.0).abs() < 1e-5,
1849                "router_scale is the ONLY learned scale on this path: {u} -> {s}"
1850            );
1851        }
1852    }
1853
1854    /// Ties break toward the lower expert id, deterministically.
1855    #[test]
1856    fn ties_break_toward_the_lower_expert_id() {
1857        let logits = vec![1.0, 1.0, 1.0, 1.0];
1858        for _ in 0..8 {
1859            assert_eq!(
1860                route_gemma4_moe(&logits, 2, &[1.0; 4]).expert_ids,
1861                vec![0, 1]
1862            );
1863        }
1864    }
1865}