spark_model/lora/expert_apply.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Feature-1 phase-1 correctness-first MoE LoRA apply.
4//!
5//! The routed base MoE GEMM stays BYTE-IDENTICAL: this module folds an additive
6//! BF16 delta onto its output buffers via the existing `apply_lora_delta`
7//! (GEMV/GEMM shrink→expand + `scaled_add`), so there is **no new CUDA kernel**.
8//! Two injection shapes:
9//!
10//! - **router** (`apply_router_lora`): one `apply_lora_delta` folding
11//! `scale·(router_in @ Aᵀ) @ Bᵀ` onto the `[n, num_experts]` routing logits,
12//! BEFORE top-k selection — reproduces PEFT `mlp.gate` (a routing-logit delta).
13//! - **experts** (`apply_expert_lora_sorted`): after the base grouped GEMM
14//! writes the sorted `[total_expanded, n_out]` output, loop the ADAPTED experts
15//! and fold each one's delta onto ITS contiguous row range (`expert_offsets`),
16//! BEFORE `moe_unpermute_reduce_indexed` so the router weight multiplies
17//! `base+delta` exactly like PEFT. For gate/up inject before `silu_mul`; for
18//! down inject after the down GEMM.
19//!
20//! PHASE-1 caveats (deliberate, throwaway scaffold — do NOT benchmark):
21//! * single-active adapter (installed-pair path; no per-request `seq_slot`
22//! routing over experts — that is the phase-2 2-D `(slot, expert)` grouped
23//! BGMV kernel);
24//! * the caller D2H-copies `expert_offsets` once per MoE layer to drive the
25//! host loop — this BREAKS CUDA-graph capture (legal in eager prefill;
26//! phase-2 removes it). See `MoeLayer` injection sites.
27
28use anyhow::Result;
29use spark_runtime::gpu::{DevicePtr, GpuBackend};
30
31use super::{ExpertLoraLayer, ExpertProj};
32use crate::layers::ops::lora_delta::{LoraKernels, LoraPair, apply_lora_delta};
33
34const BF16_BYTES: u64 = 2;
35
36/// One expert's contiguous sorted-row range (the grouped-GEMM row block for
37/// that expert). `rows == 0` experts are dropped by the planner.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct ExpertWork {
40 pub expert: u16,
41 pub row_off: u32,
42 pub rows: u32,
43}
44
45/// PURE: from the base MoE `expert_offsets` prefix-sum (`[num_experts + 1]`,
46/// `expert_offsets[e]..expert_offsets[e+1]` = expert `e`'s sorted rows) and the
47/// adapter's adapted-expert set, produce the (expert, row_off, rows) work-items
48/// for the delta side-path. Experts with zero routed rows or a malformed offset
49/// pair are skipped (never a panic). This is the correctness-critical mapping
50/// and is unit-tested without a GPU.
51pub fn expert_delta_workitems(expert_offsets: &[u32], adapted: &[u16]) -> Vec<ExpertWork> {
52 let n_experts = expert_offsets.len().saturating_sub(1);
53 let mut work = Vec::with_capacity(adapted.len());
54 for &e in adapted {
55 let e_us = e as usize;
56 if e_us >= n_experts {
57 continue; // out of range for this layer's routing table
58 }
59 let start = expert_offsets[e_us];
60 let end = expert_offsets[e_us + 1];
61 if end <= start {
62 continue; // no tokens routed to this expert this step
63 }
64 work.push(ExpertWork {
65 expert: e,
66 row_off: start,
67 rows: end - start,
68 });
69 }
70 work
71}
72
73/// Fold one projection's delta over `rows` contiguous rows, CHUNKED so the
74/// caller's scratch (`lora_xa >= max_rows·max_rank`, `lora_delta >=
75/// max_rows·n_out` BF16) is never overrun. `rows > max_rows` is split into
76/// `ceil(rows/max_rows)` `apply_lora_delta` folds over disjoint row blocks —
77/// byte-identical to one fold (each row is independent).
78#[allow(clippy::too_many_arguments)]
79fn fold_chunked(
80 gpu: &dyn GpuBackend,
81 kernels: &LoraKernels,
82 pair: &LoraPair,
83 x: DevicePtr,
84 base_out: DevicePtr,
85 rows: u32,
86 max_rows: u32,
87 lora_xa: DevicePtr,
88 lora_delta: DevicePtr,
89 stream: u64,
90) -> Result<()> {
91 let step = max_rows.max(1);
92 let mut done = 0u32;
93 while done < rows {
94 let m = (rows - done).min(step);
95 let x_row = x.offset((done as u64 * pair.k_in as u64 * BF16_BYTES) as usize);
96 let out_row = base_out.offset((done as u64 * pair.n_out as u64 * BF16_BYTES) as usize);
97 apply_lora_delta(
98 gpu, kernels, pair, x_row, out_row, m, lora_xa, lora_delta, stream,
99 )?;
100 done += m;
101 }
102 Ok(())
103}
104
105/// Fold the router (`mlp.gate`) LoRA delta onto the routing logits in place,
106/// BEFORE top-k. `router_in` = `[n, hidden]`, `gate_logits` = `[n, num_experts]`
107/// (modified in place). Chunked by `max_rows` (the scratch capacity in rows).
108#[allow(clippy::too_many_arguments)]
109pub fn apply_router_lora(
110 gpu: &dyn GpuBackend,
111 kernels: &LoraKernels,
112 pair: &LoraPair,
113 router_in: DevicePtr,
114 gate_logits: DevicePtr,
115 n: u32,
116 max_rows: u32,
117 lora_xa: DevicePtr,
118 lora_delta: DevicePtr,
119 stream: u64,
120) -> Result<()> {
121 fold_chunked(
122 gpu,
123 kernels,
124 pair,
125 router_in,
126 gate_logits,
127 n,
128 max_rows,
129 lora_xa,
130 lora_delta,
131 stream,
132 )
133}
134
135/// Fold `proj`'s per-expert LoRA deltas onto the SORTED grouped-GEMM output.
136///
137/// `x` = the projection's sorted input (`[total_expanded, pair.k_in]`),
138/// `base_out` = the projection's sorted output (`[total_expanded, pair.n_out]`,
139/// modified in place). `expert_offsets_host` is the D2H copy of the device
140/// `expert_offsets` (`[num_experts + 1]`). One `apply_lora_delta(m = rows)` per
141/// adapted expert, over that expert's contiguous row block — byte-identical to
142/// `rows` sequential `m=1` folds. Only experts with a pair for `proj` AND
143/// non-zero routed rows launch.
144#[allow(clippy::too_many_arguments)]
145pub fn apply_expert_lora_sorted(
146 gpu: &dyn GpuBackend,
147 kernels: &LoraKernels,
148 layer: &ExpertLoraLayer,
149 proj: ExpertProj,
150 expert_offsets_host: &[u32],
151 x: DevicePtr,
152 base_out: DevicePtr,
153 max_rows: u32,
154 lora_xa: DevicePtr,
155 lora_delta: DevicePtr,
156 stream: u64,
157) -> Result<()> {
158 let work = expert_delta_workitems(expert_offsets_host, &layer.adapted_experts());
159 for w in work {
160 let Some(pair) = layer.pair(w.expert, proj) else {
161 continue; // this expert adapts a different projection only
162 };
163 let x_row = x.offset((w.row_off as u64 * pair.k_in as u64 * BF16_BYTES) as usize);
164 let out_row = base_out.offset((w.row_off as u64 * pair.n_out as u64 * BF16_BYTES) as usize);
165 fold_chunked(
166 gpu, kernels, pair, x_row, out_row, w.rows, max_rows, lora_xa, lora_delta, stream,
167 )?;
168 }
169 Ok(())
170}
171
172#[cfg(test)]
173#[path = "expert_apply_tests.rs"]
174mod tests;