spark_model/lora/target.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Feature-1 (MoE expert + router LoRA) target surface.
4//!
5//! [`classify_key`](super::classify_key) historically decoded a PEFT tensor
6//! name to `(layer, LoraModule, A|B)` where [`LoraModule`] is the flat 7-variant
7//! dense-projection enum (q/k/v/o + gate/up/down). MoE routed-expert and router
8//! deltas need a third dimension the dense enum cannot carry: the EXPERT INDEX
9//! (`mlp.experts.{N}.{gate,up,down}_proj`) and the router (`mlp.gate`, distinct
10//! from the dense `mlp.gate_proj`). [`LoraTarget`] wraps the dense enum and adds
11//! those two variants so the classifier return keeps `LoraModule` intact for the
12//! attention/dense path (zero blast-radius on the S-LoRA BGMV route tables) while
13//! routing expert/router deltas into their own sparse per-layer storage.
14//!
15//! CORRECTNESS-FIRST (Feature-1 phase 1): the internal representation is always
16//! PER-EXPERT ([`ExpertLoraLayer`], a sparse `BTreeMap<(expert, proj), LoraPair>`),
17//! applied via the existing `apply_lora_delta` fold (no new CUDA kernel). Fused
18//! on-disk import (`target_parameters` / fused `experts.gate_up_proj`) and the
19//! grouped-BGMV / fused-epilogue kernels are explicit follow-ups (phases 2/3).
20
21use std::collections::BTreeMap;
22
23use atlas_core::config::ModelConfig;
24
25use crate::layers::ops::lora_delta::LoraPair;
26
27use super::LoraModule;
28
29/// Which routed-expert projection a delta targets. Ordered so
30/// `BTreeMap<(u16, ExpertProj), _>` has a stable, testable key order.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub enum ExpertProj {
33 Gate,
34 Up,
35 Down,
36}
37
38impl ExpertProj {
39 /// PEFT suffix (the leaf `.`-segment of the on-disk tensor name).
40 pub fn peft_name(&self) -> &'static str {
41 match self {
42 Self::Gate => "gate_proj",
43 Self::Up => "up_proj",
44 Self::Down => "down_proj",
45 }
46 }
47
48 /// (out_dim, in_dim) of the base routed-expert projection on `layer`.
49 ///
50 /// Uses the PER-LAYER routed intermediate ([`ModelConfig::moe_intermediate_size_for`],
51 /// e.g. 512 on Holo-3.1-35B-A3B) — NEVER the dense `intermediate_size`
52 /// (5120), which belongs to the standalone SwiGLU FFN. gate/up map hidden→
53 /// inter, down maps inter→hidden.
54 pub fn dims(&self, cfg: &ModelConfig, layer: usize) -> (usize, usize) {
55 let h = cfg.hidden_size;
56 let inter = cfg.moe_intermediate_size_for(layer);
57 match self {
58 Self::Gate | Self::Up => (inter, h),
59 Self::Down => (h, inter),
60 }
61 }
62}
63
64/// (out_dim, in_dim) of the base router (`mlp.gate`) projection: `[num_experts,
65/// hidden]`. A router LoRA perturbs the pre-selection routing logits.
66pub fn router_dims(cfg: &ModelConfig) -> (usize, usize) {
67 (cfg.num_experts, cfg.hidden_size)
68}
69
70/// The decoded target of one PEFT LoRA tensor. `Attn` keeps the existing dense
71/// [`LoraModule`] path byte-identical; `Router` / `Expert` are the Feature-1
72/// additions routed into per-layer [`ExpertLoraLayer`] / router storage.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum LoraTarget {
75 /// One of the 7 dense projections (q/k/v/o/gate/up/down_proj).
76 Attn(LoraModule),
77 /// The MoE router `mlp.gate` (`[num_experts, hidden]`).
78 Router,
79 /// One routed expert's projection (`mlp.experts.{n}.{proj}`).
80 Expert { n: u16, proj: ExpertProj },
81}
82
83/// One MoE layer's routed-expert LoRA coverage: a SPARSE map keyed by
84/// `(expert_index, projection)`. Real adapters adapt a subset of the (up to 512)
85/// experts, so a dense `Vec<[LoraPair; 3]>` sized `num_experts` would waste
86/// storage and force a 512-wide static walk; the map only holds the packed pairs.
87/// `LoraPair` is `Copy`, so the whole struct is cheap to `Clone` on install.
88#[derive(Clone, Default)]
89pub struct ExpertLoraLayer {
90 pub pairs: BTreeMap<(u16, ExpertProj), LoraPair>,
91}
92
93impl ExpertLoraLayer {
94 /// This layer's `(expert, proj)` pair, if adapted.
95 pub fn pair(&self, expert: u16, proj: ExpertProj) -> Option<&LoraPair> {
96 self.pairs.get(&(expert, proj))
97 }
98
99 /// Sorted, deduped list of the expert indices this layer adapts — the
100 /// SSOT for "which experts get a delta applied" in the forward side-path.
101 pub fn adapted_experts(&self) -> Vec<u16> {
102 let mut v: Vec<u16> = self.pairs.keys().map(|(e, _)| *e).collect();
103 v.dedup();
104 v
105 }
106
107 pub fn is_empty(&self) -> bool {
108 self.pairs.is_empty()
109 }
110}
111
112/// Pure padded-byte estimator for the (separate) expert/router pool, used by the
113/// VRAM preflight and pinned by a golden unit test — mirrors
114/// `super::pool_slot_bytes` but over the audited routed-expert + router key
115/// set (real adapters target a SUBSET, so this is sized from the audit, never
116/// from `num_experts × num_layers` maxima). Per (layer, expert, proj) and per
117/// router layer: `(stride·in + out·stride)·2` BF16 bytes, where `stride` is
118/// the DERIVED uint4-aligned `expert_pack::packed_stride` of
119/// `max_rank` — the same derivation the pack loop uses (SSOT), so sizing and
120/// packing agree byte-for-byte even at a non-multiple-of-8 rank cap.
121pub fn expert_router_bytes(
122 cfg: &ModelConfig,
123 expert_keys: &[(usize, ExpertProj)],
124 router_layers: &[usize],
125 max_rank: usize,
126) -> usize {
127 let stride = super::expert_pack::packed_stride(max_rank);
128 let per = |out: usize, inp: usize| (stride * inp + out * stride) * 2;
129 let experts: usize = expert_keys
130 .iter()
131 .map(|(layer, proj)| {
132 let (out, inp) = proj.dims(cfg, *layer);
133 per(out, inp)
134 })
135 .sum();
136 let routers: usize = router_layers
137 .iter()
138 .map(|_| {
139 let (out, inp) = router_dims(cfg);
140 per(out, inp)
141 })
142 .sum();
143 experts + routers
144}
145
146#[cfg(test)]
147#[path = "target_tests.rs"]
148mod tests;