Skip to main content

ferrum_models/moe/
dispatch.rs

1//! Expert dispatch — load per-layer expert weights from a GGUF file and run
2//! the per-token MoE forward (top-K experts per token, weighted combine).
3//!
4//! Phase 2 ships a CPU-only implementation (`moe_forward_cpu`). The
5//! algorithm is:
6//!
7//! ```text
8//! for each token b in batch:
9//!     route token b → (expert_ids[K], weights[K])
10//!     out[b] = 0
11//!     for each (expert_id, weight) pair:
12//!         gate_up = experts.gate_up[expert_id].forward(x[b])     # [2*ffn]
13//!         silu_mul = silu(gate_up[..ffn]) * gate_up[ffn..]       # [ffn]
14//!         contribution = experts.down[expert_id].forward(silu_mul) # [hidden]
15//!         out[b] += weight * contribution
16//! ```
17//!
18//! The fused `gate || up` per-expert layout means we can call
19//! `Backend::fused_silu_mul_split` directly on the projection's output
20//! — same kernel ferrum already uses for dense Llama-family models.
21
22use std::path::Path;
23use std::sync::{
24    atomic::{AtomicU64, Ordering},
25    OnceLock,
26};
27
28use candle_core::quantized::GgmlDType;
29use candle_core::{Device, Result as CandleResult};
30use ferrum_kernels::backend::cpu::CpuBackend;
31use ferrum_kernels::backend::{
32    Backend, BackendMoeFused, BackendPagedKv, BackendQuantGguf, BackendQuantMarlin, GgufQuantType,
33    LlmBackend, QuantLlmBackend,
34};
35use ferrum_kernels::{Linear, StackedExpertGgufLinear};
36use ferrum_quantization::gguf::GgufFile;
37use ferrum_quantization::{DenseLinear, QuantLinear};
38use ferrum_types::{FerrumError, Result};
39
40use crate::moe::router::RouterOutput;
41
42/// MoE per-op timers. Public so the model wrapper can drain + print at
43/// end of decode. Times are in microseconds, atomically accumulated.
44/// Toggle via env `FERRUM_MOE_PROFILE=1`.
45pub static MOE_SYNC_US: AtomicU64 = AtomicU64::new(0);
46pub static MOE_SYNC_CALLS: AtomicU64 = AtomicU64::new(0);
47pub static MOE_GEMV_GATE_UP_US: AtomicU64 = AtomicU64::new(0);
48pub static MOE_GEMV_GATE_UP_CALLS: AtomicU64 = AtomicU64::new(0);
49pub static MOE_SILU_US: AtomicU64 = AtomicU64::new(0);
50pub static MOE_SILU_CALLS: AtomicU64 = AtomicU64::new(0);
51pub static MOE_GEMV_DOWN_US: AtomicU64 = AtomicU64::new(0);
52pub static MOE_GEMV_DOWN_CALLS: AtomicU64 = AtomicU64::new(0);
53pub static MOE_SCALED_ADD_US: AtomicU64 = AtomicU64::new(0);
54pub static MOE_SCALED_ADD_CALLS: AtomicU64 = AtomicU64::new(0);
55pub static MOE_COPY_US: AtomicU64 = AtomicU64::new(0);
56pub static MOE_COPY_CALLS: AtomicU64 = AtomicU64::new(0);
57pub static MOE_HOST_TOPK_US: AtomicU64 = AtomicU64::new(0);
58pub static MOE_HOST_TOPK_CALLS: AtomicU64 = AtomicU64::new(0);
59
60// Bucketed-path per-phase timers. Drained by the model wrapper alongside
61// the per-pair counters above. Same `FERRUM_MOE_PROFILE=1` gate.
62pub static MOE_BUCKET_SYNC_US: AtomicU64 = AtomicU64::new(0);
63pub static MOE_BUCKET_D2H_US: AtomicU64 = AtomicU64::new(0);
64pub static MOE_BUCKET_ROUTE_US: AtomicU64 = AtomicU64::new(0);
65pub static MOE_BUCKET_PLAN_US: AtomicU64 = AtomicU64::new(0);
66pub static MOE_BUCKET_GATHER_US: AtomicU64 = AtomicU64::new(0);
67pub static MOE_BUCKET_GEMM1_US: AtomicU64 = AtomicU64::new(0);
68pub static MOE_BUCKET_SILU_US: AtomicU64 = AtomicU64::new(0);
69pub static MOE_BUCKET_GEMM3_US: AtomicU64 = AtomicU64::new(0);
70pub static MOE_BUCKET_COMBINE_US: AtomicU64 = AtomicU64::new(0);
71pub static MOE_BUCKET_LAYER_CALLS: AtomicU64 = AtomicU64::new(0);
72
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
74pub struct MoeBucketProfileSnapshot {
75    pub layers: u64,
76    pub sync_us: u64,
77    pub d2h_us: u64,
78    pub route_us: u64,
79    pub plan_us: u64,
80    pub gather_us: u64,
81    pub gemm1_us: u64,
82    pub silu_us: u64,
83    pub gemm3_us: u64,
84    pub combine_us: u64,
85}
86
87impl MoeBucketProfileSnapshot {
88    pub fn total_us(self) -> u64 {
89        self.sync_us
90            + self.d2h_us
91            + self.route_us
92            + self.plan_us
93            + self.gather_us
94            + self.gemm1_us
95            + self.silu_us
96            + self.gemm3_us
97            + self.combine_us
98    }
99
100    pub fn has_layers(self) -> bool {
101        self.layers > 0
102    }
103}
104
105pub fn drain_moe_bucket_profile() -> MoeBucketProfileSnapshot {
106    MoeBucketProfileSnapshot {
107        layers: MOE_BUCKET_LAYER_CALLS.swap(0, Ordering::Relaxed),
108        sync_us: MOE_BUCKET_SYNC_US.swap(0, Ordering::Relaxed),
109        d2h_us: MOE_BUCKET_D2H_US.swap(0, Ordering::Relaxed),
110        route_us: MOE_BUCKET_ROUTE_US.swap(0, Ordering::Relaxed),
111        plan_us: MOE_BUCKET_PLAN_US.swap(0, Ordering::Relaxed),
112        gather_us: MOE_BUCKET_GATHER_US.swap(0, Ordering::Relaxed),
113        gemm1_us: MOE_BUCKET_GEMM1_US.swap(0, Ordering::Relaxed),
114        silu_us: MOE_BUCKET_SILU_US.swap(0, Ordering::Relaxed),
115        gemm3_us: MOE_BUCKET_GEMM3_US.swap(0, Ordering::Relaxed),
116        combine_us: MOE_BUCKET_COMBINE_US.swap(0, Ordering::Relaxed),
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121struct MoeDispatchRuntimeConfig {
122    moe_profile: bool,
123    decode_op_profile: bool,
124    vllm_moe_zero_ws: bool,
125    vllm_moe_pair_ids: bool,
126    moe_load_trace: bool,
127    moe_block_size: Option<usize>,
128    moe_large_m_block_size: Option<usize>,
129    moe_large_m_min_pairs: usize,
130    vllm_moe: bool,
131    moe_host_route: bool,
132}
133
134impl Default for MoeDispatchRuntimeConfig {
135    fn default() -> Self {
136        Self {
137            moe_profile: false,
138            decode_op_profile: false,
139            vllm_moe_zero_ws: false,
140            vllm_moe_pair_ids: false,
141            moe_load_trace: false,
142            moe_block_size: None,
143            moe_large_m_block_size: None,
144            moe_large_m_min_pairs: 1024,
145            vllm_moe: false,
146            moe_host_route: false,
147        }
148    }
149}
150
151impl MoeDispatchRuntimeConfig {
152    fn from_env() -> Self {
153        Self::from_env_vars(std::env::vars())
154    }
155
156    fn from_env_vars<I, K, V>(vars: I) -> Self
157    where
158        I: IntoIterator<Item = (K, V)>,
159        K: AsRef<str>,
160        V: AsRef<str>,
161    {
162        let mut config = Self::default();
163        for (name, value) in vars {
164            let value = value.as_ref();
165            match name.as_ref() {
166                "FERRUM_MOE_PROFILE" => config.moe_profile = true,
167                "FERRUM_DECODE_OP_PROFILE" => config.decode_op_profile = true,
168                "FERRUM_VLLM_MOE_ZERO_WS" => config.vllm_moe_zero_ws = value == "1",
169                "FERRUM_VLLM_MOE_PAIR_IDS" => config.vllm_moe_pair_ids = value == "1",
170                "FERRUM_MOE_LOAD_TRACE" => config.moe_load_trace = true,
171                "FERRUM_MOE_BLOCK_SIZE" => {
172                    config.moe_block_size = parse_moe_block_size_value(value);
173                }
174                "FERRUM_MOE_LARGE_M_BLOCK_SIZE" => {
175                    config.moe_large_m_block_size = parse_moe_block_size_value(value);
176                }
177                "FERRUM_MOE_LARGE_M_MIN_PAIRS" => {
178                    config.moe_large_m_min_pairs = value.parse::<usize>().unwrap_or(1024);
179                }
180                "FERRUM_VLLM_MOE" => config.vllm_moe = value == "1",
181                "FERRUM_MOE_HOST_ROUTE" => config.moe_host_route = value == "1",
182                _ => {}
183            }
184        }
185        config
186    }
187}
188
189fn parse_moe_block_size_value(value: &str) -> Option<usize> {
190    value
191        .parse::<usize>()
192        .ok()
193        .filter(|bs| matches!(*bs, 8 | 16 | 32 | 48 | 64))
194}
195
196fn moe_dispatch_runtime_config() -> &'static MoeDispatchRuntimeConfig {
197    static CONFIG: OnceLock<MoeDispatchRuntimeConfig> = OnceLock::new();
198    CONFIG.get_or_init(MoeDispatchRuntimeConfig::from_env)
199}
200
201fn moe_profile_enabled() -> bool {
202    moe_dispatch_runtime_config().moe_profile
203}
204
205/// Per-layer expert weights, materialised as `[num_experts]`-long vectors
206/// of `Box<dyn Linear<B>>`. Each entry runs the corresponding expert's
207/// fused `[gate; up]` projection or its `down` projection.
208///
209/// `B::Buffer` is hidden behind `Linear<B>` so this struct is generic
210/// over backend. Production (`Qwen3MoeModel::forward`) dispatches through
211/// the generic [`moe_forward<B>`] (this file, line ~960) and
212/// [`moe_forward_bucketed<B>`]; the CPU-only `moe_forward_cpu` is the
213/// reference path used by parity tests + `Qwen3MoeLayer::forward_cpu`.
214pub struct ExpertStack<B: QuantLlmBackend + BackendMoeFused> {
215    /// Fused `[gate; up]` projection per expert. Output shape per token:
216    /// `[2 * expert_intermediate]` — the lower half is gate, upper is up.
217    pub gate_up: Vec<Box<dyn Linear<B>>>,
218    /// `down` projection per expert. Output shape per token: `[hidden_size]`.
219    pub down: Vec<Box<dyn Linear<B>>>,
220    /// Stacked-experts representation for backends that have a batched
221    /// MoE indirect-dispatch kernel (Metal `gemv_q4kw_moe_id_f32` /
222    /// `gemv_q6kw_moe_id_f32`). Holds **all experts** for one matmul
223    /// role behind a `StackedExpertGgufLinear<B>` (typically backed by a
224    /// single GPU buffer with byte stride between expert slabs), so a
225    /// single dispatch can cover all selected (token, expert) pairs at
226    /// decode m=1.
227    ///
228    /// `None` on backends without the kernel (CPU, CUDA-without-MoE-kernel)
229    /// and on quant flavours that don't have a stacked path yet — callers
230    /// fall back to the per-expert `gate_up` / `down` Linears in those
231    /// cases.
232    pub gate_stacked: Option<Box<dyn StackedExpertGgufLinear<B>>>,
233    pub up_stacked: Option<Box<dyn StackedExpertGgufLinear<B>>>,
234    pub down_stacked: Option<Box<dyn StackedExpertGgufLinear<B>>>,
235
236    /// Stacked Marlin GPTQ expert tiles for the bucketed CUDA path.
237    /// When both are Some, [`moe_forward_bucketed`] dispatches expert
238    /// GEMMs through trait-object methods (`store.gemm_phase_*` /
239    /// `store.zero_workspace`). None on CPU / Metal / GGUF.
240    ///
241    /// Phase C step 3: replaces `Option<Arc<B::GptqStore>>` with a
242    /// `Box<dyn MarlinExpertStack<B>>` trait object — kills the
243    /// `type GptqStore` leak through the model layer.
244    pub gate_up_marlin_stack: Option<std::sync::Arc<dyn ferrum_kernels::MarlinExpertStack<B>>>,
245    pub down_marlin_stack: Option<std::sync::Arc<dyn ferrum_kernels::MarlinExpertStack<B>>>,
246}
247
248impl<B: QuantLlmBackend + BackendMoeFused> ExpertStack<B> {
249    /// Returns the shared stacked Marlin expert tile for `gate_up` if
250    /// loaded via the bucketed/Marlin path. Used by
251    /// [`moe_forward_bucketed`].
252    pub fn gate_up_stacked_store(
253        &self,
254        _expert_idx: usize,
255    ) -> Option<&std::sync::Arc<dyn ferrum_kernels::MarlinExpertStack<B>>> {
256        self.gate_up_marlin_stack.as_ref()
257    }
258
259    /// Same for `down`.
260    pub fn down_stacked_store(
261        &self,
262        _expert_idx: usize,
263    ) -> Option<&std::sync::Arc<dyn ferrum_kernels::MarlinExpertStack<B>>> {
264        self.down_marlin_stack.as_ref()
265    }
266
267    // ── MoE GEMV dispatch (hides B::QuantStore + in_stride from callers) ──
268    //
269    // These wrap `B::gemv_quant_moe_id*` so the MoE forward path goes
270    // through the ExpertStack abstraction instead of reaching into
271    // `self.gate_stacked` / `self.up_stacked` / `self.down_stacked`
272    // directly. The weight + correct in_stride are picked from `self`,
273    // so callers only pass activations + routing + scratch out.
274
275    /// Gate projection: `out_stacked[k] = gate_weight[expert_id[k]] · input`,
276    /// broadcast input across all top_k slots.
277    pub fn gemv_gate(
278        &self,
279        ctx: &mut B::Context,
280        input: &B::Buffer,
281        ids: &B::Buffer,
282        out: &mut B::Buffer,
283        top_k: usize,
284    ) -> Result<()> {
285        let weight = self.gate_stacked.as_deref().ok_or_else(|| {
286            FerrumError::unsupported("ExpertStack::gemv_gate: gate_stacked not loaded")
287        })?;
288        weight.gemv_moe_id(ctx, input, ids, out, top_k, 0)
289    }
290
291    /// Up projection: same shape as gate, broadcast input.
292    pub fn gemv_up(
293        &self,
294        ctx: &mut B::Context,
295        input: &B::Buffer,
296        ids: &B::Buffer,
297        out: &mut B::Buffer,
298        top_k: usize,
299    ) -> Result<()> {
300        let weight = self.up_stacked.as_deref().ok_or_else(|| {
301            FerrumError::unsupported("ExpertStack::gemv_up: up_stacked not loaded")
302        })?;
303        weight.gemv_moe_id(ctx, input, ids, out, top_k, 0)
304    }
305
306    /// Down projection: per-slot input via `in_stride = expert_intermediate`.
307    /// Caller's `input` is the SiLU-mul stacked output (`top_k × inter` floats).
308    pub fn gemv_down(
309        &self,
310        ctx: &mut B::Context,
311        input: &B::Buffer,
312        ids: &B::Buffer,
313        out: &mut B::Buffer,
314        top_k: usize,
315        expert_intermediate: usize,
316    ) -> Result<()> {
317        let weight = self.down_stacked.as_deref().ok_or_else(|| {
318            FerrumError::unsupported("ExpertStack::gemv_down: down_stacked not loaded")
319        })?;
320        weight.gemv_moe_id(ctx, input, ids, out, top_k, expert_intermediate)
321    }
322
323    /// Fused gate + up + SiLU·gate: replaces 3 separate dispatches with 1.
324    /// Backend must support the fused path
325    /// (`B::supports_fused_moe_gate_up_silu()`); caller checks first.
326    pub fn gemv_gate_up_silu_fused(
327        &self,
328        ctx: &mut B::Context,
329        input: &B::Buffer,
330        ids: &B::Buffer,
331        out_silu_stacked: &mut B::Buffer,
332        top_k: usize,
333    ) -> Result<()> {
334        let gate = self.gate_stacked.as_deref().ok_or_else(|| {
335            FerrumError::unsupported(
336                "ExpertStack::gemv_gate_up_silu_fused: gate_stacked not loaded",
337            )
338        })?;
339        let up = self.up_stacked.as_deref().ok_or_else(|| {
340            FerrumError::unsupported("ExpertStack::gemv_gate_up_silu_fused: up_stacked not loaded")
341        })?;
342        gate.gemv_moe_id_gate_up_silu(ctx, input, up, ids, out_silu_stacked, top_k)
343    }
344
345    // ── Prefill GEMM dispatch (Phase 3d) ──
346    //
347    // Same role as the gemv wrappers above, but for the m>1 path that
348    // emits batched mul_mm_id instead of per-pair gemv. `args_buf`
349    // toggles between direct (`gemm_quant_moe_id`) and indirect-grid
350    // (`gemm_quant_moe_id_indirect`) dispatch — the indirect form lets
351    // `compute_ids_tpe_gpu` produce a tighter grid sized to `max(tpe[e])`.
352    //
353    // ne11 is fixed by role: gate/up = 1 (broadcast across slots),
354    // down = top_k (per-slot src1 read). Callers no longer pass it.
355
356    /// Gate prefill GEMM. `dst` shape: `[batch, top_k, expert_inter]`.
357    /// `args_buf=Some` triggers indirect-grid dispatch.
358    #[allow(clippy::too_many_arguments)]
359    pub fn gemm_gate(
360        &self,
361        ctx: &mut B::Context,
362        src1: &B::Buffer,
363        ids: &B::Buffer,
364        tpe: &B::Buffer,
365        dst: &mut B::Buffer,
366        args_buf: Option<&B::Buffer>,
367        top_k: usize,
368        max_per_expert: usize,
369        tokens: usize,
370    ) -> Result<()> {
371        let weight = self.gate_stacked.as_deref().ok_or_else(|| {
372            FerrumError::unsupported("ExpertStack::gemm_gate: gate_stacked not loaded")
373        })?;
374        match args_buf {
375            Some(args) => weight.gemm_moe_id_indirect(
376                ctx,
377                src1,
378                ids,
379                tpe,
380                dst,
381                args,
382                1,
383                top_k,
384                max_per_expert,
385                tokens,
386            ),
387            None => weight.gemm_moe_id(ctx, src1, ids, tpe, dst, 1, top_k, max_per_expert, tokens),
388        }
389    }
390
391    /// Up prefill GEMM. Same shape contract as [`Self::gemm_gate`].
392    #[allow(clippy::too_many_arguments)]
393    pub fn gemm_up(
394        &self,
395        ctx: &mut B::Context,
396        src1: &B::Buffer,
397        ids: &B::Buffer,
398        tpe: &B::Buffer,
399        dst: &mut B::Buffer,
400        args_buf: Option<&B::Buffer>,
401        top_k: usize,
402        max_per_expert: usize,
403        tokens: usize,
404    ) -> Result<()> {
405        let weight = self.up_stacked.as_deref().ok_or_else(|| {
406            FerrumError::unsupported("ExpertStack::gemm_up: up_stacked not loaded")
407        })?;
408        match args_buf {
409            Some(args) => weight.gemm_moe_id_indirect(
410                ctx,
411                src1,
412                ids,
413                tpe,
414                dst,
415                args,
416                1,
417                top_k,
418                max_per_expert,
419                tokens,
420            ),
421            None => weight.gemm_moe_id(ctx, src1, ids, tpe, dst, 1, top_k, max_per_expert, tokens),
422        }
423    }
424
425    /// Down prefill GEMM. `dst` shape: `[batch, top_k, hidden]`.
426    /// ne11=top_k (per-slot src1 read from `silu_stacked[batch, top_k, inter]`).
427    #[allow(clippy::too_many_arguments)]
428    pub fn gemm_down(
429        &self,
430        ctx: &mut B::Context,
431        src1: &B::Buffer,
432        ids: &B::Buffer,
433        tpe: &B::Buffer,
434        dst: &mut B::Buffer,
435        args_buf: Option<&B::Buffer>,
436        top_k: usize,
437        max_per_expert: usize,
438        tokens: usize,
439    ) -> Result<()> {
440        let weight = self.down_stacked.as_deref().ok_or_else(|| {
441            FerrumError::unsupported("ExpertStack::gemm_down: down_stacked not loaded")
442        })?;
443        match args_buf {
444            Some(args) => weight.gemm_moe_id_indirect(
445                ctx,
446                src1,
447                ids,
448                tpe,
449                dst,
450                args,
451                top_k,
452                top_k,
453                max_per_expert,
454                tokens,
455            ),
456            None => weight.gemm_moe_id(
457                ctx,
458                src1,
459                ids,
460                tpe,
461                dst,
462                top_k,
463                top_k,
464                max_per_expert,
465                tokens,
466            ),
467        }
468    }
469
470    // ── Batched-decode GEMV dispatch (Phase 3d) ──
471    //
472    // For the small-m batched-decode range (c=2..32). Single Metal
473    // launch covers all m*top_k (token, expert) pairs.
474
475    /// Gate batched gemv: `dst[m * top_k]` with broadcast input
476    /// (slots within a token share the activation row).
477    #[allow(clippy::too_many_arguments)]
478    pub fn gemv_gate_batched(
479        &self,
480        ctx: &mut B::Context,
481        input: &B::Buffer,
482        ids: &B::Buffer,
483        dst: &mut B::Buffer,
484        m: usize,
485        top_k: usize,
486        src1_outer_stride: usize,
487        src1_inner_stride: usize,
488    ) -> Result<()> {
489        let weight = self.gate_stacked.as_deref().ok_or_else(|| {
490            FerrumError::unsupported("ExpertStack::gemv_gate_batched: gate_stacked not loaded")
491        })?;
492        weight.gemv_moe_id_batched(
493            ctx,
494            input,
495            ids,
496            dst,
497            m,
498            top_k,
499            src1_outer_stride,
500            src1_inner_stride,
501        )
502    }
503
504    /// Up batched gemv: same shape as [`Self::gemv_gate_batched`].
505    #[allow(clippy::too_many_arguments)]
506    pub fn gemv_up_batched(
507        &self,
508        ctx: &mut B::Context,
509        input: &B::Buffer,
510        ids: &B::Buffer,
511        dst: &mut B::Buffer,
512        m: usize,
513        top_k: usize,
514        src1_outer_stride: usize,
515        src1_inner_stride: usize,
516    ) -> Result<()> {
517        let weight = self.up_stacked.as_deref().ok_or_else(|| {
518            FerrumError::unsupported("ExpertStack::gemv_up_batched: up_stacked not loaded")
519        })?;
520        weight.gemv_moe_id_batched(
521            ctx,
522            input,
523            ids,
524            dst,
525            m,
526            top_k,
527            src1_outer_stride,
528            src1_inner_stride,
529        )
530    }
531
532    /// Down batched gemv: src1 = `silu_stacked[m, top_k, inter]` per-slot read.
533    #[allow(clippy::too_many_arguments)]
534    pub fn gemv_down_batched(
535        &self,
536        ctx: &mut B::Context,
537        input: &B::Buffer,
538        ids: &B::Buffer,
539        dst: &mut B::Buffer,
540        m: usize,
541        top_k: usize,
542        src1_outer_stride: usize,
543        src1_inner_stride: usize,
544    ) -> Result<()> {
545        let weight = self.down_stacked.as_deref().ok_or_else(|| {
546            FerrumError::unsupported("ExpertStack::gemv_down_batched: down_stacked not loaded")
547        })?;
548        weight.gemv_moe_id_batched(
549            ctx,
550            input,
551            ids,
552            dst,
553            m,
554            top_k,
555            src1_outer_stride,
556            src1_inner_stride,
557        )
558    }
559
560    /// Fused batched gate + up + SiLU·gate. Single dispatch over `m * top_k`
561    /// pairs. Caller gates on `B::supports_batched_moe_gate_up_silu()` first.
562    #[allow(clippy::too_many_arguments)]
563    pub fn gemv_gate_up_silu_batched_fused(
564        &self,
565        ctx: &mut B::Context,
566        input: &B::Buffer,
567        ids: &B::Buffer,
568        silu_out: &mut B::Buffer,
569        m: usize,
570        top_k: usize,
571        src1_outer_stride: usize,
572        src1_inner_stride: usize,
573    ) -> Result<()> {
574        let gate = self.gate_stacked.as_deref().ok_or_else(|| {
575            FerrumError::unsupported(
576                "ExpertStack::gemv_gate_up_silu_batched_fused: gate_stacked not loaded",
577            )
578        })?;
579        let up = self.up_stacked.as_deref().ok_or_else(|| {
580            FerrumError::unsupported(
581                "ExpertStack::gemv_gate_up_silu_batched_fused: up_stacked not loaded",
582            )
583        })?;
584        gate.gemv_moe_id_gate_up_silu_batched(
585            ctx,
586            input,
587            up,
588            ids,
589            silu_out,
590            m,
591            top_k,
592            src1_outer_stride,
593            src1_inner_stride,
594        )
595    }
596
597    // ── Per-item offset GEMV (Phase 3d, qwen3_moe.rs decode path) ──
598    //
599    // Used by the per-item batched-decode loop in `Qwen3MoeModel::forward`
600    // when offset variants are supported. Reads `src1` at `src1_offset`
601    // floats and `ids` at `ids_offset` ids, writes `dst` from offset 0.
602
603    /// Gate offset gemv. `src1_stride=0` → broadcast.
604    #[allow(clippy::too_many_arguments)]
605    pub fn gemv_gate_offset(
606        &self,
607        ctx: &mut B::Context,
608        src1: &B::Buffer,
609        src1_offset: usize,
610        ids: &B::Buffer,
611        ids_offset: usize,
612        dst: &mut B::Buffer,
613        top_k: usize,
614        src1_stride: usize,
615    ) -> Result<()> {
616        let weight = self.gate_stacked.as_deref().ok_or_else(|| {
617            FerrumError::unsupported("ExpertStack::gemv_gate_offset: gate_stacked not loaded")
618        })?;
619        weight.gemv_moe_id_offset(
620            ctx,
621            src1,
622            src1_offset,
623            ids,
624            ids_offset,
625            dst,
626            top_k,
627            src1_stride,
628        )
629    }
630
631    /// Up offset gemv.
632    #[allow(clippy::too_many_arguments)]
633    pub fn gemv_up_offset(
634        &self,
635        ctx: &mut B::Context,
636        src1: &B::Buffer,
637        src1_offset: usize,
638        ids: &B::Buffer,
639        ids_offset: usize,
640        dst: &mut B::Buffer,
641        top_k: usize,
642        src1_stride: usize,
643    ) -> Result<()> {
644        let weight = self.up_stacked.as_deref().ok_or_else(|| {
645            FerrumError::unsupported("ExpertStack::gemv_up_offset: up_stacked not loaded")
646        })?;
647        weight.gemv_moe_id_offset(
648            ctx,
649            src1,
650            src1_offset,
651            ids,
652            ids_offset,
653            dst,
654            top_k,
655            src1_stride,
656        )
657    }
658
659    /// Down offset gemv.
660    #[allow(clippy::too_many_arguments)]
661    pub fn gemv_down_offset(
662        &self,
663        ctx: &mut B::Context,
664        src1: &B::Buffer,
665        src1_offset: usize,
666        ids: &B::Buffer,
667        ids_offset: usize,
668        dst: &mut B::Buffer,
669        top_k: usize,
670        src1_stride: usize,
671    ) -> Result<()> {
672        let weight = self.down_stacked.as_deref().ok_or_else(|| {
673            FerrumError::unsupported("ExpertStack::gemv_down_offset: down_stacked not loaded")
674        })?;
675        weight.gemv_moe_id_offset(
676            ctx,
677            src1,
678            src1_offset,
679            ids,
680            ids_offset,
681            dst,
682            top_k,
683            src1_stride,
684        )
685    }
686}
687
688impl<B: QuantLlmBackend + BackendMoeFused> ExpertStack<B> {
689    /// Build from raw fp32 stacked tensors (test helper). Caller has
690    /// already dequantised and laid out the data:
691    ///   `gate_stack`: `[num_experts * expert_inter * hidden]`
692    ///   `up_stack`:   `[num_experts * expert_inter * hidden]`
693    ///   `down_stack`: `[num_experts * hidden * expert_inter]`
694    /// Each per-expert slice is row-major in the natural Linear shape.
695    pub fn from_dense_stacks(
696        gate_stack: &[f32],
697        up_stack: &[f32],
698        down_stack: &[f32],
699        num_experts: usize,
700        hidden_size: usize,
701        expert_intermediate: usize,
702    ) -> Result<Self> {
703        let gate_up_per_expert = expert_intermediate * hidden_size;
704        let down_per_expert = hidden_size * expert_intermediate;
705
706        check_size(
707            gate_stack.len(),
708            num_experts * gate_up_per_expert,
709            "gate_stack",
710        )?;
711        check_size(up_stack.len(), num_experts * gate_up_per_expert, "up_stack")?;
712        check_size(
713            down_stack.len(),
714            num_experts * down_per_expert,
715            "down_stack",
716        )?;
717
718        let mut gate_up = Vec::with_capacity(num_experts);
719        let mut down = Vec::with_capacity(num_experts);
720        for e in 0..num_experts {
721            let g_off = e * gate_up_per_expert;
722            let g_slice = &gate_stack[g_off..g_off + gate_up_per_expert];
723            let u_slice = &up_stack[g_off..g_off + gate_up_per_expert];
724
725            // Fused [gate; up] is [2 * expert_inter, hidden] row-major.
726            // We concatenate row-blocks so the first expert_inter rows are
727            // gate, the next expert_inter rows are up — the layout
728            // fused_silu_mul_split expects.
729            let mut fused = Vec::with_capacity(2 * gate_up_per_expert);
730            fused.extend_from_slice(g_slice);
731            fused.extend_from_slice(u_slice);
732            gate_up.push(Box::new(DenseLinear::<B>::from_rows(
733                &fused,
734                2 * expert_intermediate,
735                hidden_size,
736            )) as Box<dyn Linear<B>>);
737
738            let d_off = e * down_per_expert;
739            let d_slice = &down_stack[d_off..d_off + down_per_expert];
740            down.push(Box::new(DenseLinear::<B>::from_rows(
741                d_slice,
742                hidden_size,
743                expert_intermediate,
744            )) as Box<dyn Linear<B>>);
745        }
746        Ok(Self {
747            gate_up,
748            down,
749            gate_stacked: None,
750            up_stacked: None,
751            down_stacked: None,
752            gate_up_marlin_stack: None,
753            down_marlin_stack: None,
754        })
755    }
756
757    /// Load all experts for one MoE layer from a GGUF file. Names follow
758    /// the GGUF convention: `blk.{layer_idx}.ffn_{gate,up,down}_exps.weight`.
759    ///
760    /// The loader picks between two strategies based on the on-disk dtype
761    /// of the expert tensors:
762    ///
763    ///   - **Quantised path** (Q4_K / Q6_K only): each expert's
764    ///     `gate || up` becomes a single `QuantLinear<B>` (Fused
765    ///     QuantStore — gate + up share `n_cols = hidden`), and `down` is
766    ///     a plain `QuantLinear<B>`. Block bytes stay compressed in
767    ///     backend memory; per-call dequant happens inside `gemm_quant`.
768    ///   - **Dense fallback** (everything else, e.g. F32 / F16 / Q5_K
769    ///     until a kernel ships): eager-dequant to fp32 and wrap
770    ///     `DenseLinear<B>`. Memory inflates ~7× vs Q4_K_M but the
771    ///     algorithm is correctness-equivalent and this is the path the
772    ///     synthetic-MoE test fixtures need.
773    ///
774    /// The runtime dispatcher (`moe_forward<B>`) doesn't see which path
775    /// was taken — it just calls `Linear::forward` per (token, expert).
776    pub fn load_from_gguf(
777        gguf: &GgufFile,
778        layer_idx: usize,
779        num_experts: usize,
780        hidden_size: usize,
781        expert_intermediate: usize,
782    ) -> Result<Self> {
783        let runtime_config = moe_dispatch_runtime_config();
784        if let Some(quant) = Self::try_load_quantised(
785            gguf,
786            layer_idx,
787            num_experts,
788            hidden_size,
789            expert_intermediate,
790        )? {
791            if runtime_config.moe_load_trace {
792                eprintln!("[moe-load] layer {layer_idx} → quantised expert path");
793            }
794            return Ok(quant);
795        }
796
797        if runtime_config.moe_load_trace {
798            eprintln!("[moe-load] layer {layer_idx} → eager fp32 dense fallback ⚠");
799        }
800
801        let device = Device::Cpu;
802        let gate = read_dequant_flat(
803            gguf,
804            &format!("blk.{layer_idx}.ffn_gate_exps.weight"),
805            &device,
806        )?;
807        let up = read_dequant_flat(
808            gguf,
809            &format!("blk.{layer_idx}.ffn_up_exps.weight"),
810            &device,
811        )?;
812        let down = read_dequant_flat(
813            gguf,
814            &format!("blk.{layer_idx}.ffn_down_exps.weight"),
815            &device,
816        )?;
817        // Eager-dense path leaves stacked variants as None — no MoE
818        // fast path for synthesised / non-quantised expert tensors.
819        Self::from_dense_stacks(
820            &gate,
821            &up,
822            &down,
823            num_experts,
824            hidden_size,
825            expert_intermediate,
826        )
827    }
828
829    /// Attempt the quantised path. Returns `Ok(None)` if any of the three
830    /// tensors isn't a supported k-quant flavour (Q4_K / Q6_K) or if the
831    /// shape doesn't match the expected per-expert tile size — caller
832    /// then takes the eager-dequant fallback. Returns `Err` only on a
833    /// genuine load failure (missing tensor, byte-count mismatch).
834    fn try_load_quantised(
835        gguf: &GgufFile,
836        layer_idx: usize,
837        num_experts: usize,
838        hidden_size: usize,
839        expert_intermediate: usize,
840    ) -> Result<Option<Self>> {
841        let device = Device::Cpu;
842
843        let gate_name = format!("blk.{layer_idx}.ffn_gate_exps.weight");
844        let up_name = format!("blk.{layer_idx}.ffn_up_exps.weight");
845        let down_name = format!("blk.{layer_idx}.ffn_down_exps.weight");
846
847        // Inspect tensor info up front — if any tensor isn't a k-quant
848        // flavour the backend can dispatch on, bail to the dense path
849        // before paying the byte-read cost.
850        let gate_kind = match quant_kind(gguf, &gate_name)? {
851            Some(k) => k,
852            None => return Ok(None),
853        };
854        let up_kind = match quant_kind(gguf, &up_name)? {
855            Some(k) => k,
856            None => return Ok(None),
857        };
858        let down_kind = match quant_kind(gguf, &down_name)? {
859            Some(k) => k,
860            None => return Ok(None),
861        };
862
863        // Slice the three 3-D quantised expert stacks directly from
864        // the mmap. These are the dominant memory cost on Qwen3-MoE
865        // (~14 GB for Qwen3-30B-A3B); going through candle's
866        // `read_tensor` would copy them into a heap `Vec<u8>` first,
867        // then `load_quant_experts` would copy again into the Metal
868        // buffer — together doubling the working set and pushing a
869        // 32 GB Mac into swap. With this slice + the Metal mmap
870        // registry, we avoid both copies (steady state: just the
871        // file mmap).
872        let gate_bytes = gguf.tensor_byte_slice(&gate_name).ok_or_else(|| {
873            FerrumError::model(format!("MoE: tensor_byte_slice failed for '{gate_name}'"))
874        })?;
875        let up_bytes = gguf.tensor_byte_slice(&up_name).ok_or_else(|| {
876            FerrumError::model(format!("MoE: tensor_byte_slice failed for '{up_name}'"))
877        })?;
878        let down_bytes = gguf.tensor_byte_slice(&down_name).ok_or_else(|| {
879            FerrumError::model(format!("MoE: tensor_byte_slice failed for '{down_name}'"))
880        })?;
881        let _ = device; // candle device no longer needed for the byte read
882
883        // Per-expert byte stride for each tensor. The 3-D layout is
884        // contiguous, [num_experts, rows, cols] row-major, so each
885        // expert's slab is exactly `total_bytes / num_experts`.
886        let gate_per = block_bytes_for(
887            gate_kind,
888            expert_intermediate * hidden_size,
889            "ffn_gate_exps",
890        )?;
891        let up_per = block_bytes_for(up_kind, expert_intermediate * hidden_size, "ffn_up_exps")?;
892        let down_per = block_bytes_for(
893            down_kind,
894            hidden_size * expert_intermediate,
895            "ffn_down_exps",
896        )?;
897
898        check_size(
899            gate_bytes.len(),
900            num_experts * gate_per,
901            "ffn_gate_exps bytes",
902        )?;
903        check_size(up_bytes.len(), num_experts * up_per, "ffn_up_exps bytes")?;
904        check_size(
905            down_bytes.len(),
906            num_experts * down_per,
907            "ffn_down_exps bytes",
908        )?;
909
910        // Try the stacked-experts fast path FIRST. If the backend has a
911        // batched MoE kernel (Metal `gemv_q*kw_moe_id_f32`), we want to
912        // hold the experts only as one big stacked buffer per role —
913        // not as 128 per-expert MetalQuantStores PLUS the stacked one
914        // (that would double-allocate ~17 GB on a 32 GB Mac, which on
915        // Qwen3-30B-A3B Q4_K_M sends the model into swap and tanks
916        // both load and forward time).
917        let gate_stacked = B::load_quant_experts(
918            gate_kind,
919            gate_bytes,
920            num_experts,
921            expert_intermediate,
922            hidden_size,
923        )
924        .ok();
925        let up_stacked = B::load_quant_experts(
926            up_kind,
927            up_bytes,
928            num_experts,
929            expert_intermediate,
930            hidden_size,
931        )
932        .ok();
933        let down_stacked = B::load_quant_experts(
934            down_kind,
935            down_bytes,
936            num_experts,
937            hidden_size,
938            expert_intermediate,
939        )
940        .ok();
941
942        // Decide the storage shape:
943        //   * Stacked-only (Metal MoE fast path): all three stacked
944        //     loaders succeeded — skip per-expert and use stacked
945        //     for both decode and prefill. Cuts memory in half.
946        //   * Per-expert: stacked path is incomplete or unsupported —
947        //     load 128-per-layer QuantLinears and let `moe_forward`
948        //     drive the per-(token, expert) loop on top of them.
949        let stacked_complete =
950            gate_stacked.is_some() && up_stacked.is_some() && down_stacked.is_some();
951
952        let (gate_up, down) = if stacked_complete {
953            // No per-expert needed — `moe_forward_stacked_decode_impl`
954            // and the per-token prefill loop both use the stacked buffers.
955            (Vec::new(), Vec::new())
956        } else {
957            let mut gate_up: Vec<Box<dyn Linear<B>>> = Vec::with_capacity(num_experts);
958            let mut down: Vec<Box<dyn Linear<B>>> = Vec::with_capacity(num_experts);
959            for e in 0..num_experts {
960                let g_slice = &gate_bytes[e * gate_per..(e + 1) * gate_per];
961                let u_slice = &up_bytes[e * up_per..(e + 1) * up_per];
962                let d_slice = &down_bytes[e * down_per..(e + 1) * down_per];
963
964                let parts: [(GgufQuantType, &[u8], usize); 2] = [
965                    (gate_kind, g_slice, expert_intermediate),
966                    (up_kind, u_slice, expert_intermediate),
967                ];
968                let gate_up_e = match QuantLinear::<B>::from_gguf_fused(&parts, hidden_size) {
969                    Ok(q) => q,
970                    Err(_) => return Ok(None),
971                };
972                gate_up.push(Box::new(gate_up_e) as Box<dyn Linear<B>>);
973
974                let down_e = match QuantLinear::<B>::from_gguf_bytes(
975                    down_kind,
976                    d_slice,
977                    hidden_size,
978                    expert_intermediate,
979                ) {
980                    Ok(q) => q,
981                    Err(_) => return Ok(None),
982                };
983                down.push(Box::new(down_e) as Box<dyn Linear<B>>);
984            }
985            (gate_up, down)
986        };
987
988        Ok(Some(Self {
989            gate_up,
990            down,
991            gate_stacked,
992            up_stacked,
993            down_stacked,
994            gate_up_marlin_stack: None,
995            down_marlin_stack: None,
996        }))
997    }
998
999    /// Convenience: open a GGUF and load layer `layer_idx`. The GGUF
1000    /// stays open inside this call only — for multi-layer loads use
1001    /// [`Self::load_from_gguf`] with a shared [`GgufFile`].
1002    pub fn open_and_load(
1003        path: impl AsRef<Path>,
1004        layer_idx: usize,
1005        num_experts: usize,
1006        hidden_size: usize,
1007        expert_intermediate: usize,
1008    ) -> Result<Self> {
1009        let gguf = GgufFile::open(path).map_err(candle_to_ferrum)?;
1010        Self::load_from_gguf(
1011            &gguf,
1012            layer_idx,
1013            num_experts,
1014            hidden_size,
1015            expert_intermediate,
1016        )
1017    }
1018
1019    /// `num_experts` for the layer (consistency check helper).
1020    ///
1021    /// Returns the per-expert Vec length, OR — when the stacked-only
1022    /// path is in effect (Metal MoE fast path with empty per-expert
1023    /// Vecs) — falls back to a stored count via the stacked variants.
1024    /// In the stacked-only case there's no Vec to count, so this method
1025    /// is mostly used by tests on the per-expert path.
1026    pub fn num_experts(&self) -> usize {
1027        debug_assert_eq!(
1028            self.gate_up.len(),
1029            self.down.len(),
1030            "ExpertStack: gate_up and down disagree on expert count"
1031        );
1032        if !self.gate_up.is_empty() || !self.down.is_empty() {
1033            return self.gate_up.len();
1034        }
1035        if let Some(gate) = self.gate_stacked.as_deref() {
1036            let num_experts = gate.num_experts();
1037            debug_assert_eq!(
1038                self.up_stacked.as_deref().map(|up| up.num_experts()),
1039                Some(num_experts),
1040                "ExpertStack: gate/up stacked expert counts disagree"
1041            );
1042            debug_assert_eq!(
1043                self.down_stacked.as_deref().map(|down| down.num_experts()),
1044                Some(num_experts),
1045                "ExpertStack: gate/down stacked expert counts disagree"
1046            );
1047            return num_experts;
1048        }
1049        if let Some(gate_up) = self.gate_up_marlin_stack.as_deref() {
1050            let num_experts = gate_up.num_experts();
1051            debug_assert_eq!(
1052                self.down_marlin_stack
1053                    .as_deref()
1054                    .map(|down| down.num_experts()),
1055                Some(num_experts),
1056                "ExpertStack: gate_up/down Marlin expert counts disagree"
1057            );
1058            return num_experts;
1059        }
1060        0
1061    }
1062}
1063
1064/// Backend-generic MoE forward.
1065///
1066/// Equivalent of [`moe_forward_cpu`] but parameterised on `B: Backend`
1067/// so Metal / CUDA paths can dispatch the same per-(token, expert) loop
1068/// using their own kernels for the gemv + silu + scaled-add primitives.
1069///
1070/// The caller pre-supplies all scratch buffers — this function does no
1071/// allocation, which matters because it's invoked from inside the
1072/// transformer's `forward_layer` where allocation during graph capture
1073/// (CUDA) would corrupt the captured graph.
1074///
1075/// Buffer contract (lengths, sized at scratch alloc time):
1076///   - `x`            : `[batch * hidden]` post-RMSNorm activations
1077///   - `router_logits`: `[batch * num_experts]` raw router output
1078///   - `out`          : `[batch * hidden]` — caller is responsible for
1079///                      zeroing this before the call (we accumulate,
1080///                      not assign)
1081///   - `x_single`     : `[hidden]` per-token input slice
1082///   - `acc_buf`      : `[hidden]` per-token output accumulator (kept
1083///                      separate from `x_single` so the gate_up gemv
1084///                      can consume `x_single` repeatedly across the
1085///                      top_k loop without an inter-pair restore)
1086///   - `gate_up_buf`  : `[2 * expert_inter]` per-(token, expert) gemv out
1087///   - `silu_buf`     : `[expert_inter]`
1088///   - `down_buf`     : `[hidden]` per-(token, expert) accumulate src
1089///
1090/// Routing (softmax + top-K + optional renorm) runs host-side using
1091/// `B::to_vec(router_logits, …)` — the routing computation is small
1092/// (`batch * num_experts` floats) and the top-K is a sort, both of
1093/// which dwarf in cost any plausible host↔device transfer.
1094///
1095/// Per-pair dispatch budget (m=1, Metal):
1096///   gate_up Fused gemv (2 parts) + silu + down gemv + scaled_add
1097///   = 5 dispatches/pair. Plus 2 copy_slice/token (load x_single,
1098///   write acc_buf back to out[b]). With top_k=8 and 48 layers, that's
1099///   8×5 + 2 = 42 dispatches/layer × 48 ≈ 2k/token (vs. ~3.5k in the
1100///   pre-PR scheme that round-tripped through `out` per pair).
1101pub struct MoeForwardParams<'a, B: QuantLlmBackend + BackendMoeFused> {
1102    pub ctx: &'a mut B::Context,
1103    pub x: &'a B::Buffer,
1104    pub router_logits: &'a B::Buffer,
1105    pub out: &'a mut B::Buffer,
1106    pub batch: usize,
1107    pub hidden_size: usize,
1108    pub expert_intermediate: usize,
1109    pub num_experts: usize,
1110    pub top_k: usize,
1111    pub norm_topk_prob: bool,
1112    pub experts: &'a ExpertStack<B>,
1113    pub x_single: &'a mut B::Buffer,
1114    pub acc_buf: &'a mut B::Buffer,
1115    pub gate_up_buf: &'a mut B::Buffer,
1116    pub silu_buf: &'a mut B::Buffer,
1117    pub down_buf: &'a mut B::Buffer,
1118    pub zero_hidden: &'a B::Buffer,
1119}
1120
1121pub fn moe_forward<B: QuantLlmBackend + BackendMoeFused>(
1122    params: MoeForwardParams<'_, B>,
1123) -> Result<()> {
1124    let MoeForwardParams {
1125        ctx,
1126        x,
1127        router_logits,
1128        out,
1129        batch,
1130        hidden_size,
1131        expert_intermediate,
1132        num_experts,
1133        top_k,
1134        norm_topk_prob,
1135        experts,
1136        x_single,
1137        acc_buf,
1138        gate_up_buf,
1139        silu_buf,
1140        down_buf,
1141        zero_hidden,
1142    } = params;
1143    let n_experts = experts.num_experts();
1144    if n_experts != num_experts {
1145        return Err(FerrumError::model(format!(
1146            "moe_forward: experts.num_experts() = {n_experts} != cfg.num_experts = {num_experts}"
1147        )));
1148    }
1149
1150    let prof = moe_profile_enabled();
1151
1152    // Routing on host. Sized batch*num_experts (e.g. 512*128 = 64k floats
1153    // per layer for Qwen3-30B-A3B prefill); cheap relative to the per-
1154    // expert gemvs that follow.
1155    let t0 = if prof {
1156        Some(std::time::Instant::now())
1157    } else {
1158        None
1159    };
1160    B::sync(ctx);
1161    if let Some(t) = t0 {
1162        MOE_SYNC_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1163        MOE_SYNC_CALLS.fetch_add(1, Ordering::Relaxed);
1164    }
1165
1166    let t0 = if prof {
1167        Some(std::time::Instant::now())
1168    } else {
1169        None
1170    };
1171    let logits_host = B::to_vec(router_logits, batch * num_experts);
1172    let route_out =
1173        crate::moe::router::route(&logits_host, batch, num_experts, top_k, norm_topk_prob);
1174    if let Some(t) = t0 {
1175        MOE_HOST_TOPK_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1176        MOE_HOST_TOPK_CALLS.fetch_add(1, Ordering::Relaxed);
1177    }
1178
1179    for b in 0..batch {
1180        // Load x[b] into x_single + reset accumulator.
1181        let t0 = if prof {
1182            Some(std::time::Instant::now())
1183        } else {
1184            None
1185        };
1186        B::copy_slice(ctx, x, b * hidden_size, x_single, 0, hidden_size);
1187        B::copy_slice(ctx, zero_hidden, 0, acc_buf, 0, hidden_size);
1188        if let Some(t) = t0 {
1189            MOE_COPY_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1190            MOE_COPY_CALLS.fetch_add(2, Ordering::Relaxed);
1191        }
1192
1193        for k in 0..top_k {
1194            let pair = b * top_k + k;
1195            let expert_id = route_out.expert_ids[pair] as usize;
1196            let weight = route_out.expert_weights[pair];
1197            if expert_id >= num_experts {
1198                return Err(FerrumError::model(format!(
1199                    "moe_forward: routed expert {expert_id} >= num_experts {num_experts}"
1200                )));
1201            }
1202
1203            // Fused gate||up gemv → [2 * expert_inter]
1204            let t0 = if prof {
1205                B::sync(ctx);
1206                Some(std::time::Instant::now())
1207            } else {
1208                None
1209            };
1210            experts.gate_up[expert_id].forward(ctx, x_single, gate_up_buf, 1);
1211            if let Some(t) = t0 {
1212                B::sync(ctx);
1213                MOE_GEMV_GATE_UP_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1214                MOE_GEMV_GATE_UP_CALLS.fetch_add(1, Ordering::Relaxed);
1215            }
1216
1217            // SiLU(gate) * up → [expert_inter]
1218            let t0 = if prof {
1219                Some(std::time::Instant::now())
1220            } else {
1221                None
1222            };
1223            B::fused_silu_mul_split(ctx, gate_up_buf, silu_buf, 1, expert_intermediate);
1224            if let Some(t) = t0 {
1225                B::sync(ctx);
1226                MOE_SILU_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1227                MOE_SILU_CALLS.fetch_add(1, Ordering::Relaxed);
1228            }
1229
1230            // down gemv → [hidden]
1231            let t0 = if prof {
1232                Some(std::time::Instant::now())
1233            } else {
1234                None
1235            };
1236            experts.down[expert_id].forward(ctx, silu_buf, down_buf, 1);
1237            if let Some(t) = t0 {
1238                B::sync(ctx);
1239                MOE_GEMV_DOWN_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1240                MOE_GEMV_DOWN_CALLS.fetch_add(1, Ordering::Relaxed);
1241            }
1242
1243            // acc_buf += weight * down_buf
1244            let t0 = if prof {
1245                Some(std::time::Instant::now())
1246            } else {
1247                None
1248            };
1249            B::scaled_add_inplace(ctx, acc_buf, down_buf, weight, hidden_size);
1250            if let Some(t) = t0 {
1251                B::sync(ctx);
1252                MOE_SCALED_ADD_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1253                MOE_SCALED_ADD_CALLS.fetch_add(1, Ordering::Relaxed);
1254            }
1255        }
1256
1257        // Final write: out[b] = acc_buf
1258        let t0 = if prof {
1259            Some(std::time::Instant::now())
1260        } else {
1261            None
1262        };
1263        B::copy_slice(ctx, acc_buf, 0, out, b * hidden_size, hidden_size);
1264        if let Some(t) = t0 {
1265            MOE_COPY_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1266            MOE_COPY_CALLS.fetch_add(1, Ordering::Relaxed);
1267        }
1268    }
1269
1270    Ok(())
1271}
1272
1273/// Largest moe_block_size we'd ever pick. Drives Qwen3MoeScratch
1274/// `route_sorted_tokens_dev` sizing (allocates `t*top_k + n_exp*MAX`).
1275pub const MOE_BLOCK_SIZE_MAX: usize = 64;
1276
1277/// Pick `moe_block_size` ∈ {16, 32, 64} based on routing distribution.
1278///
1279/// Marlin-MoE templates instantiate for thread_m_blocks ∈ {1, 2, 3, 4}
1280/// → block_size ∈ {16, 32, 48, 64}. Larger block_size enables the
1281/// "large_batch tile" path (thread_n=256, num_threads=256, 8× more work
1282/// per kernel launch) but pads each expert's tokens up to a multiple of
1283/// block_size — sparse routing wastes most of that.
1284///
1285/// Decision: pick the largest size whose **total padded tokens** stays
1286/// within 30% of actual. If we can't keep overhead below the threshold,
1287/// stick with 16. Skips block_size=48 for simplicity (rare sweet spot).
1288///
1289/// Device-routing path doesn't expose `plan` host-side; fall back to 16
1290/// (no regression vs pre-PR behaviour).
1291fn pick_moe_block_size(
1292    plan: Option<&MoeBucketPlan>,
1293    num_experts: usize,
1294    use_device_route: bool,
1295    total_pairs: usize,
1296) -> usize {
1297    pick_moe_block_size_with_config(
1298        moe_dispatch_runtime_config(),
1299        plan,
1300        num_experts,
1301        use_device_route,
1302        total_pairs,
1303    )
1304}
1305
1306fn pick_moe_block_size_with_config(
1307    config: &MoeDispatchRuntimeConfig,
1308    plan: Option<&MoeBucketPlan>,
1309    num_experts: usize,
1310    use_device_route: bool,
1311    total_pairs: usize,
1312) -> usize {
1313    const CANDIDATES: &[usize] = &[64, 32, 16];
1314    const PADDING_BUDGET: f64 = 1.30; // ≤ 30% overhead vs actual tokens
1315                                      // Manual override (testing / autotuning): FERRUM_MOE_BLOCK_SIZE=8/16/32/48/64.
1316                                      // vLLM 0.20.2 often selects 8 for small-M MoE; keep it override-only
1317                                      // until full-model correctness + throughput beats the 16 default.
1318    if let Some(bs) = config.moe_block_size {
1319        return bs;
1320    }
1321    if use_device_route {
1322        if let Some(bs) = config.moe_large_m_block_size {
1323            if total_pairs >= config.moe_large_m_min_pairs {
1324                return bs;
1325            }
1326        }
1327        // Empirical 2026-05-13: block_size=64 (`thread_m_blocks=4`,
1328        // matching vLLM's tile) regresses M3 c=32 by 5.7% on RTX 4090
1329        // because sparse routing (top_k=8 / num_experts=128 / m=32 ≈
1330        // 2 pairs per active expert) pads each expert's tile by ~32×,
1331        // and the wasted sentinel-row compute exceeds the tile-width
1332        // win. block_size=32 is within noise of 16. Keep 16 as default;
1333        // FERRUM_MOE_BLOCK_SIZE override stays for future autotuning
1334        // when m / routing density changes (e.g. dense Llama at m=32).
1335        return 16;
1336    }
1337    let Some(plan) = plan else {
1338        return 16;
1339    };
1340    let m_e: Vec<usize> = (0..num_experts)
1341        .map(|e| plan.expert_offsets[e + 1] - plan.expert_offsets[e])
1342        .collect();
1343    let total_actual: usize = m_e.iter().sum();
1344    if total_actual == 0 {
1345        return 16;
1346    }
1347    for &bs in CANDIDATES {
1348        let total_padded: usize = m_e.iter().map(|&m| m.div_ceil(bs) * bs).sum();
1349        if (total_padded as f64) <= (total_actual as f64) * PADDING_BUDGET {
1350            return bs;
1351        }
1352    }
1353    16
1354}
1355
1356/// Bucket plan: per-expert lists of which (token, k_slot) pairs route
1357/// through that expert. Built host-side from the router output and used
1358/// by [`moe_forward_bucketed`] to issue ONE m=tokens_per_expert Marlin
1359/// GEMM per active expert instead of `batch * top_k` m=1 GEMMs.
1360pub struct MoeBucketPlan {
1361    /// `expert_offsets[e+1] - expert_offsets[e]` = tokens routed to expert e.
1362    /// Length: `num_experts + 1`. `expert_offsets[num_experts]` = total_pairs
1363    /// (always `batch * top_k`).
1364    pub expert_offsets: Vec<usize>,
1365    /// `[total_pairs]` flat: which input token each packed-row gathers
1366    /// from. Index into `x[batch, hidden]`.
1367    pub packed_token_idx: Vec<u32>,
1368    /// `[batch, top_k]` row-major: for each (b, k_slot), which row of the
1369    /// packed buffers carries that pair's contribution. Used by
1370    /// `B::moe_combine` to scatter weighted sums back to `out[b]`.
1371    pub pairs_by_token: Vec<i32>,
1372    /// `[batch, top_k]` row-major: combine weight for the (b, k_slot)
1373    /// pair, copied verbatim from the router output. Used by
1374    /// `B::moe_combine`.
1375    pub pair_weights: Vec<f32>,
1376    /// Cached cursor scratch for [`Self::rebuild_into`] — sized to
1377    /// `num_experts` on first build and reused (one alloc total instead
1378    /// of one per call).
1379    cursors: Vec<usize>,
1380}
1381
1382impl MoeBucketPlan {
1383    /// Empty plan with no allocation. Use [`Self::rebuild_into`] before
1384    /// reuse — this is the cheap constructor for putting the plan in a
1385    /// scratch struct.
1386    pub fn empty() -> Self {
1387        Self {
1388            expert_offsets: Vec::new(),
1389            packed_token_idx: Vec::new(),
1390            pairs_by_token: Vec::new(),
1391            pair_weights: Vec::new(),
1392            cursors: Vec::new(),
1393        }
1394    }
1395
1396    /// Allocate a fresh plan. Convenience wrapper over [`Self::rebuild_into`]
1397    /// for tests and code paths that don't care about reuse.
1398    pub fn build(route: &RouterOutput, batch: usize, num_experts: usize, top_k: usize) -> Self {
1399        let mut p = Self::empty();
1400        p.rebuild_into(route, batch, num_experts, top_k);
1401        p
1402    }
1403
1404    /// Allocation-free rebuild. Reuses the existing `expert_offsets`,
1405    /// `packed_token_idx`, `pairs_by_token`, `pair_weights` buffers via
1406    /// `clear() + resize()`. Uses the trailing tail of `expert_offsets`
1407    /// as the host-side cursor scratch (saves the per-call `cursors.clone()`).
1408    pub fn rebuild_into(
1409        &mut self,
1410        route: &RouterOutput,
1411        batch: usize,
1412        num_experts: usize,
1413        top_k: usize,
1414    ) {
1415        debug_assert_eq!(route.expert_ids.len(), batch * top_k);
1416        debug_assert_eq!(route.expert_weights.len(), batch * top_k);
1417        let total_pairs = batch * top_k;
1418
1419        self.expert_offsets.clear();
1420        self.expert_offsets.resize(num_experts + 1, 0);
1421        self.packed_token_idx.clear();
1422        self.packed_token_idx.resize(total_pairs, 0);
1423        self.pairs_by_token.clear();
1424        self.pairs_by_token.resize(total_pairs, -1);
1425
1426        // Pass 1: count pairs per expert. Stored into expert_offsets[1..]
1427        // so the inclusive-prefix-sum in Pass 2 can run in place — no
1428        // separate `counts` Vec.
1429        for &eid in &route.expert_ids {
1430            self.expert_offsets[eid as usize + 1] += 1;
1431        }
1432
1433        // Pass 2: in-place inclusive prefix sum → expert_offsets[].
1434        for e in 0..num_experts {
1435            self.expert_offsets[e + 1] += self.expert_offsets[e];
1436        }
1437
1438        // Pass 3: fill packed_token_idx + pairs_by_token by walking pairs
1439        // in (b, k) order and bucketing. The `cursors` scratch tracks how
1440        // many pairs each expert has already received; on first call it
1441        // grows to `num_experts`, subsequent calls reuse the allocation.
1442        self.cursors.clear();
1443        self.cursors
1444            .extend_from_slice(&self.expert_offsets[..num_experts]);
1445
1446        for b in 0..batch {
1447            for k in 0..top_k {
1448                let pair_flat = b * top_k + k;
1449                let eid = route.expert_ids[pair_flat] as usize;
1450                let slot = self.cursors[eid];
1451                self.cursors[eid] += 1;
1452                self.packed_token_idx[slot] = b as u32;
1453                self.pairs_by_token[pair_flat] = slot as i32;
1454            }
1455        }
1456
1457        // Pair weights: replicate from RouterOutput. Reuse self's vector
1458        // via clear() + extend rather than the per-call `clone()`.
1459        self.pair_weights.clear();
1460        self.pair_weights.extend_from_slice(&route.expert_weights);
1461    }
1462}
1463
1464/// Reusable host-side scratch for [`moe_forward_bucketed`]. Holds the
1465/// router output, softmax scratch buffer, and bucket plan, all reused
1466/// across layers so the inner MoE forward path is allocation-free.
1467///
1468/// This avoids repeated CPU softmax, sort, and allocation setup across
1469/// layers.
1470pub struct MoeRouteScratch {
1471    pub output: RouterOutput,
1472    /// Softmax buffer reused across all rows of all layers — sized to
1473    /// `num_experts` on first use.
1474    pub probs: Vec<f32>,
1475    pub plan: MoeBucketPlan,
1476}
1477
1478impl MoeRouteScratch {
1479    pub fn new() -> Self {
1480        Self {
1481            output: RouterOutput::empty(),
1482            probs: Vec::new(),
1483            plan: MoeBucketPlan::empty(),
1484        }
1485    }
1486}
1487
1488impl Default for MoeRouteScratch {
1489    fn default() -> Self {
1490        Self::new()
1491    }
1492}
1493
1494/// Bundle of pre-allocated device buffers for the graph-capturable
1495/// device-routing path in [`moe_forward_bucketed`]. Pass `Some` to
1496/// take the device path (under `FERRUM_MOE_DEVICE_ROUTE=1`); pass
1497/// `None` for the legacy host-mediated path (used by tests + the
1498/// non-vLLM CUDA bucketed path).
1499///
1500/// Pre-allocated on Qwen3MoeScratch (`route_pairs_dev` etc.) so the
1501/// per-layer call doesn't alloc inside a captured stream.
1502pub struct DeviceRouteScratch<'a, B: crate::moe::dispatch::Backend> {
1503    pub selected_ids: &'a mut B::Buffer,
1504    pub pair_weights: &'a mut B::Buffer,
1505    pub pairs_by_token: &'a mut B::Buffer,
1506    pub packed_token_idx: &'a mut B::Buffer,
1507    pub expert_offsets: &'a mut B::Buffer,
1508    // Phase 2: moe_align_block_size outputs for the vLLM marlin_moe
1509    // fused GEMM path. Same shape as host `vllm_routing` builder
1510    // produces, but device-resident.
1511    pub sorted_tokens: &'a mut B::Buffer,
1512    pub block_ids: &'a mut B::Buffer,
1513    pub total_post_pad: &'a mut B::Buffer,
1514}
1515
1516/// Bucketed MoE forward: gather → per-expert m=N Marlin GEMM → silu_mul →
1517/// per-expert m=N Marlin GEMM → moe_combine.
1518///
1519/// Replaces the `batch × top_k` m=1 dispatch loop in [`moe_forward`] with
1520/// `num_active_experts × 2` m=tokens_per_expert dispatches. For prefill
1521/// (m=512+), this is a 30× reduction in GEMM launches AND each GEMM runs
1522/// at a much more efficient m than the m=1 path. For decode (m=1), the
1523/// number of dispatches is similar but we still benefit from the
1524/// gather/combine kernel pattern (one launch each instead of 2 per pair).
1525///
1526/// **Requires**: scratch buffers `x_packed [total_pairs, hidden]`,
1527/// `gate_up_packed [total_pairs, 2*expert_inter]`,
1528/// `silu_packed [total_pairs, expert_inter]`, and
1529/// `down_packed [total_pairs, hidden]` provisioned by the caller. The
1530/// caller is responsible for sizing these to `batch * top_k` rows
1531/// (worst-case all top_k pairs alive).
1532pub struct MoeForwardBucketedParams<'a, B: QuantLlmBackend + BackendMoeFused> {
1533    pub ctx: &'a mut B::Context,
1534    pub x: &'a B::Buffer,
1535    pub router_logits: &'a B::Buffer,
1536    pub out: &'a mut B::Buffer,
1537    pub batch: usize,
1538    pub hidden_size: usize,
1539    pub expert_intermediate: usize,
1540    pub num_experts: usize,
1541    pub top_k: usize,
1542    pub norm_topk_prob: bool,
1543    pub experts: &'a ExpertStack<B>,
1544    pub x_packed: &'a mut B::Buffer,
1545    pub gate_up_packed: &'a mut B::Buffer,
1546    pub silu_packed: &'a mut B::Buffer,
1547    pub down_packed: &'a mut B::Buffer,
1548    pub route_scratch: &'a mut MoeRouteScratch,
1549    pub profile_bucket: bool,
1550    // Optional device routing scratch — when Some AND
1551    // FERRUM_MOE_DEVICE_ROUTE=1 AND FERRUM_VLLM_MOE=1, runs the
1552    // graph-capturable device-routing branch. None / unset = legacy
1553    // host-mediated path (used by tests + non-vLLM path).
1554    pub device_route: Option<DeviceRouteScratch<'a, B>>,
1555}
1556
1557pub fn moe_forward_bucketed<B: QuantLlmBackend + BackendMoeFused>(
1558    params: MoeForwardBucketedParams<'_, B>,
1559) -> Result<()> {
1560    let MoeForwardBucketedParams {
1561        ctx,
1562        x,
1563        router_logits,
1564        out,
1565        batch,
1566        hidden_size,
1567        expert_intermediate,
1568        num_experts,
1569        top_k,
1570        norm_topk_prob,
1571        experts,
1572        x_packed,
1573        gate_up_packed,
1574        silu_packed,
1575        down_packed,
1576        route_scratch,
1577        profile_bucket,
1578        device_route,
1579    } = params;
1580    if experts.num_experts() != num_experts {
1581        return Err(FerrumError::model(format!(
1582            "moe_forward_bucketed: experts {} != num_experts {num_experts}",
1583            experts.num_experts()
1584        )));
1585    }
1586
1587    let runtime_config = moe_dispatch_runtime_config();
1588    // Bucket profiling fires on either FERRUM_MOE_PROFILE=1 (legacy)
1589    // or FERRUM_DECODE_OP_PROFILE=1 (the gate the print site uses).
1590    let prof = profile_bucket || runtime_config.moe_profile || runtime_config.decode_op_profile;
1591    if prof {
1592        MOE_BUCKET_LAYER_CALLS.fetch_add(1, Ordering::Relaxed);
1593    }
1594
1595    // ── Device-route fast path (opt-in via FERRUM_MOE_DEVICE_ROUTE=1
1596    //     + FERRUM_VLLM_MOE=1 + device_route Some) ────────────────────
1597    //
1598    // Skips ALL host round-trips in the routing + bucket-plan stages:
1599    //   1. B::route_topk_softmax → device expert_ids + weights
1600    //   2. B::moe_build_pairs_by_token → device pairs / packed_idx /
1601    //      expert_offsets
1602    //   3. Gather via B::embedding_lookup_dev (device packed_idx)
1603    //   4. (rest of function reuses these device buffers; the vLLM
1604    //      MoE GEMM consumes them directly)
1605    //
1606    // This is the prerequisite for CUDA Graph capture over the MoE
1607    // layer loop in Qwen3MoeModel::decode_batch_internal.
1608    let stack_requires_vllm = experts
1609        .gate_up_marlin_stack
1610        .as_ref()
1611        .is_some_and(|stack| stack.requires_vllm_moe())
1612        || experts
1613            .down_marlin_stack
1614            .as_ref()
1615            .is_some_and(|stack| stack.requires_vllm_moe());
1616    let use_vllm_moe = runtime_config.vllm_moe || stack_requires_vllm;
1617    // Device-routing path: enabled whenever the caller passes
1618    // pre-allocated `DeviceRouteScratch` AND the vLLM MoE path is selected
1619    // by runtime config or by the loaded weight stack. No separate env var
1620    // — the device path avoids the host path's per-layer
1621    // `try_gpu_route_topk_into_host` D2H copy and synchronization stall.
1622    //
1623    // Requires use_vllm_moe because the non-vLLM bucketed path needs
1624    // host phase1_dispatches / phase3_dispatches lists (one entry per
1625    // active expert with expert-id-dependent shape), which can't be
1626    // built on-device.
1627    //
1628    // Callers that need to force the host path for diagnostics can set
1629    // FERRUM_MOE_HOST_ROUTE=1 (opt-out).
1630    let use_device_route = device_route.is_some() && use_vllm_moe && !runtime_config.moe_host_route;
1631    let use_vllm_pair_ids = use_device_route && runtime_config.vllm_moe_pair_ids;
1632
1633    // Run device-side routing kernels EARLY so `dr.packed_token_idx`
1634    // is available for the device-buffer gather (embedding_lookup_dev),
1635    // `dr.pairs_by_token` / `dr.pair_weights` for moe_combine, and
1636    // `dr.sorted_tokens` / `dr.block_ids` / `dr.total_post_pad` (via
1637    // moe_align_block_size below) for the vLLM marlin_moe GEMM phases.
1638    // Kept alive in `dr_kept` until end of function.
1639    let mut dr_kept: Option<DeviceRouteScratch<'_, B>> = if use_device_route {
1640        let dr = device_route.expect("device_route is Some when use_device_route");
1641        B::route_topk_softmax(
1642            ctx,
1643            router_logits,
1644            dr.selected_ids,
1645            dr.pair_weights,
1646            batch,
1647            num_experts,
1648            top_k,
1649            norm_topk_prob,
1650        )?;
1651        if !use_vllm_pair_ids {
1652            B::moe_build_pairs_by_token(
1653                ctx,
1654                dr.selected_ids,
1655                dr.pairs_by_token,
1656                dr.packed_token_idx,
1657                dr.expert_offsets,
1658                batch * top_k,
1659                num_experts,
1660                top_k,
1661            )?;
1662        }
1663        Some(dr)
1664    } else {
1665        None
1666    };
1667
1668    // ── Routing + bucket plan (host) ─────────────────────────────────
1669    //
1670    // Skipped entirely under use_device_route — the device kernels run
1671    // by `dr_kept` above produce equivalent on-device buffers. The host
1672    // path stays for the legacy non-vllm bucketed dispatch and for
1673    // tests (where device_route is None).
1674    //
1675    // GPU fast-path: `try_gpu_route_topk_into_host` runs the same
1676    // route_topk_softmax kernel and D2Hs only `[batch, top_k]` ids +
1677    // weights (~1 KB at c=32) into RouterOutput. Host fallback covers
1678    // backends without the override (CPU / Metal / future).
1679    let plan: Option<&crate::moe::MoeBucketPlan> = if !use_device_route {
1680        let t_route_total = if prof {
1681            Some(std::time::Instant::now())
1682        } else {
1683            None
1684        };
1685        let gpu_route = B::try_gpu_route_topk_into_host(
1686            ctx,
1687            router_logits,
1688            &mut route_scratch.output.expert_ids,
1689            &mut route_scratch.output.expert_weights,
1690            batch,
1691            num_experts,
1692            top_k,
1693            norm_topk_prob,
1694        );
1695        if gpu_route.is_err() {
1696            let t_sync = if prof {
1697                Some(std::time::Instant::now())
1698            } else {
1699                None
1700            };
1701            B::sync(ctx);
1702            if let Some(t) = t_sync {
1703                MOE_BUCKET_SYNC_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1704            }
1705            let t_d2h = if prof {
1706                Some(std::time::Instant::now())
1707            } else {
1708                None
1709            };
1710            let logits_host = B::to_vec(router_logits, batch * num_experts);
1711            if let Some(t) = t_d2h {
1712                MOE_BUCKET_D2H_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1713            }
1714            let t_route = if prof {
1715                Some(std::time::Instant::now())
1716            } else {
1717                None
1718            };
1719            crate::moe::router::route_into(
1720                &logits_host,
1721                batch,
1722                num_experts,
1723                top_k,
1724                norm_topk_prob,
1725                &mut route_scratch.output,
1726                &mut route_scratch.probs,
1727            );
1728            if let Some(t) = t_route {
1729                MOE_BUCKET_ROUTE_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1730            }
1731        } else if let Some(t) = t_route_total {
1732            MOE_BUCKET_ROUTE_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1733        }
1734        let t_plan = if prof {
1735            Some(std::time::Instant::now())
1736        } else {
1737            None
1738        };
1739        route_scratch
1740            .plan
1741            .rebuild_into(&route_scratch.output, batch, num_experts, top_k);
1742        if let Some(t) = t_plan {
1743            MOE_BUCKET_PLAN_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1744        }
1745        Some(&route_scratch.plan)
1746    } else {
1747        None
1748    };
1749
1750    // ── Gather: x_packed[i] = x[packed_token_idx[i]] ───────────────────
1751    // Under use_device_route, read packed_token_idx from device (no
1752    // host roundtrip → graph-capturable). Else use the host plan.
1753    if !use_vllm_pair_ids {
1754        let t_gather = if prof {
1755            Some(std::time::Instant::now())
1756        } else {
1757            None
1758        };
1759        if let Some(ref dr) = dr_kept {
1760            B::embedding_lookup_dev(
1761                ctx,
1762                x,
1763                dr.packed_token_idx,
1764                x_packed,
1765                batch * top_k,
1766                hidden_size,
1767            );
1768        } else {
1769            let plan = plan.expect("plan is Some when !use_device_route");
1770            B::embedding_lookup(ctx, x, &plan.packed_token_idx, x_packed, hidden_size);
1771        }
1772        if let Some(t) = t_gather {
1773            B::sync(ctx);
1774            MOE_BUCKET_GATHER_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1775        }
1776    }
1777
1778    // ── Per-expert dispatch: gate_up + down GEMMs at m=tokens_per_expert
1779    //
1780    // Uses the strided GPTQ + silu_mul methods so we can pump through
1781    // the BIG packed buffers (allocated once at scratch alloc) without
1782    // any per-expert copies. Each expert gets its column-slice of the
1783    // shared stacked Marlin tile via expert_offset; the row-slice of
1784    // the packed input/output buffers via in_row_offset / out_row_offset.
1785    let gate_up_dim_per_expert = 2 * expert_intermediate;
1786    let down_n_per_expert = hidden_size;
1787    // Bulk-zero the gate_up workspace ONCE before phase 1 for the
1788    // non-vLLM Marlin paths. The vLLM marlin_moe_wna16 kernel resets
1789    // its lock slots internally on the reduce path; vLLM itself only
1790    // zeros this workspace at allocation time. Keep
1791    // FERRUM_VLLM_MOE_ZERO_WS=1 as an A/B escape hatch.
1792    let gu_store = experts.gate_up_stacked_store(0).ok_or_else(|| {
1793        FerrumError::model(
1794            "moe_forward_bucketed requires stacked gate_up store \
1795             (load via Qwen3MoeModel::new_safetensors)",
1796        )
1797    })?;
1798    let zero_marlin_workspace = !use_vllm_moe || runtime_config.vllm_moe_zero_ws;
1799    if zero_marlin_workspace {
1800        let _ = gu_store.zero_workspace(ctx);
1801    }
1802
1803    // Decide path: vLLM marlin_moe_wna16 fused or per-expert bucketed
1804    // GEMMs. Under use_device_route, build routing on-device via
1805    // `moe_align_block_size`; under use_vllm_moe alone, host-build it.
1806    // Either way the GEMM dispatcher takes `&Buffer` for the 3 routing
1807    // arrays.
1808    let total_pairs_active = batch * top_k;
1809    // ── Dynamic moe_block_size policy ────────────────────────────────────
1810    //
1811    // Marlin-MoE kernel template is instantiated for thread_m_blocks ∈
1812    // {1, 2, 3, 4} via COMMON_GET_IF_M1 / COMMON_GET_IF_M234 in
1813    // the native vLLM Marlin MoE artifact. Each maps to block_size = thread_m_blocks
1814    // × 16 ∈ {16, 32, 48, 64}. Picking the right one is a classic
1815    // throughput-vs-padding-waste tradeoff:
1816    //
1817    //   block_size=16 :  16 thread_n=128 num_threads=128 (small_batch tile)
1818    //   block_size=32+:  16 thread_n=256 num_threads=256 (large_batch tile,
1819    //                    8× more work per kernel launch)
1820    //
1821    // Larger tile = more arithmetic per memory load = higher DRAM
1822    // utilization. But each expert pads its actual token count up to
1823    // a multiple of block_size — sparse routing (many experts, few
1824    // tokens each) bleeds into massive padding waste.
1825    //
1826    // Decision rule: pick the largest block_size whose padding overhead
1827    // would still be ≤ ~30%. The host-routing path has `plan.expert_offsets`
1828    // which gives exact m_e per expert — pick by actual data. The
1829    // device-routing path doesn't have host visibility so falls back to
1830    // a conservative 16 (matches pre-PR behaviour, no regression).
1831    //
1832    // Worst-case scratch sizing: 64 (the largest block_size we'd pick).
1833    // `Qwen3MoeScratch.route_sorted_tokens_dev` capacity is allocated
1834    // assuming this upper bound.
1835    let max_block_size: usize = 64;
1836    let moe_block_size: usize = pick_moe_block_size_with_config(
1837        runtime_config,
1838        plan,
1839        num_experts,
1840        use_device_route,
1841        total_pairs_active,
1842    );
1843    debug_assert!(
1844        moe_block_size <= max_block_size,
1845        "moe_block_size {moe_block_size} exceeds scratch worst-case {max_block_size}"
1846    );
1847    // sorted_max bound — passed to moe_align as a runtime cap so it
1848    // never writes past `total_padded` for the chosen block_size. We use
1849    // the picked `moe_block_size`, not `max_block_size`, so moe_align
1850    // doesn't sentinel-fill the slack between the actual padded count
1851    // and the worst-case buffer capacity (saves ~6 KB of writes per
1852    // layer × 48 layers × 32 layer-loop iters when block_size lands at 16).
1853    // The buffer itself is sized for max_block_size in qwen3_moe.rs.
1854    let sorted_max_size = batch * top_k + num_experts * moe_block_size;
1855    let vllm_routing_owned: Option<ferrum_kernels::backend::MoeRouting<B>> =
1856        if use_vllm_moe && !use_device_route {
1857            let plan = plan.expect("plan is Some when host vllm builder runs");
1858            let mut padded_offsets = Vec::with_capacity(num_experts + 1);
1859            let mut acc = 0usize;
1860            for e in 0..num_experts {
1861                padded_offsets.push(acc);
1862                let m_e = plan.expert_offsets[e + 1] - plan.expert_offsets[e];
1863                let pe = m_e.div_ceil(moe_block_size) * moe_block_size;
1864                acc += pe;
1865            }
1866            padded_offsets.push(acc);
1867            let total_padded = acc;
1868            let total_blocks = total_padded / moe_block_size;
1869            let sentinel = total_pairs_active as i32;
1870
1871            let mut sorted_token_ids = vec![sentinel; total_padded];
1872            let mut expert_ids = vec![0i32; total_blocks];
1873            for e in 0..num_experts {
1874                let m_e = plan.expert_offsets[e + 1] - plan.expert_offsets[e];
1875                if m_e == 0 {
1876                    continue;
1877                }
1878                let p_off = padded_offsets[e];
1879                let real_off = plan.expert_offsets[e];
1880                for i in 0..m_e {
1881                    sorted_token_ids[p_off + i] = (real_off + i) as i32;
1882                }
1883                let blocks_for_e = (padded_offsets[e + 1] - p_off) / moe_block_size;
1884                let block_start = p_off / moe_block_size;
1885                for b in 0..blocks_for_e {
1886                    expert_ids[block_start + b] = e as i32;
1887                }
1888            }
1889            let num_tokens_past_padded = vec![total_padded as i32];
1890            Some(B::upload_moe_routing(
1891                ctx,
1892                &sorted_token_ids,
1893                &expert_ids,
1894                &num_tokens_past_padded,
1895            )?)
1896        } else {
1897            None
1898        };
1899
1900    // Device-side moe_align_block_size — under use_device_route, fill
1901    // dr.{sorted_tokens, block_ids, total_post_pad} on device from
1902    // dr.selected_ids. No host roundtrip → captures cleanly.
1903    if use_device_route {
1904        let dr = dr_kept
1905            .as_mut()
1906            .expect("dr_kept is Some when use_device_route");
1907        if use_vllm_pair_ids {
1908            B::moe_align_block_size_pair_ids(
1909                ctx,
1910                dr.selected_ids,
1911                dr.sorted_tokens,
1912                dr.block_ids,
1913                dr.total_post_pad,
1914                batch * top_k,
1915                num_experts,
1916                moe_block_size,
1917                sorted_max_size,
1918            )?;
1919        } else {
1920            B::moe_align_block_size(
1921                ctx,
1922                dr.selected_ids,
1923                dr.sorted_tokens,
1924                dr.block_ids,
1925                dr.total_post_pad,
1926                batch * top_k,
1927                num_experts,
1928                moe_block_size,
1929                sorted_max_size,
1930            )?;
1931        }
1932    }
1933
1934    // Resolve the 3 routing buffers for vLLM phase 1/3 GEMM. Either
1935    // from dr_kept (device-built by moe_align_block_size) or from
1936    // vllm_routing_owned (host-built + uploaded). None → use legacy
1937    // per-expert batched GEMM path.
1938    let vllm_refs: Option<(&B::Buffer, &B::Buffer, &B::Buffer)> = if use_device_route {
1939        let dr = dr_kept
1940            .as_ref()
1941            .expect("dr_kept is Some when use_device_route");
1942        Some((&*dr.sorted_tokens, &*dr.block_ids, &*dr.total_post_pad))
1943    } else if let Some(r) = vllm_routing_owned.as_ref() {
1944        Some((
1945            &r.sorted_token_ids,
1946            &r.expert_ids,
1947            &r.num_tokens_past_padded,
1948        ))
1949    } else {
1950        None
1951    };
1952
1953    // Phase 1/3 batched-GEMM dispatch lists. Only built (and read) for
1954    // the non-vLLM path. Under use_device_route the host plan is None
1955    // anyway, so we'd fail to build them — skip via vllm_refs.is_some.
1956    let phase1_dispatches: Vec<(usize, usize, usize, usize)> = if vllm_refs.is_none() {
1957        let plan = plan.expect("plan is Some when batched GEMM path runs");
1958        let mut v: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(num_experts);
1959        for e in 0..num_experts {
1960            let m_e = plan.expert_offsets[e + 1] - plan.expert_offsets[e];
1961            if m_e == 0 {
1962                continue;
1963            }
1964            let pair_off = plan.expert_offsets[e];
1965            v.push((e, pair_off, pair_off, m_e));
1966        }
1967        v.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
1968        v
1969    } else {
1970        Vec::new()
1971    };
1972    let t_gemm1 = if prof {
1973        Some(std::time::Instant::now())
1974    } else {
1975        None
1976    };
1977    if let Some((sorted_tokens, block_ids, total_post_pad)) = vllm_refs {
1978        // fp32_reduce path: kernel writes C directly via global reduce.
1979        #[cfg(feature = "cuda")]
1980        let _marlin_label = if ferrum_kernels::backend::cuda::marlin::profile_marlin() {
1981            Some(ferrum_kernels::backend::cuda::push_alloc_label(
1982                "moe.vllm.gate_up_proj",
1983            ))
1984        } else {
1985            None
1986        };
1987        if use_vllm_pair_ids {
1988            gu_store.gemm_phase_vllm(
1989                ctx,
1990                x,
1991                sorted_tokens,
1992                block_ids,
1993                total_post_pad,
1994                gate_up_packed,
1995                batch,
1996                moe_block_size,
1997                top_k,
1998            )?;
1999        } else {
2000            gu_store.gemm_phase_vllm(
2001                ctx,
2002                x_packed,
2003                sorted_tokens,
2004                block_ids,
2005                total_post_pad,
2006                gate_up_packed,
2007                total_pairs_active,
2008                moe_block_size,
2009                1, // top_k=1: pre-gathered rows already index packed input directly
2010            )?;
2011        }
2012    } else {
2013        gu_store.gemm_phase_batched(
2014            ctx,
2015            x_packed,
2016            &phase1_dispatches,
2017            gate_up_packed,
2018            hidden_size,
2019        )?;
2020    }
2021    if let Some(t) = t_gemm1 {
2022        B::sync(ctx);
2023        MOE_BUCKET_GEMM1_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
2024    }
2025
2026    // Phase 2: SiLU(gate) * up — single launch covering ALL active
2027    // expert rows in the packed buffer. The unused rows (zeros from
2028    // experts with m_e=0) just produce zeros that the combine step
2029    // ignores via pairs_by_token. Saves num_active_experts-1 launches
2030    // per layer.
2031    let total_pairs_active = batch * top_k;
2032    let t_silu = if prof {
2033        Some(std::time::Instant::now())
2034    } else {
2035        None
2036    };
2037    B::fused_silu_mul_split(
2038        ctx,
2039        gate_up_packed,
2040        silu_packed,
2041        total_pairs_active,
2042        expert_intermediate,
2043    );
2044    if let Some(t) = t_silu {
2045        B::sync(ctx);
2046        MOE_BUCKET_SILU_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
2047    }
2048
2049    // Phase 3: down GEMM per active expert. Multi-stream batched.
2050    let d_store = experts.down_stacked_store(0).ok_or_else(|| {
2051        FerrumError::model(
2052            "moe_forward_bucketed requires stacked down store \
2053             (load via Qwen3MoeModel::new_safetensors)",
2054        )
2055    })?;
2056    if zero_marlin_workspace {
2057        let _ = d_store.zero_workspace(ctx);
2058    }
2059    let phase3_dispatches: Vec<(usize, usize, usize, usize)> = if vllm_refs.is_none() {
2060        let plan = plan.expect("plan is Some when batched GEMM path runs");
2061        let mut v: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(num_experts);
2062        for e in 0..num_experts {
2063            let m_e = plan.expert_offsets[e + 1] - plan.expert_offsets[e];
2064            if m_e == 0 {
2065                continue;
2066            }
2067            let pair_off = plan.expert_offsets[e];
2068            v.push((e, pair_off, pair_off, m_e));
2069        }
2070        v.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
2071        v
2072    } else {
2073        Vec::new()
2074    };
2075    let t_gemm3 = if prof {
2076        Some(std::time::Instant::now())
2077    } else {
2078        None
2079    };
2080    if let Some((sorted_tokens, block_ids, total_post_pad)) = vllm_refs {
2081        #[cfg(feature = "cuda")]
2082        let _marlin_label = if ferrum_kernels::backend::cuda::marlin::profile_marlin() {
2083            Some(ferrum_kernels::backend::cuda::push_alloc_label(
2084                "moe.vllm.down_proj",
2085            ))
2086        } else {
2087            None
2088        };
2089        d_store.gemm_phase_vllm(
2090            ctx,
2091            silu_packed,
2092            sorted_tokens,
2093            block_ids,
2094            total_post_pad,
2095            down_packed,
2096            total_pairs_active,
2097            moe_block_size,
2098            1,
2099        )?;
2100    } else {
2101        d_store.gemm_phase_batched(
2102            ctx,
2103            silu_packed,
2104            &phase3_dispatches,
2105            down_packed,
2106            expert_intermediate,
2107        )?;
2108    }
2109    if let Some(t) = t_gemm3 {
2110        B::sync(ctx);
2111        MOE_BUCKET_GEMM3_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
2112    }
2113
2114    // ── Combine: out[b, h] = Σ_k weights[b,k] * down_packed[pairs_by_token[b,k], h]
2115    //
2116    // Two paths for the pairs/weights device buffers:
2117    //
2118    //   (a) device-route mode: reuse `dr_kept` populated up top by
2119    //       B::route_topk_softmax + B::moe_build_pairs_by_token. No
2120    //       host→device upload, so this is graph-capturable when
2121    //       wrapped in begin_graph_capture.
2122    //
2123    //   (b) legacy: upload host plan (plan.pairs_by_token /
2124    //       plan.pair_weights) via from_slice_typed. Records host
2125    //       pointer; captures stale on replay.
2126    //
2127    // Both produce mathematically equivalent outputs — device path
2128    // does the same counting-sort the host plan rebuild does, just
2129    // on-device via the moe_build_pairs kernel.
2130    let total_pairs = batch * top_k;
2131    let t_comb = if prof {
2132        Some(std::time::Instant::now())
2133    } else {
2134        None
2135    };
2136    if use_vllm_pair_ids {
2137        let dr = dr_kept
2138            .as_ref()
2139            .expect("dr_kept is Some when use_vllm_pair_ids");
2140        B::weighted_sum_batched(
2141            ctx,
2142            down_packed,
2143            dr.pair_weights,
2144            out,
2145            batch,
2146            top_k,
2147            hidden_size,
2148        )?;
2149    } else {
2150        let (pairs_ref, weights_ref);
2151        let _pairs_owned;
2152        let _weights_owned;
2153        if let Some(ref dr) = dr_kept {
2154            pairs_ref = &*dr.pairs_by_token;
2155            weights_ref = &*dr.pair_weights;
2156        } else {
2157            let plan = plan.expect("plan is Some when host moe_combine runs");
2158            _pairs_owned = B::from_slice_typed::<i32>(&plan.pairs_by_token);
2159            _weights_owned = B::from_slice_typed::<f32>(&plan.pair_weights);
2160            pairs_ref = &_pairs_owned;
2161            weights_ref = &_weights_owned;
2162        }
2163        B::moe_combine(
2164            ctx,
2165            down_packed,
2166            pairs_ref,
2167            weights_ref,
2168            out,
2169            batch,
2170            hidden_size,
2171            top_k,
2172            total_pairs,
2173        );
2174    }
2175    if let Some(t) = t_comb {
2176        B::sync(ctx);
2177        MOE_BUCKET_COMBINE_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
2178    }
2179
2180    Ok(())
2181}
2182
2183/// Run MoE forward on CPU.
2184///
2185/// Inputs:
2186///   - `x`: `[batch, hidden_size]` row-major hidden states (post-attention,
2187///          post-residual — i.e. what the dense MLP would normally see).
2188///   - `router`: top-K assignments + weights from [`super::router::route`].
2189///   - `experts`: per-layer expert weights from [`ExpertStack::load_from_gguf`].
2190///
2191/// Output:
2192///   - `out`: `[batch, hidden_size]`. Resized + zero-initialised.
2193///
2194/// The function recomputes its scratch buffers each call. For tight
2195/// inner loops, callers will eventually want a pre-allocated workspace
2196/// (Phase 2F refactor). For now, this is the readable reference.
2197pub fn moe_forward_cpu(
2198    x: &[f32],
2199    batch: usize,
2200    hidden_size: usize,
2201    expert_intermediate: usize,
2202    top_k: usize,
2203    router: &RouterOutput,
2204    experts: &ExpertStack<CpuBackend>,
2205    out: &mut Vec<f32>,
2206) -> Result<()> {
2207    let n_experts = experts.num_experts();
2208
2209    if x.len() != batch * hidden_size {
2210        return Err(FerrumError::model(format!(
2211            "moe_forward_cpu: x len {} doesn't match batch*hidden = {}*{} = {}",
2212            x.len(),
2213            batch,
2214            hidden_size,
2215            batch * hidden_size
2216        )));
2217    }
2218    if router.expert_ids.len() != batch * top_k {
2219        return Err(FerrumError::model(format!(
2220            "moe_forward_cpu: router has {} expert_ids but expected batch*top_k = {}*{} = {}",
2221            router.expert_ids.len(),
2222            batch,
2223            top_k,
2224            batch * top_k
2225        )));
2226    }
2227
2228    out.clear();
2229    out.resize(batch * hidden_size, 0.0);
2230
2231    let mut ctx = <CpuBackend as Backend>::new_context();
2232    let mut x_b: Vec<f32> = vec![0.0; hidden_size];
2233    let mut gate_up_buf: Vec<f32> = vec![0.0; 2 * expert_intermediate];
2234    let mut silu_mul_buf: Vec<f32> = vec![0.0; expert_intermediate];
2235    let mut down_out: Vec<f32> = vec![0.0; hidden_size];
2236
2237    for b in 0..batch {
2238        x_b.copy_from_slice(&x[b * hidden_size..(b + 1) * hidden_size]);
2239
2240        for k in 0..top_k {
2241            let pair_idx = b * top_k + k;
2242            let expert_id = router.expert_ids[pair_idx] as usize;
2243            let weight = router.expert_weights[pair_idx];
2244
2245            if expert_id >= n_experts {
2246                return Err(FerrumError::model(format!(
2247                    "moe_forward_cpu: router selected expert {expert_id} >= num_experts {n_experts}"
2248                )));
2249            }
2250
2251            // Gate||Up projection (fused) → [1, 2*expert_inter]
2252            experts.gate_up[expert_id].forward(&mut ctx, &x_b, &mut gate_up_buf, 1);
2253
2254            // SiLU(gate) * up → [1, expert_inter]
2255            <CpuBackend as Backend>::fused_silu_mul_split(
2256                &mut ctx,
2257                &gate_up_buf,
2258                &mut silu_mul_buf,
2259                1,
2260                expert_intermediate,
2261            );
2262
2263            // Down projection → [1, hidden]
2264            experts.down[expert_id].forward(&mut ctx, &silu_mul_buf, &mut down_out, 1);
2265
2266            // Weighted accumulate into out[b, :]. Done host-side because
2267            // CpuBackend::Buffer = Vec<f32> and the trait doesn't yet
2268            // expose scaled-add.
2269            let out_row = &mut out[b * hidden_size..(b + 1) * hidden_size];
2270            for (o, d) in out_row.iter_mut().zip(down_out.iter()) {
2271                *o += weight * *d;
2272            }
2273        }
2274    }
2275
2276    Ok(())
2277}
2278
2279fn check_size(actual: usize, expected: usize, label: &str) -> Result<()> {
2280    if actual != expected {
2281        return Err(FerrumError::model(format!(
2282            "ExpertStack: {label} size mismatch (got {actual}, expected {expected})"
2283        )));
2284    }
2285    Ok(())
2286}
2287
2288/// Map candle's `GgmlDType` to the kernel-side `GgufQuantType` for the
2289/// dtypes a backend can dispatch on. Returns `None` for any other dtype
2290/// (callers fall back to eager dequant).
2291fn quant_kind(gguf: &GgufFile, name: &str) -> Result<Option<GgufQuantType>> {
2292    let info = gguf.tensor_info(name).ok_or_else(|| {
2293        FerrumError::model(format!("ExpertStack: tensor info missing for '{name}'"))
2294    })?;
2295    Ok(match info.ggml_dtype {
2296        GgmlDType::Q4K => Some(GgufQuantType::Q4K),
2297        GgmlDType::Q6K => Some(GgufQuantType::Q6K),
2298        _ => None,
2299    })
2300}
2301
2302/// Per-expert block-byte count for a given k-quant flavour and element
2303/// count. Q4_K = 144 B / 256 elems, Q6_K = 210 B / 256 elems. Errors if
2304/// `n_elems` is not a multiple of the super-block size (256) — a Q-quant
2305/// invariant.
2306fn block_bytes_for(kind: GgufQuantType, n_elems: usize, label: &str) -> Result<usize> {
2307    const QK_K: usize = 256;
2308    if n_elems % QK_K != 0 {
2309        return Err(FerrumError::model(format!(
2310            "ExpertStack {label}: per-expert element count {n_elems} not a multiple of {QK_K}"
2311        )));
2312    }
2313    let block_bytes = match kind {
2314        GgufQuantType::Q4K => 144,
2315        GgufQuantType::Q6K => 210,
2316        // Other k-quants are filtered out earlier via `quant_kind`; reaching here
2317        // with one would be a programming error.
2318        other => {
2319            return Err(FerrumError::model(format!(
2320                "ExpertStack {label}: unsupported k-quant flavour {other:?}"
2321            )))
2322        }
2323    };
2324    Ok((n_elems / QK_K) * block_bytes)
2325}
2326
2327fn read_dequant_flat(gguf: &GgufFile, name: &str, device: &Device) -> Result<Vec<f32>> {
2328    let qt = gguf.read_tensor(name, device).map_err(candle_to_ferrum)?;
2329    let dense = qt.dequantize(device).map_err(candle_to_ferrum)?;
2330    let flat = dense.flatten_all().map_err(candle_to_ferrum)?;
2331    flat.to_vec1::<f32>().map_err(candle_to_ferrum)
2332}
2333
2334fn candle_to_ferrum(e: candle_core::Error) -> FerrumError {
2335    FerrumError::model(format!("candle: {e}"))
2336}
2337
2338// Suppress unused-import warning when this module compiles standalone in
2339// the lib (the candle Result alias is only used via map_err in Phase 2).
2340#[allow(dead_code)]
2341type _CandleResult<T> = CandleResult<T>;
2342
2343#[cfg(test)]
2344mod tests {
2345    use std::sync::atomic::Ordering;
2346
2347    use ferrum_kernels::backend::cpu::CpuBackend;
2348    use ferrum_kernels::backend::Backend;
2349    use ferrum_kernels::StackedExpertGgufLinear;
2350
2351    use super::{
2352        drain_moe_bucket_profile, pick_moe_block_size_with_config, ExpertStack,
2353        MoeDispatchRuntimeConfig, MOE_BUCKET_COMBINE_US, MOE_BUCKET_D2H_US, MOE_BUCKET_GATHER_US,
2354        MOE_BUCKET_GEMM1_US, MOE_BUCKET_GEMM3_US, MOE_BUCKET_LAYER_CALLS, MOE_BUCKET_PLAN_US,
2355        MOE_BUCKET_ROUTE_US, MOE_BUCKET_SILU_US, MOE_BUCKET_SYNC_US,
2356    };
2357
2358    struct FakeStackedGgufLinear {
2359        num_experts: usize,
2360        rows: usize,
2361        cols: usize,
2362    }
2363
2364    impl StackedExpertGgufLinear<CpuBackend> for FakeStackedGgufLinear {
2365        fn num_experts(&self) -> usize {
2366            self.num_experts
2367        }
2368
2369        fn n_rows(&self) -> usize {
2370            self.rows
2371        }
2372
2373        fn n_cols(&self) -> usize {
2374            self.cols
2375        }
2376
2377        fn as_any(&self) -> &dyn std::any::Any {
2378            self
2379        }
2380
2381        fn gemv_moe_id(
2382            &self,
2383            _ctx: &mut <CpuBackend as Backend>::Context,
2384            _a: &<CpuBackend as Backend>::Buffer,
2385            _ids: &<CpuBackend as Backend>::Buffer,
2386            _out: &mut <CpuBackend as Backend>::Buffer,
2387            _n_selected: usize,
2388            _src1_stride: usize,
2389        ) -> ferrum_types::Result<()> {
2390            unimplemented!("num_experts test does not dispatch kernels")
2391        }
2392
2393        fn gemv_moe_id_offset(
2394            &self,
2395            _ctx: &mut <CpuBackend as Backend>::Context,
2396            _a: &<CpuBackend as Backend>::Buffer,
2397            _a_offset: usize,
2398            _ids: &<CpuBackend as Backend>::Buffer,
2399            _ids_offset: usize,
2400            _out: &mut <CpuBackend as Backend>::Buffer,
2401            _n_selected: usize,
2402            _src1_stride: usize,
2403        ) -> ferrum_types::Result<()> {
2404            unimplemented!("num_experts test does not dispatch kernels")
2405        }
2406
2407        fn gemv_moe_id_gate_up_silu(
2408            &self,
2409            _ctx: &mut <CpuBackend as Backend>::Context,
2410            _a: &<CpuBackend as Backend>::Buffer,
2411            _other_up: &dyn StackedExpertGgufLinear<CpuBackend>,
2412            _ids: &<CpuBackend as Backend>::Buffer,
2413            _silu_out: &mut <CpuBackend as Backend>::Buffer,
2414            _n_selected: usize,
2415        ) -> ferrum_types::Result<()> {
2416            unimplemented!("num_experts test does not dispatch kernels")
2417        }
2418
2419        fn gemv_moe_id_batched(
2420            &self,
2421            _ctx: &mut <CpuBackend as Backend>::Context,
2422            _a: &<CpuBackend as Backend>::Buffer,
2423            _ids: &<CpuBackend as Backend>::Buffer,
2424            _out: &mut <CpuBackend as Backend>::Buffer,
2425            _m: usize,
2426            _top_k: usize,
2427            _src1_outer_stride: usize,
2428            _src1_inner_stride: usize,
2429        ) -> ferrum_types::Result<()> {
2430            unimplemented!("num_experts test does not dispatch kernels")
2431        }
2432
2433        fn gemv_moe_id_gate_up_silu_batched(
2434            &self,
2435            _ctx: &mut <CpuBackend as Backend>::Context,
2436            _a: &<CpuBackend as Backend>::Buffer,
2437            _other_up: &dyn StackedExpertGgufLinear<CpuBackend>,
2438            _ids: &<CpuBackend as Backend>::Buffer,
2439            _silu_out: &mut <CpuBackend as Backend>::Buffer,
2440            _m: usize,
2441            _top_k: usize,
2442            _src1_outer_stride: usize,
2443            _src1_inner_stride: usize,
2444        ) -> ferrum_types::Result<()> {
2445            unimplemented!("num_experts test does not dispatch kernels")
2446        }
2447
2448        fn gemm_moe_id(
2449            &self,
2450            _ctx: &mut <CpuBackend as Backend>::Context,
2451            _a: &<CpuBackend as Backend>::Buffer,
2452            _ids: &<CpuBackend as Backend>::Buffer,
2453            _tpe: &<CpuBackend as Backend>::Buffer,
2454            _out: &mut <CpuBackend as Backend>::Buffer,
2455            _ne11: usize,
2456            _top_k: usize,
2457            _max_per_expert: usize,
2458            _batch: usize,
2459        ) -> ferrum_types::Result<()> {
2460            unimplemented!("num_experts test does not dispatch kernels")
2461        }
2462
2463        fn gemm_moe_id_indirect(
2464            &self,
2465            _ctx: &mut <CpuBackend as Backend>::Context,
2466            _src1: &<CpuBackend as Backend>::Buffer,
2467            _ids: &<CpuBackend as Backend>::Buffer,
2468            _tpe: &<CpuBackend as Backend>::Buffer,
2469            _out: &mut <CpuBackend as Backend>::Buffer,
2470            _args_buf: &<CpuBackend as Backend>::Buffer,
2471            _ne11: usize,
2472            _top_k: usize,
2473            _max_per_expert: usize,
2474            _batch: usize,
2475        ) -> ferrum_types::Result<()> {
2476            unimplemented!("num_experts test does not dispatch kernels")
2477        }
2478    }
2479
2480    fn fake_stacked(num_experts: usize) -> Box<dyn StackedExpertGgufLinear<CpuBackend>> {
2481        Box::new(FakeStackedGgufLinear {
2482            num_experts,
2483            rows: 4,
2484            cols: 4,
2485        })
2486    }
2487
2488    #[test]
2489    fn expert_stack_num_experts_uses_stacked_fast_path_count() {
2490        let experts = ExpertStack::<CpuBackend> {
2491            gate_up: Vec::new(),
2492            down: Vec::new(),
2493            gate_stacked: Some(fake_stacked(7)),
2494            up_stacked: Some(fake_stacked(7)),
2495            down_stacked: Some(fake_stacked(7)),
2496            gate_up_marlin_stack: None,
2497            down_marlin_stack: None,
2498        };
2499
2500        assert_eq!(experts.num_experts(), 7);
2501    }
2502
2503    #[test]
2504    fn moe_dispatch_runtime_config_parses_m3_startup_knobs() {
2505        let config = MoeDispatchRuntimeConfig::from_env_vars([
2506            ("FERRUM_MOE_PROFILE", "0"),
2507            ("FERRUM_DECODE_OP_PROFILE", "true"),
2508            ("FERRUM_VLLM_MOE_ZERO_WS", "1"),
2509            ("FERRUM_VLLM_MOE_PAIR_IDS", "1"),
2510            ("FERRUM_MOE_LOAD_TRACE", ""),
2511            ("FERRUM_MOE_BLOCK_SIZE", "8"),
2512            ("FERRUM_MOE_LARGE_M_BLOCK_SIZE", "64"),
2513            ("FERRUM_MOE_LARGE_M_MIN_PAIRS", "2048"),
2514            ("FERRUM_VLLM_MOE", "1"),
2515            ("FERRUM_MOE_HOST_ROUTE", "1"),
2516        ]);
2517
2518        assert!(config.moe_profile);
2519        assert!(config.decode_op_profile);
2520        assert!(config.vllm_moe_zero_ws);
2521        assert!(config.vllm_moe_pair_ids);
2522        assert!(config.moe_load_trace);
2523        assert_eq!(config.moe_block_size, Some(8));
2524        assert_eq!(config.moe_large_m_block_size, Some(64));
2525        assert_eq!(config.moe_large_m_min_pairs, 2048);
2526        assert!(config.vllm_moe);
2527        assert!(config.moe_host_route);
2528    }
2529
2530    #[test]
2531    fn drain_moe_bucket_profile_returns_and_clears_counters() {
2532        let _ = drain_moe_bucket_profile();
2533        MOE_BUCKET_LAYER_CALLS.store(2, Ordering::Relaxed);
2534        MOE_BUCKET_SYNC_US.store(3, Ordering::Relaxed);
2535        MOE_BUCKET_D2H_US.store(5, Ordering::Relaxed);
2536        MOE_BUCKET_ROUTE_US.store(7, Ordering::Relaxed);
2537        MOE_BUCKET_PLAN_US.store(11, Ordering::Relaxed);
2538        MOE_BUCKET_GATHER_US.store(13, Ordering::Relaxed);
2539        MOE_BUCKET_GEMM1_US.store(17, Ordering::Relaxed);
2540        MOE_BUCKET_SILU_US.store(19, Ordering::Relaxed);
2541        MOE_BUCKET_GEMM3_US.store(23, Ordering::Relaxed);
2542        MOE_BUCKET_COMBINE_US.store(29, Ordering::Relaxed);
2543
2544        let snapshot = drain_moe_bucket_profile();
2545        assert_eq!(snapshot.layers, 2);
2546        assert_eq!(snapshot.total_us(), 127);
2547        assert!(snapshot.has_layers());
2548
2549        let cleared = drain_moe_bucket_profile();
2550        assert_eq!(cleared.layers, 0);
2551        assert_eq!(cleared.total_us(), 0);
2552        assert!(!cleared.has_layers());
2553    }
2554
2555    #[test]
2556    fn moe_dispatch_runtime_config_bounds_invalid_block_values() {
2557        let config = MoeDispatchRuntimeConfig::from_env_vars([
2558            ("FERRUM_MOE_BLOCK_SIZE", "12"),
2559            ("FERRUM_MOE_LARGE_M_BLOCK_SIZE", "128"),
2560            ("FERRUM_MOE_LARGE_M_MIN_PAIRS", "bad"),
2561            ("FERRUM_VLLM_MOE_ZERO_WS", "true"),
2562            ("FERRUM_MOE_HOST_ROUTE", "0"),
2563        ]);
2564
2565        assert_eq!(config.moe_block_size, None);
2566        assert_eq!(config.moe_large_m_block_size, None);
2567        assert_eq!(config.moe_large_m_min_pairs, 1024);
2568        assert!(!config.vllm_moe_zero_ws);
2569        assert!(!config.moe_host_route);
2570    }
2571
2572    #[test]
2573    fn device_route_large_m_block_size_is_thresholded() {
2574        let config = MoeDispatchRuntimeConfig::from_env_vars([
2575            ("FERRUM_MOE_LARGE_M_BLOCK_SIZE", "64"),
2576            ("FERRUM_MOE_LARGE_M_MIN_PAIRS", "1024"),
2577        ]);
2578
2579        assert_eq!(
2580            pick_moe_block_size_with_config(&config, None, 128, true, 256),
2581            16
2582        );
2583        assert_eq!(
2584            pick_moe_block_size_with_config(&config, None, 128, true, 1024),
2585            64
2586        );
2587    }
2588
2589    #[test]
2590    fn global_moe_block_size_override_still_wins() {
2591        let config = MoeDispatchRuntimeConfig::from_env_vars([
2592            ("FERRUM_MOE_BLOCK_SIZE", "32"),
2593            ("FERRUM_MOE_LARGE_M_BLOCK_SIZE", "64"),
2594            ("FERRUM_MOE_LARGE_M_MIN_PAIRS", "1024"),
2595        ]);
2596
2597        assert_eq!(
2598            pick_moe_block_size_with_config(&config, None, 128, true, 2048),
2599            32
2600        );
2601    }
2602}