spark_model/lora/overlay.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Token overlay (Feature 2): PEFT `trainable_tokens` / `modules_to_save`
4//! vocab-extension and embed/lm_head row replacement, ported from the NLLB
5//! `token_adapter` overlay to the main decoder (`TransformerModel`) path.
6//!
7//! This module is the PURE, GPU-free half: on-disk tensor classification
8//! ([`classify_overlay_key`]), the collection struct the loader fills
9//! ([`OverlayTensors`]), and the row-selection math ([`clamp_trainable_to_vocab`],
10//! [`build_override_set`], [`override_source`]) that decides which vocab rows an
11//! adapter overrides and where each row's replacement bytes come from. The GPU
12//! materialization (row-diff kernel, compact-row copy, device tables) lives in
13//! [`super::overlay_build`]; the forward hooks live in `model/token_overlay.rs`.
14//!
15//! Three on-disk mechanisms feed one runtime overlay:
16//! - `trainable_tokens`: `…token_adapter.base_layer.weight [R,h]` bf16 +
17//! `…token_adapter.trainable_tokens_delta [T,h]` f32 (full row replacement,
18//! PEFT `index_copy` semantics: `E[idx[k]] = delta[k]`, NOT `base+delta`).
19//! - `modules_to_save[embed_tokens|lm_head]`: a full `…embed_tokens.weight`
20//! `[vocab,h]` replacement (same builder, no delta — the row-diff finds the
21//! changed rows).
22//! - `lora_embedding_A/B`: classic low-rank embed LoRA — Tier-2, classified
23//! here so the loader can NAME-reject it rather than mis-route it.
24
25use anyhow::{Result, bail};
26
27/// Rows whose max abs difference from the served embed table exceeds this are
28/// treated as "overridden" by a `modules_to_save`/baked base_layer. Clears
29/// bf16 rounding noise (matching rows land ≤0.05; a real differing row ≥1.3).
30pub const ROWDIFF_THRESH: f32 = 0.1;
31
32/// Which tied embedding an overlay tensor belongs to.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum OverlayModule {
35 /// Input embedding table (`embed_tokens` / NLLB `shared`).
36 EmbedTokens,
37 /// Output projection (`lm_head`). Distinct buffer when untied.
38 LmHead,
39}
40
41/// The role an overlay tensor plays in [`build_overlay`](super::overlay_build::build_overlay).
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum OverlayTensorKind {
44 /// `token_adapter.base_layer.weight` — the adapter's own `[R,h]` table.
45 Base,
46 /// `token_adapter.trainable_tokens_delta` — `[T,h]` replacement rows.
47 Delta,
48 /// `modules_to_save` full weight (`embed_tokens.weight` / `lm_head.weight`).
49 FullSave,
50 /// Classic low-rank embedding LoRA A factor (Tier-2, load-rejected).
51 LoraEmbedA,
52 /// Classic low-rank embedding LoRA B factor (Tier-2, load-rejected).
53 LoraEmbedB,
54}
55
56/// A classified overlay tensor: its target embedding and its role.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct OverlayTensor {
59 pub module: OverlayModule,
60 pub kind: OverlayTensorKind,
61}
62
63/// Classify a PEFT adapter tensor as a token-overlay tensor, or `None` if it is
64/// an ordinary `lora_A/lora_B` weight (which [`super::classify_key`] handles).
65///
66/// Prefix-agnostic beyond the PEFT wrapper, matching `classify_key`: it accepts
67/// both `…model.embed_tokens.…` and the multimodal `…model.language_model.
68/// embed_tokens.…` spellings. Suffix order matters — the `token_adapter` /
69/// `lora_embedding` forms are checked before the bare `.weight`
70/// (`modules_to_save`) form so `…token_adapter.base_layer.weight` is not
71/// mis-read as a full save.
72pub fn classify_overlay_key(key: &str) -> Option<OverlayTensor> {
73 let stripped = key.strip_prefix("base_model.model.")?;
74 // Ordinary LoRA weights are never overlays.
75 if stripped.ends_with(".lora_A.weight") || stripped.ends_with(".lora_B.weight") {
76 return None;
77 }
78 let module = overlay_module_of(stripped)?;
79 let kind = if stripped.ends_with(".token_adapter.base_layer.weight") {
80 OverlayTensorKind::Base
81 } else if stripped.ends_with(".token_adapter.trainable_tokens_delta") {
82 OverlayTensorKind::Delta
83 } else if stripped.ends_with(".lora_embedding_A") {
84 OverlayTensorKind::LoraEmbedA
85 } else if stripped.ends_with(".lora_embedding_B") {
86 OverlayTensorKind::LoraEmbedB
87 } else if is_full_module_weight(stripped, module) {
88 OverlayTensorKind::FullSave
89 } else {
90 return None;
91 };
92 Some(OverlayTensor { module, kind })
93}
94
95/// Identify the tied-embedding module a (prefix-stripped) key targets. `lm_head`
96/// wins over `embed_tokens`/`shared` because the two never co-occur in one key.
97fn overlay_module_of(stripped: &str) -> Option<OverlayModule> {
98 if stripped.contains("lm_head") {
99 Some(OverlayModule::LmHead)
100 } else if stripped.contains("embed_tokens") || stripped.contains(".shared.") {
101 Some(OverlayModule::EmbedTokens)
102 } else {
103 None
104 }
105}
106
107/// True when `stripped` is exactly the module's own top-level weight
108/// (`…embed_tokens.weight` / `lm_head.weight`) — a `modules_to_save` full
109/// replacement — and NOT a per-layer weight that merely mentions the module.
110fn is_full_module_weight(stripped: &str, module: OverlayModule) -> bool {
111 // A modules_to_save tensor sits above the layer stack.
112 if stripped.contains(".layers.") {
113 return false;
114 }
115 let leaf_owner = match module {
116 OverlayModule::EmbedTokens => "embed_tokens.weight",
117 OverlayModule::LmHead => "lm_head.weight",
118 };
119 stripped.ends_with(leaf_owner)
120}
121
122/// Tensor names the loader has collected for one adapter, partitioned by
123/// (module, role). `Option<String>` = the safetensors key, filled at most once.
124#[derive(Debug, Default, Clone)]
125pub struct OverlayTensors {
126 pub embed_base: Option<String>,
127 pub embed_delta: Option<String>,
128 pub embed_full: Option<String>,
129 pub lmhead_base: Option<String>,
130 pub lmhead_delta: Option<String>,
131 pub lmhead_full: Option<String>,
132 /// Any classic `lora_embedding_A/B` tensor seen (Tier-2 — load-rejected).
133 pub lora_embedding_seen: bool,
134}
135
136impl OverlayTensors {
137 /// Record one classified overlay tensor. Duplicate roles are a hard error
138 /// (never silently overwrite — an ambiguous adapter must fail loudly).
139 pub fn insert(&mut self, t: OverlayTensor, name: &str) -> Result<()> {
140 use OverlayModule::*;
141 use OverlayTensorKind::*;
142 let slot = match (t.module, t.kind) {
143 (_, LoraEmbedA) | (_, LoraEmbedB) => {
144 self.lora_embedding_seen = true;
145 return Ok(());
146 }
147 (EmbedTokens, Base) => &mut self.embed_base,
148 (EmbedTokens, Delta) => &mut self.embed_delta,
149 (EmbedTokens, FullSave) => &mut self.embed_full,
150 (LmHead, Base) => &mut self.lmhead_base,
151 (LmHead, Delta) => &mut self.lmhead_delta,
152 (LmHead, FullSave) => &mut self.lmhead_full,
153 };
154 if slot.is_some() {
155 bail!(
156 "REJECT[duplicate-overlay-tensor]: two tensors map to {:?}/{:?}",
157 t.module,
158 t.kind
159 );
160 }
161 *slot = Some(name.to_string());
162 Ok(())
163 }
164
165 /// Any overlay tensor at all was collected.
166 pub fn is_empty(&self) -> bool {
167 self.embed_base.is_none()
168 && self.embed_delta.is_none()
169 && self.embed_full.is_none()
170 && self.lmhead_base.is_none()
171 && self.lmhead_delta.is_none()
172 && self.lmhead_full.is_none()
173 && !self.lora_embedding_seen
174 }
175}
176
177/// Feature 2 load gate, called from the loader once overlay tensors are
178/// collected. The device-side overlay apply is now WIRED (Stage-1
179/// [`super::overlay_build::stage_overlay_raw`] upload → Stage-2
180/// [`super::overlay_build::build_overlay`] row-diff/compact → the
181/// `embed_tokens` / `lm_head` forward hooks in `crate::model::token_overlay`),
182/// so `trainable_tokens` / `modules_to_save` `{embed_tokens, lm_head}` tensors
183/// are LOADED rather than rejected.
184///
185/// The ONLY remaining reject here is the classic low-rank embedding LoRA
186/// (`lora_embedding_A/B`): a distinct Tier-2 mechanism with no kernel yet, named
187/// so the adapter fails loudly rather than being silently mis-applied.
188pub fn reject_pending_overlay(overlay: &OverlayTensors) -> Result<()> {
189 if overlay.lora_embedding_seen {
190 bail!(
191 "REJECT[lora-embedding-unimplemented]: classic low-rank embedding LoRA \
192 (lora_embedding_A/B) is not yet supported on the decoder path"
193 );
194 }
195 Ok(())
196}
197
198/// Clamp `trainable` ids to the served vocab, preserving list order (the delta
199/// tensor's rows align positionally to it).
200///
201/// Returns `(kept_ids, skipped_extension_count)`:
202/// - `idx >= r` → hard error (id outside the adapter's own `[R,h]` embedding).
203/// - `vocab <= idx < r` → vocab-extension token the served tokenizer can't emit;
204/// dropped and counted (caller warns).
205/// - `idx < vocab` → kept, but a kept id smaller than a previously-kept id is a
206/// hard error: PEFT appends extension tokens as the largest indices with delta
207/// rows in the same order, so the kept prefix must stay positionally aligned to
208/// the delta rows after the extension tail is dropped.
209pub fn clamp_trainable_to_vocab(
210 trainable: &[u32],
211 r: usize,
212 vocab: usize,
213) -> Result<(Vec<u32>, usize)> {
214 let mut kept = Vec::new();
215 let mut skipped = 0usize;
216 let mut last_kept: Option<u32> = None;
217 for &idx in trainable {
218 let i = idx as usize;
219 if i >= r {
220 bail!("REJECT[trainable-index-out-of-adapter]: id {idx} >= adapter embedding rows {r}");
221 }
222 if i >= vocab {
223 skipped += 1;
224 continue;
225 }
226 if skipped > 0 {
227 bail!(
228 "REJECT[trainable-order]: served-vocab id {idx} appears after a skipped \
229 vocab-extension id; extension ids must form one trailing suffix"
230 );
231 }
232 if let Some(prev) = last_kept
233 && idx <= prev
234 {
235 bail!(
236 "REJECT[trainable-order]: kept id {idx} follows id {prev}; \
237 PEFT trainable-token order must be strictly ascending in the served-vocab prefix"
238 );
239 }
240 last_kept = Some(idx);
241 kept.push(idx);
242 }
243 Ok((kept, skipped))
244}
245
246/// Union of (rows that differ from the served base) and (trainable ids), sorted
247/// ascending and deduped. This is the final set of vocab rows the overlay
248/// replaces. `row_diff[i]` = row `i` of the adapter base differs from served.
249pub fn build_override_set(row_diff: &[bool], trainable: &[u32]) -> Vec<u32> {
250 let mut ids: Vec<u32> = row_diff
251 .iter()
252 .enumerate()
253 .filter_map(|(i, &d)| d.then_some(i as u32))
254 .collect();
255 ids.extend_from_slice(trainable);
256 ids.sort_unstable();
257 ids.dedup();
258 ids
259}
260
261/// Where an overridden id's replacement row comes from: `Some(k)` = trainable
262/// delta row `k` (delta WINS when an id is both trainable and baked-different);
263/// `None` = the adapter's baked `base_layer[id]`.
264pub fn override_source(id: u32, trainable: &[u32]) -> Option<usize> {
265 trainable.iter().position(|&t| t == id)
266}
267
268#[cfg(test)]
269#[path = "overlay_tests.rs"]
270mod tests;