spark_model/layers/ops/
moe_lora_grouped.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Device-side MoE expert down_proj LoRA fold launcher (`moe_lora_grouped_down`).
4//!
5//! Replaces the host-synced per-expert loop (`crate::lora::expert_apply`, which
6//! D2H-copies `expert_offsets` and drives a host launch count — both illegal
7//! under CUDA-graph capture) with a single two-launch kernel that reads
8//! `expert_offsets` DEVICE-side. The grid is a STATIC worst-case bound
9//! (`worst_case_m_tiles = ceil(te/64)`, matching the base grouped GEMM), so the
10//! launch shape is constant across capture/replay; per-tile early-return on an
11//! empty / out-of-range / unadapted expert span keeps it correct without a host
12//! value. See `kernels/gb10/common/moe_lora_grouped_down.cu`.
13//!
14//! The fold math is BYTE-IDENTICAL to `apply_lora_bgmv` / per-row
15//! `apply_lora_delta(m=1)` (shrink→BF16 xa, expand→BF16 delta, then
16//! `base += scale·fp32(bf16(delta))`), so one kernel serves the nvfp4, bf16, and
17//! fp8 grouped prefill paths — all write the same sorted BF16 `expert_down_out`.
18
19use anyhow::Result;
20use spark_runtime::gpu::{DevicePtr, GpuBackend};
21
22use super::lora_delta::LoraKernels;
23
24/// Per-EXPERT routing tables for the grouped down fold — the expert-keyed
25/// analogue of the slot-keyed [`super::lora_delta::LoraRoute`]. Built once at
26/// adapter install from the layer's `Down` pairs; load-time-fixed device
27/// addresses, so they are stable kernel args across capture/replay (adapter
28/// identity for a mixed batch flows through the per-row `moe_row_adapter`, not
29/// these tables).
30///
31/// `n_experts` is the TABLE LENGTH = `max adapted expert id + 1` (NOT the
32/// layer's full `num_experts`): the grid launches `grid.z = n_experts`, and
33/// every adapted expert has index `< n_experts`, so any higher-index expert is
34/// unadapted and correctly folds nothing. `expert_offsets[e]` / `[e+1]` are read
35/// for `e < n_experts <= num_experts`, always in range of the `[num_experts+1]`
36/// prefix sum.
37#[derive(Debug, Clone, Copy)]
38pub struct MoeExpertRoute {
39    /// `[n_experts]` u64 device array of `A_e` addresses (`0` = expert unadapted).
40    pub a_table: DevicePtr,
41    /// `[n_experts]` u64 device array of `B_e` addresses (`0` = expert unadapted).
42    pub b_table: DevicePtr,
43    /// `[n_experts]` f32 device array of per-expert `scale_e` (`0.0` where unadapted).
44    pub scale_table: DevicePtr,
45    /// Table length = max adapted expert id + 1 (== grid.z).
46    pub n_experts: u32,
47    /// Contraction dim of the shrink stage (`moe_intermediate_size`).
48    pub k_in: u32,
49    /// Output dim of the expand stage (`hidden_size`).
50    pub n_out: u32,
51    /// Padded rank (contraction dim of the expand stage; row stride of `B_e`).
52    pub max_rank: u32,
53}
54
55/// PURE (GPU-free, unit-tested): pack a set of adapted-expert
56/// `(expert_id, a_addr, b_addr, scale)` entries into dense `[n_experts]` tables
57/// indexed by expert id, with `0` / `0.0` at every unadapted slot.
58/// `n_experts = max expert_id + 1`. Returns `None` when `entries` is empty (a
59/// router-only adapter installs no expert route). Duplicate expert ids keep the
60/// LAST entry (callers pass at most one `Down` pair per expert).
61pub fn pack_expert_tables(entries: &[(u16, u64, u64, f32)]) -> Option<ExpertTables> {
62    let max_e = entries.iter().map(|(e, ..)| *e).max()?;
63    let n = max_e as usize + 1;
64    let mut a = vec![0u64; n];
65    let mut b = vec![0u64; n];
66    let mut scale = vec![0.0f32; n];
67    for &(e, a_addr, b_addr, sc) in entries {
68        let i = e as usize;
69        a[i] = a_addr;
70        b[i] = b_addr;
71        scale[i] = sc;
72    }
73    Some(ExpertTables {
74        a,
75        b,
76        scale,
77        n_experts: n as u32,
78    })
79}
80
81/// Host-side packed tables from [`pack_expert_tables`], ready for H2D upload.
82#[derive(Debug, Clone, PartialEq)]
83pub struct ExpertTables {
84    pub a: Vec<u64>,
85    pub b: Vec<u64>,
86    pub scale: Vec<f32>,
87    pub n_experts: u32,
88}
89
90/// Launch the device-side grouped fold for ONE chunk window `[row_offset,
91/// row_end)` of the sorted rows. Down (`x_gather==0`): `x` = post-SiLU sorted
92/// activations (`[te, k_in]` BF16), `base_out` = sorted `expert_down_out`.
93/// Gate/up (`x_gather==1`): `x` = the TOKEN-MAJOR `expert_input` (`[num_tokens,
94/// k_in=hidden]` BF16, gathered per sorted row via `sorted_token_ids`), `base_out`
95/// = sorted `expert_gate_out`/`expert_up_out` (`[te, n_out=inter]`). In both,
96/// `base_out` is `[te, n_out]` BF16 folded IN PLACE, `expert_offsets` = the device
97/// `[num_experts+1]` i32
98/// prefix sum, `sorted_token_ids` = the device `[te]` i32 sorted-row→token map,
99/// `moe_row_adapter` = `[num_tokens]` i32 device map (`< 0` = base skip) or
100/// `DevicePtr::NULL` for the single-active-adapter path, `xa` = the fixed-address
101/// `[cap, max_rank]` BF16 shrink scratch indexed at the LOCAL row `r-row_offset`
102/// (so the caller only needs `>= (row_end-row_offset)` rows, NOT `>= te`). The
103/// hooks loop `[0, te)` in windows of `cap`; a single call at `row_offset=0,
104/// row_end=te` (te <= cap) is bit-identical to the pre-chunk kernel.
105///
106/// ARG ORDER is in lockstep with `moe_lora_grouped_down.cu` (cuLaunchKernel is
107/// type-blind; the byte-identity oracle is the only guard — keep both in sync).
108/// `row_offset`/`row_end` are appended LAST in both kernels, so existing arg
109/// offsets are untouched.
110#[allow(clippy::too_many_arguments)]
111pub fn moe_lora_grouped_down(
112    gpu: &dyn GpuBackend,
113    kernels: &LoraKernels,
114    route: &MoeExpertRoute,
115    x: DevicePtr,                // [te, k_in] BF16 (post-SiLU sorted)
116    base_out: DevicePtr,         // [te, n_out] BF16, folded in place
117    expert_offsets: DevicePtr,   // [num_experts+1] i32 DEVICE
118    sorted_token_ids: DevicePtr, // [te] i32 DEVICE
119    moe_row_adapter: DevicePtr,  // [num_tokens] i32 DEVICE or NULL
120    xa: DevicePtr,               // [cap, max_rank] BF16 scratch (fixed address, LOCAL-row indexed)
121    row_offset: u32,             // first ABSOLUTE sorted row of this chunk window
122    row_end: u32,                // one-past-last ABSOLUTE row (== min(row_offset+cap, te))
123    x_gather: u32, // 0: x row = sorted row r (down); 1: x row = sorted_token_ids[r] (gate/up)
124    stream: u64,
125) -> Result<()> {
126    use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
127
128    anyhow::ensure!(
129        kernels.moe_down_shrink_k.0 != 0 && kernels.moe_down_expand_fold_k.0 != 0,
130        "moe_lora_grouped_down kernels unresolved (module `moe_lora_grouped_down` missing \
131         from the compiled kernel set — CUDA build required)"
132    );
133    // Grid.y covers the window (<= cap rows), not the whole te: each expert's span
134    // ∩ window has at most `window` rows, and per-expert tiles rebase to the
135    // window start device-side.
136    let window = row_end.saturating_sub(row_offset);
137    let wc = div_ceil(window, MLG_M_TILE).max(1);
138
139    // Kernel 1: shrink — xa[local_row, max_rank] = x @ A_e^T.
140    // grid = (ceil(max_rank/4), ceil(window/64), n_experts)  block = (256,1,1).
141    KernelLaunch::new(gpu, kernels.moe_down_shrink_k)
142        .grid([div_ceil(route.max_rank, 4), wc, route.n_experts])
143        .block([256, 1, 1])
144        .arg_ptr(x)
145        .arg_ptr(expert_offsets)
146        .arg_ptr(sorted_token_ids)
147        .arg_ptr(moe_row_adapter)
148        .arg_ptr(route.a_table)
149        .arg_ptr(xa)
150        .arg_u32(route.n_experts)
151        .arg_u32(route.max_rank)
152        .arg_u32(route.k_in)
153        .arg_u32(x_gather)
154        .arg_u32(row_offset)
155        .arg_u32(row_end)
156        .launch(stream)?;
157
158    // Kernel 2: expand + fold — base_out[r] += scale_e * (xa[r-row_offset] @ B_e^T).
159    // grid = (ceil(n_out/4), ceil(window/64), n_experts)  block = (256,1,1).
160    KernelLaunch::new(gpu, kernels.moe_down_expand_fold_k)
161        .grid([div_ceil(route.n_out, 4), wc, route.n_experts])
162        .block([256, 1, 1])
163        .arg_ptr(xa)
164        .arg_ptr(expert_offsets)
165        .arg_ptr(sorted_token_ids)
166        .arg_ptr(moe_row_adapter)
167        .arg_ptr(route.b_table)
168        .arg_ptr(route.scale_table)
169        .arg_ptr(base_out)
170        .arg_u32(route.n_experts)
171        .arg_u32(route.n_out)
172        .arg_u32(route.max_rank)
173        .arg_u32(row_offset)
174        .arg_u32(row_end)
175        .launch(stream)
176}
177
178/// PURE (GPU-free, unit-tested): the shrink/expand grid `grid.y` (m-tile count)
179/// for one chunk window `[row_offset, row_end)` — `ceil((row_end-row_offset)/64)`,
180/// min 1. Matches the launcher's `wc`; exposed so the chunk-boundary math is
181/// verifiable without a GPU. A full-window call (`row_offset=0, row_end=te`)
182/// returns exactly the pre-chunk `ceil(te/64)`.
183pub fn grouped_down_wc(row_offset: u32, row_end: u32) -> u32 {
184    use spark_runtime::kernel_args::div_ceil;
185    div_ceil(row_end.saturating_sub(row_offset), MLG_M_TILE).max(1)
186}
187
188/// Contiguous `[start, end)` launch windows covering `0..total_rows`.
189pub fn grouped_down_windows(total_rows: u32, cap: u32) -> impl Iterator<Item = (u32, u32)> {
190    assert!(cap > 0, "grouped LoRA scratch capacity must be nonzero");
191    (0..total_rows)
192        .step_by(cap as usize)
193        .map(move |start| (start, start.saturating_add(cap).min(total_rows)))
194}
195
196/// M_TILE the static worst-case grid pairs with — must match the `MLG_M_TILE`
197/// `#define` in `moe_lora_grouped_down.cu` AND the base grouped GEMM's
198/// `worst_case_m_tiles = ceil(total_expanded/64)` sizing.
199pub const MLG_M_TILE: u32 = 64;
200
201/// PURE (GPU-free, unit-tested): the exact `(shrink, expand)` grid triples for
202/// the decode gather-fold, given the route dims and the flat row count. Each is
203/// `[ceil(out/4), n_slots, 1]` with a `(256,1,1)` block (one 64-lane group per
204/// output, `N_PER_BLOCK = 4`). `n_slots` is a host constant per captured graph,
205/// so the shape is EXACT (no worst-case tiles) and capture-stable.
206pub fn gather_bgmv_grids(max_rank: u32, n_out: u32, n_slots: u32) -> ([u32; 3], [u32; 3]) {
207    use spark_runtime::kernel_args::div_ceil;
208    (
209        [div_ceil(max_rank, 4), n_slots, 1],
210        [div_ceil(n_out, 4), n_slots, 1],
211    )
212}
213
214/// PURE: the owning token index of a flat `(token, slot)` row — mirrors the
215/// kernel's `row / top_k` so the per-token `row_adapter` gather is verifiable.
216pub fn gather_row_token(row: u32, top_k: u32) -> u32 {
217    row / top_k
218}
219
220/// SOLID Incr-4: launch the DECODE-path MoE expert down fold. The unsorted,
221/// slot-major analogue of [`moe_lora_grouped_down`] — instead of an
222/// `expert_offsets` prefix sum over sorted rows, each flat `(token, slot)` row
223/// gathers its expert from `indices[row]` (the same `indices_dev` the fused
224/// expert GEMV routed on) and its base/adapt decision from
225/// `row_adapter[row / top_k]` (`< 0` = base skip, or `DevicePtr::NULL` to fold
226/// every row on the single-active-adapter path).
227///
228/// `x` = the post-swiglu activations (`silu(gate)*up`, produced by the caller's
229/// `moe_silu_mul` launch into a packed `[n_slots, k_in]` BF16 scratch — the SAME
230/// kernel + BF16 round the prefill fold uses, so the delta is BF16-ULP identical
231/// to prefill). `base_out` = the slot-major `expert_down_out` (`[n_slots, n_out]`
232/// BF16, folded IN PLACE before `moe_weighted_sum_blend`, so the router weight
233/// multiplies base+delta). `xa` = the fixed-address `[n_slots, max_rank]` BF16
234/// shrink scratch. The grid is EXACT (`n_slots` is a host constant per captured
235/// graph) — no worst-case tiles — and all args are pointer/value-stable, so the
236/// launch captures cleanly.
237///
238/// ARG ORDER is in lockstep with `moe_lora_gather_bgmv.cu` (cuLaunchKernel is
239/// type-blind; keep both in sync).
240#[allow(clippy::too_many_arguments)]
241pub fn moe_lora_gather_bgmv(
242    gpu: &dyn GpuBackend,
243    kernels: &LoraKernels,
244    route: &MoeExpertRoute,
245    x: DevicePtr,           // [n_slots, k_in] BF16 (silu(gate)*up)
246    base_out: DevicePtr,    // [n_slots, n_out] BF16 = expert_down_out, folded in place
247    indices: DevicePtr,     // [n_slots] u32 = indices_dev (expert id per flat slot)
248    row_adapter: DevicePtr, // [num_tokens] i32 (<0 skip) or DevicePtr::NULL (fold all)
249    xa: DevicePtr,          // [n_slots, max_rank] BF16 scratch (fixed address)
250    n_slots: u32,           // num_tokens * top_k
251    top_k: u32,
252    x_gather: u32, // 0: x row = flat slot (down); 1: x row = token = row/top_k (gate/up)
253    stream: u64,
254) -> Result<()> {
255    use spark_runtime::kernel_args::KernelLaunch;
256
257    anyhow::ensure!(
258        kernels.moe_gather_shrink_k.0 != 0 && kernels.moe_gather_expand_fold_k.0 != 0,
259        "moe_lora_gather_bgmv kernels unresolved (module `moe_lora_gather_bgmv` missing \
260         from the compiled kernel set — CUDA build required)"
261    );
262    let (shrink_grid, expand_grid) = gather_bgmv_grids(route.max_rank, route.n_out, n_slots);
263
264    // Kernel 1: shrink — xa[n_slots, max_rank] = x @ A_e^T.
265    // grid = (ceil(max_rank/4), n_slots, 1)  block = (256,1,1).
266    KernelLaunch::new(gpu, kernels.moe_gather_shrink_k)
267        .grid(shrink_grid)
268        .block([256, 1, 1])
269        .arg_ptr(x)
270        .arg_ptr(indices)
271        .arg_ptr(row_adapter)
272        .arg_ptr(route.a_table)
273        .arg_ptr(xa)
274        .arg_u32(n_slots)
275        .arg_u32(top_k)
276        .arg_u32(route.n_experts)
277        .arg_u32(route.max_rank)
278        .arg_u32(route.k_in)
279        .arg_u32(x_gather)
280        .launch(stream)?;
281
282    // Kernel 2: expand + fold — base_out += scale_e * (xa @ B_e^T).
283    // grid = (ceil(n_out/4), n_slots, 1)  block = (256,1,1).
284    KernelLaunch::new(gpu, kernels.moe_gather_expand_fold_k)
285        .grid(expand_grid)
286        .block([256, 1, 1])
287        .arg_ptr(xa)
288        .arg_ptr(indices)
289        .arg_ptr(row_adapter)
290        .arg_ptr(route.b_table)
291        .arg_ptr(route.scale_table)
292        .arg_ptr(base_out)
293        .arg_u32(n_slots)
294        .arg_u32(top_k)
295        .arg_u32(route.n_experts)
296        .arg_u32(route.n_out)
297        .arg_u32(route.max_rank)
298        .launch(stream)
299}
300
301#[cfg(test)]
302#[path = "moe_lora_grouped_tests.rs"]
303mod tests;