spark_model/lora/
types.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! LoRA adapter type surface: the module/AB enums, per-layer + per-slot weight
4//! structs, the loaded [`LoraWeights`] set with its ref-count/LRU/generation
5//! bookkeeping, and the pure victim-selection view types. Split out of the
6//! former monolithic `lora/mod.rs` (SDD seam: TYPES) — visibility unchanged.
7
8use std::collections::BTreeMap;
9use std::sync::atomic::{AtomicU64, AtomicUsize};
10
11use atlas_core::config::PeftAdapterConfig;
12use spark_runtime::gpu::DevicePtr;
13use spark_runtime::weights::WeightStore;
14
15use super::*;
16use crate::layers::ops::lora_delta::LoraPair;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
19pub enum LoraModule {
20    QProj,
21    KProj,
22    VProj,
23    OProj,
24    GateProj,
25    UpProj,
26    DownProj,
27    /// GDN block OUTPUT projection (`linear_attn.out_proj`), value_dim ->
28    /// hidden. Not `OProj`: different contract dim, disjoint layer set.
29    OutProj,
30}
31
32impl LoraModule {
33    pub const ALL: [LoraModule; 8] = [
34        Self::QProj,
35        Self::KProj,
36        Self::VProj,
37        Self::OProj,
38        Self::GateProj,
39        Self::UpProj,
40        Self::DownProj,
41        Self::OutProj,
42    ];
43
44    /// Dense SwiGLU FFN module — see [`Self::applies_to_layer`].
45    pub fn is_dense_ffn(&self) -> bool {
46        matches!(self, Self::GateProj | Self::UpProj | Self::DownProj)
47    }
48
49    /// Whether this module is the GDN block's output projection.
50    pub fn is_gdn_out(&self) -> bool {
51        matches!(self, Self::OutProj)
52    }
53
54    /// Which layers may carry this module: attention q/k/v/o on full-attention
55    /// layers only; dense gate/up/down on EVERY layer of a dense-FFN model (a
56    /// hybrid's linear-attention layers carry the SwiGLU FFN too); GDN out_proj
57    /// on linear-attention layers only. THE authority for BOTH the pool layout
58    /// and the packing walk, so reserved and written bytes cannot disagree.
59    pub fn applies_to_layer(&self, cfg: &atlas_core::config::ModelConfig, layer: usize) -> bool {
60        use atlas_core::config::LayerType;
61        let full_attn = cfg.layer_type(layer) == LayerType::FullAttention;
62        if self.is_dense_ffn() {
63            // MoE `mlp.*` is the routed-expert path, packed separately.
64            cfg.num_experts == 0
65        } else if self.is_gdn_out() {
66            !full_attn
67        } else {
68            full_attn
69        }
70    }
71
72    /// PEFT suffix name (target_modules vocabulary).
73    pub fn peft_name(&self) -> &'static str {
74        match self {
75            Self::QProj => "q_proj",
76            Self::KProj => "k_proj",
77            Self::VProj => "v_proj",
78            Self::OProj => "o_proj",
79            Self::GateProj => "gate_proj",
80            Self::UpProj => "up_proj",
81            Self::DownProj => "down_proj",
82            Self::OutProj => "out_proj",
83        }
84    }
85
86    /// (out_dim, in_dim) of the base projection. Holo-3.1-0.8B (verified
87    /// against the checkpoint header): k/v `[512,1024]`, o `[1024,2048]`,
88    /// gate/up `[3584,1024]`, down `[1024,3584]`.
89    ///
90    /// q_proj: on a gated-attention model (`attn_gated`) the raw projection
91    /// emits the interleaved `[Q|gate]` at width `2·q_heads·head_dim` (the
92    /// FULL width the PEFT `lora_B` was trained against — verified `[8192,16]`
93    /// on holo-3.1-35b); ungated q is `q_heads·head_dim`.
94    pub fn dims(&self, cfg: &atlas_core::config::ModelConfig) -> (usize, usize) {
95        let h = cfg.hidden_size;
96        match self {
97            Self::QProj => (
98                (if cfg.attn_gated { 2 } else { 1 }) * cfg.num_attention_heads * cfg.head_dim,
99                h,
100            ),
101            Self::KProj | Self::VProj => (cfg.num_key_value_heads * cfg.head_dim, h),
102            Self::OProj => (h, cfg.num_attention_heads * cfg.head_dim),
103            Self::GateProj | Self::UpProj => (cfg.intermediate_size, h),
104            Self::DownProj => (h, cfg.intermediate_size),
105            // GDN out_proj contracts over the SSM value width, NOT over
106            // hidden or over the attention head width.
107            Self::OutProj => (h, cfg.linear_num_value_heads * cfg.linear_value_head_dim),
108        }
109    }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
113pub enum AdapterAb {
114    A = 0,
115    B = 1,
116}
117
118/// One full-attention layer's adapted modules. `None` = module not adapted.
119/// Pairs are the CANONICAL [`LoraPair`] from `layers::ops::lora_delta`
120/// (Copy — installed by copy into the layer structs at model build).
121///
122/// `Clone` (LoraPair is Copy) so a slot's layers can be re-installed onto the
123/// layer structs on a runtime rotation (`set_active_lora`).
124#[derive(Clone)]
125pub struct LoraLayerWeights {
126    pub layer_idx: usize,
127    pub q_proj: Option<LoraPair>,
128    pub k_proj: Option<LoraPair>,
129    pub v_proj: Option<LoraPair>,
130    pub o_proj: Option<LoraPair>,
131    pub gate_proj: Option<LoraPair>,
132    pub up_proj: Option<LoraPair>,
133    pub down_proj: Option<LoraPair>,
134    /// GDN out_proj delta (linear-attention layers only).
135    pub out_proj: Option<LoraPair>,
136    /// Feature-1: MoE router (`mlp.gate`) delta on the routing logits. `None`
137    /// unless the adapter targets the router AND `ATLAS_LORA_EXPERTS=1`.
138    pub router: Option<LoraPair>,
139    /// Feature-1: this layer's routed-expert LoRA coverage (sparse per-expert
140    /// pairs). `None` for attention/dense-only adapters or when expert LoRA is
141    /// disabled. Packed into a SEPARATE expert allocation (not the equal-slot
142    /// attention pool), so the BGMV route tables + slot offset math are
143    /// untouched.
144    pub experts: Option<ExpertLoraLayer>,
145}
146
147impl LoraLayerWeights {
148    /// A zeroed per-layer entry (all modules unadapted). Used by the pack loops
149    /// to lazily create a layer entry the first time any module — attention,
150    /// router, or expert — lands on it.
151    pub fn empty(layer_idx: usize) -> Self {
152        Self {
153            layer_idx,
154            q_proj: None,
155            k_proj: None,
156            v_proj: None,
157            o_proj: None,
158            gate_proj: None,
159            up_proj: None,
160            down_proj: None,
161            out_proj: None,
162            router: None,
163            experts: None,
164        }
165    }
166}
167
168/// One packed pool slot: a resident adapter's own name/config + its per-layer
169/// pairs (a/b DevicePtrs into that slot's byte sub-region of the shared pool).
170/// `layers` is GLOBAL-layer-indexed (len = num_hidden_layers), the same index
171/// the install walk uses.
172#[derive(Clone)]
173pub struct AdapterSlot {
174    pub name: String,
175    pub adapter_config: PeftAdapterConfig,
176    pub layers: Vec<Option<LoraLayerWeights>>,
177    /// Task #25 (slot generation): monotonic counter bumped every time this
178    /// slot's CONTENTS are replaced (disk/RDMA swap-into-slot). Folded into the
179    /// adapter identity ([`adapter_id_hash`]) so re-staging DIFFERENT weights
180    /// under the SAME adapter name yields a FRESH id — a later request then
181    /// misses the stale (previous-generation) prefix/KV instead of warm-hitting
182    /// it. Init 0; gen 0 is a strict no-op in the fold so a slot's FIRST-load id
183    /// (and the base sentinel) stay byte-identical to the pre-#25 (#24) value. A
184    /// pure rotate (same weights re-pointed) does NOT bump.
185    pub generation: u64,
186}
187
188/// One adapter to pack, for the multi-adapter entry point. `store` is the
189/// adapter's on-device BF16 `WeightStore` (host F16/F32→BF16 already done by
190/// `spark_runtime::weights::adapter::load_adapter_safetensors`).
191pub struct LoraAdapterInput<'a> {
192    pub name: String,
193    pub store: &'a WeightStore,
194    pub peft: PeftAdapterConfig,
195}
196
197/// The loaded adapter set: one fixed-address rank-padded pool holding up to
198/// `max_loras` equal-size slots, one [`AdapterSlot`] per resident adapter, and
199/// per-module `[max_loras]` device u64 pointer tables (the frozen M2 BGMV
200/// contract — filled index k for each packed slot, NULL for the rest).
201///
202/// Single-adapter runs pack exactly one slot (`slots.len() == 1`, `active == 0`)
203/// — byte-identical to the pre-multi-adapter path. `name`/`adapter_config` mirror
204/// the ACTIVE slot for logs/status; the install walk reads [`Self::active_layers`].
205pub struct LoraWeights {
206    /// Name of the ACTIVE adapter (mirrors `slots[active].name`).
207    pub name: String,
208    /// Config of the ACTIVE adapter (mirrors `slots[active].adapter_config`).
209    pub adapter_config: PeftAdapterConfig,
210    pub max_rank: usize,
211    pub max_loras: usize,
212    /// One fixed-address allocation holding every padded A/B for every slot.
213    pub pool: DevicePtr,
214    pub pool_bytes: usize,
215    /// Feature-1: separate fixed-address allocation for the router + routed-
216    /// expert padded A/B (sized from the audited key set, NOT `num_experts ×
217    /// num_layers`). `None` when no adapter targets experts/router (byte-
218    /// identical to the pre-Feature-1 pool). Deliberately outside the equal-size
219    /// attention pool so `pool_slot_bytes` + the BGMV route tables are untouched.
220    pub expert_pool: Option<DevicePtr>,
221    pub expert_pool_bytes: usize,
222    /// The resident adapters, slot-indexed (`slots[k]` lives at pool byte
223    /// offset `k * pool_slot_bytes`). `len() <= max_loras`.
224    pub slots: Vec<AdapterSlot>,
225    /// Index into `slots` of the currently-active adapter (0 at load).
226    pub active: usize,
227    /// key = (global_layer_idx, module) → (a_table, b_table); each table is
228    /// a device `[max_loras]` u64 array, NULL (0) = base-only slot.
229    pub tables: BTreeMap<(usize, LoraModule), (DevicePtr, DevicePtr)>,
230    /// The parallel `[max_loras]` device f32 SCALE table the bgmv reads,
231    /// indexed by slot: `scale_table[k]` = `slots[k].adapter_config.scaling()`
232    /// (alpha/r, or alpha/√r under rsLoRA — the same per-adapter scale that
233    /// rides each [`LoraPair`]), 0.0 for unpacked slots. Scale is per-ADAPTER
234    /// (not per-module), so ONE table suffices. Built once at pool pack time
235    /// alongside the a/b tables (load-time-fixed → graph-safe kernel arg).
236    pub scale_table: DevicePtr,
237    /// Task #25 (slot ref_count): per-slot in-flight-sequence count, one
238    /// [`AtomicUsize`] per pool index (`len() == max_loras`, stable across
239    /// swaps). A sequence acquires (`+1`) its resolved slot at prefill and
240    /// releases (`-1`) at terminal free; a swap/rotate INTO a slot with
241    /// `ref_count > 0` is REFUSED (you cannot replace an adapter mid-decode —
242    /// it would corrupt in-flight KV and replay a captured graph over swapped
243    /// pool bytes). Kept as a parallel Vec here (not on [`AdapterSlot`], which
244    /// derives Clone and is cloned during install — `AtomicUsize` is not Clone);
245    /// [`LoraWeights`] is deliberately non-Clone and already `Send + Sync`.
246    /// Interior-mutable through `&self` (acquire/release run on the prefill/free
247    /// `&self` paths); swaps read it under `&mut self` at a quiescent point.
248    pub ref_counts: Vec<AtomicUsize>,
249    /// Task #27 (demand-driven promotion): the PINNED/CACHE boundary. Slots
250    /// `[0, pinned)` are the startup `--lora-adapter` set — advertised by
251    /// `/v1/models`, resolved by the position-based `resolve_adapter_slot`, and
252    /// NEVER an eviction victim. Slots `[pinned, max_loras)` are the promotion
253    /// HOT CACHE (empty placeholders at load): a demand-promoted adapter lands
254    /// in one of these. `pinned == slots-populated-at-load`.
255    pub pinned: usize,
256    /// Task #27: per-slot last-used LRU tick, one [`AtomicU64`] per pool index
257    /// (`len() == max_loras`, parallel to `ref_counts`). Bumped in
258    /// [`Self::acquire_slot`] on the RESOLVED index so victim selection ages the
259    /// TRUE slot a request used (including `-1 -> active`). A cache slot with the
260    /// smallest `last_used` among the `ref_count == 0` idle slots is the LRU
261    /// eviction victim. Interior-mutable through `&self` like `ref_counts`.
262    pub last_used: Vec<AtomicU64>,
263    /// Task #27: monotonic source for `last_used` ticks (never wraps in
264    /// practice). Bumped once per acquire.
265    pub lru_tick: AtomicU64,
266    /// Feature-2 (token overlay): per-slot Stage-1 raw overlay upload, `len ==
267    /// slots.len()` (padding slots push `None`). Consumed + cleared by
268    /// `set_lora_weights` (Stage 2 `build_overlay`), which needs the served
269    /// embed/lm_head tables that only exist after weight load. `Vec::new()` /
270    /// all-`None` ⇒ no overlay adapter ⇒ byte-identical to a no-overlay build.
271    pub overlay_raw: Vec<Option<super::overlay_build::OverlayRawSlot>>,
272}
273
274/// Task #27: a per-slot snapshot for the pure victim-selection policy. Taken on
275/// the model thread at a scheduler-quiescent point (the only place `ref_count`
276/// is authoritative), then handed to `select_victim_slot`.
277#[derive(Clone, Copy, Debug)]
278pub struct SlotView {
279    /// `true` if this slot currently holds a (non-placeholder) adapter.
280    pub filled: bool,
281    /// In-flight sequence count (`0` == idle == evictable).
282    pub ref_count: usize,
283    /// LRU tick (larger = more recently used).
284    pub last_used: u64,
285}
286
287/// Why a promotion cannot find a victim slot.
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
289pub enum VictimError {
290    /// Every cache slot is busy (`ref_count > 0`) — a RETRYABLE condition, never
291    /// an eviction of an in-flight adapter.
292    PoolFull,
293}
294
295impl LoraLayerWeights {
296    /// Select this layer's [`LoraPair`] for `module` (`None` = module not
297    /// adapted). The single source of truth for the (layer, module) → pair map,
298    /// shared by [`select_routed_pair`] and [`LoraWeights::refresh_slot_tables`].
299    pub fn module_pair(&self, module: LoraModule) -> Option<&LoraPair> {
300        match module {
301            LoraModule::QProj => self.q_proj.as_ref(),
302            LoraModule::KProj => self.k_proj.as_ref(),
303            LoraModule::VProj => self.v_proj.as_ref(),
304            LoraModule::OProj => self.o_proj.as_ref(),
305            LoraModule::GateProj => self.gate_proj.as_ref(),
306            LoraModule::UpProj => self.up_proj.as_ref(),
307            LoraModule::DownProj => self.down_proj.as_ref(),
308            LoraModule::OutProj => self.out_proj.as_ref(),
309        }
310    }
311}
312
313impl LoraWeights {
314    /// The active slot's per-layer pairs (GLOBAL-layer-indexed) — what the
315    /// install walk copies onto the layer structs.
316    pub fn active_layers(&self) -> &[Option<LoraLayerWeights>] {
317        &self.slots[self.active].layers
318    }
319
320    /// #30 (routed-prefill precision): resolve a request's `adapter_slot`
321    /// (`>= 0` → that slot, `-1` → active) and return `Some(resolved)` ONLY when it
322    /// routes to a NON-active, in-range slot — the SINGLE source of truth for
323    /// "this prefill must apply the request slot's pair via the dense path". Kept
324    /// in exact lockstep with [`crate::model`]'s `upload_seq_slot_uniform`
325    /// (`resolved == active` → `DevicePtr(0)` → installed-pair path). Returns
326    /// `None` for an active/base request (byte-identical) and for out-of-range
327    /// slots (bad request → installed active pair, never a panic).
328    pub fn routed_prefill_slot(&self, adapter_slot: i32) -> Option<usize> {
329        routed_prefill_slot_of(adapter_slot, self.active, self.slots.len())
330    }
331
332    /// Resolve an adapter NAME to its slot index (for runtime rotation).
333    pub fn slot_of(&self, name: &str) -> Option<usize> {
334        self.slots
335            .iter()
336            .position(|s| !s.name.is_empty() && s.name == name)
337    }
338
339    /// All resident adapter names in slot order (for `/v1/models`).
340    pub fn adapter_names(&self) -> Vec<String> {
341        self.slots
342            .iter()
343            .filter(|s| !s.name.is_empty())
344            .map(|s| s.name.clone())
345            .collect()
346    }
347
348    /// Stable adapter_id (Task #24) for a pool slot request selector. `slot`
349    /// follows the `SequenceState.adapter_slot` convention: `>= 0` selects that
350    /// resident slot, `-1` means "defer to the installed active adapter" (so a
351    /// default request keys under whatever adapter is actually active — matching
352    /// `build_seq_slot_host`'s `-1 -> active` resolution). The id is the NAME
353    /// hash, resolved at prefill time (active may rotate between HTTP resolve and
354    /// prefill). Out-of-range slots fall back to the base sentinel `0`.
355    pub fn adapter_id_for_slot(&self, slot: i32) -> u64 {
356        let resolved = if slot >= 0 {
357            slot as usize
358        } else {
359            self.active
360        };
361        match self.slots.get(resolved) {
362            Some(s) if !s.name.is_empty() => adapter_id_hash(&s.name, s.generation),
363            Some(_) | None => 0,
364        }
365    }
366}
367
368#[path = "types_slots.rs"]
369mod types_slots;
370
371#[cfg(test)]
372#[path = "types_tests.rs"]
373mod tests;