spark_model/layers/moe/
helpers_b.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! set_down_transpose_scratch.
4
5use super::*;
6
7impl MoeLayer {
8    /// Wire a shared per-prefill down_proj scratch + transposed pointer table.
9    ///
10    /// Called by the factory after the persistent MoE transpose pass falls
11    /// back to gate+up only. The scratch and pointer tables are shared
12    /// across all MoE layers — one allocation reused layer-by-layer during
13    /// the sequential forward. The same `scale2_vals` buffer is reused
14    /// from the existing untransposed `down_ptrs` (transpose preserves
15    /// per-tensor scales).
16    pub fn set_down_transpose_scratch(
17        &mut self,
18        scratch_packed: DevicePtr,
19        scratch_scale: DevicePtr,
20        packed_ptrs_t: DevicePtr,
21        scale_ptrs_t: DevicePtr,
22    ) {
23        self.down_t_scratch_packed = Some(scratch_packed);
24        self.down_t_scratch_scale = Some(scratch_scale);
25        self.down_ptrs_t = Some(ExpertPtrTable {
26            packed_ptrs: packed_ptrs_t,
27            scale_ptrs: scale_ptrs_t,
28            scale2_vals: self.down_ptrs.scale2_vals,
29        });
30    }
31
32    /// Run the batched transpose kernel to populate `down_t_scratch_*` from
33    /// the untransposed `down_ptrs` source. Must be called once at the
34    /// start of every layer's prefill, before the silu_down GEMM. No-op
35    /// when scratch isn't wired (decode-only / persistent-full-transpose
36    /// paths).
37    pub(crate) fn transpose_down_into_scratch(
38        &self,
39        ctx: &crate::layer::ForwardContext,
40        stream: u64,
41    ) -> Result<()> {
42        let Some(dpt) = self.down_ptrs_t.as_ref() else {
43            return Ok(());
44        };
45        // Only run the transpose when scratch is wired (vs persistent
46        // transpose_for_prefill_impl path which sets down_ptrs_t to its
47        // own allocations and leaves scratch fields None).
48        if self.down_t_scratch_packed.is_none() {
49            return Ok(());
50        }
51        let num_experts = ctx.config.num_experts as u32;
52        let h = ctx.config.hidden_size as u32;
53        let inter = ctx.config.moe_intermediate_size as u32;
54        // Packed: [N=hidden, K/2=inter/2] → [K/2, N] per expert.
55        crate::layers::ops::moe_transpose_u8_batched(
56            ctx.gpu,
57            self.moe_transpose_u8_batched_k,
58            self.down_ptrs.packed_ptrs,
59            dpt.packed_ptrs,
60            h,
61            inter / 2,
62            num_experts,
63            stream,
64        )?;
65        // Scale: [N, K/GROUP_SIZE=inter/16] → [K/16, N] per expert.
66        crate::layers::ops::moe_transpose_u8_batched(
67            ctx.gpu,
68            self.moe_transpose_u8_batched_k,
69            self.down_ptrs.scale_ptrs,
70            dpt.scale_ptrs,
71            h,
72            inter / 16,
73            num_experts,
74            stream,
75        )?;
76        Ok(())
77    }
78
79    /// **NOT CURRENTLY WIRED IN** — this helper attempted to overlap the
80    /// lazy down_proj transpose with the TP attention allreduce by
81    /// kicking it off on `prefill_stream` right after attention. It
82    /// regressed cold TTFT by ~30 % on GB10 — both when scheduled
83    /// against compute-bound MoE GEMMs AND when scheduled against the
84    /// (RDMA-dominated) TP allreduce window. Either GB10's SM scheduling
85    /// has hidden contention costs across streams, or the per-call
86    /// event-sync overhead exceeds the ~4 ms transpose savings.
87    ///
88    /// Kept in source for future reference — future work that figures
89    /// out the GB10 stream-scheduling pattern can re-wire it from
90    /// `qwen3_attention::trait_impl::prefill` after attention but before
91    /// the TP allreduce, then have silu_down stall via
92    /// `lazy_transpose_done_event()`.
93    #[allow(dead_code)]
94    pub(crate) fn kick_off_lazy_transpose(
95        &self,
96        ctx: &crate::layer::ForwardContext,
97        compute_stream: u64,
98    ) -> Result<()> {
99        let Some(dpt) = self.down_ptrs_t.as_ref() else {
100            return Ok(());
101        };
102        if self.down_t_scratch_packed.is_none() {
103            return Ok(());
104        }
105        // prefill_stream waits for compute_stream's "attention done" point.
106        ctx.gpu.record_event(self.event_a, compute_stream)?;
107        ctx.gpu
108            .stream_wait_event(self.prefill_stream, self.event_a)?;
109
110        let num_experts = ctx.config.num_experts as u32;
111        let h = ctx.config.hidden_size as u32;
112        let inter = ctx.config.moe_intermediate_size as u32;
113        crate::layers::ops::moe_transpose_u8_batched(
114            ctx.gpu,
115            self.moe_transpose_u8_batched_k,
116            self.down_ptrs.packed_ptrs,
117            dpt.packed_ptrs,
118            h,
119            inter / 2,
120            num_experts,
121            self.prefill_stream,
122        )?;
123        crate::layers::ops::moe_transpose_u8_batched(
124            ctx.gpu,
125            self.moe_transpose_u8_batched_k,
126            self.down_ptrs.scale_ptrs,
127            dpt.scale_ptrs,
128            h,
129            inter / 16,
130            num_experts,
131            self.prefill_stream,
132        )?;
133        // Record transpose-done event on the secondary stream. The
134        // silu_down call site stalls compute_stream on this event before
135        // reading from scratch.
136        ctx.gpu.record_event(self.event_b, self.prefill_stream)?;
137        Ok(())
138    }
139
140    /// Companion to the (currently-unwired) `kick_off_lazy_transpose`
141    /// — silu_down would call this to know whether to stall on the
142    /// secondary-stream event.
143    #[allow(dead_code)]
144    pub(crate) fn has_overlapped_transpose(&self) -> bool {
145        self.down_t_scratch_packed.is_some()
146    }
147
148    /// Companion to `kick_off_lazy_transpose`.
149    #[allow(dead_code)]
150    pub(crate) fn lazy_transpose_done_event(&self) -> u64 {
151        self.event_b
152    }
153
154    /// True when prefill dispatch (forward_batched) should route to
155    /// `_t` transposed-layout kernels.
156    ///
157    /// Fires for both unified mode (Phase 8a — originals freed) and hybrid
158    /// mode (Block C Path 2 — originals retained alongside transposed).
159    /// Both build the same persistent `*_ptrs_t` device-side pointer tables.
160    ///
161    /// Requires:
162    /// 1. `ATLAS_UNIFIED_MOE_LAYOUT=1` OR `ATLAS_HYBRID_MOE_LAYOUT=1`
163    ///    (read at construction).
164    /// 2. Persistent transposed pointer tables for all three projections.
165    /// 3. NOT the lazy-scratch path — scratch-backed `down_ptrs_t` only
166    ///    holds one layer at a time, so multi-layer dispatch would read
167    ///    stale data. Persistent transpose pass must have populated down_t.
168    #[inline]
169    pub(crate) fn use_t_layout_for_prefill(&self) -> bool {
170        (self.unified_layout || self.hybrid_layout)
171            && self.gate_ptrs_t.is_some()
172            && self.up_ptrs_t.is_some()
173            && self.down_ptrs_t.is_some()
174            && self.down_t_scratch_packed.is_none()
175    }
176
177    /// True when decode dispatch (forward, forward_k2, forward_k3) should
178    /// route to `_t` transposed-layout kernels.
179    ///
180    /// Only fires in unified mode — hybrid mode keeps the originals so
181    /// decode + MTP verify (small N, warp-reduction wins) can preserve
182    /// the ~35 tok/s throughput that pure unified layout regresses by 15 %.
183    #[inline]
184    /// Eligible to route DECODE through the grouped read-once GEMM
185    /// (forward_prefill). Native-NVFP4 routed only: bf16/fp8-dequant gate ptrs
186    /// absent, no DeepSeek-V4 hash routing, single-GPU (no EP). Deliberately
187    /// ALLOWS the mixed BF16 shared expert (forward_prefill handles it as a
188    /// separate batched pass), unlike forward_token_major_decode which bails.
189    pub(crate) fn grouped_decode_ok(&self) -> bool {
190        self.bf16_gate_weight_ptrs.is_none()
191            && self.fp8_gate_weight_ptrs.is_none()
192            && self.tid2eid_dev.is_none()
193    }
194
195    pub(crate) fn use_t_layout_for_decode(&self) -> bool {
196        self.unified_layout
197            && !self.hybrid_layout
198            && self.gate_ptrs_t.is_some()
199            && self.up_ptrs_t.is_some()
200            && self.down_ptrs_t.is_some()
201            && self.down_t_scratch_packed.is_none()
202    }
203}
204
205impl super::MoeLayer {
206    /// The routed grouped-GEMM kernel: the bit-exact wider-K twin when it
207    /// resolved (ATLAS_MOE_GROUPED_K32=1 and the target ships it), else the
208    /// original. Both take the same grid/block, so the launcher is shared.
209    pub(super) fn grouped_gemm_kernel(&self) -> spark_runtime::gpu::KernelHandle {
210        if self.moe_grouped_gemm_k32.0 != 0 {
211            self.moe_grouped_gemm_k32
212        } else {
213            self.moe_grouped_gemm
214        }
215    }
216}
217
218impl super::MoeLayer {
219    /// Launch the routed grouped GEMM, choosing the widest tiling this build
220    /// resolved. M_TILE=256 re-reads the expert weights ONCE instead of three
221    /// times at ~160 rows/expert, so `max_m_tiles` must be recomputed against
222    /// 256 — the same adjustment the m128 path makes with `div_ceil(2)`.
223    /// All arms are bit-exact with each other.
224    ///
225    /// ⚠ BOTH WIDE ARMS MEASURED AS NON-WINS (qwen4_exp, GB10, 2026-08-27) and
226    /// are default-OFF. Controlled four-arm sweep at 28K prefill, same box,
227    /// back-to-back: baseline 272 → +QSA-TC 286 → +k32 289 → +m256 290 tok/s.
228    /// The k32/m256 deltas (+1.0% / +1.4% over the QSA-TC arm) are inside
229    /// run-to-run noise; the entire +5.1% came from the QSA tensor-core
230    /// scorer, NOT from this GEMM.
231    ///
232    /// nsys proves the null is real rather than a silent `try_kernel`
233    /// fallback: `..._m256` appears in the trace with 540 calls / 38.1% of GPU
234    /// time, and averages 31.30 ms/call against the base kernel's 26.17 ms —
235    /// i.e. ~20% SLOWER per call at matched shapes. A DRAM-bound standalone
236    /// harness predicted 1.43x for it. Do not re-enable either arm on
237    /// microbenchmark evidence; re-measure end-to-end first.
238    #[allow(clippy::too_many_arguments)]
239    pub(super) fn launch_grouped_gemm(
240        &self,
241        gpu: &dyn spark_runtime::gpu::GpuBackend,
242        a: spark_runtime::gpu::DevicePtr,
243        packed_ptrs: spark_runtime::gpu::DevicePtr,
244        scale_ptrs: spark_runtime::gpu::DevicePtr,
245        scale2_vals: spark_runtime::gpu::DevicePtr,
246        c: spark_runtime::gpu::DevicePtr,
247        expert_offsets: spark_runtime::gpu::DevicePtr,
248        sorted_token_ids: spark_runtime::gpu::DevicePtr,
249        num_experts: u32,
250        n_out: u32,
251        k: u32,
252        max_m_tiles: u32,
253        stream: u64,
254    ) -> anyhow::Result<()> {
255        if self.moe_grouped_gemm_m256.0 != 0 {
256            return crate::layers::ops::moe_w4a16_grouped_gemm_ptrtable_m256(
257                gpu,
258                self.moe_grouped_gemm_m256,
259                a,
260                packed_ptrs,
261                scale_ptrs,
262                scale2_vals,
263                c,
264                expert_offsets,
265                sorted_token_ids,
266                num_experts,
267                n_out,
268                k,
269                max_m_tiles.div_ceil(4).max(1),
270                stream,
271            );
272        }
273        crate::layers::ops::moe_w4a16_grouped_gemm_ptrtable(
274            gpu,
275            self.grouped_gemm_kernel(),
276            a,
277            packed_ptrs,
278            scale_ptrs,
279            scale2_vals,
280            c,
281            expert_offsets,
282            sorted_token_ids,
283            num_experts,
284            n_out,
285            k,
286            max_m_tiles,
287            stream,
288        )
289    }
290}