mirror of
https://github.com/qdrant/qdrant.git
synced 2026-07-31 15:11:35 -05:00
* apply p square * review remarks and reduce ram * review remarks * clean tmp logs * review remarks * review remarks * trigger ci
380 lines
14 KiB
Rust
380 lines
14 KiB
Rust
use std::sync::atomic::{AtomicBool, Ordering};
|
||
|
||
use permutation_iterator::Permutor;
|
||
use rayon::iter::{IndexedParallelIterator, IntoParallelRefMutIterator, ParallelIterator};
|
||
|
||
use crate::EncodingError;
|
||
use crate::p_square::P2Quantile;
|
||
|
||
pub const SAMPLE_SIZE: usize = 5_000;
|
||
/// P-square marker count. Original Jain & Chlamtac (1985) algorithm uses
|
||
/// 5; we run with the extended variant at 7 because Bits4 anchors on
|
||
/// `p = Φ(2.733) ≈ 0.997` and 5 markers don't track that tail accurately
|
||
/// enough on small samples (`n ≤ ~10³` failed `recall_skewed_data` on
|
||
/// Bits4 at N=5). The cost of the extra two markers is paid only on each
|
||
/// `push`, and on Bits4's deep-tail target the higher marker count is
|
||
/// what keeps the estimator stable at the chosen `sample_size`.
|
||
pub const P2_MARKERS: usize = 7;
|
||
|
||
pub(crate) fn find_min_max_from_iter<'a>(
|
||
iter: impl Iterator<Item = impl AsRef<[f32]> + 'a> + Clone,
|
||
) -> (f32, f32) {
|
||
iter.fold((f32::MAX, f32::MIN), |(mut min, mut max), vector| {
|
||
for &value in vector.as_ref() {
|
||
if value < min {
|
||
min = value;
|
||
}
|
||
if value > max {
|
||
max = value;
|
||
}
|
||
}
|
||
(min, max)
|
||
})
|
||
}
|
||
|
||
pub(crate) fn find_quantile_interval<'a>(
|
||
vector_data: impl Iterator<Item = impl AsRef<[f32]> + 'a> + Clone,
|
||
dim: usize,
|
||
count: usize,
|
||
quantile: f32,
|
||
stopped: &AtomicBool,
|
||
) -> Result<Option<(f32, f32)>, EncodingError> {
|
||
if count < 127 || quantile >= 1.0 {
|
||
return Ok(None);
|
||
}
|
||
|
||
let selected_vectors = take_random_vectors(vector_data, count, SAMPLE_SIZE, stopped)?;
|
||
let selected_vectors_count = selected_vectors.len();
|
||
let mut data_slice: Vec<f32> = Vec::with_capacity(selected_vectors_count * dim);
|
||
for vector in &selected_vectors {
|
||
if stopped.load(Ordering::Relaxed) {
|
||
return Err(EncodingError::Stopped);
|
||
}
|
||
|
||
data_slice.extend_from_slice(vector.as_ref());
|
||
}
|
||
|
||
let data_slice_len = data_slice.len();
|
||
if data_slice_len < 4 {
|
||
return Ok(None);
|
||
}
|
||
|
||
let cut_index = std::cmp::min(
|
||
(data_slice_len - 1) / 2,
|
||
(selected_vectors_count as f32 * (1.0 - quantile) / 2.0) as usize,
|
||
);
|
||
let cut_index = std::cmp::max(cut_index, 1);
|
||
let comparator = |a: &f32, b: &f32| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal);
|
||
let (selected_values, _, _) =
|
||
data_slice.select_nth_unstable_by(data_slice_len - cut_index, comparator);
|
||
let (_, _, selected_values) = selected_values.select_nth_unstable_by(cut_index, comparator);
|
||
|
||
if selected_values.len() < 2 {
|
||
return Ok(None);
|
||
}
|
||
|
||
let selected_values = [selected_values];
|
||
Ok(Some(find_min_max_from_iter(
|
||
selected_values.iter().map(|v| &v[..]),
|
||
)))
|
||
}
|
||
|
||
/// Quantile estimation via vector-level uniform random sampling, with
|
||
/// a fused per-vector preprocess.
|
||
///
|
||
/// Picks `sample_size` random indices in `[0, count)` (uniform without
|
||
/// replacement, sorted ascending), then drives the input iterator
|
||
/// forward with `iter.nth(skip)` to land exactly on each chosen index.
|
||
/// For mmap-backed `Range::map(get_dense)` style iterators — the common
|
||
/// case in segment storage — `nth(k)` advances the underlying `Range`
|
||
/// counter in O(1) and **does not invoke the closure for skipped
|
||
/// indices**. So we pay one `get_dense` (and one Hadamard rotation, and
|
||
/// one P-square push per coord) only for the `R = sample_size` vectors
|
||
/// we actually sampled, not for all `count` of them.
|
||
///
|
||
/// **Why sampling is sound here.** The variance of the per-coord
|
||
/// quantile estimator at probability `p` over `R` independent samples
|
||
/// is `p(1-p) / (R · f(F⁻¹(p))²)`. It depends on `R` only — reading
|
||
/// every one of `count` vectors gives no extra accuracy beyond an
|
||
/// `R`-sized i.i.d. sample, just at much higher I/O and compute cost.
|
||
///
|
||
/// **Pipeline shape.** Streaming, batched into `BATCH_SIZE` vectors at
|
||
/// a time:
|
||
///
|
||
/// ```text
|
||
/// 1. Sort R random indices.
|
||
/// 2. For each batch of up to BATCH_SIZE indices:
|
||
/// a. Sequential read+preprocess: iter.nth(skip) → preprocess
|
||
/// writes padded_dim f64s into row b of `batch_scratch`.
|
||
/// b. Parallel coord-chunk push: each rayon work item handles a
|
||
/// contiguous slice of coords and pushes all batch values for
|
||
/// those coords into both per-coord P-square estimators.
|
||
/// 3. Finalize: pair up the per-coord estimators and read out
|
||
/// `(p_lo, p_hi)`.
|
||
/// ```
|
||
///
|
||
/// No reservoir, no intermediate `Vec<Vec<f32>>`. The only working
|
||
/// buffer is `BATCH_SIZE × padded_dim × f64` (~192 KB at
|
||
/// padded_dim=1536) plus `2 × padded_dim × P2Quantile` (~200 KB) — both
|
||
/// independent of `R`.
|
||
///
|
||
/// `preprocess(raw, scratch)` must write exactly `padded_dim` finite
|
||
/// values into `scratch`. It runs on the main thread, so it does not
|
||
/// need `Send`/`Sync`.
|
||
///
|
||
/// `sample_size` controls how many random vectors are actually read
|
||
/// and processed. The caller picks this based on how extreme the
|
||
/// target quantile is — see `TQBits::sample_size` for the per-bits
|
||
/// table used in TQ+.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn find_quantile_interval_per_coordinate_with_preprocess<'a, F>(
|
||
data: impl Iterator<Item = impl AsRef<[f32]> + 'a>,
|
||
raw_dim: usize,
|
||
padded_dim: usize,
|
||
count: usize,
|
||
quantile: f32,
|
||
num_threads: usize,
|
||
sample_size: usize,
|
||
preprocess: F,
|
||
stopped: &AtomicBool,
|
||
) -> Result<Vec<(f32, f32)>, EncodingError>
|
||
where
|
||
F: Fn(&[f32], &mut [f64]) + Send + Sync + 'a,
|
||
{
|
||
debug_assert!(quantile > 0.5 && quantile < 1.0);
|
||
debug_assert!(raw_dim > 0 && padded_dim > 0);
|
||
debug_assert!(sample_size > 0);
|
||
|
||
// Empty-input fast path: no quantile to estimate. Match the legacy
|
||
// contract of returning a `padded_dim`-wide vector of zero pairs so
|
||
// downstream `shift = 0`, `scale = 1` and EC is identity.
|
||
if count == 0 {
|
||
return Ok(vec![(0.0, 0.0); padded_dim]);
|
||
}
|
||
|
||
let min_quantile = (1.0 - f64::from(quantile)) / 2.0;
|
||
let max_quantile = 1.0 - min_quantile;
|
||
|
||
// Random sample of `R` indices in `[0, count)`, sorted ascending so
|
||
// the iterator can step through them with monotonic `nth(skip)`
|
||
// calls. `Permutor` produces a deterministic permutation per
|
||
// `count` — same input collection produces the same sample, which
|
||
// is what we want for reproducible recall across runs.
|
||
let actual_sample = sample_size.min(count);
|
||
let mut indices: Vec<usize> = Permutor::new(count as u64)
|
||
.map(|i| i as usize)
|
||
.take(actual_sample)
|
||
.collect();
|
||
indices.sort_unstable();
|
||
|
||
// One pair of P-square estimators per coord. The interleaved
|
||
// `(min, max)` layout lets us iterate them as a single contiguous
|
||
// slice during the per-vector parallel push.
|
||
//
|
||
// Memory: `padded_dim × 2 × sizeof(P2Quantile)`, ~200 KB at
|
||
// padded_dim=1536 — independent of `sample_size`. The previous
|
||
// implementation kept an intermediate `Vec<f32>` of
|
||
// `sample_size × max(raw_dim, padded_dim)` (≈12 MB at 2k samples,
|
||
// up to ≈50 MB at 8k); streaming push removes that buffer entirely.
|
||
let mut estimators: Vec<(P2Quantile<P2_MARKERS>, P2Quantile<P2_MARKERS>)> = (0..padded_dim)
|
||
.map(|_| {
|
||
Ok::<_, EncodingError>((
|
||
P2Quantile::<P2_MARKERS>::new(min_quantile)?,
|
||
P2Quantile::<P2_MARKERS>::new(max_quantile)?,
|
||
))
|
||
})
|
||
.collect::<Result<_, _>>()?;
|
||
|
||
let pool = rayon::ThreadPoolBuilder::new()
|
||
.thread_name(|idx| format!("tq-prepass-{idx}"))
|
||
.num_threads(num_threads.max(1))
|
||
.build()
|
||
.map_err(|e| {
|
||
EncodingError::EncodingError(format!(
|
||
"Failed quantile pre-pass while thread pool init: {e}"
|
||
))
|
||
})?;
|
||
|
||
// Streaming pass with small batches. Per batch:
|
||
// 1. Read + preprocess `BATCH_SIZE` vectors sequentially into
|
||
// `batch_scratch[BATCH_SIZE * padded_dim]` (row-major: row `b`
|
||
// holds the rotated coords of the `b`-th vector in this batch).
|
||
// 2. Parallel coord-chunk push: each rayon work item handles a
|
||
// contiguous slice of coords and pushes all `BATCH_SIZE` values
|
||
// for those coords across both estimators.
|
||
//
|
||
// Why batching: the previous "push one vector at a time" form spent
|
||
// most time on rayon dispatch — `par_iter_mut().for_each(...)` invoked
|
||
// once per vector ran 24 work-units × 2k vectors = 50k dispatch events
|
||
// on Bits1, ~200k on Bits4. Batching at B=16 amortizes that cost over
|
||
// the batch (3.1k dispatches on Bits1, 12.5k on Bits4), with minimal
|
||
// memory impact: `batch_scratch` is `BATCH_SIZE × padded_dim × f64`
|
||
// ≈ 192 KB at padded_dim=1536, fits comfortably in L2.
|
||
//
|
||
// The strided column read inside the push closure
|
||
// (`batch_scratch[b * padded_dim + d]` across `b`) is non-contiguous,
|
||
// but each coord's column footprint is `BATCH_SIZE × 8 B` = 128 B, so
|
||
// the worker keeps the relevant cache lines hot for the full chunk.
|
||
const BATCH_SIZE: usize = 16;
|
||
const PUSH_MIN_CHUNK: usize = 64;
|
||
let mut batch_scratch = vec![0.0f64; BATCH_SIZE * padded_dim];
|
||
|
||
let mut data = data;
|
||
let mut cursor = 0usize;
|
||
let mut idx_iter = indices.iter().copied();
|
||
loop {
|
||
// Fill batch sequentially.
|
||
let mut filled = 0usize;
|
||
while filled < BATCH_SIZE {
|
||
let Some(idx) = idx_iter.next() else { break };
|
||
if stopped.load(Ordering::Relaxed) {
|
||
return Err(EncodingError::Stopped);
|
||
}
|
||
debug_assert!(idx >= cursor);
|
||
let skip = idx - cursor;
|
||
let v = data.nth(skip).ok_or_else(|| {
|
||
EncodingError::EncodingError(format!(
|
||
"input iterator exhausted at sampled index {idx} (count={count})",
|
||
))
|
||
})?;
|
||
let v_ref = v.as_ref();
|
||
// A short vector would leave part of `row` untouched when
|
||
// `preprocess` only writes to the first `v_ref.len()` slots,
|
||
// and the leftover f64s from the previous batch would land
|
||
// in the P-square estimators. Bail out with a real error.
|
||
if v_ref.len() != raw_dim {
|
||
return Err(EncodingError::EncodingError(format!(
|
||
"input vector dim mismatch at sampled index {idx}: expected {raw_dim}, got {}",
|
||
v_ref.len(),
|
||
)));
|
||
}
|
||
let row = &mut batch_scratch[filled * padded_dim..(filled + 1) * padded_dim];
|
||
preprocess(v_ref, row);
|
||
cursor = idx + 1;
|
||
filled += 1;
|
||
}
|
||
if filled == 0 {
|
||
break;
|
||
}
|
||
|
||
// Parallel coord-chunk push for the just-filled batch.
|
||
let batch_view = &batch_scratch[..filled * padded_dim];
|
||
pool.install(|| {
|
||
estimators
|
||
.par_iter_mut()
|
||
.enumerate()
|
||
.with_min_len(PUSH_MIN_CHUNK)
|
||
.for_each(|(d, (min_q, max_q))| {
|
||
for b in 0..filled {
|
||
let v = batch_view[b * padded_dim + d];
|
||
min_q.push(v);
|
||
max_q.push(v);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
let intervals: Vec<(f32, f32)> = estimators
|
||
.into_iter()
|
||
.map(|(min_q, max_q)| (min_q.estimate() as f32, max_q.estimate() as f32))
|
||
.collect();
|
||
|
||
Ok(intervals)
|
||
}
|
||
|
||
// Take random vectors from the input iterator using `Permutor`.
|
||
fn take_random_vectors<'a>(
|
||
vector_data: impl Iterator<Item = impl AsRef<[f32]> + 'a>,
|
||
count: usize,
|
||
sample_size: usize,
|
||
stopped: &AtomicBool,
|
||
) -> Result<Vec<impl AsRef<[f32]> + 'a>, EncodingError> {
|
||
let slice_size = std::cmp::min(count, sample_size);
|
||
let permutor = Permutor::new(count as u64);
|
||
let mut selected_vectors: Vec<usize> = permutor.map(|i| i as usize).take(slice_size).collect();
|
||
selected_vectors.sort_unstable();
|
||
|
||
let mut data_slice = Vec::with_capacity(slice_size);
|
||
let mut selected_index: usize = 0;
|
||
for (vector_index, vector_data) in vector_data.into_iter().enumerate() {
|
||
if stopped.load(Ordering::Relaxed) {
|
||
return Err(EncodingError::Stopped);
|
||
}
|
||
|
||
if vector_index == selected_vectors[selected_index] {
|
||
data_slice.push(vector_data);
|
||
selected_index += 1;
|
||
if selected_index == slice_size {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(data_slice)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use rand::rngs::StdRng;
|
||
use rand::{RngExt, SeedableRng};
|
||
use rstest::rstest;
|
||
|
||
use super::*;
|
||
|
||
/// Per-coord quantile recovery on uniform `[0, 1)` data.
|
||
///
|
||
/// The empirical quantile of `Uniform(0, 1)` at probability `p` is
|
||
/// `p`, so the symmetric interval at `q` is `((1-q)/2, 1-(1-q)/2)`.
|
||
/// Identity preprocess (f32 → f64 widening) lets us drive the same
|
||
/// pipeline TQ+ uses, without rotation.
|
||
#[rstest]
|
||
#[case(0.95, 2)]
|
||
#[case(0.95, 4)]
|
||
fn test_quantile_interval_per_coord(#[case] quantile: f32, #[case] num_threads: usize) {
|
||
const COUNT: usize = 5_000;
|
||
const DIM: usize = 4;
|
||
const SAMPLE: usize = 2_048;
|
||
|
||
let mut rng = StdRng::seed_from_u64(42);
|
||
let mut data = Vec::with_capacity(COUNT);
|
||
for _ in 0..COUNT {
|
||
let mut vector = Vec::with_capacity(DIM);
|
||
for _ in 0..DIM {
|
||
vector.push(rng.random::<f32>());
|
||
}
|
||
data.push(vector);
|
||
}
|
||
|
||
let per_coordinate = find_quantile_interval_per_coordinate_with_preprocess(
|
||
data.iter(),
|
||
DIM,
|
||
DIM,
|
||
COUNT,
|
||
quantile,
|
||
num_threads,
|
||
SAMPLE,
|
||
|raw, scratch| {
|
||
for (s, &v) in scratch.iter_mut().zip(raw.iter()) {
|
||
*s = f64::from(v);
|
||
}
|
||
},
|
||
&AtomicBool::new(false),
|
||
)
|
||
.unwrap();
|
||
|
||
let acc = 0.05;
|
||
let min_result = (1.0 - quantile) / 2.0;
|
||
let max_result = 1.0 - min_result;
|
||
for (min, max) in per_coordinate {
|
||
assert!(
|
||
(min - min_result).abs() < acc,
|
||
"Min value is out of expected range: got {min}, expected ~{min_result}"
|
||
);
|
||
assert!(
|
||
(max - max_result).abs() < acc,
|
||
"Max value is out of expected range: got {max}, expected ~{max_result}"
|
||
);
|
||
}
|
||
}
|
||
}
|