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;