spark_model/weight_loader/mod.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Weight loading traits and per-model loader implementations.
4//!
5//! Translates flat [`WeightStore`] into typed [`TransformerLayer`] objects.
6//! Each model architecture has its own [`ModelWeightLoader`] implementation
7//! that knows the HuggingFace weight name patterns.
8//!
9//! Submodules contain per-family loaders:
10//! - `qwen3`: Qwen3-Next (NVFP4, hybrid SSM+Attention+MoE)
11//! - `qwen35`: Qwen3.5 MoE (35B, 122B)
12//! - `qwen35_dense`: Qwen3.5 Dense (27B)
13//! - `qwen3_vl`: Qwen3-VL (vision-language)
14//! - `nemotron`: Nemotron-H (Mamba-2 + MoE + Attention)
15//! - `gemma4`: Gemma-4 (pure attention, GeGLU, sliding + full attention)
16
17pub(crate) mod deepseek_v4;
18pub mod dflash_loader;
19mod gemma4;
20/// GLM-5.3-Flash tensor accounting (Slice 1: classification only).
21pub mod glm5_next;
22mod laguna;
23mod longcat;
24mod minimax;
25mod nemotron;
26mod nllb;
27mod qwen3;
28mod qwen35;
29mod qwen35_dense;
30mod qwen3_vl;
31#[cfg_attr(test, allow(unreachable_pub))]
32pub(crate) mod qwen4_exp;
33mod step3p7;
34
35pub use deepseek_v4::DeepSeekV4WeightLoader;
36pub use dflash_loader::{
37 DflashConfig, DflashLayerWeights, DflashSubConfig, DflashWeights, load_dflash_weights,
38 store_has_dflash_weights,
39};
40pub mod glm5_next_load;
41mod glm5_next_mtp;
42pub use gemma4::Gemma4WeightLoader;
43pub use glm5_next_load::Glm5NextWeightLoader;
44pub(crate) use glm5_next_mtp::{Glm5NextMtpModule, load_glm5next_mtp_module};
45pub use laguna::LagunaWeightLoader;
46pub use longcat::LongcatWeightLoader;
47pub use minimax::MinimaxM2WeightLoader;
48pub use nemotron::NemotronHWeightLoader;
49pub use nllb::NllbWeightLoader;
50pub use qwen3::Qwen3WeightLoader;
51pub use qwen3_vl::Qwen3VLWeightLoader;
52pub use qwen4_exp::Qwen4ExpWeightLoader;
53pub use qwen35::Qwen35WeightLoader;
54pub use qwen35_dense::Qwen35DenseWeightLoader;
55/// The native-FP8 dense loader's derived-copy decision table and the shape
56/// arithmetic that prices it (#915).
57pub use qwen35_dense::fp8_residency;
58/// That table evaluated BEFORE the checkpoint loads, so preflight can size
59/// the SSM decode ring against the predicted post-load KV headroom (#915).
60pub use qwen35_dense::predicted_residency;
61pub use step3p7::Step3p7WeightLoader;
62
63use anyhow::Result;
64use atlas_core::config::ModelConfig;
65use spark_runtime::gpu::GpuBackend;
66use spark_runtime::kv_cache::KvCacheDtype;
67use spark_runtime::weights::WeightStore;
68
69use crate::layer::TransformerLayer;
70use crate::layers::VisionEncoder;
71use crate::weight_map::{DenseWeight, MtpWeights, Nvfp4Variant, detect_nvfp4_variant};
72
73/// Can this box hold the transposed `[K/2, N]` MoE prefill copies for EVERY
74/// layer, and does the operator want them?
75///
76/// The MoE prefill GEMMs read weights K-major; the checkpoint stores them
77/// N-major. Without the transposed copies prefill falls back to the plain
78/// `moe_w4a16_grouped_gemm` path, which on Qwen3-VL-30B measured **695 ms in
79/// `grouped_gate_up` + 351 ms in `grouped_silu_down`** — 59 % of a 1798 ms cold
80/// TTFT — versus 98.9 / 69.5 ms for the same phases on a model that does build
81/// them. So this is not a micro-optimization; skipping it is the slow path.
82///
83/// SSOT: the budget arithmetic used to live inline in `qwen3.rs` only, so every
84/// other MoE loader either hard-coded its own copy or (qwen3_vl, gemma4,
85/// step3p7) silently never transposed at all. One reader, one lever.
86///
87/// `ATLAS_MOE_PREFILL_COPIES=0` forces the fallback — an A/B lever and an
88/// escape hatch for a box under external memory pressure that the free-memory
89/// probe cannot see. Any other value (or unset) means "build them if they fit":
90/// PCND-wise the decision is *derived* from measured free memory, never a
91/// silent constant.
92pub(crate) fn moe_prefill_copies_fit(config: &ModelConfig, gpu: &dyn GpuBackend) -> bool {
93 if std::env::var("ATLAS_MOE_PREFILL_COPIES").ok().as_deref() == Some("0") {
94 tracing::info!("ATLAS_MOE_PREFILL_COPIES=0: MoE prefill uses the fallback grouped GEMM");
95 return false;
96 }
97 let inter = config.moe_intermediate_size;
98 let h = config.hidden_size;
99 // NVFP4 group_size — one ue4m3 scale per 16 elements, alongside the packed
100 // e2m1 pairs. Matches `shard_quantized_nvfp4`'s group_size for this family.
101 let group_size = 16usize;
102 let gu_bytes = inter * h / 2 + inter * h / group_size;
103 let d_bytes = h * inter / 2 + h * inter / group_size;
104 let per_layer = config.num_experts * (2 * gu_bytes + d_bytes);
105 let total = per_layer * config.num_hidden_layers;
106 let available = gpu.free_memory().unwrap_or(0);
107 let headroom = 2 * 1024 * 1024 * 1024;
108 let fits = total <= available.saturating_sub(headroom);
109 if !fits {
110 tracing::warn!(
111 "Skipping MoE weight transposition ({:.1} GB needed, {:.1} GB available). \
112 Prefill will use fallback grouped GEMM.",
113 total as f64 / (1024.0 * 1024.0 * 1024.0),
114 available as f64 / (1024.0 * 1024.0 * 1024.0),
115 );
116 }
117 fits
118}
119
120/// Runtime quantization format for weight dispatch.
121///
122/// Determines which GEMV/GEMM kernels are used for decode, prefill, and
123/// MTP verify. Adding a new quant format requires:
124/// 1. Add variant here
125/// 2. Add kernel dispatch in the layer forward paths
126/// 3. Add weight loading logic in load_moe_qwen35 / attention loader
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum QuantFormat {
129 /// NVFP4 E2M1 — default, highest throughput. Uses w4a16 kernels.
130 Nvfp4,
131 /// FP8 E4M3 block-scaled — native FP8 serving. Uses w8a16 kernels.
132 Fp8,
133 // Future: Int4, AWQ, GPTQ, etc.
134}
135
136impl QuantFormat {
137 /// Peak GPU memory multiplier for OOM pre-flight estimation.
138 ///
139 /// Accounts for model-building overhead on top of raw weight bytes:
140 /// - NVFP4: 1.3x (weight pointers aliased, transposed copies + predequant)
141 /// - FP8: 1.5x (zero-copy weights, transposed attention copies, FP8 pointer tables)
142 ///
143 /// Adding a new format: set the multiplier based on empirical peak/on-disk ratio.
144 pub fn peak_memory_multiplier(&self) -> f64 {
145 match self {
146 // NVFP4: weights are mmap'd (zero-copy), temporary buffers for
147 // runtime quantization (FP8→BF16→NVFP4) are freed after each layer.
148 // Empirical peak/on-disk ratio on GB10: ~1.15x.
149 Self::Nvfp4 => 1.15,
150 Self::Fp8 => 1.5,
151 }
152 }
153}
154
155/// Checkpoint weight format, detected from safetensors metadata.
156///
157/// Determines how raw weight bytes are interpreted and transformed into
158/// the runtime NVFP4 format used by Atlas GEMM kernels.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum WeightFormat {
161 /// NVFP4 E2M1 on disk (nvidia ModelOpt or compressed-tensors).
162 /// Weights load directly into `QuantizedWeight` with no conversion.
163 Nvfp4,
164 /// FP8 E4M3 block-scaled on disk (e.g. `quant_method: "fp8"` with `weight_block_size`).
165 /// Each weight tensor has a `weight_scale_inv` (BF16 per-block) companion.
166 /// At load time: FP8 -> BF16 -> NVFP4 (runtime quantization).
167 Fp8BlockScaled,
168 /// BF16 dense on disk (unquantized, e.g. attention Q/K/V in Standard NVFP4 models).
169 /// At load time: BF16 -> NVFP4 (runtime quantization).
170 Bf16Dense,
171}
172
173impl WeightFormat {
174 /// Detect the weight format from a [`WeightStore`] by probing key names.
175 pub fn detect(store: &WeightStore, config: &ModelConfig) -> Self {
176 match detect_nvfp4_variant(store, config) {
177 Nvfp4Variant::Fp8Dequanted => Self::Fp8BlockScaled,
178 Nvfp4Variant::CompressedTensors | Nvfp4Variant::Standard => Self::Nvfp4,
179 // Bf16Raw fine-tunes get runtime-quantized to NVFP4 inside the
180 // weight loader, so the downstream pipeline sees Nvfp4.
181 Nvfp4Variant::Bf16Raw => Self::Nvfp4,
182 }
183 }
184
185 /// Whether this format requires FP8 -> BF16 dequantization at load time.
186 pub fn is_fp8(&self) -> bool {
187 matches!(self, Self::Fp8BlockScaled)
188 }
189}
190
191/// Loads weights from a [`WeightStore`] into typed layer objects.
192pub trait ModelWeightLoader {
193 /// Whether this loader's weight slicing is TP-aware. **No default** —
194 /// every loader MUST declare this explicitly so adding a new model
195 /// architecture cannot accidentally inherit a `false` and silently
196 /// regress users who pass `--tp-size > 1`.
197 ///
198 /// Loaders that honour `config.tp_world_size` / `config.tp_rank` when
199 /// loading attention Q/K/V/O, MoE gate/up/down, head-parallel SSM
200 /// components, and lm_head return `true`. Loaders that always load
201 /// full replicated weights return `false`.
202 ///
203 /// The startup path in `spark-server/src/main.rs` consults this method
204 /// to fail-fast at load time when `--tp-size > 1` is requested against
205 /// a TP-unaware loader. Extending TP to a new architecture requires:
206 /// 1. Wire `slice_for_rank` (in `crate::tp_shard`) per Q/K/V/O,
207 /// gate/up/down, and any head-parallel SSM tensors.
208 /// 2. Divide `num_attention_heads` / `num_key_value_heads` per the
209 /// same axis when constructing layer state.
210 /// 3. Return `true` from this method.
211 ///
212 /// See `weight_loader/minimax.rs` for the reference implementation.
213 fn supports_tp(&self) -> bool;
214
215 /// Load all transformer layers from the weight store.
216 ///
217 /// `layer_kv_dtypes` is indexed by attention layer index (0-based sequential
218 /// counter over full-attention layers only). Each attention layer receives its
219 /// own KV cache dtype, enabling mixed-precision KV caching where boundary
220 /// layers use higher precision.
221 fn load_layers(
222 &self,
223 store: &WeightStore,
224 config: &ModelConfig,
225 gpu: &dyn GpuBackend,
226 layer_kv_dtypes: &[KvCacheDtype],
227 ) -> Result<Vec<Box<dyn TransformerLayer>>>;
228
229 /// Drop store tensors this loader has finished with, after every
230 /// `load_*` reader has run and before the buffer arena / KV cache are sized.
231 ///
232 /// Default: keep everything. That is correct for the loaders that bind
233 /// **zero-copy** from the store's device pointers — the store IS the model's
234 /// weights, and `TransformerModel` releases it at teardown.
235 ///
236 /// Override only when the loader uploads its own copies (a TP shard, a host
237 /// round-trip, a dtype conversion), because then the store's originals are
238 /// dead the moment the binder returns. On unified-memory GB10 that duplicate
239 /// comes straight out of the KV budget.
240 fn prune_after_load(
241 &self,
242 _store: &mut WeightStore,
243 _config: &ModelConfig,
244 _gpu: &dyn GpuBackend,
245 ) -> Result<()> {
246 Ok(())
247 }
248
249 /// Per-(layer, role) weight precision schedule (C.3, 2026-04-25).
250 /// Default impl returns the empty schedule (every lookup yields
251 /// `Dtype::Inherit`), preserving the existing per-checkpoint
252 /// dtype logic byte-for-byte. Loader-specific implementations
253 /// can override to honour MODEL.toml's `[precision]` block.
254 fn precision_schedule(
255 &self,
256 _config: &ModelConfig,
257 ) -> crate::precision_schedule::PrecisionSchedule {
258 crate::precision_schedule::PrecisionSchedule::default()
259 }
260
261 fn load_embedding(
262 &self,
263 store: &WeightStore,
264 config: &ModelConfig,
265 gpu: &dyn GpuBackend,
266 ) -> Result<DenseWeight>;
267
268 /// Build the n-gram embedding, when this architecture fuses hashed
269 /// n-gram lookups into the input embedding (LongCat / Qwen3.8-Flash-Next).
270 ///
271 /// Separate from `load_embedding` because the result is NOT a weight: it
272 /// is a small engine that needs the sequence's CONTEXT token ids at
273 /// forward time, not just the id being embedded. Returning `None` — the
274 /// default — leaves the plain `embed_tokens` gather in place.
275 fn load_ngram_embedding(
276 &self,
277 _store: &WeightStore,
278 _config: &ModelConfig,
279 _gpu: &dyn GpuBackend,
280 _max_tokens: usize,
281 ) -> Result<Option<crate::layers::ngram_embed::NgramEmbedding>> {
282 Ok(None)
283 }
284 /// Load the final RMSNorm weight used before the LM head.
285 ///
286 /// `gpu` is passed so model-specific loaders can do on-device weight
287 /// transforms at load time (e.g. Gemma-4 shifts the learned absolute-
288 /// scale weight by -1 into the offset-from-1 convention expected by
289 /// Atlas's rms_norm kernel). Loaders that don't need it should ignore
290 /// the argument.
291 fn load_final_norm(
292 &self,
293 store: &WeightStore,
294 config: &ModelConfig,
295 gpu: &dyn GpuBackend,
296 ) -> Result<DenseWeight>;
297 fn load_lm_head(
298 &self,
299 store: &WeightStore,
300 config: &ModelConfig,
301 gpu: &dyn GpuBackend,
302 ) -> Result<DenseWeight>;
303
304 /// Load MTP head weights (returns None if no MTP weights in store).
305 fn load_mtp_weights(
306 &self,
307 store: &WeightStore,
308 config: &ModelConfig,
309 gpu: &dyn GpuBackend,
310 ) -> Result<Option<MtpWeights>>;
311
312 /// Load MTP weights for multi-module MTP (DeepSeek-V3 / MiniMax-M2
313 /// style: N independent transformer modules, each with its own
314 /// attention + MoE + KV cache). Returns an empty `Vec` when the
315 /// checkpoint has no MTP modules, a 1-element Vec for single-module
316 /// MTP (Qwen3.5 family), or N elements for multi-module.
317 ///
318 /// Default impl adapts `load_mtp_weights` so existing single-module
319 /// loaders don't need to change. MiniMax overrides this directly.
320 fn load_mtp_weights_multi(
321 &self,
322 store: &WeightStore,
323 config: &ModelConfig,
324 gpu: &dyn GpuBackend,
325 ) -> Result<Vec<MtpWeights>> {
326 Ok(self
327 .load_mtp_weights(store, config, gpu)?
328 .into_iter()
329 .collect())
330 }
331
332 /// Per-layer (num_kv_heads, head_dim) overrides for heterogeneous
333 /// attention models (e.g. Gemma-4 with sliding 16×256 and full 4×512).
334 /// Default empty — homogeneous models skip per-layer dims and the KV
335 /// cache allocator uses the global (num_kv_heads, head_dim). Populated
336 /// by loaders whose models have different attention geometries per
337 /// layer. Indexed by attention layer index (same as layer_kv_dtypes).
338 fn kv_layer_dims(&self, _config: &ModelConfig) -> Vec<(usize, usize)> {
339 Vec::new()
340 }
341
342 /// Load DFlash drafter weights from a separate `WeightStore` pointing
343 /// at the drafter checkpoint (`z-lab/Qwen3.6-{27B,35B-A3B}-DFlash`).
344 /// Default impl returns `None` so loaders that don't yet support
345 /// DFlash silently fall through to the existing MTP path. Override in
346 /// loaders whose target models pair with a DFlash drafter (Qwen3.5/3.6
347 /// family). The same drafter format works across both 27B-dense and
348 /// 35B-A3B-MoE targets — only the `target_hidden_size` validated
349 /// against the drafter's `fc` input dimension differs.
350 fn load_dflash_weights(
351 &self,
352 _drafter_store: &WeightStore,
353 _config: &ModelConfig,
354 _gpu: &dyn GpuBackend,
355 _tp_size: usize,
356 ) -> Result<Option<DflashWeights>> {
357 Ok(None)
358 }
359
360 /// Load one or more startup-static PEFT LoRA adapters from their own
361 /// [`WeightStore`]s (the `adapter_model.safetensors` tensors, already
362 /// on-device BF16) into the fixed-address rank-padded pool (one slot each).
363 ///
364 /// Unlike `load_dflash_weights`' vestigial `Ok(None)` default, the
365 /// default here is a WORKING model-agnostic implementation (the remap
366 /// needs only `ModelConfig::layer_type` + projection dims); families
367 /// needing a bespoke key remap override it. Called from
368 /// `factory::build_model` BEFORE the buffer arena + KV sizing so the
369 /// pool bytes are budgeted against the KV cache. A single-element slice is
370 /// byte-identical to the pre-multi-adapter single-adapter path.
371 fn load_lora_adapters(
372 &self,
373 adapters: &[crate::lora::LoraAdapterInput<'_>],
374 config: &ModelConfig,
375 gpu: &dyn GpuBackend,
376 max_loras: usize,
377 max_lora_rank: usize,
378 ) -> Result<Option<crate::lora::LoraWeights>> {
379 crate::lora::load_lora_adapters_multi(adapters, config, gpu, max_loras, max_lora_rank)
380 .map(Some)
381 }
382
383 /// Will this loader ever bind a vision encoder for a multimodal checkpoint?
384 ///
385 /// Default `true` — "load everything" is the safe answer, so a loader that
386 /// forgets to override this can never lose weights it needs. A loader whose
387 /// port is deliberately text-only overrides it to `false`, and the weight
388 /// loader then skips the tower's tensors instead of reading a gigabyte of
389 /// unified memory that nothing will bind. `build_model` still frees an
390 /// unbound tower afterwards (keyed off the bind result, not off this), so
391 /// this is a peak-memory optimisation, not the correctness gate.
392 fn binds_vision_encoder(&self) -> bool {
393 true
394 }
395
396 /// Load vision encoder weights (returns None for text-only models).
397 fn load_vision_encoder(
398 &self,
399 _store: &WeightStore,
400 _config: &ModelConfig,
401 _gpu: &dyn GpuBackend,
402 ) -> Result<Option<VisionEncoder>> {
403 Ok(None)
404 }
405}