Skip to main content

diskann_disk/storage/quant/
generator.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use std::{
7    io::{Seek, SeekFrom, Write},
8    marker::PhantomData,
9};
10
11use diskann::{error::IntoANNResult, utils::VectorRepr, ANNError, ANNResult};
12use diskann_providers::storage::{StorageReadProvider, StorageWriteProvider};
13use diskann_providers::utils::{
14    load_metadata_from_file, BridgeErr, ParallelIteratorInPool, RayonThreadPoolRef, Timer,
15};
16use diskann_utils::{io::Metadata, views};
17use rayon::iter::IndexedParallelIterator;
18use tracing::info;
19
20use crate::{
21    build::chunking::{
22        checkpoint::Progress,
23        continuation::{process_while_resource_is_available, ChunkingConfig},
24    },
25    storage::quant::compressor::{CompressionStage, QuantCompressor},
26};
27
28/// [`GeneratorContext`] defines parameters for vector quantization checkpoint state
29///
30/// This struct holds offset position that allows resuming quantization from
31/// a specific point in the dataset as well as the data path to store the
32/// compressed vectors.
33#[derive(Clone, Debug)]
34pub struct GeneratorContext {
35    /// * `offset`: The point index to start/resume quantization from (for checkpoint support)
36    pub offset: usize,
37    /// * `compressed_data_path`: The path to which to write compressed data to.
38    pub compressed_data_path: String,
39}
40
41impl GeneratorContext {
42    pub fn new(offset: usize, compressed_data_path: String) -> Self {
43        Self {
44            offset,
45            compressed_data_path,
46        }
47    }
48}
49
50/// [`QuantDataGenerator`] orchestrates the process of reading vector data, applying quantization,
51/// and writing compressed results to storage. It resumes data generation from the checkpoint manager
52/// and processes data in batches.
53pub struct QuantDataGenerator<T, Q>
54where
55    T: Copy + VectorRepr,
56    Q: QuantCompressor<T>,
57{
58    pub quantizer: Q,
59    pub data_path: String,         // Path to the source vector data
60    pub context: GeneratorContext, // Overloadable context that contains metric and offset info
61    phantom: PhantomData<T>,
62}
63
64impl<T, Q> QuantDataGenerator<T, Q>
65where
66    T: Copy + VectorRepr,
67    Q: QuantCompressor<T>,
68{
69    pub fn new(
70        data_path: String,
71        context: GeneratorContext,
72        quantizer_context: &Q::CompressorContext,
73    ) -> ANNResult<Self> {
74        let stage = match context.offset {
75            0 => CompressionStage::Start,
76            _ => CompressionStage::Resume,
77        };
78        let quantizer = Q::new_at_stage(stage, quantizer_context)?;
79        Ok(Self {
80            data_path,
81            context,
82            quantizer,
83            phantom: PhantomData,
84        })
85    }
86
87    /// This method reads the source data file, processes vectors in batches, compresses them
88    /// using the provided quantizer, and writes the results to the compressed data file.
89    /// It supports checkpointing through the chunking_config and resumes from previous
90    /// interruptions using the offset stored in the context.
91    //
92    /// The implementation is adapted from generate_quantized_data_internal in pq_construction.rs
93    //
94    /// # Processing Flow
95    /// 1. Checks if starting from beginning (offset=0) and deletes any existing output if needed
96    /// 2. Opens source data file and reads metadata (num_points and dimension)
97    /// 3. Creates or opens output compressed file and writes metadata header - [num_points as i32, compressed_vector_size as i32]
98    /// 4. Processes data in blocks of size given by chunking_config.data_compression_chunk_vector_count = 50_000
99    /// 5. Compresses each block in small batch sizes in parallel to (potentially) take advantage of batch compression with quantizer
100    /// 6. Writes compressed blocks to the output file.
101    pub fn generate_data<Storage>(
102        &self,
103        storage_provider: &Storage, // Provider for reading source data and writing compressed results
104        pool: RayonThreadPoolRef<'_>, // Thread pool for parallel processing
105        chunking_config: &ChunkingConfig, // Configuration for batching and checkpoint handling
106    ) -> ANNResult<Progress>
107    where
108        Storage: StorageReadProvider + StorageWriteProvider,
109    {
110        let timer = Timer::new();
111
112        let metadata = load_metadata_from_file(storage_provider, &self.data_path)?;
113        let (num_points, dim) = metadata.into_dims();
114
115        self.validate_params(num_points, storage_provider)?;
116
117        let offset = self.context.offset;
118        let compressed_path = self.context.compressed_data_path.as_str();
119
120        if offset == 0 && storage_provider.exists(compressed_path) {
121            storage_provider.delete(compressed_path)?;
122        }
123
124        info!("Generating quantized data for {}", compressed_path);
125
126        let data_reader = &mut storage_provider.open_reader(&self.data_path)?;
127
128        //open the writer for the compressed dataset if starting from the middle, else create a new one.
129        let mut compressed_data_writer = if offset > 0 {
130            storage_provider.open_writer(compressed_path)?
131        } else {
132            let mut sp = storage_provider.create_for_write(compressed_path)?;
133            // write metadata to header
134            Metadata::new(num_points, self.quantizer.compressed_bytes())?.write(&mut sp)?;
135            sp
136        };
137
138        //seek to the offset after skipping metadata
139        data_reader.seek(SeekFrom::Start(
140            (size_of::<i32>() * 2 + offset * dim * size_of::<T>()) as u64,
141        ))?;
142
143        let compressed_size = self.quantizer.compressed_bytes();
144        let max_block_size = chunking_config.data_compression_chunk_vector_count;
145        let num_remaining = num_points - offset;
146
147        let block_size = std::cmp::min(num_points, max_block_size);
148        let num_blocks =
149            num_remaining / block_size + !num_remaining.is_multiple_of(block_size) as usize;
150
151        info!(
152            "Compressing with block size {}, num_remaining {}, num_blocks {}, offset {}, num_points {}",
153            block_size, num_remaining, num_blocks, offset, num_points
154        );
155
156        let mut compressed_buffer = vec![0_u8; block_size * compressed_size];
157
158        //Every block has size exactly block_size, except for potentially the last one
159        let action = |block_index| -> ANNResult<()> {
160            let start_index: usize = offset + block_index * block_size;
161            let end_index: usize = std::cmp::min(start_index + block_size, num_points);
162            let cur_block_size: usize = end_index - start_index;
163
164            let block_compressed_base = &mut compressed_buffer[..cur_block_size * compressed_size];
165
166            let raw_block: Vec<T> =
167                diskann::utils::read_exact_into(data_reader, cur_block_size * dim)?;
168
169            let full_dim = T::full_dimension(&raw_block[..dim]).into_ann_result()?; // read full-dimension from first vector
170
171            let mut block_data: Vec<f32> = vec![f32::default(); cur_block_size * full_dim];
172            for (v, dst) in raw_block
173                .chunks_exact(dim)
174                .zip(block_data.chunks_exact_mut(full_dim))
175            {
176                T::as_f32_into(v, dst).into_ann_result()?;
177            }
178
179            // We need some batch size of data to pass to `compress`. There is a balance
180            // to achieve here. It must be:
181            //
182            // 1. Small enough to allow for parallelism across threads/tasks.
183            // 2. Large enough to take advantage of cache locality in `compress`.
184            //
185            // A value of 128 is a somewhat arbitrary compromise, meaning each task will
186            // process `BATCH_SIZE` many dataset vectors at a time.
187            const BATCH_SIZE: usize = 128;
188
189            // Wrap the data in `MatrixViews` so we do not need to manually construct view
190            // in the compression loop.
191            let mut compressed_block = views::MutMatrixView::try_from(
192                block_compressed_base,
193                cur_block_size,
194                compressed_size,
195            )
196            .bridge_err()?;
197            let base_block =
198                views::MatrixView::try_from(&block_data, cur_block_size, full_dim).bridge_err()?;
199            base_block
200                .par_window_iter(BATCH_SIZE)
201                .zip_eq(compressed_block.par_window_iter_mut(BATCH_SIZE))
202                .try_for_each_in_pool(pool, |(src, dst)| self.quantizer.compress(src, dst))?;
203
204            let write_offset = start_index * compressed_size + std::mem::size_of::<i32>() * 2;
205            compressed_data_writer.seek(SeekFrom::Start(write_offset as u64))?;
206            compressed_data_writer.write_all(block_compressed_base)?;
207            compressed_data_writer.flush()?;
208            Ok(())
209        };
210
211        let progress = process_while_resource_is_available(
212            action,
213            0..num_blocks,
214            chunking_config.continuation_checker.clone_box(),
215        )?
216        .map(|processed| processed * block_size + offset);
217
218        info!(
219            "Quant data generation took {} seconds",
220            timer.elapsed().as_secs_f64()
221        );
222
223        Ok(progress)
224    }
225
226    fn validate_params<Storage: StorageReadProvider + StorageWriteProvider>(
227        &self,
228        num_points: usize,
229        storage_provider: &Storage,
230    ) -> ANNResult<()> {
231        if self.context.offset > num_points {
232            //check to make sure offset is within limits.
233            return Err(ANNError::log_pq_error(
234                "Error: offset for compression is more than number of points",
235            ));
236        }
237
238        let compressed_path = &self.context.compressed_data_path;
239
240        if self.context.offset > 0 {
241            if !storage_provider.exists(compressed_path) {
242                return Err(ANNError::log_file_not_found_error(format!(
243                    "Error: Generator expected compressed file {compressed_path} but did not find it."
244                )));
245            }
246            let expected_length = self.quantizer.compressed_bytes() * self.context.offset
247                + std::mem::size_of::<i32>() * 2;
248            let existing_length =
249                storage_provider.get_length(&self.context.compressed_data_path)?;
250
251            if existing_length != expected_length as u64 {
252                //check to make sure compressed data file lengths is as expected based on offset.
253                return Err(ANNError::log_pq_error(format_args!(
254                    "Error: compressed data file length {existing_length} does not match expected length {expected_length}."
255                )));
256            }
257        }
258
259        Ok(())
260    }
261}
262
263//////////////////
264///// Tests /////
265/////////////////
266
267#[cfg(test)]
268mod generator_tests {
269    use std::{
270        io::BufReader,
271        sync::{Arc, RwLock},
272    };
273
274    use diskann::utils::read_exact_into;
275    use diskann_providers::storage::VirtualStorageProvider;
276    use diskann_providers::utils::{create_thread_pool_for_test, save_bytes};
277    use diskann_utils::{
278        io::{write_bin, Metadata},
279        views::MatrixView,
280    };
281    use rstest::rstest;
282    use vfs::{FileSystem, MemoryFS};
283
284    use super::*;
285    use crate::build::chunking::continuation::{
286        ContinuationGrant, ContinuationTrackerTrait, NaiveContinuationTracker,
287    };
288
289    pub struct DummyCompressor {
290        pub output_dim: u32,
291        pub code: Vec<u8>,
292    }
293    impl DummyCompressor {
294        pub fn new(output_dim: u32) -> Self {
295            Self {
296                output_dim,
297                code: (0..output_dim).map(|x| (x % 256) as u8).collect(),
298            }
299        }
300    }
301    impl QuantCompressor<f32> for DummyCompressor {
302        type CompressorContext = u32;
303
304        fn new_at_stage(
305            _stage: CompressionStage,
306            context: &Self::CompressorContext,
307        ) -> ANNResult<Self> {
308            Ok(Self::new(*context))
309        }
310
311        fn compress(
312            &self,
313            _vector: views::MatrixView<f32>,
314            mut output: views::MutMatrixView<u8>,
315        ) -> ANNResult<()> {
316            output
317                .row_iter_mut()
318                .for_each(|r| r.copy_from_slice(&self.code));
319            Ok(())
320        }
321
322        fn compressed_bytes(&self) -> usize {
323            self.output_dim as usize
324        }
325    }
326
327    fn create_test_data(num_points: usize, dim: usize) -> Vec<f32> {
328        let mut data = Vec::new();
329
330        // Generate some test vector data
331        for i in 0..num_points {
332            for j in 0..dim {
333                data.push((i * dim + j) as f32);
334            }
335        }
336
337        data
338    }
339
340    //Mock continuation checker that stops after stop_count - 1 iterations.
341    struct MockStopContinuationChecker {
342        count: Arc<RwLock<usize>>,
343        stop_count: usize,
344    }
345
346    impl Clone for MockStopContinuationChecker {
347        fn clone(&self) -> Self {
348            MockStopContinuationChecker {
349                count: self.count.clone(),
350                stop_count: self.stop_count,
351            }
352        }
353    }
354
355    impl ContinuationTrackerTrait for MockStopContinuationChecker {
356        fn get_continuation_grant(&self) -> ContinuationGrant {
357            let mut count = self.count.write().unwrap();
358            *count += 1;
359            if !(*count).is_multiple_of(self.stop_count) {
360                ContinuationGrant::Continue
361            } else {
362                ContinuationGrant::Stop
363            }
364        }
365    }
366
367    fn generate_data_and_compressed(
368        num_points: usize,
369        dim: usize,
370        offset: usize,
371        output_dim: u32,
372    ) -> ANNResult<(VirtualStorageProvider<MemoryFS>, String, String)> {
373        let storage_provider = VirtualStorageProvider::new_memory();
374        storage_provider
375            .filesystem()
376            .create_dir("/test_data")
377            .expect("Could not create test directory");
378
379        let data_path = "/test_data/test_data.bin".to_string();
380        let compressed_path = "/test_data/test_compressed.bin".to_string();
381
382        // Setup test data
383        let data = create_test_data(num_points, dim);
384        let view = MatrixView::try_from(data.as_slice(), num_points, dim).unwrap();
385        write_bin(
386            view,
387            &mut storage_provider.create_for_write(data_path.as_str())?,
388        )?;
389
390        if offset > 0 {
391            // write head of file
392            let code = (0..output_dim).map(|x| (x % 256) as u8).collect::<Vec<_>>(); //this is the same code as in DummyQuantizer
393
394            let mut buffer = vec![0_u8; offset * output_dim as usize];
395            buffer
396                .chunks_exact_mut(output_dim as usize)
397                .for_each(|bf| bf.copy_from_slice(code.as_slice()));
398            let _ = save_bytes(
399                &mut storage_provider.create_for_write(compressed_path.as_str())?,
400                buffer.as_slice(),
401                num_points,
402                output_dim as usize,
403                0,
404            )?;
405        }
406
407        Ok((storage_provider, data_path, compressed_path))
408    }
409
410    fn create_and_call_generator<F: vfs::FileSystem>(
411        offset: usize,
412        compressed_path: String,
413        storage_provider: &VirtualStorageProvider<F>,
414        data_path: String,
415        output_dim: u32,
416        chunking_config: &ChunkingConfig,
417    ) -> (
418        QuantDataGenerator<f32, DummyCompressor>,
419        Result<Progress, ANNError>,
420    ) {
421        let pool: diskann_providers::utils::RayonThreadPool = create_thread_pool_for_test();
422        // Create generator
423        let context = GeneratorContext::new(offset, compressed_path.clone());
424        let generator = QuantDataGenerator::<f32, DummyCompressor>::new(
425            data_path.clone(),
426            context,
427            &output_dim,
428        )
429        .unwrap();
430        // Run generator
431        let result = generator.generate_data(storage_provider, pool.as_ref(), chunking_config);
432        (generator, result)
433    }
434
435    #[rstest]
436    #[case(100, 8, 4, 0, 10, 100 * 4)] //small test that fits in BATCH_SIZE
437    #[case(100, 8, 4, 50, 10, 100 * 4)] //small test that fits in BATCH_SIZE with offset > 0
438    #[case(257, 4, 8, 0, 10, 257 * 8)] //larger than BATCH_SIZE and not multiple of it
439    #[case(60_000, 384, 192, 5_000, 10, 60_000 * 192)] //larger than chunk_vector_count = 10_000 with offset > 0
440    #[case(60_000, 384, 192, 0, 10, 60_000 * 192)] //larger than chunk_vector_count = 10_000 with offset = 0
441    #[case(60_000, 384, 192, 0, 2, 10_000 * 192)] //should stop after 1 action block
442    #[case(60_000, 384, 192, 1000, 2, 11_000 * 192)] //same as above but with offset
443    fn test_generate_data_from_offset(
444        #[case] num_points: usize,
445        #[case] dim: usize,
446        #[case] output_dim: u32,
447        #[case] offset: usize,
448        #[case] config_stop_count: usize,
449        #[case] expected_size: usize,
450    ) -> ANNResult<()> {
451        let (storage_provider, data_path, compressed_path) =
452            generate_data_and_compressed(num_points, dim, offset, output_dim)?;
453
454        let chunking_config = ChunkingConfig {
455            continuation_checker: Box::new(MockStopContinuationChecker {
456                count: Arc::new(RwLock::new(0)),
457                stop_count: config_stop_count,
458            }),
459            data_compression_chunk_vector_count: 10_000,
460            inmemory_build_chunk_vector_count: 10_000,
461        };
462
463        let (generator, result) = create_and_call_generator(
464            offset,
465            compressed_path.clone(),
466            &storage_provider,
467            data_path,
468            output_dim,
469            &chunking_config,
470        );
471
472        assert!(result.is_ok(), "Result is not ok, got {:?}", result); //should have completed correctly
473        assert!(storage_provider.exists(&compressed_path)); // Verify output file
474
475        // Check compressed data size
476        let file_len = storage_provider.get_length(&compressed_path)? as usize;
477        assert_eq!(file_len, expected_size + 2 * std::mem::size_of::<i32>());
478
479        let mut r = storage_provider.open_reader(compressed_path.as_str())?;
480        let mut reader = BufReader::new(&mut r);
481        let metadata = Metadata::read(&mut reader)?;
482
483        let data: Vec<u8> = read_exact_into(&mut reader, expected_size)?;
484
485        // Check header
486        assert_eq!(metadata.ndims_u32(), output_dim);
487        assert_eq!(metadata.npoints(), num_points);
488
489        // Check compressed data content
490        data.chunks_exact(output_dim as usize)
491            .for_each(|chunk| assert_eq!(chunk, generator.quantizer.code.as_slice()));
492
493        Ok(())
494    }
495
496    #[test]
497    fn test_stop_and_continue_chunking_config() -> ANNResult<()> {
498        let (num_points, dim, output_dim) = (256, 128, 128);
499        let chunking_config = ChunkingConfig {
500            continuation_checker: Box::<NaiveContinuationTracker>::default(),
501            data_compression_chunk_vector_count: 10,
502            inmemory_build_chunk_vector_count: 10,
503        };
504        let (storage_provider, data_path, compressed_path) =
505            generate_data_and_compressed(num_points, dim, 0, output_dim)?;
506        let (mut generator, mut result) = create_and_call_generator(
507            0,
508            compressed_path.clone(),
509            &storage_provider,
510            data_path.clone(),
511            output_dim,
512            &chunking_config,
513        );
514        loop {
515            match result.as_ref().unwrap() {
516                Progress::Completed => break,
517                Progress::Processed(num_points) => {
518                    (generator, result) = create_and_call_generator(
519                        *num_points,
520                        compressed_path.clone(),
521                        &storage_provider,
522                        data_path.clone(),
523                        output_dim,
524                        &chunking_config,
525                    );
526                }
527            }
528        }
529
530        assert!(result.is_ok(), "Result is not ok, got {:?}", result); //should have completed correctly
531        assert!(storage_provider.exists(&compressed_path)); // Verify output file
532
533        // Check compressed data size
534        let file_len = storage_provider.get_length(&compressed_path)? as usize;
535        let expected_size = (num_points * output_dim as usize) + 2 * std::mem::size_of::<i32>();
536        assert_eq!(file_len, expected_size,);
537
538        let mut r = storage_provider.open_reader(compressed_path.as_str())?;
539        let mut reader = BufReader::new(&mut r);
540        let metadata = Metadata::read(&mut reader)?;
541
542        let data: Vec<u8> =
543            read_exact_into(&mut reader, expected_size - 2 * std::mem::size_of::<i32>())?;
544
545        // Check header
546        assert_eq!(metadata.ndims_u32(), output_dim);
547        assert_eq!(metadata.npoints(), num_points);
548
549        // Check compressed data content
550        data.chunks_exact(output_dim as usize)
551            .for_each(|chunk| assert_eq!(chunk, generator.quantizer.code.as_slice()));
552        Ok(())
553    }
554
555    #[rstest]
556    #[case(
557        1_024,
558        384,
559        192,
560        1_025,
561        0,
562        "offset for compression is more than number of points"
563    )]
564    #[case(
565        1_1024,
566        384,
567        192,
568        5,
569        15,
570        "compressed data file length 2888 does not match expected length 968."
571    )]
572    fn test_offset_error_case(
573        #[case] num_points: usize,
574        #[case] dim: usize,
575        #[case] output_dim: u32,
576        #[case] offset: usize,
577        #[case] error_offset: usize,
578        #[case] msg: String,
579    ) -> ANNResult<()> {
580        assert!(offset > 0);
581        let (storage_provider, data_path, compressed_path) =
582            generate_data_and_compressed(num_points, dim, error_offset, output_dim)?;
583
584        let (_, result) = create_and_call_generator(
585            offset,
586            compressed_path,
587            &storage_provider,
588            data_path,
589            output_dim,
590            &ChunkingConfig::default(),
591        );
592
593        assert!(result.is_err());
594        if let Err(e) = result {
595            let error_msg = format!("{:?}", e);
596            assert!(error_msg.contains(&msg), "{}", &error_msg);
597        }
598
599        Ok(())
600    }
601}