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}