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}