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}