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;