metrale_model_layers/lora/
types.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: LoRA adapter types: the module and A/B enums, per-layer and
4//! per-slot pairs, the loaded pool set [`LoraWeights`], and the view types the
5//! victim search reads.
6//!
7//! Owner: model-layers (lora).
8//! Invariants: none beyond the types.
9
10use std::collections::BTreeMap;
11use std::sync::atomic::{AtomicU64, AtomicUsize};
12
13use metrale_config::PeftAdapterConfig;
14use metrale_gpu_runtime::gpu::DevicePtr;
15use metrale_model_weights::weights::WeightStore;
16
17use super::*;
18use crate::layers::ops::lora_delta::LoraPair;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub enum LoraModule {
22    QProj,
23    KProj,
24    VProj,
25    OProj,
26    GateProj,
27    UpProj,
28    DownProj,
29    /// 2026-09-25: The GDN block's output projection (`linear_attn.out_proj`),
30    /// linear-attention value width to hidden. `OProj` instead reads the
31    /// attention head width and applies only to full-attention layers.
32    OutProj,
33}
34
35impl LoraModule {
36    pub const ALL: [LoraModule; 8] = [
37        Self::QProj,
38        Self::KProj,
39        Self::VProj,
40        Self::OProj,
41        Self::GateProj,
42        Self::UpProj,
43        Self::DownProj,
44        Self::OutProj,
45    ];
46
47    pub fn is_dense_ffn(&self) -> bool {
48        matches!(self, Self::GateProj | Self::UpProj | Self::DownProj)
49    }
50
51    pub fn is_gdn_out(&self) -> bool {
52        matches!(self, Self::OutProj)
53    }
54
55    /// 2026-09-25: Whether `layer` can carry this module: q/k/v/o on
56    /// full-attention layers; gate/up/down on every layer of a model with no
57    /// routed experts (`num_experts == 0`); out_proj on every other layer.
58    /// `pool_slot_bytes`, `module_slot_offsets`, `pack_slot` and the RDMA
59    /// landing path all filter by it, so the reserved and the written offsets
60    /// agree.
61    pub fn applies_to_layer(&self, cfg: &metrale_config::ModelConfig, layer: usize) -> bool {
62        use metrale_config::LayerType;
63        let full_attn = cfg.layer_type(layer) == LayerType::FullAttention;
64        if self.is_dense_ffn() {
65            cfg.num_experts == 0
66        } else if self.is_gdn_out() {
67            !full_attn
68        } else {
69            full_attn
70        }
71    }
72
73    /// 2026-09-25: The module's name in a PEFT `target_modules` list.
74    pub fn peft_name(&self) -> &'static str {
75        match self {
76            Self::QProj => "q_proj",
77            Self::KProj => "k_proj",
78            Self::VProj => "v_proj",
79            Self::OProj => "o_proj",
80            Self::GateProj => "gate_proj",
81            Self::UpProj => "up_proj",
82            Self::DownProj => "down_proj",
83            Self::OutProj => "out_proj",
84        }
85    }
86
87    /// 2026-09-25: `(out_dim, in_dim)` of the base projection. The adapter's A
88    /// is `[r, in_dim]` and its B is `[out_dim, r]`. On a gated-attention model
89    /// (`attn_gated`) q_proj's output includes the gate, so it is twice
90    /// `num_attention_heads * head_dim`.
91    pub fn dims(&self, cfg: &metrale_config::ModelConfig) -> (usize, usize) {
92        let h = cfg.hidden_size;
93        match self {
94            Self::QProj => (
95                (if cfg.attn_gated { 2 } else { 1 }) * cfg.num_attention_heads * cfg.head_dim,
96                h,
97            ),
98            Self::KProj | Self::VProj => (cfg.num_key_value_heads * cfg.head_dim, h),
99            Self::OProj => (h, cfg.num_attention_heads * cfg.head_dim),
100            Self::GateProj | Self::UpProj => (cfg.intermediate_size, h),
101            Self::DownProj => (h, cfg.intermediate_size),
102            Self::OutProj => (h, cfg.linear_num_value_heads * cfg.linear_value_head_dim),
103        }
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
108pub enum AdapterAb {
109    A = 0,
110    B = 1,
111}
112
113/// 2026-09-25: One layer's adapted modules, as [`LoraPair`]s; `None` means the
114/// module is not adapted. `Clone` because a rotation copies the new active
115/// slot's layers out of the pool set before re-installing them.
116#[derive(Clone)]
117pub struct LoraLayerWeights {
118    pub layer_idx: usize,
119    pub q_proj: Option<LoraPair>,
120    pub k_proj: Option<LoraPair>,
121    pub v_proj: Option<LoraPair>,
122    pub o_proj: Option<LoraPair>,
123    pub gate_proj: Option<LoraPair>,
124    pub up_proj: Option<LoraPair>,
125    pub down_proj: Option<LoraPair>,
126    pub out_proj: Option<LoraPair>,
127    /// 2026-09-25: MoE router (`mlp.gate`) delta. `None` unless the adapter
128    /// targets the router; loading such an adapter needs
129    /// `METRALE_LORA_EXPERTS=1`.
130    pub router: Option<LoraPair>,
131    /// 2026-09-25: This layer's routed-expert pairs. `None` unless the adapter
132    /// carries expert deltas (which also need `METRALE_LORA_EXPERTS=1`). They
133    /// live in `LoraWeights::expert_pool`, outside the equal-size slot pool.
134    pub experts: Option<ExpertLoraLayer>,
135}
136
137impl LoraLayerWeights {
138    /// 2026-09-25: An entry for `layer_idx` with no module adapted.
139    pub fn empty(layer_idx: usize) -> Self {
140        Self {
141            layer_idx,
142            q_proj: None,
143            k_proj: None,
144            v_proj: None,
145            o_proj: None,
146            gate_proj: None,
147            up_proj: None,
148            down_proj: None,
149            out_proj: None,
150            router: None,
151            experts: None,
152        }
153    }
154}
155
156/// 2026-09-25: One pool slot: the resident adapter's name and config, and its
157/// pairs, which point into this slot's region of the shared pool. `layers` is
158/// indexed by global layer and has `num_hidden_layers` entries. An empty
159/// `name` marks a placeholder that holds no adapter.
160#[derive(Clone)]
161pub struct AdapterSlot {
162    pub name: String,
163    pub adapter_config: PeftAdapterConfig,
164    pub layers: Vec<Option<LoraLayerWeights>>,
165    /// 2026-09-25: Incremented (wrapping) each time a disk or peer swap
166    /// replaces this slot's contents, and folded into [`adapter_id_hash`], so
167    /// new weights under the same name get a new cache identity and miss the
168    /// old prefix cache entries. It is 0 at load, where the fold is a no-op. A
169    /// rotation does not change it.
170    pub generation: u64,
171}
172
173/// 2026-09-25: One adapter to pack. `store` holds its tensors on the device as
174/// BF16: `metrale_model_weights::weights::adapter::load_adapter_safetensors`
175/// converts F16 and F32 on the host.
176pub struct LoraAdapterInput<'a> {
177    pub name: String,
178    pub store: &'a WeightStore,
179    pub peft: PeftAdapterConfig,
180}
181
182/// 2026-09-25: The loaded adapter set: one fixed-address pool of `max_loras`
183/// equal-size slots padded to `max_rank`, one [`AdapterSlot`] per pool slot,
184/// and per-(layer, module) `[max_loras]` device pointer tables.
185pub struct LoraWeights {
186    /// 2026-09-25: Name of the active adapter, for logs and status.
187    pub name: String,
188    /// 2026-09-25: Config of the adapter made active at load or by the last
189    /// rotation, for logs. A swap into the active slot updates `name` but not
190    /// this.
191    pub adapter_config: PeftAdapterConfig,
192    pub max_rank: usize,
193    pub max_loras: usize,
194    /// 2026-09-25: One allocation holding every slot's padded A and B.
195    pub pool: DevicePtr,
196    pub pool_bytes: usize,
197    /// 2026-09-25: A separate allocation for the router and routed-expert
198    /// padded A and B, sized from the audited keys of the startup adapters.
199    /// `None` when no adapter targets experts or the router.
200    pub expert_pool: Option<DevicePtr>,
201    pub expert_pool_bytes: usize,
202    /// 2026-09-25: Slot-indexed, `len() == max_loras`: the startup adapters,
203    /// then placeholders. Slot `k`'s pairs live from pool byte offset
204    /// `k * pool_slot_bytes`.
205    pub slots: Vec<AdapterSlot>,
206    /// 2026-09-25: Index into `slots` of the active adapter; 0 at load.
207    pub active: usize,
208    /// 2026-09-25: `(global layer, module)` to `(a_table, b_table)`. Each table
209    /// is a device `[max_loras]` u64 array; 0 means that slot does not adapt
210    /// the module.
211    pub tables: BTreeMap<(usize, LoraModule), (DevicePtr, DevicePtr)>,
212    /// 2026-09-25: Device `[max_loras]` f32 table: entry `k` is slot `k`'s
213    /// `PeftAdapterConfig::scaling()` (alpha/r, or alpha/sqrt(r) with rsLoRA),
214    /// the same scale its [`LoraPair`]s carry, and 0.0 for an unpacked slot.
215    /// Its address is fixed at load; a swap rewrites one entry.
216    pub scale_table: DevicePtr,
217    /// 2026-09-25: In-flight sequence count per pool index,
218    /// `len() == max_loras`. A sequence takes a count through
219    /// [`Self::acquire_slot`] and returns it when it is freed. A swap into a
220    /// slot whose count is above 0, or a rotation away from one, is refused.
221    /// It is a parallel Vec because [`AdapterSlot`] derives `Clone` and
222    /// [`AtomicUsize`] is not `Clone`.
223    pub ref_counts: Vec<AtomicUsize>,
224    /// 2026-09-25: Slots `[0, pinned)` hold the startup adapters and are never
225    /// eviction candidates. Slots `[pinned, max_loras)` are the promotion
226    /// cache, placeholders at load.
227    pub pinned: usize,
228    /// 2026-09-25: Last-used tick per pool index, `len() == max_loras`, set by
229    /// [`Self::acquire_slot`] on the resolved index and by
230    /// [`Self::touch_slot`].
231    pub last_used: Vec<AtomicU64>,
232    /// 2026-09-25: Source of the `last_used` ticks; incremented by each
233    /// acquire and touch.
234    pub lru_tick: AtomicU64,
235    /// 2026-09-25: Raw token-overlay uploads, one per startup adapter (`None`
236    /// when it ships no overlay tensors). The model's `set_lora_weights` takes
237    /// them and builds the overlay tables, which need the served embed and
238    /// lm_head tables.
239    pub overlay_raw: Vec<Option<super::overlay_build::OverlayRawSlot>>,
240}
241
242/// 2026-09-25: A cache slot's state as `select_victim_slot` reads it, built by
243/// [`LoraWeights::cache_slot_views`].
244#[derive(Clone, Copy, Debug)]
245pub struct SlotView {
246    /// 2026-09-25: The slot holds an adapter (its name is not empty).
247    pub filled: bool,
248    /// 2026-09-25: In-flight sequences; 0 means evictable.
249    pub ref_count: usize,
250    /// 2026-09-25: Larger means more recently used.
251    pub last_used: u64,
252}
253
254/// 2026-09-25: Why a promotion found no victim slot.
255#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256pub enum VictimError {
257    /// 2026-09-25: Every cache slot has in-flight sequences. The caller may
258    /// retry; a busy slot is never chosen.
259    PoolFull,
260}
261
262impl LoraLayerWeights {
263    /// 2026-09-25: This layer's pair for `module`; `None` when the module is
264    /// not adapted. [`select_routed_pair`] reads pairs through it.
265    pub fn module_pair(&self, module: LoraModule) -> Option<&LoraPair> {
266        match module {
267            LoraModule::QProj => self.q_proj.as_ref(),
268            LoraModule::KProj => self.k_proj.as_ref(),
269            LoraModule::VProj => self.v_proj.as_ref(),
270            LoraModule::OProj => self.o_proj.as_ref(),
271            LoraModule::GateProj => self.gate_proj.as_ref(),
272            LoraModule::UpProj => self.up_proj.as_ref(),
273            LoraModule::DownProj => self.down_proj.as_ref(),
274            LoraModule::OutProj => self.out_proj.as_ref(),
275        }
276    }
277}
278
279impl LoraWeights {
280    /// 2026-09-25: The active slot's pairs, indexed by global layer.
281    pub fn active_layers(&self) -> &[Option<LoraLayerWeights>] {
282        &self.slots[self.active].layers
283    }
284
285    /// 2026-09-25: Resolve a request's `adapter_slot` (`>= 0` that slot, `-1`
286    /// the active one) and return it only when it is an in-range slot other
287    /// than the active one; `None` otherwise. See [`routed_prefill_slot_of`].
288    pub fn routed_prefill_slot(&self, adapter_slot: i32) -> Option<usize> {
289        routed_prefill_slot_of(adapter_slot, self.active, self.slots.len())
290    }
291
292    /// 2026-09-25: The slot holding adapter `name`; placeholders never match.
293    pub fn slot_of(&self, name: &str) -> Option<usize> {
294        self.slots
295            .iter()
296            .position(|s| !s.name.is_empty() && s.name == name)
297    }
298
299    /// 2026-09-25: The names of all resident adapters, in slot order.
300    pub fn adapter_names(&self) -> Vec<String> {
301        self.slots
302            .iter()
303            .filter(|s| !s.name.is_empty())
304            .map(|s| s.name.clone())
305            .collect()
306    }
307
308    /// 2026-09-25: Cache identity for a request's `adapter_slot` (`>= 0` that
309    /// slot, `-1` the active one): [`adapter_id_hash`] of the resolved slot's
310    /// name and generation, or the base sentinel 0 when the slot is out of
311    /// range or a placeholder.
312    pub fn adapter_id_for_slot(&self, slot: i32) -> u64 {
313        let resolved = if slot >= 0 {
314            slot as usize
315        } else {
316            self.active
317        };
318        match self.slots.get(resolved) {
319            Some(s) if !s.name.is_empty() => adapter_id_hash(&s.name, s.generation),
320            Some(_) | None => 0,
321        }
322    }
323}
324
325#[path = "types_slots.rs"]
326mod types_slots;
327
328#[cfg(test)]
329#[path = "types_tests.rs"]
330mod tests;