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;