spark_model/model/
impl_lora.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Model-level LoRA adapter lifecycle: startup install (`set_lora_weights` +
4//! the per-layer install walk), per-request slot resolution (Tasks #24/#25),
5//! runtime rotation (`rotate_lora_to`), RDMA/disk slot swap, and the
6//! rotate/swap decode-graph invalidation drain. Split from `impl_b3.rs`
7//! (500-LoC cap).
8
9use anyhow::{Context, Result};
10
11use spark_runtime::gpu::DevicePtr;
12
13use super::types::TransformerModel;
14use crate::layers::ops;
15
16impl TransformerModel {
17    /// Install a startup-static LoRA adapter (post-construction, mirroring
18    /// [`Self::set_dflash_proposer`]). Walks the model layers by GLOBAL
19    /// index — `LoraWeights.layers` is indexed the same way — and copies
20    /// each adapted layer's K/V/O (+ optional gate/up/down) pairs into the
21    /// `Qwen3AttentionLayer` (which routes FFN pairs into its dense FFN
22    /// component). M0: layers only STORE the adapter; base output is
23    /// unchanged until the M1 compute insertions read it.
24    /// Task #24: stable adapter_id for a per-request pool-slot selector. Returns
25    /// the base sentinel `0` when no LoRA pool is resident (byte-identical base),
26    /// else the NAME-derived id of the resolved slot (`-1 -> active`). Resolved
27    /// here at prefill time because `LoraWeights.active` can rotate between HTTP
28    /// request resolution and prefill.
29    pub fn adapter_id_for_slot(&self, slot: i32) -> u64 {
30        match self.lora.as_ref() {
31            Some(lw) => lw.adapter_id_for_slot(slot),
32            None => 0,
33        }
34    }
35
36    /// Task #25: acquire a per-slot ref for a sequence beginning to use its
37    /// adapter (called at prefill, resolving `-1 -> active` exactly like
38    /// [`Self::adapter_id_for_slot`]). Returns the RESOLVED pool index the ref
39    /// was taken on — the caller stores it and releases EXACTLY that index at
40    /// terminal free, so an intervening rotate changing `active` cannot make
41    /// release hit a different counter. `-1` ("nothing acquired") when no LoRA
42    /// pool is resident or the slot is out of range → byte-identical no-op base.
43    pub fn acquire_adapter_slot(&self, slot: i32) -> i32 {
44        match self.lora.as_ref() {
45            Some(lw) => lw.acquire_slot(slot),
46            None => -1,
47        }
48    }
49
50    /// Task #25: release a ref acquired by [`Self::acquire_adapter_slot`], by the
51    /// RESOLVED index it returned. `-1` and no-pool are no-ops (base path).
52    pub fn release_adapter_slot(&self, resolved: i32) {
53        if let Some(lw) = self.lora.as_ref() {
54            lw.release_slot(resolved);
55        }
56    }
57
58    /// Feature-1: resolve the MoE-LoRA fold decision for a single-request pass
59    /// from that request's `adapter_slot`. See [`crate::layer::MoeLoraRoute`].
60    /// Base / non-active requests skip (base tokens pay nothing); only the
61    /// installed active adapter's request folds; a request for a different,
62    /// non-installed adapter refuses loudly. No pool resident ⇒ `Fold` (inert:
63    /// the fold hook no-ops on the layer's `self.lora == None`).
64    pub(crate) fn moe_lora_route(&self, adapter_slot: i32) -> crate::layer::MoeLoraRoute {
65        let (active, has) = match self.lora.as_ref() {
66            Some(lw) => (lw.active as i32, true),
67            None => (-1, false),
68        };
69        crate::lora::resolve_moe_lora_route(adapter_slot, active, has)
70    }
71
72    /// Read the per-decode MoE route stamped at the `Model` entry (decode/verify
73    /// `ForwardContext`s use this instead of a hardcoded `Fold`).
74    pub(crate) fn decode_moe_route(&self) -> crate::layer::MoeLoraRoute {
75        match self
76            .decode_moe_route
77            .load(std::sync::atomic::Ordering::Relaxed)
78        {
79            0 => crate::layer::MoeLoraRoute::Skip,
80            2 => crate::layer::MoeLoraRoute::Refuse,
81            _ => crate::layer::MoeLoraRoute::Fold,
82        }
83    }
84
85    fn store_decode_moe_route(&self, route: crate::layer::MoeLoraRoute) {
86        let v = match route {
87            crate::layer::MoeLoraRoute::Skip => 0,
88            crate::layer::MoeLoraRoute::Fold => 1,
89            crate::layer::MoeLoraRoute::Refuse => 2,
90        };
91        self.decode_moe_route
92            .store(v, std::sync::atomic::Ordering::Relaxed);
93    }
94
95    /// Stamp the per-decode MoE route from a single request's `adapter_slot`.
96    pub(crate) fn stamp_decode_moe_single(&self, adapter_slot: i32) {
97        self.store_decode_moe_route(self.moe_lora_route(adapter_slot));
98    }
99
100    /// Stamp from a decode batch — 3-way reduction over the rows' per-seq routes
101    /// (SOLID Incr-4). Any row routing to a NON-active adapter (`Refuse`) stamps
102    /// `Refuse`: the single-active phase-1 fold cannot honor a second adapter's
103    /// identity (the device gather-BGMV checks only the SIGN of the per-row map,
104    /// so a non-active slot would silently fold the ACTIVE adapter's tables), so
105    /// `decode_batch_compute_main` bails host-side before graph lookup. Else any
106    /// adapter-owning row stamps `Fold` (the batched per-row map skips base rows
107    /// individually, so base seqs in a mixed batch still decode clean). Only when
108    /// EVERY row is base (or an empty batch) does it stamp `Skip` (nothing to
109    /// fold; base decode stays byte-identical).
110    pub(crate) fn stamp_decode_moe_batch(&self, seqs: &[&mut crate::traits::SequenceState]) {
111        use crate::layer::MoeLoraRoute;
112        let mut any_fold = false;
113        for s in seqs.iter() {
114            match self.moe_lora_route(s.adapter_slot) {
115                MoeLoraRoute::Refuse => {
116                    self.store_decode_moe_route(MoeLoraRoute::Refuse);
117                    return;
118                }
119                MoeLoraRoute::Fold => any_fold = true,
120                MoeLoraRoute::Skip => {}
121            }
122        }
123        self.store_decode_moe_route(if any_fold {
124            MoeLoraRoute::Fold
125        } else {
126            MoeLoraRoute::Skip
127        });
128    }
129
130    // The batched/mixed-decode Refuse guard is the PURE
131    // `crate::lora::ensure_decode_route_servable` (unit-tested there), called
132    // by both batched decode entries before graph lookup.
133
134    pub fn set_lora_weights(&mut self, mut lora: Option<crate::lora::LoraWeights>) -> Result<()> {
135        if let Some(ref lw) = lora {
136            // eager-on-rotate: ONLY the global rotate/swap re-point path forces
137            // eager decode. A multi-adapter pool no longer implies eager —
138            // per-request routing (M2) is graph-safe by construction (the
139            // per-seq slot buffer is per-step-uploaded to a stable address, the
140            // pool tables are load-time-fixed), so decode graphs STAY captured
141            // under routing. Equating slots.len()>1 with eager here would throw
142            // away the entire point of batched routing.
143            self.lora_rotatable = self.levers.lora_rotate || crate::lora::lora_peer_env().is_some();
144            let kernels = ops::lora_delta::LoraKernels::new(self.gpu.as_ref())?;
145            // Clone the active slot's pairs (small; LoraPair is Copy) so the
146            // install walk can hold a shared borrow while it &mut-borrows
147            // `self.layers`. Clone the (Copy) pool table pointers + scale table
148            // too so the routed batched-decode path can read them per layer.
149            let active = lw.active_layers().to_vec();
150            let tables = lw.tables.clone();
151            let scale_table = lw.scale_table;
152            let installed = self.install_lora_layers(&active, kernels, &tables, scale_table)?;
153            // Task #27: `slots` is pre-sized to max_loras with empty cache
154            // placeholders; report only the filled (named) adapters.
155            let resident: Vec<String> = lw
156                .adapter_names()
157                .into_iter()
158                .filter(|n| !n.is_empty())
159                .collect();
160            tracing::info!(
161                "LoRA: {} adapter(s) resident [{}], active '{}' installed on \
162                 {installed} layers (r={}, max_rank={}, max_loras={}, \
163                 pool={:.1} MiB, rotatable={})",
164                resident.len(),
165                resident.join(", "),
166                lw.name,
167                lw.adapter_config.r,
168                lw.max_rank,
169                lw.max_loras,
170                lw.pool_bytes as f64 / (1024.0 * 1024.0),
171                self.lora_rotatable,
172            );
173        }
174        // Feature-2 Stage 2: build the token-overlay tables from the Stage-1 raw
175        // uploads now that the served embed/lm_head tables exist (they did NOT
176        // at loader time — the two-stage build closes that ordering gap). Only
177        // an adapter that actually shipped overlay tensors reaches the builder;
178        // a run with no overlay leaves `self.overlays == None` (byte-identical).
179        if let Some(ref mut lw) = lora {
180            let raws = std::mem::take(&mut lw.overlay_raw);
181            if raws.iter().any(|r| r.is_some()) {
182                self.build_token_overlays(lw.max_loras, &raws)?;
183            }
184        }
185        self.lora = lora;
186        Ok(())
187    }
188
189    /// Stage 2 of the token-overlay build: row-diff each staged adapter's raw
190    /// overlay tensors against the served embed/lm_head tables, compact the
191    /// override rows, and materialize the `[max_loras]` device tables. Sets
192    /// `self.overlays` only when some slot actually overrides a row.
193    fn build_token_overlays(
194        &mut self,
195        max_loras: usize,
196        raws: &[Option<crate::lora::OverlayRawSlot>],
197    ) -> Result<()> {
198        // Tie: lm_head aliases embed (shared buffer OR a quantized head derived
199        // from embed) ⇒ the logit recompute reuses the embed override rows.
200        let tied = self.lm_head_weight.weight.0 == self.embed_tokens.weight.0
201            || self.lm_head_nvfp4.is_some()
202            || self.lm_head_fp8.is_some();
203        let vocab = self.config.vocab_size;
204        let h = self.config.hidden_size;
205        let served_embed = self.embed_tokens.weight;
206        let served_lmhead = self.lm_head_weight.weight;
207        let stream = self.gpu.default_stream();
208        let mut overlays: Vec<Option<crate::lora::EmbedOverlay>> =
209            (0..max_loras).map(|_| None).collect();
210        for (k, raw) in raws.iter().enumerate() {
211            if let Some(slot) = raw
212                && k < max_loras
213            {
214                overlays[k] = crate::lora::build_overlay(
215                    self.gpu.as_ref(),
216                    &self.overlay_kernels,
217                    slot,
218                    served_embed,
219                    served_lmhead,
220                    vocab,
221                    h,
222                    tied,
223                    stream,
224                )?;
225            }
226        }
227        let set =
228            crate::lora::TokenOverlaySet::from_slots(self.gpu.as_ref(), overlays, max_loras, tied)?;
229        if set.any_active() {
230            tracing::info!(
231                "LoRA overlay: token-overlay tables built (max_n_override={}, tied={})",
232                set.max_n_override,
233                tied,
234            );
235            self.overlays = Some(set);
236        }
237        Ok(())
238    }
239
240    /// Install one slot's per-layer pairs onto the layer structs (the shared
241    /// walk used by both initial install and runtime rotation). `layers` is
242    /// GLOBAL-layer-indexed. Returns the number of layers installed.
243    pub(super) fn install_lora_layers(
244        &mut self,
245        layers: &[Option<crate::lora::LoraLayerWeights>],
246        kernels: ops::lora_delta::LoraKernels,
247        tables: &std::collections::BTreeMap<
248            (usize, crate::lora::LoraModule),
249            (spark_runtime::gpu::DevicePtr, spark_runtime::gpu::DevicePtr),
250        >,
251        scale_table: spark_runtime::gpu::DevicePtr,
252    ) -> Result<usize> {
253        use crate::lora::LoraModule;
254        // Build the per-module routing table from the frozen pool tables + the
255        // active-slot pair dims (k_in/n_out/max_rank identical across slots, so
256        // the active pair supplies them). `None` when the module has no table
257        // (base-only) — the bgmv apply site then no-ops for that module.
258        let mk_route = |layer_idx: usize,
259                        module: LoraModule,
260                        pair: &Option<ops::lora_delta::LoraPair>|
261         -> Option<ops::lora_delta::LoraRoute> {
262            let p = pair.as_ref()?;
263            let (a_table, b_table) = *tables.get(&(layer_idx, module))?;
264            Some(ops::lora_delta::LoraRoute {
265                a_table,
266                b_table,
267                scale_table,
268                k_in: p.k_in,
269                n_out: p.n_out,
270                max_rank: p.max_rank,
271            })
272        };
273        let mut installed = 0usize;
274        for (idx, layer) in self.layers.iter_mut().enumerate() {
275            let Some(layer_weights) = layers.get(idx).and_then(|o| o.as_ref()) else {
276                continue;
277            };
278            let has_moe = layer_weights.router.is_some()
279                || layer_weights
280                    .experts
281                    .as_ref()
282                    .is_some_and(|e| !e.is_empty());
283            // Hoisted above the downcast: the dense FFN exists on BOTH layer
284            // kinds of a hybrid, so both branches install it, and building it
285            // once keeps them from disagreeing.
286            let ffn_weights = if layer_weights.gate_proj.is_some()
287                || layer_weights.up_proj.is_some()
288                || layer_weights.down_proj.is_some()
289            {
290                Some(ops::lora_delta::LoraFfnWeights {
291                    gate: layer_weights.gate_proj,
292                    up: layer_weights.up_proj,
293                    down: layer_weights.down_proj,
294                    kernels,
295                })
296            } else {
297                None
298            };
299            let any = layer
300                .as_any_mut()
301                .ok_or_else(|| anyhow::anyhow!("LoRA: adapted layer {idx} is not downcastable"))?;
302            // Full-attention layer: attention + dense-FFN + MoE. Linear-attention
303            // (GDN/SSM) layer: MoE ONLY — its attention projections are rejected at
304            // classify, but its MoE FFN exists on every layer, so a real all-layer
305            // MoE adapter routes its router/expert deltas here too.
306            if let Some(attn) = any.downcast_mut::<crate::layers::Qwen3AttentionLayer>() {
307                let attn_weights = ops::lora_delta::LoraAttnWeights {
308                    // #30: the global layer index (from `self.layers.enumerate()`) —
309                    // the key the request slot's GLOBAL-layer-indexed pairs use.
310                    layer_idx: idx,
311                    q: layer_weights.q_proj,
312                    k: layer_weights.k_proj,
313                    v: layer_weights.v_proj,
314                    o: layer_weights.o_proj,
315                    kernels,
316                    q_route: mk_route(idx, LoraModule::QProj, &layer_weights.q_proj),
317                    k_route: mk_route(idx, LoraModule::KProj, &layer_weights.k_proj),
318                    v_route: mk_route(idx, LoraModule::VProj, &layer_weights.v_proj),
319                    o_route: mk_route(idx, LoraModule::OProj, &layer_weights.o_proj),
320                };
321                attn.set_lora_weights(attn_weights, ffn_weights)?;
322                if has_moe {
323                    attn.set_moe_lora_weights(
324                        layer_weights.router,
325                        layer_weights.experts.clone().unwrap_or_default(),
326                        kernels,
327                        self.gpu.as_ref(),
328                    )?;
329                }
330            } else if let Some(ssm) = any.downcast_mut::<crate::layers::Qwen3SsmLayer>() {
331                // No q/k/v/o here (classify_key rejects those), but a hybrid's
332                // linear-attention layer DOES carry the dense FFN, and real
333                // adapters target it on every layer.
334                let has_attn_proj = layer_weights.q_proj.is_some()
335                    || layer_weights.k_proj.is_some()
336                    || layer_weights.v_proj.is_some()
337                    || layer_weights.o_proj.is_some();
338                if has_attn_proj {
339                    anyhow::bail!(
340                        "LoRA: attention-projection delta on linear-attention layer {idx} — \
341                         classify should have rejected this (that layer has no q/k/v/o)"
342                    );
343                }
344                if let Some(ffn) = ffn_weights {
345                    ssm.set_ffn_lora_weights(ffn).with_context(|| {
346                        format!("LoRA: installing dense-FFN delta on linear-attention layer {idx}")
347                    })?;
348                }
349                // GDN out_proj: only linear-attention layers have one.
350                if let Some(pair) = layer_weights.out_proj {
351                    ssm.set_out_proj_lora(pair, kernels);
352                }
353                if has_moe {
354                    ssm.set_moe_lora_weights(
355                        layer_weights.router,
356                        layer_weights.experts.clone().unwrap_or_default(),
357                        kernels,
358                        self.gpu.as_ref(),
359                    )?;
360                }
361            } else {
362                anyhow::bail!(
363                    "LoRA: adapted layer {idx} is neither a Qwen3AttentionLayer nor a \
364                     Qwen3SsmLayer (loader/adapter layer-type mismatch)"
365                );
366            }
367            installed += 1;
368        }
369        Ok(installed)
370    }
371
372    /// #28: drain + DESTROY every graph cache that bakes the installed-active
373    /// LoRA pair pointers (decode, batched decode, K=2/3/4 verify, K=γ verify,
374    /// fused decode+verify) on a rotate/swap. `GraphHandle` has no `Drop`, so a
375    /// bare `.clear()` would LEAK the CUDA graphs. This drain is the rotation
376    /// invalidation guard in this port (the compound `(slot, active_id)` graph
377    /// re-key of the reference branch is deferred), so it MUST cover every
378    /// cache or a stale replay would decode with swapped pool bytes. Runs at
379    /// scheduler quiescence on the CUDA-bound model thread (like
380    /// `free_sequence`'s destroys).
381    pub(super) fn destroy_lora_decode_graphs(&self) {
382        let drain = |name: &str, graphs: Vec<spark_runtime::gpu::GraphHandle>| {
383            for g in graphs {
384                if g.0 != 0
385                    && let Err(e) = self.gpu.destroy_graph(g)
386                {
387                    tracing::warn!("LoRA graph clear: destroy {name}: {e:#}");
388                }
389            }
390        };
391        drain(
392            "decode_graph",
393            self.decode_graph.lock().drain().map(|(_, g)| g).collect(),
394        );
395        drain(
396            "batch_decode_graph",
397            self.batch_decode_graphs
398                .lock()
399                .0
400                .drain()
401                .map(|(_, (g, _))| g)
402                .collect(),
403        );
404        drain(
405            "verify2_graph",
406            self.verify2_graph.lock().drain().map(|(_, g)| g).collect(),
407        );
408        drain(
409            "verify3_graph",
410            self.verify3_graph.lock().drain().map(|(_, g)| g).collect(),
411        );
412        drain(
413            "verify4_graph",
414            self.verify4_graph.lock().drain().map(|(_, g)| g).collect(),
415        );
416        drain(
417            "verify_kgamma_graph",
418            self.verify_kgamma_graph
419                .lock()
420                .drain()
421                .map(|(_, g)| g)
422                .collect(),
423        );
424        drain(
425            "fused_graph",
426            self.fused_graph.lock().drain().map(|(_, g)| g).collect(),
427        );
428    }
429
430    /// Runtime adapter rotation (eager-on-rotate). Selects the resident
431    /// adapter named `name` as ACTIVE: re-points every layer's LoraPair (a/b
432    /// DevicePtr + rank/scale) to that slot's sub-region, then clears the
433    /// decode-graph caches defensively (empty under forced eager). MUST be
434    /// called at a scheduler QUIESCENT point (no in-flight decode reading the
435    /// old slot). Graph-safety rests on `lora_rotatable` forcing eager decode
436    /// — this method never re-captures a graph.
437    pub fn rotate_lora_to(&mut self, name: &str) -> Result<()> {
438        let slot = {
439            let lw = self
440                .lora
441                .as_ref()
442                .ok_or_else(|| anyhow::anyhow!("LoRA rotation: no adapter loaded"))?;
443            lw.slot_of(name).ok_or_else(|| {
444                anyhow::anyhow!(
445                    "LoRA rotation: adapter '{name}' is not resident (have [{}])",
446                    lw.adapter_names().join(", ")
447                )
448            })?
449        };
450        if !self.lora_rotatable {
451            // A single startup adapter with no rotation env is baked into the
452            // decode graph; re-pointing would be replayed stale. Refuse rather
453            // than silently mis-serve.
454            anyhow::bail!(
455                "LoRA rotation not armed (single adapter, ATLAS_LORA_ROTATE unset); \
456                 set ATLAS_LORA_ROTATE=1 (forces eager decode) to rotate at runtime"
457            );
458        }
459        // #25 safety: rotation RE-INSTALLS the new slot's pairs onto the layer
460        // structs, so any in-flight sequence still decoding on the OLD active
461        // adapter (via the installed pair) would replay with the wrong delta.
462        // Refuse while the current active slot has in-flight sequences — rotate
463        // only at a scheduler-quiescent point (matches this method's contract).
464        {
465            let lw = self.lora.as_ref().unwrap();
466            let cur = lw.active;
467            if lw.slot_ref_count(cur) > 0 {
468                anyhow::bail!(
469                    "LoRA rotation refused: active slot {cur} has in-flight \
470                     sequences (ref_count>0); rotate at a quiescent point"
471                );
472            }
473        }
474        // Re-point onto the new active slot.
475        let (layers, active_name, r, tables, scale_table) = {
476            let lw = self.lora.as_mut().unwrap();
477            lw.active = slot;
478            lw.name = lw.slots[slot].name.clone();
479            lw.adapter_config = lw.slots[slot].adapter_config.clone();
480            (
481                lw.slots[slot].layers.clone(),
482                lw.name.clone(),
483                lw.adapter_config.r,
484                lw.tables.clone(),
485                lw.scale_table,
486            )
487        };
488        let kernels = ops::lora_delta::LoraKernels::new(self.gpu.as_ref())?;
489        let installed = self.install_lora_layers(&layers, kernels, &tables, scale_table)?;
490        // Defensive: drop any captured decode graphs so a stale-pointer replay
491        // is impossible even if `lora_rotatable` were ever mis-derived. Under
492        // forced eager these are already empty.
493        self.destroy_lora_decode_graphs();
494        tracing::info!(
495            "LoRA rotation → slot {slot} '{active_name}' (r={r}) re-installed on \
496             {installed} layers"
497        );
498        Ok(())
499    }
500}