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