use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
#[cfg(feature = "calibration")]
use crate::calibration::{methods::CalibrationMethod, ActivationEstimator, CalibrationDataset};
use crate::onnx_utils::graph_builder::QdqWeightInput;
use crate::onnx_utils::{OnnxModel, SaveOptions};
use crate::quantization::{QuantConfig, Quantizer};
#[pyclass]
#[derive(Clone)]
struct ModelInfo {
#[pyo3(get)]
name: String,
#[pyo3(get)]
version: i64,
#[pyo3(get)]
opset_version: i64,
#[pyo3(get)]
num_nodes: usize,
#[pyo3(get)]
inputs: Vec<String>,
#[pyo3(get)]
outputs: Vec<String>,
}
#[pyfunction]
#[pyo3(signature = (input_path, output_path, bits=8, per_channel=false, excluded_layers=None, min_elements=0, layer_bits=None, native_int4=false, symmetric=false))]
#[allow(clippy::too_many_arguments)]
fn quantize(
py: Python<'_>,
input_path: &str,
output_path: &str,
bits: u8,
per_channel: bool,
excluded_layers: Option<Vec<String>>,
min_elements: usize,
layer_bits: Option<std::collections::HashMap<String, u8>>,
native_int4: bool,
symmetric: bool,
) -> PyResult<()> {
if bits != 4 && bits != 8 {
return Err(PyValueError::new_err(format!(
"bits must be 4 or 8, got {}",
bits
)));
}
py.allow_threads(|| -> PyResult<()> {
let mut model = OnnxModel::load(input_path)
.map_err(|e| PyRuntimeError::new_err(format!("Failed to load model: {}", e)))?;
let weights = model.extract_weights();
if weights.is_empty() {
let external = model.count_external_data_initializers();
let non_fp32 = model.count_non_fp32_weight_initializers();
return Err(PyRuntimeError::new_err(if external > 0 {
format!(
"no inline FP32 weight tensors found: {} initializer(s) store their data in \
an external file (ONNX external-data format); quantize-rs reads only inline \
tensors — re-save with weights embedded (onnx.load(path, \
load_external_data=True) then onnx.save without external data) and retry",
external
)
} else if non_fp32 > 0 {
format!(
"no FP32 weight tensors found, but the model has {} non-FP32 \
weight-shaped initializer(s) (FP16/BF16/Double); quantize-rs \
supports only FP32 input — convert the model to FP32 first",
non_fp32
)
} else {
"no weight tensors found — model may be empty or already quantized".to_string()
}));
}
let config = QuantConfig {
bits,
per_channel,
symmetric,
calibration_method: None,
excluded_layers: excluded_layers.unwrap_or_default(),
min_elements,
layer_bits: layer_bits.unwrap_or_default(),
};
let outputs = Quantizer::new(config)
.quantize_weights(&weights)
.map_err(|e| PyRuntimeError::new_err(format!("Quantization failed: {}", e)))?;
if outputs.is_empty() {
return Err(PyRuntimeError::new_err(
"all weight tensors were filtered out by excluded_layers / min_elements / \
layer_bits; nothing to quantize",
));
}
let quantized_data: Vec<QdqWeightInput> = outputs.into_iter().map(|o| o.qdq).collect();
let save_options = SaveOptions::default().with_native_int4(native_int4);
model
.save_quantized_with_options(&quantized_data, output_path, save_options)
.map_err(|e| PyRuntimeError::new_err(format!("Failed to save model: {}", e)))?;
Ok(())
})
}
#[cfg(feature = "calibration")]
#[pyfunction]
#[pyo3(signature = (
input_path,
output_path,
calibration_data=None,
bits=8,
per_channel=false,
method="minmax",
num_samples=100,
sample_shape=None,
native_int4=false,
symmetric=false,
))]
#[allow(clippy::too_many_arguments)]
fn quantize_with_calibration(
py: Python<'_>,
input_path: &str,
output_path: &str,
calibration_data: Option<&str>,
bits: u8,
per_channel: bool,
method: &str,
num_samples: usize,
sample_shape: Option<Vec<usize>>,
native_int4: bool,
symmetric: bool,
) -> PyResult<()> {
if bits != 4 && bits != 8 {
return Err(PyValueError::new_err(format!(
"bits must be 4 or 8, got {}",
bits
)));
}
let calib_method: CalibrationMethod = method
.parse()
.map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;
py.allow_threads(|| -> PyResult<()> {
let model = OnnxModel::load(input_path)
.map_err(|e| PyRuntimeError::new_err(format!("Failed to load model: {}", e)))?;
let dataset = if let Some(path) = calibration_data {
CalibrationDataset::from_numpy(path).map_err(|e| {
PyRuntimeError::new_err(format!("Failed to load calibration data: {}", e))
})?
} else {
let shape = if let Some(s) = sample_shape {
s
} else {
model
.input_shapes()
.into_iter()
.next()
.and_then(|dims| {
let sample_dims: &[i64] = if dims.len() >= 2 { &dims[1..] } else { &dims };
let shape: Vec<usize> = sample_dims
.iter()
.filter_map(|&d| if d > 0 { Some(d as usize) } else { None })
.collect();
if shape.is_empty() {
None
} else {
Some(shape)
}
})
.unwrap_or_else(|| vec![3, 224, 224])
};
CalibrationDataset::random(shape, num_samples, (0.0, 1.0)).map_err(|e| {
PyRuntimeError::new_err(format!("Failed to create random dataset: {}", e))
})?
};
let mut estimator = ActivationEstimator::new(model, input_path)
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create estimator: {}", e)))?;
estimator
.calibrate_quiet(&dataset)
.map_err(|e| PyRuntimeError::new_err(format!("Calibration failed: {}", e)))?;
let activation_stats: std::collections::HashMap<String, _> = estimator
.get_layer_stats()
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect();
let mut model = estimator.into_model();
let config = QuantConfig {
bits,
per_channel,
symmetric,
calibration_method: Some(calib_method),
..Default::default()
};
let outputs = Quantizer::with_calibration(config, activation_stats)
.quantize_model(&model)
.map_err(|e| PyRuntimeError::new_err(format!("Quantization failed: {}", e)))?;
if outputs.is_empty() {
return Err(PyRuntimeError::new_err(
"all weight tensors were filtered out by excluded_layers / min_elements / \
layer_bits; nothing to quantize",
));
}
let quantized_data: Vec<QdqWeightInput> = outputs.into_iter().map(|o| o.qdq).collect();
let save_options = SaveOptions::default().with_native_int4(native_int4);
model
.save_quantized_with_options(&quantized_data, output_path, save_options)
.map_err(|e| PyRuntimeError::new_err(format!("Failed to save model: {}", e)))?;
Ok(())
})
}
#[pyfunction]
fn model_info(input_path: &str) -> PyResult<ModelInfo> {
let model = OnnxModel::load(input_path)
.map_err(|e| PyRuntimeError::new_err(format!("Failed to load model: {}", e)))?;
let info = model.info();
Ok(ModelInfo {
name: info.name,
version: info.version,
opset_version: info.opset_version,
num_nodes: info.num_nodes,
inputs: info.inputs,
outputs: info.outputs,
})
}
#[pymodule]
fn quantize_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
let _ = pyo3_log::try_init();
m.add_function(wrap_pyfunction!(quantize, m)?)?;
#[cfg(feature = "calibration")]
m.add_function(wrap_pyfunction!(quantize_with_calibration, m)?)?;
m.add_function(wrap_pyfunction!(model_info, m)?)?;
m.add_class::<ModelInfo>()?;
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
Ok(())
}