atlas_core/config/parsers/
quantization.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Split out of `config.rs` for file-size budget. Parser for a model family.
4
5#![allow(unused_imports)]
6
7use anyhow::{Context, Result};
8use serde_json::Value;
9
10use super::super::{ModelConfig, QuantizationConfig};
11
12pub fn parse_quantization_config(raw: &serde_json::Value) -> Option<QuantizationConfig> {
13    let qc_raw = raw.get("quantization_config")?;
14    // NVIDIA ModelOpt's sibling `hf_quant_config.json` (merged into the
15    // `quantization_config` slot by `merge_sidecar_quant_config`) nests the
16    // quant fields one level deep under a `"quantization"` object and never
17    // emits a `quant_method` key — the scheme is implied by
18    // `producer.name == "modelopt"`:
19    //
20    //   { "producer": { "name": "modelopt", ... },
21    //     "quantization": { "quant_algo": "NVFP4",
22    //                       "exclude_modules": ["lm_head", ...] } }
23    //
24    // Read at the top level this parses to all-empty and returns `None`,
25    // which silently drops the checkpoint into tensor-name heuristics — for
26    // a ModelOpt NVFP4 checkpoint (no `.weight_packed`, `.mixer.`-prefixed
27    // Nemotron modules) those mis-detect as `Bf16Raw` and limp into a
28    // runtime BF16->NVFP4 requant path, degrading a fully-calibrated NVFP4
29    // release. `normalize_modelopt_sidecar` lifts the nested object and
30    // synthesizes `quant_method` so the rest of this parser sees the
31    // canonical flat shape. Already-flat configs pass through unchanged.
32    let canonical = normalize_modelopt_sidecar(qc_raw);
33    let qc = &canonical;
34
35    // Either scheme may set any of these top-level strings:
36    //   quant_method     — scheme name; both schemes set this.
37    //   quant_algo       — ModelOpt-specific label (e.g. "NVFP4"). Also
38    //                      propagated when producer.name=="modelopt".
39    //   format           — compressed-tensors only
40    //                      (e.g. "nvfp4-pack-quantized").
41    let quant_method = qc
42        .get("quant_method")
43        .and_then(serde_json::Value::as_str)
44        .unwrap_or("")
45        .to_string();
46    let quant_algo = qc
47        .get("quant_algo")
48        .and_then(serde_json::Value::as_str)
49        .or_else(|| {
50            // ModelOpt dumps sometimes put quant_algo under
51            // `config_groups.group_0.weights.type` as `"float"` with a
52            // `num_bits` sibling. Mine those for a best-effort label.
53            let group = qc.get("config_groups")?.get("group_0")?;
54            let weights = group.get("weights")?;
55            let bits = weights.get("num_bits")?.as_u64()?;
56            let ty = weights.get("type")?.as_str()?;
57            match (bits, ty) {
58                (4, "float") => Some("NVFP4"),
59                (8, "float") => Some("FP8"),
60                _ => None,
61            }
62        })
63        .unwrap_or("")
64        .to_string();
65    let format = qc
66        .get("format")
67        .and_then(serde_json::Value::as_str)
68        .unwrap_or("")
69        .to_string();
70
71    // Ignore list: ModelOpt calls it `ignore`, compressed-tensors calls
72    // it `ignore` too at the top level but also has `targets` inside
73    // `config_groups`. Collect anything useful from both places.
74    let mut ignore_modules: Vec<String> = Vec::new();
75    if let Some(arr) = qc.get("ignore").and_then(serde_json::Value::as_array) {
76        for v in arr {
77            if let Some(s) = v.as_str() {
78                ignore_modules.push(s.to_string());
79            }
80        }
81    }
82    // compressed-tensors can also use `exclude_modules` (vLLM-style).
83    if let Some(arr) = qc
84        .get("exclude_modules")
85        .and_then(serde_json::Value::as_array)
86    {
87        for v in arr {
88            if let Some(s) = v.as_str()
89                && !ignore_modules.contains(&s.to_string())
90            {
91                ignore_modules.push(s.to_string());
92            }
93        }
94    }
95
96    // An empty quant_method with empty ignore list is not a real quant
97    // config — skip so callers can fall through to heuristic detection.
98    if quant_method.is_empty() && quant_algo.is_empty() && ignore_modules.is_empty() {
99        return None;
100    }
101
102    Some(QuantizationConfig {
103        quant_method,
104        quant_algo,
105        format,
106        ignore_modules,
107    })
108}
109
110/// Flatten a ModelOpt-style `hf_quant_config.json` payload into the canonical
111/// shape the rest of [`parse_quantization_config`] reads.
112///
113/// Two transforms, both no-ops on an already-canonical (HF-standard) block:
114///   1. If the payload has a nested `"quantization"` object, lift it to the
115///      top level (ModelOpt nests `quant_algo` / `exclude_modules` there).
116///   2. If no `quant_method` is present but `producer.name == "modelopt"`,
117///      synthesize `quant_method = "modelopt"` so downstream config-first
118///      dispatch (`detect_quant_format`, `detect_nvfp4_variant`) recognizes
119///      the scheme. An explicit `quant_method` is never overwritten.
120fn normalize_modelopt_sidecar(qc_raw: &serde_json::Value) -> serde_json::Value {
121    // (1) Lift nested `quantization` object if present.
122    let mut canonical = match qc_raw.get("quantization") {
123        Some(serde_json::Value::Object(inner)) => serde_json::Value::Object(inner.clone()),
124        _ => qc_raw.clone(),
125    };
126
127    let Some(obj) = canonical.as_object_mut() else {
128        return canonical;
129    };
130
131    // (2) Synthesize `quant_method` from `producer.name` when absent.
132    let has_method = obj
133        .get("quant_method")
134        .and_then(serde_json::Value::as_str)
135        .is_some_and(|s| !s.is_empty());
136    if !has_method {
137        let producer_modelopt = qc_raw
138            .get("producer")
139            .and_then(|p| p.get("name"))
140            .and_then(serde_json::Value::as_str)
141            .is_some_and(|n| n.eq_ignore_ascii_case("modelopt"));
142        if producer_modelopt {
143            obj.insert(
144                "quant_method".to_string(),
145                serde_json::Value::String("modelopt".to_string()),
146            );
147        }
148    }
149
150    canonical
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{normalize_modelopt_sidecar, parse_quantization_config};
156
157    /// The exact `hf_quant_config.json` schema shipped by
158    /// `nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-NVFP4`, wrapped by
159    /// `merge_sidecar_quant_config` into the `quantization_config` slot.
160    #[test]
161    fn modelopt_sidecar_nested_schema_preserves_exclusions() {
162        let raw = serde_json::json!({
163            "quantization_config": {
164                "producer": { "name": "modelopt", "version": "0.29.0" },
165                "quantization": {
166                    "quant_algo": "NVFP4",
167                    "kv_cache_quant_algo": "FP8",
168                    "group_size": 16,
169                    "exclude_modules": [
170                        "lm_head",
171                        "backbone.layers.4.mixer.in_proj",
172                        "backbone.layers.0.mixer.conv1d"
173                    ]
174                }
175            }
176        });
177        let qc = parse_quantization_config(&raw)
178            .expect("ModelOpt nested sidecar must yield a QuantizationConfig");
179        assert_eq!(qc.quant_method, "modelopt");
180        assert_eq!(qc.quant_algo, "NVFP4");
181        assert_eq!(qc.ignore_modules.len(), 3);
182        assert!(qc.ignore_modules.iter().any(|m| m == "lm_head"));
183        assert!(
184            qc.ignore_modules
185                .iter()
186                .any(|m| m == "backbone.layers.4.mixer.in_proj")
187        );
188        assert!(
189            qc.ignore_modules
190                .iter()
191                .any(|m| m == "backbone.layers.0.mixer.conv1d")
192        );
193    }
194
195    /// An already-flat HF-standard block (compressed-tensors) must be
196    /// unaffected by the ModelOpt normalization.
197    #[test]
198    fn flat_compressed_tensors_block_is_not_normalized() {
199        let raw = serde_json::json!({
200            "quantization_config": {
201                "quant_method": "compressed-tensors",
202                "format": "nvfp4-pack-quantized",
203                "ignore": ["lm_head"]
204            }
205        });
206        let flat = &raw["quantization_config"];
207        assert_eq!(normalize_modelopt_sidecar(flat), flat.clone());
208
209        let qc = parse_quantization_config(&raw).expect("flat block must still parse");
210        assert_eq!(qc.quant_method, "compressed-tensors");
211        assert_eq!(qc.format, "nvfp4-pack-quantized");
212        assert_eq!(qc.ignore_modules, vec!["lm_head".to_string()]);
213    }
214
215    /// ModelOpt mixed-precision sidecar (Super-120B) — nested, no
216    /// `exclude_modules`. Must still resolve `quant_method = modelopt`.
217    #[test]
218    fn modelopt_mixed_precision_sidecar_parses_without_exclusions() {
219        let raw = serde_json::json!({
220            "quantization_config": {
221                "producer": { "name": "modelopt", "version": "0.43.0" },
222                "quantization": {
223                    "quant_algo": "MIXED_PRECISION",
224                    "kv_cache_quant_algo": "FP8"
225                }
226            }
227        });
228        let qc = parse_quantization_config(&raw)
229            .expect("mixed-precision sidecar must yield a QuantizationConfig");
230        assert_eq!(qc.quant_method, "modelopt");
231        assert_eq!(qc.quant_algo, "MIXED_PRECISION");
232        assert!(qc.ignore_modules.is_empty());
233    }
234}