spark_model/weight_map/nvfp4_detect.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Auto-extracted from `weight_map.rs` during refactor wave 4a.
4
5#![allow(unused_imports)]
6
7use anyhow::{Context, Result, bail, ensure};
8use spark_runtime::gpu::{DevicePtr, GpuBackend};
9use spark_runtime::weights::{WeightDtype, WeightStore};
10
11use super::*;
12
13/// Step (1) of [`detect_nvfp4_variant`]: the variant the CONFIG declares,
14/// asked WITHOUT a [`WeightStore`].
15///
16/// Factored out (#915, 2026-09-11) so a caller that has only `config.json` —
17/// the pre-load residency prediction in
18/// `weight_loader::predicted_residency`, which runs before the store
19/// exists — asks exactly the question the loader will later answer instead
20/// of keeping a second copy of this precedence. `None` means "the config
21/// does not say"; it is NEVER a guess, and the sniffing half of
22/// [`detect_nvfp4_variant`] is what resolves it once the store is loaded.
23pub fn config_declared_variant(config: &atlas_core::config::ModelConfig) -> Option<Nvfp4Variant> {
24 let qc = config.quantization_config.as_ref()?;
25 match qc.quant_method.as_str() {
26 "modelopt" if qc.quant_algo.eq_ignore_ascii_case("NVFP4") => Some(Nvfp4Variant::Standard),
27 "modelopt" if qc.quant_algo.eq_ignore_ascii_case("FP8") => Some(Nvfp4Variant::Fp8Dequanted),
28 "compressed-tensors" => {
29 // `format` is the sub-selector here. Block-scaled FP8 is tagged
30 // either with a literal "fp8" OR with compressed-tensors'
31 // `"float-quantized"` (8-bit float = FP8 E4M3, e.g.
32 // Hcompany/Holo-3.1-*-FP8); the rest ("nvfp4-pack-quantized",
33 // "pack-quantized") are NVFP4.
34 let fmt = qc.format.to_ascii_lowercase();
35 if fmt.contains("fp8") || fmt.contains("float-quant") {
36 Some(Nvfp4Variant::Fp8Dequanted)
37 } else {
38 Some(Nvfp4Variant::CompressedTensors)
39 }
40 }
41 "fp8" => Some(Nvfp4Variant::Fp8Dequanted),
42 // Unknown method with non-empty ignore list — the caller falls
43 // through to heuristic detection. A warning was already emitted by
44 // `quant_format::detect_quant_format`.
45 _ => None,
46 }
47}
48
49/// Detect the weight quantization variant from the weight store.
50///
51/// Dispatch order matches vLLM / TRT-LLM / SGLang:
52/// 1. **Config-declared scheme** (`config.quantization_config.quant_method`)
53/// wins outright. This is the authoritative signal and the only one
54/// that correctly handles checkpoints with an `ignore` list (e.g.
55/// `lukealonso/MiniMax-M2.7-NVFP4`, whose MLP `gate_proj` is
56/// intentionally unquantized and therefore has no `.weight_scale`
57/// tensor — sniffing would mis-detect the whole checkpoint as
58/// `Bf16Raw` and then read uint8-packed FP4 as BF16, which is the
59/// 4× byte overrun that surfaces as `CUDA_ERROR_ILLEGAL_ADDRESS`
60/// ten seconds into load).
61/// 2. **Tensor-name sniffing** for the many checkpoints in the wild
62/// that ship without a `quantization_config` block.
63pub fn detect_nvfp4_variant(
64 store: &WeightStore,
65 config: &atlas_core::config::ModelConfig,
66) -> Nvfp4Variant {
67 // (1) Config-first dispatch. See module docs on `quant_format` for
68 // the full rationale — this is the fix for the Discord 2026-04-17
69 // `CUDA_ERROR_ILLEGAL_ADDRESS` bug. `None` = the config does not declare
70 // one (or declares a method this engine does not know), which is the only
71 // case that falls through to sniffing.
72 if let Some(declared) = config_declared_variant(config) {
73 return declared;
74 }
75
76 let lp = config.layer_prefix(0);
77
78 // Check MoE expert key first (most models are MoE).
79 let local_expert = config.local_expert_range().0;
80 let moe_sehyo_key = format!("{lp}.mlp.experts.{local_expert}.gate_proj.weight_packed");
81 if store.contains(&moe_sehyo_key) {
82 return Nvfp4Variant::CompressedTensors;
83 }
84
85 // Check dense FFN key (non-MoE models like Qwen3.5-27B).
86 let dense_sehyo_key = format!("{lp}.mlp.gate_proj.weight_packed");
87 if store.contains(&dense_sehyo_key) {
88 return Nvfp4Variant::CompressedTensors;
89 }
90
91 // Mistral uses "layers.{i}.experts.{e}.w1" naming (no "model." prefix, no ".mlp.").
92 let mistral_key = format!("layers.0.experts.{local_expert}.w1.weight_packed");
93 if store.contains(&mistral_key) {
94 return Nvfp4Variant::CompressedTensors;
95 }
96
97 // Fallback: scan any tensor name for `.weight_packed` suffix.
98 // Catches compressed-tensors checkpoints with unexpected naming conventions.
99 if store.names().any(|k| k.ends_with(".weight_packed")) {
100 return Nvfp4Variant::CompressedTensors;
101 }
102
103 // Check for FP8 block-scaled weights (e.g. Qwen/Qwen3.5-35B-A3B-FP8):
104 // FP8 models have `weight_scale_inv` alongside FP8E4M3 weights.
105 //
106 // Two SPELLINGS of the same layer, not two layers. The second entry used to
107 // be indexed by `local_expert_range().0` — an EXPERT index used as a LAYER
108 // index. `local_expert_range` returns global expert ids (see its sibling
109 // `is_local_expert`), so on a single node or EP rank 0 it is 0 and layer 0
110 // is probed by accident; on rank >= 1 it is `ep_rank * num_experts /
111 // ep_world_size` and each rank probes a DIFFERENT layer. With 256 experts
112 // over 2 ranks, rank 1 probed layer 39 — a full-attention layer whose FP8
113 // `q_proj` trips the attention sniff below — so two ranks loading one
114 // checkpoint could disagree about its quantisation variant.
115 //
116 // Detection must not depend on EP rank: every rank sees the same file.
117 const ALT_LAYER0_PREFIX: &str = "model.language_model.layers.0";
118 let prefixes_to_check = [lp.clone(), ALT_LAYER0_PREFIX.to_string()];
119 for pfx in &prefixes_to_check {
120 let fp8_key = format!("{pfx}.mlp.experts.{local_expert}.gate_proj.weight_scale_inv");
121 if store.contains(&fp8_key) {
122 return Nvfp4Variant::Fp8Dequanted;
123 }
124 let fp8_dense_key = format!("{pfx}.mlp.gate_proj.weight_scale_inv");
125 if store.contains(&fp8_dense_key) {
126 return Nvfp4Variant::Fp8Dequanted;
127 }
128 let fp8_attn_key = format!("{pfx}.self_attn.q_proj.weight_scale_inv");
129 if store.contains(&fp8_attn_key) {
130 return Nvfp4Variant::Fp8Dequanted;
131 }
132 // compressed-tensors `float-quantized` FP8 (e.g. Hcompany/Holo-3.1-*-FP8)
133 // ships block-FP8 as an FP8E4M3 `.weight` + 2D `.weight_scale` — NO
134 // `.weight_packed` (that's NVFP4) and NO `.weight_scale_inv` (that's
135 // DeepSeek/Qwen-native FP8). The `.weight_scale` name alias-collides
136 // with compressed-tensors NVFP4, so the `.weight_scale` checks below
137 // would misroute it to an NVFP4 variant. Disambiguate by the
138 // unambiguous FP8E4M3 weight dtype: an FP8E4M3 projection weight is
139 // always block-FP8 (Fp8Dequanted; the FP8→BF16→NVFP4 requant path in
140 // `quantized_from_fp8` reads the 2D `.weight_scale`).
141 for key in [
142 format!("{pfx}.mlp.experts.{local_expert}.gate_proj.weight"),
143 format!("{pfx}.mlp.gate_proj.weight"),
144 format!("{pfx}.self_attn.q_proj.weight"),
145 ] {
146 if store
147 .get(&key)
148 .map(|w| w.dtype == WeightDtype::FP8E4M3)
149 .unwrap_or(false)
150 {
151 return Nvfp4Variant::Fp8Dequanted;
152 }
153 }
154 }
155 // Fallback: scan any tensor name for `.weight_scale_inv` suffix.
156 // Catches FP8 checkpoints where the layer prefix hasn't been resolved yet.
157 if store.names().any(|k| k.ends_with(".weight_scale_inv")) {
158 return Nvfp4Variant::Fp8Dequanted;
159 }
160
161 // BF16/FP16 fine-tune detection: no quantization markers at all.
162 // If even `.weight_scale` is absent (i.e., not a Standard NVFP4 model
163 // either), fall through to runtime quantization from raw BF16/FP16.
164 // Catches third-party fine-tunes like samuelcardillo/Carnice-MoE-35B-A3B
165 // that ship only `.weight` tensors with no per-channel scales.
166 let any_standard_scale = store.names().any(|k| k.ends_with(".weight_scale"));
167 if !any_standard_scale {
168 tracing::warn!(
169 "No NVFP4/FP8 quantization metadata found (no .weight_packed / .weight_scale_inv / .weight_scale). \
170 Falling back to runtime BF16→NVFP4 quantization. Quality will be inferior to a calibrated NVFP4 release."
171 );
172 return Nvfp4Variant::Bf16Raw;
173 }
174
175 // Partial-NVFP4 guard: some upstream checkpoints (notably google/gemma-4-26B-A4B-it)
176 // ship `.weight_scale` on KV-cache scale tensors but NOT on the MLP/MoE
177 // projections Atlas actually consumes. If we claim Standard here the
178 // loader will then fail with a cryptic `Weight '...mlp.gate_proj.weight_scale'
179 // not found in store` half-way through load (logged against #bugs 2026-04-15
180 // by kiiv6565). Sniff the canonical L0 MLP gate_proj — if its `.weight_scale`
181 // is missing, the right answer is BF16 runtime quantization, not Standard.
182 let has_mlp_scale = {
183 let k_dense = format!("{lp}.mlp.gate_proj.weight_scale");
184 let k_moe = format!("{lp}.mlp.experts.{local_expert}.gate_proj.weight_scale");
185 store.contains(&k_dense) || store.contains(&k_moe)
186 };
187 if !has_mlp_scale {
188 tracing::warn!(
189 "Partial NVFP4 metadata: `.weight_scale` exists for some tensors (e.g. KV scales) \
190 but not for MLP/MoE projections. Falling back to runtime BF16→NVFP4 quantization. \
191 For best quality use a fully-quantized NVFP4 release (e.g. Sehyo/*-NVFP4)."
192 );
193 return Nvfp4Variant::Bf16Raw;
194 }
195
196 Nvfp4Variant::Standard
197}
198
199/// Load a quantized weight using the appropriate naming convention.
200///
201/// For `Fp8Dequanted`, requires `quant_ctx` (absmax_k, quantize_k, stream)
202/// to runtime-quantize the dequanted BF16 to NVFP4.
203pub(crate) fn quantized_auto(
204 store: &WeightStore,
205 prefix: &str,
206 gpu: &dyn GpuBackend,
207 variant: Nvfp4Variant,
208) -> Result<QuantizedWeight> {
209 match variant {
210 Nvfp4Variant::Standard => quantized(store, prefix, gpu),
211 Nvfp4Variant::CompressedTensors => quantized_v2(store, prefix, gpu),
212 Nvfp4Variant::Fp8Dequanted => {
213 unreachable!("Fp8Dequanted must use quantized_auto_fp8 with quant context")
214 }
215 Nvfp4Variant::Bf16Raw => {
216 unreachable!("Bf16Raw must use quantized_any with quant context")
217 }
218 }
219}
220
221/// Quantize context for FP8→BF16→NVFP4 runtime conversion.
222#[derive(Clone, Copy)]
223pub(crate) struct QuantizeCtx {
224 pub absmax_k: spark_runtime::gpu::KernelHandle,
225 pub quantize_k: spark_runtime::gpu::KernelHandle,
226 pub stream: u64,
227}
228
229/// Load a quantized weight, dispatching by variant. Handles all three on-disk formats
230/// including FP8 block-scaled (requires dimensions for FP8→BF16→NVFP4 conversion).
231pub(crate) fn quantized_any(
232 store: &WeightStore,
233 prefix: &str,
234 n: usize,
235 k: usize,
236 gpu: &dyn GpuBackend,
237 variant: Nvfp4Variant,
238 qctx: QuantizeCtx,
239) -> Result<QuantizedWeight> {
240 let _t_detect = std::time::Instant::now();
241 // Per-key fallback (B8 #bugs RedHatAI/Qwen3-Coder-Next-NVFP4): some
242 // models that are CompressedTensors overall keep certain projections
243 // (e.g. `linear_attn.out_proj`) as raw BF16 with no quantization
244 // metadata. Detect that case here and runtime-quantize, instead of
245 // failing the whole load with "weight_global_scale not found".
246 let has_packed = store.contains(&format!("{prefix}.weight_packed"));
247 let has_scale = store.contains(&format!("{prefix}.weight_scale"));
248 let has_scale_inv = store.contains(&format!("{prefix}.weight_scale_inv"));
249 let has_only_dense =
250 !has_packed && !has_scale && !has_scale_inv && store.contains(&format!("{prefix}.weight"));
251
252 // Per-key fallback #2 (unsloth/Qwen3.6-{27B,35B-A3B}-NVFP4, re-quantized
253 // 2026-07-10): mixed-precision checkpoints that are NVFP4 for most of the
254 // net but leave a tail of layers — and, in the MoE, the shared experts —
255 // as FP8 E4M3 with a per-row `weight_scale` ([N,1] BF16). Those keys carry
256 // no NVFP4 metadata at all (no `weight_packed`, no `weight_global_scale`,
257 // no `weight_scale_2`), so the declared NVFP4 variant cannot load them and
258 // the whole model dies on `weight_global_scale not found in store`.
259 // Detect the FP8 layout per key and dequant→runtime-quantize instead.
260 //
261 // The three NVFP4 layouts are all excluded by construction, so this can
262 // never steal a key that IS NVFP4:
263 // Standard (ModelOpt/nvidia) -> has `weight_scale_2`
264 // CompressedTensors (Sehyo) -> has `weight_packed` + `weight_global_scale`
265 // this FP8 case -> has neither, and `.weight` is FP8E4M3
266 let has_fp8_dense = !has_packed
267 && !store.contains(&format!("{prefix}.weight_global_scale"))
268 && !store.contains(&format!("{prefix}.weight_scale_2"))
269 && (has_scale || has_scale_inv)
270 && store
271 .get(&format!("{prefix}.weight"))
272 .map(|w| w.dtype == WeightDtype::FP8E4M3)
273 .unwrap_or(false);
274
275 let effective_variant = if has_only_dense && !matches!(variant, Nvfp4Variant::Bf16Raw) {
276 tracing::debug!("{prefix}: no quantization metadata; falling back to runtime BF16→NVFP4");
277 Nvfp4Variant::Bf16Raw
278 } else if has_fp8_dense
279 && !matches!(variant, Nvfp4Variant::Fp8Dequanted | Nvfp4Variant::Bf16Raw)
280 {
281 tracing::debug!("{prefix}: FP8 key in an NVFP4 checkpoint; dequant FP8→BF16→NVFP4");
282 Nvfp4Variant::Fp8Dequanted
283 } else {
284 variant
285 };
286
287 let _t_detect_ns = _t_detect.elapsed().as_nanos() as u64;
288 match effective_variant {
289 Nvfp4Variant::Standard => quantized(store, prefix, gpu),
290 Nvfp4Variant::CompressedTensors => quantized_v2(store, prefix, gpu),
291 Nvfp4Variant::Fp8Dequanted => quantized_from_fp8(
292 store,
293 prefix,
294 n,
295 k,
296 gpu,
297 qctx.absmax_k,
298 qctx.quantize_k,
299 qctx.stream,
300 ),
301 Nvfp4Variant::Bf16Raw => {
302 use std::sync::atomic::{AtomicU64, Ordering};
303 static T_DETECT: AtomicU64 = AtomicU64::new(0);
304 static T_GET: AtomicU64 = AtomicU64::new(0);
305 static T_QUANT: AtomicU64 = AtomicU64::new(0);
306 static T_FREE: AtomicU64 = AtomicU64::new(0);
307 static N: AtomicU64 = AtomicU64::new(0);
308 T_DETECT.fetch_add(_t_detect_ns, Ordering::Relaxed);
309 // Raw BF16/FP16 fine-tune: load the dense weight then runtime-quantize.
310 let _t = std::time::Instant::now();
311 let w = store.get(&format!("{prefix}.weight"))?;
312 let bf16 = DenseWeight { weight: w.ptr };
313 T_GET.fetch_add(_t.elapsed().as_nanos() as u64, Ordering::Relaxed);
314 let _t = std::time::Instant::now();
315 let q = quantize_to_nvfp4(
316 &bf16,
317 n,
318 k,
319 gpu,
320 qctx.absmax_k,
321 qctx.quantize_k,
322 qctx.stream,
323 )?;
324 T_QUANT.fetch_add(_t.elapsed().as_nanos() as u64, Ordering::Relaxed);
325 let _t = std::time::Instant::now();
326 // Free the BF16 source: the NVFP4 buffer is a fresh allocation, so the
327 // on-disk BF16 weight is now redundant. Without this a 35B BF16 MoE
328 // (Bf16Raw, SEPARATE per-expert layout routed through here by #200's
329 // `quantized_any`) holds BOTH the ~60GB BF16 experts AND the ~22GB
330 // NVFP4 copies → ~109GB pre-KV, no room for KV. Safe + mirrors
331 // `quantized_from_fp8` which frees its BF16 intermediate the same way.
332 gpu.free(w.ptr)?;
333 T_FREE.fetch_add(_t.elapsed().as_nanos() as u64, Ordering::Relaxed);
334 let c = N.fetch_add(1, Ordering::Relaxed) + 1;
335 if c.is_multiple_of(512) {
336 let ms = |a: &AtomicU64| a.load(Ordering::Relaxed) as f64 / 1.0e6;
337 tracing::info!(
338 "quantized_any(Bf16Raw) PROFILE after {c} calls (ms total): detect={:.1} \
339 store_get={:.1} quantize={:.1} free={:.1} | sum={:.1} per_call={:.3}ms",
340 ms(&T_DETECT),
341 ms(&T_GET),
342 ms(&T_QUANT),
343 ms(&T_FREE),
344 ms(&T_DETECT) + ms(&T_GET) + ms(&T_QUANT) + ms(&T_FREE),
345 (ms(&T_DETECT) + ms(&T_GET) + ms(&T_QUANT) + ms(&T_FREE)) / c as f64,
346 );
347 }
348 Ok(q)
349 }
350 }
351}
352
353/// Load a quantized weight from FP8 block-scaled data: FP8→BF16→NVFP4.
354///
355/// `n` and `k` are the logical weight dimensions (e.g. [inter, hidden] for gate_proj).
356pub(crate) fn quantized_from_fp8(
357 store: &WeightStore,
358 prefix: &str,
359 n: usize,
360 k: usize,
361 gpu: &dyn GpuBackend,
362 absmax_k: spark_runtime::gpu::KernelHandle,
363 quantize_k: spark_runtime::gpu::KernelHandle,
364 stream: u64,
365) -> Result<QuantizedWeight> {
366 let bf16 = dequant_fp8_blockscaled_to_bf16(store, prefix, gpu)?;
367 let result = quantize_to_nvfp4(&bf16, n, k, gpu, absmax_k, quantize_k, stream)?;
368 // Free the BF16 intermediate — only the NVFP4 result is needed.
369 gpu.free(bf16.weight)?;
370 Ok(result)
371}
372
373/// Load FP8 block-scaled weight as BF16 dense (no NVFP4 re-quantization).
374///
375/// Use this when the runtime NVFP4 quantization produces degenerate weights
376/// (e.g., FP8 checkpoints where double-quantization degrades quality).
377/// The weight stays in BF16 and uses `dense_gemv`/`dense_gemm` kernels.
378#[allow(dead_code)]
379pub(crate) fn dense_from_fp8(
380 store: &WeightStore,
381 prefix: &str,
382 gpu: &dyn GpuBackend,
383) -> Result<DenseWeight> {
384 dequant_fp8_blockscaled_to_bf16(store, prefix, gpu)
385}
386
387/// Load full attention weights for Qwen3.5 (all Q/K/V/O are NVFP4 on disk).
388#[allow(dead_code)]
389pub(crate) fn load_attention_qwen35(
390 store: &WeightStore,
391 layer_prefix: &str,
392 gpu: &dyn GpuBackend,
393) -> Result<AttentionWeights> {
394 let p = format!("{layer_prefix}.self_attn");
395 let (k_scale, v_scale) = load_kv_scales(store, &p, gpu);
396 Ok(AttentionWeights {
397 // Q/K/V are NVFP4 quantized — load packed, return as dense (the weight_packed data)
398 // The weight_loader will handle creating QuantizedWeight from these
399 q_proj: dense(store, &format!("{p}.q_proj.weight_packed"))?,
400 k_proj: dense(store, &format!("{p}.k_proj.weight_packed"))?,
401 v_proj: dense(store, &format!("{p}.v_proj.weight_packed"))?,
402 o_proj: quantized_v2(store, &format!("{p}.o_proj"), gpu)?,
403 q_norm: dense(store, &format!("{p}.q_norm.weight"))?,
404 k_norm: dense(store, &format!("{p}.k_norm.weight"))?,
405 q_norm_full: None,
406 k_norm_full: None,
407 k_scale,
408 v_scale,
409 })
410}
411
412/// Load NVFP4 quantized projection for Qwen3.5 full attention layer.
413#[allow(dead_code)]
414pub(crate) fn load_quantized_proj_qwen35(
415 store: &WeightStore,
416 prefix: &str,
417 gpu: &dyn GpuBackend,
418) -> Result<QuantizedWeight> {
419 quantized_v2(store, prefix, gpu)
420}
421
422#[cfg(test)]
423mod ep_detection_tests {
424 use super::*;
425 use atlas_core::config::ModelConfig;
426 use spark_runtime::weights::WeightStore;
427
428 /// A store holding only the FP8 attention marker at a given layer, which is
429 /// what the detector sniffs for. Names are all the detector reads.
430 fn store_with(names: &[String]) -> WeightStore {
431 use std::collections::HashMap;
432 let map: HashMap<String, spark_runtime::weights::WeightTensor> = names
433 .iter()
434 .map(|n| {
435 (
436 n.clone(),
437 spark_runtime::weights::WeightTensor {
438 ptr: spark_runtime::gpu::DevicePtr::NULL,
439 shape: vec![1],
440 dtype: spark_runtime::weights::WeightDtype::FP8E4M3,
441 },
442 )
443 })
444 .collect();
445 WeightStore::from_map(map)
446 }
447
448 #[test]
449 fn alternate_layer0_fp8_dtype_is_detected_on_every_ep_rank() {
450 let mut cfg = ModelConfig::qwen3_next_80b_nvfp4();
451 cfg.quantization_config = None;
452 let store =
453 store_with(&["model.language_model.layers.0.self_attn.q_proj.weight".to_string()]);
454
455 cfg.ep_world_size = 2;
456 for ep_rank in 0..2 {
457 cfg.ep_rank = ep_rank;
458 assert_eq!(
459 detect_nvfp4_variant(&store, &cfg),
460 Nvfp4Variant::Fp8Dequanted,
461 "EP rank {ep_rank} must inspect the same layer-zero checkpoint marker"
462 );
463 }
464 }
465
466 #[test]
467 fn scale_inv_suffix_fallback_detects_an_unexpected_prefix() {
468 let mut cfg = ModelConfig::qwen3_next_80b_nvfp4();
469 cfg.quantization_config = None;
470 let store =
471 store_with(&["third_party.transformer.blocks.17.attn.q.weight_scale_inv".to_string()]);
472 assert_eq!(
473 detect_nvfp4_variant(&store, &cfg),
474 Nvfp4Variant::Fp8Dequanted
475 );
476 }
477}