Skip to main content

diskann_disk/search/provider/aligned_file_reader/reader/
linux.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5use std::{
6    fs::OpenOptions,
7    os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
8};
9
10use diskann::{ANNError, ANNResult};
11use io_uring::IoUring;
12use libc;
13
14use crate::search::provider::aligned_file_reader::{
15    platform::IOContext, traits::AlignedFileReader, AlignedRead, A512,
16};
17
18pub const MAX_IO_CONCURRENCY: usize = 128;
19
20pub struct LinuxAlignedFileReader {
21    io_context: IOContext,
22}
23
24/// AlignedFileReader for Linux.  When you modify this class run the benchmarks to make sure
25/// we don't regress on runtime.
26///
27/// # Run this before making code your change
28/// cargo bench --bench bench_main -p diskann -- --save-baseline prior_to_change
29///
30/// # Run this after making your code change to generate comparison metrics
31/// cargo bench --bench bench_main -p diskann -- --baseline prior_to_change
32impl LinuxAlignedFileReader {
33    pub fn new(fname: &str) -> ANNResult<Self> {
34        // Open file as read-only
35        // Apply the `O_DIRECT` flag to bypass the kernel page cache.
36        // See: https://man7.org/linux/man-pages/man2/open.2.html
37        let open_result = OpenOptions::new()
38            .read(true)
39            .custom_flags(libc::O_DIRECT)
40            .open(fname);
41
42        let file = match open_result {
43            Ok(file_handle) => file_handle,
44            Err(err) => {
45                return Err(ANNError::log_io_error(err));
46            }
47        };
48
49        let ring = IoUring::new(MAX_IO_CONCURRENCY as u32)?;
50        let fd = file.as_raw_fd();
51        ring.submitter().register_files(std::slice::from_ref(&fd))?;
52        let io_context = IOContext::new(file, ring);
53
54        Ok(LinuxAlignedFileReader { io_context })
55    }
56
57    fn submit_aligned_read(
58        aligned_read: &mut AlignedRead<u8, A512>,
59        ring: &mut IoUring,
60        identifier: u64,
61    ) -> Result<(), ANNError> {
62        let fixed_buffer = libc::iovec {
63            iov_base: aligned_read.aligned_buf_mut().as_mut_ptr() as _,
64            iov_len: aligned_read.aligned_buf_mut().len() as _,
65        };
66
67        let read = io_uring::opcode::Read::new(
68            // 0 represents the file descriptor that was registered with the ring via `register_files()` method.
69            io_uring::types::Fixed(0),
70            fixed_buffer.iov_base.cast::<u8>(),
71            fixed_buffer.iov_len as _,
72        )
73        .offset(aligned_read.offset())
74        .build()
75        .user_data(identifier);
76
77        // Submission should not fail because the batch_size should always be less
78        // than MAX_IO_CONCURRENCY and the ring was initialized with MAX_IO_CONCURRENCY
79        // spaces in the processing queue
80        unsafe {
81            ring.submission()
82                .push(&read)
83                .map_err(ANNError::log_push_error)?
84        };
85        Ok(())
86    }
87}
88
89impl AlignedFileReader for LinuxAlignedFileReader {
90    /// O_DIRECT requires the buffer pointer to be aligned to the device sector
91    /// size in memory (512 bytes on typical Linux block devices).
92    type Alignment = A512;
93
94    // Read the data from the file by sending concurrent io requests in batches.
95    fn read(&mut self, read_requests: &mut [AlignedRead<u8, A512>]) -> ANNResult<()> {
96        let n_requests = read_requests.len();
97        let n_batches = n_requests.div_ceil(MAX_IO_CONCURRENCY);
98
99        let ring = &mut self.io_context.ring;
100
101        for batch_idx in 0..n_batches {
102            // batch_size is the number of requests to submit, not the size of the request.
103            let batch_start = MAX_IO_CONCURRENCY * batch_idx;
104            let batch_size = std::cmp::min(n_requests - batch_start, MAX_IO_CONCURRENCY);
105
106            for j in 0..batch_size {
107                let read_id = j + batch_start;
108                let aligned_read = &mut read_requests[read_id];
109                Self::submit_aligned_read(aligned_read, ring, read_id as u64)?;
110            }
111
112            // Wait for the batch to complete.
113            ring.submit_and_wait(batch_size)?;
114
115            // N.B.: Flushing the completion queue appears to be important for proper
116            // operation.
117            // Flush the completion queue.
118            for cqe in ring.completion() {
119                if cqe.result() < 0 {
120                    return Err(ANNError::log_io_error(std::io::Error::from_raw_os_error(
121                        cqe.result(),
122                    )));
123                }
124            }
125        }
126
127        Ok(())
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use std::{
134        cmp::max,
135        fs::File,
136        io::{BufReader, Read, Seek, SeekFrom},
137    };
138
139    use bincode::deserialize_from;
140    use serde::{Deserialize, Serialize};
141
142    use super::*;
143    use diskann_quantization::alloc::{AlignedAllocator, Poly};
144    pub const TEST_INDEX_PATH: &str =
145        "../test_data/disk_index_misc/disk_index_siftsmall_learn_256pts_R4_L50_A1.2_aligned_reader_test.index";
146    pub const TRUTH_NODE_DATA_PATH: &str =
147        "../test_data/disk_index_misc/disk_index_node_data_aligned_reader_truth.bin";
148    const DEFAULT_DISK_SECTOR_LEN: usize = 4096;
149
150    #[derive(Debug, Serialize, Deserialize)]
151    struct NodeData {
152        num_neighbors: u32,
153        coordinates: Vec<f32>,
154        neighbors: Vec<u32>,
155    }
156
157    impl PartialEq for NodeData {
158        fn eq(&self, other: &Self) -> bool {
159            self.num_neighbors == other.num_neighbors
160                && self.coordinates == other.coordinates
161                && self.neighbors == other.neighbors
162        }
163    }
164
165    #[test]
166    fn test_new_aligned_file_reader() {
167        // Replace "test_file_path" with actual file path
168        let result = LinuxAlignedFileReader::new(TEST_INDEX_PATH);
169        assert!(result.is_ok());
170    }
171
172    #[test]
173    fn test_read() {
174        let mut reader = LinuxAlignedFileReader::new(TEST_INDEX_PATH).unwrap();
175
176        let read_length = 512; // adjust according to your logic
177        let num_read = 10;
178        let mut aligned_mem =
179            Poly::broadcast(0u8, read_length * num_read, AlignedAllocator::A512).unwrap();
180
181        // create and add AlignedReads to the vector
182        let mut mem_slices: Vec<&mut [u8]> = aligned_mem.chunks_mut(read_length).collect();
183
184        let mut aligned_reads: Vec<AlignedRead<'_, u8, A512>> = mem_slices
185            .iter_mut()
186            .enumerate()
187            .map(|(i, slice)| {
188                let offset = (i * read_length) as u64;
189                AlignedRead::new(offset, slice).unwrap()
190            })
191            .collect();
192
193        let result = reader.read(&mut aligned_reads);
194        assert!(result.is_ok());
195
196        // Assert that the actual data is correct.
197        let mut file = File::open(TEST_INDEX_PATH).unwrap();
198        for current_read in aligned_reads {
199            let mut expected = vec![0; current_read.aligned_buf().len()];
200            file.seek(SeekFrom::Start(current_read.offset())).unwrap();
201            file.read_exact(&mut expected).unwrap();
202
203            assert_eq!(
204                expected,
205                current_read.aligned_buf(),
206                "aligned_buf did not contain the expected data"
207            );
208        }
209    }
210
211    /// BUG: io-uring submit_and_wait waits for a cumulative number of items to be completed, not
212    /// just the current batch.  This causes the LinuxAlignedFileReader.read method to return
213    /// before the final batches have been completely read.  The purpose of this test is to
214    /// force many batches to be queued for read and ensure that all are read when the
215    /// LinuxAlignedFileReader.read method returns.
216    #[test]
217    fn many_batches_all_should_have_data() {
218        let mut reader = LinuxAlignedFileReader::new(TEST_INDEX_PATH).unwrap();
219
220        let read_length = 512;
221        let num_read = MAX_IO_CONCURRENCY * 100; // The LinuxAlignedFileReader batches reads according to MAX_IO_CONCURRENCY.  Make sure we have many batches to handle.
222        let mut aligned_mem =
223            Poly::broadcast(0u8, read_length * num_read, AlignedAllocator::A512).unwrap();
224
225        // create and add AlignedReads to the vector
226        let mut mem_slices: Vec<&mut [u8]> = aligned_mem.chunks_mut(read_length).collect();
227
228        // Read the same data from disk over and over again.  We guarantee that it is not all zeros.
229        let mut aligned_reads: Vec<AlignedRead<'_, u8, A512>> = mem_slices
230            .iter_mut()
231            .map(|slice| AlignedRead::new(0, slice).unwrap())
232            .collect();
233
234        let result = reader.read(&mut aligned_reads);
235
236        // Make sure read completed successfully
237        assert!(result.is_ok());
238
239        // If we find any AlignedRead objects that are empty then the reader never read them
240        // from disk.
241        assert!(
242            !aligned_reads.iter().any(aligned_read_buffer_is_empty),
243            "Found uninitialized data that should have been read from disk"
244        );
245    }
246
247    /// Return True if the AlignedRead value is empty or False if the AlignedRead value is not empty.
248    fn aligned_read_buffer_is_empty(read: &AlignedRead<'_, u8, A512>) -> bool {
249        let max_value = read.aligned_buf().iter().fold(0, |acc, &x| max(acc, x));
250
251        // If max_value is zero then this aligned read was not completed.  Data was not
252        // read from disk because all values in memory are zero.
253        max_value == 0
254    }
255
256    #[test]
257    fn test_read_disk_index_by_sector() {
258        let mut reader = LinuxAlignedFileReader::new(TEST_INDEX_PATH).unwrap();
259
260        let read_length = 512; // adjust according to your logic
261        let num_sector = 10;
262        let mut aligned_mem =
263            Poly::broadcast(0u8, read_length * num_sector, AlignedAllocator::A512).unwrap();
264
265        // Each slice will be used as the buffer for a read request of a sector.
266        let mut mem_slices: Vec<&mut [u8]> = aligned_mem.chunks_mut(read_length).collect();
267
268        let mut aligned_reads: Vec<AlignedRead<'_, u8, A512>> = mem_slices
269            .iter_mut()
270            .enumerate()
271            .map(|(sector_id, slice)| {
272                let offset = (sector_id * read_length) as u64;
273                AlignedRead::new(offset, slice).unwrap()
274            })
275            .collect();
276
277        let result = reader.read(&mut aligned_reads);
278        assert!(result.is_ok());
279
280        aligned_reads.iter().for_each(|read| {
281            assert_eq!(read.aligned_buf().len(), 512);
282        });
283
284        let disk_layout_meta = reconstruct_disk_meta(aligned_reads[0].aligned_buf_mut());
285        assert!(disk_layout_meta.len() > 9);
286
287        let dims = disk_layout_meta[1];
288        let num_pts = disk_layout_meta[0];
289        let node_len = disk_layout_meta[3];
290        let max_num_nodes_per_sector = disk_layout_meta[4];
291
292        assert!(node_len * max_num_nodes_per_sector < DEFAULT_DISK_SECTOR_LEN as u64);
293
294        let num_nbrs_start = (dims as usize) * std::mem::size_of::<f32>();
295        let nbrs_buf_start = num_nbrs_start + std::mem::size_of::<u32>();
296
297        let mut node_data_array = Vec::with_capacity(max_num_nodes_per_sector as usize * 9);
298
299        // Only validate the first 9 sectors with graph nodes.
300        (1..9).for_each(|sector_id| {
301            let sector_data = &mem_slices[sector_id];
302            for node_data in sector_data.chunks_exact(node_len as usize) {
303                // Extract coordinates data from the start of the node_data
304                let coordinates_end = (dims as usize) * std::mem::size_of::<f32>();
305                let coordinates = node_data[0..coordinates_end]
306                    .chunks_exact(std::mem::size_of::<f32>())
307                    .map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap()))
308                    .collect();
309
310                // Extract number of neighbors from the node_data
311                let neighbors_num = u32::from_le_bytes(
312                    node_data[num_nbrs_start..nbrs_buf_start]
313                        .try_into()
314                        .unwrap(),
315                );
316
317                let nbors_buf_end =
318                    nbrs_buf_start + (neighbors_num as usize) * std::mem::size_of::<u32>();
319
320                // Extract neighbors from the node data.
321                let mut neighbors = Vec::new();
322                for nbors_data in node_data[nbrs_buf_start..nbors_buf_end]
323                    .chunks_exact(std::mem::size_of::<u32>())
324                {
325                    let nbors_id = u32::from_le_bytes(nbors_data.try_into().unwrap());
326                    assert!(nbors_id < num_pts as u32);
327                    neighbors.push(nbors_id);
328                }
329
330                // Create NodeData struct and push it to the node_data_array
331                node_data_array.push(NodeData {
332                    num_neighbors: neighbors_num,
333                    coordinates,
334                    neighbors,
335                });
336            }
337        });
338
339        // Compare that each node read from the disk index are expected.
340        let node_data_truth_file = File::open(TRUTH_NODE_DATA_PATH).unwrap();
341        let reader = BufReader::new(node_data_truth_file);
342
343        let node_data_vec: Vec<NodeData> = deserialize_from(reader).unwrap();
344        for (node_from_node_data_file, node_from_disk_index) in
345            node_data_vec.iter().zip(node_data_array.iter())
346        {
347            // Verify that the NodeData from the file is equal to the NodeData in node_data_array
348            assert_eq!(node_from_node_data_file, node_from_disk_index);
349        }
350    }
351
352    #[test]
353    fn test_read_fail_invalid_file() {
354        let reader = LinuxAlignedFileReader::new("/invalid_path");
355        assert!(reader.is_err());
356    }
357
358    #[test]
359    #[allow(clippy::read_zero_byte_vec)]
360    fn test_read_no_requests() {
361        let mut reader = LinuxAlignedFileReader::new(TEST_INDEX_PATH).unwrap();
362
363        let mut read_requests = Vec::<AlignedRead<u8, A512>>::new();
364        let result = reader.read(&mut read_requests);
365        assert!(result.is_ok());
366    }
367
368    fn reconstruct_disk_meta(buffer: &[u8]) -> Vec<u64> {
369        let size_of_u64 = std::mem::size_of::<u64>();
370
371        let num_values = buffer.len() / size_of_u64;
372        let mut disk_layout_meta = Vec::with_capacity(num_values);
373        let meta_data = &buffer[8..];
374
375        for chunk in meta_data.chunks_exact(size_of_u64) {
376            let value = u64::from_le_bytes(chunk.try_into().unwrap());
377            disk_layout_meta.push(value);
378        }
379
380        disk_layout_meta
381    }
382}