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}