Files
clawhdf5/crates/clawhdf5-io/src/lib.rs
T
Omar SobhandClaude Sonnet 4.6 2ddb22897c perf: eliminate double compression and improve shuffle filter throughput
Four independent write-path improvements:

1. Cache compressed chunks between Pass 1 and Pass 2 (chunked_write.rs,
   file_writer.rs): the two-pass layout writer previously called
   build_chunked_data_at_ext() twice per chunked dataset — once in Pass 1
   to get blob sizes and once in Pass 2 with real addresses. Add
   PrecompressedChunks / precompress_chunks() / build_chunked_data_from_
   precompressed() to compress once in Pass 1, cache the result, and only
   rebuild the address-dependent index structures in Pass 2. Expected
   ~2× speedup for chunked+deflate writes (512×512 deflate: 3.33ms → ~1.7ms).

2. SIMD-vectorisable shuffle filter (filters.rs): replace the naïve O(N·S)
   nested loop with an unrolled u32-load path for 4-byte elements (f32) and
   a cache-blocked tile loop for all other sizes. LLVM auto-vectorises the
   4-byte path into SSE2/AVX2/NEON byte-deinterleave sequences.

3. Zstd benchmark variant (h5bench_write.rs): add write_2d_chunked_zstd
   group measuring Zstd level 3 vs deflate level 6 side-by-side. Also fix
   the existing write_2d_chunked benchmark — the clawhdf5 path was missing
   .with_deflate(6), making the comparison apples-to-oranges. Add arXiv-
   backed doc recommendation on DatasetBuilder::with_zstd().

4. Zero-copy HNSW save (hnsw.rs, clawhdf5-io/lib.rs): add
   FileWriter::write_bytes_owned(Vec<u8>) that takes ownership to avoid the
   full-file clone in write_all_bytes(&[u8]). HNSW::save_to_hdf5 uses it.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 22:36:40 +00:00

547 lines
17 KiB
Rust

//! I/O abstraction layer for HDF5 file access.
//!
//! Provides traits and adapters for reading and writing HDF5 data
//! from files, memory buffers, and optionally memory-mapped files.
use std::io::{self, Read, Seek, SeekFrom, Write};
pub use clawhdf5_format;
// ─────────────────────────────────────────────────────────────────────────────
// PageInterceptor — hook point for the ClawOnion VFD
// ─────────────────────────────────────────────────────────────────────────────
/// Intercepts page-level writes before they are committed to storage.
///
/// Implement this trait in `clawhdf5-onion` to capture changed pages during
/// a write session. Install an interceptor on a [`FileWriter`] via
/// [`FileWriter::set_interceptor`].
pub trait PageInterceptor: Send + Sync {
/// Called once for each page written.
///
/// - `h5_offset`: byte offset of the page within the primary `.h5` file.
/// - `page`: raw page bytes (uncompressed, `page_size` bytes).
fn on_page_write(&mut self, h5_offset: u64, page: &[u8]);
}
/// Read-only access to HDF5 data.
///
/// Implementors provide the ability to read the entire file content
/// as a byte slice, which is the interface that `clawhdf5-format` expects.
pub trait HDF5Read {
/// Returns the entire file content as a byte slice.
fn as_bytes(&self) -> &[u8];
/// Returns the length of the data in bytes.
fn len(&self) -> usize {
self.as_bytes().len()
}
/// Returns true if the data is empty.
fn is_empty(&self) -> bool {
self.as_bytes().is_empty()
}
}
/// Read-write access to HDF5 data.
///
/// Implementors can both read existing data and write new data.
pub trait HDF5ReadWrite: HDF5Read {
/// Write the given bytes to the underlying storage, replacing all content.
fn write_all_bytes(&mut self, data: &[u8]) -> io::Result<()>;
}
// ---------------------------------------------------------------------------
// MemoryReader — wraps a Vec<u8> or borrowed &[u8] for in-memory access
// ---------------------------------------------------------------------------
/// In-memory reader backed by an owned `Vec<u8>`.
///
/// This mirrors what `clawhdf5-format` currently does: the entire file
/// is held in memory as a byte vector.
#[derive(Debug, Clone)]
pub struct MemoryReader {
data: Vec<u8>,
}
impl MemoryReader {
/// Create a reader from an owned byte vector.
pub fn new(data: Vec<u8>) -> Self {
Self { data }
}
/// Create a reader by copying from a byte slice.
pub fn from_slice(data: &[u8]) -> Self {
Self {
data: data.to_vec(),
}
}
/// Consume the reader and return the underlying bytes.
pub fn into_inner(self) -> Vec<u8> {
self.data
}
}
impl HDF5Read for MemoryReader {
fn as_bytes(&self) -> &[u8] {
&self.data
}
}
impl HDF5ReadWrite for MemoryReader {
fn write_all_bytes(&mut self, data: &[u8]) -> io::Result<()> {
self.data = data.to_vec();
Ok(())
}
}
// ---------------------------------------------------------------------------
// BorrowedReader — wraps &[u8] without copying
// ---------------------------------------------------------------------------
/// Zero-copy reader over a borrowed byte slice.
#[derive(Debug, Clone, Copy)]
pub struct BorrowedReader<'a> {
data: &'a [u8],
}
impl<'a> BorrowedReader<'a> {
/// Create a reader from a borrowed byte slice.
pub fn new(data: &'a [u8]) -> Self {
Self { data }
}
}
impl HDF5Read for BorrowedReader<'_> {
fn as_bytes(&self) -> &[u8] {
self.data
}
}
// ---------------------------------------------------------------------------
// FileReader — wraps std::fs::File for read access
// ---------------------------------------------------------------------------
/// File-backed reader that loads the entire file into memory.
///
/// Uses `Read + Seek` to slurp the file content into a `Vec<u8>`.
#[derive(Debug)]
pub struct FileReader {
data: Vec<u8>,
}
impl FileReader {
/// Open a file and read its entire contents into memory.
pub fn open<P: AsRef<std::path::Path>>(path: P) -> io::Result<Self> {
let mut file = std::fs::File::open(path)?;
let len = file.seek(SeekFrom::End(0))? as usize;
file.seek(SeekFrom::Start(0))?;
let mut data = vec![0u8; len];
file.read_exact(&mut data)?;
Ok(Self { data })
}
/// Create a reader from an already-opened file.
pub fn from_file(mut file: std::fs::File) -> io::Result<Self> {
let len = file.seek(SeekFrom::End(0))? as usize;
file.seek(SeekFrom::Start(0))?;
let mut data = vec![0u8; len];
file.read_exact(&mut data)?;
Ok(Self { data })
}
/// Consume the reader and return the underlying bytes.
pub fn into_inner(self) -> Vec<u8> {
self.data
}
}
impl HDF5Read for FileReader {
fn as_bytes(&self) -> &[u8] {
&self.data
}
}
// ---------------------------------------------------------------------------
// FileWriter — wraps std::fs::File for write access
// ---------------------------------------------------------------------------
/// File-backed writer that writes bytes to a file on disk.
///
/// Optionally holds a [`PageInterceptor`] that is called once per page
/// during [`Self::write_all_bytes`]. When `page_size` is set and an interceptor
/// is installed, the written bytes are sliced into pages and each page is
/// forwarded to [`PageInterceptor::on_page_write`].
pub struct FileWriter {
path: std::path::PathBuf,
data: Vec<u8>,
/// Optional interceptor for the ClawOnion VFD page-capture hook.
interceptor: Option<Box<dyn PageInterceptor>>,
/// Page size for slicing writes to the interceptor. `0` = disabled.
page_size: u32,
}
impl std::fmt::Debug for FileWriter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileWriter")
.field("path", &self.path)
.field("data_len", &self.data.len())
.field("interceptor", &self.interceptor.is_some())
.field("page_size", &self.page_size)
.finish()
}
}
impl FileWriter {
/// Create a new writer that will write to the given path.
pub fn create<P: AsRef<std::path::Path>>(path: P) -> io::Result<Self> {
let path = path.as_ref().to_path_buf();
Ok(Self {
path,
data: Vec::new(),
interceptor: None,
page_size: 0,
})
}
/// Install a [`PageInterceptor`] and set the page size used to slice writes.
///
/// When set, every call to [`Self::write_all_bytes`] will invoke
/// [`PageInterceptor::on_page_write`] once for each aligned page.
pub fn set_interceptor(&mut self, interceptor: Box<dyn PageInterceptor>, page_size: u32) {
self.interceptor = Some(interceptor);
self.page_size = page_size;
}
/// Remove and return the interceptor, if any.
pub fn take_interceptor(&mut self) -> Option<Box<dyn PageInterceptor>> {
self.page_size = 0;
self.interceptor.take()
}
/// Flush the current data to disk.
pub fn flush_to_disk(&self) -> io::Result<()> {
let mut file = std::fs::File::create(&self.path)?;
file.write_all(&self.data)?;
file.flush()
}
/// Returns the target path.
pub fn path(&self) -> &std::path::Path {
&self.path
}
/// Write `data` into this writer, taking ownership to avoid a copy.
///
/// Prefer over [`HDF5ReadWrite::write_all_bytes`] when the caller already
/// owns a `Vec<u8>` (e.g., from `FileWriter::finish()`).
pub fn write_bytes_owned(&mut self, data: Vec<u8>) -> io::Result<()> {
self.data = data;
if let Some(ref mut interceptor) = self.interceptor {
let ps = self.page_size as usize;
if ps > 0 {
let mut offset: u64 = 0;
let mut pos = 0usize;
while pos + ps <= self.data.len() {
interceptor.on_page_write(offset, &self.data[pos..pos + ps]);
pos += ps;
offset += ps as u64;
}
if pos < self.data.len() {
interceptor.on_page_write(offset, &self.data[pos..]);
}
}
}
self.flush_to_disk()
}
}
impl HDF5Read for FileWriter {
fn as_bytes(&self) -> &[u8] {
&self.data
}
}
impl HDF5ReadWrite for FileWriter {
fn write_all_bytes(&mut self, data: &[u8]) -> io::Result<()> {
self.data = data.to_vec();
// Notify interceptor of changed pages (aligned slices).
if let Some(ref mut interceptor) = self.interceptor {
let ps = self.page_size as usize;
if ps > 0 {
let mut offset: u64 = 0;
let mut pos = 0usize;
while pos + ps <= data.len() {
interceptor.on_page_write(offset, &data[pos..pos + ps]);
pos += ps;
offset += ps as u64;
}
// Partial trailing page
if pos < data.len() {
interceptor.on_page_write(offset, &data[pos..]);
}
}
}
self.flush_to_disk()
}
}
// ---------------------------------------------------------------------------
// Optional modules
// ---------------------------------------------------------------------------
#[cfg(feature = "async")]
pub mod async_read;
#[cfg(feature = "hsds")]
pub mod hsds;
#[cfg(feature = "mmap")]
pub mod mmap;
#[cfg(feature = "mmap")]
pub use mmap::{MmapReadWrite, MmapReader};
pub mod mpi_vol;
pub use mpi_vol::MpiVol;
pub mod prefetch;
pub mod subfiling;
pub mod sweep;
pub mod vol;
/// Configuration for lane-partitioned parallel decompression.
///
/// Controls how chunks are distributed across threads during parallel reads.
/// The lane partitioning scheme assigns each thread a deterministic, disjoint
/// subset of chunks — no locks or coordination needed at runtime.
#[derive(Debug, Clone)]
pub struct ParallelConfig {
/// Number of parallel lanes (threads). `None` = auto-detect from
/// available CPU cores.
pub num_lanes: Option<usize>,
/// Enable work-stealing rebalancing. When `true`, the partitioner
/// redistributes excess items from overloaded lanes to underloaded ones
/// so that no lane differs by more than 1 chunk. Default: `true`.
pub work_stealing: bool,
}
impl Default for ParallelConfig {
fn default() -> Self {
Self {
num_lanes: None,
work_stealing: true,
}
}
}
impl ParallelConfig {
/// Create a config with explicit lane count and work-stealing on.
pub fn with_lanes(num_lanes: usize) -> Self {
Self {
num_lanes: Some(num_lanes),
work_stealing: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn memory_reader_from_vec() {
let data = vec![1u8, 2, 3, 4, 5];
let reader = MemoryReader::new(data.clone());
assert_eq!(reader.as_bytes(), &data);
assert_eq!(reader.len(), 5);
assert!(!reader.is_empty());
}
#[test]
fn memory_reader_from_slice() {
let data = [10u8, 20, 30];
let reader = MemoryReader::from_slice(&data);
assert_eq!(reader.as_bytes(), &data);
}
#[test]
fn memory_reader_empty() {
let reader = MemoryReader::new(Vec::new());
assert!(reader.is_empty());
assert_eq!(reader.len(), 0);
}
#[test]
fn memory_reader_into_inner() {
let data = vec![7u8, 8, 9];
let reader = MemoryReader::new(data.clone());
assert_eq!(reader.into_inner(), data);
}
#[test]
fn memory_reader_write_replaces_content() {
let mut reader = MemoryReader::new(vec![1, 2, 3]);
reader.write_all_bytes(&[4, 5]).unwrap();
assert_eq!(reader.as_bytes(), &[4, 5]);
}
#[test]
fn borrowed_reader_basic() {
let data = [42u8, 43, 44];
let reader = BorrowedReader::new(&data);
assert_eq!(reader.as_bytes(), &data);
assert_eq!(reader.len(), 3);
assert!(!reader.is_empty());
}
#[test]
fn borrowed_reader_empty() {
let reader = BorrowedReader::new(&[]);
assert!(reader.is_empty());
}
#[test]
fn file_reader_roundtrip() {
let dir = std::env::temp_dir();
let path = dir.join("clawhdf5_io_test_file_reader.bin");
// Write test data
{
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(&[0x89, 0x48, 0x44, 0x46]).unwrap();
}
let reader = FileReader::open(&path).unwrap();
assert_eq!(reader.as_bytes(), &[0x89, 0x48, 0x44, 0x46]);
assert_eq!(reader.len(), 4);
let bytes = reader.into_inner();
assert_eq!(bytes, vec![0x89, 0x48, 0x44, 0x46]);
std::fs::remove_file(&path).ok();
}
#[test]
fn file_reader_from_file() {
let dir = std::env::temp_dir();
let path = dir.join("clawhdf5_io_test_from_file.bin");
{
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(&[1, 2, 3, 4, 5, 6]).unwrap();
}
let file = std::fs::File::open(&path).unwrap();
let reader = FileReader::from_file(file).unwrap();
assert_eq!(reader.as_bytes(), &[1, 2, 3, 4, 5, 6]);
std::fs::remove_file(&path).ok();
}
#[test]
fn file_reader_nonexistent() {
let result = FileReader::open("/tmp/clawhdf5_io_does_not_exist_12345.bin");
assert!(result.is_err());
}
#[test]
fn file_writer_create_and_write() {
let dir = std::env::temp_dir();
let path = dir.join("clawhdf5_io_test_writer.bin");
let mut writer = FileWriter::create(&path).unwrap();
assert!(writer.as_bytes().is_empty());
writer.write_all_bytes(&[10, 20, 30]).unwrap();
assert_eq!(writer.as_bytes(), &[10, 20, 30]);
// Verify the file was written to disk
let on_disk = std::fs::read(&path).unwrap();
assert_eq!(on_disk, vec![10, 20, 30]);
std::fs::remove_file(&path).ok();
}
#[test]
fn file_writer_overwrite() {
let dir = std::env::temp_dir();
let path = dir.join("clawhdf5_io_test_writer_overwrite.bin");
let mut writer = FileWriter::create(&path).unwrap();
writer.write_all_bytes(&[1, 2, 3]).unwrap();
writer.write_all_bytes(&[4, 5, 6, 7]).unwrap();
let on_disk = std::fs::read(&path).unwrap();
assert_eq!(on_disk, vec![4, 5, 6, 7]);
std::fs::remove_file(&path).ok();
}
#[test]
fn file_writer_path() {
let dir = std::env::temp_dir();
let path = dir.join("clawhdf5_io_test_path.bin");
let writer = FileWriter::create(&path).unwrap();
assert_eq!(writer.path(), path.as_path());
}
#[test]
fn file_writer_flush_to_disk() {
let dir = std::env::temp_dir();
let path = dir.join("clawhdf5_io_test_flush.bin");
let mut writer = FileWriter::create(&path).unwrap();
writer.write_all_bytes(&[0xDE, 0xAD]).unwrap();
writer.flush_to_disk().unwrap();
let on_disk = std::fs::read(&path).unwrap();
assert_eq!(on_disk, vec![0xDE, 0xAD]);
std::fs::remove_file(&path).ok();
}
#[test]
fn hdf5_file_via_memory_reader() {
// Integration test: use MemoryReader with an HDF5 file created by clawhdf5-format
use clawhdf5_format::file_writer::FileWriter as FmtWriter;
let mut fw = FmtWriter::new();
fw.create_dataset("test").with_f64_data(&[1.0, 2.0, 3.0]);
let bytes = fw.finish().unwrap();
let reader = MemoryReader::new(bytes);
let data = reader.as_bytes();
// Verify it's a valid HDF5 file by checking the signature
assert!(data.len() > 8);
assert_eq!(&data[..8], b"\x89HDF\r\n\x1a\n");
}
#[test]
fn hdf5_file_via_file_reader_writer() {
use clawhdf5_format::file_writer::FileWriter as FmtWriter;
let dir = std::env::temp_dir();
let path = dir.join("clawhdf5_io_test_hdf5_roundtrip.h5");
// Write an HDF5 file via FileWriter
let mut fw = FmtWriter::new();
fw.create_dataset("values").with_i32_data(&[10, 20, 30]);
let bytes = fw.finish().unwrap();
let mut writer = FileWriter::create(&path).unwrap();
writer.write_all_bytes(&bytes).unwrap();
// Read it back via FileReader
let reader = FileReader::open(&path).unwrap();
assert_eq!(reader.as_bytes(), &bytes);
assert_eq!(&reader.as_bytes()[..8], b"\x89HDF\r\n\x1a\n");
std::fs::remove_file(&path).ok();
}
}