Skip to content

Commit a984aa7

Browse files
committed
feat(rust): add scalar quantizer bindings
1 parent d40ee2d commit a984aa7

4 files changed

Lines changed: 373 additions & 0 deletions

File tree

rust/cuvs/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod dlpack;
1818
mod error;
1919
pub mod ivf_flat;
2020
pub mod ivf_pq;
21+
pub mod preprocessing;
2122
mod resources;
2223
#[cfg(test)]
2324
pub(crate) mod test_utils;

rust/cuvs/src/preprocessing/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
//! Preprocessing utilities for cuVS datasets.
7+
//!
8+
//! Currently this exposes the [`quantize`] module, which provides quantizers
9+
//! that compress floating-point datasets into more compact representations.
10+
11+
pub mod quantize;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
//! Dataset quantizers.
7+
//!
8+
//! Quantizers compress a floating-point dataset into a more compact
9+
//! representation. The [`scalar`] quantizer maps an interval of the input
10+
//! float range onto the full range of an 8-bit integer.
11+
//!
12+
//! Train a [`scalar::Quantizer`] on a dataset, then
13+
//! [`transform`](scalar::Quantizer::transform) a float matrix into int8 and
14+
//! [`inverse_transform`](scalar::Quantizer::inverse_transform) it back to an
15+
//! approximation of the original. Tensors are passed through the
16+
//! [`AsDlTensor`](crate::AsDlTensor) /
17+
//! [`AsDlTensorMut`](crate::AsDlTensorMut) traits; see the
18+
//! [`dlpack`](crate::dlpack) module for the tensor model and `examples/cagra.rs`
19+
//! for a device-tensor adapter.
20+
//!
21+
//! The binary and product (PQ) quantizers exposed by the cuVS C API are not
22+
//! yet wrapped in Rust; they are intended to be added in follow-up
23+
//! contributions.
24+
25+
pub mod scalar;
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
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, &params, &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, &params, &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, &params, &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

Comments
 (0)