use super::*;
use crate::mstg::hnsw::{CentroidData, DistBF16, DistFP16, DistINT8, HnswIndex};
use crate::RabitqError;
use crc32fast::Hasher;
use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
const MAGIC_BYTES: &[u8; 4] = b"MSTG";
const PERSISTENCE_VERSION: u32 = 1;
fn write_u32<W: Write>(
writer: &mut W,
value: u32,
hasher: Option<&mut Hasher>,
) -> std::io::Result<()> {
let bytes = value.to_le_bytes();
writer.write_all(&bytes)?;
if let Some(h) = hasher {
h.update(&bytes);
}
Ok(())
}
fn read_u32<R: Read>(reader: &mut R, hasher: Option<&mut Hasher>) -> std::io::Result<u32> {
let mut bytes = [0u8; 4];
reader.read_exact(&mut bytes)?;
if let Some(h) = hasher {
h.update(&bytes);
}
Ok(u32::from_le_bytes(bytes))
}
fn write_u64<W: Write>(
writer: &mut W,
value: u64,
hasher: Option<&mut Hasher>,
) -> std::io::Result<()> {
let bytes = value.to_le_bytes();
writer.write_all(&bytes)?;
if let Some(h) = hasher {
h.update(&bytes);
}
Ok(())
}
fn read_u64<R: Read>(reader: &mut R, hasher: Option<&mut Hasher>) -> std::io::Result<u64> {
let mut bytes = [0u8; 8];
reader.read_exact(&mut bytes)?;
if let Some(h) = hasher {
h.update(&bytes);
}
Ok(u64::from_le_bytes(bytes))
}
impl MstgIndex {
pub fn save_to_path<P: AsRef<Path>>(&self, path: P) -> Result<(), RabitqError> {
let path_str = path.as_ref().to_string_lossy().to_string();
let index_path = format!("{}.mstg", path_str);
self.save_main_index(&index_path)?;
self.save_hnsw(&path_str)?;
Ok(())
}
pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self, RabitqError> {
let path_str = path.as_ref().to_string_lossy().to_string();
let index_path = format!("{}.mstg", path_str);
let mut index = Self::load_main_index_inmemory(&index_path)?;
index.load_hnsw(&path_str)?;
Ok(index)
}
pub fn load_from_path_mmap<P: AsRef<Path>>(path: P) -> Result<Self, RabitqError> {
let path_str = path.as_ref().to_string_lossy().to_string();
let index_path = format!("{}.mstg", path_str);
let mut index = Self::load_main_index_mmap(&index_path)?;
index.load_hnsw(&path_str)?;
Ok(index)
}
fn save_main_index(&self, path: &str) -> Result<(), RabitqError> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
let mut hasher = Hasher::new();
writer.write_all(MAGIC_BYTES)?;
write_u32(&mut writer, PERSISTENCE_VERSION, None)?;
let config_bytes = bincode::serialize(&self.config)
.map_err(|_e| RabitqError::InvalidPersistence("failed to serialize config"))?;
write_u64(&mut writer, config_bytes.len() as u64, Some(&mut hasher))?;
writer.write_all(&config_bytes)?;
hasher.update(&config_bytes);
let centroid_ids = self.centroid_index.get_all_ids();
write_u64(&mut writer, centroid_ids.len() as u64, Some(&mut hasher))?;
for &id in ¢roid_ids {
write_u32(&mut writer, id, Some(&mut hasher))?;
}
let dir_bytes = bincode::serialize(&self.directory)
.map_err(|_e| RabitqError::InvalidPersistence("failed to serialize directory"))?;
write_u64(&mut writer, dir_bytes.len() as u64, Some(&mut hasher))?;
writer.write_all(&dir_bytes)?;
hasher.update(&dir_bytes);
write_u64(
&mut writer,
self.posting_lists.len() as u64,
Some(&mut hasher),
)?;
for plist in self.posting_lists.iter() {
let plist_bytes = bincode::serialize(plist)
.map_err(|_| RabitqError::InvalidPersistence("failed to serialize posting list"))?;
write_u64(&mut writer, plist_bytes.len() as u64, Some(&mut hasher))?;
writer.write_all(&plist_bytes)?;
hasher.update(&plist_bytes);
}
let checksum = hasher.finalize();
write_u32(&mut writer, checksum, None)?;
writer.flush()?;
Ok(())
}
#[allow(clippy::type_complexity)]
fn load_header(
path: &str,
) -> Result<
(
MstgConfig,
Vec<u32>,
PostingListDirectory,
Hasher,
BufReader<File>,
),
RabitqError,
> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut hasher = Hasher::new();
let mut magic = [0u8; 4];
reader.read_exact(&mut magic)?;
if &magic != MAGIC_BYTES {
return Err(RabitqError::InvalidPersistence("invalid magic bytes"));
}
let version = read_u32(&mut reader, None)?;
if version != PERSISTENCE_VERSION {
return Err(RabitqError::InvalidPersistence("unsupported version"));
}
let config_len = read_u64(&mut reader, Some(&mut hasher))? as usize;
let mut config_bytes = vec![0u8; config_len];
reader.read_exact(&mut config_bytes)?;
hasher.update(&config_bytes);
let config: MstgConfig = bincode::deserialize(&config_bytes)
.map_err(|_| RabitqError::InvalidPersistence("failed to deserialize config"))?;
let num_centroids = read_u64(&mut reader, Some(&mut hasher))? as usize;
let mut centroid_ids = Vec::with_capacity(num_centroids);
for _ in 0..num_centroids {
centroid_ids.push(read_u32(&mut reader, Some(&mut hasher))?);
}
let dir_len = read_u64(&mut reader, Some(&mut hasher))? as usize;
let mut dir_bytes = vec![0u8; dir_len];
reader.read_exact(&mut dir_bytes)?;
hasher.update(&dir_bytes);
let directory: PostingListDirectory = bincode::deserialize(&dir_bytes)
.map_err(|_| RabitqError::InvalidPersistence("failed to deserialize directory"))?;
Ok((config, centroid_ids, directory, hasher, reader))
}
fn verify_checksum(
mmap: &memmap2::Mmap,
base_offset: usize,
file_len: usize,
mut hasher: Hasher,
) -> Result<(), RabitqError> {
hasher.update(&mmap[base_offset..file_len - 4]);
let mut stored_checksum_bytes = [0u8; 4];
stored_checksum_bytes.copy_from_slice(&mmap[file_len - 4..file_len]);
let stored_checksum = u32::from_le_bytes(stored_checksum_bytes);
let computed_checksum = hasher.finalize();
if stored_checksum != computed_checksum {
return Err(RabitqError::InvalidPersistence("checksum mismatch"));
}
Ok(())
}
fn extract_centroids(
mmap: &memmap2::Mmap,
base_offset: usize,
num_centroids: usize,
) -> Result<Vec<Vec<f32>>, RabitqError> {
let mut centroid_vecs: Vec<Vec<f32>> = Vec::with_capacity(num_centroids);
let mut offset = base_offset + 8;
for _ in 0..num_centroids {
let plist_len =
u64::from_le_bytes(mmap[offset..offset + 8].try_into().unwrap()) as usize;
offset += 8;
let plist: PostingList = bincode::deserialize(&mmap[offset..offset + plist_len])
.map_err(|_| {
RabitqError::InvalidPersistence("failed to deserialize posting list")
})?;
centroid_vecs.push(plist.centroid);
offset += plist_len;
}
Ok(centroid_vecs)
}
fn load_main_index_inmemory(path: &str) -> Result<Self, RabitqError> {
let (config, centroid_ids, directory, hasher, mut reader) = Self::load_header(path)?;
let num_centroids = centroid_ids.len();
let base_offset = std::io::Seek::stream_position(&mut reader)? as usize;
let file = reader.into_inner();
let file_len = file.metadata()?.len() as usize;
let mmap = unsafe { memmap2::MmapOptions::new().map(&file)? };
Self::verify_checksum(&mmap, base_offset, file_len, hasher)?;
println!(
"Deserializing {} posting lists into memory...",
num_centroids
);
let mut posting_lists: Vec<PostingList> = Vec::with_capacity(num_centroids);
let mut centroid_vecs: Vec<Vec<f32>> = Vec::with_capacity(num_centroids);
let mut offset = base_offset + 8;
for _ in 0..num_centroids {
let plist_len =
u64::from_le_bytes(mmap[offset..offset + 8].try_into().unwrap()) as usize;
offset += 8;
let mut plist: PostingList = bincode::deserialize(&mmap[offset..offset + plist_len])
.map_err(|_| {
RabitqError::InvalidPersistence("failed to deserialize posting list")
})?;
plist.padded_dim = plist.centroid.len();
centroid_vecs.push(plist.centroid.clone());
posting_lists.push(plist);
offset += plist_len;
}
println!("Rebuilding FastScan batch layouts...");
for plist in posting_lists.iter_mut() {
plist.build_batch_layout();
}
let centroid_index =
CentroidIndex::build(centroid_vecs, centroid_ids, config.centroid_precision);
Ok(Self {
config,
centroid_index,
posting_lists: PostingDataSource::InMemory(posting_lists),
directory,
})
}
fn load_main_index_mmap(path: &str) -> Result<Self, RabitqError> {
let (config, centroid_ids, directory, hasher, mut reader) = Self::load_header(path)?;
let num_centroids = centroid_ids.len();
let base_offset = std::io::Seek::stream_position(&mut reader)? as usize;
let file = reader.into_inner();
let file_len = file.metadata()?.len() as usize;
let mmap = unsafe { memmap2::MmapOptions::new().map(&file)? };
Self::verify_checksum(&mmap, base_offset, file_len, hasher)?;
let centroid_vecs = Self::extract_centroids(&mmap, base_offset, num_centroids)?;
let centroid_index =
CentroidIndex::build(centroid_vecs, centroid_ids, config.centroid_precision);
let posting_lists_data = PostingDataSource::Mmap(mmap, base_offset as u64 + 8);
println!(
"Loaded MSTG in disk mode: {} centroids, posting lists on mmap",
num_centroids
);
Ok(Self {
config,
centroid_index,
posting_lists: posting_lists_data,
directory,
})
}
fn save_hnsw(&self, base_path: &str) -> Result<(), RabitqError> {
self.centroid_index.save_hnsw(base_path).map_err(|e| {
RabitqError::Io(std::io::Error::other(format!("HNSW save failed: {}", e)))
})?;
Ok(())
}
fn load_hnsw(&mut self, base_path: &str) -> Result<(), RabitqError> {
self.centroid_index.load_hnsw(base_path).map_err(|e| {
RabitqError::Io(std::io::Error::other(format!("HNSW load failed: {}", e)))
})?;
Ok(())
}
}
impl CentroidIndex {
pub(crate) fn save_hnsw(&self, base_path: &str) -> Result<(), String> {
use hnsw_rs::api::AnnT;
self.ensure_hnsw_built();
let cache = self.hnsw_cache.read();
let hnsw_opt = cache.as_ref().ok_or_else(|| "HNSW not built".to_string())?;
let path_string = base_path.to_string();
match hnsw_opt {
HnswIndex::FP32(h) => h.file_dump(&path_string),
HnswIndex::BF16(h) => h.file_dump(&path_string),
HnswIndex::FP16(h) => h.file_dump(&path_string),
HnswIndex::INT8(h) => h.file_dump(&path_string),
}
.map_err(|e| format!("HNSW dump failed: {}", e))?;
Ok(())
}
pub(crate) fn load_hnsw(&mut self, base_path: &str) -> Result<(), String> {
use hnsw_rs::hnswio::*;
use hnsw_rs::prelude::*;
let path = PathBuf::from(base_path);
let dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let basename = path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| "invalid path".to_string())?
.to_string();
let hnswio = HnswIo::new(dir, basename);
let hnsw_index = match &self.centroids {
CentroidData::FP32(_) => {
let hnsw: Hnsw<f32, DistL2> = hnswio
.load_hnsw_with_dist(DistL2 {})
.map_err(|e| format!("HNSW load failed: {}", e))?;
let hnsw_static: Hnsw<'static, f32, DistL2> = unsafe { std::mem::transmute(hnsw) };
HnswIndex::FP32(hnsw_static)
}
CentroidData::BF16(_) => {
let hnsw: Hnsw<u16, DistBF16> = hnswio
.load_hnsw_with_dist(DistBF16 {})
.map_err(|e| format!("HNSW load failed: {}", e))?;
let hnsw_static: Hnsw<'static, u16, DistBF16> =
unsafe { std::mem::transmute(hnsw) };
HnswIndex::BF16(hnsw_static)
}
CentroidData::FP16(_) => {
let hnsw: Hnsw<half::f16, DistFP16> = hnswio
.load_hnsw_with_dist(DistFP16 {})
.map_err(|e| format!("HNSW load failed: {}", e))?;
let hnsw_static: Hnsw<'static, half::f16, DistFP16> =
unsafe { std::mem::transmute(hnsw) };
HnswIndex::FP16(hnsw_static)
}
CentroidData::INT8 { scale, .. } => {
let hnsw: Hnsw<i8, DistINT8> = hnswio
.load_hnsw_with_dist(DistINT8 { scale: *scale })
.map_err(|e| format!("HNSW load failed: {}", e))?;
let hnsw_static: Hnsw<'static, i8, DistINT8> = unsafe { std::mem::transmute(hnsw) };
HnswIndex::INT8(hnsw_static)
}
};
let mut cache = self.hnsw_cache.write();
*cache = Some(hnsw_index);
Ok(())
}
pub(crate) fn get_all_ids(&self) -> Vec<u32> {
self.centroid_ids.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mstg::SearchParams;
use rand::prelude::*;
fn generate_test_data(n: usize, dim: usize) -> Vec<Vec<f32>> {
let mut rng = StdRng::seed_from_u64(42);
(0..n)
.map(|_| (0..dim).map(|_| rng.gen()).collect())
.collect()
}
#[test]
fn test_save_load_mstg_index() {
let data = generate_test_data(10000, 128);
let config = MstgConfig {
max_posting_size: 20, branching_factor: 8, ..Default::default()
};
let index = MstgIndex::build(&data, config.clone()).unwrap();
let path = format!(
"/tmp/test_mstg_save_load_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
);
index.save_to_path(&path).unwrap();
let loaded_index = MstgIndex::load_from_path(&path).unwrap();
assert_eq!(index.directory.len(), loaded_index.directory.len());
assert_eq!(
index.centroid_index.len(),
loaded_index.centroid_index.len()
);
let query = &data[0];
let params = SearchParams::balanced(10);
let results1 = index.search(query, ¶ms);
let results2 = loaded_index.search(query, ¶ms);
assert!(!results1.is_empty());
assert!(!results2.is_empty());
assert_eq!(results1[0].vector_id, results2[0].vector_id);
let _ = std::fs::remove_file(format!("{}.mstg", path));
let _ = std::fs::remove_file(format!("{}.hnsw.graph", path));
let _ = std::fs::remove_file(format!("{}.hnsw.data", path));
}
#[test]
fn test_save_load_mstg_mmap() {
let data = generate_test_data(10000, 128);
let config = MstgConfig {
max_posting_size: 20,
branching_factor: 8,
..Default::default()
};
let index = MstgIndex::build(&data, config.clone()).unwrap();
let path = format!(
"/tmp/test_mstg_mmap_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
);
index.save_to_path(&path).unwrap();
let mmap_index = MstgIndex::load_from_path_mmap(&path).unwrap();
assert_eq!(index.directory.len(), mmap_index.directory.len());
assert_eq!(index.centroid_index.len(), mmap_index.centroid_index.len());
assert!(matches!(
mmap_index.posting_lists,
PostingDataSource::Mmap(_, _)
));
let query = &data[0];
let params = SearchParams::balanced(10);
let results_mem = index.search(query, ¶ms);
let results_mmap = mmap_index.search(query, ¶ms);
assert!(!results_mem.is_empty());
assert!(!results_mmap.is_empty());
assert_eq!(results_mem[0].vector_id, results_mmap[0].vector_id);
let _ = std::fs::remove_file(format!("{}.mstg", path));
let _ = std::fs::remove_file(format!("{}.hnsw.graph", path));
let _ = std::fs::remove_file(format!("{}.hnsw.data", path));
}
}