spark_model/layers/ops/
lora_delta.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Runtime LoRA delta: y += scale * (x @ A^T) @ B^T, BF16 side-path.
4//! Zero new CUDA kernels — reuses dense_gemv_bf16 / dense_gemm_tc /
5//! dense_gemm_bf16 / bf16_scaled_add, all shipped in kernels/gb10/common/.
6
7use anyhow::Result;
8use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
9
10use crate::layers::ops;
11use crate::weight_map::DenseWeight;
12
13/// Resolved once at adapter load (module names per common/KERNEL.toml:
14/// gemv=dense_gemv_bf16.cu, gemm_tc=dense_gemm_tc.cu, gemm=dense_gemm_bf16.cu,
15/// residual_add=residual_add.cu — stem, no override).
16#[derive(Clone, Copy)]
17pub struct LoraKernels {
18    pub gemv_k: KernelHandle,
19    pub gemm_tc_k: KernelHandle, // KernelHandle(0) on miss -> gemm_k fallback
20    /// Fused expand+fold (`C += scale * A@B^T`). `0` on miss -> the
21    /// unfused gemm_tc + scaled_add pair, which is what this replaces.
22    pub gemm_tc_acc_k: KernelHandle,
23    pub gemm_k: KernelHandle,
24    pub scaled_add_k: KernelHandle,
25    /// M2 fused batched bgmv (per-request routing): shrink then expand+fold.
26    /// Module "lora_bgmv" (stem == module — no KERNEL.toml override).
27    pub bgmv_shrink_k: KernelHandle,
28    pub bgmv_expand_fold_k: KernelHandle,
29    /// Feature-1 device-side MoE expert down_proj fold: shrink then expand+fold,
30    /// keyed on expert via device `expert_offsets`. Module "moe_lora_grouped_down"
31    /// (stem == module). `KernelHandle(0)` on miss (e.g. non-CUDA builds) — the
32    /// launcher bails loudly rather than dispatching a null handle.
33    pub moe_down_shrink_k: KernelHandle,
34    pub moe_down_expand_fold_k: KernelHandle,
35    /// SOLID Incr-4 DECODE-path MoE expert down_proj fold: shrink then
36    /// expand+fold, keyed on expert via the per-slot `indices` array (the
37    /// unsorted slot-major analogue of `moe_down_*_k`). Module
38    /// "moe_lora_gather_bgmv" (stem == module). `KernelHandle(0)` on miss (e.g.
39    /// non-CUDA builds) — the launcher bails loudly rather than dispatching null.
40    pub moe_gather_shrink_k: KernelHandle,
41    pub moe_gather_expand_fold_k: KernelHandle,
42}
43
44impl LoraKernels {
45    pub fn new(gpu: &dyn GpuBackend) -> Result<Self> {
46        Ok(Self {
47            gemv_k: gpu.kernel("gemv", "dense_gemv_bf16")?,
48            gemm_tc_k: crate::layers::try_kernel(gpu, "gemm_tc", "dense_gemm_tc"),
49            gemm_tc_acc_k: crate::layers::try_kernel(gpu, "gemm_tc", "dense_gemm_tc_scaled_acc"),
50            gemm_k: gpu.kernel("gemm", "dense_gemm_bf16")?,
51            scaled_add_k: gpu.kernel("residual_add", "bf16_scaled_add")?,
52            bgmv_shrink_k: gpu.kernel("lora_bgmv", "lora_bgmv_shrink")?,
53            bgmv_expand_fold_k: gpu.kernel("lora_bgmv", "lora_bgmv_expand_fold")?,
54            moe_down_shrink_k: crate::layers::try_kernel(
55                gpu,
56                "moe_lora_grouped_down",
57                "moe_lora_grouped_down_shrink",
58            ),
59            moe_down_expand_fold_k: crate::layers::try_kernel(
60                gpu,
61                "moe_lora_grouped_down",
62                "moe_lora_grouped_down_expand_fold",
63            ),
64            moe_gather_shrink_k: crate::layers::try_kernel(
65                gpu,
66                "moe_lora_gather_bgmv",
67                "moe_lora_gather_bgmv_shrink",
68            ),
69            moe_gather_expand_fold_k: crate::layers::try_kernel(
70                gpu,
71                "moe_lora_gather_bgmv",
72                "moe_lora_gather_bgmv_expand_fold",
73            ),
74        })
75    }
76}
77
78/// Frozen per-(layer,module) routing tables the bgmv reads: the `[max_loras]`
79/// device pointer tables (`a_table`/`b_table`, NULL=base) + the shared
80/// `[max_loras]` f32 `scale_table`, plus the projection dims. Load-time-fixed
81/// device addresses (built at pool pack time), so they are stable kernel args
82/// across CUDA-graph capture/replay — adapter identity flows ONLY through the
83/// per-step `seq_slot` buffer. Installed by copy onto the layer next to the
84/// active-slot [`LoraPair`] (which the single-seq n==1 path still uses).
85#[derive(Debug, Clone, Copy)]
86pub struct LoraRoute {
87    pub a_table: DevicePtr,
88    pub b_table: DevicePtr,
89    pub scale_table: DevicePtr,
90    pub k_in: u32,
91    pub n_out: u32,
92    pub max_rank: u32,
93}
94
95/// One adapted module. A/B are PEFT tensors VERBATIM (host F16->BF16 at load):
96///   a: [rank, k_in]  row-major BF16  (PEFT lora_A [r, in_features] — already
97///                                     the B-operand `[N,K]` layout dense_* expect)
98///   b: [n_out, rank] row-major BF16  (PEFT lora_B [out_features, r] — likewise)
99/// Both are rank-padded to the pool's max_rank (zero rows/cols beyond `rank`),
100/// so kernels may uniformly run at the pool rank — bit-identical to true rank.
101/// scale = lora_alpha/r, or lora_alpha/sqrt(r) under use_rslora — read per
102/// adapter at load, never defaulted. Do NOT pre-fold into B (keeps tensors
103/// verbatim for the M0 offline parity test); it rides the scaled_add for free.
104#[derive(Debug, Clone, Copy)]
105pub struct LoraPair {
106    pub a: DenseWeight,
107    pub b: DenseWeight,
108    pub rank: u32,
109    pub k_in: u32,
110    pub n_out: u32,
111    pub scale: f32,
112    /// The pool's padded rank — the ROW STRIDE of `b` (and row count of `a`).
113    /// Kernels MUST contract/produce at this dim, not `rank`: B rows are
114    /// `max_rank` elements apart in the pool, so a `k = rank` expand would
115    /// misread every row past the first when `rank < max_rank`. Pad rows of
116    /// A and pad cols of B are zeroed at pack time, so running the shrink at
117    /// `n = max_rank` and the expand at `k = max_rank` is bit-identical to
118    /// the true-rank product.
119    pub max_rank: u32,
120}
121
122/// Per-layer attention-side LoRA weights, installed by copy onto
123/// `Qwen3AttentionLayer`.
124///
125/// The `q` pair folds the delta into the RAW q_proj output at offset 0, full
126/// width = q_proj_dim (on a gated model the interleaved `[Q|gate]`, width
127/// `2·q_heads·head_dim`), BEFORE the `deinterleave_qg` split — the PEFT
128/// `lora_B` was trained against exactly that interleaved basis, so the delta
129/// applies like k/v/o, just wider.
130#[derive(Clone, Copy)]
131pub struct LoraAttnWeights {
132    /// #30: the TRUE global layer index (`0..num_hidden_layers`), stamped at
133    /// install from the global `idx`. The prefill apply sites index the
134    /// request slot's GLOBAL-layer-indexed pairs with THIS (not `attn_layer_idx`,
135    /// an attention-only counter that diverges from the global index on hybrid
136    /// GDN/attention models).
137    pub layer_idx: usize,
138    pub q: Option<LoraPair>,
139    pub k: Option<LoraPair>,
140    pub v: Option<LoraPair>,
141    pub o: Option<LoraPair>,
142    pub kernels: LoraKernels,
143    /// M2 per-request routing tables (per module). `None` = single/global
144    /// adapter with no routing (the n==1 path uses the pair above and stays
145    /// byte-identical). `Some` when a multi-adapter pool is resident; the
146    /// batched decode path reads these + the per-seq `seq_slot` via the bgmv.
147    pub q_route: Option<LoraRoute>,
148    pub k_route: Option<LoraRoute>,
149    pub v_route: Option<LoraRoute>,
150    pub o_route: Option<LoraRoute>,
151}
152
153/// Per-layer dense-FFN LoRA weights, installed by copy onto `DenseFfnLayer`.
154#[derive(Clone, Copy)]
155pub struct LoraFfnWeights {
156    pub gate: Option<LoraPair>,
157    pub up: Option<LoraPair>,
158    pub down: Option<LoraPair>,
159    pub kernels: LoraKernels,
160}
161
162/// base_out[m, n_out] += scale * (x[m, k_in] @ a^T) @ b^T.
163///
164/// CONTIGUITY CONTRACT: x rows contiguous with stride k_in*2 bytes, base_out
165/// rows contiguous with stride n_out*2 bytes. Every v0 site satisfies this
166/// (k/v/o/gate/up/down all land in dedicated contiguous buffers/regions);
167/// strided cases (multi-seq per-seq qkv_buf) must loop with m=1 on offset ptrs.
168///
169/// GRAPH-SAFE: pure kernel launches, no alloc/sync; a/b (load-time device
170/// weights), lora_xa/lora_delta (BufferArena, fixed address), and scale
171/// (baked kernel arg, constant for a startup-static adapter) are all
172/// pointer/value-stable across capture and replay — identical status to base
173/// weights. m==1 -> GEMV; m>1 -> tensor-core GEMM (scalar fallback).
174///
175/// POOL LAYOUT (lora/mod.rs pack): A is [max_rank, k_in] (real rows at the
176/// head, pad rows zero), B is [n_out, max_rank] row-major (pad COLS zero,
177/// row stride = max_rank). Both stages therefore run at `pair.max_rank`:
178/// shrink n = max_rank (xa pad cols come out zero), expand k = max_rank
179/// (matches B's row stride; zero pads contribute nothing) — bit-identical
180/// to a true-rank product.
181/// `ATLAS_LORA_NO_APPLY=1` — keep the adapter RESIDENT but skip every delta.
182///
183/// A measurement lever, not a serving one: it separates "what does applying
184/// the adapter cost" from "what does having an adapter loaded cost", which are
185/// different numbers and were conflated the first time this path was profiled.
186/// Output is base-like while set, so it is useless for serving and is never a
187/// default.
188pub fn lora_no_apply() -> bool {
189    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
190    *V.get_or_init(|| std::env::var("ATLAS_LORA_NO_APPLY").as_deref() == Ok("1"))
191}
192
193/// Row count at or below which the delta runs as `m` row-wise GEMVs instead
194/// of one GEMM (`ATLAS_LORA_GEMV_MAX_M`, default 8).
195///
196/// `dense_gemm_tc` tiles 16 rows. At m=2 it does the FULL B-matrix traffic —
197/// B is [n_out, max_rank] and independent of m — to produce 2 useful rows out
198/// of 16, at one row-block of grid. Measured on qwen3.8-27B + an r=64 adapter:
199/// the FFN deltas cost ~8% of a decode step at m=1 (GEMV) and ~64% at m=2
200/// (GEMM). Same work, 8x the price, purely from crossing this boundary.
201///
202/// The GEMV loop is also the canonical form: `apply_lora_bgmv` documents
203/// itself as byte-identical to `n` single-row `apply_lora_delta` calls, so
204/// this makes the small-m path agree with that oracle rather than diverge
205/// from it.
206///
207/// Default 48 covers the plain decode ladder (C=1..8) AND the speculative
208/// verify shapes, which present n*k rows — up to 4 seqs x k=9 = 36 under
209/// cross-sequence batched DFlash verify. 8 left those on the GEMM: raising it
210/// took DFlash+LoRA from 34.8 to 48.6 tok/s at C=2 and 47.6 to 54.2 at C=4,
211/// with accepts and output unchanged. Prefill's m is orders of magnitude
212/// larger and keeps the GEMM.
213pub fn lora_gemv_max_m() -> u32 {
214    static V: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
215    *V.get_or_init(|| {
216        std::env::var("ATLAS_LORA_GEMV_MAX_M")
217            .ok()
218            .and_then(|v| v.parse().ok())
219            .unwrap_or(48)
220    })
221}
222
223/// `ATLAS_LORA_NO_FFN=1` — skip the dense-FFN and GDN-out_proj deltas, keep
224/// the attention ones.
225///
226/// The companion to `ATLAS_LORA_NO_APPLY`: that one answers "deltas or
227/// residency", this one answers "which deltas". Measurement only; output is
228/// wrong while set.
229pub fn lora_no_ffn() -> bool {
230    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
231    *V.get_or_init(|| std::env::var("ATLAS_LORA_NO_FFN").as_deref() == Ok("1"))
232}
233
234#[allow(clippy::too_many_arguments)]
235pub fn apply_lora_delta(
236    gpu: &dyn GpuBackend,
237    kernels: &LoraKernels,
238    pair: &LoraPair,
239    x: DevicePtr,        // [m, pair.k_in] BF16
240    base_out: DevicePtr, // [m, pair.n_out] BF16, modified in place
241    m: u32,
242    lora_xa: DevicePtr,    // arena scratch >= m * max_rank BF16
243    lora_delta: DevicePtr, // arena scratch >= m * n_out BF16
244    stream: u64,
245) -> Result<()> {
246    if lora_no_apply() {
247        return Ok(());
248    }
249    // Small-m: run `m` single-row deltas rather than one 16-row-tiled GEMM.
250    // Rows are contiguous by this function's contiguity contract, so a row is
251    // just a pointer offset. See `lora_gemv_max_m` for why.
252    if m > 1 && m <= lora_gemv_max_m() {
253        for row in 0..m {
254            let x_row = DevicePtr(x.0 + (row as u64) * (pair.k_in as u64) * 2);
255            let out_row = DevicePtr(base_out.0 + (row as u64) * (pair.n_out as u64) * 2);
256            apply_lora_delta(
257                gpu, kernels, pair, x_row, out_row, 1, lora_xa, lora_delta, stream,
258            )?;
259        }
260        return Ok(());
261    }
262    if m == 1 {
263        // shrink: [1,k_in] @ A[max_rank,k_in]^T -> xa[1,max_rank]
264        ops::dense_gemv(
265            gpu,
266            kernels.gemv_k,
267            x,
268            &pair.a,
269            lora_xa,
270            pair.max_rank,
271            pair.k_in,
272            stream,
273        )?;
274        // expand: [1,max_rank] @ B[n_out,max_rank]^T -> delta[1,n_out]
275        ops::dense_gemv(
276            gpu,
277            kernels.gemv_k,
278            lora_xa,
279            &pair.b,
280            lora_delta,
281            pair.n_out,
282            pair.max_rank,
283            stream,
284        )?;
285    } else if kernels.gemm_tc_acc_k.0 != 0 && kernels.gemm_tc_k.0 != 0 {
286        // FUSED path: shrink, then expand-and-fold in one launch. Skips the
287        // [m, n_out] scratch entirely — no write, no read-back, and no
288        // separate scaled_add pass over it. Bit-identical to the pair below.
289        ops::dense_gemm_tc(
290            gpu,
291            kernels.gemm_tc_k,
292            x,
293            &pair.a,
294            lora_xa,
295            m,
296            pair.max_rank,
297            pair.k_in,
298            stream,
299        )?;
300        return ops::dense_gemm_tc_scaled_acc(
301            gpu,
302            kernels.gemm_tc_acc_k,
303            lora_xa,
304            &pair.b,
305            base_out,
306            m,
307            pair.n_out,
308            pair.max_rank,
309            pair.scale,
310            stream,
311        );
312    } else if kernels.gemm_tc_k.0 != 0 {
313        ops::dense_gemm_tc(
314            gpu,
315            kernels.gemm_tc_k,
316            x,
317            &pair.a,
318            lora_xa,
319            m,
320            pair.max_rank,
321            pair.k_in,
322            stream,
323        )?;
324        ops::dense_gemm_tc(
325            gpu,
326            kernels.gemm_tc_k,
327            lora_xa,
328            &pair.b,
329            lora_delta,
330            m,
331            pair.n_out,
332            pair.max_rank,
333            stream,
334        )?;
335    } else {
336        ops::dense_gemm(
337            gpu,
338            kernels.gemm_k,
339            x,
340            &pair.a,
341            lora_xa,
342            m,
343            pair.max_rank,
344            pair.k_in,
345            stream,
346        )?;
347        ops::dense_gemm(
348            gpu,
349            kernels.gemm_k,
350            lora_xa,
351            &pair.b,
352            lora_delta,
353            m,
354            pair.n_out,
355            pair.max_rank,
356            stream,
357        )?;
358    }
359    // fold: base_out += scale * delta   (kernels/gb10/common/residual_add.cu:60)
360    ops::scaled_add(
361        gpu,
362        kernels.scaled_add_k,
363        base_out,
364        lora_delta,
365        pair.scale,
366        m * pair.n_out,
367        stream,
368    )
369}
370
371/// M2 per-request routed LoRA delta over a batch of `n` decode rows, each
372/// naming its own adapter slot via `seq_slot[n]` (i32, `<0` = base/no delta).
373/// Two launches — shrink then expand+fold — reading the module's frozen
374/// `route.a_table`/`route.b_table`/`route.scale_table` (`[max_loras]` device
375/// arrays, NULL/0 = base-only slot) at the load-time-fixed pool addresses.
376///
377/// `out[i, :] += scale_s * (x[i, :] @ A_s^T) @ B_s^T`   where `s = seq_slot[i]`.
378///
379/// BYTE-IDENTICAL to `n` sequential `apply_lora_delta(m=1)` calls for the same
380/// `(x_i, s_i)` (the on-hardware oracle): kernel 1 is `dense_gemv_bf16` with a
381/// per-row A-base gather (emits BF16 xa = the oracle's lora_xa boundary);
382/// kernel 2 is the same body reading BF16 xa back, then the oracle's fold
383/// (round delta→BF16, then `base += scale*bf16(delta)`), so per-slot scale is
384/// applied in fp32 AFTER the BF16 delta rounding. Contraction runs at
385/// `route.max_rank` (never true rank) — pad rows/cols are zero, bit-identical.
386///
387/// STRIDES (elements, not bytes):
388/// - `x_row_stride`   : distance between `x` rows (normed = `h`; attn_out = `q_dim`).
389/// - `out_row_stride` : distance between `base_out` rows. Contiguous O uses
390///   `n_out`; the STRIDED K/V `qkv_buf` uses `per_seq_qkv/2` (BF16 elements) so
391///   the fold lands inside the interleaved `[Q|K|V]` layout without corrupting it.
392///
393/// GRAPH-SAFE: only pointer/value-stable args — `x`/`base_out` are the fixed
394/// forward buffers, the tables are load-time-fixed, `xa` is a fixed arena
395/// scratch (`>= n*max_rank` BF16), and `seq_slot` is a fixed-address buffer
396/// whose CONTENTS are re-uploaded each decode step (like positions/block_table).
397/// No alloc/sync — captures inside the decode graph.
398///
399/// ARG ORDER is in lockstep with `lora_bgmv.cu` (cuLaunchKernel is type-blind;
400/// the byte-identity oracle is the only guard — keep both in sync).
401#[allow(clippy::too_many_arguments)]
402pub fn apply_lora_bgmv(
403    gpu: &dyn GpuBackend,
404    kernels: &LoraKernels,
405    route: &LoraRoute,
406    x: DevicePtr,        // [n, x_row_stride] BF16
407    base_out: DevicePtr, // [n, out_row_stride] BF16, folded in place
408    seq_slot: DevicePtr, // [n] i32 (<0 => base)
409    n: u32,              // batch rows
410    x_row_stride: u32,   // elements between x rows (>= route.k_in)
411    out_row_stride: u32, // elements between base_out rows (>= route.n_out)
412    lora_xa: DevicePtr,  // arena scratch >= n * max_rank BF16
413    stream: u64,
414) -> Result<()> {
415    use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
416
417    // Kernel 1: shrink — xa[n, max_rank] = x @ A_s^T.
418    // grid = (ceil(max_rank/4), n, 1)  block = (256,1,1).
419    KernelLaunch::new(gpu, kernels.bgmv_shrink_k)
420        .grid([div_ceil(route.max_rank, 4), n, 1])
421        .block([256, 1, 1])
422        .arg_ptr(x)
423        .arg_ptr(seq_slot)
424        .arg_ptr(route.a_table)
425        .arg_ptr(lora_xa)
426        .arg_u32(n)
427        .arg_u32(route.max_rank)
428        .arg_u32(route.k_in)
429        .arg_u32(x_row_stride)
430        .launch(stream)?;
431
432    // Kernel 2: expand + fold — base_out += scale_s * (xa @ B_s^T).
433    // grid = (ceil(n_out/4), n, 1)  block = (256,1,1).
434    KernelLaunch::new(gpu, kernels.bgmv_expand_fold_k)
435        .grid([div_ceil(route.n_out, 4), n, 1])
436        .block([256, 1, 1])
437        .arg_ptr(lora_xa)
438        .arg_ptr(seq_slot)
439        .arg_ptr(route.b_table)
440        .arg_ptr(route.scale_table)
441        .arg_ptr(base_out)
442        .arg_u32(n)
443        .arg_u32(route.n_out)
444        .arg_u32(route.max_rank)
445        .arg_u32(out_row_stride)
446        .launch(stream)
447}