diff --git a/Cargo.lock b/Cargo.lock index d849376..211746b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2218,6 +2218,7 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" name = "vecq-bench" version = "0.2.0" dependencies = [ + "memmap2", "rabitq-rs", "turboquant", "usearch", diff --git a/crates/vecq-bench/Cargo.toml b/crates/vecq-bench/Cargo.toml index 8f2b19b..71ad7aa 100644 --- a/crates/vecq-bench/Cargo.toml +++ b/crates/vecq-bench/Cargo.toml @@ -14,6 +14,8 @@ vecq-core = { path = "../vecq-core" } # Bench-only deps: vecq-core itself remains zero-dependency. turboquant = "0.1" rabitq-rs = "0.9" +# mmap cold-start harness (bin/view_mmap) — core stays dependency-free. +memmap2 = "0.9" [[bin]] name = "vecq-bench" diff --git a/crates/vecq-bench/src/bin/view_mmap.rs b/crates/vecq-bench/src/bin/view_mmap.rs new file mode 100644 index 0000000..94aaa62 --- /dev/null +++ b/crates/vecq-bench/src/bin/view_mmap.rs @@ -0,0 +1,130 @@ +//! mmap cold-start harness for #25: time-to-first-query for a full load +//! (`VecqIndex::from_bytes`) vs a zero-copy view (`VecqView` over +//! `memmap2::Mmap`) at growing collection sizes. +//! +//! Run after generating the dataset (see BENCHMARK.md), e.g.: +//! cargo run --release -p vecq-bench --bin view_mmap -- 12000 +//! The optional argument scales the real dataset by repetition so the +//! load-vs-view gap is visible at 10k+ vectors. + +use std::fs; +use std::io::Write; +use std::time::Instant; + +use memmap2::Mmap; +use vecq_core::{VecqIndex, VecqView}; + +fn load_f32(path: &str, n: usize, dim: usize) -> Vec> { + let bytes = fs::read(path).expect("read file"); + assert_eq!(bytes.len(), n * dim * 4, "file size mismatch"); + (0..n) + .map(|i| { + (0..dim) + .map(|j| { + f32::from_le_bytes( + bytes[(i * dim + j) * 4..(i * dim + j) * 4 + 4] + .try_into() + .unwrap(), + ) + }) + .collect() + }) + .collect() +} + +fn main() { + let target_n: usize = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(2000); + let dir = "/tmp/vecq-bench"; + let meta = fs::read_to_string(format!("{dir}/meta.json")).expect("meta.json"); + let get = |k: &str| -> usize { + let i = meta.find(&format!("\"{k}\"")).expect(k) + k.len() + 4; + let rest = &meta[i..]; + let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap(); + rest[..end].parse().unwrap() + }; + let (nb, nq, dim) = (get("n_base"), get("n_query"), get("dim")); + let reps = target_n.div_ceil(nb); + let n = nb * reps; + println!("dataset: n={n} ({reps}x{nb}) queries={nq} dim={dim}"); + + let base = load_f32(&format!("{dir}/base.f32"), nb, dim); + let queries = load_f32(&format!("{dir}/queries.f32"), nq, dim); + + // Build a 5-bit index of n vectors and persist it. + let t0 = Instant::now(); + let mut idx = VecqIndex::new(dim, 42); + for r in 0..reps { + for v in &base { + idx.add(v); + } + let _ = r; + } + let build = t0.elapsed(); + let bytes = idx.to_bytes(); + let file_path = format!("{dir}/index_{n}.vecq"); + let mut f = fs::File::create(&file_path).expect("create file"); + f.write_all(&bytes).expect("write file"); + drop(f); + println!( + "built {:?}, file {:.1} MB ({} B/vec)", + build, + bytes.len() as f64 / 1e6, + bytes.len() / n + ); + + // Full load: read + parse + copy payloads into heap structures. + let t1 = Instant::now(); + let raw = std::fs::read(&file_path).expect("read"); + let full = VecqIndex::from_bytes(&raw).expect("parse"); + let full_load = t1.elapsed(); + + // mmap view: map (lazy) + header parse only. + let t2 = Instant::now(); + let file = fs::File::open(&file_path).expect("open"); + let map = unsafe { Mmap::map(&file).expect("mmap") }; + let view = VecqView::from_bytes(&map).expect("parse view"); + let view_ready = t2.elapsed(); + + // Correctness cross-check on a few queries, then time-to-first-query. + let q0 = &queries[0]; + let a = full.search(q0, 10); + let b = view.search(q0, 10); + for ((sa, fa), (sb, fb)) in a.iter().zip(b.iter()) { + assert_eq!(sa, sb); + assert_eq!(fa.to_bits(), fb.to_bits()); + } + + let t3 = Instant::now(); + let first = view.search(&queries[0], 10); + let view_first = t3.elapsed(); + let t4 = Instant::now(); + let firstf = full.search(&queries[0], 10); + let full_first = t4.elapsed(); + assert_eq!(first.len(), firstf.len()); + + println!("full load (read+parse+copy): {:?}", full_load); + println!("mmap view ready (map+parse): {:?}", view_ready); + println!("first query through view: {:?}", view_first); + println!("first query through full: {:?}", full_first); + println!("warm comparison (100 queries):"); + let t5 = Instant::now(); + for q in &queries { + let _ = view.search(q, 10); + } + let vw = t5.elapsed(); + let t6 = Instant::now(); + for q in &queries { + let _ = full.search(q, 10); + } + let fw = t6.elapsed(); + println!( + " view {:?} ({:.2} ms/q) | full {:?} ({:.2} ms/q)", + vw, + vw.as_secs_f64() * 1e3 / nq as f64, + fw, + fw.as_secs_f64() * 1e3 / nq as f64 + ); +} diff --git a/crates/vecq-core/src/format.rs b/crates/vecq-core/src/format.rs index dc9db0a..07a23da 100644 --- a/crates/vecq-core/src/format.rs +++ b/crates/vecq-core/src/format.rs @@ -33,13 +33,13 @@ use crate::store::VecqIndex; -const MAGIC: u32 = u32::from_le_bytes(*b"VECQ"); +pub(crate) const MAGIC: u32 = u32::from_le_bytes(*b"VECQ"); const V1: u16 = 1; const V1_1: u16 = 257; pub const V1_2: u16 = 258; -const V1_3: u16 = 259; -const V1_4: u16 = 260; -const V1_5: u16 = 261; +pub(crate) const V1_3: u16 = 259; +pub(crate) const V1_4: u16 = 260; +pub(crate) const V1_5: u16 = 261; #[derive(Debug)] pub enum Error { @@ -118,7 +118,7 @@ fn f32_to_f16_bits(x: f32) -> u16 { } /// IEEE 754 half-precision decode. -fn f16_bits_to_f32(h: u16) -> f32 { +pub(crate) fn f16_bits_to_f32(h: u16) -> f32 { let sign = ((h & 0x8000) as u32) << 16; let exp = ((h >> 10) & 0x1F) as u32; let mant = (h & 0x03FF) as u32; diff --git a/crates/vecq-core/src/lib.rs b/crates/vecq-core/src/lib.rs index 3491c01..9c79453 100644 --- a/crates/vecq-core/src/lib.rs +++ b/crates/vecq-core/src/lib.rs @@ -10,5 +10,7 @@ pub mod format; pub mod lloyd; pub mod rhdh; pub mod store; +pub mod view; pub use store::{cosine_f32, PreparedQuery, VecqIndex}; +pub use view::VecqView; diff --git a/crates/vecq-core/src/store.rs b/crates/vecq-core/src/store.rs index 5ffca5d..7429e7c 100644 --- a/crates/vecq-core/src/store.rs +++ b/crates/vecq-core/src/store.rs @@ -801,27 +801,7 @@ impl VecqIndex { /// the best available kernel for the target. #[inline] fn score_raw(&self, codes: &[u8], q: &[f32], lut: &[f32; 16], bits: u8) -> f32 { - if bits != 4 { - return score_wide_scalar(codes, q, bits); - } - #[cfg(target_arch = "aarch64")] - { - // NEON is baseline on aarch64. - unsafe { neon::score_neon(codes, q, lut) } - } - #[cfg(target_arch = "x86_64")] - { - if avx2::available() { - // SAFETY: feature availability checked immediately above. - unsafe { avx2::score_avx2(codes, q, lut) } - } else { - score_scalar(codes, q, lut) - } - } - #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] - { - score_scalar(codes, q, lut) - } + score_raw_dispatch(codes, q, lut, bits) } /// Brute-force top-k search. Returns (slot index, score) sorted by score @@ -974,6 +954,71 @@ impl VecqIndex { } } +/// Batch-4 scoring over raw code slices, shared by the index search loop +/// and the zero-copy view (issue #25): both must hit the same kernels with +/// the same association order. 4-bit uses the nibble-LUT kernels; 5/6-bit +/// uses the wide kernel; residual planes are 4-bit by construction. +pub(crate) fn score_batch4(codes4: &[u8], q_rot: &[f32], lut: &[f32; 16], bits: u8) -> [f32; 4] { + #[cfg(target_arch = "aarch64")] + { + if bits == 4 { + // SAFETY: NEON is baseline on aarch64. + return unsafe { neon::score_neon4(codes4, q_rot, lut) }; + } + // SAFETY: NEON is baseline on aarch64. + unsafe { neon::score_neon_wide4(codes4, q_rot, bits) } + } + #[cfg(target_arch = "x86_64")] + { + if avx2::available() && bits == 4 { + // SAFETY: feature availability checked immediately above. + return unsafe { avx2::score_avx24(codes4, q_rot, lut) }; + } + let nb = codes4.len() / 4; + let mut out = [0f32; 4]; + for v in 0..4 { + out[v] = score_raw_dispatch(&codes4[v * nb..(v + 1) * nb], q_rot, lut, bits); + } + out + } + #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] + { + let nb = codes4.len() / 4; + let mut out = [0f32; 4]; + for v in 0..4 { + out[v] = score_raw_dispatch(&codes4[v * nb..(v + 1) * nb], q_rot, lut, bits); + } + out + } +} + +/// Free-function kernel dispatch shared by [`VecqIndex`] and the zero-copy +/// [`crate::view::VecqView`] — one place guarantees both owners pick the +/// same kernel with the same association order (bit-identity). +pub(crate) fn score_raw_dispatch(codes: &[u8], q: &[f32], lut: &[f32; 16], bits: u8) -> f32 { + if bits != 4 { + return score_wide_scalar(codes, q, bits); + } + #[cfg(target_arch = "aarch64")] + { + // NEON is baseline on aarch64. + unsafe { neon::score_neon(codes, q, lut) } + } + #[cfg(target_arch = "x86_64")] + { + if avx2::available() { + // SAFETY: feature availability checked immediately above. + unsafe { avx2::score_avx2(codes, q, lut) } + } else { + score_scalar(codes, q, lut) + } + } + #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] + { + score_scalar(codes, q, lut) + } +} + /// Write `code` for dimension `i` into the bit-packed code block at /// `base` (LSB-first: dimension i occupies bits [i·w, (i+1)·w) of the /// block). Read-modify-write on a 16-bit window so codes spanning a byte @@ -1271,6 +1316,7 @@ mod neon { let mut acc = [vdupq_n_f32(0.0); 4]; let mut i = 0; while i + 8 <= n_dims { + #[allow(clippy::needless_range_loop)] // v is both a vector index and the acc lane for v in 0..4 { let base = v * nb; let b0 = i * bits as usize; @@ -1278,12 +1324,13 @@ mod neon { // Only the bits [b0, b0 + 8*w) matter: ceil((s0+8w)/8) <= 7 // bytes. Loading a fixed 8-byte u64 would run past the end // of the last vector's block. - let need = (b0 % 8 + 8 * bits as usize + 7) / 8; + let need = (b0 % 8 + 8 * bits as usize).div_ceil(8); let mut wb = [0u8; 8]; wb[..need].copy_from_slice(&codes4[off..off + need]); let win = u64::from_le_bytes(wb); let s0 = (b0 % 8) as u64; let mut g = [0f32; 8]; + #[allow(clippy::needless_range_loop)] // l indexes g and feeds the shift math for l in 0..8usize { let c = ((win >> (s0 + l as u64 * w)) & mask) as usize; debug_assert_eq!(c, unpack_code(&codes4[base..], i + l, bits) as usize); diff --git a/crates/vecq-core/src/view.rs b/crates/vecq-core/src/view.rs new file mode 100644 index 0000000..8918618 --- /dev/null +++ b/crates/vecq-core/src/view.rs @@ -0,0 +1,417 @@ +//! Zero-copy read-only index view (`VecqView`) — issue #25. +//! +//! Parse the v1.2/v1.3/v1.4/v1.5 on-disk layout without copying codes or +//! scales, so an index can be served straight from a memory map. Any owner +//! works: `memmap2::Mmap`, `&[u8]`, `Box<[u8]>`, `Vec` — the view only +//! needs `&[u8]`. +//! +//! Scoring goes through the same shared kernel dispatch as [`crate::store:: +//! VecqIndex`] and the layout is identical, so a view and a loaded index +//! over the same bytes return **bit-identical** results (tested). +//! +//! Views are always dense: `to_bytes` drops tombstones before writing, and +//! the keyed API layer is in-memory by design (#10/#16) — not available on +//! a borrowed, read-only slice. + +use crate::format::{f16_bits_to_f32, Error, MAGIC, V1_2, V1_3, V1_4, V1_5}; +use crate::rhdh::Rhdh; +use crate::store::{score_batch4, score_raw_dispatch}; + +fn rd_u16(b: &[u8]) -> u16 { + u16::from_le_bytes([b[0], b[1]]) +} + +fn rd_u32(b: &[u8]) -> u32 { + u32::from_le_bytes([b[0], b[1], b[2], b[3]]) +} + +/// Prepared query for a [`VecqView`] (same fields and semantics as the +/// index-side [`PreparedQuery`], freed from its lifetime by cloning the +/// small rotated-query buffer). +pub struct ViewQuery { + rotated: Vec, + lut: [f32; 16], +} + +/// Read-only, zero-copy view over an index file (or any bytes in the same +/// layout). Generic over the byte owner's lifetime. +pub struct VecqView<'a> { + dim: usize, + working_dim: usize, + padded: usize, + n: usize, + bits: u8, + residual: bool, + transform: Rhdh, + codes: &'a [u8], + scales_raw: &'a [u8], // 2 bytes per vector, LE u16 f16 bits + codes2: Option<&'a [u8]>, + scales2_raw: Option<&'a [u8]>, +} + +impl<'a> VecqView<'a> { + /// Parse `bytes` as a vecq index file (v1.2+) without copying payloads. + /// v1 files (f32 scales) are not view-eligible: their scale blocks are + /// not the 2-byte layout shared by every current writer. + pub fn from_bytes(bytes: &'a [u8]) -> Result { + if bytes.len() < 24 || rd_u32(&bytes[0..4]) != MAGIC { + return Err(Error::NotAStableFile); + } + let version = rd_u16(&bytes[4..6]); + if version != V1_2 && version != V1_3 && version != V1_4 && version != V1_5 { + return Err(Error::UnsupportedVersion(version)); + } + let dim = rd_u32(&bytes[8..12]) as usize; + let seed = u64::from_le_bytes(bytes[12..20].try_into().unwrap()); + let count = rd_u32(&bytes[20..24]) as usize; + let working_dim = match rd_u16(&bytes[6..8]) as usize { + 0 => dim, + w if w <= dim => w, + w => { + return Err(Error::InvalidWorkingDim { + dim, + working_dim: w, + }) + } + }; + let mut off = 24usize; + let bits = if version == V1_5 { + if bytes.len() < 25 { + return Err(Error::Truncated); + } + let w = bytes[24]; + if !matches!(w, 4..=6) { + return Err(Error::InvalidWidth { width: w }); + } + off += 1; + w + } else { + 4 + }; + let padded = crate::rhdh::padded_dim(working_dim); + let codes_bytes = (padded * bits as usize).div_ceil(8); + let expected = off + count * (2 + codes_bytes); + if bytes.len() < expected { + return Err(Error::Truncated); + } + let scales_raw = &bytes[off..off + count * 2]; + let codes = &bytes[off + count * 2..off + count * (2 + codes_bytes)]; + off += count * (2 + codes_bytes); + let mut residual = false; + let mut codes2 = None; + let mut scales2_raw = None; + if version == V1_4 { + if bytes.len() < off + count * (2 + codes_bytes) { + return Err(Error::Truncated); + } + scales2_raw = Some(&bytes[off..off + count * 2]); + codes2 = Some(&bytes[off + count * 2..off + count * (2 + codes_bytes)]); + off += count * (2 + codes_bytes); + residual = true; + } + // v1.3+ trail a keyed-slot table: validate its extent too, so a view + // rejects any file the full loader would reject (no silent acceptance + // of truncation in trailing sections the view itself never reads). + if version == V1_3 || version == V1_4 || version == V1_5 { + if bytes.len() < off + 4 { + return Err(Error::Truncated); + } + let entries = rd_u32(&bytes[off..off + 4]) as usize; + if bytes.len() < off + 4 + entries * 12 { + return Err(Error::Truncated); + } + } + Ok(Self { + dim, + working_dim, + padded, + n: count, + bits, + residual, + transform: Rhdh::new(working_dim, seed), + codes, + scales_raw, + codes2, + scales2_raw, + }) + } + + pub fn len(&self) -> usize { + self.n + } + + pub fn is_empty(&self) -> bool { + self.n == 0 + } + + pub fn dim(&self) -> usize { + self.dim + } + + pub fn working_dim(&self) -> usize { + self.working_dim + } + + /// Code width of the viewed file (4, 5, or 6 bits). + pub fn bits(&self) -> u8 { + self.bits + } + + /// Whether the file carries a residual second pass (v1.4). + pub fn is_residual(&self) -> bool { + self.residual + } + + /// Prepare a query: truncate to `working_dim`, normalize, rotate, then + /// normalize again — mirroring the index-side `prepare_query` exactly so + /// both paths see identical rotated queries. + pub fn prepare_query(&self, q: &[f32]) -> ViewQuery { + assert_eq!(q.len(), self.dim); + let norm: f32 = q[..self.working_dim] + .iter() + .map(|x| x * x) + .sum::() + .sqrt(); + assert!(norm > 0.0, "zero vector"); + let unit: Vec = q[..self.working_dim].iter().map(|x| x / norm).collect(); + let mut rotated = Vec::with_capacity(self.padded); + self.transform.apply(&unit, &mut rotated); + let rnorm: f32 = rotated.iter().map(|x| x * x).sum::().sqrt(); + for x in rotated.iter_mut() { + *x /= rnorm; + } + let mut lut = [0f32; 16]; + for (c, slot) in lut.iter_mut().enumerate() { + *slot = crate::lloyd::dequantize_4bit(c as u8); + } + ViewQuery { rotated, lut } + } + + /// Asymmetric score of vector `idx` — same kernel dispatch and + /// association order as [`crate::store::VecqIndex::score`]. + pub fn score(&self, pq: &ViewQuery, idx: usize) -> f32 { + let base = idx * self.bytes_per_vector(); + let codes = &self.codes[base..base + self.bytes_per_vector()]; + let q = &pq.rotated[..self.padded]; + let raw0 = score_raw_dispatch(codes, q, &pq.lut, self.bits); + if !self.residual { + let s = u16::from_le_bytes(self.scales_raw[idx * 2..idx * 2 + 2].try_into().unwrap()); + return raw0 * f16_bits_to_f32(s); + } + let codes1 = &self.codes2.unwrap()[base..base + self.bytes_per_vector()]; + let raw1 = score_raw_dispatch(codes1, q, &pq.lut, self.bits); + let s = u16::from_le_bytes(self.scales_raw[idx * 2..idx * 2 + 2].try_into().unwrap()); + let s2 = u16::from_le_bytes( + self.scales2_raw.unwrap()[idx * 2..idx * 2 + 2] + .try_into() + .unwrap(), + ); + raw0 * f16_bits_to_f32(s) + raw1 * f16_bits_to_f32(s2) + } + + /// Brute-force top-k over the borrowed codes — same bounded heap, + /// key encoding, batched kernel dispatch, and output ordering as + /// [`crate::store::VecqIndex::search`]. + pub fn search(&self, q: &[f32], k: usize) -> Vec<(usize, f32)> { + use std::cmp::Reverse; + use std::collections::BinaryHeap; + let pq = self.prepare_query(q); + let k = k.min(self.n).max(1); + let bpv = self.bytes_per_vector(); + let key = |s: f32| -> u32 { + let b = s.to_bits(); + if b & 0x8000_0000 != 0 { + !b + } else { + b ^ 0x8000_0000 + } + }; + let mut heap: BinaryHeap> = BinaryHeap::with_capacity(k + 1); + let consider = |s: f32, idx: usize, heap: &mut BinaryHeap>| { + let ks = key(s); + if heap.len() < k { + heap.push(Reverse((ks, idx))); + } else if ks > heap.peek().map(|r| r.0 .0).unwrap_or(0) { + heap.push(Reverse((ks, idx))); + heap.pop(); + } + }; + let combine = |r0: f32, r1: Option, si: usize| -> f32 { + match r1 { + Some(r1) => { + let s = + u16::from_le_bytes(self.scales_raw[si * 2..si * 2 + 2].try_into().unwrap()); + let s2 = u16::from_le_bytes( + self.scales2_raw.unwrap()[si * 2..si * 2 + 2] + .try_into() + .unwrap(), + ); + r0 * f16_bits_to_f32(s) + r1 * f16_bits_to_f32(s2) + } + None => { + let s = + u16::from_le_bytes(self.scales_raw[si * 2..si * 2 + 2].try_into().unwrap()); + r0 * f16_bits_to_f32(s) + } + } + }; + let q_rot = &pq.rotated[..self.padded]; + let mut idx = 0; + // Batch-4 scoring over contiguous slices — the exact loop shape of + // the index search, so the view keeps the same kernel setup costs. + while idx + 4 <= self.n { + let codes4 = &self.codes[idx * bpv..(idx + 4) * bpv]; + let raw = score_batch4(codes4, q_rot, &pq.lut, self.bits); + let raw1 = if self.residual { + Some(score_batch4( + &self.codes2.unwrap()[idx * bpv..(idx + 4) * bpv], + q_rot, + &pq.lut, + self.bits, + )) + } else { + None + }; + for (v, &r) in raw.iter().enumerate() { + consider(combine(r, raw1.map(|a| a[v]), idx + v), idx + v, &mut heap); + } + idx += 4; + } + while idx < self.n { + consider(self.score(&pq, idx), idx, &mut heap); + idx += 1; + } + let key_undo = |k: u32| -> u32 { + if k & 0x8000_0000 != 0 { + k ^ 0x8000_0000 + } else { + !k + } + }; + let mut out: Vec<(usize, f32)> = heap + .into_iter() + .map(|r| (r.0 .1, f32::from_bits(key_undo(r.0 .0)))) + .collect(); + out.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("no NaN scores")); + out + } + + /// Bytes per vector's code block at this view's width. + fn bytes_per_vector(&self) -> usize { + (self.padded * self.bits as usize).div_ceil(8) + } +} + +#[cfg(test)] +mod tests { + use super::VecqView; + use crate::store::VecqIndex; + + fn rand_unit(dim: usize, salt: u64) -> Vec { + let mut x = salt | 1; + let mut v = Vec::with_capacity(dim); + for _ in 0..dim { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + v.push((x as f32 / u32::MAX as f32 - 0.5) * 2.0); + } + let n: f32 = v.iter().map(|a| a * a).sum::().sqrt(); + v.iter_mut().for_each(|a| *a /= n); + v + } + + #[test] + fn view_matches_loaded_index_bitwise() { + // Same bytes -> view and fully-loaded index must return identical + // slot order AND identical score bits, at every width. + let dim = 128; + for bits in [4u8, 5, 6] { + let mut idx = VecqIndex::new(dim, 42); + idx.set_bits(bits); + for i in 0..20 { + idx.add(&rand_unit(dim, i + 11)); + } + let bytes = idx.to_bytes(); + let loaded = VecqIndex::from_bytes(&bytes).unwrap(); + let view = VecqView::from_bytes(&bytes).unwrap(); + assert_eq!(view.len(), 20); + assert_eq!(view.bits(), bits); + for qi in 0..5 { + let q = rand_unit(dim, 900 + qi); + let a = loaded.search(&q, 7); + let b = view.search(&q, 7); + assert_eq!(a.len(), b.len(), "bits {bits} q{qi}"); + for ((sa, fa), (sb, fb)) in a.iter().zip(b.iter()) { + assert_eq!(sa, sb, "bits {bits} q{qi}"); + assert_eq!(fa.to_bits(), fb.to_bits(), "bits {bits} q{qi}"); + } + } + } + } + + #[test] + fn view_supports_residual_and_working_dim() { + // v1.4 residual files and v1.2 working_dim files both parse to + // bit-identical views. + let dim = 128; + let mut resid = VecqIndex::with_residual(dim, 7); + for i in 0..12 { + resid.add(&rand_unit(dim, i + 300)); + } + let bytes = resid.to_bytes(); + let loaded = VecqIndex::from_bytes(&bytes).unwrap(); + let view = VecqView::from_bytes(&bytes).unwrap(); + assert!(view.is_residual()); + let q = rand_unit(dim, 500); + for (sa, fa) in loaded.search(&q, 5) { + let (_, fb) = view + .search(&q, 5) + .into_iter() + .find(|(sb, _)| *sb == sa) + .unwrap(); + assert_eq!(fa.to_bits(), fb.to_bits()); + } + + let mut wd = VecqIndex::with_working_dim(256, 64, 21); + for i in 0..10 { + wd.add(&rand_unit(256, i + 700)); + } + let bytes = wd.to_bytes(); + let loaded = VecqIndex::from_bytes(&bytes).unwrap(); + let view = VecqView::from_bytes(&bytes).unwrap(); + assert_eq!(view.working_dim(), 64); + let q = rand_unit(256, 999); + for (sa, fa) in loaded.search(&q, 5) { + let (_, fb) = view + .search(&q, 5) + .into_iter() + .find(|(sb, _)| *sb == sa) + .unwrap(); + assert_eq!(fa.to_bits(), fb.to_bits()); + } + } + + #[test] + fn view_rejects_v1_and_truncated_bytes() { + let mut idx = VecqIndex::new(64, 3); + idx.add(&rand_unit(64, 1)); + let bytes = idx.to_bytes(); + // v1.3 default: patch the version down to v1 (f32 scales era) — + // views only accept v1.2+. + let mut v1 = bytes.clone(); + v1[4] = 1; + v1[5] = 0; + assert!(matches!( + VecqView::from_bytes(&v1), + Err(crate::format::Error::UnsupportedVersion(1)) + )); + // Too short / wrong magic must error, never panic. + assert!(VecqView::from_bytes(&bytes[..10]).is_err()); + let mut bad = bytes.clone(); + bad[0] = b'X'; + assert!(VecqView::from_bytes(&bad).is_err()); + // Truncated payload. + assert!(VecqView::from_bytes(&bytes[..bytes.len() - 1]).is_err()); + } +} diff --git a/docs/SQLITE.md b/docs/SQLITE.md index 32a9b76..fbe82bd 100644 --- a/docs/SQLITE.md +++ b/docs/SQLITE.md @@ -131,3 +131,29 @@ batch" is comfortably fast, and per-shard rows are only worth it past ~50 MB. `VACUUM` slow; another reason for the ~100 MB embed threshold. - **Seed discipline**: always store `seed` next to the BLOB; a rebuilt index with a different seed produces a different rotation and incompatible scores. + +## When to skip the BLOB: zero-copy mmap views (#25) + +For read-only serving of large indexes, skip the BLOB *and* the full load: +`VecqView::from_bytes` parses any owner of the file bytes — including a +`memmap2::Mmap` — with zero copying of codes/scales. Measured on the +12k-vector reference dataset (5-bit, 7.7 MB file, aarch64 release): +map+parse ready in **~64 µs vs 4.9 ms** for read+parse+copy (~76x faster +time-to-ready), then identical warm search throughput (same batched +kernels; results bit-identical to `VecqIndex::from_bytes`, tested). + +```rust +use memmap2::Mmap; // bench/example-only dep; vecq-core stays dependency-free +use vecq_core::VecqView; + +let file = std::fs::File::open("index.vecq")?; +let map = unsafe { Mmap::map(&file)? }; +let view = VecqView::from_bytes(&map)?; +let hits = view.search(&query, 10); // (slot, score), same as the loaded index +``` + +Choose by workload: BLOB-in-SQLite for mutable, transactional, embedded +storage (this document); `VecqView` over an mmap'd file for large read-only +deployments and cold-start-sensitive serving. Views are dense (tombstones +are dropped on save) and carry no keyed map — persist keys separately if +you need the keyed layer on a read-only view.