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