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