Skip to main content

quantize_rs/onnx_utils/
mod.rs

1// src/onnx_utils/mod.rs
2//! ONNX model utilities — loading, weight extraction, quantized save (QDQ),
3//! graph connectivity validation, and quantized-model introspection.
4
5pub mod graph_builder;
6// Internal QDQ node-construction helpers. Kept crate-private: they traffic in
7// raw prost `onnx_proto` types (NodeProto/TensorProto), which are `#[doc(hidden)]`
8// and not part of the stable public API. Use the `OnnxModel` save methods instead.
9pub(crate) mod quantization_nodes;
10
11use crate::errors::{QuantizeError, Result};
12use crate::onnx_proto::{
13    tensor_proto, tensor_shape_proto, type_proto, ModelProto, StringStringEntryProto,
14};
15use prost::Message;
16use std::fs;
17use std::io::{Read, Write};
18
19// Re-export so callers don't have to reach into submodules
20pub use graph_builder::{ConnectivityReport, SaveOptions};
21
22/// Maximum accepted ONNX model size for [`OnnxModel::load`],
23/// [`OnnxModel::load_mmap`], and [`OnnxModel::from_bytes`].
24///
25/// Inputs over this size are rejected to prevent OOM from pathological or
26/// malicious protobufs.  10 GB is well above any production ONNX model in
27/// the wild — including multi-billion-parameter LLMs — while still being a
28/// hard ceiling that protects callers feeding bytes from untrusted sources
29/// (HTTP, IPC, fuzz harnesses).
30const MAX_MODEL_SIZE_BYTES: u64 = 10 * 1024 * 1024 * 1024;
31
32// ===========================================================================
33// Core types
34// ===========================================================================
35
36/// An ONNX model loaded from a protobuf file.
37///
38/// Provides methods for inspecting, extracting weights, saving quantized
39/// models, and validating graph connectivity.
40pub struct OnnxModel {
41    proto: ModelProto,
42    /// Wire-format sections present in the *source* bytes that the vendored
43    /// schema does not model and that will therefore be dropped when the model
44    /// is re-encoded on save (see [`dropped_scan`]).  Populated at load time;
45    /// `Default` (all-zero) for any other construction path.
46    dropped: DroppedSections,
47}
48
49/// Count of ONNX wire-format sections that round-tripping through the vendored
50/// (minimal) protobuf schema would silently drop on save.
51///
52/// `quantize-rs` models a subset of the ONNX schema, and prost discards fields
53/// it doesn't know.  Most are inert metadata, but a few carry real graph
54/// semantics — most importantly `ModelProto.functions` (local-function custom
55/// ops).  We detect their presence at load time so the save path can warn
56/// instead of silently writing a model that omits them.
57#[derive(Debug, Clone, Default)]
58struct DroppedSections {
59    /// `ModelProto.functions` — local `FunctionProto` definitions.
60    functions: usize,
61    /// `GraphProto.sparse_initializer` — sparse weight tensors.
62    sparse_initializers: usize,
63    /// `ModelProto.training_info` — training metadata.
64    training_info: usize,
65}
66
67impl DroppedSections {
68    /// Re-decode the raw model bytes with a probe schema that models *only* the
69    /// unmodeled tags, letting prost do the wire parsing.  A wrong tag here can
70    /// only produce a spurious or missing warning — never corruption — because
71    /// nothing is written from the result.
72    fn scan(bytes: &[u8]) -> Self {
73        use prost::Message;
74        match dropped_scan::ProbeModel::decode(bytes) {
75            Ok(p) => Self {
76                functions: p.functions.len(),
77                sparse_initializers: p.graph.map(|g| g.sparse_initializer.len()).unwrap_or(0),
78                training_info: p.training_info.len(),
79            },
80            // The bytes already decoded once as a full ModelProto, so a probe
81            // failure is unexpected; treat it as "nothing dropped".
82            Err(_) => Self::default(),
83        }
84    }
85
86    fn any(&self) -> bool {
87        self.functions > 0 || self.sparse_initializers > 0 || self.training_info > 0
88    }
89
90    /// Human-readable list of what will be dropped, for the save-time warning.
91    fn describe(&self) -> String {
92        let mut parts = Vec::new();
93        if self.functions > 0 {
94            parts.push(format!("{} local function definition(s)", self.functions));
95        }
96        if self.sparse_initializers > 0 {
97            parts.push(format!(
98                "{} sparse initializer(s)",
99                self.sparse_initializers
100            ));
101        }
102        if self.training_info > 0 {
103            parts.push(format!("{} training-info section(s)", self.training_info));
104        }
105        parts.join(", ")
106    }
107}
108
109/// Minimal prost messages that model *only* the ONNX wire-format tags the main
110/// schema intentionally omits.  Decoding a model's bytes a second time with
111/// these tells us whether the round-trip will drop anything, without a
112/// hand-rolled protobuf scanner.  Field numbers match the official ONNX spec
113/// (`functions` = 25, `training_info` = 20, `GraphProto.sparse_initializer`
114/// = 15); a `graph` is descended via its real tag (7).
115mod dropped_scan {
116    use prost::Message;
117
118    /// Empty placeholder: prost decodes a sub-message into this and skips all
119    /// of its contents, but each occurrence is still counted.
120    #[derive(Clone, PartialEq, Message)]
121    pub(super) struct Ignore {}
122
123    #[derive(Clone, PartialEq, Message)]
124    pub(super) struct ProbeGraph {
125        #[prost(message, repeated, tag = "15")]
126        pub sparse_initializer: Vec<Ignore>,
127    }
128
129    #[derive(Clone, PartialEq, Message)]
130    pub(super) struct ProbeModel {
131        #[prost(message, optional, tag = "7")]
132        pub graph: Option<ProbeGraph>,
133        #[prost(message, repeated, tag = "20")]
134        pub training_info: Vec<Ignore>,
135        #[prost(message, repeated, tag = "25")]
136        pub functions: Vec<Ignore>,
137    }
138}
139
140impl std::fmt::Debug for OnnxModel {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        let name = self
143            .proto
144            .graph
145            .as_ref()
146            .map(|g| g.name.as_str())
147            .unwrap_or("");
148        let num_nodes = self.proto.graph.as_ref().map(|g| g.node.len()).unwrap_or(0);
149        f.debug_struct("OnnxModel")
150            .field("name", &name)
151            .field("num_nodes", &num_nodes)
152            .finish()
153    }
154}
155
156/// Summary of an ONNX model's structure.
157///
158/// Marked `#[non_exhaustive]` so future summary fields can be added without a
159/// breaking change.
160#[derive(Debug)]
161#[non_exhaustive]
162pub struct ModelInfo {
163    /// Graph name from the protobuf.
164    pub name: String,
165    /// `model_version` field from the protobuf (often 0 in practice).
166    pub version: i64,
167    /// Default-domain opset version the model declares (0 if absent).  This is
168    /// the value that governs operator compatibility — usually more useful
169    /// than [`version`](Self::version).
170    pub opset_version: i64,
171    /// Number of computation nodes in the graph.
172    pub num_nodes: usize,
173    /// Names of the graph inputs.
174    pub inputs: Vec<String>,
175    /// Names of the graph outputs.
176    pub outputs: Vec<String>,
177}
178
179/// Metadata about a quantized weight recovered from a QDQ-format model.
180///
181/// Marked `#[non_exhaustive]` so future fields can be added without a
182/// breaking change.
183#[derive(Debug, Clone)]
184#[non_exhaustive]
185pub struct QuantizedWeightInfo {
186    /// Original weight name (without `_quantized` suffix).
187    pub name: String,
188    /// Quantization bit width (4 or 8).
189    pub bits: u8,
190    /// Quantization scales.  `len() == 1` for per-tensor quantization;
191    /// `len() == num_channels` for per-channel.
192    pub scales: Vec<f32>,
193    /// Quantization zero points.  Same length as [`scales`](Self::scales).
194    pub zero_points: Vec<i8>,
195    /// Number of elements in the quantized tensor.
196    pub original_length: usize,
197    /// Actual on-disk byte count of the quantized initializer's `raw_data`.
198    /// For INT8 storage this equals `original_length`; for native INT4
199    /// (opset 21) it is `ceil(original_length / 2)`.
200    pub storage_bytes: usize,
201}
202
203impl QuantizedWeightInfo {
204    /// `true` if the weight was quantized per-channel (more than one scale).
205    pub fn is_per_channel(&self) -> bool {
206        self.scales.len() > 1
207    }
208
209    /// Per-tensor convenience accessor: returns the first scale, or `None`
210    /// if no scales were recovered for this weight (malformed model).
211    ///
212    /// For per-channel tensors, iterate over [`scales`](Self::scales) instead.
213    pub fn scale(&self) -> Option<f32> {
214        self.scales.first().copied()
215    }
216
217    /// Per-tensor convenience accessor: returns the first zero-point, or
218    /// `None` if no zero-points were recovered (malformed model).
219    ///
220    /// For per-channel tensors, iterate over [`zero_points`](Self::zero_points) instead.
221    pub fn zero_point(&self) -> Option<i8> {
222        self.zero_points.first().copied()
223    }
224}
225
226// ===========================================================================
227// OnnxModel — load / inspect
228// ===========================================================================
229
230impl OnnxModel {
231    /// Load an ONNX model from a file path.
232    ///
233    /// Reads the entire file into a `Vec<u8>` before decoding.  For
234    /// multi-gigabyte models consider [`load_mmap`](Self::load_mmap)
235    /// (requires the `mmap` feature) to avoid the extra heap buffer.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`QuantizeError::ModelLoad`] if the file cannot be opened,
240    /// is too large (>10 GB), or contains invalid protobuf data.
241    pub fn load(path: impl AsRef<std::path::Path>) -> Result<Self> {
242        let path = path.as_ref();
243        let mut file = fs::File::open(path).map_err(|e| QuantizeError::ModelLoad {
244            path: path.to_path_buf(),
245            reason: format!("Failed to open ONNX file: {e}"),
246        })?;
247
248        let file_size = file
249            .metadata()
250            .map_err(|e| QuantizeError::ModelLoad {
251                path: path.to_path_buf(),
252                reason: format!("Failed to read metadata: {e}"),
253            })?
254            .len();
255        if file_size > MAX_MODEL_SIZE_BYTES {
256            return Err(QuantizeError::ModelLoad {
257                path: path.to_path_buf(),
258                reason: format!(
259                    "Model file too large: {:.2} GB (max: 10 GB)",
260                    file_size as f64 / (1024.0 * 1024.0 * 1024.0)
261                ),
262            });
263        }
264
265        let mut buffer = Vec::with_capacity(file_size as usize);
266        file.read_to_end(&mut buffer)
267            .map_err(|e| QuantizeError::ModelLoad {
268                path: path.to_path_buf(),
269                reason: format!("Failed to read ONNX file: {e}"),
270            })?;
271
272        let proto = ModelProto::decode(&buffer[..]).map_err(|e| QuantizeError::ModelLoad {
273            path: path.to_path_buf(),
274            reason: format!("Failed to parse ONNX protobuf: {e}"),
275        })?;
276
277        let dropped = DroppedSections::scan(&buffer);
278        Ok(Self { proto, dropped })
279    }
280
281    /// Decode an ONNX model directly from a byte slice.
282    ///
283    /// Useful for in-memory or fuzzing scenarios where the source isn't a
284    /// filesystem path.  The same 10 GB size cap that [`load`](Self::load)
285    /// applies to files is also enforced here so callers feeding bytes from
286    /// untrusted sources (HTTP, IPC, fuzz harnesses) can't OOM the decoder
287    /// with a pathologically large input.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`QuantizeError::ModelLoad`] if `bytes` exceeds 10 GB or
292    /// cannot be decoded as a `ModelProto`.
293    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
294        if bytes.len() as u64 > MAX_MODEL_SIZE_BYTES {
295            return Err(QuantizeError::ModelLoad {
296                path: std::path::PathBuf::new(),
297                reason: format!(
298                    "Input too large: {:.2} GB (max: 10 GB)",
299                    bytes.len() as f64 / (1024.0 * 1024.0 * 1024.0)
300                ),
301            });
302        }
303        let proto = ModelProto::decode(bytes).map_err(|e| QuantizeError::ModelLoad {
304            path: std::path::PathBuf::new(),
305            reason: format!("Failed to parse ONNX protobuf: {e}"),
306        })?;
307        let dropped = DroppedSections::scan(bytes);
308        Ok(Self { proto, dropped })
309    }
310
311    /// Load an ONNX model by memory-mapping the file (requires the `mmap`
312    /// feature).
313    ///
314    /// Compared to [`load`](Self::load), this avoids the intermediate
315    /// `Vec<u8>` buffer — useful for multi-gigabyte models where doubling
316    /// the working set during decode is a problem.  Peak RAM during load
317    /// falls from roughly `2 × file_size` to `1 × file_size + mmap overhead`.
318    ///
319    /// # Safety
320    ///
321    /// Memory-mapping requires that the file is not modified for the
322    /// duration of the load.  Another process truncating or rewriting the
323    /// file while decoding would be undefined behaviour.  This function
324    /// uses the `unsafe { Mmap::map(&file) }` call under the hood; its
325    /// invariants are the caller's responsibility.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`QuantizeError::ModelLoad`] on I/O failure, invalid size,
330    /// or malformed protobuf.
331    #[cfg(feature = "mmap")]
332    pub fn load_mmap(path: impl AsRef<std::path::Path>) -> Result<Self> {
333        let path = path.as_ref();
334        let file = fs::File::open(path).map_err(|e| QuantizeError::ModelLoad {
335            path: path.to_path_buf(),
336            reason: format!("Failed to open ONNX file: {e}"),
337        })?;
338
339        let file_size = file
340            .metadata()
341            .map_err(|e| QuantizeError::ModelLoad {
342                path: path.to_path_buf(),
343                reason: format!("Failed to read metadata: {e}"),
344            })?
345            .len();
346        if file_size > MAX_MODEL_SIZE_BYTES {
347            return Err(QuantizeError::ModelLoad {
348                path: path.to_path_buf(),
349                reason: format!(
350                    "Model file too large: {:.2} GB (max: 10 GB)",
351                    file_size as f64 / (1024.0 * 1024.0 * 1024.0)
352                ),
353            });
354        }
355
356        // SAFETY: see method-level docs — caller guarantees the file is
357        // not modified while it is mapped.
358        let mmap = unsafe {
359            memmap2::Mmap::map(&file).map_err(|e| QuantizeError::ModelLoad {
360                path: path.to_path_buf(),
361                reason: format!("Failed to mmap ONNX file: {e}"),
362            })?
363        };
364
365        let proto = ModelProto::decode(&mmap[..]).map_err(|e| QuantizeError::ModelLoad {
366            path: path.to_path_buf(),
367            reason: format!("Failed to parse ONNX protobuf: {e}"),
368        })?;
369
370        let dropped = DroppedSections::scan(&mmap[..]);
371
372        // mmap is dropped here; `proto` owns all its data (prost copies
373        // bytes out of the source during decode), so this is sound.
374        Ok(Self { proto, dropped })
375    }
376
377    /// Return a summary of the model's structure.
378    pub fn info(&self) -> ModelInfo {
379        let graph = self.proto.graph.as_ref();
380
381        let inputs: Vec<String> = graph
382            .map(|g| g.input.iter().map(|i| i.name.clone()).collect())
383            .unwrap_or_default();
384
385        let outputs: Vec<String> = graph
386            .map(|g| g.output.iter().map(|o| o.name.clone()).collect())
387            .unwrap_or_default();
388
389        // Default-domain opset (empty domain string) — what actually governs
390        // operator compatibility.  `model_version` is usually 0 and unhelpful.
391        let opset_version = self
392            .proto
393            .opset_import
394            .iter()
395            .find(|o| o.domain.is_empty())
396            .map(|o| o.version)
397            .unwrap_or(0);
398
399        ModelInfo {
400            name: graph.map(|g| g.name.clone()).unwrap_or_default(),
401            version: self.proto.model_version,
402            opset_version,
403            num_nodes: graph.map(|g| g.node.len()).unwrap_or(0),
404            inputs,
405            outputs,
406        }
407    }
408
409    /// Return the shapes of each graph input from the protobuf type info.
410    ///
411    /// Each inner `Vec<i64>` contains the dimension values.  Dynamic dims
412    /// (symbolic or missing) are returned as -1.  Returns one entry per
413    /// `graph.input` that has tensor type information.
414    pub fn input_shapes(&self) -> Vec<Vec<i64>> {
415        let graph = match &self.proto.graph {
416            Some(g) => g,
417            None => return Vec::new(),
418        };
419
420        let mut shapes = Vec::new();
421        for inp in &graph.input {
422            if let Some(type_proto) = &inp.r#type {
423                if let Some(type_proto::Value::TensorType(tensor_type)) = &type_proto.value {
424                    if let Some(shape) = &tensor_type.shape {
425                        let dims: Vec<i64> = shape
426                            .dim
427                            .iter()
428                            .map(|d| match &d.value {
429                                Some(tensor_shape_proto::dimension::Value::DimValue(v)) => *v,
430                                _ => -1,
431                            })
432                            .collect();
433                        shapes.push(dims);
434                    }
435                }
436            }
437        }
438        shapes
439    }
440
441    /// Number of weight-shaped initializers whose dtype is a non-FP32
442    /// *floating-point* type (FP16, BF16, or Double).  Useful for the CLI
443    /// to explain why `extract_weights` returned nothing on a model that
444    /// visibly has data — most commonly an FP16-exported HuggingFace model.
445    ///
446    /// Only float-family dtypes are counted; rank-≥2 INT64 tensors are
447    /// usually shape constants for `Reshape` / `Tile` / `Gather` and would
448    /// otherwise show up as "non-FP32 weights" in the error message,
449    /// confusing users.
450    pub fn count_non_fp32_weight_initializers(&self) -> usize {
451        let graph = match &self.proto.graph {
452            Some(g) => g,
453            None => return 0,
454        };
455        let fp32 = tensor_proto::DataType::Float as i32;
456        let fp16 = tensor_proto::DataType::Float16 as i32;
457        let bf16 = tensor_proto::DataType::Bfloat16 as i32;
458        let f64 = tensor_proto::DataType::Double as i32;
459        graph
460            .initializer
461            .iter()
462            .filter(|init| {
463                init.dims.len() >= 2
464                    && init.data_type != fp32
465                    && (init.data_type == fp16 || init.data_type == bf16 || init.data_type == f64)
466            })
467            .count()
468    }
469
470    /// Number of initializers whose tensor data lives in an external file
471    /// (`data_location == EXTERNAL`), rather than inline in the protobuf.
472    ///
473    /// quantize-rs reads only inline `raw_data` / `float_data`, so external-data
474    /// tensors are skipped by [`extract_weights`](Self::extract_weights).  The
475    /// CLI and Python layers use this to turn an otherwise-confusing "no weight
476    /// tensors found" into a precise diagnostic: ONNX exports above ~2 GB
477    /// (large LLMs in particular) commonly store weights in a sidecar
478    /// `.onnx.data` file, which must be inlined before quantization.
479    pub fn count_external_data_initializers(&self) -> usize {
480        let graph = match &self.proto.graph {
481            Some(g) => g,
482            None => return 0,
483        };
484        let external = tensor_proto::DataLocation::External as i32;
485        graph
486            .initializer
487            .iter()
488            .filter(|init| init.data_location == external)
489            .count()
490    }
491
492    /// Extract the quantizable FP32 weight tensors from the model's initializers.
493    ///
494    /// Only **rank-≥2** tensors are returned.  Rank-0/1 initializers — biases,
495    /// BatchNorm `scale`/`B`/`mean`/`var`, LayerNorm parameters, PRelu slopes —
496    /// are not weights and must not be quantized: per-tensor INT8 on a BatchNorm
497    /// `running_var` rounds near-zero variances to 0, and the `1/sqrt(var)` in
498    /// BatchNorm then explodes the activations (this broke MobileNetV2 outright,
499    /// cosine ≈ 0.10).  Genuine quantization targets are always rank ≥ 2 (Conv
500    /// 4-D, MatMul/Gemm 2-D, embedding tables 2-D).
501    ///
502    /// QDQ scale scaffolding is also excluded: a `{base}_scale` FP32 initializer
503    /// that has a sibling `{base}_quantized` is a DequantizeLinear scale (not a
504    /// weight), as is any `_quantize_rs_`-prefixed initializer the save path
505    /// synthesizes.  Without this, loading an already-quantized model and
506    /// quantizing it again would quantize the scales and silently corrupt the
507    /// dequantization.
508    pub fn extract_weights(&self) -> Vec<WeightTensor> {
509        let graph = match &self.proto.graph {
510            Some(g) => g,
511            None => return Vec::new(),
512        };
513
514        // Initializer names, used to recognize QDQ scale scaffolding below.
515        let init_names: std::collections::HashSet<&str> =
516            graph.initializer.iter().map(|i| i.name.as_str()).collect();
517
518        let mut weights = Vec::new();
519        for initializer in &graph.initializer {
520            // Only extract FP32 tensors — skip INT8, INT64, DOUBLE, etc.
521            if initializer.data_type != tensor_proto::DataType::Float as i32 {
522                continue;
523            }
524
525            // Skip rank-0/1 initializers: biases, BatchNorm parameters
526            // (scale/B/mean/var), LayerNorm scale/bias, PRelu slopes, etc.  None
527            // of these are the weight input of a Conv/MatMul/Gemm, and quantizing
528            // them corrupts the model — BatchNorm `running_var` is the worst
529            // case (near-zero variance → 0 under per-tensor INT8 → `1/sqrt(var)`
530            // explodes).  A genuine quantizable weight is always rank ≥ 2.
531            if initializer.dims.len() < 2 {
532                continue;
533            }
534
535            // Skip quantize-rs scaffolding: internal synthesized initializers
536            // and QDQ scale tensors (recognized by a sibling `_quantized`).
537            if initializer.name.starts_with("_quantize_rs_") {
538                continue;
539            }
540            if let Some(base) = initializer.name.strip_suffix("_scale") {
541                if init_names.contains(format!("{base}_quantized").as_str()) {
542                    continue;
543                }
544            }
545
546            let name = initializer.name.clone();
547
548            let shape: Vec<usize> = initializer
549                .dims
550                .iter()
551                .map(|&d| d.max(0) as usize)
552                .collect();
553
554            let data = if !initializer.raw_data.is_empty() {
555                if initializer.raw_data.len() % 4 != 0 {
556                    // Misaligned raw_data — skip this initializer rather than panic
557                    continue;
558                }
559                initializer
560                    .raw_data
561                    .chunks_exact(4)
562                    .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
563                    .collect()
564            } else {
565                initializer.float_data.clone()
566            };
567
568            if !data.is_empty() {
569                weights.push(WeightTensor { name, data, shape });
570            }
571        }
572
573        weights
574    }
575
576    /// Total size of all weight tensors in bytes (float32).
577    ///
578    /// Prefer computing this from already-extracted weights when available:
579    /// `weights.iter().map(|w| w.size_bytes()).sum()` avoids reparsing.
580    pub fn total_size_bytes(&self) -> usize {
581        let graph = match &self.proto.graph {
582            Some(g) => g,
583            None => return 0,
584        };
585        graph
586            .initializer
587            .iter()
588            .map(|init| {
589                if !init.raw_data.is_empty() {
590                    init.raw_data.len()
591                } else {
592                    init.float_data.len() * std::mem::size_of::<f32>()
593                }
594            })
595            .sum()
596    }
597}
598
599// ===========================================================================
600// OnnxModel — quantized save (QDQ pattern, v0.3.0+)
601// ===========================================================================
602
603impl OnnxModel {
604    /// Save a quantized model using the QDQ (DequantizeLinear) pattern.
605    ///
606    /// **Signature is identical to v0.2.0** — existing callers (CLI, calibration
607    /// pipeline, examples) compile without changes.
608    ///
609    /// ### What changed internally
610    ///
611    /// v0.2.0 appended metadata to initializer names (e.g. `conv1.weight` →
612    /// `conv1.weight__qINT8_s0.001_z-3_len9408`) without updating the nodes that
613    /// reference them.  ONNX Runtime rejected these models on load.
614    ///
615    /// v0.3.0 inserts a `DequantizeLinear` node per weight.  The node's output
616    /// carries the **original** name, so every downstream node is unchanged.
617    /// Graph connectivity is preserved by construction, and the resulting model
618    /// loads and runs in ONNX Runtime.
619    ///
620    /// ### INT4 storage note
621    ///
622    /// `DequantizeLinear` requires INT8 input in opsets &lt; 21.  By default,
623    /// INT4-quantized values ([-8, 7]) are widened to INT8 bytes — 4×
624    /// compression from FP32.  For true 8× compression, call
625    /// [`save_quantized_with_options`](Self::save_quantized_with_options) with
626    /// [`SaveOptions::with_native_int4`](graph_builder::SaveOptions::with_native_int4)`(true)`,
627    /// which emits native `INT4` initializers and bumps the opset to 21.
628    pub fn save_quantized(
629        &mut self,
630        quantized_data: &[graph_builder::QdqWeightInput],
631        path: impl AsRef<std::path::Path>,
632    ) -> Result<()> {
633        self.save_quantized_with_options(quantized_data, path, SaveOptions::default())
634    }
635
636    /// Save a quantized model with explicit [`SaveOptions`] control.
637    ///
638    /// See [`save_quantized`](Self::save_quantized) for the transform details.
639    /// Enabling [`SaveOptions::native_int4`] for INT4 weights bumps the
640    /// required opset to 21 automatically.
641    ///
642    /// ### Fields not preserved on save
643    ///
644    /// quantize-rs models a subset of the ONNX schema, so re-encoding drops any
645    /// section outside it: `ModelProto.functions` (local-function custom ops),
646    /// `GraphProto.sparse_initializer`, `ModelProto.training_info`, and assorted
647    /// `metadata_props`/`doc_string` on nodes and tensors.  When a *loaded* model
648    /// carried `functions`, `sparse_initializer`, or `training_info`, this method
649    /// prints a warning to stderr.  Models built from such sections (notably
650    /// custom-op graphs) should be quantized with care — the dequantized weights
651    /// are correct, but the saved graph will not contain those sections.
652    pub fn save_quantized_with_options(
653        &mut self,
654        quantized_data: &[graph_builder::QdqWeightInput],
655        path: impl AsRef<std::path::Path>,
656        options: SaveOptions,
657    ) -> Result<()> {
658        let path = path.as_ref();
659        use graph_builder::{apply_qdq_transform_with_options, ensure_opset_version};
660
661        // Empty input list means "no quantization requested".  Treat this as
662        // an error rather than silently bumping the opset and wiping any
663        // existing `quantize_rs.bits.*` metadata.  Callers that genuinely
664        // want to write the model unchanged should encode the protobuf and
665        // write it themselves; the quantization save path is not the right
666        // tool for that.
667        if quantized_data.is_empty() {
668            return Err(QuantizeError::GraphTransform {
669                reason: "save_quantized_with_options called with an empty \
670                         quantized_data slice; nothing to write.  Construct \
671                         a non-empty Vec<QdqWeightInput> or save the proto \
672                         directly via ModelProto::encode."
673                    .to_string(),
674            });
675        }
676
677        // --- 0c. Warn about wire-format sections this save will drop ---
678        // The vendored ONNX schema is a subset, so re-encoding silently omits
679        // anything outside it.  Most are inert, but local functions carry real
680        // op semantics — a model that relies on them can be invalid after save.
681        // Routed through the `log` facade (not `eprintln!`) so library and
682        // Python consumers can suppress or redirect it; the CLI installs a
683        // stderr logger so it still surfaces there.
684        if self.dropped.any() {
685            log::warn!(
686                "the input model contains ONNX wire-format section(s) that quantize-rs \
687                 does not preserve — {} will be ABSENT from '{}'.  Models that rely on local \
688                 functions (custom ops) in particular may be invalid after quantization; verify \
689                 the saved model before deploying.",
690                self.dropped.describe(),
691                path.display()
692            );
693        }
694
695        // --- 1. Opset: ≥10 for per-tensor, ≥13 for per-channel, ≥21 for native INT4 ---
696        let needs_per_channel = quantized_data.iter().any(|w| w.axis.is_some());
697        let uses_native_int4 = options.native_int4 && quantized_data.iter().any(|w| w.bits == 4);
698        let min_opset = if uses_native_int4 {
699            21
700        } else if needs_per_channel {
701            13
702        } else {
703            10
704        };
705        ensure_opset_version(&mut self.proto, min_opset);
706
707        // --- 2. Persist per-weight bits in model metadata ---
708        // Drop any prior `quantize_rs.bits.*` entries before re-emitting so
709        // repeated `save_quantized_with_options` calls on the same OnnxModel
710        // do not accumulate duplicate metadata.
711        self.proto
712            .metadata_props
713            .retain(|p| !p.key.starts_with("quantize_rs.bits."));
714        for inp in quantized_data.iter() {
715            self.proto.metadata_props.push(StringStringEntryProto {
716                key: format!("quantize_rs.bits.{}", inp.original_name),
717                value: inp.bits.to_string(),
718            });
719        }
720
721        // --- 3. Apply QDQ transform to the graph ---
722        let graph = self
723            .proto
724            .graph
725            .as_mut()
726            .ok_or_else(|| QuantizeError::ModelSave {
727                path: path.to_path_buf(),
728                reason: "Model has no graph".to_string(),
729            })?;
730        apply_qdq_transform_with_options(graph, quantized_data, options)?;
731
732        // --- 4. Encode and write to disk atomically ---
733        // Write to a sibling temp file and rename into place so a crash or
734        // power loss mid-write does not leave a corrupted output at `path`.
735        // Callers reloading the same path after a crash get either the old
736        // file or the fully-written new file — never a torn write.
737        let mut buf = Vec::new();
738        self.proto
739            .encode(&mut buf)
740            .map_err(|e| QuantizeError::ModelSave {
741                path: path.to_path_buf(),
742                reason: format!("Failed to encode ONNX model: {e}"),
743            })?;
744
745        // Unique temp path (pid + a process-local counter) so concurrent saves
746        // to the same output path — across processes or threads — never share a
747        // temp file and clobber each other.  The atomic rename below still makes
748        // the final output appear all-at-once.
749        let tmp_path = {
750            use std::sync::atomic::{AtomicU64, Ordering};
751            static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
752            let unique = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
753            let mut s = path.as_os_str().to_owned();
754            s.push(format!(
755                ".quantize-rs.{}.{}.tmp",
756                std::process::id(),
757                unique
758            ));
759            std::path::PathBuf::from(s)
760        };
761
762        // Encode → temp file → fsync.  If any step fails after the temp file
763        // is created, remove the partial `.quantize-rs.tmp` before returning so
764        // a failed save never leaves an orphan behind — mirroring the
765        // rename-failure cleanup below.
766        let write_tmp = || -> Result<()> {
767            let mut file =
768                std::fs::File::create(&tmp_path).map_err(|e| QuantizeError::ModelSave {
769                    path: tmp_path.clone(),
770                    reason: format!("Failed to create temp output file: {e}"),
771                })?;
772
773            file.write_all(&buf).map_err(|e| QuantizeError::ModelSave {
774                path: tmp_path.clone(),
775                reason: format!("Failed to write ONNX model: {e}"),
776            })?;
777
778            // Flush kernel buffers to disk before the rename so the new file
779            // contents are durable.  Without this, the rename can succeed
780            // before the data hits stable storage.
781            file.sync_all().map_err(|e| QuantizeError::ModelSave {
782                path: tmp_path.clone(),
783                reason: format!("Failed to fsync ONNX model: {e}"),
784            })?;
785
786            Ok(())
787        };
788        if let Err(e) = write_tmp() {
789            // Best-effort cleanup of the partial temp file.
790            let _ = std::fs::remove_file(&tmp_path);
791            return Err(e);
792        }
793
794        std::fs::rename(&tmp_path, path).map_err(|e| {
795            // Best-effort cleanup so we don't leave a stale .tmp file behind.
796            // If even the cleanup fails (file locked by antivirus, EACCES,
797            // etc.) we warn so the user knows there's a stray file to remove.
798            if let Err(cleanup_err) = std::fs::remove_file(&tmp_path) {
799                log::warn!(
800                    "failed to clean up temporary file '{}' after \
801                     rename failure: {} (please delete it manually)",
802                    tmp_path.display(),
803                    cleanup_err
804                );
805            }
806            QuantizeError::ModelSave {
807                path: path.to_path_buf(),
808                reason: format!("Failed to rename temp file into place: {e}"),
809            }
810        })?;
811
812        Ok(())
813    }
814}
815
816// ===========================================================================
817// OnnxModel — validation
818// ===========================================================================
819
820impl OnnxModel {
821    /// Check that every node input in the graph resolves to a known tensor.
822    ///
823    /// A "known tensor" is one of:
824    ///   - a declared graph input
825    ///   - an initializer
826    ///   - the output of a node appearing earlier in the node list
827    ///
828    /// This is the exact check ONNX Runtime performs on load.  It's the check
829    /// that v0.2.0's `validate` command skipped, which is why the rename bug
830    /// went undetected.  Integrate `report.summary()` into the CLI validate
831    /// output alongside the existing structure / weight checks.
832    pub fn validate_connectivity(&self) -> ConnectivityReport {
833        match &self.proto.graph {
834            Some(graph) => graph_builder::validate_graph_connectivity(graph),
835            None => {
836                use crate::onnx_proto::GraphProto;
837                graph_builder::validate_graph_connectivity(&GraphProto::default())
838            }
839        }
840    }
841}
842
843// ===========================================================================
844// OnnxModel — quantized model introspection (v0.3.0 QDQ format)
845// ===========================================================================
846
847impl OnnxModel {
848    /// Extract metadata about quantized weights from a QDQ-format model.
849    ///
850    /// Looks for initializer triples:
851    ///   `{base}_quantized`, `{base}_scale`, `{base}_zp`
852    ///
853    /// Scale and zero-point are decoded in full — per-tensor yields a single
854    /// element; per-channel yields one entry per channel.  Bit-width comes
855    /// from `metadata_props` (written by `save_quantized`); defaults to 8 if
856    /// the metadata entry is missing.
857    ///
858    /// Native INT4 zero-point tensors (`DataType::Int4`) are unpacked from
859    /// their two-per-byte on-disk layout automatically.
860    pub fn load_quantized_info(&self) -> Vec<QuantizedWeightInfo> {
861        let graph = match &self.proto.graph {
862            Some(g) => g,
863            None => return Vec::new(),
864        };
865
866        let mut scale_map: std::collections::HashMap<String, Vec<f32>> =
867            std::collections::HashMap::new();
868        let mut zp_map: std::collections::HashMap<String, Vec<i8>> =
869            std::collections::HashMap::new();
870        let mut quant_bases: Vec<String> = Vec::new();
871
872        for init in &graph.initializer {
873            let name = &init.name;
874
875            if let Some(base) = name.strip_suffix("_scale") {
876                scale_map.insert(base.to_string(), decode_scale_tensor(init));
877            } else if let Some(base) = name.strip_suffix("_zp") {
878                zp_map.insert(base.to_string(), decode_zero_point_tensor(init));
879            } else if let Some(base) = name.strip_suffix("_quantized") {
880                quant_bases.push(base.to_string());
881            }
882        }
883
884        // Read bits from metadata_props (written by save_quantized)
885        let mut bits_map: std::collections::HashMap<String, u8> = std::collections::HashMap::new();
886        for prop in &self.proto.metadata_props {
887            if let Some(base) = prop.key.strip_prefix("quantize_rs.bits.") {
888                if let Ok(bits) = prop.value.parse::<u8>() {
889                    bits_map.insert(base.to_string(), bits);
890                }
891            }
892        }
893
894        quant_bases
895            .iter()
896            .map(|base| {
897                let scales = scale_map.get(base).cloned().unwrap_or_else(|| vec![1.0]);
898                let zero_points = zp_map.get(base).cloned().unwrap_or_else(|| vec![0]);
899                let bits = bits_map.get(base).copied().unwrap_or(8);
900
901                // Element count = product of dims on the _quantized tensor;
902                // byte count = actual raw_data length (accounts for native INT4 packing).
903                let quant_init = graph
904                    .initializer
905                    .iter()
906                    .find(|i| i.name == format!("{}_quantized", base));
907                let original_length = quant_init
908                    .map(|i| i.dims.iter().product::<i64>() as usize)
909                    .unwrap_or(0);
910                let storage_bytes = quant_init.map(|i| i.raw_data.len()).unwrap_or(0);
911
912                QuantizedWeightInfo {
913                    name: base.clone(),
914                    bits,
915                    scales,
916                    zero_points,
917                    original_length,
918                    storage_bytes,
919                }
920            })
921            .collect()
922    }
923}
924
925// ---------------------------------------------------------------------------
926// Helpers for load_quantized_info
927// ---------------------------------------------------------------------------
928
929/// Expected element count for a 1-D or scalar tensor: rank-0 → 1, rank-1 → dims[0].
930fn expected_element_count(init: &crate::onnx_proto::TensorProto) -> usize {
931    if init.dims.is_empty() {
932        1
933    } else {
934        init.dims
935            .iter()
936            .copied()
937            .filter(|&d| d > 0)
938            .product::<i64>() as usize
939    }
940}
941
942fn decode_scale_tensor(init: &crate::onnx_proto::TensorProto) -> Vec<f32> {
943    let expected = expected_element_count(init).max(1);
944
945    if !init.float_data.is_empty() {
946        return init.float_data.clone();
947    }
948
949    if !init.raw_data.is_empty() && init.raw_data.len() >= 4 * expected {
950        return init
951            .raw_data
952            .chunks_exact(4)
953            .take(expected)
954            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
955            .collect();
956    }
957
958    // Malformed or missing — fall back to a safe default so callers can still
959    // report the weight exists without a division-by-zero risk.
960    vec![1.0; expected]
961}
962
963fn decode_zero_point_tensor(init: &crate::onnx_proto::TensorProto) -> Vec<i8> {
964    use crate::onnx_proto::tensor_proto::DataType;
965    use crate::onnx_utils::quantization_nodes::unpack_int4_onnx;
966
967    let expected = expected_element_count(init).max(1);
968
969    // Native INT4: raw_data is packed two-per-byte, logical count in dims.
970    if init.data_type == DataType::Int4 as i32 {
971        return unpack_int4_onnx(&init.raw_data, expected);
972    }
973
974    // INT8 / widened INT4 / UINT8: raw_data is one byte per value.
975    if !init.raw_data.is_empty() {
976        return init
977            .raw_data
978            .iter()
979            .take(expected)
980            .map(|&b| b as i8)
981            .collect();
982    }
983
984    // int32_data carries int-type scalars when raw_data is absent.
985    if !init.int32_data.is_empty() {
986        return init
987            .int32_data
988            .iter()
989            .take(expected)
990            .map(|&v| v as i8)
991            .collect();
992    }
993
994    vec![0; expected]
995}
996
997// ===========================================================================
998// WeightTensor (unchanged from v0.2.0)
999// ===========================================================================
1000
1001/// An FP32 weight tensor extracted from an ONNX model.
1002#[derive(Debug, Clone)]
1003pub struct WeightTensor {
1004    /// Initializer name in the ONNX graph.
1005    pub name: String,
1006    /// FP32 weight values.
1007    pub data: Vec<f32>,
1008    /// Tensor dimensions.
1009    pub shape: Vec<usize>,
1010}
1011
1012impl WeightTensor {
1013    /// Size of this tensor in bytes (as FP32).
1014    pub fn size_bytes(&self) -> usize {
1015        self.data.len() * std::mem::size_of::<f32>()
1016    }
1017
1018    /// Total number of scalar elements.
1019    pub fn num_elements(&self) -> usize {
1020        self.data.len()
1021    }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027
1028    #[test]
1029    fn dropped_scan_detects_local_functions() {
1030        // Hand-encoded protobuf: ModelProto field 25 (functions), wire type 2,
1031        // length 0 → one empty FunctionProto.  The tag (25<<3)|2 = 202 encodes
1032        // as the varint [0xCA, 0x01]; this pins the probe to the ONNX spec
1033        // field number, so a future typo can't silently disable the warning.
1034        let bytes = [0xCA, 0x01, 0x00];
1035        let d = DroppedSections::scan(&bytes);
1036        assert_eq!(d.functions, 1);
1037        assert!(d.any());
1038        assert!(d.describe().contains("local function"));
1039    }
1040
1041    #[test]
1042    fn dropped_scan_detects_sparse_initializer() {
1043        // ModelProto.graph (field 7, wire 2) → GraphProto.sparse_initializer
1044        // (field 15, wire 2), each length 0.
1045        let bytes = [0x3A, 0x02, 0x7A, 0x00];
1046        let d = DroppedSections::scan(&bytes);
1047        assert_eq!(d.sparse_initializers, 1);
1048        assert!(d.any());
1049    }
1050
1051    #[test]
1052    fn dropped_scan_clean_model_reports_nothing() {
1053        use prost::Message;
1054        let proto = ModelProto {
1055            ir_version: 7,
1056            ..Default::default()
1057        };
1058        let mut buf = Vec::new();
1059        proto.encode(&mut buf).unwrap();
1060        let d = DroppedSections::scan(&buf);
1061        assert!(!d.any(), "clean model should drop nothing: {d:?}");
1062    }
1063}