|
| 1 | +/* |
| 2 | + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 3 | + * SPDX-License-Identifier: Apache-2.0 |
| 4 | + */ |
| 5 | + |
| 6 | +//! Scalar quantizer. |
| 7 | +//! |
| 8 | +//! The scalar quantizer performs a linear mapping of an interval of the input |
| 9 | +//! float range onto the full range of an 8-bit integer. The interval is |
| 10 | +//! derived during [`Quantizer::train`] from the dataset, optionally clipping a |
| 11 | +//! configurable fraction of outliers (see |
| 12 | +//! [`ScalarQuantizerParams::set_quantile`]). |
| 13 | +
|
| 14 | +use std::fmt; |
| 15 | +use std::io::{Write, stderr}; |
| 16 | + |
| 17 | +use crate::dlpack::{AsDlTensor, AsDlTensorMut, DLTensorView}; |
| 18 | +use crate::error::{Error, Result, check_cuvs}; |
| 19 | +use crate::resources::Resources; |
| 20 | + |
| 21 | +/// The C API reinterprets `i8` buffers without validating dtype; guard |
| 22 | +/// Rust-side so a wrong-dtype tensor surfaces as `InvalidArgument` instead |
| 23 | +/// of memory corruption. |
| 24 | +fn expect_i8_view(view: &DLTensorView, arg: &str) -> Result<()> { |
| 25 | + let dtype = view.dtype(); |
| 26 | + if dtype.code != ffi::DLDataTypeCode::kDLInt as u8 || dtype.bits != 8 || dtype.lanes != 1 { |
| 27 | + return Err(Error::InvalidArgument(format!( |
| 28 | + "{arg} must be an i8 tensor (got code={}, bits={}, lanes={})", |
| 29 | + dtype.code, dtype.bits, dtype.lanes |
| 30 | + ))); |
| 31 | + } |
| 32 | + Ok(()) |
| 33 | +} |
| 34 | + |
| 35 | +/// Parameters controlling how a [`Quantizer`] is trained. |
| 36 | +pub struct ScalarQuantizerParams(pub ffi::cuvsScalarQuantizerParams_t); |
| 37 | + |
| 38 | +impl ScalarQuantizerParams { |
| 39 | + /// Returns a new `ScalarQuantizerParams` populated with default values. |
| 40 | + pub fn new() -> Result<ScalarQuantizerParams> { |
| 41 | + unsafe { |
| 42 | + let mut params = std::mem::MaybeUninit::<ffi::cuvsScalarQuantizerParams_t>::uninit(); |
| 43 | + check_cuvs(ffi::cuvsScalarQuantizerParamsCreate(params.as_mut_ptr()))?; |
| 44 | + Ok(ScalarQuantizerParams(params.assume_init())) |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + /// Sets the fraction of the data that is kept once outliers at the top and |
| 49 | + /// bottom of the distribution have been ignored. |
| 50 | + /// |
| 51 | + /// Must be within the range `(0, 1]`. The default is `0.99`. |
| 52 | + pub fn set_quantile(self, quantile: f32) -> ScalarQuantizerParams { |
| 53 | + unsafe { |
| 54 | + (*self.0).quantile = quantile; |
| 55 | + } |
| 56 | + self |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +impl fmt::Debug for ScalarQuantizerParams { |
| 61 | + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { |
| 62 | + // custom debug impl: the default would just print the raw pointer |
| 63 | + write!(f, "ScalarQuantizerParams({:?})", unsafe { *self.0 }) |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +impl Drop for ScalarQuantizerParams { |
| 68 | + fn drop(&mut self) { |
| 69 | + if let Err(e) = check_cuvs(unsafe { ffi::cuvsScalarQuantizerParamsDestroy(self.0) }) { |
| 70 | + let _ = write!(stderr(), "failed to call cuvsScalarQuantizerParamsDestroy {:?}", e); |
| 71 | + } |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +/// A trained scalar quantizer. |
| 76 | +/// |
| 77 | +/// Build one with [`Quantizer::train`], then use [`Quantizer::transform`] to |
| 78 | +/// quantize a float dataset into int8 and [`Quantizer::inverse_transform`] to |
| 79 | +/// reconstruct an approximation of the original float values. |
| 80 | +#[derive(Debug)] |
| 81 | +pub struct Quantizer(ffi::cuvsScalarQuantizer_t); |
| 82 | + |
| 83 | +impl Quantizer { |
| 84 | + /// Creates a new, untrained quantizer. |
| 85 | + fn new() -> Result<Quantizer> { |
| 86 | + unsafe { |
| 87 | + let mut quantizer = std::mem::MaybeUninit::<ffi::cuvsScalarQuantizer_t>::uninit(); |
| 88 | + check_cuvs(ffi::cuvsScalarQuantizerCreate(quantizer.as_mut_ptr()))?; |
| 89 | + Ok(Quantizer(quantizer.assume_init())) |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + /// Trains a scalar quantizer on `dataset` for later use in quantizing data. |
| 94 | + /// |
| 95 | + /// `dataset` is a row-major `f32`, `f16`, or `f64` matrix on the host or |
| 96 | + /// device implementing [`AsDlTensor`]. |
| 97 | + /// |
| 98 | + /// # Arguments |
| 99 | + /// |
| 100 | + /// * `res` - Resources to use |
| 101 | + /// * `params` - Parameters controlling the quantization (e.g. quantile) |
| 102 | + /// * `dataset` - The training dataset |
| 103 | + pub fn train<T>( |
| 104 | + res: &Resources, |
| 105 | + params: &ScalarQuantizerParams, |
| 106 | + dataset: &T, |
| 107 | + ) -> Result<Quantizer> |
| 108 | + where |
| 109 | + T: AsDlTensor + ?Sized, |
| 110 | + { |
| 111 | + let dataset = dataset.as_dl_tensor()?; |
| 112 | + let quantizer = Quantizer::new()?; |
| 113 | + unsafe { |
| 114 | + check_cuvs(ffi::cuvsScalarQuantizerTrain( |
| 115 | + res.0, |
| 116 | + params.0, |
| 117 | + dataset.to_c().as_mut_ptr(), |
| 118 | + quantizer.0, |
| 119 | + ))?; |
| 120 | + } |
| 121 | + Ok(quantizer) |
| 122 | + } |
| 123 | + |
| 124 | + /// Quantizes `dataset` into `out`. |
| 125 | + /// |
| 126 | + /// `dataset` and `out` implement [`AsDlTensor`] / |
| 127 | + /// [`AsDlTensorMut`]; `out` is written in place. |
| 128 | + /// |
| 129 | + /// # Arguments |
| 130 | + /// |
| 131 | + /// * `res` - Resources to use |
| 132 | + /// * `dataset` - A row-major `f32`, `f16`, or `f64` matrix to quantize, shape `(m, n)` |
| 133 | + /// * `out` - A row-major `i8` matrix that receives the quantized data, shape `(m, n)` |
| 134 | + /// — the output dtype must be `i8`: the C API does not validate it and will |
| 135 | + /// reinterpret the buffer otherwise (unlike `inverse_transform`, whose output |
| 136 | + /// dtype is validated) |
| 137 | + pub fn transform<D, O>(&self, res: &Resources, dataset: &D, out: &mut O) -> Result<()> |
| 138 | + where |
| 139 | + D: AsDlTensor + ?Sized, |
| 140 | + O: AsDlTensorMut + ?Sized, |
| 141 | + { |
| 142 | + let dataset = dataset.as_dl_tensor()?; |
| 143 | + let out = out.as_dl_tensor_mut()?; |
| 144 | + expect_i8_view(&out, "transform output")?; |
| 145 | + unsafe { |
| 146 | + check_cuvs(ffi::cuvsScalarQuantizerTransform( |
| 147 | + res.0, |
| 148 | + self.0, |
| 149 | + dataset.to_c().as_mut_ptr(), |
| 150 | + out.to_c().as_mut_ptr(), |
| 151 | + )) |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + /// Reconstructs an approximation of the original float dataset from |
| 156 | + /// previously quantized data. |
| 157 | + /// |
| 158 | + /// Note that scalar quantization is lossy, so the reconstructed values only |
| 159 | + /// approximate the originals. `dataset` and `out` implement |
| 160 | + /// [`AsDlTensor`] / [`AsDlTensorMut`]; |
| 161 | + /// `out` is written in place. |
| 162 | + /// |
| 163 | + /// # Arguments |
| 164 | + /// |
| 165 | + /// * `res` - Resources to use |
| 166 | + /// * `dataset` - A row-major `i8` matrix of quantized data, shape `(m, n)` |
| 167 | + /// * `out` - A row-major `f32` matrix that receives the reconstructed data, shape `(m, n)` |
| 168 | + pub fn inverse_transform<D, O>(&self, res: &Resources, dataset: &D, out: &mut O) -> Result<()> |
| 169 | + where |
| 170 | + D: AsDlTensor + ?Sized, |
| 171 | + O: AsDlTensorMut + ?Sized, |
| 172 | + { |
| 173 | + let dataset = dataset.as_dl_tensor()?; |
| 174 | + expect_i8_view(&dataset, "inverse_transform input")?; |
| 175 | + let out = out.as_dl_tensor_mut()?; |
| 176 | + unsafe { |
| 177 | + check_cuvs(ffi::cuvsScalarQuantizerInverseTransform( |
| 178 | + res.0, |
| 179 | + self.0, |
| 180 | + dataset.to_c().as_mut_ptr(), |
| 181 | + out.to_c().as_mut_ptr(), |
| 182 | + )) |
| 183 | + } |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +impl Drop for Quantizer { |
| 188 | + fn drop(&mut self) { |
| 189 | + if let Err(e) = check_cuvs(unsafe { ffi::cuvsScalarQuantizerDestroy(self.0) }) { |
| 190 | + let _ = write!(stderr(), "failed to call cuvsScalarQuantizerDestroy {:?}", e); |
| 191 | + } |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +#[cfg(test)] |
| 196 | +mod tests { |
| 197 | + use super::*; |
| 198 | + use crate::test_utils::DeviceTensor; |
| 199 | + use ndarray_rand::RandomExt; |
| 200 | + use ndarray_rand::rand_distr::Uniform; |
| 201 | + |
| 202 | + #[test] |
| 203 | + fn test_scalar_quantizer_params() { |
| 204 | + let params = ScalarQuantizerParams::new().unwrap().set_quantile(0.95); |
| 205 | + |
| 206 | + // make sure the setter actually updated the internal c-struct |
| 207 | + unsafe { |
| 208 | + assert_eq!((*params.0).quantile, 0.95); |
| 209 | + } |
| 210 | + } |
| 211 | + |
| 212 | + #[test] |
| 213 | + fn test_scalar_quantizer_roundtrip() { |
| 214 | + let res = Resources::new().unwrap(); |
| 215 | + |
| 216 | + // Create a random dataset to quantize. The data range is [0, 10), so |
| 217 | + // the int8 quantization step is roughly 10 / 256 ~= 0.04. |
| 218 | + let n_rows = 1024; |
| 219 | + let n_cols = 16; |
| 220 | + let data_lo = 0.0f32; |
| 221 | + let data_hi = 10.0f32; |
| 222 | + let dataset = ndarray::Array::<f32, _>::random( |
| 223 | + (n_rows, n_cols), |
| 224 | + Uniform::new(data_lo, data_hi).unwrap(), |
| 225 | + ); |
| 226 | + let dataset_device = DeviceTensor::from_host(&res, &dataset).unwrap(); |
| 227 | + |
| 228 | + // Train the quantizer (use the full range so we don't clip outliers). |
| 229 | + let params = ScalarQuantizerParams::new().unwrap().set_quantile(1.0); |
| 230 | + let quantizer = Quantizer::train(&res, ¶ms, &dataset_device).unwrap(); |
| 231 | + |
| 232 | + // Quantize the dataset into int8. |
| 233 | + let mut quantized_host = ndarray::Array::<i8, _>::zeros((n_rows, n_cols)); |
| 234 | + let mut quantized = DeviceTensor::<i8>::zeros(&res, &[n_rows, n_cols]).unwrap(); |
| 235 | + quantizer.transform(&res, &dataset_device, &mut quantized).unwrap(); |
| 236 | + quantized.copy_to_host(&res, &mut quantized_host).unwrap(); |
| 237 | + |
| 238 | + // The quantized values should span a good chunk of the int8 range, |
| 239 | + // confirming the transform actually did something. |
| 240 | + let q_min = *quantized_host.iter().min().unwrap(); |
| 241 | + let q_max = *quantized_host.iter().max().unwrap(); |
| 242 | + assert!( |
| 243 | + q_max as i32 - q_min as i32 > 200, |
| 244 | + "quantized values should span most of the int8 range, got [{q_min}, {q_max}]" |
| 245 | + ); |
| 246 | + |
| 247 | + // Reconstruct an approximation of the original f32 values. |
| 248 | + let mut reconstructed_host = ndarray::Array::<f32, _>::zeros((n_rows, n_cols)); |
| 249 | + let mut reconstructed = DeviceTensor::<f32>::zeros(&res, &[n_rows, n_cols]).unwrap(); |
| 250 | + quantizer.inverse_transform(&res, &quantized, &mut reconstructed).unwrap(); |
| 251 | + reconstructed.copy_to_host(&res, &mut reconstructed_host).unwrap(); |
| 252 | + |
| 253 | + // Compute the max absolute reconstruction error. It should be bounded |
| 254 | + // by a few quantization steps and far below the data range. |
| 255 | + let mut max_abs_err = 0.0f32; |
| 256 | + for (orig, recon) in dataset.iter().zip(reconstructed_host.iter()) { |
| 257 | + let err = (orig - recon).abs(); |
| 258 | + if err > max_abs_err { |
| 259 | + max_abs_err = err; |
| 260 | + } |
| 261 | + } |
| 262 | + |
| 263 | + let data_range = data_hi - data_lo; |
| 264 | + // A loose epsilon: a handful of quantization steps. One step is |
| 265 | + // data_range / 256 ~= 0.04; allow up to ~5 steps of slack. |
| 266 | + let epsilon = data_range / 50.0; |
| 267 | + assert!( |
| 268 | + max_abs_err < epsilon, |
| 269 | + "max abs reconstruction error {max_abs_err} should be below {epsilon}" |
| 270 | + ); |
| 271 | + assert!( |
| 272 | + max_abs_err < data_range * 0.05, |
| 273 | + "max abs reconstruction error {max_abs_err} should be far below data range {data_range}" |
| 274 | + ); |
| 275 | + } |
| 276 | + |
| 277 | + #[test] |
| 278 | + fn test_train_unsupported_dtype_errors() { |
| 279 | + let res = Resources::new().unwrap(); |
| 280 | + |
| 281 | + // The C API only supports float (16/32/64-bit) training datasets, and |
| 282 | + // surfaces an integer dataset as an error rather than silently |
| 283 | + // succeeding. (Note: a freshly created, untrained quantizer has |
| 284 | + // min_ == max_ == 0, which produces degenerate output but is *not* |
| 285 | + // reported as an error by the C API, so we exercise the dtype guard |
| 286 | + // instead to cover the error path.) |
| 287 | + let n_rows = 8; |
| 288 | + let n_cols = 4; |
| 289 | + let dataset = ndarray::Array::<i32, _>::zeros((n_rows, n_cols)); |
| 290 | + let dataset_device = DeviceTensor::from_host(&res, &dataset).unwrap(); |
| 291 | + |
| 292 | + let params = ScalarQuantizerParams::new().unwrap(); |
| 293 | + let result = Quantizer::train(&res, ¶ms, &dataset_device); |
| 294 | + assert!( |
| 295 | + result.is_err(), |
| 296 | + "training on an unsupported (integer) dtype should return an error" |
| 297 | + ); |
| 298 | + } |
| 299 | + |
| 300 | + #[test] |
| 301 | + fn test_transform_rejects_non_i8_output() { |
| 302 | + let res = Resources::new().unwrap(); |
| 303 | + let n_rows = 8; |
| 304 | + let n_cols = 4; |
| 305 | + |
| 306 | + let dataset = ndarray::Array::<f32, _>::zeros((n_rows, n_cols)); |
| 307 | + let mut dataset_device = DeviceTensor::from_host(&res, &dataset).unwrap(); |
| 308 | + let params = ScalarQuantizerParams::new().unwrap(); |
| 309 | + let quantizer = Quantizer::train(&res, ¶ms, &dataset_device).unwrap(); |
| 310 | + |
| 311 | + // The C API would silently reinterpret a non-i8 output buffer; |
| 312 | + // the wrapper must reject it before any FFI happens. |
| 313 | + let bad_out = ndarray::Array::<f32, _>::zeros((n_rows, n_cols)); |
| 314 | + let mut bad_out_device = DeviceTensor::from_host(&res, &bad_out).unwrap(); |
| 315 | + let result = quantizer.transform(&res, &dataset_device, &mut bad_out_device); |
| 316 | + assert!( |
| 317 | + matches!( |
| 318 | + &result, |
| 319 | + Err(Error::InvalidArgument(msg)) |
| 320 | + if msg.contains("transform output") && msg.contains("i8 tensor") |
| 321 | + ), |
| 322 | + "transform must reject a non-i8 output tensor via the dtype guard, got {result:?}" |
| 323 | + ); |
| 324 | + |
| 325 | + // Same guard on the inverse path's input. |
| 326 | + let result = quantizer.inverse_transform(&res, &bad_out_device, &mut dataset_device); |
| 327 | + assert!( |
| 328 | + matches!( |
| 329 | + &result, |
| 330 | + Err(Error::InvalidArgument(msg)) |
| 331 | + if msg.contains("inverse_transform input") && msg.contains("i8 tensor") |
| 332 | + ), |
| 333 | + "inverse_transform must reject a non-i8 input tensor via the dtype guard, got {result:?}" |
| 334 | + ); |
| 335 | + } |
| 336 | +} |
0 commit comments