metrale_model_layers/layers/ops/lora_delta.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Runtime LoRA delta `y += scale * (x @ A^T) @ B^T` on BF16 activations: the single-adapter path and the per-request routed path.
4//!
5//! Owner: model-layers ops.
6//! Invariants:
7//! - Both stages contract at the pool's padded rank `max_rank`, never at the adapter's rank;
8//! the pool is zeroed before packing, so the pad rows of A and pad columns of B are zero
9//! (`lora/loading.rs`).
10//! - The launchers here only launch kernels: they allocate nothing and do not synchronize.
11//! - `apply_lora_delta` returns without launching when `METRALE_LORA_NO_APPLY=1`.
12
13use anyhow::Result;
14use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
15
16use crate::layers::ops;
17use crate::weight_map::DenseWeight;
18
19/// 2026-09-25: The kernel handles the LoRA launchers use, resolved when an adapter is installed.
20/// Module names follow the `[modules]` table of kernels/gb10/common/KERNEL.toml
21/// (`dense_gemv_bf16.cu` is `gemv`, `dense_gemm_tc.cu` is `gemm_tc`, `dense_gemm_bf16.cu` is
22/// `gemm`); `residual_add`, `lora_bgmv`, `moe_lora_grouped_down` and `moe_lora_gather_bgmv` are
23/// file stems.
24#[derive(Clone, Copy)]
25pub struct LoraKernels {
26 pub gemv_k: KernelHandle,
27 pub gemm_tc_k: KernelHandle, // 2026-09-25: KernelHandle(0) when missing; `gemm_k` is used instead.
28 /// 2026-09-25: Fused expand and fold (`C += scale * A @ B^T`). `KernelHandle(0)` when missing;
29 /// the unfused `gemm_tc` + `scaled_add` pair is used instead.
30 pub gemm_tc_acc_k: KernelHandle,
31 pub gemm_k: KernelHandle,
32 pub scaled_add_k: KernelHandle,
33 /// 2026-09-25: The per-request routed kernels of [`apply_lora_bgmv`]: shrink, then expand and
34 /// fold.
35 pub bgmv_shrink_k: KernelHandle,
36 pub bgmv_expand_fold_k: KernelHandle,
37 /// 2026-09-25: MoE expert LoRA over expert-sorted rows, keyed on expert through the device
38 /// `expert_offsets`: shrink, then expand and fold. `KernelHandle(0)` when missing; the launcher
39 /// in `moe_lora_grouped.rs` then returns an error rather than launching.
40 pub moe_down_shrink_k: KernelHandle,
41 pub moe_down_expand_fold_k: KernelHandle,
42 /// 2026-09-25: MoE expert LoRA over unsorted, slot-major rows, keyed on expert through the
43 /// per-slot `indices` array: shrink, then expand and fold. `KernelHandle(0)` when missing; the
44 /// launcher in `moe_lora_grouped.rs` then returns an error rather than launching.
45 pub moe_gather_shrink_k: KernelHandle,
46 pub moe_gather_expand_fold_k: KernelHandle,
47}
48
49impl LoraKernels {
50 pub fn new(gpu: &dyn GpuBackend) -> Result<Self> {
51 Ok(Self {
52 gemv_k: gpu.kernel("gemv", "dense_gemv_bf16")?,
53 gemm_tc_k: crate::layers::try_kernel(gpu, "gemm_tc", "dense_gemm_tc"),
54 gemm_tc_acc_k: crate::layers::try_kernel(gpu, "gemm_tc", "dense_gemm_tc_scaled_acc"),
55 gemm_k: gpu.kernel("gemm", "dense_gemm_bf16")?,
56 scaled_add_k: gpu.kernel("residual_add", "bf16_scaled_add")?,
57 bgmv_shrink_k: gpu.kernel("lora_bgmv", "lora_bgmv_shrink")?,
58 bgmv_expand_fold_k: gpu.kernel("lora_bgmv", "lora_bgmv_expand_fold")?,
59 moe_down_shrink_k: crate::layers::try_kernel(
60 gpu,
61 "moe_lora_grouped_down",
62 "moe_lora_grouped_down_shrink",
63 ),
64 moe_down_expand_fold_k: crate::layers::try_kernel(
65 gpu,
66 "moe_lora_grouped_down",
67 "moe_lora_grouped_down_expand_fold",
68 ),
69 moe_gather_shrink_k: crate::layers::try_kernel(
70 gpu,
71 "moe_lora_gather_bgmv",
72 "moe_lora_gather_bgmv_shrink",
73 ),
74 moe_gather_expand_fold_k: crate::layers::try_kernel(
75 gpu,
76 "moe_lora_gather_bgmv",
77 "moe_lora_gather_bgmv_expand_fold",
78 ),
79 })
80 }
81}
82
83/// 2026-09-25: Per-(layer, module) routing tables that [`apply_lora_bgmv`] reads: the
84/// `[max_loras]` device tables of A and B pool addresses (`a_table` / `b_table`, 0 = the slot
85/// does not adapt this module), the `[max_loras]` f32 `scale_table`, and the projection dims.
86/// The addresses are fixed when the pool is packed, so the adapter for each row is chosen only
87/// by the per-step `seq_slot` buffer. Installed by copy onto the layer next to the active
88/// [`LoraPair`], which the unrouted path uses.
89#[derive(Debug, Clone, Copy)]
90pub struct LoraRoute {
91 pub a_table: DevicePtr,
92 pub b_table: DevicePtr,
93 pub scale_table: DevicePtr,
94 pub k_in: u32,
95 pub n_out: u32,
96 pub max_rank: u32,
97}
98
99/// 2026-09-25: One adapted module, with the PEFT tensors in their own layouts, which are the
100/// `[N, K]` weight layout the dense kernels expect:
101/// a: `[max_rank, k_in]` row-major BF16 (PEFT `lora_A` `[r, in_features]` in the first `rank`
102/// rows, zero rows after);
103/// b: `[n_out, max_rank]` row-major BF16 (PEFT `lora_B` `[out_features, r]` in the first
104/// `rank` columns of each row, zero columns after).
105/// `scale` is `lora_alpha / r`, or `lora_alpha / sqrt(r)` with `use_rslora`, from the adapter's
106/// config, where `use_rslora` is required (config/src/parsers/lora.rs). It is applied in the fold,
107/// not folded into B.
108#[derive(Debug, Clone, Copy)]
109pub struct LoraPair {
110 pub a: DenseWeight,
111 pub b: DenseWeight,
112 pub rank: u32,
113 pub k_in: u32,
114 pub n_out: u32,
115 pub scale: f32,
116 /// 2026-09-25: The pool's padded rank: the row stride of `b` and the row count of `a`. The
117 /// shrink produces `max_rank` columns and the expand contracts over `max_rank`, because B rows
118 /// are `max_rank` elements apart: an expand at `k = rank` would misread every row after the
119 /// first when `rank < max_rank`. The pad entries are zero.
120 pub max_rank: u32,
121}
122
123/// 2026-09-25: Per-layer attention LoRA weights, installed by copy onto `Qwen3AttentionLayer`.
124///
125/// The `q` pair folds its delta into the raw q_proj output at offset 0 over the full q_proj
126/// width (on a gated model the interleaved `[Q|gate]` row), before `deinterleave_qg` splits it
127/// (`apply_q_lora` in `qwen3_attention/decode/attention_forward.rs`).
128#[derive(Clone, Copy)]
129pub struct LoraAttnWeights {
130 /// 2026-09-25: The global layer index (`0..num_hidden_layers`), set at install. Prefill uses it
131 /// to index a request slot's per-layer pairs; `attn_layer_idx` counts attention layers only and
132 /// differs from it on hybrid GDN/attention models.
133 pub layer_idx: usize,
134 pub q: Option<LoraPair>,
135 pub k: Option<LoraPair>,
136 pub v: Option<LoraPair>,
137 pub o: Option<LoraPair>,
138 pub kernels: LoraKernels,
139 /// 2026-09-25: Per-request routing tables, per module. `None` means no routing, and the
140 /// module's pair above is applied. With `Some`, a step that carries a `seq_slot` buffer applies
141 /// the routed delta through [`apply_lora_bgmv`].
142 pub q_route: Option<LoraRoute>,
143 pub k_route: Option<LoraRoute>,
144 pub v_route: Option<LoraRoute>,
145 pub o_route: Option<LoraRoute>,
146}
147
148/// 2026-09-25: Per-layer dense-FFN LoRA weights, installed by copy onto `DenseFfnLayer`.
149#[derive(Clone, Copy)]
150pub struct LoraFfnWeights {
151 pub gate: Option<LoraPair>,
152 pub up: Option<LoraPair>,
153 pub down: Option<LoraPair>,
154 pub kernels: LoraKernels,
155}
156
157/// 2026-09-25: `METRALE_LORA_NO_APPLY` equal to `"1"`, read once per process: keep the adapter
158/// resident but skip every [`apply_lora_delta`]. A measurement lever that separates the cost of
159/// applying an adapter from the cost of having one loaded; the output is the base model's while
160/// it is set.
161pub fn lora_no_apply() -> bool {
162 static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
163 *V.get_or_init(|| std::env::var("METRALE_LORA_NO_APPLY").as_deref() == Ok("1"))
164}
165
166/// 2026-09-25: Row count at or below which [`apply_lora_delta`] runs `m` single-row GEMV deltas
167/// instead of one GEMM: `METRALE_LORA_GEMV_MAX_M`, read once per process, default 48 when unset
168/// or unparsable. `dense_gemm_tc` tiles 16 rows (`TC_TM` in dense_gemm_tc.cu) and reads all of B
169/// whatever `m` is, so a small `m` pays full B traffic for a partly empty tile.
170pub fn lora_gemv_max_m() -> u32 {
171 static V: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
172 *V.get_or_init(|| {
173 std::env::var("METRALE_LORA_GEMV_MAX_M")
174 .ok()
175 .and_then(|v| v.parse().ok())
176 .unwrap_or(48)
177 })
178}
179
180/// 2026-09-25: `METRALE_LORA_NO_FFN` equal to `"1"`, read once per process: the dense-FFN
181/// (`dense_ffn.rs`) and GDN out_proj (`qwen3_ssm/lora.rs`) skip their deltas, and the attention
182/// deltas still apply. A measurement lever; the output is wrong while it is set.
183pub fn lora_no_ffn() -> bool {
184 static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
185 *V.get_or_init(|| std::env::var("METRALE_LORA_NO_FFN").as_deref() == Ok("1"))
186}
187
188/// 2026-09-25: `base_out[m, n_out] += scale * (x[m, k_in] @ a^T) @ b^T` for one [`LoraPair`].
189///
190/// `x` rows must be contiguous with stride `k_in * 2` bytes and `base_out` rows with stride
191/// `n_out * 2` bytes; a strided caller loops with `m = 1` on offset pointers. `lora_xa` must hold
192/// `m * max_rank` BF16 and `lora_delta` `m * n_out` BF16.
193///
194/// Paths: `m == 1` runs two GEMVs; `1 < m <= lora_gemv_max_m()` runs one single-row delta per
195/// row; larger `m` runs the tensor-core GEMMs, with the fused expand-and-fold when both
196/// `gemm_tc` handles resolved, and `dense_gemm` when `gemm_tc` is missing. Every unfused path
197/// ends with `bf16_scaled_add`.
198#[allow(clippy::too_many_arguments)]
199pub fn apply_lora_delta(
200 gpu: &dyn GpuBackend,
201 kernels: &LoraKernels,
202 pair: &LoraPair,
203 x: DevicePtr, // 2026-09-25: [m, pair.k_in] BF16
204 base_out: DevicePtr, // 2026-09-25: [m, pair.n_out] BF16, modified in place
205 m: u32,
206 lora_xa: DevicePtr, // 2026-09-25: scratch of at least m * max_rank BF16
207 lora_delta: DevicePtr, // 2026-09-25: scratch of at least m * n_out BF16
208 stream: u64,
209) -> Result<()> {
210 if lora_no_apply() {
211 return Ok(());
212 }
213 // 2026-09-25: Small m: one single-row delta per row. Rows are contiguous by this function's
214 // contract, so a row is a pointer offset.
215 if m > 1 && m <= lora_gemv_max_m() {
216 for row in 0..m {
217 let x_row = DevicePtr(x.0 + (row as u64) * (pair.k_in as u64) * 2);
218 let out_row = DevicePtr(base_out.0 + (row as u64) * (pair.n_out as u64) * 2);
219 apply_lora_delta(
220 gpu, kernels, pair, x_row, out_row, 1, lora_xa, lora_delta, stream,
221 )?;
222 }
223 return Ok(());
224 }
225 if m == 1 {
226 // 2026-09-25: shrink: [1,k_in] @ A[max_rank,k_in]^T -> xa[1,max_rank]
227 ops::dense_gemv(
228 gpu,
229 kernels.gemv_k,
230 x,
231 &pair.a,
232 lora_xa,
233 pair.max_rank,
234 pair.k_in,
235 stream,
236 )?;
237 // 2026-09-25: expand: [1,max_rank] @ B[n_out,max_rank]^T -> delta[1,n_out]
238 ops::dense_gemv(
239 gpu,
240 kernels.gemv_k,
241 lora_xa,
242 &pair.b,
243 lora_delta,
244 pair.n_out,
245 pair.max_rank,
246 stream,
247 )?;
248 } else if kernels.gemm_tc_acc_k.0 != 0 && kernels.gemm_tc_k.0 != 0 {
249 // 2026-09-25: Fused path: the shrink, then expand and fold in one launch, without the
250 // `[m, n_out]` scratch or a separate scaled_add pass.
251 ops::dense_gemm_tc(
252 gpu,
253 kernels.gemm_tc_k,
254 x,
255 &pair.a,
256 lora_xa,
257 m,
258 pair.max_rank,
259 pair.k_in,
260 stream,
261 )?;
262 return ops::dense_gemm_tc_scaled_acc(
263 gpu,
264 kernels.gemm_tc_acc_k,
265 lora_xa,
266 &pair.b,
267 base_out,
268 m,
269 pair.n_out,
270 pair.max_rank,
271 pair.scale,
272 stream,
273 );
274 } else if kernels.gemm_tc_k.0 != 0 {
275 ops::dense_gemm_tc(
276 gpu,
277 kernels.gemm_tc_k,
278 x,
279 &pair.a,
280 lora_xa,
281 m,
282 pair.max_rank,
283 pair.k_in,
284 stream,
285 )?;
286 ops::dense_gemm_tc(
287 gpu,
288 kernels.gemm_tc_k,
289 lora_xa,
290 &pair.b,
291 lora_delta,
292 m,
293 pair.n_out,
294 pair.max_rank,
295 stream,
296 )?;
297 } else {
298 ops::dense_gemm(
299 gpu,
300 kernels.gemm_k,
301 x,
302 &pair.a,
303 lora_xa,
304 m,
305 pair.max_rank,
306 pair.k_in,
307 stream,
308 )?;
309 ops::dense_gemm(
310 gpu,
311 kernels.gemm_k,
312 lora_xa,
313 &pair.b,
314 lora_delta,
315 m,
316 pair.n_out,
317 pair.max_rank,
318 stream,
319 )?;
320 }
321 // 2026-09-25: fold: base_out += scale * delta (`bf16_scaled_add` in residual_add.cu)
322 ops::scaled_add(
323 gpu,
324 kernels.scaled_add_k,
325 base_out,
326 lora_delta,
327 pair.scale,
328 m * pair.n_out,
329 stream,
330 )
331}
332
333/// 2026-09-25: Per-request routed LoRA delta over `n` rows, each naming its adapter slot in
334/// `seq_slot[n]` (i32; a negative slot gets no delta):
335/// `out[i, :] += scale_s * (x[i, :] @ A_s^T) @ B_s^T` with `s = seq_slot[i]`.
336///
337/// Two launches (kernels/gb10/common/lora_bgmv.cu): the shrink writes BF16 `xa`, and the expand
338/// rounds each delta to BF16 before adding `scale_table[s] * delta` in FP32, the order of
339/// `bf16_scaled_add`. A slot whose table entry is 0 does not adapt this module. Both stages run
340/// at `route.max_rank`.
341///
342/// Strides are in elements: `x_row_stride` between `x` rows (at least `k_in`) and
343/// `out_row_stride` between `base_out` rows (at least `n_out`), so the fold can land inside an
344/// interleaved buffer. `lora_xa` must hold `n * max_rank` BF16. The launch arguments follow the
345/// kernels' parameter order; the launch does not check their types.
346#[allow(clippy::too_many_arguments)]
347pub fn apply_lora_bgmv(
348 gpu: &dyn GpuBackend,
349 kernels: &LoraKernels,
350 route: &LoraRoute,
351 x: DevicePtr, // 2026-09-25: [n, x_row_stride] BF16
352 base_out: DevicePtr, // 2026-09-25: [n, out_row_stride] BF16, folded in place
353 seq_slot: DevicePtr,
354 n: u32,
355 x_row_stride: u32,
356 out_row_stride: u32,
357 lora_xa: DevicePtr,
358 stream: u64,
359) -> Result<()> {
360 use metrale_gpu_runtime::kernel_args::{KernelLaunch, div_ceil};
361
362 // 2026-09-25: Kernel 1, shrink: xa[n, max_rank] = x @ A_s^T, 4 outputs per block.
363 KernelLaunch::new(gpu, kernels.bgmv_shrink_k)
364 .grid([div_ceil(route.max_rank, 4), n, 1])
365 .block([256, 1, 1])
366 .arg_ptr(x)
367 .arg_ptr(seq_slot)
368 .arg_ptr(route.a_table)
369 .arg_ptr(lora_xa)
370 .arg_u32(n)
371 .arg_u32(route.max_rank)
372 .arg_u32(route.k_in)
373 .arg_u32(x_row_stride)
374 .launch(stream)?;
375
376 // 2026-09-25: Kernel 2, expand and fold: base_out += scale_s * (xa @ B_s^T), 4 outputs per
377 // block.
378 KernelLaunch::new(gpu, kernels.bgmv_expand_fold_k)
379 .grid([div_ceil(route.n_out, 4), n, 1])
380 .block([256, 1, 1])
381 .arg_ptr(lora_xa)
382 .arg_ptr(seq_slot)
383 .arg_ptr(route.b_table)
384 .arg_ptr(route.scale_table)
385 .arg_ptr(base_out)
386 .arg_u32(n)
387 .arg_u32(route.n_out)
388 .arg_u32(route.max_rank)
389 .arg_u32(out_row_stride)
390 .launch(stream)
391}