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/// At c=32 / Qwen3-MoE / 48 layers, the previous fresh-`Vec`-per-layer
1469/// pattern accounted for ~10 ms / token of pure CPU softmax+sort+alloc
1470/// (25% of MoE wallclock — see `docs/bench/cuda-rtx4090-2026-05-08-m3-moe`).
1471pub struct MoeRouteScratch {
1472    pub output: RouterOutput,
1473    /// Softmax buffer reused across all rows of all layers — sized to
1474    /// `num_experts` on first use.
1475    pub probs: Vec<f32>,
1476    pub plan: MoeBucketPlan,
1477}
1478
1479impl MoeRouteScratch {
1480    pub fn new() -> Self {
1481        Self {
1482            output: RouterOutput::empty(),
1483            probs: Vec::new(),
1484            plan: MoeBucketPlan::empty(),
1485        }
1486    }
1487}
1488
1489impl Default for MoeRouteScratch {
1490    fn default() -> Self {
1491        Self::new()
1492    }
1493}
1494
1495/// Bundle of pre-allocated device buffers for the graph-capturable
1496/// device-routing path in [`moe_forward_bucketed`]. Pass `Some` to
1497/// take the device path (under `FERRUM_MOE_DEVICE_ROUTE=1`); pass
1498/// `None` for the legacy host-mediated path (used by tests + the
1499/// non-vLLM CUDA bucketed path).
1500///
1501/// Pre-allocated on Qwen3MoeScratch (`route_pairs_dev` etc.) so the
1502/// per-layer call doesn't alloc inside a captured stream.
1503pub struct DeviceRouteScratch<'a, B: crate::moe::dispatch::Backend> {
1504    pub selected_ids: &'a mut B::Buffer,
1505    pub pair_weights: &'a mut B::Buffer,
1506    pub pairs_by_token: &'a mut B::Buffer,
1507    pub packed_token_idx: &'a mut B::Buffer,
1508    pub expert_offsets: &'a mut B::Buffer,
1509    // Phase 2: moe_align_block_size outputs for the vLLM marlin_moe
1510    // fused GEMM path. Same shape as host `vllm_routing` builder
1511    // produces, but device-resident.
1512    pub sorted_tokens: &'a mut B::Buffer,
1513    pub block_ids: &'a mut B::Buffer,
1514    pub total_post_pad: &'a mut B::Buffer,
1515}
1516
1517/// Bucketed MoE forward: gather → per-expert m=N Marlin GEMM → silu_mul →
1518/// per-expert m=N Marlin GEMM → moe_combine.
1519///
1520/// Replaces the `batch × top_k` m=1 dispatch loop in [`moe_forward`] with
1521/// `num_active_experts × 2` m=tokens_per_expert dispatches. For prefill
1522/// (m=512+), this is a 30× reduction in GEMM launches AND each GEMM runs
1523/// at a much more efficient m than the m=1 path. For decode (m=1), the
1524/// number of dispatches is similar but we still benefit from the
1525/// gather/combine kernel pattern (one launch each instead of 2 per pair).
1526///
1527/// **Requires**: scratch buffers `x_packed [total_pairs, hidden]`,
1528/// `gate_up_packed [total_pairs, 2*expert_inter]`,
1529/// `silu_packed [total_pairs, expert_inter]`, and
1530/// `down_packed [total_pairs, hidden]` provisioned by the caller. The
1531/// caller is responsible for sizing these to `batch * top_k` rows
1532/// (worst-case all top_k pairs alive).
1533pub struct MoeForwardBucketedParams<'a, B: QuantLlmBackend + BackendMoeFused> {
1534    pub ctx: &'a mut B::Context,
1535    pub x: &'a B::Buffer,
1536    pub router_logits: &'a B::Buffer,
1537    pub out: &'a mut B::Buffer,
1538    pub batch: usize,
1539    pub hidden_size: usize,
1540    pub expert_intermediate: usize,
1541    pub num_experts: usize,
1542    pub top_k: usize,
1543    pub norm_topk_prob: bool,
1544    pub experts: &'a ExpertStack<B>,
1545    pub x_packed: &'a mut B::Buffer,
1546    pub gate_up_packed: &'a mut B::Buffer,
1547    pub silu_packed: &'a mut B::Buffer,
1548    pub down_packed: &'a mut B::Buffer,
1549    pub route_scratch: &'a mut MoeRouteScratch,
1550    pub profile_bucket: bool,
1551    // Optional device routing scratch — when Some AND
1552    // FERRUM_MOE_DEVICE_ROUTE=1 AND FERRUM_VLLM_MOE=1, runs the
1553    // graph-capturable device-routing branch. None / unset = legacy
1554    // host-mediated path (used by tests + non-vLLM path).
1555    pub device_route: Option<DeviceRouteScratch<'a, B>>,
1556}
1557
1558pub fn moe_forward_bucketed<B: QuantLlmBackend + BackendMoeFused>(
1559    params: MoeForwardBucketedParams<'_, B>,
1560) -> Result<()> {
1561    let MoeForwardBucketedParams {
1562        ctx,
1563        x,
1564        router_logits,
1565        out,
1566        batch,
1567        hidden_size,
1568        expert_intermediate,
1569        num_experts,
1570        top_k,
1571        norm_topk_prob,
1572        experts,
1573        x_packed,
1574        gate_up_packed,
1575        silu_packed,
1576        down_packed,
1577        route_scratch,
1578        profile_bucket,
1579        device_route,
1580    } = params;
1581    if experts.num_experts() != num_experts {
1582        return Err(FerrumError::model(format!(
1583            "moe_forward_bucketed: experts {} != num_experts {num_experts}",
1584            experts.num_experts()
1585        )));
1586    }
1587
1588    let runtime_config = moe_dispatch_runtime_config();
1589    // Bucket profiling fires on either FERRUM_MOE_PROFILE=1 (legacy)
1590    // or FERRUM_DECODE_OP_PROFILE=1 (the gate the print site uses).
1591    let prof = profile_bucket || runtime_config.moe_profile || runtime_config.decode_op_profile;
1592    if prof {
1593        MOE_BUCKET_LAYER_CALLS.fetch_add(1, Ordering::Relaxed);
1594    }
1595
1596    // ── Device-route fast path (opt-in via FERRUM_MOE_DEVICE_ROUTE=1
1597    //     + FERRUM_VLLM_MOE=1 + device_route Some) ────────────────────
1598    //
1599    // Skips ALL host round-trips in the routing + bucket-plan stages:
1600    //   1. B::route_topk_softmax → device expert_ids + weights
1601    //   2. B::moe_build_pairs_by_token → device pairs / packed_idx /
1602    //      expert_offsets
1603    //   3. Gather via B::embedding_lookup_dev (device packed_idx)
1604    //   4. (rest of function reuses these device buffers; the vLLM
1605    //      MoE GEMM consumes them directly)
1606    //
1607    // This is the prerequisite for CUDA Graph capture over the MoE
1608    // layer loop in Qwen3MoeModel::decode_batch_internal.
1609    let stack_requires_vllm = experts
1610        .gate_up_marlin_stack
1611        .as_ref()
1612        .is_some_and(|stack| stack.requires_vllm_moe())
1613        || experts
1614            .down_marlin_stack
1615            .as_ref()
1616            .is_some_and(|stack| stack.requires_vllm_moe());
1617    let use_vllm_moe = runtime_config.vllm_moe || stack_requires_vllm;
1618    // Device-routing path: enabled whenever the caller passes
1619    // pre-allocated `DeviceRouteScratch` AND the vLLM MoE path is selected
1620    // by runtime config or by the loaded weight stack. No separate env var
1621    // — the device path is strictly faster than
1622    // the host path (+15.4% c=32 on Qwen3-30B-A3B-GPTQ-Int4, RTX 4090
1623    // bench docs/bench/moe-phase3-vast-2026-05-12); the host path's
1624    // per-layer `try_gpu_route_topk_into_host` (D2H + cuStreamSynchronize)
1625    // was a per-layer GPU stall that compounded over 48 layers.
1626    //
1627    // Requires use_vllm_moe because the non-vLLM bucketed path needs
1628    // host phase1_dispatches / phase3_dispatches lists (one entry per
1629    // active expert with expert-id-dependent shape), which can't be
1630    // built on-device.
1631    //
1632    // Callers that need to force the host path for diagnostics can set
1633    // FERRUM_MOE_HOST_ROUTE=1 (opt-out).
1634    let use_device_route = device_route.is_some() && use_vllm_moe && !runtime_config.moe_host_route;
1635    let use_vllm_pair_ids = use_device_route && runtime_config.vllm_moe_pair_ids;
1636
1637    // Run device-side routing kernels EARLY so `dr.packed_token_idx`
1638    // is available for the device-buffer gather (embedding_lookup_dev),
1639    // `dr.pairs_by_token` / `dr.pair_weights` for moe_combine, and
1640    // `dr.sorted_tokens` / `dr.block_ids` / `dr.total_post_pad` (via
1641    // moe_align_block_size below) for the vLLM marlin_moe GEMM phases.
1642    // Kept alive in `dr_kept` until end of function.
1643    let mut dr_kept: Option<DeviceRouteScratch<'_, B>> = if use_device_route {
1644        let dr = device_route.expect("device_route is Some when use_device_route");
1645        B::route_topk_softmax(
1646            ctx,
1647            router_logits,
1648            dr.selected_ids,
1649            dr.pair_weights,
1650            batch,
1651            num_experts,
1652            top_k,
1653            norm_topk_prob,
1654        )?;
1655        if !use_vllm_pair_ids {
1656            B::moe_build_pairs_by_token(
1657                ctx,
1658                dr.selected_ids,
1659                dr.pairs_by_token,
1660                dr.packed_token_idx,
1661                dr.expert_offsets,
1662                batch * top_k,
1663                num_experts,
1664                top_k,
1665            )?;
1666        }
1667        Some(dr)
1668    } else {
1669        None
1670    };
1671
1672    // ── Routing + bucket plan (host) ─────────────────────────────────
1673    //
1674    // Skipped entirely under use_device_route — the device kernels run
1675    // by `dr_kept` above produce equivalent on-device buffers. The host
1676    // path stays for the legacy non-vllm bucketed dispatch and for
1677    // tests (where device_route is None).
1678    //
1679    // GPU fast-path: `try_gpu_route_topk_into_host` runs the same
1680    // route_topk_softmax kernel and D2Hs only `[batch, top_k]` ids +
1681    // weights (~1 KB at c=32) into RouterOutput. Host fallback covers
1682    // backends without the override (CPU / Metal / future).
1683    let plan: Option<&crate::moe::MoeBucketPlan> = if !use_device_route {
1684        let t_route_total = if prof {
1685            Some(std::time::Instant::now())
1686        } else {
1687            None
1688        };
1689        let gpu_route = B::try_gpu_route_topk_into_host(
1690            ctx,
1691            router_logits,
1692            &mut route_scratch.output.expert_ids,
1693            &mut route_scratch.output.expert_weights,
1694            batch,
1695            num_experts,
1696            top_k,
1697            norm_topk_prob,
1698        );
1699        if gpu_route.is_err() {
1700            let t_sync = if prof {
1701                Some(std::time::Instant::now())
1702            } else {
1703                None
1704            };
1705            B::sync(ctx);
1706            if let Some(t) = t_sync {
1707                MOE_BUCKET_SYNC_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1708            }
1709            let t_d2h = if prof {
1710                Some(std::time::Instant::now())
1711            } else {
1712                None
1713            };
1714            let logits_host = B::to_vec(router_logits, batch * num_experts);
1715            if let Some(t) = t_d2h {
1716                MOE_BUCKET_D2H_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1717            }
1718            let t_route = if prof {
1719                Some(std::time::Instant::now())
1720            } else {
1721                None
1722            };
1723            crate::moe::router::route_into(
1724                &logits_host,
1725                batch,
1726                num_experts,
1727                top_k,
1728                norm_topk_prob,
1729                &mut route_scratch.output,
1730                &mut route_scratch.probs,
1731            );
1732            if let Some(t) = t_route {
1733                MOE_BUCKET_ROUTE_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1734            }
1735        } else if let Some(t) = t_route_total {
1736            MOE_BUCKET_ROUTE_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1737        }
1738        let t_plan = if prof {
1739            Some(std::time::Instant::now())
1740        } else {
1741            None
1742        };
1743        route_scratch
1744            .plan
1745            .rebuild_into(&route_scratch.output, batch, num_experts, top_k);
1746        if let Some(t) = t_plan {
1747            MOE_BUCKET_PLAN_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1748        }
1749        Some(&route_scratch.plan)
1750    } else {
1751        None
1752    };
1753
1754    // ── Gather: x_packed[i] = x[packed_token_idx[i]] ───────────────────
1755    // Under use_device_route, read packed_token_idx from device (no
1756    // host roundtrip → graph-capturable). Else use the host plan.
1757    if !use_vllm_pair_ids {
1758        let t_gather = if prof {
1759            Some(std::time::Instant::now())
1760        } else {
1761            None
1762        };
1763        if let Some(ref dr) = dr_kept {
1764            B::embedding_lookup_dev(
1765                ctx,
1766                x,
1767                dr.packed_token_idx,
1768                x_packed,
1769                batch * top_k,
1770                hidden_size,
1771            );
1772        } else {
1773            let plan = plan.expect("plan is Some when !use_device_route");
1774            B::embedding_lookup(ctx, x, &plan.packed_token_idx, x_packed, hidden_size);
1775        }
1776        if let Some(t) = t_gather {
1777            B::sync(ctx);
1778            MOE_BUCKET_GATHER_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
1779        }
1780    }
1781
1782    // ── Per-expert dispatch: gate_up + down GEMMs at m=tokens_per_expert
1783    //
1784    // Uses the strided GPTQ + silu_mul methods so we can pump through
1785    // the BIG packed buffers (allocated once at scratch alloc) without
1786    // any per-expert copies. Each expert gets its column-slice of the
1787    // shared stacked Marlin tile via expert_offset; the row-slice of
1788    // the packed input/output buffers via in_row_offset / out_row_offset.
1789    let gate_up_dim_per_expert = 2 * expert_intermediate;
1790    let down_n_per_expert = hidden_size;
1791    // Bulk-zero the gate_up workspace ONCE before phase 1 for the
1792    // non-vLLM Marlin paths. The vLLM marlin_moe_wna16 kernel resets
1793    // its lock slots internally on the reduce path; vLLM itself only
1794    // zeros this workspace at allocation time. Keep
1795    // FERRUM_VLLM_MOE_ZERO_WS=1 as an A/B escape hatch.
1796    let gu_store = experts.gate_up_stacked_store(0).ok_or_else(|| {
1797        FerrumError::model(
1798            "moe_forward_bucketed requires stacked gate_up store \
1799             (load via Qwen3MoeModel::new_safetensors)",
1800        )
1801    })?;
1802    let zero_marlin_workspace = !use_vllm_moe || runtime_config.vllm_moe_zero_ws;
1803    if zero_marlin_workspace {
1804        let _ = gu_store.zero_workspace(ctx);
1805    }
1806
1807    // Decide path: vLLM marlin_moe_wna16 fused or per-expert bucketed
1808    // GEMMs. Under use_device_route, build routing on-device via
1809    // `moe_align_block_size`; under use_vllm_moe alone, host-build it.
1810    // Either way the GEMM dispatcher takes `&Buffer` for the 3 routing
1811    // arrays.
1812    let total_pairs_active = batch * top_k;
1813    // ── Dynamic moe_block_size policy ────────────────────────────────────
1814    //
1815    // Marlin-MoE kernel template is instantiated for thread_m_blocks ∈
1816    // {1, 2, 3, 4} via COMMON_GET_IF_M1 / COMMON_GET_IF_M234 in
1817    // the native vLLM Marlin MoE artifact. Each maps to block_size = thread_m_blocks
1818    // × 16 ∈ {16, 32, 48, 64}. Picking the right one is a classic
1819    // throughput-vs-padding-waste tradeoff:
1820    //
1821    //   block_size=16 :  16 thread_n=128 num_threads=128 (small_batch tile)
1822    //   block_size=32+:  16 thread_n=256 num_threads=256 (large_batch tile,
1823    //                    8× more work per kernel launch)
1824    //
1825    // Larger tile = more arithmetic per memory load = higher DRAM
1826    // utilization. But each expert pads its actual token count up to
1827    // a multiple of block_size — sparse routing (many experts, few
1828    // tokens each) bleeds into massive padding waste.
1829    //
1830    // Test data (commit ccba35f static block=64 vs reverted block=16):
1831    //   bench/v0.2-cuda dmon @ c=32:
1832    //     block=16  →  SM=99%  DRAM=50%  (mem-stalled, tile too small)
1833    //     block=64  →  varies wildly:
1834    //                    same-prompt c=32  : 2078 tok/s (+100% vs block=16)
1835    //                    apples c=32 diverse: 921 tok/s (-11% vs block=16)
1836    //
1837    // Decision rule: pick the largest block_size whose padding overhead
1838    // would still be ≤ ~30%. The host-routing path has `plan.expert_offsets`
1839    // which gives exact m_e per expert — pick by actual data. The
1840    // device-routing path doesn't have host visibility so falls back to
1841    // a conservative 16 (matches pre-PR behaviour, no regression).
1842    //
1843    // Worst-case scratch sizing: 64 (the largest block_size we'd pick).
1844    // `Qwen3MoeScratch.route_sorted_tokens_dev` capacity is allocated
1845    // assuming this upper bound.
1846    let max_block_size: usize = 64;
1847    let moe_block_size: usize = pick_moe_block_size_with_config(
1848        runtime_config,
1849        plan,
1850        num_experts,
1851        use_device_route,
1852        total_pairs_active,
1853    );
1854    debug_assert!(
1855        moe_block_size <= max_block_size,
1856        "moe_block_size {moe_block_size} exceeds scratch worst-case {max_block_size}"
1857    );
1858    // sorted_max bound — passed to moe_align as a runtime cap so it
1859    // never writes past `total_padded` for the chosen block_size. We use
1860    // the picked `moe_block_size`, not `max_block_size`, so moe_align
1861    // doesn't sentinel-fill the slack between the actual padded count
1862    // and the worst-case buffer capacity (saves ~6 KB of writes per
1863    // layer × 48 layers × 32 layer-loop iters when block_size lands at 16).
1864    // The buffer itself is sized for max_block_size in qwen3_moe.rs.
1865    let sorted_max_size = batch * top_k + num_experts * moe_block_size;
1866    let vllm_routing_owned: Option<ferrum_kernels::backend::MoeRouting<B>> =
1867        if use_vllm_moe && !use_device_route {
1868            let plan = plan.expect("plan is Some when host vllm builder runs");
1869            let mut padded_offsets = Vec::with_capacity(num_experts + 1);
1870            let mut acc = 0usize;
1871            for e in 0..num_experts {
1872                padded_offsets.push(acc);
1873                let m_e = plan.expert_offsets[e + 1] - plan.expert_offsets[e];
1874                let pe = m_e.div_ceil(moe_block_size) * moe_block_size;
1875                acc += pe;
1876            }
1877            padded_offsets.push(acc);
1878            let total_padded = acc;
1879            let total_blocks = total_padded / moe_block_size;
1880            let sentinel = total_pairs_active as i32;
1881
1882            let mut sorted_token_ids = vec![sentinel; total_padded];
1883            let mut expert_ids = vec![0i32; total_blocks];
1884            for e in 0..num_experts {
1885                let m_e = plan.expert_offsets[e + 1] - plan.expert_offsets[e];
1886                if m_e == 0 {
1887                    continue;
1888                }
1889                let p_off = padded_offsets[e];
1890                let real_off = plan.expert_offsets[e];
1891                for i in 0..m_e {
1892                    sorted_token_ids[p_off + i] = (real_off + i) as i32;
1893                }
1894                let blocks_for_e = (padded_offsets[e + 1] - p_off) / moe_block_size;
1895                let block_start = p_off / moe_block_size;
1896                for b in 0..blocks_for_e {
1897                    expert_ids[block_start + b] = e as i32;
1898                }
1899            }
1900            let num_tokens_past_padded = vec![total_padded as i32];
1901            Some(B::upload_moe_routing(
1902                ctx,
1903                &sorted_token_ids,
1904                &expert_ids,
1905                &num_tokens_past_padded,
1906            )?)
1907        } else {
1908            None
1909        };
1910
1911    // Device-side moe_align_block_size — under use_device_route, fill
1912    // dr.{sorted_tokens, block_ids, total_post_pad} on device from
1913    // dr.selected_ids. No host roundtrip → captures cleanly.
1914    if use_device_route {
1915        let dr = dr_kept
1916            .as_mut()
1917            .expect("dr_kept is Some when use_device_route");
1918        if use_vllm_pair_ids {
1919            B::moe_align_block_size_pair_ids(
1920                ctx,
1921                dr.selected_ids,
1922                dr.sorted_tokens,
1923                dr.block_ids,
1924                dr.total_post_pad,
1925                batch * top_k,
1926                num_experts,
1927                moe_block_size,
1928                sorted_max_size,
1929            )?;
1930        } else {
1931            B::moe_align_block_size(
1932                ctx,
1933                dr.selected_ids,
1934                dr.sorted_tokens,
1935                dr.block_ids,
1936                dr.total_post_pad,
1937                batch * top_k,
1938                num_experts,
1939                moe_block_size,
1940                sorted_max_size,
1941            )?;
1942        }
1943    }
1944
1945    // Resolve the 3 routing buffers for vLLM phase 1/3 GEMM. Either
1946    // from dr_kept (device-built by moe_align_block_size) or from
1947    // vllm_routing_owned (host-built + uploaded). None → use legacy
1948    // per-expert batched GEMM path.
1949    let vllm_refs: Option<(&B::Buffer, &B::Buffer, &B::Buffer)> = if use_device_route {
1950        let dr = dr_kept
1951            .as_ref()
1952            .expect("dr_kept is Some when use_device_route");
1953        Some((&*dr.sorted_tokens, &*dr.block_ids, &*dr.total_post_pad))
1954    } else if let Some(r) = vllm_routing_owned.as_ref() {
1955        Some((
1956            &r.sorted_token_ids,
1957            &r.expert_ids,
1958            &r.num_tokens_past_padded,
1959        ))
1960    } else {
1961        None
1962    };
1963
1964    // Phase 1/3 batched-GEMM dispatch lists. Only built (and read) for
1965    // the non-vLLM path. Under use_device_route the host plan is None
1966    // anyway, so we'd fail to build them — skip via vllm_refs.is_some.
1967    let phase1_dispatches: Vec<(usize, usize, usize, usize)> = if vllm_refs.is_none() {
1968        let plan = plan.expect("plan is Some when batched GEMM path runs");
1969        let mut v: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(num_experts);
1970        for e in 0..num_experts {
1971            let m_e = plan.expert_offsets[e + 1] - plan.expert_offsets[e];
1972            if m_e == 0 {
1973                continue;
1974            }
1975            let pair_off = plan.expert_offsets[e];
1976            v.push((e, pair_off, pair_off, m_e));
1977        }
1978        v.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
1979        v
1980    } else {
1981        Vec::new()
1982    };
1983    let t_gemm1 = if prof {
1984        Some(std::time::Instant::now())
1985    } else {
1986        None
1987    };
1988    if let Some((sorted_tokens, block_ids, total_post_pad)) = vllm_refs {
1989        // fp32_reduce path: kernel writes C directly via global reduce.
1990        #[cfg(feature = "cuda")]
1991        let _marlin_label = if ferrum_kernels::backend::cuda::marlin::profile_marlin() {
1992            Some(ferrum_kernels::backend::cuda::push_alloc_label(
1993                "moe.vllm.gate_up_proj",
1994            ))
1995        } else {
1996            None
1997        };
1998        if use_vllm_pair_ids {
1999            gu_store.gemm_phase_vllm(
2000                ctx,
2001                x,
2002                sorted_tokens,
2003                block_ids,
2004                total_post_pad,
2005                gate_up_packed,
2006                batch,
2007                moe_block_size,
2008                top_k,
2009            )?;
2010        } else {
2011            gu_store.gemm_phase_vllm(
2012                ctx,
2013                x_packed,
2014                sorted_tokens,
2015                block_ids,
2016                total_post_pad,
2017                gate_up_packed,
2018                total_pairs_active,
2019                moe_block_size,
2020                1, // top_k=1: pre-gathered rows already index packed input directly
2021            )?;
2022        }
2023    } else {
2024        gu_store.gemm_phase_batched(
2025            ctx,
2026            x_packed,
2027            &phase1_dispatches,
2028            gate_up_packed,
2029            hidden_size,
2030        )?;
2031    }
2032    if let Some(t) = t_gemm1 {
2033        B::sync(ctx);
2034        MOE_BUCKET_GEMM1_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
2035    }
2036
2037    // Phase 2: SiLU(gate) * up — single launch covering ALL active
2038    // expert rows in the packed buffer. The unused rows (zeros from
2039    // experts with m_e=0) just produce zeros that the combine step
2040    // ignores via pairs_by_token. Saves num_active_experts-1 launches
2041    // per layer.
2042    let total_pairs_active = batch * top_k;
2043    let t_silu = if prof {
2044        Some(std::time::Instant::now())
2045    } else {
2046        None
2047    };
2048    B::fused_silu_mul_split(
2049        ctx,
2050        gate_up_packed,
2051        silu_packed,
2052        total_pairs_active,
2053        expert_intermediate,
2054    );
2055    if let Some(t) = t_silu {
2056        B::sync(ctx);
2057        MOE_BUCKET_SILU_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
2058    }
2059
2060    // Phase 3: down GEMM per active expert. Multi-stream batched.
2061    let d_store = experts.down_stacked_store(0).ok_or_else(|| {
2062        FerrumError::model(
2063            "moe_forward_bucketed requires stacked down store \
2064             (load via Qwen3MoeModel::new_safetensors)",
2065        )
2066    })?;
2067    if zero_marlin_workspace {
2068        let _ = d_store.zero_workspace(ctx);
2069    }
2070    let phase3_dispatches: Vec<(usize, usize, usize, usize)> = if vllm_refs.is_none() {
2071        let plan = plan.expect("plan is Some when batched GEMM path runs");
2072        let mut v: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(num_experts);
2073        for e in 0..num_experts {
2074            let m_e = plan.expert_offsets[e + 1] - plan.expert_offsets[e];
2075            if m_e == 0 {
2076                continue;
2077            }
2078            let pair_off = plan.expert_offsets[e];
2079            v.push((e, pair_off, pair_off, m_e));
2080        }
2081        v.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
2082        v
2083    } else {
2084        Vec::new()
2085    };
2086    let t_gemm3 = if prof {
2087        Some(std::time::Instant::now())
2088    } else {
2089        None
2090    };
2091    if let Some((sorted_tokens, block_ids, total_post_pad)) = vllm_refs {
2092        #[cfg(feature = "cuda")]
2093        let _marlin_label = if ferrum_kernels::backend::cuda::marlin::profile_marlin() {
2094            Some(ferrum_kernels::backend::cuda::push_alloc_label(
2095                "moe.vllm.down_proj",
2096            ))
2097        } else {
2098            None
2099        };
2100        d_store.gemm_phase_vllm(
2101            ctx,
2102            silu_packed,
2103            sorted_tokens,
2104            block_ids,
2105            total_post_pad,
2106            down_packed,
2107            total_pairs_active,
2108            moe_block_size,
2109            1,
2110        )?;
2111    } else {
2112        d_store.gemm_phase_batched(
2113            ctx,
2114            silu_packed,
2115            &phase3_dispatches,
2116            down_packed,
2117            expert_intermediate,
2118        )?;
2119    }
2120    if let Some(t) = t_gemm3 {
2121        B::sync(ctx);
2122        MOE_BUCKET_GEMM3_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
2123    }
2124
2125    // ── Combine: out[b, h] = Σ_k weights[b,k] * down_packed[pairs_by_token[b,k], h]
2126    //
2127    // Two paths for the pairs/weights device buffers:
2128    //
2129    //   (a) device-route mode: reuse `dr_kept` populated up top by
2130    //       B::route_topk_softmax + B::moe_build_pairs_by_token. No
2131    //       host→device upload, so this is graph-capturable when
2132    //       wrapped in begin_graph_capture.
2133    //
2134    //   (b) legacy: upload host plan (plan.pairs_by_token /
2135    //       plan.pair_weights) via from_slice_typed. Records host
2136    //       pointer; captures stale on replay.
2137    //
2138    // Both produce mathematically equivalent outputs — device path
2139    // does the same counting-sort the host plan rebuild does, just
2140    // on-device via the moe_build_pairs kernel.
2141    let total_pairs = batch * top_k;
2142    let t_comb = if prof {
2143        Some(std::time::Instant::now())
2144    } else {
2145        None
2146    };
2147    if use_vllm_pair_ids {
2148        let dr = dr_kept
2149            .as_ref()
2150            .expect("dr_kept is Some when use_vllm_pair_ids");
2151        B::weighted_sum_batched(
2152            ctx,
2153            down_packed,
2154            dr.pair_weights,
2155            out,
2156            batch,
2157            top_k,
2158            hidden_size,
2159        )?;
2160    } else {
2161        let (pairs_ref, weights_ref);
2162        let _pairs_owned;
2163        let _weights_owned;
2164        if let Some(ref dr) = dr_kept {
2165            pairs_ref = &*dr.pairs_by_token;
2166            weights_ref = &*dr.pair_weights;
2167        } else {
2168            let plan = plan.expect("plan is Some when host moe_combine runs");
2169            _pairs_owned = B::from_slice_typed::<i32>(&plan.pairs_by_token);
2170            _weights_owned = B::from_slice_typed::<f32>(&plan.pair_weights);
2171            pairs_ref = &_pairs_owned;
2172            weights_ref = &_weights_owned;
2173        }
2174        B::moe_combine(
2175            ctx,
2176            down_packed,
2177            pairs_ref,
2178            weights_ref,
2179            out,
2180            batch,
2181            hidden_size,
2182            top_k,
2183            total_pairs,
2184        );
2185    }
2186    if let Some(t) = t_comb {
2187        B::sync(ctx);
2188        MOE_BUCKET_COMBINE_US.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
2189    }
2190
2191    Ok(())
2192}
2193
2194/// Run MoE forward on CPU.
2195///
2196/// Inputs:
2197///   - `x`: `[batch, hidden_size]` row-major hidden states (post-attention,
2198///          post-residual — i.e. what the dense MLP would normally see).
2199///   - `router`: top-K assignments + weights from [`super::router::route`].
2200///   - `experts`: per-layer expert weights from [`ExpertStack::load_from_gguf`].
2201///
2202/// Output:
2203///   - `out`: `[batch, hidden_size]`. Resized + zero-initialised.
2204///
2205/// The function recomputes its scratch buffers each call. For tight
2206/// inner loops, callers will eventually want a pre-allocated workspace
2207/// (Phase 2F refactor). For now, this is the readable reference.
2208pub fn moe_forward_cpu(
2209    x: &[f32],
2210    batch: usize,
2211    hidden_size: usize,
2212    expert_intermediate: usize,
2213    top_k: usize,
2214    router: &RouterOutput,
2215    experts: &ExpertStack<CpuBackend>,
2216    out: &mut Vec<f32>,
2217) -> Result<()> {
2218    let n_experts = experts.num_experts();
2219
2220    if x.len() != batch * hidden_size {
2221        return Err(FerrumError::model(format!(
2222            "moe_forward_cpu: x len {} doesn't match batch*hidden = {}*{} = {}",
2223            x.len(),
2224            batch,
2225            hidden_size,
2226            batch * hidden_size
2227        )));
2228    }
2229    if router.expert_ids.len() != batch * top_k {
2230        return Err(FerrumError::model(format!(
2231            "moe_forward_cpu: router has {} expert_ids but expected batch*top_k = {}*{} = {}",
2232            router.expert_ids.len(),
2233            batch,
2234            top_k,
2235            batch * top_k
2236        )));
2237    }
2238
2239    out.clear();
2240    out.resize(batch * hidden_size, 0.0);
2241
2242    let mut ctx = <CpuBackend as Backend>::new_context();
2243    let mut x_b: Vec<f32> = vec![0.0; hidden_size];
2244    let mut gate_up_buf: Vec<f32> = vec![0.0; 2 * expert_intermediate];
2245    let mut silu_mul_buf: Vec<f32> = vec![0.0; expert_intermediate];
2246    let mut down_out: Vec<f32> = vec![0.0; hidden_size];
2247
2248    for b in 0..batch {
2249        x_b.copy_from_slice(&x[b * hidden_size..(b + 1) * hidden_size]);
2250
2251        for k in 0..top_k {
2252            let pair_idx = b * top_k + k;
2253            let expert_id = router.expert_ids[pair_idx] as usize;
2254            let weight = router.expert_weights[pair_idx];
2255
2256            if expert_id >= n_experts {
2257                return Err(FerrumError::model(format!(
2258                    "moe_forward_cpu: router selected expert {expert_id} >= num_experts {n_experts}"
2259                )));
2260            }
2261
2262            // Gate||Up projection (fused) → [1, 2*expert_inter]
2263            experts.gate_up[expert_id].forward(&mut ctx, &x_b, &mut gate_up_buf, 1);
2264
2265            // SiLU(gate) * up → [1, expert_inter]
2266            <CpuBackend as Backend>::fused_silu_mul_split(
2267                &mut ctx,
2268                &gate_up_buf,
2269                &mut silu_mul_buf,
2270                1,
2271                expert_intermediate,
2272            );
2273
2274            // Down projection → [1, hidden]
2275            experts.down[expert_id].forward(&mut ctx, &silu_mul_buf, &mut down_out, 1);
2276
2277            // Weighted accumulate into out[b, :]. Done host-side because
2278            // CpuBackend::Buffer = Vec<f32> and the trait doesn't yet
2279            // expose scaled-add.
2280            let out_row = &mut out[b * hidden_size..(b + 1) * hidden_size];
2281            for (o, d) in out_row.iter_mut().zip(down_out.iter()) {
2282                *o += weight * *d;
2283            }
2284        }
2285    }
2286
2287    Ok(())
2288}
2289
2290fn check_size(actual: usize, expected: usize, label: &str) -> Result<()> {
2291    if actual != expected {
2292        return Err(FerrumError::model(format!(
2293            "ExpertStack: {label} size mismatch (got {actual}, expected {expected})"
2294        )));
2295    }
2296    Ok(())
2297}
2298
2299/// Map candle's `GgmlDType` to the kernel-side `GgufQuantType` for the
2300/// dtypes a backend can dispatch on. Returns `None` for any other dtype
2301/// (callers fall back to eager dequant).
2302fn quant_kind(gguf: &GgufFile, name: &str) -> Result<Option<GgufQuantType>> {
2303    let info = gguf.tensor_info(name).ok_or_else(|| {
2304        FerrumError::model(format!("ExpertStack: tensor info missing for '{name}'"))
2305    })?;
2306    Ok(match info.ggml_dtype {
2307        GgmlDType::Q4K => Some(GgufQuantType::Q4K),
2308        GgmlDType::Q6K => Some(GgufQuantType::Q6K),
2309        _ => None,
2310    })
2311}
2312
2313/// Per-expert block-byte count for a given k-quant flavour and element
2314/// count. Q4_K = 144 B / 256 elems, Q6_K = 210 B / 256 elems. Errors if
2315/// `n_elems` is not a multiple of the super-block size (256) — a Q-quant
2316/// invariant.
2317fn block_bytes_for(kind: GgufQuantType, n_elems: usize, label: &str) -> Result<usize> {
2318    const QK_K: usize = 256;
2319    if n_elems % QK_K != 0 {
2320        return Err(FerrumError::model(format!(
2321            "ExpertStack {label}: per-expert element count {n_elems} not a multiple of {QK_K}"
2322        )));
2323    }
2324    let block_bytes = match kind {
2325        GgufQuantType::Q4K => 144,
2326        GgufQuantType::Q6K => 210,
2327        // Other k-quants are filtered out earlier via `quant_kind`; reaching here
2328        // with one would be a programming error.
2329        other => {
2330            return Err(FerrumError::model(format!(
2331                "ExpertStack {label}: unsupported k-quant flavour {other:?}"
2332            )))
2333        }
2334    };
2335    Ok((n_elems / QK_K) * block_bytes)
2336}
2337
2338fn read_dequant_flat(gguf: &GgufFile, name: &str, device: &Device) -> Result<Vec<f32>> {
2339    let qt = gguf.read_tensor(name, device).map_err(candle_to_ferrum)?;
2340    let dense = qt.dequantize(device).map_err(candle_to_ferrum)?;
2341    let flat = dense.flatten_all().map_err(candle_to_ferrum)?;
2342    flat.to_vec1::<f32>().map_err(candle_to_ferrum)
2343}
2344
2345fn candle_to_ferrum(e: candle_core::Error) -> FerrumError {
2346    FerrumError::model(format!("candle: {e}"))
2347}
2348
2349// Suppress unused-import warning when this module compiles standalone in
2350// the lib (the candle Result alias is only used via map_err in Phase 2).
2351#[allow(dead_code)]
2352type _CandleResult<T> = CandleResult<T>;
2353
2354#[cfg(test)]
2355mod tests {
2356    use std::sync::atomic::Ordering;
2357
2358    use ferrum_kernels::backend::cpu::CpuBackend;
2359    use ferrum_kernels::backend::Backend;
2360    use ferrum_kernels::StackedExpertGgufLinear;
2361
2362    use super::{
2363        drain_moe_bucket_profile, pick_moe_block_size_with_config, ExpertStack,
2364        MoeDispatchRuntimeConfig, MOE_BUCKET_COMBINE_US, MOE_BUCKET_D2H_US, MOE_BUCKET_GATHER_US,
2365        MOE_BUCKET_GEMM1_US, MOE_BUCKET_GEMM3_US, MOE_BUCKET_LAYER_CALLS, MOE_BUCKET_PLAN_US,
2366        MOE_BUCKET_ROUTE_US, MOE_BUCKET_SILU_US, MOE_BUCKET_SYNC_US,
2367    };
2368
2369    struct FakeStackedGgufLinear {
2370        num_experts: usize,
2371        rows: usize,
2372        cols: usize,
2373    }
2374
2375    impl StackedExpertGgufLinear<CpuBackend> for FakeStackedGgufLinear {
2376        fn num_experts(&self) -> usize {
2377            self.num_experts
2378        }
2379
2380        fn n_rows(&self) -> usize {
2381            self.rows
2382        }
2383
2384        fn n_cols(&self) -> usize {
2385            self.cols
2386        }
2387
2388        fn as_any(&self) -> &dyn std::any::Any {
2389            self
2390        }
2391
2392        fn gemv_moe_id(
2393            &self,
2394            _ctx: &mut <CpuBackend as Backend>::Context,
2395            _a: &<CpuBackend as Backend>::Buffer,
2396            _ids: &<CpuBackend as Backend>::Buffer,
2397            _out: &mut <CpuBackend as Backend>::Buffer,
2398            _n_selected: usize,
2399            _src1_stride: usize,
2400        ) -> ferrum_types::Result<()> {
2401            unimplemented!("num_experts test does not dispatch kernels")
2402        }
2403
2404        fn gemv_moe_id_offset(
2405            &self,
2406            _ctx: &mut <CpuBackend as Backend>::Context,
2407            _a: &<CpuBackend as Backend>::Buffer,
2408            _a_offset: usize,
2409            _ids: &<CpuBackend as Backend>::Buffer,
2410            _ids_offset: usize,
2411            _out: &mut <CpuBackend as Backend>::Buffer,
2412            _n_selected: usize,
2413            _src1_stride: usize,
2414        ) -> ferrum_types::Result<()> {
2415            unimplemented!("num_experts test does not dispatch kernels")
2416        }
2417
2418        fn gemv_moe_id_gate_up_silu(
2419            &self,
2420            _ctx: &mut <CpuBackend as Backend>::Context,
2421            _a: &<CpuBackend as Backend>::Buffer,
2422            _other_up: &dyn StackedExpertGgufLinear<CpuBackend>,
2423            _ids: &<CpuBackend as Backend>::Buffer,
2424            _silu_out: &mut <CpuBackend as Backend>::Buffer,
2425            _n_selected: usize,
2426        ) -> ferrum_types::Result<()> {
2427            unimplemented!("num_experts test does not dispatch kernels")
2428        }
2429
2430        fn gemv_moe_id_batched(
2431            &self,
2432            _ctx: &mut <CpuBackend as Backend>::Context,
2433            _a: &<CpuBackend as Backend>::Buffer,
2434            _ids: &<CpuBackend as Backend>::Buffer,
2435            _out: &mut <CpuBackend as Backend>::Buffer,
2436            _m: usize,
2437            _top_k: usize,
2438            _src1_outer_stride: usize,
2439            _src1_inner_stride: usize,
2440        ) -> ferrum_types::Result<()> {
2441            unimplemented!("num_experts test does not dispatch kernels")
2442        }
2443
2444        fn gemv_moe_id_gate_up_silu_batched(
2445            &self,
2446            _ctx: &mut <CpuBackend as Backend>::Context,
2447            _a: &<CpuBackend as Backend>::Buffer,
2448            _other_up: &dyn StackedExpertGgufLinear<CpuBackend>,
2449            _ids: &<CpuBackend as Backend>::Buffer,
2450            _silu_out: &mut <CpuBackend as Backend>::Buffer,
2451            _m: usize,
2452            _top_k: usize,
2453            _src1_outer_stride: usize,
2454            _src1_inner_stride: usize,
2455        ) -> ferrum_types::Result<()> {
2456            unimplemented!("num_experts test does not dispatch kernels")
2457        }
2458
2459        fn gemm_moe_id(
2460            &self,
2461            _ctx: &mut <CpuBackend as Backend>::Context,
2462            _a: &<CpuBackend as Backend>::Buffer,
2463            _ids: &<CpuBackend as Backend>::Buffer,
2464            _tpe: &<CpuBackend as Backend>::Buffer,
2465            _out: &mut <CpuBackend as Backend>::Buffer,
2466            _ne11: usize,
2467            _top_k: usize,
2468            _max_per_expert: usize,
2469            _batch: usize,
2470        ) -> ferrum_types::Result<()> {
2471            unimplemented!("num_experts test does not dispatch kernels")
2472        }
2473
2474        fn gemm_moe_id_indirect(
2475            &self,
2476            _ctx: &mut <CpuBackend as Backend>::Context,
2477            _src1: &<CpuBackend as Backend>::Buffer,
2478            _ids: &<CpuBackend as Backend>::Buffer,
2479            _tpe: &<CpuBackend as Backend>::Buffer,
2480            _out: &mut <CpuBackend as Backend>::Buffer,
2481            _args_buf: &<CpuBackend as Backend>::Buffer,
2482            _ne11: usize,
2483            _top_k: usize,
2484            _max_per_expert: usize,
2485            _batch: usize,
2486        ) -> ferrum_types::Result<()> {
2487            unimplemented!("num_experts test does not dispatch kernels")
2488        }
2489    }
2490
2491    fn fake_stacked(num_experts: usize) -> Box<dyn StackedExpertGgufLinear<CpuBackend>> {
2492        Box::new(FakeStackedGgufLinear {
2493            num_experts,
2494            rows: 4,
2495            cols: 4,
2496        })
2497    }
2498
2499    #[test]
2500    fn expert_stack_num_experts_uses_stacked_fast_path_count() {
2501        let experts = ExpertStack::<CpuBackend> {
2502            gate_up: Vec::new(),
2503            down: Vec::new(),
2504            gate_stacked: Some(fake_stacked(7)),
2505            up_stacked: Some(fake_stacked(7)),
2506            down_stacked: Some(fake_stacked(7)),
2507            gate_up_marlin_stack: None,
2508            down_marlin_stack: None,
2509        };
2510
2511        assert_eq!(experts.num_experts(), 7);
2512    }
2513
2514    #[test]
2515    fn moe_dispatch_runtime_config_parses_m3_startup_knobs() {
2516        let config = MoeDispatchRuntimeConfig::from_env_vars([
2517            ("FERRUM_MOE_PROFILE", "0"),
2518            ("FERRUM_DECODE_OP_PROFILE", "true"),
2519            ("FERRUM_VLLM_MOE_ZERO_WS", "1"),
2520            ("FERRUM_VLLM_MOE_PAIR_IDS", "1"),
2521            ("FERRUM_MOE_LOAD_TRACE", ""),
2522            ("FERRUM_MOE_BLOCK_SIZE", "8"),
2523            ("FERRUM_MOE_LARGE_M_BLOCK_SIZE", "64"),
2524            ("FERRUM_MOE_LARGE_M_MIN_PAIRS", "2048"),
2525            ("FERRUM_VLLM_MOE", "1"),
2526            ("FERRUM_MOE_HOST_ROUTE", "1"),
2527        ]);
2528
2529        assert!(config.moe_profile);
2530        assert!(config.decode_op_profile);
2531        assert!(config.vllm_moe_zero_ws);
2532        assert!(config.vllm_moe_pair_ids);
2533        assert!(config.moe_load_trace);
2534        assert_eq!(config.moe_block_size, Some(8));
2535        assert_eq!(config.moe_large_m_block_size, Some(64));
2536        assert_eq!(config.moe_large_m_min_pairs, 2048);
2537        assert!(config.vllm_moe);
2538        assert!(config.moe_host_route);
2539    }
2540
2541    #[test]
2542    fn drain_moe_bucket_profile_returns_and_clears_counters() {
2543        let _ = drain_moe_bucket_profile();
2544        MOE_BUCKET_LAYER_CALLS.store(2, Ordering::Relaxed);
2545        MOE_BUCKET_SYNC_US.store(3, Ordering::Relaxed);
2546        MOE_BUCKET_D2H_US.store(5, Ordering::Relaxed);
2547        MOE_BUCKET_ROUTE_US.store(7, Ordering::Relaxed);
2548        MOE_BUCKET_PLAN_US.store(11, Ordering::Relaxed);
2549        MOE_BUCKET_GATHER_US.store(13, Ordering::Relaxed);
2550        MOE_BUCKET_GEMM1_US.store(17, Ordering::Relaxed);
2551        MOE_BUCKET_SILU_US.store(19, Ordering::Relaxed);
2552        MOE_BUCKET_GEMM3_US.store(23, Ordering::Relaxed);
2553        MOE_BUCKET_COMBINE_US.store(29, Ordering::Relaxed);
2554
2555        let snapshot = drain_moe_bucket_profile();
2556        assert_eq!(snapshot.layers, 2);
2557        assert_eq!(snapshot.total_us(), 127);
2558        assert!(snapshot.has_layers());
2559
2560        let cleared = drain_moe_bucket_profile();
2561        assert_eq!(cleared.layers, 0);
2562        assert_eq!(cleared.total_us(), 0);
2563        assert!(!cleared.has_layers());
2564    }
2565
2566    #[test]
2567    fn moe_dispatch_runtime_config_bounds_invalid_block_values() {
2568        let config = MoeDispatchRuntimeConfig::from_env_vars([
2569            ("FERRUM_MOE_BLOCK_SIZE", "12"),
2570            ("FERRUM_MOE_LARGE_M_BLOCK_SIZE", "128"),
2571            ("FERRUM_MOE_LARGE_M_MIN_PAIRS", "bad"),
2572            ("FERRUM_VLLM_MOE_ZERO_WS", "true"),
2573            ("FERRUM_MOE_HOST_ROUTE", "0"),
2574        ]);
2575
2576        assert_eq!(config.moe_block_size, None);
2577        assert_eq!(config.moe_large_m_block_size, None);
2578        assert_eq!(config.moe_large_m_min_pairs, 1024);
2579        assert!(!config.vllm_moe_zero_ws);
2580        assert!(!config.moe_host_route);
2581    }
2582
2583    #[test]
2584    fn device_route_large_m_block_size_is_thresholded() {
2585        let config = MoeDispatchRuntimeConfig::from_env_vars([
2586            ("FERRUM_MOE_LARGE_M_BLOCK_SIZE", "64"),
2587            ("FERRUM_MOE_LARGE_M_MIN_PAIRS", "1024"),
2588        ]);
2589
2590        assert_eq!(
2591            pick_moe_block_size_with_config(&config, None, 128, true, 256),
2592            16
2593        );
2594        assert_eq!(
2595            pick_moe_block_size_with_config(&config, None, 128, true, 1024),
2596            64
2597        );
2598    }
2599
2600    #[test]
2601    fn global_moe_block_size_override_still_wins() {
2602        let config = MoeDispatchRuntimeConfig::from_env_vars([
2603            ("FERRUM_MOE_BLOCK_SIZE", "32"),
2604            ("FERRUM_MOE_LARGE_M_BLOCK_SIZE", "64"),
2605            ("FERRUM_MOE_LARGE_M_MIN_PAIRS", "1024"),
2606        ]);
2607
2608        assert_eq!(
2609            pick_moe_block_size_with_config(&config, None, 128, true, 2048),
2610            32
2611        );
2612    }
2613}