spark_model/lora/
loading.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! LoRA adapter loading: family allow-list check, per-adapter classify/shape
4//! audit, the pool-slot pack loop, and the multi/single/disk-swap entry points.
5//! Split out of the former monolithic `lora/mod.rs` (SDD seam: LOADING) —
6//! visibility unchanged.
7
8use std::collections::BTreeMap;
9use std::sync::atomic::{AtomicU64, AtomicUsize};
10
11use anyhow::{Result, bail};
12use atlas_core::config::{ModelConfig, PeftAdapterConfig};
13use spark_runtime::gpu::{DevicePtr, GpuBackend};
14use spark_runtime::weights::WeightStore;
15
16use super::*;
17use crate::layers::ops::lora_delta::LoraPair;
18use crate::weight_map::DenseWeight;
19
20/// The v0 family allow-list, checked once per load. v0 is validated on the
21/// Qwen3.5-family attention trunk — qwen3_5 DENSE (holo-3.1-0.8b), holo3_1_moe
22/// (holo-3.1-35b-a3b, MoE), and qwen3_6_moe (Qwen3.6-35B-A3B, MoE). All route to
23/// `Qwen35WeightLoader`, so their full-attention layers are `Qwen3AttentionLayer`
24/// — what the install walk downcasts to (attention q/k/v/o; MoE expert MLPs +
25/// SSM layers stay rejected by `classify_key`). Other families stay
26/// rejected (no validated mapping). NOTE: `qwen3_5_moe` on disk is rewritten to
27/// `qwen3_6_moe` at parse time (dispatch.rs, MRoPE MoE), so we gate the
28/// post-dispatch name — the on-disk `qwen3_5_moe` never reaches here.
29fn check_family(cfg: &ModelConfig) -> Result<()> {
30    if !(cfg.is_qwen35_dense()
31        || cfg.model_type == "holo3_1_moe"
32        || cfg.model_type == "qwen3_6_moe")
33    {
34        bail!(
35            "REJECT[unvalidated-family]: LoRA v0 is validated on qwen3_5 dense \
36             (holo-3.1-0.8b), holo3_1_moe (holo-3.1-35b-a3b), and qwen3_6_moe \
37             (Qwen3.6-35B-A3B) only; model_type='{}', num_experts={}",
38            cfg.model_type,
39            cfg.num_experts
40        );
41    }
42    Ok(())
43}
44
45/// Pack one already-audited adapter into pool `slot` (byte sub-region at base
46/// `slot * pool_slot_bytes`). The intra-slot walk (layer asc ×
47/// [`LoraModule::ALL`] × A-then-B, A contiguous, B row-repacked stride r →
48/// max_rank) is IDENTICAL for every slot — slot 0 is byte-for-byte the
49/// pre-multi-adapter path. Returns this slot's GLOBAL-layer-indexed pairs and,
50/// per (layer, module), the packed (a_ptr, b_ptr) as raw u64 ((0,0) where the
51/// adapter omits the module) for the post-pass pointer-table build.
52#[allow(clippy::type_complexity)]
53fn pack_slot(
54    slot: usize,
55    name: &str,
56    adapter_store: &WeightStore,
57    peft: &PeftAdapterConfig,
58    found: &BTreeMap<(usize, LoraModule), [Option<String>; 2]>,
59    cfg: &ModelConfig,
60    gpu: &dyn GpuBackend,
61    pool: DevicePtr,
62    max_lora_rank: usize,
63) -> Result<(
64    Vec<Option<LoraLayerWeights>>,
65    BTreeMap<(usize, LoraModule), (u64, u64)>,
66)> {
67    let scale = peft.scaling();
68    let slot_bytes = pool_slot_bytes(cfg, max_lora_rank);
69    let mut layers: Vec<Option<LoraLayerWeights>> =
70        (0..cfg.num_hidden_layers).map(|_| None).collect();
71    let mut slot_ptrs: BTreeMap<(usize, LoraModule), (u64, u64)> = BTreeMap::new();
72    let mut off = slot * slot_bytes; // slot base offset
73    // Walks EVERY layer, taking only the modules that layer can carry — the
74    // same order and the same predicate `pool_slot_bytes` reserved from, so
75    // the offsets this advances through cannot drift out of the slot.
76    for layer_idx in 0..cfg.num_hidden_layers {
77        let mut lw = LoraLayerWeights::empty(layer_idx);
78        let mut any = false;
79        for module in LoraModule::ALL {
80            if !module.applies_to_layer(cfg, layer_idx) {
81                continue;
82            }
83            let (out_dim, in_dim) = module.dims(cfg);
84            let a_off = off;
85            let b_off = off + max_lora_rank * in_dim * BF16_BYTES;
86            off = b_off + out_dim * max_lora_rank * BF16_BYTES;
87            let a_ptr = DevicePtr(pool.0 + a_off as u64);
88            let b_ptr = DevicePtr(pool.0 + b_off as u64);
89
90            let mut this = (0u64, 0u64); // NULL = base-only
91            if let Some([Some(a_key), Some(b_key)]) = found.get(&(layer_idx, module)) {
92                // A: contiguous [r, in] → head of the padded [max_rank, in] region.
93                let a_t = adapter_store.get(a_key)?;
94                let mut a_host = vec![0u8; peft.r * in_dim * BF16_BYTES];
95                gpu.copy_d2h(a_t.ptr, &mut a_host)?;
96                gpu.copy_h2d(&a_host, a_ptr)?;
97                // B: [out, r] → row-stride pad to [out, max_rank].
98                let b_t = adapter_store.get(b_key)?;
99                let mut b_src = vec![0u8; out_dim * peft.r * BF16_BYTES];
100                gpu.copy_d2h(b_t.ptr, &mut b_src)?;
101                let mut b_host = vec![0u8; out_dim * max_lora_rank * BF16_BYTES];
102                for row in 0..out_dim {
103                    let d = row * max_lora_rank * BF16_BYTES;
104                    let s = row * peft.r * BF16_BYTES;
105                    b_host[d..d + peft.r * BF16_BYTES]
106                        .copy_from_slice(&b_src[s..s + peft.r * BF16_BYTES]);
107                }
108                gpu.copy_h2d(&b_host, b_ptr)?;
109
110                let pair = LoraPair {
111                    a: DenseWeight { weight: a_ptr },
112                    b: DenseWeight { weight: b_ptr },
113                    rank: peft.r as u32,
114                    k_in: in_dim as u32,
115                    n_out: out_dim as u32,
116                    scale,
117                    // Kernel contraction dim: B's packed row stride (and A's
118                    // padded row count) — see LoraPair docs in lora_delta.rs.
119                    max_rank: max_lora_rank as u32,
120                };
121                tracing::info!(
122                    "LoRA: slot {slot} '{name}' layer {layer_idx} {module:?} r={} \
123                     scale={:.6} A=[{},{}] B=[{},{}] (padded to max_rank={})",
124                    peft.r,
125                    scale,
126                    peft.r,
127                    in_dim,
128                    out_dim,
129                    peft.r,
130                    max_lora_rank
131                );
132                match module {
133                    LoraModule::QProj => lw.q_proj = Some(pair),
134                    LoraModule::KProj => lw.k_proj = Some(pair),
135                    LoraModule::VProj => lw.v_proj = Some(pair),
136                    LoraModule::OProj => lw.o_proj = Some(pair),
137                    LoraModule::GateProj => lw.gate_proj = Some(pair),
138                    LoraModule::UpProj => lw.up_proj = Some(pair),
139                    LoraModule::DownProj => lw.down_proj = Some(pair),
140                    LoraModule::OutProj => lw.out_proj = Some(pair),
141                }
142                this = (a_ptr.0, b_ptr.0);
143                any = true;
144            }
145            slot_ptrs.insert((layer_idx, module), this);
146        }
147        if any {
148            layers[layer_idx] = Some(lw);
149        }
150    }
151    debug_assert_eq!(off, (slot + 1) * slot_bytes); // one slot filled exactly
152    Ok((layers, slot_ptrs))
153}
154
155/// Model-agnostic MULTI-adapter PEFT load: audit every adapter, VRAM-preflight
156/// the N-slot pool, pack each adapter into its slot (0..N-1), and build the
157/// per-module `[max_loras]` pointer tables (index k filled per packed slot,
158/// rest NULL). One resident adapter is byte-identical to the single-adapter
159/// path (slot 0, `off` starts at 0).
160///
161/// Called (via the `ModelWeightLoader::load_lora_adapters` hook) from
162/// `build_model` BEFORE `BufferArena::new` and the free-memory snapshot, so
163/// the pool bytes land in `used_so_far` and the KV budget shrinks
164/// automatically. Do NOT move the call later.
165pub fn load_lora_adapters_multi(
166    adapters: &[LoraAdapterInput<'_>],
167    cfg: &ModelConfig,
168    gpu: &dyn GpuBackend,
169    max_loras: usize,
170    max_lora_rank: usize,
171) -> Result<LoraWeights> {
172    check_family(cfg)?;
173    if adapters.is_empty() {
174        bail!("REJECT[no-adapters]: load_lora_adapters_multi called with an empty set");
175    }
176    if adapters.len() > max_loras {
177        bail!(
178            "REJECT[too-many-adapters]: {} --lora-adapter given but --max-loras={} \
179             (pool has {} slots); raise --max-loras or stage the extras on an \
180             $ATLAS_LORA_PEER for on-demand RDMA swap",
181            adapters.len(),
182            max_loras,
183            max_loras
184        );
185    }
186
187    // Audit every adapter up front (each gets its own classify/shape/target
188    // audit + rank<=max_lora_rank check) before touching VRAM.
189    let mut audited: Vec<AuditedAdapter> = Vec::with_capacity(adapters.len());
190    for a in adapters {
191        audited.push(audit_adapter(a.store, &a.peft, cfg, max_lora_rank)?);
192    }
193
194    // Feature-1: separate expert/router pool bytes, summed across adapters from
195    // the AUDITED key set. Sized at the (lower) expert rank cap.
196    let expert_rank = max_lora_expert_rank();
197    let expert_total: usize = adapters
198        .iter()
199        .zip(&audited)
200        .map(|(_, au)| {
201            let (ek, rl) = expert_pack::key_lists(&au.router, &au.experts);
202            expert_router_bytes(cfg, &ek, &rl, expert_rank)
203        })
204        .sum();
205
206    // VRAM preflight, then one fixed-address pool alloc for ALL slots, zeroed
207    // once (pad rows/cols and unpacked slots stay 0 = padded-K correctness).
208    let pool_bytes = pool_slot_bytes(cfg, max_lora_rank) * max_loras;
209    let free = gpu.free_memory()?;
210    if (pool_bytes + expert_total) * 2 > free {
211        bail!(
212            "OOM pre-flight (LoRA pool): {:.1} MiB attn pool ({} slots) + {:.1} MiB \
213             expert/router pool would leave < 1× headroom of {:.1} MiB free; every \
214             pool byte comes directly out of the KV-cache budget on GB10 unified memory",
215            pool_bytes as f64 / (1024.0 * 1024.0),
216            max_loras,
217            expert_total as f64 / (1024.0 * 1024.0),
218            free as f64 / (1024.0 * 1024.0),
219        );
220    }
221    let pool = gpu.alloc(pool_bytes)?;
222    gpu.memset(pool, 0, pool_bytes)?;
223    // One shared, zeroed expert/router pool (pad rows/cols stay 0). Allocated
224    // only when some adapter actually targets experts/router.
225    let expert_pool = if expert_total > 0 {
226        let ep = gpu.alloc(expert_total)?;
227        gpu.memset(ep, 0, expert_total)?;
228        Some(ep)
229    } else {
230        None
231    };
232    let mut expert_off = 0usize;
233
234    // Pack each adapter into its slot; accumulate per-(layer,module) [max_loras]
235    // pointer arrays for the post-pass table build.
236    let mut slots: Vec<AdapterSlot> = Vec::with_capacity(adapters.len());
237    let mut a_tabs: BTreeMap<(usize, LoraModule), Vec<u64>> = BTreeMap::new();
238    let mut b_tabs: BTreeMap<(usize, LoraModule), Vec<u64>> = BTreeMap::new();
239    // Feature-2: Stage-1 raw overlay upload per slot (device scratch owned here;
240    // Stage 2 in `set_lora_weights` row-diffs it against the served embed/lm_head
241    // tables). `None` for adapters that ship no overlay tensors.
242    let mut overlay_raw: Vec<Option<OverlayRawSlot>> = Vec::with_capacity(adapters.len());
243    for (k, a) in adapters.iter().enumerate() {
244        overlay_raw.push(stage_overlay_raw(
245            a.store,
246            &audited[k].overlay,
247            &a.peft,
248            cfg.hidden_size,
249            gpu,
250        )?);
251        let (mut layers, slot_ptrs) = pack_slot(
252            k,
253            &a.name,
254            a.store,
255            &a.peft,
256            &audited[k].attn,
257            cfg,
258            gpu,
259            pool,
260            max_lora_rank,
261        )?;
262        // Feature-1: pack this adapter's router + routed-expert pairs into the
263        // shared expert pool (fills layers[l].router / layers[l].experts).
264        if let Some(ep) = expert_pool {
265            let packed = expert_pack::pack_into(
266                &mut layers,
267                a.store,
268                &a.peft,
269                &audited[k].router,
270                &audited[k].experts,
271                cfg,
272                gpu,
273                ep,
274                expert_rank,
275                &mut expert_off,
276            )?;
277            if packed > 0 {
278                tracing::info!(
279                    "LoRA: slot {k} '{}' packed {packed} router/expert pair(s) \
280                     (expert_rank={expert_rank})",
281                    a.name
282                );
283            }
284        }
285        for ((layer, module), (a_ptr, b_ptr)) in slot_ptrs {
286            a_tabs
287                .entry((layer, module))
288                .or_insert_with(|| vec![0u64; max_loras])[k] = a_ptr;
289            b_tabs
290                .entry((layer, module))
291                .or_insert_with(|| vec![0u64; max_loras])[k] = b_ptr;
292        }
293        slots.push(AdapterSlot {
294            name: a.name.clone(),
295            adapter_config: a.peft.clone(),
296            layers,
297            generation: 0, // first load: gen 0 keeps ids byte-identical to #24
298        });
299    }
300
301    // Task #27: the pinned/cache boundary is the startup adapter count; the
302    // remaining pool indices `[pinned, max_loras)` are the promotion HOT CACHE.
303    // Pre-size `slots` to `max_loras` with EMPTY placeholders so a demand-promote
304    // (`swap_lora_slot_from_peer`) can `slots.get_mut(cache_slot)` a never-filled
305    // index (it would otherwise bail "slot not resident"). The placeholder's pool
306    // byte-region is already allocated + zeroed above; its empty name is never
307    // matched by the resolver nor advertised, and it contributes nothing to the
308    // a/b/scale tables — so resident-only serving is byte-identical. `pinned == 0`
309    // is impossible here (the caller rejects an empty adapter set).
310    let pinned = slots.len();
311    let num_layers = cfg.num_hidden_layers;
312    while slots.len() < max_loras {
313        slots.push(AdapterSlot {
314            name: String::new(),
315            adapter_config: PeftAdapterConfig {
316                r: 1,
317                lora_alpha: 0.0,
318                target_modules: Vec::new(),
319                target_modules_pattern: None,
320                use_rslora: false,
321                layers_to_transform: None,
322                trainable_token_indices: Vec::new(),
323                modules_to_save: Vec::new(),
324                lora_embedding: false,
325            },
326            layers: vec![None; num_layers],
327            generation: 0,
328        });
329    }
330
331    // Post-pass: materialize the per-module [max_loras] u64 pointer tables (the
332    // frozen M2 BGMV contract; currently dormant — no compute site reads them).
333    // build_ptr_table pattern (nemotron_moe.rs:414): pack le bytes → alloc → h2d.
334    let mk = |tab: &[u64]| -> Result<DevicePtr> {
335        let bytes: Vec<u8> = tab.iter().flat_map(|p| p.to_le_bytes()).collect();
336        let d = gpu.alloc(bytes.len())?;
337        gpu.copy_h2d(&bytes, d)?;
338        Ok(d)
339    };
340    let mut tables = BTreeMap::new();
341    for (key, a_tab) in &a_tabs {
342        let b_tab = &b_tabs[key];
343        tables.insert(*key, (mk(a_tab)?, mk(b_tab)?));
344    }
345
346    // Parallel [max_loras] f32 scale table (per-slot scale, 0.0 for unpacked
347    // slots) — the bgmv fold reads scale_table[seq_slot] in fp32. Same
348    // load-time-fixed pattern as the a/b tables.
349    debug_assert_eq!(expert_off, expert_total, "expert pool filled exactly");
350    let scale_vals = scale_table_values(adapters, max_loras);
351    let scale_bytes: Vec<u8> = scale_vals.iter().flat_map(|s| s.to_le_bytes()).collect();
352    let scale_table = gpu.alloc(scale_bytes.len())?;
353    gpu.copy_h2d(&scale_bytes, scale_table)?;
354
355    Ok(LoraWeights {
356        name: slots[0].name.clone(),
357        adapter_config: slots[0].adapter_config.clone(),
358        max_rank: max_lora_rank,
359        max_loras,
360        pool,
361        pool_bytes,
362        expert_pool,
363        expert_pool_bytes: expert_total,
364        slots,
365        active: 0,
366        tables,
367        scale_table,
368        // One counter per pool index, stable across swaps (sized to max_loras,
369        // not slots.len(), so a later swap-into an empty slot has a counter).
370        ref_counts: (0..max_loras).map(|_| AtomicUsize::new(0)).collect(),
371        pinned,
372        last_used: (0..max_loras).map(|_| AtomicU64::new(0)).collect(),
373        lru_tick: AtomicU64::new(0),
374        overlay_raw,
375    })
376}
377
378/// Runtime disk swap: audit + pack an already-loaded adapter `store` into an
379/// EXISTING pool `slot` of `lw`, in place, and stamp that slot's
380/// name/config/layers. Byte-identical to a startup pack of the same adapter into
381/// that slot — same audit, A-contiguous copy, and B row-repack via `pack_slot`.
382/// The slot sub-region is re-zeroed first (a reused slot still holds the prior
383/// adapter's bytes, and pad rows/cols must stay 0 for padded-K correctness).
384/// Returns the rebuilt per-layer pairs so the caller can re-install them if the
385/// slot is currently active. Like the startup pack, the intermediate `store`'s
386/// device copies leak (small, one-off per swap). Used for the pool-size-1
387/// dynamic-load demo (load a different adapter into the single slot at runtime).
388pub fn pack_store_into_slot(
389    lw: &mut LoraWeights,
390    slot: usize,
391    name: &str,
392    store: &WeightStore,
393    peft: &PeftAdapterConfig,
394    cfg: &ModelConfig,
395    gpu: &dyn GpuBackend,
396) -> Result<Vec<Option<LoraLayerWeights>>> {
397    if slot >= lw.max_loras {
398        bail!(
399            "LoRA disk swap: slot {slot} >= max_loras {} (pool has {} slots)",
400            lw.max_loras,
401            lw.max_loras
402        );
403    }
404    // Task #25 busy-slot refusal: bail BEFORE any destructive op (memset/pack)
405    // so a refused swap leaves the slot's bytes + identity untouched. Replacing
406    // an adapter while sequences are mid-decode on it would corrupt their KV and
407    // replay a captured graph over swapped pool bytes.
408    let busy = lw.slot_ref_count(slot);
409    if busy > 0 {
410        bail!(
411            "LoRA disk swap REFUSED: slot {slot} has {busy} in-flight sequence(s) \
412             (ref_count>0); cannot replace an adapter mid-decode"
413        );
414    }
415    validate_peft_config(peft, lw.max_rank)?;
416    let audited = audit_adapter(store, peft, cfg, lw.max_rank)?;
417    if expert_pack::present(&audited.router, &audited.experts) {
418        bail!(
419            "LoRA disk swap REFUSED: adapter '{name}' carries router/expert deltas \
420             (Feature-1); runtime slot-swap of the expert pool is a phase-2 followup"
421        );
422    }
423    if !audited.overlay.is_empty() {
424        bail!(
425            "LoRA disk swap REFUSED: adapter '{name}' ships token-overlay tensors \
426             (Feature-2); runtime slot-swap of the overlay tables is a phase-2 \
427             followup (would silently drop the overlay otherwise)"
428        );
429    }
430    let found = audited.attn;
431    let slot_bytes = pool_slot_bytes(cfg, lw.max_rank);
432    gpu.memset(
433        DevicePtr(lw.pool.0 + (slot * slot_bytes) as u64),
434        0,
435        slot_bytes,
436    )?;
437    let (layers, _slot_ptrs) = pack_slot(
438        slot,
439        name,
440        store,
441        peft,
442        &found,
443        cfg,
444        gpu,
445        lw.pool,
446        lw.max_rank,
447    )?;
448    lw.slots[slot].name = name.to_string();
449    lw.slots[slot].adapter_config = peft.clone();
450    lw.slots[slot].layers = layers.clone();
451    // Task #26: refresh this slot's a/b pointer tables + scale table from the
452    // new adapter's actual coverage (see refresh_slot_tables) so a re-staged slot
453    // with different module coverage doesn't leave a stale/NULL bgmv route entry.
454    lw.refresh_slot_tables(slot, &layers, peft.scaling(), gpu)?;
455    // Task #25: contents changed → bump generation so this re-staged slot yields
456    // a FRESH adapter_id and a later request misses the stale prior KV. (Covers
457    // the disk swap and any future caller of this shared helper.)
458    lw.slots[slot].generation = lw.slots[slot].generation.wrapping_add(1);
459    Ok(layers)
460}
461
462/// Single-adapter convenience wrapper (packs slot 0 only) — byte-identical to
463/// the pre-multi-adapter path. Kept for the unit tests and any single-adapter
464/// caller. The `name` is stamped onto the sole slot.
465pub fn load_lora_adapters_generic(
466    adapter_store: &WeightStore,
467    peft: &PeftAdapterConfig,
468    cfg: &ModelConfig,
469    gpu: &dyn GpuBackend,
470    max_loras: usize,
471    max_lora_rank: usize,
472) -> Result<LoraWeights> {
473    let inputs = [LoraAdapterInput {
474        name: String::new(),
475        store: adapter_store,
476        peft: peft.clone(),
477    }];
478    load_lora_adapters_multi(&inputs, cfg, gpu, max_loras, max_lora_rank)
479}