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;