Files
clawhdf5/crates/clawhdf5-io/src/prefetch.rs
T
osobhandClaude Opus 5.5 60502593b7 fix: apply a metadata cache image without copying the file
apply_cache_image returned a copy of the whole file with the image's
entries written in, and File (mmap by default), MmapFile and LazyFile
used that copy for every read: opening a 1 GiB sparse file with an image
needed 2 GB of memory, and an 8 GiB one aborted the process, where
de2a53f (which ignored the image) opened them in a few MB.

The metadata parsers read one contiguous slice, so the image still has
to be laid over the file's bytes; it is now laid over a private copy
that costs only the pages it touches:

- clawhdf5_format::superblock_ext::CacheImage decodes the image into an
  entry list (address, offset in the block, length) and applies it to
  any destination; cache_image_state tells an opener whether the file
  has no image, a loadable one, or one libhdf5 cannot load;
  apply_cache_image_in_place is for readers that own their buffer.
  apply_cache_image and metadata_view (which copied) are gone.
- clawhdf5_io::HDF5Read::private_copy returns a writable private copy
  of a reader's bytes: MmapReader gives a MAP_PRIVATE copy-on-write
  mapping (memmap2 map_copy), so only the pages the entries land on are
  copied; the default copies the bytes (in-memory readers).
- File, MmapFile and LazyFile write the image into that mapping
  (crate::cache_image). File::from_bytes / open_buffered patch their own
  buffer in place, copying only the image block, as libhdf5 does. A
  file without an image is read straight from the mapping, unchanged.

An image entry that runs past the end of file is now refused: libhdf5
checks only that it starts inside the file, and the images libhdf5
writes never do this, but those bytes have nowhere to go in a view of
the file.

Tests: tests/cache_image_memory.rs has libhdf5 (through ctypes) add an
image to a 1 GiB sparse file and bounds resident-memory growth for all
three openers at 256 MiB; it fails on the previous commit (File::open
grew 2,148,720,640 bytes). reader.rs zero_copy_tests check that a file
without an image is read from the mapping itself and that an image goes
into a copy-on-write mapping, not a heap copy; clawhdf5-io checks that
private_copy writes never reach the reader or the file.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 11:42:43 -05:00

556 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Prefetch / read-ahead for chunked data access.
//!
//! [`PrefetchReader`] wraps any [`HDF5Read`] implementation and provides
//! prefetching capabilities. For memory-mapped readers, prefetch translates
//! to `madvise(MADV_WILLNEED)`. For file-backed readers, data is preloaded
//! into a ring buffer.
//!
//! [`SweepDetector`] tracks N-dimensional chunk access patterns and triggers
//! adaptive prefetching when a sweep pattern is detected.
use crate::HDF5Read;
use crate::sweep::{ChunkCoord, SweepDirection, detect_sweep, predict_next};
/// A ring buffer that caches prefetched chunks of data.
///
/// Holds up to `capacity` entries, each keyed by `(offset, length)`.
struct RingBuffer {
entries: Vec<Option<RingEntry>>,
capacity: usize,
next_slot: usize,
}
struct RingEntry {
offset: usize,
data: Vec<u8>,
}
impl RingBuffer {
fn new(capacity: usize) -> Self {
let mut entries = Vec::with_capacity(capacity);
entries.resize_with(capacity, || None);
Self {
entries,
capacity,
next_slot: 0,
}
}
/// Look up cached data for the given offset and length.
fn get(&self, offset: usize, len: usize) -> Option<&[u8]> {
for entry in self.entries.iter().flatten() {
if entry.offset == offset && entry.data.len() >= len {
return Some(&entry.data[..len]);
}
}
None
}
/// Insert a new entry, evicting the oldest if at capacity.
fn insert(&mut self, offset: usize, data: Vec<u8>) {
self.entries[self.next_slot] = Some(RingEntry { offset, data });
self.next_slot = (self.next_slot + 1) % self.capacity;
}
/// Clear all cached entries.
fn clear(&mut self) {
for entry in &mut self.entries {
*entry = None;
}
self.next_slot = 0;
}
}
/// Default number of chunks to prefetch ahead.
pub const DEFAULT_PREFETCH_CHUNKS: usize = 4;
/// Wraps any [`HDF5Read`] implementation with prefetch capabilities.
///
/// When reading chunks sequentially, the `PrefetchReader` will prefetch
/// the next N chunks' data into a ring buffer. This is beneficial for
/// sequential access patterns on chunked datasets.
///
/// For memory-mapped readers, the prefetch hint is issued via the
/// underlying OS page cache. For file-backed readers, data is eagerly
/// loaded into a ring buffer for quick subsequent access.
pub struct PrefetchReader<R: HDF5Read> {
inner: R,
buffer: RingBuffer,
chunk_size: usize,
prefetch_count: usize,
last_offset: Option<usize>,
}
impl<R: HDF5Read> PrefetchReader<R> {
/// Create a new `PrefetchReader` wrapping the given reader.
///
/// - `chunk_size`: the size of each chunk in bytes.
/// - `prefetch_count`: how many chunks ahead to prefetch (default: 4).
pub fn new(inner: R, chunk_size: usize, prefetch_count: usize) -> Self {
Self {
inner,
buffer: RingBuffer::new(prefetch_count),
chunk_size,
prefetch_count,
last_offset: None,
}
}
/// Create with default prefetch count (4 chunks).
pub fn with_defaults(inner: R, chunk_size: usize) -> Self {
Self::new(inner, chunk_size, DEFAULT_PREFETCH_CHUNKS)
}
/// Read a chunk at the given offset.
///
/// If the data is in the ring buffer, returns it directly.
/// Otherwise reads from the inner reader and prefetches ahead.
pub fn read_chunk(&mut self, offset: usize) -> Option<Vec<u8>> {
let bytes = self.inner.as_bytes();
let len = self.chunk_size.min(bytes.len().saturating_sub(offset));
if len == 0 {
return None;
}
// Check ring buffer first
if let Some(cached) = self.buffer.get(offset, len) {
self.last_offset = Some(offset);
return Some(cached.to_vec());
}
// Read from inner
let data = bytes.get(offset..offset + len)?.to_vec();
// Detect sequential access and prefetch
let is_sequential = self
.last_offset
.is_some_and(|prev| offset == prev + self.chunk_size);
if is_sequential {
self.prefetch_ahead(offset);
}
self.last_offset = Some(offset);
Some(data)
}
/// Prefetch the next N chunks into the ring buffer.
fn prefetch_ahead(&mut self, current_offset: usize) {
let bytes = self.inner.as_bytes();
self.buffer.clear();
for i in 1..=self.prefetch_count {
let next_offset = current_offset + i * self.chunk_size;
let remaining = bytes.len().saturating_sub(next_offset);
let len = self.chunk_size.min(remaining);
if len == 0 {
break;
}
if let Some(slice) = bytes.get(next_offset..next_offset + len) {
self.buffer.insert(next_offset, slice.to_vec());
}
}
}
/// Preload predicted chunk byte ranges into the ring buffer.
///
/// Called by the sweep detector when it has predicted upcoming chunk
/// file offsets. Each `(offset, size)` pair is loaded from the inner
/// reader into the ring buffer for fast subsequent access.
pub fn preload_ranges(&mut self, ranges: &[(usize, usize)]) {
let bytes = self.inner.as_bytes();
for &(offset, size) in ranges {
let len = size.min(bytes.len().saturating_sub(offset));
if len == 0 {
continue;
}
if let Some(slice) = bytes.get(offset..offset + len) {
self.buffer.insert(offset, slice.to_vec());
}
}
}
/// Access the underlying reader.
pub fn inner(&self) -> &R {
&self.inner
}
/// Consume the prefetch reader and return the inner reader.
pub fn into_inner(self) -> R {
self.inner
}
/// Returns the configured chunk size.
pub fn chunk_size(&self) -> usize {
self.chunk_size
}
/// Returns the prefetch count.
pub fn prefetch_count(&self) -> usize {
self.prefetch_count
}
}
impl<R: HDF5Read> HDF5Read for PrefetchReader<R> {
fn as_bytes(&self) -> &[u8] {
self.inner.as_bytes()
}
fn private_copy(&self) -> std::io::Result<crate::PrivateCopy> {
self.inner.private_copy()
}
}
// ---------------------------------------------------------------------------
// SweepDetector — N-dimensional sweep pattern tracking
// ---------------------------------------------------------------------------
/// Default sliding window size for sweep detection.
pub const DEFAULT_WINDOW_SIZE: usize = 12;
/// Default number of chunks to prefetch when a sweep is detected.
pub const DEFAULT_SWEEP_PREFETCH_COUNT: usize = 4;
/// Tracks chunk access patterns and detects N-dimensional sweep directions.
///
/// Maintains a sliding window of the last N chunk coordinates. When a
/// consistent sweep pattern is detected across the window, it predicts
/// the next chunks and can trigger prefetch via the ring buffer or
/// `madvise(MADV_WILLNEED)` for memory-mapped readers.
pub struct SweepDetector {
/// Sliding window of recent chunk coordinates.
history: Vec<ChunkCoord>,
/// Maximum history window size.
window_size: usize,
/// Number of dimensions in the dataset.
ndims: usize,
/// Currently detected sweep direction (cached).
current_direction: SweepDirection,
/// How many chunks ahead to prefetch on sweep detection.
prefetch_count: usize,
}
impl SweepDetector {
/// Create a new sweep detector.
///
/// - `ndims`: number of dimensions in the chunked dataset.
/// - `window_size`: sliding window size (8–16 recommended).
/// - `prefetch_count`: how many chunks to predict ahead (2–4 recommended).
pub fn new(ndims: usize, window_size: usize, prefetch_count: usize) -> Self {
Self {
history: Vec::with_capacity(window_size),
window_size,
ndims,
current_direction: SweepDirection::Random,
prefetch_count,
}
}
/// Create with default settings (window=12, prefetch=4).
pub fn with_defaults(ndims: usize) -> Self {
Self::new(ndims, DEFAULT_WINDOW_SIZE, DEFAULT_SWEEP_PREFETCH_COUNT)
}
/// Record a chunk coordinate access and update the detected direction.
///
/// Returns the predicted next chunk coordinates if a sweep pattern
/// is detected, or an empty `Vec` if the pattern is random.
pub fn record_access(&mut self, coord: ChunkCoord) -> Vec<ChunkCoord> {
// Add to sliding window
if self.history.len() >= self.window_size {
self.history.remove(0);
}
self.history.push(coord);
// Re-detect sweep direction
self.current_direction = detect_sweep(&self.history, self.ndims);
// Predict next chunks if pattern detected
if self.current_direction != SweepDirection::Random {
predict_next(&self.history, self.current_direction, self.prefetch_count)
} else {
Vec::new()
}
}
/// Returns the currently detected sweep direction.
pub fn direction(&self) -> SweepDirection {
self.current_direction
}
/// Returns the current access history.
pub fn history(&self) -> &[ChunkCoord] {
&self.history
}
/// Reset the detector state.
pub fn reset(&mut self) {
self.history.clear();
self.current_direction = SweepDirection::Random;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MemoryReader;
fn make_test_data(num_chunks: usize, chunk_size: usize) -> Vec<u8> {
let mut data = Vec::with_capacity(num_chunks * chunk_size);
for chunk_idx in 0..num_chunks {
for byte_idx in 0..chunk_size {
data.push(((chunk_idx * chunk_size + byte_idx) % 256) as u8);
}
}
data
}
#[test]
fn prefetch_reader_basic_read() {
let data = make_test_data(10, 100);
let reader = MemoryReader::new(data.clone());
let mut prefetch = PrefetchReader::with_defaults(reader, 100);
let chunk = prefetch.read_chunk(0).unwrap();
assert_eq!(chunk, &data[0..100]);
}
#[test]
fn prefetch_reader_sequential_chunks() {
let data = make_test_data(10, 100);
let reader = MemoryReader::new(data.clone());
let mut prefetch = PrefetchReader::with_defaults(reader, 100);
// Read chunks sequentially
for i in 0..10 {
let offset = i * 100;
let chunk = prefetch.read_chunk(offset).unwrap();
assert_eq!(chunk, &data[offset..offset + 100]);
}
}
#[test]
fn prefetch_reader_uses_cache() {
let data = make_test_data(10, 100);
let reader = MemoryReader::new(data.clone());
let mut prefetch = PrefetchReader::new(reader, 100, 4);
// Read first two chunks to trigger sequential detection
prefetch.read_chunk(0).unwrap();
prefetch.read_chunk(100).unwrap();
// Now chunks 200..600 should be in the ring buffer
// Reading chunk at 200 should hit the cache
let chunk = prefetch.read_chunk(200).unwrap();
assert_eq!(chunk, &data[200..300]);
}
#[test]
fn prefetch_reader_out_of_bounds() {
let data = make_test_data(2, 100);
let reader = MemoryReader::new(data);
let mut prefetch = PrefetchReader::with_defaults(reader, 100);
// Past the end
let result = prefetch.read_chunk(300);
assert!(result.is_none());
}
#[test]
fn prefetch_reader_partial_last_chunk() {
// 250 bytes = 2 full chunks of 100 + 50 remaining
let data = vec![42u8; 250];
let reader = MemoryReader::new(data);
let mut prefetch = PrefetchReader::with_defaults(reader, 100);
let chunk = prefetch.read_chunk(200).unwrap();
assert_eq!(chunk.len(), 50);
assert_eq!(chunk, vec![42u8; 50]);
}
#[test]
fn prefetch_reader_hdf5_read_trait() {
let data = vec![1, 2, 3, 4, 5];
let reader = MemoryReader::new(data.clone());
let prefetch = PrefetchReader::with_defaults(reader, 2);
assert_eq!(prefetch.as_bytes(), &data[..]);
}
#[test]
fn prefetch_reader_inner_access() {
let data = vec![10, 20, 30];
let reader = MemoryReader::new(data.clone());
let prefetch = PrefetchReader::with_defaults(reader, 1);
assert_eq!(prefetch.inner().as_bytes(), &data[..]);
assert_eq!(prefetch.chunk_size(), 1);
assert_eq!(prefetch.prefetch_count(), DEFAULT_PREFETCH_CHUNKS);
}
#[test]
fn prefetch_reader_into_inner() {
let data = vec![5, 6, 7];
let reader = MemoryReader::new(data.clone());
let prefetch = PrefetchReader::with_defaults(reader, 1);
let inner = prefetch.into_inner();
assert_eq!(inner.as_bytes(), &data[..]);
}
#[test]
fn ring_buffer_eviction() {
let mut rb = RingBuffer::new(2);
rb.insert(0, vec![1, 2, 3]);
rb.insert(100, vec![4, 5, 6]);
// Both should be present
assert!(rb.get(0, 3).is_some());
assert!(rb.get(100, 3).is_some());
// Insert a third — evicts the first
rb.insert(200, vec![7, 8, 9]);
assert!(rb.get(0, 3).is_none());
assert!(rb.get(100, 3).is_some());
assert!(rb.get(200, 3).is_some());
}
// --- SweepDetector tests ---
#[test]
fn sweep_detector_detects_row_major() {
let mut detector = SweepDetector::with_defaults(2);
// Feed a row-major pattern (dim 1 changes)
detector.record_access(vec![0, 0]);
detector.record_access(vec![0, 10]);
let predictions = detector.record_access(vec![0, 20]);
assert_eq!(detector.direction(), SweepDirection::RowMajor);
assert!(!predictions.is_empty());
assert_eq!(predictions[0], vec![0, 30]);
}
#[test]
fn sweep_detector_detects_column_major() {
let mut detector = SweepDetector::with_defaults(2);
detector.record_access(vec![0, 0]);
detector.record_access(vec![10, 0]);
let predictions = detector.record_access(vec![20, 0]);
assert_eq!(detector.direction(), SweepDirection::ColumnMajor);
assert!(!predictions.is_empty());
assert_eq!(predictions[0], vec![30, 0]);
}
#[test]
fn sweep_detector_random_gives_no_predictions() {
let mut detector = SweepDetector::with_defaults(2);
detector.record_access(vec![0, 0]);
detector.record_access(vec![30, 20]);
let predictions = detector.record_access(vec![10, 0]);
assert_eq!(detector.direction(), SweepDirection::Random);
assert!(predictions.is_empty());
}
#[test]
fn sweep_detector_reset() {
let mut detector = SweepDetector::with_defaults(2);
detector.record_access(vec![0, 0]);
detector.record_access(vec![0, 10]);
detector.record_access(vec![0, 20]);
assert_eq!(detector.direction(), SweepDirection::RowMajor);
detector.reset();
assert_eq!(detector.direction(), SweepDirection::Random);
assert!(detector.history().is_empty());
}
#[test]
fn sweep_detector_sliding_window() {
let mut detector = SweepDetector::new(2, 4, 2);
// Fill window
detector.record_access(vec![0, 0]);
detector.record_access(vec![0, 10]);
detector.record_access(vec![0, 20]);
detector.record_access(vec![0, 30]);
assert_eq!(detector.history().len(), 4);
// Adding one more should evict oldest
detector.record_access(vec![0, 40]);
assert_eq!(detector.history().len(), 4);
assert_eq!(detector.history()[0], vec![0, 10]);
}
#[test]
fn sweep_detector_3d_slice_major() {
let mut detector = SweepDetector::with_defaults(3);
detector.record_access(vec![0, 0, 0]);
detector.record_access(vec![0, 4, 0]);
let predictions = detector.record_access(vec![0, 8, 0]);
assert_eq!(detector.direction(), SweepDirection::SliceMajor(1));
assert!(!predictions.is_empty());
assert_eq!(predictions[0], vec![0, 12, 0]);
}
#[test]
fn preload_ranges_into_ring_buffer() {
let data = make_test_data(10, 100);
let reader = MemoryReader::new(data.clone());
let mut prefetch = PrefetchReader::new(reader, 100, 8);
// Preload specific ranges
prefetch.preload_ranges(&[(200, 100), (500, 100)]);
// These should now be in the ring buffer
let chunk = prefetch.read_chunk(200).unwrap();
assert_eq!(chunk, &data[200..300]);
}
#[test]
fn sweep_prefetch_reduces_misses() {
// Simulate a row-major sweep on a 2D chunked dataset
// and verify that predicted chunks are correct
let mut detector = SweepDetector::new(2, 8, 3);
// Build up pattern
let mut all_predictions = Vec::new();
for i in 0..6 {
let coord = vec![0, i * 10];
let preds = detector.record_access(coord);
all_predictions.push(preds);
}
// After a few accesses, should be predicting correctly
assert_eq!(detector.direction(), SweepDirection::RowMajor);
let last_preds = all_predictions.last().unwrap();
assert!(!last_preds.is_empty());
// Should predict the next chunks along dim 1
assert_eq!(last_preds[0], vec![0, 60]);
assert_eq!(last_preds[1], vec![0, 70]);
assert_eq!(last_preds[2], vec![0, 80]);
}
#[test]
fn random_access_no_false_sweep() {
let mut detector = SweepDetector::with_defaults(3);
// Random-ish access pattern
let coords = vec![
vec![0, 0, 0],
vec![12, 8, 4],
vec![4, 0, 12],
vec![0, 12, 0],
vec![8, 4, 8],
vec![12, 0, 4],
vec![4, 8, 12],
vec![0, 4, 0],
];
for coord in coords {
let preds = detector.record_access(coord);
// Should never get predictions for random access
assert!(
preds.is_empty(),
"false sweep detected: direction={:?}",
detector.direction()
);
}
assert_eq!(detector.direction(), SweepDirection::Random);
}
}