Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/vecq-bench/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
130 changes: 130 additions & 0 deletions crates/vecq-bench/src/bin/view_mmap.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<f32>> {
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
);
}
10 changes: 5 additions & 5 deletions crates/vecq-core/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions crates/vecq-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
91 changes: 69 additions & 22 deletions crates/vecq-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1271,19 +1316,21 @@ 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;
let off = base + b0 / 8;
// 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);
Expand Down
Loading