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}