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}