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;