Skip to main content

diskann_disk/utils/
partition.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5use diskann::{error::IntoANNResult, utils::VectorRepr, ANNError, ANNResult};
6use diskann_providers::storage::{StorageReadProvider, StorageWriteProvider};
7use diskann_providers::utils::{gen_random_slice, RayonThreadPoolRef, READ_WRITE_BLOCK_SIZE};
8
9use crate::utils::{compute_closest_centers, k_meanspp_selecting_pivots, run_lloyds};
10use rand::Rng;
11use tracing::info;
12
13use crate::{
14    disk_index_build_parameter::BYTES_IN_GB,
15    storage::{CachedReader, CachedWriter, DiskIndexWriter},
16};
17
18/// Block size for reading/processing large files and matrices in blocks
19const BLOCK_SIZE_LARGE_FILE: u32 = 10_000;
20
21#[allow(clippy::too_many_arguments)]
22pub fn partition_with_ram_budget<T, StorageProvider, F>(
23    dataset_file: &str,
24    dim: usize,
25    sampling_rate: f64,
26    ram_budget_in_bytes: f64,
27    k_base: usize,
28    merged_index_prefix: &str,
29    storage_provider: &StorageProvider,
30    rng: &mut impl Rng,
31    pool: RayonThreadPoolRef<'_>,
32    ram_estimator: F,
33) -> ANNResult<usize>
34where
35    T: VectorRepr,
36    StorageProvider: StorageReadProvider + StorageWriteProvider,
37    F: Fn(u64, u64) -> f64,
38{
39    // Find partition size and get pivot data
40    let (num_parts, pivot_data, train_dim) = find_partition_size::<T, StorageProvider, F>(
41        dataset_file,
42        sampling_rate,
43        ram_budget_in_bytes,
44        k_base,
45        storage_provider,
46        rng,
47        pool,
48        &ram_estimator,
49    )?;
50
51    info!("Saving shard data into clusters, with only ids");
52
53    shard_data_into_clusters_only_ids::<T, StorageProvider>(
54        dataset_file,
55        &pivot_data,
56        num_parts,
57        dim,
58        train_dim,
59        k_base,
60        merged_index_prefix,
61        storage_provider,
62        pool,
63    )?;
64
65    Ok(num_parts)
66}
67
68#[allow(clippy::too_many_arguments)]
69fn find_partition_size<T, StorageProvider, F>(
70    dataset_file: &str,
71    sampling_rate: f64,
72    ram_budget_in_bytes: f64,
73    k_base: usize,
74    storage_provider: &StorageProvider,
75    rng: &mut impl Rng,
76    pool: RayonThreadPoolRef<'_>,
77    ram_estimator: &F,
78) -> ANNResult<(usize, Vec<f32>, usize)>
79where
80    T: VectorRepr,
81    StorageProvider: StorageReadProvider + StorageWriteProvider,
82    F: Fn(u64, u64) -> f64,
83{
84    const MAX_K_MEANS_REPS: usize = 10;
85
86    let (train_data_float, num_train, train_dim) =
87        gen_random_slice::<T, StorageProvider>(dataset_file, sampling_rate, storage_provider, rng)?;
88    info!("Loaded {} points for train, dim: {}", num_train, train_dim);
89
90    let (test_data_float, num_test, test_dim) =
91        gen_random_slice::<T, StorageProvider>(dataset_file, sampling_rate, storage_provider, rng)?;
92    info!("Loaded {} points for test, dim: {}", num_test, test_dim);
93
94    // Calculate total points accounting for sampling rate
95    let total_points = (num_train as f64 / sampling_rate) as u64;
96    // Get initial partition count estimate
97    let initial_num_parts = estimate_initial_partition_count::<F>(
98        total_points,
99        train_dim as u64,
100        k_base,
101        ram_budget_in_bytes,
102        ram_estimator,
103    );
104
105    let mut num_parts = initial_num_parts;
106    let mut fit_in_ram = false;
107    let mut pivot_data = Vec::new();
108    // Iteratively find the right number of parts, kmeans_partitioning on training data
109    while !fit_in_ram {
110        fit_in_ram = true;
111
112        let mut max_ram_usage_in_bytes = 0.0;
113
114        pivot_data = vec![0.0; num_parts * train_dim];
115
116        // Process Global k-means for kmeans_partitioning Step
117        info!("Processing global k-means (kmeans_partitioning Step)");
118        k_meanspp_selecting_pivots(
119            &train_data_float,
120            num_train,
121            train_dim,
122            &mut pivot_data,
123            num_parts,
124            rng,
125            &mut (false),
126            pool,
127        )?;
128
129        run_lloyds(
130            &train_data_float,
131            num_train,
132            train_dim,
133            &mut pivot_data,
134            num_parts,
135            MAX_K_MEANS_REPS,
136            &mut (false),
137            pool,
138        )?;
139
140        // now pivots are ready. need to stream base points and assign them to closest clusters.
141
142        let mut cluster_sizes = Vec::new();
143        estimate_cluster_sizes(
144            &test_data_float,
145            num_test,
146            &pivot_data,
147            num_parts,
148            test_dim,
149            k_base,
150            &mut cluster_sizes,
151            pool,
152        )?;
153
154        let mut partition_stats = Vec::with_capacity(num_parts);
155        for p in &cluster_sizes {
156            // to account for the fact that p is the size of the shard over the testing sample.
157            let p = (*p as f64 / sampling_rate) as u64;
158            let cur_shard_ram_estimate_in_bytes = ram_estimator(p, train_dim as u64);
159            partition_stats.push((p, cur_shard_ram_estimate_in_bytes));
160
161            if cur_shard_ram_estimate_in_bytes > max_ram_usage_in_bytes {
162                max_ram_usage_in_bytes = cur_shard_ram_estimate_in_bytes;
163            }
164        }
165
166        info!(
167            "Partition RAM estimates (GB): {}",
168            partition_stats
169                .iter()
170                .map(|(size, ram)| format!("#{}: {:.2}", size, ram / BYTES_IN_GB))
171                .collect::<Vec<_>>()
172                .join(", ")
173        );
174
175        info!(
176            "With {} parts, max estimated RAM usage: {:.2} GB, budget given is {:.2} GB",
177            num_parts,
178            max_ram_usage_in_bytes / BYTES_IN_GB,
179            ram_budget_in_bytes / BYTES_IN_GB
180        );
181        if max_ram_usage_in_bytes > ram_budget_in_bytes {
182            fit_in_ram = false;
183            num_parts += 2;
184        } else {
185            info!(
186                "Found optimal partition count: [parts={}, initial={}, max_ram={:.2}GB, budget={:.2}GB]",
187                num_parts,
188                initial_num_parts,
189                max_ram_usage_in_bytes / BYTES_IN_GB,
190                ram_budget_in_bytes / BYTES_IN_GB
191            );
192        }
193    }
194
195    Ok((num_parts, pivot_data, train_dim))
196}
197
198/// Initial estimation of partition count based on dataset characteristics and RAM budget
199fn estimate_initial_partition_count<F>(
200    total_points: u64,
201    dimension: u64,
202    k_base: usize,
203    ram_budget_in_bytes: f64,
204    ram_estimator: &F,
205) -> usize
206where
207    F: Fn(u64, u64) -> f64,
208{
209    // Calculate total RAM needed without partitioning
210    let total_ram_estimate = ram_estimator(total_points * k_base as u64, dimension);
211
212    let mut partition_count = (total_ram_estimate / ram_budget_in_bytes).ceil() as usize;
213
214    // Ensure minimum of 3 partitions and odd number for balanced splitting
215    partition_count = std::cmp::max(3, partition_count);
216    if partition_count.is_multiple_of(2) {
217        partition_count += 1;
218    }
219
220    info!(
221        "Estimated initial partition count: {} (total points: {}, dimension: {}, k_base: {}, total_ram_estimate: {:.2} GB, ram_budget: {:.2} GB)",
222        partition_count,
223        total_points,
224        dimension,
225        k_base,
226        total_ram_estimate / BYTES_IN_GB,
227        ram_budget_in_bytes / BYTES_IN_GB
228    );
229
230    partition_count
231}
232
233#[allow(clippy::too_many_arguments)]
234fn shard_data_into_clusters_only_ids<T, StorageProvider>(
235    dataset_file: &str,
236    pivot_data: &[f32],
237    num_parts: usize,
238    dim: usize,
239    full_dim: usize,
240    k_base: usize,
241    merged_index_prefix: &str,
242    storage_provider: &StorageProvider,
243    pool: RayonThreadPoolRef<'_>,
244) -> ANNResult<()>
245where
246    T: VectorRepr,
247    StorageProvider: StorageReadProvider + StorageWriteProvider,
248{
249    let mut dataset_reader = CachedReader::<StorageProvider>::new(
250        dataset_file,
251        READ_WRITE_BLOCK_SIZE,
252        storage_provider,
253    )?;
254    let num_points = dataset_reader.read_u32()?;
255    let base_dim = dataset_reader.read_u32()?;
256    if base_dim != dim as u32 {
257        return Err(ANNError::log_index_error(
258            "dimensions dont match for train set and base set",
259        ));
260    }
261
262    let mut shard_counts = vec![0; num_parts];
263    let shard_idmaps_names = (0..num_parts)
264        .map(|shard| {
265            DiskIndexWriter::get_merged_index_subshard_id_map_file(merged_index_prefix, shard)
266        })
267        .collect::<Vec<String>>();
268
269    // 8KB cache for small ID map files - matches default BufWriter size
270    const WRITE_ID_CACHE_SIZE: u64 = 8 * 1024;
271    let mut shard_idmap_cached_writers = Vec::new();
272    for name in &shard_idmaps_names {
273        let writer = storage_provider.create_for_write(name)?;
274        let cached_writer =
275            CachedWriter::<StorageProvider>::new(name, WRITE_ID_CACHE_SIZE, writer)?;
276        shard_idmap_cached_writers.push(cached_writer);
277    }
278
279    let dummy_size: u32 = 0;
280    let const_one: u32 = 1;
281    for writer in shard_idmap_cached_writers.iter_mut() {
282        writer.write(&dummy_size.to_le_bytes())?;
283        writer.write(&const_one.to_le_bytes())?;
284    }
285
286    let block_size = if num_points <= BLOCK_SIZE_LARGE_FILE {
287        num_points
288    } else {
289        BLOCK_SIZE_LARGE_FILE
290    };
291
292    let num_blocks = num_points.div_ceil(block_size);
293
294    let mut block_closest_centers = vec![0u32; block_size as usize * k_base];
295    let mut block_data_t: Vec<u8> = vec![0; block_size as usize * dim * std::mem::size_of::<T>()];
296    let mut block_data_float: Vec<f32> = vec![0.0; full_dim * block_size as usize];
297
298    for block in 0..num_blocks {
299        let start_id = (block * block_size) as usize;
300        let end_id = std::cmp::min((block + 1) * block_size, num_points) as usize;
301        let cur_blk_size = end_id - start_id;
302
303        dataset_reader.read(&mut block_data_t[..cur_blk_size * dim * std::mem::size_of::<T>()])?;
304
305        // convert data from type T to f32
306        let cur_vector_t: &[T] =
307            bytemuck::cast_slice(&block_data_t[..cur_blk_size * dim * std::mem::size_of::<T>()]);
308
309        for (v, dst) in cur_vector_t
310            .chunks_exact(dim)
311            .zip(block_data_float.chunks_exact_mut(full_dim))
312        {
313            T::as_f32_into(v, dst).into_ann_result()?;
314        }
315
316        compute_closest_centers(
317            &block_data_float[..full_dim * cur_blk_size],
318            cur_blk_size,
319            full_dim,
320            pivot_data,
321            num_parts,
322            k_base,
323            &mut block_closest_centers[..cur_blk_size * k_base],
324            None,
325            None,
326            pool,
327        )?;
328
329        for p in 0..cur_blk_size {
330            for p1 in 0..k_base {
331                let shard_id = block_closest_centers[p * k_base + p1] as usize;
332                let original_point_map_id = (start_id + p) as u32;
333                shard_idmap_cached_writers[shard_id].write(&original_point_map_id.to_le_bytes())?;
334                shard_counts[shard_id] += 1;
335            }
336        }
337    }
338
339    let mut total_count = 0;
340
341    for i in 0..num_parts {
342        let cur_shard_count = shard_counts[i] as u32;
343        info!(" shard_{} with npts : {} ", i, cur_shard_count);
344        total_count += cur_shard_count;
345        shard_idmap_cached_writers[i].reset()?;
346        shard_idmap_cached_writers[i].write(&cur_shard_count.to_le_bytes())?;
347        shard_idmap_cached_writers[i].flush()?;
348    }
349
350    info!(
351        "Partitioned {} with replication factor {} to get {} points across {} shards",
352        num_points, k_base, total_count, num_parts
353    );
354
355    Ok(())
356}
357
358#[allow(clippy::too_many_arguments)]
359fn estimate_cluster_sizes(
360    data_float: &[f32],
361    num_pts: usize,
362    pivot_data: &[f32],
363    num_centers: usize,
364    dim: usize,
365    k_base: usize,
366    cluster_sizes: &mut Vec<u32>,
367    pool: RayonThreadPoolRef<'_>,
368) -> ANNResult<()> {
369    cluster_sizes.clear();
370    let mut shard_counts = vec![0; num_centers];
371
372    let block_size = if num_pts <= BLOCK_SIZE_LARGE_FILE as usize {
373        num_pts
374    } else {
375        BLOCK_SIZE_LARGE_FILE as usize
376    };
377
378    let mut block_closest_centers = vec![0; block_size * k_base];
379
380    let num_blocks = num_pts.div_ceil(block_size);
381
382    for block in 0..num_blocks {
383        let start_id = block * block_size;
384        let end_id = std::cmp::min((block + 1) * block_size, num_pts);
385        let cur_blk_size = end_id - start_id;
386
387        let block_data_float = &data_float[start_id * dim..(start_id + cur_blk_size) * dim];
388
389        compute_closest_centers(
390            block_data_float,
391            cur_blk_size,
392            dim,
393            pivot_data,
394            num_centers,
395            k_base,
396            &mut block_closest_centers[..cur_blk_size * k_base],
397            None,
398            None,
399            pool,
400        )?;
401
402        for p in 0..cur_blk_size {
403            for p1 in 0..k_base {
404                let shard_id = block_closest_centers[p * k_base + p1] as usize;
405                shard_counts[shard_id] += 1;
406            }
407        }
408    }
409
410    (0..num_centers).for_each(|i| {
411        let cur_shard_count = shard_counts[i] as u32;
412        cluster_sizes.push(cur_shard_count);
413    });
414    info!("Estimated cluster sizes: {:?}", cluster_sizes);
415    Ok(())
416}
417
418#[cfg(test)]
419mod partition_test {
420    use std::io::Read;
421
422    use diskann_providers::storage::VirtualStorageProvider;
423    use diskann_providers::utils::create_thread_pool_for_test;
424    use diskann_utils::test_data_root;
425    use vfs::{MemoryFS, OverlayFS};
426
427    use super::*;
428
429    #[test]
430    fn test_estimate_cluster_sizes() {
431        let data_float = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
432        let num_pts = 3;
433        let pivot_data = &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
434        let num_centers = 3;
435        let dim = 2;
436        let k_base = 2;
437        let mut cluster_sizes = vec![];
438        let pool = create_thread_pool_for_test();
439
440        estimate_cluster_sizes(
441            &data_float,
442            num_pts,
443            pivot_data,
444            num_centers,
445            dim,
446            k_base,
447            &mut cluster_sizes,
448            pool.as_ref(),
449        )
450        .unwrap();
451
452        assert_eq!(cluster_sizes.len(), num_centers);
453        assert_eq!(cluster_sizes, &[2, 3, 1]);
454    }
455
456    #[test]
457    fn test_shard_data_into_clusters_only_ids() {
458        // create a temporary file for the dataset
459        let dataset_path = "/dataset_file";
460        // write some dummy data to the dataset file
461        let mut data_float = Vec::new();
462        let num_points: u32 = 100;
463        let dim: usize = 10;
464
465        let storage_provider = VirtualStorageProvider::new_overlay(test_data_root());
466        {
467            let writer = storage_provider.create_for_write(dataset_path).unwrap();
468            let mut dataset_writer = CachedWriter::<VirtualStorageProvider<MemoryFS>>::new(
469                dataset_path,
470                READ_WRITE_BLOCK_SIZE,
471                writer,
472            )
473            .unwrap();
474            dataset_writer.write(&num_points.to_le_bytes()).unwrap();
475            dataset_writer.write(&dim.to_le_bytes()).unwrap();
476            for i in 0..num_points {
477                for j in 0..dim {
478                    let val = (i * dim as u32 + j as u32) as f32;
479                    data_float.push(val);
480                    dataset_writer.write(&val.to_le_bytes()).unwrap();
481                }
482            }
483        }
484
485        // create some dummy pivot data
486        let k_base: usize = 2;
487        let num_parts = 3;
488
489        // generate pivot data
490        let pivot_data: [f32; 30] = [
491            820.0, 821.0, 822.0, 823.0, 824.0, 825.0, 826.0, 827.0, 828.0, 829.0, 155.0, 156.0,
492            157.0, 158.0, 159.0, 160.0, 161.0, 162.0, 163.0, 164.0, 480.0, 481.0, 482.0, 483.0,
493            484.0, 485.0, 486.0, 487.0, 488.0, 489.0,
494        ];
495
496        // create a temporary prefix for the merged index prefix
497        let merged_index_prefix = "/merged_index";
498        let pool = create_thread_pool_for_test();
499        // call the function being tested
500        shard_data_into_clusters_only_ids::<f32, VirtualStorageProvider<OverlayFS>>(
501            dataset_path,
502            &pivot_data,
503            num_parts,
504            dim,
505            dim,
506            k_base,
507            merged_index_prefix,
508            &storage_provider,
509            pool.as_ref(),
510        )
511        .unwrap();
512
513        // check that the output is as expected
514        let expected_prefix = "/partition/id_maps/merged_index_expected";
515        for shard in 0..num_parts {
516            let path1 =
517                DiskIndexWriter::get_merged_index_subshard_id_map_file(merged_index_prefix, shard);
518            let path2 =
519                DiskIndexWriter::get_merged_index_subshard_id_map_file(expected_prefix, shard);
520            let file1 =
521                load_file_to_vec::<VirtualStorageProvider<OverlayFS>>(&path1, &storage_provider);
522            let file2 =
523                load_file_to_vec::<VirtualStorageProvider<OverlayFS>>(&path2, &storage_provider);
524
525            assert_eq!(file1.len(), file2.len());
526            assert_eq!(file1[..], file2[..]);
527
528            // clean up the temporary files and directory
529            storage_provider.delete(&path1).unwrap();
530        }
531
532        storage_provider.delete(dataset_path).unwrap();
533    }
534
535    fn load_file_to_vec<StorageProvider>(
536        file_path: &str,
537        storage_provider: &StorageProvider,
538    ) -> Vec<u8>
539    where
540        StorageProvider: StorageReadProvider,
541    {
542        let mut file = storage_provider.open_reader(file_path).unwrap();
543        let mut buffer = vec![];
544        file.read_to_end(&mut buffer).unwrap();
545        buffer
546    }
547
548    #[test]
549    fn test_estimate_initial_partition_count_minimum_clamp() {
550        // When total RAM fits well within budget, should clamp to minimum of 3
551        let count = estimate_initial_partition_count(
552            100,                       // total_points
553            10,                        // dimension
554            1,                         // k_base
555            1_000_000.0,               // ram_budget_in_bytes
556            &|n, _d| n as f64 * 100.0, // total_ram = 100 * 100 = 10_000 << 1_000_000 => clamp to 3
557        );
558        assert_eq!(count, 3);
559    }
560
561    #[test]
562    fn test_estimate_initial_partition_count_odd_rounding() {
563        // Even partition count should be bumped to odd
564        let count = estimate_initial_partition_count(
565            1000,
566            128,
567            1,
568            1000.0,                  // budget
569            &|n, _d| n as f64 * 4.0, // total_ram = 4000, ratio = 4 => ceil = 4 (even) => 5
570        );
571        assert_eq!(count, 5);
572    }
573
574    #[test]
575    fn test_estimate_initial_partition_count_large_ratio() {
576        // Odd result that is >= 3 should be returned as-is
577        let count = estimate_initial_partition_count(
578            1000,
579            128,
580            1,
581            1000.0,                  // budget
582            &|n, _d| n as f64 * 7.0, // total_ram = 7000, ratio = 7 => odd, >= 3
583        );
584        assert_eq!(count, 7);
585    }
586
587    #[test]
588    fn test_estimate_initial_partition_count_k_base_multiplier() {
589        // k_base multiplies total_points in the estimator call
590        let count = estimate_initial_partition_count(
591            100,
592            10,
593            3,                       // k_base
594            100.0,                   // budget
595            &|n, _d| n as f64 * 1.0, // n = total_points * k_base = 300, total_ram = 300, ratio = 3
596        );
597        assert_eq!(count, 3);
598    }
599
600    #[test]
601    fn test_partition_with_ram_budget() -> ANNResult<()> {
602        let storage_provider = VirtualStorageProvider::new_overlay(test_data_root());
603        let dataset_file = "/sift/siftsmall_learn.bin";
604        let mut file = storage_provider.open_reader(dataset_file).unwrap();
605        let mut data = vec![];
606        file.read_to_end(&mut data).unwrap();
607
608        let sampling_rate = 1.0;
609        let ram_budget_in_bytes = 15_000_000.0;
610        let max_degree = 64;
611        let k_base = 2;
612        let merged_index_prefix = "/test_merged_index_prefix";
613        let pool = create_thread_pool_for_test();
614
615        let num_parts = partition_with_ram_budget::<f32, _, _>(
616            dataset_file,
617            128, //sift is 128 dimensions
618            sampling_rate,
619            ram_budget_in_bytes,
620            k_base,
621            merged_index_prefix,
622            &storage_provider,
623            &mut diskann_providers::utils::create_rnd_in_tests(),
624            pool.as_ref(),
625            |num_points, dim| {
626                // Simple RAM estimation for test - capture datasize and graph_degree from context
627                use diskann_providers::model::GRAPH_SLACK_FACTOR;
628
629                let datasize = std::mem::size_of::<f32>() as u64;
630                let graph_degree = max_degree as u64;
631                let dataset_size = (num_points * dim.next_multiple_of(8u64) * datasize) as f64;
632                let graph_size = (num_points * graph_degree * 4) as f64 * GRAPH_SLACK_FACTOR;
633                1.1 * (dataset_size + graph_size)
634            },
635        )?;
636
637        assert!(num_parts >= 3);
638
639        for i in 0..num_parts {
640            let idmap_filename =
641                DiskIndexWriter::get_merged_index_subshard_id_map_file(merged_index_prefix, i);
642            storage_provider.delete(&idmap_filename)?;
643        }
644
645        Ok(())
646    }
647}