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}