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 < 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}