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}