spark_model/lora/slot_math.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! LoRA slot/offset math: the pure, GPU-free functions that place adapters in
4//! the fixed-address rank-padded pool and route requests to slots — victim
5//! selection, per-step `seq_slot` build, scale-table values, routed-prefill
6//! predicate + pair selection, and the frozen per-slot byte-offset layout.
7//! Split out of the former monolithic `lora/mod.rs` (SDD seam: SLOT/OFFSET
8//! MATH) — visibility unchanged.
9
10use atlas_core::config::ModelConfig;
11
12use super::*;
13use crate::layers::ops::lora_delta::LoraPair;
14
15pub(crate) const BF16_BYTES: usize = 2;
16
17/// Task #27 pure victim-selection policy over the CACHE region only (the caller
18/// passes `(slot_index, view)` for slots `[pinned, max_loras)` — pinned startup
19/// adapters are never candidates, so the resident set and its position-based
20/// resolver can never desync). Tiers:
21/// 1. FREE-FIRST: the first `!filled` (never-promoted) placeholder slot.
22/// 2. LRU-IDLE: else the `ref_count == 0` slot with the smallest `last_used`.
23/// 3. POOL-FULL: else every cache slot is busy → `Err(PoolFull)` (retryable);
24/// a `ref_count > 0` slot is NEVER returned.
25pub fn select_victim_slot(cache: &[(usize, SlotView)]) -> Result<usize, VictimError> {
26 // Tier 1: a never-filled placeholder is the cheapest victim (no eviction).
27 if let Some((idx, _)) = cache.iter().find(|(_, v)| !v.filled) {
28 return Ok(*idx);
29 }
30 // Tier 2: LRU among the idle (ref_count == 0) filled slots.
31 cache
32 .iter()
33 .filter(|(_, v)| v.ref_count == 0)
34 .min_by_key(|(_, v)| v.last_used)
35 .map(|(idx, _)| *idx)
36 // Tier 3: all cache slots busy — retryable, never evict a busy slot.
37 .ok_or(VictimError::PoolFull)
38}
39
40/// Build the per-step `seq_slot[padded_n]` host buffer the batched bgmv reads,
41/// from each real sequence's `adapter_slot`. Resolution rules (graph-safe:
42/// contents vary per step, buffer address is fixed):
43/// real row i (< n): `adapter_slots[i]` if `>= 0`, else `active` — a request
44/// with no `adapter` field carries `-1` and DEFERS to the installed active
45/// adapter, so a single global adapter (or a rotate re-point) applies to
46/// every default row exactly like the n==1 path.
47/// pad row i (n..padded_n): `-1` — base / no delta (bgmv early-returns).
48/// A row that explicitly names the base model (some future `-1`-means-base
49/// convention) is out of scope here; `-1` uniformly means "defer to active".
50pub fn build_seq_slot_host(adapter_slots: &[i32], padded_n: usize, active: i32) -> Vec<i32> {
51 let n = adapter_slots.len();
52 (0..padded_n)
53 .map(|i| {
54 if i < n {
55 let s = adapter_slots[i];
56 if s >= 0 { s } else { active }
57 } else {
58 -1
59 }
60 })
61 .collect()
62}
63
64/// Pure per-slot scale vector for the `[max_loras]` f32 scale table: entry `k`
65/// = adapter `k`'s `scaling()` (alpha/r, or alpha/√r under rsLoRA — read per
66/// adapter, never defaulted), 0.0 for unpacked slots `k >= adapters.len()`.
67/// Split out for unit testing (the device upload is a thin wrapper).
68pub(crate) fn scale_table_values(adapters: &[LoraAdapterInput<'_>], max_loras: usize) -> Vec<f32> {
69 let mut v = vec![0.0f32; max_loras];
70 for (k, a) in adapters.iter().enumerate() {
71 v[k] = a.peft.scaling();
72 }
73 v
74}
75
76/// #30 (routed-prefill precision): the pure predicate behind
77/// [`LoraWeights::routed_prefill_slot`], split out for unit testing. Resolves a
78/// request's `adapter_slot` (`>= 0` → that slot, `-1` → active) and returns
79/// `Some(resolved)` ONLY when it routes to a NON-active, in-range slot. Returns
80/// `None` for an active/base request (byte-identical installed-pair path) and for
81/// out-of-range slots. Kept in exact lockstep with `upload_seq_slot_uniform`
82/// (`resolved == active` → `DevicePtr(0)`).
83pub fn routed_prefill_slot_of(adapter_slot: i32, active: usize, num_slots: usize) -> Option<usize> {
84 let resolved = if adapter_slot >= 0 {
85 adapter_slot as usize
86 } else {
87 active
88 };
89 (resolved != active && resolved < num_slots).then_some(resolved)
90}
91
92/// #30 (routed-prefill precision): pure selector for a routed prefill's
93/// (global_layer, module) [`LoraPair`] out of a request slot's GLOBAL-layer-indexed
94/// `layers`. `None` when the index is out of range, the layer is unadapted, or the
95/// routed adapter does not adapt that module (the caller then falls back to the
96/// bgmv/installed path — no delta if the slot's a_table cell is base). GPU-free +
97/// unit-tested so the (layer, module) indexing is verifiable without hardware.
98pub fn select_routed_pair(
99 layers: &[Option<LoraLayerWeights>],
100 global_layer_idx: usize,
101 module: LoraModule,
102) -> Option<&LoraPair> {
103 layers
104 .get(global_layer_idx)
105 .and_then(|o| o.as_ref())
106 .and_then(|l| l.module_pair(module))
107}
108
109/// Padded per-slot bytes: Σ over (full-attn layers × 6 modules) of
110/// (max_rank·in + out·max_rank)·2. Holo @ max_rank=64: ≈ 2.44 MiB/layer
111/// × 6 = ~14.6 MiB/slot; × max_loras=8 ≈ 117 MiB total.
112pub(crate) fn pool_slot_bytes(cfg: &ModelConfig, max_rank: usize) -> usize {
113 // EVERY layer, each contributing only the modules that layer can carry
114 // (`LoraModule::applies_to_layer`). It used to be full-attention layers x
115 // ALL modules, which reserved q/k/v/o space on layers that have them but
116 // reserved NOTHING for the dense FFN a hybrid carries on its
117 // linear-attention layers — so those pairs had nowhere to land and were
118 // silently dropped. `pack_slot` walks in exactly this order.
119 (0..cfg.num_hidden_layers)
120 .map(|layer| {
121 LoraModule::ALL
122 .iter()
123 .filter(|m| m.applies_to_layer(cfg, layer))
124 .map(|m| {
125 let (out, inp) = m.dims(cfg);
126 (max_rank * inp + out * max_rank) * BF16_BYTES
127 })
128 .sum::<usize>()
129 })
130 .sum()
131}
132
133/// Byte offset of slot `k`'s base within the pool. Slots are equal fixed size,
134/// so slot `k` starts at `k * pool_slot_bytes`. Slot 0 → 0 (byte-identical to
135/// the single-adapter path).
136// Only the RDMA landing path (`rdma_stage`) and the unit tests call this. That
137// path is cuda AND unix (it lands through spark-storage's RDMA weight loader),
138// so the dead-code allowance must match: `not(all(cuda, unix))`, not
139// `not(cuda)`. It stays defined everywhere for the offset unit tests.
140#[cfg_attr(not(all(feature = "cuda", unix)), allow(dead_code))]
141pub(crate) fn slot_base_offset(slot: usize, cfg: &ModelConfig, max_rank: usize) -> usize {
142 slot * pool_slot_bytes(cfg, max_rank)
143}
144
145/// The (a_off, b_off) of a given (layer, module) WITHIN a slot — the exact
146/// running offsets the pack loop computes (layer asc × [`LoraModule::ALL`] ×
147/// A-then-B). `None` if `target_layer` is not a full-attention layer. Used by
148/// the pack loop, the RDMA landing path, and the offset unit tests so all three
149/// agree on the one frozen layout.
150#[cfg_attr(not(all(feature = "cuda", unix)), allow(dead_code))]
151pub(crate) fn module_slot_offsets(
152 cfg: &ModelConfig,
153 max_rank: usize,
154 target_layer: usize,
155 target_module: LoraModule,
156) -> Option<(usize, usize)> {
157 // MUST walk exactly as `pack_slot` and `pool_slot_bytes` do — every
158 // layer, applicable modules only. This is the RDMA landing path's view of
159 // the layout; if it disagrees with the packer, staged weights land at the
160 // wrong offsets. The three share `applies_to_layer` for that reason.
161 let mut off = 0usize;
162 for layer_idx in 0..cfg.num_hidden_layers {
163 for module in LoraModule::ALL {
164 if !module.applies_to_layer(cfg, layer_idx) {
165 continue;
166 }
167 let (out_dim, in_dim) = module.dims(cfg);
168 let a_off = off;
169 let b_off = off + max_rank * in_dim * BF16_BYTES;
170 off = b_off + out_dim * max_rank * BF16_BYTES;
171 if layer_idx == target_layer && module == target_module {
172 return Some((a_off, b_off));
173 }
174 }
175 }
176 None
177}
178
179#[cfg(test)]
180#[path = "slot_math_tests.rs"]
181mod tests;