spark_model/lora/env.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! LoRA env/config leaves: the `$ATLAS_LORA_*` runtime hatches (eager / rotate /
4//! peer), the full-attention layer enumerator, and the build-time
5//! `validate_peft_config` gate. These sit on the model-integration side of the
6//! eventual `lora-core` carve. Split out of the former monolithic `lora/mod.rs`
7//! (SDD seam: ENV/CONFIG) — visibility unchanged.
8
9use anyhow::{Result, bail};
10use atlas_core::config::{LayerType, ModelConfig, PeftAdapterConfig};
11
12use super::LoraModule;
13
14/// Permanent LoRA debugging hatch: `ATLAS_LORA_EAGER=1` (or `true`) forces
15/// eager decode (no CUDA-graph capture) when an adapter is active, so
16/// graph-vs-eager output parity can be compared in the field. Read ONCE —
17/// the decode graph gate runs per token.
18/// Reads the process-wide `ModelLevers`, which resolve from the environment
19/// exactly once. This used to say the value was "resolved at the point of use
20/// rather than cached … a getenv is free" — but `from_env` reads ~30 variables
21/// per call, and a sibling caller was invoking it once per layer per prefill.
22pub fn lora_eager_env() -> bool {
23 crate::layers::ops::ModelLevers::get().lora_eager
24}
25
26/// `ATLAS_LORA_ROTATE=1` (or `true`) ARMS runtime adapter rotation: it forces
27/// eager decode (no CUDA-graph capture) so a `set_active_lora` re-point is
28/// immediately live (eager-on-rotate — the graph would otherwise replay the
29/// previously-captured slot pointers). A pool with >1 resident adapter arms
30/// this automatically (see `TransformerModel::lora_rotatable`), so this env is
31/// only needed to arm rotation on a SINGLE resident adapter (e.g. RDMA
32/// slot-swap-in-place). Unset + a single startup adapter = today's behaviour
33/// exactly (graphs ON, slot-0 pointers baked).
34/// See [`lora_eager_env`]: this reads the once-resolved process levers.
35pub fn lora_rotate_env() -> bool {
36 crate::layers::ops::ModelLevers::get().lora_rotate
37}
38
39/// `$ATLAS_LORA_PEER` (host:port of an `atlas-weight-peer` staging a rotation
40/// set) — when set, arms rotation (eager decode) even for a single resident
41/// slot, because an RDMA swap re-points that slot in place. Unset = disk path
42/// only, byte-identical to today.
43pub fn lora_peer_env() -> Option<String> {
44 std::env::var("ATLAS_LORA_PEER")
45 .ok()
46 .filter(|s| !s.is_empty())
47}
48
49/// Feature-1 (MoE expert + router LoRA) master switch. `ATLAS_LORA_EXPERTS=1`
50/// (or `true`) opts INTO loading + applying routed-expert / router deltas.
51/// DEFAULT OFF: an adapter that targets `mlp.experts.*` / `mlp.gate` is a NAMED
52/// reject at load unless this is set, so the base path stays byte-identical and
53/// the (correctness-first, host-synced, non-graphable) expert side-path is never
54/// silently on. Read once.
55pub fn lora_experts_env() -> bool {
56 static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
57 *V.get_or_init(|| {
58 std::env::var("ATLAS_LORA_EXPERTS")
59 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
60 })
61}
62
63/// Feature-1 padded expert/router LoRA rank cap (`ATLAS_LORA_EXPERT_RANK`,
64/// default 16). Separate from `--max-lora-rank` (the attention pool) because the
65/// per-(layer,expert,proj) pool grows ~`num_experts × num_layers` faster, so a
66/// low cap bounds the expert-pool VRAM blow-up. An adapter with `r` above this
67/// is a named reject.
68pub fn max_lora_expert_rank() -> usize {
69 static V: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
70 *V.get_or_init(|| {
71 std::env::var("ATLAS_LORA_EXPERT_RANK")
72 .ok()
73 .and_then(|v| v.parse().ok())
74 .filter(|&r: &usize| r > 0)
75 .unwrap_or(16)
76 })
77}
78
79/// `ATLAS_LORA_PREFILL_BGMV=1` — force prefill LoRA through the per-row BGMV
80/// instead of the tensor-core GEMM.
81///
82/// Default OFF because the GEMM is ~4.8x faster on a 2K prompt (841 vs 176
83/// tok/s measured on qwen3.8-27B) and the prefill call site is uniform-slot by
84/// construction. The BGMV is the only form that can honour per-row slots
85/// (including base rows), so this exists for the day a prefill batches rows
86/// from different sequences — and as the bisect handle if the GEMM path is
87/// ever suspected of a numerics difference, since the two are NOT bit-identical
88/// (GEMV-per-row vs one GEMM).
89pub fn prefill_bgmv_forced() -> bool {
90 static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
91 *V.get_or_init(|| std::env::var("ATLAS_LORA_PREFILL_BGMV").as_deref() == Ok("1"))
92}
93
94/// `ATLAS_LORA_NO_BATCH_VERIFY=1` — restore the old refusal of cross-sequence
95/// batched speculative verify while a LoRA adapter is resident.
96///
97/// Default OFF: the batched path applies the deltas on every op it batches,
98/// and all rows share one adapter (mixed batches are refused upstream). The
99/// refusal used to be unconditional and undocumented, and it flattened DFlash
100/// throughput to ~34 tok/s at every concurrency. This is the bisect handle if
101/// a batched-verify numerics difference is ever suspected under an adapter.
102pub fn no_batch_verify() -> bool {
103 static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
104 *V.get_or_init(|| std::env::var("ATLAS_LORA_NO_BATCH_VERIFY").as_deref() == Ok("1"))
105}
106
107pub fn full_attention_layers(cfg: &ModelConfig) -> Vec<usize> {
108 (0..cfg.num_hidden_layers)
109 .filter(|&i| cfg.layer_type(i) == LayerType::FullAttention)
110 .collect()
111}
112
113/// Adapter-config gates that need build-time context (`--max-lora-rank`).
114/// Parse-time gates (peft_type/DoRA/bias/regex target_modules/…) already
115/// ran in `atlas_core::config::parse_peft_adapter_config`.
116pub fn validate_peft_config(peft: &PeftAdapterConfig, max_lora_rank: usize) -> Result<()> {
117 if peft.r > max_lora_rank {
118 bail!(
119 "REJECT[rank-exceeds-pool]: r={} > --max-lora-rank={}",
120 peft.r,
121 max_lora_rank
122 );
123 }
124 let mut unsupported: Vec<&str> = Vec::new();
125 for t in &peft.target_modules {
126 let last = t.rsplit('.').next().unwrap_or(t);
127 // `gate` is the MoE router (Feature-1), distinct from `gate_proj`. Expert
128 // projections reuse the dense leaves (gate_proj/up_proj/down_proj), so
129 // the LoraModule allow-list already covers them.
130 let ok = last == "gate" || LoraModule::ALL.iter().any(|m| m.peft_name() == last);
131 if !ok {
132 unsupported.push(t.as_str());
133 }
134 }
135 if !unsupported.is_empty() {
136 if !allow_partial_targets() {
137 bail!(
138 "REJECT[unsupported-target]: target_modules {unsupported:?} \
139 (allowed: q_proj k_proj v_proj o_proj gate_proj up_proj down_proj gate). \
140 Set ATLAS_LORA_ALLOW_PARTIAL=1 to load anyway, applying only the \
141 supported modules — the adapter will then be PARTIALLY applied and \
142 will not reproduce its training behaviour."
143 );
144 }
145 // Opt-in partial load. Loud and once per adapter: a silently partial
146 // adapter reads as "the model is behaving oddly", which is a far worse
147 // debugging experience than a refused load. Real hybrid-model adapters
148 // hit this constantly — Qwen3.8-27B community LoRAs target `out_proj`
149 // (the SSM/GDN output projection, 48 of its 64 layers), which has no
150 // LoraModule variant and no wiring in the SSM layers.
151 tracing::warn!(
152 "LoRA PARTIAL LOAD (ATLAS_LORA_ALLOW_PARTIAL=1): target_modules \
153 {unsupported:?} are NOT supported and will be SKIPPED. Their \
154 trained deltas will not be applied; output will differ from the \
155 adapter's intent. Supported: q_proj k_proj v_proj o_proj \
156 gate_proj up_proj down_proj gate."
157 );
158 }
159 Ok(())
160}
161
162/// `ATLAS_LORA_ALLOW_PARTIAL=1` — load an adapter that names target modules
163/// Atlas cannot apply, skipping those and applying the rest.
164///
165/// Delegates to the atlas-core definition rather than re-reading the env:
166/// the parse-time allow-list down there is the FIRST gate an adapter meets,
167/// so the flag has to be defined below this layer. Two OnceLocks reading one
168/// variable is exactly the hand-synced drift this repo keeps getting bitten
169/// by (cf. the 384-vs-3072 thinking-budget bug).
170pub use atlas_core::config::allow_partial_targets;