metrale_model_layers/layers/ops/
moe_lora_grouped.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Launchers for the device-side MoE expert LoRA folds:
4//! `moe_lora_grouped_down` over the expert-sorted prefill rows and
5//! `moe_lora_gather_bgmv` over the slot-major decode rows, plus the host packing
6//! of their per-expert tables.
7//!
8//! Both read the expert routing (`expert_offsets` or `indices`) on the device.
9//! The host loop `crate::lora::expert_apply::apply_expert_lora_sorted` needs a
10//! host copy of `expert_offsets` and one launch per adapted expert, which a CUDA
11//! graph cannot capture.
12//!
13//! Owner: model-layers ops.
14//! Invariants:
15//! - An expert whose `a_table` or `b_table` cell is `0`, or whose id is at or
16//!   above the table length, folds nothing (`moe_lora_grouped_down.cu`,
17//!   `moe_lora_gather_bgmv.cu`).
18
19use anyhow::Result;
20use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
21
22use super::lora_delta::LoraKernels;
23
24/// 2026-09-25: Device tables for one projection's expert LoRA pairs, indexed by
25/// expert id: the expert-keyed counterpart of the slot-keyed
26/// [`super::lora_delta::LoraRoute`]. `MoeLayer::build_expert_route` builds one
27/// per projection (down, gate, up) at adapter install, and the router fold
28/// builds a one-entry route whose expert 0 is the router pair.
29///
30/// `n_experts` is the table length, max adapted expert id + 1, not the layer's
31/// expert count. The grouped launcher uses it as `grid.z`.
32#[derive(Debug, Clone, Copy)]
33pub struct MoeExpertRoute {
34    /// 2026-09-25: `[n_experts]` u64 device array of `A_e` addresses (`0` = expert unadapted).
35    pub a_table: DevicePtr,
36    /// 2026-09-25: `[n_experts]` u64 device array of `B_e` addresses (`0` = expert unadapted).
37    pub b_table: DevicePtr,
38    /// 2026-09-25: `[n_experts]` f32 device array of per-expert `scale_e` (`0.0` where unadapted).
39    pub scale_table: DevicePtr,
40    pub n_experts: u32,
41    /// 2026-09-25: Contraction dim of the shrink stage: the projection's input width.
42    pub k_in: u32,
43    /// 2026-09-25: Output dim of the expand stage: the projection's output width.
44    pub n_out: u32,
45    /// 2026-09-25: Padded rank: the contraction dim of the expand stage and the
46    /// row stride of `B_e`.
47    pub max_rank: u32,
48}
49
50/// 2026-09-25: Pack `(expert_id, a_addr, b_addr, scale)` entries into dense
51/// tables indexed by expert id, with `0` / `0.0` at every unadapted id and
52/// `n_experts = max expert_id + 1`. Returns `None` when `entries` is empty.
53/// A duplicate expert id keeps its last entry.
54pub fn pack_expert_tables(entries: &[(u16, u64, u64, f32)]) -> Option<ExpertTables> {
55    let max_e = entries.iter().map(|(e, ..)| *e).max()?;
56    let n = max_e as usize + 1;
57    let mut a = vec![0u64; n];
58    let mut b = vec![0u64; n];
59    let mut scale = vec![0.0f32; n];
60    for &(e, a_addr, b_addr, sc) in entries {
61        let i = e as usize;
62        a[i] = a_addr;
63        b[i] = b_addr;
64        scale[i] = sc;
65    }
66    Some(ExpertTables {
67        a,
68        b,
69        scale,
70        n_experts: n as u32,
71    })
72}
73
74/// 2026-09-25: Host-side tables from [`pack_expert_tables`], before upload.
75#[derive(Debug, Clone, PartialEq)]
76pub struct ExpertTables {
77    pub a: Vec<u64>,
78    pub b: Vec<u64>,
79    pub scale: Vec<f32>,
80    pub n_experts: u32,
81}
82
83/// 2026-09-25: Fold `base_out[r] += scale_e * (x[row(r)] @ A_e^T) @ B_e^T` for
84/// the sorted rows `r` in the window `[row_offset, row_end)`, where `e` is the
85/// expert whose `expert_offsets` span holds `r`.
86///
87/// - `x`: BF16. With `x_gather == 0` (down) it is `[te, k_in]` in sorted order
88///   and row `r` is read. With `x_gather == 1` (gate/up) it is the token-major
89///   `[num_tokens, k_in]` input and row `sorted_token_ids[r]` is read.
90/// - `base_out`: `[te, n_out]` BF16 in sorted order, folded in place.
91/// - `expert_offsets`: device `[num_experts + 1]` i32 prefix sum.
92/// - `sorted_token_ids`: device `[te]` i32, sorted row to token.
93/// - `moe_row_adapter`: device `[num_tokens]` i32, where a row whose token maps
94///   to a value `< 0` is skipped; `DevicePtr::NULL` folds every row.
95/// - `xa`: fixed-address BF16 shrink scratch indexed by the local row
96///   `r - row_offset`, so it needs `row_end - row_offset` rows of `max_rank`.
97///
98/// The argument order must match the kernel parameters in
99/// `kernels/gb10/common/moe_lora_grouped_down.cu`: `cuLaunchKernel` does not
100/// check types.
101#[allow(clippy::too_many_arguments)]
102pub fn moe_lora_grouped_down(
103    gpu: &dyn GpuBackend,
104    kernels: &LoraKernels,
105    route: &MoeExpertRoute,
106    x: DevicePtr,
107    base_out: DevicePtr,
108    expert_offsets: DevicePtr,
109    sorted_token_ids: DevicePtr,
110    moe_row_adapter: DevicePtr,
111    xa: DevicePtr,
112    row_offset: u32,
113    row_end: u32,
114    x_gather: u32,
115    stream: u64,
116) -> Result<()> {
117    use metrale_gpu_runtime::kernel_args::{KernelLaunch, div_ceil};
118
119    anyhow::ensure!(
120        kernels.moe_down_shrink_k.0 != 0 && kernels.moe_down_expand_fold_k.0 != 0,
121        "moe_lora_grouped_down kernels unresolved (module `moe_lora_grouped_down` missing \
122         from the compiled kernel set — CUDA build required)"
123    );
124    // 2026-09-25: grid.y covers the window, not all of `te`: an expert's span
125    // clipped to the window has at most `window` rows, and the kernels start each
126    // expert's tiles at `max(m_start, row_offset)`.
127    let window = row_end.saturating_sub(row_offset);
128    let wc = div_ceil(window, MLG_M_TILE).max(1);
129
130    // 2026-09-25: Shrink: `xa[r - row_offset] = x[row(r)] @ A_e^T`, stored as BF16.
131    KernelLaunch::new(gpu, kernels.moe_down_shrink_k)
132        .grid([div_ceil(route.max_rank, 4), wc, route.n_experts])
133        .block([256, 1, 1])
134        .arg_ptr(x)
135        .arg_ptr(expert_offsets)
136        .arg_ptr(sorted_token_ids)
137        .arg_ptr(moe_row_adapter)
138        .arg_ptr(route.a_table)
139        .arg_ptr(xa)
140        .arg_u32(route.n_experts)
141        .arg_u32(route.max_rank)
142        .arg_u32(route.k_in)
143        .arg_u32(x_gather)
144        .arg_u32(row_offset)
145        .arg_u32(row_end)
146        .launch(stream)?;
147
148    // 2026-09-25: Expand and fold: `base_out[r] += scale_e * (xa[r - row_offset] @ B_e^T)`.
149    KernelLaunch::new(gpu, kernels.moe_down_expand_fold_k)
150        .grid([div_ceil(route.n_out, 4), wc, route.n_experts])
151        .block([256, 1, 1])
152        .arg_ptr(xa)
153        .arg_ptr(expert_offsets)
154        .arg_ptr(sorted_token_ids)
155        .arg_ptr(moe_row_adapter)
156        .arg_ptr(route.b_table)
157        .arg_ptr(route.scale_table)
158        .arg_ptr(base_out)
159        .arg_u32(route.n_experts)
160        .arg_u32(route.n_out)
161        .arg_u32(route.max_rank)
162        .arg_u32(row_offset)
163        .arg_u32(row_end)
164        .launch(stream)
165}
166
167/// 2026-09-25: `grid.y` of both grouped-fold kernels for the window
168/// `[row_offset, row_end)`: `ceil((row_end - row_offset) / MLG_M_TILE)`, at
169/// least 1. It is the same expression as the launcher's `wc`, kept separate so
170/// tests can check the window math without a GPU.
171pub fn grouped_down_wc(row_offset: u32, row_end: u32) -> u32 {
172    use metrale_gpu_runtime::kernel_args::div_ceil;
173    div_ceil(row_end.saturating_sub(row_offset), MLG_M_TILE).max(1)
174}
175
176/// 2026-09-25: Contiguous `[start, end)` windows of at most `cap` rows covering
177/// `0..total_rows`. Panics when `cap` is 0.
178pub fn grouped_down_windows(total_rows: u32, cap: u32) -> impl Iterator<Item = (u32, u32)> {
179    assert!(cap > 0, "grouped LoRA scratch capacity must be nonzero");
180    (0..total_rows)
181        .step_by(cap as usize)
182        .map(move |start| (start, start.saturating_add(cap).min(total_rows)))
183}
184
185/// 2026-09-25: Rows per grouped-fold tile. It must equal the `MLG_M_TILE`
186/// `#define` in `moe_lora_grouped_down.cu`, or the host grid and the kernel's
187/// tiles cover different rows.
188pub const MLG_M_TILE: u32 = 64;
189
190/// 2026-09-25: `(shrink, expand)` grids of the gather fold: `[ceil(out/4),
191/// n_slots, 1]`, where `out` is `max_rank` for the shrink and `n_out` for the
192/// expand. Each 256-thread block computes 4 outputs, 64 threads per output
193/// (`GBGMV_N_PER_BLOCK` in `moe_lora_gather_bgmv.cu`).
194pub fn gather_bgmv_grids(max_rank: u32, n_out: u32, n_slots: u32) -> ([u32; 3], [u32; 3]) {
195    use metrale_gpu_runtime::kernel_args::div_ceil;
196    (
197        [div_ceil(max_rank, 4), n_slots, 1],
198        [div_ceil(n_out, 4), n_slots, 1],
199    )
200}
201
202/// 2026-09-25: The token that owns flat `(token, slot)` row `row`: the gather
203/// kernels' `row / top_k`.
204pub fn gather_row_token(row: u32, top_k: u32) -> u32 {
205    row / top_k
206}
207
208/// 2026-09-25: Fold `base_out[row] += scale_e * (x[x_row] @ A_e^T) @ B_e^T` for
209/// every flat `(token, slot)` row in `[0, n_slots)`, where `e = indices[row]`.
210/// This is the unsorted counterpart of [`moe_lora_grouped_down`]: the expert
211/// comes from `indices` instead of an `expert_offsets` span.
212///
213/// - `x`: BF16. With `x_gather == 0` (down) it is `[n_slots, k_in]` and row
214///   `row` is read. With `x_gather == 1` (gate/up) it is `[num_tokens, k_in]`
215///   and row `row / top_k` is read.
216/// - `base_out`: `[n_slots, n_out]` BF16, folded in place.
217/// - `indices`: device `[n_slots]` u32 expert id per row.
218/// - `row_adapter`: device `[num_tokens]` i32, where a token `< 0` is skipped;
219///   `DevicePtr::NULL` folds every row.
220/// - `xa`: fixed-address `[n_slots, max_rank]` BF16 shrink scratch.
221///
222/// The argument order must match the kernel parameters in
223/// `kernels/gb10/common/moe_lora_gather_bgmv.cu`: `cuLaunchKernel` does not
224/// check types.
225#[allow(clippy::too_many_arguments)]
226pub fn moe_lora_gather_bgmv(
227    gpu: &dyn GpuBackend,
228    kernels: &LoraKernels,
229    route: &MoeExpertRoute,
230    x: DevicePtr,
231    base_out: DevicePtr,
232    indices: DevicePtr,
233    row_adapter: DevicePtr,
234    xa: DevicePtr,
235    n_slots: u32,
236    top_k: u32,
237    x_gather: u32,
238    stream: u64,
239) -> Result<()> {
240    use metrale_gpu_runtime::kernel_args::KernelLaunch;
241
242    anyhow::ensure!(
243        kernels.moe_gather_shrink_k.0 != 0 && kernels.moe_gather_expand_fold_k.0 != 0,
244        "moe_lora_gather_bgmv kernels unresolved (module `moe_lora_gather_bgmv` missing \
245         from the compiled kernel set — CUDA build required)"
246    );
247    let (shrink_grid, expand_grid) = gather_bgmv_grids(route.max_rank, route.n_out, n_slots);
248
249    // 2026-09-25: Shrink: `xa[row] = x[x_row] @ A_e^T`, stored as BF16.
250    KernelLaunch::new(gpu, kernels.moe_gather_shrink_k)
251        .grid(shrink_grid)
252        .block([256, 1, 1])
253        .arg_ptr(x)
254        .arg_ptr(indices)
255        .arg_ptr(row_adapter)
256        .arg_ptr(route.a_table)
257        .arg_ptr(xa)
258        .arg_u32(n_slots)
259        .arg_u32(top_k)
260        .arg_u32(route.n_experts)
261        .arg_u32(route.max_rank)
262        .arg_u32(route.k_in)
263        .arg_u32(x_gather)
264        .launch(stream)?;
265
266    // 2026-09-25: Expand and fold: `base_out[row] += scale_e * (xa[row] @ B_e^T)`.
267    KernelLaunch::new(gpu, kernels.moe_gather_expand_fold_k)
268        .grid(expand_grid)
269        .block([256, 1, 1])
270        .arg_ptr(xa)
271        .arg_ptr(indices)
272        .arg_ptr(row_adapter)
273        .arg_ptr(route.b_table)
274        .arg_ptr(route.scale_table)
275        .arg_ptr(base_out)
276        .arg_u32(n_slots)
277        .arg_u32(top_k)
278        .arg_u32(route.n_experts)
279        .arg_u32(route.n_out)
280        .arg_u32(route.max_rank)
281        .launch(stream)
282}
283
284#[cfg(test)]
285#[path = "moe_lora_grouped_tests.rs"]
286mod tests;