andygrove opened a new issue, #880:
URL: https://github.com/apache/arrow-rs-object-store/issues/880

   **Is your feature request related to a problem or challenge? Please describe 
what you are trying to do.**
   
   Since #628, `read_range` in `LocalFileSystem` zero-fills each buffer before 
reading into it: it calls `buf.resize(requested, 0)` and then fills the buffer 
with `read_at`. Before #628 it did a `seek` and then 
`take(requested).read_to_end(&mut buf)`, which reads straight into the `Vec`'s 
uninitialized capacity. #628 saved the `lseek` per range, but added a pass over 
every byte read.
   
   I benchmarked the three ways to read a range of a 474 MB file that is 
already in the page cache, with a fresh `Vec` per range as `read_range` uses 
(std only, source below, Apple M3 Max, macOS, median of 15 interleaved rounds, 
GB/s):
   
   | Range | zero-fill + `read_at` (main) | `seek` + `read_to_end` (before 
#628) | `read_at` into uninitialized capacity |
   |---:|---:|---:|---:|
   | 16 KiB | 9.68 | 7.40 | 10.68 |
   | 64 KiB | 11.12 | 11.85 | 13.75 |
   | 256 KiB | 13.66 | 14.74 | 15.38 |
   | 1 MiB | 14.59 | 15.58 | 15.80 |
   | 8 MiB | 11.42 | 11.04 | 11.04 |
   
   Reading into uninitialized capacity is 8% to 19% faster from 64 KiB to 1 
MiB, the range of most Parquet column chunk and page reads. At 16 KiB, saving 
the `lseek` matters more than the zero-fill, consistent with #628's benchmark, 
which read ranges of about 8 KiB. At 8 MiB the page faults on each fresh 
allocation dominate, and all three are the same.
   
   The end-to-end effect is small. I found this profiling DataFusion Parquet 
scans through Apache DataFusion Comet, where `read_range` was 1% to 4% of CPU 
samples, so it would save a fraction of a percent of a scan. It is a cheap 
change to the hottest local read path, though.
   
   **Describe the solution you'd like**
   
   Read into the `Vec`'s spare capacity without initializing it first, the way 
std's `File::read_buf` does for `read_to_end`. std has no stable positioned 
read into uninitialized memory yet, so that means either a small `unsafe` block 
that calls `pread` with a pointer into `spare_capacity_mut()` and then 
`set_len`s the bytes read, or waiting for the `BorrowedBuf`-based positioned 
reads to stabilize.
   
   Note that the `read_at`-into-uninitialized arm in the benchmark hands 
`read_at` a `&mut [u8]` over uninitialized memory. That is fine for measuring, 
but a real implementation should pass the raw pointer to `pread` instead of 
making that slice.
   
   **Describe alternatives you've considered**
   
   Keep `read_at` with the zero-fill for small ranges and go back to `seek` + 
`read_to_end` above a threshold of around 32 KiB. That needs no `unsafe`, and 
gets most of the gain from 64 KiB up (the middle column above).
   
   **Additional context**
   
   Benchmark source, built with `rustc -C opt-level=3 readbench.rs` and run as 
`./readbench <file> <range_bytes> <rounds>`:
   
   <details>
   <summary>readbench.rs</summary>
   
   ```rust
   // Compares object_store LocalFileSystem's read_range strategies on a 
page-cache-resident file.
   //
   //   zero_fill_read_at: current main (since 
apache/arrow-rs-object-store#628):
   //                      Vec::with_capacity + resize(n, 0) + read_at loop
   //   seek_read_to_end:  before #628: seek + take(n).read_to_end into an 
uninitialized Vec
   //   pread_uninit:      read_at straight into the Vec's spare capacity 
(reference only)
   //
   // Each range gets a fresh Vec, as read_range does. Usage: readbench <file> 
<range_bytes> <rounds>
   use std::fs::File;
   use std::io::{Read, Seek, SeekFrom};
   use std::os::unix::fs::FileExt;
   use std::time::Instant;
   
   fn zero_fill_read_at(file: &File, off: u64, n: usize) -> Vec<u8> {
       let mut buf = Vec::with_capacity(n);
       buf.resize(n, 0u8);
       let mut slice = &mut buf[..];
       let mut offset = off;
       while !slice.is_empty() {
           let r = file.read_at(slice, offset).unwrap();
           if r == 0 {
               break;
           }
           let tmp = slice;
           slice = &mut tmp[r..];
           offset += r as u64;
       }
       buf
   }
   
   fn seek_read_to_end(file: &mut File, off: u64, n: usize) -> Vec<u8> {
       file.seek(SeekFrom::Start(off)).unwrap();
       let mut buf = Vec::with_capacity(n);
       (&mut *file).take(n as u64).read_to_end(&mut buf).unwrap();
       buf
   }
   
   fn pread_uninit(file: &File, off: u64, n: usize) -> Vec<u8> {
       let mut buf: Vec<u8> = Vec::with_capacity(n);
       let mut filled = 0usize;
       while filled < n {
           // Reference only: hands read_at a slice over uninitialized capacity.
           let dst = unsafe { 
std::slice::from_raw_parts_mut(buf.as_mut_ptr().add(filled), n - filled) };
           let r = file.read_at(dst, off + filled as u64).unwrap();
           if r == 0 {
               break;
           }
           filled += r;
       }
       unsafe { buf.set_len(filled) };
       buf
   }
   
   fn main() {
       let args: Vec<String> = std::env::args().collect();
       let path = &args[1];
       let range: usize = args[2].parse().unwrap();
       let rounds: usize = args[3].parse().unwrap();
       let mut file = File::open(path).unwrap();
       let len = file.metadata().unwrap().len();
       let ranges: Vec<u64> = (0..len / range as u64).map(|i| i * range as 
u64).collect();
       let total_bytes = ranges.len() as f64 * range as f64;
   
       // Warm the page cache.
       let mut sink = 0u64;
       for &o in &ranges {
           sink += seek_read_to_end(&mut file, o, range)[0] as u64;
       }
   
       let names = ["zero_fill_read_at", "seek_read_to_end", "pread_uninit"];
       let mut results: Vec<Vec<f64>> = vec![Vec::new(); 3];
       for _ in 0..rounds {
           for (arm, res) in results.iter_mut().enumerate() {
               let t = Instant::now();
               for &o in &ranges {
                   let b = match arm {
                       0 => zero_fill_read_at(&file, o, range),
                       1 => seek_read_to_end(&mut file, o, range),
                       _ => pread_uninit(&file, o, range),
                   };
                   sink = sink.wrapping_add(b[b.len() / 2] as u64);
               }
               res.push(total_bytes / t.elapsed().as_secs_f64() / 1e9);
           }
       }
       for (name, mut r) in names.iter().zip(results) {
           r.sort_by(|a, b| a.partial_cmp(b).unwrap());
           println!(
               "{:>18} range={:>8} median={:6.2} GB/s min={:6.2} max={:6.2}",
               name,
               range,
               r[r.len() / 2],
               r[0],
               r[r.len() - 1]
           );
       }
       eprintln!("sink {sink}");
   }
   ```
   
   </details>
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to