Skip to main content

hdf5_pure/
chunk_cache.rs

1//! Chunk cache with hash-based index and LRU eviction.
2//!
3//! The [`ChunkCache`] avoids re-traversing B-trees on repeated reads of chunked
4//! datasets.  On first access it scans the B-tree once and builds a
5//! `HashMap<ChunkCoord, ChunkInfo>` (the *chunk index*).  Decompressed chunk
6//! data is cached with LRU eviction controlled by a byte-budget.
7
8#[cfg(not(feature = "std"))]
9extern crate alloc;
10
11#[cfg(not(feature = "std"))]
12use alloc::vec::Vec;
13
14#[cfg(not(feature = "std"))]
15use crate::nosync::Mutex;
16#[cfg(feature = "std")]
17use std::sync::Mutex;
18
19#[cfg(not(feature = "std"))]
20use alloc::collections::BTreeMap;
21#[cfg(feature = "std")]
22use std::collections::HashMap;
23
24use crate::chunked_read::ChunkInfo;
25
26// ---------------------------------------------------------------------------
27// Cache-line alignment
28// ---------------------------------------------------------------------------
29
30/// Cache line size in bytes for the target architecture.
31///
32/// ARM64 uses 128-byte cache lines; x86_64 uses 64-byte. The writer aligns
33/// chunk data blocks to this boundary on disk so a chunk read lands on a
34/// cache-line-aligned file offset.
35#[cfg(target_arch = "aarch64")]
36pub const CACHE_LINE_SIZE: usize = 128;
37
38#[cfg(target_arch = "x86_64")]
39pub const CACHE_LINE_SIZE: usize = 64;
40
41#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
42pub const CACHE_LINE_SIZE: usize = 64;
43
44/// Round `size` up to the next multiple of [`CACHE_LINE_SIZE`].
45#[inline]
46pub fn align_to_cache_line(size: usize) -> usize {
47    (size + CACHE_LINE_SIZE - 1) & !(CACHE_LINE_SIZE - 1)
48}
49
50/// Coordinate key for a chunk — the N-dimensional offset vector.
51pub type ChunkCoord = Vec<u64>;
52
53/// Default maximum bytes of decompressed chunk data to cache.
54pub const DEFAULT_CACHE_BYTES: usize = 1024 * 1024; // 1 MiB
55
56/// Default maximum number of cached decompressed chunks.
57pub const DEFAULT_MAX_SLOTS: usize = 16;
58
59/// Configuration for a per-dataset chunk cache.
60///
61/// The byte and slot limits are the `hdf5-pure` counterpart of the
62/// `rdcc_nbytes` and `rdcc_nslots` raw-data chunk-cache settings from HDF5's
63/// `H5Pset_cache`. They apply to decompressed raw chunk data. The optional
64/// chunk-index cache controls whether `hdf5-pure` retains the parsed chunk
65/// address index between reads of the same [`crate::Dataset`]. Disabling the
66/// index cache lowers retained metadata memory at the cost of re-scanning the
67/// on-disk chunk index for repeated reads.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct ChunkCacheConfig {
70    max_bytes: usize,
71    max_slots: usize,
72    cache_index: bool,
73}
74
75impl ChunkCacheConfig {
76    /// Create a config matching the historical defaults: 1 MiB of decompressed
77    /// chunks, 16 slots, and retained parsed chunk indexes.
78    pub const fn new() -> Self {
79        Self {
80            max_bytes: DEFAULT_CACHE_BYTES,
81            max_slots: DEFAULT_MAX_SLOTS,
82            cache_index: true,
83        }
84    }
85
86    /// Create a config from HDF5 `H5Pset_cache` raw data chunk-cache values.
87    ///
88    /// `rdcc_nslots` maps to the maximum retained chunk slots and
89    /// `rdcc_nbytes` maps to the maximum retained decompressed chunk bytes.
90    /// Modern HDF5 ignores `H5Pset_cache`'s `mdc_nelmts`; use
91    /// [`crate::MetadataCacheConfig`] for the metadata-cache budget. The
92    /// `rdcc_w0` preemption policy has no direct equivalent because this
93    /// read-only cache uses strict LRU eviction.
94    pub const fn from_h5p_cache(rdcc_nslots: usize, rdcc_nbytes: usize) -> Self {
95        Self {
96            max_bytes: rdcc_nbytes,
97            max_slots: rdcc_nslots,
98            cache_index: true,
99        }
100    }
101
102    /// Disable retained decompressed chunks and parsed chunk indexes.
103    pub const fn disabled() -> Self {
104        Self {
105            max_bytes: 0,
106            max_slots: 0,
107            cache_index: false,
108        }
109    }
110
111    /// Set the maximum decompressed chunk bytes retained per dataset.
112    pub const fn with_max_bytes(mut self, max_bytes: usize) -> Self {
113        self.max_bytes = max_bytes;
114        self
115    }
116
117    /// Set the maximum number of decompressed chunk slots retained per dataset.
118    pub const fn with_max_slots(mut self, max_slots: usize) -> Self {
119        self.max_slots = max_slots;
120        self
121    }
122
123    /// Enable or disable retaining the parsed chunk index between reads.
124    pub const fn with_index_cache(mut self, enabled: bool) -> Self {
125        self.cache_index = enabled;
126        self
127    }
128
129    /// Return the maximum decompressed chunk bytes retained per dataset.
130    pub const fn max_bytes(&self) -> usize {
131        self.max_bytes
132    }
133
134    /// Return the maximum decompressed chunk slots retained per dataset.
135    pub const fn max_slots(&self) -> usize {
136        self.max_slots
137    }
138
139    /// Return whether parsed chunk indexes are retained between reads.
140    pub const fn index_cache_enabled(&self) -> bool {
141        self.cache_index
142    }
143}
144
145impl Default for ChunkCacheConfig {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151/// A read-only snapshot of a dataset's chunk-cache occupancy.
152///
153/// Returned by [`crate::Dataset::chunk_cache_stats`]. Use it to confirm a
154/// chunk-cache configuration is taking effect: after reading a chunked dataset,
155/// an enabled cache reports a loaded index and retained chunks, a disabled one
156/// (or one over its byte/slot budget) reports fewer or none. The counts are a
157/// point-in-time view and change as further reads populate or evict chunks.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
159pub struct ChunkCacheStats {
160    index_loaded: bool,
161    cached_chunks: usize,
162    cached_bytes: usize,
163}
164
165impl ChunkCacheStats {
166    /// Whether the parsed chunk index is currently held in memory.
167    pub const fn index_loaded(&self) -> bool {
168        self.index_loaded
169    }
170
171    /// Number of decompressed chunks currently retained.
172    pub const fn cached_chunks(&self) -> usize {
173        self.cached_chunks
174    }
175
176    /// Total bytes of decompressed chunk data currently retained.
177    pub const fn cached_bytes(&self) -> usize {
178        self.cached_bytes
179    }
180}
181
182// ---------------------------------------------------------------------------
183// LRU entry
184// ---------------------------------------------------------------------------
185
186struct CachedChunk {
187    coord: ChunkCoord,
188    data: Vec<u8>,
189    /// Monotonically increasing access counter for LRU ordering.
190    last_access: u64,
191}
192
193// ---------------------------------------------------------------------------
194// ChunkCache
195// ---------------------------------------------------------------------------
196
197/// A per-dataset chunk cache with hash-based index and LRU eviction.
198///
199/// # Usage
200///
201/// ```ignore
202/// let cache = ChunkCache::new();
203/// // Pass &cache to read_chunked_data — it will populate the index lazily.
204/// ```
205///
206/// The cache is wrapped in `Mutex` internally so it can be mutated through
207/// shared references (thread-safe).
208pub struct ChunkCache {
209    inner: Mutex<CacheInner>,
210}
211
212struct CacheInner {
213    /// Hash index: chunk coordinate → ChunkInfo (offset + size in file).
214    /// Populated once per dataset on first access.
215    #[cfg(feature = "std")]
216    index: Option<HashMap<ChunkCoord, ChunkInfo>>,
217    #[cfg(not(feature = "std"))]
218    index: Option<BTreeMap<ChunkCoord, ChunkInfo>>,
219
220    /// LRU cache of decompressed chunk data.
221    slots: Vec<CachedChunk>,
222
223    /// Current total bytes of cached decompressed data.
224    current_bytes: usize,
225
226    /// Maximum bytes of decompressed data to cache.
227    max_bytes: usize,
228
229    /// Maximum number of slots.
230    max_slots: usize,
231
232    /// Monotonic counter for LRU ordering.
233    tick: u64,
234
235    /// Whether the parsed chunk index should be retained between reads.
236    cache_index: bool,
237}
238
239impl ChunkCache {
240    /// Create a new chunk cache with default limits (1 MiB, 16 slots).
241    pub fn new() -> Self {
242        Self::with_capacity(DEFAULT_CACHE_BYTES, DEFAULT_MAX_SLOTS)
243    }
244
245    /// Create a new chunk cache with custom byte budget and slot count.
246    pub fn with_capacity(max_bytes: usize, max_slots: usize) -> Self {
247        Self::with_config(
248            ChunkCacheConfig::new()
249                .with_max_bytes(max_bytes)
250                .with_max_slots(max_slots),
251        )
252    }
253
254    /// Create a new chunk cache from a full configuration.
255    pub fn with_config(config: ChunkCacheConfig) -> Self {
256        Self {
257            inner: Mutex::new(CacheInner {
258                index: None,
259                slots: Vec::with_capacity(config.max_slots.min(64)),
260                current_bytes: 0,
261                max_bytes: config.max_bytes,
262                max_slots: config.max_slots,
263                tick: 0,
264                cache_index: config.cache_index,
265            }),
266        }
267    }
268
269    /// Snapshot the current chunk-cache occupancy (index loaded, retained
270    /// chunk count, retained bytes).
271    ///
272    /// This is the public, read-only way to observe whether a chunk-cache
273    /// configuration is taking effect. It locks the cache briefly to read a
274    /// consistent snapshot.
275    pub fn stats(&self) -> ChunkCacheStats {
276        let inner = self.inner.lock().unwrap();
277        ChunkCacheStats {
278            index_loaded: inner.index.is_some(),
279            cached_chunks: inner.slots.len(),
280            cached_bytes: inner.current_bytes,
281        }
282    }
283
284    // ----- Index operations -----
285
286    /// Build the chunk index from a pre-collected list of `ChunkInfo`.
287    ///
288    /// The `rank` parameter is used to truncate offsets to spatial dims only
289    /// (B-tree v1 stores rank+1 offsets).
290    pub fn populate_index(&self, chunks: &[ChunkInfo], rank: usize) {
291        let mut inner = self.inner.lock().unwrap();
292        if !inner.cache_index {
293            return;
294        }
295        if inner.index.is_some() {
296            return; // already populated
297        }
298        #[cfg(feature = "std")]
299        let mut map = HashMap::with_capacity(chunks.len());
300        #[cfg(not(feature = "std"))]
301        let mut map = BTreeMap::new();
302
303        for ci in chunks {
304            let coord: ChunkCoord = ci.offsets.iter().take(rank).copied().collect();
305            map.insert(coord, ci.clone());
306        }
307        inner.index = Some(map);
308    }
309
310    /// Return all indexed chunks as a `Vec<ChunkInfo>` (order unspecified).
311    pub fn all_indexed_chunks(&self) -> Option<Vec<ChunkInfo>> {
312        let inner = self.inner.lock().unwrap();
313        inner.index.as_ref().map(|m| m.values().cloned().collect())
314    }
315
316    // ----- Decompressed data cache (LRU) -----
317
318    /// Run `f` over a borrowed view of a cached chunk's decompressed bytes, if
319    /// present, returning its result.
320    ///
321    /// The closure runs while the cache lock is held, which lets the caller copy
322    /// the chunk straight into its output buffer with no intermediate `Vec`
323    /// allocation or clone. The closure must not touch this cache (it would
324    /// deadlock); the chunk-assembly scatter it is used for does not.
325    pub fn with_decompressed<R>(&self, coord: &[u64], f: impl FnOnce(&[u8]) -> R) -> Option<R> {
326        let mut inner = self.inner.lock().unwrap();
327        inner.tick += 1;
328        let tick = inner.tick;
329        for slot in inner.slots.iter_mut() {
330            if slot.coord.as_slice() == coord {
331                slot.last_access = tick;
332                return Some(f(&slot.data));
333            }
334        }
335        None
336    }
337
338    /// Whether a decompressed chunk of `data_len` bytes would be admitted to the
339    /// cache (cache enabled and the chunk within the per-chunk byte budget). Used
340    /// to skip copying a chunk into an owned buffer when it would be rejected.
341    fn accepts_decompressed_len(&self, data_len: usize) -> bool {
342        let inner = self.inner.lock().unwrap();
343        inner.max_bytes != 0 && inner.max_slots != 0 && data_len <= inner.max_bytes
344    }
345
346    /// Insert decompressed chunk data into the LRU cache, taking ownership of the
347    /// buffer (no copy). A chunk too large for the budget, or a disabled cache,
348    /// drops the buffer instead of storing it.
349    pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) {
350        let mut inner = self.inner.lock().unwrap();
351        let data_len = data.len();
352
353        // Don't cache if disabled or if a single chunk exceeds the budget.
354        if inner.max_bytes == 0 || inner.max_slots == 0 || data_len > inner.max_bytes {
355            return;
356        }
357
358        // Check if already present
359        inner.tick += 1;
360        let tick = inner.tick;
361        for slot in inner.slots.iter_mut() {
362            if slot.coord == coord {
363                slot.last_access = tick;
364                return; // already cached
365            }
366        }
367
368        // Evict until we have room
369        while inner.slots.len() >= inner.max_slots
370            || (inner.current_bytes + data_len > inner.max_bytes && !inner.slots.is_empty())
371        {
372            // Find LRU slot
373            let lru_idx = inner
374                .slots
375                .iter()
376                .enumerate()
377                .min_by_key(|(_, s)| s.last_access)
378                .map(|(i, _)| i)
379                .unwrap();
380            let removed = inner.slots.swap_remove(lru_idx);
381            inner.current_bytes -= removed.data.len();
382        }
383
384        inner.current_bytes += data_len;
385        inner.slots.push(CachedChunk {
386            coord,
387            data,
388            last_access: tick,
389        });
390    }
391
392    /// Insert a copy of `data` into the LRU cache, but only if it would actually
393    /// be admitted. This lets the unfiltered read path scatter directly from the
394    /// file buffer and copy into the cache only when caching is enabled and the
395    /// chunk fits the budget (avoiding a throwaway copy otherwise).
396    pub fn put_decompressed_slice(&self, coord: ChunkCoord, data: &[u8]) {
397        if !self.accepts_decompressed_len(data.len()) {
398            return;
399        }
400        self.put_decompressed(coord, data.to_vec());
401    }
402
403    /// Clear the entire cache (index + decompressed data).
404    ///
405    /// Currently only exercised by unit tests; gated so it is not shipped as
406    /// dead code.
407    #[cfg(test)]
408    pub fn clear(&self) {
409        let mut inner = self.inner.lock().unwrap();
410        inner.index = None;
411        inner.slots.clear();
412        inner.current_bytes = 0;
413        inner.tick = 0;
414    }
415}
416
417impl Default for ChunkCache {
418    fn default() -> Self {
419        Self::new()
420    }
421}
422
423// ---------------------------------------------------------------------------
424// Tests
425// ---------------------------------------------------------------------------
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn make_chunk(offsets: Vec<u64>, address: u64, size: u32) -> ChunkInfo {
432        ChunkInfo {
433            chunk_size: size,
434            filter_mask: 0,
435            offsets,
436            address,
437        }
438    }
439
440    #[test]
441    fn index_populate_and_lookup() {
442        let cache = ChunkCache::new();
443        let chunks = vec![
444            make_chunk(vec![0, 0, 0], 0x1000, 80),
445            make_chunk(vec![10, 0, 0], 0x2000, 80),
446        ];
447        cache.populate_index(&chunks, 2); // rank=2, truncate to [0,0] and [10,0]
448        assert!(cache.stats().index_loaded());
449
450        let mut addrs: Vec<u64> = cache
451            .all_indexed_chunks()
452            .unwrap()
453            .iter()
454            .map(|c| c.address)
455            .collect();
456        addrs.sort_unstable();
457        assert_eq!(addrs, vec![0x1000, 0x2000]);
458    }
459
460    /// Test helper: clone a cached chunk's bytes if present (the production
461    /// path uses `with_decompressed` to avoid this copy).
462    fn get_decompressed(cache: &ChunkCache, coord: &[u64]) -> Option<Vec<u8>> {
463        cache.with_decompressed(coord, <[u8]>::to_vec)
464    }
465
466    #[test]
467    fn decompressed_cache_hit() {
468        let cache = ChunkCache::new();
469        cache.put_decompressed(vec![0, 0], vec![1, 2, 3, 4]);
470        let got = get_decompressed(&cache, &[0, 0]).unwrap();
471        assert_eq!(got, vec![1, 2, 3, 4]);
472    }
473
474    #[test]
475    fn lru_eviction_by_slots() {
476        let cache = ChunkCache::with_capacity(1024 * 1024, 2); // max 2 slots
477
478        cache.put_decompressed(vec![0], vec![1; 10]);
479        cache.put_decompressed(vec![1], vec![2; 10]);
480        assert_eq!(cache.stats().cached_chunks(), 2);
481
482        // Access slot 0 to make it more recent
483        get_decompressed(&cache, &[0]);
484
485        // Insert slot 2 — should evict slot 1 (LRU)
486        cache.put_decompressed(vec![2], vec![3; 10]);
487        assert_eq!(cache.stats().cached_chunks(), 2);
488
489        assert!(get_decompressed(&cache, &[0]).is_some());
490        assert!(get_decompressed(&cache, &[1]).is_none()); // evicted
491        assert!(get_decompressed(&cache, &[2]).is_some());
492    }
493
494    #[test]
495    fn lru_eviction_by_bytes() {
496        let cache = ChunkCache::with_capacity(50, 100); // 50 bytes max
497
498        cache.put_decompressed(vec![0], vec![0; 20]);
499        cache.put_decompressed(vec![1], vec![0; 20]);
500        assert_eq!(cache.stats().cached_bytes(), 40);
501
502        // This needs 20 bytes but only 10 free — evict LRU
503        cache.put_decompressed(vec![2], vec![0; 20]);
504        assert!(cache.stats().cached_bytes() <= 50);
505        assert!(get_decompressed(&cache, &[0]).is_none()); // evicted (LRU)
506    }
507
508    #[test]
509    fn put_decompressed_slice_only_copies_when_admitted() {
510        // Disabled cache: the slice is not copied or stored.
511        let cache = ChunkCache::with_config(ChunkCacheConfig::disabled());
512        cache.put_decompressed_slice(vec![0], &[1, 2, 3]);
513        assert_eq!(cache.stats().cached_chunks(), 0);
514
515        // Enabled cache within budget: stored.
516        let cache = ChunkCache::with_capacity(1024, 16);
517        cache.put_decompressed_slice(vec![0], &[1, 2, 3, 4]);
518        assert_eq!(get_decompressed(&cache, &[0]).unwrap(), vec![1, 2, 3, 4]);
519
520        // Over the per-chunk budget: not stored.
521        let cache = ChunkCache::with_capacity(2, 16);
522        cache.put_decompressed_slice(vec![0], &[1, 2, 3, 4]);
523        assert_eq!(cache.stats().cached_chunks(), 0);
524    }
525
526    #[test]
527    fn oversized_chunk_not_cached() {
528        let cache = ChunkCache::with_capacity(10, 16);
529        cache.put_decompressed(vec![0], vec![0; 100]); // too big
530        assert_eq!(cache.stats().cached_chunks(), 0);
531    }
532
533    #[test]
534    fn disabled_cache_retains_no_index_or_chunks() {
535        let cache = ChunkCache::with_config(ChunkCacheConfig::disabled());
536        let chunks = vec![make_chunk(vec![0, 0], 0x1000, 80)];
537        cache.populate_index(&chunks, 1);
538        assert!(!cache.stats().index_loaded());
539
540        cache.put_decompressed(vec![0], vec![1, 2, 3]);
541        assert_eq!(cache.stats().cached_chunks(), 0);
542        assert_eq!(cache.stats().cached_bytes(), 0);
543    }
544
545    #[test]
546    fn h5p_cache_constructor_maps_raw_data_chunk_settings() {
547        let config = ChunkCacheConfig::from_h5p_cache(521, 2 * 1024 * 1024);
548        assert_eq!(config.max_slots(), 521);
549        assert_eq!(config.max_bytes(), 2 * 1024 * 1024);
550        assert!(config.index_cache_enabled());
551    }
552
553    #[test]
554    fn clear_resets_everything() {
555        let cache = ChunkCache::new();
556        let chunks = vec![make_chunk(vec![0, 0], 0x1000, 80)];
557        cache.populate_index(&chunks, 1);
558        cache.put_decompressed(vec![0], vec![1, 2, 3]);
559
560        cache.clear();
561        assert!(!cache.stats().index_loaded());
562        assert_eq!(cache.stats().cached_chunks(), 0);
563        assert_eq!(cache.stats().cached_bytes(), 0);
564    }
565
566    #[test]
567    fn duplicate_insert_is_noop() {
568        let cache = ChunkCache::new();
569        cache.put_decompressed(vec![0], vec![1, 2, 3]);
570        cache.put_decompressed(vec![0], vec![1, 2, 3]); // duplicate
571        assert_eq!(cache.stats().cached_chunks(), 1);
572        assert_eq!(cache.stats().cached_bytes(), 3);
573    }
574
575    #[test]
576    fn align_to_cache_line_values() {
577        assert_eq!(align_to_cache_line(0), 0);
578        assert_eq!(align_to_cache_line(1), CACHE_LINE_SIZE);
579        assert_eq!(align_to_cache_line(CACHE_LINE_SIZE), CACHE_LINE_SIZE);
580        assert_eq!(
581            align_to_cache_line(CACHE_LINE_SIZE + 1),
582            CACHE_LINE_SIZE * 2
583        );
584    }
585}