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